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
+13 -34
View File
@@ -31,7 +31,6 @@ interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
@@ -49,11 +48,9 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
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<number | undefined>({
defaultProp: undefined,
prop: durationProp,
});
const hasEverStreamedRef = useRef(isStreaming);
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)
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 <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
return <p>Agent's thinking</p>;
};
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 (
<CollapsibleTrigger
@@ -188,7 +167,7 @@ export const ReasoningTrigger = memo(
spinner (owner, 2026-10-02): the handoff between the two
states must be visually seamless, one size everywhere. */}
{isStreaming && <BrailleSpinner label="Thinking" />}
{getThinkingMessage(isStreaming, duration)}
{getThinkingMessage(isStreaming)}
<ChevronDownIcon
className={cn(
"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
rail as before; the label is now plain "Your thinking". */}
{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="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) && (
<div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3">
<span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground">
Your thinking
</span>
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.thinking ?? answer.note}</span>
</div>
/* 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. */
<ThinkingBlock testId="your-thinking" label="Your thinking" text={answer.thinking ?? answer.note ?? ""} />
)}
{/* 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"}
</button>
{whyOpen && (
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">Why: </span>
<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>
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
</div>
)}
@@ -169,8 +168,8 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
) : (
isQuiz &&
answer.explanation && (
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">Why: </span>
<div className="mt-3 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>
+25 -13
View File
@@ -1249,16 +1249,28 @@ export function Chat({
{m.role === "assistant" && mi > 0 && phases[mi] === "teach" && phases[mi - 1] !== "teach" ? (
<PhaseDivider key={`teach-${m.id || `m-${mi}`}`} phase="teach" />
) : null}
<Message from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}>
{m.role === "user" ? (
// User text renders plain: the model's LaTeX pipeline is not
// applied to learner-authored text ($ stays $, no surprises).
<div className="lh-prose whitespace-pre-wrap">
{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. */
<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>
) : (
m.parts.map((part, pi) => {
</div>
) : (
<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;
// 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;
}
}
})
)}
</MessageContent>
})}
</MessageContent>
</Message>
)}
</Fragment>
))}
@@ -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)",
});
}
+4 -4
View File
@@ -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 (
<ThinkingBlock
testId="thinking-card"
dataCommit={data.commit}
label={data.commit ? "Your solution" : "Your thinking"}
text={data.text}
rail={data.commit ? "primary" : "muted"}
/>
);
}
+31 -25
View File
@@ -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 (
<div
data-testid={testId}
data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"}
className="lh-card-enter ml-auto max-w-[95%] rounded-2xl border border-border/60 bg-muted/40 px-4 py-3"
>
<div className={cn("border-l-2 pl-3", rail === "primary" ? "border-primary/40" : "border-muted-foreground/30")}>
<span className="mb-0.5 block text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</span>
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{text}</span>
</div>
<div data-testid={testId} data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"} className="lh-card-enter my-1 w-full">
<button
type="button"
data-testid={testId ? `${testId}-trigger` : undefined}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
>
{label}
<ChevronDownIcon className={cn("size-4 transition-transform", open ? "rotate-180" : "rotate-0")} />
</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>
);
}