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}