feat(topology): seat-overview info table 9-field + 2-row-shape

V0.3.1 slice 25 scope amendment (delta to attempt-1 at f48ea03).
Adds two info-table fields + a row-shape convention without
restructuring the slice's broader Overview/Details architecture.

Two row-shape conventions in one cohesive table:

- COMPACT (2-col key/value) — 7 rows: runtime / model / profile /
  spec / activity / context% / total tokens. Same shape as
  attempt-1.
- FULL-WIDTH (single cell with inline label) — 2 rows: cwd / current
  work. New shape; cwd was a compact row in attempt-1.

New "activity" compact row (HG-3a):

- Reads from data.agentActivity via getActivityState(); same source
  LiveNodeCurrentState uses on the same page, same baseline the
  topology graph/table read. No parallel source of truth.
- State "running" maps to the label "active" so the seat page agrees
  with the topology graph/table naming convention.
- HG-3c shimmer reuse: applies the slice-14
  .topology-table-active-shimmer CSS class on the value span when
  state === "running". The class honors prefers-reduced-motion. Slice
  14's CSS module is imported into SeatOverviewTable directly; no
  copy.

New "current work" full-width row (HG-3b):

- Reads from data.currentQitems[0] — already exposed by NodeDetailData
  via /api/rigs/<rigId>/nodes/<logicalId>; same source
  LiveNodeCurrentState's Current Work pane uses. No new daemon
  endpoint; no parallel fetch.
- Renders qitem id + body excerpt when present; em-dash placeholder
  when no in-progress qitem.
- Real-time refresh: react-query's existing useNodeDetail refetch
  cadence on this page propagates qitem changes; no additional SSE
  wiring needed at this layer.

CWD becomes full-width (HG-3d):

- Was a compact 2-col row in attempt-1 with the path crushed into the
  value cell. Now a full-width row with truncate-ellipsis on the
  value + title attribute carrying the full path for tooltip-on-hover
  inspection of long paths.

Tests added (extend live-node-details.test.tsx):

- HG-3: shape now asserts 9 fields total with explicit data-row-shape
  (compact for 7, full-width for cwd + current-work).
- HG-3a: activity row shows "active" + shimmer class when
  agentActivity.state="running".
- HG-3a (idle): activity row shows "idle" without shimmer when
  state="idle".
- HG-3b: current-work row surfaces qitem id + body excerpt when
  in-progress.
- HG-3b (empty): current-work renders em-dash when currentQitems[]
  is empty.
- HG-3d: cwd row is data-row-shape="full-width" + title attribute set
  + value cell carries truncate class.

Forensic-trace findings (per scope-amendment dispatch):

- Activity source: NodeDetailData.agentActivity, read via
  getActivityState() in lib/activity-visuals.ts. Same source the
  existing LiveNodeCurrentState pane uses. The topology graph + table
  use the same baseline fused with stream events via
  useTopologyActivity; the seat detail page reads the baseline only,
  which matches LiveNodeCurrentState's existing behavior on this
  surface (no new subscription wiring required for slice 25 scope).
- Current-work source: NodeDetailData.currentQitems[], already shaped
  by the /api/rigs/<rigId>/nodes/<logicalId> endpoint. LiveNodeCurrentState
  already reads it. Reusing in-place.
- Shimmer source: packages/ui/src/components/topology/topology-table-shimmer.css
  declares .topology-table-active-shimmer + the prefers-reduced-motion
  override. Imported directly into SeatOverviewTable for class reuse.

Verification:

- live-node-details.test.tsx: 21/21 PASS (was 16; +5 amendment tests).
- Full UI suite: 1000 passed / 1 skipped / 4 failed (same 4
  pre-existing failures as main; zero new failures from this slice).

Hard gates (revised):

- HG-3 9 fields total: PASS.
- HG-3a activity wired live: PASS.
- HG-3b current-work wired live: PASS.
- HG-3c shimmer on active: PASS (slice-14 class reused).
- HG-3d cwd full-width with truncate: PASS.

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-11 21:38:37 -07:00
1 parent f48ea03040
commit 46b5132e62
2 files changed
+260 -51

No files matched your search

