feat(topology): re-orient seat overview table + notification banner

V0.3.1 slice 25 follow-on (builds on 84b4888). Five UI changes on the
seat detail page; UI-only; no daemon source changes; SC-29 NONE.

1) Re-orient SeatOverviewTable info table to column-headers.

The 7 compact key-value rows (runtime / model / profile / spec /
activity / context% / total tokens) flip from one-field-per-row to a
single column-header row plus a single data row spanning columns.
Tighter horizontal layout; more glanceable in-place. The cwd and
current-work full-width rows remain in the same table primitive
(not separate cards) below the data row. Renders an
`overflow-x-auto` wrapper around the column-header + data row so a
narrow viewport (375px) scrolls horizontally instead of mashing 7
cells.

2) Add SeatNotificationBanner above the info table.

New component at packages/ui/src/components/SeatNotificationBanner.tsx
surfaces detailed activity / attention messages front-and-center on
Overview. Source-of-truth fields on NodeDetailData:
  - latestError (string | null)
  - recoveryGuidance ({summary, commands[], notes[]} | null)
  - startupStatus ("attention_required" | "failed" | ...)

Same fields drive the StatusSection card in the Details tab; this
banner is a slim Alert-primitive version for the Overview tab.
Renders nothing when there is no active message. Reuses the existing
Vellum `<Alert>` primitive from components/ui/alert.tsx (no new
design tokens).

3) Remove LiveNodeCurrentState card from Overview.

Activity is now a column in the SeatOverviewTable; current-work is a
full-width row in the same table. The standalone card was redundant.
Grep confirmed no external callers beyond the Overview tab
invocation; the function definition was removed alongside the
invocation. Two helper functions (activityLabelFromVisual,
activityTextClassFromVisual) and the activity-visuals imports that
were only used inside LiveNodeCurrentState were removed in the same
pass.

4) Recent Events stays at the bottom of Overview.

Overview stack order is now: notification banner (optional) → info
table → inline terminal → Recent Events. Recent Events surfaces a
distinct shape of data from anything in the table, so it stays per
the dispatch spec.

5) Re-order Details tab.

Old order (slice 25 baseline): Edges → Peers → ContextUsage →
AgentSpec → Startup → Transcript.

