merge(0.3.1): slice 25 node-page Overview/Details — info table + tab consolidation + activity alignment

--no-ff merge from slice-25-node-page-overview-details (4 commits: f48ea03 + 46b5132 + 594b0a2 + 8e9868d).

Seat detail page (/topology/seat/$rigId/$logicalId) restructured:
- Two tabs total: Overview (default; renamed from Identity) + Details
- Terminal tab removed; black-glass smoked terminal moves inline into Overview
- Overview composition: 9-row info table -> inline terminal -> Activity card + Recent Events card
- Info table has two row shapes:
  - Compact 2-col rows (7): runtime / model / profile / spec / activity / context% / total tokens
  - Full-width rows (2): cwd (truncate-with-ellipsis + tooltip) / current work
- Activity state surfaces via shared useTopologyActivity (same source the topology graph + table read; no parallel pipeline); state names align with topology vocabulary (active / needs_input / idle / blocked); shimmer animation on "active" reuses topology-table-shimmer.css; prefers-reduced-motion honored
- Current-work row reads existing data.currentQitems[0]; em-dash when no in-progress qitem; updates via existing useNodeDetail 30s refetch interval
- Details tab composes Edges + Peers + AgentSpec + Startup (preview pane removed; terminal lives in Overview) + Transcript
- Mobile: 44px tap targets at 375px viewport; no horizontal overflow

HG-7 source-pinning discriminator: anchored Tab union regex requires trailing semicolon + negative assertion rejecting 3+ member unions; verified discriminator-first by injecting a fake third tab and confirming the test FAILS (prior prefix-matcher would have silently passed).

Triple-gate verdicts:
- redo3-guard-3 CLEAR (functional + commit-body hygiene; discriminator verified by independent injection test)
- velocity-qa PASS on persistent-rsync-refresh founder-walk VM (Overview default; 9-row table; topology activity sync; CWD tooltip + truncate; Details composition; mobile responsive; reduced-motion honored)

SC-29 EXCEPTION declared: NONE.

UI focused suite: 1001/1006 (4 pre-existing failures unchanged from baseline; zero new).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 22:01:10 -07:00
commit 84b4888ccf
5 files changed
+780 -211

No files matched your search

