diff --git a/src/features/glance/GlanceCanvas.tsx b/src/features/glance/GlanceCanvas.tsx index 2424a3ea..ea02f884 100644 --- a/src/features/glance/GlanceCanvas.tsx +++ b/src/features/glance/GlanceCanvas.tsx @@ -64,7 +64,7 @@ export function GlanceCanvas(p: CanvasProps) { const border = selected ? "var(--accent)" : isCycle ? "color-mix(in oklch, #f2555f 55%, transparent)" : (focus && inFocus ? "var(--border)" : "var(--border-soft)"); const faults = n.faults ?? 0; // #2265: unresolved runtime-fault count → corner badge return ( - p.onHoverNode(n.id)} onMouseLeave={() => p.onHoverNode(null)} onClick={click(() => p.onSelectNode(n.id))} + p.onHoverNode(n.id)} onMouseLeave={() => p.onHoverNode(null)} onClick={click(() => p.onSelectNode(n.id))} style={{ position: "absolute", left: n.x, top: n.y, width: NW, height: NH, cursor: "pointer", zIndex: selected ? 6 : inFocus ? 3 : 1, opacity: focus ? (inFocus ? 1 : REST_N) : 1, transition: "opacity .18s ease" }}> diff --git a/src/features/glance/GlanceStreamMorph.test.tsx b/src/features/glance/GlanceStreamMorph.test.tsx new file mode 100644 index 00000000..4cfa5df4 --- /dev/null +++ b/src/features/glance/GlanceStreamMorph.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { GlanceStreamMorph } from "./GlanceStreamMorph"; + +// Same stubs as the dock test: xterm can't init in jsdom, and the Logs tab polls `bsc`. We test the +// morph SHELL — that it hosts the live terminal for the pane id, and that closing morphs back → onClose. +// The dock renders TerminalSlot since the single Terminal Host landed (#2378) — mock the slot, not +// the old direct TerminalView (which the dock no longer mounts). +vi.mock("@/app/console/terminal/TerminalSlot", () => ({ + TerminalSlot: ({ paneId }: { paneId: string }) =>
, +})); +vi.mock("./GlanceSessionLog", () => ({ GlanceSessionLog: () =>
})); +vi.mock("@/shared/lib/core/safeInvoke", () => ({ fireInvoke: vi.fn() })); + +describe("GlanceStreamMorph (#2401)", () => { + afterEach(() => vi.useRealTimers()); + + it("hosts the agent's live terminal (its identity pane id) inside the morph panel", () => { + render( {}} />); + expect(screen.getByText("api-client")).toBeInTheDocument(); + expect(screen.getByTestId("terminal")).toHaveAttribute("data-pane", "proj:api-client"); + }); + + it("morphs back → fires onClose after the exit transition, not before", () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + render(); + // The dock's ✕ triggers the morph-back — onClose is deferred until the panel returns to the node. + fireEvent.click(screen.getByRole("button", { name: "Close stream" })); + expect(onClose).not.toHaveBeenCalled(); + act(() => { vi.advanceTimersByTime(500); }); // past the exit fallback + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("closes on Escape", () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + render(); + fireEvent.keyDown(window, { key: "Escape" }); + act(() => { vi.advanceTimersByTime(500); }); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/glance/GlanceStreamMorph.tsx b/src/features/glance/GlanceStreamMorph.tsx new file mode 100644 index 00000000..c28980ff --- /dev/null +++ b/src/features/glance/GlanceStreamMorph.tsx @@ -0,0 +1,108 @@ +// GlanceStreamMorph (#2401) — presents a live agent node's CLI session as the node GROWING in place. +// A portal overlay that morphs from the clicked node's on-screen rect to an expanded panel hosting the +// SAME GlanceChatDock (the real PTY stream + a "message the agent" input). The morph animates GEOMETRY +// (left/top/width/height), never a scale — so the terminal text stays pixel-crisp the whole way. Esc, +// the scrim, or the dock's ✕ morphs it back to the node. Reduced-motion → an instant swap (the CSS zeroes +// the transitions; the fallback timer still fires onClose). +// +// The graph stays the entire UI: a node just gains a second state. Nothing new is mounted permanently — +// closing UNMOUNTS the TerminalView, which keeps its PTY alive (TerminalView cleanup is kill-free), so +// the agent is untouched. +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Box } from "@/shared/ui/layout/Box"; +import { GlanceChatDock } from "./GlanceChatDock"; + +/** Panel transition duration (ms) — the reduced-motion / missing-node fallback for onClose. Keep in + * sync with the `.glance-morph-panel` transition in glance.css. */ +const EXIT_MS = 420; + +interface Rect { left: number; top: number; w: number; h: number } + +export function GlanceStreamMorph({ nodeId, paneId, name, role, onClose }: { + /** The graph node this session belongs to — the morph's origin + return rect (`[data-glance-node]`). */ + nodeId: string; + /** The agent's identity pane id (`:`) — the live PTY the dock reconnects to. */ + paneId: string; + name: string; + role?: string; + onClose: () => void; +}) { + const panelRef = useRef(null); + const [open, setOpen] = useState(false); + const closingRef = useRef(false); + + const nodeRect = (): DOMRect | null => + document.querySelector(`[data-glance-node="${CSS.escape(nodeId)}"]`)?.getBoundingClientRect() ?? null; + + // The expanded rect: a panel anchored toward the node's centre, clamped on-screen (never scaled). + const targetRect = (): Rect => { + const vw = window.innerWidth, vh = window.innerHeight; + const w = Math.min(760, vw - 48); + const h = Math.min(Math.round(vh * 0.64), vh - 96); + const o = nodeRect(); + const cx = o ? o.left + o.width / 2 : vw / 2; + const cy = o ? o.top + o.height / 2 : vh / 2; + return { + left: Math.max(24, Math.min(cx - w / 2, vw - 24 - w)), + top: Math.max(24, Math.min(cy - h / 2, vh - 24 - h)), + w, h, + }; + }; + const setRect = (el: HTMLDivElement, r: Rect) => { + el.style.left = `${r.left}px`; el.style.top = `${r.top}px`; el.style.width = `${r.w}px`; el.style.height = `${r.h}px`; + }; + + // Mount: start ON the node's rect (no transition), then grow to the target on the next frame. + useLayoutEffect(() => { + const el = panelRef.current; if (!el) return; + const o = nodeRect(); + el.style.transition = "none"; + if (o) setRect(el, { left: o.left, top: o.top, w: o.width, h: o.height }); + else { const t = targetRect(); setRect(el, { left: t.left + t.w / 2 - 20, top: t.top + t.h / 2 - 20, w: 40, h: 40 }); } + const id = requestAnimationFrame(() => { + el.style.transition = ""; // re-enable the CSS transition + setRect(el, targetRect()); + setOpen(true); + }); + return () => cancelAnimationFrame(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const close = () => { + if (closingRef.current) return; + closingRef.current = true; + setOpen(false); + const el = panelRef.current; + const o = nodeRect(); + let fired = false; + const fire = () => { if (fired) return; fired = true; onClose(); }; + if (el && o) { + setRect(el, { left: o.left, top: o.top, w: o.width, h: o.height }); // shrink back to the node + const onEnd = () => { el.removeEventListener("transitionend", onEnd); fire(); }; + el.addEventListener("transitionend", onEnd); + } + window.setTimeout(fire, EXIT_MS); // reduced-motion (no transitionend) or the node is gone + }; + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return createPortal( + + + {/* eslint-disable-next-line no-restricted-syntax -- ref'd panel whose geometry (left/top/width/height) + is measured + animated imperatively for the morph; a Box would obscure the direct ref/style access. */} +
+ + + +
+
, + document.body, + ); +} diff --git a/src/features/glance/GlanceWorkspace.tsx b/src/features/glance/GlanceWorkspace.tsx index 2dcf14be..348ff333 100644 --- a/src/features/glance/GlanceWorkspace.tsx +++ b/src/features/glance/GlanceWorkspace.tsx @@ -26,7 +26,7 @@ import { useGraphViewport } from "@/shared/ui/layouts/useGraphViewport"; import { Fleet } from "@/features/planner/fleet/Fleet"; import { GlanceCanvas, GlanceOverlays } from "./GlanceCanvas"; import { GlanceInspector } from "./GlanceInspector"; -import { GlanceChatDock } from "./GlanceChatDock"; +import { GlanceStreamMorph } from "./GlanceStreamMorph"; import { fleetPaneId } from "@/app/console/lib/paneIdentity"; import { buildGraph, focusSets, STATUS_META, ROLE_COLOR, EDGE_META, type GEdgeKind } from "./lib/glanceGraph"; import { buildGlanceData } from "./lib/glanceData"; @@ -128,8 +128,11 @@ export function GlanceWorkspace({ pageOverride }: { pageOverride?: string } = {} const pickNode = (id: string) => { setSel({ type: "node", id }); setShowCycle(false); }; const pickEdge = (id: string) => { setSel({ type: "edge", id }); setShowCycle(false); }; + // A node is LIVE — its real PTY openable — iff its identity pane id (`:`) is in the + // launched fleet. Only a drilled, live agent has a session to check in on. + const isLiveAgent = (nodeId: string) => !!drill && !!fleetPaneStreams[fleetPaneId(drill, nodeId)]; // On the L0 network: connect-mode wires two projects; otherwise a click drills into the fleet. Inside a - // fleet a click selects an agent. + // fleet a click checks in on a LIVE agent (morph → terminal, #2401) or selects a non-live one. const onNodeClick = (id: string) => { if (!drill && connect) { if (!connect.from) { setConnect({ ...connect, from: id }); return; } @@ -137,13 +140,13 @@ export function GlanceWorkspace({ pageOverride }: { pageOverride?: string } = {} setConnect(null); return; } - if (drill) pickNode(id); else { setDrill(id); setSel(null); setShowCycle(false); } + // Inside a fleet: opening a LIVE agent morphs its node into the live terminal; a non-live agent has + // no session to open, so it just selects → inspector. + if (drill) { if (isLiveAgent(id)) setChatNode(id); else pickNode(id); } + else { setDrill(id); setSel(null); setShowCycle(false); } }; const exitDrill = () => { setDrill(null); setSel(null); setShowCycle(false); setChatNode(null); }; - // The agent stream dock (#2369). A node is LIVE — its real PTY openable — iff its identity pane id - // (`:`) is in the launched fleet. The dock only opens for a drilled, live agent. - const isLiveAgent = (nodeId: string) => !!drill && !!fleetPaneStreams[fleetPaneId(drill, nodeId)]; // The dock shows ONLY while the open node is still a live agent in the CURRENT fleet — so drilling // out (or a nav-history back/forward that swaps `drill`) closes it by derivation, no reset effect. const chatPaneId = drill && chatNode && isLiveAgent(chatNode) ? fleetPaneId(drill, chatNode) : null; @@ -300,8 +303,9 @@ export function GlanceWorkspace({ pageOverride }: { pageOverride?: string } = {}
- {chatPaneId && ( -