From 9a99b01159b5ffa1ef54ebedd435ad31d1cf24a6 Mon Sep 17 00:00:00 2001 From: Amos Date: Sat, 22 Aug 2026 10:26:51 +0300 Subject: [PATCH] =?UTF-8?q?W5:=20probe/teach=20phases=20=E2=80=94=20divide?= =?UTF-8?q?r=20+=20collapsed=20teach=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/artifacts/plan-divider.tsx | 30 +++++++-- components/chat.tsx | 47 ++++++++++++-- components/question-dock.tsx | 48 +++++++++++++-- src/agent/prompts/30-phases.md | 18 ++++++ src/core/phases.ts | 47 ++++++++++++++ tests/phases.test.ts | 85 +++++++++++++++++++++++++ tests/render-cards.test.tsx | 89 +++++++++++++++++++++++++++ 7 files changed, 350 insertions(+), 14 deletions(-) create mode 100644 src/agent/prompts/30-phases.md create mode 100644 src/core/phases.ts create mode 100644 tests/phases.test.ts diff --git a/components/artifacts/plan-divider.tsx b/components/artifacts/plan-divider.tsx index a978320..2e60dab 100644 --- a/components/artifacts/plan-divider.tsx +++ b/components/artifacts/plan-divider.tsx @@ -1,9 +1,29 @@ -import type { ArtifactPartProps } from "@/src/core/contracts"; +import type { ArtifactPartProps, PlanToolResult } from "@/src/core/contracts"; /** - * Phase divider rendered where plan_init lands in the transcript (W5). - * Probe → teach transition marker per vision §2 "Two phases". + * 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(_props: ArtifactPartProps) { - return null; +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 bfaa942..0aa8425 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -23,6 +23,7 @@ import type { TeacherUIMessage } from "@/src/agent/ui-message"; import type { ArtifactPartProps } from "@/src/core/contracts"; import type { QuestionAnswerData } from "@/src/core/question-core"; import { findPendingJobCall, findPendingToolCall } from "@/src/core/pending"; +import { phaseAt } from "@/src/core/phases"; /** One width for everything: transcript, cards, and the bottom blob share this column. */ const COLUMN = "mx-auto w-full max-w-3xl px-4"; @@ -105,6 +106,14 @@ export function Chat({ const pending = findPendingToolCall(messages); const busy = status === "submitted" || status === "streaming"; + // 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"; + // A tool call still streaming its input: the teacher is writing a question. // Shown as a transcript shimmer, exactly like the thinking indicator. const questionStreaming = @@ -224,6 +233,7 @@ export function Chat({ ; + /** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */ + requiresReveal: boolean; composer: React.ReactNode; onSubmitAnswer: (output: QuestionAnswerData) => void; }) { const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext(); - const [expanded, setExpanded] = useState(true); + // Teach-phase mount (session reload with a pending check): the dock starts + // as the collapsed invitation too — the check must not pop open on reload. + const [expanded, setExpanded] = useState(!requiresReveal); + // Reveal state (W5): false until the learner clicks the invitation. While + // false, the scroll machine must NOT auto-expand at the bottom — the + // learner reads the teaching first and opens the check when ready. Once + // revealed, the normal collapse machine rules apply untouched. + const [revealed, setRevealed] = useState(false); + const revealPending = requiresReveal && !revealed; const pendingId = pending?.toolCallId ?? null; const initialPendingRef = useRef(pendingId); // New question MID-SESSION: open expanded if the learner is following at // the bottom (scroll to reveal it); reading history → arrive as the bar. - // On session mount the dock simply starts expanded at the bottom (the - // library's instant initial scroll positions us) — no scroll, no morph. + // Teach-phase checks ALWAYS arrive collapsed behind the reveal button, + // even at the bottom. On session mount the dock simply starts in its + // initial state (expanded for probe, invitation for teach) — no scroll, no + // morph. useEffect(() => { if (!pendingId || pendingId === initialPendingRef.current) return; - setExpanded(isAtBottom); - if (isAtBottom) void scrollToBottom(); + if (requiresReveal) { + setRevealed(false); + setExpanded(false); + } else { + setExpanded(isAtBottom); + if (isAtBottom) void scrollToBottom(); + } // eslint-disable-next-line react-hooks/exhaustive-deps -- fire only when a NEW question lands }, [pendingId]); @@ -299,6 +327,10 @@ function DockOverlay({ expandedRef.current = expanded; const pendingRef = useRef(!!pending); pendingRef.current = !!pending; + const requiresRevealRef = useRef(requiresReveal); + requiresRevealRef.current = requiresReveal; + const revealedRef = useRef(revealed); + revealedRef.current = revealed; const expandedHRef = useRef(0); const collapsedHRef = useRef(42); @@ -311,6 +343,7 @@ function DockOverlay({ const intentTimerRef = useRef>(undefined); const expandAndReveal = () => { expandIntentRef.current = true; + setRevealed(true); // the teach-check invitation was accepted (W5) setExpanded(true); clearTimeout(intentTimerRef.current); intentTimerRef.current = setTimeout(() => { @@ -332,6 +365,9 @@ function DockOverlay({ if (expandedRef.current) { if (d > delta + 64 && !expandIntentRef.current) setExpanded(false); } else if (d < 16) { + // Teach checks stay collapsed until the learner opens them (W5): the + // at-bottom auto-expand would pop the form over the teaching. + if (requiresRevealRef.current && !revealedRef.current) return; setExpanded(true); void scrollToBottom(); } @@ -397,6 +433,7 @@ function DockOverlay({ toolCallId={pending.toolCallId} input={pending.input as never} expanded={expanded} + reveal={revealPending} onExpand={expandAndReveal} onSubmit={onSubmitAnswer} /> diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 4c4c158..235efdd 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { ArrowDownIcon } from "lucide-react"; +import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { MessageResponse } from "@/components/ai-elements/message"; import { cn } from "@/lib/utils"; @@ -37,14 +37,28 @@ export interface QuestionDockProps { input: QuizToolInput | AskToolInput; /** Collapse state, owned by the DockOverlay (it drives the shell's height morph). */ expanded: boolean; + /** + * Teach-phase check awaiting its reveal (vision §2, W5): while collapsed, + * the bar is the "Check your understanding" invitation instead of the + * scroll-collapse strip. Owned by the DockOverlay; false once the learner + * has revealed (afterwards the normal collapse machine rules apply). + */ + reveal?: boolean; onExpand: () => void; onSubmit: (output: QuestionAnswerData) => void; } -export function QuestionDock({ toolName, toolCallId, input, expanded, onExpand, onSubmit }: QuestionDockProps) { +export function QuestionDock({ toolName, toolCallId, input, expanded, reveal = false, onExpand, onSubmit }: QuestionDockProps) { return ( <> - {!expanded && } + {!expanded && ( + + )}
void; + variant?: "collapsed" | "reveal"; }) { + if (variant === "reveal") { + return ( + + ); + } return (