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
+4
View File
@@ -270,5 +270,9 @@ export interface ArtifactPartProps {
toolCallId: string;
input?: 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;
};
}
+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 {
+9 -1
View File
@@ -34,6 +34,7 @@
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL } from "./native-messages.ts";
import { QUESTION_TOOLS } from "./pending.ts";
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.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") {
reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
} 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];