topology compact terminal preview popover

This commit is contained in:
mvschwarz committed 2026-05-07 01:54:00 -07:00
1 parent 3151a5d9eb
commit 9512ab0502
4 files changed
+117 -36

No files matched your search

@@ -11,23 +11,32 @@
import { useLayoutEffect, useRef } from "react";
import { useSessionPreview, isNodePreviewUnavailable } from "../../hooks/useNodePreview.js";
import { cn } from "../../lib/utils.js";
interface SessionPreviewPaneProps {
sessionName: string;
lines?: number;
paused?: boolean;
testIdPrefix?: string;
variant?: "default" | "compact-terminal";
}
function isNearBottom(el: HTMLElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= 4;
}
export function SessionPreviewPane({ sessionName, lines, paused, testIdPrefix = "session-preview" }: SessionPreviewPaneProps) {
export function SessionPreviewPane({
sessionName,
lines,
paused,
testIdPrefix = "session-preview",
variant = "default",
}: 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;
const compactTerminal = variant === "compact-terminal";
useLayoutEffect(() => {
const el = contentRef.current;
@@ -47,24 +56,45 @@ export function SessionPreviewPane({ sessionName, lines, paused, testIdPrefix =
<div
data-testid={`${testIdPrefix}-pane`}
data-session-name={sessionName}
className="border border-stone-300/40 bg-white/8 px-3 py-2 space-y-1"
data-variant={variant}
className={cn(
"space-y-1",
compactTerminal
? "border-0 bg-transparent p-0 text-stone-100"
: "border border-stone-300/40 bg-white/8 px-3 py-2",
)}
>
<div className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500 truncate">
live preview · {sessionName}
</div>
{!compactTerminal && (
<div className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500 truncate">
live preview · {sessionName}
</div>
)}
{preview.isLoading && (
<div data-testid={`${testIdPrefix}-loading`} className="font-mono text-[9px] text-stone-400">Loading…</div>
)}
{preview.isError && (
<div data-testid={`${testIdPrefix}-error`} className="font-mono text-[9px] text-red-600">
<div
data-testid={`${testIdPrefix}-error`}
className={cn("font-mono text-[9px]", compactTerminal ? "text-red-300" : "text-red-600")}
>
{(preview.error as Error)?.message ?? "Preview failed."}
</div>
)}
{isNodePreviewUnavailable(preview.data) && (
<div data-testid={`${testIdPrefix}-unavailable`} className="font-mono text-[9px] text-stone-500 space-y-0.5">
<div
data-testid={`${testIdPrefix}-unavailable`}
className={cn(
"font-mono space-y-0.5",
compactTerminal ? "text-[10px] text-stone-300" : "text-[9px] text-stone-500",
)}
>
<div>Preview unavailable: {preview.data.reason}.</div>
{preview.data.hint && <div className="text-stone-400">{preview.data.hint}</div>}
<div className="text-stone-400">Use <code>rig capture {sessionName}</code> from terminal as a fallback.</div>
{preview.data.hint && (
<div className={compactTerminal ? "text-stone-500" : "text-stone-400"}>{preview.data.hint}</div>
)}
<div className={compactTerminal ? "text-stone-500" : "text-stone-400"}>
Use <code>rig capture {sessionName}</code> from terminal as a fallback.
</div>
</div>
)}
{!isNodePreviewUnavailable(preview.data) && preview.data && (
@@ -73,14 +103,28 @@ export function SessionPreviewPane({ sessionName, lines, paused, testIdPrefix =
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"
className={cn(
"font-mono overflow-y-auto whitespace-pre-wrap break-all",
compactTerminal
? "max-h-72 bg-transparent px-1 py-0.5 text-[10px] leading-[1.25] text-stone-100"
: "max-h-32 bg-stone-50 px-2 py-1 text-[9px] text-stone-800",
)}
>
{preview.data.content || "(empty pane)"}
</pre>
<div className="font-mono text-[8px] text-stone-400 flex justify-between">
<span>captured {new Date(preview.data.capturedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}</span>
<span>{preview.data.lines} lines</span>
</div>
{!compactTerminal && (
<div className="font-mono text-[8px] text-stone-400 flex justify-between">
<span>
captured{" "}
{new Date(preview.data.capturedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</span>
<span>{preview.data.lines} lines</span>
</div>
)}
</>
)}
</div>
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import type React from "react";
import { Terminal, X } from "lucide-react";
import { Terminal } from "lucide-react";
import { SessionPreviewPane } from "../preview/SessionPreviewPane.js";
import { cn } from "../../lib/utils.js";
@@ -79,32 +79,19 @@ export function TerminalPreviewPopover({
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",
"nodrag nopan absolute left-full top-0 z-[80] ml-2 w-80 border border-outline-variant bg-stone-950 p-1.5 hard-shadow",
"cursor-default select-text text-stone-100",
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`} />
<SessionPreviewPane
sessionName={sessionName}
lines={80}
testIdPrefix={`${testIdPrefix}-terminal-preview`}
variant="compact-terminal"
/>
</div>
) : null}
</div>
@@ -195,6 +195,19 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith("/api/sessions/velocity-driver%40openrig-velocity/preview?lines=80");
});
await screen.findByText("latest terminal line");
expect(screen.queryByText(/terminal preview/i)).toBeNull();
expect(screen.queryByTestId("hybrid-driver-terminal-close")).toBeNull();
expect(screen.queryByText(/live preview/i)).toBeNull();
expect(screen.queryByText(/captured/i)).toBeNull();
expect(screen.queryByText(/1 lines/i)).toBeNull();
expect(screen.getByTestId("hybrid-driver-terminal-preview-pane").getAttribute("data-variant")).toBe("compact-terminal");
fireEvent.pointerDown(document.body);
expect(screen.queryByTestId("hybrid-driver-terminal-popover")).toBeNull();
fireEvent.click(screen.getByTestId("hybrid-driver-terminal-open"));
expect(screen.getByTestId("hybrid-driver-terminal-popover")).toBeDefined();
fireEvent.click(screen.getByTestId("hybrid-guard-terminal-open"));
expect(screen.getByTestId("hybrid-guard-terminal-popover")).toBeDefined();
@@ -228,6 +241,10 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(rigNode).toMatch(/getActivityCardClasses/);
expect(rigNode).toMatch(/TerminalPreviewPopover/);
expect(terminalPopover).toMatch(/SessionPreviewPane/);
expect(terminalPopover).toMatch(/variant="compact-terminal"/);
expect(terminalPopover).toMatch(/bg-stone-950/);
expect(terminalPopover).not.toMatch(/terminal preview/);
expect(terminalPopover).not.toMatch(/terminal-close/);
expect(activityCards).toMatch(/activity-card-active/);
expect(rigGraph).toMatch(/activityRing/);
expect(table).toMatch(/ActivityRing/);
@@ -62,4 +62,37 @@ describe("SessionPreviewPane", () => {
expect(content.scrollTop).toBe(240);
});
});
it("renders compact terminal variant without metadata chrome", 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: "tail line",
lines: 1,
capturedAt: "2026-05-07T08:00:00Z",
}));
}
return new Response(JSON.stringify({}), { status: 404 });
});
withQueryClient(
<SessionPreviewPane
sessionName="driver@test-rig"
testIdPrefix="compact-terminal-test"
variant="compact-terminal"
/>,
);
const pane = await screen.findByTestId("compact-terminal-test-pane");
const content = await screen.findByTestId("compact-terminal-test-content");
expect(pane.getAttribute("data-variant")).toBe("compact-terminal");
expect(content.className).toContain("text-[10px]");
expect(content.className).toContain("text-stone-100");
expect(screen.queryByText(/live preview/i)).toBeNull();
expect(screen.queryByText(/captured/i)).toBeNull();
expect(screen.queryByText(/1 lines/i)).toBeNull();
});
});