mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +02:00
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:
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user