diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 27753e029aa0..ebe7313541c6 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/test/vitest/vitest.ui-isolated-paths.mjs b/test/vitest/vitest.ui-isolated-paths.mjs index 045f4e160d18..881ed3adc149 100644 --- a/test/vitest/vitest.ui-isolated-paths.mjs +++ b/test/vitest/vitest.ui-isolated-paths.mjs @@ -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", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index fd6fc1e386ce..01268476e9ea 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.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", diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 67517f87b787..46b85af406d1 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -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"; diff --git a/ui/src/pages/chat/chat-pane-lifecycle.test.ts b/ui/src/pages/chat/chat-pane-lifecycle.test.ts index 2e9aec9414a5..baf9325e494c 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.test.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.test.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(); }); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index edb5269253f1..c5ccb817002d 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -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); diff --git a/ui/src/pages/chat/chat-pane-retained-presentation.ts b/ui/src/pages/chat/chat-pane-retained-presentation.ts index 097139100e74..4be8b39a658a 100644 --- a/ui/src/pages/chat/chat-pane-retained-presentation.ts +++ b/ui/src/pages/chat/chat-pane-retained-presentation.ts @@ -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); diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 2ea70f14a6bb..07aabeb88a99 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -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> | undefined; - - try { - environment = await builtinEnvironments.jsdom.setup(globalThis, { - jsdom: { url: "http://localhost/", pretendToBeVisual: true }, - }); - const [{ render }, { ChatTranscriptController, resetChatThreadPresentationState }] = - await Promise.all([import("lit"), import("./components/chat-thread.ts")]); - const host = { - addController() {}, - removeController() {}, - requestUpdate() {}, - updateComplete: Promise.resolve(true), - }; - const sessionKey = "retained-session"; - const props = { - paneId: "retained-pane", - sessionKey, - loading: false, - messages: [ - { role: "user", content: "long user message ".repeat(100), timestamp: 1 }, - { - role: "assistant", - content: [ - { type: "text", text: "assistant reply" }, - { type: "toolcall", id: "retained-call", name: "browser.open" }, - ], - timestamp: 2, - }, - ], - toolMessages: [], - streamSegments: [], - stream: null, - streamStartedAt: null, - queue: [], - showThinking: false, - showToolCalls: true, - sessions: null, - assistantName: "Molty", - assistantAvatar: null, - onDraftChange() {}, - onSend() {}, - }; - const controller = new ChatTranscriptController(host); - const retainedPane = document.createElement("div"); - document.body.append(retainedPane); - render(controller.render(props), retainedPane); - const staleTools = getExpandedToolCards(sessionKey); - const staleUsers = getExpandedUserMessages(sessionKey); - const previousToolVersion = getExpansionStateVersion(staleTools); - const previousUserVersion = getExpansionStateVersion(staleUsers); - - for (let index = 0; index < 20; index += 1) { - const alternatePane = document.createElement("div"); - document.body.append(alternatePane); - render( - new ChatTranscriptController(host).render({ - ...props, - paneId: `alternate-pane-${index}`, - sessionKey: `alternate-session-${index}`, - }), - alternatePane, - ); - } - - render(controller.render(props), retainedPane); - const currentTools = getExpandedToolCards(sessionKey); - const currentUsers = getExpandedUserMessages(sessionKey); - expect(currentTools).not.toBe(staleTools); - expect(currentUsers).not.toBe(staleUsers); - expect(getExpansionStateVersion(currentTools)).toBe(previousToolVersion); - expect(getExpansionStateVersion(currentUsers)).toBe(previousUserVersion); - const toolCardId = expectDefined(currentTools.keys().next().value, "retained tool card"); - expectDefined( - retainedPane.querySelector( - ".chat-group.user .chat-message-disclosure__toggle", - ), - "mounted user disclosure", - ).click(); - expectDefined( - retainedPane.querySelector(".chat-tool-msg-summary"), - "mounted tool disclosure", - ).click(); - - expect(currentTools.get(toolCardId)).toBe(true); - expect(staleTools.get(toolCardId)).toBe(false); - expect(currentUsers.size).toBe(1); - expect(staleUsers.size).toBe(0); - - const toolVisibilitySession = "tool-visibility-session"; - const toolVisibilityProps = { - ...props, - paneId: "tool-visibility-pane", - sessionKey: toolVisibilitySession, - messages: [ - { role: "user", content: "tool visibility prompt", timestamp: 1 }, - { - role: "toolResult", - toolCallId: "expanded-tool", - toolName: "browser.open", - content: "Expanded tool result", - timestamp: 2, - }, - { role: "assistant", content: "The first tool completed.", timestamp: 3 }, - { role: "user", content: "Show the next tool result.", timestamp: 4 }, - { - role: "toolResult", - toolCallId: "collapsed-tool", - toolName: "browser.open", - content: "Collapsed tool result", - timestamp: 5, - }, - ], - }; - const toolVisibilityController = new ChatTranscriptController(host); - const toolVisibilityPane = document.createElement("div"); - document.body.append(toolVisibilityPane); - render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); - const visibilityState = getExpandedToolCards(toolVisibilitySession); - const visibilityIds = [...visibilityState.keys()].filter((key) => key.startsWith("toolmsg:")); - const expandedToolId = expectDefined(visibilityIds[0], "expanded standalone tool disclosure"); - const collapsedToolId = expectDefined( - visibilityIds[1], - "collapsed standalone tool disclosure", - ); - const disclosureButtons = () => - Array.from( - toolVisibilityPane.querySelectorAll(".chat-tool-msg-summary"), - ).filter((button) => !button.closest(".chat-tool-msg-body")); - expect(disclosureButtons()).toHaveLength(2); - expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([ - "false", - "false", - ]); - expectDefined(disclosureButtons()[0], "first mounted tool disclosure").click(); - render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); - expectDefined(disclosureButtons()[1], "second mounted tool disclosure").click(); - render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); - expectDefined(disclosureButtons()[1], "second mounted tool disclosure").click(); - render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); - expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([ - "true", - "false", - ]); - - render( - toolVisibilityController.render({ ...toolVisibilityProps, showToolCalls: false }), - toolVisibilityPane, - ); - expect(disclosureButtons()).toHaveLength(0); - render(toolVisibilityController.render(toolVisibilityProps), toolVisibilityPane); - - expect(disclosureButtons()).toHaveLength(2); - expect(disclosureButtons().map((button) => button.getAttribute("aria-expanded"))).toEqual([ - "true", - "false", - ]); - expect(visibilityState.get(expandedToolId)).toBe(true); - expect(visibilityState.get(collapsedToolId)).toBe(false); - render( - toolVisibilityController.render({ - ...toolVisibilityProps, - messages: toolVisibilityProps.messages.filter( - (message) => !("toolCallId" in message && message.toolCallId === "expanded-tool"), - ), - }), - toolVisibilityPane, - ); - expect(visibilityState.has(expandedToolId)).toBe(false); - expect(visibilityState.get(collapsedToolId)).toBe(false); - resetChatThreadPresentationState(); - } finally { - try { - if (environment) { - try { - document.body.replaceChildren(); - await new Promise((resolve) => { - window.setTimeout(resolve, 0); - }); - } finally { - await environment.teardown(globalThis); - } - } - } finally { - // Vitest assigns these compatibility globals after its own restore snapshot. - for (const [name, descriptor] of originalFixtureGlobals) { - if (descriptor) { - Object.defineProperty(globalThis, name, descriptor); - } else { - Reflect.deleteProperty(globalThis, name); - } - } - resetChatThreadState(); - } - } - - for (const [name, descriptor] of originalFixtureGlobals) { - expect(Object.getOwnPropertyDescriptor(globalThis, name)).toEqual(descriptor); - } - expect(Object.getOwnPropertyDescriptor(globalThis, "document")).toEqual(originalDocument); - expect(Object.getOwnPropertyDescriptor(globalThis, "window")).toEqual(originalWindow); - }); }); describe("user message expansion state", () => { diff --git a/ui/src/pages/chat/chat-view-state.ts b/ui/src/pages/chat/chat-view-state.ts index 5bc383d39c6a..d3143c890046 100644 --- a/ui/src/pages/chat/chat-view-state.ts +++ b/ui/src/pages/chat/chat-view-state.ts @@ -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); } diff --git a/ui/src/pages/chat/chat-view.test-helpers.ts b/ui/src/pages/chat/chat-view.test-helpers.ts index 72a095f02164..24d9f776c997 100644 --- a/ui/src/pages/chat/chat-view.test-helpers.ts +++ b/ui/src/pages/chat/chat-view.test-helpers.ts @@ -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({ diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 27934d1638b1..6b77c2c95d7a 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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(); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 257e5acfd9b3..534e8f89da43 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -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; onForkMessage?: (entryId: string) => Promise | 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)}
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; + 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; + onForkMessage?: (entryId: string) => Promise | 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(); + +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` + + `; +} + +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( + ".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(".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(".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("[data-message-actions-for]")].find( + (element) => element.dataset.messageActionsFor === messageId, + ); + const copyButton = actionOwner?.querySelector(".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); + }); +} diff --git a/ui/src/pages/chat/components/chat-thread.measure.test.ts b/ui/src/pages/chat/components/chat-thread.measure.test.ts deleted file mode 100644 index ccf2d51c3e31..000000000000 --- a/ui/src/pages/chat/components/chat-thread.measure.test.ts +++ /dev/null @@ -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(); -const resizeObservers = new Set(); -let measuredRowHeight = 100; - -class RecordingResizeObserver implements ResizeObserver { - private readonly targets = new Set(); - 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(".chat-virtual-row")]; -} - -async function flushDeferredRowPrune(): Promise { - 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(".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(".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(".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("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( - ".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("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(".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(".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((_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[0]["client"]; - const sessions = {} as Parameters[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( - "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( - "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(".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((_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[0]["client"]; - const sessions = {} as Parameters[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); - }); -}); diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index 6ffa96ffff3c..70fec717c2fe 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -1,1395 +1,22 @@ -// Chat-owned message thread presentation and thread-local interaction state. -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -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 { guard } from "lit/directives/guard.js"; -import { ref } from "lit/directives/ref.js"; -import { repeat } from "lit/directives/repeat.js"; -import { styleMap } from "lit/directives/style-map.js"; -import { classifySessionKind } from "../../../../../src/sessions/classify-session-kind.js"; -import type { SessionsListResult } from "../../../api/types.ts"; -import type { QuestionPrompt } from "../../../app/question-prompt.ts"; -import { resolveLocalUserName } from "../../../app/user-identity.ts"; -import { copyMarkdownLabel } from "../../../components/copy-button.ts"; -import { icons } from "../../../components/icons.ts"; -import type { ImageLightboxItem } from "../../../components/image-lightbox.ts"; +// Public chat transcript renderer and DOM shell. +import { html, nothing, type TemplateResult } from "lit"; import { handleMarkdownCodeBlockCopy } from "../../../components/markdown-code-blocks.ts"; import { markdownFileLinkFromEvent, markdownFileLinkFromKeyboardEvent, } from "../../../components/markdown-file-links.ts"; -import "../../../components/tooltip.ts"; -import { McpAppUnmountGate } from "../../../components/mcp-app-unmount.ts"; -import { i18n, t } from "../../../i18n/index.ts"; -import type { BoardProvider } from "../../../lib/board/provider.ts"; -import type { - ChatQueueItem, - ChatStreamSegment, - MessageGroup, -} from "../../../lib/chat/chat-types.ts"; +import { t } from "../../../i18n/index.ts"; import { - buildCompanionQuestionPrefill, - buildMoreDetailsCompanionQuestion, -} from "../../../lib/chat/companion-question.ts"; -import { extractTextCached } from "../../../lib/chat/message-extract.ts"; -import { normalizeMessage } from "../../../lib/chat/message-normalizer.ts"; -import type { EmbedSandboxMode } from "../../../lib/chat/tool-display.ts"; -import { copyToClipboard } from "../../../lib/clipboard.ts"; -import { fnv1aUtf16 } from "../../../lib/fnv1a.ts"; + handleTranscriptContextMenu, + handleTranscriptSelection, + type ChatThreadProps, +} from "./chat-thread-interactions.ts"; import { - areUiSessionKeysEquivalent, - isUiGlobalScopeConfigured, - parseAgentSessionKey, - resolveUiGlobalAliasAgentId, - type UiSessionDefaultsHost, -} from "../../../lib/sessions/session-key.ts"; -import { resolveTurnRecap, type TurnRecap } from "../chat-progress.ts"; -import type { ChatRunStartupStatus } from "../chat-run-startup.ts"; -import { - assistantGroupCanOwnActiveRunStatus, - assistantMessageExpansionSignature, - buildCachedChatItems, - coalesceActivityRuns, - coalesceStreamRuns, - collapseCompletedTurnWork, - getExpansionStateVersion, - getExpandedToolCards, - getExpandedAssistantMessages, - getExpandedUserMessages, - persistedMessageEntryId, - resetChatThreadState, - setExpansionState, - syncToolCardExpansionState, -} from "../chat-thread.ts"; -import { PinnedMessages } from "../pinned-messages.ts"; -import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts"; -import { - CHAT_TRANSCRIPT_END_THRESHOLD_PX, - getChatSessionScrollPosition, - saveChatSessionScrollPosition, - type ChatSessionScrollPosition, -} from "../scroll.ts"; -import { getOrCreateSessionCacheValue } from "../session-cache.ts"; -import type { PlanStatus } from "../tool-stream.ts"; -import { getToolTitlesVersion } from "../tool-titles.ts"; -import { renderBackgroundTasksStatusRow } from "./chat-background-tasks-status.ts"; -import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; -import { renderChatDivider, renderChatNotice } from "./chat-divider.ts"; -import { resolveMessageGroupSenderLabel } from "./chat-message-group.ts"; -import { resolveMessageReplyText } from "./chat-message-markdown.ts"; -import type { ArtifactDownloadResolver } from "./chat-message-media.ts"; -import { - dismissConfirmedActionPopovers, - getChatMediaRenderVersion, - openChatRewindConfirmation, - renderMessageGroup, - renderActivityGroup, - renderStreamGroup, - renderWorkGroupSummary, - type MessageReplyTarget, - type StreamGroupOptions, - type StreamGroupPart, -} from "./chat-message.ts"; -import { renderRealtimeTalkConversation } from "./chat-realtime-controls.ts"; -import { handleChatSelectionPointerUp, removeChatSelectionPopup } from "./chat-selection-popup.ts"; -import type { SidebarContent, SidebarFullMessageLoader } from "./chat-sidebar.ts"; -import { renderWelcomeState, resolveAssistantDisplayAvatar } from "./chat-welcome.ts"; -import { renderTurnRecapRow } from "./chat-working-indicator.ts"; - -const pinnedMessagesMap = new Map(); - -type ChatThreadState = { - searchOpen: boolean; - searchQuery: string; - searchFocusPending: boolean; - searchReturnFocusTarget: HTMLElement | null; - searchReturnFocusOwner: HTMLElement | null; - pinnedExpanded: boolean; - transcriptRenderDependencies: readonly unknown[]; - transcriptRenderContext: { - onSetReply?: ChatThreadProps["onSetReply"]; - onOpenReply?: (replyToId: string) => void; - }; -}; - -export type ChatReplyMessageAccess = { - revision: number; - navigationId: string | null; - read: (messageId: string) => unknown; - request: (messageId: string) => void; - open: (messageId: string) => void; -}; - -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; - /** True while the session has an abortable live run (marks running tool rows). */ - runActive?: boolean; - /** True while the agent is visibly working (isChatRunWorking); shows the working spark. */ - runWorking?: boolean; - /** Coarse startup stage shown until assistant or tool activity becomes visible. */ - startupStatus?: ChatRunStartupStatus | null; - /** Re-labels the working spark while the active run is parked on an approval. */ - waitingApproval?: boolean; - planStatus?: PlanStatus | null; - questionPrompts?: readonly QuestionPrompt[]; - sessions: SessionsListResult | null; - /** Host context resolving global-alias session keys (scope=global fleets). */ - /** Includes assistantAgentId so bare-global welcome recents scope to the selected agent. */ - sessionHost?: UiSessionDefaultsHost | null; - gatewayUrl?: string; - 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; - 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?: ChatReplyMessageAccess; - onRewindMessage?: (entryId: string) => Promise | boolean; - onForkMessage?: (entryId: string) => Promise | void; - onFocusComposer?: () => void; - onCompanionQuestion?: (question: string) => void; - onCompanionPrefill?: (question: string) => void; - onOpenSession?: (sessionKey: string) => void; - modelSetupRequired?: boolean; - onModelSetup?: () => void; - /** Tasks-rail snapshot backing the post-turn running-tasks status row. */ - backgroundTasks?: BackgroundTasksProps; -}; - -type ChatPinnedMessagesProps = Pick< - ChatThreadProps, - "paneId" | "sessionKey" | "messages" | "userName" | "userAvatar" ->; - -type ChatRenderItem = ReturnType[number]; - -type ChatTranscriptRow = - | { kind: "item"; key: string; item: ChatRenderItem } - | { kind: "content"; key: string; content: unknown }; - -type ChatTranscriptAnnouncement = { - key: string; - text: string; -}; - -type LoadedReplySource = { - rowKey: string; - preview: MessageReplyTarget & { sourceMessageId: string }; -}; - -function projectResolvedReplyPreview( - message: unknown, - replyToId: string, - props: Pick, -): 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, - }; -} - -const CHAT_TRANSCRIPT_ESTIMATED_ROW_PX = 120; -const CHAT_TRANSCRIPT_OVERSCAN = 6; -const CHAT_TRANSCRIPT_ANNOUNCEMENT_MAX_CHARS = 500; -// 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 { - private readonly controllers = new Set(); - private readonly virtualizerController: VirtualizerController; - 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 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(".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(); - private messageRowKeysById = new Map(); - 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( - rows: readonly ChatTranscriptRow[], - renderRow: (row: ChatTranscriptRow) => unknown, - announcement: ChatTranscriptAnnouncement | 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` -
-
- ${overlay} - ${repeat( - virtualRows, - (virtualRow) => virtualRow.key, - (virtualRow) => { - const row = rows[virtualRow.index]; - if (!row) { - return nothing; - } - return html` -
- ${renderRow(row)} -
- `; - }, - )} -
-
- `; - return this.mcpAppUnmountGate.render(JSON.stringify([...nextRowKeys]), rendered, () => - this.threadInnerElement - ? [...this.threadInnerElement.querySelectorAll(".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): 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(".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(".chat-virtual-row[data-virtual-row-key]"); - if (!row || !this.scrollElement.contains(row)) { - return null; - } - return row.dataset.virtualRowKey || null; - } - - private syncAnnouncement( - announcement: ChatTranscriptAnnouncement | 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 ChatTranscriptRow[]): 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; - } - - render(props: ChatThreadProps): TemplateResult { - if ( - !this.sessionVirtualizer || - this.activeSessionKey === null || - !areUiSessionKeysEquivalent(this.activeSessionKey, props.sessionKey) - ) { - this.sessionVirtualizer?.dispose(); - const savedPosition = getChatSessionScrollPosition(props.paneId, props.sessionKey); - const initialOffset = savedPosition?.anchorToEnd ? null : (savedPosition?.scrollTop ?? null); - this.activeSessionKey = props.sessionKey; - this.sessionVirtualizer = new ChatSessionVirtualizerHost( - this.host, - initialOffset, - initialOffset === null - ? undefined - : (position) => { - saveChatSessionScrollPosition(props.paneId, props.sessionKey, position); - }, - ); - if (this.connected) { - this.sessionVirtualizer.connect(); - } - } - return renderChatThreadContents(props, 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(); - } -} - -function createChatThreadState(): ChatThreadState { - return { - searchOpen: false, - searchQuery: "", - searchFocusPending: false, - searchReturnFocusTarget: null, - searchReturnFocusOwner: null, - pinnedExpanded: false, - transcriptRenderDependencies: [], - transcriptRenderContext: {}, - }; -} - -const threadStates = new Map(); - -function getChatThreadState(paneId: string): ChatThreadState { - const existing = threadStates.get(paneId); - if (existing) { - return existing; - } - const state = createChatThreadState(); - threadStates.set(paneId, state); - return state; -} - -function getPinnedMessages(sessionKey: string): PinnedMessages { - return getOrCreateSessionCacheValue( - pinnedMessagesMap, - sessionKey, - () => new PinnedMessages(sessionKey), - ); -} - -function getPinnedMessageSummary(message: unknown): string { - return extractTextCached(message) ?? ""; -} - -function dismissChatThreadPortals(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 resetChatThreadSessionPresentationState(paneId: string, owner?: ParentNode): void { - dismissChatThreadPortals(paneId, owner); - const state = threadStates.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 resetChatThreadPresentationState(paneId?: string, owner?: ParentNode) { - dismissChatThreadPortals(paneId, owner); - if (paneId) { - threadStates.delete(paneId); - resetChatThreadState(paneId); - } else { - threadStates.clear(); - resetChatThreadState(); - } -} - -export function renderChatSearchBar( - paneId: string, - requestUpdate: () => void, -): TemplateResult | typeof nothing { - const state = getChatThreadState(paneId); - if (!state.searchOpen) { - return nothing; - } - return html` - - `; -} - -function closeChatThreadSearch(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( - ".agent-chat__composer-combobox > textarea", - ); - target?.focus({ preventScroll: true }); - }); -} - -/** Toggles transcript search and retains the shortcut origin for focus restoration. */ -export function toggleChatThreadSearch( - paneId: string, - requestUpdate: () => void, - triggerEvent?: Event, -): void { - const state = getChatThreadState(paneId); - if (state.searchOpen) { - closeChatThreadSearch(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(); -} - -export function renderChatPinnedMessages( - props: ChatPinnedMessagesProps, - requestUpdate: () => void, -): TemplateResult | typeof nothing { - const state = getChatThreadState(props.paneId); - const pinned = getPinnedMessages(props.sessionKey); - const userRoleLabel = resolveLocalUserName({ - name: props.userName ?? null, - avatar: props.userAvatar ?? null, - }); - const messages = Array.isArray(props.messages) ? props.messages : []; - const entries: Array<{ index: number; text: string; role: string }> = []; - for (const idx of pinned.indices) { - const msg = messages[idx] as Record | undefined; - if (!msg) { - continue; - } - const text = getPinnedMessageSummary(msg); - const role = typeof msg.role === "string" ? msg.role : "unknown"; - entries.push({ index: idx, text, role }); - } - if (entries.length === 0) { - return nothing; - } - return html` -
- - ${state.pinnedExpanded - ? html` -
- ${entries.map( - ({ index, text, role }) => html` -
- ${role === "user" ? userRoleLabel : t("common.assistant")} - ${truncateUtf16Safe(text, 100)}${text.length > 100 ? "..." : ""} - - - -
- `, - )} -
- ` - : nothing} -
- `; -} - -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(".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 }; -} - -function handleChatThreadSelectionPointerUp(event: PointerEvent, props: ChatThreadProps) { - 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; -} - -function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) { - 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(".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("[data-message-actions-for]")].find( - (element) => element.dataset.messageActionsFor === messageId, - ); - const copyButton = actionOwner?.querySelector(".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); - }); -} + type ChatTranscriptSession, + ChatTranscriptController, +} from "./chat-transcript-controller.ts"; +import { projectChatTranscript } from "./chat-transcript-projection.ts"; +import { renderWelcomeState } from "./chat-welcome.ts"; function renderLoadingSkeleton() { return html` @@ -1446,600 +73,42 @@ function renderHistorySentinel(loading: boolean) { `; } -function latestTranscriptAnnouncement( - items: readonly ChatRenderItem[], -): ChatTranscriptAnnouncement | 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 renderChatThread( props: ChatThreadProps, transcript: ChatTranscriptController, ): TemplateResult { - return transcript.render(props); + return transcript.renderSession(props.paneId, props.sessionKey, (session) => + renderTranscriptShell(props, session), + ); } -function renderChatThreadContents( +function renderTranscriptShell( props: ChatThreadProps, - transcript: ChatSessionVirtualizerHost, + transcript: ChatTranscriptSession, ): TemplateResult { - const state = getChatThreadState(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::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(); - const loadedReplySources = new Map(); - const resolvedReplyPreviews = new Map(); - 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[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: ChatTranscriptRow[] = 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.gatewayUrl, - 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) { - closeChatThreadSearch(state, requestUpdate); - } - props.replyMessageAccess?.open(replyToId); - }; + const projection = projectChatTranscript(props, transcript); const transcriptContents = - showLoadingSkeleton || isEmpty + projection.showLoadingSkeleton || projection.isEmpty ? html`
${props.historyPagination ? renderHistorySentinel(props.historyPagination.loading) : nothing} - ${showLoadingSkeleton ? renderLoadingSkeleton() : nothing} - ${isEmpty && !state.searchOpen ? renderWelcomeState(props) : nothing} - ${isEmpty && state.searchOpen + ${projection.showLoadingSkeleton ? renderLoadingSkeleton() : nothing} + ${projection.isEmpty && !projection.searchOpen ? renderWelcomeState(props) : nothing} + ${projection.isEmpty && projection.searchOpen ? html`
${t("chat.thread.noMatches")}
` : nothing}
` - : transcript.render( - transcriptRows, - (row) => (row.kind === "item" ? renderItem(row.item) : row.content), - latestTranscriptAnnouncement(collapsedItems), - props.announceTranscript !== false && !state.searchOpen && !props.loading, + : projection.renderRows( props.historyPagination ? renderHistorySentinel(props.historyPagination.loading) : nothing, ); return html`
handleChatContextMenu(event, props)} - @pointerup=${(event: PointerEvent) => handleChatThreadSelectionPointerUp(event, props)} + @contextmenu=${(event: MouseEvent) => handleTranscriptContextMenu(event, props)} + @pointerup=${(event: PointerEvent) => handleTranscriptSelection(event, props)} > `; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/ui/src/pages/chat/components/chat-transcript-controller.test.ts b/ui/src/pages/chat/components/chat-transcript-controller.test.ts new file mode 100644 index 000000000000..ee405b099f38 --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-controller.test.ts @@ -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(".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(".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(); + }, + ); +}); diff --git a/ui/src/pages/chat/components/chat-transcript-controller.ts b/ui/src/pages/chat/components/chat-transcript-controller.ts new file mode 100644 index 000000000000..6981503daf28 --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-controller.ts @@ -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 = + | { 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( + rows: readonly TranscriptRow[], + renderRow: (row: TranscriptRow) => unknown, + announcement: TranscriptAnnouncement | null, + announce: boolean, + overlay?: unknown, + ): TemplateResult; + syncMessageRows(messageRowKeysById: ReadonlyMap): 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(); + private readonly virtualizerController: VirtualizerController; + 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 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(".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(); + private messageRowKeysById = new Map(); + 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( + rows: readonly TranscriptRow[], + renderRow: (row: TranscriptRow) => 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` +
+
+ ${overlay} + ${repeat( + virtualRows, + (virtualRow) => virtualRow.key, + (virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) { + return nothing; + } + return html` +
+ ${renderRow(row)} +
+ `; + }, + )} +
+
+ `; + return this.mcpAppUnmountGate.render(JSON.stringify([...nextRowKeys]), rendered, () => + this.threadInnerElement + ? [...this.threadInnerElement.querySelectorAll(".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): 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(".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(".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(); + } +} diff --git a/ui/src/pages/chat/components/chat-transcript-invalidation.test.ts b/ui/src/pages/chat/components/chat-transcript-invalidation.test.ts new file mode 100644 index 000000000000..3cae65d308eb --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-invalidation.test.ts @@ -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((_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[0]["client"]; + const sessions = {} as Parameters[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( + "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( + "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(".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((_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[0]["client"]; + const sessions = {} as Parameters[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( + ".chat-group.user .chat-message-disclosure__toggle", + ), + "mounted user disclosure", + ).click(); + expectDefined( + retainedPane.querySelector(".chat-tool-msg-summary"), + "mounted tool disclosure", + ).click(); + + expect(currentTools.get(toolCardId)).toBe(true); + expect(staleTools.get(toolCardId)).toBe(false); + expect(currentUsers.size).toBe(1); + expect(staleUsers.size).toBe(0); + + const toolVisibilitySession = "tool-visibility-session"; + const toolVisibilityProps = { + ...props, + paneId: "tool-visibility-pane", + sessionKey: toolVisibilitySession, + messages: [ + { role: "user", content: "tool visibility prompt", timestamp: 1 }, + { + role: "toolResult", + toolCallId: "expanded-tool", + toolName: "browser.open", + content: "Expanded tool result", + timestamp: 2, + }, + { role: "assistant", content: "The first tool completed.", timestamp: 3 }, + { role: "user", content: "Show the next tool result.", timestamp: 4 }, + { + role: "toolResult", + toolCallId: "collapsed-tool", + toolName: "browser.open", + content: "Collapsed tool result", + timestamp: 5, + }, + ], + }; + const toolVisibilityController = 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(".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); + }); +}); diff --git a/ui/src/pages/chat/components/chat-transcript-projection.ts b/ui/src/pages/chat/components/chat-transcript-projection.ts new file mode 100644 index 000000000000..c02172584151 --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-projection.ts @@ -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[number]; +const CHAT_TRANSCRIPT_ANNOUNCEMENT_MAX_CHARS = 500; + +type LoadedReplySource = { + rowKey: string; + preview: MessageReplyTarget & { sourceMessageId: string }; +}; + +function projectResolvedReplyPreview( + message: unknown, + replyToId: string, + props: Pick, +): 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::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(); + const loadedReplySources = new Map(); + const resolvedReplyPreviews = new Map(); + 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[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[] = 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, + ), + }; +} diff --git a/ui/src/pages/chat/components/chat-transcript-render.test.ts b/ui/src/pages/chat/components/chat-transcript-render.test.ts new file mode 100644 index 000000000000..edf638f297af --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-render.test.ts @@ -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(".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(".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(".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("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( + ".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("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(); + }); +}); diff --git a/ui/src/pages/chat/components/chat-transcript.test-support.ts b/ui/src/pages/chat/components/chat-transcript.test-support.ts new file mode 100644 index 000000000000..64b6e43e50ff --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript.test-support.ts @@ -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(); +export const resizeObservers = new Set(); +export const transcriptDomState = { measuredRowHeight: 100 }; + +class RecordingResizeObserver implements ResizeObserver { + private readonly targets = new Set(); + + 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(".chat-virtual-row")]; +} + +export async function flushDeferredRowPrune(): Promise { + 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(); +} diff --git a/ui/src/pages/chat/persisted-set.ts b/ui/src/pages/chat/persisted-set.ts deleted file mode 100644 index f652ef37d1eb..000000000000 --- a/ui/src/pages/chat/persisted-set.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { getSafeLocalStorage } from "../../local-storage.ts"; - -export class PersistedSet { - protected values = new Set(); - - 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. - } - } -} diff --git a/ui/src/pages/chat/pinned-messages.ts b/ui/src/pages/chat/pinned-messages.ts deleted file mode 100644 index e16c3a299504..000000000000 --- a/ui/src/pages/chat/pinned-messages.ts +++ /dev/null @@ -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 { - constructor(sessionKey: string) { - super(PREFIX + sessionKey, (value): value is number => typeof value === "number"); - } - - get indices(): Set { - 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); - } - } -}