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:
@@ -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
@@ -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
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ·/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user