diff --git a/ui/src/app/app-host-pairing-access.test.ts b/ui/src/app/app-host-pairing-access.test.ts index a6a576ae9d04..a612228fb16e 100644 --- a/ui/src/app/app-host-pairing-access.test.ts +++ b/ui/src/app/app-host-pairing-access.test.ts @@ -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; diff --git a/ui/src/app/app-host.dock-suppression.test.ts b/ui/src/app/app-host.dock-suppression.test.ts index 591e7285cd60..7cf784eab1b6 100644 --- a/ui/src/app/app-host.dock-suppression.test.ts +++ b/ui/src/app/app-host.dock-suppression.test.ts @@ -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; diff --git a/ui/src/app/app-shell-gateway.ts b/ui/src/app/app-shell-gateway.ts index c75bc49c1a7c..2cd51ad14852 100644 --- a/ui/src/app/app-shell-gateway.ts +++ b/ui/src/app/app-shell-gateway.ts @@ -28,10 +28,10 @@ export type StoredOutboxScopeHost = { }; export type OutboxStoreRuntime = { - listStoredDraftScopes: (state: StoredOutboxScopeHost) => ReadonlySet; summarizeStoredChatOutboxes: (state: StoredOutboxScopeHost) => { countsByScope: ReadonlyMap; attentionCountsByScope: ReadonlyMap; + draftScopes: ReadonlySet; total: number; }; resolveStoredChatOutboxScope: ( diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 3eb6e6f7ba75..43f1fa25c1a7 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -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"; diff --git a/ui/src/app/bootstrap.test.ts b/ui/src/app/bootstrap.test.ts index 0047faaeb8fe..dbad398c1978 100644 --- a/ui/src/app/bootstrap.test.ts +++ b/ui/src/app/bootstrap.test.ts @@ -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(); diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index c1f81cb7fcac..e26c33197b67 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -143,6 +143,9 @@ function createApplicationTheme( syncSystemThemeListener(); return { + get settings() { + return settings; + }, get mode() { return settings.themeMode; }, diff --git a/ui/src/app/context.ts b/ui/src/app/context.ts index 06bdf23cef85..47fdceae52b0 100644 --- a/ui/src/app/context.ts +++ b/ui/src/app/context.ts @@ -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; diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index 3a9734137d06..2e69ec3082b1 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -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; } diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index ec2ee87eb35c..55a106a3f4b7 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -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]; diff --git a/ui/src/components/app-sidebar-session-projection.test.ts b/ui/src/components/app-sidebar-session-projection.test.ts index ce39984b2596..ed8db46681db 100644 --- a/ui/src/components/app-sidebar-session-projection.test.ts +++ b/ui/src/components/app-sidebar-session-projection.test.ts @@ -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", () => { diff --git a/ui/src/components/app-sidebar-session-projection.ts b/ui/src/components/app-sidebar-session-projection.ts index e43af0388ce6..84532d978155 100644 --- a/ui/src/components/app-sidebar-session-projection.ts +++ b/ui/src/components/app-sidebar-session-projection.ts @@ -174,9 +174,9 @@ export class SidebarSessionProjection { }; this.previousCollapsedSections = new Set(input.collapsedSections); - const retainedKeys = new Set(); + 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; diff --git a/ui/src/components/markdown.test.ts b/ui/src/components/markdown.test.ts index 2cfd63a0406d..a1de97442aca 100644 --- a/ui/src/components/markdown.test.ts +++ b/ui/src/components/markdown.test.ts @@ -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( diff --git a/ui/src/components/markdown.ts b/ui/src/components/markdown.ts index 0cae9edb7e16..f793c749d26b 100644 --- a/ui/src/components/markdown.ts +++ b/ui/src/components/markdown.ts @@ -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; } diff --git a/ui/src/lib/chat/outbox-store-projection.ts b/ui/src/lib/chat/outbox-store-projection.ts index 1a95fd37a9b5..00a699368bf3 100644 --- a/ui/src/lib/chat/outbox-store-projection.ts +++ b/ui/src/lib/chat/outbox-store-projection.ts @@ -93,21 +93,6 @@ function listStoredComposerRows( } } -export function listStoredDraftScopes(state: ChatComposerScope): ReadonlySet { - 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; attention: Set }>(); - for (const outbox of listStoredChatOutboxes(state)) { - const ids = idsByScope.get(storedChatOutboxScopeKey(outbox)) ?? { + const draftScopes = new Set(); + 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(), attention: new Set(), }; - 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(); @@ -160,5 +153,5 @@ export function summarizeStoredChatOutboxes(state: ChatComposerScope) { attentionCountsByScope.set(scopeKey, ids.attention.size); } } - return { countsByScope, attentionCountsByScope, total }; + return { countsByScope, attentionCountsByScope, draftScopes, total }; } diff --git a/ui/src/lib/chat/outbox-store.test.ts b/ui/src/lib/chat/outbox-store.test.ts index eaad00d03dc0..00fe9ccae11b 100644 --- a/ui/src/lib/chat/outbox-store.test.ts +++ b/ui/src/lib/chat/outbox-store.test.ts @@ -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")), ]); }); diff --git a/ui/src/lib/sessions/grouping.ts b/ui/src/lib/sessions/grouping.ts index 501cdb4dc594..7d5c02f62e85 100644 --- a/ui/src/lib/sessions/grouping.ts +++ b/ui/src/lib/sessions/grouping.ts @@ -239,12 +239,14 @@ export function groupSidebarSessionRows( const groups: Row[] = []; const coding: Row[] = []; const categories = new Map(); + const knownGroups: string[] = []; const people = new Map>(); 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( ); }), ); - 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[] = orderedCategories.map((category) => ({ id: `category:${category}`, diff --git a/ui/src/lib/sessions/navigation.ts b/ui/src/lib/sessions/navigation.ts index 158ef4a1ef89..c9fc28f850b1 100644 --- a/ui/src/lib/sessions/navigation.ts +++ b/ui/src/lib/sessions/navigation.ts @@ -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, diff --git a/ui/src/pages/chat/chat-state-refresh.ts b/ui/src/pages/chat/chat-state-refresh.ts index e55ea8a4e14a..e65ebdfdce8b 100644 --- a/ui/src/pages/chat/chat-state-refresh.ts +++ b/ui/src/pages/chat/chat-state-refresh.ts @@ -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; } diff --git a/ui/src/pages/chat/chat-thread-build.ts b/ui/src/pages/chat/chat-thread-build.ts index 8de08b0ed211..d26d1c5c08be 100644 --- a/ui/src/pages/chat/chat-thread-build.ts +++ b/ui/src/pages/chat/chat-thread-build.ts @@ -117,18 +117,6 @@ export function buildChatItems(props: BuildChatItemsProps): Array(); - 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 { }); 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: [ diff --git a/ui/src/pages/chat/history-merge.ts b/ui/src/pages/chat/history-merge.ts index 5e3f28467c64..b497e85b8dfc 100644 --- a/ui/src/pages/chat/history-merge.ts +++ b/ui/src/pages/chat/history-merge.ts @@ -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; diff --git a/ui/src/pages/chat/run-lifecycle.ts b/ui/src/pages/chat/run-lifecycle.ts index 662162a6d03d..381dd6d8dd8a 100644 --- a/ui/src/pages/chat/run-lifecycle.ts +++ b/ui/src/pages/chat/run-lifecycle.ts @@ -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. diff --git a/ui/src/pages/chat/session-prefetch.ts b/ui/src/pages/chat/session-prefetch.ts index e03c9f79f4bc..5758bfce3233 100644 --- a/ui/src/pages/chat/session-prefetch.ts +++ b/ui/src/pages/chat/session-prefetch.ts @@ -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[] = [];