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