From e8516dc0717d9d2360ab661fc325af2cc8a4c5f4 Mon Sep 17 00:00:00 2001 From: anime girl <151763538+0xSolarPunk@users.noreply.github.com> Date: Sat, 30 May 2026 09:15:31 +0800 Subject: [PATCH 1/3] feat: Group consecutive bash tool cards (#176) * Group consecutive bash tool cards * fix: keep bash group keys stable --------- Co-authored-by: YK --- .../__tests__/conversation-feed-items.test.ts | 77 +++++++++++++ web/src/lib/chat/conversation-feed-items.ts | 101 ++++++++++++++++++ .../components/chat/ConversationFeed.svelte | 26 +++-- .../chat/tools/ChatBashToolGroup.svelte | 67 ++++++++++++ 4 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/chat/__tests__/conversation-feed-items.test.ts create mode 100644 web/src/lib/chat/conversation-feed-items.ts create mode 100644 web/src/lib/components/chat/tools/ChatBashToolGroup.svelte diff --git a/web/src/lib/chat/__tests__/conversation-feed-items.test.ts b/web/src/lib/chat/__tests__/conversation-feed-items.test.ts new file mode 100644 index 00000000..6218ad95 --- /dev/null +++ b/web/src/lib/chat/__tests__/conversation-feed-items.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + AssistantMessage, + BashToolUseMessage, + ReadToolUseMessage, + ToolResultMessage, + UserMessage, +} from '$shared/chat-types' +import { buildConversationFeedRenderItems } from '../conversation-feed-items' + +const TS = '2026-05-29T00:00:00.000Z' + +describe('buildConversationFeedRenderItems', () => { + it('groups adjacent bash tool uses into one render item', () => { + const messages = [ + new UserMessage(TS, 'start'), + new BashToolUseMessage(TS, 'bash-1', 'pwd'), + new BashToolUseMessage(TS, 'bash-2', 'rg foo'), + new AssistantMessage(TS, 'done'), + ] + + const items = buildConversationFeedRenderItems(messages) + + expect(items).toHaveLength(3) + expect(items[1]).toMatchObject({ kind: 'bash-group' }) + if (items[1].kind !== 'bash-group') throw new Error('expected bash group') + expect(items[1].messages.map((message) => message.command)).toEqual(['pwd', 'rg foo']) + expect(items[2]).toMatchObject({ kind: 'message', prevMessage: messages[2] }) + }) + + it('keeps a single bash tool use as a normal message', () => { + const messages = [ + new UserMessage(TS, 'start'), + new BashToolUseMessage(TS, 'bash-1', 'pwd'), + new ReadToolUseMessage(TS, 'read-1', '/tmp/file.ts'), + ] + + const items = buildConversationFeedRenderItems(messages) + + expect(items).toHaveLength(3) + expect(items[1]).toMatchObject({ kind: 'message', message: messages[1] }) + }) + + it('groups bash tool uses across hidden tool results', () => { + const messages = [ + new BashToolUseMessage(TS, 'bash-1', 'pwd'), + new ToolResultMessage(TS, 'bash-1', { content: 'ok' }, false), + new BashToolUseMessage(TS, 'bash-2', 'rg foo'), + new ToolResultMessage(TS, 'bash-2', { content: 'ok' }, false), + ] + + const items = buildConversationFeedRenderItems(messages) + + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ kind: 'bash-group' }) + if (items[0].kind !== 'bash-group') throw new Error('expected bash group') + expect(items[0].messages.map((message) => message.toolId)).toEqual(['bash-1', 'bash-2']) + }) + + it('keeps the group id stable as more adjacent bash tool uses arrive', () => { + const firstBatch = [ + new BashToolUseMessage(TS, 'bash-1', 'pwd'), + new BashToolUseMessage(TS, 'bash-2', 'rg foo'), + ] + const secondBatch = [ + ...firstBatch, + new BashToolUseMessage(TS, 'bash-3', 'bun run test'), + ] + + const firstItems = buildConversationFeedRenderItems(firstBatch) + const secondItems = buildConversationFeedRenderItems(secondBatch) + + expect(firstItems[0]).toMatchObject({ kind: 'bash-group' }) + expect(secondItems[0]).toMatchObject({ kind: 'bash-group' }) + expect(firstItems[0].id).toBe(secondItems[0].id) + }) +}) diff --git a/web/src/lib/chat/conversation-feed-items.ts b/web/src/lib/chat/conversation-feed-items.ts new file mode 100644 index 00000000..01318c77 --- /dev/null +++ b/web/src/lib/chat/conversation-feed-items.ts @@ -0,0 +1,101 @@ +import { + BashToolUseMessage, + PermissionCancelledMessage, + PermissionRequestMessage, + PermissionResolvedMessage, + ToolResultMessage, +} from '$shared/chat-types' +import type { ChatMessage } from '$shared/chat-types' + +export type ConversationFeedRenderItem = + | { + kind: 'message' + id: string + message: ChatMessage + index: number + prevMessage: ChatMessage | null + } + | { + kind: 'bash-group' + id: string + messages: BashToolUseMessage[] + index: number + prevMessage: ChatMessage | null + } + +function shouldSkipStandaloneMessage(message: ChatMessage): boolean { + return ( + message instanceof ToolResultMessage || + message instanceof PermissionResolvedMessage || + message instanceof PermissionCancelledMessage || + (message instanceof PermissionRequestMessage && message.requestedTool.type === 'exit-plan-mode-tool-use') + ) +} + +function bashGroupId(messages: BashToolUseMessage[]): string { + return `bash-group-${messages[0]?.toolId ?? 'empty'}` +} + +export function buildConversationFeedRenderItems(messages: ChatMessage[]): ConversationFeedRenderItem[] { + const items: ConversationFeedRenderItem[] = [] + let previousRenderable: ChatMessage | null = null + let index = 0 + + while (index < messages.length) { + const message = messages[index] + + if (shouldSkipStandaloneMessage(message)) { + index += 1 + continue + } + + if (message instanceof BashToolUseMessage) { + const group: BashToolUseMessage[] = [] + const prevMessage = previousRenderable + const firstIndex = index + + while (index < messages.length) { + const candidate = messages[index] + if (candidate instanceof ToolResultMessage) { + index += 1 + continue + } + if (!(candidate instanceof BashToolUseMessage)) break + group.push(candidate) + previousRenderable = candidate + index += 1 + } + + if (group.length > 1) { + items.push({ + kind: 'bash-group', + id: bashGroupId(group), + messages: group, + index: firstIndex, + prevMessage, + }) + } else { + items.push({ + kind: 'message', + id: group[0].toolId, + message: group[0], + index: firstIndex, + prevMessage, + }) + } + continue + } + + items.push({ + kind: 'message', + id: `${message.type}-${index}`, + message, + index, + prevMessage: previousRenderable, + }) + previousRenderable = message + index += 1 + } + + return items +} diff --git a/web/src/lib/components/chat/ConversationFeed.svelte b/web/src/lib/components/chat/ConversationFeed.svelte index 3e233968..bd5d26e7 100644 --- a/web/src/lib/components/chat/ConversationFeed.svelte +++ b/web/src/lib/components/chat/ConversationFeed.svelte @@ -1,5 +1,6 @@ + +
+ + {#snippet body()} +
+ Bash + {commandLabel} + +
+ +
+ {#each messages as message (message.toolId)} +
+ + {message.command} + + {#if message.description} +
+ {message.description} +
+ {/if} +
+ {/each} +
+ {/snippet} +
+
From ee8bcb76bec8762495ab5c0f38690b3a014f7f5c Mon Sep 17 00:00:00 2001 From: anime girl <151763538+0xSolarPunk@users.noreply.github.com> Date: Sat, 30 May 2026 16:06:51 +0800 Subject: [PATCH 2/3] perf: polish chat streaming and queue UX (#177) Co-authored-by: yongkangc --- web/messages/en.json | 4 + web/src/lib/chat/__tests__/composer.test.ts | 45 ++++++- web/src/lib/chat/composer.svelte.ts | 68 ++++++++-- .../lib/components/chat/PromptComposer.svelte | 3 +- .../lib/components/chat/QueueControls.svelte | 125 ++++++++++++------ .../chat/__tests__/QueueControls.test.ts | 64 +++++++++ .../__tests__/router-integration.test.ts | 29 ++++ .../events/__tests__/router-preview.test.ts | 43 ++++++ web/src/lib/events/router.svelte.ts | 86 +++++++----- 9 files changed, 378 insertions(+), 89 deletions(-) diff --git a/web/messages/en.json b/web/messages/en.json index f129ac09..460516a3 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -119,9 +119,13 @@ "chat_queue_pause": "Pause", "chat_queue_pause_queue": "Pause queue", "chat_queue_paused": "Queue paused", + "chat_queue_pending_count": "{count} queued", + "chat_queue_pending_inputs": "Queued inputs", "chat_queue_remove_from_queue": "Remove from queue", "chat_queue_resume": "Resume", "chat_queue_resume_queue": "Resume queue", + "chat_queue_sending": "Sending", + "chat_queue_more_pending": "{count} more queued", "chat_tool_display_copy_to_clipboard": "Copy to clipboard", "chat_tool_display_jump_to_results": "Jump to results", "chat_tool_web_fetch_instruction": "Instruction", diff --git a/web/src/lib/chat/__tests__/composer.test.ts b/web/src/lib/chat/__tests__/composer.test.ts index c94f6591..0160d448 100644 --- a/web/src/lib/chat/__tests__/composer.test.ts +++ b/web/src/lib/chat/__tests__/composer.test.ts @@ -1,10 +1,15 @@ // Unit tests for ComposerState class. Tests synchronous state management; // submitMessage and localStorage-dependent draft methods are not tested here. -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { ComposerState } from '../composer.svelte'; describe('ComposerState', () => { + afterEach(() => { + vi.useRealTimers(); + localStorage.clear(); + }); + it('starts with empty state', () => { const state = new ComposerState(); expect(state.inputText).toBe(''); @@ -56,4 +61,42 @@ describe('ComposerState', () => { expect(state.inputText).toBe(''); expect(state.images).toEqual([]); }); + + it('debounces draft writes and persists the latest queued text', () => { + vi.useFakeTimers(); + const composer = new ComposerState(); + composer.inputText = 'first'; + composer.queueDraftSave('chat-1'); + composer.inputText = 'second'; + composer.queueDraftSave('chat-1'); + + expect(localStorage.getItem('chat_draft_chat-1')).toBeNull(); + vi.advanceTimersByTime(250); + + expect(localStorage.getItem('chat_draft_chat-1')).toBe('second'); + }); + + it('does not let an old chat draft save after restore changes the input', () => { + vi.useFakeTimers(); + const composer = new ComposerState(); + composer.inputText = 'old chat text'; + composer.queueDraftSave('old-chat'); + + composer.restoreDraft('new-chat'); + composer.inputText = 'new chat text'; + vi.runAllTimers(); + + expect(localStorage.getItem('chat_draft_old-chat')).toBeNull(); + }); + + it('flushes a pending draft immediately', () => { + vi.useFakeTimers(); + const composer = new ComposerState(); + composer.inputText = 'draft body'; + composer.queueDraftSave('chat-2'); + + composer.flushDraftSave(); + + expect(localStorage.getItem('chat_draft_chat-2')).toBe('draft body'); + }); }); diff --git a/web/src/lib/chat/composer.svelte.ts b/web/src/lib/chat/composer.svelte.ts index 74bddd7a..4a1c95ef 100644 --- a/web/src/lib/chat/composer.svelte.ts +++ b/web/src/lib/chat/composer.svelte.ts @@ -1,35 +1,78 @@ // Composer state: input text, image attachments, draft persistence, // and message submission. Manages the input area lifecycle for a single chat. - const DRAFT_PREFIX = 'chat_draft_'; +const DRAFT_PREFIX = 'chat_draft_'; +const DEFAULT_DRAFT_SAVE_DELAY_MS = 250; + +function draftKey(chatId: string): string { + return `${DRAFT_PREFIX}${chatId}`; +} + +function writeDraft(chatId: string, text: string): void { + if (!chatId) return; + const key = draftKey(chatId); + try { + if (text.trim()) { + localStorage.setItem(key, text); + } else { + localStorage.removeItem(key); + } + } catch { + // Storage can be full or unavailable. + } +} export class ComposerState { inputText = $state(''); images = $state([]); isSubmitting = $state(false); isDragActive = $state(false); + #draftSaveTimer: ReturnType | null = null; + #pendingDraftSave: { chatId: string; text: string } | null = null; /** Saves the current input text as a draft keyed by chat ID. */ saveDraft(chatId: string): void { + writeDraft(chatId, this.inputText); + } + + /** Schedules draft persistence without blocking every input event. */ + queueDraftSave(chatId: string, delayMs = DEFAULT_DRAFT_SAVE_DELAY_MS): void { if (!chatId) return; - const key = `${DRAFT_PREFIX}${chatId}`; - try { - if (this.inputText.trim()) { - localStorage.setItem(key, this.inputText); - } else { - localStorage.removeItem(key); - } - } catch { - // Storage full or unavailable + this.cancelDraftSave(); + this.#pendingDraftSave = { chatId, text: this.inputText }; + this.#draftSaveTimer = setTimeout(() => { + this.flushDraftSave(); + }, delayMs); + } + + /** Persists the latest queued draft immediately. */ + flushDraftSave(): void { + if (this.#draftSaveTimer) { + clearTimeout(this.#draftSaveTimer); + this.#draftSaveTimer = null; + } + const pending = this.#pendingDraftSave; + this.#pendingDraftSave = null; + if (pending) writeDraft(pending.chatId, pending.text); + } + + /** Drops a queued draft write, optionally scoped to one chat. */ + cancelDraftSave(chatId?: string): void { + if (chatId && this.#pendingDraftSave?.chatId !== chatId) return; + if (this.#draftSaveTimer) { + clearTimeout(this.#draftSaveTimer); + this.#draftSaveTimer = null; } + this.#pendingDraftSave = null; } /** Restores a previously saved draft for the given chat ID. */ restoreDraft(chatId: string): void { + this.cancelDraftSave(); this.inputText = ''; this.clearImages(); if (!chatId) return; - const key = `${DRAFT_PREFIX}${chatId}`; + const key = draftKey(chatId); try { const saved = localStorage.getItem(key); if (saved) { @@ -43,7 +86,7 @@ export class ComposerState { /** Removes the saved draft for the given chat ID. */ clearDraft(chatId: string): void { if (!chatId) return; - const key = `${DRAFT_PREFIX}${chatId}`; + const key = draftKey(chatId); try { localStorage.removeItem(key); } catch { @@ -70,6 +113,7 @@ export class ComposerState { /** Resets input text, images, and draft for the given chat. */ clearAfterSubmit(chatId: string): void { + this.cancelDraftSave(chatId); this.inputText = ''; this.images = []; this.clearDraft(chatId); diff --git a/web/src/lib/components/chat/PromptComposer.svelte b/web/src/lib/components/chat/PromptComposer.svelte index eea2d492..f20764b9 100644 --- a/web/src/lib/components/chat/PromptComposer.svelte +++ b/web/src/lib/components/chat/PromptComposer.svelte @@ -71,6 +71,7 @@ }); onDestroy(() => { + composerState.flushDraftSave(); imageAttachments.revokeAll(); }); @@ -144,7 +145,7 @@ // Auto-save draft on input. const chatId = sessions.selectedChatId; if (chatId) { - composerState.saveDraft(chatId); + composerState.queueDraftSave(chatId); } } diff --git a/web/src/lib/components/chat/QueueControls.svelte b/web/src/lib/components/chat/QueueControls.svelte index 8921c704..22d36739 100644 --- a/web/src/lib/components/chat/QueueControls.svelte +++ b/web/src/lib/components/chat/QueueControls.svelte @@ -2,6 +2,7 @@ import type { QueueState } from '$lib/types/chat'; import * as m from '$lib/paraglide/messages.js'; import { X, Play, Pause } from '@lucide/svelte'; + import { cn } from '$lib/utils/cn'; interface Props { queue: QueueState | null; @@ -12,58 +13,98 @@ let { queue, onResume, onPause, onDequeue }: Props = $props(); - const pendingEntries = $derived( - queue?.entries.filter((e) => e.status === 'queued') ?? [] - ); + const VISIBLE_ENTRY_LIMIT = 3; + const PREVIEW_CHAR_LIMIT = 180; - const hasEntries = $derived(pendingEntries.length > 0); + const queueEntries = $derived(queue?.entries ?? []); + const queuedEntryCount = $derived(queueEntries.filter((entry) => entry.status === 'queued').length); + const visibleEntries = $derived(queueEntries.slice(0, VISIBLE_ENTRY_LIMIT)); + const hiddenEntryCount = $derived(Math.max(0, queueEntries.length - visibleEntries.length)); + const hasEntries = $derived(queueEntries.length > 0); const visible = $derived(hasEntries); + + function previewContent(content: string): string { + if (content.length <= PREVIEW_CHAR_LIMIT) return content; + return `${content.slice(0, PREVIEW_CHAR_LIMIT).trimEnd()}...`; + } + + function entryStatusLabel(status: 'queued' | 'sending'): string { + return status === 'sending' ? m.chat_queue_sending() : m.chat_queue_pending_inputs(); + } {#if visible} -
-
- {#each pendingEntries as entry (entry.id)} -
- - {entry.content} - - +
+
+
+ {m.chat_queue_pending_inputs()} + + {m.chat_queue_pending_count({ count: queuedEntryCount })} + +
+ + {#if queue?.paused} + {m.chat_queue_paused()} + {/if} +
+ +
+ {#each visibleEntries as entry (entry.id)} +
+
+
+ {entryStatusLabel(entry.status)} +
+ PREVIEW_CHAR_LIMIT && 'max-h-[4.75rem] overflow-hidden' + )}> + {previewContent(entry.content)} + +
+ {#if entry.status === 'queued'} + + {/if}
{/each}
- {#if queue?.paused} - {m.chat_queue_paused()} + {#if hiddenEntryCount > 0} +
+ {m.chat_queue_more_pending({ count: hiddenEntryCount })} +
{/if} - {#if queue?.paused} - - {:else if hasEntries && onPause} - - {/if} +
+ {#if queue?.paused} + + {:else if hasEntries && onPause} + + {/if} +
{/if} diff --git a/web/src/lib/components/chat/__tests__/QueueControls.test.ts b/web/src/lib/components/chat/__tests__/QueueControls.test.ts index fa55a5bb..2ec73e55 100644 --- a/web/src/lib/components/chat/__tests__/QueueControls.test.ts +++ b/web/src/lib/components/chat/__tests__/QueueControls.test.ts @@ -75,4 +75,68 @@ describe('QueueControls', () => { expect(content).toBeTruthy(); expect(content?.textContent).toBe(multiline); }); + + it('caps visible queued entries and shows the hidden count', () => { + render(QueueControls, { + queue: { + paused: false, + entries: Array.from({ length: 5 }, (_, index) => ({ + id: `q${index}`, + content: `queued ${index}`, + status: 'queued' as const, + createdAt: '2026-02-27T00:00:00.000Z', + })), + }, + onResume: vi.fn(), + onPause: vi.fn(), + onDequeue: vi.fn(), + }); + + expect(screen.getByText('queued 0')).toBeTruthy(); + expect(screen.getByText('queued 2')).toBeTruthy(); + expect(screen.queryByText('queued 3')).toBeNull(); + expect(screen.getByText(m.chat_queue_more_pending({ count: 2 }))).toBeTruthy(); + }); + + it('truncates long queued previews', () => { + const longText = 'x'.repeat(220); + const { container } = render(QueueControls, { + queue: { + paused: false, + entries: [{ + id: 'q1', + content: longText, + status: 'queued', + createdAt: '2026-02-27T00:00:00.000Z', + }], + }, + onResume: vi.fn(), + onPause: vi.fn(), + onDequeue: vi.fn(), + }); + + const content = container.querySelector('.whitespace-pre-wrap'); + expect(content?.textContent).toBe(`${'x'.repeat(180)}...`); + }); + + it('shows sending entries without offering the queued remove action', () => { + render(QueueControls, { + queue: { + paused: false, + entries: [{ + id: 'q1', + content: 'dispatching now', + status: 'sending', + createdAt: '2026-02-27T00:00:00.000Z', + }], + }, + onResume: vi.fn(), + onPause: vi.fn(), + onDequeue: vi.fn(), + }); + + expect(screen.getByText(m.chat_queue_sending())).toBeTruthy(); + expect(screen.queryByRole('button', { name: m.chat_queue_remove_from_queue() })).toBeNull(); + expect(screen.getByText(m.chat_queue_pending_count({ count: 0 }))).toBeTruthy(); + }); }); diff --git a/web/src/lib/events/__tests__/router-integration.test.ts b/web/src/lib/events/__tests__/router-integration.test.ts index 9951769b..5b1a4a03 100644 --- a/web/src/lib/events/__tests__/router-integration.test.ts +++ b/web/src/lib/events/__tests__/router-integration.test.ts @@ -5,6 +5,7 @@ import type { EventRouterStores } from '../router.svelte'; import type { WsConnection } from '$lib/ws/connection.svelte'; import type { DrainHandle } from '$lib/ws/drain'; import type { PendingUserInput } from '$shared/pending-user-input'; +import type { ChatMessage } from '$shared/chat-types'; function createStores(overrides: Partial = {}): EventRouterStores { return { @@ -177,4 +178,32 @@ describe('event router integration', () => { expect(pendingUserInputs[0]?.deliveryStatus).toBe('failed'); }); + + it('preserves streamed output order before same-drain stop messages', () => { + let currentMessages: ChatMessage[] = []; + const stores = createStores({ + chatMessages: () => currentMessages, + setChatMessages: (updater) => { + currentMessages = typeof updater === 'function' ? updater(currentMessages) : updater; + }, + }); + + renderRouterWithRawMessages([ + { + type: 'agent-run-output', + chatId: 'chat-a', + messages: [{ type: 'assistant-message', timestamp: '2026-05-14T00:00:01.000Z', content: 'streamed' }], + }, + { + type: 'chat-session-stopped', + chatId: 'chat-a', + success: true, + }, + ], stores); + + expect(currentMessages.map((message) => 'content' in message ? String(message.content) : '')).toEqual([ + 'streamed', + 'Chat interrupted by user.', + ]); + }); }); diff --git a/web/src/lib/events/__tests__/router-preview.test.ts b/web/src/lib/events/__tests__/router-preview.test.ts index 9fe655ba..33965703 100644 --- a/web/src/lib/events/__tests__/router-preview.test.ts +++ b/web/src/lib/events/__tests__/router-preview.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { selectPreviewFromBatch, _extractFirstLine, + createAgentOutputAccumulator, } from '../router.svelte'; import { UserMessage, @@ -11,6 +12,7 @@ import { UnknownToolUseMessage, } from '$shared/chat-types'; import type { ChatMessage } from '$shared/chat-types'; +import { AgentRunOutputMessage } from '$shared/ws-events'; describe('extractFirstLine', () => { it('returns text before first newline', () => { @@ -34,6 +36,47 @@ describe('extractFirstLine', () => { }); }); +describe('createAgentOutputAccumulator', () => { + it('coalesces same-drain output chunks into one message write', () => { + let current: ChatMessage[] = []; + let writes = 0; + const accumulator = createAgentOutputAccumulator({ + setChatMessages: (updater) => { + writes += 1; + current = typeof updater === 'function' ? updater(current) : updater; + }, + }); + + accumulator.enqueue(new AgentRunOutputMessage( + 'chat-a', + [new AssistantMessage('2024-01-01T00:00:00Z', 'first')], + 'req-1', + )); + accumulator.enqueue(new AgentRunOutputMessage( + 'chat-a', + [new AssistantMessage('2024-01-01T00:00:01Z', 'second')], + 'req-1', + )); + accumulator.flush(); + + expect(writes).toBe(1); + expect(current.map((message) => (message as AssistantMessage).content)).toEqual(['first', 'second']); + }); + + it('does not write when no output was queued', () => { + let writes = 0; + const accumulator = createAgentOutputAccumulator({ + setChatMessages: () => { + writes += 1; + }, + }); + + accumulator.flush(); + + expect(writes).toBe(0); + }); +}); + describe('selectPreviewFromBatch', () => { it('returns first line from the latest assistant message', () => { const messages: ChatMessage[] = [ diff --git a/web/src/lib/events/router.svelte.ts b/web/src/lib/events/router.svelte.ts index ba771148..016028c5 100644 --- a/web/src/lib/events/router.svelte.ts +++ b/web/src/lib/events/router.svelte.ts @@ -136,17 +136,22 @@ export function selectPreviewFromBatch( return null; } -// Applies server-provided ChatMessage[] from a broadcast envelope. -// Sidebar preview is handled by the pre-filter in createEventRouter -// which runs for all chats (including background ones). -function applyServerMessages( - msg: AgentRunOutputMessage, - stores: EventRouterStores, -) { - if (msg.messages.length === 0) return; - const current = stores.chatMessages(); - const updated = applyChatMessages(current, msg.messages); - stores.setChatMessages(updated); +// Coalesces output chunks from one drain pass into one message-array write. +export function createAgentOutputAccumulator(stores: Pick) { + let pendingMessages: ChatMessage[] = []; + + return { + enqueue(msg: AgentRunOutputMessage) { + if (msg.messages.length === 0) return; + pendingMessages.push(...msg.messages); + }, + flush() { + if (pendingMessages.length === 0) return; + const messages = pendingMessages; + pendingMessages = []; + stores.setChatMessages((current) => applyChatMessages(current, messages)); + }, + }; } function markPendingUserInputDelivery( @@ -183,7 +188,10 @@ function createHelpers(stores: EventRouterStores) { } // Builds the dispatch table mapping EventKey to handler functions. -function buildDispatch(stores: EventRouterStores): Partial void>> { +function buildDispatch( + stores: EventRouterStores, + outputAccumulator: ReturnType, +): Partial void>> { const { activateLoadingFor, clearLoadingIndicators, markChatsAsCompleted } = createHelpers(stores); const selectedChat = stores.selectedChat(); @@ -303,28 +311,30 @@ function buildDispatch(stores: EventRouterStores): Partial { - if (!(msg instanceof AgentRunOutputMessage)) return; - activateLoadingFor(msg.chatId); - stores.setCanAbort(true); - onChatProcessing(msg.chatId); - markPendingUserInputDelivery(msg.clientRequestId, stores, 'accepted'); - applyServerMessages(msg, stores); - handlePlanModeMessages(msg, planModeCtx); - handlePermissionLifecycleFromBatch(msg, permLifecycleCtx); + 'agent-run-output': (msg) => { + if (!(msg instanceof AgentRunOutputMessage)) return; + activateLoadingFor(msg.chatId); + stores.setCanAbort(true); + onChatProcessing(msg.chatId); + markPendingUserInputDelivery(msg.clientRequestId, stores, 'accepted'); + outputAccumulator.enqueue(msg); + handlePlanModeMessages(msg, planModeCtx); + handlePermissionLifecycleFromBatch(msg, permLifecycleCtx); }, 'agent-run-finished': (msg) => { if (msg instanceof AgentRunFinishedMessage) { + outputAccumulator.flush(); markPendingUserInputDelivery(msg.clientRequestId, stores, 'accepted'); handleAgentComplete(msg, lifecycleCtx); } - }, - 'agent-run-failed': (msg) => { - if (msg instanceof AgentRunFailedMessage) { - markPendingUserInputDelivery(msg.clientRequestId, stores, 'failed'); - handleAgentError(msg, lifecycleCtx); - } - }, + }, + 'agent-run-failed': (msg) => { + if (msg instanceof AgentRunFailedMessage) { + outputAccumulator.flush(); + markPendingUserInputDelivery(msg.clientRequestId, stores, 'failed'); + handleAgentError(msg, lifecycleCtx); + } + }, 'chat-session-created': (msg) => { if (msg instanceof ChatSessionCreatedMessage) handleChatCreated(msg, chatEventCtx); @@ -338,7 +348,10 @@ function buildDispatch(stores: EventRouterStores): Partial { - if (msg instanceof ChatSessionStoppedMessage) handleChatAborted(msg, chatEventCtx); + if (msg instanceof ChatSessionStoppedMessage) { + outputAccumulator.flush(); + handleChatAborted(msg, chatEventCtx); + } }, 'chat-processing-updated': (msg) => { if (msg instanceof ChatProcessingUpdatedMessage) handleChatStatus(msg, chatEventCtx); @@ -365,6 +378,7 @@ function buildDispatch(stores: EventRouterStores): Partial { if (!(msg instanceof PendingUserInputClearedMessage)) return; + outputAccumulator.flush(); if (msg.reason !== 'persisted') { stores.clearPendingUserInput(msg.clientRequestId); return; @@ -381,7 +395,10 @@ function buildDispatch(stores: EventRouterStores): Partial { - if (msg instanceof WsFaultMessage) handleWsError(msg, chatEventCtx); + if (msg instanceof WsFaultMessage) { + outputAccumulator.flush(); + handleWsError(msg, chatEventCtx); + } }, 'chat-title-updated': (msg) => { @@ -418,7 +435,8 @@ export function createEventRouter( const newMessages = drainHandle.drain(); if (newMessages.length === 0) return; - const dispatch = buildDispatch(stores); + const outputAccumulator = createAgentOutputAccumulator(stores); + const dispatch = buildDispatch(stores, outputAccumulator); const selectedChat = stores.selectedChat(); const currentChatId = stores.currentChatId(); @@ -457,8 +475,10 @@ export function createEventRouter( const handler = dispatch[event.key]; if (handler) handler(event.message); } + + outputAccumulator.flush(); + }); }); - }); -} + } export { extractFirstLine as _extractFirstLine }; From e0b3b1d6391cffdde86089fb1e90534ecc107648 Mon Sep 17 00:00:00 2001 From: 0xSolarPunk <151763538+0xSolarPunk@users.noreply.github.com> Date: Sat, 30 May 2026 01:19:52 +0000 Subject: [PATCH 3/3] fix: preserve scroll position when loading older messages --- .../conversation-scroll-controller.test.ts | 51 +++++++++++++++++++ .../conversation-scroll-controller.svelte.ts | 46 ++++++++++------- .../chat/ConversationWorkspace.svelte | 8 +-- 3 files changed, 84 insertions(+), 21 deletions(-) diff --git a/web/src/lib/chat/__tests__/conversation-scroll-controller.test.ts b/web/src/lib/chat/__tests__/conversation-scroll-controller.test.ts index 6ad94958..a25dd05d 100644 --- a/web/src/lib/chat/__tests__/conversation-scroll-controller.test.ts +++ b/web/src/lib/chat/__tests__/conversation-scroll-controller.test.ts @@ -96,6 +96,57 @@ describe('ConversationScrollController', () => { cleanup?.(); }); + it('preserves the viewport anchor after older messages render', async () => { + const scroller = { scrollTop: 40, scrollHeight: 800, clientHeight: 400 } as HTMLDivElement; + const chatState = { + isUserScrolledUp: true, + loadMoreMessages: vi.fn(async () => { + Object.defineProperty(scroller, 'scrollHeight', { value: 1100, configurable: true }); + return true; + }), + }; + + const controller = new ConversationScrollController({ + getScrollContainer: () => scroller, + getQueueContainer: () => undefined, + chatState: chatState as never, + sessions: { selectedChatId: 'chat-1' }, + }); + + controller.isPinnedToBottom = false; + await controller.loadMoreMessagesPreservingAnchor('chat-1', 800, 40); + + expect(chatState.loadMoreMessages).toHaveBeenCalledWith('chat-1'); + expect(scroller.scrollTop).toBe(340); + expect(chatState.isUserScrolledUp).toBe(true); + expect(controller.isPinnedToBottom).toBe(false); + }); + + it('does not restore an older-message anchor after switching chats', async () => { + const scroller = { scrollTop: 40, scrollHeight: 800, clientHeight: 400 } as HTMLDivElement; + const sessions = { selectedChatId: 'chat-1' }; + const chatState = { + isUserScrolledUp: true, + loadMoreMessages: vi.fn(async () => { + sessions.selectedChatId = 'chat-2'; + Object.defineProperty(scroller, 'scrollHeight', { value: 1100, configurable: true }); + return true; + }), + }; + + const controller = new ConversationScrollController({ + getScrollContainer: () => scroller, + getQueueContainer: () => undefined, + chatState: chatState as never, + sessions, + }); + + await controller.loadMoreMessagesPreservingAnchor('chat-1', 800, 40); + + expect(scroller.scrollTop).toBe(40); + expect(chatState.loadMoreMessages).toHaveBeenCalledWith('chat-1'); + }); + it('keeps the viewport pinned to bottom when the scroll container height changes', () => { const scrollToBottom = vi.spyOn(ConversationScrollController.prototype, 'scrollToBottom'); const scroller = { scrollTop: 120, scrollHeight: 800, clientHeight: 520 } as HTMLDivElement; diff --git a/web/src/lib/chat/conversation-scroll-controller.svelte.ts b/web/src/lib/chat/conversation-scroll-controller.svelte.ts index 2527e997..41d7e468 100644 --- a/web/src/lib/chat/conversation-scroll-controller.svelte.ts +++ b/web/src/lib/chat/conversation-scroll-controller.svelte.ts @@ -2,15 +2,16 @@ // near-bottom detection, pinned-to-bottom state, infinite scroll // loading, and layout resize reconciliation. - import { reconcileScrollAfterHeightDelta } from '$lib/chat/scroll-anchor'; - import type { ChatState } from '$lib/chat/state.svelte'; +import { tick } from 'svelte'; +import { reconcileScrollAfterHeightDelta } from '$lib/chat/scroll-anchor'; +import type { ChatState } from '$lib/chat/state.svelte'; export interface ScrollControllerDeps { getScrollContainer: () => HTMLDivElement | null; - getQueueContainer: () => HTMLDivElement | undefined; - chatState: ChatState; - sessions: { selectedChatId: string | null }; - } + getQueueContainer: () => HTMLDivElement | undefined; + chatState: ChatState; + sessions: { selectedChatId: string | null }; +} export class ConversationScrollController { isPinnedToBottom = $state(true); @@ -40,9 +41,9 @@ export class ConversationScrollController { this.isScrollingToTop = true; try { - if (this.deps.chatState.hasMoreMessages) { - await this.deps.chatState.loadAllMessages(chatId); - } + if (this.deps.chatState.hasMoreMessages) { + await this.deps.chatState.loadAllMessages(chatId); + } const node = this.deps.getScrollContainer(); if (node) node.scrollTop = 0; } finally { @@ -60,19 +61,28 @@ export class ConversationScrollController { if (node.scrollTop < 100 && this.deps.chatState.hasMoreMessages) { const chatId = this.deps.sessions.selectedChatId; if (chatId) { - const prevHeight = node.scrollHeight; - const prevTop = node.scrollTop; - this.deps.chatState.loadMoreMessages(chatId).then((loaded) => { - const container = this.deps.getScrollContainer(); - if (loaded && container) { - const newHeight = container.scrollHeight; - container.scrollTop = prevTop + (newHeight - prevHeight); - } - }); + void this.loadMoreMessagesPreservingAnchor(chatId, node.scrollHeight, node.scrollTop); } } } + async loadMoreMessagesPreservingAnchor(chatId: string, prevHeight: number, prevTop: number): Promise { + const loaded = await this.deps.chatState.loadMoreMessages(chatId); + if (!loaded) return; + if (this.deps.sessions.selectedChatId !== chatId) return; + + await tick(); + if (this.deps.sessions.selectedChatId !== chatId) return; + + const container = this.deps.getScrollContainer(); + if (!container) return; + + const newHeight = container.scrollHeight; + container.scrollTop = prevTop + (newHeight - prevHeight); + this.deps.chatState.isUserScrolledUp = true; + this.isPinnedToBottom = false; + } + // Creates a ResizeObserver for the queue controls container that // reconciles scroll position when the queue panel height changes. // Returns a cleanup function to disconnect the observer. diff --git a/web/src/lib/components/chat/ConversationWorkspace.svelte b/web/src/lib/components/chat/ConversationWorkspace.svelte index 95cebe3f..6f481b94 100644 --- a/web/src/lib/components/chat/ConversationWorkspace.svelte +++ b/web/src/lib/components/chat/ConversationWorkspace.svelte @@ -245,9 +245,11 @@ // calls from loadChat fire against an undefined container. $effect(() => { const _container = scrollContainer; - if (_container && chatState.displayMessageCount > 0) { - requestAnimationFrame(() => scroll.scrollToBottom()); - } + untrack(() => { + if (_container && chatState.displayMessageCount > 0 && localSettings.autoScrollToBottom) { + requestAnimationFrame(() => scroll.scrollToBottom()); + } + }); }); // Preserves viewport anchoring when queue controls change height.