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:
Peter Steinberger
2026-08-25 16:32:12 -07:00
committed by GitHub
parent a74080d5d5
commit 0246aaf06d
24 changed files with 117 additions and 104 deletions
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -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: (
+3 -5
View File
@@ -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";
+2
View File
@@ -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();
+3
View File
@@ -143,6 +143,9 @@ function createApplicationTheme(
syncSystemThemeListener();
return {
get settings() {
return settings;
},
get mode() {
return settings.themeMode;
},
+2
View File
@@ -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;
+3 -5
View File
@@ -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;
+8
View File
@@ -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(
+7 -9
View File
@@ -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;
}
+13 -20
View File
@@ -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 };
}
+2 -6
View File
@@ -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")),
]);
});
+4 -7
View File
@@ -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}`,
+2 -2
View File
@@ -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,
+6 -12
View File
@@ -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;
}
+10 -13
View File
@@ -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;
}
+4 -2
View File
@@ -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;
}
+15
View File
@@ -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: [
+2 -2
View File
@@ -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;
+5 -3
View File
@@ -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.
+1 -1
View File
@@ -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[] = [];