ui: plan canvas ink extent + the keyboard-first round

Plan canvas (owner report: arrows clipped at the viewBox edge in dist —
measured 14px/20px cuts in the owner's real plans): blockLayout now
unions node boxes with every edge path's control-point hull (a bezier
never leaves it; the layout calls edgePathD with the renderer's exact
inputs). Dagre's node extent stays the floor, so ordinary plans render
byte-identical; the viewBox origin shifts only when ink bulges past
zero. Verified on both real chats; pins use the owner's real
linear-algebra tree (golden-real-data).

Keyboard-first (owner's law: Enter always submits, arrows always cycle;
text fields the only exception):
- Enter guard split (enter-submits): a focused BUTTON keeps native
  Enter only when keyboard-walked (:focus-visible) or inside an open
  overlay (menus/dialogs); pointer-clicked transcript buttons submit,
  preventDefault suppressing the re-activation underneath.
- Focus hygiene (focus-hygiene): pointer clicks on transient controls
  (agent/user thinking toggles, copy, mermaid expand, plan chip) blur
  to body — the state clicking empty space produces.
- Session search owns the walk: Up/Down wrap-cycle the filtered rows,
  Enter opens the walked row or the first match (quick-open), then
  blurs and keeps the query. The Alt-arrow session cycle is RETIRED.
- Registry: model-menu walk/pick entries removed (obvious in-field
  navigation, not settings material); question-dock keys stay
  displayOnly per owner ruling.