+157 -109
View File
@@ -1,13 +1,22 @@
// 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 9-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 { useMemo, useState } from "react";
import { CirclePlay } from "lucide-react";
import { useNodeDetail, type NodeDetailData } from "../hooks/useNodeDetail.js";
import { useTopologyActivity } from "../hooks/useTopologyActivity.js";
import { useSpecLibrary, useLibraryReview } from "../hooks/useSpecLibrary.js";
import { WorkspacePage } from "./WorkspacePage.js";
import { WorkflowHeader } from "./WorkflowScaffold.js";
@@ -17,17 +26,25 @@ 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";
import { getActivityLabel, getActivityState, getActivityTextClass, isActivityStale } from "../lib/activity-visuals.js";
import {
buildTopologySessionIndex,
type TopologyActivityVisual,
} from "../lib/topology-activity.js";
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 +86,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");
@@ -204,7 +198,7 @@ function ActionButtonsRow({ rigId, logicalId, data }: { rigId: string; logicalId
<button
onClick={handleOpenCmux}
data-testid="detail-cmux-open"
className="inline-flex items-center gap-1.5 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"
className="inline-flex min-h-11 items-center gap-1.5 px-3 py-2 border border-outline-variant bg-white/30 font-mono text-[10px] uppercase tracking-wide text-stone-900 hover:bg-stone-100/60"
>
<ToolMark tool="cmux" size="sm" />
Open CMUX
@@ -213,7 +207,7 @@ function ActionButtonsRow({ rigId, logicalId, data }: { rigId: string; logicalId
<button
onClick={handleCopyAttach}
data-testid="detail-copy-attach"
className="inline-flex items-center gap-1.5 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"
className="inline-flex min-h-11 items-center gap-1.5 px-3 py-2 border border-outline-variant bg-white/30 font-mono text-[10px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60"
>
<ToolMark tool="tmux" size="sm" />
Copy tmux attach
@@ -223,7 +217,7 @@ function ActionButtonsRow({ rigId, logicalId, data }: { rigId: string; logicalId
<button
onClick={handleCopyResume}
data-testid="detail-copy-resume"
className="inline-flex items-center gap-1.5 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"
className="inline-flex min-h-11 items-center gap-1.5 px-3 py-2 border border-outline-variant bg-white/30 font-mono text-[10px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60"
>
<CirclePlay aria-hidden="true" className="h-4 w-4 shrink-0" strokeWidth={1.5} />
Copy resume command
@@ -320,11 +314,40 @@ function StatusSection({ data }: { data: NodeDetailData }) {
);
}
function LiveNodeCurrentState({ data }: { data: NodeDetailData }) {
const activityState = getActivityState(data.agentActivity);
const activityLabel = getActivityLabel(activityState);
const activityTextClass = getActivityTextClass(activityState);
const activityStale = isActivityStale(data.agentActivity);
function activityLabelFromVisual(activityVisual: TopologyActivityVisual): string {
if (activityVisual.state === "active") return "active";
if (activityVisual.state === "needs_input") return "needs input";
return activityVisual.state;
}
function activityTextClassFromVisual(activityVisual: TopologyActivityVisual): string {
switch (activityVisual.state) {
case "active":
return "text-emerald-600";
case "needs_input":
return "text-amber-600";
case "blocked":
return "text-red-600";
case "idle":
return "text-stone-400";
}
}
function LiveNodeCurrentState({
data,
activityVisual,
}: {
data: NodeDetailData;
activityVisual?: TopologyActivityVisual | null;
}) {
const fallbackActivityState = getActivityState(data.agentActivity);
const activityLabel = activityVisual
? activityLabelFromVisual(activityVisual)
: getActivityLabel(fallbackActivityState);
const activityTextClass = activityVisual
? activityTextClassFromVisual(activityVisual)
: getActivityTextClass(fallbackActivityState);
const activityStale = activityVisual ? false : isActivityStale(data.agentActivity);
const qitems = data.currentQitems ?? [];
return (
@@ -382,25 +405,9 @@ 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 9-field info table at the top of Overview; 7 compact rows
// + 2 full-width rows per the slice 25 scope amendment).
function EdgesSection({ data }: { data: NodeDetailData }) {
const { outgoing, incoming } = data.edges;
@@ -476,36 +483,78 @@ function ContextUsageSection({ data }: { data: NodeDetailData }) {
);
}
function IdentityTab({ data }: { data: NodeDetailData }) {
// V0.3.1 slice 25 — Overview tab. Dense info-table (9 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, activityVisual }: { data: NodeDetailData; activityVisual?: TopologyActivityVisual | null }) {
return (
<div data-testid="live-identity-section" className="space-y-4">
<IdentitySummary data={data} />
<LiveNodeCurrentState data={data} />
<div data-testid="live-overview-section" className="space-y-4">
<SeatOverviewTable data={data} activityVisual={activityVisual} />
<InlineTerminal data={data} />
<LiveNodeCurrentState data={data} activityVisual={activityVisual} />
<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 +616,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 +644,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,
@@ -634,7 +662,7 @@ function TabNav({
aria-selected={activeTab === tab}
data-testid={`live-tab-${tab}`}
onClick={() => onSelect(tab)}
className={`px-3 py-1.5 font-mono text-[10px] uppercase tracking-wider transition-colors ${
className={`min-h-11 px-3 py-2 font-mono text-[10px] uppercase tracking-wider transition-colors ${
activeTab === tab
? "border-b-2 border-stone-900 text-stone-900 font-bold -mb-px"
: "text-stone-500 hover:text-stone-700"
@@ -649,11 +677,32 @@ function TabNav({
export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
const { data, isLoading, error } = useNodeDetail(rigId, logicalId);
const [activeTab, setActiveTab] = useState<Tab>("identity");
const sessionIndex = useMemo(() => buildTopologySessionIndex(data ? [{
nodeId: `${data.rigId}::${data.logicalId}`,
rigId: data.rigId,
rigName: data.rigName,
logicalId: data.logicalId,
canonicalSessionName: data.canonicalSessionName,
agentActivity: data.agentActivity ?? null,
currentQitems: data.currentQitems ?? null,
startupStatus: data.startupStatus,
}] : []), [data]);
const topologyActivity = useTopologyActivity(sessionIndex);
const activityVisual = data
? topologyActivity.getNodeActivity(`${data.rigId}::${data.logicalId}`, {
agentActivity: data.agentActivity ?? null,
currentQitems: data.currentQitems ?? null,
startupStatus: data.startupStatus,
})
: null;
// 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 +734,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} activityVisual={activityVisual} />}
{activeTab === "details" && (
<DetailsTab rigId={rigId} logicalId={logicalId} data={data} isAgent={isAgent} />
)}
</div>
</>
)}
@@ -0,0 +1,289 @@
// V0.3.1 slice 25 node-page Overview/Details consolidation.
//
// Dense 9-row info table at the top of the seat-detail Overview tab.
// Renders the most-asked questions about a seat at a single glance.
// Two row-shape conventions in one visually-cohesive table:
//
// COMPACT (2-col key/value) — 7 rows:
// runtime / model / profile / spec / activity / context% / total tokens
//
// FULL-WIDTH (single cell with inline label) — 2 rows:
// cwd / current work
//
// Density anchor: TopologyTableView (per ui.md §Topology) — same
// compact mono cells, 1px outline-variant cell borders, vellum
// surface chrome.
//
// Data sources (single source of truth across surfaces):
// - runtime / model / profile / spec / cwd — NodeDetailData directly
// - activity — getActivityState(data.agentActivity); same helper
// LiveNodeCurrentState uses, same source the topology table
// baseline reads. State "running" maps to label "active" so the
// seat page agrees with the topology graph/table naming.
// - context% / total tokens — data.contextUsage.usedPercentage +
// sumTokenCounts(input, output); same helpers TopologyTableView
// uses for the topology table.
// - current work — first entry of data.currentQitems[]; NodeDetailData
// surfaces this via the /api/rigs/<rigId>/nodes/<logicalId>
// endpoint. The qitem refreshes when react-query refetches the
// useNodeDetail query (default refetch interval).
//
// Shimmer (slice 25 HG-3c): when activity state is "running" the
// activity value picks up the slice-14 .topology-table-active-shimmer
// CSS class, giving the same subtle left-to-right sweep TopologyTableView
// uses on active-status text. The class honors prefers-reduced-motion
// (suppressed under reduce per DESIGN.md §Motion).
//
// Graceful absence: any null/undefined/empty cell renders an em-dash
// placeholder. Model + current-work in particular are commonly absent;
// the row stays visible with a dash rather than being hidden or
// rendering "undefined" — 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";
import {
getActivityLabel,
getActivityState,
type ActivityState,
} from "../lib/activity-visuals.js";
import type { TopologyActivityVisual } from "../lib/topology-activity.js";
// Slice 14 shimmer CSS — reused on the activity value when state is
// "running" so the seat-page activity reads with the same visual
// vocabulary as the topology table.
import "./topology/topology-table-shimmer.css";
interface SeatOverviewTableProps {
data: NodeDetailData;
activityVisual?: TopologyActivityVisual | null;
}
type RowShape = "compact" | "full-width";
interface Row {
/** Stable kebab-case key used as testid suffix + render key. */
key: string;
/** Display label. 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;
shape: RowShape;
/** Use the body-font (sans) for human-readable values; mono for IDs
* + numeric metrics. Per DESIGN.md §Typography. Compact rows
* default to mono; full-width rows default to mono too. */
mono?: boolean;
/** When present, the truncated value cell gets `title={titleAttr}`
* so hovering shows the full string. Used for cwd. */
titleAttr?: string;
}
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;
}
function activityLabelFromState(state: ActivityState): string {
// The seat page uses the topology-naming convention: "running" reads
// as "active" so the cell text agrees with the topology graph/table
// (where the "active" label is the one that catches the eye + carries
// the shimmer). Other states pass through the canonical label.
if (state === "running") return "active";
return getActivityLabel(state);
}
function activityLabelFromVisualState(state: TopologyActivityVisual["state"]): string {
if (state === "active") return "active";
if (state === "needs_input") return "needs input";
return state;
}
export function SeatOverviewTable({ data, activityVisual }: SeatOverviewTableProps) {
const fallbackActivityState = getActivityState(data.agentActivity);
const activityState = activityVisual?.state ?? fallbackActivityState;
const activityLabel = activityVisual
? activityLabelFromVisualState(activityVisual.state)
: activityLabelFromState(fallbackActivityState);
const activityIsActive = activityVisual
? activityVisual.state === "active"
: fallbackActivityState === "running";
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 currentQitem = data.currentQitems?.[0] ?? null;
const currentWorkValue: ReactNode | null = currentQitem ? (
<span className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 font-mono text-[10px] text-stone-500">
{currentQitem.qitemId}
</span>
<span className="min-w-0 truncate text-[11px] text-stone-900">
{currentQitem.bodyExcerpt}
</span>
</span>
) : null;
const activityValue: ReactNode = (
<span className="inline-flex items-baseline gap-2">
<span
data-testid="seat-overview-activity-state"
data-activity-state={activityState}
className={
activityIsActive
? "topology-table-active-shimmer text-emerald-600"
: "text-stone-700"
}
>
{activityLabel}
</span>
</span>
);
const rows: Row[] = [
{
key: "runtime",
label: "runtime",
shape: "compact",
value: data.runtime ? (
<RuntimeBadge
runtime={data.runtime}
model={data.model}
size="xs"
compact
variant="inline"
className="max-w-full"
/>
) : null,
},
{ key: "model", label: "model", shape: "compact", value: data.model, mono: true },
{ key: "profile", label: "profile", shape: "compact", value: data.profile, mono: true },
{ key: "spec", label: "spec", shape: "compact", value: specCell, mono: true },
{ key: "activity", label: "activity", shape: "compact", value: activityValue },
{
key: "context-percent",
label: "context %",
shape: "compact",
value: contextPercentage,
mono: true,
},
{
key: "total-tokens",
label: "total tokens",
shape: "compact",
value: tokenLabel,
mono: true,
},
{
key: "cwd",
label: "cwd",
shape: "full-width",
value: data.cwd,
mono: true,
titleAttr: data.cwd ?? undefined,
},
{
key: "current-work",
label: "current work",
shape: "full-width",
value: currentWorkValue,
},
];
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 table-fixed text-left">
<tbody>
{rows.map((row) =>
row.shape === "compact" ? (
<CompactRow key={row.key} row={row} />
) : (
<FullWidthRow key={row.key} row={row} />
),
)}
</tbody>
</table>
</section>
);
}
function CompactRow({ row }: { row: Row }) {
return (
<tr
data-testid={`seat-overview-row-${row.key}`}
data-row-shape="compact"
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>
);
}
function FullWidthRow({ row }: { row: Row }) {
const hasValue =
row.value !== null &&
row.value !== undefined &&
!(typeof row.value === "string" && row.value.trim() === "");
return (
<tr
data-testid={`seat-overview-row-${row.key}`}
data-row-shape="full-width"
className="border-b border-outline-variant/55 last:border-b-0"
>
<td
colSpan={2}
data-testid={`seat-overview-cell-${row.key}`}
className="bg-white/15 px-3 py-1.5 align-middle"
title={row.titleAttr}
>
<div className="flex min-w-0 items-baseline gap-3">
<span className="shrink-0 font-mono text-[10px] lowercase tracking-[0.04em] text-stone-500">
{row.label}
</span>
<span
className={`min-w-0 flex-1 truncate ${
row.mono ? "font-mono text-[11px]" : "text-[11px]"
} text-stone-900`}
>
{hasValue ? row.value : <span className="text-stone-400">—</span>}
</span>
</div>
</td>
</tr>
);
}
+7 -1
View File
@@ -43,6 +43,12 @@ function createStore(): TopologyActivityStore {
};
}
let sharedTopologyActivityStore: TopologyActivityStore = createStore();
export function resetTopologyActivityStoreForTests(): void {
sharedTopologyActivityStore = createStore();
}
function recordNodeActivity(
store: TopologyActivityStore,
nodeId: string,
@@ -95,7 +101,7 @@ function pruneStore(store: TopologyActivityStore, nowMs: number): boolean {
export function useTopologyActivity(index: TopologySessionIndex): TopologyActivitySnapshot {
const [version, setVersion] = useState(0);
const indexRef = useRef(index);
const storeRef = useRef<TopologyActivityStore>(createStore());
const storeRef = useRef<TopologyActivityStore>(sharedTopologyActivityStore);
indexRef.current = index;
const bump = useCallback(() => {
+310 -97
View File
@@ -3,9 +3,18 @@ import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/re
import { createTestRouter } from "./helpers/test-router.js";
import { LiveNodeDetails } from "../src/components/LiveNodeDetails.js";
import { DrawerSelectionContext, type DrawerSelection } from "../src/components/AppShell.js";
import {
resetTopologyActivityStoreForTests,
useTopologyActivity,
} from "../src/hooks/useTopologyActivity.js";
import { buildTopologySessionIndex } from "../src/lib/topology-activity.js";
import { createMockEventSourceClass, instances } from "./helpers/mock-event-source.js";
const mockFetch = vi.fn();
let OriginalEventSource: typeof EventSource | undefined;
// 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 +52,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,14 +70,23 @@ const INFRA_DETAIL = {
compactSpec: { name: null, version: null, profile: null, skillCount: 0, guidanceCount: 0 },
};
describe("LiveNodeDetails", () => {
describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
beforeEach(() => {
OriginalEventSource = globalThis.EventSource;
globalThis.EventSource = createMockEventSourceClass() as unknown as typeof EventSource;
resetTopologyActivityStoreForTests();
globalThis.fetch = mockFetch as unknown as typeof fetch;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
if (OriginalEventSource) {
globalThis.EventSource = OriginalEventSource;
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (globalThis as any).EventSource;
}
});
function mockNodeDetail(detail: Record<string, unknown>) {
@@ -96,103 +124,293 @@ describe("LiveNodeDetails", () => {
);
}
it("renders with identity section showing peers and edges", async () => {
function TopologyActivityWarmup() {
useTopologyActivity(buildTopologySessionIndex([{
nodeId: "rig-1::dev.impl",
rigId: "rig-1",
rigName: "test-rig",
logicalId: "dev.impl",
canonicalSessionName: "dev-impl@test-rig",
}]));
return <div data-testid="activity-warmup" />;
}
// HG-1 — default tab is Overview.
it("HG-1: default tab is Overview on landing", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
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");
});
// 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 — 9 info-table fields (7 compact + 2 full-width). Every row
// testid present.
it("HG-3: info table renders all 9 fields (7 compact + 2 full-width)", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await screen.findByTestId("seat-overview-table");
// 7 compact rows
expect(screen.getByTestId("seat-overview-row-runtime").getAttribute("data-row-shape")).toBe("compact");
expect(screen.getByTestId("seat-overview-row-model").getAttribute("data-row-shape")).toBe("compact");
expect(screen.getByTestId("seat-overview-row-profile").getAttribute("data-row-shape")).toBe("compact");
expect(screen.getByTestId("seat-overview-row-spec").getAttribute("data-row-shape")).toBe("compact");
expect(screen.getByTestId("seat-overview-row-activity").getAttribute("data-row-shape")).toBe("compact");
expect(screen.getByTestId("seat-overview-row-context-percent").getAttribute("data-row-shape")).toBe("compact");
expect(screen.getByTestId("seat-overview-row-total-tokens").getAttribute("data-row-shape")).toBe("compact");
// 2 full-width rows
expect(screen.getByTestId("seat-overview-row-cwd").getAttribute("data-row-shape")).toBe("full-width");
expect(screen.getByTestId("seat-overview-row-current-work").getAttribute("data-row-shape")).toBe("full-width");
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-3a — activity row wires to data.agentActivity via
// getActivityState (the SAME helper LiveNodeCurrentState uses; the
// SAME source the topology baseline reads). When agentActivity.state
// is "running", the cell shows label "active" matching topology
// graph/table naming.
it("HG-3a: activity row wires live and shows 'active' for state=running with shimmer", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await screen.findByTestId("seat-overview-table");
const cell = screen.getByTestId("seat-overview-cell-activity");
expect(cell.textContent?.trim()).toContain("active");
const stateEl = screen.getByTestId("seat-overview-activity-state");
expect(stateEl.getAttribute("data-activity-state")).toBe("active");
// HG-3c shimmer reuse: slice-14 shimmer class applied on active.
expect(stateEl.className).toContain("topology-table-active-shimmer");
});
it("HG-3a: activity row shows 'idle' label and NO shimmer when agentActivity.state=idle", async () => {
mockNodeDetail({
...NODE_DETAIL,
agentActivity: { ...NODE_DETAIL.agentActivity, state: "idle" },
currentQitems: [],
});
renderDetails();
await screen.findByTestId("seat-overview-table");
const cell = screen.getByTestId("seat-overview-cell-activity");
expect(cell.textContent?.trim()).toContain("idle");
const stateEl = screen.getByTestId("seat-overview-activity-state");
expect(stateEl.getAttribute("data-activity-state")).toBe("idle");
expect(stateEl.className).not.toContain("topology-table-active-shimmer");
});
it("HG-3a: seat page reuses recent topology activity across graph/table -> seat navigation", async () => {
const warmup = render(<TopologyActivityWarmup />);
await waitFor(() => {
// Wait for data to load — peers section proves data arrived
expect(screen.getByTestId("detail-peers")).toBeDefined();
expect(instances).toHaveLength(1);
});
expect(screen.getByText("Live Node Details")).toBeDefined();
instances[0]!.simulateMessage(JSON.stringify({
type: "agent.activity",
sessionName: "dev-impl@test-rig",
activity: { state: "running" },
}));
warmup.unmount();
mockNodeDetail({
...NODE_DETAIL,
agentActivity: {
...NODE_DETAIL.agentActivity,
state: "unknown",
reason: "no_activity_signal",
fallback: true,
},
currentQitems: [],
});
renderDetails();
const stateEl = await screen.findByTestId("seat-overview-activity-state");
expect(stateEl.textContent).toBe("active");
expect(stateEl.getAttribute("data-activity-state")).toBe("active");
expect(stateEl.className).toContain("topology-table-active-shimmer");
});
// HG-3b — current-work row wires live to data.currentQitems[0].
// Shows qitemId + bodyExcerpt when present.
it("HG-3b: current-work row wires live and surfaces the in-progress qitem", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await screen.findByTestId("seat-overview-table");
const cell = screen.getByTestId("seat-overview-cell-current-work");
expect(cell.textContent).toContain("qitem-20260504001234-driver");
expect(cell.textContent).toContain("Implement PL-019 edge activity pulse");
});
it("HG-3b: current-work row renders em-dash when no in-progress qitem", async () => {
mockNodeDetail({ ...NODE_DETAIL, currentQitems: [] });
renderDetails();
await screen.findByTestId("seat-overview-table");
const cell = screen.getByTestId("seat-overview-cell-current-work");
expect(cell.textContent).toContain("—");
});
// HG-3d — cwd full-width with truncate-ellipsis + title attribute.
it("HG-3d: cwd renders as full-width row with truncate-ellipsis + tooltip", async () => {
const longCwd = "/Users/example/very/long/workspace/path/that/should/truncate/at/the/end";
mockNodeDetail({ ...NODE_DETAIL, cwd: longCwd });
renderDetails();
const row = await screen.findByTestId("seat-overview-row-cwd");
expect(row.getAttribute("data-row-shape")).toBe("full-width");
const cell = screen.getByTestId("seat-overview-cell-cwd");
expect(cell.getAttribute("title")).toBe(longCwd);
// The inner truncate span carries the truncate class so the cwd
// doesn't overflow the row.
expect(cell.innerHTML).toContain("truncate");
});
// 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 +422,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 +451,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-agent-activity").textContent).toContain("active");
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,24 @@ 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).
// V0.3.1 slice 25 forward-fix-1 — anchored discriminator. The
// earlier `/type\s+Tab\s*=\s*"overview"\s*\|\s*"details"/` regex
// matched as a prefix and would not have failed if a third tab
// was reintroduced (e.g., `"overview" | "details" | "terminal"`).
// Two assertions together prove exact 2-member shape:
// (1) positive — the declaration ends at the second member with
// a trailing `;` (or `\n`) so the union cannot extend silently
// (2) negative — no 3-or-more-member string union exists anywhere
// in the file at the top-level Tab declaration position.
expect(src).toMatch(/type\s+Tab\s*=\s*"overview"\s*\|\s*"details"\s*;/);
// 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"/);
// Negative: NO Tab union literal with 3+ string members. Catches a
// silent reintroduction like `"overview" | "details" | "anything"`.
expect(src).not.toMatch(/type\s+Tab\s*=\s*"[^"]+"\s*\|\s*"[^"]+"\s*\|\s*"[^"]+"/);
// FileReferenceTrigger wraps startup files (P5.1-1a preserved).
expect(src).toMatch(/import\s*\{[^}]*FileReferenceTrigger[^}]*\}\s*from/);
expect(src).toMatch(/<FileReferenceTrigger/);
});