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:
@@ -435,6 +435,11 @@ export class GraphCanvasRenderer {
|
|||||||
private transform: ZoomTransform = zoomIdentity;
|
private transform: ZoomTransform = zoomIdentity;
|
||||||
/** Set once the first fit happens; the camera then survives setData. */
|
/** Set once the first fit happens; the camera then survives setData. */
|
||||||
private hasFit = false;
|
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×". */
|
/** The k that frames the whole graph — the label-gate's "1×". */
|
||||||
private kFit = 1;
|
private kFit = 1;
|
||||||
/** World-space bounds of the current nodes — fit() and the resize-time
|
/** World-space bounds of the current nodes — fit() and the resize-time
|
||||||
@@ -573,9 +578,28 @@ export class GraphCanvasRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.resizeObserver = new ResizeObserver(() => {
|
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();
|
this.syncSize();
|
||||||
if (!this.hasFit) this.fit(); // first real size → fit once
|
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
|
// Redraw SYNCHRONOUSLY: syncSize may have reallocated the backing
|
||||||
// store (which clears the canvas), and during live window resizes
|
// store (which clears the canvas), and during live window resizes
|
||||||
// rAF is starved by relayout — a deferred draw would leave the
|
// rAF is starved by relayout — a deferred draw would leave the
|
||||||
@@ -996,6 +1020,8 @@ export class GraphCanvasRenderer {
|
|||||||
this.canvas.height = bh;
|
this.canvas.height = bh;
|
||||||
}
|
}
|
||||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
this.viewW = w;
|
||||||
|
this.viewH = h;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Derive ALL world geometry from data + visibility + settings.
|
/** Derive ALL world geometry from data + visibility + settings.
|
||||||
|
|||||||
+60
-18
@@ -100,8 +100,13 @@ export function ModelControl({
|
|||||||
const [open, setOpen] = useState<"model" | "thinking" | null>(null);
|
const [open, setOpen] = useState<"model" | "thinking" | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 searchRef = useRef<HTMLInputElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listRef = useRef<HTMLUListElement>(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 +119,11 @@ export function ModelControl({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open === "model") searchRef.current?.focus();
|
if (open === "model") searchRef.current?.focus();
|
||||||
}, [open]);
|
}, [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
|
// The intuitive dropdown contract (owner, stats-control round 1): any
|
||||||
// click outside the open menu — or an Esc keypress — closes it. Only the
|
// click outside the open menu — or an Esc keypress — closes it. Only the
|
||||||
@@ -182,6 +192,7 @@ export function ModelControl({
|
|||||||
setOpen("model");
|
setOpen("model");
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setMenuIdx(null);
|
||||||
};
|
};
|
||||||
window.addEventListener("lh:kbd-open-model-picker", onOpenPicker);
|
window.addEventListener("lh:kbd-open-model-picker", onOpenPicker);
|
||||||
return () => window.removeEventListener("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);
|
return () => window.removeEventListener("lh:kbd-cycle-thinking", onCycleThinking);
|
||||||
}, [levels, effectiveLevel, save, settings]);
|
}, [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.
|
// The filtered, ranked model list.
|
||||||
const results = (catalog?.models ?? [])
|
const results = (catalog?.models ?? [])
|
||||||
.map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) }))
|
.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"));
|
setOpen((o) => (o === "model" ? null : "model"));
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setMenuIdx(null);
|
||||||
}}
|
}}
|
||||||
aria-expanded={open === "model"}
|
aria-expanded={open === "model"}
|
||||||
title="Conversation model — applies next turn"
|
title="Conversation model — applies next turn"
|
||||||
@@ -249,7 +274,34 @@ export function ModelControl({
|
|||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
data-testid="model-search"
|
data-testid="model-search"
|
||||||
value={query}
|
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…"
|
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"
|
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 && (
|
{catalog && results.length === 0 && (
|
||||||
<div className="px-2 py-3 text-center text-muted-foreground">No models match.</div>
|
<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">
|
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto">
|
||||||
{results.map((m) => (
|
{results.map((m, i) => (
|
||||||
<li key={`${m.provider}/${m.id}`}>
|
<li key={`${m.provider}/${m.id}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
|
data-testid={`model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
|
||||||
onClick={() => {
|
data-idx={i}
|
||||||
// Keep the thinking level only when the new model
|
onClick={() => pickModel(m)}
|
||||||
// supports it — otherwise drop it (config fallback
|
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 ${
|
||||||
// clamps honestly; a carry-over would 400).
|
i === menuIdx ? "bg-muted ring-1 ring-border" : ""
|
||||||
const nextLevel =
|
}${m.id === effectiveId && i !== menuIdx ? " bg-muted/60" : ""}`}
|
||||||
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" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span className="flex items-center justify-between gap-2">
|
<span className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-card-foreground">{m.name}</span>
|
<span className="truncate text-card-foreground">{m.name}</span>
|
||||||
|
|||||||
@@ -55,6 +55,18 @@ second pillar of input).
|
|||||||
| Cheat sheet | ? | keyboard-shortcuts.tsx |
|
| Cheat sheet | ? | keyboard-shortcuts.tsx |
|
||||||
| Find (graph: node search) | ⌘F | `lh:kbd-find` (graph-view.tsx; chat transcript search rides the same event when that feature lands) |
|
| Find (graph: node search) | ⌘F | `lh:kbd-find` (graph-view.tsx; chat transcript search rides the same event when that feature lands) |
|
||||||
|
|
||||||
|
## Model menu keyboard machine (owner polish, 2026-09-17)
|
||||||
|
|
||||||
|
While the model menu is open, focus stays in the search field and the
|
||||||
|
menu owns ↑/↓/Enter inside the input's own handler:
|
||||||
|
|
||||||
|
- **↑/↓** walk the filtered results — clamped, no wrap. First ↓ starts at
|
||||||
|
the first row; first ↑ at the last. The walked row gets a muted ring and
|
||||||
|
scrollIntoView.
|
||||||
|
- **Enter** picks the walked row, or the **first result** when nothing is
|
||||||
|
walked yet (owner decision). Typing a new query resets the walk.
|
||||||
|
- Clicking and Enter share one `pickModel` path; Esc still closes the menu.
|
||||||
|
|
||||||
## Question dock row machine (Wave B, owner rounds 2026-09-17)
|
## Question dock row machine (Wave B, owner rounds 2026-09-17)
|
||||||
|
|
||||||
While a question is expanded, the dock owns ↑/↓/Space/Enter/Tab:
|
While a question is expanded, the dock owns ↑/↓/Space/Enter/Tab:
|
||||||
|
|||||||
@@ -225,3 +225,17 @@ LinLog-only FA2 (one afternoon), then the two-level pipeline
|
|||||||
(geometry already unit-tested in `components/cluster-geometry.ts`).
|
(geometry already unit-tested in `components/cluster-geometry.ts`).
|
||||||
- Label-gate hysteresis: small dead-band in log-zoom to avoid threshold flicker.
|
- Label-gate hysteresis: small dead-band in log-zoom to avoid threshold flicker.
|
||||||
- Whether layout runs server-side at extraction (persisted as data) or in-browser.
|
- Whether layout runs server-side at extraction (persisted as data) or in-browser.
|
||||||
|
|
||||||
|
## Center-anchor on window resize (2026-09-17)
|
||||||
|
|
||||||
|
The camera translate is measured from the canvas's TOP-LEFT corner, so a
|
||||||
|
window resize used to keep the top-left pinned and the graph appeared to
|
||||||
|
slide by half the size delta (owner report). The resize observer now
|
||||||
|
captures the world point under the PRE-resize viewport center (from the
|
||||||
|
last-synced `viewW/H` — the ResizeObserver already sees the new size in
|
||||||
|
clientWidth/Height), then re-anchors the translate so that same world
|
||||||
|
point lands under the NEW center (applied through the zoom behavior, so
|
||||||
|
d3 state, camera persistence, and card-dismiss all behave like any
|
||||||
|
camera move). Zoom (k) is untouched; `refreshFitZoom` metadata is
|
||||||
|
viewport-bound and recomputed as before. Verified by canvas pixel
|
||||||
|
sampling: 7/7 center-area samples identical across a 300×200 shrink.
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export type KeyAction =
|
|||||||
| "rename-session"
|
| "rename-session"
|
||||||
| "cheat-sheet"
|
| "cheat-sheet"
|
||||||
| "find"
|
| "find"
|
||||||
|
| "model-menu-walk"
|
||||||
|
| "model-menu-pick"
|
||||||
| "question-focus"
|
| "question-focus"
|
||||||
| "question-next-row"
|
| "question-next-row"
|
||||||
| "question-prev-row"
|
| "question-prev-row"
|
||||||
@@ -76,6 +78,8 @@ export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [
|
|||||||
// the keys while a question is expanded; the registry never dispatches
|
// the keys while a question is expanded; the registry never dispatches
|
||||||
// them, so bare arrows stay free everywhere else.
|
// them, so bare arrows stay free everywhere else.
|
||||||
{ id: "question-focus", label: "Question · focus thinking ↔ rows", scope: "chat", binding: "tab", displayOnly: true },
|
{ id: "question-focus", label: "Question · focus thinking ↔ rows", scope: "chat", binding: "tab", displayOnly: true },
|
||||||
|
{ id: "model-menu-walk", label: "Model menu · walk results", scope: "global", binding: "arrowup", displayOnly: true },
|
||||||
|
{ id: "model-menu-pick", label: "Model menu · pick result", scope: "global", binding: "enter", displayOnly: true },
|
||||||
{ id: "question-next-row", label: "Question · next row", scope: "chat", binding: "arrowdown", displayOnly: true },
|
{ id: "question-next-row", label: "Question · next row", scope: "chat", binding: "arrowdown", displayOnly: true },
|
||||||
{ id: "question-prev-row", label: "Question · previous row", scope: "chat", binding: "arrowup", displayOnly: true },
|
{ id: "question-prev-row", label: "Question · previous row", scope: "chat", binding: "arrowup", displayOnly: true },
|
||||||
{ id: "question-pick-row", label: "Question · pick row", scope: "chat", binding: "space", displayOnly: true },
|
{ id: "question-pick-row", label: "Question · pick row", scope: "chat", binding: "space", displayOnly: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user