From cb83185c12f71f51a5f49658a4fa46a4a306db61 Mon Sep 17 00:00:00 2001 From: 0xKT Date: Tue, 22 Sep 2026 04:49:49 +0800 Subject: [PATCH 1/2] fix(ui-web): keep each conversation's staged files with its own draft 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) --- .../features/composer/ComposerPage.test.tsx | 165 ++++++++++++++++++ ui-web/src/features/composer/store.ts | 85 +++++++-- 2 files changed, 234 insertions(+), 16 deletions(-) diff --git a/ui-web/src/features/composer/ComposerPage.test.tsx b/ui-web/src/features/composer/ComposerPage.test.tsx index aa1c66a4b..167531045 100644 --- a/ui-web/src/features/composer/ComposerPage.test.tsx +++ b/ui-web/src/features/composer/ComposerPage.test.tsx @@ -302,6 +302,171 @@ describe('composer drafts', () => { expect(drafts().a).toBeUndefined() expect(drafts().b?.t).toBe('for b') }) + + /* A staged file is as unsent as the text typed next to it, so the tray + follows the same owner: what is in it belongs to one conversation, and the + upload that lands late belongs to the one it was staged in. */ + const stage = async (name: string): Promise => { + await act(async () => { + store.addFiles([new File(['x'], name, { type: 'text/plain' })]) + await flush() + }) + } + + const uploader = (): Partial => ({ + upload: (req) => Promise.resolve({ path: `uploads/${req.name}`, size: 4 }), + }) + + const paths = (): Array => store.get().atts.map((a) => a.path) + + it('keeps the staged files of each session with that session', async () => { + wire(uploader()) + const box = mountTray() + store.loadDraft('a') + await stage('a.txt') + expect(box.querySelectorAll('.att').length).toBe(1) + expect(go().disabled).toBe(false) + + act(() => { store.loadDraft('b') }) + expect(store.get().atts).toEqual([]) + expect(box.hidden).toBe(true) + expect(go().disabled).toBe(true) + + act(() => { store.loadDraft('a') }) + expect(box.querySelectorAll('.att').length).toBe(1) + expect(paths()).toEqual(['uploads/a.txt']) + }) + + it('does not hand one session files staged in another', async () => { + const { calls } = wire(uploader()) + const box = mountTray() + store.loadDraft('a') + await stage('a.txt') + + act(() => { store.loadDraft('b') }) + ta().value = 'nothing attached here' + act(() => { store.fireSend() }) + expect(calls.sent).toEqual(['nothing attached here']) + + act(() => { store.loadDraft('a') }) + expect(box.querySelectorAll('.att').length).toBe(1) + }) + + it('lands a late upload in the tray it was staged in', async () => { + let settle: (r: { path: string; size: number }) => void = () => {} + wire({ upload: () => new Promise((r) => { settle = r }) }) + const box = mountTray() + store.loadDraft('a') + await stage('slow.bin') + + act(() => { store.loadDraft('b') }) + await act(async () => { + settle({ path: 'uploads/slow.bin', size: 9 }) + await flush() + }) + expect(store.attsPending()).toBe(0) + expect(store.get().atts).toEqual([]) + expect(box.hidden).toBe(true) + + act(() => { store.loadDraft('a') }) + expect(store.attsPending()).toBe(0) + let taken: string[] = [] + act(() => { taken = store.takeAtts() }) + expect(taken).toEqual(['uploads/slow.bin']) + }) + + /* The other session stages one too: it is what tells a tray that forgot the + failed chip apart from one that never held it. */ + it('drops a failed upload from the tray it was staged in', async () => { + let fail: (e: unknown) => void = () => {} + const { calls } = wire({ + upload: (req) => (req.name === 'big.bin' + ? new Promise((_r, rej) => { fail = rej }) + : Promise.resolve({ path: `uploads/${req.name}`, size: 4 })), + }) + mountTray() + store.loadDraft('a') + await stage('big.bin') + + act(() => { store.loadDraft('b') }) + await stage('b.txt') + await act(async () => { + fail({ data: { detail: 'disk full' } }) + await flush() + }) + expect(calls.notes.length).toBe(1) + expect(paths()).toEqual(['uploads/b.txt']) + + act(() => { store.loadDraft('a') }) + expect(store.get().atts).toEqual([]) + expect(store.attsPending()).toBe(0) + }) + + it('files what the new-task page staged under the session that claims it', async () => { + wire(uploader()) + const box = mountTray() + store.loadDraft('new') + await stage('n.txt') + store.claimDraft('created') + + act(() => { store.loadDraft('other') }) + expect(box.querySelectorAll('.att').length).toBe(0) + act(() => { store.loadDraft('created') }) + expect(paths()).toEqual(['uploads/n.txt']) + act(() => { store.loadDraft('new') }) + expect(box.querySelectorAll('.att').length).toBe(0) + }) + + it('empties only the tray of the session that sent', async () => { + const { calls } = wire(uploader()) + const box = mountTray() + store.loadDraft('a') + await stage('a.txt') + act(() => { store.loadDraft('b') }) + await stage('b.txt') + + act(() => { store.loadDraft('a') }) + act(() => { store.fireSend() }) + expect(calls.sent[0]).toBe(`\n\n${word('gui.att.note')}\n- uploads/a.txt`) + expect(box.querySelectorAll('.att').length).toBe(0) + + act(() => { store.loadDraft('b') }) + expect(paths()).toEqual(['uploads/b.txt']) + }) + + it('forgets the files of a dropped session and leaves the others', async () => { + wire(uploader()) + mountTray() + store.loadDraft('a') + await stage('a.txt') + act(() => { store.loadDraft('b') }) + await stage('b.txt') + + store.dropDraft('a') + act(() => { store.loadDraft('a') }) + expect(store.get().atts).toEqual([]) + act(() => { store.loadDraft('b') }) + expect(paths()).toEqual(['uploads/b.txt']) + }) + + /* A slash command drops the draft it owns without leaving the conversation: + the one path that empties a tray with no switch behind it to repaint. */ + it('clears the open tray when a slash command drops its draft', async () => { + const cmd: SlashCmd = { id: 'gui.clear', fn: vi.fn() } + wire({ ...uploader(), slash: [cmd] }) + const box = mountTray() + store.loadDraft('a') + await stage('a.txt') + + act(() => { store.runSlash(cmd) }) + expect(cmd.fn).toHaveBeenCalledTimes(1) + expect(store.get().atts).toEqual([]) + expect(box.hidden).toBe(true) + expect(go().disabled).toBe(true) + + await stage('later.txt') + expect(paths()).toEqual(['uploads/later.txt']) + }) }) describe('the queue rows', () => { diff --git a/ui-web/src/features/composer/store.ts b/ui-web/src/features/composer/store.ts index 0e7df762d..59ed0d410 100644 --- a/ui-web/src/features/composer/store.ts +++ b/ui-web/src/features/composer/store.ts @@ -14,9 +14,10 @@ import type { Attachment, ComposerSource, SlashCmd, TemplateRow } from './types' /* Plain external store, same shape as the other islands: the dock is driven * by callers that are not React. The turn machine advances the phase, the - * queue drains into `send` and a session switch resets everything -- all of it - * state/session's (pipeline, runtime, registry, residency, stages) -- so the - * state lives here where those can reach it and the views subscribe. + * queue drains into `send` and a session switch swaps in that conversation's + * own draft and tray -- all of it state/session's (pipeline, runtime, registry, + * residency, stages) -- so the state lives here where those can reach it and + * the views subscribe. * * The live phase and the queue are composer state: every path that changes, * parks, or restores them goes through this island, so their ownership and @@ -24,6 +25,8 @@ import type { Attachment, ComposerSource, SlashCmd, TemplateRow } from './types' */ export interface ComposerState { + /* The open conversation's tray, mirrored for the views: what is staged + belongs to `trays`, keyed like the text draft. */ atts: Attachment[] queue: string[] /* The queue row being edited, by index, or null. The text itself stays in @@ -102,8 +105,13 @@ function draftsWrite(all: Drafts): void { } } +/* What the unsent belongs to: the open conversation, or 'new' while the first + message has no session yet. The text draft and the tray share it, so a + switch moves both. */ +const ownerKey = (): string => draftOwner || currentSession() || 'new' + export function parkDraft(): void { - const key = draftOwner || currentSession() || 'new' + const key = ownerKey() const text = field()?.value || '' const all = draftsRead() if (text.trim()) all[key] = { t: text, at: Date.now() } @@ -113,6 +121,10 @@ export function parkDraft(): void { export function loadDraft(id: string | null): void { draftOwner = id || 'new' + /* Above the guard: the tray is swapped even on the paths that run before the + field is mounted, or the files of the conversation left behind stay on + screen and ride the next message out. */ + traySet(draftOwner, trayOf(draftOwner)) const ta = field() if (!ta) return ta.value = draftsRead()[draftOwner]?.t || '' @@ -121,13 +133,25 @@ export function loadDraft(id: string | null): void { } export function dropDraft(id: string | null): void { + const key = id || 'new' const all = draftsRead() - delete all[id || 'new'] + delete all[key] draftsWrite(all) + /* Through the writer rather than the map: a slash command drops the draft it + owns without leaving the conversation, so the chips on screen have to go + with the tray behind them. */ + traySet(key, []) } export function claimDraft(id: string | null): void { - if (draftOwner === 'new') draftOwner = id || currentSession() || 'new' + if (draftOwner !== 'new') return + const key = id || currentSession() || 'new' + const staged = trays.get('new') + if (staged) { + trays.delete('new') + trays.set(key, staged) + } + draftOwner = key } export function touchDraft(): void { @@ -368,10 +392,31 @@ function trayPaint(atts: Attachment[]): void { set({ atts }) } +/* One tray per conversation, filed under the draft's key: a file staged in one + conversation is as unsent as the text typed next to it, and must not leave + with another's message. Not persisted, unlike the text: a screenshot's data + URL blows the storage quota on its own, and an upload still in flight means + nothing after a reload. */ +const trays = new Map() + +const trayOf = (key: string): Attachment[] => trays.get(key) || [] + +/* The only writer. An upload that lands after the reader has moved on writes + the tray it was staged in, and only the open one is painted. */ +function traySet(key: string, next: Attachment[]): void { + if (next.length) trays.set(key, next) + else trays.delete(key) + if (key !== ownerKey()) return + /* Empty replacing empty is not a change: every rail click would otherwise + repaint the dock of conversations that never staged a file. */ + if (!next.length && !get().atts.length) return + trayPaint(next) +} + export function removeAtt(i: number): void { const atts = get().atts.slice() atts.splice(i, 1) - trayPaint(atts) + traySet(ownerKey(), atts) goPaint() } @@ -383,7 +428,7 @@ export const attsPending = (): number => get().atts.filter((a) => a.uploading).l itself is the record of what was handed over from here on. */ export function takeAtts(): string[] { const paths = get().atts.map((a) => String(a.path || '')).filter(Boolean) - trayPaint([]) + traySet(ownerKey(), []) goPaint() return paths } @@ -420,8 +465,11 @@ export function canPickTemplate(): boolean { export function addTemplate(row: TemplateRow): void { const api = source().templates if (!api) return + /* Captured once, so what the pick lands in is the conversation it was picked + for however long the copy takes. */ + const owner = ownerKey() const entry: Attachment = { name: `${row.name}.pptx`, size: row.size, uploading: true, path: null, url: row.cover || null } - trayPaint(get().atts.concat([entry])) + traySet(owner, trayOf(owner).concat([entry])) goPaint() api.pick(row.name) .then((r) => { @@ -429,11 +477,11 @@ export function addTemplate(row: TemplateRow): void { entry.size = r.size entry.uploading = false if (entry.url) attachmentCache.set(r.path, entry.url) - trayPaint(get().atts.slice()) + traySet(owner, trayOf(owner).slice()) goPaint() }) .catch((e: unknown) => { - trayPaint(get().atts.filter((a) => a !== entry)) + traySet(owner, trayOf(owner).filter((a) => a !== entry)) goPaint() transcriptNote(t('gui.tpl.fail', { name: row.label }), failDetail(e)) }) @@ -445,13 +493,17 @@ export function addTemplate(row: TemplateRow): void { export function addFiles(files: ArrayLike): void { const up = source().upload if (!up) return + /* Captured once, before the read and the round trip: an upload that lands or + fails after the reader has switched conversations belongs to the tray it + was staged in, and a failure must clear the chip there rather than leave a + ghost that refuses that conversation every later send. */ + const owner = ownerKey() Array.from(files).forEach((file) => { const entry: Attachment = { name: file.name, size: file.size, uploading: true, path: null, url: null } - trayPaint(get().atts.concat([entry])) + traySet(owner, trayOf(owner).concat([entry])) goPaint() const drop = (): void => { - const rest = get().atts.filter((a) => a !== entry) - trayPaint(rest) + traySet(owner, trayOf(owner).filter((a) => a !== entry)) goPaint() } const reader = new FileReader() @@ -467,7 +519,7 @@ export function addFiles(files: ArrayLike): void { entry.size = r.size entry.uploading = false if (entry.url) attachmentCache.set(r.path, entry.url) - trayPaint(get().atts.slice()) + traySet(owner, trayOf(owner).slice()) goPaint() }) .catch((e: unknown) => { @@ -522,8 +574,8 @@ export function runSlash(x: SlashCmd | undefined): void { const ta = field() if (ta) ta.value = '' fitField() - goPaint() dropOwnedDraft() + goPaint() x.fn() } @@ -654,5 +706,6 @@ export function _resetForTests(): void { liveT0 = 0 draftOwner = null draftTick = null + trays.clear() store.set({ ...initial }) } From c63e307d65c4007ae5dcbcf2805314ca6f6d3007 Mon Sep 17 00:00:00 2001 From: 0xKT <74288668+0xKT@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:52:14 +0800 Subject: [PATCH 2/2] fix(ui-web): keep staged files across slash commands, toast a late upload 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) --- .../features/composer/ComposerPage.test.tsx | 45 +++++++++++++++---- ui-web/src/features/composer/store.ts | 34 ++++++++++---- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/ui-web/src/features/composer/ComposerPage.test.tsx b/ui-web/src/features/composer/ComposerPage.test.tsx index 167531045..02eb97cce 100644 --- a/ui-web/src/features/composer/ComposerPage.test.tsx +++ b/ui-web/src/features/composer/ComposerPage.test.tsx @@ -394,7 +394,8 @@ describe('composer drafts', () => { fail({ data: { detail: 'disk full' } }) await flush() }) - expect(calls.notes.length).toBe(1) + expect(calls.notes).toEqual([]) + expect(calls.toasts).toEqual(['big.bin 上传失败 · disk full']) expect(paths()).toEqual(['uploads/b.txt']) act(() => { store.loadDraft('a') }) @@ -449,23 +450,27 @@ describe('composer drafts', () => { expect(paths()).toEqual(['uploads/b.txt']) }) - /* A slash command drops the draft it owns without leaving the conversation: - the one path that empties a tray with no switch behind it to repaint. */ - it('clears the open tray when a slash command drops its draft', async () => { + /* A slash command takes the text it owns and nothing else: compressing the + context is not a reset, and /clear asks before it destroys anything. */ + it('leaves the staged files where they are when a slash command runs', async () => { const cmd: SlashCmd = { id: 'gui.clear', fn: vi.fn() } wire({ ...uploader(), slash: [cmd] }) const box = mountTray() store.loadDraft('a') + ta().value = 'typed' await stage('a.txt') act(() => { store.runSlash(cmd) }) expect(cmd.fn).toHaveBeenCalledTimes(1) - expect(store.get().atts).toEqual([]) - expect(box.hidden).toBe(true) - expect(go().disabled).toBe(true) + expect(ta().value).toBe('') + expect(paths()).toEqual(['uploads/a.txt']) + expect(box.querySelectorAll('.att').length).toBe(1) + expect(go().disabled).toBe(false) - await stage('later.txt') - expect(paths()).toEqual(['uploads/later.txt']) + act(() => { store.loadDraft('b') }) + expect(store.get().atts).toEqual([]) + act(() => { store.loadDraft('a') }) + expect(paths()).toEqual(['uploads/a.txt']) }) }) @@ -720,6 +725,28 @@ describe('the attachment tray', () => { }) expect(box.querySelectorAll('.att').length).toBe(0) expect(calls.notes).toEqual([['big.bin 上传失败', 'disk full']]) + expect(calls.toasts).toEqual([]) + }) + + /* The note is written into a transcript, so it can only be written while the + conversation it belongs to is the one on screen. */ + it('says a late failure in a toast rather than another session\'s transcript', async () => { + let fail: (e: unknown) => void = () => {} + const { calls } = wire({ upload: () => new Promise((_r, rej) => { fail = rej }) }) + mountTray() + store.loadDraft('a') + await act(async () => { + store.addFiles([new File(['x'], 'big.bin', { type: '' })]) + await flush() + }) + + act(() => { store.loadDraft('b') }) + await act(async () => { + fail({ data: { detail: 'disk full' } }) + await flush() + }) + expect(calls.notes).toEqual([]) + expect(calls.toasts).toEqual(['big.bin 上传失败 · disk full']) }) it('hands the staged paths over and empties the tray when the message leaves', async () => { diff --git a/ui-web/src/features/composer/store.ts b/ui-web/src/features/composer/store.ts index 59ed0d410..0ef00768a 100644 --- a/ui-web/src/features/composer/store.ts +++ b/ui-web/src/features/composer/store.ts @@ -132,14 +132,18 @@ export function loadDraft(id: string | null): void { goPaint() } -export function dropDraft(id: string | null): void { - const key = id || 'new' +function dropDraftText(key: string): void { const all = draftsRead() delete all[key] draftsWrite(all) - /* Through the writer rather than the map: a slash command drops the draft it - owns without leaving the conversation, so the chips on screen have to go - with the tray behind them. */ +} + +/* The conversation itself is gone -- this is the rail's delete -- so its tray + goes with its text. Through the writer rather than the map, because the one + deleted may be the conversation on screen. */ +export function dropDraft(id: string | null): void { + const key = id || 'new' + dropDraftText(key) traySet(key, []) } @@ -159,10 +163,15 @@ export function touchDraft(): void { draftTick = setTimeout(parkDraft, 250) } +/* Consuming the draft this conversation owns -- a send, a slash command -- takes + the text only: what is staged has not been handed to anyone yet, and a command + that compresses or clears the history is not the reader saying to throw the + files away. `fireSend` empties the tray itself, through `takeAtts`, because + the message it builds is where those files went. */ export function dropOwnedDraft(): void { if (draftTick) clearTimeout(draftTick) draftTick = null - dropDraft(draftOwner) + dropDraftText(draftOwner || 'new') } /* A file on its own is a message -- "look at this" is what dropping it already @@ -438,6 +447,15 @@ const failDetail = (e: unknown): string => { return (o && o.data && o.data.detail) || (o && o.message) || String(e) } +/* Where a failed upload is reported: the transcript of the conversation it was + staged in, or a toast once the reader has moved on -- a note written then + would land in whatever conversation is open instead of that one. */ +function sayFailed(owner: string, label: string, e: unknown): void { + const detail = failDetail(e) + if (owner === ownerKey()) transcriptNote(label, detail) + else toast(`${label} · ${detail}`) +} + /* Whether files can be staged at all. False on the demo canvas, which has nowhere to put the bytes -- so it does not offer a drop target either. */ export function canAttach(): boolean { @@ -483,7 +501,7 @@ export function addTemplate(row: TemplateRow): void { .catch((e: unknown) => { traySet(owner, trayOf(owner).filter((a) => a !== entry)) goPaint() - transcriptNote(t('gui.tpl.fail', { name: row.label }), failDetail(e)) + sayFailed(owner, t('gui.tpl.fail', { name: row.label }), e) }) } @@ -524,7 +542,7 @@ export function addFiles(files: ArrayLike): void { }) .catch((e: unknown) => { drop() - transcriptNote(t('gui.att.fail', { name: file.name }), failDetail(e)) + sayFailed(owner, t('gui.att.fail', { name: file.name }), e) }) } reader.onerror = drop