fix(topology): align seat overview activity with graph state

Seat Overview now reads the same topology-activity visual state used by graph and table views instead of only the static node-detail activity snapshot. The topology activity hook keeps recent node activity across graph/table to seat-page navigation, so a clicked active row stays active on the destination page.

- Pass topology activity visual state into SeatOverviewTable and LiveNodeCurrentState
- Share the topology activity store across hook remounts
- Cover graph/table activity warmup followed by seat detail render

Verification:
- npm --workspace packages/ui test -- live-node-details.test.tsx p5-3-topology-event-hub.test.tsx
- npm --workspace packages/ui run build
This commit is contained in:
mvschwarz committed 2026-05-11 21:47:45 -07:00
1 parent 46b5132e62
commit 594b0a27a5
4 files changed
+150 -20

No files matched your search

+66 -13
View File
@@ -2,7 +2,7 @@
//
// 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 7-field info table at
// 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).
@@ -13,9 +13,10 @@
// Startup section in Details no longer shows the preview pane (since
// the terminal moved up to Overview).
import { useState } 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";
@@ -35,6 +36,10 @@ 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";
@@ -309,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 (
@@ -372,7 +406,8 @@ function RecentEventsSection({ data }: { data: NodeDetailData }) {
}
// V0.3.1 slice 25 — IdentitySummary card is replaced by SeatOverviewTable
// (dense 7-field info table at the top of Overview).
// (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;
@@ -448,16 +483,16 @@ function ContextUsageSection({ data }: { data: NodeDetailData }) {
);
}
// V0.3.1 slice 25 — Overview tab. Dense info-table (7 fields) on top;
// 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 }: { data: NodeDetailData }) {
function OverviewTab({ data, activityVisual }: { data: NodeDetailData; activityVisual?: TopologyActivityVisual | null }) {
return (
<div data-testid="live-overview-section" className="space-y-4">
<SeatOverviewTable data={data} />
<SeatOverviewTable data={data} activityVisual={activityVisual} />
<InlineTerminal data={data} />
<LiveNodeCurrentState data={data} />
<LiveNodeCurrentState data={data} activityVisual={activityVisual} />
<RecentEventsSection data={data} />
</div>
);
@@ -642,6 +677,24 @@ function TabNav({
export function LiveNodeDetails({ rigId, logicalId }: LiveNodeDetailsProps) {
const { data, isLoading, error } = useNodeDetail(rigId, logicalId);
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.
@@ -681,7 +734,7 @@ 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 === "overview" && <OverviewTab data={data} />}
{activeTab === "overview" && <OverviewTab data={data} activityVisual={activityVisual} />}
{activeTab === "details" && (
<DetailsTab rigId={rigId} logicalId={logicalId} data={data} isAgent={isAgent} />
)}
@@ -51,6 +51,7 @@ import {
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.
@@ -58,6 +59,7 @@ import "./topology/topology-table-shimmer.css";
interface SeatOverviewTableProps {
data: NodeDetailData;
activityVisual?: TopologyActivityVisual | null;
}
type RowShape = "compact" | "full-width";
@@ -95,10 +97,21 @@ function activityLabelFromState(state: ActivityState): string {
return getActivityLabel(state);
}
export function SeatOverviewTable({ data }: SeatOverviewTableProps) {
const activityState = getActivityState(data.agentActivity);
const activityLabel = activityLabelFromState(activityState);
const activityIsActive = activityState === "running";
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" &&
+7 -1
View File
@@ -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(() => {
+60 -2
View File
@@ -3,8 +3,15 @@ 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.
@@ -65,12 +72,21 @@ const INFRA_DETAIL = {
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>) {
@@ -108,6 +124,17 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
);
}
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);
@@ -193,7 +220,7 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
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");
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");
});
@@ -202,6 +229,7 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
mockNodeDetail({
...NODE_DETAIL,
agentActivity: { ...NODE_DETAIL.agentActivity, state: "idle" },
currentQitems: [],
});
renderDetails();
await screen.findByTestId("seat-overview-table");
@@ -212,6 +240,36 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
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(() => {
expect(instances).toHaveLength(1);
});
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 () => {
@@ -408,7 +466,7 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
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");
});