Skip to content

Commit e99d840

Browse files
committed
Coalesce streaming agent updates in chat UI
1 parent 8ddf205 commit e99d840

File tree

3 files changed

+93
-13
lines changed

3 files changed

+93
-13
lines changed

packages/web/frontend/context.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
- Split the chat event display heuristics into `services/chat_eventDisplay.ts`, exposing typed helpers for banner/status body selection and command preview normalisation.
5050
- WebSocket lifecycle cleanup now removes listeners and ignores stale socket messages in `services/chat.ts`, preventing duplicate renders after reconnects and reducing memory pressure during repeated reconnect cycles.
5151
- Extracted DOM mutations into `services/chat_domController.ts` so `services/chat.ts` focuses on socket orchestration while the controller manages message rendering, status updates, and plan display resets.
52+
- Streaming agent responses now keep a single DOM bubble even when the runtime emits new event ids per chunk, preventing duplicate partial sentences from appearing mid-response.
5253
- Further decomposed the chat orchestration into `chat_socket.ts`, `chat_router.ts`, and `chat_inputController.ts` so socket lifecycle, payload routing, and input handling stay isolated and testable; new Jest suites cover reconnection, routing, and queued input dispatch behaviour.
5354
- Refactored the chat entrypoint to compose `chat_bootstrap.ts`, `chat_lifecycle.ts`, and `chat_sessionController.ts`, pushing socket observers, pending-queue prompts, and DOM bootstrap glue into dedicated modules while tightening discriminated-union typings across lifecycle events.
5455
- Retired the unused terminal dock panel styling and element plumbing so the agent chat stands alone.

packages/web/frontend/src/js/services/__tests__/chat_domController.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,29 @@ describe('createChatDomController', () => {
6060
const bubble = messageList.querySelector('.agent-message-bubble');
6161
expect(bubble?.textContent ?? '').toContain('Hello friend!');
6262
});
63+
64+
it('keeps streaming agent updates within a single bubble even if event ids change', () => {
65+
const { controller, messageList } = setupController();
66+
67+
controller.beginRuntimeSession();
68+
69+
controller.appendMessage('agent', 'Chunk one', { eventId: 'key-1' });
70+
expect(messageList.children).toHaveLength(1);
71+
let wrapper = messageList.firstElementChild as HTMLElement | null;
72+
expect(wrapper?.dataset.eventId).toBe('key-1');
73+
74+
controller.appendMessage('agent', 'Chunk two', { eventId: 'key-2' });
75+
expect(messageList.children).toHaveLength(1);
76+
wrapper = messageList.firstElementChild as HTMLElement | null;
77+
expect(wrapper?.dataset.eventId).toBe('key-2');
78+
79+
controller.appendMessage('agent', 'Final chunk', { eventId: 'final-3', final: true });
80+
expect(messageList.children).toHaveLength(1);
81+
wrapper = messageList.firstElementChild as HTMLElement | null;
82+
expect(wrapper?.dataset.eventId).toBe('final-3');
83+
expect(wrapper?.dataset.runtimeGeneration).toBe('1');
84+
85+
const bubble = messageList.querySelector('.agent-message-bubble');
86+
expect(bubble?.textContent ?? '').toContain('Final chunk');
87+
});
6388
});

