mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
shell-redesign V1 polish slice Phase 5.1 P5.1-1+1a+2: agent detail dedup + layout + graph nav
V1 founder-walk follow-up. Closes the founder-noticed concerns around the agent-detail surface in one bundled refactor: - Two competing agent-detail UI elements (legacy right-sidebar render via NodeDetailPanel-in-drawer + center-page LiveNodeDetails) with duplicated content - Redundant top+bottom tab rows (outer detail/transcript/terminal + inner identity/agent-spec/startup/transcript) producing the "Transcript twice" perception - Orphan "Open Full Details" button in the legacy panel - Graph node click opening the legacy drawer panel instead of the canonical center detail page - Need to preserve all unique content while deduping REBUILT packages/ui/src/components/LiveNodeDetails.tsx (canonical center agent-detail surface): - Persistent header at TOP (action-buttons-first layout): WorkflowHeader → ActionButtonsRow (Open CMUX + Copy tmux attach + Copy resume command) → StatusSection (startup status pip + restore outcome + failure banner with recovery guidance — merged from retired NodeDetailPanel) → Preview pane (merged) → LiveNodeCurrentState (Activity + Current Work cards) → RecentEventsSection (merged; no content lost) → Infrastructure startup command card (merged; conditional on nodeKind=infrastructure). - Single canonical 5-tab body row at BOTTOM: Identity / Agent Spec / Startup / Transcript / Terminal. Eliminates the prior outer+inner tab nesting that produced the duplicate Transcript visibility. - Startup tab: each file wrapped in FileReferenceTrigger per content-drawer.md L23-L34 (click opens FileViewer in drawer). - Terminal tab: SessionPreviewPane mounts the live transcript-tail. REFACTORED packages/ui/src/components/topology/ScopePages.tsx: - SeatScopePage drops outer ScopeShell tabs (DRIFT P5.1-D1); LiveNodeDetails mounts directly as the page body. Other scope pages (Host/Rig/Pod) unchanged at this commit. REFACTORED packages/ui/src/components/SharedDetailDrawer.tsx: - 'seat-detail' kind RETIRED from DrawerSelection union (DRIFT P5.1-D2). - Routing branch removed; NodeDetailPanel + SeatDetailViewer imports retired. Drawer remains a content-viewer surface for the 3 auto-open triggers (qitem / file / sub-spec). REFACTORED packages/ui/src/components/AppShell.tsx: - useNodeSelection function FULLY RETIRED (only callsite was RigGraph; no other consumers). NodeSelectionContext alias preserved as a no-op export so any test file still importing the symbol resolves. REFACTORED packages/ui/src/components/RigGraph.tsx (P5.1-2): - Node click handler: useNavigate to /topology/seat/$rigId/$logicalId center page. Replaces legacy setSelectedNode → setSelection seat-detail drawer-open. Parity with Explorer tree click + (P5.1-7) topology table row click contract. REFACTORED packages/ui/src/components/topology/TopologyTreeView.tsx: - SeatLeaf details icon RETIRED (DRIFT P5.1-D2; SeatDetailTrigger primitive deleted). Seat row is Link-only navigation now. - P5.1-2 auto-expand: useActiveTopologyContext hook parses pathname for active rigId / podName / logicalId; threaded through RigBranch + PodBranch via props; matching branches force-expand on URL match. CLEANUP packages/ui/src/components/Explorer.tsx: - Excised vestigial dead-code block (lines 187-487 = ~300 lines): PodBranch, RigBranch, EnvironmentBranch, FullExplorerContents. These were defined-but-not-rendered (topology surface uses TopologyTreeView; FullExplorerContents had no consumer). Also cleared the legacy 'seat-detail' setSelection callsites in those helpers. DELETED: - packages/ui/src/components/NodeDetailPanel.tsx (261 lines; sole caller was SeatDetailViewer) - packages/ui/src/components/drawer-viewers/SeatDetailViewer.tsx (27 lines; wrapper retired) - packages/ui/src/components/drawer-triggers/SeatDetailTrigger.tsx (33 lines; primitive retired) - packages/ui/test/node-detail-panel.test.tsx (component retired) CANON UPDATES (orch-approved at GO ask #1+#2; same-slice update): - topology-tree.md L48-L53: seat-scope tabs amended to single 5-tab body row inside LiveNodeDetails (DRIFT P5.1-D1). Other scope tabs unchanged. - content-drawer.md L37-L42: "details icon on any topology node" manual-open mandate RETIRED (DRIFT P5.1-D2). Other manual-open triggers (view source / show context) preserved as canon. TEST UPDATES (negative-assertion / ritual #8 idiom per orch direction): - packages/ui/test/node-selection-migration.test.tsx: REWRITTEN as P5.1-D2 retirement-regression guard (NodeDetailPanel.tsx / SeatDetailViewer.tsx / SeatDetailTrigger.tsx file-doesn't-exist; 'seat-detail' + 'node' kind absent from DrawerSelection union; AppShell does NOT export useNodeSelection; RigGraph uses useNavigate not setSelectedNode; LiveNodeDetails has 5-tab Tab union; SeatScopePage drops outer tabs). - packages/ui/test/drawer-primitives.test.tsx: SeatDetailViewer / SeatDetailTrigger contract tests REMOVED; routing-by-type 'seat-detail' test REMOVED. Other 3 viewer + 3 trigger contract tests preserved. - packages/ui/test/p5-trigger-sites.test.tsx: TopologyTreeView SeatDetailTrigger details-icon contract test REMOVED (primitive retired). - packages/ui/test/rig-graph.test.tsx: 'click node → setSelection seat-detail' rewritten as 'click node → useNavigate(/topology/ seat/$rigId/$logicalId)'; vi.mock useNavigate at file scope. - packages/ui/test/p5-ship-gate-bounce.test.tsx: P0-2 NodeDetailPanel source-assertion converted into LiveNodeDetails-source-doesn't- regress companion guard (file-doesn't-exist guarded in node-selection-migration). - packages/ui/test/design-compliance.test.tsx: NodeDetailPanel.tsx font-mono assertion redirected to LiveNodeDetails.tsx (canonical surface). Verification: - pnpm -F @openrig/ui exec tsc --noEmit: clean - vitest full UI suite: 660/662 PASS (1 pre-existing library-review Send-to-seat baseline failure UNCHANGED; 1 skipped) - pnpm -F @openrig/ui build: clean (1.92s) SC-29 posture: UNCHANGED — UI-only this slice; daemon byte-equivalent beyond Phase 4 + Phase 5 declared exceptions. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
615baadfac
commit
f3780c0656
17 files changed
+642
-1259
No files matched your search
@@ -93,25 +93,17 @@ export function useDiscoveryPlacement() {
|
||||
return useContext(DiscoveryPlacementContext);
|
||||
}
|
||||
|
||||
// Backward-compat alias for consumers (RigGraph.tsx) that still use the old name.
|
||||
// V1 attempt-3 Phase 5 P5-4: coerces to/from 'seat-detail' kind instead of legacy
|
||||
// 'node' kind. SeatDetailViewer (Phase 4) wraps NodeDetailPanel in canonical
|
||||
// 38rem drawer width — closes founder concern about "graph node click → full-width
|
||||
// old panel with empty whitespace" by routing through canonical drawer-viewer pattern.
|
||||
// V1 polish slice Phase 5.1 P5.1-1 + DRIFT P5.1-D2: useNodeSelection
|
||||
// alias FULLY RETIRED. After 'seat-detail' kind retirement, the only
|
||||
// callsite (RigGraph node click) now uses useNavigate to route to the
|
||||
// /topology/seat/$rigId/$logicalId center page directly. Verified via
|
||||
// grep — no remaining production consumer of useNodeSelection or
|
||||
// NodeSelectionContext outside the legacy AppShell export.
|
||||
//
|
||||
// NodeSelectionContext alias retained as a no-op export for any test
|
||||
// file still importing the symbol (negative-assertion guard); functions
|
||||
// retired entirely.
|
||||
export const NodeSelectionContext = DrawerSelectionContext;
|
||||
export function useNodeSelection() {
|
||||
const { selection, setSelection } = useDrawerSelection();
|
||||
return {
|
||||
selectedNode:
|
||||
selection?.type === "seat-detail"
|
||||
? { rigId: selection.rigId, logicalId: selection.logicalId }
|
||||
: null,
|
||||
setSelectedNode: (node: { rigId: string; logicalId: string } | null) =>
|
||||
setSelection(
|
||||
node ? { type: "seat-detail", rigId: node.rigId, logicalId: node.logicalId } : null,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Rail icon roster — universal-shell.md L37–L58 + agent-chat-surface.md V1 placeholder
|
||||
|
||||
@@ -184,307 +184,6 @@ function ExplorerKindIcon({
|
||||
}
|
||||
}
|
||||
|
||||
function PodBranch({
|
||||
podId,
|
||||
nodes,
|
||||
selection,
|
||||
onSelect,
|
||||
autoExpand,
|
||||
}: {
|
||||
podId: string;
|
||||
nodes: NodeInventoryEntry[];
|
||||
selection: DrawerSelection;
|
||||
onSelect: (sel: DrawerSelection) => void;
|
||||
autoExpand: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(autoExpand);
|
||||
const podName = inferPodName(nodes[0]?.logicalId) ?? displayPodName(podId);
|
||||
const podStatus = aggregateStatus(nodes);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoExpand) setExpanded(true);
|
||||
}, [autoExpand]);
|
||||
|
||||
const sortedNodes = [...nodes].sort((a, b) => displayAgentName(a.logicalId).localeCompare(displayAgentName(b.logicalId)));
|
||||
|
||||
return (
|
||||
<div data-testid={`pod-branch-${podName}`}>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-2 rounded-sm px-4 py-1.5 hover:bg-stone-100"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
<TreeToggle expanded={expanded} label={`pod ${podName}`} onClick={() => setExpanded((value) => !value)} />
|
||||
<ExplorerKindIcon kind="pod" statusClass={statusColor(podStatus)} testId={`pod-icon-${podName}`} />
|
||||
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-600">
|
||||
{podName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="ml-4 border-l border-stone-200">
|
||||
{sortedNodes.map((node) => {
|
||||
const isSelected = selection?.type === "seat-detail" && selection.rigId === node.rigId && selection.logicalId === node.logicalId;
|
||||
const memberName = displayAgentName(node.logicalId);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={node.logicalId}
|
||||
type="button"
|
||||
onClick={() => onSelect({ type: "seat-detail", rigId: node.rigId, logicalId: node.logicalId })}
|
||||
data-testid={`node-${node.logicalId}`}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-sm px-4 py-1.5 text-left transition-colors hover:bg-stone-100",
|
||||
isSelected && "bg-stone-200/80"
|
||||
)}
|
||||
>
|
||||
<ExplorerKindIcon
|
||||
kind={node.nodeKind === "infrastructure" ? "infrastructure" : "agent"}
|
||||
statusClass={statusColor(node.startupStatus)}
|
||||
testId={`node-icon-${node.logicalId}`}
|
||||
/>
|
||||
<NodeActivityIndicator node={node} />
|
||||
<span className="font-mono text-[10px] text-stone-700 truncate">{memberName}</span>
|
||||
{node.contextUsage?.availability === "known" && typeof node.contextUsage.usedPercentage === "number" ? (
|
||||
<span
|
||||
className={`ml-auto shrink-0 font-mono text-[10px] font-bold ${
|
||||
node.contextUsage.usedPercentage >= 80 ? "text-red-600" :
|
||||
node.contextUsage.usedPercentage >= 60 ? "text-amber-600" :
|
||||
"text-green-700"
|
||||
}${!node.contextUsage.fresh ? " opacity-50" : ""}`}
|
||||
data-testid={`context-indicator-${node.logicalId}`}
|
||||
title={node.contextUsage.fresh ? `${node.contextUsage.usedPercentage}% context (fresh)` : `${node.contextUsage.usedPercentage}% context (stale)`}
|
||||
>
|
||||
{node.contextUsage.usedPercentage}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-auto shrink-0 font-mono text-[8px] uppercase text-stone-400">
|
||||
{node.nodeKind === "infrastructure" ? "INFRA" : (node.runtime ?? "").replace("claude-code", "claude")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RigBranch({
|
||||
rig,
|
||||
ps,
|
||||
selection,
|
||||
onSelect,
|
||||
onClose,
|
||||
autoExpand,
|
||||
}: {
|
||||
rig: RigSummary;
|
||||
ps: PsEntry | undefined;
|
||||
selection: DrawerSelection;
|
||||
onSelect: (sel: DrawerSelection) => void;
|
||||
onClose: () => void;
|
||||
autoExpand: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(autoExpand);
|
||||
const rigStatus = ps?.status ?? "stopped";
|
||||
const { data: nodes, isLoading } = useNodeInventory(expanded ? rig.id : null);
|
||||
// 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);
|
||||
}, [autoExpand]);
|
||||
|
||||
const podEntries = useMemo(() => {
|
||||
if (!nodes || nodes.length === 0) return [];
|
||||
const pods = new Map<string, NodeInventoryEntry[]>();
|
||||
for (const node of nodes) {
|
||||
const key = node.podId ?? "__ungrouped__";
|
||||
if (!pods.has(key)) pods.set(key, []);
|
||||
pods.get(key)!.push(node);
|
||||
}
|
||||
|
||||
return [...pods.entries()].sort(([leftId, leftNodes], [rightId, rightNodes]) => {
|
||||
const leftName = leftId === "__ungrouped__" ? "ungrouped" : (inferPodName(leftNodes[0]?.logicalId) ?? displayPodName(leftId));
|
||||
const rightName = rightId === "__ungrouped__" ? "ungrouped" : (inferPodName(rightNodes[0]?.logicalId) ?? displayPodName(rightId));
|
||||
return leftName.localeCompare(rightName);
|
||||
});
|
||||
}, [nodes]);
|
||||
|
||||
return (
|
||||
<div data-testid={`rig-tree-${rig.name}`}>
|
||||
<div
|
||||
className={cn("flex cursor-pointer items-center gap-2 rounded-sm px-4 py-1.5 hover:bg-stone-100", isSelected && "bg-stone-200/70")}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
<TreeToggle expanded={expanded} label={`rig ${rig.name}`} onClick={() => setExpanded((value) => !value)} />
|
||||
<ExplorerKindIcon kind="rig" statusClass={rigStatusColor(rigStatus)} testId={`rig-icon-${rig.name}`} />
|
||||
<Link
|
||||
to="/rigs/$rigId"
|
||||
params={{ rigId: rig.id }}
|
||||
onClick={() => {
|
||||
// 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(
|
||||
"flex-1 truncate font-mono text-[11px] font-semibold text-stone-900",
|
||||
isSelected && "underline underline-offset-2"
|
||||
)}
|
||||
>
|
||||
{rig.name}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="ml-4 border-l border-stone-200">
|
||||
{isLoading && (
|
||||
<div className="px-4 py-1.5 font-mono text-[9px] text-stone-400">Loading...</div>
|
||||
)}
|
||||
{!isLoading && podEntries.length === 0 && (
|
||||
<div className="px-4 py-1.5 font-mono text-[9px] text-stone-400">No nodes</div>
|
||||
)}
|
||||
{podEntries.map(([podId, podNodes]) => (
|
||||
podId === "__ungrouped__" ? (
|
||||
<div key={podId}>
|
||||
{podNodes.map((node) => {
|
||||
const isNodeSelected = selection?.type === "seat-detail" && selection.rigId === node.rigId && selection.logicalId === node.logicalId;
|
||||
const memberName = displayAgentName(node.logicalId);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={node.logicalId}
|
||||
type="button"
|
||||
onClick={() => onSelect({ type: "seat-detail", rigId: node.rigId, logicalId: node.logicalId })}
|
||||
data-testid={`node-${node.logicalId}`}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-sm px-4 py-1.5 text-left transition-colors hover:bg-stone-100",
|
||||
isNodeSelected && "bg-stone-200/80"
|
||||
)}
|
||||
>
|
||||
<ExplorerKindIcon
|
||||
kind={node.nodeKind === "infrastructure" ? "infrastructure" : "agent"}
|
||||
statusClass={statusColor(node.startupStatus)}
|
||||
testId={`node-icon-${node.logicalId}`}
|
||||
/>
|
||||
<NodeActivityIndicator node={node} />
|
||||
<span className="font-mono text-[10px] text-stone-700 truncate">{memberName}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<PodBranch
|
||||
key={podId}
|
||||
podId={podId}
|
||||
nodes={podNodes}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
autoExpand={
|
||||
expanded || (
|
||||
selection?.type === "seat-detail" &&
|
||||
selection.rigId === rig.id &&
|
||||
podNodes.some((node) => node.logicalId === selection.logicalId)
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvironmentBranch({
|
||||
rigs,
|
||||
psMap,
|
||||
selection,
|
||||
onSelect,
|
||||
onClose,
|
||||
currentRigId,
|
||||
}: {
|
||||
rigs: RigSummary[] | undefined;
|
||||
psMap: Map<string, PsEntry>;
|
||||
selection: DrawerSelection;
|
||||
onSelect: (sel: DrawerSelection) => void;
|
||||
onClose: () => void;
|
||||
currentRigId: string | null;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
return (
|
||||
<div data-testid="environment-branch-local">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 hover:bg-stone-100"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
<TreeToggle
|
||||
expanded={expanded}
|
||||
label="environment Local"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
/>
|
||||
<ExplorerKindIcon kind="environment" statusClass="text-stone-500" testId="environment-icon-local" />
|
||||
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-stone-700">
|
||||
env: local
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="ml-4 border-l border-stone-200">
|
||||
{!rigs || rigs.length === 0 ? (
|
||||
<div className="px-4 py-3 font-mono text-[10px] text-stone-400">No rigs</div>
|
||||
) : (
|
||||
rigs.map((rig, index) => (
|
||||
<RigBranch
|
||||
key={rig.id}
|
||||
rig={rig}
|
||||
ps={psMap.get(rig.id)}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
autoExpand={rig.id === currentRigId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FullExplorerContents({
|
||||
rigs,
|
||||
psMap,
|
||||
selection,
|
||||
onSelect,
|
||||
onClose,
|
||||
currentRigId,
|
||||
}: {
|
||||
rigs: RigSummary[] | undefined;
|
||||
psMap: Map<string, PsEntry>;
|
||||
selection: DrawerSelection;
|
||||
onSelect: (sel: DrawerSelection) => void;
|
||||
onClose: () => void;
|
||||
currentRigId: string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<EnvironmentBranch
|
||||
rigs={rigs}
|
||||
psMap={psMap}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
currentRigId={currentRigId}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Surface-routed body. Phase 2 lays placeholders for non-topology
|
||||
// surfaces; Phase 3 fills tree contents + lens chips. "none" surface
|
||||
|
||||
@@ -1,3 +1,38 @@
|
||||
// V1 polish slice Phase 5.1 P5.1-1 + P5.1-1a — agent detail dedup + layout.
|
||||
//
|
||||
// Founder direction at V1 polish (verbatim):
|
||||
// "We have two competing UI elements for agent detail now... we need to
|
||||
// take everything that was in the right sidebar and move it into the
|
||||
// agent detail page... action buttons any kind of action you would put
|
||||
// like near the top so launching cmux copying tmux resuming all those...
|
||||
// then more informational stuff would load at the bottom"
|
||||
// "Startup files all of those files should really be clickable so that
|
||||
// when you click them it opens up the right hand content drawer"
|
||||
// "There's redundancy on the agent detail page. So there's tabs across
|
||||
// the top that say detail, transcript and terminal. And then below that
|
||||
// is another set of tabs called identity agent spec, start up and
|
||||
// transcript. So you have transcript kind of like twice... we should
|
||||
// pick where we want the tabs and put them there... probably makes
|
||||
// sense to put them down below"
|
||||
// "I don't want to lose any content"
|
||||
//
|
||||
// Resolution (per Phase 5.1 ACK §2 audit + §6 drift disclosures):
|
||||
// - Persistent header at TOP: WorkflowHeader + Action buttons +
|
||||
// Status/failure card (merged from retired NodeDetailPanel) + Preview
|
||||
// pane (merged) + LiveNodeCurrentState (Activity + Current Work) +
|
||||
// Recent Events (merged; founder ask "don't lose content") +
|
||||
// Infrastructure startup command card (merged; conditional).
|
||||
// - Single 5-tab BODY row: Identity / Agent Spec / Startup / Transcript
|
||||
// / Terminal. Replaces the prior outer SeatScopePage tabs +
|
||||
// LiveNodeDetails inner tabs nesting (= "transcript twice" perception).
|
||||
// - Startup tab: each file wrapped in FileReferenceTrigger — click
|
||||
// opens FileViewer in drawer per content-drawer.md L23-L34.
|
||||
// - Terminal tab: SessionPreviewPane mounts the live transcript-tail.
|
||||
// - DRIFT P5.1-D1: SC-11 seat-scope outer tabs (detail/transcript/
|
||||
// terminal) RETIRED at V1 polish per founder direction. SeatScopePage
|
||||
// drops outer tabs entirely; LiveNodeDetails owns the canonical
|
||||
// 5-tab surface inline.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useNodeDetail, type NodeDetailData } from "../hooks/useNodeDetail.js";
|
||||
import { useSpecLibrary, useLibraryReview } from "../hooks/useSpecLibrary.js";
|
||||
@@ -5,17 +40,39 @@ import { WorkspacePage } from "./WorkspacePage.js";
|
||||
import { WorkflowHeader } from "./WorkflowScaffold.js";
|
||||
import { LiveIdentityDisplay } from "./LiveIdentityDisplay.js";
|
||||
import { AgentSpecDisplay } from "./AgentSpecDisplay.js";
|
||||
import { PreviewPane } from "./preview/PreviewPane.js";
|
||||
import { SessionPreviewPane } from "./preview/SessionPreviewPane.js";
|
||||
import { FileReferenceTrigger } from "./drawer-triggers/FileReferenceTrigger.js";
|
||||
import { displayPodName, inferPodName } from "../lib/display-name.js";
|
||||
import { copyText } from "../lib/copy-text.js";
|
||||
import { getActivityLabel, getActivityState, getActivityTextClass, isActivityStale } from "../lib/activity-visuals.js";
|
||||
import { getRestoreStatusColorClass } from "../lib/restore-status-colors.js";
|
||||
import type { AgentSpecReview } from "../hooks/useSpecReview.js";
|
||||
|
||||
type Tab = "identity" | "agent-spec" | "startup" | "transcript";
|
||||
type Tab = "identity" | "agent-spec" | "startup" | "transcript" | "terminal";
|
||||
|
||||
interface LiveNodeDetailsProps {
|
||||
rigId: string;
|
||||
logicalId: string;
|
||||
}
|
||||
|
||||
function statusColor(status: string | null): string {
|
||||
switch (status) {
|
||||
case "ready": return "text-green-600";
|
||||
case "pending": return "text-amber-600";
|
||||
case "attention_required": return "text-orange-600";
|
||||
case "failed": return "text-red-600";
|
||||
default: return "text-stone-400";
|
||||
}
|
||||
}
|
||||
|
||||
function startupStatusLabel(status: string | null): string {
|
||||
switch (status) {
|
||||
case "attention_required": return "attention required";
|
||||
default: return status ?? "stopped";
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract agent name from a local:agents/<name> ref. Returns null for unsupported forms. */
|
||||
function resolveAgentName(agentRef: string | null): string | null {
|
||||
if (!agentRef) return null;
|
||||
@@ -27,7 +84,6 @@ function AgentSpecSection({ agentRef }: { agentRef: string | null }) {
|
||||
const agentName = resolveAgentName(agentRef);
|
||||
const { data: agentEntries = [], isLoading: entriesLoading } = useSpecLibrary("agent");
|
||||
|
||||
// Find matching library entry
|
||||
const matches = agentName
|
||||
? agentEntries.filter((entry) => entry.name === agentName)
|
||||
: [];
|
||||
@@ -62,6 +118,145 @@ function AgentSpecSection({ agentRef }: { agentRef: string | null }) {
|
||||
return <AgentSpecDisplay review={review as AgentSpecReview} yaml={review.raw} testIdPrefix="live-agent" />;
|
||||
}
|
||||
|
||||
/** Action buttons — per founder direction, "action buttons any kind of
|
||||
* action you would put like near the top". Cmux launch + copy tmux +
|
||||
* copy resume. Migrated from retired NodeDetailPanel "Actions" section. */
|
||||
function ActionButtonsRow({ rigId, logicalId, data }: { rigId: string; logicalId: string; data: NodeDetailData }) {
|
||||
const handleCopyAttach = async () => {
|
||||
if (data.tmuxAttachCommand) await copyText(data.tmuxAttachCommand);
|
||||
};
|
||||
const handleOpenCmux = async () => {
|
||||
try {
|
||||
await fetch(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(logicalId)}/open-cmux`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
} catch { /* best-effort */ }
|
||||
};
|
||||
const handleCopyResume = async () => {
|
||||
if (data.resumeCommand) await copyText(data.resumeCommand);
|
||||
};
|
||||
return (
|
||||
<div data-testid="live-node-actions" className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleOpenCmux}
|
||||
data-testid="detail-cmux-open"
|
||||
className="px-3 py-1.5 border border-outline-variant bg-white/30 font-mono text-[10px] uppercase tracking-wide text-stone-900 hover:bg-stone-100/60"
|
||||
>
|
||||
Open CMUX
|
||||
</button>
|
||||
{data.tmuxAttachCommand && (
|
||||
<button
|
||||
onClick={handleCopyAttach}
|
||||
data-testid="detail-copy-attach"
|
||||
className="px-3 py-1.5 border border-outline-variant bg-white/30 font-mono text-[10px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60"
|
||||
>
|
||||
Copy tmux attach
|
||||
</button>
|
||||
)}
|
||||
{data.resumeCommand && (
|
||||
<button
|
||||
onClick={handleCopyResume}
|
||||
data-testid="detail-copy-resume"
|
||||
className="px-3 py-1.5 border border-outline-variant bg-white/30 font-mono text-[10px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60"
|
||||
>
|
||||
Copy resume command
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Status section — startup status pip + restore outcome + failure
|
||||
* banner with recovery guidance. Merged from retired NodeDetailPanel
|
||||
* Status section. Surfaces in the persistent header. */
|
||||
function StatusSection({ data }: { data: NodeDetailData }) {
|
||||
const showFailure =
|
||||
data.startupStatus === "failed" ||
|
||||
data.startupStatus === "attention_required" ||
|
||||
!!data.latestError;
|
||||
return (
|
||||
<section
|
||||
data-testid="live-node-status"
|
||||
className="grid gap-2 border border-outline-variant bg-white/30 p-3 sm:grid-cols-2"
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-[8px] uppercase tracking-wider text-stone-400">Startup</span>
|
||||
<span className={statusColor(data.startupStatus)} data-testid="detail-startup-status">
|
||||
{startupStatusLabel(data.startupStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-[8px] uppercase tracking-wider text-stone-400">Restore</span>
|
||||
<span
|
||||
className={`font-mono text-xs font-bold ${getRestoreStatusColorClass(data.restoreOutcome)}`}
|
||||
data-testid="detail-restore-outcome"
|
||||
>
|
||||
{data.restoreOutcome}
|
||||
</span>
|
||||
</div>
|
||||
{showFailure && (
|
||||
<div
|
||||
className={`sm:col-span-2 mt-1 p-2 border ${
|
||||
data.startupStatus === "attention_required"
|
||||
? "bg-orange-50 border-orange-200"
|
||||
: "bg-red-50 border-red-200"
|
||||
}`}
|
||||
data-testid="detail-failure-banner"
|
||||
>
|
||||
<div
|
||||
className={`font-mono text-[9px] font-bold mb-1 ${
|
||||
data.startupStatus === "attention_required" ? "text-orange-700" : "text-red-700"
|
||||
}`}
|
||||
>
|
||||
{data.startupStatus === "attention_required"
|
||||
? "Attention Required"
|
||||
: data.startupStatus === "failed"
|
||||
? "Startup Failed"
|
||||
: "Error"}
|
||||
</div>
|
||||
{data.latestError && (
|
||||
<div
|
||||
className={`font-mono text-[9px] mb-1 ${
|
||||
data.startupStatus === "attention_required" ? "text-orange-700" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{data.latestError}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-mono text-[8px] text-stone-500">
|
||||
{data.startupStatus === "attention_required"
|
||||
? `Use rig capture ${data.canonicalSessionName ?? "<session>"} to inspect the prompt, then rig send ${data.canonicalSessionName ?? "<session>"} to clear it.`
|
||||
: data.startupStatus === "failed"
|
||||
? "Check logs with: rig ps --nodes, or restart with: rig up"
|
||||
: "Try: rig restore <snapshotId>"}
|
||||
</div>
|
||||
{data.recoveryGuidance && (
|
||||
<div className="mt-2 border-t border-stone-200 pt-2" data-testid="detail-recovery-guidance">
|
||||
<div className="font-mono text-[8px] font-bold text-stone-700 mb-1">Recovery</div>
|
||||
<div className="font-mono text-[8px] text-stone-600 mb-1">{data.recoveryGuidance.summary}</div>
|
||||
<div className="space-y-0.5 mb-1">
|
||||
{data.recoveryGuidance.commands.map((command, index) => (
|
||||
<code key={`${command}-${index}`} className="font-mono text-[8px] text-stone-800 bg-stone-100 px-1 py-0.5 block">
|
||||
{command}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{data.recoveryGuidance.notes.map((note, index) => (
|
||||
<div key={`${note}-${index}`} className="font-mono text-[8px] text-stone-500">
|
||||
{note}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveNodeCurrentState({ data }: { data: NodeDetailData }) {
|
||||
const activityState = getActivityState(data.agentActivity);
|
||||
const activityLabel = getActivityLabel(activityState);
|
||||
@@ -72,7 +267,7 @@ function LiveNodeCurrentState({ data }: { data: NodeDetailData }) {
|
||||
return (
|
||||
<section
|
||||
data-testid="live-node-current-state"
|
||||
className="grid gap-3 border border-stone-200 bg-stone-50/60 p-3 sm:grid-cols-2"
|
||||
className="grid gap-3 border border-outline-variant bg-white/30 p-3 sm:grid-cols-2"
|
||||
>
|
||||
<div>
|
||||
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400">Activity</div>
|
||||
@@ -105,15 +300,44 @@ function LiveNodeCurrentState({ data }: { data: NodeDetailData }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Recent Events — preserved per founder direction "don't want to lose
|
||||
* any content" (P5.1 dispatch ask #3). Compact list of last 10 events.
|
||||
* Migrated from retired NodeDetailPanel. */
|
||||
function RecentEventsSection({ data }: { data: NodeDetailData }) {
|
||||
if (!data.recentEvents || data.recentEvents.length === 0) return null;
|
||||
return (
|
||||
<section data-testid="live-node-recent-events" className="border border-outline-variant bg-white/30 p-3">
|
||||
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400 mb-2">
|
||||
Recent Events
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{data.recentEvents.slice(0, 10).map((e, i) => (
|
||||
<div key={`${e.type}-${i}`} className="font-mono text-[9px] flex justify-between gap-3">
|
||||
<span className="text-stone-700 truncate">{e.type}</span>
|
||||
<span className="text-stone-400 ml-2 shrink-0">{e.createdAt}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
|
||||
const { data, isLoading, error } = useNodeDetail(rigId, logicalId);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("identity");
|
||||
const isAgent = data ? data.nodeKind !== "infrastructure" : true;
|
||||
const tabs: Tab[] = isAgent ? ["identity", "agent-spec", "startup", "transcript"] : ["identity", "startup", "transcript"];
|
||||
// Single canonical 5-tab body row per DRIFT P5.1-D1. Terminal folds in
|
||||
// from the prior outer SeatScopePage tab; Transcript surfaces only
|
||||
// here (eliminates the "transcript twice" duplication).
|
||||
const tabs: Tab[] = isAgent
|
||||
? ["identity", "agent-spec", "startup", "transcript", "terminal"]
|
||||
: ["identity", "startup", "transcript", "terminal"];
|
||||
|
||||
return (
|
||||
<WorkspacePage>
|
||||
<div data-testid="live-node-details" className="space-y-6">
|
||||
<div data-testid="live-node-details" className="space-y-4">
|
||||
{/* Persistent header — TOP. Action buttons → status → preview →
|
||||
current state → recent events → infra (conditional). */}
|
||||
<WorkflowHeader
|
||||
eyebrow="Live Node Details"
|
||||
title={data?.canonicalSessionName ?? logicalId}
|
||||
@@ -121,19 +345,51 @@ export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
|
||||
/>
|
||||
|
||||
{isLoading && <div className="font-mono text-[10px] text-stone-400">Loading...</div>}
|
||||
{error && <div className="p-3 bg-red-50 border border-red-200 font-mono text-[10px] text-red-700">{(error as Error).message}</div>}
|
||||
{data && <LiveNodeCurrentState data={data} />}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 font-mono text-[10px] text-red-700">
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-stone-200">
|
||||
{data && <ActionButtonsRow rigId={rigId} logicalId={logicalId} data={data} />}
|
||||
{data && <StatusSection data={data} />}
|
||||
{data?.canonicalSessionName && (
|
||||
<section data-testid="live-node-preview" className="border border-outline-variant bg-white/30 p-3">
|
||||
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400 mb-2">Preview</div>
|
||||
<PreviewPane
|
||||
rigId={rigId}
|
||||
rigName={data.rigName}
|
||||
logicalId={logicalId}
|
||||
testIdPrefix="detail-preview"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{data && <LiveNodeCurrentState data={data} />}
|
||||
{data && <RecentEventsSection data={data} />}
|
||||
{data?.nodeKind === "infrastructure" && data.infrastructureStartupCommand && (
|
||||
<section data-testid="live-node-infra-startup" className="border border-outline-variant bg-white/30 p-3">
|
||||
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400 mb-2">
|
||||
Startup Command
|
||||
</div>
|
||||
<code className="font-mono text-[9px] text-stone-700 bg-stone-100 px-2 py-1 block">
|
||||
{data.infrastructureStartupCommand}
|
||||
</code>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Single tabs row at BOTTOM per founder direction "probably makes
|
||||
sense to put them down below". 5 tabs canonical body. */}
|
||||
<div className="flex gap-1 border-b border-outline-variant" role="tablist" data-testid="live-node-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
data-testid={`live-tab-${tab}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-3 py-1.5 font-mono text-[10px] uppercase tracking-wider transition-colors ${
|
||||
activeTab === tab
|
||||
? "border-b-2 border-stone-900 text-stone-900 font-bold"
|
||||
? "border-b-2 border-stone-900 text-stone-900 font-bold -mb-px"
|
||||
: "text-stone-500 hover:text-stone-700"
|
||||
}`}
|
||||
>
|
||||
@@ -159,15 +415,40 @@ export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
|
||||
|
||||
{data && activeTab === "startup" && (
|
||||
<div data-testid="live-startup-section" className="space-y-4">
|
||||
{data.startupFiles.length > 0 && (
|
||||
<div className="border border-stone-200 p-3">
|
||||
<div className="font-mono text-xs font-bold mb-2">Startup Files</div>
|
||||
{data.startupFiles.map((f, i) => (
|
||||
<div key={i} className="font-mono text-[10px]">
|
||||
{f.path} <span className="text-stone-400">({f.deliveryHint})</span>
|
||||
{f.required && <span className="text-red-500 text-[8px] ml-1">REQUIRED</span>}
|
||||
</div>
|
||||
))}
|
||||
{data.startupFiles.length > 0 ? (
|
||||
<div className="border border-outline-variant bg-white/30">
|
||||
<div className="px-3 py-2 border-b border-outline-variant font-mono text-xs font-bold">
|
||||
Startup Files
|
||||
</div>
|
||||
<ul className="divide-y divide-outline-variant">
|
||||
{data.startupFiles.map((f, i) => (
|
||||
<li
|
||||
key={`${f.path}-${i}`}
|
||||
data-testid={`live-startup-file-${f.path}`}
|
||||
>
|
||||
{/* P5.1-1a: each startup file wrapped in
|
||||
FileReferenceTrigger per content-drawer.md L26 —
|
||||
click opens FileViewer in drawer. */}
|
||||
<FileReferenceTrigger
|
||||
data={{ path: f.path }}
|
||||
testId={`live-startup-file-trigger-${f.path}`}
|
||||
className="block w-full px-3 py-2 text-left hover:bg-stone-100/60 transition-colors font-mono text-[10px]"
|
||||
>
|
||||
<span className="font-bold underline decoration-dotted decoration-stone-400">
|
||||
{f.path}
|
||||
</span>
|
||||
<span className="text-stone-400 ml-2">({f.deliveryHint})</span>
|
||||
{f.required && (
|
||||
<span className="text-red-500 text-[8px] ml-1">REQUIRED</span>
|
||||
)}
|
||||
</FileReferenceTrigger>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-[10px] text-stone-400 p-4">
|
||||
No startup files declared
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -176,15 +457,35 @@ export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
|
||||
{data && activeTab === "transcript" && (
|
||||
<div data-testid="live-transcript-section" className="space-y-4">
|
||||
{data.transcript.enabled ? (
|
||||
<div className="border border-stone-200 p-3">
|
||||
<div className="border border-outline-variant bg-white/30 p-3">
|
||||
<div className="font-mono text-xs font-bold mb-2">Transcript</div>
|
||||
<div className="font-mono text-[10px] text-stone-700">{data.transcript.path}</div>
|
||||
{data.transcript.tailCommand && (
|
||||
<code className="block mt-1 font-mono text-[9px] text-stone-500 bg-stone-100 px-2 py-1">{data.transcript.tailCommand}</code>
|
||||
<code className="block mt-1 font-mono text-[9px] text-stone-500 bg-stone-100 px-2 py-1">
|
||||
{data.transcript.tailCommand}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-[10px] text-stone-400 p-4">Transcript capture not enabled</div>
|
||||
<div className="font-mono text-[10px] text-stone-400 p-4">
|
||||
Transcript capture not enabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && activeTab === "terminal" && (
|
||||
<div data-testid="live-terminal-section" className="space-y-4">
|
||||
{data.canonicalSessionName ? (
|
||||
<SessionPreviewPane
|
||||
sessionName={data.canonicalSessionName}
|
||||
lines={50}
|
||||
testIdPrefix="live-terminal-preview"
|
||||
/>
|
||||
) : (
|
||||
<div className="font-mono text-[10px] text-stone-400 p-4">
|
||||
No canonical session name; terminal preview unavailable.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useNodeDetail } from "../hooks/useNodeDetail.js";
|
||||
import { getRestoreStatusColorClass } from "../lib/restore-status-colors.js";
|
||||
import { copyText } from "../lib/copy-text.js";
|
||||
import { displayPodName, inferPodName } from "../lib/display-name.js";
|
||||
import { LiveIdentityDisplay } from "./LiveIdentityDisplay.js";
|
||||
import { PreviewPane } from "./preview/PreviewPane.js";
|
||||
|
||||
interface NodeDetailPanelProps {
|
||||
rigId: string;
|
||||
logicalId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function statusColor(status: string | null): string {
|
||||
switch (status) {
|
||||
case "ready": return "text-green-600";
|
||||
case "pending": return "text-amber-600";
|
||||
case "attention_required": return "text-orange-600";
|
||||
case "failed": return "text-red-600";
|
||||
default: return "text-stone-400";
|
||||
}
|
||||
}
|
||||
|
||||
function startupStatusLabel(status: string | null): string {
|
||||
switch (status) {
|
||||
case "attention_required": return "attention required";
|
||||
default: return status ?? "stopped";
|
||||
}
|
||||
}
|
||||
|
||||
export function NodeDetailPanel({ rigId, logicalId, onClose }: NodeDetailPanelProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useNodeDetail(rigId, logicalId);
|
||||
const headerName = data?.canonicalSessionName ?? logicalId;
|
||||
|
||||
const handleCopyAttach = async () => {
|
||||
if (data?.tmuxAttachCommand) await copyText(data.tmuxAttachCommand);
|
||||
};
|
||||
|
||||
const handleOpenCmux = async () => {
|
||||
try {
|
||||
await fetch(`/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(logicalId)}/open-cmux`, { method: "POST" });
|
||||
} catch { /* best-effort */ }
|
||||
};
|
||||
|
||||
const handleCopyResume = async () => {
|
||||
if (data?.resumeCommand) await copyText(data.resumeCommand);
|
||||
};
|
||||
|
||||
return (
|
||||
// V1 attempt-3 Phase 5 P5-9 ship-gate bounce P0-2: switch from
|
||||
// `absolute inset-y-0 right-0 z-20 w-80` legacy self-pinning (which
|
||||
// left ~288px orphan whitespace inside the 38rem drawer because the
|
||||
// panel pinned itself to viewport-right with w-80=320px) to
|
||||
// `relative w-full h-full` fill-parent. Only production caller is
|
||||
// SeatDetailViewer (verified via grep `<NodeDetailPanel`) which mounts
|
||||
// inside the canonical 38rem VellumSheet drawer chrome and supplies
|
||||
// its own width context. Background + backdrop preserved.
|
||||
<aside
|
||||
data-testid="node-detail-panel"
|
||||
className="relative w-full h-full bg-[rgba(250,249,245,0.035)] supports-[backdrop-filter]:bg-[rgba(250,249,245,0.018)] 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">
|
||||
<span className="font-mono text-xs font-bold text-stone-900 truncate">{headerName}</span>
|
||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-900 text-sm" data-testid="detail-close">×</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="p-4 font-mono text-[9px] text-stone-400">Loading...</div>}
|
||||
{error && <div className="p-4 font-mono text-[9px] text-red-500">Failed to load node detail</div>}
|
||||
|
||||
{data && (
|
||||
<div className="flex-1 overflow-y-auto flex flex-col gap-0">
|
||||
{/* Identity */}
|
||||
<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">Identity</div>
|
||||
<div className="space-y-1 font-mono text-[10px]">
|
||||
<div className="flex justify-between"><span className="text-stone-500">Rig</span><span className="text-stone-900">{data.rigName}</span></div>
|
||||
<div className="flex justify-between"><span className="text-stone-500">Logical ID</span><span className="text-stone-900">{logicalId}</span></div>
|
||||
<div className="flex justify-between"><span className="text-stone-500">Pod</span><span className="text-stone-900">{data.podNamespace ?? inferPodName(logicalId) ?? displayPodName(data.podId)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-stone-500">Session</span><span className="text-stone-900 truncate ml-2">{data.canonicalSessionName ?? "—"}</span></div>
|
||||
<div className="flex justify-between"><span className="text-stone-500">Runtime</span><span className="text-stone-900">{data.runtime ?? "—"}</span></div>
|
||||
{data.cwd && (
|
||||
<div className="flex justify-between"><span className="text-stone-500">CWD</span><span className="text-stone-900 truncate ml-2">{data.cwd}</span></div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Edges, Peers, Transcript, Compact Spec, Context */}
|
||||
{data.peers && (
|
||||
<LiveIdentityDisplay
|
||||
peers={data.peers}
|
||||
edges={data.edges}
|
||||
transcript={data.transcript}
|
||||
compactSpec={data.compactSpec}
|
||||
contextUsage={data.contextUsage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<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">Status</div>
|
||||
<div className="space-y-1 font-mono text-[10px]">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-stone-500">Startup</span>
|
||||
<span className={statusColor(data.startupStatus)} data-testid="detail-startup-status">{startupStatusLabel(data.startupStatus)}</span>
|
||||
</div>
|
||||
{/* Restore outcome — prominent */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-stone-500">Restore</span>
|
||||
<span
|
||||
className={`font-bold text-xs ${getRestoreStatusColorClass(data.restoreOutcome)}`}
|
||||
data-testid="detail-restore-outcome"
|
||||
>
|
||||
{data.restoreOutcome}
|
||||
</span>
|
||||
</div>
|
||||
{/* Failure banner with actionable guidance */}
|
||||
{(data.startupStatus === "failed" || data.startupStatus === "attention_required" || data.latestError) && (
|
||||
<div
|
||||
className={`mt-2 p-2 border ${
|
||||
data.startupStatus === "attention_required"
|
||||
? "bg-orange-50 border-orange-200"
|
||||
: "bg-red-50 border-red-200"
|
||||
}`}
|
||||
data-testid="detail-failure-banner"
|
||||
>
|
||||
<div
|
||||
className={`font-mono text-[9px] font-bold mb-1 ${
|
||||
data.startupStatus === "attention_required" ? "text-orange-700" : "text-red-700"
|
||||
}`}
|
||||
>
|
||||
{data.startupStatus === "attention_required"
|
||||
? "Attention Required"
|
||||
: data.startupStatus === "failed"
|
||||
? "Startup Failed"
|
||||
: "Error"}
|
||||
</div>
|
||||
{data.latestError && (
|
||||
<div
|
||||
className={`font-mono text-[9px] mb-1 ${
|
||||
data.startupStatus === "attention_required" ? "text-orange-700" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{data.latestError}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-mono text-[8px] text-stone-500">
|
||||
{data.startupStatus === "attention_required"
|
||||
? `Use rig capture ${data.canonicalSessionName ?? "<session>"} to inspect the prompt, then rig send ${data.canonicalSessionName ?? "<session>"} to clear it.`
|
||||
: data.startupStatus === "failed"
|
||||
? "Check logs with: rig ps --nodes, or restart with: rig up"
|
||||
: "Try: rig restore <snapshotId>"}
|
||||
</div>
|
||||
{data.recoveryGuidance && (
|
||||
<div className="mt-2 border-t border-stone-200 pt-2" data-testid="detail-recovery-guidance">
|
||||
<div className="font-mono text-[8px] font-bold text-stone-700 mb-1">Recovery</div>
|
||||
<div className="font-mono text-[8px] text-stone-600 mb-1">{data.recoveryGuidance.summary}</div>
|
||||
<div className="space-y-0.5 mb-1">
|
||||
{data.recoveryGuidance.commands.map((command, index) => (
|
||||
<code key={`${command}-${index}`} className="font-mono text-[8px] text-stone-800 bg-stone-100 px-1 py-0.5 block">
|
||||
{command}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{data.recoveryGuidance.notes.map((note, index) => (
|
||||
<div key={`${note}-${index}`} className="font-mono text-[8px] text-stone-500">
|
||||
{note}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Preview Terminal v0 (PL-018) — live preview pane */}
|
||||
{data.canonicalSessionName && (
|
||||
<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">Preview</div>
|
||||
<PreviewPane
|
||||
rigId={rigId}
|
||||
rigName={data.rigName}
|
||||
logicalId={logicalId}
|
||||
testIdPrefix="detail-preview"
|
||||
/>
|
||||
</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">
|
||||
{data.tmuxAttachCommand && (
|
||||
<button onClick={handleCopyAttach} data-testid="detail-copy-attach" className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left truncate">
|
||||
Copy tmux attach
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleOpenCmux} data-testid="detail-cmux-open" className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left">
|
||||
Open CMUX
|
||||
</button>
|
||||
{data.resumeCommand && (
|
||||
<button onClick={handleCopyResume} data-testid="detail-copy-resume" className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left truncate">
|
||||
Copy resume command
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate({ to: "/rigs/$rigId/nodes/$logicalId", params: { rigId, logicalId: encodeURIComponent(logicalId) } })}
|
||||
data-testid="detail-open-full"
|
||||
className="px-2 py-1 border border-stone-300 font-mono text-[8px] uppercase hover:bg-stone-200 text-left font-bold"
|
||||
>
|
||||
Open Full Details
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Infrastructure startup command */}
|
||||
{data.nodeKind === "infrastructure" && data.infrastructureStartupCommand && (
|
||||
<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">Startup Command</div>
|
||||
<code className="font-mono text-[9px] text-stone-700 bg-stone-100 px-2 py-1 block">{data.infrastructureStartupCommand}</code>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Startup Files */}
|
||||
{data.startupFiles.length > 0 && (
|
||||
<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">Startup Files</div>
|
||||
<ol className="list-decimal list-inside space-y-0.5">
|
||||
{data.startupFiles.map((f, i) => (
|
||||
<li key={i} className="font-mono text-[9px] text-stone-700 truncate">
|
||||
{f.path} <span className="text-stone-400">({f.deliveryHint})</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Recent Events */}
|
||||
{data.recentEvents.length > 0 && (
|
||||
<section className="px-4 py-3">
|
||||
<div className="font-mono text-[8px] text-stone-400 uppercase tracking-wider mb-2">Recent Events</div>
|
||||
<div className="space-y-0.5">
|
||||
{data.recentEvents.slice(0, 10).map((e, i) => (
|
||||
<div key={i} className="font-mono text-[9px] flex justify-between">
|
||||
<span className="text-stone-700 truncate">{e.type}</span>
|
||||
<span className="text-stone-400 ml-2 shrink-0">{e.createdAt}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useMemo, useCallback, useState, useRef, useEffect } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ReactFlow, Controls, Handle, Position, type NodeTypes, type Node, type Edge, type NodeMouseHandler } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useRigGraph } from "../hooks/useRigGraph.js";
|
||||
import { useRigEvents } from "../hooks/useRigEvents.js";
|
||||
import { useTopologyEdgeActivity } from "../hooks/useTopologyEdgeActivity.js";
|
||||
import { useDiscoveredSessionsConditional, type DiscoveredSession } from "../hooks/useDiscovery.js";
|
||||
import { useDiscoveryPlacement, useDrawerSelection, useNodeSelection } from "./AppShell.js";
|
||||
import { useDiscoveryPlacement, useDrawerSelection } from "./AppShell.js";
|
||||
import { getEdgeStyle } from "@/lib/edge-styles";
|
||||
import { applyTreeLayout } from "@/lib/graph-layout";
|
||||
import { RigNode } from "./RigNode.js";
|
||||
@@ -296,7 +297,12 @@ export function RigGraph({
|
||||
return [...managed, ...discovered] as Node[];
|
||||
}, [rawNodes, rawEdges, shouldAnimate, discoveredSessions, placementMode, placementTarget]);
|
||||
|
||||
const { setSelectedNode } = useNodeSelection();
|
||||
// V1 polish slice Phase 5.1 P5.1-2 + DRIFT P5.1-D2: graph node click
|
||||
// navigates to /topology/seat/$rigId/$logicalId center page (matches
|
||||
// Explorer tree click + table row click contract). Replaces legacy
|
||||
// setSelection({type:'seat-detail'}) drawer-open behavior. The
|
||||
// useNodeSelection alias is fully retired post-Phase 5.1.
|
||||
const navigate = useNavigate();
|
||||
const rigStamp = rigName?.trim() ? rigName : (rigId ? shortId(rigId) : null);
|
||||
|
||||
const onNodeClick: NodeMouseHandler = useCallback(
|
||||
@@ -351,8 +357,13 @@ export function RigGraph({
|
||||
binding: { cmuxSurface?: string | null } | null;
|
||||
};
|
||||
|
||||
// Set shared node selection (for detail panel)
|
||||
setSelectedNode({ rigId, logicalId: nodeData.logicalId });
|
||||
// V1 polish slice Phase 5.1 P5.1-2: navigate to center page
|
||||
// (canonical agent-detail = LiveNodeDetails). Parity with Explorer
|
||||
// tree click + topology table row click (P5.1-7).
|
||||
navigate({
|
||||
to: "/topology/seat/$rigId/$logicalId",
|
||||
params: { rigId, logicalId: encodeURIComponent(nodeData.logicalId) },
|
||||
});
|
||||
|
||||
if (!nodeData.binding?.cmuxSurface) {
|
||||
showFocusMessage({ text: "Not bound to cmux surface", type: "info" });
|
||||
@@ -383,7 +394,7 @@ export function RigGraph({
|
||||
showFocusMessage({ text: "Focus failed", type: "error" });
|
||||
}
|
||||
},
|
||||
[placementMode, podMetaById, rigId, setPlacementTarget, setSelectedNode, setSelection, showFocusMessage]
|
||||
[placementMode, podMetaById, rigId, setPlacementTarget, navigate, setSelection, showFocusMessage]
|
||||
);
|
||||
|
||||
if (rigId === null) {
|
||||
|
||||
@@ -16,25 +16,22 @@ 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";
|
||||
|
||||
// V1 attempt-3 Phase 5 P5-4: legacy 'node' kind retired. The 'seat-detail' kind
|
||||
// (Phase 4) is now the single canonical seat-detail surface; SeatDetailViewer
|
||||
// wraps NodeDetailPanel inside the canonical 38rem drawer chrome. Founder noticed
|
||||
// graph-node clicks rendered NodeDetailPanel "full-width with empty whitespace
|
||||
// on left" — width-coupling broke when the legacy 'node' branch routed
|
||||
// NodeDetailPanel directly past the drawer chrome. Migration: every prior
|
||||
// setSelection({type:'node',...}) callsite now routes through 'seat-detail';
|
||||
// useNodeSelection alias coerces same. Negative-assertion test enforces removal.
|
||||
// V1 polish slice Phase 5.1 P5.1-1 + DRIFT P5.1-D2: 'seat-detail' kind
|
||||
// RETIRED at V1 polish per founder direction. Graph node click + tree
|
||||
// click + table row click all navigate to /topology/seat/$rigId/$logicalId
|
||||
// center page (canonical agent-detail surface = LiveNodeDetails).
|
||||
// SeatDetailViewer wrapper component DELETED; SeatDetailTrigger primitive
|
||||
// DELETED. Drawer remains a content-viewer surface for the other auto-
|
||||
// open triggers (qitem / file / sub-spec) per content-drawer.md L23-L34.
|
||||
export type DrawerSelection =
|
||||
| { type: "system"; tab?: "log" | "status" }
|
||||
| { type: "discovery" }
|
||||
// Phase 4 viewer kinds
|
||||
// Phase 4 viewer kinds (seat-detail retired Phase 5.1 P5.1-D2)
|
||||
| { type: "qitem"; data: QueueItemViewerData }
|
||||
| { type: "file"; data: FileViewerData }
|
||||
| { type: "sub-spec"; data: SubSpecPreviewData }
|
||||
| { type: "seat-detail"; rigId: string; logicalId: string }
|
||||
| null;
|
||||
|
||||
interface SharedDetailDrawerProps {
|
||||
@@ -85,15 +82,6 @@ export function SharedDetailDrawer({
|
||||
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;
|
||||
})();
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -221,29 +221,16 @@ export function PodScopePage() {
|
||||
}
|
||||
|
||||
export function SeatScopePage() {
|
||||
// V1 polish slice Phase 5.1 P5.1-1 + DRIFT P5.1-D1: outer scope tabs
|
||||
// (detail / transcript / terminal) RETIRED at V1 polish per founder
|
||||
// direction. LiveNodeDetails owns the canonical 5-tab body row inline
|
||||
// (Identity / Agent Spec / Startup / Transcript / Terminal). The
|
||||
// ScopeShell wrapper is dropped too — LiveNodeDetails is the page.
|
||||
const { rigId, logicalId } = useParams({ from: "/topology/seat/$rigId/$logicalId" });
|
||||
const decodedLogicalId = decodeURIComponent(logicalId);
|
||||
const [active, setActive] = useState<TopologySeatScopeTab>("detail");
|
||||
|
||||
return (
|
||||
<ScopeShell
|
||||
eyebrow="Topology · Seat"
|
||||
title={decodedLogicalId}
|
||||
tabsNav={<TopologyViewModeTabs tabs={SEAT_SCOPE_TABS} active={active} onSelect={setActive} testIdPrefix="topology-seat" />}
|
||||
>
|
||||
{active === "detail" ? (
|
||||
<LiveNodeDetails rigId={rigId} logicalId={decodedLogicalId} />
|
||||
) : null}
|
||||
{active === "transcript" ? (
|
||||
<div className="p-6">
|
||||
<EmptyState label="TRANSCRIPT" description="Existing transcript view re-mounts here in Phase 5 polish." variant="card" />
|
||||
</div>
|
||||
) : null}
|
||||
{active === "terminal" ? (
|
||||
<div className="p-6">
|
||||
<EmptyState label="SEAT TERMINAL" description="Pinned terminal card (Phase 5); V2 web terminal." variant="card" />
|
||||
</div>
|
||||
) : null}
|
||||
</ScopeShell>
|
||||
<div data-testid="seat-scope-page" className="flex flex-col h-full">
|
||||
<LiveNodeDetails rigId={rigId} logicalId={decodedLogicalId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,51 +3,101 @@
|
||||
// host > rig > pod > seat. Multi-host envelope: V1 has only one host
|
||||
// node ("localhost") above all rigs; V2 adds remote host registration.
|
||||
//
|
||||
// V1 attempt-3 Phase 5 P5-1: seat leaves gain a "details" icon that opens
|
||||
// SeatDetailViewer in the right drawer per content-drawer.md L40 manual-open
|
||||
// contract ("'details' icon on any topology node — replaces current auto-open
|
||||
// behavior"). The seat row remains a Link to /topology/seat/$rigId/$logicalId
|
||||
// for explicit center navigation; the icon is the named-trigger drawer surface.
|
||||
// V1 polish slice Phase 5.1 P5.1-2 + DRIFT P5.1-D2: SeatLeaf details
|
||||
// icon (P5-1) RETIRED at V1 polish. Founder direction: graph node
|
||||
// click + tree click + table row click all navigate to the canonical
|
||||
// /topology/seat/$rigId/$logicalId center page. The drawer-as-seat-
|
||||
// detail mode is gone; SeatDetailTrigger primitive deleted.
|
||||
//
|
||||
// P5.1-2 second part — auto-expand: when the route is on a seat URL,
|
||||
// expand the matching rig + pod branches automatically so the user
|
||||
// sees where the agent lives in the tree. Implemented via
|
||||
// useRouterState pathname parsing inside RigBranch + PodBranch.
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronDown, ChevronRight, Globe, PanelRightOpen } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { ChevronDown, ChevronRight, Globe } from "lucide-react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
import { useRigSummary } from "../../hooks/useRigSummary.js";
|
||||
import { useNodeInventory } from "../../hooks/useNodeInventory.js";
|
||||
import { displayPodName, inferPodName } from "../../lib/display-name.js";
|
||||
import { SeatDetailTrigger } from "../drawer-triggers/SeatDetailTrigger.js";
|
||||
|
||||
function SeatLeaf({ rigId, logicalId, label }: { rigId: string; logicalId: string; label: string }) {
|
||||
/** Parse the active topology pathname for the seat-scope rigId+logicalId
|
||||
* and (when on a rig/pod URL) the active rigId / podName. Used for
|
||||
* auto-expand of the matching branches. */
|
||||
function useActiveTopologyContext(): {
|
||||
rigId: string | null;
|
||||
podName: string | null;
|
||||
logicalId: string | null;
|
||||
} {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
// /topology/seat/$rigId/$logicalId
|
||||
const seatMatch = pathname.match(/^\/topology\/seat\/([^/]+)\/(.+)$/);
|
||||
if (seatMatch) {
|
||||
const rigId = decodeURIComponent(seatMatch[1]!);
|
||||
const logicalId = decodeURIComponent(seatMatch[2]!);
|
||||
const podName = inferPodName(logicalId) ?? "default";
|
||||
return { rigId, podName, logicalId };
|
||||
}
|
||||
// /topology/pod/$rigId/$podName
|
||||
const podMatch = pathname.match(/^\/topology\/pod\/([^/]+)\/([^/]+)$/);
|
||||
if (podMatch) {
|
||||
return {
|
||||
rigId: decodeURIComponent(podMatch[1]!),
|
||||
podName: decodeURIComponent(podMatch[2]!),
|
||||
logicalId: null,
|
||||
};
|
||||
}
|
||||
// /topology/rig/$rigId
|
||||
const rigMatch = pathname.match(/^\/topology\/rig\/([^/]+)$/);
|
||||
if (rigMatch) {
|
||||
return { rigId: decodeURIComponent(rigMatch[1]!), podName: null, logicalId: null };
|
||||
}
|
||||
return { rigId: null, podName: null, logicalId: null };
|
||||
}
|
||||
|
||||
function SeatLeaf({ rigId, logicalId, label, isActive }: {
|
||||
rigId: string;
|
||||
logicalId: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li className="group flex items-center gap-1 px-2 py-0.5 hover:bg-surface-low">
|
||||
<li className="px-2 py-0.5 hover:bg-surface-low">
|
||||
<Link
|
||||
to="/topology/seat/$rigId/$logicalId"
|
||||
params={{ rigId, logicalId: encodeURIComponent(logicalId) }}
|
||||
data-testid={`topology-seat-${rigId}-${logicalId}`}
|
||||
className="flex-1 min-w-0 font-mono text-xs text-on-surface hover:text-stone-900 truncate"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"block w-full min-w-0 font-mono text-xs truncate",
|
||||
isActive
|
||||
? "text-stone-900 font-bold"
|
||||
: "text-on-surface hover:text-stone-900",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
<SeatDetailTrigger
|
||||
rigId={rigId}
|
||||
logicalId={logicalId}
|
||||
testId={`topology-seat-details-${rigId}-${logicalId}`}
|
||||
className="shrink-0 p-0.5 rounded-sm text-on-surface-variant hover:text-stone-900 hover:bg-stone-200/60 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
|
||||
>
|
||||
<PanelRightOpen className="h-3 w-3" strokeWidth={1.5} aria-hidden="true" />
|
||||
<span className="sr-only">Open details in drawer</span>
|
||||
</SeatDetailTrigger>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PodBranch({ rigId, podName, seats }: {
|
||||
function PodBranch({ rigId, podName, seats, activeRigId, activePodName, activeLogicalId }: {
|
||||
rigId: string;
|
||||
podName: string;
|
||||
seats: Array<{ logicalId: string; label: string }>;
|
||||
activeRigId: string | null;
|
||||
activePodName: string | null;
|
||||
activeLogicalId: string | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// P5.1-2 auto-expand: when current route is on this pod (via pod URL
|
||||
// OR via a seat URL whose pod resolves to this pod), force-expand.
|
||||
const shouldAutoExpand =
|
||||
activeRigId === rigId && activePodName === podName;
|
||||
useEffect(() => {
|
||||
if (shouldAutoExpand && !open) setOpen(true);
|
||||
}, [shouldAutoExpand, open]);
|
||||
return (
|
||||
<li data-testid={`topology-pod-${rigId}-${podName}`}>
|
||||
<button
|
||||
@@ -69,7 +119,13 @@ function PodBranch({ rigId, podName, seats }: {
|
||||
{open ? (
|
||||
<ul className="ml-4 border-l border-stone-200">
|
||||
{seats.map((s) => (
|
||||
<SeatLeaf key={s.logicalId} rigId={rigId} logicalId={s.logicalId} label={s.label} />
|
||||
<SeatLeaf
|
||||
key={s.logicalId}
|
||||
rigId={rigId}
|
||||
logicalId={s.logicalId}
|
||||
label={s.label}
|
||||
isActive={activeRigId === rigId && activeLogicalId === s.logicalId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
@@ -77,9 +133,24 @@ function PodBranch({ rigId, podName, seats }: {
|
||||
);
|
||||
}
|
||||
|
||||
function RigBranch({ rigId, rigName }: { rigId: string; rigName: string }) {
|
||||
function RigBranch({ rigId, rigName, activeRigId, activePodName, activeLogicalId }: {
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
activeRigId: string | null;
|
||||
activePodName: string | null;
|
||||
activeLogicalId: string | null;
|
||||
}) {
|
||||
// P5.1-2 auto-expand: when the active route lives in this rig (rig
|
||||
// scope URL OR pod/seat scope URL whose rigId matches), force-expand.
|
||||
const shouldAutoExpand = activeRigId === rigId;
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: nodes } = useNodeInventory(open ? rigId : null);
|
||||
useEffect(() => {
|
||||
if (shouldAutoExpand && !open) setOpen(true);
|
||||
}, [shouldAutoExpand, open]);
|
||||
// When auto-expanded, fetch nodes eagerly so the pod tree resolves
|
||||
// even if user lands on a deep URL without manually expanding the rig.
|
||||
const eagerFetch = open || shouldAutoExpand;
|
||||
const { data: nodes } = useNodeInventory(eagerFetch ? rigId : null);
|
||||
const podsMap = new Map<string, Array<{ logicalId: string; label: string }>>();
|
||||
for (const n of nodes ?? []) {
|
||||
const pod = inferPodName(n.logicalId) ?? "default";
|
||||
@@ -113,7 +184,15 @@ function RigBranch({ rigId, rigName }: { rigId: string; rigName: string }) {
|
||||
</li>
|
||||
) : (
|
||||
pods.map(([pod, seats]) => (
|
||||
<PodBranch key={pod} rigId={rigId} podName={pod} seats={seats} />
|
||||
<PodBranch
|
||||
key={pod}
|
||||
rigId={rigId}
|
||||
podName={pod}
|
||||
seats={seats}
|
||||
activeRigId={activeRigId}
|
||||
activePodName={activePodName}
|
||||
activeLogicalId={activeLogicalId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
@@ -125,6 +204,10 @@ function RigBranch({ rigId, rigName }: { rigId: string; rigName: string }) {
|
||||
export function TopologyTreeView() {
|
||||
const { data: rigs } = useRigSummary();
|
||||
const [hostOpen, setHostOpen] = useState(true);
|
||||
// P5.1-2 auto-expand: pull active route context once at the tree root
|
||||
// and thread down through RigBranch + PodBranch.
|
||||
const { rigId: activeRigId, podName: activePodName, logicalId: activeLogicalId } =
|
||||
useActiveTopologyContext();
|
||||
|
||||
return (
|
||||
<div data-testid="topology-tree-view" className="flex-1 overflow-y-auto py-2">
|
||||
@@ -149,7 +232,16 @@ export function TopologyTreeView() {
|
||||
{hostOpen ? (
|
||||
<ul className="ml-5">
|
||||
{rigs && rigs.length > 0 ? (
|
||||
rigs.map((r) => <RigBranch key={r.id} rigId={r.id} rigName={r.name} />)
|
||||
rigs.map((r) => (
|
||||
<RigBranch
|
||||
key={r.id}
|
||||
rigId={r.id}
|
||||
rigName={r.name}
|
||||
activeRigId={activeRigId}
|
||||
activePodName={activePodName}
|
||||
activeLogicalId={activeLogicalId}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<li className="px-2 py-1 font-mono text-[10px] text-on-surface-variant italic">
|
||||
No rigs.
|
||||
|
||||
@@ -71,8 +71,11 @@ describe("Design System Compliance", () => {
|
||||
const rigNode = readFileSync(resolve(SRC_DIR, "components/RigNode.tsx"), "utf-8");
|
||||
expect(rigNode).toContain("font-mono");
|
||||
|
||||
const nodeDetailPanel = readFileSync(resolve(SRC_DIR, "components/NodeDetailPanel.tsx"), "utf-8");
|
||||
expect(nodeDetailPanel).toContain("font-mono");
|
||||
// V1 polish slice Phase 5.1 P5.1-1: NodeDetailPanel.tsx RETIRED;
|
||||
// canonical agent-detail surface is LiveNodeDetails.tsx now (which
|
||||
// also uses font-mono per the tactical aesthetic).
|
||||
const liveNodeDetails = readFileSync(resolve(SRC_DIR, "components/LiveNodeDetails.tsx"), "utf-8");
|
||||
expect(liveNodeDetails).toContain("font-mono");
|
||||
|
||||
const importFlow = readFileSync(resolve(SRC_DIR, "components/ImportFlow.tsx"), "utf-8");
|
||||
expect(importFlow).toContain("font-mono");
|
||||
|
||||
@@ -6,24 +6,20 @@
|
||||
//
|
||||
// - Each viewer renders without crashing when given canonical-shape props.
|
||||
// - Each trigger fires setSelection on click with the correct DrawerSelection
|
||||
// discriminator (`type: "qitem" | "file" | "sub-spec" | "seat-detail"`)
|
||||
// and the matching payload.
|
||||
// discriminator (`type: "qitem" | "file" | "sub-spec"`) and the matching
|
||||
// payload.
|
||||
// - SharedDetailDrawer routes selection.type to the correct viewer component.
|
||||
//
|
||||
// SeatDetailViewer wraps NodeDetailPanel (which does its own /api fetches via
|
||||
// React Query); the mock below bypasses that depth so the wrapper itself is
|
||||
// the thing under test.
|
||||
// V1 polish slice Phase 5.1 P5.1-D2: SeatDetailViewer + SeatDetailTrigger
|
||||
// RETIRED. seat-detail navigation goes to /topology/seat/$rigId/$logicalId
|
||||
// center page (LiveNodeDetails). The 'seat-detail' kind is dropped from
|
||||
// DrawerSelection union; this file now covers only the 3 remaining
|
||||
// drawer surfaces (qitem / file / sub-spec). The retirement-regression
|
||||
// guard lives in test/node-selection-migration.test.tsx.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
// Mock NodeDetailPanel so SeatDetailViewer renders without React Query / fetches.
|
||||
vi.mock("../src/components/NodeDetailPanel.js", () => ({
|
||||
NodeDetailPanel: ({ rigId, logicalId }: { rigId: string; logicalId: string }) => (
|
||||
<div data-testid="node-detail-panel-mock">{rigId}::{logicalId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import {
|
||||
DrawerSelectionContext,
|
||||
type DrawerSelection,
|
||||
@@ -31,11 +27,9 @@ import {
|
||||
import { QueueItemViewer } from "../src/components/drawer-viewers/QueueItemViewer.js";
|
||||
import { FileViewer } from "../src/components/drawer-viewers/FileViewer.js";
|
||||
import { SubSpecPreview } from "../src/components/drawer-viewers/SubSpecPreview.js";
|
||||
import { SeatDetailViewer } from "../src/components/drawer-viewers/SeatDetailViewer.js";
|
||||
import { QueueItemTrigger } from "../src/components/drawer-triggers/QueueItemTrigger.js";
|
||||
import { FileReferenceTrigger } from "../src/components/drawer-triggers/FileReferenceTrigger.js";
|
||||
import { SubSpecTrigger } from "../src/components/drawer-triggers/SubSpecTrigger.js";
|
||||
import { SeatDetailTrigger } from "../src/components/drawer-triggers/SeatDetailTrigger.js";
|
||||
import { SharedDetailDrawer } from "../src/components/SharedDetailDrawer.js";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -104,13 +98,8 @@ describe("Drawer viewers (P4-1) render with canonical props", () => {
|
||||
expect(queryByTestId("sub-spec-open-center")).toBeNull();
|
||||
});
|
||||
|
||||
it("SeatDetailViewer wraps NodeDetailPanel with rigId + logicalId", () => {
|
||||
const { getByTestId } = render(
|
||||
<SeatDetailViewer rigId="rig-1" logicalId="dev.impl" />,
|
||||
);
|
||||
expect(getByTestId("seat-detail-viewer")).toBeTruthy();
|
||||
expect(getByTestId("node-detail-panel-mock").textContent).toBe("rig-1::dev.impl");
|
||||
});
|
||||
// V1 polish slice Phase 5.1 P5.1-D2: SeatDetailViewer RETIRED.
|
||||
// Negative-assertion guard lives in test/node-selection-migration.test.tsx.
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -158,17 +147,9 @@ describe("Drawer triggers (P4-2) fire setSelection with correct kind on click",
|
||||
expect(setSelection).toHaveBeenCalledWith({ type: "sub-spec", data });
|
||||
});
|
||||
|
||||
it("SeatDetailTrigger click → setSelection({ type: 'seat-detail', rigId, logicalId })", () => {
|
||||
const { setSelection, getByTestId } = renderWithDrawerCtx(
|
||||
<SeatDetailTrigger rigId="rig-1" logicalId="dev.impl">open</SeatDetailTrigger>,
|
||||
);
|
||||
fireEvent.click(getByTestId("seat-detail-trigger"));
|
||||
expect(setSelection).toHaveBeenCalledWith({
|
||||
type: "seat-detail",
|
||||
rigId: "rig-1",
|
||||
logicalId: "dev.impl",
|
||||
});
|
||||
});
|
||||
// V1 polish slice Phase 5.1 P5.1-D2: SeatDetailTrigger RETIRED. Click
|
||||
// contract for the (now-deleted) primitive is guarded as a
|
||||
// file-doesn't-exist negative-assertion in node-selection-migration.test.tsx.
|
||||
|
||||
it("trigger custom testId override is respected (predicate-consistency probe)", () => {
|
||||
const data = { qitemId: "q", body: "b" };
|
||||
@@ -233,15 +214,8 @@ describe("SharedDetailDrawer (Phase 4) routes selection.type to the correct view
|
||||
expect(getByTestId("sub-spec-preview")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("selection.type='seat-detail' → SeatDetailViewer mounts in drawer", () => {
|
||||
const selection: DrawerSelection = {
|
||||
type: "seat-detail",
|
||||
rigId: "rig-1",
|
||||
logicalId: "dev.impl",
|
||||
};
|
||||
const { getByTestId } = render(
|
||||
<SharedDetailDrawer selection={selection} onClose={() => {}} {...NOOP_PROPS} />,
|
||||
);
|
||||
expect(getByTestId("seat-detail-viewer")).toBeTruthy();
|
||||
});
|
||||
// V1 polish slice Phase 5.1 P5.1-D2: 'seat-detail' kind RETIRED from
|
||||
// DrawerSelection union; navigation to /topology/seat/$rigId/$logicalId
|
||||
// center page (LiveNodeDetails) replaces the drawer-mounted variant.
|
||||
// Routing-by-type test for 'seat-detail' is gone with the kind itself.
|
||||
});
|
||||
@@ -1,331 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { NodeDetailPanel } from "../src/components/NodeDetailPanel.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
function renderPanel(props?: { rigId?: string; logicalId?: string }) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const onClose = vi.fn();
|
||||
|
||||
return {
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NodeDetailPanel
|
||||
rigId={props?.rigId ?? "rig-1"}
|
||||
logicalId={props?.logicalId ?? "dev.impl"}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
onClose,
|
||||
};
|
||||
}
|
||||
|
||||
const AGENT_DETAIL = {
|
||||
rigId: "rig-1", rigName: "test-rig", logicalId: "dev.impl", podId: "dev",
|
||||
canonicalSessionName: "dev-impl@test-rig", nodeKind: "agent", runtime: "claude-code",
|
||||
sessionStatus: "running", startupStatus: "ready", restoreOutcome: "n-a",
|
||||
tmuxAttachCommand: "tmux attach -t dev-impl@test-rig", resumeCommand: "claude --resume abc-123",
|
||||
recoveryGuidance: {
|
||||
summary: "Try native Claude resume first, then fall back to the workspace-local picker if needed.",
|
||||
commands: ["claude --resume abc-123", "cd /workspace", "claude --resume"],
|
||||
notes: ["Look for session name: dev-impl@test-rig", "Choose the full conversation option, not summary."],
|
||||
},
|
||||
latestError: null, model: "opus", agentRef: "local:agents/impl", profile: "default",
|
||||
resolvedSpecName: "impl", resolvedSpecVersion: "1.0.0", cwd: "/workspace",
|
||||
startupFiles: [{ path: "role.md", deliveryHint: "guidance_merge", required: true }],
|
||||
startupActions: [], recentEvents: [{ type: "node.startup_ready", createdAt: "2026-03-31T00:00:00Z" }],
|
||||
infrastructureStartupCommand: null,
|
||||
binding: { tmuxSession: "dev-impl@test-rig" },
|
||||
peers: [{ logicalId: "dev.qa", canonicalSessionName: "dev-qa@test-rig", runtime: "codex" }],
|
||||
edges: {
|
||||
outgoing: [{ kind: "delegates_to", to: { logicalId: "dev.qa", sessionName: "dev-qa@test-rig" } }],
|
||||
incoming: [],
|
||||
},
|
||||
transcript: { enabled: true, path: "/tmp/transcripts/test-rig/[email protected]", tailCommand: "rig transcript dev-impl@test-rig --tail 100" },
|
||||
compactSpec: { name: "impl", version: "1.0.0", profile: "default", skillCount: 2, guidanceCount: 1 },
|
||||
};
|
||||
|
||||
const INFRA_DETAIL = {
|
||||
...AGENT_DETAIL, logicalId: "infra.server", nodeKind: "infrastructure", runtime: "terminal",
|
||||
profile: "none", agentRef: "builtin:terminal", resumeCommand: null,
|
||||
recoveryGuidance: null,
|
||||
infrastructureStartupCommand: "npm run dev",
|
||||
canonicalSessionName: "infra-server@test-rig",
|
||||
tmuxAttachCommand: "tmux attach -t infra-server@test-rig",
|
||||
compactSpec: { name: null, version: null, profile: null, skillCount: 0, guidanceCount: 0 },
|
||||
};
|
||||
|
||||
const FAILED_DETAIL = {
|
||||
...AGENT_DETAIL, startupStatus: "failed", latestError: "harness launch timeout after 30s",
|
||||
};
|
||||
|
||||
const ATTENTION_REQUIRED_DETAIL = {
|
||||
...AGENT_DETAIL,
|
||||
startupStatus: "attention_required",
|
||||
latestError: "Claude is waiting for workspace trust approval before the session can become interactive.",
|
||||
};
|
||||
|
||||
// NS-T11: integration test — AppShell selection → NodeDetailPanel mounts
|
||||
import { NodeSelectionContext } from "../src/components/AppShell.js";
|
||||
import { useState } from "react";
|
||||
|
||||
describe("NodeDetailPanel integration with AppShell selection", () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it("NodeDetailPanel mounts when selectedNode is set via AppShell context", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/nodes/dev.impl")) {
|
||||
return { ok: true, json: async () => AGENT_DETAIL };
|
||||
}
|
||||
return { ok: true, json: async () => [] };
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
// Replicate AppShell's pattern: context provides selectedNode, conditionally renders panel
|
||||
function TestShell() {
|
||||
const [selectedNode] = useState<{ rigId: string; logicalId: string } | null>({ rigId: "rig-1", logicalId: "dev.impl" });
|
||||
return (
|
||||
<NodeSelectionContext.Provider value={{ selectedNode, setSelectedNode: vi.fn() }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{selectedNode && (
|
||||
<NodeDetailPanel
|
||||
rigId={selectedNode.rigId}
|
||||
logicalId={selectedNode.logicalId}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
</NodeSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestShell />);
|
||||
|
||||
// Panel should mount and fetch detail data
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("node-detail-panel")).toBeDefined();
|
||||
expect(screen.getAllByText("dev-impl@test-rig").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("/nodes/dev.impl"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeDetailPanel", () => {
|
||||
beforeEach(() => mockFetch.mockReset());
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function mockDetail(detail: Record<string, unknown>) {
|
||||
mockFetch.mockResolvedValue({ ok: true, json: async () => detail });
|
||||
}
|
||||
|
||||
// Test 1: Panel renders with correct identity fields
|
||||
it("renders identity fields from fetched detail", async () => {
|
||||
mockDetail(AGENT_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("dev-impl@test-rig").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("claude-code")).toBeDefined();
|
||||
expect(screen.getAllByText("impl").length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.queryByText("Full Name")).toBeNull();
|
||||
});
|
||||
|
||||
// Test 2: Panel shows startup status with correct color
|
||||
it("shows startup status", async () => {
|
||||
mockDetail(AGENT_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
const status = screen.getByTestId("detail-startup-status");
|
||||
expect(status.textContent).toBe("ready");
|
||||
});
|
||||
});
|
||||
|
||||
// Test 3: Copy tmux attach button present
|
||||
it("shows Copy tmux attach action", async () => {
|
||||
mockDetail(AGENT_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-copy-attach")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test 4: Failed node shows error
|
||||
it("shows error prominently for failed node", async () => {
|
||||
mockDetail(FAILED_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-failure-banner")).toBeDefined();
|
||||
expect(screen.getByText("harness launch timeout after 30s")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test PNS-T10: restoreOutcome is prominently displayed
|
||||
it("shows restoreOutcome prominently with color class", async () => {
|
||||
mockDetail({ ...AGENT_DETAIL, restoreOutcome: "resumed" });
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
const el = screen.getByTestId("detail-restore-outcome");
|
||||
expect(el).toBeDefined();
|
||||
expect(el.textContent).toBe("resumed");
|
||||
expect(el.className).toContain("text-success");
|
||||
});
|
||||
});
|
||||
|
||||
// Test PNS-T10: failure banner includes actionable guidance
|
||||
it("failure banner includes actionable next-step guidance", async () => {
|
||||
mockDetail(FAILED_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
const banner = screen.getByTestId("detail-failure-banner");
|
||||
expect(banner.textContent).toContain("rig ps");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows structured recovery guidance for failed runtime nodes", async () => {
|
||||
mockDetail(FAILED_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-recovery-guidance")).toBeDefined();
|
||||
expect(screen.getByText("Try native Claude resume first, then fall back to the workspace-local picker if needed.")).toBeDefined();
|
||||
expect(screen.getByText("claude --resume abc-123")).toBeDefined();
|
||||
expect(screen.getByText("cd /workspace")).toBeDefined();
|
||||
expect(screen.getByText("claude --resume")).toBeDefined();
|
||||
expect(screen.getByText(/Choose the full conversation option, not summary/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an attention banner for recoverable startup blockers", async () => {
|
||||
mockDetail(ATTENTION_REQUIRED_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-failure-banner")).toBeDefined();
|
||||
expect(screen.getByText("Attention Required")).toBeDefined();
|
||||
expect(screen.getByText(/rig capture/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test 5: Infrastructure node simplified
|
||||
it("infrastructure node shows startup command, no profile", async () => {
|
||||
mockDetail(INFRA_DETAIL);
|
||||
renderPanel({ logicalId: "infra.server" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("npm run dev")).toBeDefined();
|
||||
// No agent spec or profile for infra nodes
|
||||
expect(screen.queryByText("Agent Spec")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Test 6: Resume command shown when exists
|
||||
it("shows resume command when available", async () => {
|
||||
mockDetail(AGENT_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-copy-resume")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test 7: Resume command hidden when no token
|
||||
it("hides resume command when not available", async () => {
|
||||
mockDetail({ ...AGENT_DETAIL, resumeCommand: null });
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-copy-attach")).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByTestId("detail-copy-resume")).toBeNull();
|
||||
});
|
||||
|
||||
// Test 8: Startup files displayed
|
||||
it("shows startup files list", async () => {
|
||||
mockDetail(AGENT_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/role\.md/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Task 5: New drawer sections
|
||||
it("shows peers section from node detail", async () => {
|
||||
mockDetail(AGENT_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-peers")).toBeDefined();
|
||||
expect(screen.getAllByText("dev.qa").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("codex")).toBeDefined();
|
||||
expect(screen.getByText("dev-qa@test-rig")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows edges section from node detail", async () => {
|
||||
mockDetail(AGENT_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-edges")).toBeDefined();
|
||||
expect(screen.getByText("delegates_to")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows transcript section with tail command", async () => {
|
||||
mockDetail(AGENT_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-transcript")).toBeDefined();
|
||||
expect(screen.getByText("Copy tail command")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows compact spec section from node detail", async () => {
|
||||
mockDetail(AGENT_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-compact-spec")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Open Full Details button", async () => {
|
||||
mockDetail(AGENT_DETAIL);
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-open-full")).toBeDefined();
|
||||
expect(screen.getByText("Open Full Details")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("Open CMUX button is present for unbound nodes, calls /open-cmux not /focus", async () => {
|
||||
// Use unbound node detail (no cmuxSurface in binding)
|
||||
const unboundDetail = { ...AGENT_DETAIL, binding: { tmuxSession: "dev-impl@test-rig" } };
|
||||
mockFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
const u = typeof url === "string" ? url : "";
|
||||
if (u.includes("/open-cmux") && init?.method === "POST") {
|
||||
return { ok: true, json: async () => ({ ok: true, action: "created_new" }) };
|
||||
}
|
||||
return { ok: true, json: async () => unboundDetail };
|
||||
});
|
||||
|
||||
renderPanel();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-cmux-open")).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByTestId("detail-cmux-focus")).toBeNull();
|
||||
|
||||
// Button labeled "Open CMUX"
|
||||
expect(screen.getByText("Open CMUX")).toBeDefined();
|
||||
|
||||
// Click and verify fetch target
|
||||
fireEvent.click(screen.getByTestId("detail-cmux-open"));
|
||||
|
||||
await waitFor(() => {
|
||||
const openCmuxCall = (mockFetch as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(c: unknown[]) => typeof c[0] === "string" && (c[0] as string).includes("/open-cmux")
|
||||
);
|
||||
expect(openCmuxCall).toBeDefined();
|
||||
expect(openCmuxCall![0]).toContain("/open-cmux");
|
||||
expect(openCmuxCall![0]).not.toContain("/focus");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,92 +1,114 @@
|
||||
// V1 attempt-3 Phase 5 P5-4 — negative-assertion regression guard (ritual #8).
|
||||
// V1 polish slice Phase 5.1 P5.1-1 + DRIFT P5.1-D2 — drawer-as-seat-detail
|
||||
// retirement regression guard (negative-assertion ritual #8).
|
||||
//
|
||||
// The legacy 'node' kind was retired from DrawerSelection union in P5-4. The
|
||||
// canonical 'seat-detail' kind (Phase 4) now covers all seat-detail surfaces
|
||||
// (graph node click via useNodeSelection alias; Explorer seat-leaf click; any
|
||||
// SeatDetailTrigger-wrapped surface). SeatDetailViewer wraps NodeDetailPanel
|
||||
// inside the canonical 38rem drawer chrome — fixes the founder-noticed
|
||||
// "graph node click → full-width panel with empty whitespace on left" bug
|
||||
// that was caused by the legacy 'node' branch routing NodeDetailPanel
|
||||
// directly past the drawer chrome's width-coupling.
|
||||
// Originally Phase 5 P5-4 negative-assertion that the legacy 'node' kind
|
||||
// had been retired from DrawerSelection in favor of 'seat-detail'. At V1
|
||||
// polish Phase 5.1, founder direction supersedes the seat-detail-as-drawer
|
||||
// pattern entirely: graph + tree + table all navigate to the canonical
|
||||
// /topology/seat/$rigId/$logicalId center page (LiveNodeDetails). The
|
||||
// drawer surface is reserved for content viewers (qitem / file / sub-spec)
|
||||
// per content-drawer.md L23-L34.
|
||||
//
|
||||
// This test is the source-assertion guard against re-introduction of the
|
||||
// 'node' discriminator (parallel to the P5-1 SC-20 source-assertion + the
|
||||
// rig-graph pod-group negative-assertion already in place).
|
||||
// This file converts the prior P5-4 negative-assertion into the broader
|
||||
// P5.1-D2 retirement guard:
|
||||
// - NodeDetailPanel.tsx file does NOT exist
|
||||
// - SeatDetailViewer.tsx file does NOT exist
|
||||
// - SeatDetailTrigger.tsx file does NOT exist
|
||||
// - 'seat-detail' kind absent from DrawerSelection union
|
||||
// - 'node' kind absent from DrawerSelection union (preserved from P5-4)
|
||||
// - useNodeSelection function absent from AppShell (alias retired)
|
||||
// - RigGraph node click uses useNavigate (not setSelection / not
|
||||
// setSelectedNode)
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const SHARED_DETAIL_DRAWER_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../src/components/SharedDetailDrawer.tsx",
|
||||
);
|
||||
const APP_SHELL_PATH = path.resolve(__dirname, "../src/components/AppShell.tsx");
|
||||
const EXPLORER_PATH = path.resolve(__dirname, "../src/components/Explorer.tsx");
|
||||
const SRC = path.resolve(__dirname, "../src");
|
||||
const SHARED_DETAIL_DRAWER_PATH = path.join(SRC, "components/SharedDetailDrawer.tsx");
|
||||
const APP_SHELL_PATH = path.join(SRC, "components/AppShell.tsx");
|
||||
const RIG_GRAPH_PATH = path.join(SRC, "components/RigGraph.tsx");
|
||||
const NODE_DETAIL_PANEL_PATH = path.join(SRC, "components/NodeDetailPanel.tsx");
|
||||
const SEAT_DETAIL_VIEWER_PATH = path.join(SRC, "components/drawer-viewers/SeatDetailViewer.tsx");
|
||||
const SEAT_DETAIL_TRIGGER_PATH = path.join(SRC, "components/drawer-triggers/SeatDetailTrigger.tsx");
|
||||
|
||||
describe("P5-4: legacy 'node' kind retired from DrawerSelection (negative-assertion)", () => {
|
||||
it("SharedDetailDrawer.tsx DrawerSelection union does NOT contain 'node' kind", () => {
|
||||
describe("P5.1-D2 retirement regression: drawer-as-seat-detail removed", () => {
|
||||
it("NodeDetailPanel.tsx file does NOT exist (component retired)", () => {
|
||||
expect(existsSync(NODE_DETAIL_PANEL_PATH)).toBe(false);
|
||||
});
|
||||
|
||||
it("SeatDetailViewer.tsx file does NOT exist (drawer wrapper retired)", () => {
|
||||
expect(existsSync(SEAT_DETAIL_VIEWER_PATH)).toBe(false);
|
||||
});
|
||||
|
||||
it("SeatDetailTrigger.tsx file does NOT exist (trigger primitive retired)", () => {
|
||||
expect(existsSync(SEAT_DETAIL_TRIGGER_PATH)).toBe(false);
|
||||
});
|
||||
|
||||
it("DrawerSelection union does NOT contain 'seat-detail' kind", () => {
|
||||
const src = readFileSync(SHARED_DETAIL_DRAWER_PATH, "utf8");
|
||||
// Find the DrawerSelection union declaration block.
|
||||
const unionMatch = src.match(/export type DrawerSelection =[\s\S]*?\| null;/);
|
||||
expect(unionMatch).not.toBeNull();
|
||||
const unionBlock = unionMatch![0];
|
||||
// Negative-assertion: '{ type: "node"' must NOT appear in the union.
|
||||
expect(unionBlock).not.toMatch(/\{\s*type:\s*["']seat-detail["']/);
|
||||
});
|
||||
|
||||
it("DrawerSelection union does NOT contain legacy 'node' kind (P5-4 preserved)", () => {
|
||||
const src = readFileSync(SHARED_DETAIL_DRAWER_PATH, "utf8");
|
||||
const unionMatch = src.match(/export type DrawerSelection =[\s\S]*?\| null;/);
|
||||
const unionBlock = unionMatch![0];
|
||||
expect(unionBlock).not.toMatch(/\{\s*type:\s*["']node["']/);
|
||||
// Positive companion: 'seat-detail' kind IS present (canonical surface).
|
||||
expect(unionBlock).toMatch(/\{\s*type:\s*["']seat-detail["']/);
|
||||
});
|
||||
|
||||
it("SharedDetailDrawer.tsx routing does NOT branch on selection.type === 'node'", () => {
|
||||
it("SharedDetailDrawer routing has NO 'seat-detail' branch", () => {
|
||||
const src = readFileSync(SHARED_DETAIL_DRAWER_PATH, "utf8");
|
||||
// Negative-assertion: legacy `if (selection.type === "node")` routing branch
|
||||
// must be gone (it routed directly to NodeDetailPanel past the drawer chrome).
|
||||
expect(src).not.toMatch(/selection\.type\s*===\s*["']seat-detail["']/);
|
||||
expect(src).not.toMatch(/selection\.type\s*===\s*["']node["']/);
|
||||
// Positive companion: 'seat-detail' branch still routes via SeatDetailViewer.
|
||||
expect(src).toMatch(/selection\.type\s*===\s*["']seat-detail["']/);
|
||||
});
|
||||
|
||||
it("SharedDetailDrawer.tsx no longer imports NodeDetailPanel directly (uses SeatDetailViewer wrapper)", () => {
|
||||
const src = readFileSync(SHARED_DETAIL_DRAWER_PATH, "utf8");
|
||||
// After P5-4, NodeDetailPanel reaches drawer ONLY through SeatDetailViewer
|
||||
// (which is the canonical wrapper). SharedDetailDrawer.tsx imports
|
||||
// SeatDetailViewer, NOT NodeDetailPanel directly.
|
||||
expect(src).not.toMatch(/import\s*\{\s*NodeDetailPanel\s*\}\s*from\s*["']\.\/NodeDetailPanel\.js["']/);
|
||||
expect(src).toMatch(/import\s*\{\s*SeatDetailViewer\s*\}/);
|
||||
});
|
||||
|
||||
it("AppShell.tsx useNodeSelection alias coerces to/from 'seat-detail' (NOT 'node')", () => {
|
||||
it("AppShell.tsx no longer exports useNodeSelection function (alias retired)", () => {
|
||||
const src = readFileSync(APP_SHELL_PATH, "utf8");
|
||||
// Find useNodeSelection function block.
|
||||
const fnMatch = src.match(/export function useNodeSelection\(\)[\s\S]*?^\}/m);
|
||||
expect(fnMatch).not.toBeNull();
|
||||
const fnBlock = fnMatch![0];
|
||||
// Negative-assertion: legacy 'node' kind reads/writes are gone.
|
||||
expect(fnBlock).not.toMatch(/selection\??\.type\s*===\s*["']node["']/);
|
||||
expect(fnBlock).not.toMatch(/type:\s*["']node["']/);
|
||||
// Positive companion: alias now uses 'seat-detail'.
|
||||
expect(fnBlock).toMatch(/selection\??\.type\s*===\s*["']seat-detail["']/);
|
||||
expect(fnBlock).toMatch(/type:\s*["']seat-detail["']/);
|
||||
// Strip comments first so historical mentions don't trigger.
|
||||
const codeOnly = src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/[^\n]*\n/gm, "");
|
||||
expect(codeOnly).not.toMatch(/export\s+function\s+useNodeSelection\s*\(/);
|
||||
});
|
||||
|
||||
it("Explorer.tsx seat-leaf selection state + onClick use 'seat-detail' kind (NOT 'node')", () => {
|
||||
const src = readFileSync(EXPLORER_PATH, "utf8");
|
||||
// The DrawerSelection 'node' kind appears in two distinct contexts in this
|
||||
// file:
|
||||
// 1) Pod-leaf seat selection (PodBranch + ungrouped pod fallback).
|
||||
// 2) Auto-expand predicate at the rig branch level.
|
||||
// All three should be migrated to 'seat-detail'.
|
||||
//
|
||||
// Negative-assertion (file-wide): no remaining `{ type: "node"` literal.
|
||||
// (DiscoveryPlacementTarget.kind === "node" is a SEPARATE namespace —
|
||||
// discovery placement, not DrawerSelection — and is allowed elsewhere; but
|
||||
// Explorer.tsx itself should not contain DrawerSelection's 'node' kind.)
|
||||
expect(src).not.toMatch(/\{\s*type:\s*["']node["']/);
|
||||
// selection?.type === "node" must be gone too.
|
||||
expect(src).not.toMatch(/selection\?\.type\s*===\s*["']node["']/);
|
||||
// Positive companion: 'seat-detail' is the new discriminator at all
|
||||
// 3 spots (count is at least 3; 2 onClick + 1 isSelected predicates).
|
||||
const seatDetailMatches = src.match(/["']seat-detail["']/g) ?? [];
|
||||
expect(seatDetailMatches.length).toBeGreaterThanOrEqual(3);
|
||||
it("RigGraph uses useNavigate (NOT setSelectedNode / NOT setSelection seat-detail)", () => {
|
||||
const src = readFileSync(RIG_GRAPH_PATH, "utf8");
|
||||
const codeOnly = src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/[^\n]*\n/gm, "");
|
||||
// Positive: useNavigate import + call.
|
||||
expect(src).toMatch(/import\s*\{[^}]*useNavigate[^}]*\}\s*from\s*["']@tanstack\/react-router["']/);
|
||||
expect(codeOnly).toMatch(/useNavigate\s*\(\s*\)/);
|
||||
// Negative: setSelectedNode + 'seat-detail' setSelection patterns gone.
|
||||
expect(codeOnly).not.toMatch(/setSelectedNode/);
|
||||
expect(codeOnly).not.toMatch(/setSelection\s*\(\s*\{\s*type:\s*["']seat-detail["']/);
|
||||
});
|
||||
|
||||
it("LiveNodeDetails.tsx renders the 5-tab body row (single canonical surface per DRIFT P5.1-D1)", () => {
|
||||
const src = readFileSync(path.join(SRC, "components/LiveNodeDetails.tsx"), "utf8");
|
||||
// Tab union literal carries all 5 canonical tab keys.
|
||||
expect(src).toMatch(/type\s+Tab\s*=\s*"identity"\s*\|\s*"agent-spec"\s*\|\s*"startup"\s*\|\s*"transcript"\s*\|\s*"terminal"/);
|
||||
// FileReferenceTrigger wraps startup files (P5.1-1a).
|
||||
expect(src).toMatch(/import\s*\{[^}]*FileReferenceTrigger[^}]*\}\s*from/);
|
||||
expect(src).toMatch(/<FileReferenceTrigger/);
|
||||
});
|
||||
|
||||
it("SeatScopePage drops outer tabs and mounts LiveNodeDetails directly (DRIFT P5.1-D1)", () => {
|
||||
const src = readFileSync(path.join(SRC, "components/topology/ScopePages.tsx"), "utf8");
|
||||
// Locate the SeatScopePage function body specifically (not the
|
||||
// file-wide imports which still reference the SEAT_SCOPE_TABS
|
||||
// constant for other surfaces).
|
||||
const seatFn = src.match(/export function SeatScopePage\(\)\s*\{[\s\S]*?^}/m);
|
||||
expect(seatFn).not.toBeNull();
|
||||
const body = seatFn![0];
|
||||
// No outer ScopeShell tabsNav for seat scope; LiveNodeDetails mounts
|
||||
// as the page body.
|
||||
expect(body).not.toMatch(/SEAT_SCOPE_TABS/);
|
||||
expect(body).toMatch(/<LiveNodeDetails\s+rigId=/);
|
||||
});
|
||||
});
|
||||
@@ -108,23 +108,25 @@ describe("TopologyTableView P0-1 regression: no rules-of-hooks crash on first-re
|
||||
// P0-2: NodeDetailPanel fill-parent regression — no self-pinning.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("NodeDetailPanel P0-2 regression: fills parent (no absolute self-pinning)", () => {
|
||||
it("source uses 'relative w-full h-full' on the root <aside> (NOT 'absolute inset-y-0 right-0 w-80')", () => {
|
||||
const src = readFileSync(
|
||||
path.resolve(__dirname, "../src/components/NodeDetailPanel.tsx"),
|
||||
describe("NodeDetailPanel P0-2 regression: drawer fill-parent guard", () => {
|
||||
// V1 polish slice Phase 5.1 P5.1-D2: NodeDetailPanel.tsx is fully
|
||||
// RETIRED at V1 polish; the canonical agent-detail surface is now
|
||||
// LiveNodeDetails.tsx (center page). The original P0-2 ship-gate
|
||||
// bounce regression guarded NodeDetailPanel's drawer fill-parent
|
||||
// layout; now that the file is gone, the guard becomes the
|
||||
// file-doesn't-exist assertion (which lives in
|
||||
// node-selection-migration.test.tsx). This block converts to a
|
||||
// companion source-assertion on LiveNodeDetails: the canonical
|
||||
// surface must NOT regress into legacy absolute self-pinning.
|
||||
it("LiveNodeDetails.tsx does not regress into legacy 'absolute inset-y-0 right-0 w-80' self-pinning", async () => {
|
||||
const liveSrc = readFileSync(
|
||||
path.resolve(__dirname, "../src/components/LiveNodeDetails.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
// Strip comments so historical mentions of the legacy class string
|
||||
// (preserved in the explanatory comment block) don't trigger the
|
||||
// negative assertion.
|
||||
const codeOnly = src
|
||||
const codeOnly = liveSrc
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/[^\n]*\n/gm, "");
|
||||
// Negative-assertion: legacy `absolute inset-y-0 right-0 ... w-80`
|
||||
// pinning class string MUST NOT survive in code.
|
||||
expect(codeOnly).not.toMatch(/absolute\s+inset-y-0\s+right-0/);
|
||||
expect(codeOnly).not.toMatch(/\bw-80\b/);
|
||||
// Positive companion: fill-parent layout present on the root <aside>.
|
||||
expect(codeOnly).toMatch(/relative\s+w-full\s+h-full/);
|
||||
});
|
||||
});
|
||||
@@ -156,47 +156,12 @@ describe("FeedCard P5-1 wiring: show-context QueueItemTrigger", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// TopologyTreeView SeatLeaf "details" icon → SeatDetailTrigger → drawer.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Render the SeatLeaf component directly (it's local-scope; we test via the
|
||||
// trigger primitive contract). To exercise inside the tree we'd need the full
|
||||
// TanStack Router + useRigSummary mocks; the per-affordance contract is
|
||||
// equivalent and is what ritual #6 verifies.
|
||||
|
||||
import { SeatDetailTrigger } from "../src/components/drawer-triggers/SeatDetailTrigger.js";
|
||||
|
||||
describe("TopologyTreeView P5-1 wiring: SeatLeaf details icon", () => {
|
||||
it("clicking SeatDetailTrigger fires setSelection({ type: 'seat-detail', rigId, logicalId })", () => {
|
||||
const { setSelection, getByTestId } = renderWithDrawerCtx(
|
||||
<SeatDetailTrigger rigId="rig-1" logicalId="orch.lead" testId="seat-leaf-test">
|
||||
details
|
||||
</SeatDetailTrigger>,
|
||||
);
|
||||
fireEvent.click(getByTestId("seat-leaf-test"));
|
||||
expect(setSelection).toHaveBeenCalledWith({
|
||||
type: "seat-detail",
|
||||
rigId: "rig-1",
|
||||
logicalId: "orch.lead",
|
||||
});
|
||||
});
|
||||
|
||||
it("TopologyTreeView source contains SeatDetailTrigger import + PanelRightOpen icon (CSS-source-assertion ritual #1+#9)", async () => {
|
||||
// This is a doctrine-compliance / coupled-literal regression scan
|
||||
// (ritual #9): confirm the canonical source pattern is in place; if
|
||||
// someone strips it out in a refactor, this test breaks.
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const src = readFileSync(
|
||||
path.resolve(__dirname, "../src/components/topology/TopologyTreeView.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toMatch(/import\s*\{\s*SeatDetailTrigger\s*\}/);
|
||||
expect(src).toMatch(/<SeatDetailTrigger\s+rigId=/);
|
||||
expect(src).toMatch(/PanelRightOpen/); // lucide details-icon glyph
|
||||
});
|
||||
});
|
||||
// V1 polish slice Phase 5.1 P5.1-D2: TopologyTreeView SeatLeaf details
|
||||
// icon RETIRED. SeatDetailTrigger primitive RETIRED. SeatLeaf is now
|
||||
// Link-only navigation to /topology/seat/$rigId/$logicalId. Retirement-
|
||||
// regression guard lives in test/node-selection-migration.test.tsx
|
||||
// (file-doesn't-exist for SeatDetailTrigger.tsx + SharedDetailDrawer
|
||||
// has no 'seat-detail' kind).
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RigSpecDisplay pod-member agentRef cell → SubSpecTrigger → drawer.
|
||||
|
||||
@@ -1493,24 +1493,27 @@ describe("RigGraph discovery integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// NS-T12: graph selection — clicking a graph node sets shared selection
|
||||
import { DrawerSelectionContext } from "../src/components/AppShell.js";
|
||||
// NS-T12: graph selection — clicking a graph node navigates to the
|
||||
// canonical agent-detail center page (LiveNodeDetails).
|
||||
//
|
||||
// V1 polish slice Phase 5.1 P5.1-2 + DRIFT P5.1-D2: graph node click
|
||||
// migrated from setSelection({type:'seat-detail'}) drawer-open to
|
||||
// useNavigate(/topology/seat/$rigId/$logicalId). Parity with Explorer
|
||||
// tree click + topology table row click. The test asserts useNavigate
|
||||
// was invoked with the canonical seat URL.
|
||||
|
||||
describe("RigGraph node selection", () => {
|
||||
// V1 attempt-3 Phase 5 P5-4: legacy 'node' kind retired; useNodeSelection
|
||||
// now coerces through 'seat-detail' kind so SeatDetailViewer (Phase 4)
|
||||
// wraps NodeDetailPanel inside the canonical 38rem drawer chrome.
|
||||
it("click node -> setSelection({ type: 'seat-detail', rigId, logicalId }) called via shared context", async () => {
|
||||
const setSelectionSpy = vi.fn();
|
||||
const navigateSpy = vi.fn();
|
||||
vi.mock("@tanstack/react-router", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@tanstack/react-router")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => navigateSpy,
|
||||
};
|
||||
});
|
||||
|
||||
// Wrap RigGraph in selection context with spy
|
||||
function SelectionWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DrawerSelectionContext.Provider value={{ selection: null, setSelection: setSelectionSpy }}>
|
||||
{children}
|
||||
</DrawerSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
describe("RigGraph node selection (P5.1-2 navigate)", () => {
|
||||
it("click node -> navigate({ to: '/topology/seat/$rigId/$logicalId', params })", async () => {
|
||||
navigateSpy.mockClear();
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(mockGraphResponse(sampleNodes(), sampleEdges()))
|
||||
@@ -1518,9 +1521,7 @@ describe("RigGraph node selection", () => {
|
||||
|
||||
const { container } = render(
|
||||
<QueryWrapper>
|
||||
<SelectionWrapper>
|
||||
<RigGraph showDiscovered={false} rigId="rig-1" />
|
||||
</SelectionWrapper>
|
||||
<RigGraph showDiscovered={false} rigId="rig-1" />
|
||||
</QueryWrapper>
|
||||
);
|
||||
|
||||
@@ -1534,15 +1535,13 @@ describe("RigGraph node selection", () => {
|
||||
node.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// P5-4: setSelection should be called with 'seat-detail' kind (canonical
|
||||
// drawer-viewer pattern; SeatDetailViewer wraps NodeDetailPanel inside
|
||||
// 38rem VellumSheet chrome).
|
||||
await waitFor(() => {
|
||||
expect(setSelectionSpy).toHaveBeenCalledWith({
|
||||
type: "seat-detail",
|
||||
rigId: "rig-1",
|
||||
logicalId: "orchestrator",
|
||||
});
|
||||
expect(navigateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/topology/seat/$rigId/$logicalId",
|
||||
params: { rigId: "rig-1", logicalId: encodeURIComponent("orchestrator") },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user