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 ?