perf: keep unchanged transcript rows stable while a chat reply streams (#11962)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-23 01:37:44 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8a0318a28f
commit 4c97368c9d
3 changed files with 87 additions and 3 deletions
@@ -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<number | null>(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).
@@ -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])
})
})
@@ -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 {