Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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!');
});
});
91 changes: 70 additions & 21 deletions packages/web/frontend/src/js/services/chat_domController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -201,46 +211,56 @@ export function createChatDomController({
let lastStatus = '';
let lastStatusLevel = '';
let runtimeGeneration = 0;
const messageEntries = new Map<string, MessageEntry>();
const commandEntries = new Map<string, CommandEntry>();
const messageEntries = new Map<string, MessageEntryRecord>();
const commandEntries = new Map<string, CommandEntryRecord>();

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 => {
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/web/frontend/src/js/services/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading