From 891e872c7949aa452db9e6b86277e4d2d32d5c4e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 06:43:29 -0700 Subject: [PATCH 1/2] Add tests for the global Routines page and removed per-workbench chrome Covers CL-6362: GlobalRoutinesList rendering (workbench attribution, state chips, schedule, inline enable/disable, Run now, inline-expandable run detail), membership-based aggregation across every bench a signed-in account belongs to (not just the selected one, never creator-scoped), the Routines rail row, and removal of the per-workbench header buttons, the `/run` slash command, and the canvas panel's list/runs views. --- apps/web/test/routes.test.tsx | 1 + apps/web/test/routine-panel.test.tsx | 341 +------- apps/web/test/routines-page.test.tsx | 751 +++++++++--------- apps/web/test/sidebar.test.tsx | 17 +- packages/chat-ui/src/slash-commands.test.ts | 12 +- packages/chat-ui/test/chat-workspace.test.tsx | 112 +-- packages/chat-ui/test/components.test.tsx | 1 - packages/chat-ui/test/composer.test.tsx | 2 - .../src/workflow-routine-routes.test.ts | 4 +- packages/routines/test/routes.test.ts | 4 +- 10 files changed, 428 insertions(+), 817 deletions(-) diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index 8c6f4dc18..511601635 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -113,6 +113,7 @@ function activeFooterLabel(markup: string): string | undefined { } const FOOTER_LABELS: Record = { + "/routines": "Routines", "/files": "Files", "/skills": "Skills", "/agents": "Agents", diff --git a/apps/web/test/routine-panel.test.tsx b/apps/web/test/routine-panel.test.tsx index e3fa4f720..4467a9b6c 100644 --- a/apps/web/test/routine-panel.test.tsx +++ b/apps/web/test/routine-panel.test.tsx @@ -1,13 +1,13 @@ -// The routine panel (CL-6125, reworked CL-6139): a list view (this -// workbench's routines, a "New routine" row, name · cadence · Active -// toggle) and an editor view (create/edit one routine), navigated inline -// in the canvas column — the back chevron goes list→close, editor→list, -// never a route hop. Every write autosaves and is serialized through one -// queue (`saveState` shows "Saving…"/"Saved"/an honest error). A routine -// created from the panel always targets the conversation it was opened -// beside — that workbench's own host agent and its own id as the delivery -// destination — or, with no workbench in scope, this workbench's existing -// Myra workbench; never a newly minted one. +// The routine panel (CL-6125, reworked CL-6139, trimmed to editor-only by +// CL-6362): create/edit one routine, inline in the canvas column — the +// back chevron closes the canvas, never a route hop. Browsing/running +// existing routines lives on the global `/routines` page now. Every write +// autosaves and is serialized through one queue (`saveState` shows +// "Saving…"/"Saved"/an honest error). A routine created from the panel +// always targets the conversation it was opened beside — that workbench's +// own host agent and its own id as the delivery destination — or, with no +// workbench in scope, this workbench's existing Myra workbench; never a +// newly minted one. import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { act } from "react"; @@ -359,332 +359,29 @@ describe("RoutinePanel", () => { } describe("shared canvas-pane chrome (CL-6200)", () => { - test("the list, runs, and editor views all render through the shared CanvasPaneHeader, not a hand-rolled one", async () => { - await renderPanel({ view: "list" }); - let header = container.querySelector(".shell-canvas-pane-header"); - expect(header).not.toBeNull(); - expect( - header?.querySelector(".shell-canvas-pane-title")?.textContent, - ).toBe("Routines"); - expect(header?.querySelector('[aria-label="Back"]')).not.toBeNull(); - - await renderPanel({ view: "runs" }); - header = container.querySelector(".shell-canvas-pane-header"); - expect(header).not.toBeNull(); - expect( - header?.querySelector(".shell-canvas-pane-title")?.textContent, - ).toBe("Runs"); - + test("the editor view renders through the shared CanvasPaneHeader, not a hand-rolled one", async () => { await renderPanel({ routineId: null, workbenchId: "ch_1" }); - header = container.querySelector(".shell-canvas-pane-header"); + const header = container.querySelector(".shell-canvas-pane-header"); expect(header).not.toBeNull(); expect( header?.querySelector(".shell-canvas-pane-title")?.textContent, ).toBe("Routine"); - }); - }); - - describe("list view", () => { - test("back chevron on the list view closes the canvas", async () => { - await renderPanel({ view: "list" }); - const back = container.querySelector('[aria-label="Back"]'); - act(() => (back as HTMLButtonElement).click()); - expect(closed).toBe(true); - }); - - test("lists the workbench's routines with a New routine row above them", async () => { - routines = [ - routineRecord({ - id: "rtn_a", - name: "Morning digest", - trigger: { kind: "daily", hour: 9, minute: 0 }, - }), - routineRecord({ id: "rtn_b", name: "Weekly report", enabled: true }), - ]; - await renderPanel({ view: "list" }); - - expect(buttonWithText("New routine")).toBeDefined(); - expect(container.textContent).toContain("Morning digest"); - expect(container.textContent).toContain("Weekly report"); - expect(container.textContent).toContain("Daily 09:00"); - }); - - test("opened beside a workbench, the list shows only routines delivering there", async () => { - routines = [ - routineRecord({ - id: "rtn_here", - name: "Here digest", - deliveryWorkbenchId: "ch_1", - }), - routineRecord({ - id: "rtn_elsewhere", - name: "Elsewhere digest", - deliveryWorkbenchId: "ch_other", - }), - routineRecord({ id: "rtn_unbound", name: "Unbound digest" }), - ]; - await renderPanel({ view: "list", workbenchId: "ch_1" }); - - expect(container.textContent).toContain("Here digest"); - expect(container.textContent).not.toContain("Elsewhere digest"); - expect(container.textContent).not.toContain("Unbound digest"); - }); - - test("selecting a row opens that routine's editor via openRoutine", async () => { - routines = [routineRecord({ id: "rtn_a", name: "Morning digest" })]; - await renderPanel({ view: "list" }); - - const row = [...container.querySelectorAll("button")].find((b) => - b.textContent?.includes("Morning digest"), - ); - act(() => row?.click()); - - expect(openedSubjects).toContainEqual({ routineId: "rtn_a" }); - }); - - test("New routine opens the editor with a null routineId, carrying the workbench through", async () => { - await renderPanel({ view: "list", workbenchId: "ch_1" }); - act(() => buttonWithText("New routine")?.click()); - expect(openedSubjects).toContainEqual({ - routineId: null, - workbenchId: "ch_1", - }); - }); - - test("a routine with no run history shows Idle", async () => { - routines = [routineRecord({ id: "rtn_a", name: "Morning digest" })]; - await renderPanel({ view: "list" }); - await settle(); - expect(container.textContent).toContain("Idle"); - }); - - test("a routine whose latest run succeeded shows Last run OK", async () => { - routines = [routineRecord({ id: "rtn_a", name: "Morning digest" })]; - runsByRoutineId["rtn_a"] = [ - { - runId: "run_a", - triggeredBy: "schedule", - createdAt: new Date(Date.now() - 120_000).toISOString(), - run: { status: "completed" }, - }, - ]; - await renderPanel({ view: "list" }); - await settle(); - expect(container.textContent).toContain("Last run OK"); - }); - - test("a routine whose latest run failed shows Last run failed", async () => { - routines = [routineRecord({ id: "rtn_a", name: "Morning digest" })]; - runsByRoutineId["rtn_a"] = [ - { - runId: "run_a", - triggeredBy: "schedule", - createdAt: new Date().toISOString(), - error: "sidecar unreachable", - run: { status: "failed" }, - }, - ]; - await renderPanel({ view: "list" }); - await settle(); - expect(container.textContent).toContain("Last run failed"); - }); - - test("Run now flips the row to Running now immediately, then renders an inline outcome once the run completes", async () => { - networkDelayMs = 20; - routines = [routineRecord({ id: "rtn_a", name: "Morning digest" })]; - runsByRoutineId["rtn_a"] = []; - await renderPanel({ view: "list" }); - - const runButton = buttonWithText("Run now"); - expect(runButton).toBeDefined(); - act(() => { - runButton?.click(); - }); - // The run "completes" between the click and the panel's poll — - // the poll (not the click) is what has to notice. - runsByRoutineId["rtn_a"] = [ - { - runId: "run_a", - triggeredBy: "manual", - createdAt: new Date().toISOString(), - run: { status: "completed", reply: "All done — 3 items summarized." }, - }, - ]; - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 5)); - }); - expect(container.textContent).toContain("Running now"); - - await settle(); - expect(container.textContent).toContain("All done — 3 items summarized."); - expect(buttonWithText("Open trace →")).toBeDefined(); - }); - - test("a failed run's inline outcome shows the error, styled distinctly from a successful one", async () => { - networkDelayMs = 10; - routines = [routineRecord({ id: "rtn_a", name: "Morning digest" })]; - runsByRoutineId["rtn_a"] = []; - await renderPanel({ view: "list" }); - act(() => { - buttonWithText("Run now")?.click(); - }); - runsByRoutineId["rtn_a"] = [ - { - runId: "run_a", - triggeredBy: "manual", - createdAt: new Date().toISOString(), - error: "sidecar unreachable", - run: { status: "failed" }, - }, - ]; - await settle(); - - expect(container.textContent).toContain("sidecar unreachable"); - const errorSpan = [...container.querySelectorAll("span")].find( - (el) => el.textContent === "sidecar unreachable", - ); - expect(errorSpan?.className).toContain("danger"); - }); - - test("Tasks section lists this workbench's in-flight and recent tasks with the same state chips", async () => { - tasks = [ - { - id: "tsk_1", - definitionId: "def_1", - workbenchId: "ch_1", - agentName: "Myra", - prompt: "Summarize the week", - modelPreference: null, - status: "running", - runId: "run_1", - runIds: ["run_1"], - stepCount: 1, - resultMailId: null, - createdAt: new Date().toISOString(), - completedAt: null, - }, - { - id: "tsk_2", - definitionId: "def_1", - workbenchId: "ch_1", - agentName: "Myra", - prompt: "Draft the memo", - modelPreference: null, - status: "failed", - runId: "run_2", - runIds: ["run_2"], - stepCount: 1, - resultMailId: null, - createdAt: new Date().toISOString(), - completedAt: new Date().toISOString(), - }, - ]; - await renderPanel({ view: "list" }); - await settle(); - - expect(container.textContent).toContain("Tasks"); - expect(container.textContent).toContain("Running now"); - expect(container.textContent).toContain("Last run failed"); - expect(container.textContent).toContain("Failed."); - }); - - test("Tasks empty state says exactly how to verify", async () => { - await renderPanel({ view: "list" }); - await settle(); - expect(container.textContent).toContain("Run one now to see it here."); - }); - }); - - describe("runs view", () => { - function runRecord( - overrides: Partial> = {}, - ): Record { - return { - id: "run_a", - definitionId: "wfd_1", - workbenchId: "ch_1", - definitionName: "Myra", - tenantId: "tnt_1", - address: "myra_1@wf_1.tnt_1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - routineId: null, - routineName: null, - ...overrides, - }; - } - - test("Runs button on the list view opens the runs view", async () => { - await renderPanel({ view: "list" }); - act(() => buttonWithText("Runs")?.click()); - expect(openedSubjects).toContainEqual({ view: "runs" }); - }); - - test("empty state says No runs yet.", async () => { - await renderPanel({ view: "runs" }); - await settle(); - expect(container.textContent).toContain("No runs yet."); - }); - - test("lists runs and never navigates away", async () => { - topLevelRuns = [runRecord({ id: "run_a", definitionName: "Myra" })]; - await renderPanel({ view: "runs" }); - await settle(); - expect(container.textContent).toContain("Myra"); - }); - - test("clicking a run row shows its trace inline, without navigating", async () => { - topLevelRuns = [runRecord({ id: "run_a", definitionName: "Myra" })]; - runTraces["run_a"] = { - runId: "run_a", - spans: [ - { - id: "sp_1", - label: "Plan", - kind: "tool", - start: 0, - end: 100, - durationMs: 100, - tokens: null, - phase: "ok", - error: null, - timingSource: "measured", - }, - ], - }; - await renderPanel({ view: "runs" }); - await settle(); - - const row = [...container.querySelectorAll("button")].find((b) => - b.textContent?.includes("Myra"), - ); - act(() => row?.click()); - await settle(); - - expect(container.textContent).toContain("Run trace"); - expect(container.textContent).toContain("Plan"); - expect(closed).toBe(false); + expect(header?.querySelector('[aria-label="Back"]')).not.toBeNull(); }); - test("back chevron on the runs view returns to the list", async () => { - await renderPanel({ view: "runs" }); - const back = container.querySelector('[aria-label="Back"]'); - act(() => (back as HTMLButtonElement).click()); - expect(openedSubjects).toContainEqual({ view: "list" }); + test("renders nothing when opened with no subject (CL-6362: the panel is editor-only, never a list to fall back to)", async () => { + await renderPanel(null); + expect(container.querySelector(".shell-canvas-pane-header")).toBeNull(); + expect(container.querySelector(".shell-routine-pane")).toBeNull(); }); }); describe("editor view", () => { - test("back chevron returns to the list, not close — carrying the workbench through", async () => { + test("back chevron closes the canvas (CL-6362: no list view to step back to)", async () => { await renderPanel({ routineId: null, workbenchId: "ch_1" }); const back = container.querySelector('[aria-label="Back"]'); act(() => (back as HTMLButtonElement).click()); - expect(closed).toBe(false); - expect(openedSubjects).toContainEqual({ - view: "list", - workbenchId: "ch_1", - }); + expect(closed).toBe(true); }); test("creating a routine targets the panel's own workbench: its host agent, and delivers back into it", async () => { diff --git a/apps/web/test/routines-page.test.tsx b/apps/web/test/routines-page.test.tsx index e01263cb2..407599095 100644 --- a/apps/web/test/routines-page.test.tsx +++ b/apps/web/test/routines-page.test.tsx @@ -1,6 +1,10 @@ -// Screen-level proof for the Routines page's pure components: real -// (possibly empty) `APIQuery` props in, honest markup out — no live fetch. -// List rows live in shell col2; this page owns create + detail only. +// Screen-level proof for the global Routines page (CL-6362): every +// routine across every workbench the account belongs to, as rows — +// workbench attribution, running-or-not state, schedule, inline +// enable/disable, Run now, and an inline-expandable detail with recent +// runs. `GlobalRoutinesList` is pure (real props in, honest markup out); +// `RoutinesRoute` (aggregation across bench memberships, never +// creator-scoped) gets its own fetch-mocked integration coverage below. import { describe, expect, test } from "bun:test"; import { act, createElement } from "react"; @@ -8,62 +12,23 @@ import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; -import type { APIQuery } from "@corbits/api-query"; import { - RoutineDetailPage, - RoutinesListPage, + GlobalRoutinesList, + routineStateChip, + scheduleSummary, } from "../src/pages/routines-page"; +import type { GlobalRoutineRow } from "../src/pages/routines-page"; import type { Routine, RoutineRun } from "../src/routines-api"; -import { - CanvasAvailabilityProvider, - type RoutinePanelSubject, -} from "../src/shell/canvas-availability"; -import type { WebhookTrigger } from "../src/webhook-triggers-api"; const noop = () => undefined; -/** Stands in for `ShellChromeProvider`'s position above these pages — just - * enough of the canvas host context for `useOpenRoutineInCanvas` to resolve - * to a real, capturing callback instead of the no-op default. */ -function CanvasCapture({ - onOpenRoutine, - children, -}: { - readonly onOpenRoutine: (subject: RoutinePanelSubject) => void; - readonly children: import("react").ReactNode; -}) { - return ( - - {children} - - ); -} - -function ready(data: T): APIQuery { - return { kind: "ready", data }; -} - const routine: Routine = { id: "rtn_1", name: "Morning brief", definitionId: "wfd_1", trigger: { kind: "daily", hour: 9, minute: 0 }, scope: "bench", - input: { - draftedSteps: [{ title: "Pull signups", detail: "CSV from warehouse" }], - }, + input: {}, enabled: true, deliveryWorkbenchId: "ch_1", consecutiveFailures: 0, @@ -72,368 +37,235 @@ const routine: Routine = { updatedAt: "2026-01-01T00:00:00.000Z", }; -const researcherDefinition = { - id: "wfd_1", - assetName: "researcher", - deliveryMode: "workbench" as const, - name: "Researcher", - status: "deployed", - whatItDoes: "Pulls research from connected sources.", - requiredConnections: [] as const, - exampleOutput: "Research summary, three sources cited.", - typicalDuration: "a few minutes", - triggerFields: [] as const, -}; +function row(overrides: Partial = {}): GlobalRoutineRow { + return { + routine, + tenantId: "tnt_1", + tenantName: "Acme Team", + deliveryWorkbenchName: "Ops", + runs: [], + ...overrides, + }; +} const listProps = { - runHistories: new Map(), - liveRuns: ready([]), - definitions: [] as const, - workbenches: [] as const, - selectedId: null as string | null, - onSelect: (_id: string | null) => {}, - webhookTrigger: null, - onRotateWebhookSecret: () => Promise.resolve({ secret: "rotated-secret" }), - onToggleEnabled: () => {}, - onRunNow: () => Promise.resolve(), - onOpenRuns: () => {}, + now: Date.parse("2026-01-01T12:00:00.000Z"), + expandedId: null as string | null, + onToggleExpanded: noop, + onToggleEnabled: (_row: GlobalRoutineRow, _enabled: boolean) => {}, + onRunNow: (_row: GlobalRoutineRow) => Promise.resolve(), + onEdit: (_row: GlobalRoutineRow) => {}, onOpenWorkbench: (_workbenchId: string) => {}, }; -describe("RoutinesListPage", () => { - test("says there are no routines yet when the list is empty", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("No routines yet"); - expect(markup).toContain("Create one from a workflow or a prompt."); - expect(markup).toContain("New routine"); - }); - - test("prompts to select when routines exist but none is open", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Select a routine"); - // List rows live in col2 — the stage must not re-render the master list. - expect(markup).not.toContain("Morning brief"); - expect(markup).not.toContain("rtn_1"); +describe("routineStateChip", () => { + test("Off for a disabled routine, regardless of run history", () => { + expect( + routineStateChip(row({ routine: { ...routine, enabled: false } })), + ).toEqual({ label: "Off", tone: "neutral" }); }); - test("selected routine shows steps and recent runs", () => { - const runHistories = new Map([ - [ - routine.id, - [ - { - runId: "run_1", - triggeredBy: "schedule", - createdAt: "2026-01-01T00:00:00.000Z", - run: { status: "completed" }, - }, - ], - ], - ]); - const markup = renderToStaticMarkup( - { + expect( + routineStateChip( + row({ + routine: { + ...routine, + deadLetteredAt: "2026-01-02T00:00:00.000Z", }, - ]} - />, - ); - expect(markup).toContain("Morning brief"); - expect(markup).toContain("Daily at 09:00 UTC, delivers to Ops."); - expect(markup).toContain("Pull signups"); - expect(markup).toContain("Recent runs"); - expect(markup).toContain("completed"); - expect(markup).not.toContain("rtn_1"); - }); - - test("a dead-lettered selected routine shows a plain-language paused banner with the real error", () => { - const deadLettered: Routine = { - ...routine, - consecutiveFailures: 5, - deadLetteredAt: "2026-01-02T00:00:00.000Z", - }; - const runHistories = new Map([ - [ - routine.id, - [ - { - runId: "run_fail_1", - triggeredBy: "schedule-failed", - createdAt: "2026-01-02T00:00:00.000Z", - error: "sidecar unreachable", - }, - ], - ], - ]); - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Paused after 5 failed attempts"); - expect(markup).toContain("sidecar unreachable"); - }); - - test("shows an Edit action and an insights link instead of a local toggle", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Edit"); - expect(markup).toContain("All runs & traces →"); - expect(markup).not.toContain("Show three"); - }); - - test("a recent-run row deep-links to the routine's delivery workbench", () => { - const runHistories = new Map([ - [ - routine.id, - [ - { - runId: "run_1", - triggeredBy: "schedule", - createdAt: "2026-01-01T00:00:00.000Z", - run: { status: "completed" }, - }, - ], - ], - ]); - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("routine-run-row-linked"); - expect(markup).toContain('role="link"'); + }), + ), + ).toEqual({ label: "Paused", tone: "danger" }); }); -}); -describe("RoutineDetailPage", () => { - test("shows the routine's name, cadence, and empty run history", () => { - const markup = renderToStaticMarkup( - ([])} - onBack={() => {}} - onOpenRuns={() => {}} - onOpenWorkbench={(_workbenchId: string) => {}} - />, - ); - expect(markup).toContain("Morning brief"); - expect(markup).toContain("Daily at 09:00 UTC"); - expect(markup).toContain("No runs yet"); - expect(markup).toContain("Pull signups"); + test("Idle for an enabled routine with no run history", () => { + expect(routineStateChip(row())).toEqual({ label: "Idle", tone: "neutral" }); }); - test("renders run history with a resolved status", () => { + test("Running now while the latest run is in flight", () => { const run: RoutineRun = { runId: "run_1", - triggeredBy: "manual", + triggeredBy: "schedule", createdAt: "2026-01-01T00:00:00.000Z", - run: { status: "completed" }, + run: { status: "running" }, }; - const markup = renderToStaticMarkup( - ([run])} - onBack={() => {}} - onOpenRuns={() => {}} - onOpenWorkbench={(_workbenchId: string) => {}} - />, - ); - expect(markup).toContain("manual"); - expect(markup).toContain("completed"); + expect(routineStateChip(row({ runs: [run] }))).toEqual({ + label: "Running now", + tone: "success", + }); }); - test("a dead-lettered routine shows a plain-language paused state and the real error text", () => { - const deadLettered: Routine = { - ...routine, - consecutiveFailures: 5, - deadLetteredAt: "2026-01-02T00:00:00.000Z", - }; - const failedRun: RoutineRun = { - runId: "run_fail_1", + test("Last run failed when the latest run errored", () => { + const run: RoutineRun = { + runId: "run_1", triggeredBy: "schedule-failed", - createdAt: "2026-01-02T00:00:00.000Z", - error: 'no definition "wfd_deleted" for this tenant', + createdAt: "2026-01-01T00:00:00.000Z", + error: "sidecar unreachable", }; - const markup = renderToStaticMarkup( - ([failedRun])} - onBack={() => {}} - onOpenRuns={() => {}} - onOpenWorkbench={(_workbenchId: string) => {}} - />, - ); - expect(markup).toContain("Paused after 5 failed attempts"); - expect(markup).toContain( - "no definition "wfd_deleted" for this tenant", + expect(routineStateChip(row({ runs: [run] }))).toEqual({ + label: "Last run failed", + tone: "danger", + }); + }); +}); + +describe("scheduleSummary", () => { + test("humanizes the cadence and appends a relative next-run", () => { + const summary = scheduleSummary( + row(), + Date.parse("2026-01-01T00:00:00.000Z"), ); - expect(markup).toContain("Failed to start"); + expect(summary).toContain("Daily at 09:00 UTC"); + expect(summary).toContain("next"); + expect(summary).not.toMatch(/\d+ \d+ \* \* \*/); }); - test("a healthy routine shows no paused banner", () => { - const markup = renderToStaticMarkup( - ([])} - onBack={() => {}} - onOpenRuns={() => {}} - onOpenWorkbench={(_workbenchId: string) => {}} - />, + test("no next-run suffix for a manual routine", () => { + const summary = scheduleSummary( + row({ routine: { ...routine, trigger: null } }), + Date.now(), ); - expect(markup).not.toContain("Paused after"); + expect(summary).toBe("Manual"); }); }); -const webhookRoutine: Routine = { - ...routine, - id: "rtn_webhook", - name: "Support digest", - trigger: { kind: "webhook", webhookTriggerId: "wht_1" }, -}; - -const webhookTriggerFixture: WebhookTrigger = { - id: "wht_1", - tenantId: "tnt_1", - name: "Support digest", - workflowDefinitionId: "wfd_1", - inputTemplate: "New webhook delivery.", - enabled: true, - createdBy: "usr_1", - createdAt: "2026-01-01T00:00:00.000Z", - lastFiredAt: null, -}; - -describe("webhook trigger panel", () => { - test("RoutinesListPage detail shows the hook URL and a masked-secret note for a webhook routine", () => { +describe("GlobalRoutinesList", () => { + test("says there are no routines yet when the list is empty", () => { const markup = renderToStaticMarkup( - , + , ); - expect(markup).toContain("/api/webhooks/wht_1"); - expect(markup).toContain("Rotate secret"); - expect(markup).toContain("Hidden"); + expect(markup).toContain("No routines yet"); }); - test("RoutinesListPage detail omits the webhook section for a scheduled routine", () => { + test("renders a row with its name and workbench attribution", () => { const markup = renderToStaticMarkup( - , + , ); - expect(markup).not.toContain("Rotate secret"); + expect(markup).toContain("Morning brief"); + expect(markup).toContain("Acme Team"); + expect(markup).toContain("Ops"); + expect(markup).toContain("Daily at 09:00 UTC"); }); - test("RoutineDetailPage shows the hook URL for a webhook routine", () => { + test("a routine with no delivery workbench shows a dash, not a broken link", () => { const markup = renderToStaticMarkup( - ([])} - webhookTrigger={ready(webhookTriggerFixture)} - onRotateWebhookSecret={() => - Promise.resolve({ secret: "rotated-secret" }) - } - onBack={() => {}} - onOpenRuns={() => {}} - onOpenWorkbench={(_workbenchId: string) => {}} + , ); - expect(markup).toContain("/api/webhooks/wht_1"); - expect(markup).toContain("Rotate secret"); + expect(markup).toContain("—"); }); - test("clicking Rotate secret reveals the newly rotated secret", async () => { - let rotateCalls = 0; + test("Run now calls onRunNow with the row", async () => { + const calls: GlobalRoutineRow[] = []; const container = document.createElement("div"); document.body.appendChild(container); const root: Root = createRoot(container); act(() => { root.render( - createElement(RoutinesListPage, { + createElement(GlobalRoutinesList, { + rows: [row()], ...listProps, - routines: ready([webhookRoutine]), - selectedId: webhookRoutine.id, - webhookTrigger: ready(webhookTriggerFixture), - definitions: [researcherDefinition], - onRotateWebhookSecret: () => { - rotateCalls += 1; - return Promise.resolve({ secret: "freshly-rotated" }); + onRunNow: (r: GlobalRoutineRow) => { + calls.push(r); + return Promise.resolve(); }, }), ); }); try { - const rotateButton = [...container.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Rotate secret"), + const runButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Run now", ); - expect(rotateButton).not.toBeUndefined(); - await act(async () => { - rotateButton?.click(); - await new Promise((resolve) => setTimeout(resolve, 10)); + expect(runButton).not.toBeUndefined(); + act(() => { + runButton?.click(); + }); + expect(calls).toHaveLength(1); + expect(calls[0]?.routine.id).toBe("rtn_1"); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + + test("the Enabled switch calls onToggleEnabled with the flipped value", () => { + const calls: [GlobalRoutineRow, boolean][] = []; + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render( + createElement(GlobalRoutinesList, { + rows: [row()], + ...listProps, + onToggleEnabled: (r: GlobalRoutineRow, enabled: boolean) => { + calls.push([r, enabled]); + }, + }), + ); + }); + try { + const toggle = container.querySelector('button[role="switch"]'); + expect(toggle).not.toBeNull(); + act(() => { + (toggle as HTMLButtonElement).click(); }); - expect(rotateCalls).toBe(1); - expect(container.textContent).toContain("freshly-rotated"); + expect(calls).toHaveLength(1); + expect(calls[0]?.[1]).toBe(false); } finally { act(() => root.unmount()); container.remove(); } }); - test("Edit opens the routine panel scoped to this routine, not a dialog (CL-6125)", async () => { + test("clicking the delivery workbench opens it", () => { + const opened: string[] = []; const container = document.createElement("div"); document.body.appendChild(container); const root: Root = createRoot(container); - const opened: RoutinePanelSubject[] = []; act(() => { root.render( - opened.push(subject)}> - {createElement(RoutinesListPage, { - ...listProps, - routines: ready([routine]), - selectedId: routine.id, - definitions: [researcherDefinition], - })} - , + createElement(GlobalRoutinesList, { + rows: [row()], + ...listProps, + onOpenWorkbench: (workbenchId: string) => opened.push(workbenchId), + }), + ); + }); + try { + const link = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Ops", + ); + expect(link).not.toBeUndefined(); + act(() => { + link?.click(); + }); + expect(opened).toEqual(["ch_1"]); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + + test("Edit calls onEdit with the row", () => { + const edited: GlobalRoutineRow[] = []; + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render( + createElement(GlobalRoutinesList, { + rows: [row()], + ...listProps, + onEdit: (r: GlobalRoutineRow) => edited.push(r), + }), ); }); try { @@ -444,56 +276,225 @@ describe("webhook trigger panel", () => { act(() => { editButton?.click(); }); - expect(opened).toEqual([{ routineId: routine.id }]); - expect(document.body.querySelector('[data-slot="dialog-content"]')).toBe( - null, - ); + expect(edited).toHaveLength(1); + expect(edited[0]?.routine.id).toBe("rtn_1"); } finally { act(() => root.unmount()); container.remove(); } }); - test("the routine detail's 'Delivers to' line links to its space", () => { - let openedWorkbenchId: string | null = null; + test("expanding a row shows recent runs and the delivery note inline, without navigating", () => { + const run: RoutineRun = { + runId: "run_1", + triggeredBy: "schedule", + createdAt: "2026-01-01T00:00:00.000Z", + run: { status: "completed" }, + }; + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("Run updates post into Ops"); + expect(markup).toContain("completed"); + }); + + test("a collapsed row shows no run detail", () => { + const run: RoutineRun = { + runId: "run_1", + triggeredBy: "schedule", + createdAt: "2026-01-01T00:00:00.000Z", + run: { status: "completed" }, + }; + const markup = renderToStaticMarkup( + , + ); + expect(markup).not.toContain("Run updates post into"); + }); + + test("expand toggling calls onToggleExpanded with the routine id", () => { + const toggled: string[] = []; const container = document.createElement("div"); document.body.appendChild(container); const root: Root = createRoot(container); act(() => { root.render( - createElement(RoutineDetailPage, { - routine: ready(routine), - runs: ready([]), - workbenches: [ - { - id: "ch_1", - title: "Ops", - kind: "workbench" as const, - pinned: false, - participants: [], - }, - ], - onBack: () => {}, - onOpenRuns: () => {}, - onOpenWorkbench: (workbenchId: string) => { - openedWorkbenchId = workbenchId; - }, + createElement(GlobalRoutinesList, { + rows: [row()], + ...listProps, + onToggleExpanded: (id: string) => toggled.push(id), }), ); }); try { - expect(container.textContent).toContain("Delivers to"); - const link = [...container.querySelectorAll("button")].find( - (button) => button.textContent?.trim() === "Ops", - ); - expect(link).not.toBeUndefined(); + const expandButton = container.querySelector("button[aria-expanded]"); + expect(expandButton).not.toBeNull(); act(() => { - link?.click(); + (expandButton as HTMLButtonElement).click(); }); - expect(openedWorkbenchId as string | null).toBe("ch_1"); + expect(toggled).toEqual(["rtn_1"]); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); +}); + +describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { + function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + test("lists routines from every bench the account is a member of, not just the currently selected one, and never creator-scoped", async () => { + const { BenchProvider } = await import("../src/bench-context"); + const { NavigationProvider } = await import("../src/navigation"); + const { CanvasAvailabilityProvider } = + await import("../src/shell/canvas-availability"); + const { RoutinesRoute } = await import("../src/pages/routines-page"); + const { TestQueryProvider } = await import("./test-query-provider"); + + const realFetch = globalThis.fetch; + // Two benches this account belongs to — GET /routines is already + // tenant-scoped, never filtered by who created a row, so a second + // member's routine (created by a different principal) shows up here + // exactly like the viewer's own. + const memberships = [ + { + principalId: "prn_me", + tenantId: "tnt_1", + tenantName: "Acme Team", + tenantSlug: "acme", + kind: "user", + status: "active", + roles: [], + }, + { + principalId: "prn_me_2", + tenantId: "tnt_2", + tenantName: "Beta Team", + tenantSlug: "beta", + kind: "user", + status: "active", + roles: [], + }, + ]; + const routinesByTenant: Record[]> = { + tnt_1: [ + { + id: "rtn_mine", + name: "My digest", + definitionId: "wfd_1", + trigger: null, + scope: "bench", + input: {}, + enabled: true, + deliveryWorkbenchId: null, + consecutiveFailures: 0, + deadLetteredAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + tnt_2: [ + { + id: "rtn_theirs", + name: "Their digest", + definitionId: "wfd_2", + trigger: null, + scope: "bench", + input: {}, + enabled: true, + deliveryWorkbenchId: null, + consecutiveFailures: 0, + deadLetteredAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }; + + globalThis.fetch = (async ( + input: RequestInfo | URL, + _init?: RequestInit, + ): Promise => { + const url = String(input); + if (url.includes("/api/me/principals")) { + return jsonResponse({ data: memberships, nextCursor: null }); + } + if (url.includes("/api/workbench-tenancies/kinds")) { + return jsonResponse({ workbenchTenantIds: [] }); + } + const routinesMatch = url.match(/\/api\/tenants\/([^/]+)\/routines$/); + if (routinesMatch) { + return jsonResponse({ + items: routinesByTenant[routinesMatch[1] as string] ?? [], + }); + } + if (url.includes("/routines/") && url.endsWith("/runs")) { + return jsonResponse({ items: [], nextCursor: null }); + } + if (url.includes("/chat/workbenches") && url.includes("kind=workbench")) { + return jsonResponse({ items: [] }); + } + return Promise.reject(new Error(`unrouted fetch: ${url}`)); + }) as typeof fetch; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + try { + await act(async () => { + root.render( + + {}}> + + {}} + openArtifact={() => {}} + openRoutine={() => {}} + toggleFocus={() => {}} + close={() => {}} + > + {createElement(RoutinesRoute, { + path: "/routines", + navigate: () => {}, + })} + + + + , + ); + }); + for (let i = 0; i < 8; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + + // Bench switcher defaults to the first bench (tnt_1) — proving the + // second bench's routine still renders proves this page never + // narrows to just the selected tenant. + expect(container.textContent).toContain("My digest"); + expect(container.textContent).toContain("Their digest"); + expect(container.textContent).toContain("Acme Team"); + expect(container.textContent).toContain("Beta Team"); } finally { act(() => root.unmount()); container.remove(); + globalThis.fetch = realFetch; + window.localStorage.clear(); } }); }); diff --git a/apps/web/test/sidebar.test.tsx b/apps/web/test/sidebar.test.tsx index cad2cf5ed..ebf8fb2bd 100644 --- a/apps/web/test/sidebar.test.tsx +++ b/apps/web/test/sidebar.test.tsx @@ -120,9 +120,10 @@ describe("Sidebar", () => { expect(markup).not.toContain("shell-rail-item"); }); - test("footer is Files, Skills, Agents, Plugins, Insights, then the account row — no Inbox", () => { + test("footer is Routines, Files, Skills, Agents, Plugins, Insights, then the account row — no Inbox", () => { const markup = renderSidebar("/w"); expect(markup).toContain("shell-sidebar-footer-row"); + expect(markup).toContain(">Routines<"); expect(markup).toContain(">Files<"); expect(markup).toContain(">Skills<"); expect(markup).toContain(">Agents<"); @@ -133,6 +134,20 @@ describe("Sidebar", () => { expect(markup).not.toContain('aria-label="Notifications"'); // Settings stays in the account menu, not a standalone footer icon. expect(markup).not.toContain('aria-label="Settings"'); + // Routines is first — CL-6362 gives it the same top-level rail slot + // as every other global surface. + expect(markup.indexOf(">Routines<")).toBeLessThan( + markup.indexOf(">Files<"), + ); + }); + + test("marks the Routines row current for its own route only", () => { + const onRoutines = renderSidebar("/routines"); + expect(onRoutines).toMatch( + /shell-sidebar-footer-row"[^>]*data-active="true"[^>]*>[\s\S]*?>RoutinesRoutines<[\s\S]{0,80}aria-current="page"/); }); test("marks the Plugins row current for its own route only", () => { diff --git a/packages/chat-ui/src/slash-commands.test.ts b/packages/chat-ui/src/slash-commands.test.ts index 9964a093c..bdb6a5f2d 100644 --- a/packages/chat-ui/src/slash-commands.test.ts +++ b/packages/chat-ui/src/slash-commands.test.ts @@ -46,18 +46,12 @@ describe("filterSlashCommands", () => { expect(filterSlashCommands("zzz")).toEqual([]); }); - test("the catalog omits /thread, /status, and /pin — no real action behind them today", () => { + test("the catalog omits /thread, /status, /pin, and /run — no real action behind them today (routines browse/run moved to the global Routines page, CL-6362)", () => { const ids = SLASH_COMMANDS.map((c) => c.id); expect(ids).not.toContain("thread"); expect(ids).not.toContain("status"); expect(ids).not.toContain("pin"); - expect(ids).toEqual([ - "invite", - "summarize", - "run", - "routine", - "agents", - "help", - ]); + expect(ids).not.toContain("run"); + expect(ids).toEqual(["invite", "summarize", "routine", "agents", "help"]); }); }); diff --git a/packages/chat-ui/test/chat-workspace.test.tsx b/packages/chat-ui/test/chat-workspace.test.tsx index 12e6271d2..aa7b830cb 100644 --- a/packages/chat-ui/test/chat-workspace.test.tsx +++ b/packages/chat-ui/test/chat-workspace.test.tsx @@ -96,7 +96,6 @@ function stubFetch( } const { ChatWorkspace } = await import("../src/chat-workspace"); -const { CHAT_STRINGS } = await import("../src/strings"); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -654,27 +653,6 @@ describe("composer slash commands — each wired command's real action", () => { harness.unmount(); }); - test("/run calls the host's routine create/run hop", async () => { - stubFetch(); - let opened = 0; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - onOpenRoutines: () => { - opened += 1; - }, - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "/run"); - pressEnter(textarea); - await harness.settle(); - - expect(opened).toBe(1); - expect(textarea.value).toBe(""); - harness.unmount(); - }); - test("/routine opens the New Routine panel pre-bound to the active workbench", async () => { stubFetch(); const opened: string[] = []; @@ -696,7 +674,7 @@ describe("composer slash commands — each wired command's real action", () => { harness.unmount(); }); - test("/routine with no host-supplied hop wired falls back to the same unavailable toast /run uses", async () => { + test("/routine with no host-supplied hop wired falls back to an unavailable toast", async () => { stubFetch(); const harness = await mount({ tenant: { kind: "ready", tenantId: "tnt_1" }, @@ -712,35 +690,7 @@ describe("composer slash commands — each wired command's real action", () => { harness.unmount(); }); - test("the header's Routines button calls onOpenRoutines, not the per-space create hop", async () => { - stubFetch(); - let opened = 0; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - onOpenRoutines: () => { - opened += 1; - }, - onCreateRoutineInSpace: () => { - throw new Error("the header button must not call this hop"); - }, - }); - await harness.settle(); - - const button = harness.container.querySelector( - `[aria-label="${CHAT_STRINGS.routinesAction}"]`, - ); - expect(button).not.toBeNull(); - act(() => { - (button as HTMLButtonElement).click(); - }); - await harness.settle(); - - expect(opened).toBe(1); - harness.unmount(); - }); - - test("the header's Routines button is hidden when onOpenRoutines is not wired", async () => { + test("there is no per-workbench header Routines button (CL-6362: Routines is global-only, reached from the shell rail)", async () => { stubFetch(); const harness = await mount({ tenant: { kind: "ready", tenantId: "tnt_1" }, @@ -748,10 +698,12 @@ describe("composer slash commands — each wired command's real action", () => { }); await harness.settle(); - const button = harness.container.querySelector( - `[aria-label="${CHAT_STRINGS.routinesAction}"]`, + const byLabel = harness.container.querySelector('[aria-label="Routines"]'); + const byText = [...harness.container.querySelectorAll("button")].find( + (element) => element.textContent?.trim() === "Routines", ); - expect(button).toBeNull(); + expect(byLabel).toBeNull(); + expect(byText).toBeUndefined(); harness.unmount(); }); @@ -770,32 +722,7 @@ describe("composer slash commands — each wired command's real action", () => { harness.unmount(); }); - test("'Insights' header button calls the host's onOpenInsights hop", async () => { - stubFetch(); - let opened = 0; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - onOpenInsights: () => { - opened += 1; - }, - }); - await harness.settle(); - - const button = [...harness.container.querySelectorAll("button")].find( - (element) => element.textContent?.trim() === "Insights", - ); - expect(button).not.toBeUndefined(); - act(() => { - button?.click(); - }); - await harness.settle(); - - expect(opened).toBe(1); - harness.unmount(); - }); - - test("the 'Insights' header button is hidden when the host has not wired the hop", async () => { + test("there is no per-workbench header Insights button (CL-6362: Insights is global-only, reached from the shell rail)", async () => { stubFetch(); const harness = await mount({ tenant: { kind: "ready", tenantId: "tnt_1" }, @@ -884,9 +811,9 @@ describe("composer slash commands — each wired command's real action", () => { expect(popoverText).not.toContain("/thread"); expect(popoverText).not.toContain("/status"); expect(popoverText).not.toContain("/pin"); + expect(popoverText).not.toContain("/run"); expect(popoverText).toContain("/invite"); expect(popoverText).toContain("/summarize"); - expect(popoverText).toContain("/run"); expect(popoverText).toContain("/agents"); expect(popoverText).toContain("/help"); harness.unmount(); @@ -1572,27 +1499,6 @@ describe("Workbench header polish (CL-6106)", () => { harness.unmount(); }); - test("the Routines and Insights header buttons render as quiet ghost buttons, not outlined controls", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - onOpenRoutines: () => {}, - onOpenInsights: () => {}, - }); - await harness.settle(); - - const routines = harness.container.querySelector( - `[aria-label="${CHAT_STRINGS.routinesAction}"]`, - ); - const insights = [...harness.container.querySelectorAll("button")].find( - (element) => element.textContent?.trim() === "Insights", - ); - expect(routines?.className).not.toContain("border-input"); - expect(insights?.className).not.toContain("border-input"); - harness.unmount(); - }); - test("the settings control is icon-only, at the far right, with a tooltip", async () => { stubFetch(); const harness = await mount({ diff --git a/packages/chat-ui/test/components.test.tsx b/packages/chat-ui/test/components.test.tsx index fb6d39379..1ab0c7e6a 100644 --- a/packages/chat-ui/test/components.test.tsx +++ b/packages/chat-ui/test/components.test.tsx @@ -26,7 +26,6 @@ const RAW_ID_PATTERN = /\b(prn_|ins_|tnt_)[a-z0-9]/i; const composerSlashHandlers = { onInviteAgent: () => {}, onOpenAgentsSettings: () => {}, - onOpenRoutines: () => {}, onCreateRoutineInSpace: () => {}, }; diff --git a/packages/chat-ui/test/composer.test.tsx b/packages/chat-ui/test/composer.test.tsx index 9e887229d..57ba344cd 100644 --- a/packages/chat-ui/test/composer.test.tsx +++ b/packages/chat-ui/test/composer.test.tsx @@ -41,7 +41,6 @@ function mount(onSend: () => Promise) { onSend, onInviteAgent: () => undefined, onOpenAgentsSettings: () => undefined, - onOpenRoutines: () => undefined, onCreateRoutineInSpace: () => undefined, }), ); @@ -147,7 +146,6 @@ function mountWithMentions( onSend, onInviteAgent: () => undefined, onOpenAgentsSettings: () => undefined, - onOpenRoutines: () => undefined, onCreateRoutineInSpace: () => undefined, }), ); diff --git a/packages/routines/src/workflow-routine-routes.test.ts b/packages/routines/src/workflow-routine-routes.test.ts index 4cce513ea..925a004b9 100644 --- a/packages/routines/src/workflow-routine-routes.test.ts +++ b/packages/routines/src/workflow-routine-routes.test.ts @@ -400,7 +400,7 @@ test("POST /routines posts an honest notice when created enabled", async () => { ); expect(workbenchNotice.calls[0]?.text).toBe( 'Created routine "Morning digest" — runs Daily at 09:00 UTC. ' + - "Disable it in the Routines panel.", + "Manage it from Routines.", ); }); @@ -431,7 +431,7 @@ test("PATCH /routines/:id posts an honest notice when flipped to enabled", async expect(workbenchNotice.calls.length).toBe(1); expect(workbenchNotice.calls[0]?.text).toBe( 'Enabled routine "Morning digest" — runs Daily at 09:00 UTC. ' + - "Disable it in the Routines panel.", + "Manage it from Routines.", ); }); diff --git a/packages/routines/test/routes.test.ts b/packages/routines/test/routes.test.ts index c81d2c534..4609548d9 100644 --- a/packages/routines/test/routes.test.ts +++ b/packages/routines/test/routes.test.ts @@ -420,7 +420,7 @@ describe("createRoutineRoutes", () => { ); expect(workbenchNotice.calls[0]?.text).toBe( 'Created routine "Morning digest" — runs Daily at 09:00 UTC. ' + - "Disable it in the Routines panel.", + "Manage it from Routines.", ); }); @@ -450,7 +450,7 @@ describe("createRoutineRoutes", () => { expect(workbenchNotice.calls.length).toBe(1); expect(workbenchNotice.calls[0]?.text).toBe( 'Enabled routine "Morning digest" — runs Daily at 09:00 UTC. ' + - "Disable it in the Routines panel.", + "Manage it from Routines.", ); }); From 47f88d8fa12722a9643ecc0bf0ae6593efe733d3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 06:43:44 -0700 Subject: [PATCH 2/2] CL-6362: Routines becomes a global-only surface Routines moves from per-workbench chrome to a global page in the shell rail, listing every routine across every workbench the account is a member of (membership-resolved, never just the selected one or creator-scoped) as rows: name, workbench attribution, running-or-not state, humanized schedule + next run, inline enable/disable, Run now, and an inline-expandable detail with recent runs and its delivery workbench. Data stays workbench-scoped (`GET /routines` is unchanged, tenant-scoped) - this is UI consolidation only, aggregated client-side across bench memberships the cheapest correct way. Removed, not hidden: the workbench header's Routines and Insights buttons, the composer's `/run` command, and the canvas routine panel's list/runs views (`RoutineListPanel`, `RunsCanvasPanel`) - the panel is editor-only now (create/edit), reached from the composer's `/routine` command, "New routine in this space", and the global page's own actions. In-room routine cards (created-routine notices, run-now approval cards) are untouched - they stay "visible where it was made". The routine-created/enabled in-room notice's copy no longer points to the retired "Routines panel". --- apps/web/src/pages/chat-page.tsx | 25 +- apps/web/src/pages/routines-page.tsx | 1187 +++++++------------- apps/web/src/shell/canvas-availability.tsx | 14 +- apps/web/src/shell/routine-panel.tsx | 675 +---------- apps/web/src/shell/sidebar.tsx | 20 +- packages/chat-ui/src/chat-workspace.tsx | 77 +- packages/chat-ui/src/composer.tsx | 6 - packages/chat-ui/src/slash-commands.ts | 3 +- packages/chat-ui/src/strings.ts | 4 +- packages/routines/src/routes.ts | 6 +- 10 files changed, 476 insertions(+), 1541 deletions(-) diff --git a/apps/web/src/pages/chat-page.tsx b/apps/web/src/pages/chat-page.tsx index 96c2c710c..be29881d7 100644 --- a/apps/web/src/pages/chat-page.tsx +++ b/apps/web/src/pages/chat-page.tsx @@ -31,7 +31,6 @@ import { isWorkbenchSettingsPath, } from "../workbench-path"; import { reportWorkbenchNotFound } from "../workbench-not-found-event"; -import { workbenchInsightsPath } from "../insights-deeplinks"; import { ONBOARDING_PATH } from "../routes"; import { useProviderHealthBanner, @@ -227,27 +226,11 @@ export function ChatPage({ {...(blockResponses !== undefined ? { blockResponses } : {})} {...(connectGithubActions !== undefined ? { connectGithubActions } : {})} listMembers={listMembers} - // The header's Routines affordance and `/run`: the panel's default - // list view, beside this conversation — never a `/routines` hop - // (CL-6139). Bound to this workbench so the list's own "New routine" - // row still targets this conversation's agent/workbench. - onOpenRoutines={() => - openRoutine({ - view: "list", - ...(workbenchId !== null ? { workbenchId } : {}), - }) - } - // The header's Insights affordance: this conversation's own scoped - // timeline, never the global landing. Passes the workbench id as-is — - // the route itself resolves the workbench's workbench tenant (see - // `insights-workbench-scope.ts`), since a workbench id is never a - // tenant id. - onOpenInsights={() => { - if (workbenchId === null) return; - navigate(workbenchInsightsPath(workbenchId)); - }} // `/routine`: opens the editor directly on a brand-new routine - // bound to this workbench. + // bound to this workbench. Routines and Insights (CL-6362, CL-6099) + // are global-only pages now, reached from the shell rail — no + // per-workbench header button or `/run` command opens a scoped view + // of either here. onCreateRoutineInSpace={(inSpaceWorkbenchId) => openRoutine({ routineId: null, workbenchId: inSpaceWorkbenchId }) } diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index ed6d846f7..553451e68 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -1,9 +1,17 @@ -// Routines: named automations over workflow runs. -// Layout matches the shell mock — col2 search + simple list (name, when, -// ON/OFF); detail is calm (steps, three recent runs, All runs & traces). -// Creating and editing a routine happens in the canvas column's routine -// panel now (CL-6125, see shell/routine-panel.tsx) — this page only lists -// and links to it via `useOpenRoutineInCanvas`. +// Routines: one global list, every automation across every workbench the +// signed-in account is a member of (CL-6362). Per-workbench routines +// chrome (the header's Routines button, the `/run` composer command, and +// the canvas pane's list/runs views) is gone — this page is the only +// place to browse and run routines now; a routine's own workbench still +// shows it "where it was made" via in-room notices and run-now approval +// cards, which this page never touches. +// +// Visibility resolves through the same membership the sidebar's bench +// switcher uses (`useBench().memberships`, the `/api/me/principals` / +// CL-6332 principal model), filtered to actual benches with +// `classifyBenchMembership` — never just the currently selected one, and +// never creator-scoped: `GET /routines` already lists every routine a +// bench's own grant covers, regardless of who created it. import { Badge, Button, @@ -21,53 +29,32 @@ import { toast, } from "@corbits/react-ui"; import type { BadgeTone } from "@corbits/react-ui"; -import type { Workbench } from "@corbits/chat-ui"; import { listWorkbenches } from "@corbits/chat-ui"; -import { CopyButton, WebhookSecretPanel } from "@corbits/settings-ui"; -import { Clock, Plus, RotateCw } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { + classifyBenchMembership, + listWorkbenchTenantIds, +} from "@corbits/bench-ui"; +import { ChevronDown, ChevronRight, Clock } from "lucide-react"; +import { Fragment, useMemo, useState } from "react"; import type { KeyboardEvent } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import type { APIQuery } from "@corbits/api-query"; -import { QueryView } from "@corbits/api-query"; -import { useAPIQuery, RunsSchema } from "../api"; -import type { WorkflowRun } from "../api"; +import type { Principal } from "../api"; import { useBench } from "../bench-context"; import { workbenchPath } from "../workbench-path"; -import { tenantKeys } from "../query-client"; -import { cadenceLabel } from "../routine-trigger"; +import { meKeys, tenantKeys } from "../query-client"; +import { cadenceLabel, approximateNextRun } from "../routine-trigger"; import { useOpenRoutineInCanvas } from "../shell/canvas-availability"; -import { StageCrumbs, StageTopBar } from "../shell/stage-top-bar"; +import { StageTopBar } from "../shell/stage-top-bar"; import { listRoutineRuns, listRoutines, - listWorkflowDefinitions, routineRunStartedToast, runRoutineNow, updateRoutine, - useTenantQuery, -} from "../routines-api"; -import type { - Routine, - RoutineRun, - WorkflowDefinitionSummary, } from "../routines-api"; -import { - getWebhookTrigger, - rotateWebhookTriggerSecret, - sampleWebhookPayload, - webhookTriggerUrl, -} from "../webhook-triggers-api"; -import type { WebhookTrigger } from "../webhook-triggers-api"; - -const ROUTINES_PATH_PREFIX = "/routines"; - -function routineIdFromPath(path: string): string | null { - if (!path.startsWith(`${ROUTINES_PATH_PREFIX}/`)) return null; - const rest = path.slice(ROUTINES_PATH_PREFIX.length + 1); - return rest === "" ? null : decodeURIComponent(rest); -} +import type { Routine, RoutineRun } from "../routines-api"; const RUN_STATUS_TONE: Record = { running: "success", @@ -76,169 +63,14 @@ const RUN_STATUS_TONE: Record = { cancelled: "neutral", }; -/** One calm sentence under the routine name; deliver-to only when known. */ -function routineDetailSentence( - routine: Routine, - workbenches: readonly Workbench[], -): string { - const when = cadenceLabel(routine.trigger); - const workbench = workbenches.find( - (c) => c.id === routine.deliveryWorkbenchId, - ); - if (workbench !== undefined) { - return `${when}, delivers to ${workbench.title}.`; - } - return `${when}.`; -} - -/** Plain-language state for a routine the scheduler has stopped firing — - * `consecutiveFailures` at the moment it dead-lettered equals the - * threshold, so it's an honest count, not a guess. `null` for a - * healthy routine (never rendered). */ -function routinePausedMessage(routine: Routine): string | null { - if (routine.deadLetteredAt === null) return null; - return `Paused after ${routine.consecutiveFailures} failed attempt${ - routine.consecutiveFailures === 1 ? "" : "s" - }.`; -} - -/** The most recent recorded failure's own error text, for the honest - * "why" next to `routinePausedMessage`'s "that". `undefined` runs - * (still loading) and runs with no `error` are skipped. */ -function mostRecentRunError(runs: readonly RoutineRun[]): string | null { - const failed = runs.find( - (run) => run.error !== undefined && run.error !== null, - ); - return failed?.error ?? null; -} - -function draftedStepsFromInput( - input: Record, -): readonly { title: string; detail?: string }[] { - const raw = input["draftedSteps"]; - if (!Array.isArray(raw)) return []; - const steps: { title: string; detail?: string }[] = []; - for (const item of raw) { - if (item === null || typeof item !== "object") continue; - const record = item as Record; - if (typeof record["title"] !== "string") continue; - const step: { title: string; detail?: string } = { - title: record["title"], - }; - if (typeof record["detail"] === "string") step.detail = record["detail"]; - steps.push(step); - } - return steps; -} - -/** - * The routine detail view's webhook section: hook URL (built from the - * trigger id, matching `POST /api/webhooks/:triggerId`), status, and a - * "Rotate secret" action. Secret text only ever appears here right after - * a rotate — `GET .../webhook-triggers/:id` never returns it, so between - * rotates the panel shows the URL and payload sample with the secret row - * masked, exactly like a freshly-loaded page that has never seen it. - */ -export function WebhookTriggerPanel({ - webhookTrigger, - onRotate, -}: { - readonly webhookTrigger: APIQuery; - readonly onRotate: () => Promise<{ secret: string }>; -}) { - const [rotatedSecret, setRotatedSecret] = useState(null); - const [rotating, setRotating] = useState(false); - const [rotateError, setRotateError] = useState(null); - - const triggerId = - webhookTrigger.kind === "ready" ? webhookTrigger.data.id : null; - useEffect(() => { - setRotatedSecret(null); - setRotateError(null); - }, [triggerId]); - - return ( -
-
-

