2026-10-05 02:25:13 +03:00
|
|
|
"use client";
|
|
|
|
|
|
2026-10-08 02:29:05 +03:00
|
|
|
import { useEffect, useRef, useState } from "react";
|
2026-10-05 03:41:39 +03:00
|
|
|
import { ChevronDownIcon } from "lucide-react";
|
2026-10-05 02:25:13 +03:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
2026-10-05 03:41:39 +03:00
|
|
|
/** The unified user-thinking row (owner round, 2026-10-05): ONE visual
|
2026-10-05 02:25:13 +03:00
|
|
|
* language for every surface where the learner's own thinking renders —
|
2026-10-05 03:41:39 +03:00
|
|
|
* practice thinking traces, the reading channel lifted above the answered
|
2026-10-08 02:29:05 +03:00
|
|
|
* card, the quiz answer's thinking inside its container. It arrives 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.
|
2026-10-05 02:25:13 +03:00
|
|
|
*
|
2026-10-05 03:41:39 +03:00
|
|
|
* The glance-level law (owner): user thinking = rail + 50% italic;
|
2026-10-08 02:29:05 +03:00
|
|
|
* 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);
|
|
|
|
|
}
|
2026-10-05 02:25:13 +03:00
|
|
|
|
|
|
|
|
export function ThinkingBlock({
|
2026-10-05 04:14:21 +03:00
|
|
|
label = "YOUR THINKING",
|
2026-10-05 02:25:13 +03:00
|
|
|
text,
|
|
|
|
|
testId,
|
|
|
|
|
dataCommit,
|
2026-10-08 02:29:05 +03:00
|
|
|
mirror = false,
|
|
|
|
|
defaultOpen = false,
|
2026-10-05 02:25:13 +03:00
|
|
|
}: {
|
2026-10-05 03:41:39 +03:00
|
|
|
label?: string;
|
2026-10-05 02:25:13 +03:00
|
|
|
text: string;
|
|
|
|
|
testId?: string;
|
|
|
|
|
/** The practice marker's register flag (commit vs nudge). */
|
|
|
|
|
dataCommit?: boolean;
|
2026-10-08 02:29:05 +03:00
|
|
|
/** Right-side placement (transcript rows): mirrored rail, right edge. */
|
|
|
|
|
mirror?: boolean;
|
|
|
|
|
/** Transcript rows arrive expanded (owner, 2026-10-07). */
|
|
|
|
|
defaultOpen?: boolean;
|
2026-10-05 02:25:13 +03:00
|
|
|
}) {
|
2026-10-08 02:29:05 +03:00
|
|
|
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); }),
|
|
|
|
|
[],
|
|
|
|
|
);
|
2026-10-05 02:25:13 +03:00
|
|
|
return (
|
2026-10-05 04:39:47 +03:00
|
|
|
/* The rail GROUPS and the rail IS THE MISSING CHARACTER (owner
|
|
|
|
|
rounds, 2026-10-05): the heading sits INSIDE the rail, and the
|
2026-10-05 04:55:59 +03:00
|
|
|
rail's full width (border + padding) is exactly THREE CHARACTERS of
|
|
|
|
|
the mono heading font — 'YOUR THINKING' is three chars shorter
|
|
|
|
|
than 'AGENT'S THINKING', so with the user row offset by the rail
|
|
|
|
|
and the agent row flush, the chevrons line up vertically. Three
|
|
|
|
|
(not one, owner round 5): the wider gap reads better than the
|
|
|
|
|
cramped single-char rail. The heading
|
2026-10-05 04:39:47 +03:00
|
|
|
register is the card eyebrows' small mono caps (text-xs = the
|
|
|
|
|
11px rung). */
|
2026-10-05 03:57:35 +03:00
|
|
|
<div
|
|
|
|
|
data-testid={testId}
|
|
|
|
|
data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"}
|
2026-10-08 02:29:05 +03:00
|
|
|
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)]",
|
|
|
|
|
)}
|
2026-10-05 03:57:35 +03:00
|
|
|
>
|
2026-10-05 03:41:39 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid={testId ? `${testId}-trigger` : undefined}
|
|
|
|
|
onClick={() => setOpen((v) => !v)}
|
|
|
|
|
aria-expanded={open}
|
2026-10-05 04:39:47 +03:00
|
|
|
className="flex w-full items-center gap-2 font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
|
2026-10-05 03:41:39 +03:00
|
|
|
>
|
|
|
|
|
{label}
|
2026-10-05 04:39:47 +03:00
|
|
|
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} />
|
2026-10-05 03:41:39 +03:00
|
|
|
</button>
|
|
|
|
|
{open && (
|
2026-10-05 04:39:47 +03:00
|
|
|
<span className="lh-prose mt-1.5 block whitespace-pre-wrap text-[15px] italic text-foreground/50">{text}</span>
|
2026-10-05 03:41:39 +03:00
|
|
|
)}
|
2026-10-05 02:25:13 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|