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