practice polish: no phantom wake, Practice header tag, honest meta

- needsContinuation: a persisted problem-pose ack (terminate:true ended
  the turn deliberately) owes no continuation — reloads after a pose no
  longer wake a phantom turn; error results stay owed like any
  interrupted tool.
- Chat header: practice sessions swap the phase chip for a quiet
  Practice tag (no probe→teach arc applies) and hide the coverage ring
  (practice never extracts — an ever-filling ring would lie).
- ChatSummary gains pendingProblem (activeProblemOf at projection); the
  sidebar meta says 'Problem waiting' for a practice row with a posed
  problem. Kind threads through Chat/[id] page.
- Home picker chip gains Clear (picks were otherwise irremovable).
This commit is contained in:
amosblomqvist
2026-10-04 22:06:22 +03:00
parent e76e42f764
commit 9362baf36e
9 changed files with 101 additions and 7 deletions
+1
View File
@@ -24,6 +24,7 @@ export default async function ChatPage({
key={chat.id}
chatId={chat.id}
title={chat.title}
kind={chat.kind ?? "teach"}
// Wave D: the record IS native agent history — the client view is
// derived app-side (core/view.ts projectView; GET serves the same
// native messages).
+25 -3
View File
@@ -33,7 +33,7 @@ import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanMarginalia, planExists } from "@/components/plan-window";
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
import { AbortTurnDialog, type AbortMode } from "@/components/abort-turn-dialog";
import { WaypointsIcon } from "lucide-react";
import { DumbbellIcon, WaypointsIcon } from "lucide-react";
import type { PlanBlockData } from "@/src/core";
import { QuestionDock } from "@/components/question-dock";
import { PracticeDock } from "@/components/practice-dock";
@@ -176,10 +176,15 @@ export function Chat({
initialPrompt,
initiallyInFlight,
initialConfigured = false,
kind = "teach",
}: {
chatId: string;
title: string;
initialMessages: LhMessage[];
/** The session kind (practice sessions, owner 2026-10-04): practice
* renders no phase chip (no probe→teach arc applies) and a Practice
* tag rides the header instead. */
kind?: "teach" | "practice";
initialPrompt?: string;
/** A teacher turn is running server-side but attached to no client
* (navigated away mid-turn, returned while in flight). */
@@ -1146,8 +1151,25 @@ export function Chat({
with uncovered vs the chunk threshold, pulses while a run is in
flight, vanishes when the graph is off. Click → the exact
numbers. Before the phase label — the chips cluster right. */}
<CoverageRing chatId={chatId} refreshKey={coverageTick} />
<PhaseChip phase={phases.at(-1) ?? "probe"} />
{/* Practice (owner, 2026-10-04): the coverage ring hides —
practice never extracts, so an ever-filling ring with no
run behind it would lie. */}
{kind !== "practice" && <CoverageRing chatId={chatId} refreshKey={coverageTick} />}
{/* Practice sessions: no phase arc — a quiet Practice tag
instead of the phase chip. */}
{kind === "practice" ? (
<span
data-testid="phase-chip"
data-phase="practice"
aria-hidden="true"
className="pointer-events-none flex shrink-0 select-none items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
<DumbbellIcon className="size-3" />
Practice
</span>
) : (
<PhaseChip phase={phases.at(-1) ?? "probe"} />
)}
</TopBar>
<div className="relative flex min-h-0 flex-1">
+11 -1
View File
@@ -187,7 +187,7 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
the chain table as the picker. Quiet, left-aligned above
the composer; teaching mode renders nothing. */}
{mode === "practice" && (
<div className="mb-2 flex justify-start">
<div className="mb-2 flex items-center justify-start gap-1.5">
<button
type="button"
data-testid="practice-props-chip"
@@ -199,6 +199,16 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
? `${practiceProps.length} ${practiceProps.length === 1 ? "proposition" : "propositions"} selected`
: "Pick propositions from the map"}
</button>
{practiceProps.length > 0 && (
<button
type="button"
data-testid="practice-props-clear"
onClick={() => setPracticeProps([])}
className="rounded-full px-2 py-1 font-ui text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Clear
</button>
)}
</div>
)}
{zeroKeys ? (
+3
View File
@@ -227,6 +227,9 @@ export function timeAgo(iso: string, now = new Date()): string {
* ever wanted. */
function sessionMeta(chat: ChatListItem): string {
if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`;
if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) {
return `Problem waiting · ${timeAgo(chat.updatedAt)}`;
}
if (chat.busy) return (chat.kind ?? "teach") === "practice" ? "Practicing…" : "Teacher is working…";
return timeAgo(chat.updatedAt);
}
+3
View File
@@ -233,6 +233,9 @@ export interface ChatSummary {
phase: Phase3;
/** An unanswered quiz/ask question is waiting for the learner. */
pendingQuestion: boolean;
/** Practice sessions (2026-10-04): a posed problem with no conclude —
* the sidebar meta's "Problem waiting". Absent on teaching sessions. */
pendingProblem: boolean;
/** The record's model-control settings — rides the list payload via the
* summary spread (the picker never needs it from here; the full record
* is its source). */
+11 -2
View File
@@ -515,9 +515,18 @@ export function needsContinuation(messages: readonly LhMessage[]): boolean {
if (!owner || (owner as { role?: string }).role !== "assistant") return false;
const content = (owner as { content?: unknown }).content;
if (!Array.isArray(content)) return false;
const answered = new Set(
messages.slice(start).map((m) => (m as ToolResultMessage).toolCallId),
const results = messages.slice(start) as ToolResultMessage[];
// PRACTICE (owner design, 2026-10-04): a batch acking a problem POSE
// owes nothing — terminate:true ended that turn DELIBERATELY (the
// learner's next thinking submission is the next turn), and unlike a
// question's placeholder this ack PERSISTS (the model reads its own
// "Problem posed" result later). Without this fold a reload after a
// pose would wake a phantom continuation turn.
const posedProblem = results.some(
(m) => m.toolName === PRACTICE_PROBLEM_TOOL && !m.isError && m.details == null,
);
if (posedProblem) return false;
const answered = new Set(results.map((m) => m.toolCallId));
const calls = (content as Array<{ type?: string; id?: string }>).filter(
(b) => b.type === "toolCall" && typeof b.id === "string",
);
+1 -1
View File
@@ -14,7 +14,7 @@ import { emit } from "../core/events.ts";
import type { ChatStore } from "../core/ports.ts";
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
import type { LhMessage } from "../core/native-messages.ts";
import { danglingQuestionOf } from "../core/native-messages.ts";
import { activeProblemOf, danglingQuestionOf } from "../core/native-messages.ts";
import { phaseAt3Lh } from "../core/phases.ts";
function chatFile(id: string): string {
+40
View File
@@ -17,6 +17,7 @@ import {
concludedByDismissalPair,
newProblemDismissedMarker,
newThinkingMessage,
needsContinuation,
newUserMessage,
pendingProblemDismissalOf,
toLlmMessages,
@@ -262,3 +263,42 @@ describe("toLlmMessages (practice shapes)", () => {
assert.equal(out[1]!.role, "toolResult");
});
});
// ── the continuation fold's practice carve-out ──────────────────────────────
describe("needsContinuation (a persisted pose ack owes nothing)", () => {
it("a record ending on a problem-pose ack does NOT wake a continuation", () => {
// The pose's terminate:true ended that turn deliberately; the
// ack persists (unlike a question's placeholder). Without the
// carve-out, a reload after a posed problem would wake a
// phantom turn (owner design, 2026-10-04).
assert.equal(needsContinuation(problemPose()), false);
});
it("a mixed batch (conclude ack then pose ack) still owes nothing — the pose is terminal", () => {
const messages = [
...problemPose("call_p0", "the earlier one"),
concludeCall("solved"),
{
id: "msg_r_conclude",
role: "toolResult",
toolCallId: "call_conclude_solved",
toolName: PRACTICE_CONCLUDE_TOOL,
content: [{ type: "text", text: CONCLUDE_ACK_TEXT }],
details: null,
isError: false,
timestamp: 4,
} as LhMessage & ToolResultMessage,
...problemPose("call_p2", "the next one"),
];
assert.equal(needsContinuation(messages), false);
});
it("an ERROR result is not a pose — the agent loop continues past it, so a record ending there (an abort) is owed the wake like any interrupted tool", () => {
const messages = [...problemPose("call_pe")];
const err = messages[1] as LhMessage & ToolResultMessage;
err.isError = true;
err.content = [{ type: "text", text: "A problem is already active." }];
assert.equal(needsContinuation(messages), true);
});
});
+6
View File
@@ -27,6 +27,7 @@ function item(overrides: Partial<ChatListItem>): ChatListItem {
messageCount: 4,
phase: "probe",
pendingQuestion: false,
pendingProblem: false,
busy: false,
...overrides,
};
@@ -135,4 +136,9 @@ describe("SessionRow practice badge (practice sessions, 2026-10-04)", () => {
const html = render(item({ busy: true }));
assert.match(html, /Teacher is working…/);
});
it("a practice row with a posed problem says the problem is waiting", () => {
const html = render(item({ kind: "practice", pendingProblem: true }));
assert.match(html, /Problem waiting ·/);
});
});