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
+29 -1
View File
@@ -18,6 +18,8 @@ import type { ToolResultMessage } from "@earendil-works/pi-ai";
import {
needsContinuation,
newAnswerResult,
newProblemDismissedMarker,
newThinkingMessage,
newUserMessage,
projectView,
type ChatInput,
@@ -503,7 +505,12 @@ export class SessionModel {
// adoption can tell them from the record's absorbed copies.
const optimistic: LhMessage[] = [];
if (input.type === "text") optimistic.push({ ...newUserMessage(input.text), id: localId() });
else if (input.type === "answer") {
else if (input.type === "thinking") {
// The practice submissions (2026-10-04): the marker lands
// optimistically — the register (trace / commit) renders from
// its details the moment the button fires.
optimistic.push({ ...newThinkingMessage(input.problemCallId, input.commit, input.text), id: localId() });
} else if (input.type === "answer") {
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
}
@@ -605,6 +612,27 @@ export class SessionModel {
}
}
/** The problem discard (practice sessions, owner 2026-10-04): the
* ask-style dismissal — the UI-only marker lands optimistically, then
* the SILENT route persists it (no turn, no wake). The composer
* returns; the conclude synthesis rides the user's next input. */
async dismissProblem(problemCallId: string): Promise<void> {
if (!this.readyRef) return;
this.mutate(() => {
this.tail = [...this.tail, { ...newProblemDismissedMarker(problemCallId), id: localId() }];
});
try {
const res = await this.fetch(`/api/chats/${this.chatId}/dismiss`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: { type: "dismiss-problem", problemCallId } }),
});
if (!res.ok) throw new Error(`The dismissal failed (${res.status}).`);
} catch (err) {
this.setError((err as Error).message);
}
}
/** Local detach: cancel the consume, keep the partial tail. The page's
* settle watch adopts the record when the server turn finishes. */
stop(): void {