diff --git a/packages/web/frontend/context.md b/packages/web/frontend/context.md index 9540b55..65ff43a 100644 --- a/packages/web/frontend/context.md +++ b/packages/web/frontend/context.md @@ -49,6 +49,7 @@ - Split the chat event display heuristics into `services/chat_eventDisplay.ts`, exposing typed helpers for banner/status body selection and command preview normalisation. - 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. - 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. +- 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. - 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. - 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. - Retired the unused terminal dock panel styling and element plumbing so the agent chat stands alone. diff --git a/packages/web/frontend/src/js/services/__tests__/chat_domController.test.ts b/packages/web/frontend/src/js/services/__tests__/chat_domController.test.ts index 035c967..ef0754f 100644 --- a/packages/web/frontend/src/js/services/__tests__/chat_domController.test.ts +++ b/packages/web/frontend/src/js/services/__tests__/chat_domController.test.ts @@ -60,4 +60,29 @@ describe('createChatDomController', () => { const bubble = messageList.querySelector('.agent-message-bubble'); expect(bubble?.textContent ?? '').toContain('Hello friend!'); }); + + it('keeps streaming agent updates within a single bubble even if event ids change', () => { + const { controller, messageList } = setupController(); + + controller.beginRuntimeSession(); + + controller.appendMessage('agent', 'Chunk one', { eventId: 'key-1' }); + expect(messageList.children).toHaveLength(1); + let wrapper = messageList.firstElementChild as HTMLElement | null; + expect(wrapper?.dataset.eventId).toBe('key-1'); + + controller.appendMessage('agent', 'Chunk two', { eventId: 'key-2' }); + expect(messageList.children).toHaveLength(1); + wrapper = messageList.firstElementChild as HTMLElement | null; + expect(wrapper?.dataset.eventId).toBe('key-2'); + + controller.appendMessage('agent', 'Final chunk', { eventId: 'final-3', final: true }); + expect(messageList.children).toHaveLength(1); + wrapper = messageList.firstElementChild as HTMLElement | null; + expect(wrapper?.dataset.eventId).toBe('final-3'); + expect(wrapper?.dataset.runtimeGeneration).toBe('1'); + + const bubble = messageList.querySelector('.agent-message-bubble'); + expect(bubble?.textContent ?? '').toContain('Final chunk'); + }); }); diff --git a/packages/web/frontend/src/js/services/chat_domController.ts b/packages/web/frontend/src/js/services/chat_domController.ts index 39dbb3a..9e568be 100644 --- a/packages/web/frontend/src/js/services/chat_domController.ts +++ b/packages/web/frontend/src/js/services/chat_domController.ts @@ -213,6 +213,7 @@ export function createChatDomController({ let runtimeGeneration = 0; const messageEntries = new Map(); const commandEntries = new Map(); + let lastStreamingAgentEntry: { entry: MessageEntry; generation: number } | null = null; const getMessageEntry = (eventId: string | null): MessageEntry | null => { if (!eventId) { @@ -263,6 +264,55 @@ export function createChatDomController({ commandEntries.set(eventId, { entry, generation: runtimeGeneration }); }; + const resolveLastStreamingEntry = (): MessageEntry | null => { + const record = lastStreamingAgentEntry; + if (!record) { + return null; + } + + if (record.generation !== runtimeGeneration) { + lastStreamingAgentEntry = null; + return null; + } + + const { entry } = record; + if (!entry.wrapper.parentElement || entry.final) { + lastStreamingAgentEntry = null; + return null; + } + + return entry; + }; + + const updateEntryTracking = (entry: MessageEntry, eventId: string | null): void => { + const previousEventId = entry.wrapper.dataset.eventId ?? ''; + + if (previousEventId && (!eventId || previousEventId !== eventId)) { + messageEntries.delete(previousEventId); + } + + if (eventId) { + entry.wrapper.dataset.eventId = eventId; + entry.wrapper.dataset.runtimeGeneration = String(runtimeGeneration); + setMessageEntry(eventId, entry); + } else { + delete entry.wrapper.dataset.eventId; + } + }; + + const clearLastStreamingEntry = (entry: MessageEntry | null): void => { + if (!lastStreamingAgentEntry) { + return; + } + if (!entry || lastStreamingAgentEntry.entry === entry) { + lastStreamingAgentEntry = null; + } + }; + + const markLastStreamingEntry = (entry: MessageEntry): void => { + lastStreamingAgentEntry = { entry, generation: runtimeGeneration }; + }; + const ensureButtons = (disabled: boolean): void => { for (const button of sendButtons) { button.disabled = disabled; @@ -478,18 +528,15 @@ export function createChatDomController({ const normalized = normaliseText(text); const eventId = normaliseEventId(options.eventId); const isFinal = options.final === true; - const existing = getMessageEntry(eventId); + let existing = getMessageEntry(eventId); + + if (!existing && role === 'agent') { + existing = resolveLastStreamingEntry(); + } + if (existing) { - if (eventId) { - existing.wrapper.dataset.eventId = eventId; - existing.wrapper.dataset.runtimeGeneration = String(runtimeGeneration); - const record = messageEntries.get(eventId); - if (record) { - record.generation = runtimeGeneration; - } else { - setMessageEntry(eventId, existing); - } - } + updateEntryTracking(existing, eventId); + existing.wrapper.dataset.runtimeGeneration = String(runtimeGeneration); if (existing.role !== role) { existing.role = role; @@ -501,11 +548,14 @@ export function createChatDomController({ if (role === 'agent') { if (isFinal) { renderAgentMarkdown(existing, { updateCurrent: true }); + clearLastStreamingEntry(existing); } else { setAgentStreamingContent(existing); + markLastStreamingEntry(existing); } } else { existing.bubble.textContent = normalized; + clearLastStreamingEntry(existing); } scrollToLatest(); @@ -536,17 +586,19 @@ export function createChatDomController({ markdownDisplay.render(entry.text, { updateCurrent: true }); entry.markdown = markdownDisplay; entry.final = true; + clearLastStreamingEntry(entry); } else { setAgentStreamingContent(entry); + markLastStreamingEntry(entry); } } else { bubble.textContent = entry.text; + clearLastStreamingEntry(entry); } + updateEntryTracking(entry, eventId); if (eventId) { - wrapper.dataset.eventId = eventId; wrapper.dataset.runtimeGeneration = String(runtimeGeneration); - setMessageEntry(eventId, entry); } appendMessageWrapper(wrapper); @@ -785,6 +837,7 @@ export function createChatDomController({ commandEntries.delete(eventId); } } + lastStreamingAgentEntry = null; }, isThinking() { return isThinking; @@ -799,6 +852,7 @@ export function createChatDomController({ runtimeGeneration = 0; messageEntries.clear(); commandEntries.clear(); + lastStreamingAgentEntry = null; setPanelActive(false); updateStatusDisplay(); },