feat(ui): add workboard keyboard movement controls

Add compact keyboard-accessible Workboard status movement controls for writable operators. The control reuses the existing workboard.cards.move path, preserves drag/drop as the pointer enhancement, and suppresses mutation controls for read-only operators.\n\nVerification:\n- node scripts/run-vitest.mjs ui/src/ui/views/workboard.test.ts\n- corepack pnpm exec oxfmt --check --threads=1 ui/src/ui/views/workboard.ts ui/src/ui/views/workboard.test.ts ui/src/styles/workboard.css docs/plugins/workboard.md\n- git diff --check origin/main...HEAD\n- Chromium Control UI mock Gateway keyboard movement proof\n- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --no-web-search
This commit is contained in:
Val Alexander
2026-06-02 16:08:29 -05:00
committed by GitHub
parent 8cecf2c7ea
commit be336cc1e4
4 changed files with 290 additions and 1 deletions

View File

@@ -369,6 +369,73 @@
height: 15px;
}
.workboard-card__move {
position: relative;
display: inline-flex;
align-items: center;
color: var(--muted);
}
.workboard-card__move-icon {
position: absolute;
left: 7px;
display: inline-flex;
pointer-events: none;
}
.workboard-card__move-icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.workboard-card__move-select {
height: 28px;
max-width: 112px;
min-width: 92px;
padding: 0 22px 0 25px;
border: 1px solid var(--border);
border-radius: 6px;
appearance: none;
background-color: var(--bg-elevated);
background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position:
calc(100% - 13px) 50%,
calc(100% - 8px) 50%;
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
color: var(--muted);
cursor: pointer;
font-size: 12px;
font-weight: 500;
letter-spacing: 0;
line-height: 1;
}
.workboard-card__move-select:hover {
border-color: var(--border-strong);
background-color: var(--bg-hover);
}
.workboard-card__move-select:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 56%, transparent);
outline-offset: 2px;
border-color: var(--accent);
}
.workboard-card__move-select:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.workboard-card__delete:hover {
border-color: color-mix(in srgb, var(--danger) 34%, var(--border));
background: color-mix(in srgb, var(--danger) 14%, transparent);

View File

@@ -509,10 +509,161 @@ describe("renderWorkboard", () => {
expect(
container.querySelector<HTMLButtonElement>(".workboard-toolbar__actions .btn.primary"),
).toBeNull();
expect(container.querySelector<HTMLSelectElement>(".workboard-card__move-select")).toBeNull();
expect(container.querySelector(".workboard-card")?.getAttribute("draggable")).toBe("false");
expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBe("button");
});
it("moves a card from the compact status control", async () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Keyboard move",
status: "todo",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
},
];
const request = vi.fn(async () => ({
card: { ...state.cards[0], status: "blocked", position: 1000, updatedAt: 2 },
}));
const props = {
host,
client: { request } as unknown as GatewayBrowserClient,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
};
const container = document.createElement("div");
render(renderWorkboard(props), container);
const moveSelect = container.querySelector<HTMLSelectElement>(".workboard-card__move-select");
expect(moveSelect?.value).toBe("todo");
expect(moveSelect?.tagName).toBe("SELECT");
expect(moveSelect?.getAttribute("aria-keyshortcuts")).toBe("ArrowLeft ArrowRight");
expect(moveSelect?.getAttribute("aria-label")).toBe("Status: Keyboard move");
moveSelect!.value = "blocked";
moveSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
render(renderWorkboard(props), container);
expect(request).toHaveBeenCalledWith("workboard.cards.move", {
id: "card-1",
status: "blocked",
position: 1000,
});
const blockedColumn = [...container.querySelectorAll<HTMLElement>(".workboard-column")].find(
(column) => column.querySelector("h2")?.textContent === "Blocked",
);
expect(blockedColumn?.textContent).toContain("Keyboard move");
expect(state.cards[0]).toMatchObject({ status: "blocked", updatedAt: 2 });
});
it("moves a focused status control with keyboard arrows", async () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Keyboard arrow move",
status: "todo",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
},
];
const request = vi.fn(async () => ({
card: { ...state.cards[0], status: "scheduled", position: 1000, updatedAt: 2 },
}));
const props = {
host,
client: { request } as unknown as GatewayBrowserClient,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
};
const container = document.createElement("div");
render(renderWorkboard(props), container);
const moveSelect = container.querySelector<HTMLSelectElement>(".workboard-card__move-select");
const dispatched = moveSelect!.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
);
await Promise.resolve();
await Promise.resolve();
expect(dispatched).toBe(false);
expect(request).toHaveBeenCalledWith("workboard.cards.move", {
id: "card-1",
status: "scheduled",
position: 1000,
});
expect(state.cards[0]).toMatchObject({ status: "scheduled", updatedAt: 2 });
});
it("does not queue status-control moves while a card is busy", async () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.busyCardId = "card-1";
state.cards = [
{
id: "card-1",
title: "Busy move",
status: "todo",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
},
];
const request = vi.fn();
const props = {
host,
client: { request } as unknown as GatewayBrowserClient,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
};
const container = document.createElement("div");
render(renderWorkboard(props), container);
const moveSelect = container.querySelector<HTMLSelectElement>(".workboard-card__move-select");
expect(moveSelect?.disabled).toBe(true);
moveSelect!.value = "blocked";
moveSelect!.dispatchEvent(new Event("change", { bubbles: true }));
const dispatched = moveSelect!.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
);
await Promise.resolve();
expect(dispatched).toBe(false);
expect(request).not.toHaveBeenCalled();
expect(state.cards[0]).toMatchObject({ status: "todo", updatedAt: 1 });
});
it("offers start controls when a linked session no longer exists", () => {
const host = {};
const state = getWorkboardState(host);

View File

@@ -299,6 +299,75 @@ function isCardActionTarget(event: Event): boolean {
: false;
}
function moveCardToStatus(
props: WorkboardProps,
card: WorkboardCard,
status: WorkboardStatus,
state: WorkboardUiState,
) {
if (status === card.status || state.busyCardId === card.id || !props.connected || !props.client) {
return;
}
void moveWorkboardCard({
host: props.host,
client: props.client,
cardId: card.id,
status,
position: nextPosition(state.cards, status),
requestUpdate: props.onRequestUpdate,
});
}
function renderCardMoveControl(props: WorkboardProps, card: WorkboardCard, busy: boolean) {
const state = getWorkboardState(props.host);
const statuses = state.statuses.includes(card.status)
? state.statuses
: [card.status, ...state.statuses];
if (statuses.length < 2) {
return nothing;
}
return html`
<label class="workboard-card__move" title=${t("workboard.fieldStatus")}>
<span class="workboard-card__move-icon" aria-hidden="true">${icons.cornerDownRight}</span>
<select
class="workboard-card__move-select"
aria-keyshortcuts="ArrowLeft ArrowRight"
aria-label=${`${t("workboard.fieldStatus")}: ${card.title}`}
.value=${card.status}
?disabled=${busy || !props.connected || !props.client}
@change=${(event: Event) => {
const target = event.currentTarget as HTMLSelectElement;
moveCardToStatus(props, card, target.value as WorkboardStatus, state);
}}
@keydown=${(event: KeyboardEvent) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
return;
}
if (state.busyCardId === card.id || !props.connected || !props.client) {
event.preventDefault();
return;
}
const currentIndex = statuses.indexOf(card.status);
const offset = event.key === "ArrowRight" ? 1 : -1;
const status = statuses[currentIndex + offset];
if (!status) {
return;
}
event.preventDefault();
moveCardToStatus(props, card, status, state);
}}
>
${statuses.map(
(status) =>
html`<option value=${status} ?selected=${status === card.status}>
${formatStatusLabel(status)}
</option>`,
)}
</select>
</label>
`;
}
function openCardDetails(state: WorkboardUiState, card: WorkboardCard) {
state.detailCardId = card.id;
state.detailCommentBody = "";
@@ -1223,6 +1292,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
${showStartControls ? renderStartExecutionButton(props, card, null, "autonomous") : nothing}
${writable
? html`
${renderCardMoveControl(props, card, busy)}
<button
class="btn btn--icon workboard-card__icon"
title=${t("workboard.archiveCard")}