ui: the document look — one language for every thinking, no bubbles

Owner round 2 (2026-10-05), Slice F:

- USER TEXT: the right-aligned bubble is gone. User messages are
  full-width document rows — muted 'You' label, muted quote rail,
  full-opacity text. Learner words are content, not a chat-app tell.
- USER THINKING (everywhere): 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 inner-voice signature. The ThinkingBlock loses
  its shell/border: pure document material.
- QUIZ CARDS: the answer's thinking is the SAME collapsible row, same
  placement, just inside the container; the explanation drops to 50%
  opacity (upright — italics stays the thinking signature).
- AGENT'S THINKING: the duration bookkeeping is gone (it was live-turn
  state that died to 'a few seconds' on remount anyway) — the settled
  row reads static 'Agent's thinking', pairing with 'Your thinking'.
  Spinner + shimmer stay the working signal while streaming.
- Glance law: user thinking = rail + 50% italic; agent thinking = no
  rail; user text = upright, full opacity, 'You' label.
This commit is contained in:
amosblomqvist
2026-10-05 03:41:39 +03:00
parent a5a554582e
commit e2284e723a
7 changed files with 97 additions and 96 deletions
+2 -2
View File
@@ -286,7 +286,7 @@ body {
} }
.dock-variant { .dock-variant {
animation: dock-variant-in 160ms cubic-bezier(0.2, 0.8, 0.2, 1); animation: dock-variant-in 120ms cubic-bezier(0.2, 0.8, 0.2, 1);
} }
/* ============================================================ /* ============================================================
@@ -548,7 +548,7 @@ body {
} }
.lh-card-enter { .lh-card-enter {
animation: lh-card-enter 150ms ease-out; animation: lh-card-enter 120ms ease-out;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
+13 -34
View File
@@ -31,7 +31,6 @@ interface ReasoningContextValue {
isStreaming: boolean; isStreaming: boolean;
isOpen: boolean; isOpen: boolean;
setIsOpen: (open: boolean) => void; setIsOpen: (open: boolean) => void;
duration: number | undefined;
} }
const ReasoningContext = createContext<ReasoningContextValue | null>(null); const ReasoningContext = createContext<ReasoningContextValue | null>(null);
@@ -49,11 +48,9 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
open?: boolean; open?: boolean;
defaultOpen?: boolean; defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
duration?: number;
}; };
const AUTO_CLOSE_DELAY = 1000; const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo( export const Reasoning = memo(
({ ({
@@ -62,7 +59,6 @@ export const Reasoning = memo(
open, open,
defaultOpen, defaultOpen,
onOpenChange, onOpenChange,
duration: durationProp,
children, children,
...props ...props
}: ReasoningProps) => { }: ReasoningProps) => {
@@ -79,27 +75,8 @@ export const Reasoning = memo(
onChange: onOpenChange, onChange: onOpenChange,
prop: open, prop: open,
}); });
const [duration, setDuration] = useControllableState<number | undefined>({
defaultProp: undefined,
prop: durationProp,
});
const hasEverStreamedRef = useRef(isStreaming); const hasEverStreamedRef = useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = useState(false); const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming, setDuration]);
// Auto-close when streaming ends (once only, and only if it ever streamed) // Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => { useEffect(() => {
@@ -126,8 +103,8 @@ export const Reasoning = memo(
); );
const contextValue = useMemo( const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }), () => ({ isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen] [isOpen, isStreaming, setIsOpen]
); );
return ( return (
@@ -148,17 +125,19 @@ export const Reasoning = memo(
export type ReasoningTriggerProps = ComponentProps< export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger typeof CollapsibleTrigger
> & { > & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode; getThinkingMessage?: (isStreaming: boolean) => ReactNode;
}; };
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => { /* The settled label (owner round, 2026-10-05): static "Agent's thinking" —
if (isStreaming || duration === 0) { the duration was live-turn state that died on remount anyway ("thought
for a few seconds"), bookkeeping for information we knowingly lose. The
label pairs with the user's collapsed "Your thinking" rows — one
language; motion (spinner + shimmer) stays the working signal. */
const defaultGetThinkingMessage = (isStreaming: boolean) => {
if (isStreaming) {
return <Shimmer duration={1}>Thinking...</Shimmer>; return <Shimmer duration={1}>Thinking...</Shimmer>;
} }
if (duration === undefined) { return <p>Agent's thinking</p>;
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
}; };
export const ReasoningTrigger = memo( export const ReasoningTrigger = memo(
@@ -168,7 +147,7 @@ export const ReasoningTrigger = memo(
getThinkingMessage = defaultGetThinkingMessage, getThinkingMessage = defaultGetThinkingMessage,
...props ...props
}: ReasoningTriggerProps) => { }: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning(); const { isStreaming, isOpen } = useReasoning();
return ( return (
<CollapsibleTrigger <CollapsibleTrigger
@@ -188,7 +167,7 @@ export const ReasoningTrigger = memo(
spinner (owner, 2026-10-02): the handoff between the two spinner (owner, 2026-10-02): the handoff between the two
states must be visually seamless, one size everywhere. */} states must be visually seamless, one size everywhere. */}
{isStreaming && <BrailleSpinner label="Thinking" />} {isStreaming && <BrailleSpinner label="Thinking" />}
{getThinkingMessage(isStreaming, duration)} {getThinkingMessage(isStreaming)}
<ChevronDownIcon <ChevronDownIcon
className={cn( className={cn(
"size-4 transition-transform", "size-4 transition-transform",
+10 -11
View File
@@ -41,7 +41,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
ThinkingBlock, instead of an inset inside the answer. Same muted ThinkingBlock, instead of an inset inside the answer. Same muted
rail as before; the label is now plain "Your thinking". */} rail as before; the label is now plain "Your thinking". */}
{answer.readingThinking && ( {answer.readingThinking && (
<ThinkingBlock testId="reading-thinking-block" label="Your thinking" text={answer.readingThinking} rail="muted" /> <ThinkingBlock testId="reading-thinking-block" label="Your thinking" text={answer.readingThinking} />
)} )}
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card"> <div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card">
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
@@ -135,12 +135,11 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
)} )}
{(answer.thinking ?? answer.note) && ( {(answer.thinking ?? answer.note) && (
<div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3"> /* The SAME unified row (owner round, 2026-10-05): the quiz answer's
<span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground"> thinking is the identical collapsible ThinkingBlock — it just
Your thinking happens to sit inside the quiz container. Same placement, same
</span> trigger, same 50%-italic quote block on expand. */
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.thinking ?? answer.note}</span> <ThinkingBlock testId="your-thinking" label="Your thinking" text={answer.thinking ?? answer.note ?? ""} />
</div>
)} )}
{/* px-3→removed: card p-4 + box px-3 double-inset sat the "Why" label deeper {/* px-3→removed: card p-4 + box px-3 double-inset sat the "Why" label deeper
@@ -160,8 +159,8 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
{whyOpen ? "Hide why" : "Why"} {whyOpen ? "Hide why" : "Why"}
</button> </button>
{whyOpen && ( {whyOpen && (
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-muted-foreground"> <div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-foreground/50">
<span className="font-medium text-foreground">Why: </span> <span className="font-medium text-muted-foreground">Why: </span>
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse> <MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
</div> </div>
)} )}
@@ -169,8 +168,8 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
) : ( ) : (
isQuiz && isQuiz &&
answer.explanation && ( answer.explanation && (
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground"> <div className="mt-3 rounded-lg bg-muted py-2 text-sm text-foreground/50">
<span className="font-medium text-foreground">Why: </span> <span className="font-medium text-muted-foreground">Why: </span>
{/* The markdown wrapper must go truly inline — a block <p> after the {/* The markdown wrapper must go truly inline — a block <p> after the
"Why:" label created the oversized leading gap (owner's fix list). */} "Why:" label created the oversized leading gap (owner's fix list). */}
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse> <MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
+25 -13
View File
@@ -1249,16 +1249,28 @@ export function Chat({
{m.role === "assistant" && mi > 0 && phases[mi] === "teach" && phases[mi - 1] !== "teach" ? ( {m.role === "assistant" && mi > 0 && phases[mi] === "teach" && phases[mi - 1] !== "teach" ? (
<PhaseDivider key={`teach-${m.id || `m-${mi}`}`} phase="teach" /> <PhaseDivider key={`teach-${m.id || `m-${mi}`}`} phase="teach" />
) : null} ) : null}
<Message from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}> {m.role === "user" ? (
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}> /* THE DOCUMENT ROW (owner round, 2026-10-05): no right-aligned
{m.role === "user" ? ( bubble — the transcript reads as a document. User text is a
// User text renders plain: the model's LaTeX pipeline is not full-width quote block: muted "You" label, muted rail,
// applied to learner-authored text ($ stays $, no surprises). FULL-opacity text (learner words are content, not whisper).
<div className="lh-prose whitespace-pre-wrap"> Distinguished from user thinking at a glance: thinking is
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} collapsed + 50% italic; this is upright and present.
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">
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">You</div>
<div className="border-l-2 border-muted-foreground/30 pl-3 text-foreground">
<div className="lh-prose whitespace-pre-wrap">
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
</div>
</div> </div>
) : ( </div>
m.parts.map((part, pi) => { ) : (
<Message from="assistant" className="max-w-full">
<MessageContent className="w-full">
{m.parts.map((part, pi) => {
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1; const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
// A tool call that errored BACK TO THE AGENT (validation // A tool call that errored BACK TO THE AGENT (validation
// failure, malformed args) is invisible work — the agent // failure, malformed args) is invisible work — the agent
@@ -1329,10 +1341,10 @@ export function Chat({
return null; return null;
} }
} }
}) })}
)} </MessageContent>
</MessageContent>
</Message> </Message>
)}
</Fragment> </Fragment>
))} ))}
@@ -1983,7 +1995,7 @@ function DockOverlay({
measureBlock(); measureBlock();
if (p && Math.abs(p.height - h) > 1) { if (p && Math.abs(p.height - h) > 1) {
el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], { el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], {
duration: 180, duration: 120,
easing: "cubic-bezier(0.2, 0.8, 0.2, 1)", easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
}); });
} }
+4 -4
View File
@@ -97,16 +97,16 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
} }
export function ThinkingCard({ data }: { data: PracticeThinkingData }) { export function ThinkingCard({ data }: { data: PracticeThinkingData }) {
// One component, two registers (owner ruling): the committed register // The unified row (owner round, 2026-10-05): one style — collapsed
// carries the primary rail (the quiz answer thinking's paint), the // arrival, 50%-italic quote block on expand. The register survives only
// working register the muted one. // in the label ("Your solution" when committed) and the data-commit
// flag for the tests.
return ( return (
<ThinkingBlock <ThinkingBlock
testId="thinking-card" testId="thinking-card"
dataCommit={data.commit} dataCommit={data.commit}
label={data.commit ? "Your solution" : "Your thinking"} label={data.commit ? "Your solution" : "Your thinking"}
text={data.text} text={data.text}
rail={data.commit ? "primary" : "muted"}
/> />
); );
} }
+31 -25
View File
@@ -1,46 +1,52 @@
"use client"; "use client";
import { useState } from "react";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** The unified user-thinking block (owner round, 2026-10-05): ONE visual /** The unified user-thinking row (owner round, 2026-10-05): ONE visual
* language for every surface where the learner's own thinking renders — * language for every surface where the learner's own thinking renders —
* practice thinking traces (nudge / commit), the reading channel lifted * practice thinking traces, the reading channel lifted above the answered
* above the answered card. The recipe is the quiz cards' quote block * card, the quiz answer's thinking inside its container. It arrives
* verbatim (rail + uppercase label + prose), carried by a faint shell * COLLAPSED as a trigger row in the exact register of the agent's
* so it still reads user-side at a glance — right-aligned, message * reasoning rows (muted label + chevron + hover affordance); expanding
* width, never collapsible (they arrive open; collapsing bought * opens the quote block — rail, 50% opacity, italics. The italics + the
* nothing and cost the chevron-on-the-right weirdness). * half-opacity text are the inner-voice signature; the rail marks it as
* learner material. No shell, no border: document material, full width.
* *
* Rails reuse the existing vocabulary: primary for the committed * The glance-level law (owner): user thinking = rail + 50% italic;
* register (the quiz answer thinking, practice commits), muted for the * agent thinking = no rail. */
* working register (nudges, reading thoughts). */
export function ThinkingBlock({ export function ThinkingBlock({
label, label = "Your thinking",
text, text,
rail = "muted",
testId, testId,
dataCommit, dataCommit,
}: { }: {
label: string; label?: string;
text: string; text: string;
rail?: "primary" | "muted";
testId?: string; testId?: string;
/** The practice marker's register flag (commit vs nudge). */ /** The practice marker's register flag (commit vs nudge). */
dataCommit?: boolean; dataCommit?: boolean;
}) { }) {
const [open, setOpen] = useState(false); // arrives collapsed, always
return ( return (
<div <div data-testid={testId} data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"} className="lh-card-enter my-1 w-full">
data-testid={testId} <button
data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"} type="button"
className="lh-card-enter ml-auto max-w-[95%] rounded-2xl border border-border/60 bg-muted/40 px-4 py-3" data-testid={testId ? `${testId}-trigger` : undefined}
> onClick={() => setOpen((v) => !v)}
<div className={cn("border-l-2 pl-3", rail === "primary" ? "border-primary/40" : "border-muted-foreground/30")}> aria-expanded={open}
<span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground"> className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
{label} >
</span> {label}
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{text}</span> <ChevronDownIcon className={cn("size-4 transition-transform", open ? "rotate-180" : "rotate-0")} />
</div> </button>
{open && (
<div className="mt-1.5 border-l-2 border-muted-foreground/30 pl-3">
<span className="lh-prose block whitespace-pre-wrap italic text-foreground/50">{text}</span>
</div>
)}
</div> </div>
); );
} }
+12 -7
View File
@@ -286,9 +286,13 @@ describe("render smoke tests", () => {
thinking: "I picked Venus because it looks brightest — but now I realize brightness isn't distance.", thinking: "I picked Venus because it looks brightest — but now I realize brightness isn't distance.",
}); });
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />); const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
assert.ok(html.includes('data-testid="your-thinking"'), "the thinking block exists"); assert.ok(html.includes('data-testid="your-thinking"'), "the thinking row exists");
assert.match(html, /Your thinking/, "labeled as the learner's voice"); assert.match(html, /Your thinking/, "labeled as the learner's voice");
assert.match(html, /brightness isn&#x27;t distance/, "the thinking itself renders"); // COLLAPSED ARRIVAL (owner round 2, 2026-10-05): the SSR output is the
// trigger row only — the thinking text is one click away, never loud.
assert.ok(html.includes('data-testid="your-thinking-trigger"'), "the trigger row");
assert.ok(html.includes('aria-expanded="false"'), "arrives collapsed");
assert.ok(!html.includes("brightness isn&#x27;t distance"), "the text is hidden until expanded");
assert.ok(!html.includes("Note:"), "no longer a muted 'Note:' footnote"); assert.ok(!html.includes("Note:"), "no longer a muted 'Note:' footnote");
// The thinking sits ABOVE the verdict's explanation — the dialogue of // The thinking sits ABOVE the verdict's explanation — the dialogue of
// thinking, not an afterthought. // thinking, not an afterthought.
@@ -580,8 +584,8 @@ describe("predict vs check rendering", () => {
}); });
const html = renderToStaticMarkup(<AnsweredCard answer={withReading} />); const html = renderToStaticMarkup(<AnsweredCard answer={withReading} />);
// The unified ThinkingBlock (owner round, 2026-10-05): the reading // The unified ThinkingBlock (owner round, 2026-10-05): the reading
// thoughts render BEFORE the card, user-side (right shell), under the // thoughts render BEFORE the card as the collapsed user-thinking row
// plain "Your thinking" label — no longer an inset inside the answer. // — same trigger, same 50%-italic quote block on expand.
const blockAt = html.indexOf("reading-thinking-block"); const blockAt = html.indexOf("reading-thinking-block");
const cardAt = html.indexOf("answered-card"); const cardAt = html.indexOf("answered-card");
assert.ok(blockAt !== -1, "the lifted block renders"); assert.ok(blockAt !== -1, "the lifted block renders");
@@ -589,10 +593,11 @@ describe("predict vs check rendering", () => {
assert.ok(html.includes("Your thinking"), "the plain label"); assert.ok(html.includes("Your thinking"), "the plain label");
assert.ok(!html.includes("Your thinking while reading"), "the old verbose label is gone"); assert.ok(!html.includes("Your thinking while reading"), "the old verbose label is gone");
assert.ok(!html.includes("your-reading-thinking"), "the in-card inset is gone"); assert.ok(!html.includes("your-reading-thinking"), "the in-card inset is gone");
assert.ok(html.includes("answering thought"), "the answer thinking stays inside the card"); // COLLAPSED ARRIVAL: SSR shows the triggers, not the texts.
assert.ok(html.includes("was unsure mid-paragraph")); assert.ok(html.includes('data-testid="your-thinking-trigger"'), "the answer thinking stays inside the card");
assert.ok(!html.includes("answering thought") && !html.includes("was unsure mid-paragraph"), "texts hidden until expanded");
const legacy = { ...buildAnswer(quizQD(), { values: ["Mercury"] }), note: "a 2026-09 answer" }; const legacy = { ...buildAnswer(quizQD(), { values: ["Mercury"] }), note: "a 2026-09 answer" };
const legacyHtml = renderToStaticMarkup(<AnsweredCard answer={legacy} />); const legacyHtml = renderToStaticMarkup(<AnsweredCard answer={legacy} />);
assert.ok(legacyHtml.includes("a 2026-09 answer"), "records from before the rename still render"); assert.ok(legacyHtml.includes("a 2026-09 answer") || legacyHtml.includes('data-testid="your-thinking-trigger"'), "records from before the rename still render");
}); });
}); });