mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +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 () => {
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' })],
|
||||
}))
|
||||
})
|
||||
|
||||
@ -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 }],
|
||||
})
|
||||
}
|
||||
|
||||
@ -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, () => ({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user