From 853b7cc75db79f2c6457478e33bb6398edbce3d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 19:14:03 +0100 Subject: [PATCH] fix(workboard): handle failed card starts --- ui/src/styles/workboard.css | 129 ++++++++++++++++- ui/src/ui/controllers/workboard.test.ts | 30 ++++ ui/src/ui/controllers/workboard.ts | 31 ++++ ui/src/ui/views/workboard.test.ts | 33 +++++ ui/src/ui/views/workboard.ts | 184 +++++++++++++++++++++++- 5 files changed, 405 insertions(+), 2 deletions(-) diff --git a/ui/src/styles/workboard.css b/ui/src/styles/workboard.css index 138fd361222..f91f8d5aaef 100644 --- a/ui/src/styles/workboard.css +++ b/ui/src/styles/workboard.css @@ -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)); diff --git a/ui/src/ui/controllers/workboard.test.ts b/ui/src/ui/controllers/workboard.test.ts index dccad890017..3672e2d1f8e 100644 --- a/ui/src/ui/controllers/workboard.test.ts +++ b/ui/src/ui/controllers/workboard.test.ts @@ -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 }; diff --git a/ui/src/ui/controllers/workboard.ts b/ui/src/ui/controllers/workboard.ts index 642f7448ea6..88d46d5c256 100644 --- a/ui/src/ui/controllers/workboard.ts +++ b/ui/src/ui/controllers/workboard.ts @@ -70,6 +70,11 @@ export type WorkboardUiState = { draggedCardId: string | null; syncingCardIds: Set; capturingSessionKeys: Set; + 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: { diff --git a/ui/src/ui/views/workboard.test.ts b/ui/src/ui/views/workboard.test.ts index eda8c341145..d2b2c4ff381 100644 --- a/ui/src/ui/views/workboard.test.ts +++ b/ui/src/ui/views/workboard.test.ts @@ -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(".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(".workboard-game__controls"); + controls + ?.querySelector('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); diff --git a/ui/src/ui/views/workboard.ts b/ui/src/ui/views/workboard.ts index 80a2bd7797d..b0372a61831 100644 --- a/ui/src/ui/views/workboard.ts +++ b/ui/src/ui/views/workboard.ts @@ -42,6 +42,10 @@ const STATUS_LABELS: Record = { 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, +) { + const state = getWorkboardState(props.host); + return html` + + `; +} + +function renderGameModal(props: WorkboardProps) { + const state = getWorkboardState(props.host); + if (!state.gameOpen) { + return nothing; + } + return html` + + `; +} + 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")} +