mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
92b01565a3
commit
80b2e98f8d
@ -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 () => {
|
it('shows branch and worktree launch controls for an empty active Git session', async () => {
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
sessions: [{
|
sessions: [{
|
||||||
|
|||||||
@ -121,7 +121,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
const { sendMessage, stopGeneration, clearComposerInsertion } = useChatStore()
|
const { sendMessage, stopGeneration, clearComposerPrefill, clearComposerInsertion } = useChatStore()
|
||||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||||
const chatState = sessionState?.chatState ?? 'idle'
|
const chatState = sessionState?.chatState ?? 'idle'
|
||||||
@ -230,21 +230,25 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
}, [isActive])
|
}, [isActive])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!composerPrefill) return
|
if (!composerPrefill || !activeTabId) return
|
||||||
|
|
||||||
setComposerInput(composerPrefill.text)
|
const nextAttachments = (composerPrefill.attachments ?? [])
|
||||||
setComposerAttachments(
|
.filter((attachment) => attachment.type === 'image' || attachment.data)
|
||||||
(composerPrefill.attachments ?? [])
|
.map((attachment, index) => ({
|
||||||
.filter((attachment) => attachment.type === 'image' || attachment.data)
|
id: `composer-prefill-${composerPrefill.nonce}-${index}`,
|
||||||
.map((attachment, index) => ({
|
name: attachment.name,
|
||||||
id: `rewind-prefill-${composerPrefill.nonce}-${index}`,
|
type: attachment.type,
|
||||||
name: attachment.name,
|
mimeType: attachment.mimeType,
|
||||||
type: attachment.type,
|
previewUrl: attachment.type === 'image' ? attachment.data : undefined,
|
||||||
mimeType: attachment.mimeType,
|
data: attachment.data,
|
||||||
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)
|
setPlusMenuOpen(false)
|
||||||
setSlashMenuOpen(false)
|
setSlashMenuOpen(false)
|
||||||
setFileSearchOpen(false)
|
setFileSearchOpen(false)
|
||||||
@ -255,10 +259,19 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
el?.focus()
|
el?.focus()
|
||||||
const cursor = composerPrefill.text.length
|
if (composerPrefill.mode !== 'append') {
|
||||||
el?.setSelectionRange(cursor, cursor)
|
const cursor = composerPrefill.text.length
|
||||||
|
el?.setSelectionRange(cursor, cursor)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}, [composerPrefill, setComposerAttachments, setComposerInput])
|
clearComposerPrefill(activeTabId, composerPrefill.nonce)
|
||||||
|
}, [
|
||||||
|
activeTabId,
|
||||||
|
clearComposerPrefill,
|
||||||
|
composerPrefill,
|
||||||
|
setComposerAttachments,
|
||||||
|
setComposerInput,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!composerInsertion || !activeTabId || isMemberSession) return
|
if (!composerInsertion || !activeTabId || isMemberSession) return
|
||||||
|
|||||||
@ -38,6 +38,7 @@ describe('subscribePreviewEvents', () => {
|
|||||||
await subscribePreviewEvents('s1')
|
await subscribePreviewEvents('s1')
|
||||||
listeners['preview://event']!({ payload: JSON.stringify({ v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,AAAA', kind: 'full' }) })
|
listeners['preview://event']!({ payload: JSON.stringify({ v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,AAAA', kind: 'full' }) })
|
||||||
expect(prefill).toHaveBeenCalledWith('s1', expect.objectContaining({
|
expect(prefill).toHaveBeenCalledWith('s1', expect.objectContaining({
|
||||||
|
mode: 'append',
|
||||||
attachments: [expect.objectContaining({ type: 'image', data: 'data:image/png;base64,AAAA' })],
|
attachments: [expect.objectContaining({ type: 'image', data: 'data:image/png;base64,AAAA' })],
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export async function subscribePreviewEvents(sessionId: string): Promise<() => v
|
|||||||
else if (msg.type === 'screenshot' && msg.dataUrl) {
|
else if (msg.type === 'screenshot' && msg.dataUrl) {
|
||||||
useChatStore.getState().queueComposerPrefill(sessionId, {
|
useChatStore.getState().queueComposerPrefill(sessionId, {
|
||||||
text: '',
|
text: '',
|
||||||
|
mode: 'append',
|
||||||
attachments: [{ type: 'image', name: `screenshot-${kindLabel(msg.kind)}.png`, mimeType: 'image/png', data: msg.dataUrl }],
|
attachments: [{ type: 'image', name: `screenshot-${kindLabel(msg.kind)}.png`, mimeType: 'image/png', data: msg.dataUrl }],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,6 +53,8 @@ export type ComposerReferenceInsertion = {
|
|||||||
nonce: number
|
nonce: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ComposerPrefillMode = 'replace' | 'append'
|
||||||
|
|
||||||
export type PerSessionState = {
|
export type PerSessionState = {
|
||||||
messages: UIMessage[]
|
messages: UIMessage[]
|
||||||
chatState: ChatState
|
chatState: ChatState
|
||||||
@ -87,6 +89,7 @@ export type PerSessionState = {
|
|||||||
composerPrefill?: {
|
composerPrefill?: {
|
||||||
text: string
|
text: string
|
||||||
attachments?: UIAttachment[]
|
attachments?: UIAttachment[]
|
||||||
|
mode?: ComposerPrefillMode
|
||||||
nonce: number
|
nonce: number
|
||||||
} | null
|
} | null
|
||||||
composerInsertion?: ComposerReferenceInsertion | null
|
composerInsertion?: ComposerReferenceInsertion | null
|
||||||
@ -157,8 +160,9 @@ type ChatStore = {
|
|||||||
reloadHistory: (sessionId: string) => Promise<void>
|
reloadHistory: (sessionId: string) => Promise<void>
|
||||||
queueComposerPrefill: (
|
queueComposerPrefill: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
prefill: { text: string; attachments?: UIAttachment[] },
|
prefill: { text: string; attachments?: UIAttachment[]; mode?: ComposerPrefillMode },
|
||||||
) => void
|
) => void
|
||||||
|
clearComposerPrefill: (sessionId: string, nonce?: number) => void
|
||||||
queueComposerInsertion: (
|
queueComposerInsertion: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
insertion: Omit<ComposerReferenceInsertion, 'nonce'>,
|
insertion: Omit<ComposerReferenceInsertion, 'nonce'>,
|
||||||
@ -1203,12 +1207,22 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
composerPrefill: {
|
composerPrefill: {
|
||||||
text: prefill.text,
|
text: prefill.text,
|
||||||
attachments: prefill.attachments,
|
attachments: prefill.attachments,
|
||||||
|
mode: prefill.mode,
|
||||||
nonce: Date.now(),
|
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) => {
|
queueComposerInsertion: (sessionId, insertion) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
sessions: updateSessionIn(state.sessions, sessionId, () => ({
|
sessions: updateSessionIn(state.sessions, sessionId, () => ({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user