topology add terminal preview popover

This commit is contained in:
mvschwarz committed 2026-05-07 01:22:07 -07:00
1 parent b53849c044
commit 1d3cabbdda
6 files changed
+294 -4

No files matched your search

+13
View File
@@ -16,6 +16,7 @@ import type { AgentActivitySummary, CurrentQitemSummary } from "../hooks/useNode
import { ContextUsageRing } from "./ContextUsageRing.js";
import { ActivityRing } from "./topology/ActivityRing.js";
import { getActivityCardClasses, getActivityCardSignal } from "./topology/activity-card-visuals.js";
import { TerminalPreviewPopover } from "./topology/TerminalPreviewPopover.js";
import type { TopologyActivityVisual } from "../lib/topology-activity.js";
import { formatCompactTokenCount, formatTokenTotalTitle, sumTokenCounts } from "../lib/token-format.js";
@@ -174,6 +175,8 @@ export function RigNode({ data }: { data: RigNodeData }) {
? "bg-stone-900 text-white border-stone-900"
: "bg-white text-stone-900 border-stone-300 hover:bg-stone-100"
}`;
const toolbarIconButtonClass = "border font-mono text-[7px] uppercase transition-colors bg-white text-stone-900 border-stone-300 hover:bg-stone-100 inline-flex h-6 w-6 items-center justify-center px-0 py-0";
const terminalSessionName = data.canonicalSessionName ?? data.binding?.tmuxSession ?? null;
const card = (
<div
@@ -346,6 +349,16 @@ export function RigNode({ data }: { data: RigNodeData }) {
<span className="sr-only">{actionFeedback === "cmux" ? "opened" : "cmux"}</span>
</button>
)}
{data.rigId && terminalSessionName && (
<TerminalPreviewPopover
rigId={data.rigId}
logicalId={data.logicalId}
sessionName={terminalSessionName}
reducedMotion={data.reducedMotion}
testIdPrefix={`rig-node-${data.logicalId}`}
buttonClassName={toolbarIconButtonClass}
/>
)}
{data.resumeToken && data.runtime && data.runtime !== "terminal" && (
<button
onClick={handleCopyResume}
@@ -9,6 +9,7 @@
// node-detail context. Operator dogfood reports needing pin from
// Loop State / Topology tab → NAMED v0+1 trigger.
import { useLayoutEffect, useRef } from "react";
import { useSessionPreview, isNodePreviewUnavailable } from "../../hooks/useNodePreview.js";
interface SessionPreviewPaneProps {
@@ -18,8 +19,29 @@ interface SessionPreviewPaneProps {
testIdPrefix?: string;
}
function isNearBottom(el: HTMLElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= 4;
}
export function SessionPreviewPane({ sessionName, lines, paused, testIdPrefix = "session-preview" }: SessionPreviewPaneProps) {
const preview = useSessionPreview({ sessionName, lines, paused });
const contentRef = useRef<HTMLPreElement | null>(null);
const shouldFollowTailRef = useRef(true);
const content = !isNodePreviewUnavailable(preview.data) ? preview.data?.content : undefined;
useLayoutEffect(() => {
const el = contentRef.current;
if (!el || !preview.data || isNodePreviewUnavailable(preview.data)) return;
if (shouldFollowTailRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [content, preview.data]);
const handleScroll = () => {
const el = contentRef.current;
if (!el) return;
shouldFollowTailRef.current = isNearBottom(el);
};
return (
<div
@@ -48,7 +70,9 @@ export function SessionPreviewPane({ sessionName, lines, paused, testIdPrefix =
{!isNodePreviewUnavailable(preview.data) && preview.data && (
<>
<pre
ref={contentRef}
data-testid={`${testIdPrefix}-content`}
onScroll={handleScroll}
className="font-mono text-[9px] text-stone-800 bg-stone-50 px-2 py-1 max-h-32 overflow-y-auto whitespace-pre-wrap break-all"
>
{preview.data.content || "(empty pane)"}
@@ -13,6 +13,7 @@ import { cn } from "../../lib/utils.js";
import { useCmuxLaunch } from "../../hooks/useCmuxLaunch.js";
import { ActivityRing } from "./ActivityRing.js";
import { getActivityCardClasses, getActivityCardSignal } from "./activity-card-visuals.js";
import { TerminalPreviewPopover } from "./TerminalPreviewPopover.js";
import type { TopologyActivityVisual } from "../../lib/topology-activity.js";
import { formatCompactTokenCount, formatTokenTotalTitle, sumTokenCounts } from "../../lib/token-format.js";
@@ -99,6 +100,7 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
const tokenTotal = sumTokenCounts(data.contextTotalInputTokens, data.contextTotalOutputTokens);
const tokenLabel = formatCompactTokenCount(tokenTotal);
const tokenTitle = formatTokenTotalTitle(data.contextTotalInputTokens, data.contextTotalOutputTokens);
const hoverIconClass = "inline-flex h-6 w-6 items-center justify-center border border-outline-variant bg-white/90 text-stone-700 opacity-0 shadow-[1px_1px_0_rgba(46,52,46,0.14)] transition-opacity hover:bg-stone-100 hover:text-stone-950 focus:!opacity-100 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-stone-900/20 group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100";
const card = (
<div
@@ -148,6 +150,17 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
aria-label={`activity: ${activityLabel}`}
/>
</div>
{data.rigId ? (
<TerminalPreviewPopover
rigId={data.rigId}
logicalId={data.logicalId}
sessionName={data.canonicalSessionName ?? null}
reducedMotion={data.reducedMotion}
testIdPrefix={`hybrid-${data.logicalId}`}
wrapperClassName="absolute right-8 top-6 z-20"
buttonClassName={hoverIconClass}
/>
) : null}
{data.rigId ? (
<button
type="button"
@@ -158,7 +171,7 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
event.stopPropagation();
cmuxLaunch.mutate({ rigId: data.rigId!, logicalId: data.logicalId });
}}
className="absolute right-1.5 top-6 z-10 inline-flex h-6 w-6 items-center justify-center border border-outline-variant bg-white/90 text-stone-700 opacity-0 shadow-[1px_1px_0_rgba(46,52,46,0.14)] transition-opacity hover:bg-stone-100 hover:text-stone-950 focus:!opacity-100 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-stone-900/20 group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100"
className={cn("absolute right-1.5 top-6 z-10", hoverIconClass)}
>
<PanelsTopLeft className="h-3.5 w-3.5" aria-hidden="true" />
</button>
@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from "react";
import type React from "react";
import { Terminal, X } from "lucide-react";
import { SessionPreviewPane } from "../preview/SessionPreviewPane.js";
import { cn } from "../../lib/utils.js";
const TERMINAL_PREVIEW_EVENT = "openrig:topology-terminal-preview";
interface TerminalPreviewEventDetail {
key: string;
}
interface TerminalPreviewPopoverProps {
rigId: string | null | undefined;
logicalId: string;
sessionName: string | null | undefined;
reducedMotion?: boolean;
wrapperClassName?: string;
buttonClassName?: string;
popoverClassName?: string;
testIdPrefix: string;
}
export function TerminalPreviewPopover({
rigId,
logicalId,
sessionName,
reducedMotion,
wrapperClassName,
buttonClassName,
popoverClassName,
testIdPrefix,
}: TerminalPreviewPopoverProps) {
const key = `${rigId ?? "unknown"}:${logicalId}`;
const rootRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
const handleOpen = (event: Event) => {
const detail = (event as CustomEvent<TerminalPreviewEventDetail>).detail;
setOpen(detail?.key === key);
};
window.addEventListener(TERMINAL_PREVIEW_EVENT, handleOpen);
return () => window.removeEventListener(TERMINAL_PREVIEW_EVENT, handleOpen);
}, [key]);
useEffect(() => {
if (!open) return undefined;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (!rootRef.current?.contains(target)) setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
if (!sessionName) return null;
const openPreview = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
window.dispatchEvent(new CustomEvent<TerminalPreviewEventDetail>(TERMINAL_PREVIEW_EVENT, { detail: { key } }));
};
return (
<div ref={rootRef} className={cn("relative inline-flex", wrapperClassName)}>
<button
type="button"
data-testid={`${testIdPrefix}-terminal-open`}
aria-label={`View ${logicalId} terminal`}
title="View terminal"
onClick={openPreview}
className={buttonClassName}
>
<Terminal className="h-3.5 w-3.5" aria-hidden="true" />
</button>
{open ? (
<div
data-testid={`${testIdPrefix}-terminal-popover`}
data-reduced-motion={reducedMotion ? "true" : "false"}
className={cn(
"nodrag nopan absolute left-full top-0 z-[80] ml-2 w-80 border border-stone-900 bg-white p-2 hard-shadow",
"cursor-default select-text",
popoverClassName,
)}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="truncate font-mono text-[8px] uppercase tracking-[0.14em] text-stone-500">
terminal preview
</div>
<button
type="button"
data-testid={`${testIdPrefix}-terminal-close`}
aria-label="Close terminal preview"
title="Close"
onClick={(event) => {
event.stopPropagation();
setOpen(false);
}}
className="inline-flex h-5 w-5 items-center justify-center border border-outline-variant bg-white text-stone-600 hover:bg-stone-100 hover:text-stone-950 focus:outline-none focus:ring-2 focus:ring-stone-900/20"
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
</div>
<SessionPreviewPane sessionName={sessionName} lines={80} testIdPrefix={`${testIdPrefix}-terminal-preview`} />
</div>
) : null}
</div>
);
}
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactFlowProvider } from "@xyflow/react";
import type React from "react";
@@ -66,7 +66,10 @@ function renderHybridNode(children: React.ReactNode) {
}
describe("P5.3 ActivityRing and HotPotatoEdge", () => {
afterEach(() => cleanup());
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("renders no ring for idle state", () => {
render(
@@ -142,12 +145,69 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(screen.getByTestId("hybrid-token-total").textContent).toBe("219k");
});
it("hybrid terminal hover action opens a single canvas-local terminal popover", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/config") {
return new Response(JSON.stringify({ settings: {} }));
}
if (url.includes("/api/sessions/")) {
return new Response(JSON.stringify({
sessionName: "velocity-driver@openrig-velocity",
content: "latest terminal line",
lines: 1,
capturedAt: "2026-05-07T08:00:00Z",
}));
}
return new Response(JSON.stringify({}), { status: 404 });
});
renderHybridNode(
<>
<HybridAgentNode
data={{
logicalId: "driver",
role: "driver",
runtime: "claude-code",
model: null,
status: "running",
rigId: "rig-1",
canonicalSessionName: "velocity-driver@openrig-velocity",
}}
/>
<HybridAgentNode
data={{
logicalId: "guard",
role: "guard",
runtime: "codex",
model: null,
status: "running",
rigId: "rig-1",
canonicalSessionName: "velocity-guard@openrig-velocity",
}}
/>
</>,
);
fireEvent.click(screen.getByTestId("hybrid-driver-terminal-open"));
expect(screen.getByTestId("hybrid-driver-terminal-popover")).toBeDefined();
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith("/api/sessions/velocity-driver%40openrig-velocity/preview?lines=80");
});
fireEvent.click(screen.getByTestId("hybrid-guard-terminal-open"));
expect(screen.getByTestId("hybrid-guard-terminal-popover")).toBeDefined();
expect(screen.queryByTestId("hybrid-driver-terminal-popover")).toBeNull();
});
it("source scan keeps activity and packet layers production-reachable", () => {
const srcRoot = path.resolve(__dirname, "../src");
const host = readFileSync(path.join(srcRoot, "components/topology/HostMultiRigGraph.tsx"), "utf8");
const rigGraph = readFileSync(path.join(srcRoot, "components/RigGraph.tsx"), "utf8");
const rigNode = readFileSync(path.join(srcRoot, "components/RigNode.tsx"), "utf8");
const hybridNodes = readFileSync(path.join(srcRoot, "components/topology/HybridTopologyNodes.tsx"), "utf8");
const terminalPopover = readFileSync(path.join(srcRoot, "components/topology/TerminalPreviewPopover.tsx"), "utf8");
const activityCards = readFileSync(path.join(srcRoot, "components/topology/activity-card-visuals.ts"), "utf8");
const table = readFileSync(path.join(srcRoot, "components/topology/TopologyTableView.tsx"), "utf8");
const ring = readFileSync(path.join(srcRoot, "components/topology/ActivityRing.tsx"), "utf8");
@@ -164,7 +224,10 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(rigGraph).toMatch(/edgeTypes=\{edgeTypes\}/);
expect(hybridNodes).toMatch(/ActivityRing/);
expect(hybridNodes).toMatch(/getActivityCardClasses/);
expect(hybridNodes).toMatch(/TerminalPreviewPopover/);
expect(rigNode).toMatch(/getActivityCardClasses/);
expect(rigNode).toMatch(/TerminalPreviewPopover/);
expect(terminalPopover).toMatch(/SessionPreviewPane/);
expect(activityCards).toMatch(/activity-card-active/);
expect(rigGraph).toMatch(/activityRing/);
expect(table).toMatch(/ActivityRing/);
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type React from "react";
import { SessionPreviewPane } from "../src/components/preview/SessionPreviewPane.js";
const mockFetch = vi.fn();
let scrollHeightDescriptor: PropertyDescriptor | undefined;
let clientHeightDescriptor: PropertyDescriptor | undefined;
function withQueryClient(ui: React.ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
function settingsResponse() {
return {
settings: {
"ui.preview.refresh_interval_seconds": { value: 60, source: "default", defaultValue: 3 },
"ui.preview.default_lines": { value: 50, source: "default", defaultValue: 50 },
},
};
}
describe("SessionPreviewPane", () => {
beforeEach(() => {
mockFetch.mockReset();
globalThis.fetch = mockFetch as unknown as typeof fetch;
scrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
clientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
Object.defineProperty(HTMLElement.prototype, "scrollHeight", { configurable: true, get: () => 240 });
Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, get: () => 80 });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
if (scrollHeightDescriptor) Object.defineProperty(HTMLElement.prototype, "scrollHeight", scrollHeightDescriptor);
else delete (HTMLElement.prototype as { scrollHeight?: number }).scrollHeight;
if (clientHeightDescriptor) Object.defineProperty(HTMLElement.prototype, "clientHeight", clientHeightDescriptor);
else delete (HTMLElement.prototype as { clientHeight?: number }).clientHeight;
});
it("scrolls terminal preview content to the bottom on mount", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/config") return new Response(JSON.stringify(settingsResponse()));
if (url.includes("/api/sessions/")) {
return new Response(JSON.stringify({
sessionName: "driver@test-rig",
content: "line 1\nline 2\nline 3\nline 4",
lines: 4,
capturedAt: "2026-05-07T08:00:00Z",
}));
}
return new Response(JSON.stringify({}), { status: 404 });
});
withQueryClient(<SessionPreviewPane sessionName="driver@test-rig" testIdPrefix="terminal-test" />);
const content = await screen.findByTestId("terminal-test-content");
await waitFor(() => {
expect(content.scrollTop).toBe(240);
});
});
});