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 new file mode 100644 index 0000000..035c967 --- /dev/null +++ b/packages/web/frontend/src/js/services/__tests__/chat_domController.test.ts @@ -0,0 +1,63 @@ +/** + * @jest-environment jsdom + */ + +import { describe, expect, it } from '@jest/globals'; +import { createChatDomController } from '../chat_domController.js'; + +describe('createChatDomController', () => { + const setupController = () => { + const panel = document.createElement('div'); + const startContainer = document.createElement('div'); + const chatContainer = document.createElement('div'); + const chatBody = document.createElement('div'); + const messageList = document.createElement('div'); + const chatInput = document.createElement('textarea'); + const planContainer = document.createElement('div'); + const statusElement = document.createElement('div'); + const sendButton = document.createElement('button'); + + const controller = createChatDomController({ + panel, + startContainer, + chatContainer, + chatBody, + messageList, + chatInput, + planContainer, + statusElement, + windowRef: window, + documentRef: document, + sendButtons: new Set([sendButton]), + }); + + return { controller, messageList }; + }; + + it('reuses streaming entries across runtime reconnects when the event id is stable', () => { + const { controller, messageList } = setupController(); + + controller.beginRuntimeSession(); + + controller.appendMessage('agent', 'Hello', { eventId: 'message-1' }); + expect(messageList.children).toHaveLength(1); + + controller.appendMessage('agent', 'Hello friend', { eventId: 'message-1' }); + expect(messageList.children).toHaveLength(1); + + controller.beginRuntimeSession(); + + controller.appendMessage('agent', 'Hello friend!', { + eventId: 'message-1', + final: true, + }); + + expect(messageList.children).toHaveLength(1); + + const wrapper = messageList.firstElementChild as HTMLElement | null; + expect(wrapper?.dataset.runtimeGeneration).toBe('2'); + + const bubble = messageList.querySelector('.agent-message-bubble'); + expect(bubble?.textContent ?? '').toContain('Hello friend!'); + }); +}); diff --git a/packages/web/frontend/src/js/services/chat_domController.ts b/packages/web/frontend/src/js/services/chat_domController.ts index 64d96de..39dbb3a 100644 --- a/packages/web/frontend/src/js/services/chat_domController.ts +++ b/packages/web/frontend/src/js/services/chat_domController.ts @@ -73,6 +73,16 @@ interface CommandEntry { bubble: HTMLElement; } +interface MessageEntryRecord { + entry: MessageEntry; + generation: number; +} + +interface CommandEntryRecord { + entry: CommandEntry; + generation: number; +} + interface CommandOutputBlock { label: string; content: string; @@ -201,46 +211,56 @@ export function createChatDomController({ let lastStatus = ''; let lastStatusLevel = ''; let runtimeGeneration = 0; - const messageEntries = new Map(); - const commandEntries = new Map(); + const messageEntries = new Map(); + const commandEntries = new Map(); - const buildRuntimeKey = (eventId: string | null): string | null => { + const getMessageEntry = (eventId: string | null): MessageEntry | null => { if (!eventId) { return null; } - return `${runtimeGeneration}:${eventId}`; - }; - - const getMessageEntry = (eventId: string | null): MessageEntry | null => { - const key = buildRuntimeKey(eventId); - if (!key) { + const record = messageEntries.get(eventId); + if (!record) { return null; } - return messageEntries.get(key) ?? null; + if (!record.entry.wrapper.parentElement) { + messageEntries.delete(eventId); + return null; + } + if (record.generation !== runtimeGeneration && record.entry.final) { + return null; + } + record.generation = runtimeGeneration; + return record.entry; }; const setMessageEntry = (eventId: string | null, entry: MessageEntry): void => { - const key = buildRuntimeKey(eventId); - if (!key) { + if (!eventId) { return; } - messageEntries.set(key, entry); + messageEntries.set(eventId, { entry, generation: runtimeGeneration }); }; const getCommandEntry = (eventId: string | null): CommandEntry | null => { - const key = buildRuntimeKey(eventId); - if (!key) { + if (!eventId) { + return null; + } + const record = commandEntries.get(eventId); + if (!record) { return null; } - return commandEntries.get(key) ?? null; + if (!record.entry.wrapper.parentElement) { + commandEntries.delete(eventId); + return null; + } + record.generation = runtimeGeneration; + return record.entry; }; const setCommandEntry = (eventId: string | null, entry: CommandEntry): void => { - const key = buildRuntimeKey(eventId); - if (!key) { + if (!eventId) { return; } - commandEntries.set(key, entry); + commandEntries.set(eventId, { entry, generation: runtimeGeneration }); }; const ensureButtons = (disabled: boolean): void => { @@ -460,6 +480,17 @@ export function createChatDomController({ const isFinal = options.final === true; const existing = getMessageEntry(eventId); 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); + } + } + if (existing.role !== role) { existing.role = role; existing.wrapper.className = `agent-message agent-message--${role}`; @@ -678,6 +709,16 @@ export function createChatDomController({ const eventId = normaliseEventId(options.eventId); const existing = getCommandEntry(eventId); if (existing) { + if (eventId) { + existing.wrapper.dataset.eventId = eventId; + existing.wrapper.dataset.runtimeGeneration = String(runtimeGeneration); + const record = commandEntries.get(eventId); + if (record) { + record.generation = runtimeGeneration; + } else { + setCommandEntry(eventId, existing); + } + } renderCommandBubble(existing.bubble, payload ?? null); scrollToLatest(); return; @@ -734,8 +775,16 @@ export function createChatDomController({ }, beginRuntimeSession() { runtimeGeneration += 1; - messageEntries.clear(); - commandEntries.clear(); + for (const [eventId, record] of messageEntries) { + if (!record.entry.wrapper.parentElement || record.entry.final) { + messageEntries.delete(eventId); + } + } + for (const [eventId, record] of commandEntries) { + if (!record.entry.wrapper.parentElement) { + commandEntries.delete(eventId); + } + } }, isThinking() { return isThinking; diff --git a/packages/web/frontend/src/js/services/context.md b/packages/web/frontend/src/js/services/context.md index c8459f6..ec3e50b 100644 --- a/packages/web/frontend/src/js/services/context.md +++ b/packages/web/frontend/src/js/services/context.md @@ -9,7 +9,7 @@ - `chat.ts` — top-level composition layer that wires DOM controller methods to typed socket/router/input helpers while tracking conversation state. - `chat_domHelpers.ts` — Provides DOM listener helpers and textarea auto-resize logic with injectable scheduling/document dependencies; `chat_dom.ts` re-exports the defaults for compatibility with existing callers. -- `chat_domController.ts` — DOM mutation utilities responsible for rendering messages, plans, status banners, and thinking indicators; tracks runtime `__id` values to update existing rows in place, namespacing entries per socket generation so reconnects that spawn a new agent no longer overwrite earlier passes, and renders command metadata/output blocks using the CSS-driven layout. Approval-scope prompt requests now bypass the previous suppression heuristics so the UI surfaces the runtime instructions instead of silently clearing the status bar. Agent streaming updates render via the markdown pipeline while Mermaid diagrams stay deferred until the final chunk arrives. +- `chat_domController.ts` — DOM mutation utilities responsible for rendering messages, plans, status banners, and thinking indicators; tracks runtime `__id` values to update existing rows in place (including across reconnects) so stable identifiers keep streaming message/command entries on the same DOM row even after a socket drop, and renders command metadata/output blocks using the CSS-driven layout. Approval-scope prompt requests now bypass the previous suppression heuristics so the UI surfaces the runtime instructions instead of silently clearing the status bar. Agent streaming updates render via the markdown pipeline while Mermaid diagrams stay deferred until the final chunk arrives. - `chat_eventDisplay.ts` — Resolves banner/status headings and bodies using deterministic resolver functions so DOM renderers receive consistent copy without duplicating heuristics. - `chat_eventDisplayHelpers.ts` — Shared normalisation helpers and resolver functions used by the event display module to keep per-file complexity low. - `chat_socket.ts` — WebSocket manager emitting typed lifecycle/status events, encapsulating reconnect timers, and guarding against stale sockets; shared helpers now centralise URL resolution and teardown logging so the event handlers stay small.