diff --git a/components/chat.tsx b/components/chat.tsx index 0e779fa..834b439 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -22,6 +22,7 @@ import { Composer } from "@/components/composer"; import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; import { PlanBlock, PlanPopup } from "@/components/plan-block"; +import { PlanWindow } from "@/components/plan-window"; import type { PlanBlockData } from "@/src/core"; import { QuestionDock } from "@/components/question-dock"; import { SidebarToggle } from "@/components/sidebar"; @@ -506,6 +507,8 @@ export function Chat({ {planOpen && setPlanOpen(false)} />} + {/* The plan's ambient presence: live, zoomed on the current node; click → popup. */} + setPlanOpen(true)} /> ); diff --git a/components/plan-block.tsx b/components/plan-block.tsx index a8f91b6..db859fb 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -144,23 +144,26 @@ const TEXT: Record = { /** The diagram itself: positioned nodes + bezier edges. Pure-ish render — * every input is data, so SSR render tests pin the whole thing. */ -function PlanDiagram({ +export function PlanDiagram({ nodes, edges, statusOf, glowId, markerId, + viewBoxOverride, }: { nodes: readonly LayoutNode[]; edges: ReadonlyArray<{ from: string; to: string }>; statusOf: (id: string) => PlanNodeStatus; glowId?: string | null; markerId: string; + /** A windowed viewBox (the plan window) instead of the full extent. */ + viewBoxOverride?: string; }) { const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]); return ( = 0; m--) { const parts = messages[m]?.parts; if (!parts) continue; diff --git a/components/plan-window.tsx b/components/plan-window.tsx new file mode 100644 index 0000000..9b99613 --- /dev/null +++ b/components/plan-window.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useId, useMemo } from "react"; +import { WaypointsIcon } from "lucide-react"; +import type { PlanTree } from "@/src/core"; +import { derivedNodeStatus, derivePlanStatuses } from "@/src/core"; +import { + PlanDiagram, + blockLayout, + latestTree, + NODE_H, + NODE_W, + type PopupMessage, +} from "@/components/plan-block"; + +/** + * THE PLAN WINDOW (docs/probe-teach-split.md §6): the plan's persistent, + * ambient presence — the successor of the injected plan blocks. Always on + * once a tree exists, it shows a ZOOMED-IN window on the CURRENT node and + * moves by itself as the lesson advances (auto-pan: the viewBox re-centers + * on the latest tagged check — no animation machinery, the window just + * follows the derived position). Click opens the full-DAG popup. + * + * Live by construction: statuses derive from the transcript on every + * render (derivePlanStatuses — calls, not results), so the window moves + * the moment a tagged check call streams in or a record adoption lands. + * Hidden on narrow viewports (the dock owns that corner's collapse + * pattern); the learner's visual polish pass (colors, motion) comes later. + */ + +/** Window dimensions in SVG units — 2:1, ~2¼ node widths: the current + * node plus its immediate neighborhood. Aspect-matched to the card. */ +const VIEW_W = 360; +const VIEW_H = 180; + +/** + * The windowed viewBox, centered on the current node — pure (exported for + * tests). Falls back to the FIRST node (authoring order — the lesson's + * start) while no check has been tagged. A layout smaller than the window + * centers itself; a larger one clamps so the window never strays into + * empty space beyond an edge node. + */ +export function planWindowViewBox(tree: PlanTree, current: string | null): string { + const layout = blockLayout(tree.nodes, tree.edges); + const focusId = + current && layout.x.has(current) + ? current + : (tree.nodes[0]?.id ?? null); + const cx = + focusId !== null + ? (layout.x.get(focusId) ?? 0) + NODE_W / 2 + : Math.max(layout.width, 1) / 2; + const cy = + focusId !== null + ? (layout.y.get(focusId) ?? 0) + NODE_H / 2 + : Math.max(layout.height, 1) / 2; + const clamp = (c: number, extent: number, view: number) => + extent <= view ? extent / 2 : Math.min(Math.max(c, view / 2), extent - view / 2); + const x = clamp(cx, layout.width, VIEW_W) - VIEW_W / 2; + const y = clamp(cy, layout.height, VIEW_H) - VIEW_H / 2; + return `${x} ${y} ${VIEW_W} ${VIEW_H}`; +} + +export function PlanWindow({ messages, onOpen }: { messages: readonly PopupMessage[]; onOpen: () => void }) { + const markerId = useId().replace(/[^a-zA-Z0-9-]/g, ""); + const tree = useMemo(() => latestTree(messages), [messages]); + const statuses = useMemo( + () => (tree ? derivePlanStatuses(tree, messages as Parameters[1]) : null), + [tree, messages], + ); + if (!tree || tree.nodes.length === 0) return null; // no plan yet: no window + const statusOf = (id: string) => (statuses ? derivedNodeStatus(statuses, id) : "upcoming"); + const currentTitle = statuses?.current + ? (tree.nodes.find((n) => n.id === statuses.current)?.title ?? null) + : null; + + return ( +
+ + The plan + + {currentTitle ?? "starting"} + +
+ + + ); +} \ No newline at end of file diff --git a/next-env.d.ts b/next-env.d.ts index a419cbe..ce4e94a 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/tests/plan-window.test.tsx b/tests/plan-window.test.tsx new file mode 100644 index 0000000..e610104 --- /dev/null +++ b/tests/plan-window.test.tsx @@ -0,0 +1,111 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { planWindowViewBox, PlanWindow } from "../components/plan-window.tsx"; +import { blockLayout, NODE_H, NODE_W, type PopupMessage } from "../components/plan-block.tsx"; +import type { PlanTree } from "../src/core/contracts.ts"; + +// The plan window: the ambient live view — a windowed viewBox centered on +// the current node (auto-pan by derivation, not animation), full plan +// hidden until a tree exists, click → popup. + +const CHAIN: PlanTree = { + nodes: [ + { id: "a", title: "Alpha" }, + { id: "b", title: "Beta" }, + { id: "c", title: "Gamma" }, + ], + edges: [ + { from: "a", to: "b" }, + { from: "b", to: "c" }, + ], + version: 1, +}; + +/** A transcript with a successful plan_init (carrying `tree`) and + * optionally a tagged quiz call. */ +function transcript(tree: PlanTree, taggedNodeId?: string): PopupMessage[] { + return [ + { id: "m1", role: "user", parts: [] } as never, + { + id: "m2", + role: "assistant", + parts: [{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree } }], + } as never, + ...(taggedNodeId + ? [ + { + id: "m3", + role: "assistant", + parts: [{ type: "tool-quiz", state: "input-available", input: { nodeId: taggedNodeId } }], + } as never, + ] + : []), + ]; +} + +describe("planWindowViewBox (pure)", () => { + it("centers the window on the current node", () => { + const layout = blockLayout(CHAIN.nodes, CHAIN.edges); + const cy = layout.y.get("b")! + NODE_H / 2; // chain: all x = 0 + const vb = planWindowViewBox(CHAIN, "b").split(" ").map(Number); + assert.equal(vb[3], 180); // 2:1 window + assert.equal(vb[1]! + 90, cy, "vertical center = node b's center"); + // layout narrower than the window → centered on the (single-column) layout + assert.equal(vb[0]! + 180, layout.width / 2); + }); + + it("falls back to the first node while no check has been tagged — clamped at the layout's top edge", () => { + const layout = blockLayout(CHAIN.nodes, CHAIN.edges); + const vb = planWindowViewBox(CHAIN, null).split(" ").map(Number); + // Focusing row 0 would center half-off the layout; the clamp pins the + // window to the top edge instead — node a stays fully inside it. + assert.equal(vb[1], 0); + const aTop = layout.y.get("a")!; + assert.ok(aTop >= vb[1] && aTop + NODE_H <= vb[1] + 180, "the first node is inside the window"); + }); + + it("clamps along wide rows — the window never strays past an edge node", () => { + const wide: PlanTree = { + nodes: [ + { id: "r1", title: "R1" }, + { id: "r2", title: "R2" }, + { id: "r3", title: "R3" }, + { id: "t", title: "T" }, + ], + edges: [ + { from: "r1", to: "t" }, + { from: "r2", to: "t" }, + { from: "r3", to: "t" }, + ], + version: 1, + }; + const layout = blockLayout(wide.nodes, wide.edges); + assert.ok(layout.width > 360, "fixture: a row wider than the window"); + // Rightmost root: window's right edge must not exceed the layout width. + const vb = planWindowViewBox(wide, "r3").split(" ").map(Number); + assert.ok(vb[0]! + 360 <= layout.width + 0.001, "clamped at the right edge"); + // Leftmost root: window's left edge must not go negative. + const vb2 = planWindowViewBox(wide, "r1").split(" ").map(Number); + assert.ok(vb2[0]! >= -0.001, "clamped at the left edge"); + }); +}); + +describe("PlanWindow render", () => { + it("hidden while no plan exists", () => { + assert.equal(renderToStaticMarkup( {}} />), ""); + }); + + it("with a plan: renders windowed, glowing current from the latest tagged call; click target present", () => { + const html = renderToStaticMarkup( {}} />); + assert.ok(html.includes('data-testid="plan-window"')); + assert.ok(html.includes('data-status="current"')); + assert.ok(html.includes('data-status="upcoming"')); + // the viewBox is windowed (not the full layout extent) + assert.ok(/viewBox="-?\d+(\.\d+)? -?\d+(\.\d+)? 360 180"/.test(html)); + // no current yet → first node is the fallback focus, header says starting + const html2 = renderToStaticMarkup( {}} />); + assert.ok(html2.includes("starting")); + assert.ok(!html2.includes('data-status="current"'), "nothing current before the first tagged check"); + }); +}); \ No newline at end of file