fix(workboard): wire task-backed board runs

Summary:
- remove the leftover Workboard mini-game/prototype surface
- wire autonomous Workboard card starts through Gateway task-backed agent runs
- reconcile card task/session lifecycle for starts, stops, stale tasks, reassignment, and default-agent sessions
- clarify dispatch summary copy and admin-only model override behavior

Verification:
- autoreview clean: no accepted/actionable findings
- targeted Workboard/UI Vitest: 72 tests passed
- Workboard extension Vitest: 9 tests passed
- UI build, docs list, docs format, diff check, and focused oxlint passed
- PR CI checks: 50 ok, 0 attention
- Testbox tbx_01kt07mk5sjyj2whjq2sc967hg: pnpm verify check phase passed; broad test phase exposed unrelated latest-main failures/stalls in memory, Codex app-server, provider timeout, command daemon env, Telegram worker OOM, and gateway-client timeout suites
This commit is contained in:
Vincent Koc
2026-06-01 01:41:21 +01:00
committed by GitHub
parent 015c6b40ae
commit 82d24b26ea
50 changed files with 1662 additions and 1337 deletions

View File

@@ -99,7 +99,10 @@ openclaw workboard dispatch --url http://127.0.0.1:18789 --token "$OPENCLAW_GATE
`dispatch` first calls the running Gateway RPC method
`workboard.cards.dispatch`. That path uses the same subagent runtime as the
dashboard dispatch action, so ready cards can become real worker sessions.
dashboard dispatch action, so ready cards become task-tracked worker runs with
linked session keys. Cards with an assigned agent use agent-scoped subagent
session keys; unassigned cards keep an unscoped subagent key so the Gateway's
configured default agent is preserved.
The dispatch loop:
@@ -110,8 +113,8 @@ The dispatch loop:
5. Claims each selected card for the dispatcher or assigned agent.
6. Starts a subagent worker run with bounded card context and the card claim
token.
7. Stores the worker run id, session key, execution status, and worker log on
the card.
7. Stores the worker run id, session key, task linkage when the Gateway task
ledger reports it, execution status, and worker log on the card.
Selection is intentionally conservative. One dispatch starts at most three
workers by default, skips archived or already-claimed cards, and starts only one
@@ -146,6 +149,10 @@ JSON output includes the dispatch result. Gateway-backed dispatch can include
`started` and `startFailures`; data-only fallback includes
`gatewayUnavailable: true`. Claim tokens are redacted from card JSON output.
In the dashboard, the same dispatch result is shown as a short summary so an
operator can see how many cards started, promoted, blocked, reclaimed, or
failed without opening card details.
## Slash Command Parity
Command-capable channels can use the matching slash command:

View File

@@ -9,7 +9,8 @@ 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.
them to agents, and track the linked background task, run, and dashboard
session from one card.
Workboard is intentionally small. It tracks local operating work for an
OpenClaw Gateway; it is not a replacement for GitHub Issues, Linear, Jira, or
@@ -47,8 +48,8 @@ Each card stores:
- priority: `low`, `normal`, `high`, or `urgent`
- labels
- optional agent id
- optional linked session, run, task, or source URL
- optional execution metadata for a Codex or Claude session started from the card
- optional linked task, run, session, or source URL
- optional execution metadata for a Codex or Claude run started from the card
- compact metadata for attempts, comments, links, proof, artifacts, automation,
attachments, worker logs, worker protocol state, claims, diagnostics,
notifications, templates, archive state, and stale-session detection
@@ -65,26 +66,35 @@ proof snippets, related links, comments, archive markers, and stale-session
markers are intentionally local metadata; they do not replace session
transcripts or GitHub issue history.
## Card executions
## Card executions and tasks
Unlinked cards can start work from the card. Start uses the Gateway's configured
Unlinked cards can start work from the card. Autonomous starts use the
Gateway's task-tracked agent run path, then Workboard links the resulting task,
run id, and session key back onto the card. Start uses the Gateway's configured
default agent and model. Codex and Claude actions are optional explicit model
choices:
- Run Codex or Run Claude creates a dashboard session, sends the card prompt,
and marks the card `running`.
- Run Codex or Run Claude starts a task-backed agent run, sends the card
prompt, and marks the card `running`.
- Open Codex or Open Claude creates a linked dashboard session without sending
the card prompt or moving the card, so you can work manually while it stays
attached to the board.
Execution metadata stores the selected engine, mode, model ref, session key,
run id, and lifecycle status on the card. Codex executions use
`openai/gpt-5.5`; Claude executions use `anthropic/claude-sonnet-4-6`.
run id, task id when available, and lifecycle status on the card. Codex
executions use `openai/gpt-5.5`; Claude executions use
`anthropic/claude-sonnet-4-6`.
Each linked execution also records an attempt summary on the same card record.
The attempt summary keeps the engine, mode, model, run id, timestamps, status,
and rolling failure count so repeated failures remain visible on the board.
The dashboard refreshes task status from the Gateway task ledger and matches
tasks back to cards by task id, run id, or linked session key. If a task is
queued or running, the card lifecycle shows active task state. If the task
finishes, fails, times out, or is cancelled, the card lifecycle moves toward
review or blocked status using the same lifecycle sync as linked sessions.
## Agent coordination
Workboard also exposes optional agent tools for board-aware workflows:
@@ -160,13 +170,15 @@ blocked cards that need attention, repeated failures, done cards without proof,
and running cards that only have a loose session link.
Dispatch is intentionally Gateway-local. It does not spawn arbitrary operating
system processes; normal OpenClaw subagent sessions still own execution. A
dispatch nudge promotes dependency-ready cards, records dispatch metadata on
system processes; normal OpenClaw subagent sessions still own execution. The
dispatch action promotes dependency-ready cards, records dispatch metadata on
ready cards, blocks expired claims or timed-out runs, marks board-configured
triage cards as orchestration candidates, then claims a small batch of ready
cards and starts worker runs through the Gateway subagent runtime. Workers get
bounded card context plus the claim token they need to heartbeat, complete, or
block the card through the Workboard tools.
cards and starts worker runs through the Gateway subagent runtime. Assigned
cards use `agent:<id>:subagent:workboard-*` worker session keys; unassigned
cards use unscoped `subagent:workboard-*` keys so the Gateway still resolves the
configured default agent. Workers get bounded card context plus the claim token
they need to heartbeat, complete, or block the card through the Workboard tools.
### Dispatch worker selection

View File

