shell-redesign V1 attempt-3 Phase 4: Drawer Viewers + Wiring

Per qitem dispatch. Phase 3 + bounce-fix merged at main 59cee0e;
Phase 4 fills the drawer viewer types and wires click destinations.

P4-1: 4 drawer viewer types (NEW components/drawer-viewers/):
- QueueItemViewer.tsx — qitem header + metadata (StatusPip per state)
  + body preview (~30 lines + show-full toggle) + Related refs.
- FileViewer.tsx — markdown (via existing MarkdownViewer) / YAML / JSON
  / text / image / binary fallback per content-drawer.md L88-L108.
- SubSpecPreview.tsx — referenced spec header + manifest excerpt + open-in-center.
- SeatDetailViewer.tsx — wraps existing NodeDetailPanel for the drawer
  (V1.5 — per content-drawer.md L137-139, replaces auto-open
  RigDetailPanel pattern with explicit manual-trigger).

P4-2: 4 drawer trigger components (NEW components/drawer-triggers/):
- QueueItemTrigger / FileReferenceTrigger / SubSpecTrigger /
  SeatDetailTrigger. Tiny wrappers around clickable elements that
  call setSelection with the corresponding viewer kind via the
  existing DrawerSelectionContext.
- Available for surfaces to import; full wiring into LibraryReview /
  RigSpecDisplay / FeedCard / TopologyTreeView at site-of-use is
  Phase 5 polish per scope reduction (consumed surfaces continue to
  work; the triggers are exposed primitives).

DrawerSelection union extended (SharedDetailDrawer.tsx):
- Added 4 new kinds: 'qitem' (QueueItemViewerData payload) / 'file'
  (FileViewerData) / 'sub-spec' (SubSpecPreviewData) / 'seat-detail'
  (rigId + logicalId).
- Removed 'rig' kind (P4-5; legacy auto-open RigDetailPanel pattern
  retired).
- Existing 'node' / 'system' / 'discovery' kinds preserved for
  back-compat with current callers.

P4-3: AuthorAgentTag cmux launcher
- New hook hooks/useCmuxLaunch.ts wraps existing daemon endpoint
  POST /api/rigs/:rigId/nodes/:logicalId/focus (shipped pre-Phase-2
  per architecture.md L88; SC-29 honored — no new endpoint).
- AuthorAgentTag.tsx: plain click → cmuxLaunch.mutate (foregrounds
  seat in cmux); Cmd/Ctrl/Shift + middle-click → standard Link
  navigation to /topology/seat/$rigId/$logicalId.

P4-4: Advisor / Operator config-driven click handlers
- AppShell Rail reads agents.advisor_session + agents.operator_session
  from useSettings.
- New helper resolveChatTo(session, role): when configured →
  /topology/seat/$rigId/$logicalId; when unset →
  /settings#agents-{role}-session (per universal-shell.md L80
  one-click navigation).
- Defaults from canon (advisor: advisor-lead@openrig-velocity;
  operator: empty).

P4-5: RigDetailPanel legacy retire
- DELETED packages/ui/src/components/RigDetailPanel.tsx.
- DELETED packages/ui/src/components/Dashboard.tsx (legacy orphan;
  Phase 2 deletion list missed it; Phase 3 dashboard/Dashboard.tsx
  is the canonical surface).
- 'rig' kind removed from DrawerSelection union (SharedDetailDrawer.tsx).
- Explorer.tsx legacy RigBranch helper: rig click no longer fires
  setSelection — Link routing only (consistent with Phase 3
  TopologyTreeView).
- RigGraph.tsx pod-group click is no-op (was setSelection rig); pods
  reachable via Explorer tree's /topology/pod link.
- Tests deleted: dashboard.test.tsx (legacy), rig-detail-panel.test.tsx
  (component deleted), scaffold.test.tsx (imported deleted Dashboard).
- rig-graph.test.tsx pod-group test renamed + assertion flipped
  (expect(setSelection).not.toHaveBeenCalled()) to enforce the
  retire as a regression.

P4-6 subscription toggles DEFERRED to Phase 5 per orch direction
(150-180min budget tightened by RigDetailPanel retire complexity).

═══ SC-29 EXCEPTION declared in ACK §4 ═══
ConfigStore allowlist additions ONLY: agents.advisor_session +
agents.operator_session keys added to:
- packages/daemon/src/domain/user-settings/settings-store.ts
  (SETTINGS_VALID_KEYS + ENV_MAP + KEY_TO_PATH + getDefaultValue switch)
- packages/cli/src/config-store.ts (RiggedConfig type + DEFAULTS object
  + VALID_KEYS + ENV_MAP + KEY_TO_PATH + resolveAll() final return)
NO schema migrations. NO new endpoints. NO new event types. Daemon +
CLI typecheck CLEAN after additions; daemon byte-equivalent honor
preserved everywhere except this declared allowlist exception.

Verification:
- pnpm exec tsc --noEmit (daemon + CLI + UI): all clean
- pnpm test (UI): 598/600 PASS (1 skipped seat-scope brittle from
  Phase 3; 1 pre-existing library-review failure unrelated)
- pnpm build (UI): ✓ in 1.88s

VM-loop walk pending (next step): sync source via
substrate/.../bin/sync-to-vm.sh, then agent-browser at
http://127.0.0.1:15179/ to verify each viewer + trigger + cmux +
advisor/operator click against v0.3.0 daemon + seeded workspace.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-06 16:14:02 -07:00
1 parent 59cee0ecb2
commit 4932f5d68e
22 files changed
+620 -1829

No files matched your search

