feat(topology): seat overview polish — alert-only banner, grid lines, separated cwd/current-work

V0.3.1 slice 25 second follow-on (builds on f27da1a). Five UI polish
changes to the seat detail Overview tab based on VM walk feedback.
UI-only; SC-29 NONE.

1) Banner is alert-only.

The follow-on-1 banner triggered on `recoveryGuidance` alone, which
is set on every NodeInventoryEntry as generic recovery documentation
— it's NOT an alert. The banner now renders ONLY when one of these
real-alert states is set:
  - startupStatus === "failed"
  - startupStatus === "attention_required"
  - latestError !== null

Generic recoveryGuidance alone no longer triggers the banner.
recoveryGuidance is still surfaced as supplementary content INSIDE
the banner when one of the alert states is also present.

2) Section-header row removed from SeatOverviewTable.

The "OVERVIEW" section-header div above the column headers was
removed. Column headers are now the first row of the table; no
preamble row.

3) Vertical grid lines between column cells.

Header + data rows carry `border-r border-outline-variant/55` on
every column cell except the last. Clearer cell boundaries; matches
existing table-grid pattern in product (Explorer + AppShell use the
same `border-r border-outline-variant` convention).

4) cwd + current-work moved into a separate primitive.

New component `SeatOverviewSecondary` renders cwd + current-work as
labeled key-value rows in its own section below the column table.
Previously rendered via `colSpan` full-width rows inside the same
table primitive; now visually distinct from the column table above.

The Overview stack is now:
  1. SeatNotificationBanner (real-alert-only)
  2. SeatOverviewTable (7-column header + data row, vertical grid)
  3. SeatOverviewSecondary (cwd + current-work, separated)
  4. InlineTerminal
  5. RecentEventsSection

5) "total tokens" → "tokens" column header label.

Conserves horizontal width on the 7-column row. Underlying field key
("total-tokens") + the testid (`seat-overview-cell-total-tokens`) are
preserved so existing assertions keep working.

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

- HG-1: column headers + single data row + no OVERVIEW row + tokens
  label (not "total tokens") + cwd/current-work moved OUT of the
  column table.
- HG-2: Overview DOM order now asserts notification → table →
  secondary → terminal → recent events (5-element chain via
  compareDocumentPosition).
- HG-2 (new): banner does NOT render for normal seat with generic
  recoveryGuidance only — guards the regression class the dispatch
  surfaced.
- HG-2 (new): banner renders for startupStatus=failed alone, and for
  latestError alone, even without recoveryGuidance.
- HG-3 (preserved): existing banner-renders-with-attention_required
  test continues to pass.
- HG-3b: current-work cell wires live and renders em-dash in the
  secondary primitive (testid suffix updated).
- HG-3d: cwd cell renders with truncate + title tooltip in the
  secondary primitive (testid suffix updated).
- HG-4 (new): vertical grid-lines test asserts `border-r
  border-outline-variant` on every column cell except the last for
  BOTH header + data rows.
- HG-5 (new): seat-overview-secondary primitive is distinct from
  seat-overview-table primitive (neither contains the other).
- HG-6 (new): "tokens" column header label, not "total tokens".
- PL-019 (preserved): activity column cell + current-work in
  secondary primitive surface their data.

Forensic-trace findings:

- "OVERVIEW" row source: the section-header `<div>` at the top of
  SeatOverviewTable. Removed in the rewrite.
- Alert source: NodeDetailData.{startupStatus, latestError,
  recoveryGuidance}. recoveryGuidance is generic on every seat;
  alert-state membership is determined by startupStatus + latestError
  alone. Confirmed by reading the daemon response shape + matching
  what StatusSection in Details already uses for the showFailure
  branch.
