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 <steipete@macos.shared>
This commit is contained in:
残念
2026-08-02 11:01:49 +01:00
committed by GitHub
parent d310ca3e0c
commit 1c9e78aea0
3 changed files with 506 additions and 24 deletions
+442
View File
@@ -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<ReturnType<typeof builtinEnvironments.jsdom.setup>> | 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<HTMLButtonElement>(".chat-user-message-disclosure__toggle"),
"mounted user disclosure",
).click();
expectDefined(
retainedPane.querySelector<HTMLButtonElement>(".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<HTMLButtonElement>(".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<void>((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();
+42 -15
View File
@@ -40,9 +40,13 @@ type RenderChatItem = ReturnType<typeof buildChatItems>[number];
const chatItemsByPane = new Map<string, Map<string, CachedChatItems>>();
const expandedToolCardsBySession = new Map<string, Map<string, boolean>>();
const expandedUserMessagesBySession = new Map<string, Map<string, boolean>>();
const expandedBooleanMapVersions = new WeakMap<ReadonlyMap<string, boolean>, number>();
const initializedToolCardsBySession = new Map<string, Set<string>>();
const lastAutoExpandPrefBySession = new Map<string, boolean>();
const lastToolCardItemsBySession = new Map<string, readonly (ChatItem | MessageGroup)[]>();
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, boolean>): string {
if (values.size === 0) {
return "";
export function getExpansionStateVersion(values: ReadonlyMap<string, boolean>): number {
return expandedBooleanMapVersions.get(values) ?? 0;
}
export function setExpansionState(values: Map<string, boolean>, 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<string, boolean>, 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<string, boolean> {
@@ -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<string>();
@@ -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);
}
+22 -9
View File
@@ -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),