shell-redesign V1 attempt-3 Phase 5 P5-7: topology terminal pagination + pulsing-ring

Per topology-terminal-view.md L13-L80: real pinned-card grid replaces the
TerminalGridPlaceholder for host / rig / pod scopes. V1 reuses preserved
SessionPreviewPane primitive (per code-map AFTER tree); V2 replaces with
xterm.js + tmux-attach (out of V1 scope).

NEW packages/ui/src/components/topology/TopologyTerminalView.tsx:
- TopologyTerminalView({ scope: "host"|"rig"|"pod", rigId?, podName? }).
- Host scope: iterates useRigSummary rigs, renders one RigTerminalSection
  per rig (each section uses useNodeInventory).
- Rig + Pod scopes: single useNodeInventory(rigId); pod filters by
  podNamespace/podId match against podName.
- TerminalGrid component with safe-N=12 pagination per L70-L80
  ("showing 12 of N · show all N" toggle).
- SeatTerminalCard wraps each seat in border + SessionPreviewPane;
  active-state seats (agentActivity.state === "running") get
  data-active='true' + .terminal-card-active class for pulsing-ring.
- Responsive grid: 1 col mobile / 2 cols md / 3 cols lg.

ADDED globals.css @keyframes terminal-card-active-frames + selector per
L47 LOAD-BEARING pulsing-ring contract. 2.2s ease-in-out infinite;
secondary-purple halo (rgba(167, 139, 250, 0.30) at 50%; transparent at
0/100%). Honored by prefers-reduced-motion: reduce block.

REFACTORED ScopePages.tsx: HostScopePage / RigScopePage / PodScopePage
'terminal' tab now mounts <TopologyTerminalView scope=... />; old
TerminalGridPlaceholder removed.

NEW packages/ui/test/p5-topology-terminal.test.tsx (8 tests):
- Rig scope grid renders one card per agent seat; data-active +
  terminal-card-active class on running seats; idle seats neutral.
