From fde82bef4d363b8e489e9f86086d58e4e49c652c Mon Sep 17 00:00:00 2001 From: David Kaya Date: Mon, 13 Apr 2026 14:39:41 +0200 Subject: [PATCH] fix: rewrite Quick Prompt event handling with message-by-ID tracking The sidecar's fire-and-forget event handler pattern (invokeRunTurnHandler) causes events to arrive out of order: message-complete and status:idle can arrive before all message-delta events have been emitted. Additionally, deltas from multiple messages (thinking, response, sub-agent) were naively concatenated into a single string, producing garbled output. Replace the single-string accumulation with a proper message tracking model (Map) that mirrors the main app's SessionRecord.messages approach: - Track each message independently by messageId - Use message-complete event's content field as the authoritative final text, replacing any garbled intermediate streaming state - Mark messages as finalized on message-complete to skip late arriving deltas that would corrupt the final content - Derive display from the last non-thinking message's content - Only transition to 'complete' when all tracked messages are not-pending AND at least one non-thinking message has content, preventing premature completion from setup/init events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../quick-prompt/QuickPromptApp.tsx | 155 ++++++++++++++---- 1 file changed, 119 insertions(+), 36 deletions(-) diff --git a/src/renderer/components/quick-prompt/QuickPromptApp.tsx b/src/renderer/components/quick-prompt/QuickPromptApp.tsx index 0f5a26f..55d5f06 100644 --- a/src/renderer/components/quick-prompt/QuickPromptApp.tsx +++ b/src/renderer/components/quick-prompt/QuickPromptApp.tsx @@ -17,15 +17,49 @@ declare global { type PromptPhase = 'idle' | 'streaming' | 'complete' | 'error'; -interface StreamedMessage { +/** + * Per-message state tracked by messageId, mirroring the main app's + * SessionRecord.messages model. This is necessary because the sidecar's + * fire-and-forget event handlers can emit events out of order — e.g. + * `message-complete` and `status: idle` can arrive before all + * `message-delta` events have been emitted. + */ +interface TrackedMessage { content: string; - thinkingContent: string; + messageKind?: string; authorName: string; + pending: boolean; + finalized: boolean; +} + +/** Derive display values from tracked messages. */ +function deriveDisplay(messages: Map) { + let content = ''; + let thinkingContent = ''; + let authorName = ''; + let hasVisibleContent = false; + let allComplete = messages.size > 0; + + for (const msg of messages.values()) { + if (msg.messageKind === 'thinking') { + if (msg.content) thinkingContent += (thinkingContent ? '\n' : '') + msg.content; + } else { + // Last non-thinking message wins as the response + content = msg.content; + authorName = msg.authorName; + if (msg.content.length > 0) hasVisibleContent = true; + } + if (msg.pending) allComplete = false; + } + + return { content, thinkingContent, authorName, isComplete: allComplete && hasVisibleContent }; } export function QuickPromptApp() { const [phase, setPhase] = useState('idle'); - const [response, setResponse] = useState({ content: '', thinkingContent: '', authorName: '' }); + const [displayContent, setDisplayContent] = useState(''); + const [displayThinking, setDisplayThinking] = useState(''); + const [displayAuthor, setDisplayAuthor] = useState(''); const [errorMessage, setErrorMessage] = useState(); const [capabilities, setCapabilities] = useState(); const [selectedModel, setSelectedModel] = useState(); @@ -33,11 +67,18 @@ export function QuickPromptApp() { const [visible, setVisible] = useState(false); const sessionIdRef = useRef(null); - // Track whether we've received any response content (non-thinking) so we - // don't prematurely treat setup/init events as the final completion signal. - const hasContentRef = useRef(false); + const messagesRef = useRef>(new Map()); const api = window.quickPromptApi; + /** Push tracked-message state into React display state. */ + const syncDisplay = useCallback(() => { + const display = deriveDisplay(messagesRef.current); + setDisplayContent(display.content); + setDisplayThinking(display.thinkingContent); + setDisplayAuthor(display.authorName); + return display; + }, []); + // Load capabilities on mount useEffect(() => { api.getCapabilities().then((caps) => { @@ -50,7 +91,6 @@ export function QuickPromptApp() { // Subscribe to show/hide events from main process useEffect(() => { const offShow = api.onShow((theme: string) => { - // Apply theme to document root so CSS variables match the main app const effective = theme === 'system' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme; @@ -66,47 +106,88 @@ export function QuickPromptApp() { }; }, [api]); - // Subscribe to session events (streaming) + // Subscribe to session events (streaming) — processes events using + // message-by-ID tracking that tolerates out-of-order delivery. useEffect(() => { const off = api.onSessionEvent((event: SessionEventRecord) => { - if (event.kind === 'message-delta' && event.contentDelta) { - if (event.messageKind === 'thinking') { - setResponse((prev) => ({ ...prev, thinkingContent: prev.thinkingContent + event.contentDelta! })); + const msgs = messagesRef.current; + + if (event.kind === 'message-delta' && event.messageId && (event.contentDelta || event.content !== undefined)) { + const existing = msgs.get(event.messageId); + + if (existing) { + // Skip deltas that arrive after message-complete replaced the + // content with the authoritative final version. + if (existing.finalized) return; + + if (event.content !== undefined) { + existing.content = event.content; + } else if (event.contentDelta) { + existing.content += event.contentDelta; + } + existing.authorName = event.authorName ?? existing.authorName; } else { - hasContentRef.current = true; - setResponse((prev) => ({ - ...prev, - content: prev.content + event.contentDelta!, - authorName: event.authorName ?? prev.authorName, - })); + msgs.set(event.messageId, { + content: event.contentDelta ?? event.content ?? '', + messageKind: event.messageKind, + authorName: event.authorName ?? '', + pending: true, + finalized: false, + }); } + + syncDisplay(); setPhase('streaming'); - } else if ( - event.kind === 'message-complete' || - (event.kind === 'status' && event.status === 'idle') || - (event.kind === 'agent-activity' && event.activityType === 'completed') - ) { - // Only transition to "complete" once we've received actual response - // content. The session may emit completion events from initialisation - // or setup runs before the real content-generating turn starts. - if (hasContentRef.current) { - setPhase('complete'); + + } else if (event.kind === 'message-complete' && event.messageId) { + const existing = msgs.get(event.messageId); + if (existing) { + if (event.content !== undefined) existing.content = event.content; + existing.pending = false; + existing.finalized = true; + } else { + msgs.set(event.messageId, { + content: event.content ?? '', + messageKind: undefined, + authorName: event.authorName ?? '', + pending: false, + finalized: true, + }); } + + const display = syncDisplay(); + if (display.isComplete) setPhase('complete'); + + } else if (event.kind === 'message-reclassified' && event.messageId && event.messageKind) { + const existing = msgs.get(event.messageId); + if (existing) { + existing.messageKind = event.messageKind; + syncDisplay(); + } + + } else if (event.kind === 'status' && event.status === 'idle') { + for (const msg of msgs.values()) { + msg.pending = false; + } + const display = syncDisplay(); + if (display.isComplete) setPhase('complete'); + } else if (event.kind === 'error') { setErrorMessage(event.error ?? 'An unexpected error occurred.'); setPhase('error'); } }); return off; - }, [api]); + }, [api, syncDisplay]); const resetState = useCallback(() => { setPhase('idle'); - setResponse({ content: '', thinkingContent: '', authorName: '' }); + setDisplayContent(''); + setDisplayThinking(''); + setDisplayAuthor(''); setErrorMessage(undefined); sessionIdRef.current = null; - hasContentRef.current = false; - // Refresh capabilities in case models changed + messagesRef.current = new Map(); api.getCapabilities().then((caps) => { setCapabilities(caps); setSelectedModel(caps.defaultModel ?? caps.models[0]?.id); @@ -118,9 +199,11 @@ export function QuickPromptApp() { if (!content.trim() || phase === 'streaming') return; setPhase('streaming'); - setResponse({ content: '', thinkingContent: '', authorName: '' }); + setDisplayContent(''); + setDisplayThinking(''); + setDisplayAuthor(''); setErrorMessage(undefined); - hasContentRef.current = false; + messagesRef.current = new Map(); try { const result = await api.send({ @@ -212,9 +295,9 @@ export function QuickPromptApp() { {/* Response area — grows dynamically */} {hasResponse && (