feat: add workboard dashboard plugin

This commit is contained in:
Peter Steinberger
2026-05-24 00:17:45 +01:00
parent ed62aefeee
commit 86ed25af34
52 changed files with 2088 additions and 1 deletions

6
.github/labeler.yml vendored
View File

@@ -47,6 +47,12 @@
- "extensions/meeting-notes/**"
- "docs/plugins/meeting-notes.md"
- "src/meeting-notes/**"
"plugin: workboard":
- changed-files:
- any-glob-to-any-file:
- "extensions/workboard/**"
- "docs/plugins/workboard.md"
- "docs/plugins/reference/workboard.md"
"plugin: migrate-hermes":
- changed-files:
- any-glob-to-any-file:

View File

@@ -299,6 +299,7 @@ Docs: https://docs.openclaw.ai
- Packaging: exclude documentation images and assets from the npm tarball, reducing published package size without affecting runtime docs search or CLI behavior. Thanks @SebTardif.
- Media understanding: stop auto-probing Gemini CLI and use Antigravity CLI only as a lower-priority image/video fallback after configured provider APIs.
- Agents/subagents: limit default sub-agent bootstrap context to `AGENTS.md` and `TOOLS.md`, keeping persona, identity, user, memory, heartbeat, and setup files out of delegated workers by default. (#85283) Thanks @100yenadmin.
- Maintainer skills: require clean autoreview before surfacing bug-sweep PR URLs and treat changelog-only conflicts as routine busy-main churn.
- Maintainer skills: exclude plugin SDK/API boundary work from `openclaw-landable-bug-sweep` so bugbash sweeps stay focused on small paper-cut fixes.
- QA-Lab/diagnostics: extend the OpenTelemetry smoke harness to prove trace, metric, and log export, and add first-class Prometheus and observability smoke aliases.
- Plugin SDK: add a generic channel-message poll sender so channel plugins can expose poll delivery without depending on channel-specific SDK facades.

View File

@@ -1228,6 +1228,8 @@
"plugins/codex-native-plugins",
"plugins/codex-computer-use",
"plugins/google-meet",
"plugins/meeting-notes",
"plugins/workboard",
"plugins/webhooks",
"plugins/admin-http-rpc",
"plugins/voice-call",

View File

@@ -136,6 +136,7 @@ commands.
| [vydra](/plugins/reference/vydra) | Adds Vydra model provider support to OpenClaw. | `@openclaw/vydra-provider`<br />included in OpenClaw | providers: vydra; contracts: imageGenerationProviders, speechProviders, videoGenerationProviders |
| [web-readability](/plugins/reference/web-readability) | Extract readable article content from local HTML web fetch responses. | `@openclaw/web-readability-plugin`<br />included in OpenClaw | contracts: webContentExtractors |
| [webhooks](/plugins/reference/webhooks) | Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows. | `@openclaw/webhooks`<br />included in OpenClaw | plugin |
| [workboard](/plugins/reference/workboard) | Dashboard workboard for agent-owned issues and sessions. | `@openclaw/workboard`<br />included in OpenClaw | plugin |
| [xai](/plugins/reference/xai) | Adds xAI model provider support to OpenClaw. | `@openclaw/xai-plugin`<br />included in OpenClaw | providers: xai; contracts: imageGenerationProviders, mediaUnderstandingProviders, realtimeTranscriptionProviders, speechProviders, tools, videoGenerationProviders, webSearchProviders |
| [xiaomi](/plugins/reference/xiaomi) | Adds Xiaomi model provider support to OpenClaw. | `@openclaw/xiaomi-provider`<br />included in OpenClaw | providers: xiaomi; contracts: speechProviders |
| [zai](/plugins/reference/zai) | Adds Z.AI model provider support to OpenClaw. | `@openclaw/zai-provider`<br />included in OpenClaw | providers: zai; contracts: mediaUnderstandingProviders |

View File

@@ -135,6 +135,7 @@ pnpm plugins:inventory:gen
| [web-readability](/plugins/reference/web-readability) | Extract readable article content from local HTML web fetch responses. | `@openclaw/web-readability-plugin`<br />included in OpenClaw | contracts: webContentExtractors |
| [webhooks](/plugins/reference/webhooks) | Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows. | `@openclaw/webhooks`<br />included in OpenClaw | plugin |
| [whatsapp](/plugins/reference/whatsapp) | OpenClaw WhatsApp channel plugin for WhatsApp Web chats. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
| [workboard](/plugins/reference/workboard) | Dashboard workboard for agent-owned issues and sessions. | `@openclaw/workboard`<br />included in OpenClaw | plugin |
| [xai](/plugins/reference/xai) | Adds xAI model provider support to OpenClaw. | `@openclaw/xai-plugin`<br />included in OpenClaw | providers: xai; contracts: imageGenerationProviders, mediaUnderstandingProviders, realtimeTranscriptionProviders, speechProviders, tools, videoGenerationProviders, webSearchProviders |
| [xiaomi](/plugins/reference/xiaomi) | Adds Xiaomi model provider support to OpenClaw. | `@openclaw/xiaomi-provider`<br />included in OpenClaw | providers: xiaomi; contracts: speechProviders |
| [zai](/plugins/reference/zai) | Adds Z.AI model provider support to OpenClaw. | `@openclaw/zai-provider`<br />included in OpenClaw | providers: zai; contracts: mediaUnderstandingProviders |

View File

@@ -0,0 +1,23 @@
---
summary: "Dashboard workboard for agent-owned issues and sessions."
read_when:
- You are installing, configuring, or auditing the workboard plugin
title: "Workboard plugin"
---
# Workboard plugin
Dashboard workboard for agent-owned issues and sessions.
## Distribution
- Package: `@openclaw/workboard`
- Install route: included in OpenClaw
## Surface
plugin
## Related docs
- [workboard](/plugins/workboard)

131
docs/plugins/workboard.md Normal file
View File

@@ -0,0 +1,131 @@
---
summary: "Optional dashboard workboard for agent-owned cards and session handoff"
read_when:
- You want a Kanban-style workboard in the Control UI
- You are enabling or disabling the bundled Workboard plugin
- You want to track planned agent work without an external project manager
title: "Workboard plugin"
---
The Workboard plugin adds an optional Kanban-style board to the
[Control UI](/web/control-ui). Use it to collect agent-sized work cards, assign
them to agents, and jump from a card into the linked dashboard session.
Workboard is intentionally small. It tracks local operating work for an
OpenClaw Gateway; it is not a replacement for GitHub Issues, Linear, Jira, or
other team project management systems.
## Default state
Workboard is a bundled plugin and is disabled by default unless you enable it
in plugin config.
Enable it with:
```bash
openclaw plugins enable workboard
openclaw gateway restart
```
Then open the dashboard:
```bash
openclaw dashboard
```
The Workboard tab appears in the dashboard navigation. If the tab is visible
but the plugin is disabled or blocked by `plugins.allow` / `plugins.deny`, the
view shows a plugin-unavailable state instead of local card data.
## What cards contain
Each card stores:
- title and notes
- status: `backlog`, `todo`, `running`, `review`, `blocked`, or `done`
- priority: `low`, `normal`, `high`, or `urgent`
- labels
- optional agent id
- optional linked session, run, task, or source URL
Cards are stored in the plugin's Gateway state. They are local to the Gateway
state directory and move with the rest of that Gateway's OpenClaw state.
## Dashboard workflow
1. Open the Workboard tab in the Control UI.
2. Create a card with a title, notes, priority, labels, and optional agent.
3. Drag the card between columns or use the column controls.
4. Start work from the card to create or reuse a dashboard session.
5. Open the linked session from the card while the agent works.
6. Move the card to review, blocked, or done as the work changes state.
Starting a card uses normal Gateway sessions. The Workboard plugin only stores
card metadata and links; the conversation transcript, model selection, and run
lifecycle stay owned by the regular session system.
## Permissions
The plugin registers Gateway RPC methods under the `workboard.*` namespace:
- `workboard.cards.list` requires `operator.read`
- create, update, move, and delete methods require `operator.write`
Browsers connected with read-only operator access can inspect the board but
cannot mutate cards.
## Configuration
Workboard has no plugin-specific config today. Enable or disable it with the
standard plugin entry:
```json5
{
plugins: {
entries: {
workboard: {
enabled: true,
config: {},
},
},
},
}
```
Disable it again with:
```bash
openclaw plugins disable workboard
openclaw gateway restart
```
## Troubleshooting
### The tab says Workboard is unavailable
Check plugin policy:
```bash
openclaw plugins inspect workboard --runtime --json
```
If `plugins.allow` is configured, add `workboard` to that allowlist. If
`plugins.deny` contains `workboard`, remove it before enabling the plugin.
### Cards do not save
Confirm the browser connection has `operator.write` access. Read-only operator
sessions can list cards but cannot create, edit, move, or delete them.
### Starting a card does not open the expected session
Workboard creates links to normal dashboard sessions. Check the card's agent id
and linked session, then open the Sessions or Chat view to inspect the actual
run state.
## Related
- [Control UI](/web/control-ui)
- [Plugins](/tools/plugin)
- [Manage plugins](/plugins/manage-plugins)
- [Sessions](/concepts/session)

View File

@@ -0,0 +1 @@
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";

View File

@@ -0,0 +1,11 @@
import { definePluginEntry } from "./api.js";
import { registerWorkboardGatewayMethods } from "./runtime-api.js";
export default definePluginEntry({
id: "workboard",
name: "Workboard",
description: "Dashboard workboard for agent-owned issues and sessions.",
register(api) {
registerWorkboardGatewayMethods({ api });
},
});

View File

@@ -0,0 +1,13 @@
{
"id": "workboard",
"activation": {
"onStartup": true
},
"name": "Workboard",
"description": "Dashboard workboard for agent-owned issues and sessions.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "@openclaw/workboard",
"version": "2026.5.21",
"private": true,
"description": "OpenClaw dashboard workboard plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.5.21"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,7 @@
export { registerWorkboardGatewayMethods } from "./src/gateway.js";
export type {
WorkboardCard,
WorkboardListResult,
WorkboardPriority,
WorkboardStatus,
} from "./src/types.js";

View File

@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi } from "../api.js";
import { registerWorkboardGatewayMethods } from "./gateway.js";
import type { WorkboardKeyedStore } from "./store.js";
function createMemoryStore(): WorkboardKeyedStore {
const entries = new Map<string, Awaited<ReturnType<WorkboardKeyedStore["lookup"]>>>();
return {
async register(key, value) {
entries.set(key, value);
},
async lookup(key) {
return entries.get(key);
},
async delete(key) {
return entries.delete(key);
},
async entries() {
return [...entries].flatMap(([key, value]) => (value ? [{ key, value }] : []));
},
};
}
describe("workboard gateway methods", () => {
it("registers CRUD methods with read/write scopes", async () => {
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
};
const methods = new Map<string, RegisteredMethod>();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => createMemoryStore()),
},
},
registerGatewayMethod: vi.fn(
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
methods.set(method, { handler, opts });
},
),
} as unknown as OpenClawPluginApi;
registerWorkboardGatewayMethods({ api });
expect([...methods.keys()]).toEqual([
"workboard.cards.list",
"workboard.cards.create",
"workboard.cards.update",
"workboard.cards.move",
"workboard.cards.delete",
]);
expect(methods.get("workboard.cards.list")?.opts).toEqual({ scope: "operator.read" });
expect(methods.get("workboard.cards.create")?.opts).toEqual({ scope: "operator.write" });
const createHandler = methods.get("workboard.cards.create")?.handler;
const listHandler = methods.get("workboard.cards.list")?.handler;
const createRespond = vi.fn();
await createHandler?.({
params: { title: "Investigate queue drift", priority: "urgent" },
respond: createRespond,
} as never);
expect(createRespond.mock.calls[0]?.[0]).toBe(true);
const listRespond = vi.fn();
await listHandler?.({ params: {}, respond: listRespond } as never);
expect(listRespond.mock.calls[0]?.[1]).toMatchObject({
cards: [expect.objectContaining({ title: "Investigate queue drift" })],
});
});
});

