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<messageId, TrackedMessage>) 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>
This commit is contained in:
David Kaya
2026-04-13 14:39:41 +02:00
co-authored by Copilot
parent 6ef44c6689
commit fde82bef4d
@@ -17,15 +17,49 @@ declare global {
type PromptPhase = 'idle' | 'streaming' | 'complete' | 'error'; 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; content: string;
thinkingContent: string; messageKind?: string;
authorName: string; authorName: string;
pending: boolean;
finalized: boolean;
}
/** Derive display values from tracked messages. */
function deriveDisplay(messages: Map<string, TrackedMessage>) {
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() { export function QuickPromptApp() {
const [phase, setPhase] = useState<PromptPhase>('idle'); const [phase, setPhase] = useState<PromptPhase>('idle');
const [response, setResponse] = useState<StreamedMessage>({ content: '', thinkingContent: '', authorName: '' }); const [displayContent, setDisplayContent] = useState('');
const [displayThinking, setDisplayThinking] = useState('');
const [displayAuthor, setDisplayAuthor] = useState('');
const [errorMessage, setErrorMessage] = useState<string>(); const [errorMessage, setErrorMessage] = useState<string>();
const [capabilities, setCapabilities] = useState<QuickPromptCapabilities>(); const [capabilities, setCapabilities] = useState<QuickPromptCapabilities>();
const [selectedModel, setSelectedModel] = useState<string>(); const [selectedModel, setSelectedModel] = useState<string>();
@@ -33,11 +67,18 @@ export function QuickPromptApp() {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const sessionIdRef = useRef<string | null>(null); const sessionIdRef = useRef<string | null>(null);
// Track whether we've received any response content (non-thinking) so we const messagesRef = useRef<Map<string, TrackedMessage>>(new Map());
// don't prematurely treat setup/init events as the final completion signal.
const hasContentRef = useRef(false);
const api = window.quickPromptApi; 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 // Load capabilities on mount
useEffect(() => { useEffect(() => {
api.getCapabilities().then((caps) => { api.getCapabilities().then((caps) => {
@@ -50,7 +91,6 @@ export function QuickPromptApp() {
// Subscribe to show/hide events from main process // Subscribe to show/hide events from main process
useEffect(() => { useEffect(() => {
const offShow = api.onShow((theme: string) => { const offShow = api.onShow((theme: string) => {
// Apply theme to document root so CSS variables match the main app
const effective = theme === 'system' const effective = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme; : theme;
@@ -66,47 +106,88 @@ export function QuickPromptApp() {
}; };
}, [api]); }, [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(() => { useEffect(() => {
const off = api.onSessionEvent((event: SessionEventRecord) => { const off = api.onSessionEvent((event: SessionEventRecord) => {
if (event.kind === 'message-delta' && event.contentDelta) { const msgs = messagesRef.current;
if (event.messageKind === 'thinking') {
setResponse((prev) => ({ ...prev, thinkingContent: prev.thinkingContent + event.contentDelta! })); 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 { } else {
hasContentRef.current = true; msgs.set(event.messageId, {
setResponse((prev) => ({ content: event.contentDelta ?? event.content ?? '',
...prev, messageKind: event.messageKind,
content: prev.content + event.contentDelta!, authorName: event.authorName ?? '',
authorName: event.authorName ?? prev.authorName, pending: true,
})); finalized: false,
});
} }
syncDisplay();
setPhase('streaming'); setPhase('streaming');
} else if (
event.kind === 'message-complete' || } else if (event.kind === 'message-complete' && event.messageId) {
(event.kind === 'status' && event.status === 'idle') || const existing = msgs.get(event.messageId);
(event.kind === 'agent-activity' && event.activityType === 'completed') if (existing) {
) { if (event.content !== undefined) existing.content = event.content;
// Only transition to "complete" once we've received actual response existing.pending = false;
// content. The session may emit completion events from initialisation existing.finalized = true;
// or setup runs before the real content-generating turn starts. } else {
if (hasContentRef.current) { msgs.set(event.messageId, {
setPhase('complete'); 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') { } else if (event.kind === 'error') {
setErrorMessage(event.error ?? 'An unexpected error occurred.'); setErrorMessage(event.error ?? 'An unexpected error occurred.');
setPhase('error'); setPhase('error');
} }
}); });
return off; return off;
}, [api]); }, [api, syncDisplay]);
const resetState = useCallback(() => { const resetState = useCallback(() => {
setPhase('idle'); setPhase('idle');
setResponse({ content: '', thinkingContent: '', authorName: '' }); setDisplayContent('');
setDisplayThinking('');
setDisplayAuthor('');
setErrorMessage(undefined); setErrorMessage(undefined);
sessionIdRef.current = null; sessionIdRef.current = null;
hasContentRef.current = false; messagesRef.current = new Map();
// Refresh capabilities in case models changed
api.getCapabilities().then((caps) => { api.getCapabilities().then((caps) => {
setCapabilities(caps); setCapabilities(caps);
setSelectedModel(caps.defaultModel ?? caps.models[0]?.id); setSelectedModel(caps.defaultModel ?? caps.models[0]?.id);
@@ -118,9 +199,11 @@ export function QuickPromptApp() {
if (!content.trim() || phase === 'streaming') return; if (!content.trim() || phase === 'streaming') return;
setPhase('streaming'); setPhase('streaming');
setResponse({ content: '', thinkingContent: '', authorName: '' }); setDisplayContent('');
setDisplayThinking('');
setDisplayAuthor('');
setErrorMessage(undefined); setErrorMessage(undefined);
hasContentRef.current = false; messagesRef.current = new Map();
try { try {
const result = await api.send({ const result = await api.send({
@@ -212,9 +295,9 @@ export function QuickPromptApp() {
{/* Response area — grows dynamically */} {/* Response area — grows dynamically */}
{hasResponse && ( {hasResponse && (
<QuickPromptResponse <QuickPromptResponse
content={response.content} content={displayContent}
thinkingContent={response.thinkingContent} thinkingContent={displayThinking}
authorName={response.authorName} authorName={displayAuthor}
phase={phase} phase={phase}
error={errorMessage} error={errorMessage}
/> />