diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 17292e0..6302ae5 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -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. diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 42625e9..a02f50f 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -100,8 +100,13 @@ export function ModelControl({ const [open, setOpen] = useState<"model" | "thinking" | null>(null); const [query, setQuery] = useState(""); const [error, setError] = useState(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(null); const searchRef = useRef(null); const containerRef = useRef(null); + const listRef = useRef(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 && (
No models match.
)} -
    - {results.map((m) => ( +
      + {results.map((m, i) => (