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
This commit is contained in:
Peter Steinberger
2026-07-19 01:08:06 -07:00
committed by GitHub
parent 4efcea1fd2
commit 3a5da4faa8
31 changed files with 2274 additions and 26 deletions
+4 -1
View File
@@ -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,
+32
View File
@@ -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<ControlUiMockGatewayScenario>
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<ControlUiMockGatewayScenario>
},
},
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",
};
+2
View File
@@ -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. */
+1
View File
@@ -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(),
})
+1
View File
@@ -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}
+10
View File
@@ -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 = {
+4
View File
@@ -100,6 +100,10 @@ const SYNCED_PREFS = {
extract: (value) => normalizeSidebarEntries(value) ?? undefined,
local: (settings) => settings.sidebarEntries,
}),
sidebarLiveActivity: prefSpec<boolean>({
extract: (value) => (typeof value === "boolean" ? value : undefined),
local: (settings) => settings.sidebarLiveActivity !== false,
}),
} as const;
type SyncedPrefKey = keyof typeof SYNCED_PREFS;
+21
View File
@@ -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:",
+7
View File
@@ -125,6 +125,7 @@ export type UiSettings = {
navCollapsed: boolean; // Collapsible sidebar state
navWidth: number; // Sidebar width when expanded (240400px)
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 }
+1
View File
@@ -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";
+24 -18
View File
@@ -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<this>) {
@@ -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}
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee">${label}</span>
${subtitle
? html`<span class="sidebar-recent-session__subtitle">${subtitle}</span>`
: nothing}
${renderSidebarSessionSubtitle({ subtitle, narration })}
</span>
${!session.isChild && sessionHasBoard(session.key)
? html`<span
@@ -253,13 +255,12 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
return keyed(session.key, row);
}
private renderSessionTree(session: SidebarRecentSession): TemplateResult {
const expanded = this.isSessionChildrenExpanded(session);
protected visibleSessionChildren(session: SidebarRecentSession): readonly SidebarRecentSession[] {
const showAllChildren = this.fullyShownChildSessionKeys.has(session.key);
// The cap hides quiet children only: the active branch and any branch with
// live runs (runningChildCount is transitive) must stay visible, or an
// auto-expanded parent would omit its own selection or a running session.
const visibleChildren = showAllChildren
return showAllChildren
? session.children
: session.children.filter(
(child, index) =>
@@ -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`<div class="sidebar-session-tree" data-session-tree=${session.key}>
${this.renderRecentSession(session)}
@@ -0,0 +1,91 @@
import { state } from "lit/decorators.js";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import { AppSidebarMenusElement } from "./app-sidebar-menus.ts";
import type {
SidebarNarrationSyncInput,
SidebarSessionNarrationController,
} from "./app-sidebar-session-narration.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
/** Gateway subscription and reactive narration state for the session-list renderer. */
export abstract class AppSidebarSessionNarrationElement extends AppSidebarMenusElement {
@state() protected sidebarNarrationLines: ReadonlyMap<string, string> = new Map();
// Lazy: the controller pulls core token-suppression modules that must stay
// out of the startup chunk (QA smoke startup-JS budget). It loads on the
// first update with the preference enabled; earlier events are safely
// dropped because the controller aligns from cumulative snapshots.
private narration: SidebarSessionNarrationController | null = null;
private narrationLoad: Promise<void> | null = null;
private readonly narrationSubscriptions = new SubscriptionsController(this);
protected abstract visibleSessionChildren(
session: SidebarRecentSession,
): readonly SidebarRecentSession[];
private visibleNarrationRowsInOrder(): SidebarRecentSession[] {
const rows: SidebarRecentSession[] = [];
const append = (session: SidebarRecentSession) => {
rows.push(session);
if (this.isSessionChildrenExpanded(session)) {
this.visibleSessionChildren(session).forEach(append);
}
};
this.visibleSessionRowsInOrder().forEach(append);
return rows;
}
constructor() {
super();
this.narrationSubscriptions.effect(
() => this.context?.gateway,
(gateway) => gateway.subscribeEvents((event) => this.narration?.handleEvent(event)),
);
}
override disconnectedCallback() {
this.narration?.disconnect();
super.disconnectedCallback();
}
private narrationSyncInput(): SidebarNarrationSyncInput {
const gateway = this.context?.gateway.snapshot;
return {
enabled: this.sidebarLiveActivity,
connected: this.connected && gateway?.connected === true,
connectionIdentity: gateway?.client ?? null,
source: this.context?.sessions ?? null,
rows: this.visibleNarrationRowsInOrder(),
openSessionKey: this.activeRouteId === "chat" ? this.getRouteSessionKey() : "",
agentId: this.selectedAgentIdForSessions(),
};
}
private ensureNarrationController(): void {
if (this.narration || this.narrationLoad) {
return;
}
this.narrationLoad = import("./app-sidebar-session-narration.ts").then((module) => {
this.narrationLoad = null;
// The element may have left the DOM while the chunk loaded.
if (!this.isConnected) {
return;
}
this.narration = new module.SidebarSessionNarrationController((lines) => {
this.sidebarNarrationLines = lines;
});
this.narration.sync(this.narrationSyncInput());
});
}
override updated() {
super.updated();
if (!this.narration) {
if (this.sidebarLiveActivity) {
this.ensureNarrationController();
}
return;
}
this.narration.sync(this.narrationSyncInput());
}
}
@@ -0,0 +1,807 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayEventFrame } from "../api/gateway.ts";
import type { SessionCapability } from "../lib/sessions/index.ts";
import { SidebarSessionNarrationController } from "./app-sidebar-session-narration.ts";
import { deriveSidebarNarrationLine } from "./sidebar-narration-line.ts";
// Mirrors the controller-internal throttle; asserting through timers keeps the
// constant unexported (production-only export policy).
const SIDEBAR_NARRATION_THROTTLE_MS = 2_000;
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
function runningRow(key: string): SidebarRecentSession {
return {
key,
label: "Run",
meta: "now",
href: "#",
active: false,
visuallyActive: false,
hasActiveRun: true,
modelSelectionLocked: false,
pinned: false,
cloudWorkerActive: false,
hasAutomation: false,
unread: false,
attention: { kind: "none" },
startedAt: 1,
childSessionKeys: [],
children: [],
isChild: false,
loadingChildren: false,
containsActiveDescendant: false,
runningChildCount: 0,
failedChildCount: 0,
};
}
function gatewayEvent(eventName: string, payload: unknown): GatewayEventFrame {
return { event: eventName, payload } as GatewayEventFrame;
}
describe("sidebar narration derivation", () => {
it("uses the last paragraph and sentence while removing markdown", () => {
expect(
deriveSidebarNarrationLine(
"# Plan\n\nFirst **check** finished.\n\n```ts\nconst answer = 1;\n```\nFinal _verification_ is running.",
),
).toBe("Final verification is running.");
});
it("collapses whitespace and ellipsizes long fragments", () => {
const line = deriveSidebarNarrationLine(`Earlier.\n\n- ${"result ".repeat(30)}`);
expect(line).toHaveLength(120);
expect(line.endsWith("…")).toBe(true);
expect(line).not.toContain(" ");
});
});
describe("SidebarSessionNarrationController", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(10_000);
});
afterEach(() => {
// isolate:false shares the worker clock: a leaked fake timer deterministically
// times out unrelated later files (seen: chat-background-tasks 60s hangs).
vi.useRealTimers();
});
it("publishes assistant commentary and throttles a newer tool signal", async () => {
const subscribeMessages = vi.fn(() =>
Promise.resolve({ key: "agent:main:run", agentId: null }),
);
const unsubscribeMessages = vi.fn(() => Promise.resolve());
const source = { subscribeMessages, unsubscribeMessages } as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
const connectionIdentity = {};
controller.sync({
enabled: true,
connected: true,
connectionIdentity,
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
await Promise.resolve();
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: { phase: "commentary", text: "**Reading** files.", delta: "**Reading** files." },
}),
);
controller.handleEvent(
gatewayEvent("session.tool", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "tool",
data: { phase: "start", name: "read" },
}),
);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Reading files.");
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS - 1);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Reading files.");
await vi.advanceTimersByTimeAsync(1);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Using read");
controller.disconnect();
expect(unsubscribeMessages).toHaveBeenCalledWith({
key: "agent:main:run",
agentId: null,
});
expect(updates.at(-1)?.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
});
it("seeds a mid-run chat subscription from the cumulative message snapshot", () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "agent:main:run",
runId: "run-1",
state: "delta",
deltaText: "les now.",
message: {
role: "assistant",
content: [{ type: "text", text: "Reading files now." }],
},
}),
);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Reading files now.");
});
it("normalizes raw assistant events before publishing narration", () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: {
text: [
"Visible work is complete.",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"private runtime details",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
"[[audio_as_voice]]",
"REPLY_SKIP",
].join("\n"),
},
}),
);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible work is complete.");
});
it("removes a trailing heartbeat token from a mixed visible response", () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: {
text: `${"Visible progress continues. ".repeat(16)}Final visible status. HEARTBEAT_OK`,
},
}),
);
const line = updates.at(-1)?.get("agent:main:run");
expect(line).toBe("Final visible status.");
expect(line).not.toContain("HEARTBEAT_OK");
});
it("keeps a truncated internal block hidden until its closing delimiter arrives", async () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: {
text: `Visible setup.\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\n${"private runtime detail ".repeat(1_000)}`,
},
}),
);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible setup.");
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: { delta: "\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nFinal bounded line." },
}),
);
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Final bounded line.");
});
it("holds a partial internal delimiter until its next fragment proves the boundary", () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: { text: "Visible setup.\n<<<BEGIN_OPENCLAW_INTERNAL_CONT" },
}),
);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible setup.");
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: { delta: "EXT>>>\nprivate runtime detail" },
}),
);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible setup.");
});
it("resets internal streaming state when a chat replacement is followed by deltas", async () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "agent:main:run",
runId: "run-1",
state: "delta",
deltaText: "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nprivate runtime text",
}),
);
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "agent:main:run",
runId: "run-1",
state: "delta",
replace: true,
deltaText: "Replacement",
}),
);
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "agent:main:run",
runId: "run-1",
state: "delta",
deltaText: " now.",
}),
);
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Replacement now.");
});
it("keeps an outer internal block hidden after its opening delimiter leaves the raw buffer", async () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: {
text: [
"Visible setup.",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"private outer runtime detail ".repeat(1_000),
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"private nested runtime detail ".repeat(1_000),
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
].join("\n"),
},
}),
);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible setup.");
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: { delta: "\nStill private after the nested block." },
}),
);
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible setup.");
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: { delta: "\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nFinal public line." },
}),
);
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Final public line.");
});
it("replaces stale assistant narration when an agent event requests replacement", async () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: { text: "Draft answer." },
}),
);
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: { replace: true, text: "Corrected answer." },
}),
);
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Corrected answer.");
});
it("stays silent on a mid-run join until a cumulative snapshot aligns the stream", async () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
// First observed event is a bare delta: it could be the inside of an
// internal-context block whose opening delimiter predates the join.
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "agent:main:run",
runId: "run-1",
deltaText: "secret internal continuation.",
}),
);
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
expect(updates.at(-1)?.has("agent:main:run") ?? false).toBe(false);
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "agent:main:run",
runId: "run-1",
deltaText: " Visible update.",
message: { role: "assistant", content: "Public progress line. Visible update." },
}),
);
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Visible update.");
});
it("retracts the shown line when a chat replacement is empty", async () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "agent:main:run",
runId: "run-1",
deltaText: "Queued draft that gets withdrawn.",
message: { role: "assistant", content: "Queued draft that gets withdrawn." },
}),
);
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Queued draft that gets withdrawn.");
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "agent:main:run",
runId: "run-1",
deltaText: "",
replace: true,
}),
);
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
expect(updates.at(-1)?.has("agent:main:run")).toBe(false);
});
it("retracts the shown line when a replacement reduces to suppressed content", async () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: { text: "Draft that gets withdrawn." },
}),
);
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Draft that gets withdrawn.");
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: { replace: true, text: "HEARTBEAT_OK" },
}),
);
controller.handleEvent(
gatewayEvent("agent", {
sessionKey: "agent:main:run",
runId: "run-1",
stream: "assistant",
data: { replace: true, text: "" },
}),
);
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
expect(updates.at(-1)?.has("agent:main:run")).toBe(false);
});
it("does not let a stale subscribe completion remove replacement ownership", async () => {
const completions: Array<{
resolve: (subscription: { key: string; agentId: null }) => void;
promise: Promise<{ key: string; agentId: null }>;
}> = [];
const subscribeMessages = vi.fn(() => {
let resolve!: (subscription: { key: string; agentId: null }) => void;
const promise = new Promise<{ key: string; agentId: null }>((resolvePromise) => {
resolve = resolvePromise;
});
completions.push({ resolve, promise });
return promise;
});
const unsubscribeMessages = vi.fn(() => Promise.resolve());
const source = { subscribeMessages, unsubscribeMessages } as unknown as SessionCapability;
const controller = new SidebarSessionNarrationController(() => undefined);
const base = {
enabled: true,
connected: true,
connectionIdentity: {},
source,
openSessionKey: "",
agentId: "main",
};
controller.sync({ ...base, rows: [runningRow("agent:main:run")] });
controller.sync({ ...base, rows: [] });
controller.sync({ ...base, rows: [runningRow("agent:main:run")] });
expect(subscribeMessages).toHaveBeenCalledTimes(2);
completions[1]?.resolve({ key: "agent:main:run", agentId: null });
await Promise.resolve();
completions[0]?.resolve({ key: "agent:main:run", agentId: null });
await Promise.resolve();
expect(unsubscribeMessages).not.toHaveBeenCalled();
controller.disconnect();
expect(unsubscribeMessages).toHaveBeenCalledTimes(1);
});
it("rebinds an active global session when the selected agent changes", async () => {
const subscribeMessages = vi.fn((key: string, options?: { agentId?: string | null }) =>
Promise.resolve({ key, agentId: options?.agentId ?? null }),
);
const unsubscribeMessages = vi.fn(() => Promise.resolve());
const source = { subscribeMessages, unsubscribeMessages } as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
const base = {
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("global")],
openSessionKey: "",
};
controller.sync({ ...base, agentId: "main" });
await Promise.resolve();
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "global",
agentId: "main",
state: "delta",
deltaText: "Old agent work.",
message: { role: "assistant", content: "Old agent work." },
}),
);
expect(updates.at(-1)?.get("global")).toBe("Old agent work.");
controller.sync({ ...base, agentId: "research" });
await Promise.resolve();
expect(unsubscribeMessages).toHaveBeenCalledWith({ key: "global", agentId: "main" });
expect(subscribeMessages).toHaveBeenLastCalledWith("global", { agentId: "research" });
expect(updates.at(-1)?.has("global")).toBe(false);
});
it("resets accumulated deltas when a new run starts for the same session", () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "agent:main:run",
runId: "first",
state: "delta",
deltaText: "Unfinished old work",
message: { role: "assistant", content: "Unfinished old work" },
}),
);
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "agent:main:run",
runId: "second",
state: "delta",
deltaText: "New run work.",
message: { role: "assistant", content: "New run work." },
}),
);
expect(updates.at(-1)?.get("agent:main:run")).toBe("New run work.");
});
it("cleans up a pending subscription after a same-connection source swap", async () => {
let resolveFirst!: (subscription: { key: string; agentId: null }) => void;
const firstSource = {
subscribeMessages: vi.fn(
() =>
new Promise<{ key: string; agentId: null }>((resolve) => {
resolveFirst = resolve;
}),
),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const secondSource = {
subscribeMessages: vi.fn(),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const controller = new SidebarSessionNarrationController(() => undefined);
const connectionIdentity = {};
controller.sync({
enabled: true,
connected: true,
connectionIdentity,
source: firstSource,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.sync({
enabled: true,
connected: true,
connectionIdentity,
source: secondSource,
rows: [],
openSessionKey: "",
agentId: "main",
});
resolveFirst({ key: "agent:main:run", agentId: null });
await Promise.resolve();
expect(firstSource.unsubscribeMessages).toHaveBeenCalledWith({
key: "agent:main:run",
agentId: null,
});
});
it("does not hand an old agent's global subscription to the open chat", async () => {
const subscribeMessages = vi.fn((key: string, options?: { agentId?: string | null }) =>
Promise.resolve({ key, agentId: options?.agentId ?? null }),
);
const unsubscribeMessages = vi.fn(() => Promise.resolve());
const source = { subscribeMessages, unsubscribeMessages } as unknown as SessionCapability;
const controller = new SidebarSessionNarrationController(() => undefined);
const base = {
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("global")],
};
controller.sync({ ...base, openSessionKey: "", agentId: "main" });
await Promise.resolve();
controller.sync({ ...base, openSessionKey: "global", agentId: "research" });
expect(unsubscribeMessages).toHaveBeenCalledWith({ key: "global", agentId: "main" });
});
it("keeps the newest sentence after a response exceeds the retained tail", async () => {
const source = {
subscribeMessages: vi.fn(() => Promise.resolve({ key: "agent:main:run", agentId: null })),
unsubscribeMessages: vi.fn(() => Promise.resolve()),
} as unknown as SessionCapability;
const updates: Array<ReadonlyMap<string, string>> = [];
const controller = new SidebarSessionNarrationController((lines) => updates.push(lines));
controller.sync({
enabled: true,
connected: true,
connectionIdentity: {},
source,
rows: [runningRow("agent:main:run")],
openSessionKey: "",
agentId: "main",
});
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "agent:main:run",
runId: "long",
state: "delta",
deltaText: `Preamble ${"x".repeat(20_000)}`,
message: { role: "assistant", content: `Preamble ${"x".repeat(20_000)}` },
}),
);
await vi.advanceTimersByTimeAsync(SIDEBAR_NARRATION_THROTTLE_MS);
controller.handleEvent(
gatewayEvent("chat", {
sessionKey: "agent:main:run",
runId: "long",
state: "delta",
deltaText: ". Final bounded line.",
message: {
role: "assistant",
content: `Preamble ${"x".repeat(20_000)}. Final bounded line.`,
},
}),
);
expect(updates.at(-1)?.get("agent:main:run")).toBe("Final bounded line.");
});
});
@@ -0,0 +1,675 @@
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import {
INTERNAL_RUNTIME_CONTEXT_BEGIN,
INTERNAL_RUNTIME_CONTEXT_END,
stripInternalRuntimeContext,
} from "../../../src/agents/internal-runtime-context.js";
import {
isSuppressedControlReplyLeadFragment,
isSuppressedControlReplyText,
stripSuppressedControlReplyToken,
} from "../../../src/gateway/control-reply-text.js";
import { stripInlineDirectiveTagsForDisplay } from "../../../src/utils/directive-tags.js";
import type { GatewayEventFrame } from "../api/gateway.ts";
import { t } from "../i18n/index.ts";
import { stripHeartbeatTokenForDisplay } from "../lib/chat/heartbeat-display.ts";
import { extractText } from "../lib/chat/message-extract.ts";
import type { SessionCapability } from "../lib/sessions/index.ts";
import {
areUiSessionKeysEquivalent,
isUiGlobalSessionKey,
normalizeAgentId,
} from "../lib/sessions/session-key.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import { deriveSidebarNarrationLine } from "./sidebar-narration-line.ts";
const SIDEBAR_NARRATION_SUBSCRIPTION_LIMIT = 6;
const SIDEBAR_NARRATION_THROTTLE_MS = 2_000;
const SIDEBAR_NARRATION_BUFFER_CHARS = 16_384;
type SessionMessageSubscription = Awaited<ReturnType<SessionCapability["subscribeMessages"]>>;
type NarrationSubscription = {
source: SessionCapability;
subscription: SessionMessageSubscription;
};
type PendingSubscription = {
agentId: string | null;
connectionIdentity: object;
source: SessionCapability;
operationId: symbol;
};
type NarrationActivity = { kind: "text"; text: string } | { kind: "line"; line: string };
type ThrottledLine = {
lastPublishedAt: number;
pending: NarrationActivity | null;
timer: ReturnType<typeof globalThis.setTimeout> | null;
};
export type SidebarNarrationSyncInput = {
enabled: boolean;
connected: boolean;
connectionIdentity: object | null;
source: SessionCapability | null;
rows: readonly SidebarRecentSession[];
openSessionKey: string;
agentId: string;
};
function normalizeSidebarNarrationText(text: string): string | null {
const displayText = stripSuppressedControlReplyToken(
stripInternalRuntimeContext(stripInlineDirectiveTagsForDisplay(text).text),
);
const heartbeat = stripHeartbeatTokenForDisplay(displayText);
if (
!displayText ||
isSuppressedControlReplyText(displayText) ||
isSuppressedControlReplyLeadFragment(displayText) ||
heartbeat.shouldSkip
) {
return null;
}
return heartbeat.text;
}
function trailingInternalDelimiterPrefix(text: string): string {
const tokens = [INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END];
for (
let length = Math.min(text.length, ...tokens.map((token) => token.length - 1));
length >= 1;
length -= 1
) {
const suffix = text.slice(-length);
if (tokens.some((token) => token.startsWith(suffix))) {
return suffix;
}
}
return "";
}
function rowIsRunning(row: SidebarRecentSession): boolean {
return row.hasActiveRun || row.status === "running";
}
function rowRecency(row: SidebarRecentSession): number {
return row.startedAt ?? row.updatedAt ?? 0;
}
function eventAgentMatches(targetAgentId: string, payloadAgentId: unknown): boolean {
return (
typeof payloadAgentId !== "string" ||
!payloadAgentId.trim() ||
normalizeAgentId(payloadAgentId) === normalizeAgentId(targetAgentId)
);
}
/** Owns the bounded session subscriptions and per-row activity throttles. */
export class SidebarSessionNarrationController {
private source: SessionCapability | null = null;
private connectionIdentity: object | null = null;
private connected = false;
private enabled = false;
private openSessionKey = "";
private agentId = "main";
private desiredKeys = new Set<string>();
private subscriptions = new Map<string, NarrationSubscription>();
private pendingSubscriptions = new Map<string, PendingSubscription>();
private deferredSubscriptions = new Map<string, ReturnType<typeof globalThis.setTimeout>>();
private internalRuntimeBlockDepth = new Map<string, number>();
private internalRuntimeDelimiterTails = new Map<string, string>();
// Chars of the FULL cumulative assistant stream consumed so far, per session.
// Length arithmetic (not stored text) keeps append detection O(delta) even
// after the visible buffer trims; storing the raw stream made long responses
// fail prefix checks post-trim and reparse the whole snapshot per delta.
private consumedStreamLength = new Map<string, number>();
private visibleText = new Map<string, string>();
private runIds = new Map<string, string>();
private throttles = new Map<string, ThrottledLine>();
private lines = new Map<string, string>();
constructor(private readonly onLinesChanged: (lines: ReadonlyMap<string, string>) => void) {}
sync(input: SidebarNarrationSyncInput): void {
const previousOpenSessionKey = this.openSessionKey;
const connectionChanged = this.connectionIdentity !== input.connectionIdentity;
const sourceChanged = this.source !== input.source;
const disconnected = !input.connected || !input.connectionIdentity || !input.source;
if (connectionChanged || sourceChanged || disconnected) {
// A replaced or closed socket already discarded its server-side set.
// Never send cleanup through a new connection for ownership from the old one.
this.resetSubscriptions({ unsubscribe: !connectionChanged && this.connected });
}
this.source = input.source;
this.connectionIdentity = input.connectionIdentity;
this.connected = input.connected;
this.enabled = input.enabled;
this.openSessionKey = input.openSessionKey.trim();
this.agentId = normalizeAgentId(input.agentId);
if (disconnected || !input.enabled) {
this.desiredKeys = new Set();
this.resetSubscriptions({ unsubscribe: !disconnected });
this.clearAllLines();
return;
}
const candidates = input.rows
.map((row, index) => ({ row, index }))
.filter(
({ row }) => rowIsRunning(row) && !areUiSessionKeysEquivalent(row.key, this.openSessionKey),
)
.toSorted(
(left, right) => rowRecency(right.row) - rowRecency(left.row) || left.index - right.index,
)
.slice(0, SIDEBAR_NARRATION_SUBSCRIPTION_LIMIT);
const nextDesired = new Set(candidates.map(({ row }) => row.key));
for (const key of this.desiredKeys) {
if (nextDesired.has(key)) {
continue;
}
// The chat pane and sidebar share a per-connection Set, not a refcount.
// Hand an opening row to chat without deleting the subscription it now owns.
const ownedAgentId =
this.subscriptions.get(key)?.subscription.agentId ??
this.pendingSubscriptions.get(key)?.agentId ??
null;
const handedToChat = this.subscriptionScopeMatchesOpenChat(key, ownedAgentId);
this.releaseKey(key, { unsubscribe: !handedToChat });
}
this.desiredKeys = nextDesired;
for (const key of nextDesired) {
const targetAgentId = this.subscriptionAgentId(key);
const ownedAgentId = this.subscriptions.get(key)?.subscription.agentId ?? null;
const pendingAgentId = this.pendingSubscriptions.get(key)?.agentId ?? null;
if (
(this.subscriptions.has(key) && ownedAgentId !== targetAgentId) ||
(this.pendingSubscriptions.has(key) && pendingAgentId !== targetAgentId)
) {
this.releaseKey(key, { unsubscribe: true });
}
if (this.subscriptions.has(key) || this.pendingSubscriptions.has(key)) {
continue;
}
const chatJustReleased = areUiSessionKeysEquivalent(key, previousOpenSessionKey);
this.scheduleSubscription(key, chatJustReleased);
}
}
handleEvent(event: GatewayEventFrame): void {
if (!this.enabled || !this.connected) {
return;
}
if (event.event === "chat") {
this.handleChatEvent(event.payload);
return;
}
if (event.event === "agent" || event.event === "session.tool") {
this.handleAgentEvent(event.payload);
}
}
disconnect(): void {
this.desiredKeys = new Set();
this.resetSubscriptions({ unsubscribe: this.connected });
this.clearAllLines();
this.connected = false;
}
private scheduleSubscription(key: string, defer: boolean): void {
if (!defer) {
void this.subscribeKey(key);
return;
}
if (this.deferredSubscriptions.has(key)) {
return;
}
// Chat unsubscribes during the same Lit update. Queueing this request makes
// the wire order unsubscribe -> subscribe when a running row leaves chat.
const timer = globalThis.setTimeout(() => {
this.deferredSubscriptions.delete(key);
void this.subscribeKey(key);
}, 0);
this.deferredSubscriptions.set(key, timer);
}
private async subscribeKey(key: string): Promise<void> {
const source = this.source;
const connectionIdentity = this.connectionIdentity;
if (
!source ||
!connectionIdentity ||
!this.connected ||
!this.enabled ||
!this.desiredKeys.has(key)
) {
return;
}
const operationId = Symbol(key);
const agentId = this.subscriptionAgentId(key);
this.pendingSubscriptions.set(key, { agentId, connectionIdentity, operationId, source });
try {
const subscription = await source.subscribeMessages(key, {
agentId: agentId ?? undefined,
});
const pending = this.pendingSubscriptions.get(key);
if (pending?.operationId !== operationId || pending.source !== source) {
const completedAgentId = subscription.agentId ?? null;
const replacementOwnsSameScope =
this.desiredKeys.has(key) &&
(this.subscriptions.get(key)?.subscription.agentId === completedAgentId ||
this.pendingSubscriptions.get(key)?.agentId === completedAgentId ||
(this.deferredSubscriptions.has(key) &&
this.subscriptionAgentId(key) === completedAgentId));
if (
!replacementOwnsSameScope &&
connectionIdentity === this.connectionIdentity &&
!this.subscriptionScopeMatchesOpenChat(key, completedAgentId)
) {
await source.unsubscribeMessages(subscription).catch(() => undefined);
}
return;
}
this.pendingSubscriptions.delete(key);
if (
source !== this.source ||
connectionIdentity !== this.connectionIdentity ||
!this.connected ||
!this.enabled ||
!this.desiredKeys.has(key)
) {
if (!this.subscriptionScopeMatchesOpenChat(key, subscription.agentId ?? null)) {
await source.unsubscribeMessages(subscription).catch(() => undefined);
}
return;
}
this.subscriptions.set(key, { source, subscription });
} catch {
const pending = this.pendingSubscriptions.get(key);
if (pending?.operationId === operationId) {
this.pendingSubscriptions.delete(key);
}
}
}
private subscriptionAgentId(key: string): string | null {
return isUiGlobalSessionKey(key) ? this.agentId : null;
}
private subscriptionScopeMatchesOpenChat(key: string, agentId: string | null): boolean {
if (!areUiSessionKeysEquivalent(key, this.openSessionKey)) {
return false;
}
return !isUiGlobalSessionKey(key) || agentId === this.subscriptionAgentId(key);
}
private releaseKey(key: string, options: { unsubscribe: boolean }): void {
const deferred = this.deferredSubscriptions.get(key);
if (deferred) {
globalThis.clearTimeout(deferred);
this.deferredSubscriptions.delete(key);
}
this.pendingSubscriptions.delete(key);
const owned = this.subscriptions.get(key);
this.subscriptions.delete(key);
if (owned && options.unsubscribe) {
void owned.source.unsubscribeMessages(owned.subscription).catch(() => undefined);
}
this.clearLine(key);
}
private resetSubscriptions(options: { unsubscribe: boolean }): void {
const keys = new Set([
...this.subscriptions.keys(),
...this.pendingSubscriptions.keys(),
...this.deferredSubscriptions.keys(),
]);
for (const key of keys) {
this.releaseKey(key, options);
}
}
private matchingDesiredKey(sessionKey: unknown, payloadAgentId: unknown): string | null {
if (typeof sessionKey !== "string" || !sessionKey.trim()) {
return null;
}
for (const key of this.desiredKeys) {
if (
areUiSessionKeysEquivalent(key, sessionKey) &&
(!isUiGlobalSessionKey(key) || eventAgentMatches(this.agentId, payloadAgentId))
) {
return key;
}
}
return null;
}
private handleChatEvent(payload: unknown): void {
if (!payload || typeof payload !== "object") {
return;
}
const record = payload as Record<string, unknown>;
const key = this.matchingDesiredKey(record.sessionKey, record.agentId);
if (!key) {
return;
}
this.observeRun(key, record.runId);
const message = record.message as Record<string, unknown> | undefined;
if (message && typeof message.role === "string" && message.role !== "assistant") {
return;
}
const deltaText = typeof record.deltaText === "string" ? record.deltaText : "";
const messageText = message ? extractText(message) : null;
const consumed = this.consumedStreamLength.get(key) ?? 0;
// A newly subscribed sidebar can join mid-run. Within one run the server's
// cumulative snapshot grows monotonically, so length arithmetic decides
// append vs rejoin without storing the raw stream.
if (record.replace === true) {
// Handle before any truthiness gate: an EMPTY replacement retracts the
// narration line (streamLength 0 takes publishText's clearing path).
const replacement = deltaText || messageText || "";
this.publishText(key, {
streamLength: replacement.length,
fragment: replacement,
reset: true,
});
return;
}
if (deltaText) {
if (messageText) {
const appends = consumed > 0 && messageText.length - deltaText.length === consumed;
if (appends) {
this.publishText(key, {
streamLength: messageText.length,
fragment: deltaText,
reset: false,
});
} else {
this.publishText(key, {
streamLength: messageText.length,
fragment: messageText,
reset: true,
});
}
} else if (consumed > 0) {
this.publishText(key, {
streamLength: consumed + deltaText.length,
fragment: deltaText,
reset: false,
});
}
// consumed === 0 with a bare delta: a mid-run join may sit INSIDE an
// internal-context block whose opening delimiter we never saw. Stay
// silent until a cumulative snapshot or replacement aligns the stream.
return;
}
if (messageText) {
this.publishText(key, {
streamLength: messageText.length,
fragment: messageText,
reset: true,
});
}
}
private publishText(
key: string,
update: { streamLength: number; fragment: string; reset: boolean },
): void {
if (update.streamLength <= 0) {
if (update.reset) {
// An empty replacement retracts prior content; a stale line must not
// outlive it (the draft it showed may have been withdrawn).
this.clearLine(key);
}
return;
}
this.consumedStreamLength.set(key, update.streamLength);
if (update.reset) {
this.internalRuntimeBlockDepth.delete(key);
this.internalRuntimeDelimiterTails.delete(key);
this.visibleText.delete(key);
// A replacement supersedes anything still queued behind the throttle;
// otherwise a pre-replacement draft could republish after retraction.
const throttle = this.throttles.get(key);
if (throttle) {
throttle.pending = null;
}
}
const visibleFragment = this.stripInternalRuntimeFragment(key, update.fragment);
const previousVisibleText = update.reset ? "" : (this.visibleText.get(key) ?? "");
const nextVisibleText = `${previousVisibleText}${visibleFragment}`;
if (!nextVisibleText) {
if (update.reset && this.lines.delete(key)) {
this.onLinesChanged(new Map(this.lines));
}
return;
}
const boundedVisibleText =
nextVisibleText.length > SIDEBAR_NARRATION_BUFFER_CHARS
? sliceUtf16Safe(nextVisibleText, -SIDEBAR_NARRATION_BUFFER_CHARS)
: nextVisibleText;
this.visibleText.set(key, boundedVisibleText);
this.publishThrottled(key, { kind: "text", text: boundedVisibleText });
}
private stripInternalRuntimeFragment(key: string, fragment: string): string {
const pendingDelimiter = this.internalRuntimeDelimiterTails.get(key) ?? "";
this.internalRuntimeDelimiterTails.delete(key);
const text = `${pendingDelimiter}${fragment}`;
let depth = this.internalRuntimeBlockDepth.get(key) ?? 0;
let cursor = 0;
let visible = "";
while (cursor < text.length) {
const nextBegin = text.indexOf(INTERNAL_RUNTIME_CONTEXT_BEGIN, cursor);
const nextEnd = text.indexOf(INTERNAL_RUNTIME_CONTEXT_END, cursor);
if (depth === 0) {
if (nextBegin === -1 && nextEnd === -1) {
visible += text.slice(cursor);
break;
}
if (nextEnd !== -1 && (nextBegin === -1 || nextEnd < nextBegin)) {
// A stray closing delimiter means this fragment may start inside an
// already-trimmed block. Fail closed until that boundary passes.
cursor = nextEnd + INTERNAL_RUNTIME_CONTEXT_END.length;
continue;
}
visible += text.slice(cursor, nextBegin);
depth = 1;
cursor = nextBegin + INTERNAL_RUNTIME_CONTEXT_BEGIN.length;
continue;
}
if (nextBegin === -1 && nextEnd === -1) {
break;
}
if (nextBegin !== -1 && (nextEnd === -1 || nextBegin < nextEnd)) {
depth += 1;
cursor = nextBegin + INTERNAL_RUNTIME_CONTEXT_BEGIN.length;
continue;
}
depth -= 1;
cursor = nextEnd + INTERNAL_RUNTIME_CONTEXT_END.length;
}
const delimiterPrefix = trailingInternalDelimiterPrefix(text);
if (delimiterPrefix) {
this.internalRuntimeDelimiterTails.set(key, delimiterPrefix);
if (depth === 0 && visible.endsWith(delimiterPrefix)) {
visible = visible.slice(0, -delimiterPrefix.length);
}
}
if (depth > 0) {
this.internalRuntimeBlockDepth.set(key, depth);
} else {
this.internalRuntimeBlockDepth.delete(key);
}
return visible;
}
private handleAgentEvent(payload: unknown): void {
if (!payload || typeof payload !== "object") {
return;
}
const record = payload as Record<string, unknown>;
const key = this.matchingDesiredKey(record.sessionKey, record.agentId);
if (!key) {
return;
}
this.observeRun(key, record.runId);
const data = record.data as Record<string, unknown> | undefined;
if (record.stream === "tool") {
const name = typeof data?.name === "string" ? data.name.trim() : "";
if (!name) {
return;
}
this.publishThrottled(key, {
kind: "line",
line: t("chat.sidebar.toolActivity", { tool: name }),
});
return;
}
if (record.stream !== "assistant") {
return;
}
const text = typeof data?.text === "string" ? data.text : "";
const delta = typeof data?.delta === "string" ? data.delta : "";
const consumed = this.consumedStreamLength.get(key) ?? 0;
if (data?.replace === true) {
const replacement = text || delta;
this.publishText(key, {
streamLength: replacement.length,
fragment: replacement,
reset: true,
});
return;
}
if (text) {
// Same monotonic-length contract as the chat path: append when the
// cumulative snapshot grew by exactly this event's delta, else rejoin.
if (delta && consumed > 0 && text.length - delta.length === consumed) {
this.publishText(key, { streamLength: text.length, fragment: delta, reset: false });
} else if (text.length !== consumed) {
this.publishText(key, { streamLength: text.length, fragment: text, reset: true });
}
return;
}
if (delta && consumed > 0) {
this.publishText(key, {
streamLength: consumed + delta.length,
fragment: delta,
reset: false,
});
}
// consumed === 0 with a bare delta: same mid-run-join hazard as the chat
// path — suppress until a cumulative snapshot aligns the stream.
}
private observeRun(key: string, runIdValue: unknown): void {
const runId = typeof runIdValue === "string" ? runIdValue.trim() : "";
if (!runId) {
return;
}
const previousRunId = this.runIds.get(key);
if (previousRunId && previousRunId !== runId) {
this.clearLine(key);
}
this.runIds.set(key, runId);
}
private publishThrottled(key: string, activity: NarrationActivity): void {
const now = Date.now();
const throttle = this.throttles.get(key);
if (!throttle || now - throttle.lastPublishedAt >= SIDEBAR_NARRATION_THROTTLE_MS) {
if (throttle?.timer) {
globalThis.clearTimeout(throttle.timer);
}
this.throttles.set(key, { lastPublishedAt: now, pending: null, timer: null });
this.publishActivity(key, activity);
return;
}
throttle.pending = activity;
if (throttle.timer) {
return;
}
throttle.timer = globalThis.setTimeout(
() => {
throttle.timer = null;
const pending = throttle.pending;
throttle.pending = null;
if (!pending || !this.desiredKeys.has(key)) {
return;
}
throttle.lastPublishedAt = Date.now();
this.publishActivity(key, pending);
},
SIDEBAR_NARRATION_THROTTLE_MS - (now - throttle.lastPublishedAt),
);
}
private publishActivity(key: string, activity: NarrationActivity): void {
const safeText = activity.kind === "text" ? normalizeSidebarNarrationText(activity.text) : null;
const line = safeText
? deriveSidebarNarrationLine(safeText)
: activity.kind === "line"
? activity.line
: "";
if (line) {
this.setLine(key, line);
return;
}
// The activity text is the full visible buffer: normalizing it to nothing
// means only suppressed content remains (e.g. a replacement that reduced
// to REPLY_SKIP or a heartbeat), so retract any previously shown line.
if (activity.kind === "text" && this.lines.delete(key)) {
this.onLinesChanged(new Map(this.lines));
}
}
private setLine(key: string, line: string): void {
if (this.lines.get(key) === line) {
return;
}
this.lines.set(key, line);
this.onLinesChanged(new Map(this.lines));
}
private clearLine(key: string): void {
this.internalRuntimeBlockDepth.delete(key);
this.internalRuntimeDelimiterTails.delete(key);
this.consumedStreamLength.delete(key);
this.visibleText.delete(key);
this.runIds.delete(key);
const throttle = this.throttles.get(key);
if (throttle?.timer) {
globalThis.clearTimeout(throttle.timer);
}
this.throttles.delete(key);
if (this.lines.delete(key)) {
this.onLinesChanged(new Map(this.lines));
}
}
private clearAllLines(): void {
for (const throttle of this.throttles.values()) {
if (throttle.timer) {
globalThis.clearTimeout(throttle.timer);
}
}
this.internalRuntimeBlockDepth.clear();
this.internalRuntimeDelimiterTails.clear();
this.consumedStreamLength.clear();
this.visibleText.clear();
this.runIds.clear();
this.throttles.clear();
if (this.lines.size > 0) {
this.lines.clear();
this.onLinesChanged(new Map());
}
}
}
@@ -195,6 +195,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
spawnedBy: row.spawnedBy,
status: row.status,
startedAt: row.startedAt,
updatedAt: row.updatedAt,
endedAt: row.endedAt,
runtimeMs: row.runtimeMs,
runtimeSampledAt,
@@ -71,6 +71,7 @@ export type SidebarRecentSession = {
spawnedBy?: string;
status?: SessionRunStatus;
startedAt?: number;
updatedAt?: number | null;
endedAt?: number;
runtimeMs?: number;
runtimeSampledAt?: number;
+1
View File
@@ -13,6 +13,7 @@ import "../test-helpers/app-sidebar-cases/child-sessions-cap.ts";
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/sidebar-scroll.ts";
import "../test-helpers/app-sidebar-cases/sessions.ts";
import "../test-helpers/app-sidebar-cases/session-list-sections.ts";
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import { resolveSidebarSessionSubtitle } from "./session-row-subtitle.ts";
function workSession(): SidebarRecentSession {
return {
attention: { kind: "none" },
hasActiveRun: false,
label: "Backing session",
status: "done",
subtitle: "~/Projects/openclaw",
workSession: true,
} as unknown as SidebarRecentSession;
}
describe("resolveSidebarSessionSubtitle", () => {
it("does not fall back to a backing work subtitle when catalog display omits one", () => {
expect(
resolveSidebarSessionSubtitle({
session: workSession(),
hasDisplay: true,
displaySubtitle: undefined,
sidebarLiveActivity: true,
narrationLine: undefined,
}),
).toEqual({ subtitle: undefined, narration: undefined });
});
});
+45
View File
@@ -0,0 +1,45 @@
import { html, nothing } from "lit";
import { keyed } from "lit/directives/keyed.js";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import { sessionAttentionSubtitle } from "./session-attention-presentation.ts";
type SidebarSessionSubtitle = {
subtitle: string | undefined;
narration: string | undefined;
};
/** Resolves the single subtitle slot without displacing pending attention. */
export function resolveSidebarSessionSubtitle(params: {
session: SidebarRecentSession;
hasDisplay: boolean;
displaySubtitle: string | undefined;
sidebarLiveActivity: boolean;
narrationLine: string | undefined;
}): SidebarSessionSubtitle {
const { session } = params;
const attention = sessionAttentionSubtitle(session.attention);
const running = session.hasActiveRun || session.status === "running";
const narration =
attention || !params.sidebarLiveActivity || !running ? undefined : params.narrationLine;
const workSubtitle = params.hasDisplay
? params.displaySubtitle
: session.subtitle && session.workSession && session.subtitle !== session.label
? session.subtitle
: undefined;
return { subtitle: attention ?? narration ?? workSubtitle, narration };
}
export function renderSidebarSessionSubtitle(value: SidebarSessionSubtitle) {
if (!value.subtitle) {
return nothing;
}
return value.narration
? keyed(
value.narration,
html`<span
class="sidebar-recent-session__subtitle sidebar-recent-session__subtitle--narration"
>${value.subtitle}</span
>`,
)
: html`<span class="sidebar-recent-session__subtitle">${value.subtitle}</span>`;
}
@@ -0,0 +1,34 @@
import { clampText } from "../lib/format.ts";
const SIDEBAR_NARRATION_MAX_LENGTH = 120;
function stripMarkdown(text: string): string {
return text
.replace(/```[\s\S]*?```/g, " ")
.replace(/```/g, " ")
.replace(/`([^`]*)`/g, "$1")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/^\s{0,3}(?:#{1,6}|>|[-+*]|\d+[.)])\s+/gm, "")
.replace(/[*_~]+/g, "")
.replace(/\s+/g, " ")
.trim();
}
/** Compact the newest prose into one quiet, stable sidebar line. */
export function deriveSidebarNarrationLine(text: string): string {
const paragraphs = text
.replace(/```[\s\S]*?```/g, " ")
.split(/\n\s*\n/)
.map((paragraph) => stripMarkdown(paragraph))
.filter(Boolean);
const paragraph = paragraphs.at(-1) ?? "";
if (!paragraph) {
return "";
}
const fragments = paragraph.match(/[^.!?…]+(?:[.!?…]+(?=\s|$)|$)/g);
const newest =
fragments?.map((fragment) => fragment.trim()).findLast((fragment) => Boolean(fragment)) ??
paragraph;
return clampText(newest, SIDEBAR_NARRATION_MAX_LENGTH);
}
+7
View File
@@ -1279,6 +1279,12 @@ export const en: TranslationMap = {
title: "Chat",
hint: "Browser-local chat preferences.",
},
sidebarPrefs: {
title: "Sidebar",
hint: "Choose what appears while sessions are running.",
liveActivity: "Show live agent activity in sidebar",
liveActivityHint: "Show the latest assistant or tool activity beneath running sessions.",
},
connection: {
title: "Connection",
gateway: "Gateway",
@@ -3722,6 +3728,7 @@ export const en: TranslationMap = {
sortUpdated: "Last updated",
sessionMenu: "Actions for {session}",
sessionMenuMany: "Actions for {count} threads",
toolActivity: "Using {tool}",
},
welcome: {
hintBeforeShortcut: "Type a message below ·",
+7 -5
View File
@@ -11,10 +11,10 @@ function escapeRegExp(value: string): string {
export function stripHeartbeatTokenForDisplay(
raw: string,
maxAckChars = DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
): { shouldSkip: boolean } {
): { shouldSkip: boolean; text: string } {
let text = raw.trim();
if (!text) {
return { shouldSkip: true };
return { shouldSkip: true, text: "" };
}
const strippedMarkup = text
.replace(/<[^>]*>/g, " ")
@@ -22,7 +22,9 @@ export function stripHeartbeatTokenForDisplay(
.replace(/^[*`~_]+/, "")
.replace(/[*`~_]+$/, "");
if (!text.includes(HEARTBEAT_TOKEN) && !strippedMarkup.includes(HEARTBEAT_TOKEN)) {
return { shouldSkip: false };
// strippedMarkup exists only to DETECT tokens hidden behind markup; without
// a token, returning it would corrupt legitimate angle-bracket prose.
return { shouldSkip: false, text };
}
const tokenAtEnd = new RegExp(`${escapeRegExp(HEARTBEAT_TOKEN)}[^\\w]{0,4}$`);
@@ -49,9 +51,9 @@ export function stripHeartbeatTokenForDisplay(
}
if (!didStrip) {
return { shouldSkip: false };
return { shouldSkip: false, text };
}
return { shouldSkip: !text || text.length <= maxAckChars };
return { shouldSkip: !text || text.length <= maxAckChars, text };
}
function isHiddenDisplayBlockType(type: unknown): boolean {
+4
View File
@@ -63,6 +63,7 @@ type ConfigSelection = { activeSection: string | null; activeSubsection: string
// across devices is owned by app/server-prefs.ts, not by this type.
type ConfigPageSetting =
| "textScale"
| "sidebarLiveActivity"
| "chatSendShortcut"
| "chatFollowUpMode"
| "catalogOpenTarget"
@@ -614,6 +615,7 @@ export class ConfigPage extends OpenClawLightDomElement {
themeMode: next.themeMode,
customTheme: next.customTheme,
textScale: next.textScale,
sidebarLiveActivity: next.sidebarLiveActivity,
chatSendShortcut: next.chatSendShortcut,
chatFollowUpMode: next.chatFollowUpMode,
catalogOpenTarget: next.catalogOpenTarget,
@@ -829,6 +831,8 @@ export class ConfigPage extends OpenClawLightDomElement {
onOpenCustomThemeImport: () => this.openCustomThemeImport(),
textScale: this.settings.textScale ?? 100,
setTextScale: (value) => this.setSetting("textScale", normalizeTextScale(value)),
sidebarLiveActivity: this.settings.sidebarLiveActivity !== false,
setSidebarLiveActivity: (enabled) => this.setSetting("sidebarLiveActivity", enabled),
lobsterPetVisits: this.settings.lobsterPetVisits !== false,
setLobsterPetVisits: (enabled) =>
this.applySettings({ ...this.settings, lobsterPetVisits: enabled }),
+1
View File
@@ -6,6 +6,7 @@ export const GENERAL_SETTINGS_TARGET_IDS = {
export const APPEARANCE_SETTINGS_TARGET_IDS = {
theme: "settings-appearance-theme",
textSize: "settings-appearance-text-size",
sidebar: "settings-appearance-sidebar",
chat: "settings-appearance-chat",
connection: "settings-appearance-connection",
} as const;
+20
View File
@@ -66,6 +66,8 @@ describe("config view", () => {
onOpenCustomThemeImport: vi.fn(),
textScale: 100,
setTextScale: vi.fn(),
sidebarLiveActivity: true,
setSidebarLiveActivity: vi.fn(),
chatSendShortcut: "enter" as const,
setChatSendShortcut: vi.fn(),
chatFollowUpMode: undefined,
@@ -1347,6 +1349,24 @@ describe("config view", () => {
expect(container.textContent).toContain("Hold microphone button to dictate");
});
it("renders and changes the live sidebar activity preference", () => {
const setSidebarLiveActivity = vi.fn();
const { container } = renderConfigView({
activeSection: "__appearance__",
includeSections: ["__appearance__"],
sidebarLiveActivity: true,
setSidebarLiveActivity,
});
const row = Array.from(container.querySelectorAll<HTMLElement>(".settings-row--toggle")).find(
(candidate) => candidate.textContent?.includes("Show live agent activity in sidebar"),
);
expect(row).toBeDefined();
expect(row?.querySelector<HTMLElement & { checked: boolean }>("wa-switch")?.checked).toBe(true);
row?.click();
expect(setSidebarLiveActivity).toHaveBeenCalledWith(false);
});
it("marks browser follow-up overrides and resets them to the server", () => {
const setChatFollowUpMode = vi.fn();
const { container } = renderConfigView({
+25 -1
View File
@@ -175,6 +175,8 @@ export type ConfigProps = {
onOpenCustomThemeImport?: () => void;
textScale: number;
setTextScale: (value: number) => void;
sidebarLiveActivity: boolean;
setSidebarLiveActivity: (enabled: boolean) => void;
lobsterPetVisits?: boolean;
setLobsterPetVisits?: (enabled: boolean) => void;
lobsterPetSounds?: boolean;
@@ -1169,6 +1171,27 @@ function renderLobsterPetSection(props: ConfigProps) {
`;
}
function renderSidebarPreferencesSection(props: ConfigProps) {
return html`
<section id=${APPEARANCE_SETTINGS_TARGET_IDS.sidebar} class="settings-section">
<div class="settings-section__header">
<h2 class="settings-section__heading">${t("configView.sidebarPrefs.title")}</h2>
</div>
<p class="settings-section__desc">
${t("configView.sidebarPrefs.hint")} ${t("configView.syncedHint")}
</p>
<div class="settings-group">
${renderSettingsToggleRow({
title: t("configView.sidebarPrefs.liveActivity"),
description: t("configView.sidebarPrefs.liveActivityHint"),
checked: props.sidebarLiveActivity,
onChange: props.setSidebarLiveActivity,
})}
</div>
</section>
`;
}
function renderAppearanceSection(props: ConfigProps) {
const viewState = props.viewState;
const showCustomThemeImport = props.hasCustomTheme || props.customThemeImportExpanded === true;
@@ -1363,7 +1386,8 @@ function renderAppearanceSection(props: ConfigProps) {
</div>
</section>
${renderLobsterPetSection(props)} ${renderChatPreferencesSection(props)}
${renderSidebarPreferencesSection(props)} ${renderLobsterPetSection(props)}
${renderChatPreferencesSection(props)}
<section id=${APPEARANCE_SETTINGS_TARGET_IDS.connection} class="settings-section">
<div class="settings-section__header">
+13
View File
@@ -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;
@@ -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<HTMLAnchorElement>(
`[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<HTMLAnchorElement>(".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,
});
});
});
+12
View File
@@ -26,11 +26,13 @@ type SessionDeleteResult = Awaited<ReturnType<SessionCapability["delete"]>>;
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<SessionCapability["unsubscribeMessages"]>[0]) => Promise.resolve(),
);
const list = vi.fn((_options?: Parameters<SessionCapability["list"]>[0]) =>
Promise.resolve<SessionsListResult | null>(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<SessionState>) => {
state = { ...state, ...statePatch };
@@ -216,6 +226,8 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
list,
refresh,
refreshReplacement,
subscribeMessages,
unsubscribeMessages,
publish,
publishList(statePatch: Partial<SessionState>) {
canonicalListRevision += 1;
@@ -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<string, unknown> };
type ResponseFrame = {
event?: string;
id?: string;
type?: string;
payload?: Record<string, unknown>;
};
function flushMockTimers(): Promise<void> {
return new Promise((resolve) => {
@@ -12,6 +17,12 @@ function flushMockTimers(): Promise<void> {
});
}
function waitForMockCycle(): Promise<void> {
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: {
+79
View File
@@ -65,6 +65,11 @@ export type ControlUiMockGatewayScenario = {
methodResponses?: Record<string, unknown>;
/** 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<string, unknown> | 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<string, number>();
const sessionPatches = new Map<string, Record<string, unknown>>();
const sessionMessageSubscriptions = new Set<string>();
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(