diff --git a/docs/specs/layout.md b/docs/specs/layout.md index d67164047..51564b329 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 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 | 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. | + +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 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. @@ -86,7 +100,7 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- **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 @@ -326,6 +340,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. 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). - **Exactly one pane or door is active at a time**, drawn by one SVG renderer (`SelectionRing`, `variant: 'ants' | 'solid'`). @@ -337,6 +353,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)`. @@ -344,6 +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 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/docs/specs/terminal-context.md b/docs/specs/terminal-context.md index cf9c0e274..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. +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/docs/specs/tiling-engine.md b/docs/specs/tiling-engine.md index 96ff3c167..dbb3c4138 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. +- 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.test.tsx b/lib/src/components/Wall.test.tsx index 3f45f1224..90e9b1345 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -3127,7 +3127,8 @@ describe('Wall on the Lath engine', () => { ); 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.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 })); @@ -3798,3 +3799,33 @@ 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]'); + 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(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'); + 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.tsx b/lib/src/components/Wall.tsx index 27da6a064..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/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.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 e252e5a30..7e54ae34d 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'; @@ -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. */ @@ -51,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`, @@ -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 }); @@ -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 @@ -560,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 @@ -731,6 +734,13 @@ export function LathHost({ ); })} + {contextSource && ( + 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..c713f43ec 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -211,3 +211,28 @@ it('uses the Tool primary terminal without creating a helper or offering helper expect(focusSurface).not.toHaveBeenCalled(); openHelper.mockRestore(); terminal.mockRestore(); focusSurface.mockRestore(); }); + +it('always shows context details alongside the helper', () => { + render(); + expect(button('Terminal context details')).toBeNull(); + expect(button('Open in system browser')).not.toBeNull(); + 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 () => { + props.placement = { side: 'top', available: ['top', 'bottom'], 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(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/TerminalContext.tsx b/lib/src/components/wall/TerminalContext.tsx index a3be289e1..69236900f 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, 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 ({ rendered: vi.fn() })); +vi.mock('./TerminalContext', () => ({ TerminalContext: () => { + rendered(); + return
; +} })); +vi.mock('../../lib/terminal-registry', () => ({ getTerminalInstance: () => null })); +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +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 placer: ContextPlacer | null = null; + const lath = { + 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, closing?: boolean) => 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('484px'); + 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); + render('New source title'); + 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'); + expect(document.activeElement).toBe(input); + render('New source title', true); + act(() => { published = paint(source); }); + expect(published).toBeNull(); + } finally { + act(() => root.unmount()); + container.remove(); + } + expect(placer).toBeNull(); +}); diff --git a/lib/src/components/wall/TerminalContextOverlay.tsx b/lib/src/components/wall/TerminalContextOverlay.tsx new file mode 100644 index 000000000..2363864d1 --- /dev/null +++ b/lib/src/components/wall/TerminalContextOverlay.tsx @@ -0,0 +1,54 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import type { Rect } from '../../lib/lath/model'; +import { getTerminalInstance } from '../../lib/terminal-registry'; +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`). */ +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. 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; +}) { + 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(undefined); + const host = useRef(null); + 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(() => { + lath.setContextPlacer(paint => { + const element = host.current; + if (!element) return null; + const next = place(paint.get(context.id)?.rect); + lastSide.current = next.side; + Object.assign(element.style, boxPx(next.rect)); + setShown(previous => previous.side === next.side && previous.available.join() === next.available.join() ? previous : next); + // 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); + }, [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 a2c7794aa..914a54059 100644 --- a/lib/src/components/wall/TerminalContextView.tsx +++ b/lib/src/components/wall/TerminalContextView.tsx @@ -1,12 +1,13 @@ 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'; 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,7 @@ const DETAILS = { type Detail = keyof typeof DETAILS; export interface TerminalContextViewProps { terminalRole?: 'helper' | 'tool'; + 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. */ @@ -61,14 +63,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, 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 ; + aria-pressed={pressed} 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 }) { @@ -114,9 +117,21 @@ 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 = { '--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 @@ -169,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; @@ -181,20 +197,24 @@ 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} setDetail('title')}>Explain +
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
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}; @@ -202,9 +222,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
- {p.notification &&
{p.notification.title}
{p.notification.body}
} + {p.notification &&
{p.notification.title}
{p.notification.body}
}
@@ -214,12 +234,12 @@ 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()}> + {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/WorkspaceSelectionOverlay.test.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx index 560c88ce8..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 }: { +function Harness({ selectedId, selectedType = 'pane', mode, store, panes, doors = new Map(), active = true, subscribeFrames = null, contextHelper }: { selectedId: string | null; selectedType?: WallSelectionKind; mode: WallMode; @@ -73,6 +74,8 @@ function Harness({ selectedId, selectedType = 'pane', mode, store, panes, doors panes: Map; doors?: Map; active?: boolean; + subscribeFrames?: ((cb: (settled: boolean) => void) => () => void) | null; + contextHelper?: () => ContextHelper | null; }) { return ( @@ -80,11 +83,12 @@ function Harness({ selectedId, selectedType = 'pane', mode, store, panes, doors @@ -554,3 +558,58 @@ describe('SelectionRing motion smear', () => { expect(path.getAttribute('stroke-opacity')).toBeNull(); }); }); + +it('animates the source/helper union on opening, side changes and interrupted close, tracking same-side resizes 1:1', async () => { + const store = makeStore(); + const source = document.createElement('div'); + const element = document.createElement('div'); + document.body.append(source, element); + stubRect(source, { left: 0, top: 0, width: 500, height: 600 }); + 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(harness('passthrough'))); + const sourceBounds = ringRect(); + 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); + 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); + 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'); + 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(harness('command'))); + await paint(null); + 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(); + } finally { source.remove(); element.remove(); } +}); diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx index 44d519779..97d9aff3d 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx @@ -39,6 +39,8 @@ import { type RingEdge, } 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 @@ -93,10 +95,13 @@ 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; + 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.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 @@ -105,10 +110,10 @@ 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. */ -interface DisplayedRing { - rect: RingRect; - shape: RingShape; + * 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 extends RingFrame { speeds: RingEdgeSpeeds | null; } @@ -188,7 +193,7 @@ function writeSmear( } } -export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, selectedId, selectedType, mode, active = true }: { +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; @@ -200,6 +205,8 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele selectedType: WallSelectionKind; mode: WallMode; active?: boolean; + /** 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); @@ -253,7 +260,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`; @@ -268,14 +275,16 @@ 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 outline = union && unionRingOutline(union, rect, effShape); + 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 = 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; @@ -303,7 +312,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(); @@ -312,8 +321,7 @@ 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) => 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 @@ -324,11 +332,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 @@ -336,10 +344,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); @@ -379,7 +387,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(); @@ -395,6 +403,15 @@ 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 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:${helper!.side}` : selectionIdentity; const wall = targetEl.closest('[data-workspace-wall]'); opacityRef.current = wall?.style.opacity ?? ''; @@ -467,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, 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; 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..053b1ac7a --- /dev/null +++ b/lib/src/components/wall/terminal-context-placement.test.ts @@ -0,0 +1,68 @@ +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, 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)).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: 408 } }); + }); + 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; + 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(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'); + 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: { 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', () => { + 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({ 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'); + 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..0a502e7c7 --- /dev/null +++ b/lib/src/components/wall/terminal-context-placement.ts @@ -0,0 +1,58 @@ +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 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; +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 = !multiPane ? [] : SIDES.map(side => { + const horizontal = edgeAxis(side) === 'row'; + 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' ? 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), + 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); + 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, available: candidates.map(candidate => candidate.side) }; + + 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(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: { + 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/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..7b74b16d5 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, + 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.test.ts b/lib/src/lib/rect-union-outline.test.ts new file mode 100644 index 000000000..388731ca8 --- /dev/null +++ b/lib/src/lib/rect-union-outline.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest'; +import { ringPerimeter } from './ring-geometry'; +import { rectUnionOutline, roundedUnionOutline, unionBounds } from './rect-union-outline'; + +it('removes the shared seam while retaining a smaller helper’s step', () => { + 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 })).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), 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..26fc96c31 --- /dev/null +++ b/lib/src/lib/rect-union-outline.ts @@ -0,0 +1,64 @@ +import type { RingRect, RingShape } from './rect-tween'; +import { QUARTER_TURN } from './ring-geometry'; + +type Point = { x: number; y: number }; + +/** 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); + 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 => candidate[0].x === edge![1].x && candidate[0].y === edge![1].y); + edge = next < 0 ? undefined : edges.splice(next, 1)[0]; + } + 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)); + }); +} + +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 }; +} + +/** 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/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 new file mode 100644 index 000000000..0ff93fcec --- /dev/null +++ b/lib/src/stories/HelperPlacement.stories.tsx @@ -0,0 +1,210 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { Wall } from '../components/Wall'; +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'; +import type { LathPersistedLayout } from '../lib/lath/persistence'; +import { requireElement, settleTerminalContext, settleTerminals } from './settle-terminals'; + +const SOURCE = 'placement-source'; +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); + 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 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 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. + 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'; +} +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 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(); + 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); + // 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')) { + expect(button.getBoundingClientRect().top).toBe(close.top); + expectContained(button, context()); + } + expectContained(context(), document.querySelector('.lath-host')!); + 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 overlap = { right: b.right - a.left, left: a.right - b.left, bottom: b.bottom - a.top, top: a.bottom - b.top }; + expect(overlap[side]).toBeCloseTo(side === 'top' ? 4 : 16); + } else { + 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(side === 'top' ? a.top - b.top : b.bottom - a.bottom).toBeCloseTo(16); + } + return { expectSourceUnchanged }; +} + +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'; }, +} 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 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' } }; +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 { expectSourceUnchanged } = 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); + expectSourceUnchanged(); + 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 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()); + await rightClickSourceHeader(); + 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: '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(); + }); + 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 a63ef1891..e5fa8b746 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -1,9 +1,11 @@ import { useState, type ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; +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'; 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. @@ -87,17 +89,20 @@ 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: 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/'}
-
- + ; } -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
@@ -121,7 +126,7 @@ function TerminalContextStory({ initialScenario = 'fresh', initialDetail = null, {SCENARIOS.map(item => )}
- + ; } @@ -130,6 +135,27 @@ 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 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; type Story = StoryObj; @@ -146,3 +172,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/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 4d08266cc..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": 10250, + "docs/specs/layout.md": 10550, "docs/specs/mobile-terminal-ui.md": 2000, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3850,