fix(workboard): handle failed card starts

This commit is contained in:
Peter Steinberger
2026-05-22 19:14:03 +01:00
parent 0cdb80078f
commit 853b7cc75d
5 changed files with 405 additions and 2 deletions

View File

@@ -127,6 +127,21 @@
box-shadow: var(--shadow-xl);
}
.workboard-game {
display: grid;
gap: 14px;
width: min(420px, calc(100vw - 44px));
padding: 16px;
border: 1px solid color-mix(in srgb, var(--border) 86%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--panel) 96%, var(--bg) 4%);
box-shadow: var(--shadow-xl);
}
.workboard-game:focus {
outline: none;
}
.workboard-modal__header {
display: flex;
justify-content: space-between;
@@ -201,12 +216,124 @@
}
.workboard-toolbar .btn,
.workboard-draft .btn {
.workboard-draft .btn,
.workboard-game .btn {
min-height: var(--workboard-control-height);
padding: 0 12px;
border-radius: var(--workboard-control-radius);
}
.workboard-game__stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.workboard-game__stats span {
display: inline-flex;
align-items: center;
min-height: 24px;
border-radius: 6px;
padding: 2px 8px;
background: color-mix(in srgb, var(--border) 58%, transparent);
color: var(--muted);
font-size: 0.76rem;
line-height: 1;
}
.workboard-game__grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
aspect-ratio: 1;
min-width: 0;
}
.workboard-game__cell {
display: grid;
place-items: center;
min-width: 0;
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--bg) 84%, var(--panel) 16%);
color: var(--muted);
font-size: 0.84rem;
font-weight: 750;
line-height: 1;
}
.workboard-game__cell--player {
border-color: color-mix(in srgb, var(--accent) 54%, var(--border));
background: color-mix(in srgb, var(--accent) 18%, var(--bg));
color: var(--accent);
}
.workboard-game__cell--goal {
border-color: color-mix(in srgb, var(--ok) 48%, var(--border));
background: color-mix(in srgb, var(--ok) 14%, var(--bg));
color: var(--ok);
}
.workboard-game__cell--blocker {
background:
linear-gradient(
135deg,
transparent 0 45%,
color-mix(in srgb, var(--danger) 46%, transparent) 45% 55%,
transparent 55% 100%
),
color-mix(in srgb, var(--danger) 10%, var(--bg));
}
.workboard-game__controls {
display: grid;
grid-template-columns: repeat(3, 38px);
grid-template-areas:
". up ."
"left down right";
justify-content: center;
gap: 8px;
}
.workboard-game__arrow {
width: 38px;
height: 38px;
padding: 0;
}
.workboard-game__arrow svg {
width: 16px;
height: 16px;
}
.workboard-game__arrow--up {
grid-area: up;
}
.workboard-game__arrow--left {
grid-area: left;
}
.workboard-game__controls .workboard-game__arrow:not(.workboard-game__arrow--up, .workboard-game__arrow--left, .workboard-game__arrow--right) {
grid-area: down;
}
.workboard-game__arrow--right {
grid-area: right;
}
.workboard-game__arrow--up svg {
transform: rotate(180deg);
}
.workboard-game__arrow--left svg {
transform: rotate(90deg);
}
.workboard-game__arrow--right svg {
transform: rotate(-90deg);
}
.workboard-board {
display: grid;
grid-template-columns: repeat(6, minmax(220px, 1fr));

View File

@@ -350,6 +350,36 @@ describe("workboard controller", () => {
);
});
it("blocks a card when the initial session run fails to start", async () => {
const host = {};
const blocked = { ...sampleCard, status: "blocked", sessionKey: "agent:main:dashboard:1" };
const client = createClient({
"sessions.create": {
key: "agent:main:dashboard:1",
runStarted: false,
runError: { message: "provider unavailable" },
},
"workboard.cards.update": { card: blocked },
});
const sessionKey = await startWorkboardCard({
host,
client: client as never,
card: sampleCard,
});
expect(sessionKey).toBe("agent:main:dashboard:1");
expect(client.request).toHaveBeenNthCalledWith(
2,
"workboard.cards.update",
expect.objectContaining({
id: "card-1",
patch: { status: "blocked", sessionKey: "agent:main:dashboard:1" },
}),
);
expect(getWorkboardState(host).error).toBe("Agent run did not start: provider unavailable");
});
it("moves cards through the plugin gateway method", async () => {
const host = {};
const moved = { ...sampleCard, status: "blocked", position: 2000 };

View File

@@ -70,6 +70,11 @@ export type WorkboardUiState = {
draggedCardId: string | null;
syncingCardIds: Set<string>;
capturingSessionKeys: Set<string>;
gameOpen: boolean;
gamePlayerIndex: number;
gameMoves: number;
gameWins: number;
gameMessage: string;
};
type WorkboardHost = object;
@@ -105,6 +110,11 @@ function createDefaultState(): WorkboardUiState {
draggedCardId: null,
syncingCardIds: new Set(),
capturingSessionKeys: new Set(),
gameOpen: false,
gamePlayerIndex: 0,
gameMoves: 0,
gameWins: 0,
gameMessage: "workboard.gameStart",
};
}
@@ -124,6 +134,9 @@ function formatError(error: unknown): string {
if (typeof error === "string" && error.trim()) {
return error.trim();
}
if (isRecord(error) && typeof error.message === "string" && error.message.trim()) {
return error.message.trim();
}
return "Unknown workboard error.";
}
@@ -718,6 +731,24 @@ 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;
if (initialRunFailed) {
const payload = await params.client.request("workboard.cards.update", {
id: params.card.id,
patch: {
status: "blocked",
...(sessionKey ? { sessionKey } : {}),
},
});
replaceCard(state, normalizeCardPayload(payload));
const errorText =
isRecord(created) && "runError" in created ? formatError(created.runError) : "";
state.error =
errorText && errorText !== "Unknown workboard error."
? `Agent run did not start: ${errorText}`
: "Agent run did not start.";
return sessionKey;
}
const payload = await params.client.request("workboard.cards.update", {
id: params.card.id,
patch: {

View File

@@ -183,6 +183,39 @@ describe("renderWorkboard", () => {
expect(container.querySelector(".workboard-board")).toBeTruthy();
});
it("opens and plays the mini game", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
const container = document.createElement("div");
const props = {
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
};
render(renderWorkboard(props), container);
[...container.querySelectorAll<HTMLButtonElement>(".workboard-toolbar__actions .btn")]
.find((button) => button.textContent?.includes("Mini game"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(renderWorkboard(props), container);
expect(container.querySelector('[role="dialog"]')?.textContent).toContain("Card Chase");
const controls = container.querySelector<HTMLElement>(".workboard-game__controls");
controls
?.querySelector<HTMLButtonElement>('button[aria-label="Move right"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(renderWorkboard(props), container);
expect(state.gamePlayerIndex).toBe(1);
expect(container.querySelector(".workboard-game__stats")?.textContent).toContain("Moves 1");
});
it("opens an edit modal and submits card updates", async () => {
const host = {};
const state = getWorkboardState(host);

View File

@@ -42,6 +42,10 @@ const STATUS_LABELS: Record<WorkboardStatus, string> = {
done: "Done",
};
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]);
function formatTime(value: number | undefined): string {
if (!value) {
return "";
@@ -118,6 +122,41 @@ function openCreateModal(state: WorkboardUiState) {
state.draftOpen = true;
}
function resetGame(state: WorkboardUiState) {
state.gamePlayerIndex = 0;
state.gameMoves = 0;
state.gameMessage = "workboard.gameStart";
}
function moveGamePlayer(state: WorkboardUiState, delta: number) {
if (state.gamePlayerIndex === WORKBOARD_GAME_GOAL) {
resetGame(state);
}
const currentRow = Math.floor(state.gamePlayerIndex / WORKBOARD_GAME_SIZE);
const nextIndex = state.gamePlayerIndex + delta;
const nextRow = Math.floor(nextIndex / WORKBOARD_GAME_SIZE);
if (
nextIndex < 0 ||
nextIndex > WORKBOARD_GAME_GOAL ||
(delta === -1 && nextRow !== currentRow) ||
(delta === 1 && nextRow !== currentRow)
) {
state.gameMessage = "workboard.gameBoundary";
return;
}
if (WORKBOARD_GAME_BLOCKERS.has(nextIndex)) {
state.gameMessage = "workboard.gameBlocked";
return;
}
state.gamePlayerIndex = nextIndex;
state.gameMoves += 1;
state.gameMessage =
nextIndex === WORKBOARD_GAME_GOAL ? "workboard.gameWin" : "workboard.gameContinue";
if (nextIndex === WORKBOARD_GAME_GOAL) {
state.gameWins += 1;
}
}
function openEditModal(state: WorkboardUiState, card: WorkboardCard) {
state.draftOpen = true;
state.editingCardId = card.id;
@@ -130,6 +169,140 @@ function openEditModal(state: WorkboardUiState, card: WorkboardCard) {
state.draftSessionKey = card.sessionKey ?? "";
}
function renderGameArrow(
label: string,
className: string,
delta: number,
props: Pick<WorkboardProps, "host" | "onRequestUpdate">,
) {
const state = getWorkboardState(props.host);
return html`
<button
class="btn btn--icon workboard-game__arrow ${className}"
type="button"
title=${label}
aria-label=${label}
@click=${() => {
moveGamePlayer(state, delta);
props.onRequestUpdate?.();
}}
>
${icons.arrowDown}
</button>
`;
}
function renderGameModal(props: WorkboardProps) {
const state = getWorkboardState(props.host);
if (!state.gameOpen) {
return nothing;
}
return html`
<div
class="workboard-modal"
role="presentation"
@click=${(event: MouseEvent) => {
if (event.target === event.currentTarget) {
state.gameOpen = false;
props.onRequestUpdate?.();
}
}}
>
<div
class="workboard-game"
role="dialog"
aria-modal="true"
aria-labelledby="workboard-game-title"
tabindex="0"
@keydown=${(event: KeyboardEvent) => {
const moves: Record<string, number> = {
ArrowDown: WORKBOARD_GAME_SIZE,
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -WORKBOARD_GAME_SIZE,
};
const delta = moves[event.key];
if (typeof delta !== "number") {
return;
}
event.preventDefault();
moveGamePlayer(state, delta);
props.onRequestUpdate?.();
}}
>
<div class="workboard-modal__header">
<div>
<h2 id="workboard-game-title">${t("workboard.gameTitle")}</h2>
<p>${t(state.gameMessage)}</p>
</div>
<button
class="btn btn--icon workboard-card__icon"
type="button"
title=${t("common.cancel")}
@click=${() => {
state.gameOpen = false;
props.onRequestUpdate?.();
}}
>
${icons.x}
</button>
</div>
<div class="workboard-game__stats">
<span>${t("workboard.gameMoves", { count: String(state.gameMoves) })}</span>
<span>${t("workboard.gameWins", { count: String(state.gameWins) })}</span>
</div>
<div class="workboard-game__grid" role="grid" aria-label=${t("workboard.gameBoard")}>
${Array.from({ length: WORKBOARD_GAME_SIZE * WORKBOARD_GAME_SIZE }, (_, index) => {
const player = index === state.gamePlayerIndex;
const goal = index === WORKBOARD_GAME_GOAL;
const blocker = WORKBOARD_GAME_BLOCKERS.has(index);
return html`
<div
class="workboard-game__cell ${player ? "workboard-game__cell--player" : ""} ${goal
? "workboard-game__cell--goal"
: ""} ${blocker ? "workboard-game__cell--blocker" : ""}"
role="gridcell"
aria-label=${player
? t("workboard.gameAgent")
: goal
? t("workboard.gameLaunch")
: blocker
? t("workboard.gameBlockedCell")
: t("workboard.gameOpenCell")}
>
${player ? "A" : goal ? "L" : blocker ? "" : ""}
</div>
`;
})}
</div>
<div class="workboard-game__controls" aria-label=${t("workboard.gameControls")}>
${renderGameArrow(
t("workboard.gameMoveUp"),
"workboard-game__arrow--up",
-WORKBOARD_GAME_SIZE,
props,
)}
${renderGameArrow(t("workboard.gameMoveLeft"), "workboard-game__arrow--left", -1, props)}
${renderGameArrow(t("workboard.gameMoveDown"), "", WORKBOARD_GAME_SIZE, props)}
${renderGameArrow(t("workboard.gameMoveRight"), "workboard-game__arrow--right", 1, props)}
</div>
<div class="workboard-modal__actions">
<button
class="btn"
type="button"
@click=${() => {
resetGame(state);
props.onRequestUpdate?.();
}}
>
${t("common.reset")}
</button>
</div>
</div>
</div>
`;
}
function renderCardModal(props: WorkboardProps) {
const state = getWorkboardState(props.host);
const agents = props.agentsList?.agents ?? [];
@@ -630,6 +803,15 @@ export function renderWorkboard(props: WorkboardProps) {
>
${state.loading ? t("common.refreshing") : t("common.refresh")}
</button>
<button
class="btn"
@click=${() => {
state.gameOpen = true;
props.onRequestUpdate?.();
}}
>
${icons.play} ${t("workboard.gameButton")}
</button>
<button
class="btn primary"
@click=${() => {
@@ -642,7 +824,7 @@ export function renderWorkboard(props: WorkboardProps) {
</div>
</div>
${state.error ? html`<div class="callout danger">${state.error}</div>` : nothing}
${renderCardModal(props)}
${renderGameModal(props)} ${renderCardModal(props)}
<div class="workboard-board">
${state.statuses.map((status) => renderColumn(props, status, byStatus.get(status) ?? []))}
</div>