diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index c4696eee69..d9cda41712 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": "node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && 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..ba44628708 --- /dev/null +++ b/apps/presentation/dashboard/smoke/conversation-input-smoke.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { conversationInputKey, recoverBoundConversationInput } from "../src/features/personal-workspace/use-conversation-input-state"; + +// 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") }); + +console.log("conversation-input: passed (stable original Session, independent ordinary drafts and lossless legacy draft recovery)"); 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..c595185b3f 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,18 @@ 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); + if (!readSteeringRequest(composerDraftKey)) { + setActionFeedback(t("feedback.steeringStorageUnavailable")); + return; + } + } + } const previousSteering = readSteeringRequest(composerDraftKey); const retry = previousSteering && previousSteering.sessionId === conversationSessionId && previousSteering.text.trim() === message ? previousSteering : undefined; @@ -3024,13 +3051,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() ?