shell-redesign V1 attempt-3 Phase 5 P5-4: NodeDetailPanel drawer migration

Closes founder-noticed coupling bug: graph node click previously rendered
NodeDetailPanel "full-width with empty whitespace on left" because the
legacy 'node' DrawerSelection branch routed NodeDetailPanel directly past
the drawer chrome's 38rem width-coupling. Migration option (a) per Phase 5
ACK §6: remove the parallel 'node' kind from the union and migrate every
callsite to the canonical 'seat-detail' kind (Phase 4) which renders via
SeatDetailViewer wrapping NodeDetailPanel inside the canonical 38rem
VellumSheet chrome.

Changes:
- SharedDetailDrawer.tsx: 'node' kind removed from DrawerSelection union;
  if (selection.type === 'node') routing branch deleted; NodeDetailPanel
  no longer imported directly (reaches drawer only via SeatDetailViewer).
- AppShell.tsx useNodeSelection: backward-compat alias coerces to/from
  'seat-detail' instead of 'node'; preserves the (selectedNode,
  setSelectedNode) API for RigGraph consumers without breaking imports.
- Explorer.tsx: 5 spots (PodBranch seat-leaf isSelected + onClick;
  ungrouped pod seat-leaf isSelected + onClick; RigBranch auto-expand
  predicate) all switch from 'node' to 'seat-detail'.
- rig-graph.test.tsx: existing "click node -> setSelection({type:'node'})"
  test updated to assert canonical 'seat-detail' shape.
- NEW test/node-selection-migration.test.tsx (5 source-assertion tests
  per ritual #8 negative-assertion): 'node' kind absent from union;
  routing branch absent; NodeDetailPanel direct-import absent;
  useNodeSelection alias uses 'seat-detail'; Explorer.tsx file-wide free
  of '{ type: "node"' literal (DiscoveryPlacementTarget.kind === "node"
  is a SEPARATE namespace and is preserved).

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest run on app-shell + rig-graph + drawer-primitives +
  node-selection-migration: 118/118 PASS
- Branch shell-redesign-v1-attempt-3-phase-5 off main f1b91ff

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-06 17:24:11 -07:00
1 parent f1b91ff642
commit ec2e13bd52
5 files changed
+119 -21

No files matched your search

+6 -2
View File
@@ -94,17 +94,21 @@ export function useDiscoveryPlacement() {
}
// 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.
export const NodeSelectionContext = DrawerSelectionContext;
export function useNodeSelection() {
const { selection, setSelection } = useDrawerSelection();
return {
selectedNode:
selection?.type === "node"
selection?.type === "seat-detail"
? { rigId: selection.rigId, logicalId: selection.logicalId }
: null,
setSelectedNode: (node: { rigId: string; logicalId: string } | null) =>
setSelection(
node ? { type: "node", rigId: node.rigId, logicalId: node.logicalId } : null,
node ? { type: "seat-detail", rigId: node.rigId, logicalId: node.logicalId } : null,
),
};
}
+5 -5
View File
@@ -222,14 +222,14 @@ function PodBranch({
{expanded && (
<div className="ml-4 border-l border-stone-200">
{sortedNodes.map((node) => {
const isSelected = selection?.type === "node" && selection.rigId === node.rigId && selection.logicalId === node.logicalId;
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: "node", rigId: node.rigId, logicalId: node.logicalId })}
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",
@@ -349,14 +349,14 @@ function RigBranch({
podId === "__ungrouped__" ? (
<div key={podId}>
{podNodes.map((node) => {
const isNodeSelected = selection?.type === "node" && selection.rigId === node.rigId && selection.logicalId === node.logicalId;
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: "node", rigId: node.rigId, logicalId: node.logicalId })}
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",
@@ -383,7 +383,7 @@ function RigBranch({
onSelect={onSelect}
autoExpand={
expanded || (
selection?.type === "node" &&
selection?.type === "seat-detail" &&
selection.rigId === rig.id &&
podNodes.some((node) => node.logicalId === selection.logicalId)
)
@@ -10,7 +10,6 @@
// retired (legacy auto-open right-sidebar pattern); rig clicks now
// navigate to /topology/rig/$rigId via URL (no drawer auto-open).
import { NodeDetailPanel } from "./NodeDetailPanel.js";
import { SystemPanel } from "./SystemPanel.js";
import { DiscoveryPanel, type DiscoveryPlacementTarget } from "./DiscoveryPanel.js";
import { VellumSheet } from "./ui/vellum-sheet.js";
@@ -20,8 +19,15 @@ import { SubSpecPreview, type SubSpecPreviewData } from "./drawer-viewers/SubSpe
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.
export type DrawerSelection =
| { type: "node"; rigId: string; logicalId: string }
| { type: "system"; tab?: "log" | "status" }
| { type: "discovery" }
// Phase 4 viewer kinds
@@ -54,15 +60,6 @@ export function SharedDetailDrawer({
if (!selection) return null;
const inner = (() => {
if (selection.type === "node") {
return (
<NodeDetailPanel
rigId={selection.rigId}
logicalId={selection.logicalId}
onClose={onClose}
/>
);
}
if (selection.type === "system") {
return (
<SystemPanel onClose={onClose} events={events} initialTab={selection.tab ?? "log"} />
@@ -0,0 +1,92 @@
// V1 attempt-3 Phase 5 P5-4 — negative-assertion regression guard (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.
//
// 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).
import { describe, it, expect } from "vitest";
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");
describe("P5-4: legacy 'node' kind retired from DrawerSelection (negative-assertion)", () => {
it("SharedDetailDrawer.tsx DrawerSelection union does NOT contain 'node' 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*["']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'", () => {
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*["']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')", () => {
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["']/);
});
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);
});
});
+8 -3
View File
@@ -1497,7 +1497,10 @@ describe("RigGraph discovery integration", () => {
import { DrawerSelectionContext } from "../src/components/AppShell.js";
describe("RigGraph node selection", () => {
it("click node -> setSelection({ type: 'node', rigId, logicalId }) called via shared context", async () => {
// 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();
// Wrap RigGraph in selection context with spy
@@ -1531,10 +1534,12 @@ describe("RigGraph node selection", () => {
node.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// setSelection should have been called with a node selection
// 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: "node",
type: "seat-detail",
rigId: "rig-1",
logicalId: "orchestrator",
});