mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user