fix(components): keep New Chat attachments across tab switches - #410
Open
audichuang wants to merge 1 commit into
Open
fix(components): keep New Chat attachments across tab switches#410audichuang wants to merge 1 commit into
audichuang wants to merge 1 commit into
Conversation
The landing draft was split across two lifetimes. Prompt text lived in `chatLandingSessionStateAtomFamily`, a module-level atom, so it survived the chat route unmounting. Attachments and the reserved draft session id were component `useState`/`useRef`, so visiting another tab destroyed them — and both hooks ran an unmount cleanup that made the loss irreversible even if the state had been kept: the image hook revoked every preview `blob:` URL and the file hook aborted every upload still in flight. The user came back to their own text with the picture gone, and nothing said so. Both attachment lists and the reserved session id now live in module-level atoms (`atoms/chat-landing-draft.ts`), so they outlive the route exactly as the text does. Deliberately not `atomWithStorage`: a `blob:` URL and an `AbortController` do not serialize, and losing a draft attachment when the app restarts is expected. With the state kept, the unmount cleanups had to go — `URL.revokeObjectURL` and `AbortController.abort()` now belong only to removing one attachment or clearing the whole draft (send accepted, draft reset). An upload in flight when the user leaves keeps running and settles into the atom, so returning shows the finished attachment. That costs nothing new for images: `uploadSessionImage` takes no signal, so those requests already outlived the unmount and only their result was discarded. The attachment key is workspace-scoped where the prompt-text key is not, because an `imageId`/`fileId` is addressable only inside the workspace it was uploaded to — carrying one into a session created elsewhere would attach a block pointing at another workspace's object. It scopes on the workspace slug rather than the resolved id: `useResolvedWorkspaceScope()` reports `null` until the workspace resolves, and a key that flipped mid-mount would strand whatever was added first. So text still follows the user across a workspace switch and attachments do not, which is the intended asymmetry. `useChatLandingDraftSession` reads and writes the store directly, which also removes the state/ref pair its synchronous `ensureSessionId` needed. Mobile is unaffected: `mobile-workspace-stack.tsx` keeps the landing mounted beneath the session drawer, so it never had this bug. The hoist also forces one guard to move. `chat-landing.tsx` tracked the applied `resetDraftKey` in a `useRef`, which is per-mount. That was harmless while the state it cleared was per-mount too, but a New chat URL keeps that key in its history entry, so navigating back re-applied the reset and cleared the draft this change preserves. The marker moves into `chatLandingAppliedResetKeyAtomFamily`, scoped exactly like the draft it guards. Closes LodyAI#242 Model: claude-opus-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related issue
Closes #242
Problem / pressure
On the New Chat landing, an image attached to the composer disappears when the
user visits another tab and comes back. The text stays. Nothing tells them the
picture is gone, so the message ships without the context it was written around.
The draft was split across two lifetimes. Prompt text lives in
chatLandingSessionStateAtomFamily, a module-level atom, so it survives thechat route unmounting. Attachments and the reserved draft session id were
component
useState/useRef, and the landing unmounts on every navigationaway — so they were gone. Worse, both attachment hooks ran an unmount cleanup
that made the loss irreversible even if the state had been kept: the image hook
revoked every preview
blob:URL, and the file hook aborted every upload stillin flight.
Summary
Both pending-attachment lists and the reserved draft session id move to
module-level atoms in a new
atoms/chat-landing-draft.ts, keyed bybuildChatLandingDraftKey, so the draft outlives the route exactly as the textdoes. Deliberately not
atomWithStorage: ablob:URL and anAbortControllerdo not serialize, and losing a draft attachment when the app restarts is
expected.
With the state kept, the unmount cleanups had to go.
URL.revokeObjectURLandAbortController.abort()now belong only to the user's own actions — removingone attachment, or clearing the whole draft on send-accepted / draft reset. An
upload still in flight when the user leaves keeps running and settles into the
atom, so returning shows the finished attachment. For images this costs nothing
new:
uploadSessionImagetakes no abort signal, so those requests alreadyoutlived the unmount and only their result was being discarded.
The attachment key is workspace-scoped where the prompt-text key (
userId) isnot, because an
imageId/fileIdis addressable only inside the workspace itwas uploaded to; carrying one into a session created elsewhere would attach a
block pointing at another workspace's object. It scopes on the workspace slug
rather than the resolved id:
useResolvedWorkspaceScope()reportsnulluntilthe workspace resolves, and a key that flipped mid-mount would strand whatever
was added first.
useChatLandingDraftSessionnow reads and writes the store directly, which alsoremoves the state/ref pair its synchronous
ensureSessionIdneeded.One more thing the hoist forced:
chat-landing.tsxguarded theresetDraftKeyclear with a
useRef, which is per-mount. That was harmless while the state itcleared was per-mount too, but a
New chatURL keeps itsresetDraftKeyin thehistory entry, so navigating back to it would re-apply the same reset and destroy
the very draft this change preserves — revoking its preview URLs and aborting its
uploads. The marker moves into
chatLandingAppliedResetKeyAtomFamily, scopedexactly like the draft it guards.
This is 515 additions + 84 deletions, over the 200-line size threshold. It does
not split usefully: the three hooks share one reserved session id, and shipping
the image half without the file half (or the state hoist without the cleanup
move) leaves a half-migrated draft that is worse than the bug. 320 of those
lines are the new test file; the production change is ~120 lines across six
files. The Issue is linked, as the policy requires.
Before / after
blob:URLstartSessionstay on one identityTest plan
New
packages/components/tests/chat-landing-draft-persistence.test.tsxmountsthe three hooks under a real
jotaistore and a real React root, then unmountsand remounts to reproduce the tab switch. Six cases: images and the reserved
session id survive with the same preview URL; a preview URL is revoked when that
image is removed; an image upload in flight at unmount finishes into the restored
draft; a file upload's
AbortSignalis not aborted by the unmount and its resultlands on remount; clearing the draft (what submit-accepted and
resetDraftKeycall) still revokes the preview URL, aborts the upload, and stays cleared across
a remount; drafts in two workspaces stay separate. Uploads are injected deferred
promises resolved by explicit signals, and
URL.createObjectURLis stubbed — notimers, sleeps, or microtask counting. Re-running the suite with the two unmount
cleanups restored fails four cases, so the tests bind the behavior rather than
the implementation.
mainand re-verified there: applies with no conflict,@lody/componentstypechecks clean, the new suite passes (6 cases).pnpm --filter @lody/components test— 420 files, 3019 tests. Four failures, nonein a file this diff touches:
tests/markdown-streaming-reparse.test.tsfails the sameway on an unmodified
maincheckout, andtests/path-launchers-setting.test.tsxplustwo
tests/agent-config-dialog.test.tsxcases are 5s-timeout misses under a saturatedbox that pass in isolation (33/33). Reported rather than worked around.
pnpm lint(oxlint, type-aware) — 0 errors.pnpm lint:i18n,check:code-collab-imports,check:platform-boundaries,check:public-boundary— all pass.@lody/componentsandapps/cli.pnpm checkdoes not complete on this machine, for three reasons in packagesthis diff does not touch — every changed file is under
packages/components,and each failing package's working tree is identical to
main, so these wererun against unmodified base content. The
packages/acp-extension-dshsubmodule fails
tscatsrc/adapter.ts:1273withTS2352on aReadableStreamcast; one@lody/turn-diff-storeSQLite GC test exceeds its5s timeout; one
@lody/electrontest file needs an Electron binary that didnot install in this sandbox. They are reported here rather than claimed as
passing.
Context handoff
Instructions for reviewing agents
use-chat-landing-image-draft.tsanduse-chat-landing-file-draft.ts, where the unmount cleanup effects weredeleted — confirm every remaining
revokeObjectURL/abort()path stillfires on remove and on
clearPending*, which submit andresetDraftKeycall.prompt text stays keyed on
userIdalone, which is a visible asymmetry acrossa workspace switch; and letting a file upload continue after the user leaves
New Chat instead of aborting it.
workspace holds its
Fileobjects and unrevoked preview URLs for the life ofthe page (bounded at 8 images per workspace key), and an upload that finishes
for a draft the user never sends leaves an unreferenced object in storage —
both accepted for a draft that is meant to still be there. The
resetDraftKeyback-nav path has no test — the guard is in the component, the suite is
hook-level — and is instead correct by construction, the marker now sharing the
draft's scope. Verified by the new suite, not a manual desktop run.
Authoring context
switch the way the prompt text already does, and land it as one focused,
tested change against
main.change to the upload protocol, the composer UI, or the submit path; reuse the
existing landing
stateKeyconvention rather than inventing a second keyingscheme; do not touch the in-session composer.
per-mount, so the key is what keeps two workspaces apart — it is workspace-
scoped on purpose, and on the slug rather than the resolved id so it cannot
flip mid-mount. Dropping the unmount abort means a file upload survives the
user leaving the landing.
destructive unmount paths (revoking preview URLs, aborting uploads) and leaves
those operations on the user-initiated remove and clear paths, which are
unchanged. No migration, no persisted format, nothing to roll back beyond
reverting the commit.
for an attachment that fails while the landing is unmounted — the existing
retry button covers it. No
atomFamilyeviction: entries are one peruser+workspace visited in a session. No manual desktop reproduction; the new
suite covers the unmount/remount round trip instead.
reproduced and pinned by tests, the production change is ~90 lines, and the
landing has exactly two mount sites in this repository (the desktop chat route
and the mobile workspace stack, the latter unaffected because it keeps the
landing mounted). The residual judgment call is the workspace-key asymmetry
described above.