From b228c83bfc790506582ebc8e546e1dc39bfdf153 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 17 Aug 2026 16:56:23 -0700 Subject: [PATCH] feat(dashboard): add session:progress board tile rendering the live progress card (#125438) * feat(dashboard): add session:progress board tile rendering the live progress card Advertise the core-owned widget kind via hello controlUiWidgetKinds at operator.read. Render it inline without an iframe from the session-progress-cards store. Pin it with dashboard tool widget_put using pluginKind session:progress and optional props.sessionKey. Follow up the progress-card unification from #125125. * fix(dashboard): surface session progress load failures Record protected progress-card read failures in the shared per-session store. Render an actionable board-tile error with retry instead of indefinite loading. Cover the rejected-read and successful-retry flow at the widget boundary. * fix(dashboard): honor progress tile access and activity Avoid progress-card reads while a retained board is inactive. Distinguish sharing denial from transient load failures and show the correct remedy. Qualify cross-session pinning docs and cover activation plus denial behavior. --- docs/tools/progress-card.md | 16 ++ scripts/control-ui-mock-dev.ts | 42 +++- src/agents/tools/dashboard-tool.ts | 4 +- src/gateway/control-ui-plugin-tabs.test.ts | 3 +- src/gateway/control-ui-plugin-tabs.ts | 50 +++-- .../board/board-widget-cell-plugin.test.ts | 179 ++++++++++++++++++ ui/src/components/session-progress-card.ts | 2 +- .../e2e/session-progress-widget.e2e.test.ts | 99 ++++++++++ ui/src/i18n/locales/en.ts | 5 + ui/src/lib/board/widgets/index.ts | 5 + ui/src/lib/board/widgets/session-progress.ts | 135 +++++++++++++ ui/src/lib/session-progress-cards.ts | 26 +++ ui/src/styles/chat/progress-card.css | 4 + 13 files changed, 538 insertions(+), 32 deletions(-) create mode 100644 ui/src/e2e/session-progress-widget.e2e.test.ts create mode 100644 ui/src/lib/board/widgets/session-progress.ts diff --git a/docs/tools/progress-card.md b/docs/tools/progress-card.md index 3ebb260a50ec..b9aceb038f0d 100644 --- a/docs/tools/progress-card.md +++ b/docs/tools/progress-card.md @@ -79,3 +79,19 @@ The current chat shows exactly one live card: - At narrow widths where the rail is hidden, the card appears in the collapsible surface beside the composer. The two placements are mutually exclusive. Other sessions can show their latest card in the sidebar hovercard. All placements read the same Gateway-backed state and refresh after `progressCard.changed` notifications. + +## Pin the card to the dashboard + +Use the `dashboard` tool to keep the live card on the current session's dashboard: + +```json +{ + "action": "widget_put", + "name": "session-progress", + "title": "Session progress", + "pluginKind": "session:progress", + "size": "md" +} +``` + +Omit `props.sessionKey` to follow the dashboard's session. To show another session's card, add `"props": { "sessionKey": "agent:main:release" }`. The current connection must participate in that session; otherwise select an accessible session or change its sharing. diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index b73233ea8b43..88a147a56936 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -956,6 +956,18 @@ function buildWorkboardMocks(baseTime: number) { revision: 1, tabs: [{ tabId: "main", title: "Workboard", position: 0, chatDock: "hidden" }], widgets: [ + { + name: "session-progress", + tabId: "main", + title: "Session progress", + contentKind: "plugin", + pluginKind: "session:progress", + sizeW: 6, + sizeH: 5, + position: 0, + grantState: "none", + revision: 1, + }, { name: "workboard-product-operations", tabId: "main", @@ -966,7 +978,7 @@ function buildWorkboardMocks(baseTime: number) { heightMode: "fixed", sizeW: 12, sizeH: 16, - position: 0, + position: 1, grantState: "none", revision: 1, }, @@ -976,6 +988,19 @@ function buildWorkboardMocks(baseTime: number) { "workboard.cards.list": { boards: [board], cards, statuses }, "workboard.cards.stats": { ...board, byAgent: {} }, "workboard.cards.move": { card: cards[0] }, + "progressCard.get": { + card: { + sessionKey, + revision: 2, + updatedAt: baseTime, + markdown: "**Product launch** is moving through final checks.", + steps: [ + { step: "Confirm release scope", status: "completed" }, + { step: "Validate onboarding flow", status: "in_progress" }, + { step: "Publish support handoff", status: "pending" }, + ], + }, + }, }, }; } @@ -1608,6 +1633,7 @@ async function createChatPickerScenario( "openclaw.changes.list", "openclaw.chat", "openclaw.chat.history", + "progressCard.get", "sessions.delete", "sessions.diff", "sessions.files.set", @@ -1633,15 +1659,16 @@ async function createChatPickerScenario( ] : []), ], - ...(fixture === "workboard" - ? { - controlUiWidgetKinds: [ + controlUiWidgetKinds: [ + { pluginId: "session", kind: "session:progress", label: "Session progress" }, + ...(fixture === "workboard" + ? [ { pluginId: "workboard", kind: "workboard:board", label: "Workboard board" }, { pluginId: "workboard", kind: "workboard:card", label: "Workboard card" }, { pluginId: "workboard", kind: "workboard:mini", label: "Workboard summary" }, - ], - } - : {}), + ] + : []), + ], // Terminal has a second gate beyond the advertised method (see // ui/src/lib/terminal-availability.ts). terminalEnabled: true, @@ -1689,6 +1716,7 @@ async function createChatPickerScenario( }, ], }, + "progressCard.get": { card: null }, "users.self": { profile: selfProfile }, // Talk settings page pickers: realtime catalog with the model/voice // suggestion lists the gateway emits for provider entries. diff --git a/src/agents/tools/dashboard-tool.ts b/src/agents/tools/dashboard-tool.ts index b4c56201e426..a8d1947dc08c 100644 --- a/src/agents/tools/dashboard-tool.ts +++ b/src/agents/tools/dashboard-tool.ts @@ -73,7 +73,7 @@ const DashboardToolSchema = Type.Object( Type.String({ pattern: BOARD_PLUGIN_KIND_PATTERN, description: - "Plugin widget kind, for example workboard:card, workboard:mini, or workboard:board", + "Plugin widget kind, for example session:progress, workboard:card, workboard:mini, or workboard:board", }), ), props: Type.Optional( @@ -257,7 +257,7 @@ export function createDashboardTool(opts: DashboardToolOptions = {}): AnyAgentTo label: "Dashboard", name: "dashboard", description: - "Read and arrange this session dashboard: read snapshot; tab_create/tab_update/tab_delete/tabs_reorder; widget_put/widget_move/widget_resize/widget_remove; focus_tab; set_chat_dock moves or hides the chat dock (left/right/bottom/hidden). Widgets use stable names. Create trusted plugin widgets with widget_put; examples: workboard:card props {cardId}, workboard:mini props {boardId, limit}, workboard:board props {boardId}. Sizes: sm=3x3, md=6x4, lg=8x6, xl=12x8, full=12x8 single-widget emphasis.", + "Read and arrange this session dashboard: read snapshot; tab_create/tab_update/tab_delete/tabs_reorder; widget_put/widget_move/widget_resize/widget_remove; focus_tab; set_chat_dock moves or hides the chat dock (left/right/bottom/hidden). Widgets use stable names. Create trusted plugin widgets with widget_put; examples: session:progress props {sessionKey?} renders the session's live progress card (omit sessionKey for the current session), workboard:card props {cardId}, workboard:mini props {boardId, limit}, workboard:board props {boardId}. Sizes: sm=3x3, md=6x4, lg=8x6, xl=12x8, full=12x8 single-widget emphasis.", parameters: DashboardToolSchema, execute: async (_toolCallId, rawArgs) => { const params = rawArgs as Record; diff --git a/src/gateway/control-ui-plugin-tabs.test.ts b/src/gateway/control-ui-plugin-tabs.test.ts index e65f1db3bc1c..25c7d6ab5251 100644 --- a/src/gateway/control-ui-plugin-tabs.test.ts +++ b/src/gateway/control-ui-plugin-tabs.test.ts @@ -95,7 +95,7 @@ describe("listControlUiPluginTabs", () => { expect(listControlUiPluginTabs([]).map((tab) => tab.id)).toEqual(["beta", "zed", "alpha"]); }); - it("projects scoped widget descriptors as namespaced kinds", () => { + it("merges the read-scoped core kind into deterministic plugin ordering", () => { activateDescriptors([ { pluginId: "workboard", @@ -119,6 +119,7 @@ describe("listControlUiPluginTabs", () => { expect(listControlUiPluginWidgetKinds([])).toEqual([]); expect(listControlUiPluginWidgetKinds(["operator.read"])).toEqual([ + { pluginId: "session", kind: "session:progress", label: "Session progress" }, { pluginId: "workboard", kind: "workboard:card", label: "Workboard card" }, { pluginId: "workboard", kind: "workboard:mini", label: "Workboard summary" }, ]); diff --git a/src/gateway/control-ui-plugin-tabs.ts b/src/gateway/control-ui-plugin-tabs.ts index e3f1113a0ccd..39bfd7648866 100644 --- a/src/gateway/control-ui-plugin-tabs.ts +++ b/src/gateway/control-ui-plugin-tabs.ts @@ -31,6 +31,12 @@ type ControlUiPluginWidgetKind = { label: string; }; +// `session` is a core-reserved widget-kind namespace. Core owns progress cards, +// so their availability is scope-gated rather than plugin-gated. +const CORE_CONTROL_UI_WIDGET_KINDS: readonly ControlUiPluginWidgetKind[] = [ + { pluginId: "session", kind: "session:progress", label: "Session progress" }, +]; + function findControlUiTabGatewayRoute( registry: PluginRegistry, tab: ControlUiPluginTab, @@ -125,28 +131,30 @@ export function listControlUiPluginWidgetKinds( scopes: readonly string[], ): ControlUiPluginWidgetKind[] { const entries = getActivePluginSessionExtensionRegistry()?.controlUiDescriptors ?? []; - return entries - .flatMap((entry) => { - const descriptor = entry.descriptor; - if (descriptor.surface !== "widget") { - return []; - } - const visible = (descriptor.requiredScopes ?? []).every( - (scope) => authorizeOperatorScopesForRequiredScope(scope, scopes).allowed, - ); - return visible - ? [ - { - pluginId: entry.pluginId, - kind: `${entry.pluginId}:${descriptor.id}`, - label: descriptor.label, - }, - ] - : []; - }) - .toSorted( - (left, right) => left.label.localeCompare(right.label) || left.kind.localeCompare(right.kind), + const coreEntries = authorizeOperatorScopesForRequiredScope(READ_SCOPE, scopes).allowed + ? CORE_CONTROL_UI_WIDGET_KINDS + : []; + const pluginEntries = entries.flatMap((entry) => { + const descriptor = entry.descriptor; + if (descriptor.surface !== "widget") { + return []; + } + const visible = (descriptor.requiredScopes ?? []).every( + (scope) => authorizeOperatorScopesForRequiredScope(scope, scopes).allowed, ); + return visible + ? [ + { + pluginId: entry.pluginId, + kind: `${entry.pluginId}:${descriptor.id}`, + label: descriptor.label, + }, + ] + : []; + }); + return [...coreEntries, ...pluginEntries].toSorted( + (left, right) => left.label.localeCompare(right.label) || left.kind.localeCompare(right.kind), + ); } /** Builds least-privilege grants only for visible tabs backed by same-plugin gateway routes. */ diff --git a/ui/src/components/board/board-widget-cell-plugin.test.ts b/ui/src/components/board/board-widget-cell-plugin.test.ts index 2befd1fbe374..472c81c62678 100644 --- a/ui/src/components/board/board-widget-cell-plugin.test.ts +++ b/ui/src/components/board/board-widget-cell-plugin.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { GatewayRequestError } from "../../api/gateway.ts"; import type { ApplicationContext } from "../../app/context.ts"; import type { BoardWidget } from "../../lib/board/types.ts"; import { createApplicationContextProvider } from "../../test-helpers/application-context.ts"; @@ -124,6 +125,184 @@ describe("plugin board widget cells", () => { expect(cell.querySelector('[data-test-id="board-widget-error"]')).toBeNull(); }); + it("renders an advertised session progress card and its empty state", async () => { + const dashboardSessionKey = "agent:main:dashboard"; + const targetSessionKey = "agent:main:target"; + const responses = [ + { + props: { sessionKey: targetSessionKey }, + response: { + card: { + sessionKey: targetSessionKey, + revision: 1, + updatedAt: 1, + markdown: "**Release** validation is active.", + steps: [{ step: "Run focused checks", status: "in_progress" }], + }, + }, + text: "Run focused checks", + requestedSessionKey: targetSessionKey, + }, + { + props: undefined, + response: { card: null }, + text: "No progress card yet", + requestedSessionKey: dashboardSessionKey, + }, + ] as const; + + for (const [index, scenario] of responses.entries()) { + const request = vi.fn(async () => scenario.response); + const context = { + gateway: { + snapshot: { + phase: "connected", + client: { request }, + hello: { + features: { methods: ["progressCard.get"] }, + controlUiWidgetKinds: [ + { pluginId: "session", kind: "session:progress", label: "Session progress" }, + ], + }, + }, + subscribe: () => () => undefined, + subscribeEvents: () => () => undefined, + }, + } as unknown as ApplicationContext; + const widget: BoardWidget = { + name: `session-progress-${index}`, + tabId: "main", + title: "Session progress", + contentKind: "plugin", + pluginKind: "session:progress", + ...(scenario.props ? { props: scenario.props } : {}), + sizeW: 6, + sizeH: 4, + position: index, + grantState: "none", + revision: 1, + }; + const provider = createApplicationContextProvider(context); + const cell = document.createElement("openclaw-board-widget-cell"); + cell.widget = widget; + cell.rect = { name: widget.name, x: 0, y: index * 4, w: 6, h: 4 }; + cell.sessionKey = dashboardSessionKey; + cell.active = index !== 0; + cell.callbacks = callbacks(); + provider.append(cell); + document.body.append(provider); + + if (index === 0) { + await cell.updateComplete; + expect(request).not.toHaveBeenCalled(); + cell.active = true; + } + + await vi.waitFor( + () => + expect(cell.querySelector("openclaw-session-progress-widget")?.textContent).toContain( + scenario.text, + ), + CHUNK_LOAD_WAIT, + ); + expect(request).toHaveBeenCalledWith("progressCard.get", { + sessionKey: scenario.requestedSessionKey, + }); + } + }); + + it("surfaces a failed session progress read and retries it", async () => { + const sessionKey = "agent:main:protected"; + let attempts = 0; + const deniedSessionKey = "agent:main:private"; + const request = vi.fn(async (_method: string, params: { sessionKey: string }) => { + if (params.sessionKey === deniedSessionKey) { + throw new GatewayRequestError({ + code: "INVALID_REQUEST", + message: "session is private for this connection", + details: { code: "SESSION_PARTICIPATION_REQUIRED" }, + }); + } + attempts += 1; + if (attempts === 1) { + throw new Error("session not shared"); + } + return { + card: { + sessionKey, + revision: 1, + updatedAt: 1, + steps: [{ step: "Recovered progress", status: "in_progress" }], + }, + }; + }); + const context = { + gateway: { + snapshot: { + phase: "connected", + client: { request }, + hello: { + features: { methods: ["progressCard.get"] }, + controlUiWidgetKinds: [ + { pluginId: "session", kind: "session:progress", label: "Session progress" }, + ], + }, + }, + subscribe: () => () => undefined, + subscribeEvents: () => () => undefined, + }, + } as unknown as ApplicationContext; + const widget: BoardWidget = { + name: "protected-session-progress", + tabId: "main", + title: "Session progress", + contentKind: "plugin", + pluginKind: "session:progress", + props: { sessionKey }, + sizeW: 6, + sizeH: 4, + position: 0, + grantState: "none", + revision: 1, + }; + const provider = createApplicationContextProvider(context); + const cell = document.createElement("openclaw-board-widget-cell"); + cell.widget = widget; + cell.rect = { name: widget.name, x: 0, y: 0, w: 6, h: 4 }; + cell.sessionKey = "agent:main:dashboard"; + cell.callbacks = callbacks(); + provider.append(cell); + document.body.append(provider); + + await vi.waitFor( + () => expect(cell.querySelector('[data-test-id="session-progress-error"]')).not.toBeNull(), + CHUNK_LOAD_WAIT, + ); + cell + .querySelector('[data-test-id="session-progress-error"] button') + ?.click(); + + await vi.waitFor( + () => + expect(cell.querySelector("openclaw-session-progress-widget")?.textContent).toContain( + "Recovered progress", + ), + CHUNK_LOAD_WAIT, + ); + expect(request).toHaveBeenCalledTimes(2); + + cell.widget = { ...widget, props: { sessionKey: deniedSessionKey } }; + await vi.waitFor( + () => + expect( + cell.querySelector('[data-test-id="session-progress-error"]')?.textContent, + ).toContain("Select a session you can access or change sharing for this session."), + CHUNK_LOAD_WAIT, + ); + expect(cell.querySelector('[data-test-id="session-progress-error"] button')).toBeNull(); + expect(request).toHaveBeenCalledTimes(3); + }); + it("passes activity to a retained Workboard plugin element", async () => { const context = { gateway: { diff --git a/ui/src/components/session-progress-card.ts b/ui/src/components/session-progress-card.ts index 7f996cc9c520..10d1aef91b86 100644 --- a/ui/src/components/session-progress-card.ts +++ b/ui/src/components/session-progress-card.ts @@ -4,7 +4,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { t } from "../i18n/index.ts"; import { toSanitizedMarkdownHtml } from "./markdown.ts"; -type SessionProgressCardPlacement = "composer" | "hovercard" | "rail"; +type SessionProgressCardPlacement = "board" | "composer" | "hovercard" | "rail"; const STATUS_LABEL_KEYS: Record[0]> = { completed: "sessionProgressCard.status.completed", diff --git a/ui/src/e2e/session-progress-widget.e2e.test.ts b/ui/src/e2e/session-progress-widget.e2e.test.ts new file mode 100644 index 000000000000..bfd4f23075a8 --- /dev/null +++ b/ui/src/e2e/session-progress-widget.e2e.test.ts @@ -0,0 +1,99 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { + controlUiBundledSettingsStorageKey, + installMockGateway, +} from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ + name: "Control UI session progress dashboard widget", + startServerBeforeBrowser: true, +}); +const sessionKey = "agent:main:progress-dashboard"; +const proofDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/session-progress-widget"); + +suite.define(() => { + it("renders the live session progress card through an advertised dashboard kind", async () => { + await suite.withPage({ viewport: { height: 900, width: 1280 } }, async ({ page }) => { + const gateway = await installMockGateway(page, { + sessionKey, + controlUiWidgetKinds: [ + { pluginId: "session", kind: "session:progress", label: "Session progress" }, + ], + featureMethods: [ + "board.get", + "chat.metadata", + "chat.startup", + "progressCard.get", + "sessions.patch", + ], + methodResponses: { + "board.get": { + sessionKey, + revision: 1, + tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }], + widgets: [ + { + name: "session-progress", + tabId: "main", + title: "Session progress", + contentKind: "plugin", + pluginKind: "session:progress", + sizeW: 6, + sizeH: 5, + position: 0, + grantState: "none", + revision: 1, + }, + ], + }, + "progressCard.get": { + card: { + sessionKey, + revision: 3, + updatedAt: 3, + markdown: "**Dashboard tile** follows the live session card.", + steps: [ + { step: "Inspect dashboard seams", status: "completed" }, + { step: "Render the progress tile", status: "in_progress" }, + { step: "Capture browser proof", status: "pending" }, + ], + }, + }, + }, + }); + const storageKey = controlUiBundledSettingsStorageKey(suite.server.baseUrl); + await page.addInitScript( + ({ key, storage }) => { + localStorage.setItem( + storage, + JSON.stringify({ boardSessionViews: { [key]: { activeTabId: "main" } } }), + ); + }, + { key: sessionKey, storage: storageKey }, + ); + + await page.goto(`${suite.server.baseUrl}dashboard`); + const card = page.locator('[data-progress-card-placement="board"]'); + await card.waitFor(); + expect(await card.locator("iframe").count()).toBe(0); + await expect.poll(() => card.textContent()).toContain("Dashboard tile"); + await expect.poll(() => card.textContent()).toContain("Inspect dashboard seams"); + await expect.poll(() => card.textContent()).toContain("Render the progress tile"); + await expect.poll(() => card.textContent()).toContain("Capture browser proof"); + await expect + .poll(() => card.locator(".session-progress-card__heading").textContent()) + .toContain("1/3"); + await expect.poll(() => gateway.getRequests("progressCard.get")).toHaveLength(1); + + await mkdir(proofDir, { recursive: true }); + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(proofDir, "session-progress-widget.png"), + }); + }); + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 06750afbe3a3..c8e7770107e4 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -170,6 +170,11 @@ export const en: TranslationMap = { ariaLabel: "Session progress", title: "Progress", noteLabel: "Progress note", + widgetLabel: "Session progress", + widgetLoading: "Loading session progress…", + widgetEmpty: "No progress card yet", + widgetUnavailable: "Session progress is unavailable.", + widgetAccessDenied: "Select a session you can access or change sharing for this session.", countLabel: "{completed} of {total} completed", stepLabel: "{step}, {status}", status: { diff --git a/ui/src/lib/board/widgets/index.ts b/ui/src/lib/board/widgets/index.ts index b1a0c08ea76f..4f25c15bbead 100644 --- a/ui/src/lib/board/widgets/index.ts +++ b/ui/src/lib/board/widgets/index.ts @@ -23,6 +23,11 @@ type PluginWidgetKindContribution = { * props, and use the standard gateway client for RPCs owned by their plugin. */ const PLUGIN_WIDGET_KIND_CONTRIBUTIONS: Record = { + "session:progress": { + kind: "session:progress", + label: t("sessionProgressCard.widgetLabel"), + loader: async () => (await import("./session-progress.ts")).renderSessionProgressWidget, + }, "workboard:board": { kind: "workboard:board", label: t("workboard.widget.boardLabel"), diff --git a/ui/src/lib/board/widgets/session-progress.ts b/ui/src/lib/board/widgets/session-progress.ts new file mode 100644 index 000000000000..2dcc2cc40a05 --- /dev/null +++ b/ui/src/lib/board/widgets/session-progress.ts @@ -0,0 +1,135 @@ +import { consume } from "@lit/context"; +import { html } from "lit"; +import { property } from "lit/decorators.js"; +import { applicationContext, type ApplicationContext } from "../../../app/context.ts"; +import { renderSessionProgressCard } from "../../../components/session-progress-card.ts"; +import { t } from "../../../i18n/index.ts"; +import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts"; +import { + sessionProgressCardsForGateway, + type SessionProgressCardStore, +} from "../../session-progress-cards.ts"; +import type { BoardWidget } from "../types.ts"; +import type { PluginBoardWidgetRenderer } from "./index.ts"; + +function readSessionKeyProp(widget: BoardWidget | undefined): string | undefined { + const value = widget?.props?.sessionKey; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +class OpenClawSessionProgressWidget extends OpenClawLightDomElement { + @consume({ context: applicationContext, subscribe: true }) + private context?: ApplicationContext; + + @property({ attribute: false }) widget?: BoardWidget; + @property({ attribute: false }) sessionKey = ""; + @property({ attribute: false }) active = true; + + private store?: SessionProgressCardStore; + private targetSessionKey = ""; + private unsubscribe?: () => void; + + override connectedCallback(): void { + super.connectedCallback(); + this.syncStore(); + } + + override willUpdate(): void { + this.syncStore(); + } + + override disconnectedCallback(): void { + this.releaseStore(); + super.disconnectedCallback(); + } + + override render() { + const loadError = this.store?.getError(this.targetSessionKey); + if (loadError) { + return html``; + } + const card = this.store?.get(this.targetSessionKey); + if (card === undefined) { + return html`

+ ${t("sessionProgressCard.widgetLoading")} +

`; + } + if (card === null) { + return html`

+ ${t("sessionProgressCard.widgetEmpty")} +

`; + } + return renderSessionProgressCard(card, "board"); + } + + private syncStore(): void { + const targetSessionKey = readSessionKeyProp(this.widget) ?? this.sessionKey.trim(); + const store = + this.active && this.context + ? sessionProgressCardsForGateway(this.context.gateway) + : undefined; + if (store === this.store && targetSessionKey === this.targetSessionKey) { + return; + } + this.releaseStore(); + this.store = store; + this.targetSessionKey = targetSessionKey; + if (store && targetSessionKey) { + store.watch(this, [targetSessionKey]); + this.unsubscribe = store.subscribe(() => this.requestUpdate()); + } + } + + private readonly retryLoad = () => { + if (!this.store || !this.targetSessionKey) { + return; + } + void this.store.load(this.targetSessionKey).catch(() => undefined); + }; + + private releaseStore(): void { + this.store?.unwatch(this); + this.unsubscribe?.(); + this.store = undefined; + this.unsubscribe = undefined; + } +} + +if (!customElements.get("openclaw-session-progress-widget")) { + customElements.define("openclaw-session-progress-widget", OpenClawSessionProgressWidget); +} + +export const renderSessionProgressWidget: PluginBoardWidgetRenderer = ({ + widget, + sessionKey, + active, +}) => html` + +`; + +declare global { + interface HTMLElementTagNameMap { + "openclaw-session-progress-widget": OpenClawSessionProgressWidget; + } +} diff --git a/ui/src/lib/session-progress-cards.ts b/ui/src/lib/session-progress-cards.ts index 3de5d4d645f7..7ad9bbb581f6 100644 --- a/ui/src/lib/session-progress-cards.ts +++ b/ui/src/lib/session-progress-cards.ts @@ -4,6 +4,7 @@ import type { ProgressCardStep, } from "@openclaw/gateway-protocol"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { GatewayRequestError } from "../api/gateway.ts"; import type { ApplicationGateway } from "../app/gateway.ts"; import { isGatewayMethodAdvertised } from "./gateway-methods.ts"; @@ -16,11 +17,14 @@ type CachedProgressCard = { revision: number | null; }; +type SessionProgressCardLoadError = "access-denied" | "unavailable"; + export type SessionProgressCardStore = { watch: (owner: object, sessionKeys: readonly string[]) => void; unwatch: (owner: object) => void; load: (sessionKey: string) => Promise; get: (sessionKey: string) => ProgressCard | null | undefined; + getError: (sessionKey: string) => SessionProgressCardLoadError | undefined; subscribe: (listener: () => void) => () => void; }; @@ -87,6 +91,7 @@ function parseProgressCard(value: unknown, sessionKey: string): ProgressCard | n function createStore(gateway: ApplicationGateway): SessionProgressCardStore { const watchedByOwner = new Map>(); const cache = new Map(); + const errors = new Map(); const loads = new Map>(); const loadGenerations = new Map(); const listeners = new Set<() => void>(); @@ -140,6 +145,9 @@ function createStore(gateway: ApplicationGateway): SessionProgressCardStore { if (!client) { return null; } + if (errors.delete(sessionKey)) { + notify(); + } const generation = loadGenerations.get(sessionKey) ?? 0; const clientAtRequest = client; const request = client @@ -156,6 +164,20 @@ function createStore(gateway: ApplicationGateway): SessionProgressCardStore { notify(); return card; }) + .catch((error: unknown) => { + if ( + (loadGenerations.get(sessionKey) ?? 0) === generation && + gateway.snapshot.client === clientAtRequest + ) { + const accessDenied = + error instanceof GatewayRequestError && + isRecord(error.details) && + error.details.code === "SESSION_PARTICIPATION_REQUIRED"; + errors.set(sessionKey, accessDenied ? "access-denied" : "unavailable"); + notify(); + } + throw error; + }) .finally(() => { if (loads.get(sessionKey) === request) { loads.delete(sessionKey); @@ -185,6 +207,7 @@ function createStore(gateway: ApplicationGateway): SessionProgressCardStore { loadGenerations.set(sessionKey, (loadGenerations.get(sessionKey) ?? 0) + 1); } cache.clear(); + errors.clear(); loads.clear(); notify(); } @@ -208,12 +231,14 @@ function createStore(gateway: ApplicationGateway): SessionProgressCardStore { } if (revision === null) { loadGenerations.set(sessionKey, (loadGenerations.get(sessionKey) ?? 0) + 1); + errors.delete(sessionKey); remember(sessionKey, { card: null, revision: null }); notify(); return; } loadGenerations.set(sessionKey, (loadGenerations.get(sessionKey) ?? 0) + 1); cache.delete(sessionKey); + errors.delete(sessionKey); if (watchedKeys().has(sessionKey)) { const active = loads.get(sessionKey); if (active) { @@ -265,6 +290,7 @@ function createStore(gateway: ApplicationGateway): SessionProgressCardStore { unwatch: (owner) => watch(owner, []), load, get: (sessionKey) => cache.get(sessionKey)?.card, + getError: (sessionKey) => errors.get(sessionKey), subscribe: (listener) => { listeners.add(listener); attach(); diff --git a/ui/src/styles/chat/progress-card.css b/ui/src/styles/chat/progress-card.css index b6e048f2eda8..a08601171c0b 100644 --- a/ui/src/styles/chat/progress-card.css +++ b/ui/src/styles/chat/progress-card.css @@ -20,6 +20,10 @@ border-top: 1px solid var(--border); } +.session-progress-card--board { + padding: var(--space-3); +} + .session-progress-card__heading { display: flex; align-items: center;