ui: round 8 — fuzzy session search, stacked doors, All right, teach divider at the step seam

- SESSION SEARCH goes fuzzy on the SAME fuzzyScore the model pickers
  use (de0c286's util — one scoring across every search surface):
  ranked by score instead of raw substring; empty query keeps order.
- SIDEBAR: New Teaching / New Practice STACK (full-width rows); the
  kind filter reads Teaching · Practice · All — the real modes first,
  All as the escape hatch, mirroring the stack above. Home stays in the
  LH band (owner-confirmed).
- TEACH DIVIDER, the stacked-dividers bug: the plan presentation and
  the first tagged quiz call usually share ONE assistant message, so
  the message-level seam drew Planning+Teaching back-to-back. New pure
  teachStepIndex(parts) finds the STEP boundary governing the flip
  call; the divider renders there (replacing that boundary's plain
  hairline — a labeled rule supersedes it), prepending before the whole
  message only when the flip is in its first step. The fold (chip,
  sidebar state) is untouched.
- JOBS SUITE, flake round 3: the sweep scans the SHARED jobs dir, so
  parallel test FILES' running records respawn into this file's
  counters — the assertions go jurisdictional (own-job ids collected;
  foreign respawns are not this test's business). 1331/1331.
This commit is contained in:
amosblomqvist
2026-10-05 06:08:51 +03:00
parent 6b58d87d9e
commit 450e0f78f0
5 changed files with 155 additions and 26 deletions
+32 -9
View File
@@ -50,6 +50,7 @@ import {
findPendingToolCall, findPendingToolCall,
phaseAt3, phaseAt3,
phaseTimeline, phaseTimeline,
teachStepIndex,
toolQuestionKind, toolQuestionKind,
quizNeedsReveal, quizNeedsReveal,
} from "@/src/core"; } from "@/src/core";
@@ -1238,15 +1239,26 @@ export function Chat({
{/* THE INFERRED TEACH SEAM (owner ruling 2026-09-26, the verdict {/* THE INFERRED TEACH SEAM (owner ruling 2026-09-26, the verdict
death): teaching begins where the FIRST nodeId-tagged quiz is death): teaching begins where the FIRST nodeId-tagged quiz is
posed — the fold (phaseTimeline, the same derivation the phase chip posed — the fold (phaseTimeline, the same derivation the phase chip
reads) flips to teach exactly AT this assistant group, so the divider reads) flips to teach exactly AT this assistant group. STEP-LEVEL
renders BEFORE the whole block (thinking -> prose -> the quiz call) — since owner round 8 (2026-10-05 — the stacked-dividers bug): the
where the explicit accept marker used to sit. The boundary check plan presentation often lives in the SAME message, an earlier
(this index flips, the previous didn't) guarantees exactly ONE divider provider step than the quiz call, and the old message-level seam
per session. PREPENDED, never a replacement branch: the teach group is drew this divider before the plan too (Planning and Teaching
a real message with content (the marker branches above have none) — a stacked back-to-back, nothing between). teachStepIndex finds the
replacement ternary swallowed the entire first lesson block (the step boundary governing the flip call: -1 = the message's first
Lacan session, 2026-09-27). */} step → prepend before the whole block (this branch); a later
{m.role === "assistant" && mi > 0 && phases[mi] === "teach" && phases[mi - 1] !== "teach" ? ( 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 ? (
<PhaseDivider key={`teach-${m.id || `m-${mi}`}`} phase="teach" /> <PhaseDivider key={`teach-${m.id || `m-${mi}`}`} phase="teach" />
) : null} ) : null}
{m.role === "user" ? ( {m.role === "user" ? (
@@ -1273,6 +1285,15 @@ export function Chat({
<MessageContent className="w-full"> <MessageContent className="w-full">
{m.parts.map((part, pi) => { {m.parts.map((part, pi) => {
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1; 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 // A tool call that errored BACK TO THE AGENT (validation
// failure, malformed args) is invisible work — the agent // failure, malformed args) is invisible work — the agent
// reads the error and retries. Owner ruling 2026-09-26: // reads the error and retries. Owner ruling 2026-09-26:
@@ -1302,6 +1323,8 @@ export function Chat({
</Reasoning> </Reasoning>
); );
case "step-start": case "step-start":
if (teachBoundary)
return <PhaseDivider key={`teach-step-${pi}`} phase="teach" />;
return pi > 0 ? <Separator key={pi} className="my-3" /> : null; return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
case "data-plan-block": case "data-plan-block":
// The plan in the chat (wave 4): a frozen snapshot // The plan in the chat (wave 4): a frozen snapshot
+22 -10
View File
@@ -17,6 +17,7 @@ import { Hint } from "@/components/ui/hint";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { fuzzyScore } from "@/src/core";
import { useServerEvent, useServerResync } from "@/components/server-events"; import { useServerEvent, useServerResync } from "@/components/server-events";
import type { ChatListItem } from "@/src/core"; import type { ChatListItem } from "@/src/core";
import { kbdEventForAction } from "@/src/core"; import { kbdEventForAction } from "@/src/core";
@@ -384,15 +385,22 @@ function SessionsSidebar() {
router.push("/chat/new"); router.push("/chat/new");
}, [router]); }, [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 shown = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim();
let list = chats; let list = chats;
if (list && kindFilter !== "all") { if (list && kindFilter !== "all") {
list = list.filter((c) => (c.kind ?? "teach") === kindFilter); list = list.filter((c) => (c.kind ?? "teach") === kindFilter);
} }
if (!list) return list; if (!list) return list;
if (!q) 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]); }, [chats, query, kindFilter]);
const refreshInFlightRef = useRef(false); const refreshInFlightRef = useRef(false);
@@ -688,14 +696,16 @@ function SessionsSidebar() {
</Hint> </Hint>
</div> </div>
{/* Two doors (practice sessions, owner 2026-10-04): teaching {/* Two doors, STACKED (owner round 8, 2026-10-05 — was a
keeps the original draft route; practice carries its mode. */} half/half row): full-width rows read cleaner and give the
<div className="mx-3 mb-1 flex gap-1.5"> labels room; teaching keeps the original draft route,
practice carries its mode. */}
<div className="mx-3 mb-1 flex flex-col gap-1.5">
<button <button
type="button" type="button"
data-testid="new-session" data-testid="new-session"
onClick={() => void newSession()} onClick={() => void newSession()}
className="flex flex-1 items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
> >
<PlusIcon className="size-3" /> <PlusIcon className="size-3" />
Teaching Teaching
@@ -705,7 +715,7 @@ function SessionsSidebar() {
type="button" type="button"
data-testid="new-session-practice" data-testid="new-session-practice"
onClick={() => router.push("/chat/new?mode=practice")} onClick={() => router.push("/chat/new?mode=practice")}
className="flex flex-1 items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
> >
<DumbbellIcon className="size-3" /> <DumbbellIcon className="size-3" />
Practice Practice
@@ -713,10 +723,12 @@ function SessionsSidebar() {
</button> </button>
</div> </div>
{/* The three-way filter (owner, 2026-10-04): Teaching / All / {/* The three-way filter (owner, 2026-10-04; order owner round 8,
Practice, quiet segmented, persisted client-side. */} 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. */}
<div className="mx-3 mb-1.5 flex items-center gap-0.5 rounded-md border border-border/60 bg-background p-0.5" data-testid="session-kind-filter"> <div className="mx-3 mb-1.5 flex items-center gap-0.5 rounded-md border border-border/60 bg-background p-0.5" data-testid="session-kind-filter">
{(["teach", "all", "practice"] as const).map((f) => ( {(["teach", "practice", "all"] as const).map((f) => (
<button <button
key={f} key={f}
type="button" type="button"
+39
View File
@@ -96,6 +96,45 @@ function advance(
return phase; return phase;
} }
/**
* WHERE inside the flipping message teaching begins (owner round 8,
* 2026-10-05 — the stacked-dividers bug): plan→teach flips at the first
* tagged quiz/explain CALL, but the plan PRESENTATION often lives in the
* same assistant message — an earlier provider STEP than the quiz call.
* A message-level seam therefore drew the Teaching divider before the
* plan presentation too (Planning and Teaching stacked back-to-back).
* This returns the part index of the STEP BOUNDARY governing the flip
* call: -1 when the call sits in the message's FIRST step (the divider
* belongs before the whole message), else the index of the step-start
* part whose generation contains the call — the divider renders there,
* between the plan presentation and the teaching block.
*/
export function teachStepIndex(
parts: ReadonlyArray<{ type: string; state?: string; input?: unknown }>,
): number {
let stepStarts = 0; // how many step-start parts seen so far
let latest = -1; // index of the latest step-start
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!;
if (part.type === "step-start") {
stepStarts++;
latest = i;
continue;
}
if (
(part.type === "tool-quiz" || part.type === "tool-explain") &&
taggedNodeId(part.input)
) {
// The call's governing step: 0–1 step-starts seen = the message's
// FIRST step → -1 (the divider belongs before the whole message);
// a later step → that step-start's index (the divider renders at
// the boundary, replacing the plain hairline there).
return stepStarts <= 1 ? -1 : latest;
}
}
return -1;
}
/** /**
* The phase at EVERY message index, in one forward fold — the transcript * 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 * dividers render one per boundary, so phaseAt3(m, i) per index would rescan
+14 -7
View File
@@ -1574,13 +1574,18 @@ describe("stale-job recovery sweep (liveness-gated)", () => {
// alone must NOT trigger recovery anymore. // alone must NOT trigger recovery anymore.
jobStore.updateJob(jobId, { updatedAt: new Date(Date.now() - 30 * 60_000).toISOString() }); jobStore.updateJob(jobId, { updatedAt: new Date(Date.now() - 30 * 60_000).toISOString() });
registerJobAbort(jobId, new AbortController()); // a loop is executing registerJobAbort(jobId, new AbortController()); // a loop is executing
let respawned = 0; // JURISDICTIONAL (owner round 8, 2026-10-05): the sweep scans the
setJobRespawn(() => { // SHARED jobs dir — under the full suite, OTHER FILES' processes
respawned++; // legitimately leave running records this sweep may respawn. Only
// THIS test's job is this test's business: collect ids, assert ours
// never respawns (the counter form saw foreign respawns as failures).
const respawnedIds: string[] = [];
setJobRespawn((id) => {
respawnedIds.push(id);
}); });
resetSweepThrottle(); resetSweepThrottle();
recoverStaleJobs(); recoverStaleJobs();
assert.equal(respawned, 0, "a live loop is provably executing — no respawn beside it"); assert.ok(!respawnedIds.includes(jobId), "a live loop is provably executing — no respawn beside it");
assert.equal(jobStore.getJob(jobId)!.activity, "starting", "the record is untouched"); assert.equal(jobStore.getJob(jobId)!.activity, "starting", "the record is untouched");
// Hermetic teardown: settle + unregister so later sweeps in THIS suite // Hermetic teardown: settle + unregister so later sweeps in THIS suite
// never see this fixture as a fresh orphan. // never see this fixture as a fresh orphan.
@@ -1590,13 +1595,15 @@ describe("stale-job recovery sweep (liveness-gated)", () => {
it("a running record WITHOUT a registration is an orphan: the loop is re-spawned immediately (no age wait)", () => { it("a running record WITHOUT a registration is an orphan: the loop is re-spawned immediately (no age wait)", () => {
const { jobId } = orphanJob(); const { jobId } = orphanJob();
let respawned: string | null = null; // JURISDICTIONAL (see above): foreign respawns may interleave under
// the full suite — ours must be among them, at once.
const respawnedIds: string[] = [];
setJobRespawn((id) => { setJobRespawn((id) => {
respawned = id; respawnedIds.push(id);
}); });
resetSweepThrottle(); resetSweepThrottle();
recoverStaleJobs(); recoverStaleJobs();
assert.equal(respawned, jobId, "the orphan's loop is re-spawned at once"); assert.ok(respawnedIds.includes(jobId), "the orphan's loop is re-spawned at once");
assert.equal(jobStore.getJob(jobId)!.activity, "recovering: re-running"); assert.equal(jobStore.getJob(jobId)!.activity, "recovering: re-running");
// Hermetic teardown (see above) — the respawned loop is the TEST's // Hermetic teardown (see above) — the respawned loop is the TEST's
// fake seam, nothing real runs. // fake seam, nothing real runs.
+48
View File
@@ -13,6 +13,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { PhaseChip, PhaseDivider, planNodeCount } from "../components/phase-divider.tsx"; import { PhaseChip, PhaseDivider, planNodeCount } from "../components/phase-divider.tsx";
import { teachStepIndex } from "../src/core/phases.ts";
describe("PhaseChip (header)", () => { describe("PhaseChip (header)", () => {
it("renders the learner-facing label — no colored dot (phase color retired)", () => { it("renders the learner-facing label — no colored dot (phase color retired)", () => {
@@ -79,6 +80,53 @@ describe("PhaseDivider (transcript boundary)", () => {
}); });
}); });
describe("teachStepIndex (WHERE teaching begins inside the flipping message — owner round 8)", () => {
const call = { type: "tool-quiz", input: { nodeId: "n1", question: "q?" } };
it("the flip call in the message's FIRST step → -1 (divider prepends before the message)", () => {
assert.equal(teachStepIndex([{ type: "step-start" }, { type: "text" }, call]), -1);
});
it("a later step: the boundary's step-start index — between plan presentation and teaching", () => {
// The stacked-dividers bug shape: plan_init + plan text, then a NEW
// step that poses the first tagged quiz. The seam is the SECOND
// step-start (index 3), not before the message.
const parts = [
{ type: "step-start" },
{ type: "tool-plan_init", state: "output-available" },
{ type: "text", text: "Here is your plan." },
{ type: "step-start" },
{ type: "text", text: "Let's begin." },
call,
];
assert.equal(teachStepIndex(parts), 3);
});
it("no step-start parts at all (defensive): first step → -1", () => {
assert.equal(teachStepIndex([{ type: "text" }, call]), -1);
});
it("an untagged quiz call is not the flip — scan continues to the tagged one", () => {
const parts = [
{ type: "step-start" },
{ type: "tool-quiz", input: { question: "probe quiz — no nodeId" } },
{ type: "step-start" },
call,
];
assert.equal(teachStepIndex(parts), 2);
});
it("explain calls carry nodeId too — same seam", () => {
const parts = [
{ type: "step-start" },
{ type: "text" },
{ type: "step-start" },
{ type: "tool-explain", input: { nodeId: "n2", topic: "t" } },
];
assert.equal(teachStepIndex(parts), 2);
});
});
describe("planNodeCount (the seam message's tree)", () => { describe("planNodeCount (the seam message's tree)", () => {
it("counts the first output-available plan tree in the message", () => { it("counts the first output-available plan tree in the message", () => {
const count = planNodeCount({ const count = planNodeCount({