mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06:00
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.
This commit is contained in:
committed by
GitHub
parent
b3248bf8f1
commit
b228c83bfc
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<HTMLButtonElement>('[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: {
|
||||
|
||||
@@ -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<ProgressCardStep["status"], Parameters<typeof t>[0]> = {
|
||||
completed: "sessionProgressCard.status.completed",
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string, PluginWidgetKindContribution> = {
|
||||
"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"),
|
||||
|
||||
@@ -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`<div
|
||||
class="board-widget__plugin-loading"
|
||||
data-test-id="session-progress-error"
|
||||
role="alert"
|
||||
>
|
||||
<span
|
||||
>${t(
|
||||
loadError === "access-denied"
|
||||
? "sessionProgressCard.widgetAccessDenied"
|
||||
: "sessionProgressCard.widgetUnavailable",
|
||||
)}</span
|
||||
>
|
||||
${loadError === "unavailable"
|
||||
? html`<button class="btn btn--sm" type="button" @click=${this.retryLoad}>
|
||||
${t("common.retry")}
|
||||
</button>`
|
||||
: null}
|
||||
</div>`;
|
||||
}
|
||||
const card = this.store?.get(this.targetSessionKey);
|
||||
if (card === undefined) {
|
||||
return html`<p class="board-widget__plugin-loading">
|
||||
${t("sessionProgressCard.widgetLoading")}
|
||||
</p>`;
|
||||
}
|
||||
if (card === null) {
|
||||
return html`<p class="board-widget__plugin-loading">
|
||||
${t("sessionProgressCard.widgetEmpty")}
|
||||
</p>`;
|
||||
}
|
||||
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`
|
||||
<openclaw-session-progress-widget
|
||||
.widget=${widget}
|
||||
.sessionKey=${sessionKey}
|
||||
.active=${active}
|
||||
></openclaw-session-progress-widget>
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"openclaw-session-progress-widget": OpenClawSessionProgressWidget;
|
||||
}
|
||||
}
|
||||
@@ -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<ProgressCard | null>;
|
||||
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<object, Set<string>>();
|
||||
const cache = new Map<string, CachedProgressCard>();
|
||||
const errors = new Map<string, SessionProgressCardLoadError>();
|
||||
const loads = new Map<string, Promise<ProgressCard | null>>();
|
||||
const loadGenerations = new Map<string, number>();
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user