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) => (