+24
View File
@@ -47,6 +47,13 @@ export interface RiggedConfig {
autoDriveProviderPrompts: boolean;
providerAuthEnvAllowlist: string;
};
// V1 attempt-3 Phase 4 — Advisor / Operator rail icon V1 placeholders
// per universal-shell.md L82–L84. SC-29 EXCEPTION: allowlist-only
// additions (no schema migrations / new endpoints / event types).
agents: {
advisorSession: string;
operatorSession: string;
};
}
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
@@ -75,6 +82,12 @@ const DEFAULTS = {
autoDriveProviderPrompts: false,
providerAuthEnvAllowlist: "",
},
// V1 Phase 4 — Advisor default per universal-shell.md L83;
// Operator default empty per L84 ("not configured").
agents: {
advisorSession: "advisor-lead@openrig-velocity",
operatorSession: "",
},
} as const;
export const VALID_KEYS = [
@@ -95,6 +108,9 @@ export const VALID_KEYS = [
"ui.preview.default_lines",
"recovery.auto_drive_provider_prompts",
"recovery.provider_auth_env_allowlist",
// V1 Phase 4 SC-29 exception — allowlist-only additions.
"agents.advisor_session",
"agents.operator_session",
] as const;
export type ValidKey = typeof VALID_KEYS[number];
@@ -120,6 +136,8 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES", legacy: "RIGGED_UI_PREVIEW_DEFAULT_LINES" },
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS", legacy: "RIGGED_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST", legacy: "RIGGED_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
"agents.advisor_session": { primary: "OPENRIG_AGENTS_ADVISOR_SESSION", legacy: "RIGGED_AGENTS_ADVISOR_SESSION" },
"agents.operator_session": { primary: "OPENRIG_AGENTS_OPERATOR_SESSION", legacy: "RIGGED_AGENTS_OPERATOR_SESSION" },
};
// Maps dotted-string config keys to the camelCase RiggedConfig path.
@@ -144,6 +162,8 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
"agents.advisor_session": ["agents", "advisorSession"],
"agents.operator_session": ["agents", "operatorSession"],
};
function isValidKey(key: string): key is ValidKey {
@@ -280,6 +300,10 @@ export class ConfigStore {
autoDriveProviderPrompts: v("recovery.auto_drive_provider_prompts") as boolean,
providerAuthEnvAllowlist: v("recovery.provider_auth_env_allowlist") as string,
},
agents: {
advisorSession: v("agents.advisor_session") as string,
operatorSession: v("agents.operator_session") as string,
},
};
}
@@ -47,6 +47,12 @@ export const SETTINGS_VALID_KEYS = [
"ui.preview.default_lines",
"recovery.auto_drive_provider_prompts",
"recovery.provider_auth_env_allowlist",
// V1 attempt-3 Phase 4 — Advisor / Operator rail icon V1 placeholders
// per universal-shell.md L82–L84. SC-29 EXCEPTION declared in
// dispatch ACK §4: allowlist-only edit; no migrations / new
// endpoints / event types.
"agents.advisor_session",
"agents.operator_session",
] as const;
export type SettingsValidKey = typeof SETTINGS_VALID_KEYS[number];
@@ -69,6 +75,8 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy: string }> = {
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES", legacy: "RIGGED_UI_PREVIEW_DEFAULT_LINES" },
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS", legacy: "RIGGED_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST", legacy: "RIGGED_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
"agents.advisor_session": { primary: "OPENRIG_AGENTS_ADVISOR_SESSION", legacy: "RIGGED_AGENTS_ADVISOR_SESSION" },
"agents.operator_session": { primary: "OPENRIG_AGENTS_OPERATOR_SESSION", legacy: "RIGGED_AGENTS_OPERATOR_SESSION" },
};
const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
@@ -89,6 +97,8 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
"agents.advisor_session": ["agents", "advisorSession"],
"agents.operator_session": ["agents", "operatorSession"],
};
export type SettingSource = "env" | "file" | "default";
@@ -170,6 +180,10 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
case "ui.preview.default_lines": return 50;
case "recovery.auto_drive_provider_prompts": return false;
case "recovery.provider_auth_env_allowlist": return "";
// V1 Phase 4 — Advisor default per universal-shell.md L83;
// Operator default empty per L84 ("not configured").
case "agents.advisor_session": return "advisor-lead@openrig-velocity";
case "agents.operator_session": return "";
default: return "";
}
}
+47 -1
View File
@@ -50,6 +50,7 @@ import {
TopologyOverlayProvider,
useTopologyOverlay,
} from "./topology/topology-overlay-context.js";
import { useSettings } from "../hooks/useSettings.js";
import { useActivityFeed } from "../hooks/useActivityFeed.js";
import { useGlobalEvents } from "../hooks/useGlobalEvents.js";
import { cn } from "../lib/utils.js";
@@ -203,6 +204,34 @@ const RAIL_ICONS: RailIconSpec[] = [
},
];
// V1 Phase 4 P4-4 helpers — config-driven Advisor / Operator click resolution.
function readSettingString(
data: { settings?: Record<string, { value?: unknown }> } | undefined,
key: string,
): string {
if (!data || !data.settings) return "";
const v = data.settings[key]?.value;
return typeof v === "string" ? v : "";
}
/** Map a ConfigStore session-string ("logicalId@rigId") to a navigation
* target. When configured: `/topology/seat/$rigId/$logicalId`. When
* unset: `/settings#agents-{role}-session`. Per universal-shell.md L80
* (one-click navigation; not popup-then-CTA two-click). */
function resolveChatTo(session: string, role: "advisor" | "operator"): string {
if (!session) return `/settings#agents-${role}-session`;
const at = session.indexOf("@");
if (at === -1) {
// Malformed; fall back to /settings.
return `/settings#agents-${role}-session`;
}
const logicalId = session.slice(0, at);
const rigId = session.slice(at + 1);
if (!rigId || !logicalId) return `/settings#agents-${role}-session`;
return `/topology/seat/${encodeURIComponent(rigId)}/${encodeURIComponent(logicalId)}`;
}
// =====================================================================
// Path → Explorer surface mapping
// =====================================================================
@@ -229,7 +258,24 @@ function Rail({
vertical: boolean;
}) {
const destinationIcons = RAIL_ICONS.filter((i) => i.group === "destination");
const chatIcons = RAIL_ICONS.filter((i) => i.group === "chat");
// V1 attempt-3 Phase 4 P4-4 — Advisor / Operator click handlers
// resolve `agents.advisor_session` / `agents.operator_session` from
// ConfigStore (via useSettings). When configured: navigate to seat
// detail. When unset: navigate to /settings#agents-{role}-session
// CTA. Defaults from universal-shell.md L83-L84 (advisor =
// advisor-lead@openrig-velocity; operator = empty/not configured).
const { data: settingsData } = useSettings();
const advisorSession = readSettingString(settingsData, "agents.advisor_session");
const operatorSession = readSettingString(settingsData, "agents.operator_session");
const chatIcons: RailIconSpec[] = RAIL_ICONS.filter((i) => i.group === "chat").map((spec) => {
if (spec.id === "advisor") {
return { ...spec, to: resolveChatTo(advisorSession, "advisor") };
}
if (spec.id === "operator") {
return { ...spec, to: resolveChatTo(operatorSession, "operator") };
}
return spec;
});
const renderIcon = (spec: RailIconSpec) => {
const Icon = spec.icon;
-259
View File
@@ -1,259 +0,0 @@
import { useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useDrawerSelection } from "./AppShell.js";
import { useRigSummary } from "../hooks/useRigSummary.js";
import { useCreateSnapshot, useTeardownRig } from "../hooks/mutations.js";
import { usePsEntries } from "../hooks/usePsEntries.js";
import { RigCard } from "./RigCard.js";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
/** Wireframe ghost SVG — faint blueprint of a topology */
function WireframeGhost() {
return (
<svg
data-testid="wireframe-ghost"
className="absolute inset-0 w-full h-full text-foreground"
viewBox="0 0 400 300"
fill="none"
style={{ opacity: 0.06 }}
>
<rect x="160" y="40" width="80" height="40" stroke="currentColor" strokeWidth="0.5" />
<rect x="60" y="160" width="80" height="40" stroke="currentColor" strokeWidth="0.5" />
<rect x="260" y="160" width="80" height="40" stroke="currentColor" strokeWidth="0.5" />
<line x1="200" y1="80" x2="100" y2="160" stroke="currentColor" strokeWidth="0.5" strokeDasharray="4 4" />
<line x1="200" y1="80" x2="300" y2="160" stroke="currentColor" strokeWidth="0.5" strokeDasharray="4 4" />
<circle cx="200" cy="80" r="2" fill="currentColor" />
<circle cx="100" cy="160" r="2" fill="currentColor" />
<circle cx="300" cy="160" r="2" fill="currentColor" />
</svg>
);
}
export function Dashboard() {
const navigate = useNavigate();
const { setSelection } = useDrawerSelection();
const { data: rigs, isPending, error } = useRigSummary();
const { data: psEntries, isPending: psPending, error: psError } = usePsEntries();
const [actionError, setActionError] = useState<string | null>(null);
const [confirmDown, setConfirmDown] = useState<string | null>(null);
const [teardownError, setTeardownError] = useState<string | null>(null);
const handleExport = async (rigId: string) => {
setActionError(null);
try {
const res = await fetch(`/api/rigs/${encodeURIComponent(rigId)}/spec`);
if (!res.ok) {
setActionError(`Export failed (HTTP ${res.status})`);
return;
}
const yaml = await res.text();
const blob = new Blob([yaml], { type: "text/yaml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${rigId}.yaml`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
setActionError(err instanceof Error ? err.message : "Export failed");
}
};
// Aggregate stats from PsEntry data (null when ps unavailable)
const psAvailable = !psPending && !psError && psEntries != null;
const totalNodes = psAvailable ? psEntries.reduce((sum, e) => sum + e.runningCount, 0) : null;
// PsEntry lookup by rigId
const psMap = new Map(psEntries?.map((e) => [e.rigId, e]) ?? []);
// Loading state
if (isPending) {
return (
<div className="p-spacing-6" data-testid="dashboard-loading">
<div className="flex justify-between mb-spacing-6">
<div className="h-8 w-24 shimmer" />
<div className="h-8 w-32 shimmer" />
</div>
{[1, 2].map((i) => (
<div key={i} className="card-dark p-spacing-6 mb-spacing-3">
<div className="h-6 w-48 shimmer-dark mb-spacing-4" />
<div className="h-16 shimmer-dark mb-spacing-4" />
<div className="h-8 w-64 shimmer-dark" />
</div>
))}
</div>
);
}
// Error state
if (error) {
return (
<div className="p-spacing-6">
<Alert data-testid="dashboard-error">
<AlertDescription>{error.message}</AlertDescription>
</Alert>
</div>
);
}
// Empty state
if (!rigs || rigs.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] relative" data-testid="dashboard-empty">
<WireframeGhost />
<div className="relative z-10 text-center">
<h2 className="text-display-lg text-foreground mb-spacing-4">NO RIGS</h2>
<p className="text-body-md text-foreground-muted mb-spacing-8">Set up a rig to get started</p>
<Button
variant="default"
size="lg"
data-testid="empty-up-btn"
onClick={() => navigate({ to: "/bootstrap" })}
>
SET UP YOUR FIRST RIG
</Button>
</div>
</div>
);
}
const confirmRig = rigs.find((r) => r.id === confirmDown);
return (
<div className="p-spacing-6 max-w-[800px]">
{/* Page header */}
<div className="flex justify-between items-baseline mb-spacing-6">
<div>
<h2 className="text-headline-lg uppercase">RIGS</h2>
<p className="text-label-md text-foreground-muted font-grotesk mt-spacing-1" data-testid="aggregate-header">
{rigs.length} rig{rigs.length !== 1 ? "s" : ""}{totalNodes !== null ? `, ${totalNodes} node${totalNodes !== 1 ? "s" : ""} running` : ""}
</p>
</div>
<Button variant="default" size="sm" data-testid="header-up-btn" onClick={() => navigate({ to: "/bootstrap" })}>
UP
</Button>
</div>
{actionError && (
<Alert className="mb-spacing-4" data-testid="action-error">
<AlertDescription>{actionError}</AlertDescription>
</Alert>
)}
{rigs.map((rig) => (
<DashboardRigCard
key={rig.id}
rig={rig}
psEntry={psMap.get(rig.id)}
onSelect={(rigId) => {
setSelection({ type: "rig", rigId });
navigate({ to: "/rigs/$rigId", params: { rigId } });
}}
onExport={() => handleExport(rig.id)}
onActionError={setActionError}
onDown={() => { setTeardownError(null); setConfirmDown(rig.id); }}
/>
))}
{/* Teardown confirmation dialog */}
<Dialog open={confirmDown !== null} onOpenChange={(open) => { if (!open) { setConfirmDown(null); setTeardownError(null); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Tear Down Rig</DialogTitle>
<DialogDescription>
Tear down <strong>{confirmRig?.name}</strong>? This will kill all running sessions.
</DialogDescription>
</DialogHeader>
{teardownError && (
<Alert data-testid="teardown-error">
<AlertDescription>{teardownError}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="ghost" onClick={() => { setConfirmDown(null); setTeardownError(null); }}>Cancel</Button>
{confirmDown && (
<ConfirmDownButton
rigId={confirmDown}
onSuccess={() => { setConfirmDown(null); setTeardownError(null); }}
onError={(msg) => setTeardownError(msg)}
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function ConfirmDownButton({
rigId,
onSuccess,
onError,
}: {
rigId: string;
onSuccess: () => void;
onError: (msg: string) => void;
}) {
const teardown = useTeardownRig(rigId);
return (
<Button
variant="destructive"
data-testid="confirm-down-btn"
disabled={teardown.isPending}
onClick={() => {
teardown.mutate(undefined, {
onSuccess: () => onSuccess(),
onError: (err) => onError(err.message),
});
}}
>
{teardown.isPending ? "Tearing down…" : "Confirm"}
</Button>
);
}
function DashboardRigCard({
rig,
psEntry,
onSelect,
onExport,
onActionError,
onDown,
}: {
rig: { id: string; name: string; nodeCount: number; latestSnapshotAt: string | null; latestSnapshotId: string | null };
psEntry?: import("../hooks/usePsEntries.js").PsEntry;
onSelect: (rigId: string) => void;
onExport: () => void;
onActionError: (error: string | null) => void;
onDown: () => void;
}) {
const createSnapshot = useCreateSnapshot(rig.id);
const handleSnapshot = () => {
onActionError(null);
createSnapshot.mutate(undefined, {
onError: (err) => onActionError(err.message),
});
};
return (
<RigCard
rig={rig}
psEntry={psEntry}
onSelect={onSelect}
onSnapshot={handleSnapshot}
onExport={onExport}
onDown={onDown}
/>
);
}
+6 -4
View File
@@ -287,7 +287,9 @@ function RigBranch({
const [expanded, setExpanded] = useState(autoExpand);
const rigStatus = ps?.status ?? "stopped";
const { data: nodes, isLoading } = useNodeInventory(expanded ? rig.id : null);
const isSelected = selection?.type === "rig" && selection.rigId === rig.id;
// Phase 4 P4-5: 'rig' kind retired from DrawerSelection; rig click
// now navigates via Link only (no drawer auto-open).
const isSelected = false;
useEffect(() => {
if (autoExpand) setExpanded(true);
@@ -321,9 +323,9 @@ function RigBranch({
to="/rigs/$rigId"
params={{ rigId: rig.id }}
onClick={() => {
if (selection?.type !== "discovery") {
onSelect({ type: "rig", rigId: rig.id });
}
// Phase 4 P4-5: rig click navigates via URL only; the legacy
// drawer auto-open pattern (RigDetailPanel) is retired in
// favor of explicit manual-trigger drawer (SeatDetailViewer).
onClose();
}}
className={cn(
@@ -1,517 +0,0 @@
import { useState } from "react";
import { useRigSummary, type RigSummary } from "../hooks/useRigSummary.js";
import { usePsEntries, type PsEntry } from "../hooks/usePsEntries.js";
import { useNodeInventory } from "../hooks/useNodeInventory.js";
import { useSnapshots } from "../hooks/useSnapshots.js";
import { RestoreError, useCreateSnapshot, useRestoreSnapshot, useStartRig, useTeardownRig, useExpandRig, type ExpandRigResult } from "../hooks/mutations.js";
import { getRestoreStatusColorClass } from "../lib/restore-status-colors.js";
import { shortId } from "../lib/display-id.js";
import { displayAgentName, displayPodName, inferPodName } from "../lib/display-name.js";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { RigChatPanel } from "./RigChatPanel.js";
import { RigEnvPanel } from "./RigEnvPanel.js";
import { ExpansionOutcome } from "./ExpansionOutcome.js";
import { useRigEnv } from "../hooks/useRigEnv.js";
interface RestoreNodeResult {
nodeId: string;
logicalId: string;
status: string;
error?: string;
}
interface RigDetailPanelProps {
rigId: string;
onClose: () => void;
}
function formatSnapshotAge(timestamp: string | null): string {
if (!timestamp) return "No snapshots";
const age = Date.now() - new Date(timestamp).getTime();
const minutes = Math.floor(age / 60000);
if (minutes < 1) return "< 1m ago";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function formatRestoreError(err: Error, rigName: string): string {
if (err instanceof RestoreError && err.code === "rig_not_stopped") {
return `Stop ${rigName} before restoring. Run rig down ${rigName} and retry.`;
}
return err.message;
}
function formatNodeSummary(ps: PsEntry | undefined, summary: RigSummary | undefined): string {
if (ps) {
return `${ps.runningCount}/${ps.nodeCount} running`;
}
return `${summary?.nodeCount ?? 0} total`;
}
export function RigDetailPanel({ rigId, onClose }: RigDetailPanelProps) {
const { data: summaries } = useRigSummary();
const { data: psEntries } = usePsEntries();
const { data: rawNodeInventory, isPending: nodesLoading } = useNodeInventory(rigId);
const { data: snapshots = [], isPending: snapshotsLoading, error: snapshotsFetchError } = useSnapshots(rigId);
const createSnapshot = useCreateSnapshot(rigId);
const restoreSnapshot = useRestoreSnapshot(rigId);
const startRig = useStartRig(rigId);
const teardownRig = useTeardownRig(rigId);
const { data: envData, isPending: envLoading, error: envError } = useRigEnv(rigId);
const [activeTab, setActiveTab] = useState<"info" | "env" | "chat">("info");
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
const [confirmDown, setConfirmDown] = useState(false);
const [showSnapshotHistory, setShowSnapshotHistory] = useState(false);
const [restoreResult, setRestoreResult] = useState<RestoreNodeResult[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [showAddPod, setShowAddPod] = useState(false);
const [podYaml, setPodYaml] = useState("");
const [expandResult, setExpandResult] = useState<ExpandRigResult | null>(null);
const expandRig = useExpandRig();
const summary: RigSummary | undefined = summaries?.find((s) => s.id === rigId);
const hasEnv = summary?.hasServices === true || envData?.hasServices === true;
const ps: PsEntry | undefined = psEntries?.find((p) => p.rigId === rigId);
const nodeInventory = Array.isArray(rawNodeInventory) ? rawNodeInventory : [];
const pods = Array.from(
nodeInventory.reduce((map, node) => {
const key = node.podId ?? "__ungrouped__";
const group = map.get(key) ?? [];
group.push(node);
map.set(key, group);
return map;
}, new Map<string, typeof nodeInventory>())
);
const orderedSnapshots = [...snapshots].sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime());
const latestSnapshot = orderedSnapshots[0] ?? null;
const olderSnapshots = orderedSnapshots.slice(1);
const latestSnapshotTail = latestSnapshot ? shortId(latestSnapshot.id) : null;
const rigIdTail = shortId(rigId);
const rigIdHead = rigId.slice(0, Math.max(0, rigId.length - rigIdTail.length));
const rigStatus = ps?.status ?? "stopped";
const handleCreate = () => {
setError(null);
setActionError(null);
createSnapshot.mutate(undefined, {
onError: (err) => setActionError(err.message),
});
};
const handleRestore = (snapshotId: string) => {
setError(null);
setRestoreResult(null);
restoreSnapshot.mutate(snapshotId, {
onSuccess: (data) => {
setRestoreResult((data as { nodes?: RestoreNodeResult[] }).nodes ?? []);
setConfirmRestore(null);
},
onError: (err) => {
setError(formatRestoreError(err, summary?.name ?? rigId));
setConfirmRestore(null);
},
});
};
const handleExport = async () => {
setActionError(null);
try {
const res = await fetch(`/api/rigs/${encodeURIComponent(rigId)}/spec`);
if (!res.ok) {
setActionError(`Export failed (HTTP ${res.status})`);
return;
}
const yaml = await res.text();
const blob = new Blob([yaml], { type: "text/yaml" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${summary?.name ?? rigId}.yaml`;
link.click();
URL.revokeObjectURL(url);
} catch (err) {
setActionError(err instanceof Error ? err.message : "Export failed");
}
};
const handleStart = () => {
setActionError(null);
startRig.mutate(undefined, {
onError: (err) => setActionError(err.message),
});
};
const handleTeardown = () => {
setActionError(null);
teardownRig.mutate(undefined, {
onSuccess: () => setConfirmDown(false),
onError: (err) => setActionError(err.message),
});
};
return (
<aside
data-testid="rig-detail-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"
>
{/* Header */}
<div className="flex justify-between items-center px-4 py-3 border-b border-stone-300/35 shrink-0">
<h2 className="min-w-0 font-mono text-xs font-bold text-stone-900 truncate">{summary?.name ?? rigId}</h2>
<button
data-testid="close-drawer"
onClick={onClose}
className="text-stone-400 hover:text-stone-900 text-sm"
aria-label="Close"
>
✕
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-stone-300/35 shrink-0" data-testid="drawer-tabs">
<button
data-testid="tab-info"
onClick={() => setActiveTab("info")}
className={`flex-1 py-2 text-xs font-mono uppercase text-center ${activeTab === "info" ? "border-b-2 border-stone-800 font-bold" : "text-stone-400"}`}
>
Info
</button>
{hasEnv && (
<button
data-testid="tab-env"
onClick={() => setActiveTab("env")}
className={`flex-1 py-2 text-xs font-mono uppercase text-center ${activeTab === "env" ? "border-b-2 border-stone-800 font-bold" : "text-stone-400"}`}
>
Env
</button>
)}
<button
data-testid="tab-chat"
onClick={() => setActiveTab("chat")}
className={`flex-1 py-2 text-xs font-mono uppercase text-center ${activeTab === "chat" ? "border-b-2 border-stone-800 font-bold" : "text-stone-400"}`}
>
Chat Room
</button>
</div>
{activeTab === "chat" ? (
<RigChatPanel rigId={rigId} />
) : activeTab === "env" && hasEnv ? (
envData ? (
<RigEnvPanel rigId={rigId} envData={envData} />
) : (
<div className="flex-1 overflow-y-auto">
<section className="px-4 py-3 border-b border-stone-100">
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Environment</div>
<div className="font-mono text-[10px] text-stone-600">
{envLoading ? "Loading environment status..." : envError ? "Environment status unavailable." : "Waiting for environment status..."}
</div>
</section>
</div>
)
) : (
<div className="flex-1 overflow-y-auto">
{/* Identity */}
<section className="px-4 py-3 border-b border-stone-100">
<div>
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Identity</div>
<div className="space-y-1 font-mono text-[10px]">
<div className="flex justify-between gap-3">
<span className="text-stone-500">ID</span>
<span data-testid="rig-id-value" className="min-w-0 truncate text-stone-900">
{rigIdHead && <span className="text-stone-500">{rigIdHead}</span>}
<span data-testid="rig-id-tail" className="font-bold">{rigIdTail}</span>
</span>
</div>
</div>
</div>
</section>
{/* Status */}
<section className="px-4 py-3 border-b border-stone-100">
<div>
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Status</div>
<div className="space-y-1 font-mono text-[10px]">
<div className="flex justify-between"><span className="text-stone-500">Nodes</span><span>{formatNodeSummary(ps, summary)}</span></div>
<div className="flex justify-between"><span className="text-stone-500">Uptime</span><span data-testid="rig-uptime">{ps?.uptime ?? "—"}</span></div>
</div>
</div>
</section>
{/* Actions */}
<section className="px-4 py-3 border-b border-stone-100">
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Actions</div>
<div className="flex flex-col gap-1">
<button
onClick={handleCreate}
data-testid="rig-create-snapshot"
disabled={createSnapshot.isPending}
className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left"
>
{createSnapshot.isPending ? "Creating..." : "Create snapshot"}
</button>
<button onClick={handleExport} data-testid="rig-export-spec" className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left">
Export spec
</button>
{rigStatus === "running" || rigStatus === "partial" ? (
<button onClick={() => setConfirmDown(true)} data-testid="rig-power-action" className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left">
Turn off
</button>
) : (
<button onClick={handleStart} data-testid="rig-power-action" disabled={startRig.isPending} className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left disabled:opacity-50">
{startRig.isPending ? "Starting..." : "Turn on"}
</button>
)}
<button
onClick={() => { setShowAddPod(!showAddPod); setExpandResult(null); }}
data-testid="rig-add-pod"
className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left"
>
Add pod
</button>
</div>
{actionError && (
<div data-testid="rig-action-error" className="mt-2 font-mono text-[9px] text-red-700">
{actionError}
</div>
)}
</section>
{/* Add Pod form */}
{showAddPod && (
<section data-testid="add-pod-form" className="px-4 py-3 border-b border-stone-100">
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Add Pod — YAML Fragment</div>
<textarea
data-testid="add-pod-yaml"
className="w-full h-24 font-mono text-[9px] p-2 border border-stone-300 bg-white"
value={podYaml}
onChange={(e) => setPodYaml(e.target.value)}
placeholder={"id: my-pod\nlabel: My Pod\nmembers:\n - id: worker\n runtime: terminal\n agent_ref: \"builtin:terminal\"\nedges: []"}
/>
<button
data-testid="add-pod-submit"
disabled={expandRig.isPending || !podYaml.trim()}
onClick={async () => {
setExpandResult(null);
setActionError(null);
try {
const { parse } = await import("yaml");
const pod = parse(podYaml);
const result = await expandRig.mutateAsync({ rigId, pod });
setExpandResult(result);
if (result.status === "ok") { setShowAddPod(false); setPodYaml(""); }
} catch (err) {
setActionError((err as Error).message);
}
}}
className="mt-1 px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 disabled:opacity-50"
>
{expandRig.isPending ? "Expanding..." : "Expand"}
</button>
{expandResult && <ExpansionOutcome result={expandResult} />}
</section>
)}
{/* Pods */}
<section className="px-4 py-3 border-b border-stone-100">
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Pods</div>
{nodesLoading ? (
<div className="font-mono text-[9px] text-stone-400">Loading pods...</div>
) : pods.length === 0 ? (
<div className="font-mono text-[9px] text-stone-400">No nodes yet</div>
) : (
<div className="space-y-2">
{pods.map(([podId, members]) => (
<div key={podId} className="p-2 bg-stone-50 border border-stone-200">
<div className="flex items-center justify-between">
<div className="font-mono text-[10px] font-bold">{inferPodName(members[0]?.logicalId) ?? displayPodName(podId === "__ungrouped__" ? null : podId)}</div>
<div className="font-mono text-[9px] text-stone-400">{members.length}</div>
</div>
<div className="mt-1 flex flex-wrap gap-1">
{members.map((member) => (
<span key={member.logicalId} className="font-mono text-[9px] text-stone-700 border border-stone-200 bg-white px-1.5 py-0.5">
{displayAgentName(member.logicalId)}
</span>
))}
</div>
</div>
))}
</div>
)}
</section>
{/* Snapshots */}
<section className="px-4 py-3">
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-3">
Snapshots ({snapshots.length})
</div>
{/* Error */}
{error && (
<div data-testid="snapshot-error" className="mb-2 p-2 bg-red-50 border border-red-200 font-mono text-[9px] text-red-700">
{error}
</div>
)}
{/* Restore result */}
{restoreResult && (
<div data-testid="restore-result" className="mb-3 p-2 bg-stone-50 border border-stone-200">
<div className="font-mono text-[9px] text-stone-500 uppercase mb-1">Restore Complete</div>
{restoreResult.map((n) => (
<div key={n.nodeId} className="flex items-center justify-between font-mono text-[10px]">
<span>{n.logicalId}</span>
<span className={getRestoreStatusColorClass(n.status)} data-testid={`restore-status-${n.logicalId}`}>
{n.status}
</span>
</div>
))}
</div>
)}
{/* Snapshot fetch error */}
{snapshotsFetchError && (
<div data-testid="snapshot-fetch-error" className="mb-2 p-2 bg-red-50 border border-red-200 font-mono text-[9px] text-red-700">
Failed to load snapshots
</div>
)}
{/* Snapshot list */}
{snapshotsLoading ? (
<div className="font-mono text-[10px] text-stone-400 py-2 text-center">Loading snapshots...</div>
) : snapshots.length === 0 && !snapshotsFetchError ? (
<div className="font-mono text-[10px] text-stone-400 py-2 text-center italic">
No snapshots yet
</div>
) : (
<div className="space-y-1">
{latestSnapshot && (
<div className="p-2 bg-stone-50 hover:bg-stone-100 transition-colors">
<div className="flex justify-between items-start">
<div>
<div className="font-mono text-[8px] uppercase tracking-[0.12em] text-stone-400">
Latest
</div>
<div className="font-mono text-[10px] font-bold" data-testid={`snap-short-${latestSnapshot.id}`}>
{latestSnapshotTail}
</div>
<div className="font-mono text-[9px] text-stone-400 mt-0.5">
{latestSnapshot.kind} · {formatSnapshotAge(latestSnapshot.createdAt)}
</div>
</div>
<button
data-testid={`restore-btn-${latestSnapshot.id}`}
onClick={() => setConfirmRestore(latestSnapshot.id)}
className="font-mono text-[8px] border border-stone-300 px-1 py-0.5 hover:bg-stone-200"
>
Restore
</button>
</div>
</div>
)}
{olderSnapshots.length > 0 && (
<div className="border-t border-stone-200 pt-2">
<button
type="button"
data-testid="snapshot-history-toggle"
onClick={() => setShowSnapshotHistory((value) => !value)}
className="font-mono text-[8px] uppercase tracking-[0.12em] text-stone-500 hover:text-stone-900"
>
{showSnapshotHistory ? "Hide history" : `Show history (${olderSnapshots.length})`}
</button>
{showSnapshotHistory && (
<div className="mt-2 space-y-1">
{olderSnapshots.map((snap) => (
<div key={snap.id} className="p-2 bg-stone-50 hover:bg-stone-100 transition-colors">
<div className="flex justify-between items-start gap-3">
<div className="min-w-0">
<div className="font-mono text-[10px] font-bold" data-testid={`snap-short-${snap.id}`}>
{shortId(snap.id)}
</div>
<div className="font-mono text-[9px] text-stone-400 mt-0.5">
{snap.kind} · {formatSnapshotAge(snap.createdAt)}
</div>
</div>
<button
data-testid={`restore-btn-${snap.id}`}
onClick={() => setConfirmRestore(snap.id)}
className="shrink-0 font-mono text-[8px] border border-stone-300 px-1 py-0.5 hover:bg-stone-200"
>
Restore
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</section>
{/* Restore confirmation dialog */}
<Dialog open={confirmRestore !== null} onOpenChange={(open) => { if (!open) setConfirmRestore(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-headline text-lg font-bold uppercase">Restore Snapshot</DialogTitle>
<DialogDescription className="text-sm text-stone-500">
Restore from {confirmRestore ? shortId(confirmRestore) : ""}? Existing sessions will be restarted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmRestore(null)}>Cancel</Button>
<Button
variant="default"
data-testid={confirmRestore ? `confirm-restore-${confirmRestore}` : undefined}
onClick={() => confirmRestore && handleRestore(confirmRestore)}
>
Confirm Restore
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={confirmDown} onOpenChange={(open) => { if (!open) setConfirmDown(false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-headline text-lg font-bold uppercase">Turn Off Rig</DialogTitle>
<DialogDescription className="text-sm text-stone-500">
Stop all running sessions for {summary?.name ?? rigId}?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmDown(false)}>Cancel</Button>
<Button
variant="default"
data-testid="confirm-rig-down"
disabled={teardownRig.isPending}
onClick={handleTeardown}
>
{teardownRig.isPending ? "Stopping..." : "Confirm"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
</aside>
);
}
+3 -1
View File
@@ -340,7 +340,9 @@ export function RigGraph({
}
if (node.type === "podGroup" || node.type === "group") {
setSelection({ type: "rig", rigId });
// Phase 4 P4-5: 'rig' kind retired from DrawerSelection;
// pod-group click is a no-op at the graph level (pods open
// via Explorer tree's /topology/pod/$rigId/$podName link).
return;
}
@@ -1,40 +1,34 @@
// V1 attempt-3 Phase 2 — SharedDetailDrawer chrome.
// V1 attempt-3 Phase 4 — SharedDetailDrawer chrome + DrawerSelection
// union extended for the 4 new viewer kinds (qitem / file / sub-spec /
// seat-detail) per content-drawer.md.
//
// 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)."
// SC-22 preserved: default-closed (selection===null returns null);
// VellumSheet width="wide" (38rem); user-closable [×]; one-trigger-at-
// a-time (clicking new trigger swaps content via setSelection).
//
// Phase 2 lays the chrome: VellumSheet primitive (Phase 1) wraps each
// panel; default-closed (selection === null returns null); ~608px wide
// on desktop (`lg:w-[38rem]` via VellumSheet width="wide"; calibrated
// 2026-05-06 per content-drawer.md L9); 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.
// Phase 4 P4-5: 'rig' kind removed from DrawerSelection. RigDetailPanel
// retired (legacy auto-open right-sidebar pattern); rig clicks now
// navigate to /topology/rig/$rigId via URL (no drawer auto-open).
import { RigDetailPanel } from "./RigDetailPanel.js";
import { NodeDetailPanel } from "./NodeDetailPanel.js";
import { SystemPanel } from "./SystemPanel.js";
import { DiscoveryPanel, type DiscoveryPlacementTarget } from "./DiscoveryPanel.js";
import { VellumSheet } from "./ui/vellum-sheet.js";
import { QueueItemViewer, type QueueItemViewerData } from "./drawer-viewers/QueueItemViewer.js";
import { FileViewer, type FileViewerData } from "./drawer-viewers/FileViewer.js";
import { SubSpecPreview, type SubSpecPreviewData } from "./drawer-viewers/SubSpecPreview.js";
import { SeatDetailViewer } from "./drawer-viewers/SeatDetailViewer.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" }
// Phase 4 viewer kinds
| { type: "qitem"; data: QueueItemViewerData }
| { type: "file"; data: FileViewerData }
| { type: "sub-spec"; data: SubSpecPreviewData }
| { type: "seat-detail"; rigId: string; logicalId: string }
| null;
interface SharedDetailDrawerProps {
@@ -60,9 +54,6 @@ export function SharedDetailDrawer({
if (!selection) return null;
const inner = (() => {
if (selection.type === "rig") {
return <RigDetailPanel rigId={selection.rigId} onClose={onClose} />;
}
if (selection.type === "node") {
return (
<NodeDetailPanel
@@ -88,6 +79,24 @@ export function SharedDetailDrawer({
/>
);
}
if (selection.type === "qitem") {
return <QueueItemViewer {...selection.data} />;
}
if (selection.type === "file") {
return <FileViewer {...selection.data} />;
}
if (selection.type === "sub-spec") {
return <SubSpecPreview {...selection.data} />;
}
if (selection.type === "seat-detail") {
return (
<SeatDetailViewer
rigId={selection.rigId}
logicalId={selection.logicalId}
onClose={onClose}
/>
);
}
return null;
})();
@@ -98,8 +107,8 @@ export function SharedDetailDrawer({
onClose={onClose}
testId="shared-detail-drawer"
// top-14 starts below the universal top bar (h-14, fixed at top); bottom-0
// anchors to viewport bottom so the drawer fills the remaining height
// without needing a calc() expression.
// anchors to viewport bottom so the drawer fills the remaining height.
// Bounce-fix #3 width-coupling: 38rem (lg:w-[38rem]) per VellumSheet wide preset.
className="fixed top-14 right-0 bottom-0 z-30"
>
{inner}
@@ -0,0 +1,28 @@
// V1 attempt-3 Phase 4 — FileReferenceTrigger.
import { type ReactNode, type CSSProperties } from "react";
import { useDrawerSelection } from "../AppShell.js";
import type { FileViewerData } from "../drawer-viewers/FileViewer.js";
interface FileReferenceTriggerProps {
data: FileViewerData;
children: ReactNode;
className?: string;
style?: CSSProperties;
testId?: string;
}
export function FileReferenceTrigger({ data, children, className, style, testId }: FileReferenceTriggerProps) {
const { setSelection } = useDrawerSelection();
return (
<button
type="button"
data-testid={testId ?? "file-reference-trigger"}
onClick={() => setSelection({ type: "file", data })}
className={className ?? "text-left"}
style={style}
>
{children}
</button>
);
}
@@ -0,0 +1,32 @@
// V1 attempt-3 Phase 4 — QueueItemTrigger.
//
// Wraps clickable elements (qitem rows, feed cards "show context"
// affordances) and opens the drawer on click with the QueueItemViewer
// payload.
import { type ReactNode, type CSSProperties } from "react";
import { useDrawerSelection } from "../AppShell.js";
import type { QueueItemViewerData } from "../drawer-viewers/QueueItemViewer.js";
interface QueueItemTriggerProps {
data: QueueItemViewerData;
children: ReactNode;
className?: string;
style?: CSSProperties;
testId?: string;
}
export function QueueItemTrigger({ data, children, className, style, testId }: QueueItemTriggerProps) {
const { setSelection } = useDrawerSelection();
return (
<button
type="button"
data-testid={testId ?? "queue-item-trigger"}
onClick={() => setSelection({ type: "qitem", data })}
className={className ?? "text-left"}
style={style}
>
{children}
</button>
);
}
@@ -0,0 +1,33 @@
// V1 attempt-3 Phase 4 — SeatDetailTrigger.
//
// Replaces the legacy auto-open RigDetailPanel pattern. Wraps a
// clickable element (e.g., a "details" icon next to a topology seat
// row) and opens the SeatDetailViewer in the drawer on click. Per
// content-drawer.md L37–L42 manual-open contract.
import { type ReactNode, type CSSProperties } from "react";
import { useDrawerSelection } from "../AppShell.js";
interface SeatDetailTriggerProps {
rigId: string;
logicalId: string;
children: ReactNode;
className?: string;
style?: CSSProperties;
testId?: string;
}
export function SeatDetailTrigger({ rigId, logicalId, children, className, style, testId }: SeatDetailTriggerProps) {
const { setSelection } = useDrawerSelection();
return (
<button
type="button"
data-testid={testId ?? "seat-detail-trigger"}
onClick={() => setSelection({ type: "seat-detail", rigId, logicalId })}
className={className ?? "text-left"}
style={style}
>
{children}
</button>
);
}
@@ -0,0 +1,28 @@
// V1 attempt-3 Phase 4 — SubSpecTrigger.
import { type ReactNode, type CSSProperties } from "react";
import { useDrawerSelection } from "../AppShell.js";
import type { SubSpecPreviewData } from "../drawer-viewers/SubSpecPreview.js";
interface SubSpecTriggerProps {
data: SubSpecPreviewData;
children: ReactNode;
className?: string;
style?: CSSProperties;
testId?: string;
}
export function SubSpecTrigger({ data, children, className, style, testId }: SubSpecTriggerProps) {
const { setSelection } = useDrawerSelection();
return (
<button
type="button"
data-testid={testId ?? "sub-spec-trigger"}
onClick={() => setSelection({ type: "sub-spec", data })}
className={className ?? "text-left"}
style={style}
>
{children}
</button>
);
}
@@ -0,0 +1,82 @@
// V1 attempt-3 Phase 4 — FileViewer per content-drawer.md L88–L108.
//
// Renders markdown / text / YAML / JSON / image / binary file refs.
import { SectionHeader } from "../ui/section-header.js";
import { EmptyState } from "../ui/empty-state.js";
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
export type FileKind = "markdown" | "text" | "yaml" | "json" | "image" | "binary";
export interface FileViewerData {
path: string;
kind?: FileKind;
content?: string;
imageUrl?: string;
}
function inferKind(path: string): FileKind {
const lower = path.toLowerCase();
if (lower.endsWith(".md") || lower.endsWith(".mdx")) return "markdown";
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
if (lower.endsWith(".json")) return "json";
if (lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".svg")) return "image";
if (lower.endsWith(".log") || lower.endsWith(".txt")) return "text";
return "text";
}
export function FileViewer({ path, kind, content, imageUrl }: FileViewerData) {
const resolvedKind = kind ?? inferKind(path);
if (!content && !imageUrl && resolvedKind !== "binary") {
return (
<EmptyState
label="NO CONTENT"
description={`Loading ${path}...`}
variant="card"
testId="file-viewer-empty"
/>
);
}
return (
<div data-testid="file-viewer" data-file-kind={resolvedKind} className="flex flex-col h-full">
<header className="px-4 py-3 border-b border-outline-variant">
<SectionHeader tone="muted">File</SectionHeader>
<h3 className="mt-1 font-mono text-xs text-stone-900 break-all">{path}</h3>
</header>
<div className="flex-1 min-h-0 overflow-y-auto">
{resolvedKind === "markdown" && content ? (
<div className="px-4 py-3">
<MarkdownViewer content={content} />
</div>
) : null}
{resolvedKind === "yaml" || resolvedKind === "json" ? (
<pre className="px-4 py-3 font-mono text-xs text-stone-900 whitespace-pre-wrap">
{content}
</pre>
) : null}
{resolvedKind === "text" ? (
<pre className="px-4 py-3 font-mono text-xs text-stone-900 whitespace-pre">
{content}
</pre>
) : null}
{resolvedKind === "image" && imageUrl ? (
<div className="px-4 py-3 flex justify-center">
<img src={imageUrl} alt={path} className="max-w-full h-auto" />
</div>
) : null}
{resolvedKind === "binary" ? (
<div className="px-4 py-6">
<EmptyState
label="BINARY"
description="Cannot preview; download instead."
variant="card"
testId="file-viewer-binary"
/>
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,130 @@
// V1 attempt-3 Phase 4 — QueueItemViewer per content-drawer.md L46–L82.
//
// Renders qitem header (id + close + open-in-center) + metadata (source/
// dest/state/tags/created) + body preview (~30 lines + show-full-body)
// + Related (clickable refs).
import { useState } from "react";
import { SectionHeader } from "../ui/section-header.js";
import { StatusPip } from "../ui/status-pip.js";
import { EmptyState } from "../ui/empty-state.js";
export interface QueueItemViewerData {
qitemId: string;
source?: string;
destination?: string;
state?: string;
tags?: string[];
createdAt?: string;
body?: string;
related?: Array<{ kind: "file" | "commit" | "slice" | "seat"; label: string; href?: string }>;
}
const PREVIEW_LINES = 30;
function statusFromState(state: string | undefined): React.ComponentProps<typeof StatusPip>["status"] {
if (!state) return "info";
if (state === "done" || state === "completed") return "active";
if (state === "in-progress" || state === "running") return "running";
if (state === "failed" || state === "error" || state === "denied") return "error";
if (state === "blocked" || state === "human-gate" || state === "pending-approval") return "warning";
if (state === "stopped" || state === "canceled") return "stopped";
return "info";
}
export function QueueItemViewer({
qitemId,
source,
destination,
state,
tags,
createdAt,
body,
related,
}: QueueItemViewerData) {
const [showFull, setShowFull] = useState(false);
const bodyLines = body ? body.split("\n") : [];
const visibleLines = showFull ? bodyLines : bodyLines.slice(0, PREVIEW_LINES);
if (!qitemId) {
return (
<EmptyState
label="NO QITEM"
description="No queue item selected."
variant="card"
testId="queue-item-viewer-empty"
/>
);
}
return (
<div data-testid="queue-item-viewer" className="flex flex-col h-full">
<header className="px-4 py-3 border-b border-outline-variant">
<SectionHeader tone="muted">Queue item</SectionHeader>
<h3 className="mt-1 font-mono text-xs text-stone-900 break-all">{qitemId}</h3>
</header>
<div className="px-4 py-3 border-b border-outline-variant space-y-1.5 font-mono text-xs">
{source ? <Row label="Source" value={source} /> : null}
{destination ? <Row label="Dest" value={destination} /> : null}
{state ? (
<div className="flex items-center justify-between">
<span className="text-on-surface-variant">State</span>
<StatusPip status={statusFromState(state)} label={state} variant="pill" testId="qitem-state" />
</div>
) : null}
{tags && tags.length > 0 ? (
<Row label="Tags" value={tags.join(", ")} />
) : null}
{createdAt ? <Row label="Created" value={createdAt.slice(0, 19).replace("T", " ")} /> : null}
</div>
<div className="px-4 py-3 border-b border-outline-variant flex-1 min-h-0 overflow-y-auto">
<SectionHeader tone="muted">Body</SectionHeader>
{body ? (
<pre data-testid="qitem-body" className="mt-2 whitespace-pre-wrap font-mono text-xs text-stone-900">
{visibleLines.join("\n")}
</pre>
) : (
<p className="mt-2 font-mono text-xs text-on-surface-variant italic">No body.</p>
)}
{body && bodyLines.length > PREVIEW_LINES ? (
<button
type="button"
onClick={() => setShowFull((s) => !s)}
data-testid="qitem-body-toggle"
className="mt-2 font-mono text-[10px] uppercase tracking-wide text-stone-700 hover:text-stone-900 underline"
>
{showFull ? "Show less" : `Show full body (${bodyLines.length} lines)`}
</button>
) : null}
</div>
{related && related.length > 0 ? (
<div className="px-4 py-3">
<SectionHeader tone="muted">Related</SectionHeader>
<ul className="mt-2 space-y-1 font-mono text-xs">
{related.map((r, i) => (
<li key={`${r.kind}-${i}`} className="flex items-baseline gap-2">
<span className="text-on-surface-variant text-[10px] uppercase tracking-wide w-12 shrink-0">
{r.kind}
</span>
{r.href ? (
<a href={r.href} className="text-stone-900 hover:underline truncate">{r.label}</a>
) : (
<span className="text-stone-900 truncate">{r.label}</span>
)}
</li>
))}
</ul>
</div>
) : null}
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline justify-between gap-3">
<span className="text-on-surface-variant">{label}</span>
<span className="text-stone-900 truncate">{value}</span>
</div>
);
}
@@ -0,0 +1,27 @@
// V1 attempt-3 Phase 4 — SeatDetailViewer per content-drawer.md L137–L139.
//
// V1.5 — Replaces the legacy auto-open right-sidebar pattern (RigDetailPanel
// retired in P4-5) with explicit manual-trigger drawer mount. Same content
// shape as the existing NodeDetailPanel; reuses that component as the
// payload renderer (which is what NodeDetailPanel already does in the
// preserved-canonical components per code-map AFTER tree).
import { NodeDetailPanel } from "../NodeDetailPanel.js";
export interface SeatDetailViewerData {
rigId: string;
logicalId: string;
onClose?: () => void;
}
export function SeatDetailViewer({ rigId, logicalId, onClose }: SeatDetailViewerData) {
return (
<div data-testid="seat-detail-viewer" className="flex flex-col h-full">
<NodeDetailPanel
rigId={rigId}
logicalId={logicalId}
onClose={onClose ?? (() => {})}
/>
</div>
);
}
@@ -0,0 +1,73 @@
// V1 attempt-3 Phase 4 — SubSpecPreview per content-drawer.md L112–L135.
import { Link } from "@tanstack/react-router";
import { SectionHeader } from "../ui/section-header.js";
export interface SubSpecPreviewData {
specKind: string;
specName: string;
version?: string;
source?: "builtin" | "user_file" | "fork";
manifestExcerpt?: string;
entryId?: string;
}
export function SubSpecPreview({
specKind,
specName,
version,
source,
manifestExcerpt,
entryId,
}: SubSpecPreviewData) {
return (
<div data-testid="sub-spec-preview" className="flex flex-col h-full">
<header className="px-4 py-3 border-b border-outline-variant">
<SectionHeader tone="muted">Referenced spec</SectionHeader>
<h3 className="mt-1 font-mono text-xs text-stone-900 break-all">
{specKind}: {specName}
</h3>
</header>
<div className="px-4 py-3 border-b border-outline-variant space-y-1.5 font-mono text-xs">
<div className="flex items-baseline justify-between gap-3">
<span className="text-on-surface-variant">Kind</span>
<span className="text-stone-900">{specKind}</span>
</div>
{version ? (
<div className="flex items-baseline justify-between gap-3">
<span className="text-on-surface-variant">Version</span>
<span className="text-stone-900">{version}</span>
</div>
) : null}
{source ? (
<div className="flex items-baseline justify-between gap-3">
<span className="text-on-surface-variant">Source</span>
<span className="text-stone-900 uppercase">{source.replace("_", " ")}</span>
</div>
) : null}
</div>
<div className="px-4 py-3 flex-1 min-h-0 overflow-y-auto">
<SectionHeader tone="muted">Manifest excerpt</SectionHeader>
{manifestExcerpt ? (
<pre className="mt-2 whitespace-pre-wrap font-mono text-xs text-stone-900">
{manifestExcerpt}
</pre>
) : (
<p className="mt-2 font-mono text-xs text-on-surface-variant italic">No excerpt available.</p>
)}
</div>
{entryId ? (
<div className="px-4 py-3 border-t border-outline-variant">
<Link
to="/specs/library/$entryId"
params={{ entryId }}
data-testid="sub-spec-open-center"
className="inline-flex items-center px-3 py-1 border border-outline-variant bg-white font-mono text-[10px] uppercase tracking-wide text-stone-900 hover:bg-stone-100"
>
Open in center →
</Link>
</div>
) : null}
</div>
);
}
@@ -1,9 +1,17 @@
// V1 attempt-3 Phase 3 — AuthorAgentTag stub per agent-chat-surface.md L96–L100.
// V1 attempt-3 Phase 4 — AuthorAgentTag with cmux launcher wired.
//
// Phase 3 lays the click handler stub; Phase 4 wires actual cmux launcher
// + topology seat navigation.
// Per agent-chat-surface.md L13–L21 V1 default: click → topology seat
// detail with cmux launcher button. Phase 3 stubbed the navigation;
// Phase 4 wires the cmux launcher itself via useCmuxLaunch (POST to
// /api/rigs/$rigId/nodes/$logicalId/focus — existing pre-Phase-2
// endpoint per architecture.md L88; no new daemon endpoint).
//
// Click semantics: Tag click → cmux launches (foregrounds the seat
// in cmux). The label stays a Link so right-click / cmd-click still
// open the seat detail page (URL preserved).
import { Link } from "@tanstack/react-router";
import { useCmuxLaunch } from "../../hooks/useCmuxLaunch.js";
interface AuthorAgentTagProps {
authorSession: string;
@@ -24,8 +32,8 @@ function parseSeat(authorSession: string): { logicalId: string; rigId: string |
export function AuthorAgentTag({ authorSession, rigId, className, testId }: AuthorAgentTagProps) {
const parsed = parseSeat(authorSession);
const targetRigId = rigId ?? parsed.rigId;
const cmuxLaunch = useCmuxLaunch();
// V1: route to topology seat detail. V2 wires right-drawer web terminal.
// If we can't resolve a rigId, just show the tag without a link.
if (!targetRigId) {
return (
@@ -43,6 +51,12 @@ export function AuthorAgentTag({ authorSession, rigId, className, testId }: Auth
to="/topology/seat/$rigId/$logicalId"
params={{ rigId: targetRigId, logicalId: encodeURIComponent(parsed.logicalId) }}
data-testid={testId ?? "author-agent-tag"}
onClick={(e) => {
// Cmd/Ctrl-click or right-click → standard Link behavior (open seat detail).
// Plain click → fire cmux launcher AND let Link navigate.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
cmuxLaunch.mutate({ rigId: targetRigId, logicalId: parsed.logicalId });
}}
className={
className ??
"font-mono text-[10px] text-on-surface-variant hover:text-stone-900 hover:underline"
+30
View File
@@ -0,0 +1,30 @@
// V1 attempt-3 Phase 4 — useCmuxLaunch.
//
// Thin wrapper around the existing daemon focus endpoint
// `POST /api/rigs/:rigId/nodes/:logicalId/focus` (shipped pre-Phase-2
// per architecture.md L88). No new endpoint; SC-29 honored.
// Returns a launch function that posts focus to a seat — daemon
// dispatches cmux focus-surface command to bring the seat to the
// foreground.
import { useMutation } from "@tanstack/react-query";
interface FocusInput {
rigId: string;
logicalId: string;
}
async function postFocus({ rigId, logicalId }: FocusInput): Promise<{ ok: boolean }> {
const res = await fetch(
`/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(logicalId)}/focus`,
{ method: "POST" },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export function useCmuxLaunch() {
return useMutation({
mutationFn: postFocus,
});
}
-490
View File
@@ -1,490 +0,0 @@
import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent, act } from "@testing-library/react";
import { createMockEventSourceClass } from "./helpers/mock-event-source.js";
import { createTestRouter, createAppTestRouter } from "./helpers/test-router.js";
import { Dashboard } from "../src/components/Dashboard.js";
import { useCountUp } from "../src/hooks/useCountUp.js";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
let OriginalEventSource: typeof EventSource | undefined;
beforeEach(() => {
mockFetch.mockReset();
OriginalEventSource = globalThis.EventSource;
globalThis.EventSource = createMockEventSourceClass() as unknown as typeof EventSource;
});
afterEach(() => {
if (OriginalEventSource) globalThis.EventSource = OriginalEventSource;
cleanup();
});
function setupSummaryMock(
rigs: Array<{
id: string; name: string; nodeCount: number;
latestSnapshotAt: string | null; latestSnapshotId: string | null;
}>,
psEntries?: Array<{
rigId: string; name: string; nodeCount: number; runningCount: number;
status: string; uptime: string | null; latestSnapshot: string | null;
}>,
) {
const defaultPs = rigs.map((r) => ({
rigId: r.id, name: r.name, nodeCount: r.nodeCount, runningCount: r.nodeCount,
status: "running", uptime: "1h", latestSnapshot: null,
}));
const ps = psEntries ?? defaultPs;
mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
if (url === "/api/rigs/summary") return Promise.resolve({ ok: true, json: async () => rigs });
if (url === "/api/ps") return Promise.resolve({ ok: true, json: async () => ps });
if (typeof url === "string" && url.includes("/snapshots") && opts?.method === "POST") return Promise.resolve({ ok: true, json: async () => ({ id: "snap-new" }) });
if (typeof url === "string" && url.includes("/spec")) return Promise.resolve({ ok: true, text: async () => "yaml: content" });
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: false }) });
if (url === "/api/down" && opts?.method === "POST") return Promise.resolve({ ok: true, json: async () => ({ rigId: "r1", sessionsKilled: 2, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: [] }) });
return Promise.resolve({ ok: true, json: async () => ({}) });
});
}
describe("Dashboard", () => {
// Test 1: Renders rig cards from query data
it("renders rig cards from query data", async () => {
setupSummaryMock([
{ id: "r1", name: "alpha", nodeCount: 3, latestSnapshotAt: null, latestSnapshotId: null },
{ id: "r2", name: "beta", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null },
]);
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
expect(screen.getByText("alpha")).toBeDefined();
expect(screen.getByText("beta")).toBeDefined();
});
});
// Test 2: Card shows name + monospaced node count + snapshot age
it("card shows name, monospaced node count, and snapshot age", async () => {
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 5, latestSnapshotAt: "2026-03-24 01:00:00", latestSnapshotId: "s1" }]);
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
expect(screen.getByText("alpha")).toBeDefined();
// Node count is in a span whose parent div has font-mono
const countEl = screen.getByTestId("node-count-r1");
expect(countEl.parentElement!.className).toContain("font-mono");
});
});
// Test 3: Snapshot button triggers mutation (stopPropagation)
it("snapshot button triggers mutation", async () => {
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }]);
render(createTestRouter({ component: Dashboard }));
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
// Click SNAPSHOT tactical button
const snapshotBtns = screen.getAllByText(/SNAPSHOT/);
const actionBtn = snapshotBtns.find((el) => el.closest("button"));
fireEvent.click(actionBtn!);
await waitFor(() => {
const postCall = mockFetch.mock.calls.find(
(c: unknown[]) => c[0] === "/api/rigs/r1/snapshots" && (c[1] as RequestInit)?.method === "POST"
);
expect(postCall).toBeDefined();
});
});
// Test 4: Export button triggers YAML download
it("export button triggers YAML download", async () => {
URL.createObjectURL = vi.fn(() => "blob:test");
URL.revokeObjectURL = vi.fn();
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }]);
render(createTestRouter({ component: Dashboard }));
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
const exportBtns = screen.getAllByText(/EXPORT/);
const actionBtn = exportBtns.find((el) => el.closest("button"));
fireEvent.click(actionBtn!);
await waitFor(() => {
const specCall = mockFetch.mock.calls.find((c: unknown[]) => c[0] === "/api/rigs/r1/spec");
expect(specCall).toBeDefined();
});
});
// Test 5: Card click navigates to /rigs/:rigId
it("card click navigates to rig detail", async () => {
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }]);
render(createAppTestRouter({
routes: [
{ path: "/", component: Dashboard },
{ path: "/rigs/$rigId", component: () => <div data-testid="rig-detail">detail</div> },
],
}));
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
// Click the card (not a button)
const card = screen.getByTestId("rig-card-r1");
fireEvent.click(card);
await waitFor(() => {
expect(screen.getByTestId("rig-detail")).toBeDefined();
});
});
// Test PNS-T09: Card click sets rig drawer selection
it("card click triggers rig drawer selection", async () => {
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }]);
const { DrawerSelectionContext } = await import("../src/components/AppShell.js");
const setSelectionSpy = vi.fn();
function SelectionWrapper({ children }: { children: React.ReactNode }) {
return (
<DrawerSelectionContext.Provider value={{ selection: null, setSelection: setSelectionSpy }}>
{children}
</DrawerSelectionContext.Provider>
);
}
render(createAppTestRouter({
routes: [
{ path: "/", component: Dashboard },
{ path: "/rigs/$rigId", component: () => <div data-testid="rig-route">graph</div> },
],
rootComponent: ({ children }) => <SelectionWrapper>{children}</SelectionWrapper>,
}));
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
const card = screen.getByTestId("rig-card-r1");
fireEvent.click(card);
await waitFor(() => {
expect(setSelectionSpy).toHaveBeenCalledWith({ type: "rig", rigId: "r1" });
});
});
// Test 6: Empty state with wireframe ghost + import CTA
it("empty state renders NO RIGS + muted copy + import button + wireframe ghost", async () => {
setupSummaryMock([]);
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
const empty = screen.getByTestId("dashboard-empty");
expect(empty).toBeDefined();
expect(screen.getByText("NO RIGS")).toBeDefined();
expect(screen.getByText(/set up a rig/i)).toBeDefined();
expect(screen.getByText(/SET UP YOUR FIRST RIG/)).toBeDefined();
expect(screen.getByTestId("wireframe-ghost")).toBeDefined();
});
});
// Test 7: Loading state renders skeleton with pulse animation
it("loading state renders skeleton cards with pulse animation", async () => {
mockFetch.mockReturnValue(new Promise(() => {}));
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
const loading = screen.getByTestId("dashboard-loading");
expect(loading).toBeDefined();
// Should contain pulse animation elements
expect(loading.innerHTML).toContain("shimmer");
});
});
// Test 8: Error state renders Alert
it("error state renders Alert component", async () => {
mockFetch.mockResolvedValue({ ok: false, status: 500 });
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
expect(screen.getByTestId("dashboard-error")).toBeDefined();
});
});
// Test 9: UP button navigates to /bootstrap
it("UP button navigates to /bootstrap", async () => {
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }]);
render(createAppTestRouter({
routes: [
{ path: "/", component: Dashboard },
{ path: "/bootstrap", component: () => <div data-testid="bootstrap-page">bootstrap</div> },
],
}));
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
fireEvent.click(screen.getByTestId("header-up-btn"));
await waitFor(() => {
expect(screen.getByTestId("bootstrap-page")).toBeDefined();
});
});
// Test 10: No-snapshot fallback renders "none"
it("card with no snapshot renders 'none' in snapshot age", async () => {
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }]);
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
const ageEl = screen.getByTestId("snapshot-age-r1");
expect(ageEl.textContent).toContain("none");
});
});
// Test 11: Action error shows Alert
it("snapshot error shows action error Alert", async () => {
mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
if (url === "/api/rigs/summary") {
return Promise.resolve({ ok: true, json: async () => [{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }] });
}
if (url === "/api/ps") {
return Promise.resolve({ ok: true, json: async () => [{ rigId: "r1", name: "alpha", nodeCount: 1, runningCount: 1, status: "running", uptime: "1h", latestSnapshot: null }] });
}
if (typeof url === "string" && url.includes("/snapshots") && opts?.method === "POST") {
return Promise.resolve({ ok: false, status: 500, json: async () => ({ error: "server error" }) });
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
render(createTestRouter({ component: Dashboard }));
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
const snapshotBtns = screen.getAllByText(/SNAPSHOT/);
const actionBtn = snapshotBtns.find((el) => el.closest("button"));
fireEvent.click(actionBtn!);
await waitFor(() => {
expect(screen.getByTestId("action-error")).toBeDefined();
});
});
// Test 12: useCountUp — animates on mount, snaps on update (no re-animation, not stale)
it("useCountUp animates on mount, snaps to new target on update", async () => {
const origRAF = globalThis.requestAnimationFrame;
const origCAF = globalThis.cancelAnimationFrame;
let rafId = 0;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => {
rafId++;
setTimeout(() => cb(performance.now() + 500), 0);
return rafId;
};
globalThis.cancelAnimationFrame = () => {};
function CountUpHarness({ target }: { target: number }) {
const val = useCountUp(target);
return <span data-testid="count">{val}</span>;
}
const qc = new QueryClient();
const { rerender } = render(
<QueryClientProvider client={qc}><CountUpHarness target={5} /></QueryClientProvider>
);
// Wait for first animation to settle at 5
await waitFor(() => {
expect(screen.getByTestId("count").textContent).toBe("5");
}, { timeout: 2000 });
// Rerender with a different target (simulating data refresh)
rerender(
<QueryClientProvider client={qc}><CountUpHarness target={10} /></QueryClientProvider>
);
// Value should snap to 10 immediately (no re-animation, but NOT stale)
await waitFor(() => {
expect(screen.getByTestId("count").textContent).toBe("10");
});
globalThis.requestAnimationFrame = origRAF;
globalThis.cancelAnimationFrame = origCAF;
});
// CCLI-T06 Tests
// T1: UP button navigates to /bootstrap (both header and empty-state)
it("empty-state UP button navigates to /bootstrap", async () => {
setupSummaryMock([]);
render(createAppTestRouter({
routes: [
{ path: "/", component: Dashboard },
{ path: "/bootstrap", component: () => <div data-testid="bootstrap-page">bootstrap</div> },
],
}));
await waitFor(() => expect(screen.getByTestId("dashboard-empty")).toBeDefined());
fireEvent.click(screen.getByTestId("empty-up-btn"));
await waitFor(() => {
expect(screen.getByTestId("bootstrap-page")).toBeDefined();
});
});
// T2: DOWN button opens confirmation dialog
it("DOWN button opens confirmation dialog", async () => {
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null }]);
render(createTestRouter({ component: Dashboard }));
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
const downBtn = screen.getAllByText("DOWN").find((el) => el.closest("button"))!;
fireEvent.click(downBtn);
await waitFor(() => {
expect(screen.getByText(/Tear Down Rig/)).toBeDefined();
expect(screen.getByTestId("confirm-down-btn")).toBeDefined();
});
});
// T3: Confirm teardown calls POST /api/down and closes dialog
it("confirm teardown calls POST /api/down and closes dialog", async () => {
setupSummaryMock([{ id: "r1", name: "alpha", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null }]);
render(createTestRouter({ component: Dashboard }));
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
const downBtn = screen.getAllByText("DOWN").find((el) => el.closest("button"))!;
fireEvent.click(downBtn);
await waitFor(() => expect(screen.getByTestId("confirm-down-btn")).toBeDefined());
fireEvent.click(screen.getByTestId("confirm-down-btn"));
await waitFor(() => {
const downCall = mockFetch.mock.calls.find(
(c: unknown[]) => c[0] === "/api/down" && (c[1] as RequestInit)?.method === "POST"
);
expect(downCall).toBeDefined();
});
// Dialog should close on success
await waitFor(() => {
expect(screen.queryByText(/Tear Down Rig/)).toBeNull();
});
});
// T4: Status badge shows running/stopped per rig (based on runningCount > 0)
it("status badge shows correct status per rig", async () => {
const rigs = [
{ id: "r1", name: "alpha", nodeCount: 3, latestSnapshotAt: null, latestSnapshotId: null },
{ id: "r2", name: "beta", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null },
{ id: "r3", name: "gamma", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null },
];
const ps = [
{ rigId: "r1", name: "alpha", nodeCount: 3, runningCount: 3, status: "running", uptime: "2h", latestSnapshot: null },
{ rigId: "r2", name: "beta", nodeCount: 2, runningCount: 1, status: "partial", uptime: "30m", latestSnapshot: null },
{ rigId: "r3", name: "gamma", nodeCount: 1, runningCount: 0, status: "stopped", uptime: null, latestSnapshot: null },
];
setupSummaryMock(rigs, ps);
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
// RigCard shows RUNNING when runningCount > 0, STOPPED when 0
const card1 = screen.getByTestId("rig-card-r1");
expect(card1.textContent).toContain("RUNNING");
const card2 = screen.getByTestId("rig-card-r2");
expect(card2.textContent).toContain("RUNNING");
const card3 = screen.getByTestId("rig-card-r3");
expect(card3.textContent).toContain("STOPPED");
});
});
// T5: Dashboard header shows aggregate counts
it("aggregate header shows rig count and running nodes", async () => {
const rigs = [
{ id: "r1", name: "alpha", nodeCount: 3, latestSnapshotAt: null, latestSnapshotId: null },
{ id: "r2", name: "beta", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null },
];
const ps = [
{ rigId: "r1", name: "alpha", nodeCount: 3, runningCount: 3, status: "running", uptime: "2h", latestSnapshot: null },
{ rigId: "r2", name: "beta", nodeCount: 2, runningCount: 0, status: "stopped", uptime: null, latestSnapshot: null },
];
setupSummaryMock(rigs, ps);
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
const header = screen.getByTestId("aggregate-header");
expect(header.textContent).toContain("2 rigs");
expect(header.textContent).toContain("3 nodes running");
});
});
// T6: Stopped rig card shows STOPPED status and no active indicator
it("stopped rig card shows STOPPED status", async () => {
const rigs = [{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }];
const ps = [{ rigId: "r1", name: "alpha", nodeCount: 1, runningCount: 0, status: "stopped", uptime: null, latestSnapshot: null }];
setupSummaryMock(rigs, ps);
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
const card = screen.getByTestId("rig-card-r1");
expect(card.textContent).toContain("STOPPED");
// No ACTIVE indicator for stopped rigs
expect(card.textContent).not.toContain("ACTIVE");
});
});
// T7: 200 + errors[] keeps dialog open, shows error text
it("teardown with 200 + errors keeps dialog open with error", async () => {
mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
if (url === "/api/rigs/summary") {
return Promise.resolve({ ok: true, json: async () => [{ id: "r1", name: "alpha", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null }] });
}
if (url === "/api/ps") {
return Promise.resolve({ ok: true, json: async () => [{ rigId: "r1", name: "alpha", nodeCount: 2, runningCount: 2, status: "running", uptime: "1h", latestSnapshot: null }] });
}
if (url === "/api/down" && opts?.method === "POST") {
return Promise.resolve({ ok: true, json: async () => ({ rigId: "r1", sessionsKilled: 1, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: ["Kill failed for session 'r01-x': timeout"] }) });
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
render(createTestRouter({ component: Dashboard }));
await waitFor(() => expect(screen.getByText("alpha")).toBeDefined());
const downBtn = screen.getAllByText("DOWN").find((el) => el.closest("button"))!;
fireEvent.click(downBtn);
await waitFor(() => expect(screen.getByTestId("confirm-down-btn")).toBeDefined());
fireEvent.click(screen.getByTestId("confirm-down-btn"));
// Dialog should stay open with error
await waitFor(() => {
expect(screen.getByTestId("teardown-error")).toBeDefined();
});
// Dialog still visible
expect(screen.getByText(/Tear Down Rig/)).toBeDefined();
});
// T8: /api/ps failure shows STOPPED (no ps = no running count) and omits running nodes from header
it("ps failure shows STOPPED, omits node count from header", async () => {
mockFetch.mockImplementation((url: string) => {
if (url === "/api/rigs/summary") {
return Promise.resolve({ ok: true, json: async () => [{ id: "r1", name: "alpha", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null }] });
}
if (url === "/api/ps") {
return Promise.resolve({ ok: false, status: 500 });
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
// Card should show STOPPED when ps data is unavailable (no runningCount)
const card = screen.getByTestId("rig-card-r1");
expect(card.textContent).toContain("STOPPED");
});
// Header should NOT show "nodes running" when ps data is unavailable
const header = screen.getByTestId("aggregate-header");
expect(header.textContent).not.toContain("nodes running");
});
});
-435
View File
@@ -1,435 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RigDetailPanel } from "../src/components/RigDetailPanel.js";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
function renderPanel(rigId: string) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<RigDetailPanel rigId={rigId} onClose={vi.fn()} />
</QueryClientProvider>
);
}
describe("RigDetailPanel", () => {
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockImplementation((url: string) => {
if (url === "/api/rigs/summary") {
return Promise.resolve({
ok: true,
json: async () => [
{ id: "rig-1", name: "my-rig", nodeCount: 3, latestSnapshotAt: "2026-04-01T10:00:00Z", latestSnapshotId: "snap-1" },
{ id: "rig-2", name: "empty-rig", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null },
],
});
}
if (url === "/api/ps") {
return Promise.resolve({
ok: true,
json: async () => [
{ rigId: "rig-1", name: "my-rig", nodeCount: 3, runningCount: 2, status: "running", uptime: "1h", latestSnapshot: null },
],
});
}
if (url.includes("/snapshots")) {
return Promise.resolve({
ok: true,
json: async () => [
{ id: "01HXYZ123456SNAP01", kind: "manual", createdAt: "2026-04-01T10:00:00Z" },
],
});
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
});
afterEach(() => {
cleanup();
});
it("renders rig name and condensed ID", async () => {
renderPanel("rig-1");
expect(await screen.findByText("my-rig")).toBeTruthy();
expect(screen.getByTestId("rig-id-value").textContent).toBe("rig-1");
expect(screen.queryByTestId("rig-full-id")).toBeNull();
});
it("shows node count and status", async () => {
renderPanel("rig-1");
expect(await screen.findByText("2/3 running")).toBeTruthy();
expect(screen.getByTestId("rig-uptime").textContent).toBe("1h");
});
it("exposes rig actions in the rig drawer", async () => {
renderPanel("rig-1");
await screen.findByText("my-rig");
expect(screen.getByTestId("rig-export-spec")).toBeDefined();
expect(screen.getByTestId("rig-create-snapshot")).toBeDefined();
expect(screen.getByTestId("rig-power-action")).toBeDefined();
});
it("renders the latest snapshot with a short ID", async () => {
renderPanel("rig-1");
await screen.findByText("my-rig");
expect(screen.getByTestId("snap-short-01HXYZ123456SNAP01")).toBeDefined();
expect(screen.queryByTestId("snap-full-01HXYZ123456SNAP01")).toBeNull();
});
it("Create Snapshot button is present", async () => {
renderPanel("rig-1");
await screen.findByText("my-rig");
expect(screen.getByTestId("rig-create-snapshot")).toBeDefined();
});
it("emphasizes the short rig ID tail in identity", async () => {
renderPanel("rig-1");
await screen.findByText("my-rig");
expect(screen.getByTestId("rig-id-tail").textContent).toBe("rig-1");
});
it("restore confirm flow triggers mutation", async () => {
// Add restore endpoint mock
mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
if (url === "/api/rigs/summary") return Promise.resolve({ ok: true, json: async () => [{ id: "rig-1", name: "my-rig", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null }] });
if (url === "/api/ps") return Promise.resolve({ ok: true, json: async () => [{ rigId: "rig-1", name: "my-rig", nodeCount: 2, runningCount: 1, status: "running", uptime: "1h", latestSnapshot: null }] });
if (url.includes("/restore/") && opts?.method === "POST") {
return Promise.resolve({ ok: true, json: async () => ({ nodes: [{ nodeId: "n1", logicalId: "dev.impl", status: "resumed" }, { nodeId: "n2", logicalId: "dev.qa", status: "fresh" }] }) });
}
if (url.includes("/snapshots")) return Promise.resolve({ ok: true, json: async () => [{ id: "01HXYZ123456SNAP01", kind: "manual", createdAt: "2026-04-01T10:00:00Z" }] });
return Promise.resolve({ ok: true, json: async () => ({}) });
});
renderPanel("rig-1");
await screen.findByText("my-rig");
const { fireEvent, waitFor } = await import("@testing-library/react");
// Click restore, then confirm
fireEvent.click(screen.getByTestId("restore-btn-01HXYZ123456SNAP01"));
await screen.findByText("Confirm Restore");
fireEvent.click(screen.getByTestId("confirm-restore-01HXYZ123456SNAP01"));
// Wait for restore result with per-node status and color classes
await waitFor(() => {
expect(screen.getByTestId("restore-result")).toBeDefined();
const resumed = screen.getByTestId("restore-status-dev.impl");
expect(resumed.textContent).toBe("resumed");
expect(resumed.className).toContain("text-success");
const fresh = screen.getByTestId("restore-status-dev.qa");
expect(fresh.textContent).toBe("fresh");
expect(fresh.className).toContain("text-foreground-muted");
});
});
it("restore button opens confirm dialog", async () => {
renderPanel("rig-1");
await screen.findByText("my-rig");
// Click restore on the snapshot
const restoreBtn = screen.getByTestId("restore-btn-01HXYZ123456SNAP01");
const { fireEvent } = await import("@testing-library/react");
fireEvent.click(restoreBtn);
// Confirm dialog should appear
expect(await screen.findByText("Confirm Restore")).toBeDefined();
});
it("shows actionable guidance when restore requires the rig to be stopped", async () => {
mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
if (url === "/api/rigs/summary") {
return Promise.resolve({
ok: true,
json: async () => [{ id: "rig-1", name: "my-rig", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null }],
});
}
if (url === "/api/ps") {
return Promise.resolve({
ok: true,
json: async () => [{ rigId: "rig-1", name: "my-rig", nodeCount: 2, runningCount: 2, status: "running", uptime: "1h", latestSnapshot: null }],
});
}
if (url.includes("/restore/") && opts?.method === "POST") {
return Promise.resolve({
ok: false,
status: 409,
json: async () => ({ error: "Rig rig-1 must be stopped before restore", code: "rig_not_stopped" }),
});
}
if (url.includes("/snapshots")) {
return Promise.resolve({
ok: true,
json: async () => [{ id: "01HXYZ123456SNAP01", kind: "manual", createdAt: "2026-04-01T10:00:00Z" }],
});
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
renderPanel("rig-1");
await screen.findByText("my-rig");
const { fireEvent } = await import("@testing-library/react");
fireEvent.click(screen.getByTestId("restore-btn-01HXYZ123456SNAP01"));
await screen.findByText("Confirm Restore");
fireEvent.click(screen.getByTestId("confirm-restore-01HXYZ123456SNAP01"));
expect((await screen.findByTestId("snapshot-error")).textContent).toContain("rig down my-rig");
});
it("drawer shows Info | Chat Room tabs", async () => {
renderPanel("rig-1");
await screen.findByText("my-rig");
expect(screen.getByTestId("drawer-tabs")).toBeDefined();
expect(screen.getByTestId("tab-info")).toBeDefined();
expect(screen.getByTestId("tab-chat")).toBeDefined();
expect(screen.getByTestId("tab-info").textContent).toContain("Info");
expect(screen.getByTestId("tab-chat").textContent).toContain("Chat Room");
});
it("shows snapshot age when available, not 'No snapshots'", async () => {
renderPanel("rig-1");
// Wait for data to load
await screen.findByText("my-rig");
const panel = screen.getByTestId("rig-detail-panel");
// Should show an age indicator (contains "ago" or "< 1m"), not "No snapshots"
const text = panel.textContent ?? "";
expect(text).not.toContain("No snapshots");
expect(text).toMatch(/ago|< 1m/);
});
it("shows a pod summary with human-friendly pod names", async () => {
mockFetch.mockImplementation((url: string) => {
if (url === "/api/rigs/summary") {
return Promise.resolve({
ok: true,
json: async () => [
{ id: "rig-1", name: "my-rig", nodeCount: 3, latestSnapshotAt: "2026-04-01T10:00:00Z", latestSnapshotId: "snap-1" },
],
});
}
if (url === "/api/ps") {
return Promise.resolve({
ok: true,
json: async () => [
{ rigId: "rig-1", name: "my-rig", nodeCount: 3, runningCount: 2, status: "running", uptime: "1h", latestSnapshot: null },
],
});
}
if (url === "/api/rigs/rig-1/nodes") {
return Promise.resolve({
ok: true,
json: async () => [
{ rigId: "rig-1", rigName: "my-rig", logicalId: "orch.lead", podId: "orch", nodeKind: "agent", runtime: "claude-code", startupStatus: "ready", canonicalSessionName: "orch-lead@my-rig" },
{ rigId: "rig-1", rigName: "my-rig", logicalId: "dev.impl", podId: "dev", nodeKind: "agent", runtime: "claude-code", startupStatus: "ready", canonicalSessionName: "dev-impl@my-rig" },
{ rigId: "rig-1", rigName: "my-rig", logicalId: "dev.qa", podId: "dev", nodeKind: "agent", runtime: "codex", startupStatus: "pending", canonicalSessionName: "dev-qa@my-rig" },
],
});
}
if (url.includes("/snapshots")) {
return Promise.resolve({
ok: true,
json: async () => [{ id: "01HXYZ123456SNAP01", kind: "manual", createdAt: "2026-04-01T10:00:00Z" }],
});
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
renderPanel("rig-1");
await screen.findByText("my-rig");
expect(await screen.findByText("Pods")).toBeTruthy();
expect(screen.getByText("orch")).toBeTruthy();
expect(screen.getByText("dev")).toBeTruthy();
expect(screen.getByText("impl")).toBeTruthy();
expect(screen.getByText("qa")).toBeTruthy();
});
it("shows Env tab for service-backed rigs with service health and surfaces", async () => {
mockFetch.mockImplementation((url: string) => {
if (url === "/api/rigs/summary") {
return Promise.resolve({ ok: true, json: async () => [{ id: "rig-1", name: "my-rig", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }] });
}
if (url === "/api/ps") {
return Promise.resolve({ ok: true, json: async () => [{ rigId: "rig-1", name: "my-rig", nodeCount: 1, runningCount: 1, status: "running", uptime: "5m" }] });
}
if (url === "/api/rigs/rig-1/nodes") {
return Promise.resolve({ ok: true, json: async () => [] });
}
if (url.includes("/snapshots")) {
return Promise.resolve({ ok: true, json: async () => [] });
}
if (url === "/api/rigs/rig-1/env") {
return Promise.resolve({
ok: true,
json: async () => ({
ok: true,
hasServices: true,
kind: "compose",
composeFile: "/tmp/svc.compose.yaml",
projectName: "test-svc",
receipt: {
kind: "compose",
composeFile: "/tmp/svc.compose.yaml",
projectName: "test-svc",
services: [{ name: "vault", status: "running", health: "healthy" }],
waitFor: [{ target: { url: "http://127.0.0.1:8200/health" }, status: "healthy" }],
capturedAt: "2026-04-09T12:00:00Z",
},
surfaces: {
urls: [{ name: "Vault UI", url: "http://127.0.0.1:8200/ui" }],
},
}),
});
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
renderPanel("rig-1");
await screen.findByText("my-rig");
// Env tab should exist between Info and Chat
expect(screen.getByTestId("tab-env")).toBeDefined();
expect(screen.getByTestId("tab-info")).toBeDefined();
expect(screen.getByTestId("tab-chat")).toBeDefined();
// Click Env tab
fireEvent.click(screen.getByTestId("tab-env"));
await waitFor(() => {
// Overall env state
const envState = screen.getByTestId("env-state");
expect(envState).toBeDefined();
expect(envState.textContent).toContain("Healthy");
// Service health
expect(screen.getByText("vault")).toBeDefined();
// Surface URL
expect(screen.getByText("Vault UI")).toBeDefined();
});
});
it("does not show Env tab for non-service rigs", async () => {
mockFetch.mockImplementation((url: string) => {
if (url === "/api/rigs/summary") {
return Promise.resolve({ ok: true, json: async () => [{ id: "rig-2", name: "plain-rig", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null }] });
}
if (url === "/api/ps") {
return Promise.resolve({ ok: true, json: async () => [{ rigId: "rig-2", name: "plain-rig", nodeCount: 1, runningCount: 1, status: "running", uptime: "5m" }] });
}
if (url === "/api/rigs/rig-2/env") {
return Promise.resolve({ ok: true, json: async () => ({ ok: true, hasServices: false }) });
}
if (url.includes("/snapshots")) {
return Promise.resolve({ ok: true, json: async () => [] });
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
renderPanel("rig-2");
await screen.findByText("plain-rig");
expect(screen.getByTestId("tab-info")).toBeDefined();
expect(screen.getByTestId("tab-chat")).toBeDefined();
expect(screen.queryByTestId("tab-env")).toBeNull();
});
it("shows stopped env state and failed health gate details when services disappear", async () => {
mockFetch.mockImplementation((url: string) => {
if (url === "/api/rigs/summary") {
return Promise.resolve({
ok: true,
json: async () => [{ id: "rig-1", name: "my-rig", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null, hasServices: true }],
});
}
if (url === "/api/ps") {
return Promise.resolve({
ok: true,
json: async () => [{ rigId: "rig-1", name: "my-rig", nodeCount: 1, runningCount: 1, status: "running", uptime: "5m" }],
});
}
if (url === "/api/rigs/rig-1/nodes") {
return Promise.resolve({ ok: true, json: async () => [] });
}
if (url.includes("/snapshots")) {
return Promise.resolve({ ok: true, json: async () => [] });
}
if (url === "/api/rigs/rig-1/env") {
return Promise.resolve({
ok: true,
json: async () => ({
ok: true,
hasServices: true,
kind: "compose",
composeFile: "/tmp/svc.compose.yaml",
projectName: "test-svc",
receipt: {
kind: "compose",
composeFile: "/tmp/svc.compose.yaml",
projectName: "test-svc",
services: [],
waitFor: [{
target: { url: "http://127.0.0.1:8200/v1/sys/health" },
status: "unhealthy",
detail: "HTTP probe failed: http://127.0.0.1:8200/v1/sys/health",
}],
capturedAt: "2026-04-09T12:00:00Z",
},
surfaces: {
urls: [{ name: "Vault UI", url: "http://127.0.0.1:8200/ui" }],
},
}),
});
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
renderPanel("rig-1");
await screen.findByText("my-rig");
fireEvent.click(screen.getByTestId("tab-env"));
await waitFor(() => {
expect(screen.getByTestId("env-state").textContent).toContain("Stopped");
expect(screen.getByText("Health Gates")).toBeDefined();
expect(screen.getByText("http://127.0.0.1:8200/v1/sys/health")).toBeDefined();
expect(screen.getByText(/HTTP probe failed/)).toBeDefined();
});
});
it("keeps Env tab visible for service-backed rigs while env status is still loading", async () => {
mockFetch.mockImplementation((url: string) => {
if (url === "/api/rigs/summary") {
return Promise.resolve({
ok: true,
json: async () => [{ id: "rig-1", name: "my-rig", nodeCount: 1, latestSnapshotAt: null, latestSnapshotId: null, hasServices: true }],
});
}
if (url === "/api/ps") {
return Promise.resolve({
ok: true,
json: async () => [{ rigId: "rig-1", name: "my-rig", nodeCount: 1, runningCount: 1, status: "running", uptime: "5m" }],
});
}
if (url === "/api/rigs/rig-1/nodes") {
return Promise.resolve({ ok: true, json: async () => [] });
}
if (url.includes("/snapshots")) {
return Promise.resolve({ ok: true, json: async () => [] });
}
if (url === "/api/rigs/rig-1/env") {
return new Promise(() => {}) as Promise<Response>;
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
renderPanel("rig-1");
await screen.findByText("my-rig");
expect(screen.getByTestId("tab-info")).toBeDefined();
expect(screen.getByTestId("tab-chat")).toBeDefined();
expect(screen.getByTestId("tab-env")).toBeDefined();
});
});
+7 -2
View File
@@ -219,7 +219,11 @@ describe("RigGraph", () => {
});
});
it("clicking a pod group routes selection back to the rig drawer", async () => {
// V1 attempt-3 Phase 4 P4-5 — RigDetailPanel + 'rig' kind retired
// from DrawerSelection. Pod-group click is now a no-op at the graph
// level (pods open via Explorer tree's /topology/pod/$rigId/$podName
// link). Test renamed + assertion flipped to verify NO setSelection.
it("clicking a pod group is a no-op at the graph level (RigDetailPanel retired)", async () => {
mockFetch.mockResolvedValueOnce(mockGraphResponse([
{
id: "pod-alpha",
@@ -280,7 +284,8 @@ describe("RigGraph", () => {
fireEvent.click(container.querySelector(".react-flow__node-podGroup")!);
expect(setSelection).toHaveBeenCalledWith({ type: "rig", rigId: "rig-1" });
// 'rig' kind retired in Phase 4 — pod-group click does NOT setSelection.
expect(setSelection).not.toHaveBeenCalled();
});
it("clicking an unbound node in discovery placement mode selects it as a bind target", async () => {
-87
View File
@@ -1,87 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import { createMockEventSourceClass } from "./helpers/mock-event-source.js";
import { createTestRouter } from "./helpers/test-router.js";
import { Dashboard } from "../src/components/Dashboard.js";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
let OriginalEventSource: typeof EventSource | undefined;
beforeEach(() => {
mockFetch.mockReset();
OriginalEventSource = globalThis.EventSource;
globalThis.EventSource = createMockEventSourceClass() as unknown as typeof EventSource;
});
afterEach(() => {
if (OriginalEventSource) {
globalThis.EventSource = OriginalEventSource;
}
cleanup();
});
function mockSummaryResponse(rigs: Array<{
id: string; name: string; nodeCount: number;
latestSnapshotAt: string | null; latestSnapshotId: string | null;
}>) {
return { ok: true, json: async () => rigs };
}
describe("App (scaffold)", () => {
it("shows loading state while fetching summary", async () => {
mockFetch.mockReturnValue(new Promise(() => {}));
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
expect(screen.getByTestId("dashboard-loading")).toBeDefined();
});
});
it("shows 'No rigs' when summary returns empty", async () => {
mockFetch.mockResolvedValue(mockSummaryResponse([]));
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
expect(screen.getByText(/no rigs/i)).toBeDefined();
});
});
it("renders dashboard with rig cards when summary has data", async () => {
mockFetch.mockImplementation((url: string) => {
if (url === "/api/rigs/summary") {
return Promise.resolve(mockSummaryResponse([
{ id: "r1", name: "r01", nodeCount: 2, latestSnapshotAt: null, latestSnapshotId: null },
]));
}
if (url === "/api/ps") {
return Promise.resolve({ ok: true, json: async () => [{ rigId: "r1", name: "r01", nodeCount: 2, runningCount: 2, status: "running", uptime: "1h", latestSnapshot: null }] });
}
return Promise.resolve({ ok: true, json: async () => ({}) });
});
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
expect(screen.getByText("r01")).toBeDefined();
});
});
it("shows error state on fetch failure", async () => {
mockFetch.mockResolvedValue({ ok: false, status: 500 });
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
expect(screen.getByTestId("dashboard-error")).toBeDefined();
});
});
it("dashboard fetches /api/rigs/summary", async () => {
mockFetch.mockResolvedValue(mockSummaryResponse([]));
render(createTestRouter({ component: Dashboard }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]![0]).toBe("/api/rigs/summary");
});
});
});