View File

@@ -0,0 +1,110 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { OpenClawPluginApi } from "../api.js";
import { WorkboardStore, type PersistedWorkboardCard } from "./store.js";
import { WORKBOARD_STATUSES } from "./types.js";
const READ_SCOPE = "operator.read" as const;
const WRITE_SCOPE = "operator.write" as const;
type GatewayMethodContext = Parameters<
Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1]
>[0];
type GatewayRespond = GatewayMethodContext["respond"];
function respondError(respond: GatewayRespond, error: unknown) {
respond(false, undefined, {
code: "workboard_error",
message: formatErrorMessage(error),
});
}
function readId(params: Record<string, unknown>): string {
const value = params.id;
if (typeof value === "string" && value.trim()) {
return value.trim();
}
throw new Error("id is required.");
}
function readPatch(params: Record<string, unknown>): Record<string, unknown> {
const patch = params.patch;
if (patch && typeof patch === "object" && !Array.isArray(patch)) {
return patch as Record<string, unknown>;
}
return params;
}
export function registerWorkboardGatewayMethods(params: { api: OpenClawPluginApi }) {
const { api } = params;
const store = WorkboardStore.open((options) =>
api.runtime.state.openKeyedStore<PersistedWorkboardCard>(options),
);
api.registerGatewayMethod(
"workboard.cards.list",
async ({ respond }) => {
try {
respond(true, { cards: await store.list(), statuses: WORKBOARD_STATUSES });
} catch (error) {
respondError(respond, error);
}
},
{ scope: READ_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.create",
async ({ params: requestParams, respond }) => {
try {
respond(true, { card: await store.create(requestParams) });
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.update",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: await store.update(readId(requestParams), readPatch(requestParams)),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.move",
async ({ params: requestParams, respond }) => {
try {
respond(true, {
card: await store.move(
readId(requestParams),
requestParams.status,
requestParams.position,
),
});
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"workboard.cards.delete",
async ({ params: requestParams, respond }) => {
try {
respond(true, await store.delete(readId(requestParams)));
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { WorkboardStore, type WorkboardKeyedStore } from "./store.js";
function createMemoryStore(): WorkboardKeyedStore {
const entries = new Map<string, Awaited<ReturnType<WorkboardKeyedStore["lookup"]>>>();
return {
async register(key, value) {
entries.set(key, value);
},
async lookup(key) {
return entries.get(key);
},
async delete(key) {
return entries.delete(key);
},
async entries() {
return [...entries].flatMap(([key, value]) => (value ? [{ key, value }] : []));
},
};
}
describe("WorkboardStore", () => {
it("creates and lists cards by status order and position", async () => {
const store = new WorkboardStore(createMemoryStore());
const review = await store.create({
title: "Review release notes",
status: "review",
priority: "high",
labels: "release, docs",
});
const todo = await store.create({ title: "Fix dashboard copy", status: "todo" });
expect((await store.list()).map((card) => card.id)).toEqual([todo.id, review.id]);
expect(review.labels).toEqual(["release", "docs"]);
expect(review.priority).toBe("high");
});
it("keeps initial session, run, and task links when creating cards", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Follow up",
sessionKey: "agent:main:dashboard:1",
runId: "run-1",
taskId: "task-1",
});
expect(card).toMatchObject({
sessionKey: "agent:main:dashboard:1",
runId: "run-1",
taskId: "task-1",
});
});
it("moves cards and records lifecycle timestamps", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Ship workboard" });
const running = await store.move(card.id, "running", 500);
expect(running.status).toBe("running");
expect(running.position).toBe(500);
expect(running.startedAt).toBeGreaterThanOrEqual(card.createdAt);
const done = await store.update(card.id, { status: "done" });
expect(done.completedAt).toBeGreaterThanOrEqual(done.startedAt ?? 0);
});
it("rejects invalid status values", async () => {
const store = new WorkboardStore(createMemoryStore());
await expect(store.create({ title: "Bad card", status: "later" })).rejects.toThrow(
/status must be one of/,
);
});
});

View File

@@ -0,0 +1,274 @@
import { randomUUID } from "node:crypto";
import {
WORKBOARD_PRIORITIES,
WORKBOARD_STATUSES,
type WorkboardCard,
type WorkboardPriority,
type WorkboardStatus,
} from "./types.js";
const POSITION_STEP = 1000;
const MAX_CARDS = 2000;
export type PersistedWorkboardCard = {
version: 1;
card: WorkboardCard;
};
export type WorkboardKeyedStore = {
register(key: string, value: PersistedWorkboardCard): Promise<void>;
lookup(key: string): Promise<PersistedWorkboardCard | undefined>;
delete(key: string): Promise<boolean>;
entries(): Promise<Array<{ key: string; value: PersistedWorkboardCard }>>;
};
export type WorkboardCardInput = {
title?: unknown;
notes?: unknown;
status?: unknown;
priority?: unknown;
labels?: unknown;
agentId?: unknown;
sessionKey?: unknown;
runId?: unknown;
taskId?: unknown;
sourceUrl?: unknown;
position?: unknown;
};
export type WorkboardCardPatch = Partial<WorkboardCardInput>;
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeTitle(value: unknown): string {
const title = normalizeOptionalString(value);
if (!title) {
throw new Error("title is required.");
}
if (title.length > 180) {
throw new Error("title must be 180 characters or fewer.");
}
return title;
}
function normalizeNotes(value: unknown): string | undefined {
const notes = normalizeOptionalString(value);
if (!notes) {
return undefined;
}
if (notes.length > 4000) {
throw new Error("notes must be 4000 characters or fewer.");
}
return notes;
}
function normalizeStatus(value: unknown, fallback: WorkboardStatus): WorkboardStatus {
if (typeof value !== "string" || !value.trim()) {
return fallback;
}
if ((WORKBOARD_STATUSES as readonly string[]).includes(value)) {
return value as WorkboardStatus;
}
throw new Error(`status must be one of: ${WORKBOARD_STATUSES.join(", ")}.`);
}
function normalizePriority(value: unknown, fallback: WorkboardPriority): WorkboardPriority {
if (typeof value !== "string" || !value.trim()) {
return fallback;
}
if ((WORKBOARD_PRIORITIES as readonly string[]).includes(value)) {
return value as WorkboardPriority;
}
throw new Error(`priority must be one of: ${WORKBOARD_PRIORITIES.join(", ")}.`);
}
function normalizeLabels(value: unknown, fallback: string[] = []): string[] {
if (value == null) {
return fallback;
}
if (typeof value === "string") {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 12);
}
if (!Array.isArray(value)) {
throw new Error("labels must be an array or comma-separated string.");
}
const labels: string[] = [];
for (const entry of value) {
const label = normalizeOptionalString(entry);
if (!label || labels.includes(label)) {
continue;
}
if (label.length > 40) {
throw new Error("labels must be 40 characters or fewer.");
}
labels.push(label);
if (labels.length >= 12) {
break;
}
}
return labels;
}
function normalizePosition(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.max(0, Math.trunc(value));
}
function compareCards(left: WorkboardCard, right: WorkboardCard): number {
if (left.status !== right.status) {
return WORKBOARD_STATUSES.indexOf(left.status) - WORKBOARD_STATUSES.indexOf(right.status);
}
if (left.position !== right.position) {
return left.position - right.position;
}
return left.createdAt - right.createdAt;
}
function removeUndefinedCardFields(card: WorkboardCard): WorkboardCard {
const next = { ...card };
for (const key of [
"notes",
"agentId",
"sessionKey",
"runId",
"taskId",
"sourceUrl",
"startedAt",
"completedAt",
] as const) {
if (next[key] === undefined) {
delete next[key];
}
}
return next;
}
export class WorkboardStore {
constructor(private readonly store: WorkboardKeyedStore) {}
async list(): Promise<WorkboardCard[]> {
const entries = await this.store.entries();
return entries
.map((entry) => entry.value)
.filter(
(entry): entry is PersistedWorkboardCard => entry?.version === 1 && Boolean(entry.card?.id),
)
.map((entry) => entry.card)
.toSorted(compareCards);
}
async get(id: string): Promise<WorkboardCard | undefined> {
const entry = await this.store.lookup(id.trim());
return entry?.version === 1 ? entry.card : undefined;
}
async create(input: WorkboardCardInput): Promise<WorkboardCard> {
const now = Date.now();
const status = normalizeStatus(input.status, "todo");
const cards = await this.list();
const position =
normalizePosition(input.position, 0) ||
Math.max(0, ...cards.filter((card) => card.status === status).map((card) => card.position)) +
POSITION_STEP;
const notes = normalizeNotes(input.notes);
const agentId = normalizeOptionalString(input.agentId);
const sessionKey = normalizeOptionalString(input.sessionKey);
const runId = normalizeOptionalString(input.runId);
const taskId = normalizeOptionalString(input.taskId);
const sourceUrl = normalizeOptionalString(input.sourceUrl);
const card: WorkboardCard = {
id: randomUUID(),
title: normalizeTitle(input.title),
status,
priority: normalizePriority(input.priority, "normal"),
labels: normalizeLabels(input.labels),
position,
createdAt: now,
updatedAt: now,
...(notes ? { notes } : {}),
...(agentId ? { agentId } : {}),
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
...(taskId ? { taskId } : {}),
...(sourceUrl ? { sourceUrl } : {}),
};
await this.store.register(card.id, { version: 1, card });
return card;
}
async update(id: string, patch: WorkboardCardPatch): Promise<WorkboardCard> {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
const status = normalizeStatus(patch.status, existing.status);
const now = Date.now();
const completedAt = status === "done" ? (existing.completedAt ?? now) : undefined;
const startedAt = status === "running" ? (existing.startedAt ?? now) : existing.startedAt;
const next = removeUndefinedCardFields({
...existing,
title: patch.title === undefined ? existing.title : normalizeTitle(patch.title),
notes: patch.notes === undefined ? existing.notes : normalizeNotes(patch.notes),
status,
priority:
patch.priority === undefined
? existing.priority
: normalizePriority(patch.priority, existing.priority),
labels: patch.labels === undefined ? existing.labels : normalizeLabels(patch.labels),
agentId:
patch.agentId === undefined ? existing.agentId : normalizeOptionalString(patch.agentId),
sessionKey:
patch.sessionKey === undefined
? existing.sessionKey
: normalizeOptionalString(patch.sessionKey),
runId: patch.runId === undefined ? existing.runId : normalizeOptionalString(patch.runId),
taskId: patch.taskId === undefined ? existing.taskId : normalizeOptionalString(patch.taskId),
sourceUrl:
patch.sourceUrl === undefined
? existing.sourceUrl
: normalizeOptionalString(patch.sourceUrl),
position:
patch.position === undefined
? existing.position
: normalizePosition(patch.position, existing.position),
updatedAt: now,
...(startedAt ? { startedAt } : {}),
...(completedAt ? { completedAt } : {}),
});
if (status !== "done") {
delete next.completedAt;
}
await this.store.register(next.id, { version: 1, card: next });
return next;
}
async move(id: string, status: unknown, position: unknown): Promise<WorkboardCard> {
return await this.update(id, {
status,
position,
});
}
async delete(id: string): Promise<{ deleted: boolean }> {
return { deleted: await this.store.delete(id.trim()) };
}
static open(
openKeyedStore: (options: { namespace: string; maxEntries: number }) => WorkboardKeyedStore,
) {
return new WorkboardStore(
openKeyedStore({
namespace: "workboard.cards",
maxEntries: MAX_CARDS,
}),
);
}
}

View File

@@ -0,0 +1,37 @@
export const WORKBOARD_STATUSES = [
"backlog",
"todo",
"running",
"review",
"blocked",
"done",
] as const;
export const WORKBOARD_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
export type WorkboardStatus = (typeof WORKBOARD_STATUSES)[number];
export type WorkboardPriority = (typeof WORKBOARD_PRIORITIES)[number];
export type WorkboardCard = {
id: string;
title: string;
notes?: string;
status: WorkboardStatus;
priority: WorkboardPriority;
labels: string[];
agentId?: string;
sessionKey?: string;
runId?: string;
taskId?: string;
sourceUrl?: string;
position: number;
createdAt: number;
updatedAt: number;
startedAt?: number;
completedAt?: number;
};
export type WorkboardListResult = {
cards: WorkboardCard[];
statuses: readonly WorkboardStatus[];
};

9
pnpm-lock.yaml generated
View File

@@ -1696,6 +1696,15 @@ importers:
specifier: workspace:*
version: link:../..
extensions/workboard:
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
openclaw:
specifier: workspace:*
version: link:../..
extensions/xai:
dependencies:
typebox:

View File

@@ -16,5 +16,6 @@
"dist/extensions/telegram/runtime-setter-api.js",
"dist/extensions/tokenjuice/runtime-api.js",
"dist/extensions/webhooks/runtime-api.js",
"dist/extensions/workboard/runtime-api.js",
"dist/extensions/zai/runtime-api.js"
]

View File

@@ -4655,6 +4655,83 @@
"name": "aria-label",
"path": "ui/src/ui/views/usage-render-overview.ts",
"text": "Remove session filter"
},
{
"count": 1,
"kind": "html-attribute",
"name": "placeholder",
"path": "ui/src/ui/views/workboard.ts",
"text": "Card title"
},
{
"count": 1,
"kind": "html-attribute",
"name": "placeholder",
"path": "ui/src/ui/views/workboard.ts",
"text": "Notes, acceptance criteria, links"
},
{
"count": 1,
"kind": "html-attribute",
"name": "placeholder",
"path": "ui/src/ui/views/workboard.ts",
"text": "Search cards"
},
{
"count": 1,
"kind": "html-attribute",
"name": "title",
"path": "ui/src/ui/views/workboard.ts",
"text": "Delete card"
},
{
"count": 1,
"kind": "html-attribute",
"name": "title",
"path": "ui/src/ui/views/workboard.ts",
"text": "Open session"
},
{
"count": 1,
"kind": "html-attribute",
"name": "title",
"path": "ui/src/ui/views/workboard.ts",
"text": "Start session"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/views/workboard.ts",
"text": "All priorities"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/views/workboard.ts",
"text": "default agent"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/views/workboard.ts",
"text": "Default agent"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/views/workboard.ts",
"text": "Drop work here"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/views/workboard.ts",
"text": "live"
}
]
}

