From bc6475d90dd68c024ab633b079b7d97c34040230 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 10:03:48 -0700 Subject: [PATCH 01/23] Add deterministic helper overlay placement geometry --- .../wall/terminal-context-placement.test.ts | 41 ++++++++++++++++ .../wall/terminal-context-placement.ts | 49 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 lib/src/components/wall/terminal-context-placement.test.ts create mode 100644 lib/src/components/wall/terminal-context-placement.ts diff --git a/lib/src/components/wall/terminal-context-placement.test.ts b/lib/src/components/wall/terminal-context-placement.test.ts new file mode 100644 index 000000000..73fba8dee --- /dev/null +++ b/lib/src/components/wall/terminal-context-placement.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { cursorHalfSide, placeTerminalContext } from './terminal-context-placement'; +const wall = { x: 0, y: 0, width: 1200, height: 800 }; +describe('terminal context placement', () => { + it('places beside either column without covering the source', () => { + expect(placeTerminalContext(wall, { x: 0, y: 0, width: 596, height: 800 }, true)).toMatchObject({ side: 'right', rect: { x: 604, y: 0, width: 596, height: 800 } }); + expect(placeTerminalContext(wall, { x: 604, y: 0, width: 596, height: 800 }, true)).toMatchObject({ side: 'left', rect: { x: 0, y: 0, width: 596, height: 800 } }); + }); + it('uses below/above in stacked layouts', () => { + expect(placeTerminalContext(wall, { ...wall, height: 396 }, true).side).toBe('bottom'); + expect(placeTerminalContext(wall, { ...wall, y: 404, height: 396 }, true).side).toBe('top'); + }); + it('breaks equal grid fits right-first and honors manual sides', () => { + const source = { x: 0, y: 0, width: 596, height: 396 }; + expect(placeTerminalContext(wall, source, true).side).toBe('right'); + expect(placeTerminalContext(wall, source, true, 'bottom').side).toBe('bottom'); + }); + it('shrinks into an uneven neighbor and rejects unusable slivers', () => { + expect(placeTerminalContext(wall, { ...wall, width: 800 }, true)).toMatchObject({ mode: 'adjacent', rect: { width: 392 } }); + expect(placeTerminalContext(wall, { ...wall, width: 1000 }, true)).toMatchObject({ mode: 'half', side: 'top' }); + }); + it('uses source halves for single or zoomed panes', () => { + expect(placeTerminalContext(wall, wall, false)).toMatchObject({ rect: { ...wall, height: 400 }, available: ['top', 'bottom'] }); + expect(placeTerminalContext(wall, wall, false, 'bottom').rect.y).toBe(400); + }); + it('keeps even tiny fallback panels inside offset Wall bounds', () => { + const tiny = { x: 40, y: 60, width: 250, height: 180 }; + expect(placeTerminalContext(tiny, tiny, false, 'bottom').rect).toEqual(tiny); + }); + it('keeps an existing side when still usable, then falls back when it is not', () => { + expect(placeTerminalContext(wall, { x: 400, y: 0, width: 380, height: 800 }, true, 'left').side).toBe('left'); + expect(placeTerminalContext(wall, { x: 0, y: 0, width: 380, height: 800 }, true, 'left').side).toBe('right'); + }); +}); +it('samples the visible cursor, treating offscreen and unknown cursors as top', () => { + expect(cursorHalfSide({ baseY: 100, cursorY: 2, viewportY: 100 }, 24)).toBe('bottom'); + expect(cursorHalfSide({ baseY: 100, cursorY: 12, viewportY: 100 }, 24)).toBe('top'); + expect(cursorHalfSide({ baseY: 100, cursorY: 2, viewportY: 0 }, 24)).toBe('top'); + expect(cursorHalfSide({ baseY: 0, cursorY: 2, viewportY: 10 }, 24)).toBe('top'); + expect(cursorHalfSide(undefined, 24)).toBe('top'); +}); diff --git a/lib/src/components/wall/terminal-context-placement.ts b/lib/src/components/wall/terminal-context-placement.ts new file mode 100644 index 000000000..6214d66a9 --- /dev/null +++ b/lib/src/components/wall/terminal-context-placement.ts @@ -0,0 +1,49 @@ +import type { Rect } from '../../lib/lath/model'; + +export type ContextSide = 'right' | 'left' | 'bottom' | 'top'; +export type ContextPlacement = { rect: Rect; side: ContextSide; mode: 'adjacent' | 'half'; available: ContextSide[] }; +const SIDES: ContextSide[] = ['right', 'left', 'bottom', 'top']; +const GAP = 8; +// Compact source/directory/status chrome plus a useful terminal viewport. +const MIN_WIDTH = 360; +const MIN_HEIGHT = 240; +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max)); + +/** Only an on-screen cursor is evidence about which half should stay visible. */ +export function cursorHalfSide(buffer: { baseY: number; cursorY: number; viewportY: number } | undefined, rows: number): ContextSide { + if (!buffer || rows <= 0) return 'top'; + const row = buffer.baseY + buffer.cursorY - buffer.viewportY; + return row >= 0 && row < rows / 2 ? 'bottom' : 'top'; +} + +/** Bounds are in Wall coordinates. Overlays may cover peers, never resize them. */ +export function placeTerminalContext(wall: Rect, source: Rect, multiPane: boolean, preferred?: ContextSide, fallback: ContextSide = 'top'): ContextPlacement { + const right = wall.x + wall.width; + const bottom = wall.y + wall.height; + const candidates = SIDES.map(side => { + const horizontal = side === 'left' || side === 'right'; + const space = side === 'right' ? right - source.x - source.width - GAP + : side === 'left' ? source.x - wall.x - GAP + : side === 'bottom' ? bottom - source.y - source.height - GAP : source.y - wall.y - GAP; + const width = Math.max(0, Math.min(source.width, horizontal ? space : wall.width)); + const height = Math.max(0, Math.min(source.height, horizontal ? wall.height : space)); + return { side, rect: { + x: side === 'right' ? source.x + source.width + GAP : side === 'left' ? source.x - GAP - width : clamp(source.x, wall.x, right - width), + y: side === 'bottom' ? source.y + source.height + GAP : side === 'top' ? source.y - GAP - height : clamp(source.y, wall.y, bottom - height), + width, height, + } }; + }).filter(candidate => multiPane && candidate.rect.width >= MIN_WIDTH && candidate.rect.height >= MIN_HEIGHT); + const chosen = candidates.find(candidate => candidate.side === preferred) + ?? candidates.reduce((best, candidate) => !best || candidate.rect.width * candidate.rect.height > best.rect.width * best.rect.height ? candidate : best, undefined); + if (chosen) return { ...chosen, mode: 'adjacent', available: candidates.map(candidate => candidate.side) }; + + const side = preferred === 'top' || preferred === 'bottom' ? preferred : fallback; + // Small source panes borrow Wall width/height only when the half-pane would be unusable. + const width = Math.min(wall.width, Math.max(source.width, MIN_WIDTH)); + const height = Math.min(wall.height, Math.max(source.height / 2, MIN_HEIGHT)); + return { side, mode: 'half', available: ['top', 'bottom'], rect: { + x: clamp(source.x, wall.x, right - width), + y: clamp(side === 'bottom' ? source.y + source.height - height : source.y, wall.y, bottom - height), + width, height, + } }; +} From 142d6804e22162c130598e9574815d063a5eb7df Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 10:10:29 -0700 Subject: [PATCH 02/23] Anchor helper context beside its source with manual placement controls --- docs/specs/layout.md | 16 ++++++- docs/specs/tiling-engine.md | 1 + lib/src/components/Wall.test.tsx | 39 ++++++++++++++- lib/src/components/wall/LathHost.tsx | 32 ++++++++----- .../components/wall/TerminalContext.test.tsx | 27 +++++++++++ lib/src/components/wall/TerminalContext.tsx | 6 +-- .../wall/TerminalContextOverlay.tsx | 48 +++++++++++++++++++ .../components/wall/TerminalContextView.tsx | 34 +++++++++---- .../wall/terminal-context-placement.ts | 2 +- lib/src/stories/TerminalContext.stories.tsx | 6 ++- scripts/spec-word-budgets.json | 2 +- 11 files changed, 183 insertions(+), 30 deletions(-) create mode 100644 lib/src/components/wall/TerminalContextOverlay.tsx diff --git a/docs/specs/layout.md b/docs/specs/layout.md index ba97ce4c9..5ddef9e2e 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -60,7 +60,21 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- **Must open the terminal context from terminal header, body, and command-mode `a` and `>` entry points.** Browser-only Surfaces and Doors have no context. Tool context displays its primary terminal; `docs/specs/terminal-context.md` → Tool context owns that composition. Application mouse ownership follows `docs/specs/mouse-and-clipboard.md` → Terminal context input. -**Must float the context inside its source Pane with a one-rem inset on every side**, overlapping the header, with a theme-derived edge and raised shadow. Render it in the Lath leaf's overlay slot, outside the body's clipping box, so it follows the leaf's layout without remounting the helper. Keep one context per Wall. Outside pointer press and explicit close dismiss it. No separate context heading or clipboard toolbar is shown. +**Must render one context per Wall in a stable Wall-level overlay**, with a theme-derived edge and raised shadow. Anchor it to the invoking source, outline that source, and follow its painted bounds without resizing panes or remounting the helper. Outside pointer press and explicit close dismiss it. + +**Must choose placement on opening and retain its side while usable.** Never reposition in response to terminal output. Minimized panes do not count; zoom uses single-pane placement. + +| Layout | Placement | +|---|---| +| Multiple visible panes | Outside the source, separated by 8px; match its outer bounds where possible. Choose the largest usable candidate, ties right / left / bottom / top. Align the shared edge, shifting only to stay inside the Wall. | +| No usable adjacent candidate; single or zoomed pane | Source's top or bottom half, opposite its visible terminal cursor sampled on opening; unknown, offscreen, or midpoint cursor defaults to top. | +| Small source or Wall | Expand the half-pane fallback to the minimum usable size, clamped to Wall bounds. | + +**Must offer available side buttons plus Auto**, with destination tooltips, accessible labels, and selected state. Remember manual choices per source for the mounted Wall's lifetime; clear on source removal or Auto. Preserve terminal focus on pointer repositioning. An unavailable choice falls back automatically; no preference is persisted to disk. + +**Must keep source title, directory, and helper actions visible in compact context**, disclosing title explanation, directory actions, ports, and alerts through Details. Bound detail scrolling so the helper retains space. The full state gallery shares the same presentation. + +Source of truth: `placeTerminalContext` in `lib/src/components/wall/terminal-context-placement.ts`; `TerminalContextOverlay` in `lib/src/components/wall/TerminalContextOverlay.tsx`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`. Tests: `lib/src/components/wall/terminal-context-placement.test.ts`, `lib/src/components/wall/TerminalContext.test.tsx`, `lib/src/components/Wall.test.tsx`. **Must reveal the context from the opening pointer position, clamped to its bounds, over 320ms.** Command-mode `a` and `>` use the header's bottom-left; openings without a position use the context's top-left. Keep final layout dimensions throughout the reveal. Start helper creation, settings reads, and port scanning immediately on mount; fade mounted content, including detail dialogs, in over 140ms after 160ms. Reduced motion or disabled layout animation skips both animations and the delay. diff --git a/docs/specs/tiling-engine.md b/docs/specs/tiling-engine.md index 22af4aa0f..a8cb6bad5 100644 --- a/docs/specs/tiling-engine.md +++ b/docs/specs/tiling-engine.md @@ -167,6 +167,7 @@ Source of truth: `lib/src/components/wall/lath-wall-store.ts`; `lib/src/componen - Sashes render from core `sashes()` geometry as sibling divs (hit area widened to 8px, cursor per axis); a drag streams a core `resize` preview from the drag-start tree with the cumulative delta and proposes one commit on pointerup (`onCommitResize`); Escape cancels. **Geometry is reported through `store.setLayoutGeometry` from inside the measuring layout effect, never a passive effect over the rendered size** (rationale); the store's zero-area rejection is the backstop. - Zoom retargets only the chosen leaf to the wall rect inset by `LATH_ZOOM_MARGIN` (half a pane header) and elevates it above tiled/dying panes and sashes, applying the blurred `LATH_ZOOM_SHADOW` while elevated. Unzoom keeps both until the return frame settles. - **The binding never calls `.focus()` and emits no activation events.** Gestures surface as proposals (`onCommitResize`, `onLeafFocused`, the drag callbacks) that the Wall commits. +- **Must host Terminal Context above the tiled leaves**, keeping its terminal mounted during placement changes. `docs/specs/layout.md` → Header context menu owns placement. - The selection ring and kill overlay measure leaf elements through `resolvePaneElement`, which climbs to `[data-lath-leaf]`; `WorkspaceSelectionOverlay` re-measures on every store commit (`revision`) and every animator tick, and **same-identity re-measures snap 1:1**, so the ring tracks kills, restores, and tweens frame-accurately ([layout.md → Ring travel](layout.md#ring-travel) owns its between-panes travel, a JS tween rather than a CSS transition). Source of truth: `BODY_COMPONENTS` / `TAB_COMPONENTS` / `OVERLAY_COMPONENTS` in `lib/src/components/wall/LathHost.tsx`; the `.lath-host` rules in `lib/src/index.css`. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 3f45f1224..4062ade71 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -749,6 +749,7 @@ describe('Wall on the Lath engine', () => { })); }); await flush(); + act(() => container.querySelector('[aria-label="Terminal context details"]')!.click()); const portRow = document.querySelector( '[data-terminal-context] button[aria-label="Open in agent-browser screencast"]', )!; @@ -3122,12 +3123,14 @@ describe('Wall on the Lath engine', () => { }); await flush(); + act(() => container.querySelector('[aria-label="Terminal context details"]')!.click()); const portRow = document.querySelector( '[data-terminal-context] button[aria-label="Open in agent-browser screencast"]', ); expect(portRow).not.toBeNull(); const contextMenu = portRow!.closest('[data-terminal-context]')!; - expect(contextMenu.closest('[data-lath-leaf]')).toBe(header.closest('[data-lath-leaf]')); + expect(contextMenu.closest('[data-lath-leaf]')).toBeNull(); + expect(contextMenu.parentElement?.classList.contains('lath-host')).toBe(true); expect(contextMenu.closest('.lath-leaf-body')).toBeNull(); await act(async () => { portRow!.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -3798,3 +3801,37 @@ it('leaves a reveal for a hidden Workspace unanswered', async () => { expect(refit).not.toHaveBeenCalled(); expect(container.querySelector('[data-terminal-context]')).toBeNull(); }); + +it('moves a retained helper without resizing or replacing its source, and remembers the manual side', async () => { + const retained: helpers.HelperTerminal = { id: 'placement-helper', parentId: 'placement-source', command: '', status: 'preserved' }; + vi.spyOn(helpers, 'getHelper').mockImplementation(id => id === 'placement-source' ? retained : undefined); + const openHelper = vi.spyOn(helpers, 'openHelper').mockResolvedValue(retained); + await act(async () => root.render()); + await flush(); + const source = container.querySelector('[data-lath-leaf="placement-source"]')!; + const sourceStyle = source.getAttribute('style'); + const open = async () => { + act(() => container.querySelector('[data-pane-header-for="placement-source"]')!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))); + await flush(); + }; + await open(); + const menu = container.querySelector('[data-terminal-context]')!; + const terminal = menu.querySelector('[data-helper-terminal]'); + const helper = helpers.getHelper('placement-source'); + expect(helper).toBeDefined(); + expect(terminal).not.toBeNull(); + act(() => menu.querySelector('[aria-label="Place helper at bottom"]')!.click()); + expect(menu.dataset.contextSide).toBe('bottom'); + expect(menu.querySelector('[data-helper-terminal]')).toBe(terminal); + expect(openHelper).toHaveBeenCalledTimes(1); + expect(helpers.getHelper('placement-source')).toBe(helper); + expect(source.getAttribute('style')).toBe(sourceStyle); + expect(container.querySelector('[data-lath-leaf="placement-source"]')).toBe(source); + act(() => menu.querySelector('[aria-label="Close terminal context"]')!.click()); + await flush(); + await open(); + expect(container.querySelector('[data-terminal-context]')!.dataset.contextSide).toBe('bottom'); + expect(helpers.getHelper('placement-source')).toBe(helper); + act(() => container.querySelector('[aria-label="Use automatic helper placement"]')!.click()); + expect(container.querySelector('[data-terminal-context]')!.dataset.contextSide).toBe('top'); +}); diff --git a/lib/src/components/wall/LathHost.tsx b/lib/src/components/wall/LathHost.tsx index e252e5a30..f9db19508 100644 --- a/lib/src/components/wall/LathHost.tsx +++ b/lib/src/components/wall/LathHost.tsx @@ -34,7 +34,8 @@ import { ToolPaneHeader } from './ToolPaneHeader'; import { TerminalPaneHeader } from './TerminalPaneHeader'; import { SurfacePaneHeader } from './SurfacePaneHeader'; import { AlertRingIndicator } from './AlertRingIndicator'; -import { TerminalContext } from './TerminalContext'; +import { TerminalContextOverlay } from './TerminalContextOverlay'; +import type { ContextSide } from './terminal-context-placement'; import { TerminalContextContext, TerminalResizeContext } from './wall-context'; /** Widened pointer target over each (thin) sash band, in px. */ @@ -108,17 +109,9 @@ const TAB_COMPONENTS: Record> = { tool: ToolPaneHeader, }; -/** For a terminal Surface the pane id is its session id (docs/specs/layout.md). - * The terminal context floats over the whole leaf, so it lives here rather than - * in the body, whose clipping box it must escape. */ -function TerminalLeafOverlay({ id, title, params }: PaneProps) { - const { mounted } = useContext(TerminalContextContext); - return ( - <> - - {mounted?.id === id && } - - ); +/** Alerts stay attached to their source leaf; context lives above the Wall. */ +function TerminalLeafOverlay({ id }: PaneProps) { + return ; } // Whole-leaf overlays keyed by `leafMeta.component`: chrome spanning header *and* @@ -305,10 +298,17 @@ export function LathHost({ onExternalDrop?: (target: DropTarget | null) => void; componentsOverride?: LathComponentsOverride; }) { + const { mounted: terminalContext } = useContext(TerminalContextContext); + const contextPreferences = useRef(new Map()); const store = lath.store; const animator = lath.animator; const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); + useEffect(() => { + for (const id of contextPreferences.current.keys()) { + if (!snapshot.leafMeta.has(id)) contextPreferences.current.delete(id); + } + }, [snapshot.leafMeta]); const containerRef = useRef(null); const [size, setSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); @@ -731,6 +731,14 @@ export function LathHost({ ); })} + {terminalContext && frames.has(terminalContext.id) && ( + 1} preferences={contextPreferences.current} /> + )} + {/* Drop-preview overlay: the exact rect the current candidate would commit to, painted in the selection color (translucent fill + solid border). */} {dragPreview && ( diff --git a/lib/src/components/wall/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx index ab7f79407..94491f07b 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -211,3 +211,30 @@ it('uses the Tool primary terminal without creating a helper or offering helper expect(focusSurface).not.toHaveBeenCalled(); openHelper.mockRestore(); terminal.mockRestore(); focusSurface.mockRestore(); }); + +it('keeps the helper mounted while compact details are toggled', async () => { + props.compact = true; + render(); + const terminal = container.querySelector('textarea'); + expect(button('Open in system browser')).toBeNull(); + expect(container.textContent).toContain('pnpm dev'); + expect(container.textContent).toContain('~/repo'); + await click('Terminal context details'); + expect(button('Open in system browser')).not.toBeNull(); + expect(button('Terminal context details').getAttribute('aria-expanded')).toBe('true'); + await click('Terminal context details'); + expect(container.querySelector('textarea')).toBe(terminal); +}); + +it('position buttons preserve input focus and report the destination', async () => { + props.placement = { rect: { x: 0, y: 0, width: 600, height: 400 }, side: 'top', mode: 'half', available: ['top', 'bottom'], manual: false, onChange: vi.fn() }; + render(); + const input = container.querySelector('textarea')!; + act(() => input.focus()); + const down = new MouseEvent('pointerdown', { bubbles: true, cancelable: true }); + act(() => button('Place helper at bottom').dispatchEvent(down)); + expect(down.defaultPrevented).toBe(true); + await click('Place helper at bottom'); + expect(props.placement.onChange).toHaveBeenCalledWith('bottom'); + expect(document.activeElement).toBe(input); +}); diff --git a/lib/src/components/wall/TerminalContext.tsx b/lib/src/components/wall/TerminalContext.tsx index a3be289e1..a083bbec3 100644 --- a/lib/src/components/wall/TerminalContext.tsx +++ b/lib/src/components/wall/TerminalContext.tsx @@ -4,7 +4,7 @@ import { NotepadHeaderButton } from './NotepadHeaderButton'; import { isSurfaceClosing } from '../../lib/notepad/notepad-store'; import { messageOf } from '../../lib/errors'; import { TerminalPane } from '../TerminalPane'; -import { TerminalContextView, type ContextScan } from './TerminalContextView'; +import { TerminalContextView, type ContextScan, type TerminalContextViewProps } from './TerminalContextView'; import { TerminalContextContext, WallActionsContext, type TerminalContextState } from './wall-context'; import { disposeHelper, getHelper, helperRevision, openHelper, setHelperVisible, subscribeHelpers } from '../../lib/helper-terminal'; import { getPlatform, IS_MAC, IS_WINDOWS } from '../../lib/platform'; @@ -14,7 +14,7 @@ import { writeTextToClipboard } from '../../lib/clipboard'; import { listenerUrlsByPort } from './port-url'; import { DEFAULT_HELPER_COMMAND } from '../../lib/terminal-context-types'; -export function TerminalContext({ id, title, closing, origin, warning: openWarning, tool = false }: TerminalContextState & { title?: string; tool?: boolean }) { +export function TerminalContext({ id, title, closing, origin, warning: openWarning, tool = false, presentation }: TerminalContextState & { title?: string; tool?: boolean; presentation?: Pick }) { const context = useContext(TerminalContextContext); const actions = useContext(WallActionsContext); const states = useSyncExternalStore(subscribeToTerminalPaneState, getTerminalPaneStateSnapshot); @@ -52,7 +52,7 @@ export function TerminalContext({ id, title, closing, origin, warning: openWarni const copy = async (value: string) => { if (!await writeTextToClipboard(value)) throw new Error('Could not copy to clipboard'); }; const mismatch = !!helper && !!cwd && !!helperCwd && (cwd.path !== helperCwd.path || cwd.isRemote !== helperCwd.isRemote || (cwd.isRemote && cwd.host !== helperCwd.host)); const warning = openWarning ?? (helperError || (helper && helper.status !== 'waiting' && (!cwd || !helperCwd) ? 'Directory comparison unavailable: a terminal has not reported its directory.' : undefined)); - return ; +}) { + const [cursorSide] = useState(() => { + const terminal = getTerminalInstance(context.id); + return cursorHalfSide(terminal?.buffer.active, terminal?.rows ?? 0); + }); + const [manual, setManual] = useState(() => preferences.get(context.id)); + const lastSide = useRef(manual); + const [painted, setPainted] = useState(source); + useLayoutEffect(() => { + const update = () => { + const next = lath.animator.framesAt(nowMs()).get(context.id)?.rect ?? source; + setPainted(previous => previous.x === next.x && previous.y === next.y && previous.width === next.width && previous.height === next.height ? previous : next); + }; + update(); + return lath.subscribeFrames(update); + }, [lath, context.id, source.x, source.y, source.width, source.height]); + const placement = placeTerminalContext(wall, painted, multiPane, manual ?? lastSide.current, cursorSide); + useLayoutEffect(() => { lastSide.current = placement.side; }, [placement.side]); + const { rect } = placement; + return <> +
+ { + if (side) preferences.set(context.id, side); else preferences.delete(context.id); + lastSide.current = side; + setManual(side); + } }, + }} /> + ; +} diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index a2c7794aa..60db32fd7 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -7,6 +7,7 @@ import type { PortUrlEntry } from './port-url'; import type { HelperStatus } from '../../lib/helper-terminal'; import { WindowFocusedContext } from './wall-context'; import { motionIsInstant } from '../../lib/ui-geometry'; +import type { ContextPlacement, ContextSide } from './terminal-context-placement'; import { messageOf } from '../../lib/errors'; export type PortMode = 'system' | 'iframe' | 'ab-screencast' | 'ab-popout'; @@ -44,6 +45,9 @@ const DETAILS = { type Detail = keyof typeof DETAILS; export interface TerminalContextViewProps { terminalRole?: 'helper' | 'tool'; + style?: CSSProperties; + compact?: boolean; + placement?: ContextPlacement & { manual: boolean; onChange(side?: ContextSide): void }; /** Exit in progress: the view is inert, and `onClose` is not called again. */ closing?: boolean; /** Viewport coordinates the reveal grows from; absent, the top-left corner. */ @@ -150,6 +154,7 @@ export function TerminalContextView(p: TerminalContextViewProps) { return () => document.removeEventListener('pointerdown', outside, true); }, [p.closing, close]); const detailRoot = useRef(null); + const [expanded, setExpanded] = useState(!p.compact); const [detail, setDetail] = useState(p.initialDetail ?? null); useEffect(() => { if (!detail) return; @@ -169,8 +174,8 @@ export function TerminalContextView(p: TerminalContextViewProps) { const status = HELPER_STATUS[p.status]; const isTool = p.terminalRole === 'tool'; const statusLabel = isTool ? (p.status === 'running' ? `Running ${p.command}…` : 'At prompt') : status.label(p.command); - return
event.preventDefault()} onKeyDown={event => { if ((event.target as HTMLElement).closest('[data-helper-terminal], [data-context-terminal]') && !detail) return; @@ -181,16 +186,16 @@ export function TerminalContextView(p: TerminalContextViewProps) { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); if (detail) setDetail(null); else close(); } }}>
-
+
Title
- {p.title} setDetail('title')}>Explain -
attempt(p.onCopyRef)}>{p.surfaceRef}
+ {p.title}{expanded && setDetail('title')}>Explain} +
attempt(p.onCopyRef)}>{p.surfaceRef}{p.compact && }
Dir -
{p.cwd} attempt(p.onExplore)}>{p.explorerLabel} attempt(p.onCopyPath)}>Copy path
- Ports +
{p.cwd}{expanded && <> attempt(p.onExplore)}>{p.explorerLabel} attempt(p.onCopyPath)}>Copy path}
+ {expanded && <>Ports
{p.scan.status === 'scanning' ? Scanning ports… : p.scan.status === 'failed' ? Port scan failed · Reopen to try again : !selected ? No listening ports : <> {entries.length > 1 ?
{entries.length} ports
: <>{selected.host}:{selected.port}{selected.processName}} @@ -202,10 +207,19 @@ export function TerminalContextView(p: TerminalContextViewProps) {
}
- Alerts
{p.argv0 ? `Watch all ${p.argv0} commands` : 'No command running'}{p.argv0 && }TODO
+ Alerts
{p.argv0 ? `Watch all ${p.argv0} commands` : 'No command running'}{p.argv0 && }TODO
}
- {p.notification &&
{p.notification.title}
{p.notification.body}
} + {expanded && p.notification &&
{p.notification.title}
{p.notification.body}
}
+ {p.placement &&
+ {p.placement.available.map(side => )} + p.placement!.onChange()} disabled={!p.placement.manual}>Auto +
}
{isTool ? 'Tool terminal' : 'Helper terminal'} @@ -219,7 +233,7 @@ export function TerminalContextView(p: TerminalContextViewProps) {
{p.children}
{p.notepadPanel} - {detail &&
setDetail(null)}>
e.stopPropagation()}> + {detail &&
setDetail(null)}>
e.stopPropagation()}>
{DETAILS[detail].heading} setDetail(null)} muted>
{detail === 'title' ?
{p.titleSources.map((source, index) =>
{source.source}{source.value}{source.note}
)}
: detail === 'modify' ? <> setCommand(e.target.value)} maxLength={4096} placeholder="Leave empty to turn autorun off" className="w-full border-b border-input-border bg-input-bg px-2 py-1.5 outline-focus-ring" />

Global default. Applies to new and reset helpers. Leave empty to turn autorun off.

setDetail('reset')}>Reset helper… void submit(() => p.onModify(command))}>Save default
: <>

Discard this helper, including scrollback, unfinished input, and any running program? Unsaved edits will be lost.

A fresh helper starts in the parent's current directory using the global autorun default.