- Webhook -

- -
- {rotateError !== null ? ( -

- {rotateError} -

- ) : null} - {webhookTrigger.kind !== "ready" || triggerId === null ? ( -

- Loading webhook details… -

- ) : rotatedSecret !== null ? ( - - ) : ( -
-
- Hook URL -
- - {webhookTriggerUrl(triggerId)} - - -
-
-
- Signing secret -

- Hidden — shown only once, right after creation or a rotate. Rotate - to issue (and reveal) a new one; the old secret stops verifying - immediately. -

-
-
- Example payload -
-              {sampleWebhookPayload()}
-            
-
-
- )} -
- ); -} - /** * Recent-run rows deep-link to the workbench the routine delivers to — a - * routine has one `deliveryWorkbenchId`, not a per-run one, so every row in - * a given table shares the same destination. Rows render as plain data + * routine has one `deliveryWorkbenchId`, not a per-run one, so every row + * in a given table shares the same destination. Rows render as plain data * when there is nowhere to deep-link (`deliveryWorkbenchId` absent or no - * `onOpenWorkbench` handler wired). + * `onOpenWorkbench` handler wired). Exported: the canvas routine editor + * panel (`shell/routine-panel.tsx`) reuses this exact rendering for its + * own "Recent runs" section — one run table, never two drifting ones. */ export function RunsTable({ runs, @@ -327,395 +159,335 @@ export function RunsTable({ ); } -export function RoutinesListPage({ - routines, - runHistories, - liveRuns: _liveRuns, - now = Date.now(), - definitions, - workbenches, - selectedId, - onSelect: _onSelect, - webhookTrigger, - onRotateWebhookSecret, - onToggleEnabled, - onRunNow, - onOpenRuns, - onOpenWorkbench, -}: { - readonly routines: APIQuery; +/** Every bench the signed-in account belongs to — not just the currently + * selected one — the same classification the bench switcher uses so a + * workbench child tenancy or a raw-id row never masquerades as a bench a + * person can browse routines in. */ +function useMemberBenches(): { + readonly kind: "loading" | "ready"; + readonly benches: readonly { tenantId: string; tenantName: string }[]; +} { + const { memberships } = useBench(); + const allMemberships: readonly Principal[] = + memberships.kind === "ready" ? memberships.data.data : []; + const tenantIds = useMemo( + () => allMemberships.map((m) => m.tenantId), + [allMemberships], + ); + const workbenchTenancyKinds = useQuery({ + queryKey: meKeys.workbenchTenancyKinds(tenantIds), + queryFn: () => listWorkbenchTenantIds(tenantIds), + enabled: tenantIds.length > 0, + }); + const benches = useMemo( + () => + allMemberships + .filter( + (m) => + classifyBenchMembership( + m, + workbenchTenancyKinds.data ?? new Set(), + ) === "bench", + ) + .map((m) => ({ tenantId: m.tenantId, tenantName: m.tenantName })), + [allMemberships, workbenchTenancyKinds.data], + ); + if (memberships.kind !== "ready") return { kind: "loading", benches: [] }; + return { kind: "ready", benches }; +} + +export type GlobalRoutineRow = { + readonly routine: Routine; + readonly tenantId: string; + readonly tenantName: string; + readonly deliveryWorkbenchName: string | null; + readonly runs: readonly RoutineRun[]; +}; + +type BenchRoutinesData = { + readonly routines: readonly Routine[]; + readonly workbenchNames: ReadonlyMap; readonly runHistories: ReadonlyMap; - readonly liveRuns: APIQuery; - readonly now?: number; - readonly definitions: readonly WorkflowDefinitionSummary[]; - readonly workbenches: readonly Workbench[]; - readonly selectedId: string | null; - readonly onSelect: (routineId: string | null) => void; - readonly webhookTrigger: APIQuery | null; - readonly onRotateWebhookSecret: () => Promise<{ secret: string }>; - readonly onToggleEnabled: (routine: Routine, enabled: boolean) => void; - readonly onRunNow: (routine: Routine) => Promise; - readonly onOpenRuns: () => void; - readonly onOpenWorkbench: (workbenchId: string) => void; -}) { - const openRoutine = useOpenRoutineInCanvas(); +}; - const selected = - routines.kind === "ready" && selectedId !== null - ? (routines.data.find((r) => r.id === selectedId) ?? null) - : null; - const selectedRuns = - selectedId !== null ? (runHistories.get(selectedId) ?? []) : []; - const recentRuns = selectedRuns.slice(0, 3); - const steps = selected !== null ? draftedStepsFromInput(selected.input) : []; +async function fetchBenchRoutinesData( + tenantId: string, +): Promise { + const [routines, workbenches] = await Promise.all([ + listRoutines(tenantId), + listWorkbenches(tenantId, "workbench"), + ]); + const runHistoryEntries = await Promise.all( + routines.map( + async (r) => [r.id, await listRoutineRuns(tenantId, r.id)] as const, + ), + ); + return { + routines, + workbenchNames: new Map(workbenches.map((w) => [w.id, w.title])), + runHistories: new Map(runHistoryEntries), + }; +} - return ( -
- openRoutine({ routineId: null })}> - New routine - - ) : ( - <> - - - onToggleEnabled(selected, enabled) - } - /> - onRunNow(selected)} - /> - - - ) - } - /> - {selected !== null && routinePausedMessage(selected) !== null ? ( -
-

- {routinePausedMessage(selected)} -

- {mostRecentRunError(recentRuns) !== null ? ( -

- {mostRecentRunError(recentRuns)} -

- ) : null} -
- ) : null} +/** Every routine across every bench the account belongs to, flattened + * into one list with its own workbench attribution — the aggregation + * `GET /routines` doesn't do server-side (it's tenant-scoped, per bench), + * done the cheapest correct client-side way: one fetch per bench, run in + * parallel. */ +function useGlobalRoutines(): APIQuery { + const { kind: benchesKind, benches } = useMemberBenches(); + const results = useQueries({ + queries: benches.map((bench) => ({ + queryKey: [...tenantKeys.routines(bench.tenantId), "global-page"], + queryFn: () => fetchBenchRoutinesData(bench.tenantId), + })), + }); + + if (benchesKind === "loading") return { kind: "loading" }; + if (results.some((r) => r.isLoading)) return { kind: "loading" }; + const failed = results.find((r) => r.isError); + if (failed !== undefined) { + return { + kind: "error", + message: + failed.error instanceof Error + ? failed.error.message + : "Couldn't load routines.", + retry: () => { + for (const result of results) void result.refetch(); + }, + }; + } - {/* List lives in shell col2; stage is detail only. */} -
- {selected === null ? ( -
- {routines.kind === "ready" && routines.data.length === 0 ? ( - } - title="No routines yet" - description="Create one from a workflow or a prompt." - /> - ) : ( - } - title="Select a routine" - description="Pick a routine from the sidebar to see its steps and recent runs." - /> - )} -
- ) : ( -
-
-

- Steps -

- {steps.length === 0 ? ( -

- Runs workflow{" "} - - {definitions.find((d) => d.id === selected.definitionId) - ?.name ?? "selected agent"} - - . -

- ) : ( -
    - {steps.map((step, index) => ( -
  1. - {step.title} - {step.detail !== undefined ? ( - - {" — "} - {step.detail} - - ) : null} -
  2. - ))} -
- )} -
+ const rows: GlobalRoutineRow[] = []; + benches.forEach((bench, index) => { + const data = results[index]?.data; + if (data === undefined) return; + for (const routine of data.routines) { + rows.push({ + routine, + tenantId: bench.tenantId, + tenantName: bench.tenantName, + deliveryWorkbenchName: + routine.deliveryWorkbenchId !== null + ? (data.workbenchNames.get(routine.deliveryWorkbenchId) ?? null) + : null, + runs: data.runHistories.get(routine.id) ?? [], + }); + } + }); + return { kind: "ready", data: rows }; +} - {selected.trigger !== null && - selected.trigger.kind === "webhook" ? ( -
- -
- ) : null} +/** Idle/On/Off/Paused/Running/Failed — every row's own running-or-not + * state at a glance, never a separate detail hop to find out. */ +export function routineStateChip(row: GlobalRoutineRow): { + readonly label: string; + readonly tone: BadgeTone; +} { + if (!row.routine.enabled) return { label: "Off", tone: "neutral" }; + if (row.routine.deadLetteredAt !== null) { + return { label: "Paused", tone: "danger" }; + } + const latest = row.runs[0]; + if (latest === undefined) return { label: "Idle", tone: "neutral" }; + const status = latest.run?.status; + if (status === "running") return { label: "Running now", tone: "success" }; + if ( + (latest.error !== undefined && latest.error !== null) || + status === "failed" + ) { + return { label: "Last run failed", tone: "danger" }; + } + return { label: "On", tone: "success" }; +} -
-
-

