mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
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:
1 parent
46b5132e62
commit
594b0a27a5
4 files changed
+150
-20
No files matched your search
@@ -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" &&
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user