diff --git a/docs/plugins/workboard.md b/docs/plugins/workboard.md index d191fc15385..ceeea25de6a 100644 --- a/docs/plugins/workboard.md +++ b/docs/plugins/workboard.md @@ -47,10 +47,27 @@ Each card stores: - labels - optional agent id - optional linked session, run, task, or source URL +- optional execution metadata for a Codex or Claude session started from the card 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. +## Card executions + +Unlinked cards can start work from the card. Start uses the Gateway's configured +default agent and model. Codex and Claude actions are optional explicit model +choices: + +- Run Codex or Run Claude creates a dashboard session, sends the card prompt, + and marks the card `running`. +- Open Codex or Open Claude creates a linked dashboard session without sending + the card prompt or moving the card, so you can work manually while it stays + attached to the board. + +Execution metadata stores the selected engine, mode, model ref, session key, +run id, and lifecycle status on the card. Codex executions use +`openai/gpt-5.5`; Claude executions use `anthropic/claude-sonnet-4-6`. + ## Session lifecycle sync Cards can be linked to existing dashboard sessions or to the session created diff --git a/extensions/workboard/src/store.test.ts b/extensions/workboard/src/store.test.ts index 0748498b20b..749f1c9c8d0 100644 --- a/extensions/workboard/src/store.test.ts +++ b/extensions/workboard/src/store.test.ts @@ -44,12 +44,28 @@ describe("WorkboardStore", () => { sessionKey: "agent:main:dashboard:1", runId: "run-1", taskId: "task-1", + execution: { + id: "exec-1", + kind: "agent-session", + engine: "claude", + mode: "manual", + status: "running", + model: "anthropic/claude-sonnet-4-6", + sessionKey: "agent:main:dashboard:1", + startedAt: 10, + updatedAt: 10, + }, }); expect(card).toMatchObject({ sessionKey: "agent:main:dashboard:1", runId: "run-1", taskId: "task-1", + execution: { + engine: "claude", + mode: "manual", + model: "anthropic/claude-sonnet-4-6", + }, }); }); @@ -66,6 +82,36 @@ describe("WorkboardStore", () => { expect(done.completedAt).toBeGreaterThanOrEqual(done.startedAt ?? 0); }); + it("keeps execution session links aligned with edited card links", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ + title: "Relink me", + sessionKey: "agent:main:dashboard: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: 10, + updatedAt: 10, + }, + }); + + const relinked = await store.update(card.id, { sessionKey: "agent:main:dashboard:2" }); + expect(relinked.sessionKey).toBe("agent:main:dashboard:2"); + expect(relinked.execution?.sessionKey).toBe("agent:main:dashboard:2"); + + const unlinked = await store.update(card.id, { sessionKey: "" }); + expect(unlinked.sessionKey).toBeUndefined(); + expect(unlinked.execution?.sessionKey).toBeUndefined(); + + const cleared = await store.update(card.id, { execution: null }); + expect(cleared.execution).toBeUndefined(); + }); + 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 a7943087c47..63ad5b07b83 100644 --- a/extensions/workboard/src/store.ts +++ b/extensions/workboard/src/store.ts @@ -1,8 +1,15 @@ import { randomUUID } from "node:crypto"; import { + WORKBOARD_EXECUTION_ENGINES, + WORKBOARD_EXECUTION_MODES, + WORKBOARD_EXECUTION_STATUSES, WORKBOARD_PRIORITIES, WORKBOARD_STATUSES, type WorkboardCard, + type WorkboardExecution, + type WorkboardExecutionEngine, + type WorkboardExecutionMode, + type WorkboardExecutionStatus, type WorkboardPriority, type WorkboardStatus, } from "./types.js"; @@ -33,6 +40,7 @@ export type WorkboardCardInput = { runId?: unknown; taskId?: unknown; sourceUrl?: unknown; + execution?: unknown; position?: unknown; }; @@ -117,6 +125,105 @@ function normalizePosition(value: unknown, fallback: number): number { return Math.max(0, Math.trunc(value)); } +function normalizeExecutionEngine( + value: unknown, + fallback: WorkboardExecutionEngine, +): WorkboardExecutionEngine { + if ( + typeof value === "string" && + WORKBOARD_EXECUTION_ENGINES.includes(value as WorkboardExecutionEngine) + ) { + return value as WorkboardExecutionEngine; + } + return fallback; +} + +function normalizeExecutionMode( + value: unknown, + fallback: WorkboardExecutionMode, +): WorkboardExecutionMode { + if ( + typeof value === "string" && + WORKBOARD_EXECUTION_MODES.includes(value as WorkboardExecutionMode) + ) { + return value as WorkboardExecutionMode; + } + return fallback; +} + +function normalizeExecutionStatus( + value: unknown, + fallback: WorkboardExecutionStatus, +): WorkboardExecutionStatus { + if ( + typeof value === "string" && + WORKBOARD_EXECUTION_STATUSES.includes(value as WorkboardExecutionStatus) + ) { + return value as WorkboardExecutionStatus; + } + return fallback; +} + +function normalizeTimestamp(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(0, Math.trunc(value)) + : fallback; +} + +function normalizeExecution(value: unknown): WorkboardExecution | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const record = value as Record; + const now = Date.now(); + const model = normalizeOptionalString(record.model); + const id = normalizeOptionalString(record.id) ?? randomUUID(); + if (!model) { + return undefined; + } + const startedAt = normalizeTimestamp(record.startedAt, now); + const updatedAt = normalizeTimestamp(record.updatedAt, startedAt); + const sessionKey = normalizeOptionalString(record.sessionKey); + const runId = normalizeOptionalString(record.runId); + return { + id, + kind: "agent-session", + engine: normalizeExecutionEngine(record.engine, "codex"), + mode: normalizeExecutionMode(record.mode, "autonomous"), + status: normalizeExecutionStatus(record.status, "idle"), + model, + startedAt, + updatedAt, + ...(sessionKey ? { sessionKey } : {}), + ...(runId ? { runId } : {}), + }; +} + +function syncExecutionSessionKey( + execution: WorkboardExecution | undefined, + sessionKey: string | undefined, +): WorkboardExecution | undefined { + if (!execution) { + return undefined; + } + return removeUndefinedExecutionFields({ + ...execution, + sessionKey, + updatedAt: Date.now(), + }); +} + +function removeUndefinedExecutionFields(execution: WorkboardExecution): WorkboardExecution { + const next = { ...execution }; + if (next.sessionKey === undefined) { + delete next.sessionKey; + } + if (next.runId === undefined) { + delete next.runId; + } + 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); @@ -136,6 +243,7 @@ function removeUndefinedCardFields(card: WorkboardCard): WorkboardCard { "runId", "taskId", "sourceUrl", + "execution", "startedAt", "completedAt", ] as const) { @@ -179,6 +287,7 @@ export class WorkboardStore { const runId = normalizeOptionalString(input.runId); const taskId = normalizeOptionalString(input.taskId); const sourceUrl = normalizeOptionalString(input.sourceUrl); + const execution = normalizeExecution(input.execution); const card: WorkboardCard = { id: randomUUID(), title: normalizeTitle(input.title), @@ -194,6 +303,7 @@ export class WorkboardStore { ...(runId ? { runId } : {}), ...(taskId ? { taskId } : {}), ...(sourceUrl ? { sourceUrl } : {}), + ...(execution ? { execution } : {}), }; await this.store.register(card.id, { version: 1, card }); return card; @@ -208,6 +318,16 @@ export class WorkboardStore { const now = Date.now(); const completedAt = status === "done" ? (existing.completedAt ?? now) : undefined; const startedAt = status === "running" ? (existing.startedAt ?? now) : existing.startedAt; + const sessionKey = + patch.sessionKey === undefined + ? existing.sessionKey + : normalizeOptionalString(patch.sessionKey); + const execution = + patch.execution === undefined + ? patch.sessionKey === undefined + ? existing.execution + : syncExecutionSessionKey(existing.execution, sessionKey) + : normalizeExecution(patch.execution); const next = removeUndefinedCardFields({ ...existing, title: patch.title === undefined ? existing.title : normalizeTitle(patch.title), @@ -220,16 +340,14 @@ export class WorkboardStore { labels: patch.labels === undefined ? existing.labels : normalizeLabels(patch.labels), agentId: patch.agentId === undefined ? existing.agentId : normalizeOptionalString(patch.agentId), - sessionKey: - patch.sessionKey === undefined - ? existing.sessionKey - : normalizeOptionalString(patch.sessionKey), + sessionKey, runId: patch.runId === undefined ? existing.runId : normalizeOptionalString(patch.runId), taskId: patch.taskId === undefined ? existing.taskId : normalizeOptionalString(patch.taskId), sourceUrl: patch.sourceUrl === undefined ? existing.sourceUrl : normalizeOptionalString(patch.sourceUrl), + execution, position: patch.position === undefined ? existing.position diff --git a/extensions/workboard/src/types.ts b/extensions/workboard/src/types.ts index b2c88d309a7..37da6598b78 100644 --- a/extensions/workboard/src/types.ts +++ b/extensions/workboard/src/types.ts @@ -8,9 +8,34 @@ export const WORKBOARD_STATUSES = [ ] as const; export const WORKBOARD_PRIORITIES = ["low", "normal", "high", "urgent"] as const; +export const WORKBOARD_EXECUTION_ENGINES = ["codex", "claude"] as const; +export const WORKBOARD_EXECUTION_MODES = ["autonomous", "manual"] as const; +export const WORKBOARD_EXECUTION_STATUSES = [ + "idle", + "running", + "review", + "blocked", + "done", +] as const; export type WorkboardStatus = (typeof WORKBOARD_STATUSES)[number]; export type WorkboardPriority = (typeof WORKBOARD_PRIORITIES)[number]; +export type WorkboardExecutionEngine = (typeof WORKBOARD_EXECUTION_ENGINES)[number]; +export type WorkboardExecutionMode = (typeof WORKBOARD_EXECUTION_MODES)[number]; +export type WorkboardExecutionStatus = (typeof WORKBOARD_EXECUTION_STATUSES)[number]; + +export type WorkboardExecution = { + id: string; + kind: "agent-session"; + engine: WorkboardExecutionEngine; + mode: WorkboardExecutionMode; + status: WorkboardExecutionStatus; + model: string; + sessionKey?: string; + runId?: string; + startedAt: number; + updatedAt: number; +}; export type WorkboardCard = { id: string; @@ -24,6 +49,7 @@ export type WorkboardCard = { runId?: string; taskId?: string; sourceUrl?: string; + execution?: WorkboardExecution; position: number; createdAt: number; updatedAt: number; diff --git a/ui/src/styles/workboard.css b/ui/src/styles/workboard.css index 03626964c73..1f1915680e4 100644 --- a/ui/src/styles/workboard.css +++ b/ui/src/styles/workboard.css @@ -457,6 +457,13 @@ justify-content: flex-end; } +.workboard-card__execution-controls { + display: grid; + grid-template-columns: repeat(2, minmax(72px, 1fr)); + gap: 6px; + flex: 1 1 100%; +} + .workboard-card__icon { width: 28px; min-width: 28px; @@ -481,6 +488,17 @@ min-height: 28px; padding: 4px 9px; border-radius: 6px; + justify-content: center; + min-width: 0; + white-space: nowrap; +} + +.workboard-card__start--manual { + color: var(--muted); +} + +.workboard-card__start--default { + grid-column: 1 / -1; } .workboard-card__lifecycle { diff --git a/ui/src/ui/controllers/workboard.test.ts b/ui/src/ui/controllers/workboard.test.ts index a98ff8fca18..1b7d9f1c828 100644 --- a/ui/src/ui/controllers/workboard.test.ts +++ b/ui/src/ui/controllers/workboard.test.ts @@ -342,12 +342,133 @@ describe("workboard controller", () => { message: expect.stringContaining("Work on this OpenClaw Workboard card: Build board"), }), ); + expect(client.request.mock.calls[0]?.[1]).not.toHaveProperty("model"); expect(client.request).toHaveBeenNthCalledWith( 2, "workboard.cards.update", expect.objectContaining({ id: "card-1", - patch: expect.objectContaining({ status: "running", runId: "run-1" }), + patch: expect.objectContaining({ + status: "running", + runId: "run-1", + }), + }), + ); + expect(client.request.mock.calls[1]?.[1]).toHaveProperty("patch.execution", null); + }); + + it("starts a Codex execution with an explicit model override", async () => { + const host = {}; + const running = { + ...sampleCard, + status: "running", + sessionKey: "agent:main:dashboard:1", + execution: { + id: "card-1:codex", + 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, + }, + }; + const client = createClient({ + "sessions.create": { key: "agent:main:dashboard:1", runId: "run-1" }, + "workboard.cards.update": { card: running }, + }); + + await startWorkboardCard({ + host, + client: client as never, + card: sampleCard, + engine: "codex", + }); + + expect(client.request).toHaveBeenNthCalledWith( + 1, + "sessions.create", + expect.objectContaining({ + model: "openai/gpt-5.5", + message: expect.stringContaining("Work on this OpenClaw Workboard card: Build board"), + }), + ); + expect(client.request).toHaveBeenNthCalledWith( + 2, + "workboard.cards.update", + expect.objectContaining({ + id: "card-1", + patch: expect.objectContaining({ + status: "running", + execution: expect.objectContaining({ + engine: "codex", + mode: "autonomous", + model: "openai/gpt-5.5", + runId: "run-1", + }), + }), + }), + ); + }); + + it("starts a manual Claude execution without sending the card prompt", async () => { + const host = {}; + const running = { + ...sampleCard, + status: "todo", + sessionKey: "agent:main:dashboard:1", + execution: { + id: "card-1:claude", + kind: "agent-session", + engine: "claude", + mode: "manual", + status: "idle", + model: "anthropic/claude-sonnet-4-6", + sessionKey: "agent:main:dashboard:1", + startedAt: 10, + updatedAt: 10, + }, + }; + const client = createClient({ + "sessions.create": { key: "agent:main:dashboard:1", runStarted: false }, + "workboard.cards.update": { card: running }, + }); + + const sessionKey = await startWorkboardCard({ + host, + client: client as never, + card: sampleCard, + engine: "claude", + mode: "manual", + }); + + expect(sessionKey).toBe("agent:main:dashboard:1"); + expect(client.request).toHaveBeenNthCalledWith( + 1, + "sessions.create", + expect.objectContaining({ + model: "anthropic/claude-sonnet-4-6", + }), + ); + expect(client.request.mock.calls[0]?.[1]).not.toHaveProperty("message"); + expect(client.request.mock.calls[0]?.[1]).not.toHaveProperty("task"); + expect(client.request).toHaveBeenNthCalledWith( + 2, + "workboard.cards.update", + expect.objectContaining({ + id: "card-1", + patch: expect.objectContaining({ + status: "todo", + execution: expect.objectContaining({ + engine: "claude", + mode: "manual", + status: "idle", + model: "anthropic/claude-sonnet-4-6", + }), + }), }), ); }); @@ -376,9 +497,13 @@ describe("workboard controller", () => { "workboard.cards.update", expect.objectContaining({ id: "card-1", - patch: { status: "blocked", sessionKey: "agent:main:dashboard:1" }, + patch: expect.objectContaining({ + status: "blocked", + sessionKey: "agent:main:dashboard:1", + }), }), ); + expect(client.request.mock.calls[1]?.[1]).toHaveProperty("patch.execution", null); expect(getWorkboardState(host).error).toBe("Agent run did not start: provider unavailable"); }); @@ -424,6 +549,28 @@ describe("workboard controller", () => { state: "failed", targetStatus: "blocked", }); + expect( + getWorkboardLifecycle( + { + ...sampleCard, + execution: { + id: "exec-1", + kind: "agent-session", + engine: "codex", + mode: "autonomous", + status: "running", + model: "openai/gpt-5.5", + sessionKey: sampleSession.key, + startedAt: 1, + updatedAt: 1, + }, + }, + [sampleSession], + ), + ).toMatchObject({ + state: "running", + targetStatus: "running", + }); }); it("syncs linked card status from session lifecycle without overriding manual review", async () => { @@ -458,6 +605,38 @@ describe("workboard controller", () => { expect(state.cards.find((card) => card.id === "card-review")?.status).toBe("review"); }); + it("does not mark executions blocked when the linked session is missing from the current list", async () => { + const host = {}; + const state = getWorkboardState(host); + const linked = { + ...sampleCard, + status: "running", + sessionKey: "agent:main:dashboard:missing", + execution: { + id: "exec-1", + kind: "agent-session", + engine: "codex", + mode: "autonomous", + status: "running", + model: "openai/gpt-5.5", + sessionKey: "agent:main:dashboard:missing", + startedAt: 1, + updatedAt: 1, + }, + } as const; + state.loaded = true; + state.cards = [linked]; + const client = createClient({ "workboard.cards.update": { card: linked } }); + + await syncWorkboardLifecycle({ + host, + client: client as never, + sessions: [], + }); + + expect(client.request).not.toHaveBeenCalled(); + }); + it("skips lifecycle writeback for read-only workboard clients", async () => { const host = {}; const state = getWorkboardState(host); diff --git a/ui/src/ui/controllers/workboard.ts b/ui/src/ui/controllers/workboard.ts index 408fc3fe8a3..aaacc44f8b0 100644 --- a/ui/src/ui/controllers/workboard.ts +++ b/ui/src/ui/controllers/workboard.ts @@ -11,9 +11,39 @@ export const WORKBOARD_STATUSES = [ ] as const; export const WORKBOARD_PRIORITIES = ["low", "normal", "high", "urgent"] as const; +export const WORKBOARD_EXECUTION_ENGINES = ["codex", "claude"] as const; +export const WORKBOARD_EXECUTION_MODES = ["autonomous", "manual"] as const; +export const WORKBOARD_EXECUTION_STATUSES = [ + "idle", + "running", + "review", + "blocked", + "done", +] as const; + +export const WORKBOARD_ENGINE_MODELS = { + codex: "openai/gpt-5.5", + claude: "anthropic/claude-sonnet-4-6", +} as const; export type WorkboardStatus = (typeof WORKBOARD_STATUSES)[number]; export type WorkboardPriority = (typeof WORKBOARD_PRIORITIES)[number]; +export type WorkboardExecutionEngine = (typeof WORKBOARD_EXECUTION_ENGINES)[number]; +export type WorkboardExecutionMode = (typeof WORKBOARD_EXECUTION_MODES)[number]; +export type WorkboardExecutionStatus = (typeof WORKBOARD_EXECUTION_STATUSES)[number]; + +export type WorkboardExecution = { + id: string; + kind: "agent-session"; + engine: WorkboardExecutionEngine; + mode: WorkboardExecutionMode; + status: WorkboardExecutionStatus; + model: string; + sessionKey?: string; + runId?: string; + startedAt: number; + updatedAt: number; +}; export type WorkboardCard = { id: string; @@ -27,6 +57,7 @@ export type WorkboardCard = { runId?: string; taskId?: string; sourceUrl?: string; + execution?: WorkboardExecution; position: number; createdAt: number; updatedAt: number; @@ -144,6 +175,40 @@ function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } +function normalizeExecution(value: unknown): WorkboardExecution | undefined { + if (!isRecord(value)) { + return undefined; + } + const id = typeof value.id === "string" && value.id.trim() ? value.id.trim() : ""; + const engine = WORKBOARD_EXECUTION_ENGINES.includes(value.engine as WorkboardExecutionEngine) + ? (value.engine as WorkboardExecutionEngine) + : null; + const mode = WORKBOARD_EXECUTION_MODES.includes(value.mode as WorkboardExecutionMode) + ? (value.mode as WorkboardExecutionMode) + : null; + const status = WORKBOARD_EXECUTION_STATUSES.includes(value.status as WorkboardExecutionStatus) + ? (value.status as WorkboardExecutionStatus) + : "idle"; + const model = typeof value.model === "string" && value.model.trim() ? value.model.trim() : ""; + const startedAt = typeof value.startedAt === "number" ? value.startedAt : 0; + const updatedAt = typeof value.updatedAt === "number" ? value.updatedAt : startedAt; + if (!id || !engine || !mode || !model || !startedAt) { + return undefined; + } + return { + id, + kind: "agent-session", + engine, + mode, + status, + model, + startedAt, + updatedAt, + ...(typeof value.sessionKey === "string" ? { sessionKey: value.sessionKey } : {}), + ...(typeof value.runId === "string" ? { runId: value.runId } : {}), + }; +} + function normalizeCard(value: unknown): WorkboardCard | null { if (!isRecord(value)) { return null; @@ -159,6 +224,7 @@ function normalizeCard(value: unknown): WorkboardCard | null { if (!id || !title) { return null; } + const execution = normalizeExecution(value.execution); return { id, title, @@ -176,6 +242,7 @@ function normalizeCard(value: unknown): WorkboardCard | null { ...(typeof value.runId === "string" ? { runId: value.runId } : {}), ...(typeof value.taskId === "string" ? { taskId: value.taskId } : {}), ...(typeof value.sourceUrl === "string" ? { sourceUrl: value.sourceUrl } : {}), + ...(execution ? { execution } : {}), ...(typeof value.startedAt === "number" ? { startedAt: value.startedAt } : {}), ...(typeof value.completedAt === "number" ? { completedAt: value.completedAt } : {}), }; @@ -294,12 +361,20 @@ function isFailedSessionStatus(status: GatewaySessionRow["status"]): boolean { return status === "failed" || status === "killed" || status === "timeout"; } +function workboardCardSessionKey(card: WorkboardCard): string | undefined { + return card.sessionKey ?? card.execution?.sessionKey; +} + +function workboardCardRunId(card: WorkboardCard): string | undefined { + return card.runId ?? card.execution?.runId; +} + export function getWorkboardLifecycle( card: WorkboardCard, sessions: readonly GatewaySessionRow[], ): WorkboardLifecycle { const session = findWorkboardSession(card, sessions); - if (!card.sessionKey) { + if (!workboardCardSessionKey(card)) { return { session: null, state: "unlinked" }; } if (!session) { @@ -330,6 +405,32 @@ function shouldSyncCardStatus(card: WorkboardCard, targetStatus: WorkboardStatus return false; } +function executionStatusForLifecycle( + lifecycle: WorkboardLifecycle, +): WorkboardExecutionStatus | undefined { + switch (lifecycle.state) { + case "running": + return "running"; + case "succeeded": + return "review"; + case "failed": + return "blocked"; + case "missing": + return undefined; + case "idle": + return "idle"; + case "unlinked": + return undefined; + } +} + +function shouldSyncExecutionStatus( + card: WorkboardCard, + targetStatus: WorkboardExecutionStatus | undefined, +) { + return Boolean(card.execution && targetStatus && card.execution.status !== targetStatus); +} + function lifecycleSyncKey(card: WorkboardCard, lifecycle: WorkboardLifecycle): string { const session = lifecycle.session; return [ @@ -340,6 +441,8 @@ function lifecycleSyncKey(card: WorkboardCard, lifecycle: WorkboardLifecycle): s session?.status ?? "", session?.hasActiveRun === true ? "active" : "idle", session?.updatedAt ?? "", + card.execution?.status ?? "", + card.execution?.updatedAt ?? "", ].join(":"); } @@ -546,7 +649,19 @@ export async function syncWorkboardLifecycle(params: { const syncKeys = getLifecycleSyncKeys(params.host); for (const card of state.cards) { const lifecycle = getWorkboardLifecycle(card, params.sessions); - if (!shouldSyncCardStatus(card, lifecycle.targetStatus)) { + const executionStatus = executionStatusForLifecycle(lifecycle); + const patch: Record = {}; + if (shouldSyncCardStatus(card, lifecycle.targetStatus)) { + patch.status = lifecycle.targetStatus; + } + if (shouldSyncExecutionStatus(card, executionStatus)) { + patch.execution = { + ...card.execution, + status: executionStatus, + updatedAt: Date.now(), + }; + } + if (Object.keys(patch).length === 0) { continue; } const key = lifecycleSyncKey(card, lifecycle); @@ -558,7 +673,7 @@ export async function syncWorkboardLifecycle(params: { try { const payload = await params.client.request("workboard.cards.update", { id: card.id, - patch: { status: lifecycle.targetStatus }, + patch, }); replaceCard(state, normalizeCardPayload(payload)); syncKeys.set(card.id, key); @@ -705,10 +820,35 @@ function buildCardSessionLabel(card: WorkboardCard): string { return `${title.slice(0, titleMax - 3).trimEnd()}...${suffixText}`; } +function buildWorkboardExecution(params: { + card: WorkboardCard; + engine: WorkboardExecutionEngine; + mode: WorkboardExecutionMode; + sessionKey?: string | null; + runId?: string; + status: WorkboardExecutionStatus; +}): WorkboardExecution { + const now = Date.now(); + return { + id: params.card.execution?.id ?? `${params.card.id}:${params.engine}`, + kind: "agent-session", + engine: params.engine, + mode: params.mode, + status: params.status, + model: WORKBOARD_ENGINE_MODELS[params.engine], + startedAt: params.card.execution?.startedAt ?? now, + updatedAt: now, + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + ...(params.runId ? { runId: params.runId } : {}), + }; +} + export async function startWorkboardCard(params: { host: WorkboardHost; client: GatewayBrowserClient | null; card: WorkboardCard; + engine?: WorkboardExecutionEngine; + mode?: WorkboardExecutionMode; requestUpdate?: () => void; }): Promise { const state = getWorkboardState(params.host); @@ -718,11 +858,14 @@ export async function startWorkboardCard(params: { state.busyCardId = params.card.id; state.error = null; params.requestUpdate?.(); + const engine = params.engine; + const mode = params.mode ?? "autonomous"; try { const created = await params.client.request("sessions.create", { ...(params.card.agentId ? { agentId: params.card.agentId } : {}), label: buildCardSessionLabel(params.card), - message: buildCardPrompt(params.card), + ...(engine ? { model: WORKBOARD_ENGINE_MODELS[engine] } : {}), + ...(mode === "autonomous" ? { message: buildCardPrompt(params.card) } : {}), }); const sessionKey = isRecord(created) && typeof created.key === "string" && created.key.trim() @@ -732,13 +875,25 @@ export async function startWorkboardCard(params: { isRecord(created) && typeof created.runId === "string" && created.runId.trim() ? created.runId.trim() : undefined; - const initialRunFailed = isRecord(created) && created.runStarted === false; + const initialRunFailed = + mode === "autonomous" && isRecord(created) && created.runStarted === false; if (initialRunFailed) { const payload = await params.client.request("workboard.cards.update", { id: params.card.id, patch: { status: "blocked", ...(sessionKey ? { sessionKey } : {}), + ...(engine + ? { + execution: buildWorkboardExecution({ + card: params.card, + engine, + mode, + sessionKey, + status: "blocked", + }), + } + : { execution: null }), }, }); replaceCard(state, normalizeCardPayload(payload)); @@ -750,12 +905,26 @@ export async function startWorkboardCard(params: { : "Agent run did not start."; return sessionKey; } + const nextCardStatus = mode === "autonomous" ? "running" : params.card.status; + const nextExecutionStatus = mode === "autonomous" ? "running" : "idle"; const payload = await params.client.request("workboard.cards.update", { id: params.card.id, patch: { - status: "running", + status: nextCardStatus, ...(sessionKey ? { sessionKey } : {}), ...(runId ? { runId } : {}), + ...(engine + ? { + execution: buildWorkboardExecution({ + card: params.card, + engine, + mode, + sessionKey, + runId, + status: nextExecutionStatus, + }), + } + : { execution: null }), }, }); replaceCard(state, normalizeCardPayload(payload)); @@ -776,7 +945,8 @@ export async function stopWorkboardCard(params: { requestUpdate?: () => void; }) { const state = getWorkboardState(params.host); - if (!params.client || !params.card.sessionKey) { + const sessionKey = workboardCardSessionKey(params.card); + if (!params.client || !sessionKey) { return; } state.busyCardId = params.card.id; @@ -784,16 +954,16 @@ export async function stopWorkboardCard(params: { params.requestUpdate?.(); try { let abortResult = await params.client.request("chat.abort", { - sessionKey: params.card.sessionKey, - ...(params.card.runId ? { runId: params.card.runId } : {}), + sessionKey, + ...(workboardCardRunId(params.card) ? { runId: workboardCardRunId(params.card) } : {}), }); let aborted = isRecord(abortResult) && (abortResult.aborted === true || (Array.isArray(abortResult.runIds) && abortResult.runIds.length > 0)); - if (!aborted && params.card.runId) { + if (!aborted && workboardCardRunId(params.card)) { abortResult = await params.client.request("chat.abort", { - sessionKey: params.card.sessionKey, + sessionKey, }); aborted = isRecord(abortResult) && @@ -805,7 +975,18 @@ export async function stopWorkboardCard(params: { } const payload = await params.client.request("workboard.cards.update", { id: params.card.id, - patch: { status: "blocked" }, + patch: { + status: "blocked", + ...(params.card.execution + ? { + execution: { + ...params.card.execution, + status: "blocked", + updatedAt: Date.now(), + }, + } + : {}), + }, }); replaceCard(state, normalizeCardPayload(payload)); } catch (error) { @@ -820,8 +1001,9 @@ export function findWorkboardSession( card: WorkboardCard, sessions: readonly GatewaySessionRow[], ): GatewaySessionRow | null { - if (!card.sessionKey) { + const sessionKey = workboardCardSessionKey(card); + if (!sessionKey) { return null; } - return sessions.find((session) => session.key === card.sessionKey) ?? null; + return sessions.find((session) => session.key === sessionKey) ?? null; } diff --git a/ui/src/ui/views/workboard.test.ts b/ui/src/ui/views/workboard.test.ts index d2b2c4ff381..4177e64f7dc 100644 --- a/ui/src/ui/views/workboard.test.ts +++ b/ui/src/ui/views/workboard.test.ts @@ -109,7 +109,7 @@ describe("renderWorkboard", () => { expect(onOpenSession).not.toHaveBeenCalled(); }); - it("shows a labeled start action for unlinked cards", () => { + it("shows Codex and Claude execution actions for unlinked cards", () => { const host = {}; const state = getWorkboardState(host); state.loaded = true; @@ -140,8 +140,23 @@ describe("renderWorkboard", () => { container, ); - const startButton = container.querySelector(".workboard-card__start"); - expect(startButton?.textContent).toContain("Start"); + const startButtons = [ + ...container.querySelectorAll(".workboard-card__start"), + ]; + expect(startButtons.map((button) => button.textContent?.trim())).toEqual([ + "Start", + "codex", + "claude", + "codex", + "claude", + ]); + expect(startButtons.map((button) => button.title)).toEqual([ + "Run default agent", + "Run codex", + "Run claude", + "Open codex", + "Open claude", + ]); expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBeNull(); }); diff --git a/ui/src/ui/views/workboard.ts b/ui/src/ui/views/workboard.ts index db96cc33f14..f99ba9de66c 100644 --- a/ui/src/ui/views/workboard.ts +++ b/ui/src/ui/views/workboard.ts @@ -12,6 +12,8 @@ import { stopWorkboardCard, syncWorkboardLifecycle, WORKBOARD_PRIORITIES, + type WorkboardExecutionEngine, + type WorkboardExecutionMode, type WorkboardCard, type WorkboardLifecycle, type WorkboardPriority, @@ -63,7 +65,17 @@ function matchesFilter( if (!query) { return true; } - return [card.title, card.notes, card.agentId, card.sessionKey, ...card.labels] + return [ + card.title, + card.notes, + card.agentId, + card.sessionKey, + card.execution?.engine, + card.execution?.mode, + card.execution?.model, + card.execution?.sessionKey, + ...card.labels, + ] .filter((value): value is string => typeof value === "string") .some((value) => value.toLowerCase().includes(query)); } @@ -94,10 +106,11 @@ function openCardSession( props: Pick, card: WorkboardCard, ): boolean { - if (!card.sessionKey) { + const sessionKey = card.sessionKey ?? card.execution?.sessionKey; + if (!sessionKey) { return false; } - props.onOpenSession(card.sessionKey); + props.onOpenSession(sessionKey); return true; } @@ -533,10 +546,11 @@ function renderLifecycle(card: WorkboardCard, sessions: readonly GatewaySessionR const lifecycle = getWorkboardLifecycle(card, sessions); const formatted = formatLifecycle(lifecycle); const session = lifecycle.session; + const execution = card.execution; return html`
- ${formatted.label} + ${execution ? `${execution.engine} ${execution.mode}` : formatted.label} ${session?.displayName ?? session?.label ?? formatted.detail} @@ -545,13 +559,63 @@ function renderLifecycle(card: WorkboardCard, sessions: readonly GatewaySessionR `; } +function renderStartExecutionButton( + props: WorkboardProps, + card: WorkboardCard, + engine: WorkboardExecutionEngine | null, + mode: WorkboardExecutionMode, +) { + const state = getWorkboardState(props.host); + const busy = state.busyCardId === card.id; + const title = engine + ? `${mode === "autonomous" ? "Run" : "Open"} ${engine}` + : "Run default agent"; + return html` + + `; +} + +function renderStartExecutionControls(props: WorkboardProps, card: WorkboardCard) { + return html` +
+ ${renderStartExecutionButton(props, card, null, "autonomous")} + ${renderStartExecutionButton(props, card, "codex", "autonomous")} + ${renderStartExecutionButton(props, card, "claude", "autonomous")} + ${renderStartExecutionButton(props, card, "codex", "manual")} + ${renderStartExecutionButton(props, card, "claude", "manual")} +
+ `; +} + function renderCard(props: WorkboardProps, card: WorkboardCard) { const state = getWorkboardState(props.host); const session = findWorkboardSession(card, props.sessions); const busy = state.busyCardId === card.id; const syncing = state.syncingCardIds.has(card.id); const live = session?.hasActiveRun === true; - const linked = Boolean(card.sessionKey); + const linkedSessionKey = card.sessionKey ?? card.execution?.sessionKey; + const linked = Boolean(linkedSessionKey); return html`
${icons.edit} - ${card.sessionKey + ${linked ? html` @@ -640,26 +704,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) { ` : nothing} ` - : html` - - `} + : renderStartExecutionControls(props, card)}