Skip to content

Commit be336cc

Browse files
authored
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
1 parent 8cecf2c commit be336cc

4 files changed

Lines changed: 290 additions & 1 deletion

File tree

docs/plugins/workboard.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,8 @@ Workboard stops auto-moving that card until you move it back to `todo` or
292292
2. Create a card with a title, notes, priority, labels, optional agent, and
293293
optional linked session.
294294
3. Or open Sessions and choose Add to Workboard for an existing session.
295-
4. Drag the card between columns or use the column controls.
295+
4. Drag the card between columns or focus the compact status control on the card
296+
and use its menu or ArrowLeft/ArrowRight.
296297
5. Start work from the card to create or reuse a dashboard session.
297298
6. Open the linked session from the card while the agent works.
298299
7. Let lifecycle sync move running work into review or blocked, then manually

ui/src/styles/workboard.css

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,73 @@
369369
height: 15px;
370370
}
371371

372+
.workboard-card__move {
373+
position: relative;
374+
display: inline-flex;
375+
align-items: center;
376+
color: var(--muted);
377+
}
378+
379+
.workboard-card__move-icon {
380+
position: absolute;
381+
left: 7px;
382+
display: inline-flex;
383+
pointer-events: none;
384+
}
385+
386+
.workboard-card__move-icon svg {
387+
width: 14px;
388+
height: 14px;
389+
stroke: currentColor;
390+
fill: none;
391+
stroke-width: 1.5px;
392+
stroke-linecap: round;
393+
stroke-linejoin: round;
394+
}
395+
396+
.workboard-card__move-select {
397+
height: 28px;
398+
max-width: 112px;
399+
min-width: 92px;
400+
padding: 0 22px 0 25px;
401+
border: 1px solid var(--border);
402+
border-radius: 6px;
403+
appearance: none;
404+
background-color: var(--bg-elevated);
405+
background-image:
406+
linear-gradient(45deg, transparent 50%, currentColor 50%),
407+
linear-gradient(135deg, currentColor 50%, transparent 50%);
408+
background-position:
409+
calc(100% - 13px) 50%,
410+
calc(100% - 8px) 50%;
411+
background-size:
412+
5px 5px,
413+
5px 5px;
414+
background-repeat: no-repeat;
415+
color: var(--muted);
416+
cursor: pointer;
417+
font-size: 12px;
418+
font-weight: 500;
419+
letter-spacing: 0;
420+
line-height: 1;
421+
}
422+
423+
.workboard-card__move-select:hover {
424+
border-color: var(--border-strong);
425+
background-color: var(--bg-hover);
426+
}
427+
428+
.workboard-card__move-select:focus-visible {
429+
outline: 2px solid color-mix(in srgb, var(--accent) 56%, transparent);
430+
outline-offset: 2px;
431+
border-color: var(--accent);
432+
}
433+
434+
.workboard-card__move-select:disabled {
435+
cursor: not-allowed;
436+
opacity: 0.58;
437+
}
438+
372439
.workboard-card__delete:hover {
373440
border-color: color-mix(in srgb, var(--danger) 34%, var(--border));
374441
background: color-mix(in srgb, var(--danger) 14%, transparent);

ui/src/ui/views/workboard.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,10 +509,161 @@ describe("renderWorkboard", () => {
509509
expect(
510510
container.querySelector<HTMLButtonElement>(".workboard-toolbar__actions .btn.primary"),
511511
).toBeNull();
512+
expect(container.querySelector<HTMLSelectElement>(".workboard-card__move-select")).toBeNull();
512513
expect(container.querySelector(".workboard-card")?.getAttribute("draggable")).toBe("false");
513514
expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBe("button");
514515
});
515516

517+
it("moves a card from the compact status control", async () => {
518+
const host = {};
519+
const state = getWorkboardState(host);
520+
state.loaded = true;
521+
state.cards = [
522+
{
523+
id: "card-1",
524+
title: "Keyboard move",
525+
status: "todo",
526+
priority: "normal",
527+
labels: [],
528+
position: 1000,
529+
createdAt: 1,
530+
updatedAt: 1,
531+
},
532+
];
533+
const request = vi.fn(async () => ({
534+
card: { ...state.cards[0], status: "blocked", position: 1000, updatedAt: 2 },
535+
}));
536+
const props = {
537+
host,
538+
client: { request } as unknown as GatewayBrowserClient,
539+
connected: true,
540+
pluginEnabled: true,
541+
agentsList: null,
542+
sessions: [],
543+
onOpenSession: () => undefined,
544+
onRequestUpdate: () => undefined,
545+
};
546+
const container = document.createElement("div");
547+
548+
render(renderWorkboard(props), container);
549+
const moveSelect = container.querySelector<HTMLSelectElement>(".workboard-card__move-select");
550+
expect(moveSelect?.value).toBe("todo");
551+
expect(moveSelect?.tagName).toBe("SELECT");
552+
expect(moveSelect?.getAttribute("aria-keyshortcuts")).toBe("ArrowLeft ArrowRight");
553+
expect(moveSelect?.getAttribute("aria-label")).toBe("Status: Keyboard move");
554+
555+
moveSelect!.value = "blocked";
556+
moveSelect!.dispatchEvent(new Event("change", { bubbles: true }));
557+
await Promise.resolve();
558+
await Promise.resolve();
559+
render(renderWorkboard(props), container);
560+
561+
expect(request).toHaveBeenCalledWith("workboard.cards.move", {
562+
id: "card-1",
563+
status: "blocked",
564+
position: 1000,
565+
});
566+
const blockedColumn = [...container.querySelectorAll<HTMLElement>(".workboard-column")].find(
567+
(column) => column.querySelector("h2")?.textContent === "Blocked",
568+
);
569+
expect(blockedColumn?.textContent).toContain("Keyboard move");
570+
expect(state.cards[0]).toMatchObject({ status: "blocked", updatedAt: 2 });
571+
});
572+
573+
it("moves a focused status control with keyboard arrows", async () => {
574+
const host = {};
575+
const state = getWorkboardState(host);
576+
state.loaded = true;
577+
state.cards = [
578+
{
579+
id: "card-1",
580+
title: "Keyboard arrow move",
581+
status: "todo",
582+
priority: "normal",
583+
labels: [],
584+
position: 1000,
585+
createdAt: 1,
586+
updatedAt: 1,
587+
},
588+
];
589+
const request = vi.fn(async () => ({
590+
card: { ...state.cards[0], status: "scheduled", position: 1000, updatedAt: 2 },
591+
}));
592+
const props = {
593+
host,
594+
client: { request } as unknown as GatewayBrowserClient,
595+
connected: true,
596+
pluginEnabled: true,
597+
agentsList: null,
598+
sessions: [],
599+
onOpenSession: () => undefined,
600+
onRequestUpdate: () => undefined,
601+
};
602+
const container = document.createElement("div");
603+
604+
render(renderWorkboard(props), container);
605+
const moveSelect = container.querySelector<HTMLSelectElement>(".workboard-card__move-select");
606+
const dispatched = moveSelect!.dispatchEvent(
607+
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
608+
);
609+
await Promise.resolve();
610+
await Promise.resolve();
611+
612+
expect(dispatched).toBe(false);
613+
expect(request).toHaveBeenCalledWith("workboard.cards.move", {
614+
id: "card-1",
615+
status: "scheduled",
616+
position: 1000,
617+
});
618+
expect(state.cards[0]).toMatchObject({ status: "scheduled", updatedAt: 2 });
619+
});
620+
621+
it("does not queue status-control moves while a card is busy", async () => {
622+
const host = {};
623+
const state = getWorkboardState(host);
624+
state.loaded = true;
625+
state.busyCardId = "card-1";
626+
state.cards = [
627+
{
628+
id: "card-1",
629+
title: "Busy move",
630+
status: "todo",
631+
priority: "normal",
632+
labels: [],
633+
position: 1000,
634+
createdAt: 1,
635+
updatedAt: 1,
636+
},
637+
];
638+
const request = vi.fn();
639+
const props = {
640+
host,
641+
client: { request } as unknown as GatewayBrowserClient,
642+
connected: true,
643+
pluginEnabled: true,
644+
agentsList: null,
645+
sessions: [],
646+
onOpenSession: () => undefined,
647+
onRequestUpdate: () => undefined,
648+
};
649+
const container = document.createElement("div");
650+
651+
render(renderWorkboard(props), container);
652+
const moveSelect = container.querySelector<HTMLSelectElement>(".workboard-card__move-select");
653+
expect(moveSelect?.disabled).toBe(true);
654+
655+
moveSelect!.value = "blocked";
656+
moveSelect!.dispatchEvent(new Event("change", { bubbles: true }));
657+
const dispatched = moveSelect!.dispatchEvent(
658+
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
659+
);
660+
await Promise.resolve();
661+
662+
expect(dispatched).toBe(false);
663+
expect(request).not.toHaveBeenCalled();
664+
expect(state.cards[0]).toMatchObject({ status: "todo", updatedAt: 1 });
665+
});
666+
516667
it("offers start controls when a linked session no longer exists", () => {
517668
const host = {};
518669
const state = getWorkboardState(host);

ui/src/ui/views/workboard.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,75 @@ function isCardActionTarget(event: Event): boolean {
299299
: false;
300300
}
301301

302+
function moveCardToStatus(
303+
props: WorkboardProps,
304+
card: WorkboardCard,
305+
status: WorkboardStatus,
306+
state: WorkboardUiState,
307+
) {
308+
if (status === card.status || state.busyCardId === card.id || !props.connected || !props.client) {
309+
return;
310+
}
311+
void moveWorkboardCard({
312+
host: props.host,
313+
client: props.client,
314+
cardId: card.id,
315+
status,
316+
position: nextPosition(state.cards, status),
317+
requestUpdate: props.onRequestUpdate,
318+
});
319+
}
320+
321+
function renderCardMoveControl(props: WorkboardProps, card: WorkboardCard, busy: boolean) {
322+
const state = getWorkboardState(props.host);
323+
const statuses = state.statuses.includes(card.status)
324+
? state.statuses
325+
: [card.status, ...state.statuses];
326+
if (statuses.length < 2) {
327+
return nothing;
328+
}
329+
return html`
330+
<label class="workboard-card__move" title=${t("workboard.fieldStatus")}>
331+
<span class="workboard-card__move-icon" aria-hidden="true">${icons.cornerDownRight}</span>
332+
<select
333+
class="workboard-card__move-select"
334+
aria-keyshortcuts="ArrowLeft ArrowRight"
335+
aria-label=${`${t("workboard.fieldStatus")}: ${card.title}`}
336+
.value=${card.status}
337+
?disabled=${busy || !props.connected || !props.client}
338+
@change=${(event: Event) => {
339+
const target = event.currentTarget as HTMLSelectElement;
340+
moveCardToStatus(props, card, target.value as WorkboardStatus, state);
341+
}}
342+
@keydown=${(event: KeyboardEvent) => {
343+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
344+
return;
345+
}
346+
if (state.busyCardId === card.id || !props.connected || !props.client) {
347+
event.preventDefault();
348+
return;
349+
}
350+
const currentIndex = statuses.indexOf(card.status);
351+
const offset = event.key === "ArrowRight" ? 1 : -1;
352+
const status = statuses[currentIndex + offset];
353+
if (!status) {
354+
return;
355+
}
356+
event.preventDefault();
357+
moveCardToStatus(props, card, status, state);
358+
}}
359+
>
360+
${statuses.map(
361+
(status) =>
362+
html`<option value=${status} ?selected=${status === card.status}>
363+
${formatStatusLabel(status)}
364+
</option>`,
365+
)}
366+
</select>
367+
</label>
368+
`;
369+
}
370+
302371
function openCardDetails(state: WorkboardUiState, card: WorkboardCard) {
303372
state.detailCardId = card.id;
304373
state.detailCommentBody = "";
@@ -1223,6 +1292,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
12231292
${showStartControls ? renderStartExecutionButton(props, card, null, "autonomous") : nothing}
12241293
${writable
12251294
? html`
1295+
${renderCardMoveControl(props, card, busy)}
12261296
<button
12271297
class="btn btn--icon workboard-card__icon"
12281298
title=${t("workboard.archiveCard")}

0 commit comments

Comments
 (0)