- safe-N pagination: 15 seats → 12 shown by default; toggle reveals all.
- Pod scope filters seats by podName.
- Empty states (no seats / no rigs).
- CSS-source-assertion (ritual #7 pseudo-element-paint contract):
  globals.css declares @keyframes terminal-card-active-frames +
  .terminal-card-active selector + reduced-motion suppression.

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-topology-terminal: 8/8 pass
- pnpm -F @openrig/ui build: clean

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:49:31 -07:00
1 parent 4e67cf660c
commit 3981ad187e
4 files changed
+426 -15

No files matched your search

@@ -17,6 +17,7 @@ import {
type TopologySeatScopeTab,
} from "./TopologyViewModeTabs.js";
import { TopologyTableView } from "./TopologyTableView.js";
import { TopologyTerminalView } from "./TopologyTerminalView.js";
import { SectionHeader } from "../ui/section-header.js";
import { EmptyState } from "../ui/empty-state.js";
import { RigGraph } from "../RigGraph.js";
@@ -78,18 +79,9 @@ function ScopeShell({
);
}
function TerminalGridPlaceholder({ scope }: { scope: string }) {
return (
<div className="p-6">
<EmptyState
label="TERMINAL GRID"
description={`${scope} terminals — pinned-card grid (Phase 5 polish; safe-N pagination per topology-terminal-view.md L70-80).`}
variant="card"
testId="topology-terminal-placeholder"
/>
</div>
);
}
// V1 attempt-3 Phase 5 P5-7: TopologyTerminalView replaces the placeholder
// with a real safe-N=12 paginated pinned-card grid + pulsing-ring on active
// terminals (per topology-terminal-view.md L47/L60-65/L70-80).
export function HostScopePage() {
const [active, setActive] = useState<TopologyHostScopeTab>("graph");
@@ -122,7 +114,7 @@ export function HostScopePage() {
<TopologyTableView />
</div>
) : null}
{active === "terminal" ? <TerminalGridPlaceholder scope="Host" /> : null}
{active === "terminal" ? <TopologyTerminalView scope="host" /> : null}
</ScopeShell>
);
}
@@ -150,7 +142,7 @@ export function RigScopePage() {
<TopologyTableView rigIdScope={rigId} />
</div>
) : null}
{active === "terminal" ? <TerminalGridPlaceholder scope="Rig" /> : null}
{active === "terminal" ? <TopologyTerminalView scope="rig" rigId={rigId} /> : null}
{active === "overview" ? (
<div className="p-6">
<EmptyState
@@ -186,7 +178,9 @@ export function PodScopePage() {
<TopologyTableView rigIdScope={rigId} />
</div>
) : null}
{active === "terminal" ? <TerminalGridPlaceholder scope="Pod" /> : null}
{active === "terminal" ? (
<TopologyTerminalView scope="pod" rigId={rigId} podName={podName} />
) : null}
{active === "overview" ? (
<div className="p-6">
<EmptyState label="POD OVERVIEW" description="Pod detail (Phase 5)." variant="card" />
@@ -0,0 +1,206 @@
// V1 attempt-3 Phase 5 P5-7 — Topology terminal grid view.
//
// Per topology-terminal-view.md L13-L80: pinned-card grid where each card
// is a transcript-tail preview of an agent's terminal output. V1 reuses
// the existing SessionPreviewPane primitive (preserved per code-map AFTER
// tree). V2 ships interactive xterm.js + tmux-attach (out of scope here).
//
// Key behaviors (canon-load-bearing):
// - Pulsing-ring on cards whose agent is "running" (active recently)
// per topology-terminal-view.md L47 — at-a-glance scan signal
// ("humans must be able to spot which agents to attend to without
// reading every card"). CSS keyframe is pseudo-element-paint
// (jsdom-incompatible) so a CSS-source-assertion test guards it.
// - safe-N pagination at host scope (L70-L80): default 12 cards;
// "show all N" toggle for operators who need the full grid.
// - Polling cadence: SessionPreviewPane uses useSessionPreview which
// respects the configured ui.preview.refresh_interval_seconds
// (default 3s). Per L60-L65, larger scopes = lower frequency; the
// existing config covers it.
import { useMemo, useState } from "react";
import { useRigSummary } from "../../hooks/useRigSummary.js";
import { useNodeInventory, type NodeInventoryEntry } from "../../hooks/useNodeInventory.js";
import { displayAgentName } from "../../lib/display-name.js";
import { SessionPreviewPane } from "../preview/SessionPreviewPane.js";
import { EmptyState } from "../ui/empty-state.js";
import { SectionHeader } from "../ui/section-header.js";
import { cn } from "../../lib/utils.js";
const SAFE_N = 12;
interface TopologyTerminalViewProps {
scope: "host" | "rig" | "pod";
/** Required for rig + pod scopes. */
rigId?: string;
/** Required for pod scope. */
podName?: string;
}
function isActiveRunning(seat: NodeInventoryEntry): boolean {
// Per topology-terminal-view.md L47: pulsing-ring on cards for "active"
// agents (output emitted within polling window). The PL-019 agentActivity
// summary's "running" state is the closest available proxy.
return seat.agentActivity?.state === "running";
}
function SeatTerminalCard({ seat }: { seat: NodeInventoryEntry }) {
const sessionName = seat.canonicalSessionName ?? seat.logicalId;
const active = isActiveRunning(seat);
const memberName = displayAgentName(seat.logicalId);
return (
<div
data-testid={`terminal-card-${seat.rigId}-${seat.logicalId}`}
data-active={active ? "true" : "false"}
className={cn(
"border bg-white/40 p-2 flex flex-col gap-2",
active
? "border-secondary terminal-card-active"
: "border-outline-variant",
)}
>
<header className="flex items-center justify-between gap-2">
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.10em] text-stone-900 truncate">
{memberName}
</span>
<span className="font-mono text-[8px] uppercase tracking-wide text-on-surface-variant shrink-0">
{seat.runtime?.replace("claude-code", "claude") ?? "?"}
{typeof seat.contextUsage?.usedPercentage === "number"
? ` · ${seat.contextUsage.usedPercentage}%`
: ""}
</span>
</header>
<SessionPreviewPane
sessionName={sessionName}
lines={20}
testIdPrefix={`terminal-preview-${seat.rigId}-${seat.logicalId}`}
/>
</div>
);
}
function TerminalGrid({
seats,
emptyLabel,
emptyDescription,
}: {
seats: NodeInventoryEntry[];
emptyLabel: string;
emptyDescription: string;
}) {
const [showAll, setShowAll] = useState(false);
const visible = showAll ? seats : seats.slice(0, SAFE_N);
const hasMore = seats.length > SAFE_N;
if (seats.length === 0) {
return (
<EmptyState
label={emptyLabel}
description={emptyDescription}
variant="card"
testId="topology-terminal-empty"
/>
);
}
return (
<div data-testid="topology-terminal-grid" className="space-y-3">
<div className="font-mono text-[9px] text-on-surface-variant flex items-center justify-between">
<span data-testid="topology-terminal-count">
showing {visible.length} of {seats.length} terminal{seats.length === 1 ? "" : "s"}
</span>
{hasMore ? (
<button
type="button"
data-testid="topology-terminal-show-toggle"
onClick={() => setShowAll((s) => !s)}
className="px-2 py-0.5 border border-outline-variant font-mono text-[9px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60"
>
{showAll ? `show first ${SAFE_N}` : `show all ${seats.length}`}
</button>
) : null}
</div>
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{visible.map((seat) => (
<SeatTerminalCard
key={`${seat.rigId}-${seat.logicalId}`}
seat={seat}
/>
))}
</div>
</div>
);
}
function RigTerminalSection({ rigId, rigName }: { rigId: string; rigName: string }) {
const { data: nodes } = useNodeInventory(rigId);
const seats = useMemo(
() => (nodes ?? []).filter((n) => n.nodeKind !== "infrastructure"),
[nodes],
);
if (seats.length === 0) return null;
return (
<section
data-testid={`topology-terminal-rig-${rigId}`}
className="border-t border-outline-variant pt-4 first:border-t-0 first:pt-0"
>
<SectionHeader tone="muted">{rigName}</SectionHeader>
<div className="mt-2">
<TerminalGrid
seats={seats}
emptyLabel="NO SEATS"
emptyDescription={`No agent seats in ${rigName}.`}
/>
</div>
</section>
);
}
export function TopologyTerminalView({ scope, rigId, podName }: TopologyTerminalViewProps) {
const { data: rigs } = useRigSummary();
const { data: rigNodes } = useNodeInventory(scope !== "host" ? (rigId ?? null) : null);
if (scope === "host") {
if (!rigs || rigs.length === 0) {
return (
<div className="p-6">
<EmptyState
label="NO RIGS"
description="No rigs registered. Register a rig to see terminals at host scope."
variant="card"
testId="topology-terminal-empty"
/>
</div>
);
}
return (
<div data-testid="topology-terminal-host" className="p-6 space-y-6">
{rigs.map((r) => (
<RigTerminalSection key={r.id} rigId={r.id} rigName={r.name} />
))}
</div>
);
}
// Rig + Pod scopes share the same single-rig data source; pod filters by
// podName.
const seatsAll = (rigNodes ?? []).filter((n) => n.nodeKind !== "infrastructure");
const seats =
scope === "pod" && podName
? seatsAll.filter((s) => (s.podNamespace ?? s.podId) === podName)
: seatsAll;
return (
<div data-testid={`topology-terminal-${scope}`} className="p-6">
<TerminalGrid
seats={seats}
emptyLabel="NO SEATS"
emptyDescription={
scope === "pod"
? `No agent seats in pod ${podName}.`
: "No agent seats in this rig."
}
/>
</div>
);
}
+14
View File
@@ -265,6 +265,19 @@
animation: activity-pulse-running-frames 1.8s ease-in-out infinite;
}
/* V1 attempt-3 Phase 5 P5-7 — Topology terminal grid pulsing-ring per
topology-terminal-view.md L47. Cards for "active" agents (output
emitted within polling window) get a pulsing ring around the card;
idle cards are neutral. At-a-glance scan signal so the human can
spot which agents to attend to without reading every card. */
@keyframes terminal-card-active-frames {
0%, 100% { box-shadow: 0 0 0 0 rgba(167, 139, 250, 0.0); }
50% { box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.30); }
}
.terminal-card-active {
animation: terminal-card-active-frames 2.2s ease-in-out infinite;
}
/* PL-019 item 6 — edge animation. One-shot ~1.2s emphasis on edges
between source-seat and dest-seat when a queue event fires. React Flow
renders edges as SVG <path> wrapped in .react-flow__edge; we toggle
@@ -297,6 +310,7 @@
.shimmer,
.animate-pulse-tactical,
.activity-pulse-running,
.terminal-card-active,
.edge-just-fired {
animation: none;
opacity: 1;
@@ -0,0 +1,197 @@
// V1 attempt-3 Phase 5 P5-7 — Topology terminal grid (safe-N + pulsing-ring).
//
// Coverage:
// - safe-N pagination: with > 12 seats, only 12 cards render by default;
// "show all N" toggle reveals the rest.
// - Pulsing-ring: active seats render with terminal-card-active class
// (CSS keyframe is paint-only — pseudo-element-paint test contract
// ritual #7; CSS-source-assertion guards the @keyframes rule).
// - data-active attribute reflects activity state.
// - Empty state when no seats.
// - Pod scope filters by podName.
//
// Pseudo-element-paint contract: jsdom can't render @keyframes. The
// CSS-source-assertion test reads globals.css and asserts the
// @keyframes terminal-card-active-frames rule + .terminal-card-active
// selector are present — guards the at-a-glance scan signal from being
// silently removed in a refactor.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { readFileSync } from "node:fs";
import path from "node:path";
import { TopologyTerminalView } from "../src/components/topology/TopologyTerminalView.js";
import type { NodeInventoryEntry } from "../src/hooks/useNodeInventory.js";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
beforeEach(() => {
mockFetch.mockReset();
});
afterEach(() => {
cleanup();
});
function withQueryClient(ui: React.ReactNode) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
function makeSeat(opts: {
rigId?: string;
logicalId: string;
pod?: string;
active?: boolean;
}): NodeInventoryEntry {
return {
rigId: opts.rigId ?? "rig-1",
rigName: "test-rig",
logicalId: opts.logicalId,
podId: opts.pod ?? "default",
podNamespace: opts.pod ?? "default",
canonicalSessionName: `${opts.logicalId}@test-rig`,
nodeKind: "agent",
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready",
restoreOutcome: "n-a",
tmuxAttachCommand: null,
resumeCommand: null,
latestError: null,
agentActivity: opts.active
? {
state: "running",
reason: "test",
evidenceSource: "test",
sampledAt: "2026-05-06T18:00:00Z",
}
: { state: "idle", reason: "test", evidenceSource: "test", sampledAt: "2026-05-06T18:00:00Z" },
};
}
function setupFetch(opts: {
rigs?: Array<{ id: string; name: string }>;
seatsByRig?: Record<string, NodeInventoryEntry[]>;
}) {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/api/rigs/summary")) {
return new Response(JSON.stringify(opts.rigs ?? []));
}
const m = url.match(/\/api\/rigs\/([^/]+)\/nodes/);
if (m) {
const rigId = decodeURIComponent(m[1]!);
return new Response(JSON.stringify(opts.seatsByRig?.[rigId] ?? []));
}
// SessionPreviewPane fetches /api/preview/session/:name etc.
return new Response(JSON.stringify({
sessionName: "test", content: "", lines: 0, capturedAt: new Date().toISOString(),
}));
});
}
describe("TopologyTerminalView P5-7 grid", () => {
it("rig scope: renders one card per seat under safe-N=12", async () => {
const seats = [
makeSeat({ logicalId: "orch.lead" }),
makeSeat({ logicalId: "review.lead" }),
makeSeat({ logicalId: "driver.impl", active: true }),
];
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": seats } });
const { findByTestId, container } = withQueryClient(
<TopologyTerminalView scope="rig" rigId="rig-1" />,
);
expect(await findByTestId("topology-terminal-grid")).toBeTruthy();
expect(await findByTestId("terminal-card-rig-1-orch.lead")).toBeTruthy();
expect(await findByTestId("terminal-card-rig-1-driver.impl")).toBeTruthy();
// Active seat carries data-active='true' AND the pulse class.
const driverCard = container.querySelector(
"[data-testid='terminal-card-rig-1-driver.impl']",
);
expect(driverCard?.getAttribute("data-active")).toBe("true");
expect(driverCard?.className).toMatch(/terminal-card-active/);
// Idle seat does NOT carry the pulse class.
const idleCard = container.querySelector(
"[data-testid='terminal-card-rig-1-orch.lead']",
);
expect(idleCard?.getAttribute("data-active")).toBe("false");
expect(idleCard?.className ?? "").not.toMatch(/terminal-card-active/);
});
it("safe-N pagination: with > 12 seats, default shows 12; toggle reveals all", async () => {
const seats: NodeInventoryEntry[] = [];
for (let i = 0; i < 15; i++) {
seats.push(makeSeat({ logicalId: `seat${i.toString().padStart(2, "0")}` }));
}
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": seats } });
const { findByTestId, container } = withQueryClient(
<TopologyTerminalView scope="rig" rigId="rig-1" />,
);
await findByTestId("topology-terminal-grid");
// Default: 12 cards visible.
expect(
container.querySelectorAll("[data-testid^='terminal-card-rig-1-']").length,
).toBe(12);
expect((await findByTestId("topology-terminal-count")).textContent).toContain("12 of 15");
// Toggle: show all.
const toggle = await findByTestId("topology-terminal-show-toggle");
fireEvent.click(toggle);
await waitFor(() => {
expect(
container.querySelectorAll("[data-testid^='terminal-card-rig-1-']").length,
).toBe(15);
});
});
it("pod scope filters seats by podName", async () => {
const seats = [
makeSeat({ logicalId: "orch.lead", pod: "orch" }),
makeSeat({ logicalId: "driver.impl", pod: "implementation" }),
makeSeat({ logicalId: "qa.codex", pod: "implementation" }),
];
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": seats } });
const { findByTestId, container } = withQueryClient(
<TopologyTerminalView scope="pod" rigId="rig-1" podName="implementation" />,
);
await findByTestId("topology-terminal-grid");
expect(container.querySelector("[data-testid='terminal-card-rig-1-orch.lead']")).toBeNull();
expect(container.querySelector("[data-testid='terminal-card-rig-1-driver.impl']")).toBeTruthy();
expect(container.querySelector("[data-testid='terminal-card-rig-1-qa.codex']")).toBeTruthy();
});
it("empty state when scope has no agent seats", async () => {
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": [] } });
const { findByTestId } = withQueryClient(
<TopologyTerminalView scope="rig" rigId="rig-1" />,
);
expect(await findByTestId("topology-terminal-empty")).toBeTruthy();
});
it("host scope with no rigs renders empty state", async () => {
setupFetch({ rigs: [] });
const { findByTestId } = withQueryClient(<TopologyTerminalView scope="host" />);
expect(await findByTestId("topology-terminal-empty")).toBeTruthy();
});
});
describe("globals.css pulsing-ring CSS contract (ritual #7 pseudo-element-paint)", () => {
const cssPath = path.resolve(__dirname, "../src/globals.css");
it("globals.css declares @keyframes terminal-card-active-frames", () => {
const src = readFileSync(cssPath, "utf8");
expect(src).toMatch(/@keyframes\s+terminal-card-active-frames/);
});
it("globals.css declares .terminal-card-active selector binding the keyframes", () => {
const src = readFileSync(cssPath, "utf8");
expect(src).toMatch(/\.terminal-card-active\s*\{[^}]*animation:\s*terminal-card-active-frames/);
});
it("globals.css honors prefers-reduced-motion for terminal-card-active", () => {
const src = readFileSync(cssPath, "utf8");
// Reduced-motion block must include .terminal-card-active so the pulse
// animation is suppressed for users with motion-sensitivity preferences.
const reducedMotion = src.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\}\s*\}/);
expect(reducedMotion).not.toBeNull();
expect(reducedMotion![0]).toMatch(/\.terminal-card-active/);
});
});