mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:21:35 +00:00
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:
committed by
GitHub
parent
d4f19bfd79
commit
aa35dc8c40
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
14
ui/src/lib/board/builtin-dashboard.ts
Normal file
14
ui/src/lib/board/builtin-dashboard.ts
Normal 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);
|
||||
}
|
||||
54
ui/src/lib/board/observer-dashboard.test.ts
Normal file
54
ui/src/lib/board/observer-dashboard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
ui/src/lib/board/observer-dashboard.ts
Normal file
47
ui/src/lib/board/observer-dashboard.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
85
ui/src/lib/board/widgets/observer.test.ts
Normal file
85
ui/src/lib/board/widgets/observer.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
166
ui/src/lib/board/widgets/observer.ts
Normal file
166
ui/src/lib/board/widgets/observer.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
170
ui/src/lib/observer-digest.test.ts
Normal file
170
ui/src/lib/observer-digest.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user