Merge feat/ui-polish: owner refinement rounds — chat top bar, stats-bar dropdown discipline, plan as marginalia, dock → sidebar background-agents section, graph-view chrome rounds
This commit is contained in:
@@ -1,19 +1,20 @@
|
||||
"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 { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } 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
|
||||
* THE BACKGROUND-AGENTS SECTION (owner, sidebar round — the successor of
|
||||
* the retired bottom-right floating dock): the bottom strip of the LEFT
|
||||
* SIDEBAR, 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).
|
||||
* deliberately NOT here: the section 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.
|
||||
@@ -23,13 +24,18 @@ import { formatTokens, formatUsd } from "@/components/usage-format";
|
||||
* 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).
|
||||
* in place — polling never flickers. ALWAYS present (owner, sidebar round):
|
||||
* empty means EMPTY — the header sits alone, nothing hides; it populates and
|
||||
* GROWS when processes run (max-h + internal scroll so a pile never crowds
|
||||
* the session list). The sidebar's closed state means the learner opted
|
||||
* out — no ambient signal while closed (user decision: invisibility until
|
||||
* a clearer observability story wants building). Per blob: kind icon,
|
||||
* elapsed, cost, activity line, 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. Detail (context, model,
|
||||
* steps, session link) EXPANDS INLINE — the section scrolls, so a
|
||||
* floating popover would clip at its bounds.
|
||||
*/
|
||||
|
||||
// ─── Wire shapes (client-safe mirrors of server/observability.ts) ────────────
|
||||
@@ -181,22 +187,9 @@ export function formatElapsed(ms: number): string {
|
||||
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
// ─── The dock ─────────────────────────────────────────────────────────────────
|
||||
// ─── The section (the sidebar's bottom strip) ─────────────────────────────────
|
||||
|
||||
/** 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. */
|
||||
/** A 1s heartbeat while the section is populated — elapsed lines tick. */
|
||||
function useNow(active: boolean): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
@@ -216,14 +209,12 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
|
||||
/** Abort states per blob id: pending (fired) / failed (backend absent). */
|
||||
type AbortState = "pending" | "failed";
|
||||
|
||||
export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) {
|
||||
export function ActivitySection({ 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 [openBlob, setOpenBlob] = useState<string | null>(null); // detail, inline-expanded
|
||||
const [abortStates, setAbortStates] = useState<Record<string, AbortState>>({});
|
||||
const now = useNow(blobs.length > 0);
|
||||
// Refs for stable abort handlers (no re-created closures per tick).
|
||||
@@ -266,63 +257,38 @@ export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Sidebar-native skin (owner, sidebar round): part of the sidebar's
|
||||
// surface, not a floating card — the border-t separator matches the
|
||||
// sidebar's own edge line, the header matches the "Sessions" label, the
|
||||
// rows match the session rows' hover. Empty means empty: the header sits
|
||||
// alone (the cost span is population, not decoration).
|
||||
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-xs 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)}
|
||||
<section data-testid="activity-section" className="border-t border-border px-3 pb-3 pt-2">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xxs uppercase tracking-widest text-muted-foreground/70">
|
||||
<ActivityIcon className="size-3" />
|
||||
Background agents
|
||||
{blobs.length > 0 && (
|
||||
<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>
|
||||
{blobs.length > 0 && (
|
||||
<div className="flex max-h-[40dvh] flex-col gap-1 overflow-y-auto">
|
||||
{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>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,7 +322,7 @@ function ActivityBlobRow({
|
||||
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"
|
||||
className="relative rounded-lg px-2 py-1.5 text-xs transition-colors hover:bg-accent"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -398,7 +364,7 @@ function ActivityBlobRow({
|
||||
{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"
|
||||
className="mt-1 flex flex-col gap-1 rounded-lg bg-accent/40 p-2 text-muted-foreground"
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<Icon className="size-3.5 text-muted-foreground" />
|
||||
|
||||
+120
-17
@@ -23,7 +23,8 @@ import { JobsContext } from "@/components/jobs-context";
|
||||
import { StatsBar } from "@/components/stats-bar";
|
||||
import type { ChatSettings } from "@/src/core";
|
||||
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
|
||||
import { PlanWindow } from "@/components/plan-window";
|
||||
import { PlanMarginalia } from "@/components/plan-window";
|
||||
import { WaypointsIcon } from "lucide-react";
|
||||
import type { PlanBlockData } from "@/src/core";
|
||||
import { QuestionDock } from "@/components/question-dock";
|
||||
import { SidebarToggle } from "@/components/sidebar";
|
||||
@@ -40,6 +41,17 @@ import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
|
||||
*/
|
||||
const COLUMN = "mx-auto w-full max-w-none px-6";
|
||||
const DOCK = "mx-auto w-full max-w-[46rem] px-4";
|
||||
// The DOCK container's fixed width (46rem) and the plan marginalia's
|
||||
// geometry (owner, plan-placement round): the bare diagram sits in the
|
||||
// dock band's RIGHT flank — MARGIN_GAP from the DOCK container's edge and
|
||||
// the same from the column's right edge. The box width is pure CSS, so
|
||||
// the diagram shrinks CONTINUOUSLY with the flank; MARGIN_MIN_W is only
|
||||
// the fade floor (owner, resize round) — below it the marginalia fades
|
||||
// out over the normal transition instead of popping away, and it stays
|
||||
// mounted while the question owns the band so widening fades it back.
|
||||
const DOCK_W = 46 * 16;
|
||||
const MARGIN_GAP = 16;
|
||||
const MARGIN_MIN_W = 220;
|
||||
// The dock overlay's gradient band (h-24) — part of the reserved tail.
|
||||
const GRADIENT_H = 96;
|
||||
// Fold thresholds (owner, scroll round 1, tuned round 3): pixel distance
|
||||
@@ -212,8 +224,12 @@ export function Chat({
|
||||
}
|
||||
}, [chatId, setMessages, sendMessage, status]);
|
||||
|
||||
// The live plan popup (wave 4): opened by any transcript plan block.
|
||||
// The live plan popup (wave 4): opened by any transcript plan block, the
|
||||
// top-bar plan chip, and the dock band's marginalia.
|
||||
const [planOpen, setPlanOpen] = useState(false);
|
||||
// The session's live plan (drives the top-bar chip; the marginalia
|
||||
// derives its own from the same transcript).
|
||||
const planTree = useMemo(() => latestTree(messages), [messages]);
|
||||
|
||||
// ── The plan verdict gate (docs/probe-teach-split.md §4) ──
|
||||
// The plan has been presented (a tree exists) and the session still sits in
|
||||
@@ -221,8 +237,8 @@ export function Chat({
|
||||
// Accept / Decline buttons. Acceptance is the explicit plan→teach seam
|
||||
// (a data-plan-accepted part on the message); decline asks for revision.
|
||||
const planVerdict = useMemo(
|
||||
() => phaseAt3(messages) === "plan" && latestTree(messages) !== null,
|
||||
[messages],
|
||||
() => phaseAt3(messages) === "plan" && planTree !== null,
|
||||
[messages, planTree],
|
||||
);
|
||||
const sendVerdict = useCallback(
|
||||
(accepted: boolean) => {
|
||||
@@ -478,19 +494,31 @@ export function Chat({
|
||||
the transcript reserves its height. */}
|
||||
<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="relative flex h-10 items-center gap-3 px-3">
|
||||
{/* The top bar is its own block (owner, top-fade round 4): in flow
|
||||
above the scroller on the plain page background — no gradient. Its
|
||||
bottom outline (border-b border-border) matches the one that
|
||||
separates the left sidebar — same width, same color (owner, r5). 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 border-b border-border px-3">
|
||||
<SidebarToggle />
|
||||
<div className="min-w-0 flex-1 truncate font-title text-2xl tracking-tight">{title}</div>
|
||||
{/* The plan chip (owner, plan-placement round): session-level
|
||||
structure beside the phase chip — access to the full-DAG popup
|
||||
at every width; the marginalia is the wide-screen glance view. */}
|
||||
{planTree && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="plan-chip"
|
||||
onClick={() => setPlanOpen(true)}
|
||||
title="Open the full plan"
|
||||
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
||||
>
|
||||
<WaypointsIcon className="size-3.5 text-primary" aria-hidden />
|
||||
Plan
|
||||
</button>
|
||||
)}
|
||||
<PhaseChip phase={phases.at(-1) ?? "probe"} />
|
||||
{/* Soft top fade (owner): the scroller edge under the header eases
|
||||
in exactly like the dock gradient at the bottom. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-full z-10 h-5 bg-gradient-to-b from-background to-transparent"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
@@ -633,6 +661,8 @@ export function Chat({
|
||||
chatId={chatId}
|
||||
settings={settings}
|
||||
onSettingsSaved={setSettings}
|
||||
plan={<PlanMarginalia messages={messages} />}
|
||||
onOpenPlan={() => setPlanOpen(true)}
|
||||
requiresReveal={requiresReveal}
|
||||
phase={phases.at(-1) ?? "probe"}
|
||||
turns={turns}
|
||||
@@ -672,8 +702,6 @@ export function Chat({
|
||||
</div>
|
||||
</div>
|
||||
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
|
||||
{/* The plan's ambient presence: live, zoomed on the current node; click → popup. */}
|
||||
<PlanWindow messages={messages} onOpen={() => setPlanOpen(true)} />
|
||||
</JobsContext.Provider>
|
||||
</ChatActionsContext.Provider>
|
||||
);
|
||||
@@ -713,6 +741,8 @@ function DockOverlay({
|
||||
settings,
|
||||
onSettingsSaved,
|
||||
onReserve,
|
||||
plan,
|
||||
onOpenPlan,
|
||||
}: {
|
||||
pending: ReturnType<typeof findPendingToolCall>;
|
||||
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
|
||||
@@ -730,6 +760,11 @@ function DockOverlay({
|
||||
/** Reports the dock's settled height (block + gradient) — ChatView's
|
||||
* dockReserve state feeds the transcript's spacer from it. */
|
||||
onReserve: (h: number) => void;
|
||||
/** The plan marginalia's content (the bare diagram) — rendered only
|
||||
* while a question owns the band. Null until a plan exists. */
|
||||
plan?: React.ReactNode;
|
||||
/** Opens the full-DAG popup (the marginalia + chip share it). */
|
||||
onOpenPlan: () => void;
|
||||
}) {
|
||||
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
|
||||
// Teach-phase mount (session reload with a pending check): the dock starts
|
||||
@@ -776,6 +811,43 @@ function DockOverlay({
|
||||
const revealedRef = useRef(revealed);
|
||||
revealedRef.current = revealed;
|
||||
|
||||
// The plan marginalia's flank width (owner, plan-placement round): from
|
||||
// the DOCK container's right edge to the block's right edge — the bare
|
||||
// diagram's canvas. Tracked LIVE by a ResizeObserver on the block
|
||||
// (owner, resize round): every geometry change — window resize, sidebar
|
||||
// toggle, scrollbar appearance, the box's own rewrap — reports the real
|
||||
// width instantly, so the fade floor never lags behind reality (the old
|
||||
// render-time measurement went stale during drag-resizes and the mount
|
||||
// decision popped late).
|
||||
const [flankW, setFlankW] = useState(0);
|
||||
const flankWRef = useRef(0);
|
||||
// Part A of the same round (the resize mute): any block size change
|
||||
// stamps this — the fold machine ignores scroll events for a beat after,
|
||||
// because a resize/rewrap re-clamps scrollTop and emits PHANTOM scroll
|
||||
// events mid-transient (the box flickered to the bar and back).
|
||||
const geometryAtRef = useRef(0);
|
||||
useIsoLayoutEffect(() => {
|
||||
const block = blockRef.current;
|
||||
if (!block) return;
|
||||
const measure = () => {
|
||||
const next = Math.round(block.offsetWidth / 2 - DOCK_W / 2 - 2 * MARGIN_GAP);
|
||||
// Stamp the mute on WIDTH changes ONLY: column resizes are the
|
||||
// phantom-scroll source. Height changes are the dock's own doing
|
||||
// (morphs, question rewraps) — stamping those would self-mute the
|
||||
// fold machine and swallow the at-bottom auto re-expand right
|
||||
// after a fold (verified live: the box got stuck collapsed).
|
||||
if (Math.abs(next - flankWRef.current) > 1) {
|
||||
flankWRef.current = next;
|
||||
setFlankW(next);
|
||||
geometryAtRef.current = Date.now();
|
||||
}
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(block);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// The overlay must not paint over the scroller's scrollbar (owner, scroll
|
||||
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
|
||||
// stable both-edges), so inset the overlay's right edge by the gutter —
|
||||
@@ -858,6 +930,11 @@ function DockOverlay({
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
if (!pendingRef.current) return;
|
||||
// Resize mute (owner, resize round): a geometry change re-clamps
|
||||
// scrollTop and the browser emits the clamp as a scroll event —
|
||||
// reading the transient position folded the box to the bar for a
|
||||
// flicker. Real scrolling resumes after the brief mute window.
|
||||
if (Date.now() - geometryAtRef.current < 400) return;
|
||||
const d = el.scrollHeight - el.clientHeight - el.scrollTop;
|
||||
if (expandedRef.current) {
|
||||
if (d > COLLAPSE_DIST && !expandIntentRef.current) setExpanded(false);
|
||||
@@ -924,7 +1001,7 @@ function DockOverlay({
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}>
|
||||
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
|
||||
<div ref={blockRef} className="bg-background pb-4">
|
||||
<div ref={blockRef} className="relative bg-background pb-5">
|
||||
<div className={DOCK}>
|
||||
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
||||
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
|
||||
@@ -954,6 +1031,32 @@ function DockOverlay({
|
||||
onSettingsSaved={onSettingsSaved}
|
||||
/>
|
||||
</div>
|
||||
{/* The plan marginalia (owner, plan-placement round): the bare
|
||||
diagram in the band's right flank — boundaless, meet-fitted, part
|
||||
of the lesson's page rather than a floating widget. Mounted only
|
||||
while a question owns the band (its reserved height is the canvas);
|
||||
folding the dock fades + blurs it out of existence, so reading
|
||||
the transcript in full wipes the margin clean. Click → popup.
|
||||
Width (owner, resize round): the box shrinks continuously with
|
||||
the flank; below MARGIN_MIN_W it FADES (never pops away — no
|
||||
mid-question unmount), and widening past the floor fades it
|
||||
back in. */}
|
||||
{pending && plan && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="plan-marginalia"
|
||||
onClick={onOpenPlan}
|
||||
title="Open the full plan"
|
||||
style={{ left: `calc(50% + ${DOCK_W / 2 + MARGIN_GAP}px)` }}
|
||||
className={`absolute bottom-5 right-4 top-0 cursor-pointer transition-[filter,opacity] duration-300 ease-out ${
|
||||
expanded && flankW >= MARGIN_MIN_W
|
||||
? "pointer-events-auto opacity-100 blur-0"
|
||||
: "pointer-events-none opacity-0 blur-md"
|
||||
}`}
|
||||
>
|
||||
{plan}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+350
-313
@@ -194,6 +194,328 @@ function SettingSlider({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendering-settings gear + its collapsible panel (owner, graph-view
|
||||
* round 2): its own container at the top right, separate from the search
|
||||
* box and the import button — clicking the gear expands/collapses the
|
||||
* panel, which drops below the button, right-aligned with it.
|
||||
*/
|
||||
function GraphSettingsControl({
|
||||
settings,
|
||||
changeSettings,
|
||||
}: {
|
||||
settings: GraphRenderSettings;
|
||||
changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const regionTuning = settings.regions;
|
||||
const physicsTuning = settings.physics;
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
title="Rendering settings"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={`flex size-11 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${
|
||||
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon className="size-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 w-64 rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* All sliders: log-scaled 0-100, step 5, so every
|
||||
DEFAULT sits at 50 (src/core/render-settings.ts). */}
|
||||
<SettingSlider
|
||||
label="Node size"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(settings.nodeSize, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max)}
|
||||
display={sizeDisplay(settings.nodeSize)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({ ...cur, nodeSize: fromSliderPosition(p, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max) }))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Edge width"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(settings.edgeWidth, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max)}
|
||||
display={sizeDisplay(settings.edgeWidth)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({ ...cur, edgeWidth: fromSliderPosition(p, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max) }))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Arrowhead size"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(settings.arrowLength, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max)}
|
||||
display={sizeDisplay(settings.arrowLength)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({ ...cur, arrowLength: fromSliderPosition(p, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max) }))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Label size"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(settings.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max)}
|
||||
display={sizeDisplay(settings.labelSize)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({ ...cur, labelSize: fromSliderPosition(p, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max) }))}
|
||||
/>
|
||||
{/* Displayed units normalize the default (0.5) to "1×". */}
|
||||
<SettingSlider
|
||||
label="Labels until zoom"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(settings.labelZoom, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX)}
|
||||
display={labelZoomDisplay(settings.labelZoom)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))}
|
||||
/>
|
||||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>Cluster labels</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.clusterLabels}
|
||||
onChange={(e) => changeSettings((cur) => ({ ...cur, clusterLabels: e.target.checked }))}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</label>
|
||||
{/* Regions: the blurred cluster tints. Sliders + a fixed-set
|
||||
dropdown for the bitmap resolution (it's a discrete
|
||||
choice, not a free number). Server-persisted like the
|
||||
sizes; every range end was sweep-validated on the real
|
||||
graph. */}
|
||||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Regions
|
||||
</span>
|
||||
<SettingSlider
|
||||
label="Dilation"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(regionTuning.dilation, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max)}
|
||||
display={sizeDisplay(regionTuning.dilation)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span>Blur</span>
|
||||
<select
|
||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
||||
value={regionTuning.blur}
|
||||
onChange={(e) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
regions: { ...cur.regions, blur: Number(e.target.value) },
|
||||
}))
|
||||
}
|
||||
>
|
||||
{REGION_BLUR_OPTIONS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<SettingSlider
|
||||
label="Edge alpha"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(regionTuning.edgeAlpha, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max)}
|
||||
display={sizeDisplay(regionTuning.edgeAlpha)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
regions: { ...cur.regions, edgeAlpha: fromSliderPosition(p, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span>Bitmap resolution</span>
|
||||
<select
|
||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
||||
value={regionTuning.bitmap}
|
||||
onChange={(e) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
regions: { ...cur.regions, bitmap: Number(e.target.value) },
|
||||
}))
|
||||
}
|
||||
>
|
||||
{REGION_BITMAP_OPTIONS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{/* Physics: the live sim's forces. Every value feeds the SAME
|
||||
engine the server settle runs (createGraphSimulation);
|
||||
changing one rebuilds the sim from the current positions —
|
||||
drag mid-settle re-anneals, pins survive. Sliders over
|
||||
sweep-validated ranges (src/core/render-settings.ts):
|
||||
range ends and all four corners settle cleanly — no NaN,
|
||||
no explosion, no non-settle. Repulsion is displayed as
|
||||
the negative it is; cluster pull's far left = off. */}
|
||||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Physics
|
||||
</span>
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
Depth anchor
|
||||
<select
|
||||
className="rounded border bg-background px-1 py-0.5 text-xs"
|
||||
value={physicsTuning.depthMode}
|
||||
onChange={(e) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthMode: e.target.value as GraphRenderSettings["physics"]["depthMode"] },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="vertical">Vertical (up)</option>
|
||||
<option value="radial">Radial (outward)</option>
|
||||
</select>
|
||||
</label>
|
||||
<SettingSlider
|
||||
label="Depth strength"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.depthStrength, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.depthStrength)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Depth spacing"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.depthSpacing, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.depthSpacing)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Base ring radius"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.depthBaseRadius, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.depthBaseRadius)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Node repulsion"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(-physicsTuning.chargeStrength, -CHARGE_RANGE.max, -CHARGE_RANGE.min)}
|
||||
display={`-${Math.round(-physicsTuning.chargeStrength)}`}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, chargeStrength: -fromSliderPosition(p, -CHARGE_RANGE.max, -CHARGE_RANGE.min) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Edge attraction"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.linkStrength, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.linkStrength)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, linkStrength: fromSliderPosition(p, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Edge length"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.linkDistance, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.linkDistance)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, linkDistance: fromSliderPosition(p, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Central pull"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.gravity, GRAVITY_RANGE.min, GRAVITY_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.gravity)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, gravity: fromSliderPosition(p, GRAVITY_RANGE.min, GRAVITY_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Cluster pull"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toOffSliderPosition(physicsTuning.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max)}
|
||||
display={physicsTuning.clusterPull <= 0 ? "off" : sizeDisplay(physicsTuning.clusterPull)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, clusterPull: fromOffSliderPosition(p, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Friction"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.velocityDecay, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.velocityDecay)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, velocityDecay: fromSliderPosition(p, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphView() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
@@ -207,7 +529,6 @@ export function GraphView() {
|
||||
|
||||
// Search panel state: the query text + the settings section toggle.
|
||||
const [query, setQuery] = useState("");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
// Render settings: SERVER-persisted (data/render-settings.json via
|
||||
// /api/graph/render-settings) — sizes AND the region/physics sections
|
||||
// (user-facing since 2026-09-13; the dev-session era is over). Loaded
|
||||
@@ -522,319 +843,31 @@ export function GraphView() {
|
||||
)}
|
||||
|
||||
{/* Legend + filters: search box and per-state toggle rows. */}
|
||||
{/* Top-right controls — three separate floating containers (owner,
|
||||
graph-view round 2): the search box, the settings gear (its own
|
||||
collapsible component), and the import button. */}
|
||||
{!isEmpty && (
|
||||
<div className="absolute right-3 top-3 z-10 w-64 rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search nodes…"
|
||||
spellCheck={false}
|
||||
className="h-8 w-full rounded-lg border bg-background pl-8 pr-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title="Rendering settings"
|
||||
onClick={() => setSettingsOpen((o) => !o)}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<SettingsIcon className="size-3.5" />
|
||||
</button>
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
||||
{/* Search — its own container (icon + input + no-match note). */}
|
||||
<div className="w-56 rounded-xl border bg-card/85 p-1.5 shadow-lg backdrop-blur">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search nodes…"
|
||||
spellCheck={false}
|
||||
className="h-8 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
||||
<div className="px-1.5 pt-1 text-[11px] text-muted-foreground">
|
||||
No matching nodes.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{settingsOpen && (
|
||||
<div className="mt-1.5 flex flex-col gap-2 border-t pt-1.5">
|
||||
{/* All sliders: log-scaled 0-100, step 5, so every
|
||||
DEFAULT sits at 50 (src/core/render-settings.ts). */}
|
||||
<SettingSlider
|
||||
label="Node size"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(settings.nodeSize, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max)}
|
||||
display={sizeDisplay(settings.nodeSize)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({ ...cur, nodeSize: fromSliderPosition(p, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max) }))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Edge width"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(settings.edgeWidth, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max)}
|
||||
display={sizeDisplay(settings.edgeWidth)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({ ...cur, edgeWidth: fromSliderPosition(p, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max) }))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Arrowhead size"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(settings.arrowLength, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max)}
|
||||
display={sizeDisplay(settings.arrowLength)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({ ...cur, arrowLength: fromSliderPosition(p, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max) }))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Label size"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(settings.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max)}
|
||||
display={sizeDisplay(settings.labelSize)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({ ...cur, labelSize: fromSliderPosition(p, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max) }))}
|
||||
/>
|
||||
{/* Displayed units normalize the default (0.5) to "1×". */}
|
||||
<SettingSlider
|
||||
label="Labels until zoom"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(settings.labelZoom, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX)}
|
||||
display={labelZoomDisplay(settings.labelZoom)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))}
|
||||
/>
|
||||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>Cluster labels</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.clusterLabels}
|
||||
onChange={(e) => changeSettings((cur) => ({ ...cur, clusterLabels: e.target.checked }))}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</label>
|
||||
{/* Regions: the blurred cluster tints. Sliders + a fixed-set
|
||||
dropdown for the bitmap resolution (it's a discrete
|
||||
choice, not a free number). Server-persisted like the
|
||||
sizes; every range end was sweep-validated on the real
|
||||
graph. */}
|
||||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Regions
|
||||
</span>
|
||||
<SettingSlider
|
||||
label="Dilation"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(regionTuning.dilation, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max)}
|
||||
display={sizeDisplay(regionTuning.dilation)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span>Blur</span>
|
||||
<select
|
||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
||||
value={regionTuning.blur}
|
||||
onChange={(e) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
regions: { ...cur.regions, blur: Number(e.target.value) },
|
||||
}))
|
||||
}
|
||||
>
|
||||
{REGION_BLUR_OPTIONS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<SettingSlider
|
||||
label="Edge alpha"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(regionTuning.edgeAlpha, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max)}
|
||||
display={sizeDisplay(regionTuning.edgeAlpha)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
regions: { ...cur.regions, edgeAlpha: fromSliderPosition(p, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span>Bitmap resolution</span>
|
||||
<select
|
||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
||||
value={regionTuning.bitmap}
|
||||
onChange={(e) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
regions: { ...cur.regions, bitmap: Number(e.target.value) },
|
||||
}))
|
||||
}
|
||||
>
|
||||
{REGION_BITMAP_OPTIONS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{/* Physics: the live sim's forces. Every value feeds the SAME
|
||||
engine the server settle runs (createGraphSimulation);
|
||||
changing one rebuilds the sim from the current positions —
|
||||
drag mid-settle re-anneals, pins survive. Sliders over
|
||||
sweep-validated ranges (src/core/render-settings.ts):
|
||||
range ends and all four corners settle cleanly — no NaN,
|
||||
no explosion, no non-settle. Repulsion is displayed as
|
||||
the negative it is; cluster pull's far left = off. */}
|
||||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Physics
|
||||
</span>
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
Depth anchor
|
||||
<select
|
||||
className="rounded border bg-background px-1 py-0.5 text-xs"
|
||||
value={physicsTuning.depthMode}
|
||||
onChange={(e) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthMode: e.target.value as GraphRenderSettings["physics"]["depthMode"] },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="vertical">Vertical (up)</option>
|
||||
<option value="radial">Radial (outward)</option>
|
||||
</select>
|
||||
</label>
|
||||
<SettingSlider
|
||||
label="Depth strength"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.depthStrength, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.depthStrength)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Depth spacing"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.depthSpacing, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.depthSpacing)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Base ring radius"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.depthBaseRadius, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.depthBaseRadius)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Node repulsion"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(-physicsTuning.chargeStrength, -CHARGE_RANGE.max, -CHARGE_RANGE.min)}
|
||||
display={`-${Math.round(-physicsTuning.chargeStrength)}`}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, chargeStrength: -fromSliderPosition(p, -CHARGE_RANGE.max, -CHARGE_RANGE.min) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Edge attraction"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.linkStrength, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.linkStrength)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, linkStrength: fromSliderPosition(p, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Edge length"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.linkDistance, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.linkDistance)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, linkDistance: fromSliderPosition(p, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Central pull"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.gravity, GRAVITY_RANGE.min, GRAVITY_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.gravity)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, gravity: fromSliderPosition(p, GRAVITY_RANGE.min, GRAVITY_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Cluster pull"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toOffSliderPosition(physicsTuning.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max)}
|
||||
display={physicsTuning.clusterPull <= 0 ? "off" : sizeDisplay(physicsTuning.clusterPull)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, clusterPull: fromOffSliderPosition(p, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Friction"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.velocityDecay, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.velocityDecay)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, velocityDecay: fromSliderPosition(p, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
||||
<div className="mt-1.5 border-t px-1.5 pt-1.5 text-[11px] text-muted-foreground">
|
||||
No matching nodes.
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1.5 border-t pt-1.5">
|
||||
{/* Import notes — its own container. */}
|
||||
<div className="rounded-xl border bg-card/85 p-1.5 shadow-lg backdrop-blur">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -850,12 +883,16 @@ export function GraphView() {
|
||||
type="button"
|
||||
disabled={importing}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-lg border text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg px-3 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<UploadIcon className="size-3" />
|
||||
{importing ? "Importing…" : "Import notes…"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Rendering settings — own component; the gear toggles it. The
|
||||
gear sits at the far right (owner, round 3) so its panel
|
||||
drops at the very right edge, over less of the graph. */}
|
||||
<GraphSettingsControl settings={settings} changeSettings={changeSettings} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+23
-15
@@ -9,9 +9,10 @@ import { StatsBar } from "@/components/stats-bar";
|
||||
import type { ChatSettings, TurnUsage } from "@/src/core";
|
||||
|
||||
/**
|
||||
* Home — the knowledge graph is the main view (placeholder until the graph
|
||||
* pipeline lands), with the new-session blob docked below it. Sessions live
|
||||
* in the toggleable sidebar.
|
||||
* Home — the knowledge graph is the main view, full-bleed edge to edge
|
||||
* (owner, graph-view round 1): the canvas owns the whole column, running
|
||||
* under the header and the new-session dock, both of which float over it
|
||||
* with no gradient scrims. Sessions live in the toggleable sidebar.
|
||||
*/
|
||||
export function Home() {
|
||||
const router = useRouter();
|
||||
@@ -48,21 +49,28 @@ export function Home() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
<header className="flex items-center gap-2 px-3 py-2">
|
||||
<SidebarToggle />
|
||||
</header>
|
||||
|
||||
{/* Graph — the main view (W4: sigma.js render of the derived graph). */}
|
||||
<main className="min-h-0 flex-1 px-4 py-2">
|
||||
<div className="relative h-dvh">
|
||||
{/* Graph — the main view, full-bleed (owner, graph-view round 1): no
|
||||
padding on any side; the canvas runs from the sidebar to the right
|
||||
edge and from the bottom to the top, under everything else. */}
|
||||
<main className="absolute inset-0">
|
||||
<GraphView />
|
||||
</main>
|
||||
|
||||
{/* New session — same floating blob as the chat composer. */}
|
||||
<div className="pb-4">
|
||||
{/* Matches chat.tsx's DOCK geometry (max-w-[46rem] px-4) so nothing
|
||||
shifts when a session starts (owner). */}
|
||||
<div className="mx-auto w-full max-w-[46rem] px-4">
|
||||
{/* Header floats over the graph — pointer-events-none except the
|
||||
toggle, so the canvas stays draggable/hoverable around it. */}
|
||||
<header className="pointer-events-none absolute inset-x-0 top-0 flex items-center gap-2 px-3 py-2">
|
||||
<div className="pointer-events-auto">
|
||||
<SidebarToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* New session — same floating blob as the chat composer, docked
|
||||
OVER the graph (no gradient: the graph runs clean under it to the
|
||||
bottom edge). Matches chat.tsx's DOCK geometry (max-w-[46rem]
|
||||
px-4) so nothing shifts when a session starts (owner). */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 pb-5">
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-[46rem] px-4">
|
||||
<form
|
||||
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
|
||||
onSubmit={(e) => {
|
||||
|
||||
@@ -358,6 +358,7 @@ export function PlanDiagram({
|
||||
textCapScale,
|
||||
naturalSize,
|
||||
monochrome,
|
||||
fill,
|
||||
}: {
|
||||
nodes: readonly LayoutNode[];
|
||||
edges: ReadonlyArray<{ from: string; to: string }>;
|
||||
@@ -379,6 +380,11 @@ export function PlanDiagram({
|
||||
/** The in-chat block's mode: every node white (fill-none, white stroke
|
||||
* and text, no glow) — statuses never show in the transcript (owner). */
|
||||
monochrome?: boolean;
|
||||
/** Meet-fits the diagram inside a fixed box (h-full w-full — the svg's
|
||||
* default preserveAspectRatio centers + contains the viewBox): the
|
||||
* dock band's marginalia mode, where the diagram must fit BOTH the
|
||||
* flank's width and the band's height rather than run width-first. */
|
||||
fill?: boolean;
|
||||
}) {
|
||||
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
|
||||
// Pad only the full-extent render — the plan window's override viewBox is
|
||||
@@ -391,7 +397,7 @@ export function PlanDiagram({
|
||||
return (
|
||||
<svg
|
||||
viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`}
|
||||
className={`block h-auto${naturalSize ? "" : " w-full"}${textCapScale ? " mx-auto" : ""}`}
|
||||
className={`block ${fill ? "h-full w-full" : `h-auto${naturalSize ? "" : " w-full"}`}${textCapScale ? " mx-auto" : ""}`}
|
||||
style={
|
||||
textCapScale
|
||||
? { maxWidth: `${Math.round(vbW * textCapScale * 100) / 100}px` }
|
||||
|
||||
+30
-78
@@ -1,102 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo } from "react";
|
||||
import { WaypointsIcon } from "lucide-react";
|
||||
import type { PlanTree } from "@/src/core";
|
||||
import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
|
||||
import {
|
||||
PlanDiagram,
|
||||
blockLayout,
|
||||
GRAPH_PAD,
|
||||
latestTree,
|
||||
NODE_H,
|
||||
NODE_W,
|
||||
TEXT_CAP_SCALE,
|
||||
type PopupMessage,
|
||||
} from "@/components/plan-block";
|
||||
|
||||
/**
|
||||
* THE PLAN WINDOW (docs/probe-teach-split.md §6): the plan's persistent,
|
||||
* ambient presence — the successor of the injected plan blocks. Always on
|
||||
* once a tree exists, it shows a ZOOMED-IN window on the CURRENT node and
|
||||
* moves by itself as the lesson advances (auto-pan: the viewBox re-centers
|
||||
* on the latest tagged check — no animation machinery, the window just
|
||||
* follows the derived position). Click opens the full-DAG popup.
|
||||
* THE PLAN MARGINALIA: the plan's ambient presence in the session view —
|
||||
* the successor of the old fixed corner card (the plan window, retired
|
||||
* with the dock-band placement round). NO container: the bare diagram,
|
||||
* meet-fitted into the dock band's right flank — the wrapper (DockOverlay)
|
||||
* positions and fades it — so it reads as marginalia on the lesson's
|
||||
* page, not a floating widget. Live by construction: statuses derive from
|
||||
* the transcript on every render (derivePlanStatuses — calls, not
|
||||
* results), so the glow moves the moment a tagged check streams in.
|
||||
*
|
||||
* Live by construction: statuses derive from the transcript on every
|
||||
* render (derivePlanStatuses — calls, not results), so the window moves
|
||||
* the moment a tagged check call streams in or a record adoption lands.
|
||||
* Hidden on narrow viewports (the dock owns that corner's collapse
|
||||
* pattern); the learner's visual polish pass (colors, motion) comes later.
|
||||
* Visibile only while a question owns the dock band (the reserved tail's
|
||||
* height is the diagram's canvas — quiz open, tall band, full plan);
|
||||
* folding the dock fades + blurs the whole thing out of existence. The
|
||||
* top-bar plan chip carries access at every width; the popup is the study
|
||||
* view. The viewBox pad clears the current node's blurred yellow halo
|
||||
* (GLOW_REACH) at the extent edges.
|
||||
*/
|
||||
|
||||
/** Window dimensions in SVG units — 2:1, ~2¼ node widths: the current
|
||||
* node plus its immediate neighborhood. Aspect-matched to the card. */
|
||||
const VIEW_W = 360;
|
||||
const VIEW_H = 180;
|
||||
const MARGIN_PAD = GRAPH_PAD + 16;
|
||||
|
||||
/**
|
||||
* The windowed viewBox, centered on the current node — pure (exported for
|
||||
* tests). Falls back to the FIRST node (authoring order — the lesson's
|
||||
* start) while no check has been tagged. A layout smaller than the window
|
||||
* centers itself; a larger one clamps so the window never strays into
|
||||
* empty space beyond an edge node.
|
||||
*/
|
||||
export function planWindowViewBox(tree: PlanTree, current: string | null): string {
|
||||
const layout = blockLayout(tree.nodes, tree.edges);
|
||||
const focusId =
|
||||
current && layout.x.has(current)
|
||||
? current
|
||||
: (tree.nodes[0]?.id ?? null);
|
||||
const cx =
|
||||
focusId !== null
|
||||
? (layout.x.get(focusId) ?? 0) + NODE_W / 2
|
||||
: Math.max(layout.width, 1) / 2;
|
||||
const cy =
|
||||
focusId !== null
|
||||
? (layout.y.get(focusId) ?? 0) + (layout.nodeH.get(focusId) ?? NODE_H) / 2
|
||||
: Math.max(layout.height, 1) / 2;
|
||||
const clamp = (c: number, extent: number, view: number) =>
|
||||
extent <= view ? extent / 2 : Math.min(Math.max(c, view / 2), extent - view / 2);
|
||||
const x = clamp(cx, layout.width, VIEW_W) - VIEW_W / 2;
|
||||
const y = clamp(cy, layout.height, VIEW_H) - VIEW_H / 2;
|
||||
return `${x} ${y} ${VIEW_W} ${VIEW_H}`;
|
||||
}
|
||||
|
||||
export function PlanWindow({ messages, onOpen }: { messages: readonly PopupMessage[]; onOpen: () => void }) {
|
||||
export function PlanMarginalia({ messages }: { messages: readonly PopupMessage[] }) {
|
||||
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
|
||||
const tree = useMemo(() => latestTree(messages), [messages]);
|
||||
const statuses = useMemo(
|
||||
() => (tree ? derivePlanStatuses(tree, messages as Parameters<typeof derivePlanStatuses>[1]) : null),
|
||||
[tree, messages],
|
||||
);
|
||||
if (!tree || tree.nodes.length === 0) return null; // no plan yet: no window
|
||||
if (!tree || tree.nodes.length === 0) return null; // no plan yet: nothing in the margin
|
||||
const statusOf = (id: string) => (statuses ? derivedNodeStatus(statuses, id) : "upcoming");
|
||||
const currentTitle = statuses?.current
|
||||
? (tree.nodes.find((n) => n.id === statuses.current)?.title ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="plan-window"
|
||||
onClick={onOpen}
|
||||
title="Open the full plan"
|
||||
className="fixed bottom-3 left-3 z-30 hidden w-80 flex-col gap-1 rounded-xl border bg-card/95 p-2 text-left shadow-lg backdrop-blur sm:flex"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<WaypointsIcon className="size-3.5 text-primary" />
|
||||
The plan
|
||||
<span className="ml-auto min-w-0 truncate font-normal normal-case" title={currentTitle ?? undefined}>
|
||||
{currentTitle ?? "starting"}
|
||||
</span>
|
||||
</div>
|
||||
<PlanDiagram
|
||||
nodes={tree.nodes}
|
||||
edges={tree.edges}
|
||||
statusOf={statusOf}
|
||||
glowId={statuses?.current ?? null}
|
||||
markerId={markerId}
|
||||
viewBoxOverride={planWindowViewBox(tree, statuses?.current ?? null)}
|
||||
/>
|
||||
</button>
|
||||
<PlanDiagram
|
||||
nodes={tree.nodes}
|
||||
edges={tree.edges}
|
||||
statusOf={statusOf}
|
||||
glowId={statuses?.current ?? null}
|
||||
markerId={markerId}
|
||||
pad={MARGIN_PAD}
|
||||
textCapScale={TEXT_CAP_SCALE}
|
||||
fill
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||
import { ActivitySection } from "@/components/activity-dock";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -284,8 +285,15 @@ function SessionsSidebar() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Background agents (owner, sidebar round): the section is
|
||||
ALWAYS here — empty means empty (the header sits alone); it
|
||||
populates and grows when processes run. The session list is
|
||||
flex-1, so this pins to the bottom. Sidebar closed = the
|
||||
learner opted out (no ambient signal, user decision). */}
|
||||
<ActivitySection />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+96
-71
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core";
|
||||
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
|
||||
import { ModelControl } from "@/components/model-picker";
|
||||
@@ -14,8 +14,8 @@ import { ModelControl } from "@/components/model-picker";
|
||||
* 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 the model + thinking control (components/
|
||||
* can't price count as unpriced, shown in the popover, never folded into
|
||||
* a fake $0. The RIGHT side is the model + thinking control (components/
|
||||
* model-picker.tsx): one controller for the conversation agents, backed by
|
||||
* pi-ai's catalog; the buttons show the EFFECTIVE model (the record's
|
||||
* settings, else config's pin), while the left-side figures still fold the
|
||||
@@ -33,6 +33,24 @@ export function StatsBar({
|
||||
onSettingsSaved: (settings: ChatSettings) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Same dropdown discipline as the model control (owner, df4433f treatment,
|
||||
// stats round 1): close on any pointerdown outside the bar or on Escape.
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (barRef.current && !barRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
const ctx = contextWindow({ turns });
|
||||
const { cost, unpriced } = sessionCost({ turns });
|
||||
// Honest cost: show dollars only when at least one turn priced;
|
||||
@@ -48,22 +66,81 @@ export function StatsBar({
|
||||
// (so its transparent gradient area never blocks scrolling) — this row
|
||||
// must re-enable events or every control on it is dead (the model-
|
||||
// control wave caught this live: buttons rendered, clicks never landed).
|
||||
className="pointer-events-auto relative flex items-center justify-between gap-3 px-3 pt-1.5 text-xs text-muted-foreground"
|
||||
className="pointer-events-auto relative flex items-center justify-between gap-3 px-3 pt-2 text-xs 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>
|
||||
{/* The summary button + its popover: the popover anchors to this
|
||||
wrapper, CENTERED over the button itself (owner, stats round 2)
|
||||
— not over the whole bar row, which made it line up oddly. The
|
||||
outside-close ref anchors HERE too (owner, round 3): on the whole
|
||||
bar it wrapped the model control, so clicking model/thinking never
|
||||
counted as outside — the asymmetry the owner observed. */}
|
||||
<div ref={barRef} className="relative">
|
||||
<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>
|
||||
{open && (
|
||||
<div
|
||||
data-testid="stats-detail"
|
||||
className="absolute bottom-full left-1/2 z-40 mb-1.5 w-72 -translate-x-1/2 rounded-xl border bg-card p-3 text-xs shadow-xl"
|
||||
>
|
||||
<div className="mb-2 text-xs 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-xs 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>
|
||||
{/* The model + thinking control — one controller for the conversation
|
||||
agents (prober/teacher); the record's `turns` above still folds
|
||||
the ACTUAL models billed, regardless of what the picker says. */}
|
||||
@@ -73,58 +150,6 @@ export function StatsBar({
|
||||
onSaved={onSettingsSaved}
|
||||
fallbackModelId={lastModel}
|
||||
/>
|
||||
|
||||
{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-xs 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-xs 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user