2026-09-11 03:26:56 +03:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
|
|
|
|
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-09-14 07:30:53 +03:00
|
|
|
|
// The placement engine — literally mermaid's: dagre-d3-es, the fork
|
|
|
|
|
|
// mermaid 11 ships with (owner, plan-graph round 8: same engine as the
|
|
|
|
|
|
// mermaid graphs, our SVG skin stays). The deep subpath is how mermaid
|
|
|
|
|
|
// itself imports it.
|
|
|
|
|
|
import { graphlib } from "dagre-d3-es";
|
|
|
|
|
|
import { layout as dagreLayout } from "dagre-d3-es/src/dagre/index.js";
|
2026-09-14 07:38:13 +03:00
|
|
|
|
// Mermaid's default flowchart edge curve: dagre's waypoints drawn through
|
|
|
|
|
|
// d3's curveBasis (the exact recipe dagre-d3's own renderer uses).
|
|
|
|
|
|
import { curveBasis, line } from "d3-shape";
|
2026-09-19 13:16:34 +03:00
|
|
|
|
import { fitGraphView, fitZoomFor, zoomedGraphView, type Viewport } from "@/components/pan-zoom";
|
2026-09-11 03:26:56 +03:00
|
|
|
|
import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core";
|
|
|
|
|
|
import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* PLAN BLOCKS — the plan lives in the chat (wave 4): persisted
|
|
|
|
|
|
* `data-plan-block` parts render as DAG blocks in the transcript flow, and
|
|
|
|
|
|
* the right sidebar is retired.
|
|
|
|
|
|
*
|
|
|
|
|
|
* - kind "full": the whole DAG, BORDERLESS — no wrapping chrome, the SVG
|
2026-09-14 06:26:55 +03:00
|
|
|
|
* scales to the full column width (viewBox = layout extent + GRAPH_PAD,
|
|
|
|
|
|
* so strokes/glow never clip), natural height, capped at the chat prose
|
|
|
|
|
|
* font size (TEXT_CAP_SCALE — fills the column, never oversizes text).
|
2026-09-19 13:16:34 +03:00
|
|
|
|
* - Clicking opens the full plan in a session-scoped focus overlay
|
|
|
|
|
|
* (absolute inside the session area, below the drag bar — chrome-melt
|
|
|
|
|
|
* wave, 2026-09-19; was a fixed inset-0 full-screen popup) —
|
2026-09-11 03:26:56 +03:00
|
|
|
|
* the LIVE plan: the latest tree from the transcript with statuses
|
|
|
|
|
|
* derived at click time (contracts/plan.ts derivePlanStatuses). Blocks
|
|
|
|
|
|
* are frozen snapshots rendered exactly as carried (never re-derived);
|
|
|
|
|
|
* the popup is the live view (user decision, 2026-09).
|
|
|
|
|
|
*
|
2026-09-14 07:30:53 +03:00
|
|
|
|
* Hand-built SVG everywhere (user decision): placement runs on dagre
|
|
|
|
|
|
* (mermaid's engine — deterministic, synchronous, DOM-free, so SSR and
|
|
|
|
|
|
* render tests stay exact); the skin is ours — no coupling to the graph
|
|
|
|
|
|
* stream's internals. Pan/zoom in the popup
|
2026-09-11 03:26:56 +03:00
|
|
|
|
* is harvested from the retired tree panel's graph (fit-floor semantics:
|
|
|
|
|
|
* fully zoomed out IS the fit; panning locks at the floor).
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Layout constants + pure helpers (exported for tests) ────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export const NODE_W = 160;
|
|
|
|
|
|
export const NODE_H = 44;
|
2026-09-14 07:12:38 +03:00
|
|
|
|
/** Baseline-to-baseline distance of node titles. */
|
|
|
|
|
|
export const LINE_H = 13;
|
2026-09-11 03:26:56 +03:00
|
|
|
|
export const GAP_X = 24;
|
|
|
|
|
|
export const GAP_Y = 48;
|
|
|
|
|
|
|
|
|
|
|
|
/** A node for layout purposes — the payload's shape and the tree's shape agree. */
|
|
|
|
|
|
export interface LayoutNode {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 07:30:53 +03:00
|
|
|
|
/** Topological order + longest-path depth (Kahn, authoring tie-break) —
|
|
|
|
|
|
* order + depth semantics kept for tests; placement itself runs on dagre
|
|
|
|
|
|
* now (owner, round 8). */
|
2026-09-11 03:26:56 +03:00
|
|
|
|
export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) {
|
|
|
|
|
|
const index = new Map(nodes.map((n, i) => [n.id, i] as const));
|
|
|
|
|
|
const indegree = new Map<string, number>(nodes.map((n) => [n.id, 0] as const));
|
|
|
|
|
|
const dependents = new Map<string, string[]>();
|
|
|
|
|
|
for (const e of 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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
const ready = nodes.filter((n) => (indegree.get(n.id) ?? 0) === 0).map((n) => n.id);
|
|
|
|
|
|
const order: LayoutNode[] = [];
|
|
|
|
|
|
const depth = new Map<string, number>();
|
|
|
|
|
|
const byId = new Map(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);
|
|
|
|
|
|
const d = depth.get(id) ?? 0;
|
|
|
|
|
|
depth.set(id, d);
|
|
|
|
|
|
for (const t of dependents.get(id) ?? []) {
|
|
|
|
|
|
if ((depth.get(t) ?? -1) < d + 1) depth.set(t, d + 1);
|
|
|
|
|
|
const left = (indegree.get(t) ?? 0) - 1;
|
|
|
|
|
|
indegree.set(t, left);
|
|
|
|
|
|
if (left === 0) ready.push(t);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const n of nodes) {
|
|
|
|
|
|
if (!depth.has(n.id)) {
|
|
|
|
|
|
order.push(n);
|
|
|
|
|
|
depth.set(n.id, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return { order, depth };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 07:30:53 +03:00
|
|
|
|
/** Placement — dagre (mermaid's engine, owner round 8): Sugiyama-style
|
|
|
|
|
|
* layered layout, TB, with our geometry (NODE_W boxes, GAP_X/GAP_Y
|
|
|
|
|
|
* separations, zero margin — the caller adds GRAPH_PAD/POPUP_PAD).
|
|
|
|
|
|
* Node sizes are fed in (boxes grow with their wrapped content, round
|
|
|
|
|
|
* 7); dagre returns node CENTERS, we hand back top-lefts like before,
|
|
|
|
|
|
* so every consumer (diagram, popup fit, plan window) is unchanged.
|
|
|
|
|
|
* Deterministic + synchronous + DOM-free: SSR and render tests stay
|
|
|
|
|
|
* exact. Dangling edges are ignored defensively — a ghost endpoint
|
|
|
|
|
|
* would spawn an unmeasured node and break the measurer. */
|
2026-09-11 03:26:56 +03:00
|
|
|
|
export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) {
|
|
|
|
|
|
const x = new Map<string, number>();
|
|
|
|
|
|
const y = new Map<string, number>();
|
2026-09-14 07:12:38 +03:00
|
|
|
|
const nodeH = new Map<string, number>();
|
2026-09-14 07:38:13 +03:00
|
|
|
|
/** Dagre's per-edge waypoint routes — each starts/ends at the node
|
|
|
|
|
|
* border intersection on whatever SIDE the edge leaves/enters
|
|
|
|
|
|
* (round 11: the mermaid line drawing, no more fixed bottom-center →
|
|
|
|
|
|
* top-center). Keyed `${from}->${to}`. */
|
|
|
|
|
|
const edgePts = new Map<string, Array<{ x: number; y: number }>>();
|
|
|
|
|
|
if (nodes.length === 0) return { x, y, nodeH, edgePts, width: 0, height: 0 };
|
2026-09-14 07:30:53 +03:00
|
|
|
|
const ids = new Set(nodes.map((n) => n.id));
|
2026-09-14 07:38:13 +03:00
|
|
|
|
const routed: Array<{ from: string; to: string }> = [];
|
2026-09-14 07:30:53 +03:00
|
|
|
|
const g = new graphlib.Graph({ compound: false, multigraph: true });
|
|
|
|
|
|
g.setGraph({ rankdir: "TB", nodesep: GAP_X, ranksep: GAP_Y, marginx: 0, marginy: 0 });
|
|
|
|
|
|
for (const n of nodes) {
|
|
|
|
|
|
const h = NODE_H + Math.max(0, wrapTitle(n.title).length - 2) * LINE_H;
|
|
|
|
|
|
nodeH.set(n.id, h);
|
|
|
|
|
|
g.setNode(n.id, { width: NODE_W, height: h });
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const e of edges) {
|
2026-09-14 07:38:13 +03:00
|
|
|
|
if (ids.has(e.from) && ids.has(e.to)) {
|
|
|
|
|
|
g.setEdge(e.from, e.to, { label: "", width: 0, height: 0 });
|
|
|
|
|
|
routed.push(e);
|
|
|
|
|
|
}
|
2026-09-14 07:30:53 +03:00
|
|
|
|
}
|
|
|
|
|
|
// (the d.ts marks opts required; the runtime ignores it)
|
|
|
|
|
|
dagreLayout(g, undefined);
|
|
|
|
|
|
for (const n of nodes) {
|
|
|
|
|
|
const pos = g.node(n.id);
|
|
|
|
|
|
const h = nodeH.get(n.id) ?? NODE_H;
|
|
|
|
|
|
if (pos) {
|
|
|
|
|
|
x.set(n.id, pos.x - NODE_W / 2);
|
|
|
|
|
|
y.set(n.id, pos.y - h / 2);
|
2026-09-14 07:12:38 +03:00
|
|
|
|
}
|
2026-09-14 07:30:53 +03:00
|
|
|
|
}
|
2026-09-14 07:38:13 +03:00
|
|
|
|
for (const e of routed) {
|
|
|
|
|
|
const pts: Array<{ x: number; y: number }> | undefined = g.edge(e.from, e.to)?.points;
|
|
|
|
|
|
if (pts) edgePts.set(`${e.from}->${e.to}`, pts.map((pt) => ({ x: pt.x, y: pt.y })));
|
|
|
|
|
|
}
|
2026-09-14 07:30:53 +03:00
|
|
|
|
return {
|
|
|
|
|
|
x,
|
|
|
|
|
|
y,
|
|
|
|
|
|
nodeH,
|
2026-09-14 07:38:13 +03:00
|
|
|
|
edgePts,
|
2026-09-14 07:30:53 +03:00
|
|
|
|
width: Math.max(g.graph().width ?? 0, 1),
|
|
|
|
|
|
height: Math.max(g.graph().height ?? 0, 1),
|
|
|
|
|
|
};
|
2026-09-11 03:26:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 07:12:38 +03:00
|
|
|
|
/** Greedy word wrap to lines of ≤ maxChars — NOTHING is dropped: the
|
|
|
|
|
|
* node's box grows to fit every line (owner: show the full content of
|
|
|
|
|
|
* each node). A single oversized word is hard-chunked across lines at
|
|
|
|
|
|
* maxChars. Pure. */
|
|
|
|
|
|
export function wrapTitle(title: string, maxChars = 20): string[] {
|
2026-09-11 03:26:56 +03:00
|
|
|
|
const words = title.trim().split(/\s+/).filter(Boolean);
|
|
|
|
|
|
if (words.length === 0) return [""];
|
2026-09-14 07:12:38 +03:00
|
|
|
|
// Oversized words can never fit a line — chunk them so no content is lost.
|
|
|
|
|
|
const atoms: string[] = [];
|
2026-09-11 03:26:56 +03:00
|
|
|
|
for (const w of words) {
|
2026-09-14 07:12:38 +03:00
|
|
|
|
for (let i = 0; i < w.length; i += maxChars) atoms.push(w.slice(i, i + maxChars));
|
|
|
|
|
|
}
|
|
|
|
|
|
const lines: string[] = [];
|
|
|
|
|
|
for (const a of atoms) {
|
2026-09-11 03:26:56 +03:00
|
|
|
|
const last = lines[lines.length - 1];
|
2026-09-14 07:12:38 +03:00
|
|
|
|
if (last !== undefined && last.length + 1 + a.length <= maxChars) {
|
|
|
|
|
|
lines[lines.length - 1] = `${last} ${a}`;
|
2026-09-11 03:26:56 +03:00
|
|
|
|
} else {
|
2026-09-14 07:12:38 +03:00
|
|
|
|
lines.push(a);
|
2026-09-11 03:26:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return lines;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 06:22:26 +03:00
|
|
|
|
// ─── Geometry caps (owner, plan-graph refinement round 1) ─────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** SVG-unit padding around the layout extent: strokes are centered on the
|
|
|
|
|
|
* geometry (half bleeds outside the extent) and the glow halo reaches −4 —
|
|
|
|
|
|
* without this pad the outermost node borders clip at the svg edge. */
|
|
|
|
|
|
export const GRAPH_PAD = 8;
|
2026-09-14 07:06:25 +03:00
|
|
|
|
/** The current node's yellow glow: a fill blurred so it bleeds over the
|
|
|
|
|
|
* node's edges (owner). A Gaussian's visible spread is ≈3σ, so the glow
|
|
|
|
|
|
* reaches GLOW_MARGIN + 3·GLOW_BLUR past the node — any surface that
|
|
|
|
|
|
* shows the glow must pad its viewBox by GLOW_REACH or the blur clips
|
|
|
|
|
|
* at the frame edge (owner: extend the container padding to fit it). */
|
|
|
|
|
|
export const GLOW_MARGIN = 8;
|
|
|
|
|
|
export const GLOW_BLUR = 5;
|
|
|
|
|
|
export const GLOW_REACH = GLOW_MARGIN + 3 * GLOW_BLUR;
|
2026-09-14 06:22:26 +03:00
|
|
|
|
/** Node titles render at text-xs (12.5px) in svg units. The in-chat block
|
|
|
|
|
|
* scales up to fill the column but NEVER past the chat prose size
|
2026-09-17 13:43:26 +03:00
|
|
|
|
* (--lh-prose-size, 16px — the ladder's reading rung) — the width cap
|
|
|
|
|
|
* that makes that hold. */
|
|
|
|
|
|
export const TEXT_CAP_SCALE = 16 / 12.5;
|
2026-09-14 06:22:26 +03:00
|
|
|
|
|
2026-09-14 06:59:38 +03:00
|
|
|
|
// ─── The owner's arrowhead (plan-graph round 5) ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** Baked from arrowhead_1.svg (the CM/MJX long-arrow head), transform chain
|
|
|
|
|
|
* collapsed: viewBox 0 0 50.2 104, tip at (50.2, 52), notch mouth at
|
|
|
|
|
|
* x 27.2 with a 9.6-tall opening — at full height 104 the tail opening
|
|
|
|
|
|
* is ≈10, per the owner's spec. Solid white fill, no padding. */
|
|
|
|
|
|
export const ARROW_HEAD = "M48,55.2C49.4,54.6 50.2,53.4 50.2,52C50.2,50.6 49.4,49.4 48,48.8C38.2,45.6 29.2,38.8 21,28.4C15.2,21.4 11.4,13.2 9.6,3.8C9,1.2 7.4,0 4.8,0C1.6,0 0,1.6 0,5L0.2,5.6C3.6,23 12.6,36.8 27.2,47.2L27.2,56.8C12.6,67.2 3.6,81 0.2,98.4L0,99C0,102.4 1.6,104 4.8,104C7.4,104 9,102.8 9.6,100.2C11.4,90.8 15.2,82.6 21,75.6C29.2,65.2 38.2,58.4 48,55.2Z";
|
|
|
|
|
|
/** Head height 16: the tail opening (9.6/104 of height) then tucks the
|
|
|
|
|
|
* 1.5-wide line flush — the LaTeX proportion. The owner's rule: keep the
|
|
|
|
|
|
* current line width; size the head from the 104→10 ratio. */
|
|
|
|
|
|
const HEAD_H = 16;
|
|
|
|
|
|
const HEAD_SCALE = HEAD_H / 104;
|
|
|
|
|
|
const HEAD_W = 50.2 * HEAD_SCALE;
|
|
|
|
|
|
/** The drawn line terminates at the notch mouth (refX 27.2) — it never
|
|
|
|
|
|
* reaches the narrow tip, where it would show through (owner). The tip
|
|
|
|
|
|
* overhangs the line's end by this much, so edges shorten by it to land
|
|
|
|
|
|
* the tip exactly on the target node's border. */
|
|
|
|
|
|
const HEAD_TIP_OVERHANG = (50.2 - 27.2) * HEAD_SCALE;
|
2026-09-14 07:53:54 +03:00
|
|
|
|
/** The head floats this far off the target face — the tip kisses nothing
|
|
|
|
|
|
* (owner, round 12). ~half a head length: clearly docked-off, still
|
|
|
|
|
|
* visually attached. */
|
|
|
|
|
|
const DOCK_GAP = 4;
|
|
|
|
|
|
/** The shaft stub — the straight run before the head (owner, round 12):
|
|
|
|
|
|
* control points from Q on sit exactly on the approach line, so the
|
|
|
|
|
|
* basis spline renders that stretch perfectly straight and the head
|
|
|
|
|
|
* lands on a straight shaft; the corner where the route squares up onto
|
|
|
|
|
|
* the line is rounded by the curve. ~1.5 head lengths of shaft. */
|
|
|
|
|
|
const STRAIGHT_RUN = 12;
|
2026-09-14 08:04:31 +03:00
|
|
|
|
/** The tilt limit at the termination point, measured off the entry
|
|
|
|
|
|
* face's perpendicular: past ±40° one of the head's barbs crosses the
|
|
|
|
|
|
* tip's level and the silhouette breaks (owner measured it in
|
|
|
|
|
|
* Photoshop, round 14). The final run swings to the cone edge instead. */
|
|
|
|
|
|
const ENTRY_ANGLE_LIMIT = (40 * Math.PI) / 180;
|
2026-09-14 07:38:13 +03:00
|
|
|
|
const edgeLine = line<{ x: number; y: number }>()
|
|
|
|
|
|
.x((p) => p.x)
|
|
|
|
|
|
.y((p) => p.y)
|
|
|
|
|
|
.curve(curveBasis);
|
|
|
|
|
|
|
2026-09-14 08:04:31 +03:00
|
|
|
|
/** The entry face's outward unit normal — whichever face of the target
|
|
|
|
|
|
* box dagre's border point sits on. Pure. */
|
|
|
|
|
|
function faceNormal(
|
|
|
|
|
|
border: { x: number; y: number },
|
|
|
|
|
|
box: { x: number; y: number; w: number; h: number },
|
|
|
|
|
|
): { x: number; y: number } {
|
|
|
|
|
|
const d = { top: border.y - box.y, bottom: box.y + box.h - border.y, left: border.x - box.x, right: box.x + box.w - border.x };
|
|
|
|
|
|
const face = (Object.keys(d) as Array<keyof typeof d>).reduce((a, b) => (d[a] <= d[b] ? a : b));
|
|
|
|
|
|
switch (face) {
|
|
|
|
|
|
case "top":
|
|
|
|
|
|
return { x: 0, y: -1 };
|
|
|
|
|
|
case "bottom":
|
|
|
|
|
|
return { x: 0, y: 1 };
|
|
|
|
|
|
case "left":
|
|
|
|
|
|
return { x: -1, y: 0 };
|
|
|
|
|
|
default:
|
|
|
|
|
|
return { x: 1, y: 0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 07:38:13 +03:00
|
|
|
|
/** The edge path — mermaid's own line drawing: dagre's border-intersected
|
|
|
|
|
|
* waypoints through d3's curveBasis (mermaid's default flowchart curve —
|
2026-09-14 08:04:31 +03:00
|
|
|
|
* the same recipe dagre-d3's renderer uses). The head docks off the
|
|
|
|
|
|
* target: its TIP floats DOCK_GAP past the border along the FACE NORMAL
|
|
|
|
|
|
* (constant gap at any tilt), its mouth sits at the path's end on a
|
|
|
|
|
|
* straight run (the shaft stub), and — when the target box is given —
|
|
|
|
|
|
* the entry direction is clamped to ±ENTRY_ANGLE_LIMIT of the face's
|
|
|
|
|
|
* perpendicular (owner, round 14): a steeper natural approach swings to
|
|
|
|
|
|
* the 40° cone edge instead of breaking the head's silhouette. Without
|
|
|
|
|
|
* a target box the unclamped geometry applies. Straight collinear
|
|
|
|
|
|
* routes render identically. Pure, exported for tests. */
|
|
|
|
|
|
export function edgePathD(
|
|
|
|
|
|
points: Array<{ x: number; y: number }>,
|
|
|
|
|
|
target?: { x: number; y: number; w: number; h: number },
|
|
|
|
|
|
): string {
|
2026-09-14 07:38:13 +03:00
|
|
|
|
const pts = points.map((p) => ({ ...p }));
|
|
|
|
|
|
if (pts.length >= 2) {
|
|
|
|
|
|
const last = pts[pts.length - 1]!;
|
|
|
|
|
|
const prev = pts[pts.length - 2]!;
|
|
|
|
|
|
const dx = last.x - prev.x;
|
|
|
|
|
|
const dy = last.y - prev.y;
|
|
|
|
|
|
const len = Math.hypot(dx, dy) || 1;
|
2026-09-14 08:04:31 +03:00
|
|
|
|
let ux = dx / len;
|
|
|
|
|
|
let uy = dy / len;
|
|
|
|
|
|
// The tip: DOCK_GAP outside the face along its normal (or along the
|
|
|
|
|
|
// approach when no box is known).
|
|
|
|
|
|
let tipx: number;
|
|
|
|
|
|
let tipy: number;
|
|
|
|
|
|
if (target) {
|
|
|
|
|
|
const n = faceNormal(last, target);
|
|
|
|
|
|
tipx = last.x + n.x * DOCK_GAP;
|
|
|
|
|
|
tipy = last.y + n.y * DOCK_GAP;
|
|
|
|
|
|
// Clamp the approach to the ±LIMIT cone around the normal: if
|
|
|
|
|
|
// the outgoing direction -u tilts past ENTRY_ANGLE_LIMIT, swing
|
|
|
|
|
|
// it to the cone edge (same rotation side as the route).
|
|
|
|
|
|
const v = { x: -ux, y: -uy };
|
|
|
|
|
|
const vn = v.x * n.x + v.y * n.y;
|
|
|
|
|
|
if (vn < Math.cos(ENTRY_ANGLE_LIMIT)) {
|
|
|
|
|
|
const t = { x: v.x - vn * n.x, y: v.y - vn * n.y };
|
|
|
|
|
|
const tl = Math.hypot(t.x, t.y);
|
|
|
|
|
|
const that = tl > 0 ? { x: t.x / tl, y: t.y / tl } : { x: -n.y, y: n.x };
|
|
|
|
|
|
const c = Math.cos(ENTRY_ANGLE_LIMIT);
|
|
|
|
|
|
const s = Math.sin(ENTRY_ANGLE_LIMIT);
|
|
|
|
|
|
v.x = c * n.x + s * that.x;
|
|
|
|
|
|
v.y = c * n.y + s * that.y;
|
|
|
|
|
|
ux = -v.x;
|
|
|
|
|
|
uy = -v.y;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
tipx = last.x - ux * DOCK_GAP;
|
|
|
|
|
|
tipy = last.y - uy * DOCK_GAP;
|
|
|
|
|
|
}
|
|
|
|
|
|
// The mouth: overhang back from the tip along the (clamped) approach.
|
|
|
|
|
|
const M = { x: tipx - ux * HEAD_TIP_OVERHANG, y: tipy - uy * HEAD_TIP_OVERHANG };
|
2026-09-14 07:56:01 +03:00
|
|
|
|
// From Q the control points lie on the approach line — the basis
|
2026-09-14 07:53:54 +03:00
|
|
|
|
// spline renders Q→M perfectly straight (a B-spline span with
|
2026-09-14 07:56:01 +03:00
|
|
|
|
// collinear control points IS the line), and curveBasis's internal
|
|
|
|
|
|
// endpoint repetition makes the end tangent exactly the approach
|
|
|
|
|
|
// direction. NOTE: do NOT repeat M manually — a duplicated final
|
|
|
|
|
|
// control point renders a zero-length last segment, and
|
|
|
|
|
|
// orient="auto" on a zero-length segment falls back to 0° (every
|
|
|
|
|
|
// head pointing right — the round-12 regression, fixed round 13).
|
2026-09-14 07:53:54 +03:00
|
|
|
|
const Q = { x: M.x - ux * STRAIGHT_RUN, y: M.y - uy * STRAIGHT_RUN };
|
2026-09-14 07:56:01 +03:00
|
|
|
|
pts.splice(pts.length - 1, 1, Q, M);
|
2026-09-14 07:38:13 +03:00
|
|
|
|
}
|
|
|
|
|
|
return edgeLine(pts) ?? "";
|
|
|
|
|
|
}
|
2026-09-14 06:59:38 +03:00
|
|
|
|
|
2026-09-11 03:26:56 +03:00
|
|
|
|
// ─── Status styling (the retired panel's color language, unchanged) ──────────
|
|
|
|
|
|
|
2026-09-14 06:55:10 +03:00
|
|
|
|
// The simple look (owner, plan-graph round 4): rectangular boxes, white
|
|
|
|
|
|
// outlines/arrows/text; no fill on visited, today's gray look on upcoming,
|
|
|
|
|
|
// and the current node carries a faint yellow glow (a blurred yellow fill
|
|
|
|
|
|
// bleeding over the edges). The IN-CHAT block is monochrome white instead
|
|
|
|
|
|
// — no status language; the popup/window show the progression.
|
2026-09-11 03:26:56 +03:00
|
|
|
|
const BOX: Record<PlanNodeStatus, string> = {
|
2026-09-14 07:33:00 +03:00
|
|
|
|
// 50%-opacity app background instead of true transparency: a routed
|
|
|
|
|
|
// edge passing behind the box composites UNDER the fill — dimmed to
|
|
|
|
|
|
// half, not shown straight through (owner, round 9).
|
|
|
|
|
|
visited: "fill-background/50 stroke-white",
|
|
|
|
|
|
current: "fill-background/50 stroke-white",
|
2026-09-14 06:55:10 +03:00
|
|
|
|
// The grayed-out not-yet-visited look stays as-is (owner).
|
2026-09-11 03:26:56 +03:00
|
|
|
|
upcoming: "fill-card stroke-border",
|
|
|
|
|
|
};
|
|
|
|
|
|
const TEXT: Record<PlanNodeStatus, string> = {
|
2026-09-14 06:55:10 +03:00
|
|
|
|
visited: "fill-white text-xs",
|
|
|
|
|
|
current: "fill-white text-xs font-medium",
|
2026-09-11 03:26:56 +03:00
|
|
|
|
upcoming: "fill-muted-foreground/80 text-xs",
|
|
|
|
|
|
};
|
2026-09-14 07:33:00 +03:00
|
|
|
|
const MONO_BOX = "fill-background/50 stroke-white";
|
2026-09-14 06:55:10 +03:00
|
|
|
|
const MONO_TEXT = "fill-white text-xs";
|
2026-09-11 03:26:56 +03:00
|
|
|
|
|
|
|
|
|
|
/** The diagram itself: positioned nodes + bezier edges. Pure-ish render —
|
|
|
|
|
|
* every input is data, so SSR render tests pin the whole thing. */
|
2026-09-11 21:21:43 +03:00
|
|
|
|
export function PlanDiagram({
|
2026-09-11 03:26:56 +03:00
|
|
|
|
nodes,
|
|
|
|
|
|
edges,
|
|
|
|
|
|
statusOf,
|
|
|
|
|
|
glowId,
|
|
|
|
|
|
markerId,
|
2026-09-11 21:21:43 +03:00
|
|
|
|
viewBoxOverride,
|
2026-09-14 06:22:26 +03:00
|
|
|
|
pad: padUnits,
|
|
|
|
|
|
textCapScale,
|
2026-09-14 06:26:55 +03:00
|
|
|
|
naturalSize,
|
2026-09-14 06:55:10 +03:00
|
|
|
|
monochrome,
|
2026-09-14 11:23:28 +03:00
|
|
|
|
fill,
|
2026-09-11 03:26:56 +03:00
|
|
|
|
}: {
|
|
|
|
|
|
nodes: readonly LayoutNode[];
|
|
|
|
|
|
edges: ReadonlyArray<{ from: string; to: string }>;
|
|
|
|
|
|
statusOf: (id: string) => PlanNodeStatus;
|
|
|
|
|
|
glowId?: string | null;
|
|
|
|
|
|
markerId: string;
|
2026-09-11 21:21:43 +03:00
|
|
|
|
/** A windowed viewBox (the plan window) instead of the full extent. */
|
|
|
|
|
|
viewBoxOverride?: string;
|
2026-09-14 06:22:26 +03:00
|
|
|
|
/** Pads the viewBox by this many svg units so centered strokes and the
|
|
|
|
|
|
* glow halo never clip at the extent edges (full-extent renders only). */
|
|
|
|
|
|
pad?: number;
|
|
|
|
|
|
/** Caps the rendered width at extent × scale — the chat-font-size zoom
|
|
|
|
|
|
* ceiling for the in-chat block (fills the column, never past prose). */
|
|
|
|
|
|
textCapScale?: number;
|
2026-09-14 06:26:55 +03:00
|
|
|
|
/** Renders at the viewBox width in px (1 svg unit = 1 px at zoom 1) —
|
|
|
|
|
|
* the popup's transform math depends on it; w-full inside the popup's
|
|
|
|
|
|
* shrink-to-fit wrapper resolved to the popup width and broke fit. */
|
|
|
|
|
|
naturalSize?: boolean;
|
2026-09-14 06:55:10 +03:00
|
|
|
|
/** The in-chat block's mode: every node white (fill-none, white stroke
|
|
|
|
|
|
* and text, no glow) — statuses never show in the transcript (owner). */
|
|
|
|
|
|
monochrome?: boolean;
|
2026-09-14 11:23:28 +03:00
|
|
|
|
/** Meet-fits the diagram inside a fixed box (h-full w-full — the svg's
|
|
|
|
|
|
* default preserveAspectRatio centers + contains the viewBox): the
|
|
|
|
|
|
* dock band's marginalia mode, where the diagram must fit BOTH the
|
|
|
|
|
|
* flank's width and the band's height rather than run width-first. */
|
|
|
|
|
|
fill?: boolean;
|
2026-09-11 03:26:56 +03:00
|
|
|
|
}) {
|
|
|
|
|
|
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
|
2026-09-14 06:22:26 +03:00
|
|
|
|
// Pad only the full-extent render — the plan window's override viewBox is
|
|
|
|
|
|
// a deliberate viewport crop, and the popup keeps its own fit margins.
|
|
|
|
|
|
const pad = viewBoxOverride ? 0 : (padUnits ?? 0);
|
|
|
|
|
|
const w = Math.max(layout.width, 1);
|
|
|
|
|
|
const h = Math.max(layout.height, 1);
|
|
|
|
|
|
const vbW = w + 2 * pad;
|
|
|
|
|
|
const vbH = h + 2 * pad;
|
2026-09-11 03:26:56 +03:00
|
|
|
|
return (
|
|
|
|
|
|
<svg
|
2026-09-14 06:22:26 +03:00
|
|
|
|
viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`}
|
2026-09-14 11:23:28 +03:00
|
|
|
|
className={`block ${fill ? "h-full w-full" : `h-auto${naturalSize ? "" : " w-full"}`}${textCapScale ? " mx-auto" : ""}`}
|
2026-09-14 06:26:55 +03:00
|
|
|
|
style={
|
|
|
|
|
|
textCapScale
|
|
|
|
|
|
? { maxWidth: `${Math.round(vbW * textCapScale * 100) / 100}px` }
|
|
|
|
|
|
: naturalSize
|
|
|
|
|
|
? { width: `${vbW}px` }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
2026-09-11 03:26:56 +03:00
|
|
|
|
role="img"
|
|
|
|
|
|
aria-label="Plan diagram"
|
|
|
|
|
|
>
|
|
|
|
|
|
<defs>
|
|
|
|
|
|
<marker
|
|
|
|
|
|
id={markerId}
|
2026-09-14 06:59:38 +03:00
|
|
|
|
viewBox="0 0 50.2 104"
|
|
|
|
|
|
refX={27.2}
|
|
|
|
|
|
refY={52}
|
|
|
|
|
|
markerWidth={HEAD_W}
|
|
|
|
|
|
markerHeight={HEAD_H}
|
|
|
|
|
|
markerUnits="userSpaceOnUse"
|
|
|
|
|
|
orient="auto"
|
2026-09-11 03:26:56 +03:00
|
|
|
|
>
|
2026-09-14 06:59:38 +03:00
|
|
|
|
{/* The owner's arrowhead. The mouth (x 27.2) sits at refX so the
|
|
|
|
|
|
line ends AT the notch — the tip beyond it is solid fill. */}
|
|
|
|
|
|
<path d={ARROW_HEAD} className="fill-white" />
|
2026-09-11 03:26:56 +03:00
|
|
|
|
</marker>
|
2026-09-14 07:06:25 +03:00
|
|
|
|
{/* Same head, grayed — arrows into not-yet-visited nodes are
|
|
|
|
|
|
grayed out like the nodes themselves (owner, round 6). */}
|
|
|
|
|
|
<marker
|
|
|
|
|
|
id={`${markerId}-dim`}
|
|
|
|
|
|
viewBox="0 0 50.2 104"
|
|
|
|
|
|
refX={27.2}
|
|
|
|
|
|
refY={52}
|
|
|
|
|
|
markerWidth={HEAD_W}
|
|
|
|
|
|
markerHeight={HEAD_H}
|
|
|
|
|
|
markerUnits="userSpaceOnUse"
|
|
|
|
|
|
orient="auto"
|
|
|
|
|
|
>
|
|
|
|
|
|
<path d={ARROW_HEAD} className="fill-border" />
|
|
|
|
|
|
</marker>
|
2026-09-14 06:55:10 +03:00
|
|
|
|
{/* The current node's highlight: a faint yellow fill BLURRED so it
|
|
|
|
|
|
bleeds out over the node's edges (owner). Generous filter region
|
|
|
|
|
|
so the blur never clips at its own bounds. */}
|
|
|
|
|
|
<filter id={`${markerId}-glow`} x="-60%" y="-60%" width="220%" height="220%">
|
2026-09-14 07:06:25 +03:00
|
|
|
|
<feGaussianBlur stdDeviation={GLOW_BLUR} />
|
2026-09-14 06:55:10 +03:00
|
|
|
|
</filter>
|
2026-09-11 03:26:56 +03:00
|
|
|
|
</defs>
|
|
|
|
|
|
{edges.map((e) => {
|
2026-09-14 07:38:13 +03:00
|
|
|
|
// Dagre's route for this edge: border-intersected waypoints —
|
|
|
|
|
|
// the line leaves/enters nodes on whatever side dagre chose
|
|
|
|
|
|
// (mermaid's drawing, owner round 11). Unroutable edges
|
|
|
|
|
|
// (dangling endpoints) draw nothing.
|
|
|
|
|
|
const pts = layout.edgePts.get(`${e.from}->${e.to}`);
|
|
|
|
|
|
if (!pts) return null;
|
2026-09-14 08:04:31 +03:00
|
|
|
|
// The target's box — drives the docking gap + entry-angle clamp.
|
|
|
|
|
|
const tx = layout.x.get(e.to);
|
|
|
|
|
|
const ty = layout.y.get(e.to);
|
|
|
|
|
|
const target =
|
|
|
|
|
|
tx !== undefined && ty !== undefined
|
|
|
|
|
|
? { x: tx, y: ty, w: NODE_W, h: layout.nodeH.get(e.to) ?? NODE_H }
|
|
|
|
|
|
: undefined;
|
2026-09-14 07:06:25 +03:00
|
|
|
|
// An arrow into a not-yet-visited node is grayed out like the
|
|
|
|
|
|
// node (owner, round 6); the in-chat block stays all-white.
|
|
|
|
|
|
const dim = !monochrome && statusOf(e.to) === "upcoming";
|
2026-09-11 03:26:56 +03:00
|
|
|
|
return (
|
|
|
|
|
|
<path
|
|
|
|
|
|
key={`${e.from}->${e.to}`}
|
2026-09-14 08:04:31 +03:00
|
|
|
|
d={edgePathD(pts, target)}
|
2026-09-14 07:06:25 +03:00
|
|
|
|
className={dim ? "fill-none stroke-border" : "fill-none stroke-white"}
|
2026-09-11 03:26:56 +03:00
|
|
|
|
strokeWidth={1.5}
|
2026-09-14 07:06:25 +03:00
|
|
|
|
markerEnd={`url(#${markerId}${dim ? "-dim" : ""})`}
|
2026-09-11 03:26:56 +03:00
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
{nodes.map((n) => {
|
|
|
|
|
|
const status = statusOf(n.id);
|
2026-09-14 06:55:10 +03:00
|
|
|
|
const glow = n.id === glowId && !monochrome;
|
2026-09-11 03:26:56 +03:00
|
|
|
|
const lines = wrapTitle(n.title);
|
2026-09-14 07:12:38 +03:00
|
|
|
|
// The box grows with its wrapped content; text centers in it.
|
|
|
|
|
|
const h = layout.nodeH.get(n.id) ?? NODE_H;
|
|
|
|
|
|
const y0 = (h - lines.length * LINE_H) / 2 + 10;
|
2026-09-11 03:26:56 +03:00
|
|
|
|
return (
|
|
|
|
|
|
<g
|
|
|
|
|
|
key={n.id}
|
|
|
|
|
|
transform={`translate(${layout.x.get(n.id) ?? 0}, ${layout.y.get(n.id) ?? 0})`}
|
|
|
|
|
|
data-testid={`plan-node-${n.id}`}
|
|
|
|
|
|
data-status={status}
|
|
|
|
|
|
>
|
2026-09-14 07:34:01 +03:00
|
|
|
|
{/* Rectangular — no corner rounding (owner). */}
|
|
|
|
|
|
<rect
|
|
|
|
|
|
width={NODE_W}
|
|
|
|
|
|
height={h}
|
|
|
|
|
|
className={monochrome ? MONO_BOX : BOX[status]}
|
|
|
|
|
|
strokeWidth={1.5}
|
|
|
|
|
|
/>
|
2026-09-11 03:26:56 +03:00
|
|
|
|
{glow && (
|
2026-09-14 06:55:10 +03:00
|
|
|
|
// The current node's highlight: faint yellow, blurred so it
|
2026-09-14 07:06:25 +03:00
|
|
|
|
// bleeds over the edges — static, no pulse (owner, round 6).
|
2026-09-14 07:34:01 +03:00
|
|
|
|
// Paints ABOVE the box fill (owner, round 10): the round-9
|
|
|
|
|
|
// background fill no longer dims the glow inside the box,
|
|
|
|
|
|
// while edges behind the box still dim under that fill.
|
2026-09-11 03:26:56 +03:00
|
|
|
|
<rect
|
2026-09-14 07:06:25 +03:00
|
|
|
|
x={-GLOW_MARGIN}
|
|
|
|
|
|
y={-GLOW_MARGIN}
|
|
|
|
|
|
width={NODE_W + 2 * GLOW_MARGIN}
|
2026-09-14 07:12:38 +03:00
|
|
|
|
height={h + 2 * GLOW_MARGIN}
|
2026-09-14 07:06:25 +03:00
|
|
|
|
className="fill-yellow-400/25"
|
2026-09-14 06:55:10 +03:00
|
|
|
|
filter={`url(#${markerId}-glow)`}
|
2026-09-11 03:26:56 +03:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{lines.map((line, i) => (
|
2026-09-14 06:55:10 +03:00
|
|
|
|
<text
|
|
|
|
|
|
key={i}
|
|
|
|
|
|
x={12}
|
2026-09-14 07:12:38 +03:00
|
|
|
|
y={y0 + i * LINE_H}
|
2026-09-14 06:55:10 +03:00
|
|
|
|
className={monochrome ? MONO_TEXT : TEXT[status]}
|
|
|
|
|
|
>
|
2026-09-11 03:26:56 +03:00
|
|
|
|
{line}
|
|
|
|
|
|
</text>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</g>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── The transcript block ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A persisted plan-block part, rendered as carried (snapshot statuses,
|
2026-09-11 06:44:20 +03:00
|
|
|
|
* never re-derived): borderless and column-filling, it opens the live plan
|
|
|
|
|
|
* popup. Full-only by design since the 2026-09-11 pivot (the neighborhood
|
|
|
|
|
|
* fragments are retired — position derives live in the popup and the
|
|
|
|
|
|
* future DAG window).
|
2026-09-11 03:26:56 +03:00
|
|
|
|
*/
|
|
|
|
|
|
export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => void }) {
|
|
|
|
|
|
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
|
|
|
|
|
|
const statusMap = useMemo(() => new Map(data.nodes.map((n) => [n.id, n.status] as const)), [data]);
|
|
|
|
|
|
const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming";
|
|
|
|
|
|
return (
|
2026-09-11 06:44:20 +03:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
data-testid="plan-block-full"
|
|
|
|
|
|
onClick={onOpen}
|
|
|
|
|
|
title="Open the full plan"
|
|
|
|
|
|
className="block w-full text-left"
|
|
|
|
|
|
>
|
2026-09-14 06:22:26 +03:00
|
|
|
|
{/* pad keeps centered strokes + the glow halo inside the frame; the
|
|
|
|
|
|
cap keeps node text at or below chat prose size while still filling
|
|
|
|
|
|
the column when it can (owner, plan-graph refinement round 1). */}
|
|
|
|
|
|
<PlanDiagram
|
|
|
|
|
|
nodes={data.nodes}
|
|
|
|
|
|
edges={data.edges}
|
|
|
|
|
|
statusOf={statusOf}
|
|
|
|
|
|
markerId={markerId}
|
|
|
|
|
|
pad={GRAPH_PAD}
|
|
|
|
|
|
textCapScale={TEXT_CAP_SCALE}
|
2026-09-14 06:55:10 +03:00
|
|
|
|
monochrome
|
2026-09-14 06:22:26 +03:00
|
|
|
|
/>
|
2026-09-11 06:44:20 +03:00
|
|
|
|
</button>
|
2026-09-11 03:26:56 +03:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── The live plan popup ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** Minimal structural message slice (same trick as phases.ts). */
|
|
|
|
|
|
export interface PopupMessage {
|
|
|
|
|
|
parts: ReadonlyArray<{ type: string; state?: string; input?: unknown; output?: unknown }>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The latest plan tree in the transcript (latest tool-plan_ output). */
|
2026-09-11 21:21:43 +03:00
|
|
|
|
export function latestTree(messages: readonly PopupMessage[]): PlanTree | null {
|
2026-09-11 03:26:56 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 07:06:25 +03:00
|
|
|
|
/** The popup shows the glow, so its frame pads by the glow's reach —
|
|
|
|
|
|
* the in-chat block (monochrome, no glow) stays tight at GRAPH_PAD. */
|
|
|
|
|
|
const POPUP_PAD = GLOW_REACH;
|
2026-09-11 03:26:56 +03:00
|
|
|
|
|
2026-09-19 13:16:34 +03:00
|
|
|
|
// The pan/zoom math now lives in components/pan-zoom.ts (shared with the
|
|
|
|
|
|
// mermaid focus overlay since the session-overlay wave, 2026-09-19) — same
|
|
|
|
|
|
// fit-floor semantics: fully zoomed out IS the fit, panning locks at the
|
|
|
|
|
|
// floor, zoom ceiling 4× for reading node titles closely.
|
2026-09-11 03:26:56 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The full plan, live: latest tree + statuses derived at click time. Esc,
|
|
|
|
|
|
* backdrop click, or the X close it; drag pans, wheel zooms to the cursor,
|
|
|
|
|
|
* the zoom floor is the fit (panning locks at it — nothing to move).
|
2026-09-19 13:16:34 +03:00
|
|
|
|
*
|
|
|
|
|
|
* SESSION-SCOPED since the chrome-melt wave (2026-09-19): it mounts
|
|
|
|
|
|
* ABSOLUTE inside the chat page's session-area div (chat.tsx) — below the
|
|
|
|
|
|
* 60px drag bar, right of the sidebar — instead of a fixed inset-0 overlay.
|
|
|
|
|
|
* The top bar stays sharp and draggable, and the X can never land in drag
|
|
|
|
|
|
* territory. Surface 80% + blur (owner decision): the transcript ghosts
|
|
|
|
|
|
* through while the focus owns the session area.
|
2026-09-11 03:26:56 +03:00
|
|
|
|
*/
|
|
|
|
|
|
export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessage[]; onClose: () => void }) {
|
|
|
|
|
|
const tree = useMemo(() => latestTree(messages), [messages]);
|
|
|
|
|
|
const statuses = useMemo(
|
|
|
|
|
|
() => (tree ? derivePlanStatuses(tree, messages as Parameters<typeof derivePlanStatuses>[1]) : null),
|
|
|
|
|
|
[tree, messages],
|
|
|
|
|
|
);
|
|
|
|
|
|
const statusOf = (id: string): PlanNodeStatus => (statuses ? derivedNodeStatus(statuses, id) : "upcoming");
|
|
|
|
|
|
|
|
|
|
|
|
// Esc closes.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
|
if (e.key === "Escape") onClose();
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener("keydown", onKey);
|
|
|
|
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
|
|
|
|
}, [onClose]);
|
|
|
|
|
|
|
|
|
|
|
|
// ── pan/zoom viewport (harvested semantics: fit floor = the default) ──
|
|
|
|
|
|
const viewportRef = useRef<HTMLDivElement>(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();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const layout = useMemo(() => (tree ? blockLayout(tree.nodes, tree.edges) : null), [tree]);
|
2026-09-14 07:06:25 +03:00
|
|
|
|
// The fit box is the PADDED ink box — the diagram renders with POPUP_PAD
|
|
|
|
|
|
// (= the glow's reach) so the blurred glow never clips at the frame, and
|
|
|
|
|
|
// the fit must account for that padding.
|
2026-09-14 06:26:55 +03:00
|
|
|
|
const box = useMemo(
|
2026-09-14 07:06:25 +03:00
|
|
|
|
() => (layout ? { w: layout.width + 2 * POPUP_PAD, h: layout.height + 2 * POPUP_PAD } : { w: 0, h: 0 }),
|
2026-09-14 06:26:55 +03:00
|
|
|
|
[layout],
|
|
|
|
|
|
);
|
2026-09-11 03:26:56 +03:00
|
|
|
|
const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]);
|
|
|
|
|
|
|
2026-09-19 13:16:34 +03:00
|
|
|
|
const [view, setView] = useState<Viewport>({ x: 0, y: 0, zoom: 1 });
|
2026-09-11 03:26:56 +03:00
|
|
|
|
const userAdjustedRef = useRef(false);
|
|
|
|
|
|
const atFloor = view.zoom <= fitZoom + 1e-9;
|
2026-09-14 06:55:10 +03:00
|
|
|
|
// The transition stays off until the FIRST fit has been applied — the
|
|
|
|
|
|
// popup paints at the fit directly instead of flying in from the initial
|
|
|
|
|
|
// top-left zoom:1 state (owner, plan-graph round 4).
|
|
|
|
|
|
const [settled, setSettled] = useState(false);
|
2026-09-11 03:26:56 +03:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (atFloor) userAdjustedRef.current = false;
|
|
|
|
|
|
}, [atFloor]);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (userAdjustedRef.current || !size.w || !size.h) return;
|
|
|
|
|
|
setView(fitGraphView(box, size, fitZoom));
|
2026-09-14 07:12:38 +03:00
|
|
|
|
// Enable the transition only AFTER the fit has painted: flipping
|
|
|
|
|
|
// settled in the SAME commit lets the browser animate from the
|
|
|
|
|
|
// initial top-left zoom:1 state — the fly-in the owner keeps
|
|
|
|
|
|
// seeing (round 4's gate wasn't enough; round 7 fix).
|
|
|
|
|
|
const raf = requestAnimationFrame(() => setSettled(true));
|
|
|
|
|
|
return () => cancelAnimationFrame(raf);
|
2026-09-11 03:26:56 +03:00
|
|
|
|
}, [fitZoom, size, box]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const el = viewportRef.current;
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
const onWheel = (e: WheelEvent) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-09-14 06:30:05 +03:00
|
|
|
|
// Smooth zoom: the transform eases between states (CSS retargets from the
|
|
|
|
|
|
// current computed value, so chained wheel ticks stay fluid). Panning is
|
|
|
|
|
|
// per-frame — the transition is off while dragging or it rubber-bands.
|
|
|
|
|
|
const [panning, setPanning] = useState(false);
|
2026-09-11 03:26:56 +03:00
|
|
|
|
const dragRef = useRef<{ px: number; py: number; x: number; y: number; moved: boolean } | null>(null);
|
|
|
|
|
|
const startDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
|
|
|
|
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 };
|
2026-09-14 06:30:05 +03:00
|
|
|
|
setPanning(true);
|
2026-09-11 03:26:56 +03:00
|
|
|
|
e.currentTarget.classList.add("cursor-grabbing");
|
|
|
|
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
|
|
|
|
};
|
|
|
|
|
|
const moveDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
|
|
|
|
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<HTMLDivElement>) => {
|
|
|
|
|
|
const d = dragRef.current;
|
|
|
|
|
|
if (d) {
|
|
|
|
|
|
dragRef.current = null;
|
|
|
|
|
|
if (d.moved) userAdjustedRef.current = true;
|
|
|
|
|
|
}
|
2026-09-14 06:30:05 +03:00
|
|
|
|
setPanning(false);
|
2026-09-11 03:26:56 +03:00
|
|
|
|
e.currentTarget.classList.remove("cursor-grabbing");
|
|
|
|
|
|
};
|
|
|
|
|
|
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));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
data-testid="plan-popup"
|
2026-09-19 13:16:34 +03:00
|
|
|
|
// SESSION-SCOPED (absolute, not fixed): the chat page mounts this
|
|
|
|
|
|
// INSIDE the session-area div — below the drag bar, right of the
|
|
|
|
|
|
// sidebar. 80% + blur: the transcript ghosts through.
|
|
|
|
|
|
className="absolute inset-0 z-40 flex flex-col bg-background/80 p-4 backdrop-blur-sm"
|
2026-09-11 03:26:56 +03:00
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-label="The full plan"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
if (e.target === e.currentTarget) onClose();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="mb-2 flex items-center gap-2">
|
|
|
|
|
|
<span className="text-sm font-medium">The plan</span>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">live — statuses derive from your check outcomes</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
aria-label="Close"
|
|
|
|
|
|
data-testid="plan-popup-close"
|
|
|
|
|
|
className="ml-auto rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<XIcon className="size-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={viewportRef}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"relative min-h-0 flex-1 touch-none overflow-hidden rounded-xl border select-none",
|
|
|
|
|
|
atFloor ? "cursor-default" : "cursor-grab",
|
|
|
|
|
|
)}
|
|
|
|
|
|
onPointerDown={startDrag}
|
|
|
|
|
|
onPointerMove={moveDrag}
|
|
|
|
|
|
onPointerUp={endDrag}
|
|
|
|
|
|
onPointerCancel={endDrag}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tree ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute top-0 left-0 origin-top-left"
|
2026-09-14 06:30:05 +03:00
|
|
|
|
style={{
|
|
|
|
|
|
transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})`,
|
2026-09-19 13:16:34 +03:00
|
|
|
|
// Hidden until the first fit has APPLIED — the fit runs in an
|
|
|
|
|
|
// effect (after paint), so an opacity-gated chart can never flash
|
|
|
|
|
|
// at the initial top-left zoom:1 state (same fix as the mermaid
|
|
|
|
|
|
// overlay; transition-gating alone didn't hide the fly-in the
|
|
|
|
|
|
// owner kept seeing in rounds 4–7).
|
|
|
|
|
|
opacity: settled ? 1 : 0,
|
2026-09-14 06:55:10 +03:00
|
|
|
|
transition:
|
2026-09-19 13:16:34 +03:00
|
|
|
|
panning || !settled
|
|
|
|
|
|
? "none"
|
|
|
|
|
|
: "transform 180ms ease-out, opacity 160ms ease-out",
|
2026-09-14 06:30:05 +03:00
|
|
|
|
}}
|
2026-09-11 03:26:56 +03:00
|
|
|
|
>
|
|
|
|
|
|
<PlanDiagram
|
|
|
|
|
|
nodes={tree.nodes}
|
|
|
|
|
|
edges={tree.edges}
|
|
|
|
|
|
statusOf={statusOf}
|
|
|
|
|
|
glowId={statuses?.current ?? null}
|
|
|
|
|
|
markerId={markerId}
|
2026-09-14 07:06:25 +03:00
|
|
|
|
pad={POPUP_PAD}
|
2026-09-14 06:26:55 +03:00
|
|
|
|
naturalSize
|
2026-09-11 03:26:56 +03:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
2026-09-17 13:43:26 +03:00
|
|
|
|
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
|
2026-09-11 03:26:56 +03:00
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title="Zoom out"
|
|
|
|
|
|
aria-label="Zoom out"
|
|
|
|
|
|
onClick={() => stepZoom(1 / 1.25)}
|
|
|
|
|
|
className="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"
|
|
|
|
|
|
>
|
|
|
|
|
|
<MinusIcon className="size-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title="Fit plan to screen"
|
|
|
|
|
|
aria-label="Fit plan to screen"
|
|
|
|
|
|
onClick={fitView}
|
|
|
|
|
|
data-testid="plan-popup-fit"
|
|
|
|
|
|
className="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"
|
|
|
|
|
|
>
|
|
|
|
|
|
<MaximizeIcon className="size-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title="Zoom in"
|
|
|
|
|
|
aria-label="Zoom in"
|
|
|
|
|
|
onClick={() => stepZoom(1.25)}
|
|
|
|
|
|
className="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"
|
|
|
|
|
|
>
|
|
|
|
|
|
<PlusIcon className="size-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
|
|
|
|
No plan in this session yet.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|