From 86ed25af34ebbcbc04e7079a4bdcc3ad4ead3254 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 24 May 2026 00:17:45 +0100 Subject: [PATCH] feat: add workboard dashboard plugin --- .github/labeler.yml | 6 + CHANGELOG.md | 1 + docs/docs.json | 2 + docs/plugins/plugin-inventory.md | 1 + docs/plugins/reference.md | 1 + docs/plugins/reference/workboard.md | 23 ++ docs/plugins/workboard.md | 131 ++++++ extensions/workboard/api.ts | 1 + extensions/workboard/index.ts | 11 + extensions/workboard/openclaw.plugin.json | 13 + extensions/workboard/package.json | 24 ++ extensions/workboard/runtime-api.ts | 7 + extensions/workboard/src/gateway.test.ts | 71 ++++ extensions/workboard/src/gateway.ts | 110 +++++ extensions/workboard/src/store.test.ts | 75 ++++ extensions/workboard/src/store.ts | 274 +++++++++++++ extensions/workboard/src/types.ts | 37 ++ pnpm-lock.yaml | 9 + .../lib/bundled-runtime-sidecar-paths.json | 1 + ui/src/i18n/.i18n/raw-copy-baseline.json | 77 ++++ ui/src/i18n/locales/ar.ts | 2 + ui/src/i18n/locales/de.ts | 2 + ui/src/i18n/locales/en.ts | 2 + ui/src/i18n/locales/es.ts | 2 + ui/src/i18n/locales/fa.ts | 2 + ui/src/i18n/locales/fr.ts | 2 + ui/src/i18n/locales/id.ts | 2 + ui/src/i18n/locales/it.ts | 2 + ui/src/i18n/locales/ja-JP.ts | 2 + ui/src/i18n/locales/ko.ts | 2 + ui/src/i18n/locales/nl.ts | 2 + ui/src/i18n/locales/pl.ts | 2 + ui/src/i18n/locales/pt-BR.ts | 2 + ui/src/i18n/locales/th.ts | 2 + ui/src/i18n/locales/tr.ts | 2 + ui/src/i18n/locales/uk.ts | 2 + ui/src/i18n/locales/vi.ts | 2 + ui/src/i18n/locales/zh-CN.ts | 2 + ui/src/i18n/locales/zh-TW.ts | 2 + ui/src/styles.css | 1 + ui/src/styles/workboard.css | 207 ++++++++++ ui/src/ui/app-render.ts | 20 + ...p-settings.refresh-active-tab.node.test.ts | 10 + ui/src/ui/app-settings.ts | 3 + ui/src/ui/controllers/workboard.test.ts | 103 +++++ ui/src/ui/controllers/workboard.ts | 353 +++++++++++++++++ ui/src/ui/icons.ts | 1 + ui/src/ui/navigation-groups.test.ts | 1 + ui/src/ui/navigation.test.ts | 3 + ui/src/ui/navigation.ts | 6 +- ui/src/ui/views/workboard.test.ts | 93 +++++ ui/src/ui/views/workboard.ts | 375 ++++++++++++++++++ 52 files changed, 2088 insertions(+), 1 deletion(-) create mode 100644 docs/plugins/reference/workboard.md create mode 100644 docs/plugins/workboard.md create mode 100644 extensions/workboard/api.ts create mode 100644 extensions/workboard/index.ts create mode 100644 extensions/workboard/openclaw.plugin.json create mode 100644 extensions/workboard/package.json create mode 100644 extensions/workboard/runtime-api.ts create mode 100644 extensions/workboard/src/gateway.test.ts create mode 100644 extensions/workboard/src/gateway.ts create mode 100644 extensions/workboard/src/store.test.ts create mode 100644 extensions/workboard/src/store.ts create mode 100644 extensions/workboard/src/types.ts create mode 100644 ui/src/styles/workboard.css create mode 100644 ui/src/ui/controllers/workboard.test.ts create mode 100644 ui/src/ui/controllers/workboard.ts create mode 100644 ui/src/ui/views/workboard.test.ts create mode 100644 ui/src/ui/views/workboard.ts diff --git a/.github/labeler.yml b/.github/labeler.yml index 18728eebc36b..8535f0e0789e 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 29720aca329b..e968fb6baf0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/docs.json b/docs/docs.json index 60e4900f21aa..560da4b7a54f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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", diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 88b6497c9ef5..5c9e69af4b00 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -136,6 +136,7 @@ commands. | [vydra](/plugins/reference/vydra) | Adds Vydra model provider support to OpenClaw. | `@openclaw/vydra-provider`
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`
included in OpenClaw | contracts: webContentExtractors | | [webhooks](/plugins/reference/webhooks) | Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows. | `@openclaw/webhooks`
included in OpenClaw | plugin | +| [workboard](/plugins/reference/workboard) | Dashboard workboard for agent-owned issues and sessions. | `@openclaw/workboard`
included in OpenClaw | plugin | | [xai](/plugins/reference/xai) | Adds xAI model provider support to OpenClaw. | `@openclaw/xai-plugin`
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`
included in OpenClaw | providers: xiaomi; contracts: speechProviders | | [zai](/plugins/reference/zai) | Adds Z.AI model provider support to OpenClaw. | `@openclaw/zai-provider`
included in OpenClaw | providers: zai; contracts: mediaUnderstandingProviders | diff --git a/docs/plugins/reference.md b/docs/plugins/reference.md index b1f2e9234d77..f72ab6f9cbb2 100644 --- a/docs/plugins/reference.md +++ b/docs/plugins/reference.md @@ -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`
included in OpenClaw | contracts: webContentExtractors | | [webhooks](/plugins/reference/webhooks) | Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows. | `@openclaw/webhooks`
included in OpenClaw | plugin | | [whatsapp](/plugins/reference/whatsapp) | OpenClaw WhatsApp channel plugin for WhatsApp Web chats. | `@openclaw/whatsapp`
ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp | +| [workboard](/plugins/reference/workboard) | Dashboard workboard for agent-owned issues and sessions. | `@openclaw/workboard`
included in OpenClaw | plugin | | [xai](/plugins/reference/xai) | Adds xAI model provider support to OpenClaw. | `@openclaw/xai-plugin`
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`
included in OpenClaw | providers: xiaomi; contracts: speechProviders | | [zai](/plugins/reference/zai) | Adds Z.AI model provider support to OpenClaw. | `@openclaw/zai-provider`
included in OpenClaw | providers: zai; contracts: mediaUnderstandingProviders | diff --git a/docs/plugins/reference/workboard.md b/docs/plugins/reference/workboard.md new file mode 100644 index 000000000000..f62faa1c8760 --- /dev/null +++ b/docs/plugins/reference/workboard.md @@ -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) diff --git a/docs/plugins/workboard.md b/docs/plugins/workboard.md new file mode 100644 index 000000000000..9f5c2b5720dc --- /dev/null +++ b/docs/plugins/workboard.md @@ -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) diff --git a/extensions/workboard/api.ts b/extensions/workboard/api.ts new file mode 100644 index 000000000000..786d7fa0d0c2 --- /dev/null +++ b/extensions/workboard/api.ts @@ -0,0 +1 @@ +export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; diff --git a/extensions/workboard/index.ts b/extensions/workboard/index.ts new file mode 100644 index 000000000000..1ccad5f2414b --- /dev/null +++ b/extensions/workboard/index.ts @@ -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 }); + }, +}); diff --git a/extensions/workboard/openclaw.plugin.json b/extensions/workboard/openclaw.plugin.json new file mode 100644 index 000000000000..d7853f385a39 --- /dev/null +++ b/extensions/workboard/openclaw.plugin.json @@ -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": {} + } +} diff --git a/extensions/workboard/package.json b/extensions/workboard/package.json new file mode 100644 index 000000000000..afdf37b65420 --- /dev/null +++ b/extensions/workboard/package.json @@ -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" + ] + } +} diff --git a/extensions/workboard/runtime-api.ts b/extensions/workboard/runtime-api.ts new file mode 100644 index 000000000000..713c9d7e26c3 --- /dev/null +++ b/extensions/workboard/runtime-api.ts @@ -0,0 +1,7 @@ +export { registerWorkboardGatewayMethods } from "./src/gateway.js"; +export type { + WorkboardCard, + WorkboardListResult, + WorkboardPriority, + WorkboardStatus, +} from "./src/types.js"; diff --git a/extensions/workboard/src/gateway.test.ts b/extensions/workboard/src/gateway.test.ts new file mode 100644 index 000000000000..ee0dba2e4a58 --- /dev/null +++ b/extensions/workboard/src/gateway.test.ts @@ -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>>(); + 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[1]; + opts: Parameters[2]; + }; + const methods = new Map(); + 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" })], + }); + }); +}); diff --git a/extensions/workboard/src/gateway.ts b/extensions/workboard/src/gateway.ts new file mode 100644 index 000000000000..386c43252189 --- /dev/null +++ b/extensions/workboard/src/gateway.ts @@ -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[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 { + const value = params.id; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + throw new Error("id is required."); +} + +function readPatch(params: Record): Record { + const patch = params.patch; + if (patch && typeof patch === "object" && !Array.isArray(patch)) { + return patch as Record; + } + return params; +} + +export function registerWorkboardGatewayMethods(params: { api: OpenClawPluginApi }) { + const { api } = params; + const store = WorkboardStore.open((options) => + api.runtime.state.openKeyedStore(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 }, + ); +} diff --git a/extensions/workboard/src/store.test.ts b/extensions/workboard/src/store.test.ts new file mode 100644 index 000000000000..0748498b20ba --- /dev/null +++ b/extensions/workboard/src/store.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { WorkboardStore, type WorkboardKeyedStore } from "./store.js"; + +function createMemoryStore(): WorkboardKeyedStore { + const entries = new Map>>(); + 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/, + ); + }); +}); diff --git a/extensions/workboard/src/store.ts b/extensions/workboard/src/store.ts new file mode 100644 index 000000000000..f212c81da4cb --- /dev/null +++ b/extensions/workboard/src/store.ts @@ -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; + lookup(key: string): Promise; + delete(key: string): Promise; + entries(): Promise>; +}; + +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; + +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 { + 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 { + const entry = await this.store.lookup(id.trim()); + return entry?.version === 1 ? entry.card : undefined; + } + + async create(input: WorkboardCardInput): Promise { + 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 { + 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 { + 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, + }), + ); + } +} diff --git a/extensions/workboard/src/types.ts b/extensions/workboard/src/types.ts new file mode 100644 index 000000000000..b2c88d309a78 --- /dev/null +++ b/extensions/workboard/src/types.ts @@ -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[]; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e324fede5ab5..455076d4a856 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/lib/bundled-runtime-sidecar-paths.json b/scripts/lib/bundled-runtime-sidecar-paths.json index b3402442b670..b8b9777c2ad4 100644 --- a/scripts/lib/bundled-runtime-sidecar-paths.json +++ b/scripts/lib/bundled-runtime-sidecar-paths.json @@ -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" ] diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index 4d5adfcf1282..da5654d201cb 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -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" } ] } diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index e4b6f27169bc..d78203c03baf 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -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: "الجلسات النشطة والافتراضيات.", diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index 3c794610ebda..3628665276a6 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -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.", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 99a5f696aca9..10cea3466a75 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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.", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index 7ec0669908b6..497eea8a9783 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -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.", diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index 73855832ed1d..0d9371a52bff 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -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: "نشست‌های فعال و پیش‌فرض‌ها.", diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index f56d83b33395..ee0742a2f7d4 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -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 d’activité des outils locaux au navigateur.", overview: "Statut, points d’entrée, santé.", + workboard: "File de travail de l’agent et transfert de session.", channels: "Canaux et paramètres.", instances: "Clients et nœuds connectés.", sessions: "Sessions actives et valeurs par défaut.", diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index f40edbd44c50..9ca95bf66b3b 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -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.", diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index 63a246490c15..1a31e73c6f5c 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -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.", diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index 5f1ad4c51023..a8c1efb6b772 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -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: "アクティブなセッションとデフォルト。", diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index 25fe1d79ffc4..cfff2fee104e 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -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: "활성 세션 및 기본값.", diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index 3f215ca64359..0430e7b7fe75 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -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.", diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index 7dbec7ec0367..793fb74e4b6b 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -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.", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index d1cdba10594b..5e3787180786 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -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.", diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index 067a2a70046d..25e20379ac9a 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -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: "เซสชันที่ใช้งานอยู่และค่าเริ่มต้น", diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index 3102aa9cfd00..5012261a35f0 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -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.", diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index 3a269ecaec6d..95930b74f999 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -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: "Активні сеанси та типові значення.", diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index a4bd998e5ba1..79ef1f46593b 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -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.", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index a41f52d193e8..7a9b1f6094c7 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -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: "活动会话和默认设置。", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 888bb37b2420..643f541715d8 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -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: "活動會話和默認設置。", diff --git a/ui/src/styles.css b/ui/src/styles.css index 6b81df04e66d..599e3ac524bb 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -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 { diff --git a/ui/src/styles/workboard.css b/ui/src/styles/workboard.css new file mode 100644 index 000000000000..55095f7e4cf8 --- /dev/null +++ b/ui/src/styles/workboard.css @@ -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)); + } +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index f97c5ed25d31..da2b2fd8709c 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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" diff --git a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts index 7ebb2b90fce7..917457915e9c 100644 --- a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts +++ b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts @@ -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"; diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index dd752d24e934..fce80950b88a 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -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; diff --git a/ui/src/ui/controllers/workboard.test.ts b/ui/src/ui/controllers/workboard.test.ts new file mode 100644 index 000000000000..d5dd96a5c370 --- /dev/null +++ b/ui/src/ui/controllers/workboard.test.ts @@ -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) { + 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, + }); + }); +}); diff --git a/ui/src/ui/controllers/workboard.ts b/ui/src/ui/controllers/workboard.ts new file mode 100644 index 000000000000..316407d5eb59 --- /dev/null +++ b/ui/src/ui/controllers/workboard.ts @@ -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(); + +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 { + 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 { + 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; +} diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts index ff1a13b2829a..f0bb470923b7 100644 --- a/ui/src/ui/icons.ts +++ b/ui/src/ui/icons.ts @@ -127,6 +127,7 @@ export const icons = { `, check: html` `, + play: html` `, arrowDown: html` diff --git a/ui/src/ui/navigation-groups.test.ts b/ui/src/ui/navigation-groups.test.ts index 05bd2f5b5ca9..6e467d1fdf54 100644 --- a/ui/src/ui/navigation-groups.test.ts +++ b/ui/src/ui/navigation-groups.test.ts @@ -19,6 +19,7 @@ describe("TAB_GROUPS", () => { expect(control?.tabs).toEqual([ "overview", "activity", + "workboard", "instances", "sessions", "usage", diff --git a/ui/src/ui/navigation.test.ts b/ui/src/ui/navigation.test.ts index 47edb19310cb..1d612e1480b5 100644 --- a/ui/src/ui/navigation.test.ts +++ b/ui/src/ui/navigation.test.ts @@ -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.", diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 098cbc5571a0..fa24019cae63 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -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 = { 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": diff --git a/ui/src/ui/views/workboard.test.ts b/ui/src/ui/views/workboard.test.ts new file mode 100644 index 000000000000..1d68fda25134 --- /dev/null +++ b/ui/src/ui/views/workboard.test.ts @@ -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"); + }); +}); diff --git a/ui/src/ui/views/workboard.ts b/ui/src/ui/views/workboard.ts new file mode 100644 index 000000000000..01e1b9b74a9e --- /dev/null +++ b/ui/src/ui/views/workboard.ts @@ -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 = { + 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` +
{ + event.preventDefault(); + void createWorkboardCard({ + host: props.host, + client: props.client, + requestUpdate: props.onRequestUpdate, + }); + }} + > +
+ { + state.draftTitle = (event.currentTarget as HTMLInputElement).value; + props.onRequestUpdate?.(); + }} + /> + +
+
+ + + + +
+ + `; +} + +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` +
{ + 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?.(); + }} + > +
+ ${card.priority} + ${live ? html`live` : nothing} +
+

${card.title}

+ ${card.notes ? html`

${card.notes}

` : nothing} + ${card.labels.length + ? html`
+ ${card.labels.map((label) => html`${label}`)} +
` + : nothing} +
+ ${card.agentId ? html`${card.agentId}` : html`default agent`} + ${formatTime(card.updatedAt)} +
+
+ ${card.sessionKey + ? html` + + ` + : html` + + `} + +
+
+ `; +} + +function renderColumn(props: WorkboardProps, status: WorkboardStatus, cards: WorkboardCard[]) { + const state = getWorkboardState(props.host); + return html` +
{ + 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, + }); + }} + > +
+

${STATUS_LABELS[status]}

+ ${cards.length} +
+
+ ${cards.length + ? cards.map((card) => renderCard(props, card)) + : html`
Drop work here
`} +
+
+ `; +} + +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` +
+
+ Workboard is disabled. Enable plugins.entries.workboard.enabled = true, then + reload this tab. +
+
+ `; + } + + const filtered = state.cards.filter((card) => + matchesFilter(card, { query: state.query, priority: state.priorityFilter }), + ); + const byStatus = new Map(); + for (const status of state.statuses) { + byStatus.set(status, []); + } + for (const card of filtered) { + byStatus.get(card.status)?.push(card); + } + + return html` +
+
+
+ { + state.query = (event.currentTarget as HTMLInputElement).value; + props.onRequestUpdate?.(); + }} + /> + +
+
+ + +
+
+ ${state.error ? html`
${state.error}
` : nothing} + ${renderDraft(props)} +
+ ${state.statuses.map((status) => renderColumn(props, status, byStatus.get(status) ?? []))} +
+
+ `; +}