Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/features/glance/GlanceCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Box key={n.id} onMouseEnter={() => p.onHoverNode(n.id)} onMouseLeave={() => p.onHoverNode(null)} onClick={click(() => p.onSelectNode(n.id))}
<Box key={n.id} data-glance-node={n.id} onMouseEnter={() => 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" }}>
<Box style={{ width: "100%", height: "100%", background: "var(--bg-elev)", border: `1px solid ${border}`,
Expand Down
6 changes: 4 additions & 2 deletions src/features/glance/GlanceChatDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@ export function GlanceChatDock({
};

return (
// Fills its container (#2401): the morph panel owns the frame (border/radius/shadow) + sizing, so
// this is a plain fill — height:100%, no bottom-dock border. (Was a fixed 40vh bottom dock.)
<Box style={{
height: "40vh", minHeight: 240, flex: "none",
borderTop: "1px solid var(--border)", background: "var(--bg-panel)",
height: "100%", minHeight: 0, flex: 1,
background: "var(--bg-panel)",
display: "flex", flexDirection: "column", minWidth: 0,
}}>
<Row justify="between" align="center" style={{ padding: "7px 12px", borderBottom: "1px solid var(--border)", flex: "none" }}>
Expand Down
43 changes: 43 additions & 0 deletions src/features/glance/GlanceStreamMorph.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <div data-testid="terminal" data-pane={paneId} />,
}));
vi.mock("./GlanceSessionLog", () => ({ GlanceSessionLog: () => <div data-testid="logs" /> }));
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(<GlanceStreamMorph nodeId="api-client" paneId="proj:api-client" name="api-client" role="worker" onClose={() => {}} />);
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(<GlanceStreamMorph nodeId="api-client" paneId="proj:api-client" name="api-client" onClose={onClose} />);
// 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(<GlanceStreamMorph nodeId="api-client" paneId="proj:api-client" name="api-client" onClose={onClose} />);
fireEvent.keyDown(window, { key: "Escape" });
act(() => { vi.advanceTimersByTime(500); });
expect(onClose).toHaveBeenCalledOnce();
});
});
108 changes: 108 additions & 0 deletions src/features/glance/GlanceStreamMorph.tsx
Original file line number Diff line number Diff line change
@@ -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 (`<project>:<stream>`) — the live PTY the dock reconnects to. */
paneId: string;
name: string;
role?: string;
onClose: () => void;
}) {
const panelRef = useRef<HTMLDivElement>(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(
<Box className={`glance-morph${open ? " open" : ""}`} role="dialog" aria-label={`${name} session`}>
<Box className="glance-morph-scrim" onClick={close} />
{/* 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. */}
<div className="glance-morph-panel" ref={panelRef}>
<Box className="glance-morph-body">
<GlanceChatDock paneId={paneId} name={name} role={role} onClose={close} />
</Box>
</div>
</Box>,
document.body,
);
}
20 changes: 12 additions & 8 deletions src/features/glance/GlanceWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -128,22 +128,25 @@ 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 (`<project>:<stream>`) 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; }
if (connect.from !== id) addProjectLink(connect.from, id, connect.kind);
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
// (`<project>:<stream>`) 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;
Expand Down Expand Up @@ -300,8 +303,9 @@ export function GlanceWorkspace({ pageOverride }: { pageOverride?: string } = {}
</Box>
</GraphCanvas>
</Box>
{chatPaneId && (
<GlanceChatDock
{chatPaneId && chatNode && (
<GlanceStreamMorph
nodeId={chatNode}
paneId={chatPaneId}
name={chatMeta?.slug ?? "agent"}
role={chatMeta?.roleLabel}
Expand Down
27 changes: 27 additions & 0 deletions src/features/glance/glance.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,30 @@
.graph-drill-anim (shared/ui/layouts/graphCanvas.css, #2418). */
@keyframes glance-dashmove { to { stroke-dashoffset: -26; } }
@keyframes glance-softpulse { 0%, 100% { opacity: .45; } 50% { opacity: 1; } }

/* Node → live-terminal morph (#2401): a node grows in place into its CLI session. JS sets the panel's
geometry (left/top/width/height) from the node's rect → an expanded rect; the CSS animates that change
(never a scale, so the terminal stays crisp). The body cross-fades in on the second half of the grow. */
.glance-morph { position: fixed; inset: 0; z-index: 60; }
.glance-morph-scrim {
position: absolute; inset: 0; background: rgba(6, 9, 13, .5); backdrop-filter: blur(2px);
opacity: 0; transition: opacity .26s cubic-bezier(.22, .61, .36, 1);
}
.glance-morph.open .glance-morph-scrim { opacity: 1; }
.glance-morph-panel {
position: absolute; display: flex; overflow: hidden;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 30px 80px rgba(0, 0, 0, .6);
transition: left .4s cubic-bezier(.22, .61, .36, 1), top .4s cubic-bezier(.22, .61, .36, 1),
width .4s cubic-bezier(.22, .61, .36, 1), height .4s cubic-bezier(.22, .61, .36, 1),
border-color .4s;
}
.glance-morph.open .glance-morph-panel { border-color: var(--border-strong, var(--border)); }
.glance-morph-body {
flex: 1; min-width: 0; min-height: 0; display: flex;
opacity: 0; transition: opacity .22s .14s cubic-bezier(.22, .61, .36, 1);
}
.glance-morph.open .glance-morph-body { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.glance-morph-scrim, .glance-morph-panel, .glance-morph-body { transition-duration: .001ms !important; }
}
Loading