Repository navigation
feat(web): preserve new-chat text and context across navigation and reloads - #1034
Max-Samson wants to merge 2 commits into
Conversation
Keep unsent text, composer context and attachments in memory when navigating away from New chat. Restore the draft with the caret at the end, and clear it on logout or identity changes. Fixes rome-os#1029
There was a problem hiding this comment.
🔁 This review has been superseded. See the latest review.
There was a problem hiding this comment.
🔁 This review has been superseded. See the latest review.
Suggestion: shrink the scope and keep the draft in
|
|
Follow-up to my comment above. We've settled the scope:
|
Replace the root draft Provider and controlled composer paths with composer-local state and sessionStorage recovery. - Restore exact text and selected context across navigation and reloads - Clear drafts on logout or identity changes - Prevent stale failures and consumed prefill from overwriting newer edits - Preserve restored persona until settings are ready - Keep first-send focus, early navigation and ordinary uploads unchanged - Exclude attachment retention across navigation and reloads - Add storage, hydration and request-isolated regression coverage
Jessie-QingYu
left a comment
There was a problem hiding this comment.
Code Review: 🛑 REQUEST_CHANGES
This PR keeps the New chat composer's draft, meaning its text plus the selected project, agent, skill and persona. The draft is saved in a sessionStorage record per identity, managed by a new useNewChatDraft hook and new-chat-draft-storage helper. Editing state stays local to ChatComposer. Submission clears the saved record when the send starts, and fail() restores it only when nothing newer has happened. Prefill intents (keyed by location.key) are used once across remounts. Drafts are cleared on logout and identity change via useNewChatDraftCleanup and ProfileMenu. ChatComponent now remounts Chat when the location changes in draft mode, and Chat stops a first send (session creation plus first turn) when its composer unmounts. This matches the updated scope of #1029: text and context only, sessionStorage, no root Provider. CI checks pass except layout-invariants, which is still pending.
The PR description is out of date. The head commit (27f9ffe) replaced the in-memory root Provider with sessionStorage. As a result, drafts now survive reloads, and attachments are deliberately not kept. The description claims the opposite on both points and still cites NewChatDraftProvider tests that no longer exist. In the code, the main problem is the recovery path after a session is adopted early: a failed first turn is written back into New chat storage, and nothing on the existing-session send path ever clears it. A second, smaller design issue: leaving New chat while the session is being created leaves an empty server session behind. Storage isolation looks sound. Records are keyed by owner, cleared on identity switch and logout, and parsed with zod. The rest of the race handling (revision checks, late-failure protection, intent receipts) is careful and well tested.
Verdict: REQUEST_CHANGES — If a first send creates its session but the turn POST fails, the message is restored into New chat storage and never cleared. A retry that succeeds in the session therefore leaves an already-sent message to reappear as a New chat draft.
3 finding(s) posted as inline comments below.
| Severity | Category | File | Title |
|---|---|---|---|
| P1 | bug | packages/web/src/hooks/use-new-chat-draft.ts |
Failure after session adoption leaves an already-sent message as a New chat draft |
| P2 | design | packages/web/src/components/chat/Chat.tsx |
Leaving New chat during creation orphans a server session |
| P3 | documentation | packages/web/src/lib/new-chat-draft-storage.ts |
PR description contradicts the implementation |
| // they must still defeat this older recovery snapshot. | ||
| if (edits.current !== revision || (enabled && live.current.owner !== owner)) return false; | ||
| const restored = | ||
| owner && cleared ? restoreNewChatDraft(owner, cleared.revision, snapshot) : false; |
There was a problem hiding this comment.
[P1] bug — Failure after session adoption leaves an already-sent message as a New chat draft
fail() uses the enabled/owner values captured when the send began. Suppose createSession succeeds (so persistNewChatDraft becomes false once mainSessionId is set) but postTurn fails. restoreNewChatDraft then writes the snapshot back to New chat storage, and the session composer shows the same text. If the guardian retries in the session and it succeeds, nothing clears that record: beginSend gets cleared = null when disabled. The next visit to New chat shows the already-sent message as a draft, which invites a duplicate send. Fix: skip the storage restore when live.current.enabled is false at failure time, because the session composer already holds the text locally. Add a hook test for 'adopt, then fail without edits, then New chat is empty'.
| creatingSessionRef.current = false; | ||
| } | ||
| if (!isMountedRef.current || controls.signal.aborted) | ||
| throw new DOMException("Cancelled draft", "AbortError"); |
There was a problem hiding this comment.
[P2] design — Leaving New chat during creation orphans a server session
If the composer unmounts after createSession resolves, the send now throws AbortError and restores the draft. The session already exists on the server, though, and gets no turn, and the e2e test 'late create … never posts a turn' confirms this. Each time the guardian leaves during creation and later resends, another empty "New chat" session is created. Either delete the created session (best-effort) before throwing, or keep posting the turn without calling onSessionCreated and accept the submission instead of restoring it. The second option still meets the 'no redirect' requirement without leaving a blank session.
| @@ -0,0 +1,118 @@ | |||
| import { z } from "zod"; | |||
There was a problem hiding this comment.
[P3] documentation — PR description contradicts the implementation
The description says 'Keep one in-memory draft above the route tree', 'Preserve … pending attachments', and lists 'Draft restoration after reload' under 'Not in this PR'. Its test plan also cites NewChatDraftProvider suites. The head commit does the opposite: it persists to sessionStorage (so drafts survive reload), excludes attachments, and removes the Provider. Update the description to match the actual design (and #1029's current scope) so reviewers and the changelog aren't misled.
yunfanye
left a comment
There was a problem hiding this comment.
Code Review: 🛑 REQUEST_CHANGES
The implementation fits #1029’s updated sessionStorage scope, although the PR description still describes the earlier in-memory design. Two identity-loading races need correction.
Web typecheck, 138 targeted unit tests, and seven desktop E2Es pass. Temporary mounted-composer regression tests reproduce both findings below.
Verdict: REQUEST_CHANGES — Initial identity hydration can erase saved context and prevent recovery of failed submissions.
2 finding(s) posted as inline comments below.
| Severity | Category | File | Title |
|---|---|---|---|
| P1 | error-handling | packages/web/src/hooks/use-new-chat-draft.ts |
Preserve failed sends across initial identity hydration |
| P1 | data-integrity | packages/web/src/hooks/use-new-chat-draft.ts |
Preserve untouched saved context during late hydration |
| fail() { | ||
| // After early session adoption local edits no longer save to New chat; | ||
| // they must still defeat this older recovery snapshot. | ||
| if (edits.current !== revision || (enabled && live.current.owner !== owner)) return false; |
There was a problem hiding this comment.
[P1] error-handling — Preserve failed sends across initial identity hydration
The composer allows submission while /api/auth/me is pending, so beginSend can capture owner === null and optimistically clear the text. If identity resolves before session creation or the first POST rejects, this guard treats initial hydration as an identity change and skips recovery, leaving the composer and saved draft empty. Prevent submission until draft.ready, or distinguish initial identity resolution from an actual identity reset; add a regression covering this ordering.
| const switching = hydrated.current !== null; | ||
| activateDraftOwner(owner); | ||
| const stored = readNewChatDraft(owner); | ||
| if (switching || edits.current === 0) replace(stored?.value ?? emptyNewChatDraft()); |
There was a problem hiding this comment.
[P1] data-integrity — Preserve untouched saved context during late hydration
Typing before identity resolves increments edits.current, causing hydration to skip the entire saved record and then overwrite it with local defaults at line 65. Consequently, editing only text permanently erases the saved project, agent, skill, and persona; a mounted-composer regression confirms the saved project becomes default. Merge untouched context fields from storage, or finish hydration before allowing edits.
Thanks @yunfanye for clarifying the scope and suggesting a simpler approach. Scope updated following the review discussion. Commit 27f9ffe replaces the in-memory Provider with composer-local Attachment retention, whether in memory or IndexedDB, and Issue #1029 has been updated to match this scope. I will also align |
Scope updated following the review discussion. Commit 27f9ffe replaces the in-memory Provider with composer-local |
What this PR does
Leaving New chat discards an unfinished message. This PR keeps that
draft available when the guardian returns.
Closes #1029
Design & Invariants
Conflicting prefill does not replace an existing draft. This follows the
updated silent-retention scope instead of the confirmation dialog proposed
in the issue. No dialogs or translated strings are added.
Test plan
Suites: NewChatDraftProvider, ChatComponent, ChatComposer, and Chat.
and for consumed prefill intents across identity resets.
Not in this PR