shell-redesign V1 attempt-3 Phase 2: Shell chrome refactor (Sidebar GONE)

Phase 2 of V1 shell redesign attempt-3, per qitem-20260506184039-61beee6c
from orch-lead@openrig-velocity. Drift-disclosed structured-ACK + GO at
substrate/shared-docs/openrig-work/missions/recursive-self-improvement-v2/
shell-redesign-v1/attempt-3-driver-3-phase-2-ack.md.

⭐ HEADLINE — Sidebar.tsx deletion is the load-bearing structural fix
attempts 1+2 missed. SC-1 mandates exactly 2 left chromes on desktop;
Sidebar surviving alongside the new chrome is exactly what made attempt-2
fail. Phase 2 attempt-3 deletes it entirely. Runtime verified via
agent-browser at 1440x900: document.querySelectorAll("nav, aside").length === 2.

Universal shell chrome implemented per universal-shell.md L13–L34:
- 48px LEFT RAIL (<nav data-testid="app-rail">) with 6 destination icons
  (Dashboard / Topology / For You / Project / Specs / Settings) + 2 chat
  icons (Advisor / Operator V1 placeholders per agent-chat-surface.md
  L45–L52). Order matches universal-shell.md L42–L48. NO discovery icon.
- 280px EXPLORE SIDEBAR (<aside data-testid="explorer">) with surface-routed
  body per universal-shell.md L62 — surface union "topology | project |
  specs | for-you | none". Phase 2 lays the union; Phase 3 fills tree
  contents.
- CENTER WORKSPACE renders Outlet; Phase 2 routes mount canon destinations
  as VellumCard placeholders.
- CONTENT DRAWER (SharedDetailDrawer) wraps panels in VellumSheet (Phase 1
  primitive); default-closed (selection=null returns null); ~720px wide
  via VellumSheet width="wide"; user-closable [×]; SC-6 satisfied.
- Mobile collapse (<lg = 1024px): rail collapses to top-bar menu; rail
  content moves into slide-over tray rendered ONLY at narrow viewport
  (conditional render so desktop DOM doesn't carry an offscreen <nav>
  that fails the SC-1 strict count check). SC-8 scaffolding in place.

DRIFT P2-A resolution: WorkspacePage.tsx PRESERVED (30-line layout
primitive used by 13 keep-listed surfaces). Code-map updated upstream.

DRIFT P2-B resolution: SpecsWorkspace.tsx PRESERVED Option A. Provider +
hook used by AppShell + 5 keep-listed forms. Only SpecsPanel.tsx (the
actual destination chrome) is deleted; the state provider stays.

DRIFT P2-C resolution: MissionControlSurface.tsx shell-and-stub —
removed view imports, replaced body with EmptyState placeholder. The
/mission-control route is deleted per SC-18 (redirects to /for-you), so
the placeholder is unreachable; Phase 3 refactors the stub body in-place
into the For You feed render shape.

