mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
topology add terminal preview popover
This commit is contained in:
1 parent
b53849c044
commit
1d3cabbdda
6 files changed
+294
-4
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user