From 1c9e78aea033c76f7b38a404801d18caac9cdd9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AE=8B=E5=BF=B5?= Date: Sun, 2 Aug 2026 11:01:49 +0100 Subject: [PATCH] fix(ui): increasing webchat lags per keystroke on slash commands and input-history recall in long sessions (#111539) * perf(ui): replace tool-card localeCompare signature with a version counter (#111526) * test(ui): use typed jsdom environment fixture * test(ui): restore jsdom fixture globals * fix(ui): preserve tool disclosure across filters * test(ui): exercise disclosure toggles through DOM --------- Co-authored-by: Peter Steinberger --- ui/src/pages/chat/chat-thread.test.ts | 442 ++++++++++++++++++++ ui/src/pages/chat/chat-thread.ts | 57 ++- ui/src/pages/chat/components/chat-thread.ts | 31 +- 3 files changed, 506 insertions(+), 24 deletions(-) diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 3483bc83e95a..2fc255b223d0 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -10,10 +10,12 @@ import { buildCachedChatItems, coalesceStreamRuns, collapseCompletedTurnWork, + getExpansionStateVersion, getExpandedToolCards, getExpandedUserMessages, persistedMessageEntryId, resetChatThreadState, + setExpansionState, syncToolCardExpansionState, } from "./chat-thread.ts"; import { rememberLiveTerminalRun } from "./terminal-message-identity.ts"; @@ -3585,6 +3587,446 @@ describe("tool expansion state", () => { }); }); +describe("expansion-state render dependencies", () => { + it("reads unchanged tool and user expansion maps without locale sorting", () => { + resetChatThreadState(); + const tools = getExpandedToolCards("fast-session"); + const users = getExpandedUserMessages("fast-session"); + for (let index = 0; index < 128; index += 1) { + setExpansionState(tools, `tool-${127 - index}`, index % 2 === 0); + setExpansionState(users, `user-${127 - index}`, index % 2 === 0); + } + const compare = vi.spyOn(String.prototype, "localeCompare"); + try { + for (let render = 0; render < 3; render += 1) { + expect(getExpansionStateVersion(tools)).toBe(tools.size); + expect(getExpansionStateVersion(users)).toBe(users.size); + } + expect(compare.mock.calls.length).toBe(0); + } finally { + compare.mockRestore(); + } + }); + + it("invalidates same-size toggles but keeps no-op updates stable", () => { + resetChatThreadState(); + const cards = getExpandedToolCards("version-session"); + expect(getExpansionStateVersion(cards)).toBe(0); + + setExpansionState(cards, "card", false); + const initializedVersion = getExpansionStateVersion(cards); + expect(initializedVersion).toBe(1); + + setExpansionState(cards, "card", false); + expect(getExpansionStateVersion(cards)).toBe(initializedVersion); + + setExpansionState(cards, "card", true); + expect(getExpansionStateVersion(cards)).toBe(initializedVersion + 1); + expect(getExpandedToolCards("version-session").size).toBe(1); + }); + + it("shares user-message render versions across equivalent session aliases", () => { + resetChatThreadState(); + setExpansionState(getExpandedUserMessages("main"), "user-message", true); + + expect(getExpansionStateVersion(getExpandedUserMessages("main"))).toBe(1); + expect(getExpansionStateVersion(getExpandedUserMessages("agent:main:main"))).toBe(1); + + setExpansionState(getExpandedUserMessages("agent:main:main"), "user-message", false); + expect(getExpansionStateVersion(getExpandedUserMessages("main"))).toBe(2); + expect(getExpandedUserMessages("main").get("user-message")).toBe(false); + }); + + it("keeps expanded disclosures while transcript search temporarily hides them", () => { + resetChatThreadState(); + const sessionKey = "search-preserves-disclosures"; + const messages = [ + { role: "user", content: "hidden user prompt" }, + { + role: "assistant", + content: [ + { type: "text", text: "hidden assistant reply" }, + { type: "toolcall", id: "search-hidden-call", name: "browser.open" }, + ], + }, + { role: "user", content: "needle" }, + ]; + const unfiltered = buildCachedChatItems(createProps({ sessionKey, messages })); + syncToolCardExpansionState(sessionKey, unfiltered, false); + const tools = getExpandedToolCards(sessionKey); + const cardId = expectDefined( + [...tools.keys()].find((key) => key.includes(":toolcard:")), + "unfiltered transcript tool card", + ); + setExpansionState(tools, cardId, true); + const users = getExpandedUserMessages(sessionKey); + const hiddenUserGroup = expectDefined( + unfiltered.find( + (item): item is MessageGroup => item.kind === "group" && item.role === "user", + ), + "unfiltered transcript user group", + ); + const hiddenUserId = expectDefined(hiddenUserGroup.messages[0]?.key, "hidden user message"); + setExpansionState(users, hiddenUserId, true); + + const filtered = buildCachedChatItems( + createProps({ sessionKey, messages, searchOpen: true, searchQuery: "needle" }), + ); + expect(filtered).not.toBe(unfiltered); + expect(filtered.every((item) => item.kind !== "group" || item.role !== "assistant")).toBe(true); + syncToolCardExpansionState(sessionKey, filtered, false, true); + + expect(tools.get(cardId)).toBe(true); + expect(users.get(hiddenUserId)).toBe(true); + + const restored = buildCachedChatItems(createProps({ sessionKey, messages })); + syncToolCardExpansionState(sessionKey, restored, false); + + expect(tools.get(cardId)).toBe(true); + expect(users.get(hiddenUserId)).toBe(true); + }); + + it("prunes cards removed during search when the same visible projection becomes complete", () => { + resetChatThreadState(); + const sessionKey = "search-removes-hidden-card"; + const group = (key: string): MessageGroup => ({ + kind: "group", + key, + role: "assistant", + messages: [ + { + key, + message: { + role: "assistant", + content: [{ type: "toolcall", id: `call-${key}`, name: "browser.open" }], + }, + }, + ], + timestamp: 1, + isStreaming: false, + }); + const hidden = group("hidden-card"); + const visible = group("visible-card"); + const visibleProjection = [visible]; + syncToolCardExpansionState(sessionKey, [hidden, visible], false); + const expanded = getExpandedToolCards(sessionKey); + const hiddenCardId = "hidden-card:toolcard:0"; + setExpansionState(expanded, hiddenCardId, true); + + syncToolCardExpansionState(sessionKey, visibleProjection, false, true); + expect(expanded.get(hiddenCardId)).toBe(true); + const filteredVersion = getExpansionStateVersion(expanded); + + syncToolCardExpansionState(sessionKey, visibleProjection, false); + + expect(expanded.has(hiddenCardId)).toBe(false); + expect(expanded.has("visible-card:toolcard:0")).toBe(true); + expect(getExpansionStateVersion(expanded)).toBe(filteredVersion + 1); + }); + + it("auto-expands retained cards hidden while transcript search is active", () => { + resetChatThreadState(); + const sessionKey = "search-auto-expands-hidden-cards"; + const messages = [ + { + role: "assistant", + content: [ + { type: "text", text: "hidden assistant reply" }, + { type: "toolcall", id: "hidden-call", name: "browser.open" }, + ], + }, + { role: "user", content: "another turn" }, + { + role: "assistant", + content: [ + { type: "text", text: "needle visible reply" }, + { type: "toolcall", id: "visible-call", name: "browser.open" }, + ], + }, + ]; + const complete = buildCachedChatItems(createProps({ sessionKey, messages })); + syncToolCardExpansionState(sessionKey, complete, false); + const expanded = getExpandedToolCards(sessionKey); + const cardIds = [...expanded.keys()]; + const hiddenCardId = expectDefined(cardIds[0], "hidden retained card"); + const visibleCardId = expectDefined(cardIds[1], "visible retained card"); + + const filtered = buildCachedChatItems( + createProps({ sessionKey, messages, searchOpen: true, searchQuery: "needle" }), + ); + syncToolCardExpansionState(sessionKey, filtered, false, true); + expect(expanded.get(hiddenCardId)).toBe(false); + expect(expanded.get(visibleCardId)).toBe(false); + + syncToolCardExpansionState(sessionKey, filtered, true, true); + + expect(expanded.get(hiddenCardId)).toBe(true); + expect(expanded.get(visibleCardId)).toBe(true); + syncToolCardExpansionState( + sessionKey, + buildCachedChatItems(createProps({ sessionKey, messages })), + true, + ); + expect(expanded.get(hiddenCardId)).toBe(true); + expect(expanded.get(visibleCardId)).toBe(true); + }); + + it("prunes expansion state when a tool card leaves the transcript", () => { + resetChatThreadState(); + const group: MessageGroup = { + kind: "group", + key: "assistant-pruned", + role: "assistant", + messages: [ + { + key: "assistant-pruned", + message: { + role: "assistant", + content: [{ type: "toolcall", id: "call-pruned", name: "browser.open" }], + }, + }, + ], + timestamp: 1, + isStreaming: false, + }; + syncToolCardExpansionState("prune-session", [group], false); + const expanded = getExpandedToolCards("prune-session"); + expect(expanded.has("assistant-pruned:toolcard:0")).toBe(true); + const populatedVersion = getExpansionStateVersion(expanded); + + syncToolCardExpansionState("prune-session", [], false); + + expect(expanded.has("assistant-pruned:toolcard:0")).toBe(false); + expect(getExpansionStateVersion(expanded)).toBe(populatedVersion + 1); + }); + + it("drops render versions with evicted and reset session maps", () => { + resetChatThreadState(); + const evicted = getExpandedToolCards("evicted-session"); + setExpansionState(evicted, "card", true); + for (let index = 0; index < 20; index += 1) { + getExpandedToolCards(`other-session-${index}`); + } + + expect(getExpandedToolCards("evicted-session")).not.toBe(evicted); + expect(getExpansionStateVersion(getExpandedToolCards("evicted-session"))).toBe(0); + + setExpansionState(getExpandedUserMessages("reset-session"), "message", true); + resetChatThreadState(); + expect(getExpansionStateVersion(getExpandedUserMessages("reset-session"))).toBe(0); + }); + + it("keeps mounted disclosure handlers attached to recreated session expansion maps", async () => { + resetChatThreadState(); + const { builtinEnvironments } = await import("vitest/runtime"); + const fixtureGlobals = ["Request", "URL", "jsdom"] as const; + const originalFixtureGlobals = fixtureGlobals.map( + (name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const, + ); + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document"); + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + let environment: Awaited> | undefined; + + try { + environment = await builtinEnvironments.jsdom.setup(globalThis, { + jsdom: { url: "http://localhost/", pretendToBeVisual: true }, + }); + const [{ render }, { ChatTranscriptController, resetChatThreadPresentationState }] = + await Promise.all([import("lit"), import("./components/chat-thread.ts")]); + const host = { + addController() {}, + removeController() {}, + requestUpdate() {}, + updateComplete: Promise.resolve(true), + }; + const sessionKey = "retained-session"; + const props = { + paneId: "retained-pane", + sessionKey, + loading: false, + messages: [ + { role: "user", content: "long user message ".repeat(100), timestamp: 1 }, + { + role: "assistant", + content: [ + { type: "text", text: "assistant reply" }, + { type: "toolcall", id: "retained-call", name: "browser.open" }, + ], + timestamp: 2, + }, + ], + toolMessages: [], + streamSegments: [], + stream: null, + streamStartedAt: null, + queue: [], + showThinking: false, + showToolCalls: true, + sessions: null, + assistantName: "Molty", + assistantAvatar: null, + onDraftChange() {}, + onSend() {}, + }; + const controller = new ChatTranscriptController(host); + const retainedPane = document.createElement("div"); + document.body.append(retainedPane); + render(controller.render(props), retainedPane); + const staleTools = getExpandedToolCards(sessionKey); + const staleUsers = getExpandedUserMessages(sessionKey); + const previousToolVersion = getExpansionStateVersion(staleTools); + const previousUserVersion = getExpansionStateVersion(staleUsers); + + for (let index = 0; index < 20; index += 1) { + const alternatePane = document.createElement("div"); + document.body.append(alternatePane); + render( + new ChatTranscriptController(host).render({ + ...props, + paneId: `alternate-pane-${index}`, + sessionKey: `alternate-session-${index}`, + }), + alternatePane, + ); + } + + render(controller.render(props), retainedPane); + const currentTools = getExpandedToolCards(sessionKey); + const currentUsers = getExpandedUserMessages(sessionKey); + expect(currentTools).not.toBe(staleTools); + expect(currentUsers).not.toBe(staleUsers); + expect(getExpansionStateVersion(currentTools)).toBe(previousToolVersion); + expect(getExpansionStateVersion(currentUsers)).toBe(previousUserVersion); + const toolCardId = expectDefined(currentTools.keys().next().value, "retained tool card"); + expectDefined( + retainedPane.querySelector(".chat-user-message-disclosure__toggle"), + "mounted user disclosure", + ).click(); + expectDefined( + retainedPane.querySelector(".chat-tool-msg-summary"), + "mounted tool disclosure", + ).click(); + + expect(currentTools.get(toolCardId)).toBe(true); + expect(staleTools.get(toolCardId)).toBe(false); + expect(currentUsers.size).toBe(1); + expect(staleUsers.size).toBe(0); + + const toolVisibilitySession = "tool-visibility-session"; + const toolVisibilityProps = { + ...props, + paneId: "tool-visibility-pane", + sessionKey: toolVisibilitySession, + messages: [ + { role: "user", content: "tool visibility prompt", timestamp: 1 }, + { + role: "toolResult", + toolCallId: "expanded-tool", + toolName: "browser.open", + content: "Expanded tool result", + timestamp: 2, + }, + { role: "assistant", content: "The first tool completed.", timestamp: 3 }, + { role: "user", content: "Show the next tool result.", timestamp: 4 }, + { + role: "toolResult", + toolCallId: "collapsed-tool", + toolName: "browser.open", + content: "Collapsed tool result", + timestamp: 5, + }, + ], + }; + const toolVisibilityController = new ChatTranscriptController(host); + const toolVisibilityPane = document.createElement("div"); + document.body.append(toolVisibilityPane); + render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); + const visibilityState = getExpandedToolCards(toolVisibilitySession); + const visibilityIds = [...visibilityState.keys()].filter((key) => key.startsWith("toolmsg:")); + const expandedToolId = expectDefined(visibilityIds[0], "expanded standalone tool disclosure"); + const collapsedToolId = expectDefined( + visibilityIds[1], + "collapsed standalone tool disclosure", + ); + const disclosureButtons = () => + Array.from( + toolVisibilityPane.querySelectorAll(".chat-tool-msg-summary"), + ).filter((button) => !button.closest(".chat-tool-msg-body")); + expect(disclosureButtons()).toHaveLength(2); + expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([ + "false", + "false", + ]); + expectDefined(disclosureButtons()[0], "first mounted tool disclosure").click(); + render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); + expectDefined(disclosureButtons()[1], "second mounted tool disclosure").click(); + render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); + expectDefined(disclosureButtons()[1], "second mounted tool disclosure").click(); + render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); + expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([ + "true", + "false", + ]); + + render( + toolVisibilityController.render({ ...toolVisibilityProps, showToolCalls: false }), + toolVisibilityPane, + ); + expect(disclosureButtons()).toHaveLength(0); + render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); + + expect(disclosureButtons()).toHaveLength(2); + expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([ + "true", + "false", + ]); + expect(visibilityState.get(expandedToolId)).toBe(true); + expect(visibilityState.get(collapsedToolId)).toBe(false); + render( + toolVisibilityController.render({ + ...toolVisibilityProps, + messages: toolVisibilityProps.messages.filter( + (message) => !("toolCallId" in message && message.toolCallId === "expanded-tool"), + ), + }), + toolVisibilityPane, + ); + expect(visibilityState.has(expandedToolId)).toBe(false); + expect(visibilityState.get(collapsedToolId)).toBe(false); + resetChatThreadPresentationState(); + } finally { + try { + if (environment) { + try { + document.body.replaceChildren(); + await new Promise((resolve) => { + window.setTimeout(resolve, 0); + }); + } finally { + await environment.teardown(globalThis); + } + } + } finally { + // Vitest assigns these compatibility globals after its own restore snapshot. + for (const [name, descriptor] of originalFixtureGlobals) { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + Reflect.deleteProperty(globalThis, name); + } + } + resetChatThreadState(); + } + } + + for (const [name, descriptor] of originalFixtureGlobals) { + expect(Object.getOwnPropertyDescriptor(globalThis, name)).toEqual(descriptor); + } + expect(Object.getOwnPropertyDescriptor(globalThis, "document")).toEqual(originalDocument); + expect(Object.getOwnPropertyDescriptor(globalThis, "window")).toEqual(originalWindow); + }); +}); + describe("user message expansion state", () => { it("keeps disclosure state per session and clears it with thread state", () => { resetChatThreadState(); diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index dfe75b84f29c..737fa7fe11b9 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -40,9 +40,13 @@ type RenderChatItem = ReturnType[number]; const chatItemsByPane = new Map>(); const expandedToolCardsBySession = new Map>(); const expandedUserMessagesBySession = new Map>(); +const expandedBooleanMapVersions = new WeakMap, number>(); const initializedToolCardsBySession = new Map>(); const lastAutoExpandPrefBySession = new Map(); -const lastToolCardItemsBySession = new Map(); +const lastToolCardItemsBySession = new Map< + string, + { items: readonly (ChatItem | MessageGroup)[]; isFilteredProjection: boolean } +>(); export function resetChatThreadState(paneId?: string): void { if (paneId) { @@ -352,14 +356,22 @@ export function deletedChatItemsSignature( return deletedKeys.length === 0 ? "" : deletedKeys.join("\u0000"); } -export function stableBooleanMapSignature(values: ReadonlyMap): string { - if (values.size === 0) { - return ""; +export function getExpansionStateVersion(values: ReadonlyMap): number { + return expandedBooleanMapVersions.get(values) ?? 0; +} + +export function setExpansionState(values: Map, key: string, value: boolean): void { + if (values.has(key) && values.get(key) === value) { + return; + } + values.set(key, value); + expandedBooleanMapVersions.set(values, getExpansionStateVersion(values) + 1); +} + +function deleteExpansionState(values: Map, key: string): void { + if (values.delete(key)) { + expandedBooleanMapVersions.set(values, getExpansionStateVersion(values) + 1); } - return Array.from(values) - .toSorted(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => `${key}:${value ? "1" : "0"}`) - .join("\u0000"); } export function getExpandedToolCards(sessionKey: string): Map { @@ -387,12 +399,17 @@ export function syncToolCardExpansionState( sessionKey: string, items: readonly (ChatItem | MessageGroup)[], autoExpandToolCalls: boolean, + isFilteredProjection = false, ): void { const expanded = getExpandedToolCards(sessionKey); const initialized = getInitializedToolCards(sessionKey); - const previousItems = getSessionCacheValue(lastToolCardItemsBySession, sessionKey); + const previousProjection = getSessionCacheValue(lastToolCardItemsBySession, sessionKey); const previousAutoExpand = getSessionCacheValue(lastAutoExpandPrefBySession, sessionKey) ?? false; - if (previousItems === items && previousAutoExpand === autoExpandToolCalls) { + if ( + previousProjection?.items === items && + previousProjection.isFilteredProjection === isFilteredProjection && + previousAutoExpand === autoExpandToolCalls + ) { return; } const currentToolCardIds = new Set(); @@ -408,7 +425,7 @@ export function syncToolCardExpansionState( if (initialized.has(disclosureId)) { continue; } - expanded.set(disclosureId, autoExpandToolCalls); + setExpansionState(expanded, disclosureId, autoExpandToolCalls); initialized.add(disclosureId); } if (!isStandaloneToolMessageForDisplay(entry.message)) { @@ -419,15 +436,25 @@ export function syncToolCardExpansionState( if (initialized.has(disclosureId)) { continue; } - expanded.set(disclosureId, autoExpandToolCalls); + setExpansionState(expanded, disclosureId, autoExpandToolCalls); initialized.add(disclosureId); } } if (autoExpandToolCalls && !previousAutoExpand) { - for (const toolCardId of currentToolCardIds) { - expanded.set(toolCardId, true); + for (const toolCardId of initialized) { + setExpansionState(expanded, toolCardId, true); } } - setSessionCacheValue(lastToolCardItemsBySession, sessionKey, items); + // Search hides existing cards temporarily; pruning that projection would + // discard the user's disclosure choice before the full transcript returns. + if (!isFilteredProjection) { + for (const disclosureId of initialized) { + if (!currentToolCardIds.has(disclosureId)) { + initialized.delete(disclosureId); + deleteExpansionState(expanded, disclosureId); + } + } + } + setSessionCacheValue(lastToolCardItemsBySession, sessionKey, { items, isFilteredProjection }); setSessionCacheValue(lastAutoExpandPrefBySession, sessionKey, autoExpandToolCalls); } diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index b868d08d1d65..64973413e14b 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -57,11 +57,12 @@ import { coalesceStreamRuns, collapseCompletedTurnWork, deletedChatItemsSignature, + getExpansionStateVersion, getExpandedToolCards, getExpandedUserMessages, persistedMessageEntryId, resetChatThreadState, - stableBooleanMapSignature, + setExpansionState, syncToolCardExpansionState, } from "../chat-thread.ts"; import { DeletedMessages } from "../deleted-messages.ts"; @@ -1468,6 +1469,7 @@ function renderChatThreadContents( }; const deleted = getDeletedMessages(props.sessionKey); const locale = i18n.getLocale(); + const searchFiltering = state.searchOpen && Boolean(state.searchQuery.trim()); const chatItems = buildCachedChatItems({ paneId: props.paneId, sessionKey: props.sessionKey, @@ -1489,14 +1491,19 @@ function renderChatThreadContents( searchOpen: state.searchOpen, searchQuery: state.searchQuery, }); - syncToolCardExpansionState(props.sessionKey, chatItems, Boolean(props.autoExpandToolCalls)); + syncToolCardExpansionState( + props.sessionKey, + chatItems, + Boolean(props.autoExpandToolCalls), + searchFiltering || !props.showToolCalls, + ); const expandedToolCards = getExpandedToolCards(props.sessionKey); const expandedUserMessages = getExpandedUserMessages(props.sessionKey); const questionPrompts = new Map( (props.questionPrompts ?? []).map((prompt) => [prompt.id, prompt]), ); const toggleToolCardExpanded = (toolCardId: string) => { - expandedToolCards.set(toolCardId, !expandedToolCards.get(toolCardId)); + setExpansionState(expandedToolCards, toolCardId, !expandedToolCards.get(toolCardId)); requestUpdate(); }; const hasRealtimeTalkConversation = (props.realtimeTalkConversation?.length ?? 0) > 0; @@ -1552,12 +1559,16 @@ function renderChatThreadContents( autoExpandToolCalls: Boolean(props.autoExpandToolCalls), isToolMessageExpanded: (messageId: string) => expandedToolCards.get(messageId), onToggleToolMessageExpanded: (messageId: string, expanded?: boolean) => { - expandedToolCards.set(messageId, !(expanded ?? expandedToolCards.get(messageId) ?? false)); + setExpansionState( + expandedToolCards, + messageId, + !(expanded ?? expandedToolCards.get(messageId) ?? false), + ); requestUpdate(); }, isUserMessageExpanded: (messageId: string) => expandedUserMessages.get(messageId) ?? false, onToggleUserMessageExpanded: (messageId: string) => { - expandedUserMessages.set(messageId, !expandedUserMessages.get(messageId)); + setExpansionState(expandedUserMessages, messageId, !expandedUserMessages.get(messageId)); requestUpdate(); }, isToolExpanded: (toolCardId: string) => expandedToolCards.get(toolCardId) ?? false, @@ -1648,7 +1659,7 @@ function renderChatThreadContents( ${renderWorkGroupSummary(item, { expanded: workExpanded, onToggle: () => { - expandedToolCards.set(item.key, !workExpanded); + setExpansionState(expandedToolCards, item.key, !workExpanded); requestUpdate(); }, })} @@ -1668,7 +1679,7 @@ function renderChatThreadContents( const collapsedItems = collapseCompletedTurnWork(coalesceStreamRuns(chatItems), { sessionKey: props.sessionKey, runWorking: Boolean(props.runWorking), - searchActive: state.searchOpen && Boolean(state.searchQuery.trim()), + searchActive: searchFiltering, }); // Watch/settle on actual indicator visibility (not runWorking): queued // sends show the claw before the run starts, and the recap must never @@ -1752,8 +1763,10 @@ function renderChatThreadContents( chatItems, locale, deletedChatItemsSignature(deleted, chatItems), - stableBooleanMapSignature(expandedToolCards), - stableBooleanMapSignature(expandedUserMessages), + expandedToolCards, + getExpansionStateVersion(expandedToolCards), + expandedUserMessages, + getExpansionStateVersion(expandedUserMessages), getAssistantAttachmentAvailabilityRenderVersion(), // The host minute poll requests an update; this key crosses row guard() memoization. Math.floor(Date.now() / 60_000),