diff --git a/ui/src/pages/chat/chat-outbox-owner.ts b/ui/src/pages/chat/chat-outbox-owner.ts index f5f1814759e4..37ab64a0da70 100644 --- a/ui/src/pages/chat/chat-outbox-owner.ts +++ b/ui/src/pages/chat/chat-outbox-owner.ts @@ -158,7 +158,12 @@ class ChatOutboxGatewayOwner { return; } observeOutboxRecoveryOwner(host); - host.chatQueue = this.snapshot(host, resolveUiConversationIdentity(host, host.sessionKey)); + const queue = this.snapshot(host, resolveUiConversationIdentity(host, host.sessionKey)); + // Draft persistence also publishes outbox changes; an empty queue must not + // invalidate the transcript merely because the composer changed. + if (queue.length || host.chatQueue.length) { + host.chatQueue = queue; + } for (const item of host.chatQueue) { const key = item.attachmentPayload?.key; if ( diff --git a/ui/src/pages/chat/chat-pane-projection-scale.test.ts b/ui/src/pages/chat/chat-pane-projection-scale.test.ts new file mode 100644 index 000000000000..07e9fa6ae40d --- /dev/null +++ b/ui/src/pages/chat/chat-pane-projection-scale.test.ts @@ -0,0 +1,57 @@ +/* @vitest-environment jsdom */ + +import { expectDefined } from "@openclaw/normalization-core"; +import { afterEach, expect, it, vi } from "vitest"; +import { createStorageMock } from "../../test-helpers/storage.ts"; +import { chatOutboxOwner } from "./chat-outbox-owner.ts"; +import { createRefreshChatPane } from "./chat-pane-history.test-support.ts"; +import * as chatThreadBuild from "./chat-thread-build.ts"; +import { stubAnimationFrames } from "./chat-view.test-helpers.ts"; +import { renderChat } from "./chat-view.ts"; +import { resetTranscriptTestDom } from "./components/chat-transcript.test-support.ts"; +import { ChatComposerPersistence } from "./composer-persistence.ts"; + +afterEach(() => { + resetTranscriptTestDom(); + vi.useRealTimers(); +}); + +it("keeps a 3,000-message transcript cached when typing publishes an unchanged outbox", () => { + stubAnimationFrames(); + vi.useFakeTimers({ toNotFake: ["requestAnimationFrame", "cancelAnimationFrame"] }); + vi.stubGlobal("sessionStorage", createStorageMock()); + const build = vi.spyOn(chatThreadBuild, "buildChatItems"); + const { pane, state } = createRefreshChatPane(); + state.sessionKey = "agent:main:projection-draft"; + state.chatMessage = ""; + state.chatMessages = Array.from({ length: 3_000 }, (_, index) => ({ + role: index % 2 ? "assistant" : "user", + content: `Message ${index}`, + timestamp: index + 1, + __openclaw: { id: `message-${index}`, seq: index + 1, runId: `run-${index >> 1}` }, + })); + const unsubscribe = chatOutboxOwner(state).subscribe(state); + const persistence = new ChatComposerPersistence(() => state); + persistence.start(); + const project = () => { + pane.render(); + renderChat(expectDefined(pane.chatProps, "pane transcript props")); + }; + try { + project(); + expect(build).toHaveBeenCalled(); + build.mockClear(); + for (const draft of ["a", "ab", ""]) { + state.handleChatDraftChange(draft); + persistence.persistChangedState(); + project(); + expect(state.chatMessage).toBe(draft); + expect(state.chatQueue).toEqual([]); + expect(build).not.toHaveBeenCalled(); + } + } finally { + persistence.stop(); + unsubscribe(); + pane.chatProps?.transcript.hostDisconnected(); + } +}); diff --git a/ui/src/pages/chat/chat-thread-input-order.test.ts b/ui/src/pages/chat/chat-thread-input-order.test.ts index 29468a9afa84..26fe01545f3e 100644 --- a/ui/src/pages/chat/chat-thread-input-order.test.ts +++ b/ui/src/pages/chat/chat-thread-input-order.test.ts @@ -42,7 +42,7 @@ function acceptedInput( function visibleRows( overrides: Partial, - build = buildChatItems, + build: (input: BuildChatItemsProps) => ReturnType = buildChatItems, ): Array { return build({ paneId: "input-order", @@ -100,6 +100,11 @@ describe("transcript input order", () => { expect( visibleRows({ messages: [canonical[1]], queue: [first] }, buildCachedChatItems), ).toEqual(["First input", "Second input"]); + expect( + visibleRows({ messages: [canonical[1]], queue: [first] }, (input) => + buildCachedChatItems(input, "unfiltered"), + ), + ).toEqual(["First input", "Second input"]); expect(visibleRows({ messages: canonical, queue: [] }, buildCachedChatItems)).toEqual([ "First input", "Second input", diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index 87d047267f85..881a09201f75 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -30,8 +30,8 @@ export { export { coalesceAgentRunFrames } from "./chat-agent-run-grouping.ts"; type CachedChatItems = { + generation: number; input: BuildChatItemsProps | null; - inputOrder: ChatInputOrderState; items: ReturnType; liveStream: { index: number; @@ -54,7 +54,15 @@ type ToolCardExpansionState = { }; }; -const chatItemsByPane = new Map>(); +type ChatItemsProjection = "visible" | "unfiltered"; +type CachedChatProjections = Partial> & { + inputOrder: ChatInputOrderState; + initialTurnId: BuildChatItemsProps["initialTurnId"]; +}; +// Search and gallery inputs must not evict each other. Both projections share +// the pane/session's input order, bounded lifetime, and canonical builder. +const chatItemsByPane = new Map>(); +const chatItemsGenerations = new WeakMap(); const toolCardStateBySession = new Map(); const expandedUserMessagesBySession = new Map>(); const expansionMapVersions = new WeakMap, number>(); @@ -342,20 +350,43 @@ export function findLiveStreamIndex(items: readonly RenderChatItem[]): number { return items.findIndex((item) => item.kind === "stream" && item.isStreaming); } +export function getChatItemsGeneration(items: readonly RenderChatItem[]): number { + return chatItemsGenerations.get(items) ?? 0; +} + export function buildCachedChatItems( input: BuildChatItemsProps, + projection: ChatItemsProjection = "visible", ): ReturnType { let paneCache = chatItemsByPane.get(input.paneId); if (!paneCache) { paneCache = new Map(); chatItemsByPane.set(input.paneId, paneCache); } - const cached = getOrCreateSessionCacheValue(paneCache, input.sessionKey, () => ({ + const projections = getOrCreateSessionCacheValue( + paneCache, + input.sessionKey, + (): CachedChatProjections => ({ + inputOrder: { keys: [] }, + initialTurnId: input.initialTurnId, + }), + ); + if (projections.initialTurnId !== input.initialTurnId) { + projections.initialTurnId = input.initialTurnId; + projections.inputOrder.keys = []; + // Retiring the initial input resets both views of that ordering ledger. + for (const cached of [projections.visible, projections.unfiltered]) { + if (cached) { + cached.input = null; + } + } + } + const cached = (projections[projection] ??= { + generation: 0, input: null, - inputOrder: { keys: [] }, items: [], liveStream: null, - })); + }); // Keep stream-only updates off the loaded-history path; structural changes // still use the full builder. if (cached.input && sameChatItemsStructuralInput(cached.input, input)) { @@ -367,10 +398,10 @@ export function buildCachedChatItems( return cached.items; } } - if (cached.input?.initialTurnId !== input.initialTurnId) { - cached.inputOrder.keys = []; - } - const items = stabilizeChatItems(cached.items, buildChatItems(input, cached.inputOrder)); + const items = stabilizeChatItems(cached.items, buildChatItems(input, projections.inputOrder)); + // A rebuild can retain every row and the array. Consumers still need to + // observe the structural pass; text-only slot replacements do not advance it. + chatItemsGenerations.set(items, ++cached.generation); cached.input = input; cached.items = items; const liveStreamIndex = findLiveStreamIndex(items); diff --git a/ui/src/pages/chat/components/chat-transcript-activity.ts b/ui/src/pages/chat/components/chat-transcript-activity.ts new file mode 100644 index 000000000000..f0130e93c753 --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-activity.ts @@ -0,0 +1,45 @@ +import type { ChatItem } from "../../../lib/chat/chat-types.ts"; +import { readPreparedActivity } from "../../../lib/chat/tool-call-grouping.ts"; +import { transcriptRunId } from "../chat-thread-run-identity.ts"; +import { getChatItemsGeneration, type buildCachedChatItems } from "../chat-thread.ts"; +import type { ChatThreadProps } from "./chat-thread-interactions.ts"; +import { createTranscriptMemo } from "./chat-transcript-memo.ts"; + +const workingIndicators = createTranscriptMemo< + Extract | undefined +>(); +const activityGroups = createTranscriptMemo(); + +export function projectTranscriptActivity( + chatItems: ReturnType, + props: Pick, +) { + const generation = getChatItemsGeneration(chatItems); + const workingIndicator = workingIndicators(chatItems, [generation], () => + chatItems.find((item) => item.kind === "reading-indicator"), + ); + const activityRunId = workingIndicator?.runId ?? props.runId; + const activityGroupKey = activityGroups( + chatItems, + [generation, activityRunId, props.runActive], + () => + props.runActive && activityRunId + ? chatItems.findLast( + (item) => + item.kind === "group" && + item.messages.some( + ({ message }) => + transcriptRunId(message) === activityRunId && + readPreparedActivity(message).some( + (activity) => + !activity.hideFromChannelProgress && !activity.suppressChannelProgress, + ), + ), + )?.key + : undefined, + ); + const runOutputTokens = workingIndicator?.runId + ? (props.runUsageById?.get(workingIndicator.runId)?.outputTokens ?? null) + : null; + return { workingIndicator, activityRunId, activityGroupKey, runOutputTokens }; +} diff --git a/ui/src/pages/chat/components/chat-transcript-identity.ts b/ui/src/pages/chat/components/chat-transcript-identity.ts index 9ece21cae4b8..80a4f421e560 100644 --- a/ui/src/pages/chat/components/chat-transcript-identity.ts +++ b/ui/src/pages/chat/components/chat-transcript-identity.ts @@ -12,48 +12,70 @@ import { parseAgentSessionKey, resolveUiGlobalAliasAgentId, } from "../../../lib/sessions/session-key.ts"; -import type { buildCachedChatItems } from "../chat-thread.ts"; +import { getChatItemsGeneration, type buildCachedChatItems } from "../chat-thread.ts"; import { hasForwardedSource } from "../chat-turn-boundary.ts"; import { resolveChatDefaultAvatarPlacement } from "./chat-author-avatar.ts"; import type { ChatThreadProps } from "./chat-thread-interactions.ts"; +import { createTranscriptMemo } from "./chat-transcript-memo.ts"; + +const participants = createTranscriptMemo<{ + showOwnSenderName: boolean; + sessionPeople: Set; +}>(); +const forwardedGroups = createTranscriptMemo(); export function resolveTranscriptParticipants( props: Pick, ) { const activeSession = props.selectedSession; - // Use unfiltered history and retained participants so searching or paging away - // another person's messages cannot turn a shared conversation into a solo one. - const showOwnSenderName = - (activeSession?.expandedParticipants ?? activeSession?.participants ?? []).some( - ({ identity }) => - identity.type !== "agent" && !(identity.type === "profile" && identity.id === props.userId), - ) || - [...props.messages, ...(props.pendingInputs ?? []).map((input) => input.message)].some( - (message) => { - if (normalizeRoleForGrouping(resolveMessageRole(message)) !== "user") { - return false; - } - const sender = resolveMessageSender( - asOptionalRecord(asOptionalRecord(message)?.["__openclaw"]), - ); - return Boolean( - sender && !(sender.identity?.type === "profile" && sender.identity.id === props.userId), - ); - }, - ); - // The session row counts every person who spoke, including rows not loaded yet. - // Grouping adds the loaded senders with the same keys, so one person counts once. - const sessionPeople = new Set( + // Pending-input lists are freshly filtered by renderChat; their immutable + // records, not that temporary array, identify the unfiltered inputs. + return participants( + props.messages, [ + props.userId, + activeSession?.expandedParticipants ?? activeSession?.participants, activeSession?.owner?.actor.identity, - ...(activeSession?.expandedParticipants ?? activeSession?.participants ?? []).map( - ({ identity }) => identity, - ), - ].flatMap((identity) => - identity && identity.type !== "agent" ? [sessionParticipantIdentityKey(identity)] : [], - ), + ...(props.pendingInputs ?? []), + ], + () => { + // Use unfiltered history and retained participants so searching or paging away + // another person's messages cannot turn a shared conversation into a solo one. + const showOwnSenderName = + (activeSession?.expandedParticipants ?? activeSession?.participants ?? []).some( + ({ identity }) => + identity.type !== "agent" && + !(identity.type === "profile" && identity.id === props.userId), + ) || + [...props.messages, ...(props.pendingInputs ?? []).map((input) => input.message)].some( + (message) => { + if (normalizeRoleForGrouping(resolveMessageRole(message)) !== "user") { + return false; + } + const sender = resolveMessageSender( + asOptionalRecord(asOptionalRecord(message)?.["__openclaw"]), + ); + return Boolean( + sender && + !(sender.identity?.type === "profile" && sender.identity.id === props.userId), + ); + }, + ); + // The session row counts every person who spoke, including rows not loaded yet. + // Grouping adds the loaded senders with the same keys, so one person counts once. + const sessionPeople = new Set( + [ + activeSession?.owner?.actor.identity, + ...(activeSession?.expandedParticipants ?? activeSession?.participants ?? []).map( + ({ identity }) => identity, + ), + ].flatMap((identity) => + identity && identity.type !== "agent" ? [sessionParticipantIdentityKey(identity)] : [], + ), + ); + return { showOwnSenderName, sessionPeople }; + }, ); - return { showOwnSenderName, sessionPeople }; } export function isTranscriptGlobalAlias( @@ -91,8 +113,8 @@ export function resolveTranscriptAvatarPlacement( // including groups/channels; identity-resolving gateways also share sessions // between people, so both keep avatars. A forwarded cross-session message adds // another voice to a direct exchange and restores identity chrome. - const hasForwardedGroups = chatItems.some( - (item) => item.kind === "group" && hasForwardedSource(item), + const hasForwardedGroups = forwardedGroups(chatItems, [getChatItemsGeneration(chatItems)], () => + chatItems.some((item) => item.kind === "group" && hasForwardedSource(item)), ); const defaultAvatarPlacement = resolveChatDefaultAvatarPlacement( (sessionKind === "direct" || sessionKind === "cron" || sessionKind === "spawn-child") && diff --git a/ui/src/pages/chat/components/chat-transcript-memo.ts b/ui/src/pages/chat/components/chat-transcript-memo.ts new file mode 100644 index 000000000000..ca75ed3b7bfd --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-memo.ts @@ -0,0 +1,16 @@ +/** Derived facts share their source's lifetime, with one current entry per owner. */ +export function createTranscriptMemo() { + const cache = new WeakMap(); + return (owner: object, key: readonly unknown[], build: () => T): T => { + const cached = cache.get(owner); + if ( + cached?.key.length === key.length && + cached.key.every((value, index) => Object.is(value, key[index])) + ) { + return cached.value; + } + const value = build(); + cache.set(owner, { key, value }); + return value; + }; +} diff --git a/ui/src/pages/chat/components/chat-transcript-message-index.ts b/ui/src/pages/chat/components/chat-transcript-message-index.ts index 47a53981fc08..098e627be49a 100644 --- a/ui/src/pages/chat/components/chat-transcript-message-index.ts +++ b/ui/src/pages/chat/components/chat-transcript-message-index.ts @@ -11,6 +11,7 @@ import { collapseCompletedTurnWork, findLiveStreamIndex, getExpansionStateVersion, + getChatItemsGeneration, persistedMessageEntryId, setExpansionState, } from "../chat-thread.ts"; @@ -22,11 +23,13 @@ import { projectChatPositions, type ChatPositionIndex } from "./chat-position-pr import type { LoadedReplySource } from "./chat-reply-preview.ts"; import type { ChatThreadProps } from "./chat-thread-interactions.ts"; import type { TranscriptRow } from "./chat-transcript-layout.ts"; +import { createTranscriptMemo } from "./chat-transcript-memo.ts"; type ChatRenderItem = ReturnType[number]; type TranscriptChain = { searchActive: boolean; + workGroups: readonly Extract[]; collapsedItems: readonly ChatRenderItem[]; transcriptItems: readonly ChatRenderItem[]; /** Active status parts shown inside the preceding reply, keyed by that reply's group. */ @@ -41,6 +44,8 @@ type TranscriptIndex = { rows: readonly TranscriptRow[]; }; +type TranscriptIndexSource = Pick; + type LiveStream = Extract[number], { kind: "stream" }>; type StreamOwner = Extract; type LiveOwner = { @@ -65,7 +70,7 @@ type ChainEntry = { live?: LiveSlot; }; type BaseIndex = { - chain: TranscriptChain; + chain: TranscriptIndexSource; index: TranscriptIndex; ownerRowIndex: number; }; @@ -73,9 +78,9 @@ type BaseIndex = { // Structural inputs and terminal outcomes invalidate the full projection. A live // slot replacement derives immutable tail updates from that structural entry. const chains = new WeakMap(); -const liveChains = new WeakMap(); -const indexes = new WeakMap(); -const baseIndexes = new WeakMap(); +const liveChains = new WeakMap(); +const indexes = createTranscriptMemo(); +const baseIndexes = createTranscriptMemo(); // Fold only the final presentation, after causal run/turn ownership is settled. // Every original message and its reply identity remains in chronological order. @@ -167,21 +172,6 @@ function replaceOwnerStream( }; } -function memoize( - cache: WeakMap, - owner: object, - key: readonly unknown[], - build: () => T, -): T { - const cached = cache.get(owner); - if (cached?.key.every((value, index) => Object.is(value, key[index]))) { - return cached.value; - } - const value = build(); - cache.set(owner, { key, value }); - return value; -} - export function projectTranscriptChain( chatItems: ReturnType, options: { @@ -193,6 +183,7 @@ export function projectTranscriptChain( ): TranscriptChain { const { session } = options; const key = [ + getChatItemsGeneration(chatItems), options.sessionKey, options.runWorking, options.searchActive, @@ -221,6 +212,7 @@ export function projectTranscriptChain( const value = { collapsedItems, transcriptItems, + workGroups: cached.value.workGroups, continuations: cached.value.continuations, searchActive: cached.value.searchActive, }; @@ -270,7 +262,13 @@ export function projectTranscriptChain( continuations.set(previous.key, activeStatusParts); return false; }); - return { collapsedItems, transcriptItems, continuations, searchActive: options.searchActive }; + return { + collapsedItems, + transcriptItems, + workGroups: transcriptItems.filter((item) => item.kind === "work-group"), + continuations, + searchActive: options.searchActive, + }; }; const value = build(); const index = findLiveStreamIndex(chatItems); @@ -299,7 +297,7 @@ export function projectTranscriptChain( } export function projectTranscriptIndex( - chain: TranscriptChain, + chain: TranscriptIndexSource, expandedToolCards: Map, props: Pick, ): TranscriptIndex { @@ -310,7 +308,7 @@ export function projectTranscriptIndex( props.userId, props.userName, ]; - return memoize(indexes, chain, key, () => { + return indexes(chain, key, () => { const live = liveChains.get(chain); if (!live) { return buildTranscriptIndex(chain, expandedToolCards, props); @@ -322,7 +320,7 @@ export function projectTranscriptIndex( chain.searchActive, ); const visible = tail.markerIdsByMessageId.has(live.item.key); - const base = memoize(baseIndexes, live.structuralChain, [...key, visible], () => { + const base = baseIndexes(live.structuralChain, [...key, visible], () => { const index = buildTranscriptIndex(chain, expandedToolCards, props); const ownerRowIndex = index.rows.findLastIndex((row) => row.key === live.owner.item.key); if (ownerRowIndex < 0) { @@ -358,7 +356,7 @@ export function projectTranscriptIndex( } function buildTranscriptIndex( - chain: TranscriptChain, + chain: TranscriptIndexSource, expandedToolCards: Map, props: Pick, ): TranscriptIndex { diff --git a/ui/src/pages/chat/components/chat-transcript-projection-scale.test.ts b/ui/src/pages/chat/components/chat-transcript-projection-scale.test.ts new file mode 100644 index 000000000000..f5fe466d12c7 --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-projection-scale.test.ts @@ -0,0 +1,320 @@ +/* @vitest-environment jsdom */ + +import { html, nothing, render } from "lit"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { AgentActivityItem } from "../../../../../packages/gateway-protocol/src/schema/logs-chat.js"; +import { messageRecoveryKey } from "../chat-message-recovery.ts"; +import * as chatThreadBuild from "../chat-thread-build.ts"; +import { setExpansionState } from "../chat-thread.ts"; +import { createTestTranscript, stubAnimationFrames } from "../chat-view.test-helpers.ts"; +import { getTranscriptState } from "./chat-thread-interactions.ts"; +import { renderChatThread } from "./chat-thread.ts"; +import { projectChatTranscript } from "./chat-transcript-projection.ts"; +import { + installTranscriptDomMocks, + resetTranscriptTestDom, + threadProps, +} from "./chat-transcript.test-support.ts"; + +beforeEach(() => { + stubAnimationFrames(); + vi.useFakeTimers({ toNotFake: ["requestAnimationFrame", "cancelAnimationFrame"] }); + vi.setSystemTime(10_000); + installTranscriptDomMocks(); +}); + +afterEach(() => { + resetTranscriptTestDom(); + vi.useRealTimers(); +}); + +it("projects unchanged renders and streaming deltas without rereading 3,000 retained messages", () => { + let reads = 0; + const messages = Array.from( + { length: 3_000 }, + (_, index) => + new Proxy( + { + role: index % 2 ? "assistant" : "user", + content: `Message ${index}`, + timestamp: index + 1, + __openclaw: { id: `message-${index}`, runId: `finished-${index >> 1}` }, + }, + { + get(target, property, receiver) { + reads += 1; + return Reflect.get(target, property, receiver); + }, + }, + ), + ); + const props = { + ...threadProps("projection-scale", "agent:main:projection-scale", messages), + runId: "active", + runActive: true, + stream: "Reply", + streamStartedAt: 4_000, + }; + const transcript = createTestTranscript(props.paneId); + const build = vi.spyOn(chatThreadBuild, "buildChatItems"); + const project = () => + transcript.renderSession(props.sessionKey, (session) => { + projectChatTranscript(props, session); + return html``; + }); + try { + project(); + expect(reads).toBeGreaterThan(0); + transcript.renderSession(props.sessionKey, (session) => { + setExpansionState( + session.expandedAssistantMessages, + messageRecoveryKey(undefined, "message-1"), + { status: "loaded", markdown: "Full answer", revision: 1 }, + ); + return html``; + }); + project(); + build.mockClear(); + for (const stream of ["Reply", "Reply continues", "Reply continues again"]) { + props.stream = stream; + reads = 0; + project(); + expect(build).not.toHaveBeenCalled(); + expect(reads, stream).toBe(0); + } + } finally { + transcript.hostDisconnected(); + } +}); + +function personMessage(id: string, name: string, timestamp: number) { + return { + role: "user", + content: `Message from ${name}`, + timestamp, + __openclaw: { + id: `message-${timestamp}`, + senderId: id, + senderName: name, + senderIdentity: { type: "profile" as const, id }, + }, + }; +} + +function drawThread(props: ReturnType) { + const transcript = createTestTranscript(props.paneId); + const container = document.body.appendChild(document.createElement("div")); + return { + container, + draw: () => render(renderChatThread(props, transcript), container), + dispose: () => { + render(nothing, container); + transcript.hostDisconnected(); + }, + }; +} + +it("refreshes sender names for a replaced sender and a changed signed-in viewer", () => { + const own = personMessage("alex", "Alex", 1); + const props = threadProps("projection-sender", "agent:main:main", [own]); + props.userId = "alex"; + props.userName = "Alex"; + const view = drawThread(props); + const names = () => + [...view.container.querySelectorAll(".chat-sender-name")].map((node) => node.textContent); + try { + view.draw(); + expect(names()).toEqual([]); + props.messages = [own, personMessage("riley", "Riley", 2)]; + view.draw(); + expect(names()).toEqual(["Alex", "Riley"]); + props.messages = [own, personMessage("alex", "Alex", 2)]; + view.draw(); + expect(names()).toEqual([]); + props.userId = "riley"; + props.userName = "Riley"; + view.draw(); + expect(names()).toContain("Alex"); + props.userId = "alex"; + props.userName = "Alex"; + view.draw(); + expect(names()).toEqual([]); + } finally { + view.dispose(); + } +}); + +it("updates automatic reply attribution when only the session owner changes", () => { + const own = personMessage("alex", "Alex", 1); + const props = threadProps("projection-owner", "agent:main:main", [ + own, + { role: "assistant", content: "Answer", timestamp: 2 }, + ]); + props.userId = "alex"; + props.selectedSession = { key: props.sessionKey, kind: "direct", updatedAt: 1 }; + const view = drawThread(props); + try { + for (const id of ["alex", "riley", "alex"]) { + props.selectedSession = { + ...props.selectedSession, + owner: { actor: { type: "human", id, identity: { type: "profile", id } } }, + }; + view.draw(); + expect(view.container.querySelector(".chat-reply-attribution__name")?.textContent).toBe( + id === "riley" ? "You" : undefined, + ); + } + } finally { + view.dispose(); + } +}); + +function activityMessage(id: string, timestamp: number) { + const activity: AgentActivityItem[] = ["prior", "current"].map((part) => ({ + itemId: `${id}-${part}`, + toolCallId: `${id}-${part}`, + kind: "tool", + name: "read", + title: id, + status: "running", + phase: "start", + })); + return { + role: "assistant", + runId: "active", + timestamp, + __openclaw: { id }, + content: activity.map(({ toolCallId }) => ({ + type: "toolCall", + id: toolCallId, + name: "read", + arguments: {}, + })), + activity, + }; +} + +it("moves live activity to the current eligible group after replacement and run changes", () => { + const first = activityMessage("Earlier operation", 2); + const latest = activityMessage("Latest operation", 4); + const props = threadProps("projection-activity", "agent:main:main", [ + { role: "user", content: "Inspect the workspace", timestamp: 1 }, + first, + { role: "assistant", content: "Next file", timestamp: 3, phase: "commentary" }, + latest, + ]); + Object.assign(props, { runId: "active", runActive: true, showToolCalls: true }); + const view = drawThread(props); + const liveLabels = () => + [...view.container.querySelectorAll(".chat-activity-group__label--live")].map( + (node) => node.textContent, + ); + try { + view.draw(); + expect(liveLabels()).toEqual(["Latest operation…"]); + props.messages = props.messages.map((message) => + message === latest ? { ...latest, runId: "peer" } : message, + ); + view.draw(); + expect(liveLabels()).toEqual(["Earlier operation…"]); + props.runId = "peer"; + view.draw(); + expect(liveLabels()).toEqual(["Latest operation…"]); + props.runActive = false; + view.draw(); + expect(liveLabels()).toEqual([]); + } finally { + view.dispose(); + } +}); + +it("keeps search and gallery caches warm while refreshing streamed and replaced messages", () => { + const hidden = { + role: "assistant", + content: "MEDIA:https://example.com/hidden.mp4", + timestamp: 1, + __openclaw: { id: "hidden" }, + }; + const hit = { + role: "assistant", + content: "needle MEDIA:https://example.com/visible.mp4", + timestamp: 2, + __openclaw: { id: "visible" }, + }; + const props = { + ...threadProps("projection-search-video", "agent:main:main", [hidden, hit]), + stream: "MEDIA:https://example.com/live.mp4", + streamStartedAt: 3, + }; + const state = getTranscriptState(props.paneId); + Object.assign(state, { searchOpen: true, searchQuery: "needle" }); + const transcript = createTestTranscript(props.paneId); + const build = vi.spyOn(chatThreadBuild, "buildChatItems"); + const project = () => { + transcript.renderSession(props.sessionKey, (session) => { + projectChatTranscript(props, session); + return html``; + }); + return state.transcriptRenderContext.turnVideoMessages + ?.values() + .next() + .value?.map(({ message }) => message); + }; + const liveMessage = () => ({ + role: "assistant", + content: [{ type: "text", text: props.stream }], + }); + try { + expect(project()).toEqual([hidden, hit, liveMessage()]); + build.mockClear(); + for (const stream of [props.stream, "MEDIA:https://example.com/updated.mp4"]) { + props.stream = stream; + expect(project()).toEqual([hidden, hit, liveMessage()]); + expect(build).not.toHaveBeenCalled(); + } + const replacement = { + ...hidden, + content: "MEDIA:https://example.com/replacement.mp4", + __openclaw: { id: "replacement" }, + }; + props.messages = [replacement, hit]; + expect(project()).toEqual([replacement, hit, liveMessage()]); + } finally { + transcript.hostDisconnected(); + } +}); + +it("opens the visible forwarded disclosure when replying across search after a prepend", () => { + const forwarded = (id: string, content: string, timestamp: number) => ({ + role: "assistant", + provenance: { kind: "inter_session", sourceTool: "sessions_send" }, + senderSession: { sessionKey: "agent:other:main", agentId: "other", label: "Other" }, + content, + timestamp, + __openclaw: { id }, + }); + const later = forwarded("later", "needle", 2); + const answer = { role: "assistant", content: "needle answer", timestamp: 3 }; + const props = threadProps("projection-reply-owner", "agent:main:main", [later, answer]); + const state = getTranscriptState(props.paneId); + const view = drawThread(props); + props.onRequestUpdate = view.draw; + try { + view.draw(); + props.messages = [forwarded("earlier", "Hidden original", 1), later, answer]; + view.draw(); + Object.assign(state, { searchOpen: true, searchQuery: "needle" }); + view.draw(); + expect(view.container.textContent).not.toContain("Hidden original"); + + state.transcriptRenderContext.onOpenReply?.("earlier"); + + expect(state.searchOpen).toBe(false); + expect(view.container.querySelector(".chat-session-activity")?.open).toBe( + true, + ); + expect(view.container.textContent).toContain("Hidden original"); + } finally { + view.dispose(); + } +}); diff --git a/ui/src/pages/chat/components/chat-transcript-projection.ts b/ui/src/pages/chat/components/chat-transcript-projection.ts index 1e410640416f..9a4ce7dda9b8 100644 --- a/ui/src/pages/chat/components/chat-transcript-projection.ts +++ b/ui/src/pages/chat/components/chat-transcript-projection.ts @@ -6,11 +6,9 @@ import { i18n } from "../../../i18n/index.ts"; import type { MessageGroup } from "../../../lib/chat/chat-types.ts"; import { extractTextCached } from "../../../lib/chat/message-extract.ts"; import { localParticipantIdentityKey } from "../../../lib/chat/sender-label.ts"; -import { readPreparedActivity } from "../../../lib/chat/tool-call-grouping.ts"; import { chatItemGroups } from "../chat-agent-run-grouping.ts"; import { messageRecoveryKey } from "../chat-message-recovery.ts"; import { resolveTurnRecap, type TurnRecap } from "../chat-progress.ts"; -import { transcriptRunId } from "../chat-thread-run-identity.ts"; import { assistantGroupCanOwnActiveRunStatus, buildCachedChatItems, @@ -19,7 +17,6 @@ import { getExpandedToolCards, getExpandedUserMessages, persistedMessageEntryId, - pruneAssistantMessageExpansions, setExpansionState, syncToolCardExpansionState, } from "../chat-thread.ts"; @@ -42,6 +39,7 @@ import { type ChatThreadProps, } from "./chat-thread-interactions.ts"; import { renderWorkGroupBrowserTabPreviews } from "./chat-tool-cards.ts"; +import { projectTranscriptActivity } from "./chat-transcript-activity.ts"; import { latestTranscriptAnnouncement } from "./chat-transcript-announcement.ts"; import { isTranscriptGlobalAlias, @@ -49,11 +47,13 @@ import { resolveTranscriptParticipants, } from "./chat-transcript-identity.ts"; import type { TranscriptRow } from "./chat-transcript-layout.ts"; +import { createTranscriptMemo } from "./chat-transcript-memo.ts"; import { expandReplyTargetWork, projectTranscriptChain, projectTranscriptIndex, } from "./chat-transcript-message-index.ts"; +import { pruneTranscriptExpansions } from "./chat-transcript-recovery.ts"; import { guardChatRenderItems, trackTranscriptRenderDependencies, @@ -69,6 +69,9 @@ import { resolveAssistantDisplayAvatar } from "./chat-welcome.ts"; import { renderTurnRecapRow } from "./chat-working-indicator.ts"; type ChatRenderItem = ReturnType[number]; +const workPreviewCache = + createTranscriptMemo>(); +const persistedMessageIds = createTranscriptMemo>(); export function projectChatTranscript( props: ChatThreadProps, @@ -100,13 +103,7 @@ export function projectChatTranscript( const expandedAssistantMessages = transcript.expandedAssistantMessages; const recoveryKey = (messageId: string) => messageRecoveryKey(props.fullMessageAgentId, messageId); - if (expandedAssistantMessages.size > 0) { - pruneAssistantMessageExpansions(expandedAssistantMessages, props.fullMessageAgentId, [ - ...props.messages, - ...props.toolMessages, - ...(props.pendingInputs ?? []).map((input) => input.message), - ]); - } + pruneTranscriptExpansions(expandedAssistantMessages, props); const chatItemsInput = { paneId: props.paneId, sessionKey: props.sessionKey, @@ -152,26 +149,8 @@ export function projectChatTranscript( : undefined, } satisfies Parameters[0]; const chatItems = buildCachedChatItems(chatItemsInput); - const workingIndicator = chatItems.find((item) => item.kind === "reading-indicator"); - const activityRunId = workingIndicator?.runId ?? props.runId; - const activityGroupKey = - props.runActive && activityRunId - ? chatItems.findLast( - (item) => - item.kind === "group" && - item.messages.some( - ({ message }) => - transcriptRunId(message) === activityRunId && - readPreparedActivity(message).some( - (activity) => - !activity.hideFromChannelProgress && !activity.suppressChannelProgress, - ), - ), - )?.key - : undefined; - const runOutputTokens = workingIndicator?.runId - ? (props.runUsageById?.get(workingIndicator.runId)?.outputTokens ?? null) - : null; + const { workingIndicator, activityRunId, activityGroupKey, runOutputTokens } = + projectTranscriptActivity(chatItems, props); const latestBrowserTabs = props.latestBrowserTabs; syncToolCardExpansionState( props.sessionKey, @@ -194,11 +173,20 @@ export function projectChatTranscript( } const { messageRowKeysById, transcriptMessageKeys, loadedReplySources, positionIndex, rows } = projectTranscriptIndex(transcriptChain, expandedToolCards, props); - const workPreviews = renderWorkGroupBrowserTabPreviews( - transcriptItems.flatMap((item) => - item.kind === "work-group" && !expandedToolCards.get(item.key) ? [item] : [], - ), - { sessionKey: props.sessionKey, latestBrowserTabs }, + const latestBrowserTabsKey = JSON.stringify([...(latestBrowserTabs ?? [])]); + const workPreviews = workPreviewCache( + transcriptChain.workGroups, + [ + expandedToolCards, + getExpansionStateVersion(expandedToolCards), + props.sessionKey, + latestBrowserTabsKey, + ], + () => + renderWorkGroupBrowserTabPreviews( + transcriptChain.workGroups.filter((item) => !expandedToolCards.get(item.key)), + { sessionKey: props.sessionKey, latestBrowserTabs }, + ), ); const questionPrompts = new Map( (props.questionPrompts ?? []).map((prompt) => [prompt.id, prompt]), @@ -509,8 +497,8 @@ export function projectChatTranscript( if (turnRecap !== null && tailStatusOwner?.runId === turnRecap.runId) { turnRecapByGroupKey.set(tailStatusOwner.key, turnRecap); } - const transcriptRows: TranscriptRow[] = []; - for (const row of rows) { + const transcriptRows: TranscriptRow[] = workPreviews.size ? [] : rows.slice(); + for (const row of workPreviews.size ? rows : []) { transcriptRows.push(row); const previews = workPreviews.get(row.key); if (previews && !(row.kind === "item" && row.item.kind === "work-group")) { @@ -523,17 +511,19 @@ export function projectChatTranscript( }); } } - // Only ID-bearing voice captions need a history scan. Keep membership local - // to this projection so history replacement and search cannot stale it. - let persistedIds: Set | undefined; + // Voice captions reconcile against unfiltered immutable history, not the + // current search or streaming projection. const realtimeConversation = renderRealtimeTalkConversation({ ...props, realtimeTalkConversation: props.realtimeTalkConversation?.filter((entry) => { if (!entry.transcriptId) { return true; } - persistedIds ??= new Set(props.messages.map(persistedMessageEntryId)); - return !persistedIds.has(entry.transcriptId); + return !persistedMessageIds( + props.messages, + [], + () => new Set(props.messages.map(persistedMessageEntryId)), + ).has(entry.transcriptId); }), }); if (realtimeConversation !== nothing) { @@ -577,7 +567,7 @@ export function projectChatTranscript( getChatMediaRenderVersion(), // The host minute poll requests an update; this key crosses row guard() memoization. Math.floor(Date.now() / 60_000), - JSON.stringify([...(latestBrowserTabs ?? [])]), + latestBrowserTabsKey, props.sessionKey, props.presented, props.transcriptVisible, @@ -642,9 +632,13 @@ export function projectChatTranscript( ]); // Rebind disclosures to the current pane without repainting unchanged rows. state.transcriptRenderContext.onRequestUpdate = props.onRequestUpdate; + const unfilteredItems = () => + buildCachedChatItems( + { ...chatItemsInput, searchOpen: false, searchQuery: "", messageRecovery: undefined }, + "unfiltered", + ); state.transcriptRenderContext.turnVideoMessages = projectTurnVideoMessages( - chatItems, - searchFiltering ? chatItemsInput : undefined, + searchFiltering ? unfilteredItems() : chatItems, ); state.transcriptRenderContext.onSetReply = props.onSetReply; state.transcriptRenderContext.onOpenReply = (replyToId) => { @@ -653,6 +647,8 @@ export function projectChatTranscript( // owners as visible targets rather than requiring a history loader. const targetChain = searchFiltering ? projectTranscriptChain( + // Navigation expands the stabilized row keys of the visible cache; + // the gallery cache owns membership, not mounted disclosure identity. buildCachedChatItems({ ...chatItemsInput, searchOpen: false, searchQuery: "" }), { sessionKey: props.sessionKey, diff --git a/ui/src/pages/chat/components/chat-transcript-recovery.ts b/ui/src/pages/chat/components/chat-transcript-recovery.ts new file mode 100644 index 000000000000..af77fadaff75 --- /dev/null +++ b/ui/src/pages/chat/components/chat-transcript-recovery.ts @@ -0,0 +1,33 @@ +import type { AssistantMessageExpansionState } from "../chat-message-recovery.ts"; +import { getExpansionStateVersion, pruneAssistantMessageExpansions } from "../chat-thread.ts"; +import type { ChatThreadProps } from "./chat-thread-interactions.ts"; +import { createTranscriptMemo } from "./chat-transcript-memo.ts"; + +const expansionPrunes = createTranscriptMemo(); + +export function pruneTranscriptExpansions( + expandedAssistantMessages: Map, + props: Pick< + ChatThreadProps, + "fullMessageAgentId" | "messages" | "toolMessages" | "pendingInputs" + >, +): void { + if (expandedAssistantMessages.size > 0) { + expansionPrunes( + expandedAssistantMessages, + [ + props.fullMessageAgentId, + props.messages, + props.toolMessages, + getExpansionStateVersion(expandedAssistantMessages), + ...(props.pendingInputs ?? []), + ], + () => + pruneAssistantMessageExpansions(expandedAssistantMessages, props.fullMessageAgentId, [ + ...props.messages, + ...props.toolMessages, + ...(props.pendingInputs ?? []).map((input) => input.message), + ]), + ); + } +} diff --git a/ui/src/pages/chat/components/chat-turn-video-gallery.test.ts b/ui/src/pages/chat/components/chat-turn-video-gallery.test.ts index 277cdf21a974..6efb3475feb1 100644 --- a/ui/src/pages/chat/components/chat-turn-video-gallery.test.ts +++ b/ui/src/pages/chat/components/chat-turn-video-gallery.test.ts @@ -3,6 +3,8 @@ import { nothing, render } from "lit"; import { afterEach, expect, it, vi } from "vitest"; import type { ImageLightboxItem } from "../../../components/image-lightbox.types.ts"; import type { MessageGroup } from "../../../lib/chat/chat-types.ts"; +import { buildCachedChatItems, resetChatThreadState } from "../chat-thread.ts"; +import * as turnBoundary from "../chat-turn-boundary.ts"; import { renderMessageGroup } from "./chat-message-group.ts"; import { createAssistantMessage, @@ -16,6 +18,7 @@ afterEach(() => { render(nothing, container); container.remove(); vi.restoreAllMocks(); + resetChatThreadState(); }); function group(key: string, role = "assistant", extra: Partial = {}) { @@ -26,6 +29,54 @@ function group(key: string, role = "assistant", extra: Partial = { }); } +it("retains turn membership across renders and stream deltas while reading fresh stream text", () => { + const input = { + paneId: "video-pane", + sessionKey: "video-session", + messages: [{ role: "assistant", content: "Saved clip", timestamp: 1 }], + toolMessages: [], + streamSegments: [], + stream: "First clip", + streamStartedAt: 2, + showToolCalls: true, + }; + const items = buildCachedChatItems(input); + const byMessage = projectTurnVideoMessages(items); + const live = items.find((item) => item.kind === "stream"); + expect(live?.kind).toBe("stream"); + const key = live!.key; + const firstMessage = byMessage.get(key)!.at(-1)!.message; + const boundary = vi.spyOn(turnBoundary, "chatItemStartsUserTurn"); + + projectTurnVideoMessages(buildCachedChatItems(input)); + const next = projectTurnVideoMessages(buildCachedChatItems({ ...input, stream: "Second clip" })); + expect(boundary).not.toHaveBeenCalled(); + expect(next.get(key)?.map((entry) => entry.message)).toEqual([ + input.messages[0], + { role: "assistant", content: [{ type: "text", text: "Second clip" }] }, + ]); + expect(firstMessage).toEqual({ + role: "assistant", + content: [{ type: "text", text: "First clip" }], + }); + + // A full build can preserve its array; its generation still owns invalidation. + boundary.mockClear(); + const rebuilt = buildCachedChatItems({ ...input, stream: "Second clip", queue: [] }); + expect(rebuilt).toBe(items); + projectTurnVideoMessages(rebuilt); + expect(boundary).toHaveBeenCalled(); + + const replacement = { + ...input.messages[0], + provenance: { kind: "inter_session", sourceTool: "sessions_send" }, + }; + const replaced = projectTurnVideoMessages( + buildCachedChatItems({ ...input, messages: [replacement], stream: "Second clip" }), + ); + expect(replaced.get(key)?.map((entry) => entry.key)).toEqual([key]); +}); + it("projects a whole turn across run frames, but never another user turn, divider, or forwarded session", () => { const a = group("a", "assistant", { runId: "run-a" }); const b = group("b", "assistant", { runId: "run-b" }); diff --git a/ui/src/pages/chat/components/chat-turn-video-gallery.ts b/ui/src/pages/chat/components/chat-turn-video-gallery.ts index 0fc5675ddd46..209151cde37d 100644 --- a/ui/src/pages/chat/components/chat-turn-video-gallery.ts +++ b/ui/src/pages/chat/components/chat-turn-video-gallery.ts @@ -1,21 +1,47 @@ import type { ChatItem, MessageGroup } from "../../../lib/chat/chat-types.ts"; import { normalizeRoleForGrouping } from "../../../lib/chat/message-normalizer.ts"; -import { buildChatItems, type BuildChatItemsProps } from "../chat-thread-build.ts"; +import { getChatItemsGeneration } from "../chat-thread.ts"; import { chatItemStartsUserTurn, hasForwardedSource } from "../chat-turn-boundary.ts"; export type TurnVideoMessage = { key: string; message: unknown }; +type CachedTurnVideoMessages = { + generation: number; + byMessage: Map; + live?: { index: number; turn: TurnVideoMessage[]; slot: number; text: string }; +}; + +const turnVideosByItems = new WeakMap< + readonly (ChatItem | MessageGroup)[], + CachedTurnVideoMessages +>(); + +function streamMessage(item: Extract): TurnVideoMessage { + return { + key: item.key, + message: { role: "assistant", content: [{ type: "text", text: item.text }] }, + }; +} + /** Membership is projected before run frames/collapsed work, never from mounted DOM rows. */ -export function projectTurnVideoMessages( - items: readonly (ChatItem | MessageGroup)[], - unfilteredInput?: BuildChatItemsProps, -) { - const source = unfilteredInput - ? buildChatItems({ ...unfilteredInput, searchOpen: false, searchQuery: "" }) - : items; +export function projectTurnVideoMessages(items: readonly (ChatItem | MessageGroup)[]) { + const generation = getChatItemsGeneration(items); + const cached = turnVideosByItems.get(items); + if (cached?.generation === generation) { + const live = cached.live; + const item = live ? items[live.index] : undefined; + if (live && item?.kind === "stream" && live.text !== item.text) { + // Gallery expansion reads this live lookup lazily. Replace the synthetic + // message so previously prepared immutable message snapshots stay valid. + live.turn[live.slot] = streamMessage(item); + live.text = item.text; + } + return cached.byMessage; + } const byMessage = new Map(); + const projection: CachedTurnVideoMessages = { generation, byMessage }; let turn: TurnVideoMessage[] = []; - for (const item of source) { + for (const [index, item] of items.entries()) { if ( item.kind === "divider" || chatItemStartsUserTurn(item) || @@ -32,13 +58,13 @@ export function projectTurnVideoMessages( byMessage.set(message.key, turn); } } else if (item.kind === "stream") { - const message = { - key: item.key, - message: { role: "assistant", content: [{ type: "text", text: item.text }] }, - }; - turn.push(message); + if (item.isStreaming) { + projection.live = { index, turn, slot: turn.length, text: item.text }; + } + turn.push(streamMessage(item)); byMessage.set(item.key, turn); } } + turnVideosByItems.set(items, projection); return byMessage; }