mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-10 07:42:56 +00:00
fix(workboard): handle failed card starts
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user