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