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:
Peter Steinberger
2026-08-17 16:56:23 -07:00
committed by GitHub
parent b3248bf8f1
commit b228c83bfc
13 changed files with 538 additions and 32 deletions
+16
View File
@@ -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.
+35 -7
View File
@@ -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.
+2 -2
View File
@@ -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>;
+2 -1
View File
@@ -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" },
]);
+29 -21
View File
@@ -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: {
+1 -1
View File
@@ -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"),
});
});
});
});
+5
View File
@@ -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: {
+5
View File
@@ -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;
}
}
+26
View File
@@ -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();
+4
View File
@@ -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;