Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/web/frontend/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
80 changes: 67 additions & 13 deletions packages/web/frontend/src/js/services/chat_domController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export function createChatDomController({
let runtimeGeneration = 0;
const messageEntries = new Map<string, MessageEntryRecord>();
const commandEntries = new Map<string, CommandEntryRecord>();
let lastStreamingAgentEntry: { entry: MessageEntry; generation: number } | null = null;

const getMessageEntry = (eventId: string | null): MessageEntry | null => {
if (!eventId) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -785,6 +837,7 @@ export function createChatDomController({
commandEntries.delete(eventId);
}
}
lastStreamingAgentEntry = null;
},
isThinking() {
return isThinking;
Expand All @@ -799,6 +852,7 @@ export function createChatDomController({
runtimeGeneration = 0;
messageEntries.clear();
commandEntries.clear();
lastStreamingAgentEntry = null;
setPanelActive(false);
updateStatusDisplay();
},
Expand Down
Loading