mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
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:
1 parent
f1b91ff642
commit
ec2e13bd52
5 files changed
+119
-21
No files matched your search
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user