+181 -42
View File
@@ -1,22 +1,43 @@
// V0.3.1 slice 25 node-page Overview/Details consolidation.
//
// Dense 7-field info table at the top of the seat-detail Overview tab.
// Renders the most-asked questions about a seat at a single glance:
// runtime, model, profile, spec (name + version), cwd, context%, and
// total tokens. Density anchor: TopologyTableView (per ui.md §Topology)
// — same compact mono cells, 1px outline-variant cell borders, vellum
// Dense 9-row 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:
// runtime / model / profile / spec / activity / context% / total tokens
//
// FULL-WIDTH (single cell with inline label) — 2 rows:
// cwd / current work
//
// Density anchor: TopologyTableView (per ui.md §Topology) — same
// compact mono cells, 1px outline-variant cell borders, vellum
// surface chrome.
//
// Reuses the shared token / runtime helpers so the values rendered
// here are the SAME values rendered in topology graph + table +
// terminal views (slice 25 HG-3 — single source of truth across
// surfaces).
// 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.
// - 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).
//
// Graceful absence rule: when a field is null/undefined/empty the
// cell renders an em-dash placeholder. Model in particular is
// commonly absent (not every RigSpec member declares one); the row
// stays visible with a dash rather than rendering "undefined" or
// being hidden — HG-4 from the slice 25 spec.
// 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).
//
// 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.
import type { ReactNode } from "react";
import type { NodeDetailData } from "../hooks/useNodeDetail.js";
@@ -25,22 +46,38 @@ import {
formatCompactTokenCount,
sumTokenCounts,
} from "../lib/token-format.js";
import {
getActivityLabel,
getActivityState,
type ActivityState,
} from "../lib/activity-visuals.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.
import "./topology/topology-table-shimmer.css";
interface SeatOverviewTableProps {
data: NodeDetailData;
}
type RowShape = "compact" | "full-width";
interface Row {
/** Stable kebab-case key used as testid suffix + render key. */
key: string;
/** Display label in the left column. Lowercase mono. */
/** Display label. Lowercase mono. */
label: string;
/** Cell content. May be string OR a ReactNode (e.g., RuntimeBadge).
* null/undefined/empty-string renders as "—" placeholder. */
value: ReactNode | null | undefined;
shape: RowShape;
/** Use the body-font (sans) for human-readable values; mono for IDs
* + numeric metrics. Per DESIGN.md §Typography. */
* + numeric metrics. Per DESIGN.md §Typography. Compact rows
* default to mono; full-width rows default to mono too. */
mono?: boolean;
/** When present, the truncated value cell gets `title={titleAttr}`
* so hovering shows the full string. Used for cwd. */
titleAttr?: string;
}
function placeholderOrValue(value: ReactNode | null | undefined): ReactNode {
@@ -49,7 +86,20 @@ function placeholderOrValue(value: ReactNode | null | undefined): ReactNode {
return value;
}
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);
}
export function SeatOverviewTable({ data }: SeatOverviewTableProps) {
const activityState = getActivityState(data.agentActivity);
const activityLabel = activityLabelFromState(activityState);
const activityIsActive = activityState === "running";
const contextPercentage =
data.contextUsage?.availability === "known" &&
typeof data.contextUsage.usedPercentage === "number"
@@ -65,10 +115,39 @@ export function SeatOverviewTable({ data }: SeatOverviewTableProps) {
? `${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 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>
);
const rows: Row[] = [
{
key: "runtime",
label: "runtime",
shape: "compact",
value: data.runtime ? (
<RuntimeBadge
runtime={data.runtime}
@@ -80,22 +159,38 @@ export function SeatOverviewTable({ data }: SeatOverviewTableProps) {
/>
) : null,
},
{ key: "model", label: "model", value: data.model, mono: true },
{ key: "profile", label: "profile", value: data.profile, mono: true },
{ key: "spec", label: "spec", value: specCell, mono: true },
{ key: "cwd", label: "cwd", value: data.cwd, mono: true },
{ key: "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: "cwd",
label: "cwd",
shape: "full-width",
value: data.cwd,
mono: true,
titleAttr: data.cwd ?? undefined,
},
{
key: "current-work",
label: "current work",
shape: "full-width",
value: currentWorkValue,
},
];
return (
@@ -108,30 +203,74 @@ export function SeatOverviewTable({ data }: SeatOverviewTableProps) {
</div>
<table className="w-full text-left">
<tbody>
{rows.map((row) => (
<tr
key={row.key}
data-testid={`seat-overview-row-${row.key}`}
className="border-b border-outline-variant/55 last:border-b-0"
>
<th
scope="row"
className="w-32 shrink-0 bg-stone-50/30 px-3 py-1.5 text-left align-top font-mono text-[10px] font-normal lowercase tracking-[0.04em] text-stone-500"
>
{row.label}
</th>
<td
data-testid={`seat-overview-cell-${row.key}`}
className={`min-w-0 px-3 py-1.5 align-middle ${
row.mono ? "font-mono text-[11px]" : "text-[11px]"
} text-stone-900`}
>
<div className="truncate">{placeholderOrValue(row.value)}</div>
</td>
</tr>
))}
{rows.map((row) =>
row.shape === "compact" ? (
<CompactRow key={row.key} row={row} />
) : (
<FullWidthRow key={row.key} row={row} />
),
)}
</tbody>
</table>
</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 }) {
const hasValue =
row.value !== null &&
row.value !== undefined &&
!(typeof row.value === "string" && row.value.trim() === "");
return (
<tr
data-testid={`seat-overview-row-${row.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}`}
className="bg-white/15 px-3 py-1.5 align-middle"
title={row.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}
</span>
<span
className={`min-w-0 flex-1 truncate ${
row.mono ? "font-mono text-[11px]" : "text-[11px]"
} text-stone-900`}
>
{hasValue ? row.value : <span className="text-stone-400">—</span>}
</span>
</div>
</td>
</tr>
);
}
+79 -9
View File
@@ -154,19 +154,24 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
expect(terminal.compareDocumentPosition(activity)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
// HG-3 — 7 info-table fields. Every row testid present.
it("HG-3: info table renders all 7 fields (runtime, model, profile, spec, cwd, context%, total tokens)", async () => {
// 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 () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await screen.findByTestId("seat-overview-table");
expect(screen.getByTestId("seat-overview-row-runtime")).toBeDefined();
expect(screen.getByTestId("seat-overview-row-model")).toBeDefined();
expect(screen.getByTestId("seat-overview-row-profile")).toBeDefined();
expect(screen.getByTestId("seat-overview-row-spec")).toBeDefined();
expect(screen.getByTestId("seat-overview-row-cwd")).toBeDefined();
expect(screen.getByTestId("seat-overview-row-context-percent")).toBeDefined();
expect(screen.getByTestId("seat-overview-row-total-tokens")).toBeDefined();
// 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
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");
expect(screen.getByTestId("seat-overview-cell-model").textContent).toContain("opus");
expect(screen.getByTestId("seat-overview-cell-profile").textContent).toContain("default");
@@ -175,6 +180,71 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
expect(screen.getByTestId("seat-overview-cell-context-percent").textContent).toContain("42%");
});
// 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
// is "running", the cell shows label "active" matching topology
// graph/table naming.
it("HG-3a: activity row wires live and shows 'active' for state=running with shimmer", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await screen.findByTestId("seat-overview-table");
const cell = screen.getByTestId("seat-overview-cell-activity");
expect(cell.textContent?.trim()).toContain("active");
const stateEl = screen.getByTestId("seat-overview-activity-state");
expect(stateEl.getAttribute("data-activity-state")).toBe("running");
// HG-3c shimmer reuse: slice-14 shimmer class applied on active.
expect(stateEl.className).toContain("topology-table-active-shimmer");
});
it("HG-3a: activity row shows 'idle' label and NO shimmer when agentActivity.state=idle", async () => {
mockNodeDetail({
...NODE_DETAIL,
agentActivity: { ...NODE_DETAIL.agentActivity, state: "idle" },
});
renderDetails();
await screen.findByTestId("seat-overview-table");
const cell = screen.getByTestId("seat-overview-cell-activity");
expect(cell.textContent?.trim()).toContain("idle");
const stateEl = screen.getByTestId("seat-overview-activity-state");
expect(stateEl.getAttribute("data-activity-state")).toBe("idle");
expect(stateEl.className).not.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 () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
await screen.findByTestId("seat-overview-table");
const cell = screen.getByTestId("seat-overview-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 () => {
mockNodeDetail({ ...NODE_DETAIL, currentQitems: [] });
renderDetails();
await screen.findByTestId("seat-overview-table");
const cell = screen.getByTestId("seat-overview-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 () => {
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");
});
// 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 () => {