- Grid-line pattern: Explorer + AppShell + vellum-sheet use
  `border-r border-outline-variant` for vertical separators. Reused
  the same class (with the /55 alpha modifier to match the
  follow-on-1 table's horizontal row borders).

Verification:

- live-node-details.test.tsx: 29/29 PASS.
- Full UI suite: 1023 passed / 1 skipped / 3 failed (same 3
  pre-existing failures as main f27da1a; zero new).

Hard gates:

- HG-1 banner hidden for normal seats with recoveryGuidance: PASS.
- HG-2 banner renders for failed / attention_required / latestError:
  PASS.
- HG-3 "OVERVIEW" row removed: PASS.
- HG-4 vertical grid lines on column cells: PASS.
- HG-5 cwd + current-work in separate primitive below the table:
  PASS.
- HG-6 column label is "tokens" not "total tokens": PASS.
- HG-7 UI focused suite green; no new failures: PASS.
- HG-8 mobile responsive (column table overflow-x-auto preserved;
  secondary primitive uses flex layout for narrow viewports): manual
  VM walk pending.
- HG-9 design-reviewer V1-bar audit: 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:48:50 -07:00
1 parent f27da1a48a
commit bc5af3f201
5 files changed
+309 -181

No files matched your search

@@ -32,6 +32,7 @@ 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 { SeatOverviewSecondary } from "./SeatOverviewSecondary.js";
import { SeatNotificationBanner } from "./SeatNotificationBanner.js";
import { FileReferenceTrigger } from "./drawer-triggers/FileReferenceTrigger.js";
import { displayPodName, inferPodName } from "../lib/display-name.js";
@@ -426,14 +427,19 @@ function ContextUsageSection({ data }: { data: NodeDetailData }) {
// 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).
// V0.3.1 slice 25 follow-on-2 — Overview stack:
// 1. SeatNotificationBanner (real-alert-only; hidden for normal seats)
// 2. SeatOverviewTable (column-headers + data row; vertical grid lines)
// 3. SeatOverviewSecondary (cwd + current-work, visually separated
// from the column table above)
// 4. InlineTerminal (black-glass)
// 5. RecentEventsSection (at bottom)
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} />
<SeatOverviewSecondary data={data} />
<InlineTerminal data={data} />
<RecentEventsSection data={data} />
</div>
@@ -40,23 +40,30 @@ function variantFor(data: NodeDetailData): "default" | "destructive" {
export function SeatNotificationBanner({ data }: SeatNotificationBannerProps) {
const headline = headlineFor(data);
// V0.3.1 slice 25 follow-on-2 — banner renders ONLY for real alert
// states (failed / attention_required / latestError). Generic
// recoveryGuidance alone does NOT qualify; it's documentation of
// recovery steps, not an attention event. The follow-on-1 rendered
// the banner on guidance alone, producing false alerts on every
// normal seat — the dispatch corrected the intent to alert-only.
if (!headline) return null;
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}
<AlertTitle
data-testid="seat-notification-headline"
className="font-mono text-[11px] uppercase tracking-[0.08em]"
>
{headline}
</AlertTitle>
{hasError ? (
<AlertDescription
data-testid="seat-notification-error"
@@ -0,0 +1,98 @@
// V0.3.1 slice 25 second follow-on — cwd + current-work section.
//
// Visually separated from the SeatOverviewTable above. Renders the
// two "wide" fields as labeled key-value rows in their own section
// primitive so the eye reads them as distinct from the dense column
// table.
//
// Data sources (same NodeDetailData fields as the original
// full-width rows):
// - cwd — data.cwd
// - current work — data.currentQitems[0]; rendered as qitemId +
// body excerpt; em-dash when no in-progress qitem
//
// CWD value carries `title={cwd}` so hovering reveals the full path
// when the value cell truncates.
import type { ReactNode } from "react";
import type { NodeDetailData } from "../hooks/useNodeDetail.js";
interface SeatOverviewSecondaryProps {
data: NodeDetailData;
}
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 SeatOverviewSecondary({ data }: SeatOverviewSecondaryProps) {
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;
return (
<section
data-testid="seat-overview-secondary"
className="border border-outline-variant bg-white/30"
>
<dl className="divide-y divide-outline-variant/55">
<Row
fieldKey="cwd"
label="cwd"
value={data.cwd}
mono
titleAttr={data.cwd ?? undefined}
/>
<Row
fieldKey="current-work"
label="current work"
value={currentWorkValue}
/>
</dl>
</section>
);
}
function Row({
fieldKey,
label,
value,
mono,
titleAttr,
}: {
fieldKey: string;
label: string;
value: ReactNode | null | undefined;
mono?: boolean;
titleAttr?: string;
}) {
return (
<div
data-testid={`seat-overview-secondary-row-${fieldKey}`}
className="flex min-w-0 items-baseline gap-3 px-3 py-1.5"
title={titleAttr}
>
<dt className="shrink-0 font-mono text-[10px] lowercase tracking-[0.04em] text-stone-500">
{label}
</dt>
<dd
data-testid={`seat-overview-secondary-cell-${fieldKey}`}
className={`min-w-0 flex-1 truncate ${
mono ? "font-mono text-[11px]" : "text-[11px]"
} text-stone-900`}
>
{placeholderOrValue(value)}
</dd>
</div>
);
}
+27 -127
View File
@@ -1,28 +1,22 @@
// V0.3.1 slice 25 node-page Overview/Details — column-oriented variant.
// V0.3.1 slice 25 second follow-on — Overview info table polish.
//
// 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:
// Column-oriented dense info table at the top of the seat-detail
// Overview tab. The follow-on-2 polish:
// - Section-header row removed (column headers ARE the first row).
// - Vertical grid lines (border-r border-outline-variant) between
// column cells in header + data rows; clearer cell boundaries.
// - "total tokens" column header label tightened to "tokens" to
// conserve horizontal width.
// - cwd + current-work moved OUT of this table into a separate
// primitive (SeatOverviewSecondary) below; this component renders
// ONLY the 7-column row of compact fields now.
//
// COLUMN-HEADER + DATA ROW (horizontal compact) — 7 fields:
// runtime / model / profile / spec / activity / context% / total tokens
//
// 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.
// Mobile (HG-8): the column-header row + data row wrap in an
// `overflow-x-auto` scroll container so a 375px viewport scrolls
// horizontally rather than mash 7 cells together.
//
// Data sources (single source of truth across surfaces):
// - runtime / model / profile / spec / cwd — NodeDetailData directly
// - runtime / model / profile / spec — NodeDetailData directly
// - activity — getActivityState(data.agentActivity) baseline OR
// activityVisual when wired via useTopologyActivity; same source
// the topology graph + table read. State "running" maps to label
@@ -30,21 +24,11 @@
// - 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: 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.
//
// 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.
// .topology-table-active-shimmer CSS class. Honors
// prefers-reduced-motion per DESIGN.md §Motion.
import type { ReactNode } from "react";
import type { NodeDetailData } from "../hooks/useNodeDetail.js";
@@ -59,9 +43,6 @@ import {
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
// "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 {
@@ -70,25 +51,10 @@ interface SeatOverviewTableProps {
}
interface ColumnField {
/** Stable kebab-case key used as testid suffix + render key. */
key: string;
/** Display header. Lowercase mono. */
label: string;
/** Cell content. null/undefined/empty renders as em-dash placeholder. */
value: ReactNode | null | undefined;
/** Mono for IDs + numeric metrics; non-mono for human-readable
* values like the RuntimeBadge. Per DESIGN.md §Typography. */
mono?: boolean;
}
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;
}
function placeholderOrValue(value: ReactNode | null | undefined): ReactNode {
@@ -133,18 +99,6 @@ export function SeatOverviewTable({ data, activityVisual }: SeatOverviewTablePro
? `${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
data-testid="seat-overview-activity-state"
@@ -179,32 +133,16 @@ export function SeatOverviewTable({ data, activityVisual }: SeatOverviewTablePro
{ 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 },
{ key: "total-tokens", label: "tokens", value: tokenLabel, mono: true },
];
const fullWidthFields: FullWidthField[] = [
{
key: "cwd",
label: "cwd",
value: data.cwd,
mono: true,
titleAttr: data.cwd ?? undefined,
},
{
key: "current-work",
label: "current work",
value: currentWorkValue,
},
];
const lastIdx = columnFields.length - 1;
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>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
@@ -212,12 +150,14 @@ export function SeatOverviewTable({ data, activityVisual }: SeatOverviewTablePro
data-testid="seat-overview-header-row"
className="border-b border-outline-variant/55 bg-stone-50/30"
>
{columnFields.map((field) => (
{columnFields.map((field, idx) => (
<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"
className={`px-3 py-1.5 text-left font-mono text-[10px] font-normal lowercase tracking-[0.04em] text-stone-500 ${
idx < lastIdx ? "border-r border-outline-variant/55" : ""
}`}
>
{field.label}
</th>
@@ -228,64 +168,24 @@ export function SeatOverviewTable({ data, activityVisual }: SeatOverviewTablePro
<tr
data-testid="seat-overview-data-row"
data-row-shape="data"
className="border-b border-outline-variant/55"
>
{columnFields.map((field) => (
{columnFields.map((field, idx) => (
<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`}
} text-stone-900 ${
idx < lastIdx ? "border-r border-outline-variant/55" : ""
}`}
>
<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 FullWidthRow({ field, colSpan }: { field: FullWidthField; colSpan: number }) {
const hasValue =
field.value !== null &&
field.value !== undefined &&
!(typeof field.value === "string" && field.value.trim() === "");
return (
<tr
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={colSpan}
data-testid={`seat-overview-cell-${field.key}`}
className="bg-white/15 px-3 py-1.5 align-middle"
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">
{field.label}
</span>
<span
className={`min-w-0 flex-1 truncate ${
field.mono ? "font-mono text-[11px]" : "text-[11px]"
} text-stone-900`}
>
{hasValue ? field.value : <span className="text-stone-400">—</span>}
</span>
</div>
</td>
</tr>
);
}
+160 -43
View File
@@ -164,13 +164,14 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
expect(screen.getByTestId("live-tab-details")).toBeDefined();
});
// HG-2 (follow-on) — Overview stack order: notification banner
// (optional) -> info table -> inline terminal -> recent events
// (at bottom). LiveNodeCurrentState is REMOVED. Order asserted via
// HG-2 (follow-on-2) — Overview stack order: notification banner
// (optional, real-alert-only) -> info table -> secondary (cwd +
// current-work) -> 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 () => {
it("HG-2: Overview tab DOM order is notification -> table -> secondary -> terminal -> recent events", async () => {
// Inject a startupStatus that surfaces the notification banner so
// the assertion covers the full 4-element order.
// the assertion covers the full 5-element order.
mockNodeDetail({
...NODE_DETAIL,
startupStatus: "attention_required",
@@ -183,25 +184,29 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
const banner = await screen.findByTestId("seat-notification-banner");
const table = await screen.findByTestId("seat-overview-table");
const secondary = await screen.findByTestId("seat-overview-secondary");
const terminal = await screen.findByTestId("live-terminal-shell");
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(table.compareDocumentPosition(secondary)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(secondary.compareDocumentPosition(terminal)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(terminal.compareDocumentPosition(events)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
// HG-4: LiveNodeCurrentState no longer mounts inside Overview.
// LiveNodeCurrentState no longer mounts inside Overview (follow-on-1
// invariant preserved).
expect(screen.queryByTestId("live-node-current-state")).toBeNull();
});
// 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 () => {
// HG-1 (follow-on-2) — info table renders column-headers + single
// data row for 7 fields. The "OVERVIEW" section header row is
// removed; column headers are the first row.
it("HG-1: info table renders column-headers + single data row (7 fields); no OVERVIEW row", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await screen.findByTestId("seat-overview-table");
const table = await screen.findByTestId("seat-overview-table");
// 7 column headers + 1 data row in the column-oriented section.
// 7 column headers
const headerRow = screen.getByTestId("seat-overview-header-row");
expect(headerRow).toBeDefined();
expect(screen.getByTestId("seat-overview-header-runtime")).toBeDefined();
@@ -212,22 +217,83 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
expect(screen.getByTestId("seat-overview-header-context-percent")).toBeDefined();
expect(screen.getByTestId("seat-overview-header-total-tokens")).toBeDefined();
// HG-6 (follow-on-2): header label is "tokens" not "total tokens".
expect(screen.getByTestId("seat-overview-header-total-tokens").textContent).toBe("tokens");
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");
// HG-3 (follow-on-2) section-header row removed — no element with
// "OVERVIEW" / "Overview" as its only content inside the table.
const tableText = table.textContent ?? "";
// The activity COLUMN may show "active" but the legacy section
// header was the string "Overview" as standalone div text.
expect(tableText.toLowerCase()).not.toContain("overview");
// Data-cell content reads from the same NodeDetailData fields.
// HG-5 (follow-on-2) cwd + current-work moved OUT of the table.
// No full-width rows inside the column table anymore.
expect(screen.queryByTestId("seat-overview-row-cwd")).toBeNull();
expect(screen.queryByTestId("seat-overview-row-current-work")).toBeNull();
// Data-cell content reads from 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]");
expect(screen.getByTestId("seat-overview-cell-cwd").textContent).toContain("/workspace");
expect(screen.getByTestId("seat-overview-cell-context-percent").textContent).toContain("42%");
});
// HG-4 (follow-on-2) — vertical grid lines between column cells.
// Every column cell except the last carries `border-r border-outline-variant`.
it("HG-4: column cells have vertical grid lines (border-r between columns)", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await screen.findByTestId("seat-overview-table");
// Header cells (first 6 of 7 carry border-r; last does not).
const headers = [
"runtime",
"model",
"profile",
"spec",
"activity",
"context-percent",
];
for (const key of headers) {
const cell = screen.getByTestId(`seat-overview-header-${key}`);
expect(cell.className).toContain("border-r");
expect(cell.className).toContain("border-outline-variant");
}
// Last header (tokens) — no trailing border-r.
expect(screen.getByTestId("seat-overview-header-total-tokens").className).not.toContain("border-r");
// Data cells mirror.
for (const key of headers) {
const cell = screen.getByTestId(`seat-overview-cell-${key}`);
expect(cell.className).toContain("border-r");
expect(cell.className).toContain("border-outline-variant");
}
expect(screen.getByTestId("seat-overview-cell-total-tokens").className).not.toContain("border-r");
});
// HG-5 (follow-on-2) — cwd + current-work moved into a separate
// primitive below the column table; not via colSpan inside the
// same table.
it("HG-5: cwd + current-work render in a separate primitive (seat-overview-secondary)", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
const secondary = await screen.findByTestId("seat-overview-secondary");
expect(secondary).toBeDefined();
// Separate primitive — distinct from the column table.
const table = screen.getByTestId("seat-overview-table");
expect(table.contains(secondary)).toBe(false);
expect(secondary.contains(table)).toBe(false);
// Rows present inside the secondary primitive.
expect(screen.getByTestId("seat-overview-secondary-row-cwd")).toBeDefined();
expect(screen.getByTestId("seat-overview-secondary-row-current-work")).toBeDefined();
});
// 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
@@ -291,37 +357,36 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
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 () => {
// HG-3b (follow-on-2) — current-work wires live in the secondary
// primitive below the column table.
it("HG-3b: current-work cell 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");
await screen.findByTestId("seat-overview-secondary");
const cell = screen.getByTestId("seat-overview-secondary-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 () => {
it("HG-3b: current-work cell 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");
await screen.findByTestId("seat-overview-secondary");
const cell = screen.getByTestId("seat-overview-secondary-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 () => {
// HG-3d (follow-on-2) — cwd renders in the secondary primitive
// with truncate + title tooltip on the row.
it("HG-3d: cwd renders in the secondary primitive with truncate + 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");
const row = await screen.findByTestId("seat-overview-secondary-row-cwd");
expect(row.getAttribute("title")).toBe(longCwd);
const cell = screen.getByTestId("seat-overview-secondary-cell-cwd");
// The cell carries the truncate class so the cwd doesn't overflow.
expect(cell.className).toContain("truncate");
});
// HG-4 (preserved) — model graceful absence: column cell shows
@@ -419,6 +484,59 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
expect(screen.queryByTestId("seat-notification-banner")).toBeNull();
});
// HG-2 (follow-on-2 critical) — banner does NOT render when only
// generic recoveryGuidance is present (no failed / attention_required
// / latestError). recoveryGuidance is documentation of recovery
// steps, NOT an alert. The follow-on-1 banner triggered on guidance
// alone, producing false alerts on every normal seat; this test
// guards that regression class.
it("HG-2: notification banner does NOT render for normal seat with generic recoveryGuidance only", async () => {
mockNodeDetail({
...NODE_DETAIL,
startupStatus: "ready",
latestError: null,
recoveryGuidance: {
summary: "Generic recovery guidance documentation.",
commands: ["rig restore <snap>"],
notes: [],
},
});
renderDetails();
await screen.findByTestId("seat-overview-table");
// Banner must NOT mount — recoveryGuidance alone is not an alert.
expect(screen.queryByTestId("seat-notification-banner")).toBeNull();
});
// HG-2 — banner DOES render for each alert-triggering condition:
// failed startupStatus, attention_required startupStatus, or
// latestError present (even when startupStatus is "ready").
it("HG-2: notification banner renders for startupStatus=failed", async () => {
mockNodeDetail({
...NODE_DETAIL,
startupStatus: "failed",
latestError: null,
recoveryGuidance: null,
});
renderDetails();
const banner = await screen.findByTestId("seat-notification-banner");
expect(banner.getAttribute("data-startup-status")).toBe("failed");
expect(screen.getByTestId("seat-notification-headline").textContent).toContain("Startup failed");
});
it("HG-2: notification banner renders for latestError alone (no startupStatus alert)", async () => {
mockNodeDetail({
...NODE_DETAIL,
startupStatus: "ready",
latestError: "Runtime error occurred.",
recoveryGuidance: null,
});
renderDetails();
const banner = await screen.findByTestId("seat-notification-banner");
expect(banner.getAttribute("data-startup-status")).toBe("ready");
expect(screen.getByTestId("seat-notification-headline").textContent).toContain("Error");
expect(screen.getByTestId("seat-notification-error").textContent).toContain("Runtime error occurred");
});
// 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 () => {
@@ -521,22 +639,21 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
expect(screen.getByTestId("agent-plugins-empty")).toBeDefined();
});
// 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 () => {
// PL-019 preserved (follow-on-2) — activity surfaces in the
// Overview info table (column cell); current-work surfaces in the
// secondary primitive below the table.
it("PL-019 preserved: Overview surfaces activity + current qitem", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
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");
// Current-work in the secondary primitive carries qitem + excerpt.
const cwCell = screen.getByTestId("seat-overview-secondary-cell-current-work");
expect(cwCell.textContent).toContain("04001234-driver");
expect(cwCell.textContent).toContain("Implement PL-019 edge activity pulse");
// LiveNodeCurrentState card removed.
// LiveNodeCurrentState card removed (preserved invariant).
expect(screen.queryByTestId("live-node-current-state")).toBeNull();
});