feat: model menu keyboard machine + graph center-anchor on resize

Owner polish pair before the settings milestone:

- Model menu: arrows walk the filtered results (clamped, no wrap) while
  focus stays in the search field; Enter picks the walked row or, with no
  walk yet, the first result; typing resets the walk; click and Enter
  share one pickModel path; walked row gets a muted ring + scrollIntoView;
  cheat-sheet display entries added.

- Graph: window resizes re-anchor the camera so the world point under the
  pre-resize viewport center stays under the new center — the graph stays
  visually still while scaling (was top-left pinned). viewW/H tracked in
  syncSize; re-anchor applied through the zoom behavior (persistence and
  card-dismiss unchanged); kFit refresh as before. Pixel-verified: 7/7
  center samples identical across a 300x200 shrink.
This commit is contained in:
amosblomqvist
2026-09-18 08:53:31 +03:00
parent 76ea6f5a53
commit 61bd43282d
5 changed files with 117 additions and 19 deletions
+27 -1
View File
@@ -435,6 +435,11 @@ export class GraphCanvasRenderer {
private transform: ZoomTransform = zoomIdentity;
/** Set once the first fit happens; the camera then survives setData. */
private hasFit = false;
/** Last-synced CSS size — the resize anchor computes the world point
* under the PRE-resize viewport center from these (ResizeObserver
* callbacks already see the NEW size in clientWidth/Height). */
private viewW = 0;
private viewH = 0;
/** The k that frames the whole graph — the label-gate's "1×". */
private kFit = 1;
/** World-space bounds of the current nodes — fit() and the resize-time
@@ -573,9 +578,28 @@ export class GraphCanvasRenderer {
}
this.resizeObserver = new ResizeObserver(() => {
// The world point under the pre-resize viewport center — the
// visual anchor the new transform must preserve. Capture it
// BEFORE syncSize (which updates viewW/H to the new size).
const prevW = this.viewW;
const prevH = this.viewH;
const { x: tx, y: ty, k } = this.transform;
const wx = (prevW / 2 - tx) / k;
const wy = (prevH / 2 - ty) / k;
this.syncSize();
if (!this.hasFit) this.fit(); // first real size → fit once
else this.refreshFitZoom(); // the gate's "1×" is viewport-bound
else {
this.refreshFitZoom(); // the gate's "1×" is viewport-bound
// Center-anchor (2026-09-17, owner): same world point under
// the NEW center. Applied through the zoom behavior like every
// camera move (d3 state, persistence, card-dismiss all agree).
if (prevW > 0 && prevH > 0 && this.viewW > 0 && this.viewH > 0) {
select(canvas).call(
this.zoomBehavior.transform,
zoomIdentity.translate(this.viewW / 2 - wx * k, this.viewH / 2 - wy * k).scale(k),
);
}
}
// Redraw SYNCHRONOUSLY: syncSize may have reallocated the backing
// store (which clears the canvas), and during live window resizes
// rAF is starved by relayout — a deferred draw would leave the
@@ -996,6 +1020,8 @@ export class GraphCanvasRenderer {
this.canvas.height = bh;
}
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.viewW = w;
this.viewH = h;
}
/** Derive ALL world geometry from data + visibility + settings.
+60 -18
View File
@@ -100,8 +100,13 @@ export function ModelControl({
const [open, setOpen] = useState<"model" | "thinking" | null>(null);
const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null);
/** The arrow-walked result (owner polish, 2026-09-17): arrows move it
* through `results` while focus STAYS in the search field; Enter
* picks it (or the first result when nothing is walked). */
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const defaults = catalog?.defaults;
const effectiveId = settings?.model?.id ?? defaults?.model.id ?? fallbackModelId;
@@ -114,6 +119,11 @@ export function ModelControl({
useEffect(() => {
if (open === "model") searchRef.current?.focus();
}, [open]);
// Keep the arrow-walked result visible in the scrolling list.
useEffect(() => {
if (open !== "model" || menuIdx === null) return;
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
}, [menuIdx, open]);
// The intuitive dropdown contract (owner, stats-control round 1): any
// click outside the open menu — or an Esc keypress — closes it. Only the
@@ -182,6 +192,7 @@ export function ModelControl({
setOpen("model");
setQuery("");
setError(null);
setMenuIdx(null);
};
window.addEventListener("lh:kbd-open-model-picker", onOpenPicker);
return () => window.removeEventListener("lh:kbd-open-model-picker", onOpenPicker);
@@ -197,6 +208,19 @@ export function ModelControl({
return () => window.removeEventListener("lh:kbd-cycle-thinking", onCycleThinking);
}, [levels, effectiveLevel, save, settings]);
// Selecting a model (click and Enter share one path): keep the
// thinking level only when the new model supports it — otherwise
// drop it (config fallback clamps honestly; a carry-over would 400).
// Replace-whole discipline: spread the current settings — a model pick
// must not silently clear a sibling override (graphContextTokens).
const pickModel = (m: ModelCatalogEntry) => {
const nextLevel =
effectiveLevel !== undefined && m.supportedLevels.includes(effectiveLevel)
? effectiveLevel
: undefined;
void save({ ...(settings ?? {}), model: { provider: m.provider, id: m.id }, thinkingLevel: nextLevel });
};
// The filtered, ranked model list.
const results = (catalog?.models ?? [])
.map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) }))
@@ -213,6 +237,7 @@ export function ModelControl({
setOpen((o) => (o === "model" ? null : "model"));
setQuery("");
setError(null);
setMenuIdx(null);
}}
aria-expanded={open === "model"}
title="Conversation model — applies next turn"
@@ -249,7 +274,34 @@ export function ModelControl({
ref={searchRef}
data-testid="model-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => {
setQuery(e.target.value);
setMenuIdx(null); // new query → the walk restarts on Enter/↓
}}
// The menu keyboard machine (owner polish): focus STAYS in the
// search field — ↑/↓ walk the filtered results (clamped, no
// wrap), Enter picks the walked row (or the first result when
// nothing is walked). stopPropagation keeps these keys out of
// every other listener while the menu is open.
onKeyDown={(e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
if (results.length === 0) return;
setMenuIdx((i) =>
e.key === "ArrowDown"
? Math.min((i ?? -1) + 1, results.length - 1)
: Math.max((i ?? results.length) - 1, 0),
);
} else if (e.key === "Enter") {
const chosen = results[menuIdx ?? 0];
if (chosen) {
e.preventDefault();
e.stopPropagation();
pickModel(chosen);
}
}
}}
placeholder="Search models…"
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
@@ -260,27 +312,17 @@ export function ModelControl({
{catalog && results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">No models match.</div>
)}
<ul data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto">
{results.map((m) => (
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto">
{results.map((m, i) => (
<li key={`${m.provider}/${m.id}`}>
<button
type="button"
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
onClick={() => {
// Keep the thinking level only when the new model
// supports it — otherwise drop it (config fallback
// clamps honestly; a carry-over would 400).
const nextLevel =
effectiveLevel !== undefined && m.supportedLevels.includes(effectiveLevel)
? effectiveLevel
: undefined;
// Replace-whole discipline: spread the current settings — a model pick
// must not silently clear a sibling override (graphContextTokens).
void save({ ...(settings ?? {}), model: { provider: m.provider, id: m.id }, thinkingLevel: nextLevel });
}}
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
m.id === effectiveId ? "bg-muted/60" : ""
}`}
data-idx={i}
onClick={() => pickModel(m)}
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
i === menuIdx ? "bg-muted ring-1 ring-border" : ""
}${m.id === effectiveId && i !== menuIdx ? " bg-muted/60" : ""}`}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span>