diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index c0527ce6bc..1e96c89175 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -250,7 +250,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses syncAttachmentsForSubmit, updateSessionState: (sessionId, updater) => sessionTileDelegate()!.updateSession(sessionId, updater), scope: { - clearAttachments: scope.attachments.clear, + removeAttachments: attachments => scope.attachments.removeOccurrences(attachments), readAttachments: () => scope.attachments.$attachments.get(), // Busy/messages flow through updateSession -> the tile's state slice; // the primary view atoms must never see a tile turn. diff --git a/apps/desktop/src/app/chat/session-tile-attachments.test.tsx b/apps/desktop/src/app/chat/session-tile-attachments.test.tsx index 3c1af11ff5..206d973961 100644 --- a/apps/desktop/src/app/chat/session-tile-attachments.test.tsx +++ b/apps/desktop/src/app/chat/session-tile-attachments.test.tsx @@ -206,4 +206,41 @@ describe('session tile attachment occurrence ownership', () => { expect(scope.attachments.$attachments.get()).toEqual([replacement]) }) + + it('preserves a same-path replacement added while a successful tile submit is in flight', async () => { + const scope = createScope() + const original = makeAttachment() + const replacement = makeAttachment() + const attach = deferred<{ attached: boolean; path: string }>() + + requestGateway.mockImplementation(async (method: string) => { + if (method === 'image.attach_bytes') { + return attach.promise + } + + return {} + }) + + scope.attachments.add(original) + + const { result } = renderHook(() => + useSessionTileActions({ runtimeId: RUNTIME_ID, scope, storedSessionId: STORED_ID }) + ) + + let submitted!: Promise + act(() => { + submitted = result.current.submitText('describe this') + }) + + await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('image.attach_bytes', expect.anything())) + scope.attachments.remove(original.id) + scope.attachments.add(replacement) + attach.resolve({ attached: true, path: STAGED_PATH }) + + await act(async () => { + await expect(submitted).resolves.toBe(true) + }) + + expect(scope.attachments.$attachments.get()).toEqual([replacement]) + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 5994893eab..37fc959b80 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -2517,6 +2517,7 @@ describe('usePromptActions restoreToMessage', () => { describe('usePromptActions file attachment sync', () => { afterEach(() => { cleanup() + $composerAttachments.set([]) $connection.set(null) $currentCwd.set('') vi.restoreAllMocks() @@ -2725,6 +2726,68 @@ describe('usePromptActions file attachment sync', () => { }) }) + it('preserves a same-path replacement added while a successful main submit is in flight', async () => { + $connection.set({ mode: 'local' } as never) + $currentCwd.set('/root') + + const hostPath = 'C:\\Users\\alice\\Pictures\\photo.jpg' + + const original: ComposerAttachment = { + detail: hostPath, + id: 'image:photo.jpg', + kind: 'image', + label: 'photo.jpg', + occurrenceId: 'occurrence-original', + path: hostPath + } + + const replacement: ComposerAttachment = { + ...original, + occurrenceId: 'occurrence-replacement' + } + + let resolveAttach!: (value: { attached: boolean; path: string }) => void + + const attachResult = new Promise<{ attached: boolean; path: string }>(resolve => { + resolveAttach = resolve + }) + + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl: vi.fn(async () => 'data:image/jpeg;base64,aGVsbG8=') } + }) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'image.attach_bytes') { + return (await attachResult) as never + } + + return {} as never + }) + + $composerAttachments.set([original]) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> + ) + + let submitted!: Promise + act(() => { + submitted = handle!.submitTextRaw('describe this') + }) + + await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('image.attach_bytes', expect.anything())) + $composerAttachments.set([replacement]) + resolveAttach({ attached: true, path: '/root/tmp/photo.jpg' }) + + await act(async () => { + await expect(submitted).resolves.toBe(true) + }) + + expect($composerAttachments.get()).toEqual([replacement]) + }) + it('uploads file bytes when the terminal backend is a container (docker)', async () => { // Container backends have their own filesystem: the host drop path would // dangle inside the sandbox, so the bytes must cross via file.attach's diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index aa6d4a3210..68e8c8bcd2 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -14,8 +14,8 @@ import { } from '@/lib/voice-playback' import { $composerAttachments, - clearComposerAttachments, type ComposerAttachment, + mainComposerScope, terminalContextBlocksFromDraft } from '@/store/composer' import { $hudMode } from '@/store/hud' @@ -77,7 +77,7 @@ interface SubmitPromptDeps { * (defaults); a session tile injects its own so a tile submit never writes * the primary view's $busy/$messages or clears the main attachment chips. */ scope?: { - clearAttachments: () => void + removeAttachments: (attachments: readonly ComposerAttachment[]) => void readAttachments: () => ComposerAttachment[] setAwaitingResponse: (awaiting: boolean) => void setBusy: (busy: boolean) => void @@ -88,7 +88,7 @@ interface SubmitPromptDeps { // Stable identity — a fresh default object per render would churn the // useCallback below on every render. const MAIN_SUBMIT_SCOPE: NonNullable = { - clearAttachments: clearComposerAttachments, + removeAttachments: attachments => mainComposerScope.removeOccurrences(attachments), readAttachments: () => $composerAttachments.get(), setAwaitingResponse, setBusy, @@ -714,7 +714,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } if (usingComposerAttachments) { - scope.clearAttachments() + // A submit owns only the occurrences captured before its first await. + // Preserve chips added or replaced while staging / prompt.submit was + // in flight instead of clearing the composer's newer draft state. + scope.removeAttachments(attachments) } // Submit landed — the turn now runs (busy stays true), but the submit diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts index 5213613652..e1f040742f 100644 --- a/apps/desktop/src/store/composer.test.ts +++ b/apps/desktop/src/store/composer.test.ts @@ -137,6 +137,44 @@ describe('updateComposerAttachment', () => { thumbnailUrl: 'data:image/png;base64,current' }) }) + + it('removes submitted occurrences while preserving unrelated attachments', () => { + const scope = createComposerAttachmentScope() + + const submitted = attachment({ + id: 'image:submitted', + kind: 'image', + occurrenceId: 'occurrence-submitted' + }) + + const other = attachment({ id: 'file:other', occurrenceId: 'occurrence-other' }) + + scope.add(submitted) + scope.add(other) + scope.removeOccurrences([submitted]) + + expect(scope.$attachments.get()).toEqual([other]) + }) + + it('preserves a same-id replacement of a submitted occurrence', () => { + const scope = createComposerAttachmentScope() + + const submitted = attachment({ + id: 'image:submitted', + kind: 'image', + occurrenceId: 'occurrence-submitted' + }) + + const replacement = attachment({ + ...submitted, + occurrenceId: 'occurrence-replacement' + }) + + scope.add(replacement) + scope.removeOccurrences([submitted]) + + expect(scope.$attachments.get()).toEqual([replacement]) + }) }) describe('session drafts', () => { diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 5d3c0b8b49..69be0df417 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -64,6 +64,7 @@ export interface ComposerAttachmentScope { add(attachment: ComposerAttachment): void clear(): void remove(id: string): ComposerAttachment | null + removeOccurrences(attachments: readonly ComposerAttachment[]): void setUploadState(id: string, uploadState?: ComposerAttachment['uploadState']): void update(attachment: ComposerAttachment): boolean updateIfCurrent(expected: ComposerAttachment, patch: ComposerAttachmentPatch): boolean @@ -99,6 +100,20 @@ export function createComposerAttachmentScope($attachments = atom `${attachment.id}\u0000${attachment.occurrenceId ?? ''}`)) + + const next = current.filter( + attachment => !submitted.has(`${attachment.id}\u0000${attachment.occurrenceId ?? ''}`) + ) + + // Preserve clear()'s notification semantics even when no captured + // occurrence remains. Some composer consumers settle local state on the + // successful-submit store emission. + $attachments.set(next) + }, setUploadState(id, uploadState) { const current = $attachments.get() const index = current.findIndex(attachment => attachment.id === id)