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:
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ChevronRightIcon } from "lucide-react";
|
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
import type { ArtifactPartProps } from "@/src/core";
|
import type { ArtifactPartProps } from "@/src/core";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The practice transcript cards (practice sessions, owner design
|
* The practice transcript cards (practice sessions, owner design
|
||||||
@@ -32,25 +29,18 @@ export interface PracticeThinkingData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProblemCard({ part }: ArtifactPartProps) {
|
export function ProblemCard({ part }: ArtifactPartProps) {
|
||||||
const [open, setOpen] = useState(false);
|
// Always open, no chevron (owner round, 2026-10-05): the record IS the
|
||||||
|
// statement — same container + eyebrow discipline as the answered card
|
||||||
|
// and the dock (which mirrors this in Slice D). The commitment never
|
||||||
|
// renders — only the call's statement.
|
||||||
const input = (part.input ?? {}) as { statement?: string };
|
const input = (part.input ?? {}) as { statement?: string };
|
||||||
const statement = typeof input.statement === "string" ? input.statement : "";
|
const statement = typeof input.statement === "string" ? input.statement : "";
|
||||||
return (
|
return (
|
||||||
<div data-testid="problem-card" className="my-1">
|
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
|
||||||
<button
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
||||||
type="button"
|
<div className="lh-prose text-[15px]">
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className={cn("size-4 shrink-0 transition-transform", open && "rotate-90")} />
|
|
||||||
<span className="font-medium">Problem</span>
|
|
||||||
<span className="min-w-0 flex-1 truncate">{statement}</span>
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="lh-prose mt-1 rounded-lg border border-border/60 bg-card/60 px-4 py-3 text-[15px]">
|
|
||||||
<MessageResponse>{statement}</MessageResponse>
|
<MessageResponse>{statement}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -64,15 +54,17 @@ const OUTCOME_LABEL: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ConcludeCard({ part }: ArtifactPartProps) {
|
export function ConcludeCard({ part }: ArtifactPartProps) {
|
||||||
const [open, setOpen] = useState(false);
|
// The conclusion redesigned (owner round, 2026-10-05): the SOLUTION is
|
||||||
const input = (part.input ?? {}) as {
|
// the hero — the modelSolution committed at pose time (part.reveal, the
|
||||||
outcome?: string;
|
// view-seam join), shown verbatim, never re-authored. The verdict sits
|
||||||
verdict?: string;
|
// below in the quiz-explanation register (small, muted, low-key).
|
||||||
solutionReveal?: string;
|
// Nothing collapsible. Legacy inputs (pre-slim records) that carry
|
||||||
};
|
// their own solutionReveal fall back to it.
|
||||||
|
const input = (part.input ?? {}) as { outcome?: string; verdict?: string; solutionReveal?: string };
|
||||||
const outcome = typeof input.outcome === "string" ? input.outcome : "";
|
const outcome = typeof input.outcome === "string" ? input.outcome : "";
|
||||||
const verdict = typeof input.verdict === "string" ? input.verdict : "";
|
const verdict = typeof input.verdict === "string" ? input.verdict : "";
|
||||||
const reveal = typeof input.solutionReveal === "string" ? input.solutionReveal : "";
|
const reveal =
|
||||||
|
typeof part.reveal === "string" ? part.reveal : typeof input.solutionReveal === "string" ? input.solutionReveal : "";
|
||||||
|
|
||||||
// The dismissal synthesis: no verdict, no reveal — one muted line (the
|
// The dismissal synthesis: no verdict, no reveal — one muted line (the
|
||||||
// learner clicked; the record needs only the fact).
|
// learner clicked; the record needs only the fact).
|
||||||
@@ -85,35 +77,21 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="conclude-card" className="my-1">
|
<div data-testid="conclude-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
|
||||||
<button
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
type="button"
|
{OUTCOME_LABEL[outcome] ?? "Concluded"}
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className={cn("size-4 shrink-0 transition-transform", open && "rotate-90")} />
|
|
||||||
<span className="font-medium">{OUTCOME_LABEL[outcome] ?? "Concluded"}</span>
|
|
||||||
<span className="min-w-0 flex-1 truncate">{verdict}</span>
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="mt-1 space-y-2 rounded-lg border border-border/60 bg-card/60 px-4 py-3 text-[15px]">
|
|
||||||
{verdict.length > 0 && (
|
|
||||||
<div className="text-sm text-foreground">
|
|
||||||
<MessageResponse>{verdict}</MessageResponse>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{reveal.length > 0 && (
|
|
||||||
<div className="border-t border-border/50 pt-2">
|
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
The solution
|
|
||||||
</div>
|
|
||||||
<div className="lh-prose">
|
|
||||||
<MessageResponse>{reveal}</MessageResponse>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{reveal.length > 0 && (
|
||||||
|
<div className="lh-prose text-[15px]">
|
||||||
|
<MessageResponse>{reveal}</MessageResponse>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{verdict.length > 0 && (
|
||||||
|
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">Verdict: </span>
|
||||||
|
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{verdict}</MessageResponse>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,5 +270,9 @@ export interface ArtifactPartProps {
|
|||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
input?: unknown;
|
input?: unknown;
|
||||||
output?: unknown;
|
output?: unknown;
|
||||||
|
/** The view-seam join for conclude cards (owner round, 2026-10-05):
|
||||||
|
* the modelSolution committed at POSE time, folded onto the part by
|
||||||
|
* projectView — the reveal is never re-authored. */
|
||||||
|
reveal?: unknown;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,6 +328,40 @@ function cleanToolCallIds(messages: readonly LhMessage[]): Set<string> {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The conclusion's reveal (owner round, 2026-10-05): the hero of a
|
||||||
|
* conclude card is the modelSolution committed at POSE time — the newest
|
||||||
|
* cleanly-posed problem BEFORE this conclude call. The reveal is never
|
||||||
|
* re-authored (the slim schema dropped solutionReveal); legacy conclude
|
||||||
|
* inputs that still carry their own solutionReveal fall back to it at the
|
||||||
|
* card. Undefined when the pose carries no string solution. */
|
||||||
|
export function modelSolutionForConclude(
|
||||||
|
messages: readonly LhMessage[],
|
||||||
|
concludeCallId: string,
|
||||||
|
): string | undefined {
|
||||||
|
let mi = messages.findIndex(
|
||||||
|
(m) =>
|
||||||
|
(m as { role?: string }).role === "assistant" &&
|
||||||
|
(m as AssistantMessage).content.some(
|
||||||
|
(b) => b.type === "toolCall" && b.id === concludeCallId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mi < 0) return undefined;
|
||||||
|
const clean = cleanToolCallIds(messages);
|
||||||
|
for (; mi >= 0; mi--) {
|
||||||
|
const m = messages[mi]!;
|
||||||
|
if ((m as { role?: string }).role !== "assistant") continue;
|
||||||
|
const assistant = m as AssistantMessage;
|
||||||
|
for (let bi = assistant.content.length - 1; bi >= 0; bi--) {
|
||||||
|
const block = assistant.content[bi]!;
|
||||||
|
if (block.type !== "toolCall" || block.name !== PRACTICE_PROBLEM_TOOL) continue;
|
||||||
|
if (!clean.has(block.id)) continue; // a refused pose revealed nothing
|
||||||
|
const solution = (block.arguments as { modelSolution?: unknown } | undefined)?.modelSolution;
|
||||||
|
return typeof solution === "string" ? solution : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function activeProblemOf(
|
export function activeProblemOf(
|
||||||
messages: readonly LhMessage[],
|
messages: readonly LhMessage[],
|
||||||
): { toolCallId: string; input: unknown } | null {
|
): { toolCallId: string; input: unknown } | null {
|
||||||
|
|||||||
+9
-1
@@ -34,6 +34,7 @@
|
|||||||
|
|
||||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
||||||
|
import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL } from "./native-messages.ts";
|
||||||
import { QUESTION_TOOLS } from "./pending.ts";
|
import { QUESTION_TOOLS } from "./pending.ts";
|
||||||
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
|
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
|
||||||
import type { PlanBlockData } from "./contracts/chat.ts";
|
import type { PlanBlockData } from "./contracts/chat.ts";
|
||||||
@@ -194,7 +195,14 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
|
|||||||
} else if (block.type === "thinking") {
|
} else if (block.type === "thinking") {
|
||||||
reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
|
reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
|
||||||
} else if (block.type === "toolCall") {
|
} else if (block.type === "toolCall") {
|
||||||
rest.push(toolPartOf(block, live, streamingMessage));
|
const part = toolPartOf(block, live, streamingMessage);
|
||||||
|
// The conclusion's reveal join (owner round, 2026-10-05): the
|
||||||
|
// hero of a conclude card is the modelSolution committed at POSE
|
||||||
|
// time — a record fold, enriched here so the card renders pure.
|
||||||
|
if (block.name === PRACTICE_CONCLUDE_TOOL) {
|
||||||
|
part.reveal = modelSolutionForConclude(messages, block.id);
|
||||||
|
}
|
||||||
|
rest.push(part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const parts: LhViewPart[] = [...reasoning, ...rest];
|
const parts: LhViewPart[] = [...reasoning, ...rest];
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
} from "../src/core/native-messages.ts";
|
} from "../src/core/native-messages.ts";
|
||||||
import { CONCLUDE_ACK_TEXT } from "../src/core/contracts/chat.ts";
|
import { CONCLUDE_ACK_TEXT } from "../src/core/contracts/chat.ts";
|
||||||
import { dismissAppends, inputAppends, parseChatInput, parseDismissInput } from "../src/core/input-events.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) ───────────────────────────────────
|
// ── builders (native-shaped test fixtures) ───────────────────────────────────
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ function problemPose(callId = "call_p1", statement = "Show that…"): LhMessage[
|
|||||||
id: `msg_a_${callId}`,
|
id: `msg_a_${callId}`,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [
|
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,
|
timestamp: 1,
|
||||||
...STAMPS,
|
...STAMPS,
|
||||||
@@ -110,7 +112,7 @@ describe("activeProblemOf (the conclude-before-next-problem gate's fold)", () =>
|
|||||||
const messages = problemPose();
|
const messages = problemPose();
|
||||||
const active = activeProblemOf(messages);
|
const active = activeProblemOf(messages);
|
||||||
assert.equal(active?.toolCallId, "call_p1");
|
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)", () => {
|
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);
|
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 ─────────────────────────────────────────────────────
|
// ── the transcript cards ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("the practice 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(
|
const html = renderToStaticMarkup(
|
||||||
<ProblemCard
|
<ProblemCard
|
||||||
part={part({
|
part={part({
|
||||||
@@ -84,33 +84,48 @@ describe("the practice transcript cards", () => {
|
|||||||
statement: "Prove the parity claim.",
|
statement: "Prove the parity claim.",
|
||||||
mode: "recall",
|
mode: "recall",
|
||||||
modelSolution: "Pair them.",
|
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, /data-testid="problem-card"/);
|
||||||
|
assert.match(html, /Problem/);
|
||||||
assert.match(html, /Prove the parity claim\./);
|
assert.match(html, /Prove the parity claim\./);
|
||||||
for (const hidden of ["Pair them.", "induction", "near-twin", "weakest target", "pairing"]) {
|
assert.doesNotMatch(html, /Pair them\./, "the commitment stays hidden");
|
||||||
assert.doesNotMatch(html, new RegExp(hidden.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `commitment leaked: ${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(
|
const html = renderToStaticMarkup(
|
||||||
<ConcludeCard
|
<ConcludeCard
|
||||||
part={part({
|
part={part({
|
||||||
type: "tool-conclude",
|
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, /data-testid="conclude-card"/);
|
||||||
assert.match(html, /Solved with hints/);
|
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\./);
|
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", () => {
|
it("ConcludeCard: the dismissal synthesis is one muted line — no reveal card", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user