mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user