feat: vendor gesture-control source into packages/gesture-control

Bring the Ark0N/codeman-gesture-control repo in-tree as the codeman-gesture-control
workspace package so the hand-tracking overlay can be developed in the Codeman repo.
New npm run build:gesture bundles src/codeman/entry.ts into the served
gesture-codeman.js; scripts/build.mjs reruns it on every production build.
Source formatted to Codeman's prettier style.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-08 20:48:20 +02:00
co-authored by Claude Opus 4.8
parent 695e4047a1
commit 09a142d14b
27 changed files with 3630 additions and 441 deletions
+224
View File
@@ -0,0 +1,224 @@
# Codeman Gesture Control — Prototype Build Plan
> Canonical spec for this project. A Jarvis-style hand-tracking input layer for
> the Codeman dashboard. Webcam sees your hands; you pinch-drag session tabs
> between Screen columns and fire discrete gesture commands. Runs entirely
> in-browser, camera feed never leaves the machine.
Build it as a **standalone prototype first** (its own folder, fake tabs) so the
input feel can be validated on real hardware before any integration with
Codeman's existing drag/command code.
---
## Goal & success criteria
Build a `GestureController` module + a self-contained demo page that:
1. Opens the webcam and runs MediaPipe Gesture Recognizer at ~30fps.
2. Emits a smoothed cursor position and a `pinch` state (grab/release) from hand landmarks.
3. Lets you **drag fake tabs between columns by pinching**, dropping on release.
4. Fires **discrete gesture commands** (open palm, thumbs up, victory) onto an event bus.
5. Feels responsive — drag lag is not perceptible, jitter is filtered out.
**Done when:** you can sit at your desk, pinch a tab, move it to another column,
release, and it lands — reliably, without visible jitter, with the camera
mounted at your chosen angle.
---
## Tech stack
- **MediaPipe Tasks Vision** (`@mediapipe/tasks-vision`) — `GestureRecognizer` in `VIDEO` running mode, `numHands: 1` for v1 (add 2 later). Loads the prebuilt `gesture_recognizer.task` model + WASM from CDN.
- **Vanilla TS + Vite** for the prototype (no framework needed; keep it portable so the module drops into Codeman regardless of its stack). If Codeman is React, the module stays framework-agnostic and you wrap it in a hook at integration time.
- **One-Euro filter** for cursor smoothing — implement it directly, it's ~40 lines and is the correct tool for noisy interactive landmark streams (low lag at speed, heavy smoothing when still).
- **Web Worker** for inference is a **Phase 4** optimization — do NOT start there. Get it working on the main thread first; only move to a worker if the dashboard UI stutters.
---
## File structure
```
gesture-proto/
├── index.html # demo page: video preview + columns of fake tabs
├── package.json
├── vite.config.ts
├── src/
│ ├── main.ts # wires GestureController -> demo UI
│ ├── gesture/
│ │ ├── GestureController.ts # core: camera + recognizer + state machine + events
│ │ ├── OneEuroFilter.ts # cursor smoothing
│ │ ├── pinch.ts # pinch detection w/ hysteresis
│ │ ├── landmarks.ts # landmark index constants + helpers
│ │ └── types.ts # event payload types, config
│ └── demo/
│ ├── tabs.ts # fake tab/column model + render
│ └── overlay.ts # draws hand skeleton + cursor dot over video (debug)
└── README.md
```
---
## Core algorithms (the parts that decide whether it feels good)
### 1. Cursor from landmarks
The drag cursor is the **midpoint of thumb tip (landmark 4) and index tip (landmark 8)**, in normalized [0,1] coords from MediaPipe.
- **Mirror X** (`x = 1 - x`) — the webcam image is flipped relative to the user.
- Map normalized → screen pixels against the dashboard's bounding rect.
- Run the resulting (x, y) through **two independent One-Euro filters** (one per axis) before using it. Raw landmarks jitter by several pixels even when the hand is still; this is the single most important quality step.
### 2. Pinch detection with hysteresis
Compute euclidean distance between landmark 4 and landmark 8. **Normalize by hand size** (e.g. distance wrist→middle-finger-MCP, landmarks 0→9) so the threshold is robust to how close the hand is to the camera.
- Use **two thresholds, not one** (hysteresis): enter pinch below `PINCH_ON` (e.g. 0.35 of hand size), exit only above `PINCH_OFF` (e.g. 0.5). This stops flickering between grab/release at the boundary — critical for not "dropping" a tab mid-drag.
- Require the pinch state to persist N frames (e.g. 2–3) before firing, to reject single-frame noise.
### 3. State machine
```
IDLE ──hand detected──> HOVER ──pinch on──> GRABBED ──pinch off──> (drop) ──> HOVER
^ | |
└────hand lost───────────┴────────────────hand lost────────────────────────┘
```
- `HOVER`: cursor moves, highlights the tab/column under it (hit-test).
- `GRABBED`: the grabbed tab follows the cursor; emit `drag` events.
- On `pinch off` in GRABBED: hit-test cursor against drop columns, emit `drop {tabId, targetColumnId}` or `dropCancelled` if outside any column.
### 4. Discrete gestures → command bus
From `result.gestures[0].categoryName`, debounced (fire once per gesture entry, not every frame while held):
- `Open_Palm` held ~1s → `command: "halt-all"` (dead-man's-switch — pauses every session; genuinely useful for autonomous loops).
- `Thumb_Up` → `command: "approve"`.
- `Victory` → `command: "new-session"`.
- Map these to the SAME command names your voice layer already dispatches, so both input sources converge on one dispatcher.
---
## GestureController public API (target shape)
```ts
const gc = new GestureController({
video: videoEl,
surface: dashboardEl, // coords mapped against this element's rect
numHands: 1,
pinchOn: 0.35, pinchOff: 0.5,
palmHoldMs: 1000,
});
gc.on("hover", ({ x, y, targetId }) => {...});
gc.on("grab", ({ x, y }) => {...});
gc.on("drag", ({ x, y }) => {...}); // throttled to frame rate
gc.on("drop", ({ targetColumnId }) => {...});
gc.on("command",({ name }) => {...}); // halt-all | approve | new-session
gc.on("status", ({ fps, handPresent, pinchDist }) => {...}); // debug HUD
await gc.start(); // requests camera, loads model
gc.stop();
```
Keep it **transport-agnostic**: it emits semantic events, it does NOT know about
Codeman's DOM. Integration is just subscribing to these events and calling
Codeman's existing tab-move / command functions.
---
## Phased build (each phase is independently testable — stop and feel it before moving on)
**Phase 0 — Scaffold & camera (½ day)**
Vite + TS project. `index.html` with a mirrored `<video>` and a "start" button (camera must be a user gesture). Confirm `getUserMedia` works and you see yourself. Must be served over http(s), not `file://`.
**Phase 1 — Recognizer + debug overlay (½ day)**
Load `GestureRecognizer` (`VIDEO` mode, CDN model+wasm). Run `recognizeForVideo(video, performance.now())` in a `requestAnimationFrame` loop. Draw the 21-point skeleton + an FPS counter on a canvas over the video. **Checkpoint: confirm you're getting ≥25fps on your actual camera/lighting setup.** Tune lighting here.
**Phase 2 — Cursor + pinch (1 day)**
Implement `OneEuroFilter` and `pinch.ts`. Render a cursor dot driven by the filtered thumb/index midpoint. Show live pinch distance in the HUD and a color change on grab. **Checkpoint: the dot is steady when your hand is still, and pinch grab/release is crisp with no flicker.** Tune filter constants (`minCutoff`, `beta`) and pinch thresholds here — this is where the "feel" is won or lost.
**Phase 3 — Drag the fake tabs (1 day)**
Build `tabs.ts`: 3 columns of draggable fake "sessions." Wire the state machine: hover-highlight, grab, drag-follow, drop-with-hit-test. **Checkpoint: you can move a tab across columns reliably 10/10 times.** This is the core demo and the real go/no-go for the whole idea.
**Phase 4 — Discrete commands + polish (1 day)**
Add gesture→command bus with debouncing and the 1s open-palm halt. Add an on-screen toast when a command fires. Optional: move inference to a Web Worker if the UI stutters; add second-hand support.
**Phase 5 — Codeman integration (✅ working, 2026-06-07)**
Drop `gesture/` into Codeman via a new consumer `src/codeman/entry.ts` (the core is unchanged; the demo's `main.ts` is *not* the integration point). It binds `grab`/`drag`/`drop` to real `.session-tab`s (grab-to-detach → `app.detachSession`) and pinch-taps the Run / Run Shell toolbar buttons. Runs in Codeman behind `CODEMAN_GESTURE=1`. See the detailed status under "Implementation status" below.
> **Prerequisite (decided 2026-06-06, ✅ done 2026-06-07): Codeman tab-detach first.**
> Codeman needed a **tab-detach / undock** feature — a session pops out into its
> own browser window — *before* gesture wiring, because gestures can only drag DOM
> *within* the one page that owns the camera (you can't drag a node across isolated
> tabs/OS windows). So undock is a Codeman session-placement op the gesture `drop`
> *triggers*. **Shipped** as `app.detachSession(id)` → `/session/:id` solo window +
> BroadcastChannel sync + re-dock on close (the gesture layer calls it directly).
> Per-monitor placement via `getScreenDetails` stays in the multi-monitor backlog.
---
## Tuning defaults to start from (then adjust by feel)
- One-Euro: `minCutoff ≈ 1.0`, `beta ≈ 0.01` (raise `beta` if drag lags during fast moves; lower `minCutoff` if it's jittery when still).
- Pinch: `PINCH_ON 0.35`, `PINCH_OFF 0.5` (fractions of hand-size reference distance).
- `min_detection_confidence` / `min_tracking_confidence` ≈ 0.6; lower if quick gestures get missed, raise if you get false hands.
- Camera: target 30–60fps, even frontal lighting on the hand zone (matters more than the sensor).
---
## Hardware note
Prototype on the **MacBook M1 Max built-in cam** for zero-friction Phase 0–3.
For the real setup, switch to **iPhone via Continuity Camera** mounted at desk
level aimed at your hand-gesture zone — best sensor + best angle. Decide
front-facing (pointing/pinch-to-grab) vs overhead (swipe/drag-on-a-plane) mount
before Phase 3, since it slightly changes the gesture grammar.
---
## Implementation status (kept current)
- ✅ Phase 0, ✅ Phase 1 — see top-level `CLAUDE.md` and `gesture-proto/README.md`.
- ✅ Phase 1 fps checkpoint — 60fps on MacBook + iPhone 17 Pro (Continuity Camera).
- ✅ Phase 2 — `OneEuroFilter.ts` + `pinch.ts`: filtered cursor dot + pinch hysteresis. Cursor + `pinchDist` + `pinching` on the `status` event; HUD shows pinch distance, cursor ring turns green on grab.
- ✅ Phase 3 — `demo/tabs.ts`: 3 Screen columns of draggable session tabs. Controller owns the per-hand pinch state machine and emits `grab`/`drag`/`drop` in surface pixels (with a `hand` id); the demo hit-tests and moves tabs. Two-handed (drag two tabs at once); drop-on-vanish releases a tab if a pinched hand leaves frame.
- ✅ Beyond plan — two-hand tracking (`numHands: 2`, filters keyed by handedness) and a live camera picker.
- ✅ Camera (2026-06-06) — front-facing iPhone 17 Pro main lens (Chrome) is the default. **The superwide / Desk View (ultra-wide, overhead) camera now works with no issues and tracking is confirmed on it** — the earlier "Safari-only / stretched / unusable" finding is superseded. Pick either via the in-app camera picker.
- ✅ Phase 4 (built) — `commands.ts`: debounced gesture→command bus. `Thumb_Up`→approve, `Victory`→new-session (edge-triggered, fire once per entry), `Open_Palm` held `palmHoldMs`→halt-all (dead-man's-switch) with a 0–1 `haltProgress` charge surfaced on `status`. Commands ignore a pinching (mid-drag) hand.
- ⛔ **Phase 4 unwired in the demo (2026-06-06).** User wants pinch-drag only — an open palm while reaching to pinch kept charging the hold-to-halt. `main.ts` no longer subscribes to `command`/`haltProgress` and the command/charge toasts are gone. The `GestureController` core is untouched and still emits both events, so Phase 5 (or a re-enabled demo) can pick them up unchanged.
- 🐛 **Drag-position fix (2026-06-06).** A `.dragging` tab is `position: absolute`; the `.column`s establish a containing block via `backdrop-filter`, so board-local left/top were offset by the column's own position — tabs in the middle/right columns flew to the right on grab. Fix: `tabs.ts` reparents the floating tab onto `#board` (no filter/transform) for the drag, so the coordinates `moveTo` computes match the containing block.
- ✅ **Phase 5 — WORKING at the desk (2026-06-07).** Prerequisite cleared: Codeman tab-detach/undock works in the runtime (`app.detachSession(id)` is the idempotent hook). The gesture overlay runs live in the real Codeman dashboard on `:5000` and was confirmed by the user (normal tab): fullscreen cam + hand/cursor tracking, undock-by-pinch, and Run/Run Shell taps.
- **Integration shape: in-page overlay, built into Codeman beta.** The gesture `core` (`src/gesture/`) ships **unchanged**; the consumer is `src/codeman/entry.ts`, esbuild-bundled (`npm run build:codeman`) and served by Codeman at `/gesture/gesture-codeman.js`. A full-viewport, click-through overlay maps coords straight to `elementFromPoint`.
- **Gestures (routed by what the pinch lands on):** (a) **grab → in-page floating panel** *(⚠️ pivoted 2026-06-08 — was grab-to-detach)*: pinch a `.session-tab`, a ghost clone follows the hand, pull >`DETACH_PULL_PX` (70) and release → `floatSession(id, x, y)` spawns a re-grabbable `.cg-float` iframe of `/session/:id` (640×420). This **replaced** `window.app.detachSession(id)` (an OS window is a sealed box the hand can't move again — a one-way trip); the float stays in-page so the hand keeps control. See `../../docs/MULTIMONITOR_DESIGN.md`. (b) **Run / Run Shell taps** — pinch over `#runBtn`→`app.run()` / `.btn-shell`→`app.runShell()` and release in place; drift >`TAP_CANCEL_PX` (45) cancels. `CLICK_SELECTOR` is the extensible list. (c) **Fullscreen dimmed cam** by default, **⛶** toggles corner PiP.
- **Self-hosted MediaPipe (no CDN).** `entry.ts` passes `wasmBase: "/gesture/wasm"` + `modelUrl: "/gesture/gesture_recognizer.task"`; Codeman serves them same-origin. The CDN path failed in the normal browser tab (content/ad blocker blocking `jsdelivr`/`googleapis`) → surfaced as `failed: {"isTrusted":true}` once `entry.ts` learned to report non-Error throws. Core also gained a GPU→CPU delegate fallback.
- **Gated by `CODEMAN_GESTURE=1` (OFF by default).** Under the flag Codeman injects the module script (dashboard only, not `/session/:id` solo popups), cache-busts it with `?v=<mtime>` (static is `max-age=1y`), and widens CSP (`'wasm-unsafe-eval'` + `worker-src 'self' blob:`; same-origin assets now covered by `'self'`). Flag off ⇒ Codeman HTML/CSP unchanged.
- **Codeman-side / version control:** the detach + instance isolation + base gesture overlay are committed on `Ark0N/Codeman` branch `beta/session-detach`, open as **PR #103** (tip `afea6d6`; `ceca853` after I fixed its `auth.ts` format:check → CI green). Gotcha: the local prod clone `~/.codeman/app` tracks only `master`, so the branch is hidden until `git fetch origin beta/session-detach` (this briefly misled me into a bogus local reconstruction `03b31b8`, since deleted). The session improvements — direct-detach via `window.app.detachSession`, Run/Run Shell pinch-taps, self-hosted MediaPipe (`/gesture/wasm` + `.task`), and the `server.ts` mtime cache-bust — were **ported onto PR #103** in commit `eea84db` (CI green); their source is `Ark0N/codeman-gesture-control` (`src/codeman/entry.ts`).
- **Commits (gesture-proto):** `ddf9cda` (consumer: detach/cam/errors) → `2dd97da` (detach direct) → `21ef793` (Run/Run Shell taps) → `e055b79` (self-host MediaPipe).
- **Next:** tune feel; optional in-strip reorder (deferred — user chose detach-only) and more buttons (Stop). Discrete `command` events remain available but unwired (pinch-only). Hand-off brief: `../docs/CODEMAN_DETACH_BRIEF.md`.
## Backlog (requested, for later)
- ✅ **Fullscreen mode** — done. Toggle button fullscreens the `#stage`;
`:fullscreen` CSS fills the viewport and the coord mapping adapts since it
reads the stage rect every frame.
- ✅ **Multi-monitor mode — A+C BUILT & validated at the desk (2026-06-08).** The
eventual real goal (fling a Codeman session onto an external display by gesture)
is reached. **Design → [`../../docs/MULTIMONITOR_DESIGN.md`](../../docs/MULTIMONITOR_DESIGN.md)**,
approach **A+C**:
- **A — in-page floating panels** (`581fcf9`, `3e0447a`): `entry.ts`
`floatSession(id, x, y)` pops a tab into a re-grabbable `.cg-float` iframe of
`/session/:id` (640×420) instead of `window.app.detachSession`. The session
stays in the camera-owning page's DOM, so the hand keeps control — fixing the
one-way-trip flaw of OS-window detach.
- **C-span — spanned window** (`063fd8f`, `59946b8`): `scripts/span-codeman.sh`
launches a Brave-first (`BROWSER=` override) `--app` window sized to the
display union; prereq macOS "Displays have separate Spaces" OFF + re-login.
One-click via the **Codeman header button** → `POST /api/system/span-displays`
(PR #103 `95b0035`). **Validated:** one window spans both monitors and a panel
drags across the seam.
- **C-snap** (`getScreenDetails` snapping + seam dead-band) and the **re-dock**
gesture/zone are **still pending** — not needed for basic cross-seam dragging.
- **Concurrent-rendering question** was RESOLVED first: Codeman already mounts
live terminals into floating panels (teammate terminals, log-viewer SSE
windows, the iframe-able `/session/:id` solo route), so the live float needed
no new Codeman rendering.
Note: the public event surface evolved from the original API sketch. The
controller stays transport-agnostic but emits coordinate-only `grab`/`drag`/
`drop` (hit-testing lives in the consumer, since only it knows the DOM/columns).
`hover`/`dropCancelled`/`targetId` were dropped; hover highlighting is derived
from the `status` snapshot instead.
@@ -0,0 +1,80 @@
# Feature brief: Session tab detach / undock (for Codeman)
> ✅ **SHIPPED — on GitHub as PR #103 (open).** Branch `beta/session-detach` on
> `Ark0N/Codeman` (base `master`): "feat(web): session detach/undock + beta
> instance isolation (port 5000)", containing detach/undock + instance isolation
> + the base gesture overlay (commit `afea6d6`). `app.detachSession(id)` in
> `app.js` opens `/session/:id` as a solo window (another live client of the same
> session), tracks it (badge + `BroadcastChannel` sync + re-dock on close), and is
> the single idempotent entry point both the on-tab ⧉ icon and the gesture layer
> call. PTY fan-out (the open question below) resolved **yes**, so no streaming
> work was needed. CI green after I fixed a prettier format:check on `auth.ts`
> (commit `ceca853`).
>
> ⚠️ **Note:** the local **prod** clone `~/.codeman/app` only tracks `master`, so
> the PR branch is invisible there until `git fetch origin beta/session-detach`.
> (Earlier today I briefly mis-concluded the PR didn't exist and made a bogus
> local reconstruction — deleted. The PR was real all along.) The gesture-side
> *improvements* from this session — direct-detach, Run/Run Shell pinch-taps,
> self-hosted MediaPipe, `server.ts` cache-bust — were **ported onto PR #103**
> (commit `eea84db`, CI green); their source is `Ark0N/codeman-gesture-control`.
> The rest of this doc is the original hand-off brief, kept for history.
> Hand-off brief for **Codeman** to refine and implement **on a beta branch**.
> Authored from the gesture-control project, which needs this as a prerequisite.
> Codeman is "aicodeman": a Fastify + WebSocket server streaming xterm.js
> terminal (tmux) sessions to a web dashboard.
## Goal
Let a session "tab" pop out of the main dashboard into its **own browser
window** (and back). Each detached window shows just that one session's
terminal, fully live. This is a standalone UX win *and* a prerequisite for
gesture control later (a hand-gesture "drop" will eventually trigger
detach/relocate — but that's a separate project; **this feature is plain UI
buttons only**).
## Core approach (refine as needed)
- Add a **"Detach" control** on each tab. It opens a new browser window
(`window.open`) pointing at a **single-session view** — ideally a real route
like `/session/:id` so the popup just loads a URL and attaches like a normal
client.
- The detached window runs its **own xterm.js instance connected to the same
session's WebSocket**, so it's live, not a screenshot.
- Keep the dashboard and detached windows **in sync** (session list, titles,
alive/dead state, focus) — via the existing events channel, or a
`BroadcastChannel` if simpler.
- Support **re-dock** (close popup → tab returns to the dashboard) and handle the
popup being closed/refreshed gracefully.
## The one critical question to resolve first (in Codeman's own code)
Can the server currently **fan out one session's PTY/tmux output to multiple
concurrent WebSocket clients**, or is it single-consumer? A detached window is a
*second* viewer of the same session. If it's single-consumer today, that's the
main change: make the pty→socket stream **broadcast to N subscribers** (and merge
input) so dashboard + popup can both watch/type. This likely matters more than
the UI work.
## Other decisions for Codeman
- Per-session route (`/session/:id`) vs. a single-page popup that's told which id
to show.
- Multi-monitor placement later via the Window Management API
(`getScreenDetails`) — **out of scope now**, just don't design against it.
- Auth/cookie sharing so a popup window authenticates the same as the dashboard.
## Constraints
- Implement on a **beta branch**, not `main`.
- The gesture-control side keeps a **read-only** copy of Codeman (its `.git`
removed); the live `Ark0N/Codeman` repo is **not** touched from here. Codeman
implements this itself.
## Why this is sequenced before gesture wiring
Gestures can only drag DOM **within the single page that owns the camera**; you
cannot drag a node across isolated browser tabs / OS windows. So undock must be a
**Codeman session-placement operation** that a gesture `drop` later *triggers* —
not something the gesture layer does. Detach first; wire gestures to it after.
@@ -0,0 +1,260 @@
# 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 `<iframe src="/session/:id">` (640×420) at the drop
> point; panels are re-grabbable. Replaces `window.app.detachSession`. Commits
> `581fcf9`, `3e0447a`.
> - ✅ **C-span — spanned window.** `scripts/span-codeman.sh` (Brave-first;
> `BROWSER=` override) launches a `--app` window sized to the display union.
> Commits `063fd8f`, `59946b8`. **Validated at the desk:** one window spans
> both monitors (3432×1080) and a panel drags across the seam.
> - ✅ **Launch entry point in Codeman.** A header "multi-monitor" button (replaces
> the notification bell) → `POST /api/system/span-displays` → spawns the span
> script. Bundled `span-codeman.sh` into Codeman's repo. **PR #103** `95b0035`.
> - ✅ **Cache-bust** all same-origin module scripts/CSS (`renderIndexHtml` →
> `cacheBustAssets`), so frontend edits show on a normal reload. PR #103 `b5ea711`.
> - ⏳ **C-snap** (`getScreenDetails` snapping + seam dead-band) — not built; not
> required for basic cross-seam dragging. Also pending: the re-dock gesture/zone.
> - **Known caveats from the desk run:** dead band on a taller/offset external
> display (inherent to one spanning rect); superwide lens not selectable in the
> fresh span-window browser profile; 3-monitor works unchanged but dead-space +
> cursor-sensitivity caveats grow.
---
## Goal
Pinch a Codeman session, drag it anywhere — including **across a second
physical monitor** — drop it, and have it stay where you put it and stay
grabbable again. The "fling a session onto the external display by gesture"
end-goal from the BUILD_PLAN backlog, made real **without losing the ability to
move a session after you've placed it.**
## The root constraint (why this design, not the others)
The hand only exists in **the one page that owns the camera**. Hand tracking,
the cursor, and `document.elementFromPoint` hit-testing all live in that single
document. **An OS window created by `window.open` is a sealed box that page
cannot reach into** — no shared DOM, no shared cursor.
> **Why detach broke movability.** Today `entry.ts` → `detach(id)` calls
> `window.app.detachSession(id)`, which `window.open`s the session into its own
> OS window. The instant it leaves the camera-owning page, the hand can never
> touch it again. Detach-by-pinch *works*, but it's a one-way trip.
**Rule:** anything you want to keep gesture-movable must stay inside **one
page's DOM.** This design honors that with two composed pieces:
- **A — In-page floating panels.** "Detach" pops a session into a free-floating,
absolutely-positioned element *in the same page* (not an OS window), so the
hand keeps control forever.
- **C — One window spanning both monitors.** Run Codeman in a single window
stretched across both physical displays, so "drag across monitors" is just
"drag across the page," and the second monitor's pixels are actually used.
Option **B** (one OS window per monitor + a distributed BroadcastChannel cursor
protocol + cross-window session hand-off) was considered and deferred: it's the
only path to *independent* per-monitor OS windows, but it's a much larger build
and reintroduces the cross-window wall this design exists to avoid.
---
## Part A — In-page floating panels
### Codeman already has the primitive
`panels-ui.js` has a `.detached` "floating window": an absolutely-positioned
`<div>` with `panel.style.top/left/width/height` and a drag handler
(`setupMonitorDrag`) — **all in-page, no `window.open`.** The session-tab detach
simply picked the wrong primitive (the OS-window one). Part A reuses the
in-page one.
### Concurrent session rendering — RESOLVED (2026-06-08): live floats are feasible now
The main *dashboard view* renders **one active session at a time** (a single
shared `this.terminal` opened into `#terminalContainer` in `terminal-ui.js:26`,
swapped by `selectSession()`). But the page is **not** limited to one terminal —
the PR #103 branch already ships **three independent in-page concurrent
floating-content subsystems** we can reuse, so a live floating panel per session
needs **no new Codeman rendering architecture**:
1. **Teammate terminals** (`panels-ui.js` `teammateTerminals` Map +
`subagent-windows.js`). A `Map` of **concurrent live `new Terminal()`
instances**, each `terminal.open(body)`'d into a floating panel, bound to a
`sessionId` + tmux `paneTarget`, **seeded via REST** buffer fetch
(`/api/sessions/:id/teammate-pane-buffer/:pane`), **input via REST**
(`/api/sessions/:id/teammate-pane-input`), with lazy-mount (`_lazyPaneTarget`)
and `dispose()` cleanup. *This is option 2 already built and shipping.*
2. **Log-viewer windows** (`panels-ui.js:2632`). Concurrent draggable floating
windows, each with its own `EventSource` **SSE stream** and lifecycle map —
proof of the generic "floating window + independent per-window stream"
pattern.
3. **`/session/:id` solo route** (`server.ts:573`, `renderIndexHtml(soloId)`,
`text/event-stream` at `:633`). A full **standalone live session page** —
**iframe-able** — reusing the exact multi-client fan-out detach already
depends on. Solo mode deliberately **omits** the gesture overlay
(`server.ts:1010-1014`), so there's no nested-overlay problem.
**The PTY multi-viewer fan-out is confirmed** (the brief's open "yes"): a session
is addressable by multiple concurrent clients — the solo route, the teammate
REST endpoints, and the on-tab pop-out all view the same live session.
**Resolution:** skip the static-preview fallback. Build live floats directly,
ranked by new-code cost:
- **Primary — iframe the solo route.** `floatPanel(id)` =
`<div class="cg-float" data-id><iframe src="/session/:id"></iframe></div>`.
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<string, FloatingPanel>` (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=<mtime>` 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 |