diff --git a/agent-chat/src/components/Chat.tsx b/agent-chat/src/components/Chat.tsx index 2922ad2b9f38..4a4d9c7cf851 100644 --- a/agent-chat/src/components/Chat.tsx +++ b/agent-chat/src/components/Chat.tsx @@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } import { useCtx } from "../context"; import { agentChatText } from "../i18n"; import { readStoredProviderOptions, persistOptionsSnapshot, updateStoredProviderOption } from "../options-store"; -import { routedToTranscript, transcriptComposerLocked, type OptionValue, type SessionOption } from "../session"; +import { composerDraftKey, routedToTranscript, transcriptComposerLocked, type OptionValue, type SessionOption } from "../session"; import { ArrowUp } from "./icons"; import { isCtrlJ, insertNewlineAtCaret, useCommandMenu } from "./CommandMenu"; import { optionAcceptsValue, optionsForSelectedModel } from "./options"; @@ -57,7 +57,7 @@ function useStickToBottom(scrollRef: RefObject, stickRef: export function Chat() { const { ready, connectionEpoch, providers, capabilities, providerOptions, session, routing, blocks, options, actions, commands, filesByCwd, fileDiffs, fileDiffErrors, ctrlJ, forkPending, handoffPending, reply, stop, focusTerminal, setOption, fork, handoff, compose, requestProviderOptions, requestProviderCommands, requestFiles, requestFileDiff } = useCtx(); - const [text, setText] = useState(""); + const [text, setText] = useState(() => draftStorage.getItem(composerDraftKey) || ""); const [openOptionId, setOpenOptionId] = useState(null); const [helpOpen, setHelpOpen] = useState(false); const taRef = useAutoGrow(text, 200); @@ -84,6 +84,10 @@ export function Chat() { useProviderCatalogs(ready, connectionEpoch, providers, session?.provider ?? "", catalogCwd, requestProviderOptions, requestProviderCommands); useFileCatalog(ready, connectionEpoch, catalogCwd, requestFiles); useStickToBottom(scrollRef, stickRef, blocks, running); + useEffect(() => { + if (text) draftStorage.setItem(composerDraftKey, text); + else draftStorage.removeItem(composerDraftKey); + }, [text]); useKeymap({ options: resolvedOptions, setOption, diff --git a/agent-chat/src/session.ts b/agent-chat/src/session.ts index 32f3aec7e54b..4e5d67da5692 100644 --- a/agent-chat/src/session.ts +++ b/agent-chat/src/session.ts @@ -329,7 +329,29 @@ function newClientRequestId(prefix: string): string { } export function restoreComposerDraft(storage: Pick, prompt: string) { - storage.setItem(composerDraftKey, prompt); + try { storage.setItem(composerDraftKey, prompt); } catch { + // Draft recovery is best effort when browser storage is unavailable. + } +} + +export function readComposerDraft(storage: Pick): string { + try { + return storage.getItem(composerDraftKey) || ""; + } catch { + return ""; + } +} + +export function writeComposerDraft( + storage: Pick, + draft: string, +) { + try { + if (draft) storage.setItem(composerDraftKey, draft); + else storage.removeItem(composerDraftKey); + } catch { + // Private browsing and embedded Cloud contexts can deny session storage. + } } // An echo matches anywhere in the queue: one that never lands (a failed send) diff --git a/agent-chat/test/session.test.ts b/agent-chat/test/session.test.ts index f764be7c064d..156fb4e666f9 100644 --- a/agent-chat/test/session.test.ts +++ b/agent-chat/test/session.test.ts @@ -3,7 +3,7 @@ Object.defineProperty(globalThis, "location", { value: { pathname: "/" }, }); -const { composerDraftKey, consumeOptimisticUserEcho, foldEvent, latestRouting, restoreComposerDraft, shouldAcceptSessionActionResponse, transcriptComposerLocked } = await import("../src/session"); +const { composerDraftKey, consumeOptimisticUserEcho, foldEvent, latestRouting, readComposerDraft, restoreComposerDraft, shouldAcceptSessionActionResponse, transcriptComposerLocked, writeComposerDraft } = await import("../src/session"); const { latestRouteStatus, normalizeRouteStatus, routeHealthForPhase } = await import("../route-status"); const { draftStorage } = await import("../src/browser-storage"); @@ -35,6 +35,20 @@ try { else Reflect.deleteProperty(globalThis, "sessionStorage"); } +const draftValues: Record = {}; +const draftFixture = { + getItem: (key: string) => draftValues[key] ?? null, + setItem: (key: string, value: string) => { draftValues[key] = value; }, + removeItem: (key: string) => { delete draftValues[key]; }, +}; +writeComposerDraft(draftFixture, "typed while Cloud was reconnecting"); +if (readComposerDraft(draftFixture) !== "typed while Cloud was reconnecting") throw new Error("live draft was not recoverable"); +writeComposerDraft(draftFixture, ""); +if (readComposerDraft(draftFixture) !== "") throw new Error("clearing draft did not remove it"); +const unavailableStorage = { getItem() { throw new Error("storage unavailable"); }, setItem() { throw new Error("storage unavailable"); }, removeItem() { throw new Error("storage unavailable"); } }; +writeComposerDraft(unavailableStorage, "still usable"); +if (readComposerDraft(unavailableStorage) !== "") throw new Error("storage failures should not break draft access"); + const repeated = [ { kind: "user" as const, text: "same" }, { kind: "user" as const, text: "same" },