mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 03:31:40 -10:00
merge(0.3.1): slice 25 node-page Overview/Details — info table + tab consolidation + activity alignment
--no-ff merge from slice-25-node-page-overview-details (4 commits:f48ea03+46b5132+594b0a2+8e9868d). Seat detail page (/topology/seat/$rigId/$logicalId) restructured: - Two tabs total: Overview (default; renamed from Identity) + Details - Terminal tab removed; black-glass smoked terminal moves inline into Overview - Overview composition: 9-row info table -> inline terminal -> Activity card + Recent Events card - Info table has two row shapes: - Compact 2-col rows (7): runtime / model / profile / spec / activity / context% / total tokens - Full-width rows (2): cwd (truncate-with-ellipsis + tooltip) / current work - Activity state surfaces via shared useTopologyActivity (same source the topology graph + table read; no parallel pipeline); state names align with topology vocabulary (active / needs_input / idle / blocked); shimmer animation on "active" reuses topology-table-shimmer.css; prefers-reduced-motion honored - Current-work row reads existing data.currentQitems[0]; em-dash when no in-progress qitem; updates via existing useNodeDetail 30s refetch interval - Details tab composes Edges + Peers + AgentSpec + Startup (preview pane removed; terminal lives in Overview) + Transcript - Mobile: 44px tap targets at 375px viewport; no horizontal overflow HG-7 source-pinning discriminator: anchored Tab union regex requires trailing semicolon + negative assertion rejecting 3+ member unions; verified discriminator-first by injecting a fake third tab and confirming the test FAILS (prior prefix-matcher would have silently passed). Triple-gate verdicts: - redo3-guard-3 CLEAR (functional + commit-body hygiene; discriminator verified by independent injection test) - velocity-qa PASS on persistent-rsync-refresh founder-walk VM (Overview default; 9-row table; topology activity sync; CWD tooltip + truncate; Details composition; mobile responsive; reduced-motion honored) SC-29 EXCEPTION declared: NONE. UI focused suite: 1001/1006 (4 pre-existing failures unchanged from baseline; zero new). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
commit
84b4888ccf
5 files changed
+780
-211
No files matched your search
@@ -1,13 +1,22 @@
|
||||
// V1 agent-detail canonical surface.
|
||||
//
|
||||
// Polish-8 keeps the title and action row at the top, then makes the tab row
|
||||
// the only body switcher. Status, preview, current work, transcript, terminal,
|
||||
// and startup content live inside their named tabs so the page has one clear
|
||||
// information hierarchy.
|
||||
// V0.3.1 slice 25 reshapes the seat-detail page into a 2-tab
|
||||
// Overview + Details layout. Overview is the default and answers the
|
||||
// most-common at-a-glance questions: a dense 9-field info table at
|
||||
// the top, the black-glass smoked terminal inline below it, then the
|
||||
// Activity + Recent Events cards. Details holds everything else
|
||||
// (edges, peers, agent spec, startup content sans preview, transcript).
|
||||
//
|
||||
// Previous 5-tab structure (identity / agent-spec / startup /
|
||||
// transcript / terminal) collapses into the 2 new tabs. The terminal
|
||||
// is no longer in its own tab — it lives inline in Overview. The
|
||||
// Startup section in Details no longer shows the preview pane (since
|
||||
// the terminal moved up to Overview).
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CirclePlay } from "lucide-react";
|
||||
import { useNodeDetail, type NodeDetailData } from "../hooks/useNodeDetail.js";
|
||||
import { useTopologyActivity } from "../hooks/useTopologyActivity.js";
|
||||
import { useSpecLibrary, useLibraryReview } from "../hooks/useSpecLibrary.js";
|
||||
import { WorkspacePage } from "./WorkspacePage.js";
|
||||
import { WorkflowHeader } from "./WorkflowScaffold.js";
|
||||
@@ -17,17 +26,25 @@ import { AgentSpecDisplay } from "./AgentSpecDisplay.js";
|
||||
// defensively (the resources.plugins field is owned by batch 1 on
|
||||
// plugin-primitive-v0; on main it's absent and we render empty state).
|
||||
import { AgentPluginsList } from "./specs/AgentPluginsList.js";
|
||||
import { PreviewPane } from "./preview/PreviewPane.js";
|
||||
// V0.3.1 slice 25 — PreviewPane no longer rendered (Startup section
|
||||
// in Details tab drops the preview; the terminal moved up to Overview
|
||||
// via SessionPreviewPane). PreviewPane is still owned by other
|
||||
// surfaces in the codebase; this file simply doesn't reference it.
|
||||
import { SessionPreviewPane } from "./preview/SessionPreviewPane.js";
|
||||
import { SeatOverviewTable } from "./SeatOverviewTable.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";
|
||||
import {
|
||||
buildTopologySessionIndex,
|
||||
type TopologyActivityVisual,
|
||||
} from "../lib/topology-activity.js";
|
||||
import { getRestoreStatusColorClass } from "../lib/restore-status-colors.js";
|
||||
import type { AgentSpecReview } from "../hooks/useSpecReview.js";
|
||||
import { RuntimeBadge, ToolMark } from "./graphics/RuntimeMark.js";
|
||||
|
||||
type Tab = "identity" | "agent-spec" | "startup" | "transcript" | "terminal";
|
||||
type Tab = "overview" | "details";
|
||||
|
||||
interface LiveNodeDetailsProps {
|
||||
rigId: string;
|
||||
@@ -69,29 +86,6 @@ function InfoRow({ label, value }: { label: string; value: string | number | nul
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityField({
|
||||
label,
|
||||
children,
|
||||
wide = false,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
if (children === null || children === undefined || children === "") return null;
|
||||
return (
|
||||
<div
|
||||
data-testid={`identity-field-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
|
||||
className={`min-w-0 border border-outline-variant/55 bg-white/20 px-2 py-1.5 font-mono ${
|
||||
wide ? "md:col-span-2 xl:col-span-3" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="text-[8px] uppercase tracking-[0.14em] text-stone-400">{label}</div>
|
||||
<div className="mt-1 min-w-0 truncate text-[11px] text-stone-900">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSpecSection({ data }: { data: NodeDetailData }) {
|
||||
const agentName = resolveAgentName(data.agentRef);
|
||||
const { data: agentEntries = [], isLoading: entriesLoading } = useSpecLibrary("agent");
|
||||
@@ -204,7 +198,7 @@ function ActionButtonsRow({ rigId, logicalId, data }: { rigId: string; logicalId
|
||||
<button
|
||||
onClick={handleOpenCmux}
|
||||
data-testid="detail-cmux-open"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 border border-outline-variant bg-white/30 font-mono text-[10px] uppercase tracking-wide text-stone-900 hover:bg-stone-100/60"
|
||||
className="inline-flex min-h-11 items-center gap-1.5 px-3 py-2 border border-outline-variant bg-white/30 font-mono text-[10px] uppercase tracking-wide text-stone-900 hover:bg-stone-100/60"
|
||||
>
|
||||
<ToolMark tool="cmux" size="sm" />
|
||||
Open CMUX
|
||||
@@ -213,7 +207,7 @@ function ActionButtonsRow({ rigId, logicalId, data }: { rigId: string; logicalId
|
||||
<button
|
||||
onClick={handleCopyAttach}
|
||||
data-testid="detail-copy-attach"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 border border-outline-variant bg-white/30 font-mono text-[10px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60"
|
||||
className="inline-flex min-h-11 items-center gap-1.5 px-3 py-2 border border-outline-variant bg-white/30 font-mono text-[10px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60"
|
||||
>
|
||||
<ToolMark tool="tmux" size="sm" />
|
||||
Copy tmux attach
|
||||
@@ -223,7 +217,7 @@ function ActionButtonsRow({ rigId, logicalId, data }: { rigId: string; logicalId
|
||||
<button
|
||||
onClick={handleCopyResume}
|
||||
data-testid="detail-copy-resume"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 border border-outline-variant bg-white/30 font-mono text-[10px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60"
|
||||
className="inline-flex min-h-11 items-center gap-1.5 px-3 py-2 border border-outline-variant bg-white/30 font-mono text-[10px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60"
|
||||
>
|
||||
<CirclePlay aria-hidden="true" className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
||||
Copy resume command
|
||||
@@ -320,11 +314,40 @@ function StatusSection({ data }: { data: NodeDetailData }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LiveNodeCurrentState({ data }: { data: NodeDetailData }) {
|
||||
const activityState = getActivityState(data.agentActivity);
|
||||
const activityLabel = getActivityLabel(activityState);
|
||||
const activityTextClass = getActivityTextClass(activityState);
|
||||
const activityStale = isActivityStale(data.agentActivity);
|
||||
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 (
|
||||
@@ -382,25 +405,9 @@ function RecentEventsSection({ data }: { data: NodeDetailData }) {
|
||||
);
|
||||
}
|
||||
|
||||
function IdentitySummary({ data }: { data: NodeDetailData }) {
|
||||
return (
|
||||
<section data-testid="live-identity-summary" className={SECTION_CLASS}>
|
||||
<div className="mb-2 font-mono text-[8px] uppercase tracking-wider text-stone-400">Identity</div>
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{data.runtime ? (
|
||||
<IdentityField label="Runtime">
|
||||
<RuntimeBadge runtime={data.runtime} model={data.model} size="xs" compact variant="inline" className="max-w-full" />
|
||||
</IdentityField>
|
||||
) : null}
|
||||
<IdentityField label="Model">{data.model}</IdentityField>
|
||||
<IdentityField label="Profile">{data.profile}</IdentityField>
|
||||
<IdentityField label="Spec">{data.resolvedSpecName}</IdentityField>
|
||||
<IdentityField label="Version">{data.resolvedSpecVersion}</IdentityField>
|
||||
<IdentityField label="CWD" wide>{data.cwd}</IdentityField>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
// V0.3.1 slice 25 — IdentitySummary card is replaced by SeatOverviewTable
|
||||
// (dense 9-field info table at the top of Overview; 7 compact rows
|
||||
// + 2 full-width rows per the slice 25 scope amendment).
|
||||
|
||||
function EdgesSection({ data }: { data: NodeDetailData }) {
|
||||
const { outgoing, incoming } = data.edges;
|
||||
@@ -476,36 +483,78 @@ function ContextUsageSection({ data }: { data: NodeDetailData }) {
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityTab({ 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.
|
||||
function OverviewTab({ data, activityVisual }: { data: NodeDetailData; activityVisual?: TopologyActivityVisual | null }) {
|
||||
return (
|
||||
<div data-testid="live-identity-section" className="space-y-4">
|
||||
<IdentitySummary data={data} />
|
||||
<LiveNodeCurrentState data={data} />
|
||||
<div data-testid="live-overview-section" className="space-y-4">
|
||||
<SeatOverviewTable data={data} activityVisual={activityVisual} />
|
||||
<InlineTerminal data={data} />
|
||||
<LiveNodeCurrentState data={data} activityVisual={activityVisual} />
|
||||
<RecentEventsSection data={data} />
|
||||
<EdgesSection data={data} />
|
||||
<PeersSection data={data} />
|
||||
<ContextUsageSection data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartupTab({ rigId, logicalId, data }: { rigId: string; logicalId: string; data: NodeDetailData }) {
|
||||
// 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.
|
||||
function DetailsTab({
|
||||
rigId,
|
||||
logicalId,
|
||||
data,
|
||||
isAgent,
|
||||
}: {
|
||||
rigId: string;
|
||||
logicalId: string;
|
||||
data: NodeDetailData;
|
||||
isAgent: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="live-details-section" className="space-y-4">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// V0.3.1 slice 25 — Inline black-glass terminal. Renders the same
|
||||
// SessionPreviewPane the old Terminal tab rendered, but embedded
|
||||
// directly in Overview rather than behind a tab. The black-glass
|
||||
// chrome class is preserved verbatim so the visual feel matches the
|
||||
// pre-slice-25 terminal tab.
|
||||
function InlineTerminal({ data }: { data: NodeDetailData }) {
|
||||
if (!data.canonicalSessionName) {
|
||||
return (
|
||||
<div className="font-mono text-[10px] text-stone-400 p-4">
|
||||
No canonical session name; terminal preview unavailable.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-testid="live-terminal-shell" className="bg-stone-950/65 p-2 text-stone-50 backdrop-blur-sm">
|
||||
<SessionPreviewPane
|
||||
sessionName={data.canonicalSessionName}
|
||||
lines={80}
|
||||
testIdPrefix="live-terminal-preview"
|
||||
variant="compact-terminal"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartupContent({ rigId: _rigId, logicalId: _logicalId, data }: { rigId: string; logicalId: string; data: NodeDetailData }) {
|
||||
void _rigId; void _logicalId;
|
||||
return (
|
||||
<div data-testid="live-startup-section" className="space-y-4">
|
||||
<StatusSection data={data} />
|
||||
|
||||
{data.canonicalSessionName && (
|
||||
<section data-testid="live-node-preview" className={SECTION_CLASS}>
|
||||
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400 mb-2">Preview</div>
|
||||
<PreviewPane
|
||||
rigId={rigId}
|
||||
rigName={data.rigName}
|
||||
logicalId={logicalId}
|
||||
testIdPrefix="detail-preview"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data.infrastructureStartupCommand && (
|
||||
<section data-testid="live-node-infra-startup" className={SECTION_CLASS}>
|
||||
<div className="font-mono text-[8px] uppercase tracking-wider text-stone-400 mb-2">
|
||||
@@ -567,7 +616,7 @@ function StartupTab({ rigId, logicalId, data }: { rigId: string; logicalId: stri
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptTab({ data }: { data: NodeDetailData }) {
|
||||
function TranscriptContent({ data }: { data: NodeDetailData }) {
|
||||
if (!data.transcript.enabled) {
|
||||
return (
|
||||
<div data-testid="live-transcript-section" className="font-mono text-[10px] text-stone-400 p-4">
|
||||
@@ -595,27 +644,6 @@ function TranscriptTab({ data }: { data: NodeDetailData }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalTab({ data }: { data: NodeDetailData }) {
|
||||
return (
|
||||
<div data-testid="live-terminal-section" className="space-y-4">
|
||||
{data.canonicalSessionName ? (
|
||||
<div data-testid="live-terminal-shell" className="bg-stone-950/65 p-2 text-stone-50 backdrop-blur-sm">
|
||||
<SessionPreviewPane
|
||||
sessionName={data.canonicalSessionName}
|
||||
lines={80}
|
||||
testIdPrefix="live-terminal-preview"
|
||||
variant="compact-terminal"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-[10px] text-stone-400 p-4">
|
||||
No canonical session name; terminal preview unavailable.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabNav({
|
||||
tabs,
|
||||
activeTab,
|
||||
@@ -634,7 +662,7 @@ function TabNav({
|
||||
aria-selected={activeTab === tab}
|
||||
data-testid={`live-tab-${tab}`}
|
||||
onClick={() => onSelect(tab)}
|
||||
className={`px-3 py-1.5 font-mono text-[10px] uppercase tracking-wider transition-colors ${
|
||||
className={`min-h-11 px-3 py-2 font-mono text-[10px] uppercase tracking-wider transition-colors ${
|
||||
activeTab === tab
|
||||
? "border-b-2 border-stone-900 text-stone-900 font-bold -mb-px"
|
||||
: "text-stone-500 hover:text-stone-700"
|
||||
@@ -649,11 +677,32 @@ function TabNav({
|
||||
|
||||
export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
|
||||
const { data, isLoading, error } = useNodeDetail(rigId, logicalId);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("identity");
|
||||
const sessionIndex = useMemo(() => buildTopologySessionIndex(data ? [{
|
||||
nodeId: `${data.rigId}::${data.logicalId}`,
|
||||
rigId: data.rigId,
|
||||
rigName: data.rigName,
|
||||
logicalId: data.logicalId,
|
||||
canonicalSessionName: data.canonicalSessionName,
|
||||
agentActivity: data.agentActivity ?? null,
|
||||
currentQitems: data.currentQitems ?? null,
|
||||
startupStatus: data.startupStatus,
|
||||
}] : []), [data]);
|
||||
const topologyActivity = useTopologyActivity(sessionIndex);
|
||||
const activityVisual = data
|
||||
? topologyActivity.getNodeActivity(`${data.rigId}::${data.logicalId}`, {
|
||||
agentActivity: data.agentActivity ?? null,
|
||||
currentQitems: data.currentQitems ?? null,
|
||||
startupStatus: data.startupStatus,
|
||||
})
|
||||
: null;
|
||||
// V0.3.1 slice 25 — 2-tab Overview/Details restructure. Default
|
||||
// tab is Overview so operators landing from topology see the
|
||||
// at-a-glance info table + inline terminal without tab-switching.
|
||||
// Analogous to slice 12's project-scope default-tab flip
|
||||
// (story → overview).
|
||||
const [activeTab, setActiveTab] = useState<Tab>("overview");
|
||||
const isAgent = data ? data.nodeKind !== "infrastructure" : true;
|
||||
const tabs: Tab[] = isAgent
|
||||
? ["identity", "agent-spec", "startup", "transcript", "terminal"]
|
||||
: ["identity", "startup", "transcript", "terminal"];
|
||||
const tabs: Tab[] = ["overview", "details"];
|
||||
|
||||
return (
|
||||
<WorkspacePage>
|
||||
@@ -685,11 +734,10 @@ export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
|
||||
<ActionButtonsRow rigId={rigId} logicalId={logicalId} data={data} />
|
||||
<TabNav tabs={tabs} activeTab={activeTab} onSelect={setActiveTab} />
|
||||
<div data-testid="live-node-tab-body" className="space-y-4">
|
||||
{activeTab === "identity" && <IdentityTab data={data} />}
|
||||
{activeTab === "agent-spec" && isAgent && <AgentSpecSection data={data} />}
|
||||
{activeTab === "startup" && <StartupTab rigId={rigId} logicalId={logicalId} data={data} />}
|
||||
{activeTab === "transcript" && <TranscriptTab data={data} />}
|
||||
{activeTab === "terminal" && <TerminalTab data={data} />}
|
||||
{activeTab === "overview" && <OverviewTab data={data} activityVisual={activityVisual} />}
|
||||
{activeTab === "details" && (
|
||||
<DetailsTab rigId={rigId} logicalId={logicalId} data={data} isAgent={isAgent} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// V0.3.1 slice 25 node-page Overview/Details consolidation.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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).
|
||||
//
|
||||
// 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";
|
||||
import { RuntimeBadge } from "./graphics/RuntimeMark.js";
|
||||
import {
|
||||
formatCompactTokenCount,
|
||||
sumTokenCounts,
|
||||
} from "../lib/token-format.js";
|
||||
import {
|
||||
getActivityLabel,
|
||||
getActivityState,
|
||||
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
|
||||
// "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;
|
||||
activityVisual?: TopologyActivityVisual | null;
|
||||
}
|
||||
|
||||
type RowShape = "compact" | "full-width";
|
||||
|
||||
interface Row {
|
||||
/** Stable kebab-case key used as testid suffix + render key. */
|
||||
key: string;
|
||||
/** 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. 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function activityLabelFromVisualState(state: TopologyActivityVisual["state"]): string {
|
||||
if (state === "active") return "active";
|
||||
if (state === "needs_input") return "needs input";
|
||||
return state;
|
||||
}
|
||||
|
||||
export function SeatOverviewTable({ data, activityVisual }: SeatOverviewTableProps) {
|
||||
const fallbackActivityState = getActivityState(data.agentActivity);
|
||||
const activityState = activityVisual?.state ?? fallbackActivityState;
|
||||
const activityLabel = activityVisual
|
||||
? activityLabelFromVisualState(activityVisual.state)
|
||||
: activityLabelFromState(fallbackActivityState);
|
||||
const activityIsActive = activityVisual
|
||||
? activityVisual.state === "active"
|
||||
: fallbackActivityState === "running";
|
||||
|
||||
const contextPercentage =
|
||||
data.contextUsage?.availability === "known" &&
|
||||
typeof data.contextUsage.usedPercentage === "number"
|
||||
? `${data.contextUsage.usedPercentage}%`
|
||||
: null;
|
||||
const tokenTotal = sumTokenCounts(
|
||||
data.contextUsage?.totalInputTokens,
|
||||
data.contextUsage?.totalOutputTokens,
|
||||
);
|
||||
const tokenLabel = formatCompactTokenCount(tokenTotal);
|
||||
const specCell =
|
||||
data.resolvedSpecName && data.resolvedSpecVersion
|
||||
? `${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}
|
||||
model={data.model}
|
||||
size="xs"
|
||||
compact
|
||||
variant="inline"
|
||||
className="max-w-full"
|
||||
/>
|
||||
) : 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: "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 (
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,12 @@ function createStore(): TopologyActivityStore {
|
||||
};
|
||||
}
|
||||
|
||||
let sharedTopologyActivityStore: TopologyActivityStore = createStore();
|
||||
|
||||
export function resetTopologyActivityStoreForTests(): void {
|
||||
sharedTopologyActivityStore = createStore();
|
||||
}
|
||||
|
||||
function recordNodeActivity(
|
||||
store: TopologyActivityStore,
|
||||
nodeId: string,
|
||||
@@ -95,7 +101,7 @@ function pruneStore(store: TopologyActivityStore, nowMs: number): boolean {
|
||||
export function useTopologyActivity(index: TopologySessionIndex): TopologyActivitySnapshot {
|
||||
const [version, setVersion] = useState(0);
|
||||
const indexRef = useRef(index);
|
||||
const storeRef = useRef<TopologyActivityStore>(createStore());
|
||||
const storeRef = useRef<TopologyActivityStore>(sharedTopologyActivityStore);
|
||||
indexRef.current = index;
|
||||
|
||||
const bump = useCallback(() => {
|
||||
|
||||
@@ -3,9 +3,18 @@ import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/re
|
||||
import { createTestRouter } from "./helpers/test-router.js";
|
||||
import { LiveNodeDetails } from "../src/components/LiveNodeDetails.js";
|
||||
import { DrawerSelectionContext, type DrawerSelection } from "../src/components/AppShell.js";
|
||||
import {
|
||||
resetTopologyActivityStoreForTests,
|
||||
useTopologyActivity,
|
||||
} from "../src/hooks/useTopologyActivity.js";
|
||||
import { buildTopologySessionIndex } from "../src/lib/topology-activity.js";
|
||||
import { createMockEventSourceClass, instances } from "./helpers/mock-event-source.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
let OriginalEventSource: typeof EventSource | undefined;
|
||||
|
||||
// V0.3.1 slice 25 — seat detail page now uses a 2-tab Overview +
|
||||
// Details layout. Tests target the new structure.
|
||||
const NODE_DETAIL = {
|
||||
rigId: "rig-1", rigName: "test-rig", logicalId: "dev.impl", podId: "dev",
|
||||
canonicalSessionName: "dev-impl@test-rig", nodeKind: "agent", runtime: "claude-code",
|
||||
@@ -43,6 +52,16 @@ const NODE_DETAIL = {
|
||||
tier: "mode-2",
|
||||
},
|
||||
],
|
||||
contextUsage: {
|
||||
availability: "known",
|
||||
usedPercentage: 42,
|
||||
remainingPercentage: 58,
|
||||
contextWindowSize: 320000,
|
||||
sampledAt: "2026-05-04T07:58:31.057Z",
|
||||
fresh: true,
|
||||
totalInputTokens: 120000,
|
||||
totalOutputTokens: 14000,
|
||||
},
|
||||
};
|
||||
|
||||
const INFRA_DETAIL = {
|
||||
@@ -51,14 +70,23 @@ const INFRA_DETAIL = {
|
||||
compactSpec: { name: null, version: null, profile: null, skillCount: 0, guidanceCount: 0 },
|
||||
};
|
||||
|
||||
describe("LiveNodeDetails", () => {
|
||||
describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
|
||||
beforeEach(() => {
|
||||
OriginalEventSource = globalThis.EventSource;
|
||||
globalThis.EventSource = createMockEventSourceClass() as unknown as typeof EventSource;
|
||||
resetTopologyActivityStoreForTests();
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
if (OriginalEventSource) {
|
||||
globalThis.EventSource = OriginalEventSource;
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (globalThis as any).EventSource;
|
||||
}
|
||||
});
|
||||
|
||||
function mockNodeDetail(detail: Record<string, unknown>) {
|
||||
@@ -96,103 +124,293 @@ describe("LiveNodeDetails", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("renders with identity section showing peers and edges", async () => {
|
||||
function TopologyActivityWarmup() {
|
||||
useTopologyActivity(buildTopologySessionIndex([{
|
||||
nodeId: "rig-1::dev.impl",
|
||||
rigId: "rig-1",
|
||||
rigName: "test-rig",
|
||||
logicalId: "dev.impl",
|
||||
canonicalSessionName: "dev-impl@test-rig",
|
||||
}]));
|
||||
return <div data-testid="activity-warmup" />;
|
||||
}
|
||||
|
||||
// HG-1 — default tab is Overview.
|
||||
it("HG-1: default tab is Overview on landing", async () => {
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetails();
|
||||
|
||||
const overviewTab = await screen.findByTestId("live-tab-overview");
|
||||
expect(overviewTab.getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByTestId("live-overview-section")).toBeDefined();
|
||||
// Details exists but is not active on first paint.
|
||||
const detailsTab = screen.getByTestId("live-tab-details");
|
||||
expect(detailsTab.getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
|
||||
// HG-7 — Terminal tab no longer exists; identity / agent-spec /
|
||||
// startup / transcript tabs no longer exist as named tabs either.
|
||||
it("HG-7: legacy 5-tab structure is gone — only overview + details remain", async () => {
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetails();
|
||||
await screen.findByTestId("live-tab-overview");
|
||||
|
||||
expect(screen.queryByTestId("live-tab-terminal")).toBeNull();
|
||||
expect(screen.queryByTestId("live-tab-identity")).toBeNull();
|
||||
expect(screen.queryByTestId("live-tab-agent-spec")).toBeNull();
|
||||
expect(screen.queryByTestId("live-tab-startup")).toBeNull();
|
||||
expect(screen.queryByTestId("live-tab-transcript")).toBeNull();
|
||||
expect(screen.getByTestId("live-tab-overview")).toBeDefined();
|
||||
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);
|
||||
renderDetails();
|
||||
|
||||
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.
|
||||
|
||||
expect(table.compareDocumentPosition(terminal)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(terminal.compareDocumentPosition(activity)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
// 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");
|
||||
|
||||
// 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");
|
||||
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-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("active");
|
||||
// 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" },
|
||||
currentQitems: [],
|
||||
});
|
||||
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");
|
||||
});
|
||||
|
||||
it("HG-3a: seat page reuses recent topology activity across graph/table -> seat navigation", async () => {
|
||||
const warmup = render(<TopologyActivityWarmup />);
|
||||
await waitFor(() => {
|
||||
// Wait for data to load — peers section proves data arrived
|
||||
expect(screen.getByTestId("detail-peers")).toBeDefined();
|
||||
expect(instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Live Node Details")).toBeDefined();
|
||||
instances[0]!.simulateMessage(JSON.stringify({
|
||||
type: "agent.activity",
|
||||
sessionName: "dev-impl@test-rig",
|
||||
activity: { state: "running" },
|
||||
}));
|
||||
warmup.unmount();
|
||||
|
||||
mockNodeDetail({
|
||||
...NODE_DETAIL,
|
||||
agentActivity: {
|
||||
...NODE_DETAIL.agentActivity,
|
||||
state: "unknown",
|
||||
reason: "no_activity_signal",
|
||||
fallback: true,
|
||||
},
|
||||
currentQitems: [],
|
||||
});
|
||||
renderDetails();
|
||||
const stateEl = await screen.findByTestId("seat-overview-activity-state");
|
||||
expect(stateEl.textContent).toBe("active");
|
||||
expect(stateEl.getAttribute("data-activity-state")).toBe("active");
|
||||
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 () => {
|
||||
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 () => {
|
||||
mockNodeDetail({ ...NODE_DETAIL, model: null });
|
||||
renderDetails();
|
||||
await screen.findByTestId("seat-overview-table");
|
||||
|
||||
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("—");
|
||||
});
|
||||
|
||||
// HG-5 — black-glass terminal renders inline in Overview (not in a
|
||||
// separate tab). The terminal shell wrapper carries the black-glass
|
||||
// chrome class.
|
||||
it("HG-5: black-glass terminal renders inline in Overview", async () => {
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetails();
|
||||
const terminalShell = await screen.findByTestId("live-terminal-shell");
|
||||
expect(terminalShell.className).toContain("bg-stone-950/65");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("live-terminal-preview-pane").getAttribute("data-variant")).toBe("compact-terminal");
|
||||
});
|
||||
// The terminal sits inside the Overview section, NOT a separate
|
||||
// tab body. The Overview section wraps it.
|
||||
const overview = screen.getByTestId("live-overview-section");
|
||||
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 () => {
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetails();
|
||||
fireEvent.click(await screen.findByTestId("live-tab-details"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("live-details-section")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByTestId("detail-edges")).toBeDefined();
|
||||
expect(screen.getByTestId("live-node-actions").nextElementSibling?.getAttribute("data-testid")).toBe("live-node-tabs");
|
||||
expect(screen.queryByTestId("live-node-status")).toBeNull();
|
||||
expect(screen.queryByTestId("detail-transcript")).toBeNull();
|
||||
expect(screen.queryByTestId("detail-compact-spec")).toBeNull();
|
||||
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).
|
||||
expect(screen.queryByTestId("live-node-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders identity fields as isolated cells so runtime badges do not collide with adjacent labels", async () => {
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetails();
|
||||
|
||||
const runtimeField = await screen.findByTestId("identity-field-runtime");
|
||||
const modelField = screen.getByTestId("identity-field-model");
|
||||
const cwdField = screen.getByTestId("identity-field-cwd");
|
||||
|
||||
expect(runtimeField.className).toContain("bg-white/20");
|
||||
expect(runtimeField.textContent).toContain("Claude");
|
||||
expect(modelField.textContent).toContain("opus");
|
||||
expect(cwdField.className).toContain("xl:col-span-3");
|
||||
expect(cwdField.textContent).toContain("/workspace");
|
||||
});
|
||||
|
||||
it("uses a resume action glyph instead of a runtime mark on the copy resume command", async () => {
|
||||
mockNodeDetail({ ...NODE_DETAIL, resumeCommand: "rig seat resume dev.impl" });
|
||||
renderDetails();
|
||||
|
||||
const resumeButton = await screen.findByTestId("detail-copy-resume");
|
||||
|
||||
expect(resumeButton.textContent).toContain("Copy resume command");
|
||||
expect(resumeButton.textContent).not.toContain("Claude");
|
||||
expect(resumeButton.querySelector("svg")).toBeDefined();
|
||||
});
|
||||
|
||||
it("agent spec tab shows unavailable when agentRef is null", async () => {
|
||||
mockNodeDetail({ ...NODE_DETAIL, agentRef: null });
|
||||
renderDetails();
|
||||
|
||||
fireEvent.click(await screen.findByTestId("live-tab-agent-spec"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("agent-spec-unavailable")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("agent spec tab shows unavailable when agentRef is non-local form", async () => {
|
||||
mockNodeDetail({ ...NODE_DETAIL, agentRef: "remote:agents/impl" });
|
||||
renderDetails();
|
||||
|
||||
fireEvent.click(await screen.findByTestId("live-tab-agent-spec"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("agent-spec-unavailable")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("infrastructure node does not show agent-spec tab", async () => {
|
||||
// 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 () => {
|
||||
mockNodeDetail(INFRA_DETAIL);
|
||||
renderDetails("infra.server");
|
||||
|
||||
// Wait for data to load and re-render without agent-spec tab
|
||||
await screen.findByTestId("live-tab-identity");
|
||||
|
||||
await screen.findByTestId("live-tab-overview");
|
||||
expect(screen.getByTestId("live-tab-overview")).toBeDefined();
|
||||
expect(screen.getByTestId("live-tab-details")).toBeDefined();
|
||||
expect(screen.queryByTestId("live-tab-agent-spec")).toBeNull();
|
||||
expect(screen.getByTestId("live-tab-identity")).toBeDefined();
|
||||
expect(screen.getByTestId("live-tab-startup")).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByTestId("live-tab-details"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("live-details-section")).toBeDefined();
|
||||
});
|
||||
// No live-agent-spec-section for infra nodes.
|
||||
expect(screen.queryByTestId("live-agent-spec-section")).toBeNull();
|
||||
});
|
||||
|
||||
it("startup tab shows startup files", async () => {
|
||||
// Agent spec unavailable cases — switch to Details, then exercise the
|
||||
// null + non-local agentRef shapes.
|
||||
it("Details tab: agent spec section shows unavailable when agentRef is null", async () => {
|
||||
mockNodeDetail({ ...NODE_DETAIL, agentRef: null });
|
||||
renderDetails();
|
||||
fireEvent.click(await screen.findByTestId("live-tab-details"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("agent-spec-unavailable")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("Details tab: agent spec section shows unavailable when agentRef is non-local form", async () => {
|
||||
mockNodeDetail({ ...NODE_DETAIL, agentRef: "remote:agents/impl" });
|
||||
renderDetails();
|
||||
fireEvent.click(await screen.findByTestId("live-tab-details"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("agent-spec-unavailable")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Startup files surface inside Details > Startup section.
|
||||
it("Details tab: Startup section shows startup files", async () => {
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetails();
|
||||
|
||||
fireEvent.click(await screen.findByTestId("live-tab-startup"));
|
||||
|
||||
fireEvent.click(await screen.findByTestId("live-tab-details"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("live-startup-section")).toBeDefined();
|
||||
expect(screen.getByTestId("live-node-status")).toBeDefined();
|
||||
expect(screen.getByTestId("live-node-preview")).toBeDefined();
|
||||
expect(screen.getByText(/role\.md/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("startup file trigger includes resolved file provenance for drawer loading", async () => {
|
||||
it("Details tab: Startup file trigger threads file provenance for drawer loading", async () => {
|
||||
const setSelection = vi.fn();
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetailsWithDrawerSelection(setSelection as (sel: DrawerSelection) => void);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("live-tab-startup"));
|
||||
fireEvent.click(await screen.findByTestId("live-tab-details"));
|
||||
fireEvent.click(await screen.findByTestId("live-startup-file-trigger-role.md"));
|
||||
|
||||
expect(setSelection).toHaveBeenCalledWith({
|
||||
@@ -204,27 +422,16 @@ describe("LiveNodeDetails", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("transcript and terminal tabs own their named content", async () => {
|
||||
it("Details tab: Transcript section owns transcript content", async () => {
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetails();
|
||||
|
||||
fireEvent.click(await screen.findByTestId("live-tab-transcript"));
|
||||
fireEvent.click(await screen.findByTestId("live-tab-details"));
|
||||
expect(await screen.findByTestId("detail-transcript")).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByTestId("live-tab-terminal"));
|
||||
const terminalShell = await screen.findByTestId("live-terminal-shell");
|
||||
expect(terminalShell.className).toContain("bg-stone-950/65");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("live-terminal-preview-pane").getAttribute("data-variant")).toBe("compact-terminal");
|
||||
});
|
||||
});
|
||||
|
||||
it("slice 3.3 fix-B — agent-spec tab includes Plugins section between Skills and Startup Files", async () => {
|
||||
// velocity-qa VM verify failure #2 — Plugins section must be visible
|
||||
// in current build per dispatch §3.2. Empty state is acceptable on
|
||||
// main without plugin-primitive-v0 merged in (no agent.yaml carries
|
||||
// resources.plugins[] until merge); the section's PRESENCE is the
|
||||
// load-bearing assertion.
|
||||
// Slice 3.3 fix-B preserved — Plugins section inside Details > Agent
|
||||
// spec area. Renders empty state on builds without batch 1.
|
||||
it("slice 3.3 fix-B preserved: Plugins section in Details tab agent-spec area", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/nodes/")) {
|
||||
return { ok: true, json: async () => NODE_DETAIL };
|
||||
@@ -244,27 +451,33 @@ describe("LiveNodeDetails", () => {
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
renderDetails();
|
||||
// Switch to the agent-spec tab to see the Plugins section.
|
||||
const agentSpecTab = await screen.findByTestId("live-tab-agent-spec");
|
||||
fireEvent.click(agentSpecTab);
|
||||
// Section present (drives the visual proof the dispatch + velocity-qa want).
|
||||
fireEvent.click(await screen.findByTestId("live-tab-details"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("live-agent-plugins-section")).toBeDefined();
|
||||
});
|
||||
// Empty state body when no plugins declared.
|
||||
expect(screen.getByTestId("agent-plugins-empty")).toBeDefined();
|
||||
});
|
||||
|
||||
it("PL-019: full details shows activity and current qitems from node detail", async () => {
|
||||
// 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 () => {
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetails();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("live-node-current-state")).toBeDefined();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("live-node-agent-activity").textContent).toContain("running");
|
||||
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");
|
||||
});
|
||||
|
||||
// Resume action glyph remains independent of tab structure.
|
||||
it("uses a resume action glyph instead of a runtime mark on the copy resume command", async () => {
|
||||
mockNodeDetail({ ...NODE_DETAIL, resumeCommand: "rig seat resume dev.impl" });
|
||||
renderDetails();
|
||||
const resumeButton = await screen.findByTestId("detail-copy-resume");
|
||||
expect(resumeButton.textContent).toContain("Copy resume command");
|
||||
expect(resumeButton.textContent).not.toContain("Claude");
|
||||
expect(resumeButton.querySelector("svg")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -89,11 +89,24 @@ describe("P5.1-D2 retirement regression: drawer-as-seat-detail removed", () => {
|
||||
expect(codeOnly).not.toMatch(/setSelection\s*\(\s*\{\s*type:\s*["']seat-detail["']/);
|
||||
});
|
||||
|
||||
it("LiveNodeDetails.tsx renders the 5-tab body row (single canonical surface per DRIFT P5.1-D1)", () => {
|
||||
it("LiveNodeDetails.tsx renders the 2-tab Overview + Details body row (single canonical surface per slice 25)", () => {
|
||||
const src = readFileSync(path.join(SRC, "components/LiveNodeDetails.tsx"), "utf8");
|
||||
// Tab union literal carries all 5 canonical tab keys.
|
||||
expect(src).toMatch(/type\s+Tab\s*=\s*"identity"\s*\|\s*"agent-spec"\s*\|\s*"startup"\s*\|\s*"transcript"\s*\|\s*"terminal"/);
|
||||
// FileReferenceTrigger wraps startup files (P5.1-1a).
|
||||
// V0.3.1 slice 25 forward-fix-1 — anchored discriminator. The
|
||||
// earlier `/type\s+Tab\s*=\s*"overview"\s*\|\s*"details"/` regex
|
||||
// matched as a prefix and would not have failed if a third tab
|
||||
// was reintroduced (e.g., `"overview" | "details" | "terminal"`).
|
||||
// Two assertions together prove exact 2-member shape:
|
||||
// (1) positive — the declaration ends at the second member with
|
||||
// a trailing `;` (or `\n`) so the union cannot extend silently
|
||||
// (2) negative — no 3-or-more-member string union exists anywhere
|
||||
// in the file at the top-level Tab declaration position.
|
||||
expect(src).toMatch(/type\s+Tab\s*=\s*"overview"\s*\|\s*"details"\s*;/);
|
||||
// Negative: the legacy 5-tab literal is gone.
|
||||
expect(src).not.toMatch(/"identity"\s*\|\s*"agent-spec"\s*\|\s*"startup"\s*\|\s*"transcript"\s*\|\s*"terminal"/);
|
||||
// Negative: NO Tab union literal with 3+ string members. Catches a
|
||||
// silent reintroduction like `"overview" | "details" | "anything"`.
|
||||
expect(src).not.toMatch(/type\s+Tab\s*=\s*"[^"]+"\s*\|\s*"[^"]+"\s*\|\s*"[^"]+"/);
|
||||
// FileReferenceTrigger wraps startup files (P5.1-1a preserved).
|
||||
expect(src).toMatch(/import\s*\{[^}]*FileReferenceTrigger[^}]*\}\s*from/);
|
||||
expect(src).toMatch(/<FileReferenceTrigger/);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user