diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index f7550b4e5201..0e0cc09ab80a 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -1068,9 +1068,10 @@ async function createChatPickerScenario(): Promise 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 { 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 { 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 { 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, diff --git a/ui/src/components/board/board-view.ts b/ui/src/components/board/board-view.ts index 7362fc2a1642..a2111e1346b1 100644 --- a/ui/src/components/board/board-view.ts +++ b/ui/src/components/board/board-view.ts @@ -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} diff --git a/ui/src/components/board/board-widget-cell.ts b/ui/src/components/board/board-widget-cell.ts index 035701bf5410..e29ce6d3bcce 100644 --- a/ui/src/components/board/board-widget-cell.ts +++ b/ui/src/components/board/board-widget-cell.ts @@ -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) { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index b2a824e113e7..cd980e77b30f 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/lib/board/builtin-dashboard.ts b/ui/src/lib/board/builtin-dashboard.ts new file mode 100644 index 000000000000..ad96121f4a35 --- /dev/null +++ b/ui/src/lib/board/builtin-dashboard.ts @@ -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); +} diff --git a/ui/src/lib/board/observer-dashboard.test.ts b/ui/src/lib/board/observer-dashboard.test.ts new file mode 100644 index 000000000000..d7c2b3c38f26 --- /dev/null +++ b/ui/src/lib/board/observer-dashboard.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/board/observer-dashboard.ts b/ui/src/lib/board/observer-dashboard.ts new file mode 100644 index 000000000000..492744327c9b --- /dev/null +++ b/ui/src/lib/board/observer-dashboard.ts @@ -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 }; +} diff --git a/ui/src/lib/board/view-types.ts b/ui/src/lib/board/view-types.ts index b0b63a7dc189..a93cfc83e475 100644 --- a/ui/src/lib/board/view-types.ts +++ b/ui/src/lib/board/view-types.ts @@ -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 & { - builtin: "swarm"; + builtin: "observer" | "swarm"; contentKind: "builtin"; readOnly: true; }; @@ -20,6 +21,12 @@ export type BoardViewSnapshot = Omit & { widgets: BoardViewWidget[]; }; +export type BoardObserverContext = { + activeRunId: string | null; + digests: readonly SessionObserverDigest[]; + lastReadAt?: number; +}; + export type BoardViewCallbacks = { applyOps: (ops: BoardOp[]) => Promise; grant: (name: string, decision: BoardGrantDecision) => Promise; diff --git a/ui/src/lib/board/widgets/index.ts b/ui/src/lib/board/widgets/index.ts index b186ff49bd50..b8a1f1e29d89 100644 --- a/ui/src/lib/board/widgets/index.ts +++ b/ui/src/lib/board/widgets/index.ts @@ -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>(); const BUILTIN_WIDGET_RENDERERS: Record = { + observer: renderObserverWidget, swarm: renderSwarmWidget, }; diff --git a/ui/src/lib/board/widgets/observer.test.ts b/ui/src/lib/board/widgets/observer.test.ts new file mode 100644 index 000000000000..efc3c738bd0a --- /dev/null +++ b/ui/src/lib/board/widgets/observer.test.ts @@ -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 { + 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"); + }); +}); diff --git a/ui/src/lib/board/widgets/observer.ts b/ui/src/lib/board/widgets/observer.ts new file mode 100644 index 000000000000..e3c5c28a691c --- /dev/null +++ b/ui/src/lib/board/widgets/observer.ts @@ -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( + (freshest, digest) => pickFreshestObserverDigest(freshest, digest), + null, + ); +} + +function healthLabel(health: SessionObserverDigest["health"]): string { + return t(`chat.observer.health.${health}` as Parameters[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` +
+
+ +
+ ${t("chat.observer.boardCurrentStatus")} + ${digest.headline} +
+
+ ${digest.assessment + ? html`

${digest.assessment}

` + : nothing} + ${progress + ? html`
+ ${t("chat.observer.plan")} + ${t("chat.observer.progress", { + completed: String(progress.completed), + total: String(progress.total), + })} + +
` + : nothing} +
+ `; +} + +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`
+ ${currentRun + ? t("chat.observer.boardCurrentRun") + : t("chat.observer.boardPreviousRun")} + ${compactRunId(digest.runId)} +
` + : nothing} +
+ + + ${label} + ${digest.headline} +
+ ${entry.unreadBoundaryAfter + ? html`
+ ${t("chat.observer.boardSinceYouLeft")} +
` + : 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` +
+ ${current ? renderCurrentStatus(current) : nothing} +
+
${t("chat.observer.boardTimeline")}
+ ${timeline.map((entry) => renderTimelineEntry(entry, currentRunId))} +
+
+ `; +} diff --git a/ui/src/lib/observer-digest.test.ts b/ui/src/lib/observer-digest.test.ts new file mode 100644 index 000000000000..b4693508a53a --- /dev/null +++ b/ui/src/lib/observer-digest.test.ts @@ -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 { + 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"); + }); +}); diff --git a/ui/src/lib/observer-digest.ts b/ui/src/lib/observer-digest.ts index b102fb883965..314fddfef082 100644 --- a/ui/src/lib/observer-digest.ts +++ b/ui/src/lib/observer-digest.ts @@ -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( + first: T, + second: T, +): T; +export function pickFreshestObserverDigest( + first: T | null | undefined, + second: T | null | undefined, +): T | null; export function pickFreshestObserverDigest( first: T | null | undefined, second: T | null | undefined, @@ -38,3 +55,152 @@ export function pickFreshestObserverDigest( } 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(); + // 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(); + // 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(); + + /** 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(); + } +} diff --git a/ui/src/pages/chat/board-session-surface.ts b/ui/src/pages/chat/board-session-surface.ts index b831365b44ad..6383567d17e0 100644 --- a/ui/src/pages/chat/board-session-surface.ts +++ b/ui/src/pages/chat/board-session-surface.ts @@ -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} > diff --git a/ui/src/pages/chat/chat-pane-board.test.ts b/ui/src/pages/chat/chat-pane-board.test.ts index f526985bcb52..d70a5d18ca99 100644 --- a/ui/src/pages/chat/chat-pane-board.test.ts +++ b/ui/src/pages/chat/chat-pane-board.test.ts @@ -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; resetConfirmationOpen: boolean; + observerDigestHistory: ObserverDigestHistory; confirmConversationReset: () => Promise; 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"), diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index bc5316400d24..0048f5c55672 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -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(); - 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(); private readonly sessionDiscussionOpenUrls = new Map(); private readonly sessionDiscussionProbes = new Set(); @@ -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, diff --git a/ui/src/styles/board.css b/ui/src/styles/board.css index 6d45155c2be8..f2d18bbd4542 100644 --- a/ui/src/styles/board.css +++ b/ui/src/styles/board.css @@ -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 {