From 99ba029a3c4bee8724016d75f9488763031c456b Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 11 Sep 2026 03:33:13 +0300 Subject: [PATCH] Retire the chat right sidebar: chat = sessions sidebar + chat column (wave-4 item 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan now lives IN the chat (plan blocks, wave-4 item 3) and background processes live in the activity dock (item 1), so the right sidebar's tree panel + sub-agent fleet column is retired from the chat view: - delete components/tree-panel.tsx (three-mode rail/list/graph panel) and components/sub-agents-panel.tsx; their graph interaction machinery lives on in plan-block.tsx and fleet observability in activity-dock.tsx. - app/layout.tsx drops TreePanelModeProvider (the mode no longer exists). - chat.tsx drops the xl:flex right column; the middle column is the view. - tests: render-tree-panel.test.tsx deleted; the sub-agents panel suite removed from demos.test.tsx (dispatch cards' jobs-context stays — the central poll drives transcript dispatch cards). --- app/layout.tsx | 7 +- components/chat.tsx | 9 - components/sub-agents-panel.tsx | 108 ---- components/tree-panel.tsx | 877 ------------------------------- tests/demos.test.tsx | 20 - tests/render-tree-panel.test.tsx | 498 ------------------ 6 files changed, 2 insertions(+), 1517 deletions(-) delete mode 100644 components/sub-agents-panel.tsx delete mode 100644 components/tree-panel.tsx delete mode 100644 tests/render-tree-panel.test.tsx diff --git a/app/layout.tsx b/app/layout.tsx index bb741c3..d108f76 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import "katex/dist/katex.min.css"; import "./globals.css"; import { AppShell, SidebarProvider } from "@/components/sidebar"; -import { TreePanelModeProvider } from "@/components/tree-panel"; import { ActivityDock, ActivityProvider } from "@/components/activity-dock"; export const metadata: Metadata = { @@ -15,15 +14,13 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - - + {children} {/* The reserved bottom-right corner of every view — renders nothing while no background process runs. */} - - + ); diff --git a/components/chat.tsx b/components/chat.tsx index 3af89ae..274ae5c 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -25,8 +25,6 @@ import { PlanBlock, PlanPopup } from "@/components/plan-block"; import type { PlanBlockData } from "@/src/core"; import { QuestionDock } from "@/components/question-dock"; import { SidebarToggle } from "@/components/sidebar"; -import { SubAgentsPanel } from "@/components/sub-agents-panel"; -import { TreePanel } from "@/components/tree-panel"; import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage, TurnUsage } from "@/src/core"; import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider"; import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core"; @@ -471,13 +469,6 @@ export function Chat({ - {/* Right sidebar, a full-height sibling of the middle column: the - live sub-agent fleet (when running) above the plan tree — the - fleet panel renders nothing while idle, so the column is all plan. */} -
- - -
{planOpen && setPlanOpen(false)} />} diff --git a/components/sub-agents-panel.tsx b/components/sub-agents-panel.tsx deleted file mode 100644 index 4e644e0..0000000 --- a/components/sub-agents-panel.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { BotIcon, PenToolIcon, PlayIcon, SearchIcon } from "lucide-react"; -import type { JobKind, JobRecord } from "@/src/core"; -import { useTreePanelMode } from "@/components/tree-panel"; - -/** - * The sub-agents sidebar section (vision §2.5 live observability): what the - * teacher's background fleet is doing RIGHT NOW in this session — one card - * per running sub-agent with its live activity line (current tool + args) - * and elapsed time, fed by the central jobs poll. - * - * Renders nothing when the fleet is idle (zero clutter); finished jobs - * vanish from the list — their artifacts live in the transcript. Sits at - * the top of the right sidebar, above the plan-tree panel. - */ - -const KIND_META: Record = { - research: { label: "Researcher", Icon: SearchIcon }, - demo: { label: "Demo builder", Icon: PlayIcon }, - visual: { label: "Figure maker", Icon: PenToolIcon }, -}; - -/** The collapsed fleet: a quiet strip on the sidebar rail — bot icon + - * running count, exactly like the plan's node-count rail below it. Click - * expands the sidebar back to the full fleet list. */ -function SubAgentsRail({ count, onExpand }: { count: number; onExpand: () => void }) { - return ( -
- - - {count} running - -
- ); -} - -export function SubAgentsPanel({ jobs }: { jobs: JobRecord[] }) { - const { mode, go } = useTreePanelMode(); - const running = jobs.filter((j) => j.status === "running"); - if (running.length === 0) return null; - if (mode === "rail") - return go("list")} />; - return ( - - ); -} - -function elapsedOf(job: JobRecord): string { - if (!job.createdAt) return ""; - const seconds = Math.max(0, Math.round((Date.now() - Date.parse(job.createdAt)) / 1000)); - return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`; -} diff --git a/components/tree-panel.tsx b/components/tree-panel.tsx deleted file mode 100644 index 1de4af3..0000000 --- a/components/tree-panel.tsx +++ /dev/null @@ -1,877 +0,0 @@ -"use client"; - -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { - CheckIcon, - ChevronsRightIcon, - ListTreeIcon, - MaximizeIcon, - MinusIcon, - NetworkIcon, - PlusIcon, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } from "@/src/core"; - -/** -* The persistent plan-tree panel (vision §2 "Persistent tree view", W7). -* -* A collapsible column on the right side of the session: the whole tree, at -* all times, with per-node status (done / current / next / upcoming) derived -* from the SAME object the teacher edits — no separate state channel. The -* data source is the transcript itself: every plan_* tool returns the full -* updated tree in its output, so the panel scans backward for the LATEST -* output-available plan part and renders its tree. Live during streaming: -* set_current / mark_done outputs flip the panel mid-session. -* -* The panel and the agent's tools share one object with two faces (agent -* edits it, UI renders it) — the phase seam in the transcript -* (PhaseDivider, message-level) marks the transition; THIS panel is the -* surface for every plan_* part (they render nothing inline, by design). -* -* Three modes, one persisted preference: the LIST (nested — the default, -* and the always-visible "collapsed" view), the GRAPH (expanded: a -* hand-built mermaid-style diagram — full styling control, no mermaid -* renderer — with drag-pan + wheel-zoom whose FLOOR is fit-to-panel), and -* the RAIL (thin strip). Quiet register throughout (matches the phase -* dividers): hairline borders, muted text, one highlighted node for the -* current position. Hidden below `xl` (CSS-only). -*/ - -/** localStorage: the mode persists as "lh:tree-panel-mode" - * ("rail" | "list" | "graph"); the pre-graph key - * ("lh:tree-panel-collapsed": "1" rail / "0" list) is the fallback. */ -/** The panel width per mode — the aside AND its fixed inner layer (see - * TreePanel: the inner layer must not squish while the aside animates). */ -const PANEL_WIDTH: Record = { - rail: "w-10", - list: "w-64", - graph: "w-96", -}; - -const MODE_KEY = "lh:tree-panel-mode"; -const LEGACY_KEY = "lh:tree-panel-collapsed"; - -/** The three panel modes: rail (fully collapsed) | list (default) | graph. */ -type PanelMode = "rail" | "list" | "graph"; - -/** -* Minimal structural slice of a UI message — anything with typed parts. -* (Same shape-of-shapes trick as src/core/phases.ts.) -*/ -export interface TreePanelMessage { - parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>; -} - -/** -* The latest plan tree in the transcript: scans messages (and parts) backward -* for the LAST `tool-plan_*` part with an output available and returns its -* `output.tree`. A failed op ({ok:false}) still carries the current tree, so -* it counts; a malformed/missing output falls through to the next candidate. -* Null when no plan output exists yet (probe phase / pre-plan sessions). -*/ -export function latestTree( - messages: readonly TreePanelMessage[], -): PlanTree | null { - for (let m = messages.length - 1; m >= 0; m--) { - const parts = messages[m]?.parts; - if (!parts) continue; - for (let p = parts.length - 1; p >= 0; p--) { - const part = parts[p]!; - if ( - !part.type.startsWith("tool-plan_") || - part.state !== "output-available" - ) - continue; - const output = part.output as { tree?: PlanTree } | undefined; - if (output?.tree && Array.isArray(output.tree.nodes)) return output.tree; - } - } - return null; -} - -/** Topological layout of a tree: display order + dependency depth per node. */ -export interface TopoLayout { - /** Nodes in topological order (prerequisites first), deterministically tie-broken by authoring order. */ - order: PlanNode[]; - /** Longest-path depth from a root — drives indentation (a DAG, not a tree: a node indents under its DEEPEST prerequisite). */ - depth: Map; -} - -/** Kahn's algorithm over the tree's `requires` edges. Server ops already guarantee a DAG. */ -export function topoLayout(tree: PlanTree): TopoLayout { - const index = new Map(tree.nodes.map((n, i) => [n.id, i] as const)); - const indegree = new Map( - tree.nodes.map((n) => [n.id, 0] as const), - ); - const dependents = new Map(); - for (const e of tree.edges) { - if (!index.has(e.from) || !index.has(e.to)) continue; // defensive: dangling ids - indegree.set(e.to, (indegree.get(e.to) ?? 0) + 1); - const list = dependents.get(e.from); - if (list) list.push(e.to); - else dependents.set(e.from, [e.to]); - } - - // Deterministic: among the ready nodes always emit the earliest-authored first. - const ready = tree.nodes - .filter((n) => (indegree.get(n.id) ?? 0) === 0) - .map((n) => n.id); - const order: PlanNode[] = []; - const depth = new Map(); - const byId = new Map(tree.nodes.map((n) => [n.id, n] as const)); - while (ready.length > 0) { - ready.sort((a, b) => (index.get(a) ?? 0) - (index.get(b) ?? 0)); - const id = ready.shift()!; - const node = byId.get(id); - if (node) order.push(node); - let d = 0; - for (const e of tree.edges) { - if (e.to === id) d = Math.max(d, (depth.get(e.from) ?? 0) + 1); - } - depth.set(id, d); - for (const t of dependents.get(id) ?? []) { - const left = (indegree.get(t) ?? 0) - 1; - indegree.set(t, left); - if (left === 0) ready.push(t); - } - } - // Unreachable only if the DAG invariant broke; keep every node visible regardless. - for (const n of tree.nodes) { - if (!depth.has(n.id)) { - order.push(n); - depth.set(n.id, 0); - } - } - return { order, depth }; -} - -/** -* The "next" node (vision §2 lists it as a distinct state): the first -* upcoming node in topological order whose prerequisites are ALL done — the -* head of the unlocked frontier. Null when nothing is ready (or all done). -*/ -export function nextNodeId( - tree: PlanTree, - order: readonly PlanNode[], -): string | null { - const done = new Set(tree.doneNodeIds); - for (const n of order) { - if (planNodeStatus(tree, n.id) !== "upcoming") continue; - const blocked = tree.edges.some((e) => e.to === n.id && !done.has(e.from)); - if (!blocked) return n.id; - } - return null; -} - -// ─── panel ─────────────────────────────────────────────────────────────────── - -/** - * The panel mode lives in the ROOT layout, not in TreePanel. TreePanel is - * per-page (chat sessions remount it on every navigation), so a local copy - * would paint its default first and then "correct" to the stored mode — - * with the animated frame, opening a session visibly opened-then-closed - * the panel. In the root layout the mode survives navigations untouched, - * exactly like the left sidebar's open state (SidebarProvider). - */ -const TreePanelModeContext = createContext<{ mode: PanelMode; go: (next: PanelMode) => void }>({ - mode: "list", - go: () => {}, -}); - -/** The right sidebar's shared mode — every section of the sidebar (plan, - * sub-agents) collapses and expands in lockstep through this hook. */ -export function useTreePanelMode() { - return useContext(TreePanelModeContext); -} - -/** Root-layout persistence of the panel mode (rail | list | graph). */ -export function TreePanelModeProvider({ children }: { children: React.ReactNode }) { - // SSR default: "list" — server and client first-render agree (no - // hydration mismatch). The stored preference applies on mount, and - // the provider itself never remounts, so after that first correction - // the mode is simply THERE on every session you open. - const [mode, setMode] = useState("list"); - - useEffect(() => { - try { - const stored = window.localStorage.getItem(MODE_KEY); - if (stored === "rail" || stored === "list" || stored === "graph") { - setMode(stored); - } else { - // legacy key (pre-graph): "1" = the collapsed rail - const legacy = window.localStorage.getItem(LEGACY_KEY); - if (legacy === "1") setMode("rail"); - } - } catch { - // private mode / storage disabled — the preference is simply not persisted - } - }, []); - - const go = useCallback((next: PanelMode) => { - setMode(next); - try { - window.localStorage.setItem(MODE_KEY, next); - } catch { - // see above - } - }, []); - - return {children}; -} - -export function TreePanel({ - messages, -}: { - messages: readonly TreePanelMessage[]; -}) { - const tree = useMemo(() => latestTree(messages), [messages]); - const { mode, go } = useTreePanelMode(); - - // Empty state: no tree yet → the panel does not render at all. It appears - // the moment plan_init's output lands and updates live from there. - if (!tree || tree.nodes.length === 0) return null; - - // ONE animated container, exactly like the left sidebar: the aside - // animates its width (sidebar-motion transition cascades from the - // provider and is mount-gated there) while the inner layer keeps a - // FIXED width and is clipped by overflow-hidden — content never - // reflows mid-transition, so no scrollbar flash or overshoot. The - // mode's content just swaps instantly inside the steady frame. - return ( - - ); -} - -/** The collapsed panel: a thin strip with the map icon + quiet progress. */ -export function TreePanelRail({ - doneCount, - totalCount, - onExpand, -}: { - doneCount: number; - totalCount: number; - onExpand: () => void; -}) { - return ( -
- - - {doneCount}/{totalCount} - -
- ); -} - -/** The expanded panel (list mode): header + the node list in topological order. */ -export function TreePanelBody({ - tree, - onShowGraph, - onCollapse, -}: { - tree: PlanTree; - /** Expand into the mermaid-style graph view. */ - onShowGraph: () => void; - onCollapse: () => void; -}) { - const { order, depth } = useMemo(() => topoLayout(tree), [tree]); - const nextId = useMemo(() => nextNodeId(tree, order), [tree, order]); - const doneCount = tree.doneNodeIds.length; - const totalCount = tree.nodes.length; - - return ( -
-
- - - Plan - - - {doneCount}/{totalCount} - - - -
- - {/* Keyed by tree.version: every mutation (contracts.ts) bumps it, so a - set_current/mark_done visibly re-animates the list mid-session. */} - -
- ); -} - -/** The per-state glyph: a settled check, the position dot, an unlocked ring, a hollow ring. */ -function StatusMark({ - status, - next = false, -}: { - status: PlanNodeStatus; - next?: boolean; -}) { - switch (status) { - case "done": - return ( - - ); - case "current": - return ( - - ); - case "upcoming": - return ( - - ); - } -} - -// ─── the graph (expanded view) ──────────────────────────────────────────────── -// -// A hand-built mermaid-style diagram — NOT mermaid's renderer: the styling -// stays in this app's design language (phase colors, quiet strokes) and the -// status coloring maps 1:1 onto the list's marks. Boxes top-down (vertical -// space is the cheap axis here), layered by topoLayout depth, authoring -// order within a layer, edges as vertical bezier curves. - -/** Graph geometry (px): node boxes and the gaps that carry the arrows. */ -export const NODE_W = 176; -export const NODE_H = 48; -export const GAP_X = 28; -export const GAP_Y = 64; - -/** Top-left position per node + the diagram's extent. */ -export interface GraphLayout { - x: Map; - y: Map; - width: number; - height: number; -} - -/** - * Layered positions: depth = row (top-down), authoring order within the - * row, each row centered on the widest one. No crossing-reduction pass — - * the teacher authors nodes in dependency order as a rule, and v1 keeps - * the layout deterministic and readable over prettier. - */ -export function graphLayout(order: readonly PlanNode[], depth: ReadonlyMap): GraphLayout { - const layers: PlanNode[][] = []; - for (const n of order) { - const d = depth.get(n.id) ?? 0; - if (!layers[d]) layers[d] = []; - layers[d].push(n); - } - const rowWidth = (l: PlanNode[]) => l.length * NODE_W + (l.length - 1) * GAP_X; - let maxWidth = 0; - for (const l of layers) maxWidth = Math.max(maxWidth, rowWidth(l)); - const x = new Map(); - const y = new Map(); - layers.forEach((l, d) => { - const start = (maxWidth - rowWidth(l)) / 2; - l.forEach((n, i) => { - x.set(n.id, start + i * (NODE_W + GAP_X)); - y.set(n.id, d * (NODE_H + GAP_Y)); - }); - }); - const height = layers.length * NODE_H + (layers.length - 1) * GAP_Y; - return { x, y, width: maxWidth, height: Math.max(height, 0) }; -} - -/** - * Wrap a node title to at most `maxLines` lines of at most `maxChars` - * (greedy word wrap); overflow ends in an ellipsis, a single oversized - * word is hard-clamped. Pure — unit-tested. - */ -export function wrapTitle(title: string, maxChars = 22, maxLines = 2): string[] { - const words = title.trim().split(/\s+/).filter(Boolean); - if (words.length === 0) return [""]; - const lines: string[] = []; - for (const w of words) { - const last = lines[lines.length - 1]; - const fits = last !== undefined && last.length + 1 + w.length <= maxChars; - if (fits) { - lines[lines.length - 1] = `${last} ${w}`; - } else if (lines.length < maxLines) { - lines.push(w.length > maxChars ? `${w.slice(0, maxChars - 1)}…` : w); - } else { - // out of lines: mark the last one as truncated (once) - const lastLine = lines[lines.length - 1]; - if (lastLine !== undefined && !lastLine.endsWith("…")) { - lines[lines.length - 1] = lastLine.length >= maxChars - ? `${lastLine.slice(0, maxChars - 1)}…` - : `${lastLine}…`; - } - } - } - return lines; -} - -/** Box + label styling per status — the graph's face of the list's marks: - * done = colored in (the teach-phase green), current = the position, - * next = the unlocked ring, upcoming = quiet. */ -const GRAPH_BOX: Record = { - done: "fill-phase-teach/10 stroke-phase-teach/50", - current: "fill-primary/10 stroke-primary", - upcoming: "fill-card stroke-border", -}; -const GRAPH_TEXT: Record = { - done: "fill-muted-foreground text-xs", - current: "fill-foreground text-xs font-medium", - upcoming: "fill-muted-foreground/80 text-xs", -}; - -const GRAPH_HEADER_BTN = - "flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"; -const GRAPH_ZOOM_BTN = - "pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"; - -/** Zoom bounds: never below fit-to-panel (the graph can't disappear into - * nothing — fully zoomed out IS the fit), never above 2×. */ -const ZOOM_MAX = 2; -const ZOOM_FLOOR = 0.15; -/** The fit view's breathing room. The fit is the default view — slim - * margins fit more graph. */ -const FIT_PAD = 16; -/** The fit may scale a SMALL graph up to fill the panel (wave-2 fix: the - * old ≤1× cap left small diagrams tiny with big empty margins), but - * never past this — a lone node shouldn't become a billboard. */ -const FIT_ZOOM_MAX = 1.5; - -/** The graph's view transform: zoom + translation (viewport px). */ -export interface GraphView { - zoom: number; - x: number; - y: number; -} - -/** The zoom that fits a bw×bh graph into a w×h viewport: scales DOWN - * large graphs (floor-clamped) and UP small ones (FIT_ZOOM_MAX-capped) - * so the panel is used, not padded with empty margins. */ -export function fitZoomFor( - box: { w: number; h: number }, - viewport: { w: number; h: number }, -): number { - if (!viewport.w || !viewport.h || box.w === 0) return 1; - return Math.max( - ZOOM_FLOOR, - Math.min( - (viewport.w - FIT_PAD) / box.w, - (viewport.h - FIT_PAD) / box.h, - FIT_ZOOM_MAX, - ), - ); -} - -/** The fit-centered view: the whole bw×bh graph centered in the w×h - * viewport — fully zoomed out is EXACTLY this, nothing to pan. */ -export function fitGraphView( - box: { w: number; h: number }, - viewport: { w: number; h: number }, - zoom: number, -): GraphView { - return { zoom, x: (viewport.w - box.w * zoom) / 2, y: (viewport.h - box.h * zoom) / 2 }; -} - -/** - * One zoom step toward `focus` (viewport px), clamped to [floor, ZOOM_MAX]. - * At the floor the view SNAPS to the fit-centered position: fully zoomed - * out is THE fit view — centered, pan locked (the component enforces it). - */ -export function zoomedGraphView( - view: GraphView, - factor: number, - floor: number, - focus: { x: number; y: number }, - box: { w: number; h: number }, - viewport: { w: number; h: number }, -): GraphView { - const zoom = Math.min(Math.max(view.zoom * factor, floor), ZOOM_MAX); - if (zoom === view.zoom) return view; - if (zoom <= floor) return fitGraphView(box, viewport, floor); - const k = zoom / view.zoom; - return { zoom, x: focus.x - (focus.x - view.x) * k, y: focus.y - (focus.y - view.y) * k }; -} - -/** - * The interactive diagram: drag to pan, wheel to zoom (to the cursor), the - * fit button resets. The zoom floor is fit-to-panel — at it, the view is - * EXACTLY the centered fit and panning is locked (there is nothing to - * move). Auto-fits on first measure and on layout changes while the view - * sits at the floor; once the learner zooms in or pans, the view is theirs - * (until they return to the floor or press fit). SSR-safe: all measurement - * lives in effects. The zoom controls sit INSIDE the pan surface but - * never start a drag (pointer capture would swallow their clicks). - */ -export function TreeGraph({ tree }: { tree: PlanTree }) { - const { order, depth } = useMemo(() => topoLayout(tree), [tree]); - const nextId = useMemo(() => nextNodeId(tree, order), [tree, order]); - const layout = useMemo(() => graphLayout(order, depth), [order, depth]); - - const viewportRef = useRef(null); - const [size, setSize] = useState({ w: 0, h: 0 }); - - useEffect(() => { - const el = viewportRef.current; - if (!el || typeof ResizeObserver === "undefined") return; - const ro = new ResizeObserver(() => { - setSize({ w: el.clientWidth, h: el.clientHeight }); - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - // The zoom floor: fully zoomed out, the whole diagram fills the panel - // (small graphs scale UP to use the space, capped at FIT_ZOOM_MAX). - const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]); - const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]); - - const [view, setView] = useState({ x: 0, y: 0, zoom: 1 }); - const userAdjustedRef = useRef(false); - // Epsilon: zoom is float; "at the floor" needs a tolerance. - const atFloor = view.zoom <= fitZoom + 1e-9; - - // Being AT the floor re-arms auto-fit: fully zoomed out is the default - // view, so later layout changes (tree edits) keep it fitting. - useEffect(() => { - if (atFloor) userAdjustedRef.current = false; - }, [atFloor]); - - // Auto-fit (first measure, layout changes) — suppressed once the learner - // takes manual control, until they return to the floor (or press fit). - useEffect(() => { - if (userAdjustedRef.current || !size.w || !size.h) return; - setView(fitGraphView(box, size, fitZoom)); - }, [fitZoom, size, box]); - - // Wheel zoom to the cursor. Non-passive so the page never scrolls instead. - useEffect(() => { - const el = viewportRef.current; - if (!el) return; - const onWheel = (e: WheelEvent) => { - e.preventDefault(); - const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; // Firefox line mode - const factor = Math.exp(-dy * 0.0015); - const rect = el.getBoundingClientRect(); - setView((v) => - zoomedGraphView( - v, - factor, - fitZoom, - { x: e.clientX - rect.left, y: e.clientY - rect.top }, - box, - size, - ), - ); - userAdjustedRef.current = true; - }; - el.addEventListener("wheel", onWheel, { passive: false }); - return () => el.removeEventListener("wheel", onWheel); - }, [fitZoom, box, size]); - - // Drag pan. Guards: the zoom controls sit inside the pan surface — their - // pointerdown must NOT capture (capture redirects the click away from - // the button — the bug that made them feel dead). And at the fit floor - // there is nothing to pan: don't even begin (the cursor stays honest). - const dragRef = useRef<{ - px: number; - py: number; - x: number; - y: number; - moved: boolean; - } | null>(null); - const startDrag = (e: React.PointerEvent) => { - if (e.button !== 0) return; - if ((e.target as HTMLElement).closest("button")) return; - if (atFloor) return; - dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y, moved: false }; - e.currentTarget.classList.add("cursor-grabbing"); - e.currentTarget.setPointerCapture(e.pointerId); - }; - const moveDrag = (e: React.PointerEvent) => { - const d = dragRef.current; - if (!d) return; - d.moved = true; - setView((v) => - v.zoom <= fitZoom + 1e-9 - ? v - : { ...v, x: d.x + (e.clientX - d.px), y: d.y + (e.clientY - d.py) }, - ); - }; - const endDrag = (e: React.PointerEvent) => { - const d = dragRef.current; - if (d) { - dragRef.current = null; - if (d.moved) userAdjustedRef.current = true; - } - e.currentTarget.classList.remove("cursor-grabbing"); - }; - - // Button zoom steps toward the viewport center (same math as wheel). - const stepZoom = (factor: number) => { - userAdjustedRef.current = true; - setView((v) => - zoomedGraphView(v, factor, fitZoom, { x: size.w / 2, y: size.h / 2 }, box, size), - ); - }; - const fitView = () => { - userAdjustedRef.current = false; - if (!size.w || !size.h) return; - setView(fitGraphView(box, size, fitZoom)); - }; - - return ( -
-
- - - - - - - {tree.edges.map((e) => { - const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2; - const y1 = (layout.y.get(e.from) ?? 0) + NODE_H; - const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2; - const y2 = layout.y.get(e.to) ?? 0; - const mid = (y1 + y2) / 2; - return ( - ${e.to}`} - d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`} - className="fill-none stroke-muted-foreground/40" - strokeWidth={1.5} - markerEnd="url(#lh-plan-arrow)" - /> - ); - })} - {order.map((n) => { - const status: PlanNodeStatus = planNodeStatus(tree, n.id); - const lines = wrapTitle(n.title); - return ( - - {n.summary ? `${n.title} — ${n.summary}` : n.title} - - {status === "current" && ( - - )} - {lines.map((line, i) => ( - - {line} - - ))} - - ); - })} - -
- - {/* Quiet affordances: the hint and the zoom controls. */} -
- Drag to pan · scroll to zoom -
- - - -
-
-
- ); -} - -/** The expanded panel (graph mode): wider, the diagram, and the mode toggles. */ -export function TreeGraphPanel({ - tree, - onShowList, - onCollapse, -}: { - tree: PlanTree; - onShowList: () => void; - onCollapse: () => void; -}) { - const doneCount = tree.doneNodeIds.length; - const totalCount = tree.nodes.length; - return ( -
-
- - Plan - - {doneCount}/{totalCount} - - - -
- -
- ); -} diff --git a/tests/demos.test.tsx b/tests/demos.test.tsx index 84c151b..95cd543 100644 --- a/tests/demos.test.tsx +++ b/tests/demos.test.tsx @@ -43,7 +43,6 @@ import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx"; import { DispatchCard } from "../components/artifacts/dispatch-card.tsx"; import { JobCard } from "../components/artifacts/job-card.tsx"; import { JobsContext } from "../components/jobs-context.ts"; -import { SubAgentsPanel } from "../components/sub-agents-panel.tsx"; import { demoInputSchema, jobTools, visualInputSchema } from "../src/agent/tools/jobs.ts"; import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts"; import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts"; @@ -381,25 +380,6 @@ describe("steer card rendering (the delivered artifact)", () => { }); }); -describe("sub-agents panel (sidebar observability)", () => { - const runningJob = (kind: "research" | "visual"): JobRecord => - ({ id: `j-${kind}`, kind, chatId: "c", toolCallId: "t", status: "running", activity: 'web_search: "sigma"', createdAt: new Date(Date.now() - 40000).toISOString(), updatedAt: new Date().toISOString() }) as JobRecord; - - it("lists running sub-agents with kind, activity, and elapsed", () => { - const html = renderToStaticMarkup(); - assert.match(html, /Sub-agents/); - assert.match(html, /Researcher/); - assert.match(html, /Figure maker/); - assert.match(html, /web_search: ("|")sigma/); - assert.match(html, /40s|\d+m/); - }); - - it("renders nothing when the fleet is idle (done jobs vanish)", () => { - const done = { ...runningJob("research"), status: "done" }; - assert.equal(renderToStaticMarkup(), ""); - assert.equal(renderToStaticMarkup(), ""); - }); -}); describe("sanitizeSvg", () => { it("strips script blocks, event handlers, and javascript: urls, keeps geometry", () => { diff --git a/tests/render-tree-panel.test.tsx b/tests/render-tree-panel.test.tsx deleted file mode 100644 index 3014e35..0000000 --- a/tests/render-tree-panel.test.tsx +++ /dev/null @@ -1,498 +0,0 @@ -import React from "react"; -(globalThis as Record).React = React; - -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { renderToStaticMarkup } from "react-dom/server"; -import { - latestTree, - nextNodeId, - topoLayout, - TreePanel, - TreePanelRail, - type TreePanelMessage, -} from "../components/tree-panel.tsx"; -import type { PlanTree } from "../src/core/contracts.ts"; - -/** - * Render + derivation tests for the persistent plan-tree panel (W7, vision - * §2 "Persistent tree view"). Pattern follows render-cards.test.tsx: static - * HTML via react-dom/server, assertions on the markup — plus unit coverage - * for the pure helpers (latestTree / topoLayout / nextNodeId) that drive the - * panel from the transcript's plan tool outputs. - */ - -/** A small DAG authored in NON-topological node order (b, a, c): a → b → c. */ -const chainTree: PlanTree = { - nodes: [ - { id: "b", title: "Chain middle", summary: "The middle step." }, - { id: "a", title: "Foundation", summary: "Where it all starts." }, - { id: "c", title: "Summit", summary: "The payoff." }, - ], - edges: [ - { from: "a", to: "b" }, - { from: "b", to: "c" }, - ], - currentNodeId: "b", - doneNodeIds: ["a"], - version: 3, -}; - -function planPart(tool: string, tree: PlanTree, ok = true) { - return { - type: `tool-${tool}`, - state: "output-available", - toolCallId: `${tool}-1`, - output: { ok, tree }, - }; -} - -/** - * Test literals carry real UI-part fields (toolCallId, text, …) beyond the - * narrow structural slice TreePanelMessage needs — typed once at the boundary - * so the fixtures can read like real transcript parts. - */ -function asMessages(raw: unknown[]): TreePanelMessage[] { - return raw as TreePanelMessage[]; -} - -describe("latestTree (the transcript is the data source)", () => { - it("returns null when no plan tool output exists (probe phase / pre-plan sessions)", () => { - const messages = asMessages([ - { parts: [{ type: "text", text: "hello" }] }, - { parts: [{ type: "tool-quiz", state: "output-available", toolCallId: "q1", output: {} }] }, - ]); - assert.equal(latestTree(messages), null); - }); - - it("returns the LAST output-available plan tree (set_current overrides init)", () => { - const initTree: PlanTree = { ...chainTree, currentNodeId: null, doneNodeIds: [], version: 1 }; - const messages = asMessages([ - { parts: [planPart("plan_init", initTree)] }, - { parts: [planPart("plan_set_current", chainTree)] }, - ]); - const tree = latestTree(messages); - assert.equal(tree?.currentNodeId, "b"); - assert.equal(tree?.version, 3); - }); - - it("skips plan parts still streaming or pending their output", () => { - const initTree: PlanTree = { ...chainTree, version: 1 }; - const messages = asMessages([ - { - parts: [ - planPart("plan_init", initTree), - { type: "tool-plan_mark_done", state: "input-streaming", toolCallId: "md1" }, - ], - }, - { parts: [{ type: "tool-plan_set_current", state: "input-available", toolCallId: "sc1" }] }, - ]); - // The pending mark_done must not shadow the init output behind it. - assert.equal(latestTree(messages)?.version, 1); - }); - - it("counts a failed op's output (it carries the current tree) but skips malformed output", () => { - const failedOp = asMessages([{ parts: [planPart("plan_add_edge", chainTree, false)] }]); - assert.equal(latestTree(failedOp)?.version, 3, "ok:false still carries the live tree"); - - const malformed = asMessages([ - { parts: [{ type: "tool-plan_init", state: "output-available", toolCallId: "p1", output: { ok: true } }] }, - { parts: [planPart("plan_mark_done", chainTree)] }, - ]); - assert.equal(latestTree(malformed)?.version, 3, "falls through a treeless output to the earlier one"); - }); -}); - -describe("topoLayout (order + depth)", () => { - it("orders nodes topologically even when authored out of order", () => { - const { order } = topoLayout(chainTree); - assert.deepEqual( - order.map((n) => n.id), - ["a", "b", "c"], - ); - }); - - it("computes longest-path depth for indentation", () => { - const { depth } = topoLayout(chainTree); - assert.equal(depth.get("a"), 0); - assert.equal(depth.get("b"), 1); - assert.equal(depth.get("c"), 2); - }); - - it("keeps every node visible even if an edge references an unknown id", () => { - const broken: PlanTree = { - ...chainTree, - edges: [{ from: "ghost", to: "a" }], - }; - assert.equal(topoLayout(broken).order.length, 3); - }); -}); - -describe("nextNodeId (the unlocked frontier)", () => { - it("picks the first upcoming node whose prerequisites are all done", () => { - const { order } = topoLayout(chainTree); - // a done, b current (not done), c upcoming but blocked by b → no next. - assert.equal(nextNodeId(chainTree, order), null); - - const unblocked: PlanTree = { ...chainTree, doneNodeIds: ["a", "b"], currentNodeId: null }; - assert.equal(nextNodeId(unblocked, order), "c"); - }); - - it("an independent root is next as soon as it is upcoming", () => { - const tree: PlanTree = { - nodes: [ - { id: "x", title: "X" }, - { id: "y", title: "Y" }, - ], - edges: [], - currentNodeId: "x", - doneNodeIds: [], - version: 1, - }; - assert.equal(nextNodeId(tree, topoLayout(tree).order), "y"); - }); -}); - -describe("TreePanel rendering", () => { - it("renders nothing before any tree exists (probe phase) — and for a failed first init", () => { - assert.equal(renderToStaticMarkup(), ""); - const probing = asMessages([ - { parts: [{ type: "tool-quiz", state: "output-available", toolCallId: "q1", output: {} }] }, - ]); - assert.equal(renderToStaticMarkup(), ""); - - const failedInit = asMessages([{ parts: [planPart("plan_init", { ...chainTree, nodes: [] }, false)] }]); - assert.equal(renderToStaticMarkup(), "", "empty tree → no panel"); - }); - - it("renders the panel with nodes in topological order, statuses, and progress", () => { - const messages = asMessages([{ parts: [planPart("plan_init", chainTree)] }]); - const html = renderToStaticMarkup(); - assert.ok(html.includes('data-testid="tree-panel"'), "the panel renders once a tree lands"); - - // Topological order in the markup (authored order was b, a, c). - const ia = html.indexOf("Foundation"); - const ib = html.indexOf("Chain middle"); - const ic = html.indexOf("Summit"); - assert.ok(ia > -1 && ib > -1 && ic > -1); - assert.ok(ia < ib && ib < ic, "prerequisites render before dependents"); - - // Per-node status from contracts.planNodeStatus. - assert.ok(html.includes('data-status="done"'), "a: done"); - assert.ok(html.includes('data-status="current"'), "b: current"); - assert.ok(html.includes('data-status="upcoming"'), "c: upcoming"); - - // Current-position highlight + its summary surfaced (hover has it too). - assert.ok(/data-status="current"[^>]*bg-primary\/10/.test(html), "current row is the highlighted one"); - assert.ok(html.includes("The middle step."), "current node's summary is visible"); - - // Progress counter in the header. - assert.match(html, /1\/3/); - }); - - it("dependency depth indents dependents (marginLeft grows with depth)", () => { - const messages = asMessages([{ parts: [planPart("plan_init", chainTree)] }]); - const html = renderToStaticMarkup(); - assert.ok(html.includes("margin-left:0"), "root at depth 0"); - assert.ok(html.includes("margin-left:12px"), "depth 1"); - assert.ok(html.includes("margin-left:24px"), "depth 2"); - }); - - it("node summaries are available on hover (title attribute)", () => { - const messages = asMessages([{ parts: [planPart("plan_init", chainTree)] }]); - const html = renderToStaticMarkup(); - assert.ok(html.includes('title="Where it all starts."'), "done node's summary on hover"); - }); - - it("marks the next unlocked node (data-next + the quiet label)", () => { - const tree: PlanTree = { - nodes: [ - { id: "a", title: "Foundation" }, - { id: "b", title: "Middle" }, - { id: "c", title: "Summit" }, - ], - edges: [ - { from: "a", to: "b" }, - { from: "b", to: "c" }, - ], - currentNodeId: null, - doneNodeIds: ["a"], - version: 2, - }; - const html = renderToStaticMarkup( - , - ); - // b's prerequisite (a) is done → b is next; c stays blocked. - assert.ok(/data-testid="tree-node-b"[^>]*data-next="true"/.test(html), "b is the next node"); - assert.ok(!/data-testid="tree-node-c"[^>]*data-next/.test(html), "c is not next"); - assert.match(html, /next/, "the quiet next label"); - }); - - it("live updates: a later plan output replaces the earlier tree", () => { - const init: PlanTree = { - nodes: [{ id: "a", title: "Only node" }], - edges: [], - currentNodeId: null, - doneNodeIds: [], - version: 1, - }; - const before = renderToStaticMarkup( - , - ); - assert.ok(before.includes("Only node")); - - const progressed: PlanTree = { - ...init, - nodes: [ - { id: "a", title: "Only node" }, - { id: "z", title: "Detour" }, - ], - currentNodeId: "z", - doneNodeIds: ["a"], - version: 2, - }; - const after = renderToStaticMarkup( - , - ); - assert.ok(after.includes("Detour"), "the new node appears"); - assert.ok(after.includes('data-status="current"'), "position moved"); - }); - - it("the collapsed rail: thin strip with the expand affordance and quiet progress", () => { - const html = renderToStaticMarkup( {}} />); - assert.ok(html.includes('data-testid="tree-panel-rail"')); - assert.ok(html.includes('aria-label="Show plan"'), "an accessible expand action"); - assert.match(html, /1\/3/, "progress survives the collapse"); - assert.ok(!html.includes("Foundation"), "no node content in the strip"); - }); -}); - -// ─── the graph (expanded view) ──────────────────────────────────────────────── - -import { - TreeGraph, - graphLayout, - wrapTitle, - NODE_H, - NODE_W, -} from "../components/tree-panel.tsx"; - -describe("wrapTitle (box labels)", () => { - it("wraps to lines within the char budget", () => { - assert.deepEqual(wrapTitle("Chain rule intuition for composites"), [ - "Chain rule intuition", - "for composites", - ]); - }); - - it("a title that fits stays one line", () => { - assert.deepEqual(wrapTitle("Foundation"), ["Foundation"]); - }); - - it("overflowing titles end in an ellipsis", () => { - const lines = wrapTitle("Substitution and the differential of implicit functions"); - assert.equal(lines.length, 2); - assert.ok(lines.at(-1)?.endsWith("…")); - }); - - it("a single oversized word is hard-clamped", () => { - const lines = wrapTitle("antidifferentiation-techniques-overview"); - assert.ok(lines.every((l) => l.length <= 22)); - assert.ok(lines.at(-1)?.endsWith("…")); - }); - - it("empty titles render one empty line (never an empty array)", () => { - assert.deepEqual(wrapTitle(""), [""]); - }); -}); - -describe("graphLayout (layered, top-down)", () => { - it("one node per depth row, authoring order within the row, rows centered", () => { - const { order, depth } = topoLayout(chainTree); - const layout = graphLayout(order, depth); - assert.equal(layout.height, NODE_H * 3 + 64 * 2, "three rows plus arrow gaps"); - // b, a: depth 0; c: depth 1 → wait: a is authored second but is the root. - assert.equal(depth.get("a"), 0); - assert.equal(depth.get("b"), 1); - assert.equal(depth.get("c"), 2); - // each row of one node is centered on the (single-column) width. - assert.equal(layout.x.get("a"), 0); - assert.equal(layout.x.get("b"), 0); - assert.equal(layout.x.get("c"), 0); - assert.equal(layout.y.get("a"), 0); - assert.equal(layout.y.get("b"), NODE_H + 64); - }); - - it("two same-depth nodes sit side by side, centered over the widest row", () => { - const tree: PlanTree = { - nodes: [ - { id: "r", title: "Root" }, - { id: "x", title: "X" }, - { id: "y", title: "Y" }, - ], - edges: [ - { from: "r", to: "x" }, - { from: "r", to: "y" }, - ], - currentNodeId: null, - doneNodeIds: [], - version: 1, - }; - const { order, depth } = topoLayout(tree); - const layout = graphLayout(order, depth); - const xX = layout.x.get("x")!; - const xY = layout.x.get("y")!; - assert.equal(xY - xX, NODE_W + 28, "one box + one gap apart"); - // row 1 is the widest → row 0 centers over it: the root sits exactly - // between the two dependents' centers (88 and 292 → 190 - 88 = 102). - assert.equal(layout.x.get("r"), (2 * NODE_W + 28 - NODE_W) / 2, "the single root centers over the two-wide row"); - assert.equal(layout.width, 2 * NODE_W + 28); - }); -}); - -describe("TreeGraph rendering", () => { - it("renders one box per node with its status, one arrow per edge", () => { - const html = renderToStaticMarkup(); - for (const [id, status] of [ - ["a", "done"], - ["b", "current"], - ["c", "upcoming"], - ] as const) { - assert.ok( - new RegExp(`data-testid="graph-node-${id}"[^>]*data-status="${status}"`).test(html), - `${id} is ${status}`, - ); - } - assert.equal(html.match(/marker-end="url\(#lh-plan-arrow\)"/g)?.length, 2, "two edges, two arrows"); - }); - - it("colors progress: done boxes carry the teach-phase tint", () => { - const html = renderToStaticMarkup(); - assert.match(html, /data-status="done"[\s\S]*?fill-phase-teach\/10/); - assert.match(html, /data-status="current"[\s\S]*?stroke-primary/); - }); - - it("marks the next unlocked node", () => { - const tree: PlanTree = { - nodes: [ - { id: "a", title: "Foundation" }, - { id: "b", title: "Middle" }, - ], - edges: [{ from: "a", to: "b" }], - currentNodeId: null, - doneNodeIds: ["a"], - version: 2, - }; - const html = renderToStaticMarkup(); - assert.match(html, /data-testid="graph-node-b"[^>]*data-next="true"/); - }); - - it("node titles wrap inside the box (two lines max, ellipsis on overflow)", () => { - const tree: PlanTree = { - nodes: [{ id: "long", title: "Substitution and the differential of implicit functions" }], - edges: [], - currentNodeId: null, - doneNodeIds: [], - version: 1, - }; - const html = renderToStaticMarkup(); - const texts = html.match(/]*>([^<]*)<\/text>/g) ?? []; - assert.equal(texts.length, 2, "two wrapped lines"); - assert.ok(texts.at(-1)?.includes("…")); - }); - - it("hover shows title and summary", () => { - const html = renderToStaticMarkup(); - assert.ok(html.includes("Foundation — Where it all starts.")); - }); -}); - -// ─── graph view math (pan lock at fit, zoom-to-cursor) ──────────────────────── - -import { - fitGraphView, - zoomedGraphView, - type GraphView, -} from "../components/tree-panel.tsx"; - -describe("fitGraphView / zoomedGraphView", () => { - const box = { w: 1000, h: 800 }; - const viewport = { w: 300, h: 400 }; - - it("fit centers the whole graph in the viewport at the given zoom", () => { - const v = fitGraphView(box, viewport, 0.3); - assert.equal(v.zoom, 0.3); - assert.equal(v.x, (300 - 1000 * 0.3) / 2); - assert.equal(v.y, (400 - 800 * 0.3) / 2); - }); - - it("zooming in moves the point under the cursor to stay put (zoom-to-cursor)", () => { - const v: GraphView = { zoom: 0.5, x: -100, y: -50 }; - // world point at cursor (50, 60) pre-zoom: (50 - (-100))/0.5 = 300 - const next = zoomedGraphView(v, 2, 0.1, { x: 50, y: 60 }, box, viewport); - assert.equal(next.zoom, 1); - // the same world point maps back to the cursor: (50 - next.x)/1 = 300 - assert.equal(50 - next.x, 300); - assert.equal(60 - next.y, 220); - }); - - it("zooming out past the floor SNAPS to the centered fit (fully zoomed out = the fit)", () => { - const v: GraphView = { zoom: 1.8, x: -900, y: -400 }; - const next = zoomedGraphView(v, 1 / 8, 0.3, { x: 100, y: 100 }, box, viewport); - assert.equal(next.zoom, 0.3, "clamped at the floor"); - assert.deepEqual(next, fitGraphView(box, viewport, 0.3), "and re-centered: nothing to pan"); - }); - - it("a no-op zoom returns the same view (no drift)", () => { - const v: GraphView = { zoom: 2, x: 10, y: 10 }; - // factor 1 → same zoom; at MAX already → clamped to same → same view - assert.equal(zoomedGraphView(v, 1, 0.3, { x: 0, y: 0 }, box, viewport), v); - }); - - it("zoom-in clamps at the ceiling (2×)", () => { - const v: GraphView = { zoom: 1.9, x: 0, y: 0 }; - const next = zoomedGraphView(v, 3, 0.3, { x: 10, y: 10 }, box, viewport); - assert.equal(next.zoom, 2); - }); -}); - -// ─── fit sizing (wave-2: small graphs must USE the panel, not float tiny) ───── - -import { fitZoomFor } from "../components/tree-panel.tsx"; - -describe("fitZoomFor", () => { - it("a graph smaller than the panel scales UP to use it — no tiny diagram with empty margins", () => { - // 5-node chain: 176 wide × 448 tall in a 384×600 panel: height-bound. - // Old bug: the ≤1× cap rendered such graphs at natural size, centered, - // tiny, with big empty areas above and below. - const z = fitZoomFor({ w: 176, h: 448 }, { w: 384, h: 600 }); - assert.equal(z, (600 - 16) / 448, "height-bound: fills the tall panel"); - assert.ok(z > 1, "scales UP past 1×"); - }); - - it("the scale-up is capped: a lone node doesn't become a billboard", () => { - const z = fitZoomFor({ w: 176, h: 48 }, { w: 384, h: 600 }); - assert.equal(z, 1.5, "capped at FIT_ZOOM_MAX"); - }); - - it("a wide graph is width-bound; a tall graph is height-bound", () => { - assert.equal(fitZoomFor({ w: 1000, h: 100 }, { w: 384, h: 600 }), (384 - 16) / 1000); - assert.equal(fitZoomFor({ w: 100, h: 1000 }, { w: 384, h: 600 }), (600 - 16) / 1000); - }); - - it("a graph larger than the panel scales DOWN, floor-clamped", () => { - assert.equal(fitZoomFor({ w: 1500, h: 1500 }, { w: 384, h: 600 }), (384 - 16) / 1500); - // absurdly large → clamped at the floor, never disappears - assert.equal(fitZoomFor({ w: 5000, h: 5000 }, { w: 384, h: 600 }), 0.15); - assert.equal(fitZoomFor({ w: 1e6, h: 1e6 }, { w: 384, h: 600 }), 0.15); - }); - - it("an unmeasured viewport (SSR / first paint) stays at 1×", () => { - assert.equal(fitZoomFor({ w: 1000, h: 800 }, { w: 0, h: 0 }), 1); - assert.equal(fitZoomFor({ w: 0, h: 800 }, { w: 384, h: 600 }), 1); - }); -});