feat(workboard): add card execution actions

This commit is contained in:
Peter Steinberger
2026-05-23 21:25:00 +01:00
parent e7e3b4a58b
commit 7e59e43ce6
9 changed files with 696 additions and 50 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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<string, unknown>;
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

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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<string, unknown> {
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<string, unknown> = {};
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<string | null> {
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;
}

View File

@@ -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<HTMLButtonElement>(".workboard-card__start");
expect(startButton?.textContent).toContain("Start");
const startButtons = [
...container.querySelectorAll<HTMLButtonElement>(".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();
});

View File

@@ -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<WorkboardProps, "onOpenSession">,
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`
<div class="workboard-card__lifecycle">
<span class="workboard-lifecycle workboard-lifecycle--${formatted.tone}">
${formatted.label}
${execution ? `${execution.engine} ${execution.mode}` : formatted.label}
</span>
<span class="workboard-card__lifecycle-detail">
${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`
<button
class="btn btn--xs workboard-card__start workboard-card__start--${mode} ${engine
? ""
: "workboard-card__start--default"}"
title=${title}
?disabled=${busy || !props.connected}
@click=${async () => {
const key = await startWorkboardCard({
host: props.host,
client: props.client,
card,
...(engine ? { engine } : {}),
mode,
requestUpdate: props.onRequestUpdate,
});
if (key) {
props.onOpenSession(key);
}
}}
>
${mode === "autonomous" ? icons.play : icons.penLine} ${engine ?? "Start"}
</button>
`;
}
function renderStartExecutionControls(props: WorkboardProps, card: WorkboardCard) {
return html`
<div class="workboard-card__execution-controls">
${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")}
</div>
`;
}
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`
<article
class="workboard-card priority-${card.priority} ${busy ? "workboard-card--busy" : ""} ${linked
@@ -612,12 +676,12 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
>
${icons.edit}
</button>
${card.sessionKey
${linked
? html`
<button
class="btn btn--icon workboard-card__icon"
title="Open session"
@click=${() => props.onOpenSession(card.sessionKey!)}
@click=${() => props.onOpenSession(linkedSessionKey!)}
>
${icons.messageSquare}
</button>
@@ -640,26 +704,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
`
: nothing}
`
: html`
<button
class="btn btn--xs workboard-card__start"
title="Start session"
?disabled=${busy || !props.connected}
@click=${async () => {
const key = await startWorkboardCard({
host: props.host,
client: props.client,
card,
requestUpdate: props.onRequestUpdate,
});
if (key) {
props.onOpenSession(key);
}
}}
>
${icons.play} Start
</button>
`}
: renderStartExecutionControls(props, card)}
<button
class="btn btn--icon workboard-card__icon workboard-card__delete"
title="Delete card"