feat(ui): improve Workboard task details

Make Workboard cards compact by moving expanded task/run metadata, proof, diagnostics, worker logs, automation, protocol state, events, and operator notes into a detail drawer.

Keep execution state simple and safe: active, linked, and archived cards avoid duplicate start paths; stale task cache is ignored when session lifecycle is authoritative; recent proof/events stay visible; dispatcher capacity distinguishes unclaimed review cards from claimed cards.
This commit is contained in:
Vincent Koc
2026-06-01 05:52:40 +01:00
committed by GitHub
parent 5957bfdc54
commit 0ae0051ae7
45 changed files with 1761 additions and 207 deletions

View File

@@ -80,6 +80,64 @@ describe("dispatchAndStartWorkboardCards", () => {
});
});
it("does not let review cards consume an agent running slot", async () => {
const store = new WorkboardStore(createMemoryStore());
await store.create({
title: "Waiting for operator review",
status: "review",
priority: "normal",
agentId: "codex-main",
});
const ready = await store.create({
title: "Next ready card",
status: "ready",
priority: "high",
agentId: "codex-main",
});
const run = vi.fn().mockResolvedValue({ runId: "run-next" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3 },
});
expect(result.started).toEqual([
expect.objectContaining({
cardId: ready.id,
runId: "run-next",
}),
]);
expect(run).toHaveBeenCalledOnce();
});
it("keeps claimed review cards in the owner running slot", async () => {
const store = new WorkboardStore(createMemoryStore());
const review = await store.create({
title: "Claimed operator review",
status: "review",
priority: "normal",
agentId: "codex-main",
});
await store.claim(review.id, { ownerId: "codex-main", token: "review-token" });
await store.create({
title: "Next ready card",
status: "ready",
priority: "high",
agentId: "codex-main",
});
const run = vi.fn().mockResolvedValue({ runId: "run-next" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3 },
});
expect(result.started).toEqual([]);
expect(run).not.toHaveBeenCalled();
});
it("blocks a card when worker start fails after claim", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Fail worker", status: "ready" });

View File

@@ -1,7 +1,7 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { WorkboardStore, type WorkboardDispatchResult } from "./store.js";
import type { WorkboardCard, WorkboardExecution, WorkboardStatus } from "./types.js";
import type { WorkboardCard, WorkboardExecution } from "./types.js";
const DEFAULT_DISPATCH_MAX_STARTS = 3;
const DEFAULT_DISPATCH_OWNER = "workboard-dispatcher";
@@ -126,10 +126,13 @@ function selectStartableCards(cards: WorkboardCard[], limit: number): WorkboardC
if (limit <= 0) {
return [];
}
const activeStatuses = new Set<WorkboardStatus>(["running", "review"]);
const runningByOwner = new Map<string, number>();
for (const card of cards) {
if (!activeStatuses.has(card.status) || cardIsArchived(card)) {
const consumesOwnerSlot =
card.status === "running" ||
Boolean(card.metadata?.claim) ||
card.execution?.status === "running";
if (!consumesOwnerSlot || cardIsArchived(card)) {
continue;
}
const owner = card.agentId ?? DEFAULT_DISPATCH_OWNER;

View File

@@ -977,7 +977,7 @@ export function createWorkboardTools(params: {
name: "workboard_dispatch",
label: "Workboard Dispatch",
description:
"Nudge Workboard dependency promotion and reclaim expired claims or timed-out runs.",
"Run one Workboard dispatcher pass: promote unblocked cards, reclaim expired claims, and block timed-out runs.",
parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => {
const result = await store.dispatch();

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:05.990Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:31.090Z",
"locale": "ar",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:05.583Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:30.623Z",
"locale": "de",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:05.664Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:30.724Z",
"locale": "es",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:06.719Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:31.893Z",
"locale": "fa",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:05.907Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:31.001Z",
"locale": "fr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:06.305Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:31.448Z",
"locale": "id",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:06.068Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:31.183Z",
"locale": "it",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:05.743Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:30.818Z",
"locale": "ja-JP",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:05.823Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:30.911Z",
"locale": "ko",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:06.639Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:31.801Z",
"locale": "nl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:06.382Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:31.535Z",
"locale": "pl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:05.498Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:30.531Z",
"locale": "pt-BR",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:06.469Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:31.623Z",
"locale": "th",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:06.146Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:31.272Z",
"locale": "tr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:06.227Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:31.362Z",
"locale": "uk",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:06.555Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:31.711Z",
"locale": "vi",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:05.323Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:30.090Z",
"locale": "zh-CN",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,32 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:28:05.410Z",
"fallbackKeys": [
"workboard.detailAddNote",
"workboard.detailAutomation",
"workboard.detailAutomationBoard",
"workboard.detailAutomationSkills",
"workboard.detailAutomationSummary",
"workboard.detailAutomationTenant",
"workboard.detailAutomationWorkspace",
"workboard.detailDiagnostics",
"workboard.detailNoNotes",
"workboard.detailNotePlaceholder",
"workboard.detailOperatorNotes",
"workboard.detailProof",
"workboard.detailRun",
"workboard.detailTask",
"workboard.detailTitle",
"workboard.detailUpdated",
"workboard.detailUpdatedValue",
"workboard.detailWorkerLogs",
"workboard.detailWorkerProtocol",
"workboard.viewDetails"
],
"generatedAt": "2026-06-01T02:32:30.440Z",
"locale": "zh-TW",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"sourceHash": "59589c3b29f7126995ed4763e88b763702a7c20b1791b4e16c62b394bca02d45",
"totalKeys": 1304,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -507,6 +507,26 @@ export const ar: TranslationMap = {
newCardHelp: "أضف العمل إلى قائمة الانتظار لجلسة وكيل.",
archiveCard: "أرشفة البطاقة",
deleteCard: "حذف البطاقة",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "فتح الجلسة",
openLinkedSession: "فتح الجلسة المرتبطة",
defaultAgent: "الوكيل الافتراضي",

View File

@@ -512,6 +512,26 @@ export const de: TranslationMap = {
newCardHelp: "Arbeit für eine Agentensitzung in die Warteschlange einreihen.",
archiveCard: "Karte archivieren",
deleteCard: "Karte löschen",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "Sitzung öffnen",
openLinkedSession: "Verknüpfte Sitzung öffnen",
defaultAgent: "Standard-Agent",

View File

@@ -506,6 +506,26 @@ export const en: TranslationMap = {
newCardHelp: "Queue work for an agent session.",
archiveCard: "Archive card",
deleteCard: "Delete card",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "Open session",
openLinkedSession: "Open linked session",
defaultAgent: "Default agent",

View File

@@ -509,6 +509,26 @@ export const es: TranslationMap = {
newCardHelp: "Pon trabajo en cola para una sesión de agente.",
archiveCard: "Archivar tarjeta",
deleteCard: "Eliminar tarjeta",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "Abrir sesión",
openLinkedSession: "Abrir sesión vinculada",
defaultAgent: "Agente predeterminado",

View File

@@ -509,6 +509,26 @@ export const fa: TranslationMap = {
newCardHelp: "کار را برای یک نشست عامل در صف قرار دهید.",
archiveCard: "بایگانی کارت",
deleteCard: "حذف کارت",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "باز کردن نشست",
openLinkedSession: "باز کردن نشست پیوندشده",
defaultAgent: "عامل پیش‌فرض",

View File

@@ -511,6 +511,26 @@ export const fr: TranslationMap = {
newCardHelp: "Mettez du travail en file dattente pour une session dagent.",
archiveCard: "Archiver la carte",
deleteCard: "Supprimer la carte",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "Ouvrir la session",
openLinkedSession: "Ouvrir la session liée",
defaultAgent: "Agent par défaut",

View File

@@ -508,6 +508,26 @@ export const id: TranslationMap = {
newCardHelp: "Antrekan pekerjaan untuk sesi agen.",
archiveCard: "Arsipkan kartu",
deleteCard: "Hapus kartu",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "Buka sesi",
openLinkedSession: "Buka sesi tertaut",
defaultAgent: "Agen default",

View File

@@ -510,6 +510,26 @@ export const it: TranslationMap = {
newCardHelp: "Metti in coda il lavoro per una sessione dell'agente.",
archiveCard: "Archivia scheda",
deleteCard: "Elimina scheda",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "Apri sessione",
openLinkedSession: "Apri sessione collegata",
defaultAgent: "Agente predefinito",

View File

@@ -511,6 +511,26 @@ export const ja_JP: TranslationMap = {
newCardHelp: "エージェントセッションの作業をキューに追加します。",
archiveCard: "カードをアーカイブ",
deleteCard: "カードを削除",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "セッションを開く",
openLinkedSession: "リンクされたセッションを開く",
defaultAgent: "デフォルトエージェント",

View File

@@ -507,6 +507,26 @@ export const ko: TranslationMap = {
newCardHelp: "에이전트 세션을 위한 작업을 대기열에 추가합니다.",
archiveCard: "카드 보관",
deleteCard: "카드 삭제",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "세션 열기",
openLinkedSession: "연결된 세션 열기",
defaultAgent: "기본 에이전트",

View File

@@ -510,6 +510,26 @@ export const nl: TranslationMap = {
newCardHelp: "Zet werk in de wachtrij voor een agentsessie.",
archiveCard: "Kaart archiveren",
deleteCard: "Kaart verwijderen",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "Sessie openen",
openLinkedSession: "Gekoppelde sessie openen",
defaultAgent: "Standaardagent",

View File

@@ -509,6 +509,26 @@ export const pl: TranslationMap = {
newCardHelp: "Dodaj zadanie do kolejki dla sesji agenta.",
archiveCard: "Archiwizuj kartę",
deleteCard: "Usuń kartę",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "Otwórz sesję",
openLinkedSession: "Otwórz powiązaną sesję",
defaultAgent: "Domyślny agent",

View File

@@ -508,6 +508,26 @@ export const pt_BR: TranslationMap = {
newCardHelp: "Enfileire trabalho para uma sessão de agente.",
archiveCard: "Arquivar cartão",
deleteCard: "Excluir cartão",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "Abrir sessão",
openLinkedSession: "Abrir sessão vinculada",
defaultAgent: "Agente padrão",

View File

@@ -506,6 +506,26 @@ export const th: TranslationMap = {
newCardHelp: "จัดคิวงานสำหรับเซสชันของเอเจนต์",
archiveCard: "เก็บถาวรการ์ด",
deleteCard: "ลบการ์ด",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "เปิดเซสชัน",
openLinkedSession: "เปิดเซสชันที่ลิงก์ไว้",
defaultAgent: "เอเจนต์เริ่มต้น",

View File

@@ -511,6 +511,26 @@ export const tr: TranslationMap = {
newCardHelp: "Bir ajan oturumu için işi kuyruğa alın.",
archiveCard: "Kartı arşivle",
deleteCard: "Kartı sil",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "Oturumu aç",
openLinkedSession: "Bağlantılı oturumu aç",
defaultAgent: "Varsayılan ajan",

View File

@@ -510,6 +510,26 @@ export const uk: TranslationMap = {
newCardHelp: "Поставте роботу в чергу для сесії агента.",
archiveCard: "Архівувати картку",
deleteCard: "Видалити картку",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "Відкрити сесію",
openLinkedSession: "Відкрити пов’язану сесію",
defaultAgent: "Агент за замовчуванням",

View File

@@ -509,6 +509,26 @@ export const vi: TranslationMap = {
newCardHelp: "Đưa công việc vào hàng đợi cho một phiên agent.",
archiveCard: "Lưu trữ thẻ",
deleteCard: "Xóa thẻ",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "Mở phiên",
openLinkedSession: "Mở phiên được liên kết",
defaultAgent: "Agent mặc định",

View File

@@ -505,6 +505,26 @@ export const zh_CN: TranslationMap = {
newCardHelp: "为代理会话排队工作。",
archiveCard: "归档卡片",
deleteCard: "删除卡片",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "打开会话",
openLinkedSession: "打开关联会话",
defaultAgent: "默认代理",

View File

@@ -505,6 +505,26 @@ export const zh_TW: TranslationMap = {
newCardHelp: "為代理程式工作階段排入工作。",
archiveCard: "封存卡片",
deleteCard: "刪除卡片",
viewDetails: "View details",
detailTitle: "Card details",
detailTask: "Gateway task",
detailRun: "Run",
detailUpdated: "Updated",
detailProof: "Proof",
detailDiagnostics: "Diagnostics",
detailWorkerLogs: "Worker logs",
detailWorkerProtocol: "Worker protocol",
detailAutomation: "Automation",
detailUpdatedValue: "Updated: {time}",
detailAutomationTenant: "Tenant: {tenant}",
detailAutomationBoard: "Board: {board}",
detailAutomationSkills: "Skills: {skills}",
detailAutomationWorkspace: "Workspace: {workspace}",
detailAutomationSummary: "Summary: {summary}",
detailOperatorNotes: "Operator notes",
detailNoNotes: "No operator notes yet.",
detailNotePlaceholder: "Add a decision, blocker, or proof note...",
detailAddNote: "Add note",
openSession: "開啟工作階段",
openLinkedSession: "開啟連結的工作階段",
defaultAgent: "預設代理程式",

View File

@@ -502,6 +502,142 @@
font-size: 0.86rem;
}
.workboard-detail-drawer {
position: fixed;
inset: 0 0 0 auto;
z-index: 900;
display: flex;
justify-content: flex-end;
width: min(460px, 100vw);
pointer-events: none;
}
.workboard-detail {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
min-height: 0;
padding: 16px;
overflow: auto;
pointer-events: auto;
border-left: 1px solid color-mix(in srgb, var(--border) 86%, transparent);
background: color-mix(in srgb, var(--panel) 98%, var(--bg) 2%);
box-shadow: var(--shadow-xl);
}
.workboard-detail__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.workboard-detail__header h2 {
margin: 8px 0 0;
font-size: 1.04rem;
line-height: 1.25;
}
.workboard-detail__section {
display: grid;
gap: 8px;
padding-top: 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
}
.workboard-detail__section:first-of-type {
padding-top: 0;
border-top: 0;
}
.workboard-detail__section h3 {
margin: 0;
color: var(--muted);
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
}
.workboard-detail__section p {
margin: 0;
color: var(--text);
font-size: 0.86rem;
line-height: 1.45;
white-space: pre-wrap;
}
.workboard-detail__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.workboard-detail__row {
min-width: 0;
padding: 8px;
border: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--bg) 74%, transparent);
}
.workboard-detail__row span {
display: block;
color: var(--muted);
font-size: 0.7rem;
font-weight: 650;
text-transform: uppercase;
}
.workboard-detail__row strong {
display: block;
min-width: 0;
margin-top: 4px;
overflow: hidden;
color: var(--text);
font-size: 0.8rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.workboard-detail__list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.workboard-detail__list li {
min-width: 0;
padding: 7px 8px;
border-radius: 7px;
background: color-mix(in srgb, var(--bg) 68%, transparent);
color: var(--text);
font-size: 0.82rem;
line-height: 1.35;
}
.workboard textarea.input.workboard-detail__note {
width: 100%;
min-height: 84px;
padding: 9px 10px;
resize: vertical;
}
.workboard-detail__actions {
display: grid;
gap: 8px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
}
.workboard-detail__actions > .btn {
justify-content: center;
}
@media (max-width: 860px) {
.workboard-toolbar {
display: flex;
@@ -530,4 +666,19 @@
.workboard-board {
grid-auto-columns: minmax(260px, 82vw);
}
.workboard-detail-drawer {
inset: auto 0 0;
width: 100vw;
max-height: 82vh;
}
.workboard-detail {
border-top: 1px solid color-mix(in srgb, var(--border) 86%, transparent);
border-left: 0;
}
.workboard-detail__grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewaySessionRow } from "../types.ts";
import {
addWorkboardCardComment,
archiveWorkboardCard,
captureSessionToWorkboard,
createWorkboardCard,
@@ -323,6 +324,36 @@ describe("workboard controller", () => {
expect(state.editingCardId).toBeNull();
});
it("adds operator notes to a selected detail card without opening the edit draft", async () => {
const host = {};
const state = getWorkboardState(host);
state.cards = [sampleCard];
state.detailCardId = sampleCard.id;
state.detailCommentBody = "Need one more proof run.";
const updated = {
...sampleCard,
metadata: {
comments: [{ id: "comment-1", body: "Need one more proof run.", createdAt: 2 }],
},
} satisfies WorkboardCard;
const client = createClient({ "workboard.cards.comment": { card: updated } });
await addWorkboardCardComment({
host,
client: client as never,
cardId: sampleCard.id,
body: state.detailCommentBody,
});
expect(client.request).toHaveBeenCalledWith("workboard.cards.comment", {
id: "card-1",
body: "Need one more proof run.",
});
expect(state.cards[0]?.metadata?.comments?.[0]?.body).toBe("Need one more proof run.");
expect(state.detailCommentBody).toBe("");
expect(state.draftOpen).toBe(false);
});
it("captures existing sessions as linked workboard cards", async () => {
const host = {};
const session = {

View File

@@ -347,6 +347,8 @@ export type WorkboardUiState = {
draftSessionKey: string;
draftTemplateId: WorkboardTemplateId | "";
draftCommentBody: string;
detailCardId: string | null;
detailCommentBody: string;
busyCardId: string | null;
draggedCardId: string | null;
syncingCardIds: Set<string>;
@@ -389,6 +391,8 @@ function createDefaultState(): WorkboardUiState {
draftSessionKey: "",
draftTemplateId: "",
draftCommentBody: "",
detailCardId: null,
detailCommentBody: "",
busyCardId: null,
draggedCardId: null,
syncingCardIds: new Set(),
@@ -1680,27 +1684,34 @@ export async function saveWorkboardCardDraft(params: {
export async function addWorkboardCardComment(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
cardId?: string;
body?: string;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
const body = state.draftCommentBody.trim();
if (!state.editingCardId || !params.client || !body) {
const cardId = params.cardId ?? state.editingCardId;
const body = (params.body ?? state.draftCommentBody).trim();
if (!cardId || !params.client || !body) {
return;
}
state.loading = true;
state.busyCardId = cardId;
state.error = null;
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.comment", {
id: state.editingCardId,
id: cardId,
body,
});
replaceCard(state, normalizeCardPayload(payload));
state.draftCommentBody = "";
if (params.body === undefined) {
state.draftCommentBody = "";
} else if (state.detailCardId === cardId) {
state.detailCommentBody = "";
}
} catch (error) {
state.error = formatError(error);
} finally {
state.loading = false;
state.busyCardId = null;
params.requestUpdate?.();
}
}

View File

@@ -4,6 +4,8 @@ import { getWorkboardState } from "../controllers/workboard.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import { renderWorkboard } from "./workboard.ts";
type WorkboardRenderProps = Parameters<typeof renderWorkboard>[0];
describe("renderWorkboard", () => {
it("renders board columns and preloaded cards", () => {
const now = Date.now();
@@ -93,7 +95,7 @@ describe("renderWorkboard", () => {
expect(container.textContent).not.toContain("Invalid Date");
});
it("opens linked cards from the card surface without hijacking action buttons", () => {
it("opens card details from the card surface without hijacking action buttons", () => {
const host = {};
const state = getWorkboardState(host);
const onOpenSession = vi.fn();
@@ -137,7 +139,31 @@ describe("renderWorkboard", () => {
const card = container.querySelector<HTMLElement>(".workboard-card");
card?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onOpenSession).toHaveBeenCalledWith("agent:main:dashboard:1");
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [
{
key: "agent:main:dashboard:1",
kind: "direct",
displayName: "Dashboard session",
updatedAt: 2,
hasActiveRun: true,
status: "running",
},
],
onOpenSession,
}),
container,
);
expect(container.querySelector(".workboard-detail")?.textContent).toContain(
"Inspect a running task",
);
expect(onOpenSession).not.toHaveBeenCalled();
onOpenSession.mockClear();
container
@@ -146,7 +172,7 @@ describe("renderWorkboard", () => {
expect(onOpenSession).not.toHaveBeenCalled();
});
it("shows Codex and Claude execution actions for unlinked cards", () => {
it("keeps cards compact and puts model-specific execution actions in details", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
@@ -180,21 +206,36 @@ describe("renderWorkboard", () => {
const startButtons = [
...container.querySelectorAll<HTMLButtonElement>(".workboard-card__start"),
];
expect(startButtons.map((button) => button.textContent?.trim())).toEqual([
expect(startButtons.map((button) => button.textContent?.trim())).toEqual(["Start"]);
expect(startButtons.map((button) => button.title)).toEqual(["Run default agent"]);
expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBe("button");
container
.querySelector<HTMLButtonElement>('button[title="View details"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
const detailStartButtons = [
...container.querySelectorAll<HTMLButtonElement>(".workboard-detail .workboard-card__start"),
];
expect(detailStartButtons.map((button) => button.textContent?.trim())).toEqual([
"Start",
"codex",
"claude",
"codex",
"claude",
]);
expect(startButtons.map((button) => button.title)).toEqual([
"Run default agent",
"Run codex",
"Run claude",
"Open codex",
"Open claude",
]);
expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBeNull();
});
it("hides autonomous model override actions for non-admin operators", () => {
@@ -232,16 +273,33 @@ describe("renderWorkboard", () => {
const startButtons = [
...container.querySelectorAll<HTMLButtonElement>(".workboard-card__start"),
];
expect(startButtons.map((button) => button.textContent?.trim())).toEqual([
expect(startButtons.map((button) => button.textContent?.trim())).toEqual(["Start"]);
expect(startButtons.map((button) => button.title)).toEqual(["Run default agent"]);
container
.querySelector<HTMLButtonElement>('button[title="View details"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(
renderWorkboard({
host,
client: null,
connected: true,
canModelOverride: false,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
const detailStartButtons = [
...container.querySelectorAll<HTMLButtonElement>(".workboard-detail .workboard-card__start"),
];
expect(detailStartButtons.map((button) => button.textContent?.trim())).toEqual([
"Start",
"codex",
"claude",
]);
expect(startButtons.map((button) => button.title)).toEqual([
"Run default agent",
"Open codex",
"Open claude",
]);
});
it("renders linked Gateway task status on cards", () => {
@@ -321,33 +379,42 @@ describe("renderWorkboard", () => {
progressSummary: "Still running according to stale cache.",
});
const container = document.createElement("div");
const props = {
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [
{
key: "agent:main:subagent:workboard-default-card-1",
kind: "direct",
displayName: "Finished session",
updatedAt: 2,
hasActiveRun: false,
status: "done",
},
],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
} satisfies WorkboardRenderProps;
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [
{
key: "agent:main:subagent:workboard-default-card-1",
kind: "direct",
displayName: "Finished session",
updatedAt: 2,
hasActiveRun: false,
status: "done",
},
],
onOpenSession: () => undefined,
}),
container,
);
render(renderWorkboard(props), container);
expect(container.textContent).toContain("Done");
expect(container.textContent).toContain("Finished session");
expect(container.textContent).not.toContain("Task running");
expect(container.textContent).not.toContain("Still running according to stale cache.");
container
.querySelector<HTMLButtonElement>('button[title="View details"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(renderWorkboard(props), container);
expect(container.querySelector(".workboard-detail")?.textContent).toContain("Finished session");
expect(container.querySelector(".workboard-detail")?.textContent).not.toContain(
"Still running according to stale cache.",
);
});
it("shows stop controls without start controls for active task-only cards", () => {
@@ -375,24 +442,33 @@ describe("renderWorkboard", () => {
progressSummary: "Worker is active.",
});
const container = document.createElement("div");
const props = {
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
};
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
render(renderWorkboard(props), container);
expect(container.textContent).toContain("Task running");
expect(container.querySelector('button[title="Stop session"]')).not.toBeNull();
expect(container.querySelectorAll<HTMLButtonElement>(".workboard-card__start")).toHaveLength(0);
expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBeNull();
expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBe("button");
container
.querySelector<HTMLButtonElement>('button[title="View details"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(renderWorkboard(props), container);
expect(container.querySelector(".workboard-detail")?.textContent).toContain(
"Worker is active.",
);
expect(container.querySelectorAll<HTMLButtonElement>(".workboard-card__start")).toHaveLength(0);
});
it("hides write controls for read-only operators", () => {
@@ -434,6 +510,7 @@ describe("renderWorkboard", () => {
container.querySelector<HTMLButtonElement>(".workboard-toolbar__actions .btn.primary"),
).toBeNull();
expect(container.querySelector(".workboard-card")?.getAttribute("draggable")).toBe("false");
expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBe("button");
});
it("offers start controls when a linked session no longer exists", () => {
@@ -469,7 +546,7 @@ describe("renderWorkboard", () => {
);
expect(container.textContent).toContain("Session missing");
expect(container.querySelectorAll<HTMLButtonElement>(".workboard-card__start")).toHaveLength(5);
expect(container.querySelectorAll<HTMLButtonElement>(".workboard-card__start")).toHaveLength(1);
});
it("opens a modal for new cards", () => {
@@ -560,27 +637,41 @@ describe("renderWorkboard", () => {
createdAt: 1,
updatedAt: 2,
events: [
{ id: "event-1", kind: "created", at: 1, toStatus: "todo" },
{ id: "event-2", kind: "moved", at: 2, fromStatus: "todo", toStatus: "review" },
{ id: "event-1", kind: "moved", at: 1, fromStatus: "triage", toStatus: "backlog" },
{ id: "event-2", kind: "moved", at: 2, fromStatus: "backlog", toStatus: "todo" },
{ id: "event-3", kind: "moved", at: 3, fromStatus: "todo", toStatus: "scheduled" },
{ id: "event-4", kind: "moved", at: 4, fromStatus: "scheduled", toStatus: "ready" },
{ id: "event-5", kind: "moved", at: 5, fromStatus: "ready", toStatus: "running" },
{ id: "event-6", kind: "moved", at: 6, fromStatus: "running", toStatus: "review" },
{ id: "event-7", kind: "moved", at: 7, fromStatus: "review", toStatus: "done" },
],
},
];
const container = document.createElement("div");
const props = {
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
};
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
render(renderWorkboard(props), container);
expect(container.querySelector(".workboard-events")?.textContent).toContain("Moved to Review");
container
.querySelector<HTMLButtonElement>('button[title="View details"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(renderWorkboard(props), container);
expect(container.querySelector(".workboard-detail")?.textContent).toContain("Moved to Done");
expect(container.querySelector(".workboard-detail")?.textContent).not.toContain(
"Moved to Backlog",
);
});
it("renders card metadata badges and hides archived cards", () => {
@@ -603,7 +694,26 @@ describe("renderWorkboard", () => {
failureCount: 1,
comments: [{ id: "comment-1", body: "Needs owner check", createdAt: 3 }],
links: [{ id: "link-1", type: "relates_to", url: "https://example.com", createdAt: 4 }],
proof: [{ id: "proof-1", status: "passed", command: "pnpm test", createdAt: 5 }],
workerProtocol: {
state: "blocked",
detail: "Worker asked for owner input.",
updatedAt: 12,
},
automation: {
tenant: "ops",
boardId: "quality",
skills: ["review", "test"],
workspace: { kind: "worktree", path: "/tmp/workboard", branch: "proof" },
dispatchCount: 3,
summary: "Ready for review.",
},
proof: Array.from({ length: 7 }, (_, index) => ({
id: `proof-${index + 1}`,
status: "passed",
command: `pnpm test ${index + 1}`,
url: `https://example.com/proof-${index + 1}`,
createdAt: 5 + index,
})),
stale: { detectedAt: 6, reason: "No recent activity." },
},
},
@@ -635,13 +745,83 @@ describe("renderWorkboard", () => {
);
expect(container.textContent).toContain("Plugin");
expect(container.textContent).toContain("1 attempts");
expect(container.textContent).toContain("1 failed");
expect(container.textContent).toContain("1 comments");
expect(container.textContent).toContain("1 links");
expect(container.textContent).toContain("1 proof");
expect(container.textContent).toContain("7 proof");
expect(container.textContent).toContain("stale");
expect(container.textContent).not.toContain("Archived task");
container
.querySelector<HTMLButtonElement>('button[title="View details"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
expect(container.querySelector(".workboard-detail")?.textContent).toContain("1 attempts");
expect(container.querySelector(".workboard-detail")?.textContent).toContain("1 links");
expect(container.querySelector(".workboard-detail")?.textContent).not.toContain("pnpm test 1");
expect(container.querySelector(".workboard-detail")?.textContent).toContain("pnpm test 7");
expect(container.querySelector(".workboard-detail")?.textContent).toContain(
"https://example.com/proof-7",
);
expect(container.querySelector(".workboard-detail")?.textContent).toContain("Worker protocol");
expect(container.querySelector(".workboard-detail")?.textContent).toContain(
"Worker asked for owner input.",
);
expect(container.querySelector(".workboard-detail")?.textContent).toContain("Automation");
expect(container.querySelector(".workboard-detail")?.textContent).toContain("Tenant: ops");
expect(container.querySelector(".workboard-detail")?.textContent).toContain(
"Skills: review, test",
);
expect(container.querySelector(".workboard-detail")?.textContent).toContain(
"Workspace: worktree /tmp/workboard proof",
);
});
it("does not render details for archived selected cards", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.detailCardId = "card-1";
state.cards = [
{
id: "card-1",
title: "Archived selected task",
status: "todo",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
metadata: { archivedAt: 2 },
},
];
const container = document.createElement("div");
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
expect(container.querySelector(".workboard-detail")).toBeNull();
expect(container.querySelectorAll<HTMLButtonElement>(".workboard-card__start")).toHaveLength(0);
});
it("shows stale lifecycle on executed linked cards", () => {
@@ -854,6 +1034,107 @@ describe("renderWorkboard", () => {
});
});
it("locks edit-modal actions while a comment request is in flight", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.draftOpen = true;
state.editingCardId = "card-1";
state.draftTitle = "Rename me";
state.draftCommentBody = "Ship after CI";
state.busyCardId = "card-1";
state.cards = [
{
id: "card-1",
title: "Rename me",
status: "todo",
priority: "normal",
labels: [],
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,
);
const buttons = [...container.querySelectorAll<HTMLButtonElement>("button")];
expect(buttons.find((button) => button.textContent?.includes("Create"))?.disabled).toBe(true);
expect(buttons.find((button) => button.textContent?.includes("Save"))?.disabled).toBe(true);
});
it("adds operator notes from the details drawer", async () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Investigate proof gap",
status: "review",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
},
];
const request = vi.fn(async () => ({
card: {
...state.cards[0],
metadata: {
comments: [{ id: "comment-1", body: "Need Linux proof.", createdAt: 2 }],
},
},
}));
const props = {
host,
client: { request } as unknown as GatewayBrowserClient,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
};
const container = document.createElement("div");
render(renderWorkboard(props), container);
container
.querySelector<HTMLButtonElement>('button[title="View details"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(renderWorkboard(props), container);
const note = container.querySelector<HTMLTextAreaElement>(".workboard-detail__note");
note!.value = "Need Linux proof.";
note!.dispatchEvent(new InputEvent("input", { bubbles: true }));
render(renderWorkboard(props), container);
[...container.querySelectorAll<HTMLButtonElement>(".workboard-detail button")]
.find((button) => button.textContent?.includes("Add note"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
expect(request).toHaveBeenCalledWith("workboard.cards.comment", {
id: "card-1",
body: "Need Linux proof.",
});
expect(state.detailCommentBody).toBe("");
expect(state.cards[0]?.metadata?.comments?.[0]?.body).toBe("Need Linux proof.");
});
it("archives cards from the card action", async () => {
const host = {};
const state = getWorkboardState(host);

View File

@@ -185,47 +185,20 @@ function renderEvents(card: WorkboardCard) {
`;
}
function renderMetadataBadges(card: WorkboardCard) {
function renderCompactBadges(card: WorkboardCard, task?: WorkboardTaskSummary) {
const metadata = card.metadata;
const badges = [
metadata?.templateId ? t(`workboard.template.${metadata.templateId}`) : null,
card.taskId ? t("workboard.badgeTaskLinked") : null,
metadata?.attempts?.length
? t("workboard.badgeAttempts", { count: String(metadata.attempts.length) })
: null,
(task ?? card.taskId) ? t("workboard.badgeTaskLinked") : null,
metadata?.failureCount
? t("workboard.badgeFailures", { count: String(metadata.failureCount) })
: null,
metadata?.comments?.length
? t("workboard.badgeComments", { count: String(metadata.comments.length) })
: null,
metadata?.links?.length
? t("workboard.badgeLinks", { count: String(metadata.links.length) })
: null,
metadata?.proof?.length
? t("workboard.badgeProof", { count: String(metadata.proof.length) })
: null,
metadata?.artifacts?.length
? t("workboard.badgeArtifacts", { count: String(metadata.artifacts.length) })
: null,
metadata?.attachments?.length
? t("workboard.badgeAttachments", { count: String(metadata.attachments.length) })
: null,
metadata?.workerLogs?.length
? t("workboard.badgeWorkerLogs", { count: String(metadata.workerLogs.length) })
: null,
metadata?.workerProtocol?.state
? t("workboard.badgeWorkerProtocol", { state: metadata.workerProtocol.state })
: null,
metadata?.automation?.tenant
? t("workboard.badgeTenant", { tenant: metadata.automation.tenant })
: null,
metadata?.automation?.skills?.length
? t("workboard.badgeSkills", { count: String(metadata.automation.skills.length) })
: null,
metadata?.automation?.dispatchCount
? t("workboard.badgeDispatches", { count: String(metadata.automation.dispatchCount) })
: null,
metadata?.claim ? t("workboard.badgeClaimed", { owner: metadata.claim.ownerId }) : null,
metadata?.diagnostics?.length
? t("workboard.badgeDiagnostics", { count: String(metadata.diagnostics.length) })
@@ -326,16 +299,9 @@ function isCardActionTarget(event: Event): boolean {
: false;
}
function openCardSession(
props: Pick<WorkboardProps, "onOpenSession">,
card: WorkboardCard,
): boolean {
const sessionKey = card.sessionKey ?? card.execution?.sessionKey;
if (!sessionKey) {
return false;
}
props.onOpenSession(sessionKey);
return true;
function openCardDetails(state: WorkboardUiState, card: WorkboardCard) {
state.detailCardId = card.id;
state.detailCommentBody = "";
}
function resetDraft(state: WorkboardUiState) {
@@ -395,6 +361,7 @@ function renderCardModal(props: WorkboardProps) {
? (state.cards.find((card) => card.id === state.editingCardId) ?? null)
: null;
const comments = editingCard?.metadata?.comments ?? [];
const draftCommentBusy = editing && state.busyCardId === state.editingCardId;
return html`
<div
class="workboard-modal"
@@ -413,6 +380,9 @@ function renderCardModal(props: WorkboardProps) {
aria-labelledby="workboard-card-modal-title"
@submit=${(event: SubmitEvent) => {
event.preventDefault();
if (state.loading || draftCommentBusy) {
return;
}
void saveWorkboardCardDraft({
host: props.host,
client: props.client,
@@ -601,7 +571,7 @@ function renderCardModal(props: WorkboardProps) {
<button
class="btn"
type="button"
?disabled=${state.loading || !state.draftCommentBody.trim()}
?disabled=${state.loading || draftCommentBusy || !state.draftCommentBody.trim()}
@click=${() => {
void addWorkboardCardComment({
host: props.host,
@@ -617,7 +587,10 @@ function renderCardModal(props: WorkboardProps) {
`
: nothing}
<div class="workboard-modal__actions">
<button class="btn primary" ?disabled=${state.loading || !state.draftTitle.trim()}>
<button
class="btn primary"
?disabled=${state.loading || draftCommentBusy || !state.draftTitle.trim()}
>
${editing ? t("common.save") : t("common.create")}
</button>
<button
@@ -714,6 +687,18 @@ function taskIsActive(task: WorkboardTaskSummary | undefined): boolean {
return task?.status === "queued" || task?.status === "running";
}
function cardCanStart(
state: WorkboardUiState,
sessions: readonly GatewaySessionRow[],
card: WorkboardCard,
): boolean {
const task = state.tasksByCardId.get(card.id);
const session = findWorkboardSession(card, sessions);
const activeTask = taskIsActive(task);
const linkedSessionKey = card.sessionKey ?? card.execution?.sessionKey;
return !activeTask && (!linkedSessionKey || !session);
}
function renderLifecycle(
card: WorkboardCard,
sessions: readonly GatewaySessionRow[],
@@ -797,6 +782,272 @@ function renderStartExecutionControls(props: WorkboardProps, card: WorkboardCard
`;
}
function renderDetailRow(label: string, value: unknown) {
if (typeof value !== "string" && typeof value !== "number") {
return nothing;
}
const text = String(value).trim();
if (!text) {
return nothing;
}
return html`
<div class="workboard-detail__row">
<span>${label}</span>
<strong>${text}</strong>
</div>
`;
}
function renderDetailList(
title: string,
values: readonly string[],
empty: string | typeof nothing = nothing,
) {
const entries = values
.map((value) => value.trim())
.filter(Boolean)
.slice(-6);
if (entries.length === 0) {
return empty;
}
return html`
<section class="workboard-detail__section">
<h3>${title}</h3>
<ol class="workboard-detail__list">
${entries.map((entry) => html`<li>${entry}</li>`)}
</ol>
</section>
`;
}
function renderCardDetailsPanel(props: WorkboardProps) {
const state = getWorkboardState(props.host);
const card = state.detailCardId
? (state.cards.find((entry) => entry.id === state.detailCardId) ?? null)
: null;
if (!card || card.metadata?.archivedAt) {
return nothing;
}
const task = state.tasksByCardId.get(card.id);
const lifecycle = getWorkboardLifecycle(card, props.sessions, task);
const formatted = formatLifecycle(lifecycle);
const taskIsAuthoritative = task ? taskMatchesLifecycle(task, lifecycle) : false;
const linkedSessionKey = card.sessionKey ?? card.execution?.sessionKey;
const writable = canMutate(props);
const comments = card.metadata?.comments ?? [];
const attempts = card.metadata?.attempts ?? [];
const links = card.metadata?.links ?? [];
const proof = card.metadata?.proof ?? [];
const artifacts = card.metadata?.artifacts ?? [];
const attachments = card.metadata?.attachments ?? [];
const diagnostics = card.metadata?.diagnostics ?? [];
const workerLogs = card.metadata?.workerLogs ?? [];
const workerProtocol = card.metadata?.workerProtocol;
const automation = card.metadata?.automation;
const events = (card.events ?? []).slice(-6).toReversed();
const busy = state.busyCardId === card.id;
const showStartControls = writable && cardCanStart(state, props.sessions, card);
return html`
<aside class="workboard-detail-drawer" aria-label=${t("workboard.detailTitle")}>
<div class="workboard-detail">
<header class="workboard-detail__header">
<div>
<span class="workboard-card__priority">${card.priority}</span>
<h2>${card.title}</h2>
</div>
<button
class="btn btn--icon workboard-card__icon"
type="button"
title=${t("common.cancel")}
@click=${() => {
state.detailCardId = null;
state.detailCommentBody = "";
props.onRequestUpdate?.();
}}
>
${icons.x}
</button>
</header>
<section class="workboard-detail__section">
<div class="workboard-card__lifecycle">
<span class="workboard-lifecycle workboard-lifecycle--${formatted.tone}">
${formatted.label}
</span>
<span class="workboard-card__lifecycle-detail">
${task && taskIsAuthoritative
? taskDetail(task)
: (lifecycle.session?.displayName ?? formatted.detail)}
</span>
</div>
<div class="workboard-detail__grid">
${renderDetailRow(t("workboard.fieldStatus"), formatStatusLabel(card.status))}
${renderDetailRow(
t("workboard.fieldAgent"),
card.agentId ?? t("workboard.defaultAgent"),
)}
${renderDetailRow(t("workboard.detailTask"), task?.taskId ?? card.taskId)}
${renderDetailRow(t("workboard.fieldSession"), linkedSessionKey)}
${renderDetailRow(t("workboard.detailRun"), card.runId ?? card.execution?.runId)}
${renderDetailRow(t("workboard.detailUpdated"), formatTime(card.updatedAt))}
</div>
</section>
${card.notes
? html`
<section class="workboard-detail__section">
<h3>${t("workboard.fieldNotes")}</h3>
<p>${card.notes}</p>
</section>
`
: nothing}
${renderDetailList(t("workboard.fieldLabels"), card.labels)}
${renderDetailList(
t("workboard.badgeAttempts", { count: String(attempts.length) }),
attempts.map((entry) =>
[entry.status, entry.model, entry.sessionKey, entry.error].filter(Boolean).join(" - "),
),
)}
${renderDetailList(
t("workboard.badgeLinks", { count: String(links.length) }),
links.map((entry) =>
[entry.type, entry.title, entry.targetCardId, entry.url].filter(Boolean).join(" - "),
),
)}
${renderDetailList(
t("workboard.detailProof"),
proof.map((entry) =>
[entry.status, entry.label, entry.command, entry.url, entry.note]
.filter(Boolean)
.join(" - "),
),
)}
${renderDetailList(
t("workboard.badgeArtifacts", { count: String(artifacts.length) }),
artifacts.map((entry) =>
[entry.label, entry.url, entry.path, entry.mimeType].filter(Boolean).join(" - "),
),
)}
${renderDetailList(
t("workboard.badgeAttachments", { count: String(attachments.length) }),
attachments.map((entry) =>
[entry.fileName, entry.mimeType, entry.note].filter(Boolean).join(" - "),
),
)}
${renderDetailList(
t("workboard.detailDiagnostics"),
diagnostics.map((entry) => `${entry.severity}: ${entry.title}`),
)}
${renderDetailList(
t("workboard.detailWorkerLogs"),
workerLogs.map((entry) => `${entry.level}: ${entry.message}`),
)}
${workerProtocol
? renderDetailList(t("workboard.detailWorkerProtocol"), [
workerProtocol.state,
workerProtocol.detail ?? "",
workerProtocol.updatedAt
? t("workboard.detailUpdatedValue", { time: formatTime(workerProtocol.updatedAt) })
: "",
])
: nothing}
${automation
? renderDetailList(t("workboard.detailAutomation"), [
automation.tenant
? t("workboard.detailAutomationTenant", { tenant: automation.tenant })
: "",
automation.boardId
? t("workboard.detailAutomationBoard", { board: automation.boardId })
: "",
automation.skills?.length
? t("workboard.detailAutomationSkills", { skills: automation.skills.join(", ") })
: "",
automation.workspace
? t("workboard.detailAutomationWorkspace", {
workspace: [
automation.workspace.kind,
automation.workspace.path,
automation.workspace.branch,
]
.filter(Boolean)
.join(" "),
})
: "",
automation.dispatchCount
? t("workboard.badgeDispatches", { count: String(automation.dispatchCount) })
: "",
automation.lastDispatchAt
? t("workboard.detailUpdatedValue", { time: formatTime(automation.lastDispatchAt) })
: "",
automation.summary
? t("workboard.detailAutomationSummary", { summary: automation.summary })
: "",
])
: nothing}
${renderDetailList(
t("workboard.eventsLabel"),
events.map((event) => `${formatEventLabel(event)} ${formatTime(event.at)}`),
)}
<section class="workboard-detail__section">
<h3>${t("workboard.detailOperatorNotes")}</h3>
${comments.length
? html`
<ol class="workboard-detail__list">
${comments.slice(-6).map((comment) => html`<li>${comment.body}</li>`)}
</ol>
`
: html`<p>${t("workboard.detailNoNotes")}</p>`}
${writable
? html`
<textarea
class="input workboard-detail__note"
maxlength="2000"
placeholder=${t("workboard.detailNotePlaceholder")}
.value=${state.detailCommentBody}
@input=${(event: InputEvent) => {
state.detailCommentBody = (event.currentTarget as HTMLTextAreaElement).value;
props.onRequestUpdate?.();
}}
></textarea>
<button
class="btn"
type="button"
?disabled=${busy || !state.detailCommentBody.trim()}
@click=${() =>
addWorkboardCardComment({
host: props.host,
client: props.client,
cardId: card.id,
body: state.detailCommentBody,
requestUpdate: props.onRequestUpdate,
})}
>
${icons.plus} ${t("workboard.detailAddNote")}
</button>
`
: nothing}
</section>
<div class="workboard-detail__actions">
${linkedSessionKey
? html`
<button
class="btn"
type="button"
@click=${() => props.onOpenSession(linkedSessionKey)}
>
${icons.messageSquare} ${t("workboard.openSession")}
</button>
`
: nothing}
${showStartControls ? renderStartExecutionControls(props, card) : nothing}
</div>
</div>
</aside>
`;
}
function renderDispatchSummary(state: WorkboardUiState) {
const summary = state.lastDispatchSummary;
if (!summary) {
@@ -836,30 +1087,30 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
session?.hasActiveRun === true ||
(session?.hasActiveRun !== false && session?.status === "running");
const linkedSessionKey = card.sessionKey ?? card.execution?.sessionKey;
const openable = Boolean(linkedSessionKey);
const writable = canMutate(props);
const showStartControls = writable && !activeTask && (!linkedSessionKey || !session);
const showStartControls = writable && cardCanStart(state, props.sessions, card);
return html`
<article
class="workboard-card priority-${card.priority} ${busy
? "workboard-card--busy"
: ""} ${openable ? "workboard-card--openable" : ""}"
role=${openable ? "button" : nothing}
tabindex=${openable ? 0 : nothing}
title=${openable ? t("workboard.openLinkedSession") : nothing}
: ""} workboard-card--openable"
role="button"
tabindex="0"
title=${t("workboard.viewDetails")}
draggable=${writable ? "true" : "false"}
@click=${(event: MouseEvent) => {
if (!isCardActionTarget(event)) {
openCardSession(props, card);
openCardDetails(state, card);
props.onRequestUpdate?.();
}
}}
@keydown=${(event: KeyboardEvent) => {
if (isCardActionTarget(event) || (event.key !== "Enter" && event.key !== " ")) {
return;
}
if (openCardSession(props, card)) {
event.preventDefault();
}
openCardDetails(state, card);
props.onRequestUpdate?.();
event.preventDefault();
}}
@dragstart=${(event: DragEvent) => {
if (!writable) {
@@ -889,7 +1140,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
${card.labels.map((label) => html`<span>${label}</span>`)}
</div>`
: nothing}
${renderMetadataBadges(card)}
${renderCompactBadges(card, task)}
<div class="workboard-card__meta">
${card.agentId
? html`<span>${card.agentId}</span>`
@@ -898,6 +1149,16 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
</div>
${renderEvents(card)}
<div class="workboard-card__actions">
<button
class="btn btn--icon workboard-card__icon"
title=${t("workboard.viewDetails")}
@click=${() => {
openCardDetails(state, card);
props.onRequestUpdate?.();
}}
>
${icons.panelRightOpen}
</button>
${writable
? html`
<button
@@ -959,7 +1220,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
</button>
`
: nothing}
${showStartControls ? renderStartExecutionControls(props, card) : nothing}
${showStartControls ? renderStartExecutionButton(props, card, null, "autonomous") : nothing}
${writable
? html`
<button
@@ -1155,7 +1416,7 @@ export function renderWorkboard(props: WorkboardProps) {
</div>
</div>
${state.error ? html`<div class="callout danger">${state.error}</div>` : nothing}
${renderDispatchSummary(state)} ${renderCardModal(props)}
${renderDispatchSummary(state)} ${renderCardModal(props)} ${renderCardDetailsPanel(props)}
<div class="workboard-board">
${state.statuses.map((status) => renderColumn(props, status, byStatus.get(status) ?? []))}
</div>