Skip to content

feat(web): persist composer draft across session switches#438

Merged
hqhq1025 merged 10 commits intotiann:mainfrom
junmo-kim:feat/composer-draft-persistence
Apr 12, 2026
Merged

feat(web): persist composer draft across session switches#438
hqhq1025 merged 10 commits intotiann:mainfrom
junmo-kim:feat/composer-draft-persistence

Conversation

@junmo-kim
Copy link
Copy Markdown
Contributor

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 sessionStorage and restored when
navigating back. The implementation uses a mount/unmount lifecycle
pattern on HappyComposer since TanStack Router remounts the
component tree on route parameter changes.

  • On mount: restore the draft via requestAnimationFrame
    (deferred so the runtime has settled); skip if the user already
    started typing
  • On unmount: save current text as a draft
  • On send: clear the draft
  • Eviction: oldest entries are removed when exceeding 50 drafts

Files changed

File Change
web/src/lib/composer-drafts.ts New — sessionStorage utility with in-memory cache and eviction
web/src/lib/composer-drafts.test.ts New — 11 unit tests
web/src/components/AssistantChat/HappyComposer.tsx Add sessionId prop, draft save/restore lifecycle
web/src/components/SessionChat.tsx Pass sessionId to HappyComposer

Test plan

  • bun run typecheck:web passes
  • bun run test:web passes (25 files, 136 tests)
  • E2E: type in Session A → switch to B → type in B → back to A →
    text restored ✓ → back to B → text restored ✓

Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review mode: initial

Findings

  • [Major] Drafts can be saved under the wrong session during chat-to-chat navigation. SessionChat currently reuses the same HappyComposer instance across sessionId changes (web/src/router.tsx:328, web/src/components/SessionChat.tsx:390), but the new cleanup persists composerTextRef.current from 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 sessionId without remounting the whole route tree and verifies Session A's draft survives the switch.

HAPI Bot

Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Drafts are cleared before the send is actually accepted. HappyComposer now deletes the persisted draft immediately after api.composer().send() (web/src/components/AssistantChat/HappyComposer.tsx:349, web/src/components/AssistantChat/HappyComposer.tsx:522), but SessionChat can 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

Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Drafts are still cleared before the send is actually accepted. SessionChat now deletes the saved draft immediately after props.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 on api.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 resumeSession fails 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.
@junmo-kim junmo-kim force-pushed the feat/composer-draft-persistence branch from b884361 to c3d21b6 Compare April 11, 2026 23:32
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Sent drafts can come back after a refresh because the new flow only deletes storage during unmount cleanup. HappyComposer restores 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 inside saveDraft(...) (web/src/lib/composer-drafts.ts:74) and clearDraft(...) 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 old sessionStorage entry 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.
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] The new send-success callback is not scoped to the session that actually sent the message. useSendMessage drops input.sessionId when it calls options?.onSuccess?.() (web/src/hooks/mutations/useSendMessage.ts:87), and the route-level handler clears whatever session is currently selected via clearDraft(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.
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:163 and the route-level clear-on-send path in web/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
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. Evidence web/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.
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Successful sends can still delete a newer unsent draft. onSuccess clears 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. Evidence web/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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)
    }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

@hqhq1025 hqhq1025 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — clean implementation, solid test coverage.

A few highlights:

  1. The rAF deferral pattern in useComposerDraft is well thought out — prevents React's initial empty state from overwriting a persisted draft. Good catch.

  2. The draftReady guard correctly prevents saving before initial restore completes — this is the kind of subtle race condition that's easy to miss.

  3. Bot's MAJOR concern about clearDraftsAfterSend is correctly addressed: useSendMessage blocks concurrent sends via mutation.isPending, so the described race condition cannot occur.

  4. 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.

@hqhq1025 hqhq1025 merged commit c32378b into tiann:main Apr 12, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Preserve composer input text when switching sessions

2 participants