refactor(ui): split chat thread into transcript owners (#122420)

* refactor(ui): split chat thread into transcript owners

* refactor(ui): delete orphaned pinned-messages surface

* chore(lint): ratchet max-lines baseline after chat-thread split

* fix(ui): tighten transcript owner type boundaries

* refactor(ui): prune pinned-message orphans
This commit is contained in:
Peter Steinberger
2026-08-11 21:49:47 -07:00
committed by GitHub
parent b82ad646a5
commit f6459a3255
23 changed files with 3064 additions and 3199 deletions
-1
View File
@@ -909,7 +909,6 @@ ui/src/pages/chat/chat-view.test.ts
ui/src/pages/chat/components/chat-message.test.ts
ui/src/pages/chat/components/chat-session-workspace.ts
ui/src/pages/chat/components/chat-sidebar.ts
ui/src/pages/chat/components/chat-thread.ts
ui/src/pages/chat/components/chat-tool-cards.ts
ui/src/pages/chat/composer-persistence.test.ts
ui/src/pages/chat/composer-persistence.ts
+3 -1
View File
@@ -20,7 +20,9 @@ export const uiIsolatedTestFiles = [
"ui/src/pages/chat/chat-pane.read-marker.test.ts",
"ui/src/pages/chat/chat-pane.session-discussion.test.ts",
"ui/src/pages/chat/chat-pane.test.ts",
"ui/src/pages/chat/components/chat-thread.measure.test.ts",
"ui/src/pages/chat/components/chat-transcript-controller.test.ts",
"ui/src/pages/chat/components/chat-transcript-invalidation.test.ts",
"ui/src/pages/chat/components/chat-transcript-render.test.ts",
"ui/src/pages/config/config-page.custom-theme.test.ts",
"ui/src/pages/config/memory-mutation-owner.test.ts",
"ui/src/pages/config/memory-page.test.ts",
-2
View File
@@ -5093,10 +5093,8 @@ export const en: TranslationMap = {
search: "Search messages",
searchPlaceholder: "Search messages...",
closeSearch: "Close search",
unpin: "Unpin",
loading: "Loading chat",
noMatches: "No matching messages",
pinnedCount: "{count} pinned",
},
pairingQrExpired: {
title: "Pairing QR expired",
+1 -1
View File
@@ -57,7 +57,7 @@ import type { ChatPageHost } from "./chat-state-host.ts";
import type { ChatPaneHeaderAction } from "./components/chat-pane-header.ts";
import type { SessionRailCommand, SessionRailMode } from "./components/chat-session-rail.ts";
import type { ChatSessionSharingState } from "./components/chat-session-sharing.ts";
import { ChatTranscriptController } from "./components/chat-thread.ts";
import { ChatTranscriptController } from "./components/chat-transcript-controller.ts";
import type { SessionDiscussionPanelConfig } from "./components/session-discussion-panel.ts";
import type { ChatMessageCache } from "./session-message-cache.ts";
@@ -22,7 +22,7 @@ import {
dismissConfirmedActionPopovers,
openChatRewindConfirmation,
} from "./components/chat-message.ts";
import * as chatThread from "./components/chat-thread.ts";
import * as chatThread from "./components/chat-thread-interactions.ts";
import { prepareInitialUserMessageHandoff } from "./initial-turn-handoff.ts";
const SKIP_REWIND_CONFIRM_PREFERENCE = "openclaw:skip-rewind-confirm";
@@ -709,7 +709,7 @@ afterEach(() => {
owner.remove();
}
confirmationOwners.clear();
chatThread.resetChatThreadPresentationState();
chatThread.resetThreadPresentation();
window.localStorage.removeItem(SKIP_REWIND_CONFIRM_PREFERENCE);
vi.unstubAllGlobals();
});
-1
View File
@@ -602,7 +602,6 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state as never),
resolveArtifactDownload: (params) => resolveChatArtifactDownload(state, params),
basePath: state.basePath,
gatewayUrl: state.settings.gatewayUrl,
};
const chat = renderChat(props);
const primary = this.renderBoardPrimary(board, chat);
@@ -12,7 +12,7 @@ import { setChatError } from "./chat-send-queue-state.ts";
import { refreshCurrentChatSessionList } from "./chat-session.ts";
import { invalidateImageLightbox } from "./chat-state-page.ts";
import { dismissConfirmedActionPopovers } from "./components/chat-message.ts";
import { resetChatThreadSessionPresentationState } from "./components/chat-thread.ts";
import { resetTranscriptSession } from "./components/chat-thread-interactions.ts";
import { CHAT_COMPOSER_DRAFT_STORAGE_ERROR } from "./composer-persistence.ts";
/** Owns the resources and composer state that follow one retained presentation. */
@@ -58,7 +58,7 @@ export abstract class ChatPaneRetainedPresentation extends ChatPaneBoard {
this.settleResetConfirmation(false);
this.cancelHeaderRename();
dismissConfirmedActionPopovers(this);
resetChatThreadSessionPresentationState(this.presentationId, this);
resetTranscriptSession(this.presentationId, this);
const state = this.state;
if (state) {
stopChatRealtimeTalk(state);
-212
View File
@@ -3867,218 +3867,6 @@ describe("expansion-state render dependencies", () => {
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-group.user .chat-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", () => {
+2 -2
View File
@@ -1,7 +1,7 @@
import { resetChatComposerState } from "./components/chat-composer.ts";
import { resetChatThreadPresentationState } from "./components/chat-thread.ts";
import { resetThreadPresentation } from "./components/chat-thread-interactions.ts";
export function resetChatViewState(paneId?: string, owner?: ParentNode) {
resetChatComposerState(paneId);
resetChatThreadPresentationState(paneId, owner);
resetThreadPresentation(paneId, owner);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ReactiveControllerHost } from "lit";
import { vi } from "vitest";
import { ChatTranscriptController } from "./components/chat-thread.ts";
import { ChatTranscriptController } from "./components/chat-transcript-controller.ts";
export function createTestTranscript(): ChatTranscriptController {
return new ChatTranscriptController({
+9 -9
View File
@@ -52,10 +52,10 @@ import * as chatMessage from "./components/chat-message.ts";
import { renderChatModelControls } from "./components/chat-model-controls.ts";
import { ChatSessionRailElement } from "./components/chat-session-rail.ts";
import {
resetChatThreadPresentationState,
resetChatThreadSessionPresentationState,
toggleChatThreadSearch,
} from "./components/chat-thread.ts";
resetThreadPresentation,
resetTranscriptSession,
toggleTranscriptSearch,
} from "./components/chat-thread-interactions.ts";
import { renderWelcomeState } from "./components/chat-welcome.ts";
import { RealtimeTalkLevelSignal } from "./realtime-talk-level.ts";
import {
@@ -2141,14 +2141,14 @@ describe("per-pane chat presentation state", () => {
renderChatInto(container, { paneId, draft, getDraft: () => draft });
};
toggleChatThreadSearch("pane-a", vi.fn());
toggleTranscriptSearch("pane-a", vi.fn());
renderPane(paneA, "pane-a", "");
renderPane(paneB, "pane-b", "");
expect(paneA.querySelector(".agent-chat__search-bar")).not.toBeNull();
expect(paneB.querySelector(".agent-chat__search-bar")).toBeNull();
toggleChatThreadSearch("pane-b", vi.fn());
resetChatThreadSessionPresentationState("pane-a");
toggleTranscriptSearch("pane-b", vi.fn());
resetTranscriptSession("pane-a");
renderPane(paneA, "pane-a", "");
renderPane(paneB, "pane-b", "");
expect(paneA.querySelector(".agent-chat__search-bar")).toBeNull();
@@ -6848,11 +6848,11 @@ describe("right-click Reply", () => {
.click();
flushFrames();
resetChatThreadPresentationState("pane-b");
resetThreadPresentation("pane-b");
expect(document.querySelector(".chat-reply-context-menu")).not.toBeNull();
expect(document.querySelector(".chat-confirm-popover")).not.toBeNull();
resetChatThreadPresentationState("pane-a");
resetThreadPresentation("pane-a");
expect(document.querySelector(".chat-reply-context-menu")).toBeNull();
expect(document.querySelector(".chat-confirm-popover")).toBeNull();
+9 -22
View File
@@ -57,13 +57,13 @@ import type { SidebarContent, SidebarFullMessageLoader } from "./components/chat
import { renderChatSwarmProgress } from "./components/chat-swarm-progress.ts";
import { renderChatTaskSuggestionTray } from "./components/chat-task-suggestions.ts";
import type { ChatTaskSuggestionTrayProps } from "./components/chat-task-suggestions.ts";
import type { ChatReplyMessageAccess, ChatTranscriptController } from "./components/chat-thread.ts";
import type { ReplyMessageAccess } from "./components/chat-thread-interactions.ts";
import {
renderChatPinnedMessages,
renderChatSearchBar,
renderChatThread,
toggleChatThreadSearch,
} from "./components/chat-thread.ts";
renderTranscriptSearch,
toggleTranscriptSearch,
} from "./components/chat-thread-interactions.ts";
import { renderChatThread } from "./components/chat-thread.ts";
import type { ChatTranscriptController } from "./components/chat-transcript-controller.ts";
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./input-history.ts";
import type { RealtimeTalkConversationEntry } from "./realtime-talk-conversation.ts";
import type { RealtimeTalkCameraDevice } from "./realtime-talk-input.ts";
@@ -256,12 +256,11 @@ export type ChatProps = ChatTaskSuggestionTrayProps &
onRevealWorkspaceFile?: (path: string) => void;
onChatScroll?: (event: Event) => void;
basePath?: string;
gatewayUrl?: string;
composerControls?: TemplateResult | typeof nothing;
replyTarget?: ChatReplyTarget | null;
onClearReply?: () => void;
onSetReply?: (target: ChatReplyTarget) => void;
replyMessageAccess?: ChatReplyMessageAccess;
replyMessageAccess?: ReplyMessageAccess;
onRewindMessage?: (entryId: string) => Promise<boolean> | boolean;
onForkMessage?: (entryId: string) => Promise<void> | void;
sessionWorkspace?: SessionWorkspaceProps;
@@ -348,7 +347,6 @@ export function renderChat(props: ChatProps) {
questionPrompts: props.gatewayQuestionPrompts,
sessions: props.sessions,
sessionHost: props.sessionHost,
gatewayUrl: props.gatewayUrl,
boardProvider: props.boardProvider,
assistantName: props.assistantName,
assistantAvatar: props.assistantAvatar,
@@ -422,7 +420,6 @@ export function renderChat(props: ChatProps) {
persistCommentary: props.persistCommentary,
sessions: props.sessions,
sessionHost: props.sessionHost,
gatewayUrl: props.gatewayUrl,
assistantName: props.assistantName,
assistantAvatar: props.assistantAvatar,
assistantAvatarUrl: props.assistantAvatarUrl,
@@ -582,21 +579,11 @@ export function renderChat(props: ChatProps) {
resolveAsciiShortcutKey(event) === "f"
) {
event.preventDefault();
toggleChatThreadSearch(props.paneId, requestUpdate, event);
toggleTranscriptSearch(props.paneId, requestUpdate, event);
}
}}
>
${renderChatViewNotices(props)} ${renderChatSearchBar(props.paneId, requestUpdate)}
${renderChatPinnedMessages(
{
paneId: props.paneId,
sessionKey: props.sessionKey,
messages: props.messages,
userName: props.userName,
userAvatar: props.userAvatar,
},
requestUpdate,
)}
${renderChatViewNotices(props)} ${renderTranscriptSearch(props.paneId, requestUpdate)}
<div
class="chat-workbench ${workspaceCollapsed
? "chat-workbench--workspace-collapsed"
@@ -0,0 +1,562 @@
// Pane-local search, context menus, selection actions, and presentation resets.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import type { SessionsListResult } from "../../../api/types.ts";
import type { QuestionPrompt } from "../../../app/question-prompt.ts";
import { copyMarkdownLabel } from "../../../components/copy-button.ts";
import { icons } from "../../../components/icons.ts";
import type { ImageLightboxItem } from "../../../components/image-lightbox.ts";
import "../../../components/tooltip.ts";
import { t } from "../../../i18n/index.ts";
import type { BoardProvider } from "../../../lib/board/provider.ts";
import type { ChatQueueItem, ChatStreamSegment } from "../../../lib/chat/chat-types.ts";
import {
buildCompanionQuestionPrefill,
buildMoreDetailsCompanionQuestion,
} from "../../../lib/chat/companion-question.ts";
import type { EmbedSandboxMode } from "../../../lib/chat/tool-display.ts";
import { copyToClipboard } from "../../../lib/clipboard.ts";
import { fnv1aUtf16 } from "../../../lib/fnv1a.ts";
import type { UiSessionDefaultsHost } from "../../../lib/sessions/session-key.ts";
import type { ChatRunStartupStatus } from "../chat-run-startup.ts";
import { resetChatThreadState } from "../chat-thread.ts";
import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts";
import type { PlanStatus } from "../tool-stream.ts";
import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts";
import type { ArtifactDownloadResolver } from "./chat-message-media.ts";
import {
dismissConfirmedActionPopovers,
openChatRewindConfirmation,
type MessageReplyTarget,
} from "./chat-message.ts";
import { handleChatSelectionPointerUp, removeChatSelectionPopup } from "./chat-selection-popup.ts";
import type { SidebarContent, SidebarFullMessageLoader } from "./chat-sidebar.ts";
export type ChatThreadState = {
searchOpen: boolean;
searchQuery: string;
searchFocusPending: boolean;
searchReturnFocusTarget: HTMLElement | null;
searchReturnFocusOwner: HTMLElement | null;
transcriptRenderDependencies: readonly unknown[];
transcriptRenderContext: {
onSetReply?: (target: MessageReplyTarget) => void;
onOpenReply?: (replyToId: string) => void;
};
};
export type ReplyMessageAccess = {
revision: number;
navigationId: string | null;
read: (messageId: string) => unknown;
request: (messageId: string) => void;
open: (messageId: string) => void;
};
export type ChatThreadProps = {
paneId: string;
sessionKey: string;
boardProvider?: BoardProvider;
announceTranscript?: boolean;
loading: boolean;
historyPagination?: { loading: boolean };
messages: unknown[];
toolMessages: unknown[];
streamSegments: ChatStreamSegment[];
stream: string | null;
streamStartedAt: number | null;
runId?: string | null;
runOutputTokens?: number | null;
queue: ChatQueueItem[];
showThinking: boolean;
showToolCalls: boolean;
persistCommentary?: boolean;
runActive?: boolean;
runWorking?: boolean;
startupStatus?: ChatRunStartupStatus | null;
waitingApproval?: boolean;
planStatus?: PlanStatus | null;
questionPrompts?: readonly QuestionPrompt[];
sessions: SessionsListResult | null;
sessionHost?: UiSessionDefaultsHost | null;
assistantName: string;
assistantAvatar: string | null;
assistantAvatarUrl?: string | null;
userId?: string | null;
userName?: string | null;
userAvatar?: string | null;
basePath?: string;
fullMessageAgentId?: string;
loadFullAssistantMessage?: SidebarFullMessageLoader | null;
localMediaPreviewRoots?: string[];
assistantAttachmentAuthToken?: string | null;
resolveArtifactDownload?: ArtifactDownloadResolver;
canvasPluginSurfaceUrl?: string | null;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
autoExpandToolCalls?: boolean;
realtimeTalkConversation?: RealtimeTalkConversationEntry[];
onOpenSidebar?: (content: SidebarContent) => void;
onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void;
onOpenSessionCheckpoints?: () => void | Promise<void>;
onAssistantAttachmentLoaded?: () => void;
onRequestOpenImage?: () => number;
onOpenImage?: (item: ImageLightboxItem, requestVersion?: number) => void;
onRequestUpdate?: () => void;
onChatScroll?: (event: Event) => void;
onHistoryIntent?: (event: Event) => void;
onDraftChange: (next: string) => void;
onSend: () => void;
onSetReply?: (target: MessageReplyTarget) => void;
replyMessageAccess?: ReplyMessageAccess;
onRewindMessage?: (entryId: string) => Promise<boolean> | boolean;
onForkMessage?: (entryId: string) => Promise<void> | void;
onFocusComposer?: () => void;
onCompanionQuestion?: (question: string) => void;
onCompanionPrefill?: (question: string) => void;
onOpenSession?: (sessionKey: string) => void;
modelSetupRequired?: boolean;
onModelSetup?: () => void;
backgroundTasks?: BackgroundTasksProps;
};
type TranscriptInteractionProps = Pick<
ChatThreadProps,
| "paneId"
| "runActive"
| "runWorking"
| "onSetReply"
| "onRewindMessage"
| "onForkMessage"
| "onFocusComposer"
| "onCompanionQuestion"
| "onCompanionPrefill"
>;
function createTranscriptState(): ChatThreadState {
return {
searchOpen: false,
searchQuery: "",
searchFocusPending: false,
searchReturnFocusTarget: null,
searchReturnFocusOwner: null,
transcriptRenderDependencies: [],
transcriptRenderContext: {},
};
}
const transcriptStates = new Map<string, ChatThreadState>();
export function getTranscriptState(paneId: string): ChatThreadState {
const existing = transcriptStates.get(paneId);
if (existing) {
return existing;
}
const state = createTranscriptState();
transcriptStates.set(paneId, state);
return state;
}
function dismissThreadPortals(paneId?: string, owner?: ParentNode): void {
removeReplyContextMenu(paneId);
if (owner) {
dismissConfirmedActionPopovers(owner);
}
// The selection popup is body-portaled; pane teardown/route changes must
// drop it so it cannot outlive the render that owns its callbacks.
removeChatSelectionPopup();
}
export function resetTranscriptSession(paneId: string, owner?: ParentNode): void {
dismissThreadPortals(paneId, owner);
const state = transcriptStates.get(paneId);
if (state) {
// Search input belongs to the outgoing transcript. Other fields are pane
// preferences or dependency memos and invalidate themselves on new props.
state.searchOpen = false;
state.searchQuery = "";
state.searchFocusPending = false;
state.searchReturnFocusTarget = null;
state.searchReturnFocusOwner = null;
}
}
export function resetThreadPresentation(paneId?: string, owner?: ParentNode) {
dismissThreadPortals(paneId, owner);
if (paneId) {
transcriptStates.delete(paneId);
resetChatThreadState(paneId);
} else {
transcriptStates.clear();
resetChatThreadState();
}
}
export function renderTranscriptSearch(
paneId: string,
requestUpdate: () => void,
): TemplateResult | typeof nothing {
const state = getTranscriptState(paneId);
if (!state.searchOpen) {
return nothing;
}
return html`
<div class="agent-chat__search-bar">
${icons.search}
<input
type="text"
placeholder=${t("chat.thread.searchPlaceholder")}
aria-label=${t("chat.thread.search")}
.value=${state.searchQuery}
${state.searchFocusPending
? ref((element) => {
if (element instanceof HTMLInputElement) {
state.searchFocusPending = false;
queueMicrotask(() => {
if (element.isConnected) {
element.focus({ preventScroll: true });
}
});
}
})
: nothing}
@input=${(event: Event) => {
state.searchQuery = (event.target as HTMLInputElement).value;
requestUpdate();
}}
/>
<openclaw-tooltip .content=${t("chat.thread.closeSearch")}>
<button
class="btn btn--ghost"
aria-label=${t("chat.thread.closeSearch")}
@click=${() => closeTranscriptSearch(state, requestUpdate)}
>
${icons.x}
</button>
</openclaw-tooltip>
</div>
`;
}
export function closeTranscriptSearch(state: ChatThreadState, requestUpdate: () => void): void {
const returnFocusTarget = state.searchReturnFocusTarget;
const returnFocusOwner = state.searchReturnFocusOwner;
state.searchOpen = false;
state.searchQuery = "";
state.searchFocusPending = false;
state.searchReturnFocusTarget = null;
state.searchReturnFocusOwner = null;
requestUpdate();
queueMicrotask(() => {
const target = returnFocusTarget?.isConnected
? returnFocusTarget
: returnFocusOwner?.querySelector<HTMLTextAreaElement>(
".agent-chat__composer-combobox > textarea",
);
target?.focus({ preventScroll: true });
});
}
/** Toggles transcript search and retains the shortcut origin for focus restoration. */
export function toggleTranscriptSearch(
paneId: string,
requestUpdate: () => void,
triggerEvent?: Event,
): void {
const state = getTranscriptState(paneId);
if (state.searchOpen) {
closeTranscriptSearch(state, requestUpdate);
return;
}
state.searchOpen = true;
state.searchFocusPending = true;
const returnFocusTarget = triggerEvent?.target;
const returnFocusOwner = triggerEvent?.currentTarget;
state.searchReturnFocusTarget =
returnFocusTarget instanceof HTMLElement && returnFocusTarget.isConnected
? returnFocusTarget
: null;
state.searchReturnFocusOwner =
returnFocusOwner instanceof HTMLElement && returnFocusOwner.isConnected
? returnFocusOwner
: null;
requestUpdate();
}
let activeReplyContextMenu: HTMLElement | null = null;
let activeReplyContextMenuPaneId: string | null = null;
let contextMenuDocumentClickHandler: ((event: MouseEvent) => void) | null = null;
let contextMenuDocumentContextMenuHandler: ((event: MouseEvent) => void) | null = null;
let contextMenuKeydownHandler: ((event: KeyboardEvent) => void) | null = null;
function removeReplyContextMenu(paneId?: string) {
if (paneId && paneId !== activeReplyContextMenuPaneId) {
return;
}
if (activeReplyContextMenu) {
dismissConfirmedActionPopovers(activeReplyContextMenu);
activeReplyContextMenu.remove();
}
activeReplyContextMenu = null;
activeReplyContextMenuPaneId = null;
const fallbackMenu = document.querySelector<HTMLElement>(".chat-reply-context-menu");
if (fallbackMenu) {
dismissConfirmedActionPopovers(fallbackMenu);
fallbackMenu.remove();
}
if (contextMenuDocumentClickHandler) {
document.removeEventListener("click", contextMenuDocumentClickHandler);
contextMenuDocumentClickHandler = null;
}
if (contextMenuDocumentContextMenuHandler) {
document.removeEventListener("contextmenu", contextMenuDocumentContextMenuHandler, true);
contextMenuDocumentContextMenuHandler = null;
}
if (contextMenuKeydownHandler) {
document.removeEventListener("keydown", contextMenuKeydownHandler);
contextMenuKeydownHandler = null;
}
}
function stableReplyMessageId(senderLabel: string | undefined, text: string): string {
const source = `${senderLabel ?? ""}\n${text}`;
return `reply:${fnv1aUtf16(source).toString(16)}`;
}
function createReplyContextMenuButton(onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.setAttribute("role", "menuitem");
button.setAttribute("aria-label", t("chat.messages.replyToMessage"));
button.textContent = t("chat.messages.reply");
button.addEventListener("click", onClick);
return button;
}
function createMessageActionContextButton(params: {
label: string;
disabled: boolean;
tooltip: string;
onClick: () => void;
}): { element: HTMLElement; button: HTMLButtonElement } {
const button = document.createElement("button");
button.type = "button";
button.disabled = params.disabled;
button.setAttribute("role", "menuitem");
button.setAttribute("aria-label", params.label);
button.textContent = params.label;
button.addEventListener("click", params.onClick);
const tooltip = document.createElement("openclaw-tooltip");
tooltip.content = params.tooltip;
tooltip.append(button);
return { element: tooltip, button };
}
export function handleTranscriptSelection(event: PointerEvent, props: TranscriptInteractionProps) {
if (
typeof props.onCompanionQuestion !== "function" ||
typeof props.onCompanionPrefill !== "function"
) {
return;
}
handleChatSelectionPointerUp(event, {
onMoreDetails: (selection) => {
const question = buildMoreDetailsCompanionQuestion(selection);
if (question) {
props.onCompanionQuestion?.(question);
}
},
onAskSideChat: (selection) => {
const question = buildCompanionQuestionPrefill(selection);
if (question) {
props.onCompanionPrefill?.(question);
}
},
});
}
function selectionIntersectsElement(selection: Selection | null, element: Element): boolean {
if (!selection || selection.isCollapsed) {
return false;
}
for (let index = 0; index < selection.rangeCount; index += 1) {
if (selection.getRangeAt(index).intersectsNode(element)) {
return true;
}
}
return false;
}
export function handleTranscriptContextMenu(event: MouseEvent, props: TranscriptInteractionProps) {
if (event.composedPath().some((target) => target instanceof HTMLAnchorElement)) {
return;
}
const bubble = (event.target as HTMLElement).closest(".chat-bubble");
if (!bubble) {
return;
}
const group = bubble.closest<HTMLElement>(".chat-group");
if (!group) {
return;
}
if (
group.querySelector(".chat-reading-indicator") ||
group.querySelector(".chat-bubble.streaming")
) {
return;
}
const senderEl = group.querySelector(".chat-sender-name");
const senderLabel = senderEl?.textContent?.trim() ?? undefined;
const text = truncateUtf16Safe((bubble as HTMLElement).dataset.messageText?.trim() ?? "", 500);
const entryId = (bubble as HTMLElement).dataset.entryId?.trim() ?? "";
const messageId = (bubble as HTMLElement).dataset.messageId?.trim() ?? "";
const isUserMessage = group.classList.contains("user") && Boolean(entryId);
// Grouped rows can contain several bubbles. Match the clicked bubble to its
// own action owner so copy never targets a sibling message.
const actionOwner = [...group.querySelectorAll<HTMLElement>("[data-message-actions-for]")].find(
(element) => element.dataset.messageActionsFor === messageId,
);
const copyButton = actionOwner?.querySelector<HTMLButtonElement>(".chat-copy-btn");
const canReply = Boolean(text && props.onSetReply);
const canRewind = isUserMessage && typeof props.onRewindMessage === "function";
const canCopy = Boolean(copyButton);
const canFork = isUserMessage && typeof props.onForkMessage === "function";
if (!canReply && !canRewind && !canCopy && !canFork) {
return;
}
const selection = window.getSelection();
const selectedText = selectionIntersectsElement(selection, bubble) ? selection?.toString() : "";
event.preventDefault();
event.stopPropagation();
removeReplyContextMenu();
const menu = document.createElement("div");
menu.className = "chat-reply-context-menu";
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", t("chat.messages.actions"));
menu.style.left = `${event.clientX}px`;
menu.style.top = `${event.clientY}px`;
const focusCandidates: HTMLButtonElement[] = [];
if (selectedText) {
const action = createMessageActionContextButton({
label: t("chat.messages.copySelection"),
disabled: false,
tooltip: t("chat.messages.copySelection"),
onClick: () => {
void copyToClipboard(selectedText);
removeReplyContextMenu();
},
});
menu.append(action.element);
focusCandidates.push(action.button);
}
if (canReply) {
const replyMessageId = messageId || stableReplyMessageId(senderLabel, text);
const replyButton = createReplyContextMenuButton(() => {
props.onSetReply?.({
messageId: replyMessageId,
text,
senderLabel,
...(entryId ? { sourceMessageId: entryId } : {}),
});
removeReplyContextMenu();
props.onFocusComposer?.();
});
menu.append(replyButton);
focusCandidates.push(replyButton);
}
const working = Boolean(props.runActive || props.runWorking);
if (canRewind) {
const action = createMessageActionContextButton({
label: t("chat.messages.rewindToHere"),
disabled: working,
tooltip: working ? t("chat.messages.rewindUnavailable") : t("chat.messages.rewindToHere"),
onClick: () => {
openChatRewindConfirmation(action.button, () => {
removeReplyContextMenu();
void Promise.resolve(props.onRewindMessage?.(entryId)).then((rewound) => {
if (rewound) {
props.onFocusComposer?.();
}
});
});
},
});
action.element.classList.add("chat-confirm-wrap", "chat-rewind-wrap");
menu.append(action.element);
focusCandidates.push(action.button);
}
if (canCopy) {
const action = createMessageActionContextButton({
label: copyMarkdownLabel(),
disabled: false,
tooltip: copyMarkdownLabel(),
onClick: () => {
removeReplyContextMenu();
copyButton?.click();
},
});
menu.append(action.element);
focusCandidates.push(action.button);
}
if (canFork) {
const action = createMessageActionContextButton({
label: t("chat.messages.forkFromHere"),
disabled: working,
tooltip: working ? t("chat.messages.forkUnavailable") : t("chat.messages.forkFromHere"),
onClick: () => {
removeReplyContextMenu();
void props.onForkMessage?.(entryId);
},
});
menu.append(action.element);
focusCandidates.push(action.button);
}
document.body.appendChild(menu);
activeReplyContextMenu = menu;
activeReplyContextMenuPaneId = props.paneId;
const menuRect = menu.getBoundingClientRect();
let left = event.clientX;
let top = event.clientY;
if (left + menuRect.width > window.innerWidth) {
left = window.innerWidth - menuRect.width - 8;
}
if (top + menuRect.height > window.innerHeight) {
top = window.innerHeight - menuRect.height - 8;
}
menu.style.left = `${Math.max(0, left)}px`;
menu.style.top = `${Math.max(0, top)}px`;
focusCandidates.find((button) => !button.disabled)?.focus();
requestAnimationFrame(() => {
if (!menu.isConnected || activeReplyContextMenu !== menu) {
return;
}
contextMenuDocumentClickHandler = (nextEvent: MouseEvent) => {
if (!menu.contains(nextEvent.target as Node | null)) {
removeReplyContextMenu();
}
};
contextMenuDocumentContextMenuHandler = (nextEvent: MouseEvent) => {
if (!menu.contains(nextEvent.target as Node | null)) {
removeReplyContextMenu();
}
};
const handleKeydown = (nextEvent: KeyboardEvent) => {
if (nextEvent.key === "Escape") {
nextEvent.preventDefault();
nextEvent.stopPropagation();
removeReplyContextMenu();
props.onFocusComposer?.();
}
};
contextMenuKeydownHandler = handleKeydown;
document.addEventListener("click", contextMenuDocumentClickHandler);
// Capture closes this owner even when the next menu stops event propagation.
document.addEventListener("contextmenu", contextMenuDocumentContextMenuHandler, true);
document.addEventListener("keydown", handleKeydown);
});
}
@@ -1,909 +0,0 @@
/* @vitest-environment jsdom */
// Regression: re-stamping the transcript into a new container (the
// chat<->dashboard face switch) must keep every rendered row observed for
// size changes. A synchronous measureElement(null) prune during the commit
// unobserved just-registered sibling rows, freezing their heights at the old
// pane width and overlapping the bubbles in the dashboard chat dock.
import { render } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardProvider } from "../../../lib/board/provider.ts";
import { resolveAssistantAttachmentAuthToken } from "../chat-pane-state.ts";
import { createTestChatPane } from "../chat-pane.test-support.ts";
import * as chatThreadBuild from "../chat-thread-build.ts";
import { buildCachedChatItems, resetChatThreadState } from "../chat-thread.ts";
import { createTestTranscript } from "../chat-view.test-helpers.ts";
import {
isChatMediaResourceCurrent,
observeChatMediaResource,
releaseChatMediaResourceSubscriber,
} from "./chat-message-media.ts";
import {
renderChatThread,
renderChatSearchBar,
resetChatThreadPresentationState,
resetChatThreadSessionPresentationState,
toggleChatThreadSearch,
} from "./chat-thread.ts";
const observedElements = new Set<Element>();
const resizeObservers = new Set<RecordingResizeObserver>();
let measuredRowHeight = 100;
class RecordingResizeObserver implements ResizeObserver {
private readonly targets = new Set<Element>();
constructor(private readonly callback: ResizeObserverCallback) {
resizeObservers.add(this);
}
observe(target: Element): void {
this.targets.add(target);
observedElements.add(target);
}
unobserve(target: Element): void {
this.targets.delete(target);
observedElements.delete(target);
}
disconnect(): void {
for (const target of this.targets) {
observedElements.delete(target);
}
this.targets.clear();
resizeObservers.delete(this);
}
emit(width: number, height: number): void {
const entries = [...this.targets].map(
(target) =>
({
target,
borderBoxSize: [{ inlineSize: width, blockSize: height }],
}) as unknown as ResizeObserverEntry,
);
if (entries.length > 0) {
this.callback(entries, this);
}
}
observes(target: Element): boolean {
return this.targets.has(target);
}
}
const defaultMessages = [
{ role: "user", content: "message one", timestamp: 1_000 },
{ role: "assistant", content: "reply one", timestamp: 2_000 },
{ role: "user", content: "message two", timestamp: 3_000 },
{ role: "assistant", content: "reply two", timestamp: 4_000 },
];
function threadProps(
paneId: string,
sessionKey = "agent:main:main",
messages: unknown[] = defaultMessages,
) {
return {
paneId,
sessionKey,
loading: false,
messages,
toolMessages: [],
streamSegments: [],
stream: null,
streamStartedAt: null,
queue: [],
showThinking: false,
showToolCalls: false,
sessions: null,
assistantName: "Molty",
assistantAvatar: null,
onDraftChange: () => {},
onSend: () => {},
};
}
function transcriptRows(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(".chat-virtual-row")];
}
async function flushDeferredRowPrune(): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
describe("chat transcript row measurement", () => {
beforeEach(() => {
observedElements.clear();
resizeObservers.clear();
measuredRowHeight = 100;
vi.stubGlobal("ResizeObserver", RecordingResizeObserver);
// jsdom reports 0x0 rects and offsetHeight 0; keep the virtualizer
// viewport and measured row sizes non-zero so re-renders keep producing
// virtual rows.
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(
() => measuredRowHeight,
);
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
toJSON: () => ({}),
} as DOMRect);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
resetChatThreadPresentationState();
resetChatThreadState();
document.body.replaceChildren();
});
it("keeps every re-stamped row observed after moving containers", async () => {
const transcript = createTestTranscript();
const props = threadProps("pane-measure");
const chatFace = document.body.appendChild(document.createElement("div"));
render(renderChatThread(props, transcript), chatFace);
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const chatRows = transcriptRows(chatFace);
expect(chatRows.length).toBeGreaterThanOrEqual(4);
for (const row of chatRows) {
expect(observedElements.has(row)).toBe(true);
}
// Re-stamp the same session transcript into a new container while the old
// tree is still tracked, mirroring the dashboard face-switch commit.
const dashboardDock = document.body.appendChild(document.createElement("div"));
render(renderChatThread(props, transcript), dashboardDock);
transcript.hostUpdated();
await flushDeferredRowPrune();
const dockRows = transcriptRows(dashboardDock);
expect(dockRows.length).toBe(chatRows.length);
for (const row of dockRows) {
expect(observedElements.has(row)).toBe(true);
}
for (const row of chatRows) {
expect(observedElements.has(row)).toBe(false);
}
});
it("resolves persisted replies to their source and highlights it on click", async () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const props = threadProps("pane-reply-preview", "agent:main:main", [
{
role: "assistant",
content: "The original answer",
__openclaw: { id: "source-message" },
timestamp: 1_000,
},
{
role: "user",
content: "Follow up",
__openclaw: { id: "reply-message", replyToId: "source-message" },
timestamp: 2_000,
},
]);
render(renderChatThread(props, transcript), container);
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const preview = container.querySelector<HTMLButtonElement>(".chat-reply-preview--message");
expect(preview?.textContent).toContain("Replying to Molty");
expect(preview?.textContent).toContain("The original answer");
expect(preview?.textContent).not.toContain("source-message");
preview?.click();
await Promise.resolve();
const sourceBubble = [...container.querySelectorAll<HTMLElement>(".chat-bubble")].find(
(bubble) => bubble.dataset.entryId === "source-message",
);
expect(sourceBubble?.classList.contains("chat-bubble--reply-target")).toBe(true);
transcript.hostDisconnected();
});
it("hydrates an unloaded reply preview without inserting its source row", async () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
let resolvedMessage: unknown = undefined;
const request = vi.fn();
const open = vi.fn();
const props = {
...threadProps("pane-reply-hydration", "agent:main:main", [
{
role: "user",
content: "Follow up",
__openclaw: { id: "reply-message", replyToId: "source-message" },
timestamp: 2_000,
},
]),
replyMessageAccess: {
revision: 0,
navigationId: null,
read: () => resolvedMessage,
request,
open,
},
};
const rerender = () => {
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
};
rerender();
transcript.hostConnected();
await flushDeferredRowPrune();
expect(request).toHaveBeenCalledWith("source-message");
expect(container.querySelector("[data-entry-id='source-message']")).toBeNull();
resolvedMessage = {
role: "assistant",
content: "The original answer",
__openclaw: { id: "source-message" },
timestamp: 1_000,
};
props.replyMessageAccess.revision += 1;
rerender();
const preview = container.querySelector<HTMLButtonElement>(".chat-reply-preview--message");
expect(preview?.textContent).toContain("Replying to Molty");
expect(preview?.textContent).toContain("The original answer");
preview?.click();
expect(open).toHaveBeenCalledWith("source-message");
transcript.hostDisconnected();
});
it("clears search before navigating to a filtered reply target", async () => {
const transcript = createTestTranscript();
const searchContainer = document.body.appendChild(document.createElement("div"));
const threadContainer = document.body.appendChild(document.createElement("div"));
const open = vi.fn();
const paneId = "pane-filtered-reply-navigation";
const props = {
...threadProps(paneId, "agent:main:main", [
{
role: "assistant",
content: "The original answer",
__openclaw: { id: "source-message" },
timestamp: 1_000,
},
{
role: "user",
content: "Follow up",
__openclaw: {
id: "reply-message",
replyToId: "source-message",
replyToPreview: { text: "The original answer", senderLabel: "Molty" },
},
timestamp: 2_000,
},
]),
replyMessageAccess: {
revision: 0,
navigationId: null,
read: () => undefined,
request: vi.fn(),
open,
},
};
const rerender = () => {
render(renderChatSearchBar(paneId, rerender), searchContainer);
render(
renderChatThread({ ...props, onRequestUpdate: rerender }, transcript),
threadContainer,
);
transcript.hostUpdated();
};
toggleChatThreadSearch(paneId, rerender);
rerender();
transcript.hostConnected();
const input = searchContainer.querySelector<HTMLInputElement>("input");
expect(input).not.toBeNull();
input!.value = "Follow up";
input!.dispatchEvent(new Event("input", { bubbles: true }));
await flushDeferredRowPrune();
expect(threadContainer.querySelector("[data-entry-id='source-message']")).toBeNull();
const preview = threadContainer.querySelector<HTMLButtonElement>(
".chat-reply-preview--message",
);
expect(preview).not.toBeNull();
preview!.click();
expect(open).toHaveBeenCalledWith("source-message");
expect(searchContainer.querySelector("input")).toBeNull();
transcript.hostDisconnected();
});
it("loads a truncated assistant message once and keeps the full text visible", async () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const loadFullAssistantMessage = vi.fn().mockResolvedValue({
ok: true,
message: { role: "assistant", content: "Complete assistant content." },
});
function rerender() {
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
}
const props = {
...threadProps("pane-assistant-expand", "agent:work:main", [
{
role: "assistant",
content: "Preview\n...(truncated)...",
__openclaw: { id: "assistant-full-1" },
timestamp: 1_000,
},
]),
fullMessageAgentId: "work",
loadFullAssistantMessage,
onRequestUpdate: rerender,
};
rerender();
transcript.hostConnected();
transcript.hostUpdated();
await vi.waitFor(() => expect(container.textContent).toContain("Complete assistant content."));
expect(loadFullAssistantMessage).toHaveBeenCalledOnce();
expect(loadFullAssistantMessage).toHaveBeenCalledWith({
sessionKey: "agent:work:main",
agentId: "work",
messageId: "assistant-full-1",
kind: "assistant_message",
});
expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull();
expect(container.textContent).toContain("Complete assistant content.");
expect(loadFullAssistantMessage).toHaveBeenCalledOnce();
transcript.hostDisconnected();
});
it("keeps transport-cut assistant text as received when full content is unavailable", async () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const loadFullAssistantMessage = vi.fn().mockRejectedValue(new Error("offline"));
function rerender() {
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
}
const props = {
...threadProps("pane-assistant-retry", "agent:main:main", [
{
role: "assistant",
content: "Preview\n...(truncated)...",
__openclaw: { id: "assistant-retry-1" },
timestamp: 1_000,
},
]),
loadFullAssistantMessage,
onRequestUpdate: rerender,
};
rerender();
transcript.hostConnected();
transcript.hostUpdated();
await vi.waitFor(() => expect(loadFullAssistantMessage).toHaveBeenCalledOnce());
expect(container.textContent).toContain("Preview");
expect(container.textContent).toContain("...(truncated)...");
expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull();
transcript.hostDisconnected();
});
it.each(["Enter", " "])("opens focused transcript file links with %j", async (key) => {
const transcript = createTestTranscript();
const onOpenWorkspaceFile = vi.fn();
const onHistoryIntent = vi.fn();
const container = document.body.appendChild(document.createElement("div"));
const props = {
...threadProps("pane-file-link", "agent:main:main", [
{ role: "assistant", content: "Inspect `src/chat.ts:17`", timestamp: 1_000 },
]),
onOpenWorkspaceFile,
onHistoryIntent,
};
render(renderChatThread(props, transcript), container);
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const link = container.querySelector<HTMLAnchorElement>("a.markdown-file-link");
link?.focus();
expect(document.activeElement).toBe(link);
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
link?.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(onOpenWorkspaceFile).toHaveBeenCalledWith({ path: "src/chat.ts", line: 17 });
expect(onHistoryIntent).not.toHaveBeenCalled();
transcript.hostDisconnected();
});
it("keeps built row identities across an A to B to A presentation reset", () => {
const paneId = "pane-session-items";
const messagesA = [{ role: "assistant", content: "session A", timestamp: 1_000 }];
const messagesB = [{ role: "assistant", content: "session B", timestamp: 2_000 }];
const stableInputs = {
paneId,
runId: null,
toolMessages: [],
streamSegments: [],
stream: null,
streamStartedAt: null,
showToolCalls: true,
};
const buildSpy = vi.spyOn(chatThreadBuild, "buildChatItems");
const itemsA = buildCachedChatItems({
...stableInputs,
sessionKey: "agent:main:session-a",
messages: messagesA,
});
resetChatThreadSessionPresentationState(paneId);
buildCachedChatItems({
...stableInputs,
sessionKey: "agent:main:session-b",
messages: messagesB,
});
resetChatThreadSessionPresentationState(paneId);
const restoredItemsA = buildCachedChatItems({
...stableInputs,
sessionKey: "agent:main:session-a",
messages: messagesA,
});
expect(buildSpy).toHaveBeenCalledTimes(2);
expect(restoredItemsA).toBe(itemsA);
expect(restoredItemsA.every((item, index) => item === itemsA[index])).toBe(true);
});
it("pauses an unmeasurable restore until loading commits an empty transcript", () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const props = threadProps("pane-loading-scroll", "agent:main:session-a", []);
render(renderChatThread({ ...props, loading: true }, transcript), container);
transcript.hostConnected();
transcript.hostUpdated();
transcript.scrollToOffset(420);
transcript.hostUpdated();
expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBe(420);
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBeNull();
});
it("settles a restored offset when loaded rows no longer overflow", () => {
const frames: FrameRequestCallback[] = [];
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
frames.push(callback);
return frames.length;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const props = threadProps("pane-short-scroll", "agent:main:session-a");
render(renderChatThread(props, transcript), container);
transcript.hostConnected();
transcript.hostUpdated();
transcript.scrollToOffset(420);
for (let index = 0; index <= 60; index += 1) {
transcript.hostUpdated();
for (const frame of frames.splice(0)) {
frame(0);
}
}
expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBeNull();
});
it("updates rendered row offsets from freshly wrapped heights while scrolling", async () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const props = threadProps("pane-width-remeasure");
const renderTranscript = async () => {
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
await flushDeferredRowPrune();
};
await renderTranscript();
transcript.hostConnected();
await renderTranscript();
expect(transcriptRows(container)[1]?.style.transform).toBe("translateY(100px)");
const scrollElement = container.querySelector<HTMLElement>(".chat-thread");
expect(scrollElement).not.toBeNull();
scrollElement!.scrollTop = 40;
scrollElement!.dispatchEvent(new Event("scroll"));
const virtualizer = (
transcript as unknown as {
sessionVirtualizer: {
virtualizerController: { getVirtualizer: () => { isScrolling: boolean } };
};
}
).sessionVirtualizer.virtualizerController.getVirtualizer();
expect(virtualizer.isScrolling).toBe(true);
measuredRowHeight = 180;
for (const observer of resizeObservers) {
if (scrollElement && observer.observes(scrollElement)) {
observer.emit(640, 600);
}
}
await renderTranscript();
expect(transcriptRows(container)[1]?.style.transform).toBe("translateY(180px)");
transcript.hostDisconnected();
});
it.each([
{ label: "end-pinned", distanceFromEnd: 0, expectedCalls: 1 },
{ label: "scrolled away", distanceFromEnd: 100, expectedCalls: 0 },
])(
"$label transcript preserves its resize anchor",
async ({ distanceFromEnd, expectedCalls }) => {
measuredRowHeight = 240;
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const messages = Array.from({ length: 12 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
timestamp: index + 1,
}));
const props = threadProps(
`pane-height-resize-${distanceFromEnd}`,
"agent:main:resize",
messages,
);
render(renderChatThread(props, transcript), container);
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const scrollElement = container.querySelector<HTMLElement>(".chat-thread");
expect(scrollElement).not.toBeNull();
const virtualizer = (
transcript as unknown as {
sessionVirtualizer: {
virtualizerController: {
getVirtualizer: () => {
scrollOffset: number | null;
getTotalSize: () => number;
scrollToEnd: (options?: { behavior?: ScrollBehavior }) => void;
};
};
};
}
).sessionVirtualizer.virtualizerController.getVirtualizer();
const scrollToEnd = vi.spyOn(virtualizer, "scrollToEnd");
const emitViewportResize = (height: number) => {
for (const observer of resizeObservers) {
if (scrollElement && observer.observes(scrollElement)) {
observer.emit(800, height);
}
}
};
emitViewportResize(600);
scrollToEnd.mockClear();
expect(virtualizer.getTotalSize()).toBeGreaterThan(700);
virtualizer.scrollOffset = Math.max(0, virtualizer.getTotalSize() - 600 - distanceFromEnd);
emitViewportResize(560);
expect(scrollToEnd).toHaveBeenCalledTimes(expectedCalls);
if (expectedCalls > 0) {
expect(scrollToEnd).toHaveBeenCalledWith({ behavior: "auto" });
}
transcript.hostDisconnected();
},
);
it("rebinds guarded transcript images when the gateway rotates its auth token", async () => {
const NativeUrl = URL;
const blobUrl = `blob:transcript-media-${crypto.randomUUID()}`;
vi.stubGlobal(
"URL",
class extends NativeUrl {
static override createObjectURL = vi.fn(() => blobUrl);
static override revokeObjectURL = vi.fn();
},
);
let previousSignal: AbortSignal | undefined;
const fetchMock = vi.fn((_source: string, init?: RequestInit) => {
if (fetchMock.mock.calls.length === 1) {
return new Promise<Response>((_resolve, reject) => {
previousSignal = init?.signal ?? undefined;
previousSignal?.addEventListener(
"abort",
() => reject(new DOMException("media scope changed", "AbortError")),
{ once: true },
);
});
}
return Promise.resolve({
ok: true,
blob: async () => new Blob(["png"], { type: "image/png" }),
} as Response);
});
vi.stubGlobal("fetch", fetchMock);
const source = `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`;
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const client = {
request: vi.fn(async () => null),
} as unknown as Parameters<typeof createTestChatPane>[0]["client"];
const sessions = {} as Parameters<typeof createTestChatPane>[0]["sessions"];
const { pane, state } = createTestChatPane({ client, sessions });
state.hello = {
auth: { deviceToken: "old-token" },
} as typeof state.hello;
const messages = [
{
role: "assistant",
content: [{ type: "image", url: source }],
timestamp: 1_000,
},
];
const renderPane = () => {
render(
renderChatThread(
{
...threadProps("pane-gateway-media-auth", state.sessionKey, messages),
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state),
onRequestUpdate: renderPane,
},
transcript,
),
container,
);
transcript.hostUpdated();
};
state.requestUpdate = renderPane;
renderPane();
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const thumbnailSource = source.replace(/\/full$/u, "/thumbnail");
const previousResource = observeChatMediaResource<string | null>(
"managed-image",
`${thumbnailSource}::old-token::`,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(previousResource.subscribers.size).toBe(1);
pane.applyGatewaySnapshot({
...pane.context.gateway.snapshot,
client,
phase: "connected",
hello: {
...pane.context.gateway.snapshot.hello,
auth: { deviceToken: "next-token" },
} as typeof pane.context.gateway.snapshot.hello,
});
expect(previousSignal?.aborted).toBe(true);
expect(isChatMediaResourceCurrent(previousResource)).toBe(false);
await flushDeferredRowPrune();
const nextResource = observeChatMediaResource<string | null>(
"managed-image",
`${thumbnailSource}::next-token::`,
);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe(
"Bearer next-token",
);
expect(isChatMediaResourceCurrent(nextResource)).toBe(true);
expect(nextResource.subscribers.size).toBe(1);
expect(container.querySelector<HTMLImageElement>(".chat-message-image")?.src).toBe(blobUrl);
releaseChatMediaResourceSubscriber(renderPane);
transcript.hostDisconnected();
});
it("reconciles guarded local attachments when pane preview roots change", async () => {
let previousSignal: AbortSignal | undefined;
const fetchMock = vi.fn((_source: string, init?: RequestInit) => {
if (fetchMock.mock.calls.length === 1) {
return new Promise<Response>((_resolve, reject) => {
previousSignal = init?.signal ?? undefined;
previousSignal?.addEventListener(
"abort",
() => reject(new DOMException("preview roots changed", "AbortError")),
{ once: true },
);
});
}
return Promise.resolve({
ok: true,
json: async () => ({
available: true,
mediaTicket: "root-restored-ticket",
mediaTicketExpiresAt: new Date(Date.now() + 90_000).toISOString(),
}),
} as Response);
});
vi.stubGlobal("fetch", fetchMock);
const client = {
request: vi.fn(async () => null),
} as unknown as Parameters<typeof createTestChatPane>[0]["client"];
const sessions = {} as Parameters<typeof createTestChatPane>[0]["sessions"];
const { pane, state } = createTestChatPane({ client, sessions });
const configPane = pane as typeof pane & {
applyApplicationConfig: (config: typeof pane.context.config.current) => void;
};
state.hello = {
auth: { deviceToken: "old-token" },
} as typeof state.hello;
state.localMediaPreviewRoots = ["/tmp/openclaw"];
state.embedSandboxMode = "scripts";
state.allowExternalEmbedUrls = false;
const source = `/tmp/openclaw/${crypto.randomUUID()}.pdf`;
const messages = [
{
role: "assistant",
content: `Local document\nMEDIA:${source}`,
timestamp: 1_000,
},
];
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const renderPane = () => {
render(
renderChatThread(
{
...threadProps("pane-local-media-roots", state.sessionKey, messages),
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state),
localMediaPreviewRoots: state.localMediaPreviewRoots,
onRequestUpdate: renderPane,
},
transcript,
),
container,
);
transcript.hostUpdated();
};
state.requestUpdate = renderPane;
renderPane();
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const previousResource = observeChatMediaResource(
"assistant-attachment",
`::old-token::${source}`,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(previousResource.subscribers.size).toBe(1);
const config = {
...pane.context.config.current,
localMediaPreviewRoots: ["/tmp/elsewhere"],
embedSandboxMode: "scripts" as const,
allowExternalEmbedUrls: false,
};
configPane.applyApplicationConfig(config);
await flushDeferredRowPrune();
expect(previousSignal?.aborted).toBe(true);
expect(isChatMediaResourceCurrent(previousResource)).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(
container.querySelector(".chat-assistant-attachment-card__reason")?.textContent,
).toContain("Outside allowed folders");
configPane.applyApplicationConfig({
...config,
localMediaPreviewRoots: ["/tmp/openclaw"],
});
await flushDeferredRowPrune();
const restoredResource = observeChatMediaResource(
"assistant-attachment",
`::old-token::${source}`,
);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe(
"Bearer old-token",
);
expect(isChatMediaResourceCurrent(restoredResource)).toBe(true);
expect(restoredResource.subscribers.size).toBe(1);
expect(
container.querySelector(".chat-assistant-attachment-card__link")?.getAttribute("href"),
).toContain("mediaTicket=root-restored-ticket");
releaseChatMediaResourceSubscriber(renderPane);
transcript.hostDisconnected();
});
it("updates MCP App pinning when the same provider's capability changes", async () => {
const provider = {
sessionKey: "agent:main:main",
canPinWidgets: true,
canPinMcpApps: false,
pinMcpApp: vi.fn(async () => undefined),
snapshot$: {
value: {
sessionKey: "agent:main:main",
revision: 1,
tabs: [],
widgets: [],
},
subscribe: () => () => undefined,
},
};
const props = {
...threadProps("pane-mcp-capability"),
boardProvider: provider as unknown as BoardProvider,
messages: [
{
role: "assistant",
timestamp: 1_000,
content: [
{ type: "text", text: "Here is the dashboard app." },
{
type: "canvas",
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
title: "Dashboard app",
viewId: "outer-view-must-not-be-pinned",
mcpApp: {
viewId: "view-dashboard-app",
serverName: "dashboard",
toolName: "show",
uiResourceUri: "ui://dashboard/app.html",
toolCallId: "call-dashboard-app",
originSessionKey: "agent:main:main",
},
},
},
],
},
],
};
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
render(renderChatThread(props, transcript), container);
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
expect(container.querySelector('[data-content-kind="mcp-app"]')).not.toBeNull();
expect(container.querySelector("[data-pin-widget]")).toBeNull();
provider.canPinMcpApps = true;
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
expect(container.querySelector("[data-pin-widget]")).not.toBeNull();
expect(provider.snapshot$.value.revision).toBe(1);
provider.canPinMcpApps = false;
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
expect(container.querySelector("[data-pin-widget]")).toBeNull();
expect(provider.snapshot$.value.revision).toBe(1);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestTranscript } from "../chat-view.test-helpers.ts";
import { renderChatThread } from "./chat-thread.ts";
import {
flushDeferredRowPrune,
installTranscriptDomMocks,
observedElements,
resetTranscriptTestDom,
resizeObservers,
threadProps,
transcriptDomState,
transcriptRows,
} from "./chat-transcript.test-support.ts";
describe("chat transcript controller", () => {
beforeEach(installTranscriptDomMocks);
afterEach(resetTranscriptTestDom);
it("keeps every re-stamped row observed after moving containers", async () => {
const transcript = createTestTranscript();
const props = threadProps("pane-measure");
const chatFace = document.body.appendChild(document.createElement("div"));
render(renderChatThread(props, transcript), chatFace);
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const chatRows = transcriptRows(chatFace);
expect(chatRows.length).toBeGreaterThanOrEqual(4);
for (const row of chatRows) {
expect(observedElements.has(row)).toBe(true);
}
// Re-stamp the same session transcript into a new container while the old
// tree is still tracked, mirroring the dashboard face-switch commit.
const dashboardDock = document.body.appendChild(document.createElement("div"));
render(renderChatThread(props, transcript), dashboardDock);
transcript.hostUpdated();
await flushDeferredRowPrune();
const dockRows = transcriptRows(dashboardDock);
expect(dockRows.length).toBe(chatRows.length);
for (const row of dockRows) {
expect(observedElements.has(row)).toBe(true);
}
for (const row of chatRows) {
expect(observedElements.has(row)).toBe(false);
}
});
it("pauses an unmeasurable restore until loading commits an empty transcript", () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const props = threadProps("pane-loading-scroll", "agent:main:session-a", []);
render(renderChatThread({ ...props, loading: true }, transcript), container);
transcript.hostConnected();
transcript.hostUpdated();
transcript.scrollToOffset(420);
transcript.hostUpdated();
expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBe(420);
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBeNull();
});
it("settles a restored offset when loaded rows no longer overflow", () => {
const frames: FrameRequestCallback[] = [];
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
frames.push(callback);
return frames.length;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const props = threadProps("pane-short-scroll", "agent:main:session-a");
render(renderChatThread(props, transcript), container);
transcript.hostConnected();
transcript.hostUpdated();
transcript.scrollToOffset(420);
for (let index = 0; index <= 60; index += 1) {
transcript.hostUpdated();
for (const frame of frames.splice(0)) {
frame(0);
}
}
expect(transcript.pendingScrollOffsetFor(props.sessionKey)).toBeNull();
});
it("updates rendered row offsets from freshly wrapped heights while scrolling", async () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const props = threadProps("pane-width-remeasure");
const renderTranscript = async () => {
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
await flushDeferredRowPrune();
};
await renderTranscript();
transcript.hostConnected();
await renderTranscript();
expect(transcriptRows(container)[1]?.style.transform).toBe("translateY(100px)");
const scrollElement = container.querySelector<HTMLElement>(".chat-thread");
expect(scrollElement).not.toBeNull();
scrollElement!.scrollTop = 40;
scrollElement!.dispatchEvent(new Event("scroll"));
const virtualizer = (
transcript as unknown as {
sessionVirtualizer: {
virtualizerController: { getVirtualizer: () => { isScrolling: boolean } };
};
}
).sessionVirtualizer.virtualizerController.getVirtualizer();
expect(virtualizer.isScrolling).toBe(true);
transcriptDomState.measuredRowHeight = 180;
for (const observer of resizeObservers) {
if (scrollElement && observer.observes(scrollElement)) {
observer.emit(640, 600);
}
}
await renderTranscript();
expect(transcriptRows(container)[1]?.style.transform).toBe("translateY(180px)");
transcript.hostDisconnected();
});
it.each([
{ label: "end-pinned", distanceFromEnd: 0, expectedCalls: 1 },
{ label: "scrolled away", distanceFromEnd: 100, expectedCalls: 0 },
])(
"$label transcript preserves its resize anchor",
async ({ distanceFromEnd, expectedCalls }) => {
transcriptDomState.measuredRowHeight = 240;
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const messages = Array.from({ length: 12 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
timestamp: index + 1,
}));
const props = threadProps(
`pane-height-resize-${distanceFromEnd}`,
"agent:main:resize",
messages,
);
render(renderChatThread(props, transcript), container);
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const scrollElement = container.querySelector<HTMLElement>(".chat-thread");
expect(scrollElement).not.toBeNull();
const virtualizer = (
transcript as unknown as {
sessionVirtualizer: {
virtualizerController: {
getVirtualizer: () => {
scrollOffset: number | null;
getTotalSize: () => number;
scrollToEnd: (options?: { behavior?: ScrollBehavior }) => void;
};
};
};
}
).sessionVirtualizer.virtualizerController.getVirtualizer();
const scrollToEnd = vi.spyOn(virtualizer, "scrollToEnd");
const emitViewportResize = (height: number) => {
for (const observer of resizeObservers) {
if (scrollElement && observer.observes(scrollElement)) {
observer.emit(800, height);
}
}
};
emitViewportResize(600);
scrollToEnd.mockClear();
expect(virtualizer.getTotalSize()).toBeGreaterThan(700);
virtualizer.scrollOffset = Math.max(0, virtualizer.getTotalSize() - 600 - distanceFromEnd);
emitViewportResize(560);
expect(scrollToEnd).toHaveBeenCalledTimes(expectedCalls);
if (expectedCalls > 0) {
expect(scrollToEnd).toHaveBeenCalledWith({ behavior: "auto" });
}
transcript.hostDisconnected();
},
);
});
@@ -0,0 +1,670 @@
// Session-owned virtualizer lifecycle for chat transcripts.
import { VirtualizerController } from "@tanstack/lit-virtual";
import { defaultRangeExtractor, observeElementRect } from "@tanstack/virtual-core";
import {
html,
nothing,
type ReactiveController,
type ReactiveControllerHost,
type TemplateResult,
} from "lit";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { styleMap } from "lit/directives/style-map.js";
import { McpAppUnmountGate } from "../../../components/mcp-app-unmount.ts";
import { areUiSessionKeysEquivalent } from "../../../lib/sessions/session-key.ts";
import {
CHAT_TRANSCRIPT_END_THRESHOLD_PX,
getChatSessionScrollPosition,
saveChatSessionScrollPosition,
type ChatSessionScrollPosition,
} from "../scroll.ts";
export type TranscriptRow<T = unknown> =
| { kind: "item"; key: string; item: T }
| { kind: "content"; key: string; content: unknown };
export type TranscriptAnnouncement = {
key: string;
text: string;
};
export type ChatTranscriptSession = {
readonly liveAnnouncementText: string;
render<T>(
rows: readonly TranscriptRow<T>[],
renderRow: (row: TranscriptRow<T>) => unknown,
announcement: TranscriptAnnouncement | null,
announce: boolean,
overlay?: unknown,
): TemplateResult;
syncMessageRows(messageRowKeysById: ReadonlyMap<string, string>): void;
revealMessage(messageId: string): boolean;
setContentReady(ready: boolean): void;
handleFocusIn(event: FocusEvent): void;
handleFocusOut(event: FocusEvent): void;
};
const CHAT_TRANSCRIPT_ESTIMATED_ROW_PX = 120;
const CHAT_TRANSCRIPT_OVERSCAN = 6;
// Initial virtual rows can correct their estimates for several frames. Hold a
// restored offset for ~200ms so those corrections cannot reapply the end anchor.
const CHAT_TRANSCRIPT_SCROLL_RESTORE_STABLE_FRAMES = 12;
// A committed short transcript can legitimately remain at maxOffset=0. Give
// initial measurement one second before treating that zero range as final.
const CHAT_TRANSCRIPT_ZERO_MAX_SETTLE_FRAMES = 60;
function initialTranscriptRect(host: ReactiveControllerHost) {
const width = host instanceof HTMLElement ? host.clientWidth : 0;
const height = host instanceof HTMLElement ? host.clientHeight : 0;
return {
width: width || (typeof window === "undefined" ? 0 : window.innerWidth),
height: height || (typeof window === "undefined" ? 0 : window.innerHeight),
};
}
function transcriptScrollMargin(element: Element | null): number {
if (!(element instanceof HTMLElement) || typeof getComputedStyle !== "function") {
return 0;
}
const margin = Number.parseFloat(getComputedStyle(element).paddingTop);
return Number.isFinite(margin) ? margin : 0;
}
function initialTranscriptScrollMargin(host: ReactiveControllerHost): number {
return host instanceof HTMLElement
? transcriptScrollMargin(host.querySelector(".chat-thread"))
: 0;
}
class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscriptSession {
private readonly controllers = new Set<ReactiveController>();
private readonly virtualizerController: VirtualizerController<HTMLDivElement, HTMLElement>;
private threadInnerElement: HTMLDivElement | null = null;
private connected = false;
private observedWidth: number | null = null;
private observedHeight: number | null = null;
private contentReady = false;
private pendingScrollOffset: {
offset: number;
stableFrames: number;
zeroMaxFrames: number;
onSettled?: (position: ChatSessionScrollPosition) => void;
} | null = null;
private pendingScrollFrame: number | null = null;
// Lit calls refs before newly rendered nodes are connected. Resolve the
// scroll parent lazily or a stable ref can permanently capture null.
private get scrollElement(): HTMLDivElement | null {
const parent = this.threadInnerElement?.parentElement;
return parent instanceof HTMLDivElement ? parent : null;
}
// Stable Lit refs: inline arrows change identity per render, making Lit
// re-invoke them for every visible row and re-measure each row every render.
// Lit tracks the last element per callback, so each row needs its own.
private readonly scrollElementRef = (element?: Element) => {
this.threadInnerElement = element instanceof HTMLDivElement ? element : null;
};
private readonly measureRowRefs = new Map<string, (element?: Element) => void>();
private pruneDetachedRowsQueued = false;
private pendingRowMeasureFrame: number | null = null;
private measureConnectedRows(): void {
// Only width invalidation owns forced DOM reads. Ordinary row refs stay on
// TanStack's observer path so resizeItem cannot perturb scroll restoration.
const instance = this.virtualizerController.getVirtualizer();
for (const row of this.threadInnerElement?.querySelectorAll<HTMLElement>(".chat-virtual-row") ??
[]) {
instance.resizeItem(
instance.indexFromElement(row),
row[instance.options.horizontal ? "offsetWidth" : "offsetHeight"],
);
}
}
private queueConnectedRowMeasure(): void {
if (this.pendingRowMeasureFrame !== null) {
return;
}
this.pendingRowMeasureFrame = requestAnimationFrame(() => {
this.pendingRowMeasureFrame = null;
this.measureConnectedRows();
});
}
private measureRowRefFor(key: string): (element?: Element) => void {
let callback = this.measureRowRefs.get(key);
if (!callback) {
callback = (element?: Element) => {
if (element instanceof HTMLElement) {
this.virtualizerController.getVirtualizer().measureElement(element);
return;
}
// Re-stamps (e.g. the chat<->dashboard face switch) re-invoke each
// stable row ref as an (undefined, element) pair while the new subtree
// is still detached. measureElement(null) prunes every disconnected
// row, so calling it synchronously unobserves just-registered sibling
// rows and freezes their heights at the old pane width (overlapping
// bubbles). Defer until the commit lands so only removed rows prune.
if (this.pruneDetachedRowsQueued) {
return;
}
this.pruneDetachedRowsQueued = true;
queueMicrotask(() => {
this.pruneDetachedRowsQueued = false;
this.virtualizerController.getVirtualizer().measureElement(null);
});
};
this.measureRowRefs.set(key, callback);
}
return callback;
}
private rowKeys: readonly string[] = [];
private rowIndexesByKey = new Map<string, number>();
private messageRowKeysById = new Map<string, string>();
private focusedRowKey: string | null = null;
private announcementInitialized = false;
private announcementKey: string | null = null;
private currentAnnouncementText = "";
private readonly mcpAppUnmountGate = new McpAppUnmountGate(this);
constructor(
private readonly host: ReactiveControllerHost,
initialOffset: number | null = null,
onInitialOffsetSettled?: (position: ChatSessionScrollPosition) => void,
) {
this.virtualizerController = new VirtualizerController(this, {
count: 0,
getScrollElement: () => this.scrollElement,
estimateSize: () => CHAT_TRANSCRIPT_ESTIMATED_ROW_PX,
getItemKey: () => "",
initialRect: initialTranscriptRect(host),
initialOffset: initialOffset ?? Number.MAX_SAFE_INTEGER,
scrollMargin: initialTranscriptScrollMargin(host),
anchorTo: "end",
followOnAppend: false,
observeElementRect: (instance, callback) =>
observeElementRect(instance, (rect) => {
const previousHeight = this.observedHeight;
const widthChanged = this.observedWidth !== null && this.observedWidth !== rect.width;
const heightChanged = previousHeight !== null && previousHeight !== rect.height;
const scrollOffset = instance.scrollOffset;
const wasAtEndBeforeResize =
heightChanged &&
this.pendingScrollOffset === null &&
scrollOffset !== null &&
instance.getTotalSize() - previousHeight - scrollOffset <=
CHAT_TRANSCRIPT_END_THRESHOLD_PX;
this.observedWidth = rect.width;
this.observedHeight = rect.height;
this.syncScrollMargin(instance.scrollElement);
callback(rect);
if (wasAtEndBeforeResize) {
instance.scrollToEnd({ behavior: "auto" });
}
if (widthChanged) {
// Cached offscreen sizes belong to the old wrapping width. Reset
// them, seed current rows, then repeat after any same-commit
// re-stamp has attached and completed layout.
instance.measure();
this.measureConnectedRows();
this.queueConnectedRowMeasure();
}
}),
rangeExtractor: (range) => {
const indexes = defaultRangeExtractor(range);
const focused =
this.focusedRowKey === null ? undefined : this.rowIndexesByKey.get(this.focusedRowKey);
if (
focused === undefined ||
focused < 0 ||
focused >= range.count ||
indexes.includes(focused)
) {
return indexes;
}
return [...indexes, focused].toSorted((left, right) => left - right);
},
scrollEndThreshold: CHAT_TRANSCRIPT_END_THRESHOLD_PX,
overscan: CHAT_TRANSCRIPT_OVERSCAN,
});
if (initialOffset !== null) {
this.pendingScrollOffset = {
offset: initialOffset,
stableFrames: 0,
zeroMaxFrames: 0,
onSettled: onInitialOffsetSettled,
};
}
}
get updateComplete() {
return this.host.updateComplete;
}
get liveAnnouncementText() {
return this.currentAnnouncementText;
}
requestUpdate = () => {
this.host.requestUpdate();
};
addController(controller: ReactiveController): void {
this.controllers.add(controller);
}
removeController(controller: ReactiveController): void {
this.controllers.delete(controller);
}
connect(): void {
if (this.connected) {
return;
}
this.connected = true;
for (const controller of this.controllers) {
controller.hostConnected?.();
}
if (this.pendingScrollOffset) {
this.host.requestUpdate();
}
}
update(): void {
for (const controller of this.controllers) {
controller.hostUpdated?.();
}
this.applyPendingScrollOffset();
}
disconnect(): void {
if (this.pendingRowMeasureFrame !== null) {
cancelAnimationFrame(this.pendingRowMeasureFrame);
this.pendingRowMeasureFrame = null;
}
if (this.pendingScrollFrame !== null) {
cancelAnimationFrame(this.pendingScrollFrame);
this.pendingScrollFrame = null;
}
if (!this.connected) {
this.threadInnerElement = null;
return;
}
this.connected = false;
for (const controller of this.controllers) {
controller.hostDisconnected?.();
}
this.threadInnerElement = null;
}
dispose(): void {
this.disconnect();
this.measureRowRefs.clear();
this.rowKeys = [];
this.rowIndexesByKey.clear();
this.messageRowKeysById.clear();
this.focusedRowKey = null;
this.pendingScrollOffset = null;
}
render<T>(
rows: readonly TranscriptRow<T>[],
renderRow: (row: TranscriptRow<T>) => unknown,
announcement: TranscriptAnnouncement | null,
announce: boolean,
overlay: unknown = nothing,
): TemplateResult {
this.syncRows(rows);
this.syncAnnouncement(announcement, announce);
const virtualizer = this.virtualizerController.getVirtualizer();
const virtualRows = virtualizer.getVirtualItems();
const nextRowKeys = new Set(
virtualRows.flatMap((virtualRow) => {
const row = rows[virtualRow.index];
return row ? [row.key] : [];
}),
);
const rendered = html`
<div class="chat-thread-inner chat-thread-inner--virtual" ${ref(this.scrollElementRef)}>
<div
class="chat-virtual-sizer"
style=${styleMap({ height: `${virtualizer.getTotalSize()}px` })}
>
${overlay}
${repeat(
virtualRows,
(virtualRow) => virtualRow.key,
(virtualRow) => {
const row = rows[virtualRow.index];
if (!row) {
return nothing;
}
return html`
<div
class="chat-virtual-row ${virtualRow.index === 0
? "chat-virtual-row--first"
: ""}"
style=${styleMap({
transform: `translateY(${
virtualRow.start - virtualizer.options.scrollMargin
}px)`,
// Keep skipped overscan rows at the virtualizer's known size.
containIntrinsicBlockSize: `auto ${virtualRow.size}px`,
})}
data-index=${String(virtualRow.index)}
data-virtual-row-key=${row.key}
${ref(this.measureRowRefFor(row.key))}
>
${renderRow(row)}
</div>
`;
},
)}
</div>
</div>
`;
return this.mcpAppUnmountGate.render(JSON.stringify([...nextRowKeys]), rendered, () =>
this.threadInnerElement
? [...this.threadInnerElement.querySelectorAll<HTMLElement>(".chat-virtual-row")].filter(
(row) => !nextRowKeys.has(row.dataset.virtualRowKey ?? ""),
)
: [],
) as TemplateResult;
}
scrollToEnd(options: { behavior?: ScrollBehavior } = {}): void {
this.virtualizerController.getVirtualizer().scrollToEnd(options);
}
scrollToOffset(offset: number): void {
if (this.scrollElement) {
this.scrollElement.scrollTop = offset;
}
this.virtualizerController.getVirtualizer().scrollToOffset(offset);
}
syncMessageRows(messageRowKeysById: ReadonlyMap<string, string>): void {
this.messageRowKeysById = new Map(messageRowKeysById);
}
revealMessage(messageId: string): boolean {
const rowKey = this.messageRowKeysById.get(messageId);
if (!rowKey) {
return false;
}
const rowIndex = this.rowIndexesByKey.get(rowKey);
if (rowIndex === undefined) {
return false;
}
this.virtualizerController.getVirtualizer().scrollToIndex(rowIndex, { align: "center" });
this.host.requestUpdate();
void this.host.updateComplete.then(() => {
const bubble = [
...(this.threadInnerElement?.querySelectorAll<HTMLElement>(".chat-bubble") ?? []),
].find((candidate) => candidate.dataset.entryId === messageId);
if (!bubble) {
return;
}
this.threadInnerElement
?.querySelector(".chat-bubble--reply-target")
?.classList.remove("chat-bubble--reply-target");
bubble.scrollIntoView?.({ behavior: "smooth", block: "center" });
bubble.classList.add("chat-bubble--reply-target");
bubble.addEventListener(
"animationend",
() => bubble.classList.remove("chat-bubble--reply-target"),
{ once: true },
);
});
return true;
}
getScrollOffset(): number | null {
return this.scrollElement?.scrollTop ?? null;
}
getMaxScrollOffset(): number | null {
const scrollElement = this.scrollElement;
return scrollElement
? Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight)
: null;
}
setContentReady(ready: boolean): void {
this.contentReady = ready;
}
restoreScrollOffset(
offset: number,
onSettled?: (position: ChatSessionScrollPosition) => void,
): void {
this.pendingScrollOffset = { offset, stableFrames: 0, zeroMaxFrames: 0, onSettled };
if (this.connected) {
this.host.requestUpdate();
}
}
getPendingScrollOffset(): number | null {
return this.pendingScrollOffset?.offset ?? null;
}
handleFocusIn(event: FocusEvent): void {
this.focusedRowKey = this.rowKeyFromEvent(event);
}
handleFocusOut(event: FocusEvent): void {
this.focusedRowKey = this.rowKeyFromEvent(event, event.relatedTarget);
}
private rowKeyFromEvent(event: FocusEvent, target: EventTarget | null = event.target) {
if (!(target instanceof Element) || !this.scrollElement?.contains(target)) {
return null;
}
const row = target.closest<HTMLElement>(".chat-virtual-row[data-virtual-row-key]");
if (!row || !this.scrollElement.contains(row)) {
return null;
}
return row.dataset.virtualRowKey || null;
}
private syncAnnouncement(announcement: TranscriptAnnouncement | null, announce: boolean): void {
if (!this.announcementInitialized || !announce) {
this.announcementInitialized = true;
this.announcementKey = announcement?.key ?? null;
this.currentAnnouncementText = "";
return;
}
if (!announcement || announcement.key === this.announcementKey) {
return;
}
this.announcementKey = announcement.key;
this.currentAnnouncementText = announcement.text;
}
private syncRows(rows: readonly TranscriptRow[]): void {
const nextKeys = rows.map((row) => row.key);
if (
nextKeys.length === this.rowKeys.length &&
nextKeys.every((key, index) => key === this.rowKeys[index])
) {
return;
}
this.rowKeys = Object.freeze(nextKeys);
this.rowIndexesByKey = new Map(this.rowKeys.map((key, index) => [key, index]));
for (const key of this.measureRowRefs.keys()) {
if (!this.rowIndexesByKey.has(key)) {
this.measureRowRefs.delete(key);
}
}
const keys = this.rowKeys;
const virtualizer = this.virtualizerController.getVirtualizer();
virtualizer.setOptions({
...virtualizer.options,
count: keys.length,
getItemKey: (index) => keys[index] ?? `missing:${index}`,
});
}
private syncScrollMargin(scrollElement: HTMLDivElement | null): void {
const scrollMargin = transcriptScrollMargin(scrollElement);
const virtualizer = this.virtualizerController.getVirtualizer();
if (scrollMargin === virtualizer.options.scrollMargin) {
return;
}
virtualizer.setOptions({
...virtualizer.options,
scrollMargin,
});
}
private applyPendingScrollOffset(): void {
const pending = this.pendingScrollOffset;
if (!pending || !this.connected) {
return;
}
const maxOffset = this.getMaxScrollOffset();
if (maxOffset === null) {
if (this.contentReady && this.rowKeys.length === 0) {
this.settlePendingScroll(0);
}
return;
}
if (maxOffset === 0 && pending.offset > 0) {
if (this.contentReady && this.rowKeys.length === 0) {
this.settlePendingScroll(0);
} else if (this.contentReady) {
if (pending.zeroMaxFrames >= CHAT_TRANSCRIPT_ZERO_MAX_SETTLE_FRAMES) {
this.settlePendingScroll(0);
return;
}
pending.zeroMaxFrames += 1;
this.schedulePendingScrollRetry();
}
return;
}
pending.zeroMaxFrames = 0;
const targetOffset = Math.min(pending.offset, maxOffset);
this.scrollToOffset(targetOffset);
const currentOffset = this.getScrollOffset();
if (currentOffset != null && Math.abs(currentOffset - targetOffset) <= 1) {
if (pending.stableFrames >= CHAT_TRANSCRIPT_SCROLL_RESTORE_STABLE_FRAMES) {
this.settlePendingScroll(currentOffset);
} else {
pending.stableFrames += 1;
this.schedulePendingScrollRetry();
}
} else {
pending.stableFrames = 0;
this.schedulePendingScrollRetry();
}
}
private schedulePendingScrollRetry(): void {
if (!this.connected || this.pendingScrollFrame !== null) {
return;
}
this.pendingScrollFrame = requestAnimationFrame(() => {
this.pendingScrollFrame = null;
if (this.connected && this.pendingScrollOffset) {
this.host.requestUpdate();
}
});
}
private settlePendingScroll(scrollTop: number): void {
const pending = this.pendingScrollOffset;
this.pendingScrollOffset = null;
if (!pending) {
return;
}
const maxScrollTop = this.getMaxScrollOffset();
pending.onSettled?.({
scrollTop,
anchorToEnd:
maxScrollTop === null
? this.contentReady && this.rowKeys.length === 0
: maxScrollTop - scrollTop <= CHAT_TRANSCRIPT_END_THRESHOLD_PX,
});
}
}
export class ChatTranscriptController implements ReactiveController {
private activeSessionKey: string | null = null;
private sessionVirtualizer: ChatSessionVirtualizerHost | null = null;
private connected = false;
constructor(private readonly host: ReactiveControllerHost) {
host.addController(this);
}
get renderedSessionKey(): string | null {
return this.activeSessionKey;
}
renderSession(
paneId: string,
sessionKey: string,
render: (transcript: ChatTranscriptSession) => TemplateResult,
): TemplateResult {
if (
!this.sessionVirtualizer ||
this.activeSessionKey === null ||
!areUiSessionKeysEquivalent(this.activeSessionKey, sessionKey)
) {
this.sessionVirtualizer?.dispose();
const savedPosition = getChatSessionScrollPosition(paneId, sessionKey);
const initialOffset = savedPosition?.anchorToEnd ? null : (savedPosition?.scrollTop ?? null);
this.activeSessionKey = sessionKey;
this.sessionVirtualizer = new ChatSessionVirtualizerHost(
this.host,
initialOffset,
initialOffset === null
? undefined
: (position) => {
saveChatSessionScrollPosition(paneId, sessionKey, position);
},
);
if (this.connected) {
this.sessionVirtualizer.connect();
}
}
return render(this.sessionVirtualizer);
}
scrollToEnd(options: { behavior?: ScrollBehavior } = {}): void {
this.sessionVirtualizer?.scrollToEnd(options);
}
scrollToOffset(offset: number, onSettled?: (position: ChatSessionScrollPosition) => void): void {
this.sessionVirtualizer?.restoreScrollOffset(offset, onSettled);
}
revealMessage(messageId: string): boolean {
return this.sessionVirtualizer?.revealMessage(messageId) ?? false;
}
pendingScrollOffsetFor(sessionKey: string): number | null {
return this.activeSessionKey !== null &&
areUiSessionKeysEquivalent(this.activeSessionKey, sessionKey)
? (this.sessionVirtualizer?.getPendingScrollOffset() ?? null)
: null;
}
handleFocusIn(event: FocusEvent): void {
this.sessionVirtualizer?.handleFocusIn(event);
}
handleFocusOut(event: FocusEvent): void {
this.sessionVirtualizer?.handleFocusOut(event);
}
hostConnected(): void {
this.connected = true;
this.sessionVirtualizer?.connect();
}
hostUpdated(): void {
this.sessionVirtualizer?.update();
}
hostDisconnected(): void {
this.connected = false;
this.sessionVirtualizer?.disconnect();
}
}
@@ -0,0 +1,507 @@
/* @vitest-environment jsdom */
import { expectDefined } from "@openclaw/normalization-core";
import { render } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardProvider } from "../../../lib/board/provider.ts";
import { resolveAssistantAttachmentAuthToken } from "../chat-pane-state.ts";
import { createTestChatPane } from "../chat-pane.test-support.ts";
import * as chatThreadBuild from "../chat-thread-build.ts";
import {
buildCachedChatItems,
getExpandedToolCards,
getExpandedUserMessages,
getExpansionStateVersion,
} from "../chat-thread.ts";
import { createTestTranscript } from "../chat-view.test-helpers.ts";
import {
isChatMediaResourceCurrent,
observeChatMediaResource,
releaseChatMediaResourceSubscriber,
} from "./chat-message-media.ts";
import { resetTranscriptSession } from "./chat-thread-interactions.ts";
import { renderChatThread } from "./chat-thread.ts";
import {
flushDeferredRowPrune,
installTranscriptDomMocks,
resetTranscriptTestDom,
threadProps,
} from "./chat-transcript.test-support.ts";
describe("chat transcript invalidation", () => {
beforeEach(installTranscriptDomMocks);
afterEach(resetTranscriptTestDom);
it("keeps built row identities across an A to B to A presentation reset", () => {
const paneId = "pane-session-items";
const messagesA = [{ role: "assistant", content: "session A", timestamp: 1_000 }];
const messagesB = [{ role: "assistant", content: "session B", timestamp: 2_000 }];
const stableInputs = {
paneId,
runId: null,
toolMessages: [],
streamSegments: [],
stream: null,
streamStartedAt: null,
showToolCalls: true,
};
const buildSpy = vi.spyOn(chatThreadBuild, "buildChatItems");
const itemsA = buildCachedChatItems({
...stableInputs,
sessionKey: "agent:main:session-a",
messages: messagesA,
});
resetTranscriptSession(paneId);
buildCachedChatItems({
...stableInputs,
sessionKey: "agent:main:session-b",
messages: messagesB,
});
resetTranscriptSession(paneId);
const restoredItemsA = buildCachedChatItems({
...stableInputs,
sessionKey: "agent:main:session-a",
messages: messagesA,
});
expect(buildSpy).toHaveBeenCalledTimes(2);
expect(restoredItemsA).toBe(itemsA);
expect(restoredItemsA.every((item, index) => item === itemsA[index])).toBe(true);
});
it("rebinds guarded transcript images when the gateway rotates its auth token", async () => {
const NativeUrl = URL;
const blobUrl = `blob:transcript-media-${crypto.randomUUID()}`;
vi.stubGlobal(
"URL",
class extends NativeUrl {
static override createObjectURL = vi.fn(() => blobUrl);
static override revokeObjectURL = vi.fn();
},
);
let previousSignal: AbortSignal | undefined;
const fetchMock = vi.fn((_source: string, init?: RequestInit) => {
if (fetchMock.mock.calls.length === 1) {
return new Promise<Response>((_resolve, reject) => {
previousSignal = init?.signal ?? undefined;
previousSignal?.addEventListener(
"abort",
() => reject(new DOMException("media scope changed", "AbortError")),
{ once: true },
);
});
}
return Promise.resolve({
ok: true,
blob: async () => new Blob(["png"], { type: "image/png" }),
} as Response);
});
vi.stubGlobal("fetch", fetchMock);
const source = `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`;
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const client = {
request: vi.fn(async () => null),
} as unknown as Parameters<typeof createTestChatPane>[0]["client"];
const sessions = {} as Parameters<typeof createTestChatPane>[0]["sessions"];
const { pane, state } = createTestChatPane({ client, sessions });
state.hello = {
auth: { deviceToken: "test-auth-token" },
} as typeof state.hello;
const messages = [
{
role: "assistant",
content: [{ type: "image", url: source }],
timestamp: 1_000,
},
];
const renderPane = () => {
render(
renderChatThread(
{
...threadProps("pane-gateway-media-auth", state.sessionKey, messages),
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state),
onRequestUpdate: renderPane,
},
transcript,
),
container,
);
transcript.hostUpdated();
};
state.requestUpdate = renderPane;
renderPane();
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const thumbnailSource = source.replace(/\/full$/u, "/thumbnail");
const previousResource = observeChatMediaResource<string | null>(
"managed-image",
`${thumbnailSource}::test-auth-token::`,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(previousResource.subscribers.size).toBe(1);
pane.applyGatewaySnapshot({
...pane.context.gateway.snapshot,
client,
phase: "connected",
hello: {
...pane.context.gateway.snapshot.hello,
auth: { deviceToken: "test-token" },
} as typeof pane.context.gateway.snapshot.hello,
});
expect(previousSignal?.aborted).toBe(true);
expect(isChatMediaResourceCurrent(previousResource)).toBe(false);
await flushDeferredRowPrune();
const nextResource = observeChatMediaResource<string | null>(
"managed-image",
`${thumbnailSource}::test-token::`,
);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe(
"Bearer test-token",
);
expect(isChatMediaResourceCurrent(nextResource)).toBe(true);
expect(nextResource.subscribers.size).toBe(1);
expect(container.querySelector<HTMLImageElement>(".chat-message-image")?.src).toBe(blobUrl);
releaseChatMediaResourceSubscriber(renderPane);
transcript.hostDisconnected();
});
it("reconciles guarded local attachments when pane preview roots change", async () => {
let previousSignal: AbortSignal | undefined;
const fetchMock = vi.fn((_source: string, init?: RequestInit) => {
if (fetchMock.mock.calls.length === 1) {
return new Promise<Response>((_resolve, reject) => {
previousSignal = init?.signal ?? undefined;
previousSignal?.addEventListener(
"abort",
() => reject(new DOMException("preview roots changed", "AbortError")),
{ once: true },
);
});
}
return Promise.resolve({
ok: true,
json: async () => ({
available: true,
mediaTicket: "root-restored-ticket",
mediaTicketExpiresAt: new Date(Date.now() + 90_000).toISOString(),
}),
} as Response);
});
vi.stubGlobal("fetch", fetchMock);
const client = {
request: vi.fn(async () => null),
} as unknown as Parameters<typeof createTestChatPane>[0]["client"];
const sessions = {} as Parameters<typeof createTestChatPane>[0]["sessions"];
const { pane, state } = createTestChatPane({ client, sessions });
const configPane = pane as typeof pane & {
applyApplicationConfig: (config: typeof pane.context.config.current) => void;
};
state.hello = {
auth: { deviceToken: "test-auth-token" },
} as typeof state.hello;
state.localMediaPreviewRoots = ["/tmp/openclaw"];
state.embedSandboxMode = "scripts";
state.allowExternalEmbedUrls = false;
const source = `/tmp/openclaw/${crypto.randomUUID()}.pdf`;
const messages = [
{
role: "assistant",
content: `Local document\nMEDIA:${source}`,
timestamp: 1_000,
},
];
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const renderPane = () => {
render(
renderChatThread(
{
...threadProps("pane-local-media-roots", state.sessionKey, messages),
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state),
localMediaPreviewRoots: state.localMediaPreviewRoots,
onRequestUpdate: renderPane,
},
transcript,
),
container,
);
transcript.hostUpdated();
};
state.requestUpdate = renderPane;
renderPane();
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const previousResource = observeChatMediaResource(
"assistant-attachment",
`::test-auth-token::${source}`,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(previousResource.subscribers.size).toBe(1);
const config = {
...pane.context.config.current,
localMediaPreviewRoots: ["/tmp/elsewhere"],
embedSandboxMode: "scripts" as const,
allowExternalEmbedUrls: false,
};
configPane.applyApplicationConfig(config);
await flushDeferredRowPrune();
expect(previousSignal?.aborted).toBe(true);
expect(isChatMediaResourceCurrent(previousResource)).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(
container.querySelector(".chat-assistant-attachment-card__reason")?.textContent,
).toContain("Outside allowed folders");
configPane.applyApplicationConfig({
...config,
localMediaPreviewRoots: ["/tmp/openclaw"],
});
await flushDeferredRowPrune();
const restoredResource = observeChatMediaResource(
"assistant-attachment",
`::test-auth-token::${source}`,
);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe(
"Bearer test-auth-token",
);
expect(isChatMediaResourceCurrent(restoredResource)).toBe(true);
expect(restoredResource.subscribers.size).toBe(1);
expect(
container.querySelector(".chat-assistant-attachment-card__link")?.getAttribute("href"),
).toContain("mediaTicket=root-restored-ticket");
releaseChatMediaResourceSubscriber(renderPane);
transcript.hostDisconnected();
});
it("updates MCP App pinning when the same provider's capability changes", async () => {
const provider = {
sessionKey: "agent:main:main",
canPinWidgets: true,
canPinMcpApps: false,
pinMcpApp: vi.fn(async () => undefined),
snapshot$: {
value: {
sessionKey: "agent:main:main",
revision: 1,
tabs: [],
widgets: [],
},
subscribe: () => () => undefined,
},
};
const props = {
...threadProps("pane-mcp-capability"),
boardProvider: provider as unknown as BoardProvider,
messages: [
{
role: "assistant",
timestamp: 1_000,
content: [
{ type: "text", text: "Here is the dashboard app." },
{
type: "canvas",
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
title: "Dashboard app",
viewId: "outer-view-must-not-be-pinned",
mcpApp: {
viewId: "view-dashboard-app",
serverName: "dashboard",
toolName: "show",
uiResourceUri: "ui://dashboard/app.html",
toolCallId: "call-dashboard-app",
originSessionKey: "agent:main:main",
},
},
},
],
},
],
};
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
render(renderChatThread(props, transcript), container);
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
expect(container.querySelector('[data-content-kind="mcp-app"]')).not.toBeNull();
expect(container.querySelector("[data-pin-widget]")).toBeNull();
provider.canPinMcpApps = true;
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
expect(container.querySelector("[data-pin-widget]")).not.toBeNull();
expect(provider.snapshot$.value.revision).toBe(1);
provider.canPinMcpApps = false;
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
expect(container.querySelector("[data-pin-widget]")).toBeNull();
expect(provider.snapshot$.value.revision).toBe(1);
});
it("keeps mounted disclosure handlers attached to recreated session expansion maps", () => {
const sessionKey = "retained-session";
const props = {
...threadProps("retained-pane", sessionKey, [
{ 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,
},
]),
showToolCalls: true,
};
const controller = createTestTranscript();
const retainedPane = document.body.appendChild(document.createElement("div"));
render(renderChatThread(props, controller), 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.body.appendChild(document.createElement("div"));
render(
renderChatThread(
{
...props,
paneId: `alternate-pane-${index}`,
sessionKey: `alternate-session-${index}`,
},
createTestTranscript(),
),
alternatePane,
);
}
render(renderChatThread(props, controller), 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-group.user .chat-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 = createTestTranscript();
const toolVisibilityPane = document.body.appendChild(document.createElement("div"));
const renderToolVisibility = (next = toolVisibilityProps) =>
render(renderChatThread(next, toolVisibilityController), toolVisibilityPane);
renderToolVisibility();
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();
renderToolVisibility();
expectDefined(disclosureButtons()[1], "second mounted tool disclosure").click();
renderToolVisibility();
expectDefined(disclosureButtons()[1], "second mounted tool disclosure").click();
renderToolVisibility();
expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([
"true",
"false",
]);
renderToolVisibility({ ...toolVisibilityProps, showToolCalls: false });
expect(disclosureButtons()).toHaveLength(0);
renderToolVisibility();
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);
renderToolVisibility({
...toolVisibilityProps,
messages: toolVisibilityProps.messages.filter(
(message) => !("toolCallId" in message && message.toolCallId === "expanded-tool"),
),
});
expect(visibilityState.has(expandedToolId)).toBe(false);
expect(visibilityState.get(collapsedToolId)).toBe(false);
});
});
@@ -0,0 +1,681 @@
// Chat-item projection, expansion, reply hydration, and guarded row rendering.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { html, nothing, type TemplateResult } from "lit";
import { guard } from "lit/directives/guard.js";
import { classifySessionKind } from "../../../../../src/sessions/classify-session-kind.js";
import { i18n } from "../../../i18n/index.ts";
import type { MessageGroup } from "../../../lib/chat/chat-types.ts";
import { extractTextCached } from "../../../lib/chat/message-extract.ts";
import { normalizeMessage } from "../../../lib/chat/message-normalizer.ts";
import {
areUiSessionKeysEquivalent,
isUiGlobalScopeConfigured,
parseAgentSessionKey,
resolveUiGlobalAliasAgentId,
} from "../../../lib/sessions/session-key.ts";
import { resolveTurnRecap, type TurnRecap } from "../chat-progress.ts";
import {
assistantGroupCanOwnActiveRunStatus,
assistantMessageExpansionSignature,
buildCachedChatItems,
coalesceActivityRuns,
coalesceStreamRuns,
collapseCompletedTurnWork,
getExpansionStateVersion,
getExpandedAssistantMessages,
getExpandedToolCards,
getExpandedUserMessages,
persistedMessageEntryId,
setExpansionState,
syncToolCardExpansionState,
} from "../chat-thread.ts";
import { getToolTitlesVersion } from "../tool-titles.ts";
import { renderBackgroundTasksStatusRow } from "./chat-background-tasks-status.ts";
import { renderChatDivider, renderChatNotice } from "./chat-divider.ts";
import { resolveMessageGroupSenderLabel } from "./chat-message-group.ts";
import { resolveMessageReplyText } from "./chat-message-markdown.ts";
import {
getChatMediaRenderVersion,
renderActivityGroup,
renderMessageGroup,
renderStreamGroup,
renderWorkGroupSummary,
type MessageReplyTarget,
type StreamGroupOptions,
type StreamGroupPart,
} from "./chat-message.ts";
import { renderRealtimeTalkConversation } from "./chat-realtime-controls.ts";
import {
closeTranscriptSearch,
getTranscriptState,
type ChatThreadProps,
type ChatThreadState,
} from "./chat-thread-interactions.ts";
import type {
ChatTranscriptSession,
TranscriptAnnouncement,
TranscriptRow,
} from "./chat-transcript-controller.ts";
import { resolveAssistantDisplayAvatar } from "./chat-welcome.ts";
import { renderTurnRecapRow } from "./chat-working-indicator.ts";
type ChatTranscriptProjection = {
isDirectThread: boolean;
isEmpty: boolean;
showLoadingSkeleton: boolean;
searchOpen: boolean;
renderRows: (overlay?: unknown) => TemplateResult;
};
type ChatRenderItem = ReturnType<typeof coalesceActivityRuns>[number];
const CHAT_TRANSCRIPT_ANNOUNCEMENT_MAX_CHARS = 500;
type LoadedReplySource = {
rowKey: string;
preview: MessageReplyTarget & { sourceMessageId: string };
};
function projectResolvedReplyPreview(
message: unknown,
replyToId: string,
props: Pick<ChatThreadProps, "assistantName" | "userAvatar" | "userId" | "userName">,
): LoadedReplySource["preview"] | undefined {
const normalized = normalizeMessage(message);
const text = resolveMessageReplyText(message);
if (!text) {
return undefined;
}
const group: MessageGroup = {
kind: "group",
key: replyToId,
role: normalized.role,
senderLabel: normalized.senderLabel,
...(normalized.sender ? { sender: normalized.sender } : {}),
messages: [{ key: replyToId, message }],
timestamp: normalized.timestamp,
isStreaming: false,
};
const sourceMessageId = persistedMessageEntryId(message) ?? replyToId;
return {
messageId: sourceMessageId,
sourceMessageId,
senderLabel: resolveMessageGroupSenderLabel(group, props),
text,
};
}
function latestTranscriptAnnouncement(
items: readonly ChatRenderItem[],
): TranscriptAnnouncement | null {
for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) {
const item = items[itemIndex];
if (!item || item.kind !== "group" || item.role.toLowerCase() !== "assistant") {
continue;
}
for (let messageIndex = item.messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = item.messages[messageIndex]?.message;
const text = extractTextCached(message)?.trim();
if (text) {
return {
key: item.key,
text: truncateUtf16Safe(text, CHAT_TRANSCRIPT_ANNOUNCEMENT_MAX_CHARS),
};
}
}
}
return null;
}
function chatRenderItemGuardDependencies(item: ChatRenderItem): readonly unknown[] {
if (item.kind === "stream-run") {
return [item.key, ...item.parts];
}
if (item.kind === "work-group") {
return [item.key, item.durationMs, item.hasError, ...item.groups];
}
if (item.kind === "activity-run") {
return [item.key, ...item.groups];
}
return [item];
}
function trackTranscriptRenderDependencies(
state: ChatThreadState,
dependencies: unknown[],
): unknown[] {
const previous = state.transcriptRenderDependencies;
const nextLength = dependencies.length - 1;
let changed = previous.length !== nextLength;
for (let index = 0; !changed && index < nextLength; index += 1) {
changed = !Object.is(previous[index], dependencies[index + 1]);
}
if (changed) {
// The first dependency is chatItems. Keep the shared context stable when
// only the live row changes, but invalidate every row for presentation changes.
state.transcriptRenderDependencies = dependencies.slice(1);
state.transcriptRenderContext = {};
}
return dependencies;
}
function guardChatRenderItems(
state: ChatThreadState,
// Live run status is not derivable from a row's own item identity: ownership
// is decided by sibling rows, and the usage counter ticks on run patches that
// touch nothing else. Rows showing status must re-render on both, or the
// memoized copy stacks a second claw row or freezes the token count.
liveStatus: (item: ChatRenderItem) => string,
render: (item: ChatRenderItem) => unknown,
) {
return (item: ChatRenderItem) =>
guard(
[...chatRenderItemGuardDependencies(item), state.transcriptRenderContext, liveStatus(item)],
() => render(item),
);
}
export function projectChatTranscript(
props: ChatThreadProps,
transcript: ChatTranscriptSession,
): ChatTranscriptProjection {
const state = getTranscriptState(props.paneId);
const requestUpdate = props.onRequestUpdate ?? (() => {});
const displayStream = props.stream ?? null;
const sessionHost = props.sessionHost ?? null;
// Equivalence, not exact match: the default session travels under alias
// keys ("main" vs "agent:main:main") depending on the caller.
const activeSession = props.sessions?.sessions?.find((row) =>
areUiSessionKeysEquivalent(row.key, props.sessionKey),
);
// Global-alias detection needs no session row: under configured global
// scope, agent:<id>:global and configured-main aliases route to the global
// stream even when the capped sessions list omits the canonical row (or it
// does not exist yet). The scope gate keeps per-sender main threads direct.
const isGlobalAliasKey =
parseAgentSessionKey(props.sessionKey)?.rest === "global" ||
(sessionHost !== null &&
isUiGlobalScopeConfigured(sessionHost) &&
resolveUiGlobalAliasAgentId(sessionHost, props.sessionKey) !== null);
const reasoningLevel = activeSession?.reasoningLevel ?? "off";
const showReasoning = props.showThinking && reasoningLevel !== "off";
const assistantIdentity = {
name: props.assistantName,
avatar: resolveAssistantDisplayAvatar(props),
};
const locale = i18n.getLocale();
const searchFiltering = state.searchOpen && Boolean(state.searchQuery.trim());
const chatItems = buildCachedChatItems({
paneId: props.paneId,
sessionKey: props.sessionKey,
runId: props.runId === undefined ? (activeSession?.activeRunIds?.[0] ?? null) : props.runId,
locale,
messages: props.messages,
toolMessages: props.toolMessages,
streamSegments: props.streamSegments,
stream: displayStream,
streamStartedAt: props.streamStartedAt,
queue: props.queue,
showToolCalls: props.showToolCalls,
persistCommentary: props.persistCommentary,
runWorking: Boolean(props.runWorking),
runActive: Boolean(props.runActive),
planStatus: props.planStatus,
questionPrompts: props.questionPrompts,
loading: props.loading,
searchOpen: state.searchOpen,
searchQuery: state.searchQuery,
});
syncToolCardExpansionState(
props.sessionKey,
chatItems,
Boolean(props.autoExpandToolCalls),
searchFiltering || !props.showToolCalls,
);
const expandedToolCards = getExpandedToolCards(props.sessionKey);
const expandedUserMessages = getExpandedUserMessages(props.sessionKey);
const expandedAssistantMessages = getExpandedAssistantMessages(props.sessionKey);
const questionPrompts = new Map(
(props.questionPrompts ?? []).map((prompt) => [prompt.id, prompt]),
);
const toggleToolCardExpanded = (toolCardId: string) => {
setExpansionState(expandedToolCards, toolCardId, !expandedToolCards.get(toolCardId));
requestUpdate();
};
const toggleAssistantMessageExpanded = (messageId: string) => {
const current = expandedAssistantMessages.get(messageId);
if (current?.status === "loaded") {
expandedAssistantMessages.set(messageId, {
...current,
expanded: !current.expanded,
revision: current.revision + 1,
});
requestUpdate();
return;
}
const loader = props.loadFullAssistantMessage;
if (!loader || current?.status === "loading") {
return;
}
const revision = (current?.revision ?? 0) + 1;
expandedAssistantMessages.set(messageId, { status: "loading", revision });
requestUpdate();
void loader({
sessionKey: props.sessionKey,
...(props.fullMessageAgentId ? { agentId: props.fullMessageAgentId } : {}),
messageId,
kind: "assistant_message",
}).then(
(result) => {
const pending = expandedAssistantMessages.get(messageId);
if (pending?.status !== "loading" || pending.revision !== revision) {
return;
}
const markdown =
result?.ok && result.message && typeof result.message === "object"
? extractTextCached(result.message)
: null;
expandedAssistantMessages.set(
messageId,
markdown === null
? { status: "error", revision: revision + 1 }
: { status: "loaded", expanded: true, markdown, revision: revision + 1 },
);
requestUpdate();
},
() => {
const pending = expandedAssistantMessages.get(messageId);
if (pending?.status !== "loading" || pending.revision !== revision) {
return;
}
expandedAssistantMessages.set(messageId, { status: "error", revision: revision + 1 });
requestUpdate();
},
);
};
const hasRealtimeTalkConversation = (props.realtimeTalkConversation?.length ?? 0) > 0;
const isEmpty = chatItems.length === 0 && !props.loading && !hasRealtimeTalkConversation;
transcript.setContentReady(!props.loading);
// 1:1 sessions drop the avatar gutter entirely; group threads keep avatars
// as the always-visible identity marker. The canonical session kind decides;
// the sessions list is capped, so absent/unknown rows classify by key:
// global aliases first, then the same core key-shape helper the gateway
// uses. Message senderLabels are not a signal here: gateway sanitization
// labels 1:1 channel DM rows too.
const rowKind = activeSession?.kind;
const sessionKind =
rowKind && rowKind !== "unknown"
? rowKind
: isGlobalAliasKey
? "global"
: classifySessionKind(props.sessionKey);
// Only agent-solo kinds qualify: "global" aggregates every inbound context
// under session.scope="global" (including group/channel senders), so it
// keeps avatars like "group" and "unknown" do. An identity-resolving gateway
// (multi-user trusted proxy) also keeps them: several people share these
// sessions, so the author marker is signal, not decoration.
const isDirectThread =
(sessionKind === "direct" || sessionKind === "cron" || sessionKind === "spawn-child") &&
!props.userId;
const showLoadingSkeleton = props.loading && chatItems.length === 0;
const threadContextWindow =
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null;
const activeContinuationByGroupKey = new Map<
string,
{ parts: StreamGroupPart[]; options: StreamGroupOptions }
>();
const turnRecapByGroupKey = new Map<string, TurnRecap>();
const loadedReplySources = new Map<string, LoadedReplySource>();
const resolvedReplyPreviews = new Map<string, LoadedReplySource["preview"] | undefined>();
const resolveReplyPreview = (replyToId: string) => {
const loaded = loadedReplySources.get(replyToId)?.preview;
if (loaded) {
return loaded;
}
if (resolvedReplyPreviews.has(replyToId)) {
return resolvedReplyPreviews.get(replyToId);
}
const message = props.replyMessageAccess?.read(replyToId);
const preview = message ? projectResolvedReplyPreview(message, replyToId, props) : undefined;
resolvedReplyPreviews.set(replyToId, preview);
return preview;
};
const sharedMessageRenderOptions = {
onOpenSidebar: props.onOpenSidebar,
sessionKey: props.sessionKey,
boardProvider: props.boardProvider,
agentId: props.fullMessageAgentId,
runActive: props.runActive,
onOpenWorkspaceFile: props.onOpenWorkspaceFile,
onRequestUpdate: requestUpdate,
basePath: props.basePath,
localMediaPreviewRoots: props.localMediaPreviewRoots ?? [],
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null,
resolveArtifactDownload: props.resolveArtifactDownload,
onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded,
onRequestOpenImage: props.onRequestOpenImage,
onOpenImage: props.onOpenImage,
canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
showAssistantAvatar: false,
} satisfies StreamGroupOptions;
const streamGroupOptions = {
...sharedMessageRenderOptions,
assistant: assistantIdentity,
} satisfies StreamGroupOptions;
const renderGroupOptions = (item: MessageGroup) => {
const lastMessage = item.messages.at(-1)?.message;
const rewindEntryId =
item.role.toLowerCase() === "user" && lastMessage
? persistedMessageEntryId(lastMessage)
: null;
return {
...sharedMessageRenderOptions,
showReasoning,
showToolCalls: props.showToolCalls,
autoExpandToolCalls: Boolean(props.autoExpandToolCalls),
isToolMessageExpanded: (messageId: string) => expandedToolCards.get(messageId),
onToggleToolMessageExpanded: (messageId: string, expanded?: boolean) => {
setExpansionState(
expandedToolCards,
messageId,
!(expanded ?? expandedToolCards.get(messageId) ?? false),
);
requestUpdate();
},
isUserMessageExpanded: (messageId: string) => expandedUserMessages.get(messageId) ?? false,
onToggleUserMessageExpanded: (messageId: string) => {
setExpansionState(expandedUserMessages, messageId, !expandedUserMessages.get(messageId));
requestUpdate();
},
loadFullAssistantMessage: props.loadFullAssistantMessage ?? undefined,
getAssistantMessageExpansion: (messageId: string) => expandedAssistantMessages.get(messageId),
onToggleAssistantMessageExpanded: toggleAssistantMessageExpanded,
isToolExpanded: (toolCardId: string) => expandedToolCards.get(toolCardId) ?? false,
onToggleToolExpanded: toggleToolCardExpanded,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
userId: props.userId ?? null,
userName: props.userName ?? null,
userAvatar: props.userAvatar ?? null,
showAvatarGutter: !isDirectThread,
contextWindow: threadContextWindow,
onReply: props.onSetReply
? (target) => state.transcriptRenderContext.onSetReply?.(target)
: undefined,
resolveReplyPreview,
onResolveReply: props.replyMessageAccess?.request,
onOpenReply: (replyToId: string) => state.transcriptRenderContext.onOpenReply?.(replyToId),
replyNavigationId: props.replyMessageAccess?.navigationId,
onRewind:
rewindEntryId && props.onRewindMessage
? () => {
void Promise.resolve(props.onRewindMessage?.(rewindEntryId)).then((rewound) => {
if (rewound) {
props.onFocusComposer?.();
}
});
}
: undefined,
rewindDisabled: Boolean(props.runActive || props.runWorking),
activeContinuation: activeContinuationByGroupKey.get(item.key),
turnRecap: turnRecapByGroupKey.get(item.key),
} satisfies Parameters<typeof renderMessageGroup>[1];
};
const renderGroupItem = (item: MessageGroup) => {
return renderMessageGroup(item, renderGroupOptions(item));
};
// Only the working indicator shows live usage, so rows without one keep
// memoizing across usage patches.
const workingUsageKey = `usage:${props.runOutputTokens ?? ""}`;
const liveStatusSignature = (item: ChatRenderItem): string => {
if (item.kind === "stream-run") {
return item.parts.some((part) => part.kind === "reading-indicator") ? workingUsageKey : "";
}
if (item.kind !== "group") {
return "";
}
const continuation = activeContinuationByGroupKey.get(item.key);
const recap = turnRecapByGroupKey.get(item.key);
// Part keys stand in for the rest of the continuation: its remaining
// options mirror props that already invalidate every row through the
// shared render context.
const continuationKey = continuation
? `${continuation.parts.map((part) => part.key).join(" ")}${workingUsageKey}`
: "";
const recapKey = recap ? `${recap.runtimeMs}:${recap.outputTokens ?? ""}` : "";
return `${continuationKey}|${recapKey}`;
};
const renderItem = guardChatRenderItems(state, liveStatusSignature, (item) => {
if (item.kind === "divider") {
return renderChatDivider(item, props.onOpenSessionCheckpoints);
}
if (item.kind === "notice") {
return renderChatNotice(item);
}
if (item.kind === "stream-run") {
return renderStreamGroup(item.parts, {
...streamGroupOptions,
questionPrompts,
planStatus: props.planStatus,
planActive: Boolean(props.runActive),
startupPhase: props.startupStatus?.phase,
waitingApproval: props.waitingApproval,
runOutputTokens: props.runOutputTokens,
});
}
if (item.kind === "work-group") {
const workExpanded = expandedToolCards.get(item.key) ?? item.hasError;
return html`
${renderWorkGroupSummary(item, {
expanded: workExpanded,
onToggle: () => {
setExpansionState(expandedToolCards, item.key, !workExpanded);
requestUpdate();
},
})}
${workExpanded ? item.groups.map((group) => renderGroupItem(group)) : nothing}
`;
}
if (item.kind === "activity-run") {
const firstGroup = item.groups[0];
if (!firstGroup) {
return nothing;
}
if (item.groups.length === 1) {
return renderGroupItem(firstGroup);
}
return renderActivityGroup(item.groups, renderGroupOptions(firstGroup));
}
if (item.kind === "group") {
return renderGroupItem(item);
}
if (item.kind === "question") {
return renderStreamGroup([item], {
questionPrompts,
});
}
return nothing;
});
const collapsedItems = coalesceActivityRuns(
collapseCompletedTurnWork(coalesceStreamRuns(chatItems), {
sessionKey: props.sessionKey,
runWorking: Boolean(props.runWorking),
searchActive: searchFiltering,
}),
{ searchActive: searchFiltering },
);
// Watch/settle on actual indicator visibility (not runWorking): queued
// sends show the claw before the run starts, and the recap must never
// stack under a visible working row.
const workingIndicatorVisible = chatItems.some((item) => item.kind === "reading-indicator");
const turnRecap = resolveTurnRecap(props.sessionKey, workingIndicatorVisible, activeSession);
const transcriptItems = collapsedItems.filter((item, index) => {
if (item.kind !== "stream-run") {
return true;
}
const previous = collapsedItems[index - 1];
const isActiveStatusRun =
item.parts.some((part) => part.kind === "reading-indicator") &&
item.parts.every((part) => part.kind === "reading-indicator" || part.kind === "plan");
if (
previous?.kind !== "group" ||
!isActiveStatusRun ||
!assistantGroupCanOwnActiveRunStatus(previous)
) {
return true;
}
// A reply and its still-running state are one turn-level presentation.
// Keeping the status in the reply avoids a second claw/assistant row.
activeContinuationByGroupKey.set(previous.key, {
parts: item.parts,
options: {
...streamGroupOptions,
planStatus: props.planStatus,
planActive: Boolean(props.runActive),
startupPhase: props.startupStatus?.phase,
waitingApproval: props.waitingApproval,
runOutputTokens: props.runOutputTokens,
},
});
return false;
});
for (const item of transcriptItems) {
if (item.kind !== "group") {
continue;
}
const senderLabel = resolveMessageGroupSenderLabel(item, {
assistantName: props.assistantName,
userId: props.userId,
userName: props.userName,
userAvatar: props.userAvatar,
});
for (const source of item.messages) {
const sourceMessageId = persistedMessageEntryId(source.message);
const text = resolveMessageReplyText(source.message);
if (sourceMessageId && text) {
loadedReplySources.set(sourceMessageId, {
rowKey: item.key,
preview: {
messageId: source.key,
sourceMessageId,
senderLabel,
text,
},
});
}
}
}
transcript.syncMessageRows(
new Map([...loadedReplySources].map(([messageId, source]) => [messageId, source.rowKey])),
);
let turnRecapOwnerKey: string | null = null;
if (turnRecap !== null) {
const lastItem = transcriptItems.at(-1);
if (lastItem?.kind === "group" && assistantGroupCanOwnActiveRunStatus(lastItem)) {
turnRecapByGroupKey.set(lastItem.key, turnRecap);
turnRecapOwnerKey = lastItem.key;
}
}
const transcriptRows: TranscriptRow<ChatRenderItem>[] = transcriptItems.map((item) => ({
kind: "item",
key: item.key,
item,
}));
const realtimeConversation = renderRealtimeTalkConversation(props);
if (realtimeConversation !== nothing) {
transcriptRows.push({
kind: "content",
key: "realtime-talk",
content: realtimeConversation,
});
}
if (turnRecap !== null && turnRecapOwnerKey === null && !isEmpty && !showLoadingSkeleton) {
transcriptRows.push({
kind: "content",
key: "turn-recap",
content: renderTurnRecapRow(turnRecap),
});
}
const backgroundTasks =
!props.runWorking && !isEmpty && !showLoadingSkeleton
? renderBackgroundTasksStatusRow(props.backgroundTasks)
: nothing;
if (backgroundTasks !== nothing) {
transcriptRows.push({
kind: "content",
key: "background-tasks",
content: backgroundTasks,
});
}
trackTranscriptRenderDependencies(state, [
chatItems,
locale,
expandedToolCards,
getExpansionStateVersion(expandedToolCards),
expandedUserMessages,
getExpansionStateVersion(expandedUserMessages),
assistantMessageExpansionSignature(expandedAssistantMessages),
getChatMediaRenderVersion(),
// The host minute poll requests an update; this key crosses row guard() memoization.
Math.floor(Date.now() / 60_000),
getToolTitlesVersion(),
props.sessionKey,
props.boardProvider,
props.boardProvider?.canPinWidgets,
props.boardProvider?.canPinMcpApps,
props.boardProvider?.snapshot$.value.revision,
props.fullMessageAgentId,
Boolean(props.loadFullAssistantMessage),
showReasoning,
props.showToolCalls,
Boolean(props.runActive),
Boolean(props.runWorking),
props.startupStatus?.phase,
Boolean(props.waitingApproval),
props.planStatus,
props.questionPrompts,
Boolean(props.autoExpandToolCalls),
props.assistantName,
assistantIdentity.avatar,
props.userId,
props.userName,
props.userAvatar,
props.basePath,
(props.localMediaPreviewRoots ?? []).join("\u0000"),
props.assistantAttachmentAuthToken,
props.canvasPluginSurfaceUrl,
props.embedSandboxMode ?? "scripts",
props.allowExternalEmbedUrls ?? false,
threadContextWindow,
Boolean(props.onSetReply),
props.replyMessageAccess?.revision ?? 0,
props.replyMessageAccess?.navigationId ?? "",
turnRecap === null ? "" : `${turnRecap.runtimeMs}:${turnRecap.outputTokens ?? ""}`,
]);
state.transcriptRenderContext.onSetReply = props.onSetReply;
state.transcriptRenderContext.onOpenReply = (replyToId) => {
if (loadedReplySources.has(replyToId)) {
transcript.revealMessage(replyToId);
return;
}
if (searchFiltering) {
closeTranscriptSearch(state, requestUpdate);
}
props.replyMessageAccess?.open(replyToId);
};
return {
isDirectThread,
isEmpty,
showLoadingSkeleton,
searchOpen: state.searchOpen,
renderRows: (overlay: unknown = nothing) =>
transcript.render(
transcriptRows,
(row) => (row.kind === "item" ? renderItem(row.item) : row.content),
latestTranscriptAnnouncement(collapsedItems),
props.announceTranscript !== false && !state.searchOpen && !props.loading,
overlay,
),
};
}
@@ -0,0 +1,271 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestTranscript } from "../chat-view.test-helpers.ts";
import { renderTranscriptSearch, toggleTranscriptSearch } from "./chat-thread-interactions.ts";
import { renderChatThread } from "./chat-thread.ts";
import {
flushDeferredRowPrune,
installTranscriptDomMocks,
resetTranscriptTestDom,
threadProps,
} from "./chat-transcript.test-support.ts";
describe("chat transcript rendering", () => {
beforeEach(installTranscriptDomMocks);
afterEach(resetTranscriptTestDom);
it("resolves persisted replies to their source and highlights it on click", async () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const props = threadProps("pane-reply-preview", "agent:main:main", [
{
role: "assistant",
content: "The original answer",
__openclaw: { id: "source-message" },
timestamp: 1_000,
},
{
role: "user",
content: "Follow up",
__openclaw: { id: "reply-message", replyToId: "source-message" },
timestamp: 2_000,
},
]);
render(renderChatThread(props, transcript), container);
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const preview = container.querySelector<HTMLButtonElement>(".chat-reply-preview--message");
expect(preview?.textContent).toContain("Replying to Molty");
expect(preview?.textContent).toContain("The original answer");
expect(preview?.textContent).not.toContain("source-message");
preview?.click();
await Promise.resolve();
const sourceBubble = [...container.querySelectorAll<HTMLElement>(".chat-bubble")].find(
(bubble) => bubble.dataset.entryId === "source-message",
);
expect(sourceBubble?.classList.contains("chat-bubble--reply-target")).toBe(true);
transcript.hostDisconnected();
});
it("hydrates an unloaded reply preview without inserting its source row", async () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
let resolvedMessage: unknown = undefined;
const request = vi.fn();
const open = vi.fn();
const props = {
...threadProps("pane-reply-hydration", "agent:main:main", [
{
role: "user",
content: "Follow up",
__openclaw: { id: "reply-message", replyToId: "source-message" },
timestamp: 2_000,
},
]),
replyMessageAccess: {
revision: 0,
navigationId: null,
read: () => resolvedMessage,
request,
open,
},
};
const rerender = () => {
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
};
rerender();
transcript.hostConnected();
await flushDeferredRowPrune();
expect(request).toHaveBeenCalledWith("source-message");
expect(container.querySelector("[data-entry-id='source-message']")).toBeNull();
resolvedMessage = {
role: "assistant",
content: "The original answer",
__openclaw: { id: "source-message" },
timestamp: 1_000,
};
props.replyMessageAccess.revision += 1;
rerender();
const preview = container.querySelector<HTMLButtonElement>(".chat-reply-preview--message");
expect(preview?.textContent).toContain("Replying to Molty");
expect(preview?.textContent).toContain("The original answer");
preview?.click();
expect(open).toHaveBeenCalledWith("source-message");
transcript.hostDisconnected();
});
it("clears search before navigating to a filtered reply target", async () => {
const transcript = createTestTranscript();
const searchContainer = document.body.appendChild(document.createElement("div"));
const threadContainer = document.body.appendChild(document.createElement("div"));
const open = vi.fn();
const paneId = "pane-filtered-reply-navigation";
const props = {
...threadProps(paneId, "agent:main:main", [
{
role: "assistant",
content: "The original answer",
__openclaw: { id: "source-message" },
timestamp: 1_000,
},
{
role: "user",
content: "Follow up",
__openclaw: {
id: "reply-message",
replyToId: "source-message",
replyToPreview: { text: "The original answer", senderLabel: "Molty" },
},
timestamp: 2_000,
},
]),
replyMessageAccess: {
revision: 0,
navigationId: null,
read: () => undefined,
request: vi.fn(),
open,
},
};
const rerender = () => {
render(renderTranscriptSearch(paneId, rerender), searchContainer);
render(
renderChatThread({ ...props, onRequestUpdate: rerender }, transcript),
threadContainer,
);
transcript.hostUpdated();
};
toggleTranscriptSearch(paneId, rerender);
rerender();
transcript.hostConnected();
const input = searchContainer.querySelector<HTMLInputElement>("input");
expect(input).not.toBeNull();
input!.value = "Follow up";
input!.dispatchEvent(new Event("input", { bubbles: true }));
await flushDeferredRowPrune();
expect(threadContainer.querySelector("[data-entry-id='source-message']")).toBeNull();
const preview = threadContainer.querySelector<HTMLButtonElement>(
".chat-reply-preview--message",
);
expect(preview).not.toBeNull();
preview!.click();
expect(open).toHaveBeenCalledWith("source-message");
expect(searchContainer.querySelector("input")).toBeNull();
transcript.hostDisconnected();
});
it("loads a truncated assistant message once and keeps the full text visible", async () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const loadFullAssistantMessage = vi.fn().mockResolvedValue({
ok: true,
message: { role: "assistant", content: "Complete assistant content." },
});
function rerender() {
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
}
const props = {
...threadProps("pane-assistant-expand", "agent:work:main", [
{
role: "assistant",
content: "Preview\n...(truncated)...",
__openclaw: { id: "assistant-full-1" },
timestamp: 1_000,
},
]),
fullMessageAgentId: "work",
loadFullAssistantMessage,
onRequestUpdate: rerender,
};
rerender();
transcript.hostConnected();
transcript.hostUpdated();
await vi.waitFor(() => expect(container.textContent).toContain("Complete assistant content."));
expect(loadFullAssistantMessage).toHaveBeenCalledOnce();
expect(loadFullAssistantMessage).toHaveBeenCalledWith({
sessionKey: "agent:work:main",
agentId: "work",
messageId: "assistant-full-1",
kind: "assistant_message",
});
expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull();
expect(container.textContent).toContain("Complete assistant content.");
expect(loadFullAssistantMessage).toHaveBeenCalledOnce();
transcript.hostDisconnected();
});
it("keeps transport-cut assistant text as received when full content is unavailable", async () => {
const transcript = createTestTranscript();
const container = document.body.appendChild(document.createElement("div"));
const loadFullAssistantMessage = vi.fn().mockRejectedValue(new Error("offline"));
function rerender() {
render(renderChatThread(props, transcript), container);
transcript.hostUpdated();
}
const props = {
...threadProps("pane-assistant-retry", "agent:main:main", [
{
role: "assistant",
content: "Preview\n...(truncated)...",
__openclaw: { id: "assistant-retry-1" },
timestamp: 1_000,
},
]),
loadFullAssistantMessage,
onRequestUpdate: rerender,
};
rerender();
transcript.hostConnected();
transcript.hostUpdated();
await vi.waitFor(() => expect(loadFullAssistantMessage).toHaveBeenCalledOnce());
expect(container.textContent).toContain("Preview");
expect(container.textContent).toContain("...(truncated)...");
expect(container.querySelector(".chat-message-disclosure__toggle")).toBeNull();
transcript.hostDisconnected();
});
it.each(["Enter", " "])("opens focused transcript file links with %j", async (key) => {
const transcript = createTestTranscript();
const onOpenWorkspaceFile = vi.fn();
const onHistoryIntent = vi.fn();
const container = document.body.appendChild(document.createElement("div"));
const props = {
...threadProps("pane-file-link", "agent:main:main", [
{ role: "assistant", content: "Inspect `src/chat.ts:17`", timestamp: 1_000 },
]),
onOpenWorkspaceFile,
onHistoryIntent,
};
render(renderChatThread(props, transcript), container);
transcript.hostConnected();
transcript.hostUpdated();
await flushDeferredRowPrune();
const link = container.querySelector<HTMLAnchorElement>("a.markdown-file-link");
link?.focus();
expect(document.activeElement).toBe(link);
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
link?.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(onOpenWorkspaceFile).toHaveBeenCalledWith({ path: "src/chat.ts", line: 17 });
expect(onHistoryIntent).not.toHaveBeenCalled();
transcript.hostDisconnected();
});
});
@@ -0,0 +1,121 @@
import { vi } from "vitest";
import { resetChatThreadState } from "../chat-thread.ts";
import { resetThreadPresentation } from "./chat-thread-interactions.ts";
export const observedElements = new Set<Element>();
export const resizeObservers = new Set<RecordingResizeObserver>();
export const transcriptDomState = { measuredRowHeight: 100 };
class RecordingResizeObserver implements ResizeObserver {
private readonly targets = new Set<Element>();
constructor(private readonly callback: ResizeObserverCallback) {
resizeObservers.add(this);
}
observe(target: Element): void {
this.targets.add(target);
observedElements.add(target);
}
unobserve(target: Element): void {
this.targets.delete(target);
observedElements.delete(target);
}
disconnect(): void {
for (const target of this.targets) {
observedElements.delete(target);
}
this.targets.clear();
resizeObservers.delete(this);
}
emit(width: number, height: number): void {
const entries = [...this.targets].map(
(target) =>
({
target,
borderBoxSize: [{ inlineSize: width, blockSize: height }],
}) as unknown as ResizeObserverEntry,
);
if (entries.length > 0) {
this.callback(entries, this);
}
}
observes(target: Element): boolean {
return this.targets.has(target);
}
}
const defaultMessages = [
{ role: "user", content: "message one", timestamp: 1_000 },
{ role: "assistant", content: "reply one", timestamp: 2_000 },
{ role: "user", content: "message two", timestamp: 3_000 },
{ role: "assistant", content: "reply two", timestamp: 4_000 },
];
export function threadProps(
paneId: string,
sessionKey = "agent:main:main",
messages: unknown[] = defaultMessages,
) {
return {
paneId,
sessionKey,
loading: false,
messages,
toolMessages: [],
streamSegments: [],
stream: null,
streamStartedAt: null,
queue: [],
showThinking: false,
showToolCalls: false,
sessions: null,
assistantName: "Molty",
assistantAvatar: null,
onDraftChange: () => {},
onSend: () => {},
};
}
export function transcriptRows(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(".chat-virtual-row")];
}
export async function flushDeferredRowPrune(): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
export function installTranscriptDomMocks(): void {
observedElements.clear();
resizeObservers.clear();
transcriptDomState.measuredRowHeight = 100;
vi.stubGlobal("ResizeObserver", RecordingResizeObserver);
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(
() => transcriptDomState.measuredRowHeight,
);
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
toJSON: () => ({}),
} as DOMRect);
}
export function resetTranscriptTestDom(): void {
vi.restoreAllMocks();
vi.unstubAllGlobals();
resetThreadPresentation();
resetChatThreadState();
document.body.replaceChildren();
}
-46
View File
@@ -1,46 +0,0 @@
import { getSafeLocalStorage } from "../../local-storage.ts";
export class PersistedSet<T> {
protected values = new Set<T>();
constructor(
private readonly key: string,
isValue: (value: unknown) => value is T,
) {
try {
const parsed: unknown = JSON.parse(getSafeLocalStorage()?.getItem(key) ?? "");
if (Array.isArray(parsed)) {
this.values = new Set(parsed.filter(isValue));
}
} catch {
// Storage is optional and corrupt entries are ignored.
}
}
has(value: T): boolean {
return this.values.has(value);
}
protected add(value: T): void {
this.values.add(value);
this.save();
}
protected remove(value: T): void {
this.values.delete(value);
this.save();
}
clear(): void {
this.values.clear();
this.save();
}
private save(): void {
try {
getSafeLocalStorage()?.setItem(this.key, JSON.stringify([...this.values]));
} catch {
// Storage is optional.
}
}
}
-30
View File
@@ -1,30 +0,0 @@
// Control UI chat module implements pinned messages behavior.
import { PersistedSet } from "./persisted-set.ts";
const PREFIX = "openclaw:pinned:";
export class PinnedMessages extends PersistedSet<number> {
constructor(sessionKey: string) {
super(PREFIX + sessionKey, (value): value is number => typeof value === "number");
}
get indices(): Set<number> {
return this.values;
}
pin(index: number): void {
this.add(index);
}
unpin(index: number): void {
this.remove(index);
}
toggle(index: number): void {
if (this.has(index)) {
this.unpin(index);
} else {
this.pin(index);
}
}
}