feat(topology): consolidate seat detail to Overview + Details

V0.3.1 slice 25 node-page Overview/Details consolidation.

The seat detail page at /topology/seat/$rigId/$logicalId restructures
from a 5-tab (identity / agent-spec / startup / transcript / terminal)
layout to a 2-tab (overview / details) layout. Overview is the
default and answers the at-a-glance questions operators ask when
landing from topology; Details holds everything else.

Tab structure changes:

- Default tab: overview (analogous to slice 12's project-scope
  default-tab flip from story to overview).
- Tab list: ["overview", "details"]. Terminal / identity / agent-spec /
  startup / transcript no longer exist as named tabs.
- Overview body order: SeatOverviewTable (dense 7-field info table)
  -> InlineTerminal (black-glass smoked terminal embedded directly)
  -> LiveNodeCurrentState (Activity + Current Work) ->
  RecentEventsSection.
- Details body: EdgesSection -> PeersSection -> ContextUsageSection ->
  AgentSpecSection (for agents only) -> StartupContent (without
  PreviewPane; preview is now the inline terminal in Overview) ->
  TranscriptContent.

New component: packages/ui/src/components/SeatOverviewTable.tsx

- Dense 7-row info table. Density anchor: TopologyTableView; same
  compact-mono cells, 1px outline-variant cell borders, vellum
  surface chrome.
- Rows: runtime (RuntimeBadge), model, profile, spec (name@version),
  cwd, context% (from contextUsage.usedPercentage when availability
  is "known"), total tokens (sumTokenCounts via the same helpers
  TopologyTableView uses).
- Graceful absence: any null/undefined/empty cell renders an em-dash
  placeholder. Model in particular is commonly absent on rig-spec
  members that don't declare one; the row stays visible.
- Per-row testids (seat-overview-row-<key>) + per-cell testids
  (seat-overview-cell-<key>) for HG-3 + HG-4 verification.

Modified: packages/ui/src/components/LiveNodeDetails.tsx

- Tab union literal: "overview" | "details" (was 5-key).
- Default activeTab: "overview" (was "identity").
- Old IdentityTab + IdentitySummary + IdentityField + TerminalTab
  removed. Old StartupTab refactored into StartupContent (drops the
  PreviewPane; everything else preserved). Old TranscriptTab
  refactored into TranscriptContent (same body).
- New OverviewTab composes SeatOverviewTable + InlineTerminal +
  LiveNodeCurrentState + RecentEventsSection.
- New DetailsTab composes EdgesSection + PeersSection +
  ContextUsageSection + AgentSpecSection (gated on isAgent) +
  StartupContent + TranscriptContent.
- Dropped unused PreviewPane import (preview no longer rendered on
  the seat detail page; the inline terminal is the operator-facing
  preview).

Tests updated:

- packages/ui/test/live-node-details.test.tsx fully rewritten to
  target the 2-tab layout. 16 it() blocks cover HG-1 (default
  Overview), HG-2 (Overview DOM order via compareDocumentPosition),
  HG-3 (7 fields), HG-4 (graceful em-dash for missing model), HG-5
  (inline black-glass terminal in Overview), HG-6 (Details
  composition + PreviewPane absent), HG-7 (legacy tabs gone),
  infrastructure node parity (no agent-spec card), Startup file
  trigger preserved, Transcript surfaced inside Details, slice 3.3
  Plugins section preserved, PL-019 activity + qitems preserved,
  resume action glyph preserved.
- packages/ui/test/node-selection-migration.test.tsx — source
  pinning updated: assert Tab union literal is "overview" | "details"
  and the legacy 5-tab literal is gone.

Forensic-trace findings (pre-scaffold per dispatch):

- Seat page owner: LiveNodeDetails.tsx (mounted from SeatScopePage in
  components/topology/ScopePages.tsx). Confirmed.
- Pre-slice tabs: ["identity", "agent-spec", "startup", "transcript",
  "terminal"] for agents; same minus agent-spec for infra. Default:
  "identity".
- Black-glass terminal: was already rendered inline via
  SessionPreviewPane (variant="compact-terminal"); the popover-only
  concern from the dispatch is moot — slice migrates the existing
  inline pattern from a tab body to the Overview tab body. No new
  primitive needed.
- NodeDetailEntry.model: present + already wired (the old
  IdentitySummary used data.model). SeatOverviewTable reads it
  directly. No new accessor required.
- Activity card + Recent Events card: existing components
  (LiveNodeCurrentState + RecentEventsSection); reused as-is.

Verification:

- live-node-details.test.tsx: 16/16 PASS.
- node-selection-migration.test.tsx: 10/10 PASS.
- Full UI suite: 994 passed / 1 skipped / 4 failed (same 4
  pre-existing failures as main; zero new failures from this slice).

