From b853293f184c1fe0deab5ff8aa50eee7cbced431 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 1 Oct 2026 16:30:37 +0800 Subject: [PATCH] fix(chat): retain inline steering drafts and retry identity Signed-off-by: huangruiteng --- .../composer-steering-recovery.ts | 36 ----------- .../personal-workspace/message-activity.tsx | 52 +++++++++++++--- .../personal-workspace-page.tsx | 17 +----- .../personal-workspace/steering-recovery.ts | 59 +++++++++++++++++++ .../capable-manager-semantic-handoff-v0.md | 12 ++++ .../rfcs/loopx-overall-roadmap-v0.md | 7 +++ .../conversation-activity.mjs | 45 +++++++++++++- 7 files changed, 166 insertions(+), 62 deletions(-) delete mode 100644 apps/presentation/dashboard/src/features/personal-workspace/composer-steering-recovery.ts create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/steering-recovery.ts diff --git a/apps/presentation/dashboard/src/features/personal-workspace/composer-steering-recovery.ts b/apps/presentation/dashboard/src/features/personal-workspace/composer-steering-recovery.ts deleted file mode 100644 index bfd75598da..0000000000 --- a/apps/presentation/dashboard/src/features/personal-workspace/composer-steering-recovery.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** Client retry identity only; the Chat ingress/store still owns delivery. */ -export type ComposerSteeringRequest = { - sessionId: string; - turnId: string; - text: string; - id: string; -}; - -const storageKey = "loopx-pw-composer-steering"; - -export function readComposerSteeringRequests(): Map { - try { - const raw = window.sessionStorage.getItem(storageKey); - const entries: unknown = raw ? JSON.parse(raw) : []; - if (!Array.isArray(entries)) return new Map(); - return new Map(entries.flatMap((entry): [string, ComposerSteeringRequest][] => { - if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") return []; - const request = entry[1]; - if (!request || typeof request !== "object" || Array.isArray(request) - || ![request.sessionId, request.turnId, request.text, request.id] - .every(value => typeof value === "string" && value.length > 0)) return []; - return [[entry[0], { sessionId: request.sessionId, turnId: request.turnId, text: request.text, id: request.id }]]; - })); - } catch { - return new Map(); - } -} - -export function persistComposerSteeringRequests(requests: ReadonlyMap) { - try { - window.sessionStorage.setItem(storageKey, JSON.stringify([...requests])); - } catch { - // As with composer drafts, unavailable browser storage retains only memory. - // No provider action or delivery conclusion follows from this cache. - } -} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/message-activity.tsx b/apps/presentation/dashboard/src/features/personal-workspace/message-activity.tsx index cf10c8b4de..5f6d35757b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/message-activity.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/message-activity.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Square } from "lucide-react"; import { ChatApiError } from "../../data/chat.js"; +import { readSteeringRequest, retainSteeringRequest, retireSteeringRequest, type SteeringRequest } from "./steering-recovery"; import { useWorkspaceI18n } from "./i18n"; import type { WorkspaceMessage } from "./personal-workspace-model"; @@ -15,12 +16,23 @@ export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCance const zh = locale === "zh-CN"; const [stopping, setStopping] = useState(false); const [error, setError] = useState(null); - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(""); + const cacheKey = message.sourceSessionId && message.sourceTurnId + ? JSON.stringify(["inline", message.sourceSessionId, message.sourceTurnId]) : null; + const [restoredRequest] = useState(() => cacheKey ? readSteeringRequest(cacheKey) : undefined); + const [editing, setEditing] = useState(Boolean(restoredRequest)); + const [draft, setDraft] = useState(restoredRequest?.text ?? ""); const [steering, setSteering] = useState(false); const [steerError, setSteerError] = useState(null); const [steerReceipt, setSteerReceipt] = useState(false); - const request = useRef<{ text: string; id: string } | null>(null); + const request = useRef(restoredRequest ?? null); + useEffect(() => { + const restored = cacheKey ? readSteeringRequest(cacheKey) : undefined; + request.current = restored ?? null; + setDraft(restored?.text ?? ""); + setEditing(Boolean(restored)); + setSteerError(null); + setSteerReceipt(false); + }, [cacheKey]); const activity = message.activity ?? []; const [now, setNow] = useState(() => Date.now()); useEffect(() => { @@ -35,19 +47,43 @@ export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCance : `${Math.floor(elapsed / 60)}${zh ? "分" : "m "}${elapsed % 60}${zh ? "秒" : "s"}`; const quiet = message.updatedAt !== undefined && now - message.updatedAt >= 20000; + function updateDraft(text: string) { + setDraft(text); + const previous = request.current; + if (!text) { + if (cacheKey && previous) retireSteeringRequest(cacheKey, previous.id); + request.current = null; + return; + } + const next = { sessionId: message.sourceSessionId ?? "", turnId: message.sourceTurnId ?? "", text, + id: previous?.text.trim() === text.trim() ? previous.id : crypto.randomUUID() }; + request.current = next; + if (cacheKey) retainSteeringRequest(cacheKey, next); + } + async function steer() { const text = draft.trim(); if (!text || !message.pending || !message.sourceTurnId || !onSteerTurn || steering) return; // Retain the operation identity after a lost response; retry cannot deliver twice. - if (request.current?.text !== text) request.current = { text, id: crypto.randomUUID() }; + if (request.current?.text.trim() !== text) updateDraft(draft); + const sent = request.current!; + if (cacheKey) retainSteeringRequest(cacheKey, sent); setSteering(true); setSteerError(null); try { - await onSteerTurn(message.sourceTurnId, text, request.current.id); - setDraft(""); setEditing(false); setSteerReceipt(true); request.current = null; + await onSteerTurn(message.sourceTurnId, text, sent.id); + if (cacheKey) retireSteeringRequest(cacheKey, sent.id); + if (request.current?.id === sent.id) { + setDraft(""); setEditing(false); setSteerReceipt(true); request.current = null; + } } catch (cause) { const definitelyNotDelivered = cause instanceof ChatApiError && cause.payload.delivery_state === "not_delivered"; - if (definitelyNotDelivered) request.current = null; + if (definitelyNotDelivered && request.current?.id === sent.id) { + // Preserve the draft; only confirmed non-delivery can renew retry identity. + const next = { ...sent, id: crypto.randomUUID() }; + request.current = next; + if (cacheKey && readSteeringRequest(cacheKey)?.id === sent.id) retainSteeringRequest(cacheKey, next); + } const message = cause instanceof Error ? cause.message : (zh ? "未确认接收,草稿已保留。" : "Delivery unconfirmed. Draft retained."); setSteerError(definitelyNotDelivered ? (zh ? "本次未送达;请检查当前回合与执行器,条件恢复后可重试原文。" : "Not delivered; check the current turn and executor, then retry the unchanged draft.") @@ -85,7 +121,7 @@ export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCance : "No new activity yet. Waiting for the executor to update."}

: null} {editing ?
{ event.preventDefault(); void steer(); }}>