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() ?