Hard gates:

- HG-1 default tab is Overview: PASS.
- HG-2 Overview DOM order (table -> terminal -> activity): PASS.
- HG-3 7 fields rendered: PASS.
- HG-4 model graceful absence: PASS (em-dash placeholder).
- HG-5 inline black-glass terminal in Overview: PASS.
- HG-6 Details composition + no preview in Startup: PASS.
- HG-7 Terminal + legacy tabs gone: PASS.
- HG-8 UI focused suite green (zero new regressions): PASS.
- HG-9 design-reviewer V1-bar visual audit: DEFERRED.
- HG-10 Activity + Recent Events render correctly in new Overview
  position: PASS (existing components reused; tests cover Activity).

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 21:34:31 -07:00
1 parent 159433a163
commit f48ea03040
4 files changed
+421 -201

No files matched your search

+94 -99
View File
@@ -1,11 +1,19 @@
// V1 agent-detail canonical surface.
//
// Polish-8 keeps the title and action row at the top, then makes the tab row
// the only body switcher. Status, preview, current work, transcript, terminal,
// and startup content live inside their named tabs so the page has one clear
// information hierarchy.
// V0.3.1 slice 25 reshapes the seat-detail page into a 2-tab
// Overview + Details layout. Overview is the default and answers the
// most-common at-a-glance questions: a dense 7-field info table at
// the top, the black-glass smoked terminal inline below it, then the
// Activity + Recent Events cards. Details holds everything else
// (edges, peers, agent spec, startup content sans preview, transcript).
//
// Previous 5-tab structure (identity / agent-spec / startup /
// transcript / terminal) collapses into the 2 new tabs. The terminal
// is no longer in its own tab — it lives inline in Overview. The
// Startup section in Details no longer shows the preview pane (since
// the terminal moved up to Overview).
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { CirclePlay } from "lucide-react";
import { useNodeDetail, type NodeDetailData } from "../hooks/useNodeDetail.js";
import { useSpecLibrary, useLibraryReview } from "../hooks/useSpecLibrary.js";
@@ -17,8 +25,12 @@ import { AgentSpecDisplay } from "./AgentSpecDisplay.js";
// defensively (the resources.plugins field is owned by batch 1 on
// plugin-primitive-v0; on main it's absent and we render empty state).
import { AgentPluginsList } from "./specs/AgentPluginsList.js";
import { PreviewPane } from "./preview/PreviewPane.js";
// V0.3.1 slice 25 — PreviewPane no longer rendered (Startup section
// in Details tab drops the preview; the terminal moved up to Overview
// via SessionPreviewPane). PreviewPane is still owned by other
// surfaces in the codebase; this file simply doesn't reference it.
import { SessionPreviewPane } from "./preview/SessionPreviewPane.js";
import { SeatOverviewTable } from "./SeatOverviewTable.js";
import { FileReferenceTrigger } from "./drawer-triggers/FileReferenceTrigger.js";
import { displayPodName, inferPodName } from "../lib/display-name.js";
import { copyText } from "../lib/copy-text.js";
@@ -27,7 +39,7 @@ import { getRestoreStatusColorClass } from "../lib/restore-status-colors.js";
import type { AgentSpecReview } from "../hooks/useSpecReview.js";
import { RuntimeBadge, ToolMark } from "./graphics/RuntimeMark.js";
type Tab = "identity" | "agent-spec" | "startup" | "transcript" | "terminal";
type Tab = "overview" | "details";
interface LiveNodeDetailsProps {
rigId: string;
@@ -69,29 +81,6 @@ function InfoRow({ label, value }: { label: string; value: string | number | nul
);
}
function IdentityField({
label,
children,
wide = false,
}: {
label: string;
children: ReactNode;
wide?: boolean;
}) {
if (children === null || children === undefined || children === "") return null;
return (
<div
data-testid={`identity-field-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
className={`min-w-0 border border-outline-variant/55 bg-white/20 px-2 py-1.5 font-mono ${
wide ? "md:col-span-2 xl:col-span-3" : ""
}`}
>
<div className="text-[8px] uppercase tracking-[0.14em] text-stone-400">{label}</div>
<div className="mt-1 min-w-0 truncate text-[11px] text-stone-900">{children}</div>
</div>
);
}
function AgentSpecSection({ data }: { data: NodeDetailData }) {
const agentName = resolveAgentName(data.agentRef);
const { data: agentEntries = [], isLoading: entriesLoading } = useSpecLibrary("agent");
@@ -382,25 +371,8 @@ function RecentEventsSection({ data }: { data: NodeDetailData }) {
);
}
function IdentitySummary({ data }: { data: NodeDetailData }) {
return (
<section data-testid="live-identity-summary" className={SECTION_CLASS}>
<div className="mb-2 font-mono text-[8px] uppercase tracking-wider text-stone-400">Identity</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{data.runtime ? (
<IdentityField label="Runtime">
<RuntimeBadge runtime={data.runtime} model={data.model} size="xs" compact variant="inline" className="max-w-full" />
</IdentityField>
) : null}
<IdentityField label="Model">{data.model}</IdentityField>
<IdentityField label="Profile">{data.profile}</IdentityField>
<IdentityField label="Spec">{data.resolvedSpecName}</IdentityField>
<IdentityField label="Version">{data.resolvedSpecVersion}</IdentityField>
<IdentityField label="CWD" wide>{data.cwd}</IdentityField>
</div>
</section>
);
}
// V0.3.1 slice 25 — IdentitySummary card is replaced by SeatOverviewTable
// (dense 7-field info table at the top of Overview).
function EdgesSection({ data }: { data: NodeDetailData }) {
const { outgoing, incoming } = data.edges;
@@ -476,36 +448,78 @@ function ContextUsageSection({ data }: { data: NodeDetailData }) {
);
}
function IdentityTab({ data }: { data: NodeDetailData }) {
// V0.3.1 slice 25 — Overview tab. Dense info-table (7 fields) on top;
// inline black-glass terminal below; Activity + Recent Events cards
// at the bottom. Composes the at-a-glance answer to operators
// landing on the seat page from topology.
function OverviewTab({ data }: { data: NodeDetailData }) {
return (
<div data-testid="live-identity-section" className="space-y-4">
<IdentitySummary data={data} />
<div data-testid="live-overview-section" className="space-y-4">
<SeatOverviewTable data={data} />
<InlineTerminal data={data} />
<LiveNodeCurrentState data={data} />
<RecentEventsSection data={data} />
<EdgesSection data={data} />
<PeersSection data={data} />
<ContextUsageSection data={data} />
</div>
);
}
function StartupTab({ rigId, logicalId, data }: { rigId: string; logicalId: string; data: NodeDetailData }) {
// V0.3.1 slice 25 — Details tab. Holds Edges + Peers + ContextUsage
// detail + AgentSpec content (for agents) + Startup content sans
// preview + Transcript. Everything-else for the seat detail page.
function DetailsTab({
rigId,
logicalId,
data,
isAgent,
}: {
rigId: string;
logicalId: string;
data: NodeDetailData;
isAgent: boolean;
}) {
return (
<div data-testid="live-details-section" className="space-y-4">
<EdgesSection data={data} />
<PeersSection data={data} />
<ContextUsageSection data={data} />
{isAgent ? <AgentSpecSection data={data} /> : null}
<StartupContent rigId={rigId} logicalId={logicalId} data={data} />
<TranscriptContent data={data} />
</div>
);
}
// V0.3.1 slice 25 — Inline black-glass terminal. Renders the same
// SessionPreviewPane the old Terminal tab rendered, but embedded
// directly in Overview rather than behind a tab. The black-glass
// chrome class is preserved verbatim so the visual feel matches the
// pre-slice-25 terminal tab.
function InlineTerminal({ data }: { data: NodeDetailData }) {
if (!data.canonicalSessionName) {
return (
<div className="font-mono text-[10px] text-stone-400 p-4">
No canonical session name; terminal preview unavailable.
</div>
);
}
return (
<div data-testid="live-terminal-shell" className="bg-stone-950/65 p-2 text-stone-50 backdrop-blur-sm">
<SessionPreviewPane
sessionName={data.canonicalSessionName}
lines={80}
testIdPrefix="live-terminal-preview"
variant="compact-terminal"
/>
</div>
);
}
function StartupContent({ rigId: _rigId, logicalId: _logicalId, data }: { rigId: string; logicalId: string; data: NodeDetailData }) {
void _rigId; void _logicalId;
return (
<div data-testid="live-startup-section" className="space-y-4">
<StatusSection data={data} />
{data.canonicalSessionName && (
<section data-testid="live-node-preview" className={SECTION_CLASS}>
<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.infrastructureStartupCommand && (
<section data-testid="live-node-infra-startup" className={SECTION_CLASS}>
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400 mb-2">
@@ -567,7 +581,7 @@ function StartupTab({ rigId, logicalId, data }: { rigId: string; logicalId: stri
);
}
function TranscriptTab({ data }: { data: NodeDetailData }) {
function TranscriptContent({ data }: { data: NodeDetailData }) {
if (!data.transcript.enabled) {
return (
<div data-testid="live-transcript-section" className="font-mono text-[10px] text-stone-400 p-4">
@@ -595,27 +609,6 @@ function TranscriptTab({ data }: { data: NodeDetailData }) {
);
}
function TerminalTab({ data }: { data: NodeDetailData }) {
return (
<div data-testid="live-terminal-section" className="space-y-4">
{data.canonicalSessionName ? (
<div data-testid="live-terminal-shell" className="bg-stone-950/65 p-2 text-stone-50 backdrop-blur-sm">
<SessionPreviewPane
sessionName={data.canonicalSessionName}
lines={80}
testIdPrefix="live-terminal-preview"
variant="compact-terminal"
/>
</div>
) : (
<div className="font-mono text-[10px] text-stone-400 p-4">
No canonical session name; terminal preview unavailable.
</div>
)}
</div>
);
}
function TabNav({
tabs,
activeTab,
@@ -649,11 +642,14 @@ function TabNav({
export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
const { data, isLoading, error } = useNodeDetail(rigId, logicalId);
const [activeTab, setActiveTab] = useState<Tab>("identity");
// V0.3.1 slice 25 — 2-tab Overview/Details restructure. Default
// tab is Overview so operators landing from topology see the
// at-a-glance info table + inline terminal without tab-switching.
// Analogous to slice 12's project-scope default-tab flip
// (story → overview).
const [activeTab, setActiveTab] = useState<Tab>("overview");
const isAgent = data ? data.nodeKind !== "infrastructure" : true;
const tabs: Tab[] = isAgent
? ["identity", "agent-spec", "startup", "transcript", "terminal"]
: ["identity", "startup", "transcript", "terminal"];
const tabs: Tab[] = ["overview", "details"];
return (
<WorkspacePage>
@@ -685,11 +681,10 @@ export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
<ActionButtonsRow rigId={rigId} logicalId={logicalId} data={data} />
<TabNav tabs={tabs} activeTab={activeTab} onSelect={setActiveTab} />
<div data-testid="live-node-tab-body" className="space-y-4">
{activeTab === "identity" && <IdentityTab data={data} />}
{activeTab === "agent-spec" && isAgent && <AgentSpecSection data={data} />}
{activeTab === "startup" && <StartupTab rigId={rigId} logicalId={logicalId} data={data} />}
{activeTab === "transcript" && <TranscriptTab data={data} />}
{activeTab === "terminal" && <TerminalTab data={data} />}
{activeTab === "overview" && <OverviewTab data={data} />}
{activeTab === "details" && (
<DetailsTab rigId={rigId} logicalId={logicalId} data={data} isAgent={isAgent} />
)}
</div>
</>
)}
@@ -0,0 +1,137 @@
// V0.3.1 slice 25 node-page Overview/Details consolidation.
//
// Dense 7-field info table at the top of the seat-detail Overview tab.
// Renders the most-asked questions about a seat at a single glance:
// runtime, model, profile, spec (name + version), cwd, context%, and
// total tokens. Density anchor: TopologyTableView (per ui.md §Topology)
// — same compact mono cells, 1px outline-variant cell borders, vellum
// surface chrome.
//
// Reuses the shared token / runtime helpers so the values rendered
// here are the SAME values rendered in topology graph + table +
// terminal views (slice 25 HG-3 — single source of truth across
// surfaces).
//
// Graceful absence rule: when a field is null/undefined/empty the
// cell renders an em-dash placeholder. Model in particular is
// commonly absent (not every RigSpec member declares one); the row
// stays visible with a dash rather than rendering "undefined" or
// being hidden — HG-4 from the slice 25 spec.
import type { ReactNode } from "react";
import type { NodeDetailData } from "../hooks/useNodeDetail.js";
import { RuntimeBadge } from "./graphics/RuntimeMark.js";
import {
formatCompactTokenCount,
sumTokenCounts,
} from "../lib/token-format.js";
interface SeatOverviewTableProps {
data: NodeDetailData;
}
interface Row {
/** Stable kebab-case key used as testid suffix + render key. */
key: string;
/** Display label in the left column. Lowercase mono. */
label: string;
/** Cell content. May be string OR a ReactNode (e.g., RuntimeBadge).
* null/undefined/empty-string renders as "—" placeholder. */
value: ReactNode | null | undefined;
/** Use the body-font (sans) for human-readable values; mono for IDs
* + numeric metrics. Per DESIGN.md §Typography. */
mono?: boolean;
}
function placeholderOrValue(value: ReactNode | null | undefined): ReactNode {
if (value === null || value === undefined) return <span className="text-stone-400">—</span>;
if (typeof value === "string" && value.trim() === "") return <span className="text-stone-400">—</span>;
return value;
}
export function SeatOverviewTable({ data }: SeatOverviewTableProps) {
const contextPercentage =
data.contextUsage?.availability === "known" &&
typeof data.contextUsage.usedPercentage === "number"
? `${data.contextUsage.usedPercentage}%`
: null;
const tokenTotal = sumTokenCounts(
data.contextUsage?.totalInputTokens,
data.contextUsage?.totalOutputTokens,
);
const tokenLabel = formatCompactTokenCount(tokenTotal);
const specCell =
data.resolvedSpecName && data.resolvedSpecVersion
? `${data.resolvedSpecName}@${data.resolvedSpecVersion}`
: data.resolvedSpecName ?? null;
const rows: Row[] = [
{
key: "runtime",
label: "runtime",
value: data.runtime ? (
<RuntimeBadge
runtime={data.runtime}
model={data.model}
size="xs"
compact
variant="inline"
className="max-w-full"
/>
) : null,
},
{ key: "model", label: "model", value: data.model, mono: true },
{ key: "profile", label: "profile", value: data.profile, mono: true },
{ key: "spec", label: "spec", value: specCell, mono: true },
{ key: "cwd", label: "cwd", value: data.cwd, mono: true },
{
key: "context-percent",
label: "context %",
value: contextPercentage,
mono: true,
},
{
key: "total-tokens",
label: "total tokens",
value: tokenLabel,
mono: true,
},
];
return (
<section
data-testid="seat-overview-table"
className="border border-outline-variant bg-white/30"
>
<div className="border-b border-outline-variant px-3 py-2 font-mono text-[8px] uppercase tracking-[0.16em] text-stone-400">
Overview
</div>
<table className="w-full text-left">
<tbody>
{rows.map((row) => (
<tr
key={row.key}
data-testid={`seat-overview-row-${row.key}`}
className="border-b border-outline-variant/55 last:border-b-0"
>
<th
scope="row"
className="w-32 shrink-0 bg-stone-50/30 px-3 py-1.5 text-left align-top font-mono text-[10px] font-normal lowercase tracking-[0.04em] text-stone-500"
>
{row.label}
</th>
<td
data-testid={`seat-overview-cell-${row.key}`}
className={`min-w-0 px-3 py-1.5 align-middle ${
row.mono ? "font-mono text-[11px]" : "text-[11px]"
} text-stone-900`}
>
<div className="truncate">{placeholderOrValue(row.value)}</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
+183 -98
View File
@@ -6,6 +6,8 @@ import { DrawerSelectionContext, type DrawerSelection } from "../src/components/
const mockFetch = vi.fn();
// V0.3.1 slice 25 — seat detail page now uses a 2-tab Overview +
// Details layout. Tests target the new structure.
const NODE_DETAIL = {
rigId: "rig-1", rigName: "test-rig", logicalId: "dev.impl", podId: "dev",
canonicalSessionName: "dev-impl@test-rig", nodeKind: "agent", runtime: "claude-code",
@@ -43,6 +45,16 @@ const NODE_DETAIL = {
tier: "mode-2",
},
],
contextUsage: {
availability: "known",
usedPercentage: 42,
remainingPercentage: 58,
contextWindowSize: 320000,
sampledAt: "2026-05-04T07:58:31.057Z",
fresh: true,
totalInputTokens: 120000,
totalOutputTokens: 14000,
},
};
const INFRA_DETAIL = {
@@ -51,7 +63,7 @@ const INFRA_DETAIL = {
compactSpec: { name: null, version: null, profile: null, skillCount: 0, guidanceCount: 0 },
};
describe("LiveNodeDetails", () => {
describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
});
@@ -96,103 +108,181 @@ describe("LiveNodeDetails", () => {
);
}
it("renders with identity section showing peers and edges", async () => {
// HG-1 — default tab is Overview.
it("HG-1: default tab is Overview on landing", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await waitFor(() => {
// Wait for data to load — peers section proves data arrived
expect(screen.getByTestId("detail-peers")).toBeDefined();
});
const overviewTab = await screen.findByTestId("live-tab-overview");
expect(overviewTab.getAttribute("aria-selected")).toBe("true");
expect(screen.getByTestId("live-overview-section")).toBeDefined();
// Details exists but is not active on first paint.
const detailsTab = screen.getByTestId("live-tab-details");
expect(detailsTab.getAttribute("aria-selected")).toBe("false");
});
expect(screen.getByText("Live Node Details")).toBeDefined();
// HG-7 — Terminal tab no longer exists; identity / agent-spec /
// startup / transcript tabs no longer exist as named tabs either.
it("HG-7: legacy 5-tab structure is gone — only overview + details remain", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await screen.findByTestId("live-tab-overview");
expect(screen.queryByTestId("live-tab-terminal")).toBeNull();
expect(screen.queryByTestId("live-tab-identity")).toBeNull();
expect(screen.queryByTestId("live-tab-agent-spec")).toBeNull();
expect(screen.queryByTestId("live-tab-startup")).toBeNull();
expect(screen.queryByTestId("live-tab-transcript")).toBeNull();
expect(screen.getByTestId("live-tab-overview")).toBeDefined();
expect(screen.getByTestId("live-tab-details")).toBeDefined();
});
// HG-2 — Overview stack order: info table -> terminal -> Activity +
// Recent Events. Asserts DOM order via compareDocumentPosition.
it("HG-2: Overview tab DOM order is info table -> terminal -> activity -> recent events", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
const table = await screen.findByTestId("seat-overview-table");
const terminal = await screen.findByTestId("live-terminal-shell");
const activity = await screen.findByTestId("live-node-current-state");
// Recent events only renders when there's at least one event;
// NODE_DETAIL has recentEvents: [] so this section is conditional.
// The order assertion focuses on the always-rendered trio.
expect(table.compareDocumentPosition(terminal)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(terminal.compareDocumentPosition(activity)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
// HG-3 — 7 info-table fields. Every row testid present.
it("HG-3: info table renders all 7 fields (runtime, model, profile, spec, cwd, context%, total tokens)", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await screen.findByTestId("seat-overview-table");
expect(screen.getByTestId("seat-overview-row-runtime")).toBeDefined();
expect(screen.getByTestId("seat-overview-row-model")).toBeDefined();
expect(screen.getByTestId("seat-overview-row-profile")).toBeDefined();
expect(screen.getByTestId("seat-overview-row-spec")).toBeDefined();
expect(screen.getByTestId("seat-overview-row-cwd")).toBeDefined();
expect(screen.getByTestId("seat-overview-row-context-percent")).toBeDefined();
expect(screen.getByTestId("seat-overview-row-total-tokens")).toBeDefined();
expect(screen.getByTestId("seat-overview-cell-model").textContent).toContain("opus");
expect(screen.getByTestId("seat-overview-cell-profile").textContent).toContain("default");
expect(screen.getByTestId("seat-overview-cell-spec").textContent).toContain("[email protected]");
expect(screen.getByTestId("seat-overview-cell-cwd").textContent).toContain("/workspace");
expect(screen.getByTestId("seat-overview-cell-context-percent").textContent).toContain("42%");
});
// HG-4 — model graceful absence: row visible with em-dash, NOT
// "undefined" rendered as a string.
it("HG-4: model row renders em-dash gracefully when model field is absent", async () => {
mockNodeDetail({ ...NODE_DETAIL, model: null });
renderDetails();
await screen.findByTestId("seat-overview-table");
const modelCell = screen.getByTestId("seat-overview-cell-model");
expect(modelCell).toBeDefined();
// Row still visible.
expect(screen.getByTestId("seat-overview-row-model")).toBeDefined();
// No literal "undefined".
expect(modelCell.textContent).not.toContain("undefined");
// Em-dash placeholder.
expect(modelCell.textContent).toContain("—");
});
// HG-5 — black-glass terminal renders inline in Overview (not in a
// separate tab). The terminal shell wrapper carries the black-glass
// chrome class.
it("HG-5: black-glass terminal renders inline in Overview", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
const terminalShell = await screen.findByTestId("live-terminal-shell");
expect(terminalShell.className).toContain("bg-stone-950/65");
await waitFor(() => {
expect(screen.getByTestId("live-terminal-preview-pane").getAttribute("data-variant")).toBe("compact-terminal");
});
// The terminal sits inside the Overview section, NOT a separate
// tab body. The Overview section wraps it.
const overview = screen.getByTestId("live-overview-section");
expect(overview.contains(terminalShell)).toBe(true);
});
// HG-6 — Details tab contains edges, peers, agent spec, startup
// content (no preview), transcript content.
it("HG-6: Details tab composes edges + peers + agent spec + startup (no preview) + transcript", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
fireEvent.click(await screen.findByTestId("live-tab-details"));
await waitFor(() => {
expect(screen.getByTestId("live-details-section")).toBeDefined();
});
expect(screen.getByTestId("detail-edges")).toBeDefined();
expect(screen.getByTestId("live-node-actions").nextElementSibling?.getAttribute("data-testid")).toBe("live-node-tabs");
expect(screen.queryByTestId("live-node-status")).toBeNull();
expect(screen.queryByTestId("detail-transcript")).toBeNull();
expect(screen.queryByTestId("detail-compact-spec")).toBeNull();
expect(screen.getByTestId("detail-peers")).toBeDefined();
expect(screen.getByTestId("live-startup-section")).toBeDefined();
expect(screen.getByTestId("live-transcript-section")).toBeDefined();
// PreviewPane is intentionally absent from Startup in slice 25
// (terminal moved to Overview).
expect(screen.queryByTestId("live-node-preview")).toBeNull();
});
it("renders identity fields as isolated cells so runtime badges do not collide with adjacent labels", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
const runtimeField = await screen.findByTestId("identity-field-runtime");
const modelField = screen.getByTestId("identity-field-model");
const cwdField = screen.getByTestId("identity-field-cwd");
expect(runtimeField.className).toContain("bg-white/20");
expect(runtimeField.textContent).toContain("Claude");
expect(modelField.textContent).toContain("opus");
expect(cwdField.className).toContain("xl:col-span-3");
expect(cwdField.textContent).toContain("/workspace");
});
it("uses a resume action glyph instead of a runtime mark on the copy resume command", async () => {
mockNodeDetail({ ...NODE_DETAIL, resumeCommand: "rig seat resume dev.impl" });
renderDetails();
const resumeButton = await screen.findByTestId("detail-copy-resume");
expect(resumeButton.textContent).toContain("Copy resume command");
expect(resumeButton.textContent).not.toContain("Claude");
expect(resumeButton.querySelector("svg")).toBeDefined();
});
it("agent spec tab shows unavailable when agentRef is null", async () => {
mockNodeDetail({ ...NODE_DETAIL, agentRef: null });
renderDetails();
fireEvent.click(await screen.findByTestId("live-tab-agent-spec"));
await waitFor(() => {
expect(screen.getByTestId("agent-spec-unavailable")).toBeDefined();
});
});
it("agent spec tab shows unavailable when agentRef is non-local form", async () => {
mockNodeDetail({ ...NODE_DETAIL, agentRef: "remote:agents/impl" });
renderDetails();
fireEvent.click(await screen.findByTestId("live-tab-agent-spec"));
await waitFor(() => {
expect(screen.getByTestId("agent-spec-unavailable")).toBeDefined();
});
});
it("infrastructure node does not show agent-spec tab", async () => {
// Infrastructure nodes still have the same 2-tab structure; the
// agent-spec section just doesn't render inside Details.
it("infrastructure node renders Overview + Details (no agent-spec card inside Details)", async () => {
mockNodeDetail(INFRA_DETAIL);
renderDetails("infra.server");
// Wait for data to load and re-render without agent-spec tab
await screen.findByTestId("live-tab-identity");
await screen.findByTestId("live-tab-overview");
expect(screen.getByTestId("live-tab-overview")).toBeDefined();
expect(screen.getByTestId("live-tab-details")).toBeDefined();
expect(screen.queryByTestId("live-tab-agent-spec")).toBeNull();
expect(screen.getByTestId("live-tab-identity")).toBeDefined();
expect(screen.getByTestId("live-tab-startup")).toBeDefined();
fireEvent.click(screen.getByTestId("live-tab-details"));
await waitFor(() => {
expect(screen.getByTestId("live-details-section")).toBeDefined();
});
// No live-agent-spec-section for infra nodes.
expect(screen.queryByTestId("live-agent-spec-section")).toBeNull();
});
it("startup tab shows startup files", async () => {
// Agent spec unavailable cases — switch to Details, then exercise the
// null + non-local agentRef shapes.
it("Details tab: agent spec section shows unavailable when agentRef is null", async () => {
mockNodeDetail({ ...NODE_DETAIL, agentRef: null });
renderDetails();
fireEvent.click(await screen.findByTestId("live-tab-details"));
await waitFor(() => {
expect(screen.getByTestId("agent-spec-unavailable")).toBeDefined();
});
});
it("Details tab: agent spec section shows unavailable when agentRef is non-local form", async () => {
mockNodeDetail({ ...NODE_DETAIL, agentRef: "remote:agents/impl" });
renderDetails();
fireEvent.click(await screen.findByTestId("live-tab-details"));
await waitFor(() => {
expect(screen.getByTestId("agent-spec-unavailable")).toBeDefined();
});
});
// Startup files surface inside Details > Startup section.
it("Details tab: Startup section shows startup files", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
fireEvent.click(await screen.findByTestId("live-tab-startup"));
fireEvent.click(await screen.findByTestId("live-tab-details"));
await waitFor(() => {
expect(screen.getByTestId("live-startup-section")).toBeDefined();
expect(screen.getByTestId("live-node-status")).toBeDefined();
expect(screen.getByTestId("live-node-preview")).toBeDefined();
expect(screen.getByText(/role\.md/)).toBeDefined();
});
});
it("startup file trigger includes resolved file provenance for drawer loading", async () => {
it("Details tab: Startup file trigger threads file provenance for drawer loading", async () => {
const setSelection = vi.fn();
mockNodeDetail(NODE_DETAIL);
renderDetailsWithDrawerSelection(setSelection as (sel: DrawerSelection) => void);
fireEvent.click(await screen.findByTestId("live-tab-startup"));
fireEvent.click(await screen.findByTestId("live-tab-details"));
fireEvent.click(await screen.findByTestId("live-startup-file-trigger-role.md"));
expect(setSelection).toHaveBeenCalledWith({
@@ -204,27 +294,16 @@ describe("LiveNodeDetails", () => {
});
});
it("transcript and terminal tabs own their named content", async () => {
it("Details tab: Transcript section owns transcript content", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
fireEvent.click(await screen.findByTestId("live-tab-transcript"));
fireEvent.click(await screen.findByTestId("live-tab-details"));
expect(await screen.findByTestId("detail-transcript")).toBeDefined();
fireEvent.click(screen.getByTestId("live-tab-terminal"));
const terminalShell = await screen.findByTestId("live-terminal-shell");
expect(terminalShell.className).toContain("bg-stone-950/65");
await waitFor(() => {
expect(screen.getByTestId("live-terminal-preview-pane").getAttribute("data-variant")).toBe("compact-terminal");
});
});
it("slice 3.3 fix-B — agent-spec tab includes Plugins section between Skills and Startup Files", async () => {
// velocity-qa VM verify failure #2 — Plugins section must be visible
// in current build per dispatch §3.2. Empty state is acceptable on
// main without plugin-primitive-v0 merged in (no agent.yaml carries
// resources.plugins[] until merge); the section's PRESENCE is the
// load-bearing assertion.
// Slice 3.3 fix-B preserved — Plugins section inside Details > Agent
// spec area. Renders empty state on builds without batch 1.
it("slice 3.3 fix-B preserved: Plugins section in Details tab agent-spec area", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (typeof url === "string" && url.includes("/nodes/")) {
return { ok: true, json: async () => NODE_DETAIL };
@@ -244,27 +323,33 @@ describe("LiveNodeDetails", () => {
return { ok: true, json: async () => ({}) };
});
renderDetails();
// Switch to the agent-spec tab to see the Plugins section.
const agentSpecTab = await screen.findByTestId("live-tab-agent-spec");
fireEvent.click(agentSpecTab);
// Section present (drives the visual proof the dispatch + velocity-qa want).
fireEvent.click(await screen.findByTestId("live-tab-details"));
await waitFor(() => {
expect(screen.getByTestId("live-agent-plugins-section")).toBeDefined();
});
// Empty state body when no plugins declared.
expect(screen.getByTestId("agent-plugins-empty")).toBeDefined();
});
it("PL-019: full details shows activity and current qitems from node detail", async () => {
// PL-019 preserved — activity + current qitems surface in the
// Overview tab (LiveNodeCurrentState now sits under the terminal).
it("PL-019 preserved: Overview shows activity + current qitems from node detail", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await waitFor(() => {
expect(screen.getByTestId("live-node-current-state")).toBeDefined();
});
expect(screen.getByTestId("live-node-agent-activity").textContent).toContain("running");
expect(screen.getByTestId("live-node-current-qitems").textContent).toContain("04001234-driver");
expect(screen.getByTestId("live-node-current-qitems").textContent).toContain("Implement PL-019 edge activity pulse");
});
// Resume action glyph remains independent of tab structure.
it("uses a resume action glyph instead of a runtime mark on the copy resume command", async () => {
mockNodeDetail({ ...NODE_DETAIL, resumeCommand: "rig seat resume dev.impl" });
renderDetails();
const resumeButton = await screen.findByTestId("detail-copy-resume");
expect(resumeButton.textContent).toContain("Copy resume command");
expect(resumeButton.textContent).not.toContain("Claude");
expect(resumeButton.querySelector("svg")).toBeDefined();
});
});
@@ -89,11 +89,14 @@ describe("P5.1-D2 retirement regression: drawer-as-seat-detail removed", () => {
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)", () => {
it("LiveNodeDetails.tsx renders the 2-tab Overview + Details body row (single canonical surface per slice 25)", () => {
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).
// Tab union literal carries the 2 canonical tab keys after the
// slice 25 consolidation.
expect(src).toMatch(/type\s+Tab\s*=\s*"overview"\s*\|\s*"details"/);
// Negative: the legacy 5-tab literal is gone.
expect(src).not.toMatch(/"identity"\s*\|\s*"agent-spec"\s*\|\s*"startup"\s*\|\s*"transcript"\s*\|\s*"terminal"/);
// FileReferenceTrigger wraps startup files (P5.1-1a preserved).
expect(src).toMatch(/import\s*\{[^}]*FileReferenceTrigger[^}]*\}\s*from/);
expect(src).toMatch(/<FileReferenceTrigger/);
});