mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
b82ad646a5
commit
f6459a3255
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user