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:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user