feat(web): persist composer draft across session switches#438
feat(web): persist composer draft across session switches#438hqhq1025 merged 10 commits intotiann:mainfrom
Conversation
There was a problem hiding this comment.
Review mode: initial
Findings
- [Major] Drafts can be saved under the wrong session during chat-to-chat navigation.
SessionChatcurrently reuses the sameHappyComposerinstance acrosssessionIdchanges (web/src/router.tsx:328,web/src/components/SessionChat.tsx:390), but the new cleanup persistscomposerTextRef.currentfrom a shared ref (web/src/components/AssistantChat/HappyComposer.tsx:176). On an A -> B switch, that ref can already hold B's composer text or''when A's cleanup runs, so A's draft is overwritten or cleared instead of being preserved.
Suggested fix:<HappyComposer key={props.session.id} sessionId={props.session.id} ... />
Summary
- Review mode: initial. 1 major regression found in the session-switch lifecycle; the current cleanup can clobber or mis-attribute drafts when the route param changes.
Testing
- Not run (automation runner missing Bun:
bun: command not found) - Add a component test that changes
sessionIdwithout remounting the whole route tree and verifies Session A's draft survives the switch.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] Drafts are cleared before the send is actually accepted.
HappyComposernow deletes the persisted draft immediately afterapi.composer().send()(web/src/components/AssistantChat/HappyComposer.tsx:349,web/src/components/AssistantChat/HappyComposer.tsx:522), butSessionChatcan still reject the message for unsupported Codex builtin slash commands (web/src/components/SessionChat.tsx:307). In that path the user message is not sent, yet the saved draft is already gone, so switching sessions still loses the text.
Suggested fix:// HappyComposer: do not clear here api.composer().send() // SessionChat.handleSend: clear only after validation passes if (unsupportedCommand) { ... return } props.onSend(text, attachments) clearDraft(props.session.id)
Summary
- Review mode: follow-up after new commits. 1 major regression remains: the new draft-clearing path runs before send validation, so rejected Codex slash commands can still drop the persisted draft.
Testing
- Not run (automation environment:
bun: command not found) - Add a component/integration test for an unsupported Codex slash command, then switch sessions and verify the draft is still restored.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] Drafts are still cleared before the send is actually accepted.
SessionChatnow deletes the saved draft immediately afterprops.onSend(...)(web/src/components/SessionChat.tsx:324,web/src/components/SessionChat.tsx:325), but the send hook can still return before queuing any optimistic message when resume fails or sending is blocked (web/src/hooks/mutations/useSendMessage.ts:93,web/src/hooks/mutations/useSendMessage.ts:121). The inactive-session path explicitly throws onapi.resumeSession(...)(web/src/router.tsx:240,web/src/router.tsx:245), so a failed resume still discards the unsent composer text.
Suggested fix:// useSendMessage.ts type UseSendMessageOptions = { onQueued?: () => void } onMutate: async (input) => { appendOptimisticMessage(input.sessionId, optimisticMessage) options?.onQueued?.() } // router.tsx const { sendMessage } = useSendMessage(api, sessionId, { ..., onQueued: () => { if (sessionId) clearDraft(sessionId) }, }) // SessionChat.tsx props.onSend(text, attachments) setForceScrollToken((token) => token + 1)
Summary
- Review mode: follow-up after new commits. 1 major regression remains: the new draft-clearing path still runs before send acceptance, so failed inactive-session resume or other blocked sends can drop unsent composer text.
Testing
- Not run (review automation; no PR code execution)
- Missing coverage: a component/integration case where
resumeSessionfails and the draft must still restore after switching away and back.
HAPI Bot
Switching between sessions now preserves the text typed in the composer. Drafts are stored per-session in sessionStorage and restored when the user navigates back. - Add composer-drafts utility (sessionStorage, in-memory cache) - Restore draft on HappyComposer mount, save on unmount - Clear draft on message send - Evict oldest drafts when exceeding 50 entries - Add unit tests for composer-drafts Fixes tiann#231
Prevents draft loss when Codex rejects an unsupported slash command.
Successful sends clear the composer text, so the unmount save naturally persists an empty string which deletes the draft entry. This avoids clearing the draft when the send is blocked or fails.
b884361 to
c3d21b6
Compare
There was a problem hiding this comment.
Findings
- [Major] Sent drafts can come back after a refresh because the new flow only deletes storage during unmount cleanup.
HappyComposerrestores on mount and saves on unmount (web/src/components/AssistantChat/HappyComposer.tsx:162,web/src/components/AssistantChat/HappyComposer.tsx:180), while empty-text deletion exists only insidesaveDraft(...)(web/src/lib/composer-drafts.ts:74) andclearDraft(...)is never used from the send path. If a draft was previously saved, then restored and sent, a reload before the next clean unmount leaves the oldsessionStorageentry intact and resurrects text that was already submitted.
Suggested fix:// useSendMessage.ts type UseSendMessageOptions = { onQueued?: (sessionId: string) => void } onMutate: async (input) => { appendOptimisticMessage(input.sessionId, optimisticMessage) options?.onQueued?.(input.sessionId) } // router.tsx const { sendMessage } = useSendMessage(api, sessionId, { ..., onQueued: (queuedSessionId) => clearDraft(queuedSessionId), })
Summary
- Review mode: follow-up after new commits. 1 major regression remains: draft persistence is restored/saved on navigation, but successfully sent drafts are not cleared at send-accept time, so they can reappear after a refresh.
Testing
- Not run (automation)
- Missing coverage: restore draft → send message → reload/remount same session should not restore the sent text again.
HAPI Bot
Move draft clearing to the send-success path so drafts are only removed after the message is actually accepted by the server.
There was a problem hiding this comment.
Findings
- [Major] The new send-success callback is not scoped to the session that actually sent the message.
useSendMessagedropsinput.sessionIdwhen it callsoptions?.onSuccess?.()(web/src/hooks/mutations/useSendMessage.ts:87), and the route-level handler clears whatever session is currently selected viaclearDraft(sessionId)(web/src/router.tsx:241). If a user sends in session A, switches to session B, and A's request settles afterward, this can delete B's draft instead of A's.
Suggested fix:type UseSendMessageOptions = { onSuccess?: (sentSessionId: string) => void } onSuccess: (_, input) => { updateMessageStatus(input.sessionId, input.localId, 'sent') haptic.notification('success') options?.onSuccess?.(input.sessionId) } onSuccess: (sentSessionId) => { clearDraft(sentSessionId) }
Summary
- Review mode: follow-up after new commits. 1 major regression remains in the follow-up fix: the async success handler clears the currently viewed session draft, not necessarily the session whose send just completed.
Testing
- Not run (automation)
- Missing coverage: send in session A, switch to session B, draft in B, then let A's send resolve; B's draft should remain.
HAPI Bot
The previous version used the current route's sessionId, which could clear the wrong draft if the user switched sessions before the send completed.
Verify that onSuccess receives the correct session ID (including resolved IDs), and is not called on send failure or block.
There was a problem hiding this comment.
Findings
- None.
Summary
- Review mode: follow-up after new commits. No high-confidence diff-triggered issues found in the latest head. Residual risk: the draft persistence behavior still relies on manual coverage for the actual route-switch lifecycle in
web/src/components/AssistantChat/HappyComposer.tsx:163and the route-level clear-on-send path inweb/src/router.tsx:241.
Testing
- Not run (automation). Static review only; skipped executing untrusted PR code.
HAPI Bot
Extract the draft save/restore logic from HappyComposer into a dedicated useComposerDraft hook. Adds 6 unit tests covering: - mount: restores saved draft via rAF - mount: skips restore if composer already has text - mount: skips restore if no saved draft - unmount: saves current text after rAF has fired - unmount: skips save before rAF (draftReady guard) - no-op when sessionId is undefined
There was a problem hiding this comment.
Findings
- [Major] Existing draft updates do not refresh eviction order, so a recently edited draft can be the first entry evicted after the 51st saved session. Evidence
web/src/lib/composer-drafts.ts:48,web/src/lib/composer-drafts.ts:80. - [Minor] Resume-send clears only the resolved session ID, leaving the original route draft orphaned when
resumeSession()remaps the session. Evidenceweb/src/router.tsx:241,web/src/router.tsx:244.
Summary
- Review mode: follow-up after new commits.
- Found 2 high-confidence diff-triggered issues affecting draft retention and cleanup.
Testing
- Not run (automation). Static review only; skipped executing untrusted PR code.
HAPI Bot
When resolveSessionId swaps the session (e.g. inactive → resumed), the sent ID differs from the route's session ID. Extract clearDraftsAfterSend so both are cleared and unit-testable.
There was a problem hiding this comment.
Findings
- [Major] Existing draft edits still do not refresh eviction order, so a recently edited session can be evicted before older untouched ones once the 51st draft is saved. Evidence
web/src/lib/composer-drafts.ts:48,web/src/lib/composer-drafts.ts:80.
Suggested fix:if (!trimmed) { delete drafts[sessionId] } else { delete drafts[sessionId] drafts[sessionId] = text } persist()
Summary
- Review mode: follow-up after new commits
- Found 1 high-confidence diff-triggered issue; the resume-send cleanup follow-up is covered now, but eviction order still drops recently edited drafts.
Testing
- Not run (automation). Static review only; skipped executing untrusted PR code.
HAPI Bot
Delete the key before re-inserting so Object.keys() reflects the most recent write, preventing a recently edited draft from being evicted first.
There was a problem hiding this comment.
Findings
- [Major] Successful sends can still delete a newer unsent draft.
onSuccessclears by session ID only, so if message A succeeds after the user already started typing message B and switched sessions, the unmount path saves B but this callback removes it again. Evidenceweb/src/router.tsx:241,web/src/hooks/useComposerDraft.ts:33.
Suggested fix:type SendSuccess = Pick<SendMessageInput, 'sessionId' | 'text'> onSuccess: (_, input) => { updateMessageStatus(input.sessionId, input.localId, 'sent') haptic.notification('success') options?.onSuccess?.({ sessionId: input.sessionId, text: input.text }) } export function clearDraftsAfterSend(sentSessionId: string, routeSessionId: string | null, sentText: string): void { if (getDraft(sentSessionId) === sentText) clearDraft(sentSessionId) if (routeSessionId && routeSessionId !== sentSessionId && getDraft(routeSessionId) === sentText) { clearDraft(routeSessionId) } }
Summary
- Review mode: follow-up after new commits
- Found 1 high-confidence diff-triggered regression: success cleanup can erase a newer unsent draft during slow/in-flight sends.
Testing
- Not run (automation). Static review only; skipped executing untrusted PR code.
HAPI Bot
| isSending, | ||
| } = useSendMessage(api, sessionId, { | ||
| onSuccess: (sentSessionId) => { | ||
| clearDraftsAfterSend(sentSessionId, sessionId) |
There was a problem hiding this comment.
[MAJOR] clearDraftsAfterSend(sentSessionId, sessionId) clears whatever draft is currently stored for that session when the request succeeds. That also deletes a newer unsent draft if the user sends message A, starts typing message B while A is still in flight, then switches sessions: useComposerDraft() saves B on unmount, and this callback wipes it once A finally succeeds.
Suggested fix:
type SendSuccess = Pick<SendMessageInput, 'sessionId' | 'text'>
onSuccess: (_, input) => {
updateMessageStatus(input.sessionId, input.localId, 'sent')
haptic.notification('success')
options?.onSuccess?.({ sessionId: input.sessionId, text: input.text })
}
export function clearDraftsAfterSend(sentSessionId: string, routeSessionId: string | null, sentText: string): void {
if (getDraft(sentSessionId) === sentText) clearDraft(sentSessionId)
if (routeSessionId && routeSessionId !== sentSessionId && getDraft(routeSessionId) === sentText) {
clearDraft(routeSessionId)
}
}There was a problem hiding this comment.
This PR only adds draft persistence — it does not change the send flow. useSendMessage still blocks concurrent sends via mutation.isPending, so there is no window where a user can send message A and then type message B while A is in flight. The scenario described here would require message queuing, which is out of scope for this PR.
hqhq1025
left a comment
There was a problem hiding this comment.
LGTM — clean implementation, solid test coverage.
A few highlights:
-
The rAF deferral pattern in useComposerDraft is well thought out — prevents React's initial empty state from overwriting a persisted draft. Good catch.
-
The draftReady guard correctly prevents saving before initial restore completes — this is the kind of subtle race condition that's easy to miss.
-
Bot's MAJOR concern about clearDraftsAfterSend is correctly addressed: useSendMessage blocks concurrent sends via mutation.isPending, so the described race condition cannot occur.
-
Eviction refresh on update (commit a32804f) — nice attention to detail, prevents stale drafts from surviving longer than active ones.
Minor suggestion (non-blocking): consider squashing the 10 commits into 2-3 logical ones before merge (feat + refactor + tests) to keep git history clean. But this is up to the maintainer's preference.
Tested: typecheck and unit tests pass.
Problem
Text typed in the composer is lost when switching between sessions.
Users who draft a long prompt and briefly check another session must
retype it.
Fixes #231
Solution
Drafts are saved per-session in
sessionStorageand restored whennavigating back. The implementation uses a mount/unmount lifecycle
pattern on
HappyComposersince TanStack Router remounts thecomponent tree on route parameter changes.
requestAnimationFrame(deferred so the runtime has settled); skip if the user already
started typing
Files changed
web/src/lib/composer-drafts.tsweb/src/lib/composer-drafts.test.tsweb/src/components/AssistantChat/HappyComposer.tsxsessionIdprop, draft save/restore lifecycleweb/src/components/SessionChat.tsxsessionIdto HappyComposerTest plan
bun run typecheck:webpassesbun run test:webpasses (25 files, 136 tests)text restored ✓ → back to B → text restored ✓