diff --git a/surfaces/gui/src/components/Composer.skills.test.tsx b/surfaces/gui/src/components/Composer.skills.test.tsx index 9d1250be2..c67727f97 100644 --- a/surfaces/gui/src/components/Composer.skills.test.tsx +++ b/surfaces/gui/src/components/Composer.skills.test.tsx @@ -43,6 +43,7 @@ const box = () => screen.getByPlaceholderText(/Ask the coworker/); afterEach(() => { cleanup(); + localStorage.clear(); vi.unstubAllGlobals(); }); @@ -147,4 +148,55 @@ describe("Composer — the doorway prefill (SKILLS-SPEC §5.2)", () => { ); }); }); + + it("preserves unsent draft when switching conversations and restores it upon return (#605)", async () => { + stubFetch(); + const { rerender } = render(); + fireEvent.change(box(), { target: { value: "draft for s1" } }); + expect((box() as HTMLTextAreaElement).value).toBe("draft for s1"); + + // Switch to session 2 + rerender(); + expect((box() as HTMLTextAreaElement).value).toBe(""); + + // Type draft in session 2 + fireEvent.change(box(), { target: { value: "draft for s2" } }); + expect((box() as HTMLTextAreaElement).value).toBe("draft for s2"); + + // Switch back to session 1 + rerender(); + expect((box() as HTMLTextAreaElement).value).toBe("draft for s1"); + + // Switch back to session 2 + rerender(); + expect((box() as HTMLTextAreaElement).value).toBe("draft for s2"); + }); + + it("clears the persisted draft when message is sent (#605)", async () => { + stubFetch(); + const onSend = vi.fn(); + const { rerender } = render(); + fireEvent.change(box(), { target: { value: "send me" } }); + + // Send the message via Enter key + fireEvent.keyDown(box(), { key: "Enter" }); + expect(onSend).toHaveBeenCalledWith("send me", [], undefined); + expect((box() as HTMLTextAreaElement).value).toBe(""); + + // Switch to session 2 and back to session 1 + rerender(); + rerender(); + expect((box() as HTMLTextAreaElement).value).toBe(""); + }); + + it("restores draft across unmount and remount (#605)", async () => { + stubFetch(); + const { unmount } = render(); + fireEvent.change(box(), { target: { value: "persisted after unmount" } }); + unmount(); + + // Remount with same session + render(); + expect((box() as HTMLTextAreaElement).value).toBe("persisted after unmount"); + }); }); diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index 5e4b0e1bf..e6c2600c1 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -135,16 +135,74 @@ interface Props { reviewerPaused?: boolean; } +interface ComposerDraft { + text: string; + attachments?: Attachment[]; + pendingSkill?: SessionSkillRow | null; +} + +const DRAFT_PREFIX = "ocw:composer-draft:"; + +function loadDraft(key?: string): ComposerDraft | null { + if (!key) return null; + try { + const raw = localStorage.getItem(`${DRAFT_PREFIX}${key}`); + if (!raw) return null; + return JSON.parse(raw) as ComposerDraft; + } catch { + return null; + } +} + +function saveDraft(key?: string, draft?: ComposerDraft | null): void { + if (!key) return; + try { + if ( + !draft || + (!draft.text.trim() && + (!draft.attachments || draft.attachments.length === 0) && + !draft.pendingSkill) + ) { + localStorage.removeItem(`${DRAFT_PREFIX}${key}`); + } else { + localStorage.setItem(`${DRAFT_PREFIX}${key}`, JSON.stringify(draft)); + } + } catch { + /* best effort */ + } +} + +function clearDraft(key?: string): void { + if (!key) return; + try { + localStorage.removeItem(`${DRAFT_PREFIX}${key}`); + } catch { + /* best effort */ + } +} + export function Composer(props: Props) { const { t } = useTranslation(); - const [text, setText] = useState(""); - const [attachments, setAttachments] = useState([]); + const currentKey = props.resetKey || props.sessionId; + const initialDraft = loadDraft(currentKey); + const [text, setText] = useState(initialDraft?.text ?? ""); + const [attachments, setAttachments] = useState(initialDraft?.attachments ?? []); // "/" force-run (SKILLS-SPEC §4.1 #3). The popup derives from the draft: it is open while // the text is a bare "/query" (no whitespace yet) and no skill is picked. Selecting a row // inserts "/name " INLINE in the box (Claude-Code style — the slash text IS the state); // the user keeps typing after it, and on send the prefix is stripped while the skill name // rides the user_message as its own field. Editing the prefix away un-picks the skill. - const [pendingSkill, setPendingSkill] = useState(null); + const [pendingSkill, setPendingSkill] = useState(initialDraft?.pendingSkill ?? null); + const prevKeyRef = useRef(currentKey); + const draftRef = useRef({ text, attachments, pendingSkill }); + draftRef.current = { text, attachments, pendingSkill }; + + // Persist the current draft as it changes + useEffect(() => { + if (currentKey) { + saveDraft(currentKey, { text, attachments, pendingSkill }); + } + }, [currentKey, text, attachments, pendingSkill]); const [slashSkills, setSlashSkills] = useState(null); const [slashIndex, setSlashIndex] = useState(0); const prefixIntact = @@ -231,16 +289,39 @@ export function Composer(props: Props) { el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden"; }, [text]); - // Clear the draft when the conversation changes, so a half-typed message / picked file doesn't - // bleed from one session into another. Declared BEFORE the prefill effect: when both fire in - // the same render (the Skills doorway starts a new session AND prefills it), effects run in - // declaration order — clear first, then the prefill lands on the fresh session. - useEffect(() => { - setText(""); - setAttachments([]); - setPendingSkill(null); + // Switch session: persist outgoing draft under old session, then restore draft (if any) + // for incoming session. Kept declared BEFORE prefill effect so doorway prefill still overrides. + useLayoutEffect(() => { + const newKey = props.resetKey || props.sessionId; + const oldKey = prevKeyRef.current; + if (oldKey !== newKey) { + if (oldKey) { + saveDraft(oldKey, draftRef.current); + } + prevKeyRef.current = newKey; + const restored = loadDraft(newKey); + if (restored) { + setText(restored.text ?? ""); + setAttachments(restored.attachments ?? []); + setPendingSkill(restored.pendingSkill ?? null); + } else { + setText(""); + setAttachments([]); + setPendingSkill(null); + } + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.resetKey]); + }, [props.resetKey, props.sessionId]); + + // Save on unmount (e.g. navigating to Settings or Inbox) + useEffect(() => { + return () => { + const k = prevKeyRef.current; + if (k) { + saveDraft(k, draftRef.current); + } + }; + }, []); // Apply a prefill (text + attachments) pushed from outside, then focus the composer. Applied at // most once per nonce (a ref guards against StrictMode/re-render double-fires), and attachments @@ -397,6 +478,7 @@ export function Composer(props: Props) { props.onConnectModel?.(); return; } + clearDraft(currentKey); props.onSend(body, attachments, skill); setText(""); setAttachments([]);