practice: the metadata popover — the node-card info-box register, not a drawer (round 17 refinement)

Owner corrections (2026-10-06): no expand/collapse; a quiet METADATA
trigger sits at the card's bottom (no divider — it just sits there);
click opens the info box in the graph-view node card's exact register
(rounded-xl border bg-card p-4 shadow-lg, w-80, floating ABOVE the
trigger so the transcript scroller never clips it); ANY scroll
(capture-phase — every scroller in the tree) or click outside closes
it, plus Esc claimed from the layered chain (usePopoverDismiss, the
popover law). Values match the model solution's font size
(text-[15px], verified — the drawer's text-xs did not); labels keep
the eyebrow register.
This commit is contained in:
amosblomqvist
2026-10-06 04:26:34 +03:00
parent 793638d6ce
commit 4eeb05b3c4
2 changed files with 73 additions and 49 deletions
+61 -39
View File
@@ -21,9 +21,10 @@ import type { ArtifactPartProps } from "@/src/core";
*/ */
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ThinkingBlock } from "@/components/thinking-block"; import { ThinkingBlock } from "@/components/thinking-block";
import { usePopoverDismiss } from "@/components/popover-dismiss";
/* TARGET NAMES (owner round 17, 2026-10-06): targets are normalized to /* TARGET NAMES (owner round 17, 2026-10-06): targets are normalized to
* opaque node ids at the record seam (owner round 9 — the mastery trace * opaque node ids at the record seam (owner round 9 — the mastery trace
@@ -116,7 +117,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
} }
return ( return (
<div data-testid="conclude-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4"> <div data-testid="conclude-card" className="lh-card-enter relative my-1 rounded-xl border bg-card p-4">
{/* The heading row (owner round 3, 2026-10-05): plain 'Concluded' {/* The heading row (owner round 3, 2026-10-05): plain 'Concluded'
eyebrow + the outcome in the quiz cards' EXACT chip register — eyebrow + the outcome in the quiz cards' EXACT chip register —
no more two same-weight headings stacked. */} no more two same-weight headings stacked. */}
@@ -148,7 +149,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
</div> </div>
</> </>
)} )}
<AgentsDataDrawer <MetadataPopover
concludeInput={input} concludeInput={input}
problemInput={(part.problemInput ?? {}) as { mode?: unknown; target?: unknown }} problemInput={(part.problemInput ?? {}) as { mode?: unknown; target?: unknown }}
/> />
@@ -156,13 +157,19 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
); );
} }
/* THE AGENT'S DATA DRAWER (owner round 17, 2026-10-06 — full /* THE METADATA POPOVER (owner round 17 refinement, 2026-10-06 — full
* transparency): a collapsible at the very bottom of the conclude card, * transparency): a quiet METADATA trigger at the very bottom of the
* arriving collapsed, showing every field the agent filled in both the * conclude card (no divider — it just sits there); click opens the
* problem call (mode, targets) and the conclude call (outcome, rung, * info-box register of the graph-view node cards (rounded-xl border
* miss class, retest). Statement and modelSolution stay out — they are * bg-card p-4 shadow-lg, floating ABOVE the trigger); any scroll or
* the visible card above. Pure rows, the eyebrow register, mono values. */ * click outside closes it (plus Esc, claimed from the layered chain
function AgentsDataDrawer({ * per the popover law). Shows every field the agent filled in both the
* problem call (mode, targets — names resolved lazily) and the
* conclude call (outcome, rung, miss class, retest). Statement and
* modelSolution stay out — they are the visible card. The VALUES match
* the model solution's font size (text-[15px], owner-verified law);
* labels keep the eyebrow register. */
function MetadataPopover({
concludeInput, concludeInput,
problemInput, problemInput,
}: { }: {
@@ -170,6 +177,7 @@ function AgentsDataDrawer({
problemInput: { mode?: unknown; target?: unknown }; problemInput: { mode?: unknown; target?: unknown };
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const [names, setNames] = useState<Record<string, string> | null>(null); const [names, setNames] = useState<Record<string, string> | null>(null);
const targets = Array.isArray(problemInput.target) const targets = Array.isArray(problemInput.target)
? problemInput.target.filter((t): t is string => typeof t === "string") ? problemInput.target.filter((t): t is string => typeof t === "string")
@@ -186,6 +194,16 @@ function AgentsDataDrawer({
}; };
}, [open, targets.length, names]); }, [open, targets.length, names]);
usePopoverDismiss(rootRef, open, () => setOpen(false));
// ANY scroll closes it (owner law): capture-phase, so every scroller
// in the tree counts — the transcript's own scroll included.
useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
window.addEventListener("scroll", close, { capture: true, passive: true });
return () => window.removeEventListener("scroll", close, { capture: true });
}, [open]);
const mode = typeof problemInput.mode === "string" ? problemInput.mode : ""; const mode = typeof problemInput.mode === "string" ? problemInput.mode : "";
const rung = typeof concludeInput.rung === "string" ? concludeInput.rung : ""; const rung = typeof concludeInput.rung === "string" ? concludeInput.rung : "";
const miss = typeof concludeInput.missClass === "string" ? concludeInput.missClass : ""; const miss = typeof concludeInput.missClass === "string" ? concludeInput.missClass : "";
@@ -194,25 +212,29 @@ function AgentsDataDrawer({
if (!mode && targets.length === 0 && !rung && !miss && retest === undefined) return null; if (!mode && targets.length === 0 && !rung && !miss && retest === undefined) return null;
return ( return (
<> <div ref={rootRef} className="relative mt-3">
<div className="my-3 border-t" /> <button
<div> type="button"
<button data-testid="metadata-toggle"
type="button" onClick={() => setOpen((v) => !v)}
data-testid="agents-data-toggle" aria-expanded={open}
onClick={() => setOpen((v) => !v)} className="flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
aria-expanded={open} >
className="flex w-full items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground" <ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
Metadata
</button>
{open && (
<div
data-testid="metadata-box"
// The node-card info-box register (graph view), floating above
// the trigger — never clipped by the transcript scroller.
className="absolute bottom-full left-0 z-20 mb-2 w-80 max-w-[calc(100%-1rem)] rounded-xl border bg-card p-4 text-left shadow-lg"
> >
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} /> <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
Agent&rsquo;s data <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Mode</dt>
</button> <dd className="text-[15px]">{mode || "—"}</dd>
{open && ( <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Targets</dt>
<dl data-testid="agents-data" className="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs"> <dd className="text-[15px]">
<dt className="text-muted-foreground">Mode</dt>
<dd className="font-mono">{mode || "—"}</dd>
<dt className="text-muted-foreground">Targets</dt>
<dd className="font-mono">
{targets.length === 0 ? ( {targets.length === 0 ? (
"untagged" "untagged"
) : ( ) : (
@@ -226,18 +248,18 @@ function AgentsDataDrawer({
</span> </span>
)} )}
</dd> </dd>
<dt className="text-muted-foreground">Outcome</dt> <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Outcome</dt>
<dd className="font-mono">{outcome || "—"}</dd> <dd className="text-[15px]">{outcome || "—"}</dd>
<dt className="text-muted-foreground">Hint rung</dt> <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Hint rung</dt>
<dd className="font-mono">{rung || "none"}</dd> <dd className="text-[15px]">{rung || "none"}</dd>
<dt className="text-muted-foreground">Miss class</dt> <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Miss class</dt>
<dd className="font-mono">{miss || "none"}</dd> <dd className="text-[15px]">{miss || "none"}</dd>
<dt className="text-muted-foreground">Retest</dt> <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Retest</dt>
<dd className="font-mono">{retest === undefined ? "—" : retest ? "yes" : "no"}</dd> <dd className="text-[15px]">{retest === undefined ? "—" : retest ? "yes" : "no"}</dd>
</dl> </dl>
)} </div>
</div> )}
</> </div>
); );
} }
+12 -10
View File
@@ -137,8 +137,8 @@ describe("the practice transcript cards", () => {
assert.match(html, /Pair them; each pair is even\./); assert.match(html, /Pair them; each pair is even\./);
}); });
it("ConcludeCard: THE AGENT'S DATA DRAWER (owner round 17) — arrives collapsed, opens to every field both calls filled", () => { it("ConcludeCard: THE METADATA POPOVER (owner round 17 refinement) — quiet trigger at the bottom, box hidden until clicked", () => {
const collapsed = renderToStaticMarkup( const html = renderToStaticMarkup(
<ConcludeCard <ConcludeCard
part={part({ part={part({
type: "tool-conclude", type: "tool-conclude",
@@ -148,22 +148,24 @@ describe("the practice transcript cards", () => {
})} })}
/>, />,
); );
assert.ok(collapsed.includes('data-testid="agents-data-toggle"'), "the toggle row"); assert.ok(html.includes('data-testid="metadata-toggle"'), "the trigger row");
assert.ok(collapsed.includes("Agent"), "the label"); assert.match(html, /Metadata/, "the label (CSS renders it mono-caps)");
assert.ok(!collapsed.includes('data-testid="agents-data"'), "arrives COLLAPSED — no rows in the markup"); assert.ok(!html.includes('data-testid="metadata-box"'), "the info box is CLOSED until clicked — no rows in the markup");
// The drawer is a client useState fold; the OPEN face is pinned by the assert.ok(!html.includes("border-t \"/>"), "no separate divider — the trigger just sits at the bottom");
// rows' data shape (view join) below — static render cannot toggle. // The open face (info-box rows at the model solution's 15px) is a
// client useState fold — static render cannot click; the rows' data
// shape is pinned by the view-join test in practice-record.
}); });
it("ConcludeCard: no drawer when the calls filled nothing beyond the visible card", () => { it("ConcludeCard: no metadata trigger when the calls filled nothing beyond the visible card", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<ConcludeCard part={part({ type: "tool-conclude", input: { outcome: "dismissed" } })} />, <ConcludeCard part={part({ type: "tool-conclude", input: { outcome: "dismissed" } })} />,
); );
assert.ok(!html.includes("agents-data"), "the dismissal line carries no drawer"); assert.ok(!html.includes("metadata-"), "the dismissal line carries no trigger");
const legacy = renderToStaticMarkup( const legacy = renderToStaticMarkup(
<ConcludeCard part={part({ type: "tool-conclude", input: { outcome: "solved" }, reveal: "s" })} />, <ConcludeCard part={part({ type: "tool-conclude", input: { outcome: "solved" }, reveal: "s" })} />,
); );
assert.ok(!legacy.includes("agents-data"), "empty problemInput + bare outcome: nothing to reveal"); assert.ok(!legacy.includes("metadata-"), "empty problemInput + bare outcome: nothing to reveal");
}); });
it("ConcludeCard: the dismissal synthesis is one muted line — no reveal card", () => { it("ConcludeCard: the dismissal synthesis is one muted line — no reveal card", () => {