fix(ui): keep Workboard detail drawer actions in parity (#101658)

* Fix Workboard detail action parity

* test(ui): cover Workboard detail drawer actions

* fix(ui): satisfy Workboard action lint
This commit is contained in:
Momo 2026-07-08 10:25:42 +08:00 committed by GitHub
parent d5e4ed93fa
commit fdc98242a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 303 additions and 127 deletions

View file

@ -149,6 +149,7 @@ describe("renderWorkboard", () => {
const request = vi.fn(async () => ({ card: state.cards[0] }));
state.loaded = true;
state.dispatching = true;
state.detailCardId = "card-1";
state.cards = [
{
id: "card-1",
@ -184,6 +185,15 @@ describe("renderWorkboard", () => {
expect(
container.querySelector<HTMLSelectElement>(".workboard-card__move-select")?.disabled,
).toBe(true);
const detailActions = container.querySelector<HTMLElement>(".workboard-detail__actions");
expect(detailActions).not.toBeNull();
expect(buttonByLabel(detailActions!, "Edit card")?.disabled).toBe(true);
expect(buttonByLabel(detailActions!, "Archive card")?.disabled).toBe(true);
expect(buttonByLabel(detailActions!, "Stop session")?.disabled).toBe(true);
expect(buttonByLabel(detailActions!, "Delete card")?.disabled).toBe(true);
expect(
detailActions!.querySelector<HTMLSelectElement>(".workboard-card__move-select")?.disabled,
).toBe(true);
expect(container.querySelector<HTMLElement>(".workboard-card")?.getAttribute("draggable")).toBe(
"false",
);
@ -1243,6 +1253,69 @@ describe("renderWorkboard", () => {
expect(onOpenSession).not.toHaveBeenCalled();
});
it("mirrors compact card actions in the detail drawer", () => {
const host = {};
const state = getWorkboardState(host);
const sessionKey = "agent:main:detail-parity";
state.loaded = true;
state.detailCardId = "card-1";
state.cards = [
{
id: "card-1",
title: "Detail parity",
status: "running",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
sessionKey,
},
];
const container = document.createElement("div");
const onOpenSession = vi.fn();
render(
renderWorkboard({
host,
client: { request: vi.fn() } as unknown as GatewayBrowserClient,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [
{
key: sessionKey,
kind: "direct",
displayName: "Detail parity session",
updatedAt: 2,
hasActiveRun: true,
status: "running",
},
],
onOpenSession,
}),
container,
);
const actions = container.querySelector<HTMLElement>(".workboard-detail__actions");
expect(actions).not.toBeNull();
expect(buttonByLabel(actions!, "Edit card")).not.toBeNull();
expect(buttonByLabel(actions!, "Archive card")).not.toBeNull();
expect(buttonByLabel(actions!, "Stop session")).not.toBeNull();
expect(buttonByLabel(actions!, "Open session")).not.toBeNull();
expect(buttonByLabel(actions!, "Delete card")).not.toBeNull();
const moveSelect = actions!.querySelector<HTMLSelectElement>(".workboard-card__move-select");
expect(moveSelect?.getAttribute("aria-label")).toBe("Status: Detail parity");
expect([...moveSelect!.options].map((option) => option.textContent?.trim())).toContain(
"Review",
);
buttonByLabel(actions!, "Edit card")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(state.draftOpen).toBe(true);
expect(state.editingCardId).toBe("card-1");
});
it("keeps focus inside the card modal and restores focus on Escape", async () => {
const host = {};
const state = getWorkboardState(host);

View file

@ -1135,7 +1135,12 @@ function moveCardToStatus(
});
}
function renderCardMoveControl(props: WorkboardProps, card: WorkboardCard, busy: boolean) {
function renderCardMoveControl(
props: WorkboardProps,
card: WorkboardCard,
busy: boolean,
options: { wide?: boolean } = {},
) {
const state = getWorkboardState(props.host);
const statuses = state.statuses.includes(card.status)
? state.statuses
@ -1144,7 +1149,10 @@ function renderCardMoveControl(props: WorkboardProps, card: WorkboardCard, busy:
return nothing;
}
return html`
<label class="workboard-card__move" title=${t("workboard.fieldStatus")}>
<label
class="workboard-card__move ${options.wide ? "workboard-card__move--wide" : ""}"
title=${t("workboard.fieldStatus")}
>
<span class="workboard-card__move-icon" aria-hidden="true">${icons.cornerDownRight}</span>
<select
class="workboard-card__move-select"
@ -1200,6 +1208,166 @@ function renderCardActionSlot(content: TemplateResult | typeof nothing) {
`;
}
function getCardActionState(props: WorkboardProps, card: WorkboardCard) {
const state = getWorkboardState(props.host);
const task = state.tasksByCardId.get(card.id);
const session = findWorkboardSession(card, props.sessions);
const busy = state.busyCardIds.has(card.id) || state.dispatching;
const activeTask = cardHasActiveOrRunningUnresolvedTask(card, task, state.missingTaskIds);
const writable = canMutate(props);
const live =
activeTask ||
cardHasUnresolvedStartedRun(card) ||
session?.hasActiveRun === true ||
(session?.hasActiveRun !== false && session?.status === "running");
return {
state,
task,
session,
busy,
activeTask,
live,
linkedSessionKey: card.sessionKey ?? card.execution?.sessionKey,
writable,
showStartControls: writable && cardCanStart(state, props.sessions, card),
archived: Boolean(card.metadata?.archivedAt),
};
}
function renderCardActionButton(params: {
label: string;
icon: TemplateResult;
iconOnly?: boolean;
className?: string;
disabled?: boolean;
ariaHaspopup?: "dialog";
onClick: (event: MouseEvent) => void;
}) {
const button = html`
<button
class=${params.iconOnly
? `btn btn--icon workboard-card__icon ${params.className ?? ""}`
: `btn ${params.className ?? ""}`}
type="button"
aria-label=${params.label}
aria-haspopup=${params.ariaHaspopup ?? nothing}
?disabled=${params.disabled}
@click=${params.onClick}
>
${params.icon}${params.iconOnly ? nothing : html`<span>${params.label}</span>`}
</button>
`;
return params.iconOnly
? html`<openclaw-tooltip .content=${params.label}>${button}</openclaw-tooltip>`
: button;
}
function renderEditCardAction(
props: WorkboardProps,
card: WorkboardCard,
options: { iconOnly?: boolean } = {},
) {
const state = getWorkboardState(props.host);
return renderCardActionButton({
label: t("workboard.editCard"),
icon: icons.edit,
iconOnly: options.iconOnly,
ariaHaspopup: "dialog",
disabled: state.dispatching,
onClick: (event) => {
rememberWorkboardReturnFocus(event.currentTarget);
openEditModal(state, card);
props.onRequestUpdate?.();
},
});
}
function renderArchiveCardAction(
props: WorkboardProps,
card: WorkboardCard,
busy: boolean,
archived: boolean,
options: { iconOnly?: boolean } = {},
) {
const label = archived ? t("workboard.unarchiveCard") : t("workboard.archiveCard");
return renderCardActionButton({
label,
icon: archived ? icons.archiveRestore : icons.archive,
iconOnly: options.iconOnly,
disabled: busy,
onClick: () => {
void archiveWorkboardCard({
host: props.host,
client: props.client,
cardId: card.id,
archived: !archived,
requestUpdate: props.onRequestUpdate,
});
},
});
}
function renderOpenSessionCardAction(
props: WorkboardProps,
linkedSessionKey: string | undefined,
options: { iconOnly?: boolean } = {},
) {
if (!linkedSessionKey) {
return nothing;
}
return renderCardActionButton({
label: t("workboard.openSession"),
icon: icons.messageSquare,
iconOnly: options.iconOnly,
onClick: () => props.onOpenSession(linkedSessionKey),
});
}
function renderStopCardAction(
props: WorkboardProps,
card: WorkboardCard,
busy: boolean,
options: { iconOnly?: boolean } = {},
) {
return renderCardActionButton({
label: t("workboard.stopSession"),
icon: icons.stop,
iconOnly: options.iconOnly,
disabled: busy || !props.connected,
onClick: () => {
void stopWorkboardCard({
host: props.host,
client: props.client,
card,
requestUpdate: props.onRequestUpdate,
});
},
});
}
function renderDeleteCardAction(
props: WorkboardProps,
card: WorkboardCard,
busy: boolean,
options: { iconOnly?: boolean } = {},
) {
return renderCardActionButton({
label: t("workboard.deleteCard"),
icon: icons.trash,
iconOnly: options.iconOnly,
className: "workboard-card__delete",
disabled: busy,
onClick: () => {
void deleteWorkboardCard({
host: props.host,
client: props.client,
cardId: card.id,
requestUpdate: props.onRequestUpdate,
});
},
});
}
function openCardDetails(state: WorkboardUiState, card: WorkboardCard) {
state.detailCardId = card.id;
state.detailCommentBody = "";
@ -1863,12 +2031,12 @@ function renderCardDetailsPanel(props: WorkboardProps) {
if (!card) {
return nothing;
}
const task = state.tasksByCardId.get(card.id);
const cardActions = getCardActionState(props, card);
const { task, busy, activeTask, live, linkedSessionKey, writable, showStartControls, archived } =
cardActions;
const lifecycle = getWorkboardLifecycle(card, props.sessions, task);
const formatted = formatLifecycle(lifecycle);
const taskIsAuthoritative = task ? taskMatchesLifecycle(task, lifecycle) : false;
const linkedSessionKey = card.sessionKey ?? card.execution?.sessionKey;
const writable = canMutate(props);
const comments = card.metadata?.comments ?? [];
const attempts = card.metadata?.attempts ?? [];
const links = card.metadata?.links ?? [];
@ -1880,8 +2048,6 @@ function renderCardDetailsPanel(props: WorkboardProps) {
const workerProtocol = card.metadata?.workerProtocol;
const automation = card.metadata?.automation;
const events = (card.events ?? []).slice(-6).toReversed();
const busy = state.busyCardIds.has(card.id) || state.dispatching;
const showStartControls = writable && cardCanStart(state, props.sessions, card);
const dependencies = getWorkboardDependencyState(card, state.cards);
return html`
<aside
@ -2085,17 +2251,14 @@ function renderCardDetailsPanel(props: WorkboardProps) {
</section>
<div class="workboard-detail__actions">
${linkedSessionKey
? html`
<button
class="btn"
type="button"
@click=${() => props.onOpenSession(linkedSessionKey)}
>
${icons.messageSquare} ${t("workboard.openSession")}
</button>
`
${writable && !archived ? renderEditCardAction(props, card) : nothing}
${writable ? renderArchiveCardAction(props, card, busy, archived) : nothing}
${writable ? renderCardMoveControl(props, card, busy, { wide: true }) : nothing}
${writable && (linkedSessionKey ? live : activeTask)
? renderStopCardAction(props, card, busy)
: nothing}
${renderOpenSessionCardAction(props, linkedSessionKey)}
${writable ? renderDeleteCardAction(props, card, busy) : nothing}
${showStartControls ? renderStartExecutionControls(props, card) : nothing}
</div>
</div>
@ -2217,21 +2380,19 @@ const viewPresetOptions: Array<{ value: WorkboardUiState["viewPreset"]; labelKey
];
function renderCard(props: WorkboardProps, card: WorkboardCard) {
const state = getWorkboardState(props.host);
const task = state.tasksByCardId.get(card.id);
const session = findWorkboardSession(card, props.sessions);
const busy = state.busyCardIds.has(card.id) || state.dispatching;
const cardActions = getCardActionState(props, card);
const {
state,
task,
busy,
activeTask,
live,
linkedSessionKey,
writable,
showStartControls,
archived,
} = cardActions;
const syncing = state.syncingCardIds.has(card.id);
const activeTask = cardHasActiveOrRunningUnresolvedTask(card, task, state.missingTaskIds);
const live =
activeTask ||
cardHasUnresolvedStartedRun(card) ||
session?.hasActiveRun === true ||
(session?.hasActiveRun !== false && session?.status === "running");
const linkedSessionKey = card.sessionKey ?? card.execution?.sessionKey;
const writable = canMutate(props);
const showStartControls = writable && cardCanStart(state, props.sessions, card);
const archived = Boolean(card.metadata?.archivedAt);
const healthHighlighted = state.activeHealthHighlight
? workboardCardMatchesHealthKey(card, state.activeHealthHighlight, props.sessions, task)
: false;
@ -2240,49 +2401,9 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
? renderStartExecutionButton(props, card, null, "autonomous", { iconOnly: true })
: nothing;
const topEditAction =
writable && !archived
? html`
<openclaw-tooltip .content=${t("workboard.editCard")}>
<button
class="btn btn--icon workboard-card__icon"
type="button"
aria-label=${t("workboard.editCard")}
aria-haspopup="dialog"
?disabled=${state.dispatching}
@click=${(event: MouseEvent) => {
rememberWorkboardReturnFocus(event.currentTarget);
openEditModal(state, card);
props.onRequestUpdate?.();
}}
>
${icons.edit}
</button>
</openclaw-tooltip>
`
: nothing;
writable && !archived ? renderEditCardAction(props, card, { iconOnly: true }) : nothing;
const topArchiveAction = writable
? html`
<openclaw-tooltip
.content=${archived ? t("workboard.unarchiveCard") : t("workboard.archiveCard")}
>
<button
class="btn btn--icon workboard-card__icon"
type="button"
aria-label=${archived ? t("workboard.unarchiveCard") : t("workboard.archiveCard")}
?disabled=${busy}
@click=${() =>
archiveWorkboardCard({
host: props.host,
client: props.client,
cardId: card.id,
archived: !archived,
requestUpdate: props.onRequestUpdate,
})}
>
${archived ? icons.archiveRestore : icons.archive}
</button>
</openclaw-tooltip>
`
? renderArchiveCardAction(props, card, busy, archived, { iconOnly: true })
: nothing;
const detailAction = html`
<openclaw-tooltip .content=${t("workboard.viewDetails")}>
@ -2302,61 +2423,14 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
</button>
</openclaw-tooltip>
`;
const sessionAction = linkedSessionKey
? html`
<openclaw-tooltip .content=${t("workboard.openSession")}>
<button
class="btn btn--icon workboard-card__icon"
aria-label=${t("workboard.openSession")}
@click=${() => props.onOpenSession(linkedSessionKey)}
>
${icons.messageSquare}
</button>
</openclaw-tooltip>
`
: nothing;
const sessionAction = renderOpenSessionCardAction(props, linkedSessionKey, { iconOnly: true });
const stopAction =
writable && (linkedSessionKey ? live : activeTask)
? html`
<openclaw-tooltip .content=${t("workboard.stopSession")}>
<button
class="btn btn--icon workboard-card__icon"
aria-label=${t("workboard.stopSession")}
?disabled=${busy || !props.connected}
@click=${() =>
stopWorkboardCard({
host: props.host,
client: props.client,
card,
requestUpdate: props.onRequestUpdate,
})}
>
${icons.stop}
</button>
</openclaw-tooltip>
`
? renderStopCardAction(props, card, busy, { iconOnly: true })
: nothing;
const moveAction = writable ? renderCardMoveControl(props, card, busy) : nothing;
const deleteAction = writable
? html`
<openclaw-tooltip .content=${t("workboard.deleteCard")}>
<button
class="btn btn--icon workboard-card__icon workboard-card__delete"
type="button"
aria-label=${t("workboard.deleteCard")}
?disabled=${busy}
@click=${() =>
deleteWorkboardCard({
host: props.host,
client: props.client,
cardId: card.id,
requestUpdate: props.onRequestUpdate,
})}
>
${icons.trash}
</button>
</openclaw-tooltip>
`
? renderDeleteCardAction(props, card, busy, { iconOnly: true })
: nothing;
return html`
<article

View file

@ -423,6 +423,13 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
await details.getByText("Acceptance: mocked Gateway browser proof").waitFor({
state: "visible",
});
await details.locator(".workboard-card__move-select").waitFor({ state: "visible" });
expect(await details.getByRole("button", { name: "Open session" }).count()).toBe(1);
expect(await details.getByRole("button", { name: "Edit card" }).count()).toBe(1);
expect(await details.getByRole("button", { name: "Archive card" }).count()).toBe(1);
expect(await details.getByRole("button", { name: "Delete card" }).count()).toBe(1);
expect(await details.getByRole("button", { name: "Stop session" }).count()).toBe(0);
await captureScreenshot(writable.page, artifacts, "05-detail-actions");
await details.locator('button[aria-label="Cancel"]').click();
await writableGateway.deferNext("workboard.cards.move");
@ -443,7 +450,7 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
await cardInColumn(writable.page, "Running", editedCard.title).waitFor({
state: "visible",
});
await captureScreenshot(writable.page, artifacts, "05-moved-running");
await captureScreenshot(writable.page, artifacts, "06-moved-running");
await writableGateway.deferNext("workboard.cards.update");
const syncBefore = (await writableGateway.getRequests("workboard.cards.update")).length;
@ -473,7 +480,7 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
await writable.page.locator(".workboard-detail").getByText("Moved to Review").waitFor({
state: "visible",
});
await captureScreenshot(writable.page, artifacts, "06-lifecycle-review");
await captureScreenshot(writable.page, artifacts, "07-lifecycle-review");
await details.locator('button[aria-label="Cancel"]').click();
await details.waitFor({ state: "hidden" });
@ -492,7 +499,7 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
await writable.page.getByText("Acceptance: mocked Gateway browser proof").waitFor({
state: "visible",
});
await captureScreenshot(writable.page, artifacts, "07-reloaded-review");
await captureScreenshot(writable.page, artifacts, "08-reloaded-review");
} finally {
await closeRecordedPage(writable, artifacts, "workboard-writable");
}
@ -515,7 +522,7 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
await cardInColumn(readOnly.page, "Running", editedCard.title).waitFor({
state: "visible",
});
await captureScreenshot(readOnly.page, artifacts, "08-read-only-board");
await captureScreenshot(readOnly.page, artifacts, "09-read-only-board");
expect(await readOnly.page.getByRole("button", { name: /New card/u }).count()).toBe(0);
expect(await readOnly.page.locator('button[aria-label="Edit card"]').count()).toBe(0);
expect(await readOnly.page.locator('button[aria-label="Delete card"]').count()).toBe(0);
@ -528,6 +535,11 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
await readOnly.page.locator(".workboard-detail").getByText(editedCard.title).waitFor({
state: "visible",
});
const readOnlyDetail = readOnly.page.locator(".workboard-detail");
expect(await readOnlyDetail.locator(".workboard-card__move-select").count()).toBe(0);
expect(await readOnlyDetail.getByRole("button", { name: "Edit card" }).count()).toBe(0);
expect(await readOnlyDetail.getByRole("button", { name: "Archive card" }).count()).toBe(0);
expect(await readOnlyDetail.getByRole("button", { name: "Delete card" }).count()).toBe(0);
expect(await readOnly.page.locator(".workboard-detail__note").count()).toBe(0);
expect(await readOnly.page.getByRole("button", { name: /Add note/u }).count()).toBe(0);
expect(await readOnlyGateway.getRequests("workboard.cards.update")).toHaveLength(0);

View file

@ -1014,6 +1014,10 @@
color: var(--muted);
}
.workboard-card__move--wide {
width: 100%;
}
.workboard-card__move-icon {
position: absolute;
left: 7px;
@ -1075,6 +1079,11 @@
opacity: 0.58;
}
.workboard-card__move--wide .workboard-card__move-select {
max-width: none;
color: var(--text);
}
.workboard-card__quick-actions .workboard-card__icon,
.workboard-card__quick-actions .workboard-card__start--icon {
width: 26px;
@ -1493,6 +1502,7 @@
.workboard-detail__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: auto;
padding-top: 12px;
@ -1501,6 +1511,13 @@
.workboard-detail__actions > .btn {
justify-content: center;
min-width: 0;
white-space: normal;
}
.workboard-detail__actions > .workboard-card__move,
.workboard-detail__actions > .workboard-card__execution-controls {
grid-column: 1 / -1;
}
@media (max-width: 860px) {