Skip to content
Open
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
52 changes: 52 additions & 0 deletions surfaces/gui/src/components/Composer.skills.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const box = () => screen.getByPlaceholderText(/Ask the coworker/);

afterEach(() => {
cleanup();
localStorage.clear();
vi.unstubAllGlobals();
});

Expand Down Expand Up @@ -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(<Composer {...props({ resetKey: "s1", sessionId: "s1" })} />);
fireEvent.change(box(), { target: { value: "draft for s1" } });
expect((box() as HTMLTextAreaElement).value).toBe("draft for s1");

// Switch to session 2
rerender(<Composer {...props({ resetKey: "s2", sessionId: "s2" })} />);
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(<Composer {...props({ resetKey: "s1", sessionId: "s1" })} />);
expect((box() as HTMLTextAreaElement).value).toBe("draft for s1");

// Switch back to session 2
rerender(<Composer {...props({ resetKey: "s2", sessionId: "s2" })} />);
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(<Composer {...props({ resetKey: "s1", sessionId: "s1", onSend })} />);
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(<Composer {...props({ resetKey: "s2", sessionId: "s2" })} />);
rerender(<Composer {...props({ resetKey: "s1", sessionId: "s1" })} />);
expect((box() as HTMLTextAreaElement).value).toBe("");
});

it("restores draft across unmount and remount (#605)", async () => {
stubFetch();
const { unmount } = render(<Composer {...props({ resetKey: "s1", sessionId: "s1" })} />);
fireEvent.change(box(), { target: { value: "persisted after unmount" } });
unmount();

// Remount with same session
render(<Composer {...props({ resetKey: "s1", sessionId: "s1" })} />);
expect((box() as HTMLTextAreaElement).value).toBe("persisted after unmount");
});
});
106 changes: 94 additions & 12 deletions surfaces/gui/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Attachment[]>([]);
const currentKey = props.resetKey || props.sessionId;
const initialDraft = loadDraft(currentKey);
const [text, setText] = useState(initialDraft?.text ?? "");
const [attachments, setAttachments] = useState<Attachment[]>(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<SessionSkillRow | null>(null);
const [pendingSkill, setPendingSkill] = useState<SessionSkillRow | null>(initialDraft?.pendingSkill ?? null);
const prevKeyRef = useRef<string | undefined>(currentKey);
const draftRef = useRef<ComposerDraft>({ 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<SessionSkillRow[] | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const prefixIntact =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -397,6 +478,7 @@ export function Composer(props: Props) {
props.onConnectModel?.();
return;
}
clearDraft(currentKey);
props.onSend(body, attachments, skill);
setText("");
setAttachments([]);
Expand Down