From d48c98384a44354f017c415379a88004783f91e4 Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Mon, 26 Oct 2026 06:05:57 +0800
Subject: [PATCH 1/2] fix(chat): retain original conversation drafts through
source recovery
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
apps/presentation/dashboard/package.json | 3 +-
.../smoke/conversation-input-smoke.ts | 52 ++++++++++++++++++
.../personal-workspace-page.tsx | 49 ++++++++++++++---
.../personal-workspace/steering-recovery.ts | 13 +++--
.../use-conversation-input-state.ts | 40 +++++++++++++-
.../app-conversation-and-async-inbox-v0.md | 9 +++
...p-conversation-and-async-inbox-v0.zh-CN.md | 9 ++-
.../after-desktop.png | Bin 0 -> 151627 bytes
.../after-narrow.png | Bin 0 -> 69691 bytes
.../before-desktop.png | Bin 0 -> 161147 bytes
10 files changed, 157 insertions(+), 18 deletions(-)
create mode 100644 apps/presentation/dashboard/smoke/conversation-input-smoke.ts
create mode 100644 docs/architecture/rfcs/assets/app-conversation-draft-recovery/after-desktop.png
create mode 100644 docs/architecture/rfcs/assets/app-conversation-draft-recovery/after-narrow.png
create mode 100644 docs/architecture/rfcs/assets/app-conversation-draft-recovery/before-desktop.png
diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json
index c4696eee69..baeaad7050 100644
--- a/apps/presentation/dashboard/package.json
+++ b/apps/presentation/dashboard/package.json
@@ -81,7 +81,8 @@
"smoke:bound-conversation": "vite build --ssr smoke/bound-conversation-smoke.ts --outDir node_modules/.cache/loopx-bound-conversation --emptyOutDir && node node_modules/.cache/loopx-bound-conversation/bound-conversation-smoke.js",
"smoke:delegation-search": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/delegation-search-browser-smoke.mjs",
"smoke:workspace-command-palette": "node --experimental-strip-types --test src/features/personal-workspace/workspace-command-entries.test.mjs && node --experimental-strip-types smoke/workspace-command-palette-browser-smoke.mjs && node --experimental-strip-types smoke/workspace-navigation-browser-smoke.mjs",
- "smoke:app-steward": "vite build --ssr smoke/app-steward-smoke.ts --outDir node_modules/.cache/loopx-app-steward --emptyOutDir && node node_modules/.cache/loopx-app-steward/app-steward-smoke.js"
+ "smoke:app-steward": "vite build --ssr smoke/app-steward-smoke.ts --outDir node_modules/.cache/loopx-app-steward --emptyOutDir && node node_modules/.cache/loopx-app-steward/app-steward-smoke.js",
+ "smoke:conversation-input": "vite build --ssr smoke/conversation-input-smoke.ts --outDir node_modules/.cache/loopx-conversation-input --emptyOutDir && node node_modules/.cache/loopx-conversation-input/conversation-input-smoke.js"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
diff --git a/apps/presentation/dashboard/smoke/conversation-input-smoke.ts b/apps/presentation/dashboard/smoke/conversation-input-smoke.ts
new file mode 100644
index 0000000000..6570d7f1a1
--- /dev/null
+++ b/apps/presentation/dashboard/smoke/conversation-input-smoke.ts
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict";
+import { conversationInputKey, recoverBoundConversationInput } from "../src/features/personal-workspace/use-conversation-input-state";
+import { legacySteeringRequests, readSteeringRequest, retainSteeringRequest, restoreLegacySteeringRequest } from "../src/features/personal-workspace/steering-recovery";
+
+// A source read cannot change the owner of unsent input or of an uncertain send.
+// Ordinary multi-executor drafts remain independent; recovery never combines them.
+const original = "bound/source-one/session-one";
+const peer = "bound/source-two/session-two";
+assert.equal(conversationInputKey(original, "original-conversation", true), conversationInputKey(original, "codex", true));
+assert.notEqual(conversationInputKey("manager", "codex", false), conversationInputKey("manager", "claude", false));
+assert.notEqual(conversationInputKey(original, "codex", true), conversationInputKey(peer, "codex", true));
+const image = { name: "draft.png", mimeType: "image/png", dataUrl: "data:image/png;base64,c3ludGhldGlj", id: "draft-image", size: 9 };
+const input = (composer: string) => ({ composer, sending: false, steering: false, actionFeedback: null,
+ imageAttachments: [], imageAttachmentError: null, loopxMessageReceipt: "" });
+const oldKey = `${original}:codex`;
+const legacy = { [oldKey]: { ...input("Retain this unsent message"), imageAttachments: [image] }, [peer]: input("Peer stays separate") };
+const recovered = recoverBoundConversationInput(legacy, original);
+assert.equal(recovered[original], legacy[oldKey], "A unique old draft keeps all in-memory fields");
+assert.equal(recovered[peer], legacy[peer]);
+assert.equal(recovered[oldKey], undefined);
+assert.equal(legacy[oldKey].composer, "Retain this unsent message", "Recovery does not mutate its input");
+const conflicting = { ...legacy, [`${original}:original-conversation`]: input("A different draft") };
+assert.equal(recoverBoundConversationInput(conflicting, original), conflicting, "Multiple drafts require selection, never overwrite or concatenate");
+const current = { ...legacy, [original]: input("") };
+assert.equal(recoverBoundConversationInput(current, original), current, "Clearing an existing draft stays cleared");
+assert.deepEqual(recoverBoundConversationInput({ [peer]: input("Peer") }, original), { [peer]: input("Peer") });
+
+const storage = new Map();
+Object.assign(globalThis, { window: { sessionStorage: {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => storage.set(key, value),
+} } });
+const request = { sessionId: "session-one", turnId: "original-turn", text: "Keep the original retry", id: "original-request", delivery: "queue" as const };
+assert.ok(retainSteeringRequest(oldKey, request));
+assert.ok(retainSteeringRequest(`${peer}:codex`, { ...request, sessionId: "session-two", id: "peer-request" }));
+assert.deepEqual(legacySteeringRequests("session-one", original), [[oldKey, request]]);
+assert.equal(restoreLegacySteeringRequest(peer, oldKey, "session-two"), undefined, "A peer cannot adopt an uncertain send");
+assert.deepEqual(restoreLegacySteeringRequest(original, oldKey, "session-one"), request);
+assert.deepEqual(readSteeringRequest(original), request, "Recovery retains exact request, Turn and delivery identity");
+assert.equal(readSteeringRequest(oldKey), undefined);
+assert.ok(retainSteeringRequest(oldKey, { ...request, id: "conflicting-request" }));
+assert.equal(restoreLegacySteeringRequest(original, oldKey, "session-one"), undefined, "A distinct pending request cannot replace the current one");
+assert.equal(readSteeringRequest(original)?.id, "original-request");
+const unavailable = "bound/source-three/session-three";
+const unavailableLegacy = `${unavailable}:codex`;
+assert.ok(retainSteeringRequest(unavailableLegacy, { ...request, sessionId: "session-three", id: "storage-failure-request" }));
+window.sessionStorage.setItem = () => { throw new Error("Synthetic storage is unavailable"); };
+assert.equal(restoreLegacySteeringRequest(unavailable, unavailableLegacy, "session-three"), undefined,
+ "A failed durable copy cannot be presented as a recovered request");
+assert.equal(readSteeringRequest(unavailable), undefined);
+assert.equal(readSteeringRequest(unavailableLegacy)?.id, "storage-failure-request");
+console.log("conversation-input: passed (stable original Session, independent ordinary drafts, lossless legacy recovery and exact uncertain-send identity)");
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
index e83712ead2..6bb0640578 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
@@ -7,7 +7,7 @@ import {useGoalWorkspaceDigest} from "./goal-masthead";
import { goalCreateRequest, goalCreationAgentId } from "./goal-create-request";
import { todoCompletionPreviewFeedback, todoCompletionProofRequired } from "./todo-completion-feedback";
import { readSteeringRequest, retainSteeringRequest, retireSteeringRequest, legacySteeringRequests, restoreLegacySteeringRequest } from "./steering-recovery";
-import { useConversationInputState } from "./use-conversation-input-state";
+import { conversationInputKey, useConversationInputState } from "./use-conversation-input-state";
import { composerKeyAction } from "./composer-keyboard";
import type { ConversationHistoryStatus } from "../../data/use-conversation-history";
import { GoalDraftCard } from "./goal-draft-card";
@@ -1114,17 +1114,29 @@ export function PersonalWorkspacePage({
const conversationKey = workspaceConversations?.managed ? conversationContextKey({kind: "managed", ...workspaceConversations.managed.target}) : originalConversation ? conversationContextKey({ kind: "bound", ...originalConversation.target }) : selectedWorkspaceRef
? conversationContextKey({ kind: "project", projectRef: selectedWorkspaceRef })
: selectedGoalId ?? "manager";
- const composerDraftKey = managedWorkActive ? conversationKey : `${conversationKey}:${selectedAgentId}`;
+ const composerDraftKey = conversationInputKey(conversationKey, selectedAgentId, managedWorkActive || Boolean(originalConversation));
const { composer, setComposer, restoreFailedSubmission,
sending, setSending, steering, setSteering, actionFeedback, setActionFeedback,
imageAttachments, setImageAttachments, imageAttachmentError, setImageAttachmentError,
loopxMessageReceipt, setLoopxMessageReceipt, isCurrentConversation,
- } = useConversationInputState(composerDraftKey);
+ retainedDrafts, restoreRetainedDraft,
+ } = useConversationInputState(composerDraftKey, Boolean(originalConversation));
useEffect(() => {
const input = composerRef.current;
if (!input) return;
- input.style.height = "auto";
- input.style.height = `${Math.min(input.scrollHeight, 120)}px`;
+ const resize = () => {
+ input.style.height = "auto";
+ input.style.height = `${Math.min(input.scrollHeight, 120)}px`;
+ };
+ resize();
+ let width = input.clientWidth;
+ const observer = new ResizeObserver(() => {
+ if (input.clientWidth === width) return;
+ width = input.clientWidth;
+ resize();
+ });
+ observer.observe(input);
+ return () => observer.disconnect();
}, [composer, selectedGoalId, managerChatOpen]);
async function reviewGoalDraft(draft: GoalDraft, edit = false, draftId = "") {
// Source message + reviewed contents survive retry without merging distinct requests.
@@ -1334,11 +1346,14 @@ export function PersonalWorkspacePage({
&& callbacks.onSteerConversationTurn ? runningMessage?.sourceTurnId : undefined;
const queueTurnId = conversationTurnRunning && !readOnly && callbacks.onQueueConversationTurn
? runningMessage?.sourceTurnId : undefined;
- const legacyDrafts = conversationSessionId ? legacySteeringRequests(conversationSessionId) : [];
+ const legacyDrafts = conversationSessionId ? legacySteeringRequests(conversationSessionId, composerDraftKey) : [];
function restoreLegacyDraft(key: string) {
if (composer.trim() || !conversationSessionId) return;
const restored = restoreLegacySteeringRequest(composerDraftKey, key, conversationSessionId);
- if (restored) setComposer(restored.text);
+ if (restored) {
+ setComposer(restored.text);
+ if (restored.delivery === "queue" && !imageAttachments.length) setImageAttachments(restored.attachments ?? []);
+ }
}
useEffect(() => {
if (!conversationSessionId) return;
@@ -2331,6 +2346,15 @@ export function PersonalWorkspacePage({
const pendingImages = messageOverride ? [] : imageAttachments;
const message = (messageOverride ?? composer).trim() || (pendingImages.length ? t("composer.imageAnalysisPrompt") : "");
if (!message || composerBlocked || workspaceUnavailable || (originalConversation && !originalConversation.session?.resumable) || (workspaceConversations?.managed && !workspaceConversations.managed.session?.resumable) || conversationHistoryState?.sendBlocked) return;
+ // A retained uncertain request must be durably recovered before any new
+ // dispatch. Restoring its text alone cannot turn it into a fresh send.
+ if (conversationSessionId && !readSteeringRequest(composerDraftKey)) {
+ const legacy = legacySteeringRequests(conversationSessionId, composerDraftKey)[0];
+ if (legacy && !restoreLegacySteeringRequest(composerDraftKey, legacy[0], conversationSessionId)) {
+ setActionFeedback(t("feedback.steeringStorageUnavailable"));
+ return;
+ }
+ }
const previousSteering = readSteeringRequest(composerDraftKey);
const retry = previousSteering && previousSteering.sessionId === conversationSessionId && previousSteering.text.trim() === message
? previousSteering : undefined;
@@ -3024,13 +3048,20 @@ export function PersonalWorkspacePage({
{t("readiness.guide")}
) : null}
+ {retainedDrafts.length ?
+ {locale === "zh-CN" ? `保留的草稿 · ${retainedDrafts.length}` : `Retained message drafts · ${retainedDrafts.length}`}
+ {retainedDrafts.map(([key, draft]) => {draft.composer}
+
)}
+ : null}
{legacyDrafts.length ?
- {locale === "zh-CN" ? `旧版保留的调整草稿 · ${legacyDrafts.length}` : `Retained drafts · ${legacyDrafts.length}`}
+ {locale === "zh-CN" ? `待确认的投递 · ${legacyDrafts.length}` : `Unconfirmed deliveries · ${legacyDrafts.length}`}
{legacyDrafts.map(([key, request]) => {request.text}
)}
- {locale === "zh-CN" ? "先处理待确认指令并清空输入框后恢复;重试仍发往原回合。" : "Resolve pending instructions and clear the composer to restore. Retries keep their original turn."}
+ {locale === "zh-CN" ? "清空输入框后恢复;重试保留原请求。" : "Clear the composer to restore. Retries keep the original request."}
: null}
{retainedSteering && retainedSteering.sessionId === conversationSessionId && !retryingSteering && !composer.trim() ?