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, () => ({