From 44dec998bcf422f6a3eb30762ab198a862a34f2c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 17:39:28 -0700 Subject: [PATCH 1/2] Add tests for first-run footer rail omitting Plugins, Insights, and Evals The first-run footer is Routines, Files, Skills, and Agents. Plugins stays off that rail; Insights and Evals join only when existing usage/eval-run reads return real items. All three remain reachable by URL and palette. --- apps/web/test/routes.test.tsx | 28 ++--- apps/web/test/sidebar.test.tsx | 219 +++++++++++++++++++++++++++++---- 2 files changed, 211 insertions(+), 36 deletions(-) diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index 5ffcd895..f37332c1 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -116,12 +116,14 @@ function stagePageTitle(markup: string): string | undefined { return /class="stage-crumb-current"[^>]*>([^<]*)]*aria-current="page"[^>]*>([\s\S]*?)<\/button>/.exec( @@ -136,9 +138,6 @@ const FOOTER_LABELS: Record = { "/files": "Files", "/skills": "Skills", "/agents": "Agents", - "/plugins": "Plugins", - "/insights": "Insights", - "/evals": "Evals", }; describe("route table", () => { @@ -167,12 +166,13 @@ describe("route table", () => { ]); }); - test("palette pages are Routines, Files, Skills, Agents, Insights, Evals, Settings", () => { + test("palette pages are Routines, Files, Skills, Agents, Plugins, Insights, Evals, Settings", () => { expect(NAV_ROUTES.map((route) => route.label)).toEqual([ "Routines", "Files", "Skills", "Agents", + "Plugins", "Insights", "Evals", "Settings", @@ -369,12 +369,12 @@ describe("routes render", () => { }); test.each([["/plugins/linear", "linear", "Plugins"]])( - "%s titles the detail placeholder %s with its roster row lit", - async (path, slug, footerLabel) => { + "%s titles the detail placeholder %s without lighting a Plugins footer row", + async (path, slug, rosterLabel) => { const markup = await renderApp(path); expect(stagePageTitle(markup)).toBe(slug); - expect(markup).toContain(`Back to ${footerLabel}`); - expect(activeFooterLabel(markup)).toBe(footerLabel); + expect(markup).toContain(`Back to ${rosterLabel}`); + expect(activeFooterLabel(markup)).toBeUndefined(); }, ); diff --git a/apps/web/test/sidebar.test.tsx b/apps/web/test/sidebar.test.tsx index e79b1a75..8064dcf1 100644 --- a/apps/web/test/sidebar.test.tsx +++ b/apps/web/test/sidebar.test.tsx @@ -9,6 +9,7 @@ import { createRoot } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { BenchProvider } from "../src/bench-context"; +import { APP_ROUTES, NAV_ROUTES } from "../src/routes"; import { Sidebar } from "../src/shell/sidebar"; import { TestQueryProvider } from "./test-query-provider"; @@ -45,14 +46,48 @@ const membership = { nextCursor: null, }; -function json(body: unknown): Response { +function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { - status: 200, + status, headers: { "content-type": "application/json" }, }); } -function stubFetch(): void { +const emptyTokens = { + input: 0, + cacheRead: 0, + cacheWrite: 0, + output: 0, + thinking: 0, + total: 0, +}; + +function usageBody(turns: number): unknown { + return { + turns, + tokens: { ...emptyTokens, total: turns }, + costUsd: turns > 0 ? 1.25 : 0, + byModel: [], + }; +} + +const oneEvalRun = { + id: "evalrun_1", + evalName: "factory", + evalDescription: null, + configName: "default", + startedAt: "2026-01-01T00:00:00.000Z", + finishedAt: "2026-01-01T00:02:00.000Z", + stepCount: 1, + scorerTally: { passed: 1, failed: 0, skipped: 0 }, +}; + +function stubFetch(options?: { + readonly usageTurns?: number; + readonly evalRuns?: boolean; + readonly failUsage?: boolean; + readonly failEvals?: boolean; +}): void { globalThis.fetch = ((input: RequestInfo | URL) => { const path = typeof input === "string" ? input : String(input); if (path.includes("/api/me/principals")) @@ -61,10 +96,70 @@ function stubFetch(): void { return Promise.resolve(json({ data: [], nextCursor: null })); if (path.includes("/agent-definitions/visible")) return Promise.resolve(json({ definitions: [] })); + if (path.includes("/insights/usage")) { + if (options?.failUsage === true) + return Promise.resolve(json({ error: "unavailable" }, 500)); + return Promise.resolve(json(usageBody(options?.usageTurns ?? 0))); + } + if (path.includes("/eval-runs/runs")) { + if (options?.failEvals === true) + return Promise.resolve(json({ error: "unavailable" }, 500)); + return Promise.resolve( + json({ runs: options?.evalRuns === true ? [oneEvalRun] : [] }), + ); + } return Promise.resolve(json({ items: [] })); }) as typeof fetch; } +function footerRowLabelsFromMarkup(markup: string): string[] { + return [...markup.matchAll(/shell-sidebar-footer-row[\s\S]*?([^<]*)<\/span>/g)].map( + (match) => match[1] ?? "", + ); +} + +function footerRowLabelsFromDom(container: HTMLElement): string[] { + return [...container.querySelectorAll(".shell-sidebar-footer-row")].map( + (row) => row.querySelector("span")?.textContent ?? "", + ); +} + +async function flush(ticks = 40): Promise { + for (let i = 0; i < ticks; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +async function mountSidebar( + path: string, + onNavigate: (to: string) => void = noop, +): Promise<{ + container: HTMLDivElement; + root: ReturnType; +}> { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + + + + + , + ); + }); + await flush(60); + return { container, root }; +} + describe("Sidebar", () => { test("header offers create + search; there is no collapse affordance", () => { const markup = renderSidebar("/w"); @@ -120,28 +215,70 @@ describe("Sidebar", () => { expect(markup).not.toContain("shell-rail-item"); }); - test("footer is Routines, Files, Skills, Agents, Plugins, Insights, then the account row — no Inbox", () => { + test("first-run footer rail is Routines, Files, Skills, Agents, then the account row — no Plugins, Insights, Evals, or 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<"); - expect(markup).toContain(">Plugins<"); - expect(markup).toContain(">Insights<"); + expect(footerRowLabelsFromMarkup(markup)).toEqual([ + "Routines", + "Files", + "Skills", + "Agents", + ]); expect(markup).toContain("data-ctx-account"); expect(markup).not.toContain(">Inbox<"); expect(markup).not.toContain('aria-label="Notifications"'); // Settings is its own direct control beside the account row (one // click, not buried in the account menu). expect(markup).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<"), + // Mission Control stays pinned above the rail — not a new footer + // destination. + expect(markup).toContain(">Mission Control<"); + expect(markup).toContain("shell-sidebar-mission-control"); + expect(markup.indexOf("shell-sidebar-mission-control")).toBeLessThan( + markup.indexOf("shell-sidebar-footer-row"), + ); + expect(markup.indexOf(">Routines<")).toBeLessThan(markup.indexOf(">Files<")); + expect(markup.indexOf(">Files<")).toBeLessThan(markup.indexOf(">Skills<")); + expect(markup.indexOf(">Skills<")).toBeLessThan(markup.indexOf(">Agents<")); + }); + + test("first-run footer rail does not list Evals or Insights before there is honest usage", async () => { + stubFetch({ usageTurns: 0, evalRuns: false }); + const { container, root } = await mountSidebar("/w"); + expect(footerRowLabelsFromDom(container)).toEqual([ + "Routines", + "Files", + "Skills", + "Agents", + ]); + act(() => root.unmount()); + container.remove(); + }); + + test("Plugins is not presented as a first-run tour destination", () => { + const onPlugins = renderSidebar("/plugins"); + expect(footerRowLabelsFromMarkup(onPlugins)).toEqual([ + "Routines", + "Files", + "Skills", + "Agents", + ]); + expect(onPlugins).not.toContain(">Plugins<"); + expect(onPlugins).not.toMatch( + /shell-sidebar-footer-row"[^>]*aria-current="page"/, ); }); + test("Evals, Insights, and Plugins remain reachable by URL and command palette", () => { + const palettePaths = NAV_ROUTES.map((route) => route.path); + const routedPaths = APP_ROUTES.map((route) => route.path); + expect(palettePaths).toContain("/evals"); + expect(palettePaths).toContain("/insights"); + expect(palettePaths).toContain("/plugins"); + expect(routedPaths).toContain("/evals"); + expect(routedPaths).toContain("/insights"); + expect(routedPaths).toContain("/plugins"); + }); + test("marks the Routines row current for its own route only", () => { const onRoutines = renderSidebar("/routines"); expect(onRoutines).toMatch( @@ -151,15 +288,53 @@ describe("Sidebar", () => { expect(elsewhere).not.toMatch(/>Routines<[\s\S]{0,80}aria-current="page"/); }); - test("marks the Plugins row current for its own route only", () => { - const onPlugins = renderSidebar("/plugins"); - expect(onPlugins).toMatch( - /shell-sidebar-footer-row"[^>]*aria-current="page"/, + test("Insights joins the footer rail only when usage has turns", async () => { + stubFetch({ usageTurns: 4, evalRuns: false }); + const { container, root } = await mountSidebar("/insights"); + expect(footerRowLabelsFromDom(container)).toEqual([ + "Routines", + "Files", + "Skills", + "Agents", + "Insights", + ]); + const insights = [...container.querySelectorAll(".shell-sidebar-footer-row")].find( + (row) => row.textContent?.includes("Insights") === true, ); - const elsewhere = renderSidebar("/w"); - expect(elsewhere).not.toMatch( - /shell-sidebar-footer-row"[^>]*aria-current="page"/, + expect(insights?.getAttribute("aria-current")).toBe("page"); + act(() => root.unmount()); + container.remove(); + }); + + test("Evals joins the footer rail only when eval runs exist", async () => { + stubFetch({ usageTurns: 0, evalRuns: true }); + const { container, root } = await mountSidebar("/evals"); + expect(footerRowLabelsFromDom(container)).toEqual([ + "Routines", + "Files", + "Skills", + "Agents", + "Evals", + ]); + const evals = [...container.querySelectorAll(".shell-sidebar-footer-row")].find( + (row) => row.textContent?.includes("Evals") === true, ); + expect(evals?.getAttribute("aria-current")).toBe("page"); + act(() => root.unmount()); + container.remove(); + }); + + test("a failed usage or evals probe omits the row rather than claiming usage", async () => { + stubFetch({ failUsage: true, failEvals: true }); + const { container, root } = await mountSidebar("/w"); + expect(footerRowLabelsFromDom(container)).toEqual([ + "Routines", + "Files", + "Skills", + "Agents", + ]); + act(() => root.unmount()); + container.remove(); }); // CL-6178: the global pages (Plugins, Insights) used to be the one place From 6caaf118eb45cf464fad8b26c0af739160f61486 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 18:06:31 -0700 Subject: [PATCH 2/2] Omit Plugins Insights and Evals from the first-run footer rail First-run footer stays Routines, Files, Skills, and Agents. Plugins stays on URL and the command palette. Insights and Evals appear on the rail only when existing usage and eval-run reads return real items. --- apps/web/src/routes.tsx | 30 +++++----- apps/web/src/shell/sidebar.tsx | 105 ++++++++++++++++++--------------- apps/web/test/sidebar.test.tsx | 24 ++++---- 3 files changed, 87 insertions(+), 72 deletions(-) diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 54ac0163..91a31700 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -1,13 +1,14 @@ // The route table: one entry per screen, consumed by the command palette // (label) and the route switch (render), so navigation and pages cannot // drift apart. The sidebar itself lists workbenches (conversations), not -// routes — Files, Skills, Agents, Plugins, Insights, and Settings are -// reached from its footer, and everything here also stays reachable by -// deep link and the palette. Conversation deep links (`/w/:workbenchId`) -// stay routable; `/` is the Myra land hop (ensure + open her conversation) -// for a bench with a workbench already, or the guided first-workbench -// describe screen for a bench with none (CL-6104) — never a Home -// dashboard. +// routes — the first-run footer reaches Routines, Files, Skills, and +// Agents; Insights and Evals join that rail only given honest usage. +// Plugins, Insights, Evals, and Settings stay reachable by deep link and +// the palette even when they are off the rail. Conversation deep links +// (`/w/:workbenchId`) stay routable; `/` is the Myra land hop (ensure + +// open her conversation) for a bench with a workbench already, or the +// guided first-workbench describe screen for a bench with none (CL-6104) +// — never a Home dashboard. // Approvals has no page — the Activity band owns them. Agents (CL-6354) // and Skills (CL-6355) are their own rail destinations again — they spent // a stretch as Settings sections (CL-5990) and `/settings/agents[/:id]` / @@ -124,7 +125,7 @@ export const SETTINGS_PATH = "/settings"; * Navigation section), reachable by direct URL and the command palette * like everything else, but deliberately off `NAV_ROUTES`: it isn't a * roster to browse, it's the one destination the sidebar always pins in - * view, the same way Plugins stays reachable without joining that list. */ + * view. */ export const MISSION_CONTROL_PATH = "/mission-control"; /** The template picker (CL-6342) — every "+ New workbench" affordance @@ -410,9 +411,8 @@ export const APP_ROUTES: readonly AppRoute[] = [ ), }, { - // Reached from the sidebar footer and by deep link, never from - // `NAV_ROUTES` — Plugins is deliberately absent from the palette's - // Pages group. + // Reached by deep link and the command palette's Pages group — never + // from the first-run footer rail. path: "/plugins", label: "Plugins", icon: , @@ -440,15 +440,17 @@ function routesInOrder(paths: readonly string[]): readonly AppRoute[] { /** * Everything the command palette treats as a product destination (its - * "Pages" group). The sidebar footer reaches Files / Skills / Agents / - * Plugins / Insights / Settings directly; the rest are palette- and - * deep-link-reachable. + * "Pages" group). The first-run sidebar footer reaches Routines / Files / + * Skills / Agents (and Insights / Evals only given honest usage); + * Plugins, Insights, Evals, and Settings stay palette- and + * deep-link-reachable even when they are off the rail. */ export const NAV_ROUTES: readonly AppRoute[] = routesInOrder([ "/routines", "/files", "/skills", "/agents", + "/plugins", "/insights", EVALS_PATH_PREFIX, SETTINGS_PATH, diff --git a/apps/web/src/shell/sidebar.tsx b/apps/web/src/shell/sidebar.tsx index 00b41b86..befd02d4 100644 --- a/apps/web/src/shell/sidebar.tsx +++ b/apps/web/src/shell/sidebar.tsx @@ -1,16 +1,17 @@ // The one sidebar. Header: the brand mark, then create + search. Body: the // workbench list — nothing page-scoped ever renders here. Footer: the -// utility icon row (Files, Skills, Agents, Plugins, Insights, Evals — -// CL-6353/CL-6354/CL-6355 moved the first three out of Settings and onto -// this row; CL-6465 added Evals alongside Insights), and below it the -// account row — -// avatar + name, the whole row is the trigger for a menu that pops upward -// with weekly usage, settings, feedback, and log out. Always present; -// there is no collapse affordance and no second nav column. Approvals -// belong in the conversation, not as a standing band here. +// first-run rail is Routines, Files, Skills, Agents; Insights and Evals +// join only when the existing usage / eval-run reads return real items +// (never a fabricated row, never a new analytics store). Plugins is +// reachable by URL and the command palette, not as a first-run tour +// destination. Below the rail: the account row — avatar + name, the whole +// row is the trigger for a menu that pops upward with weekly usage, +// settings, feedback, and log out. Always present; there is no collapse +// affordance and no second nav column. Approvals belong in the +// conversation, not as a standing band here. // // Inbox is gone (CL-6151, owner decision: tasks + approvals don't flow -// into workbenches) — Insights took its footer slot instead. +// into workbenches). // // No bench switcher (CL-6089): a workbench IS an agent conversation now, // one per account, so there is nothing to switch between in the common @@ -40,7 +41,6 @@ import { Lightning, ListBullets, Plus, - PuzzlePiece, Robot, SignOut, Repeat, @@ -60,6 +60,7 @@ import { import webPackage from "../../package.json"; import { useAPIQuery } from "../api"; import { useBench } from "../bench-context"; +import { EvalRunsResponseSchema, evalRunsPath } from "../evals-api"; import { OverallUsageSchema, insightsUsagePath } from "../insights-api"; import { matchesRoute, @@ -124,6 +125,20 @@ export function Sidebar({ readonly onNavigate: (to: string) => void; readonly onSignOut: () => void; }) { + const { selectedTenantId } = useBench(); + const range = useMemo(() => createInsightsWindow(), []); + const usageQuery = useAPIQuery( + selectedTenantId === null ? "" : insightsUsagePath(selectedTenantId, range), + OverallUsageSchema, + ); + const evalsQuery = useAPIQuery( + selectedTenantId === null ? "" : evalRunsPath(selectedTenantId, null), + EvalRunsResponseSchema, + ); + const showInsights = usageQuery.kind === "ready" && usageQuery.data.turns > 0; + const showEvals = + evalsQuery.kind === "ready" && evalsQuery.data.runs.length > 0; + return ( {/* Mission Control is pinned above the footer rail as its own row - (DESIGN.md's Shell & Navigation) — not a 7th button inside the - rail below, which stays Routines/Files/Skills/Agents/Plugins/ - Insights exactly as it was. */} + (DESIGN.md's Shell & Navigation) — not a button inside the + first-run rail, which stays Routines/Files/Skills/Agents. */}
- - - + {showInsights ? ( + + ) : null} + {showEvals ? ( + + ) : null}
diff --git a/apps/web/test/sidebar.test.tsx b/apps/web/test/sidebar.test.tsx index 8064dcf1..b86abb03 100644 --- a/apps/web/test/sidebar.test.tsx +++ b/apps/web/test/sidebar.test.tsx @@ -113,9 +113,11 @@ function stubFetch(options?: { } function footerRowLabelsFromMarkup(markup: string): string[] { - return [...markup.matchAll(/shell-sidebar-footer-row[\s\S]*?([^<]*)<\/span>/g)].map( - (match) => match[1] ?? "", - ); + return [ + ...markup.matchAll( + /shell-sidebar-footer-row[\s\S]*?([^<]*)<\/span>/g, + ), + ].map((match) => match[1] ?? ""); } function footerRowLabelsFromDom(container: HTMLElement): string[] { @@ -236,7 +238,9 @@ describe("Sidebar", () => { expect(markup.indexOf("shell-sidebar-mission-control")).toBeLessThan( markup.indexOf("shell-sidebar-footer-row"), ); - expect(markup.indexOf(">Routines<")).toBeLessThan(markup.indexOf(">Files<")); + expect(markup.indexOf(">Routines<")).toBeLessThan( + markup.indexOf(">Files<"), + ); expect(markup.indexOf(">Files<")).toBeLessThan(markup.indexOf(">Skills<")); expect(markup.indexOf(">Skills<")).toBeLessThan(markup.indexOf(">Agents<")); }); @@ -298,9 +302,9 @@ describe("Sidebar", () => { "Agents", "Insights", ]); - const insights = [...container.querySelectorAll(".shell-sidebar-footer-row")].find( - (row) => row.textContent?.includes("Insights") === true, - ); + const insights = [ + ...container.querySelectorAll(".shell-sidebar-footer-row"), + ].find((row) => row.textContent?.includes("Insights") === true); expect(insights?.getAttribute("aria-current")).toBe("page"); act(() => root.unmount()); container.remove(); @@ -316,9 +320,9 @@ describe("Sidebar", () => { "Agents", "Evals", ]); - const evals = [...container.querySelectorAll(".shell-sidebar-footer-row")].find( - (row) => row.textContent?.includes("Evals") === true, - ); + const evals = [ + ...container.querySelectorAll(".shell-sidebar-footer-row"), + ].find((row) => row.textContent?.includes("Evals") === true); expect(evals?.getAttribute("aria-current")).toBe("page"); act(() => root.unmount()); container.remove();