mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user