mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
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:
1 parent
159433a163
commit
f48ea03040
4 files changed
+421
-201
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user