From 1d645ff66bfd0604d47570f84b9d6ed5e87f98ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 10:01:14 +0100 Subject: [PATCH] feat(workboard): persist card metadata --- docs/plugins/workboard.md | 27 +- extensions/workboard/src/gateway.test.ts | 51 +++ extensions/workboard/src/gateway.ts | 68 +++ extensions/workboard/src/store.test.ts | 250 +++++++++++ extensions/workboard/src/store.ts | 544 +++++++++++++++++++++++ extensions/workboard/src/types.ts | 79 ++++ ui/src/i18n/.i18n/ar.meta.json | 8 +- ui/src/i18n/.i18n/de.meta.json | 8 +- ui/src/i18n/.i18n/es.meta.json | 8 +- ui/src/i18n/.i18n/fa.meta.json | 8 +- ui/src/i18n/.i18n/fr.meta.json | 8 +- ui/src/i18n/.i18n/id.meta.json | 8 +- ui/src/i18n/.i18n/it.meta.json | 8 +- ui/src/i18n/.i18n/ja-JP.meta.json | 8 +- ui/src/i18n/.i18n/ko.meta.json | 8 +- ui/src/i18n/.i18n/nl.meta.json | 8 +- ui/src/i18n/.i18n/pl.meta.json | 8 +- ui/src/i18n/.i18n/pt-BR.meta.json | 8 +- ui/src/i18n/.i18n/raw-copy-baseline.json | 35 ++ ui/src/i18n/.i18n/th.meta.json | 8 +- ui/src/i18n/.i18n/tr.meta.json | 8 +- ui/src/i18n/.i18n/uk.meta.json | 8 +- ui/src/i18n/.i18n/vi.meta.json | 8 +- ui/src/i18n/.i18n/zh-CN.meta.json | 8 +- ui/src/i18n/.i18n/zh-TW.meta.json | 8 +- ui/src/i18n/locales/ar.ts | 25 ++ ui/src/i18n/locales/de.ts | 25 ++ ui/src/i18n/locales/en.ts | 25 ++ ui/src/i18n/locales/es.ts | 25 ++ ui/src/i18n/locales/fa.ts | 25 ++ ui/src/i18n/locales/fr.ts | 25 ++ ui/src/i18n/locales/id.ts | 25 ++ ui/src/i18n/locales/it.ts | 25 ++ ui/src/i18n/locales/ja-JP.ts | 25 ++ ui/src/i18n/locales/ko.ts | 25 ++ ui/src/i18n/locales/nl.ts | 25 ++ ui/src/i18n/locales/pl.ts | 25 ++ ui/src/i18n/locales/pt-BR.ts | 25 ++ ui/src/i18n/locales/th.ts | 25 ++ ui/src/i18n/locales/tr.ts | 25 ++ ui/src/i18n/locales/uk.ts | 25 ++ ui/src/i18n/locales/vi.ts | 25 ++ ui/src/i18n/locales/zh-CN.ts | 25 ++ ui/src/i18n/locales/zh-TW.ts | 25 ++ ui/src/styles/workboard.css | 22 + ui/src/ui/controllers/workboard.test.ts | 278 +++++++++++- ui/src/ui/controllers/workboard.ts | 301 ++++++++++++- ui/src/ui/views/workboard.test.ts | 253 ++++++++++- ui/src/ui/views/workboard.ts | 174 +++++++- 49 files changed, 2613 insertions(+), 88 deletions(-) diff --git a/docs/plugins/workboard.md b/docs/plugins/workboard.md index 22d6e348748a..9530ae340718 100644 --- a/docs/plugins/workboard.md +++ b/docs/plugins/workboard.md @@ -48,15 +48,17 @@ Each card stores: - optional agent id - optional linked session, run, task, or source URL - optional execution metadata for a Codex or Claude session started from the card -- recent card events such as created, moved, linked, or agent-updated changes +- compact metadata for attempts, comments, links, proof, templates, archive state, and stale-session detection +- recent card events such as created, moved, linked, attempt, proof, archive, stale, or agent-updated changes Cards are stored in the plugin's Gateway state. They are local to the Gateway state directory and move with the rest of that Gateway's OpenClaw state. -Workboard keeps a compact per-card event history so operators can see how a -card moved through the board without opening the linked session. The event trail -is intentionally local metadata; it does not replace session transcripts or -GitHub issue history. +Workboard keeps compact per-card metadata so operators can see how a card moved +through the board without opening the linked session. Events, attempt summaries, +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 @@ -74,14 +76,20 @@ 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`. +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. + ## Session lifecycle sync Cards can be linked to existing dashboard sessions or to the session created when you start work from a card. Linked cards show the session lifecycle inline: -running, linked idle, done, failed, or missing. +running, stale, linked idle, done, failed, or missing. If the linked session is missing, the card stays linked for context and still offers start controls so you can restart work into a fresh dashboard session. +If an active linked session stops reporting recent activity, Workboard marks the +card stale and stores the marker as card metadata until the lifecycle clears it. You can also capture an existing dashboard session from the Sessions tab with Add to Workboard. The card is linked to that session, uses the session label or @@ -118,12 +126,17 @@ lifecycle stay owned by the regular session system. Use Stop on a live linked card to abort the active session run. Workboard marks that card `blocked` so it remains visible for follow-up. +New cards can start from Workboard templates for bugfixes, docs, releases, PR +reviews, or plugin work. Templates prefill title, notes, labels, and priority, +and the selected template id is stored as card metadata. + ## Permissions The plugin registers Gateway RPC methods under the `workboard.*` namespace: - `workboard.cards.list` requires `operator.read` -- create, update, move, and delete methods require `operator.write` +- `workboard.cards.export` requires `operator.read` +- create, update, move, delete, comment, link, proof, and archive methods require `operator.write` Browsers connected with read-only operator access can inspect the board but cannot mutate cards. diff --git a/extensions/workboard/src/gateway.test.ts b/extensions/workboard/src/gateway.test.ts index d0c829d487fd..d83631cee34b 100644 --- a/extensions/workboard/src/gateway.test.ts +++ b/extensions/workboard/src/gateway.test.ts @@ -49,8 +49,14 @@ describe("workboard gateway methods", () => { "workboard.cards.update", "workboard.cards.move", "workboard.cards.delete", + "workboard.cards.comment", + "workboard.cards.link", + "workboard.cards.proof", + "workboard.cards.archive", + "workboard.cards.export", ]); expect(methods.get("workboard.cards.list")?.opts).toEqual({ scope: "operator.read" }); + expect(methods.get("workboard.cards.export")?.opts).toEqual({ scope: "operator.read" }); expect(methods.get("workboard.cards.create")?.opts).toEqual({ scope: "operator.write" }); const createHandler = methods.get("workboard.cards.create")?.handler; @@ -69,6 +75,51 @@ describe("workboard gateway methods", () => { }); }); + it("stores metadata updates through dedicated card methods", async () => { + type RegisteredMethod = { + handler: Parameters[1]; + opts: Parameters[2]; + }; + const methods = new Map(); + const api = { + runtime: { + state: { + openKeyedStore: vi.fn(() => createMemoryStore()), + }, + }, + registerGatewayMethod: vi.fn( + (method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => { + methods.set(method, { handler, opts }); + }, + ), + } as unknown as OpenClawPluginApi; + + registerWorkboardGatewayMethods({ api }); + + const createRespond = vi.fn(); + await methods.get("workboard.cards.create")?.handler({ + params: { title: "Carry metadata" }, + respond: createRespond, + } as never); + const cardId = createRespond.mock.calls[0]?.[1]?.card.id; + + const commentRespond = vi.fn(); + await methods.get("workboard.cards.comment")?.handler({ + params: { id: cardId, body: "Waiting on CI" }, + respond: commentRespond, + } as never); + + expect(commentRespond.mock.calls[0]?.[0]).toBe(true); + expect(commentRespond.mock.calls[0]?.[1]).toMatchObject({ + card: { + metadata: { + comments: [expect.objectContaining({ body: "Waiting on CI" })], + }, + events: expect.arrayContaining([expect.objectContaining({ kind: "comment_added" })]), + }, + }); + }); + it("validates labels from comma-separated gateway input", async () => { type RegisteredMethod = { handler: Parameters[1]; diff --git a/extensions/workboard/src/gateway.ts b/extensions/workboard/src/gateway.ts index 386c43252189..a7cc9f07f0b9 100644 --- a/extensions/workboard/src/gateway.ts +++ b/extensions/workboard/src/gateway.ts @@ -107,4 +107,72 @@ export function registerWorkboardGatewayMethods(params: { api: OpenClawPluginApi }, { scope: WRITE_SCOPE }, ); + + api.registerGatewayMethod( + "workboard.cards.comment", + async ({ params: requestParams, respond }) => { + try { + respond(true, { + card: await store.addComment(readId(requestParams), requestParams), + }); + } catch (error) { + respondError(respond, error); + } + }, + { scope: WRITE_SCOPE }, + ); + + api.registerGatewayMethod( + "workboard.cards.link", + async ({ params: requestParams, respond }) => { + try { + respond(true, { + card: await store.addLink(readId(requestParams), requestParams), + }); + } catch (error) { + respondError(respond, error); + } + }, + { scope: WRITE_SCOPE }, + ); + + api.registerGatewayMethod( + "workboard.cards.proof", + async ({ params: requestParams, respond }) => { + try { + respond(true, { + card: await store.addProof(readId(requestParams), requestParams), + }); + } catch (error) { + respondError(respond, error); + } + }, + { scope: WRITE_SCOPE }, + ); + + api.registerGatewayMethod( + "workboard.cards.archive", + async ({ params: requestParams, respond }) => { + try { + respond(true, { + card: await store.archive(readId(requestParams), requestParams.archived), + }); + } catch (error) { + respondError(respond, error); + } + }, + { scope: WRITE_SCOPE }, + ); + + api.registerGatewayMethod( + "workboard.cards.export", + async ({ respond }) => { + try { + respond(true, await store.exportCards()); + } catch (error) { + respondError(respond, error); + } + }, + { scope: READ_SCOPE }, + ); } diff --git a/extensions/workboard/src/store.test.ts b/extensions/workboard/src/store.test.ts index ce34418b9abf..c86f20a21030 100644 --- a/extensions/workboard/src/store.test.ts +++ b/extensions/workboard/src/store.test.ts @@ -37,6 +37,17 @@ describe("WorkboardStore", () => { expect(review.events?.[0]).toMatchObject({ kind: "created", toStatus: "review" }); }); + it("does not persist empty metadata for default cards", async () => { + const keyed = createMemoryStore(); + const store = new WorkboardStore(keyed); + + const card = await store.create({ title: "Plain card" }); + + expect(card.metadata).toBeUndefined(); + const entry = await keyed.lookup(card.id); + expect(Object.hasOwn(entry?.card ?? {}, "metadata")).toBe(false); + }); + it("preserves explicit zero positions", async () => { const store = new WorkboardStore(createMemoryStore()); @@ -75,6 +86,45 @@ describe("WorkboardStore", () => { mode: "manual", model: "anthropic/claude-sonnet-4-6", }, + metadata: { + attempts: [ + expect.objectContaining({ + id: "agent:main:dashboard:1", + status: "running", + engine: "claude", + mode: "manual", + sessionKey: "agent:main:dashboard:1", + startedAt: 10, + }), + ], + }, + }); + }); + + it("stores card templates and metadata in the card record", async () => { + const keyed = createMemoryStore(); + const store = new WorkboardStore(keyed); + + const card = await store.create({ + title: "Fix flaky lane", + templateId: "bugfix", + metadata: { + comments: [{ id: "comment-1", body: "Seen twice", createdAt: 10 }], + links: [{ id: "link-1", type: "blocks", targetCardId: "card-2", createdAt: 11 }], + proof: [{ id: "proof-1", status: "passed", command: "pnpm test", createdAt: 12 }], + }, + }); + + await expect(keyed.lookup(card.id)).resolves.toMatchObject({ + version: 1, + card: { + metadata: { + templateId: "bugfix", + comments: [expect.objectContaining({ body: "Seen twice" })], + links: [expect.objectContaining({ type: "blocks", targetCardId: "card-2" })], + proof: [expect.objectContaining({ status: "passed", command: "pnpm test" })], + }, + }, }); }); @@ -130,6 +180,206 @@ describe("WorkboardStore", () => { expect(cleared.execution).toBeUndefined(); }); + it("tracks execution attempts as card metadata", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Run worker" }); + + const running = await store.update(card.id, { + status: "running", + execution: { + id: "exec-1", + kind: "agent-session", + engine: "codex", + mode: "autonomous", + status: "running", + model: "openai/gpt-5.5", + sessionKey: "agent:main:dashboard:1", + runId: "run-1", + startedAt: 10, + updatedAt: 10, + }, + }); + expect(running.metadata?.attempts).toEqual([ + expect.objectContaining({ + id: "run-1", + status: "running", + engine: "codex", + runId: "run-1", + }), + ]); + expect(running.events?.at(-1)).toMatchObject({ kind: "moved" }); + + const blocked = await store.update(card.id, { + execution: { + ...running.execution!, + status: "blocked", + updatedAt: 20, + }, + }); + + expect(blocked.metadata?.attempts?.[0]).toMatchObject({ + status: "blocked", + endedAt: 20, + }); + expect(blocked.metadata?.failureCount).toBe(1); + expect(blocked.events?.at(-1)).toMatchObject({ kind: "attempt_updated", runId: "run-1" }); + + const commented = await store.addComment(card.id, { body: "Need provider follow-up." }); + expect(commented.metadata?.failureCount).toBe(1); + expect(commented.metadata?.attempts?.[0]).toMatchObject({ + status: "blocked", + endedAt: 20, + }); + + const retrying = await store.update(card.id, { + execution: { + ...running.execution!, + id: "exec-2", + runId: "run-2", + status: "running", + startedAt: 30, + updatedAt: 30, + }, + }); + expect(retrying.metadata?.failureCount).toBe(1); + expect(retrying.metadata?.attempts?.[1]).toMatchObject({ + id: "run-2", + startedAt: 30, + status: "running", + }); + + const blockedAgain = await store.update(card.id, { + execution: { + ...retrying.execution!, + status: "blocked", + updatedAt: 40, + }, + }); + expect(blockedAgain.metadata?.failureCount).toBe(2); + }); + + it("adds comments, links, proof, and archive metadata", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Track proof" }); + + const commented = await store.addComment(card.id, { body: "Reviewer asked for screenshots." }); + expect(commented.metadata?.comments?.[0]).toMatchObject({ + body: "Reviewer asked for screenshots.", + }); + expect(commented.events?.at(-1)).toMatchObject({ kind: "comment_added" }); + + const linked = await store.addLink(card.id, { + type: "blocked_by", + targetCardId: "card-upstream", + title: "Upstream fix", + }); + expect(linked.metadata?.links?.[0]).toMatchObject({ + type: "blocked_by", + targetCardId: "card-upstream", + }); + expect(linked.events?.at(-1)).toMatchObject({ kind: "link_added" }); + + const proven = await store.addProof(card.id, { + status: "passed", + command: "pnpm test extensions/workboard", + }); + expect(proven.metadata?.proof?.[0]).toMatchObject({ + status: "passed", + command: "pnpm test extensions/workboard", + }); + expect(proven.events?.at(-1)).toMatchObject({ kind: "proof_added" }); + + const archived = await store.archive(card.id, true); + expect(archived.metadata?.archivedAt).toBeGreaterThan(0); + expect(archived.events?.at(-1)).toMatchObject({ kind: "archived" }); + + const restored = await store.archive(card.id, false); + expect(restored.metadata?.archivedAt).toBeUndefined(); + expect(restored.events?.at(-1)).toMatchObject({ kind: "unarchived" }); + }); + + it("keeps concurrent metadata appends from dropping siblings", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Collect notes" }); + + await Promise.all([ + store.addComment(card.id, { body: "First note." }), + store.addComment(card.id, { body: "Second note." }), + ]); + + const saved = await store.get(card.id); + expect(saved?.metadata?.comments?.map((comment) => comment.body).toSorted()).toEqual([ + "First note.", + "Second note.", + ]); + }); + + it("keeps metadata under the keyed-store value budget", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Collect a lot of notes" }); + + for (let index = 0; index < 50; index += 1) { + await store.addComment(card.id, { + body: `${String(index).padStart(2, "0")} ${"x".repeat(1990)}`, + }); + } + + const saved = await store.get(card.id); + expect(Buffer.byteLength(JSON.stringify(saved?.metadata), "utf8")).toBeLessThanOrEqual( + 24 * 1024, + ); + expect(saved?.metadata?.comments?.at(-1)?.body).toContain("49 "); + expect(saved?.metadata?.comments?.length).toBeLessThan(50); + }); + + it("records append events when metadata retention drops old comments", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Track retained comments" }); + + let updated = card; + for (let index = 0; index < 51; index += 1) { + updated = await store.addComment(card.id, { body: `Note ${index}` }); + } + + expect(updated.metadata?.comments).toHaveLength(50); + expect(updated.metadata?.comments?.at(0)?.body).toBe("Note 1"); + expect(updated.events?.at(-1)).toMatchObject({ kind: "comment_added" }); + }); + + it("keeps queued metadata when lifecycle updates add stale state", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Sync stale state" }); + + await Promise.all([ + store.update(card.id, { + status: "running", + metadata: { + stale: { + detectedAt: 10, + lastSessionUpdatedAt: 1, + reason: "Linked session has not reported recent activity.", + }, + }, + }), + store.addComment(card.id, { body: "Operator note." }), + ]); + + const saved = await store.get(card.id); + expect(saved?.status).toBe("running"); + expect(saved?.metadata?.stale?.lastSessionUpdatedAt).toBe(1); + expect(saved?.metadata?.comments?.map((comment) => comment.body)).toContain("Operator note."); + }); + + it("exports card records with metadata", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Export me", templateId: "docs" }); + + await expect(store.exportCards()).resolves.toMatchObject({ + cards: [expect.objectContaining({ id: card.id, metadata: { templateId: "docs" } })], + exportedAt: expect.any(Number), + }); + }); + it("rejects invalid status values", async () => { const store = new WorkboardStore(createMemoryStore()); await expect(store.create({ title: "Bad card", status: "later" })).rejects.toThrow( diff --git a/extensions/workboard/src/store.ts b/extensions/workboard/src/store.ts index 5af198472ce2..d9b9986d10bf 100644 --- a/extensions/workboard/src/store.ts +++ b/extensions/workboard/src/store.ts @@ -3,23 +3,41 @@ import { WORKBOARD_EXECUTION_ENGINES, WORKBOARD_EXECUTION_MODES, WORKBOARD_EXECUTION_STATUSES, + WORKBOARD_ATTEMPT_STATUSES, WORKBOARD_EVENT_KINDS, + WORKBOARD_LINK_TYPES, WORKBOARD_PRIORITIES, + WORKBOARD_PROOF_STATUSES, WORKBOARD_STATUSES, + WORKBOARD_TEMPLATE_IDS, type WorkboardCard, + type WorkboardAttemptStatus, + type WorkboardComment, type WorkboardEvent, type WorkboardEventKind, type WorkboardExecution, type WorkboardExecutionEngine, type WorkboardExecutionMode, type WorkboardExecutionStatus, + type WorkboardLink, + type WorkboardLinkType, + type WorkboardMetadata, type WorkboardPriority, + type WorkboardProof, + type WorkboardProofStatus, + type WorkboardRunAttempt, type WorkboardStatus, + type WorkboardTemplateId, } from "./types.js"; const POSITION_STEP = 1000; const MAX_CARDS = 2000; const MAX_CARD_EVENTS = 50; +const MAX_CARD_ATTEMPTS = 30; +const MAX_CARD_COMMENTS = 50; +const MAX_CARD_LINKS = 50; +const MAX_CARD_PROOF = 40; +const MAX_CARD_METADATA_BYTES = 24 * 1024; export type PersistedWorkboardCard = { version: 1; @@ -45,10 +63,26 @@ export type WorkboardCardInput = { taskId?: unknown; sourceUrl?: unknown; execution?: unknown; + metadata?: unknown; + templateId?: unknown; position?: unknown; }; export type WorkboardCardPatch = Partial; +export type WorkboardCommentInput = { body?: unknown }; +export type WorkboardLinkInput = { + type?: unknown; + targetCardId?: unknown; + title?: unknown; + url?: unknown; +}; +export type WorkboardProofInput = { + status?: unknown; + label?: unknown; + command?: unknown; + url?: unknown; + note?: unknown; +}; function normalizeOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; @@ -76,6 +110,22 @@ function normalizeNotes(value: unknown): string | undefined { return notes; } +function normalizeBoundedString( + value: unknown, + fallback: string | undefined, + maxLength: number, + fieldName: string, +): string | undefined { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return fallback; + } + if (normalized.length > maxLength) { + throw new Error(`${fieldName} must be ${maxLength} characters or fewer.`); + } + return normalized; +} + function normalizeStatus(value: unknown, fallback: WorkboardStatus): WorkboardStatus { if (typeof value !== "string" || !value.trim()) { return fallback; @@ -168,6 +218,45 @@ function normalizeExecutionStatus( return fallback; } +function normalizeAttemptStatus( + value: unknown, + fallback: WorkboardAttemptStatus, +): WorkboardAttemptStatus { + if ( + typeof value === "string" && + WORKBOARD_ATTEMPT_STATUSES.includes(value as WorkboardAttemptStatus) + ) { + return value as WorkboardAttemptStatus; + } + return fallback; +} + +function normalizeLinkType(value: unknown, fallback: WorkboardLinkType): WorkboardLinkType { + if (typeof value === "string" && WORKBOARD_LINK_TYPES.includes(value as WorkboardLinkType)) { + return value as WorkboardLinkType; + } + return fallback; +} + +function normalizeProofStatus( + value: unknown, + fallback: WorkboardProofStatus, +): WorkboardProofStatus { + if ( + typeof value === "string" && + WORKBOARD_PROOF_STATUSES.includes(value as WorkboardProofStatus) + ) { + return value as WorkboardProofStatus; + } + return fallback; +} + +function normalizeTemplateId(value: unknown): WorkboardTemplateId | undefined { + return typeof value === "string" && WORKBOARD_TEMPLATE_IDS.includes(value as WorkboardTemplateId) + ? (value as WorkboardTemplateId) + : undefined; +} + function normalizeTimestamp(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) @@ -220,6 +309,165 @@ function normalizeEvents(value: unknown): WorkboardEvent[] { .slice(-MAX_CARD_EVENTS); } +function normalizeAttempt(value: unknown): WorkboardRunAttempt | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + const id = normalizeOptionalString(record.id); + const startedAt = normalizeTimestamp(record.startedAt, 0); + if (!id || !startedAt) { + return null; + } + const endedAt = normalizeTimestamp(record.endedAt, 0); + const sessionKey = normalizeOptionalString(record.sessionKey); + const runId = normalizeOptionalString(record.runId); + const error = normalizeBoundedString(record.error, undefined, 800, "attempt error"); + const model = normalizeBoundedString(record.model, undefined, 160, "attempt model"); + return { + id, + status: normalizeAttemptStatus(record.status, "running"), + startedAt, + ...(endedAt ? { endedAt } : {}), + ...(typeof record.engine === "string" && + WORKBOARD_EXECUTION_ENGINES.includes(record.engine as WorkboardExecutionEngine) + ? { engine: record.engine as WorkboardExecutionEngine } + : {}), + ...(typeof record.mode === "string" && + WORKBOARD_EXECUTION_MODES.includes(record.mode as WorkboardExecutionMode) + ? { mode: record.mode as WorkboardExecutionMode } + : {}), + ...(model ? { model } : {}), + ...(sessionKey ? { sessionKey } : {}), + ...(runId ? { runId } : {}), + ...(error ? { error } : {}), + }; +} + +function normalizeComment(value: unknown): WorkboardComment | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + const id = normalizeOptionalString(record.id); + const body = normalizeBoundedString(record.body, undefined, 2000, "comment body"); + const createdAt = normalizeTimestamp(record.createdAt, 0); + if (!id || !body || !createdAt) { + return null; + } + const updatedAt = normalizeTimestamp(record.updatedAt, 0); + return { id, body, createdAt, ...(updatedAt ? { updatedAt } : {}) }; +} + +function normalizeLink(value: unknown): WorkboardLink | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + const id = normalizeOptionalString(record.id); + const createdAt = normalizeTimestamp(record.createdAt, 0); + if (!id || !createdAt) { + return null; + } + const targetCardId = normalizeBoundedString(record.targetCardId, undefined, 120, "link target"); + const title = normalizeBoundedString(record.title, undefined, 180, "link title"); + const url = normalizeBoundedString(record.url, undefined, 2000, "link URL"); + if (!targetCardId && !url) { + return null; + } + return { + id, + type: normalizeLinkType(record.type, "relates_to"), + createdAt, + ...(targetCardId ? { targetCardId } : {}), + ...(title ? { title } : {}), + ...(url ? { url } : {}), + }; +} + +function normalizeProof(value: unknown): WorkboardProof | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + const id = normalizeOptionalString(record.id); + const createdAt = normalizeTimestamp(record.createdAt, 0); + if (!id || !createdAt) { + return null; + } + const label = normalizeBoundedString(record.label, undefined, 160, "proof label"); + const command = normalizeBoundedString(record.command, undefined, 1000, "proof command"); + const url = normalizeBoundedString(record.url, undefined, 2000, "proof URL"); + const note = normalizeBoundedString(record.note, undefined, 2000, "proof note"); + return { + id, + status: normalizeProofStatus(record.status, "unknown"), + createdAt, + ...(label ? { label } : {}), + ...(command ? { command } : {}), + ...(url ? { url } : {}), + ...(note ? { note } : {}), + }; +} + +function normalizeMetadata(value: unknown, fallback: WorkboardMetadata = {}): WorkboardMetadata { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return trimMetadataToBudget(fallback); + } + const record = value as Record; + const stale = + record.stale && typeof record.stale === "object" && !Array.isArray(record.stale) + ? (record.stale as Record) + : null; + const hasArchivedAt = Object.hasOwn(record, "archivedAt"); + const hasStale = Object.hasOwn(record, "stale"); + return trimMetadataToBudget({ + attempts: Array.isArray(record.attempts) + ? record.attempts + .map(normalizeAttempt) + .filter((attempt): attempt is WorkboardRunAttempt => attempt !== null) + .slice(-MAX_CARD_ATTEMPTS) + : fallback.attempts, + comments: Array.isArray(record.comments) + ? record.comments + .map(normalizeComment) + .filter((comment): comment is WorkboardComment => comment !== null) + .slice(-MAX_CARD_COMMENTS) + : fallback.comments, + links: Array.isArray(record.links) + ? record.links + .map(normalizeLink) + .filter((link): link is WorkboardLink => link !== null) + .slice(-MAX_CARD_LINKS) + : fallback.links, + proof: Array.isArray(record.proof) + ? record.proof + .map(normalizeProof) + .filter((proof): proof is WorkboardProof => proof !== null) + .slice(-MAX_CARD_PROOF) + : fallback.proof, + templateId: normalizeTemplateId(record.templateId) ?? fallback.templateId, + archivedAt: hasArchivedAt + ? normalizeTimestamp(record.archivedAt, 0) || undefined + : fallback.archivedAt, + stale: hasStale + ? stale + ? { + detectedAt: normalizeTimestamp(stale.detectedAt, Date.now()), + lastSessionUpdatedAt: normalizeTimestamp(stale.lastSessionUpdatedAt, 0) || undefined, + reason: + normalizeBoundedString(stale.reason, fallback.stale?.reason, 240, "stale reason") ?? + "Session has not reported recent activity.", + } + : undefined + : fallback.stale, + failureCount: + typeof record.failureCount === "number" && Number.isFinite(record.failureCount) + ? Math.max(0, Math.trunc(record.failureCount)) + : fallback.failureCount, + }); +} + function normalizeExecution(value: unknown): WorkboardExecution | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; @@ -274,6 +522,66 @@ function removeUndefinedExecutionFields(execution: WorkboardExecution): Workboar return next; } +function removeUndefinedMetadataFields(metadata: WorkboardMetadata): WorkboardMetadata { + const next = { ...metadata }; + for (const key of [ + "attempts", + "comments", + "links", + "proof", + "templateId", + "archivedAt", + "stale", + "failureCount", + ] as const) { + const value = next[key]; + if ( + value === undefined || + (Array.isArray(value) && value.length === 0) || + (typeof value === "number" && value === 0 && key === "failureCount") + ) { + delete next[key]; + } + } + return next; +} + +function metadataIsEmpty(metadata: WorkboardMetadata | undefined): boolean { + return !metadata || Object.keys(metadata).length === 0; +} + +function metadataByteSize(metadata: WorkboardMetadata): number { + return Buffer.byteLength(JSON.stringify(metadata), "utf8"); +} + +function dropFirst(items: readonly T[] | undefined): T[] | undefined { + if (!items?.length) { + return undefined; + } + const next = items.slice(1); + return next.length ? next : undefined; +} + +function trimMetadataToBudget(metadata: WorkboardMetadata): WorkboardMetadata { + let next = removeUndefinedMetadataFields(metadata); + while (metadataByteSize(next) > MAX_CARD_METADATA_BYTES) { + const currentSize = metadataByteSize(next); + if (next.attempts?.length) { + next = removeUndefinedMetadataFields({ ...next, attempts: dropFirst(next.attempts) }); + } else if (next.proof?.length) { + next = removeUndefinedMetadataFields({ ...next, proof: dropFirst(next.proof) }); + } else if (next.links?.length) { + next = removeUndefinedMetadataFields({ ...next, links: dropFirst(next.links) }); + } else if (next.comments?.length) { + next = removeUndefinedMetadataFields({ ...next, comments: dropFirst(next.comments) }); + } + if (metadataByteSize(next) >= currentSize) { + break; + } + } + return next; +} + function compareCards(left: WorkboardCard, right: WorkboardCard): number { if (left.status !== right.status) { return WORKBOARD_STATUSES.indexOf(left.status) - WORKBOARD_STATUSES.indexOf(right.status); @@ -292,6 +600,69 @@ function cardRunId(card: WorkboardCard): string | undefined { return card.runId ?? card.execution?.runId; } +function executionAttemptStatus(execution: WorkboardExecution): WorkboardAttemptStatus { + if (execution.status === "running") { + return "running"; + } + if (execution.status === "blocked") { + return "blocked"; + } + if (execution.status === "done" || execution.status === "review") { + return "succeeded"; + } + return "stopped"; +} + +function syncExecutionAttemptMetadata( + metadata: WorkboardMetadata, + execution: WorkboardExecution | undefined, + now: number, +): WorkboardMetadata { + if (!execution) { + return metadata; + } + const attemptStatus = executionAttemptStatus(execution); + const attempts = [...(metadata.attempts ?? [])]; + const key = execution.runId ?? execution.sessionKey ?? execution.id; + const existingIndex = attempts.findIndex( + (attempt) => + (execution.runId && attempt.runId === execution.runId) || + (!execution.runId && attempt.id === key), + ); + const existingAttempt = existingIndex >= 0 ? attempts[existingIndex] : undefined; + const nextAttempt: WorkboardRunAttempt = { + id: existingAttempt?.id ?? key, + status: attemptStatus, + startedAt: existingAttempt?.startedAt ?? execution.startedAt, + engine: execution.engine, + mode: execution.mode, + model: execution.model, + ...(execution.sessionKey ? { sessionKey: execution.sessionKey } : {}), + ...(execution.runId ? { runId: execution.runId } : {}), + ...(attemptStatus !== "running" && { endedAt: execution.updatedAt || now }), + }; + if (existingIndex >= 0) { + attempts[existingIndex] = nextAttempt; + } else { + attempts.push(nextAttempt); + } + const previousFailed = + existingAttempt?.status === "blocked" || existingAttempt?.status === "failed"; + const attemptFailed = attemptStatus === "blocked" || attemptStatus === "failed"; + const failureCount = attemptFailed + ? previousFailed + ? metadata.failureCount + : (metadata.failureCount ?? 0) + 1 + : attemptStatus === "succeeded" + ? 0 + : metadata.failureCount; + return removeUndefinedMetadataFields({ + ...metadata, + attempts: attempts.slice(-MAX_CARD_ATTEMPTS), + failureCount, + }); +} + function appendEvent( card: WorkboardCard, event: Omit, @@ -307,6 +678,14 @@ function appendEvent( ].slice(-MAX_CARD_EVENTS); } +function latestMetadataIdChanged( + existing: readonly { id: string }[] | undefined, + next: readonly { id: string }[] | undefined, +): boolean { + const latestId = next?.at(-1)?.id; + return Boolean(latestId && latestId !== existing?.at(-1)?.id); +} + function updateEvent( existing: WorkboardCard, next: WorkboardCard, @@ -329,12 +708,59 @@ function updateEvent( existing.execution?.engine !== next.execution?.engine || cardRunId(existing) !== cardRunId(next) ) { + const existingAttempts = existing.metadata?.attempts ?? []; + const nextAttempts = next.metadata?.attempts ?? []; + const latestAttempt = nextAttempts.at(-1); + if (nextAttempts.length > existingAttempts.length) { + return { + kind: "attempt_started", + ...(latestAttempt?.sessionKey ? { sessionKey: latestAttempt.sessionKey } : {}), + ...(latestAttempt?.runId ? { runId: latestAttempt.runId } : {}), + }; + } + const previousAttempt = latestAttempt + ? existingAttempts.find((attempt) => attempt.id === latestAttempt.id) + : undefined; + if (latestAttempt && previousAttempt?.status !== latestAttempt.status) { + return { + kind: "attempt_updated", + ...(latestAttempt.sessionKey ? { sessionKey: latestAttempt.sessionKey } : {}), + ...(latestAttempt.runId ? { runId: latestAttempt.runId } : {}), + }; + } return { kind: "execution_updated", ...(cardSessionKey(next) ? { sessionKey: cardSessionKey(next) } : {}), ...(cardRunId(next) ? { runId: cardRunId(next) } : {}), }; } + if ( + (existing.metadata?.comments?.length ?? 0) !== (next.metadata?.comments?.length ?? 0) || + latestMetadataIdChanged(existing.metadata?.comments, next.metadata?.comments) + ) { + return { kind: "comment_added" }; + } + if ( + (existing.metadata?.links?.length ?? 0) !== (next.metadata?.links?.length ?? 0) || + latestMetadataIdChanged(existing.metadata?.links, next.metadata?.links) + ) { + return { kind: "link_added" }; + } + if ( + (existing.metadata?.proof?.length ?? 0) !== (next.metadata?.proof?.length ?? 0) || + latestMetadataIdChanged(existing.metadata?.proof, next.metadata?.proof) + ) { + return { kind: "proof_added" }; + } + if (!existing.metadata?.archivedAt && next.metadata?.archivedAt) { + return { kind: "archived" }; + } + if (existing.metadata?.archivedAt && !next.metadata?.archivedAt) { + return { kind: "unarchived" }; + } + if (!existing.metadata?.stale && next.metadata?.stale) { + return { kind: "stale" }; + } return { kind: "edited" }; } @@ -350,17 +776,45 @@ function removeUndefinedCardFields(card: WorkboardCard): WorkboardCard { "execution", "startedAt", "completedAt", + "metadata", ] as const) { if (next[key] === undefined) { delete next[key]; } } + if (metadataIsEmpty(next.metadata)) { + delete next.metadata; + } return next; } export class WorkboardStore { + private mutationQueue: Promise = Promise.resolve(); + constructor(private readonly store: WorkboardKeyedStore) {} + private async enqueueMutation(run: () => Promise): Promise { + const result = this.mutationQueue.then(run, run); + this.mutationQueue = result.then( + () => undefined, + () => undefined, + ); + return await result; + } + + private async updateMetadata( + id: string, + mutate: (existing: WorkboardCard) => WorkboardMetadata, + ): Promise { + return await this.enqueueMutation(async () => { + const existing = await this.get(id); + if (!existing) { + throw new Error(`card not found: ${id}`); + } + return await this.updateCard(id, { metadata: mutate(existing) }); + }); + } + async list(): Promise { const entries = await this.store.entries(); return entries @@ -395,6 +849,12 @@ export class WorkboardStore { const taskId = normalizeOptionalString(input.taskId); const sourceUrl = normalizeOptionalString(input.sourceUrl); const execution = normalizeExecution(input.execution); + const metadata = normalizeMetadata(input.metadata, { + templateId: normalizeTemplateId(input.templateId), + }); + const syncedMetadata = trimMetadataToBudget( + syncExecutionAttemptMetadata(metadata, execution, now), + ); const card: WorkboardCard = { id: randomUUID(), title: normalizeTitle(input.title), @@ -421,12 +881,17 @@ export class WorkboardStore { ...(taskId ? { taskId } : {}), ...(sourceUrl ? { sourceUrl } : {}), ...(execution ? { execution } : {}), + ...(!metadataIsEmpty(syncedMetadata) ? { metadata: syncedMetadata } : {}), }; await this.store.register(card.id, { version: 1, card }); return card; } async update(id: string, patch: WorkboardCardPatch): Promise { + return await this.enqueueMutation(async () => await this.updateCard(id, patch)); + } + + private async updateCard(id: string, patch: WorkboardCardPatch): Promise { const existing = await this.get(id); if (!existing) { throw new Error(`card not found: ${id}`); @@ -445,6 +910,7 @@ export class WorkboardStore { ? existing.execution : syncExecutionSessionKey(existing.execution, sessionKey) : normalizeExecution(patch.execution); + const metadata = normalizeMetadata(patch.metadata, existing.metadata); const next = removeUndefinedCardFields({ ...existing, title: patch.title === undefined ? existing.title : normalizeTitle(patch.title), @@ -465,6 +931,10 @@ export class WorkboardStore { ? existing.sourceUrl : normalizeOptionalString(patch.sourceUrl), execution, + metadata: + patch.templateId === undefined + ? metadata + : { ...metadata, templateId: normalizeTemplateId(patch.templateId) }, position: patch.position === undefined ? existing.position @@ -473,10 +943,16 @@ export class WorkboardStore { ...(startedAt ? { startedAt } : {}), ...(completedAt ? { completedAt } : {}), }); + next.metadata = trimMetadataToBudget( + syncExecutionAttemptMetadata(next.metadata ?? {}, execution, now), + ); next.events = appendEvent(next, updateEvent(existing, next), now); if (status !== "done") { delete next.completedAt; } + if (metadataIsEmpty(next.metadata)) { + delete next.metadata; + } await this.store.register(next.id, { version: 1, card: next }); return next; } @@ -492,6 +968,74 @@ export class WorkboardStore { return { deleted: await this.store.delete(id.trim()) }; } + async addComment(id: string, input: WorkboardCommentInput): Promise { + const now = Date.now(); + const body = normalizeBoundedString(input.body, undefined, 2000, "comment body"); + if (!body) { + throw new Error("comment body is required."); + } + const comment = { id: randomUUID(), body, createdAt: now }; + return await this.updateMetadata(id, (existing) => ({ + ...existing.metadata, + comments: [...(existing.metadata?.comments ?? []), comment].slice(-MAX_CARD_COMMENTS), + })); + } + + async addLink(id: string, input: WorkboardLinkInput): Promise { + const now = Date.now(); + const targetCardId = normalizeBoundedString(input.targetCardId, undefined, 120, "link target"); + const url = normalizeBoundedString(input.url, undefined, 2000, "link URL"); + const title = normalizeBoundedString(input.title, undefined, 180, "link title"); + if (!targetCardId && !url) { + throw new Error("link targetCardId or url is required."); + } + const link: WorkboardLink = { + id: randomUUID(), + type: normalizeLinkType(input.type, "relates_to"), + createdAt: now, + ...(targetCardId ? { targetCardId } : {}), + ...(title ? { title } : {}), + ...(url ? { url } : {}), + }; + return await this.updateMetadata(id, (existing) => ({ + ...existing.metadata, + links: [...(existing.metadata?.links ?? []), link].slice(-MAX_CARD_LINKS), + })); + } + + async addProof(id: string, input: WorkboardProofInput): Promise { + const now = Date.now(); + const label = normalizeBoundedString(input.label, undefined, 160, "proof label"); + const command = normalizeBoundedString(input.command, undefined, 1000, "proof command"); + const url = normalizeBoundedString(input.url, undefined, 2000, "proof URL"); + const note = normalizeBoundedString(input.note, undefined, 2000, "proof note"); + const proof: WorkboardProof = { + id: randomUUID(), + status: normalizeProofStatus(input.status, "unknown"), + createdAt: now, + ...(label ? { label } : {}), + ...(command ? { command } : {}), + ...(url ? { url } : {}), + ...(note ? { note } : {}), + }; + return await this.updateMetadata(id, (existing) => ({ + ...existing.metadata, + proof: [...(existing.metadata?.proof ?? []), proof].slice(-MAX_CARD_PROOF), + })); + } + + async archive(id: string, archived: unknown): Promise { + const shouldArchive = archived !== false; + return await this.updateMetadata(id, (existing) => ({ + ...existing.metadata, + archivedAt: shouldArchive ? Date.now() : 0, + })); + } + + async exportCards(): Promise<{ cards: WorkboardCard[]; exportedAt: number }> { + return { cards: await this.list(), exportedAt: Date.now() }; + } + static open( openKeyedStore: (options: { namespace: string; maxEntries: number }) => WorkboardKeyedStore, ) { diff --git a/extensions/workboard/src/types.ts b/extensions/workboard/src/types.ts index 4e47395de93b..7472b077efbb 100644 --- a/extensions/workboard/src/types.ts +++ b/extensions/workboard/src/types.ts @@ -23,7 +23,25 @@ export const WORKBOARD_EVENT_KINDS = [ "moved", "linked", "execution_updated", + "attempt_started", + "attempt_updated", + "comment_added", + "link_added", + "proof_added", + "archived", + "unarchived", + "stale", ] as const; +export const WORKBOARD_ATTEMPT_STATUSES = [ + "running", + "succeeded", + "failed", + "blocked", + "stopped", +] as const; +export const WORKBOARD_LINK_TYPES = ["blocks", "blocked_by", "relates_to"] as const; +export const WORKBOARD_PROOF_STATUSES = ["passed", "failed", "skipped", "unknown"] as const; +export const WORKBOARD_TEMPLATE_IDS = ["bugfix", "docs", "release", "pr_review", "plugin"] as const; export type WorkboardStatus = (typeof WORKBOARD_STATUSES)[number]; export type WorkboardPriority = (typeof WORKBOARD_PRIORITIES)[number]; @@ -31,6 +49,10 @@ export type WorkboardExecutionEngine = (typeof WORKBOARD_EXECUTION_ENGINES)[numb export type WorkboardExecutionMode = (typeof WORKBOARD_EXECUTION_MODES)[number]; export type WorkboardExecutionStatus = (typeof WORKBOARD_EXECUTION_STATUSES)[number]; export type WorkboardEventKind = (typeof WORKBOARD_EVENT_KINDS)[number]; +export type WorkboardAttemptStatus = (typeof WORKBOARD_ATTEMPT_STATUSES)[number]; +export type WorkboardLinkType = (typeof WORKBOARD_LINK_TYPES)[number]; +export type WorkboardProofStatus = (typeof WORKBOARD_PROOF_STATUSES)[number]; +export type WorkboardTemplateId = (typeof WORKBOARD_TEMPLATE_IDS)[number]; export type WorkboardExecution = { id: string; @@ -55,6 +77,62 @@ export type WorkboardEvent = { runId?: string; }; +export type WorkboardRunAttempt = { + id: string; + status: WorkboardAttemptStatus; + startedAt: number; + endedAt?: number; + engine?: WorkboardExecutionEngine; + mode?: WorkboardExecutionMode; + model?: string; + sessionKey?: string; + runId?: string; + error?: string; +}; + +export type WorkboardComment = { + id: string; + body: string; + createdAt: number; + updatedAt?: number; +}; + +export type WorkboardLink = { + id: string; + type: WorkboardLinkType; + createdAt: number; + targetCardId?: string; + title?: string; + url?: string; +}; + +export type WorkboardProof = { + id: string; + status: WorkboardProofStatus; + createdAt: number; + label?: string; + command?: string; + url?: string; + note?: string; +}; + +export type WorkboardStaleState = { + detectedAt: number; + lastSessionUpdatedAt?: number; + reason: string; +}; + +export type WorkboardMetadata = { + attempts?: WorkboardRunAttempt[]; + comments?: WorkboardComment[]; + links?: WorkboardLink[]; + proof?: WorkboardProof[]; + templateId?: WorkboardTemplateId; + archivedAt?: number; + stale?: WorkboardStaleState; + failureCount?: number; +}; + export type WorkboardCard = { id: string; title: string; @@ -74,6 +152,7 @@ export type WorkboardCard = { startedAt?: number; completedAt?: number; events?: WorkboardEvent[]; + metadata?: WorkboardMetadata; }; export type WorkboardListResult = { diff --git a/ui/src/i18n/.i18n/ar.meta.json b/ui/src/i18n/.i18n/ar.meta.json index 0c308484744c..fe4e848ecff3 100644 --- a/ui/src/i18n/.i18n/ar.meta.json +++ b/ui/src/i18n/.i18n/ar.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:49:35.013Z", + "generatedAt": "2026-05-29T00:32:14.710Z", "locale": "ar", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index 16f07f275140..741a2c6e1f6f 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:49:02.619Z", + "generatedAt": "2026-05-29T00:31:32.282Z", "locale": "de", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index 0dc996019d3e..addfbe1f1f1d 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:49:07.283Z", + "generatedAt": "2026-05-29T00:31:40.304Z", "locale": "es", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fa.meta.json b/ui/src/i18n/.i18n/fa.meta.json index f9e48a4bf303..e5480cc83e40 100644 --- a/ui/src/i18n/.i18n/fa.meta.json +++ b/ui/src/i18n/.i18n/fa.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:51:04.444Z", + "generatedAt": "2026-05-29T00:33:25.728Z", "locale": "fa", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index de7de045aac8..5abe3578eaab 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:49:27.030Z", + "generatedAt": "2026-05-29T00:32:06.264Z", "locale": "fr", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index ad68b5a06b7e..6c381baa3864 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:50:00.392Z", + "generatedAt": "2026-05-29T00:32:46.357Z", "locale": "id", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/it.meta.json b/ui/src/i18n/.i18n/it.meta.json index 141e15cb789e..4c26c7ca000d 100644 --- a/ui/src/i18n/.i18n/it.meta.json +++ b/ui/src/i18n/.i18n/it.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:49:40.467Z", + "generatedAt": "2026-05-29T00:32:22.391Z", "locale": "it", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index 8d5bb802f2f9..47676e3dbf64 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:49:14.097Z", + "generatedAt": "2026-05-29T00:31:48.617Z", "locale": "ja-JP", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index 77251874f9d5..a3899e3f15c1 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:49:19.581Z", + "generatedAt": "2026-05-29T00:31:56.568Z", "locale": "ko", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/nl.meta.json b/ui/src/i18n/.i18n/nl.meta.json index bf1fc7fb7c9b..04412d15f430 100644 --- a/ui/src/i18n/.i18n/nl.meta.json +++ b/ui/src/i18n/.i18n/nl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:50:48.437Z", + "generatedAt": "2026-05-29T00:33:17.149Z", "locale": "nl", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index 82b21aec81a6..d0ae484277d2 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:50:12.160Z", + "generatedAt": "2026-05-29T00:32:54.325Z", "locale": "pl", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index 0ce1c40fd723..e742b422513e 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:48:56.314Z", + "generatedAt": "2026-05-29T00:31:23.519Z", "locale": "pt-BR", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index 4d5adfcf1282..ffc20bda9c90 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -4655,6 +4655,41 @@ "name": "aria-label", "path": "ui/src/ui/views/usage-render-overview.ts", "text": "Remove session filter" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/ui/views/workboard.ts", + "text": "Docs:" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/ui/views/workboard.ts", + "text": "Fix:" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/ui/views/workboard.ts", + "text": "Plugin:" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/ui/views/workboard.ts", + "text": "Release:" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/ui/views/workboard.ts", + "text": "Review PR" } ] } diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index b48f69834a6e..47832e86b76a 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:50:26.593Z", + "generatedAt": "2026-05-29T00:33:02.612Z", "locale": "th", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index 7cfa4d9fda01..587188bd88d4 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:49:47.972Z", + "generatedAt": "2026-05-29T00:32:30.109Z", "locale": "tr", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index 97bb3f53283f..c0a01572d3a2 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:49:54.214Z", + "generatedAt": "2026-05-29T00:32:37.218Z", "locale": "uk", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/vi.meta.json b/ui/src/i18n/.i18n/vi.meta.json index 8329bd20fa6b..9089580911f3 100644 --- a/ui/src/i18n/.i18n/vi.meta.json +++ b/ui/src/i18n/.i18n/vi.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:50:37.343Z", + "generatedAt": "2026-05-29T00:33:09.499Z", "locale": "vi", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index c018ca542024..d218ba5981bb 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:48:43.195Z", + "generatedAt": "2026-05-29T00:31:05.242Z", "locale": "zh-CN", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index 9d1c18834c9b..775472c91b37 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-28T23:48:49.571Z", + "generatedAt": "2026-05-29T00:31:15.002Z", "locale": "zh-TW", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b966847cbbaca64097b1e105dcc26ad803434cfb2d3b39cae3edc2be3874cfda", - "totalKeys": 1238, - "translatedKeys": 1238, + "sourceHash": "7c73d242d089cdd14b247b19cd604c4016240309b2bf080837c543751bfb4fce", + "totalKeys": 1261, + "translatedKeys": 1261, "workflow": 1 } diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index dcc97a22f422..b409c1fe433f 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -495,6 +495,7 @@ export const ar: TranslationMap = { editCardHelp: "حدّث بيانات تعريف قائمة الانتظار وتسليم الجلسة.", newCard: "بطاقة جديدة", newCardHelp: "أضف العمل إلى قائمة الانتظار لجلسة وكيل.", + archiveCard: "أرشفة البطاقة", deleteCard: "حذف البطاقة", openSession: "فتح الجلسة", openLinkedSession: "فتح الجلسة المرتبطة", @@ -511,6 +512,20 @@ export const ar: TranslationMap = { fieldAgent: "الوكيل", fieldSession: "الجلسة", fieldLabels: "التصنيفات", + templatesLabel: "قوالب البطاقات", + template: { + bugfix: "إصلاح خطأ", + docs: "المستندات", + release: "إصدار", + pr_review: "مراجعة PR", + plugin: "مكوّن إضافي", + }, + badgeAttempts: "{count} محاولة", + badgeFailures: "{count} فشل", + badgeComments: "{count} تعليقات", + badgeLinks: "{count} روابط", + badgeProof: "{count} إثبات", + badgeStale: "قديم", titlePlaceholder: "عنوان البطاقة", notesPlaceholder: "ملاحظات، معايير القبول، روابط", labelsPlaceholder: "ui, docs", @@ -525,6 +540,8 @@ export const ar: TranslationMap = { lifecycleIdleDetail: "لا يوجد تشغيل نشط", lifecycleRunning: "قيد التشغيل", lifecycleRunningDetail: "تشغيل نشط قيد التقدم", + lifecycleStale: "قديم", + lifecycleStaleDetail: "لا يوجد نشاط جلسة حديث", lifecycleDone: "مكتمل", lifecycleDoneDetail: "تم النقل إلى المراجعة", lifecycleNeedsReview: "تحتاج إلى مراجعة", @@ -536,6 +553,14 @@ export const ar: TranslationMap = { eventMovedTo: "تم النقل إلى {status}", eventLinked: "تم ربط الجلسة", eventExecutionUpdated: "تم تحديث الوكيل", + eventAttemptStarted: "بدأت المحاولة", + eventAttemptUpdated: "تم تحديث المحاولة", + eventCommentAdded: "تمت إضافة تعليق", + eventLinkAdded: "تمت إضافة رابط", + eventProofAdded: "تمت إضافة إثبات", + eventArchived: "مؤرشف", + eventUnarchived: "غير مؤرشف", + eventStale: "جلسة قديمة", gameButton: "لعبة مصغرة", gameTitle: "مطاردة البطاقات", gameStart: "صِل إلى مربع الإطلاق.", diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index 2feeccb3438d..36affa43582d 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -499,6 +499,7 @@ export const de: TranslationMap = { editCardHelp: "Warteschlangen-Metadaten und Sitzungsübergabe aktualisieren.", newCard: "Neue Karte", newCardHelp: "Arbeit für eine Agentensitzung in die Warteschlange einreihen.", + archiveCard: "Karte archivieren", deleteCard: "Karte löschen", openSession: "Sitzung öffnen", openLinkedSession: "Verknüpfte Sitzung öffnen", @@ -515,6 +516,20 @@ export const de: TranslationMap = { fieldAgent: "Agent", fieldSession: "Sitzung", fieldLabels: "Labels", + templatesLabel: "Kartenvorlagen", + template: { + bugfix: "Bugfix", + docs: "Dokumentation", + release: "Release", + pr_review: "PR-Review", + plugin: "Plugin", + }, + badgeAttempts: "{count} Versuche", + badgeFailures: "{count} fehlgeschlagen", + badgeComments: "{count} Kommentare", + badgeLinks: "{count} Links", + badgeProof: "{count} Nachweis", + badgeStale: "veraltet", titlePlaceholder: "Kartentitel", notesPlaceholder: "Notizen, Akzeptanzkriterien, Links", labelsPlaceholder: "ui, docs", @@ -529,6 +544,8 @@ export const de: TranslationMap = { lifecycleIdleDetail: "Kein aktiver Lauf", lifecycleRunning: "Wird ausgeführt", lifecycleRunningDetail: "Aktiver Lauf läuft", + lifecycleStale: "Veraltet", + lifecycleStaleDetail: "Keine aktuelle Sitzungsaktivität", lifecycleDone: "Fertig", lifecycleDoneDetail: "Zur Überprüfung verschoben", lifecycleNeedsReview: "Überprüfung erforderlich", @@ -540,6 +557,14 @@ export const de: TranslationMap = { eventMovedTo: "Verschoben nach {status}", eventLinked: "Sitzung verknüpft", eventExecutionUpdated: "Agent aktualisiert", + eventAttemptStarted: "Versuch gestartet", + eventAttemptUpdated: "Versuch aktualisiert", + eventCommentAdded: "Kommentar hinzugefügt", + eventLinkAdded: "Link hinzugefügt", + eventProofAdded: "Nachweis hinzugefügt", + eventArchived: "Archiviert", + eventUnarchived: "Aus Archiv wiederhergestellt", + eventStale: "Veraltete Sitzung", gameButton: "Minispiel", gameTitle: "Card Chase", gameStart: "Erreiche das Startfeld.", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 0ed2273f4255..3ebf656012dc 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -494,6 +494,7 @@ export const en: TranslationMap = { editCardHelp: "Update queue metadata and session handoff.", newCard: "New card", newCardHelp: "Queue work for an agent session.", + archiveCard: "Archive card", deleteCard: "Delete card", openSession: "Open session", openLinkedSession: "Open linked session", @@ -510,6 +511,20 @@ export const en: TranslationMap = { fieldAgent: "Agent", fieldSession: "Session", fieldLabels: "Labels", + templatesLabel: "Card templates", + template: { + bugfix: "Bugfix", + docs: "Docs", + release: "Release", + pr_review: "PR review", + plugin: "Plugin", + }, + badgeAttempts: "{count} attempts", + badgeFailures: "{count} failed", + badgeComments: "{count} comments", + badgeLinks: "{count} links", + badgeProof: "{count} proof", + badgeStale: "stale", titlePlaceholder: "Card title", notesPlaceholder: "Notes, acceptance criteria, links", labelsPlaceholder: "ui, docs", @@ -524,6 +539,8 @@ export const en: TranslationMap = { lifecycleIdleDetail: "No active run", lifecycleRunning: "Running", lifecycleRunningDetail: "Active run in progress", + lifecycleStale: "Stale", + lifecycleStaleDetail: "No recent session activity", lifecycleDone: "Done", lifecycleDoneDetail: "Moved to review", lifecycleNeedsReview: "Needs review", @@ -535,6 +552,14 @@ export const en: TranslationMap = { eventMovedTo: "Moved to {status}", eventLinked: "Linked session", eventExecutionUpdated: "Agent updated", + eventAttemptStarted: "Attempt started", + eventAttemptUpdated: "Attempt updated", + eventCommentAdded: "Comment added", + eventLinkAdded: "Link added", + eventProofAdded: "Proof added", + eventArchived: "Archived", + eventUnarchived: "Unarchived", + eventStale: "Stale session", gameButton: "Mini game", gameTitle: "Card Chase", gameStart: "Reach the launch tile.", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index 0e8816d83a21..6caa20a3a37f 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -496,6 +496,7 @@ export const es: TranslationMap = { editCardHelp: "Actualiza los metadatos de la cola y la transferencia de sesión.", newCard: "Nueva tarjeta", newCardHelp: "Pon trabajo en cola para una sesión de agente.", + archiveCard: "Archivar tarjeta", deleteCard: "Eliminar tarjeta", openSession: "Abrir sesión", openLinkedSession: "Abrir sesión vinculada", @@ -512,6 +513,20 @@ export const es: TranslationMap = { fieldAgent: "Agente", fieldSession: "Sesión", fieldLabels: "Etiquetas", + templatesLabel: "Plantillas de tarjetas", + template: { + bugfix: "Corrección de errores", + docs: "Documentación", + release: "Lanzamiento", + pr_review: "Revisión de PR", + plugin: "Plugin", + }, + badgeAttempts: "{count} intentos", + badgeFailures: "{count} fallidos", + badgeComments: "{count} comentarios", + badgeLinks: "{count} enlaces", + badgeProof: "{count} prueba", + badgeStale: "obsoleta", titlePlaceholder: "Título de la tarjeta", notesPlaceholder: "Notas, criterios de aceptación, enlaces", labelsPlaceholder: "ui, docs", @@ -526,6 +541,8 @@ export const es: TranslationMap = { lifecycleIdleDetail: "No hay ninguna ejecución activa", lifecycleRunning: "En ejecución", lifecycleRunningDetail: "Ejecución activa en curso", + lifecycleStale: "Obsoleta", + lifecycleStaleDetail: "Sin actividad de sesión reciente", lifecycleDone: "Completado", lifecycleDoneDetail: "Movido a revisión", lifecycleNeedsReview: "Necesita revisión", @@ -537,6 +554,14 @@ export const es: TranslationMap = { eventMovedTo: "Movido a {status}", eventLinked: "Sesión vinculada", eventExecutionUpdated: "Agente actualizado", + eventAttemptStarted: "Intento iniciado", + eventAttemptUpdated: "Intento actualizado", + eventCommentAdded: "Comentario añadido", + eventLinkAdded: "Enlace añadido", + eventProofAdded: "Prueba añadida", + eventArchived: "Archivado", + eventUnarchived: "Desarchivado", + eventStale: "Sesión obsoleta", gameButton: "Minijuego", gameTitle: "Card Chase", gameStart: "Alcanza la casilla de lanzamiento.", diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index f4c3fc38afeb..8606b47c1b4b 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -497,6 +497,7 @@ export const fa: TranslationMap = { editCardHelp: "به‌روزرسانی فرادادهٔ صف و واگذاری نشست.", newCard: "کارت جدید", newCardHelp: "کار را برای یک نشست عامل در صف قرار دهید.", + archiveCard: "بایگانی کارت", deleteCard: "حذف کارت", openSession: "باز کردن نشست", openLinkedSession: "باز کردن نشست پیوندشده", @@ -513,6 +514,20 @@ export const fa: TranslationMap = { fieldAgent: "عامل", fieldSession: "نشست", fieldLabels: "برچسب‌ها", + templatesLabel: "قالب‌های کارت", + template: { + bugfix: "رفع باگ", + docs: "مستندات", + release: "انتشار", + pr_review: "بازبینی PR", + plugin: "افزونه", + }, + badgeAttempts: "{count} تلاش", + badgeFailures: "{count} ناموفق", + badgeComments: "{count} نظر", + badgeLinks: "{count} پیوند", + badgeProof: "{count} مدرک", + badgeStale: "قدیمی", titlePlaceholder: "عنوان کارت", notesPlaceholder: "یادداشت‌ها، معیارهای پذیرش، پیوندها", labelsPlaceholder: "ui, docs", @@ -527,6 +542,8 @@ export const fa: TranslationMap = { lifecycleIdleDetail: "اجرای فعالی وجود ندارد", lifecycleRunning: "در حال اجرا", lifecycleRunningDetail: "اجرای فعال در جریان است", + lifecycleStale: "قدیمی", + lifecycleStaleDetail: "فعالیت جلسهٔ اخیر وجود ندارد", lifecycleDone: "انجام شد", lifecycleDoneDetail: "به بازبینی منتقل شد", lifecycleNeedsReview: "نیازمند بازبینی", @@ -538,6 +555,14 @@ export const fa: TranslationMap = { eventMovedTo: "به {status} منتقل شد", eventLinked: "نشست پیوند داده شد", eventExecutionUpdated: "Agent به‌روزرسانی شد", + eventAttemptStarted: "تلاش شروع شد", + eventAttemptUpdated: "تلاش به‌روزرسانی شد", + eventCommentAdded: "نظر اضافه شد", + eventLinkAdded: "پیوند اضافه شد", + eventProofAdded: "مدرک اضافه شد", + eventArchived: "بایگانی شد", + eventUnarchived: "از بایگانی خارج شد", + eventStale: "نشست منقضی‌شده", gameButton: "بازی کوچک", gameTitle: "تعقیب کارت", gameStart: "به کاشی راه‌اندازی برسید.", diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 6add4d16bcf3..edd6cd1f7287 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -498,6 +498,7 @@ export const fr: TranslationMap = { editCardHelp: "Mettez à jour les métadonnées de la file d’attente et le transfert de session.", newCard: "Nouvelle carte", newCardHelp: "Mettez du travail en file d’attente pour une session d’agent.", + archiveCard: "Archiver la carte", deleteCard: "Supprimer la carte", openSession: "Ouvrir la session", openLinkedSession: "Ouvrir la session liée", @@ -514,6 +515,20 @@ export const fr: TranslationMap = { fieldAgent: "Agent", fieldSession: "Session", fieldLabels: "Étiquettes", + templatesLabel: "Modèles de carte", + template: { + bugfix: "Correction de bug", + docs: "Docs", + release: "Release", + pr_review: "Revue de PR", + plugin: "Plugin", + }, + badgeAttempts: "{count} tentatives", + badgeFailures: "{count} échecs", + badgeComments: "{count} commentaires", + badgeLinks: "{count} liens", + badgeProof: "{count} preuve", + badgeStale: "obsolète", titlePlaceholder: "Titre de la carte", notesPlaceholder: "Notes, critères d’acceptation, liens", labelsPlaceholder: "ui, docs", @@ -528,6 +543,8 @@ export const fr: TranslationMap = { lifecycleIdleDetail: "Aucune exécution active", lifecycleRunning: "En cours d’exécution", lifecycleRunningDetail: "Exécution active en cours", + lifecycleStale: "Obsolète", + lifecycleStaleDetail: "Aucune activité de session récente", lifecycleDone: "Terminé", lifecycleDoneDetail: "Déplacé vers la révision", lifecycleNeedsReview: "Nécessite une révision", @@ -539,6 +556,14 @@ export const fr: TranslationMap = { eventMovedTo: "Déplacé vers {status}", eventLinked: "Session liée", eventExecutionUpdated: "Agent mis à jour", + eventAttemptStarted: "Tentative démarrée", + eventAttemptUpdated: "Tentative mise à jour", + eventCommentAdded: "Commentaire ajouté", + eventLinkAdded: "Lien ajouté", + eventProofAdded: "Preuve ajoutée", + eventArchived: "Archivé", + eventUnarchived: "Désarchivé", + eventStale: "Session inactive", gameButton: "Mini-jeu", gameTitle: "Card Chase", gameStart: "Atteignez la tuile de lancement.", diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index 70209bb4e182..b0aaa7d0b8a2 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -496,6 +496,7 @@ export const id: TranslationMap = { editCardHelp: "Perbarui metadata antrean dan serah terima sesi.", newCard: "Kartu baru", newCardHelp: "Antrekan pekerjaan untuk sesi agen.", + archiveCard: "Arsipkan kartu", deleteCard: "Hapus kartu", openSession: "Buka sesi", openLinkedSession: "Buka sesi tertaut", @@ -512,6 +513,20 @@ export const id: TranslationMap = { fieldAgent: "Agen", fieldSession: "Sesi", fieldLabels: "Label", + templatesLabel: "Templat kartu", + template: { + bugfix: "Bugfix", + docs: "Docs", + release: "Release", + pr_review: "Tinjauan PR", + plugin: "Plugin", + }, + badgeAttempts: "{count} upaya", + badgeFailures: "{count} gagal", + badgeComments: "{count} komentar", + badgeLinks: "{count} tautan", + badgeProof: "{count} bukti", + badgeStale: "usang", titlePlaceholder: "Judul kartu", notesPlaceholder: "Catatan, kriteria penerimaan, tautan", labelsPlaceholder: "ui, docs", @@ -526,6 +541,8 @@ export const id: TranslationMap = { lifecycleIdleDetail: "Tidak ada run aktif", lifecycleRunning: "Berjalan", lifecycleRunningDetail: "Run aktif sedang berlangsung", + lifecycleStale: "Usang", + lifecycleStaleDetail: "Tidak ada aktivitas sesi terbaru", lifecycleDone: "Selesai", lifecycleDoneDetail: "Dipindahkan ke peninjauan", lifecycleNeedsReview: "Perlu ditinjau", @@ -537,6 +554,14 @@ export const id: TranslationMap = { eventMovedTo: "Dipindahkan ke {status}", eventLinked: "Sesi ditautkan", eventExecutionUpdated: "Agen diperbarui", + eventAttemptStarted: "Upaya dimulai", + eventAttemptUpdated: "Upaya diperbarui", + eventCommentAdded: "Komentar ditambahkan", + eventLinkAdded: "Tautan ditambahkan", + eventProofAdded: "Bukti ditambahkan", + eventArchived: "Diarsipkan", + eventUnarchived: "Dibatalkan pengarsipannya", + eventStale: "Sesi kedaluwarsa", gameButton: "Mini game", gameTitle: "Card Chase", gameStart: "Capai petak peluncuran.", diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index 9d4494a4a1e0..b2db2bf6d259 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -498,6 +498,7 @@ export const it: TranslationMap = { editCardHelp: "Aggiorna i metadati della coda e il passaggio di sessione.", newCard: "Nuova scheda", newCardHelp: "Metti in coda il lavoro per una sessione dell'agente.", + archiveCard: "Archivia scheda", deleteCard: "Elimina scheda", openSession: "Apri sessione", openLinkedSession: "Apri sessione collegata", @@ -514,6 +515,20 @@ export const it: TranslationMap = { fieldAgent: "Agente", fieldSession: "Sessione", fieldLabels: "Etichette", + templatesLabel: "Modelli di scheda", + template: { + bugfix: "Correzione bug", + docs: "Documentazione", + release: "Release", + pr_review: "Revisione PR", + plugin: "Plugin", + }, + badgeAttempts: "{count} tentativi", + badgeFailures: "{count} non riusciti", + badgeComments: "{count} commenti", + badgeLinks: "{count} link", + badgeProof: "{count} prova", + badgeStale: "obsoleto", titlePlaceholder: "Titolo scheda", notesPlaceholder: "Note, criteri di accettazione, link", labelsPlaceholder: "ui, docs", @@ -528,6 +543,8 @@ export const it: TranslationMap = { lifecycleIdleDetail: "Nessuna esecuzione attiva", lifecycleRunning: "In esecuzione", lifecycleRunningDetail: "Esecuzione attiva in corso", + lifecycleStale: "Obsoleto", + lifecycleStaleDetail: "Nessuna attività recente nella sessione", lifecycleDone: "Completato", lifecycleDoneDetail: "Spostata in revisione", lifecycleNeedsReview: "Richiede revisione", @@ -539,6 +556,14 @@ export const it: TranslationMap = { eventMovedTo: "Spostato in {status}", eventLinked: "Sessione collegata", eventExecutionUpdated: "Agente aggiornato", + eventAttemptStarted: "Tentativo avviato", + eventAttemptUpdated: "Tentativo aggiornato", + eventCommentAdded: "Commento aggiunto", + eventLinkAdded: "Link aggiunto", + eventProofAdded: "Prova aggiunta", + eventArchived: "Archiviato", + eventUnarchived: "Non archiviato", + eventStale: "Sessione obsoleta", gameButton: "Mini gioco", gameTitle: "Card Chase", gameStart: "Raggiungi la casella di lancio.", diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index c0d968163faa..87aceee6c8c8 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -499,6 +499,7 @@ export const ja_JP: TranslationMap = { editCardHelp: "キューのメタデータとセッションの引き継ぎを更新します。", newCard: "新規カード", newCardHelp: "エージェントセッションの作業をキューに追加します。", + archiveCard: "カードをアーカイブ", deleteCard: "カードを削除", openSession: "セッションを開く", openLinkedSession: "リンクされたセッションを開く", @@ -515,6 +516,20 @@ export const ja_JP: TranslationMap = { fieldAgent: "エージェント", fieldSession: "セッション", fieldLabels: "ラベル", + templatesLabel: "カードテンプレート", + template: { + bugfix: "バグ修正", + docs: "ドキュメント", + release: "リリース", + pr_review: "PR レビュー", + plugin: "プラグイン", + }, + badgeAttempts: "{count} 件の試行", + badgeFailures: "{count} 件失敗", + badgeComments: "{count} 件のコメント", + badgeLinks: "{count} 件のリンク", + badgeProof: "{count} 件の証跡", + badgeStale: "古い", titlePlaceholder: "カードのタイトル", notesPlaceholder: "メモ、受け入れ条件、リンク", labelsPlaceholder: "ui, docs", @@ -529,6 +544,8 @@ export const ja_JP: TranslationMap = { lifecycleIdleDetail: "アクティブな実行はありません", lifecycleRunning: "実行中", lifecycleRunningDetail: "アクティブな実行が進行中です", + lifecycleStale: "古い", + lifecycleStaleDetail: "最近のセッションアクティビティはありません", lifecycleDone: "完了", lifecycleDoneDetail: "レビューに移動しました", lifecycleNeedsReview: "レビューが必要", @@ -540,6 +557,14 @@ export const ja_JP: TranslationMap = { eventMovedTo: "{status} に移動しました", eventLinked: "セッションをリンクしました", eventExecutionUpdated: "Agent が更新されました", + eventAttemptStarted: "試行を開始しました", + eventAttemptUpdated: "試行を更新しました", + eventCommentAdded: "コメントを追加しました", + eventLinkAdded: "リンクを追加しました", + eventProofAdded: "証跡を追加しました", + eventArchived: "アーカイブ済み", + eventUnarchived: "アーカイブ解除済み", + eventStale: "古いセッション", gameButton: "ミニゲーム", gameTitle: "Card Chase", gameStart: "ローンチタイルに到達してください。", diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index 30d2e3f56f6d..4b8a25f0dc41 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -495,6 +495,7 @@ export const ko: TranslationMap = { editCardHelp: "대기열 메타데이터와 세션 인계를 업데이트합니다.", newCard: "새 카드", newCardHelp: "에이전트 세션을 위한 작업을 대기열에 추가합니다.", + archiveCard: "카드 보관", deleteCard: "카드 삭제", openSession: "세션 열기", openLinkedSession: "연결된 세션 열기", @@ -511,6 +512,20 @@ export const ko: TranslationMap = { fieldAgent: "에이전트", fieldSession: "세션", fieldLabels: "레이블", + templatesLabel: "카드 템플릿", + template: { + bugfix: "버그 수정", + docs: "문서", + release: "릴리스", + pr_review: "PR 리뷰", + plugin: "플러그인", + }, + badgeAttempts: "{count}회 시도", + badgeFailures: "{count}개 실패", + badgeComments: "댓글 {count}개", + badgeLinks: "링크 {count}개", + badgeProof: "증빙 {count}개", + badgeStale: "오래됨", titlePlaceholder: "카드 제목", notesPlaceholder: "메모, 승인 기준, 링크", labelsPlaceholder: "ui, docs", @@ -525,6 +540,8 @@ export const ko: TranslationMap = { lifecycleIdleDetail: "활성 실행 없음", lifecycleRunning: "실행 중", lifecycleRunningDetail: "활성 실행이 진행 중입니다", + lifecycleStale: "오래됨", + lifecycleStaleDetail: "최근 세션 활동 없음", lifecycleDone: "완료", lifecycleDoneDetail: "검토로 이동됨", lifecycleNeedsReview: "검토 필요", @@ -536,6 +553,14 @@ export const ko: TranslationMap = { eventMovedTo: "{status}(으)로 이동됨", eventLinked: "연결된 세션", eventExecutionUpdated: "Agent 업데이트됨", + eventAttemptStarted: "시도 시작됨", + eventAttemptUpdated: "시도 업데이트됨", + eventCommentAdded: "댓글 추가됨", + eventLinkAdded: "링크 추가됨", + eventProofAdded: "증빙 추가됨", + eventArchived: "보관됨", + eventUnarchived: "보관 해제됨", + eventStale: "오래된 세션", gameButton: "미니 게임", gameTitle: "Card Chase", gameStart: "출발 타일에 도달하세요.", diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index 8e6dbf985741..52def70b918d 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -498,6 +498,7 @@ export const nl: TranslationMap = { editCardHelp: "Werk wachtrijmetadata en sessieoverdracht bij.", newCard: "Nieuwe kaart", newCardHelp: "Zet werk in de wachtrij voor een agentsessie.", + archiveCard: "Kaart archiveren", deleteCard: "Kaart verwijderen", openSession: "Sessie openen", openLinkedSession: "Gekoppelde sessie openen", @@ -514,6 +515,20 @@ export const nl: TranslationMap = { fieldAgent: "Agent", fieldSession: "Sessie", fieldLabels: "Labels", + templatesLabel: "Kaartsjablonen", + template: { + bugfix: "Bugfix", + docs: "Docs", + release: "Release", + pr_review: "PR review", + plugin: "Plugin", + }, + badgeAttempts: "{count} pogingen", + badgeFailures: "{count} mislukt", + badgeComments: "{count} opmerkingen", + badgeLinks: "{count} links", + badgeProof: "{count} bewijs", + badgeStale: "verouderd", titlePlaceholder: "Kaarttitel", notesPlaceholder: "Notities, acceptatiecriteria, links", labelsPlaceholder: "ui, docs", @@ -528,6 +543,8 @@ export const nl: TranslationMap = { lifecycleIdleDetail: "Geen actieve run", lifecycleRunning: "Actief", lifecycleRunningDetail: "Actieve run bezig", + lifecycleStale: "Verouderd", + lifecycleStaleDetail: "Geen recente sessieactiviteit", lifecycleDone: "Voltooid", lifecycleDoneDetail: "Verplaatst naar review", lifecycleNeedsReview: "Review nodig", @@ -539,6 +556,14 @@ export const nl: TranslationMap = { eventMovedTo: "Verplaatst naar {status}", eventLinked: "Gekoppelde sessie", eventExecutionUpdated: "Agent bijgewerkt", + eventAttemptStarted: "Poging gestart", + eventAttemptUpdated: "Poging bijgewerkt", + eventCommentAdded: "Opmerking toegevoegd", + eventLinkAdded: "Link toegevoegd", + eventProofAdded: "Bewijs toegevoegd", + eventArchived: "Gearchiveerd", + eventUnarchived: "Uit archief gehaald", + eventStale: "Verlopen sessie", gameButton: "Minigame", gameTitle: "Card Chase", gameStart: "Bereik de lanceringstegel.", diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index 70ac0b579ca3..2b9653264555 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -497,6 +497,7 @@ export const pl: TranslationMap = { editCardHelp: "Zaktualizuj metadane kolejki i przekazanie sesji.", newCard: "Nowa karta", newCardHelp: "Dodaj zadanie do kolejki dla sesji agenta.", + archiveCard: "Archiwizuj kartę", deleteCard: "Usuń kartę", openSession: "Otwórz sesję", openLinkedSession: "Otwórz powiązaną sesję", @@ -513,6 +514,20 @@ export const pl: TranslationMap = { fieldAgent: "Agent", fieldSession: "Sesja", fieldLabels: "Etykiety", + templatesLabel: "Szablony kart", + template: { + bugfix: "Poprawka błędu", + docs: "Dokumentacja", + release: "Wydanie", + pr_review: "Przegląd PR", + plugin: "Wtyczka", + }, + badgeAttempts: "{count} prób", + badgeFailures: "{count} nieudanych", + badgeComments: "{count} komentarzy", + badgeLinks: "{count} linków", + badgeProof: "{count} dowodów", + badgeStale: "nieaktualne", titlePlaceholder: "Tytuł karty", notesPlaceholder: "Notatki, kryteria akceptacji, linki", labelsPlaceholder: "ui, docs", @@ -527,6 +542,8 @@ export const pl: TranslationMap = { lifecycleIdleDetail: "Brak aktywnego uruchomienia", lifecycleRunning: "Uruchomiono", lifecycleRunningDetail: "Trwa aktywne uruchomienie", + lifecycleStale: "Nieaktualne", + lifecycleStaleDetail: "Brak ostatniej aktywności w sesji", lifecycleDone: "Gotowe", lifecycleDoneDetail: "Przeniesiono do przeglądu", lifecycleNeedsReview: "Wymaga przeglądu", @@ -538,6 +555,14 @@ export const pl: TranslationMap = { eventMovedTo: "Przeniesiono do {status}", eventLinked: "Połączona sesja", eventExecutionUpdated: "Agent zaktualizowany", + eventAttemptStarted: "Rozpoczęto próbę", + eventAttemptUpdated: "Zaktualizowano próbę", + eventCommentAdded: "Dodano komentarz", + eventLinkAdded: "Dodano link", + eventProofAdded: "Dodano dowód", + eventArchived: "Zarchiwizowano", + eventUnarchived: "Przywrócono z archiwum", + eventStale: "Nieaktualna sesja", gameButton: "Minigra", gameTitle: "Pościg za kartą", gameStart: "Dotrzyj do pola uruchomienia.", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 57d92d41fb96..c4b9676122ce 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -496,6 +496,7 @@ export const pt_BR: TranslationMap = { editCardHelp: "Atualize os metadados da fila e a transferência de sessão.", newCard: "Novo cartão", newCardHelp: "Enfileire trabalho para uma sessão de agente.", + archiveCard: "Arquivar cartão", deleteCard: "Excluir cartão", openSession: "Abrir sessão", openLinkedSession: "Abrir sessão vinculada", @@ -512,6 +513,20 @@ export const pt_BR: TranslationMap = { fieldAgent: "Agente", fieldSession: "Sessão", fieldLabels: "Etiquetas", + templatesLabel: "Modelos de cartão", + template: { + bugfix: "Correção de bug", + docs: "Documentação", + release: "Release", + pr_review: "Revisão de PR", + plugin: "Plugin", + }, + badgeAttempts: "{count} tentativas", + badgeFailures: "{count} falharam", + badgeComments: "{count} comentários", + badgeLinks: "{count} links", + badgeProof: "{count} prova", + badgeStale: "desatualizado", titlePlaceholder: "Título do cartão", notesPlaceholder: "Notas, critérios de aceitação, links", labelsPlaceholder: "ui, docs", @@ -526,6 +541,8 @@ export const pt_BR: TranslationMap = { lifecycleIdleDetail: "Nenhuma execução ativa", lifecycleRunning: "Em execução", lifecycleRunningDetail: "Execução ativa em andamento", + lifecycleStale: "Desatualizado", + lifecycleStaleDetail: "Nenhuma atividade de sessão recente", lifecycleDone: "Concluída", lifecycleDoneDetail: "Movido para revisão", lifecycleNeedsReview: "Precisa de revisão", @@ -537,6 +554,14 @@ export const pt_BR: TranslationMap = { eventMovedTo: "Movido para {status}", eventLinked: "Sessão vinculada", eventExecutionUpdated: "Agente atualizado", + eventAttemptStarted: "Tentativa iniciada", + eventAttemptUpdated: "Tentativa atualizada", + eventCommentAdded: "Comentário adicionado", + eventLinkAdded: "Link adicionado", + eventProofAdded: "Prova adicionada", + eventArchived: "Arquivado", + eventUnarchived: "Desarquivado", + eventStale: "Sessão obsoleta", gameButton: "Mini game", gameTitle: "Card Chase", gameStart: "Alcance o bloco de lançamento.", diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index aa7c0c076b45..1caee97031a0 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -494,6 +494,7 @@ export const th: TranslationMap = { editCardHelp: "อัปเดตข้อมูลเมตาของคิวและการส่งต่อเซสชัน", newCard: "การ์ดใหม่", newCardHelp: "จัดคิวงานสำหรับเซสชันของเอเจนต์", + archiveCard: "เก็บถาวรการ์ด", deleteCard: "ลบการ์ด", openSession: "เปิดเซสชัน", openLinkedSession: "เปิดเซสชันที่ลิงก์ไว้", @@ -510,6 +511,20 @@ export const th: TranslationMap = { fieldAgent: "เอเจนต์", fieldSession: "เซสชัน", fieldLabels: "ป้ายกำกับ", + templatesLabel: "เทมเพลตการ์ด", + template: { + bugfix: "แก้ไขบั๊ก", + docs: "เอกสาร", + release: "รีลีส", + pr_review: "รีวิว PR", + plugin: "ปลั๊กอิน", + }, + badgeAttempts: "{count} ครั้ง", + badgeFailures: "ล้มเหลว {count} รายการ", + badgeComments: "{count} ความคิดเห็น", + badgeLinks: "{count} ลิงก์", + badgeProof: "หลักฐาน {count} รายการ", + badgeStale: "ค้างนาน", titlePlaceholder: "ชื่อการ์ด", notesPlaceholder: "บันทึก, เกณฑ์การยอมรับ, ลิงก์", labelsPlaceholder: "ui, docs", @@ -524,6 +539,8 @@ export const th: TranslationMap = { lifecycleIdleDetail: "ไม่มีการรันที่ทำงานอยู่", lifecycleRunning: "กำลังทำงาน", lifecycleRunningDetail: "กำลังมีการรันที่ทำงานอยู่", + lifecycleStale: "ค้างนาน", + lifecycleStaleDetail: "ไม่มีกิจกรรมเซสชันล่าสุด", lifecycleDone: "เสร็จสิ้น", lifecycleDoneDetail: "ย้ายไปยังการตรวจทานแล้ว", lifecycleNeedsReview: "ต้องตรวจทาน", @@ -535,6 +552,14 @@ export const th: TranslationMap = { eventMovedTo: "ย้ายไปยัง {status}", eventLinked: "เซสชันที่ลิงก์แล้ว", eventExecutionUpdated: "เอเจนต์อัปเดตแล้ว", + eventAttemptStarted: "เริ่มความพยายามแล้ว", + eventAttemptUpdated: "อัปเดตความพยายามแล้ว", + eventCommentAdded: "เพิ่มความคิดเห็นแล้ว", + eventLinkAdded: "เพิ่มลิงก์แล้ว", + eventProofAdded: "เพิ่มหลักฐานแล้ว", + eventArchived: "เก็บถาวรแล้ว", + eventUnarchived: "ยกเลิกการเก็บถาวรแล้ว", + eventStale: "เซสชันหมดอายุ", gameButton: "มินิเกม", gameTitle: "Card Chase", gameStart: "ไปให้ถึงช่องเปิดตัว", diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index bdf69b29087d..e99441221528 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -498,6 +498,7 @@ export const tr: TranslationMap = { editCardHelp: "Kuyruk meta verilerini ve oturum devrini güncelleyin.", newCard: "Yeni kart", newCardHelp: "Bir ajan oturumu için işi kuyruğa alın.", + archiveCard: "Kartı arşivle", deleteCard: "Kartı sil", openSession: "Oturumu aç", openLinkedSession: "Bağlantılı oturumu aç", @@ -514,6 +515,20 @@ export const tr: TranslationMap = { fieldAgent: "Aracı", fieldSession: "Oturum", fieldLabels: "Etiketler", + templatesLabel: "Kart şablonları", + template: { + bugfix: "Hata düzeltmesi", + docs: "Belgeler", + release: "Sürüm", + pr_review: "PR incelemesi", + plugin: "Eklenti", + }, + badgeAttempts: "{count} deneme", + badgeFailures: "{count} başarısız", + badgeComments: "{count} yorum", + badgeLinks: "{count} bağlantı", + badgeProof: "{count} kanıt", + badgeStale: "eski", titlePlaceholder: "Kart başlığı", notesPlaceholder: "Notlar, kabul kriterleri, bağlantılar", labelsPlaceholder: "ui, docs", @@ -528,6 +543,8 @@ export const tr: TranslationMap = { lifecycleIdleDetail: "Etkin çalışma yok", lifecycleRunning: "Çalışıyor", lifecycleRunningDetail: "Etkin çalışma devam ediyor", + lifecycleStale: "Eski", + lifecycleStaleDetail: "Yakın zamanda oturum etkinliği yok", lifecycleDone: "Tamamlandı", lifecycleDoneDetail: "İncelemeye taşındı", lifecycleNeedsReview: "İnceleme gerekli", @@ -539,6 +556,14 @@ export const tr: TranslationMap = { eventMovedTo: "{status} durumuna taşındı", eventLinked: "Oturum bağlandı", eventExecutionUpdated: "Aracı güncellendi", + eventAttemptStarted: "Deneme başlatıldı", + eventAttemptUpdated: "Deneme güncellendi", + eventCommentAdded: "Yorum eklendi", + eventLinkAdded: "Bağlantı eklendi", + eventProofAdded: "Kanıt eklendi", + eventArchived: "Arşivlendi", + eventUnarchived: "Arşivden çıkarıldı", + eventStale: "Eski oturum", gameButton: "Mini oyun", gameTitle: "Kart Takibi", gameStart: "Başlatma karesine ulaşın.", diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index f2cf004fcceb..d87d8f6cd012 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -497,6 +497,7 @@ export const uk: TranslationMap = { editCardHelp: "Оновіть метадані черги та передавання сесії.", newCard: "Нова картка", newCardHelp: "Поставте роботу в чергу для сесії агента.", + archiveCard: "Архівувати картку", deleteCard: "Видалити картку", openSession: "Відкрити сесію", openLinkedSession: "Відкрити пов’язану сесію", @@ -513,6 +514,20 @@ export const uk: TranslationMap = { fieldAgent: "Агент", fieldSession: "Сеанс", fieldLabels: "Мітки", + templatesLabel: "Шаблони карток", + template: { + bugfix: "Виправлення помилки", + docs: "Документація", + release: "Реліз", + pr_review: "Перегляд PR", + plugin: "Плагін", + }, + badgeAttempts: "{count} спроб", + badgeFailures: "{count} невдалих", + badgeComments: "{count} коментарів", + badgeLinks: "{count} посилань", + badgeProof: "{count} доказів", + badgeStale: "застаріле", titlePlaceholder: "Заголовок картки", notesPlaceholder: "Нотатки, критерії приймання, посилання", labelsPlaceholder: "ui, docs", @@ -527,6 +542,8 @@ export const uk: TranslationMap = { lifecycleIdleDetail: "Немає активного запуску", lifecycleRunning: "Запущено", lifecycleRunningDetail: "Виконується активний запуск", + lifecycleStale: "Застаріле", + lifecycleStaleDetail: "Немає нещодавньої активності сеансу", lifecycleDone: "Готово", lifecycleDoneDetail: "Переміщено на перевірку", lifecycleNeedsReview: "Потребує перевірки", @@ -538,6 +555,14 @@ export const uk: TranslationMap = { eventMovedTo: "Переміщено до {status}", eventLinked: "Пов’язаний сеанс", eventExecutionUpdated: "Агент оновлено", + eventAttemptStarted: "Спробу розпочато", + eventAttemptUpdated: "Спробу оновлено", + eventCommentAdded: "Коментар додано", + eventLinkAdded: "Посилання додано", + eventProofAdded: "Доказ додано", + eventArchived: "Заархівовано", + eventUnarchived: "Розархівовано", + eventStale: "Застарілий сеанс", gameButton: "Мінігра", gameTitle: "Card Chase", gameStart: "Дістаньтеся клітинки запуску.", diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index c64149256daa..482e01a68b92 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -496,6 +496,7 @@ export const vi: TranslationMap = { editCardHelp: "Cập nhật siêu dữ liệu hàng đợi và bàn giao phiên.", newCard: "Thẻ mới", newCardHelp: "Đưa công việc vào hàng đợi cho một phiên agent.", + archiveCard: "Lưu trữ thẻ", deleteCard: "Xóa thẻ", openSession: "Mở phiên", openLinkedSession: "Mở phiên được liên kết", @@ -512,6 +513,20 @@ export const vi: TranslationMap = { fieldAgent: "Agent", fieldSession: "Phiên", fieldLabels: "Nhãn", + templatesLabel: "Mẫu thẻ", + template: { + bugfix: "Sửa lỗi", + docs: "Tài liệu", + release: "Bản phát hành", + pr_review: "Đánh giá PR", + plugin: "Plugin", + }, + badgeAttempts: "{count} lần thử", + badgeFailures: "{count} thất bại", + badgeComments: "{count} bình luận", + badgeLinks: "{count} liên kết", + badgeProof: "{count} bằng chứng", + badgeStale: "cũ", titlePlaceholder: "Tiêu đề thẻ", notesPlaceholder: "Ghi chú, tiêu chí chấp nhận, liên kết", labelsPlaceholder: "ui, docs", @@ -526,6 +541,8 @@ export const vi: TranslationMap = { lifecycleIdleDetail: "Không có lượt chạy đang hoạt động", lifecycleRunning: "Đang chạy", lifecycleRunningDetail: "Lượt chạy đang hoạt động đang diễn ra", + lifecycleStale: "Cũ", + lifecycleStaleDetail: "Không có hoạt động phiên gần đây", lifecycleDone: "Hoàn tất", lifecycleDoneDetail: "Đã chuyển sang xem xét", lifecycleNeedsReview: "Cần xem xét", @@ -537,6 +554,14 @@ export const vi: TranslationMap = { eventMovedTo: "Đã di chuyển đến {status}", eventLinked: "Phiên đã liên kết", eventExecutionUpdated: "Agent đã cập nhật", + eventAttemptStarted: "Đã bắt đầu lần thử", + eventAttemptUpdated: "Đã cập nhật lần thử", + eventCommentAdded: "Đã thêm bình luận", + eventLinkAdded: "Đã thêm liên kết", + eventProofAdded: "Đã thêm bằng chứng", + 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.", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index c65766db2ee0..34c711f85f6a 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -493,6 +493,7 @@ export const zh_CN: TranslationMap = { editCardHelp: "更新队列元数据和会话交接。", newCard: "新建卡片", newCardHelp: "为代理会话排队工作。", + archiveCard: "归档卡片", deleteCard: "删除卡片", openSession: "打开会话", openLinkedSession: "打开关联会话", @@ -509,6 +510,20 @@ export const zh_CN: TranslationMap = { fieldAgent: "代理", fieldSession: "会话", fieldLabels: "标签", + templatesLabel: "卡片模板", + template: { + bugfix: "Bugfix", + docs: "Docs", + release: "Release", + pr_review: "PR review", + plugin: "Plugin", + }, + badgeAttempts: "{count} 次尝试", + badgeFailures: "{count} 次失败", + badgeComments: "{count} 条评论", + badgeLinks: "{count} 个链接", + badgeProof: "{count} 个证明", + badgeStale: "已过期", titlePlaceholder: "卡片标题", notesPlaceholder: "备注、验收标准、链接", labelsPlaceholder: "ui, docs", @@ -523,6 +538,8 @@ export const zh_CN: TranslationMap = { lifecycleIdleDetail: "无活动运行", lifecycleRunning: "运行中", lifecycleRunningDetail: "活动运行正在进行", + lifecycleStale: "已过期", + lifecycleStaleDetail: "最近没有会话活动", lifecycleDone: "已完成", lifecycleDoneDetail: "已移至审核", lifecycleNeedsReview: "需要审核", @@ -534,6 +551,14 @@ export const zh_CN: TranslationMap = { eventMovedTo: "已移至 {status}", eventLinked: "已关联会话", eventExecutionUpdated: "Agent 已更新", + eventAttemptStarted: "尝试已开始", + eventAttemptUpdated: "尝试已更新", + eventCommentAdded: "评论已添加", + eventLinkAdded: "链接已添加", + eventProofAdded: "证明已添加", + eventArchived: "已归档", + eventUnarchived: "已取消归档", + eventStale: "过期会话", gameButton: "迷你游戏", gameTitle: "卡片追逐", gameStart: "到达发布图块。", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index ee9c47f0ddc2..f7c26623f6bc 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -493,6 +493,7 @@ export const zh_TW: TranslationMap = { editCardHelp: "更新佇列中繼資料與工作階段交接。", newCard: "新增卡片", newCardHelp: "為代理程式工作階段排入工作。", + archiveCard: "封存卡片", deleteCard: "刪除卡片", openSession: "開啟工作階段", openLinkedSession: "開啟連結的工作階段", @@ -509,6 +510,20 @@ export const zh_TW: TranslationMap = { fieldAgent: "代理", fieldSession: "工作階段", fieldLabels: "標籤", + templatesLabel: "卡片範本", + template: { + bugfix: "錯誤修正", + docs: "文件", + release: "發佈", + pr_review: "PR 審查", + plugin: "外掛程式", + }, + badgeAttempts: "{count} 次嘗試", + badgeFailures: "{count} 個失敗", + badgeComments: "{count} 則留言", + badgeLinks: "{count} 個連結", + badgeProof: "{count} 個證明", + badgeStale: "過期", titlePlaceholder: "卡片標題", notesPlaceholder: "備註、驗收標準、連結", labelsPlaceholder: "ui, docs", @@ -523,6 +538,8 @@ export const zh_TW: TranslationMap = { lifecycleIdleDetail: "沒有進行中的執行", lifecycleRunning: "執行中", lifecycleRunningDetail: "正在執行中", + lifecycleStale: "過期", + lifecycleStaleDetail: "最近沒有工作階段活動", lifecycleDone: "完成", lifecycleDoneDetail: "已移至審查", lifecycleNeedsReview: "需要審查", @@ -534,6 +551,14 @@ export const zh_TW: TranslationMap = { eventMovedTo: "已移動至 {status}", eventLinked: "已連結工作階段", eventExecutionUpdated: "代理程式已更新", + eventAttemptStarted: "嘗試已開始", + eventAttemptUpdated: "嘗試已更新", + eventCommentAdded: "留言已新增", + eventLinkAdded: "連結已新增", + eventProofAdded: "證明已新增", + eventArchived: "已封存", + eventUnarchived: "已取消封存", + eventStale: "過期工作階段", gameButton: "小遊戲", gameTitle: "卡片追逐", gameStart: "到達啟動方格。", diff --git a/ui/src/styles/workboard.css b/ui/src/styles/workboard.css index 06f148682a53..42d8083a0df6 100644 --- a/ui/src/styles/workboard.css +++ b/ui/src/styles/workboard.css @@ -28,7 +28,9 @@ .workboard-toolbar__filters, .workboard-toolbar__actions, .workboard-draft__meta, +.workboard-template-strip, .workboard-card__actions, +.workboard-card__badges, .workboard-card__meta, .workboard-card__top, .workboard-labels { @@ -167,6 +169,21 @@ min-width: 0; } +.workboard-template-strip { + gap: 6px; +} + +.workboard-template-strip .btn { + min-height: 28px; + padding-inline: 9px; +} + +.workboard-template-strip__button--active { + border-color: color-mix(in srgb, var(--accent) 50%, var(--border-strong)); + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); +} + .workboard-draft__title { font-weight: 650; width: 100%; @@ -457,6 +474,10 @@ justify-content: flex-end; } +.workboard-card__badges { + gap: 5px; +} + .workboard-card__execution-controls { display: grid; grid-template-columns: repeat(2, minmax(72px, 1fr)); @@ -518,6 +539,7 @@ } .workboard-card__priority, +.workboard-card__badges span, .workboard-live, .workboard-lifecycle, .workboard-labels span { diff --git a/ui/src/ui/controllers/workboard.test.ts b/ui/src/ui/controllers/workboard.test.ts index 159eb2ed7435..14fdd2beee1f 100644 --- a/ui/src/ui/controllers/workboard.test.ts +++ b/ui/src/ui/controllers/workboard.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { GatewaySessionRow } from "../types.ts"; import { + archiveWorkboardCard, captureSessionToWorkboard, createWorkboardCard, getWorkboardLifecycle, @@ -48,7 +49,7 @@ const sampleCard: WorkboardCard = { const sampleSession: GatewaySessionRow = { key: "agent:main:dashboard:1", kind: "direct", - updatedAt: 2, + updatedAt: Date.now(), displayName: "Dashboard session", hasActiveRun: true, status: "running", @@ -97,6 +98,32 @@ describe("workboard controller", () => { expect(state.draftSessionKey).toBe(""); }); + it("creates template-backed cards from draft state", async () => { + const host = {}; + const state = getWorkboardState(host); + state.draftTitle = "Fix: flaky worker"; + state.draftTemplateId = "bugfix"; + const created = { + ...sampleCard, + id: "card-2", + title: "Fix: flaky worker", + metadata: { templateId: "bugfix" }, + } satisfies WorkboardCard; + const client = createClient({ "workboard.cards.create": { card: created } }); + + await createWorkboardCard({ host, client: client as never }); + + expect(client.request).toHaveBeenCalledWith( + "workboard.cards.create", + expect.objectContaining({ + title: "Fix: flaky worker", + templateId: "bugfix", + }), + ); + expect(state.cards[0]?.metadata?.templateId).toBe("bugfix"); + expect(state.draftTemplateId).toBe(""); + }); + it("updates cards from draft state when editing", async () => { const host = {}; const state = getWorkboardState(host); @@ -232,6 +259,39 @@ describe("workboard controller", () => { expect(client.request).not.toHaveBeenCalled(); }); + it("restores archived captured sessions instead of leaving them hidden", async () => { + const host = {}; + const state = getWorkboardState(host); + const archived = { + ...sampleCard, + sessionKey: sampleSession.key, + metadata: { archivedAt: 10 }, + } satisfies WorkboardCard; + const restored = { + ...archived, + metadata: {}, + } satisfies WorkboardCard; + state.loaded = true; + state.cards = [archived]; + const client = createClient({ + "workboard.cards.archive": { card: restored }, + }); + + const card = await captureSessionToWorkboard({ + host, + client: client as never, + session: sampleSession, + }); + + expect(card).toMatchObject({ id: restored.id, sessionKey: sampleSession.key }); + expect(card?.metadata?.archivedAt).toBeUndefined(); + expect(client.request).toHaveBeenCalledWith("workboard.cards.archive", { + id: archived.id, + archived: false, + }); + expect(state.cards[0]?.metadata?.archivedAt).toBeUndefined(); + }); + it("does not start duplicate capture requests while a session is in flight", async () => { const host = {}; const state = getWorkboardState(host); @@ -429,6 +489,54 @@ describe("workboard controller", () => { ); }); + it("resets execution start time when retrying a card run", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1234); + try { + const host = {}; + const previous = { + ...sampleCard, + execution: { + id: "card-1:codex", + kind: "agent-session", + engine: "codex", + mode: "autonomous", + status: "blocked", + model: "openai/gpt-5.5", + sessionKey: "agent:main:dashboard:1", + runId: "run-1", + startedAt: 10, + updatedAt: 20, + }, + } satisfies WorkboardCard; + const client = createClient({ + "sessions.create": { key: "agent:main:dashboard:1", runId: "run-2" }, + "workboard.cards.update": { card: previous }, + }); + + await startWorkboardCard({ + host, + client: client as never, + card: previous, + engine: "codex", + }); + + expect(client.request).toHaveBeenNthCalledWith( + 2, + "workboard.cards.update", + expect.objectContaining({ + patch: expect.objectContaining({ + execution: expect.objectContaining({ + runId: "run-2", + startedAt: 1234, + }), + }), + }), + ); + } finally { + nowSpy.mockRestore(); + } + }); + it("starts a manual Claude execution without sending the card prompt", async () => { const host = {}; const running = { @@ -570,6 +678,35 @@ describe("workboard controller", () => { state: "failed", targetStatus: "blocked", }); + expect( + getWorkboardLifecycle(linked, [ + { + ...sampleSession, + hasActiveRun: false, + status: "running", + updatedAt: Date.now() - 31 * 60 * 1000, + }, + ]), + ).toMatchObject({ + state: "stale", + targetStatus: "running", + }); + expect( + getWorkboardLifecycle(linked, [ + { ...sampleSession, hasActiveRun: true, updatedAt: Date.now() - 31 * 60 * 1000 }, + ]), + ).toMatchObject({ + state: "running", + targetStatus: "running", + }); + expect( + getWorkboardLifecycle(linked, [ + { ...sampleSession, hasActiveRun: undefined, updatedAt: Date.now() - 31 * 60 * 1000 }, + ]), + ).toMatchObject({ + state: "running", + targetStatus: "running", + }); expect( getWorkboardLifecycle( { @@ -626,6 +763,124 @@ describe("workboard controller", () => { expect(state.cards.find((card) => card.id === "card-review")?.status).toBe("review"); }); + it("moves stale running sessions into running while recording stale metadata", async () => { + const host = {}; + const state = getWorkboardState(host); + const staleUpdatedAt = Date.now() - 31 * 60 * 1000; + const linked = { + ...sampleCard, + sessionKey: sampleSession.key, + metadata: { + comments: [{ id: "comment-1", body: "Keep me", createdAt: 1 }], + }, + } satisfies WorkboardCard; + state.loaded = true; + state.cards = [linked]; + const client = createClient({ + "workboard.cards.update": { + card: { + ...linked, + status: "running", + metadata: { + stale: { + detectedAt: 1, + lastSessionUpdatedAt: staleUpdatedAt, + reason: "Linked session has not reported recent activity.", + }, + }, + }, + }, + }); + + await syncWorkboardLifecycle({ + host, + client: client as never, + sessions: [{ ...sampleSession, updatedAt: staleUpdatedAt, hasActiveRun: false }], + }); + + expect(client.request).toHaveBeenCalledWith("workboard.cards.update", { + id: "card-1", + patch: { + status: "running", + metadata: { + stale: expect.objectContaining({ + lastSessionUpdatedAt: staleUpdatedAt, + reason: "Linked session has not reported recent activity.", + }), + }, + }, + }); + }); + + it("syncs stale session metadata and clears it when the session recovers", async () => { + const host = {}; + const state = getWorkboardState(host); + const linked = { + ...sampleCard, + status: "running", + sessionKey: sampleSession.key, + metadata: { + comments: [{ id: "comment-1", body: "Keep me", createdAt: 1 }], + stale: { + detectedAt: 1, + lastSessionUpdatedAt: 1, + reason: "Linked session has not reported recent activity.", + }, + }, + } satisfies WorkboardCard; + state.loaded = true; + state.cards = [linked]; + const client = createClient({ + "workboard.cards.update": { + card: { ...linked, metadata: undefined, updatedAt: 3 }, + }, + }); + + await syncWorkboardLifecycle({ + host, + client: client as never, + sessions: [{ ...sampleSession, updatedAt: Date.now(), hasActiveRun: true }], + }); + + expect(client.request).toHaveBeenCalledWith("workboard.cards.update", { + id: "card-1", + patch: { + metadata: { + stale: null, + }, + }, + }); + }); + + it("does not rewrite unchanged stale session metadata", async () => { + const host = {}; + const state = getWorkboardState(host); + const staleUpdatedAt = Date.now() - 31 * 60 * 1000; + const linked = { + ...sampleCard, + status: "running", + sessionKey: sampleSession.key, + metadata: { + stale: { + detectedAt: 1, + lastSessionUpdatedAt: staleUpdatedAt, + reason: "Linked session has not reported recent activity.", + }, + }, + } satisfies WorkboardCard; + state.loaded = true; + state.cards = [linked]; + const client = createClient({ "workboard.cards.update": { card: linked } }); + + await syncWorkboardLifecycle({ + host, + client: client as never, + sessions: [{ ...sampleSession, updatedAt: staleUpdatedAt, hasActiveRun: false }], + }); + + expect(client.request).not.toHaveBeenCalled(); + }); + it("does not mark executions blocked when the linked session is missing from the current list", async () => { const host = {}; const state = getWorkboardState(host); @@ -774,6 +1029,27 @@ describe("workboard controller", () => { expect(getWorkboardState(host).cards[0]).toMatchObject({ status: "blocked" }); }); + it("archives cards through the plugin gateway method", async () => { + const host = {}; + const archived = { + ...sampleCard, + metadata: { archivedAt: 20 }, + } satisfies WorkboardCard; + const client = createClient({ "workboard.cards.archive": { card: archived } }); + + await archiveWorkboardCard({ + host, + client: client as never, + cardId: "card-1", + }); + + expect(client.request).toHaveBeenCalledWith("workboard.cards.archive", { + id: "card-1", + archived: true, + }); + expect(getWorkboardState(host).cards[0]?.metadata?.archivedAt).toBe(20); + }); + it("falls back to the active session abort when the stored run id is stale", async () => { const host = {}; const linked = { ...sampleCard, sessionKey: sampleSession.key, runId: "old-run" }; diff --git a/ui/src/ui/controllers/workboard.ts b/ui/src/ui/controllers/workboard.ts index 0ca26b72cd21..058bbe557acb 100644 --- a/ui/src/ui/controllers/workboard.ts +++ b/ui/src/ui/controllers/workboard.ts @@ -26,7 +26,25 @@ export const WORKBOARD_EVENT_KINDS = [ "moved", "linked", "execution_updated", + "attempt_started", + "attempt_updated", + "comment_added", + "link_added", + "proof_added", + "archived", + "unarchived", + "stale", ] as const; +export const WORKBOARD_ATTEMPT_STATUSES = [ + "running", + "succeeded", + "failed", + "blocked", + "stopped", +] as const; +export const WORKBOARD_LINK_TYPES = ["blocks", "blocked_by", "relates_to"] as const; +export const WORKBOARD_PROOF_STATUSES = ["passed", "failed", "skipped", "unknown"] as const; +export const WORKBOARD_TEMPLATE_IDS = ["bugfix", "docs", "release", "pr_review", "plugin"] as const; export const WORKBOARD_ENGINE_MODELS = { codex: "openai/gpt-5.5", @@ -39,6 +57,10 @@ export type WorkboardExecutionEngine = (typeof WORKBOARD_EXECUTION_ENGINES)[numb export type WorkboardExecutionMode = (typeof WORKBOARD_EXECUTION_MODES)[number]; export type WorkboardExecutionStatus = (typeof WORKBOARD_EXECUTION_STATUSES)[number]; export type WorkboardEventKind = (typeof WORKBOARD_EVENT_KINDS)[number]; +export type WorkboardAttemptStatus = (typeof WORKBOARD_ATTEMPT_STATUSES)[number]; +export type WorkboardLinkType = (typeof WORKBOARD_LINK_TYPES)[number]; +export type WorkboardProofStatus = (typeof WORKBOARD_PROOF_STATUSES)[number]; +export type WorkboardTemplateId = (typeof WORKBOARD_TEMPLATE_IDS)[number]; export type WorkboardExecution = { id: string; @@ -63,6 +85,62 @@ export type WorkboardEvent = { runId?: string; }; +export type WorkboardRunAttempt = { + id: string; + status: WorkboardAttemptStatus; + startedAt: number; + endedAt?: number; + engine?: WorkboardExecutionEngine; + mode?: WorkboardExecutionMode; + model?: string; + sessionKey?: string; + runId?: string; + error?: string; +}; + +export type WorkboardComment = { + id: string; + body: string; + createdAt: number; + updatedAt?: number; +}; + +export type WorkboardLink = { + id: string; + type: WorkboardLinkType; + createdAt: number; + targetCardId?: string; + title?: string; + url?: string; +}; + +export type WorkboardProof = { + id: string; + status: WorkboardProofStatus; + createdAt: number; + label?: string; + command?: string; + url?: string; + note?: string; +}; + +export type WorkboardStaleState = { + detectedAt: number; + lastSessionUpdatedAt?: number; + reason: string; +}; + +export type WorkboardMetadata = { + attempts?: WorkboardRunAttempt[]; + comments?: WorkboardComment[]; + links?: WorkboardLink[]; + proof?: WorkboardProof[]; + templateId?: WorkboardTemplateId; + archivedAt?: number; + stale?: WorkboardStaleState; + failureCount?: number; +}; + export type WorkboardCard = { id: string; title: string; @@ -82,6 +160,7 @@ export type WorkboardCard = { startedAt?: number; completedAt?: number; events?: WorkboardEvent[]; + metadata?: WorkboardMetadata; }; export type WorkboardLifecycleState = @@ -89,6 +168,7 @@ export type WorkboardLifecycleState = | "missing" | "idle" | "running" + | "stale" | "succeeded" | "failed"; @@ -116,6 +196,7 @@ export type WorkboardUiState = { draftLabels: string; draftAgentId: string; draftSessionKey: string; + draftTemplateId: WorkboardTemplateId | ""; busyCardId: string | null; draggedCardId: string | null; syncingCardIds: Set; @@ -136,6 +217,7 @@ const SESSION_CAPTURE_HISTORY_MAX_CHARS = 6000; 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; function createDefaultState(): WorkboardUiState { return { @@ -156,6 +238,7 @@ function createDefaultState(): WorkboardUiState { draftLabels: "", draftAgentId: "", draftSessionKey: "", + draftTemplateId: "", busyCardId: null, draggedCardId: null, syncingCardIds: new Set(), @@ -263,6 +346,137 @@ function normalizeEvents(value: unknown): WorkboardEvent[] { : []; } +function normalizeMetadata(value: unknown): WorkboardMetadata | undefined { + if (!isRecord(value)) { + return undefined; + } + const attempts = Array.isArray(value.attempts) + ? value.attempts.flatMap((entry): WorkboardRunAttempt[] => { + if ( + !isRecord(entry) || + typeof entry.id !== "string" || + typeof entry.startedAt !== "number" + ) { + return []; + } + const status = WORKBOARD_ATTEMPT_STATUSES.includes(entry.status as WorkboardAttemptStatus) + ? (entry.status as WorkboardAttemptStatus) + : "running"; + return [ + { + id: entry.id, + status, + startedAt: entry.startedAt, + ...(typeof entry.endedAt === "number" ? { endedAt: entry.endedAt } : {}), + ...(WORKBOARD_EXECUTION_ENGINES.includes(entry.engine as WorkboardExecutionEngine) + ? { engine: entry.engine as WorkboardExecutionEngine } + : {}), + ...(WORKBOARD_EXECUTION_MODES.includes(entry.mode as WorkboardExecutionMode) + ? { mode: entry.mode as WorkboardExecutionMode } + : {}), + ...(typeof entry.model === "string" ? { model: entry.model } : {}), + ...(typeof entry.sessionKey === "string" ? { sessionKey: entry.sessionKey } : {}), + ...(typeof entry.runId === "string" ? { runId: entry.runId } : {}), + ...(typeof entry.error === "string" ? { error: entry.error } : {}), + }, + ]; + }) + : []; + const comments = Array.isArray(value.comments) + ? value.comments.flatMap((entry): WorkboardComment[] => { + if ( + !isRecord(entry) || + typeof entry.id !== "string" || + typeof entry.body !== "string" || + typeof entry.createdAt !== "number" + ) { + return []; + } + return [ + { + id: entry.id, + body: entry.body, + createdAt: entry.createdAt, + ...(typeof entry.updatedAt === "number" ? { updatedAt: entry.updatedAt } : {}), + }, + ]; + }) + : []; + const links = Array.isArray(value.links) + ? value.links.flatMap((entry): WorkboardLink[] => { + if ( + !isRecord(entry) || + typeof entry.id !== "string" || + typeof entry.createdAt !== "number" + ) { + return []; + } + return [ + { + id: entry.id, + type: WORKBOARD_LINK_TYPES.includes(entry.type as WorkboardLinkType) + ? (entry.type as WorkboardLinkType) + : "relates_to", + createdAt: entry.createdAt, + ...(typeof entry.targetCardId === "string" ? { targetCardId: entry.targetCardId } : {}), + ...(typeof entry.title === "string" ? { title: entry.title } : {}), + ...(typeof entry.url === "string" ? { url: entry.url } : {}), + }, + ]; + }) + : []; + const proof = Array.isArray(value.proof) + ? value.proof.flatMap((entry): WorkboardProof[] => { + if ( + !isRecord(entry) || + typeof entry.id !== "string" || + typeof entry.createdAt !== "number" + ) { + return []; + } + return [ + { + id: entry.id, + status: WORKBOARD_PROOF_STATUSES.includes(entry.status as WorkboardProofStatus) + ? (entry.status as WorkboardProofStatus) + : "unknown", + createdAt: entry.createdAt, + ...(typeof entry.label === "string" ? { label: entry.label } : {}), + ...(typeof entry.command === "string" ? { command: entry.command } : {}), + ...(typeof entry.url === "string" ? { url: entry.url } : {}), + ...(typeof entry.note === "string" ? { note: entry.note } : {}), + }, + ]; + }) + : []; + const stale = isRecord(value.stale) + ? { + detectedAt: + typeof value.stale.detectedAt === "number" ? value.stale.detectedAt : Date.now(), + ...(typeof value.stale.lastSessionUpdatedAt === "number" + ? { lastSessionUpdatedAt: value.stale.lastSessionUpdatedAt } + : {}), + reason: + typeof value.stale.reason === "string" + ? value.stale.reason + : "Session has not reported recent activity.", + } + : undefined; + const metadata: WorkboardMetadata = { + ...(attempts.length ? { attempts } : {}), + ...(comments.length ? { comments } : {}), + ...(links.length ? { links } : {}), + ...(proof.length ? { proof } : {}), + ...(WORKBOARD_TEMPLATE_IDS.includes(value.templateId as WorkboardTemplateId) + ? { templateId: value.templateId as WorkboardTemplateId } + : {}), + ...(typeof value.archivedAt === "number" ? { archivedAt: value.archivedAt } : {}), + ...(stale ? { stale } : {}), + ...(typeof value.failureCount === "number" ? { failureCount: value.failureCount } : {}), + }; + return Object.keys(metadata).length ? metadata : undefined; +} + function normalizeCard(value: unknown): WorkboardCard | null { if (!isRecord(value)) { return null; @@ -280,6 +494,7 @@ function normalizeCard(value: unknown): WorkboardCard | null { } const execution = normalizeExecution(value.execution); const events = normalizeEvents(value.events); + const metadata = normalizeMetadata(value.metadata); return { id, title, @@ -301,6 +516,7 @@ function normalizeCard(value: unknown): WorkboardCard | null { ...(typeof value.startedAt === "number" ? { startedAt: value.startedAt } : {}), ...(typeof value.completedAt === "number" ? { completedAt: value.completedAt } : {}), ...(events.length ? { events } : {}), + ...(metadata ? { metadata } : {}), }; } @@ -385,6 +601,7 @@ function resetDraftState(state: WorkboardUiState) { state.draftLabels = ""; state.draftAgentId = ""; state.draftSessionKey = ""; + state.draftTemplateId = ""; } function normalizeDraftLabels(value: string): string[] { @@ -410,6 +627,7 @@ function draftPayload(state: WorkboardUiState) { labels: normalizeDraftLabels(state.draftLabels), agentId: state.draftAgentId, sessionKey: state.draftSessionKey, + ...(state.draftTemplateId ? { templateId: state.draftTemplateId } : {}), }; } @@ -417,6 +635,26 @@ function isFailedSessionStatus(status: GatewaySessionRow["status"]): boolean { return status === "failed" || status === "killed" || status === "timeout"; } +function staleSessionState(session: GatewaySessionRow): WorkboardStaleState | undefined { + if (session.status !== "running") { + return undefined; + } + if (session.hasActiveRun !== false) { + return undefined; + } + if ( + typeof session.updatedAt !== "number" || + Date.now() - session.updatedAt < WORKBOARD_STALE_SESSION_MS + ) { + return undefined; + } + return { + detectedAt: Date.now(), + lastSessionUpdatedAt: session.updatedAt, + reason: "Linked session has not reported recent activity.", + }; +} + function workboardCardSessionKey(card: WorkboardCard): string | undefined { return card.sessionKey ?? card.execution?.sessionKey; } @@ -436,6 +674,9 @@ export function getWorkboardLifecycle( if (!session) { return { session: null, state: "missing" }; } + if (staleSessionState(session)) { + return { session, state: "stale", targetStatus: "running" }; + } if (session.hasActiveRun === true || session.status === "running") { return { session, state: "running", targetStatus: "running" }; } @@ -466,6 +707,7 @@ function executionStatusForLifecycle( ): WorkboardExecutionStatus | undefined { switch (lifecycle.state) { case "running": + case "stale": return "running"; case "succeeded": return "review"; @@ -495,6 +737,7 @@ function lifecycleSyncKey(card: WorkboardCard, lifecycle: WorkboardLifecycle): s card.status, card.updatedAt, lifecycle.targetStatus ?? "", + lifecycle.state, session?.status ?? "", session?.hasActiveRun === true ? "active" : "idle", session?.updatedAt ?? "", @@ -662,6 +905,15 @@ export async function captureSessionToWorkboard(params: { (card) => workboardCardSessionKey(card) === params.session.key, ); if (existing) { + if (existing.metadata?.archivedAt) { + const payload = await params.client.request("workboard.cards.archive", { + id: existing.id, + archived: false, + }); + const restored = normalizeCardPayload(payload); + replaceCard(state, restored); + return restored; + } return existing; } const messages = await loadSessionCaptureHistory({ @@ -720,6 +972,26 @@ export async function syncWorkboardLifecycle(params: { updatedAt: Date.now(), }; } + const stale = lifecycle.session ? staleSessionState(lifecycle.session) : undefined; + const existingStale = card.metadata?.stale; + if (stale) { + const staleChanged = + !existingStale || + existingStale.lastSessionUpdatedAt !== stale.lastSessionUpdatedAt || + existingStale.reason !== stale.reason; + if (staleChanged) { + patch.metadata = { + stale: { + ...stale, + detectedAt: existingStale?.detectedAt ?? stale.detectedAt, + }, + }; + } + } else if (existingStale) { + patch.metadata = { + stale: null, + }; + } if (Object.keys(patch).length === 0) { continue; } @@ -856,6 +1128,33 @@ export async function deleteWorkboardCard(params: { } } +export async function archiveWorkboardCard(params: { + host: WorkboardHost; + client: GatewayBrowserClient | null; + cardId: string; + requestUpdate?: () => void; +}) { + const state = getWorkboardState(params.host); + if (!params.client) { + return; + } + state.busyCardId = params.cardId; + state.error = null; + params.requestUpdate?.(); + try { + const payload = await params.client.request("workboard.cards.archive", { + id: params.cardId, + archived: true, + }); + replaceCard(state, normalizeCardPayload(payload)); + } catch (error) { + state.error = formatError(error); + } finally { + state.busyCardId = null; + params.requestUpdate?.(); + } +} + function buildCardPrompt(card: WorkboardCard): string { const lines = [`Work on this OpenClaw Workboard card: ${card.title}`]; if (card.notes?.trim()) { @@ -895,7 +1194,7 @@ function buildWorkboardExecution(params: { mode: params.mode, status: params.status, model: WORKBOARD_ENGINE_MODELS[params.engine], - startedAt: params.card.execution?.startedAt ?? now, + startedAt: now, updatedAt: now, ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), ...(params.runId ? { runId: params.runId } : {}), diff --git a/ui/src/ui/views/workboard.test.ts b/ui/src/ui/views/workboard.test.ts index 9690665caca9..7f1a2e03e1a4 100644 --- a/ui/src/ui/views/workboard.test.ts +++ b/ui/src/ui/views/workboard.test.ts @@ -6,6 +6,7 @@ import { renderWorkboard } from "./workboard.ts"; describe("renderWorkboard", () => { it("renders board columns and preloaded cards", () => { + const now = Date.now(); const host = {}; const state = getWorkboardState(host); state.loaded = true; @@ -38,7 +39,7 @@ describe("renderWorkboard", () => { key: "agent:main:dashboard:1", kind: "direct", displayName: "Dashboard session", - updatedAt: 2, + updatedAt: now, hasActiveRun: true, status: "running", }, @@ -272,9 +273,44 @@ describe("renderWorkboard", () => { ); expect(container.querySelector('[role="dialog"]')?.textContent).toContain("New card"); + expect(container.querySelector('[aria-label="Card templates"]')?.textContent).toContain( + "Bugfix", + ); expect(container.querySelector(".workboard-board")).toBeTruthy(); }); + it("applies card templates in the create modal", () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.draftOpen = 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(".workboard-template-strip .btn")] + .find((button) => button.textContent?.includes("Release")) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + render(renderWorkboard(props), container); + + expect(state.draftTemplateId).toBe("release"); + expect(container.querySelector(".workboard-draft__title")?.value).toBe( + "Release: ", + ); + expect( + container.querySelector(".workboard-draft__notes")?.value, + ).toContain("Verification:"); + }); + it("opens and plays the mini game", () => { const host = {}; const state = getWorkboardState(host); @@ -346,6 +382,174 @@ describe("renderWorkboard", () => { expect(container.querySelector(".workboard-events")?.textContent).toContain("Moved to Review"); }); + it("renders card metadata badges and hides archived cards", () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = [ + { + id: "card-1", + title: "Metadata rich", + status: "todo", + priority: "normal", + labels: [], + position: 1000, + createdAt: 1, + updatedAt: 1, + metadata: { + templateId: "plugin", + attempts: [{ id: "run-1", status: "blocked", startedAt: 1, endedAt: 2 }], + failureCount: 1, + comments: [{ id: "comment-1", body: "Needs owner check", createdAt: 3 }], + links: [{ id: "link-1", type: "relates_to", url: "https://example.com", createdAt: 4 }], + proof: [{ id: "proof-1", status: "passed", command: "pnpm test", createdAt: 5 }], + stale: { detectedAt: 6, reason: "No recent activity." }, + }, + }, + { + id: "card-2", + title: "Archived task", + status: "todo", + priority: "normal", + labels: [], + position: 2000, + createdAt: 1, + updatedAt: 1, + metadata: { archivedAt: 7 }, + }, + ]; + const container = document.createElement("div"); + + render( + renderWorkboard({ + host, + client: null, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [], + onOpenSession: () => undefined, + }), + container, + ); + + expect(container.textContent).toContain("Plugin"); + expect(container.textContent).toContain("1 attempts"); + expect(container.textContent).toContain("1 failed"); + expect(container.textContent).toContain("1 comments"); + expect(container.textContent).toContain("1 links"); + expect(container.textContent).toContain("1 proof"); + expect(container.textContent).toContain("stale"); + expect(container.textContent).not.toContain("Archived task"); + }); + + it("shows stale lifecycle on executed linked cards", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(60 * 60 * 1000); + try { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = [ + { + id: "card-1", + title: "Watch stale run", + status: "running", + priority: "normal", + labels: [], + position: 1000, + createdAt: 1, + updatedAt: 1, + execution: { + id: "exec-1", + kind: "agent-session", + engine: "codex", + mode: "autonomous", + status: "running", + model: "openai/gpt-5.5", + sessionKey: "agent:main:dashboard:1", + startedAt: 1, + updatedAt: 1, + }, + }, + ]; + const container = document.createElement("div"); + + render( + renderWorkboard({ + host, + client: null, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [ + { + key: "agent:main:dashboard:1", + kind: "direct", + displayName: "Dashboard session", + updatedAt: 1, + hasActiveRun: false, + status: "running", + }, + ], + onOpenSession: () => undefined, + }), + container, + ); + + expect(container.textContent).toContain("Stale"); + expect(container.textContent).toContain("No recent session activity"); + expect(container.textContent).not.toContain("codex autonomous"); + expect(container.querySelector(".workboard-live")).toBeNull(); + expect(container.querySelector('button[title="Stop session"]')).toBeNull(); + } finally { + nowSpy.mockRestore(); + } + }); + + it("keeps live controls for legacy running session rows", () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = [ + { + id: "card-1", + title: "Stop legacy run", + status: "running", + priority: "normal", + labels: [], + position: 1000, + createdAt: 1, + updatedAt: 1, + sessionKey: "agent:main:dashboard:1", + }, + ]; + const container = document.createElement("div"); + + render( + renderWorkboard({ + host, + client: null, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [ + { + key: "agent:main:dashboard:1", + kind: "direct", + displayName: "Dashboard session", + updatedAt: 1, + status: "running", + }, + ], + onOpenSession: () => undefined, + }), + container, + ); + + expect(container.querySelector(".workboard-live")?.textContent).toContain("live"); + expect(container.querySelector('button[title="Stop session"]')).not.toBeNull(); + }); + it("opens an edit modal and submits card updates", async () => { const host = {}; const state = getWorkboardState(host); @@ -414,6 +618,53 @@ describe("renderWorkboard", () => { }); }); + it("archives cards from the card action", async () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = [ + { + id: "card-1", + title: "Archive me", + status: "done", + priority: "normal", + labels: [], + position: 1000, + createdAt: 1, + updatedAt: 1, + }, + ]; + const request = vi.fn(async () => ({ + card: { ...state.cards[0], metadata: { archivedAt: 2 } }, + })); + const container = document.createElement("div"); + + render( + renderWorkboard({ + host, + client: { request } as unknown as GatewayBrowserClient, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [], + onOpenSession: () => undefined, + onRequestUpdate: () => undefined, + }), + container, + ); + container + .querySelector('button[title="Archive card"]') + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + + expect(request).toHaveBeenCalledWith("workboard.cards.archive", { + id: "card-1", + archived: true, + }); + expect(state.cards[0]?.metadata?.archivedAt).toBe(2); + }); + it("offers existing sessions when creating a card", () => { const host = {}; const state = getWorkboardState(host); diff --git a/ui/src/ui/views/workboard.ts b/ui/src/ui/views/workboard.ts index 17f9a4211bb7..278d8cb329dc 100644 --- a/ui/src/ui/views/workboard.ts +++ b/ui/src/ui/views/workboard.ts @@ -1,6 +1,7 @@ import { html, nothing } from "lit"; import { t } from "../../i18n/index.ts"; import { + archiveWorkboardCard, deleteWorkboardCard, findWorkboardSession, getWorkboardLifecycle, @@ -19,6 +20,7 @@ import { type WorkboardLifecycle, type WorkboardPriority, type WorkboardStatus, + type WorkboardTemplateId, type WorkboardUiState, } from "../controllers/workboard.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; @@ -40,6 +42,49 @@ type WorkboardProps = { 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; + notes: string; + labels: string; + priority: WorkboardPriority; +}> = [ + { + id: "bugfix", + title: "Fix: ", + notes: "Symptom:\nCause:\nAcceptance:\nProof:", + labels: "fix, test", + priority: "high", + }, + { + id: "docs", + title: "Docs: ", + notes: "Page:\nChange:\nSource proof:", + labels: "docs", + priority: "normal", + }, + { + id: "release", + title: "Release: ", + notes: "Scope:\nVerification:\nCloseout:", + labels: "release", + priority: "urgent", + }, + { + id: "pr_review", + title: "Review PR ", + notes: "Surface:\nRisks:\nProof:", + labels: "review", + priority: "normal", + }, + { + id: "plugin", + title: "Plugin: ", + notes: "Boundary:\nConfig/docs:\nTests:", + labels: "plugin", + priority: "normal", + }, +]; function formatStatusLabel(status: WorkboardStatus): string { return t(`workboard.status.${status}`); @@ -73,6 +118,22 @@ function formatEventLabel(event: WorkboardEvent): string { return t("workboard.eventLinked"); case "execution_updated": return t("workboard.eventExecutionUpdated"); + case "attempt_started": + return t("workboard.eventAttemptStarted"); + case "attempt_updated": + return t("workboard.eventAttemptUpdated"); + case "comment_added": + return t("workboard.eventCommentAdded"); + case "link_added": + return t("workboard.eventLinkAdded"); + case "proof_added": + return t("workboard.eventProofAdded"); + case "archived": + return t("workboard.eventArchived"); + case "unarchived": + return t("workboard.eventUnarchived"); + case "stale": + return t("workboard.eventStale"); } return ""; } @@ -96,6 +157,38 @@ 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 + ? t("workboard.badgeAttempts", { count: String(metadata.attempts.length) }) + : null, + metadata.failureCount + ? t("workboard.badgeFailures", { count: String(metadata.failureCount) }) + : null, + metadata.comments?.length + ? t("workboard.badgeComments", { count: String(metadata.comments.length) }) + : null, + metadata.links?.length + ? t("workboard.badgeLinks", { count: String(metadata.links.length) }) + : null, + metadata.proof?.length + ? t("workboard.badgeProof", { count: String(metadata.proof.length) }) + : null, + metadata.stale ? t("workboard.badgeStale") : null, + ].filter((badge): badge is string => Boolean(badge)); + if (badges.length === 0) { + return nothing; + } + return html` +
${badges.map((badge) => html`${badge}`)}
+ `; +} + function matchesFilter( card: WorkboardCard, options: { query: string; priority: "all" | WorkboardPriority }, @@ -116,6 +209,15 @@ function matchesFilter( card.execution?.mode, card.execution?.model, card.execution?.sessionKey, + card.metadata?.templateId, + ...(card.metadata?.comments ?? []).map((comment) => comment.body), + ...(card.metadata?.links ?? []).flatMap((link) => [link.title, link.url, link.targetCardId]), + ...(card.metadata?.proof ?? []).flatMap((proof) => [ + proof.label, + proof.command, + proof.url, + proof.note, + ]), ...card.labels, ] .filter((value): value is string => typeof value === "string") @@ -166,6 +268,7 @@ function resetDraft(state: WorkboardUiState) { state.draftLabels = ""; state.draftAgentId = ""; state.draftSessionKey = ""; + state.draftTemplateId = ""; } function openCreateModal(state: WorkboardUiState) { @@ -218,6 +321,19 @@ function openEditModal(state: WorkboardUiState, card: WorkboardCard) { state.draftLabels = card.labels.join(", "); state.draftAgentId = card.agentId ?? ""; state.draftSessionKey = card.sessionKey ?? ""; + state.draftTemplateId = card.metadata?.templateId ?? ""; +} + +function applyTemplate(state: WorkboardUiState, templateId: WorkboardTemplateId) { + const template = WORKBOARD_TEMPLATES.find((entry) => entry.id === templateId); + if (!template) { + return; + } + state.draftTemplateId = template.id; + state.draftTitle = template.title; + state.draftNotes = template.notes; + state.draftLabels = template.labels; + state.draftPriority = template.priority; } function renderGameArrow( @@ -406,6 +522,28 @@ function renderCardModal(props: WorkboardProps) { ${icons.x} + ${!editing + ? html` +
+ ${WORKBOARD_TEMPLATES.map( + (template) => html` + + `, + )} +
+ ` + : nothing}
` : nothing} + ${renderMetadataBadges(card)}
${card.agentId ? html`${card.agentId}` @@ -763,6 +911,20 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) { ${showStartControls ? renderStartExecutionControls(props, card) : nothing} ${writable ? html` +