Merge feat/ui-polish: wave 4 UI — activity dock, stats bar, plan blocks + popup, sidebar retirement
This commit is contained in:
+8
-5
@@ -2,7 +2,7 @@ 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 = {
|
||||
title: "Learning Harness",
|
||||
@@ -14,10 +14,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<html lang="en" className="dark">
|
||||
<body className="min-h-dvh antialiased">
|
||||
<SidebarProvider>
|
||||
<TreePanelModeProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</TreePanelModeProvider>
|
||||
</SidebarProvider>
|
||||
<ActivityProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
{/* The reserved bottom-right corner of every view — renders
|
||||
nothing while no background process runs. */}
|
||||
<ActivityDock />
|
||||
</ActivityProvider>
|
||||
</SidebarProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon, XIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
||||
import { CONTEXT_LIMITS, jobCost } from "@/src/core";
|
||||
import { formatTokens, formatUsd } from "@/components/usage-format";
|
||||
|
||||
/**
|
||||
* THE ACTIVITY DOCK (wave 4, vision observability): the bottom-right corner
|
||||
* of EVERY view, one blob per running BACKGROUND process — session sub-agents
|
||||
* (research/demo/visual) and global extraction runs. Teaching turns are
|
||||
* deliberately NOT here: the dock exists to give background processes the
|
||||
* observability the learner can't otherwise see; an in-flight teaching turn
|
||||
* is fully visible where it happens (user decision, 2026-09).
|
||||
*
|
||||
* Data: ONE poll of GET /api/activity (jobs across ALL chats, title-joined,
|
||||
* oldest-first + extraction runs), folded client-side with core/prices.ts.
|
||||
* Cost is INFERRED (jobCost); the context line is the LAST model call's
|
||||
* input+output against the model's limit (JobUsage.lastStep — pending
|
||||
* contract) — never a cumulative sum, which would be the wrong number.
|
||||
* Absent data renders "—", never zero (the doctrine).
|
||||
*
|
||||
* Quiet/ambient by design: no entry animations, stable keys, updates land
|
||||
* in place — polling never flickers. Idle (no jobs, no extractions) renders
|
||||
* NOTHING. Narrow viewports collapse to a count badge → click opens the
|
||||
* drawer. Per blob: kind icon, elapsed, cost, context, activity line, and an
|
||||
* explicit END button (POST /api/activity/abort — the wave-4 backend is
|
||||
* live): 404/409 mean the process is already gone (refresh, no error);
|
||||
* real failures degrade to an inline "Abort failed", never a
|
||||
* dead control).
|
||||
*/
|
||||
|
||||
// ─── Wire shapes (client-safe mirrors of server/observability.ts) ────────────
|
||||
|
||||
/** JobUsage extended by the pending lastStep contract (the context line). */
|
||||
interface FeedUsage extends NonNullable<JobRecord["usage"]> {
|
||||
lastStep?: CallUsage;
|
||||
}
|
||||
|
||||
export interface FeedJob extends JobRecord {
|
||||
title: string;
|
||||
usage?: FeedUsage;
|
||||
}
|
||||
|
||||
export interface FeedExtraction {
|
||||
chatId: string;
|
||||
title: string;
|
||||
phase: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface ActivityFeed {
|
||||
jobs: FeedJob[];
|
||||
extractions: FeedExtraction[];
|
||||
}
|
||||
|
||||
const EMPTY_FEED: ActivityFeed = { jobs: [], extractions: [] };
|
||||
|
||||
// ─── The one poll (composition root: the root layout) ────────────────────────
|
||||
|
||||
const ActivityContext = createContext<{ feed: ActivityFeed; refresh: () => void }>({
|
||||
feed: EMPTY_FEED,
|
||||
refresh: () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Polls /api/activity for the whole app shell. Adaptive interval: fast while
|
||||
* anything runs, slow when idle, ~4× slower on hidden tabs. A failed fetch
|
||||
* keeps the last feed (the dock never flickers to empty on a blip).
|
||||
*/
|
||||
export function ActivityProvider({ children }: { children: React.ReactNode }) {
|
||||
const [feed, setFeed] = useState<ActivityFeed>(EMPTY_FEED);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const refresh = useCallback(() => setNonce((n) => n + 1), []);
|
||||
const hasRunning = feed.jobs.length > 0 || feed.extractions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/activity");
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as Partial<ActivityFeed>;
|
||||
if (alive)
|
||||
setFeed({ jobs: data.jobs ?? [], extractions: data.extractions ?? [] });
|
||||
}
|
||||
} catch {
|
||||
// transport blip: keep the last feed, try again
|
||||
}
|
||||
if (!alive) return;
|
||||
const base = hasRunning ? 2500 : 10000;
|
||||
timer = setTimeout(poll, document.hidden ? base * 4 : base);
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
alive = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [hasRunning, nonce]);
|
||||
|
||||
return <ActivityContext.Provider value={{ feed, refresh }}>{children}</ActivityContext.Provider>;
|
||||
}
|
||||
|
||||
// ─── Pure presentation helpers (exported for tests) ──────────────────────────
|
||||
|
||||
/** One dock blob: the unified view of a job or an extraction run. */
|
||||
export interface ActivityBlob {
|
||||
/** job.id, or the extraction's chatId (one run per chat). */
|
||||
id: string;
|
||||
process: "job" | "extraction";
|
||||
/** Sub-agent kind for jobs (extraction blobs have none). */
|
||||
jobKind?: JobKind;
|
||||
/** Extraction phase (extracting/auditing/merging); jobs carry none. */
|
||||
phase?: string;
|
||||
title: string;
|
||||
chatId: string;
|
||||
/** ms epoch — blobs order oldest-first and elapsed counts from it. */
|
||||
startedAt: number;
|
||||
/** Inferred USD cost (jobCost) — null renders "—". */
|
||||
cost: number | null;
|
||||
/** Last model call's footprint vs the model's limit — null renders "—". */
|
||||
context: { used: number; limit: number } | null;
|
||||
/** Latest human-readable activity line (""). */
|
||||
activity: string;
|
||||
/** Sub-agent model id, when usage is reported. */
|
||||
model?: string;
|
||||
/** Model calls made so far, when usage is reported. */
|
||||
steps?: number;
|
||||
}
|
||||
|
||||
/** Merge the feed into one oldest-first blob list (the stable blob order). */
|
||||
export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
|
||||
const blobs: ActivityBlob[] = feed.jobs.map((job) => {
|
||||
const usage = job.usage;
|
||||
let context: ActivityBlob["context"] = null;
|
||||
const lastStep = usage?.lastStep;
|
||||
const limit = usage ? CONTEXT_LIMITS[usage.model] : undefined;
|
||||
if (usage && lastStep && limit !== undefined && lastStep.inputTokens !== undefined) {
|
||||
context = { used: lastStep.inputTokens + (lastStep.outputTokens ?? 0), limit };
|
||||
}
|
||||
return {
|
||||
id: job.id,
|
||||
process: "job",
|
||||
jobKind: job.kind,
|
||||
title: job.title,
|
||||
chatId: job.chatId,
|
||||
startedAt: Date.parse(job.createdAt) || 0,
|
||||
cost: jobCost(job),
|
||||
context,
|
||||
activity: job.activity ?? "",
|
||||
model: usage?.model,
|
||||
steps: usage?.steps,
|
||||
};
|
||||
});
|
||||
for (const run of feed.extractions) {
|
||||
blobs.push({
|
||||
id: run.chatId,
|
||||
process: "extraction",
|
||||
phase: run.phase,
|
||||
title: run.title,
|
||||
chatId: run.chatId,
|
||||
startedAt: run.startedAt,
|
||||
cost: null, // extraction runs don't ride the money channel (v1)
|
||||
context: null,
|
||||
activity: "",
|
||||
});
|
||||
}
|
||||
blobs.sort((a, b) => a.startedAt - b.startedAt);
|
||||
return blobs;
|
||||
}
|
||||
|
||||
/** Elapsed as a compact ticking string: 45s / 3m 04s / 1h 12m. */
|
||||
export function formatElapsed(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
||||
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
// ─── The dock ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Narrow viewports (< sm) collapse the dock to a count badge → drawer. */
|
||||
function useNarrow(): boolean {
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 639px)");
|
||||
const apply = () => setNarrow(mq.matches);
|
||||
apply();
|
||||
mq.addEventListener("change", apply);
|
||||
return () => mq.removeEventListener("change", apply);
|
||||
}, []);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
/** A 1s heartbeat while the dock is on screen — elapsed lines tick. */
|
||||
function useNow(active: boolean): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [active]);
|
||||
return now;
|
||||
}
|
||||
|
||||
const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
|
||||
research: SearchIcon,
|
||||
demo: PlayIcon,
|
||||
visual: PenToolIcon,
|
||||
};
|
||||
|
||||
/** Abort states per blob id: pending (fired) / failed (backend absent). */
|
||||
type AbortState = "pending" | "failed";
|
||||
|
||||
export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) {
|
||||
const ctx = useContext(ActivityContext);
|
||||
const feed = feedProp ?? ctx.feed;
|
||||
const refresh = ctx.refresh;
|
||||
const blobs = useMemo(() => toBlobs(feed), [feed]);
|
||||
const narrow = useNarrow();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [openBlob, setOpenBlob] = useState<string | null>(null); // detail popover
|
||||
const [abortStates, setAbortStates] = useState<Record<string, AbortState>>({});
|
||||
const now = useNow(blobs.length > 0);
|
||||
// Refs for stable abort handlers (no re-created closures per tick).
|
||||
const abortRef = useRef({ blobs, refresh, setAbortStates });
|
||||
abortRef.current = { blobs, refresh, setAbortStates };
|
||||
|
||||
const totalCost = useMemo(() => {
|
||||
const priced = blobs.map((b) => b.cost).filter((c): c is number => c !== null);
|
||||
return priced.length === 0 ? null : priced.reduce((a, c) => a + c, 0);
|
||||
}, [blobs]);
|
||||
|
||||
const abort = useCallback(async (blob: ActivityBlob) => {
|
||||
const { setAbortStates: set } = abortRef.current;
|
||||
set((s) => ({ ...s, [blob.id]: "pending" }));
|
||||
try {
|
||||
const res = await fetch("/api/activity/abort", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
kind: blob.process === "job" ? "job" : "extraction",
|
||||
id: blob.id,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// 404 not_found / 409 not_running: the process is already gone —
|
||||
// the desired end state. Refresh and clear; nothing to report.
|
||||
if (res.status === 404 || res.status === 409) {
|
||||
abortRef.current.refresh();
|
||||
set((s) => {
|
||||
const { [blob.id]: _drop, ...rest } = s;
|
||||
return rest;
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(String(res.status));
|
||||
}
|
||||
abortRef.current.refresh();
|
||||
} catch {
|
||||
set((s) => ({ ...s, [blob.id]: "failed" }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (blobs.length === 0) return null; // idle: the dock is invisible
|
||||
|
||||
const count = blobs.length;
|
||||
// Narrow: a quiet count badge; the drawer opens on click.
|
||||
if (narrow && !drawerOpen) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="activity-badge"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
title={`${count} background process${count === 1 ? "" : "es"} running`}
|
||||
aria-label={`${count} background processes running — show activity dock`}
|
||||
className="fixed bottom-3 right-3 z-30 flex items-center gap-1.5 rounded-full border bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur"
|
||||
>
|
||||
<ActivityIcon className="size-3.5 text-primary" />
|
||||
<span className="tabular-nums">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="activity-dock"
|
||||
className={cn(
|
||||
"fixed bottom-3 right-3 z-30 flex w-72 flex-col gap-1 rounded-xl border bg-card/95 p-2 shadow-lg backdrop-blur",
|
||||
narrow && "max-h-[60dvh] overflow-y-auto",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-1 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<ActivityIcon className="size-3.5" />
|
||||
Background activity
|
||||
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
|
||||
{totalCost === null ? "—" : formatUsd(totalCost)}
|
||||
</span>
|
||||
{narrow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
aria-label="Hide activity dock"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{blobs.map((blob) => (
|
||||
<ActivityBlobRow
|
||||
key={blob.id}
|
||||
blob={blob}
|
||||
now={now}
|
||||
detailOpen={openBlob === blob.id}
|
||||
onToggleDetail={() => setOpenBlob((id) => (id === blob.id ? null : blob.id))}
|
||||
abortState={abortStates[blob.id]}
|
||||
onAbort={() => void abort(blob)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One blob: icon + elapsed + cost on the face; click → detail popover. */
|
||||
function ActivityBlobRow({
|
||||
blob,
|
||||
now,
|
||||
detailOpen,
|
||||
onToggleDetail,
|
||||
abortState,
|
||||
onAbort,
|
||||
}: {
|
||||
blob: ActivityBlob;
|
||||
now: number;
|
||||
detailOpen: boolean;
|
||||
onToggleDetail: () => void;
|
||||
abortState: AbortState | undefined;
|
||||
onAbort: () => void;
|
||||
}) {
|
||||
const Icon = blob.process === "extraction" ? WaypointsIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon;
|
||||
const label =
|
||||
blob.process === "extraction"
|
||||
? `Extraction — ${blob.phase ?? "running"}`
|
||||
: blob.jobKind === "research"
|
||||
? "Researcher"
|
||||
: blob.jobKind === "demo"
|
||||
? "Demo builder"
|
||||
: blob.jobKind === "visual"
|
||||
? "Figure maker"
|
||||
: "Sub-agent";
|
||||
return (
|
||||
<div
|
||||
data-testid={`activity-blob-${blob.process}`}
|
||||
className="relative rounded-lg bg-background/40 px-2 py-1.5 text-xs transition-colors hover:bg-background/70"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleDetail}
|
||||
aria-expanded={detailOpen}
|
||||
className="flex w-full items-center gap-2 pr-7 text-left"
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-card-foreground">{blob.title}</span>
|
||||
{blob.activity ? (
|
||||
<span className="block truncate text-muted-foreground" title={blob.activity}>
|
||||
{blob.activity}
|
||||
</span>
|
||||
) : (
|
||||
<span className="block truncate text-muted-foreground/80">{label}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
|
||||
<span data-testid="activity-blob-cost">{blob.cost === null ? "—" : formatUsd(blob.cost)}</span>
|
||||
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt)}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/* The explicit END always on the blob's face (SCOPE) — the popover
|
||||
is for DETAIL, not the control. A failed abort locks the button and
|
||||
says why; a succeeded one just makes the blob vanish on refresh. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="activity-abort"
|
||||
onClick={onAbort}
|
||||
disabled={abortState !== undefined}
|
||||
title={abortState === "failed" ? "Abort failed — try again" : "End this background process"}
|
||||
aria-label={`End ${label} in ${blob.title}`}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
|
||||
>
|
||||
<SquareIcon className="size-3" />
|
||||
</button>
|
||||
|
||||
{detailOpen && (
|
||||
<div
|
||||
data-testid="activity-blob-detail"
|
||||
className="absolute bottom-full right-0 z-40 mb-1.5 w-72 rounded-xl border bg-card p-3 text-xs shadow-xl"
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<Icon className="size-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="ml-auto tabular-nums text-muted-foreground">
|
||||
{formatElapsed(now - blob.startedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-muted-foreground">
|
||||
<div className="truncate text-card-foreground" title={blob.title}>
|
||||
{blob.title}
|
||||
</div>
|
||||
{blob.activity && <div className="break-words">{blob.activity}</div>}
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Cost</span>
|
||||
<span className="tabular-nums text-card-foreground">
|
||||
{blob.cost === null ? "—" : formatUsd(blob.cost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Context</span>
|
||||
<span className="tabular-nums text-card-foreground" data-testid="activity-blob-context">
|
||||
{blob.context === null
|
||||
? "—"
|
||||
: `${formatTokens(blob.context.used)} / ${formatTokens(blob.context.limit)}`}
|
||||
</span>
|
||||
</div>
|
||||
{blob.model && (
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Model</span>
|
||||
<span className="truncate text-card-foreground">{blob.model}</span>
|
||||
</div>
|
||||
)}
|
||||
{blob.steps !== undefined && (
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Steps</span>
|
||||
<span className="tabular-nums text-card-foreground">{blob.steps}</span>
|
||||
</div>
|
||||
)}
|
||||
<a href={`/chat/${blob.chatId}`} className="text-primary hover:underline">
|
||||
Open session →
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-2 text-muted-foreground/80">
|
||||
{abortState === "failed" && "Abort failed — try again"}
|
||||
{abortState === "pending" && "Stopping…"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+55
-11
@@ -20,11 +20,12 @@ import { artifactRenderers } from "@/components/artifacts";
|
||||
import { SubagentSteerCard } from "@/components/artifacts/steer-card";
|
||||
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 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 } from "@/src/core";
|
||||
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";
|
||||
|
||||
@@ -160,7 +161,8 @@ export function Chat({
|
||||
const syncFromServer = useCallback(async () => {
|
||||
const res = await fetch(`/api/chats/${chatId}`);
|
||||
if (!res.ok) return;
|
||||
const record = (await res.json()) as { messages?: TeacherUIMessage[] };
|
||||
const record = (await res.json()) as { messages?: TeacherUIMessage[]; turns?: TurnUsage[] };
|
||||
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||
if (!Array.isArray(record.messages)) return;
|
||||
setMessages(record.messages);
|
||||
// setMessages does NOT fire sendAutomaticallyWhen (the SDK only checks
|
||||
@@ -175,6 +177,36 @@ export function Chat({
|
||||
}
|
||||
}, [chatId, setMessages, sendMessage, status]);
|
||||
|
||||
// The live plan popup (wave 4): opened by any transcript plan block.
|
||||
const [planOpen, setPlanOpen] = useState(false);
|
||||
|
||||
// ── Session usage (wave-4 stats bar) ──
|
||||
// The money channel: the record's turns log, folded client-side by
|
||||
// core/prices.ts. Seeded once on mount (the page doesn't carry turns),
|
||||
// refreshed when the local stream settles (each turn appends to the log
|
||||
// server-side), and kept fresh by syncFromServer whenever it already
|
||||
// fetches the record. No turns yet → the bar renders "—" everywhere.
|
||||
const [turns, setTurns] = useState<TurnUsage[]>([]);
|
||||
const syncTurns = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/chats/${chatId}`);
|
||||
if (!res.ok) return;
|
||||
const record = (await res.json()) as { turns?: TurnUsage[] };
|
||||
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||
} catch {
|
||||
// transient blip: stats stay on the last known figures
|
||||
}
|
||||
}, [chatId]);
|
||||
useEffect(() => {
|
||||
void syncTurns();
|
||||
}, [syncTurns]);
|
||||
// After the learner's own turn ends the log has a fresh entry — refetch.
|
||||
const prevStatusRef = useRef(status);
|
||||
useEffect(() => {
|
||||
if (prevStatusRef.current !== "ready" && status === "ready") void syncTurns();
|
||||
prevStatusRef.current = status;
|
||||
}, [status, syncTurns]);
|
||||
|
||||
// ── Central job observability (contracts.ts "THE ASYNC PROTOCOL" §4) ──
|
||||
// ONE poll of /api/jobs drives every job surface (transcript dispatch
|
||||
// cards, the sub-agents panel) and re-syncs the transcript exactly once
|
||||
@@ -360,6 +392,16 @@ export function Chat({
|
||||
);
|
||||
case "step-start":
|
||||
return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
|
||||
case "data-plan-block":
|
||||
// The plan in the chat (wave 4): a frozen snapshot
|
||||
// block — rendered as carried, never re-derived.
|
||||
return (
|
||||
<PlanBlock
|
||||
key={pi}
|
||||
data={part.data as PlanBlockData}
|
||||
onOpen={() => setPlanOpen(true)}
|
||||
/>
|
||||
);
|
||||
case "tool-quiz":
|
||||
case "tool-ask_user_question": {
|
||||
// Pending/streaming questions live in the dock, not the
|
||||
@@ -400,6 +442,7 @@ export function Chat({
|
||||
pending={pending}
|
||||
requiresReveal={requiresReveal}
|
||||
phase={phases.at(-1) ?? "probe"}
|
||||
turns={turns}
|
||||
composer={
|
||||
<Composer
|
||||
// Stop applies only to a LOCAL stream: a server-side turn has no
|
||||
@@ -426,14 +469,8 @@ export function Chat({
|
||||
</Conversation>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div className="hidden shrink-0 flex-col xl:flex">
|
||||
<SubAgentsPanel jobs={jobs} />
|
||||
<TreePanel messages={messages} />
|
||||
</div>
|
||||
</div>
|
||||
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
|
||||
</JobsContext.Provider>
|
||||
</ChatActionsContext.Provider>
|
||||
);
|
||||
@@ -453,6 +490,7 @@ function DockOverlay({
|
||||
pending,
|
||||
requiresReveal,
|
||||
phase,
|
||||
turns,
|
||||
composer,
|
||||
onSubmitAnswer,
|
||||
}: {
|
||||
@@ -461,6 +499,8 @@ function DockOverlay({
|
||||
requiresReveal: boolean;
|
||||
/** Session phase at the pending question — scales the thinking field. */
|
||||
phase: Phase3;
|
||||
/** The session's usage log — the stats bar beneath the composer folds it. */
|
||||
turns: TurnUsage[];
|
||||
composer: React.ReactNode;
|
||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||
}) {
|
||||
@@ -623,6 +663,10 @@ function DockOverlay({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* The stats row: beneath the composer/question shell, not
|
||||
flush to the screen bottom — static height, outside the
|
||||
morphing shell so it never joins a height animation. */}
|
||||
<StatsBar turns={turns} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
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
|
||||
* scales to the full column width (viewBox = layout extent, so the
|
||||
* leftmost/rightmost nodes hug the edges), natural height, no cap.
|
||||
* - kind "neighborhood": the fragment in its own small container, labeled
|
||||
* as a FRAGMENT (clearly not the whole plan), the current node glowing.
|
||||
* - Clicking either opens the full plan in a full-screen popup overlay —
|
||||
* 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).
|
||||
*
|
||||
* Hand-built SVG everywhere (user decision): a deterministic layered
|
||||
* layout fills exactly and stays SSR-testable — no force simulation, no
|
||||
* coupling to the graph stream's sigma internals. Pan/zoom in the popup
|
||||
* 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;
|
||||
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;
|
||||
}
|
||||
|
||||
/** Topological order + longest-path depth (Kahn, authoring tie-break). */
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Layered positions: depth = row (top-down), authoring order within the
|
||||
* row, each row centered on the widest one. */
|
||||
export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) {
|
||||
const { order, depth } = topoFor(nodes, edges);
|
||||
const layers: LayoutNode[][] = [];
|
||||
for (const n of order) {
|
||||
const d = depth.get(n.id) ?? 0;
|
||||
if (!layers[d]) layers[d] = [];
|
||||
layers[d].push(n);
|
||||
}
|
||||
const rowWidth = (l: LayoutNode[]) => 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<string, number>();
|
||||
const y = new Map<string, number>();
|
||||
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 + Math.max(0, layers.length - 1) * GAP_Y;
|
||||
return { x, y, width: maxWidth, height };
|
||||
}
|
||||
|
||||
/** Greedy word wrap to ≤ maxLines lines of ≤ maxChars; overflow ends in an
|
||||
* ellipsis, a single oversized word is hard-clamped. Pure. */
|
||||
export function wrapTitle(title: string, maxChars = 20, 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Status styling (the retired panel's color language, unchanged) ──────────
|
||||
|
||||
const BOX: Record<PlanNodeStatus, string> = {
|
||||
done: "fill-phase-teach/10 stroke-phase-teach/50",
|
||||
current: "fill-primary/10 stroke-primary",
|
||||
upcoming: "fill-card stroke-border",
|
||||
};
|
||||
const TEXT: Record<PlanNodeStatus, string> = {
|
||||
done: "fill-muted-foreground text-xs",
|
||||
current: "fill-foreground text-xs font-medium",
|
||||
upcoming: "fill-muted-foreground/80 text-xs",
|
||||
};
|
||||
|
||||
/** The diagram itself: positioned nodes + bezier edges. Pure-ish render —
|
||||
* every input is data, so SSR render tests pin the whole thing. */
|
||||
function PlanDiagram({
|
||||
nodes,
|
||||
edges,
|
||||
statusOf,
|
||||
glowId,
|
||||
markerId,
|
||||
}: {
|
||||
nodes: readonly LayoutNode[];
|
||||
edges: ReadonlyArray<{ from: string; to: string }>;
|
||||
statusOf: (id: string) => PlanNodeStatus;
|
||||
glowId?: string | null;
|
||||
markerId: string;
|
||||
}) {
|
||||
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${Math.max(layout.width, 1)} ${Math.max(layout.height, 1)}`}
|
||||
className="block h-auto w-full"
|
||||
role="img"
|
||||
aria-label="Plan diagram"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id={markerId}
|
||||
viewBox="0 0 8 8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M0,1 L7,4 L0,7 z" className="fill-muted-foreground/50" />
|
||||
</marker>
|
||||
</defs>
|
||||
{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 (
|
||||
<path
|
||||
key={`${e.from}->${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(#${markerId})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{nodes.map((n) => {
|
||||
const status = statusOf(n.id);
|
||||
const glow = n.id === glowId;
|
||||
const lines = wrapTitle(n.title);
|
||||
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}
|
||||
>
|
||||
{glow && (
|
||||
// The glow target: a soft primary halo breathing around the box.
|
||||
<rect
|
||||
x={-4}
|
||||
y={-4}
|
||||
width={NODE_W + 8}
|
||||
height={NODE_H + 8}
|
||||
rx={12}
|
||||
className="fill-none stroke-primary/70 animate-pulse"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
<rect width={NODE_W} height={NODE_H} rx={10} className={BOX[status]} strokeWidth={1.5} />
|
||||
{lines.map((line, i) => (
|
||||
<text key={i} x={12} y={lines.length === 1 ? 27 : 18 + i * 13} className={TEXT[status]}>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── The transcript block ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A persisted plan-block part, rendered as carried (snapshot statuses,
|
||||
* never re-derived). Full = borderless and column-filling; neighborhood =
|
||||
* a labeled fragment container with the current node glowing. Either
|
||||
* opens the live plan popup.
|
||||
*/
|
||||
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";
|
||||
|
||||
if (data.kind === "full") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="plan-block-full"
|
||||
onClick={onOpen}
|
||||
title="Open the full plan"
|
||||
className="block w-full text-left"
|
||||
>
|
||||
<PlanDiagram nodes={data.nodes} edges={data.edges} statusOf={statusOf} markerId={markerId} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-testid="plan-block-neighborhood" className="rounded-xl border bg-card/60 p-2.5">
|
||||
<div className="mb-1.5 px-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Plan fragment
|
||||
</div>
|
||||
<button type="button" onClick={onOpen} title="Open the full plan" className="block w-full text-left">
|
||||
<PlanDiagram
|
||||
nodes={data.nodes}
|
||||
edges={data.edges}
|
||||
statusOf={statusOf}
|
||||
glowId={data.current}
|
||||
markerId={markerId}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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). */
|
||||
function latestTree(messages: readonly PopupMessage[]): 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;
|
||||
}
|
||||
|
||||
/** Zoom bounds: never below fit (fully zoomed out IS the fit), never above 2×. */
|
||||
const ZOOM_MAX = 2;
|
||||
const ZOOM_FLOOR = 0.15;
|
||||
const FIT_PAD = 16;
|
||||
const FIT_ZOOM_MAX = 1.5;
|
||||
|
||||
interface GraphView {
|
||||
zoom: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 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).
|
||||
*/
|
||||
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]);
|
||||
const box = useMemo(() => (layout ? { w: layout.width, h: layout.height } : { w: 0, h: 0 }), [layout]);
|
||||
const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]);
|
||||
|
||||
const [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 });
|
||||
const userAdjustedRef = useRef(false);
|
||||
const atFloor = view.zoom <= fitZoom + 1e-9;
|
||||
|
||||
useEffect(() => {
|
||||
if (atFloor) userAdjustedRef.current = false;
|
||||
}, [atFloor]);
|
||||
useEffect(() => {
|
||||
if (userAdjustedRef.current || !size.w || !size.h) return;
|
||||
setView(fitGraphView(box, size, fitZoom));
|
||||
}, [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]);
|
||||
|
||||
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 };
|
||||
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;
|
||||
}
|
||||
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"
|
||||
className="fixed inset-0 z-50 flex flex-col bg-background/95 p-4 backdrop-blur-sm"
|
||||
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"
|
||||
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})` }}
|
||||
>
|
||||
<PlanDiagram
|
||||
nodes={tree.nodes}
|
||||
edges={tree.edges}
|
||||
statusOf={statusOf}
|
||||
glowId={statuses?.current ?? null}
|
||||
markerId={markerId}
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
||||
<span className="text-[10px] text-muted-foreground/60">Drag to pan · scroll to zoom</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { contextWindow, sessionCost, turnCost, type TurnUsage } from "@/src/core";
|
||||
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
|
||||
|
||||
/**
|
||||
* The below-composer stats bar (wave 4 observability): the session's
|
||||
* money/context footprint as one quiet row beneath the composer —
|
||||
* LEFT-aligned: context window + session cost; RIGHT-aligned: model +
|
||||
* thinking level. Clicking the left side opens the per-turn detail popover.
|
||||
*
|
||||
* All folds come from core/prices.ts (sessionCost, contextWindow, turnCost)
|
||||
* — no cost math lives here. Absent data renders "—", never zero (the
|
||||
* doctrine): no turns yet → every figure is "—"; turns the price table
|
||||
* can't price count as unpriced, shown in the popover, never folded into a
|
||||
* fake $0. The RIGHT side is a PLACEHOLDER for the model + thinking-level
|
||||
* control (later work, with its contract changes): display-only for now,
|
||||
* model read from the last turn's usage.
|
||||
*/
|
||||
export function StatsBar({ turns }: { turns: TurnUsage[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ctx = contextWindow({ turns });
|
||||
const { cost, unpriced } = sessionCost({ turns });
|
||||
// Honest cost: show dollars only when at least one turn priced;
|
||||
// otherwise "—" (never a fake $0.0000).
|
||||
const pricedCount = turns.length - unpriced;
|
||||
const costLabel = pricedCount > 0 ? formatUsd(cost) : "—";
|
||||
const lastModel = turns.at(-1)?.model;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="stats-bar"
|
||||
className="relative flex items-center justify-between gap-3 px-3 pt-1 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
title="Session stats — per-turn detail"
|
||||
data-testid="stats-summary"
|
||||
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
|
||||
>
|
||||
<span data-testid="stats-context">
|
||||
{ctx ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : "—"}
|
||||
</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span data-testid="stats-cost">{costLabel}</span>
|
||||
</button>
|
||||
{/* Model + thinking level — a placeholder for the coming control
|
||||
(contract changes land with it); display-only today. */}
|
||||
<div className="flex items-center gap-1.5" data-testid="stats-model-chip">
|
||||
<span data-testid="stats-model">{lastModel ? prettyModel(lastModel) : "—"}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span data-testid="stats-thinking" title="Thinking level control lands soon">
|
||||
Thinking —
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
data-testid="stats-detail"
|
||||
className="absolute bottom-full left-0 z-40 mb-1.5 w-72 rounded-xl border bg-card p-3 text-xs shadow-xl"
|
||||
>
|
||||
<div className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Session stats
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-muted-foreground">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Context (last call)</span>
|
||||
<span className="tabular-nums text-card-foreground">
|
||||
{ctx ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Total cost</span>
|
||||
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
||||
</div>
|
||||
{unpriced > 0 && (
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Unpriced turns</span>
|
||||
<span className="tabular-nums text-card-foreground">{unpriced}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{turns.length > 0 && (
|
||||
<>
|
||||
<div className="mb-1 mt-2.5 border-t pt-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Turns
|
||||
</div>
|
||||
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
|
||||
{turns.map((turn, i) => {
|
||||
const c = turnCost(turn);
|
||||
return (
|
||||
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-muted-foreground">
|
||||
#{i + 1} · {prettyModel(turn.model)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
||||
<span className="text-muted-foreground">{turn.steps.length} steps</span>
|
||||
<span>{c === null ? "—" : formatUsd(c)}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<JobKind, { label: string; Icon: typeof SearchIcon }> = {
|
||||
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 (
|
||||
<div
|
||||
data-testid="sub-agents-rail"
|
||||
className="flex w-10 shrink-0 flex-col items-center gap-3 border-b border-l border-border/60 bg-card/30 py-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
title="Show sub-agents"
|
||||
aria-label="Show sub-agents"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<BotIcon className="size-4" />
|
||||
</button>
|
||||
<span
|
||||
className="text-[10px] tracking-widest text-muted-foreground/70 [writing-mode:vertical-rl]"
|
||||
title={`${count} sub-agent${count === 1 ? "" : "s"} running`}
|
||||
>
|
||||
{count} running
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <SubAgentsRail count={running.length} onExpand={() => go("list")} />;
|
||||
return (
|
||||
<aside
|
||||
data-testid="sub-agents-panel"
|
||||
className="flex w-full shrink-0 flex-col border-b border-l border-border/60 bg-card/30 px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<BotIcon className="size-3.5" />
|
||||
Sub-agents
|
||||
</div>
|
||||
<ul className="mt-2 flex flex-col gap-2">
|
||||
{running.map((job) => (
|
||||
<li
|
||||
key={job.id}
|
||||
className="rounded-lg border bg-card px-2.5 py-2 text-card-foreground"
|
||||
data-testid="sub-agent-card"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-medium">
|
||||
{(() => {
|
||||
const { label, Icon } = KIND_META[job.kind] ?? KIND_META.research;
|
||||
return <Icon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
})()}
|
||||
<span className="min-w-0 flex-1 truncate">{KIND_META[job.kind]?.label ?? job.kind}</span>
|
||||
<span
|
||||
className="inline-block size-1.5 animate-pulse rounded-full bg-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" data-testid="sub-agent-elapsed">
|
||||
{elapsedOf(job)}
|
||||
</span>
|
||||
</div>
|
||||
{job.activity && (
|
||||
<div
|
||||
className="mt-1 truncate text-xs text-muted-foreground"
|
||||
data-testid="sub-agent-activity"
|
||||
title={job.activity}
|
||||
>
|
||||
{job.activity}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -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<PanelMode, string> = {
|
||||
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<string, number>;
|
||||
}
|
||||
|
||||
/** 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<string, number>(
|
||||
tree.nodes.map((n) => [n.id, 0] as const),
|
||||
);
|
||||
const dependents = new Map<string, string[]>();
|
||||
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<string, number>();
|
||||
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<PanelMode>("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 <TreePanelModeContext.Provider value={{ mode, go }}>{children}</TreePanelModeContext.Provider>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<aside
|
||||
data-testid="tree-panel"
|
||||
className={cn(
|
||||
"sidebar-motion hidden min-h-0 flex-1 shrink-0 overflow-hidden border-l border-border/60 bg-card/30 duration-200 xl:flex",
|
||||
PANEL_WIDTH[mode],
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex h-full flex-col", PANEL_WIDTH[mode])}>
|
||||
{mode === "rail" ? (
|
||||
<TreePanelRail
|
||||
doneCount={tree.doneNodeIds.length}
|
||||
totalCount={tree.nodes.length}
|
||||
onExpand={() => go("list")}
|
||||
/>
|
||||
) : mode === "graph" ? (
|
||||
<TreeGraphPanel tree={tree} onShowList={() => go("list")} onCollapse={() => go("rail")} />
|
||||
) : (
|
||||
<TreePanelBody tree={tree} onShowGraph={() => go("graph")} onCollapse={() => go("rail")} />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div
|
||||
data-testid="tree-panel-rail"
|
||||
className="flex h-full flex-col items-center gap-3 py-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
title="Show plan"
|
||||
aria-label="Show plan"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ListTreeIcon className="size-4" />
|
||||
</button>
|
||||
<span
|
||||
className="text-[10px] tracking-widest text-muted-foreground/70 [writing-mode:vertical-rl]"
|
||||
title={`${doneCount} of ${totalCount} nodes done`}
|
||||
>
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-2 px-3 py-2.5">
|
||||
<ListTreeIcon className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Plan
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto text-[11px] tabular-nums text-muted-foreground"
|
||||
title={`${doneCount} of ${totalCount} nodes done`}
|
||||
>
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowGraph}
|
||||
title="Show plan graph"
|
||||
aria-label="Show plan graph"
|
||||
className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<NetworkIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
title="Hide plan"
|
||||
aria-label="Hide plan"
|
||||
className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ChevronsRightIcon className="size-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Keyed by tree.version: every mutation (contracts.ts) bumps it, so a
|
||||
set_current/mark_done visibly re-animates the list mid-session. */}
|
||||
<nav
|
||||
key={tree.version}
|
||||
className="dock-variant min-h-0 flex-1 overflow-y-auto px-2 pb-4"
|
||||
aria-label="Plan tree"
|
||||
>
|
||||
<ol className="flex flex-col">
|
||||
{order.map((node) => {
|
||||
const status: PlanNodeStatus = planNodeStatus(tree, node.id);
|
||||
return (
|
||||
<li
|
||||
key={node.id}
|
||||
data-testid={`tree-node-${node.id}`}
|
||||
data-status={status}
|
||||
data-next={node.id === nextId ? "true" : undefined}
|
||||
title={node.summary || undefined}
|
||||
style={{ marginLeft: (depth.get(node.id) ?? 0) * 12 }}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-lg px-2 py-1.5 text-sm",
|
||||
status === "current" && "bg-primary/10 text-foreground",
|
||||
status === "done" && "text-muted-foreground",
|
||||
status === "upcoming" && "text-muted-foreground/70",
|
||||
)}
|
||||
>
|
||||
<StatusMark status={status} next={node.id === nextId} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
status === "current" && "font-medium",
|
||||
)}
|
||||
>
|
||||
{node.title}
|
||||
</span>
|
||||
{node.id === nextId && (
|
||||
<span className="shrink-0 text-[10px] font-medium uppercase tracking-wide text-primary/80">
|
||||
next
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{/* The current position also surfaces its summary — the "where
|
||||
we are, what this covers" answer without hovering. */}
|
||||
{status === "current" && node.summary && (
|
||||
<span className="mt-0.5 block text-xs leading-snug text-muted-foreground">
|
||||
{node.summary}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
);
|
||||
case "current":
|
||||
return (
|
||||
<span
|
||||
className="mt-1.5 size-2 shrink-0 rounded-full bg-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
case "upcoming":
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1.5 size-2 shrink-0 rounded-full border",
|
||||
next ? "border-primary/60" : "border-muted-foreground/40",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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<string, number>;
|
||||
y: Map<string, number>;
|
||||
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<string, number>): 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<string, number>();
|
||||
const y = new Map<string, number>();
|
||||
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<PlanNodeStatus, string> = {
|
||||
done: "fill-phase-teach/10 stroke-phase-teach/50",
|
||||
current: "fill-primary/10 stroke-primary",
|
||||
upcoming: "fill-card stroke-border",
|
||||
};
|
||||
const GRAPH_TEXT: Record<PlanNodeStatus, string> = {
|
||||
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<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();
|
||||
}, []);
|
||||
|
||||
// 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<GraphView>({ 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<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 };
|
||||
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;
|
||||
}
|
||||
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 (
|
||||
<div
|
||||
ref={viewportRef}
|
||||
data-testid="plan-graph"
|
||||
className={cn(
|
||||
"relative min-h-0 flex-1 touch-none overflow-hidden select-none",
|
||||
atFloor ? "cursor-default" : "cursor-grab",
|
||||
)}
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 left-0 origin-top-left"
|
||||
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})` }}
|
||||
>
|
||||
<svg width={layout.width} height={layout.height} role="img" aria-label="Plan tree graph">
|
||||
<defs>
|
||||
<marker
|
||||
id="lh-plan-arrow"
|
||||
viewBox="0 0 8 8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M0,1 L7,4 L0,7 z" className="fill-muted-foreground/50" />
|
||||
</marker>
|
||||
</defs>
|
||||
{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 (
|
||||
<path
|
||||
key={`${e.from}->${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 (
|
||||
<g
|
||||
key={n.id}
|
||||
transform={`translate(${layout.x.get(n.id) ?? 0}, ${layout.y.get(n.id) ?? 0})`}
|
||||
data-testid={`graph-node-${n.id}`}
|
||||
data-status={status}
|
||||
data-next={n.id === nextId ? "true" : undefined}
|
||||
>
|
||||
<title>{n.summary ? `${n.title} — ${n.summary}` : n.title}</title>
|
||||
<rect width={NODE_W} height={NODE_H} rx={10} className={GRAPH_BOX[status]} strokeWidth={1.5} />
|
||||
{status === "current" && (
|
||||
<circle cx={NODE_W - 12} cy={12} r={3} className="fill-primary" aria-hidden />
|
||||
)}
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={12}
|
||||
y={lines.length === 1 ? 29 : 19 + i * 14}
|
||||
className={GRAPH_TEXT[status]}
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Quiet affordances: the hint and the zoom controls. */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
||||
<span className="text-[10px] text-muted-foreground/60">Drag to pan · scroll to zoom</span>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" title="Zoom out" aria-label="Zoom out" onClick={() => stepZoom(1 / 1.25)} className={GRAPH_ZOOM_BTN}>
|
||||
<MinusIcon className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" title="Fit plan to panel" aria-label="Fit plan to panel" onClick={fitView} data-testid="graph-fit" className={GRAPH_ZOOM_BTN}>
|
||||
<MaximizeIcon className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" title="Zoom in" aria-label="Zoom in" onClick={() => stepZoom(1.25)} className={GRAPH_ZOOM_BTN}>
|
||||
<PlusIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div data-testid="tree-graph-panel" className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-2 px-3 py-2.5">
|
||||
<NetworkIcon className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Plan</span>
|
||||
<span
|
||||
className="ml-auto text-[11px] tabular-nums text-muted-foreground"
|
||||
title={`${doneCount} of ${totalCount} nodes done`}
|
||||
>
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowList}
|
||||
title="Show as list"
|
||||
aria-label="Show as list"
|
||||
className={GRAPH_HEADER_BTN}
|
||||
>
|
||||
<ListTreeIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
title="Hide plan"
|
||||
aria-label="Hide plan"
|
||||
className={GRAPH_HEADER_BTN}
|
||||
>
|
||||
<ChevronsRightIcon className="size-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
<TreeGraph tree={tree} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Money/token presentation helpers (wave-4 observability UI) — shared by the
|
||||
* activity dock and the stats bar. Pure; exported for tests.
|
||||
*/
|
||||
|
||||
/** USD for cent-scale money: >= $1 keeps two decimals, smaller keeps four. */
|
||||
export function formatUsd(c: number): string {
|
||||
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
|
||||
}
|
||||
|
||||
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
|
||||
export function formatTokens(n: number): string {
|
||||
if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
|
||||
if (n >= 1e3) return `${+(n / 1e3).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* "z-ai/glm-5.3-flash" → "Glm 5.3 Flash" — vendor prefix dropped, dashes to
|
||||
* spaces, capitalized; a trailing all-digit segment (OpenRouter's date
|
||||
* suffix, "qwen3.8-max-0902") is dropped. Quiet-chip friendly.
|
||||
*/
|
||||
export function prettyModel(id: string): string {
|
||||
const short = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id;
|
||||
const parts = short.split("-");
|
||||
const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts;
|
||||
return trimmed.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { DemoToolInput, ResearchToolInput, SubagentArtifactData, VisualToolInput } from "./contracts.ts";
|
||||
import type { DemoToolInput, PlanBlockData, ResearchToolInput, SubagentArtifactData, VisualToolInput } from "./contracts.ts";
|
||||
import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question-core.ts";
|
||||
|
||||
/**
|
||||
@@ -24,6 +24,8 @@ export type TeacherUITools = {
|
||||
|
||||
export type TeacherUIDataParts = {
|
||||
"subagent-artifact": SubagentArtifactData;
|
||||
/** Plan blocks (wave 4): frozen plan snapshots rendered in the chat flow. */
|
||||
"plan-block": PlanBlockData;
|
||||
};
|
||||
|
||||
export type TeacherUIMessage = UIMessage<unknown, TeacherUIDataParts, TeacherUITools>;
|
||||
|
||||
@@ -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";
|
||||
@@ -393,25 +392,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(<SubAgentsPanel jobs={[runningJob("research"), runningJob("visual")]} />);
|
||||
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(<SubAgentsPanel jobs={[done as JobRecord]} />), "");
|
||||
assert.equal(renderToStaticMarkup(<SubAgentsPanel jobs={[]} />), "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSvg", () => {
|
||||
it("strips script blocks, event handlers, and javascript: urls, keeps geometry", () => {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ActivityDock, formatElapsed, toBlobs, type ActivityFeed } from "../components/activity-dock.tsx";
|
||||
import { formatTokens, formatUsd } from "../components/usage-format.ts";
|
||||
|
||||
// The dock's pure layer: folds, formatting, and blob order — then the SSR
|
||||
// render (blobs, "—" doctrine, the abort affordance, the narrow badge).
|
||||
|
||||
const NOW = Date.parse("2026-09-09T12:00:00Z");
|
||||
|
||||
/** A full CallUsage (its fields are undefined-valued, not optional). */
|
||||
function step(inputTokens: number, outputTokens: number) {
|
||||
return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
|
||||
}
|
||||
|
||||
function job(over: Partial<ActivityFeed["jobs"][number]> = {}): ActivityFeed["jobs"][number] {
|
||||
return {
|
||||
id: "job-1",
|
||||
kind: "research",
|
||||
chatId: "c1",
|
||||
toolCallId: "t1",
|
||||
status: "running",
|
||||
activity: 'web_search: "sigma.js v3"',
|
||||
createdAt: new Date(NOW - 45_000).toISOString(),
|
||||
updatedAt: new Date(NOW - 1000).toISOString(),
|
||||
title: "Limits lesson",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("activity dock pure layer", () => {
|
||||
it("merges jobs + extractions oldest-first (the stable blob order)", () => {
|
||||
const feed: ActivityFeed = {
|
||||
jobs: [job({ id: "new", createdAt: new Date(NOW - 10_000).toISOString() })],
|
||||
extractions: [{ chatId: "cx", title: "Old lesson", phase: "auditing", startedAt: NOW - 100_000 }],
|
||||
};
|
||||
const blobs = toBlobs(feed);
|
||||
assert.equal(blobs.length, 2);
|
||||
const first = blobs[0]!;
|
||||
assert.equal(first.id, "cx"); // extraction started first
|
||||
assert.equal(first.process, "extraction");
|
||||
assert.equal(first.phase, "auditing");
|
||||
assert.equal(blobs[1]!.id, "new");
|
||||
});
|
||||
|
||||
it("folds cost with jobCost; absent usage is null, never zero", () => {
|
||||
const priced = job({
|
||||
usage: {
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
inputTokens: 10_000,
|
||||
outputTokens: 1000,
|
||||
cacheReadTokens: 5000,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
steps: 2,
|
||||
},
|
||||
});
|
||||
const blob = toBlobs({ jobs: [priced], extractions: [] })[0]!;
|
||||
// (10000-5000)/1e6*0.15 + 5000/1e6*0.03 + 1000/1e6*0.5 = 0.0014
|
||||
assert.ok(blob.cost !== null && blob.cost > 0.0013 && blob.cost < 0.0015);
|
||||
const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!;
|
||||
assert.equal(absent.cost, null);
|
||||
});
|
||||
|
||||
it("context line = LAST step's tokens vs the model limit, never a cumulative sum", () => {
|
||||
const base = {
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
inputTokens: 400_000, // running total (much bigger — must NOT feed the line)
|
||||
outputTokens: 8_000,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
steps: 5,
|
||||
};
|
||||
const withLast = job({
|
||||
usage: { ...base, lastStep: step(90_000, 2_000) },
|
||||
});
|
||||
const blob = toBlobs({ jobs: [withLast], extractions: [] })[0]!;
|
||||
assert.equal(blob.context?.used, 92_000);
|
||||
assert.equal(blob.context?.limit, 1310720);
|
||||
// No lastStep yet (capture pending) → "—", even though usage exists.
|
||||
const stripped = toBlobs({ jobs: [job({ usage: { ...base } })], extractions: [] })[0]!;
|
||||
assert.equal(stripped.context, null);
|
||||
// Unknown model → no limit → "—".
|
||||
const unknownModel = toBlobs({
|
||||
jobs: [job({ usage: { ...base, model: "nope/unknown", lastStep: step(5, 5) } })],
|
||||
extractions: [],
|
||||
})[0]!;
|
||||
assert.equal(unknownModel.context, null);
|
||||
});
|
||||
|
||||
it("extraction blobs carry no money/context (—, the doctrine)", () => {
|
||||
const blob = toBlobs({
|
||||
jobs: [],
|
||||
extractions: [{ chatId: "cx", title: "T", phase: "extracting", startedAt: NOW }],
|
||||
})[0]!;
|
||||
assert.equal(blob.cost, null);
|
||||
assert.equal(blob.context, null);
|
||||
});
|
||||
|
||||
it("formatElapsed ticks compactly", () => {
|
||||
assert.equal(formatElapsed(45_000), "45s");
|
||||
assert.equal(formatElapsed(184_000), "3m 04s");
|
||||
assert.equal(formatElapsed(4_320_000), "1h 12m");
|
||||
});
|
||||
|
||||
it("formatUsd keeps four decimals under $1, two above", () => {
|
||||
assert.equal(formatUsd(0.0014), "$0.0014");
|
||||
assert.equal(formatUsd(1.5), "$1.50");
|
||||
});
|
||||
|
||||
it("formatTokens trims (never 38.0k)", () => {
|
||||
assert.equal(formatTokens(38_000), "38k");
|
||||
assert.equal(formatTokens(1_234_567), "1.2M");
|
||||
assert.equal(formatTokens(950), "950");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity dock render", () => {
|
||||
it("renders NOTHING when idle (no jobs, no extractions)", () => {
|
||||
assert.equal(renderToStaticMarkup(<ActivityDock feed={{ jobs: [], extractions: [] }} />), "");
|
||||
});
|
||||
|
||||
it("renders one blob per running process with cost, elapsed, and the abort affordance", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ActivityDock
|
||||
feed={{
|
||||
jobs: [job({ id: "j1", kind: "demo", activity: "building sandbox…", title: "Chain-rule demo" })],
|
||||
extractions: [{ chatId: "cx", title: "Old lesson", phase: "merging", startedAt: NOW - 60_000 }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
assert.match(html, /data-testid="activity-dock"/);
|
||||
assert.match(html, /data-testid="activity-blob-job"/);
|
||||
assert.match(html, /data-testid="activity-blob-extraction"/);
|
||||
assert.match(html, /Chain-rule demo/);
|
||||
assert.match(html, /building sandbox…/);
|
||||
assert.match(html, /Extraction — merging/);
|
||||
// Unpriced job → "—", never $0.0000 (doctrine); both blobs show it.
|
||||
assert.match(html, /activity-blob-cost[^>]*>—</);
|
||||
assert.match(html, /data-testid="activity-abort"/, "END on the blob face, popover-independent");
|
||||
assert.match(html, /\bEnd\b/, "the explicit END button");
|
||||
assert.match(html, /Background activity/);
|
||||
// Total cost: nothing priced → "—" in the header.
|
||||
assert.match(html, /activity-total-cost[^>]*>—</);
|
||||
});
|
||||
|
||||
it("shows total running cost when jobs are priced", () => {
|
||||
const usage = {
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
inputTokens: 10_000,
|
||||
outputTokens: 1000,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
steps: 1,
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
<ActivityDock
|
||||
feed={{
|
||||
jobs: [
|
||||
job({ id: "a", usage }),
|
||||
job({ id: "b", usage, createdAt: new Date(NOW - 20_000).toISOString() }),
|
||||
],
|
||||
extractions: [],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
// two identical priced jobs → 2 × $0.002 = $0.0040 total
|
||||
assert.match(html, /activity-total-cost[^>]*>\$0\.0040</);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
PlanBlock,
|
||||
PlanPopup,
|
||||
blockLayout,
|
||||
topoFor,
|
||||
wrapTitle,
|
||||
GAP_X,
|
||||
NODE_H,
|
||||
NODE_W,
|
||||
type PopupMessage,
|
||||
} from "../components/plan-block.tsx";
|
||||
import type { PlanBlockData, PlanTree } from "../src/core/contracts.ts";
|
||||
|
||||
// Plan blocks: the pure layered layout, the frozen-snapshot rendering
|
||||
// (full = borderless + column-filling, neighborhood = labeled fragment
|
||||
// with the glowing current), and the popup's LIVE derivation.
|
||||
|
||||
const CHAIN: PlanTree = {
|
||||
nodes: [
|
||||
{ id: "a", title: "Alpha node" },
|
||||
{ id: "b", title: "Beta node" },
|
||||
{ id: "c", title: "Gamma node" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "c" },
|
||||
],
|
||||
currentNodeId: null,
|
||||
doneNodeIds: [],
|
||||
version: 1,
|
||||
};
|
||||
|
||||
describe("plan block layout (pure)", () => {
|
||||
it("layers a chain top-down, one node per depth row", () => {
|
||||
const { order, depth } = topoFor(CHAIN.nodes, CHAIN.edges);
|
||||
assert.deepEqual(order.map((n) => n.id), ["a", "b", "c"]);
|
||||
assert.equal(depth.get("c"), 2);
|
||||
const layout = blockLayout(CHAIN.nodes, CHAIN.edges);
|
||||
assert.equal(layout.width, NODE_W); // widest row is the single-node rows
|
||||
assert.equal(layout.height, 3 * NODE_H + 2 * 48);
|
||||
});
|
||||
|
||||
it("centers same-depth nodes over the widest row; tolerates dangling edges", () => {
|
||||
const nodes = [
|
||||
{ id: "r", title: "Root" },
|
||||
{ id: "x", title: "Left" },
|
||||
{ id: "y", title: "Right" },
|
||||
];
|
||||
const layout = blockLayout(nodes, [
|
||||
{ from: "r", to: "x" },
|
||||
{ from: "r", to: "y" },
|
||||
{ from: "ghost", to: "r" }, // dangling: ignored defensively
|
||||
]);
|
||||
assert.equal(layout.width, 2 * NODE_W + GAP_X);
|
||||
// Root centered over its two children: (2*W+GAP - W)/2
|
||||
assert.equal(layout.x.get("r"), (2 * NODE_W + GAP_X - NODE_W) / 2);
|
||||
assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X);
|
||||
});
|
||||
|
||||
it("wrapTitle wraps greedily and ellipsizes overflow", () => {
|
||||
assert.deepEqual(wrapTitle("Chain rule intuition"), ["Chain rule intuition"]); // exactly 20 chars — fits one line
|
||||
assert.deepEqual(wrapTitle("Chain rule intuition and fluency"), ["Chain rule intuition", "and fluency"]);
|
||||
assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), ["supercalifragilisti…", "word"]); // oversized word hard-clamped, overflow dropped
|
||||
assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly maxChars — fits
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan block render", () => {
|
||||
it("full block: borderless, column-filling, statuses AS CARRIED (never re-derived)", () => {
|
||||
const data: PlanBlockData = {
|
||||
kind: "full",
|
||||
at: "2026-09-09T12:00:00Z",
|
||||
current: null,
|
||||
nodes: [
|
||||
{ id: "a", title: "Alpha node", status: "done" },
|
||||
{ id: "b", title: "Beta node", status: "upcoming" },
|
||||
],
|
||||
edges: [{ from: "a", to: "b" }],
|
||||
};
|
||||
const html = renderToStaticMarkup(<PlanBlock data={data} onOpen={() => {}} />);
|
||||
assert.match(html, /data-testid="plan-block-full"/);
|
||||
// Borderless: the wrapper carries no border/rounded chrome.
|
||||
assert.ok(!/data-testid="plan-block-full"[^>]*border/.test(html), "no wrapping chrome");
|
||||
// Fills the column: the svg is w-full with the layout extent as viewBox
|
||||
// (leftmost/rightmost nodes hug the edges), height natural.
|
||||
assert.match(html, /<svg[^>]*class="block h-auto w-full"/);
|
||||
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 natural height, width = one node column.
|
||||
assert.match(html, /viewBox="0 0 160 136"/);
|
||||
// Snapshot statuses render as carried.
|
||||
assert.match(html, /data-testid="plan-node-a" data-status="done"/);
|
||||
assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/);
|
||||
assert.match(html, /Open the full plan/);
|
||||
});
|
||||
|
||||
it("neighborhood block: own small container, labeled fragment, glowing current", () => {
|
||||
const data: PlanBlockData = {
|
||||
kind: "neighborhood",
|
||||
at: "2026-09-09T12:00:00Z",
|
||||
current: "mid",
|
||||
nodes: [
|
||||
{ id: "up", title: "Upstream", status: "done" },
|
||||
{ id: "mid", title: "Current node", status: "current" },
|
||||
{ id: "down", title: "Downstream", status: "upcoming" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "up", to: "mid" },
|
||||
],
|
||||
};
|
||||
const html = renderToStaticMarkup(<PlanBlock data={data} onOpen={() => {}} />);
|
||||
assert.match(html, /data-testid="plan-block-neighborhood"/);
|
||||
assert.match(html, /class="[^"]*border/, "the fragment has its own container");
|
||||
assert.match(html, /Plan fragment/);
|
||||
// The glow: a pulsing primary halo around the current node.
|
||||
assert.match(html, /stroke-primary\/70 animate-pulse/);
|
||||
assert.match(html, /data-testid="plan-node-mid" data-status="current"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan popup (live derivation)", () => {
|
||||
function messages(): PopupMessage[] {
|
||||
return [
|
||||
{
|
||||
parts: [
|
||||
{
|
||||
type: "tool-plan_init",
|
||||
state: "output-available",
|
||||
output: { ok: true, tree: CHAIN },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
parts: [
|
||||
{
|
||||
type: "tool-quiz",
|
||||
state: "output-available",
|
||||
input: { nodeId: "a" },
|
||||
output: { correct: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
parts: [
|
||||
{
|
||||
type: "tool-quiz",
|
||||
state: "input-available",
|
||||
input: { nodeId: "b" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
it("derives statuses at click time: passed → done, live check → current (glowing)", () => {
|
||||
const html = renderToStaticMarkup(<PlanPopup messages={messages()} onClose={() => {}} />);
|
||||
assert.match(html, /data-testid="plan-popup"/);
|
||||
assert.match(html, /data-testid="plan-node-a" data-status="done"/);
|
||||
assert.match(html, /data-testid="plan-node-b" data-status="current"/);
|
||||
assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/);
|
||||
// The current node is the glow target.
|
||||
assert.match(html, /stroke-primary\/70 animate-pulse/);
|
||||
assert.match(html, /live — statuses derive from your check outcomes/);
|
||||
});
|
||||
|
||||
it("renders the empty state when the session has no plan", () => {
|
||||
const html = renderToStaticMarkup(<PlanPopup messages={[{ parts: [] }]} onClose={() => {}} />);
|
||||
assert.match(html, /No plan in this session yet\./);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { StatsBar } from "../components/stats-bar.tsx";
|
||||
import { prettyModel } from "../components/usage-format.ts";
|
||||
import type { TurnUsage } from "../src/core/contracts.ts";
|
||||
|
||||
// The stats bar: folds only (no cost math in the component), the "—"
|
||||
// doctrine for absent data, honest cost (never a fake $0), and the
|
||||
// model/thinking placeholder chip for the coming control.
|
||||
|
||||
function step(inputTokens: number, outputTokens: number, over: Record<string, number | undefined> = {}) {
|
||||
return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined, ...over };
|
||||
}
|
||||
|
||||
function turn(over: Partial<TurnUsage> = {}): TurnUsage {
|
||||
return {
|
||||
messageId: "m1",
|
||||
at: "2026-09-09T12:00:00Z",
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
steps: [step(10_000, 1_000)],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("stats bar", () => {
|
||||
it("renders — everywhere when the record has no turns yet (doctrine)", () => {
|
||||
const html = renderToStaticMarkup(<StatsBar turns={[]} />);
|
||||
assert.match(html, /data-testid="stats-bar"/);
|
||||
assert.match(html, /data-testid="stats-context">—</);
|
||||
assert.match(html, /data-testid="stats-cost">—</);
|
||||
assert.match(html, /data-testid="stats-model">—</);
|
||||
assert.match(html, /Thinking —</);
|
||||
});
|
||||
|
||||
it("folds context + cost from the turns log (core/prices, no local math)", () => {
|
||||
const html = renderToStaticMarkup(<StatsBar turns={[turn(), turn()]} />);
|
||||
// last turn's last call: 10k in + 1k out = 11k; limit 1,310,720 → "1.3M"
|
||||
assert.match(html, /data-testid="stats-context">11k \/ 1\.3M</);
|
||||
// each turn: 10k fresh input ×0.15 + 1k output ×0.5 = $0.002 → two = $0.0040
|
||||
assert.match(html, /data-testid="stats-cost">\$0\.0040</);
|
||||
// model chip: pretty name from the last turn's model id
|
||||
assert.match(html, /data-testid="stats-model">Glm 5\.3 Flash</);
|
||||
});
|
||||
|
||||
it("shows — for cost when every turn is unpriceable, never a fake $0", () => {
|
||||
const html = renderToStaticMarkup(<StatsBar turns={[turn({ model: "nope/unknown" })]} />);
|
||||
assert.match(html, /data-testid="stats-cost">—</);
|
||||
// unknown model also kills the context limit
|
||||
assert.match(html, /data-testid="stats-context">—</);
|
||||
});
|
||||
|
||||
it("prettyModel strips vendor + date suffix, capitalizes", () => {
|
||||
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "Glm 5.3 Flash");
|
||||
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "Qwen3.8 Max");
|
||||
});
|
||||
});
|
||||
@@ -1,498 +0,0 @@
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).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(<TreePanel messages={[]} />), "");
|
||||
const probing = asMessages([
|
||||
{ parts: [{ type: "tool-quiz", state: "output-available", toolCallId: "q1", output: {} }] },
|
||||
]);
|
||||
assert.equal(renderToStaticMarkup(<TreePanel messages={probing} />), "");
|
||||
|
||||
const failedInit = asMessages([{ parts: [planPart("plan_init", { ...chainTree, nodes: [] }, false)] }]);
|
||||
assert.equal(renderToStaticMarkup(<TreePanel messages={failedInit} />), "", "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(<TreePanel messages={messages} />);
|
||||
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(<TreePanel messages={messages} />);
|
||||
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(<TreePanel messages={messages} />);
|
||||
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(
|
||||
<TreePanel messages={asMessages([{ parts: [planPart("plan_mark_done", tree)] }])} />,
|
||||
);
|
||||
// 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(
|
||||
<TreePanel messages={asMessages([{ parts: [planPart("plan_init", init)] }])} />,
|
||||
);
|
||||
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(
|
||||
<TreePanel
|
||||
messages={asMessages([
|
||||
{ parts: [planPart("plan_init", init), planPart("plan_add_node", progressed)] },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
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(<TreePanelRail doneCount={1} totalCount={3} onExpand={() => {}} />);
|
||||
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(<TreeGraph tree={chainTree} />);
|
||||
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(<TreeGraph tree={chainTree} />);
|
||||
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(<TreeGraph tree={tree} />);
|
||||
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(<TreeGraph tree={tree} />);
|
||||
const texts = html.match(/<text[^>]*>([^<]*)<\/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(<TreeGraph tree={chainTree} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user