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([]);