mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
perf(ui): keep chat renders independent of history length (#162035)
Draft-presence notifications republished empty outbox arrays, invalidating transcript items on the first keystroke. Stream paints also repeated history scans for participants, activity ownership, and gallery membership. Keep empty queues stable at their publisher. Cache derived facts by immutable inputs and a full-build generation while preserving stream-slot replacement. Share session input ordering across visible and unfiltered projections; keep reply navigation on the visible cache's stabilized disclosure keys. Reuse structural work-group metadata and memoize recovery and voice indexes. Source projection microbenchmark, median of seven 300-render bursts: - 534 messages: unchanged 51.72 -> 3.07 ms; streaming 93.74 -> 12.08 ms. - 3000 messages: unchanged 268.86 -> 7.45 ms; streaming 474.68 -> 14.49 ms. The 3000-message regression observes no retained-message reads or full builds on unchanged and stream-only renders. The production perf rig remains with the lead. Validation: 586 unit/browser tests across 33 files with one worker; 89 E2E cases across 15 files plus 32 final navigation/gallery cases; pnpm tsgo:ui; node scripts/check-changed.mjs.
This commit is contained in:
parent
807a723309
commit
d53b32bb96
13 changed files with 732 additions and 127 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
57
ui/src/pages/chat/chat-pane-projection-scale.test.ts
Normal file
57
ui/src/pages/chat/chat-pane-projection-scale.test.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -42,7 +42,7 @@ function acceptedInput(
|
|||
|
||||
function visibleRows(
|
||||
overrides: Partial<BuildChatItemsProps>,
|
||||
build = buildChatItems,
|
||||
build: (input: BuildChatItemsProps) => ReturnType<typeof buildChatItems> = buildChatItems,
|
||||
): Array<string | null> {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ export {
|
|||
export { coalesceAgentRunFrames } from "./chat-agent-run-grouping.ts";
|
||||
|
||||
type CachedChatItems = {
|
||||
generation: number;
|
||||
input: BuildChatItemsProps | null;
|
||||
inputOrder: ChatInputOrderState;
|
||||
items: ReturnType<typeof buildChatItems>;
|
||||
liveStream: {
|
||||
index: number;
|
||||
|
|
@ -54,7 +54,15 @@ type ToolCardExpansionState = {
|
|||
};
|
||||
};
|
||||
|
||||
const chatItemsByPane = new Map<string, Map<string, CachedChatItems>>();
|
||||
type ChatItemsProjection = "visible" | "unfiltered";
|
||||
type CachedChatProjections = Partial<Record<ChatItemsProjection, CachedChatItems>> & {
|
||||
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<string, Map<string, CachedChatProjections>>();
|
||||
const chatItemsGenerations = new WeakMap<readonly RenderChatItem[], number>();
|
||||
const toolCardStateBySession = new Map<string, ToolCardExpansionState>();
|
||||
const expandedUserMessagesBySession = new Map<string, Map<string, boolean>>();
|
||||
const expansionMapVersions = new WeakMap<ReadonlyMap<string, unknown>, 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<typeof buildChatItems> {
|
||||
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);
|
||||
|
|
|
|||
45
ui/src/pages/chat/components/chat-transcript-activity.ts
Normal file
45
ui/src/pages/chat/components/chat-transcript-activity.ts
Normal file
|
|
@ -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<ChatItem, { kind: "reading-indicator" }> | undefined
|
||||
>();
|
||||
const activityGroups = createTranscriptMemo<string | undefined>();
|
||||
|
||||
export function projectTranscriptActivity(
|
||||
chatItems: ReturnType<typeof buildCachedChatItems>,
|
||||
props: Pick<ChatThreadProps, "runId" | "runActive" | "runUsageById">,
|
||||
) {
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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<string>;
|
||||
}>();
|
||||
const forwardedGroups = createTranscriptMemo<boolean>();
|
||||
|
||||
export function resolveTranscriptParticipants(
|
||||
props: Pick<ChatThreadProps, "selectedSession" | "userId" | "messages" | "pendingInputs">,
|
||||
) {
|
||||
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") &&
|
||||
|
|
|
|||
16
ui/src/pages/chat/components/chat-transcript-memo.ts
Normal file
16
ui/src/pages/chat/components/chat-transcript-memo.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** Derived facts share their source's lifetime, with one current entry per owner. */
|
||||
export function createTranscriptMemo<T>() {
|
||||
const cache = new WeakMap<object, { key: readonly unknown[]; value: T }>();
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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<typeof coalesceAgentRunFrames>[number];
|
||||
|
||||
type TranscriptChain = {
|
||||
searchActive: boolean;
|
||||
workGroups: readonly Extract<ChatRenderItem, { kind: "work-group" }>[];
|
||||
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<ChatRenderItem>[];
|
||||
};
|
||||
|
||||
type TranscriptIndexSource = Pick<TranscriptChain, "searchActive" | "transcriptItems">;
|
||||
|
||||
type LiveStream = Extract<ReturnType<typeof buildCachedChatItems>[number], { kind: "stream" }>;
|
||||
type StreamOwner = Extract<ChatRenderItem, { kind: "stream-run" | "agent-run-frame" }>;
|
||||
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<object, ChainEntry>();
|
||||
const liveChains = new WeakMap<TranscriptChain, LiveProjection>();
|
||||
const indexes = new WeakMap<object, { key: readonly unknown[]; value: TranscriptIndex }>();
|
||||
const baseIndexes = new WeakMap<object, { key: readonly unknown[]; value: BaseIndex }>();
|
||||
const liveChains = new WeakMap<TranscriptIndexSource, LiveProjection>();
|
||||
const indexes = createTranscriptMemo<TranscriptIndex>();
|
||||
const baseIndexes = createTranscriptMemo<BaseIndex>();
|
||||
|
||||
// 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<T>(
|
||||
cache: WeakMap<object, { key: readonly unknown[]; value: T }>,
|
||||
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<typeof buildCachedChatItems>,
|
||||
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<string, boolean>,
|
||||
props: Pick<ChatThreadProps, "assistantName" | "userId" | "userName">,
|
||||
): 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<string, boolean>,
|
||||
props: Pick<ChatThreadProps, "assistantName" | "userId" | "userName">,
|
||||
): TranscriptIndex {
|
||||
|
|
|
|||
|
|
@ -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<typeof threadProps>) {
|
||||
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<HTMLDetailsElement>(".chat-session-activity")?.open).toBe(
|
||||
true,
|
||||
);
|
||||
expect(view.container.textContent).toContain("Hidden original");
|
||||
} finally {
|
||||
view.dispose();
|
||||
}
|
||||
});
|
||||
|
|
@ -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<typeof coalesceAgentRunFrames>[number];
|
||||
const workPreviewCache =
|
||||
createTranscriptMemo<ReturnType<typeof renderWorkGroupBrowserTabPreviews>>();
|
||||
const persistedMessageIds = createTranscriptMemo<Set<string | null>>();
|
||||
|
||||
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<typeof buildCachedChatItems>[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<ChatRenderItem>[] = [];
|
||||
for (const row of rows) {
|
||||
const transcriptRows: TranscriptRow<ChatRenderItem>[] = 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<string | null> | 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,
|
||||
|
|
|
|||
33
ui/src/pages/chat/components/chat-transcript-recovery.ts
Normal file
33
ui/src/pages/chat/components/chat-transcript-recovery.ts
Normal file
|
|
@ -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<void>();
|
||||
|
||||
export function pruneTranscriptExpansions(
|
||||
expandedAssistantMessages: Map<string, AssistantMessageExpansionState>,
|
||||
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),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MessageGroup> = {}) {
|
||||
|
|
@ -26,6 +29,54 @@ function group(key: string, role = "assistant", extra: Partial<MessageGroup> = {
|
|||
});
|
||||
}
|
||||
|
||||
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" });
|
||||
|
|
|
|||
|
|
@ -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<string, readonly TurnVideoMessage[]>;
|
||||
live?: { index: number; turn: TurnVideoMessage[]; slot: number; text: string };
|
||||
};
|
||||
|
||||
const turnVideosByItems = new WeakMap<
|
||||
readonly (ChatItem | MessageGroup)[],
|
||||
CachedTurnVideoMessages
|
||||
>();
|
||||
|
||||
function streamMessage(item: Extract<ChatItem, { kind: "stream" }>): 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<string, readonly TurnVideoMessage[]>();
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue