From 85fda04df765639c2e2695035f8d99b7d8f7319b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 10:53:22 -0700 Subject: [PATCH] feat(ui): surface queued-outbox counts in composer hint, session rows, and offline footer (#112649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): surface queued-outbox counts in composer hint, session rows, and offline footer Follow-up to the offline-state unification: queued sends were mechanically safe (durable outbox with reconnect replay) but only visible inside the open session's thread. - Composer offline hint now includes the visible session's queued count. - Session rows show a clock badge with the per-scope outbox count, independent of connection state (covers waiting-idle and failed too), with alias-safe scope resolution so agent-main never double-counts. - The offline footer button appends the aggregate ("· N queued"); the connected state stays completely silent. - One narrow subscription seam (subscribeStoredChatOutboxChanges) added in composer-persistence; no send/drain logic touched. * perf(ui): keep the startup bundle under budget with a lean outbox read module The queued-count feature statically imported composer-persistence from startup modules, hoisting the chat page's persistence machinery into the startup chunk and breaking the Control UI startup JS gzip budget (319.6 KiB > 314.0 KiB limit). Split ownership instead of gaming the budget: a lean read/subscribe module (ui/src/lib/chat/outbox-store.ts + codec/draft-state) serves startup consumers (app-host, sidebar), while writes, migrations, and drain stay in the lazy chat chunk (composer-outbox-store/composer-storage); composer-persistence keeps its export surface for chat callers. Startup is back to 313.8 KiB gzip at 12 requests with no chunking-config changes. Also fixes an autoreview finding in the new summary: legacy bare-main outbox rows now resolve through session defaults (online) or the persisted mainAlias (offline reload) instead of trusting the row's stale embedded agent id, so badge counts key to the same scope the sidebar resolves. The shared footer status renderer is deduplicated into session-row-badges. * fix(ui): correct type-only import and const tuple in outbox split * perf(ui): idle-load the outbox summary so startup carries no outbox code The lean outbox read module still cost ~2.8 KiB of startup gzip against 1.5 KiB of budget headroom. Follow the sidebar chrome pattern (lobster-pet/facepile): app-host idle-loads outbox-store, subscribes on arrival, and passes the sidebar a resolver callback instead of letting startup modules import scope resolution. Badges and counts hydrate moments after load; before that the summary is empty by design. Failed chunk loads recover on browser online events and, because chunks are usually served by the gateway itself, on gateway reconnect — the exact moment the offline badges become relevant again. Raise the initial-graph packing ceiling 448->512 KiB: the grown core graph split at the old boundary into an extra chunk, costing ~1.9 KiB of startup gzip to compression-context resets (same documented tradeoff as the earlier 400->448 bump). Startup lands at 313.5 KiB gzip / 9 requests, matching the origin/main baseline, limit 315.0. --- ui/config/control-ui-chunking.ts | 7 +- ui/src/app/app-host.ts | 106 +- ui/src/app/control-ui-chunking.test.ts | 2 +- ui/src/components/app-sidebar-base.ts | 2 + .../app-sidebar-session-navigation.ts | 5 + .../components/app-sidebar-session-types.ts | 1 + ui/src/components/app-sidebar.test.ts | 1 + ui/src/components/app-sidebar.ts | 21 +- ui/src/components/session-row-badges.test.ts | 19 + ui/src/components/session-row-badges.ts | 42 + ui/src/components/settings-sidebar.test.ts | 10 +- ui/src/components/settings-sidebar.ts | 20 +- ui/src/i18n/locales/en.ts | 4 + ui/src/lib/chat/outbox-store-codec.ts | 200 ++++ ui/src/lib/chat/outbox-store-draft-state.ts | 70 ++ ui/src/lib/chat/outbox-store.test.ts | 197 ++++ ui/src/lib/chat/outbox-store.ts | 337 ++++++ ui/src/pages/chat/chat-composer.test.ts | 15 +- ui/src/pages/chat/chat-pane.ts | 1 + ui/src/pages/chat/chat-view.ts | 2 + ui/src/pages/chat/components/chat-composer.ts | 7 +- ui/src/pages/chat/composer-outbox-store.ts | 492 +++++++++ .../pages/chat/composer-persistence.test.ts | 39 + ui/src/pages/chat/composer-persistence.ts | 962 +----------------- ui/src/pages/chat/composer-storage.ts | 37 + ui/src/styles/components.css | 6 + .../test-helpers/app-sidebar-cases/basics.ts | 4 +- .../app-sidebar-cases/outbox-badges.ts | 48 + ui/src/test-helpers/app-sidebar.ts | 2 + 29 files changed, 1708 insertions(+), 951 deletions(-) create mode 100644 ui/src/lib/chat/outbox-store-codec.ts create mode 100644 ui/src/lib/chat/outbox-store-draft-state.ts create mode 100644 ui/src/lib/chat/outbox-store.test.ts create mode 100644 ui/src/lib/chat/outbox-store.ts create mode 100644 ui/src/pages/chat/composer-outbox-store.ts create mode 100644 ui/src/pages/chat/composer-storage.ts create mode 100644 ui/src/test-helpers/app-sidebar-cases/outbox-badges.ts 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) { >