# Multi-monitor gesture design — in-page panels + spanned window (A + C) **Status:** **A + C-span BUILT & validated at the desk (2026-06-08).** C-snap (`getScreenDetails` snapping) still pending. Decided + built 2026-06-08. **Supersedes** the OS-window detach as the *gesture* verb (see "Why detach broke movability") — *now actually replaced in `entry.ts`, not just planned.* **Companion docs:** `CODEMAN_DETACH_BRIEF.md` (the original window.open detach), `../gesture-proto/docs/BUILD_PLAN.md` (canonical build spec, Phase 5). > ## Implementation status (2026-06-08) > - ✅ **A — in-page floating panels.** `entry.ts` `floatSession(id, x, y)` spawns > a `.cg-float` div with an ``. The iframe is a complete live session view (its own terminal + stream client); the gesture layer moves the **div** and the iframe rides along. Lowest new code; reuses proven fan-out; no terminal wiring. Trade-off: a full app shell per float (heavier — fine for a few, watch memory at many). - **Richer alt — native teammate-style panel.** Mount a `new Terminal()` in the float body, seed via the session buffer endpoint, feed via the teammate stream. Native (no iframe), lighter per-float, same-document. Use if the iframe feels heavy or you want tighter integration. Either way the hand only **places** the float; typing into it uses a keyboard (focus the iframe / native terminal) — consistent with "gesture places, keyboard types." ### Gesture grammar (replaces the current detach path in `entry.ts`) The grab/drag/drop plumbing already exists; only the **drop action** changes. - **Grab** a `.session-tab[data-id]` (unchanged: `onGrab`, ghost-follow). - **Pull** past `DETACH_PULL_PX` to arm (unchanged: `grab.armed`). - **Drop while armed** → **no longer** `window.app.detachSession`. Instead spawn an **in-page floating panel** for that session id at the drop point. New method `floatPanel(id, x, y)` replacing `detach(id)`. - **Re-grab** a floating panel (new `PANEL_SELECTOR`, e.g. `.cg-float[data-id]`) → move it; drop anywhere → it stays. This is the capability detach lost. - **Drop a panel back over the tab strip** (or a dock zone) → **re-dock** (remove the float; session returns to a plain tab). Mirrors the `.detached` → attach toggle Codeman already has. - **Keep `window.open` detach as a separate, deliberate verb** — e.g. a button, or a distinct "throw up and off-screen" gesture — for intentionally parking a session in its own OS window. It is *not* the default pinch action anymore. ### `entry.ts` change surface - New state: `floats: Map` (id → element + position), parallel to the existing `grabs`/`taps` maps. - `onGrab`: extend hit-testing to also match `PANEL_SELECTOR`, so an existing float can be re-grabbed (priority: panel over tab when overlapping). - `onDrop`: replace `if (grab.armed) this.detach(grab.id)` with `this.floatPanel(...)`; add the panel re-dock branch. - `floatPanel(id, x, y)`: create/show the in-page panel (Part A option 1/2), position it absolutely at the drop point. Idempotent per id (re-grab moves the existing one, never duplicates). - Coordinate mapping is **already viewport-pixel based** (the click-through surface maps cursor → viewport px), so it needs **no change** for spanning — see Part C. --- ## Part C — One window spanning both monitors Once the Codeman window physically covers both displays, Part A's panels drag across the seam for free, because the gesture cursor is already in viewport pixels and the viewport now spans both monitors. ### macOS setup (operational, near-zero code) 1. **System Settings → Desktop & Dock → uncheck "Displays have separate Spaces."** (Requires a logout/login.) This is what lets a single window straddle two physical displays. 2. Run Codeman **maximized, not fullscreen.** Browser fullscreen is *per display* and will **not** span — use a maximized/borderless window dragged to cover both monitors. (A kiosk/`--app` Chrome window sized to the union rect is the cleanest.) **Automated by `scripts/span-codeman.sh`** — it reads the display-union rect (Finder desktop bounds) and launches a **Brave-first** Chromium-family `--app` window sized to it (fresh per-browser profile so the geometry flags are honored; `BROWSER=` overrides — plain Chrome bounced on the desk machine). One-click from the **Codeman header "multi-monitor" button** (`POST /api/system/span-displays`, which spawns this script), or run it directly. Step 1 + logout is still manual; the script warns if spanning isn't active. 3. Arrange the two monitors as a contiguous rectangle in Display settings so the union has no vertical offset gap. ### Coordinate model & the bezel seam - Enumerate displays with **`window.getScreenDetails()`** (Chrome, secure context, `window-management` permission). Gives each screen's `left/top/width/height/availLeft/...` in a **virtual-desktop coordinate space** spanning all monitors. - Use it for **screen-edge snapping zones**: e.g. dropping a panel within the right screen's bounds snaps it to fill that screen; the seam between the two screens' rects is a "halt / boundary" zone the cursor crosses. - **Account for the bezel gap.** The two monitors are physically separated, but the spanned window's pixels are contiguous — a panel dragged across the seam visually jumps the bezel. Optional: add a dead-band at the seam x-coordinate so a panel snaps to one side rather than straddling. - `getScreenDetails` is **only needed for snapping/zone logic**, not for basic dragging — dragging works the moment the window spans. So Part C can ship in two steps: (1) just span + free drag, (2) add `getScreenDetails` snapping. ### Constraints to surface to the user - macOS-specific; the "separate Spaces" toggle is global and affects all apps. - Real fullscreen is unavailable (must run maximized). - A bezel-width discontinuity sits in the middle of the coordinate space. - `window-management` permission prompts once. --- ## Build phases 1. ✅ **A-MVP — in-page live float, single monitor (DONE, `581fcf9`/`3e0447a`).** `entry.ts` `floatSession(id, x, y)` spawns an **iframe of `/session/:id`** in a `.cg-float` div (640×420) at the drop point; re-grab + move work (re-dock zone still TBD). Movability restored — the live session rides inside the page. (The method is named `floatSession`, not the design-sketch `floatPanel`.) 2. ✅ **C-span — span the window (DONE, `063fd8f`/`59946b8`; validated at desk).** macOS "separate Spaces" off + `scripts/span-codeman.sh` launches a maximized `--app` window across the display union (Brave-first; `BROWSER=` override). **Confirmed:** panels drag across the seam — viewport mapping held with zero `entry.ts` change. Launchable one-click from the **Codeman header button** → `POST /api/system/span-displays` (PR #103 `95b0035`), or by running the script. 3. ⏳ **C-snap — screen-aware snapping (pending).** Add `getScreenDetails`; snap a panel to the screen it's dropped on; add the seam dead-band. 4. **A-alt (optional).** Swap the iframe float for a native teammate-style `new Terminal()` panel if the iframe shell feels heavy or many floats strain memory. ## Open questions / verification before coding - [x] **Concurrent rendering — RESOLVED (2026-06-08).** A live session view *can* be mounted into an arbitrary in-page container concurrently with the main view. The PR #103 branch already ships it three ways (teammate terminals, log-viewer SSE windows, the iframe-able `/session/:id` solo route); fan-out confirmed. Primary path: iframe the solo route. See the resolved section above. - [ ] **Native-panel live feed (only if taking A-alt, not the iframe):** trace the exact transport that pushes *continuous* teammate-pane output after the REST buffer seed (`pendingData` flush source). The iframe path sidesteps this. - [ ] **`window.app.detachSession` location:** when wiring the *kept* OS-window detach verb, note this method was **not found in PR #103 source** (only server-side `detachSessionListeners`); it works at runtime, so confirm where it's actually defined before extending it. - [ ] **Spanning feasibility on the actual desk setup** (monitor arrangement, whether "separate Spaces" off is acceptable to the user globally). - [ ] **Re-dock target:** decide the re-dock gesture/zone (drop over tab strip vs a dedicated dock region). ## Touch-points summary | Layer | File | Change | Status | |-------|------|--------|--------| | Gesture consumer | `gesture-proto/src/codeman/entry.ts` | `detach()` → `floatSession()`; `floats` map; panel re-grab; (later) re-dock branch + `getScreenDetails` snapping | ✅ float+re-grab done (`581fcf9`/`3e0447a`); re-dock/snap pending | | Gesture core | `gesture-proto/src/gesture/*` | **none** — stays transport-agnostic | ✅ unchanged | | Codeman — launch | `src/web/routes/system-routes.ts`, `src/web/public/{index.html,panels-ui.js}`, `scripts/span-codeman.sh` | header button → `POST /api/system/span-displays` → spawn span script (bundled into repo) | ✅ PR #103 `95b0035` | | Codeman — caching | `src/web/server.ts` | `cacheBustAssets()` — `?v=` on all same-origin `.js`/`.css` so frontend edits show on normal reload | ✅ PR #103 `b5ea711` | | Ops | macOS display settings + `scripts/span-codeman.sh` | "separate Spaces" off + re-login; Brave-first maximized spanning window | ✅ validated at desk |