Stats-control round 1 (owner): bare labels + intuitive dropdown close
Model + thinking control refinements (components/model-picker.tsx):
- Drop the '·' separator between the two buttons.
- The thinking button shows just the level ('High', 'Max') — same
doctrine as the model button showing just the model name; '—' when
unknown. Pinned in render-stats-bar (label + no-dot assertions).
- The dropdowns now close on any pointerdown outside the control
(the toggle buttons stay inside the container and keep their own
toggling) and on Esc — the intuitive dropdown contract, instead of
the button being the only way out.
Note: file is graph-stream-owned per SCOPE; touching it per the owner's
direct request (same as cd518a4) — flagged for master.
This commit is contained in:
@@ -101,6 +101,7 @@ export function ModelControl({
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const defaults = catalog?.defaults;
|
const defaults = catalog?.defaults;
|
||||||
const effectiveId = settings?.model?.id ?? defaults?.model.id ?? fallbackModelId;
|
const effectiveId = settings?.model?.id ?? defaults?.model.id ?? fallbackModelId;
|
||||||
@@ -114,6 +115,28 @@ export function ModelControl({
|
|||||||
if (open === "model") searchRef.current?.focus();
|
if (open === "model") searchRef.current?.focus();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// The intuitive dropdown contract (owner, stats-control round 1): any
|
||||||
|
// click outside the open menu — or an Esc keypress — closes it. Only the
|
||||||
|
// menu's own surface keeps it open (the toggle buttons live inside the
|
||||||
|
// same container and handle their own toggling in onClick).
|
||||||
|
useEffect(() => {
|
||||||
|
if (open === null) return;
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setOpen(null);
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const save = useCallback(
|
const save = useCallback(
|
||||||
async (next: ChatSettings) => {
|
async (next: ChatSettings) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -153,7 +176,7 @@ export function ModelControl({
|
|||||||
.map((r) => r.m);
|
.map((r) => r.m);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center gap-1.5" data-testid="model-control">
|
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="stats-model"
|
data-testid="stats-model"
|
||||||
@@ -167,9 +190,8 @@ export function ModelControl({
|
|||||||
className="flex items-center gap-1.5 rounded-full border border-border/60 bg-card/70 px-2.5 py-1 transition-colors hover:border-border hover:bg-muted"
|
className="flex items-center gap-1.5 rounded-full border border-border/60 bg-card/70 px-2.5 py-1 transition-colors hover:border-border hover:bg-muted"
|
||||||
>
|
>
|
||||||
<span className="text-card-foreground">{effectiveId ? prettyModel(effectiveId) : "—"}</span>
|
<span className="text-card-foreground">{effectiveId ? prettyModel(effectiveId) : "—"}</span>
|
||||||
<span aria-hidden className="text-xxs text-muted-foreground">▾</span>
|
<span aria-hidden className="text-xxs text-muted-foreground">▾</span>
|
||||||
</button>
|
</button>
|
||||||
<span aria-hidden>·</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="stats-thinking"
|
data-testid="stats-thinking"
|
||||||
@@ -181,8 +203,10 @@ export function ModelControl({
|
|||||||
title="Thinking level — applies next turn"
|
title="Thinking level — applies next turn"
|
||||||
className="flex items-center gap-1.5 rounded-full border border-border/60 bg-card/70 px-2.5 py-1 transition-colors hover:border-border hover:bg-muted"
|
className="flex items-center gap-1.5 rounded-full border border-border/60 bg-card/70 px-2.5 py-1 transition-colors hover:border-border hover:bg-muted"
|
||||||
>
|
>
|
||||||
|
{/* Just the level — the button reads like the model button reads
|
||||||
|
the model name (owner, stats-control round 1). */}
|
||||||
<span className="text-card-foreground">
|
<span className="text-card-foreground">
|
||||||
{effectiveLevel === undefined ? "Thinking —" : `Thinking: ${LEVEL_LABEL[effectiveLevel] ?? effectiveLevel}`}
|
{effectiveLevel === undefined ? "—" : (LEVEL_LABEL[effectiveLevel] ?? effectiveLevel)}
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden className="text-xxs text-muted-foreground">▾</span>
|
<span aria-hidden className="text-xxs text-muted-foreground">▾</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -34,9 +34,12 @@ describe("stats bar", () => {
|
|||||||
assert.match(html, /data-testid="stats-bar"/);
|
assert.match(html, /data-testid="stats-bar"/);
|
||||||
assert.match(html, /data-testid="stats-context">—</);
|
assert.match(html, /data-testid="stats-context">—</);
|
||||||
assert.match(html, /data-testid="stats-cost">—</);
|
assert.match(html, /data-testid="stats-cost">—</);
|
||||||
// no turns, no settings, catalog not loaded → both control labels "—"
|
// no turns, no settings, catalog not loaded → both control labels
|
||||||
|
// "—" (the thinking button shows just the level, like the model
|
||||||
|
// button shows just the model name — no prefix, no dot separator)
|
||||||
assert.match(html, /data-testid="stats-model"[^>]*>\s*<span[^>]*>—<\/span>/);
|
assert.match(html, /data-testid="stats-model"[^>]*>\s*<span[^>]*>—<\/span>/);
|
||||||
assert.match(html, /Thinking —</);
|
assert.match(html, /data-testid="stats-thinking"[^>]*>\s*<span[^>]*>—<\/span>/);
|
||||||
|
assert.ok(!html.includes("aria-hidden>·"), "no dot between the controls");
|
||||||
// the control reads as a button: pill border + dropdown chevron
|
// the control reads as a button: pill border + dropdown chevron
|
||||||
assert.match(html, /data-testid="stats-model"[^>]*rounded-full[^>]*>/);
|
assert.match(html, /data-testid="stats-model"[^>]*rounded-full[^>]*>/);
|
||||||
assert.match(html, /▾/);
|
assert.match(html, /▾/);
|
||||||
|
|||||||
Reference in New Issue
Block a user