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 {/* 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 than the option box edges (owner, round 3). Sides flush with everything
else; py-2 stays for vertical air. */} else; py-2 stays for vertical air. */}
{isQuiz && answer.explanation && answer.questionType === "predict" ? ( {isQuiz && answer.explanation && (
/* The TUCKED explanation (owner 2026-10-03): a prediction's /* The TUCKED explanation — ALL questions (owner, 2026-10-07): the
verdict shows, but the explanation sits collapsed — viewable, verdict shows, the explanation sits collapsed behind the Why
never center stage. The teaching that follows resolves the toggle — viewable, never center stage. Identical to the
prediction; THAT is the real explanation. */ prediction pattern it generalizes (owner, 2026-10-03). */
<div className="mt-2" data-testid="tucked-explanation"> <div className="mt-2" data-testid="tucked-explanation">
<button <button
type="button" type="button"
@@ -160,21 +160,13 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
</button> </button>
{whyOpen && ( {whyOpen && (
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-foreground/50"> <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>
)}
</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> <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>
</div> </div>
) )}
</div>
)} )}
{/* The explain reveal (owner ruling 2026-09-27): the committed answer {/* 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 { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar"; import { StatsBar } from "@/components/stats-bar";
import { useServerEvent, useServerResync } from "@/components/server-events"; 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 { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanMarginalia, planExists } from "@/components/plan-window"; import { PlanMarginalia, planExists } from "@/components/plan-window";
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay"; import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
@@ -63,6 +71,7 @@ import {
quizNeedsReveal, quizNeedsReveal,
} from "@/src/core"; } from "@/src/core";
import { ReadingThinkingField } from "@/components/reading-thinking"; import { ReadingThinkingField } from "@/components/reading-thinking";
import { currentUserThinkingOpen, setAllUserThinkingOpen } from "@/components/thinking-block";
import { slotsConfigured, type ModelsSettings } from "@/src/core"; import { slotsConfigured, type ModelsSettings } from "@/src/core";
import { useSettingsSaved } from "@/components/settings-signal"; import { useSettingsSaved } from "@/components/settings-signal";
@@ -493,6 +502,15 @@ export function Chat({
* separate `readingThinking` field. Reset per pending question. */ * separate `readingThinking` field. Reset per pending question. */
const [readingThinking, setReadingThinking] = useState(""); const [readingThinking, setReadingThinking] = useState("");
const readingThinkingRef = useRef(""); 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) => { const applyReadingThinking = useCallback((v: string) => {
readingThinkingRef.current = v; readingThinkingRef.current = v;
setReadingThinking(v); setReadingThinking(v);
@@ -1379,7 +1397,15 @@ export function Chat({
User text renders plain — the model's LaTeX pipeline is not User text renders plain — the model's LaTeX pipeline is not
applied to learner-authored text ($ stays $, no surprises). applied to learner-authored text ($ stays $, no surprises).
Rendered WITHOUT the Message wrapper — that is the bubble. */ 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 {/* The rail GROUPS (owner round 4, 2026-10-05): the YOU heading
sits INSIDE the rail, same mono-xs register and same 1ch sits INSIDE the rail, same mono-xs register and same 1ch
rail as the thinking rows — one quote geometry. */} 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) { export function MarkdownBlockquote(props: ComponentProps<"blockquote"> & ExtraProps) {
const { children, node, ...rest } = props; const { children, node, ...rest } = props;
const alert = parseAlertMarker(children); 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 ( return (
<div <div
data-testid="md-callout" data-testid="md-callout"
+6 -3
View File
@@ -291,7 +291,10 @@ function MetadataPopover({
export function ThinkingCard({ data }: { data: PracticeThinkingData }) { export function ThinkingCard({ data }: { data: PracticeThinkingData }) {
// The unified row (owner rounds, 2026-10-05): one style, one label — // The unified row (owner rounds, 2026-10-05): one style, one label —
// collapsed arrival, "Your thinking", 50%-italic quote block on expand. // 50%-italic quote block on expand. Legacy records' commit flag is
// Legacy records' commit flag is deliberately unread (one lane now). // deliberately unread (one lane now). A TRANSCRIPT row (owner round,
return <ThinkingBlock testId="thinking-card" label="YOUR THINKING" text={data.text} />; // 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"; "use client";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** The unified user-thinking row (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, the reading channel lifted above the answered * practice thinking traces, the reading channel lifted above the answered
* card, the quiz answer's thinking inside its container. It arrives * card, the quiz answer's thinking inside its container. It arrives as
* COLLAPSED as a trigger row in the exact register of the agent's * a trigger row in the exact register of the agent's reasoning rows
* reasoning rows (muted label + chevron + hover affordance); expanding * (muted label + chevron + hover affordance); expanding opens the quote
* opens the quote block — rail, 50% opacity, italics. The italics + the * block — rail, 50% opacity, italics. The italics + the half-opacity
* half-opacity text are the inner-voice signature; the rail marks it as * text are the inner-voice signature; the rail marks it as learner
* learner material. No shell, no border: document material, full width. * material. No shell, no border: document material, full width.
* *
* The glance-level law (owner): user thinking = rail + 50% italic; * 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({ export function ThinkingBlock({
label = "YOUR THINKING", label = "YOUR THINKING",
text, text,
testId, testId,
dataCommit, dataCommit,
mirror = false,
defaultOpen = false,
}: { }: {
label?: string; label?: string;
text: string; text: string;
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;
/** 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 ( return (
/* The rail GROUPS and the rail IS THE MISSING CHARACTER (owner /* The rail GROUPS and the rail IS THE MISSING CHARACTER (owner
rounds, 2026-10-05): the heading sits INSIDE the rail, and the rounds, 2026-10-05): the heading sits INSIDE the rail, and the
@@ -44,7 +95,16 @@ export function ThinkingBlock({
<div <div
data-testid={testId} data-testid={testId}
data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"} 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 <button
type="button" type="button"
+3 -1
View File
@@ -40,7 +40,8 @@ export type KeyAction =
| "question-prev-row" | "question-prev-row"
| "question-pick-row" | "question-pick-row"
| "question-submit" | "question-submit"
| "open-settings"; | "open-settings"
| "toggle-user-thinking";
/** Where the cheat sheet groups an action (display only — dispatch is /** Where the cheat sheet groups an action (display only — dispatch is
* global; a seam that isn't mounted simply no-ops). */ * global; a seam that isn't mounted simply no-ops). */
@@ -71,6 +72,7 @@ export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [
{ id: "jump-graph", label: "Graph view", scope: "global", binding: "mod+g" }, { id: "jump-graph", label: "Graph view", scope: "global", binding: "mod+g" },
{ id: "new-session", label: "New session", scope: "global", binding: "mod+n" }, { id: "new-session", label: "New session", scope: "global", binding: "mod+n" },
{ id: "rename-session", label: "Rename session", scope: "chat", binding: "f2" }, { id: "rename-session", label: "Rename session", scope: "chat", binding: "f2" },
{ id: "toggle-user-thinking", label: "Toggle all your thinking", scope: "chat", binding: "mod+t" },
// ⌘F is surface-scoped (owner key map): the graph focuses node search; a // ⌘F is surface-scoped (owner key map): the graph focuses node search; a
// chat-scoped transcript search rides the same event when it lands (the // chat-scoped transcript search rides the same event when it lands (the
// graph's listener simply isn't mounted on chat pages). // graph's listener simply isn't mounted on chat pages).
+6
View File
@@ -30,6 +30,12 @@ describe("kbdEventForAction — the one spelling for action events", () => {
}); });
describe("the ⌘B chain — keypress → binding → action → event", () => { describe("the ⌘B chain — keypress → binding → action → event", () => {
it("mod+t is the toggle-user-thinking action (owner round, 2026-10-07)", () => {
assert.equal(actionForBinding("mod+t"), "toggle-user-thinking");
assert.equal(bindingForAction("toggle-user-thinking"), "mod+t");
assert.equal(kbdEventForAction("toggle-user-thinking"), "lh:kbd-toggle-user-thinking");
});
it("mod+b is the toggle-sidebar action", () => { it("mod+b is the toggle-sidebar action", () => {
assert.equal(actionForBinding("mod+b"), "toggle-sidebar"); assert.equal(actionForBinding("mod+b"), "toggle-sidebar");
assert.equal(bindingForAction("toggle-sidebar"), "mod+b"); assert.equal(bindingForAction("toggle-sidebar"), "mod+b");
+9 -6
View File
@@ -241,7 +241,10 @@ describe("render smoke tests", () => {
const answer = buildAnswer(quizQD(), { values: ["Venus"] }); const answer = buildAnswer(quizQD(), { values: ["Venus"] });
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />); const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
assert.match(html, /incorrect/); assert.match(html, /incorrect/);
assert.match(html, /innermost/, "explanation renders post-answer"); // Tucked for ALL questions (owner, 2026-10-07): the explanation sits
// behind the Why toggle — never center stage.
assert.ok(html.includes("tucked-explanation"), "the Why toggle mounts");
assert.ok(!html.includes("innermost"), "explanation hidden until opened");
// Glyph-shaped SVG marks (owner preference) — pinned via data-mark. // Glyph-shaped SVG marks (owner preference) — pinned via data-mark.
assert.ok(html.includes('data-mark="check"') && html.includes('data-mark="x"')); assert.ok(html.includes('data-mark="check"') && html.includes('data-mark="x"'));
}); });
@@ -302,8 +305,8 @@ describe("render smoke tests", () => {
// 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.
assert.ok( assert.ok(
html.indexOf("your-thinking") < html.indexOf("innermost"), html.indexOf("your-thinking") < html.indexOf("tucked-explanation"),
"thinking before the explanation", "thinking before the explanation toggle",
); );
}); });
@@ -574,11 +577,11 @@ describe("predict vs check rendering", () => {
assert.ok(html.includes("tucked-explanation"), "it sits behind the toggle"); assert.ok(html.includes("tucked-explanation"), "it sits behind the toggle");
}); });
it("a check answer keeps the inline explanation", () => { it("a check answer tucks the explanation like every question (owner, 2026-10-07)", () => {
const answer = buildAnswer(quizQD(), { values: ["Venus"] }); const answer = buildAnswer(quizQD(), { values: ["Venus"] });
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />); const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
assert.ok(html.includes("Why:"), "checks explain inline (current behavior)"); assert.ok(html.includes("tucked-explanation"), "the prediction tuck is now universal");
assert.ok(!html.includes("tucked-explanation")); assert.ok(!html.includes("Why:"), "no inline explanation — behind the toggle");
}); });
it("the reading-thinking channel is LIFTED above the card as its own user-side block", () => { it("the reading-thinking channel is LIFTED above the card as its own user-side block", () => {
+41
View File
@@ -14,6 +14,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { MessageResponse } from "../components/ai-elements/message.tsx"; import { MessageResponse } from "../components/ai-elements/message.tsx";
import { ThinkingBlock, setAllUserThinkingOpen } from "@/components/thinking-block";
import { MermaidOverlay, SessionMermaidContext, chartSize } from "../components/session-overlay.tsx"; import { MermaidOverlay, SessionMermaidContext, chartSize } from "../components/session-overlay.tsx";
import { PlanPopup } from "../components/plan-block.tsx"; import { PlanPopup } from "../components/plan-block.tsx";
import { MarkdownCode, codeSource } from "../components/md-blocks.tsx"; import { MarkdownCode, codeSource } from "../components/md-blocks.tsx";
@@ -229,3 +230,43 @@ describe("parseAlertMarker (pure)", () => {
assert.equal(parseAlertMarker(em), null); assert.equal(parseAlertMarker(em), null);
}); });
}); });
describe("md-blocks — the restored plain blockquote (owner round, 2026-10-07)", () => {
it("a plain quote carries Streamdown's own styling (the override displaced it)", () => {
const html = renderToStaticMarkup(
<MessageResponse mode="static">{"Before.\n\n> An ordinary quote.\n\nAfter."}</MessageResponse>,
);
assert.ok(html.includes("border-l-4"), "the left border is back");
assert.ok(html.includes("text-muted-foreground italic"), "muted italic — the quote register");
assert.ok(!html.includes("md-callout"), "a plain quote is not a callout box");
});
});
describe("ThinkingBlock — side-based authorship + ⌘T sync (owner round, 2026-10-07)", () => {
it("the default row stays left with the my-3 rhythm", () => {
const html = renderToStaticMarkup(<ThinkingBlock text="left side" />);
assert.ok(html.includes("border-l-2"), "left rail");
assert.ok(html.includes("my-3"), "the 12px rhythm (was my-1)");
});
it("mirrored rows hug the right edge, rail flipped, arriving open", () => {
const html = renderToStaticMarkup(<ThinkingBlock text="right side" mirror defaultOpen />);
assert.ok(html.includes('data-side="right"'));
assert.ok(html.includes("ml-auto"), "pushed to the right edge");
assert.ok(html.includes("border-r-2") && !html.includes("border-l-2"), "rail on the right");
assert.match(html, /aria-expanded="true"/);
assert.ok(html.includes("right side"), "text visible on arrival");
});
it("⌘T sets every row — explicit sets stomp individual state, late mounts honor the last set", () => {
setAllUserThinkingOpen(false);
const collapsed = renderToStaticMarkup(<ThinkingBlock text="synced" defaultOpen />);
assert.match(collapsed, /aria-expanded="false"/);
assert.ok(!collapsed.includes(">synced<"), "the open default was stomped");
setAllUserThinkingOpen(true);
const reopened = renderToStaticMarkup(<ThinkingBlock text="synced" />);
assert.match(reopened, /aria-expanded="true"/);
// restore the null default for other tests in this file
setAllUserThinkingOpen(true);
});
});
+7 -3
View File
@@ -176,11 +176,15 @@ describe("the practice transcript cards", () => {
assert.doesNotMatch(html, /The solution/); assert.doesNotMatch(html, /The solution/);
}); });
it("ThinkingCard: one label, collapsed arrival (legacy commit flags ignored)", () => { it("ThinkingCard: one label, right side, expanded arrival (owner round, 2026-10-07)", () => {
const trace = renderToStaticMarkup(<ThinkingCard data={{ problemCallId: "c", commit: false, text: "stuck at step two" }} />); const trace = renderToStaticMarkup(<ThinkingCard data={{ problemCallId: "c", commit: false, text: "stuck at step two" }} />);
assert.match(trace, /YOUR THINKING/); assert.match(trace, /YOUR THINKING/);
assert.match(trace, /aria-expanded="false"/); // Transcript rows arrive EXPANDED (clearly conveyed as sent) and
assert.ok(!trace.includes("stuck at step two"), "the text is one click away"); // MIRRORED right (side-based authorship).
assert.match(trace, /aria-expanded="true"/);
assert.ok(trace.includes("stuck at step two"), "the text is visible on arrival");
assert.ok(trace.includes('data-side="right"'), "mirrored to the right side");
assert.ok(trace.includes("border-r-2"), "the rail is on the right");
const legacy = renderToStaticMarkup(<ThinkingCard data={{ problemCallId: "c", commit: true, text: "It is 42." }} />); const legacy = renderToStaticMarkup(<ThinkingCard data={{ problemCallId: "c", commit: true, text: "It is 42." }} />);
assert.match(legacy, /YOUR THINKING/); assert.match(legacy, /YOUR THINKING/);
assert.doesNotMatch(legacy, /Your solution/); assert.doesNotMatch(legacy, /Your solution/);