From fbe4f163057c36ea745ebe38272a7e07d78bb610 Mon Sep 17 00:00:00 2001 From: Amos Date: Tue, 1 Sep 2026 18:31:01 +0300 Subject: [PATCH] In-session phase presentation: header chip + dotted boundary dividers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - components/phase-divider.tsx: PhaseChip (header, next to the session title: phase-colored dot + Probing/Planning/Teaching) and PhaseDivider (the transcript boundary: color-coded dotted rules + a small chip — 'Plan · N nodes' at the plan seam, 'Teaching' at the teach seam; probe never renders, phases only advance). planNodeCount reads the seam message's first plan tree. - chat.tsx: dividers render at MESSAGE boundaries (phaseTimeline, one linear fold) — the seam is the transition to the message's phase, so the seam message's prose sits below its divider. requiresReveal now uses phaseAt3 teach (an occupied node), so probe- AND plan-phase questions auto-open. - Retired the part-level plan-divider (artifact registry): the boundary is message-level, not part-level. Its tests replaced by render-phase-divider tests; core/phases gains tested phaseTimeline. --- components/artifacts/index.ts | 6 +- components/artifacts/plan-divider.tsx | 29 -------- components/chat.tsx | 34 ++++++--- components/phase-divider.tsx | 99 +++++++++++++++++++++++++++ components/tree-panel.tsx | 13 ++-- src/core/phases.ts | 25 +++++++ tests/phases.test.ts | 24 +++++++ tests/render-cards.test.tsx | 43 ------------ tests/render-phase-divider.test.tsx | 95 +++++++++++++++++++++++++ 9 files changed, 276 insertions(+), 92 deletions(-) delete mode 100644 components/artifacts/plan-divider.tsx create mode 100644 components/phase-divider.tsx create mode 100644 tests/render-phase-divider.test.tsx diff --git a/components/artifacts/index.ts b/components/artifacts/index.ts index bf6f87e..dc6eea2 100644 --- a/components/artifacts/index.ts +++ b/components/artifacts/index.ts @@ -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> = { 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, diff --git a/components/artifacts/plan-divider.tsx b/components/artifacts/plan-divider.tsx deleted file mode 100644 index e3ac6fd..0000000 --- a/components/artifacts/plan-divider.tsx +++ /dev/null @@ -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 ( -
- - - Teaching{nodeCount > 0 ? ` · ${nodeCount} node${nodeCount === 1 ? "" : "s"}` : ""} - - -
- ); -} diff --git a/components/chat.tsx b/components/chat.tsx index ba5fa89..56b4e8f 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useCallback, useEffect, useLayoutEffect, 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"; @@ -21,7 +21,8 @@ import { QuestionDock } from "@/components/question-dock"; import { SidebarToggle } from "@/components/sidebar"; import { TreePanel } from "@/components/tree-panel"; import type { ArtifactPartProps, QuestionAnswerData, TeacherUIMessage } from "@/src/core"; -import { findPendingJobCall, findPendingToolCall, phaseAt } from "@/src/core"; +import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider"; +import { findPendingJobCall, 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"; @@ -118,13 +119,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. @@ -201,6 +207,7 @@ export function Chat({
{title}
+
{/* The session row: transcript scroller + the persistent plan-tree panel @@ -218,7 +225,11 @@ export function Chat({ )} {messages.map((m, mi) => ( - + + {mi > 0 && phases[mi] !== phases[mi - 1] && ( + + )} + {m.role === "user" ? ( // User text renders plain: the model's LaTeX pipeline is not @@ -264,7 +275,8 @@ export function Chat({ }) )} - + + ))} {(status === "submitted" || turnInFlight) && Thinking…} diff --git a/components/phase-divider.tsx b/components/phase-divider.tsx new file mode 100644 index 0000000..0e9697b --- /dev/null +++ b/components/phase-divider.tsx @@ -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 = { + probe: "Probing", + plan: "Planning", + teach: "Teaching", +}; + +const PHASE_DOT: Record = { + 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 ( + + + {CHIP_LABEL[phase]} + + ); +} + +/** Divider rule + chip colors per phase (probe never reaches here). */ +const DIVIDER_RULE: Partial> = { + plan: "border-phase-plan/50", + teach: "border-phase-teach/50", +}; +const DIVIDER_TEXT: Partial> = { + 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 ( +
+ + + {label} + + +
+ ); +} + +/** + * 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; +} diff --git a/components/tree-panel.tsx b/components/tree-panel.tsx index 7865c1b..4b217b9 100644 --- a/components/tree-panel.tsx +++ b/components/tree-panel.tsx @@ -17,13 +17,14 @@ 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). +* Quiet register (matches the phase dividers): 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). */ /** localStorage key for the collapse preference (matches lh:sidebar-open). */ diff --git a/src/core/phases.ts b/src/core/phases.ts index f0a22ea..3157184 100644 --- a/src/core/phases.ts +++ b/src/core/phases.ts @@ -127,3 +127,28 @@ export function phaseAt3(messages: readonly Phase3Message[], upToIndex?: number) } return phase; } + +/** + * The phase at EVERY message index, in one forward fold — the transcript + * dividers render one per boundary, so phaseAt3(m, i) per index would + * rescan 0..i each time (quadratic); this is the same derivation, linear. + * out[i] === phaseAt3(messages, i) for every index. + */ +export function phaseTimeline(messages: readonly Phase3Message[]): Phase3[] { + const out: Phase3[] = []; + let phase: Phase3 = "probe"; + for (let i = 0; i < messages.length; i++) { + const parts = messages[i]?.parts; + if (parts) { + for (const part of parts) { + if (!part.type.startsWith("tool-plan_") || part.state !== "output-available") continue; + const tree = (part.output as PlanOutput | undefined)?.tree as PhaseTree | undefined; + if (!tree || !Array.isArray(tree.nodes)) continue; + const candidate = phaseOfTree(tree); + if (PHASE3_RANK[candidate] > PHASE3_RANK[phase]) phase = candidate; + } + } + out.push(phase); + } + return out; +} diff --git a/tests/phases.test.ts b/tests/phases.test.ts index 14982a5..45728ab 100644 --- a/tests/phases.test.ts +++ b/tests/phases.test.ts @@ -12,6 +12,7 @@ import { phaseAt, phaseAt3, phaseOfTree, + phaseTimeline, type Phase3Message, type PhaseMessage, } from "../src/core/phases.ts"; @@ -194,3 +195,26 @@ describe("phaseAt3", () => { assert.equal(phaseAt3(messages, -1), "probe", "empty scan range → probe"); }); }); + +describe("phaseTimeline", () => { + it("equals phaseAt3 at every index (the linear fold is the same derivation)", () => { + const messages: Phase3Message[] = [ + msg("text"), + planMsg({ tool: "plan_init", ok: false, tree: tree([], null) }), + planMsg({ tool: "plan_init", ok: true, tree: tree(["a", "b"], null) }), + msg("text"), + planMsg({ tool: "plan_set_current", ok: true, tree: tree(["a", "b"], "a") }), + msg("text"), + ]; + const timeline = phaseTimeline(messages); + assert.equal(timeline.length, messages.length); + for (let i = 0; i < messages.length; i++) { + assert.equal(timeline[i], phaseAt3(messages, i), `index ${i}`); + } + assert.deepEqual(timeline, ["probe", "probe", "plan", "plan", "teach", "teach"]); + }); + + it("empty transcript → empty timeline", () => { + assert.deepEqual(phaseTimeline([]), []); + }); +}); diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index def9193..f1c00d8 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -18,7 +18,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { MessageResponse } from "../components/ai-elements/message.tsx"; import { QuestionDock } from "../components/question-dock.tsx"; import { AnsweredCard } from "../components/answered-card.tsx"; -import { PlanDivider } from "../components/artifacts/plan-divider.tsx"; import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts"; const quizInput = { @@ -42,7 +41,6 @@ function quizQD(): QuestionData { }; } - function renderDock(props: { toolName: "quiz" | "ask_user_question"; toolCallId: string; input: never; onSubmit?: () => void }) { // expanded=true renders the full form (the collapsed bar is covered by the // QuestionDockBar test below). No scroll context needed — the DockOverlay @@ -220,44 +218,3 @@ describe("teach-check reveal (W5 phase separation)", () => { }); }); -describe("PlanDivider (the probe→teach seam)", () => { - const tree = { - nodes: [ - { id: "a", title: "A" }, - { id: "b", title: "B" }, - { id: "c", title: "C" }, - ], - edges: [{ from: "a", to: "b" }], - currentNodeId: null, - doneNodeIds: [], - version: 1, - }; - - it("renders the quiet divider with the node count once output-available", () => { - const html = renderToStaticMarkup( - , - ); - assert.ok(html.includes('data-testid="plan-divider"')); - assert.match(html, /Teaching/); - assert.match(html, /3 nodes/, "node count from the output tree"); - assert.ok(!html.includes("banner"), "never a loud banner"); - }); - - it("renders null while the tool streams and on a failed init", () => { - const streaming = renderToStaticMarkup( - , - ); - assert.equal(streaming, "", "null while input-streaming"); - const failed = renderToStaticMarkup( - , - ); - assert.equal(failed, "", "a refused re-init must not draw a second seam"); - }); -}); diff --git a/tests/render-phase-divider.test.tsx b/tests/render-phase-divider.test.tsx new file mode 100644 index 0000000..2054625 --- /dev/null +++ b/tests/render-phase-divider.test.tsx @@ -0,0 +1,95 @@ +/** + * Phase presentation render tests — the in-session face of the picker's + * color strips (components/phase-divider.tsx): the header chip and the + * dotted transcript boundary rules. Same tsx classic-JSX shim as + * render-cards.test.tsx. (Replaces the old part-level PlanDivider tests.) + */ +import React from "react"; +(globalThis as Record).React = React; + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { PhaseChip, PhaseDivider, planNodeCount } from "../components/phase-divider.tsx"; + +describe("PhaseChip (header)", () => { + it("renders a phase-colored dot + the learner-facing label", () => { + for (const [phase, label] of [ + ["probe", "Probing"], + ["plan", "Planning"], + ["teach", "Teaching"], + ] as const) { + const html = renderToStaticMarkup(); + assert.ok(html.includes('data-testid="phase-chip"')); + assert.ok(html.includes(`data-phase="${phase}"`)); + assert.ok(html.includes(label), `${phase} label`); + assert.ok(html.includes(`bg-phase-${phase}`), `${phase} dot color`); + } + }); +}); + +describe("PhaseDivider (transcript boundary)", () => { + it("the plan seam: dotted rules in the plan color + node count", () => { + const html = renderToStaticMarkup(); + assert.ok(html.includes('data-testid="phase-divider-plan"')); + assert.match(html, /Plan · 3 nodes/); + assert.ok(html.includes("border-dotted"), "dotted rule"); + assert.ok(html.includes("border-phase-plan"), "plan-colored rule"); + }); + + it("the teach seam: dotted rules in the teach color, no count", () => { + const html = renderToStaticMarkup(); + assert.ok(html.includes('data-testid="phase-divider-teach"')); + assert.match(html, /Teaching/); + assert.ok(html.includes("border-phase-teach")); + assert.ok(!html.includes("nodes")); + }); + + it("singular node count", () => { + const html = renderToStaticMarkup(); + assert.match(html, /Plan · 1 node\b/); + }); + + it("a plan seam without a count still labels itself", () => { + const html = renderToStaticMarkup(); + assert.match(html, /Planning/); + }); + + it("probe renders null (never a boundary — phases only advance)", () => { + assert.equal(renderToStaticMarkup(), ""); + }); +}); + +describe("planNodeCount (the seam message's tree)", () => { + it("counts the first output-available plan tree in the message", () => { + const count = planNodeCount({ + parts: [ + { type: "text", text: "Here's your plan." }, + { + type: "tool-plan_init", + state: "output-available", + toolCallId: "p1", + output: { ok: true, tree: { nodes: [{ id: "a" }, { id: "b" }], edges: [], currentNodeId: null, doneNodeIds: [], version: 1 } }, + }, + ], + }); + assert.equal(count, 2); + }); + + it("skips streaming/failed parts (an empty or failed tree gives no count)", () => { + assert.equal( + planNodeCount({ parts: [{ type: "tool-plan_init", state: "input-streaming" }] }), + undefined, + ); + assert.equal( + planNodeCount({ + parts: [ + { type: "tool-plan_init", state: "output-available", toolCallId: "p1", output: { ok: false, tree: { nodes: [], edges: [], currentNodeId: null, doneNodeIds: [], version: 0 } } }, + ], + }), + undefined, + "an empty tree (failed init) carries no count", + ); + assert.equal(planNodeCount({ parts: [{ type: "text" }] }), undefined); + }); +});