Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions apps/web/src/routes.tsx
Original file line number Diff line number Diff line change
@@ -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]` /
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: <SquaresFour />,
Expand Down Expand Up @@ -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,
Expand Down
105 changes: 57 additions & 48 deletions apps/web/src/shell/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -40,7 +41,6 @@ import {
Lightning,
ListBullets,
Plus,
PuzzlePiece,
Robot,
SignOut,
Repeat,
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
<SidebarPanel
className="shell-sidebar"
Expand Down Expand Up @@ -153,9 +168,8 @@ export function Sidebar({
</SidebarPanelBody>

{/* 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. */}
<div className="shell-sidebar-mission-control">
<button
type="button"
Expand All @@ -174,11 +188,12 @@ export function Sidebar({
</div>

<SidebarPanelFooter>
{/* Footer order: Routines, Files, Skills, Agents, Plugins, Insights,
Evals, 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. */}
{/* Footer order: Routines, Files, Skills, Agents, then Insights and
Evals only when those existing reads prove real items, 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. */}
<button
type="button"
className="shell-sidebar-footer-row"
Expand Down Expand Up @@ -219,36 +234,30 @@ export function Sidebar({
<Robot />
<span>Agents</span>
</button>
<button
type="button"
className="shell-sidebar-footer-row"
data-active={matchesRoute("/plugins", path) ? "true" : undefined}
aria-current={matchesRoute("/plugins", path) ? "page" : undefined}
onClick={() => onNavigate("/plugins")}
>
<PuzzlePiece />
<span>Plugins</span>
</button>
<button
type="button"
className="shell-sidebar-footer-row"
data-active={matchesRoute("/insights", path) ? "true" : undefined}
aria-current={matchesRoute("/insights", path) ? "page" : undefined}
onClick={() => onNavigate("/insights")}
>
<ChartBar />
<span>Insights</span>
</button>
<button
type="button"
className="shell-sidebar-footer-row"
data-active={matchesRoute("/evals", path) ? "true" : undefined}
aria-current={matchesRoute("/evals", path) ? "page" : undefined}
onClick={() => onNavigate("/evals")}
>
<ListBullets />
<span>Evals</span>
</button>
{showInsights ? (
<button
type="button"
className="shell-sidebar-footer-row"
data-active={matchesRoute("/insights", path) ? "true" : undefined}
aria-current={matchesRoute("/insights", path) ? "page" : undefined}
onClick={() => onNavigate("/insights")}
>
<ChartBar />
<span>Insights</span>
</button>
) : null}
{showEvals ? (
<button
type="button"
className="shell-sidebar-footer-row"
data-active={matchesRoute("/evals", path) ? "true" : undefined}
aria-current={matchesRoute("/evals", path) ? "page" : undefined}
onClick={() => onNavigate("/evals")}
>
<ListBullets />
<span>Evals</span>
</button>
) : null}

<div className="shell-sidebar-account-row">
<Menu>
Expand Down
28 changes: 14 additions & 14 deletions apps/web/test/routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,14 @@ function stagePageTitle(markup: string): string | undefined {
return /class="stage-crumb-current"[^>]*>([^<]*)</.exec(markup)?.[1];
}

/** The sidebar footer marks its own destination current: Plugins and
* Insights are text rows with `aria-current="page"` on the lit one.
* Settings lives in the account menu, so its route lights nothing in the
* chrome - the stage title carries it. Returns the active row's label so
* tests confirm the *right* footer affordance lights, and nothing else
* does. */
/** The first-run footer rail marks its own destination current: Routines,
* Files, Skills, and Agents are text rows with `aria-current="page"` on
* the lit one. Insights and Evals join only given honest usage, and
* Plugins is never a first-run tour destination — those three stay
* reachable by URL and palette instead. Settings lives beside the
* account row, so its route lights nothing in the chrome - the stage
* title carries it. Returns the active row's label so tests confirm the
* *right* footer affordance lights, and nothing else does. */
function activeFooterLabel(markup: string): string | undefined {
const lit =
/shell-sidebar-footer-row"[^>]*aria-current="page"[^>]*>([\s\S]*?)<\/button>/.exec(
Expand All @@ -136,9 +138,6 @@ const FOOTER_LABELS: Record<string, string> = {
"/files": "Files",
"/skills": "Skills",
"/agents": "Agents",
"/plugins": "Plugins",
"/insights": "Insights",
"/evals": "Evals",
};

describe("route table", () => {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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();
},
);

Expand Down
Loading
Loading