From 80b2e98f8d01ac55d9e4f2074e3c911a81f5a8f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 1 Jun 2026 16:47:52 +0800 Subject: [PATCH] fix: preserve composer state for browser screenshots (#678) Browser screenshot capture reused composer prefill, which replaced local draft text and remained queued after the composer consumed it. The fix keeps rewind-style prefill as replacement while letting Browser screenshots append as one-shot composer attachments. Constraint: Screenshot events can arrive while the chat composer is unmounted during Settings navigation. Rejected: Store browser screenshots only in composerDraft | mounted composers would not update immediately. Confidence: high Scope-risk: narrow Directive: Keep Browser screenshot capture on append mode; replacement prefill is for rewind-style composer restoration. Tested: bun run test -- ChatInput.test.tsx previewEvents.test.ts --run Tested: bun run check:desktop Not-tested: Live Windows desktop/Tauri screenshot click --- .../src/components/chat/ChatInput.test.tsx | 66 +++++++++++++++++++ desktop/src/components/chat/ChatInput.tsx | 49 +++++++++----- desktop/src/lib/previewEvents.test.ts | 1 + desktop/src/lib/previewEvents.ts | 1 + desktop/src/stores/chatStore.ts | 16 ++++- 5 files changed, 114 insertions(+), 19 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index c0236c6e..a94a7138 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -316,6 +316,72 @@ describe('ChatInput file mentions', () => { }) }) + it('appends a delayed browser screenshot without clearing an unsent draft after remount', async () => { + const { unmount } = render() + + const input = screen.getByRole('textbox') as HTMLTextAreaElement + fireEvent.change(input, { + target: { value: 'draft written while the agent is still running', selectionStart: 44 }, + }) + expect(input.value).toBe('draft written while the agent is still running') + + unmount() + + act(() => { + useChatStore.getState().queueComposerPrefill(sessionId, { + text: '', + mode: 'append', + attachments: [{ + type: 'image', + name: 'screenshot-full.png', + mimeType: 'image/png', + data: 'data:image/png;base64,DELAYED', + }], + }) + }) + + render() + + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveValue('draft written while the agent is still running') + expect(screen.getByAltText('screenshot-full.png')).toBeInTheDocument() + }) + }) + + it('does not replay a handled browser screenshot after the composer remounts', async () => { + const { unmount } = render() + + act(() => { + useChatStore.getState().queueComposerPrefill(sessionId, { + text: '', + mode: 'append', + attachments: [{ + type: 'image', + name: 'screenshot-full.png', + mimeType: 'image/png', + data: 'data:image/png;base64,OLD', + }], + }) + }) + + await waitFor(() => { + expect(screen.getByAltText('screenshot-full.png')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByLabelText('Remove screenshot-full.png')) + + await waitFor(() => { + expect(screen.queryByAltText('screenshot-full.png')).not.toBeInTheDocument() + }) + + unmount() + render() + + await waitFor(() => { + expect(screen.queryByAltText('screenshot-full.png')).not.toBeInTheDocument() + }) + }) + it('shows branch and worktree launch controls for an empty active Git session', async () => { useSessionStore.setState({ sessions: [{ diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 1a03397c..c7e87975 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -121,7 +121,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro return next }) }, []) - const { sendMessage, stopGeneration, clearComposerInsertion } = useChatStore() + const { sendMessage, stopGeneration, clearComposerPrefill, clearComposerInsertion } = useChatStore() const activeTabId = useTabStore((s) => s.activeTabId) const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) const chatState = sessionState?.chatState ?? 'idle' @@ -230,21 +230,25 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro }, [isActive]) useEffect(() => { - if (!composerPrefill) return + if (!composerPrefill || !activeTabId) return - setComposerInput(composerPrefill.text) - setComposerAttachments( - (composerPrefill.attachments ?? []) - .filter((attachment) => attachment.type === 'image' || attachment.data) - .map((attachment, index) => ({ - id: `rewind-prefill-${composerPrefill.nonce}-${index}`, - name: attachment.name, - type: attachment.type, - mimeType: attachment.mimeType, - previewUrl: attachment.type === 'image' ? attachment.data : undefined, - data: attachment.data, - })), - ) + const nextAttachments = (composerPrefill.attachments ?? []) + .filter((attachment) => attachment.type === 'image' || attachment.data) + .map((attachment, index) => ({ + id: `composer-prefill-${composerPrefill.nonce}-${index}`, + name: attachment.name, + type: attachment.type, + mimeType: attachment.mimeType, + previewUrl: attachment.type === 'image' ? attachment.data : undefined, + data: attachment.data, + })) + + if (composerPrefill.mode === 'append') { + setComposerAttachments((previous) => [...previous, ...nextAttachments]) + } else { + setComposerInput(composerPrefill.text) + setComposerAttachments(nextAttachments) + } setPlusMenuOpen(false) setSlashMenuOpen(false) setFileSearchOpen(false) @@ -255,10 +259,19 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro requestAnimationFrame(() => { const el = textareaRef.current el?.focus() - const cursor = composerPrefill.text.length - el?.setSelectionRange(cursor, cursor) + if (composerPrefill.mode !== 'append') { + const cursor = composerPrefill.text.length + el?.setSelectionRange(cursor, cursor) + } }) - }, [composerPrefill, setComposerAttachments, setComposerInput]) + clearComposerPrefill(activeTabId, composerPrefill.nonce) + }, [ + activeTabId, + clearComposerPrefill, + composerPrefill, + setComposerAttachments, + setComposerInput, + ]) useEffect(() => { if (!composerInsertion || !activeTabId || isMemberSession) return diff --git a/desktop/src/lib/previewEvents.test.ts b/desktop/src/lib/previewEvents.test.ts index 54d3f6c7..02fb020d 100644 --- a/desktop/src/lib/previewEvents.test.ts +++ b/desktop/src/lib/previewEvents.test.ts @@ -38,6 +38,7 @@ describe('subscribePreviewEvents', () => { await subscribePreviewEvents('s1') listeners['preview://event']!({ payload: JSON.stringify({ v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,AAAA', kind: 'full' }) }) expect(prefill).toHaveBeenCalledWith('s1', expect.objectContaining({ + mode: 'append', attachments: [expect.objectContaining({ type: 'image', data: 'data:image/png;base64,AAAA' })], })) }) diff --git a/desktop/src/lib/previewEvents.ts b/desktop/src/lib/previewEvents.ts index 02c4b1c7..420fed48 100644 --- a/desktop/src/lib/previewEvents.ts +++ b/desktop/src/lib/previewEvents.ts @@ -19,6 +19,7 @@ export async function subscribePreviewEvents(sessionId: string): Promise<() => v else if (msg.type === 'screenshot' && msg.dataUrl) { useChatStore.getState().queueComposerPrefill(sessionId, { text: '', + mode: 'append', attachments: [{ type: 'image', name: `screenshot-${kindLabel(msg.kind)}.png`, mimeType: 'image/png', data: msg.dataUrl }], }) } diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index c370c053..11f01147 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -53,6 +53,8 @@ export type ComposerReferenceInsertion = { nonce: number } +export type ComposerPrefillMode = 'replace' | 'append' + export type PerSessionState = { messages: UIMessage[] chatState: ChatState @@ -87,6 +89,7 @@ export type PerSessionState = { composerPrefill?: { text: string attachments?: UIAttachment[] + mode?: ComposerPrefillMode nonce: number } | null composerInsertion?: ComposerReferenceInsertion | null @@ -157,8 +160,9 @@ type ChatStore = { reloadHistory: (sessionId: string) => Promise queueComposerPrefill: ( sessionId: string, - prefill: { text: string; attachments?: UIAttachment[] }, + prefill: { text: string; attachments?: UIAttachment[]; mode?: ComposerPrefillMode }, ) => void + clearComposerPrefill: (sessionId: string, nonce?: number) => void queueComposerInsertion: ( sessionId: string, insertion: Omit, @@ -1203,12 +1207,22 @@ export const useChatStore = create((set, get) => ({ composerPrefill: { text: prefill.text, attachments: prefill.attachments, + mode: prefill.mode, nonce: Date.now(), }, })), })) }, + clearComposerPrefill: (sessionId, nonce) => { + set((state) => ({ + sessions: updateSessionIn(state.sessions, sessionId, (session) => { + if (nonce !== undefined && session.composerPrefill?.nonce !== nonce) return {} + return { composerPrefill: null } + }), + })) + }, + queueComposerInsertion: (sessionId, insertion) => { set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({