Merge feat/ui-polish: three-phase session model, plan graph, layout restructure
chat.tsx woven by hand: UI's session-surface layout (transcript column + right sidebar as full-height siblings, h-10 header with PhaseChip) wrapped in sub-agents' JobsContext.Provider; the message map carries both the steer ternary (steers bypass the Message wrapper — the model-facing glue text never renders) and UI's PhaseDivider emission (steers are verified phase-boundary no-ops). Right sidebar = sub-agents' fleet panel column (SubAgentsPanel above TreePanel, xl-only) carried onto UI's skeleton. contracts.ts auto-merged (ChatSummary.phase/pendingQuestion + ChatListItem, additive above the async-protocol section). Smoke-verified on the merged tree: steer renders bare as its artifact badge with no learner bubble, no rendered glue text, and no spurious phase divider at the steer boundary.
This commit is contained in:
@@ -2,7 +2,6 @@ import type { ComponentType } from "react";
|
||||
import type { ArtifactPartProps } from "@/src/core";
|
||||
import { DemoCard } from "@/components/artifacts/demo-card";
|
||||
import { JobCard } from "@/components/artifacts/job-card";
|
||||
import { PlanDivider } from "@/components/artifacts/plan-divider";
|
||||
import { VisualCard } from "@/components/artifacts/visual-card";
|
||||
|
||||
/**
|
||||
@@ -19,8 +18,9 @@ import { VisualCard } from "@/components/artifacts/visual-card";
|
||||
*/
|
||||
export const artifactRenderers: Record<string, ComponentType<ArtifactPartProps>> = {
|
||||
research: JobCard,
|
||||
// Phase divider where the plan lands (W5 fills in the component):
|
||||
plan_init: PlanDivider,
|
||||
// plan_* parts render nothing inline by design: the phase seam is a
|
||||
// message-level boundary, drawn by PhaseDivider in chat.tsx (the old
|
||||
// part-level plan_init divider retired with it).
|
||||
// Artifacts (W6 fills in the components):
|
||||
demo: DemoCard,
|
||||
visual: VisualCard,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { ArtifactPartProps, PlanToolResult } from "@/src/core";
|
||||
|
||||
/**
|
||||
* The probe→teach divider (vision §2 "Two phases, clearly separated").
|
||||
*
|
||||
* Rendered by the artifact registry wherever the session's plan_init part
|
||||
* sits in the transcript — the plan call IS the transition marker, so the
|
||||
* divider marks the seam: probing above, teaching below. Deliberately quiet:
|
||||
* a hairline rule with a small chip, never a banner.
|
||||
*
|
||||
* Renders null until the tool's output is available AND the init succeeded —
|
||||
* a failed first plan_init must not mark the seam (the retry that succeeds
|
||||
* draws the divider at its own, correct spot).
|
||||
*/
|
||||
export function PlanDivider({ part }: ArtifactPartProps) {
|
||||
if (part.state !== "output-available") return null;
|
||||
const result = part.output as PlanToolResult | undefined;
|
||||
if (!result || result.ok !== true) return null;
|
||||
const nodeCount = result.tree?.nodes?.length ?? 0;
|
||||
return (
|
||||
<div className="my-4 flex w-full items-center gap-3" data-testid="plan-divider">
|
||||
<span className="h-px flex-1 bg-border/80" />
|
||||
<span className="rounded-full border border-border/80 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Teaching{nodeCount > 0 ? ` · ${nodeCount} node${nodeCount === 1 ? "" : "s"}` : ""}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-border/80" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+41
-21
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
@@ -24,7 +24,8 @@ import { SidebarToggle } from "@/components/sidebar";
|
||||
import { SubAgentsPanel } from "@/components/sub-agents-panel";
|
||||
import { TreePanel } from "@/components/tree-panel";
|
||||
import type { ArtifactPartProps, JobRecord, QuestionAnswerData, TeacherUIMessage } from "@/src/core";
|
||||
import { findPendingToolCall, phaseAt } from "@/src/core";
|
||||
import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider";
|
||||
import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
|
||||
|
||||
/** One width for everything: transcript, cards, and the bottom blob share this column. */
|
||||
const COLUMN = "mx-auto w-full max-w-3xl px-4";
|
||||
@@ -127,13 +128,18 @@ export function Chat({
|
||||
const localStream = status === "submitted" || status === "streaming";
|
||||
const busy = localStream || turnInFlight;
|
||||
|
||||
// Teach-phase check (W5, vision §2 "two phases"): a pending question that
|
||||
// lands after plan_init starts collapsed behind a "Check your
|
||||
// understanding" reveal button; probe-phase questions auto-open as today.
|
||||
// A pending question can never precede plan_init in the transcript (the
|
||||
// teacher's turn ends at the question call), so the session's current
|
||||
// phase IS the question's phase. Pre-plan sessions: all probe — unchanged.
|
||||
const requiresReveal = pending !== null && phaseAt(messages) === "teach";
|
||||
// Teach-phase check (W5, vision §2): a pending question that lands once a
|
||||
// plan node is OCCUPIED (phaseAt3 "teach") starts collapsed behind a
|
||||
// "Check your understanding" reveal button; probe- and plan-phase
|
||||
// questions auto-open as today. A pending question can never precede its
|
||||
// phase's seam in the transcript (the teacher's turn ends at the question
|
||||
// call), so the session's current phase IS the question's phase.
|
||||
const requiresReveal = pending !== null && phaseAt3(messages) === "teach";
|
||||
|
||||
// The phase at every message (one linear fold): the transcript draws a
|
||||
// color-coded dotted divider at each boundary; the header chip reads the
|
||||
// session's current phase.
|
||||
const phases = useMemo(() => phaseTimeline(messages), [messages]);
|
||||
|
||||
// A tool call still streaming its input: the teacher is writing a question.
|
||||
// Shown as a transcript shimmer, exactly like the thinking indicator.
|
||||
@@ -267,16 +273,23 @@ export function Chat({
|
||||
return (
|
||||
<ChatActionsContext.Provider value={{ chatId, syncFromServer, sendText: (t: string) => void sendMessage({ text: t }) }}>
|
||||
<JobsContext.Provider value={jobs}>
|
||||
<div className="flex h-dvh flex-col">
|
||||
<header className="flex items-center gap-3 px-3 py-2">
|
||||
<SidebarToggle />
|
||||
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
|
||||
</header>
|
||||
{/* The session surface: the transcript column (header + scroller) and
|
||||
the plan-tree panel as full-height SIBLINGS — the panel spans top to
|
||||
bottom exactly like the left sidebar, and the header (name + phase
|
||||
chip) spans from the left sidebar's edge to the panel's edge. The
|
||||
dock stays sticky inside the scroller, untouched. */}
|
||||
<div className="flex h-dvh">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* h-10, not py-2: the toggle (a size-9 icon button) renders only
|
||||
while the sidebar is CLOSED — an auto-height row would collapse
|
||||
when it disappears and slide the title + chip up. */}
|
||||
<header className="flex h-10 items-center gap-3 px-3">
|
||||
<SidebarToggle />
|
||||
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
|
||||
<PhaseChip phase={phases.at(-1) ?? "probe"} />
|
||||
</header>
|
||||
|
||||
{/* The session row: transcript scroller + the persistent plan-tree panel
|
||||
(W7, vision §2). The panel is a flex SIBLING of the scroller — the dock
|
||||
stays sticky inside the scroller, untouched. */}
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<Conversation className="h-full min-w-0 flex-1" initial="instant">
|
||||
<ConversationContent className="min-h-full gap-0 p-0">
|
||||
<div className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
|
||||
@@ -296,7 +309,11 @@ export function Chat({
|
||||
// can still hold empty ids — the index keeps keys unique for them.
|
||||
<SubagentSteerCard key={m.id || `m-${mi}`} message={m} />
|
||||
) : (
|
||||
<Message key={m.id || `m-${mi}`} from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
|
||||
<Fragment key={m.id || `m-${mi}`}>
|
||||
{mi > 0 && phases[mi] !== phases[mi - 1] && (
|
||||
<PhaseDivider phase={phases[mi]!} nodeCount={planNodeCount(m)} />
|
||||
)}
|
||||
<Message from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
|
||||
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}>
|
||||
{m.role === "user" ? (
|
||||
// User text renders plain: the model's LaTeX pipeline is not
|
||||
@@ -342,7 +359,8 @@ export function Chat({
|
||||
})
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</Message>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{(status === "submitted" || turnInFlight) && <Shimmer className="text-sm">Thinking…</Shimmer>}
|
||||
@@ -381,11 +399,13 @@ export function Chat({
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
{/* Right sidebar: live sub-agent fleet (when running) above the plan tree. */}
|
||||
{/* Right sidebar: the live sub-agent fleet (when running) above
|
||||
the plan tree — the fleet panel renders nothing while idle. */}
|
||||
<div className="hidden shrink-0 flex-col xl:flex">
|
||||
<SubAgentsPanel jobs={jobs} />
|
||||
<TreePanel messages={messages} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</JobsContext.Provider>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Phase3 } from "@/src/core";
|
||||
|
||||
/**
|
||||
* The in-session phase presentation — the explicit face of what the picker
|
||||
* shows as a color strip (one color system everywhere, globals.css
|
||||
* --phase-*): the header chip next to the session title, and the dotted
|
||||
* boundary rule the transcript draws where the session moves into a new
|
||||
* phase. Replaces the old inline plan-divider (artifact registry): the
|
||||
* boundary is a MESSAGE-level seam, so chat.tsx renders it at the message
|
||||
* where the phase changes, not inside a tool part.
|
||||
*
|
||||
* Quiet register (matches the old divider): hairline rules, small chips —
|
||||
* never a banner. Probe never renders a divider: phases only advance, so
|
||||
* probe is the start of the transcript and "Planning"/"Teaching" are the two
|
||||
* visible seams.
|
||||
*/
|
||||
|
||||
/** Header-chip labels: the phases as the learner thinks of them. */
|
||||
const CHIP_LABEL: Record<Phase3, string> = {
|
||||
probe: "Probing",
|
||||
plan: "Planning",
|
||||
teach: "Teaching",
|
||||
};
|
||||
|
||||
const PHASE_DOT: Record<Phase3, string> = {
|
||||
probe: "bg-phase-probe",
|
||||
plan: "bg-phase-plan",
|
||||
teach: "bg-phase-teach",
|
||||
};
|
||||
|
||||
/** The header chip: a phase-colored dot + label next to the session title. */
|
||||
export function PhaseChip({ phase }: { phase: Phase3 }) {
|
||||
return (
|
||||
<span
|
||||
data-testid="phase-chip"
|
||||
data-phase={phase}
|
||||
title={`Session phase: ${CHIP_LABEL[phase]}`}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full", PHASE_DOT[phase])} aria-hidden />
|
||||
{CHIP_LABEL[phase]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Divider rule + chip colors per phase (probe never reaches here). */
|
||||
const DIVIDER_RULE: Partial<Record<Phase3, string>> = {
|
||||
plan: "border-phase-plan/50",
|
||||
teach: "border-phase-teach/50",
|
||||
};
|
||||
const DIVIDER_TEXT: Partial<Record<Phase3, string>> = {
|
||||
plan: "border-phase-plan/50 text-phase-plan",
|
||||
teach: "border-phase-teach/50 text-phase-teach",
|
||||
};
|
||||
|
||||
/**
|
||||
* The transcript boundary: a color-coded dotted rule at the message where
|
||||
* the session enters `phase`. The plan seam carries its node count ("Plan ·
|
||||
* 4 nodes"); teaching is just the label. Null for probe (defensive — the
|
||||
* fold never emits a probe transition after the start).
|
||||
*/
|
||||
export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?: number }) {
|
||||
if (phase === "probe") return null;
|
||||
const label =
|
||||
phase === "plan" && nodeCount
|
||||
? `Plan · ${nodeCount} node${nodeCount === 1 ? "" : "s"}`
|
||||
: CHIP_LABEL[phase];
|
||||
return (
|
||||
<div className="my-4 flex w-full items-center gap-3" data-testid={`phase-divider-${phase}`}>
|
||||
<span className={cn("h-0 flex-1 border-t-2 border-dotted", DIVIDER_RULE[phase])} aria-hidden />
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wide",
|
||||
DIVIDER_TEXT[phase],
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn("h-0 flex-1 border-t-2 border-dotted", DIVIDER_RULE[phase])} aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The node count for a plan seam: the first plan-op tree output in the
|
||||
* message that carries the seam (the plan_init that made the phase flip).
|
||||
* Structural slice of a UI message, same shape-of-shapes as core/phases.ts.
|
||||
*/
|
||||
export function planNodeCount(
|
||||
message: { parts: ReadonlyArray<{ type: string; state?: string; output?: unknown; [k: string]: unknown }> },
|
||||
): number | undefined {
|
||||
for (const part of message.parts) {
|
||||
if (!part.type.startsWith("tool-plan_") || part.state !== "output-available") continue;
|
||||
const tree = (part.output as { tree?: { nodes?: unknown[] } } | undefined)?.tree;
|
||||
if (tree && Array.isArray(tree.nodes) && tree.nodes.length > 0) return tree.nodes.length;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
+143
-30
@@ -6,7 +6,7 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatSummary } from "@/src/core";
|
||||
import type { ChatListItem, Phase3 } from "@/src/core";
|
||||
|
||||
/**
|
||||
* Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It
|
||||
@@ -65,23 +65,147 @@ export function SidebarToggle() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Phase colors for the session dot — one system with the header chip
|
||||
* and the transcript dividers (globals.css --phase-*). Static class
|
||||
* strings per phase: Tailwind can't compile dynamic class names. */
|
||||
const PHASE_FILL: Record<Phase3, string> = {
|
||||
probe: "bg-phase-probe",
|
||||
plan: "bg-phase-plan",
|
||||
teach: "bg-phase-teach",
|
||||
};
|
||||
const PHASE_RING: Record<Phase3, string> = {
|
||||
probe: "border-phase-probe",
|
||||
plan: "border-phase-plan",
|
||||
teach: "border-phase-teach",
|
||||
};
|
||||
|
||||
/**
|
||||
* The session dot, left of the title. TWO channels, one dot: COLOR is the
|
||||
* phase (probe/plan/teach — sky/violet/emerald), FILL+MOTION is what's
|
||||
* owed. Pulsing = the teacher is working (rare, time-sensitive — the only
|
||||
* state that deserves motion). Solid = a question awaits the learner's
|
||||
* answer (the DOMINANT resting state of real sessions — calm by design,
|
||||
* no animation). Hollow = nothing owed, idle. A pending answer outranks
|
||||
* "working": if a question is owed, that's the blocking fact.
|
||||
*/
|
||||
function SessionDot({ phase, pending, busy }: { phase: Phase3; pending: boolean; busy: boolean }) {
|
||||
if (pending) {
|
||||
return (
|
||||
<span
|
||||
data-testid="session-dot-awaiting"
|
||||
title="Waiting for your answer"
|
||||
className={cn("size-2 shrink-0 rounded-full", PHASE_FILL[phase])}
|
||||
>
|
||||
<span className="sr-only">Waiting for your answer</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (busy) {
|
||||
return (
|
||||
<span
|
||||
data-testid="session-dot-working"
|
||||
title="Teacher is working"
|
||||
className={cn("size-2 shrink-0 rounded-full", PHASE_FILL[phase], "lh-dot-breathe")}
|
||||
>
|
||||
<span className="sr-only">Teacher is working</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-testid="session-dot-idle"
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-2 shrink-0 rounded-full border-2 bg-transparent",
|
||||
PHASE_RING[phase],
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact "how long ago": "just now", "5m ago", "3h ago", "2d ago",
|
||||
* then a plain date. How long a session has been WAITING matters more
|
||||
* than an absolute timestamp. Pure — exported for tests. */
|
||||
export function timeAgo(iso: string, now = new Date()): string {
|
||||
const ms = now.getTime() - new Date(iso).getTime();
|
||||
if (ms < 60_000) return "just now";
|
||||
const min = Math.floor(ms / 60_000);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
/** The row's second line: state replaces the message count when the state
|
||||
* is the more useful fact (most resting sessions are awaiting an answer —
|
||||
* that's what the learner scans for). */
|
||||
function sessionMeta(chat: ChatListItem): string {
|
||||
if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`;
|
||||
if (chat.busy) return "Teacher is working…";
|
||||
return `${chat.messageCount} messages · ${new Date(chat.updatedAt).toLocaleDateString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One session row: the status dot, then title + state-aware meta. Exported
|
||||
* for render tests — pure function of the list item.
|
||||
*/
|
||||
export function SessionRow({
|
||||
chat,
|
||||
active,
|
||||
onOpen,
|
||||
}: {
|
||||
chat: ChatListItem;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
"relative mb-0.5 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent",
|
||||
active && "bg-accent font-medium",
|
||||
)}
|
||||
>
|
||||
<SessionDot phase={chat.phase} pending={chat.pendingQuestion} busy={chat.busy} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{chat.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">{sessionMeta(chat)}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsSidebar() {
|
||||
const { open, toggle } = useSidebar();
|
||||
const [chats, setChats] = useState<ChatSummary[] | null>(null);
|
||||
const [chats, setChats] = useState<ChatListItem[] | null>(null);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/chats", { cache: "no-store" });
|
||||
const d = (await res.json()) as { chats: ChatListItem[] };
|
||||
setChats(d.chats);
|
||||
} catch {
|
||||
// a transient failure on the periodic poll keeps the last snapshot
|
||||
setChats((prev) => prev ?? []);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
fetch("/api/chats", { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((d: { chats: ChatSummary[] }) => !cancelled && setChats(d.chats))
|
||||
.catch(() => !cancelled && setChats([]));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, pathname]);
|
||||
void refresh();
|
||||
// Poll while open: the statuses are live (a question lands in another
|
||||
// session, a turn finishes in the background) and the payload is tiny.
|
||||
// Pause while the tab is hidden; navigation refetches via pathname.
|
||||
const id = setInterval(() => {
|
||||
if (!document.hidden) void refresh();
|
||||
}, 2500);
|
||||
return () => clearInterval(id);
|
||||
}, [open, pathname, refresh]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -104,25 +228,14 @@ function SessionsSidebar() {
|
||||
) : chats.length === 0 ? (
|
||||
<div className="px-2 py-4 text-sm text-muted-foreground">No sessions yet.</div>
|
||||
) : (
|
||||
chats.map((c) => {
|
||||
const active = pathname === `/chat/${c.id}`;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/chat/${c.id}`)}
|
||||
className={cn(
|
||||
"mb-0.5 w-full rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent",
|
||||
active && "bg-accent font-medium",
|
||||
)}
|
||||
>
|
||||
<div className="truncate">{c.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{c.messageCount} messages · {new Date(c.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
chats.map((c) => (
|
||||
<SessionRow
|
||||
key={c.id}
|
||||
chat={c}
|
||||
active={pathname === `/chat/${c.id}`}
|
||||
onOpen={() => router.push(`/chat/${c.id}`)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+551
-51
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CheckIcon, ChevronsRightIcon, ListTreeIcon } from "lucide-react";
|
||||
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";
|
||||
|
||||
@@ -17,17 +25,35 @@ import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } fro
|
||||
* 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 divider in the transcript (plan-divider)
|
||||
* marks the seam; THIS panel is the surface for every other plan_* part
|
||||
* (they render nothing inline, by design).
|
||||
* 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).
|
||||
*
|
||||
* Quiet register (matches plan-divider): hairline border, muted text, one
|
||||
* highlighted row for the current position. Collapses to a thin rail; the
|
||||
* preference persists in localStorage. Hidden below `xl` (CSS-only).
|
||||
* 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 key for the collapse preference (matches lh:sidebar-open). */
|
||||
const STORAGE_KEY = "lh:tree-panel-collapsed";
|
||||
/** 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.
|
||||
@@ -140,49 +166,94 @@ export function nextNodeId(
|
||||
|
||||
// ─── 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: () => {},
|
||||
});
|
||||
|
||||
/** 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]);
|
||||
|
||||
// Start expanded so server and client first-render agree (no hydration
|
||||
// mismatch); the stored preference is applied after mount. Same pattern
|
||||
// as the sidebar.
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored !== null) setCollapsed(stored === "1");
|
||||
} catch {
|
||||
// private mode / storage disabled — the preference is simply not persisted
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
// see above
|
||||
}
|
||||
};
|
||||
const { mode, go } = useContext(TreePanelModeContext);
|
||||
|
||||
// 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;
|
||||
|
||||
return collapsed ? (
|
||||
<TreePanelRail
|
||||
doneCount={tree.doneNodeIds.length}
|
||||
totalCount={tree.nodes.length}
|
||||
onExpand={toggle}
|
||||
/>
|
||||
) : (
|
||||
<TreePanelBody tree={tree} onCollapse={toggle} />
|
||||
// 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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,9 +268,9 @@ export function TreePanelRail({
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
return (
|
||||
<aside
|
||||
<div
|
||||
data-testid="tree-panel-rail"
|
||||
className="hidden w-10 shrink-0 flex-col items-center gap-3 border-l border-border/60 py-3 xl:flex"
|
||||
className="flex h-full flex-col items-center gap-3 py-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -216,16 +287,19 @@ export function TreePanelRail({
|
||||
>
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The expanded panel: header + the node list in topological order. */
|
||||
/** 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]);
|
||||
@@ -234,10 +308,7 @@ export function TreePanelBody({
|
||||
const totalCount = tree.nodes.length;
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="tree-panel"
|
||||
className="hidden w-64 shrink-0 flex-col border-l border-border/60 bg-card/30 xl:flex"
|
||||
>
|
||||
<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">
|
||||
@@ -249,6 +320,15 @@ export function TreePanelBody({
|
||||
>
|
||||
{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}
|
||||
@@ -315,7 +395,7 @@ export function TreePanelBody({
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -351,3 +431,423 @@ function StatusMark({
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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 graph's view transform: zoom + translation (viewport px). */
|
||||
export interface GraphView {
|
||||
zoom: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** 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 fits the panel.
|
||||
// Pad 16: the fit view IS the default — slim margins fit more graph.
|
||||
const fitZoom = useMemo(() => {
|
||||
const pad = 16;
|
||||
if (!size.w || !size.h || layout.width === 0) return 1;
|
||||
return Math.max(
|
||||
ZOOM_FLOOR,
|
||||
Math.min((size.w - pad) / layout.width, (size.h - pad) / layout.height, 1),
|
||||
);
|
||||
}, [size, layout]);
|
||||
|
||||
const [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 });
|
||||
const userAdjustedRef = useRef(false);
|
||||
const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]);
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user