feat(workflows): add responsive library card actions (#6008)

**Category:** improvement
**User Impact:** Users can scan what each workflow does and trigger,
edit, duplicate, enable, disable, or delete it directly from the
library.
**Problem:** The workflow list buried common actions and did not expose
each automation's trigger-to-action shape at a glance.
**Solution:** Add a responsive workflow library with a persistent create
tile, compact trigger/action diagrams, prominent workflow titles with
supporting descriptions, and shared card actions while preserving
existing detail, editor, and run-history entry points. Card toggles
refresh both list and open-detail caches so status and definition stay
consistent.

<details>
<summary>File changes</summary>

**desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx**
Adds a shared card menu for trigger, edit, duplicate, enable/disable,
and delete actions.

**desktop/src/features/workflows/ui/WorkflowCard.tsx**
Reworks cards around the prototype's visual hierarchy: color-coded
trigger, action flow, sentence-case eyebrow, prominent title, supporting
description, status, channel, and update date without a footer clock
icon.

**desktop/src/features/workflows/ui/WorkflowsView.tsx**
Adds the responsive grid, create tile, mutation wiring, and list/detail
cache invalidation. Container breakpoints keep cards two-across at
medium widths and three-across in the 1280px desktop layout.

**desktop/src/features/workflows/ui/workflowDefinition.ts**
Adds immutable enabled-state updates plus narrow trigger and
first-action readers used only to select card icons.

**desktop/src/features/workflows/ui/workflowDefinition.test.mjs**
Covers neutral icon selection, enabled-state immutability, and status
presentation.

**desktop/tests/e2e/workflows.spec.ts**
Covers the create tile, title/description hierarchy, selected-card
enable/disable consistency, and deterministic narrow/medium/wide
captures while retaining existing action coverage.

</details>

## Reproduction steps

1. Open **Workflows** and confirm the create tile stays first as cards
flow from one to three columns with available width.
2. Confirm each card shows a sentence-case trigger eyebrow, prominent
workflow title, supporting description when present, status, channel,
and update date without a clock icon.
3. Open a card's overflow menu and trigger, edit, duplicate,
enable/disable, or delete the workflow.
4. Leave the detail panel open while toggling and confirm its badge and
JSON definition update with the card.

## Screenshots

Real built E2E UI with representative workflow data at three viewport
sizes.

### Narrow — 800 × 720

![Workflow library at 800 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-narrow-482d1b4c8.png)

### Medium — 1024 × 720

![Workflow library at 1024 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-medium-482d1b4c8.png)

### Wide — 1280 × 720

![Workflow library at 1280 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-wide-482d1b4c8.png)

### Card actions

![Workflow library actions at 1280 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-wide-actions-482d1b4c8.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-17 09:56:34 -07:00
committed by GitHub
parent f716eef437
commit edc4a09aaa
19 changed files with 1038 additions and 238 deletions

View File

@@ -62,13 +62,13 @@ async function createWorkflow(
).not.toBeVisible();
}
test("navigates to workflows view and shows empty state", async ({ page }) => {
test("navigates to workflows view and shows the empty create tile", async ({
page,
}) => {
await navigateToWorkflows(page);
await expect(page.getByText("No workflows yet")).toBeVisible();
await expect(
page.getByRole("button", { name: "Create your first workflow" }),
).toBeVisible();
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
});
test("creates a workflow via the form builder", async ({ page }) => {
@@ -99,6 +99,49 @@ test("disables autocapitalization in the workflow form", async ({ page }) => {
);
});
test("captures workflow library across responsive viewports", async ({
page,
}) => {
await navigateToWorkflows(page);
await createWorkflow(page, "Notify reviewers when source files change", {
description: "Watches diff events for src/ changes",
enabled: false,
trigger: "diff_posted",
});
await createWorkflow(page, "Post the daily standup reminder to the team", {
description: "Keeps the team aligned every morning",
trigger: "schedule",
});
await createWorkflow(
page,
"Request approval before deploying to production",
{
description: "Requires a final review before release",
trigger: "reaction_added",
},
);
for (const viewport of [
{ width: 800, height: 720, name: "narrow" },
{ width: 1024, height: 720, name: "medium" },
{ width: 1280, height: 720, name: "wide" },
]) {
await page.setViewportSize(viewport);
await page.screenshot({
animations: "disabled",
path: `test-results/workflow-library-${viewport.name}.png`,
});
}
await page.setViewportSize({ width: 1280, height: 720 });
const firstCard = page.locator('[data-testid^="workflow-card-"]').first();
await firstCard.getByRole("button", { name: "Workflow actions" }).click();
await page.screenshot({
animations: "disabled",
path: "test-results/workflow-library-wide-actions.png",
});
});
test("captures disabled diff workflows in the list UI", async ({ page }) => {
const workflowName = `diff_workflow_${Date.now()}`;
const description = "Watches diff events for src/ changes";
@@ -117,12 +160,145 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => {
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
await expect(card).toContainText(workflowName);
await expect(card).toContainText(description);
await expect(card).toContainText("Diff Posted");
await expect(card.getByText("Diff Posted", { exact: true })).toBeVisible();
await expect(card.locator("h3")).toHaveText(workflowName);
await expect(card.getByText(description, { exact: true })).toBeVisible();
await expect(card).toContainText("disabled");
});
test("enables and disables a workflow from its card menu", async ({ page }) => {
const workflowName = `toggle_workflow_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
const workflowCard = () =>
page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
const workflowActions = () =>
workflowCard().getByRole("button", { name: "Workflow actions" });
const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" });
await page.getByRole("button", { name: `View ${workflowName}` }).click();
const detailPanel = page.getByTestId("workflow-detail-panel");
await expect(detailPanel).toBeVisible();
await expect(detailPanel.getByText("active", { exact: true })).toBeVisible();
await workflowActions().click();
await expect(enableItem).toHaveAttribute("aria-checked", "true");
await expect(enableItem.locator("button")).toHaveCount(0);
await expect(
enableItem.getByTestId("workflow-enabled-switch-visual"),
).toHaveAttribute("aria-hidden", "true");
await enableItem.click();
await expect(
workflowCard().getByText("disabled", { exact: true }),
).toBeVisible();
await expect(
detailPanel.getByText("disabled", { exact: true }),
).toBeVisible();
await enableItem.click();
await expect(
workflowCard().getByText("active", { exact: true }),
).toBeVisible();
await expect(detailPanel.getByText("active", { exact: true })).toBeVisible();
});
test("rejects a stale card toggle without overwriting a newer edit", async ({
page,
}) => {
const workflowName = `stale_toggle_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
const workflowCard = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
await page.evaluate(async (name) => {
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) throw new Error("mock command bridge unavailable");
const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((call) => call.command === "create_workflow");
const channelId = (
createCall?.payload as { channelId?: string } | undefined
)?.channelId;
if (!channelId) throw new Error("create workflow channel unavailable");
const workflows = (await invoke("get_channels_workflows", {
channelIds: [channelId],
})) as Array<{
id: string;
revision: string;
definition: Record<string, unknown>;
}>;
const workflow = workflows.find(
(candidate) => candidate.definition.name === name,
);
if (!workflow) throw new Error("created workflow unavailable");
await invoke("update_workflow", {
workflowId: workflow.id,
expectedRevision: workflow.revision,
yamlDefinition: `name: ${name} edited elsewhere\nenabled: true\ntrigger:\n on: message_posted\nsteps:\n - id: step_1\n action: post_message\n`,
});
}, workflowName);
await workflowCard.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitemcheckbox", { name: "Enable" }).click();
await expect(
page
.locator("[data-sonner-toast][data-removed='false']")
.filter({ hasText: "workflow changed since it was loaded" }),
).toBeVisible();
const authoritativeName = await page.evaluate(async () => {
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) throw new Error("mock command bridge unavailable");
const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((call) => call.command === "create_workflow");
const channelId = (
createCall?.payload as { channelId?: string } | undefined
)?.channelId;
if (!channelId) throw new Error("create workflow channel unavailable");
const workflows = (await invoke("get_channels_workflows", {
channelIds: [channelId],
})) as Array<{ name: string }>;
return workflows[0]?.name;
});
expect(authoritativeName).toBe(`${workflowName} edited elsewhere`);
});
test("reports a rejected workflow status change", async ({ page }) => {
const workflowName = `rejected_toggle_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
await page.evaluate(() => {
window.__BUZZ_E2E__ ??= {};
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.workflowUpdateError = "relay refused the update";
});
const workflowCard = page
.locator('[data-testid^="workflow-card-"]')
.filter({ hasText: workflowName })
.first();
await workflowCard.getByRole("button", { name: "Workflow actions" }).click();
await page.getByRole("menuitemcheckbox", { name: "Enable" }).click();
const errorToast = page
.locator("[data-sonner-toast][data-removed='false']")
.filter({ hasText: "Couldn’t change workflow status" });
await expect(errorToast).toContainText("relay refused the update");
await expect(workflowCard.getByText("active", { exact: true })).toBeVisible();
});
test("shows the webhook secret dialog after saving a webhook workflow", async ({
page,
}) => {
@@ -215,8 +391,9 @@ test("deletes a workflow with confirmation", async ({ page }) => {
await page.getByRole("button", { name: "Delete" }).click();
await expect(page.getByRole("alertdialog")).not.toBeVisible();
// Verify workflow is gone — back to empty state
await expect(page.getByText("No workflows yet")).toBeVisible();
// Verify workflow is gone — back to the empty create tile.
await expect(page.getByTestId("new-workflow-card")).toBeVisible();
await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0);
});
test("triggers a workflow from the detail panel", async ({ page }) => {

View File

@@ -192,6 +192,7 @@ type MockBridgeOptions = {
acpAuthMethods?: Record<string, { methods: Record<string, unknown>[] }>;
acpAuthMethodsError?: string;
/** When set, the `delete_custom_harness` mock command throws with this message. */
workflowUpdateError?: string;
deleteCustomHarnessError?: string;
connectAcpRuntimeResult?: { launched: boolean };
connectAcpRuntimeDelayMs?: number;