ui: ProblemCard/ConcludeCard redesigned — the solution is the hero
Owner round (2026-10-05), Slice C:
- ProblemCard: ALWAYS OPEN — the answered card's container + eyebrow
discipline ('Problem' label, lh-prose statement), no chevron, no
collapsed bar. The dock mirrors it in Slice D.
- ConcludeCard: the reveal is the modelSolution committed at POSE time,
shown verbatim as the hero — never re-authored (the slim schema's
law). The verdict rides below in the quiz-explanation register
(small, muted 'Verdict:' row). Nothing collapsible. Legacy inputs
with their own solutionReveal fall back to it.
- The reveal join: modelSolutionForConclude (native-messages.ts) — the
newest cleanly-posed problem before the conclude call; refused poses
reveal nothing. projectView enriches tool-conclude parts with
part.reveal at projection; ArtifactPartProps carries the field
(contracts/chat.ts).
Pinned: always-open problem card; hero-first conclude layout with the
verdict after it; legacy fallback; the join (clean pose, refused pose,
multi-problem records) and the view enrichment.
This commit is contained in:
@@ -27,6 +27,8 @@ import {
|
||||
} from "../src/core/native-messages.ts";
|
||||
import { CONCLUDE_ACK_TEXT } from "../src/core/contracts/chat.ts";
|
||||
import { dismissAppends, inputAppends, parseChatInput, parseDismissInput } from "../src/core/input-events.ts";
|
||||
import { modelSolutionForConclude } from "../src/core/native-messages.ts";
|
||||
import { projectView } from "../src/core/index.ts";
|
||||
|
||||
// ── builders (native-shaped test fixtures) ───────────────────────────────────
|
||||
|
||||
@@ -50,7 +52,7 @@ function problemPose(callId = "call_p1", statement = "Show that…"): LhMessage[
|
||||
id: `msg_a_${callId}`,
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: callId, name: PRACTICE_PROBLEM_TOOL, arguments: { statement, mode: "recall" } },
|
||||
{ type: "toolCall", id: callId, name: PRACTICE_PROBLEM_TOOL, arguments: { statement, mode: "recall", modelSolution: statement } },
|
||||
],
|
||||
timestamp: 1,
|
||||
...STAMPS,
|
||||
@@ -110,7 +112,7 @@ describe("activeProblemOf (the conclude-before-next-problem gate's fold)", () =>
|
||||
const messages = problemPose();
|
||||
const active = activeProblemOf(messages);
|
||||
assert.equal(active?.toolCallId, "call_p1");
|
||||
assert.deepEqual(active?.input, { statement: "Show that…", mode: "recall" });
|
||||
assert.deepEqual(active?.input, { statement: "Show that…", mode: "recall", modelSolution: "Show that…" });
|
||||
});
|
||||
|
||||
it("thinking submissions keep it active (nudges never conclude)", () => {
|
||||
@@ -345,3 +347,39 @@ describe("needsContinuation (a persisted pose ack owes nothing)", () => {
|
||||
assert.equal(needsContinuation(messages), true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ── the reveal join (owner round, 2026-10-05) ───────────────────────────────
|
||||
|
||||
describe("modelSolutionForConclude (the conclusion's hero is the pose's commitment)", () => {
|
||||
it("returns the cleanly-posed problem's modelSolution before the conclude", () => {
|
||||
const messages = [...problemPose(), ...concludeCall("solved")];
|
||||
assert.equal(modelSolutionForConclude(messages, "call_conclude_solved"), "Show that…");
|
||||
});
|
||||
|
||||
it("skips refused poses — a rejected pose revealed nothing", () => {
|
||||
const rejected = problemPose("call_bad", "A different student…");
|
||||
(rejected[1]! as unknown as { isError: boolean }).isError = true;
|
||||
const messages = [...rejected, ...concludeCall("solved")];
|
||||
assert.equal(modelSolutionForConclude(messages, "call_conclude_solved"), undefined);
|
||||
});
|
||||
|
||||
it("the newest pose before ITS conclude wins (multi-problem records)", () => {
|
||||
const messages = [
|
||||
...problemPose("call_p1", "first"),
|
||||
...concludeCall("solved"),
|
||||
...problemPose("call_p2", "second"),
|
||||
...concludeCall("movedOn"),
|
||||
];
|
||||
assert.equal(modelSolutionForConclude(messages, "call_conclude_movedOn"), "second");
|
||||
assert.equal(modelSolutionForConclude(messages, "call_conclude_solved"), "first");
|
||||
});
|
||||
|
||||
it("projectView enriches the conclude part with the reveal", () => {
|
||||
const messages = [...problemPose(), ...concludeCall("solved")];
|
||||
const view = projectView(messages);
|
||||
const conclude = view.flatMap((m) => m.parts).find((p) => p.type === "tool-conclude");
|
||||
assert.ok(conclude, "the conclude part projects");
|
||||
assert.equal(conclude!.reveal, "Show that…", "the view-seam join carries the committed solution");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("PracticeDock", () => {
|
||||
// ── the transcript cards ─────────────────────────────────────────────────────
|
||||
|
||||
describe("the practice transcript cards", () => {
|
||||
it("ProblemCard: the statement renders — the commitment NEVER does", () => {
|
||||
it("ProblemCard: ALWAYS OPEN, the 'Problem' eyebrow + the statement — the commitment NEVER renders", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ProblemCard
|
||||
part={part({
|
||||
@@ -84,33 +84,48 @@ describe("the practice transcript cards", () => {
|
||||
statement: "Prove the parity claim.",
|
||||
mode: "recall",
|
||||
modelSolution: "Pair them.",
|
||||
decisionPoints: ["pairing"],
|
||||
expectedWrongMoves: [{ move: "induction", resolution: "pairing is direct" }],
|
||||
strategy: "near-twin",
|
||||
rationale: "weakest target",
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
assert.match(html, /data-testid="problem-card"/);
|
||||
assert.match(html, /Problem/);
|
||||
assert.match(html, /Prove the parity claim\./);
|
||||
for (const hidden of ["Pair them.", "induction", "near-twin", "weakest target", "pairing"]) {
|
||||
assert.doesNotMatch(html, new RegExp(hidden.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `commitment leaked: ${hidden}`);
|
||||
}
|
||||
assert.doesNotMatch(html, /Pair them\./, "the commitment stays hidden");
|
||||
// No chevron, no collapsed bar (owner round, 2026-10-05).
|
||||
assert.doesNotMatch(html, /lucide-chevron|<button/, "nothing collapsible");
|
||||
});
|
||||
|
||||
it("ConcludeCard: outcome badge + verdict headline; the reveal expands", () => {
|
||||
it("ConcludeCard: the SOLUTION is the hero (the pose's modelSolution via part.reveal); the verdict rides below, low-key", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConcludeCard
|
||||
part={part({
|
||||
type: "tool-conclude",
|
||||
input: { outcome: "solvedWithHints", verdict: "The pairing came after R2.", solutionReveal: "Pair them; each pair is even." },
|
||||
input: { outcome: "solvedWithHints", verdict: "The pairing came after R2." },
|
||||
reveal: "Pair them; each pair is even.",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
assert.match(html, /data-testid="conclude-card"/);
|
||||
assert.match(html, /Solved with hints/);
|
||||
assert.match(html, /Pair them; each pair is even\./, "the committed solution, verbatim");
|
||||
assert.match(html, /Verdict:/);
|
||||
assert.match(html, /The pairing came after R2\./);
|
||||
// The verdict row renders AFTER the solution (the hero first).
|
||||
assert.ok(html.indexOf("Pair them; each pair is even.") < html.indexOf("The pairing came after R2."));
|
||||
assert.doesNotMatch(html, /<button/, "nothing collapsible");
|
||||
});
|
||||
|
||||
it("ConcludeCard: legacy records fall back to their own solutionReveal", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConcludeCard
|
||||
part={part({
|
||||
type: "tool-conclude",
|
||||
input: { outcome: "solved", verdict: "Clean.", solutionReveal: "Pair them; each pair is even." },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
assert.match(html, /Pair them; each pair is even\./);
|
||||
});
|
||||
|
||||
it("ConcludeCard: the dismissal synthesis is one muted line — no reveal card", () => {
|
||||
|
||||
Reference in New Issue
Block a user