topology portal terminal preview popover

This commit is contained in:
mvschwarz committed 2026-05-07 02:07:07 -07:00
1 parent 06fb4f6f2c
commit c265707236
2 files changed
+97 -23

No files matched your search

@@ -1,15 +1,32 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type React from "react";
import { createPortal } from "react-dom";
import { Terminal } from "lucide-react";
import { SessionPreviewPane } from "../preview/SessionPreviewPane.js";
import { cn } from "../../lib/utils.js";
const TERMINAL_PREVIEW_EVENT = "openrig:topology-terminal-preview";
const POPOVER_GAP = 8;
const POPOVER_MARGIN = 8;
const FALLBACK_POPOVER_WIDTH = 320;
const FALLBACK_POPOVER_HEIGHT = 240;
interface TerminalPreviewEventDetail {
key: string;
}
interface AnchorRect {
left: number;
right: number;
top: number;
bottom: number;
}
interface PopoverPosition {
left: number;
top: number;
}
interface TerminalPreviewPopoverProps {
rigId: string | null | undefined;
logicalId: string;
@@ -21,6 +38,32 @@ interface TerminalPreviewPopoverProps {
testIdPrefix: string;
}
function rectFromElement(el: HTMLElement | null): AnchorRect {
const rect = el?.getBoundingClientRect();
return {
left: rect?.left ?? POPOVER_MARGIN,
right: rect?.right ?? POPOVER_MARGIN,
top: rect?.top ?? POPOVER_MARGIN,
bottom: rect?.bottom ?? POPOVER_MARGIN,
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function computePopoverPosition(anchor: AnchorRect, width: number, height: number): PopoverPosition {
const viewportWidth = window.innerWidth || width + POPOVER_MARGIN * 2;
const viewportHeight = window.innerHeight || height + POPOVER_MARGIN * 2;
const rightSideLeft = anchor.right + POPOVER_GAP;
const leftSideLeft = anchor.left - width - POPOVER_GAP;
const left = rightSideLeft + width <= viewportWidth - POPOVER_MARGIN ? rightSideLeft : leftSideLeft;
return {
left: clamp(left, POPOVER_MARGIN, Math.max(POPOVER_MARGIN, viewportWidth - width - POPOVER_MARGIN)),
top: clamp(anchor.top, POPOVER_MARGIN, Math.max(POPOVER_MARGIN, viewportHeight - height - POPOVER_MARGIN)),
};
}
export function TerminalPreviewPopover({
rigId,
logicalId,
@@ -33,23 +76,39 @@ export function TerminalPreviewPopover({
}: TerminalPreviewPopoverProps) {
const key = `${rigId ?? "unknown"}:${logicalId}`;
const rootRef = useRef<HTMLDivElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [anchorRect, setAnchorRect] = useState<AnchorRect | null>(null);
const [position, setPosition] = useState<PopoverPosition | null>(null);
useEffect(() => {
const handleOpen = (event: Event) => {
const detail = (event as CustomEvent<TerminalPreviewEventDetail>).detail;
setOpen(detail?.key === key);
const nextOpen = detail?.key === key;
setOpen(nextOpen);
if (nextOpen) {
const nextAnchor = rectFromElement(rootRef.current);
setAnchorRect(nextAnchor);
setPosition(computePopoverPosition(nextAnchor, FALLBACK_POPOVER_WIDTH, FALLBACK_POPOVER_HEIGHT));
}
};
window.addEventListener(TERMINAL_PREVIEW_EVENT, handleOpen);
return () => window.removeEventListener(TERMINAL_PREVIEW_EVENT, handleOpen);
}, [key]);
useLayoutEffect(() => {
if (!open || !anchorRect || !popoverRef.current) return;
setPosition(computePopoverPosition(anchorRect, popoverRef.current.offsetWidth, popoverRef.current.offsetHeight));
}, [anchorRect, open]);
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);
if (rootRef.current?.contains(target)) return;
if (popoverRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
@@ -62,6 +121,30 @@ export function TerminalPreviewPopover({
window.dispatchEvent(new CustomEvent<TerminalPreviewEventDetail>(TERMINAL_PREVIEW_EVENT, { detail: { key } }));
};
const popover = open && position ? createPortal(
<div
ref={popoverRef}
data-testid={`${testIdPrefix}-terminal-popover`}
data-reduced-motion={reducedMotion ? "true" : "false"}
className={cn(
"nodrag nopan fixed z-[1000] w-80 border border-outline-variant bg-stone-950 p-1.5 hard-shadow",
"cursor-default select-text text-stone-100",
popoverClassName,
)}
style={{ left: position.left, top: position.top }}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<SessionPreviewPane
sessionName={sessionName}
lines={80}
testIdPrefix={`${testIdPrefix}-terminal-preview`}
variant="compact-terminal"
/>
</div>,
document.body,
) : null;
return (
<div ref={rootRef} className={cn("relative inline-flex", wrapperClassName)}>
<button
@@ -74,26 +157,7 @@ export function TerminalPreviewPopover({
>
<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-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()}
>
<SessionPreviewPane
sessionName={sessionName}
lines={80}
testIdPrefix={`${testIdPrefix}-terminal-preview`}
variant="compact-terminal"
/>
</div>
) : null}
{popover}
</div>
);
}
@@ -202,6 +202,12 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
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");
const driverPopover = screen.getByTestId("hybrid-driver-terminal-popover");
expect(driverPopover.parentElement).toBe(document.body);
expect(driverPopover.className).toContain("fixed");
expect(driverPopover.className).toContain("z-[1000]");
expect(driverPopover.style.left).not.toBe("");
expect(driverPopover.style.top).not.toBe("");
fireEvent.pointerDown(document.body);
expect(screen.queryByTestId("hybrid-driver-terminal-popover")).toBeNull();
@@ -241,8 +247,12 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(rigNode).toMatch(/getActivityCardClasses/);
expect(rigNode).toMatch(/TerminalPreviewPopover/);
expect(terminalPopover).toMatch(/SessionPreviewPane/);
expect(terminalPopover).toMatch(/createPortal/);
expect(terminalPopover).toMatch(/document\.body/);
expect(terminalPopover).toMatch(/variant="compact-terminal"/);
expect(terminalPopover).toMatch(/bg-stone-950/);
expect(terminalPopover).toMatch(/z-\[1000\]/);
expect(terminalPopover).not.toMatch(/absolute left-full/);
expect(terminalPopover).not.toMatch(/terminal preview/);
expect(terminalPopover).not.toMatch(/terminal-close/);
expect(activityCards).toMatch(/activity-card-active/);