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:
+32
-9
@@ -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 ? (
|
||||
<PhaseDivider key={`teach-${m.id || `m-${mi}`}`} phase="teach" />
|
||||
) : null}
|
||||
{m.role === "user" ? (
|
||||
@@ -1273,6 +1285,15 @@ export function Chat({
|
||||
<MessageContent className="w-full">
|
||||
{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({
|
||||
</Reasoning>
|
||||
);
|
||||
case "step-start":
|
||||
if (teachBoundary)
|
||||
return <PhaseDivider key={`teach-step-${pi}`} phase="teach" />;
|
||||
return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
|
||||
case "data-plan-block":
|
||||
// The plan in the chat (wave 4): a frozen snapshot
|
||||
|
||||
+22
-10
@@ -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() {
|
||||
</Hint>
|
||||
</div>
|
||||
|
||||
{/* Two doors (practice sessions, owner 2026-10-04): teaching
|
||||
keeps the original draft route; practice carries its mode. */}
|
||||
<div className="mx-3 mb-1 flex gap-1.5">
|
||||
{/* 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. */}
|
||||
<div className="mx-3 mb-1 flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="new-session"
|
||||
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" />
|
||||
Teaching
|
||||
@@ -705,7 +715,7 @@ function SessionsSidebar() {
|
||||
type="button"
|
||||
data-testid="new-session-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" />
|
||||
Practice
|
||||
@@ -713,10 +723,12 @@ function SessionsSidebar() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<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
|
||||
key={f}
|
||||
type="button"
|
||||
|
||||
@@ -96,6 +96,45 @@ function advance(
|
||||
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
|
||||
* dividers render one per boundary, so phaseAt3(m, i) per index would rescan
|
||||
|
||||
+14
-7
@@ -1574,13 +1574,18 @@ describe("stale-job recovery sweep (liveness-gated)", () => {
|
||||
// alone must NOT trigger recovery anymore.
|
||||
jobStore.updateJob(jobId, { updatedAt: new Date(Date.now() - 30 * 60_000).toISOString() });
|
||||
registerJobAbort(jobId, new AbortController()); // a loop is executing
|
||||
let respawned = 0;
|
||||
setJobRespawn(() => {
|
||||
respawned++;
|
||||
// JURISDICTIONAL (owner round 8, 2026-10-05): the sweep scans the
|
||||
// SHARED jobs dir — under the full suite, OTHER FILES' processes
|
||||
// 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();
|
||||
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");
|
||||
// Hermetic teardown: settle + unregister so later sweeps in THIS suite
|
||||
// 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)", () => {
|
||||
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) => {
|
||||
respawned = id;
|
||||
respawnedIds.push(id);
|
||||
});
|
||||
resetSweepThrottle();
|
||||
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");
|
||||
// Hermetic teardown (see above) — the respawned loop is the TEST's
|
||||
// fake seam, nothing real runs.
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
import { teachStepIndex } from "../src/core/phases.ts";
|
||||
|
||||
describe("PhaseChip (header)", () => {
|
||||
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)", () => {
|
||||
it("counts the first output-available plan tree in the message", () => {
|
||||
const count = planNodeCount({
|
||||
|
||||
Reference in New Issue
Block a user