From e2284e723a2523f6f636d11f2c2e43a5537b3919 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 5 Oct 2026 03:41:39 +0300 Subject: [PATCH] =?UTF-8?q?ui:=20the=20document=20look=20=E2=80=94=20one?= =?UTF-8?q?=20language=20for=20every=20thinking,=20no=20bubbles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/globals.css | 4 +- components/ai-elements/reasoning.tsx | 47 +++++++---------------- components/answered-card.tsx | 21 +++++------ components/chat.tsx | 38 ++++++++++++------- components/practice-cards.tsx | 8 ++-- components/thinking-block.tsx | 56 +++++++++++++++------------- tests/render-cards.test.tsx | 19 ++++++---- 7 files changed, 97 insertions(+), 96 deletions(-) diff --git a/app/globals.css b/app/globals.css index c213836..0b62d28 100644 --- a/app/globals.css +++ b/app/globals.css @@ -286,7 +286,7 @@ body { } .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 { - animation: lh-card-enter 150ms ease-out; + animation: lh-card-enter 120ms ease-out; } @media (prefers-reduced-motion: reduce) { diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx index a8cce0e..ecb828d 100644 --- a/components/ai-elements/reasoning.tsx +++ b/components/ai-elements/reasoning.tsx @@ -31,7 +31,6 @@ interface ReasoningContextValue { isStreaming: boolean; isOpen: boolean; setIsOpen: (open: boolean) => void; - duration: number | undefined; } const ReasoningContext = createContext(null); @@ -49,11 +48,9 @@ export type ReasoningProps = ComponentProps & { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; - duration?: number; }; const AUTO_CLOSE_DELAY = 1000; -const MS_IN_S = 1000; export const Reasoning = memo( ({ @@ -62,7 +59,6 @@ export const Reasoning = memo( open, defaultOpen, onOpenChange, - duration: durationProp, children, ...props }: ReasoningProps) => { @@ -79,27 +75,8 @@ export const Reasoning = memo( onChange: onOpenChange, prop: open, }); - const [duration, setDuration] = useControllableState({ - defaultProp: undefined, - prop: durationProp, - }); - const hasEverStreamedRef = useRef(isStreaming); const [hasAutoClosed, setHasAutoClosed] = useState(false); - const startTimeRef = useRef(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) useEffect(() => { @@ -126,8 +103,8 @@ export const Reasoning = memo( ); const contextValue = useMemo( - () => ({ duration, isOpen, isStreaming, setIsOpen }), - [duration, isOpen, isStreaming, setIsOpen] + () => ({ isOpen, isStreaming, setIsOpen }), + [isOpen, isStreaming, setIsOpen] ); return ( @@ -148,17 +125,19 @@ export const Reasoning = memo( export type ReasoningTriggerProps = ComponentProps< typeof CollapsibleTrigger > & { - getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode; + getThinkingMessage?: (isStreaming: boolean) => ReactNode; }; -const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => { - if (isStreaming || duration === 0) { +/* The settled label (owner round, 2026-10-05): static "Agent's thinking" — + 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 Thinking...; } - if (duration === undefined) { - return

Thought for a few seconds

; - } - return

Thought for {duration} seconds

; + return

Agent's thinking

; }; export const ReasoningTrigger = memo( @@ -168,7 +147,7 @@ export const ReasoningTrigger = memo( getThinkingMessage = defaultGetThinkingMessage, ...props }: ReasoningTriggerProps) => { - const { isStreaming, isOpen, duration } = useReasoning(); + const { isStreaming, isOpen } = useReasoning(); return ( } - {getThinkingMessage(isStreaming, duration)} + {getThinkingMessage(isStreaming)} + )}
@@ -135,12 +135,11 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { )} {(answer.thinking ?? answer.note) && ( -
- - Your thinking - - {answer.thinking ?? answer.note} -
+ /* The SAME unified row (owner round, 2026-10-05): the quiz answer's + thinking is the identical collapsible ThinkingBlock — it just + happens to sit inside the quiz container. Same placement, same + trigger, same 50%-italic quote block on expand. */ + )} {/* 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 && ( -
- Why: +
+ Why: {answer.explanation}
)} @@ -169,8 +168,8 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { ) : ( isQuiz && answer.explanation && ( -
- Why: +
+ 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} diff --git a/components/chat.tsx b/components/chat.tsx index 1926cce..30d99b8 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1249,16 +1249,28 @@ export function Chat({ {m.role === "assistant" && mi > 0 && phases[mi] === "teach" && phases[mi - 1] !== "teach" ? ( ) : null} - - - {m.role === "user" ? ( - // User text renders plain: the model's LaTeX pipeline is not - // applied to learner-authored text ($ stays $, no surprises). -

- {m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} + {m.role === "user" ? ( + /* THE DOCUMENT ROW (owner round, 2026-10-05): no right-aligned + bubble — the transcript reads as a document. User text is a + full-width quote block: muted "You" label, muted rail, + FULL-opacity text (learner words are content, not whisper). + Distinguished from user thinking at a glance: thinking is + 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. */ +
+
You
+
+
+ {m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} +
- ) : ( - m.parts.map((part, pi) => { +
+ ) : ( + + + {m.parts.map((part, pi) => { const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1; // A tool call that errored BACK TO THE AGENT (validation // failure, malformed args) is invisible work — the agent @@ -1329,10 +1341,10 @@ export function Chat({ return null; } } - }) - )} - + })} + + )} ))} @@ -1983,7 +1995,7 @@ function DockOverlay({ measureBlock(); if (p && Math.abs(p.height - h) > 1) { el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], { - duration: 180, + duration: 120, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)", }); } diff --git a/components/practice-cards.tsx b/components/practice-cards.tsx index 4574f92..f97f210 100644 --- a/components/practice-cards.tsx +++ b/components/practice-cards.tsx @@ -97,16 +97,16 @@ export function ConcludeCard({ part }: ArtifactPartProps) { } export function ThinkingCard({ data }: { data: PracticeThinkingData }) { - // One component, two registers (owner ruling): the committed register - // carries the primary rail (the quiz answer thinking's paint), the - // working register the muted one. + // The unified row (owner round, 2026-10-05): one style — collapsed + // arrival, 50%-italic quote block on expand. The register survives only + // in the label ("Your solution" when committed) and the data-commit + // flag for the tests. return ( ); } diff --git a/components/thinking-block.tsx b/components/thinking-block.tsx index 0299017..74ec809 100644 --- a/components/thinking-block.tsx +++ b/components/thinking-block.tsx @@ -1,46 +1,52 @@ "use client"; +import { useState } from "react"; +import { ChevronDownIcon } from "lucide-react"; 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 — - * practice thinking traces (nudge / commit), the reading channel lifted - * above the answered card. The recipe is the quiz cards' quote block - * verbatim (rail + uppercase label + prose), carried by a faint shell - * so it still reads user-side at a glance — right-aligned, message - * width, never collapsible (they arrive open; collapsing bought - * nothing and cost the chevron-on-the-right weirdness). + * 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. * - * Rails reuse the existing vocabulary: primary for the committed - * register (the quiz answer thinking, practice commits), muted for the - * working register (nudges, reading thoughts). */ + * The glance-level law (owner): user thinking = rail + 50% italic; + * agent thinking = no rail. */ export function ThinkingBlock({ - label, + label = "Your thinking", text, - rail = "muted", testId, dataCommit, }: { - label: string; + label?: string; text: string; - rail?: "primary" | "muted"; testId?: string; /** The practice marker's register flag (commit vs nudge). */ dataCommit?: boolean; }) { + const [open, setOpen] = useState(false); // arrives collapsed, always return ( -
-
- - {label} - - {text} -
+
+ + {open && ( +
+ {text} +
+ )}
); } diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index acd7661..6068c88 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -286,9 +286,13 @@ describe("render smoke tests", () => { thinking: "I picked Venus because it looks brightest — but now I realize brightness isn't distance.", }); const html = renderToStaticMarkup(); - 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, /brightness isn'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't distance"), "the text is hidden until expanded"); assert.ok(!html.includes("Note:"), "no longer a muted 'Note:' footnote"); // The thinking sits ABOVE the verdict's explanation — the dialogue of // thinking, not an afterthought. @@ -580,8 +584,8 @@ describe("predict vs check rendering", () => { }); const html = renderToStaticMarkup(); // The unified ThinkingBlock (owner round, 2026-10-05): the reading - // thoughts render BEFORE the card, user-side (right shell), under the - // plain "Your thinking" label — no longer an inset inside the answer. + // thoughts render BEFORE the card as the collapsed user-thinking row + // — same trigger, same 50%-italic quote block on expand. const blockAt = html.indexOf("reading-thinking-block"); const cardAt = html.indexOf("answered-card"); 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 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("answering thought"), "the answer thinking stays inside the card"); - assert.ok(html.includes("was unsure mid-paragraph")); + // COLLAPSED ARRIVAL: SSR shows the triggers, not the texts. + 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 legacyHtml = renderToStaticMarkup(); - 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"); }); });