From 3a5da4faa8c52da3f3867354718c0f82499836f2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Jul 2026 01:08:06 -0700 Subject: [PATCH] feat(ui): live agent activity subtitles on running sidebar sessions (#111221) * feat(ui): live agent activity subtitles on running sidebar sessions * refactor(ui): lazy-load narration controller off the startup chunk, unexport test-only symbols * chore(ui): raise startup JS budget to 312 KiB for the lazy narration feature * test(ui): restore real timers after narration controller cases --- scripts/check-control-ui-performance.mjs | 5 +- scripts/control-ui-mock-dev.ts | 32 + src/config/types.openclaw.ts | 2 + src/config/zod-schema.root-shape.ts | 1 + ui/src/app/app-host.ts | 1 + ui/src/app/server-prefs.test.ts | 10 + ui/src/app/server-prefs.ts | 4 + ui/src/app/settings.node.test.ts | 21 + ui/src/app/settings.ts | 7 + ui/src/components/app-sidebar-base.ts | 1 + ui/src/components/app-sidebar-session-list.ts | 42 +- .../app-sidebar-session-narration-element.ts | 91 ++ .../app-sidebar-session-narration.test.ts | 807 ++++++++++++++++++ .../app-sidebar-session-narration.ts | 675 +++++++++++++++ .../app-sidebar-session-navigation.ts | 1 + .../components/app-sidebar-session-types.ts | 1 + ui/src/components/app-sidebar.test.ts | 1 + .../components/session-row-subtitle.test.ts | 28 + ui/src/components/session-row-subtitle.ts | 45 + ui/src/components/sidebar-narration-line.ts | 34 + ui/src/i18n/locales/en.ts | 7 + ui/src/lib/chat/heartbeat-display.ts | 12 +- ui/src/pages/config/config-page.ts | 4 + ui/src/pages/config/settings-targets.ts | 1 + ui/src/pages/config/view.browser.test.ts | 20 + ui/src/pages/config/view.ts | 26 +- ui/src/styles/layout.css | 13 + .../app-sidebar-cases/narration.ts | 218 +++++ ui/src/test-helpers/app-sidebar.ts | 12 + .../control-ui-e2e.mock-gateway.test.ts | 99 ++- ui/src/test-helpers/control-ui-e2e.ts | 79 ++ 31 files changed, 2274 insertions(+), 26 deletions(-) create mode 100644 ui/src/components/app-sidebar-session-narration-element.ts create mode 100644 ui/src/components/app-sidebar-session-narration.test.ts create mode 100644 ui/src/components/app-sidebar-session-narration.ts create mode 100644 ui/src/components/session-row-subtitle.test.ts create mode 100644 ui/src/components/session-row-subtitle.ts create mode 100644 ui/src/components/sidebar-narration-line.ts create mode 100644 ui/src/test-helpers/app-sidebar-cases/narration.ts diff --git a/scripts/check-control-ui-performance.mjs b/scripts/check-control-ui-performance.mjs index 09cd22b3bb5..0d9f2ab79b0 100644 --- a/scripts/check-control-ui-performance.mjs +++ b/scripts/check-control-ui-performance.mjs @@ -12,7 +12,10 @@ const KIB = 1024; export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({ startupJsRequests: 18, startupCssRequests: 1, - startupJsGzipBytes: 310 * KIB, + // 312 KiB accompanies the live-narration sidebar feature (2026-07): the + // controller is a lazy chunk; only its thin element/pref wiring (~1.5 KiB) + // stays in startup, which exhausted the previous ratchet's headroom. + startupJsGzipBytes: 312 * KIB, // 45 KiB CSS ceilings maintainer-approved 2026-07 alongside the interleaved // sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline. startupCssGzipBytes: 45 * KIB, diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 67e2e832da4..e1fa1301755 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -39,6 +39,8 @@ const SESSION_PAGE_SIZE = 50; const TOTAL_MOCK_SESSIONS = 650; const TOTAL_TELEGRAM_SESSIONS = 180; const ATTENTION_FIXTURE_EXPIRES_AT = Date.parse("2099-01-01T00:00:00.000Z"); +const NARRATION_DEMO_SESSION_KEY = "agent:main:sidebar-narration-demo"; +const NARRATION_DEMO_RUN_ID = "mock-sidebar-narration-run"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const uiRoot = path.join(repoRoot, "ui"); @@ -1031,6 +1033,11 @@ async function createChatPickerScenario(): Promise sessionRow("agent:main:main", "Molty", baseTime - 1_000, { childSessions: ["agent:main:lisbon-trip"], }), + sessionRow(NARRATION_DEMO_SESSION_KEY, "Sidebar narration demo", baseTime - 15_000, { + hasActiveRun: true, + startedAt: baseTime - 45_000, + status: "running", + }), sessionRow("agent:main:tax-research", "Tax filing research", baseTime - 60_000, { hasActiveRun: true, status: "running", @@ -1510,6 +1517,31 @@ async function createChatPickerScenario(): Promise }, }, models: modelProviders.models, + repeatingSessionEvents: { + intervalMs: 3_000, + events: [ + { + event: "agent", + payload: { + // replace: the demo replays the same snapshot each cycle; without + // it the controller's cumulative-length dedupe drops the repeats. + data: { replace: true, text: "Rebasing onto main and rerunning the sidebar suite." }, + runId: NARRATION_DEMO_RUN_ID, + sessionKey: NARRATION_DEMO_SESSION_KEY, + stream: "assistant", + }, + }, + { + event: "session.tool", + payload: { + data: { name: "exec" }, + runId: NARRATION_DEMO_RUN_ID, + sessionKey: NARRATION_DEMO_SESSION_KEY, + stream: "tool", + }, + }, + ], + }, sessionArchiveFiltering: true, sessionKey: "agent:main:main", }; diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index db84f7ea790..e99b6e63f14 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -217,6 +217,8 @@ export type OpenClawConfig = { chatSendShortcut?: "enter" | "modifier-enter"; /** Follow-up handling while a run is active; unset uses the server queue mode. */ chatFollowUpMode?: "steer" | "queue"; + /** Show live agent activity beneath running Control UI sidebar sessions. */ + sidebarLiveActivity?: boolean; }; }; /** Secret providers, defaults, and ref-resolution settings. */ diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts index b327bde7aad..0188018638e 100644 --- a/src/config/zod-schema.root-shape.ts +++ b/src/config/zod-schema.root-shape.ts @@ -301,6 +301,7 @@ export const OpenClawSchemaShape = { chatPersistCommentary: z.boolean().optional(), chatSendShortcut: z.union([z.literal("enter"), z.literal("modifier-enter")]).optional(), chatFollowUpMode: z.union([z.literal("steer"), z.literal("queue")]).optional(), + sidebarLiveActivity: z.boolean().optional(), }) .optional(), }) diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 16cddd13394..110c110a490 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -1401,6 +1401,7 @@ class OpenClawShell extends OpenClawLightDomElement { .canPairDevice=${gatewaySnapshot.connected && hasOperatorAdminAccess(gatewaySnapshot.hello?.auth ?? null)} .sidebarEntries=${navigationSnapshot.sidebarEntries} + .sidebarLiveActivity=${uiSettings.sidebarLiveActivity !== false} .pinnedAgentIds=${navigationSnapshot.pinnedAgentIds} .themeMode=${context.theme.mode} .lobsterPetVisits=${uiSettings.lobsterPetVisits !== false} diff --git a/ui/src/app/server-prefs.test.ts b/ui/src/app/server-prefs.test.ts index bed92482269..59f9783d1fa 100644 --- a/ui/src/app/server-prefs.test.ts +++ b/ui/src/app/server-prefs.test.ts @@ -35,6 +35,7 @@ describe("server pref extraction", () => { locale: "de", chatShowThinking: false, chatSendShortcut: "modifier-enter", + sidebarLiveActivity: false, sidebarEntries: ["route:usage", "session:agent:main:test", "route:usage", 7], bogus: true, }), @@ -48,6 +49,7 @@ describe("server pref extraction", () => { locale: "de", chatShowThinking: false, chatSendShortcut: "modifier-enter", + sidebarLiveActivity: false, sidebarEntries: ["route:usage", "session:agent:main:test"], }); }); @@ -148,6 +150,14 @@ describe("changedServerUiPrefs", () => { ).toBeNull(); }); + it("syncs the live sidebar activity preference", () => { + const previous = loadSettings(); + expect(previous.sidebarLiveActivity).toBe(true); + expect(changedServerUiPrefs(previous, { ...previous, sidebarLiveActivity: false })).toEqual({ + sidebarLiveActivity: false, + }); + }); + it("syncs chat behavior prefs and pushes clearable resets as null", () => { const previous = loadSettings(); const withOverrides = { diff --git a/ui/src/app/server-prefs.ts b/ui/src/app/server-prefs.ts index a08ad341711..850f1a39a7c 100644 --- a/ui/src/app/server-prefs.ts +++ b/ui/src/app/server-prefs.ts @@ -100,6 +100,10 @@ const SYNCED_PREFS = { extract: (value) => normalizeSidebarEntries(value) ?? undefined, local: (settings) => settings.sidebarEntries, }), + sidebarLiveActivity: prefSpec({ + extract: (value) => (typeof value === "boolean" ? value : undefined), + local: (settings) => settings.sidebarLiveActivity !== false, + }), } as const; type SyncedPrefKey = keyof typeof SYNCED_PREFS; diff --git a/ui/src/app/settings.node.test.ts b/ui/src/app/settings.node.test.ts index 219da32c3f3..22adac89bf6 100644 --- a/ui/src/app/settings.node.test.ts +++ b/ui/src/app/settings.node.test.ts @@ -597,6 +597,27 @@ describe("loadSettings default gateway URL derivation", () => { expect(loadSettings().catalogOpenTarget).toBe("viewer"); }); + it("defaults live sidebar activity on and persists only an explicit opt-out", () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const gwUrl = expectedGatewayUrl(""); + const scopedKey = `openclaw.control.settings.v1:${gwUrl}`; + expect(loadSettings().sidebarLiveActivity).toBe(true); + + saveSettings({ ...loadSettings(), sidebarLiveActivity: false }); + expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}").sidebarLiveActivity).toBe(false); + expect(loadSettings().sidebarLiveActivity).toBe(false); + + saveSettings({ ...loadSettings(), sidebarLiveActivity: true }); + expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).not.toHaveProperty( + "sidebarLiveActivity", + ); + }); + it("persists only a normalized realtime Talk microphone id", () => { setTestLocation({ protocol: "https:", diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index 6c4f4fa737a..e2ba6f50d15 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -125,6 +125,7 @@ export type UiSettings = { navCollapsed: boolean; // Collapsible sidebar state navWidth: number; // Sidebar width when expanded (240–400px) sidebarEntries: string[]; // Ordered routes and pinned sessions below Home + sidebarLiveActivity?: boolean; // Latest activity under running sidebar sessions (default true) pinnedAgentIds?: string[]; // Agents surfaced first in the agent-chip quick switcher textScale?: TextScaleStop; // Browser-local text scale percentage customTheme?: ImportedCustomTheme; @@ -357,6 +358,7 @@ export function loadSettings(): UiSettings { navCollapsed: false, navWidth: NAV_WIDTH_DEFAULT, sidebarEntries: [...DEFAULT_SIDEBAR_ENTRIES], + sidebarLiveActivity: true, pinnedAgentIds: [], textScale: 100, composerHoldToRecord: true, @@ -454,6 +456,10 @@ export function loadSettings(): UiSettings { normalizeSidebarEntries(parsedRecord.sidebarEntries) ?? migratedSidebarEntries ?? defaults.sidebarEntries, + sidebarLiveActivity: + typeof parsed.sidebarLiveActivity === "boolean" + ? parsed.sidebarLiveActivity + : defaults.sidebarLiveActivity, pinnedAgentIds: normalizePinnedAgentIds(parsed.pinnedAgentIds), textScale: normalizeTextScale(parsed.textScale, defaults.textScale), customTheme: customTheme ?? undefined, @@ -592,6 +598,7 @@ function persistSettings(next: UiSettings, options: { selectGateway?: boolean } navCollapsed: next.navCollapsed, navWidth: next.navWidth, sidebarEntries: next.sidebarEntries, + ...(next.sidebarLiveActivity === false ? { sidebarLiveActivity: false } : {}), // Empty pin list is the default; only real pins persist. ...(next.pinnedAgentIds && next.pinnedAgentIds.length > 0 ? { pinnedAgentIds: next.pinnedAgentIds } diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index cb2f3a3b255..df8a742cf6a 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -25,6 +25,7 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement { @property({ attribute: false }) canPairDevice = false; @property({ attribute: false }) sessionKey = ""; @property({ attribute: false }) sidebarEntries: readonly string[] = DEFAULT_SIDEBAR_ENTRIES; + @property({ attribute: false }) sidebarLiveActivity = true; /** Agents surfaced first in the chip quick switcher when many exist. */ @property({ attribute: false }) pinnedAgentIds: readonly string[] = []; @property({ attribute: false }) themeMode: ThemeMode = "system"; diff --git a/ui/src/components/app-sidebar-session-list.ts b/ui/src/components/app-sidebar-session-list.ts index 4c4c347a193..0a2a23f1d59 100644 --- a/ui/src/components/app-sidebar-session-list.ts +++ b/ui/src/components/app-sidebar-session-list.ts @@ -10,11 +10,11 @@ import { openCatalogSessionInTerminal } from "../lib/sessions/catalog-terminal.t import { writeSessionDragData, writeSessionGroupDragData } from "../lib/sessions/drag.ts"; import { sidebarSectionHasHeader } from "../lib/sessions/grouping.ts"; import { normalizeAgentId } from "../lib/sessions/session-key.ts"; -import { AppSidebarMenusElement } from "./app-sidebar-menus.ts"; import { type CatalogBackingSessionDisplay, renderSessionCatalogGroups, } from "./app-sidebar-session-catalogs.ts"; +import { AppSidebarSessionNarrationElement } from "./app-sidebar-session-narration-element.ts"; import { limitSidebarSessionRows, loadStoredSidebarCatalogGrouping, @@ -28,16 +28,19 @@ import { icons } from "./icons.ts"; import { renderSessionAttentionIcon, renderSessionState, - sessionAttentionSubtitle, } from "./session-attention-presentation.ts"; import { resolveSessionIcon } from "./session-icon-registry.ts"; import { renderSessionRowBadges } from "./session-row-badges.ts"; +import { + renderSidebarSessionSubtitle, + resolveSidebarSessionSubtitle, +} from "./session-row-subtitle.ts"; import "./elapsed-time.ts"; const SIDEBAR_VISIBLE_CHILD_SESSION_LIMIT = 4; /** Session-list presentation and catalog renderer wiring. */ -export abstract class AppSidebarSessionListElement extends AppSidebarMenusElement { +export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarrationElement { @state() protected catalogProjectGrouping = loadStoredSidebarCatalogGrouping(); protected override willUpdate(changed: PropertyValues) { @@ -59,19 +62,20 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen display?: CatalogBackingSessionDisplay, ) { const label = display?.label ?? session.label; - const subtitle = - sessionAttentionSubtitle(session.attention) ?? - (display - ? display.subtitle - : session.subtitle && session.workSession && session.subtitle !== session.label - ? session.subtitle - : undefined); + const { subtitle, narration } = resolveSidebarSessionSubtitle({ + session, + hasDisplay: display !== undefined, + displaySubtitle: display?.subtitle, + sidebarLiveActivity: this.sidebarLiveActivity, + narrationLine: this.sidebarNarrationLines.get(session.key), + }); + const running = session.hasActiveRun || session.status === "running"; const meta = display?.meta ?? session.meta; const rowMeta = session.pinned ? "" : meta; const hasTrail = session.isChild && (session.runtimeMs != null || session.startedAt != null); const metaId = hasTrail ? sidebarSessionMetaId(session.key) : undefined; const menuSession = display ? { ...session, meta } : session; - const title = display?.title ?? [label, rowMeta].filter(Boolean).join(" · "); + const title = display?.title ?? [label, narration, rowMeta].filter(Boolean).join(" · "); // Pinned rows reposition the state badge into the nav-item slot; render // every state renderSessionState knows (spinner, unread, child terminal // badges) so pinning a subagent session cannot hide its outcome. @@ -87,7 +91,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen session.visuallyActive ? "sidebar-recent-session--active" : "", this.selectedSessionKeys.has(session.key) ? "sidebar-recent-session--selected" : "", session.pinned ? "session-row-host--pinned" : "", - session.hasActiveRun ? "session-row-host--running" : "", + running ? "session-row-host--running" : "", session.attention.kind === "error" ? "sidebar-recent-session--attention-danger" : session.attention.kind !== "none" @@ -145,9 +149,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen : nothing} ${label} - ${subtitle - ? html`${subtitle}` - : nothing} + ${renderSidebarSessionSubtitle({ subtitle, narration })} ${!session.isChild && sessionHasBoard(session.key) ? html` @@ -271,6 +272,11 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen child.runningChildCount > 0 || child.attention.kind !== "none", ); + } + + private renderSessionTree(session: SidebarRecentSession): TemplateResult { + const expanded = this.isSessionChildrenExpanded(session); + const visibleChildren = this.visibleSessionChildren(session); const hiddenChildCount = session.children.length - visibleChildren.length; return html` - ${renderLobsterPetSection(props)} ${renderChatPreferencesSection(props)} + ${renderSidebarPreferencesSection(props)} ${renderLobsterPetSection(props)} + ${renderChatPreferencesSection(props)}
diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index deb3707698e..7e4d18268be 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -3518,6 +3518,19 @@ wa-dropdown.sidebar-session-sort-menu::part(menu) { color: color-mix(in srgb, var(--danger) 86%, var(--text)); } +.sidebar-recent-session__subtitle--narration { + animation: sidebar-narration-arrive var(--duration-fast) ease-out; +} + +@keyframes sidebar-narration-arrive { + from { + opacity: 0.45; + } + to { + opacity: 1; + } +} + /* Kept for the agent-chip menu switcher rows (sessions list is single-agent now). */ .sidebar-agent-section__avatar { display: inline-flex; diff --git a/ui/src/test-helpers/app-sidebar-cases/narration.ts b/ui/src/test-helpers/app-sidebar-cases/narration.ts new file mode 100644 index 00000000000..8b1fca7ee20 --- /dev/null +++ b/ui/src/test-helpers/app-sidebar-cases/narration.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; +import { createGatewayHarness, createSessionsHarness, mountSidebar } from "../app-sidebar.ts"; +import { waitForFast } from "../wait-for.ts"; +import "../../components/app-sidebar.ts"; + +const defaults: SessionsListResult["defaults"] = { + modelProvider: null, + model: null, + contextTokens: null, +}; + +function runningRow(key: string, updatedAt: number): GatewaySessionRow { + return { + key, + kind: "direct", + label: `Run ${updatedAt}`, + updatedAt, + startedAt: updatedAt, + status: "running", + hasActiveRun: true, + }; +} + +function sessionsResult(rows: GatewaySessionRow[]): SessionsListResult { + return { + ts: 10, + path: "", + count: rows.length, + defaults, + sessions: rows, + }; +} + +describe("AppSidebar live narration", () => { + it("subscribes for a running row, renders prose, and cleans up when the run ends", async () => { + const key = "agent:main:narrated"; + const gateway = createGatewayHarness({} as GatewayBrowserClient); + const sessions = createSessionsHarness("main", [key]); + sessions.publishList({ result: sessionsResult([runningRow(key, 5)]), agentId: "main" }); + const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions); + sidebar.connected = true; + await sidebar.updateComplete; + + await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(1)); + expect(sessions.subscribeMessages).toHaveBeenCalledWith(key, { agentId: undefined }); + + gateway.publishEvent("chat", { + sessionKey: key, + state: "delta", + message: { + role: "assistant", + content: [ + { + type: "text", + text: "# Earlier work\n\nChecked the inputs. Final **verification** is running.", + }, + ], + }, + }); + + await waitForFast(() => + expect( + sidebar.querySelector(`[data-session-key="${key}"] .sidebar-recent-session__subtitle`) + ?.textContent, + ).toBe("Final verification is running."), + ); + const link = sidebar.querySelector( + `[data-session-key="${key}"] .sidebar-recent-session__link`, + ); + expect(link?.title).toContain("Final verification is running."); + expect(link?.querySelector("[aria-live]")).toBeNull(); + + sessions.publishList({ + result: sessionsResult([ + { ...runningRow(key, 5), hasActiveRun: false, status: "done", endedAt: 20 }, + ]), + agentId: "main", + }); + await waitForFast(() => expect(sessions.unsubscribeMessages).toHaveBeenCalledTimes(1)); + await sidebar.updateComplete; + expect( + sidebar.querySelector(`[data-session-key="${key}"] .sidebar-recent-session__subtitle`), + ).toBeNull(); + }); + + it("gives a pending question attention priority over a running narration", async () => { + const key = "agent:main:needs-answer"; + const gateway = createGatewayHarness({} as GatewayBrowserClient); + const sessions = createSessionsHarness("main", [key]); + sessions.publishList({ result: sessionsResult([runningRow(key, 5)]), agentId: "main" }); + const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions); + sidebar.connected = true; + await sidebar.updateComplete; + + // Republish inside the wait: the narration controller chunk loads lazily, + // so an event raced before its import resolves is intentionally dropped. + await waitForFast(() => { + gateway.publishEvent("chat", { + sessionKey: key, + state: "delta", + message: { + role: "assistant", + content: [{ type: "text", text: "Checking the remaining files." }], + }, + }); + expect( + sidebar.querySelector(`[data-session-key="${key}"] .sidebar-recent-session__subtitle`) + ?.textContent, + ).toBe("Checking the remaining files."); + }); + + gateway.publishEvent("question.requested", { + id: "question-narration-priority", + agentId: "main", + sessionKey: key, + questions: [{ id: "confirm", header: "Confirm", question: "Continue?", options: [] }], + createdAtMs: Date.now(), + expiresAtMs: Date.now() + 60_000, + status: "pending", + }); + await sidebar.updateComplete; + + const row = sidebar.querySelector(`[data-session-key="${key}"]`); + expect(row?.querySelector("[data-session-attention=question]")).not.toBeNull(); + expect(row?.querySelector(".sidebar-recent-session__subtitle")?.textContent).toBe( + "Waiting for your answer", + ); + expect(row?.textContent).not.toContain("Checking the remaining files."); + expect( + row?.querySelector(".sidebar-recent-session__link")?.title, + ).not.toContain("Checking the remaining files."); + }); + + it("keeps only the six newest running subscriptions and evicts the old boundary", async () => { + const keys = Array.from({ length: 7 }, (_, index) => `agent:main:run-${index + 1}`); + const gateway = createGatewayHarness({} as GatewayBrowserClient); + const sessions = createSessionsHarness("main", keys); + const rows = keys.map((key, index) => runningRow(key, index + 1)); + sessions.publishList({ result: sessionsResult(rows), agentId: "main" }); + const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions); + sidebar.connected = true; + await sidebar.updateComplete; + + await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(6)); + expect(sessions.subscribeMessages.mock.calls.map(([key]) => key)).toEqual( + expect.arrayContaining(keys.slice(1)), + ); + expect(sessions.subscribeMessages).not.toHaveBeenCalledWith(keys[0], expect.anything()); + + sessions.publishList({ + result: sessionsResult([{ ...rows[0]!, startedAt: 100 }, ...rows.slice(1)]), + agentId: "main", + }); + await waitForFast(() => expect(sessions.unsubscribeMessages).toHaveBeenCalledTimes(1)); + await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(7)); + expect(sessions.unsubscribeMessages.mock.calls[0]?.[0]).toMatchObject({ key: keys[1] }); + expect(sessions.subscribeMessages.mock.calls.at(-1)?.[0]).toBe(keys[0]); + }); + + it("stays inert when the synced preference is off", async () => { + const key = "agent:main:quiet"; + const gateway = createGatewayHarness({} as GatewayBrowserClient); + const sessions = createSessionsHarness("main", [key]); + sessions.publishList({ result: sessionsResult([runningRow(key, 1)]), agentId: "main" }); + const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions); + sidebar.sidebarLiveActivity = false; + sidebar.connected = true; + await sidebar.updateComplete; + + gateway.publishEvent("chat", { + sessionKey: key, + state: "delta", + message: { role: "assistant", content: [{ type: "text", text: "Should stay hidden" }] }, + }); + await sidebar.updateComplete; + + expect(sessions.subscribeMessages).not.toHaveBeenCalled(); + expect( + sidebar.querySelector(`[data-session-key="${key}"] .sidebar-recent-session__subtitle`), + ).toBeNull(); + }); + + it("skips the open chat subscription and resubscribes background runs after reconnect", async () => { + const openKey = "agent:main:open"; + const backgroundKey = "agent:main:background"; + const gateway = createGatewayHarness({} as GatewayBrowserClient); + const sessions = createSessionsHarness("main", [openKey, backgroundKey]); + sessions.publishList({ + result: sessionsResult([runningRow(openKey, 1), runningRow(backgroundKey, 2)]), + agentId: "main", + }); + const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions); + sidebar.activeRouteId = "chat"; + sidebar.sessionKey = openKey; + sidebar.connected = true; + await sidebar.updateComplete; + + await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(1)); + expect(sessions.subscribeMessages).toHaveBeenLastCalledWith(backgroundKey, { + agentId: undefined, + }); + + gateway.publish({ connected: false }); + sidebar.connected = false; + await sidebar.updateComplete; + await waitForFast(() => expect(sessions.unsubscribeMessages).toHaveBeenCalledTimes(1)); + + gateway.publish({ connected: true }); + sidebar.connected = true; + await sidebar.updateComplete; + await waitForFast(() => expect(sessions.subscribeMessages).toHaveBeenCalledTimes(2)); + expect(sessions.subscribeMessages).toHaveBeenLastCalledWith(backgroundKey, { + agentId: undefined, + }); + }); +}); diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index 7b264f14b58..b0ed2b7a356 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -26,11 +26,13 @@ type SessionDeleteResult = Awaited>; type SessionState = SessionCapability["state"]; export type SidebarLifecycleState = HTMLElement & { + activeRouteId?: string; connected: boolean; terminalAvailable: boolean; catalogOpenTarget: "viewer" | "terminal"; canPairDevice: boolean; sidebarEntries: readonly string[]; + sidebarLiveActivity: boolean; onUpdateSidebarEntries?: (entries: string[]) => void; pinnedAgentIds: readonly string[]; sessionKey: string; @@ -171,6 +173,12 @@ export function createSessionsHarness(agentId: string, keys: string[]) { ); const refresh = vi.fn(() => Promise.resolve()); const refreshReplacement = vi.fn(() => Promise.resolve()); + const subscribeMessages = vi.fn((key: string, options?: { agentId?: string | null }) => + Promise.resolve({ key, agentId: options?.agentId ?? null }), + ); + const unsubscribeMessages = vi.fn( + (_subscription: Parameters[0]) => Promise.resolve(), + ); const list = vi.fn((_options?: Parameters[0]) => Promise.resolve(null), ); @@ -197,6 +205,8 @@ export function createSessionsHarness(agentId: string, keys: string[]) { list, refresh, refreshReplacement, + subscribeMessages, + unsubscribeMessages, } as unknown as SessionCapability; const publish = (statePatch: Partial) => { state = { ...state, ...statePatch }; @@ -216,6 +226,8 @@ export function createSessionsHarness(agentId: string, keys: string[]) { list, refresh, refreshReplacement, + subscribeMessages, + unsubscribeMessages, publish, publishList(statePatch: Partial) { canonicalListRevision += 1; diff --git a/ui/src/test-helpers/control-ui-e2e.mock-gateway.test.ts b/ui/src/test-helpers/control-ui-e2e.mock-gateway.test.ts index bb9d7a0aa57..8e5db8dd95b 100644 --- a/ui/src/test-helpers/control-ui-e2e.mock-gateway.test.ts +++ b/ui/src/test-helpers/control-ui-e2e.mock-gateway.test.ts @@ -4,7 +4,12 @@ import { describe, expect, it } from "vitest"; import { createControlUiMockGatewayInitScript } from "./control-ui-e2e.ts"; -type ResponseFrame = { id?: string; type?: string; payload?: Record }; +type ResponseFrame = { + event?: string; + id?: string; + type?: string; + payload?: Record; +}; function flushMockTimers(): Promise { return new Promise((resolve) => { @@ -12,6 +17,12 @@ function flushMockTimers(): Promise { }); } +function waitForMockCycle(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 300); + }); +} + describe("mock gateway stateful config", () => { it("round-trips config.set through config.get with an advancing hash", async () => { const raw = '{\n "logging": {\n "level": "info"\n }\n}\n'; @@ -151,6 +162,92 @@ describe("mock gateway stateful config", () => { }); describe("mock gateway stateful sessions", () => { + it("cycles subscription-scoped session events and stops after unsubscribe", async () => { + const sessionKey = "agent:main:sidebar-narration-demo"; + const script = createControlUiMockGatewayInitScript({ + repeatingSessionEvents: { + intervalMs: 250, + events: [ + { + event: "agent", + payload: { + data: { + replace: true, + text: "Rebasing onto main and rerunning the sidebar suite.", + }, + sessionKey, + stream: "assistant", + }, + }, + { + event: "session.tool", + payload: { data: { name: "exec" }, sessionKey, stream: "tool" }, + }, + ], + }, + }); + window.sessionStorage.clear(); + // oxlint-disable-next-line typescript/no-implied-eval -- Executes the generated init script standalone, proving it captures no module closures. + new Function(script)(); + + const socket = new WebSocket("ws://mock-gateway"); + const frames: ResponseFrame[] = []; + socket.addEventListener("message", (event) => { + frames.push(JSON.parse(String((event as MessageEvent).data)) as ResponseFrame); + }); + await flushMockTimers(); + + socket.send( + JSON.stringify({ + type: "req", + id: "subscribe-1", + method: "sessions.messages.subscribe", + params: { key: sessionKey }, + }), + ); + await flushMockTimers(); + expect(frames.find((frame) => frame.id === "subscribe-1")?.payload).toEqual({ + key: sessionKey, + }); + expect(frames.find((frame) => frame.event === "agent")?.payload).toMatchObject({ + sessionKey, + stream: "assistant", + data: { text: "Rebasing onto main and rerunning the sidebar suite." }, + }); + + await waitForMockCycle(); + expect(frames.find((frame) => frame.event === "session.tool")?.payload).toMatchObject({ + sessionKey, + stream: "tool", + data: { name: "exec" }, + }); + + // Second assistant cycle must repeat: the replayed snapshot carries + // replace, so the narration controller re-renders instead of deduping. + await waitForMockCycle(); + const assistantFrames = frames.filter((frame) => frame.event === "agent"); + expect(assistantFrames.length).toBeGreaterThanOrEqual(2); + expect(assistantFrames.at(-1)?.payload).toMatchObject({ + sessionKey, + stream: "assistant", + data: { replace: true, text: "Rebasing onto main and rerunning the sidebar suite." }, + }); + + socket.send( + JSON.stringify({ + type: "req", + id: "unsubscribe-1", + method: "sessions.messages.unsubscribe", + params: { key: sessionKey }, + }), + ); + await flushMockTimers(); + const eventCount = frames.filter((frame) => frame.type === "event").length; + await waitForMockCycle(); + expect(frames.filter((frame) => frame.type === "event")).toHaveLength(eventCount); + socket.close(); + }); + it("keeps archive filtering opt-in for static session fixtures", async () => { const script = createControlUiMockGatewayInitScript({ methodResponses: { diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 417b4d970f1..1c695ff7b8e 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -65,6 +65,11 @@ export type ControlUiMockGatewayScenario = { methodResponses?: Record; /** Replayed in-flight run snapshot served by chat.history and chat.startup. */ inFlightRun?: { runId: string; text?: string; plan?: unknown } | null; + /** Subscription-scoped Gateway events replayed on a fixed browser-side cycle. */ + repeatingSessionEvents?: { + events: Array<{ event: "agent" | "session.tool"; payload: unknown }>; + intervalMs?: number; + }; /** Session run state served alongside history (hasActiveRun/activeRunIds). */ sessionInfo?: Record | null; /** Partition sessions.list fixtures by archived state after applying patches. */ @@ -261,6 +266,7 @@ function normalizeScenario( methodResponses: scenario.methodResponses ?? {}, inFlightRun: scenario.inFlightRun ?? null, models: scenario.models ?? [{ id: "gpt-5.5", name: "gpt-5.5", provider: "openai" }], + repeatingSessionEvents: scenario.repeatingSessionEvents ?? { events: [] }, sessionInfo: scenario.sessionInfo ?? null, sessionArchiveFiltering: scenario.sessionArchiveFiltering ?? false, sessionKey, @@ -367,7 +373,10 @@ function installControlUiMockGateway(input: { const requests: BrowserRequest[] = []; const methodResponseSequenceIndexes = new Map(); const sessionPatches = new Map>(); + const sessionMessageSubscriptions = new Set(); const sockets: Array<{ readonly url: string }> = []; + let sessionMessageEventIndex = 0; + let sessionMessageEventTimer: number | null = null; const offlineStateKey = "openclaw.control-ui-e2e.gatewayOffline"; // Gateway-owned custom group catalog (sessions.groups.*). Persisted in // sessionStorage so a page reload keeps the catalog the way the real @@ -626,6 +635,61 @@ function installControlUiMockGateway(input: { }; } + function stopRepeatingSessionEvents(): void { + if (sessionMessageEventTimer !== null) { + window.clearInterval(sessionMessageEventTimer); + sessionMessageEventTimer = null; + } + } + + function emitRepeatingSessionEvent(): void { + const events = scenario.repeatingSessionEvents.events; + if (events.length === 0) { + return; + } + const event = events[sessionMessageEventIndex % events.length]; + sessionMessageEventIndex += 1; + if (!event || !isRecord(event.payload) || typeof event.payload.sessionKey !== "string") { + return; + } + if (!sessionMessageSubscriptions.has(event.payload.sessionKey)) { + return; + } + MockWebSocket.latest?.deliver({ + event: event.event, + payload: event.payload, + seq: ++seq, + type: "event", + }); + } + + function startRepeatingSessionEvents(): void { + if (sessionMessageEventTimer !== null || scenario.repeatingSessionEvents.events.length === 0) { + return; + } + emitRepeatingSessionEvent(); + const intervalMs = Math.max(250, scenario.repeatingSessionEvents.intervalMs ?? 3_000); + sessionMessageEventTimer = window.setInterval(emitRepeatingSessionEvent, intervalMs); + } + + function updateSessionMessageSubscription(method: string, params: unknown): void { + const sessionKey = isRecord(params) && typeof params.key === "string" ? params.key : ""; + if (!sessionKey) { + return; + } + if (method === "sessions.messages.subscribe") { + sessionMessageSubscriptions.add(sessionKey); + startRepeatingSessionEvents(); + return; + } + if (method === "sessions.messages.unsubscribe") { + sessionMessageSubscriptions.delete(sessionKey); + if (sessionMessageSubscriptions.size === 0) { + stopRepeatingSessionEvents(); + } + } + } + function sessionRow() { return { contextTokens: null, @@ -906,6 +970,12 @@ function installControlUiMockGateway(input: { } case "sessions.subscribe": return { ok: true }; + case "sessions.messages.subscribe": + return { + key: isRecord(params) && typeof params.key === "string" ? params.key : "", + }; + case "sessions.messages.unsubscribe": + return { ok: true }; default: return {}; } @@ -992,6 +1062,8 @@ function installControlUiMockGateway(input: { return; } this.readyState = MockWebSocket.CLOSED; + sessionMessageSubscriptions.clear(); + stopRepeatingSessionEvents(); this.dispatchEvent(new CloseEvent("close", { code, reason })); } @@ -1019,6 +1091,9 @@ function installControlUiMockGateway(input: { ? { id, ok: false, error: mockError, type: "res" } : { id, ok: true, payload, type: "res" }, ); + if (!mockError) { + updateSessionMessageSubscription(method, frame.params); + } if ( method === "chat.abort" && isRecord(frame.params) && @@ -1153,6 +1228,10 @@ function installControlUiMockGateway(input: { (window as unknown as WindowWithGateway).openclawControlUiE2eGateway = exposed; window.WebSocket = MockWebSocket as unknown as typeof WebSocket; + window.addEventListener("pagehide", () => { + sessionMessageSubscriptions.clear(); + stopRepeatingSessionEvents(); + }); } export async function installMockGateway(