Phase 1 advisory carry-forwards landed (review-lead's 3 items):
1. VellumCard href: NO Link wrapper in primitive. Rail consumers compose
   TanStack <Link> directly.
2. VellumCard focus-visible: ADD when href|onClick present
   (focus-visible:outline focus-visible:outline-2 focus-visible:outline-stone-900
   focus-visible:outline-offset-2). +3 tests covering interactive vs static.
3. SectionHeader emphasis tone SPLIT: "emphasis" → "alert" (tertiary red)
   + new "strong" (stone-900 + font-bold). Resolves semantic ambiguity.

Routes refactored (canon shape):
- New: /, /topology, /topology/{rig|pod|seat}/$..., /for-you, /project,
  /project/{mission|slice}/$..., /specs, /specs/applications,
  /specs/$specKind/$specName, /settings, /search.
- Redirects from deleted routes: /mission-control → /for-you (SC-18);
  /context → /topology (SC-25); /slices, /slices/$name → /project; /progress,
  /steering → /project (per project-tree.md L114 + code-map).
- Auto-open-drawer bridges (DiscoveryRouteBridge / SpecsRouteBridge /
  RigSpecReviewRoute / AgentSpecReviewRoute / LibraryReviewRoute) removed
  per SC-6 — drawer opens only on named triggers, never on route load.
- Daemon /api/* UNTOUCHED (SC-29).

Deletions (22 source files):
- Sidebar.tsx (LOAD-BEARING fix)
- SpecsPanel.tsx (specs detail mounts in CENTER per content-drawer.md)
- WorkspaceHome.tsx (Project destination replaces, Phase 3)
- context/ContextWorkspace.tsx (SC-25 — relocates to topology table)
- progress/ProgressWorkspace.tsx (folds into Project tabs Phase 3)
- steering/{Health,InMotion,LaneRails,LoopState,PriorityStack,RoadmapRail}Panel.tsx
  + SteeringWorkspace.tsx (folds into Project workspace > overview Phase 3)
- slices/{SliceExplorerPanel, SliceStoryView}.tsx (Project tree replaces)
- mission-control/views/{ActiveWork, Fleet, Generic, HumanGate, MyQueue,
  RecentObservations, RecentShips, RecentlyActive}View.tsx (8 files;
  semantics fold into For You feed Phase 3)
- AuditHistoryView.tsx PRESERVED (Phase 3 /search mount).

Test cleanup (9 orphaned tests deleted; targeting deleted source):
- context-workspace, mission-control-surface, rig-expansion-ui,
  slice-explorer-lens, slice-story-view-surface, spec-library-management-ui,
  specs-panel, steering-panels, workspace-home test files.
- app-shell.test.tsx fully rewritten (24 tests covering SC-1 strict count,
  SC-2 rail roster, SC-6 drawer default-closed, SC-7 Settings center,
  SC-8 mobile collapse, surface routing, active rail state, legacy
  buttons absent).

Verification gates:
- pnpm exec tsc --noEmit: clean
- pnpm test: 593/594 PASS (single failure pre-existing on baseline,
  test/library-review.test.tsx > Send-to-seat modal — verified unrelated
  to Phase 2 via git stash on baseline)
- pnpm build: ✓ built in 2.11s; JS bundle 957kB → 826kB (-131kB by
  deleting legacy chrome)
- agent-browser visual walk at 1440x900: 2 left chromes (rail + explore);
  rail icons rendered in canonical order; topology rail active at
  /topology; drawer NOT visible at any default route; paper-grid background
  preserved.

Founder walk packet authored at:
substrate/shared-docs/openrig-work/missions/recursive-self-improvement-v2/
shell-redesign-v1/founder-walks/phase-2-walk.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-06 12:08:54 -07:00
1 parent 609e525436
commit 5f53d31ec3
41 files changed
+1091 -5503

No files matched your search

+378 -313
View File
@@ -1,6 +1,46 @@
import { type CSSProperties, type ReactNode, useCallback, useEffect, useState, createContext, useContext } from "react";
// V1 attempt-3 Phase 2 — AppShell chrome.
//
// Universal shell per universal-shell.md L13–L34 (LAYOUT CONTRACT):
// rail (48px) + explore sidebar (280px) + center workspace (flex) +
// content drawer (~720px when open / 0 closed).
//
// Phase 2 deletes Sidebar.tsx (the load-bearing structural fix attempts
// 1+2 missed) and lays the canonical rail with 6 destination icons +
// 2 chat icons (Advisor, Operator V1 placeholders per
// agent-chat-surface.md L45–L52).
//
// Phase 3 fills tree contents in Explorer; Phase 4 wires drawer viewers
// + chat icon click behavior to the configured advisor/operator seats.
//
// SC-1 satisfied: exactly 2 left chromes on desktop (rail + explore).
// SC-2 satisfied: rail order Dashboard / Topology / For You / Project /
// Specs / Settings + Advisor + Operator (no discovery in rail).
// SC-7 satisfied: Settings mounts in CENTER (rail icon → /settings route,
// not a drawer toggle).
// SC-8 satisfied: mobile rail collapses to top-bar menu; explore
// becomes slide-over.
import {
type CSSProperties,
type ReactNode,
useCallback,
useEffect,
useState,
createContext,
useContext,
type ComponentType,
} from "react";
import { Link, useRouterState } from "@tanstack/react-router";
import { CheckSquare, Cog, Compass, FileText, Folder, Gauge, Layers, SquarePlus } from "lucide-react";
import {
Brain,
Cog,
FileText,
Folder,
LayoutDashboard,
Network,
Sparkles,
Wrench,
} from "lucide-react";
import { Explorer, type ExplorerSurface } from "./Explorer.js";
import { SharedDetailDrawer, type DrawerSelection } from "./SharedDetailDrawer.js";
import { PreviewStack } from "./preview/PreviewStack.js";
@@ -8,20 +48,17 @@ import type { DiscoveryPlacementTarget } from "./DiscoveryPanel.js";
import { SpecsWorkspaceProvider } from "./SpecsWorkspace.js";
import { useActivityFeed } from "../hooks/useActivityFeed.js";
import { useGlobalEvents } from "../hooks/useGlobalEvents.js";
import { useRigSummary } from "../hooks/useRigSummary.js";
import { shortId } from "../lib/display-id.js";
import { cn } from "../lib/utils.js";
// -- Shared drawer selection context --
// =====================================================================
// Contexts (preserved per DRIFT P2-B + active consumers in Dashboard, RigGraph)
// =====================================================================
interface DrawerSelectionContextValue {
selection: DrawerSelection;
setSelection: (sel: DrawerSelection) => void;
}
interface ExplorerVisibilityContextValue {
openExplorer: () => void;
}
interface DiscoveryPlacementContextValue {
selectedDiscoveredId: string | null;
setSelectedDiscoveredId: (id: string | null) => void;
@@ -35,10 +72,6 @@ export const DrawerSelectionContext = createContext<DrawerSelectionContextValue>
setSelection: () => {},
});
export const ExplorerVisibilityContext = createContext<ExplorerVisibilityContextValue>({
openExplorer: () => {},
});
export const DiscoveryPlacementContext = createContext<DiscoveryPlacementContextValue>({
selectedDiscoveredId: null,
setSelectedDiscoveredId: () => {},
@@ -51,26 +84,214 @@ export function useDrawerSelection() {
return useContext(DrawerSelectionContext);
}
export function useExplorerVisibility() {
return useContext(ExplorerVisibilityContext);
}
export function useDiscoveryPlacement() {
return useContext(DiscoveryPlacementContext);
}
// Backward-compat alias for consumers that still use the old name
// Backward-compat alias for consumers (RigGraph.tsx) that still use the old name.
export const NodeSelectionContext = DrawerSelectionContext;
export function useNodeSelection() {
const { selection, setSelection } = useDrawerSelection();
return {
selectedNode: selection?.type === "node" ? { rigId: selection.rigId, logicalId: selection.logicalId } : null,
selectedNode:
selection?.type === "node"
? { rigId: selection.rigId, logicalId: selection.logicalId }
: null,
setSelectedNode: (node: { rigId: string; logicalId: string } | null) =>
setSelection(node ? { type: "node", rigId: node.rigId, logicalId: node.logicalId } : null),
setSelection(
node ? { type: "node", rigId: node.rigId, logicalId: node.logicalId } : null,
),
};
}
// -- AppShell --
// =====================================================================
// Rail icon roster — universal-shell.md L37–L58 + agent-chat-surface.md V1 placeholder
// =====================================================================
interface RailIconSpec {
id: string;
label: string;
to: string;
icon: ComponentType<{ className?: string }>;
/** Path prefix used for active-state matching. */
activeWhen: (pathname: string) => boolean;
testId: string;
group: "destination" | "chat";
}
// V1 default seats per agent-chat-surface.md L51–L52. Phase 4 swaps in
// ConfigStore-driven resolution (`agents.advisor_session` /
// `agents.operator_session`); Phase 2 mounts the icons with /settings
// links as functional placeholders.
const RAIL_ICONS: RailIconSpec[] = [
{
id: "dashboard",
label: "Dashboard",
to: "/",
icon: LayoutDashboard,
activeWhen: (p) => p === "/",
testId: "rail-dashboard",
group: "destination",
},
{
id: "topology",
label: "Topology",
to: "/topology",
icon: Network,
activeWhen: (p) => p.startsWith("/topology") || p.startsWith("/rigs/"),
testId: "rail-topology",
group: "destination",
},
{
id: "for-you",
label: "For You",
to: "/for-you",
icon: Sparkles,
activeWhen: (p) => p.startsWith("/for-you"),
testId: "rail-for-you",
group: "destination",
},
{
id: "project",
label: "Project",
to: "/project",
icon: Folder,
activeWhen: (p) => p.startsWith("/project"),
testId: "rail-project",
group: "destination",
},
{
id: "specs",
label: "Specs",
to: "/specs",
icon: FileText,
activeWhen: (p) => p.startsWith("/specs"),
testId: "rail-specs",
group: "destination",
},
{
id: "settings",
label: "Settings",
to: "/settings",
icon: Cog,
activeWhen: (p) => p.startsWith("/settings"),
testId: "rail-settings",
group: "destination",
},
{
id: "advisor",
label: "Advisor",
to: "/settings#agents-advisor-session",
icon: Brain,
activeWhen: () => false,
testId: "rail-advisor",
group: "chat",
},
{
id: "operator",
label: "Operator",
to: "/settings#agents-operator-session",
icon: Wrench,
activeWhen: () => false,
testId: "rail-operator",
group: "chat",
},
];
// =====================================================================
// Path → Explorer surface mapping
// =====================================================================
function surfaceForPath(pathname: string): ExplorerSurface {
if (pathname.startsWith("/topology") || pathname.startsWith("/rigs/")) return "topology";
if (pathname.startsWith("/project")) return "project";
if (pathname.startsWith("/specs")) return "specs";
if (pathname.startsWith("/for-you")) return "for-you";
return "none";
}
// =====================================================================
// Rail component
// =====================================================================
function Rail({
pathname,
onMobileClose,
vertical,
}: {
pathname: string;
onMobileClose?: () => void;
vertical: boolean;
}) {
const destinationIcons = RAIL_ICONS.filter((i) => i.group === "destination");
const chatIcons = RAIL_ICONS.filter((i) => i.group === "chat");
const renderIcon = (spec: RailIconSpec) => {
const Icon = spec.icon;
const active = spec.activeWhen(pathname);
return (
<Link
key={spec.id}
to={spec.to}
data-testid={spec.testId}
data-active={active}
aria-label={spec.label}
title={spec.label}
onClick={onMobileClose}
className={cn(
"relative flex h-10 w-10 items-center justify-center transition-colors",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-stone-900 focus-visible:outline-offset-2",
active
? "bg-stone-900 text-stone-50"
: "text-stone-700 hover:bg-stone-200 hover:text-stone-900",
)}
>
<Icon className="h-5 w-5" />
{active && (
<span
aria-hidden="true"
className="absolute left-0 top-1 bottom-1 w-[2px] bg-tertiary"
/>
)}
</Link>
);
};
return (
<nav
data-testid="app-rail"
aria-label="Primary navigation"
className={cn(
"bg-background border-stone-900 flex shrink-0",
vertical
? "w-12 flex-col items-center border-r-2 py-2 gap-1"
: "w-full flex-row items-center border-b-2 px-2 gap-1 overflow-x-auto",
)}
>
<div
className={cn(
"flex",
vertical ? "flex-col gap-1 items-center" : "flex-row gap-1 items-center",
)}
>
{destinationIcons.map(renderIcon)}
</div>
<div className={cn(vertical ? "flex-1" : "flex-1 hidden lg:block")} />
<div
className={cn(
"flex",
vertical ? "flex-col gap-1 items-center pb-1" : "flex-row gap-1 items-center",
)}
>
{chatIcons.map(renderIcon)}
</div>
</nav>
);
}
// =====================================================================
// AppShell
// =====================================================================
interface AppShellProps {
children: ReactNode;
@@ -78,119 +299,83 @@ interface AppShellProps {
const WIDE_LAYOUT_BREAKPOINT = 1024;
function parseCurrentRigId(pathname: string): string | null {
const match = pathname.match(/^\/rigs\/([^/]+)/);
return match?.[1] ?? null;
}
function resolveSurfaceTitle(pathname: string, rigId: string | null, rigName: string | null): string | null {
if (pathname === "/") return null;
if (rigId) return rigName ?? shortId(rigId, 8);
if (pathname.startsWith("/discovery")) return "Discovery";
if (
pathname === "/specs" ||
pathname.startsWith("/specs/") ||
pathname.startsWith("/packages") ||
pathname === "/import" ||
pathname === "/bootstrap" ||
pathname === "/agents/validate"
) return "Specs";
if (pathname.startsWith("/bundles/inspect")) return "Bundle Inspector";
if (pathname.startsWith("/bundles/install")) return "Bundle Install";
if (pathname.startsWith("/slices")) return "Slices";
if (pathname.startsWith("/progress")) return "Progress";
if (pathname.startsWith("/files")) return "Files";
if (pathname.startsWith("/steering")) return "Steering";
if (pathname.startsWith("/context")) return "Context";
return null;
}
export function AppShell({ children }: AppShellProps) {
const routerState = useRouterState();
const pathname = routerState.location.pathname;
const currentRigId = parseCurrentRigId(pathname);
const { data: rigs } = useRigSummary();
const [sidebarOpen, setSidebarOpen] = useState(false);
const surface = surfaceForPath(pathname);
const [explorerOpen, setExplorerOpen] = useState(false); // mobile slide-over state
const [desktopExplorerOpen, setDesktopExplorerOpen] = useState(true);
const [isWideLayout, setIsWideLayout] = useState(() => {
if (typeof window === "undefined") return true;
return window.innerWidth >= WIDE_LAYOUT_BREAKPOINT;
});
const { events } = useActivityFeed();
const [selectionState, setSelectionState] = useState<DrawerSelection>(null);
const [selectedDiscoveredId, setSelectedDiscoveredIdState] = useState<string | null>(null);
const [placementTarget, setPlacementTargetState] = useState<DiscoveryPlacementTarget>(null);
const [explorerSurface, setExplorerSurface] = useState<ExplorerSurface>(() => pathname.startsWith("/slices") ? "slices" : "topology");
const currentRigName = currentRigId ? (rigs?.find((rig) => rig.id === currentRigId)?.name ?? null) : null;
const surfaceTitle = resolveSurfaceTitle(pathname, currentRigId, currentRigName);
const setSelection = useCallback((next: DrawerSelection) => {
setSelectionState(next);
if (!isWideLayout && next) {
setSidebarOpen(false);
}
}, [isWideLayout]);
const openExplorer = useCallback(() => {
setDesktopExplorerOpen(true);
if (!isWideLayout) {
setSelectionState(null);
setSidebarOpen(true);
return;
}
setSidebarOpen(true);
}, [isWideLayout]);
const { events } = useActivityFeed();
const setSelection = useCallback(
(next: DrawerSelection) => {
setSelectionState(next);
if (!isWideLayout && next) {
setExplorerOpen(false);
}
},
[isWideLayout],
);
const clearPlacement = useCallback(() => {
setSelectedDiscoveredIdState(null);
setPlacementTargetState(null);
}, []);
const setSelectedDiscoveredId = useCallback((id: string | null) => {
setSelectedDiscoveredIdState(id);
setPlacementTargetState(null);
}, []);
useEffect(() => {
if (selectionState?.type !== "discovery") {
clearPlacement();
}
}, [selectionState, clearPlacement]);
useEffect(() => {
if (
selectionState?.type === "discovery" &&
placementTarget &&
currentRigId !== placementTarget.rigId
) {
setPlacementTargetState(null);
}
}, [currentRigId, placementTarget, selectionState]);
useEffect(() => {
setExplorerSurface(pathname.startsWith("/slices") ? "slices" : "topology");
}, [pathname]);
// Window resize → wide-layout flag.
useEffect(() => {
const handleResize = () => {
setIsWideLayout(window.innerWidth >= WIDE_LAYOUT_BREAKPOINT);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// Cross-route: clear discovery placement when discovery selection clears.
useEffect(() => {
const isDrawerBridgeRoute = pathname === "/specs" || pathname === "/discovery";
if (!isWideLayout && !isDrawerBridgeRoute) {
if (selectionState?.type !== "discovery") clearPlacement();
}, [selectionState, clearPlacement]);
// SC-3a — drawer content does NOT persist across reload (it's contextual).
// Mobile-narrow viewports: close drawer + close explorer when route changes
// unless the route specifically handles the drawer (none in Phase 2).
useEffect(() => {
if (!isWideLayout) {
setSelectionState(null);
setSidebarOpen(false);
setExplorerOpen(false);
}
}, [isWideLayout, pathname]);
// Mount global SSE event listener
// Mount global SSE event listener.
useGlobalEvents();
const explorerVisible = surface !== "none";
const drawerOpen = Boolean(selectionState);
// CSS var consumed by surfaces that pad themselves inside center.
// Rail: 48px (3rem). Explorer: 280px (18rem) when desktop-open, 0 when collapsed/none.
const workspaceLeftOffset = isWideLayout
? `${3 + (explorerVisible && desktopExplorerOpen ? 18 : 0)}rem`
: "0rem";
const workspaceRightOffset = isWideLayout && drawerOpen ? "45rem" : "0rem";
const workspaceStyle = {
"--workspace-left-offset": isWideLayout ? (desktopExplorerOpen ? "18rem" : "3rem") : "0rem",
"--workspace-right-offset": isWideLayout && selectionState ? "20rem" : "0rem",
"--workspace-left-offset": workspaceLeftOffset,
"--workspace-right-offset": workspaceRightOffset,
} as CSSProperties;
return (
@@ -205,226 +390,106 @@ export function AppShell({ children }: AppShellProps) {
clearPlacement,
}}
>
<ExplorerVisibilityContext.Provider value={{ openExplorer }}>
<div className="h-screen flex flex-col">
{/* Header — paper with thick bottom border */}
<header
data-testid="app-header"
className="h-14 flex items-center justify-between px-spacing-6 bg-background border-b-2 border-stone-900 shrink-0 relative z-30"
>
{surfaceTitle && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-28">
<div
data-testid="header-surface-title"
className="truncate font-mono text-sm font-semibold uppercase tracking-[0.12em] text-stone-700"
<div className="h-screen flex flex-col">
{/* Mobile top-bar (lg:hidden) — hamburger + brand. Desktop has no top-bar. */}
<header
data-testid="app-mobile-topbar"
className="h-14 flex items-center justify-between px-4 bg-background border-b-2 border-stone-900 shrink-0 relative z-30 lg:hidden"
>
<button
type="button"
data-testid="mobile-menu-toggle"
onClick={() => setExplorerOpen((open) => !open)}
aria-label="Toggle navigation"
className="flex flex-col gap-[3px] p-2"
>
{surfaceTitle}
<span className="block w-4 h-[1.5px] bg-stone-900" />
<span className="block w-4 h-[1.5px] bg-stone-900" />
<span className="block w-3 h-[1.5px] bg-stone-900" />
</button>
<Link
to="/"
data-testid="brand-home-link-mobile"
className="inline-flex items-center bg-stone-950 px-3 py-1 font-mono text-sm font-bold uppercase tracking-[0.08em] text-stone-50"
>
OPENRIG
</Link>
<span className="w-8" /> {/* spacer to balance hamburger */}
</header>
{/* Main: rail + explore + center + drawer */}
<div className="flex flex-1 min-h-0 relative">
{/* Rail — desktop only (lg:flex). Mobile rail surfaces inside the slide-over. */}
<div className="hidden lg:flex">
<Rail pathname={pathname} vertical />
</div>
{/* Mobile slide-over: rail (horizontal) + explore.
Conditionally rendered ONLY at narrow viewports so the
desktop DOM doesn't carry an offscreen <nav> that
fails the SC-1 "exactly 2 left chromes" count check. */}
{!isWideLayout && (
<>
{explorerOpen && (
<div
className="fixed inset-0 bg-black/20 z-20 lg:hidden"
onClick={() => setExplorerOpen(false)}
/>
)}
<div
data-testid="mobile-rail-tray"
className={cn(
"fixed top-14 left-0 bottom-0 z-30 bg-background border-r-2 border-stone-900 transition-transform duration-200 ease-tactical lg:hidden",
"w-72 max-w-[85vw] flex flex-col",
explorerOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<Rail pathname={pathname} vertical={false} onMobileClose={() => setExplorerOpen(false)} />
</div>
</>
)}
{/* Explorer — desktop column or mobile slide-over (Explorer.tsx handles both modes). */}
{explorerVisible && (
<Explorer
open={explorerOpen}
onClose={() => setExplorerOpen(false)}
selection={selectionState}
onSelect={setSelection}
desktopMode={desktopExplorerOpen ? "full" : "hidden"}
surface={surface}
onDesktopToggle={() => setDesktopExplorerOpen((open) => !open)}
/>
)}
{/* Center workspace */}
<main
data-testid="content-area"
className="flex-1 flex flex-col overflow-auto relative"
style={workspaceStyle}
>
<div key={pathname} className="relative z-10 route-enter flex-1 flex flex-col">
{children}
</div>
</main>
{/* Content drawer — default closed (selection===null returns null inside drawer). */}
<SharedDetailDrawer
selection={selectionState}
onClose={() => setSelection(null)}
events={events}
selectedDiscoveredId={selectedDiscoveredId}
onSelectDiscoveredId={setSelectedDiscoveredId}
placementTarget={placementTarget}
onClearPlacement={clearPlacement}
/>
{/* Preview Terminal v0 (PL-018) — pinned-preview side rail.
Self-hides when no pins; positioned right edge under the
drawer when drawer mounted. */}
<PreviewStack />
</div>
)}
<div className="flex items-center gap-spacing-4">
{/* Hamburger — narrow viewports only */}
<button
data-testid="sidebar-toggle"
onClick={() => {
if (!sidebarOpen) {
setSelectionState(null);
}
setSidebarOpen(!sidebarOpen);
}}
className="flex flex-col gap-[3px] p-1 lg:hidden"
aria-label="Toggle navigation"
>
<span className="block w-4 h-[1.5px] bg-stone-900" />
<span className="block w-4 h-[1.5px] bg-stone-900" />
<span className="block w-3 h-[1.5px] bg-stone-900" />
</button>
<Link
to="/"
data-testid="brand-home-link"
className="inline-flex items-center bg-stone-950 px-3 py-1 font-mono text-sm font-bold uppercase tracking-[0.08em] text-stone-50 transition-colors hover:bg-stone-800"
>
OPENRIG
</Link>
</div>
<div className="flex-1" />
<div className="flex items-center gap-1">
<button
type="button"
data-testid="discovery-toggle"
onClick={() => setSelection(selectionState?.type === "discovery" ? null : { type: "discovery" })}
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
selectionState?.type === "discovery"
? "text-stone-950"
: "hover:text-stone-950"
}`}
aria-label={selectionState?.type === "discovery" ? "Close discovery drawer" : "Open discovery drawer"}
title={selectionState?.type === "discovery" ? "Close discovery drawer" : "Open discovery drawer"}
>
<SquarePlus className="h-4 w-4" />
</button>
<button
type="button"
data-testid="specs-toggle"
onClick={() => setSelection(selectionState?.type === "specs" ? null : { type: "specs" })}
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
selectionState?.type === "specs"
? "text-stone-950"
: "hover:text-stone-950"
}`}
aria-label={selectionState?.type === "specs" ? "Close specs drawer" : "Open specs drawer"}
title={selectionState?.type === "specs" ? "Close specs drawer" : "Open specs drawer"}
>
<FileText className="h-4 w-4" />
</button>
{/* Slice Story View v0 — top-level link to /slices. Slices uses
the existing explorer column for its filter/list and the route
body for the six-tab story detail pane. */}
<Link
to="/slices"
data-testid="slices-link"
onClick={() => {
setExplorerSurface("slices");
setDesktopExplorerOpen(true);
if (!isWideLayout) setSidebarOpen(true);
}}
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
pathname.startsWith("/slices")
? "text-stone-950"
: "hover:text-stone-950"
}`}
aria-label="Open slices"
title="Open slices"
>
<Layers className="h-4 w-4" />
</Link>
{/* UI Enhancement Pack v0 — top-level Progress (item 1B) +
Files (items 3 + 4) workspace links. Both navigate to
their own routes with internal layouts (no left-sidebar
overlay; the route body owns its UX entirely). */}
<Link
to="/progress"
data-testid="progress-link"
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
pathname.startsWith("/progress")
? "text-stone-950"
: "hover:text-stone-950"
}`}
aria-label="Open progress"
title="Open progress"
>
<CheckSquare className="h-4 w-4" />
</Link>
<Link
to="/files"
data-testid="files-link"
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
pathname.startsWith("/files")
? "text-stone-950"
: "hover:text-stone-950"
}`}
aria-label="Open files"
title="Open files"
>
<Folder className="h-4 w-4" />
</Link>
{/* Operator Surface Reconciliation v0 — Steering Link
(item 1, HEADLINE). One-screen composed steering surface
— priority stack + roadmap + in-motion + lane rails +
loop state + health gates. */}
{/* Token / Context Usage Surface v0 (PL-012) — top-level
/context dashboard. Portfolio scan of context-usage
across all running seats. */}
<Link
to="/context"
data-testid="context-link"
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
pathname.startsWith("/context")
? "text-stone-950"
: "hover:text-stone-950"
}`}
aria-label="Open context dashboard"
title="Open context dashboard"
>
<Gauge className="h-4 w-4" />
</Link>
<Link
to="/steering"
data-testid="steering-link"
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
pathname.startsWith("/steering")
? "text-stone-950"
: "hover:text-stone-950"
}`}
aria-label="Open steering"
title="Open steering"
>
<Compass className="h-4 w-4" />
</Link>
<button
type="button"
data-testid="system-toggle"
onClick={() => setSelection(selectionState?.type === "system" ? null : { type: "system", tab: "log" })}
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
selectionState?.type === "system"
? "text-stone-950"
: "hover:text-stone-950"
}`}
aria-label={selectionState?.type === "system" ? "Close system drawer" : "Open system drawer"}
title={selectionState?.type === "system" ? "Close system drawer" : "Open system drawer"}
>
<Cog className="h-4 w-4" />
</button>
</div>
</header>
{/* Main: Explorer + Content + Detail Panel */}
<div className="flex flex-1 min-h-0 relative">
{/* Explorer overlay backdrop for mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/20 z-20 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<Explorer
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
selection={selectionState}
onSelect={setSelection}
desktopMode={desktopExplorerOpen ? "full" : "hidden"}
surface={explorerSurface}
onSurfaceChange={setExplorerSurface}
onDesktopToggle={() => setDesktopExplorerOpen((open) => !open)}
/>
<main data-testid="content-area" className="flex-1 flex flex-col overflow-auto relative" style={workspaceStyle}>
<div key={pathname} className="relative z-10 route-enter flex-1 flex flex-col">{children}</div>
</main>
{/* Detail drawer — visible when a rig or node is selected */}
<SharedDetailDrawer
selection={selectionState}
onClose={() => setSelection(null)}
events={events}
selectedDiscoveredId={selectedDiscoveredId}
onSelectDiscoveredId={setSelectedDiscoveredId}
placementTarget={placementTarget}
onClearPlacement={clearPlacement}
/>
{/* Preview Terminal v0 (PL-018) — pinned-preview side rail.
Self-hides when no pins; positioned right edge under the
detail drawer so the drawer takes precedence when both
are mounted (z-index 20 > 10). */}
<PreviewStack />
</div>
</div>
</ExplorerVisibilityContext.Provider>
</DiscoveryPlacementContext.Provider>
</DrawerSelectionContext.Provider>
</SpecsWorkspaceProvider>
+97 -59
View File
@@ -13,12 +13,23 @@ import {
getActivityAnimationClass,
shortQitemTail,
} from "../lib/activity-visuals.js";
import { SliceExplorerPanel } from "./slices/SliceExplorerPanel.js";
import { EmptyState } from "./ui/empty-state.js";
import type { DrawerSelection } from "./SharedDetailDrawer.js";
export type ExplorerDesktopMode = "full" | "hidden";
export type ExplorerSurface = "topology" | "slices";
// V1 attempt-3 Phase 2 — canon surface union per universal-shell.md L62:
// "Renders the destination's tree (or a feed lens filter chip rail for
// For You; or a flat nav for Settings; or nothing for Dashboard)."
//
// Phase 2 lays the union; Phase 3 fills tree contents + lens chips.
export type ExplorerSurface =
| "topology"
| "project"
| "specs"
| "for-you"
| "none";
interface ExplorerProps {
open: boolean;
@@ -27,7 +38,6 @@ interface ExplorerProps {
onSelect: (sel: DrawerSelection) => void;
desktopMode?: ExplorerDesktopMode;
surface?: ExplorerSurface;
onSurfaceChange?: (surface: ExplorerSurface) => void;
onDesktopToggle?: () => void;
}
@@ -465,41 +475,75 @@ function FullExplorerContents({
);
}
function ExplorerModeTabs({
// Surface-routed body. Phase 2 lays placeholders for non-topology
// surfaces; Phase 3 fills tree contents + lens chips. "none" surface
// (Dashboard / Settings) means Explorer is not rendered at all.
function SurfaceBody({
surface,
onSurfaceChange,
rigs,
psMap,
selection,
onSelect,
onClose,
currentRigId,
}: {
surface: ExplorerSurface;
onSurfaceChange: (surface: ExplorerSurface) => void;
rigs: RigSummary[] | undefined;
psMap: Map<string, PsEntry>;
selection: DrawerSelection;
onSelect: (sel: DrawerSelection) => void;
onClose: () => void;
currentRigId: string | null;
}) {
const tabs: Array<{ value: ExplorerSurface; label: string; testId: string }> = [
{ value: "topology", label: "Explore", testId: "explorer-tab-explore" },
{ value: "slices", label: "Slices", testId: "explorer-tab-slices" },
];
return (
<div data-testid="explorer-mode-tabs" className="border-b border-stone-200 p-2 pr-12">
<div className="grid grid-cols-2 gap-1">
{tabs.map((tab) => (
<button
key={tab.value}
type="button"
data-testid={tab.testId}
data-active={surface === tab.value}
onClick={() => onSurfaceChange(tab.value)}
className={cn(
"border px-2 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors",
surface === tab.value
? "border-stone-700 bg-stone-700 text-white"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
)}
>
{tab.label}
</button>
))}
if (surface === "topology") {
return (
<FullExplorerContents
rigs={rigs}
psMap={psMap}
selection={selection}
onSelect={onSelect}
onClose={onClose}
currentRigId={currentRigId}
/>
);
}
if (surface === "project") {
return (
<div className="flex-1 p-4">
<EmptyState
label="PROJECT TREE"
description="workspace > mission > slice — Phase 3 fills."
variant="card"
testId="explorer-project-placeholder"
/>
</div>
</div>
);
);
}
if (surface === "specs") {
return (
<div className="flex-1 p-4">
<EmptyState
label="SPECS TREE"
description="rigs / workspaces / workflows / context-packs / agent-images / applications — Phase 3 fills."
variant="card"
testId="explorer-specs-placeholder"
/>
</div>
);
}
if (surface === "for-you") {
return (
<div className="flex-1 p-4">
<EmptyState
label="FOR YOU LENS"
description="Lens chips + filter chips + subscriptions list — Phase 3 fills (settings-shaped subscriptions, NOT feed-shaped)."
variant="card"
testId="explorer-for-you-placeholder"
/>
</div>
);
}
return null;
}
export function Explorer({
@@ -509,7 +553,6 @@ export function Explorer({
onSelect,
desktopMode = "full",
surface = "topology",
onSurfaceChange = () => {},
onDesktopToggle = () => {},
}: ExplorerProps) {
const routerState = useRouterState();
@@ -520,16 +563,23 @@ export function Explorer({
const psMap = new Map((psEntries ?? []).map((entry) => [entry.rigId, entry]));
// Surface "none" (Dashboard / Settings) — Explorer is not rendered.
if (surface === "none") return null;
return (
<aside
data-testid="explorer"
data-surface={surface}
className={cn(
"border-r border-stone-300/25 flex z-20 overflow-hidden",
"bg-[rgba(250,249,245,0.035)] supports-[backdrop-filter]:bg-[rgba(250,249,245,0.018)] backdrop-blur-[14px] backdrop-saturate-75 shadow-[6px_0_14px_rgba(46,52,46,0.04)]",
// Mobile: slide-over from left below the top-bar header (h-14).
"fixed top-14 bottom-0 left-0 transition-transform duration-200 ease-tactical w-72 max-w-[80vw]",
open ? "translate-x-0" : "-translate-x-full",
desktopMode === "full" && "lg:absolute lg:top-0 lg:bottom-0 lg:left-0 lg:w-72 lg:max-w-none lg:translate-x-0",
desktopMode === "hidden" && "lg:absolute lg:top-0 lg:bottom-0 lg:left-0 lg:w-12 lg:max-w-none lg:translate-x-0"
// Desktop (>=lg): persistent column at 280px (lg:w-72) per universal-shell.md L34.
// Position absolutely after the 48px rail; AppShell pads its main content with workspace-left-offset.
desktopMode === "full" && "lg:absolute lg:top-0 lg:bottom-0 lg:left-12 lg:w-72 lg:max-w-none lg:translate-x-0",
desktopMode === "hidden" && "lg:hidden",
)}
>
<div className="relative flex h-full w-full flex-col">
@@ -541,33 +591,21 @@ export function Explorer({
className={cn(
"hidden lg:flex absolute z-10 h-8 w-8 items-center justify-center rounded-full border border-stone-300 bg-background/90 text-stone-700",
"shadow-[0_2px_8px_rgba(41,37,36,0.08)] backdrop-blur-sm transition-colors hover:bg-stone-100 hover:text-stone-900",
desktopMode === "full" ? "right-2 top-3" : "left-1/2 top-3 -translate-x-1/2"
"right-2 top-3",
)}
>
{desktopMode === "full" ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<ChevronLeft className="h-4 w-4" />
</button>
{desktopMode === "full" ? (
<>
<ExplorerModeTabs surface={surface} onSurfaceChange={onSurfaceChange} />
{surface === "slices" ? (
<div className="min-h-0 flex-1">
<SliceExplorerPanel onNavigate={onClose} />
</div>
) : (
<FullExplorerContents
rigs={rigs}
psMap={psMap}
selection={selection}
onSelect={onSelect}
onClose={onClose}
currentRigId={currentRigId}
/>
)}
</>
) : (
<div className="hidden h-full w-full lg:block" />
)}
<SurfaceBody
surface={surface}
rigs={rigs}
psMap={psMap}
selection={selection}
onSelect={onSelect}
onClose={onClose}
currentRigId={currentRigId}
/>
</div>
</aside>
);
@@ -1,16 +1,39 @@
// V1 attempt-3 Phase 2 — SharedDetailDrawer chrome.
//
// Per content-drawer.md L9: "default closed, opens only on named
// triggers, user-closable at any time, wider than the current
// right-sidebar (closer to document-reading width)."
//
// Phase 2 lays the chrome: VellumSheet primitive (Phase 1) wraps each
// panel; default-closed (selection === null returns null); ~720px wide
// on desktop (`lg:w-[45rem]` via VellumSheet width="wide"); user-closable
// via VellumSheet's onClose [×] button.
//
// Phase 4 wires the new viewer types (QueueItemViewer, FileViewer,
// SubSpecPreview, SeatDetailViewer) and named triggers from feed cards
// + topology table rows + spec sub-references. The current rig / node /
// system / discovery panels are preserved as transitional viewers
// (existing surfaces still set DrawerSelection.type to one of them).
//
// SC-6 (default-closed; opens only on named triggers): selection===null
// → renders null; chrome only mounts when a named selection is present.
import { RigDetailPanel } from "./RigDetailPanel.js";
import { NodeDetailPanel } from "./NodeDetailPanel.js";
import { SystemPanel } from "./SystemPanel.js";
import { DiscoveryPanel, type DiscoveryPlacementTarget } from "./DiscoveryPanel.js";
import { SpecsPanel } from "./SpecsPanel.js";
import { VellumSheet } from "./ui/vellum-sheet.js";
import type { ActivityEvent } from "../hooks/useActivityFeed.js";
// Drawer selection union. The "specs" variant from prior shells was
// removed in Phase 2 (SpecsPanel.tsx deleted; specs detail pages render
// in the CENTER workspace per content-drawer.md decision rule —
// "Full-feature page (spec detail, slice detail, mission detail) — Center").
export type DrawerSelection =
| { type: "rig"; rigId: string }
| { type: "node"; rigId: string; logicalId: string }
| { type: "system"; tab?: "log" | "status" }
| { type: "discovery" }
| { type: "specs" }
| null;
interface SharedDetailDrawerProps {
@@ -32,41 +55,50 @@ export function SharedDetailDrawer({
placementTarget,
onClearPlacement,
}: SharedDetailDrawerProps) {
// SC-6 — default-closed; chrome only mounts when a named trigger has set selection.
if (!selection) return null;
if (selection.type === "rig") {
return <RigDetailPanel rigId={selection.rigId} onClose={onClose} />;
}
const inner = (() => {
if (selection.type === "rig") {
return <RigDetailPanel rigId={selection.rigId} onClose={onClose} />;
}
if (selection.type === "node") {
return (
<NodeDetailPanel
rigId={selection.rigId}
logicalId={selection.logicalId}
onClose={onClose}
/>
);
}
if (selection.type === "system") {
return (
<SystemPanel onClose={onClose} events={events} initialTab={selection.tab ?? "log"} />
);
}
if (selection.type === "discovery") {
return (
<DiscoveryPanel
onClose={onClose}
selectedDiscoveredId={selectedDiscoveredId}
onSelectDiscoveredId={onSelectDiscoveredId}
placementTarget={placementTarget}
onClearPlacement={onClearPlacement}
/>
);
}
return null;
})();
if (selection.type === "node") {
return (
<NodeDetailPanel
rigId={selection.rigId}
logicalId={selection.logicalId}
onClose={onClose}
/>
);
}
if (selection.type === "system") {
return <SystemPanel onClose={onClose} events={events} initialTab={selection.tab ?? "log"} />;
}
if (selection.type === "discovery") {
return (
<DiscoveryPanel
onClose={onClose}
selectedDiscoveredId={selectedDiscoveredId}
onSelectDiscoveredId={onSelectDiscoveredId}
placementTarget={placementTarget}
onClearPlacement={onClearPlacement}
/>
);
}
if (selection.type === "specs") {
return <SpecsPanel onClose={onClose} />;
}
return null;
return (
<VellumSheet
edge="right"
width="wide"
onClose={onClose}
testId="shared-detail-drawer"
className="fixed top-0 right-0 z-30 h-full"
>
{inner}
</VellumSheet>
);
}
-69
View File
@@ -1,69 +0,0 @@
import { Link, useRouterState } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ to: "/", label: "RIGS", icon: "hub" },
{ to: "/packages", label: "SPECS", icon: "folder" },
{ to: "/bootstrap", label: "BOOT", icon: "play_arrow" },
{ to: "/discovery", label: "DISC", icon: "radar" },
{ to: "/import", label: "IMPORT", icon: "upload" },
] as const;
interface SidebarProps {
open: boolean;
onClose: () => void;
}
export function Sidebar({ open, onClose }: SidebarProps) {
const routerState = useRouterState();
const currentPath = routerState.location.pathname;
return (
<aside
data-testid="sidebar"
className={cn(
"w-20 bg-stone-100/80 backdrop-blur-md border-r border-stone-300 flex flex-col items-center py-spacing-6 shrink-0 z-20",
// Mobile: slide-in overlay
"fixed top-14 bottom-0 left-0 transition-transform duration-200 ease-tactical lg:relative lg:top-0 lg:translate-x-0",
open ? "translate-x-0" : "-translate-x-full"
)}
>
{/* Brand mark */}
<div className="mb-spacing-6 text-center">
<div className="text-stone-900 font-bold font-mono text-[10px] tracking-widest uppercase">
TOPOLOGY
</div>
</div>
{/* Nav items */}
<nav className="flex flex-col w-full gap-spacing-1 flex-1">
{NAV_ITEMS.map((item) => {
const isActive = item.to === "/"
? currentPath === "/" || currentPath.startsWith("/rigs")
: currentPath.startsWith(item.to);
return (
<Link
key={item.to}
to={item.to}
data-testid={`nav-${item.label.toLowerCase()}`}
aria-current={isActive ? "page" : undefined}
onClick={onClose}
className={cn(
"flex flex-col items-center py-spacing-3 gap-1 transition-all duration-75",
isActive
? "bg-background text-stone-950 border-y border-l-4 border-l-stone-900"
: "text-stone-400 hover:text-stone-900"
)}
>
<span className="material-symbols-outlined text-[20px]" style={{ fontVariationSettings: "'FILL' 0, 'wght' 300" }}>
{item.icon}
</span>
<span className="font-mono text-[8px] tracking-widest">{item.label}</span>
</Link>
);
})}
</nav>
</aside>
);
}
-566
View File
@@ -1,566 +0,0 @@
import { useState, type ReactNode } from "react";
import { useNavigate } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { useSpecsWorkspace, type SpecsDraft } from "./SpecsWorkspace.js";
import { useSpecLibrary, useLibraryReview, type SpecLibraryEntry } from "../hooks/useSpecLibrary.js";
import { useContextPackLibrary, type ContextPackEntry } from "../hooks/useContextPackLibrary.js";
import { useAgentImageLibrary, type AgentImageEntry } from "../hooks/useAgentImageLibrary.js";
import { usePsEntries } from "../hooks/usePsEntries.js";
import { useExpandRig, useRemoveLibrarySpec, useRenameLibrarySpec, type ExpandRigResult } from "../hooks/mutations.js";
import { ExpansionOutcome } from "./ExpansionOutcome.js";
import { useWorkspace, type WhoamiWorkspaceUI } from "../hooks/useWorkspace.js";
import { WorkspaceKindBadge, resolveKindForPath, type WorkspaceKindLabel } from "./WorkspaceKindBadge.js";
type LibraryFilter = "all" | "apps" | "rigs" | "agents" | "workflows" | "context-packs" | "agent-images";
// PL-014 + PL-016: unified entry shape covers spec library entries,
// context-pack entries, and agent-image entries — all filesystem-
// canonical, library-discoverable, reviewable. Discriminated by kind.
type UnifiedLibraryEntry =
| SpecLibraryEntry
| (ContextPackEntry & { summary?: string; hasServices?: undefined })
| (AgentImageEntry & { summary?: string; hasServices?: undefined });
function deriveStability(entry: UnifiedLibraryEntry): "Stable" | "Experimental" | "Community" {
if (entry.sourceType !== "builtin") return "Community";
const rp = (entry.relativePath ?? "").replaceAll("\\", "/");
if (rp.startsWith("rigs/launch/")) return "Stable";
return "Experimental";
}
function deriveTypeBadge(entry: UnifiedLibraryEntry): "APP" | "RIG" | "AGENT" | "WORKFLOW" | "PACK" | "IMAGE" {
if (entry.kind === "agent-image") return "IMAGE";
if (entry.kind === "context-pack") return "PACK";
if (entry.kind === "workflow") return "WORKFLOW";
if (entry.kind === "agent") return "AGENT";
if ((entry as SpecLibraryEntry).hasServices) return "APP";
return "RIG";
}
function filterEntries(entries: UnifiedLibraryEntry[], filter: LibraryFilter): UnifiedLibraryEntry[] {
switch (filter) {
case "apps": return entries.filter((e) => e.kind === "rig" && (e as SpecLibraryEntry).hasServices);
case "rigs": return entries.filter((e) => e.kind === "rig" && !(e as SpecLibraryEntry).hasServices);
case "agents": return entries.filter((e) => e.kind === "agent");
case "workflows": return entries.filter((e) => e.kind === "workflow");
case "context-packs": return entries.filter((e) => e.kind === "context-pack");
case "agent-images": return entries.filter((e) => e.kind === "agent-image");
default: return entries;
}
}
/** PL-007: classify a library entry by workspace kind.
* user_file → "user"; workspace entries resolved against the rig's
* RigSpec.workspace block; built-in entries return null (they ship
* with the daemon and do not belong to operator workspace topology). */
function deriveEntryWorkspaceKind(
entry: UnifiedLibraryEntry,
workspace: WhoamiWorkspaceUI | null,
): WorkspaceKindLabel | null {
if (entry.sourceType === "user_file") return "user";
if (entry.sourceType === "workspace") {
return resolveKindForPath(entry.sourcePath ?? null, workspace);
}
return null;
}
// PL-016: project AgentImageEntry into the row-render shape with a
// summary line surfacing runtime + token estimate + fork count.
function agentImageToUnified(entry: AgentImageEntry): UnifiedLibraryEntry {
const lastUsed = entry.stats.lastUsedAt
? ` · last used ${new Date(entry.stats.lastUsedAt).toLocaleDateString()}`
: "";
const summary = `${entry.runtime} · forks ${entry.stats.forkCount} · ~${entry.derivedEstimatedTokens} tokens${lastUsed}${entry.pinned ? " · pinned" : ""}`;
return { ...entry, summary };
}
// PL-014: project ContextPackEntry into the row-shape the LibraryList
// renders (adds a `summary` line surfacing files-count + token estimate).
function contextPackToUnified(entry: ContextPackEntry): UnifiedLibraryEntry {
const firstPurposeLine = entry.purpose?.split("\n")[0]?.trim();
const summary = `${entry.files.length} file${entry.files.length === 1 ? "" : "s"} · ~${entry.derivedEstimatedTokens} tokens${firstPurposeLine ? ` · ${firstPurposeLine}` : ""}`;
return { ...entry, summary };
}
interface SpecsPanelProps {
onClose: () => void;
}
function Section({
title,
description,
children,
}: {
title: string;
description: string;
children: ReactNode;
}) {
return (
<section className="border border-stone-300/28 bg-white/10 px-3 py-3">
<div className="font-mono text-[8px] uppercase tracking-[0.16em] text-stone-500">{title}</div>
<p className="mt-2 text-[11px] leading-5 text-stone-600">{description}</p>
<div className="mt-3 flex flex-wrap gap-2">
{children}
</div>
</section>
);
}
function DraftList({
title,
drafts,
onSelect,
}: {
title: string;
drafts: SpecsDraft[];
onSelect: (draftId: string) => void;
}) {
if (drafts.length === 0) return null;
return (
<div className="mt-3 w-full space-y-2">
<div className="font-mono text-[8px] uppercase tracking-[0.16em] text-stone-500">{title}</div>
<div className="w-full space-y-1">
{drafts.map((draft) => (
<button
key={draft.id}
type="button"
onClick={() => onSelect(draft.id)}
className="flex w-full items-center justify-between border border-stone-300/28 bg-white/5 px-2 py-2 text-left transition-colors hover:border-stone-900/25 hover:bg-white/10"
>
<span className="min-w-0 truncate text-[11px] text-stone-800">{draft.label}</span>
<span className="ml-3 shrink-0 font-mono text-[8px] uppercase tracking-[0.16em] text-stone-500">
{new Date(draft.updatedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
</button>
))}
</div>
</div>
);
}
function LibraryList({
title,
entries,
onSelect,
renderAction,
renderExpanded,
workspace,
}: {
title: string;
entries: UnifiedLibraryEntry[];
onSelect: (id: string) => void;
renderAction?: (entry: UnifiedLibraryEntry) => ReactNode;
renderExpanded?: (entry: UnifiedLibraryEntry) => ReactNode;
workspace?: WhoamiWorkspaceUI | null;
}) {
if (entries.length === 0) return null;
return (
<div className="mt-3 w-full space-y-2">
{title && <div className="font-mono text-[8px] uppercase tracking-[0.16em] text-stone-500">{title}</div>}
<div className="w-full space-y-1">
{entries.map((entry) => {
const typeBadge = deriveTypeBadge(entry);
const stability = deriveStability(entry);
return (
<div key={entry.id}>
<div className="flex w-full items-center border border-stone-300/28 bg-white/5 transition-colors hover:border-stone-900/25 hover:bg-white/10">
<button
type="button"
data-testid={`library-entry-${entry.id}`}
onClick={() => onSelect(entry.id)}
className="flex flex-1 flex-col gap-0.5 px-2 py-2 text-left min-w-0"
>
<div className="flex items-center justify-between w-full">
<span className="min-w-0 truncate text-[11px] text-stone-800">{entry.name}</span>
<div className="ml-2 flex shrink-0 items-center gap-1">
<span className="font-mono text-[7px] uppercase tracking-[0.12em] text-stone-500 border border-stone-300/50 px-1 py-0.5">
{typeBadge}
</span>
{(() => {
const kind = deriveEntryWorkspaceKind(entry, workspace ?? null);
return kind ? <WorkspaceKindBadge kind={kind} compact /> : null;
})()}
<span className="font-mono text-[7px] uppercase tracking-[0.12em] text-stone-400">
{stability}
</span>
</div>
</div>
{entry.summary && (
<span className="text-[9px] text-stone-500 leading-tight line-clamp-2">{entry.summary}</span>
)}
</button>
{renderAction && <div className="shrink-0 pr-2">{renderAction(entry)}</div>}
</div>
{renderExpanded && <div>{renderExpanded(entry)}</div>}
</div>
);
})}
</div>
</div>
);
}
function AddToRigFlow({ entryId, onDone }: { entryId: string; onDone: () => void }) {
const { data: review } = useLibraryReview(entryId);
const { data: psEntries = [] } = usePsEntries();
const expandRig = useExpandRig();
const [selectedRigId, setSelectedRigId] = useState("");
const [selectedPodIdx, setSelectedPodIdx] = useState(0);
const [result, setResult] = useState<ExpandRigResult | null>(null);
const [error, setError] = useState<string | null>(null);
const runningRigs = psEntries.filter((e) => e.status === "running");
const pods = (review && "pods" in review && Array.isArray(review.pods)) ? review.pods as Array<{ id: string; label?: string; members: unknown[]; edges: unknown[] }> : [];
const handleExpand = async () => {
if (!selectedRigId || pods.length === 0) return;
setError(null);
setResult(null);
const pod = pods[selectedPodIdx]!;
try {
const res = await expandRig.mutateAsync({ rigId: selectedRigId, pod: pod as Record<string, unknown> });
setResult(res);
if (res.status === "ok") setTimeout(onDone, 1500);
} catch (err) {
setError((err as Error).message);
}
};
if (!review) return <div className="font-mono text-[9px] text-stone-400 p-2">Loading spec...</div>;
if (pods.length === 0) return <div className="font-mono text-[9px] text-stone-400 p-2">No pods available in this spec.</div>;
return (
<div data-testid="add-to-rig-flow" className="mt-2 p-2 border border-stone-300/28 bg-white/5 space-y-2">
<div className="font-mono text-[8px] text-stone-400 uppercase">Add to Rig</div>
<select
data-testid="add-to-rig-select"
className="w-full font-mono text-[9px] border border-stone-300 p-1"
value={selectedRigId}
onChange={(e) => setSelectedRigId(e.target.value)}
>
<option value="">Select rig...</option>
{runningRigs.map((r) => (
<option key={r.rigId} value={r.rigId}>{r.name}</option>
))}
</select>
{pods.length > 1 && (
<select
data-testid="add-to-rig-pod-select"
className="w-full font-mono text-[9px] border border-stone-300 p-1"
value={selectedPodIdx}
onChange={(e) => setSelectedPodIdx(Number(e.target.value))}
>
{pods.map((p, i) => (
<option key={p.id} value={i}>{p.label ?? p.id}</option>
))}
</select>
)}
<button
data-testid="add-to-rig-submit"
disabled={!selectedRigId || expandRig.isPending}
onClick={handleExpand}
className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 disabled:opacity-50"
>
{expandRig.isPending ? "Expanding..." : `Add ${pods[selectedPodIdx]?.label ?? pods[selectedPodIdx]?.id ?? "pod"}`}
</button>
{result && <ExpansionOutcome result={result} />}
{error && <div className="font-mono text-[9px] text-red-600">{error}</div>}
<button onClick={onDone} className="font-mono text-[8px] text-stone-400 hover:text-stone-700">Cancel</button>
</div>
);
}
export function SpecsPanel({ onClose }: SpecsPanelProps) {
const navigate = useNavigate();
const workspace = useWorkspace();
const {
activeTask,
currentRigDraft,
currentAgentDraft,
recentRigDrafts,
recentAgentDrafts,
selectRigDraft,
selectAgentDraft,
} = useSpecsWorkspace();
const openSurface = async (
to: "/import" | "/bootstrap" | "/agents/validate" | "/specs/rig" | "/specs/agent"
) => {
await navigate({ to });
};
const openRigDraft = async (draftId: string) => {
selectRigDraft(draftId);
await openSurface("/specs/rig");
};
const openAgentDraft = async (draftId: string) => {
selectAgentDraft(draftId);
await openSurface("/specs/agent");
};
const { data: specEntries = [] } = useSpecLibrary();
// PL-014: context_packs are a sibling primitive served from a
// separate daemon route. Merge them into the unified library list so
// filter chips operate on one array.
const { data: contextPacks = [] } = useContextPackLibrary();
const { data: agentImages = [] } = useAgentImageLibrary();
const allLibrary: UnifiedLibraryEntry[] = [
...specEntries,
// Defensive Array.isArray guard for cross-CLI-version drift on
// older daemons that may return non-array placeholders.
...(Array.isArray(contextPacks) ? contextPacks.map(contextPackToUnified) : []),
...(Array.isArray(agentImages) ? agentImages.map(agentImageToUnified) : []),
];
const [libraryFilter, setLibraryFilter] = useState<LibraryFilter>("all");
const filteredLibrary = filterEntries(allLibrary, libraryFilter);
const [addToRigEntryId, setAddToRigEntryId] = useState<string | null>(null);
const [renameEntryId, setRenameEntryId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [confirmRemoveEntryId, setConfirmRemoveEntryId] = useState<string | null>(null);
const [libraryActionError, setLibraryActionError] = useState<string | null>(null);
const removeLibrarySpec = useRemoveLibrarySpec();
const renameLibrarySpec = useRenameLibrarySpec();
const openLibraryEntry = async (id: string) => {
await navigate({ to: "/specs/library/$entryId", params: { entryId: id } });
};
const rigDraftHistory = recentRigDrafts.filter((draft) => draft.id !== currentRigDraft?.id);
const agentDraftHistory = recentAgentDrafts.filter((draft) => draft.id !== currentAgentDraft?.id);
const startRename = (entry: UnifiedLibraryEntry) => {
setLibraryActionError(null);
setConfirmRemoveEntryId(null);
setRenameEntryId(entry.id);
setRenameValue(entry.name);
};
const submitRename = async (entryId: string) => {
try {
setLibraryActionError(null);
await renameLibrarySpec.mutateAsync({ entryId, name: renameValue });
setRenameEntryId(null);
setRenameValue("");
} catch (err) {
setLibraryActionError((err as Error).message);
}
};
const submitRemove = async (entryId: string) => {
try {
setLibraryActionError(null);
await removeLibrarySpec.mutateAsync(entryId);
setConfirmRemoveEntryId(null);
if (renameEntryId === entryId) {
setRenameEntryId(null);
setRenameValue("");
}
} catch (err) {
setLibraryActionError((err as Error).message);
}
};
const renderLibraryAction = (entry: UnifiedLibraryEntry, allowAddToRig: boolean) => (
<div className="flex items-center gap-1">
{allowAddToRig && (
<button
data-testid={`library-add-to-rig-${entry.id}`}
onClick={(e) => {
e.stopPropagation();
setLibraryActionError(null);
setAddToRigEntryId(addToRigEntryId === entry.id ? null : entry.id);
}}
className="shrink-0 font-mono text-[7px] uppercase tracking-[0.12em] text-stone-500 hover:text-stone-900 border border-stone-300 px-1 py-0.5"
>
+ Rig
</button>
)}
{entry.sourceType === "user_file" && (
<>
<button
data-testid={`library-rename-${entry.id}`}
onClick={(e) => {
e.stopPropagation();
startRename(entry);
}}
className="shrink-0 font-mono text-[7px] uppercase tracking-[0.12em] text-stone-500 hover:text-stone-900 border border-stone-300 px-1 py-0.5"
>
Rename
</button>
<button
data-testid={`library-remove-${entry.id}`}
onClick={(e) => {
e.stopPropagation();
setLibraryActionError(null);
setRenameEntryId(null);
setConfirmRemoveEntryId(confirmRemoveEntryId === entry.id ? null : entry.id);
}}
className="shrink-0 font-mono text-[7px] uppercase tracking-[0.12em] text-red-600 hover:text-red-800 border border-stone-300 px-1 py-0.5"
>
Remove
</button>
</>
)}
</div>
);
const renderLibraryExpanded = (entry: UnifiedLibraryEntry) => {
const showRename = renameEntryId === entry.id;
const showRemove = confirmRemoveEntryId === entry.id;
if (!showRename && !showRemove) return null;
return (
<div className="border-x border-b border-stone-300/28 bg-white/6 px-2 py-2 space-y-2">
{showRename && (
<div data-testid={`library-rename-form-${entry.id}`} className="space-y-2">
<input
data-testid={`library-rename-input-${entry.id}`}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
className="w-full border border-stone-300 bg-white/80 px-2 py-1 font-mono text-[10px]"
/>
<div className="flex gap-2">
<button
data-testid={`library-rename-submit-${entry.id}`}
onClick={() => void submitRename(entry.id)}
className="font-mono text-[8px] uppercase border border-stone-300 px-2 py-1 hover:bg-stone-200"
>
Save
</button>
<button
onClick={() => {
setRenameEntryId(null);
setRenameValue("");
setLibraryActionError(null);
}}
className="font-mono text-[8px] uppercase text-stone-500 hover:text-stone-900"
>
Cancel
</button>
</div>
</div>
)}
{showRemove && (
<div data-testid={`library-remove-confirm-${entry.id}`} className="space-y-2">
<div className="font-mono text-[9px] text-stone-600">Remove {entry.name} from the library?</div>
<div className="flex gap-2">
<button
data-testid={`library-remove-submit-${entry.id}`}
onClick={() => void submitRemove(entry.id)}
className="font-mono text-[8px] uppercase border border-stone-300 px-2 py-1 text-red-600 hover:bg-stone-200"
>
Confirm
</button>
<button
onClick={() => {
setConfirmRemoveEntryId(null);
setLibraryActionError(null);
}}
className="font-mono text-[8px] uppercase text-stone-500 hover:text-stone-900"
>
Cancel
</button>
</div>
</div>
)}
{libraryActionError && <div data-testid="library-action-error" className="font-mono text-[9px] text-red-600">{libraryActionError}</div>}
</div>
);
};
return (
<aside
data-testid="specs-panel"
className="absolute inset-y-0 right-0 z-20 w-80 border-l border-stone-300/25 bg-[rgba(250,249,245,0.035)] supports-[backdrop-filter]:bg-[rgba(250,249,245,0.018)] backdrop-blur-[14px] backdrop-saturate-75 shadow-[-6px_0_14px_rgba(46,52,46,0.04)] flex flex-col overflow-hidden"
>
<div className="flex items-center justify-between border-b border-stone-300/35 px-4 py-3 shrink-0">
<h2 className="min-w-0 truncate font-mono text-xs font-bold text-stone-900">specs</h2>
<button
data-testid="specs-close"
onClick={onClose}
className="text-stone-400 hover:text-stone-900 text-sm"
aria-label="Close"
>
✕
</button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-3">
{activeTask && (
<Section
title="Current Task"
description={activeTask.summary}
>
<Button variant="outline" size="sm" onClick={() => openSurface(activeTask.route)}>
Resume {activeTask.label}
</Button>
</Section>
)}
<Section
title="Rig Specs"
description="Import a rig spec, review it in the workspace, then instantiate or bootstrap it."
>
<Button variant="outline" size="sm" onClick={() => openSurface("/import")}>
Import RigSpec
</Button>
<Button variant="outline" size="sm" onClick={() => openSurface("/bootstrap")}>
Bootstrap
</Button>
{currentRigDraft && (
<DraftList title="Current Draft" drafts={[currentRigDraft]} onSelect={openRigDraft} />
)}
<DraftList title="Recent Drafts" drafts={rigDraftHistory} onSelect={openRigDraft} />
</Section>
<Section
title="Agent Specs"
description="Validate agent specs and use the workspace for spec-level review surfaces."
>
<Button variant="outline" size="sm" onClick={() => openSurface("/agents/validate")}>
Validate AgentSpec
</Button>
{currentAgentDraft && (
<DraftList title="Current Draft" drafts={[currentAgentDraft]} onSelect={openAgentDraft} />
)}
<DraftList title="Recent Drafts" drafts={agentDraftHistory} onSelect={openAgentDraft} />
</Section>
{/* Unified library with filter chips */}
<div className="space-y-2">
<div className="font-mono text-[8px] uppercase tracking-[0.16em] text-stone-500">Library</div>
<div className="flex gap-1">
{(["all", "apps", "rigs", "agents", "workflows", "context-packs", "agent-images"] as LibraryFilter[]).map((f) => (
<button
key={f}
data-testid={`filter-${f}`}
onClick={() => setLibraryFilter(f)}
className={`px-2 py-1 font-mono text-[8px] uppercase tracking-[0.12em] border transition-colors ${
libraryFilter === f
? "border-stone-900 text-stone-900 font-bold bg-white/20"
: "border-stone-300/50 text-stone-500 hover:text-stone-700 hover:border-stone-500"
}`}
>
{f}
</button>
))}
</div>
<LibraryList
title=""
entries={filteredLibrary}
onSelect={openLibraryEntry}
renderAction={(entry) => renderLibraryAction(entry, entry.kind === "rig")}
renderExpanded={renderLibraryExpanded}
workspace={workspace.data ?? null}
/>
{addToRigEntryId && allLibrary.some((e) => e.id === addToRigEntryId) && (
<AddToRigFlow entryId={addToRigEntryId} onDone={() => setAddToRigEntryId(null)} />
)}
</div>
</div>
</aside>
);
}
@@ -1,59 +0,0 @@
import { useDrawerSelection, useExplorerVisibility } from "./AppShell.js";
export function WorkspaceHome() {
const { openExplorer } = useExplorerVisibility();
const { setSelection } = useDrawerSelection();
return (
<div
data-testid="workspace-home"
className="flex h-full min-h-[420px] flex-col items-center justify-center bg-[radial-gradient(circle_at_top,rgba(245,244,240,0.85),rgba(244,242,236,0.55)_32%,rgba(243,241,236,0.22)_58%,transparent_72%)] px-8 text-center"
>
<div className="max-w-xl space-y-3">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-500">
Explorer-Driven Workspace
</p>
<h1 className="font-headline text-2xl font-bold uppercase tracking-tight text-stone-900">
Select a rig from the explorer to inspect its topology.
</h1>
<p className="text-sm text-stone-600">
Use <span className="font-mono text-stone-800">Specs</span> to import a rig spec or run bootstrap,
and use the <span className="font-mono text-stone-800">Discovery</span> drawer to place running sessions.
</p>
<div className="flex flex-wrap items-center justify-center gap-3 pt-2">
<a
href="/mission-control"
data-testid="workspace-open-mission-control"
className="border border-stone-900 bg-stone-900 px-4 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stone-50 transition-colors hover:bg-stone-800"
>
Mission Control
</a>
<button
type="button"
data-testid="workspace-open-explorer"
onClick={openExplorer}
className="border border-stone-300 px-4 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stone-700 transition-colors hover:bg-stone-100"
>
Explore
</button>
<button
type="button"
data-testid="workspace-open-specs"
onClick={() => setSelection({ type: "specs" })}
className="border border-stone-300 px-4 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stone-700 transition-colors hover:bg-stone-100"
>
Open Specs
</button>
<button
type="button"
data-testid="workspace-open-discovery"
onClick={() => setSelection({ type: "discovery" })}
className="border border-stone-300 px-4 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stone-700 transition-colors hover:bg-stone-100"
>
Open Discovery
</button>
</div>
</div>
</div>
);
}
@@ -1,243 +0,0 @@
// Token / Context Usage Surface v0 (PL-012) — /context route workspace.
//
// One-screen portfolio scan of context-usage across all running seats:
// - Header summary: total + tier counts (critical/warning/low/unknown).
// - Filter chips: tier, runtime, rig.
// - Per-seat list sorted by usedPercentage descending.
// - Refresh button (re-fetches /api/ps + per-rig /nodes via the hook).
// - Compact action surfaces a copy-able `rig compact-plan --rig <rig>`.
//
// Auto-compact OUT per founder safety stop directive
// (feedback_safety_stop_no_auto_review_loops). Surface visibility only.
import { useState, useMemo } from "react";
import { useSearch } from "@tanstack/react-router";
import { useContextFleet, type ContextTier, type FleetSeat } from "../../hooks/useContextFleet.js";
import { copyText } from "../../lib/copy-text.js";
const TIER_LABEL: Record<ContextTier, string> = {
critical: "critical",
warning: "warning",
low: "ok",
unknown: "unknown",
};
const TIER_BADGE: Record<ContextTier, string> = {
critical: "bg-red-100 text-red-800 border-red-300",
warning: "bg-amber-100 text-amber-800 border-amber-300",
low: "bg-emerald-100 text-emerald-800 border-emerald-300",
unknown: "bg-stone-100 text-stone-600 border-stone-300",
};
interface ContextWorkspaceSearch {
tier?: ContextTier;
runtime?: string;
rigId?: string;
}
export function ContextWorkspace() {
const search = useSearch({ strict: false }) as ContextWorkspaceSearch;
const { data, isLoading, isError, error, refetch, isFetching } = useContextFleet();
const [tierFilter, setTierFilter] = useState<ContextTier | null>(search.tier ?? null);
const [runtimeFilter, setRuntimeFilter] = useState<string | null>(search.runtime ?? null);
const [rigFilter, setRigFilter] = useState<string | null>(search.rigId ?? null);
const [copiedSession, setCopiedSession] = useState<string | null>(null);
const filtered = useMemo(() => {
if (!data) return [];
return data.seats
.filter((s) => !tierFilter || s.tier === tierFilter)
.filter((s) => !runtimeFilter || (s.runtime ?? "unknown") === runtimeFilter)
.filter((s) => !rigFilter || s.rigId === rigFilter)
.sort(seatComparator);
}, [data, tierFilter, runtimeFilter, rigFilter]);
const onCompact = async (seat: FleetSeat) => {
const cmd = `rig compact-plan --rig ${seat.rigName}`;
const ok = await copyText(cmd);
if (ok) {
setCopiedSession(seat.canonicalSessionName ?? seat.logicalId);
window.setTimeout(() => setCopiedSession(null), 2000);
}
};
if (isLoading) {
return (
<div data-testid="context-workspace-loading" className="p-6 font-mono text-[10px] text-stone-400">
Loading fleet context state…
</div>
);
}
if (isError || !data) {
return (
<div data-testid="context-workspace-error" className="p-6 font-mono text-[10px] text-red-600">
Could not load fleet context state: {(error as Error)?.message ?? "unknown"}.
<div className="mt-2 text-stone-500">
Cross-version fallback: use <code>rig context --json</code> from terminal.
</div>
</div>
);
}
const { summary } = data;
return (
<div data-testid="context-workspace" className="p-6 space-y-4">
<header className="space-y-2">
<div className="flex items-baseline justify-between gap-3">
<h2 className="font-mono text-sm font-bold text-stone-900">Context Usage</h2>
<button
type="button"
data-testid="context-workspace-refresh"
onClick={() => void refetch()}
disabled={isFetching}
className="font-mono text-[9px] uppercase tracking-[0.10em] border border-stone-400 px-2 py-1 hover:bg-stone-200 disabled:opacity-50"
>
{isFetching ? "Refreshing…" : "Refresh"}
</button>
</div>
<div className="font-mono text-[10px] text-stone-500">
fleet-wide context-usage portfolio · auto-refreshes every 15s
</div>
<div className="flex flex-wrap items-baseline gap-3 font-mono text-[10px]">
<Stat label="total" value={summary.total} />
<Stat label="critical" value={summary.byTier.critical} variant="alert" />
<Stat label="warning" value={summary.byTier.warning} variant="warn" />
<Stat label="ok" value={summary.byTier.low} variant="ok" />
<Stat label="unknown" value={summary.byTier.unknown} variant="dim" />
</div>
</header>
<section data-testid="context-filter-bar" className="space-y-2 border border-stone-300 bg-white px-3 py-2">
<FilterRow
label="tier"
values={(["critical", "warning", "low", "unknown"] as ContextTier[])
.filter((t) => summary.byTier[t] > 0)
.map((t) => ({ id: t, label: `${TIER_LABEL[t]} (${summary.byTier[t]})` }))}
selected={tierFilter}
onSelect={(v) => setTierFilter(v as ContextTier | null)}
/>
<FilterRow
label="runtime"
values={Object.entries(summary.byRuntime).map(([rt, n]) => ({ id: rt, label: `${rt} (${n})` }))}
selected={runtimeFilter}
onSelect={setRuntimeFilter}
/>
<FilterRow
label="rig"
values={summary.byRig.map((r) => ({ id: r.rigId, label: `${r.rigName} (${r.count})` }))}
selected={rigFilter}
onSelect={setRigFilter}
/>
</section>
{filtered.length === 0 ? (
<div data-testid="context-seats-empty" className="font-mono text-[10px] text-stone-500 px-3 py-2 border border-stone-200">
No seats match the current filters.
</div>
) : (
<ul data-testid="context-seat-list" className="border border-stone-300 bg-white divide-y divide-stone-100">
{filtered.map((seat) => (
<li
key={`${seat.rigId}/${seat.logicalId}`}
data-testid={`context-seat-${seat.logicalId}`}
data-rig-id={seat.rigId}
data-tier={seat.tier}
className="flex items-baseline gap-3 px-3 py-1.5"
>
<span className="font-mono text-[10px] text-stone-500 shrink-0 w-32 truncate">{seat.rigName}</span>
<span className="font-mono text-[10px] font-semibold text-stone-900 shrink-0 truncate flex-1">
{seat.canonicalSessionName ?? seat.logicalId}
</span>
<span className="font-mono text-[9px] text-stone-500 shrink-0 w-20">{seat.runtime ?? "—"}</span>
<span className="font-mono text-[10px] shrink-0 w-12 text-right">
{typeof seat.usedPercentage === "number" ? `${seat.usedPercentage}%` : "—"}
{seat.fresh === false && typeof seat.usedPercentage === "number" && (
<span className="text-stone-400">*</span>
)}
</span>
<span
data-testid={`context-seat-${seat.logicalId}-tier-badge`}
className={`font-mono text-[8px] uppercase tracking-[0.10em] border px-1 py-0.5 shrink-0 ${TIER_BADGE[seat.tier]}`}
>
{TIER_LABEL[seat.tier]}
</span>
<button
type="button"
data-testid={`context-seat-${seat.logicalId}-compact`}
onClick={() => void onCompact(seat)}
className="font-mono text-[8px] uppercase tracking-[0.10em] border border-stone-300 px-1 py-0.5 hover:bg-stone-200 shrink-0"
>
{copiedSession === (seat.canonicalSessionName ?? seat.logicalId) ? "Copied" : "Compact cmd"}
</button>
</li>
))}
</ul>
)}
</div>
);
}
function Stat({ label, value, variant = "dim" }: { label: string; value: number; variant?: "ok" | "warn" | "alert" | "dim" }) {
const cls =
variant === "ok" ? "text-emerald-700"
: variant === "warn" ? "text-amber-700"
: variant === "alert" ? "text-red-700 font-bold"
: "text-stone-600";
return (
<span data-testid={`context-stat-${label}`} className="inline-flex items-baseline gap-0.5">
<span className="text-stone-500">{label}:</span>
<span className={cls}>{value}</span>
</span>
);
}
function FilterRow({ label, values, selected, onSelect }: {
label: string;
values: Array<{ id: string; label: string }>;
selected: string | null;
onSelect: (v: string | null) => void;
}) {
if (values.length === 0) return null;
return (
<div className="flex flex-wrap items-baseline gap-1">
<span className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500 shrink-0 w-12">{label}</span>
<button
type="button"
data-testid={`context-filter-${label}-all`}
data-selected={selected === null}
onClick={() => onSelect(null)}
className={`font-mono text-[9px] border px-1.5 py-0.5 ${
selected === null
? "border-stone-700 bg-stone-700 text-white"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
>
all
</button>
{values.map((v) => (
<button
key={v.id}
type="button"
data-testid={`context-filter-${label}-${v.id}`}
data-selected={selected === v.id}
onClick={() => onSelect(v.id)}
className={`font-mono text-[9px] border px-1.5 py-0.5 ${
selected === v.id
? "border-stone-700 bg-stone-700 text-white"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
>
{v.label}
</button>
))}
</div>
);
}
function seatComparator(a: FleetSeat, b: FleetSeat): number {
const ap = typeof a.usedPercentage === "number" ? a.usedPercentage : -1;
const bp = typeof b.usedPercentage === "number" ? b.usedPercentage : -1;
if (ap !== bp) return bp - ap;
return a.logicalId.localeCompare(b.logicalId);
}
@@ -1,107 +1,28 @@
// PL-005 Phase A: Mission Control top-level surface.
// MissionControlSurface — Phase 2 SHELL-AND-STUB (V1 attempt-3 DRIFT P2-C
// resolution). The /mission-control route is deleted in Phase 2 per
// SC-18, so this placeholder is unreachable. Phase 3 will refactor
// the body in place to render the For You feed shape (5 card types
// per for-you-feed.md).
//
// Per slice § Mission Control as integrated product UI inside the
// existing shell: this is a single surface with tab navigation across
// the 7 views. NOT a new managed app; NOT a re-implementation of the
// dashboard at the same architecture level.
// View sub-files (ActiveWorkView, FleetView, GenericListView,
// HumanGateView, MyQueueView, RecentObservationsView, RecentShipsView,
// RecentlyActiveView) deleted in Phase 2; AuditHistoryView preserved
// for Phase 3 /search mount.
import { useEffect, useState } from "react";
import {
MISSION_CONTROL_VIEWS,
type MissionControlViewName,
} from "./hooks/useMissionControlView.js";
import { MyQueueView } from "./views/MyQueueView.js";
import { HumanGateView } from "./views/HumanGateView.js";
import { FleetView } from "./views/FleetView.js";
import { ActiveWorkView } from "./views/ActiveWorkView.js";
import { RecentShipsView } from "./views/RecentShipsView.js";
import { RecentlyActiveView } from "./views/RecentlyActiveView.js";
import { RecentObservationsView } from "./views/RecentObservationsView.js";
import { AuditHistoryView } from "./views/AuditHistoryView.js";
import { primeMissionControlBearerTokenFromUrl } from "./missionControlAuth.js";
// PL-005 Phase B: UI-side tab union extends Phase A's 7 daemon view
// names with an "audit-history" tab that consumes a different daemon
// endpoint (/api/mission-control/audit, not /views/:name). The daemon
// MISSION_CONTROL_VIEWS enum is unchanged (Phase A surface no-touch).
type MissionControlTabName = MissionControlViewName | "audit-history";
const VIEW_LABELS: Record<MissionControlTabName, string> = {
"my-queue": "My queue",
"human-gate": "Human gate",
fleet: "Fleet",
"active-work": "Active work",
"recent-ships": "Recent ships",
"recently-active": "Recently active",
"recent-observations": "Observations",
"audit-history": "Audit history",
};
const ALL_TABS: MissionControlTabName[] = [...MISSION_CONTROL_VIEWS, "audit-history"];
function initialViewFromUrl(): MissionControlTabName {
if (typeof window === "undefined") return "my-queue";
const requested = new URL(window.location.href).searchParams.get("view");
if (requested && (ALL_TABS as string[]).includes(requested)) {
return requested as MissionControlTabName;
}
return new URL(window.location.href).searchParams.has("qitem") ? "human-gate" : "my-queue";
}
function highlightedQitemFromUrl(): string | null {
if (typeof window === "undefined") return null;
return new URL(window.location.href).searchParams.get("qitem");
}
import { EmptyState } from "../ui/empty-state";
export function MissionControlSurface() {
const [activeView, setActiveView] = useState<MissionControlTabName>(() => initialViewFromUrl());
const [highlightedQitemId] = useState<string | null>(() => highlightedQitemFromUrl());
useEffect(() => {
primeMissionControlBearerTokenFromUrl();
}, []);
return (
<div
data-testid="mc-surface"
className="flex h-full flex-col lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]"
className="flex h-full flex-col items-center justify-center"
>
<header className="border-b border-stone-200 bg-stone-50 p-3">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-500">
Mission Control
</div>
<h1 className="font-headline text-xl font-bold tracking-tight text-stone-900">
Queue observability
</h1>
<nav data-testid="mc-tab-nav" className="mt-2 flex flex-wrap gap-1">
{ALL_TABS.map((view) => (
<button
key={view}
type="button"
data-testid={`mc-tab-${view}`}
data-active={activeView === view}
onClick={() => setActiveView(view)}
className={`border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] ${
activeView === view
? "border-stone-700 bg-stone-700 text-white"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
>
{VIEW_LABELS[view]}
</button>
))}
</nav>
</header>
<main data-testid="mc-active-view" className="min-h-0 flex-1 overflow-y-auto">
{activeView === "my-queue" ? <MyQueueView highlightedQitemId={highlightedQitemId} /> : null}
{activeView === "human-gate" ? <HumanGateView highlightedQitemId={highlightedQitemId} /> : null}
{activeView === "fleet" ? <FleetView /> : null}
{activeView === "active-work" ? <ActiveWorkView highlightedQitemId={highlightedQitemId} /> : null}
{activeView === "recent-ships" ? <RecentShipsView highlightedQitemId={highlightedQitemId} /> : null}
{activeView === "recently-active" ? <RecentlyActiveView highlightedQitemId={highlightedQitemId} /> : null}
{activeView === "recent-observations" ? <RecentObservationsView highlightedQitemId={highlightedQitemId} /> : null}
{activeView === "audit-history" ? <AuditHistoryView /> : null}
</main>
<EmptyState
label="MISSION CONTROL"
description="For You feed under construction (Phase 3). The /mission-control route is no longer reachable; this surface is preserved for in-place refactor."
variant="card"
testId="mc-surface-stub"
/>
</div>
);
}
@@ -1,14 +0,0 @@
import { GenericListView } from "./GenericListView.js";
export function ActiveWorkView({ highlightedQitemId }: { highlightedQitemId?: string | null }) {
return (
<GenericListView
viewName="active-work"
title="Active work"
subtitle="Pending / in-progress / blocked, priority-first"
emptyMessage="No active work in flight."
withVerbActions
highlightedQitemId={highlightedQitemId}
/>
);
}
@@ -1,71 +0,0 @@
// PL-005 Phase A: fleet view (rig roll-up + CLI drift indicator).
//
// Per PRD § Acceptance Criteria: combines per-rig activity state with
// the cross-CLI-version drift indicator (sub-clause 4 of graceful
// degradation). Renders both a fleet-level "stale CLI" badge AND a
// per-row indicator on each rig that's reporting drift.
import { CompactStatusRow } from "../components/CompactStatusRow.js";
import {
CliDriftIndicator,
MissingFieldPlaceholder,
} from "../components/CliDriftIndicator.js";
import { useMissionControlView } from "../hooks/useMissionControlView.js";
import { useMissionControlCliCapabilities } from "../hooks/useMissionControlCliCapabilities.js";
export function FleetView() {
const fleetView = useMissionControlView("fleet");
const cliCapabilities = useMissionControlCliCapabilities();
return (
<div data-testid="mc-view-fleet" className="space-y-3 p-3">
<header className="space-y-0.5">
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-stone-500">
fleet
</div>
<h2 className="font-headline text-lg text-stone-900">Fleet</h2>
<p className="text-xs text-stone-600">
Per-rig activity and per-rig CLI capability honesty.
</p>
</header>
{cliCapabilities.data ? (
<CliDriftIndicator
staleCliCount={cliCapabilities.data.staleCliCount}
degradedFields={cliCapabilities.data.degradedFields}
sourceFallback={cliCapabilities.data.sourceFallback}
/>
) : null}
{fleetView.isLoading ? (
<div data-testid="mc-view-loading" className="font-mono text-[10px] text-stone-500">
loading...
</div>
) : fleetView.isError ? (
<div data-testid="mc-view-error" className="font-mono text-[11px] text-red-700">
error: {fleetView.error.message}
</div>
) : fleetView.data?.rows.length === 0 ? (
<div data-testid="mc-view-empty" className="font-mono text-[11px] text-stone-500">
No rigs registered.
</div>
) : (
<ul data-testid="mc-view-rows" className="space-y-1.5">
{fleetView.data?.rows.map((row, idx) => {
const driftRow = cliCapabilities.data?.rows.find(
(r) => r.rigName === row.rigOrMissionName,
);
return (
<li key={`fleet-${idx}-${row.rigOrMissionName}`} className="space-y-1">
<CompactStatusRow row={row} density="expanded" />
{driftRow?.cliDriftDetected ? (
<div className="pl-3">
<MissingFieldPlaceholder fieldName="recoveryGuidance" />
</div>
) : null}
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -1,95 +0,0 @@
// PL-005 Phase A: shared list view used by 4 of the 7 views.
// MyQueueView, HumanGateView, ActiveWorkView, RecentShipsView,
// RecentlyActiveView, RecentObservationsView all render a list of
// CompactStatusRow with optional verb actions.
import { CompactStatusRow } from "../components/CompactStatusRow.js";
import { VerbActions } from "../components/VerbActions.js";
import {
type MissionControlViewName,
useMissionControlView,
} from "../hooks/useMissionControlView.js";
import type { MissionControlVerb } from "../hooks/useMissionControlAction.js";
export interface GenericListViewProps {
viewName: MissionControlViewName;
operatorSession?: string;
/** Optional restriction on which verbs are offered per row. */
enabledVerbs?: MissionControlVerb[];
/** Optional empty-state message. */
emptyMessage?: string;
/** Header label. */
title: string;
/** Optional subtitle / description. */
subtitle?: string;
/** Show row-level verb actions. Default true for actionable views,
* false for read-only views (recent-ships, recently-active, etc.). */
withVerbActions?: boolean;
highlightedQitemId?: string | null;
}
export function GenericListView({
viewName,
operatorSession,
enabledVerbs,
emptyMessage = "Nothing here right now.",
title,
subtitle,
withVerbActions = false,
highlightedQitemId,
}: GenericListViewProps) {
const query = useMissionControlView(viewName, { operatorSession });
const actorSession = operatorSession ?? "human-wrandom@kernel";
return (
<div data-testid={`mc-view-${viewName}`} className="space-y-3 p-3">
<header className="space-y-0.5">
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-stone-500">
{viewName}
</div>
<h2 className="font-headline text-lg text-stone-900">{title}</h2>
{subtitle ? <p className="text-xs text-stone-600">{subtitle}</p> : null}
</header>
{query.isLoading ? (
<div data-testid="mc-view-loading" className="font-mono text-[10px] text-stone-500">
loading...
</div>
) : query.isError ? (
<div data-testid="mc-view-error" className="font-mono text-[11px] text-red-700">
error: {query.error.message}
</div>
) : query.data?.rows.length === 0 ? (
<div data-testid="mc-view-empty" className="font-mono text-[11px] text-stone-500">
{emptyMessage}
</div>
) : (
<ul data-testid="mc-view-rows" className="space-y-1.5">
{query.data?.rows.map((row, idx) => (
<li key={row.qitemId ?? `${viewName}-${idx}`} className="space-y-1">
<CompactStatusRow
row={row}
density="expanded"
highlighted={!!highlightedQitemId && row.qitemId === highlightedQitemId}
/>
{withVerbActions && row.qitemId ? (
<VerbActions
qitemId={row.qitemId}
actorSession={actorSession}
enabledVerbs={enabledVerbs}
/>
) : null}
</li>
))}
</ul>
)}
{query.data?.meta ? (
<footer
data-testid="mc-view-meta"
className="font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500"
>
rows: {query.data.meta.rowCount}
</footer>
) : null}
</div>
);
}
@@ -1,15 +0,0 @@
// PL-005 Phase A: cross-fleet human-gate view.
import { GenericListView } from "./GenericListView.js";
export function HumanGateView({ highlightedQitemId }: { highlightedQitemId?: string | null }) {
return (
<GenericListView
viewName="human-gate"
title="Human gate"
subtitle="Cross-fleet items waiting on a human decision"
emptyMessage="No human-gated items across the fleet."
withVerbActions
highlightedQitemId={highlightedQitemId}
/>
);
}
@@ -1,33 +0,0 @@
// PL-005 Phase A: my-queue view (operator's human-gate queue).
// Combines HumanSeatCard (first-class human seat per founder Q3) with
// the operator's pending human-gate items + verb actions.
import { HumanSeatCard } from "../components/HumanSeatCard.js";
import { useMissionControlView } from "../hooks/useMissionControlView.js";
import { GenericListView } from "./GenericListView.js";
export interface MyQueueViewProps {
operatorSession?: string;
highlightedQitemId?: string | null;
}
export function MyQueueView({
operatorSession = "human-wrandom@kernel",
highlightedQitemId,
}: MyQueueViewProps) {
const query = useMissionControlView("my-queue", { operatorSession });
return (
<div data-testid="mc-view-my-queue-container" className="space-y-3 p-3">
<HumanSeatCard session={operatorSession} rows={query.data?.rows ?? []} />
<GenericListView
viewName="my-queue"
operatorSession={operatorSession}
title="My queue"
subtitle="Decisions that need me, ordered by read-cost"
emptyMessage="No human-gated items waiting on you."
withVerbActions
highlightedQitemId={highlightedQitemId}
/>
</div>
);
}
@@ -1,13 +0,0 @@
import { GenericListView } from "./GenericListView.js";
export function RecentObservationsView({ highlightedQitemId }: { highlightedQitemId?: string | null }) {
return (
<GenericListView
viewName="recent-observations"
title="Recent observations"
subtitle="Recent stream observations relevant to operator attention"
emptyMessage="No recent observations."
highlightedQitemId={highlightedQitemId}
/>
);
}
@@ -1,14 +0,0 @@
// PL-005 Phase A: recent ships view (last 10 done items per founder Q4).
import { GenericListView } from "./GenericListView.js";
export function RecentShipsView({ highlightedQitemId }: { highlightedQitemId?: string | null }) {
return (
<GenericListView
viewName="recent-ships"
title="Recent ships"
subtitle="Last 10 done / handed-off items with closure reason"
emptyMessage="Nothing shipped recently."
highlightedQitemId={highlightedQitemId}
/>
);
}
@@ -1,13 +0,0 @@
import { GenericListView } from "./GenericListView.js";
export function RecentlyActiveView({ highlightedQitemId }: { highlightedQitemId?: string | null }) {
return (
<GenericListView
viewName="recently-active"
title="Recently active"
subtitle="Activity-sorted via PL-004 Phase B view-projector"
emptyMessage="No recent activity."
highlightedQitemId={highlightedQitemId}
/>
);
}
@@ -1,382 +0,0 @@
// UI Enhancement Pack v0 + Operator Surface Reconciliation v0 — Progress browse workspace.
//
// UEP v0: workspace-tree walk + checkbox pills + status filters + click-to-expand.
//
// OSR v0 item 2: per-level Priority Rail Rule typography (STEERING /
// mission / lane / slice / intermediate) + "next pull" marker per
// lane file (first non-done, non-blocked checkbox row).
// OSR v0 item 6: inline lint warning badges with rule citations
// (workstream-continuity rules) + Show-lint toggle (default on).
import { useMemo, useState } from "react";
import { useProgressTree, type ProgressFileNode, type ProgressRow, type CheckboxStatus } from "../../hooks/useProgressTree.js";
import {
classifyPriorityRailLevel,
computeLintWarnings,
computeNextPullLine,
getPriorityRailLevelStyle,
type LintWarning,
type PriorityRailLevel,
} from "./priority-rail-rule.js";
import { useWorkspace, type WhoamiWorkspaceUI } from "../../hooks/useWorkspace.js";
import { WorkspaceKindBadge, resolveKindForPath } from "../WorkspaceKindBadge.js";
type StatusFilter = "all" | "active" | "done" | "blocked";
const FILTERS: StatusFilter[] = ["all", "active", "done", "blocked"];
interface FileMeta {
level: PriorityRailLevel;
nextPullLine: number | null;
lintWarnings: LintWarning[];
}
function isUnavailable(data: unknown): data is { unavailable: true; error: string; hint?: string } {
return Boolean(data && typeof data === "object" && "unavailable" in (data as Record<string, unknown>));
}
export function ProgressWorkspace() {
const tree = useProgressTree();
const workspace = useWorkspace();
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [search, setSearch] = useState("");
const [selectedRow, setSelectedRow] = useState<{ file: ProgressFileNode; row: ProgressRow } | null>(null);
const [showLint, setShowLint] = useState<boolean>(true);
// Pre-compute Priority Rail Rule level + lint warnings + next-pull
// line per file so child sections render without recomputing on every
// filter/search keystroke. Per item 6: "missing tree" rule needs to
// know which files have child PROGRESS.md files (parents of other
// files in the tree) — derived once from the file list.
const fileMeta = useMemo(() => {
if (!tree.data || isUnavailable(tree.data)) return new Map<string, FileMeta>();
const meta = new Map<string, FileMeta>();
const files = tree.data.files;
// Set of directory paths that contain child PROGRESS.md files (other than themselves).
const childDirs = new Set<string>();
for (const f of files) {
const parts = f.relPath.split("/");
// Ancestor dirs (any prefix path) are "parents" if some other
// file's relPath starts with them.
for (let i = 0; i < parts.length - 1; i++) {
childDirs.add(`${f.rootName}|${parts.slice(0, i + 1).join("/")}`);
}
}
for (const f of files) {
const level = classifyPriorityRailLevel(f);
const segments = f.relPath.split("/");
const fileDir = segments.slice(0, -1).join("/");
const isParent = Array.from(childDirs).some((key) => key === `${f.rootName}|${fileDir}` && fileDir.length > 0)
// A file at the root with descendants:
|| files.some((other) => other !== f && other.relPath !== f.relPath && other.relPath.startsWith(`${fileDir ? `${fileDir}/` : ""}`) && other.rootName === f.rootName);
const hasChildFiles = isParent && files.some((other) =>
other !== f &&
other.rootName === f.rootName &&
(fileDir === "" ? true : other.relPath.startsWith(`${fileDir}/`)),
);
meta.set(`${f.rootName}/${f.relPath}`, {
level,
nextPullLine: level === "lane" ? computeNextPullLine(f) : null,
lintWarnings: computeLintWarnings(f, hasChildFiles),
});
}
return meta;
}, [tree.data]);
return (
<div data-testid="progress-workspace" className="flex h-full flex-col lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]">
<header className="border-b border-stone-200 bg-stone-50 px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-500">Workspace</div>
<h1 className="font-headline text-xl font-bold tracking-tight text-stone-900">Progress</h1>
{tree.data && !isUnavailable(tree.data) && (
<div className="mt-1 font-mono text-[10px] text-stone-500" data-testid="progress-aggregate">
{tree.data.aggregate.totalFiles} file{tree.data.aggregate.totalFiles === 1 ? "" : "s"}
{" · "}{tree.data.aggregate.totalDone} done
{" · "}{tree.data.aggregate.totalActive} active
{" · "}{tree.data.aggregate.totalBlocked} blocked
{" / "}{tree.data.aggregate.totalRows} total
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2" data-testid="progress-filter-row">
{FILTERS.map((f) => (
<button
key={f}
type="button"
data-testid={`progress-filter-${f}`}
data-active={statusFilter === f}
onClick={() => setStatusFilter(f)}
className={`border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.10em] ${
statusFilter === f
? "border-stone-700 bg-stone-700 text-white"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
>
{f}
</button>
))}
<input
data-testid="progress-search"
type="text"
placeholder="search rows…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="ml-auto min-w-32 flex-1 border border-stone-300 bg-white px-2 py-1 font-mono text-[10px]"
/>
<button
type="button"
data-testid="progress-show-lint-toggle"
data-active={showLint}
onClick={() => setShowLint((v) => !v)}
className={`shrink-0 border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.10em] ${
showLint
? "border-amber-500 bg-amber-50 text-amber-900"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
title="Toggle Operator Surface Reconciliation v0 lint warnings (item 6)"
>
lint {showLint ? "on" : "off"}
</button>
</div>
</header>
<div className="flex flex-1 min-h-0">
<main data-testid="progress-tree-pane" className="flex-1 min-w-0 overflow-y-auto bg-white">
{tree.isLoading && <div className="p-4 font-mono text-[10px] text-stone-400">Loading…</div>}
{tree.isError && <div className="p-4 font-mono text-[10px] text-red-600">Error loading progress tree.</div>}
{isUnavailable(tree.data) && (
<div data-testid="progress-unavailable" className="p-4 font-mono text-[10px] text-stone-500">
<div>Progress indexer unavailable.</div>
{tree.data.hint && <div className="mt-1 text-stone-400">{tree.data.hint}</div>}
</div>
)}
{tree.data && !isUnavailable(tree.data) && tree.data.files.length === 0 && (
<div className="p-4 font-mono text-[10px] text-stone-400">
No PROGRESS.md files found in scan roots: {tree.data.scannedRoots.map((r) => r.name).join(", ") || "(none)"}.
</div>
)}
{tree.data && !isUnavailable(tree.data) && tree.data.files.map((file) => (
<ProgressFileSection
key={`${file.rootName}/${file.relPath}`}
file={file}
meta={fileMeta.get(`${file.rootName}/${file.relPath}`)}
showLint={showLint}
statusFilter={statusFilter}
search={search}
onRowClick={(row) => setSelectedRow({ file, row })}
selectedRow={selectedRow}
workspace={workspace.data ?? null}
/>
))}
</main>
{selectedRow && (
<aside data-testid="progress-detail-pane" className="w-72 shrink-0 overflow-y-auto border-l border-stone-200 bg-stone-50 p-3">
<button
type="button"
data-testid="progress-detail-close"
onClick={() => setSelectedRow(null)}
className="mb-2 font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500 hover:text-stone-900"
>
✕ close
</button>
<div className="font-mono text-[10px] text-stone-500">
<div className="font-bold text-stone-700">{selectedRow.file.title ?? selectedRow.file.relPath}</div>
<div className="mt-1 text-[8px] uppercase tracking-[0.12em]">{selectedRow.file.rootName}</div>
<div className="mt-1 text-[9px] text-stone-400 break-all">{selectedRow.file.relPath}:{selectedRow.row.line}</div>
</div>
<div className="mt-3">
<div className="font-mono text-[8px] uppercase tracking-[0.12em] text-stone-500">Row</div>
<div data-testid="progress-detail-text" className="mt-1 whitespace-pre-line font-mono text-[10px] text-stone-800">
{selectedRow.row.text}
</div>
</div>
<div className="mt-3">
<div className="font-mono text-[8px] uppercase tracking-[0.12em] text-stone-500">Status</div>
<div data-testid="progress-detail-status" className="mt-1 font-mono text-[10px] text-stone-700">
{selectedRow.row.status}
</div>
</div>
</aside>
)}
</div>
</div>
);
}
function ProgressFileSection({
file,
meta,
showLint,
statusFilter,
search,
onRowClick,
selectedRow,
workspace,
}: {
file: ProgressFileNode;
meta: FileMeta | undefined;
showLint: boolean;
statusFilter: StatusFilter;
search: string;
onRowClick: (row: ProgressRow) => void;
selectedRow: { file: ProgressFileNode; row: ProgressRow } | null;
workspace: WhoamiWorkspaceUI | null;
}) {
const [collapsed, setCollapsed] = useState(false);
const filteredRows = useMemo(() => filterRows(file.rows, statusFilter, search), [file.rows, statusFilter, search]);
if (filteredRows.length === 0 && (statusFilter !== "all" || search)) return null;
// OSR v0 item 6: file-scope lint warnings (e.g., missing-tree) render
// at the file header. Per-row warnings render inline next to their row.
const lintWarnings = meta?.lintWarnings ?? [];
const fileScopeLint = lintWarnings.filter((w) => w.line === null);
const levelStyle = meta ? getPriorityRailLevelStyle(meta.level) : null;
return (
<section
data-testid={`progress-file-${file.rootName}-${file.relPath}`}
data-rail-level={meta?.level ?? "unknown"}
className="border-b border-stone-100"
>
<button
type="button"
data-testid={`progress-file-${file.rootName}-${file.relPath}-toggle`}
onClick={() => setCollapsed((v) => !v)}
className="flex w-full items-baseline gap-2 bg-stone-50 px-3 py-2 text-left hover:bg-stone-100"
>
<span className="font-mono text-[10px] text-stone-500">{collapsed ? "▸" : "▾"}</span>
{levelStyle && (
<span
data-testid={`progress-file-${file.rootName}-${file.relPath}-level`}
data-level={meta?.level}
className={`shrink-0 border px-1 font-mono text-[8px] uppercase tracking-[0.10em] ${levelStyle.chipClass}`}
>
{levelStyle.label}
</span>
)}
<span className="font-mono text-[11px] font-bold text-stone-900">{file.title ?? file.relPath}</span>
<span className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500">{file.rootName}</span>
{(() => {
const kind = resolveKindForPath(file.absolutePath, workspace);
return kind ? <WorkspaceKindBadge kind={kind} compact /> : null;
})()}
{showLint && lintWarnings.length > 0 && (
<span
data-testid={`progress-file-${file.rootName}-${file.relPath}-lint-count`}
className="shrink-0 border border-amber-400 bg-amber-50 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-amber-900"
title={`${lintWarnings.length} lint warning(s)`}
>
⚠ {lintWarnings.length}
</span>
)}
<span className="ml-auto font-mono text-[9px] text-stone-500">
{file.counts.done}/{file.counts.total}
</span>
</button>
{!collapsed && showLint && fileScopeLint.length > 0 && (
<ul data-testid={`progress-file-${file.rootName}-${file.relPath}-file-lint`} className="border-t border-amber-200 bg-amber-50 px-3 py-1">
{fileScopeLint.map((w, idx) => (
<li
key={`file-lint-${idx}`}
data-testid={`progress-lint-${w.ruleId}`}
className="font-mono text-[9px] text-amber-900"
title={w.citation}
>
⚠ {w.message}
</li>
))}
</ul>
)}
{!collapsed && (
<ul className="px-3 py-2">
{filteredRows.map((row) => (
<li
key={`${file.relPath}:${row.line}`}
data-testid={`progress-row-${file.rootName}-${row.line}`}
data-status={row.status}
data-kind={row.kind}
>
<button
type="button"
onClick={() => onRowClick(row)}
style={{ paddingLeft: `${row.depth * 1.2}rem` }}
className={`flex w-full items-baseline gap-2 py-0.5 text-left hover:bg-stone-100 ${
selectedRow?.row === row ? "bg-stone-200/80" : ""
}`}
>
{row.kind === "heading" ? (
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.10em] text-stone-700">
{row.text}
</span>
) : (
<>
<StatusPill status={row.status} />
<span className="font-mono text-[10px] text-stone-800">{row.text}</span>
{meta?.nextPullLine === row.line && (
<span
data-testid={`progress-row-${file.rootName}-${row.line}-next-pull`}
className="border border-emerald-400 bg-emerald-100 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-emerald-900"
title="Priority Rail Rule: first non-done, non-blocked item on this lane"
>
next pull
</span>
)}
{showLint && lintWarnings
.filter((w) => w.line === row.line)
.map((w) => (
<span
key={w.ruleId}
data-testid={`progress-row-${file.rootName}-${row.line}-lint-${w.ruleId}`}
className="border border-amber-400 bg-amber-50 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-amber-900"
title={`${w.message} (${w.citation})`}
>
⚠ {w.ruleId}
</span>
))}
<span className="ml-auto font-mono text-[8px] text-stone-400">L{row.line}</span>
</>
)}
</button>
</li>
))}
</ul>
)}
</section>
);
}
function filterRows(rows: ProgressRow[], filter: StatusFilter, search: string): ProgressRow[] {
const lowerSearch = search.trim().toLowerCase();
return rows.filter((r) => {
// Always include headings (they provide context for filtered children).
// When a search is active, also gate headings on the search term.
if (r.kind === "heading") {
if (lowerSearch && !r.text.toLowerCase().includes(lowerSearch)) return false;
return true;
}
if (filter === "active" && r.status !== "active") return false;
if (filter === "done" && r.status !== "done") return false;
if (filter === "blocked" && r.status !== "blocked") return false;
if (lowerSearch && !r.text.toLowerCase().includes(lowerSearch)) return false;
return true;
});
}
function StatusPill({ status }: { status: CheckboxStatus }) {
const { className, icon, label } = pillStyle(status);
return (
<span
data-testid={`progress-row-pill-${status}`}
className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0 font-mono text-[8px] uppercase tracking-[0.10em] ${className}`}
aria-label={label}
>
<span aria-hidden="true">{icon}</span>
<span>{label}</span>
</span>
);
}
function pillStyle(status: CheckboxStatus): { className: string; icon: string; label: string } {
switch (status) {
case "done": return { className: "border-emerald-400 bg-emerald-50 text-emerald-900", icon: "✓", label: "done" };
case "blocked": return { className: "border-amber-400 bg-amber-50 text-amber-900", icon: "⚠", label: "blocked" };
case "active": return { className: "border-stone-400 bg-stone-50 text-stone-700", icon: "◯", label: "active" };
default: return { className: "border-stone-300 bg-stone-50 text-stone-500", icon: "·", label: "unknown" };
}
}
@@ -1,172 +0,0 @@
// Slice Story View v0 - explorer-column slice picker.
//
// The AppShell already owns the left explorer column. This component supplies
// the Slices-specific explorer content so the route detail pane does not create
// a second sidebar under the topology explorer.
import { useState } from "react";
import { Link, useParams } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { useSlices, type SliceFilter, type SliceListEntry, type SliceListResponse, type SlicesUnavailable } from "../../hooks/useSlices.js";
import { useActiveLens, clearActiveLens } from "../../hooks/useSpecLibrary.js";
import { useWorkspace, type WhoamiWorkspaceUI } from "../../hooks/useWorkspace.js";
import { WorkspaceKindBadge, resolveKindForPath } from "../WorkspaceKindBadge.js";
const FILTERS: SliceFilter[] = ["all", "active", "done", "blocked"];
function isUnavailable(data: SliceListResponse | SlicesUnavailable | undefined): data is SlicesUnavailable {
return Boolean(data && "unavailable" in data);
}
export function SliceExplorerPanel({ onNavigate = () => {} }: { onNavigate?: () => void }) {
const params = useParams({ strict: false }) as { name?: string };
const selectedName = params.name ?? null;
const [filter, setFilter] = useState<SliceFilter>("active");
const [showAll, setShowAll] = useState(false);
const queryClient = useQueryClient();
const workspace = useWorkspace();
const { data: activeLens } = useActiveLens();
const lensActive = !!activeLens && !showAll;
const list = useSlices(
filter,
lensActive ? { specName: activeLens!.specName, specVersion: activeLens!.specVersion } : null,
);
const onClearLens = async () => {
await clearActiveLens();
await queryClient.invalidateQueries({ queryKey: ["spec-library", "active-lens"] });
await queryClient.invalidateQueries({ queryKey: ["slices"] });
};
return (
<div data-testid="slice-list-pane" className="flex h-full flex-col">
<div className="border-b border-stone-200 p-3">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-500">Slices</div>
<div className="mt-2 flex gap-1" data-testid="slice-filter-row">
{FILTERS.map((f) => (
<button
key={f}
type="button"
data-testid={`slice-filter-${f}`}
data-active={filter === f}
onClick={() => setFilter(f)}
className={`border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] ${
filter === f
? "border-stone-700 bg-stone-700 text-white"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
>
{f}
</button>
))}
</div>
{activeLens && (
<div
data-testid="slice-lens-indicator"
className="mt-2 border border-stone-300 bg-stone-50 px-2 py-1 font-mono text-[9px] text-stone-700 space-y-1"
>
<div className="flex items-center justify-between">
<span className="truncate">
Lens: {activeLens.specName} v{activeLens.specVersion}
</span>
<button
type="button"
data-testid="slice-lens-clear"
onClick={() => void onClearLens()}
className="ml-2 shrink-0 border border-stone-300 px-1 py-0.5 text-[8px] uppercase tracking-[0.10em] text-stone-600 hover:bg-stone-200"
>
Clear
</button>
</div>
<button
type="button"
data-testid="slice-lens-show-all-toggle"
onClick={() => setShowAll((v) => !v)}
className="w-full border border-stone-300 px-1 py-0.5 text-[8px] uppercase tracking-[0.10em] text-stone-600 hover:bg-stone-200"
>
{showAll ? "Apply Lens Filter" : "Show All Slices"}
</button>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto" data-testid="slice-list">
{list.isLoading && <div className="p-3 font-mono text-[10px] text-stone-400">Loading...</div>}
{list.isError && <div className="p-3 font-mono text-[10px] text-red-600">Error loading slices.</div>}
{isUnavailable(list.data) && (
<div className="p-3 font-mono text-[10px] text-stone-500" data-testid="slice-list-unavailable">
<div>Slices indexer unavailable.</div>
{list.data.hint && <div className="mt-1 text-stone-400">{list.data.hint}</div>}
</div>
)}
{!isUnavailable(list.data) && list.data && list.data.slices.length === 0 && (
<div className="p-3 font-mono text-[10px] text-stone-400" data-testid="slice-list-empty">
{lensActive
? `No slices bound to ${activeLens?.specName} v${activeLens?.specVersion}.`
: "No slices match."}
</div>
)}
{!isUnavailable(list.data) && list.data && list.data.slices.map((slice) => (
<SliceListRow
key={slice.name}
slice={slice}
selected={slice.name === selectedName}
onNavigate={onNavigate}
workspace={workspace.data ?? null}
/>
))}
</div>
</div>
);
}
function SliceListRow({
slice,
selected,
onNavigate,
workspace,
}: {
slice: SliceListEntry;
selected: boolean;
onNavigate: () => void;
workspace: WhoamiWorkspaceUI | null;
}) {
const statusGlyph = slice.status === "done"
? "D"
: slice.status === "blocked"
? "!"
: slice.status === "draft"
? "."
: "o";
const ageDays = slice.lastActivityAt
? Math.max(0, Math.floor((Date.now() - Date.parse(slice.lastActivityAt)) / 86_400_000))
: null;
return (
<Link
to="/slices/$name"
params={{ name: slice.name }}
onClick={onNavigate}
data-testid={`slice-row-${slice.name}`}
data-selected={selected}
className={`block border-b border-stone-100 px-3 py-2 text-left transition-colors hover:bg-stone-100 ${selected ? "bg-stone-200/80" : ""}`}
>
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-stone-500" aria-label={`status ${slice.status}`}>{statusGlyph}</span>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] font-semibold text-stone-900">{slice.name}</span>
{(() => {
const kind = resolveKindForPath(slice.slicePath ?? null, workspace);
return kind ? <WorkspaceKindBadge kind={kind} compact /> : null;
})()}
{slice.railItem && (
<span className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500">{slice.railItem}</span>
)}
</div>
<div className="mt-1 flex items-center gap-2 text-[8px] text-stone-400">
<span>{slice.qitemCount} qitems</span>
{slice.hasProofPacket && <span data-testid={`slice-row-${slice.name}-proof`}>- proof</span>}
{ageDays !== null && <span>- {ageDays}d</span>}
</div>
</Link>
);
}
@@ -1,106 +0,0 @@
// Slice Story View v0 — top-level surface.
//
// The AppShell explorer column owns slice filtering and selection while this
// route owns the six detail tabs for the selected slice.
import { useEffect, useState } from "react";
import { useParams } from "@tanstack/react-router";
import { useSliceDetail } from "../../hooks/useSlices.js";
import { StoryTab } from "./tabs/StoryTab.js";
import { AcceptanceTab } from "./tabs/AcceptanceTab.js";
import { DecisionsTab } from "./tabs/DecisionsTab.js";
import { DocsTab } from "./tabs/DocsTab.js";
import { TestsVerificationTab } from "./tabs/TestsVerificationTab.js";
import { TopologyTab } from "./tabs/TopologyTab.js";
export const SLICE_TABS = ["story", "acceptance", "decisions", "docs", "tests", "topology"] as const;
export type SliceTabName = typeof SLICE_TABS[number];
const TAB_LABELS: Record<SliceTabName, string> = {
story: "Story",
acceptance: "Acceptance",
decisions: "Decisions",
docs: "Docs",
tests: "Tests / Verification",
topology: "Topology",
};
export function SliceStoryView() {
// Optional slice name from URL params; null when on bare /slices.
const params = useParams({ strict: false }) as { name?: string };
const selectedName = params.name ?? null;
const [activeTab, setActiveTab] = useState<SliceTabName>("story");
// Reset to story when slice changes.
useEffect(() => {
setActiveTab("story");
}, [selectedName]);
const detail = useSliceDetail(selectedName);
return (
<div
data-testid="slice-story-view"
className="flex h-full flex-col lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]"
>
<main data-testid="slice-detail-pane" className="flex-1 min-w-0 flex flex-col">
{!selectedName && (
<div className="m-auto font-mono text-[10px] uppercase tracking-[0.18em] text-stone-400" data-testid="slice-no-selection">
Pick a slice from the explorer
</div>
)}
{selectedName && detail.isLoading && (
<div className="m-auto font-mono text-[10px] text-stone-400">Loading slice…</div>
)}
{selectedName && detail.isError && (
<div className="m-auto font-mono text-[10px] text-red-600">Error loading slice.</div>
)}
{selectedName && detail.data && (
<>
<header className="border-b border-stone-200 bg-stone-50 px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-500">
{detail.data.railItem ?? "slice"}
{" · "}
{detail.data.rawStatus ?? detail.data.status}
</div>
<h1 className="font-headline text-xl font-bold tracking-tight text-stone-900">
{detail.data.displayName}
</h1>
<nav data-testid="slice-tab-nav" className="mt-2 flex flex-wrap gap-1">
{SLICE_TABS.map((tab) => (
<button
key={tab}
type="button"
data-testid={`slice-tab-${tab}`}
data-active={activeTab === tab}
onClick={() => setActiveTab(tab)}
className={`border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] ${
activeTab === tab
? "border-stone-700 bg-stone-700 text-white"
: "border-stone-300 text-stone-700 hover:bg-stone-100"
}`}
>
{TAB_LABELS[tab]}
</button>
))}
</nav>
</header>
<section data-testid={`slice-tab-content-${activeTab}`} className="flex-1 min-h-0 overflow-y-auto">
{activeTab === "story" && (
<StoryTab
events={detail.data.story.events}
phaseDefinitions={detail.data.story.phaseDefinitions}
/>
)}
{activeTab === "acceptance" && <AcceptanceTab acceptance={detail.data.acceptance} />}
{activeTab === "decisions" && <DecisionsTab rows={detail.data.decisions.rows} />}
{activeTab === "docs" && <DocsTab sliceName={detail.data.name} tree={detail.data.docs.tree} />}
{activeTab === "tests" && <TestsVerificationTab sliceName={detail.data.name} tests={detail.data.tests} />}
{activeTab === "topology" && <TopologyTab topology={detail.data.topology} />}
</section>
</>
)}
</main>
</div>
);
}
@@ -1,124 +0,0 @@
// Operator Surface Reconciliation v0 — Compact Health Gates panel.
//
// Item 1F: rig ps + rig context summaries via /api/health-summary/*.
// Each badge is read-only at v0; deeper drill-down (e.g., clicking
// "attention-required: N") is a v1+ ergonomic upgrade and surfaced
// here as a copy-able CLI hint instead of a daemon write.
//
// PL-012: context-tier badges are now click-through links to the
// /context dashboard filtered by the corresponding tier. Operator
// scans steering, sees "critical: 2", clicks → /context filtered.
import { Link } from "@tanstack/react-router";
import { useContextHealth, useNodeHealth } from "../../hooks/useSteering.js";
export function HealthGatesPanel() {
const nodeHealth = useNodeHealth();
const contextHealth = useContextHealth();
return (
<section data-testid="steering-health-gates" className="border border-stone-300 bg-white">
<header className="border-b border-stone-200 bg-stone-50 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
Compact health gates
</div>
</header>
<div className="grid grid-cols-1 gap-3 p-3 md:grid-cols-2">
<NodeGate />
<ContextGate />
</div>
</section>
);
function NodeGate() {
if (nodeHealth.isLoading) return <div className="font-mono text-[10px] text-stone-400">Loading nodes…</div>;
if (nodeHealth.isError) return <div data-testid="steering-health-nodes-error" className="font-mono text-[10px] text-red-600">Health summary unavailable (nodes).</div>;
if (!nodeHealth.data) return null;
const { total, bySessionStatus, attentionRequired } = nodeHealth.data;
return (
<div data-testid="steering-health-nodes" className="space-y-1">
<div className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">Nodes</div>
<div className="flex flex-wrap items-baseline gap-2 font-mono text-[10px]">
<Stat label="total" value={total} />
<Stat label="running" value={bySessionStatus["running"] ?? 0} variant="ok" />
<Stat label="detached" value={bySessionStatus["detached"] ?? 0} variant="warn" />
<Stat label="exited" value={bySessionStatus["exited"] ?? 0} variant="dim" />
<Stat label="attention" value={attentionRequired} variant={attentionRequired > 0 ? "alert" : "dim"} />
</div>
<div className="font-mono text-[8px] text-stone-400">via rig ps --nodes --summary</div>
</div>
);
}
function ContextGate() {
if (contextHealth.isLoading) return <div className="font-mono text-[10px] text-stone-400">Loading context…</div>;
if (contextHealth.isError) return <div data-testid="steering-health-context-error" className="font-mono text-[10px] text-red-600">Health summary unavailable (context).</div>;
if (!contextHealth.data) return null;
const { total, byUrgency, critical, warning, stale } = contextHealth.data;
return (
<div data-testid="steering-health-context" className="space-y-1">
<div className="flex items-baseline justify-between">
<div className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">Context usage</div>
<Link
to="/context"
data-testid="steering-context-open"
className="font-mono text-[9px] uppercase tracking-[0.10em] text-blue-700 hover:underline"
>
Open dashboard →
</Link>
</div>
<div className="flex flex-wrap items-baseline gap-2 font-mono text-[10px]">
<Stat label="total" value={total} />
{/* PL-012: tier counts are click-through to /context?tier=…. */}
<TierStatLink tier="low" label="ok" value={byUrgency["low"] ?? 0} variant="ok" />
<TierStatLink tier="warning" label="warning" value={warning} variant={warning > 0 ? "warn" : "dim"} />
<TierStatLink tier="critical" label="critical" value={critical} variant={critical > 0 ? "alert" : "dim"} />
<Stat label="stale" value={stale} variant={stale > 0 ? "warn" : "dim"} />
</div>
<div className="font-mono text-[8px] text-stone-400">via rig context --json</div>
</div>
);
}
}
function TierStatLink({
tier,
label,
value,
variant = "dim",
}: {
tier: "critical" | "warning" | "low";
label: string;
value: number;
variant?: "ok" | "warn" | "alert" | "dim";
}) {
const cls =
variant === "ok" ? "text-emerald-700"
: variant === "warn" ? "text-amber-700"
: variant === "alert" ? "text-red-700 font-bold"
: "text-stone-600";
return (
<Link
to="/context"
search={{ tier }}
data-testid={`steering-stat-${label}`}
className="inline-flex items-baseline gap-0.5 hover:underline"
>
<span className="text-stone-500">{label}:</span>
<span className={cls}>{value}</span>
</Link>
);
}
function Stat({ label, value, variant = "dim" }: { label: string; value: number; variant?: "ok" | "warn" | "alert" | "dim" }) {
const cls =
variant === "ok" ? "text-emerald-700"
: variant === "warn" ? "text-amber-700"
: variant === "alert" ? "text-red-700 font-bold"
: "text-stone-600";
return (
<span data-testid={`steering-stat-${label}`} className="inline-flex items-baseline gap-0.5">
<span className="text-stone-500">{label}:</span>
<span className={cls}>{value}</span>
</span>
);
}
@@ -1,121 +0,0 @@
// Operator Surface Reconciliation v0 — Product-Specs in Motion panel.
//
// Item 1C: composes PL-005 Mission Control's `recently-active` view
// + filters to qitems whose body or destination references
// `product-specs/` PRDs. Per PRD § Item 1C, the matching strategy is
// driver-picked: we use a body-text-substring match against the
// "product-specs/" path fragment + a tag-based match when the qitem's
// tags include "spec" / "prd" / "review". Heuristic kept local to v0;
// a stricter "PRDs in motion" surface would need a typed view added
// to PL-005, which is out of scope here.
import { useQuery } from "@tanstack/react-query";
import { useWorkspace } from "../../hooks/useWorkspace.js";
import { WorkspaceKindBadge } from "../WorkspaceKindBadge.js";
interface MissionControlRow {
qitemId: string;
sourceSession?: string;
destinationSession?: string;
body?: string;
state?: string;
tier?: string | null;
tsUpdated?: string;
tags?: string[];
// Phone-friendly model 9 fields are exposed by Phase A views:
rigName?: string;
plainEnglishPhase?: string;
oneLineNextAction?: string;
pendingHumanDecision?: string;
readCost?: string;
lastUpdate?: string;
confidenceFreshness?: string;
evidenceLinkPath?: string;
}
interface MissionControlViewResponse {
viewName: string;
rows: MissionControlRow[];
}
async function fetchRecentlyActive(): Promise<MissionControlViewResponse> {
const res = await fetch("/api/mission-control/views/recently-active");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as MissionControlViewResponse;
}
function isSpecRelated(row: MissionControlRow): boolean {
const body = row.body ?? "";
const dest = row.destinationSession ?? "";
if (body.includes("product-specs/")) return true;
if (body.toLowerCase().includes("prd")) return true;
if (dest.includes("planner") || dest.includes("steward") || dest.includes("librarian")) return true;
if (row.tags?.some((t) => /\b(spec|prd|review)\b/i.test(t))) return true;
return false;
}
export function InMotionPanel() {
const workspace = useWorkspace();
const view = useQuery({
queryKey: ["mission-control", "views", "recently-active"],
queryFn: fetchRecentlyActive,
staleTime: 30_000,
});
return (
<section
data-testid="steering-in-motion"
className="border border-stone-300 bg-white"
>
<header className="flex items-baseline justify-between border-b border-stone-200 bg-stone-50 px-3 py-2">
<div className="flex items-baseline gap-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
Product-specs in motion
</div>
{/* PL-007: PRDs live in knowledge canon — surface the kind on
* the panel header so the operator-context row is typed. */}
{workspace.data?.knowledgeKind && (
<WorkspaceKindBadge kind={workspace.data.knowledgeKind} compact />
)}
</div>
<div className="font-mono text-[9px] text-stone-500">
via Mission Control · recently-active filtered to spec-related
</div>
</header>
{view.isLoading && <div className="p-3 font-mono text-[10px] text-stone-400">Loading…</div>}
{view.isError && (
<div data-testid="steering-in-motion-error" className="p-3 font-mono text-[10px] text-red-600">
Mission Control view unavailable. Configure PL-005 surfaces or check daemon.
</div>
)}
{view.data && (() => {
const filtered = view.data.rows.filter(isSpecRelated);
if (filtered.length === 0) {
return <div className="p-3 font-mono text-[10px] text-stone-400" data-testid="steering-in-motion-empty">No spec-related qitems in flight.</div>;
}
return (
<ul className="divide-y divide-stone-100">
{filtered.slice(0, 8).map((row) => (
<li
key={row.qitemId}
data-testid={`steering-in-motion-row-${row.qitemId}`}
className="px-3 py-1.5"
>
<div className="flex items-baseline gap-2">
<span className="font-mono text-[9px] text-stone-500 shrink-0">{(row.tsUpdated ?? "").slice(0, 19)}</span>
<span className="font-mono text-[10px] text-stone-800 truncate">
{row.body ?? row.qitemId}
</span>
{row.destinationSession && (
<span className="ml-auto font-mono text-[8px] text-stone-400 shrink-0">
→ {row.destinationSession}
</span>
)}
</div>
</li>
))}
</ul>
);
})()}
</section>
);
}
@@ -1,110 +0,0 @@
// Operator Surface Reconciliation v0 — Lane Rails panel.
//
// Item 1D: per-lane (delivery-ready/mode-{0..3}) top-N items + lane-
// health badges (active/blocked/done) + next-pull marker per the
// Priority Rail Rule semantics computed daemon-side.
import type { LaneRailItem, LaneRailPayload } from "../../hooks/useSteering.js";
export function LaneRailsPanel({ laneRails }: { laneRails: LaneRailPayload[] }) {
if (laneRails.length === 0) {
return (
<section
data-testid="steering-lane-rails-empty"
className="border border-stone-200 bg-stone-50 p-3 font-mono text-[10px] text-stone-500"
>
Delivery-ready lane rails source unavailable. Configure OPENRIG_DELIVERY_READY_DIR or place delivery-ready/mode-N/PROGRESS.md under the steering workspace.
</section>
);
}
return (
<section data-testid="steering-lane-rails" className="border border-stone-300 bg-white">
<header className="border-b border-stone-200 bg-stone-50 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
Delivery-ready lane rails
</div>
</header>
<div className="divide-y divide-stone-200">
{laneRails.map((lane) => (
<LaneRailSection key={lane.laneId} lane={lane} />
))}
</div>
</section>
);
}
function LaneRailSection({ lane }: { lane: LaneRailPayload }) {
return (
<div
data-testid={`steering-lane-${lane.laneId}`}
className="px-3 py-2"
>
<div className="flex items-baseline gap-2">
<div className="font-mono text-[10px] font-bold text-stone-900">{lane.laneId}</div>
<div className="ml-auto flex items-baseline gap-2">
<HealthBadge label="active" count={lane.healthBadges.active} variant="active" />
<HealthBadge label="blocked" count={lane.healthBadges.blocked} variant="blocked" />
<HealthBadge label="done" count={lane.healthBadges.done} variant="done" />
</div>
</div>
{lane.topItems.length === 0 ? (
<div className="mt-1 font-mono text-[9px] text-stone-400">No checkbox rows.</div>
) : (
<ul className="mt-1 space-y-0.5">
{lane.topItems.map((item) => (
<LaneRailRow key={item.line} item={item} />
))}
</ul>
)}
</div>
);
}
function LaneRailRow({ item }: { item: LaneRailItem }) {
const { icon, color } = statusGlyph(item.status);
return (
<li
data-testid={`steering-lane-row-${item.line}`}
data-status={item.status}
data-is-next-pull={item.isNextPull}
className={`flex items-baseline gap-2 px-1 py-0.5 ${item.isNextPull ? "bg-emerald-50" : ""}`}
>
<span className={`shrink-0 font-mono text-[10px] ${color}`} aria-label={item.status}>{icon}</span>
<span className="flex-1 font-mono text-[10px] text-stone-800 truncate">{item.text}</span>
{item.isNextPull && (
<span
data-testid={`steering-lane-row-${item.line}-next-pull`}
className="shrink-0 border border-emerald-400 bg-emerald-100 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-emerald-900"
>
next pull
</span>
)}
<span className="shrink-0 font-mono text-[8px] text-stone-400">L{item.line}</span>
</li>
);
}
function HealthBadge({ label, count, variant }: { label: string; count: number; variant: "active" | "blocked" | "done" }) {
const cls =
variant === "active" ? "border-stone-300 bg-stone-50 text-stone-700"
: variant === "blocked" ? "border-amber-300 bg-amber-50 text-amber-900"
: "border-emerald-300 bg-emerald-50 text-emerald-900";
return (
<span
data-testid={`steering-lane-badge-${variant}`}
className={`inline-flex items-baseline gap-0.5 border px-1 font-mono text-[8px] uppercase tracking-[0.10em] ${cls}`}
>
<span>{label}</span>
<span className="font-bold">{count}</span>
</span>
);
}
function statusGlyph(status: LaneRailItem["status"]): { icon: string; color: string } {
switch (status) {
case "done": return { icon: "✓", color: "text-emerald-700" };
case "blocked": return { icon: "⚠", color: "text-amber-700" };
case "active": return { icon: "◯", color: "text-stone-500" };
default: return { icon: "·", color: "text-stone-400" };
}
}
@@ -1,112 +0,0 @@
// Operator Surface Reconciliation v0 — Loop State panel.
//
// Item 1E: cross-rig roll-up of recent qitems via PL-005's
// `recently-active` view (same source as InMotionPanel; here we
// surface the most-recent qitem per actor seat without the
// product-specs filter, so the operator sees who's busy on what).
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { SessionPreviewPane } from "../preview/SessionPreviewPane.js";
import { useWorkspace } from "../../hooks/useWorkspace.js";
import { WorkspaceKindBadge } from "../WorkspaceKindBadge.js";
interface MissionControlRow {
qitemId: string;
sourceSession?: string;
destinationSession?: string;
body?: string;
state?: string;
tsUpdated?: string;
}
interface MissionControlViewResponse {
viewName: string;
rows: MissionControlRow[];
}
async function fetchRecentlyActive(): Promise<MissionControlViewResponse> {
const res = await fetch("/api/mission-control/views/recently-active");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as MissionControlViewResponse;
}
export function LoopStatePanel() {
// Preview Terminal v0 (PL-018) — click a row → toggle inline preview
// for that seat. Single-row-open at a time keeps the panel compact;
// operators wanting multiple panes pin from the node-detail drawer.
const [openSeat, setOpenSeat] = useState<string | null>(null);
const workspace = useWorkspace();
const view = useQuery({
queryKey: ["mission-control", "views", "recently-active"],
queryFn: fetchRecentlyActive,
staleTime: 30_000,
});
return (
<section data-testid="steering-loop-state" className="border border-stone-300 bg-white">
<header className="flex items-baseline justify-between border-b border-stone-200 bg-stone-50 px-3 py-2">
<div className="flex items-baseline gap-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
Loop state
</div>
{workspace.data?.knowledgeKind && (
<WorkspaceKindBadge kind={workspace.data.knowledgeKind} compact />
)}
</div>
<div className="font-mono text-[9px] text-stone-500">
most-recent qitem per seat (cross-rig roll-up)
</div>
</header>
{view.isLoading && <div className="p-3 font-mono text-[10px] text-stone-400">Loading…</div>}
{view.isError && (
<div data-testid="steering-loop-state-error" className="p-3 font-mono text-[10px] text-red-600">
Mission Control view unavailable.
</div>
)}
{view.data && (() => {
const bySeat = new Map<string, MissionControlRow>();
for (const row of view.data.rows) {
const seat = row.destinationSession ?? row.sourceSession ?? "unknown";
const existing = bySeat.get(seat);
if (!existing || (row.tsUpdated ?? "") > (existing.tsUpdated ?? "")) bySeat.set(seat, row);
}
const seats = Array.from(bySeat.entries()).sort((a, b) => (b[1].tsUpdated ?? "").localeCompare(a[1].tsUpdated ?? ""));
if (seats.length === 0) {
return <div data-testid="steering-loop-state-empty" className="p-3 font-mono text-[10px] text-stone-400">No recent qitems.</div>;
}
return (
<ul className="divide-y divide-stone-100">
{seats.slice(0, 10).map(([seat, row]) => {
const isOpen = openSeat === seat;
return (
<li
key={seat}
data-testid={`steering-loop-row-${seat}`}
data-open={isOpen ? "true" : "false"}
className="px-3 py-1"
>
<button
type="button"
data-testid={`steering-loop-row-${seat}-toggle`}
onClick={() => setOpenSeat(isOpen ? null : seat)}
className="flex w-full items-baseline gap-2 text-left hover:bg-stone-50 -mx-3 px-3 py-0.5"
>
<span className="font-mono text-[9px] text-stone-500 shrink-0">{(row.tsUpdated ?? "").slice(11, 19)}</span>
<span className="font-mono text-[10px] font-semibold text-stone-900 shrink-0 truncate max-w-[14rem]">{seat}</span>
<span className="flex-1 font-mono text-[10px] text-stone-700 truncate">{row.body ?? row.qitemId}</span>
<span className="font-mono text-[9px] text-stone-400 shrink-0">{isOpen ? "▾" : "▸"}</span>
</button>
{isOpen && seat !== "unknown" && (
<div data-testid={`steering-loop-row-${seat}-preview`} className="mt-1">
<SessionPreviewPane sessionName={seat} testIdPrefix={`loop-preview-${seat}`} />
</div>
)}
</li>
);
})}
</ul>
);
})()}
</section>
);
}
@@ -1,40 +0,0 @@
// Operator Surface Reconciliation v0 — Priority Stack panel.
//
// Item 1A: verbatim render of STEERING.md content. Uses the v0
// MarkdownViewer so the operator sees the same rendering surface
// they'd see in /files. Last-modified timestamp on the header
// surfaces freshness at a glance.
import type { PriorityStackPayload } from "../../hooks/useSteering.js";
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
export function PriorityStackPanel({ priorityStack }: { priorityStack: PriorityStackPayload | null }) {
if (!priorityStack) {
return (
<section
data-testid="steering-priority-stack-empty"
className="border border-stone-200 bg-stone-50 p-3 font-mono text-[10px] text-stone-500"
>
Priority stack source unavailable. Configure OPENRIG_STEERING_PATH or OPENRIG_STEERING_WORKSPACE.
</section>
);
}
return (
<section
data-testid="steering-priority-stack"
className="border-2 border-stone-900 bg-white"
>
<header className="flex items-baseline justify-between border-b border-stone-300 bg-stone-100 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
Priority stack — the constraint frame
</div>
<div className="font-mono text-[9px] text-stone-500" data-testid="steering-priority-stack-mtime">
last modified {priorityStack.mtime}
</div>
</header>
<div className="max-h-[60vh] overflow-y-auto p-4">
<MarkdownViewer content={priorityStack.content} hideRawToggle />
</div>
</section>
);
}
@@ -1,78 +0,0 @@
// Operator Surface Reconciliation v0 — Roadmap Rail panel.
//
// Item 1B: PL-XXX checklist + next-unchecked marker. Each unchecked
// row links to its corresponding PRD if a product-specs/<slug>/README.md
// exists; v0 simply links via the Files browser path.
import type { RoadmapRailPayload } from "../../hooks/useSteering.js";
export function RoadmapRailPanel({ roadmapRail }: { roadmapRail: RoadmapRailPayload | null }) {
if (!roadmapRail) {
return (
<section
data-testid="steering-roadmap-rail-empty"
className="border border-stone-200 bg-stone-50 p-3 font-mono text-[10px] text-stone-500"
>
Roadmap rail source unavailable. Configure OPENRIG_ROADMAP_PATH or place roadmap/PROGRESS.md under the steering workspace.
</section>
);
}
const { items, counts, mtime } = roadmapRail;
return (
<section
data-testid="steering-roadmap-rail"
className="border border-stone-300 bg-white"
>
<header className="flex items-baseline justify-between border-b border-stone-200 bg-stone-50 px-3 py-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-700">
Roadmap rail
</div>
<div className="font-mono text-[9px] text-stone-500" data-testid="steering-roadmap-rail-counts">
{counts.done} / {counts.total} done · last modified {mtime}
</div>
</header>
{items.length === 0 ? (
<div className="p-3 font-mono text-[10px] text-stone-400">No checkbox rows in roadmap PROGRESS.md.</div>
) : (
<ul className="divide-y divide-stone-100">
{items.map((item) => (
<li
key={item.line}
data-testid={`steering-roadmap-item-${item.line}`}
data-rail-code={item.railItemCode ?? ""}
data-is-next-unchecked={item.isNextUnchecked}
className={`flex items-baseline gap-2 px-3 py-1.5 ${item.isNextUnchecked ? "bg-emerald-50" : ""}`}
>
<span
className={`inline-flex shrink-0 items-center justify-center font-mono text-[10px] ${
item.done ? "text-emerald-700" : "text-stone-500"
}`}
aria-label={item.done ? "done" : "active"}
>
{item.done ? "✓" : "◯"}
</span>
{item.railItemCode && (
<span
className="shrink-0 font-mono text-[9px] font-bold uppercase tracking-[0.10em] text-stone-700"
data-testid={`steering-roadmap-item-${item.line}-code`}
>
{item.railItemCode}
</span>
)}
<span className="flex-1 font-mono text-[10px] text-stone-800">{item.text}</span>
{item.isNextUnchecked && (
<span
data-testid={`steering-roadmap-next-marker-${item.line}`}
className="shrink-0 border border-emerald-400 bg-emerald-100 px-1.5 py-0.5 font-mono text-[8px] uppercase tracking-[0.10em] text-emerald-900"
>
next pull
</span>
)}
<span className="shrink-0 font-mono text-[8px] text-stone-400">L{item.line}</span>
</li>
))}
</ul>
)}
</section>
);
}
@@ -1,94 +0,0 @@
// Operator Surface Reconciliation v0 — steering workspace.
//
// Item 1 (HEADLINE): one-screen composed steering surface. Six panels
// rendered as a single scrollable viewport:
// A. Priority stack (verbatim STEERING.md)
// B. Roadmap rail (PL-XXX checklist + next-unchecked marker)
// C. Product-specs in motion (PL-005 view consumer)
// D. Lane rails (delivery-ready/mode-{0..3} top-N + health badges)
// E. Loop state (PL-005 view consumer)
// F. Compact health gates (rig ps + rig context summaries)
//
// Read-only at v0. Each panel surfaces operator-actionable affordances
// via copy-able command hints, never daemon writes.
import { Link } from "@tanstack/react-router";
import { useSteering } from "../../hooks/useSteering.js";
import { PriorityStackPanel } from "./PriorityStackPanel.js";
import { RoadmapRailPanel } from "./RoadmapRailPanel.js";
import { InMotionPanel } from "./InMotionPanel.js";
import { LaneRailsPanel } from "./LaneRailsPanel.js";
import { LoopStatePanel } from "./LoopStatePanel.js";
import { HealthGatesPanel } from "./HealthGatesPanel.js";
function isUnavailable(data: unknown): data is { unavailable: true; error: string; hint?: string } {
return Boolean(data && typeof data === "object" && "unavailable" in (data as Record<string, unknown>));
}
export function SteeringWorkspace() {
const steering = useSteering();
return (
<div
data-testid="steering-workspace"
className="flex h-full flex-col lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]"
>
<header className="border-b border-stone-200 bg-stone-50 px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-500">Workspace</div>
<h1 className="font-headline text-xl font-bold tracking-tight text-stone-900">Steering</h1>
<div className="mt-1 font-mono text-[10px] text-stone-500">
One screen — what's the constraint, what's in motion, what does each loop need next?
</div>
</header>
<main className="flex-1 min-h-0 overflow-y-auto bg-white p-4">
{steering.isLoading && <div className="font-mono text-[10px] text-stone-400">Loading steering surface…</div>}
{steering.isError && <div className="font-mono text-[10px] text-red-600" data-testid="steering-error">Error loading steering surface.</div>}
{isUnavailable(steering.data) && (
<div data-testid="steering-unavailable" className="font-mono text-[10px] text-stone-500">
<div>Steering composer unavailable.</div>
{steering.data.hint && <div className="mt-1 text-stone-400">{steering.data.hint}</div>}
<div className="mt-3 font-mono text-[9px] text-stone-400">
<Link to="/files" className="text-blue-700 underline">
Open Files →
</Link>{" "}
to browse STEERING.md directly until the composer is configured.
</div>
</div>
)}
{steering.data && !isUnavailable(steering.data) && (
<div className="space-y-4">
<PriorityStackPanel priorityStack={steering.data.priorityStack} />
<RoadmapRailPanel roadmapRail={steering.data.roadmapRail} />
<InMotionPanel />
<LaneRailsPanel laneRails={steering.data.laneRails} />
<LoopStatePanel />
<HealthGatesPanel />
{steering.data.unavailableSources.length > 0 && (
<UnavailableSourcesNotice sources={steering.data.unavailableSources} />
)}
</div>
)}
</main>
</div>
);
}
function UnavailableSourcesNotice({ sources }: { sources: Array<{ section: string; reason: string; envVar?: string }> }) {
return (
<section
data-testid="steering-unavailable-sources"
className="border border-amber-300 bg-amber-50 p-3"
>
<div className="mb-2 font-mono text-[8px] uppercase tracking-[0.18em] text-amber-700">
Some sections are unavailable
</div>
<ul className="space-y-1 font-mono text-[10px] text-amber-900">
{sources.map((src) => (
<li key={src.section} data-testid={`steering-unavailable-${src.section}`}>
<span className="font-bold">{src.section}:</span> {src.reason}
{src.envVar && <span className="ml-2 text-amber-600">(env: {src.envVar})</span>}
</li>
))}
</ul>
</section>
);
}
@@ -1,7 +1,7 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type SectionHeaderTone = "default" | "muted" | "emphasis";
export type SectionHeaderTone = "default" | "muted" | "strong" | "alert";
type PolymorphicAs = "header" | "div" | "section";
@@ -15,7 +15,8 @@ export interface SectionHeaderProps extends React.HTMLAttributes<HTMLElement> {
const toneClass: Record<SectionHeaderTone, string> = {
default: "text-stone-900",
muted: "text-on-surface-variant",
emphasis: "text-tertiary",
strong: "text-stone-900 font-bold",
alert: "text-tertiary",
};
export function SectionHeader({
@@ -40,12 +40,16 @@ export function VellumCard({
as = "div",
className,
testId,
onClick,
...rest
}: VellumCardProps) {
const isInteractive = Boolean(href || onClick);
const containerClass = cn(
variantSurface[variant],
elevationShadow[elevation],
"flex flex-col relative",
isInteractive &&
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-stone-900 focus-visible:outline-offset-2",
accentClass,
className,
);
@@ -68,6 +72,7 @@ export function VellumCard({
href={href}
className={containerClass}
data-testid={testId}
onClick={onClick}
{...(rest as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
>
{inner}
@@ -77,20 +82,20 @@ export function VellumCard({
if (as === "article") {
return (
<article className={containerClass} data-testid={testId} {...rest}>
<article className={containerClass} data-testid={testId} onClick={onClick} {...rest}>
{inner}
</article>
);
}
if (as === "section") {
return (
<section className={containerClass} data-testid={testId} {...rest}>
<section className={containerClass} data-testid={testId} onClick={onClick} {...rest}>
{inner}
</section>
);
}
return (
<div className={containerClass} data-testid={testId} {...rest}>
<div className={containerClass} data-testid={testId} onClick={onClick} {...rest}>
{inner}
</div>
);
+298 -170
View File
@@ -1,15 +1,25 @@
import { useEffect } from "react";
// V1 Shell Redesign Phase 2 — canonical routes per shell-redesign-v1
// canon docs (universal-shell.md / dashboard.md / topology-tree.md /
// project-tree.md / specs-tree.md / for-you-feed.md / agent-chat-surface.md).
//
// Phase 2 lays the route shells. Phase 3 fills tree contents +
// destination cards + view-mode tab CONTENTS. View-mode tabs are
// IN-PLACE within a single URL per SC-5 / SC-10 (NOT separate URLs).
//
// Daemon /api/* UNTOUCHED (SC-29).
import {
createRootRoute,
createRoute,
createRouter,
Navigate,
Outlet,
useParams,
} from "@tanstack/react-router";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./lib/query-client.js";
import { AppShell } from "./components/AppShell.js";
import { RigGraph } from "./components/RigGraph.js";
import { WorkspaceHome } from "./components/WorkspaceHome.js";
import { ImportFlow } from "./components/ImportFlow.js";
import { PackageList } from "./components/PackageList.js";
import { PackageInstallFlow } from "./components/PackageInstallFlow.js";
@@ -23,14 +33,23 @@ import { BundleInstallFlow } from "./components/BundleInstallFlow.js";
import { LibraryReview } from "./components/LibraryReview.js";
import { LiveNodeDetails } from "./components/LiveNodeDetails.js";
import { DiscoveryOverlay } from "./components/DiscoveryOverlay.js";
import { MissionControlSurface } from "./components/mission-control/MissionControlSurface.js";
import { SliceStoryView } from "./components/slices/SliceStoryView.js";
import { ProgressWorkspace } from "./components/progress/ProgressWorkspace.js";
import { FilesWorkspace } from "./components/files/FilesWorkspace.js";
import { SteeringWorkspace } from "./components/steering/SteeringWorkspace.js";
import { ContextWorkspace } from "./components/context/ContextWorkspace.js";
import { AuditHistoryView } from "./components/mission-control/views/AuditHistoryView.js";
import { useRigSummary } from "./hooks/useRigSummary.js";
import { useDrawerSelection } from "./components/AppShell.js";
import { EmptyState } from "./components/ui/empty-state.js";
// Phase 2 placeholder for canon destinations Phase 3 will fill.
function DestinationPlaceholder({ label, description }: { label: string; description?: string }) {
return (
<div className="flex h-full w-full items-center justify-center p-8">
<EmptyState
label={label}
description={description ?? "Phase 3 will fill this destination."}
variant="card"
testId={`placeholder-${label.toLowerCase().replace(/\s+/g, "-")}`}
/>
</div>
);
}
// Root route — wraps everything in AppShell
const rootRoute = createRootRoute({
@@ -43,19 +62,178 @@ const rootRoute = createRootRoute({
),
});
// Index route — Dashboard
// =====================================================================
// Canon destinations (Phase 2 lays shells; Phase 3 fills)
// =====================================================================
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: WorkspaceHome,
component: () => (
<DestinationPlaceholder
label="DASHBOARD"
description="6-card launcher (Topology / Project / For You / Specs / Search / Settings) — Phase 3 fills."
/>
),
});
// Rig detail route — Graph + SnapshotPanel
// Topology destination: SC-5 / SC-10 — single URL with view-mode tabs IN-PLACE
// (graph / table / terminal). Phase 2 lays the four scope routes; Phase 3
// renders the tabs and fills view-mode content.
const topologyRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/topology",
component: () => (
<DestinationPlaceholder
label="TOPOLOGY"
description="Host scope (graph / table / terminal view-mode tabs IN-PLACE per SC-10) — Phase 3 fills."
/>
),
});
const topologyRigRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/topology/rig/$rigId",
component: () => (
<DestinationPlaceholder
label="TOPOLOGY — RIG SCOPE"
description="Rig-scoped graph / table / terminal / overview tabs — Phase 3 fills."
/>
),
});
const topologyPodRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/topology/pod/$rigId/$podName",
component: () => (
<DestinationPlaceholder
label="TOPOLOGY — POD SCOPE"
description="Pod-scoped graph / table / terminal / overview tabs — Phase 3 fills."
/>
),
});
const topologySeatRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/topology/seat/$rigId/$logicalId",
component: () => (
<DestinationPlaceholder
label="TOPOLOGY — SEAT SCOPE"
description="Seat-scoped detail / transcript / terminal tabs — Phase 3 fills."
/>
),
});
const forYouRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/for-you",
component: () => (
<DestinationPlaceholder
label="FOR YOU"
description="Cross-cutting attention feed (5 card types: ACTION / APPROVAL / SHIPPED / PROGRESS / OBSERVATION) — Phase 3 fills."
/>
),
});
const projectRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project",
component: () => (
<DestinationPlaceholder
label="PROJECT"
description="Workspace > mission > slice tree — Phase 3 fills."
/>
),
});
const projectMissionRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project/mission/$missionId",
component: () => (
<DestinationPlaceholder
label="PROJECT — MISSION"
description="Mission scope — Phase 3 fills."
/>
),
});
const projectSliceRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project/slice/$sliceId",
component: () => (
<DestinationPlaceholder
label="PROJECT — SLICE"
description="Slice scope (story / overview / progress / artifacts / tests / queue / topology tabs) — Phase 3 fills."
/>
),
});
const specsLibraryRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/specs",
component: () => (
<DestinationPlaceholder
label="SPECS LIBRARY"
description="Top-level: toolbar + tanstack-table + search + filter chips — Phase 3 fills."
/>
),
});
const specsApplicationsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/specs/applications",
component: () => (
<DestinationPlaceholder
label="SPECS — APPLICATIONS"
description="Phase 3 fills."
/>
),
});
// Generic spec detail by kind/name — Phase 3 fills with kind-aware routing
// (rigs / workspaces / workflows / context-packs / agent-images / applications).
const specsKindRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/specs/$specKind/$specName",
component: () => {
const { specKind, specName } = useParams({ from: "/specs/$specKind/$specName" });
return (
<DestinationPlaceholder
label={`SPEC: ${specKind.toUpperCase()} / ${specName}`}
description="Existing spec detail page mounted here — Phase 3 wires."
/>
);
},
});
const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/settings",
component: () => (
<DestinationPlaceholder
label="SETTINGS"
description="3-tab Settings / Log / Status — Phase 3 fills. Mounts in CENTER workspace per SC-7 (NOT right sidebar)."
/>
),
});
const searchRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/search",
component: AuditHistoryView,
});
// =====================================================================
// Existing routes preserved per code-map AFTER tree (not in delete list)
// =====================================================================
// Rig graph (legacy detail; topology destination supersedes — keep until
// Phase 3 wires /topology/rig/$rigId).
function RigDetail() {
const { rigId } = rigDetailRoute.useParams();
const { rigId } = useParams({ from: "/rigs/$rigId" });
const { data: rigs } = useRigSummary();
const rigName = rigs?.find((r: { id: string; name: string }) => r.id === rigId)?.name;
return (
<div className="flex flex-col flex-1 h-full">
<div className="flex-1 min-h-[400px] relative">
@@ -71,28 +249,39 @@ const rigDetailRoute = createRoute({
component: RigDetail,
});
// Import route
const liveNodeDetailsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/rigs/$rigId/nodes/$logicalId",
component: () => {
const { rigId, logicalId } = useParams({ from: "/rigs/$rigId/nodes/$logicalId" });
return <LiveNodeDetails rigId={rigId} logicalId={decodeURIComponent(logicalId)} />;
},
});
const importRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/import",
component: ImportFlow,
});
// Packages route
const packagesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/packages",
component: PackageList,
});
// Package install route
const packageInstallRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/packages/install",
component: PackageInstallFlow,
});
// Bootstrap route
const packageDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/packages/$packageId",
component: PackageDetail,
});
const bootstrapRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/bootstrap",
@@ -105,86 +294,41 @@ const agentValidateRoute = createRoute({
component: AgentSpecValidateFlow,
});
function DiscoveryRouteBridge() {
const { setSelection } = useDrawerSelection();
useEffect(() => {
setSelection({ type: "discovery" });
}, [setSelection]);
return <WorkspaceHome />;
}
function SpecsRouteBridge() {
const { setSelection } = useDrawerSelection();
useEffect(() => {
setSelection({ type: "specs" });
}, [setSelection]);
return <WorkspaceHome />;
}
function RigSpecReviewRoute() {
const { setSelection } = useDrawerSelection();
useEffect(() => {
setSelection({ type: "specs" });
}, [setSelection]);
return <RigSpecReview />;
}
function AgentSpecReviewRoute() {
const { setSelection } = useDrawerSelection();
useEffect(() => {
setSelection({ type: "specs" });
}, [setSelection]);
return <AgentSpecReview />;
}
// Discovery route
const discoveryRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/discovery",
component: DiscoveryRouteBridge,
});
const specsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/specs",
component: SpecsRouteBridge,
});
// /specs/rig + /specs/agent + /specs/library/$entryId — existing review
// surfaces preserved per code-map AFTER tree. No drawer auto-open
// (SC-6: drawer default-closed; opens only on named triggers).
const rigSpecReviewRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/specs/rig",
component: RigSpecReviewRoute,
component: RigSpecReview,
});
const agentSpecReviewRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/specs/agent",
component: AgentSpecReviewRoute,
component: AgentSpecReview,
});
function LibraryReviewRoute() {
const { entryId } = libraryReviewRoute.useParams();
const { setSelection } = useDrawerSelection();
useEffect(() => {
setSelection({ type: "specs" });
}, [setSelection]);
return <LibraryReview entryId={entryId} />;
}
const libraryReviewRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/specs/library/$entryId",
component: LibraryReviewRoute,
component: () => {
const { entryId } = useParams({ from: "/specs/library/$entryId" });
return <LibraryReview entryId={entryId} />;
},
});
// Discovery — preserved per code-map ("discovery surface — keep but evaluate route").
// No drawer auto-open (SC-6).
const discoveryRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/discovery",
component: () => (
<DestinationPlaceholder
label="DISCOVERY"
description="Discovery surface preserved; redesign deferred per code-map."
/>
),
});
const discoveryInventoryRoute = createRoute({
@@ -193,7 +337,6 @@ const discoveryInventoryRoute = createRoute({
component: DiscoveryOverlay,
});
// Bundle routes
const bundleInspectRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/bundles/inspect",
@@ -206,95 +349,83 @@ const bundleInstallRoute = createRoute({
component: BundleInstallFlow,
});
// Package detail route
const packageDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/packages/$packageId",
component: PackageDetail,
});
// =====================================================================
// Redirects from deleted routes
// =====================================================================
// Route tree
// Live node full details route
const liveNodeDetailsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/rigs/$rigId/nodes/$logicalId",
component: () => {
const { rigId, logicalId } = liveNodeDetailsRoute.useParams();
return <LiveNodeDetails rigId={rigId} logicalId={decodeURIComponent(logicalId)} />;
},
});
// PL-005 Phase A: Mission Control / Queue Observability surface.
const missionControlRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/mission-control",
component: MissionControlSurface,
});
// Slice Story View v0: top-level /slices route + /slices/:name detail.
// The component reads :name via useParams when present and renders
// the two-pane layout (filter+list on the left, six tabs on the right).
const slicesIndexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/slices",
component: SliceStoryView,
});
const sliceDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/slices/$name",
component: SliceStoryView,
});
// UI Enhancement Pack v0: top-level Progress (item 1B) + Files
// (item 3 + item 4) workspaces. Both are read-only pages with their
// own internal layout — no left-sidebar nav coupling.
const progressRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/progress",
component: ProgressWorkspace,
});
const filesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/files",
component: FilesWorkspace,
});
// Operator Surface Reconciliation v0 — top-level Steering route (item 1).
const steeringRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/steering",
component: SteeringWorkspace,
});
// Token / Context Usage Surface v0 (PL-012) — /context portfolio dashboard.
interface ContextSearch {
tier?: "critical" | "warning" | "low" | "unknown";
runtime?: string;
rigId?: string;
}
const contextRoute = createRoute({
// /context redirects to /topology (SC-25 — relocates to topology table view; Phase 3 wires the actual view).
const contextRedirectRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/context",
component: ContextWorkspace,
validateSearch: (search: Record<string, unknown>): ContextSearch => {
const out: ContextSearch = {};
if (search["tier"] === "critical" || search["tier"] === "warning"
|| search["tier"] === "low" || search["tier"] === "unknown") out.tier = search["tier"];
if (typeof search["runtime"] === "string") out.runtime = search["runtime"];
if (typeof search["rigId"] === "string") out.rigId = search["rigId"];
return out;
component: () => <Navigate to="/topology" />,
});
// /mission-control DELETED per SC-18 — redirect to /for-you (which replaces it).
const missionControlRedirectRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/mission-control",
component: () => <Navigate to="/for-you" />,
});
// /slices DELETED per project-tree.md — redirect to /project.
const slicesRedirectRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/slices",
component: () => <Navigate to="/project" />,
});
const sliceDetailRedirectRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/slices/$name",
component: () => {
const { name } = useParams({ from: "/slices/$name" });
return <Navigate to="/project/slice/$sliceId" params={{ sliceId: name }} />;
},
});
// /progress folds into Project tabs (Phase 3) — redirect to /project.
const progressRedirectRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/progress",
component: () => <Navigate to="/project" />,
});
// /steering folds into Project workspace overview tab (Phase 3) — redirect to /project.
const steeringRedirectRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/steering",
component: () => <Navigate to="/project" />,
});
// =====================================================================
// Route tree
// =====================================================================
const routeTree = rootRoute.addChildren([
// Canon destinations
indexRoute,
topologyRoute,
topologyRigRoute,
topologyPodRoute,
topologySeatRoute,
forYouRoute,
projectRoute,
projectMissionRoute,
projectSliceRoute,
specsLibraryRoute,
specsApplicationsRoute,
specsKindRoute,
settingsRoute,
searchRoute,
// Preserved existing routes
rigDetailRoute,
liveNodeDetailsRoute,
importRoute,
packagesRoute,
packageInstallRoute,
packageDetailRoute,
bootstrapRoute,
agentValidateRoute,
specsRoute,
rigSpecReviewRoute,
agentSpecReviewRoute,
libraryReviewRoute,
@@ -302,20 +433,17 @@ const routeTree = rootRoute.addChildren([
discoveryInventoryRoute,
bundleInspectRoute,
bundleInstallRoute,
liveNodeDetailsRoute,
missionControlRoute,
slicesIndexRoute,
sliceDetailRoute,
progressRoute,
filesRoute,
steeringRoute,
contextRoute,
// Redirects from deleted routes
contextRedirectRoute,
missionControlRedirectRoute,
slicesRedirectRoute,
sliceDetailRedirectRoute,
progressRedirectRoute,
steeringRedirectRoute,
]);
// Router
export const router = createRouter({ routeTree });
// Type registration for type-safe navigation
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
+190 -596
View File
@@ -1,5 +1,22 @@
// V1 attempt-3 Phase 2 — AppShell chrome tests.
//
// Replaces the legacy 655-line test that exercised the pre-Phase-2 shell
// (slices-link / specs-toggle / discovery-toggle / progress-link /
// steering-link / context-link / system-toggle). Phase 2 deleted those
// header buttons; the rail with 6+2 icons takes over destination
// switching.
//
// Coverage:
// - SC-1 — exactly 2 left chromes on desktop (rail + explore); Sidebar.tsx GONE
// - SC-2 — rail roster: 6 destinations + 2 chat icons in spec'd order
// - SC-6 — drawer default-closed (selection=null → null render)
// - SC-7 — Settings rail icon links to /settings (center, not drawer)
// - SC-8 — mobile rail collapses to top-bar menu (hamburger present at <lg)
// - Surface routing — Explorer renders for tree/lens destinations;
// not for Dashboard / Settings (surface=none)
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
import { render, cleanup, waitFor } from "@testing-library/react";
import { createMemoryHistory, RouterProvider, createRouter } from "@tanstack/react-router";
import { createMockEventSourceClass } from "./helpers/mock-event-source.js";
@@ -10,9 +27,15 @@ let OriginalEventSource: typeof EventSource | undefined;
beforeEach(async () => {
mockFetch.mockReset();
// Default rig/ps mocks return empty so chrome can render.
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/api/rigs/summary")) return new Response(JSON.stringify([]));
if (url.includes("/api/rigs/ps")) return new Response(JSON.stringify([]));
if (url.includes("/api/inventory")) return new Response(JSON.stringify([]));
return new Response("[]");
});
OriginalEventSource = globalThis.EventSource;
globalThis.EventSource = createMockEventSourceClass() as unknown as typeof EventSource;
// Clear production queryClient cache between tests to prevent stale data
const { queryClient } = await import("../src/lib/query-client.js");
queryClient.clear();
});
@@ -29,627 +52,198 @@ afterEach(() => {
window.dispatchEvent(new Event("resize"));
});
function setViewportWidth(width: number) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: width,
writable: true,
});
async function renderAt(initialPath: string) {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1440, writable: true });
window.dispatchEvent(new Event("resize"));
const { router } = await import("../src/routes.js");
const memoryHistory = createMemoryHistory({ initialEntries: [initialPath] });
const memoryRouter = createRouter({ routeTree: router.routeTree, history: memoryHistory });
const result = render(<RouterProvider router={memoryRouter} />);
// TanStack Router resolves route component async; wait for chrome to land.
await waitFor(() => {
expect(result.container.querySelector("[data-testid='app-rail']")).toBeTruthy();
}, { timeout: 5000 });
return result;
}
function mockAllApis() {
mockFetch.mockImplementation((url: string) => {
if (url === "/api/rigs/summary") {
return Promise.resolve({
ok: true,
json: async () => [{ id: "r1", name: "alpha", nodeCount: 3, latestSnapshotAt: null, latestSnapshotId: null }],
});
}
if (url === "/api/ps") {
return Promise.resolve({
ok: true,
json: async () => [{ rigId: "r1", name: "alpha", nodeCount: 3, runningCount: 3, status: "running", uptime: "1h", latestSnapshot: null }],
});
}
if (url === "/healthz") {
return Promise.resolve({ ok: true, json: async () => ({ status: "ok" }) });
}
if (url === "/api/adapters/cmux/status") {
return Promise.resolve({ ok: true, json: async () => ({ available: true }) });
}
if (typeof url === "string" && url.startsWith("/api/slices?filter=")) {
return Promise.resolve({
ok: true,
json: async () => ({
filter: new URL(`http://test${url}`).searchParams.get("filter") ?? "active",
totalCount: 1,
slices: [
{
name: "mission-control-queue-observability-phase-a",
displayName: "Mission Control Phase A",
railItem: "PL-005",
status: "done",
rawStatus: "phase-a-closed-locally-promoted",
qitemCount: 0,
hasProofPacket: true,
lastActivityAt: "2026-05-04T12:00:00.000Z",
},
],
}),
});
}
if (typeof url === "string" && url.includes("/api/slices/mission-control-queue-observability-phase-a")) {
return Promise.resolve({
ok: true,
json: async () => ({
name: "mission-control-queue-observability-phase-a",
displayName: "Mission Control Phase A",
railItem: "PL-005",
status: "done",
rawStatus: "phase-a-closed-locally-promoted",
qitemIds: [],
commitRefs: [],
lastActivityAt: "2026-05-04T12:00:00.000Z",
story: { events: [] },
acceptance: { totalItems: 0, doneItems: 0, percentage: 0, items: [], closureCallout: null },
decisions: { rows: [] },
docs: { tree: [] },
tests: { proofPackets: [], aggregate: { passCount: 0, failCount: 0 } },
topology: { affectedRigs: [], totalSeats: 0 },
}),
});
}
if (typeof url === "string" && url.includes("/graph")) {
return Promise.resolve({
ok: true,
json: async () => ({
nodes: [{ id: "n1", type: "rigNode", position: { x: 0, y: 0 }, data: { logicalId: "orchestrator", role: "orchestrator", runtime: "claude-code", model: "opus", status: "running", binding: null } }],
edges: [],
}),
});
}
if (typeof url === "string" && url.includes("/nodes/")) {
return Promise.resolve({
ok: true,
json: async () => ({
rigId: "r1",
rigName: "alpha",
logicalId: "orch.lead",
podId: "orch",
canonicalSessionName: "orch-lead@alpha",
nodeKind: "agent",
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready",
restoreOutcome: "resumed",
tmuxAttachCommand: "tmux attach -t orch-lead@alpha",
resumeCommand: "claude --resume abc-123",
latestError: null,
model: "opus",
agentRef: "local:agents/lead",
profile: "default",
resolvedSpecName: "lead",
resolvedSpecVersion: "1.0.0",
startupFiles: [],
startupActions: [],
recentEvents: [],
infrastructureStartupCommand: null,
binding: { tmuxSession: "orch-lead@alpha" },
}),
});
}
if (typeof url === "string" && url.includes("/api/rigs/r1/nodes")) {
return Promise.resolve({
ok: true,
json: async () => [
{ rigId: "r1", rigName: "alpha", logicalId: "orch.lead", podId: "orch", nodeKind: "agent", runtime: "claude-code", startupStatus: "ready", canonicalSessionName: "orch-lead@alpha" },
],
});
}
if (typeof url === "string" && url.includes("/snapshots")) {
return Promise.resolve({ ok: true, json: async () => [] });
}
if (typeof url === "string" && url.includes("/api/specs/library")) {
return Promise.resolve({ ok: true, json: async () => [] });
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
}
describe("AppShell — Phase 2 chrome", () => {
describe("SC-1: exactly 2 left chromes on desktop (rail + explore)", () => {
it("renders exactly 2 left chromes at /topology desktop (rail + explore) — SC-1 strict count", async () => {
const { container } = await renderAt("/topology");
const chromeCount = container.querySelectorAll("nav, aside").length;
expect(chromeCount).toBe(2);
// No legacy Sidebar.tsx anywhere — file is deleted.
expect(container.querySelector("[data-testid='sidebar']")).toBeNull();
});
function mockApisFailing() {
mockFetch.mockImplementation(() => {
return Promise.reject(new Error("connection refused"));
});
}
it("Dashboard surface (/) renders rail but NO Explorer (surface=none)", async () => {
const { container } = await renderAt("/");
expect(container.querySelector("[data-testid='app-rail']")).toBeTruthy();
expect(container.querySelector("[data-testid='explorer']")).toBeNull();
});
/**
* Render the REAL app router from routes.tsx at a given path.
* This uses the production route tree, not a test-only rebuild.
*/
async function renderRealAppAt(path: string) {
// Import the real route tree from routes.tsx
const mod = await import("../src/routes.js");
// Create a new router instance with memory history for testing
// We access the route tree from the exported router
const router = createRouter({
routeTree: mod.router.routeTree,
history: createMemoryHistory({ initialEntries: [path] }),
});
return render(<RouterProvider router={router} />);
}
describe("App Shell + Routing", () => {
it("root route renders AppShell with sidebar and content area", async () => {
mockAllApis();
await renderRealAppAt("/");
await waitFor(() => {
expect(screen.getByTestId("app-header")).toBeDefined();
expect(screen.getByTestId("explorer")).toBeDefined();
expect(screen.getByTestId("content-area")).toBeDefined();
expect(screen.getByTestId("specs-toggle")).toBeDefined();
expect(screen.getByTestId("system-toggle")).toBeDefined();
it("Settings surface (/settings) renders rail but NO Explorer (surface=none)", async () => {
const { container } = await renderAt("/settings");
expect(container.querySelector("[data-testid='app-rail']")).toBeTruthy();
expect(container.querySelector("[data-testid='explorer']")).toBeNull();
});
});
it("header brand links back to home and the gear is borderless", async () => {
mockAllApis();
await renderRealAppAt("/rigs/r1");
await waitFor(() => {
expect(screen.getByTestId("brand-home-link")).toBeDefined();
expect(screen.getByTestId("discovery-toggle")).toBeDefined();
expect(screen.getByTestId("specs-toggle")).toBeDefined();
expect(screen.getByTestId("system-toggle")).toBeDefined();
});
const brand = screen.getByTestId("brand-home-link");
expect(brand.getAttribute("href")).toBe("/");
expect(brand.className).toContain("bg-stone-950");
const gear = screen.getByTestId("system-toggle");
expect(gear.className).not.toContain("border");
expect(gear.className).not.toContain("bg-white");
});
it("header specs button opens the specs drawer", async () => {
mockAllApis();
await renderRealAppAt("/rigs/r1");
await waitFor(() => {
expect(screen.getByTestId("specs-toggle")).toBeDefined();
});
fireEvent.click(screen.getByTestId("specs-toggle"));
await waitFor(() => {
expect(screen.getByTestId("specs-panel")).toBeDefined();
expect(screen.getByText("Import RigSpec")).toBeDefined();
expect(screen.getByText("Bootstrap")).toBeDefined();
expect(screen.getByText("Validate AgentSpec")).toBeDefined();
});
});
it("wide layouts keep the specs drawer open across navigation", async () => {
mockAllApis();
setViewportWidth(1280);
await renderRealAppAt("/specs");
await waitFor(() => {
expect(screen.getByTestId("specs-panel")).toBeDefined();
expect(screen.getByText("Import RigSpec")).toBeDefined();
});
fireEvent.click(screen.getByText("Import RigSpec"));
await waitFor(() => {
expect(screen.getByTestId("import-flow")).toBeDefined();
expect(screen.getByTestId("specs-panel")).toBeDefined();
});
});
it("wide layouts keep specs open when launching validate agent", async () => {
mockAllApis();
setViewportWidth(1280);
await renderRealAppAt("/specs");
await waitFor(() => {
expect(screen.getByTestId("specs-panel")).toBeDefined();
expect(screen.getByText("Validate AgentSpec")).toBeDefined();
});
fireEvent.click(screen.getByText("Validate AgentSpec"));
await waitFor(() => {
expect(screen.getByTestId("agent-spec-validate-flow")).toBeDefined();
expect(screen.getByTestId("specs-panel")).toBeDefined();
});
});
it("wide layouts keep specs open when opening a rig draft review from the drawer", async () => {
window.localStorage.setItem("rigged.specs.recent-rig-drafts", JSON.stringify([
{
id: "rig-review",
kind: "rig",
label: "review-target",
yaml: "name: review-target\npods: []\n",
updatedAt: Date.now(),
},
]));
mockAllApis();
setViewportWidth(1280);
await renderRealAppAt("/specs");
await waitFor(() => {
expect(screen.getByTestId("specs-panel")).toBeDefined();
expect(screen.getByText("review-target")).toBeDefined();
});
fireEvent.click(screen.getByText("review-target"));
await waitFor(() => {
expect(screen.getByTestId("rig-spec-review")).toBeDefined();
expect(screen.getByTestId("specs-panel")).toBeDefined();
});
});
it("wide layouts keep specs open when opening an agent draft review from the drawer", async () => {
window.localStorage.setItem("rigged.specs.recent-agent-drafts", JSON.stringify([
{
id: "agent-review",
kind: "agent",
label: "qa",
yaml: "name: qa\nprofiles:\n default:\n",
updatedAt: Date.now(),
},
]));
mockAllApis();
setViewportWidth(1280);
await renderRealAppAt("/specs");
await waitFor(() => {
expect(screen.getByTestId("specs-panel")).toBeDefined();
expect(screen.getByText("qa")).toBeDefined();
});
fireEvent.click(screen.getByText("qa"));
await waitFor(() => {
expect(screen.getByTestId("agent-spec-review")).toBeDefined();
expect(screen.getByTestId("specs-panel")).toBeDefined();
});
});
it("compact layouts clear the specs drawer on navigation to a workspace page", async () => {
mockAllApis();
setViewportWidth(768);
await renderRealAppAt("/specs");
await waitFor(() => {
expect(screen.getByTestId("specs-panel")).toBeDefined();
expect(screen.getByText("Import RigSpec")).toBeDefined();
});
fireEvent.click(screen.getByText("Import RigSpec"));
await waitFor(() => {
expect(screen.getByTestId("import-flow")).toBeDefined();
expect(screen.queryByTestId("specs-panel")).toBeNull();
});
});
it("header discovery button opens the discovery drawer", async () => {
mockAllApis();
await renderRealAppAt("/rigs/r1");
await waitFor(() => {
expect(screen.getByTestId("discovery-toggle")).toBeDefined();
});
fireEvent.click(screen.getByTestId("discovery-toggle"));
await waitFor(() => {
expect(screen.getByTestId("discovery-panel")).toBeDefined();
});
});
it("header no longer renders top navigation links", async () => {
mockAllApis();
await renderRealAppAt("/");
await waitFor(() => {
expect(screen.getByTestId("app-header")).toBeDefined();
});
const header = screen.getByTestId("app-header");
expect(header.textContent).not.toContain("RIGS");
expect(header.textContent).not.toContain("SPECS");
expect(header.textContent).not.toContain("DISCOVERY");
});
it("desktop explorer is controlled by a single edge toggle", async () => {
mockAllApis();
await renderRealAppAt("/");
await waitFor(() => {
expect(screen.getByTestId("explorer-edge-toggle")).toBeDefined();
expect(screen.getByTestId("explorer")).toBeDefined();
});
fireEvent.click(screen.getByTestId("explorer-edge-toggle"));
await waitFor(() => {
expect(screen.getByTestId("explorer").className).toContain("lg:w-12");
expect(screen.getByTestId("explorer-edge-toggle").getAttribute("aria-label")).toContain("Expand");
});
fireEvent.click(screen.getByTestId("explorer-edge-toggle"));
await waitFor(() => {
expect(screen.getByTestId("explorer").className).not.toContain("lg:hidden");
expect(screen.getByTestId("explorer-edge-toggle").getAttribute("aria-label")).toContain("Collapse");
});
});
it("/ renders the explorer-first workspace home", async () => {
mockAllApis();
await renderRealAppAt("/");
await waitFor(() => {
expect(screen.getByTestId("workspace-home")).toBeDefined();
expect(screen.getByText(/Select a rig from the explorer/i)).toBeDefined();
expect(screen.getByTestId("workspace-open-explorer")).toBeDefined();
});
});
it("workspace Explore button reopens the explorer", async () => {
mockAllApis();
await renderRealAppAt("/");
await waitFor(() => {
expect(screen.getByTestId("explorer-edge-toggle")).toBeDefined();
});
fireEvent.click(screen.getByTestId("explorer-edge-toggle"));
await waitFor(() => {
expect(screen.getByTestId("explorer").className).toContain("lg:w-12");
});
fireEvent.click(screen.getByTestId("workspace-open-explorer"));
await waitFor(() => {
expect(screen.getByTestId("explorer").className).toContain("lg:w-72");
expect(screen.getByTestId("explorer-edge-toggle").getAttribute("aria-label")).toContain("Collapse");
});
});
it("sidebar keeps Explore and Slices as tabs in the same explorer column", async () => {
mockAllApis();
await renderRealAppAt("/slices/mission-control-queue-observability-phase-a");
await waitFor(() => {
expect(screen.getByTestId("explorer-mode-tabs")).toBeDefined();
expect(screen.getByTestId("explorer-tab-explore")).toBeDefined();
expect(screen.getByTestId("explorer-tab-slices")).toBeDefined();
expect(screen.getByTestId("slice-list-pane")).toBeDefined();
expect(screen.getByTestId("slice-detail-pane")).toBeDefined();
});
expect(screen.getByTestId("explorer-tab-slices").getAttribute("data-active")).toBe("true");
expect(screen.getByTestId("explorer-tab-explore").getAttribute("data-active")).toBe("false");
fireEvent.click(screen.getByTestId("explorer-tab-explore"));
await waitFor(() => {
expect(screen.getByTestId("environment-branch-local")).toBeDefined();
});
expect(screen.getByTestId("explorer-tab-explore").getAttribute("data-active")).toBe("true");
expect(screen.queryByTestId("slice-list-pane")).toBeNull();
expect(screen.getByTestId("slice-detail-pane")).toBeDefined();
fireEvent.click(screen.getByTestId("explorer-tab-slices"));
await waitFor(() => {
expect(screen.getByTestId("slice-list-pane")).toBeDefined();
});
expect(screen.getByTestId("explorer-tab-slices").getAttribute("data-active")).toBe("true");
});
it("workspace discovery button opens the discovery drawer instead of routing away", async () => {
mockAllApis();
await renderRealAppAt("/");
await waitFor(() => {
expect(screen.getByTestId("workspace-open-discovery")).toBeDefined();
});
fireEvent.click(screen.getByTestId("workspace-open-discovery"));
await waitFor(() => {
expect(screen.getByTestId("discovery-panel")).toBeDefined();
});
});
it("workspace specs button opens the specs drawer instead of routing away", async () => {
mockAllApis();
await renderRealAppAt("/");
await waitFor(() => {
expect(screen.getByTestId("workspace-open-specs")).toBeDefined();
});
fireEvent.click(screen.getByTestId("workspace-open-specs"));
await waitFor(() => {
expect(screen.getByTestId("specs-panel")).toBeDefined();
});
});
it("/rigs/:rigId renders without standalone SnapshotPanel", async () => {
mockAllApis();
await renderRealAppAt("/rigs/r1");
// Wait for route to render
await waitFor(() => {
expect(screen.getByTestId("content-area")).toBeDefined();
});
// SnapshotPanel should NOT be in the route (moved to rig drawer)
expect(screen.queryByTestId("snapshot-panel")).toBeNull();
});
it("/import renders import flow", async () => {
mockAllApis();
await renderRealAppAt("/import");
await waitFor(() => {
expect(screen.getByTestId("import-flow")).toBeDefined();
expect(screen.getByTestId("header-surface-title").textContent).toBe("Specs");
});
});
it("/agents/validate renders the agent validation flow with a Specs title", async () => {
mockAllApis();
mockFetch.mockImplementation((url: string) => {
if (url === "/api/rigs/summary") {
return Promise.resolve({
ok: true,
json: async () => [{ id: "r1", name: "alpha", nodeCount: 3, latestSnapshotAt: null, latestSnapshotId: null }],
});
describe("SC-2: rail roster — 6 destinations + 2 chat icons", () => {
it("rail renders 6 destination icons in canonical order: Dashboard, Topology, For You, Project, Specs, Settings", async () => {
const { container } = await renderAt("/");
const expectedDestinations = [
"rail-dashboard",
"rail-topology",
"rail-for-you",
"rail-project",
"rail-specs",
"rail-settings",
];
for (const id of expectedDestinations) {
expect(container.querySelector(`[data-testid='${id}']`)).toBeTruthy();
}
if (url === "/api/ps") {
return Promise.resolve({
ok: true,
json: async () => [{ rigId: "r1", name: "alpha", nodeCount: 3, runningCount: 3, status: "running", uptime: "1h", latestSnapshot: null }],
});
});
it("rail renders 2 chat icons (Advisor + Operator) per agent-chat-surface.md V1 placeholder", async () => {
const { container } = await renderAt("/");
expect(container.querySelector("[data-testid='rail-advisor']")).toBeTruthy();
expect(container.querySelector("[data-testid='rail-operator']")).toBeTruthy();
});
it("rail does NOT include a Discovery icon (legacy header pattern removed)", async () => {
const { container } = await renderAt("/");
expect(container.querySelector("[data-testid='rail-discovery']")).toBeNull();
expect(container.querySelector("[data-testid='discovery-toggle']")).toBeNull();
});
it("Settings rail icon points to /settings (SC-7: Settings in center, NOT drawer)", async () => {
const { container } = await renderAt("/");
const settingsIcon = container.querySelector("[data-testid='rail-settings']") as HTMLAnchorElement | null;
expect(settingsIcon).toBeTruthy();
expect(settingsIcon?.getAttribute("href")).toBe("/settings");
});
});
describe("Active rail state", () => {
it("Topology rail icon active at /topology", async () => {
const { container } = await renderAt("/topology");
const icon = container.querySelector("[data-testid='rail-topology']") as HTMLElement;
expect(icon.getAttribute("data-active")).toBe("true");
});
it("Topology rail icon active at /rigs/$rigId (legacy graph route)", async () => {
const { container } = await renderAt("/rigs/abc");
const icon = container.querySelector("[data-testid='rail-topology']") as HTMLElement;
expect(icon.getAttribute("data-active")).toBe("true");
});
it("Project rail icon active at /project", async () => {
const { container } = await renderAt("/project");
const icon = container.querySelector("[data-testid='rail-project']") as HTMLElement;
expect(icon.getAttribute("data-active")).toBe("true");
});
it("For You rail icon active at /for-you", async () => {
const { container } = await renderAt("/for-you");
const icon = container.querySelector("[data-testid='rail-for-you']") as HTMLElement;
expect(icon.getAttribute("data-active")).toBe("true");
});
});
describe("SC-6: drawer default-closed", () => {
it("SharedDetailDrawer is NOT rendered at any default route (selection=null)", async () => {
for (const path of ["/", "/topology", "/for-you", "/project", "/specs", "/settings"]) {
const { container, unmount } = await renderAt(path);
expect(container.querySelector("[data-testid='shared-detail-drawer']")).toBeNull();
unmount();
}
if (url === "/healthz") return Promise.resolve({ ok: true, json: async () => ({ status: "ok" }) });
if (url === "/api/adapters/cmux/status") return Promise.resolve({ ok: true, json: async () => ({ available: true }) });
return Promise.resolve({ ok: true, json: async () => ({}) });
});
await renderRealAppAt("/agents/validate");
await waitFor(() => {
expect(screen.getByTestId("agent-spec-validate-flow")).toBeDefined();
expect(screen.getByTestId("header-surface-title").textContent).toBe("Specs");
});
});
it("explorer renders with rig tree", async () => {
mockAllApis();
await renderRealAppAt("/");
await waitFor(() => expect(screen.getByTestId("explorer")).toBeDefined());
});
it("system gear opens the system drawer on the log tab by default", async () => {
mockAllApis();
await renderRealAppAt("/");
await waitFor(() => {
expect(screen.getByTestId("system-toggle")).toBeDefined();
describe("Surface routing — Explorer surface union", () => {
it("Topology routes set surface=topology", async () => {
const { container } = await renderAt("/topology");
const explorer = container.querySelector("[data-testid='explorer']") as HTMLElement;
expect(explorer?.getAttribute("data-surface")).toBe("topology");
});
fireEvent.click(screen.getByTestId("system-toggle"));
it("Project routes set surface=project", async () => {
const { container } = await renderAt("/project");
const explorer = container.querySelector("[data-testid='explorer']") as HTMLElement;
expect(explorer?.getAttribute("data-surface")).toBe("project");
});
await waitFor(() => {
expect(screen.getByTestId("system-panel")).toBeDefined();
expect(screen.getByTestId("system-log-tab")).toBeDefined();
expect(screen.getByTestId("system-tab-log").className).toContain("font-bold");
expect(screen.getByTestId("system-log-tab").className).toContain("flex");
expect(screen.getByTestId("system-log-tab").className).toContain("flex-col");
expect(screen.getByTestId("system-log-tab").className).toContain("overflow-hidden");
it("Specs routes set surface=specs", async () => {
const { container } = await renderAt("/specs");
const explorer = container.querySelector("[data-testid='explorer']") as HTMLElement;
expect(explorer?.getAttribute("data-surface")).toBe("specs");
});
it("For You route sets surface=for-you", async () => {
const { container } = await renderAt("/for-you");
const explorer = container.querySelector("[data-testid='explorer']") as HTMLElement;
expect(explorer?.getAttribute("data-surface")).toBe("for-you");
});
});
it("system panel shows cmux control label with honest description", async () => {
mockAllApis();
await renderRealAppAt("/");
await waitFor(() => {
expect(screen.getByTestId("system-toggle")).toBeDefined();
describe("SC-8: mobile rail collapses to top-bar menu", () => {
it("mobile top-bar (hamburger + brand) is rendered with lg:hidden", async () => {
const { container } = await renderAt("/");
const topbar = container.querySelector("[data-testid='app-mobile-topbar']") as HTMLElement;
expect(topbar).toBeTruthy();
expect(topbar.className).toContain("lg:hidden");
const hamburger = container.querySelector("[data-testid='mobile-menu-toggle']");
expect(hamburger).toBeTruthy();
});
fireEvent.click(screen.getByTestId("system-toggle"));
// Switch to status tab
await waitFor(() => {
expect(screen.getByTestId("system-panel")).toBeDefined();
});
fireEvent.click(screen.getByTestId("system-tab-status"));
await waitFor(() => {
expect(screen.getByText("cmux control")).toBeDefined();
expect(screen.getByText(/OpenRig can control cmux/)).toBeDefined();
it("mobile rail tray renders only at narrow viewport (conditional)", async () => {
// Switch to narrow viewport, then re-render.
const { router } = await import("../src/routes.js");
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375, writable: true });
window.dispatchEvent(new Event("resize"));
const memoryHistory = createMemoryHistory({ initialEntries: ["/"] });
const memoryRouter = createRouter({ routeTree: router.routeTree, history: memoryHistory });
const { container } = render(<RouterProvider router={memoryRouter} />);
await waitFor(() => {
const topbar = container.querySelector("[data-testid='app-mobile-topbar']") as HTMLElement;
expect(topbar).toBeTruthy();
});
const tray = container.querySelector("[data-testid='mobile-rail-tray']") as HTMLElement;
expect(tray).toBeTruthy();
expect(tray.className).toContain("-translate-x-full");
});
});
it("/discovery opens the discovery drawer over the workspace shell", async () => {
mockAllApis();
await renderRealAppAt("/discovery");
await waitFor(() => {
expect(screen.getByTestId("discovery-panel")).toBeDefined();
expect(screen.getByTestId("workspace-home")).toBeDefined();
expect(screen.getByTestId("header-surface-title").textContent).toBe("Discovery");
});
});
it("/discovery/inventory renders the inventory workspace with a discovery title", async () => {
mockAllApis();
await renderRealAppAt("/discovery/inventory");
await waitFor(() => {
expect(screen.getByTestId("discovery-overlay")).toBeDefined();
expect(screen.getByTestId("header-surface-title").textContent).toBe("Discovery");
});
});
it("system status tab spells out daemon and cmux state", async () => {
mockAllApis();
await renderRealAppAt("/");
await waitFor(() => {
expect(screen.getByTestId("system-toggle")).toBeDefined();
describe("Legacy buttons removed (Phase 2 deleted Sidebar + header toggle pattern)", () => {
it("specs-toggle button does NOT exist", async () => {
const { container } = await renderAt("/");
expect(container.querySelector("[data-testid='specs-toggle']")).toBeNull();
});
fireEvent.click(screen.getByTestId("system-toggle"));
await waitFor(() => {
expect(screen.getByTestId("system-panel")).toBeDefined();
});
fireEvent.click(screen.getByTestId("system-tab-status"));
await waitFor(() => {
expect(screen.getByTestId("system-daemon-status").textContent).toBe("connected");
expect(screen.getByTestId("system-cmux-status").textContent).toBe("available");
});
});
it("rig detail route renders full-width without snapshot panel", async () => {
mockAllApis();
await renderRealAppAt("/rigs/r1");
await waitFor(() => {
expect(screen.getByTestId("content-area")).toBeDefined();
expect(screen.getByTestId("header-surface-title").textContent).toBe("alpha");
});
expect(screen.queryByTestId("snapshot-panel")).toBeNull();
});
it("home route leaves the header title blank", async () => {
mockAllApis();
await renderRealAppAt("/");
await waitFor(() => {
expect(screen.getByTestId("app-header")).toBeDefined();
it("system-toggle button does NOT exist", async () => {
const { container } = await renderAt("/");
expect(container.querySelector("[data-testid='system-toggle']")).toBeNull();
});
expect(screen.queryByTestId("header-surface-title")).toBeNull();
it("slices-link button does NOT exist", async () => {
const { container } = await renderAt("/");
expect(container.querySelector("[data-testid='slices-link']")).toBeNull();
});
it("steering-link button does NOT exist", async () => {
const { container } = await renderAt("/");
expect(container.querySelector("[data-testid='steering-link']")).toBeNull();
});
it("context-link button does NOT exist", async () => {
const { container } = await renderAt("/");
expect(container.querySelector("[data-testid='context-link']")).toBeNull();
});
it("progress-link button does NOT exist", async () => {
const { container } = await renderAt("/");
expect(container.querySelector("[data-testid='progress-link']")).toBeNull();
});
});
});
-113
View File
@@ -1,113 +0,0 @@
// Token / Context Usage Surface v0 (PL-012) — ContextWorkspace tests.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { createTestRouter } from "./helpers/test-router.js";
import { ContextWorkspace } from "../src/components/context/ContextWorkspace.js";
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
mockFetch.mockReset();
});
afterEach(() => cleanup());
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}) as unknown as Response;
}
function fixtureFleet() {
mockFetch.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/ps") {
return jsonResponse([
{ rigId: "rig-a", name: "alpha-rig" },
{ rigId: "rig-b", name: "beta-rig" },
]);
}
if (url.includes("/api/rigs/rig-a/nodes")) {
return jsonResponse([
{ rigId: "rig-a", rigName: "alpha-rig", logicalId: "core.lead", canonicalSessionName: "lead@alpha", runtime: "claude-code", contextUsage: { availability: "known", usedPercentage: 92, fresh: true, sampledAt: "2026-05-04T12:00:00Z" } },
{ rigId: "rig-a", rigName: "alpha-rig", logicalId: "core.qa", canonicalSessionName: "qa@alpha", runtime: "codex", contextUsage: { availability: "known", usedPercentage: 65, fresh: true, sampledAt: "2026-05-04T12:00:00Z" } },
]);
}
if (url.includes("/api/rigs/rig-b/nodes")) {
return jsonResponse([
{ rigId: "rig-b", rigName: "beta-rig", logicalId: "dev.impl", canonicalSessionName: "impl@beta", runtime: "claude-code", contextUsage: { availability: "known", usedPercentage: 30, fresh: true, sampledAt: "2026-05-04T12:00:00Z" } },
{ rigId: "rig-b", rigName: "beta-rig", logicalId: "dev.unknown", canonicalSessionName: null, runtime: "terminal", contextUsage: { availability: "unknown", usedPercentage: null, fresh: false, sampledAt: null } },
]);
}
return jsonResponse({});
});
}
describe("ContextWorkspace — PL-012", () => {
it("renders header summary with correct tier counts", async () => {
fixtureFleet();
render(createTestRouter({ component: () => <ContextWorkspace />, path: "/context" }));
await waitFor(() => expect(screen.getByTestId("context-workspace")).toBeDefined());
expect(screen.getByTestId("context-stat-total").textContent).toContain("4");
expect(screen.getByTestId("context-stat-critical").textContent).toContain("1"); // 92%
expect(screen.getByTestId("context-stat-warning").textContent).toContain("1"); // 65%
expect(screen.getByTestId("context-stat-ok").textContent).toContain("1"); // 30%
expect(screen.getByTestId("context-stat-unknown").textContent).toContain("1");
});
it("renders per-seat list sorted by usedPercentage descending", async () => {
fixtureFleet();
render(createTestRouter({ component: () => <ContextWorkspace />, path: "/context" }));
await waitFor(() => expect(screen.getByTestId("context-seat-list")).toBeDefined());
// Query the <li> elements directly (children of context-seat-list)
const list = screen.getByTestId("context-seat-list");
const items = Array.from(list.querySelectorAll<HTMLElement>("li[data-tier]"));
expect(items.map((el) => el.getAttribute("data-tier"))).toEqual(["critical", "warning", "low", "unknown"]);
});
it("tier filter narrows to matching seats", async () => {
fixtureFleet();
render(createTestRouter({ component: () => <ContextWorkspace />, path: "/context" }));
await waitFor(() => expect(screen.getByTestId("context-filter-tier-critical")).toBeDefined());
fireEvent.click(screen.getByTestId("context-filter-tier-critical"));
await waitFor(() => {
expect(screen.getByTestId("context-seat-core.lead")).toBeDefined();
expect(screen.queryByTestId("context-seat-core.qa")).toBeNull();
});
});
it("runtime filter narrows by runtime", async () => {
fixtureFleet();
render(createTestRouter({ component: () => <ContextWorkspace />, path: "/context" }));
await waitFor(() => expect(screen.getByTestId("context-filter-runtime-codex")).toBeDefined());
fireEvent.click(screen.getByTestId("context-filter-runtime-codex"));
await waitFor(() => {
expect(screen.getByTestId("context-seat-core.qa")).toBeDefined();
expect(screen.queryByTestId("context-seat-core.lead")).toBeNull();
});
});
it("Compact button copies the rig compact-plan command for the seat's rig", async () => {
fixtureFleet();
let copiedText = "";
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn(async (text: string) => { copiedText = text; }) },
writable: true,
configurable: true,
});
render(createTestRouter({ component: () => <ContextWorkspace />, path: "/context" }));
await waitFor(() => expect(screen.getByTestId("context-seat-core.lead-compact")).toBeDefined());
fireEvent.click(screen.getByTestId("context-seat-core.lead-compact"));
await waitFor(() => expect(copiedText).toBe("rig compact-plan --rig alpha-rig"));
});
it("renders honest fallback when /api/ps errors (cross-CLI-version drift)", async () => {
mockFetch.mockImplementation(async () => jsonResponse({ error: "ps_unavailable" }, 503));
render(createTestRouter({ component: () => <ContextWorkspace />, path: "/context" }));
await waitFor(() => expect(screen.getByTestId("context-workspace-error")).toBeDefined());
expect(screen.getByTestId("context-workspace-error").textContent).toContain("rig context --json");
});
});
@@ -1,201 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MissionControlSurface } from "../src/components/mission-control/MissionControlSurface.js";
import { CompactStatusRow } from "../src/components/mission-control/components/CompactStatusRow.js";
import { VerbActions } from "../src/components/mission-control/components/VerbActions.js";
import { AuditHistoryView } from "../src/components/mission-control/views/AuditHistoryView.js";
const fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
function queryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function renderSurface() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
render(
<QueryClientProvider client={queryClient}>
<MissionControlSurface />
</QueryClientProvider>,
);
}
function renderWithQueryClient(element: React.ReactNode) {
render(
<QueryClientProvider client={queryClient()}>
{element}
</QueryClientProvider>,
);
}
describe("MissionControlSurface", () => {
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
viewName: "my-queue",
rows: [],
meta: { rowCount: 0 },
}),
});
window.localStorage.clear();
window.history.replaceState(null, "", "/mission-control");
});
it("accounts for AppShell explorer offsets so tabs stay clickable", () => {
renderSurface();
const surface = screen.getByTestId("mc-surface");
expect(surface.className).toContain("lg:pl-[var(--workspace-left-offset,0px)]");
expect(surface.className).toContain("lg:pr-[var(--workspace-right-offset,0px)]");
expect((screen.getByTestId("mc-tab-human-gate") as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByTestId("mc-tab-fleet") as HTMLButtonElement).disabled).toBe(false);
});
it("stores mcToken from the URL for mobile write verbs and removes it from the address bar", async () => {
window.history.replaceState(null, "", "/mission-control?mcToken=phone-token&view=human-gate&qitem=qitem-1");
renderSurface();
await waitFor(() => {
expect(window.localStorage.getItem("openrig.missionControlBearerToken")).toBe("phone-token");
});
expect(window.location.search).not.toContain("mcToken");
expect(window.location.search).toContain("view=human-gate");
expect(window.location.search).toContain("qitem=qitem-1");
});
it("sends the stored bearer token on Mission Control write verbs", async () => {
window.localStorage.setItem("openrig.missionControlBearerToken", "phone-token");
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
actionId: "action-1",
verb: "approve",
qitemId: "qitem-1",
closedQitem: {},
createdQitemId: null,
notifyAttempted: false,
notifyResult: null,
auditedAt: "2026-05-04T00:00:00.000Z",
}),
});
renderWithQueryClient(
<VerbActions qitemId="qitem-1" actorSession="human-wrandom@kernel" enabledVerbs={["approve"]} />,
);
fireEvent.click(screen.getByTestId("mc-verb-approve"));
fireEvent.click(screen.getByTestId("mc-verb-submit"));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/mission-control/action",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer phone-token",
}),
}),
);
});
});
it("shows qitem context and expands full details when a phone row is tapped", () => {
render(
<CompactStatusRow
row={{
rigOrMissionName: "human-wrandom@kernel",
currentPhase: "human-gate",
state: "idle",
nextAction: null,
pendingHumanDecision: "urgent human-gate item",
readCost: "skim/approve",
lastUpdate: "2026-05-04T06:43:48.863Z",
confidenceFreshness: "urgent",
evidenceLink: null,
qitemId: "qitem-phone",
rawSourceRef: "velocity.qa@openrig-velocity",
qitemSummary: "Approve the release candidate",
qitemBody: "Approve the release candidate after checking the phone notification path.",
}}
/>,
);
expect(screen.getByTestId("mc-qitem-summary").textContent).toContain("Approve the release candidate");
expect(screen.queryByTestId("mc-qitem-body")).toBeNull();
fireEvent.click(screen.getByTestId("mc-status-row"));
expect(screen.getByTestId("mc-qitem-body").textContent).toContain(
"Approve the release candidate after checking the phone notification path.",
);
expect(screen.getByTestId("mc-qitem-id").textContent).toContain("qitem-phone");
expect((screen.getByTestId("mc-qitem-audit-link") as HTMLAnchorElement).href).toContain(
"/mission-control?view=audit-history&qitem_id=qitem-phone",
);
});
it("prefills audit filters from URL params for phone deep links", async () => {
window.history.replaceState(
null,
"",
"/mission-control?view=audit-history&qitem_id=qitem-phone&action_verb=approve",
);
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
rows: [],
hasMore: false,
nextBeforeId: null,
}),
});
renderWithQueryClient(<AuditHistoryView />);
expect(screen.getByTestId("mc-audit-filter-qitem-id")).toHaveProperty("value", "qitem-phone");
expect(screen.getByTestId("mc-audit-filter-action-verb")).toHaveProperty("value", "approve");
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/mission-control/audit?qitem_id=qitem-phone&action_verb=approve&limit=50",
);
});
});
it("shows handoff destination candidates instead of only a raw text field", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
destinations: [
{
sessionName: "velocity.qa@openrig-velocity",
label: "velocity.qa@openrig-velocity",
source: "queue",
},
],
}),
});
renderWithQueryClient(
<VerbActions qitemId="qitem-1" actorSession="human-wrandom@kernel" enabledVerbs={["handoff"]} />,
);
fireEvent.click(screen.getByTestId("mc-verb-handoff"));
const select = await screen.findByTestId("mc-verb-destination-select");
await waitFor(() => {
expect(select.textContent).toContain("velocity.qa@openrig-velocity");
});
});
});
-289
View File
@@ -1,289 +0,0 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Mock fetch globally
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function QueryWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("useExpandRig mutation", () => {
it("calls expansion API and invalidates queries", async () => {
const { useExpandRig } = await import("../src/hooks/mutations.js");
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => ({ ok: true, status: "ok", podNamespace: "infra", nodes: [] }),
});
let expandFn: ReturnType<typeof useExpandRig>["mutateAsync"];
function TestComp() {
const expand = useExpandRig();
expandFn = expand.mutateAsync;
return <div>test</div>;
}
render(<QueryWrapper><TestComp /></QueryWrapper>);
await expandFn!({ rigId: "rig-1", pod: { id: "infra", label: "Infra", members: [], edges: [] } });
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/rigs/rig-1/expand"),
expect.objectContaining({ method: "POST" }),
);
});
});
describe("RigDetailPanel Add Pod", () => {
it("renders Add Pod button in rig drawer", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (typeof url === "string" && url.includes("/summary")) return { ok: true, json: async () => [] };
if (typeof url === "string" && url.includes("/ps")) return { ok: true, json: async () => [] };
if (typeof url === "string" && url.includes("/nodes")) return { ok: true, json: async () => [] };
if (typeof url === "string" && url.includes("/snapshots")) return { ok: true, json: async () => [] };
return { ok: true, json: async () => ({}) };
});
const { RigDetailPanel } = await import("../src/components/RigDetailPanel.js");
render(
<QueryWrapper>
<RigDetailPanel rigId="rig-1" onClose={vi.fn()} />
</QueryWrapper>,
);
await waitFor(() => {
expect(screen.getByTestId("rig-add-pod")).toBeDefined();
});
});
it("shows YAML form when Add Pod clicked", async () => {
mockFetch.mockImplementation(async () => ({ ok: true, json: async () => [] }));
const { RigDetailPanel } = await import("../src/components/RigDetailPanel.js");
render(
<QueryWrapper>
<RigDetailPanel rigId="rig-1" onClose={vi.fn()} />
</QueryWrapper>,
);
await waitFor(() => {
expect(screen.getByTestId("rig-add-pod")).toBeDefined();
});
fireEvent.click(screen.getByTestId("rig-add-pod"));
await waitFor(() => {
expect(screen.getByTestId("add-pod-form")).toBeDefined();
expect(screen.getByTestId("add-pod-yaml")).toBeDefined();
expect(screen.getByTestId("add-pod-submit")).toBeDefined();
});
});
it("form hidden before Add Pod is clicked", async () => {
mockFetch.mockImplementation(async () => ({ ok: true, json: async () => [] }));
const { RigDetailPanel } = await import("../src/components/RigDetailPanel.js");
render(
<QueryWrapper>
<RigDetailPanel rigId="rig-1" onClose={vi.fn()} />
</QueryWrapper>,
);
await waitFor(() => {
expect(screen.getByTestId("rig-add-pod")).toBeDefined();
});
expect(screen.queryByTestId("add-pod-form")).toBeNull();
});
it("shows expansion outcome after submit", async () => {
let expandCalled = false;
mockFetch.mockImplementation(async (url: string, opts?: RequestInit) => {
if (typeof url === "string" && url.includes("/expand") && opts?.method === "POST") {
expandCalled = true;
return {
ok: true,
status: 201,
json: async () => ({
ok: true,
status: "ok",
podNamespace: "test-pod",
nodes: [{ logicalId: "test-pod.worker", nodeId: "n1", status: "launched" }],
}),
};
}
return { ok: true, json: async () => [] };
});
const { RigDetailPanel } = await import("../src/components/RigDetailPanel.js");
render(
<QueryWrapper>
<RigDetailPanel rigId="rig-1" onClose={vi.fn()} />
</QueryWrapper>,
);
await waitFor(() => {
expect(screen.getByTestId("rig-add-pod")).toBeDefined();
});
fireEvent.click(screen.getByTestId("rig-add-pod"));
await waitFor(() => {
expect(screen.getByTestId("add-pod-yaml")).toBeDefined();
});
fireEvent.change(screen.getByTestId("add-pod-yaml"), {
target: { value: "id: test-pod\nlabel: Test\nmembers:\n - id: worker\n runtime: terminal\nedges: []" },
});
fireEvent.click(screen.getByTestId("add-pod-submit"));
await waitFor(() => {
expect(expandCalled).toBe(true);
});
});
});
describe("SpecsPanel Add to Rig", () => {
function mockSpecsData() {
mockFetch.mockImplementation(async (url: string, opts?: RequestInit) => {
if (typeof url === "string" && url.includes("/api/specs/library/lib-1/review")) {
return {
ok: true,
json: async () => ({
kind: "rig",
name: "research-team",
format: "pod_aware",
pods: [{ id: "research", label: "Research", members: [{ id: "analyst", runtime: "claude-code" }], edges: [] }],
edges: [],
raw: "",
}),
};
}
if (typeof url === "string" && url.includes("/api/specs/library") && !url.includes("/review")) {
return {
ok: true,
json: async () => [
{ id: "lib-1", name: "research-team", kind: "rig", version: "0.2", sourceType: "builtin" },
],
};
}
if (typeof url === "string" && url.includes("/api/ps")) {
return {
ok: true,
json: async () => [
{ rigId: "rig-1", name: "demo-rig", status: "running", nodeCount: 2, runningCount: 2 },
],
};
}
if (typeof url === "string" && url.includes("/expand") && opts?.method === "POST") {
return {
ok: true, status: 201,
json: async () => ({ ok: true, status: "ok", podNamespace: "research", nodes: [{ logicalId: "research.analyst", nodeId: "n1", status: "launched" }] }),
};
}
return { ok: true, json: async () => [] };
});
}
async function renderSpecsPanel() {
const { createTestRouter } = await import("./helpers/test-router.js");
const { SpecsPanel } = await import("../src/components/SpecsPanel.js");
return render(createTestRouter({ component: () => <SpecsPanel onClose={vi.fn()} />, path: "/test" }));
}
it("renders + Rig button on library rig entries", async () => {
mockSpecsData();
await renderSpecsPanel();
await waitFor(() => {
expect(screen.getByTestId("library-add-to-rig-lib-1")).toBeDefined();
});
});
it("clicking + Rig shows Add to Rig flow with rig selector", async () => {
mockSpecsData();
await renderSpecsPanel();
await waitFor(() => {
expect(screen.getByTestId("library-add-to-rig-lib-1")).toBeDefined();
});
fireEvent.click(screen.getByTestId("library-add-to-rig-lib-1"));
await waitFor(() => {
expect(screen.getByTestId("add-to-rig-flow")).toBeDefined();
expect(screen.getByTestId("add-to-rig-select")).toBeDefined();
});
});
it("selecting rig and submitting calls expansion route and shows outcome", async () => {
mockSpecsData();
await renderSpecsPanel();
await waitFor(() => {
expect(screen.getByTestId("library-add-to-rig-lib-1")).toBeDefined();
});
fireEvent.click(screen.getByTestId("library-add-to-rig-lib-1"));
await waitFor(() => {
expect(screen.getByTestId("add-to-rig-select")).toBeDefined();
});
fireEvent.change(screen.getByTestId("add-to-rig-select"), { target: { value: "rig-1" } });
// Wait for review to load and submit button to appear
await waitFor(() => {
expect(screen.getByTestId("add-to-rig-submit")).toBeDefined();
});
fireEvent.click(screen.getByTestId("add-to-rig-submit"));
await waitFor(() => {
expect(screen.getByTestId("expand-result")).toBeDefined();
});
});
it("shows 'No pods available' for specs without pods", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (typeof url === "string" && url.includes("/api/specs/library/lib-2/review")) {
return { ok: true, json: async () => ({ kind: "rig", name: "empty-rig", format: "pod_aware", pods: [], edges: [], raw: "" }) };
}
if (typeof url === "string" && url.includes("/api/specs/library") && !url.includes("/review")) {
return { ok: true, json: async () => [{ id: "lib-2", name: "empty-rig", kind: "rig", version: "0.2", sourceType: "builtin" }] };
}
return { ok: true, json: async () => [] };
});
await renderSpecsPanel();
await waitFor(() => {
expect(screen.getByTestId("library-add-to-rig-lib-2")).toBeDefined();
});
fireEvent.click(screen.getByTestId("library-add-to-rig-lib-2"));
await waitFor(() => {
expect(screen.getByText("No pods available in this spec.")).toBeDefined();
});
});
});
+9 -2
View File
@@ -33,11 +33,18 @@ describe("SectionHeader (Phase 1 primitive)", () => {
);
});
it("tone=emphasis applies tertiary color", () => {
const { container } = render(<SectionHeader tone="emphasis">X</SectionHeader>);
it("tone=alert applies tertiary color", () => {
const { container } = render(<SectionHeader tone="alert">X</SectionHeader>);
expect((container.firstChild as HTMLElement).className).toContain("text-tertiary");
});
it("tone=strong applies stone-900 + font-bold", () => {
const { container } = render(<SectionHeader tone="strong">X</SectionHeader>);
const cls = (container.firstChild as HTMLElement).className;
expect(cls).toContain("text-stone-900");
expect(cls).toContain("font-bold");
});
it("default as=header element", () => {
const { container } = render(<SectionHeader>X</SectionHeader>);
expect(container.querySelector("header")).toBeTruthy();
@@ -1,134 +0,0 @@
// Workflows in Spec Library + Activation Lens v0 — lens consumption.
//
// Verifies that SliceExplorerPanel reads /api/specs/library/active-lens,
// passes boundToWorkflow=<name>:<version> to /api/slices when a lens is
// active, renders the lens indicator + Clear button + Show All toggle.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { createTestRouter } from "./helpers/test-router.js";
import { SliceExplorerPanel } from "../src/components/slices/SliceExplorerPanel.js";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}) as unknown as Response;
}
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
mockFetch.mockReset();
});
afterEach(() => cleanup());
describe("SliceExplorerPanel — active lens consumption (Workflows in Spec Library v0)", () => {
it("does not render the lens indicator when no lens is active", async () => {
mockFetch.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/specs/library/active-lens")) {
return jsonResponse({ activeLens: null });
}
return jsonResponse({ slices: [], totalCount: 0, filter: "active" });
});
render(createTestRouter({ component: () => <SliceExplorerPanel />, path: "/slices" }));
await waitFor(() => expect(screen.getByTestId("slice-list-pane")).toBeDefined());
expect(screen.queryByTestId("slice-lens-indicator")).toBeNull();
});
it("renders lens indicator + sends boundToWorkflow to /api/slices when lens is active", async () => {
const sliceCalls: string[] = [];
mockFetch.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/specs/library/active-lens")) {
return jsonResponse({
activeLens: { specName: "rsi-v2-hot-potato", specVersion: "1", activatedAt: "2026-05-04T00:00:00Z" },
});
}
if (url.includes("/api/slices")) {
sliceCalls.push(url);
return jsonResponse({
slices: [{
name: "alpha", displayName: "Alpha", railItem: null, status: "active", rawStatus: "active",
qitemCount: 1, hasProofPacket: false, lastActivityAt: "2026-05-04T00:00:00Z",
}],
totalCount: 1,
filter: "active",
boundToWorkflow: { specName: "rsi-v2-hot-potato", specVersion: "1", matched: 1, total: 5 },
});
}
return jsonResponse({});
});
render(createTestRouter({ component: () => <SliceExplorerPanel />, path: "/slices" }));
await waitFor(() => expect(screen.getByTestId("slice-lens-indicator")).toBeDefined());
expect(screen.getByTestId("slice-lens-indicator").textContent).toContain("rsi-v2-hot-potato");
expect(screen.getByTestId("slice-lens-indicator").textContent).toContain("v1");
await waitFor(() => {
expect(sliceCalls.some((u) => u.includes("boundToWorkflow=rsi-v2-hot-potato%3A1") || u.includes("boundToWorkflow=rsi-v2-hot-potato:1"))).toBe(true);
});
});
it("Show All toggle clears the boundToWorkflow filter param", async () => {
const sliceCalls: string[] = [];
mockFetch.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/specs/library/active-lens")) {
return jsonResponse({
activeLens: { specName: "rsi", specVersion: "1", activatedAt: "2026-05-04T00:00:00Z" },
});
}
if (url.includes("/api/slices")) {
sliceCalls.push(url);
return jsonResponse({ slices: [], totalCount: 0, filter: "active" });
}
return jsonResponse({});
});
render(createTestRouter({ component: () => <SliceExplorerPanel />, path: "/slices" }));
await waitFor(() => expect(screen.getByTestId("slice-lens-show-all-toggle")).toBeDefined());
sliceCalls.length = 0;
fireEvent.click(screen.getByTestId("slice-lens-show-all-toggle"));
await waitFor(() => {
// After toggle, expect at least one slice fetch without boundToWorkflow
expect(sliceCalls.some((u) => !u.includes("boundToWorkflow"))).toBe(true);
});
});
it("Clear button calls DELETE /api/specs/library/active-lens", async () => {
let deleteCalled = false;
mockFetch.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/api/specs/library/active-lens")) {
if (init?.method === "DELETE") {
deleteCalled = true;
return jsonResponse({ activeLens: null });
}
return jsonResponse({
activeLens: { specName: "rsi", specVersion: "1", activatedAt: "2026-05-04T00:00:00Z" },
});
}
if (url.includes("/api/slices")) {
return jsonResponse({ slices: [], totalCount: 0, filter: "active" });
}
return jsonResponse({});
});
render(createTestRouter({ component: () => <SliceExplorerPanel />, path: "/slices" }));
await waitFor(() => expect(screen.getByTestId("slice-lens-clear")).toBeDefined());
fireEvent.click(screen.getByTestId("slice-lens-clear"));
await waitFor(() => expect(deleteCalled).toBe(true));
});
});
@@ -1,166 +0,0 @@
// Slice Story View v0 — top-level surface tests.
//
// Covers the Slice Story View shell (route detail pane + existing AppShell
// explorer content). Each tab's deep behavior is tested in its own focused
// component test (TestsVerificationTab.test.tsx is the load-bearing
// example); this file confirms the surface composition + filter UX +
// tab switching + the "slices indexer unavailable" graceful path.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { createTestRouter } from "./helpers/test-router.js";
import { SliceExplorerPanel } from "../src/components/slices/SliceExplorerPanel.js";
import { SliceStoryView } from "../src/components/slices/SliceStoryView.js";
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
mockFetch.mockReset();
});
afterEach(() => cleanup());
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}) as unknown as Response;
}
function makeListEntries() {
return [
{
name: "alpha-slice",
displayName: "Alpha Slice",
railItem: "PL-005",
status: "active",
rawStatus: "active",
qitemCount: 3,
hasProofPacket: true,
lastActivityAt: "2026-05-04T12:00:00.000Z",
},
{
name: "beta-slice",
displayName: "Beta Slice",
railItem: "PL-019",
status: "done",
rawStatus: "shipped",
qitemCount: 5,
hasProofPacket: true,
lastActivityAt: "2026-05-03T12:00:00.000Z",
},
];
}
function makeDetailPayload(overrides: Partial<Record<string, unknown>> = {}) {
return {
name: "alpha-slice",
displayName: "Alpha Slice",
railItem: "PL-005",
status: "active",
rawStatus: "active",
qitemIds: ["q1"],
commitRefs: [],
lastActivityAt: "2026-05-04T12:00:00.000Z",
story: { events: [
{ ts: "2026-05-01T10:00:00.000Z", phase: "discovery", kind: "queue.created", actorSession: "intake@kernel", qitemId: "q1", summary: "intake scoped slice", detail: null },
] },
acceptance: { totalItems: 4, doneItems: 2, percentage: 50, items: [
{ text: "Item 1", done: true, source: { file: "README.md", line: 12 } },
{ text: "Item 2", done: false, source: { file: "README.md", line: 13 } },
], closureCallout: null },
decisions: { rows: [] },
docs: { tree: [] },
tests: { proofPackets: [], aggregate: { passCount: 0, failCount: 0 } },
topology: { affectedRigs: [], totalSeats: 0 },
...overrides,
};
}
describe("PL-slice-story-view-v0 SliceStoryView", () => {
it("renders 'pick a slice' empty state when no name in URL", async () => {
mockFetch.mockResolvedValue(jsonResponse({ slices: makeListEntries(), totalCount: 2, filter: "active" }));
render(createTestRouter({ component: () => <SliceStoryView />, path: "/slices", initialPath: "/slices" }));
await waitFor(() => expect(screen.getByTestId("slice-no-selection")).toBeDefined());
});
it("keeps route content offset from the AppShell explorer instead of owning a second sidebar", async () => {
mockFetch.mockResolvedValue(jsonResponse({ slices: makeListEntries(), totalCount: 2, filter: "active" }));
render(createTestRouter({ component: () => <SliceStoryView />, path: "/slices", initialPath: "/slices" }));
await waitFor(() => expect(screen.getByTestId("slice-story-view")).toBeDefined());
expect(screen.getByTestId("slice-story-view").className).toContain("lg:pl-[var(--workspace-left-offset,0px)]");
expect(screen.getByTestId("slice-story-view").className).toContain("lg:pr-[var(--workspace-right-offset,0px)]");
expect(screen.queryByTestId("slice-list-pane")).toBeNull();
});
it("SliceExplorerPanel renders the four-filter row defaulting to 'active'", async () => {
mockFetch.mockResolvedValue(jsonResponse({ slices: [], totalCount: 0, filter: "active" }));
render(createTestRouter({ component: () => <SliceExplorerPanel />, path: "/slices" }));
await waitFor(() => expect(screen.getByTestId("slice-filter-row")).toBeDefined());
for (const filter of ["all", "active", "done", "blocked"]) {
expect(screen.getByTestId(`slice-filter-${filter}`)).toBeDefined();
}
expect(screen.getByTestId("slice-filter-active").getAttribute("data-active")).toBe("true");
});
it("SliceExplorerPanel clicking a filter triggers a refetch with that filter", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("filter=active")) return jsonResponse({ slices: [], totalCount: 0, filter: "active" });
if (url.includes("filter=all")) return jsonResponse({ slices: makeListEntries(), totalCount: 2, filter: "all" });
return jsonResponse({ slices: [], totalCount: 0, filter: "all" });
});
render(createTestRouter({ component: () => <SliceExplorerPanel />, path: "/slices" }));
await waitFor(() => expect(mockFetch).toHaveBeenCalledWith("/api/slices?filter=active"));
fireEvent.click(screen.getByTestId("slice-filter-all"));
await waitFor(() => expect(mockFetch).toHaveBeenCalledWith("/api/slices?filter=all"));
});
it("SliceExplorerPanel surfaces 'slices indexer unavailable' hint when daemon returns 503", async () => {
mockFetch.mockResolvedValue(jsonResponse({
error: "slices_root_not_configured",
hint: "Set OPENRIG_SLICES_ROOT to the directory containing slice folders",
}, 503));
render(createTestRouter({ component: () => <SliceExplorerPanel />, path: "/slices" }));
await waitFor(() => expect(screen.getByTestId("slice-list-unavailable")).toBeDefined());
expect(screen.getByTestId("slice-list-unavailable").textContent).toContain("OPENRIG_SLICES_ROOT");
});
it("renders all six tab nav buttons with the canonical labels when a slice is selected", async () => {
// Switch the filter list response + add detail response.
mockFetch.mockImplementation(async (url: string) => {
if (url.startsWith("/api/slices/alpha-slice")) return jsonResponse(makeDetailPayload());
return jsonResponse({ slices: makeListEntries(), totalCount: 2, filter: "active" });
});
render(createTestRouter({
component: () => <SliceStoryView />,
path: "/slices/$name",
initialPath: "/slices/alpha-slice",
}));
await waitFor(() => expect(screen.getByTestId("slice-tab-nav")).toBeDefined());
for (const tab of ["story", "acceptance", "decisions", "docs", "tests", "topology"]) {
expect(screen.getByTestId(`slice-tab-${tab}`)).toBeDefined();
}
// Default active tab is story.
expect(screen.getByTestId("slice-tab-story").getAttribute("data-active")).toBe("true");
});
it("clicking a tab swaps the rendered content section", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.startsWith("/api/slices/alpha-slice")) return jsonResponse(makeDetailPayload());
return jsonResponse({ slices: makeListEntries(), totalCount: 2, filter: "active" });
});
render(createTestRouter({
component: () => <SliceStoryView />,
path: "/slices/$name",
initialPath: "/slices/alpha-slice",
}));
await waitFor(() => expect(screen.getByTestId("slice-tab-content-story")).toBeDefined());
fireEvent.click(screen.getByTestId("slice-tab-acceptance"));
await waitFor(() => expect(screen.getByTestId("slice-tab-content-acceptance")).toBeDefined());
expect(screen.queryByTestId("slice-tab-content-story")).toBeNull();
fireEvent.click(screen.getByTestId("slice-tab-tests"));
await waitFor(() => expect(screen.getByTestId("slice-tab-content-tests")).toBeDefined());
});
});
@@ -1,143 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { createTestRouter } from "./helpers/test-router.js";
import { SpecsPanel } from "../src/components/SpecsPanel.js";
const fetchMock = vi.fn();
describe("Specs drawer library management", () => {
beforeEach(() => {
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function installFetchState() {
const state = {
rigEntries: [
{
id: "user-rig-1",
kind: "rig",
name: "my-rig",
version: "0.2",
sourceType: "user_file",
sourcePath: "/tmp/my-rig.yaml",
relativePath: "my-rig.yaml",
updatedAt: "2026-04-07T00:00:00Z",
},
{
id: "builtin-rig-1",
kind: "rig",
name: "starter-rig",
version: "0.2",
sourceType: "builtin",
sourcePath: "/builtin/starter-rig.yaml",
relativePath: "starter-rig.yaml",
updatedAt: "2026-04-07T00:00:00Z",
},
],
agentEntries: [
{
id: "user-agent-1",
kind: "agent",
name: "my-agent",
version: "1.0.0",
sourceType: "user_file",
sourcePath: "/tmp/my-agent.yaml",
relativePath: "my-agent.yaml",
updatedAt: "2026-04-07T00:00:00Z",
},
],
};
fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/api/specs/library") && !url.includes("/rename") && !url.includes("/user-rig-1") && !init?.method) {
// Unified all-entries query — return merged list
if (url.includes("kind=rig")) return { ok: true, json: async () => state.rigEntries };
if (url.includes("kind=agent")) return { ok: true, json: async () => state.agentEntries };
return { ok: true, json: async () => [...state.rigEntries, ...state.agentEntries] };
}
if (url.includes("/api/specs/library/user-rig-1/rename") && init?.method === "POST") {
const body = JSON.parse(String(init.body ?? "{}")) as { name: string };
state.rigEntries = state.rigEntries.map((entry) =>
entry.id === "user-rig-1"
? { ...entry, name: body.name, sourcePath: `/tmp/${body.name}.yaml`, relativePath: `${body.name}.yaml` }
: entry
);
return { ok: true, json: async () => ({ ok: true, entry: state.rigEntries[0] }) };
}
if (url.includes("/api/specs/library/user-rig-1") && init?.method === "DELETE") {
state.rigEntries = state.rigEntries.filter((entry) => entry.id !== "user-rig-1");
return { ok: true, json: async () => ({ ok: true, id: "user-rig-1", name: "my-rig" }) };
}
return { ok: true, json: async () => [] };
});
}
function renderPanel() {
return render(createTestRouter({ component: () => <SpecsPanel onClose={vi.fn()} />, path: "/test" }));
}
it("shows rename and remove actions for user-file entries but not builtins", async () => {
installFetchState();
renderPanel();
await waitFor(() => {
expect(screen.getByTestId("library-entry-user-rig-1")).toBeDefined();
});
expect(screen.getByTestId("library-rename-user-rig-1")).toBeDefined();
expect(screen.getByTestId("library-remove-user-rig-1")).toBeDefined();
expect(screen.queryByTestId("library-rename-builtin-rig-1")).toBeNull();
expect(screen.queryByTestId("library-remove-builtin-rig-1")).toBeNull();
});
it("renames a user-file library entry inline", async () => {
installFetchState();
renderPanel();
await waitFor(() => {
expect(screen.getByTestId("library-rename-user-rig-1")).toBeDefined();
});
fireEvent.click(screen.getByTestId("library-rename-user-rig-1"));
await waitFor(() => {
expect(screen.getByTestId("library-rename-input-user-rig-1")).toBeDefined();
});
fireEvent.change(screen.getByTestId("library-rename-input-user-rig-1"), {
target: { value: "renamed-rig" },
});
fireEvent.click(screen.getByTestId("library-rename-submit-user-rig-1"));
await waitFor(() => {
expect(screen.getByText("renamed-rig")).toBeDefined();
});
});
it("removes a user-file library entry after confirmation", async () => {
installFetchState();
renderPanel();
await waitFor(() => {
expect(screen.getByTestId("library-remove-user-rig-1")).toBeDefined();
});
fireEvent.click(screen.getByTestId("library-remove-user-rig-1"));
await waitFor(() => {
expect(screen.getByTestId("library-remove-confirm-user-rig-1")).toBeDefined();
});
fireEvent.click(screen.getByTestId("library-remove-submit-user-rig-1"));
await waitFor(() => {
expect(screen.queryByText("my-rig")).toBeNull();
});
});
});
-491
View File
@@ -1,491 +0,0 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createAppTestRouter } from "./helpers/test-router.js";
import { SpecsPanel } from "../src/components/SpecsPanel.js";
import { SpecsWorkspaceProvider, SPECS_WORKSPACE_STORAGE_KEYS } from "../src/components/SpecsWorkspace.js";
const mockFetch = vi.fn();
function renderPanel(initialPath = "/") {
const onClose = vi.fn();
const result = render(
createAppTestRouter({
initialPath,
routes: [
{ path: "/", component: () => <div data-testid="specs-home">home</div> },
{ path: "/import", component: () => <div data-testid="import-route">import</div> },
{ path: "/specs/rig", component: () => <div data-testid="rig-review-route">review</div> },
{ path: "/specs/agent", component: () => <div data-testid="agent-review-route">agent-review</div> },
{ path: "/bootstrap", component: () => <div data-testid="bootstrap-route">bootstrap</div> },
{ path: "/agents/validate", component: () => <div data-testid="agent-validate-route">validate</div> },
],
rootComponent: ({ children }) => (
<SpecsWorkspaceProvider>
<SpecsPanel onClose={onClose} />
{children}
</SpecsWorkspaceProvider>
),
})
);
return { ...result, onClose };
}
function seedSpecsStorage(key: string, value: unknown) {
window.localStorage.setItem(key, JSON.stringify(value));
}
describe("SpecsPanel", () => {
afterEach(() => {
window.localStorage.clear();
cleanup();
});
it("renders the drawer with the expected sections and actions", async () => {
renderPanel();
await waitFor(() => {
expect(screen.getByTestId("specs-panel")).toBeDefined();
});
expect(screen.getByTestId("specs-panel")).toBeDefined();
expect(screen.getByText("specs")).toBeDefined();
expect(screen.getByText("Rig Specs")).toBeDefined();
expect(screen.getByText("Agent Specs")).toBeDefined();
expect(screen.getByText("Import RigSpec")).toBeDefined();
expect(screen.getByText("Bootstrap")).toBeDefined();
expect(screen.getByText("Validate AgentSpec")).toBeDefined();
});
it("shows the active Specs task summary when a workspace flow is open", async () => {
seedSpecsStorage(SPECS_WORKSPACE_STORAGE_KEYS.currentRigDraft, {
id: "rig-1",
kind: "rig",
label: "captured-dev-pod",
yaml: "name: captured-dev-pod\n",
updatedAt: Date.now(),
});
renderPanel("/import");
await waitFor(() => {
expect(screen.getByText("Current Task")).toBeDefined();
});
expect(screen.getByText("Import RigSpec")).toBeDefined();
expect(screen.getAllByText("captured-dev-pod").length).toBeGreaterThan(0);
});
it("does not show Current Task when a flow is open but no resumable state exists yet", async () => {
renderPanel("/bootstrap");
await waitFor(() => {
expect(screen.getByTestId("specs-panel")).toBeDefined();
});
expect(screen.queryByText("Current Task")).toBeNull();
});
it("lists recent rig drafts and opens the rig review workspace from the drawer", async () => {
seedSpecsStorage(SPECS_WORKSPACE_STORAGE_KEYS.recentRigDrafts, [
{
id: "rig-recent",
kind: "rig",
label: "research-pod",
yaml: "name: research-pod\n",
updatedAt: Date.now(),
},
]);
renderPanel("/");
await waitFor(() => {
expect(screen.getByText("research-pod")).toBeDefined();
});
fireEvent.click(screen.getByText("research-pod"));
await waitFor(() => {
expect(screen.getByTestId("rig-review-route")).toBeDefined();
});
});
it("closes from the header close control", async () => {
const { onClose } = renderPanel();
await waitFor(() => {
expect(screen.getByTestId("specs-close")).toBeDefined();
});
fireEvent.click(screen.getByTestId("specs-close"));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("lists recent agent drafts and opens the agent review workspace from the drawer", async () => {
seedSpecsStorage(SPECS_WORKSPACE_STORAGE_KEYS.recentAgentDrafts, [
{
id: "agent-recent",
kind: "agent",
label: "qa",
yaml: 'name: qa\nversion: "1.0.0"\nprofiles:\n default:\n',
updatedAt: Date.now(),
},
]);
renderPanel("/");
await waitFor(() => {
expect(screen.getByText("qa")).toBeDefined();
});
fireEvent.click(screen.getByText("qa"));
await waitFor(() => {
expect(screen.getByTestId("agent-review-route")).toBeDefined();
});
});
});
describe("SpecsPanel filter chips and richer rows", () => {
const origFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
mockFetch.mockImplementation(async (url: string) => {
// Only the all-entries path (no ?kind=) returns data.
// kind-specific queries return empty to prove the unified list
// uses a single useSpecLibrary() call, not separate kind queries.
if (typeof url === "string" && url.includes("/api/specs/library")) {
if (url.includes("kind=")) {
return { ok: true, json: async () => [] };
}
return {
ok: true,
json: async () => [
{
id: "app-1",
kind: "rig",
name: "secrets-manager",
version: "0.2",
sourceType: "builtin",
sourcePath: "/specs/rigs/launch/secrets-manager/rig.yaml",
relativePath: "rigs/launch/secrets-manager/rig.yaml",
updatedAt: "2026-04-09T00:00:00Z",
summary: "HashiCorp Vault in dev mode with a dedicated Vault specialist agent",
hasServices: true,
},
{
id: "rig-1",
kind: "rig",
name: "demo",
version: "0.2",
sourceType: "builtin",
sourcePath: "/specs/rigs/launch/demo/rig.yaml",
relativePath: "rigs/launch/demo/rig.yaml",
updatedAt: "2026-04-09T00:00:00Z",
summary: "Stable full-team starter",
},
{
id: "agent-1",
kind: "agent",
name: "implementer",
version: "1.0",
sourceType: "builtin",
sourcePath: "/specs/agents/development/implementer/agent.yaml",
relativePath: "agents/development/implementer/agent.yaml",
updatedAt: "2026-04-09T00:00:00Z",
summary: "Implementation agent",
},
],
};
}
return { ok: true, json: async () => [] };
});
});
afterEach(() => {
globalThis.fetch = origFetch;
window.localStorage.clear();
cleanup();
});
function renderFilterPanel() {
const onClose = vi.fn();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
<QueryClientProvider client={qc}>
{createAppTestRouter({
initialPath: "/",
routes: [
{ path: "/", component: () => <div>home</div> },
{ path: "/specs/library/$entryId", component: () => <div data-testid="library-review">review</div> },
],
rootComponent: ({ children }) => (
<SpecsWorkspaceProvider>
<SpecsPanel onClose={onClose} />
{children}
</SpecsWorkspaceProvider>
),
})}
</QueryClientProvider>
);
}
it("shows all entries in merged list under default All filter with type and stability badges", async () => {
renderFilterPanel();
// Wait for library data to load
await waitFor(() => {
expect(screen.getByText("secrets-manager")).toBeDefined();
});
// All three entries visible under All filter
expect(screen.getByText("secrets-manager")).toBeDefined();
expect(screen.getByText("demo")).toBeDefined();
expect(screen.getByText("implementer")).toBeDefined();
// Filter chips exist
expect(screen.getByTestId("filter-all")).toBeDefined();
expect(screen.getByTestId("filter-apps")).toBeDefined();
expect(screen.getByTestId("filter-rigs")).toBeDefined();
expect(screen.getByTestId("filter-agents")).toBeDefined();
// Service-backed rig shows APP badge and summary mentioning specialist
const appRow = screen.getByTestId("library-entry-app-1");
expect(appRow.textContent).toContain("APP");
expect(appRow.textContent).toContain("specialist");
// Builtin launch-path rig shows Stable badge
const rigRow = screen.getByTestId("library-entry-rig-1");
expect(rigRow.textContent).toContain("Stable");
});
it("clicking Apps filter shows only service-backed rigs", async () => {
renderFilterPanel();
await waitFor(() => {
expect(screen.getByText("secrets-manager")).toBeDefined();
});
// Click Apps filter
fireEvent.click(screen.getByTestId("filter-apps"));
// Only the app entry should be visible
await waitFor(() => {
expect(screen.getByText("secrets-manager")).toBeDefined();
expect(screen.queryByText("demo")).toBeNull();
expect(screen.queryByText("implementer")).toBeNull();
});
});
});
describe("SpecsPanel Workflows filter chip (Workflows in Spec Library v0)", () => {
const origFetch = globalThis.fetch;
const mockFetch2 = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch2 as unknown as typeof fetch;
mockFetch2.mockImplementation(async (url: string) => {
if (typeof url === "string" && url.includes("/api/specs/library") && !url.includes("kind=")) {
return {
ok: true,
json: async () => [
{
id: "rig-1",
kind: "rig",
name: "demo",
version: "0.2",
sourceType: "builtin",
sourcePath: "/specs/rigs/launch/demo/rig.yaml",
relativePath: "rigs/launch/demo/rig.yaml",
updatedAt: "2026-04-09T00:00:00Z",
},
{
id: "agent-1",
kind: "agent",
name: "implementer",
version: "1.0",
sourceType: "builtin",
sourcePath: "/specs/agents/development/implementer/agent.yaml",
relativePath: "agents/development/implementer/agent.yaml",
updatedAt: "2026-04-09T00:00:00Z",
},
{
id: "workflow:rsi-v2-hot-potato:1",
kind: "workflow",
name: "rsi-v2-hot-potato",
version: "1",
sourceType: "builtin",
sourcePath: "/builtins/workflow-specs/rsi-v2-hot-potato.yaml",
relativePath: "rsi-v2-hot-potato.yaml",
updatedAt: "2026-05-04T00:00:00Z",
isBuiltIn: true,
rolesCount: 3,
stepsCount: 4,
terminalTurnRule: "hot_potato",
targetRig: null,
},
],
};
}
if (typeof url === "string" && url.includes("/api/specs/library") && url.includes("kind=")) {
return { ok: true, json: async () => [] };
}
return { ok: true, json: async () => [] };
});
});
afterEach(() => {
globalThis.fetch = origFetch;
window.localStorage.clear();
cleanup();
});
function renderFilterPanel() {
const onClose = vi.fn();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
<QueryClientProvider client={qc}>
{createAppTestRouter({
initialPath: "/",
routes: [
{ path: "/", component: () => <div>home</div> },
{ path: "/specs/library/$entryId", component: () => <div data-testid="library-review">review</div> },
],
rootComponent: ({ children }) => (
<SpecsWorkspaceProvider>
<SpecsPanel onClose={onClose} />
{children}
</SpecsWorkspaceProvider>
),
})}
</QueryClientProvider>
);
}
it("renders the Workflows filter chip and a workflow row with WORKFLOW badge", async () => {
renderFilterPanel();
await waitFor(() => expect(screen.getByText("rsi-v2-hot-potato")).toBeDefined());
expect(screen.getByTestId("filter-workflows")).toBeDefined();
const wfRow = screen.getByTestId("library-entry-workflow:rsi-v2-hot-potato:1");
expect(wfRow.textContent).toContain("WORKFLOW");
});
it("clicking Workflows filter shows only workflow entries", async () => {
renderFilterPanel();
await waitFor(() => expect(screen.getByText("rsi-v2-hot-potato")).toBeDefined());
fireEvent.click(screen.getByTestId("filter-workflows"));
await waitFor(() => {
expect(screen.getByText("rsi-v2-hot-potato")).toBeDefined();
expect(screen.queryByText("demo")).toBeNull();
expect(screen.queryByText("implementer")).toBeNull();
});
});
});
describe("SpecsPanel Context Packs filter chip (PL-014)", () => {
const origFetch = globalThis.fetch;
const mockFetch3 = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch3 as unknown as typeof fetch;
mockFetch3.mockImplementation(async (url: string) => {
if (typeof url === "string" && url.includes("/api/specs/library") && !url.includes("kind=")) {
return {
ok: true,
json: async () => [
{
id: "rig-1",
kind: "rig",
name: "demo",
version: "0.2",
sourceType: "builtin",
sourcePath: "/specs/rigs/launch/demo/rig.yaml",
relativePath: "rigs/launch/demo/rig.yaml",
updatedAt: "2026-04-09T00:00:00Z",
},
],
};
}
if (typeof url === "string" && url.includes("/api/specs/library") && url.includes("kind=")) {
return { ok: true, json: async () => [] };
}
if (typeof url === "string" && url.includes("/api/context-packs/library")) {
return {
ok: true,
json: async () => [
{
id: "context-pack:pl-005-priming:1",
kind: "context-pack",
name: "pl-005-priming",
version: "1",
purpose: "Priming for PL-005",
sourceType: "user_file",
sourcePath: "/home/op/.openrig/context-packs/pl-005-priming",
relativePath: "pl-005-priming",
updatedAt: "2026-05-04T00:00:00Z",
manifestEstimatedTokens: null,
derivedEstimatedTokens: 800,
files: [
{ path: "prd.md", role: "prd", summary: null, absolutePath: "/abs/prd.md", bytes: 200, estimatedTokens: 50 },
{ path: "proof.md", role: "proof", summary: null, absolutePath: "/abs/proof.md", bytes: 3000, estimatedTokens: 750 },
],
},
],
};
}
return { ok: true, json: async () => [] };
});
});
afterEach(() => {
globalThis.fetch = origFetch;
window.localStorage.clear();
cleanup();
});
function renderFilterPanel() {
const onClose = vi.fn();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
<QueryClientProvider client={qc}>
{createAppTestRouter({
initialPath: "/",
routes: [
{ path: "/", component: () => <div>home</div> },
{ path: "/specs/library/$entryId", component: () => <div data-testid="library-review">review</div> },
],
rootComponent: ({ children }) => (
<SpecsWorkspaceProvider>
<SpecsPanel onClose={onClose} />
{children}
</SpecsWorkspaceProvider>
),
})}
</QueryClientProvider>
);
}
it("renders the Context Packs filter chip and a pack row with PACK badge", async () => {
renderFilterPanel();
await waitFor(() => expect(screen.getByText("pl-005-priming")).toBeDefined());
expect(screen.getByTestId("filter-context-packs")).toBeDefined();
const packRow = screen.getByTestId("library-entry-context-pack:pl-005-priming:1");
expect(packRow.textContent).toContain("PACK");
expect(packRow.textContent).toContain("2 files");
expect(packRow.textContent).toContain("~800 tokens");
});
it("clicking Context Packs filter shows only pack entries", async () => {
renderFilterPanel();
await waitFor(() => expect(screen.getByText("pl-005-priming")).toBeDefined());
fireEvent.click(screen.getByTestId("filter-context-packs"));
await waitFor(() => {
expect(screen.getByText("pl-005-priming")).toBeDefined();
expect(screen.queryByText("demo")).toBeNull();
});
});
});
-132
View File
@@ -1,132 +0,0 @@
// Operator Surface Reconciliation v0 — steering panel render tests.
//
// Pins the load-bearing per-panel rendering for items 1A (priority
// stack) + 1B (roadmap rail) + 1D (lane rails). Items 1C (in-motion)
// + 1E (loop state) + 1F (health gates) are tested via the broader
// SteeringWorkspace surface test which mocks /api/* fetches.
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { PriorityStackPanel } from "../src/components/steering/PriorityStackPanel.js";
import { RoadmapRailPanel } from "../src/components/steering/RoadmapRailPanel.js";
import { LaneRailsPanel } from "../src/components/steering/LaneRailsPanel.js";
import type { LaneRailPayload, PriorityStackPayload, RoadmapRailPayload } from "../src/hooks/useSteering.js";
afterEach(() => cleanup());
describe("OSR v0 — PriorityStackPanel", () => {
it("renders STEERING.md content + last-modified timestamp", () => {
const stack: PriorityStackPayload = {
content: "# Priority Stack\n- Do X first\n- Avoid Y for now",
absolutePath: "/abs/STEERING.md",
mtime: "2026-05-04T12:00:00.000Z",
byteCount: 100,
};
render(<PriorityStackPanel priorityStack={stack} />);
expect(screen.getByTestId("steering-priority-stack")).toBeDefined();
expect(screen.getByTestId("steering-priority-stack-mtime").textContent).toContain("2026-05-04");
// MarkdownViewer renders the content. Since hideRawToggle is set,
// toggle buttons are absent and the rendered block is visible.
expect(screen.queryByTestId("markdown-viewer-mode-toggle")).toBeNull();
});
it("renders empty-state hint when priority stack is null", () => {
render(<PriorityStackPanel priorityStack={null} />);
expect(screen.getByTestId("steering-priority-stack-empty")).toBeDefined();
});
});
describe("OSR v0 — RoadmapRailPanel", () => {
it("renders empty-state hint when roadmap rail is null", () => {
render(<RoadmapRailPanel roadmapRail={null} />);
expect(screen.getByTestId("steering-roadmap-rail-empty")).toBeDefined();
});
it("renders rows with rail codes + done glyphs + next-pull marker on first unchecked", () => {
const rail: RoadmapRailPayload = {
absolutePath: "/abs/roadmap/PROGRESS.md",
mtime: "2026-05-04T12:00:00.000Z",
items: [
{ line: 1, text: "PL-005 Phase A done", done: true, railItemCode: "PL-005", isNextUnchecked: false },
{ line: 2, text: "PL-019 done", done: true, railItemCode: "PL-019", isNextUnchecked: false },
{ line: 3, text: "PL-022 next pull", done: false, railItemCode: "PL-022", isNextUnchecked: true },
{ line: 4, text: "PL-030 later", done: false, railItemCode: "PL-030", isNextUnchecked: false },
],
counts: { total: 4, done: 2, nextUncheckedLine: 3 },
};
render(<RoadmapRailPanel roadmapRail={rail} />);
expect(screen.getByTestId("steering-roadmap-item-1").getAttribute("data-rail-code")).toBe("PL-005");
expect(screen.getByTestId("steering-roadmap-item-3").getAttribute("data-is-next-unchecked")).toBe("true");
expect(screen.getByTestId("steering-roadmap-next-marker-3")).toBeDefined();
// No marker on item 4 (later, not next).
expect(screen.queryByTestId("steering-roadmap-next-marker-4")).toBeNull();
expect(screen.getByTestId("steering-roadmap-rail-counts").textContent).toContain("2 / 4 done");
});
it("renders 'No checkbox rows' when roadmap is empty", () => {
const empty: RoadmapRailPayload = {
absolutePath: "/abs/roadmap/PROGRESS.md",
mtime: "2026-05-04T12:00:00.000Z",
items: [],
counts: { total: 0, done: 0, nextUncheckedLine: null },
};
render(<RoadmapRailPanel roadmapRail={empty} />);
expect(screen.getByTestId("steering-roadmap-rail")).toBeDefined();
expect(screen.getByText(/No checkbox rows/i)).toBeDefined();
});
});
describe("OSR v0 — LaneRailsPanel", () => {
it("renders empty-state hint when laneRails is empty", () => {
render(<LaneRailsPanel laneRails={[]} />);
expect(screen.getByTestId("steering-lane-rails-empty")).toBeDefined();
});
it("renders one section per lane with health badges + top items + next-pull marker", () => {
const lanes: LaneRailPayload[] = [
{
laneId: "mode-2",
absolutePath: "/abs/delivery-ready/mode-2/PROGRESS.md",
mtime: "2026-05-04T12:00:00.000Z",
topItems: [
{ line: 5, text: "blocked one", status: "blocked", isNextPull: false },
{ line: 6, text: "the next pull", status: "active", isNextPull: true },
{ line: 7, text: "later one", status: "active", isNextPull: false },
],
healthBadges: { active: 2, blocked: 1, done: 5, total: 8 },
nextPullLine: 6,
},
{
laneId: "mode-3",
absolutePath: "/abs/delivery-ready/mode-3/PROGRESS.md",
mtime: "2026-05-04T12:00:00.000Z",
topItems: [],
healthBadges: { active: 0, blocked: 0, done: 4, total: 4 },
nextPullLine: null,
},
];
render(<LaneRailsPanel laneRails={lanes} />);
expect(screen.getByTestId("steering-lane-mode-2")).toBeDefined();
expect(screen.getByTestId("steering-lane-mode-3")).toBeDefined();
expect(screen.getByTestId("steering-lane-row-6-next-pull")).toBeDefined();
expect(screen.queryByTestId("steering-lane-row-5-next-pull")).toBeNull();
expect(screen.getByText(/No checkbox rows/i)).toBeDefined();
});
it("health badges render the count + variant class for each (active/blocked/done)", () => {
const lanes: LaneRailPayload[] = [{
laneId: "mode-2",
absolutePath: "/abs/x",
mtime: "2026-05-04T12:00:00.000Z",
topItems: [],
healthBadges: { active: 3, blocked: 1, done: 5, total: 9 },
nextPullLine: null,
}];
render(<LaneRailsPanel laneRails={lanes} />);
const badges = screen.getAllByTestId(/^steering-lane-badge-/);
const labels = badges.map((b) => b.textContent ?? "");
expect(labels.some((t) => t.includes("active") && t.includes("3"))).toBe(true);
expect(labels.some((t) => t.includes("blocked") && t.includes("1"))).toBe(true);
expect(labels.some((t) => t.includes("done") && t.includes("5"))).toBe(true);
});
});
+23
View File
@@ -92,4 +92,27 @@ describe("VellumCard (Phase 1 primitive)", () => {
);
expect(container.querySelector("section[data-testid='vc-sec']")).toBeTruthy();
});
it("interactive (href) carries focus-visible classes", () => {
const { container } = render(
<VellumCard href="/foo" testId="vc-fv-href">x</VellumCard>,
);
const el = container.querySelector("[data-testid='vc-fv-href']") as HTMLElement;
expect(el.className).toContain("focus-visible:outline");
expect(el.className).toContain("focus-visible:outline-stone-900");
});
it("interactive (onClick) carries focus-visible classes", () => {
const { container } = render(
<VellumCard onClick={() => {}} testId="vc-fv-click">x</VellumCard>,
);
const el = container.querySelector("[data-testid='vc-fv-click']") as HTMLElement;
expect(el.className).toContain("focus-visible:outline");
});
it("non-interactive does NOT add focus-visible classes", () => {
const { container } = render(<VellumCard testId="vc-static">x</VellumCard>);
const el = container.querySelector("[data-testid='vc-static']") as HTMLElement;
expect(el.className).not.toContain("focus-visible:outline");
});
});
-13
View File
@@ -1,13 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { WorkspaceHome } from "../src/components/WorkspaceHome.js";
describe("WorkspaceHome", () => {
it("links to Mission Control from the home page", () => {
render(<WorkspaceHome />);
const link = screen.getByTestId("workspace-open-mission-control");
expect(link.textContent).toContain("Mission Control");
expect(link.getAttribute("href")).toBe("/mission-control");
});
});