practice UI: the dock, the transcript cards, layered-Esc discard

- PracticeDock (components/practice-dock.tsx): the active problem
  replaces the composer — statement (markdown+LaTeX) above, the 'Your
  thinking' composer-twin below with the two submit lanes (Nudge =
  thinking without a commit; Commit = answer commit; ⌘/Ctrl+Enter
  commits). Dictation claims the thinking field.
- Transcript cards (practice-cards.tsx + the artifact registry): the
  problem card (compact-expandable statement — the commitment NEVER
  renders), the conclude card (outcome + verdict + reveal; the
  dismissal synthesis is one muted line), and the user-side thinking
  register cards (Your thinking / Your solution).
- view.ts: the problem pose joins the placeholder fork (the ack never
  flips the part); practice-thinking markers project as data parts;
  problem-dismissed markers project nothing.
- chat.tsx: activeProblemOf drives the dock branch (question > problem
  > composer), the esc layering gains the problem rung (below question,
  above turn) with its discard confirm + the silent marker route; the
  thinking submissions POST through the session's optimistic appends.
This commit is contained in:
amosblomqvist
2026-10-04 21:55:54 +03:00
parent 4bea0b46df
commit 59bf5bf219
10 changed files with 650 additions and 19 deletions
+30 -2
View File
@@ -220,6 +220,18 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
return out;
}
/** The dock-owned tools: question tools (client-owned results — the dock
* mounts on the dangling call) and the practice pose (its ack is FINAL —
* details: null, never replaced — and the card + dock read the call's
* input; the placeholder must never flip the part to output-available).
* PRACTICE (2026-10-04): problem joins the placeholder fork; conclude is
* a NORMAL tool part (its result acks with no payload, details null —
* but the ConcludeCard reads the call's input, and output-available with
* output undefined renders fine). */
function isPlaceholderResultTool(name: string): boolean {
return (QUESTION_TOOLS as readonly string[]).includes(name) || name === "problem";
}
/** A tool call block → its view part, with the live execution layer. */
function toolPartOf(
block: { id: string; name: string; arguments?: unknown },
@@ -231,7 +243,7 @@ function toolPartOf(
input: block.arguments,
};
const exec = live?.executions.get(block.id);
const isQuestion = (QUESTION_TOOLS as readonly string[]).includes(block.name);
const isQuestion = isPlaceholderResultTool(block.name);
if (exec) {
if (exec.isError) {
// An invalid call NEVER becomes input-available — the dock's mount
@@ -267,7 +279,7 @@ function applyResultTo(part: LhViewPart | undefined, result: ToolResultMessage):
// Flipping the part to output-available on the placeholder would hand
// AnsweredCard a null and crash the render (the live crash, 2026-09-26).
const tool = part.type.startsWith("tool-") ? part.type.slice("tool-".length) : part.type;
const isQuestion = (QUESTION_TOOLS as readonly string[]).includes(tool);
const isQuestion = isPlaceholderResultTool(tool);
if (isQuestion && !result.isError && result.details == null) return;
applyResult(part, result);
}
@@ -313,6 +325,22 @@ function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void {
});
return;
}
case "practice-thinking": {
// The learner's working trace / committed answer (practice
// sessions): a user-side card keyed on the structured details
// (problemCallId, commit, text) — the register lives in the card,
// not re-parsed from content.
out.push({
id: marker.id,
role: "user",
parts: [{ type: "data-practice-thinking", id: `pt_${marker.id}`, data: marker.details }],
});
return;
}
case "problem-dismissed":
// UI-only (the ask-style dismissal): the synthesized conclude
// pair — which rides the NEXT input — carries the record's face.
return;
case "plan-block": {
// No message of its own: splice into the preceding assistant
// group, IN FLOW after the last successful plan_init call.