Gates: 1489/1489, typecheck, lint. Verified live end-to-end.
This commit is contained in:
amosblomqvist
2026-10-10 09:43:12 +03:00
parent 643e7596f0
commit 26bc305d66
14 changed files with 403 additions and 72 deletions
+6
View File
@@ -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 ?? (
<>
+5 -1
View File
@@ -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({
<button
type="button"
data-testid="plan-chip"
onClick={() => setPlanOpen(true)}
onClick={(e) => {
blurAfterPointerClick(e);
setPlanOpen(true);
}}
className="lh-no-drag flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground"
>
<WaypointsIcon className="size-3.5 text-primary" aria-hidden />
+37 -3
View File
@@ -13,10 +13,23 @@
* What the window listener refuses to touch:
* - typing surfaces (input/textarea/select/contenteditable) — the focused
* surface owns its Enter (the dock field, the composer, settings);
* - BUTTONS — Enter on a focused button is its click (keyboard button
* semantics: the collapsed Problem bar must expand, not submit);
* - modified Enter (⇧↵ is a newline, ⌘/Ctrl↵ belongs to the surface).
*
* BUTTONS — the pointer/keyboard split (owner, keyboard round 2026-10-10):
* a mouse click on a transcript control (a thinking toggle, a copy button)
* leaves focus ON that button; the learner's next Enter must SUBMIT, not
* re-activate the toggle — the report that born this rule. But two button
* populations keep native Enter-activates:
* 1. KEYBOARD-focused buttons — :focus-visible means focus arrived by
* keyboard (the arrow-walked menus; Tab is dead app-wide, so this
* only happens inside overlays);
* 2. buttons inside an OPEN OVERLAY — menus walk focus with arrows and
* Enter activates the walked button natively (menu-shell's contract),
* dialogs are their own surfaced layer (the ONE-ESC LAW's layers).
* The pointer-clicked transcript button fails both tests → Enter is ours
* (preventDefault also suppresses the browser's default activation, so
* the toggle does not fire underneath the submit).
*
* The submit callbacks self-guard (empty thinking / canSubmit) — this hook
* never checks answer validity, only focus honesty.
*/
@@ -24,6 +37,26 @@
import { useEffect, useRef } from "react";
import { isTypingSurface } from "@/src/core";
/** Overlay roots whose buttons keep native Enter (hand-rolled menus mark
* themselves data-lh-menu; Radix poppers/dialogs carry their own attrs). */
const OVERLAY_ROOTS = '[data-lh-menu], [data-radix-popper-content-wrapper], [role="menu"], [role="dialog"]';
/** Pure decision, exported for pins: does a focused BUTTON keep native
* Enter (true) or does Enter belong to submit (false)? */
export function buttonKeepsNativeEnter(el: HTMLElement): boolean {
// Keyboard-walked focus: the menu arrows landed here — Enter activates.
// (Guarded: a selector engine without the pseudo — linkedom in the
// test harness — degrades to "not keyboard focus", never throws on
// every Enter.)
try {
if (el.matches(":focus-visible")) return true;
} catch {
// unsupported pseudo — treat as pointer focus
}
// Inside an open overlay: the overlay owns its keys.
return !!el.closest(OVERLAY_ROOTS);
}
/** Mount-scoped: while the dock is mounted (a problem/question is active),
* plain Enter anywhere outside a typing surface or button submits it. */
export function useEnterSubmits(submit: () => void): void {
@@ -34,7 +67,8 @@ export function useEnterSubmits(submit: () => void): void {
if (e.key !== "Enter") return;
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
if (isTypingSurface(e)) return;
if ((e.target as HTMLElement | null)?.tagName === "BUTTON") return;
const target = e.target as HTMLElement | null;
if (target?.tagName === "BUTTON" && buttonKeepsNativeEnter(target)) return;
e.preventDefault();
submitRef.current();
};
+32
View File
@@ -0,0 +1,32 @@
"use client";
/**
* FOCUS HYGIENE — transient transcript controls don't keep focus
* (owner, keyboard round 2026-10-10). A pointer click on a thinking
* toggle, a copy button, a plan chip used to LEAVE focus on that
* button; the learner's next Enter then re-activated the toggle
* instead of submitting (see enter-submits.ts for the other half of
* the fix). Here we restore, after pointer activation, exactly the
* state clicking empty transcript space produces: focus falls back to
* the body — Enter submits, arrows cycle the question rows, nothing
* re-toggles. Deliberately NOT a focus-into-a-text-field: a focused
* field would own the arrows as caret moves, against the arrows-always
* law (owner, 2026-10-10).
*
* Keyboard-activated clicks are untouched: e.detail === 0 marks the
* synthesized click of Enter/Space on a keyboard-focused button — the
* keyboard user keeps their focus.
*/
/** Attach as (or compose into) a transient control's onClick: blurs the
* control after POINTER clicks (keyboard clicks keep focus). Node-safe
* duck-typing — no HTMLElement global needed (the test harness runs
* bare node with linkedom). */
export function blurAfterPointerClick(e: {
detail: number;
currentTarget: EventTarget | null;
}): void {
if (e.detail === 0) return;
const el = e.currentTarget as { blur?: unknown } | null;
if (el && typeof el.blur === "function") (el.blur as () => void).call(el);
}
+7 -2
View File
@@ -39,6 +39,7 @@ import {
import type { ExtraProps } from "streamdown";
import { MERMAID_FONT_SIZE, renderMermaid } from "@/components/mermaid-render";
import { MermaidError } from "@/components/mermaid-error";
import { blurAfterPointerClick } from "@/components/focus-hygiene";
import { chartSize, useSessionMermaid } from "@/components/session-overlay";
import { Hint } from "@/components/ui/hint";
@@ -101,7 +102,8 @@ export function CopyCorner({
aria-label={label}
data-testid="md-copy"
className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover/md:opacity-100"
onClick={() => {
onClick={(e) => {
blurAfterPointerClick(e);
void copyText(getText()).then((ok) => {
if (!ok) return;
setCopied(true);
@@ -210,7 +212,10 @@ function MermaidInline({ chart }: { chart: string }) {
aria-label="Expand diagram"
data-testid="md-mermaid-expand"
className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/md:opacity-100"
onClick={() => overlay.openMermaid(chart)}
onClick={(e) => {
blurAfterPointerClick(e);
overlay.openMermaid(chart);
}}
>
<MaximizeIcon className="size-3.5" />
</button>
+63 -3
View File
@@ -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 (
<svg
viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`}
viewBox={viewBoxOverride ?? `${ox - pad} ${oy - pad} ${vbW} ${vbH}`}
className={`block ${fill ? "h-full w-full" : `h-auto${naturalSize ? "" : " w-full"}`}${textCapScale ? " mx-auto" : ""}`}
style={{
// The READING font (owner, 2026-09-26) — via the runtime var, NOT
+48 -48
View File
@@ -496,14 +496,16 @@ function SessionsSidebar() {
[],
);
/* ── Keyboard seams (shortcut wave A, 2026-09-17) ── The sidebar owns
every session-list interaction, so the shortcuts land here: ⌘⇧F
focuses the search field; Alt+↑/↓ cycle the list (opening the sidebar
if it's closed — the keys work from anywhere, like the toggle does);
Alt+Enter opens the selected session. The selection is a row index
into `shown`, so a typed query narrows the cycle to the matches.
Mouse and keyboard coexist: hovering doesn't claim, a click or an
open clears the selection. */
/* ── Keyboard seams (shortcut wave A, 2026-09-17; search-walk round
2026-10-10) ── ⌘⇧F focuses the search field — and THE SEARCH OWNS THE
WALK: ↑/↓ cycle the filtered list (wrap — "cycle"), Enter opens the
walked row, or the FIRST match when nothing is walked (owner,
2026-10-10 — the quick-open pattern: ⌘⇧F, type, Enter, done). The
Alt+↑/↓/↵ global cycle was RETIRED the same round: barely used,
felt laggy. The selection is a row index into `shown`, so the walk
follows whatever the query + kind filter leave. Mouse and keyboard
coexist: hovering doesn't claim, a click or an open clears the
selection. */
const [kbdIndex, setKbdIndex] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(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…"
+5 -1
View File
@@ -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({
<button
type="button"
data-testid={testId ? `${testId}-trigger` : undefined}
onClick={() => setOpen((v) => !v)}
onClick={(e) => {
setOpen((v) => !v);
blurAfterPointerClick(e);
}}
aria-expanded={open}
className="flex w-full items-center gap-2 font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
>