diff --git a/components/answered-card.tsx b/components/answered-card.tsx index 5b900af..70cb9f2 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -145,11 +145,11 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { {/* px-3→removed: card p-4 + box px-3 double-inset sat the "Why" label deeper than the option box edges (owner, round 3). Sides flush with everything else; py-2 stays for vertical air. */} - {isQuiz && answer.explanation && answer.questionType === "predict" ? ( - /* The TUCKED explanation (owner 2026-10-03): a prediction's - verdict shows, but the explanation sits collapsed — viewable, - never center stage. The teaching that follows resolves the - prediction; THAT is the real explanation. */ + {isQuiz && answer.explanation && ( + /* The TUCKED explanation — ALL questions (owner, 2026-10-07): the + verdict shows, the explanation sits collapsed behind the Why + toggle — viewable, never center stage. Identical to the + prediction pattern it generalizes (owner, 2026-10-03). */
)} - ) : ( - isQuiz && - answer.explanation && ( -
- Why: - {/* The markdown wrapper must go truly inline — a block

after the - "Why:" label created the oversized leading gap (owner's fix list). */} - {answer.explanation} -

- ) )} {/* The explain reveal (owner ruling 2026-09-27): the committed answer diff --git a/components/chat.tsx b/components/chat.tsx index 72006fa..fe8ad09 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -31,7 +31,15 @@ import { useFieldCycle } from "@/components/field-cycle"; import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; import { useServerEvent, useServerResync } from "@/components/server-events"; -import { isProviderKeyRefusal, needsContinuation, type ChatInput, type ChatSettings, type LhMessage, type ModelSlotName } from "@/src/core"; +import { + isProviderKeyRefusal, + kbdEventForAction as kbdEventForChat, + needsContinuation, + type ChatInput, + type ChatSettings, + type LhMessage, + type ModelSlotName, +} from "@/src/core"; import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; import { PlanMarginalia, planExists } from "@/components/plan-window"; import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay"; @@ -63,6 +71,7 @@ import { quizNeedsReveal, } from "@/src/core"; import { ReadingThinkingField } from "@/components/reading-thinking"; +import { currentUserThinkingOpen, setAllUserThinkingOpen } from "@/components/thinking-block"; import { slotsConfigured, type ModelsSettings } from "@/src/core"; import { useSettingsSaved } from "@/components/settings-signal"; @@ -493,6 +502,15 @@ export function Chat({ * separate `readingThinking` field. Reset per pending question. */ const [readingThinking, setReadingThinking] = useState(""); const readingThinkingRef = useRef(""); + + // ⌘T (toggle-user-thinking, owner round 2026-10-07): set EVERY user + // thinking row to one state — transcript rows default open, so the + // first press collapses everything, the second re-expands. + useEffect(() => { + const onToggle = () => setAllUserThinkingOpen(!currentUserThinkingOpen()); + window.addEventListener(kbdEventForChat("toggle-user-thinking"), onToggle); + return () => window.removeEventListener(kbdEventForChat("toggle-user-thinking"), onToggle); + }, []); const applyReadingThinking = useCallback((v: string) => { readingThinkingRef.current = v; setReadingThinking(v); @@ -1379,7 +1397,15 @@ export function Chat({ User text renders plain — the model's LaTeX pipeline is not applied to learner-authored text ($ stays $, no surprises). Rendered WITHOUT the Message wrapper — that is the bubble. */ -
+
{/* The rail GROUPS (owner round 4, 2026-10-05): the YOU heading sits INSIDE the rail, same mono-xs register and same 1ch rail as the thinking rows — one quote geometry. */} diff --git a/components/md-blocks.tsx b/components/md-blocks.tsx index 77d7423..87c5ed5 100644 --- a/components/md-blocks.tsx +++ b/components/md-blocks.tsx @@ -335,7 +335,21 @@ export function parseAlertMarker(children: ReactNode): ParsedAlert | null { export function MarkdownBlockquote(props: ComponentProps<"blockquote"> & ExtraProps) { const { children, node, ...rest } = props; const alert = parseAlertMarker(children); - if (!alert) return
{children}
; + if (!alert) { + // Plain quote — Streamdown's own styling, restored by hand: overriding + // `blockquote` displaced its styled component (the classes below are + // its verbatim defaults), and bare
rendered unstyled + // (owner report, 2026-10-07). + return ( +
+ {children} +
+ ); + } return (
; + // 50%-italic quote block on expand. Legacy records' commit flag is + // deliberately unread (one lane now). A TRANSCRIPT row (owner round, + // 2026-10-07): mirrored to the right side, arriving expanded. + return ( + + ); } diff --git a/components/thinking-block.tsx b/components/thinking-block.tsx index dc259df..d026972 100644 --- a/components/thinking-block.tsx +++ b/components/thinking-block.tsx @@ -1,35 +1,86 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ChevronDownIcon } from "lucide-react"; import { cn } from "@/lib/utils"; /** The unified user-thinking row (owner round, 2026-10-05): ONE visual * language for every surface where the learner's own thinking renders — * practice thinking traces, the reading channel lifted above the answered - * card, the quiz answer's thinking inside its container. It arrives - * COLLAPSED as a trigger row in the exact register of the agent's - * reasoning rows (muted label + chevron + hover affordance); expanding - * opens the quote block — rail, 50% opacity, italics. The italics + the - * half-opacity text are the inner-voice signature; the rail marks it as - * learner material. No shell, no border: document material, full width. + * card, the quiz answer's thinking inside its container. It arrives as + * a trigger row in the exact register of the agent's reasoning rows + * (muted label + chevron + hover affordance); expanding opens the quote + * block — rail, 50% opacity, italics. The italics + the half-opacity + * text are the inner-voice signature; the rail marks it as learner + * material. No shell, no border: document material, full width. * * The glance-level law (owner): user thinking = rail + 50% italic; - * agent thinking = no rail. */ + * agent thinking = no rail. + * + * SIDE-BASED AUTHORSHIP (owner round, 2026-10-07): transcript-level rows + * go RIGHT — mirrored rail (border-right), block hugging the right edge + * at ~85% width, text still reading left-to-right. In-card rows (inside + * the answered card, the agent's context) stay left. `mirror` selects + * the side; `defaultOpen` marks the transcript rows, which arrive + * EXPANDED (owner: clearly conveyed that the thinking was sent). + * + * ⌘T (toggle-user-thinking) sets EVERY row to one state via the module + * store below — one source of truth, no provider tree: rows subscribe on + * mount; explicit sets (and only those) stomp individual toggles. */ + +// ── The ⌘T store (module-scope, one per page load) ───────────────────────── +// null = "rows run their own defaults" (the pre-toggle state); the first ⌘T +// resolves from `true` (transcript rows default open) and then alternates. +let allUserThinkingOpen: boolean | null = null; +const userThinkingListeners = new Set<(open: boolean) => void>(); + +export function currentUserThinkingOpen(): boolean { + return allUserThinkingOpen ?? true; +} + +/** The row's starting state: an explicit ⌘T set wins over the row's own + * default (late mounts honor the last set — same path as the live sync, + * readable at render time so static SSR reflects the store too). */ +function resolveUserThinkingOpen(defaultOpen: boolean): boolean { + return allUserThinkingOpen ?? defaultOpen; +} + +export function setAllUserThinkingOpen(open: boolean) { + allUserThinkingOpen = open; + for (const l of userThinkingListeners) l(open); +} + +function subscribeUserThinking(fn: (open: boolean) => void): () => void { + userThinkingListeners.add(fn); + if (allUserThinkingOpen !== null) fn(allUserThinkingOpen); // late mounts honor the last set + return () => userThinkingListeners.delete(fn); +} export function ThinkingBlock({ label = "YOUR THINKING", text, testId, dataCommit, + mirror = false, + defaultOpen = false, }: { label?: string; text: string; testId?: string; /** The practice marker's register flag (commit vs nudge). */ dataCommit?: boolean; + /** Right-side placement (transcript rows): mirrored rail, right edge. */ + mirror?: boolean; + /** Transcript rows arrive expanded (owner, 2026-10-07). */ + defaultOpen?: boolean; }) { - const [open, setOpen] = useState(false); // arrives collapsed, always + const [open, setOpen] = useState(() => resolveUserThinkingOpen(defaultOpen)); + // ⌘T: an explicit global set overrides every row (once set, rows re-sync). + const syncedRef = useRef(false); + useEffect( + () => subscribeUserThinking((o) => { syncedRef.current = true; setOpen(o); }), + [], + ); return ( /* The rail GROUPS and the rail IS THE MISSING CHARACTER (owner rounds, 2026-10-05): the heading sits INSIDE the rail, and the @@ -44,7 +95,16 @@ export function ThinkingBlock({