ui: the chat comeback — side-based authorship, restored quotes, tucked whys

Blockquotes: the callout override had displaced Streamdown's own styled
blockquote component, so plain quotes rendered as bare unstyled text
since that commit. The pass-through branch now restores Streamdown's
verbatim classes (border-l-4, muted italic).

Explanations tucked for ALL questions (owner): the prediction's Why /
Hide-why toggle is universal — every quiz answered card sits its
explanation behind the same collapsed button. Same pattern, one law.

The thinking row's rhythm: my-1 -> my-3. The 4px margin hugged the
options on non-graded answered cards (owner report); 12px matches the
card's mt-2/mt-3 spacing grammar everywhere the row renders.

Side-based authorship (owner round, 2026-10-07): the document shape
blurred user into agent — three left-rail idioms (user text, user
thinking, quoted prose) collided once quotes rendered. User rows go
RIGHT: 'You' messages and practice thinking cards hug the right edge at
85% width with the rail mirrored (border-r), text still reading
left-to-right. In-card thinking (inside the answered card, the agent's
context) stays left. Transcript thinking rows arrive EXPANDED — the
thinking was clearly sent. Cmd+T (toggle-user-thinking, chat scope,
cheat-sheet row) sets every user-thinking row to one state via a module
store: explicit sets stomp individual toggles, late mounts honor the
last set (the state initializer reads the store, so static render
reflects it too).
This commit is contained in:
amosblomqvist
2026-10-08 02:29:05 +03:00
parent cdf9305eea
commit d5c4c3bf9f
10 changed files with 192 additions and 41 deletions
+7 -15
View File
@@ -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). */
<div className="mt-2" data-testid="tucked-explanation">
<button
type="button"
@@ -161,20 +161,12 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
{whyOpen && (
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-foreground/50">
<span className="font-medium text-muted-foreground">Why: </span>
{/* The markdown wrapper must go truly inline — a block <p> after the
"Why:" label created the oversized leading gap (owner's fix list). */}
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
</div>
)}
</div>
) : (
isQuiz &&
answer.explanation && (
<div className="mt-2 rounded-lg bg-muted py-2 text-sm text-foreground/50">
<span className="font-medium text-muted-foreground">Why: </span>
{/* The markdown wrapper must go truly inline — a block <p> after the
"Why:" label created the oversized leading gap (owner's fix list). */}
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
</div>
)
)}
{/* The explain reveal (owner ruling 2026-09-27): the committed answer
+28 -2
View File
@@ -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. */
<div data-testid="user-message" className="my-1 w-full border-l-2 border-muted-foreground/30 pl-[calc(3ch-2px)] font-ui text-xs">
<div
data-testid="user-message"
/* SIDE-BASED AUTHORSHIP (owner round, 2026-10-07): the
document shape blurred user into agent — three left-rail
idioms (user text, user thinking, quoted prose) collided.
User rows go RIGHT: mirrored rail, ~85% width, text still
reading left-to-right (the chat convention). */
className="my-1 ml-auto w-[85%] border-r-2 border-muted-foreground/30 pr-[calc(3ch-2px)] font-ui text-xs"
>
{/* 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. */}
+15 -1
View File
@@ -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 <blockquote {...rest}>{children}</blockquote>;
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 <blockquote> rendered unstyled
// (owner report, 2026-10-07).
return (
<blockquote
{...rest}
className="my-4 border-l-4 border-muted-foreground/30 pl-4 text-muted-foreground italic"
data-streamdown="blockquote"
>
{children}
</blockquote>
);
}
return (
<div
data-testid="md-callout"
+6 -3
View File
@@ -291,7 +291,10 @@ function MetadataPopover({
export function ThinkingCard({ data }: { data: PracticeThinkingData }) {
// The unified row (owner rounds, 2026-10-05): one style, one label —
// collapsed arrival, "Your thinking", 50%-italic quote block on expand.
// Legacy records' commit flag is deliberately unread (one lane now).
return <ThinkingBlock testId="thinking-card" label="YOUR THINKING" text={data.text} />;
// 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 (
<ThinkingBlock testId="thinking-card" label="YOUR THINKING" text={data.text} mirror defaultOpen />
);
}
+70 -10
View File
@@ -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({
<div
data-testid={testId}
data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"}
className="lh-card-enter my-1 w-full border-l-2 border-muted-foreground/30 pl-[calc(3ch-2px)] font-ui text-xs"
data-side={mirror ? "right" : "left"}
className={cn(
// my-3 (owner, 2026-10-07): the row's own rhythm — 4px hugged the
// options on non-graded answered cards; 12px matches the card's
// mt-2/mt-3 spacing grammar.
"lh-card-enter my-3 font-ui text-xs",
mirror
? "ml-auto w-[85%] border-r-2 border-muted-foreground/30 pr-[calc(3ch-2px)]"
: "w-full border-l-2 border-muted-foreground/30 pl-[calc(3ch-2px)]",
)}
>
<button
type="button"