Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions apps/presentation/dashboard/smoke/conversation-input-smoke.ts
Original file line number Diff line number Diff line change
@@ -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)");
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -3024,13 +3051,20 @@ export function PersonalWorkspacePage({
<a href={runtimeSetupGuideUrl} rel="noreferrer" target="_blank">{t("readiness.guide")}</a>
</p>
) : null}
{retainedDrafts.length ? <details className="personal-legacy-steering-drafts">
<summary>{locale === "zh-CN" ? `保留的草稿 · ${retainedDrafts.length}` : `Retained message drafts · ${retainedDrafts.length}`}</summary>
{retainedDrafts.map(([key, draft]) => <div key={key}><p>{draft.composer}</p>
<button type="button" disabled={Boolean(composer) || Boolean(imageAttachments.length) || sending || steering || Boolean(retainedSteering)} onClick={() => restoreRetainedDraft(key)}>
{locale === "zh-CN" ? "恢复到输入框" : "Restore to composer"}
</button></div>)}
</details> : null}
{legacyDrafts.length ? <details className="personal-legacy-steering-drafts">
<summary>{locale === "zh-CN" ? `旧版保留的调整草稿 · ${legacyDrafts.length}` : `Retained drafts · ${legacyDrafts.length}`}</summary>
<summary>{locale === "zh-CN" ? `待确认的投递 · ${legacyDrafts.length}` : `Unconfirmed deliveries · ${legacyDrafts.length}`}</summary>
{legacyDrafts.map(([key, request]) => <div key={key}><p>{request.text}</p>
<button type="button" disabled={Boolean(composer.trim()) || Boolean(retainedSteering && retainedSteering.id !== request.id)} onClick={() => restoreLegacyDraft(key)}>
{locale === "zh-CN" ? "恢复到输入框" : "Restore to composer"}
</button></div>)}
<small>{locale === "zh-CN" ? "先处理待确认指令并清空输入框后恢复;重试仍发往原回合。" : "Resolve pending instructions and clear the composer to restore. Retries keep their original turn."}</small>
<small>{locale === "zh-CN" ? "清空输入框后恢复;重试保留原请求。" : "Clear the composer to restore. Retries keep the original request."}</small>
</details> : null}
{retainedSteering && retainedSteering.sessionId === conversationSessionId && !retryingSteering && !composer.trim() ? <button type="button"
onClick={() => { setComposer(retainedSteering.text); if (retainedSteering.delivery === "queue") setImageAttachments(retainedSteering.attachments ?? []); }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ import test from "node:test";
const key = JSON.stringify(["inline", "session", "turn"]);
const request = {sessionId: "session", turnId: "turn", text: "Keep the original constraint", id: "original-request"};

test("executor-keyed bound recovery retains the original request and excludes peers", async () => {
const bound = "bound/source-one/session", old = `${bound}:codex`;
const peer = "bound/source-two/peer:codex";
const queued = {...request, delivery: "queue", attachments: []};
let stored = JSON.stringify([[old, queued], [peer, {...request, sessionId: "peer", id: "peer-request"}]]);
let writable = false;
globalThis.window = {sessionStorage: {getItem: () => stored, setItem: (_, value) => {
if (!writable) throw new Error("Synthetic storage failure");
stored = value;
}}};
const recovery = await import("./steering-recovery.ts?bound-legacy");
assert.deepEqual(recovery.legacySteeringRequests("session", bound), [[old, queued]]);
assert.equal(recovery.restoreLegacySteeringRequest(bound, peer, "session"), undefined);
assert.deepEqual(recovery.restoreLegacySteeringRequest(bound, old, "session"), queued, "Text remains recoverable even when storage is unavailable");
assert.equal(recovery.readSteeringRequest(bound), undefined, "Recoverable text is not a qualified dispatch identity");
assert.deepEqual(recovery.readSteeringRequest(old), queued);
writable = true;
recovery.restoreLegacySteeringRequest(bound, old, "session");
assert.deepEqual(recovery.readSteeringRequest(bound), queued);
assert.equal(recovery.readSteeringRequest(old), undefined);
const reloaded = await import("./steering-recovery.ts?bound-legacy-reload");
assert.deepEqual(reloaded.readSteeringRequest(bound), queued);
assert.equal(reloaded.readSteeringRequest(peer).id, "peer-request");
});

test("steering identity survives reload only after exact persistent readback", async () => {
let stored = null;
globalThis.window = {sessionStorage: {getItem: () => stored, setItem: (_, value) => {stored = value;}}};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,16 @@ export function retireSteeringRequest(key: string, id: string) {
persistRequests();
}

// Upgrade recovery only. No new inline drafts are created by the current UI.
export function legacySteeringRequests(sessionId: string): Array<[string, SteeringRequest]> {
// Upgrade recovery only: old inline and executor-keyed original-conversation
// requests. Current input never creates either legacy location.
export function legacySteeringRequests(sessionId: string, currentKey?: string): Array<[string, SteeringRequest]> {
return [...requestCache()].filter(([key, request]) => request.sessionId === sessionId
&& key === JSON.stringify(["inline", sessionId, request.turnId]));
&& (key === JSON.stringify(["inline", sessionId, request.turnId])
|| Boolean(currentKey?.startsWith("bound/") && key.startsWith(`${currentKey}:`))));
}

export function restoreLegacySteeringRequest(key: string, legacyKey: string, sessionId: string): SteeringRequest | undefined {
const request = legacySteeringRequests(sessionId).find(([candidate]) => candidate === legacyKey)?.[1];
const request = legacySteeringRequests(sessionId, key).find(([candidate]) => candidate === legacyKey)?.[1];
if (!request) return undefined;
const current = readSteeringRequest(key);
if (current && (current.id !== request.id || current.sessionId !== request.sessionId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,46 @@ const emptyInput: ConversationInputState = {
loopxMessageReceipt: "",
};

// Original conversations have one fixed Session. Executor discovery is a read,
// so its loading/unavailable placeholder must not select another draft.
export function conversationInputKey(conversationKey: string, agentId: string, fixedSession: boolean): string {
return fixedSession ? conversationKey : `${conversationKey}:${agentId}`;
}

function retainedInputs(inputs: Record<string, ConversationInputState>, key: string) {
return Object.entries(inputs).filter(([candidate, input]) => candidate.startsWith(`${key}:`)
&& Boolean(input.composer || input.imageAttachments.length));
}

export function recoverBoundConversationInput(inputs: Record<string, ConversationInputState>, key: string) {
if (inputs[key]) return inputs;
const retained = retainedInputs(inputs, key);
// Multiple old drafts are distinct user input. Keep them available for explicit
// recovery instead of guessing which one to overwrite or concatenating them.
if (retained.length !== 1) return inputs;
const [previousKey, input] = retained[0];
const recovered = { ...inputs, [key]: input };
delete recovered[previousKey];
return recovered;
}

// An async callback keeps the key from its originating render. Leaving a
// conversation does not cancel delivery or let its late receipt edit a peer.
// This state is presentation only; Session/Turn ingress still owns effects.
export function useConversationInputState(key: string) {
export function useConversationInputState(key: string, recoverBoundDrafts = false) {
const [inputs, setInputs] = useState<Record<string, ConversationInputState>>(() => {
try {
const parsed = JSON.parse(window.sessionStorage.getItem("loopx-pw-composer-drafts") ?? "{}");
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
const restored = parsed && typeof parsed === "object" && !Array.isArray(parsed)
? Object.fromEntries(Object.entries(parsed).filter(([, text]) => typeof text === "string")
.map(([draftKey, text]) => [draftKey, { ...emptyInput, composer: text as string }]))
: {};
return recoverBoundDrafts ? recoverBoundConversationInput(restored, key) : restored;
} catch { return {}; }
});
useEffect(() => {
if (recoverBoundDrafts) setInputs(current => recoverBoundConversationInput(current, key));
}, [key, recoverBoundDrafts]);
const savedDrafts = JSON.stringify(Object.fromEntries(Object.entries(inputs)
.filter(([, input]) => input.composer).map(([draftKey, input]) => [draftKey, input.composer])));
useEffect(() => {
Expand Down Expand Up @@ -73,5 +100,14 @@ export function useConversationInputState(key: string) {
setImageAttachmentError: setter("imageAttachmentError"),
setLoopxMessageReceipt: setter("loopxMessageReceipt"),
isCurrentConversation: () => visibleKey.current === key,
retainedDrafts: recoverBoundDrafts ? retainedInputs(inputs, key) : [],
restoreRetainedDraft: (previousKey: string) => setInputs(current => {
const input = current[key] ?? emptyInput;
const previous = recoverBoundDrafts && retainedInputs(current, key).find(([candidate]) => candidate === previousKey)?.[1];
if (!previous || input.composer || input.imageAttachments.length || input.sending || input.steering) return current;
const restored = { ...current, [key]: { ...input, composer: previous.composer, imageAttachments: previous.imageAttachments } };
delete restored[previousKey];
return restored;
}),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,15 @@ listener/recovery diagnostics. The client requires the exact binding echo,
owner audience, available workspace and matching executor; missing or stale
descriptors fail closed without a catalog fallback or replacement Session. The
existing five-second read bound and fresh input admission remain unchanged.
Unsent input for an original conversation belongs to its fixed binding/Session,
not the executor's loading or unavailable display state. Source denial and
recovery retain the editable text and in-memory images without enabling Send.
Ordinary multi-executor conversations keep separate drafts. A unique retained
executor-keyed draft is recovered into the same original conversation; conflicting
drafts remain separately recoverable without overwriting current input. Retained
uncertain sends keep their original request/Turn identity and require normal
source admission before an explicit retry. Text retains the existing tab-session
storage lifetime; this does not add image persistence across reloads.
This qualifies a source-read boundary, not complete history/media synchronization
or an installed provider roundtrip.
The original-conversation request inventory pins one qualified code revision per
Expand Down
Loading
Loading