diff --git a/ui/config/control-ui-chunking.ts b/ui/config/control-ui-chunking.ts index 9fe74ca55a7a..917102dd6f1a 100644 --- a/ui/config/control-ui-chunking.ts +++ b/ui/config/control-ui-chunking.ts @@ -83,9 +83,10 @@ export const controlUiCodeSplitting = { normalizeModuleId(id).includes("/ui/src/") ? "control-ui-core" : "control-ui-foundation", tags: ["$initial"] as ["$initial"], priority: 10, - // 448 KiB packs the core graph into fewer chunks; the previous 400 KiB - // boundary split one core chunk in two, costing ~1.4 KiB startup gzip. - maxSize: 448 * 1024, + // 512 KiB packs the grown core graph into fewer chunks; the previous + // 448 KiB boundary split one core chunk in two, costing ~1.9 KiB startup + // gzip (same tradeoff as the earlier 400->448 bump). + maxSize: 512 * 1024, }, ], }; diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 797de73df43d..f5b3af2eecf4 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -53,8 +53,8 @@ import { copyToClipboard } from "../lib/clipboard.ts"; import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts"; import { searchForSession } from "../lib/sessions/index.ts"; -import { isTerminalAvailable } from "../lib/terminal-availability.ts"; import "../lib/toast.ts"; +import { isTerminalAvailable } from "../lib/terminal-availability.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import { SubscriptionsController } from "../lit/subscriptions-controller.ts"; import { findSettingsSearchBlocks } from "../pages/config/settings-search.ts"; @@ -123,6 +123,29 @@ type AppSidebarElement = HTMLElement & { // on every shell render. const ROUTE_IDS_WITHOUT_WORKBOARD = APP_ROUTE_IDS.filter((routeId) => routeId !== "workboard"); const AGENT_ROSTER_REFRESH_DEBOUNCE_MS = 100; +const EMPTY_OUTBOX_COUNT_FOR_SESSION = () => 0; + +type StoredOutboxScopeHost = { + settings: { gatewayUrl?: string | null }; + assistantAgentId?: string | null; + agentsList?: { defaultId?: string | null; mainKey?: string | null } | null; + hello?: { snapshot?: unknown } | null; +}; + +type OutboxStoreRuntime = { + summarizeStoredChatOutboxes: (state: StoredOutboxScopeHost) => { + countsByScope: ReadonlyMap; + total: number; + }; + resolveStoredChatOutboxScope: ( + state: StoredOutboxScopeHost, + sessionKey: string, + ) => { sessionKey: string; agentId?: string }; + storedChatOutboxScopeKey: (scope: { sessionKey: string; agentId?: string }) => string; + subscribeStoredChatOutboxChanges: (listener: () => void) => () => void; +}; + +let outboxStoreModuleLoad: Promise | null = null; function diffAgentRoster( previous: readonly GatewayAgentRow[], @@ -507,6 +530,9 @@ class OpenClawShell extends OpenClawLightDomElement { private sidebarWorkboardRuntimeLoad: Promise | null = null; private sidebarWorkboardEpoch = 0; private agentRosterRefreshTimer: ReturnType | null = null; + private outboxStoreRuntime: OutboxStoreRuntime | null = null; + private outboxStoreUnsubscribe: (() => void) | null = null; + private outboxStoreRetryAttempted = false; private lastNativeNavState: NativeNavState | undefined; private didConsiderNativeRouteRestore = false; private pendingNativeNewSession = false; @@ -631,6 +657,7 @@ class OpenClawShell extends OpenClawLightDomElement { override connectedCallback() { super.connectedCallback(); + this.scheduleOutboxStoreLoad(); this.nativeHistoryState = readNativeHistoryState(); this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, this.openPalette); @@ -678,11 +705,57 @@ class OpenClawShell extends OpenClawLightDomElement { window.removeEventListener("openclaw:native-new-session", this.handleNativeNewSession); window.removeEventListener(TERMINAL_PANEL_TOGGLE_EVENT, this.handleDeferredTerminalToggle); window.removeEventListener(BROWSER_PANEL_TOGGLE_EVENT, this.handleDeferredBrowserToggle); + window.removeEventListener("online", this.loadOutboxStore); + this.outboxStoreUnsubscribe?.(); + this.outboxStoreUnsubscribe = null; + this.outboxStoreRuntime = null; + this.outboxStoreRetryAttempted = false; setSettingsChangeListener(null); this.resetShellEpochState(); super.disconnectedCallback(); } + private scheduleOutboxStoreLoad() { + if ("requestIdleCallback" in window) { + requestIdleCallback(this.loadOutboxStore, { timeout: 3000 }); + } else { + setTimeout(this.loadOutboxStore, 1500); + } + } + + private readonly loadOutboxStore = () => { + outboxStoreModuleLoad ??= import("../lib/chat/outbox-store.ts") + .then((module): OutboxStoreRuntime => module) + .catch((error: unknown) => { + outboxStoreModuleLoad = null; + throw error; + }); + void outboxStoreModuleLoad + .then((runtime) => { + if (!this.isConnected) { + return; + } + window.removeEventListener("online", this.loadOutboxStore); + this.outboxStoreRetryAttempted = false; + this.outboxStoreRuntime = runtime; + this.outboxStoreUnsubscribe?.(); + this.outboxStoreUnsubscribe = runtime.subscribeStoredChatOutboxChanges(() => + this.requestUpdate(), + ); + this.requestUpdate(); + }) + .catch(() => { + if (!this.isConnected) { + return; + } + window.addEventListener("online", this.loadOutboxStore, { once: true }); + if (navigator.onLine && !this.outboxStoreRetryAttempted) { + this.outboxStoreRetryAttempted = true; + this.scheduleOutboxStoreLoad(); + } + }); + }; + private resetShellEpochState() { this.navDrawerOpen = false; this.navDrawerTrigger = null; @@ -1257,6 +1330,12 @@ class OpenClawShell extends OpenClawLightDomElement { this.ensureAgentsList(snapshot); this.ensureRuntimeConfig(snapshot); this.syncSidebarWorkboard(); + // Chunks are usually served by the gateway, so a failed idle load of the + // outbox module recovers on reconnect, not only on a browser online event. + if (snapshot.connected && !this.outboxStoreRuntime && outboxStoreModuleLoad === null) { + this.outboxStoreRetryAttempted = false; + this.loadOutboxStore(); + } } private syncSidebarWorkboard() { @@ -1470,6 +1549,28 @@ class OpenClawShell extends OpenClawLightDomElement { return nothing; } const gatewaySnapshot = context.gateway.snapshot; + const outboxScopeHost = { + settings: { gatewayUrl: context.gateway.connection.gatewayUrl }, + assistantAgentId: gatewaySnapshot.assistantAgentId, + agentsList: context.agents.state.agentsList, + hello: gatewaySnapshot.hello, + }; + const outboxStoreRuntime = this.outboxStoreRuntime; + const storedOutboxes = outboxStoreRuntime + ? outboxStoreRuntime.summarizeStoredChatOutboxes(outboxScopeHost) + : null; + const outboxCountForSession = outboxStoreRuntime + ? (sessionKey: string) => { + const scope = outboxStoreRuntime.resolveStoredChatOutboxScope( + outboxScopeHost, + sessionKey, + ); + return ( + storedOutboxes?.countsByScope.get(outboxStoreRuntime.storedChatOutboxScopeKey(scope)) ?? + 0 + ); + } + : EMPTY_OUTBOX_COUNT_FOR_SESSION; const navigationSnapshot = context.navigation.snapshot; const overlaySnapshot = context.overlays.snapshot; const terminalAvailable = isTerminalAvailable( @@ -1597,6 +1698,7 @@ class OpenClawShell extends OpenClawLightDomElement { activeSearch: this.routeState.location?.search ?? "", activeHash: this.routeState.location?.hash ?? "", offline: gatewaySnapshot.offlineStable, + queuedOutboxCount: storedOutboxes?.total ?? 0, lastError: gatewaySnapshot.lastError, version: context.config.current.serverVersion ?? @@ -1628,6 +1730,8 @@ class OpenClawShell extends OpenClawLightDomElement { .sessionKey=${this.activeSessionKey} .connected=${gatewaySnapshot.connected} .offline=${gatewaySnapshot.offlineStable} + .outboxCountForSession=${outboxCountForSession} + .queuedOutboxCount=${storedOutboxes?.total ?? 0} .lastError=${gatewaySnapshot.lastError} .terminalAvailable=${terminalAvailable} .catalogOpenTarget=${normalizeCatalogOpenTarget(uiSettings.catalogOpenTarget)} diff --git a/ui/src/app/control-ui-chunking.test.ts b/ui/src/app/control-ui-chunking.test.ts index 1ab15ae13159..52171e0f28b5 100644 --- a/ui/src/app/control-ui-chunking.test.ts +++ b/ui/src/app/control-ui-chunking.test.ts @@ -47,7 +47,7 @@ describe("Control UI build chunking", () => { expect(controlUiCodeSplitting.includeDependenciesRecursively).toBe(false); expect(controlUiCodeSplitting.groups[1]).toMatchObject({ tags: ["$initial"], - maxSize: 448 * 1024, + maxSize: 512 * 1024, }); }); diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index a558c9c257f3..548f53f58ef7 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -23,6 +23,8 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement { @property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[]; @property({ attribute: false }) connected = false; @property({ attribute: false }) offline = false; + @property({ attribute: false }) outboxCountForSession: (sessionKey: string) => number = () => 0; + @property({ attribute: false }) queuedOutboxCount = 0; @property({ attribute: false }) lastError: string | null = null; @property({ attribute: false }) terminalAvailable = false; @property({ attribute: false }) catalogOpenTarget: CatalogOpenTarget = "viewer"; diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index bc9821e8bc68..18450ce0ea70 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -121,6 +121,10 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi return this.sessionKey.trim() || this.context?.gateway.snapshot.sessionKey.trim() || ""; } + protected outboxCountForSessionKey(sessionKey: string): number { + return this.outboxCountForSession(sessionKey); + } + protected getSessionNavigationState() { const context = this.context; const routeSessionKey = this.getRouteSessionKey(); @@ -183,6 +187,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi cloudWorkerActive: isStoppableCloudWorkerPlacement(row.placement), hasAutomation: row.hasAutomation === true, pullRequest: context?.sessions.pullRequestSummary(row.key), + outboxCount: this.outboxCountForSessionKey(row.key), unread: row.archived !== true && row.unread === true, lastReadAt: row.lastReadAt, attention: diff --git a/ui/src/components/app-sidebar-session-types.ts b/ui/src/components/app-sidebar-session-types.ts index afa90431e7f2..3cc5ce7b0dfc 100644 --- a/ui/src/components/app-sidebar-session-types.ts +++ b/ui/src/components/app-sidebar-session-types.ts @@ -78,6 +78,7 @@ export type SidebarRecentSession = { cloudWorkerActive: boolean; hasAutomation: boolean; pullRequest?: SessionCatalogPullRequestSummary; + outboxCount?: number; unread: boolean; lastReadAt?: number; attention: SidebarSessionAttention; diff --git a/ui/src/components/app-sidebar.test.ts b/ui/src/components/app-sidebar.test.ts index ff66955cd9d9..a520e74cb179 100644 --- a/ui/src/components/app-sidebar.test.ts +++ b/ui/src/components/app-sidebar.test.ts @@ -14,6 +14,7 @@ import "../test-helpers/app-sidebar-cases/child-sessions.ts"; import "../test-helpers/app-sidebar-cases/group-mutations.ts"; import "../test-helpers/app-sidebar-cases/interactions.ts"; import "../test-helpers/app-sidebar-cases/narration.ts"; +import "../test-helpers/app-sidebar-cases/outbox-badges.ts"; import "../test-helpers/app-sidebar-cases/pull-request-state.ts"; import "../test-helpers/app-sidebar-cases/sidebar-scroll.ts"; import "../test-helpers/app-sidebar-cases/sessions.ts"; diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 4c431e0f02cb..714ba520d870 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -46,6 +46,7 @@ import { type LobsterLogoVisitDetail, } from "./lobster-pet-contract.ts"; import { redactLoginFailureError } from "./login-gate.ts"; +import { renderOfflineSidebarStatus, renderSessionRowBadges } from "./session-row-badges.ts"; const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "") ? "⌘K" @@ -209,6 +210,7 @@ class AppSidebar extends AppSidebarSessionListElement { const mainKey = this.selectedAgentMainSessionKey(agentId); const mainRow = this.mainSessionRow(agentId); const approvalNeeded = sessionHasPendingApproval(this.approvalBadgeSnapshot(), mainKey); + const outboxCount = this.outboxCountForSessionKey(mainKey); const active = this.activeRouteId === "chat" && areUiSessionKeysEquivalent(this.getRouteSessionKey(), mainKey); @@ -250,7 +252,7 @@ class AppSidebar extends AppSidebarSessionListElement { >${icons.layoutDashboard}` : nothing} - ${stateBadge !== nothing || approvalNeeded + ${stateBadge !== nothing || approvalNeeded || outboxCount > 0 ? html` ${stateBadge} ${approvalNeeded @@ -262,6 +264,7 @@ class AppSidebar extends AppSidebarSessionListElement { >${icons.alertTriangle}` : nothing} + ${renderSessionRowBadges({ hasAutomation: false, outboxCount })} ` : nothing} @@ -343,17 +346,11 @@ class AppSidebar extends AppSidebarSessionListElement { ? html` - + ${renderOfflineSidebarStatus({ + queuedOutboxCount: this.queuedOutboxCount, + reconnecting, + onRetry: () => this.onRetryConnect?.(), + })} ` : nothing} diff --git a/ui/src/components/session-row-badges.test.ts b/ui/src/components/session-row-badges.test.ts index 468fa7b683b9..58dfce8f3ecf 100644 --- a/ui/src/components/session-row-badges.test.ts +++ b/ui/src/components/session-row-badges.test.ts @@ -29,6 +29,25 @@ function renderBadges(placementState?: SessionPlacementState, workspaceConflictC } describe("session row placement badges", () => { + it("renders the durable outbox count and stays quiet when empty", () => { + render( + renderSessionRowBadges({ + hasAutomation: false, + outboxCount: 3, + }), + container, + ); + + const badge = container.querySelector(".session-row-badge--queued"); + expect(badge?.getAttribute("aria-label")).toBe("3 messages queued to send"); + expect(badge?.getAttribute("title")).toBe("3 messages queued to send"); + expect(badge?.textContent).toContain("3"); + expect(badge?.querySelector("svg")).not.toBeNull(); + + render(renderSessionRowBadges({ hasAutomation: false, outboxCount: 0 }), container); + expect(container.querySelector(".session-row-badges")).toBeNull(); + }); + it.each(["local", "reclaimed"] satisfies SessionPlacementState[])( "keeps %s placement visually quiet", (placementState) => { diff --git a/ui/src/components/session-row-badges.ts b/ui/src/components/session-row-badges.ts index e4136eb4d7ea..48bebb8ff8cf 100644 --- a/ui/src/components/session-row-badges.ts +++ b/ui/src/components/session-row-badges.ts @@ -42,6 +42,7 @@ export function renderSessionRowBadges(params: { hasAutomation: boolean; pullRequest?: SessionCatalogPullRequestSummary; hasApproval?: boolean; + outboxCount?: number; placementState?: SessionPlacementState; workspaceConflictCount?: number; }) { @@ -59,10 +60,18 @@ export function renderSessionRowBadges(params: { const conflictPlacementState = workspaceConflictCount > 0 ? params.placementState : undefined; const displayedPlacementState = cloudPlacementState ?? conflictPlacementState; const hasWorkspaceConflict = workspaceConflictCount > 0; + const outboxCount = Math.max(0, Math.floor(params.outboxCount ?? 0)); + const outboxLabel = + outboxCount > 0 + ? t(outboxCount === 1 ? "sessionsView.queuedMessage" : "sessionsView.queuedMessages", { + count: String(outboxCount), + }) + : ""; if ( !hasAutomation && !pullRequestLabel && !params.hasApproval && + outboxCount === 0 && !displayedPlacementState && !hasWorkspaceConflict ) { @@ -117,6 +126,15 @@ export function renderSessionRowBadges(params: { >${icons.alertTriangle}` : nothing} + ${outboxCount > 0 + ? html`${icons.clock}` + : nothing} ${displayedPlacementState || hasWorkspaceConflict ? html``; } + +export function renderOfflineSidebarStatus(props: { + queuedOutboxCount: number; + reconnecting: string; + title?: string; + onRetry: () => void; +}) { + const offline = t("common.offline"); + const count = props.queuedOutboxCount; + const queued = count ? t("connection.queuedCount", { count: String(count) }) : null; + return html``; +} diff --git a/ui/src/components/settings-sidebar.test.ts b/ui/src/components/settings-sidebar.test.ts index c183f0e40d38..812992740584 100644 --- a/ui/src/components/settings-sidebar.test.ts +++ b/ui/src/components/settings-sidebar.test.ts @@ -336,12 +336,13 @@ describe("settings sidebar search", () => { it("shows the offline retry action without an online status", () => { const onRetryConnect = vi.fn(); - const renderSidebar = (offline: boolean, lastError: string | null) => + const renderSidebar = (offline: boolean, lastError: string | null, queuedOutboxCount = 0) => render( renderSettingsSidebar({ basePath: "", activeRouteId: "config", offline, + queuedOutboxCount, lastError, version: "1.0.0", updateAvailable: null, @@ -357,13 +358,14 @@ describe("settings sidebar search", () => { container, ); - renderSidebar(false, null); + renderSidebar(false, null, 3); expect(container.querySelector(".sidebar-footer-bar__status")).toBeNull(); - renderSidebar(true, "connection refused?token=settings-secret"); + renderSidebar(true, "connection refused?token=settings-secret", 3); const button = container.querySelector(".sidebar-footer-bar__status"); expect(button?.title).toBe("connection refused?[redacted-credential]"); - expect(button?.getAttribute("aria-label")).toBe("Offline — Retry now"); + expect(button?.textContent).toContain("3 queued"); + expect(button?.getAttribute("aria-label")).toBe("Offline — Retry now — 3 queued"); button?.click(); expect(onRetryConnect).toHaveBeenCalledOnce(); }); diff --git a/ui/src/components/settings-sidebar.ts b/ui/src/components/settings-sidebar.ts index c82dfcbb8131..1663d1e1be95 100644 --- a/ui/src/components/settings-sidebar.ts +++ b/ui/src/components/settings-sidebar.ts @@ -18,6 +18,7 @@ import { t } from "../i18n/index.ts"; import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts"; import { icons } from "./icons.ts"; import { redactLoginFailureError } from "./login-gate.ts"; +import { renderOfflineSidebarStatus } from "./session-row-badges.ts"; import "./sidebar-update-card.ts"; type SettingsSidebarProps = { @@ -26,6 +27,7 @@ type SettingsSidebarProps = { activeSearch?: string; activeHash?: string; offline: boolean; + queuedOutboxCount?: number; lastError: string | null; version: string; updateAvailable: UpdateAvailable | null; @@ -298,18 +300,12 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) { >