mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
improve(ui): speed up session loading and chat rendering (#129656)
* perf(ui): reduce session loading and render overhead * test(ui): align shell fixtures with appearance settings owner
This commit is contained in:
committed by
GitHub
parent
a74080d5d5
commit
0246aaf06d
@@ -7,6 +7,7 @@ import "../components/app-sidebar.ts";
|
||||
import { waitForFast } from "../test-helpers/wait-for.ts";
|
||||
import type { ApplicationRuntime } from "./bootstrap.ts";
|
||||
import type { ApplicationContext, ApplicationGatewaySnapshot } from "./context.ts";
|
||||
import { loadSettings } from "./settings.ts";
|
||||
import "./app-host.ts";
|
||||
|
||||
type PairingShell = HTMLElement & {
|
||||
@@ -99,7 +100,7 @@ function createPairingShell(params: {
|
||||
agents: { state: { agentsList: null } },
|
||||
agentSelection: { state: { selectedId: "main", scopeId: "main" } },
|
||||
sessions: { state: { result: null } },
|
||||
theme: { mode: "system" },
|
||||
theme: { mode: "system", settings: loadSettings() },
|
||||
} as unknown as ApplicationContext;
|
||||
const shell = document.createElement("openclaw-app-shell") as PairingShell;
|
||||
shell.runtime = { context, router: {} } as ApplicationRuntime;
|
||||
|
||||
@@ -12,6 +12,7 @@ import "../components/app-sidebar.ts";
|
||||
import "./app-host.ts";
|
||||
import type { ApplicationRuntime } from "./bootstrap.ts";
|
||||
import type { ApplicationContext } from "./context.ts";
|
||||
import { loadSettings } from "./settings.ts";
|
||||
|
||||
type ShellRenderState = {
|
||||
runtime: ApplicationRuntime;
|
||||
@@ -112,7 +113,7 @@ describe("OpenClaw shell dock suppression", () => {
|
||||
},
|
||||
runUpdate: vi.fn(),
|
||||
},
|
||||
theme: { mode: "dark" },
|
||||
theme: { mode: "dark", settings: loadSettings() },
|
||||
preload: vi.fn(),
|
||||
} as unknown as ApplicationContext;
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as ShellRenderState;
|
||||
|
||||
@@ -28,10 +28,10 @@ export type StoredOutboxScopeHost = {
|
||||
};
|
||||
|
||||
export type OutboxStoreRuntime = {
|
||||
listStoredDraftScopes: (state: StoredOutboxScopeHost) => ReadonlySet<string>;
|
||||
summarizeStoredChatOutboxes: (state: StoredOutboxScopeHost) => {
|
||||
countsByScope: ReadonlyMap<string, number>;
|
||||
attentionCountsByScope: ReadonlyMap<string, number>;
|
||||
draftScopes: ReadonlySet<string>;
|
||||
total: number;
|
||||
};
|
||||
resolveStoredChatOutboxScope: (
|
||||
|
||||
@@ -50,7 +50,6 @@ import { readGatewayOperatorAccess } from "./operator-access.ts";
|
||||
import {
|
||||
NAV_WIDTH_MAX,
|
||||
NAV_WIDTH_MIN,
|
||||
loadSettings,
|
||||
normalizeCatalogOpenTarget,
|
||||
normalizeChatSendShortcut,
|
||||
} from "./settings.ts";
|
||||
@@ -213,7 +212,6 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
const outboxScopeHost = host.storedOutboxScopeHost(context);
|
||||
const outboxStoreRuntime = host.outboxStoreRuntime;
|
||||
const storedOutboxes = outboxStoreRuntime?.summarizeStoredChatOutboxes(outboxScopeHost) ?? null;
|
||||
const storedDraftScopeKeys = outboxStoreRuntime?.listStoredDraftScopes(outboxScopeHost) ?? null;
|
||||
const outboxAttentionCountForSession = outboxStoreRuntime
|
||||
? (sessionKey: string) => {
|
||||
const scope = outboxStoreRuntime.resolveStoredChatOutboxScope(outboxScopeHost, sessionKey);
|
||||
@@ -225,7 +223,8 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
? (sessionKey: string) => {
|
||||
const scope = outboxStoreRuntime.resolveStoredChatOutboxScope(outboxScopeHost, sessionKey);
|
||||
return (
|
||||
storedDraftScopeKeys?.has(outboxStoreRuntime.storedChatOutboxScopeKey(scope)) === true
|
||||
storedOutboxes?.draftScopes.has(outboxStoreRuntime.storedChatOutboxScopeKey(scope)) ===
|
||||
true
|
||||
);
|
||||
}
|
||||
: EMPTY_SESSION_HAS_DRAFT;
|
||||
@@ -327,8 +326,7 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
host.openNewSession(agentId, target);
|
||||
}
|
||||
};
|
||||
// One storage read per render; theme.refresh() re-renders on pref changes.
|
||||
const uiSettings = loadSettings();
|
||||
const uiSettings = context.theme.settings;
|
||||
// The new-session draft shares the chat layout: full-height pane that owns
|
||||
// its scrolling and pins the composer dock to the bottom.
|
||||
const chatLikeRoute = sessionRoute || activeRoute === "new-session";
|
||||
|
||||
@@ -973,11 +973,13 @@ describe("normalizeInitialApplicationLocation", () => {
|
||||
try {
|
||||
expect(runtime.context.gateway.snapshot.phase).toBe("stopped");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent")).toBe("#48d6c2");
|
||||
expect(runtime.context.theme.settings.accent).toBe("#48d6c2");
|
||||
|
||||
saveSettings({ ...loadSettings(), accent: "#f4b740" });
|
||||
runtime.context.theme.refresh();
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--accent")).toBe("#f4b740");
|
||||
expect(runtime.context.theme.settings.accent).toBe("#f4b740");
|
||||
} finally {
|
||||
saveSettings(previousSettings);
|
||||
runtime.context.theme.refresh();
|
||||
|
||||
@@ -143,6 +143,9 @@ function createApplicationTheme(
|
||||
syncSystemThemeListener();
|
||||
|
||||
return {
|
||||
get settings() {
|
||||
return settings;
|
||||
},
|
||||
get mode() {
|
||||
return settings.themeMode;
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { NativeChatDrafts } from "./native-bridge.ts";
|
||||
import type { NativeNotificationsCapability } from "./native-notifications.ts";
|
||||
import type { ApplicationOverlays } from "./overlays-types.ts";
|
||||
import type { ApplicationPlacementStartup } from "./session-placement-startup.ts";
|
||||
import type { UiSettings } from "./settings.ts";
|
||||
import type { ApplicationSkillWorkshopRevisionAdmissions } from "./skill-workshop-revision-admissions.ts";
|
||||
import type { ThemeMode, ThemeName } from "./theme.ts";
|
||||
import type { WebPushCapability } from "./web-push.ts";
|
||||
@@ -35,6 +36,7 @@ export type ApplicationThemeServerSelection = {
|
||||
};
|
||||
|
||||
export type ApplicationTheme = {
|
||||
readonly settings: UiSettings;
|
||||
readonly mode: ThemeMode;
|
||||
readonly resolvedMode: "dark" | "light";
|
||||
readonly serverSelection: ApplicationThemeServerSelection | null;
|
||||
|
||||
@@ -465,11 +465,9 @@ export function loadSettings(): UiSettings {
|
||||
const selectedGatewayUrl = normalizeOptionalString(
|
||||
storage?.getItem(currentGatewaySelectionKeyForPage(pageDerivedUrl)),
|
||||
);
|
||||
const selected = selectedGatewayUrl
|
||||
? readSettingsForGateway(storage, selectedGatewayUrl)
|
||||
: null;
|
||||
const defaultSource = readSettingsForGateway(storage, defaultUrl);
|
||||
const source = selected ?? defaultSource;
|
||||
const source =
|
||||
(selectedGatewayUrl ? readSettingsForGateway(storage, selectedGatewayUrl) : null) ??
|
||||
readSettingsForGateway(storage, defaultUrl);
|
||||
if (!source) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
@@ -395,9 +395,9 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
||||
const navigationState = this.getSessionNavigationState();
|
||||
const rows = this.selectedAgentSessionRows(navigationState);
|
||||
const { visibleRows } = this.zonedVisibleSections(rows);
|
||||
const pinnedByKey = new Map(rows.filter((row) => row.pinned).map((row) => [row.key, row]));
|
||||
const pinnedRows = this.reconciledSidebarZone().entries.flatMap((entry) => {
|
||||
const row = entry.type === "session" ? pinnedByKey.get(entry.key) : undefined;
|
||||
const { entries, sessionRows } = this.reconciledSidebarZone();
|
||||
const pinnedRows = entries.flatMap((entry) => {
|
||||
const row = entry.type === "session" ? sessionRows.get(entry.key) : undefined;
|
||||
return row ? [row] : [];
|
||||
});
|
||||
return [...pinnedRows, ...visibleRows];
|
||||
|
||||
@@ -297,6 +297,18 @@ describe("SidebarSessionProjection child expansion", () => {
|
||||
|
||||
expect(projection.isChildrenExpanded(parent.key)).toBe(false);
|
||||
});
|
||||
|
||||
it("forgets expansion when a session disappears before returning", () => {
|
||||
const projection = new SidebarSessionProjection();
|
||||
const parent = sessionRow("parent");
|
||||
projection.project(projectionInput([parent]));
|
||||
projection.toggleChildren(parent);
|
||||
|
||||
projection.project(projectionInput([]));
|
||||
projection.project(projectionInput([parent]));
|
||||
|
||||
expect(projection.isChildrenExpanded(parent.key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarSessionProjection running subtitle hold", () => {
|
||||
|
||||
@@ -174,9 +174,9 @@ export class SidebarSessionProjection {
|
||||
};
|
||||
this.previousCollapsedSections = new Set(input.collapsedSections);
|
||||
|
||||
const retainedKeys = new Set<string>();
|
||||
const staleKeys = new Set([...this.childModes.keys(), ...this.heldSubtitles.keys()]);
|
||||
const observeTree = (session: SidebarRecentSession) => {
|
||||
retainedKeys.add(session.key);
|
||||
staleKeys.delete(session.key);
|
||||
if (session.containsActiveDescendant && !this.childModes.has(session.key)) {
|
||||
this.childModes.set(session.key, "expanded");
|
||||
}
|
||||
@@ -186,15 +186,9 @@ export class SidebarSessionProjection {
|
||||
}
|
||||
};
|
||||
input.rows.forEach(observeTree);
|
||||
for (const key of this.childModes.keys()) {
|
||||
if (!retainedKeys.has(key)) {
|
||||
this.childModes.delete(key);
|
||||
}
|
||||
}
|
||||
for (const key of this.heldSubtitles.keys()) {
|
||||
if (!retainedKeys.has(key)) {
|
||||
this.heldSubtitles.delete(key);
|
||||
}
|
||||
for (const key of staleKeys) {
|
||||
this.childModes.delete(key);
|
||||
this.heldSubtitles.delete(key);
|
||||
}
|
||||
|
||||
const { grouping, knownGroups, selfOwnerId, sectionOrder, catalogIds } = input;
|
||||
|
||||
@@ -820,6 +820,14 @@ PY
|
||||
});
|
||||
|
||||
describe("large text handling", () => {
|
||||
it("does not build cache keys for replies larger than the cache limit", () => {
|
||||
const locale = vi.spyOn(i18n, "getLocale");
|
||||
|
||||
expect(toSanitizedMarkdownHtml("x".repeat(50_001))).toContain("x".repeat(100));
|
||||
expect(locale).not.toHaveBeenCalled();
|
||||
locale.mockRestore();
|
||||
});
|
||||
|
||||
it("uses plain text fallback for oversized content", () => {
|
||||
// MARKDOWN_PARSE_LIMIT is 40_000 chars
|
||||
const input = Array.from(
|
||||
|
||||
@@ -550,18 +550,16 @@ export function toSanitizedMarkdownHtml(
|
||||
return "";
|
||||
}
|
||||
const renderInput = isMarkdownBlockArtText(rawInput) ? rawInput : input;
|
||||
const cacheable = input.length <= MARKDOWN_CACHE_MAX_CHARS;
|
||||
if (input.length > MARKDOWN_CACHE_MAX_CHARS) {
|
||||
return renderSanitizedMarkdown(renderInput, renderOptions);
|
||||
}
|
||||
const cacheKey = `${i18n.getLocale()}\0${renderOptions.assistantTranscriptRoleHeaders}\0${renderOptions.codeBlockChrome}\0${renderOptions.codeBlockInteraction}\0${renderOptions.fileLinks}\0${renderOptions.interactiveImages}\0${renderOptions.linkFavicons}\0${renderOptions.progressBars}\0${renderOptions.mode}\0${renderOptions.sessionLinks}\0${renderOptions.tableInteractions}\0${renderInput}`;
|
||||
if (cacheable) {
|
||||
const cached = getCachedMarkdown(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
const cached = getCachedMarkdown(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
const sanitized = renderSanitizedMarkdown(renderInput, renderOptions);
|
||||
if (cacheable) {
|
||||
setCachedMarkdown(cacheKey, sanitized);
|
||||
}
|
||||
setCachedMarkdown(cacheKey, sanitized);
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,21 +93,6 @@ function listStoredComposerRows(
|
||||
}
|
||||
}
|
||||
|
||||
export function listStoredDraftScopes(state: ChatComposerScope): ReadonlySet<string> {
|
||||
return new Set(
|
||||
listStoredComposerRows(state).flatMap(({ scope, session }) =>
|
||||
session.draft
|
||||
? [
|
||||
storedChatOutboxScopeKey({
|
||||
sessionKey: scope.conversationKey,
|
||||
...(scope.routingAgentId ? { agentId: scope.routingAgentId } : {}),
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function listStoredChatOutboxes(state: ChatComposerScope): StoredChatOutbox[] {
|
||||
return listStoredComposerRows(state)
|
||||
.flatMap(({ scope, session }) =>
|
||||
@@ -133,12 +118,20 @@ export function listStoredChatOutboxes(state: ChatComposerScope): StoredChatOutb
|
||||
|
||||
export function summarizeStoredChatOutboxes(state: ChatComposerScope) {
|
||||
const idsByScope = new Map<string, { all: Set<string>; attention: Set<string> }>();
|
||||
for (const outbox of listStoredChatOutboxes(state)) {
|
||||
const ids = idsByScope.get(storedChatOutboxScopeKey(outbox)) ?? {
|
||||
const draftScopes = new Set<string>();
|
||||
for (const { scope, session } of listStoredComposerRows(state)) {
|
||||
const scopeKey = storedChatOutboxScopeKey({
|
||||
sessionKey: scope.conversationKey,
|
||||
...(scope.routingAgentId ? { agentId: scope.routingAgentId } : {}),
|
||||
});
|
||||
if (session.draft) {
|
||||
draftScopes.add(scopeKey);
|
||||
}
|
||||
const ids = idsByScope.get(scopeKey) ?? {
|
||||
all: new Set<string>(),
|
||||
attention: new Set<string>(),
|
||||
};
|
||||
for (const item of outbox.queue) {
|
||||
for (const item of session.queue ?? []) {
|
||||
if (!item.pendingRunId) {
|
||||
ids.all.add(item.id);
|
||||
if (item.sendState === "failed" || item.sendState === "unconfirmed") {
|
||||
@@ -147,7 +140,7 @@ export function summarizeStoredChatOutboxes(state: ChatComposerScope) {
|
||||
}
|
||||
}
|
||||
if (ids.all.size) {
|
||||
idsByScope.set(storedChatOutboxScopeKey(outbox), ids);
|
||||
idsByScope.set(scopeKey, ids);
|
||||
}
|
||||
}
|
||||
const countsByScope = new Map<string, number>();
|
||||
@@ -160,5 +153,5 @@ export function summarizeStoredChatOutboxes(state: ChatComposerScope) {
|
||||
attentionCountsByScope.set(scopeKey, ids.attention.size);
|
||||
}
|
||||
}
|
||||
return { countsByScope, attentionCountsByScope, total };
|
||||
return { countsByScope, attentionCountsByScope, draftScopes, total };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStorageMock } from "../../test-helpers/storage.ts";
|
||||
import {
|
||||
listStoredChatOutboxes,
|
||||
listStoredDraftScopes,
|
||||
summarizeStoredChatOutboxes,
|
||||
} from "./outbox-store-projection.ts";
|
||||
import { listStoredChatOutboxes, summarizeStoredChatOutboxes } from "./outbox-store-projection.ts";
|
||||
import {
|
||||
readProjectedOutboxStore,
|
||||
resolveStoredChatOutboxScope,
|
||||
@@ -245,7 +241,7 @@ describe("stored outbox summaries", () => {
|
||||
);
|
||||
const state = { settings: { gatewayUrl } };
|
||||
|
||||
expect([...listStoredDraftScopes(state)]).toEqual([
|
||||
expect([...summarizeStoredChatOutboxes(state).draftScopes]).toEqual([
|
||||
storedChatOutboxScopeKey(resolveStoredChatOutboxScope(state, "thread-draft")),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -239,12 +239,14 @@ export function groupSidebarSessionRows<Row extends SidebarGroupableRow>(
|
||||
const groups: Row[] = [];
|
||||
const coding: Row[] = [];
|
||||
const categories = new Map<string, Row[]>();
|
||||
const knownGroups: string[] = [];
|
||||
const people = new Map<string, SidebarSessionSection<Row>>();
|
||||
if (grouping === "category") {
|
||||
for (const name of options.knownGroups ?? []) {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed && !categories.has(trimmed)) {
|
||||
categories.set(trimmed, []);
|
||||
knownGroups.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,14 +315,9 @@ export function groupSidebarSessionRows<Row extends SidebarGroupableRow>(
|
||||
);
|
||||
}),
|
||||
);
|
||||
const knownGroups = [
|
||||
...new Set((options.knownGroups ?? []).map((name) => name.trim()).filter(Boolean)),
|
||||
];
|
||||
const orderedCategories = [
|
||||
...knownGroups.filter((name) => categories.has(name)),
|
||||
...[...categories.keys()]
|
||||
.filter((name) => !knownGroups.includes(name))
|
||||
.toSorted((a, b) => a.localeCompare(b)),
|
||||
...knownGroups,
|
||||
...[...categories.keys()].slice(knownGroups.length).toSorted((a, b) => a.localeCompare(b)),
|
||||
];
|
||||
const orderedSections: SidebarSessionSection<Row>[] = orderedCategories.map((category) => ({
|
||||
id: `category:${category}`,
|
||||
|
||||
@@ -373,8 +373,8 @@ export function resolveSessionNavigation(input: SessionNavigationInput): Session
|
||||
let activeRow = visibleSessions.find(matchesCurrentSession);
|
||||
if (!activeRow && activeSession && input.archivedFilter !== "archived") {
|
||||
// Deep-linked and archived sessions still need a visible selected row.
|
||||
activeRow = sortedSessions.find(matchesCurrentSession) ?? activeSession;
|
||||
visibleSessions = [activeRow, ...visibleSessions.filter((row) => row !== activeRow)];
|
||||
activeRow = activeSession;
|
||||
visibleSessions = [activeRow, ...visibleSessions];
|
||||
}
|
||||
return {
|
||||
currentSessionKey,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import {
|
||||
loadChatMetadata,
|
||||
peekChatMetadata,
|
||||
@@ -255,12 +254,12 @@ async function refreshChat(
|
||||
host.sessionsResult = host.sessions.state.result;
|
||||
host.sessionsResultAgentId = host.sessions.state.agentId;
|
||||
const sessionsResult = host.sessions.state.result;
|
||||
const rosterRow =
|
||||
sessionsResult?.sessions.find(
|
||||
(row) =>
|
||||
areUiSessionKeysEquivalent(row.key, history.sessionInfo?.key) ||
|
||||
areUiSessionKeysEquivalent(row.key, refreshedSessionKey),
|
||||
) ?? history.sessionInfo;
|
||||
const sessionInfo = sessionsResult?.sessions.find(
|
||||
(row) =>
|
||||
areUiSessionKeysEquivalent(row.key, history.sessionInfo?.key) ||
|
||||
areUiSessionKeysEquivalent(row.key, refreshedSessionKey),
|
||||
);
|
||||
const rosterRow = sessionInfo ?? history.sessionInfo;
|
||||
if (areUiSessionKeysEquivalent(rosterRow.key, refreshedSessionKey)) {
|
||||
host.selectedChatSessionArchived = rosterRow.archived === true;
|
||||
host.selectedChatSessionIncognito = rosterRow.incognito === true;
|
||||
@@ -278,11 +277,6 @@ async function refreshChat(
|
||||
// timestamp may still describe its prior terminal state during remount.
|
||||
return;
|
||||
}
|
||||
const sessionInfo = sessionsResult?.sessions.find(
|
||||
(row: GatewaySessionRow) =>
|
||||
areUiSessionKeysEquivalent(row.key, history.sessionInfo?.key) ||
|
||||
row.key === refreshedSessionKey,
|
||||
);
|
||||
if (!sessionInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,18 +117,6 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
|
||||
});
|
||||
const searchFiltering = props.searchOpen === true && Boolean(props.searchQuery?.trim());
|
||||
const persistedCanvasIdentities = new Set<string>();
|
||||
for (const message of history) {
|
||||
const source = extractChatMessagePreview(message);
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const baseIdentity = canvasPreviewBaseIdentity(message, source);
|
||||
if (baseIdentity) {
|
||||
// fetchMcpAppView assigns a fresh viewId to every invocation. Matching the call and
|
||||
// view therefore identifies the same preview while still tolerating a reused call ID.
|
||||
persistedCanvasIdentities.add(baseIdentity);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const msg = projectContextCompactionActivity(history[i]);
|
||||
const itemKey = historyKeys[i] ?? messageKey(msg, i);
|
||||
@@ -158,6 +146,12 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
|
||||
|
||||
const isToolResult = normalized.role.toLowerCase() === "toolresult";
|
||||
const persistedCanvasSource = isToolResult ? extractChatMessagePreview(msg) : null;
|
||||
if (persistedCanvasSource) {
|
||||
const identity = canvasPreviewBaseIdentity(msg, persistedCanvasSource);
|
||||
if (identity) {
|
||||
persistedCanvasIdentities.add(identity);
|
||||
}
|
||||
}
|
||||
const renderPersistedPreview =
|
||||
persistedCanvasSource != null &&
|
||||
(!searchFiltering || turnHasMatchingAssistant(history, i, props.searchQuery ?? ""));
|
||||
@@ -180,7 +174,10 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
|
||||
if (props.searchOpen && searchQuery.trim() && !messageMatchesSearchQuery(msg, searchQuery)) {
|
||||
continue;
|
||||
}
|
||||
if (!hasRenderableNormalizedMessage(msg) && normalized.role.toLowerCase() !== "assistant") {
|
||||
if (
|
||||
!hasRenderableNormalizedMessage(msg, normalized) &&
|
||||
normalized.role.toLowerCase() !== "assistant"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -552,8 +552,10 @@ export function collapseSequentialDuplicateMessages(items: ChatItem[]): ChatItem
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
export function hasRenderableNormalizedMessage(message: unknown): boolean {
|
||||
const normalized = safeNormalizeMessage(message);
|
||||
export function hasRenderableNormalizedMessage(
|
||||
message: unknown,
|
||||
normalized = safeNormalizeMessage(message),
|
||||
): boolean {
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1785,6 +1785,21 @@ describe("buildCachedChatItems working spark", () => {
|
||||
});
|
||||
|
||||
describe("buildCachedChatItems", () => {
|
||||
it("does not inspect ordinary transcript messages for tool previews", () => {
|
||||
const messages = [userMessage("hello", 1_000), assistantMessage("reply", 1_001)];
|
||||
const previewExtraction = vi.spyOn(toolCards, "extractToolCardsCached");
|
||||
|
||||
buildCachedChatItems(createProps({ paneId: "ordinary-transcript", messages }));
|
||||
|
||||
expect(
|
||||
previewExtraction.mock.calls.filter(
|
||||
([message, prefix]) =>
|
||||
messages.includes(message as (typeof messages)[number]) && prefix === "preview",
|
||||
),
|
||||
).toEqual([]);
|
||||
previewExtraction.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps consecutive user messages from different senders in separate groups", () => {
|
||||
const groups = messageGroups({
|
||||
messages: [
|
||||
|
||||
@@ -238,9 +238,9 @@ export function reduceChatSessionProjection(
|
||||
return next;
|
||||
};
|
||||
const preparedEvent =
|
||||
event.type === "messagePersisted"
|
||||
event.type === "messagePersisted" && handoff
|
||||
? { ...event, message: adopt(event.message, event.envelope ?? event) }
|
||||
: event.type === "snapshotLoaded"
|
||||
: event.type === "snapshotLoaded" && handoff
|
||||
? { ...event, messages: event.messages.map((message) => adopt(message)) }
|
||||
: event;
|
||||
let projection = current;
|
||||
|
||||
@@ -562,9 +562,11 @@ export function reconcileChatRunFromCurrentSessionRow(
|
||||
}
|
||||
|
||||
export function reconcileChatRunAfterSessionStatePublication(host: RunLifecycleHost): boolean {
|
||||
const row = currentSessionRow(host);
|
||||
if (host.chatRunId && row?.lastRunId === host.chatRunId) {
|
||||
return reconcileChatRunFromSessionRow(host, row, { publishRunStatus: false });
|
||||
if (host.chatRunId) {
|
||||
const row = currentSessionRow(host);
|
||||
if (row?.lastRunId === host.chatRunId) {
|
||||
return reconcileChatRunFromSessionRow(host, row, { publishRunStatus: false });
|
||||
}
|
||||
}
|
||||
// Both session subscriptions and direct event reconciliation can republish
|
||||
// canonical rows after the local terminal projection; guard both paths.
|
||||
|
||||
@@ -297,7 +297,7 @@ class SessionPrefetcher {
|
||||
resolveChatSnapshotKey(snapshot.snapshotHost, { sessionKey }),
|
||||
),
|
||||
);
|
||||
const rows = [...(snapshot.rows ?? [])].toSorted(
|
||||
const rows = (snapshot.rows ?? []).toSorted(
|
||||
(left, right) => sessionActivityAt(right) - sessionActivityAt(left),
|
||||
);
|
||||
const candidates: SessionPrefetchCandidate[] = [];
|
||||
|
||||
Reference in New Issue
Block a user