feat(ui): builtin observer board card with digest timeline (#112565)

* feat(ui): builtin observer board card with digest timeline

* fix(ui): satisfy type and deadcode gates for observer board card
This commit is contained in:
Peter Steinberger
2026-07-22 01:44:04 -07:00
committed by GitHub
parent d4f19bfd79
commit aa35dc8c40
17 changed files with 1054 additions and 20 deletions

View File

@@ -1068,9 +1068,10 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
sessionRow(OBSERVER_DEMO_SESSION_KEY, "Session observer demo", baseTime - 3_000, {
activeRunIds: [OBSERVER_DEMO_RUN_ID],
hasActiveRun: true,
lastReadAt: baseTime + 2_000,
observerDigest: {
headline: "Third run of the same vitest file - two assertions still failing",
health: "grinding",
headline: "Opening the focused observer tests",
health: "on-track",
revision: 1,
runId: OBSERVER_DEMO_RUN_ID,
updatedAt: baseTime - 2_000,
@@ -1718,7 +1719,7 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
{
event: "session.observer",
payload: {
headline: "Reading the failing test",
headline: "Reading the failing test and its board caller",
health: "on-track",
revision: 2,
runId: OBSERVER_DEMO_RUN_ID,
@@ -1729,8 +1730,11 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
{
event: "session.observer",
payload: {
assessment:
"The first fix was incomplete, so the agent is narrowing the assertion path.",
headline: "Third run of the same vitest file - two assertions still failing",
health: "grinding",
planProgress: { completed: 2, total: 4 },
revision: 3,
runId: OBSERVER_DEMO_RUN_ID,
sessionKey: OBSERVER_DEMO_SESSION_KEY,
@@ -1740,8 +1744,10 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
{
event: "session.observer",
payload: {
assessment: "Repeated identical failures suggest the current approach needs a reset.",
headline: "Same failure five runs in a row - it may be circling",
health: "stuck",
planProgress: { completed: 2, total: 4 },
revision: 4,
runId: OBSERVER_DEMO_RUN_ID,
sessionKey: OBSERVER_DEMO_SESSION_KEY,

View File

@@ -21,6 +21,7 @@ import {
import type { BoardOp, BoardTab } from "../../lib/board/types.ts";
import type {
BoardGrantDecision,
BoardObserverContext,
BoardViewCallbacks,
BoardViewSnapshot,
BoardViewWidget,
@@ -78,6 +79,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
@property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl;
@property({ attribute: false }) callbacks?: BoardViewCallbacks;
@property({ attribute: false }) sessions: readonly GatewaySessionRow[] = [];
@property({ attribute: false }) observer?: BoardObserverContext;
@property({ type: Boolean }) canMutate = true;
@property({ type: Boolean }) canGrant = true;
@@ -659,6 +661,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
.widgetFrameUrl=${this.widgetFrameUrl}
.callbacks=${this.cellCallbacks}
.sessions=${this.sessions}
.observer=${this.observer}
.dragging=${widget.name === this.gestureName}
.focusTabIndex=${widget.name === focusName ? 0 : -1}
.positionInSet=${(logicalPosition.get(widget.name) ?? 0) + 1}

View File

@@ -11,6 +11,7 @@ import type { BoardWidgetAppViewState } from "../../lib/board/provider.ts";
import type { BoardTab } from "../../lib/board/types.ts";
import type {
BoardGrantDecision,
BoardObserverContext,
BoardViewWidget,
BoardWidgetFrameUrl,
} from "../../lib/board/view-types.ts";
@@ -69,6 +70,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
@property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl;
@property({ attribute: false }) callbacks?: BoardWidgetCellCallbacks;
@property({ attribute: false }) sessions: readonly GatewaySessionRow[] = [];
@property({ attribute: false }) observer?: BoardObserverContext;
@property({ type: Boolean }) dragging = false;
@property({ type: Number }) focusTabIndex = -1;
@property({ type: Number }) positionInSet = 1;
@@ -252,7 +254,11 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
if (!renderer) {
throw new Error(t("board.widget.frameResolverMissing"));
}
return renderer({ sessions: this.sessions, sessionKey: this.sessionKey });
return renderer({
observer: this.observer,
sessions: this.sessions,
sessionKey: this.sessionKey,
});
}
if (widget.contentKind === "plugin") {
if (this.pluginRendererError) {

View File

@@ -4032,6 +4032,12 @@ export const en: TranslationMap = {
askPending: "Checking the observations…",
askBusy: "The observer is already answering a question.",
askUnavailable: "The observer cannot answer right now.",
boardCurrentStatus: "Current status",
boardTimeline: "Health timeline",
boardCurrentRun: "Current run",
boardPreviousRun: "Previous run",
boardUnknownRun: "Unidentified run",
boardSinceYouLeft: "Since you left",
health: {
"on-track": "On track",
grinding: "Working",

View File

@@ -0,0 +1,14 @@
import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js";
import type { GatewaySessionRow } from "../../api/types.ts";
import { withObserverWidget } from "./observer-dashboard.ts";
import { withSwarmWidget } from "./swarm-dashboard.ts";
import type { BoardSnapshot } from "./types.ts";
import type { BoardViewSnapshot } from "./view-types.ts";
export function withBuiltinDashboardWidgets(
snapshot: BoardSnapshot,
sessions: readonly GatewaySessionRow[],
observerDigests: readonly SessionObserverDigest[],
): BoardViewSnapshot {
return withObserverWidget(withSwarmWidget(snapshot, sessions), observerDigests);
}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js";
import { withObserverWidget } from "./observer-dashboard.ts";
import type { BoardSnapshot } from "./types.ts";
const snapshot: BoardSnapshot = {
sessionKey: "agent:main:observer-board",
revision: 7,
tabs: [],
widgets: [],
};
const digest: SessionObserverDigest = {
sessionKey: snapshot.sessionKey,
runId: "run-1",
revision: 1,
updatedAt: 1_000,
headline: "Reviewing the focused test",
health: "on-track",
};
describe("observer dashboard injection", () => {
it("leaves a board without observer digests unchanged", () => {
expect(withObserverWidget(snapshot, [])).toBe(snapshot);
});
it("adds one ephemeral read-only board tab and card when digest history exists", () => {
const projected = withObserverWidget(snapshot, [digest]);
expect(projected).not.toBe(snapshot);
expect(snapshot.tabs).toEqual([]);
expect(snapshot.widgets).toEqual([]);
expect(projected.tabs).toEqual([
expect.objectContaining({ tabId: "builtin-observer", title: "Session observer" }),
]);
expect(projected.widgets).toEqual([
expect.objectContaining({
name: "builtin:observer",
builtin: "observer",
contentKind: "builtin",
readOnly: true,
grantState: "granted",
}),
]);
});
it("replaces its view projection instead of duplicating the card", () => {
const first = withObserverWidget(snapshot, [digest]);
const second = withObserverWidget(first, [{ ...digest, revision: 2, updatedAt: 2_000 }]);
expect(second.tabs.filter((tab) => tab.tabId === "builtin-observer")).toHaveLength(1);
expect(second.widgets.filter((widget) => widget.name === "builtin:observer")).toHaveLength(1);
});
});

View File

@@ -0,0 +1,47 @@
import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js";
import { t } from "../../i18n/index.ts";
import type { BoardSnapshot } from "./types.ts";
import type { BoardViewSnapshot } from "./view-types.ts";
const OBSERVER_TAB_ID = "builtin-observer";
const OBSERVER_WIDGET_NAME = "builtin:observer";
/** Adds the read-only client projection without writing it through the board provider. */
export function withObserverWidget(
snapshot: BoardSnapshot | BoardViewSnapshot,
digests: readonly SessionObserverDigest[],
): BoardViewSnapshot {
if (digests.length === 0) {
return snapshot;
}
const tabs = snapshot.tabs.some((tab) => tab.tabId === OBSERVER_TAB_ID)
? snapshot.tabs
: [
...snapshot.tabs,
{
tabId: OBSERVER_TAB_ID,
title: t("chat.observer.title"),
position: Math.max(-1, ...snapshot.tabs.map((tab) => tab.position)) + 1,
chatDock: "right" as const,
},
];
const widget = {
name: OBSERVER_WIDGET_NAME,
tabId: OBSERVER_TAB_ID,
title: t("chat.observer.title"),
contentKind: "builtin" as const,
builtin: "observer" as const,
readOnly: true,
sizeW: 12,
sizeH: 6,
position: 0,
grantState: "granted" as const,
revision: snapshot.revision,
} satisfies BoardViewSnapshot["widgets"][number];
const widgets = snapshot.widgets.some((candidate) => candidate.name === OBSERVER_WIDGET_NAME)
? snapshot.widgets.map((candidate) =>
candidate.name === OBSERVER_WIDGET_NAME ? widget : candidate,
)
: [...snapshot.widgets, widget];
return { ...snapshot, tabs, widgets };
}

View File

@@ -1,4 +1,5 @@
import type { BoardOp, BoardSnapshot, BoardWidget } from "@openclaw/gateway-protocol";
import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js";
export type BoardGrantDecision = "granted" | "rejected";
export type BoardWidgetAppViewState =
@@ -11,7 +12,7 @@ type BoardStoredWidget = BoardWidget & {
readOnly?: false | undefined;
};
type BoardBuiltinWidget = Omit<BoardWidget, "contentKind"> & {
builtin: "swarm";
builtin: "observer" | "swarm";
contentKind: "builtin";
readOnly: true;
};
@@ -20,6 +21,12 @@ export type BoardViewSnapshot = Omit<BoardSnapshot, "widgets"> & {
widgets: BoardViewWidget[];
};
export type BoardObserverContext = {
activeRunId: string | null;
digests: readonly SessionObserverDigest[];
lastReadAt?: number;
};
export type BoardViewCallbacks = {
applyOps: (ops: BoardOp[]) => Promise<void>;
grant: (name: string, decision: BoardGrantDecision) => Promise<void>;

View File

@@ -3,9 +3,12 @@ import type { GatewayControlUiPluginWidgetKind } from "../../../api/gateway.ts";
import type { GatewaySessionRow } from "../../../api/types.ts";
import { t } from "../../../i18n/index.ts";
import type { BoardViewWidget } from "../view-types.ts";
import type { BoardObserverContext } from "../view-types.ts";
import { renderObserverWidget } from "./observer.ts";
import { renderSwarmWidget } from "./swarm.ts";
type BuiltinBoardWidgetRenderer = (context: {
observer?: BoardObserverContext;
sessions: readonly GatewaySessionRow[];
sessionKey: string;
}) => TemplateResult;
@@ -43,6 +46,7 @@ const PLUGIN_WIDGET_KIND_CONTRIBUTIONS: Record<string, PluginWidgetKindContribut
const pluginRendererPromises = new Map<string, Promise<PluginBoardWidgetRenderer>>();
const BUILTIN_WIDGET_RENDERERS: Record<string, BuiltinBoardWidgetRenderer> = {
observer: renderObserverWidget,
swarm: renderSwarmWidget,
};

View File

@@ -0,0 +1,85 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { afterEach, describe, expect, it } from "vitest";
import type { SessionObserverDigest } from "../../../../../packages/gateway-protocol/src/schema/sessions.js";
import { renderObserverWidget } from "./observer.ts";
const sessionKey = "agent:main:observer-card";
function digest(
revision: number,
health: SessionObserverDigest["health"],
overrides: Partial<SessionObserverDigest> = {},
): SessionObserverDigest {
return {
sessionKey,
runId: "run-current",
revision,
updatedAt: revision * 1_000,
headline: `Digest ${revision}`,
health,
...overrides,
};
}
afterEach(() => {
document.body.replaceChildren();
});
describe("observer board card", () => {
it("keeps superseded runs in history but resolves current status from the active run", () => {
const previous = digest(1, "done", { runId: "run-previous", headline: "Previous done" });
const current = [
digest(2, "on-track"),
digest(3, "grinding"),
digest(4, "stuck", {
assessment: "The repeated failure needs a different approach.",
planProgress: { completed: 2, total: 4 },
}),
];
const digests = [previous, ...current];
const container = document.createElement("div");
document.body.append(container);
render(
renderObserverWidget({
observer: { activeRunId: "run-current", digests, lastReadAt: 2_500 },
}),
container,
);
expect(container.querySelector(".observer-widget__current")?.textContent).toContain("Digest 4");
expect(container.querySelector(".observer-widget__current")?.textContent).not.toContain(
"Previous done",
);
expect(container.querySelectorAll(".observer-widget__run")).toHaveLength(2);
expect(
[...container.querySelectorAll(".observer-widget__timeline-headline")].map((entry) =>
entry.textContent?.trim(),
),
).toEqual(["Digest 4", "Digest 3", "Digest 2", "Previous done"]);
expect(container.querySelectorAll('[data-transition="true"]')).toHaveLength(2);
expect(container.textContent).toContain("2 of 4");
});
it("places the since-you-left divider after the newest unread block", () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderObserverWidget({
observer: {
activeRunId: "run-current",
digests: [digest(1, "on-track"), digest(2, "grinding"), digest(3, "stuck")],
lastReadAt: 1_500,
},
}),
container,
);
const boundary = container.querySelector("[data-test-id=observer-unread-boundary]");
expect(boundary?.textContent?.trim()).toBe("Since you left");
expect(boundary?.previousElementSibling?.textContent).toContain("Digest 2");
expect(boundary?.nextElementSibling?.textContent).toContain("Digest 1");
});
});

View File

@@ -0,0 +1,166 @@
import { html, nothing, type TemplateResult } from "lit";
import type { SessionObserverDigest } from "../../../../../packages/gateway-protocol/src/schema/sessions.js";
import { t } from "../../../i18n/index.ts";
import { formatTimeMs } from "../../format.ts";
import { pickFreshestObserverDigest } from "../../observer-digest.ts";
import type { BoardObserverContext } from "../view-types.ts";
type ObserverTimelineEntry = {
digest: SessionObserverDigest;
healthTransition: boolean;
runStart: boolean;
unreadBoundaryAfter: boolean;
};
function sameRun(
first: SessionObserverDigest | undefined,
second: SessionObserverDigest | undefined,
): boolean {
return first?.runId === second?.runId;
}
function buildObserverTimeline(
digests: readonly SessionObserverDigest[],
lastReadAt?: number,
): ObserverTimelineEntry[] {
const newestFirst = digests.toReversed();
const firstReadIndex =
lastReadAt == null ? -1 : newestFirst.findIndex((digest) => digest.updatedAt <= lastReadAt);
const unreadCount =
lastReadAt == null ? 0 : firstReadIndex === -1 ? newestFirst.length : firstReadIndex;
return newestFirst.map((digest, index) => {
const newer = newestFirst[index - 1];
const older = newestFirst[index + 1];
return {
digest,
runStart: !sameRun(digest, newer),
healthTransition: sameRun(digest, older) && digest.health !== older?.health,
unreadBoundaryAfter: unreadCount > 0 && index === unreadCount - 1,
};
});
}
function currentObserverDigest(
digests: readonly SessionObserverDigest[],
activeRunId: string | null,
): SessionObserverDigest | null {
const candidates = activeRunId
? digests.filter((digest) => digest.runId === activeRunId)
: digests;
return candidates.reduce<SessionObserverDigest | null>(
(freshest, digest) => pickFreshestObserverDigest(freshest, digest),
null,
);
}
function healthLabel(health: SessionObserverDigest["health"]): string {
return t(`chat.observer.health.${health}` as Parameters<typeof t>[0]);
}
function compactRunId(runId: string | undefined): string {
if (!runId) {
return t("chat.observer.boardUnknownRun");
}
return runId.length > 14 ? `${runId.slice(-12)}` : runId;
}
function renderCurrentStatus(digest: SessionObserverDigest): TemplateResult {
const progress = digest.planProgress;
const label = healthLabel(digest.health);
return html`
<header class="observer-widget__current" data-health=${digest.health}>
<div class="observer-widget__current-heading">
<span
class="observer-widget__health-dot"
data-health=${digest.health}
title=${label}
></span>
<div>
<span class="observer-widget__eyebrow">${t("chat.observer.boardCurrentStatus")}</span>
<strong>${digest.headline}</strong>
</div>
</div>
${digest.assessment
? html`<p class="observer-widget__assessment">${digest.assessment}</p>`
: nothing}
${progress
? html`<div class="observer-widget__progress">
<span>${t("chat.observer.plan")}</span>
<strong
>${t("chat.observer.progress", {
completed: String(progress.completed),
total: String(progress.total),
})}</strong
>
<span class="observer-widget__progress-track" aria-hidden="true">
<span
style=${`width: ${progress.total > 0 ? Math.min(100, (progress.completed / progress.total) * 100) : 0}%`}
></span>
</span>
</div>`
: nothing}
</header>
`;
}
function renderTimelineEntry(
entry: ObserverTimelineEntry,
currentRunId: string | null,
): TemplateResult {
const digest = entry.digest;
const label = healthLabel(digest.health);
const currentRun = currentRunId !== null && digest.runId === currentRunId;
return html`
${entry.runStart
? html`<div class="observer-widget__run" data-current=${currentRun ? "true" : "false"}>
<span
>${currentRun
? t("chat.observer.boardCurrentRun")
: t("chat.observer.boardPreviousRun")}</span
>
<code title=${digest.runId ?? ""}>${compactRunId(digest.runId)}</code>
</div>`
: nothing}
<div
class=${`observer-widget__timeline-row ${entry.healthTransition ? "observer-widget__timeline-row--transition" : ""}`}
data-health=${digest.health}
data-transition=${entry.healthTransition ? "true" : "false"}
>
<time datetime=${new Date(digest.updatedAt).toISOString()}
>${formatTimeMs(
digest.updatedAt,
{ hour: "numeric", minute: "2-digit", second: "2-digit" },
"",
)}</time
>
<span class="observer-widget__health-dot" data-health=${digest.health} title=${label}></span>
<span class="observer-widget__health-label">${label}</span>
<span class="observer-widget__timeline-headline">${digest.headline}</span>
</div>
${entry.unreadBoundaryAfter
? html`<div class="observer-widget__unread-boundary" data-test-id="observer-unread-boundary">
<span>${t("chat.observer.boardSinceYouLeft")}</span>
</div>`
: nothing}
`;
}
export function renderObserverWidget({
observer,
}: {
observer?: BoardObserverContext;
}): TemplateResult {
const digests = observer?.digests ?? [];
const current = currentObserverDigest(digests, observer?.activeRunId ?? null);
const currentRunId = observer?.activeRunId ?? current?.runId ?? null;
const timeline = buildObserverTimeline(digests, observer?.lastReadAt);
return html`
<div class="observer-widget" data-test-id="observer-widget">
${current ? renderCurrentStatus(current) : nothing}
<section class="observer-widget__timeline" aria-label=${t("chat.observer.boardTimeline")}>
<div class="observer-widget__timeline-title">${t("chat.observer.boardTimeline")}</div>
${timeline.map((entry) => renderTimelineEntry(entry, currentRunId))}
</section>
</div>
`;
}

View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import { ObserverDigestHistory } from "./observer-digest.ts";
const HISTORY_LIMIT = 50;
function digest(
revision: number,
overrides: Partial<SessionObserverDigest> = {},
): SessionObserverDigest {
return {
sessionKey: "agent:main:observer-history",
runId: "run-current",
revision,
updatedAt: revision * 1_000,
headline: `Digest ${revision}`,
health: "on-track",
...overrides,
};
}
describe("observer digest history", () => {
it("hydrates the first projection, reconciles later ones, and keeps entries newest-last", () => {
const history = new ObserverDigestHistory();
const sessionKey = "agent:main:observer-history";
expect(history.hydrate(sessionKey, digest(2))).toBe(true);
expect(history.hydrate(sessionKey, digest(3))).toBe(true);
expect(history.record(digest(1))).toBe(true);
expect(history.record(digest(4))).toBe(true);
expect(history.get(sessionKey).map((entry) => entry.revision)).toEqual([1, 2, 3, 4]);
});
it("uses revision then timestamp freshness and lets a live tie enrich its projection", () => {
const history = new ObserverDigestHistory();
const sessionKey = "agent:main:observer-history";
history.hydrate(sessionKey, digest(3));
expect(
history.record(
digest(3, { assessment: "Live detail", planProgress: { completed: 1, total: 2 } }),
),
).toBe(true);
expect(history.record(digest(3))).toBe(false);
expect(history.record(digest(3, { updatedAt: 2_999, headline: "Older correction" }))).toBe(
false,
);
expect(history.get(sessionKey)).toEqual([
expect.objectContaining({
revision: 3,
assessment: "Live detail",
planProgress: { completed: 1, total: 2 },
}),
]);
});
it("retains the newest fifty entries across run changes", () => {
const history = new ObserverDigestHistory();
for (let revision = 1; revision <= HISTORY_LIMIT + 5; revision += 1) {
history.record(digest(revision, { runId: revision < 20 ? "run-previous" : "run-current" }));
}
const entries = history.get("agent:main:observer-history");
expect(entries).toHaveLength(HISTORY_LIMIT);
expect(entries[0]?.revision).toBe(6);
expect(entries.at(-1)?.revision).toBe(55);
expect(new Set(entries.map((entry) => entry.runId))).toEqual(
new Set(["run-previous", "run-current"]),
);
});
});
describe("observer digest history hydration reconciliation", () => {
it("adopts a later projection after history exists and rejects stale ones", () => {
const history = new ObserverDigestHistory();
const base = {
sessionKey: "agent:main:s1",
runId: "r1",
revision: 2,
updatedAt: 100,
headline: "live",
health: "on-track" as const,
};
expect(history.record(base)).toBe(true);
expect(
history.hydrate("agent:main:s1", {
runId: "r1",
revision: 3,
updatedAt: 200,
headline: "projection advanced",
health: "grinding" as const,
}),
).toBe(true);
expect(history.get("agent:main:s1").at(-1)?.headline).toBe("projection advanced");
// An unseen older revision backfills the timeline but must never
// displace the newest entry that renders as current status.
expect(
history.hydrate("agent:main:s1", {
runId: "r1",
revision: 1,
updatedAt: 50,
headline: "stale projection",
health: "on-track" as const,
}),
).toBe(true);
expect(history.get("agent:main:s1").map((entry) => entry.revision)).toEqual([1, 2, 3]);
expect(history.get("agent:main:s1").at(-1)?.headline).toBe("projection advanced");
});
});
describe("observer digest history session identity", () => {
const entry = (revision: number, headline: string) => ({
sessionKey: "agent:main:identity",
runId: "r1",
revision,
updatedAt: revision * 100,
headline,
health: "on-track" as const,
});
it("clears history when the authoritative row reports a new sessionId", () => {
const history = new ObserverDigestHistory();
history.record(entry(1, "old conversation"));
expect(history.sync("agent:main:identity", "session-a")).toBe(false);
expect(history.sync("agent:main:identity", "session-a")).toBe(false);
expect(history.sync("agent:main:identity", "session-b")).toBe(true);
expect(history.get("agent:main:identity")).toEqual([]);
});
it("preserves replacement-conversation state when reset lands after the new row", () => {
const history = new ObserverDigestHistory();
history.hydrate("agent:main:identity", entry(1, "pre-reset"), "session-a");
history.sync("agent:main:identity", "session-b");
history.hydrate("agent:main:identity", entry(2, "fresh conversation"), "session-b");
history.markReset("agent:main:identity", "session-a");
expect(history.get("agent:main:identity").at(-1)?.headline).toBe("fresh conversation");
expect(history.hydrate("agent:main:identity", entry(3, "still fresh"), "session-b")).toBe(true);
});
it("sweeps late pre-reset live events when the replacement row arrives", () => {
const history = new ObserverDigestHistory();
history.hydrate("agent:main:identity", entry(3, "pre-reset"), "session-a");
history.markReset("agent:main:identity", "session-a");
history.record(entry(3, "late pre-reset event"));
expect(history.get("agent:main:identity").at(-1)?.headline).toBe("late pre-reset event");
expect(history.sync("agent:main:identity", "session-b")).toBe(true);
expect(history.get("agent:main:identity")).toEqual([]);
});
it("detects a remote reset even when the id was learned before any digest", () => {
const history = new ObserverDigestHistory();
expect(history.sync("agent:main:identity", "session-a")).toBe(false);
history.record(entry(1, "first digest"));
expect(history.sync("agent:main:identity", "session-b")).toBe(true);
expect(history.get("agent:main:identity")).toEqual([]);
});
it("refuses stale-row echoes after a local reset until a new sessionId arrives", () => {
const history = new ObserverDigestHistory();
history.hydrate("agent:main:identity", entry(4, "pre-reset"), "session-a");
history.markReset("agent:main:identity", "session-a");
expect(history.get("agent:main:identity")).toEqual([]);
expect(history.hydrate("agent:main:identity", entry(4, "echo"), "session-a")).toBe(false);
expect(history.get("agent:main:identity")).toEqual([]);
expect(history.hydrate("agent:main:identity", entry(5, "fresh"), "session-b")).toBe(true);
expect(history.get("agent:main:identity").at(-1)?.headline).toBe("fresh");
});
});

View File

@@ -1,9 +1,18 @@
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
// Freshest-wins reconciliation for observer digest copies (live event map vs
// projected session row). Revisions are session-monotonic by server contract
// (revision floors preserve continuity across runs), so cross-copy comparison
// by revision, then updatedAt, is safe.
type ComparableObserverDigest = { revision: number; updatedAt: number };
const OBSERVER_DIGEST_HISTORY_LIMIT = 50;
type ProjectedObserverDigest = Pick<
SessionObserverDigest,
"runId" | "headline" | "health" | "updatedAt" | "revision"
>;
/** Local live run id wins; otherwise the row's server-reported active runs
* identify the run, preferring the one the digest belongs to. */
export function resolveChatPaneObserverRunId(params: {
@@ -23,6 +32,14 @@ export function resolveChatPaneObserverRunId(params: {
: (activeRunIds[0] ?? null);
}
export function pickFreshestObserverDigest<T extends ComparableObserverDigest>(
first: T,
second: T,
): T;
export function pickFreshestObserverDigest<T extends ComparableObserverDigest>(
first: T | null | undefined,
second: T | null | undefined,
): T | null;
export function pickFreshestObserverDigest<T extends ComparableObserverDigest>(
first: T | null | undefined,
second: T | null | undefined,
@@ -38,3 +55,152 @@ export function pickFreshestObserverDigest<T extends ComparableObserverDigest>(
}
return first.updatedAt >= second.updatedAt ? first : second;
}
function compareObserverDigestFreshness(
left: ComparableObserverDigest,
right: ComparableObserverDigest,
): number {
if (left.revision === right.revision && left.updatedAt === right.updatedAt) {
return 0;
}
return pickFreshestObserverDigest(left, right) === left ? 1 : -1;
}
function observerDigestsEqual(left: SessionObserverDigest, right: SessionObserverDigest): boolean {
return (
left.sessionKey === right.sessionKey &&
left.runId === right.runId &&
left.revision === right.revision &&
left.updatedAt === right.updatedAt &&
left.headline === right.headline &&
left.assessment === right.assessment &&
left.health === right.health &&
left.planProgress?.completed === right.planProgress?.completed &&
left.planProgress?.total === right.planProgress?.total
);
}
/** Pane-local observer history. Entries stay oldest-first so trimming always
* drops the least-recent digest and renderers can reverse without re-sorting.
* Session identity is the invalidation signal: a conversation reset mints a
* new sessionId server-side, so an id change — never wall clocks — clears the
* old conversation's history, locally and across clients. */
export class ObserverDigestHistory {
private readonly bySession = new Map<string, SessionObserverDigest[]>();
// Authoritative sessionId per key, tracked independently of entries so an
// id learned before the first digest still detects a later remote reset.
private readonly sessionIds = new Map<string, string>();
// Local resets invalidate the pre-reset sessionId so a stale cached row
// cannot re-seed history before the refreshed row (new id) arrives.
private readonly invalidatedSessionIds = new Map<string, string>();
/** Reconcile with an authoritative session row. Returns true when a
* sessionId change cleared that session's history. */
sync(sessionKey: string, rowSessionId: string | null | undefined): boolean {
if (!rowSessionId) {
return false;
}
const invalidated = this.invalidatedSessionIds.get(sessionKey);
if (invalidated !== undefined && invalidated !== rowSessionId) {
this.invalidatedSessionIds.delete(sessionKey);
}
const known = this.sessionIds.get(sessionKey);
this.sessionIds.set(sessionKey, rowSessionId);
if (known !== undefined && known !== rowSessionId) {
return this.bySession.delete(sessionKey);
}
return false;
}
hydrate(
sessionKey: string,
digest: ProjectedObserverDigest | null | undefined,
rowSessionId?: string | null,
): boolean {
if (!digest) {
return false;
}
if (rowSessionId && this.invalidatedSessionIds.get(sessionKey) === rowSessionId) {
return false;
}
// Delegating to sync keeps hydrate order-independent from row updates.
this.sync(sessionKey, rowSessionId);
if (!this.bySession.has(sessionKey)) {
this.bySession.set(sessionKey, [{ ...digest, sessionKey }]);
return true;
}
// A projection can advance past the live listener (reconnect, missed
// event); reconcile it through the same freshness rules as live digests.
return this.record({ ...digest, sessionKey });
}
record(digest: SessionObserverDigest): boolean {
const history = this.bySession.get(digest.sessionKey) ?? [];
const existingIndex = history.findIndex(
(candidate) => candidate.runId === digest.runId && candidate.revision === digest.revision,
);
if (existingIndex >= 0) {
const existing = history[existingIndex]!;
// Passing the live candidate first lets an exact projection tie gain the
// richer assessment/plan fields while still using canonical freshness.
const freshest = pickFreshestObserverDigest(digest, existing);
if (freshest === existing) {
return false;
}
const next =
digest.revision === existing.revision && digest.updatedAt === existing.updatedAt
? {
...existing,
...digest,
assessment: digest.assessment ?? existing.assessment,
planProgress: digest.planProgress ?? existing.planProgress,
}
: freshest;
if (observerDigestsEqual(existing, next)) {
return false;
}
history[existingIndex] = next;
} else {
history.push(digest);
}
history.sort(compareObserverDigestFreshness);
if (history.length > OBSERVER_DIGEST_HISTORY_LIMIT) {
history.splice(0, history.length - OBSERVER_DIGEST_HISTORY_LIMIT);
}
this.bySession.set(digest.sessionKey, history);
return true;
}
get(sessionKey: string): readonly SessionObserverDigest[] {
return this.bySession.get(sessionKey) ?? [];
}
/** Conversation reset: drop history and refuse the pre-reset sessionId.
* Compare-and-invalidate: if the tracked identity already moved past the
* captured pre-reset id (the replacement row landed during the reset
* request), the new conversation's state must not be erased. The known id
* is kept on purpose otherwise: a late pre-reset live event can re-seed
* entries, and the next row's id mismatch must still sweep them. */
markReset(sessionKey: string, knownSessionId?: string | null): void {
// Accepted tradeoff: without a known pre-reset id (row absent from the
// cached roster at reset time) no tombstone is possible, so a late
// pre-reset live event could transiently survive until the next digest
// or row refresh. Guarding it needs a pending-reset quarantine state for
// a compound-improbable path with cosmetic-only impact.
const tracked = this.sessionIds.get(sessionKey);
if (knownSessionId && tracked !== undefined && tracked !== knownSessionId) {
return;
}
this.bySession.delete(sessionKey);
if (knownSessionId) {
this.sessionIds.set(sessionKey, knownSessionId);
this.invalidatedSessionIds.set(sessionKey, knownSessionId);
}
}
clear(): void {
this.bySession.clear();
this.sessionIds.clear();
this.invalidatedSessionIds.clear();
}
}

View File

@@ -8,7 +8,11 @@ import { t } from "../../i18n/index.ts";
import { isMockBoardEnabled, type BoardViewCallbacks } from "../../lib/board/provider.ts";
import type { BoardFace, BoardVisibleChatDock } from "../../lib/board/settings.ts";
import type { BoardTab } from "../../lib/board/types.ts";
import type { BoardViewSnapshot, BoardWidgetFrameUrl } from "../../lib/board/view-types.ts";
import type {
BoardObserverContext,
BoardViewSnapshot,
BoardWidgetFrameUrl,
} from "../../lib/board/view-types.ts";
export type BoardChatDockSize = {
height: number;
@@ -24,6 +28,7 @@ export type WorkboardCardChipProps = {
type BoardSessionSurfaceProps = {
snapshot: BoardViewSnapshot;
sessions: readonly GatewaySessionRow[];
observer?: BoardObserverContext;
activeTabId: string;
dock: BoardTab["chatDock"];
reopenDock: BoardVisibleChatDock;
@@ -166,6 +171,7 @@ function renderBoardView(props: BoardSessionSurfaceProps) {
.widgetFrameUrl=${props.widgetFrameUrl}
.callbacks=${props.callbacks}
.sessions=${props.sessions}
.observer=${props.observer}
.canMutate=${props.canMutate}
.canGrant=${props.canGrant}
></openclaw-board-view>

View File

@@ -10,6 +10,7 @@ import {
type BoardCommandEvent,
type BoardProvider,
} from "../../lib/board/provider.ts";
import type { ObserverDigestHistory } from "../../lib/observer-digest.ts";
import type { SessionCapability } from "../../lib/sessions/index.ts";
import { createStorageMock } from "../../test-helpers/storage.ts";
import "./chat-pane.ts";
@@ -24,6 +25,7 @@ type TestChatPane = HTMLElement & {
state: ChatPageHost;
createSession: () => Promise<boolean>;
resetConfirmationOpen: boolean;
observerDigestHistory: ObserverDigestHistory;
confirmConversationReset: () => Promise<boolean>;
settleResetConfirmation: (confirmed: boolean) => void;
updated: () => void;
@@ -35,7 +37,8 @@ type TestChatPane = HTMLElement & {
) => void;
persistBoardSessionView: (patch: { face?: "chat" | "dashboard"; activeTabId?: string }) => void;
resolveBoardProvider: () => BoardProvider;
resolveBoardView: () => { activeTabId: string; dock: string; face: string };
refreshBuiltinBoardSnapshot: () => void;
resolveBoardView: () => { activeTabId: string; dock: string; face: string; hasBoard: boolean };
};
type MockProvider = BoardProvider & { emitCommand(command: BoardCommandEvent["command"]): void };
@@ -93,6 +96,32 @@ afterEach(() => {
});
describe("chat pane board shell", () => {
it("adds the observer-only board face without replacing the default chat face", async () => {
const pane = createTestPane();
pane.boardProvider = nullBoardProvider("agent:main:observer-only");
pane.state.sessionKey = "agent:main:observer-only";
pane.observerDigestHistory.record({
sessionKey: "agent:main:observer-only",
runId: "run-1",
revision: 1,
updatedAt: 1_000,
headline: "Reviewing the board tests",
health: "on-track",
});
pane.refreshBuiltinBoardSnapshot();
await vi.waitFor(() =>
expect(pane.resolveBoardView()).toMatchObject({
activeTabId: "builtin-observer",
face: "chat",
hasBoard: true,
}),
);
pane.persistBoardSessionView({ face: "dashboard" });
expect(pane.resolveBoardView().face).toBe("dashboard");
});
it("gates New Chat when the current session has a board", async () => {
const sessions = {
create: vi.fn(async () => "agent:main:new"),

View File

@@ -95,6 +95,7 @@ import {
isGatewayMethodAdvertised,
} from "../../lib/gateway-methods.ts";
import {
ObserverDigestHistory,
pickFreshestObserverDigest,
resolveChatPaneObserverRunId,
} from "../../lib/observer-digest.ts";
@@ -466,9 +467,10 @@ class ChatPane extends OpenClawLightDomElement {
}
| undefined;
private readonly lastVisibleBoardDock = new Map<string, VisibleBoardDock>();
private swarmBoardSnapshot: BoardViewSnapshot | null = null;
private swarmBoardSnapshotBase: BoardSnapshot | null = null;
private swarmBoardSnapshotRequest = 0;
private readonly observerDigestHistory = new ObserverDigestHistory();
private builtinBoardSnapshot: BoardViewSnapshot | null = null;
private builtinBoardSnapshotBase: BoardSnapshot | null = null;
private builtinBoardSnapshotRequest = 0;
private readonly sessionDiscussionStates = new Map<string, SessionDiscussionState>();
private readonly sessionDiscussionOpenUrls = new Map<string, string | null>();
private readonly sessionDiscussionProbes = new Set<string>();
@@ -498,7 +500,7 @@ class ChatPane extends OpenClawLightDomElement {
() => this.resolveBoardProvider(),
(provider, notify) =>
provider.snapshot$.subscribe(() => {
this.refreshSwarmBoardSnapshot();
this.refreshBuiltinBoardSnapshot();
notify();
}),
)
@@ -1688,30 +1690,42 @@ class ChatPane extends OpenClawLightDomElement {
return normalized === "main" ? buildAgentMainSessionKey({ agentId: "main" }) : normalized;
}
private refreshSwarmBoardSnapshot(): void {
private refreshBuiltinBoardSnapshot(): void {
const state = this.state;
if (!state) {
return;
}
const request = ++this.swarmBoardSnapshotRequest;
const request = ++this.builtinBoardSnapshotRequest;
const sessions = state.sessionsResult?.sessions ?? [];
void import("../../lib/board/swarm-dashboard.ts").then(({ withSwarmWidget }) => {
if (request !== this.swarmBoardSnapshotRequest) {
void import("../../lib/board/builtin-dashboard.ts").then(({ withBuiltinDashboardWidgets }) => {
if (request !== this.builtinBoardSnapshotRequest) {
return;
}
const currentBase = this.resolveBoardProvider().snapshot$.value;
this.swarmBoardSnapshotBase = currentBase;
this.swarmBoardSnapshot = withSwarmWidget(currentBase, sessions);
const sessionKey = this.resolveBoardSessionKey(currentBase.sessionKey);
this.builtinBoardSnapshotBase = currentBase;
this.builtinBoardSnapshot = withBuiltinDashboardWidgets(
currentBase,
sessions,
this.observerDigestHistory.get(sessionKey),
);
this.requestUpdate();
});
}
private recordObserverDigest(digest: SessionObserverDigest): void {
const sessionKey = this.resolveBoardSessionKey(digest.sessionKey);
if (this.observerDigestHistory.record({ ...digest, sessionKey })) {
this.refreshBuiltinBoardSnapshot();
}
}
private resolveBoardView(): ResolvedBoardView {
const provider = this.resolveBoardProvider();
const baseSnapshot = provider.snapshot$.value;
const snapshot: BoardViewSnapshot =
this.swarmBoardSnapshotBase === baseSnapshot
? (this.swarmBoardSnapshot ?? baseSnapshot)
this.builtinBoardSnapshotBase === baseSnapshot
? (this.builtinBoardSnapshot ?? baseSnapshot)
: baseSnapshot;
const hasBoard = snapshot.tabs.length > 0 || snapshot.widgets.length > 0;
const sessionKey = this.resolveBoardSessionKey(snapshot.sessionKey);
@@ -2033,7 +2047,23 @@ class ChatPane extends OpenClawLightDomElement {
state.lastError = null;
state.chatError = null;
if (preservesBoard) {
// Captured before the await: the reset can land and refresh session rows
// mid-flight, and invalidating the post-reset id would eat fresh digests.
const preResetSessionId = state.sessionsResult?.sessions.find((row) =>
areUiSessionKeysEquivalent(row.key, previousSessionKey),
)?.sessionId;
const resetResult = await clearChatHistory(state);
if (resetResult !== "failed") {
// A reset reuses the session key; prior-run digests must not survive
// into the fresh conversation or keep injecting the observer card.
this.observerDigestHistory.markReset(
this.resolveBoardSessionKey(previousSessionKey),
preResetSessionId,
);
// Recompute rather than null: the builtin snapshot also carries the
// swarm card, which must survive an observer-only invalidation.
this.refreshBuiltinBoardSnapshot();
}
return resetResult !== "failed";
}
const nextSessionKey = await sessions.create({
@@ -2327,6 +2357,9 @@ class ChatPane extends OpenClawLightDomElement {
if (event.event === "task.suggestion" && event.payload) {
this.handleTaskSuggestionEvent(event.payload as TaskSuggestionEvent);
}
if (event.event === "session.observer" && event.payload) {
this.recordObserverDigest(event.payload as SessionObserverDigest);
}
handlePageGatewayEvent(state, event);
}
}),
@@ -2473,7 +2506,14 @@ class ChatPane extends OpenClawLightDomElement {
state.sessionsResultAgentId = stateValue.agentId;
state.sessionsLoading = stateValue.loading;
state.sessionsError = stateValue.error;
this.refreshSwarmBoardSnapshot();
for (const row of stateValue.result?.sessions ?? []) {
const sessionKey = this.resolveBoardSessionKey(row.key);
this.observerDigestHistory.sync(sessionKey, row.sessionId);
if (row.observerDigest) {
this.observerDigestHistory.hydrate(sessionKey, row.observerDigest, row.sessionId);
}
}
this.refreshBuiltinBoardSnapshot();
const selectedSession = stateValue.result?.sessions.find((row) =>
areUiSessionKeysEquivalent(row.key, state.sessionKey),
);
@@ -3654,6 +3694,13 @@ class ChatPane extends OpenClawLightDomElement {
? renderBoardSessionSurface({
snapshot: board.snapshot,
sessions: state.sessionsResult?.sessions ?? [],
observer: {
activeRunId: observerRunId,
digests: this.observerDigestHistory.get(
this.resolveBoardSessionKey(board.snapshot.sessionKey),
),
lastReadAt: selectedSession?.lastReadAt,
},
activeTabId: board.activeTabId,
dock: board.dock,
reopenDock: board.reopenDock,

View File

@@ -401,6 +401,224 @@ openclaw-board-widget-cell {
background: var(--danger, #ff6b6b);
}
.observer-widget {
box-sizing: border-box;
display: grid;
gap: 12px;
height: 100%;
overflow: auto;
padding: 12px;
}
.observer-widget__current {
background: color-mix(in srgb, var(--text, #d7dae0) 4%, transparent);
border: 1px solid var(--board-line);
border-left: 3px solid var(--muted, #8a919e);
border-radius: 9px;
display: grid;
gap: 8px;
padding: 10px 11px;
}
.observer-widget__current[data-health="on-track"],
.observer-widget__current[data-health="done"],
.observer-widget__current[data-health="wrapping-up"] {
border-left-color: var(--success, #45c486);
}
.observer-widget__current[data-health="grinding"] {
border-left-color: var(--warning, #e7b85c);
}
.observer-widget__current[data-health="stuck"],
.observer-widget__current[data-health="waiting-on-user"],
.observer-widget__current[data-health="failed"] {
border-left-color: var(--danger, #ff6b6b);
}
.observer-widget__current-heading {
align-items: start;
display: grid;
gap: 9px;
grid-template-columns: auto minmax(0, 1fr);
}
.observer-widget__current-heading > div {
display: grid;
gap: 3px;
min-width: 0;
}
.observer-widget__eyebrow,
.observer-widget__timeline-title,
.observer-widget__run {
color: var(--muted, #8a919e);
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.observer-widget__current-heading strong {
color: var(--text, #d7dae0);
font-size: 13px;
line-height: 1.35;
}
.observer-widget__assessment {
color: var(--muted, #8a919e);
font-size: 11px;
line-height: 1.45;
margin: 0 0 0 19px;
}
.observer-widget__progress {
align-items: center;
color: var(--muted, #8a919e);
display: grid;
font-size: 9px;
gap: 7px;
grid-template-columns: auto auto minmax(48px, 1fr);
margin-left: 19px;
}
.observer-widget__progress strong {
color: var(--text, #d7dae0);
font-weight: 600;
}
.observer-widget__progress-track {
background: color-mix(in srgb, var(--text, #d7dae0) 9%, transparent);
border-radius: 999px;
height: 3px;
overflow: hidden;
}
.observer-widget__progress-track > span {
background: var(--accent, #ff5c5c);
display: block;
height: 100%;
}
.observer-widget__timeline {
display: grid;
gap: 2px;
}
.observer-widget__timeline-title {
margin-bottom: 3px;
}
.observer-widget__run {
align-items: baseline;
border-top: 1px solid var(--board-line);
display: flex;
gap: 7px;
justify-content: space-between;
margin-top: 5px;
padding: 8px 3px 3px;
}
.observer-widget__run:first-of-type {
border-top: 0;
margin-top: 0;
padding-top: 3px;
}
.observer-widget__run[data-current="true"] > span {
color: var(--text, #d7dae0);
}
.observer-widget__run code {
color: var(--muted, #8a919e);
font-size: 9px;
letter-spacing: 0;
overflow: hidden;
text-overflow: ellipsis;
text-transform: none;
white-space: nowrap;
}
.observer-widget__timeline-row {
align-items: center;
border-radius: 6px;
display: grid;
gap: 7px;
grid-template-columns: 58px auto 66px minmax(0, 1fr);
min-height: 27px;
padding: 3px 5px;
}
.observer-widget__timeline-row--transition {
background: color-mix(in srgb, var(--text, #d7dae0) 4%, transparent);
box-shadow: inset 2px 0 color-mix(in srgb, currentcolor 48%, transparent);
}
.observer-widget__timeline-row > time,
.observer-widget__health-label {
color: var(--muted, #8a919e);
font-size: 9px;
}
.observer-widget__timeline-headline {
color: var(--text, #d7dae0);
font-size: 10.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.observer-widget__health-dot {
background: var(--muted, #8a919e);
border-radius: 50%;
height: 8px;
margin-top: 3px;
width: 8px;
}
.observer-widget__health-dot[data-health="on-track"],
.observer-widget__health-dot[data-health="done"],
.observer-widget__health-dot[data-health="wrapping-up"] {
background: var(--success, #45c486);
}
.observer-widget__health-dot[data-health="grinding"] {
background: var(--warning, #e7b85c);
}
.observer-widget__health-dot[data-health="stuck"],
.observer-widget__health-dot[data-health="waiting-on-user"],
.observer-widget__health-dot[data-health="failed"] {
background: var(--danger, #ff6b6b);
}
.observer-widget__unread-boundary {
align-items: center;
color: var(--accent, #ff5c5c);
display: grid;
font-size: 8px;
gap: 8px;
grid-template-columns: 1fr auto 1fr;
letter-spacing: 0.08em;
margin: 3px 0;
text-transform: uppercase;
}
.observer-widget__unread-boundary::before,
.observer-widget__unread-boundary::after {
border-top: 1px solid color-mix(in srgb, var(--accent, #ff5c5c) 42%, transparent);
content: "";
}
@media (max-width: 720px) {
.observer-widget__timeline-row {
grid-template-columns: 52px auto minmax(0, 1fr);
}
.observer-widget__health-label {
display: none;
}
}
.board-widget__plugin-loading,
.board-widget__disabled-plugin,
.workboard-widget__state {