packages/web/frontend/src/js/services/chat_domController.ts

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export function createChatDomController({
213213
let runtimeGeneration = 0;
214214
const messageEntries = new Map<string, MessageEntryRecord>();
215215
const commandEntries = new Map<string, CommandEntryRecord>();
216+
let lastStreamingAgentEntry: { entry: MessageEntry; generation: number } | null = null;
216217

217218
const getMessageEntry = (eventId: string | null): MessageEntry | null => {
218219
if (!eventId) {
@@ -263,6 +264,55 @@ export function createChatDomController({
263264
commandEntries.set(eventId, { entry, generation: runtimeGeneration });
264265
};
265266

267+
const resolveLastStreamingEntry = (): MessageEntry | null => {
268+
const record = lastStreamingAgentEntry;
269+
if (!record) {
270+
return null;
271+
}
272+
273+
if (record.generation !== runtimeGeneration) {
274+
lastStreamingAgentEntry = null;
275+
return null;
276+
}
277+
278+
const { entry } = record;
279+
if (!entry.wrapper.parentElement || entry.final) {
280+
lastStreamingAgentEntry = null;
281+
return null;
282+
}
283+
284+
return entry;
285+
};
286+
287+
const updateEntryTracking = (entry: MessageEntry, eventId: string | null): void => {
288+
const previousEventId = entry.wrapper.dataset.eventId ?? '';
289+
290+
if (previousEventId && (!eventId || previousEventId !== eventId)) {
291+
messageEntries.delete(previousEventId);
292+
}
293+
294+
if (eventId) {
295+
entry.wrapper.dataset.eventId = eventId;
296+
entry.wrapper.dataset.runtimeGeneration = String(runtimeGeneration);
297+
setMessageEntry(eventId, entry);
298+
} else {
299+
delete entry.wrapper.dataset.eventId;
300+
}
301+
};
302+
303+
const clearLastStreamingEntry = (entry: MessageEntry | null): void => {
304+
if (!lastStreamingAgentEntry) {
305+
return;
306+
}
307+
if (!entry || lastStreamingAgentEntry.entry === entry) {
308+
lastStreamingAgentEntry = null;
309+
}
310+
};
311+
312+
const markLastStreamingEntry = (entry: MessageEntry): void => {
313+
lastStreamingAgentEntry = { entry, generation: runtimeGeneration };
314+
};
315+
266316
const ensureButtons = (disabled: boolean): void => {
267317
for (const button of sendButtons) {
268318
button.disabled = disabled;
@@ -478,18 +528,15 @@ export function createChatDomController({
478528
const normalized = normaliseText(text);
479529
const eventId = normaliseEventId(options.eventId);
480530
const isFinal = options.final === true;
481-
const existing = getMessageEntry(eventId);
531+
let existing = getMessageEntry(eventId);
532+
533+
if (!existing && role === 'agent') {
534+
existing = resolveLastStreamingEntry();
535+
}
536+
482537
if (existing) {
483-
if (eventId) {
484-
existing.wrapper.dataset.eventId = eventId;
485-
existing.wrapper.dataset.runtimeGeneration = String(runtimeGeneration);
486-
const record = messageEntries.get(eventId);
487-
if (record) {
488-
record.generation = runtimeGeneration;
489-
} else {
490-
setMessageEntry(eventId, existing);
491-
}
492-
}
538+
updateEntryTracking(existing, eventId);
539+
existing.wrapper.dataset.runtimeGeneration = String(runtimeGeneration);
493540

494541
if (existing.role !== role) {
495542
existing.role = role;
@@ -501,11 +548,14 @@ export function createChatDomController({
501548
if (role === 'agent') {
502549
if (isFinal) {
503550
renderAgentMarkdown(existing, { updateCurrent: true });
551+
clearLastStreamingEntry(existing);
504552
} else {
505553
setAgentStreamingContent(existing);
554+
markLastStreamingEntry(existing);
506555
}
507556
} else {
508557
existing.bubble.textContent = normalized;
558+
clearLastStreamingEntry(existing);
509559
}
510560

511561
scrollToLatest();
@@ -536,17 +586,19 @@ export function createChatDomController({
536586
markdownDisplay.render(entry.text, { updateCurrent: true });
537587
entry.markdown = markdownDisplay;
538588
entry.final = true;
589+
clearLastStreamingEntry(entry);
539590
} else {
540591
setAgentStreamingContent(entry);
592+
markLastStreamingEntry(entry);
541593
}
542594
} else {
543595
bubble.textContent = entry.text;
596+
clearLastStreamingEntry(entry);
544597
}
545598

599+
updateEntryTracking(entry, eventId);
546600
if (eventId) {
547-
wrapper.dataset.eventId = eventId;
548601
wrapper.dataset.runtimeGeneration = String(runtimeGeneration);
549-
setMessageEntry(eventId, entry);
550602
}
551603

552604
appendMessageWrapper(wrapper);
@@ -785,6 +837,7 @@ export function createChatDomController({
785837
commandEntries.delete(eventId);
786838
}
787839
}
840+
lastStreamingAgentEntry = null;
788841
},
789842
isThinking() {
790843
return isThinking;
@@ -799,6 +852,7 @@ export function createChatDomController({
799852
runtimeGeneration = 0;
800853
messageEntries.clear();
801854
commandEntries.clear();
855+
lastStreamingAgentEntry = null;
802856
setPanelActive(false);
803857
updateStatusDisplay();
804858
},

0 commit comments

Comments
 (0)