diff --git a/components/chat.tsx b/components/chat.tsx index 4fc1115..16bc3d1 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -50,6 +50,7 @@ import { findPendingToolCall, phaseAt3, phaseTimeline, + teachStepIndex, toolQuestionKind, quizNeedsReveal, } from "@/src/core"; @@ -1238,15 +1239,26 @@ export function Chat({ {/* THE INFERRED TEACH SEAM (owner ruling 2026-09-26, the verdict death): teaching begins where the FIRST nodeId-tagged quiz is posed — the fold (phaseTimeline, the same derivation the phase chip - reads) flips to teach exactly AT this assistant group, so the divider - renders BEFORE the whole block (thinking -> prose -> the quiz call) — - where the explicit accept marker used to sit. The boundary check - (this index flips, the previous didn't) guarantees exactly ONE divider - per session. PREPENDED, never a replacement branch: the teach group is - a real message with content (the marker branches above have none) — a - replacement ternary swallowed the entire first lesson block (the - Lacan session, 2026-09-27). */} - {m.role === "assistant" && mi > 0 && phases[mi] === "teach" && phases[mi - 1] !== "teach" ? ( + reads) flips to teach exactly AT this assistant group. STEP-LEVEL + since owner round 8 (2026-10-05 — the stacked-dividers bug): the + plan presentation often lives in the SAME message, an earlier + provider step than the quiz call, and the old message-level seam + drew this divider before the plan too (Planning and Teaching + stacked back-to-back, nothing between). teachStepIndex finds the + step boundary governing the flip call: -1 = the message's first + step → prepend before the whole block (this branch); a later + index → the divider renders AT that part boundary in the loop + below (replacing the plain step hairline — a labeled phase rule + supersedes it). The boundary check (this index flips, the + previous didn't) still guarantees exactly ONE divider per session. + PREPENDED, never a replacement branch: the teach group is a real + message with content — a replacement ternary swallowed the entire + first lesson block (the Lacan session, 2026-09-27). */} + {m.role === "assistant" && + mi > 0 && + phases[mi] === "teach" && + phases[mi - 1] !== "teach" && + teachStepIndex(m.parts) === -1 ? ( ) : null} {m.role === "user" ? ( @@ -1273,6 +1285,15 @@ export function Chat({ {m.parts.map((part, pi) => { const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1; + // The step boundary where teaching begins (round 8): when + // the teach flip happens in a LATER step, its PhaseDivider + // replaces this boundary's plain hairline. + const teachBoundary = + m.role === "assistant" && + mi > 0 && + phases[mi] === "teach" && + phases[mi - 1] !== "teach" && + teachStepIndex(m.parts) === pi; // A tool call that errored BACK TO THE AGENT (validation // failure, malformed args) is invisible work — the agent // reads the error and retries. Owner ruling 2026-09-26: @@ -1302,6 +1323,8 @@ export function Chat({ ); case "step-start": + if (teachBoundary) + return ; return pi > 0 ? : null; case "data-plan-block": // The plan in the chat (wave 4): a frozen snapshot diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 5aabc80..575213e 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -17,6 +17,7 @@ import { Hint } from "@/components/ui/hint"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { fuzzyScore } from "@/src/core"; import { useServerEvent, useServerResync } from "@/components/server-events"; import type { ChatListItem } from "@/src/core"; import { kbdEventForAction } from "@/src/core"; @@ -384,15 +385,22 @@ function SessionsSidebar() { router.push("/chat/new"); }, [router]); + /* FUZZY (owner round 8, 2026-10-05): subsequence match ranked by + * fuzzyScore — prefix beats word-boundary beats scattered. Empty query + * keeps the natural list order (scores are all 0, sort is stable). */ const shown = useMemo(() => { - const q = query.trim().toLowerCase(); + const q = query.trim(); let list = chats; if (list && kindFilter !== "all") { list = list.filter((c) => (c.kind ?? "teach") === kindFilter); } if (!list) return list; if (!q) return list; - return list.filter((c) => c.title.toLowerCase().includes(q)); + return list + .map((c) => ({ c, score: fuzzyScore(q, c.title) })) + .filter((r): r is { c: (typeof list)[number]; score: number } => r.score !== null) + .sort((a, b) => b.score - a.score) + .map((r) => r.c); }, [chats, query, kindFilter]); const refreshInFlightRef = useRef(false); @@ -688,14 +696,16 @@ function SessionsSidebar() { - {/* Two doors (practice sessions, owner 2026-10-04): teaching - keeps the original draft route; practice carries its mode. */} -
+ {/* Two doors, STACKED (owner round 8, 2026-10-05 — was a + half/half row): full-width rows read cleaner and give the + labels room; teaching keeps the original draft route, + practice carries its mode. */} +
- {/* The three-way filter (owner, 2026-10-04): Teaching / All / - Practice, quiet segmented, persisted client-side. */} + {/* The three-way filter (owner, 2026-10-04; order owner round 8, + 2026-10-05): Teaching / Practice / All — the two real modes + first, "All" as the escape hatch on the right, mirroring the + stacked create-buttons above. Persisted client-side. */}
- {(["teach", "all", "practice"] as const).map((f) => ( + {(["teach", "practice", "all"] as const).map((f) => (