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:
amosblomqvist
2026-10-05 02:29:16 +03:00
parent d19df86b13
commit 5d253a933c
6 changed files with 142 additions and 65 deletions
+34
View File
@@ -328,6 +328,40 @@ function cleanToolCallIds(messages: readonly LhMessage[]): Set<string> {
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(
messages: readonly LhMessage[],
): { toolCallId: string; input: unknown } | null {