mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat: add workboard dashboard plugin
This commit is contained in:
6
.github/labeler.yml
vendored
6
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
23
docs/plugins/reference/workboard.md
Normal file
23
docs/plugins/reference/workboard.md
Normal 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
131
docs/plugins/workboard.md
Normal 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)
|
||||
1
extensions/workboard/api.ts
Normal file
1
extensions/workboard/api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
11
extensions/workboard/index.ts
Normal file
11
extensions/workboard/index.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
13
extensions/workboard/openclaw.plugin.json
Normal file
13
extensions/workboard/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
24
extensions/workboard/package.json
Normal file
24
extensions/workboard/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
7
extensions/workboard/runtime-api.ts
Normal file
7
extensions/workboard/runtime-api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { registerWorkboardGatewayMethods } from "./src/gateway.js";
|
||||
export type {
|
||||
WorkboardCard,
|
||||
WorkboardListResult,
|
||||
WorkboardPriority,
|
||||
WorkboardStatus,
|
||||
} from "./src/types.js";
|
||||
71
extensions/workboard/src/gateway.test.ts
Normal file
71
extensions/workboard/src/gateway.test.ts
Normal 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" })],
|
||||
});
|
||||
});
|
||||
});
|
||||
110
extensions/workboard/src/gateway.ts
Normal file
110
extensions/workboard/src/gateway.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
75
extensions/workboard/src/store.test.ts
Normal file
75
extensions/workboard/src/store.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
274
extensions/workboard/src/store.ts
Normal file
274
extensions/workboard/src/store.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
37
extensions/workboard/src/types.ts
Normal file
37
extensions/workboard/src/types.ts
Normal 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
9
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
77
ui/src/i18n/.i18n/raw-copy-baseline.json
generated
77
ui/src/i18n/.i18n/raw-copy-baseline.json
generated
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
2
ui/src/i18n/locales/ar.ts
generated
2
ui/src/i18n/locales/ar.ts
generated
@@ -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: "الجلسات النشطة والافتراضيات.",
|
||||
|
||||
2
ui/src/i18n/locales/de.ts
generated
2
ui/src/i18n/locales/de.ts
generated
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
2
ui/src/i18n/locales/es.ts
generated
2
ui/src/i18n/locales/es.ts
generated
@@ -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.",
|
||||
|
||||
2
ui/src/i18n/locales/fa.ts
generated
2
ui/src/i18n/locales/fa.ts
generated
@@ -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: "نشستهای فعال و پیشفرضها.",
|
||||
|
||||
2
ui/src/i18n/locales/fr.ts
generated
2
ui/src/i18n/locales/fr.ts
generated
@@ -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.",
|
||||
|
||||
2
ui/src/i18n/locales/id.ts
generated
2
ui/src/i18n/locales/id.ts
generated
@@ -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.",
|
||||
|
||||
2
ui/src/i18n/locales/it.ts
generated
2
ui/src/i18n/locales/it.ts
generated
@@ -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.",
|
||||
|
||||
2
ui/src/i18n/locales/ja-JP.ts
generated
2
ui/src/i18n/locales/ja-JP.ts
generated
@@ -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: "アクティブなセッションとデフォルト。",
|
||||
|
||||
2
ui/src/i18n/locales/ko.ts
generated
2
ui/src/i18n/locales/ko.ts
generated
@@ -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: "활성 세션 및 기본값.",
|
||||
|
||||
2
ui/src/i18n/locales/nl.ts
generated
2
ui/src/i18n/locales/nl.ts
generated
@@ -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.",
|
||||
|
||||
2
ui/src/i18n/locales/pl.ts
generated
2
ui/src/i18n/locales/pl.ts
generated
@@ -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.",
|
||||
|
||||
2
ui/src/i18n/locales/pt-BR.ts
generated
2
ui/src/i18n/locales/pt-BR.ts
generated
@@ -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.",
|
||||
|
||||
2
ui/src/i18n/locales/th.ts
generated
2
ui/src/i18n/locales/th.ts
generated
@@ -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: "เซสชันที่ใช้งานอยู่และค่าเริ่มต้น",
|
||||
|
||||
2
ui/src/i18n/locales/tr.ts
generated
2
ui/src/i18n/locales/tr.ts
generated
@@ -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.",
|
||||
|
||||
2
ui/src/i18n/locales/uk.ts
generated
2
ui/src/i18n/locales/uk.ts
generated
@@ -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: "Активні сеанси та типові значення.",
|
||||
|
||||
2
ui/src/i18n/locales/vi.ts
generated
2
ui/src/i18n/locales/vi.ts
generated
@@ -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.",
|
||||
|
||||
2
ui/src/i18n/locales/zh-CN.ts
generated
2
ui/src/i18n/locales/zh-CN.ts
generated
@@ -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: "活动会话和默认设置。",
|
||||
|
||||
2
ui/src/i18n/locales/zh-TW.ts
generated
2
ui/src/i18n/locales/zh-TW.ts
generated
@@ -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: "活動會話和默認設置。",
|
||||
|
||||
@@ -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
207
ui/src/styles/workboard.css
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
103
ui/src/ui/controllers/workboard.test.ts
Normal file
103
ui/src/ui/controllers/workboard.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
353
ui/src/ui/controllers/workboard.ts
Normal file
353
ui/src/ui/controllers/workboard.ts
Normal 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;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -19,6 +19,7 @@ describe("TAB_GROUPS", () => {
|
||||
expect(control?.tabs).toEqual([
|
||||
"overview",
|
||||
"activity",
|
||||
"workboard",
|
||||
"instances",
|
||||
"sessions",
|
||||
"usage",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
93
ui/src/ui/views/workboard.test.ts
Normal file
93
ui/src/ui/views/workboard.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
375
ui/src/ui/views/workboard.ts
Normal file
375
ui/src/ui/views/workboard.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user