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:
mvschwarzandClaude Opus 4.7 committed 2026-05-06 20:11:36 -07:00
1 parent 615baadfac
commit f3780c0656
17 files changed
+642 -1259

No files matched your search

+10 -18
View File
@@ -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
-301
View File
@@ -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
+323 -22
View File
@@ -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">&times;</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>
);
}
+16 -5
View File
@@ -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.
+5 -2
View File
@@ -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");
+17 -43
View File
@@ -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.
});
-331
View File
@@ -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=/);
});
});
+14 -12
View File
@@ -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/);
});
});
+6 -41
View File
@@ -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.
+26 -27
View File
@@ -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") },
}),
);
});
});
});