setDetail(null)}>Keep helper void submit(p.onReset)}>Discard and reset
} {error &&

{error}

} diff --git a/lib/src/components/wall/terminal-context-placement.ts b/lib/src/components/wall/terminal-context-placement.ts index 6214d66a9..3c56955c2 100644 --- a/lib/src/components/wall/terminal-context-placement.ts +++ b/lib/src/components/wall/terminal-context-placement.ts @@ -5,7 +5,7 @@ export type ContextPlacement = { rect: Rect; side: ContextSide; mode: 'adjacent' const SIDES: ContextSide[] = ['right', 'left', 'bottom', 'top']; const GAP = 8; // Compact source/directory/status chrome plus a useful terminal viewport. -const MIN_WIDTH = 360; +const MIN_WIDTH = 280; const MIN_HEIGHT = 240; const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max)); diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index a63ef1891..3559b933e 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -4,6 +4,7 @@ import { FrameCornersIcon, XIcon } from '@phosphor-icons/react'; import { PANE_HEADER_HEIGHT_PX } from '../components/design'; import { NotepadHeaderButton } from '../components/wall/NotepadHeaderButton'; import { NotepadPanel } from '../components/NotepadPanel'; +import { placeTerminalContext, type ContextSide } from '../components/wall/terminal-context-placement'; import { TerminalContextView } from '../components/wall/TerminalContextView'; // Sample terminal output with the shared context presentation and notepad UI. @@ -88,6 +89,9 @@ function TerminalOutput({ scenario }: { scenario: Scenario }) { } function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scenario: Scenario; initialDetail?: 'title' | 'modify' | 'reset' | null; paneWidth: number }) { + const [side, setSide] = useState(); + const bounds = { x: 0, y: 0, width: paneWidth, height: 680 }; + const placement = placeTerminalContext(bounds, bounds, false, side); const [watching, setWatching] = useState(false); const [todo, setTodo] = useState(scenario === 'notification'); const [command, setCommand] = useState(scenario === 'autorunOff' ? '' : 'git status'); @@ -97,7 +101,7 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scena
pnpm dev
{'~/projects/dormouse ❯ pnpm dev\n\n  VITE ready\n  ➜  Local: http://localhost:5173/'}
- Date: Wed, 23 Sep 2026 10:17:12 -0700 Subject: [PATCH 03/23] Simplify helper context placement overlay Write animator frames straight to the overlay DOM so tweens no longer re-render the whole context; React re-renders only when the side or the available sides change. Reuse lath's Edge/edgeAxis, route Details and placement buttons through ContextAction, drop the unread placement mode and the view's style prop, and trim spec duplication. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/layout.md | 2 +- docs/specs/tiling-engine.md | 2 +- lib/src/components/Wall.test.tsx | 2 +- lib/src/components/wall/LathHost.tsx | 11 ++-- .../components/wall/TerminalContext.test.tsx | 2 +- lib/src/components/wall/TerminalContext.tsx | 4 +- .../wall/TerminalContextOverlay.tsx | 52 ++++++++++++------- .../components/wall/TerminalContextView.tsx | 21 ++++---- .../wall/terminal-context-placement.test.ts | 4 +- .../wall/terminal-context-placement.ts | 16 +++--- lib/src/stories/TerminalContext.stories.tsx | 4 +- 11 files changed, 68 insertions(+), 52 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 5ddef9e2e..63d6e8090 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -72,7 +72,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- **Must offer available side buttons plus Auto**, with destination tooltips, accessible labels, and selected state. Remember manual choices per source for the mounted Wall's lifetime; clear on source removal or Auto. Preserve terminal focus on pointer repositioning. An unavailable choice falls back automatically; no preference is persisted to disk. -**Must keep source title, directory, and helper actions visible in compact context**, disclosing title explanation, directory actions, ports, and alerts through Details. Bound detail scrolling so the helper retains space. The full state gallery shares the same presentation. +**Must keep source title, directory, and helper actions visible in compact context**, disclosing title explanation, directory actions, ports, and alerts through Details. Bound detail scrolling so the helper retains space. Source of truth: `placeTerminalContext` in `lib/src/components/wall/terminal-context-placement.ts`; `TerminalContextOverlay` in `lib/src/components/wall/TerminalContextOverlay.tsx`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`. Tests: `lib/src/components/wall/terminal-context-placement.test.ts`, `lib/src/components/wall/TerminalContext.test.tsx`, `lib/src/components/Wall.test.tsx`. diff --git a/docs/specs/tiling-engine.md b/docs/specs/tiling-engine.md index a8cb6bad5..2259547fc 100644 --- a/docs/specs/tiling-engine.md +++ b/docs/specs/tiling-engine.md @@ -167,7 +167,7 @@ Source of truth: `lib/src/components/wall/lath-wall-store.ts`; `lib/src/componen - Sashes render from core `sashes()` geometry as sibling divs (hit area widened to 8px, cursor per axis); a drag streams a core `resize` preview from the drag-start tree with the cumulative delta and proposes one commit on pointerup (`onCommitResize`); Escape cancels. **Geometry is reported through `store.setLayoutGeometry` from inside the measuring layout effect, never a passive effect over the rendered size** (rationale); the store's zero-area rejection is the backstop. - Zoom retargets only the chosen leaf to the wall rect inset by `LATH_ZOOM_MARGIN` (half a pane header) and elevates it above tiled/dying panes and sashes, applying the blurred `LATH_ZOOM_SHADOW` while elevated. Unzoom keeps both until the return frame settles. - **The binding never calls `.focus()` and emits no activation events.** Gestures surface as proposals (`onCommitResize`, `onLeafFocused`, the drag callbacks) that the Wall commits. -- **Must host Terminal Context above the tiled leaves**, keeping its terminal mounted during placement changes. `docs/specs/layout.md` → Header context menu owns placement. +- Terminal Context renders above the tiled leaves; `docs/specs/layout.md` → Header context menu owns it. - The selection ring and kill overlay measure leaf elements through `resolvePaneElement`, which climbs to `[data-lath-leaf]`; `WorkspaceSelectionOverlay` re-measures on every store commit (`revision`) and every animator tick, and **same-identity re-measures snap 1:1**, so the ring tracks kills, restores, and tweens frame-accurately ([layout.md → Ring travel](layout.md#ring-travel) owns its between-panes travel, a JS tween rather than a CSS transition). Source of truth: `BODY_COMPONENTS` / `TAB_COMPONENTS` / `OVERLAY_COMPONENTS` in `lib/src/components/wall/LathHost.tsx`; the `.lath-host` rules in `lib/src/index.css`. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 4062ade71..b75446aa9 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -3130,7 +3130,7 @@ describe('Wall on the Lath engine', () => { expect(portRow).not.toBeNull(); const contextMenu = portRow!.closest('[data-terminal-context]')!; expect(contextMenu.closest('[data-lath-leaf]')).toBeNull(); - expect(contextMenu.parentElement?.classList.contains('lath-host')).toBe(true); + expect(contextMenu.parentElement?.parentElement?.classList.contains('lath-host')).toBe(true); expect(contextMenu.closest('.lath-leaf-body')).toBeNull(); await act(async () => { portRow!.dispatchEvent(new MouseEvent('click', { bubbles: true })); diff --git a/lib/src/components/wall/LathHost.tsx b/lib/src/components/wall/LathHost.tsx index f9db19508..3998304f0 100644 --- a/lib/src/components/wall/LathHost.tsx +++ b/lib/src/components/wall/LathHost.tsx @@ -458,6 +458,8 @@ export function LathHost({ const activeTree = preview ?? snapshot.tree; const { targets: frames, layers } = presentationTargets(activeTree, rect, snapshot.zoomedId); + const contextSource = terminalContext && frames.get(terminalContext.id); + const contextMeta = terminalContext && snapshot.leafMeta.get(terminalContext.id); const sashList = sashes(activeTree, rect, LATH_LAYOUT_OPTS); // DOM order is sorted-by-id and STABLE across layout changes; z-index (not DOM @@ -731,11 +733,10 @@ export function LathHost({ ); })} - {terminalContext && frames.has(terminalContext.id) && ( - 1} preferences={contextPreferences.current} /> )} diff --git a/lib/src/components/wall/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx index 94491f07b..99b6575bb 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -227,7 +227,7 @@ it('keeps the helper mounted while compact details are toggled', async () => { }); it('position buttons preserve input focus and report the destination', async () => { - props.placement = { rect: { x: 0, y: 0, width: 600, height: 400 }, side: 'top', mode: 'half', available: ['top', 'bottom'], manual: false, onChange: vi.fn() }; + props.placement = { rect: { x: 0, y: 0, width: 600, height: 400 }, side: 'top', available: ['top', 'bottom'], manual: false, onChange: vi.fn() }; render(); const input = container.querySelector('textarea')!; act(() => input.focus()); diff --git a/lib/src/components/wall/TerminalContext.tsx b/lib/src/components/wall/TerminalContext.tsx index a083bbec3..6c9863846 100644 --- a/lib/src/components/wall/TerminalContext.tsx +++ b/lib/src/components/wall/TerminalContext.tsx @@ -14,7 +14,7 @@ import { writeTextToClipboard } from '../../lib/clipboard'; import { listenerUrlsByPort } from './port-url'; import { DEFAULT_HELPER_COMMAND } from '../../lib/terminal-context-types'; -export function TerminalContext({ id, title, closing, origin, warning: openWarning, tool = false, presentation }: TerminalContextState & { title?: string; tool?: boolean; presentation?: Pick }) { +export function TerminalContext({ id, title, closing, origin, warning: openWarning, tool = false, compact, placement }: TerminalContextState & { title?: string; tool?: boolean } & Pick) { const context = useContext(TerminalContextContext); const actions = useContext(WallActionsContext); const states = useSyncExternalStore(subscribeToTerminalPaneState, getTerminalPaneStateSnapshot); @@ -52,7 +52,7 @@ export function TerminalContext({ id, title, closing, origin, warning: openWarni const copy = async (value: string) => { if (!await writeTextToClipboard(value)) throw new Error('Could not copy to clipboard'); }; const mismatch = !!helper && !!cwd && !!helperCwd && (cwd.path !== helperCwd.path || cwd.isRemote !== helperCwd.isRemote || (cwd.isRemote && cwd.host !== helperCwd.host)); const warning = openWarning ?? (helperError || (helper && helper.status !== 'waiting' && (!cwd || !helperCwd) ? 'Directory comparison unavailable: a terminal has not reported its directory.' : undefined)); - return ({ left: x, top: y, width, height }); +function writeBox(element: HTMLElement | null, rect: Rect) { + if (element) Object.assign(element.style, { left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.width}px`, height: `${rect.height}px` }); +} + +/** One stable host per opening: moving the overlay never remounts its terminal. Animator + * frames write bounds straight to the DOM; React re-renders only when the side or the + * available sides change. */ export function TerminalContextOverlay({ context, title, tool, wall, source, multiPane, lath, preferences }: { context: TerminalContextState; title?: string; tool: boolean; wall: Rect; source: Rect; multiPane: boolean; lath: LathWallEngine; preferences: Map; @@ -18,31 +29,36 @@ export function TerminalContextOverlay({ context, title, tool, wall, source, mul }); const [manual, setManual] = useState(() => preferences.get(context.id)); const lastSide = useRef(manual); - const [painted, setPainted] = useState(source); + const host = useRef(null); + const outline = useRef(null); + const measure = () => { + const painted = lath.animator.framesAt(nowMs()).get(context.id)?.rect ?? source; + return { painted, placement: placeTerminalContext(wall, painted, multiPane, manual ?? lastSide.current, cursorSide) }; + }; + const [shown, setShown] = useState<{ painted: Rect; placement: ContextPlacement }>(measure); useLayoutEffect(() => { const update = () => { - const next = lath.animator.framesAt(nowMs()).get(context.id)?.rect ?? source; - setPainted(previous => previous.x === next.x && previous.y === next.y && previous.width === next.width && previous.height === next.height ? previous : next); + const next = measure(); + lastSide.current = next.placement.side; + writeBox(outline.current, next.painted); + writeBox(host.current, next.placement.rect); + setShown(previous => previous.placement.side === next.placement.side + && previous.placement.available.join() === next.placement.available.join() ? previous : next); }; update(); return lath.subscribeFrames(update); - }, [lath, context.id, source.x, source.y, source.width, source.height]); - const placement = placeTerminalContext(wall, painted, multiPane, manual ?? lastSide.current, cursorSide); - useLayoutEffect(() => { lastSide.current = placement.side; }, [placement.side]); - const { rect } = placement; + // eslint-disable-next-line react-hooks/exhaustive-deps -- `measure` reads exactly these inputs + }, [lath, context.id, manual, multiPane, cursorSide, wall.x, wall.y, wall.width, wall.height, source.x, source.y, source.width, source.height]); return <> -
- { +
+ { if (side) preferences.set(context.id, side); else preferences.delete(context.id); lastSide.current = side; setManual(side); - } }, - }} /> + } }} /> +
; } diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index 60db32fd7..5a5722549 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -45,7 +45,7 @@ const DETAILS = { type Detail = keyof typeof DETAILS; export interface TerminalContextViewProps { terminalRole?: 'helper' | 'tool'; - style?: CSSProperties; + /** Fill the positioned host and fold directory actions, ports, and alerts behind Details. */ compact?: boolean; placement?: ContextPlacement & { manual: boolean; onChange(side?: ContextSide): void }; /** Exit in progress: the view is inert, and `onClose` is not called again. */ @@ -65,14 +65,15 @@ export interface TerminalContextViewProps { initialDetail?: Detail | null; } -export function ContextAction({ children, label, onClick, disabled = false, busy = false, muted = false }: { children: ReactNode; label: string; onClick?: () => void; disabled?: boolean; busy?: boolean; muted?: boolean }) { +export function ContextAction({ children, label, onClick, disabled = false, busy = false, muted = false, pressed, expanded, keepFocus = false }: { children: ReactNode; label: string; onClick?: () => void; disabled?: boolean; busy?: boolean; muted?: boolean; pressed?: boolean; expanded?: boolean; keepFocus?: boolean }) { const windowFocused = useContext(WindowFocusedContext); // Native app launches can leave :hover stale until this window regains focus. const color = muted ? 'text-muted' : windowFocused ? SUBTLE_ACTION_COLOR_CLASS : SUBTLE_ACTION_REST_COLOR_CLASS; // `busy` must never reach native `disabled`: the browser blurs a button the moment it is disabled, // and this context's Escape and Tab handling both live on the
and need a focused descendant. return ; + aria-pressed={pressed} aria-expanded={expanded} onPointerDown={keepFocus ? event => event.preventDefault() : undefined} + className={`inline-flex h-6 shrink-0 items-center justify-center gap-1.5 rounded px-1.5 disabled:opacity-40 aria-pressed:bg-current/10 ${windowFocused ? SUBTLE_ACTION_INTERACTION_CLASS : ''} ${color}`}>{children}; } function ContextCopyAction({ children, label, onCopy }: { children: ReactNode; label: string; onCopy: () => Promise }) { @@ -174,8 +175,8 @@ export function TerminalContextView(p: TerminalContextViewProps) { const status = HELPER_STATUS[p.status]; const isTool = p.terminalRole === 'tool'; const statusLabel = isTool ? (p.status === 'running' ? `Running ${p.command}…` : 'At prompt') : status.label(p.command); - return
event.preventDefault()} onKeyDown={event => { if ((event.target as HTMLElement).closest('[data-helper-terminal], [data-context-terminal]') && !detail) return; @@ -191,7 +192,7 @@ export function TerminalContextView(p: TerminalContextViewProps) { Title
{p.title}{expanded && setDetail('title')}>Explain} -
attempt(p.onCopyRef)}>{p.surfaceRef}{p.compact && }
+
attempt(p.onCopyRef)}>{p.surfaceRef}{p.compact && setExpanded(value => !value)}>Details}
Dir
{p.cwd}{expanded && <> attempt(p.onExplore)}>{p.explorerLabel} attempt(p.onCopyPath)}>Copy path}
@@ -212,13 +213,11 @@ export function TerminalContextView(p: TerminalContextViewProps) { {expanded && p.notification &&
{p.notification.title}
{p.notification.body}
}
{p.placement &&
- {p.placement.available.map(side => )} - p.placement!.onChange()} disabled={!p.placement.manual}>Auto + )} + p.placement!.onChange()} disabled={!p.placement.manual} keepFocus>Auto
}
diff --git a/lib/src/components/wall/terminal-context-placement.test.ts b/lib/src/components/wall/terminal-context-placement.test.ts index 73fba8dee..523da388f 100644 --- a/lib/src/components/wall/terminal-context-placement.test.ts +++ b/lib/src/components/wall/terminal-context-placement.test.ts @@ -16,8 +16,8 @@ describe('terminal context placement', () => { expect(placeTerminalContext(wall, source, true, 'bottom').side).toBe('bottom'); }); it('shrinks into an uneven neighbor and rejects unusable slivers', () => { - expect(placeTerminalContext(wall, { ...wall, width: 800 }, true)).toMatchObject({ mode: 'adjacent', rect: { width: 392 } }); - expect(placeTerminalContext(wall, { ...wall, width: 1000 }, true)).toMatchObject({ mode: 'half', side: 'top' }); + expect(placeTerminalContext(wall, { ...wall, width: 800 }, true)).toMatchObject({ side: 'right', rect: { width: 392 } }); + expect(placeTerminalContext(wall, { ...wall, width: 1000 }, true)).toMatchObject({ side: 'top', available: ['top', 'bottom'] }); }); it('uses source halves for single or zoomed panes', () => { expect(placeTerminalContext(wall, wall, false)).toMatchObject({ rect: { ...wall, height: 400 }, available: ['top', 'bottom'] }); diff --git a/lib/src/components/wall/terminal-context-placement.ts b/lib/src/components/wall/terminal-context-placement.ts index 3c56955c2..671d0e8eb 100644 --- a/lib/src/components/wall/terminal-context-placement.ts +++ b/lib/src/components/wall/terminal-context-placement.ts @@ -1,7 +1,7 @@ -import type { Rect } from '../../lib/lath/model'; +import { edgeAxis, type Edge, type Rect } from '../../lib/lath/model'; -export type ContextSide = 'right' | 'left' | 'bottom' | 'top'; -export type ContextPlacement = { rect: Rect; side: ContextSide; mode: 'adjacent' | 'half'; available: ContextSide[] }; +export type ContextSide = Edge; +export type ContextPlacement = { rect: Rect; side: ContextSide; available: ContextSide[] }; const SIDES: ContextSide[] = ['right', 'left', 'bottom', 'top']; const GAP = 8; // Compact source/directory/status chrome plus a useful terminal viewport. @@ -20,8 +20,8 @@ export function cursorHalfSide(buffer: { baseY: number; cursorY: number; viewpor export function placeTerminalContext(wall: Rect, source: Rect, multiPane: boolean, preferred?: ContextSide, fallback: ContextSide = 'top'): ContextPlacement { const right = wall.x + wall.width; const bottom = wall.y + wall.height; - const candidates = SIDES.map(side => { - const horizontal = side === 'left' || side === 'right'; + const candidates = !multiPane ? [] : SIDES.map(side => { + const horizontal = edgeAxis(side) === 'row'; const space = side === 'right' ? right - source.x - source.width - GAP : side === 'left' ? source.x - wall.x - GAP : side === 'bottom' ? bottom - source.y - source.height - GAP : source.y - wall.y - GAP; @@ -32,16 +32,16 @@ export function placeTerminalContext(wall: Rect, source: Rect, multiPane: boolea y: side === 'bottom' ? source.y + source.height + GAP : side === 'top' ? source.y - GAP - height : clamp(source.y, wall.y, bottom - height), width, height, } }; - }).filter(candidate => multiPane && candidate.rect.width >= MIN_WIDTH && candidate.rect.height >= MIN_HEIGHT); + }).filter(candidate => candidate.rect.width >= MIN_WIDTH && candidate.rect.height >= MIN_HEIGHT); const chosen = candidates.find(candidate => candidate.side === preferred) ?? candidates.reduce((best, candidate) => !best || candidate.rect.width * candidate.rect.height > best.rect.width * best.rect.height ? candidate : best, undefined); - if (chosen) return { ...chosen, mode: 'adjacent', available: candidates.map(candidate => candidate.side) }; + if (chosen) return { ...chosen, available: candidates.map(candidate => candidate.side) }; const side = preferred === 'top' || preferred === 'bottom' ? preferred : fallback; // Small source panes borrow Wall width/height only when the half-pane would be unusable. const width = Math.min(wall.width, Math.max(source.width, MIN_WIDTH)); const height = Math.min(wall.height, Math.max(source.height / 2, MIN_HEIGHT)); - return { side, mode: 'half', available: ['top', 'bottom'], rect: { + return { side, available: ['top', 'bottom'], rect: { x: clamp(source.x, wall.x, right - width), y: clamp(side === 'bottom' ? source.y + source.height - height : source.y, wall.y, bottom - height), width, height, diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index 3559b933e..457fe73ff 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -100,8 +100,8 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scena return
pnpm dev
{'~/projects/dormouse ❯ pnpm dev\n\n  VITE ready\n  ➜  Local: http://localhost:5173/'}
-
- + Date: Wed, 23 Sep 2026 10:18:42 -0700 Subject: [PATCH 04/23] Verify helper geometry updates preserve terminal identity and focus --- docs/specs/layout.md | 2 +- .../wall/TerminalContextOverlay.test.tsx | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 lib/src/components/wall/TerminalContextOverlay.test.tsx diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 63d6e8090..d30668d61 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -74,7 +74,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- **Must keep source title, directory, and helper actions visible in compact context**, disclosing title explanation, directory actions, ports, and alerts through Details. Bound detail scrolling so the helper retains space. -Source of truth: `placeTerminalContext` in `lib/src/components/wall/terminal-context-placement.ts`; `TerminalContextOverlay` in `lib/src/components/wall/TerminalContextOverlay.tsx`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`. Tests: `lib/src/components/wall/terminal-context-placement.test.ts`, `lib/src/components/wall/TerminalContext.test.tsx`, `lib/src/components/Wall.test.tsx`. +Source of truth: `placeTerminalContext` in `lib/src/components/wall/terminal-context-placement.ts`; `TerminalContextOverlay` in `lib/src/components/wall/TerminalContextOverlay.tsx`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`. Tests: `lib/src/components/wall/terminal-context-placement.test.ts`, `lib/src/components/wall/TerminalContext.test.tsx`, `lib/src/components/wall/TerminalContextOverlay.test.tsx`, `lib/src/components/Wall.test.tsx`. **Must reveal the context from the opening pointer position, clamped to its bounds, over 320ms.** Command-mode `a` and `>` use the header's bottom-left; openings without a position use the context's top-left. Keep final layout dimensions throughout the reveal. Start helper creation, settings reads, and port scanning immediately on mount; fade mounted content, including detail dialogs, in over 140ms after 160ms. Reduced motion or disabled layout animation skips both animations and the delay. diff --git a/lib/src/components/wall/TerminalContextOverlay.test.tsx b/lib/src/components/wall/TerminalContextOverlay.test.tsx new file mode 100644 index 000000000..3206d2665 --- /dev/null +++ b/lib/src/components/wall/TerminalContextOverlay.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; +import { TerminalContextOverlay } from './TerminalContextOverlay'; +import type { LathWallEngine } from './lath-wall-engine'; +import type { ContextSide } from './terminal-context-placement'; + +const { rendered } = vi.hoisted(() => ({ rendered: vi.fn() })); +vi.mock('./TerminalContext', () => ({ TerminalContext: () => { + rendered(); + return
; +} })); +vi.mock('../../lib/terminal-registry', () => ({ getTerminalInstance: () => null })); +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +it('tracks animation without rerendering the helper or snapping back on unrelated renders', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const source = { x: 0, y: 0, width: 500, height: 600 }; + let painted = source; + const listeners = new Set<() => void>(); + const lath = { + animator: { framesAt: () => new Map([['source', { rect: painted }]]) }, + subscribeFrames: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); }, + } as unknown as LathWallEngine; + const preferences = new Map(); + const render = (title: string) => act(() => root.render()); + try { + render('Original'); + const helper = container.querySelector('[data-test-context]')!; + const host = helper.parentElement!; + const input = helper.querySelector('input')!; + act(() => input.focus()); + input.value = 'unfinished command'; + const renders = rendered.mock.calls.length; + expect(host.style.left).toBe('508px'); + painted = { ...source, width: 550 }; + act(() => { for (const notify of listeners) notify(); }); + expect(host.style.left).toBe('558px'); + expect(host.style.width).toBe('550px'); + expect(rendered).toHaveBeenCalledTimes(renders); + render('New source title'); + expect(host.style.left).toBe('558px'); + expect(host.style.width).toBe('550px'); + expect(container.querySelector('[data-test-context]')).toBe(helper); + expect(input.value).toBe('unfinished command'); + expect(document.activeElement).toBe(input); + expect(container.querySelector('[data-context-source]')!.style.width).toBe('550px'); + } finally { + act(() => root.unmount()); + container.remove(); + } + expect(listeners.size).toBe(0); +}); From 0731f9b23c72d68dac46913cfca1effff38a7c26 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 10:39:12 -0700 Subject: [PATCH 05/23] Expose context details in visual stories and cover small panels --- lib/src/stories/TerminalContext.stories.tsx | 27 +++++++++++++++++---- lib/src/stories/Wall.stories.tsx | 1 + 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index 457fe73ff..45c0f019c 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -1,5 +1,6 @@ import { useState, type ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from 'storybook/test'; import { FrameCornersIcon, XIcon } from '@phosphor-icons/react'; import { PANE_HEADER_HEIGHT_PX } from '../components/design'; import { NotepadHeaderButton } from '../components/wall/NotepadHeaderButton'; @@ -88,16 +89,16 @@ function TerminalOutput({ scenario }: { scenario: Scenario }) { ; } -function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scenario: Scenario; initialDetail?: 'title' | 'modify' | 'reset' | null; paneWidth: number }) { +function ContextPrototype({ scenario, initialDetail = null, paneWidth, paneHeight }: { scenario: Scenario; initialDetail?: 'title' | 'modify' | 'reset' | null; paneWidth: number; paneHeight: number }) { const [side, setSide] = useState(); - const bounds = { x: 0, y: 0, width: paneWidth, height: 680 }; + const bounds = { x: 0, y: 0, width: paneWidth, height: paneHeight }; const placement = placeTerminalContext(bounds, bounds, false, side); const [watching, setWatching] = useState(false); const [todo, setTodo] = useState(scenario === 'notification'); const [command, setCommand] = useState(scenario === 'autorunOff' ? '' : 'git status'); const preserved = ['preserved', 'editor', 'differentDirectory'].includes(scenario); const ports = (scenario === 'multiplePorts' ? [5173, 6006, 9229] : [5173]).map(port => ({ port, host: 'localhost', url: `http://localhost:${port}/`, processName: port === 5173 ? 'vite' : port === 6006 ? 'storybook' : 'node inspector' })); - return
+ return
pnpm dev
{'~/projects/dormouse ❯ pnpm dev\n\n  VITE ready\n  ➜  Local: http://localhost:5173/'}
@@ -116,7 +117,7 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scena
; } -function TerminalContextStory({ initialScenario = 'fresh', initialDetail = null, paneWidth = 900 }: { initialScenario?: Scenario; initialDetail?: 'title' | 'modify' | 'reset' | null; paneWidth?: number }) { +function TerminalContextStory({ initialScenario = 'fresh', initialDetail = null, paneWidth = 900, paneHeight = 680 }: { initialScenario?: Scenario; initialDetail?: 'title' | 'modify' | 'reset' | null; paneWidth?: number; paneHeight?: number }) { const [scenario, setScenario] = useState(initialScenario); return
@@ -125,7 +126,7 @@ function TerminalContextStory({ initialScenario = 'fresh', initialDetail = null, {SCENARIOS.map(item => )}
- + ; } @@ -134,6 +135,16 @@ const meta = { component: TerminalContextStory, parameters: { layout: 'fullscreen' }, args: { initialScenario: 'fresh' }, + play: async ({ args, canvasElement }) => { + // These snapshots must actually expose the state named in the story. + if (['noPorts', 'multiplePorts', 'notification', 'scanFailed'].includes(args.initialScenario ?? 'fresh')) { + const canvas = within(canvasElement); + const details = canvas.getByRole('button', { name: 'Terminal context details' }); + await userEvent.click(details); + await expect(details).toHaveAttribute('aria-expanded', 'true'); + await expect(canvas.getByText(args.initialScenario === 'notification' ? 'Tests complete' : 'Ports', { exact: true })).toBeVisible(); + } + }, } satisfies Meta; export default meta; type Story = StoryObj; @@ -150,3 +161,9 @@ export const PortScanFailed: Story = { args: { initialScenario: 'scanFailed' } } export const TitleSources: Story = { args: { initialDetail: 'title' } }; export const ModifyAutorun: Story = { args: { initialDetail: 'modify' } }; export const ResetConfirmation: Story = { args: { initialScenario: 'editor', initialDetail: 'reset' } }; + +export const MinimumWidth: Story = { args: { paneWidth: 280, paneHeight: 620 } }; +export const ShortWindow: Story = { args: { paneWidth: 480, paneHeight: 280 } }; +export const NarrowDetails: Story = { args: { initialScenario: 'multiplePorts', paneWidth: 380, paneHeight: 520 } }; +export const NarrowDirectoryWarning: Story = { args: { initialScenario: 'differentDirectory', paneWidth: 380, paneHeight: 520 } }; +export const NarrowResetConfirmation: Story = { args: { initialScenario: 'editor', initialDetail: 'reset', paneWidth: 380, paneHeight: 520 } }; diff --git a/lib/src/stories/Wall.stories.tsx b/lib/src/stories/Wall.stories.tsx index 5bb324bb2..d64602a32 100644 --- a/lib/src/stories/Wall.stories.tsx +++ b/lib/src/stories/Wall.stories.tsx @@ -102,6 +102,7 @@ async function openAlertDialog() { const header = await requireElement('[data-pane-header-for]', 'pane header'); header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); await requireElement('[data-terminal-context]', 'terminal context'); + (await requireElement('[aria-label="Terminal context details"]', 'context Details')).click(); await settleTerminals(); } From b25c313fc72bcad6255fc9490410c7fc56ff7bdc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 10:47:46 -0700 Subject: [PATCH 06/23] Exercise helper placement in Storybook and fix narrow-panel clipping --- docs/specs/layout.md | 2 +- docs/specs/terminal-context.md | 2 +- .../components/wall/TerminalContextView.tsx | 18 +- lib/src/stories/HelperPlacement.stories.tsx | 176 ++++++++++++++++++ lib/src/stories/TerminalContext.stories.tsx | 16 +- 5 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 lib/src/stories/HelperPlacement.stories.tsx diff --git a/docs/specs/layout.md b/docs/specs/layout.md index d30668d61..7680d2afa 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -72,7 +72,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- **Must offer available side buttons plus Auto**, with destination tooltips, accessible labels, and selected state. Remember manual choices per source for the mounted Wall's lifetime; clear on source removal or Auto. Preserve terminal focus on pointer repositioning. An unavailable choice falls back automatically; no preference is persisted to disk. -**Must keep source title, directory, and helper actions visible in compact context**, disclosing title explanation, directory actions, ports, and alerts through Details. Bound detail scrolling so the helper retains space. +**Must keep source title, directory, and helper actions visible in compact context**, disclosing title explanation, directory actions, ports, and alerts through Details. Wrap header and detail actions within the panel; scroll bounded details and warnings while reserving 64px for terminal content. Source of truth: `placeTerminalContext` in `lib/src/components/wall/terminal-context-placement.ts`; `TerminalContextOverlay` in `lib/src/components/wall/TerminalContextOverlay.tsx`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`. Tests: `lib/src/components/wall/terminal-context-placement.test.ts`, `lib/src/components/wall/TerminalContext.test.tsx`, `lib/src/components/wall/TerminalContextOverlay.test.tsx`, `lib/src/components/Wall.test.tsx`. diff --git a/docs/specs/terminal-context.md b/docs/specs/terminal-context.md index cf9c0e274..d422066a6 100644 --- a/docs/specs/terminal-context.md +++ b/docs/specs/terminal-context.md @@ -53,7 +53,7 @@ Source of truth: `context` in `standalone/sidecar/pty-core.js`; `terminalContext **Must share the context presentation between the live menu and its state gallery.** -Source of truth: `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `lib/src/stories/TerminalContext.stories.tsx` supplies sample output; `lib/src/stories/Wall.stories.tsx` exercises the live helper with the fake shell. +Source of truth: `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `lib/src/stories/TerminalContext.stories.tsx` supplies sample output; `lib/src/stories/Wall.stories.tsx` exercises the live helper with the fake shell. `lib/src/stories/HelperPlacement.stories.tsx` checks rendered placement and real xterm input/focus retention; the context gallery checks narrow controls and expanded details. ## Tool context diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index 5a5722549..d1f1b998b 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -188,10 +188,10 @@ export function TerminalContextView(p: TerminalContextViewProps) { }}>
-
+
Title -
- {p.title}{expanded && setDetail('title')}>Explain} +
+ {p.title}{expanded && setDetail('title')}>Explain}
attempt(p.onCopyRef)}>{p.surfaceRef}{p.compact && setExpanded(value => !value)}>Details}
Dir @@ -199,8 +199,8 @@ export function TerminalContextView(p: TerminalContextViewProps) { {expanded && <>Ports
{p.scan.status === 'scanning' ? Scanning ports… : p.scan.status === 'failed' ? Port scan failed · Reopen to try again : !selected ? No listening ports : <> - {entries.length > 1 ?
{entries.length} ports
: <>{selected.host}:{selected.port}{selected.processName}} -
+ {entries.length > 1 ?
{entries.length} ports
: <>{selected.host}:{selected.port}{selected.processName}} +
{PORT_ACTIONS.map(action => { const unavailable = action.needs && !p[action.needs] ? action.unavailable : null; return void attempt(() => p.onPort(selected, action.mode))}>{action.icon}{action.text}; @@ -208,7 +208,7 @@ export function TerminalContextView(p: TerminalContextViewProps) {
}
- Alerts
{p.argv0 ? `Watch all ${p.argv0} commands` : 'No command running'}{p.argv0 && }TODO
} + Alerts
{p.argv0 ? `Watch all ${p.argv0} commands` : 'No command running'}{p.argv0 && }TODO
}
{expanded && p.notification &&
{p.notification.title}
{p.notification.body}
}
@@ -227,9 +227,9 @@ export function TerminalContextView(p: TerminalContextViewProps) {
{p.notepadAction}{!isTool && void submit(p.onPromote)}>Promote}
- {p.mismatch &&
Helper directory differs from parent
Helper{p.helperCwd}Parent{p.cwd}
} - {(p.warning || (!detail && error)) &&
{p.warning || error}
} -
{p.children}
+ {p.mismatch &&
Helper directory differs from parent
Helper{p.helperCwd}Parent{p.cwd}
} + {(p.warning || (!detail && error)) &&
{p.warning || error}
} +
{p.children}
{p.notepadPanel} {detail &&
setDetail(null)}>
e.stopPropagation()}> diff --git a/lib/src/stories/HelperPlacement.stories.tsx b/lib/src/stories/HelperPlacement.stories.tsx new file mode 100644 index 000000000..bcc259ed0 --- /dev/null +++ b/lib/src/stories/HelperPlacement.stories.tsx @@ -0,0 +1,176 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { Wall } from '../components/Wall'; +import { getHelper } from '../lib/helper-terminal'; +import { getTerminalInstance, refitSession } from '../lib/terminal-registry'; +import { flattenScenario, SCENARIO_SHELL_PROMPT } from '../lib/platform'; +import { leaves, normalizeWeights, type LathNode } from '../lib/lath/model'; +import type { LathPersistedLayout } from '../lib/lath/persistence'; +import { requireElement, settleTerminals } from './settle-terminals'; + +const SOURCE = 'placement-source'; +type Layout = 'single' | 'columns' | 'rows' | 'grid' | 'uneven'; +type Props = { layout: Layout; width: number; height: number; sourceAtEnd: boolean; cursor: 'top' | 'bottom'; zoomed: boolean }; +const leaf = (id: string): LathNode => ({ kind: 'leaf', id }); +const split = (dir: 'row' | 'col', nodes: LathNode[], weights = nodes.map(() => 1)): LathNode => ({ kind: 'split', dir, children: normalizeWeights(nodes.map((node, i) => ({ node, weight: weights[i] }))) }); +function boot({ layout, sourceAtEnd }: Props): LathPersistedLayout { + const pair = sourceAtEnd ? [leaf('peer'), leaf(SOURCE)] : [leaf(SOURCE), leaf('peer')]; + const root = layout === 'single' ? leaf(SOURCE) + : layout === 'rows' ? split('col', pair) + : layout === 'grid' ? split('row', [split('col', pair), split('col', [leaf('peer-2'), leaf('peer-3')])]) + : split('row', pair, layout === 'uneven' ? [2, 1] : undefined); + return { version: 1, tree: { root }, leafMeta: Object.fromEntries(leaves({ root }).map(id => [id, { component: 'terminal', tabComponent: 'terminal', title: id === SOURCE ? 'Source terminal' : 'Neighbor terminal' }])) }; +} +function PlacementWall(props: Props) { + return
+ +
; +} + +function terminal(id: string) { + const term = getTerminalInstance(id); + if (!term) throw new Error(`Terminal ${id} never mounted`); + return term; +} +const sourcePane = () => document.querySelector(`[data-lath-leaf="${SOURCE}"]`)!; +const context = () => document.querySelector('[data-terminal-context]')!; +const rect = (element: Element) => { + const { x, y, width, height } = element.getBoundingClientRect(); + return { x, y, width, height }; +}; +function expectContained(element: Element, parent: Element) { + const a = element.getBoundingClientRect(); + const b = parent.getBoundingClientRect(); + expect(a.width).toBeGreaterThan(0); + expect(a.height).toBeGreaterThan(0); + expect(a.left).toBeGreaterThanOrEqual(b.left - 1); + expect(a.top).toBeGreaterThanOrEqual(b.top - 1); + expect(a.right).toBeLessThanOrEqual(b.right + 1); + expect(a.bottom).toBeLessThanOrEqual(b.bottom + 1); +} +async function openContext() { + const header = await requireElement(`[data-pane-header-for="${SOURCE}"]`, 'source header'); + header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); + await requireElement('[data-helper-terminal] .xterm', 'helper terminal'); + await waitFor(() => expect(getHelper(SOURCE)?.status).toBe('completed')); + await settleTerminals(); +} +async function prepare(args: Props) { + await settleTerminals(); + if (args.zoomed) { + await userEvent.click(within(sourcePane()).getByRole('button', { name: 'Zoom' })); + await waitFor(() => expect(sourcePane().getBoundingClientRect().width).toBeGreaterThan(args.width * 0.8)); + } + const source = terminal(SOURCE); + refitSession(SOURCE); + await waitFor(() => expect(source.rows).toBeGreaterThan(4)); + // Real xterm cursor positioning, sampled by production code when context opens. + const row = args.cursor === 'top' ? 2 : source.rows - 1; + await new Promise(resolve => source.write(`\x1b[${row};1Hsource cursor here`, resolve)); + const before = rect(sourcePane()); + const grid = { cols: source.cols, rows: source.rows }; + await openContext(); + expect(rect(sourcePane())).toEqual(before); + expect({ cols: source.cols, rows: source.rows }).toEqual(grid); + expectContained(context(), document.querySelector('.lath-host')!); + const expected = args.zoomed || args.layout === 'single' ? args.cursor === 'top' ? 'bottom' : 'top' + : args.layout === 'grid' ? 'bottom' + : args.layout === 'rows' ? args.sourceAtEnd ? 'top' : 'bottom' + : args.sourceAtEnd ? 'left' : 'right'; + expect(context().dataset.contextSide).toBe(expected); + if (args.layout === 'grid' && !args.zoomed) { + expect(within(context()).getByRole('button', { name: 'Place helper at right' })).toBeVisible(); + expect(within(context()).getByRole('button', { name: 'Place helper at bottom' })).toBeVisible(); + } + if (!args.zoomed && args.layout !== 'single') { + const a = context().getBoundingClientRect(); + const b = sourcePane().getBoundingClientRect(); + expect(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom).toBe(true); + } + return { source, before, grid }; +} + +const meta = { + title: 'App/Helper placement', + component: PlacementWall, + args: { layout: 'single', width: 1100, height: 780, sourceAtEnd: false, cursor: 'bottom', zoomed: false }, + parameters: { layout: 'fullscreen', fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) }, primedTerminalState: { byId: { [SOURCE]: { cwd: { path: '/home/demo/projects/dormouse', pathKind: 'posix', isRemote: false, source: 'osc633', updatedAt: 0 } } } }, chromatic: { viewports: [1200] } }, + play: async ({ args, canvasElement }) => { await prepare(args); canvasElement.dataset.placementCheck = 'passed'; }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const TwoColumns: Story = { args: { layout: 'columns' } }; +export const RightColumn: Story = { args: { layout: 'columns', sourceAtEnd: true } }; +export const TwoRows: Story = { args: { layout: 'rows' } }; +export const BottomRow: Story = { args: { layout: 'rows', sourceAtEnd: true } }; +export const Grid: Story = { args: { layout: 'grid' } }; +export const UnevenColumns: Story = { args: { layout: 'uneven' } }; +export const CursorAtTop: Story = { args: { cursor: 'top' } }; +export const CursorAtBottom: Story = {}; +export const ZoomedPane: Story = { args: { layout: 'grid', zoomed: true } }; +export const NarrowWindow: Story = { args: { width: 294, height: 620 } }; +export const ShortWindow: Story = { args: { width: 580, height: 310 } }; + +/** Real xterm input and browser pointer focus, through the same controls as users. */ +export const PreserveInputAndFocus: Story = { + play: async ({ args, canvasElement, step }) => { + const { source, before, grid } = await prepare(args); + const helper = getHelper(SOURCE)!; + const input = terminal(helper.id); + const element = input.element!; + const unfinished = 'echo keep-this-input'; + const bufferText = () => Array.from({ length: input.buffer.active.length }, (_, i) => input.buffer.active.getLine(i)?.translateToString(true) ?? '').join('\n'); + const checkInput = () => { + expect(getHelper(SOURCE)?.id).toBe(helper.id); + expect(terminal(helper.id)).toBe(input); + expect(input.element).toBe(element); + expect(bufferText()).toContain(unfinished); + expectContained(context(), canvasElement.querySelector('.lath-host')!); + }; + await step('Type unfinished input and switch sides without resizing the source', async () => { + input.focus(); + await userEvent.keyboard(unfinished); + await waitFor(() => expect(bufferText()).toContain(unfinished)); + const focused = document.activeElement; + await userEvent.click(within(context()).getByRole('button', { name: 'Place helper at bottom' })); + await waitFor(() => expect(context().dataset.contextSide).toBe('bottom')); + expect(document.activeElement).toBe(focused); + expect(rect(sourcePane())).toEqual(before); + expect({ cols: source.cols, rows: source.rows }).toEqual(grid); + checkInput(); + }); + await step('Resize the Wall without replacing the helper or dropping focus', async () => { + const frame = canvasElement.querySelector('[data-placement-frame]')!; + const focused = document.activeElement; + const oldColumns = input.cols; + frame.style.width = '840px'; + frame.style.height = '660px'; + await waitFor(() => expect(input.cols).toBeLessThan(oldColumns)); + expect(document.activeElement).toBe(focused); + expect(context().dataset.contextSide).toBe('bottom'); + checkInput(); + }); + await step('Close and reopen the retained helper, then return to Auto', async () => { + await userEvent.click(within(context()).getByRole('button', { name: 'Close terminal context' })); + await waitFor(() => expect(document.querySelector('[data-terminal-context]')).toBeNull()); + const resizedSource = rect(sourcePane()); + const header = await requireElement(`[data-pane-header-for="${SOURCE}"]`, 'source header'); + header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); + await requireElement('[data-helper-terminal] .xterm', 'retained helper'); + expect(context().dataset.contextSide).toBe('bottom'); + expect(rect(sourcePane())).toEqual(resizedSource); + checkInput(); + input.focus(); + const focused = document.activeElement; + await userEvent.click(within(context()).getByRole('button', { name: 'Use automatic helper placement' })); + await waitFor(() => expect(within(context()).getByRole('button', { name: 'Use automatic helper placement' })).toBeDisabled()); + expect(context().dataset.contextSide).toBe('top'); + expect(document.activeElement).toBe(focused); + checkInput(); + }); + canvasElement.dataset.placementCheck = 'passed'; + }, +}; + +export const TwoColumnsDark: Story = { args: { layout: 'columns' }, globals: { theme: 'Dark (Visual Studio)' } }; diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index 45c0f019c..0bee0b364 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -142,8 +142,22 @@ const meta = { const details = canvas.getByRole('button', { name: 'Terminal context details' }); await userEvent.click(details); await expect(details).toHaveAttribute('aria-expanded', 'true'); - await expect(canvas.getByText(args.initialScenario === 'notification' ? 'Tests complete' : 'Ports', { exact: true })).toBeVisible(); + const target = canvas.getByText(args.initialScenario === 'notification' ? 'Tests complete' : 'Ports', { exact: true }); + target.scrollIntoView({ block: 'nearest' }); + await expect(target).toBeVisible(); } + const panel = canvasElement.querySelector('[data-terminal-context]')!; + const bounds = panel.getBoundingClientRect(); + // DOM visibility matchers do not catch overflow clipping; check actual bounds. + for (const element of [within(panel).getByTitle('pnpm dev'), ...panel.querySelectorAll('button')]) { + const box = element.getBoundingClientRect(); + expect(box.width).toBeGreaterThan(0); + expect(box.left).toBeGreaterThanOrEqual(bounds.left); + expect(box.right).toBeLessThanOrEqual(bounds.right); + } + const terminal = panel.querySelector('.bg-terminal-bg')!; + expect(terminal.getBoundingClientRect().height).toBeGreaterThanOrEqual(64); + canvasElement.dataset.contextCheck = 'passed'; }, } satisfies Meta; export default meta; From c0eeef70bce8c5373ed8961f9cb05efd1ec5d307 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 10:50:58 -0700 Subject: [PATCH 07/23] Simplify helper placement story checks and align notification indent Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/wall/TerminalContextView.tsx | 2 +- lib/src/stories/HelperPlacement.stories.tsx | 38 +++++++++++-------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index d1f1b998b..ac9364650 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -210,7 +210,7 @@ export function TerminalContextView(p: TerminalContextViewProps) {
Alerts
{p.argv0 ? `Watch all ${p.argv0} commands` : 'No command running'}{p.argv0 && }TODO
}
- {expanded && p.notification &&
{p.notification.title}
{p.notification.body}
} + {expanded && p.notification &&
{p.notification.title}
{p.notification.body}
}
{p.placement &&
{p.placement.available.map(side => p.placement!.onChange(side)}> diff --git a/lib/src/stories/HelperPlacement.stories.tsx b/lib/src/stories/HelperPlacement.stories.tsx index bcc259ed0..854c78d25 100644 --- a/lib/src/stories/HelperPlacement.stories.tsx +++ b/lib/src/stories/HelperPlacement.stories.tsx @@ -48,13 +48,23 @@ function expectContained(element: Element, parent: Element) { expect(a.right).toBeLessThanOrEqual(b.right + 1); expect(a.bottom).toBeLessThanOrEqual(b.bottom + 1); } -async function openContext() { +async function rightClickSourceHeader() { const header = await requireElement(`[data-pane-header-for="${SOURCE}"]`, 'source header'); header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); await requireElement('[data-helper-terminal] .xterm', 'helper terminal'); +} +async function openContext() { + await rightClickSourceHeader(); await waitFor(() => expect(getHelper(SOURCE)?.status).toBe('completed')); await settleTerminals(); } +function expectedSide({ layout, zoomed, cursor, sourceAtEnd }: Props) { + // Alone in the Wall, the helper avoids the cursor; beside a neighbor, it takes the neighbor's side. + if (zoomed || layout === 'single') return cursor === 'top' ? 'bottom' : 'top'; + if (layout === 'grid') return 'bottom'; + if (layout === 'rows') return sourceAtEnd ? 'top' : 'bottom'; + return sourceAtEnd ? 'left' : 'right'; +} async function prepare(args: Props) { await settleTerminals(); if (args.zoomed) { @@ -68,16 +78,15 @@ async function prepare(args: Props) { const row = args.cursor === 'top' ? 2 : source.rows - 1; await new Promise(resolve => source.write(`\x1b[${row};1Hsource cursor here`, resolve)); const before = rect(sourcePane()); - const grid = { cols: source.cols, rows: source.rows }; + const size = { cols: source.cols, rows: source.rows }; + const expectSourceUnchanged = () => { + expect(rect(sourcePane())).toEqual(before); + expect({ cols: source.cols, rows: source.rows }).toEqual(size); + }; await openContext(); - expect(rect(sourcePane())).toEqual(before); - expect({ cols: source.cols, rows: source.rows }).toEqual(grid); + expectSourceUnchanged(); expectContained(context(), document.querySelector('.lath-host')!); - const expected = args.zoomed || args.layout === 'single' ? args.cursor === 'top' ? 'bottom' : 'top' - : args.layout === 'grid' ? 'bottom' - : args.layout === 'rows' ? args.sourceAtEnd ? 'top' : 'bottom' - : args.sourceAtEnd ? 'left' : 'right'; - expect(context().dataset.contextSide).toBe(expected); + expect(context().dataset.contextSide).toBe(expectedSide(args)); if (args.layout === 'grid' && !args.zoomed) { expect(within(context()).getByRole('button', { name: 'Place helper at right' })).toBeVisible(); expect(within(context()).getByRole('button', { name: 'Place helper at bottom' })).toBeVisible(); @@ -87,7 +96,7 @@ async function prepare(args: Props) { const b = sourcePane().getBoundingClientRect(); expect(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom).toBe(true); } - return { source, before, grid }; + return { expectSourceUnchanged }; } const meta = { @@ -115,7 +124,7 @@ export const ShortWindow: Story = { args: { width: 580, height: 310 } }; /** Real xterm input and browser pointer focus, through the same controls as users. */ export const PreserveInputAndFocus: Story = { play: async ({ args, canvasElement, step }) => { - const { source, before, grid } = await prepare(args); + const { expectSourceUnchanged } = await prepare(args); const helper = getHelper(SOURCE)!; const input = terminal(helper.id); const element = input.element!; @@ -136,8 +145,7 @@ export const PreserveInputAndFocus: Story = { await userEvent.click(within(context()).getByRole('button', { name: 'Place helper at bottom' })); await waitFor(() => expect(context().dataset.contextSide).toBe('bottom')); expect(document.activeElement).toBe(focused); - expect(rect(sourcePane())).toEqual(before); - expect({ cols: source.cols, rows: source.rows }).toEqual(grid); + expectSourceUnchanged(); checkInput(); }); await step('Resize the Wall without replacing the helper or dropping focus', async () => { @@ -155,9 +163,7 @@ export const PreserveInputAndFocus: Story = { await userEvent.click(within(context()).getByRole('button', { name: 'Close terminal context' })); await waitFor(() => expect(document.querySelector('[data-terminal-context]')).toBeNull()); const resizedSource = rect(sourcePane()); - const header = await requireElement(`[data-pane-header-for="${SOURCE}"]`, 'source header'); - header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); - await requireElement('[data-helper-terminal] .xterm', 'retained helper'); + await rightClickSourceHeader(); expect(context().dataset.contextSide).toBe('bottom'); expect(rect(sourcePane())).toEqual(resizedSource); checkInput(); From 86042d58737452d2d05f30e83fbac681d5c32be0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 11:21:45 -0700 Subject: [PATCH 08/23] Address spec review and repair visual story setup --- docs/specs/layout.md | 4 +--- lib/src/stories/HelperPlacement.stories.tsx | 5 ++++- lib/src/stories/ShellCwd.stories.tsx | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 3cd84b1fc..2bac04c73 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -74,8 +74,6 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- **Must keep source title, directory, and helper actions visible in compact context**, disclosing title explanation, directory actions, ports, and alerts through Details. Wrap header and detail actions within the panel; scroll bounded details and warnings while reserving 64px for terminal content. -Source of truth: `placeTerminalContext` in `lib/src/components/wall/terminal-context-placement.ts`; `TerminalContextOverlay` in `lib/src/components/wall/TerminalContextOverlay.tsx`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`. Tests: `lib/src/components/wall/terminal-context-placement.test.ts`, `lib/src/components/wall/TerminalContext.test.tsx`, `lib/src/components/wall/TerminalContextOverlay.test.tsx`, `lib/src/components/Wall.test.tsx`. - **Must reveal the context from the opening pointer position, clamped to its bounds, over 320ms.** Command-mode `a` and `>` use the header's bottom-left; openings without a position use the context's top-left. Keep final layout dimensions throughout the reveal. Start helper creation, settings reads, and port scanning immediately on mount; fade mounted content, including detail dialogs, in over 140ms after 160ms. Reduced motion or disabled layout animation skips both animations and the delay. **Must contract dismissals toward the opening origin over 180ms, fading content over 100ms**, starting from the current reveal when interrupted. Make the closing context inert and pause helper polling immediately; release focus without waiting for removal. Reopening cancels pending removal. Reduced motion dismisses immediately; promotion, source removal, and replacement by another context retain their immediate lifecycle transitions. @@ -100,7 +98,7 @@ Source of truth: `placeTerminalContext` in `lib/src/components/wall/terminal-con **Must promote by adopting the helper Session into a new split beside the source**, preserving identity and focusing it. Helper lifetime and source closure are owned by `docs/specs/terminal-context.md`. -Source of truth: `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `TerminalLeafOverlay` in `lib/src/components/wall/LathHost.tsx`; `TerminalPanel` in `lib/src/components/wall/TerminalPanel.tsx`; `TerminalPaneHeader` in `lib/src/components/wall/TerminalPaneHeader.tsx`; `useWallKeyboard` in `lib/src/components/wall/use-wall-keyboard.ts`; `.terminal-context-enter` / `.terminal-context-content` in `lib/src/theme.css`. Tests: `lib/src/components/wall/TerminalContext.test.tsx`, `lib/src/components/Wall.test.tsx`. +Source of truth: `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `TerminalContextOverlay` in `lib/src/components/wall/TerminalContextOverlay.tsx`; `placeTerminalContext` in `lib/src/components/wall/terminal-context-placement.ts`; `TerminalPanel` in `lib/src/components/wall/TerminalPanel.tsx`; `TerminalPaneHeader` in `lib/src/components/wall/TerminalPaneHeader.tsx`; `useWallKeyboard` in `lib/src/components/wall/use-wall-keyboard.ts`; `.terminal-context-enter` / `.terminal-context-content` in `lib/src/theme.css`. Tests: `lib/src/components/wall/TerminalContext.test.tsx`, `lib/src/components/wall/TerminalContextOverlay.test.tsx`, `lib/src/components/wall/terminal-context-placement.test.ts`, `lib/src/components/Wall.test.tsx`. ### Pane body diff --git a/lib/src/stories/HelperPlacement.stories.tsx b/lib/src/stories/HelperPlacement.stories.tsx index 854c78d25..c57fbcc4a 100644 --- a/lib/src/stories/HelperPlacement.stories.tsx +++ b/lib/src/stories/HelperPlacement.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import { Wall } from '../components/Wall'; -import { getHelper } from '../lib/helper-terminal'; +import { disposeHelper, getHelper } from '../lib/helper-terminal'; import { getTerminalInstance, refitSession } from '../lib/terminal-registry'; import { flattenScenario, SCENARIO_SHELL_PROMPT } from '../lib/platform'; import { leaves, normalizeWeights, type LathNode } from '../lib/lath/model'; @@ -102,6 +102,9 @@ async function prepare(args: Props) { const meta = { title: 'App/Helper placement', component: PlacementWall, + // Argos replays stories for capture; unfinished input from the preceding run + // must not turn this run's fresh-helper fixture into a preserved helper. + beforeEach: () => { disposeHelper(SOURCE); }, args: { layout: 'single', width: 1100, height: 780, sourceAtEnd: false, cursor: 'bottom', zoomed: false }, parameters: { layout: 'fullscreen', fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) }, primedTerminalState: { byId: { [SOURCE]: { cwd: { path: '/home/demo/projects/dormouse', pathKind: 'posix', isRemote: false, source: 'osc633', updatedAt: 0 } } } }, chromatic: { viewports: [1200] } }, play: async ({ args, canvasElement }) => { await prepare(args); canvasElement.dataset.placementCheck = 'passed'; }, diff --git a/lib/src/stories/ShellCwd.stories.tsx b/lib/src/stories/ShellCwd.stories.tsx index 82e07127e..aed5c6cbe 100644 --- a/lib/src/stories/ShellCwd.stories.tsx +++ b/lib/src/stories/ShellCwd.stories.tsx @@ -409,6 +409,7 @@ async function openHeaderContextMenu() { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, })); + (await requireElement('[aria-label="Terminal context details"]', 'context Details')).click(); const explain = await requireElement('[data-terminal-context] [aria-label="Explain this title"]', 'title explanation action'); explain.click(); await requireElement('[role="dialog"][aria-label="Title sources"]', 'title sources'); From 2a9108852a51ce0015dbc5a4e43a952ebbf909a9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 11:47:38 -0700 Subject: [PATCH 09/23] Inset helpers that overlap their source pane --- docs/specs/layout.md | 4 ++-- lib/src/components/wall/TerminalContextView.tsx | 2 +- .../wall/terminal-context-placement.test.ts | 17 +++++++++++++---- .../wall/terminal-context-placement.ts | 14 +++++++++----- lib/src/stories/HelperPlacement.stories.tsx | 8 ++++++++ 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 2bac04c73..5c5b7cffe 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -67,8 +67,8 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- | Layout | Placement | |---|---| | Multiple visible panes | Outside the source, separated by 8px; match its outer bounds where possible. Choose the largest usable candidate, ties right / left / bottom / top. Align the shared edge, shifting only to stay inside the Wall. | -| No usable adjacent candidate; single or zoomed pane | Source's top or bottom half, opposite its visible terminal cursor sampled on opening; unknown, offscreen, or midpoint cursor defaults to top. | -| Small source or Wall | Expand the half-pane fallback to the minimum usable size, clamped to Wall bounds. | +| No usable adjacent candidate; single or zoomed pane | Source's top or bottom half, inset 16px on every side, opposite its visible terminal cursor sampled on opening; unknown, offscreen, or midpoint cursor defaults to top. | +| Small source or Wall | Expand the half-pane fallback to the minimum usable size, clamped inside the Wall's 16px inset; shrink below the minimum when necessary to preserve the inset. | **Must offer available side buttons plus Auto**, with destination tooltips, accessible labels, and selected state. Remember manual choices per source for the mounted Wall's lifetime; clear on source removal or Auto. Preserve terminal focus on pointer repositioning. An unavailable choice falls back automatically; no preference is persisted to disk. diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index ac9364650..575170c8c 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -192,7 +192,7 @@ export function TerminalContextView(p: TerminalContextViewProps) { Title
{p.title}{expanded && setDetail('title')}>Explain} -
attempt(p.onCopyRef)}>{p.surfaceRef}{p.compact && setExpanded(value => !value)}>Details}
+
attempt(p.onCopyRef)}>{p.surfaceRef}{p.compact && setExpanded(value => !value)}>Details}
Dir
{p.cwd}{expanded && <> attempt(p.onExplore)}>{p.explorerLabel} attempt(p.onCopyPath)}>Copy path}
diff --git a/lib/src/components/wall/terminal-context-placement.test.ts b/lib/src/components/wall/terminal-context-placement.test.ts index 523da388f..28a130670 100644 --- a/lib/src/components/wall/terminal-context-placement.test.ts +++ b/lib/src/components/wall/terminal-context-placement.test.ts @@ -19,13 +19,22 @@ describe('terminal context placement', () => { expect(placeTerminalContext(wall, { ...wall, width: 800 }, true)).toMatchObject({ side: 'right', rect: { width: 392 } }); expect(placeTerminalContext(wall, { ...wall, width: 1000 }, true)).toMatchObject({ side: 'top', available: ['top', 'bottom'] }); }); - it('uses source halves for single or zoomed panes', () => { - expect(placeTerminalContext(wall, wall, false)).toMatchObject({ rect: { ...wall, height: 400 }, available: ['top', 'bottom'] }); - expect(placeTerminalContext(wall, wall, false, 'bottom').rect.y).toBe(400); + it('insets each source half for single or zoomed panes', () => { + expect(placeTerminalContext(wall, wall, false)).toMatchObject({ rect: { x: 16, y: 16, width: 1168, height: 368 }, available: ['top', 'bottom'] }); + expect(placeTerminalContext(wall, wall, false, 'bottom').rect).toEqual({ x: 16, y: 416, width: 1168, height: 368 }); + }); + it('insets overlapping fallbacks when no adjacent candidate fits', () => { + const source = { x: 100, y: 80, width: 1000, height: 640 }; + expect(placeTerminalContext(wall, source, true).rect).toEqual({ x: 116, y: 96, width: 968, height: 288 }); + expect(placeTerminalContext(wall, source, true, 'bottom').rect).toEqual({ x: 116, y: 416, width: 968, height: 288 }); }); it('keeps even tiny fallback panels inside offset Wall bounds', () => { const tiny = { x: 40, y: 60, width: 250, height: 180 }; - expect(placeTerminalContext(tiny, tiny, false, 'bottom').rect).toEqual(tiny); + expect(placeTerminalContext(tiny, tiny, false, 'bottom').rect).toEqual({ x: 56, y: 76, width: 218, height: 148 }); + }); + it('borrows space for small sources without losing the Wall inset', () => { + const source = { x: 1100, y: 700, width: 100, height: 100 }; + expect(placeTerminalContext(wall, source, false, 'bottom').rect).toEqual({ x: 904, y: 544, width: 280, height: 240 }); }); it('keeps an existing side when still usable, then falls back when it is not', () => { expect(placeTerminalContext(wall, { x: 400, y: 0, width: 380, height: 800 }, true, 'left').side).toBe('left'); diff --git a/lib/src/components/wall/terminal-context-placement.ts b/lib/src/components/wall/terminal-context-placement.ts index 671d0e8eb..244094518 100644 --- a/lib/src/components/wall/terminal-context-placement.ts +++ b/lib/src/components/wall/terminal-context-placement.ts @@ -4,6 +4,7 @@ export type ContextSide = Edge; export type ContextPlacement = { rect: Rect; side: ContextSide; available: ContextSide[] }; const SIDES: ContextSide[] = ['right', 'left', 'bottom', 'top']; const GAP = 8; +const OVERLAP_INSET = 16; // Compact source/directory/status chrome plus a useful terminal viewport. const MIN_WIDTH = 280; const MIN_HEIGHT = 240; @@ -38,12 +39,15 @@ export function placeTerminalContext(wall: Rect, source: Rect, multiPane: boolea if (chosen) return { ...chosen, available: candidates.map(candidate => candidate.side) }; const side = preferred === 'top' || preferred === 'bottom' ? preferred : fallback; - // Small source panes borrow Wall width/height only when the half-pane would be unusable. - const width = Math.min(wall.width, Math.max(source.width, MIN_WIDTH)); - const height = Math.min(wall.height, Math.max(source.height / 2, MIN_HEIGHT)); + // Leave the source visible around overlapping helpers. Small sources may borrow + // Wall space for usable chrome, but keep the inset even below the minimum size. + const insetX = Math.min(OVERLAP_INSET, wall.width / 2); + const insetY = Math.min(OVERLAP_INSET, wall.height / 2); + const width = Math.min(wall.width - 2 * insetX, Math.max(source.width - 2 * insetX, MIN_WIDTH)); + const height = Math.min(wall.height - 2 * insetY, Math.max(source.height / 2 - 2 * insetY, MIN_HEIGHT)); return { side, available: ['top', 'bottom'], rect: { - x: clamp(source.x, wall.x, right - width), - y: clamp(side === 'bottom' ? source.y + source.height - height : source.y, wall.y, bottom - height), + x: clamp(source.x + insetX, wall.x + insetX, right - insetX - width), + y: clamp(side === 'bottom' ? source.y + source.height - insetY - height : source.y + insetY, wall.y + insetY, bottom - insetY - height), width, height, } }; } diff --git a/lib/src/stories/HelperPlacement.stories.tsx b/lib/src/stories/HelperPlacement.stories.tsx index c57fbcc4a..c10d9f1c5 100644 --- a/lib/src/stories/HelperPlacement.stories.tsx +++ b/lib/src/stories/HelperPlacement.stories.tsx @@ -95,6 +95,14 @@ async function prepare(args: Props) { const a = context().getBoundingClientRect(); const b = sourcePane().getBoundingClientRect(); expect(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom).toBe(true); + } else { + const a = context().getBoundingClientRect(); + const b = sourcePane().getBoundingClientRect(); + expect(a.left - b.left).toBeCloseTo(16); + expect(b.right - a.right).toBeCloseTo(16); + expect(a.top - b.top).toBeGreaterThanOrEqual(16); + expect(b.bottom - a.bottom).toBeGreaterThanOrEqual(16); + expect(expectedSide(args) === 'top' ? a.top - b.top : b.bottom - a.bottom).toBeCloseTo(16); } return { expectSourceUnchanged }; } From 5bd222d1736ccd4362be636b20c173eab0cee81e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 12:17:53 -0700 Subject: [PATCH 10/23] Overlap adjacent helpers with their source pane --- docs/specs/layout.md | 2 +- .../wall/TerminalContextOverlay.test.tsx | 6 ++--- .../wall/terminal-context-placement.test.ts | 25 ++++++++++++++----- .../wall/terminal-context-placement.ts | 12 ++++----- lib/src/stories/HelperPlacement.stories.tsx | 5 ++-- 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 4bd0c2d8d..a2a2bec68 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -66,7 +66,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- | Layout | Placement | |---|---| -| Multiple visible panes | Outside the source, separated by 8px; match its outer bounds where possible. Choose the largest usable candidate, ties right / left / bottom / top. Align the shared edge, shifting only to stay inside the Wall. | +| Multiple visible panes | Beside the source, overlapping it by 16px so the near edge aligns with the inset helper's edge; match its outer size where possible. Choose the largest usable candidate, ties right / left / bottom / top. Align the other axis with the source, shifting only to stay inside the Wall. | | No usable adjacent candidate; single or zoomed pane | Source's top or bottom half, inset 16px on every side, opposite its visible terminal cursor sampled on opening; unknown, offscreen, or midpoint cursor defaults to top. | | Small source or Wall | Expand the half-pane fallback to the minimum usable size, clamped inside the Wall's 16px inset; shrink below the minimum when necessary to preserve the inset. | diff --git a/lib/src/components/wall/TerminalContextOverlay.test.tsx b/lib/src/components/wall/TerminalContextOverlay.test.tsx index 3206d2665..718510715 100644 --- a/lib/src/components/wall/TerminalContextOverlay.test.tsx +++ b/lib/src/components/wall/TerminalContextOverlay.test.tsx @@ -38,14 +38,14 @@ it('tracks animation without rerendering the helper or snapping back on unrelate act(() => input.focus()); input.value = 'unfinished command'; const renders = rendered.mock.calls.length; - expect(host.style.left).toBe('508px'); + expect(host.style.left).toBe('484px'); painted = { ...source, width: 550 }; act(() => { for (const notify of listeners) notify(); }); - expect(host.style.left).toBe('558px'); + expect(host.style.left).toBe('534px'); expect(host.style.width).toBe('550px'); expect(rendered).toHaveBeenCalledTimes(renders); render('New source title'); - expect(host.style.left).toBe('558px'); + expect(host.style.left).toBe('534px'); expect(host.style.width).toBe('550px'); expect(container.querySelector('[data-test-context]')).toBe(helper); expect(input.value).toBe('unfinished command'); diff --git a/lib/src/components/wall/terminal-context-placement.test.ts b/lib/src/components/wall/terminal-context-placement.test.ts index 28a130670..334f258eb 100644 --- a/lib/src/components/wall/terminal-context-placement.test.ts +++ b/lib/src/components/wall/terminal-context-placement.test.ts @@ -2,13 +2,26 @@ import { describe, expect, it } from 'vitest'; import { cursorHalfSide, placeTerminalContext } from './terminal-context-placement'; const wall = { x: 0, y: 0, width: 1200, height: 800 }; describe('terminal context placement', () => { - it('places beside either column without covering the source', () => { - expect(placeTerminalContext(wall, { x: 0, y: 0, width: 596, height: 800 }, true)).toMatchObject({ side: 'right', rect: { x: 604, y: 0, width: 596, height: 800 } }); - expect(placeTerminalContext(wall, { x: 604, y: 0, width: 596, height: 800 }, true)).toMatchObject({ side: 'left', rect: { x: 0, y: 0, width: 596, height: 800 } }); + it('places beside either column, overlapping the source by the inset', () => { + expect(placeTerminalContext(wall, { x: 0, y: 0, width: 596, height: 800 }, true)).toMatchObject({ side: 'right', rect: { x: 580, y: 0, width: 596, height: 800 } }); + expect(placeTerminalContext(wall, { x: 604, y: 0, width: 596, height: 800 }, true)).toMatchObject({ side: 'left', rect: { x: 24, y: 0, width: 596, height: 800 } }); }); it('uses below/above in stacked layouts', () => { - expect(placeTerminalContext(wall, { ...wall, height: 396 }, true).side).toBe('bottom'); - expect(placeTerminalContext(wall, { ...wall, y: 404, height: 396 }, true).side).toBe('top'); + expect(placeTerminalContext(wall, { ...wall, height: 396 }, true)).toMatchObject({ side: 'bottom', rect: { x: 0, y: 380, width: 1200, height: 396 } }); + expect(placeTerminalContext(wall, { ...wall, y: 404, height: 396 }, true)).toMatchObject({ side: 'top', rect: { x: 0, y: 24, width: 1200, height: 396 } }); + }); + it('aligns all four adjacent edges with the inset helper bounds', () => { + const source = { x: 400, y: 260, width: 400, height: 280 }; + const insetTop = placeTerminalContext(wall, source, false, 'top').rect; + const insetBottom = placeTerminalContext(wall, source, false, 'bottom').rect; + const right = placeTerminalContext(wall, source, true, 'right').rect; + const left = placeTerminalContext(wall, source, true, 'left').rect; + const bottom = placeTerminalContext(wall, source, true, 'bottom').rect; + const top = placeTerminalContext(wall, source, true, 'top').rect; + expect(right.x).toBe(insetTop.x + insetTop.width); + expect(left.x + left.width).toBe(insetTop.x); + expect(bottom.y).toBe(insetBottom.y + insetBottom.height); + expect(top.y + top.height).toBe(insetTop.y); }); it('breaks equal grid fits right-first and honors manual sides', () => { const source = { x: 0, y: 0, width: 596, height: 396 }; @@ -16,7 +29,7 @@ describe('terminal context placement', () => { expect(placeTerminalContext(wall, source, true, 'bottom').side).toBe('bottom'); }); it('shrinks into an uneven neighbor and rejects unusable slivers', () => { - expect(placeTerminalContext(wall, { ...wall, width: 800 }, true)).toMatchObject({ side: 'right', rect: { width: 392 } }); + expect(placeTerminalContext(wall, { ...wall, width: 800 }, true)).toMatchObject({ side: 'right', rect: { x: 784, width: 416 } }); expect(placeTerminalContext(wall, { ...wall, width: 1000 }, true)).toMatchObject({ side: 'top', available: ['top', 'bottom'] }); }); it('insets each source half for single or zoomed panes', () => { diff --git a/lib/src/components/wall/terminal-context-placement.ts b/lib/src/components/wall/terminal-context-placement.ts index 244094518..20872d859 100644 --- a/lib/src/components/wall/terminal-context-placement.ts +++ b/lib/src/components/wall/terminal-context-placement.ts @@ -3,7 +3,7 @@ import { edgeAxis, type Edge, type Rect } from '../../lib/lath/model'; export type ContextSide = Edge; export type ContextPlacement = { rect: Rect; side: ContextSide; available: ContextSide[] }; const SIDES: ContextSide[] = ['right', 'left', 'bottom', 'top']; -const GAP = 8; +/** Adjacent helpers reach into the source to align with its inset helper edge. */ const OVERLAP_INSET = 16; // Compact source/directory/status chrome plus a useful terminal viewport. const MIN_WIDTH = 280; @@ -23,14 +23,14 @@ export function placeTerminalContext(wall: Rect, source: Rect, multiPane: boolea const bottom = wall.y + wall.height; const candidates = !multiPane ? [] : SIDES.map(side => { const horizontal = edgeAxis(side) === 'row'; - const space = side === 'right' ? right - source.x - source.width - GAP - : side === 'left' ? source.x - wall.x - GAP - : side === 'bottom' ? bottom - source.y - source.height - GAP : source.y - wall.y - GAP; + const space = side === 'right' ? right - source.x - source.width + OVERLAP_INSET + : side === 'left' ? source.x - wall.x + OVERLAP_INSET + : side === 'bottom' ? bottom - source.y - source.height + OVERLAP_INSET : source.y - wall.y + OVERLAP_INSET; const width = Math.max(0, Math.min(source.width, horizontal ? space : wall.width)); const height = Math.max(0, Math.min(source.height, horizontal ? wall.height : space)); return { side, rect: { - x: side === 'right' ? source.x + source.width + GAP : side === 'left' ? source.x - GAP - width : clamp(source.x, wall.x, right - width), - y: side === 'bottom' ? source.y + source.height + GAP : side === 'top' ? source.y - GAP - height : clamp(source.y, wall.y, bottom - height), + x: side === 'right' ? source.x + source.width - OVERLAP_INSET : side === 'left' ? source.x + OVERLAP_INSET - width : clamp(source.x, wall.x, right - width), + y: side === 'bottom' ? source.y + source.height - OVERLAP_INSET : side === 'top' ? source.y + OVERLAP_INSET - height : clamp(source.y, wall.y, bottom - height), width, height, } }; }).filter(candidate => candidate.rect.width >= MIN_WIDTH && candidate.rect.height >= MIN_HEIGHT); diff --git a/lib/src/stories/HelperPlacement.stories.tsx b/lib/src/stories/HelperPlacement.stories.tsx index c10d9f1c5..1a23fe1e7 100644 --- a/lib/src/stories/HelperPlacement.stories.tsx +++ b/lib/src/stories/HelperPlacement.stories.tsx @@ -61,7 +61,7 @@ async function openContext() { function expectedSide({ layout, zoomed, cursor, sourceAtEnd }: Props) { // Alone in the Wall, the helper avoids the cursor; beside a neighbor, it takes the neighbor's side. if (zoomed || layout === 'single') return cursor === 'top' ? 'bottom' : 'top'; - if (layout === 'grid') return 'bottom'; + if (layout === 'grid') return 'right'; if (layout === 'rows') return sourceAtEnd ? 'top' : 'bottom'; return sourceAtEnd ? 'left' : 'right'; } @@ -94,7 +94,8 @@ async function prepare(args: Props) { if (!args.zoomed && args.layout !== 'single') { const a = context().getBoundingClientRect(); const b = sourcePane().getBoundingClientRect(); - expect(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom).toBe(true); + const overlap = { right: b.right - a.left, left: a.right - b.left, bottom: b.bottom - a.top, top: a.bottom - b.top }; + expect(overlap[expectedSide(args)]).toBeCloseTo(16); } else { const a = context().getBoundingClientRect(); const b = sourcePane().getBoundingClientRect(); From 33ced2c2a97ed7b4c3b2a348f8df1b66b070a9e2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 12:34:39 -0700 Subject: [PATCH 11/23] Group helper header actions and outline the source-helper union --- docs/specs/layout.md | 8 ++- lib/src/components/Wall.tsx | 2 +- .../wall/TerminalContextOverlay.test.tsx | 2 +- .../wall/TerminalContextOverlay.tsx | 11 +--- .../components/wall/TerminalContextView.tsx | 16 +++--- .../wall/WorkspaceSelectionOverlay.test.tsx | 32 +++++++++++- .../wall/WorkspaceSelectionOverlay.tsx | 33 +++++++++--- lib/src/lib/rect-union-outline.test.ts | 28 ++++++++++ lib/src/lib/rect-union-outline.ts | 52 +++++++++++++++++++ lib/src/lib/ring-geometry.ts | 2 +- lib/src/stories/HelperPlacement.stories.tsx | 10 ++++ scripts/spec-word-budgets.json | 2 +- 12 files changed, 168 insertions(+), 30 deletions(-) create mode 100644 lib/src/lib/rect-union-outline.test.ts create mode 100644 lib/src/lib/rect-union-outline.ts diff --git a/docs/specs/layout.md b/docs/specs/layout.md index a2a2bec68..33de9c8bc 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -60,7 +60,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- **Must open the terminal context from terminal header, body, and command-mode `a` and `>` entry points.** Browser-only Surfaces and Doors have no context. Tool context displays its primary terminal; `docs/specs/terminal-context.md` → Tool context owns that composition. Application mouse ownership follows `docs/specs/mouse-and-clipboard.md` → Terminal context input. -**Must render one context per Wall in a stable Wall-level overlay**, with a theme-derived edge and raised shadow. Anchor it to the invoking source, outline that source, and follow its painted bounds without resizing panes or remounting the helper. Outside pointer press and explicit close dismiss it. +**Must render one context per Wall in a stable Wall-level overlay**, with a theme-derived edge and raised shadow. Anchor it to the invoking source and follow its painted bounds without resizing panes or remounting the helper. Outside pointer press and explicit close dismiss it. **Must choose placement on opening and retain its side while usable.** Never reposition in response to terminal output. Minimized panes do not count; zoom uses single-pane placement. @@ -70,7 +70,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- | No usable adjacent candidate; single or zoomed pane | Source's top or bottom half, inset 16px on every side, opposite its visible terminal cursor sampled on opening; unknown, offscreen, or midpoint cursor defaults to top. | | Small source or Wall | Expand the half-pane fallback to the minimum usable size, clamped inside the Wall's 16px inset; shrink below the minimum when necessary to preserve the inset. | -**Must offer available side buttons plus Auto**, with destination tooltips, accessible labels, and selected state. Remember manual choices per source for the mounted Wall's lifetime; clear on source removal or Auto. Preserve terminal focus on pointer repositioning. An unavailable choice falls back automatically; no preference is persisted to disk. +**Must group available side buttons plus Auto beside Close at the context header’s right edge**, with destination tooltips, accessible labels, and selected state. Remember manual choices per source for the mounted Wall's lifetime; clear on source removal or Auto. Preserve terminal focus on pointer repositioning. An unavailable choice falls back automatically; no preference is persisted to disk. **Must keep source title, directory, and helper actions visible in compact context**, disclosing title explanation, directory actions, ports, and alerts through Details. Wrap header and detail actions within the panel; scroll bounded details and warnings while reserving 64px for terminal content. @@ -338,6 +338,8 @@ Source of truth: `requestKill` (every kill gesture: Door reattach, untouched fas ## Selection overlay +**Must outline the union of the invoking source Pane and its open helper**, following their outer contour without an internal seam or enclosing unused neighboring space. Track helper repositioning and resize without replacing its terminal; restore the source-only ring on close. + A fixed-positioned element on top of the Lath host, covering the active element's area inflated by `SELECTION_RING_INFLATE_PX` (4px) for panes; doors are not inflated. **The inflate is derived in `lib/src/components/design.tsx` so both ring strokes center on the gutter's midline** (rationale). - **Exactly one pane or door is active at a time**, drawn by one SVG renderer (`SelectionRing`, `variant: 'ants' | 'solid'`). @@ -349,6 +351,8 @@ A fixed-positioned element on top of the Lath host, covering the active element' - `z-index: SELECTION_RING_Z_INDEX` (50), `pointer-events: none`. Under `WorkspaceWindow` it renders into `document.body`, outside the Workspace's transform and stacking context. - **Every modal must render into `document.body` too, at a `MODAL_LAYERS` value above the ring's** (`ModalOverlay`), or the ring crosses it — by value, never insertion order. Pinned by `lib/src/components/ModalOverlay.test.tsx`. +Source of truth: `rectUnionOutline` in `lib/src/lib/rect-union-outline.ts` and `WorkspaceSelectionOverlay` in `lib/src/components/wall/WorkspaceSelectionOverlay.tsx`. + ### Ring travel The ring's rect (and its `{tl,tr,br,bl,inset}` shape) is driven **per-frame by a JS tween, never a CSS transition**; DESIGN.md's ban on animating layout properties does not reach it (rationale). Motion is `FOCUS_MOTION_MS` (220ms — half `LATH_MOTION_MS`) on the house curve `cubic-bezier(0.22, 1, 0.36, 1)`. diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 27da6a064..8260662b5 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -2325,7 +2325,7 @@ export function Wall({ externalDrag={doorDrag ? { id: doorDrag.item.id, startX: doorDrag.startX, startY: doorDrag.startY } : null} onExternalDrop={onExternalDrop} /> - +
diff --git a/lib/src/components/wall/TerminalContextOverlay.test.tsx b/lib/src/components/wall/TerminalContextOverlay.test.tsx index 718510715..bfe325df4 100644 --- a/lib/src/components/wall/TerminalContextOverlay.test.tsx +++ b/lib/src/components/wall/TerminalContextOverlay.test.tsx @@ -50,7 +50,7 @@ it('tracks animation without rerendering the helper or snapping back on unrelate expect(container.querySelector('[data-test-context]')).toBe(helper); expect(input.value).toBe('unfinished command'); expect(document.activeElement).toBe(input); - expect(container.querySelector('[data-context-source]')!.style.width).toBe('550px'); + expect(host.dataset.contextFor).toBe('source'); } finally { act(() => root.unmount()); container.remove(); diff --git a/lib/src/components/wall/TerminalContextOverlay.tsx b/lib/src/components/wall/TerminalContextOverlay.tsx index 0781a4bd4..23b09e6ad 100644 --- a/lib/src/components/wall/TerminalContextOverlay.tsx +++ b/lib/src/components/wall/TerminalContextOverlay.tsx @@ -1,14 +1,12 @@ import { useLayoutEffect, useRef, useState } from 'react'; import type { Rect } from '../../lib/lath/model'; import { getTerminalInstance } from '../../lib/terminal-registry'; -import { TERMINAL_SELECTION_BORDER_RADIUS } from '../design'; import { TerminalContext } from './TerminalContext'; import type { TerminalContextState } from './wall-context'; import { nowMs, type LathWallEngine } from './lath-wall-engine'; import { cursorHalfSide, placeTerminalContext, type ContextPlacement, type ContextSide } from './terminal-context-placement'; -/** Above LathHost's drop preview (`Z_PREVIEW`); the outline sits just under the context. */ -const Z_CONTEXT_SOURCE = 49; +/** Above LathHost's drop preview (`Z_PREVIEW`). */ const Z_CONTEXT = 50; const boxStyle = ({ x, y, width, height }: Rect) => ({ left: x, top: y, width, height }); @@ -30,7 +28,6 @@ export function TerminalContextOverlay({ context, title, tool, wall, source, mul const [manual, setManual] = useState(() => preferences.get(context.id)); const lastSide = useRef(manual); const host = useRef(null); - const outline = useRef(null); const measure = () => { const painted = lath.animator.framesAt(nowMs()).get(context.id)?.rect ?? source; return { painted, placement: placeTerminalContext(wall, painted, multiPane, manual ?? lastSide.current, cursorSide) }; @@ -40,7 +37,6 @@ export function TerminalContextOverlay({ context, title, tool, wall, source, mul const update = () => { const next = measure(); lastSide.current = next.placement.side; - writeBox(outline.current, next.painted); writeBox(host.current, next.placement.rect); setShown(previous => previous.placement.side === next.placement.side && previous.placement.available.join() === next.placement.available.join() ? previous : next); @@ -50,10 +46,7 @@ export function TerminalContextOverlay({ context, title, tool, wall, source, mul // eslint-disable-next-line react-hooks/exhaustive-deps -- `measure` reads exactly these inputs }, [lath, context.id, manual, multiPane, cursorSide, wall.x, wall.y, wall.width, wall.height, source.x, source.y, source.width, source.height]); return <> -
-
+
{ if (side) preferences.set(context.id, side); else preferences.delete(context.id); lastSide.current = side; diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index 575170c8c..5a0edeb0e 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -192,7 +192,13 @@ export function TerminalContextView(p: TerminalContextViewProps) { Title
{p.title}{expanded && setDetail('title')}>Explain} -
attempt(p.onCopyRef)}>{p.surfaceRef}{p.compact && setExpanded(value => !value)}>Details}
+
attempt(p.onCopyRef)}>{p.surfaceRef}{p.compact && setExpanded(value => !value)}>Details}
{p.placement &&
+ {p.placement.available.map(side => p.placement!.onChange(side)}> + + + )} + p.placement!.onChange()} disabled={!p.placement.manual} keepFocus>Auto +
}
Dir
{p.cwd}{expanded && <> attempt(p.onExplore)}>{p.explorerLabel} attempt(p.onCopyPath)}>Copy path}
@@ -212,13 +218,7 @@ export function TerminalContextView(p: TerminalContextViewProps) {
{expanded && p.notification &&
{p.notification.title}
{p.notification.body}
}
- {p.placement &&
- {p.placement.available.map(side => p.placement!.onChange(side)}> - - - )} - p.placement!.onChange()} disabled={!p.placement.manual} keepFocus>Auto -
} +
{isTool ? 'Tool terminal' : 'Helper terminal'} diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx index 560c88ce8..b18a685af 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx @@ -65,7 +65,7 @@ function paneCtx(elements: Map): PaneElementsState { return { elements, version: 0, bumpVersion: () => {} }; } -function Harness({ selectedId, selectedType = 'pane', mode, store, panes, doors = new Map(), active = true }: { +function Harness({ selectedId, selectedType = 'pane', mode, store, panes, doors = new Map(), active = true, contextSourceId }: { selectedId: string | null; selectedType?: WallSelectionKind; mode: WallMode; @@ -73,6 +73,7 @@ function Harness({ selectedId, selectedType = 'pane', mode, store, panes, doors panes: Map; doors?: Map; active?: boolean; + contextSourceId?: string; }) { return ( @@ -85,6 +86,7 @@ function Harness({ selectedId, selectedType = 'pane', mode, store, panes, doors selectedType={selectedType} mode={mode} active={active} + contextSourceId={contextSourceId} /> @@ -554,3 +556,31 @@ describe('SelectionRing motion smear', () => { expect(path.getAttribute('stroke-opacity')).toBeNull(); }); }); + +it('tracks the source/helper union through repositioning and restores the source ring on close', async () => { + const store = makeStore(); + const wall = document.createElement('div'); + wall.className = 'lath-host'; + const source = document.createElement('div'); + const helper = document.createElement('div'); + helper.dataset.contextFor = 'a'; + wall.append(source, helper); + document.body.append(wall); + stubRect(source, { left: 0, top: 0, width: 500, height: 600 }); + stubRect(helper, { left: 484, top: 0, width: 400, height: 300 }); + const panes = new Map([['a', source]]); + try { + await act(async () => root.render()); + const path = container.querySelector('[data-ring="outline"]')!; + expect(path.dataset.contextUnion).toBe('true'); + expect(ringRect()?.width).toBe(892); + const original = path.getAttribute('d'); + await act(async () => { stubRect(helper, { left: 0, top: 584, width: 400, height: 300 }); helper.style.top = '584px'; }); + expect(ringRect()?.height).toBe(892); + expect(path.getAttribute('d')).not.toBe(original); + await act(async () => root.render()); + expect(path.dataset.contextUnion).toBe('false'); + expect(ringRect()?.width).toBe(508); + expect(path.getAttribute('stroke-dasharray')).toBeTruthy(); + } finally { wall.remove(); } +}); diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx index 44d519779..8f71a98f5 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx @@ -39,6 +39,7 @@ import { type RingEdge, } from '../../lib/ring-geometry'; import { SelectionRing } from './SelectionRing'; +import { rectUnionOutline, roundedUnionOutline } from '../../lib/rect-union-outline'; /** The subset of the Lath store the overlay needs — a revision that bumps on every * commit, so the ring re-measures as leaves move / resize / restore. Kept @@ -188,7 +189,7 @@ function writeSmear( } } -export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, selectedId, selectedType, mode, active = true }: { +export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, selectedId, selectedType, mode, active = true, contextSourceId }: { /** The Lath store — the overlay re-measures on every commit (`revision` via * `useSyncExternalStore`), so the ring tracks leaves as they move / resize / restore. */ lathStore: LathOverlayStore; @@ -200,6 +201,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele selectedType: WallSelectionKind; mode: WallMode; active?: boolean; + contextSourceId?: string; }) { const { elements: paneElements, version: paneVersion } = useContext(PaneElementsContext); const { elements: doorElements, version: doorVersion } = useContext(DoorElementsContext); @@ -231,6 +233,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele // variant without a stale capture. const frameRef = useRef(null); const opacityRef = useRef(''); + const unionRects = useRef<[RingRect, RingRect] | null>(null); const modeRef = useRef(mode); modeRef.current = mode; @@ -268,14 +271,18 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele // Resolved once here so every path builder below sees a single inset. const effShape = isAnts ? shape : { ...shape, inset: strokeWidth / 2 }; - path.setAttribute('d', roundedRectPath(rect, effShape)); + const union = unionRects.current?.map(r => ({ left: r.left + effShape.inset, top: r.top + effShape.inset, width: r.width - 2 * effShape.inset, height: r.height - 2 * effShape.inset })); + const contour = union ? rectUnionOutline(union[0], union[1]) : null; + const outline = contour ? roundedUnionOutline(contour.points.map(p => ({ x: p.x + contour.rect.left - rect.left, y: p.y + contour.rect.top - rect.top })), effShape.tl - effShape.inset) : null; + path.setAttribute('d', outline?.path ?? roundedRectPath(rect, effShape)); + path.dataset.contextUnion = contour ? 'true' : 'false'; if (isAnts) { // Dash sized to the perimeter so the segments stay even as the ring resizes. // Computed in closed form rather than via `path.getTotalLength()`, which // forces a synchronous style+layout flush on every frame of a travel at a // cost that scales with the whole document, not this one path. - const len = ringPerimeter(rect, effShape); + const len = outline?.perimeter ?? ringPerimeter(rect, effShape); const count = Math.max(1, Math.round(len / cfg.marchingAnts.segLen)); const adjusted = len / count; const dash = adjusted * cfg.marchingAnts.dashFraction; @@ -291,7 +298,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele } const smear = smearRef.current; - if (smear) writeSmear(smear, rect, effShape, strokeWidth, speeds); + if (smear) writeSmear(smear, rect, effShape, strokeWidth, contour ? null : speeds); }, []); // Re-run the measuring effect after each Lath commit. Runs post-render, so @@ -388,6 +395,8 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele if (isWorkspaceSelection(selectedType)) return workspaceTabElement(workspaceIdOfSelection(selectedType, selectedId)); return selectedType === 'door' ? doorElements.get(selectedId) : resolvePaneElement(paneElements.get(selectedId)); }; + const helper = () => contextSourceId === selectedId && selectedType === 'pane' + ? target()?.closest('.lath-host')?.querySelector('[data-context-for]') ?? null : null; const update = () => { if (!activeRef.current) return; const targetEl = target(); @@ -395,6 +404,11 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele const next = measureFrame(targetEl, selectedType); if (!next) return; + const helperEl = helper(); + const helperFrame = helperEl ? measureFrame(helperEl, 'pane') : null; + const hadUnion = unionRects.current !== null; + unionRects.current = helperFrame ? [next.rect, helperFrame.rect] : null; + if (helperFrame) next.rect = rectUnionOutline(next.rect, helperFrame.rect).rect; const wall = targetEl.closest('[data-workspace-wall]'); opacityRef.current = wall?.style.opacity ?? ''; @@ -412,7 +426,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele // Snap gate: the same instant-motion predicate the Lath animator's // duration uses (motionIsInstant), so the ring and the leaves agree. - if (instant) { + if (instant || helperFrame || hadUnion) { snapTo(next, identity); return; } @@ -449,6 +463,12 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele const ro = new ResizeObserver(update); const targetEl = target(); if (targetEl) ro.observe(targetEl); + const helperEl = helper(); + const mo = new MutationObserver(update); + if (helperEl) { + ro.observe(helperEl); + mo.observe(helperEl, { attributes: true, attributeFilter: ['style'] }); + } window.addEventListener('resize', update); document.addEventListener('scroll', update, true); @@ -459,6 +479,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele return () => { ro.disconnect(); + mo.disconnect(); unsubFrames?.(); unsubWorkspaceFrames(); window.removeEventListener('resize', update); @@ -467,7 +488,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele // The rAF loop is intentionally NOT torn down here: it is keyed to the tween // (a ref), so a mid-glide re-run of this effect keeps the ring moving. It is // cancelled on selection-clear (above), on snap, and on unmount (below). - }, [active, handoff, workspaces, subscribeLathFrames, lathRevision, selectedId, selectedType, paneVersion, doorVersion, paneElements, doorElements, applyRing]); + }, [active, contextSourceId, handoff, workspaces, subscribeLathFrames, lathRevision, selectedId, selectedType, paneVersion, doorVersion, paneElements, doorElements, applyRing]); // After any structural render (mount, variant/color/focus change) re-apply the // current frame imperatively so the shell's DOM matches — runs pre-paint, so a diff --git a/lib/src/lib/rect-union-outline.test.ts b/lib/src/lib/rect-union-outline.test.ts new file mode 100644 index 000000000..acf8acf79 --- /dev/null +++ b/lib/src/lib/rect-union-outline.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from 'vitest'; +import { ringPerimeter } from './ring-geometry'; +import { rectUnionOutline, roundedUnionOutline } from './rect-union-outline'; + +it('removes the shared seam while retaining a smaller helper’s step', () => { + const union = rectUnionOutline({ left: 10, top: 20, width: 100, height: 100 }, { left: 90, top: 20, width: 80, height: 50 }); + expect(union.rect).toEqual({ left: 10, top: 20, width: 160, height: 100 }); + expect(union.points).toEqual([{ x: 0, y: 0 }, { x: 160, y: 0 }, { x: 160, y: 50 }, { x: 100, y: 50 }, { x: 100, y: 100 }, { x: 0, y: 100 }]); + expect(roundedUnionOutline(union.points, 8).path).not.toMatch(/NaN|Infinity/); + expect(roundedUnionOutline(union.points, 8).path).toContain('Q100,50'); +}); +it('keeps an inset helper inside the original outline', () => { + const source = { left: 0, top: 0, width: 100, height: 100 }; + expect(rectUnionOutline(source, { left: 16, top: 16, width: 68, height: 30 }).points).toEqual([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }]); +}); +it.each(['left', 'right', 'top', 'bottom'] as const)('encloses both rectangles opening %s without extra area', side => { + const source = { left: 0, top: 0, width: 100, height: 100 }; + const helper = { left: side === 'left' ? -64 : side === 'right' ? 84 : 0, top: side === 'top' ? -64 : side === 'bottom' ? 84 : 0, width: 80, height: 80 }; + const { points } = rectUnionOutline(source, helper); + const area = Math.abs(points.reduce((sum, p, i) => { const q = points[(i + 1) % points.length]; return sum + p.x * q.y - q.x * p.y; }, 0)) / 2; + expect(area).toBe(10000 + 6400 - 16 * 80); +}); + +it('matches the regular ring perimeter for a rectangular union', () => { + const rect = { left: 0, top: 0, width: 100, height: 100 }; + const outline = roundedUnionOutline(rectUnionOutline(rect, rect).points, 8); + expect(outline.perimeter).toBeCloseTo(ringPerimeter(rect, { tl: 8, tr: 8, bl: 8, br: 8, inset: 0 })); +}); diff --git a/lib/src/lib/rect-union-outline.ts b/lib/src/lib/rect-union-outline.ts new file mode 100644 index 000000000..520b8bba2 --- /dev/null +++ b/lib/src/lib/rect-union-outline.ts @@ -0,0 +1,52 @@ +import type { RingRect } from './rect-tween'; +import { QUARTER_TURN } from './ring-geometry'; + +type Point = { x: number; y: number }; +const same = (a: Point, b: Point) => a.x === b.x && a.y === b.y; + +/** Outer contour of two overlapping rectangles. Grid cells remove internal seams + * before rounding, so a smaller helper leaves a step rather than framing peers. */ +export function rectUnionOutline(a: RingRect, b: RingRect) { + const xs = [...new Set([a.left, a.left + a.width, b.left, b.left + b.width])].sort((x, y) => x - y); + const ys = [...new Set([a.top, a.top + a.height, b.top, b.top + b.height])].sort((x, y) => x - y); + const inside = (x: number, y: number) => [a, b].some(r => x > r.left && x < r.left + r.width && y > r.top && y < r.top + r.height); + const filled = (i: number, j: number) => i >= 0 && j >= 0 && i < xs.length - 1 && j < ys.length - 1 && inside((xs[i] + xs[i + 1]) / 2, (ys[j] + ys[j + 1]) / 2); + const edges: [Point, Point][] = []; + for (let i = 0; i < xs.length - 1; i++) for (let j = 0; j < ys.length - 1; j++) { + if (!filled(i, j)) continue; + const tl = { x: xs[i], y: ys[j] }, tr = { x: xs[i + 1], y: ys[j] }; + const br = { x: xs[i + 1], y: ys[j + 1] }, bl = { x: xs[i], y: ys[j + 1] }; + if (!filled(i, j - 1)) edges.push([tl, tr]); + if (!filled(i + 1, j)) edges.push([tr, br]); + if (!filled(i, j + 1)) edges.push([br, bl]); + if (!filled(i - 1, j)) edges.push([bl, tl]); + } + const points: Point[] = []; + let edge = edges.shift(); + while (edge) { + points.push(edge[0]); + const next = edges.findIndex(candidate => same(candidate[0], edge![1])); + edge = next < 0 ? undefined : edges.splice(next, 1)[0]; + } + const corners = points.filter((p, i) => { + const before = points[(i + points.length - 1) % points.length], after = points[(i + 1) % points.length]; + return !((before.x === p.x && p.x === after.x) || (before.y === p.y && p.y === after.y)); + }); + const rect = { left: xs[0], top: ys[0], width: xs[xs.length - 1] - xs[0], height: ys[ys.length - 1] - ys[0] }; + return { rect, points: corners.map(p => ({ x: p.x - rect.left, y: p.y - rect.top })) }; +} + +export function roundedUnionOutline(points: Point[], radius: number) { + const toward = (p: Point, q: Point, distance: number) => { + const length = Math.hypot(q.x - p.x, q.y - p.y); + return `${p.x + (q.x - p.x) * distance / length},${p.y + (q.y - p.y) * distance / length}`; + }; + let perimeter = 0; + const path = points.map((p, i) => { + const before = points[(i + points.length - 1) % points.length], after = points[(i + 1) % points.length]; + const r = Math.min(radius, Math.hypot(p.x - before.x, p.y - before.y) / 2, Math.hypot(p.x - after.x, p.y - after.y) / 2); + perimeter += Math.hypot(p.x - after.x, p.y - after.y) + (QUARTER_TURN - 2) * r; + return `${i ? 'L' : 'M'}${toward(p, before, r)} Q${p.x},${p.y} ${toward(p, after, r)}`; + }).join(' ') + ' Z'; + return { path, perimeter }; +} diff --git a/lib/src/lib/ring-geometry.ts b/lib/src/lib/ring-geometry.ts index fc0aa258d..546a97a81 100644 --- a/lib/src/lib/ring-geometry.ts +++ b/lib/src/lib/ring-geometry.ts @@ -95,7 +95,7 @@ const dist = ([ax, ay]: Point, [bx, by]: Point) => Math.hypot(bx - ax, by - ay); * Verified against Simpson's rule and a 3M-segment polyline of the real curve to * 1e-12; `ring-geometry.test.ts` re-checks it against a flattened path. */ -const QUARTER_TURN = 1.6232252401402307; +export const QUARTER_TURN = 1.6232252401402307; /** * Exact length of the ring outline, for sizing the marching-ants dash. diff --git a/lib/src/stories/HelperPlacement.stories.tsx b/lib/src/stories/HelperPlacement.stories.tsx index 1a23fe1e7..340a875c1 100644 --- a/lib/src/stories/HelperPlacement.stories.tsx +++ b/lib/src/stories/HelperPlacement.stories.tsx @@ -85,6 +85,16 @@ async function prepare(args: Props) { }; await openContext(); expectSourceUnchanged(); + await waitFor(() => expect(document.querySelector('[data-ring="outline"]')).toHaveAttribute('data-context-union', 'true')); + const ring = document.querySelector('[data-ring="outline"]')!.closest('svg')!.parentElement!; + expectContained(context(), ring); + expectContained(sourcePane(), ring); + const actions = context().querySelector('[data-context-header-actions]')!; + const close = within(actions as HTMLElement).getByRole('button', { name: 'Close terminal context' }).getBoundingClientRect(); + for (const button of actions.querySelectorAll('button')) { + expect(button.getBoundingClientRect().top).toBe(close.top); + expectContained(button, context()); + } expectContained(context(), document.querySelector('.lath-host')!); expect(context().dataset.contextSide).toBe(expectedSide(args)); if (args.layout === 'grid' && !args.zoomed) { diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 56cdef3ea..4822ab298 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -10,7 +10,7 @@ "docs/specs/dor-tool.md": 4100, "docs/specs/glossary.md": 2950, "docs/specs/hosted.md": 1100, - "docs/specs/layout.md": 10450, + "docs/specs/layout.md": 10500, "docs/specs/mobile-terminal-ui.md": 2000, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3850, From f11b7a44237164495cb6c7dad9901f9ee3c99ee9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 12:39:31 -0700 Subject: [PATCH 12/23] Simplify the source-helper union ring Carry the union on the displayed ring frame instead of a side ref, resolve the helper once per effect, skip rewrites when the union is unchanged, and compute the bounds with a plain min/max. The contour returns absolute points, the union corner radius is clamped like ringPoints, and the context overlay drops its dead painted-rect state. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../wall/TerminalContextOverlay.tsx | 31 ++++----- .../components/wall/TerminalContextView.tsx | 13 ++-- .../wall/WorkspaceSelectionOverlay.tsx | 69 +++++++++++-------- lib/src/lib/rect-union-outline.test.ts | 19 ++--- lib/src/lib/rect-union-outline.ts | 19 ++--- 5 files changed, 82 insertions(+), 69 deletions(-) diff --git a/lib/src/components/wall/TerminalContextOverlay.tsx b/lib/src/components/wall/TerminalContextOverlay.tsx index 23b09e6ad..b77fa60dc 100644 --- a/lib/src/components/wall/TerminalContextOverlay.tsx +++ b/lib/src/components/wall/TerminalContextOverlay.tsx @@ -28,30 +28,25 @@ export function TerminalContextOverlay({ context, title, tool, wall, source, mul const [manual, setManual] = useState(() => preferences.get(context.id)); const lastSide = useRef(manual); const host = useRef(null); - const measure = () => { - const painted = lath.animator.framesAt(nowMs()).get(context.id)?.rect ?? source; - return { painted, placement: placeTerminalContext(wall, painted, multiPane, manual ?? lastSide.current, cursorSide) }; - }; - const [shown, setShown] = useState<{ painted: Rect; placement: ContextPlacement }>(measure); + const measure = () => placeTerminalContext(wall, lath.animator.framesAt(nowMs()).get(context.id)?.rect ?? source, + multiPane, manual ?? lastSide.current, cursorSide); + const [shown, setShown] = useState(measure); useLayoutEffect(() => { const update = () => { const next = measure(); - lastSide.current = next.placement.side; - writeBox(host.current, next.placement.rect); - setShown(previous => previous.placement.side === next.placement.side - && previous.placement.available.join() === next.placement.available.join() ? previous : next); + lastSide.current = next.side; + writeBox(host.current, next.rect); + setShown(previous => previous.side === next.side && previous.available.join() === next.available.join() ? previous : next); }; update(); return lath.subscribeFrames(update); // eslint-disable-next-line react-hooks/exhaustive-deps -- `measure` reads exactly these inputs }, [lath, context.id, manual, multiPane, cursorSide, wall.x, wall.y, wall.width, wall.height, source.x, source.y, source.width, source.height]); - return <> -
- { - if (side) preferences.set(context.id, side); else preferences.delete(context.id); - lastSide.current = side; - setManual(side); - } }} /> -
- ; + return
+ { + if (side) preferences.set(context.id, side); else preferences.delete(context.id); + lastSide.current = side; + setManual(side); + } }} /> +
; } diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index 5a0edeb0e..b39d54867 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -193,12 +193,12 @@ export function TerminalContextView(p: TerminalContextViewProps) {
{p.title}{expanded && setDetail('title')}>Explain}
attempt(p.onCopyRef)}>{p.surfaceRef}{p.compact && setExpanded(value => !value)}>Details}
{p.placement &&
- {p.placement.available.map(side => p.placement!.onChange(side)}> - - - )} - p.placement!.onChange()} disabled={!p.placement.manual} keepFocus>Auto -
}
+ {p.placement.available.map(side => p.placement!.onChange(side)}> + + + )} + p.placement!.onChange()} disabled={!p.placement.manual} keepFocus>Auto +
}
Dir
{p.cwd}{expanded && <> attempt(p.onExplore)}>{p.explorerLabel} attempt(p.onCopyPath)}>Copy path}
@@ -218,7 +218,6 @@ export function TerminalContextView(p: TerminalContextViewProps) {
{expanded && p.notification &&
{p.notification.title}
{p.notification.body}
}
-
{isTool ? 'Tool terminal' : 'Helper terminal'} diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx index 8f71a98f5..752ba2a00 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx @@ -39,7 +39,7 @@ import { type RingEdge, } from '../../lib/ring-geometry'; import { SelectionRing } from './SelectionRing'; -import { rectUnionOutline, roundedUnionOutline } from '../../lib/rect-union-outline'; +import { rectUnionOutline, roundedUnionOutline, unionBounds } from '../../lib/rect-union-outline'; /** The subset of the Lath store the overlay needs — a revision that bumps on every * commit, so the ring re-measures as leaves move / resize / restore. Kept @@ -94,10 +94,15 @@ function ringIdentity(type: WallSelectionKind, id: string): string { return `${type}:${id}`; } +const rectsEqual = (a: RingRect, b: RingRect) => + a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height; + +const insetRect = (r: RingRect, d: number): RingRect => + ({ left: r.left + d, top: r.top + d, width: r.width - 2 * d, height: r.height - 2 * d }); + function framesEqual(a: RingFrame, b: RingFrame): boolean { return ( - a.rect.top === b.rect.top && a.rect.left === b.rect.left - && a.rect.width === b.rect.width && a.rect.height === b.rect.height + rectsEqual(a.rect, b.rect) && a.shape.tl === b.shape.tl && a.shape.tr === b.shape.tr && a.shape.br === b.shape.br && a.shape.bl === b.shape.bl && a.shape.inset === b.shape.inset @@ -106,11 +111,14 @@ function framesEqual(a: RingFrame, b: RingFrame): boolean { /** The frame the ring currently shows: geometry plus the per-edge motion-smear * `speeds`, populated only while a tween runs; a settled ring carries null speeds, - * so its render is clean. Held in a ref and written to the DOM imperatively. */ + * so its render is clean. `union` holds the source and helper rects while a terminal + * context is open; `rect` is then their bounds, and the ring never tweens. + * Held in a ref and written to the DOM imperatively. */ interface DisplayedRing { rect: RingRect; shape: RingShape; speeds: RingEdgeSpeeds | null; + union?: readonly [RingRect, RingRect]; } /** @@ -233,7 +241,6 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele // variant without a stale capture. const frameRef = useRef(null); const opacityRef = useRef(''); - const unionRects = useRef<[RingRect, RingRect] | null>(null); const modeRef = useRef(mode); modeRef.current = mode; @@ -256,7 +263,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele const container = containerRef.current; const path = pathRef.current; if (!frame || !container || !path) return; - const { rect, shape, speeds } = frame; + const { rect, shape, speeds, union } = frame; container.style.top = `${rect.top}px`; container.style.left = `${rect.left}px`; @@ -271,18 +278,18 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele // Resolved once here so every path builder below sees a single inset. const effShape = isAnts ? shape : { ...shape, inset: strokeWidth / 2 }; - const union = unionRects.current?.map(r => ({ left: r.left + effShape.inset, top: r.top + effShape.inset, width: r.width - 2 * effShape.inset, height: r.height - 2 * effShape.inset })); - const contour = union ? rectUnionOutline(union[0], union[1]) : null; - const outline = contour ? roundedUnionOutline(contour.points.map(p => ({ x: p.x + contour.rect.left - rect.left, y: p.y + contour.rect.top - rect.top })), effShape.tl - effShape.inset) : null; - path.setAttribute('d', outline?.path ?? roundedRectPath(rect, effShape)); - path.dataset.contextUnion = contour ? 'true' : 'false'; + const outline = union && roundedUnionOutline( + rectUnionOutline(insetRect(union[0], effShape.inset), insetRect(union[1], effShape.inset)).map(p => ({ x: p.x - rect.left, y: p.y - rect.top })), + Math.max(0, effShape.tl - effShape.inset)); + path.setAttribute('d', outline ? outline.path : roundedRectPath(rect, effShape)); + path.dataset.contextUnion = outline ? 'true' : 'false'; if (isAnts) { // Dash sized to the perimeter so the segments stay even as the ring resizes. // Computed in closed form rather than via `path.getTotalLength()`, which // forces a synchronous style+layout flush on every frame of a travel at a // cost that scales with the whole document, not this one path. - const len = outline?.perimeter ?? ringPerimeter(rect, effShape); + const len = outline ? outline.perimeter : ringPerimeter(rect, effShape); const count = Math.max(1, Math.round(len / cfg.marchingAnts.segLen)); const adjusted = len / count; const dash = adjusted * cfg.marchingAnts.dashFraction; @@ -298,7 +305,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele } const smear = smearRef.current; - if (smear) writeSmear(smear, rect, effShape, strokeWidth, contour ? null : speeds); + if (smear) writeSmear(smear, rect, effShape, strokeWidth, speeds); }, []); // Re-run the measuring effect after each Lath commit. Runs post-render, so @@ -319,8 +326,8 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele setVisible(true); } }; - const showSettled = (frame: RingFrame) => - show({ rect: frame.rect, shape: frame.shape, speeds: null }); + const showSettled = (frame: RingFrame, union?: DisplayedRing['union']) => + show({ rect: frame.rect, shape: frame.shape, speeds: null, union }); // Per-frame imperative loop: sample the tween's position and velocity, write // the DOM, and self-schedule — no React state, so a travelling ring never @@ -360,11 +367,11 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele rafRef.current = null; } }; - const snapTo = (frame: RingFrame, identity: string) => { + const snapTo = (frame: RingFrame, identity: string, union?: DisplayedRing['union']) => { tweenRef.current = null; cancelTick(); displayedIdentityRef.current = identity; - showSettled(frame); + showSettled(frame, union); }; if (!active) { @@ -395,8 +402,8 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele if (isWorkspaceSelection(selectedType)) return workspaceTabElement(workspaceIdOfSelection(selectedType, selectedId)); return selectedType === 'door' ? doorElements.get(selectedId) : resolvePaneElement(paneElements.get(selectedId)); }; - const helper = () => contextSourceId === selectedId && selectedType === 'pane' - ? target()?.closest('.lath-host')?.querySelector('[data-context-for]') ?? null : null; + // Wall selects the context source while a context is open, and renders one context per Wall. + const helperEl = contextSourceId ? target()?.closest('.lath-host')?.querySelector('[data-context-for]') : null; const update = () => { if (!activeRef.current) return; const targetEl = target(); @@ -404,11 +411,10 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele const next = measureFrame(targetEl, selectedType); if (!next) return; - const helperEl = helper(); const helperFrame = helperEl ? measureFrame(helperEl, 'pane') : null; - const hadUnion = unionRects.current !== null; - unionRects.current = helperFrame ? [next.rect, helperFrame.rect] : null; - if (helperFrame) next.rect = rectUnionOutline(next.rect, helperFrame.rect).rect; + const previous = frameRef.current; + const union = helperFrame ? [next.rect, helperFrame.rect] as const : undefined; + if (union) next.rect = unionBounds(...union); const wall = targetEl.closest('[data-workspace-wall]'); opacityRef.current = wall?.style.opacity ?? ''; @@ -424,9 +430,17 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele wasActiveRef.current = true; } + // A union outline cannot tween, so it snaps in, tracks, and snaps out; an + // unchanged union skips the rewrite. + if (union || previous?.union) { + if (union && previous?.union && identity === displayedIdentityRef.current + && framesEqual(previous, next) && union.every((r, i) => rectsEqual(r, previous.union![i]))) return; + snapTo(next, identity, union); + return; + } // Snap gate: the same instant-motion predicate the Lath animator's // duration uses (motionIsInstant), so the ring and the leaves agree. - if (instant || helperFrame || hadUnion) { + if (instant) { snapTo(next, identity); return; } @@ -463,10 +477,11 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele const ro = new ResizeObserver(update); const targetEl = target(); if (targetEl) ro.observe(targetEl); - const helperEl = helper(); - const mo = new MutationObserver(update); + // The helper's per-frame placement is an inline style write, which a ResizeObserver misses. + let mo: MutationObserver | undefined; if (helperEl) { ro.observe(helperEl); + mo = new MutationObserver(update); mo.observe(helperEl, { attributes: true, attributeFilter: ['style'] }); } window.addEventListener('resize', update); @@ -479,7 +494,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele return () => { ro.disconnect(); - mo.disconnect(); + mo?.disconnect(); unsubFrames?.(); unsubWorkspaceFrames(); window.removeEventListener('resize', update); diff --git a/lib/src/lib/rect-union-outline.test.ts b/lib/src/lib/rect-union-outline.test.ts index acf8acf79..388731ca8 100644 --- a/lib/src/lib/rect-union-outline.test.ts +++ b/lib/src/lib/rect-union-outline.test.ts @@ -1,28 +1,29 @@ import { expect, it } from 'vitest'; import { ringPerimeter } from './ring-geometry'; -import { rectUnionOutline, roundedUnionOutline } from './rect-union-outline'; +import { rectUnionOutline, roundedUnionOutline, unionBounds } from './rect-union-outline'; it('removes the shared seam while retaining a smaller helper’s step', () => { - const union = rectUnionOutline({ left: 10, top: 20, width: 100, height: 100 }, { left: 90, top: 20, width: 80, height: 50 }); - expect(union.rect).toEqual({ left: 10, top: 20, width: 160, height: 100 }); - expect(union.points).toEqual([{ x: 0, y: 0 }, { x: 160, y: 0 }, { x: 160, y: 50 }, { x: 100, y: 50 }, { x: 100, y: 100 }, { x: 0, y: 100 }]); - expect(roundedUnionOutline(union.points, 8).path).not.toMatch(/NaN|Infinity/); - expect(roundedUnionOutline(union.points, 8).path).toContain('Q100,50'); + const a = { left: 10, top: 20, width: 100, height: 100 }, b = { left: 90, top: 20, width: 80, height: 50 }; + expect(unionBounds(a, b)).toEqual({ left: 10, top: 20, width: 160, height: 100 }); + const points = rectUnionOutline(a, b); + expect(points).toEqual([{ x: 10, y: 20 }, { x: 170, y: 20 }, { x: 170, y: 70 }, { x: 110, y: 70 }, { x: 110, y: 120 }, { x: 10, y: 120 }]); + expect(roundedUnionOutline(points, 8).path).not.toMatch(/NaN|Infinity/); + expect(roundedUnionOutline(points, 8).path).toContain('Q110,70'); }); it('keeps an inset helper inside the original outline', () => { const source = { left: 0, top: 0, width: 100, height: 100 }; - expect(rectUnionOutline(source, { left: 16, top: 16, width: 68, height: 30 }).points).toEqual([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }]); + expect(rectUnionOutline(source, { left: 16, top: 16, width: 68, height: 30 })).toEqual([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }]); }); it.each(['left', 'right', 'top', 'bottom'] as const)('encloses both rectangles opening %s without extra area', side => { const source = { left: 0, top: 0, width: 100, height: 100 }; const helper = { left: side === 'left' ? -64 : side === 'right' ? 84 : 0, top: side === 'top' ? -64 : side === 'bottom' ? 84 : 0, width: 80, height: 80 }; - const { points } = rectUnionOutline(source, helper); + const points = rectUnionOutline(source, helper); const area = Math.abs(points.reduce((sum, p, i) => { const q = points[(i + 1) % points.length]; return sum + p.x * q.y - q.x * p.y; }, 0)) / 2; expect(area).toBe(10000 + 6400 - 16 * 80); }); it('matches the regular ring perimeter for a rectangular union', () => { const rect = { left: 0, top: 0, width: 100, height: 100 }; - const outline = roundedUnionOutline(rectUnionOutline(rect, rect).points, 8); + const outline = roundedUnionOutline(rectUnionOutline(rect, rect), 8); expect(outline.perimeter).toBeCloseTo(ringPerimeter(rect, { tl: 8, tr: 8, bl: 8, br: 8, inset: 0 })); }); diff --git a/lib/src/lib/rect-union-outline.ts b/lib/src/lib/rect-union-outline.ts index 520b8bba2..b35ddd5bc 100644 --- a/lib/src/lib/rect-union-outline.ts +++ b/lib/src/lib/rect-union-outline.ts @@ -2,11 +2,16 @@ import type { RingRect } from './rect-tween'; import { QUARTER_TURN } from './ring-geometry'; type Point = { x: number; y: number }; -const same = (a: Point, b: Point) => a.x === b.x && a.y === b.y; -/** Outer contour of two overlapping rectangles. Grid cells remove internal seams - * before rounding, so a smaller helper leaves a step rather than framing peers. */ -export function rectUnionOutline(a: RingRect, b: RingRect) { +/** Bounding box of two rectangles. */ +export function unionBounds(a: RingRect, b: RingRect): RingRect { + const left = Math.min(a.left, b.left), top = Math.min(a.top, b.top); + return { left, top, width: Math.max(a.left + a.width, b.left + b.width) - left, height: Math.max(a.top + a.height, b.top + b.height) - top }; +} + +/** Corner points of the outer contour of two overlapping rectangles. Grid cells remove + * internal seams before rounding, so a smaller helper leaves a step rather than framing peers. */ +export function rectUnionOutline(a: RingRect, b: RingRect): Point[] { const xs = [...new Set([a.left, a.left + a.width, b.left, b.left + b.width])].sort((x, y) => x - y); const ys = [...new Set([a.top, a.top + a.height, b.top, b.top + b.height])].sort((x, y) => x - y); const inside = (x: number, y: number) => [a, b].some(r => x > r.left && x < r.left + r.width && y > r.top && y < r.top + r.height); @@ -25,15 +30,13 @@ export function rectUnionOutline(a: RingRect, b: RingRect) { let edge = edges.shift(); while (edge) { points.push(edge[0]); - const next = edges.findIndex(candidate => same(candidate[0], edge![1])); + const next = edges.findIndex(candidate => candidate[0].x === edge![1].x && candidate[0].y === edge![1].y); edge = next < 0 ? undefined : edges.splice(next, 1)[0]; } - const corners = points.filter((p, i) => { + return points.filter((p, i) => { const before = points[(i + points.length - 1) % points.length], after = points[(i + 1) % points.length]; return !((before.x === p.x && p.x === after.x) || (before.y === p.y && p.y === after.y)); }); - const rect = { left: xs[0], top: ys[0], width: xs[xs.length - 1] - xs[0], height: ys[ys.length - 1] - ys[0] }; - return { rect, points: corners.map(p => ({ x: p.x - rect.left, y: p.y - rect.top })) }; } export function roundedUnionOutline(points: Point[], radius: number) { From a26f6f4182ed6dd968815e24252d4094308333cc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 13:57:19 -0700 Subject: [PATCH 13/23] Animate helper focus-ring changes and remove native ghost outline --- docs/specs/layout.md | 3 +- .../components/wall/TerminalContextView.tsx | 2 +- .../wall/WorkspaceSelectionOverlay.test.tsx | 20 +++++++++-- .../wall/WorkspaceSelectionOverlay.tsx | 34 +++++++++++-------- lib/src/lib/rect-tween.test.ts | 16 +++++++++ lib/src/lib/rect-tween.ts | 12 +++++-- lib/src/stories/HelperPlacement.stories.tsx | 4 +++ scripts/spec-word-budgets.json | 2 +- 8 files changed, 71 insertions(+), 22 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 33de9c8bc..ae6926661 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -338,7 +338,7 @@ Source of truth: `requestKill` (every kill gesture: Door reattach, untouched fas ## Selection overlay -**Must outline the union of the invoking source Pane and its open helper**, following their outer contour without an internal seam or enclosing unused neighboring space. Track helper repositioning and resize without replacing its terminal; restore the source-only ring on close. +**Must outline the union of the invoking source Pane and its open helper**, following their outer contour without an internal seam or enclosing unused neighboring space. Track helper repositioning and resize without replacing its terminal; restore the source-only ring on close. The context container has no native focus outline; its controls retain their keyboard focus indicators. A fixed-positioned element on top of the Lath host, covering the active element's area inflated by `SELECTION_RING_INFLATE_PX` (4px) for panes; doors are not inflated. **The inflate is derived in `lib/src/components/design.tsx` so both ring strokes center on the gutter's midline** (rationale). @@ -360,6 +360,7 @@ The ring's rect (and its `{tl,tr,br,bl,inset}` shape) is driven **per-frame by a Per-frame writes are **imperative**: `SelectionRing` gives the overlay refs to its stable shell; the rAF loop writes rect, path `d`, marching dash, and smear geometry, then **re-applies after structural renders, pre-paint**, so fresh nodes do not flash. **Never reintroduce per-frame React state** — reconciling this subtree competes with travel for the frame budget (rationale). - **Identity change → tween.** A measurement whose identity (`${selectedType}:${selectedId}`) differs from the one on screen glides from the current interpolated position to the new target, **clock restarted**, so arrow-key spam stays responsive. +- **Helper changes → tween.** Opening, closing and repositioning interpolate the union’s two rectangles from the painted frame, including interrupted motion; retain the ordinary snap gate for reduced motion and disabled animation. - **Same identity → snap 1:1.** A same-identity re-measure with no tween in flight (sash drag, window resize, a settled leaf's store commit) writes the new rect directly, tracking the geometry exactly instead of easing behind it. - **In-flight retarget.** A same-identity re-measure *during* a tween retargets the destination **without resetting the clock**, so the ring converges on a moving target (select-a-neighbor-during-kill) and still lands on the original completion instant. - **Snap gate.** `motionIsInstant()` — `!cfg.layout.animate` (visual snapshots) or `prefersReducedMotion()` — settles the ring instantly; it is the same predicate the Lath animator's duration uses, so ring and leaves agree. **A ring appearing with nothing on screen also snaps**: there is no `from` to glide from. diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index b39d54867..e3aa123e9 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -176,7 +176,7 @@ export function TerminalContextView(p: TerminalContextViewProps) { const isTool = p.terminalRole === 'tool'; const statusLabel = isTool ? (p.status === 'running' ? `Running ${p.command}…` : 'At prompt') : status.label(p.command); return
event.preventDefault()} onKeyDown={event => { if ((event.target as HTMLElement).closest('[data-helper-terminal], [data-context-terminal]') && !detail) return; diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx index b18a685af..0baeba3eb 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx @@ -557,7 +557,7 @@ describe('SelectionRing motion smear', () => { }); }); -it('tracks the source/helper union through repositioning and restores the source ring on close', async () => { +it('animates the source/helper union on opening, repositioning and interrupted close', async () => { const store = makeStore(); const wall = document.createElement('div'); wall.className = 'lath-host'; @@ -570,15 +570,31 @@ it('tracks the source/helper union through repositioning and restores the source stubRect(helper, { left: 484, top: 0, width: 400, height: 300 }); const panes = new Map([['a', source]]); try { + await act(async () => root.render()); + const sourceBounds = ringRect(); await act(async () => root.render()); + expect(ringRect()).toEqual(sourceBounds); + await frame(30); + expect(ringRect()!.width).toBeGreaterThan(508); + expect(ringRect()!.width).toBeLessThan(892); + await frame(220); const path = container.querySelector('[data-ring="outline"]')!; expect(path.dataset.contextUnion).toBe('true'); expect(ringRect()?.width).toBe(892); const original = path.getAttribute('d'); await act(async () => { stubRect(helper, { left: 0, top: 584, width: 400, height: 300 }); helper.style.top = '584px'; }); - expect(ringRect()?.height).toBe(892); + expect(path.getAttribute('d')).toBe(original); + await frame(30); + expect(ringRect()!.height).toBeGreaterThan(608); + expect(ringRect()!.height).toBeLessThan(892); expect(path.getAttribute('d')).not.toBe(original); + const midMove = ringRect(); await act(async () => root.render()); + expect(ringRect()).toEqual(midMove); + await frame(30); + expect(ringRect()!.height).toBeGreaterThan(608); + expect(ringRect()!.height).toBeLessThan(midMove!.height); + await frame(220); expect(path.dataset.contextUnion).toBe('false'); expect(ringRect()?.width).toBe(508); expect(path.getAttribute('stroke-dasharray')).toBeTruthy(); diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx index 752ba2a00..8afec3938 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx @@ -103,6 +103,7 @@ const insetRect = (r: RingRect, d: number): RingRect => function framesEqual(a: RingFrame, b: RingFrame): boolean { return ( rectsEqual(a.rect, b.rect) + && (a.union === b.union || !!a.union && !!b.union && a.union.every((r, i) => rectsEqual(r, b.union![i]))) && a.shape.tl === b.shape.tl && a.shape.tr === b.shape.tr && a.shape.br === b.shape.br && a.shape.bl === b.shape.bl && a.shape.inset === b.shape.inset @@ -112,7 +113,7 @@ function framesEqual(a: RingFrame, b: RingFrame): boolean { /** The frame the ring currently shows: geometry plus the per-edge motion-smear * `speeds`, populated only while a tween runs; a settled ring carries null speeds, * so its render is clean. `union` holds the source and helper rects while a terminal - * context is open; `rect` is then their bounds, and the ring never tweens. + * context is open; `rect` is then their bounds, and both rectangles tween together. * Held in a ref and written to the DOM imperatively. */ interface DisplayedRing { rect: RingRect; @@ -317,7 +318,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele // the shell (the post-render layout effect applies it before paint). const show = (frame: DisplayedRing) => { frameRef.current = frame; - displayedFrameRef.current = { rect: frame.rect, shape: frame.shape }; + displayedFrameRef.current = { rect: frame.rect, shape: frame.shape, union: frame.union }; if (handoff) handoff.current = displayedFrameRef.current; if (visibleRef.current) { applyRing(); @@ -326,7 +327,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele setVisible(true); } }; - const showSettled = (frame: RingFrame, union?: DisplayedRing['union']) => + const showSettled = (frame: RingFrame, union: DisplayedRing['union'] = frame.union) => show({ rect: frame.rect, shape: frame.shape, speeds: null, union }); // Per-frame imperative loop: sample the tween's position and velocity, write @@ -338,11 +339,11 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele const tween = tweenRef.current; if (!tween) return; const now = performance.now(); - const { rect, shape, done } = sampleRingTween(tween, now); + const { rect, shape, union, done } = sampleRingTween(tween, now); if (done) { // Settled: drop the tween so the final render is clean. tweenRef.current = null; - showSettled({ rect, shape }); + showSettled({ rect, shape, union }); return; } // Velocity comes from the tween's analytic derivative, so it is exact on the @@ -350,10 +351,10 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele // smear should be strongest. Finite-differencing rendered positions cannot // do that: it has no previous sample to difference on frame one, and that // frame alone covers ~31% of a 220ms travel. - const speeds = sampleRingVelocity(tween, now); + const speeds = union ? null : sampleRingVelocity(tween, now); - frameRef.current = { rect, shape, speeds }; - displayedFrameRef.current = { rect, shape }; + frameRef.current = { rect, shape, speeds, union }; + displayedFrameRef.current = { rect, shape, union }; if (handoff) handoff.current = displayedFrameRef.current; applyRing(); rafRef.current = requestAnimationFrame(tick); @@ -414,7 +415,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele const helperFrame = helperEl ? measureFrame(helperEl, 'pane') : null; const previous = frameRef.current; const union = helperFrame ? [next.rect, helperFrame.rect] as const : undefined; - if (union) next.rect = unionBounds(...union); + if (union) { next.rect = unionBounds(...union); next.union = union; } const wall = targetEl.closest('[data-workspace-wall]'); opacityRef.current = wall?.style.opacity ?? ''; @@ -430,12 +431,15 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele wasActiveRef.current = true; } - // A union outline cannot tween, so it snaps in, tracks, and snaps out; an - // unchanged union skips the rewrite. - if (union || previous?.union) { - if (union && previous?.union && identity === displayedIdentityRef.current - && framesEqual(previous, next) && union.every((r, i) => rectsEqual(r, previous.union![i]))) return; - snapTo(next, identity, union); + // Opening, repositioning and closing a helper morph both component + // rectangles from the painted frame; never replace the union with a box. + if (union || previous?.union || tweenRef.current?.from.union) { + if (instant || !displayedFrameRef.current) { snapTo(next, identity, union); return; } + const destination = tweenRef.current?.to ?? previous; + if (destination && identity === displayedIdentityRef.current && framesEqual(destination, next)) return; + tweenRef.current = startRingTween(displayedFrameRef.current, next, performance.now(), FOCUS_MOTION_MS); + displayedIdentityRef.current = identity; + scheduleTick(); return; } // Snap gate: the same instant-motion predicate the Lath animator's diff --git a/lib/src/lib/rect-tween.test.ts b/lib/src/lib/rect-tween.test.ts index 590f43b71..70cde4042 100644 --- a/lib/src/lib/rect-tween.test.ts +++ b/lib/src/lib/rect-tween.test.ts @@ -155,3 +155,19 @@ describe('sampleRingVelocity', () => { expect(sampleRingVelocity(snap, 0)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); }); }); + +it('morphs union components continuously and returns to a plain ring at close', () => { + const helper = { ...A.rect, left: 84 }; + const joined: RingFrame = { rect: { ...A.rect, width: 184 }, shape: A.shape, union: [A.rect, helper] }; + const open = startRingTween(A, joined, 0, DUR); + expect(sampleRingTween(open, 0).rect).toEqual(A.rect); + const mid = sampleRingTween(open, 30); + expect(mid.rect.width).toBeGreaterThan(A.rect.width); + expect(mid.rect.width).toBeLessThan(joined.rect.width); + expect(sampleRingTween(open, DUR).union).toEqual(joined.union); + const close = startRingTween(mid, A, 30, DUR); + expect(sampleRingTween(close, 30).union).toEqual(mid.union); + expect(sampleRingTween(close, 60).rect.width).toBeLessThan(mid.rect.width); + expect(sampleRingTween(close, 30 + DUR).union).toBeUndefined(); + expect(sampleRingTween(close, 30 + DUR).rect).toEqual(A.rect); +}); diff --git a/lib/src/lib/rect-tween.ts b/lib/src/lib/rect-tween.ts index 35c61c20c..f4808ff67 100644 --- a/lib/src/lib/rect-tween.ts +++ b/lib/src/lib/rect-tween.ts @@ -6,6 +6,7 @@ // easing rather than re-deriving the curve. import { LATH_EASING } from './lath/animator'; +import { unionBounds } from './rect-union-outline'; /** The ring's measured box in viewport (fixed-position) coordinates. */ export interface RingRect { @@ -29,6 +30,8 @@ export interface RingShape { export interface RingFrame { rect: RingRect; shape: RingShape; + /** Two overlapping rectangles whose outer contour is the focus ring. */ + union?: readonly [RingRect, RingRect]; } /** Perpendicular speed of each ring edge while it travels (px/ms). See @@ -103,11 +106,16 @@ function progressAt(tween: RingTween, now: number): { raw: number; clamped: numb * (`LATH_EASING` returns 0/1 at the bounds, so the lerp resolves to `from`/`to` * identically); a zero-duration tween reads as done at `to`. `done` flips true * once the clock reaches the completion instant. */ -export function sampleRingTween(tween: RingTween, now: number): { rect: RingRect; shape: RingShape; done: boolean } { +export function sampleRingTween(tween: RingTween, now: number): RingFrame & { done: boolean } { const { clamped } = progressAt(tween, now); const eased = LATH_EASING(clamped); + const fromUnion = tween.from.union ?? [tween.from.rect, tween.from.rect]; + const toUnion = tween.to.union ?? [tween.to.rect, tween.to.rect]; + const union = clamped >= 1 ? tween.to.union : tween.from.union || tween.to.union + ? [lerpRect(fromUnion[0], toUnion[0], eased), lerpRect(fromUnion[1], toUnion[1], eased)] as const : undefined; return { - rect: lerpRect(tween.from.rect, tween.to.rect, eased), + ...(union ? { union } : {}), + rect: union ? unionBounds(...union) : lerpRect(tween.from.rect, tween.to.rect, eased), shape: lerpShape(tween.from.shape, tween.to.shape, eased), done: clamped >= 1, }; diff --git a/lib/src/stories/HelperPlacement.stories.tsx b/lib/src/stories/HelperPlacement.stories.tsx index 340a875c1..49db953e7 100644 --- a/lib/src/stories/HelperPlacement.stories.tsx +++ b/lib/src/stories/HelperPlacement.stories.tsx @@ -89,6 +89,10 @@ async function prepare(args: Props) { const ring = document.querySelector('[data-ring="outline"]')!.closest('svg')!.parentElement!; expectContained(context(), ring); expectContained(sourcePane(), ring); + // Keyboard focus on the container must not add a second browser-native ring. + await userEvent.tab(); + context().focus(); + expect(getComputedStyle(context()).outlineStyle).toBe('none'); const actions = context().querySelector('[data-context-header-actions]')!; const close = within(actions as HTMLElement).getByRole('button', { name: 'Close terminal context' }).getBoundingClientRect(); for (const button of actions.querySelectorAll('button')) { diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index babe012f4..e26fcc26e 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -10,7 +10,7 @@ "docs/specs/dor-tool.md": 4100, "docs/specs/glossary.md": 2950, "docs/specs/hosted.md": 1100, - "docs/specs/layout.md": 10500, + "docs/specs/layout.md": 10550, "docs/specs/mobile-terminal-ui.md": 2000, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3850, From d5ca083c65437867df551f2eeaf7c66261a2fdba Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 14:24:40 -0700 Subject: [PATCH 14/23] Use supplied panel icon for helper placement --- lib/src/components/wall/TerminalContextView.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index e3aa123e9..a05222532 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -194,8 +194,12 @@ export function TerminalContextView(p: TerminalContextViewProps) { {p.title}{expanded && setDetail('title')}>Explain}
attempt(p.onCopyRef)}>{p.surfaceRef}{p.compact && setExpanded(value => !value)}>Details}
{p.placement &&
{p.placement.available.map(side => p.placement!.onChange(side)}> - - + + + + + + )} p.placement!.onChange()} disabled={!p.placement.manual} keepFocus>Auto
}
From bac2690d91e31e783cb2e1bfeb018c4396db265c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 15:15:47 -0700 Subject: [PATCH 15/23] Use supplied side-panel icon for left and right placement --- lib/src/components/wall/TerminalContextView.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index a05222532..e90d65053 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -195,9 +195,11 @@ export function TerminalContextView(p: TerminalContextViewProps) {
attempt(p.onCopyRef)}>{p.surfaceRef}{p.compact && setExpanded(value => !value)}>Details}
{p.placement &&
{p.placement.available.map(side => p.placement!.onChange(side)}> - + - + {side === 'left' || side === 'right' + ? + : } )} From 4e59c93278c86e9f1fad5a85ba228cca2468405b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 15:23:57 -0700 Subject: [PATCH 16/23] Remove Auto button from helper placement controls --- docs/specs/layout.md | 2 +- lib/src/components/Wall.test.tsx | 2 +- lib/src/components/wall/TerminalContext.test.tsx | 4 +++- lib/src/components/wall/TerminalContextOverlay.tsx | 4 ++-- lib/src/components/wall/TerminalContextView.tsx | 3 +-- lib/src/stories/HelperPlacement.stories.tsx | 6 +++--- lib/src/stories/TerminalContext.stories.tsx | 2 +- 7 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index ae6926661..17c8f6521 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -70,7 +70,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- | No usable adjacent candidate; single or zoomed pane | Source's top or bottom half, inset 16px on every side, opposite its visible terminal cursor sampled on opening; unknown, offscreen, or midpoint cursor defaults to top. | | Small source or Wall | Expand the half-pane fallback to the minimum usable size, clamped inside the Wall's 16px inset; shrink below the minimum when necessary to preserve the inset. | -**Must group available side buttons plus Auto beside Close at the context header’s right edge**, with destination tooltips, accessible labels, and selected state. Remember manual choices per source for the mounted Wall's lifetime; clear on source removal or Auto. Preserve terminal focus on pointer repositioning. An unavailable choice falls back automatically; no preference is persisted to disk. +**Must group available side buttons beside Close at the context header’s right edge**, with destination tooltips, accessible labels, and selected state. Remember manual choices per source for the mounted Wall's lifetime; clear on source removal. Preserve terminal focus on pointer repositioning. An unavailable choice falls back automatically; no preference is persisted to disk. **Must keep source title, directory, and helper actions visible in compact context**, disclosing title explanation, directory actions, ports, and alerts through Details. Wrap header and detail actions within the panel; scroll bounded details and warnings while reserving 64px for terminal content. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index b75446aa9..d16cd11f4 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -3832,6 +3832,6 @@ it('moves a retained helper without resizing or replacing its source, and rememb await open(); expect(container.querySelector('[data-terminal-context]')!.dataset.contextSide).toBe('bottom'); expect(helpers.getHelper('placement-source')).toBe(helper); - act(() => container.querySelector('[aria-label="Use automatic helper placement"]')!.click()); + act(() => container.querySelector('[aria-label="Place helper at top"]')!.click()); expect(container.querySelector('[data-terminal-context]')!.dataset.contextSide).toBe('top'); }); diff --git a/lib/src/components/wall/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx index 99b6575bb..b5f7ebebf 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -227,7 +227,7 @@ it('keeps the helper mounted while compact details are toggled', async () => { }); it('position buttons preserve input focus and report the destination', async () => { - props.placement = { rect: { x: 0, y: 0, width: 600, height: 400 }, side: 'top', available: ['top', 'bottom'], manual: false, onChange: vi.fn() }; + props.placement = { rect: { x: 0, y: 0, width: 600, height: 400 }, side: 'top', available: ['top', 'bottom'], onChange: vi.fn() }; render(); const input = container.querySelector('textarea')!; act(() => input.focus()); @@ -236,5 +236,7 @@ it('position buttons preserve input focus and report the destination', async () expect(down.defaultPrevented).toBe(true); await click('Place helper at bottom'); expect(props.placement.onChange).toHaveBeenCalledWith('bottom'); + expect(button('Use automatic helper placement')).toBeNull(); + expect(button('Place helper at top').getAttribute('aria-pressed')).toBe('true'); expect(document.activeElement).toBe(input); }); diff --git a/lib/src/components/wall/TerminalContextOverlay.tsx b/lib/src/components/wall/TerminalContextOverlay.tsx index b77fa60dc..22aa717b8 100644 --- a/lib/src/components/wall/TerminalContextOverlay.tsx +++ b/lib/src/components/wall/TerminalContextOverlay.tsx @@ -43,8 +43,8 @@ export function TerminalContextOverlay({ context, title, tool, wall, source, mul // eslint-disable-next-line react-hooks/exhaustive-deps -- `measure` reads exactly these inputs }, [lath, context.id, manual, multiPane, cursorSide, wall.x, wall.y, wall.width, wall.height, source.x, source.y, source.width, source.height]); return
- { - if (side) preferences.set(context.id, side); else preferences.delete(context.id); + { + preferences.set(context.id, side); lastSide.current = side; setManual(side); } }} /> diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index e90d65053..029353b61 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -47,7 +47,7 @@ export interface TerminalContextViewProps { terminalRole?: 'helper' | 'tool'; /** Fill the positioned host and fold directory actions, ports, and alerts behind Details. */ compact?: boolean; - placement?: ContextPlacement & { manual: boolean; onChange(side?: ContextSide): void }; + placement?: ContextPlacement & { onChange(side: ContextSide): void }; /** Exit in progress: the view is inert, and `onClose` is not called again. */ closing?: boolean; /** Viewport coordinates the reveal grows from; absent, the top-left corner. */ @@ -203,7 +203,6 @@ export function TerminalContextView(p: TerminalContextViewProps) { )} - p.placement!.onChange()} disabled={!p.placement.manual} keepFocus>Auto
}
Dir diff --git a/lib/src/stories/HelperPlacement.stories.tsx b/lib/src/stories/HelperPlacement.stories.tsx index 49db953e7..6c87c7026 100644 --- a/lib/src/stories/HelperPlacement.stories.tsx +++ b/lib/src/stories/HelperPlacement.stories.tsx @@ -185,7 +185,7 @@ export const PreserveInputAndFocus: Story = { expect(context().dataset.contextSide).toBe('bottom'); checkInput(); }); - await step('Close and reopen the retained helper, then return to Auto', async () => { + await step('Close and reopen the retained helper, then select the top side', async () => { await userEvent.click(within(context()).getByRole('button', { name: 'Close terminal context' })); await waitFor(() => expect(document.querySelector('[data-terminal-context]')).toBeNull()); const resizedSource = rect(sourcePane()); @@ -195,8 +195,8 @@ export const PreserveInputAndFocus: Story = { checkInput(); input.focus(); const focused = document.activeElement; - await userEvent.click(within(context()).getByRole('button', { name: 'Use automatic helper placement' })); - await waitFor(() => expect(within(context()).getByRole('button', { name: 'Use automatic helper placement' })).toBeDisabled()); + await userEvent.click(within(context()).getByRole('button', { name: 'Place helper at top' })); + await waitFor(() => expect(within(context()).getByRole('button', { name: 'Place helper at top' })).toHaveAttribute('aria-pressed', 'true')); expect(context().dataset.contextSide).toBe('top'); expect(document.activeElement).toBe(focused); checkInput(); diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index 0bee0b364..37acb123c 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -102,7 +102,7 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth, paneHeigh
pnpm dev
{'~/projects/dormouse ❯ pnpm dev\n\n  VITE ready\n  ➜  Local: http://localhost:5173/'}
- Date: Wed, 23 Sep 2026 15:29:02 -0700 Subject: [PATCH 17/23] Give helper popups the zoomed pane contrast halo --- docs/specs/layout.md | 2 ++ lib/src/components/design.tsx | 3 +++ lib/src/components/wall/LathHost.tsx | 4 ++-- lib/src/components/wall/TerminalContextView.tsx | 4 ++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 17c8f6521..49dbab5a2 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -70,6 +70,8 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- | No usable adjacent candidate; single or zoomed pane | Source's top or bottom half, inset 16px on every side, opposite its visible terminal cursor sampled on opening; unknown, offscreen, or midpoint cursor defaults to top. | | Small source or Wall | Expand the half-pane fallback to the minimum usable size, clamped inside the Wall's 16px inset; shrink below the minimum when necessary to preserve the inset. | +Popups share the zoomed pane’s app-background halo. + **Must group available side buttons beside Close at the context header’s right edge**, with destination tooltips, accessible labels, and selected state. Remember manual choices per source for the mounted Wall's lifetime; clear on source removal. Preserve terminal focus on pointer repositioning. An unavailable choice falls back automatically; no preference is persisted to disk. **Must keep source title, directory, and helper actions visible in compact context**, disclosing title explanation, directory actions, ports, and alerts through Details. Wrap header and detail actions within the panel; scroll bounded details and warnings while reserving 64px for terminal content. diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index ea5e6a67b..e2c3292fe 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -14,6 +14,9 @@ import { OVERLAY_VIEWPORT_MARGIN_PX } from '../lib/ui-geometry'; * elevated zoom inset) must use this constant so the chrome stays proportional. */ export const PANE_HEADER_HEIGHT_PX = 30; +/** Soft app-ground halo separates zoomed panes and context popups from content below. */ +export const ELEVATED_PANE_SHADOW = '0 0 5px 5px var(--color-app-bg)'; + // Pane headers/doors own the top corners; terminal bodies own the bottom. // All terminal-radius constants derive from this single source so the CSS // class, the SVG-friendly px value, and the inline-style rem string can't diff --git a/lib/src/components/wall/LathHost.tsx b/lib/src/components/wall/LathHost.tsx index 3998304f0..4e12903c5 100644 --- a/lib/src/components/wall/LathHost.tsx +++ b/lib/src/components/wall/LathHost.tsx @@ -21,7 +21,7 @@ import { layout, sashes } from '../../lib/lath/layout'; import { LATH_LAYER_DYING, LATH_LAYER_ELEVATED, LATH_LAYER_TILED } from '../../lib/lath/animator'; import { type DropTarget, resize } from '../../lib/lath/ops'; import { useFocusRingColor } from '../../lib/themes/use-focus-ring-color'; -import { PANE_HEADER_HEIGHT_PX, TERMINAL_SELECTION_BORDER_RADIUS } from '../design'; +import { ELEVATED_PANE_SHADOW, PANE_HEADER_HEIGHT_PX, TERMINAL_SELECTION_BORDER_RADIUS } from '../design'; import type { PaneProps } from './pane-props'; import { type LeafMeta, LATH_LAYOUT_OPTS } from './lath-wall-store'; import { nowMs, type LathWallEngine } from './lath-wall-engine'; @@ -52,7 +52,7 @@ const Z_PREVIEW = 45; /** Reveal half a pane header of tiled layout around an elevated zoomed pane. */ export const LATH_ZOOM_MARGIN = PANE_HEADER_HEIGHT_PX / 2; /** Soft app-chrome halo separates the elevated pane from tiled content below. */ -export const LATH_ZOOM_SHADOW = '0 0 5px 5px var(--color-app-bg)'; +export const LATH_ZOOM_SHADOW = ELEVATED_PANE_SHADOW; const PANE_HEADER_STYLE: CSSProperties = { flex: `0 0 ${PANE_HEADER_HEIGHT_PX}px`, diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index 029353b61..079994395 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -1,6 +1,6 @@ import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { ArrowCounterClockwiseIcon, ArrowLineUpIcon, ArrowSquareOutIcon, BugBeetleIcon, CheckIcon, CircleNotchIcon, CopyIcon, FrameCornersIcon, PauseIcon, SlidersHorizontalIcon, TerminalIcon, WarningIcon, XIcon } from '@phosphor-icons/react'; -import { OnOffSwitch, POPUP_SURFACE_CLASS, SUBTLE_ACTION_COLOR_CLASS, SUBTLE_ACTION_INTERACTION_CLASS, SUBTLE_ACTION_REST_COLOR_CLASS, TERMINAL_CONTEXT_SURFACE_CLASS, TERMINAL_CONTEXT_EXIT_MS, TERMINAL_SELECTION_BORDER_RADIUS } from '../design'; +import { ELEVATED_PANE_SHADOW, OnOffSwitch, POPUP_SURFACE_CLASS, SUBTLE_ACTION_COLOR_CLASS, SUBTLE_ACTION_INTERACTION_CLASS, SUBTLE_ACTION_REST_COLOR_CLASS, TERMINAL_CONTEXT_SURFACE_CLASS, TERMINAL_CONTEXT_EXIT_MS, TERMINAL_SELECTION_BORDER_RADIUS } from '../design'; import { stepFocus } from '../focus-step'; import { AgentRobotIcon } from './BrowserDisplayIcon'; import type { PortUrlEntry } from './port-url'; @@ -121,7 +121,7 @@ function ContextOpenAction({ children, label, disabled, onOpen }: { children: Re /** The custom properties `.terminal-context-enter` / `-exit` read (`lib/src/theme.css`) * that JS owns: the exit length the removal timer must match, and the corner radius. */ -const SURFACE_STYLE = { '--context-exit-duration': `${TERMINAL_CONTEXT_EXIT_MS}ms`, '--context-radius': TERMINAL_SELECTION_BORDER_RADIUS } as CSSProperties; +const SURFACE_STYLE = { boxShadow: ELEVATED_PANE_SHADOW, '--context-exit-duration': `${TERMINAL_CONTEXT_EXIT_MS}ms`, '--context-radius': TERMINAL_SELECTION_BORDER_RADIUS } as CSSProperties; /** Freeze the reveal as it stands so an interrupted entrance contracts from what * is visible instead of flashing to full size; CSS clamps the origin, so it is From 3ddd0f56fadd4d291adec620a2e57bb05eac78f0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 15:37:48 -0700 Subject: [PATCH 18/23] Lift above helpers clear of their source title --- docs/specs/layout.md | 4 ++-- .../wall/terminal-context-placement.test.ts | 7 ++++--- .../components/wall/terminal-context-placement.ts | 14 ++++++++------ lib/src/lib/rect-union-outline.test.ts | 8 ++++++++ lib/src/lib/rect-union-outline.ts | 6 +++++- lib/src/stories/HelperPlacement.stories.tsx | 2 +- 6 files changed, 28 insertions(+), 13 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 49dbab5a2..b618b5cfe 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -66,7 +66,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- | Layout | Placement | |---|---| -| Multiple visible panes | Beside the source, overlapping it by 16px so the near edge aligns with the inset helper's edge; match its outer size where possible. Choose the largest usable candidate, ties right / left / bottom / top. Align the other axis with the source, shifting only to stay inside the Wall. | +| Multiple visible panes | Beside the source with 16px overlap, except above: leave a 16px gap to expose the source title. Match its size where possible. Choose the largest usable candidate, ties right / left / bottom / top. Align the other axis with the source, shifting only to stay inside the Wall. | | No usable adjacent candidate; single or zoomed pane | Source's top or bottom half, inset 16px on every side, opposite its visible terminal cursor sampled on opening; unknown, offscreen, or midpoint cursor defaults to top. | | Small source or Wall | Expand the half-pane fallback to the minimum usable size, clamped inside the Wall's 16px inset; shrink below the minimum when necessary to preserve the inset. | @@ -340,7 +340,7 @@ Source of truth: `requestKill` (every kill gesture: Door reattach, untouched fas ## Selection overlay -**Must outline the union of the invoking source Pane and its open helper**, following their outer contour without an internal seam or enclosing unused neighboring space. Track helper repositioning and resize without replacing its terminal; restore the source-only ring on close. The context container has no native focus outline; its controls retain their keyboard focus indicators. +**Must outline the union of the invoking source Pane and its open helper**, following their contour and bridging the above-helper gap without internal seams or framing neighbors. Track helper repositioning and resize without replacing its terminal; restore the source-only ring on close. The context container has no native focus outline; its controls retain their keyboard focus indicators. A fixed-positioned element on top of the Lath host, covering the active element's area inflated by `SELECTION_RING_INFLATE_PX` (4px) for panes; doors are not inflated. **The inflate is derived in `lib/src/components/design.tsx` so both ring strokes center on the gutter's midline** (rationale). diff --git a/lib/src/components/wall/terminal-context-placement.test.ts b/lib/src/components/wall/terminal-context-placement.test.ts index 334f258eb..3c86cc364 100644 --- a/lib/src/components/wall/terminal-context-placement.test.ts +++ b/lib/src/components/wall/terminal-context-placement.test.ts @@ -8,9 +8,9 @@ describe('terminal context placement', () => { }); it('uses below/above in stacked layouts', () => { expect(placeTerminalContext(wall, { ...wall, height: 396 }, true)).toMatchObject({ side: 'bottom', rect: { x: 0, y: 380, width: 1200, height: 396 } }); - expect(placeTerminalContext(wall, { ...wall, y: 404, height: 396 }, true)).toMatchObject({ side: 'top', rect: { x: 0, y: 24, width: 1200, height: 396 } }); + expect(placeTerminalContext(wall, { ...wall, y: 404, height: 396 }, true)).toMatchObject({ side: 'top', rect: { x: 0, y: 0, width: 1200, height: 388 } }); }); - it('aligns all four adjacent edges with the inset helper bounds', () => { + it('aligns adjacent edges with inset helpers, but clears the source when above', () => { const source = { x: 400, y: 260, width: 400, height: 280 }; const insetTop = placeTerminalContext(wall, source, false, 'top').rect; const insetBottom = placeTerminalContext(wall, source, false, 'bottom').rect; @@ -21,7 +21,8 @@ describe('terminal context placement', () => { expect(right.x).toBe(insetTop.x + insetTop.width); expect(left.x + left.width).toBe(insetTop.x); expect(bottom.y).toBe(insetBottom.y + insetBottom.height); - expect(top.y + top.height).toBe(insetTop.y); + expect(top.y + top.height).toBe(source.y - 16); + expect(top.y).toBe(wall.y); }); it('breaks equal grid fits right-first and honors manual sides', () => { const source = { x: 0, y: 0, width: 596, height: 396 }; diff --git a/lib/src/components/wall/terminal-context-placement.ts b/lib/src/components/wall/terminal-context-placement.ts index 20872d859..a6e28fc08 100644 --- a/lib/src/components/wall/terminal-context-placement.ts +++ b/lib/src/components/wall/terminal-context-placement.ts @@ -3,7 +3,7 @@ import { edgeAxis, type Edge, type Rect } from '../../lib/lath/model'; export type ContextSide = Edge; export type ContextPlacement = { rect: Rect; side: ContextSide; available: ContextSide[] }; const SIDES: ContextSide[] = ['right', 'left', 'bottom', 'top']; -/** Adjacent helpers reach into the source to align with its inset helper edge. */ +/** Adjacent helpers overlap the source, except above: leave its title visible. */ const OVERLAP_INSET = 16; // Compact source/directory/status chrome plus a useful terminal viewport. const MIN_WIDTH = 280; @@ -23,14 +23,16 @@ export function placeTerminalContext(wall: Rect, source: Rect, multiPane: boolea const bottom = wall.y + wall.height; const candidates = !multiPane ? [] : SIDES.map(side => { const horizontal = edgeAxis(side) === 'row'; - const space = side === 'right' ? right - source.x - source.width + OVERLAP_INSET - : side === 'left' ? source.x - wall.x + OVERLAP_INSET - : side === 'bottom' ? bottom - source.y - source.height + OVERLAP_INSET : source.y - wall.y + OVERLAP_INSET; + // Lift above helpers by twice the usual offset, clearing the source header. + const overlap = side === 'top' ? -OVERLAP_INSET : OVERLAP_INSET; + const space = side === 'right' ? right - source.x - source.width + overlap + : side === 'left' ? source.x - wall.x + overlap + : side === 'bottom' ? bottom - source.y - source.height + overlap : source.y - wall.y + overlap; const width = Math.max(0, Math.min(source.width, horizontal ? space : wall.width)); const height = Math.max(0, Math.min(source.height, horizontal ? wall.height : space)); return { side, rect: { - x: side === 'right' ? source.x + source.width - OVERLAP_INSET : side === 'left' ? source.x + OVERLAP_INSET - width : clamp(source.x, wall.x, right - width), - y: side === 'bottom' ? source.y + source.height - OVERLAP_INSET : side === 'top' ? source.y + OVERLAP_INSET - height : clamp(source.y, wall.y, bottom - height), + x: side === 'right' ? source.x + source.width - overlap : side === 'left' ? source.x + overlap - width : clamp(source.x, wall.x, right - width), + y: side === 'bottom' ? source.y + source.height - overlap : side === 'top' ? source.y + overlap - height : clamp(source.y, wall.y, bottom - height), width, height, } }; }).filter(candidate => candidate.rect.width >= MIN_WIDTH && candidate.rect.height >= MIN_HEIGHT); diff --git a/lib/src/lib/rect-union-outline.test.ts b/lib/src/lib/rect-union-outline.test.ts index 388731ca8..ea0892a92 100644 --- a/lib/src/lib/rect-union-outline.test.ts +++ b/lib/src/lib/rect-union-outline.test.ts @@ -27,3 +27,11 @@ it('matches the regular ring perimeter for a rectangular union', () => { const outline = roundedUnionOutline(rectUnionOutline(rect, rect), 8); expect(outline.perimeter).toBeCloseTo(ringPerimeter(rect, { tl: 8, tr: 8, bl: 8, br: 8, inset: 0 })); }); + +it('bridges the above-helper gap while retaining the narrower helper step', () => { + const source = { left: 0, top: 100, width: 100, height: 100 }; + const helper = { left: 0, top: 0, width: 80, height: 84 }; + const expected = [{ x: 0, y: 0 }, { x: 80, y: 0 }, { x: 80, y: 100 }, { x: 100, y: 100 }, { x: 100, y: 200 }, { x: 0, y: 200 }]; + expect(rectUnionOutline(source, helper)).toEqual(expected); + expect(rectUnionOutline(helper, source)).toEqual(expected); +}); diff --git a/lib/src/lib/rect-union-outline.ts b/lib/src/lib/rect-union-outline.ts index b35ddd5bc..8331a6ea3 100644 --- a/lib/src/lib/rect-union-outline.ts +++ b/lib/src/lib/rect-union-outline.ts @@ -9,9 +9,13 @@ export function unionBounds(a: RingRect, b: RingRect): RingRect { return { left, top, width: Math.max(a.left + a.width, b.left + b.width) - left, height: Math.max(a.top + a.height, b.top + b.height) - top }; } -/** Corner points of the outer contour of two overlapping rectangles. Grid cells remove +/** Corner points of the outer contour, bridging the gap above a source. Grid cells remove * internal seams before rounding, so a smaller helper leaves a step rather than framing peers. */ export function rectUnionOutline(a: RingRect, b: RingRect): Point[] { + // Above helpers clear the source title; extend the upper outline across that + // gap so the shared ring encloses both, including throughout its animation. + if (a.top + a.height < b.top) a = { ...a, height: b.top - a.top }; + if (b.top + b.height < a.top) b = { ...b, height: a.top - b.top }; const xs = [...new Set([a.left, a.left + a.width, b.left, b.left + b.width])].sort((x, y) => x - y); const ys = [...new Set([a.top, a.top + a.height, b.top, b.top + b.height])].sort((x, y) => x - y); const inside = (x: number, y: number) => [a, b].some(r => x > r.left && x < r.left + r.width && y > r.top && y < r.top + r.height); diff --git a/lib/src/stories/HelperPlacement.stories.tsx b/lib/src/stories/HelperPlacement.stories.tsx index 6c87c7026..c818d7e78 100644 --- a/lib/src/stories/HelperPlacement.stories.tsx +++ b/lib/src/stories/HelperPlacement.stories.tsx @@ -109,7 +109,7 @@ async function prepare(args: Props) { const a = context().getBoundingClientRect(); const b = sourcePane().getBoundingClientRect(); const overlap = { right: b.right - a.left, left: a.right - b.left, bottom: b.bottom - a.top, top: a.bottom - b.top }; - expect(overlap[expectedSide(args)]).toBeCloseTo(16); + expect(overlap[expectedSide(args)]).toBeCloseTo(expectedSide(args) === 'top' ? -16 : 16); } else { const a = context().getBoundingClientRect(); const b = sourcePane().getBoundingClientRect(); From dea969cd95c4710faa8d5815cb1abee4c81d5c6f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 15:41:44 -0700 Subject: [PATCH 19/23] Expand above helpers over peers with a slight source overlap --- docs/specs/layout.md | 4 ++-- .../components/wall/terminal-context-placement.test.ts | 10 +++++++--- lib/src/components/wall/terminal-context-placement.ts | 9 +++++---- lib/src/lib/rect-union-outline.test.ts | 8 -------- lib/src/lib/rect-union-outline.ts | 6 +----- lib/src/stories/HelperPlacement.stories.tsx | 7 +++++-- 6 files changed, 20 insertions(+), 24 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index b618b5cfe..b3cd0ac22 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -66,7 +66,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- | Layout | Placement | |---|---| -| Multiple visible panes | Beside the source with 16px overlap, except above: leave a 16px gap to expose the source title. Match its size where possible. Choose the largest usable candidate, ties right / left / bottom / top. Align the other axis with the source, shifting only to stay inside the Wall. | +| Multiple visible panes | Beside the source with 16px overlap; match its size where possible. Above helpers overlap 4px and extend 32px farther upward over peer headers. Choose the largest usable candidate, ties right / left / bottom / top. Align the other axis with the source, shifting only to stay inside the Wall. | | No usable adjacent candidate; single or zoomed pane | Source's top or bottom half, inset 16px on every side, opposite its visible terminal cursor sampled on opening; unknown, offscreen, or midpoint cursor defaults to top. | | Small source or Wall | Expand the half-pane fallback to the minimum usable size, clamped inside the Wall's 16px inset; shrink below the minimum when necessary to preserve the inset. | @@ -340,7 +340,7 @@ Source of truth: `requestKill` (every kill gesture: Door reattach, untouched fas ## Selection overlay -**Must outline the union of the invoking source Pane and its open helper**, following their contour and bridging the above-helper gap without internal seams or framing neighbors. Track helper repositioning and resize without replacing its terminal; restore the source-only ring on close. The context container has no native focus outline; its controls retain their keyboard focus indicators. +**Must outline the union of the invoking source Pane and its open helper**, following their outer contour without an internal seam or enclosing unused neighboring space. Track helper repositioning and resize without replacing its terminal; restore the source-only ring on close. The context container has no native focus outline; its controls retain their keyboard focus indicators. A fixed-positioned element on top of the Lath host, covering the active element's area inflated by `SELECTION_RING_INFLATE_PX` (4px) for panes; doors are not inflated. **The inflate is derived in `lib/src/components/design.tsx` so both ring strokes center on the gutter's midline** (rationale). diff --git a/lib/src/components/wall/terminal-context-placement.test.ts b/lib/src/components/wall/terminal-context-placement.test.ts index 3c86cc364..053b1ac7a 100644 --- a/lib/src/components/wall/terminal-context-placement.test.ts +++ b/lib/src/components/wall/terminal-context-placement.test.ts @@ -8,9 +8,9 @@ describe('terminal context placement', () => { }); it('uses below/above in stacked layouts', () => { expect(placeTerminalContext(wall, { ...wall, height: 396 }, true)).toMatchObject({ side: 'bottom', rect: { x: 0, y: 380, width: 1200, height: 396 } }); - expect(placeTerminalContext(wall, { ...wall, y: 404, height: 396 }, true)).toMatchObject({ side: 'top', rect: { x: 0, y: 0, width: 1200, height: 388 } }); + expect(placeTerminalContext(wall, { ...wall, y: 404, height: 396 }, true)).toMatchObject({ side: 'top', rect: { x: 0, y: 0, width: 1200, height: 408 } }); }); - it('aligns adjacent edges with inset helpers, but clears the source when above', () => { + it('aligns adjacent edges with inset helpers, but barely overlaps the source when above', () => { const source = { x: 400, y: 260, width: 400, height: 280 }; const insetTop = placeTerminalContext(wall, source, false, 'top').rect; const insetBottom = placeTerminalContext(wall, source, false, 'bottom').rect; @@ -21,9 +21,13 @@ describe('terminal context placement', () => { expect(right.x).toBe(insetTop.x + insetTop.width); expect(left.x + left.width).toBe(insetTop.x); expect(bottom.y).toBe(insetBottom.y + insetBottom.height); - expect(top.y + top.height).toBe(source.y - 16); + expect(top.y + top.height).toBe(source.y + 4); expect(top.y).toBe(wall.y); }); + it('grows above helpers upward over peer headers while grazing the source top', () => { + expect(placeTerminalContext(wall, { x: 0, y: 500, width: 1200, height: 280 }, true).rect) + .toEqual({ x: 0, y: 188, width: 1200, height: 316 }); + }); it('breaks equal grid fits right-first and honors manual sides', () => { const source = { x: 0, y: 0, width: 596, height: 396 }; expect(placeTerminalContext(wall, source, true).side).toBe('right'); diff --git a/lib/src/components/wall/terminal-context-placement.ts b/lib/src/components/wall/terminal-context-placement.ts index a6e28fc08..ff1d70175 100644 --- a/lib/src/components/wall/terminal-context-placement.ts +++ b/lib/src/components/wall/terminal-context-placement.ts @@ -3,7 +3,7 @@ import { edgeAxis, type Edge, type Rect } from '../../lib/lath/model'; export type ContextSide = Edge; export type ContextPlacement = { rect: Rect; side: ContextSide; available: ContextSide[] }; const SIDES: ContextSide[] = ['right', 'left', 'bottom', 'top']; -/** Adjacent helpers overlap the source, except above: leave its title visible. */ +/** Adjacent helpers overlap the source; above helpers only graze its top edge. */ const OVERLAP_INSET = 16; // Compact source/directory/status chrome plus a useful terminal viewport. const MIN_WIDTH = 280; @@ -23,13 +23,14 @@ export function placeTerminalContext(wall: Rect, source: Rect, multiPane: boolea const bottom = wall.y + wall.height; const candidates = !multiPane ? [] : SIDES.map(side => { const horizontal = edgeAxis(side) === 'row'; - // Lift above helpers by twice the usual offset, clearing the source header. - const overlap = side === 'top' ? -OVERLAP_INSET : OVERLAP_INSET; + // Grow upward over peer headers, but leave the source title readable. + const overlap = side === 'top' ? 4 : OVERLAP_INSET; const space = side === 'right' ? right - source.x - source.width + overlap : side === 'left' ? source.x - wall.x + overlap : side === 'bottom' ? bottom - source.y - source.height + overlap : source.y - wall.y + overlap; const width = Math.max(0, Math.min(source.width, horizontal ? space : wall.width)); - const height = Math.max(0, Math.min(source.height, horizontal ? wall.height : space)); + const desiredHeight = source.height + (side === 'top' ? 2 * OVERLAP_INSET + overlap : 0); + const height = Math.max(0, Math.min(desiredHeight, horizontal ? wall.height : space)); return { side, rect: { x: side === 'right' ? source.x + source.width - overlap : side === 'left' ? source.x + overlap - width : clamp(source.x, wall.x, right - width), y: side === 'bottom' ? source.y + source.height - overlap : side === 'top' ? source.y + overlap - height : clamp(source.y, wall.y, bottom - height), diff --git a/lib/src/lib/rect-union-outline.test.ts b/lib/src/lib/rect-union-outline.test.ts index ea0892a92..388731ca8 100644 --- a/lib/src/lib/rect-union-outline.test.ts +++ b/lib/src/lib/rect-union-outline.test.ts @@ -27,11 +27,3 @@ it('matches the regular ring perimeter for a rectangular union', () => { const outline = roundedUnionOutline(rectUnionOutline(rect, rect), 8); expect(outline.perimeter).toBeCloseTo(ringPerimeter(rect, { tl: 8, tr: 8, bl: 8, br: 8, inset: 0 })); }); - -it('bridges the above-helper gap while retaining the narrower helper step', () => { - const source = { left: 0, top: 100, width: 100, height: 100 }; - const helper = { left: 0, top: 0, width: 80, height: 84 }; - const expected = [{ x: 0, y: 0 }, { x: 80, y: 0 }, { x: 80, y: 100 }, { x: 100, y: 100 }, { x: 100, y: 200 }, { x: 0, y: 200 }]; - expect(rectUnionOutline(source, helper)).toEqual(expected); - expect(rectUnionOutline(helper, source)).toEqual(expected); -}); diff --git a/lib/src/lib/rect-union-outline.ts b/lib/src/lib/rect-union-outline.ts index 8331a6ea3..b35ddd5bc 100644 --- a/lib/src/lib/rect-union-outline.ts +++ b/lib/src/lib/rect-union-outline.ts @@ -9,13 +9,9 @@ export function unionBounds(a: RingRect, b: RingRect): RingRect { return { left, top, width: Math.max(a.left + a.width, b.left + b.width) - left, height: Math.max(a.top + a.height, b.top + b.height) - top }; } -/** Corner points of the outer contour, bridging the gap above a source. Grid cells remove +/** Corner points of the outer contour of two overlapping rectangles. Grid cells remove * internal seams before rounding, so a smaller helper leaves a step rather than framing peers. */ export function rectUnionOutline(a: RingRect, b: RingRect): Point[] { - // Above helpers clear the source title; extend the upper outline across that - // gap so the shared ring encloses both, including throughout its animation. - if (a.top + a.height < b.top) a = { ...a, height: b.top - a.top }; - if (b.top + b.height < a.top) b = { ...b, height: a.top - b.top }; const xs = [...new Set([a.left, a.left + a.width, b.left, b.left + b.width])].sort((x, y) => x - y); const ys = [...new Set([a.top, a.top + a.height, b.top, b.top + b.height])].sort((x, y) => x - y); const inside = (x: number, y: number) => [a, b].some(r => x > r.left && x < r.left + r.width && y > r.top && y < r.top + r.height); diff --git a/lib/src/stories/HelperPlacement.stories.tsx b/lib/src/stories/HelperPlacement.stories.tsx index c818d7e78..f01ebc7d8 100644 --- a/lib/src/stories/HelperPlacement.stories.tsx +++ b/lib/src/stories/HelperPlacement.stories.tsx @@ -9,13 +9,14 @@ import type { LathPersistedLayout } from '../lib/lath/persistence'; import { requireElement, settleTerminals } from './settle-terminals'; const SOURCE = 'placement-source'; -type Layout = 'single' | 'columns' | 'rows' | 'grid' | 'uneven'; +type Layout = 'single' | 'columns' | 'rows' | 'grid' | 'uneven' | 'wide-bottom'; type Props = { layout: Layout; width: number; height: number; sourceAtEnd: boolean; cursor: 'top' | 'bottom'; zoomed: boolean }; const leaf = (id: string): LathNode => ({ kind: 'leaf', id }); const split = (dir: 'row' | 'col', nodes: LathNode[], weights = nodes.map(() => 1)): LathNode => ({ kind: 'split', dir, children: normalizeWeights(nodes.map((node, i) => ({ node, weight: weights[i] }))) }); function boot({ layout, sourceAtEnd }: Props): LathPersistedLayout { const pair = sourceAtEnd ? [leaf('peer'), leaf(SOURCE)] : [leaf(SOURCE), leaf('peer')]; const root = layout === 'single' ? leaf(SOURCE) + : layout === 'wide-bottom' ? split('col', [split('row', [leaf('peer'), leaf('peer-2')]), leaf(SOURCE)], [1.08, 1]) : layout === 'rows' ? split('col', pair) : layout === 'grid' ? split('row', [split('col', pair), split('col', [leaf('peer-2'), leaf('peer-3')])]) : split('row', pair, layout === 'uneven' ? [2, 1] : undefined); @@ -62,6 +63,7 @@ function expectedSide({ layout, zoomed, cursor, sourceAtEnd }: Props) { // Alone in the Wall, the helper avoids the cursor; beside a neighbor, it takes the neighbor's side. if (zoomed || layout === 'single') return cursor === 'top' ? 'bottom' : 'top'; if (layout === 'grid') return 'right'; + if (layout === 'wide-bottom') return 'top'; if (layout === 'rows') return sourceAtEnd ? 'top' : 'bottom'; return sourceAtEnd ? 'left' : 'right'; } @@ -109,7 +111,7 @@ async function prepare(args: Props) { const a = context().getBoundingClientRect(); const b = sourcePane().getBoundingClientRect(); const overlap = { right: b.right - a.left, left: a.right - b.left, bottom: b.bottom - a.top, top: a.bottom - b.top }; - expect(overlap[expectedSide(args)]).toBeCloseTo(expectedSide(args) === 'top' ? -16 : 16); + expect(overlap[expectedSide(args)]).toBeCloseTo(expectedSide(args) === 'top' ? 4 : 16); } else { const a = context().getBoundingClientRect(); const b = sourcePane().getBoundingClientRect(); @@ -139,6 +141,7 @@ export const TwoColumns: Story = { args: { layout: 'columns' } }; export const RightColumn: Story = { args: { layout: 'columns', sourceAtEnd: true } }; export const TwoRows: Story = { args: { layout: 'rows' } }; export const BottomRow: Story = { args: { layout: 'rows', sourceAtEnd: true } }; +export const WideBottomRow: Story = { args: { layout: 'wide-bottom' }, globals: { theme: 'Dark (Visual Studio)' } }; export const Grid: Story = { args: { layout: 'grid' } }; export const UnevenColumns: Story = { args: { layout: 'uneven' } }; export const CursorAtTop: Story = { args: { cursor: 'top' } }; From bf600fa32d2fd8014bdb3d9812a34e88f7374ebc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 15:53:47 -0700 Subject: [PATCH 20/23] Always show terminal context details --- docs/specs/layout.md | 2 +- docs/specs/terminal-context.md | 2 +- lib/src/components/Wall.test.tsx | 2 -- .../components/wall/TerminalContext.test.tsx | 15 ++++++--------- .../components/wall/TerminalContextView.tsx | 19 +++++++++---------- lib/src/stories/ShellCwd.stories.tsx | 1 - lib/src/stories/TerminalContext.stories.tsx | 5 +---- lib/src/stories/Wall.stories.tsx | 1 - 8 files changed, 18 insertions(+), 29 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index b3cd0ac22..9c481842a 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -74,7 +74,7 @@ Popups share the zoomed pane’s app-background halo. **Must group available side buttons beside Close at the context header’s right edge**, with destination tooltips, accessible labels, and selected state. Remember manual choices per source for the mounted Wall's lifetime; clear on source removal. Preserve terminal focus on pointer repositioning. An unavailable choice falls back automatically; no preference is persisted to disk. -**Must keep source title, directory, and helper actions visible in compact context**, disclosing title explanation, directory actions, ports, and alerts through Details. Wrap header and detail actions within the panel; scroll bounded details and warnings while reserving 64px for terminal content. +**Must always show source title, directory actions, ports, alerts, and helper actions**, with title explanation available through Explain. Wrap header and detail actions within the panel; scroll bounded details and warnings while reserving 64px for terminal content. **Must reveal the context from the opening pointer position, clamped to its bounds, over 320ms.** Command-mode `a` and `>` use the header's bottom-left; openings without a position use the context's top-left. Keep final layout dimensions throughout the reveal. Start helper creation, settings reads, and port scanning immediately on mount; fade mounted content, including detail dialogs, in over 140ms after 160ms. Reduced motion or disabled layout animation skips both animations and the delay. diff --git a/docs/specs/terminal-context.md b/docs/specs/terminal-context.md index d422066a6..a340b7ede 100644 --- a/docs/specs/terminal-context.md +++ b/docs/specs/terminal-context.md @@ -53,7 +53,7 @@ Source of truth: `context` in `standalone/sidecar/pty-core.js`; `terminalContext **Must share the context presentation between the live menu and its state gallery.** -Source of truth: `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `lib/src/stories/TerminalContext.stories.tsx` supplies sample output; `lib/src/stories/Wall.stories.tsx` exercises the live helper with the fake shell. `lib/src/stories/HelperPlacement.stories.tsx` checks rendered placement and real xterm input/focus retention; the context gallery checks narrow controls and expanded details. +Source of truth: `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `lib/src/stories/TerminalContext.stories.tsx` supplies sample output; `lib/src/stories/Wall.stories.tsx` exercises the live helper with the fake shell. `lib/src/stories/HelperPlacement.stories.tsx` checks rendered placement and real xterm input/focus retention; the context gallery checks narrow controls and always-visible details. ## Tool context diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index d16cd11f4..1d3155a9b 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -749,7 +749,6 @@ describe('Wall on the Lath engine', () => { })); }); await flush(); - act(() => container.querySelector('[aria-label="Terminal context details"]')!.click()); const portRow = document.querySelector( '[data-terminal-context] button[aria-label="Open in agent-browser screencast"]', )!; @@ -3123,7 +3122,6 @@ describe('Wall on the Lath engine', () => { }); await flush(); - act(() => container.querySelector('[aria-label="Terminal context details"]')!.click()); const portRow = document.querySelector( '[data-terminal-context] button[aria-label="Open in agent-browser screencast"]', ); diff --git a/lib/src/components/wall/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx index b5f7ebebf..67b1d0ab6 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -212,18 +212,15 @@ it('uses the Tool primary terminal without creating a helper or offering helper openHelper.mockRestore(); terminal.mockRestore(); focusSurface.mockRestore(); }); -it('keeps the helper mounted while compact details are toggled', async () => { +it('always shows context details alongside the helper', () => { props.compact = true; render(); - const terminal = container.querySelector('textarea'); - expect(button('Open in system browser')).toBeNull(); - expect(container.textContent).toContain('pnpm dev'); - expect(container.textContent).toContain('~/repo'); - await click('Terminal context details'); + expect(button('Terminal context details')).toBeNull(); expect(button('Open in system browser')).not.toBeNull(); - expect(button('Terminal context details').getAttribute('aria-expanded')).toBe('true'); - await click('Terminal context details'); - expect(container.querySelector('textarea')).toBe(terminal); + expect(button('Explain this title')).not.toBeNull(); + expect(button('Copy absolute path')).not.toBeNull(); + expect(container.textContent).toContain('Alerts'); + expect(container.querySelector('textarea')).not.toBeNull(); }); it('position buttons preserve input focus and report the destination', async () => { diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index 079994395..6f7ad469d 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -45,7 +45,7 @@ const DETAILS = { type Detail = keyof typeof DETAILS; export interface TerminalContextViewProps { terminalRole?: 'helper' | 'tool'; - /** Fill the positioned host and fold directory actions, ports, and alerts behind Details. */ + /** Fill the positioned host instead of adding an inset. */ compact?: boolean; placement?: ContextPlacement & { onChange(side: ContextSide): void }; /** Exit in progress: the view is inert, and `onClose` is not called again. */ @@ -65,14 +65,14 @@ export interface TerminalContextViewProps { initialDetail?: Detail | null; } -export function ContextAction({ children, label, onClick, disabled = false, busy = false, muted = false, pressed, expanded, keepFocus = false }: { children: ReactNode; label: string; onClick?: () => void; disabled?: boolean; busy?: boolean; muted?: boolean; pressed?: boolean; expanded?: boolean; keepFocus?: boolean }) { +export function ContextAction({ children, label, onClick, disabled = false, busy = false, muted = false, pressed, keepFocus = false }: { children: ReactNode; label: string; onClick?: () => void; disabled?: boolean; busy?: boolean; muted?: boolean; pressed?: boolean; keepFocus?: boolean }) { const windowFocused = useContext(WindowFocusedContext); // Native app launches can leave :hover stale until this window regains focus. const color = muted ? 'text-muted' : windowFocused ? SUBTLE_ACTION_COLOR_CLASS : SUBTLE_ACTION_REST_COLOR_CLASS; // `busy` must never reach native `disabled`: the browser blurs a button the moment it is disabled, // and this context's Escape and Tab handling both live on the
and need a focused descendant. return ; } @@ -155,7 +155,6 @@ export function TerminalContextView(p: TerminalContextViewProps) { return () => document.removeEventListener('pointerdown', outside, true); }, [p.closing, close]); const detailRoot = useRef(null); - const [expanded, setExpanded] = useState(!p.compact); const [detail, setDetail] = useState(p.initialDetail ?? null); useEffect(() => { if (!detail) return; @@ -191,8 +190,8 @@ export function TerminalContextView(p: TerminalContextViewProps) {
Title
- {p.title}{expanded && setDetail('title')}>Explain} -
attempt(p.onCopyRef)}>{p.surfaceRef}{p.compact && setExpanded(value => !value)}>Details}
{p.placement &&
+ {p.title} setDetail('title')}>Explain +
attempt(p.onCopyRef)}>{p.surfaceRef}
{p.placement &&
{p.placement.available.map(side => p.placement!.onChange(side)}> @@ -206,8 +205,8 @@ export function TerminalContextView(p: TerminalContextViewProps) {
}
Dir -
{p.cwd}{expanded && <> attempt(p.onExplore)}>{p.explorerLabel} attempt(p.onCopyPath)}>Copy path}
- {expanded && <>Ports +
{p.cwd} attempt(p.onExplore)}>{p.explorerLabel} attempt(p.onCopyPath)}>Copy path
+ Ports
{p.scan.status === 'scanning' ? Scanning ports… : p.scan.status === 'failed' ? Port scan failed · Reopen to try again : !selected ? No listening ports : <> {entries.length > 1 ?
{entries.length} ports
: <>{selected.host}:{selected.port}{selected.processName}} @@ -219,9 +218,9 @@ export function TerminalContextView(p: TerminalContextViewProps) {
}
- Alerts
{p.argv0 ? `Watch all ${p.argv0} commands` : 'No command running'}{p.argv0 && }TODO
} + Alerts
{p.argv0 ? `Watch all ${p.argv0} commands` : 'No command running'}{p.argv0 && }TODO
- {expanded && p.notification &&
{p.notification.title}
{p.notification.body}
} + {p.notification &&
{p.notification.title}
{p.notification.body}
}
diff --git a/lib/src/stories/ShellCwd.stories.tsx b/lib/src/stories/ShellCwd.stories.tsx index aed5c6cbe..82e07127e 100644 --- a/lib/src/stories/ShellCwd.stories.tsx +++ b/lib/src/stories/ShellCwd.stories.tsx @@ -409,7 +409,6 @@ async function openHeaderContextMenu() { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, })); - (await requireElement('[aria-label="Terminal context details"]', 'context Details')).click(); const explain = await requireElement('[data-terminal-context] [aria-label="Explain this title"]', 'title explanation action'); explain.click(); await requireElement('[role="dialog"][aria-label="Title sources"]', 'title sources'); diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index 37acb123c..33413ecfd 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { expect, userEvent, within } from 'storybook/test'; +import { expect, within } from 'storybook/test'; import { FrameCornersIcon, XIcon } from '@phosphor-icons/react'; import { PANE_HEADER_HEIGHT_PX } from '../components/design'; import { NotepadHeaderButton } from '../components/wall/NotepadHeaderButton'; @@ -139,9 +139,6 @@ const meta = { // These snapshots must actually expose the state named in the story. if (['noPorts', 'multiplePorts', 'notification', 'scanFailed'].includes(args.initialScenario ?? 'fresh')) { const canvas = within(canvasElement); - const details = canvas.getByRole('button', { name: 'Terminal context details' }); - await userEvent.click(details); - await expect(details).toHaveAttribute('aria-expanded', 'true'); const target = canvas.getByText(args.initialScenario === 'notification' ? 'Tests complete' : 'Ports', { exact: true }); target.scrollIntoView({ block: 'nearest' }); await expect(target).toBeVisible(); diff --git a/lib/src/stories/Wall.stories.tsx b/lib/src/stories/Wall.stories.tsx index 660de6b36..2aa952020 100644 --- a/lib/src/stories/Wall.stories.tsx +++ b/lib/src/stories/Wall.stories.tsx @@ -102,7 +102,6 @@ async function openAlertDialog() { const header = await requireElement('[data-pane-header-for]', 'pane header'); header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); await settleTerminalContext(); - (await requireElement('[aria-label="Terminal context details"]', 'context Details')).click(); } export const Default: Story = { From 3a3cbfdb990092bb70d8bb8932be1eb8ac857f25 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 16:09:49 -0700 Subject: [PATCH 21/23] Simplify helper placement and track same-side helper motion 1:1 - Fold the helper's side into the selection-ring identity, deleting the parallel union tween path: opening, closing and side changes still tween, while same-side motion (Wall resize, Lath animation) snaps or retargets instead of restarting a 220ms ease every frame. - The overlay writes its side to the host with its bounds, renders geometry from one imperative writer, and memoizes the panel so LathHost's commit and resize renders no longer re-render the whole context. - Drop the always-true `compact` prop and the unread placement `rect`; extract the placement icon unchanged; name the inset, above-overlap and above-extension constants. - Move the union ring outline's inset and origin math into `unionRingOutline`; drop the helper ResizeObserver the MutationObserver already covers. - Tests and stories: reuse `settleTerminalContext`, drop assertions on a mocked getter, replace a DOM-depth check with `closest`, and pin the new same-side 1:1 tracking. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/layout.md | 2 +- lib/src/components/Wall.test.tsx | 6 +-- .../components/wall/TerminalContext.test.tsx | 3 +- lib/src/components/wall/TerminalContext.tsx | 4 +- .../wall/TerminalContextOverlay.tsx | 37 +++++++------- .../components/wall/TerminalContextView.tsx | 38 +++++++------- .../wall/WorkspaceSelectionOverlay.test.tsx | 7 ++- .../wall/WorkspaceSelectionOverlay.tsx | 49 ++++++------------- .../wall/terminal-context-placement.ts | 22 +++++---- lib/src/lib/rect-tween.ts | 2 +- lib/src/lib/rect-union-outline.ts | 11 ++++- lib/src/stories/HelperPlacement.stories.tsx | 19 ++++--- lib/src/stories/TerminalContext.stories.tsx | 2 +- 13 files changed, 100 insertions(+), 102 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 9c481842a..51564b329 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -362,7 +362,7 @@ The ring's rect (and its `{tl,tr,br,bl,inset}` shape) is driven **per-frame by a Per-frame writes are **imperative**: `SelectionRing` gives the overlay refs to its stable shell; the rAF loop writes rect, path `d`, marching dash, and smear geometry, then **re-applies after structural renders, pre-paint**, so fresh nodes do not flash. **Never reintroduce per-frame React state** — reconciling this subtree competes with travel for the frame budget (rationale). - **Identity change → tween.** A measurement whose identity (`${selectedType}:${selectedId}`) differs from the one on screen glides from the current interpolated position to the new target, **clock restarted**, so arrow-key spam stays responsive. -- **Helper changes → tween.** Opening, closing and repositioning interpolate the union’s two rectangles from the painted frame, including interrupted motion; retain the ordinary snap gate for reduced motion and disabled animation. +- **Helper side is identity.** An open helper appends its side, so opening, closing and switching sides tween the union’s two rectangles from the painted frame, including interrupted motion; same-side motion follows the same-identity rules below. - **Same identity → snap 1:1.** A same-identity re-measure with no tween in flight (sash drag, window resize, a settled leaf's store commit) writes the new rect directly, tracking the geometry exactly instead of easing behind it. - **In-flight retarget.** A same-identity re-measure *during* a tween retargets the destination **without resetting the clock**, so the ring converges on a moving target (select-a-neighbor-during-kill) and still lands on the original completion instant. - **Snap gate.** `motionIsInstant()` — `!cfg.layout.animate` (visual snapshots) or `prefersReducedMotion()` — settles the ring instantly; it is the same predicate the Lath animator's duration uses, so ring and leaves agree. **A ring appearing with nothing on screen also snaps**: there is no `from` to glide from. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 1d3155a9b..90e9b1345 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -3128,7 +3128,7 @@ describe('Wall on the Lath engine', () => { expect(portRow).not.toBeNull(); const contextMenu = portRow!.closest('[data-terminal-context]')!; expect(contextMenu.closest('[data-lath-leaf]')).toBeNull(); - expect(contextMenu.parentElement?.parentElement?.classList.contains('lath-host')).toBe(true); + expect(contextMenu.closest('.lath-host')).toBe(header.closest('.lath-host')); expect(contextMenu.closest('.lath-leaf-body')).toBeNull(); await act(async () => { portRow!.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -3815,21 +3815,17 @@ it('moves a retained helper without resizing or replacing its source, and rememb await open(); const menu = container.querySelector('[data-terminal-context]')!; const terminal = menu.querySelector('[data-helper-terminal]'); - const helper = helpers.getHelper('placement-source'); - expect(helper).toBeDefined(); expect(terminal).not.toBeNull(); act(() => menu.querySelector('[aria-label="Place helper at bottom"]')!.click()); expect(menu.dataset.contextSide).toBe('bottom'); expect(menu.querySelector('[data-helper-terminal]')).toBe(terminal); expect(openHelper).toHaveBeenCalledTimes(1); - expect(helpers.getHelper('placement-source')).toBe(helper); expect(source.getAttribute('style')).toBe(sourceStyle); expect(container.querySelector('[data-lath-leaf="placement-source"]')).toBe(source); act(() => menu.querySelector('[aria-label="Close terminal context"]')!.click()); await flush(); await open(); expect(container.querySelector('[data-terminal-context]')!.dataset.contextSide).toBe('bottom'); - expect(helpers.getHelper('placement-source')).toBe(helper); act(() => container.querySelector('[aria-label="Place helper at top"]')!.click()); expect(container.querySelector('[data-terminal-context]')!.dataset.contextSide).toBe('top'); }); diff --git a/lib/src/components/wall/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx index 67b1d0ab6..c713f43ec 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -213,7 +213,6 @@ it('uses the Tool primary terminal without creating a helper or offering helper }); it('always shows context details alongside the helper', () => { - props.compact = true; render(); expect(button('Terminal context details')).toBeNull(); expect(button('Open in system browser')).not.toBeNull(); @@ -224,7 +223,7 @@ it('always shows context details alongside the helper', () => { }); it('position buttons preserve input focus and report the destination', async () => { - props.placement = { rect: { x: 0, y: 0, width: 600, height: 400 }, side: 'top', available: ['top', 'bottom'], onChange: vi.fn() }; + props.placement = { side: 'top', available: ['top', 'bottom'], onChange: vi.fn() }; render(); const input = container.querySelector('textarea')!; act(() => input.focus()); diff --git a/lib/src/components/wall/TerminalContext.tsx b/lib/src/components/wall/TerminalContext.tsx index 6c9863846..69236900f 100644 --- a/lib/src/components/wall/TerminalContext.tsx +++ b/lib/src/components/wall/TerminalContext.tsx @@ -14,7 +14,7 @@ import { writeTextToClipboard } from '../../lib/clipboard'; import { listenerUrlsByPort } from './port-url'; import { DEFAULT_HELPER_COMMAND } from '../../lib/terminal-context-types'; -export function TerminalContext({ id, title, closing, origin, warning: openWarning, tool = false, compact, placement }: TerminalContextState & { title?: string; tool?: boolean } & Pick) { +export function TerminalContext({ id, title, closing, origin, warning: openWarning, tool = false, placement }: TerminalContextState & { title?: string; tool?: boolean } & Pick) { const context = useContext(TerminalContextContext); const actions = useContext(WallActionsContext); const states = useSyncExternalStore(subscribeToTerminalPaneState, getTerminalPaneStateSnapshot); @@ -52,7 +52,7 @@ export function TerminalContext({ id, title, closing, origin, warning: openWarni const copy = async (value: string) => { if (!await writeTextToClipboard(value)) throw new Error('Could not copy to clipboard'); }; const mismatch = !!helper && !!cwd && !!helperCwd && (cwd.path !== helperCwd.path || cwd.isRemote !== helperCwd.isRemote || (cwd.isRemote && cwd.host !== helperCwd.host)); const warning = openWarning ?? (helperError || (helper && helper.status !== 'waiting' && (!cwd || !helperCwd) ? 'Directory comparison unavailable: a terminal has not reported its directory.' : undefined)); - return ({ left: x, top: y, width, height }); -function writeBox(element: HTMLElement | null, rect: Rect) { - if (element) Object.assign(element.style, { left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.width}px`, height: `${rect.height}px` }); -} +const boxPx = ({ x, y, width, height }: Rect) => ({ left: `${x}px`, top: `${y}px`, width: `${width}px`, height: `${height}px` }); /** One stable host per opening: moving the overlay never remounts its terminal. Animator - * frames write bounds straight to the DOM; React re-renders only when the side or the - * available sides change. */ + * frames write bounds and side straight to the host, which the selection ring observes; + * React re-renders only when the side or the available sides change. */ export function TerminalContextOverlay({ context, title, tool, wall, source, multiPane, lath, preferences }: { context: TerminalContextState; title?: string; tool: boolean; wall: Rect; source: Rect; multiPane: boolean; lath: LathWallEngine; preferences: Map; @@ -26,27 +23,33 @@ export function TerminalContextOverlay({ context, title, tool, wall, source, mul return cursorHalfSide(terminal?.buffer.active, terminal?.rows ?? 0); }); const [manual, setManual] = useState(() => preferences.get(context.id)); - const lastSide = useRef(manual); + const lastSide = useRef(undefined); const host = useRef(null); const measure = () => placeTerminalContext(wall, lath.animator.framesAt(nowMs()).get(context.id)?.rect ?? source, multiPane, manual ?? lastSide.current, cursorSide); - const [shown, setShown] = useState(measure); + // Mount geometry only: children measure the host in layout effects that run before ours. + const [initial] = useState(measure); + const [shown, setShown] = useState>(initial); useLayoutEffect(() => { const update = () => { const next = measure(); lastSide.current = next.side; - writeBox(host.current, next.rect); + if (host.current) { + Object.assign(host.current.style, boxPx(next.rect)); + host.current.dataset.contextSide = next.side; + } setShown(previous => previous.side === next.side && previous.available.join() === next.available.join() ? previous : next); }; update(); return lath.subscribeFrames(update); // eslint-disable-next-line react-hooks/exhaustive-deps -- `measure` reads exactly these inputs }, [lath, context.id, manual, multiPane, cursorSide, wall.x, wall.y, wall.width, wall.height, source.x, source.y, source.width, source.height]); - return
- { - preferences.set(context.id, side); - lastSide.current = side; - setManual(side); - } }} /> -
; + const onChange = useCallback((side: ContextSide) => { + preferences.set(context.id, side); + setManual(side); + }, [context.id, preferences]); + // LathHost re-renders on every commit and resize frame; the panel needs only these. + const panel = useMemo(() => , [context, title, tool, shown, onChange]); + return
{panel}
; } diff --git a/lib/src/components/wall/TerminalContextView.tsx b/lib/src/components/wall/TerminalContextView.tsx index 6f7ad469d..914a54059 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -45,9 +45,7 @@ const DETAILS = { type Detail = keyof typeof DETAILS; export interface TerminalContextViewProps { terminalRole?: 'helper' | 'tool'; - /** Fill the positioned host instead of adding an inset. */ - compact?: boolean; - placement?: ContextPlacement & { onChange(side: ContextSide): void }; + placement?: Omit & { onChange(side: ContextSide): void }; /** Exit in progress: the view is inert, and `onClose` is not called again. */ closing?: boolean; /** Viewport coordinates the reveal grows from; absent, the top-left corner. */ @@ -119,6 +117,18 @@ function ContextOpenAction({ children, label, disabled, onOpen }: { children: Re ; } +/** The supplied Phosphor panel glyph, mirrored so its filled panel marks `side`. */ +function PlacementIcon({ side }: { side: ContextSide }) { + return + + + {side === 'left' || side === 'right' + ? + : } + + ; +} + /** The custom properties `.terminal-context-enter` / `-exit` read (`lib/src/theme.css`) * that JS owns: the exit length the removal timer must match, and the corner radius. */ const SURFACE_STYLE = { boxShadow: ELEVATED_PANE_SHADOW, '--context-exit-duration': `${TERMINAL_CONTEXT_EXIT_MS}ms`, '--context-radius': TERMINAL_SELECTION_BORDER_RADIUS } as CSSProperties; @@ -174,8 +184,9 @@ export function TerminalContextView(p: TerminalContextViewProps) { const status = HELPER_STATUS[p.status]; const isTool = p.terminalRole === 'tool'; const statusLabel = isTool ? (p.status === 'running' ? `Running ${p.command}…` : 'At prompt') : status.label(p.command); - return
event.preventDefault()} onKeyDown={event => { if ((event.target as HTMLElement).closest('[data-helper-terminal], [data-context-terminal]') && !detail) return; @@ -191,18 +202,11 @@ export function TerminalContextView(p: TerminalContextViewProps) { Title
{p.title} setDetail('title')}>Explain -
attempt(p.onCopyRef)}>{p.surfaceRef}
{p.placement &&
- {p.placement.available.map(side => p.placement!.onChange(side)}> - - - - {side === 'left' || side === 'right' - ? - : } - - - )} -
}
+
attempt(p.onCopyRef)}>{p.surfaceRef}
+ {placement &&
{placement.available.map(side => + placement.onChange(side)}>)}
} + +
Dir
{p.cwd} attempt(p.onExplore)}>{p.explorerLabel} attempt(p.onCopyPath)}>Copy path
diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx index 0baeba3eb..29911c193 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx @@ -557,13 +557,14 @@ describe('SelectionRing motion smear', () => { }); }); -it('animates the source/helper union on opening, repositioning and interrupted close', async () => { +it('animates the source/helper union on opening, side changes and interrupted close, tracking same-side resizes 1:1', async () => { const store = makeStore(); const wall = document.createElement('div'); wall.className = 'lath-host'; const source = document.createElement('div'); const helper = document.createElement('div'); helper.dataset.contextFor = 'a'; + helper.dataset.contextSide = 'right'; wall.append(source, helper); document.body.append(wall); stubRect(source, { left: 0, top: 0, width: 500, height: 600 }); @@ -581,8 +582,10 @@ it('animates the source/helper union on opening, repositioning and interrupted c const path = container.querySelector('[data-ring="outline"]')!; expect(path.dataset.contextUnion).toBe('true'); expect(ringRect()?.width).toBe(892); + await act(async () => { stubRect(helper, { left: 484, top: 0, width: 440, height: 300 }); helper.style.width = '440px'; }); + expect(ringRect()?.width).toBe(932); const original = path.getAttribute('d'); - await act(async () => { stubRect(helper, { left: 0, top: 584, width: 400, height: 300 }); helper.style.top = '584px'; }); + await act(async () => { stubRect(helper, { left: 0, top: 584, width: 400, height: 300 }); helper.dataset.contextSide = 'bottom'; }); expect(path.getAttribute('d')).toBe(original); await frame(30); expect(ringRect()!.height).toBeGreaterThan(608); diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx index 8afec3938..2f0c22e7d 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx @@ -39,7 +39,7 @@ import { type RingEdge, } from '../../lib/ring-geometry'; import { SelectionRing } from './SelectionRing'; -import { rectUnionOutline, roundedUnionOutline, unionBounds } from '../../lib/rect-union-outline'; +import { unionBounds, unionRingOutline } from '../../lib/rect-union-outline'; /** The subset of the Lath store the overlay needs — a revision that bumps on every * commit, so the ring re-measures as leaves move / resize / restore. Kept @@ -97,9 +97,6 @@ function ringIdentity(type: WallSelectionKind, id: string): string { const rectsEqual = (a: RingRect, b: RingRect) => a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height; -const insetRect = (r: RingRect, d: number): RingRect => - ({ left: r.left + d, top: r.top + d, width: r.width - 2 * d, height: r.height - 2 * d }); - function framesEqual(a: RingFrame, b: RingFrame): boolean { return ( rectsEqual(a.rect, b.rect) @@ -115,11 +112,8 @@ function framesEqual(a: RingFrame, b: RingFrame): boolean { * so its render is clean. `union` holds the source and helper rects while a terminal * context is open; `rect` is then their bounds, and both rectangles tween together. * Held in a ref and written to the DOM imperatively. */ -interface DisplayedRing { - rect: RingRect; - shape: RingShape; +interface DisplayedRing extends RingFrame { speeds: RingEdgeSpeeds | null; - union?: readonly [RingRect, RingRect]; } /** @@ -279,9 +273,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele // Resolved once here so every path builder below sees a single inset. const effShape = isAnts ? shape : { ...shape, inset: strokeWidth / 2 }; - const outline = union && roundedUnionOutline( - rectUnionOutline(insetRect(union[0], effShape.inset), insetRect(union[1], effShape.inset)).map(p => ({ x: p.x - rect.left, y: p.y - rect.top })), - Math.max(0, effShape.tl - effShape.inset)); + const outline = union && unionRingOutline(union, rect, effShape); path.setAttribute('d', outline ? outline.path : roundedRectPath(rect, effShape)); path.dataset.contextUnion = outline ? 'true' : 'false'; @@ -327,8 +319,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele setVisible(true); } }; - const showSettled = (frame: RingFrame, union: DisplayedRing['union'] = frame.union) => - show({ rect: frame.rect, shape: frame.shape, speeds: null, union }); + const showSettled = (frame: RingFrame) => show({ ...frame, speeds: null }); // Per-frame imperative loop: sample the tween's position and velocity, write // the DOM, and self-schedule — no React state, so a travelling ring never @@ -368,11 +359,11 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele rafRef.current = null; } }; - const snapTo = (frame: RingFrame, identity: string, union?: DisplayedRing['union']) => { + const snapTo = (frame: RingFrame, identity: string) => { tweenRef.current = null; cancelTick(); displayedIdentityRef.current = identity; - showSettled(frame, union); + showSettled(frame); }; if (!active) { @@ -394,7 +385,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele return; } - const identity = ringIdentity(selectedType, selectedId); + const selectionIdentity = ringIdentity(selectedType, selectedId); // Evaluated once per effect run, not per frame — the effect re-runs on every // Lath commit, which is plenty fresh for an OS-preference toggle. const instant = motionIsInstant(); @@ -412,10 +403,14 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele const next = measureFrame(targetEl, selectedType); if (!next) return; + // An open helper's side joins the identity, so opening, closing and switching + // sides tween the union while same-side motion tracks like any other re-measure. const helperFrame = helperEl ? measureFrame(helperEl, 'pane') : null; - const previous = frameRef.current; - const union = helperFrame ? [next.rect, helperFrame.rect] as const : undefined; - if (union) { next.rect = unionBounds(...union); next.union = union; } + if (helperFrame) { + next.union = [next.rect, helperFrame.rect]; + next.rect = unionBounds(...next.union); + } + const identity = helperFrame ? `${selectionIdentity}|context:${helperEl!.dataset.contextSide}` : selectionIdentity; const wall = targetEl.closest('[data-workspace-wall]'); opacityRef.current = wall?.style.opacity ?? ''; @@ -431,17 +426,6 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele wasActiveRef.current = true; } - // Opening, repositioning and closing a helper morph both component - // rectangles from the painted frame; never replace the union with a box. - if (union || previous?.union || tweenRef.current?.from.union) { - if (instant || !displayedFrameRef.current) { snapTo(next, identity, union); return; } - const destination = tweenRef.current?.to ?? previous; - if (destination && identity === displayedIdentityRef.current && framesEqual(destination, next)) return; - tweenRef.current = startRingTween(displayedFrameRef.current, next, performance.now(), FOCUS_MOTION_MS); - displayedIdentityRef.current = identity; - scheduleTick(); - return; - } // Snap gate: the same instant-motion predicate the Lath animator's // duration uses (motionIsInstant), so the ring and the leaves agree. if (instant) { @@ -481,12 +465,11 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele const ro = new ResizeObserver(update); const targetEl = target(); if (targetEl) ro.observe(targetEl); - // The helper's per-frame placement is an inline style write, which a ResizeObserver misses. + // The helper's placement is an inline style and side write, which a ResizeObserver misses. let mo: MutationObserver | undefined; if (helperEl) { - ro.observe(helperEl); mo = new MutationObserver(update); - mo.observe(helperEl, { attributes: true, attributeFilter: ['style'] }); + mo.observe(helperEl, { attributes: true, attributeFilter: ['style', 'data-context-side'] }); } window.addEventListener('resize', update); document.addEventListener('scroll', update, true); diff --git a/lib/src/components/wall/terminal-context-placement.ts b/lib/src/components/wall/terminal-context-placement.ts index ff1d70175..0a502e7c7 100644 --- a/lib/src/components/wall/terminal-context-placement.ts +++ b/lib/src/components/wall/terminal-context-placement.ts @@ -3,8 +3,11 @@ import { edgeAxis, type Edge, type Rect } from '../../lib/lath/model'; export type ContextSide = Edge; export type ContextPlacement = { rect: Rect; side: ContextSide; available: ContextSide[] }; const SIDES: ContextSide[] = ['right', 'left', 'bottom', 'top']; -/** Adjacent helpers overlap the source; above helpers only graze its top edge. */ -const OVERLAP_INSET = 16; +/** Adjacent helpers overlap the source by this much, and overlapping fallbacks inset by it. */ +const INSET = 16; +/** Above helpers only graze the source title, extending upward over peer headers instead. */ +const ABOVE_OVERLAP = 4; +const ABOVE_EXTENSION = 32; // Compact source/directory/status chrome plus a useful terminal viewport. const MIN_WIDTH = 280; const MIN_HEIGHT = 240; @@ -23,13 +26,12 @@ export function placeTerminalContext(wall: Rect, source: Rect, multiPane: boolea const bottom = wall.y + wall.height; const candidates = !multiPane ? [] : SIDES.map(side => { const horizontal = edgeAxis(side) === 'row'; - // Grow upward over peer headers, but leave the source title readable. - const overlap = side === 'top' ? 4 : OVERLAP_INSET; - const space = side === 'right' ? right - source.x - source.width + overlap - : side === 'left' ? source.x - wall.x + overlap - : side === 'bottom' ? bottom - source.y - source.height + overlap : source.y - wall.y + overlap; + const overlap = side === 'top' ? ABOVE_OVERLAP : INSET; + const space = overlap + (side === 'right' ? right - source.x - source.width + : side === 'left' ? source.x - wall.x + : side === 'bottom' ? bottom - source.y - source.height : source.y - wall.y); const width = Math.max(0, Math.min(source.width, horizontal ? space : wall.width)); - const desiredHeight = source.height + (side === 'top' ? 2 * OVERLAP_INSET + overlap : 0); + const desiredHeight = source.height + (side === 'top' ? ABOVE_EXTENSION + ABOVE_OVERLAP : 0); const height = Math.max(0, Math.min(desiredHeight, horizontal ? wall.height : space)); return { side, rect: { x: side === 'right' ? source.x + source.width - overlap : side === 'left' ? source.x + overlap - width : clamp(source.x, wall.x, right - width), @@ -44,8 +46,8 @@ export function placeTerminalContext(wall: Rect, source: Rect, multiPane: boolea const side = preferred === 'top' || preferred === 'bottom' ? preferred : fallback; // Leave the source visible around overlapping helpers. Small sources may borrow // Wall space for usable chrome, but keep the inset even below the minimum size. - const insetX = Math.min(OVERLAP_INSET, wall.width / 2); - const insetY = Math.min(OVERLAP_INSET, wall.height / 2); + const insetX = Math.min(INSET, wall.width / 2); + const insetY = Math.min(INSET, wall.height / 2); const width = Math.min(wall.width - 2 * insetX, Math.max(source.width - 2 * insetX, MIN_WIDTH)); const height = Math.min(wall.height - 2 * insetY, Math.max(source.height / 2 - 2 * insetY, MIN_HEIGHT)); return { side, available: ['top', 'bottom'], rect: { diff --git a/lib/src/lib/rect-tween.ts b/lib/src/lib/rect-tween.ts index f4808ff67..7b74b16d5 100644 --- a/lib/src/lib/rect-tween.ts +++ b/lib/src/lib/rect-tween.ts @@ -114,7 +114,7 @@ export function sampleRingTween(tween: RingTween, now: number): RingFrame & { do const union = clamped >= 1 ? tween.to.union : tween.from.union || tween.to.union ? [lerpRect(fromUnion[0], toUnion[0], eased), lerpRect(fromUnion[1], toUnion[1], eased)] as const : undefined; return { - ...(union ? { union } : {}), + union, rect: union ? unionBounds(...union) : lerpRect(tween.from.rect, tween.to.rect, eased), shape: lerpShape(tween.from.shape, tween.to.shape, eased), done: clamped >= 1, diff --git a/lib/src/lib/rect-union-outline.ts b/lib/src/lib/rect-union-outline.ts index b35ddd5bc..26fc96c31 100644 --- a/lib/src/lib/rect-union-outline.ts +++ b/lib/src/lib/rect-union-outline.ts @@ -1,4 +1,4 @@ -import type { RingRect } from './rect-tween'; +import type { RingRect, RingShape } from './rect-tween'; import { QUARTER_TURN } from './ring-geometry'; type Point = { x: number; y: number }; @@ -53,3 +53,12 @@ export function roundedUnionOutline(points: Point[], radius: number) { }).join(' ') + ' Z'; return { path, perimeter }; } + +/** The ring outline around a union, relative to `origin` and concentric like `roundedRectPath`: + * both rects shrink by `shape.inset`, and every corner takes the top-left radius less the inset. */ +export function unionRingOutline(union: readonly [RingRect, RingRect], origin: RingRect, shape: RingShape) { + const { inset } = shape; + const local = (r: RingRect): RingRect => + ({ left: r.left - origin.left + inset, top: r.top - origin.top + inset, width: r.width - 2 * inset, height: r.height - 2 * inset }); + return roundedUnionOutline(rectUnionOutline(local(union[0]), local(union[1])), Math.max(0, shape.tl - inset)); +} diff --git a/lib/src/stories/HelperPlacement.stories.tsx b/lib/src/stories/HelperPlacement.stories.tsx index f01ebc7d8..0ff93fcec 100644 --- a/lib/src/stories/HelperPlacement.stories.tsx +++ b/lib/src/stories/HelperPlacement.stories.tsx @@ -6,7 +6,7 @@ import { getTerminalInstance, refitSession } from '../lib/terminal-registry'; import { flattenScenario, SCENARIO_SHELL_PROMPT } from '../lib/platform'; import { leaves, normalizeWeights, type LathNode } from '../lib/lath/model'; import type { LathPersistedLayout } from '../lib/lath/persistence'; -import { requireElement, settleTerminals } from './settle-terminals'; +import { requireElement, settleTerminalContext, settleTerminals } from './settle-terminals'; const SOURCE = 'placement-source'; type Layout = 'single' | 'columns' | 'rows' | 'grid' | 'uneven' | 'wide-bottom'; @@ -56,8 +56,8 @@ async function rightClickSourceHeader() { } async function openContext() { await rightClickSourceHeader(); - await waitFor(() => expect(getHelper(SOURCE)?.status).toBe('completed')); - await settleTerminals(); + await settleTerminalContext(); + expect(getHelper(SOURCE)?.status).toBe('completed'); } function expectedSide({ layout, zoomed, cursor, sourceAtEnd }: Props) { // Alone in the Wall, the helper avoids the cursor; beside a neighbor, it takes the neighbor's side. @@ -102,24 +102,23 @@ async function prepare(args: Props) { expectContained(button, context()); } expectContained(context(), document.querySelector('.lath-host')!); - expect(context().dataset.contextSide).toBe(expectedSide(args)); + const side = expectedSide(args); + expect(context().dataset.contextSide).toBe(side); if (args.layout === 'grid' && !args.zoomed) { expect(within(context()).getByRole('button', { name: 'Place helper at right' })).toBeVisible(); expect(within(context()).getByRole('button', { name: 'Place helper at bottom' })).toBeVisible(); } + const a = context().getBoundingClientRect(); + const b = sourcePane().getBoundingClientRect(); if (!args.zoomed && args.layout !== 'single') { - const a = context().getBoundingClientRect(); - const b = sourcePane().getBoundingClientRect(); const overlap = { right: b.right - a.left, left: a.right - b.left, bottom: b.bottom - a.top, top: a.bottom - b.top }; - expect(overlap[expectedSide(args)]).toBeCloseTo(expectedSide(args) === 'top' ? 4 : 16); + expect(overlap[side]).toBeCloseTo(side === 'top' ? 4 : 16); } else { - const a = context().getBoundingClientRect(); - const b = sourcePane().getBoundingClientRect(); expect(a.left - b.left).toBeCloseTo(16); expect(b.right - a.right).toBeCloseTo(16); expect(a.top - b.top).toBeGreaterThanOrEqual(16); expect(b.bottom - a.bottom).toBeGreaterThanOrEqual(16); - expect(expectedSide(args) === 'top' ? a.top - b.top : b.bottom - a.bottom).toBeCloseTo(16); + expect(side === 'top' ? a.top - b.top : b.bottom - a.bottom).toBeCloseTo(16); } return { expectSourceUnchanged }; } diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index 33413ecfd..e5fa8b746 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -102,7 +102,7 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth, paneHeigh
pnpm dev
{'~/projects/dormouse ❯ pnpm dev\n\n  VITE ready\n  ➜  Local: http://localhost:5173/'}
- Date: Wed, 23 Sep 2026 16:14:26 -0700 Subject: [PATCH 22/23] Return the focus ring as helper dismissal begins --- lib/src/components/Wall.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 8260662b5..073dcb248 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -2325,7 +2325,7 @@ export function Wall({ externalDrag={doorDrag ? { id: doorDrag.item.id, startX: doorDrag.startX, startY: doorDrag.startY } : null} onExternalDrop={onExternalDrop} /> - +
From 783a971b15b61ebf0ff7c8283a10b990aa4f04a0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 16:23:21 -0700 Subject: [PATCH 23/23] Place the terminal context inside the Lath paint before notifying chrome The helper overlay registers a placer on the engine instead of subscribing to frames. LathHost's paint calls it with the frames it just wrote to the leaves, then notifies chrome, so the selection ring reads the placed helper from the engine where it is painted. - Removes the ring's DOM query and MutationObserver, the `contextSourceId` prop, and the helper's imperative side attribute. - Removes the overlay's per-frame `framesAt` rebuild and its drift from the leaves' paint time; `notifyFrames` is unchanged. - Fixes the subscriber-order race where the ring could measure the helper before it moved, forcing a second layout that frame. - Dismissal still returns the ring to the source alone: the placer publishes no helper while the context is closing. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/tiling-engine.md | 2 +- lib/src/components/Wall.tsx | 2 +- lib/src/components/wall/LathHost.test.tsx | 24 +++++++++- lib/src/components/wall/LathHost.tsx | 3 +- .../wall/TerminalContextOverlay.test.tsx | 28 ++++++----- .../wall/TerminalContextOverlay.tsx | 37 ++++++++------- .../wall/WorkspaceSelectionOverlay.test.tsx | 46 +++++++++++-------- .../wall/WorkspaceSelectionOverlay.tsx | 22 ++++----- lib/src/components/wall/lath-wall-engine.ts | 28 ++++++++++- 9 files changed, 123 insertions(+), 69 deletions(-) diff --git a/docs/specs/tiling-engine.md b/docs/specs/tiling-engine.md index 31daa91a0..dbb3c4138 100644 --- a/docs/specs/tiling-engine.md +++ b/docs/specs/tiling-engine.md @@ -167,7 +167,7 @@ Source of truth: `lib/src/components/wall/lath-wall-store.ts`; `lib/src/componen - Sashes render from core `sashes()` geometry as sibling divs (hit area widened to 8px, cursor per axis); a drag streams a core `resize` preview from the drag-start tree with the cumulative delta and proposes one commit on pointerup (`onCommitResize`); Escape cancels. **Geometry is reported through `store.setLayoutGeometry` from inside the measuring layout effect, never a passive effect over the rendered size** (rationale); the store's zero-area rejection is the backstop. - Zoom retargets only the chosen leaf to the wall rect inset by `LATH_ZOOM_MARGIN` (half a pane header) and elevates it above tiled/dying panes and sashes, applying the blurred `LATH_ZOOM_SHADOW` while elevated. Unzoom keeps both until the return frame settles. - **The binding never calls `.focus()` and emits no activation events.** Gestures surface as proposals (`onCommitResize`, `onLeafFocused`, the drag callbacks) that the Wall commits. -- Terminal Context renders above the tiled leaves; `docs/specs/layout.md` → Header context menu owns it. +- Terminal Context renders above the tiled leaves. **Its placer runs inside each paint, before `notifyFrames`**, so the ring measures the helper where it is painted; `docs/specs/layout.md` → Header context menu owns the context. - The selection ring and kill overlay measure leaf elements through `resolvePaneElement`, which climbs to `[data-lath-leaf]`; `WorkspaceSelectionOverlay` re-measures on every store commit (`revision`) and every animator tick, and **same-identity re-measures snap 1:1**, so the ring tracks kills, restores, and tweens frame-accurately ([layout.md → Ring travel](layout.md#ring-travel) owns its between-panes travel, a JS tween rather than a CSS transition). Source of truth: `BODY_COMPONENTS` / `TAB_COMPONENTS` / `OVERLAY_COMPONENTS` in `lib/src/components/wall/LathHost.tsx`; the `.lath-host` rules in `lib/src/index.css`. diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 073dcb248..fcdb94463 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -2325,7 +2325,7 @@ export function Wall({ externalDrag={doorDrag ? { id: doorDrag.item.id, startX: doorDrag.startX, startY: doorDrag.startY } : null} onExternalDrop={onExternalDrop} /> - +
diff --git a/lib/src/components/wall/LathHost.test.tsx b/lib/src/components/wall/LathHost.test.tsx index d1232b754..f583925db 100644 --- a/lib/src/components/wall/LathHost.test.tsx +++ b/lib/src/components/wall/LathHost.test.tsx @@ -6,7 +6,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LathHost, LATH_ZOOM_MARGIN, LATH_ZOOM_SHADOW } from './LathHost'; import { createLathWallStore, type LathWallStore, type LeafMeta, LATH_LAYOUT_OPTS } from './lath-wall-store'; -import { createLathWallEngine } from './lath-wall-engine'; +import { type ContextHelper, createLathWallEngine } from './lath-wall-engine'; import { layout } from '../../lib/lath/layout'; import { LATH_EASING } from '../../lib/lath/animator'; import { type DropTarget, move } from '../../lib/lath/ops'; @@ -438,6 +438,28 @@ describe('LathHost — empty tree', () => { }); }); +describe('LathHost — terminal context placement', () => { + it('places the context from each painted frame before notifying chrome', () => { + const store = seeded(rowOf('a', 'b'), [['a', leafMeta({ title: 'A' })], ['b', leafMeta({ title: 'B' })]]); + const { engine } = mount(store); + const element = document.createElement('div'); + const placedWidths: number[] = []; + const seen: (ContextHelper | null)[] = []; + const unsubscribe = engine.subscribeFrames(() => seen.push(engine.contextHelper())); + act(() => engine.setContextPlacer(paint => { + placedWidths.push(paint.get('a')!.rect.width); + return { sourceId: 'a', element, side: 'right' }; + })); + expect(seen.at(-1)).toEqual({ sourceId: 'a', element, side: 'right' }); + expect(`${placedWidths.at(-1)}px`).toBe(leafDiv('a')!.style.width); + act(() => store.addLeaf('c', leafMeta({ title: 'C' }), { refId: 'b', edge: 'right' })); + expect(`${placedWidths.at(-1)}px`).toBe(leafDiv('a')!.style.width); + act(() => engine.setContextPlacer(null)); + expect(seen.at(-1)).toBeNull(); + unsubscribe(); + }); +}); + describe('LathHost — imperative animation frames', () => { const DUR = 400; let clock: number; diff --git a/lib/src/components/wall/LathHost.tsx b/lib/src/components/wall/LathHost.tsx index 4e12903c5..7e54ae34d 100644 --- a/lib/src/components/wall/LathHost.tsx +++ b/lib/src/components/wall/LathHost.tsx @@ -562,8 +562,9 @@ export function LathHost({ // pane inert while it fades. el.style.pointerEvents = animator.isDying(id) ? 'none' : ''; } + lath.placeContext(paint); }, - [animator], + [animator, lath], ); // The single tick body and the loop's entry point (from the retarget effects and the diff --git a/lib/src/components/wall/TerminalContextOverlay.test.tsx b/lib/src/components/wall/TerminalContextOverlay.test.tsx index bfe325df4..097c276d8 100644 --- a/lib/src/components/wall/TerminalContextOverlay.test.tsx +++ b/lib/src/components/wall/TerminalContextOverlay.test.tsx @@ -3,8 +3,9 @@ import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { expect, it, vi } from 'vitest'; import { TerminalContextOverlay } from './TerminalContextOverlay'; -import type { LathWallEngine } from './lath-wall-engine'; +import type { ContextPlacer, LathWallEngine } from './lath-wall-engine'; import type { ContextSide } from './terminal-context-placement'; +import type { Rect } from '../../lib/lath/model'; const { rendered } = vi.hoisted(() => ({ rendered: vi.fn() })); vi.mock('./TerminalContext', () => ({ TerminalContext: () => { @@ -14,20 +15,20 @@ vi.mock('./TerminalContext', () => ({ TerminalContext: () => { vi.mock('../../lib/terminal-registry', () => ({ getTerminalInstance: () => null })); globalThis.IS_REACT_ACT_ENVIRONMENT = true; -it('tracks animation without rerendering the helper or snapping back on unrelated renders', () => { +it('places from painted frames without rerendering the helper or snapping back on unrelated renders', () => { const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); const source = { x: 0, y: 0, width: 500, height: 600 }; - let painted = source; - const listeners = new Set<() => void>(); + let placer: ContextPlacer | null = null; const lath = { - animator: { framesAt: () => new Map([['source', { rect: painted }]]) }, - subscribeFrames: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); }, + animator: { framesAt: () => new Map([['source', { rect: source }]]) }, + setContextPlacer: (next: ContextPlacer | null) => { placer = next; }, } as unknown as LathWallEngine; + const paint = (rect: Rect) => placer!(new Map([['source', { rect, opacity: 1, layer: 0 }]])); const preferences = new Map(); - const render = (title: string) => act(() => root.render( act(() => root.render()); try { @@ -39,8 +40,9 @@ it('tracks animation without rerendering the helper or snapping back on unrelate input.value = 'unfinished command'; const renders = rendered.mock.calls.length; expect(host.style.left).toBe('484px'); - painted = { ...source, width: 550 }; - act(() => { for (const notify of listeners) notify(); }); + let published: ReturnType = null; + act(() => { published = paint({ ...source, width: 550 }); }); + expect(published).toEqual({ sourceId: 'source', element: host, side: 'right' }); expect(host.style.left).toBe('534px'); expect(host.style.width).toBe('550px'); expect(rendered).toHaveBeenCalledTimes(renders); @@ -50,10 +52,12 @@ it('tracks animation without rerendering the helper or snapping back on unrelate expect(container.querySelector('[data-test-context]')).toBe(helper); expect(input.value).toBe('unfinished command'); expect(document.activeElement).toBe(input); - expect(host.dataset.contextFor).toBe('source'); + render('New source title', true); + act(() => { published = paint(source); }); + expect(published).toBeNull(); } finally { act(() => root.unmount()); container.remove(); } - expect(listeners.size).toBe(0); + expect(placer).toBeNull(); }); diff --git a/lib/src/components/wall/TerminalContextOverlay.tsx b/lib/src/components/wall/TerminalContextOverlay.tsx index 9877351b5..2363864d1 100644 --- a/lib/src/components/wall/TerminalContextOverlay.tsx +++ b/lib/src/components/wall/TerminalContextOverlay.tsx @@ -11,9 +11,9 @@ const Z_CONTEXT = 50; const boxPx = ({ x, y, width, height }: Rect) => ({ left: `${x}px`, top: `${y}px`, width: `${width}px`, height: `${height}px` }); -/** One stable host per opening: moving the overlay never remounts its terminal. Animator - * frames write bounds and side straight to the host, which the selection ring observes; - * React re-renders only when the side or the available sides change. */ +/** One stable host per opening: moving the overlay never remounts its terminal. LathHost's + * paint places the host from the frame it just wrote, then publishes it to the selection + * ring; React re-renders only when the side or the available sides change. */ export function TerminalContextOverlay({ context, title, tool, wall, source, multiPane, lath, preferences }: { context: TerminalContextState; title?: string; tool: boolean; wall: Rect; source: Rect; multiPane: boolean; lath: LathWallEngine; preferences: Map; @@ -25,25 +25,24 @@ export function TerminalContextOverlay({ context, title, tool, wall, source, mul const [manual, setManual] = useState(() => preferences.get(context.id)); const lastSide = useRef(undefined); const host = useRef(null); - const measure = () => placeTerminalContext(wall, lath.animator.framesAt(nowMs()).get(context.id)?.rect ?? source, - multiPane, manual ?? lastSide.current, cursorSide); - // Mount geometry only: children measure the host in layout effects that run before ours. - const [initial] = useState(measure); + const place = (painted: Rect | undefined) => placeTerminalContext(wall, painted ?? source, multiPane, manual ?? lastSide.current, cursorSide); + // Mount geometry only: children measure the host in layout effects that run before LathHost paints. + const [initial] = useState(() => place(lath.animator.framesAt(nowMs()).get(context.id)?.rect)); const [shown, setShown] = useState>(initial); useLayoutEffect(() => { - const update = () => { - const next = measure(); + lath.setContextPlacer(paint => { + const element = host.current; + if (!element) return null; + const next = place(paint.get(context.id)?.rect); lastSide.current = next.side; - if (host.current) { - Object.assign(host.current.style, boxPx(next.rect)); - host.current.dataset.contextSide = next.side; - } + Object.assign(element.style, boxPx(next.rect)); setShown(previous => previous.side === next.side && previous.available.join() === next.available.join() ? previous : next); - }; - update(); - return lath.subscribeFrames(update); - // eslint-disable-next-line react-hooks/exhaustive-deps -- `measure` reads exactly these inputs - }, [lath, context.id, manual, multiPane, cursorSide, wall.x, wall.y, wall.width, wall.height, source.x, source.y, source.width, source.height]); + // Dismissal returns the ring to the source alone while the exit plays. + return context.closing ? null : { sourceId: context.id, element, side: next.side }; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- `place` reads exactly these inputs + }, [lath, context.id, context.closing, manual, multiPane, cursorSide, wall.x, wall.y, wall.width, wall.height, source.x, source.y, source.width, source.height]); + useLayoutEffect(() => () => lath.setContextPlacer(null), [lath]); const onChange = useCallback((side: ContextSide) => { preferences.set(context.id, side); setManual(side); @@ -51,5 +50,5 @@ export function TerminalContextOverlay({ context, title, tool, wall, source, mul // LathHost re-renders on every commit and resize frame; the panel needs only these. const panel = useMemo(() => , [context, title, tool, shown, onChange]); - return
{panel}
; + return
{panel}
; } diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx index 29911c193..1aa97ee3f 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx @@ -21,6 +21,7 @@ import { type PaneElementsState, } from './wall-context'; import type { WallMode, WallSelectionKind } from './wall-types'; +import type { ContextHelper } from './lath-wall-engine'; import { cfg } from '../../cfg'; import { ringPerimeter } from '../../lib/ring-geometry'; import type { RingFrame } from '../../lib/rect-tween'; @@ -65,7 +66,7 @@ function paneCtx(elements: Map): PaneElementsState { return { elements, version: 0, bumpVersion: () => {} }; } -function Harness({ selectedId, selectedType = 'pane', mode, store, panes, doors = new Map(), active = true, contextSourceId }: { +function Harness({ selectedId, selectedType = 'pane', mode, store, panes, doors = new Map(), active = true, subscribeFrames = null, contextHelper }: { selectedId: string | null; selectedType?: WallSelectionKind; mode: WallMode; @@ -73,7 +74,8 @@ function Harness({ selectedId, selectedType = 'pane', mode, store, panes, doors panes: Map; doors?: Map; active?: boolean; - contextSourceId?: string; + subscribeFrames?: ((cb: (settled: boolean) => void) => () => void) | null; + contextHelper?: () => ContextHelper | null; }) { return ( @@ -81,12 +83,12 @@ function Harness({ selectedId, selectedType = 'pane', mode, store, panes, doors @@ -559,21 +561,26 @@ describe('SelectionRing motion smear', () => { it('animates the source/helper union on opening, side changes and interrupted close, tracking same-side resizes 1:1', async () => { const store = makeStore(); - const wall = document.createElement('div'); - wall.className = 'lath-host'; const source = document.createElement('div'); - const helper = document.createElement('div'); - helper.dataset.contextFor = 'a'; - helper.dataset.contextSide = 'right'; - wall.append(source, helper); - document.body.append(wall); + const element = document.createElement('div'); + document.body.append(source, element); stubRect(source, { left: 0, top: 0, width: 500, height: 600 }); - stubRect(helper, { left: 484, top: 0, width: 400, height: 300 }); + stubRect(element, { left: 484, top: 0, width: 400, height: 300 }); const panes = new Map([['a', source]]); + // LathHost's paint: publish the placed helper, then notify frames. + let helper: ContextHelper | null = null; + const frames = new Set<(settled: boolean) => void>(); + const subscribeFrames = (cb: (settled: boolean) => void) => { frames.add(cb); return () => { frames.delete(cb); }; }; + const paint = (next: ContextHelper | null) => act(async () => { helper = next; for (const cb of frames) cb(true); }); + const harness = (mode: WallMode) => helper} />; try { - await act(async () => root.render()); + await act(async () => root.render(harness('passthrough'))); const sourceBounds = ringRect(); - await act(async () => root.render()); + await paint({ sourceId: 'b', element, side: 'right' }); + expect(ringRect()).toEqual(sourceBounds); + await frame(220); + expect(ringRect()).toEqual(sourceBounds); + await paint({ sourceId: 'a', element, side: 'right' }); expect(ringRect()).toEqual(sourceBounds); await frame(30); expect(ringRect()!.width).toBeGreaterThan(508); @@ -582,17 +589,20 @@ it('animates the source/helper union on opening, side changes and interrupted cl const path = container.querySelector('[data-ring="outline"]')!; expect(path.dataset.contextUnion).toBe('true'); expect(ringRect()?.width).toBe(892); - await act(async () => { stubRect(helper, { left: 484, top: 0, width: 440, height: 300 }); helper.style.width = '440px'; }); + stubRect(element, { left: 484, top: 0, width: 440, height: 300 }); + await paint({ sourceId: 'a', element, side: 'right' }); expect(ringRect()?.width).toBe(932); const original = path.getAttribute('d'); - await act(async () => { stubRect(helper, { left: 0, top: 584, width: 400, height: 300 }); helper.dataset.contextSide = 'bottom'; }); + stubRect(element, { left: 0, top: 584, width: 400, height: 300 }); + await paint({ sourceId: 'a', element, side: 'bottom' }); expect(path.getAttribute('d')).toBe(original); await frame(30); expect(ringRect()!.height).toBeGreaterThan(608); expect(ringRect()!.height).toBeLessThan(892); expect(path.getAttribute('d')).not.toBe(original); const midMove = ringRect(); - await act(async () => root.render()); + await act(async () => root.render(harness('command'))); + await paint(null); expect(ringRect()).toEqual(midMove); await frame(30); expect(ringRect()!.height).toBeGreaterThan(608); @@ -601,5 +611,5 @@ it('animates the source/helper union on opening, side changes and interrupted cl expect(path.dataset.contextUnion).toBe('false'); expect(ringRect()?.width).toBe(508); expect(path.getAttribute('stroke-dasharray')).toBeTruthy(); - } finally { wall.remove(); } + } finally { source.remove(); element.remove(); } }); diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx index 2f0c22e7d..97d9aff3d 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx @@ -40,6 +40,7 @@ import { } from '../../lib/ring-geometry'; import { SelectionRing } from './SelectionRing'; import { unionBounds, unionRingOutline } from '../../lib/rect-union-outline'; +import type { ContextHelper } from './lath-wall-engine'; /** The subset of the Lath store the overlay needs — a revision that bumps on every * commit, so the ring re-measures as leaves move / resize / restore. Kept @@ -192,7 +193,7 @@ function writeSmear( } } -export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, selectedId, selectedType, mode, active = true, contextSourceId }: { +export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, selectedId, selectedType, mode, active = true, contextHelper }: { /** The Lath store — the overlay re-measures on every commit (`revision` via * `useSyncExternalStore`), so the ring tracks leaves as they move / resize / restore. */ lathStore: LathOverlayStore; @@ -204,7 +205,8 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele selectedType: WallSelectionKind; mode: WallMode; active?: boolean; - contextSourceId?: string; + /** The open context's helper as last painted; LathHost notifies frames after placing it. */ + contextHelper?: () => ContextHelper | null; }) { const { elements: paneElements, version: paneVersion } = useContext(PaneElementsContext); const { elements: doorElements, version: doorVersion } = useContext(DoorElementsContext); @@ -394,8 +396,6 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele if (isWorkspaceSelection(selectedType)) return workspaceTabElement(workspaceIdOfSelection(selectedType, selectedId)); return selectedType === 'door' ? doorElements.get(selectedId) : resolvePaneElement(paneElements.get(selectedId)); }; - // Wall selects the context source while a context is open, and renders one context per Wall. - const helperEl = contextSourceId ? target()?.closest('.lath-host')?.querySelector('[data-context-for]') : null; const update = () => { if (!activeRef.current) return; const targetEl = target(); @@ -405,12 +405,13 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele if (!next) return; // An open helper's side joins the identity, so opening, closing and switching // sides tween the union while same-side motion tracks like any other re-measure. - const helperFrame = helperEl ? measureFrame(helperEl, 'pane') : null; + const helper = selectedType === 'pane' ? contextHelper?.() : null; + const helperFrame = helper?.sourceId === selectedId ? measureFrame(helper.element, 'pane') : null; if (helperFrame) { next.union = [next.rect, helperFrame.rect]; next.rect = unionBounds(...next.union); } - const identity = helperFrame ? `${selectionIdentity}|context:${helperEl!.dataset.contextSide}` : selectionIdentity; + const identity = helperFrame ? `${selectionIdentity}|context:${helper!.side}` : selectionIdentity; const wall = targetEl.closest('[data-workspace-wall]'); opacityRef.current = wall?.style.opacity ?? ''; @@ -465,12 +466,6 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele const ro = new ResizeObserver(update); const targetEl = target(); if (targetEl) ro.observe(targetEl); - // The helper's placement is an inline style and side write, which a ResizeObserver misses. - let mo: MutationObserver | undefined; - if (helperEl) { - mo = new MutationObserver(update); - mo.observe(helperEl, { attributes: true, attributeFilter: ['style', 'data-context-side'] }); - } window.addEventListener('resize', update); document.addEventListener('scroll', update, true); @@ -481,7 +476,6 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele return () => { ro.disconnect(); - mo?.disconnect(); unsubFrames?.(); unsubWorkspaceFrames(); window.removeEventListener('resize', update); @@ -490,7 +484,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele // The rAF loop is intentionally NOT torn down here: it is keyed to the tween // (a ref), so a mid-glide re-run of this effect keeps the ring moving. It is // cancelled on selection-clear (above), on snap, and on unmount (below). - }, [active, contextSourceId, handoff, workspaces, subscribeLathFrames, lathRevision, selectedId, selectedType, paneVersion, doorVersion, paneElements, doorElements, applyRing]); + }, [active, contextHelper, handoff, workspaces, subscribeLathFrames, lathRevision, selectedId, selectedType, paneVersion, doorVersion, paneElements, doorElements, applyRing]); // After any structural render (mount, variant/color/focus change) re-apply the // current frame imperatively so the shell's DOM matches — runs pre-paint, so a diff --git a/lib/src/components/wall/lath-wall-engine.ts b/lib/src/components/wall/lath-wall-engine.ts index 6780c2ca9..a9b7020a6 100644 --- a/lib/src/components/wall/lath-wall-engine.ts +++ b/lib/src/components/wall/lath-wall-engine.ts @@ -9,6 +9,7 @@ import { } from '../../lib/lath/model'; import type { Direction } from '../../lib/lath/layout'; import { + type Frame, type LathAnimator, LATH_EASING, LATH_MOTION_MS, @@ -149,6 +150,11 @@ export function shouldParkOnMinimize(meta: LeafMeta): boolean { return meta.component === 'browser' || meta.component === 'tool'; } +/** The open terminal context's helper host, outlined by the selection ring with its source. */ +export type ContextHelper = { sourceId: string; element: HTMLElement; side: Edge }; +/** Places the open context's helper from one painted frame; null hides it from the ring. */ +export type ContextPlacer = (paint: ReadonlyMap) => ContextHelper | null; + export type LathWallEngine = { /** The underlying headless store — the state machine + geometry every state op and * query goes through directly (`lath.store.*`), and the reader LathHost + the @@ -171,9 +177,17 @@ export type LathWallEngine = { * calls `notifyFrames(settled)`; subscribers re-measure. Returns an unsubscribe. */ subscribeFrames(cb: (settled: boolean) => void): () => void; notifyFrames(settled: boolean): void; - /** Wake signal for the adapter's tick loop — fired when the animator becomes busy - * without a store commit (i.e. `markDying`). Returns an unsubscribe. */ + /** Wake signal for the adapter's tick loop — fired when presentation changes + * without a store commit (`markDying`, `setContextPlacer`). Returns an unsubscribe. */ subscribeWake(cb: () => void): () => void; + /** Register the open context's placer (null on unmount) and wake the tick loop, so + * the helper is repainted and chrome re-measures it. */ + setContextPlacer(placer: ContextPlacer | null): void; + /** LathHost's paint calls this with the frames it just wrote, before `notifyFrames`, + * so chrome measures the helper where it is painted. */ + placeContext(paint: ReadonlyMap): void; + /** The helper the last paint placed, or null. */ + contextHelper(): ContextHelper | null; // --- reads / projections over the store --- /** Visible leaves in tree pre-order, each with its meta title + params. Parked @@ -219,6 +233,8 @@ export function createLathWallEngine( // listener sets. Enter hints live in the store; dying state lives in the animator. const frameListeners = new Set<(settled: boolean) => void>(); const wakeListeners = new Set<() => void>(); + let contextPlacer: ContextPlacer | null = null; + let contextHelper: ContextHelper | null = null; return { store, @@ -243,6 +259,14 @@ export function createLathWallEngine( wakeListeners.add(cb); return () => wakeListeners.delete(cb); }, + setContextPlacer(placer) { + contextPlacer = placer; + for (const l of wakeListeners) l(); + }, + placeContext(paint) { + contextHelper = contextPlacer?.(paint) ?? null; + }, + contextHelper: () => contextHelper, listPanes() { const meta = snapshot().leafMeta;