From 849713f9f6ed0d8da1e388470c336e318ddf19f3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 22:57:14 -0700 Subject: [PATCH] fix(web): Insights is bench-scoped everywhere and its chart shows real runs (CL-9594) /insights hops to the last workbench's Insights; the workspace-wide page is removed. The chart now counts running/stopped runs, the header follows the mockup, and the range control moves to the top bar. --- apps/web/src/bench/insights-tab.tsx | 9 +- apps/web/src/insights-stats.test.ts | 106 +------ apps/web/src/insights-stats.ts | 74 +---- apps/web/src/pages/bench-insights.css | 20 ++ apps/web/src/pages/bench-insights.tsx | 109 +++++-- apps/web/src/pages/insights-page.tsx | 426 +++++--------------------- 6 files changed, 191 insertions(+), 553 deletions(-) create mode 100644 apps/web/src/pages/bench-insights.css diff --git a/apps/web/src/bench/insights-tab.tsx b/apps/web/src/bench/insights-tab.tsx index ab2358d1f..42461bb6b 100644 --- a/apps/web/src/bench/insights-tab.tsx +++ b/apps/web/src/bench/insights-tab.tsx @@ -4,7 +4,8 @@ import { useAPIQuery } from "../api"; import { insightsTopLevelRunsPath, TopLevelRunsSchema } from "../insights-api"; import { computeBenchInsights, durationLabel, formatCount } from "../insights-stats"; import { Link } from "../navigation"; -import { INSIGHTS_PATH_PREFIX } from "../path-ids"; +import { workbenchInsightsPath } from "../insights-deeplinks"; +import { benchLink } from "../shell/page-crumbs"; import { OutcomeChart } from "../pages/bench-insights"; /** The bench Insights page in miniature: the same runs query and @@ -30,7 +31,6 @@ export function InsightsTab({ workbenchTenantId }: { readonly workbenchTenantId: ["Succeeded", finished === 0 ? "—" : `${Math.round((stats.ok / finished) * 100)}%`], ["Median run", stats.medianMs === null ? "—" : durationLabel(stats.medianMs)], ]; - const id = encodeURIComponent(workbenchTenantId); return (
@@ -49,7 +49,10 @@ export function InsightsTab({ workbenchTenantId }: { readonly workbenchTenantId:

Runs per day

- + Open Insights
diff --git a/apps/web/src/insights-stats.test.ts b/apps/web/src/insights-stats.test.ts index 37660d90d..d0516f5f8 100644 --- a/apps/web/src/insights-stats.test.ts +++ b/apps/web/src/insights-stats.test.ts @@ -1,16 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { FIRE_RUNNING_WINDOW_MS } from "@corbits/workflows/client"; - -import { - computeInsightsStats, - groupRunsByDefinition, - INSIGHTS_RECENT_LIMIT, - purposeRunsForInsights, - runDisplayName, -} from "./insights-stats"; +import { groupRunsByDefinition, purposeRunsForInsights, runDisplayName } from "./insights-stats"; import type { InsightsRun } from "./insights-api"; -import type { ScheduledWorkflowDefinition } from "./routines-api"; function run(partial: Partial & Pick): InsightsRun { return { @@ -26,101 +17,6 @@ function run(partial: Partial & Pick) }; } -function scheduled( - partial: Partial & - Pick, -): ScheduledWorkflowDefinition { - return { - assetId: "ast_def", - name: "Daily dig", - tenantId: "t1", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - schedule: "0 9 * * *", - ...partial, - }; -} - -describe("computeInsightsStats", () => { - test("counts purposeful runs by status", () => { - const stats = computeInsightsStats( - [ - run({ - id: "1", - status: "running", - createdAt: "2026-01-03T00:00:00.000Z", - }), - run({ - id: "2", - status: "error", - createdAt: "2026-01-02T00:00:00.000Z", - }), - run({ - id: "3", - status: "stopped", - createdAt: "2026-01-01T00:00:00.000Z", - }), - ], - [ - scheduled({ definitionId: "r1", status: "deployed" }), - scheduled({ definitionId: "r2", status: "stopped" }), - ], - INSIGHTS_RECENT_LIMIT, - Date.parse("2026-01-03T00:01:00.000Z"), - ); - - expect(stats.totalRuns).toBe(3); - expect(stats.running).toBe(1); - expect(stats.errored).toBe(1); - expect(stats.stopped).toBe(1); - expect(stats.routineCount).toBe(2); - expect(stats.enabledRoutines).toBe(1); - expect(stats.recentRuns.map((r) => r.id)).toEqual(["1", "2", "3"]); - }); - - test("a live running run past the fire window is still counted as running", () => { - const stats = computeInsightsStats( - [ - run({ - id: "stale", - status: "running", - createdAt: new Date(Date.now() - FIRE_RUNNING_WINDOW_MS - 1).toISOString(), - }), - ], - [], - ); - expect(stats.running).toBe(1); - }); - - test("endedAt drops a just-finished running run from the running count immediately", () => { - const stats = computeInsightsStats( - [ - run({ - id: "just-finished", - status: "running", - createdAt: new Date().toISOString(), - endedAt: new Date().toISOString(), - }), - ], - [], - ); - expect(stats.running).toBe(0); - }); - - test("limits recent runs", () => { - const runs = Array.from({ length: 5 }, (_, i) => - run({ - id: String(i), - status: "deployed", - createdAt: `2026-01-0${i + 1}T00:00:00.000Z`, - }), - ); - const stats = computeInsightsStats(runs, [], 2); - expect(stats.recentRuns).toHaveLength(2); - expect(stats.deployed).toBe(5); - }); -}); - describe("purposeRunsForInsights", () => { const deployment = run({ id: "ins_deployed", status: "running" }); diff --git a/apps/web/src/insights-stats.ts b/apps/web/src/insights-stats.ts index 8b7423e03..a90ce7ded 100644 --- a/apps/web/src/insights-stats.ts +++ b/apps/web/src/insights-stats.ts @@ -5,7 +5,6 @@ import { runOutcomeStatus, withListingAbandoned } from "@corbits/workflows/client"; import type { InsightsRun } from "./insights-api"; -import type { ScheduledWorkflowDefinition } from "./routines-api"; /** Compact integer; null/undefined → em-dash. Lifted out of the deleted * `@corbits/insights/client` — this app's own copy since it has @@ -22,20 +21,6 @@ export function durationLabel(ms: number): string { return `${(ms / 60_000).toFixed(1)}m`; } -export type InsightsStats = { - readonly totalRuns: number; - readonly running: number; - readonly errored: number; - readonly stopped: number; - readonly deployed: number; - readonly routineCount: number; - readonly enabledRoutines: number; - readonly recentRuns: readonly InsightsRun[]; -}; - -/** Cap recent-run table rows so the page stays scannable. */ -export const INSIGHTS_RECENT_LIMIT = 12; - // Identity pass: the native `GET /workflows/runs` feed already excludes // non-top-level runs, kept only for callers that still name it explicitly. export function purposeRunsForInsights(runs: readonly InsightsRun[]): readonly InsightsRun[] { @@ -89,53 +74,6 @@ export function groupRunsByDefinition(runs: readonly InsightsRun[]): readonly De ); } -export function computeInsightsStats( - runs: readonly InsightsRun[], - routines: readonly ScheduledWorkflowDefinition[], - recentLimit: number = INSIGHTS_RECENT_LIMIT, - now: number = Date.now(), -): InsightsStats { - const purposeful = purposeRunsForInsights(runs); - let running = 0; - let errored = 0; - let stopped = 0; - let deployed = 0; - for (const run of purposeful) { - const outcome = runOutcomeStatus(withListingAbandoned(run, now), now) ?? run.status; - switch (outcome) { - case "running": - case "updating": - running += 1; - break; - case "error": - errored += 1; - break; - case "stopped": - case "completed": - stopped += 1; - break; - case "deployed": - deployed += 1; - break; - } - } - - const recentRuns = [...purposeful] - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, recentLimit); - - return { - totalRuns: purposeful.length, - running, - errored, - stopped, - deployed, - routineCount: routines.length, - enabledRoutines: routines.filter((r) => r.status === "deployed").length, - recentRuns, - }; -} - export const BENCH_RANGES = [7, 30, 90] as const; export type BenchRange = (typeof BENCH_RANGES)[number]; @@ -145,6 +83,8 @@ export type BenchDay = { readonly date: Date; readonly ok: number; readonly fail: number; + /** Runs still going, stopped, or otherwise neither succeeded nor failed. */ + readonly other: number; }; export type BenchWorkflowRow = { readonly key: string; @@ -188,8 +128,8 @@ function bucketOf(run: InsightsRun, now: number): Bucket { } /** Rollups over the runs created in the last `range` local days (today - * included). "Other" outcomes (running, stopped) count as runs but are - * neither succeeded nor failed. */ + * included). "Other" outcomes (running, stopped) count as runs and get their + * own chart segment, but are neither succeeded nor failed. */ export function computeBenchInsights( runs: readonly InsightsRun[], range: BenchRange, @@ -197,11 +137,11 @@ export function computeBenchInsights( ): BenchInsights { const today = new Date(now); today.setHours(0, 0, 0, 0); - const days: { date: Date; ok: number; fail: number }[] = []; + const days: { date: Date; ok: number; fail: number; other: number }[] = []; for (let i = range - 1; i >= 0; i--) { const date = new Date(today); date.setDate(today.getDate() - i); - days.push({ date, ok: 0, fail: 0 }); + days.push({ date, ok: 0, fail: 0, other: 0 }); } const start = days[0]?.date.getTime() ?? 0; @@ -224,6 +164,8 @@ export function computeBenchInsights( } else if (bucket === "fail") { fail += 1; if (day !== undefined) day.fail += 1; + } else if (day !== undefined) { + day.other += 1; } const key = run.routineId ?? run.definitionId; groups.set(key, [...(groups.get(key) ?? []), run]); diff --git a/apps/web/src/pages/bench-insights.css b/apps/web/src/pages/bench-insights.css new file mode 100644 index 000000000..923dce0d5 --- /dev/null +++ b/apps/web/src/pages/bench-insights.css @@ -0,0 +1,20 @@ +.bi-head { + margin-bottom: 1rem; +} +.bi-head h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; +} +.bi-head p { + margin: 0.25rem 0 0; + color: var(--muted-foreground); +} +.bi-other { + background: var(--muted-foreground); + opacity: 0.55; +} +.bi-legend .bi-other { + width: 10px; + height: 10px; +} diff --git a/apps/web/src/pages/bench-insights.tsx b/apps/web/src/pages/bench-insights.tsx index fe7ec6255..0c9386bbe 100644 --- a/apps/web/src/pages/bench-insights.tsx +++ b/apps/web/src/pages/bench-insights.tsx @@ -1,11 +1,11 @@ // The bench-scoped Insights dashboard: every number is computed from the // stock `GET /workflows/runs` listing of the bench's own tenant. -import { Badge, RichEmptyState, Skeleton, RUN_STATUS_TONE } from "@corbits/react-ui"; -import { useState } from "react"; +import { Badge, PageShell, RichEmptyState, Skeleton, RUN_STATUS_TONE } from "@corbits/react-ui"; +import { useState, type ReactNode } from "react"; import { useAPIQuery } from "../api"; -import { insightsTopLevelRunsPath, TopLevelRunsSchema } from "../insights-api"; +import { insightsTopLevelRunsPath, TopLevelRunsSchema, type InsightsRun } from "../insights-api"; import { BENCH_RANGES, computeBenchInsights, @@ -16,6 +16,11 @@ import { type BenchRange, } from "../insights-stats"; import { formatWhen } from "./insights-page"; +import { useFromBench } from "../shell/page-crumbs"; +import { StageTopBar } from "../shell/stage-top-bar"; +import { workbenchPath } from "../workbench-path"; + +import "./bench-insights.css"; const RANGE_LABEL: Readonly> = { 7: "Last 7 days", @@ -39,7 +44,7 @@ function dayLabel(date: Date): string { export function OutcomeChart({ days }: { readonly days: readonly BenchDay[] }) { const [active, setActive] = useState(null); - const max = Math.max(...days.map((d) => d.ok + d.fail), 1); + const max = Math.max(...days.map((d) => d.ok + d.fail + d.other), 1); const every = days.length > 30 ? 14 : days.length > 7 ? 5 : 1; const shown = active === null ? undefined : days[active]; return ( @@ -53,6 +58,10 @@ export function OutcomeChart({ days }: { readonly days: readonly BenchDay[] }) { Failed + + + Running or stopped +
30 ? 1 : 4 }}> {days.map((d, i) => ( @@ -61,7 +70,7 @@ export function OutcomeChart({ days }: { readonly days: readonly BenchDay[] }) { className="bi-col" tabIndex={0} role="img" - aria-label={`${dayLabel(d.date)}: ${d.ok} succeeded, ${d.fail} failed`} + aria-label={`${dayLabel(d.date)}: ${d.ok} succeeded, ${d.fail} failed, ${d.other} running or stopped`} onMouseEnter={() => setActive(i)} onMouseLeave={() => setActive(null)} onFocus={() => setActive(i)} @@ -83,6 +92,9 @@ export function OutcomeChart({ days }: { readonly days: readonly BenchDay[] }) { }} /> ) : null} + {d.other > 0 ? ( + + ) : null}
))} @@ -100,7 +112,7 @@ export function OutcomeChart({ days }: { readonly days: readonly BenchDay[] }) { style={{ left: `${((active + 0.5) / days.length) * 100}%` }} > {dayLabel(shown.date)} - {shown.ok} succeeded · {shown.fail} failed + {shown.ok} succeeded · {shown.fail} failed · {shown.other} other ) : null} @@ -138,26 +150,85 @@ function Meter({ export function BenchInsights({ tenantId, + workbenchId, + title, onOpenRun, }: { readonly tenantId: string; + readonly workbenchId: string; + readonly title: string; readonly onOpenRun: (id: string) => void; }) { const [range, setRange] = useState(7); const runs = useAPIQuery(insightsTopLevelRunsPath(tenantId), TopLevelRunsSchema); + // `?from=` already prefixes the bench's own crumb; without it the trail + // names the bench itself, so it never shows twice. + const from = useFromBench(); + const crumbs = + from === workbenchId + ? [{ label: "Insights" }] + : [{ label: title, href: workbenchPath(workbenchId) }, { label: "Insights" }]; - if (runs.kind === "loading") return ; - if (runs.kind !== "ready") { - return ( + const rangeSeg = ( +
+ {BENCH_RANGES.map((r) => ( + + ))} +
+ ); + + let body: ReactNode; + if (runs.kind === "loading") { + body = ; + } else if (runs.kind !== "ready") { + body = ( ); + } else { + body = ; } - const stats = computeBenchInsights(runs.data.data, range); + return ( +
+ +
+ +
+

Insights

+

What happened in {title}.

+
+ {body} +
+
+
+ ); +} + +function InsightsBody({ + runs, + range, + onOpenRun, +}: { + readonly runs: { readonly data: readonly InsightsRun[]; readonly nextCursor: string | null }; + readonly range: BenchRange; + readonly onOpenRun: (id: string) => void; +}) { + const stats = computeBenchInsights(runs.data, range); const finished = stats.ok + stats.fail; const tiles: readonly (readonly [string, string])[] = [ ["Runs", formatCount(stats.total)], @@ -167,23 +238,7 @@ export function BenchInsights({ return (
-
- {BENCH_RANGES.map((r) => ( - - ))} -
- {runs.data.nextCursor !== null ? ( + {runs.nextCursor !== null ? (

Figures reflect the 100 most recent runs — more exist.

) : null} diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index bb837b5ed..979aea514 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -5,12 +5,10 @@ import { Badge, PageShell, RichEmptyState, - RUN_STATUS_DOT_TONE, RUN_STATUS_TONE, Skeleton, StatGrid, StatGridItem, - StatusDot, Table, TableBody, TableCell, @@ -20,16 +18,20 @@ import { type BadgeTone, type RunStatus, } from "@corbits/react-ui"; -import { ArrowRight, ChartBar } from "@/lib/icons"; +import { ChartBar } from "@/lib/icons"; import { runOutcomeStatus, runStatusLabel, withListingAbandoned } from "@corbits/workflows/client"; import type * as React from "react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { workflowRunStatuses, type WorkflowRunStatus } from "@intx/types"; import { SignedOutNotice, type APIQuery } from "@/lib/api-query"; import { workbenchesQueryKey, listWorkbenches } from "@/chat/workbench-tenants"; import { useBench } from "../bench-context"; +import { readLastWorkbenchId } from "../last-workbench"; +import { Redirect } from "../redirect"; +import { NEW_WORKBENCH_PATH } from "../routes"; +import { workbenchInsightsPath } from "../insights-deeplinks"; import { BenchInsights } from "./bench-insights"; import { resolveWorkbenchInsightsScope } from "../insights-workbench-scope"; import { parseInsightsPath } from "../insights-path"; @@ -42,24 +44,17 @@ import { type InsightsRun, } from "../insights-api"; import { - computeInsightsStats, durationLabel, - formatCount, groupRunsByDefinition, purposeRunsForInsights, runDisplayName, } from "../insights-stats"; import { useNavigate } from "../navigation"; -import { tenantKeys } from "../query-client"; import { useAPIQuery } from "../api"; import { INSIGHTS_PATH_PREFIX, INSIGHTS_RUNS_PATH } from "../path-ids"; import { benchLink, useFromBench } from "../shell/page-crumbs"; import { StageTopBar } from "../shell/stage-top-bar"; -import { - listScheduledWorkflows, - useTenantQuery, - type ScheduledWorkflowDefinition, -} from "../routines-api"; +import { useTenantQuery } from "../routines-api"; export function formatWhen(iso: string): string { const date = new Date(iso); @@ -98,12 +93,6 @@ function insightsStatusTone(status: string): BadgeTone { return "neutral"; } -function tileValue(value: string | number | null, loading: boolean): string { - if (loading) return ""; - if (value === null) return "—"; - return String(value); -} - function InsightsStat({ label, value, @@ -155,226 +144,10 @@ function onRowActivate(onActivate: () => void) { }; } -function runsDetailLabel(stats: { readonly running: number; readonly errored: number }): string { - if (stats.running > 0) { - return `${formatCount(stats.running)} running`; - } - if (stats.errored > 0) { - return `${formatCount(stats.errored)} errored`; - } - return "runs"; -} - -const ELAPSED_TICK_MS = 1_000; - -// So the elapsed label counts up like the live indicator, instead of -// freezing at whatever instant this component last rendered. -function useTickingNow(enabled: boolean): number { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - if (!enabled) return undefined; - const timer = setInterval(() => setNow(Date.now()), ELAPSED_TICK_MS); - return () => clearInterval(timer); - }, [enabled]); - return now; -} - -// Liveness is not a windowed property, so this filters the full run set, -// never the range-filtered one. A persisted `endedAt` means the fire -// already finished, even if `status` still reads `running`. -export function isRunningNow(run: InsightsRun, now: number = Date.now()): boolean { - const outcome = runOutcomeStatus(withListingAbandoned(run, now), now); - return outcome === "running" || outcome === "updating"; -} - function insightsRunStatus(run: InsightsRun, now: number = Date.now()): string { return runOutcomeStatus(withListingAbandoned(run, now), now) ?? run.status; } -// Wall-clock time since start, never a fabricated live counter. -export function elapsedLabel(createdAt: string, now: number): string { - const startMs = Date.parse(createdAt); - if (Number.isNaN(startMs)) return "—"; - return durationLabel(Math.max(0, now - startMs)); -} - -// Renders nothing when nothing is running — same convention as react-ui's -// `WorkflowDock`: an empty strip reports the normal case, not an empty -// state worth showing. -function RunningNowStrip({ - runs, - onOpenRun, -}: { - readonly runs: readonly InsightsRun[]; - readonly onOpenRun: (id: string) => void; -}) { - const maybeLive = runs.some((run) => run.status === "running" || run.status === "updating"); - const now = useTickingNow(maybeLive); - const running = runs.filter((run) => isRunningNow(run, now)); - if (running.length === 0) return null; - - return ( -
-
-

Running now

- - {formatCount(running.length)} in progress - -
-
    - {running.map((run) => ( -
  • - -
  • - ))} -
-
- ); -} - -function RecentRunRows({ - runs, - onOpenRun, - onOpenRuns, -}: { - readonly runs: readonly InsightsRun[]; - readonly onOpenRun: (id: string) => void; - readonly onOpenRuns: () => void; -}) { - return ( - - - {runs.map((row) => ( - onOpenRun(row.id))} - > - -
- {runDisplayName(row)} - - {formatWhen(row.createdAt)} - -
-
- - - {runStatusLabel(insightsRunStatus(row))} - - -
- ))} - - - - All runs - - - -
-
- ); -} - -function InsightsLanding({ - runs, - runsNextCursor, - routines, - loading, - onOpenRun, - onOpenRuns, -}: { - readonly runs: readonly InsightsRun[]; - // Non-null means more runs exist than fetched, so KPIs disclose the cap - // instead of presenting a truncated series as complete. - readonly runsNextCursor: string | null; - readonly routines: readonly ScheduledWorkflowDefinition[]; - readonly loading: boolean; - readonly onOpenRun: (id: string) => void; - readonly onOpenRuns: () => void; -}) { - const stats = computeInsightsStats(runs, routines); - const purposeRuns = purposeRunsForInsights(runs); - const runningNow = purposeRuns.filter((run) => isRunningNow(run)); - const recent = purposeRuns.slice(0, 12); - - return ( -
- - - - - {runningNow.length > 0 || loading ? ( - - ) : null} - - - - - {runsNextCursor !== null ? ( -

- Runs and outcomes below reflect the 100 most recent runs — more exist.{" "} - - . -

- ) : null} - -
-
-

Recent runs

-
- {recent.length > 0 ? ( - - ) : ( - } - title="No runs yet" - description="When a routine or automation fires, it shows up here." - /> - )} -
-
- ); -} - export function runDurationLabel(run: InsightsRun): string { if (run.endedAt === undefined || run.endedAt === null) return "—"; const startMs = Date.parse(run.createdAt); @@ -598,7 +371,6 @@ export function InsightsRunDetail({ export function InsightsPage({ path, runs, - routines, tenantId = null, }: { readonly path: string; @@ -606,18 +378,15 @@ export function InsightsPage({ data: readonly InsightsRun[]; nextCursor: string | null; }>; - readonly routines: APIQuery; readonly tenantId?: string | null; }) { const navigate = useNavigate(); const { mode, runId } = parseInsightsPath(path); - const unauth = runs.kind === "unauthenticated" || routines.kind === "unauthenticated"; - - if (unauth) { + if (runs.kind === "unauthenticated") { return (
- + @@ -625,124 +394,91 @@ export function InsightsPage({ ); } - const loading = runs.kind === "loading" || routines.kind === "loading"; - const runsData = runs.kind === "ready" ? runs.data.data : []; const runsNextCursor = runs.kind === "ready" ? runs.data.nextCursor : null; - const routinesData = routines.kind === "ready" ? routines.data : []; if (mode === "run" && runId !== null) { const run = runsData.find((r) => r.id === runId) ?? null; return ; } - if (mode === "runs") { - return ( - navigate(`${INSIGHTS_RUNS_PATH}/${encodeURIComponent(id)}`)} - /> - ); - } - - if (runs.kind === "error") { - return ( -
- - - } - title="Couldn't load insights" - description="Something went wrong on our side. Try again in a moment." - actions={[{ label: "Retry", onClick: runs.retry }]} - /> - -
- ); - } - return ( -
- -
- - navigate(`${INSIGHTS_RUNS_PATH}/${encodeURIComponent(id)}`)} - onOpenRuns={() => navigate(INSIGHTS_RUNS_PATH)} - /> - -
-
+ navigate(`${INSIGHTS_RUNS_PATH}/${encodeURIComponent(id)}`)} + /> ); } // Titles the page by the workbench name, never the tenant's. A legacy or // mis-wired id gets an honest empty state instead of a doomed fetch. function InsightsWorkbenchPage({ + workbenchId, workbenchesLoading, resolution, onOpenRun, }: { + readonly workbenchId: string; readonly onOpenRun: (id: string) => void; readonly workbenchesLoading: boolean; readonly resolution: ReturnType; }) { - if (workbenchesLoading) { + if (resolution.kind === "ready") { return ( -
- - - - -
+ ); } - if (resolution.kind === "not-found") { - return ( -
- - + return ( +
+ + + {workbenchesLoading ? ( + + ) : ( } - title="Workbench not found" - description="This conversation may have been deleted, or you may not have access to it." + title={ + resolution.kind === "not-found" + ? "Workbench not found" + : "No insights for this workbench yet" + } + description={ + resolution.kind === "not-found" + ? "This workbench may have been deleted, or you may not have access to it." + : "This workbench predates per-workbench insights." + } /> - -
- ); - } - if (resolution.kind === "legacy") { + )} +
+
+ ); +} + +// `/insights` with no workbench hops to the last-visited workbench's +// Insights, or the new-workbench picker when none is on record. +function InsightsLandingRedirect({ benchTenantId }: { readonly benchTenantId: string | null }) { + const navigate = useNavigate(); + const { workbenches, isLoading } = useWorkbenchList(benchTenantId); + if (isLoading) { return ( -
- - - } - title="No insights for this conversation yet" - description="This conversation predates per-workbench insights." - /> - -
+ + + ); } - return ( -
- -
- - - -
-
- ); + const lastId = benchTenantId === null ? null : readLastWorkbenchId(benchTenantId); + const to = + lastId !== null && workbenches.some((workbench) => workbench.id === lastId) + ? workbenchInsightsPath(lastId) + : NEW_WORKBENCH_PATH; + return ; } export function InsightsRoute({ path }: { readonly path?: string }) { @@ -757,24 +493,13 @@ export function InsightsRoute({ path }: { readonly path?: string }) { const { mode, workbenchId } = parseInsightsPath(currentPath); const runs = useAPIQuery( - selectedTenantId === null ? "" : insightsTopLevelRunsPath(selectedTenantId), + selectedTenantId === null || mode === "landing" || mode === "workbench" + ? "" + : insightsTopLevelRunsPath(selectedTenantId), TopLevelRunsSchema, ); - const routines = useTenantQuery( - selectedTenantId === null - ? ["tenant", "none", "routines"] - : tenantKeys.routines(selectedTenantId), - selectedTenantId !== null, - () => listScheduledWorkflows(selectedTenantId as string), - ); - const routinesForPage: APIQuery = - selectedTenantId === null ? { kind: "ready", data: [] } : routines; - - const runsForPage: APIQuery<{ - data: readonly InsightsRun[]; - nextCursor: string | null; - }> = selectedTenantId === null ? { kind: "ready", data: { data: [], nextCursor: null } } : runs; + if (mode === "landing") return ; if (mode === "workbench" && workbenchId !== null) { return ( @@ -788,19 +513,15 @@ export function InsightsRoute({ path }: { readonly path?: string }) { ); } - return ( - - ); + const runsForPage: APIQuery<{ + data: readonly InsightsRun[]; + nextCursor: string | null; + }> = selectedTenantId === null ? { kind: "ready", data: { data: [], nextCursor: null } } : runs; + + return ; } -/** Resolves the workbench-scoped route's own workbench list — split out of - * `InsightsRoute` so the landing/runs/run-detail modes above never pay for - * a workbench-list fetch they don't need. */ +/** Resolves the workbench-scoped route's own workbench list. */ function InsightsWorkbenchPageRoute({ workbenchId, benchTenantId, @@ -814,6 +535,7 @@ function InsightsWorkbenchPageRoute({ const resolution = resolveWorkbenchInsightsScope(workbenches, workbenchId); return (