Repository navigation
fix(ui-web): keep each conversation's staged files with its own draft - #619
Conversation
The composer's attachment tray was one page-wide array that no conversation switch parked, loaded or cleared. A file staged in one conversation stayed on screen after switching, armed the send button there, and rode out with the next message sent from the other conversation, leaving the first one empty. An upload that landed or failed after a switch wrote whatever tray was open, so a failed chip could stay pinned to the conversation it was staged in and refuse every later send there. Key the tray by the same owner the text draft already uses: a module-level map of trays with one writer, swapped in by loadDraft alongside the text, re-keyed by claimDraft when a new draft becomes a session, and dropped with the draft. The staging paths capture their owner once, so a late upload lands in the tray it was staged in. Only the open conversation's tray is painted, and an empty tray replacing an empty one does not repaint, so a rail click on attachment-free conversations stays as cheap as before. A slash command drops its draft's tray along with the text and repaints, so the screen and the store cannot disagree. Attachments are not persisted across a reload the way text drafts are: an image chip's data URL alone can exceed the storage quota, and an upload still in flight has no meaning after a reload. Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
gloryfromca
left a comment
There was a problem hiding this comment.
No blockers; this can merge as far as I am concerned.
Reviewed the full github/main...HEAD diff and the surrounding composer, session-switch, draft-promotion, deletion, send, upload, and template-pick paths. I also checked the branch history, backward-compatible ownership behavior, the added tests for weakening/gaps, and the applicable AGENTS.md and Web UI architecture/domain rules. The per-owner tray is swapped, claimed, cleared, and updated consistently, including late upload completion/failure, without changing the wire boundary.
Verification:
npm test -- --run src/features/composer/ComposerPage.test.tsx(47 passed)npm run type-check(passed)npx eslint src/features/composer/store.ts src/features/composer/ComposerPage.test.tsx(passed)npm test(188 files, 2472 tests passed)git diff --check github/main...HEAD(passed)
|
Not a blocker -- the fix is right and I could not break it. One gap, and it is in the tests rather than the code: of the two places that capture Measured on What I retracted, and what noticedEach line of behaviour put back the way it was before this PR, one at a time, against The last two are the same shape in the same file. It is worth more than tidiness because of what the ghost costs. A failed pick that clears the wrong tray leaves Here is the missing test. It fails on the retraction above and passes on your code as written -- I checked both ways, 49 passed with it, it('drops a failed template pick from the tray it was staged in', async () => {
let fail: (e: unknown) => void = () => {}
const { calls } = wire({
...uploader(),
templates: {
list: () => Promise.resolve({ templates: [], available: true }),
pick: () => new Promise((_r, rej) => { fail = rej }),
pages: () => Promise.resolve({ pages: [] }),
} as unknown as ComposerSource['templates'],
})
mountTray()
store.loadDraft('a')
act(() => { store.addTemplate({ name: 'deck', label: 'Deck', size: 9 }) })
expect(store.attsPending()).toBe(1)
act(() => { store.loadDraft('b') })
await act(async () => { fail({ data: { detail: 'no such template' } }); await flush() })
act(() => { store.loadDraft('a') })
expect(store.get().atts).toEqual([])
expect(store.attsPending()).toBe(0)
expect(calls.notes.length).toBe(1)
})Two retractions that survived and should not count against you
Everything else I tried and could not break
|
…load failure The per-conversation tray made consuming the owned draft drop the tray as well, so a slash command -- /compress, or /clear before its confirmation was answered -- threw the staged files away. Consuming the text draft now takes the text only; the tray goes only when the conversation itself is deleted from the rail. A send still empties it through takeAtts. A failed upload wrote its transcript note into whichever conversation was open. The note stays in the conversation the file was staged in; once the reader has moved on, the failure is a toast instead. Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
|
Second commit pushed,
Tests: Served page: PR description updated (two commits, the second pass in Verification). |
gloryfromca
left a comment
There was a problem hiding this comment.
No blockers; this can merge as far as I am concerned.
Reviewed the delta from cb83185c12f7 and rechecked the full change against the composer, session deletion, slash-command, upload, template-pick, and transcript/toast callers. I also checked the applicable repository and Web UI architecture rules, history, backward compatibility, and whether the revised tests could mask the behavior. The split between consuming draft text and deleting a conversation now preserves staged files across slash commands while still clearing them on actual deletion, and owner-aware failure reporting avoids contaminating another conversation's transcript. The previously noted missing template mutation test does not expose a concrete defect because both async paths use the same captured owner and sayFailed helper.
Verification:
npm test -- --run src/features/composer/ComposerPage.test.tsx(48 passed)npm run type-check(passed)npx eslint src/features/composer/store.ts src/features/composer/ComposerPage.test.tsx(passed)npm test(188 files, 2473 tests passed)git diff --check github/main...HEAD(passed)
|
Not a blocker. Re-reviewed at I passed the head that had the slash-command bugI graded I did mutation-test that exact line. Retracting the Mutation testing shows the tests grip the code. It cannot tell me the spec is right, and here I read a test name as the spec. That is on me, and it is the more useful half of this reply. The new commit, retracted line by lineEach bites, and the third is the one that shows the split is the right one rather than just a smaller hammer: Still open from my last note
Not a blocker, and not worth another round on its own -- folding it into whatever you touch next is fine.
|
…#619) ## Summary A file staged in the composer followed the reader into every other conversation: the tray was one page-wide array that a conversation switch never parked, loaded or cleared, so the chips stayed on screen, armed the send button, and rode out with the next message sent from the other conversation. An upload that landed or failed after a switch wrote whatever tray was open, which could leave a permanently "uploading" chip on the conversation it was staged in and refuse every later send there. Two commits: 1. `fix(ui-web)`: the tray is keyed by the same owner the text draft already uses (the open conversation, or `new` for the first message of a draft): one map of trays with a single writer, swapped in by `loadDraft` alongside the text, re-keyed by `claimDraft` when a draft becomes a session, dropped with the conversation. Staging captures its owner once, so a late upload lands in the tray it was staged in. Only the open conversation's tray is painted, and empty-for-empty does not repaint. 2. `fix(ui-web)`: found by an adversarial pass over the served page. Consuming the owned text draft (a send, a slash command) had been made to drop the tray as well, so `/compress`, and `/clear` before its confirmation was answered, threw the staged files away. Consuming the text draft now takes the text only; the tray goes only when the conversation itself is deleted from the rail, and a send still empties it through `takeAtts`. Also, a failed upload wrote its transcript note into whichever conversation was open; the note stays in the conversation the file was staged in, and once the reader has moved on the failure is a toast instead. Decisions: attachments are not persisted across a reload (an image chip's data URL alone can exceed the storage quota; an in-flight upload means nothing after a reload); an upload in flight is not cancelled by a switch, it lands in its own tray. The sub-agent direct-chat composer keeps its own per-instance state and is unchanged. Server side is untouched: uploads are agent-home rooted and cross-session valid, which is why the symptom was a wrong attachment rather than a broken one. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification - `npm test --prefix ui-web -- src/features/composer` -- 117 passed (108 before). New cases in `describe('composer drafts')` and the tray describe: the tray follows its conversation across switches; a draft's tray is re-keyed when it becomes a session; a late upload lands in the tray it was staged in; a slash command keeps the staged file; a late failure while another conversation is open raises a toast and writes no transcript note there; a failure in the open conversation still writes the note. The slash and misrouted-note cases fail against the first commit's `store.ts`; the rest fail against the previous one. - `npm test --prefix ui-web` -- 188 files, 2473 passed. - `npm run --prefix ui-web type-check` and `npm run --prefix ui-web lint` -- clean (4 pre-existing warnings in untouched files). - Served page, isolated agent home, two conversations: before the fix a file staged in A shows in B, B's text-only message carries A's file and A's tray is emptied; after the fix B opens with an empty tray and a disarmed send, A keeps its chip and thumbnail, B's message carries no attachment, and a file staged on the new-task screen rides that draft's first message and is gone afterwards. Second pass: a file staged and `/compress` run keeps its chip; `/clear` cancelled at the confirmation keeps it; an upload refused after a switch (a 110 MB file against the 100 MB ceiling) shows a toast with the file name and reason, writes no note into the open conversation, and leaves no chip behind in the one it was staged in. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes Client-only change in one module; no wire or Python change. Staged attachments still vanish on a browser reload while text drafts survive it (unchanged from before). Rollback: revert the two commits in reverse order. ## Related Issues N/A --------- Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
Summary
A file staged in the composer followed the reader into every other conversation: the tray was one page-wide array that a conversation switch never parked, loaded or cleared, so the chips stayed on screen, armed the send button, and rode out with the next message sent from the other conversation. An upload that landed or failed after a switch wrote whatever tray was open, which could leave a permanently "uploading" chip on the conversation it was staged in and refuse every later send there.
Two commits:
fix(ui-web): the tray is keyed by the same owner the text draft already uses (the open conversation, ornewfor the first message of a draft): one map of trays with a single writer, swapped in byloadDraftalongside the text, re-keyed byclaimDraftwhen a draft becomes a session, dropped with the conversation. Staging captures its owner once, so a late upload lands in the tray it was staged in. Only the open conversation's tray is painted, and empty-for-empty does not repaint.fix(ui-web): found by an adversarial pass over the served page. Consuming the owned text draft (a send, a slash command) had been made to drop the tray as well, so/compress, and/clearbefore its confirmation was answered, threw the staged files away. Consuming the text draft now takes the text only; the tray goes only when the conversation itself is deleted from the rail, and a send still empties it throughtakeAtts. Also, a failed upload wrote its transcript note into whichever conversation was open; the note stays in the conversation the file was staged in, and once the reader has moved on the failure is a toast instead.Decisions: attachments are not persisted across a reload (an image chip's data URL alone can exceed the storage quota; an in-flight upload means nothing after a reload); an upload in flight is not cancelled by a switch, it lands in its own tray. The sub-agent direct-chat composer keeps its own per-instance state and is unchanged. Server side is untouched: uploads are agent-home rooted and cross-session valid, which is why the symptom was a wrong attachment rather than a broken one.
Type
Verification
npm test --prefix ui-web -- src/features/composer-- 117 passed (108 before). New cases indescribe('composer drafts')and the tray describe: the tray follows its conversation across switches; a draft's tray is re-keyed when it becomes a session; a late upload lands in the tray it was staged in; a slash command keeps the staged file; a late failure while another conversation is open raises a toast and writes no transcript note there; a failure in the open conversation still writes the note. The slash and misrouted-note cases fail against the first commit'sstore.ts; the rest fail against the previous one.npm test --prefix ui-web-- 188 files, 2473 passed.npm run --prefix ui-web type-checkandnpm run --prefix ui-web lint-- clean (4 pre-existing warnings in untouched files).Served page, isolated agent home, two conversations: before the fix a file staged in A shows in B, B's text-only message carries A's file and A's tray is emptied; after the fix B opens with an empty tray and a disarmed send, A keeps its chip and thumbnail, B's message carries no attachment, and a file staged on the new-task screen rides that draft's first message and is gone afterwards. Second pass: a file staged and
/compressrun keeps its chip;/clearcancelled at the confirmation keeps it; an upload refused after a switch (a 110 MB file against the 100 MB ceiling) shows a toast with the file name and reason, writes no note into the open conversation, and leaves no chip behind in the one it was staged in.Relevant tests pass locally
Relevant lint / type checks pass locally
User-facing docs or screenshots are updated when needed
Risk
Client-only change in one module; no wire or Python change. Staged attachments still vanish on a browser reload while text drafts survive it (unchanged from before). Rollback: revert the two commits in reverse order.
Related Issues
N/A