diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 5f31bd4272..c5fede3162 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -48,7 +48,7 @@ import { getAiChatManager } from './aiChatManagerContext' import ChatTypingIndicator from './ChatTypingIndicator.svelte' import ToolGroupDisplay from './ToolGroupDisplay.svelte' - import { chatItemKey, groupToolRuns } from './toolGroups' + import { chatItemKey, createChatItemGrouper } from './toolGroups' import AIChatInput from './AIChatInput.svelte' import { STARTER_PLACEHOLDER, type ComposerSelection } from './composerBox' import AttachedFilesBar from './files/AttachedFilesBar.svelte' @@ -189,7 +189,8 @@ const mcpMenu = new McpMenu(aiChatManager, () => assistantSettings?.open('mcp')) let plusMenuOpen = $state(false) let editingMessageIndex = $state(null) - const chatItems = $derived(groupToolRuns(messages)) + const groupChatItems = createChatItemGrouper() + const chatItems = $derived(groupChatItems(messages)) // Escape stops the generation when focus is on the chat (or parked on // body), but stays with other widgets (e.g. the session's Monaco editor). diff --git a/frontend/src/lib/components/copilot/chat/toolGroups.test.ts b/frontend/src/lib/components/copilot/chat/toolGroups.test.ts index 4dd3b376fe..d0f14e1f52 100644 --- a/frontend/src/lib/components/copilot/chat/toolGroups.test.ts +++ b/frontend/src/lib/components/copilot/chat/toolGroups.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' import type { DisplayMessage } from './shared' -import { groupHeader, groupToolRuns, type GroupState, type ToolGroup } from './toolGroups' +import { + createChatItemGrouper, + groupHeader, + groupToolRuns, + type GroupState, + type ToolGroup +} from './toolGroups' let nextId = 0 function tool( @@ -274,3 +280,37 @@ describe('groupToolRuns', () => { expect(header([mcp('list_issues'), other])).toBe('List issues, get issue') }) }) + +describe('createChatItemGrouper', () => { + it('keeps the history rows while only the live answer changes', () => { + const group = createChatItemGrouper() + const history = [ + assistant('done'), + tool('read_workspace_item'), + tool('list_runs'), + assistant('more') + ] + const first = group([...history, assistant('a', { streaming: true })]) + const second = group([...history, assistant('ab', { streaming: true })]) + expect(second).toHaveLength(first.length) + second.slice(0, -1).forEach((item, i) => expect(item).toBe(first[i])) + expect(second.at(-1)).toMatchObject({ kind: 'message', index: 4, message: { content: 'ab' } }) + expect(shape([...history, assistant('ab', { streaming: true })])).toEqual( + second.map((item) => + item.kind === 'group' ? { [item.groupKind]: item.entries.map((e) => e.index) } : item.index + ) + ) + }) + + it('keeps every row but the one a tool status change replaced', () => { + const group = createChatItemGrouper() + const history = [assistant('done'), tool('run_script'), assistant('more')] + const first = group(history) + const updated = [history[0], { ...history[1], isLoading: false }, history[2]] + const second = group(updated) + expect(second[0]).toBe(first[0]) + expect(second[1]).not.toBe(first[1]) + expect(second[1]).toMatchObject({ message: updated[1] }) + expect(second[2]).toBe(first[2]) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/toolGroups.ts b/frontend/src/lib/components/copilot/chat/toolGroups.ts index f3e3ad0aa9..5c0a8c2e1e 100644 --- a/frontend/src/lib/components/copilot/chat/toolGroups.ts +++ b/frontend/src/lib/components/copilot/chat/toolGroups.ts @@ -295,6 +295,49 @@ export function groupToolRuns(messages: DisplayMessage[]): ChatItem[] { return items } +function sameChatItem(a: ChatItem, b: ChatItem): boolean { + if (a.kind === 'message') { + return b.kind === 'message' && a.message === b.message && a.index === b.index + } + return ( + b.kind === 'group' && + a.groupKind === b.groupKind && + a.key === b.key && + a.target === b.target && + a.entries.length === b.entries.length && + a.entries.every((e, i) => e.message === b.entries[i].message && e.index === b.entries[i].index) + ) +} + +/** `groupToolRuns` for a transcript that is re-sent whole on every streamed chunk and on every + * tool status change: a row whose messages did not change keeps its previous item object. A + * fresh item per row re-renders the whole history each time (every answer re-parsing its + * markdown, every tool row its result), which freezes the tab on long sessions. + * + * A changed message is told apart by identity alone, so a row must be replaced, never mutated + * in place, for its grouping to update. */ +export function createChatItemGrouper(): (messages: DisplayMessage[]) => ChatItem[] { + let settled: DisplayMessage[] = [] + let settledItems: ChatItem[] = [] + return (messages) => { + // The live answer only ever comes last, and it can neither join nor end a group + // differently than the end of the transcript does, so it is grouped on its own and the + // rows before it are not regrouped while it streams. + const last = messages.at(-1) + const live = last?.role === 'assistant' && last.streaming ? last : undefined + const n = live ? messages.length - 1 : messages.length + if (n !== settled.length || settled.some((m, i) => m !== messages[i])) { + const previous = new Map(settledItems.map((item) => [chatItemKey(item), item])) + settled = messages.slice(0, n) + settledItems = groupToolRuns(settled).map((item) => { + const before = previous.get(chatItemKey(item)) + return before && sameChatItem(before, item) ? before : item + }) + } + return live ? [...settledItems, { kind: 'message', message: live, index: n }] : settledItems + } +} + // The server's full resource path: two servers can share a last segment (u/alice/github, // f/team/github), so only the display shortens it. function mcpServerPath(call: ToolDisplayMessage): string | undefined {