@@ -57,7 +57,7 @@ describe("dispatchAndStartWorkboardCards", () => {
);
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[0]?.[0]).toMatchObject({
sessionKey: `workboard-default-${first.id}`,
sessionKey: `agent:codex-main:subagent:workboard-default-${first.id}`,
lane: `workboard:default:${first.id}`,
deliver: false,
});
@@ -66,7 +66,7 @@ describe("dispatchAndStartWorkboardCards", () => {
expect(run.mock.calls[0]?.[0]?.message).not.toContain("ownerId and token");
await expect(store.get(first.id)).resolves.toMatchObject({
status: "running",
sessionKey: `workboard-default-${first.id}`,
sessionKey: `agent:codex-main:subagent:workboard-default-${first.id}`,
runId: "run-first",
execution: { status: "running", runId: "run-first" },
metadata: {
@@ -95,6 +95,11 @@ describe("dispatchAndStartWorkboardCards", () => {
expect(result.startFailures).toEqual([
expect.objectContaining({ cardId: card.id, error: "model unavailable" }),
]);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: `subagent:workboard-default-${card.id}`,
}),
);
await expect(store.get(card.id)).resolves.toMatchObject({
status: "blocked",
metadata: {

View File

@@ -45,12 +45,24 @@ function cardBoardId(card: WorkboardCard): string {
return card.metadata?.automation?.boardId ?? "default";
}
function sanitizeSessionSegment(value: string | undefined, fallback: string): string {
const sanitized = (value ?? fallback)
.trim()
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return (sanitized || fallback).slice(0, 96);
}
function cardIsArchived(card: WorkboardCard): boolean {
return Boolean(card.metadata?.archivedAt);
}
function buildSessionKey(card: WorkboardCard): string {
return `workboard-${cardBoardId(card)}-${card.id}`.replace(/[^a-zA-Z0-9:_-]/g, "-");
const boardId = sanitizeSessionSegment(cardBoardId(card), "default");
const cardId = sanitizeSessionSegment(card.id, "card");
const suffix = `subagent:workboard-${boardId}-${cardId}`;
return card.agentId ? `agent:${sanitizeSessionSegment(card.agentId, "agent")}:${suffix}` : suffix;
}
function buildExecution(params: {

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:22:03.309Z",
"generatedAt": "2026-05-31T23:28:05.990Z",
"locale": "ar",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:20:35.878Z",
"generatedAt": "2026-05-31T23:28:05.583Z",
"locale": "de",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:21:25.109Z",
"generatedAt": "2026-05-31T23:28:05.664Z",
"locale": "es",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:23:43.267Z",
"generatedAt": "2026-05-31T23:28:06.719Z",
"locale": "fa",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:21:41.367Z",
"generatedAt": "2026-05-31T23:28:05.907Z",
"locale": "fr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:22:46.207Z",
"generatedAt": "2026-05-31T23:28:06.305Z",
"locale": "id",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:22:12.640Z",
"generatedAt": "2026-05-31T23:28:06.068Z",
"locale": "it",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:21:27.902Z",
"generatedAt": "2026-05-31T23:28:05.743Z",
"locale": "ja-JP",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:21:22.340Z",
"generatedAt": "2026-05-31T23:28:05.823Z",
"locale": "ko",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:23:35.548Z",
"generatedAt": "2026-05-31T23:28:06.639Z",
"locale": "nl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:23:00.128Z",
"generatedAt": "2026-05-31T23:28:06.382Z",
"locale": "pl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:20:40.310Z",
"generatedAt": "2026-05-31T23:28:05.498Z",
"locale": "pt-BR",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:23:00.947Z",
"generatedAt": "2026-05-31T23:28:06.469Z",
"locale": "th",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:22:16.120Z",
"generatedAt": "2026-05-31T23:28:06.146Z",
"locale": "tr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:22:27.285Z",
"generatedAt": "2026-05-31T23:28:06.227Z",
"locale": "uk",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:23:11.024Z",
"generatedAt": "2026-05-31T23:28:06.555Z",
"locale": "vi",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:20:35.218Z",
"generatedAt": "2026-05-31T23:28:05.323Z",
"locale": "zh-CN",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T23:20:35.599Z",
"generatedAt": "2026-05-31T23:28:05.410Z",
"locale": "zh-TW",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "006a6b5650974ffa777e01c0fc1750c03024b2a1a397e4cb84ddbf7c2b9932ca",
"totalKeys": 1294,
"translatedKeys": 1294,
"sourceHash": "21d07eb018c81914c212ea2dff7f7174db30e25cf1d70dac9b269cf982d7cd8b",
"totalKeys": 1284,
"translatedKeys": 1284,
"workflow": 1
}

View File

@@ -515,6 +515,9 @@ export const ar: TranslationMap = {
runDefaultAgent: "تشغيل الوكيل الافتراضي",
start: "بدء",
dispatch: "منبّه الموزّع",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "مباشر",
fieldTitle: "العنوان",
fieldNotes: "ملاحظات",
@@ -543,6 +546,7 @@ export const ar: TranslationMap = {
badgeTenant: "المستأجر {tenant}",
badgeSkills: "{count} مهارات",
badgeDispatches: "{count} عمليات إرسال",
badgeTaskLinked: "task linked",
badgeClaimed: "محجوزة بواسطة {owner}",
badgeDiagnostics: "{count} تشخيصات",
badgeStale: "قديم",
@@ -566,6 +570,14 @@ export const ar: TranslationMap = {
lifecycleDoneDetail: "تم النقل إلى المراجعة",
lifecycleNeedsReview: "تحتاج إلى مراجعة",
lifecycleNeedsReviewDetail: "توقف التشغيل أو فشل",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "أحداث البطاقة",
eventCreated: "تم الإنشاء",
eventEdited: "تم التعديل",
@@ -592,25 +604,6 @@ export const ar: TranslationMap = {
eventArchived: "مؤرشف",
eventUnarchived: "غير مؤرشف",
eventStale: "جلسة قديمة",
gameButton: "لعبة مصغرة",
gameTitle: "مطاردة البطاقات",
gameStart: "صِل إلى مربع الإطلاق.",
gameBoundary: "تم الوصول إلى الحد.",
gameBlocked: "محظور.",
gameContinue: "استمر.",
gameWin: "تم اجتياز الإطلاق.",
gameMoves: "الحركات {count}",
gameWins: "الانتصارات {count}",
gameBoard: "لوحة مطاردة البطاقات",
gameControls: "عناصر تحكم مطاردة البطاقات",
gameAgent: "الوكيل",
gameLaunch: "إطلاق",
gameBlockedCell: "محظور",
gameOpenCell: "مفتوح",
gameMoveUp: "التحرك للأعلى",
gameMoveLeft: "التحرك لليسار",
gameMoveDown: "التحرك للأسفل",
gameMoveRight: "التحرك لليمين",
},
overview: {
access: {

View File

@@ -520,6 +520,9 @@ export const de: TranslationMap = {
runDefaultAgent: "Standard-Agent ausführen",
start: "Starten",
dispatch: "Dispatcher anstoßen",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "live",
fieldTitle: "Titel",
fieldNotes: "Notizen",
@@ -548,6 +551,7 @@ export const de: TranslationMap = {
badgeTenant: "Mandant {tenant}",
badgeSkills: "{count} Fähigkeiten",
badgeDispatches: "{count} Auslösungen",
badgeTaskLinked: "task linked",
badgeClaimed: "beansprucht von {owner}",
badgeDiagnostics: "{count} Diagnosen",
badgeStale: "veraltet",
@@ -571,6 +575,14 @@ export const de: TranslationMap = {
lifecycleDoneDetail: "Zur Überprüfung verschoben",
lifecycleNeedsReview: "Überprüfung erforderlich",
lifecycleNeedsReviewDetail: "Lauf gestoppt oder fehlgeschlagen",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "Kartenereignisse",
eventCreated: "Erstellt",
eventEdited: "Bearbeitet",
@@ -597,25 +609,6 @@ export const de: TranslationMap = {
eventArchived: "Archiviert",
eventUnarchived: "Aus Archiv wiederhergestellt",
eventStale: "Veraltete Sitzung",
gameButton: "Minispiel",
gameTitle: "Card Chase",
gameStart: "Erreiche das Startfeld.",
gameBoundary: "Grenze erreicht.",
gameBlocked: "Blockiert.",
gameContinue: "Weiter so.",
gameWin: "Start abgeschlossen.",
gameMoves: "Züge {count}",
gameWins: "Siege {count}",
gameBoard: "Card Chase-Spielfeld",
gameControls: "Card Chase-Steuerung",
gameAgent: "Agent",
gameLaunch: "Start",
gameBlockedCell: "Blockiert",
gameOpenCell: "Offen",
gameMoveUp: "Nach oben bewegen",
gameMoveLeft: "Nach links bewegen",
gameMoveDown: "Nach unten bewegen",
gameMoveRight: "Nach rechts bewegen",
},
overview: {
access: {

View File

@@ -513,7 +513,10 @@ export const en: TranslationMap = {
openEngine: "Open {engine}",
runDefaultAgent: "Run default agent",
start: "Start",
dispatch: "Nudge dispatcher",
dispatch: "Dispatch ready work",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "live",
fieldTitle: "Title",
fieldNotes: "Notes",
@@ -542,6 +545,7 @@ export const en: TranslationMap = {
badgeTenant: "tenant {tenant}",
badgeSkills: "{count} skills",
badgeDispatches: "{count} dispatches",
badgeTaskLinked: "task linked",
badgeClaimed: "claimed by {owner}",
badgeDiagnostics: "{count} diagnostics",
badgeStale: "stale",
@@ -565,6 +569,14 @@ export const en: TranslationMap = {
lifecycleDoneDetail: "Moved to review",
lifecycleNeedsReview: "Needs review",
lifecycleNeedsReviewDetail: "Run stopped or failed",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "Card events",
eventCreated: "Created",
eventEdited: "Edited",
@@ -591,25 +603,6 @@ export const en: TranslationMap = {
eventArchived: "Archived",
eventUnarchived: "Unarchived",
eventStale: "Stale session",
gameButton: "Mini game",
gameTitle: "Card Chase",
gameStart: "Reach the launch tile.",
gameBoundary: "Boundary reached.",
gameBlocked: "Blocked.",
gameContinue: "Keep going.",
gameWin: "Launch cleared.",
gameMoves: "Moves {count}",
gameWins: "Wins {count}",
gameBoard: "Card Chase board",
gameControls: "Card Chase controls",
gameAgent: "Agent",
gameLaunch: "Launch",
gameBlockedCell: "Blocked",
gameOpenCell: "Open",
gameMoveUp: "Move up",
gameMoveLeft: "Move left",
gameMoveDown: "Move down",
gameMoveRight: "Move right",
},
overview: {
access: {

View File

@@ -517,6 +517,9 @@ export const es: TranslationMap = {
runDefaultAgent: "Ejecutar agente predeterminado",
start: "Iniciar",
dispatch: "Avisar al despachador",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "en vivo",
fieldTitle: "Título",
fieldNotes: "Notas",
@@ -545,6 +548,7 @@ export const es: TranslationMap = {
badgeTenant: "inquilino {tenant}",
badgeSkills: "{count} habilidades",
badgeDispatches: "{count} envíos",
badgeTaskLinked: "task linked",
badgeClaimed: "reclamada por {owner}",
badgeDiagnostics: "{count} diagnósticos",
badgeStale: "obsoleta",
@@ -568,6 +572,14 @@ export const es: TranslationMap = {
lifecycleDoneDetail: "Movido a revisión",
lifecycleNeedsReview: "Necesita revisión",
lifecycleNeedsReviewDetail: "La ejecución se detuvo o falló",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "Eventos de la tarjeta",
eventCreated: "Creada",
eventEdited: "Editada",
@@ -594,25 +606,6 @@ export const es: TranslationMap = {
eventArchived: "Archivado",
eventUnarchived: "Desarchivado",
eventStale: "Sesión obsoleta",
gameButton: "Minijuego",
gameTitle: "Card Chase",
gameStart: "Alcanza la casilla de lanzamiento.",
gameBoundary: "Límite alcanzado.",
gameBlocked: "Bloqueado.",
gameContinue: "Sigue adelante.",
gameWin: "Lanzamiento completado.",
gameMoves: "Movimientos {count}",
gameWins: "Victorias {count}",
gameBoard: "Tablero de Card Chase",
gameControls: "Controles de Card Chase",
gameAgent: "Agente",
gameLaunch: "Lanzamiento",
gameBlockedCell: "Bloqueado",
gameOpenCell: "Abierto",
gameMoveUp: "Mover arriba",
gameMoveLeft: "Mover a la izquierda",
gameMoveDown: "Mover abajo",
gameMoveRight: "Mover a la derecha",
},
overview: {
access: {

View File

@@ -517,6 +517,9 @@ export const fa: TranslationMap = {
runDefaultAgent: "اجرای عامل پیش‌فرض",
start: "شروع",
dispatch: "تلنگر به توزیع‌کننده",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "زنده",
fieldTitle: "عنوان",
fieldNotes: "یادداشت‌ها",
@@ -545,6 +548,7 @@ export const fa: TranslationMap = {
badgeTenant: "مستاجر {tenant}",
badgeSkills: "{count} مهارت",
badgeDispatches: "{count} ارسال",
badgeTaskLinked: "task linked",
badgeClaimed: "ادعا شده توسط {owner}",
badgeDiagnostics: "{count} تشخیص",
badgeStale: "قدیمی",
@@ -568,6 +572,14 @@ export const fa: TranslationMap = {
lifecycleDoneDetail: "به بازبینی منتقل شد",
lifecycleNeedsReview: "نیازمند بازبینی",
lifecycleNeedsReviewDetail: "اجرا متوقف شد یا ناموفق بود",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "رویدادهای کارت",
eventCreated: "ایجاد شد",
eventEdited: "ویرایش شد",
@@ -594,25 +606,6 @@ export const fa: TranslationMap = {
eventArchived: "بایگانی شد",
eventUnarchived: "از بایگانی خارج شد",
eventStale: "نشست منقضی‌شده",
gameButton: "بازی کوچک",
gameTitle: "تعقیب کارت",
gameStart: "به کاشی راه‌اندازی برسید.",
gameBoundary: "به مرز رسیدید.",
gameBlocked: "مسدود شده است.",
gameContinue: "ادامه دهید.",
gameWin: "راه‌اندازی با موفقیت انجام شد.",
gameMoves: "حرکت‌ها {count}",
gameWins: "بردها {count}",
gameBoard: "صفحه Card Chase",
gameControls: "کنترل‌های Card Chase",
gameAgent: "عامل",
gameLaunch: "راه‌اندازی",
gameBlockedCell: "مسدود",
gameOpenCell: "باز",
gameMoveUp: "حرکت به بالا",
gameMoveLeft: "حرکت به چپ",
gameMoveDown: "حرکت به پایین",
gameMoveRight: "حرکت به راست",
},
overview: {
access: {

View File

@@ -519,6 +519,9 @@ export const fr: TranslationMap = {
runDefaultAgent: "Exécuter lagent par défaut",
start: "Démarrer",
dispatch: "Relancer le répartiteur",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "en direct",
fieldTitle: "Titre",
fieldNotes: "Notes",
@@ -547,6 +550,7 @@ export const fr: TranslationMap = {
badgeTenant: "locataire {tenant}",
badgeSkills: "{count} compétences",
badgeDispatches: "{count} envois",
badgeTaskLinked: "task linked",
badgeClaimed: "revendiqué par {owner}",
badgeDiagnostics: "{count} diagnostics",
badgeStale: "obsolète",
@@ -570,6 +574,14 @@ export const fr: TranslationMap = {
lifecycleDoneDetail: "Déplacé vers la révision",
lifecycleNeedsReview: "Nécessite une révision",
lifecycleNeedsReviewDetail: "Exécution arrêtée ou échouée",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "Événements de la carte",
eventCreated: "Créé",
eventEdited: "Modifié",
@@ -596,25 +608,6 @@ export const fr: TranslationMap = {
eventArchived: "Archivé",
eventUnarchived: "Désarchivé",
eventStale: "Session inactive",
gameButton: "Mini-jeu",
gameTitle: "Card Chase",
gameStart: "Atteignez la tuile de lancement.",
gameBoundary: "Limite atteinte.",
gameBlocked: "Bloqué.",
gameContinue: "Continuez.",
gameWin: "Lancement terminé.",
gameMoves: "Coups {count}",
gameWins: "Victoires {count}",
gameBoard: "Plateau Card Chase",
gameControls: "Commandes de Card Chase",
gameAgent: "Agent",
gameLaunch: "Lancement",
gameBlockedCell: "Bloqué",
gameOpenCell: "Ouvert",
gameMoveUp: "Déplacer vers le haut",
gameMoveLeft: "Déplacer vers la gauche",
gameMoveDown: "Déplacer vers le bas",
gameMoveRight: "Déplacer vers la droite",
},
overview: {
access: {

View File

@@ -516,6 +516,9 @@ export const id: TranslationMap = {
runDefaultAgent: "Jalankan agen default",
start: "Mulai",
dispatch: "Dorong dispatcher",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "langsung",
fieldTitle: "Judul",
fieldNotes: "Catatan",
@@ -544,6 +547,7 @@ export const id: TranslationMap = {
badgeTenant: "penyewa {tenant}",
badgeSkills: "{count} keahlian",
badgeDispatches: "{count} pengiriman",
badgeTaskLinked: "task linked",
badgeClaimed: "diklaim oleh {owner}",
badgeDiagnostics: "{count} diagnostik",
badgeStale: "usang",
@@ -567,6 +571,14 @@ export const id: TranslationMap = {
lifecycleDoneDetail: "Dipindahkan ke peninjauan",
lifecycleNeedsReview: "Perlu ditinjau",
lifecycleNeedsReviewDetail: "Run dihentikan atau gagal",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "Peristiwa kartu",
eventCreated: "Dibuat",
eventEdited: "Diedit",
@@ -593,25 +605,6 @@ export const id: TranslationMap = {
eventArchived: "Diarsipkan",
eventUnarchived: "Dibatalkan pengarsipannya",
eventStale: "Sesi kedaluwarsa",
gameButton: "Mini game",
gameTitle: "Card Chase",
gameStart: "Capai petak peluncuran.",
gameBoundary: "Mencapai batas.",
gameBlocked: "Terhalang.",
gameContinue: "Terus lanjut.",
gameWin: "Peluncuran selesai.",
gameMoves: "Gerakan {count}",
gameWins: "Kemenangan {count}",
gameBoard: "Papan Card Chase",
gameControls: "Kontrol Card Chase",
gameAgent: "Agen",
gameLaunch: "Luncurkan",
gameBlockedCell: "Terhalang",
gameOpenCell: "Terbuka",
gameMoveUp: "Bergerak ke atas",
gameMoveLeft: "Bergerak ke kiri",
gameMoveDown: "Bergerak ke bawah",
gameMoveRight: "Bergerak ke kanan",
},
overview: {
access: {

View File

@@ -518,6 +518,9 @@ export const it: TranslationMap = {
runDefaultAgent: "Esegui agente predefinito",
start: "Avvia",
dispatch: "Sollecita dispatcher",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "live",
fieldTitle: "Titolo",
fieldNotes: "Note",
@@ -546,6 +549,7 @@ export const it: TranslationMap = {
badgeTenant: "ambiente {tenant}",
badgeSkills: "{count} competenze",
badgeDispatches: "{count} invii",
badgeTaskLinked: "task linked",
badgeClaimed: "rivendicato da {owner}",
badgeDiagnostics: "{count} diagnostiche",
badgeStale: "obsoleto",
@@ -569,6 +573,14 @@ export const it: TranslationMap = {
lifecycleDoneDetail: "Spostata in revisione",
lifecycleNeedsReview: "Richiede revisione",
lifecycleNeedsReviewDetail: "Esecuzione interrotta o non riuscita",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "Eventi della scheda",
eventCreated: "Creato",
eventEdited: "Modificato",
@@ -595,25 +607,6 @@ export const it: TranslationMap = {
eventArchived: "Archiviato",
eventUnarchived: "Non archiviato",
eventStale: "Sessione obsoleta",
gameButton: "Mini gioco",
gameTitle: "Card Chase",
gameStart: "Raggiungi la casella di lancio.",
gameBoundary: "Limite raggiunto.",
gameBlocked: "Bloccato.",
gameContinue: "Continua.",
gameWin: "Lancio completato.",
gameMoves: "Mosse {count}",
gameWins: "Vittorie {count}",
gameBoard: "Tabellone di Card Chase",
gameControls: "Controlli di Card Chase",
gameAgent: "Agente",
gameLaunch: "Lancio",
gameBlockedCell: "Bloccata",
gameOpenCell: "Aperta",
gameMoveUp: "Sposta su",
gameMoveLeft: "Sposta a sinistra",
gameMoveDown: "Sposta giù",
gameMoveRight: "Sposta a destra",
},
overview: {
access: {

View File

@@ -519,6 +519,9 @@ export const ja_JP: TranslationMap = {
runDefaultAgent: "デフォルトエージェントを実行",
start: "開始",
dispatch: "ディスパッチャーを促す",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "ライブ",
fieldTitle: "タイトル",
fieldNotes: "メモ",
@@ -547,6 +550,7 @@ export const ja_JP: TranslationMap = {
badgeTenant: "テナント {tenant}",
badgeSkills: "{count} 個のスキル",
badgeDispatches: "{count} 回のディスパッチ",
badgeTaskLinked: "task linked",
badgeClaimed: "{owner} が要求済み",
badgeDiagnostics: "{count} 件の診断",
badgeStale: "古い",
@@ -570,6 +574,14 @@ export const ja_JP: TranslationMap = {
lifecycleDoneDetail: "レビューに移動しました",
lifecycleNeedsReview: "レビューが必要",
lifecycleNeedsReviewDetail: "実行が停止または失敗しました",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "カードイベント",
eventCreated: "作成済み",
eventEdited: "編集済み",
@@ -596,25 +608,6 @@ export const ja_JP: TranslationMap = {
eventArchived: "アーカイブ済み",
eventUnarchived: "アーカイブ解除済み",
eventStale: "古いセッション",
gameButton: "ミニゲーム",
gameTitle: "Card Chase",
gameStart: "ローンチタイルに到達してください。",
gameBoundary: "境界に到達しました。",
gameBlocked: "ブロックされています。",
gameContinue: "続けてください。",
gameWin: "ローンチをクリアしました。",
gameMoves: "移動 {count}",
gameWins: "勝利 {count}",
gameBoard: "Card Chase ボード",
gameControls: "Card Chase コントロール",
gameAgent: "エージェント",
gameLaunch: "ローンチ",
gameBlockedCell: "ブロック済み",
gameOpenCell: "オープン",
gameMoveUp: "上に移動",
gameMoveLeft: "左に移動",
gameMoveDown: "下に移動",
gameMoveRight: "右に移動",
},
overview: {
access: {

View File

@@ -515,6 +515,9 @@ export const ko: TranslationMap = {
runDefaultAgent: "기본 에이전트 실행",
start: "시작",
dispatch: "디스패처 넛지",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "라이브",
fieldTitle: "제목",
fieldNotes: "메모",
@@ -543,6 +546,7 @@ export const ko: TranslationMap = {
badgeTenant: "테넌트 {tenant}",
badgeSkills: "{count}개 스킬",
badgeDispatches: "{count}회 디스패치",
badgeTaskLinked: "task linked",
badgeClaimed: "{owner}가 할당함",
badgeDiagnostics: "진단 {count}개",
badgeStale: "오래됨",
@@ -566,6 +570,14 @@ export const ko: TranslationMap = {
lifecycleDoneDetail: "검토로 이동됨",
lifecycleNeedsReview: "검토 필요",
lifecycleNeedsReviewDetail: "실행이 중지되었거나 실패했습니다",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "카드 이벤트",
eventCreated: "생성됨",
eventEdited: "편집됨",
@@ -592,25 +604,6 @@ export const ko: TranslationMap = {
eventArchived: "보관됨",
eventUnarchived: "보관 해제됨",
eventStale: "오래된 세션",
gameButton: "미니 게임",
gameTitle: "Card Chase",
gameStart: "출발 타일에 도달하세요.",
gameBoundary: "경계에 도달했습니다.",
gameBlocked: "막혔습니다.",
gameContinue: "계속 진행하세요.",
gameWin: "출발 완료.",
gameMoves: "이동 {count}회",
gameWins: "승리 {count}회",
gameBoard: "Card Chase 보드",
gameControls: "Card Chase 컨트롤",
gameAgent: "에이전트",
gameLaunch: "출발",
gameBlockedCell: "차단됨",
gameOpenCell: "열림",
gameMoveUp: "위로 이동",
gameMoveLeft: "왼쪽으로 이동",
gameMoveDown: "아래로 이동",
gameMoveRight: "오른쪽으로 이동",
},
overview: {
access: {

View File

@@ -518,6 +518,9 @@ export const nl: TranslationMap = {
runDefaultAgent: "Standaardagent uitvoeren",
start: "Starten",
dispatch: "Dispatcher een zetje geven",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "live",
fieldTitle: "Titel",
fieldNotes: "Notities",
@@ -546,6 +549,7 @@ export const nl: TranslationMap = {
badgeTenant: "omgeving {tenant}",
badgeSkills: "{count} vaardigheden",
badgeDispatches: "{count} verzendingen",
badgeTaskLinked: "task linked",
badgeClaimed: "geclaimd door {owner}",
badgeDiagnostics: "{count} diagnoses",
badgeStale: "verouderd",
@@ -569,6 +573,14 @@ export const nl: TranslationMap = {
lifecycleDoneDetail: "Verplaatst naar review",
lifecycleNeedsReview: "Review nodig",
lifecycleNeedsReviewDetail: "Run gestopt of mislukt",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "Kaartgebeurtenissen",
eventCreated: "Gemaakt",
eventEdited: "Bewerkt",
@@ -595,25 +607,6 @@ export const nl: TranslationMap = {
eventArchived: "Gearchiveerd",
eventUnarchived: "Uit archief gehaald",
eventStale: "Verlopen sessie",
gameButton: "Minigame",
gameTitle: "Card Chase",
gameStart: "Bereik de lanceringstegel.",
gameBoundary: "Grens bereikt.",
gameBlocked: "Geblokkeerd.",
gameContinue: "Ga door.",
gameWin: "Lancering voltooid.",
gameMoves: "Zetten {count}",
gameWins: "Overwinningen {count}",
gameBoard: "Card Chase-bord",
gameControls: "Card Chase-bediening",
gameAgent: "Agent",
gameLaunch: "Starten",
gameBlockedCell: "Geblokkeerd",
gameOpenCell: "Open",
gameMoveUp: "Omhoog verplaatsen",
gameMoveLeft: "Naar links verplaatsen",
gameMoveDown: "Omlaag verplaatsen",
gameMoveRight: "Naar rechts verplaatsen",
},
overview: {
access: {

View File

@@ -517,6 +517,9 @@ export const pl: TranslationMap = {
runDefaultAgent: "Uruchom domyślnego agenta",
start: "Rozpocznij",
dispatch: "Daj znać dyspozytorowi",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "na żywo",
fieldTitle: "Tytuł",
fieldNotes: "Notatki",
@@ -545,6 +548,7 @@ export const pl: TranslationMap = {
badgeTenant: "dzierżawca {tenant}",
badgeSkills: "{count} umiejętności",
badgeDispatches: "{count} wysyłki",
badgeTaskLinked: "task linked",
badgeClaimed: "przejęte przez {owner}",
badgeDiagnostics: "{count} diagnostyk",
badgeStale: "nieaktualne",
@@ -568,6 +572,14 @@ export const pl: TranslationMap = {
lifecycleDoneDetail: "Przeniesiono do przeglądu",
lifecycleNeedsReview: "Wymaga przeglądu",
lifecycleNeedsReviewDetail: "Uruchomienie zatrzymane lub nieudane",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "Zdarzenia karty",
eventCreated: "Utworzono",
eventEdited: "Edytowano",
@@ -594,25 +606,6 @@ export const pl: TranslationMap = {
eventArchived: "Zarchiwizowano",
eventUnarchived: "Przywrócono z archiwum",
eventStale: "Nieaktualna sesja",
gameButton: "Minigra",
gameTitle: "Pościg za kartą",
gameStart: "Dotrzyj do pola uruchomienia.",
gameBoundary: "Osiągnięto granicę.",
gameBlocked: "Zablokowane.",
gameContinue: "Kontynuuj.",
gameWin: "Uruchomienie zakończone.",
gameMoves: "Ruchy {count}",
gameWins: "Wygrane {count}",
gameBoard: "Plansza Card Chase",
gameControls: "Sterowanie Card Chase",
gameAgent: "Agent",
gameLaunch: "Uruchom",
gameBlockedCell: "Zablokowane",
gameOpenCell: "Otwarte",
gameMoveUp: "Przesuń w górę",
gameMoveLeft: "Przesuń w lewo",
gameMoveDown: "Przesuń w dół",
gameMoveRight: "Przesuń w prawo",
},
overview: {
access: {

View File

@@ -516,6 +516,9 @@ export const pt_BR: TranslationMap = {
runDefaultAgent: "Executar agente padrão",
start: "Iniciar",
dispatch: "Acionar despachante",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "ao vivo",
fieldTitle: "Título",
fieldNotes: "Notas",
@@ -544,6 +547,7 @@ export const pt_BR: TranslationMap = {
badgeTenant: "locatário {tenant}",
badgeSkills: "{count} habilidades",
badgeDispatches: "{count} despachos",
badgeTaskLinked: "task linked",
badgeClaimed: "reivindicado por {owner}",
badgeDiagnostics: "{count} diagnósticos",
badgeStale: "desatualizado",
@@ -567,6 +571,14 @@ export const pt_BR: TranslationMap = {
lifecycleDoneDetail: "Movido para revisão",
lifecycleNeedsReview: "Precisa de revisão",
lifecycleNeedsReviewDetail: "A execução foi interrompida ou falhou",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "Eventos do cartão",
eventCreated: "Criado",
eventEdited: "Editado",
@@ -593,25 +605,6 @@ export const pt_BR: TranslationMap = {
eventArchived: "Arquivado",
eventUnarchived: "Desarquivado",
eventStale: "Sessão obsoleta",
gameButton: "Mini game",
gameTitle: "Card Chase",
gameStart: "Alcance o bloco de lançamento.",
gameBoundary: "Limite alcançado.",
gameBlocked: "Bloqueado.",
gameContinue: "Continue.",
gameWin: "Lançamento concluído.",
gameMoves: "Movimentos {count}",
gameWins: "Vitórias {count}",
gameBoard: "Tabuleiro do Card Chase",
gameControls: "Controles do Card Chase",
gameAgent: "Agente",
gameLaunch: "Lançamento",
gameBlockedCell: "Bloqueado",
gameOpenCell: "Aberto",
gameMoveUp: "Mover para cima",
gameMoveLeft: "Mover para a esquerda",
gameMoveDown: "Mover para baixo",
gameMoveRight: "Mover para a direita",
},
overview: {
access: {

View File

@@ -514,6 +514,9 @@ export const th: TranslationMap = {
runDefaultAgent: "เรียกใช้เอเจนต์เริ่มต้น",
start: "เริ่ม",
dispatch: "กระตุ้น dispatcher",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "สด",
fieldTitle: "ชื่อเรื่อง",
fieldNotes: "บันทึก",
@@ -542,6 +545,7 @@ export const th: TranslationMap = {
badgeTenant: "ผู้เช่า {tenant}",
badgeSkills: "{count} ทักษะ",
badgeDispatches: "ส่งงาน {count} ครั้ง",
badgeTaskLinked: "task linked",
badgeClaimed: "ถูกอ้างสิทธิ์โดย {owner}",
badgeDiagnostics: "การวินิจฉัย {count} รายการ",
badgeStale: "ค้างนาน",
@@ -565,6 +569,14 @@ export const th: TranslationMap = {
lifecycleDoneDetail: "ย้ายไปยังการตรวจทานแล้ว",
lifecycleNeedsReview: "ต้องตรวจทาน",
lifecycleNeedsReviewDetail: "การรันหยุดหรือไม่สำเร็จ",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "เหตุการณ์ของการ์ด",
eventCreated: "สร้างแล้ว",
eventEdited: "แก้ไขแล้ว",
@@ -591,25 +603,6 @@ export const th: TranslationMap = {
eventArchived: "เก็บถาวรแล้ว",
eventUnarchived: "ยกเลิกการเก็บถาวรแล้ว",
eventStale: "เซสชันหมดอายุ",
gameButton: "มินิเกม",
gameTitle: "Card Chase",
gameStart: "ไปให้ถึงช่องเปิดตัว",
gameBoundary: "ถึงขอบเขตแล้ว",
gameBlocked: "ถูกบล็อก",
gameContinue: "ไปต่อ",
gameWin: "เปิดตัวสำเร็จ",
gameMoves: "การเดิน {count}",
gameWins: "ชนะ {count}",
gameBoard: "กระดาน Card Chase",
gameControls: "ตัวควบคุม Card Chase",
gameAgent: "Agent",
gameLaunch: "เปิดใช้งาน",
gameBlockedCell: "ถูกบล็อก",
gameOpenCell: "เปิด",
gameMoveUp: "เลื่อนขึ้น",
gameMoveLeft: "เลื่อนไปทางซ้าย",
gameMoveDown: "เลื่อนลง",
gameMoveRight: "เลื่อนไปทางขวา",
},
overview: {
access: {

View File

@@ -519,6 +519,9 @@ export const tr: TranslationMap = {
runDefaultAgent: "Varsayılan ajanı çalıştır",
start: "Başlat",
dispatch: "Dağıtıcıyı dürt",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "canlı",
fieldTitle: "Başlık",
fieldNotes: "Notlar",
@@ -547,6 +550,7 @@ export const tr: TranslationMap = {
badgeTenant: "kiracı {tenant}",
badgeSkills: "{count} beceri",
badgeDispatches: "{count} gönderim",
badgeTaskLinked: "task linked",
badgeClaimed: "{owner} tarafından üstlenildi",
badgeDiagnostics: "{count} tanılama",
badgeStale: "eski",
@@ -570,6 +574,14 @@ export const tr: TranslationMap = {
lifecycleDoneDetail: "İncelemeye taşındı",
lifecycleNeedsReview: "İnceleme gerekli",
lifecycleNeedsReviewDetail: "Çalışma durduruldu veya başarısız oldu",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "Kart olayları",
eventCreated: "Oluşturuldu",
eventEdited: "Düzenlendi",
@@ -596,25 +608,6 @@ export const tr: TranslationMap = {
eventArchived: "Arşivlendi",
eventUnarchived: "Arşivden çıkarıldı",
eventStale: "Eski oturum",
gameButton: "Mini oyun",
gameTitle: "Kart Takibi",
gameStart: "Başlatma karesine ulaşın.",
gameBoundary: "Sınıra ulaşıldı.",
gameBlocked: "Engellendi.",
gameContinue: "Devam edin.",
gameWin: "Başlatma tamamlandı.",
gameMoves: "Hamleler {count}",
gameWins: "Galibiyetler {count}",
gameBoard: "Kart Takibi tahtası",
gameControls: "Kart Takibi kontrolleri",
gameAgent: "Aracı",
gameLaunch: "Başlat",
gameBlockedCell: "Engelli",
gameOpenCell: "Açık",
gameMoveUp: "Yukarı git",
gameMoveLeft: "Sola git",
gameMoveDown: "Aşağı git",
gameMoveRight: "Sağa git",
},
overview: {
access: {

View File

@@ -518,6 +518,9 @@ export const uk: TranslationMap = {
runDefaultAgent: "Запустити агента за замовчуванням",
start: "Почати",
dispatch: "Підштовхнути диспетчер",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "наживо",
fieldTitle: "Заголовок",
fieldNotes: "Нотатки",
@@ -546,6 +549,7 @@ export const uk: TranslationMap = {
badgeTenant: "орендар {tenant}",
badgeSkills: "{count} навичок",
badgeDispatches: "{count} відправлень",
badgeTaskLinked: "task linked",
badgeClaimed: "призначено {owner}",
badgeDiagnostics: "{count} діагностик",
badgeStale: "застаріле",
@@ -569,6 +573,14 @@ export const uk: TranslationMap = {
lifecycleDoneDetail: "Переміщено на перевірку",
lifecycleNeedsReview: "Потребує перевірки",
lifecycleNeedsReviewDetail: "Запуск зупинено або він завершився помилкою",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "Події картки",
eventCreated: "Створено",
eventEdited: "Змінено",
@@ -595,25 +607,6 @@ export const uk: TranslationMap = {
eventArchived: "Заархівовано",
eventUnarchived: "Розархівовано",
eventStale: "Застарілий сеанс",
gameButton: "Мінігра",
gameTitle: "Card Chase",
gameStart: "Дістаньтеся клітинки запуску.",
gameBoundary: "Досягнуто межі.",
gameBlocked: "Заблоковано.",
gameContinue: "Продовжуйте.",
gameWin: "Запуск завершено.",
gameMoves: "Ходи: {count}",
gameWins: "Перемоги: {count}",
gameBoard: "Ігрове поле Card Chase",
gameControls: "Керування Card Chase",
gameAgent: "Агент",
gameLaunch: "Запуск",
gameBlockedCell: "Заблоковано",
gameOpenCell: "Відкрито",
gameMoveUp: "Рух угору",
gameMoveLeft: "Рух ліворуч",
gameMoveDown: "Рух униз",
gameMoveRight: "Рух праворуч",
},
overview: {
access: {

View File

@@ -517,6 +517,9 @@ export const vi: TranslationMap = {
runDefaultAgent: "Chạy agent mặc định",
start: "Bắt đầu",
dispatch: "Nhắc dispatcher",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "trực tiếp",
fieldTitle: "Tiêu đề",
fieldNotes: "Ghi chú",
@@ -545,6 +548,7 @@ export const vi: TranslationMap = {
badgeTenant: "đối tượng thuê {tenant}",
badgeSkills: "{count} kỹ năng",
badgeDispatches: "{count} lượt điều phối",
badgeTaskLinked: "task linked",
badgeClaimed: "được nhận bởi {owner}",
badgeDiagnostics: "{count} chẩn đoán",
badgeStale: "cũ",
@@ -568,6 +572,14 @@ export const vi: TranslationMap = {
lifecycleDoneDetail: "Đã chuyển sang xem xét",
lifecycleNeedsReview: "Cần xem xét",
lifecycleNeedsReviewDetail: "Lượt chạy đã dừng hoặc thất bại",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "Sự kiện thẻ",
eventCreated: "Đã tạo",
eventEdited: "Đã chỉnh sửa",
@@ -594,25 +606,6 @@ export const vi: TranslationMap = {
eventArchived: "Đã lưu trữ",
eventUnarchived: "Đã bỏ lưu trữ",
eventStale: "Phiên đã cũ",
gameButton: "Trò chơi nhỏ",
gameTitle: "Đuổi bắt thẻ",
gameStart: "Đến ô khởi chạy.",
gameBoundary: "Đã đến ranh giới.",
gameBlocked: "Bị chặn.",
gameContinue: "Tiếp tục.",
gameWin: "Đã vượt qua khởi chạy.",
gameMoves: "Lượt đi {count}",
gameWins: "Thắng {count}",
gameBoard: "Bảng Card Chase",
gameControls: "Điều khiển Card Chase",
gameAgent: "Tác nhân",
gameLaunch: "Khởi chạy",
gameBlockedCell: "Bị chặn",
gameOpenCell: "Mở",
gameMoveUp: "Di chuyển lên",
gameMoveLeft: "Di chuyển sang trái",
gameMoveDown: "Di chuyển xuống",
gameMoveRight: "Di chuyển sang phải",
},
overview: {
access: {

View File

@@ -513,6 +513,9 @@ export const zh_CN: TranslationMap = {
runDefaultAgent: "运行默认代理",
start: "开始",
dispatch: "提醒调度器",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "实时",
fieldTitle: "标题",
fieldNotes: "备注",
@@ -541,6 +544,7 @@ export const zh_CN: TranslationMap = {
badgeTenant: "租户 {tenant}",
badgeSkills: "{count} 个技能",
badgeDispatches: "{count} 次调度",
badgeTaskLinked: "task linked",
badgeClaimed: "由 {owner} 认领",
badgeDiagnostics: "{count} 个诊断",
badgeStale: "已过期",
@@ -564,6 +568,14 @@ export const zh_CN: TranslationMap = {
lifecycleDoneDetail: "已移至审核",
lifecycleNeedsReview: "需要审核",
lifecycleNeedsReviewDetail: "运行已停止或失败",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "卡片事件",
eventCreated: "已创建",
eventEdited: "已编辑",
@@ -590,25 +602,6 @@ export const zh_CN: TranslationMap = {
eventArchived: "已归档",
eventUnarchived: "已取消归档",
eventStale: "过期会话",
gameButton: "迷你游戏",
gameTitle: "卡片追逐",
gameStart: "到达发布图块。",
gameBoundary: "已到达边界。",
gameBlocked: "已阻挡。",
gameContinue: "继续前进。",
gameWin: "发布已完成。",
gameMoves: "移动 {count}",
gameWins: "获胜 {count}",
gameBoard: "卡片追逐棋盘",
gameControls: "卡片追逐控制",
gameAgent: "代理",
gameLaunch: "发布",
gameBlockedCell: "已阻挡",
gameOpenCell: "开放",
gameMoveUp: "向上移动",
gameMoveLeft: "向左移动",
gameMoveDown: "向下移动",
gameMoveRight: "向右移动",
},
overview: {
access: {

View File

@@ -513,6 +513,9 @@ export const zh_TW: TranslationMap = {
runDefaultAgent: "執行預設代理程式",
start: "開始",
dispatch: "提醒分派器",
dispatchSummary:
"Dispatch complete: started {started}, promoted {promoted}, blocked {blocked}, reclaimed {reclaimed}, orchestrated {orchestrated}, failures {failures}.",
dispatchSummaryEmpty: "Dispatch complete: no ready work changed.",
live: "即時",
fieldTitle: "標題",
fieldNotes: "備註",
@@ -541,6 +544,7 @@ export const zh_TW: TranslationMap = {
badgeTenant: "租戶 {tenant}",
badgeSkills: "{count} 個技能",
badgeDispatches: "{count} 次調度",
badgeTaskLinked: "task linked",
badgeClaimed: "由 {owner} 認領",
badgeDiagnostics: "{count} 個診斷",
badgeStale: "過期",
@@ -564,6 +568,14 @@ export const zh_TW: TranslationMap = {
lifecycleDoneDetail: "已移至審查",
lifecycleNeedsReview: "需要審查",
lifecycleNeedsReviewDetail: "執行已停止或失敗",
taskStatus: {
queued: "Task queued",
running: "Task running",
completed: "Task complete",
failed: "Task failed",
cancelled: "Task cancelled",
timed_out: "Task timed out",
},
eventsLabel: "卡片事件",
eventCreated: "已建立",
eventEdited: "已編輯",
@@ -590,25 +602,6 @@ export const zh_TW: TranslationMap = {
eventArchived: "已封存",
eventUnarchived: "已取消封存",
eventStale: "過期工作階段",
gameButton: "小遊戲",
gameTitle: "卡片追逐",
gameStart: "到達啟動方格。",
gameBoundary: "已到達邊界。",
gameBlocked: "已封鎖。",
gameContinue: "繼續前進。",
gameWin: "啟動已完成。",
gameMoves: "移動 {count}",
gameWins: "獲勝 {count}",
gameBoard: "卡片追逐棋盤",
gameControls: "卡片追逐控制項",
gameAgent: "代理",
gameLaunch: "啟動",
gameBlockedCell: "已封鎖",
gameOpenCell: "開放",
gameMoveUp: "向上移動",
gameMoveLeft: "向左移動",
gameMoveDown: "向下移動",
gameMoveRight: "向右移動",
},
overview: {
access: {

View File

@@ -129,21 +129,6 @@
box-shadow: var(--shadow-xl);
}
.workboard-game {
display: grid;
gap: 14px;
width: min(620px, calc(100vw - 44px));
padding: 16px;
border: 1px solid color-mix(in srgb, var(--border) 86%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--panel) 96%, var(--bg) 4%);
box-shadow: var(--shadow-xl);
}
.workboard-game:focus {
outline: none;
}
.workboard-modal__header {
display: flex;
justify-content: space-between;
@@ -233,159 +218,12 @@
}
.workboard-toolbar .btn,
.workboard-draft .btn,
.workboard-game .btn {
.workboard-draft .btn {
min-height: var(--workboard-control-height);
padding: 0 12px;
border-radius: var(--workboard-control-radius);
}
.workboard-game__stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.workboard-game__stats span {
display: inline-flex;
align-items: center;
min-height: 24px;
border-radius: 6px;
padding: 2px 8px;
background: color-mix(in srgb, var(--border) 58%, transparent);
color: var(--muted);
font-size: 0.76rem;
line-height: 1;
}
.workboard-game__scene {
position: relative;
display: block;
width: 100%;
min-height: min(58vh, 430px);
overflow: hidden;
border-radius: 8px;
background:
radial-gradient(
circle at 24% 20%,
color-mix(in srgb, var(--accent) 24%, transparent),
transparent 30%
),
linear-gradient(180deg, color-mix(in srgb, var(--bg-elevated) 92%, #10171d), var(--bg));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 72%, transparent);
}
.workboard-game__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.workboard-game__fallback {
position: absolute;
inset: 12%;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
transform: perspective(720px) rotateX(58deg) rotateZ(-35deg);
transform-origin: center;
}
.workboard-game__scene--ready .workboard-game__fallback {
opacity: 0;
}
.workboard-game__accessible-grid {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.workboard-game__fallback-tile {
min-width: 0;
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--bg) 84%, var(--panel) 16%);
box-shadow:
0 10px 0 color-mix(in srgb, #000 28%, transparent),
inset 0 1px 0 color-mix(in srgb, white 8%, transparent);
}
.workboard-game__fallback-tile--player {
border-color: color-mix(in srgb, var(--accent) 54%, var(--border));
background: color-mix(in srgb, var(--accent) 30%, var(--bg));
}
.workboard-game__fallback-tile--goal {
border-color: color-mix(in srgb, var(--ok) 48%, var(--border));
background: color-mix(in srgb, var(--ok) 24%, var(--bg));
}
.workboard-game__fallback-tile--blocker {
background: color-mix(in srgb, var(--danger) 32%, var(--bg));
}
.workboard-game__controls {
display: grid;
grid-template-columns: repeat(3, 38px);
grid-template-areas:
". up ."
"left down right";
justify-content: center;
gap: 8px;
}
.workboard-game__arrow {
width: 38px;
height: 38px;
padding: 0;
}
.workboard-game__arrow svg {
width: 16px;
height: 16px;
}
.workboard-game__arrow--up {
grid-area: up;
}
.workboard-game__arrow--left {
grid-area: left;
}
.workboard-game__controls
.workboard-game__arrow:not(
.workboard-game__arrow--up,
.workboard-game__arrow--left,
.workboard-game__arrow--right
) {
grid-area: down;
}
.workboard-game__arrow--right {
grid-area: right;
}
.workboard-game__arrow--up svg {
transform: rotate(180deg);
}
.workboard-game__arrow--left svg {
transform: rotate(90deg);
}
.workboard-game__arrow--right svg {
transform: rotate(-90deg);
}
.workboard-board {
display: grid;
grid-auto-flow: column;

View File

@@ -15,13 +15,4 @@ describe("workboard styles", () => {
expect(css).toContain("grid-auto-columns: minmax(260px, 82vw);");
expect(css).not.toContain("grid-template-columns: repeat(6");
});
it("keeps the mini game scene framed for a 3d canvas and fallback board", () => {
const css = readWorkboardCss();
expect(css).toContain(".workboard-game__scene {\n position: relative;");
expect(css).toContain("min-height: min(58vh, 430px);");
expect(css).toContain(".workboard-game__canvas {\n position: absolute;");
expect(css).toContain("transform: perspective(720px) rotateX(58deg) rotateZ(-35deg);");
});
});

View File

@@ -22,7 +22,7 @@ import {
dismissChatError,
switchChatSession,
} from "./app-render.helpers.ts";
import { hasOperatorWriteAccess, warnQueryToken } from "./app-settings.ts";
import { hasOperatorAdminAccess, hasOperatorWriteAccess, warnQueryToken } from "./app-settings.ts";
import type { AppViewState } from "./app-view-state.ts";
import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts";
import {
@@ -2491,15 +2491,16 @@ export function renderApp(state: AppViewState) {
})
: nothing}
${state.tab === "workboard"
? renderLazyView(lazyWorkboard, (m) =>
m.renderWorkboard({
? renderLazyView(lazyWorkboard, (m) => {
const auth =
(state.hello as { auth?: { role?: string; scopes?: string[] } } | null)?.auth ??
null;
return m.renderWorkboard({
host: state,
client: state.client,
connected: state.connected,
canWrite: hasOperatorWriteAccess(
(state.hello as { auth?: { role?: string; scopes?: string[] } } | null)?.auth ??
null,
),
canWrite: hasOperatorWriteAccess(auth),
canModelOverride: hasOperatorAdminAccess(auth),
pluginEnabled: isPluginEnabledInConfigSnapshot(state.configSnapshot, "workboard", {
enabledByDefault: false,
}),
@@ -2510,8 +2511,8 @@ export function renderApp(state: AppViewState) {
state.setTab("chat" as import("./navigation.ts").Tab);
},
onRequestUpdate: requestHostUpdate,
}),
)
});
})
: nothing}
${renderUsageTab(state)}
${state.tab === "cron" ? renderCronQuickCreateForTab(state, requestHostUpdate) : nothing}

View File

@@ -824,6 +824,19 @@ export function hasOperatorWriteAccess(
});
}
export function hasOperatorAdminAccess(
auth: { role?: string; scopes?: readonly string[] } | null,
): boolean {
if (!auth?.scopes) {
return true;
}
return roleScopesAllow({
role: auth.role ?? "operator",
requestedScopes: ["operator.admin"],
allowedScopes: auth.scopes,
});
}
export function hasMissingSkillDependencies(
missing: Record<string, unknown> | null | undefined,
): boolean {

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewaySessionRow } from "../types.ts";
import {
archiveWorkboardCard,
@@ -14,6 +14,7 @@ import {
stopWorkboardCard,
syncWorkboardLifecycle,
type WorkboardCard,
type WorkboardTaskSummary,
} from "./workboard.ts";
function createClient(
@@ -56,7 +57,22 @@ const sampleSession: GatewaySessionRow = {
status: "running",
};
const sampleTaskSessionKey = "subagent:workboard-default-card-1";
const sampleTask = {
id: "task-1",
taskId: "task-1",
status: "running",
title: "Build board",
childSessionKey: sampleTaskSessionKey,
runId: "run-1",
updatedAt: 2,
} satisfies WorkboardTaskSummary;
describe("workboard controller", () => {
afterEach(() => {
vi.useRealTimers();
});
it("loads cards through the plugin gateway method", async () => {
const host = {};
const client = createClient({
@@ -69,6 +85,111 @@ describe("workboard controller", () => {
expect(getWorkboardState(host).cards).toEqual([sampleCard]);
});
it("links loaded cards to matching Gateway tasks", async () => {
const host = {};
const linked = {
...sampleCard,
sessionKey: sampleTaskSessionKey,
runId: "run-1",
} satisfies WorkboardCard;
const client = createClient({
"workboard.cards.list": { cards: [linked], statuses: ["todo", "done"] },
"tasks.list": { tasks: [sampleTask] },
});
await loadWorkboard({ host, client: client as never, force: true });
const state = getWorkboardState(host);
expect(client.request).toHaveBeenCalledWith("tasks.list", { limit: 500 });
expect(state.cards[0]).toMatchObject({ id: "card-1", taskId: "task-1" });
expect(state.tasksByCardId.get("card-1")).toMatchObject({
taskId: "task-1",
status: "running",
});
});
it("links cards from paginated Gateway task results", async () => {
const host = {};
const linked = {
...sampleCard,
sessionKey: sampleTaskSessionKey,
runId: "run-1",
} satisfies WorkboardCard;
const client = createClient((method, params) => {
if (method === "workboard.cards.list") {
return { cards: [linked], statuses: ["todo", "done"] };
}
if (method === "tasks.list" && (params as { cursor?: string }).cursor === "page-2") {
return { tasks: [sampleTask] };
}
if (method === "tasks.list") {
return { tasks: [], nextCursor: "page-2" };
}
return {};
});
await loadWorkboard({ host, client: client as never, force: true });
expect(client.request).toHaveBeenCalledWith("tasks.list", { limit: 500 });
expect(client.request).toHaveBeenCalledWith("tasks.list", {
limit: 500,
cursor: "page-2",
});
expect(getWorkboardState(host).cards[0]).toMatchObject({ taskId: "task-1" });
});
it("links unassigned default-agent tasks with canonicalized session keys", async () => {
const host = {};
const linked = {
...sampleCard,
sessionKey: sampleTaskSessionKey,
runId: "run-1",
} satisfies WorkboardCard;
const client = createClient({
"workboard.cards.list": { cards: [linked], statuses: ["todo", "done"] },
"tasks.list": {
tasks: [
{
...sampleTask,
childSessionKey: `agent:main:${sampleTaskSessionKey}`,
runId: "run-1",
},
],
},
});
await loadWorkboard({ host, client: client as never, force: true });
expect(getWorkboardState(host).cards[0]).toMatchObject({ taskId: "task-1" });
});
it("does not relink a loaded card to a stale task from another session", async () => {
const host = {};
const linked = {
...sampleCard,
sessionKey: "agent:main:dashboard:new",
runId: "run-1",
} satisfies WorkboardCard;
const client = createClient({
"workboard.cards.list": { cards: [linked], statuses: ["todo", "done"] },
"tasks.list": {
tasks: [
{
...sampleTask,
childSessionKey: sampleTaskSessionKey,
runId: "run-1",
},
],
},
});
await loadWorkboard({ host, client: client as never, force: true });
const state = getWorkboardState(host);
expect(state.cards[0]).not.toHaveProperty("taskId");
expect(state.tasksByCardId.has("card-1")).toBe(false);
});
it("preserves automation metadata loaded from the plugin gateway method", async () => {
const host = {};
const client = createClient({
@@ -428,11 +549,18 @@ describe("workboard controller", () => {
);
});
it("starts a session and links it back to the card", async () => {
it("starts a task run and links it back to the card", async () => {
const host = {};
const running = { ...sampleCard, status: "running", sessionKey: "agent:main:dashboard:1" };
const running = {
...sampleCard,
status: "running",
sessionKey: sampleTaskSessionKey,
runId: "run-1",
taskId: "task-1",
};
const client = createClient({
"sessions.create": { key: "agent:main:dashboard:1", runId: "run-1" },
agent: { sessionKey: sampleTaskSessionKey, runId: "run-1" },
"tasks.list": { tasks: [sampleTask] },
"workboard.cards.update": { card: running },
});
@@ -442,7 +570,7 @@ describe("workboard controller", () => {
card: sampleCard,
});
expect(sessionKey).toBe("agent:main:dashboard:1");
expect(sessionKey).toBe(sampleTaskSessionKey);
expect(client.request).toHaveBeenNthCalledWith(
1,
"workboard.cards.update",
@@ -453,25 +581,109 @@ describe("workboard controller", () => {
);
expect(client.request).toHaveBeenNthCalledWith(
2,
"sessions.create",
"agent",
expect.objectContaining({
sessionKey: sampleTaskSessionKey,
label: "Build board (card-1)",
message: expect.stringContaining("Work on this OpenClaw Workboard card: Build board"),
idempotencyKey: "workboard:default:card-1:1",
}),
);
expect(client.request.mock.calls[1]?.[1]).not.toHaveProperty("model");
expect(client.request).toHaveBeenNthCalledWith(3, "tasks.list", { limit: 500 });
expect(client.request).toHaveBeenNthCalledWith(
3,
4,
"workboard.cards.update",
expect.objectContaining({
id: "card-1",
patch: expect.objectContaining({
status: "running",
runId: "run-1",
taskId: "task-1",
}),
}),
);
expect(client.request.mock.calls[2]?.[1]).toHaveProperty("patch.execution", null);
expect(client.request.mock.calls[3]?.[1]).toHaveProperty("patch.execution", null);
});
it("starts reassigned cards with the current task session key", async () => {
const host = {};
const expectedSessionKey = "agent:codex-main:subagent:workboard-default-card-1";
const staleLinked = {
...sampleCard,
agentId: "codex-main",
sessionKey: "agent:old-agent:dashboard:stale",
} satisfies WorkboardCard;
const running = {
...staleLinked,
status: "running",
sessionKey: expectedSessionKey,
runId: "run-1",
taskId: "task-1",
};
const client = createClient({
agent: { sessionKey: expectedSessionKey, runId: "run-1" },
"tasks.list": {
tasks: [{ ...sampleTask, childSessionKey: expectedSessionKey }],
},
"workboard.cards.update": { card: running },
});
const sessionKey = await startWorkboardCard({
host,
client: client as never,
card: staleLinked,
});
expect(sessionKey).toBe(expectedSessionKey);
expect(client.request).toHaveBeenNthCalledWith(
2,
"agent",
expect.objectContaining({
agentId: "codex-main",
sessionKey: expectedSessionKey,
}),
);
});
it("waits briefly for task ledger registration after a started run", async () => {
vi.useFakeTimers();
const host = {};
const running = {
...sampleCard,
status: "running",
sessionKey: sampleTaskSessionKey,
runId: "run-1",
taskId: "task-1",
};
let taskLists = 0;
const client = createClient((method) => {
if (method === "agent") {
return { sessionKey: sampleTaskSessionKey, runId: "run-1" };
}
if (method === "tasks.list") {
taskLists += 1;
return { tasks: taskLists >= 3 ? [sampleTask] : [] };
}
return { card: running };
});
const started = startWorkboardCard({
host,
client: client as never,
card: sampleCard,
});
await vi.advanceTimersByTimeAsync(350);
const sessionKey = await started;
expect(sessionKey).toBe(sampleTaskSessionKey);
expect(taskLists).toBe(3);
expect(client.request).toHaveBeenLastCalledWith(
"workboard.cards.update",
expect.objectContaining({
patch: expect.objectContaining({ taskId: "task-1" }),
}),
);
});
it("lets the gateway preflight decide starts when local parent state is stale", async () => {
@@ -490,8 +702,11 @@ describe("workboard controller", () => {
if (method === "workboard.cards.list") {
return { cards: [parent, child], statuses: ["todo", "running", "done"] };
}
if (method === "sessions.create") {
return { key: "agent:main:dashboard:child" };
if (method === "agent") {
return { sessionKey: "agent:main:dashboard:child", runId: "run-child" };
}
if (method === "tasks.list") {
return { tasks: [] };
}
return { card: running };
});
@@ -538,7 +753,7 @@ describe("workboard controller", () => {
);
});
it("rolls back the running preflight when session creation fails", async () => {
it("rolls back the running preflight when task run creation fails", async () => {
const host = {};
const running = { ...sampleCard, status: "running" } satisfies WorkboardCard;
let updateCalls = 0;
@@ -547,7 +762,7 @@ describe("workboard controller", () => {
updateCalls += 1;
return { card: updateCalls === 1 ? running : sampleCard };
}
if (method === "sessions.create") {
if (method === "agent") {
throw new Error("gateway disconnected");
}
return {};
@@ -595,8 +810,11 @@ describe("workboard controller", () => {
}
return { card: sampleCard };
}
if (method === "sessions.create") {
return { key: "agent:main:dashboard:1", runId: "run-1" };
if (method === "agent") {
return { sessionKey: sampleTaskSessionKey, runId: "run-1" };
}
if (method === "tasks.list") {
return { tasks: [sampleTask] };
}
if (method === "chat.abort") {
return { aborted: true, runIds: ["run-1"] };
@@ -611,16 +829,12 @@ describe("workboard controller", () => {
});
expect(sessionKey).toBeNull();
expect(client.request).toHaveBeenNthCalledWith(5, "chat.abort", {
sessionKey: sampleTaskSessionKey,
runId: "run-1",
});
expect(client.request).toHaveBeenNthCalledWith(
4,
"chat.abort",
{
sessionKey: "agent:main:dashboard:1",
runId: "run-1",
},
);
expect(client.request).toHaveBeenNthCalledWith(
5,
6,
"workboard.cards.update",
expect.objectContaining({
patch: expect.objectContaining({
@@ -743,12 +957,37 @@ describe("workboard controller", () => {
const dueRunning = {
...dueScheduled,
status: "running",
sessionKey: "agent:main:dashboard:1",
sessionKey: "subagent:workboard-default-scheduled-3",
runId: "run-due",
taskId: "task-due",
} satisfies WorkboardCard;
const dueClient = createClient({
"workboard.cards.list": { cards: [dueScheduled], statuses: ["scheduled", "running", "done"] },
"sessions.create": { key: "agent:main:dashboard:1", runId: "run-1" },
"workboard.cards.update": { card: dueRunning },
const dueClient = createClient((method) => {
if (method === "workboard.cards.list") {
return { cards: [dueScheduled], statuses: ["scheduled", "running", "done"] };
}
if (method === "agent") {
return {
sessionKey: "subagent:workboard-default-scheduled-3",
runId: "run-due",
};
}
if (method === "tasks.list") {
return {
tasks: [
{
...sampleTask,
id: "task-due",
taskId: "task-due",
childSessionKey: "subagent:workboard-default-scheduled-3",
runId: "run-due",
},
],
};
}
if (method === "workboard.cards.update") {
return { card: dueRunning };
}
return {};
});
await loadWorkboard({ host, client: dueClient as never, force: true });
dueClient.request.mockClear();
@@ -759,9 +998,9 @@ describe("workboard controller", () => {
card: dueScheduled,
});
expect(dueSessionKey).toBe("agent:main:dashboard:1");
expect(dueSessionKey).toBe("subagent:workboard-default-scheduled-3");
expect(dueClient.request).toHaveBeenCalledWith(
"sessions.create",
"agent",
expect.objectContaining({
label: "Build board (schedule)",
}),
@@ -773,7 +1012,8 @@ describe("workboard controller", () => {
const running = {
...sampleCard,
status: "running",
sessionKey: "agent:main:dashboard:1",
sessionKey: sampleTaskSessionKey,
taskId: "task-1",
execution: {
id: "card-1:codex",
kind: "agent-session",
@@ -781,14 +1021,15 @@ describe("workboard controller", () => {
mode: "autonomous",
status: "running",
model: "openai/gpt-5.5",
sessionKey: "agent:main:dashboard:1",
sessionKey: sampleTaskSessionKey,
runId: "run-1",
startedAt: 10,
updatedAt: 10,
},
};
const client = createClient({
"sessions.create": { key: "agent:main:dashboard:1", runId: "run-1" },
agent: { sessionKey: sampleTaskSessionKey, runId: "run-1" },
"tasks.list": { tasks: [sampleTask] },
"workboard.cards.update": { card: running },
});
@@ -808,14 +1049,16 @@ describe("workboard controller", () => {
);
expect(client.request).toHaveBeenNthCalledWith(
2,
"sessions.create",
"agent",
expect.objectContaining({
sessionKey: sampleTaskSessionKey,
model: "openai/gpt-5.5",
message: expect.stringContaining("Work on this OpenClaw Workboard card: Build board"),
}),
);
expect(client.request).toHaveBeenNthCalledWith(3, "tasks.list", { limit: 500 });
expect(client.request).toHaveBeenNthCalledWith(
3,
4,
"workboard.cards.update",
expect.objectContaining({
id: "card-1",
@@ -852,7 +1095,18 @@ describe("workboard controller", () => {
},
} satisfies WorkboardCard;
const client = createClient({
"sessions.create": { key: "agent:main:dashboard:1", runId: "run-2" },
agent: { sessionKey: "agent:main:dashboard:1", runId: "run-2" },
"tasks.list": {
tasks: [
{
...sampleTask,
taskId: "task-2",
id: "task-2",
childSessionKey: "agent:main:dashboard:1",
runId: "run-2",
},
],
},
"workboard.cards.update": { card: previous },
});
@@ -864,7 +1118,7 @@ describe("workboard controller", () => {
});
expect(client.request).toHaveBeenNthCalledWith(
3,
4,
"workboard.cards.update",
expect.objectContaining({
patch: expect.objectContaining({
@@ -939,16 +1193,86 @@ describe("workboard controller", () => {
);
});
it("blocks a card when the initial session run fails to start", async () => {
it("clears stale task linkage when opening a manual execution", async () => {
const host = {};
const blocked = { ...sampleCard, status: "blocked", sessionKey: "agent:main:dashboard:1" };
const client = createClient({
"sessions.create": {
key: "agent:main:dashboard:1",
runStarted: false,
runError: { message: "provider unavailable" },
const staleLinkedCard = {
...sampleCard,
sessionKey: sampleTaskSessionKey,
runId: "run-1",
taskId: "task-1",
execution: {
id: "card-1:codex",
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "blocked",
model: "openai/gpt-5.5",
sessionKey: sampleTaskSessionKey,
runId: "run-1",
startedAt: 10,
updatedAt: 20,
},
"workboard.cards.update": { card: blocked },
} satisfies WorkboardCard;
const reopened = {
...sampleCard,
sessionKey: "agent:main:dashboard:new",
execution: {
id: "card-1:claude",
kind: "agent-session",
engine: "claude",
mode: "manual",
status: "idle",
model: "anthropic/claude-sonnet-4-6",
sessionKey: "agent:main:dashboard:new",
startedAt: 10,
updatedAt: 10,
},
} satisfies WorkboardCard;
const client = createClient({
"sessions.create": { key: "agent:main:dashboard:new", runStarted: false },
"workboard.cards.update": { card: reopened },
});
getWorkboardState(host).tasksByCardId.set("card-1", sampleTask);
await startWorkboardCard({
host,
client: client as never,
card: staleLinkedCard,
engine: "claude",
mode: "manual",
});
expect(client.request).toHaveBeenNthCalledWith(
2,
"workboard.cards.update",
expect.objectContaining({
id: "card-1",
patch: expect.objectContaining({
sessionKey: "agent:main:dashboard:new",
runId: null,
taskId: null,
}),
}),
);
expect(getWorkboardState(host).tasksByCardId.has("card-1")).toBe(false);
});
it("rolls back when the Gateway does not return a task run id", async () => {
const host = {};
let updateCalls = 0;
const client = createClient((method) => {
if (method === "agent") {
return {
sessionKey: sampleTaskSessionKey,
runStarted: false,
runError: { message: "provider unavailable" },
};
}
if (method === "workboard.cards.update") {
updateCalls += 1;
return { card: updateCalls === 1 ? { ...sampleCard, status: "running" } : sampleCard };
}
return {};
});
const sessionKey = await startWorkboardCard({
@@ -957,20 +1281,14 @@ describe("workboard controller", () => {
card: sampleCard,
});
expect(sessionKey).toBe("agent:main:dashboard:1");
expect(sessionKey).toBeNull();
expect(client.request).toHaveBeenNthCalledWith(2, "agent", expect.any(Object));
expect(client.request).toHaveBeenNthCalledWith(
3,
"workboard.cards.update",
expect.objectContaining({
id: "card-1",
patch: expect.objectContaining({
status: "blocked",
sessionKey: "agent:main:dashboard:1",
}),
}),
expect.objectContaining({ patch: expect.objectContaining({ status: "todo" }) }),
);
expect(client.request.mock.calls[2]?.[1]).toHaveProperty("patch.execution", null);
expect(getWorkboardState(host).error).toBe("Agent run did not start: provider unavailable");
expect(getWorkboardState(host).error).toBe("Gateway agent method returned an invalid runId.");
});
it("moves cards through the plugin gateway method", async () => {
@@ -1128,6 +1446,37 @@ describe("workboard controller", () => {
});
});
it("derives lifecycle state from linked Gateway tasks", () => {
const linked = { ...sampleCard, sessionKey: sampleTaskSessionKey, runId: "run-1" };
expect(getWorkboardLifecycle(linked, [], sampleTask)).toMatchObject({
state: "running",
targetStatus: "running",
});
expect(getWorkboardLifecycle(linked, [], { ...sampleTask, status: "completed" })).toMatchObject(
{
state: "succeeded",
targetStatus: "review",
},
);
expect(getWorkboardLifecycle(linked, [], { ...sampleTask, status: "timed_out" })).toMatchObject(
{
state: "failed",
targetStatus: "blocked",
},
);
expect(
getWorkboardLifecycle(
linked,
[{ ...sampleSession, key: sampleTaskSessionKey, hasActiveRun: false, status: "done" }],
sampleTask,
),
).toMatchObject({
state: "succeeded",
targetStatus: "review",
});
});
it("syncs linked card status from session lifecycle without overriding manual review", async () => {
const host = {};
const state = getWorkboardState(host);
@@ -1160,6 +1509,40 @@ describe("workboard controller", () => {
expect(state.cards.find((card) => card.id === "card-review")?.status).toBe("review");
});
it("refreshes task lifecycle before syncing task-backed cards", async () => {
const host = {};
const state = getWorkboardState(host);
const linked = {
...sampleCard,
status: "running",
sessionKey: sampleTaskSessionKey,
runId: "run-1",
taskId: "task-1",
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
state.tasksByCardId.set("card-1", sampleTask);
const client = createClient({
"tasks.list": { tasks: [{ ...sampleTask, status: "completed" }] },
"workboard.cards.update": {
card: { ...linked, status: "review" },
},
});
await syncWorkboardLifecycle({
host,
client: client as never,
sessions: [],
});
expect(client.request).toHaveBeenNthCalledWith(1, "tasks.list", { limit: 500 });
expect(client.request).toHaveBeenNthCalledWith(2, "workboard.cards.update", {
id: "card-1",
patch: { status: "review" },
});
expect(state.tasksByCardId.get("card-1")).toMatchObject({ status: "completed" });
});
it("moves stale running sessions into running while recording stale metadata", async () => {
const host = {};
const state = getWorkboardState(host);
@@ -1426,6 +1809,109 @@ describe("workboard controller", () => {
expect(getWorkboardState(host).cards[0]).toMatchObject({ status: "blocked" });
});
it("cancels active linked tasks and aborts the running session", async () => {
const host = {};
const linked = {
...sampleCard,
sessionKey: sampleTaskSessionKey,
runId: "run-1",
taskId: "task-1",
};
const blocked = { ...linked, status: "blocked" };
const state = getWorkboardState(host);
state.cards = [linked];
state.tasksByCardId.set("card-1", sampleTask);
const client = createClient({
"tasks.cancel": { cancelled: true },
"chat.abort": { aborted: true, runIds: ["run-1"] },
"workboard.cards.update": { card: blocked },
});
await stopWorkboardCard({ host, client: client as never, card: linked });
expect(client.request).toHaveBeenNthCalledWith(1, "tasks.cancel", {
taskId: "task-1",
reason: "Stopped from Workboard.",
});
expect(client.request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: sampleTaskSessionKey,
runId: "run-1",
});
expect(client.request).toHaveBeenNthCalledWith(3, "workboard.cards.update", {
id: "card-1",
patch: { status: "blocked" },
});
expect(getWorkboardState(host).cards[0]).toMatchObject({ status: "blocked" });
expect(getWorkboardState(host).tasksByCardId.get("card-1")).toMatchObject({
taskId: "task-1",
status: "cancelled",
});
});
it("keeps task-linked cards running when ledger cancel does not abort the session", async () => {
const host = {};
const linked = {
...sampleCard,
sessionKey: sampleTaskSessionKey,
runId: "run-1",
taskId: "task-1",
};
const state = getWorkboardState(host);
state.cards = [linked];
state.tasksByCardId.set("card-1", sampleTask);
const client = createClient({
"tasks.cancel": { cancelled: true },
"chat.abort": { aborted: false, runIds: [] },
});
await stopWorkboardCard({ host, client: client as never, card: linked });
expect(client.request).toHaveBeenNthCalledWith(1, "tasks.cancel", {
taskId: "task-1",
reason: "Stopped from Workboard.",
});
expect(client.request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: sampleTaskSessionKey,
runId: "run-1",
});
expect(client.request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: sampleTaskSessionKey,
});
expect(client.request).toHaveBeenCalledTimes(3);
expect(state.cards).toEqual([linked]);
expect(state.tasksByCardId.get("card-1")).toMatchObject({
taskId: "task-1",
status: "cancelled",
});
});
it("cancels active task-only cards from the local task map", async () => {
const host = {};
const blocked = { ...sampleCard, status: "blocked" };
const state = getWorkboardState(host);
state.cards = [sampleCard];
state.tasksByCardId.set("card-1", sampleTask);
const client = createClient({
"tasks.cancel": { cancelled: true },
"workboard.cards.update": { card: blocked },
});
await stopWorkboardCard({ host, client: client as never, card: sampleCard });
expect(client.request).toHaveBeenNthCalledWith(1, "tasks.cancel", {
taskId: "task-1",
reason: "Stopped from Workboard.",
});
expect(client.request).toHaveBeenNthCalledWith(2, "workboard.cards.update", {
id: "card-1",
patch: { status: "blocked" },
});
expect(getWorkboardState(host).tasksByCardId.get("card-1")).toMatchObject({
taskId: "task-1",
status: "cancelled",
});
});
it("archives cards through the plugin gateway method", async () => {
const host = {};
const archived = {

View File

@@ -291,6 +291,40 @@ export type WorkboardLifecycle = {
targetStatus?: WorkboardStatus;
};
export type WorkboardTaskStatus =
| "queued"
| "running"
| "completed"
| "failed"
| "cancelled"
| "timed_out";
export type WorkboardTaskSummary = {
id: string;
taskId: string;
status: WorkboardTaskStatus;
title?: string;
agentId?: string;
sessionKey?: string;
childSessionKey?: string;
ownerKey?: string;
runId?: string;
sourceId?: string;
updatedAt?: number | string;
progressSummary?: string;
terminalSummary?: string;
error?: string;
};
export type WorkboardDispatchSummary = {
started: number;
failures: number;
promoted: number;
blocked: number;
reclaimed: number;
orchestrated: number;
};
export type WorkboardUiState = {
loading: boolean;
loaded: boolean;
@@ -298,6 +332,8 @@ export type WorkboardUiState = {
error: string | null;
cards: WorkboardCard[];
statuses: readonly WorkboardStatus[];
tasksByCardId: Map<string, WorkboardTaskSummary>;
lastDispatchSummary: WorkboardDispatchSummary | null;
query: string;
priorityFilter: "all" | WorkboardPriority;
draftOpen: boolean;
@@ -315,11 +351,6 @@ export type WorkboardUiState = {
draggedCardId: string | null;
syncingCardIds: Set<string>;
capturingSessionKeys: Set<string>;
gameOpen: boolean;
gamePlayerIndex: number;
gameMoves: number;
gameWins: number;
gameMessage: string;
};
type WorkboardHost = object;
@@ -332,6 +363,8 @@ const SESSION_CAPTURE_TEXT_MAX_CHARS = 700;
const WORKBOARD_CAPTURE_TITLE_MAX_CHARS = 180;
const WORKBOARD_SESSION_LABEL_MAX_CHARS = 512;
const WORKBOARD_STALE_SESSION_MS = 30 * 60 * 1000;
const WORKBOARD_TASKS_LIST_LIMIT = 500;
const WORKBOARD_TASK_LOOKUP_RETRY_DELAYS_MS = [100, 250, 500] as const;
function createDefaultState(): WorkboardUiState {
return {
@@ -341,6 +374,8 @@ function createDefaultState(): WorkboardUiState {
error: null,
cards: [],
statuses: WORKBOARD_STATUSES,
tasksByCardId: new Map(),
lastDispatchSummary: null,
query: "",
priorityFilter: "all",
draftOpen: false,
@@ -358,11 +393,6 @@ function createDefaultState(): WorkboardUiState {
draggedCardId: null,
syncingCardIds: new Set(),
capturingSessionKeys: new Set(),
gameOpen: false,
gamePlayerIndex: 0,
gameMoves: 0,
gameWins: 0,
gameMessage: "workboard.gameStart",
};
}
@@ -870,6 +900,181 @@ function normalizeCardPayload(payload: unknown): WorkboardCard {
return card;
}
function normalizeTaskStatus(value: unknown): WorkboardTaskStatus | null {
switch (value) {
case "queued":
case "running":
case "completed":
case "failed":
case "cancelled":
case "timed_out":
return value;
default:
return null;
}
}
function normalizeTaskSummary(value: unknown): WorkboardTaskSummary | null {
if (!isRecord(value)) {
return null;
}
const id = typeof value.id === "string" && value.id.trim() ? value.id.trim() : null;
const taskId = typeof value.taskId === "string" && value.taskId.trim() ? value.taskId.trim() : id;
const status = normalizeTaskStatus(value.status);
if (!id || !taskId || !status) {
return null;
}
return {
id,
taskId,
status,
...(typeof value.title === "string" ? { title: value.title } : {}),
...(typeof value.agentId === "string" ? { agentId: value.agentId } : {}),
...(typeof value.sessionKey === "string" ? { sessionKey: value.sessionKey } : {}),
...(typeof value.childSessionKey === "string"
? { childSessionKey: value.childSessionKey }
: {}),
...(typeof value.ownerKey === "string" ? { ownerKey: value.ownerKey } : {}),
...(typeof value.runId === "string" ? { runId: value.runId } : {}),
...(typeof value.sourceId === "string" ? { sourceId: value.sourceId } : {}),
...(typeof value.updatedAt === "number" || typeof value.updatedAt === "string"
? { updatedAt: value.updatedAt }
: {}),
...(typeof value.progressSummary === "string"
? { progressSummary: value.progressSummary }
: {}),
...(typeof value.terminalSummary === "string"
? { terminalSummary: value.terminalSummary }
: {}),
...(typeof value.error === "string" ? { error: value.error } : {}),
};
}
function normalizeTasksPage(payload: unknown): {
tasks: WorkboardTaskSummary[];
nextCursor: string | null;
} {
if (!isRecord(payload) || !Array.isArray(payload.tasks)) {
return { tasks: [], nextCursor: null };
}
return {
tasks: payload.tasks
.map(normalizeTaskSummary)
.filter((task): task is WorkboardTaskSummary => task !== null),
nextCursor:
typeof payload.nextCursor === "string" && payload.nextCursor.trim()
? payload.nextCursor.trim()
: null,
};
}
async function listWorkboardTasks(client: GatewayBrowserClient): Promise<WorkboardTaskSummary[]> {
const tasks: WorkboardTaskSummary[] = [];
const seenCursors = new Set<string>();
let cursor: string | null = null;
while (true) {
const payload = await client.request("tasks.list", {
limit: WORKBOARD_TASKS_LIST_LIMIT,
...(cursor ? { cursor } : {}),
});
const page = normalizeTasksPage(payload);
tasks.push(...page.tasks);
if (!page.nextCursor || seenCursors.has(page.nextCursor)) {
return tasks;
}
seenCursors.add(page.nextCursor);
cursor = page.nextCursor;
}
}
function taskUpdatedAtValue(task: WorkboardTaskSummary): number {
if (typeof task.updatedAt === "number") {
return task.updatedAt;
}
if (typeof task.updatedAt === "string") {
const parsed = Date.parse(task.updatedAt);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
function taskSessionKeyMatchesCardSession(
cardSessionKey: string,
taskSessionKey: string | undefined,
): boolean {
if (!taskSessionKey) {
return false;
}
if (taskSessionKey === cardSessionKey) {
return true;
}
return (
cardSessionKey.startsWith("subagent:workboard-") &&
taskSessionKey.endsWith(`:${cardSessionKey}`)
);
}
function taskMatchesCard(task: WorkboardTaskSummary, card: WorkboardCard): boolean {
const cardTaskId = normalizeString(card.taskId);
if (cardTaskId && (task.taskId === cardTaskId || task.id === cardTaskId)) {
return true;
}
const cardSessionKey = workboardCardSessionKey(card);
const taskSessionMatches = cardSessionKey
? [task.sessionKey, task.childSessionKey, task.ownerKey].some((taskSessionKey) =>
taskSessionKeyMatchesCardSession(cardSessionKey, taskSessionKey),
)
: false;
const cardRunId = workboardCardRunId(card);
if (cardRunId && task.runId === cardRunId) {
return cardSessionKey ? taskSessionMatches : true;
}
return taskSessionMatches;
}
function applyTaskSummariesToState(
state: WorkboardUiState,
tasks: readonly WorkboardTaskSummary[],
) {
const tasksByCardId = new Map<string, WorkboardTaskSummary>();
const cards = state.cards.map((card) => {
const matches = tasks.filter((task) => taskMatchesCard(task, card));
if (matches.length === 0) {
return card;
}
const task = matches.toSorted(
(left, right) => taskUpdatedAtValue(right) - taskUpdatedAtValue(left),
)[0];
if (!task) {
return card;
}
tasksByCardId.set(card.id, task);
if (card.taskId === task.taskId) {
return card;
}
return { ...card, taskId: task.taskId };
});
state.cards = cards;
state.tasksByCardId = tasksByCardId;
}
function shouldRefreshWorkboardTasksForLifecycle(state: WorkboardUiState): boolean {
return state.tasksByCardId.size > 0 || state.cards.some((card) => Boolean(card.taskId));
}
function normalizeDispatchSummary(value: unknown): WorkboardDispatchSummary {
const countArray = (key: string) =>
isRecord(value) && Array.isArray(value[key]) ? value[key].length : 0;
return {
started: countArray("started"),
failures: countArray("startFailures"),
promoted: countArray("promoted"),
blocked: countArray("blocked"),
reclaimed: countArray("reclaimed"),
orchestrated: countArray("orchestrated"),
};
}
export async function loadWorkboard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
@@ -896,6 +1101,10 @@ export async function loadWorkboard(params: {
const normalized = normalizeCardsPayload(payload);
state.cards = normalized.cards;
state.statuses = normalized.statuses;
state.tasksByCardId = new Map();
if (state.cards.length > 0) {
applyTaskSummariesToState(state, await listWorkboardTasks(client));
}
state.loaded = true;
} catch (error) {
state.error = formatError(error);
@@ -1014,8 +1223,42 @@ function workboardCardRunId(card: WorkboardCard): string | undefined {
export function getWorkboardLifecycle(
card: WorkboardCard,
sessions: readonly GatewaySessionRow[],
task?: WorkboardTaskSummary,
): WorkboardLifecycle {
const session = findWorkboardSession(card, sessions);
if (task) {
switch (task.status) {
case "queued":
case "running":
if (
session &&
(session.abortedLastRun ||
session.status === "done" ||
isFailedSessionStatus(session.status))
) {
break;
}
return {
session,
state: "running",
targetStatus: "running",
};
case "completed":
return {
session,
state: "succeeded",
targetStatus: "review",
};
case "failed":
case "cancelled":
case "timed_out":
return {
session,
state: "failed",
targetStatus: "blocked",
};
}
}
if (!workboardCardSessionKey(card)) {
return { session: null, state: "unlinked" };
}
@@ -1305,9 +1548,22 @@ export async function syncWorkboardLifecycle(params: {
if (!params.client || !state.loaded || params.canWrite === false) {
return;
}
if (shouldRefreshWorkboardTasksForLifecycle(state)) {
try {
applyTaskSummariesToState(state, await listWorkboardTasks(params.client));
} catch (error) {
state.tasksByCardId = new Map();
state.error = formatError(error);
params.requestUpdate?.();
}
}
const syncKeys = getLifecycleSyncKeys(params.host);
for (const card of state.cards) {
const lifecycle = getWorkboardLifecycle(card, params.sessions);
const lifecycle = getWorkboardLifecycle(
card,
params.sessions,
state.tasksByCardId.get(card.id),
);
const executionStatus = executionStatusForLifecycle(lifecycle);
const patch: Record<string, unknown> = {};
if (shouldSyncCardStatus(card, lifecycle.targetStatus)) {
@@ -1542,13 +1798,16 @@ export async function dispatchWorkboard(params: {
}
state.loading = true;
state.error = null;
state.lastDispatchSummary = null;
params.requestUpdate?.();
try {
await params.client.request("workboard.cards.dispatch", {});
const dispatchResult = await params.client.request("workboard.cards.dispatch", {});
const payload = await params.client.request("workboard.cards.list", {});
const normalized = normalizeCardsPayload(payload);
state.cards = normalized.cards;
state.statuses = normalized.statuses;
state.lastDispatchSummary = normalizeDispatchSummary(dispatchResult);
applyTaskSummariesToState(state, await listWorkboardTasks(params.client));
state.loaded = true;
} catch (error) {
state.error = formatError(error);
@@ -1594,6 +1853,32 @@ function buildCardSessionLabel(card: WorkboardCard): string {
return `${title.slice(0, titleMax - 3).trimEnd()}...${suffixText}`;
}
function sanitizeSessionSegment(value: string | undefined, fallback: string): string {
const sanitized = (value ?? fallback)
.trim()
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return (sanitized || fallback).slice(0, 96);
}
function buildCardTaskSessionKey(card: WorkboardCard): string {
const boardId = sanitizeSessionSegment(card.metadata?.automation?.boardId, "default");
const cardId = sanitizeSessionSegment(card.id, "card");
const suffix = `subagent:workboard-${boardId}-${cardId}`;
const sessionKey = card.agentId
? `agent:${sanitizeSessionSegment(card.agentId, "agent")}:${suffix}`
: suffix;
const existing = workboardCardSessionKey(card)?.trim();
return existing === sessionKey ? existing : sessionKey;
}
function buildCardRunIdempotencyKey(card: WorkboardCard): string {
const boardId = sanitizeSessionSegment(card.metadata?.automation?.boardId, "default");
const cardId = sanitizeSessionSegment(card.id, "card");
return `workboard:${boardId}:${cardId}:${card.updatedAt}`;
}
function isScheduledForLater(card: WorkboardCard, now = Date.now()): boolean {
const scheduledAt = card.metadata?.automation?.scheduledAt;
if (typeof scheduledAt === "number") {
@@ -1625,6 +1910,35 @@ function buildWorkboardExecution(params: {
};
}
async function findTaskForStartedRun(params: {
client: GatewayBrowserClient;
card: WorkboardCard;
sessionKey: string;
runId?: string;
}): Promise<WorkboardTaskSummary | null> {
const probeCard = {
...params.card,
taskId: undefined,
sessionKey: params.sessionKey,
...(params.runId ? { runId: params.runId } : {}),
};
for (const delayMs of [0, ...WORKBOARD_TASK_LOOKUP_RETRY_DELAYS_MS]) {
if (delayMs > 0) {
await new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
}
const task =
(await listWorkboardTasks(params.client))
.filter((candidate) => taskMatchesCard(candidate, probeCard))
.toSorted((left, right) => taskUpdatedAtValue(right) - taskUpdatedAtValue(left))[0] ?? null;
if (task) {
return task;
}
}
return null;
}
async function abortWorkboardSessionRun(params: {
client: GatewayBrowserClient;
sessionKey: string;
@@ -1650,6 +1964,24 @@ async function abortWorkboardSessionRun(params: {
return aborted;
}
function taskIsActive(task: WorkboardTaskSummary | undefined): task is WorkboardTaskSummary {
return task?.status === "queued" || task?.status === "running";
}
async function cancelWorkboardTaskRun(params: {
client: GatewayBrowserClient;
taskId: string;
}): Promise<{ cancelled: boolean; task: WorkboardTaskSummary | null }> {
const result = await params.client.request("tasks.cancel", {
taskId: params.taskId,
reason: "Stopped from Workboard.",
});
return {
cancelled: isRecord(result) && result.cancelled === true,
task: isRecord(result) ? normalizeTaskSummary(result.task) : null,
};
}
export async function startWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
@@ -1694,59 +2026,57 @@ export async function startWorkboardCard(params: {
card = preflightCard;
}
}
const created = await params.client.request("sessions.create", {
...(card.agentId ? { agentId: card.agentId } : {}),
label: buildCardSessionLabel(card),
...(engine ? { model: WORKBOARD_ENGINE_MODELS[engine] } : {}),
...(mode === "autonomous" ? { message: buildCardPrompt(card) } : {}),
});
const created =
mode === "autonomous"
? await params.client.request("agent", {
sessionKey: buildCardTaskSessionKey(card),
...(card.agentId ? { agentId: card.agentId } : {}),
label: buildCardSessionLabel(card),
...(engine ? { model: WORKBOARD_ENGINE_MODELS[engine] } : {}),
message: buildCardPrompt(card),
deliver: false,
bootstrapContextMode: "lightweight",
idempotencyKey: buildCardRunIdempotencyKey(card),
})
: await params.client.request("sessions.create", {
...(card.agentId ? { agentId: card.agentId } : {}),
label: buildCardSessionLabel(card),
...(engine ? { model: WORKBOARD_ENGINE_MODELS[engine] } : {}),
});
const sessionKey =
isRecord(created) && typeof created.key === "string" && created.key.trim()
? created.key.trim()
: null;
isRecord(created) && typeof created.sessionKey === "string" && created.sessionKey.trim()
? created.sessionKey.trim()
: isRecord(created) && typeof created.key === "string" && created.key.trim()
? created.key.trim()
: mode === "autonomous"
? buildCardTaskSessionKey(card)
: null;
const runId =
isRecord(created) && typeof created.runId === "string" && created.runId.trim()
? created.runId.trim()
: undefined;
if (mode === "autonomous" && !runId) {
throw new Error("Gateway agent method returned an invalid runId.");
}
createdSessionKey = sessionKey;
createdRunId = runId;
const initialRunFailed =
mode === "autonomous" && isRecord(created) && created.runStarted === false;
if (initialRunFailed) {
const payload = await params.client.request("workboard.cards.update", {
id: params.card.id,
patch: {
status: "blocked",
...(sessionKey ? { sessionKey } : {}),
...(engine
? {
execution: buildWorkboardExecution({
card,
engine,
mode,
sessionKey,
status: "blocked",
}),
}
: { execution: null }),
},
});
replaceCard(state, normalizeCardPayload(payload));
const errorText =
isRecord(created) && "runError" in created ? formatError(created.runError) : "";
state.error =
errorText && errorText !== "Unknown workboard error."
? `Agent run did not start: ${errorText}`
: "Agent run did not start.";
return sessionKey;
}
const task =
mode === "autonomous" && sessionKey
? await findTaskForStartedRun({
client: params.client,
card,
sessionKey,
runId,
})
: null;
const payload = await params.client.request("workboard.cards.update", {
id: params.card.id,
patch: {
status: nextCardStatus,
...(shouldClearManualSchedule ? { scheduledAt: null } : {}),
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
runId: runId ?? null,
taskId: task?.taskId ?? null,
...(engine
? {
execution: buildWorkboardExecution({
@@ -1762,6 +2092,11 @@ export async function startWorkboardCard(params: {
},
});
replaceCard(state, normalizeCardPayload(payload));
if (task) {
state.tasksByCardId.set(params.card.id, task);
} else {
state.tasksByCardId.delete(params.card.id);
}
return sessionKey;
} catch (error) {
if (mode === "autonomous" && createdSessionKey) {
@@ -1807,19 +2142,41 @@ export async function stopWorkboardCard(params: {
}) {
const state = getWorkboardState(params.host);
const sessionKey = workboardCardSessionKey(params.card);
if (!params.client || !sessionKey) {
const task = state.tasksByCardId.get(params.card.id);
const taskId = params.card.taskId ?? task?.taskId;
if (!params.client || (!sessionKey && !taskId)) {
return;
}
state.busyCardId = params.card.id;
state.error = null;
params.requestUpdate?.();
try {
const aborted = await abortWorkboardSessionRun({
client: params.client,
sessionKey,
runId: workboardCardRunId(params.card),
});
if (!aborted) {
let taskCancelled = false;
if (taskId && taskIsActive(task)) {
const cancelled = await cancelWorkboardTaskRun({
client: params.client,
taskId,
});
taskCancelled = cancelled.cancelled;
if (cancelled.cancelled) {
state.tasksByCardId.set(
params.card.id,
cancelled.task ?? {
...task,
status: "cancelled",
updatedAt: Date.now(),
},
);
}
}
const sessionAborted = sessionKey
? await abortWorkboardSessionRun({
client: params.client,
sessionKey,
runId: workboardCardRunId(params.card),
})
: false;
if (sessionKey ? !sessionAborted : !taskCancelled) {
return;
}
const payload = await params.client.request("workboard.cards.update", {

View File

@@ -1,320 +0,0 @@
import type * as THREE from "three";
type ThreeModule = typeof THREE;
type WorkboardGameSceneObjects = {
camera: THREE.PerspectiveCamera;
player: THREE.Group;
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
};
const TILE_SPACING = 1.05;
function parseIntegerAttribute(element: Element, name: string, fallback: number): number {
const value = Number.parseInt(element.getAttribute(name) ?? "", 10);
return Number.isFinite(value) ? value : fallback;
}
function parseBlockers(value: string | null): Set<number> {
if (!value) {
return new Set();
}
return new Set(
value
.split(",")
.map((entry) => Number.parseInt(entry.trim(), 10))
.filter((entry) => Number.isFinite(entry)),
);
}
function boardPosition(index: number, size: number, three: ThreeModule) {
const row = Math.floor(index / size);
const column = index % size;
const center = (size - 1) / 2;
return new three.Vector3((column - center) * TILE_SPACING, 0.25, (row - center) * TILE_SPACING);
}
class Workboard3dGameElement extends HTMLElement {
static get observedAttributes() {
return ["blockers", "board-size", "goal-index", "player-index", "wins"];
}
private animationFrame = 0;
private canvas: HTMLCanvasElement | null = null;
private sceneObjects: WorkboardGameSceneObjects | null = null;
private resizeObserver: ResizeObserver | null = null;
private startVersion = 0;
private three: ThreeModule | null = null;
connectedCallback() {
this.ensureDom();
void this.start();
}
disconnectedCallback() {
this.startVersion += 1;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = 0;
}
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.disposeSceneObjects();
}
attributeChangedCallback() {
this.updateScene();
}
private ensureDom() {
if (this.canvas) {
return;
}
this.replaceChildren();
this.canvas = document.createElement("canvas");
this.canvas.className = "workboard-game__canvas";
this.canvas.setAttribute("aria-hidden", "true");
this.append(this.canvas);
this.renderFallback();
}
private renderFallback() {
const size = parseIntegerAttribute(this, "board-size", 5);
const goalIndex = parseIntegerAttribute(this, "goal-index", size * size - 1);
const playerIndex = parseIntegerAttribute(this, "player-index", 0);
const blockers = parseBlockers(this.getAttribute("blockers"));
const fallback = document.createElement("div");
fallback.className = "workboard-game__fallback";
fallback.setAttribute("aria-hidden", "true");
for (let index = 0; index < size * size; index += 1) {
const tile = document.createElement("span");
tile.className = [
"workboard-game__fallback-tile",
index === playerIndex ? "workboard-game__fallback-tile--player" : "",
index === goalIndex ? "workboard-game__fallback-tile--goal" : "",
blockers.has(index) ? "workboard-game__fallback-tile--blocker" : "",
]
.filter(Boolean)
.join(" ");
fallback.append(tile);
}
this.querySelector(".workboard-game__fallback")?.remove();
this.append(fallback);
}
private resetToFallback() {
this.classList.remove("workboard-game__scene--ready");
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = 0;
}
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.disposeSceneObjects();
this.renderFallback();
}
private disposeSceneObjects() {
const objects = this.sceneObjects;
if (!objects) {
return;
}
const geometries = new Set<THREE.BufferGeometry>();
const materials = new Set<THREE.Material>();
objects.scene.traverse((object) => {
const mesh = object as THREE.Mesh;
if (mesh.geometry) {
geometries.add(mesh.geometry);
}
const material = mesh.material;
if (Array.isArray(material)) {
for (const entry of material) {
materials.add(entry);
}
} else if (material) {
materials.add(material);
}
});
for (const geometry of geometries) {
geometry.dispose();
}
for (const material of materials) {
material.dispose();
}
objects.renderer.dispose();
this.sceneObjects = null;
}
private async start() {
this.ensureDom();
const canvas = this.canvas;
if (!canvas || this.sceneObjects) {
return;
}
const startVersion = ++this.startVersion;
try {
const context = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
if (!context) {
this.renderFallback();
return;
}
this.three = await import("three");
if (!this.isConnected || startVersion !== this.startVersion || this.sceneObjects) {
return;
}
const three = this.three;
const renderer = new three.WebGLRenderer({
alpha: true,
antialias: true,
canvas,
context,
preserveDrawingBuffer: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const scene = new three.Scene();
const camera = new three.PerspectiveCamera(42, 1, 0.1, 100);
const player = new three.Group();
this.sceneObjects = { camera, player, renderer, scene };
this.buildScene();
this.resize();
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(this);
this.classList.add("workboard-game__scene--ready");
this.renderFrame();
} catch {
this.resetToFallback();
}
}
private resize() {
const { camera, renderer } = this.sceneObjects ?? {};
if (!camera || !renderer) {
return;
}
const width = Math.max(1, this.clientWidth);
const height = Math.max(1, this.clientHeight);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
}
private buildScene() {
const three = this.three;
const objects = this.sceneObjects;
if (!three || !objects) {
return;
}
const { camera, player, scene } = objects;
scene.clear();
const size = parseIntegerAttribute(this, "board-size", 5);
const goalIndex = parseIntegerAttribute(this, "goal-index", size * size - 1);
const blockers = parseBlockers(this.getAttribute("blockers"));
const baseMaterial = new three.MeshStandardMaterial({
color: 0x21313b,
metalness: 0.28,
roughness: 0.58,
});
const openMaterial = new three.MeshStandardMaterial({
color: 0x2f4650,
metalness: 0.24,
roughness: 0.5,
});
const blockerMaterial = new three.MeshStandardMaterial({
color: 0xe55b4d,
emissive: 0x3b0804,
metalness: 0.18,
roughness: 0.42,
});
const goalMaterial = new three.MeshStandardMaterial({
color: 0x52d273,
emissive: 0x123d1c,
metalness: 0.2,
roughness: 0.36,
});
const playerMaterial = new three.MeshStandardMaterial({
color: 0x67a6ff,
emissive: 0x112b62,
metalness: 0.34,
roughness: 0.26,
});
const tileGeometry = new three.BoxGeometry(0.94, 0.14, 0.94);
const blockerGeometry = new three.ConeGeometry(0.28, 0.7, 5);
for (let index = 0; index < size * size; index += 1) {
const tile = new three.Mesh(
tileGeometry,
index === goalIndex ? goalMaterial : blockers.has(index) ? baseMaterial : openMaterial,
);
const position = boardPosition(index, size, three);
tile.position.set(position.x, 0, position.z);
tile.receiveShadow = true;
scene.add(tile);
if (blockers.has(index)) {
const blocker = new three.Mesh(blockerGeometry, blockerMaterial);
blocker.position.set(position.x, 0.42, position.z);
blocker.rotation.y = index * 0.61;
scene.add(blocker);
}
}
const goalPosition = boardPosition(goalIndex, size, three);
const ring = new three.Mesh(new three.TorusGeometry(0.34, 0.045, 10, 28), goalMaterial);
ring.position.set(goalPosition.x, 0.25, goalPosition.z);
ring.rotation.x = Math.PI / 2;
scene.add(ring);
player.clear();
const body = new three.Mesh(new three.IcosahedronGeometry(0.29, 1), playerMaterial);
const base = new three.Mesh(new three.CylinderGeometry(0.24, 0.3, 0.16, 16), playerMaterial);
base.position.y = -0.2;
player.add(body, base);
scene.add(player);
scene.add(new three.AmbientLight(0xa8c7ff, 0.7));
const keyLight = new three.DirectionalLight(0xffffff, 1.75);
keyLight.position.set(-3.5, 6, 4);
scene.add(keyLight);
const rimLight = new three.PointLight(0x67a6ff, 6, 9);
rimLight.position.set(3, 2.8, -4);
scene.add(rimLight);
camera.position.set(3.4, 5.5, 6.8);
camera.lookAt(0, 0, 0);
this.updateScene();
}
private updateScene() {
this.renderFallback();
const three = this.three;
const { player } = this.sceneObjects ?? {};
if (!three || !player) {
return;
}
const size = parseIntegerAttribute(this, "board-size", 5);
const playerIndex = parseIntegerAttribute(this, "player-index", 0);
const target = boardPosition(playerIndex, size, three);
player.position.copy(target);
}
private renderFrame = () => {
const three = this.three;
const objects = this.sceneObjects;
if (!three || !objects) {
return;
}
const size = parseIntegerAttribute(this, "board-size", 5);
const playerIndex = parseIntegerAttribute(this, "player-index", 0);
const target = boardPosition(playerIndex, size, three);
objects.player.position.lerp(target, 0.2);
objects.player.rotation.y += 0.035;
objects.player.position.y = target.y + Math.sin(performance.now() / 210) * 0.06;
const orbit = performance.now() / 8200;
objects.camera.position.x = Math.sin(orbit) * 1.2 + 3.4;
objects.camera.position.z = Math.cos(orbit) * 1.2 + 6.8;
objects.camera.lookAt(0, 0, 0);
objects.renderer.render(objects.scene, objects.camera);
this.animationFrame = requestAnimationFrame(this.renderFrame);
};
}
if ("customElements" in globalThis && !customElements.get("workboard-3d-game")) {
customElements.define("workboard-3d-game", Workboard3dGameElement);
}

View File

@@ -197,6 +197,204 @@ describe("renderWorkboard", () => {
expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBeNull();
});
it("hides autonomous model override actions for non-admin operators", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Start with default model",
status: "todo",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
},
];
const container = document.createElement("div");
render(
renderWorkboard({
host,
client: null,
connected: true,
canModelOverride: false,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
const startButtons = [
...container.querySelectorAll<HTMLButtonElement>(".workboard-card__start"),
];
expect(startButtons.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", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Review task result",
status: "running",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
sessionKey: "agent:main:subagent:workboard-default-card-1",
runId: "run-1",
taskId: "task-1",
},
];
state.tasksByCardId.set("card-1", {
id: "task-1",
taskId: "task-1",
status: "completed",
title: "Review task result",
childSessionKey: "agent:main:subagent:workboard-default-card-1",
runId: "run-1",
terminalSummary: "Ready for operator review.",
});
const container = document.createElement("div");
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
expect(container.textContent).toContain("task linked");
expect(container.textContent).toContain("Task complete");
expect(container.textContent).toContain("Ready for operator review.");
});
it("uses terminal session lifecycle when cached task status is stale", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Finished despite stale task",
status: "running",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
sessionKey: "agent:main:subagent:workboard-default-card-1",
runId: "run-1",
taskId: "task-1",
},
];
state.tasksByCardId.set("card-1", {
id: "task-1",
taskId: "task-1",
status: "running",
title: "Finished despite stale task",
childSessionKey: "agent:main:subagent:workboard-default-card-1",
runId: "run-1",
progressSummary: "Still running according to stale cache.",
});
const container = document.createElement("div");
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,
);
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.");
});
it("shows stop controls without start controls for active task-only cards", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Task only run",
status: "running",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
taskId: "task-1",
},
];
state.tasksByCardId.set("card-1", {
id: "task-1",
taskId: "task-1",
status: "running",
title: "Task only run",
progressSummary: "Worker is active.",
});
const container = document.createElement("div");
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
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();
});
it("hides write controls for read-only operators", () => {
const host = {};
const state = getWorkboardState(host);
@@ -347,47 +545,6 @@ describe("renderWorkboard", () => {
).toContain("Verification:");
});
it("opens and plays the mini game", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
const container = document.createElement("div");
const props = {
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
};
render(renderWorkboard(props), container);
[...container.querySelectorAll<HTMLButtonElement>(".workboard-toolbar__actions .btn")]
.find((button) => button.textContent?.includes("Mini game"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(renderWorkboard(props), container);
expect(container.querySelector('[role="dialog"]')?.textContent).toContain("Card Chase");
expect(container.querySelector("workboard-3d-game")?.getAttribute("player-index")).toBe("0");
expect(container.querySelector("workboard-3d-game")?.getAttribute("aria-hidden")).toBe("true");
expect(container.querySelector('[role="grid"]')?.getAttribute("aria-label")).toBe(
"Card Chase board",
);
expect(container.querySelector('[role="gridcell"][aria-label="Agent 1"]')).not.toBeNull();
const controls = container.querySelector<HTMLElement>(".workboard-game__controls");
controls
?.querySelector<HTMLButtonElement>('button[aria-label="Move right"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(renderWorkboard(props), container);
expect(state.gamePlayerIndex).toBe(1);
expect(container.querySelector("workboard-3d-game")?.getAttribute("player-index")).toBe("1");
expect(container.querySelector('[role="gridcell"][aria-label="Agent 2"]')).not.toBeNull();
expect(container.querySelector(".workboard-game__stats")?.textContent).toContain("Moves 1");
});
it("renders card event history", () => {
const host = {};
const state = getWorkboardState(host);

View File

@@ -22,6 +22,7 @@ import {
type WorkboardLifecycle,
type WorkboardPriority,
type WorkboardStatus,
type WorkboardTaskSummary,
type WorkboardTemplateId,
type WorkboardUiState,
} from "../controllers/workboard.ts";
@@ -29,13 +30,13 @@ import { formatDateMs } from "../format.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import { icons } from "../icons.ts";
import type { AgentsListResult, GatewaySessionRow } from "../types.ts";
import "./workboard-3d-game.ts";
type WorkboardProps = {
host: object;
client: GatewayBrowserClient | null;
connected: boolean;
canWrite?: boolean;
canModelOverride?: boolean;
pluginEnabled: boolean;
agentsList: AgentsListResult | null;
sessions: GatewaySessionRow[];
@@ -43,9 +44,6 @@ type WorkboardProps = {
onRequestUpdate?: () => void;
};
const WORKBOARD_GAME_SIZE = 5;
const WORKBOARD_GAME_GOAL = WORKBOARD_GAME_SIZE * WORKBOARD_GAME_SIZE - 1;
const WORKBOARD_GAME_BLOCKERS = new Set([6, 8, 12, 16, 18]);
const WORKBOARD_TEMPLATES: Array<{
id: WorkboardTemplateId;
title: string;
@@ -189,52 +187,50 @@ function renderEvents(card: WorkboardCard) {
function renderMetadataBadges(card: WorkboardCard) {
const metadata = card.metadata;
if (!metadata) {
return nothing;
}
const badges = [
metadata.templateId ? t(`workboard.template.${metadata.templateId}`) : null,
metadata.attempts?.length
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,
metadata.failureCount
metadata?.failureCount
? t("workboard.badgeFailures", { count: String(metadata.failureCount) })
: null,
metadata.comments?.length
metadata?.comments?.length
? t("workboard.badgeComments", { count: String(metadata.comments.length) })
: null,
metadata.links?.length
metadata?.links?.length
? t("workboard.badgeLinks", { count: String(metadata.links.length) })
: null,
metadata.proof?.length
metadata?.proof?.length
? t("workboard.badgeProof", { count: String(metadata.proof.length) })
: null,
metadata.artifacts?.length
metadata?.artifacts?.length
? t("workboard.badgeArtifacts", { count: String(metadata.artifacts.length) })
: null,
metadata.attachments?.length
metadata?.attachments?.length
? t("workboard.badgeAttachments", { count: String(metadata.attachments.length) })
: null,
metadata.workerLogs?.length
metadata?.workerLogs?.length
? t("workboard.badgeWorkerLogs", { count: String(metadata.workerLogs.length) })
: null,
metadata.workerProtocol?.state
metadata?.workerProtocol?.state
? t("workboard.badgeWorkerProtocol", { state: metadata.workerProtocol.state })
: null,
metadata.automation?.tenant
metadata?.automation?.tenant
? t("workboard.badgeTenant", { tenant: metadata.automation.tenant })
: null,
metadata.automation?.skills?.length
metadata?.automation?.skills?.length
? t("workboard.badgeSkills", { count: String(metadata.automation.skills.length) })
: null,
metadata.automation?.dispatchCount
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
metadata?.claim ? t("workboard.badgeClaimed", { owner: metadata.claim.ownerId }) : null,
metadata?.diagnostics?.length
? t("workboard.badgeDiagnostics", { count: String(metadata.diagnostics.length) })
: null,
metadata.stale ? t("workboard.badgeStale") : null,
metadata?.stale ? t("workboard.badgeStale") : null,
].filter((badge): badge is string => Boolean(badge));
if (badges.length === 0) {
return nothing;
@@ -361,41 +357,6 @@ function openCreateModal(state: WorkboardUiState) {
state.draftOpen = true;
}
function resetGame(state: WorkboardUiState) {
state.gamePlayerIndex = 0;
state.gameMoves = 0;
state.gameMessage = "workboard.gameStart";
}
function moveGamePlayer(state: WorkboardUiState, delta: number) {
if (state.gamePlayerIndex === WORKBOARD_GAME_GOAL) {
resetGame(state);
}
const currentRow = Math.floor(state.gamePlayerIndex / WORKBOARD_GAME_SIZE);
const nextIndex = state.gamePlayerIndex + delta;
const nextRow = Math.floor(nextIndex / WORKBOARD_GAME_SIZE);
if (
nextIndex < 0 ||
nextIndex > WORKBOARD_GAME_GOAL ||
(delta === -1 && nextRow !== currentRow) ||
(delta === 1 && nextRow !== currentRow)
) {
state.gameMessage = "workboard.gameBoundary";
return;
}
if (WORKBOARD_GAME_BLOCKERS.has(nextIndex)) {
state.gameMessage = "workboard.gameBlocked";
return;
}
state.gamePlayerIndex = nextIndex;
state.gameMoves += 1;
state.gameMessage =
nextIndex === WORKBOARD_GAME_GOAL ? "workboard.gameWin" : "workboard.gameContinue";
if (nextIndex === WORKBOARD_GAME_GOAL) {
state.gameWins += 1;
}
}
function openEditModal(state: WorkboardUiState, card: WorkboardCard) {
state.draftOpen = true;
state.editingCardId = card.id;
@@ -422,148 +383,6 @@ function applyTemplate(state: WorkboardUiState, templateId: WorkboardTemplateId)
state.draftPriority = template.priority;
}
function renderGameArrow(
label: string,
className: string,
delta: number,
props: Pick<WorkboardProps, "host" | "onRequestUpdate">,
) {
const state = getWorkboardState(props.host);
return html`
<button
class="btn btn--icon workboard-game__arrow ${className}"
type="button"
title=${label}
aria-label=${label}
@click=${() => {
moveGamePlayer(state, delta);
props.onRequestUpdate?.();
}}
>
${icons.arrowDown}
</button>
`;
}
function gameCellLabel(state: WorkboardUiState, index: number) {
const label =
index === state.gamePlayerIndex
? t("workboard.gameAgent")
: index === WORKBOARD_GAME_GOAL
? t("workboard.gameLaunch")
: WORKBOARD_GAME_BLOCKERS.has(index)
? t("workboard.gameBlockedCell")
: t("workboard.gameOpenCell");
return `${label} ${index + 1}`;
}
function renderAccessibleGameGrid(state: WorkboardUiState) {
return html`
<div class="workboard-game__accessible-grid" role="grid" aria-label=${t("workboard.gameBoard")}>
${Array.from({ length: WORKBOARD_GAME_SIZE * WORKBOARD_GAME_SIZE }, (_, index) => {
return html`<span role="gridcell" aria-label=${gameCellLabel(state, index)}></span>`;
})}
</div>
`;
}
function renderGameModal(props: WorkboardProps) {
const state = getWorkboardState(props.host);
if (!state.gameOpen) {
return nothing;
}
return html`
<div
class="workboard-modal"
role="presentation"
@click=${(event: MouseEvent) => {
if (event.target === event.currentTarget) {
state.gameOpen = false;
props.onRequestUpdate?.();
}
}}
>
<div
class="workboard-game"
role="dialog"
aria-modal="true"
aria-labelledby="workboard-game-title"
tabindex="0"
@keydown=${(event: KeyboardEvent) => {
const moves: Record<string, number> = {
ArrowDown: WORKBOARD_GAME_SIZE,
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -WORKBOARD_GAME_SIZE,
};
const delta = moves[event.key];
if (typeof delta !== "number") {
return;
}
event.preventDefault();
moveGamePlayer(state, delta);
props.onRequestUpdate?.();
}}
>
<div class="workboard-modal__header">
<div>
<h2 id="workboard-game-title">${t("workboard.gameTitle")}</h2>
<p>${t(state.gameMessage)}</p>
</div>
<button
class="btn btn--icon workboard-card__icon"
type="button"
title=${t("common.cancel")}
@click=${() => {
state.gameOpen = false;
props.onRequestUpdate?.();
}}
>
${icons.x}
</button>
</div>
<div class="workboard-game__stats">
<span>${t("workboard.gameMoves", { count: String(state.gameMoves) })}</span>
<span>${t("workboard.gameWins", { count: String(state.gameWins) })}</span>
</div>
<workboard-3d-game
class="workboard-game__scene"
board-size=${String(WORKBOARD_GAME_SIZE)}
goal-index=${String(WORKBOARD_GAME_GOAL)}
player-index=${String(state.gamePlayerIndex)}
blockers=${[...WORKBOARD_GAME_BLOCKERS].join(",")}
wins=${String(state.gameWins)}
aria-hidden="true"
></workboard-3d-game>
${renderAccessibleGameGrid(state)}
<div class="workboard-game__controls" aria-label=${t("workboard.gameControls")}>
${renderGameArrow(
t("workboard.gameMoveUp"),
"workboard-game__arrow--up",
-WORKBOARD_GAME_SIZE,
props,
)}
${renderGameArrow(t("workboard.gameMoveLeft"), "workboard-game__arrow--left", -1, props)}
${renderGameArrow(t("workboard.gameMoveDown"), "", WORKBOARD_GAME_SIZE, props)}
${renderGameArrow(t("workboard.gameMoveRight"), "workboard-game__arrow--right", 1, props)}
</div>
<div class="workboard-modal__actions">
<button
class="btn"
type="button"
@click=${() => {
resetGame(state);
props.onRequestUpdate?.();
}}
>
${t("common.reset")}
</button>
</div>
</div>
</div>
`;
}
function renderCardModal(props: WorkboardProps) {
const state = getWorkboardState(props.host);
const agents = props.agentsList?.agents ?? [];
@@ -869,19 +688,56 @@ function formatLifecycle(lifecycle: WorkboardLifecycle): {
throw new Error("Unknown workboard lifecycle state.");
}
function renderLifecycle(card: WorkboardCard, sessions: readonly GatewaySessionRow[]) {
const lifecycle = getWorkboardLifecycle(card, sessions);
function taskDetail(task: WorkboardTaskSummary): string {
if (task.status === "queued" || task.status === "running") {
return task.progressSummary ?? task.title ?? task.taskId;
}
return task.terminalSummary ?? task.error ?? task.progressSummary ?? task.title ?? task.taskId;
}
function taskMatchesLifecycle(task: WorkboardTaskSummary, lifecycle: WorkboardLifecycle): boolean {
switch (task.status) {
case "queued":
case "running":
return lifecycle.state === "running";
case "completed":
return lifecycle.state === "succeeded";
case "failed":
case "cancelled":
case "timed_out":
return lifecycle.state === "failed";
}
return false;
}
function taskIsActive(task: WorkboardTaskSummary | undefined): boolean {
return task?.status === "queued" || task?.status === "running";
}
function renderLifecycle(
card: WorkboardCard,
sessions: readonly GatewaySessionRow[],
task?: WorkboardTaskSummary,
) {
const lifecycle = getWorkboardLifecycle(card, sessions, task);
const formatted = formatLifecycle(lifecycle);
const session = lifecycle.session;
const execution = card.execution;
const stale = lifecycle.state === "stale";
const taskIsAuthoritative = task ? taskMatchesLifecycle(task, lifecycle) : false;
const taskStatus = task && taskIsAuthoritative ? t(`workboard.taskStatus.${task.status}`) : null;
return html`
<div class="workboard-card__lifecycle">
<span class="workboard-lifecycle workboard-lifecycle--${formatted.tone}">
${stale || !execution ? formatted.label : `${execution.engine} ${execution.mode}`}
${taskStatus ??
(stale || !execution ? formatted.label : `${execution.engine} ${execution.mode}`)}
</span>
<span class="workboard-card__lifecycle-detail">
${stale ? formatted.detail : (session?.displayName ?? session?.label ?? formatted.detail)}
${task && taskIsAuthoritative
? taskDetail(task)
: stale
? formatted.detail
: (session?.displayName ?? session?.label ?? formatted.detail)}
</span>
</div>
`;
@@ -927,37 +783,70 @@ function renderStartExecutionButton(
}
function renderStartExecutionControls(props: WorkboardProps, card: WorkboardCard) {
const canModelOverride = props.canModelOverride !== false;
return html`
<div class="workboard-card__execution-controls">
${renderStartExecutionButton(props, card, null, "autonomous")}
${renderStartExecutionButton(props, card, "codex", "autonomous")}
${renderStartExecutionButton(props, card, "claude", "autonomous")}
${canModelOverride
? html`${renderStartExecutionButton(props, card, "codex", "autonomous")}
${renderStartExecutionButton(props, card, "claude", "autonomous")}`
: nothing}
${renderStartExecutionButton(props, card, "codex", "manual")}
${renderStartExecutionButton(props, card, "claude", "manual")}
</div>
`;
}
function renderDispatchSummary(state: WorkboardUiState) {
const summary = state.lastDispatchSummary;
if (!summary) {
return nothing;
}
const total =
summary.started +
summary.failures +
summary.promoted +
summary.blocked +
summary.reclaimed +
summary.orchestrated;
const key = total === 0 ? "workboard.dispatchSummaryEmpty" : "workboard.dispatchSummary";
return html`
<div class="callout">
${t(key, {
started: String(summary.started),
failures: String(summary.failures),
promoted: String(summary.promoted),
blocked: String(summary.blocked),
reclaimed: String(summary.reclaimed),
orchestrated: String(summary.orchestrated),
})}
</div>
`;
}
function renderCard(props: WorkboardProps, card: WorkboardCard) {
const state = getWorkboardState(props.host);
const task = state.tasksByCardId.get(card.id);
const session = findWorkboardSession(card, props.sessions);
const busy = state.busyCardId === card.id;
const syncing = state.syncingCardIds.has(card.id);
const activeTask = taskIsActive(task);
const live =
activeTask ||
session?.hasActiveRun === true ||
(session?.hasActiveRun !== false && session?.status === "running");
const linkedSessionKey = card.sessionKey ?? card.execution?.sessionKey;
const linked = Boolean(linkedSessionKey);
const openable = Boolean(linkedSessionKey);
const writable = canMutate(props);
const showStartControls = writable && (!linked || !session);
const showStartControls = writable && !activeTask && (!linkedSessionKey || !session);
return html`
<article
class="workboard-card priority-${card.priority} ${busy ? "workboard-card--busy" : ""} ${linked
? "workboard-card--openable"
: ""}"
role=${linked ? "button" : nothing}
tabindex=${linked ? 0 : nothing}
title=${linked ? t("workboard.openLinkedSession") : nothing}
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}
draggable=${writable ? "true" : "false"}
@click=${(event: MouseEvent) => {
if (!isCardActionTarget(event)) {
@@ -993,7 +882,8 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
${syncing ? html`<span class="workboard-live">${t("common.saving")}</span>` : nothing}
</div>
<h3>${card.title}</h3>
${card.notes ? html`<p>${card.notes}</p>` : nothing} ${renderLifecycle(card, props.sessions)}
${card.notes ? html`<p>${card.notes}</p>` : nothing}
${renderLifecycle(card, props.sessions, task)}
${card.labels.length
? html`<div class="workboard-labels">
${card.labels.map((label) => html`<span>${label}</span>`)}
@@ -1022,12 +912,12 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
</button>
`
: nothing}
${linked
${linkedSessionKey
? html`
<button
class="btn btn--icon workboard-card__icon"
title=${t("workboard.openSession")}
@click=${() => props.onOpenSession(linkedSessionKey!)}
@click=${() => props.onOpenSession(linkedSessionKey)}
>
${icons.messageSquare}
</button>
@@ -1051,6 +941,24 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
: nothing}
`
: nothing}
${!linkedSessionKey && writable && activeTask
? html`
<button
class="btn btn--icon workboard-card__icon"
title=${t("workboard.stopSession")}
?disabled=${busy || !props.connected}
@click=${() =>
stopWorkboardCard({
host: props.host,
client: props.client,
card,
requestUpdate: props.onRequestUpdate,
})}
>
${icons.stop}
</button>
`
: nothing}
${showStartControls ? renderStartExecutionControls(props, card) : nothing}
${writable
? html`
@@ -1231,15 +1139,6 @@ export function renderWorkboard(props: WorkboardProps) {
</button>
`
: nothing}
<button
class="btn"
@click=${() => {
state.gameOpen = true;
props.onRequestUpdate?.();
}}
>
${icons.play} ${t("workboard.gameButton")}
</button>
${writable
? html`
<button
@@ -1256,7 +1155,7 @@ export function renderWorkboard(props: WorkboardProps) {
</div>
</div>
${state.error ? html`<div class="callout danger">${state.error}</div>` : nothing}
${renderGameModal(props)} ${renderCardModal(props)}
${renderDispatchSummary(state)} ${renderCardModal(props)}
<div class="workboard-board">
${state.statuses.map((status) => renderColumn(props, status, byStatus.get(status) ?? []))}
</div>