Skip to content

fix(components): keep New Chat attachments across tab switches - #410

Open
audichuang wants to merge 1 commit into
LodyAI:mainfrom
audichuang:audichuang/fix-242-chatlanding-attachments
Open

fix(components): keep New Chat attachments across tab switches#410
audichuang wants to merge 1 commit into
LodyAI:mainfrom
audichuang:audichuang/fix-242-chatlanding-attachments

Conversation

@audichuang

Copy link
Copy Markdown
Contributor

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 the
chat route unmounting. Attachments and the reserved draft session id were
component useState / useRef, and the landing unmounts on every navigation
away — 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 still
in 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 by
buildChatLandingDraftKey, so the draft outlives 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 the user's own actions — removing
one 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: uploadSessionImage takes no abort signal, so those requests already
outlived the unmount and only their result was being discarded.

The attachment key is workspace-scoped where the prompt-text key (userId) 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.

useChatLandingDraftSession now reads and writes the store directly, which also
removes the state/ref pair its synchronous ensureSessionId needed.

One more thing the hoist forced: chat-landing.tsx guarded the resetDraftKey
clear with a useRef, which is per-mount. That was harmless while the state it
cleared was per-mount too, but a New chat URL keeps its resetDraftKey in the
history 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, scoped
exactly 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

Before After
Attach an image on New Chat, visit another tab, return — text remains, attachment is gone Both remain until the user removes them, clears the draft, or sends
Leaving the landing revoked every preview blob: URL Preview URLs are revoked when that image is removed, or the draft is cleared
Leaving the landing aborted every in-flight file upload An in-flight upload keeps running and settles into the restored draft
The reserved draft session id was regenerated on every remount The reserved id is stable across the round trip, so attachments and startSession stay on one identity
Attachment state was implicitly per-mount, so workspace scope never came up Attachments are keyed per workspace; prompt text still follows the user across workspaces

Test plan

New packages/components/tests/chat-landing-draft-persistence.test.tsx mounts
the three hooks under a real jotai store and a real React root, then unmounts
and 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 AbortSignal is not aborted by the unmount and its result
lands on remount; clearing the draft (what submit-accepted and resetDraftKey
call) 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.createObjectURL is stubbed — no
timers, 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.

  • Rebased onto current main and re-verified there: applies with no conflict,
    @lody/components typechecks clean, the new suite passes (6 cases).
  • pnpm --filter @lody/components test — 420 files, 3019 tests. Four failures, none
    in a file this diff touches: tests/markdown-streaming-reparse.test.ts fails the same
    way on an unmodified main checkout, and tests/path-launchers-setting.test.tsx plus
    two tests/agent-config-dialog.test.tsx cases are 5s-timeout misses under a saturated
    box 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.
  • Typecheck passes for every workspace package including @lody/components and
    apps/cli.
  • pnpm check does not complete on this machine, for three reasons in packages
    this diff does not touch — every changed file is under packages/components,
    and each failing package's working tree is identical to main, so these were
    run against unmodified base content. The packages/acp-extension-dsh
    submodule fails tsc at src/adapter.ts:1273 with TS2352 on a
    ReadableStream cast; one @lody/turn-diff-store SQLite GC test exceeds its
    5s timeout; one @lody/electron test file needs an Electron binary that did
    not install in this sandbox. They are reported here rather than claimed as
    passing.
  • Not exercised: a manual desktop run of the reproduction steps.

Context handoff

Instructions for reviewing agents

  • Review focus: use-chat-landing-image-draft.ts and
    use-chat-landing-file-draft.ts, where the unmount cleanup effects were
    deleted — confirm every remaining revokeObjectURL / abort() path still
    fires on remove and on clearPending*, which submit and resetDraftKey call.
  • Decisions to challenge: keying attachments on the workspace slug while the
    prompt text stays keyed on userId alone, which is a visible asymmetry across
    a workspace switch; and letting a file upload continue after the user leaves
    New Chat instead of aborting it.
  • Plausible failures / evidence gaps: a draft abandoned in a visited
    workspace holds its File objects and unrevoked preview URLs for the life of
    the 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 resetDraftKey
    back-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

  • User goal / directives: fix [Bug] New chat image attachment disappears after switching tabs #242 so a New Chat attachment survives a tab
    switch the way the prompt text already does, and land it as one focused,
    tested change against main.
  • Constraints / non-goals: no localStorage persistence for attachments; no
    change to the upload protocol, the composer UI, or the submit path; reuse the
    existing landing stateKey convention rather than inventing a second keying
    scheme; do not touch the in-session composer.
  • Risk-bearing decisions: attachment state is now process-global rather than
    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 or irreversible behavior: none added. The change removes two
    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.
  • Deliberately not done or tested: no auto-retry or resumable-upload state
    for an attachment that fails while the landing is unmounted — the existing
    retry button covers it. No atomFamily eviction: entries are one per
    user+workspace visited in a session. No manual desktop reproduction; the new
    suite covers the unmount/remount round trip instead.
  • Unknowns / confidence: high on the fix and its scope — the failing case is
    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.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] New chat image attachment disappears after switching tabs

1 participant