View File

@@ -396,6 +396,7 @@ export const ar: TranslationMap = {
agents: "الوكلاء",
activity: "النشاط",
overview: "نظرة عامة",
workboard: "لوحة العمل",
channels: "القنوات",
instances: "المثيلات",
sessions: "الجلسات",
@@ -418,6 +419,7 @@ export const ar: TranslationMap = {
agents: "مساحات العمل، والأدوات، والهويات.",
activity: "ملخصات نشاط الأدوات المحلية في المتصفح.",
overview: "الحالة، ونقاط الدخول، والصحة.",
workboard: "قائمة عمل الوكيل وتسليم الجلسات.",
channels: "القنوات والإعدادات.",
instances: "العملاء والعقد المتصلة.",
sessions: "الجلسات النشطة والافتراضيات.",

View File

@@ -400,6 +400,7 @@ export const de: TranslationMap = {
agents: "Agenten",
activity: "Aktivität",
overview: "Übersicht",
workboard: "Arbeitsbereich",
channels: "Kanäle",
instances: "Instanzen",
sessions: "Sitzungen",
@@ -422,6 +423,7 @@ export const de: TranslationMap = {
agents: "Agent-Arbeitsbereiche, Tools und Identitäten verwalten.",
activity: "Browser-lokale Zusammenfassungen der Tool-Aktivität.",
overview: "Gateway-Status, Einstiegspunkte und eine schnelle Zustandsprüfung.",
workboard: "Agenten-Arbeitswarteschlange und Sitzungsübergabe.",
channels: "Kanäle und Einstellungen verwalten.",
instances: "Präsenzsignale von verbundenen Clients und Geräten.",
sessions: "Aktive Sitzungen inspizieren und Standardeinstellungen pro Sitzung anpassen.",

View File

@@ -395,6 +395,7 @@ export const en: TranslationMap = {
agents: "Agents",
activity: "Activity",
overview: "Overview",
workboard: "Workboard",
channels: "Channels",
instances: "Instances",
sessions: "Sessions",
@@ -417,6 +418,7 @@ export const en: TranslationMap = {
agents: "Workspaces, tools, identities.",
activity: "Browser-local tool activity summaries.",
overview: "Status, entry points, health.",
workboard: "Agent work queue and session handoff.",
channels: "Channels and settings.",
instances: "Connected clients and nodes.",
sessions: "Active sessions and defaults.",

View File

@@ -397,6 +397,7 @@ export const es: TranslationMap = {
agents: "Agentes",
activity: "Actividad",
overview: "Resumen",
workboard: "Panel de trabajo",
channels: "Canales",
instances: "Instancias",
sessions: "Sesiones",
@@ -419,6 +420,7 @@ export const es: TranslationMap = {
agents: "Gestionar espacios de trabajo, herramientas e identidades de agentes.",
activity: "Resúmenes de actividad de herramientas locales del navegador.",
overview: "Estado de la puerta de enlace, puntos de entrada y lectura rápida de salud.",
workboard: "Cola de trabajo del agente y traspaso de sesión.",
channels: "Gestionar canales y ajustes.",
instances: "Balizas de presencia de clientes y nodos conectados.",
sessions: "Inspeccionar sesiones activas y ajustar valores predeterminados por sesión.",

View File

@@ -398,6 +398,7 @@ export const fa: TranslationMap = {
agents: "عامل‌ها",
activity: "فعالیت",
overview: "نمای کلی",
workboard: "تابلوی کار",
channels: "کانال‌ها",
instances: "نمونه‌ها",
sessions: "نشست‌ها",
@@ -420,6 +421,7 @@ export const fa: TranslationMap = {
agents: "فضاهای کاری، ابزارها، هویت‌ها.",
activity: "خلاصه‌های فعالیت ابزار در مرورگر محلی.",
overview: "وضعیت، نقاط ورود، سلامت.",
workboard: "صف کار عامل و واگذاری جلسه.",
channels: "کانال‌ها و تنظیمات.",
instances: "کلاینت‌ها و گره‌های متصل.",
sessions: "نشست‌های فعال و پیش‌فرض‌ها.",

View File

@@ -399,6 +399,7 @@ export const fr: TranslationMap = {
agents: "Agents",
activity: "Activité",
overview: "Aperçu",
workboard: "Tableau de travail",
channels: "Canaux",
instances: "Instances",
sessions: "Sessions",
@@ -421,6 +422,7 @@ export const fr: TranslationMap = {
agents: "Espaces de travail, outils, identités.",
activity: "Résumés dactivité des outils locaux au navigateur.",
overview: "Statut, points dentrée, santé.",
workboard: "File de travail de lagent et transfert de session.",
channels: "Canaux et paramètres.",
instances: "Clients et nœuds connectés.",
sessions: "Sessions actives et valeurs par défaut.",

View File

@@ -397,6 +397,7 @@ export const id: TranslationMap = {
agents: "Agen",
activity: "Aktivitas",
overview: "Ikhtisar",
workboard: "Workboard",
channels: "Saluran",
instances: "Instans",
sessions: "Sesi",
@@ -419,6 +420,7 @@ export const id: TranslationMap = {
agents: "Ruang kerja, alat, identitas.",
activity: "Ringkasan aktivitas alat lokal browser.",
overview: "Status, titik masuk, kesehatan.",
workboard: "Antrean kerja agen dan serah terima sesi.",
channels: "Saluran dan pengaturan.",
instances: "Klien dan node yang terhubung.",
sessions: "Sesi aktif dan default.",

View File

@@ -399,6 +399,7 @@ export const it: TranslationMap = {
agents: "Agenti",
activity: "Attività",
overview: "Panoramica",
workboard: "Bacheca di lavoro",
channels: "Canali",
instances: "Istanze",
sessions: "Sessioni",
@@ -421,6 +422,7 @@ export const it: TranslationMap = {
agents: "Spazi di lavoro, strumenti, identità.",
activity: "Riepiloghi dell'attività degli strumenti locali al browser.",
overview: "Stato, punti di ingresso, integrità.",
workboard: "Coda di lavoro degli agenti e passaggio di sessione.",
channels: "Canali e impostazioni.",
instances: "Client e nodi connessi.",
sessions: "Sessioni attive e valori predefiniti.",

View File

@@ -400,6 +400,7 @@ export const ja_JP: TranslationMap = {
agents: "エージェント",
activity: "アクティビティ",
overview: "概要",
workboard: "ワークボード",
channels: "チャンネル",
instances: "インスタンス",
sessions: "セッション",
@@ -422,6 +423,7 @@ export const ja_JP: TranslationMap = {
agents: "ワークスペース、ツール、ID。",
activity: "ブラウザー内のツールアクティビティ概要。",
overview: "ステータス、エントリーポイント、健全性。",
workboard: "エージェントの作業キューとセッションの引き継ぎ。",
channels: "チャンネルと設定。",
instances: "接続されたクライアントとノード。",
sessions: "アクティブなセッションとデフォルト。",

View File

@@ -396,6 +396,7 @@ export const ko: TranslationMap = {
agents: "에이전트",
activity: "활동",
overview: "개요",
workboard: "워크보드",
channels: "채널",
instances: "인스턴스",
sessions: "세션",
@@ -418,6 +419,7 @@ export const ko: TranslationMap = {
agents: "워크스페이스, 도구, 정체성.",
activity: "브라우저 로컬 도구 활동 요약입니다.",
overview: "상태, 진입점, 상태 정보.",
workboard: "에이전트 작업 대기열 및 세션 인계.",
channels: "채널 및 설정.",
instances: "연결된 클라이언트와 노드.",
sessions: "활성 세션 및 기본값.",

View File

@@ -399,6 +399,7 @@ export const nl: TranslationMap = {
agents: "Agents",
activity: "Activiteit",
overview: "Overzicht",
workboard: "Werkbord",
channels: "Kanalen",
instances: "Instanties",
sessions: "Sessies",
@@ -421,6 +422,7 @@ export const nl: TranslationMap = {
agents: "Werkruimten, tools, identiteiten.",
activity: "Browserlokale samenvattingen van toolactiviteit.",
overview: "Status, toegangspunten, gezondheid.",
workboard: "Werkwachtrij voor agents en sessieoverdracht.",
channels: "Kanalen en instellingen.",
instances: "Verbonden clients en nodes.",
sessions: "Actieve sessies en standaarden.",

View File

@@ -398,6 +398,7 @@ export const pl: TranslationMap = {
agents: "Agenci",
activity: "Aktywność",
overview: "Przegląd",
workboard: "Tablica pracy",
channels: "Kanały",
instances: "Instancje",
sessions: "Sesje",
@@ -420,6 +421,7 @@ export const pl: TranslationMap = {
agents: "Obszary robocze, narzędzia, tożsamości.",
activity: "Podsumowania aktywności narzędzi lokalne dla przeglądarki.",
overview: "Status, punkty dostępu, stan.",
workboard: "Kolejka zadań agenta i przekazywanie sesji.",
channels: "Kanały i ustawienia.",
instances: "Połączone klienty i węzły.",
sessions: "Aktywne sesje i ustawienia domyślne.",

View File

@@ -397,6 +397,7 @@ export const pt_BR: TranslationMap = {
agents: "Agentes",
activity: "Atividade",
overview: "Visão Geral",
workboard: "Quadro de trabalho",
channels: "Canais",
instances: "Instâncias",
sessions: "Sessões",
@@ -419,6 +420,7 @@ export const pt_BR: TranslationMap = {
agents: "Espaços, ferramentas, identidades.",
activity: "Resumos de atividade de ferramentas locais do navegador.",
overview: "Status, entrada, saúde.",
workboard: "Fila de trabalho do agente e transferência de sessão.",
channels: "Canais e configurações.",
instances: "Clientes e nós conectados.",
sessions: "Sessões ativas e padrões.",

View File

@@ -395,6 +395,7 @@ export const th: TranslationMap = {
agents: "เอเจนต์",
activity: "กิจกรรม",
overview: "ภาพรวม",
workboard: "กระดานงาน",
channels: "ช่องทาง",
instances: "อินสแตนซ์",
sessions: "เซสชัน",
@@ -417,6 +418,7 @@ export const th: TranslationMap = {
agents: "เวิร์กสเปซ เครื่องมือ และข้อมูลประจำตัว",
activity: "สรุปกิจกรรมของเครื่องมือภายในเบราว์เซอร์",
overview: "สถานะ จุดเข้าใช้งาน และความพร้อมใช้งาน",
workboard: "คิวงานของ Agent และการส่งต่อเซสชัน",
channels: "ช่องทางและการตั้งค่า",
instances: "ไคลเอนต์และโหนดที่เชื่อมต่อ",
sessions: "เซสชันที่ใช้งานอยู่และค่าเริ่มต้น",

View File

@@ -399,6 +399,7 @@ export const tr: TranslationMap = {
agents: "Aracılar",
activity: "Etkinlik",
overview: "Genel Bakış",
workboard: "Çalışma panosu",
channels: "Kanallar",
instances: "Örnekler",
sessions: "Oturumlar",
@@ -421,6 +422,7 @@ export const tr: TranslationMap = {
agents: "Çalışma alanları, araçlar, kimlikler.",
activity: "Tarayıcıya yerel araç etkinliği özetleri.",
overview: "Durum, giriş noktaları, sağlık.",
workboard: "Ajan iş kuyruğu ve oturum devri.",
channels: "Kanallar ve ayarlar.",
instances: "Bağlı istemciler ve düğümler.",
sessions: "Etkin oturumlar ve varsayılanlar.",

View File

@@ -398,6 +398,7 @@ export const uk: TranslationMap = {
agents: "Агенти",
activity: "Активність",
overview: "Огляд",
workboard: "Робоча дошка",
channels: "Канали",
instances: "Екземпляри",
sessions: "Сеанси",
@@ -420,6 +421,7 @@ export const uk: TranslationMap = {
agents: "Робочі простори, інструменти, ідентичності.",
activity: "Підсумки активності інструментів, локальні для браузера.",
overview: "Стан, точки входу, справність.",
workboard: "Черга завдань агента та передавання сеансів.",
channels: "Канали та налаштування.",
instances: "Підключені клієнти та вузли.",
sessions: "Активні сеанси та типові значення.",

View File

@@ -397,6 +397,7 @@ export const vi: TranslationMap = {
agents: "Agent",
activity: "Hoạt động",
overview: "Tổng quan",
workboard: "Bảng công việc",
channels: "Kênh",
instances: "Phiên bản",
sessions: "Phiên",
@@ -419,6 +420,7 @@ export const vi: TranslationMap = {
agents: "Không gian làm việc, công cụ, danh tính.",
activity: "Tóm tắt hoạt động công cụ cục bộ trên trình duyệt.",
overview: "Trạng thái, điểm vào, tình trạng.",
workboard: "Hàng đợi công việc của tác nhân và bàn giao phiên.",
channels: "Kênh và cài đặt.",
instances: "Máy khách và nút đã kết nối.",
sessions: "Phiên đang hoạt động và mặc định.",

View File

@@ -394,6 +394,7 @@ export const zh_CN: TranslationMap = {
agents: "代理",
activity: "活动",
overview: "概览",
workboard: "工作板",
channels: "频道",
instances: "实例",
sessions: "会话",
@@ -416,6 +417,7 @@ export const zh_CN: TranslationMap = {
agents: "工作区、工具、身份。",
activity: "浏览器本地工具活动摘要。",
overview: "状态、入口点、健康。",
workboard: "智能体工作队列和会话交接。",
channels: "频道和设置。",
instances: "已连接客户端和节点。",
sessions: "活动会话和默认设置。",

View File

@@ -394,6 +394,7 @@ export const zh_TW: TranslationMap = {
agents: "代理",
activity: "活動",
overview: "概覽",
workboard: "工作板",
channels: "頻道",
instances: "實例",
sessions: "工作階段",
@@ -416,6 +417,7 @@ export const zh_TW: TranslationMap = {
agents: "工作區、工具、身份。",
activity: "瀏覽器本機工具活動摘要。",
overview: "狀態、入口點、健康。",
workboard: "代理工作佇列與工作階段交接。",
channels: "頻道和設置。",
instances: "已連接客戶端和節點。",
sessions: "活動會話和默認設置。",

View File

@@ -9,6 +9,7 @@
@import "./styles/cron-quick-create.css";
@import "./styles/usage.css";
@import "./styles/dreams.css";
@import "./styles/workboard.css";
@import "@create-markdown/preview/themes/system.css";
.cm-preview {

207
ui/src/styles/workboard.css Normal file
View File

@@ -0,0 +1,207 @@
.workboard {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.workboard-toolbar,
.workboard-draft {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.workboard-toolbar__filters,
.workboard-toolbar__actions,
.workboard-draft__meta,
.workboard-card__actions,
.workboard-card__meta,
.workboard-card__top,
.workboard-labels {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.workboard-toolbar__filters {
flex: 1;
}
.workboard-toolbar__filters .input[type="search"] {
min-width: min(340px, 100%);
}
.workboard-draft {
padding: 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
}
.workboard-draft__main {
display: grid;
gap: 8px;
flex: 1;
min-width: 220px;
}
.workboard-draft__title {
font-weight: 650;
}
.workboard-draft__notes {
min-height: 76px;
resize: vertical;
}
.workboard-draft__meta {
justify-content: flex-end;
}
.workboard-board {
display: grid;
grid-template-columns: repeat(6, minmax(220px, 1fr));
gap: 12px;
overflow-x: auto;
padding-bottom: 8px;
}
.workboard-column {
min-height: 440px;
background: color-mix(in srgb, var(--panel) 78%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: 8px;
display: flex;
flex-direction: column;
min-width: 220px;
}
.workboard-column--drop {
outline: 1px dashed color-mix(in srgb, var(--accent) 70%, transparent);
outline-offset: -4px;
}
.workboard-column__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
}
.workboard-column__header h2 {
margin: 0;
font-size: 0.82rem;
text-transform: uppercase;
color: var(--muted);
}
.workboard-column__header span {
color: var(--muted);
font-size: 0.82rem;
}
.workboard-column__cards {
padding: 10px;
display: grid;
gap: 10px;
align-content: start;
min-height: 100%;
}
.workboard-card {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
padding: 11px;
display: grid;
gap: 8px;
box-shadow: 0 1px 0 color-mix(in srgb, var(--border) 60%, transparent);
}
.workboard-card--busy {
opacity: 0.62;
}
.workboard-card h3 {
margin: 0;
font-size: 0.95rem;
line-height: 1.3;
}
.workboard-card p {
margin: 0;
color: var(--muted);
font-size: 0.86rem;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.workboard-card__top,
.workboard-card__meta {
justify-content: space-between;
color: var(--muted);
font-size: 0.76rem;
}
.workboard-card__actions {
justify-content: flex-end;
}
.workboard-card__priority,
.workboard-live,
.workboard-labels span {
border-radius: 999px;
padding: 2px 7px;
background: color-mix(in srgb, var(--border) 60%, transparent);
color: var(--muted);
font-size: 0.72rem;
}
.priority-high .workboard-card__priority,
.priority-urgent .workboard-card__priority {
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: var(--danger);
}
.priority-low .workboard-card__priority {
background: color-mix(in srgb, var(--ok) 16%, transparent);
color: var(--ok);
}
.workboard-live {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
}
.workboard-empty {
border: 1px dashed color-mix(in srgb, var(--border) 80%, transparent);
border-radius: 8px;
padding: 18px 10px;
color: var(--muted);
text-align: center;
font-size: 0.86rem;
}
@media (max-width: 860px) {
.workboard-toolbar,
.workboard-draft {
flex-direction: column;
}
.workboard-toolbar__actions,
.workboard-draft__meta {
width: 100%;
justify-content: flex-start;
}
.workboard-board {
grid-template-columns: repeat(6, minmax(260px, 82vw));
}
}

View File

@@ -369,6 +369,7 @@ const lazyLogs = createLazyView(() => import("./views/logs.ts"), notifyLazyViewC
const lazyNodes = createLazyView(() => import("./views/nodes.ts"), notifyLazyViewChanged);
const lazySessions = createLazyView(() => import("./views/sessions.ts"), notifyLazyViewChanged);
const lazySkills = createLazyView(() => import("./views/skills.ts"), notifyLazyViewChanged);
const lazyWorkboard = createLazyView(() => import("./views/workboard.ts"), notifyLazyViewChanged);
function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null {
if (typeof nextRunAtMs !== "number" || !Number.isFinite(nextRunAtMs)) {
@@ -2170,6 +2171,25 @@ export function renderApp(state: AppViewState) {
}),
)
: nothing}
${state.tab === "workboard"
? renderLazyView(lazyWorkboard, (m) =>
m.renderWorkboard({
host: state,
client: state.client,
connected: state.connected,
pluginEnabled: isPluginEnabledInConfigSnapshot(state.configSnapshot, "workboard", {
enabledByDefault: false,
}),
agentsList: state.agentsList,
sessions: state.sessionsResult?.sessions ?? [],
onOpenSession: (sessionKey) => {
switchChatSession(state, sessionKey);
state.setTab("chat" as import("./navigation.ts").Tab);
},
onRequestUpdate: requestHostUpdate,
}),
)
: nothing}
${renderUsageTab(state)}
${state.tab === "cron" ? renderCronQuickCreateForTab(state, requestHostUpdate) : nothing}
${state.tab === "cron"

View File

@@ -389,6 +389,16 @@ describe("refreshActiveTab", () => {
});
});
it("loads config before rendering the Workboard tab", async () => {
const host = createHost();
host.tab = "workboard";
await refreshActiveTab(host as never);
expect(mocks.loadConfigMock).toHaveBeenCalledOnce();
expect(mocks.loadConfigSchemaMock).not.toHaveBeenCalled();
});
it("does not start the deferred schema refresh when scoped settings fail to load", async () => {
const host = createHost();
host.tab = "communications";

View File

@@ -426,6 +426,9 @@ export async function refreshActiveTab(host: SettingsHost) {
break;
case "activity":
break;
case "workboard":
await loadConfig(app);
break;
case "channels":
await loadChannelsTab(host);
break;

View File

@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from "vitest";
import {
createWorkboardCard,
getWorkboardState,
loadWorkboard,
moveWorkboardCard,
startWorkboardCard,
type WorkboardCard,
} from "./workboard.ts";
function createClient(responses: Record<string, unknown>) {
const request = vi.fn(async (method: string) => responses[method]);
return { request };
}
const sampleCard: WorkboardCard = {
id: "card-1",
title: "Build board",
status: "todo",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
};
describe("workboard controller", () => {
it("loads cards through the plugin gateway method", async () => {
const host = {};
const client = createClient({
"workboard.cards.list": { cards: [sampleCard], statuses: ["todo", "done"] },
});
await loadWorkboard({ host, client: client as never, force: true });
expect(client.request).toHaveBeenCalledWith("workboard.cards.list", {});
expect(getWorkboardState(host).cards).toEqual([sampleCard]);
});
it("creates cards from draft state", async () => {
const host = {};
const state = getWorkboardState(host);
state.draftTitle = "Write tests";
state.draftNotes = "Cover the happy path";
const created = { ...sampleCard, id: "card-2", title: "Write tests" };
const client = createClient({ "workboard.cards.create": { card: created } });
await createWorkboardCard({ host, client: client as never });
expect(client.request).toHaveBeenCalledWith("workboard.cards.create", {
title: "Write tests",
notes: "Cover the happy path",
priority: "normal",
agentId: "",
});
expect(state.cards[0]).toMatchObject({ id: "card-2", title: "Write tests" });
expect(state.draftOpen).toBe(false);
});
it("starts a session and links it back to the card", async () => {
const host = {};
const running = { ...sampleCard, status: "running", sessionKey: "agent:main:dashboard:1" };
const client = createClient({
"sessions.create": { key: "agent:main:dashboard:1", runId: "run-1" },
"workboard.cards.update": { card: running },
});
const sessionKey = await startWorkboardCard({
host,
client: client as never,
card: sampleCard,
});
expect(sessionKey).toBe("agent:main:dashboard:1");
expect(client.request).toHaveBeenNthCalledWith(
2,
"workboard.cards.update",
expect.objectContaining({
id: "card-1",
patch: expect.objectContaining({ status: "running", runId: "run-1" }),
}),
);
});
it("moves cards through the plugin gateway method", async () => {
const host = {};
const moved = { ...sampleCard, status: "blocked", position: 2000 };
const client = createClient({ "workboard.cards.move": { card: moved } });
await moveWorkboardCard({
host,
client: client as never,
cardId: "card-1",
status: "blocked",
position: 2000,
});
expect(getWorkboardState(host).cards[0]).toMatchObject({
status: "blocked",
position: 2000,
});
});
});

View File

@@ -0,0 +1,353 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { GatewaySessionRow } from "../types.ts";
export const WORKBOARD_STATUSES = [
"backlog",
"todo",
"running",
"review",
"blocked",
"done",
] as const;
export const WORKBOARD_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
export type WorkboardStatus = (typeof WORKBOARD_STATUSES)[number];
export type WorkboardPriority = (typeof WORKBOARD_PRIORITIES)[number];
export type WorkboardCard = {
id: string;
title: string;
notes?: string;
status: WorkboardStatus;
priority: WorkboardPriority;
labels: string[];
agentId?: string;
sessionKey?: string;
runId?: string;
taskId?: string;
sourceUrl?: string;
position: number;
createdAt: number;
updatedAt: number;
startedAt?: number;
completedAt?: number;
};
export type WorkboardUiState = {
loading: boolean;
loaded: boolean;
loadAttempted: boolean;
error: string | null;
cards: WorkboardCard[];
statuses: readonly WorkboardStatus[];
query: string;
priorityFilter: "all" | WorkboardPriority;
draftOpen: boolean;
draftTitle: string;
draftNotes: string;
draftPriority: WorkboardPriority;
draftAgentId: string;
busyCardId: string | null;
draggedCardId: string | null;
};
type WorkboardHost = object;
const workboardStates = new WeakMap<WorkboardHost, WorkboardUiState>();
function createDefaultState(): WorkboardUiState {
return {
loading: false,
loaded: false,
loadAttempted: false,
error: null,
cards: [],
statuses: WORKBOARD_STATUSES,
query: "",
priorityFilter: "all",
draftOpen: false,
draftTitle: "",
draftNotes: "",
draftPriority: "normal",
draftAgentId: "",
busyCardId: null,
draggedCardId: null,
};
}
export function getWorkboardState(host: WorkboardHost): WorkboardUiState {
let state = workboardStates.get(host);
if (!state) {
state = createDefaultState();
workboardStates.set(host, state);
}
return state;
}
function formatError(error: unknown): string {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
if (typeof error === "string" && error.trim()) {
return error.trim();
}
return "Unknown workboard error.";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function normalizeCard(value: unknown): WorkboardCard | null {
if (!isRecord(value)) {
return null;
}
const id = typeof value.id === "string" ? value.id : "";
const title = typeof value.title === "string" ? value.title : "";
const status = WORKBOARD_STATUSES.includes(value.status as WorkboardStatus)
? (value.status as WorkboardStatus)
: "todo";
const priority = WORKBOARD_PRIORITIES.includes(value.priority as WorkboardPriority)
? (value.priority as WorkboardPriority)
: "normal";
if (!id || !title) {
return null;
}
return {
id,
title,
status,
priority,
labels: Array.isArray(value.labels)
? value.labels.filter((label): label is string => typeof label === "string")
: [],
position: typeof value.position === "number" ? value.position : 0,
createdAt: typeof value.createdAt === "number" ? value.createdAt : 0,
updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : 0,
...(typeof value.notes === "string" ? { notes: value.notes } : {}),
...(typeof value.agentId === "string" ? { agentId: value.agentId } : {}),
...(typeof value.sessionKey === "string" ? { sessionKey: value.sessionKey } : {}),
...(typeof value.runId === "string" ? { runId: value.runId } : {}),
...(typeof value.taskId === "string" ? { taskId: value.taskId } : {}),
...(typeof value.sourceUrl === "string" ? { sourceUrl: value.sourceUrl } : {}),
...(typeof value.startedAt === "number" ? { startedAt: value.startedAt } : {}),
...(typeof value.completedAt === "number" ? { completedAt: value.completedAt } : {}),
};
}
function normalizeCardsPayload(payload: unknown): {
cards: WorkboardCard[];
statuses: readonly WorkboardStatus[];
} {
if (!isRecord(payload)) {
return { cards: [], statuses: WORKBOARD_STATUSES };
}
const cards = Array.isArray(payload.cards)
? payload.cards.map(normalizeCard).filter((card): card is WorkboardCard => card !== null)
: [];
const statuses = Array.isArray(payload.statuses)
? payload.statuses.filter((status): status is WorkboardStatus =>
WORKBOARD_STATUSES.includes(status as WorkboardStatus),
)
: WORKBOARD_STATUSES;
return { cards, statuses: statuses.length ? statuses : WORKBOARD_STATUSES };
}
function normalizeCardPayload(payload: unknown): WorkboardCard {
const card = isRecord(payload) ? normalizeCard(payload.card) : null;
if (!card) {
throw new Error("workboard response did not include a card");
}
return card;
}
export async function loadWorkboard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
requestUpdate?: () => void;
force?: boolean;
}) {
const state = getWorkboardState(params.host);
if (!params.client || state.loading || (!params.force && (state.loaded || state.loadAttempted))) {
return;
}
state.loadAttempted = true;
state.loading = true;
state.error = null;
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.list", {});
const normalized = normalizeCardsPayload(payload);
state.cards = normalized.cards;
state.statuses = normalized.statuses;
state.loaded = true;
} catch (error) {
state.error = formatError(error);
} finally {
state.loading = false;
params.requestUpdate?.();
}
}
function replaceCard(state: WorkboardUiState, card: WorkboardCard) {
const next = state.cards.filter((existing) => existing.id !== card.id);
next.push(card);
state.cards = next.toSorted((left, right) => left.position - right.position);
}
export async function createWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
if (!params.client || !state.draftTitle.trim()) {
return;
}
state.loading = true;
state.error = null;
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.create", {
title: state.draftTitle,
notes: state.draftNotes,
priority: state.draftPriority,
agentId: state.draftAgentId,
});
replaceCard(state, normalizeCardPayload(payload));
state.draftOpen = false;
state.draftTitle = "";
state.draftNotes = "";
state.draftPriority = "normal";
state.draftAgentId = "";
} catch (error) {
state.error = formatError(error);
} finally {
state.loading = false;
params.requestUpdate?.();
}
}
export async function moveWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
cardId: string;
status: WorkboardStatus;
position: number;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
if (!params.client) {
return;
}
state.busyCardId = params.cardId;
state.error = null;
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.move", {
id: params.cardId,
status: params.status,
position: params.position,
});
replaceCard(state, normalizeCardPayload(payload));
} catch (error) {
state.error = formatError(error);
} finally {
state.busyCardId = null;
state.draggedCardId = null;
params.requestUpdate?.();
}
}
export async function deleteWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
cardId: string;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
if (!params.client) {
return;
}
state.busyCardId = params.cardId;
state.error = null;
params.requestUpdate?.();
try {
await params.client.request("workboard.cards.delete", { id: params.cardId });
state.cards = state.cards.filter((card) => card.id !== params.cardId);
} catch (error) {
state.error = formatError(error);
} finally {
state.busyCardId = null;
params.requestUpdate?.();
}
}
function buildCardPrompt(card: WorkboardCard): string {
const lines = [`Work on this OpenClaw Workboard card: ${card.title}`];
if (card.notes?.trim()) {
lines.push("", card.notes.trim());
}
if (card.labels.length > 0) {
lines.push("", `Labels: ${card.labels.join(", ")}`);
}
lines.push("", "When done, summarize what changed and what remains.");
return lines.join("\n");
}
export async function startWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
card: WorkboardCard;
requestUpdate?: () => void;
}): Promise<string | null> {
const state = getWorkboardState(params.host);
if (!params.client) {
return null;
}
state.busyCardId = params.card.id;
state.error = null;
params.requestUpdate?.();
try {
const created = await params.client.request("sessions.create", {
...(params.card.agentId ? { agentId: params.card.agentId } : {}),
label: params.card.title,
message: buildCardPrompt(params.card),
});
const sessionKey =
isRecord(created) && typeof created.key === "string" && created.key.trim()
? created.key.trim()
: null;
const runId =
isRecord(created) && typeof created.runId === "string" && created.runId.trim()
? created.runId.trim()
: undefined;
const payload = await params.client.request("workboard.cards.update", {
id: params.card.id,
patch: {
status: "running",
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
},
});
replaceCard(state, normalizeCardPayload(payload));
return sessionKey;
} catch (error) {
state.error = formatError(error);
return null;
} finally {
state.busyCardId = null;
params.requestUpdate?.();
}
}
export function findWorkboardSession(
card: WorkboardCard,
sessions: readonly GatewaySessionRow[],
): GatewaySessionRow | null {
if (!card.sessionKey) {
return null;
}
return sessions.find((session) => session.key === card.sessionKey) ?? null;
}

View File

@@ -127,6 +127,7 @@ export const icons = {
</svg>
`,
check: html` <svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></svg> `,
play: html` <svg viewBox="0 0 24 24"><polygon points="6 3 20 12 6 21 6 3" /></svg> `,
arrowDown: html`
<svg viewBox="0 0 24 24">
<path d="M12 5v14" />

View File

@@ -19,6 +19,7 @@ describe("TAB_GROUPS", () => {
expect(control?.tabs).toEqual([
"overview",
"activity",
"workboard",
"instances",
"sessions",
"usage",

View File

@@ -30,6 +30,7 @@ describe("iconForTab", () => {
chat: "messageSquare",
overview: "barChart",
activity: "activity",
workboard: "folder",
channels: "link",
instances: "radio",
sessions: "fileText",
@@ -63,6 +64,7 @@ describe("titleForTab", () => {
chat: "Chat",
overview: "Overview",
activity: "Activity",
workboard: "Workboard",
channels: "Channels",
instances: "Instances",
sessions: "Sessions",
@@ -90,6 +92,7 @@ describe("subtitleForTab", () => {
chat: "Gateway chat for quick interventions.",
overview: "Status, entry points, health.",
activity: "Browser-local tool activity summaries.",
workboard: "Agent work queue and session handoff.",
channels: "Channels and settings.",
instances: "Connected clients and nodes.",
sessions: "Active sessions and defaults.",

View File

@@ -6,7 +6,7 @@ export const TAB_GROUPS = [
{ label: "chat", tabs: ["chat"] },
{
label: "control",
tabs: ["overview", "activity", "instances", "sessions", "usage", "cron"],
tabs: ["overview", "activity", "workboard", "instances", "sessions", "usage", "cron"],
},
{ label: "agent", tabs: ["agents", "skills", "nodes", "dreams"] },
{
@@ -19,6 +19,7 @@ export type Tab =
| "agents"
| "activity"
| "overview"
| "workboard"
| "channels"
| "instances"
| "sessions"
@@ -53,6 +54,7 @@ const TAB_PATHS: Record<Tab, string> = {
agents: "/agents",
activity: "/activity",
overview: "/overview",
workboard: "/workboard",
channels: "/channels",
instances: "/instances",
sessions: "/sessions",
@@ -181,6 +183,8 @@ export function iconForTab(tab: Tab): IconName {
return "barChart";
case "activity":
return "activity";
case "workboard":
return "folder";
case "channels":
return "link";
case "instances":

View File

@@ -0,0 +1,93 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { getWorkboardState } from "../controllers/workboard.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import { renderWorkboard } from "./workboard.ts";
describe("renderWorkboard", () => {
it("renders board columns and preloaded cards", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Wire dashboard tab",
notes: "Call plugin gateway methods from the Workboard page.",
status: "todo",
priority: "high",
labels: ["ui"],
agentId: "main",
position: 1000,
createdAt: 1,
updatedAt: 1,
},
];
const container = document.createElement("div");
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
expect(container.textContent).toContain("Todo");
expect(container.textContent).toContain("Wire dashboard tab");
expect(container.querySelectorAll(".workboard-column")).toHaveLength(6);
expect(container.querySelector(".workboard-card__priority")?.textContent).toContain("high");
});
it("shows an enablement message when the optional plugin is disabled", () => {
const container = document.createElement("div");
render(
renderWorkboard({
host: {},
client: null,
connected: true,
pluginEnabled: false,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
expect(container.textContent).toContain("Workboard is disabled");
expect(container.querySelector(".workboard-column")).toBeNull();
});
it("does not retry a failed initial load on every render", async () => {
const host = {};
const container = document.createElement("div");
const request = vi.fn(async () => {
throw new Error("workboard unavailable");
});
const props = {
host,
client: { request } as unknown as GatewayBrowserClient,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
};
render(renderWorkboard(props), container);
await Promise.resolve();
await Promise.resolve();
render(renderWorkboard(props), container);
await Promise.resolve();
expect(request).toHaveBeenCalledOnce();
expect(getWorkboardState(host).error).toBe("workboard unavailable");
});
});

View File

@@ -0,0 +1,375 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import {
createWorkboardCard,
deleteWorkboardCard,
findWorkboardSession,
getWorkboardState,
loadWorkboard,
moveWorkboardCard,
startWorkboardCard,
WORKBOARD_PRIORITIES,
type WorkboardCard,
type WorkboardPriority,
type WorkboardStatus,
type WorkboardUiState,
} from "../controllers/workboard.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import { icons } from "../icons.ts";
import type { AgentsListResult, GatewaySessionRow } from "../types.ts";
type WorkboardProps = {
host: object;
client: GatewayBrowserClient | null;
connected: boolean;
pluginEnabled: boolean;
agentsList: AgentsListResult | null;
sessions: GatewaySessionRow[];
onOpenSession: (sessionKey: string) => void;
onRequestUpdate?: () => void;
};
const STATUS_LABELS: Record<WorkboardStatus, string> = {
backlog: "Backlog",
todo: "Todo",
running: "Running",
review: "Review",
blocked: "Blocked",
done: "Done",
};
function formatTime(value: number | undefined): string {
if (!value) {
return "";
}
return new Date(value).toLocaleDateString([], {
month: "short",
day: "numeric",
});
}
function matchesFilter(
card: WorkboardCard,
options: { query: string; priority: "all" | WorkboardPriority },
): boolean {
if (options.priority !== "all" && card.priority !== options.priority) {
return false;
}
const query = options.query.trim().toLowerCase();
if (!query) {
return true;
}
return [card.title, card.notes, card.agentId, card.sessionKey, ...card.labels]
.filter((value): value is string => typeof value === "string")
.some((value) => value.toLowerCase().includes(query));
}
function nextPosition(cards: readonly WorkboardCard[], status: WorkboardStatus): number {
const positions = cards.filter((card) => card.status === status).map((card) => card.position);
return (positions.length ? Math.max(...positions) : 0) + 1000;
}
function renderDraft(props: WorkboardProps) {
const state = getWorkboardState(props.host);
const agents = props.agentsList?.agents ?? [];
if (!state.draftOpen) {
return nothing;
}
return html`
<form
class="workboard-draft"
@submit=${(event: SubmitEvent) => {
event.preventDefault();
void createWorkboardCard({
host: props.host,
client: props.client,
requestUpdate: props.onRequestUpdate,
});
}}
>
<div class="workboard-draft__main">
<input
class="input workboard-draft__title"
placeholder="Card title"
.value=${state.draftTitle}
@input=${(event: InputEvent) => {
state.draftTitle = (event.currentTarget as HTMLInputElement).value;
props.onRequestUpdate?.();
}}
/>
<textarea
class="input workboard-draft__notes"
placeholder="Notes, acceptance criteria, links"
.value=${state.draftNotes}
@input=${(event: InputEvent) => {
state.draftNotes = (event.currentTarget as HTMLTextAreaElement).value;
props.onRequestUpdate?.();
}}
></textarea>
</div>
<div class="workboard-draft__meta">
<select
class="input"
.value=${state.draftPriority}
@change=${(event: Event) => {
state.draftPriority = (event.currentTarget as HTMLSelectElement)
.value as WorkboardPriority;
props.onRequestUpdate?.();
}}
>
${WORKBOARD_PRIORITIES.map(
(priority) => html`<option value=${priority}>${priority}</option>`,
)}
</select>
<select
class="input"
.value=${state.draftAgentId}
@change=${(event: Event) => {
state.draftAgentId = (event.currentTarget as HTMLSelectElement).value;
props.onRequestUpdate?.();
}}
>
<option value="">Default agent</option>
${agents.map(
(agent) =>
html`<option value=${agent.id}>
${agent.name ?? agent.identity?.name ?? agent.id}
</option>`,
)}
</select>
<button class="btn primary" ?disabled=${state.loading || !state.draftTitle.trim()}>
${t("common.create")}
</button>
<button
class="btn"
type="button"
@click=${() => {
state.draftOpen = false;
props.onRequestUpdate?.();
}}
>
${t("common.cancel")}
</button>
</div>
</form>
`;
}
function renderCard(props: WorkboardProps, card: WorkboardCard) {
const state = getWorkboardState(props.host);
const session = findWorkboardSession(card, props.sessions);
const busy = state.busyCardId === card.id;
const live = session?.hasActiveRun === true || card.status === "running";
return html`
<article
class="workboard-card priority-${card.priority} ${busy ? "workboard-card--busy" : ""}"
draggable="true"
@dragstart=${(event: DragEvent) => {
state.draggedCardId = card.id;
event.dataTransfer?.setData("text/plain", card.id);
event.dataTransfer?.setDragImage(event.currentTarget as Element, 16, 16);
props.onRequestUpdate?.();
}}
@dragend=${() => {
state.draggedCardId = null;
props.onRequestUpdate?.();
}}
>
<div class="workboard-card__top">
<span class="workboard-card__priority">${card.priority}</span>
${live ? html`<span class="workboard-live">live</span>` : nothing}
</div>
<h3>${card.title}</h3>
${card.notes ? html`<p>${card.notes}</p>` : nothing}
${card.labels.length
? html`<div class="workboard-labels">
${card.labels.map((label) => html`<span>${label}</span>`)}
</div>`
: nothing}
<div class="workboard-card__meta">
${card.agentId ? html`<span>${card.agentId}</span>` : html`<span>default agent</span>`}
<span>${formatTime(card.updatedAt)}</span>
</div>
<div class="workboard-card__actions">
${card.sessionKey
? html`
<button
class="icon-btn"
title="Open session"
@click=${() => props.onOpenSession(card.sessionKey!)}
>
${icons.messageSquare}
</button>
`
: html`
<button
class="icon-btn"
title="Start session"
?disabled=${busy || !props.connected}
@click=${async () => {
const key = await startWorkboardCard({
host: props.host,
client: props.client,
card,
requestUpdate: props.onRequestUpdate,
});
if (key) {
props.onOpenSession(key);
}
}}
>
${icons.play}
</button>
`}
<button
class="icon-btn"
title="Delete card"
?disabled=${busy}
@click=${() =>
deleteWorkboardCard({
host: props.host,
client: props.client,
cardId: card.id,
requestUpdate: props.onRequestUpdate,
})}
>
${icons.trash}
</button>
</div>
</article>
`;
}
function renderColumn(props: WorkboardProps, status: WorkboardStatus, cards: WorkboardCard[]) {
const state = getWorkboardState(props.host);
return html`
<section
class="workboard-column ${state.draggedCardId ? "workboard-column--drop" : ""}"
@dragover=${(event: DragEvent) => {
if (state.draggedCardId) {
event.preventDefault();
}
}}
@drop=${(event: DragEvent) => {
event.preventDefault();
const cardId = event.dataTransfer?.getData("text/plain") || state.draggedCardId;
if (!cardId) {
return;
}
void moveWorkboardCard({
host: props.host,
client: props.client,
cardId,
status,
position: nextPosition(state.cards, status),
requestUpdate: props.onRequestUpdate,
});
}}
>
<div class="workboard-column__header">
<h2>${STATUS_LABELS[status]}</h2>
<span>${cards.length}</span>
</div>
<div class="workboard-column__cards">
${cards.length
? cards.map((card) => renderCard(props, card))
: html`<div class="workboard-empty">Drop work here</div>`}
</div>
</section>
`;
}
export function renderWorkboard(props: WorkboardProps) {
const state = getWorkboardState(props.host);
if (props.pluginEnabled) {
void loadWorkboard({
host: props.host,
client: props.client,
requestUpdate: props.onRequestUpdate,
});
}
if (!props.pluginEnabled) {
return html`
<section class="workboard">
<div class="callout">
Workboard is disabled. Enable <code>plugins.entries.workboard.enabled = true</code>, then
reload this tab.
</div>
</section>
`;
}
const filtered = state.cards.filter((card) =>
matchesFilter(card, { query: state.query, priority: state.priorityFilter }),
);
const byStatus = new Map<WorkboardStatus, WorkboardCard[]>();
for (const status of state.statuses) {
byStatus.set(status, []);
}
for (const card of filtered) {
byStatus.get(card.status)?.push(card);
}
return html`
<section class="workboard">
<div class="workboard-toolbar">
<div class="workboard-toolbar__filters">
<input
class="input"
type="search"
placeholder="Search cards"
.value=${state.query}
@input=${(event: InputEvent) => {
state.query = (event.currentTarget as HTMLInputElement).value;
props.onRequestUpdate?.();
}}
/>
<select
class="input"
.value=${state.priorityFilter}
@change=${(event: Event) => {
state.priorityFilter = (event.currentTarget as HTMLSelectElement)
.value as WorkboardUiState["priorityFilter"];
props.onRequestUpdate?.();
}}
>
<option value="all">All priorities</option>
${WORKBOARD_PRIORITIES.map(
(priority) => html`<option value=${priority}>${priority}</option>`,
)}
</select>
</div>
<div class="workboard-toolbar__actions">
<button
class="btn"
?disabled=${state.loading}
@click=${() =>
loadWorkboard({
host: props.host,
client: props.client,
requestUpdate: props.onRequestUpdate,
force: true,
})}
>
${state.loading ? t("common.refreshing") : t("common.refresh")}
</button>
<button
class="btn primary"
@click=${() => {
state.draftOpen = true;
props.onRequestUpdate?.();
}}
>
${icons.plus} New card
</button>
</div>
</div>
${state.error ? html`<div class="callout danger">${state.error}</div>` : nothing}
${renderDraft(props)}
<div class="workboard-board">
${state.statuses.map((status) => renderColumn(props, status, byStatus.get(status) ?? []))}
</div>
</section>
`;
}