diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx index f21942f..3fe0d1c 100644 --- a/components/ai-elements/reasoning.tsx +++ b/components/ai-elements/reasoning.tsx @@ -1,6 +1,7 @@ "use client"; import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { Collapsible, CollapsibleContent, @@ -159,6 +160,11 @@ export const ReasoningTrigger = memo( className )} {...props} + /* FOCUS HYGIENE (owner, keyboard round 2026-10-10): a pointer click + on the thinking toggle must not leave focus here — the learner's + next Enter submits, it must not re-toggle. Radix composes this + with its own toggle handler. */ + onClick={(e) => blurAfterPointerClick(e)} > {children ?? ( <> diff --git a/components/chat.tsx b/components/chat.tsx index f4153f1..dc677bc 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -24,6 +24,7 @@ import { artifactRenderers } from "@/components/artifacts"; import { SubagentSteerCard } from "@/components/artifacts/steer-card"; import { Composer } from "@/components/composer"; import { useEnterSubmits } from "@/components/enter-submits"; +import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { dockStateKey } from "@/components/dock-state-key"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; @@ -1264,7 +1265,10 @@ export function Chat({ diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 027ea46..2f4d898 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -189,14 +189,69 @@ export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ const pts: Array<{ x: number; y: number }> | undefined = g.edge(e.from, e.to)?.points; if (pts) edgePts.set(`${e.from}->${e.to}`, pts.map((pt) => ({ x: pt.x, y: pt.y }))); } + // THE CANVAS MUST COVER THE ARROWS (owner, 2026-10-10): dagre's + // graph extent is the NODE bounding box — the edge curves drawn on + // top can bow past it (cross-column edges' bezier control points; + // measured 14–20px cut on the right in the owner's plans) and the + // viewBox clipped them. The ink extent unions the node boxes with + // every edge path's control-point hull: a bezier never leaves the + // hull of its control points, and parsing the d-string edgePathD + // builds (the SAME call the renderer makes, same inputs) bounds every + // segment — M, L and C pairs alternate strictly, so even/odd matches + // are x/y. Dagre's extent stays the FLOOR: plans whose arrows stay + // inside keep byte-identical viewBoxes (no reflow of every pinned + // render), and the origin only moves when ink bulges past zero. + let inkMinX = Infinity; + let inkMinY = Infinity; + let inkMaxX = -Infinity; + let inkMaxY = -Infinity; + const stretch = (px: number, py: number) => { + if (px < inkMinX) inkMinX = px; + if (py < inkMinY) inkMinY = py; + if (px > inkMaxX) inkMaxX = px; + if (py > inkMaxY) inkMaxY = py; + }; + for (const n of nodes) { + const nx = x.get(n.id); + const ny = y.get(n.id); + if (nx === undefined || ny === undefined) continue; + stretch(nx, ny); + stretch(nx + (nodeW.get(n.id) ?? NODE_W_MIN), ny + (nodeH.get(n.id) ?? NODE_H)); + } + for (const e of routed) { + const pts = edgePts.get(`${e.from}->${e.to}`); + if (!pts) continue; + const tx = x.get(e.to); + const ty = y.get(e.to); + const target = + tx !== undefined && ty !== undefined + ? { + x: tx, + y: ty, + w: nodeW.get(e.to) ?? NODE_W_MIN, + h: nodeH.get(e.to) ?? NODE_H, + } + : undefined; + const nums = edgePathD(pts, target).match(/-?\d+(?:\.\d+)?/g); + if (!nums) continue; + for (let i = 0; i + 1 < nums.length; i += 2) { + stretch(parseFloat(nums[i]!), parseFloat(nums[i + 1]!)); + } + } + // Dagre coordinates start at 0; only ink past zero on the negative + // side moves the origin (viewBox x/y shift with it). + const originX = Math.min(0, inkMinX === Infinity ? 0 : inkMinX); + const originY = Math.min(0, inkMinY === Infinity ? 0 : inkMinY); return { x, y, nodeH, nodeW, edgePts, - width: Math.max(g.graph().width ?? 0, 1), - height: Math.max(g.graph().height ?? 0, 1), + originX, + originY, + width: Math.max((g.graph().width ?? 0) - originX, inkMaxX === -Infinity ? 0 : inkMaxX - originX, 1), + height: Math.max((g.graph().height ?? 0) - originY, inkMaxY === -Infinity ? 0 : inkMaxY - originY, 1), }; } @@ -462,9 +517,14 @@ export function PlanDiagram({ const h = Math.max(layout.height, 1); const vbW = w + 2 * pad; const vbH = h + 2 * pad; + // The origin rides the ink extent (owner, 2026-10-10): an arrow + // bulging past zero shifts the viewBox with it instead of clipping. + // Zero-origin plans (the ordinary case) render exactly as before. + const ox = viewBoxOverride ? 0 : (layout.originX ?? 0); + const oy = viewBoxOverride ? 0 : (layout.originY ?? 0); return ( (null); const searchRef = useRef(null); const listRef = useRef(null); @@ -601,42 +603,6 @@ function SessionsSidebar() { return () => window.removeEventListener("lh:kbd-sessions-search", onFocusSearch); }, [open, toggle]); - useEffect(() => { - const cycle = (dir: 1 | -1) => { - setKbdIndex((i) => { - const n = shown?.length ?? 0; - if (n === 0) return null; - if (i === null) return dir === 1 ? 0 : n - 1; - return (i + dir + n) % n; - }); - }; - const onPrev = () => { - if (!open) toggle(); - cycle(-1); - }; - const onNext = () => { - if (!open) toggle(); - cycle(1); - }; - const onOpenSelected = () => { - const target = kbdIndex !== null ? shown?.[kbdIndex] : undefined; - if (target) { - router.push(`/chat/${target.id}`); - setKbdIndex(null); - } - }; - window.addEventListener("lh:kbd-cycle-session-prev", onPrev); - window.addEventListener("lh:kbd-cycle-session-next", onNext); - window.addEventListener("lh:kbd-open-session", onOpenSelected); - return () => { - window.removeEventListener("lh:kbd-cycle-session-prev", onPrev); - window.removeEventListener("lh:kbd-cycle-session-next", onNext); - window.removeEventListener("lh:kbd-open-session", onOpenSelected); - }; - // kbdIndex is read (not upserted) so Alt+Enter pushes OUTSIDE any - // state updater — a router.push inside one is setState-in-render. - }, [open, toggle, shown, router, kbdIndex]); - // Keep the kbd-selected row in view (the rows are the container's only // buttons, so the index matches). useEffect(() => { @@ -733,14 +699,48 @@ function SessionsSidebar() { ref={searchRef} data-testid="session-search" value={query} - onChange={(e) => setQuery(e.target.value)} - // Esc leaves the search (owner, keyboard wave): blur back - // to normal, and claim the key so the window Esc chain - // (dictation / abort) doesn't also fire. + onChange={(e) => { + setQuery(e.target.value); + // The list reorders under the query every keystroke — + // a stale walk index would point at the wrong row. + setKbdIndex(null); + }} + // THE SEARCH OWNS THE WALK (owner, 2026-10-10 — the + // retired Alt+↑/↓/↵ cycle's successor): ↑/↓ cycle the + // filtered rows (wrap), Enter opens the walked row — + // or the FIRST match when nothing is walked (the + // quick-open pattern) — then blurs (focus returns to + // the app per the Enter law; the query stays for + // re-searching). Esc leaves the search (owner, + // keyboard wave): blur back to normal, and claim the + // key so the window Esc chain (dictation / abort) + // doesn't also fire. onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); e.currentTarget.blur(); + return; + } + // Modified keys belong to the surface (⇧↵ etc.). + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; + const n = shown?.length ?? 0; + if (n === 0) return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + // The rows move, not the caret. + e.preventDefault(); + const dir = e.key === "ArrowDown" ? 1 : -1; + setKbdIndex((i) => + i === null || i >= n ? (dir === 1 ? 0 : n - 1) : (i + dir + n) % n, + ); + return; + } + if (e.key === "Enter") { + const target = shown![kbdIndex !== null && kbdIndex < n ? kbdIndex : 0]; + if (!target) return; + e.preventDefault(); + router.push(`/chat/${target.id}`); + setKbdIndex(null); + e.currentTarget.blur(); } }} placeholder="Search…" diff --git a/components/thinking-block.tsx b/components/thinking-block.tsx index 484030b..01ca095 100644 --- a/components/thinking-block.tsx +++ b/components/thinking-block.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { ChevronDownIcon } from "lucide-react"; +import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { cn } from "@/lib/utils"; /** The unified user-thinking row (owner round, 2026-10-05): ONE visual @@ -115,7 +116,10 @@ export function ThinkingBlock({ "); + +function buttonIn(attrs: Record): HTMLElement { + const host = document.createElement("div"); + for (const [k, v] of Object.entries(attrs)) host.setAttribute(k, v); + const b = document.createElement("button"); + b.type = "button"; + host.appendChild(b); + document.body.appendChild(host); + return b; +} + +describe("buttonKeepsNativeEnter (the pointer/keyboard Enter split)", () => { + it("a transcript button (pointer-focused) does NOT keep native Enter — Enter submits", () => { + const b = buttonIn({}); + assert.equal(buttonKeepsNativeEnter(b), false); + }); + + it("a keyboard-focused button keeps native Enter (:focus-visible)", () => { + const b = buttonIn({}); + // linkedom lacks the pseudo — stub the one call the helper makes. + (b as unknown as { matches: (sel: string) => boolean }).matches = (sel: string) => + sel === ":focus-visible"; + assert.equal(buttonKeepsNativeEnter(b), true); + }); + + it("buttons inside overlays keep native Enter: hand-rolled menu, Radix popper, dialog, menu role", () => { + assert.equal(buttonKeepsNativeEnter(buttonIn({ "data-lh-menu": "" })), true); + assert.equal( + buttonKeepsNativeEnter(buttonIn({ "data-radix-popper-content-wrapper": "" })), + true, + ); + assert.equal(buttonKeepsNativeEnter(buttonIn({ role: "dialog" })), true); + assert.equal(buttonKeepsNativeEnter(buttonIn({ role: "menu" })), true); + }); + + it("a selector engine without :focus-visible degrades to pointer-focus, never throws", () => { + const b = buttonIn({}); + assert.doesNotThrow(() => buttonKeepsNativeEnter(b)); + assert.equal(buttonKeepsNativeEnter(b), false); + }); +}); + +describe("blurAfterPointerClick (focus hygiene)", () => { + it("a POINTER click (detail > 0) blurs the transient control", () => { + const b = buttonIn({}); + let blurred = false; + b.blur = () => { + blurred = true; + }; + blurAfterPointerClick({ detail: 1, currentTarget: b }); + assert.equal(blurred, true); + }); + + it("a KEYBOARD-activated click (detail 0) keeps focus", () => { + const b = buttonIn({}); + let blurred = false; + b.blur = () => { + blurred = true; + }; + blurAfterPointerClick({ detail: 0, currentTarget: b }); + assert.equal(blurred, false); + }); +}); diff --git a/tests/keyboard-toggle-sidebar.test.ts b/tests/keyboard-toggle-sidebar.test.ts index 60ecc8a..fe48756 100644 --- a/tests/keyboard-toggle-sidebar.test.ts +++ b/tests/keyboard-toggle-sidebar.test.ts @@ -5,6 +5,7 @@ import { bindingForAction, formatBinding, kbdEventForAction, + KEYBOARD_ACTIONS, } from "../src/core/keyboard.ts"; import { kbdEventForAction as kbdEventForActionFromUi } from "../components/keyboard-shortcuts.tsx"; @@ -54,4 +55,21 @@ describe("the thinking pill suffix — ⇧⇥ via the registry", () => { assert.equal(actionForBinding("shift+tab"), "cycle-thinking"); assert.equal(formatBinding("shift+tab"), "⇧⇥"); }); +}); + +describe("the Alt session cycle is RETIRED (owner, 2026-10-10)", () => { + // Barely used, felt laggy — session navigation lives IN the search + // now (↑/↓ walk the filtered list, Enter opens; sidebar.tsx). + it("⌥↑ / ⌥↓ / ⌥↵ bind to NOTHING — no stray action fires", () => { + assert.equal(actionForBinding("alt+arrowup") ?? null, null); + assert.equal(actionForBinding("alt+arrowdown") ?? null, null); + assert.equal(actionForBinding("alt+enter") ?? null, null); + }); + + it("the actions are gone from the table (cheat sheet + settings render the registry)", () => { + const ids = KEYBOARD_ACTIONS.map((a) => a.id as string); + assert.ok(!ids.includes("cycle-session-prev")); + assert.ok(!ids.includes("cycle-session-next")); + assert.ok(!ids.includes("open-session")); + }); }); \ No newline at end of file diff --git a/tests/plan-window.test.tsx b/tests/plan-window.test.tsx index 4b4d68e..66d9b98 100644 --- a/tests/plan-window.test.tsx +++ b/tests/plan-window.test.tsx @@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { PlanMarginalia, planExists } from "../components/plan-window.tsx"; -import { blockLayout, type PopupMessage } from "../components/plan-block.tsx"; +import { blockLayout, edgePathD, type LayoutNode, type PopupMessage } from "../components/plan-block.tsx"; import type { PlanTree } from "../src/core/contracts.ts"; // The plan marginalia: the bare diagram in the dock band's right flank — @@ -88,3 +88,82 @@ describe("PlanMarginalia render", () => { ); }); }); + +// ─── The canvas must cover the arrows (owner, 2026-10-10) ──────────────────── +// The owner's REAL linear-algebra plan (chat_mv19m6xe, plan_init's mermaid +// parsed to nodes/edges — golden fixtures use real data, owner ruling +// 2026-10-10): a cross-column edge's bezier control points reached x=776 +// while dagre's node-extent width was 754 — 14px of arrow clipped at the +// viewBox edge (the Hume plan cut 20px the same way). +const LINEAR_ALGEBRA_NODES: LayoutNode[] = [ +{ id: "vector-space-axioms", title: "What a vector space is" }, + { id: "span-basis-dimension", title: "Span, basis, dimension" }, + { id: "matrix-as-map", title: "Matrices as maps" }, + { id: "elimination-rank", title: "Elimination exposes rank" }, + { id: "determinant-volume", title: "Det as volume scaling" }, + { id: "orthogonality-complements", title: "Orthogonality and complements" }, + { id: "projection-theorem", title: "The projection theorem" }, + { id: "transpose-adjoint", title: "The transpose as bridge" }, + { id: "normal-equations", title: "Least squares, normal equations" }, + { id: "eigen-diagonalization", title: "Eigenvectors as coordinates" }, + { id: "spectral-theorem", title: "The spectral theorem" }, + { id: "pca-synthesis", title: "Goal: PCA and least-squares derivations" } +]; +const LINEAR_ALGEBRA_EDGES: ReadonlyArray<{ from: string; to: string }> = [ +{ from: "span-basis-dimension", to: "matrix-as-map" }, + { from: "matrix-as-map", to: "elimination-rank" }, + { from: "matrix-as-map", to: "determinant-volume" }, + { from: "matrix-as-map", to: "orthogonality-complements" }, + { from: "span-basis-dimension", to: "orthogonality-complements" }, + { from: "orthogonality-complements", to: "projection-theorem" }, + { from: "orthogonality-complements", to: "transpose-adjoint" }, + { from: "matrix-as-map", to: "transpose-adjoint" }, + { from: "projection-theorem", to: "normal-equations" }, + { from: "transpose-adjoint", to: "normal-equations" }, + { from: "matrix-as-map", to: "normal-equations" }, + { from: "determinant-volume", to: "eigen-diagonalization" }, + { from: "matrix-as-map", to: "eigen-diagonalization" }, + { from: "span-basis-dimension", to: "eigen-diagonalization" }, + { from: "eigen-diagonalization", to: "spectral-theorem" }, + { from: "transpose-adjoint", to: "spectral-theorem" }, + { from: "orthogonality-complements", to: "spectral-theorem" }, + { from: "spectral-theorem", to: "pca-synthesis" }, + { from: "normal-equations", to: "pca-synthesis" } +]; + +describe("blockLayout ink extent", () => { + it("covers every edge path's control points — arrows never clip (owner, 2026-10-10)", () => { + const L = blockLayout(LINEAR_ALGEBRA_NODES, LINEAR_ALGEBRA_EDGES); + // The viewBox extent must reach the furthest control point of + // every drawn edge (a bezier never leaves its control-point hull). + let ctrlMaxX = -Infinity; + for (const e of LINEAR_ALGEBRA_EDGES) { + const pts = L.edgePts.get(`${e.from}->${e.to}`); + if (!pts) continue; + const target = { + x: L.x.get(e.to)!, + y: L.y.get(e.to)!, + w: L.nodeW.get(e.to)!, + h: L.nodeH.get(e.to)!, + }; + const nums = edgePathD(pts, target).match(/-?\d+(?:\.\d+)?/g) ?? []; + for (let i = 0; i + 1 < nums.length; i += 2) + ctrlMaxX = Math.max(ctrlMaxX, parseFloat(nums[i]!)); + } + assert.ok(ctrlMaxX > -Infinity, "the fixture has edges"); + assert.ok( + ctrlMaxX <= (L.originX ?? 0) + L.width + 0.5, + `control point ${ctrlMaxX} exceeds the canvas right edge ${(L.originX ?? 0) + L.width}`, + ); + }); + + it("the real plan's overhang actually EXTENDS the canvas past the node extent", () => { + const L = blockLayout(LINEAR_ALGEBRA_NODES, LINEAR_ALGEBRA_EDGES); + let nodeMaxX = -Infinity; + for (const n of LINEAR_ALGEBRA_NODES) + nodeMaxX = Math.max(nodeMaxX, (L.x.get(n.id) ?? 0) + (L.nodeW.get(n.id) ?? 0)); + // Pre-fix dagre width was 754 with ink at 776 — width must stay ≥ + // ink, which here strictly exceeds the node extent. + assert.ok(L.width > nodeMaxX + 1, `layout width ${L.width} should exceed node extent ${nodeMaxX}`); + }); +}); diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx index 28067ec..b3ed9b3 100644 --- a/tests/render-settings.test.tsx +++ b/tests/render-settings.test.tsx @@ -274,6 +274,10 @@ describe("KeyboardSection (thin SSR)", () => { const html = renderToStaticMarkup(); assert.ok(html.includes("Fixed — part of this screen")); assert.ok(!html.includes(`data-testid="kbd-rebind-question-next-row"`), "surface keys stay put"); - assert.ok(!html.includes(`data-testid="kbd-rebind-model-menu-pick"`)); + // The model-menu entries are GONE entirely (owner, 2026-10-10): + // obvious in-field navigation, not settings material. + assert.ok(!html.includes("Model menu"), "model-menu rows removed"); + assert.ok(!KEYBOARD_ACTIONS.some((a) => a.id as string === "model-menu-walk")); + assert.ok(!KEYBOARD_ACTIONS.some((a) => a.id as string === "model-menu-pick")); }); }); \ No newline at end of file