mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
1 parent
609e525436
commit
5f53d31ec3
41 files changed
+1091
-5503
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user