- Recent runs -

- -
- -
-
- )} -
+/** "Daily at 09:00 UTC, next in 3 hours" — consumer language throughout, + * never a raw cron string. `approximateNextRun` and `cadenceLabel` are + * this codebase's one source for either half. */ +export function scheduleSummary(row: GlobalRoutineRow, now: number): string { + const label = cadenceLabel(row.routine.trigger); + const next = approximateNextRun(row.routine.trigger, new Date(now)); + if (next === null) return label; + return `${label} · next ${formatRelativeTime(next.toISOString(), now)}`; +} + +function RoutineRowDetail({ + row, + now, + onOpenWorkbench, +}: { + readonly row: GlobalRoutineRow; + readonly now: number; + readonly onOpenWorkbench: (workbenchId: string) => void; +}) { + return ( +
+ {row.deliveryWorkbenchName !== null ? ( +

+ Run updates post into {row.deliveryWorkbenchName}. +

+ ) : null} +
); } -export function RoutineDetailPage({ - routine, - runs, - onBack, - now = Date.now(), - definitions = [], - workbenches = [], - webhookTrigger = null, - onRotateWebhookSecret, - onOpenRuns, +export function GlobalRoutinesList({ + rows, + now, + expandedId, + onToggleExpanded, + onToggleEnabled, + onRunNow, + onEdit, onOpenWorkbench, }: { - readonly routine: APIQuery; - readonly runs: APIQuery; - readonly onBack: () => void; - readonly now?: number; - readonly definitions?: readonly WorkflowDefinitionSummary[]; - readonly workbenches?: readonly Workbench[]; - readonly webhookTrigger?: APIQuery | null; - readonly onRotateWebhookSecret?: () => Promise<{ secret: string }>; - readonly onOpenRuns: () => void; + readonly rows: readonly GlobalRoutineRow[]; + readonly now: number; + readonly expandedId: string | null; + readonly onToggleExpanded: (routineId: string) => void; + readonly onToggleEnabled: (row: GlobalRoutineRow, enabled: boolean) => void; + readonly onRunNow: (row: GlobalRoutineRow) => Promise; + readonly onEdit: (row: GlobalRoutineRow) => void; readonly onOpenWorkbench: (workbenchId: string) => void; }) { - const openRoutine = useOpenRoutineInCanvas(); - const deliveryWorkbenchId = - routine.kind === "ready" ? routine.data.deliveryWorkbenchId : null; - return ( -
- - } - actions={ - routine.kind === "ready" ? ( - - ) : null - } + if (rows.length === 0) { + return ( + } + title="No routines yet" + description="Create one from a workflow or a prompt, in any workbench." /> -
- - {(data) => { - const steps = draftedStepsFromInput(data.input); - return ( -
-
-
Cadence
-
{cadenceLabel(data.trigger)}
-
Status
-
- - {data.enabled ? "On" : "Off"} - -
- {data.deliveryWorkbenchId !== null ? ( - <> -
Delivers to
-
- -
- - ) : null} -
- {routinePausedMessage(data) !== null ? ( -
+ + + Routine + Delivers to + Schedule + Status + Enabled + Actions + + + + {rows.map((row) => { + const chip = routineStateChip(row); + const expanded = expandedId === row.routine.id; + return ( + + + +
- ) : null} -
-

- Steps -

- {steps.length === 0 ? ( -

- Runs workflow{" "} - {definitions.find((d) => d.id === data.definitionId) - ?.name ?? "selected agent"} - . -

+ {expanded ? ( + + ) : ( + + )} + + + {row.routine.name} + + + {row.tenantName} + + + + + + {row.routine.deliveryWorkbenchId !== null && + row.deliveryWorkbenchName !== null ? ( + ) : ( -
    - {steps.map((step, index) => ( -
  1. - {step.title} - {step.detail !== undefined ? ( - - {" — "} - {step.detail} - - ) : null} -
  2. - ))} -
+ )} -
- {data.trigger !== null && - data.trigger.kind === "webhook" && - onRotateWebhookSecret !== undefined ? ( - + + {scheduleSummary(row, now)} + + + {chip.label} + + + onToggleEnabled(row, enabled)} /> - ) : null} -
- ); - }} -
- -
-
-

- Recent runs -

- -
- - {(items) => ( - - )} - -
-
-
+ + +
+ onRunNow(row)} + /> + +
+
+ + {expanded ? ( + + + + + + ) : null} + + ); + })} + + ); } -function routineRunIds( - runHistories: ReadonlyMap, -): ReadonlySet { - const ids = new Set(); - for (const runs of runHistories.values()) { - for (const run of runs) ids.add(run.runId); - } - return ids; +const ROUTINES_PATH_PREFIX = "/routines"; + +/** A deep link into one routine (the context menu's "Open routine", + * `/routines/:id` bookmarks) still lands here and expands that row — the + * page itself is one flat list now, never a route per routine. */ +function routineIdFromPath(path: string): string | null { + if (!path.startsWith(`${ROUTINES_PATH_PREFIX}/`)) return null; + const rest = path.slice(ROUTINES_PATH_PREFIX.length + 1); + return rest === "" ? null : decodeURIComponent(rest); } export function RoutinesRoute({ @@ -725,220 +497,93 @@ export function RoutinesRoute({ readonly path: string; readonly navigate: (to: string) => void; }) { - const { selectedTenantId } = useBench(); + const routinesQuery = useGlobalRoutines(); const queryClient = useQueryClient(); - const allRuns = useAPIQuery("/api/me/workflows/runs", RunsSchema); - const tenantId = selectedTenantId; + const openRoutine = useOpenRoutineInCanvas(); + const { selectTenant } = useBench(); + const deepLinkedId = routineIdFromPath(path); + const [expandedId, setExpandedId] = useState(deepLinkedId); + const now = Date.now(); - function invalidateRoutines() { - if (tenantId === null) return; - void queryClient.invalidateQueries({ - queryKey: tenantKeys.routines(tenantId), - }); + const rows = routinesQuery.kind === "ready" ? routinesQuery.data : []; + + function invalidate(tenantId: string) { void queryClient.invalidateQueries({ - queryKey: tenantKeys.routineRunHistories(tenantId), + queryKey: [...tenantKeys.routines(tenantId), "global-page"], }); } - const routines = useTenantQuery( - tenantId === null - ? (["tenant", "none", "routines"] as const) - : tenantKeys.routines(tenantId), - tenantId !== null, - () => listRoutines(tenantId ?? ""), - ); - const definitionsQuery = useTenantQuery( - tenantId === null - ? (["tenant", "none", "definitions"] as const) - : tenantKeys.definitions(tenantId), - tenantId !== null, - () => listWorkflowDefinitions(tenantId ?? ""), - ); - const definitions = - definitionsQuery.kind === "ready" ? definitionsQuery.data : []; - - const workbenchesQuery = useTenantQuery( - tenantId === null - ? tenantKeys.workbenches("none", "workbench") - : tenantKeys.workbenches(tenantId, "workbench"), - tenantId !== null, - () => listWorkbenches(tenantId ?? "", "workbench"), - ); - const workbenches = - workbenchesQuery.kind === "ready" ? workbenchesQuery.data : []; - - const routineIds = - routines.kind === "ready" ? routines.data.map((r) => r.id) : []; - const runHistoriesQuery = useTenantQuery< - ReadonlyMap - >( - tenantId === null - ? (["tenant", "none", "routine-run-histories"] as const) - : [...tenantKeys.routineRunHistories(tenantId), routineIds.join(",")], - tenantId !== null && routineIds.length > 0, - async () => { - const entries = await Promise.all( - routineIds.map( - async (id) => - [id, await listRoutineRuns(tenantId ?? "", id)] as const, - ), - ); - return new Map(entries); - }, - ); - const runHistories = - runHistoriesQuery.kind === "ready" ? runHistoriesQuery.data : new Map(); + function openWorkbench(tenantId: string, workbenchId: string) { + selectTenant(tenantId); + navigate(workbenchPath(workbenchId)); + } - const liveRuns: APIQuery = - allRuns.kind === "ready" - ? { - kind: "ready", - data: allRuns.data.data.filter((run) => - routineRunIds(runHistories).has(run.id), - ), + return ( +
+ { - if (openRoutineId !== null) return; - if (routines.kind !== "ready" || routines.data.length === 0) return; - const first = routines.data[0]; - if (first === undefined) return; - navigate(`${ROUTINES_PATH_PREFIX}/${encodeURIComponent(first.id)}`); - }, [openRoutineId, routines, navigate]); - - // Mobile full-page detail when deep-linked; desktop uses the split pane. - const isNarrow = - typeof window !== "undefined" && - window.matchMedia("(max-width: 767px)").matches; - - const detailRoutine: APIQuery = useMemo(() => { - if (openRoutineId === null || tenantId === null) { - return { kind: "loading" }; - } - if (routines.kind === "loading") return { kind: "loading" }; - if (routines.kind !== "ready") return routines; - const found = routines.data.find((r) => r.id === openRoutineId); - if (found === undefined) { - return { - kind: "error", - message: "Routine not found", - retry: invalidateRoutines, - }; - } - return { kind: "ready", data: found }; - }, [openRoutineId, tenantId, routines]); - - const detailRuns = useTenantQuery( - tenantId === null || openRoutineId === null - ? (["tenant", "none", "routines", "none", "runs"] as const) - : tenantKeys.routineRuns(tenantId, openRoutineId), - tenantId !== null && openRoutineId !== null, - () => listRoutineRuns(tenantId ?? "", openRoutineId ?? ""), - ); - - // Fetched once per selected routine, not per render of the webhook panel: - // `GET .../webhook-triggers/:id` never returns the secret (see - // webhook-triggers-api.ts), so this only ever supplies the URL/status - // side of the panel — the secret comes from create/rotate responses, - // held in the panel's own local state. - const selectedWebhookTriggerId = - detailRoutine.kind === "ready" && - detailRoutine.data.trigger !== null && - detailRoutine.data.trigger.kind === "webhook" - ? detailRoutine.data.trigger.webhookTriggerId - : null; - const webhookTriggerQuery = useTenantQuery( - tenantId === null || selectedWebhookTriggerId === null - ? (["tenant", "none", "webhook-trigger", "none"] as const) - : ([ - "tenant", - tenantId, - "webhook-trigger", - selectedWebhookTriggerId, - ] as const), - tenantId !== null && selectedWebhookTriggerId !== null, - () => getWebhookTrigger(tenantId ?? "", selectedWebhookTriggerId ?? ""), - ); - - const onRotateWebhookSecret = async () => { - if (tenantId === null || selectedWebhookTriggerId === null) { - throw new Error("No webhook trigger to rotate"); - } - const rotated = await rotateWebhookTriggerSecret( - tenantId, - selectedWebhookTriggerId, - ); - void queryClient.invalidateQueries({ - queryKey: [ - "tenant", - tenantId, - "webhook-trigger", - selectedWebhookTriggerId, - ], - }); - return { secret: rotated.secret }; - }; - - if (openRoutineId !== null && isNarrow) { - return ( - openRoutine({ routineId: null })}> + New routine + } - onRotateWebhookSecret={onRotateWebhookSecret} - onBack={() => navigate(ROUTINES_PATH_PREFIX)} - onOpenRuns={() => navigate("/insights/runs")} - onOpenWorkbench={(workbenchId) => navigate(workbenchPath(workbenchId))} /> - ); - } - - return ( - - navigate( - id === null - ? ROUTINES_PATH_PREFIX - : `${ROUTINES_PATH_PREFIX}/${encodeURIComponent(id)}`, - ) - } - webhookTrigger={ - selectedWebhookTriggerId !== null ? webhookTriggerQuery : null - } - onRotateWebhookSecret={onRotateWebhookSecret} - onToggleEnabled={(routine, enabled) => { - if (tenantId === null) return; - void updateRoutine(tenantId, routine.id, { enabled }).then( - invalidateRoutines, - ); - }} - onRunNow={async (routine) => { - if (tenantId === null) - throw new Error("No workbench to run this on yet"); - await runRoutineNow(tenantId, routine.id); - invalidateRoutines(); - toast(routineRunStartedToast(routine.name)); - }} - onOpenRuns={() => navigate("/insights/runs")} - onOpenWorkbench={(workbenchId) => navigate(workbenchPath(workbenchId))} - /> +
+ {routinesQuery.kind === "loading" ? ( +
+ } title="Loading routines…" /> +
+ ) : routinesQuery.kind === "error" ? ( +
+ } + title="Couldn't load routines" + description={routinesQuery.message} + /> +
+ ) : ( + + setExpandedId((current) => + current === routineId ? null : routineId, + ) + } + onToggleEnabled={(row, enabled) => { + void updateRoutine(row.tenantId, row.routine.id, { + enabled, + }).then(() => invalidate(row.tenantId)); + }} + onRunNow={async (row) => { + await runRoutineNow(row.tenantId, row.routine.id); + invalidate(row.tenantId); + toast(routineRunStartedToast(row.routine.name)); + }} + onEdit={(row) => + openRoutine({ + routineId: row.routine.id, + ...(row.routine.deliveryWorkbenchId !== null + ? { workbenchId: row.routine.deliveryWorkbenchId } + : {}), + }) + } + onOpenWorkbench={(workbenchId) => { + const row = rows.find( + (r) => r.routine.deliveryWorkbenchId === workbenchId, + ); + if (row === undefined) return; + openWorkbench(row.tenantId, workbenchId); + }} + /> + )} +
+
); } diff --git a/apps/web/src/shell/canvas-availability.tsx b/apps/web/src/shell/canvas-availability.tsx index 73f5e1a8a..f36889abd 100644 --- a/apps/web/src/shell/canvas-availability.tsx +++ b/apps/web/src/shell/canvas-availability.tsx @@ -41,14 +41,12 @@ export type CanvasArtifactContent = { * shared workbenches from a `ProfileSubject`'s address rather than being * handed pre-resolved content. */ export type RoutinePanelSubject = { - /** Opens straight to the panel's default list view — the workbench's - * active routines, with a "New routine" row at the top — instead of a - * specific routine's editor. The header's Routines affordance and the - * `/run` composer command both open this; `routineId` is ignored when - * present. Omitted (or a `routineId` given instead) opens the editor - * directly, the same way every pre-existing caller (routines-page's own - * "New routine"/"Edit" actions, "Make this a routine") already does. */ - readonly view?: "list" | "runs"; + /** Always opens the editor: a specific routine (`routineId` set) or a + * brand-new one (`routineId` omitted or `null`) — routines-page's own + * "New routine"/"Edit" actions, "Make this a routine", the composer's + * `/routine` command, and "New routine in this space" (CL-6362: + * browsing/running existing routines moved to the global `/routines` + * page, so this pane no longer has a list mode). */ readonly routineId?: string | null; /** Seeds the Name/Instruction fields the instant a brand-new panel opens * (`routineId: null` only) — "Make this a routine" (a completed task diff --git a/apps/web/src/shell/routine-panel.tsx b/apps/web/src/shell/routine-panel.tsx index bc34558ca..3806f36a4 100644 --- a/apps/web/src/shell/routine-panel.tsx +++ b/apps/web/src/shell/routine-panel.tsx @@ -1,17 +1,11 @@ -// The routine panel (CL-6125, reworked CL-6139): a two-view master-detail -// pane in the canvas column, beside the conversation — never a route hop. -// `RoutinePanel` branches on the subject's `view`: -// -// - list (the default, and the header's Routines affordance / `/run`): -// this workbench's active routines, name · cadence · Active toggle, -// with a "New routine" row at the top. `RoutineListPanel`. -// - editor (a specific routine, or a brand-new one): the same fields -// this pane has always had. `RoutineEditorPanel`. -// -// Back from the editor returns to the list; back from the list closes the -// canvas — one back-chevron affordance the whole way down, the same -// master-detail shape `ProfileCanvasPane`/`ArtifactCanvasPane` establish -// elsewhere in this column. +// The routine panel (CL-6125, reworked CL-6139, trimmed to editor-only by +// CL-6362): a create/edit pane in the canvas column, beside the +// conversation — never a route hop. Browsing and running existing +// routines lives on the global `/routines` page now (the shell rail's +// Routines row) — this pane only ever opens straight to +// `RoutineEditorPanel`, for a specific routine (`routineId`) or a +// brand-new one. Back closes the canvas — there is no list view to step +// back to anymore. // // There is no Save button — every field autosaves on blur/select, and // every write (create or update) is serialized through one queue @@ -37,73 +31,45 @@ import { useEffect, useRef, useState } from "react"; import type { ChangeEvent } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { - Badge, Button, ConfirmButton, EmptyState, - formatRelativeTime, Input, Menu, MenuContent, MenuItem, MenuTrigger, - RichEmptyState, RunNowButton, - StatusDot, Switch, toast, - TraceWaterfall, } from "@corbits/react-ui"; -import type { BadgeTone, StatusDotTone } from "@corbits/react-ui"; -import { listWorkbenchAgents, WorkbenchLoadingState } from "@corbits/chat-ui"; -import { listTasks } from "@corbits/tasks-ui"; -import type { Task, TaskStatus } from "@corbits/tasks-ui"; -import { Clock, Plus, X } from "lucide-react"; +import { listWorkbenchAgents } from "@corbits/chat-ui"; +import { Clock, X } from "lucide-react"; -import { useAPIQuery } from "../api"; import { useBench } from "../bench-context"; import { useNavigate } from "../navigation"; import { ensureMyraWorkbench } from "../myra-workbench"; -import { cadenceLabel, cadenceSummary } from "../routine-trigger"; +import { cadenceLabel } from "../routine-trigger"; import { ScheduleEditor } from "../routine-schedule"; import { createRoutine, deleteRoutine, getRoutine, listRoutineRuns, - listRoutines, routineCreatedToast, routineRunStartedToast, runRoutineNow, updateRoutine, - useTenantQuery, } from "../routines-api"; import type { Routine, RoutineRun, RoutineTrigger } from "../routines-api"; -import { - insightsRunTracePath, - insightsTopLevelRunsPath, - RunTraceSchema, - TopLevelRunsSchema, -} from "../insights-api"; -import type { InsightsRun } from "../insights-api"; import { RunsTable } from "../pages/routines-page"; -import { - formatWhen, - runDurationLabel, - statusTone, - toTraceSpans, -} from "../pages/insights-page"; import { createWebhookTrigger, DEFAULT_WEBHOOK_INPUT_TEMPLATE, } from "../webhook-triggers-api"; import { useDeploymentCapabilities } from "../deployment-capabilities-api"; import { tenantKeys } from "../query-client"; -import { - useCanvasColumnRoutine, - useCloseCanvas, - useOpenRoutineInCanvas, -} from "./canvas-availability"; +import { useCanvasColumnRoutine, useCloseCanvas } from "./canvas-availability"; import type { RoutinePanelSubject } from "./canvas-availability"; import { CanvasPaneHeader } from "./canvas-column"; @@ -161,621 +127,20 @@ function AddTriggerMenu({ ); } +/** Opens straight to the routine editor — create (no `routineId`) or edit + * an existing one. Routines' list/browse surface (name, cadence, enabled, + * recent runs) is the global `/routines` page now (CL-6362); this pane is + * only ever reached from a creation entry point (the composer's + * `/routine` command, "New routine in this space", "Make this a + * routine") or an "Edit" action already carrying a `routineId`. */ export function RoutinePanel() { const subject = useCanvasColumnRoutine(); const close = useCloseCanvas(); - const openRoutine = useOpenRoutineInCanvas(); - - if (subject === null || subject.view === "list") { - return ( - - openRoutine({ - routineId, - ...(subject?.workbenchId !== undefined - ? { workbenchId: subject.workbenchId } - : {}), - }) - } - onNew={() => - openRoutine({ - routineId: null, - ...(subject?.workbenchId !== undefined - ? { workbenchId: subject.workbenchId } - : {}), - }) - } - onOpenRuns={() => - openRoutine({ - view: "runs", - ...(subject?.workbenchId !== undefined - ? { workbenchId: subject.workbenchId } - : {}), - }) - } - /> - ); - } - - if (subject.view === "runs") { - return ( - - openRoutine({ - view: "list", - ...(subject.workbenchId !== undefined - ? { workbenchId: subject.workbenchId } - : {}), - }) - } - /> - ); - } - - return ( - - openRoutine({ - view: "list", - ...(subject.workbenchId !== undefined - ? { workbenchId: subject.workbenchId } - : {}), - }) - } - onClose={close} - /> - ); -} - -/** The panel's default view: this workbench's active routines, a "New - * routine" row at the top, name · cadence · Active toggle per row. */ -/** A run's own embedded status field — `RoutineRun.run` is an opaque - * `Record` (whatever the launched workflow run reports), - * `"status"` is the one key `RunsTable` already reads from it. */ -function embeddedRunStatus(run: RoutineRun): string | undefined { - const status = run.run?.["status"]; - return typeof status === "string" ? status : undefined; -} - -function runFailed(run: RoutineRun): boolean { - return ( - (run.error !== undefined && run.error !== null) || - embeddedRunStatus(run) === "failed" - ); -} - -/** Best-effort one-line outcome for a finished run: the run's own error - * when it has one, else the first plausible reply/summary field the - * embedded run record carries, else an honest "Completed." — never a - * fabricated excerpt when the data has none. */ -function runOutcomeExcerpt(run: RoutineRun): string { - if (run.error !== undefined && run.error !== null) return run.error; - const record = run.run; - if (record !== undefined) { - for (const key of ["reply", "summary", "output", "result"]) { - const value = record[key]; - if (typeof value === "string" && value.trim() !== "") { - return value.length > 140 ? `${value.slice(0, 140)}…` : value; - } - } - } - return "Completed."; -} - -type StatusChip = { - readonly label: string; - readonly tone: StatusDotTone; - readonly live: boolean; -}; - -/** `StatusDot` marks liveness only — its own doc comment is explicit that - * a `Badge` is what names the state visibly. `StatusDotTone` and - * `BadgeTone` are two different enums (`"emphasis"` vs. `"accent"`); - * every other tone name is shared. */ -function badgeToneFor(tone: StatusDotTone): BadgeTone { - return tone === "emphasis" ? "accent" : tone; -} - -/** The chip both components together render: a live/pulsing dot plus the - * visible, colour-matched label naming the state. */ -function StatusChipView({ chip }: { readonly chip: StatusChip }) { - return ( - - - {chip.label} - - ); -} - -/** "Idle · Running now (elapsed) · Last run OK Xm ago · Last run failed" — - * the routine row's live state, computed from its own run history (no - * separate live-run correlation needed: each `RoutineRun` already embeds - * the launched run's own status). `runningOverride` is the optimistic - * "I just clicked Run now" flip — true the instant the click lands, before - * the server has even accepted the request, let alone reported back. */ -function routineStatusChip( - runs: readonly RoutineRun[], - runningOverride: boolean, - now: number, -): StatusChip { - if (runningOverride) - return { label: "Running now", tone: "neutral", live: true }; - const latest = runs[0]; - if (latest === undefined) - return { label: "Idle", tone: "neutral", live: false }; - if (embeddedRunStatus(latest) === "running") { - return { - label: `Running now · ${formatRelativeTime(latest.createdAt, now)}`, - tone: "neutral", - live: true, - }; - } - if (runFailed(latest)) { - return { label: "Last run failed", tone: "danger", live: false }; - } - return { - label: `Last run OK ${formatRelativeTime(latest.createdAt, now)}`, - tone: "success", - live: false, - }; -} - -/** Polls run history for this routine until the newest run leaves - * "running", or gives up after `attempts` — the honest "when the run - * ends" signal a Run now click needs, since the create/run response - * itself only confirms the launch was accepted, not that it finished. */ -async function pollForOutcome( - tenantId: string, - routineId: string, - attempts = 6, - delayMs = 300, -): Promise { - for (let attempt = 0; attempt < attempts; attempt++) { - const runs = await listRoutineRuns(tenantId, routineId); - const latest = runs[0]; - if (latest !== undefined && embeddedRunStatus(latest) !== "running") { - return latest; - } - if (attempt < attempts - 1) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - const runs = await listRoutineRuns(tenantId, routineId); - return runs[0] ?? null; -} - -function RoutineListPanel({ - workbenchId, - onClose, - onSelect, - onNew, - onOpenRuns, -}: { - /** The workbench this panel was opened beside. The pop-out is - * strictly workbench-scoped (owner decision, CL-6200): only routines - * delivering here and tasks dispatched from here are listed. */ - readonly workbenchId?: string | undefined; - readonly onClose: () => void; - readonly onSelect: (routineId: string) => void; - readonly onNew: () => void; - readonly onOpenRuns: () => void; -}) { - const navigate = useNavigate(); - const { selectedTenantId: tenantId } = useBench(); - const queryClient = useQueryClient(); - const [pendingToggleId, setPendingToggleId] = useState(null); - const [runningIds, setRunningIds] = useState>(new Set()); - const [outcomes, setOutcomes] = useState>( - new Map(), - ); - - const routinesQuery = useTenantQuery( - tenantKeys.routines(tenantId ?? ""), - tenantId !== null, - () => listRoutines(tenantId as string), - ); - const allRoutines = routinesQuery.kind === "ready" ? routinesQuery.data : []; - const routines = - workbenchId === undefined - ? allRoutines - : allRoutines.filter((r) => r.deliveryWorkbenchId === workbenchId); - const routineIds = routines.map((r) => r.id); - const runHistoriesQuery = useTenantQuery< - ReadonlyMap - >( - tenantId === null - ? (["tenant", "none", "routine-run-histories-panel"] as const) - : [ - ...tenantKeys.routineRunHistories(tenantId), - "panel", - routineIds.join(","), - ], - tenantId !== null && routineIds.length > 0, - async () => { - const entries = await Promise.all( - routineIds.map( - async (id) => - [id, await listRoutineRuns(tenantId as string, id)] as const, - ), - ); - return new Map(entries); - }, - ); - const runHistories = - runHistoriesQuery.kind === "ready" ? runHistoriesQuery.data : new Map(); - - function toggle(routine: Routine, enabled: boolean) { - if (tenantId === null) return; - setPendingToggleId(routine.id); - void updateRoutine(tenantId, routine.id, { enabled }) - .then(() => { - void queryClient.invalidateQueries({ - queryKey: tenantKeys.routines(tenantId), - }); - }) - .finally(() => setPendingToggleId(null)); - } - function runNow(routine: Routine): Promise { - if (tenantId === null) return Promise.resolve(); - setRunningIds((prev) => new Set(prev).add(routine.id)); - setOutcomes((prev) => { - const next = new Map(prev); - next.delete(routine.id); - return next; - }); - return runRoutineNow(tenantId, routine.id) - .then(() => pollForOutcome(tenantId, routine.id)) - .then((outcome) => { - if (outcome !== null) { - setOutcomes((prev) => new Map(prev).set(routine.id, outcome)); - } - }) - .finally(() => { - setRunningIds((prev) => { - const next = new Set(prev); - next.delete(routine.id); - return next; - }); - void queryClient.invalidateQueries({ - queryKey: tenantKeys.routineRunHistories(tenantId), - }); - }); - } + if (subject === null) return null; return ( -
- - Runs - - } - /> -
- - {routinesQuery.kind === "loading" ? ( - - ) : routinesQuery.kind === "ready" ? ( - routines.length === 0 ? ( - } - title="No routines yet" - description="Create one to automate this workbench." - /> - ) : ( - routines.map((routine) => { - const runs = runHistories.get(routine.id) ?? []; - const chip = routineStatusChip( - runs, - runningIds.has(routine.id), - Date.now(), - ); - const outcome = outcomes.get(routine.id); - return ( -
-
- - - runNow(routine)} - /> - toggle(routine, enabled)} - /> -
- {outcome !== undefined ? ( -
- - {runOutcomeExcerpt(outcome)} - - -
- ) : null} -
- ); - }) - ) - ) : ( - } - title="Couldn't load routines" - description="Try again in a moment." - /> - )} - {tenantId !== null ? ( - - ) : null} -
-
- ); -} - -function runStatusDotTone(status: string): StatusDotTone { - const tone = statusTone(status); - if (tone === "danger") return "danger"; - if (tone === "success" || tone === "info") return "emphasis"; - return "neutral"; -} - -/** This workbench's own runs — its agent runs and its routines' runs - * (`insightsTopLevelRunsPath` is already tenant-scoped, so a workbench's - * runs are exactly this bench's top-level feed) — each row opening the - * same `TraceWaterfall` insights renders, inline in this pane: no route - * hop out of `/w/:id`. Selection is local state, not canvas subject state - * — a click into a trace and back never touches the shell's own history. */ -function RunsCanvasPanel({ onBack }: { readonly onBack: () => void }) { - const { selectedTenantId: tenantId } = useBench(); - const [selectedRunId, setSelectedRunId] = useState(null); - - const runsQuery = useAPIQuery( - tenantId === null ? "" : insightsTopLevelRunsPath(tenantId), - TopLevelRunsSchema, - ); - const runs: readonly InsightsRun[] = - runsQuery.kind === "ready" ? runsQuery.data.data : []; - const selectedRun = runs.find((run) => run.id === selectedRunId) ?? null; - - const traceQuery = useAPIQuery( - tenantId === null || selectedRunId === null - ? "" - : insightsRunTracePath(tenantId, selectedRunId), - RunTraceSchema, - ); - - if (selectedRunId !== null) { - const spans = - traceQuery.kind === "ready" ? toTraceSpans(traceQuery.data) : []; - return ( -
- setSelectedRunId(null)} - /> -
- {traceQuery.kind === "loading" ? ( - - ) : null} - {traceQuery.kind === "ready" && spans.length > 0 ? ( - - ) : null} - {traceQuery.kind === "ready" && spans.length === 0 ? ( - - ) : null} - {traceQuery.kind === "error" ? ( - - ) : null} -
-
- ); - } - - return ( -
- -
- {runsQuery.kind === "loading" ? ( - - ) : runsQuery.kind === "ready" && runs.length === 0 ? ( - } - title="No runs yet." - description="This workbench's agent and routine runs will show up here." - /> - ) : runsQuery.kind === "ready" ? ( - runs.map((run) => ( - - )) - ) : ( - } - title="Couldn't load runs" - description="Try again in a moment." - /> - )} -
-
- ); -} - -/** "Tasks" section: this workbench's in-flight and recent tasks - * (`@corbits/tasks-ui`'s own row/status vocabulary), the same - * verify-by-running story the routines list above tells — a task's - * outcome shows inline the moment it lands, not just in Insights. */ -function TasksSection({ - tenantId, - workbenchId, - navigate, -}: { - readonly tenantId: string; - readonly workbenchId?: string | undefined; - readonly navigate: (path: string) => void; -}) { - const tasksQuery = useTenantQuery(tenantKeys.tasks(tenantId), true, () => - listTasks(tenantId), - ); - const tasks = - tasksQuery.kind === "ready" - ? [...tasksQuery.data] - .filter( - (task) => - workbenchId === undefined || task.workbenchId === workbenchId, - ) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, 10) - : []; - - return ( -
-
-

- Tasks -

-
- {tasksQuery.kind === "loading" ? ( - - ) : tasks.length === 0 ? ( -
- } - title="No tasks yet" - description="Run one now to see it here." - /> -
- ) : ( - tasks.map((task) => ( - - )) - )} -
- ); -} - -const TASK_STATUS_CHIP: Record = { - queued: { label: "Queued", tone: "neutral", live: false }, - running: { label: "Running now", tone: "neutral", live: true }, - "needs-you": { label: "Needs you", tone: "emphasis", live: true }, - done: { label: "Last run OK", tone: "success", live: false }, - failed: { label: "Last run failed", tone: "danger", live: false }, -}; - -function TaskRow({ - task, - navigate, -}: { - readonly task: Task; - readonly navigate: (path: string) => void; -}) { - const chip = TASK_STATUS_CHIP[task.status]; - const terminal = task.status === "done" || task.status === "failed"; - return ( -
-
- {task.agentName} - -
- {terminal ? ( -
- - {task.status === "failed" ? "Failed." : "Done."} - - -
- ) : null} -
+ ); } diff --git a/apps/web/src/shell/sidebar.tsx b/apps/web/src/shell/sidebar.tsx index 025d15a6b..66b88eba9 100644 --- a/apps/web/src/shell/sidebar.tsx +++ b/apps/web/src/shell/sidebar.tsx @@ -42,6 +42,7 @@ import { Plus, SlidersHorizontal, Sparkles, + Workflow, } from "lucide-react"; import { useMemo } from "react"; @@ -142,10 +143,21 @@ export function Sidebar({ - {/* Footer order: Files, Skills, Agents, Plugins, Insights, then the - account row anchors everything else (weekly usage, Settings, Log - out) in its pop-up menu — a single footer, never two stacked - rows. */} + {/* Footer order: Routines, Files, Skills, Agents, Plugins, Insights, + then the account row anchors everything else (weekly usage, + Settings, Log out) in its pop-up menu — a single footer, never + two stacked rows. Routines (CL-6362) is global-only here — no + per-workbench routines chrome remains. */} + ) : null} - {onOpenRoutines !== undefined ? ( - - ) : null} - {onOpenInsights !== undefined ? ( - - ) : null}