New order:
  1. Startup (top — operator's go-to triage view)
  2. AgentSpec (for agents only)
  3. Edges
  4. Peers
  5. ContextUsage detail
  6. Transcript (at bottom)

No content changes; just reorder. PreviewPane stays absent from
Startup (preserved invariant from slice 25; terminal lives in
Overview).

Tests updated (packages/ui/test/live-node-details.test.tsx):

- HG-1: column-headers + data row + 2 full-width rows (replaces
  HG-3 9-row-shape assertion).
- HG-2: Overview DOM order asserted via compareDocumentPosition:
  notification → table → terminal → recent events. Also asserts
  LiveNodeCurrentState testid is absent.
- HG-3: notification banner renders on attention_required +
  latestError + recoveryGuidance set; renders nothing when status is
  ready and no error / guidance.
- HG-4 (preserved): model cell renders em-dash when null; no literal
  "undefined".
- HG-6: Details tab DOM order asserted: Startup → AgentSpec →
  Edges → Peers → Transcript via compareDocumentPosition; PreviewPane
  testid absent.
- PL-019 (preserved): activity column cell + current-work full-width
  row carry their respective data; LiveNodeCurrentState testid
  absent.

Forensic-trace findings:

- Notification source-of-truth: NodeDetailData.{latestError,
  recoveryGuidance, startupStatus}. Same fields the
  StatusSection card already reads in Details tab. No new
  endpoint; no parallel pipeline.
- Vellum alert primitive: packages/ui/src/components/ui/alert.tsx
  exists with variant: "default" | "destructive". Reused directly;
  no new primitive.
- LiveNodeCurrentState callers: ONLY the Overview tab invocation in
  LiveNodeDetails.tsx (grep verified). Safe to remove the function
  entirely.

Verification:

- live-node-details.test.tsx: 24/24 PASS.
- Full UI suite: 1022 passed / 1 skipped / 3 failed (same 3
  pre-existing failures as main 1629b70; cross-checked; zero new).

Hard gates:

- HG-1 column-headers + data row + 7 fields: PASS.
- HG-2 cwd + current-work full-width below data row in same table:
  PASS.
- HG-3 notification banner renders when active message + hidden when
  none: PASS.
- HG-4 LiveNodeCurrentState card removed from Overview: PASS.
- HG-5 Recent Events at bottom of Overview: PASS.
- HG-6 Details order (Startup → AgentSpec + Edges + Peers →
  Transcript): PASS via compareDocumentPosition.
- HG-7 UI focused suite green; no new failures: PASS.
- HG-8 mobile responsive: column wrapper has overflow-x-auto at 375px
  (manual VM walk pending).
- HG-9 design-reviewer V1-bar visual audit on column density:
  DEFERRED to gate.

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-12 02:14:34 -07:00
1 parent 1629b70187
commit 4a60ac6e9f
4 files changed
+336 -244

No files matched your search

+25 -82
View File
@@ -32,10 +32,13 @@ import { AgentPluginsList } from "./specs/AgentPluginsList.js";
// surfaces in the codebase; this file simply doesn't reference it.
import { SessionPreviewPane } from "./preview/SessionPreviewPane.js";
import { SeatOverviewTable } from "./SeatOverviewTable.js";
import { SeatNotificationBanner } from "./SeatNotificationBanner.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";
// V0.3.1 slice 25 follow-on — activity helpers no longer imported here
// (LiveNodeCurrentState was removed; SeatOverviewTable + StatusSection
// own their own activity rendering paths).
import {
buildTopologySessionIndex,
type TopologyActivityVisual,
@@ -314,77 +317,11 @@ function StatusSection({ data }: { data: NodeDetailData }) {
);
}
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 (
<section
data-testid="live-node-current-state"
className="grid gap-3 border border-outline-variant bg-white/30 p-3 sm:grid-cols-2"
>
<div>
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400">Activity</div>
<div
data-testid="live-node-agent-activity"
className={`mt-1 font-mono text-[11px] font-bold uppercase ${activityTextClass}`}
>
{activityLabel}{activityStale ? " stale" : ""}
</div>
{data.agentActivity?.reason && (
<div className="mt-1 font-mono text-[9px] text-stone-500">{data.agentActivity.reason}</div>
)}
</div>
<div>
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400">Current Work</div>
<div data-testid="live-node-current-qitems" className="mt-1 space-y-1">
{qitems.length > 0 ? qitems.map((qitem) => (
<div key={qitem.qitemId} className="font-mono text-[9px] leading-4 text-stone-700">
<div className="font-bold text-stone-900">{qitem.qitemId}</div>
<div>{qitem.bodyExcerpt}</div>
{qitem.tier && <div className="text-[8px] uppercase tracking-wider text-stone-400">{qitem.tier}</div>}
</div>
)) : (
<div className="font-mono text-[9px] text-stone-400">No current qitems</div>
)}
</div>
</div>
</section>
);
}
// V0.3.1 slice 25 follow-on — LiveNodeCurrentState removed.
// Activity now lives as a column in SeatOverviewTable; current-work
// lives as a full-width row in the same table. The standalone card
// was redundant and dropped from the Overview tab. Grep confirmed no
// external callers in packages/ui/src/ at the time of removal.
function RecentEventsSection({ data }: { data: NodeDetailData }) {
if (!data.recentEvents || data.recentEvents.length === 0) return null;
@@ -483,24 +420,30 @@ function ContextUsageSection({ 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.
// V0.3.1 slice 25 follow-on — Overview tab. Stack order:
// 1. Notification banner (renders only when active message exists)
// 2. Info table (column-oriented; cwd + current-work full-width)
// 3. Inline black-glass terminal
// 4. Recent Events card (at bottom)
//
// LiveNodeCurrentState was REMOVED from Overview in the follow-on
// (activity is a column in the table; current-work is a full-width
// row in the same table — the standalone card was redundant).
function OverviewTab({ data, activityVisual }: { data: NodeDetailData; activityVisual?: TopologyActivityVisual | null }) {
return (
<div data-testid="live-overview-section" className="space-y-4">
<SeatNotificationBanner data={data} />
<SeatOverviewTable data={data} activityVisual={activityVisual} />
<InlineTerminal data={data} />
<LiveNodeCurrentState data={data} activityVisual={activityVisual} />
<RecentEventsSection data={data} />
</div>
);
}
// 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.
// V0.3.1 slice 25 follow-on — Details tab. Re-ordered to put
// Startup first (operator's go-to triage view), then the
// spec/topology group (AgentSpec + Edges + Peers + Context usage
// detail), then Transcript at the bottom.
function DetailsTab({
rigId,
logicalId,
@@ -514,11 +457,11 @@ function DetailsTab({
}) {
return (
<div data-testid="live-details-section" className="space-y-4">
<StartupContent rigId={rigId} logicalId={logicalId} data={data} />
{isAgent ? <AgentSpecSection data={data} /> : null}
<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>
);
@@ -0,0 +1,78 @@
// V0.3.1 slice 25 follow-on — Seat detail notification banner.
//
// Renders detailed activity / attention messages ABOVE the
// SeatOverviewTable on the Overview tab so operators see them
// immediately. Hidden when no active message exists; takes no space.
//
// Source-of-truth fields on NodeDetailData:
// - latestError (string | null) — surfaces failed startup error
// text and any other runtime errors the daemon has cached.
// - recoveryGuidance ({summary, commands[], notes[]} | null) — the
// daemon-curated guidance block for getting the seat unstuck.
// - startupStatus ("attention_required" | "failed" | ...) — the
// attention/failed states drive the visual variant + headline.
//
// The same data is also rendered (in fuller form) by StatusSection
// in the Details tab; this banner is a slim, front-and-center
// surfacing of the headline-grade information for the Overview tab.
// Both surfaces read from the SAME NodeDetailData fields — no
// parallel pipeline.
import { Alert, AlertDescription, AlertTitle } from "./ui/alert.js";
import type { NodeDetailData } from "../hooks/useNodeDetail.js";
interface SeatNotificationBannerProps {
data: NodeDetailData;
}
function headlineFor(data: NodeDetailData): string | null {
if (data.startupStatus === "failed") return "Startup failed";
if (data.startupStatus === "attention_required") return "Attention required";
if (data.latestError) return "Error";
return null;
}
function variantFor(data: NodeDetailData): "default" | "destructive" {
if (data.startupStatus === "failed") return "destructive";
if (data.latestError && data.startupStatus !== "attention_required") return "destructive";
return "default";
}
export function SeatNotificationBanner({ data }: SeatNotificationBannerProps) {
const headline = headlineFor(data);
const hasError = !!data.latestError;
const hasGuidance = !!data.recoveryGuidance;
// Nothing to surface — render nothing (takes no space).
if (!headline && !hasError && !hasGuidance) return null;
return (
<Alert
data-testid="seat-notification-banner"
data-startup-status={data.startupStatus ?? "unknown"}
variant={variantFor(data)}
>
{headline ? (
<AlertTitle data-testid="seat-notification-headline" className="font-mono text-[11px] uppercase tracking-[0.08em]">
{headline}
</AlertTitle>
) : null}
{hasError ? (
<AlertDescription
data-testid="seat-notification-error"
className="font-mono text-[10px]"
>
{data.latestError}
</AlertDescription>
) : null}
{hasGuidance && data.recoveryGuidance ? (
<AlertDescription
data-testid="seat-notification-guidance"
className="mt-1 font-mono text-[10px] text-stone-700"
>
{data.recoveryGuidance.summary}
</AlertDescription>
) : null}
</Alert>
);
}
+118 -116
View File
@@ -1,25 +1,32 @@
// V0.3.1 slice 25 node-page Overview/Details consolidation.
// V0.3.1 slice 25 node-page Overview/Details — column-oriented variant.
//
// Dense 9-row info table at the top of the seat-detail Overview tab.
// Dense 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:
// COLUMN-HEADER + DATA ROW (horizontal compact) — 7 fields:
// runtime / model / profile / spec / activity / context% / total tokens
//
// FULL-WIDTH (single cell with inline label) — 2 rows:
// FULL-WIDTH rows (single cell with inline label) — 2 rows below:
// cwd / current work
//
// The follow-on re-orientation (2026-05-12) flipped the compact-row
// shape from row-oriented (one field per row) to column-oriented
// (one row with column headers + one data row spanning columns).
// Tighter screen real estate; glanceable horizontally. The cwd +
// current-work full-width rows are preserved in the same table
// primitive (not separate cards) below the data row.
//
// 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.
// - activity — getActivityState(data.agentActivity) baseline OR
// activityVisual when wired via useTopologyActivity; same source
// the topology graph + table read. State "running" maps to label
// "active" so the seat page agrees with topology naming.
// - context% / total tokens — data.contextUsage.usedPercentage +
// sumTokenCounts(input, output); same helpers TopologyTableView
// uses for the topology table.
@@ -28,16 +35,16 @@
// 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).
// Shimmer: when activity state is "active" (or baseline maps to
// "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.
// Honors prefers-reduced-motion 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.
// Mobile (HG-8): the column-header row + data row wrap in an
// `overflow-x-auto` scroll container so a 375px viewport can scroll
// horizontally rather than mash 7 cells together. The full-width
// rows below stay full-width regardless.
import type { ReactNode } from "react";
import type { NodeDetailData } from "../hooks/useNodeDetail.js";
@@ -53,8 +60,8 @@ import {
} 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.
// "active" / "running" so the seat-page activity reads with the same
// visual vocabulary as the topology table.
import "./topology/topology-table-shimmer.css";
interface SeatOverviewTableProps {
@@ -62,23 +69,25 @@ interface SeatOverviewTableProps {
activityVisual?: TopologyActivityVisual | null;
}
type RowShape = "compact" | "full-width";
interface Row {
interface ColumnField {
/** Stable kebab-case key used as testid suffix + render key. */
key: string;
/** Display label. Lowercase mono. */
/** Display header. Lowercase mono. */
label: string;
/** Cell content. May be string OR a ReactNode (e.g., RuntimeBadge).
* null/undefined/empty-string renders as "—" placeholder. */
/** Cell content. null/undefined/empty renders as em-dash 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 for IDs + numeric metrics; non-mono for human-readable
* values like the RuntimeBadge. Per DESIGN.md §Typography. */
mono?: boolean;
/** When present, the truncated value cell gets `title={titleAttr}`
* so hovering shows the full string. Used for cwd. */
}
interface FullWidthField {
key: string;
label: string;
value: ReactNode | null | undefined;
mono?: boolean;
/** Carries the full string via `title={titleAttr}` so hovering shows
* the unabbreviated value. Used for cwd. */
titleAttr?: string;
}
@@ -89,10 +98,6 @@ function placeholderOrValue(value: ReactNode | null | undefined): ReactNode {
}
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);
}
@@ -141,26 +146,23 @@ export function SeatOverviewTable({ data, activityVisual }: SeatOverviewTablePro
) : 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
data-testid="seat-overview-activity-state"
data-activity-state={activityState}
className={
activityIsActive
? "topology-table-active-shimmer text-emerald-600"
: "text-stone-700"
}
>
{activityLabel}
</span>
);
const rows: Row[] = [
const columnFields: ColumnField[] = [
{
key: "runtime",
label: "runtime",
shape: "compact",
value: data.runtime ? (
<RuntimeBadge
runtime={data.runtime}
@@ -172,28 +174,18 @@ export function SeatOverviewTable({ data, activityVisual }: SeatOverviewTablePro
/>
) : 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: "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: "activity", label: "activity", value: activityValue },
{ key: "context-percent", label: "context %", value: contextPercentage, mono: true },
{ key: "total-tokens", label: "total tokens", value: tokenLabel, mono: true },
];
const fullWidthFields: FullWidthField[] = [
{
key: "cwd",
label: "cwd",
shape: "full-width",
value: data.cwd,
mono: true,
titleAttr: data.cwd ?? undefined,
@@ -201,7 +193,6 @@ export function SeatOverviewTable({ data, activityVisual }: SeatOverviewTablePro
{
key: "current-work",
label: "current work",
shape: "full-width",
value: currentWorkValue,
},
];
@@ -214,73 +205,84 @@ export function SeatOverviewTable({ data, activityVisual }: SeatOverviewTablePro
<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>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr
data-testid="seat-overview-header-row"
className="border-b border-outline-variant/55 bg-stone-50/30"
>
{columnFields.map((field) => (
<th
key={field.key}
scope="col"
data-testid={`seat-overview-header-${field.key}`}
className="px-3 py-1.5 text-left font-mono text-[10px] font-normal lowercase tracking-[0.04em] text-stone-500"
>
{field.label}
</th>
))}
</tr>
</thead>
<tbody>
<tr
data-testid="seat-overview-data-row"
data-row-shape="data"
className="border-b border-outline-variant/55"
>
{columnFields.map((field) => (
<td
key={field.key}
data-testid={`seat-overview-cell-${field.key}`}
className={`min-w-0 px-3 py-1.5 align-middle ${
field.mono ? "font-mono text-[11px]" : "text-[11px]"
} text-stone-900`}
>
<div className="truncate">{placeholderOrValue(field.value)}</div>
</td>
))}
</tr>
{fullWidthFields.map((field) => (
<FullWidthRow
key={field.key}
field={field}
colSpan={columnFields.length}
/>
))}
</tbody>
</table>
</div>
</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 }) {
function FullWidthRow({ field, colSpan }: { field: FullWidthField; colSpan: number }) {
const hasValue =
row.value !== null &&
row.value !== undefined &&
!(typeof row.value === "string" && row.value.trim() === "");
field.value !== null &&
field.value !== undefined &&
!(typeof field.value === "string" && field.value.trim() === "");
return (
<tr
data-testid={`seat-overview-row-${row.key}`}
data-testid={`seat-overview-row-${field.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}`}
colSpan={colSpan}
data-testid={`seat-overview-cell-${field.key}`}
className="bg-white/15 px-3 py-1.5 align-middle"
title={row.titleAttr}
title={field.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}
{field.label}
</span>
<span
className={`min-w-0 flex-1 truncate ${
row.mono ? "font-mono text-[11px]" : "text-[11px]"
field.mono ? "font-mono text-[11px]" : "text-[11px]"
} text-stone-900`}
>
{hasValue ? row.value : <span className="text-stone-400">—</span>}
{hasValue ? field.value : <span className="text-stone-400">—</span>}
</span>
</div>
</td>
+115 -46
View File
@@ -164,42 +164,63 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
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);
// HG-2 (follow-on) — Overview stack order: notification banner
// (optional) -> info table -> inline terminal -> recent events
// (at bottom). LiveNodeCurrentState is REMOVED. Order asserted via
// compareDocumentPosition between always-rendered elements.
it("HG-2: Overview tab DOM order is notification -> info table -> terminal -> recent events", async () => {
// Inject a startupStatus that surfaces the notification banner so
// the assertion covers the full 4-element order.
mockNodeDetail({
...NODE_DETAIL,
startupStatus: "attention_required",
latestError: "synthetic attention",
recentEvents: [
{ type: "agent.activity", createdAt: "2026-05-12T00:00:00Z" },
],
});
renderDetails();
const banner = await screen.findByTestId("seat-notification-banner");
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.
const events = await screen.findByTestId("live-node-recent-events");
expect(banner.compareDocumentPosition(table)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(table.compareDocumentPosition(terminal)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(terminal.compareDocumentPosition(activity)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(terminal.compareDocumentPosition(events)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
// HG-4: LiveNodeCurrentState no longer mounts inside Overview.
expect(screen.queryByTestId("live-node-current-state")).toBeNull();
});
// 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 () => {
// HG-1 (follow-on) — info table renders as column-headers + data
// row for the 7 compact fields, plus 2 full-width rows below.
it("HG-1: info table renders column-headers + single data row (7 fields)", 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
// 7 column headers + 1 data row in the column-oriented section.
const headerRow = screen.getByTestId("seat-overview-header-row");
expect(headerRow).toBeDefined();
expect(screen.getByTestId("seat-overview-header-runtime")).toBeDefined();
expect(screen.getByTestId("seat-overview-header-model")).toBeDefined();
expect(screen.getByTestId("seat-overview-header-profile")).toBeDefined();
expect(screen.getByTestId("seat-overview-header-spec")).toBeDefined();
expect(screen.getByTestId("seat-overview-header-activity")).toBeDefined();
expect(screen.getByTestId("seat-overview-header-context-percent")).toBeDefined();
expect(screen.getByTestId("seat-overview-header-total-tokens")).toBeDefined();
const dataRow = screen.getByTestId("seat-overview-data-row");
expect(dataRow.getAttribute("data-row-shape")).toBe("data");
// HG-2 (follow-on) — cwd + current-work remain in the same table
// primitive as full-width rows below the data row.
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");
// Data-cell content reads from the same NodeDetailData fields.
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]");
@@ -303,20 +324,20 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
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 () => {
// HG-4 (preserved) — model graceful absence: column cell shows
// em-dash, NOT "undefined". The header row remains; the model cell
// in the data row carries the placeholder.
it("HG-4: model cell renders em-dash gracefully when model field is absent", async () => {
mockNodeDetail({ ...NODE_DETAIL, model: null });
renderDetails();
await screen.findByTestId("seat-overview-table");
// Header row still present for model.
expect(screen.getByTestId("seat-overview-header-model")).toBeDefined();
// Data cell carries placeholder, not literal "undefined".
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("—");
});
@@ -337,9 +358,11 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
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 () => {
// HG-6 (follow-on) — Details tab re-ordered. New top-to-bottom
// order: Startup → AgentSpec → Edges → Peers → (Context usage) →
// Transcript. Asserted via DOM order between always-rendered
// section testids.
it("HG-6: Details tab order is Startup -> Spec/Topology (AgentSpec + Edges + Peers) -> Transcript", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
fireEvent.click(await screen.findByTestId("live-tab-details"));
@@ -347,15 +370,55 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
await waitFor(() => {
expect(screen.getByTestId("live-details-section")).toBeDefined();
});
expect(screen.getByTestId("detail-edges")).toBeDefined();
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).
const startup = screen.getByTestId("live-startup-section");
const agentSpec = screen.getByTestId("live-agent-spec-section");
const edges = screen.getByTestId("detail-edges");
const peers = screen.getByTestId("detail-peers");
const transcript = screen.getByTestId("live-transcript-section");
expect(startup.compareDocumentPosition(agentSpec)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(agentSpec.compareDocumentPosition(edges)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(edges.compareDocumentPosition(peers)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(peers.compareDocumentPosition(transcript)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
// PreviewPane is intentionally absent from Startup (terminal lives
// in Overview; preserved invariant from the slice-25 baseline).
expect(screen.queryByTestId("live-node-preview")).toBeNull();
});
// HG-3 (follow-on) — Notification banner renders only when an
// active message exists; nothing renders otherwise.
it("HG-3: notification banner renders when latestError + attention_required is set", async () => {
mockNodeDetail({
...NODE_DETAIL,
startupStatus: "attention_required",
latestError: "Synthetic test error.",
recoveryGuidance: {
summary: "Synthetic guidance summary.",
commands: ["rig restore <snap>"],
notes: [],
},
});
renderDetails();
const banner = await screen.findByTestId("seat-notification-banner");
expect(banner.getAttribute("data-startup-status")).toBe("attention_required");
expect(screen.getByTestId("seat-notification-headline").textContent).toContain("Attention required");
expect(screen.getByTestId("seat-notification-error").textContent).toContain("Synthetic test error");
expect(screen.getByTestId("seat-notification-guidance").textContent).toContain("Synthetic guidance summary");
});
it("HG-3: notification banner does NOT render when no active message", async () => {
mockNodeDetail({
...NODE_DETAIL,
startupStatus: "ready",
latestError: null,
recoveryGuidance: null,
});
renderDetails();
await screen.findByTestId("seat-overview-table");
expect(screen.queryByTestId("seat-notification-banner")).toBeNull();
});
// 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 () => {
@@ -458,17 +521,23 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
expect(screen.getByTestId("agent-plugins-empty")).toBeDefined();
});
// 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 () => {
// PL-019 preserved (follow-on) — activity + current-work surface
// in the Overview info table now (LiveNodeCurrentState card removed
// in the follow-on). Activity is the column cell; current-work is
// the full-width row.
it("PL-019 preserved: Overview info table surfaces activity + current qitem", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await waitFor(() => {
expect(screen.getByTestId("live-node-current-state")).toBeDefined();
});
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");
await screen.findByTestId("seat-overview-table");
// Activity column cell carries the active label (topology naming).
expect(screen.getByTestId("seat-overview-cell-activity").textContent?.trim()).toContain("active");
// Current-work full-width row carries the qitem id + body excerpt.
const cwCell = screen.getByTestId("seat-overview-cell-current-work");
expect(cwCell.textContent).toContain("04001234-driver");
expect(cwCell.textContent).toContain("Implement PL-019 edge activity pulse");
// LiveNodeCurrentState card removed.
expect(screen.queryByTestId("live-node-current-state")).toBeNull();
});
// Resume action glyph remains independent of tab structure.