mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat(ui): render native Workboard widgets
This commit is contained in:
@@ -124,6 +124,7 @@ function isTrustedRetryEndpoint(url: string): boolean {
|
||||
}
|
||||
|
||||
export type GatewayControlUiPluginTab = NonNullable<HelloOk["controlUiTabs"]>[number];
|
||||
export type GatewayControlUiPluginWidgetKind = NonNullable<HelloOk["controlUiWidgetKinds"]>[number];
|
||||
export type GatewayHelloOk = Omit<HelloOk, "server" | "features" | "snapshot" | "policy"> & {
|
||||
server?: Partial<HelloOk["server"]>;
|
||||
features?: Partial<HelloOk["features"]>;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BoardViewWidget } from "../../lib/board/view-types.ts";
|
||||
import type { BoardWidgetCellCallbacks } from "./board-widget-cell.ts";
|
||||
import "./board-widget-cell.ts";
|
||||
|
||||
function callbacks(): BoardWidgetCellCallbacks {
|
||||
const noAction = vi.fn(async () => undefined);
|
||||
return {
|
||||
grant: noAction,
|
||||
movePointerDown: vi.fn(),
|
||||
resizePointerDown: vi.fn(),
|
||||
moveToTab: noAction,
|
||||
resizeTo: noAction,
|
||||
remove: noAction,
|
||||
nudge: noAction,
|
||||
focus: vi.fn(),
|
||||
focusChanged: vi.fn(),
|
||||
frameLoadFailed: noAction,
|
||||
widgetAppView: vi.fn(async () => ({ status: "stale" as const, error: "unused" })),
|
||||
refreshWidgetAppView: vi.fn(async () => ({ status: "stale" as const, error: "unused" })),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("plugin board widget cells", () => {
|
||||
it("renders a removable placeholder when the owning plugin is inactive", async () => {
|
||||
const widget: BoardViewWidget = {
|
||||
name: "work-item",
|
||||
tabId: "main",
|
||||
title: "Work item",
|
||||
contentKind: "plugin",
|
||||
pluginKind: "workboard:card",
|
||||
props: { cardId: "card-123" },
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
};
|
||||
const cellCallbacks = callbacks();
|
||||
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:test";
|
||||
cell.callbacks = cellCallbacks;
|
||||
document.body.append(cell);
|
||||
await cell.updateComplete;
|
||||
|
||||
const placeholder = cell.querySelector('[data-test-id="board-disabled-plugin"]');
|
||||
expect(placeholder?.textContent).toContain("Widget from disabled plugin workboard");
|
||||
const removeButton = placeholder?.querySelector("button");
|
||||
expect(removeButton).not.toBeNull();
|
||||
removeButton?.click();
|
||||
await vi.waitFor(() => expect(cellCallbacks.remove).toHaveBeenCalledWith(widget));
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,26 @@ export function renderBoardWidgetRejected(options: {
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderBoardDisabledPlugin(options: {
|
||||
pluginId: string;
|
||||
disabled: boolean;
|
||||
onRemove: () => void;
|
||||
}): TemplateResult {
|
||||
return html`
|
||||
<div class="board-widget__disabled-plugin" data-test-id="board-disabled-plugin">
|
||||
<strong>${t("board.widget.disabledPlugin", { pluginId: options.pluginId })}</strong>
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
?disabled=${options.disabled}
|
||||
@click=${options.onRemove}
|
||||
>
|
||||
${t("board.widget.remove")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderBoardWidgetError(error: unknown): TemplateResult {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return html`
|
||||
|
||||
@@ -14,7 +14,13 @@ import type {
|
||||
BoardViewWidget,
|
||||
BoardWidgetFrameUrl,
|
||||
} from "../../lib/board/view-types.ts";
|
||||
import { getBuiltinWidgetRenderer } from "../../lib/board/widgets/index.ts";
|
||||
import {
|
||||
getBuiltinWidgetRenderer,
|
||||
getPluginWidgetKindContribution,
|
||||
loadPluginWidgetRenderer,
|
||||
pluginIdForWidgetKind,
|
||||
type PluginBoardWidgetRenderer,
|
||||
} from "../../lib/board/widgets/index.ts";
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import { renderBoardMcpAppContent } from "./board-mcp-app-content.ts";
|
||||
import { BoardMcpAppLifecycle } from "./board-mcp-app-lifecycle.ts";
|
||||
@@ -22,6 +28,7 @@ import { renderBoardGrantedCapabilities } from "./board-widget-capabilities.ts";
|
||||
import {
|
||||
BOARD_SIZE_PRESETS,
|
||||
closeBoardWidgetMenu,
|
||||
renderBoardDisabledPlugin,
|
||||
renderBoardWidgetActionError,
|
||||
renderBoardWidgetError,
|
||||
renderBoardWidgetMenu,
|
||||
@@ -70,6 +77,11 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
|
||||
@state() private actionError = "";
|
||||
@state() private actionPending = false;
|
||||
@state() private pluginRenderer: PluginBoardWidgetRenderer | null = null;
|
||||
@state() private pluginRendererError = "";
|
||||
@state() private pluginRendererLabel = "";
|
||||
private pluginRendererKind = "";
|
||||
private pluginRendererLoadToken: object | null = null;
|
||||
private readonly appView = new BoardMcpAppLifecycle({
|
||||
connected: () => this.isConnected,
|
||||
requestUpdate: () => this.requestUpdate(),
|
||||
@@ -99,6 +111,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
this.frame.widgetChanged(previousWidget, this.widget);
|
||||
}
|
||||
this.appView.update(this.widget, this.callbacks);
|
||||
this.syncPluginRenderer();
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
@@ -119,6 +132,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.resetPluginRenderer();
|
||||
this.frame.disconnect();
|
||||
this.appView.disconnect();
|
||||
super.disconnectedCallback();
|
||||
@@ -232,9 +246,76 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
}
|
||||
return renderer({ sessions: this.sessions, sessionKey: this.sessionKey });
|
||||
}
|
||||
if (widget.contentKind === "plugin") {
|
||||
if (this.pluginRendererError) {
|
||||
return renderBoardWidgetError(this.pluginRendererError);
|
||||
}
|
||||
if (this.pluginRenderer) {
|
||||
return this.pluginRenderer({
|
||||
widget,
|
||||
sessionKey: this.sessionKey,
|
||||
requestUpdate: () => this.requestUpdate(),
|
||||
});
|
||||
}
|
||||
const pluginId = pluginIdForWidgetKind(widget.pluginKind);
|
||||
const activeKinds = this.context?.gateway.snapshot.hello?.controlUiWidgetKinds ?? [];
|
||||
const contribution = getPluginWidgetKindContribution(widget.pluginKind, activeKinds);
|
||||
return contribution
|
||||
? html`<p class="board-widget__plugin-loading">${t("board.widget.pluginLoading")}</p>`
|
||||
: renderBoardDisabledPlugin({
|
||||
pluginId,
|
||||
disabled: this.busy || this.actionPending || !this.canMutate,
|
||||
onRemove: () => void this.runAction(() => callbacks.remove(widget)),
|
||||
});
|
||||
}
|
||||
return this.frame.render(widget);
|
||||
}
|
||||
|
||||
private syncPluginRenderer(): void {
|
||||
const widget = this.widget;
|
||||
const activeKinds = this.context?.gateway.snapshot.hello?.controlUiWidgetKinds ?? [];
|
||||
const contribution =
|
||||
widget?.contentKind === "plugin"
|
||||
? getPluginWidgetKindContribution(widget.pluginKind, activeKinds)
|
||||
: null;
|
||||
if (!contribution) {
|
||||
if (this.pluginRendererKind || this.pluginRenderer || this.pluginRendererError) {
|
||||
this.resetPluginRenderer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.pluginRendererKind === contribution.kind) {
|
||||
return;
|
||||
}
|
||||
const loadToken = {};
|
||||
this.pluginRendererKind = contribution.kind;
|
||||
this.pluginRendererLabel = contribution.label;
|
||||
this.pluginRenderer = null;
|
||||
this.pluginRendererError = "";
|
||||
this.pluginRendererLoadToken = loadToken;
|
||||
void loadPluginWidgetRenderer(contribution)
|
||||
.then((renderer) => {
|
||||
if (this.pluginRendererLoadToken === loadToken) {
|
||||
this.pluginRenderer = renderer;
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (this.pluginRendererLoadToken === loadToken) {
|
||||
this.pluginRendererError = error instanceof Error ? error.message : String(error);
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resetPluginRenderer(): void {
|
||||
this.pluginRendererLoadToken = null;
|
||||
this.pluginRendererKind = "";
|
||||
this.pluginRendererLabel = "";
|
||||
this.pluginRenderer = null;
|
||||
this.pluginRendererError = "";
|
||||
}
|
||||
|
||||
private handleKeyDown(
|
||||
event: KeyboardEvent,
|
||||
widget: BoardViewWidget,
|
||||
@@ -289,7 +370,8 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
this.actionError !== "" ||
|
||||
widget.grantState === "pending" ||
|
||||
widget.grantState === "rejected";
|
||||
const contentScrollable = bodyScrollable || widget.contentKind === "mcp-app";
|
||||
const contentScrollable =
|
||||
bodyScrollable || widget.contentKind === "mcp-app" || widget.contentKind === "plugin";
|
||||
return html`
|
||||
<section
|
||||
class=${`board-widget ${this.dragging ? "board-widget--dragging" : ""}`}
|
||||
@@ -321,7 +403,9 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
: html`<span class="board-widget__kind"
|
||||
>${widget.contentKind === "mcp-app"
|
||||
? t("board.widget.kindMcp")
|
||||
: t("board.widget.kindHtml")}</span
|
||||
: widget.contentKind === "plugin"
|
||||
? this.pluginRendererLabel || t("board.widget.kindPlugin")
|
||||
: t("board.widget.kindHtml")}</span
|
||||
>`}
|
||||
${widget.contentKind === "builtin" ? nothing : renderBoardGrantedCapabilities(widget)}
|
||||
${readOnly
|
||||
|
||||
@@ -2640,9 +2640,23 @@ export const en: TranslationMap = {
|
||||
errorShow: "Show details",
|
||||
kindMcp: "MCP",
|
||||
kindHtml: "HTML",
|
||||
kindPlugin: "Plugin",
|
||||
pluginLoading: "Loading plugin widget…",
|
||||
disabledPlugin: "Widget from disabled plugin {pluginId}",
|
||||
},
|
||||
},
|
||||
workboard: {
|
||||
widget: {
|
||||
cardLabel: "Workboard card",
|
||||
summaryLabel: "Workboard summary",
|
||||
loading: "Loading Workboard…",
|
||||
cardIdRequired: "This widget needs a cardId prop.",
|
||||
cardMissing: "This Workboard card is no longer available.",
|
||||
unassigned: "Unassigned",
|
||||
openBoard: "Open board",
|
||||
statusCounts: "Cards by status",
|
||||
noActiveCards: "No ready or running cards.",
|
||||
},
|
||||
disabledHelpStart: "Workboard is disabled. Enable",
|
||||
enableConfigKey: "plugins.entries.workboard.enabled = true",
|
||||
disabledHelpEnd: ", then reload this tab.",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getPluginWidgetKindContribution,
|
||||
loadPluginWidgetRenderer,
|
||||
pluginIdForWidgetKind,
|
||||
type PluginBoardWidgetRenderer,
|
||||
} from "./index.ts";
|
||||
|
||||
describe("plugin board widget registry", () => {
|
||||
it("resolves only advertised first-party kinds", () => {
|
||||
const active = [{ pluginId: "workboard", kind: "workboard:card", label: "Workboard card" }];
|
||||
expect(getPluginWidgetKindContribution("workboard:card", active)).toMatchObject({
|
||||
kind: "workboard:card",
|
||||
label: "Workboard card",
|
||||
loader: expect.any(Function),
|
||||
});
|
||||
expect(getPluginWidgetKindContribution("workboard:mini", active)).toBeNull();
|
||||
expect(getPluginWidgetKindContribution("unknown:card", active)).toBeNull();
|
||||
expect(pluginIdForWidgetKind("workboard:card")).toBe("workboard");
|
||||
});
|
||||
|
||||
it("retries a renderer whose lazy import failed", async () => {
|
||||
const renderer: PluginBoardWidgetRenderer = () => ({}) as never;
|
||||
await expect(
|
||||
loadPluginWidgetRenderer({
|
||||
kind: "test:retry",
|
||||
label: "Retry",
|
||||
loader: async () => await Promise.reject(new Error("chunk unavailable")),
|
||||
}),
|
||||
).rejects.toThrow("chunk unavailable");
|
||||
|
||||
await expect(
|
||||
loadPluginWidgetRenderer({
|
||||
kind: "test:retry",
|
||||
label: "Retry",
|
||||
loader: async () => renderer,
|
||||
}),
|
||||
).resolves.toBe(renderer);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
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 { renderSwarmWidget } from "./swarm.ts";
|
||||
|
||||
type BuiltinBoardWidgetRenderer = (context: {
|
||||
@@ -7,6 +10,38 @@ type BuiltinBoardWidgetRenderer = (context: {
|
||||
sessionKey: string;
|
||||
}) => TemplateResult;
|
||||
|
||||
export type PluginBoardWidgetRenderer = (props: {
|
||||
widget: BoardViewWidget;
|
||||
sessionKey: string;
|
||||
requestUpdate: () => void;
|
||||
}) => TemplateResult;
|
||||
|
||||
export type PluginWidgetKindContribution = {
|
||||
kind: string;
|
||||
label: string;
|
||||
loader: () => Promise<PluginBoardWidgetRenderer>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin renderers are trusted first-party Control UI code. They render in the
|
||||
* cell without an iframe or grants, receive only widget/session/update props,
|
||||
* and use the standard gateway client for RPCs owned by their plugin.
|
||||
*/
|
||||
const PLUGIN_WIDGET_KIND_CONTRIBUTIONS: Record<string, PluginWidgetKindContribution> = {
|
||||
"workboard:card": {
|
||||
kind: "workboard:card",
|
||||
label: t("workboard.widget.cardLabel"),
|
||||
loader: async () => (await import("./workboard-card.ts")).renderWorkboardCardWidget,
|
||||
},
|
||||
"workboard:mini": {
|
||||
kind: "workboard:mini",
|
||||
label: t("workboard.widget.summaryLabel"),
|
||||
loader: async () => (await import("./workboard-mini.ts")).renderWorkboardMiniWidget,
|
||||
},
|
||||
};
|
||||
|
||||
const pluginRendererPromises = new Map<string, Promise<PluginBoardWidgetRenderer>>();
|
||||
|
||||
const BUILTIN_WIDGET_RENDERERS: Record<string, BuiltinBoardWidgetRenderer> = {
|
||||
swarm: renderSwarmWidget,
|
||||
};
|
||||
@@ -16,3 +51,41 @@ export function getBuiltinWidgetRenderer(
|
||||
): BuiltinBoardWidgetRenderer | null {
|
||||
return name ? (BUILTIN_WIDGET_RENDERERS[name] ?? null) : null;
|
||||
}
|
||||
|
||||
export function pluginIdForWidgetKind(kind: string | undefined): string {
|
||||
return kind?.split(":", 1)[0]?.trim() || "unknown";
|
||||
}
|
||||
|
||||
export function getPluginWidgetKindContribution(
|
||||
kind: string | undefined,
|
||||
activeKinds: readonly GatewayControlUiPluginWidgetKind[],
|
||||
): PluginWidgetKindContribution | null {
|
||||
if (!kind) {
|
||||
return null;
|
||||
}
|
||||
const contribution = PLUGIN_WIDGET_KIND_CONTRIBUTIONS[kind];
|
||||
if (!contribution) {
|
||||
return null;
|
||||
}
|
||||
const pluginId = pluginIdForWidgetKind(kind);
|
||||
return activeKinds.some((entry) => entry.kind === kind && entry.pluginId === pluginId)
|
||||
? contribution
|
||||
: null;
|
||||
}
|
||||
|
||||
export function loadPluginWidgetRenderer(
|
||||
contribution: PluginWidgetKindContribution,
|
||||
): Promise<PluginBoardWidgetRenderer> {
|
||||
const existing = pluginRendererPromises.get(contribution.kind);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const loaded = contribution.loader();
|
||||
pluginRendererPromises.set(contribution.kind, loaded);
|
||||
void loaded.catch(() => {
|
||||
if (pluginRendererPromises.get(contribution.kind) === loaded) {
|
||||
pluginRendererPromises.delete(contribution.kind);
|
||||
}
|
||||
});
|
||||
return loaded;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import type { WorkboardStatus } from "../../workboard/types.ts";
|
||||
import type { BoardViewWidget } from "../view-types.ts";
|
||||
import type { PluginBoardWidgetRenderer } from "./index.ts";
|
||||
import { WorkboardWidgetElement } from "./workboard-widget.ts";
|
||||
|
||||
class OpenClawWorkboardCardWidget extends WorkboardWidgetElement {
|
||||
private async handleStatusChange(event: Event): Promise<void> {
|
||||
const cardId = this.readStringProp("cardId");
|
||||
const card = this.cards.find((candidate) => candidate.id === cardId);
|
||||
const status = (event.currentTarget as HTMLSelectElement).value;
|
||||
if (!card || !this.statuses.includes(status as WorkboardStatus)) {
|
||||
return;
|
||||
}
|
||||
await this.moveCard(card, status as WorkboardStatus);
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
const cardId = this.readStringProp("cardId");
|
||||
if (!cardId) {
|
||||
return html`<p class="workboard-widget__state" role="alert">
|
||||
${t("workboard.widget.cardIdRequired")}
|
||||
</p>`;
|
||||
}
|
||||
if (this.loading && !this.loaded) {
|
||||
return html`<p class="workboard-widget__state">${t("workboard.widget.loading")}</p>`;
|
||||
}
|
||||
if (this.error) {
|
||||
return html`<p class="workboard-widget__state" role="alert">${this.error}</p>`;
|
||||
}
|
||||
const card = this.cards.find((candidate) => candidate.id === cardId);
|
||||
if (!card) {
|
||||
return html`<p class="workboard-widget__state">${t("workboard.widget.cardMissing")}</p>`;
|
||||
}
|
||||
const statuses = this.statuses.includes(card.status)
|
||||
? this.statuses
|
||||
: [card.status, ...this.statuses];
|
||||
const priority = card.priority.charAt(0).toUpperCase() + card.priority.slice(1);
|
||||
return html`
|
||||
<article class="workboard-widget-card" data-test-id="workboard-card-widget">
|
||||
<div class="workboard-widget-card__heading">
|
||||
<strong>${card.title}</strong>
|
||||
<span class=${`workboard-widget__status workboard-widget__status--${card.status}`}>
|
||||
${t(`workboard.status.${card.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
<dl class="workboard-widget-card__meta">
|
||||
<div>
|
||||
<dt>${t("workboard.fieldPriority")}</dt>
|
||||
<dd>${priority}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>${t("workboard.fieldAgent")}</dt>
|
||||
<dd>${card.agentId ?? t("workboard.widget.unassigned")}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
${statuses.length > 1
|
||||
? html`
|
||||
<label class="workboard-widget-card__move">
|
||||
<span>${t("workboard.fieldStatus")}</span>
|
||||
<select
|
||||
aria-label=${`${t("workboard.fieldStatus")}: ${card.title}`}
|
||||
.value=${card.status}
|
||||
@change=${(event: Event) => void this.handleStatusChange(event)}
|
||||
>
|
||||
${statuses.map(
|
||||
(status) => html`
|
||||
<option value=${status} ?selected=${status === card.status}>
|
||||
${t(`workboard.status.${status}`)}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`
|
||||
: nothing}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-workboard-card-widget")) {
|
||||
customElements.define("openclaw-workboard-card-widget", OpenClawWorkboardCardWidget);
|
||||
}
|
||||
|
||||
export const renderWorkboardCardWidget: PluginBoardWidgetRenderer = ({
|
||||
widget,
|
||||
sessionKey,
|
||||
requestUpdate,
|
||||
}: {
|
||||
widget: BoardViewWidget;
|
||||
sessionKey: string;
|
||||
requestUpdate: () => void;
|
||||
}) => html`
|
||||
<openclaw-workboard-card-widget
|
||||
.widget=${widget}
|
||||
.sessionKey=${sessionKey}
|
||||
.hostRequestUpdate=${requestUpdate}
|
||||
></openclaw-workboard-card-widget>
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"openclaw-workboard-card-widget": OpenClawWorkboardCardWidget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { pathForRoute } from "../../../app-route-paths.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { WORKBOARD_STATUSES, type WorkboardCard } from "../../workboard/types.ts";
|
||||
import type { BoardViewWidget } from "../view-types.ts";
|
||||
import type { PluginBoardWidgetRenderer } from "./index.ts";
|
||||
import { WorkboardWidgetElement } from "./workboard-widget.ts";
|
||||
|
||||
function cardBoardId(card: WorkboardCard): string {
|
||||
return card.metadata?.automation?.boardId ?? "default";
|
||||
}
|
||||
|
||||
class OpenClawWorkboardMiniWidget extends WorkboardWidgetElement {
|
||||
override render(): TemplateResult {
|
||||
if (this.loading && !this.loaded) {
|
||||
return html`<p class="workboard-widget__state">${t("workboard.widget.loading")}</p>`;
|
||||
}
|
||||
if (this.error) {
|
||||
return html`<p class="workboard-widget__state" role="alert">${this.error}</p>`;
|
||||
}
|
||||
const boardId = this.readStringProp("boardId") ?? "default";
|
||||
const limit = Math.min(10, this.readPositiveIntegerProp("limit", 5));
|
||||
const cards = this.cards.filter((card) => cardBoardId(card) === boardId);
|
||||
const topCards = cards
|
||||
.filter((card) => card.status === "ready" || card.status === "running")
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
Number(right.status === "running") - Number(left.status === "running") ||
|
||||
left.position - right.position ||
|
||||
left.title.localeCompare(right.title),
|
||||
)
|
||||
.slice(0, limit);
|
||||
const workboardPath = `${pathForRoute("workboard", this.context?.basePath ?? "")}?board=${encodeURIComponent(boardId)}`;
|
||||
return html`
|
||||
<section class="workboard-widget-mini" data-test-id="workboard-mini-widget">
|
||||
<header>
|
||||
<strong>${boardId}</strong>
|
||||
<a href=${workboardPath}>${t("workboard.widget.openBoard")}</a>
|
||||
</header>
|
||||
<div class="workboard-widget-mini__counts" aria-label=${t("workboard.widget.statusCounts")}>
|
||||
${WORKBOARD_STATUSES.map(
|
||||
(status) => html`
|
||||
<span title=${t(`workboard.status.${status}`)}>
|
||||
<b>${cards.filter((card) => card.status === status).length}</b>
|
||||
${t(`workboard.status.${status}`)}
|
||||
</span>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<div class="workboard-widget-mini__cards">
|
||||
${topCards.length > 0
|
||||
? topCards.map(
|
||||
(card) => html`
|
||||
<div class="workboard-widget-mini__card">
|
||||
<span
|
||||
class=${`workboard-widget__status workboard-widget__status--${card.status}`}
|
||||
>
|
||||
${t(`workboard.status.${card.status}`)}
|
||||
</span>
|
||||
<strong>${card.title}</strong>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
: html`<p class="workboard-widget__state">${t("workboard.widget.noActiveCards")}</p>`}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-workboard-mini-widget")) {
|
||||
customElements.define("openclaw-workboard-mini-widget", OpenClawWorkboardMiniWidget);
|
||||
}
|
||||
|
||||
export const renderWorkboardMiniWidget: PluginBoardWidgetRenderer = ({
|
||||
widget,
|
||||
sessionKey,
|
||||
requestUpdate,
|
||||
}: {
|
||||
widget: BoardViewWidget;
|
||||
sessionKey: string;
|
||||
requestUpdate: () => void;
|
||||
}) => html`
|
||||
<openclaw-workboard-mini-widget
|
||||
.widget=${widget}
|
||||
.sessionKey=${sessionKey}
|
||||
.hostRequestUpdate=${requestUpdate}
|
||||
></openclaw-workboard-mini-widget>
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"openclaw-workboard-mini-widget": OpenClawWorkboardMiniWidget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { property } from "lit/decorators.js";
|
||||
import type { GatewayBrowserClient } from "../../../api/gateway.ts";
|
||||
import { applicationContext, type ApplicationContext } from "../../../app/context.ts";
|
||||
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
|
||||
import { SubscriptionsController } from "../../../lit/subscriptions-controller.ts";
|
||||
import { moveWorkboardCard } from "../../workboard/mutations.ts";
|
||||
import { normalizeCardsPayload } from "../../workboard/normalization.ts";
|
||||
import { getWorkboardState, type WorkboardHost } from "../../workboard/runtime.ts";
|
||||
import {
|
||||
WORKBOARD_CHANGED_EVENT,
|
||||
type WorkboardCard,
|
||||
type WorkboardStatus,
|
||||
} from "../../workboard/types.ts";
|
||||
import type { BoardViewWidget } from "../view-types.ts";
|
||||
|
||||
export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
|
||||
@consume({ context: applicationContext, subscribe: true })
|
||||
protected context?: ApplicationContext;
|
||||
|
||||
@property({ attribute: false }) widget?: BoardViewWidget;
|
||||
@property({ attribute: false }) sessionKey = "";
|
||||
@property({ attribute: false }) hostRequestUpdate?: () => void;
|
||||
|
||||
protected cards: WorkboardCard[] = [];
|
||||
protected statuses: readonly WorkboardStatus[] = [];
|
||||
protected loading = false;
|
||||
protected loaded = false;
|
||||
protected error = "";
|
||||
private loadAttempted = false;
|
||||
|
||||
private readonly workboardHost: WorkboardHost = {};
|
||||
private client: GatewayBrowserClient | null = null;
|
||||
private refreshGeneration = 0;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
private refreshPending = false;
|
||||
private readonly subscriptions = new SubscriptionsController(this).effect(
|
||||
() => this.context?.gateway,
|
||||
(gateway) => {
|
||||
const sync = () => this.syncGateway(gateway.snapshot);
|
||||
sync();
|
||||
const unsubscribeSnapshot = gateway.subscribe(sync);
|
||||
const unsubscribeEvents = gateway.subscribeEvents((event) => {
|
||||
if (event.event === WORKBOARD_CHANGED_EVENT && gateway.snapshot.connected) {
|
||||
void this.refresh(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribeSnapshot();
|
||||
unsubscribeEvents();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.syncGateway(this.context?.gateway.snapshot);
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
if (!this.loadAttempted && !this.loading) {
|
||||
void this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.refreshGeneration += 1;
|
||||
this.refreshPromise = null;
|
||||
this.refreshPending = false;
|
||||
this.subscriptions.clear();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected readStringProp(key: string): string | undefined {
|
||||
const value = this.widget?.props?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
protected readPositiveIntegerProp(key: string, fallback: number): number {
|
||||
const value = this.widget?.props?.[key];
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
protected async moveCard(card: WorkboardCard, status: WorkboardStatus): Promise<void> {
|
||||
const client = this.client;
|
||||
if (!client || card.status === status) {
|
||||
return;
|
||||
}
|
||||
const state = getWorkboardState(this.workboardHost);
|
||||
state.cards = [...this.cards];
|
||||
state.statuses = this.statuses;
|
||||
state.loaded = true;
|
||||
state.loadAttempted = true;
|
||||
state.mutationReadiness = "ready";
|
||||
const position =
|
||||
Math.max(
|
||||
-1,
|
||||
...state.cards
|
||||
.filter((candidate) => candidate.status === status)
|
||||
.map((candidate) => candidate.position),
|
||||
) + 1;
|
||||
await moveWorkboardCard({
|
||||
host: this.workboardHost,
|
||||
client,
|
||||
cardId: card.id,
|
||||
status,
|
||||
position,
|
||||
requestUpdate: () => this.syncFromHost(),
|
||||
});
|
||||
this.syncFromHost();
|
||||
}
|
||||
|
||||
private syncGateway(snapshot: ApplicationContext["gateway"]["snapshot"] | undefined): void {
|
||||
const nextClient = snapshot?.connected ? snapshot.client : null;
|
||||
if (this.client === nextClient) {
|
||||
return;
|
||||
}
|
||||
this.client = nextClient;
|
||||
this.refreshGeneration += 1;
|
||||
this.refreshPromise = null;
|
||||
this.refreshPending = false;
|
||||
this.loaded = false;
|
||||
this.loadAttempted = false;
|
||||
this.loading = false;
|
||||
this.error = "";
|
||||
this.requestRender();
|
||||
if (nextClient) {
|
||||
void this.refresh(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async refresh(force = false): Promise<void> {
|
||||
const client = this.client;
|
||||
if (!client || (!force && this.loaded)) {
|
||||
return;
|
||||
}
|
||||
if (this.refreshPromise) {
|
||||
if (force) {
|
||||
this.refreshPending = true;
|
||||
}
|
||||
return await this.refreshPromise;
|
||||
}
|
||||
const generation = ++this.refreshGeneration;
|
||||
this.loadAttempted = true;
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
this.requestRender();
|
||||
const refresh = (async () => {
|
||||
try {
|
||||
const normalized = normalizeCardsPayload(await client.request("workboard.cards.list", {}));
|
||||
if (generation !== this.refreshGeneration || client !== this.client) {
|
||||
return;
|
||||
}
|
||||
this.cards = normalized.cards;
|
||||
this.statuses = normalized.statuses;
|
||||
this.loaded = true;
|
||||
} catch (error) {
|
||||
if (generation === this.refreshGeneration && client === this.client) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
} finally {
|
||||
if (generation === this.refreshGeneration) {
|
||||
this.loading = false;
|
||||
this.requestRender();
|
||||
}
|
||||
}
|
||||
})();
|
||||
this.refreshPromise = refresh;
|
||||
try {
|
||||
await refresh;
|
||||
} finally {
|
||||
if (this.refreshPromise === refresh) {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
const shouldRefreshAgain = this.refreshPending && client === this.client;
|
||||
this.refreshPending = false;
|
||||
if (shouldRefreshAgain) {
|
||||
await this.refresh(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private syncFromHost(): void {
|
||||
const state = getWorkboardState(this.workboardHost);
|
||||
this.cards = [...state.cards];
|
||||
this.statuses = state.statuses;
|
||||
this.error = state.error ?? "";
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
private requestRender(): void {
|
||||
this.requestUpdate();
|
||||
this.hostRequestUpdate?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ApplicationContext } from "../../../app/context.ts";
|
||||
import { createApplicationContextProvider } from "../../../test-helpers/application-context.ts";
|
||||
import type { BoardViewWidget } from "../view-types.ts";
|
||||
import "./workboard-card.ts";
|
||||
import "./workboard-mini.ts";
|
||||
|
||||
const cards = [
|
||||
{
|
||||
id: "card-ready",
|
||||
title: "Ready card",
|
||||
status: "ready",
|
||||
priority: "high",
|
||||
labels: [],
|
||||
position: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
agentId: "agent-a",
|
||||
metadata: { automation: { boardId: "ops" } },
|
||||
},
|
||||
{
|
||||
id: "card-running",
|
||||
title: "Running card",
|
||||
status: "running",
|
||||
priority: "normal",
|
||||
labels: [],
|
||||
position: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
metadata: { automation: { boardId: "ops" } },
|
||||
},
|
||||
{
|
||||
id: "card-done",
|
||||
title: "Done card",
|
||||
status: "done",
|
||||
priority: "low",
|
||||
labels: [],
|
||||
position: 2,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
metadata: { automation: { boardId: "ops" } },
|
||||
},
|
||||
] as const;
|
||||
|
||||
function pluginWidget(pluginKind: string, props: Record<string, unknown>): BoardViewWidget {
|
||||
return {
|
||||
name: pluginKind.replace(":", "-"),
|
||||
tabId: "main",
|
||||
contentKind: "plugin",
|
||||
pluginKind,
|
||||
props,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createContext(
|
||||
request: ReturnType<typeof vi.fn>,
|
||||
events?: { listener?: Parameters<ApplicationContext["gateway"]["subscribeEvents"]>[0] },
|
||||
): ApplicationContext {
|
||||
const subscribe = () => () => undefined;
|
||||
return {
|
||||
basePath: "/control",
|
||||
gateway: {
|
||||
snapshot: {
|
||||
client: { request } as never,
|
||||
connected: true,
|
||||
reconnecting: false,
|
||||
hello: null,
|
||||
assistantAgentId: null,
|
||||
sessionKey: "agent:main:test",
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
},
|
||||
subscribe,
|
||||
subscribeEvents: (
|
||||
listener: Parameters<ApplicationContext["gateway"]["subscribeEvents"]>[0],
|
||||
) => {
|
||||
if (events) {
|
||||
events.listener = listener;
|
||||
}
|
||||
return () => undefined;
|
||||
},
|
||||
} as unknown as ApplicationContext["gateway"],
|
||||
} as unknown as ApplicationContext;
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function mount<T extends HTMLElement>(
|
||||
element: T,
|
||||
context: ApplicationContext,
|
||||
request: ReturnType<typeof vi.fn>,
|
||||
): Promise<T> {
|
||||
const provider = createApplicationContextProvider(context);
|
||||
provider.append(element);
|
||||
document.body.append(provider);
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledWith("workboard.cards.list", {}));
|
||||
await (element as T & { updateComplete: Promise<boolean> }).updateComplete;
|
||||
await vi.waitFor(() => expect(element.textContent).not.toContain("Loading Workboard"));
|
||||
return element;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Workboard plugin widgets", () => {
|
||||
it("renders a card and moves it through the shared mutation helper", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "workboard.cards.list") {
|
||||
return { cards, statuses: ["ready", "running", "done"] };
|
||||
}
|
||||
if (method === "workboard.cards.move") {
|
||||
return { card: { ...cards[0], status: "running", position: 2 } };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
});
|
||||
const element = document.createElement("openclaw-workboard-card-widget");
|
||||
element.widget = pluginWidget("workboard:card", { cardId: "card-ready" });
|
||||
element.sessionKey = "agent:main:test";
|
||||
await mount(element, createContext(request), request);
|
||||
|
||||
expect(element.textContent).toContain("Ready card");
|
||||
expect(element.textContent).toContain("agent-a");
|
||||
const select = element.querySelector("select") as HTMLSelectElement;
|
||||
select.value = "running";
|
||||
select.dispatchEvent(new Event("change"));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(request).toHaveBeenCalledWith("workboard.cards.move", {
|
||||
id: "card-ready",
|
||||
status: "running",
|
||||
position: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders per-status board counts and the top ready/running cards", async () => {
|
||||
const request = vi.fn(async () => ({ cards, statuses: ["ready", "running", "done"] }));
|
||||
const element = document.createElement("openclaw-workboard-mini-widget");
|
||||
element.widget = pluginWidget("workboard:mini", { boardId: "ops", limit: 2 });
|
||||
element.sessionKey = "agent:main:test";
|
||||
await mount(element, createContext(request), request);
|
||||
|
||||
const counts = [...element.querySelectorAll(".workboard-widget-mini__counts span")].map(
|
||||
(entry) => entry.textContent?.replace(/\s+/g, " ").trim(),
|
||||
);
|
||||
expect(counts).toContain("1 Ready");
|
||||
expect(counts).toContain("1 Running");
|
||||
expect(counts).toContain("1 Done");
|
||||
expect(element.textContent).toContain("Running card");
|
||||
expect(element.textContent).toContain("Ready card");
|
||||
expect(element.querySelector("a")?.getAttribute("href")).toBe("/control/workboard?board=ops");
|
||||
});
|
||||
|
||||
it("queues a second refresh when a change arrives during an active list request", async () => {
|
||||
const firstList = deferred<unknown>();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "workboard.cards.list") {
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
}
|
||||
return request.mock.calls.length === 1
|
||||
? await firstList.promise
|
||||
: { cards, statuses: ["ready", "running", "done"] };
|
||||
});
|
||||
const events: {
|
||||
listener?: Parameters<ApplicationContext["gateway"]["subscribeEvents"]>[0];
|
||||
} = {};
|
||||
const element = document.createElement("openclaw-workboard-mini-widget");
|
||||
element.widget = pluginWidget("workboard:mini", { boardId: "ops" });
|
||||
const provider = createApplicationContextProvider(createContext(request, events));
|
||||
provider.append(element);
|
||||
document.body.append(provider);
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
||||
|
||||
events.listener?.({
|
||||
type: "event",
|
||||
event: "plugin.workboard.changed",
|
||||
payload: { epoch: "epoch-a", revision: 2 },
|
||||
});
|
||||
firstList.resolve({ cards: [], statuses: ["ready", "running", "done"] });
|
||||
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -390,6 +390,151 @@ openclaw-board-widget-cell {
|
||||
background: var(--danger, #ff6b6b);
|
||||
}
|
||||
|
||||
.board-widget__plugin-loading,
|
||||
.board-widget__disabled-plugin,
|
||||
.workboard-widget__state {
|
||||
align-content: center;
|
||||
color: var(--muted, #8a919e);
|
||||
display: grid;
|
||||
font-size: 11px;
|
||||
gap: 10px;
|
||||
justify-items: center;
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.board-widget__disabled-plugin strong {
|
||||
color: var(--text, #d7dae0);
|
||||
}
|
||||
|
||||
openclaw-workboard-card-widget,
|
||||
openclaw-workboard-mini-widget {
|
||||
display: block;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.workboard-widget-card,
|
||||
.workboard-widget-mini {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.workboard-widget-card__heading,
|
||||
.workboard-widget-mini > header,
|
||||
.workboard-widget-mini__card {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workboard-widget-card__heading > strong,
|
||||
.workboard-widget-mini__card > strong {
|
||||
color: var(--text, #d7dae0);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workboard-widget__status {
|
||||
border: 1px solid var(--board-line);
|
||||
border-radius: 999px;
|
||||
color: var(--muted, #8a919e);
|
||||
flex: 0 0 auto;
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.workboard-widget__status--ready,
|
||||
.workboard-widget__status--running {
|
||||
border-color: color-mix(in srgb, var(--accent, #ff5c5c) 45%, transparent);
|
||||
color: var(--accent, #ff5c5c);
|
||||
}
|
||||
|
||||
.workboard-widget-card__meta {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workboard-widget-card__meta div,
|
||||
.workboard-widget-mini__counts span {
|
||||
background: color-mix(in srgb, var(--text, #d7dae0) 3%, transparent);
|
||||
border: 1px solid var(--board-line);
|
||||
border-radius: 8px;
|
||||
min-width: 0;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.workboard-widget-card__meta dt,
|
||||
.workboard-widget-card__move > span {
|
||||
color: var(--muted, #8a919e);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workboard-widget-card__meta dd {
|
||||
color: var(--text, #d7dae0);
|
||||
font-size: 11px;
|
||||
margin: 3px 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workboard-widget-card__move {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.workboard-widget-card__move select {
|
||||
background: var(--panel, #171a20);
|
||||
border: 1px solid var(--board-line);
|
||||
border-radius: 7px;
|
||||
color: var(--text, #d7dae0);
|
||||
font: inherit;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.workboard-widget-mini > header a {
|
||||
color: var(--accent, #ff5c5c);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.workboard-widget-mini__counts {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.workboard-widget-mini__counts span {
|
||||
color: var(--muted, #8a919e);
|
||||
display: grid;
|
||||
font-size: 8px;
|
||||
gap: 2px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.workboard-widget-mini__counts b {
|
||||
color: var(--text, #d7dae0);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.workboard-widget-mini__cards {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.workboard-widget-mini__card {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@keyframes swarm-widget-pulse {
|
||||
50% {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #ff5c5c) 25%, transparent);
|
||||
|
||||
Reference in New Issue
Block a user