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 ;
+}
+
/** 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(); }
}}>