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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-01 16:47:52 +08:00
parent 92b01565a3
commit 80b2e98f8d
5 changed files with 114 additions and 19 deletions

View File

@ -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(<ChatInput compact />)
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(<ChatInput compact />)
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(<ChatInput compact />)
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(<ChatInput compact />)
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: [{

View File

@ -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

View File

@ -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' })],
}))
})

View File

@ -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 }],
})
}

View File

@ -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<void>
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<ComposerReferenceInsertion, 'nonce'>,
@ -1203,12 +1207,22 @@ export const useChatStore = create<ChatStore>((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, () => ({