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:
@@ -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"
|
||||||
@@ -161,20 +161,12 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
|||||||
{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>
|
<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>
|
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
|
||||||
{/* 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>
|
|
||||||
</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
@@ -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. */}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
Reference in New Issue
Block a user