mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #453 from timkjr/feat/split-pane-sessions
feat: split-pane sessions — view two live terminals side by side
This commit is contained in:
@@ -171,7 +171,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
|
||||
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (27 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~6.9K lines, core) + 34 modules + `sw.js` (+ `voice-pcm-worklet.js`, fetched from JS, not in the load order) | See Frontend section for the load order, which is authoritative |
|
||||
| **Frontend** | `src/web/public/app.js` (~6.9K lines, core) + 35 modules + `sw.js` (+ `voice-pcm-worklet.js`, fetched from JS, not in the load order) | See Frontend section for the load order, which is authoritative |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 22 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
@@ -261,6 +261,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection and automatic drop recovery always use a bounded 1 MiB `?tail=` window. Shell loads the rest only when **Load full history** is pressed; ordinary scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). Live writes are one-chunk-in-flight, released by xterm's parse callback, so xterm's private queue cannot bypass the browser's 128 KiB render cap. While WebSocket owns terminal I/O, duplicate SSE terminal events are dropped before JSON parsing, and recovery is single-flight per active session. ⚠️ **A `full=1` capture ENDS with a cursor move back to the pane's own caret position**, counted UP from the last replayed row — without it the caret stays where the last character landed, which for an agent CLI is the status line, and every cursor-relative update the CLI sends afterwards is measured from the wrong row. The move is relative, not `CUP`: absolute row addressing is only right while the browser's rows equal the pane's, and `resizeWindow` does not wait for tmux, so a capture can be taken before a requested resize applies. That makes row alignment load-bearing on this path: no transform that can DELETE A LINE may run over the capture, so it keeps its trailing blank rows and skips redraw-bloat stripping, the banner trim and the leading-whitespace strip. ⚠️ Those three skips key on whether a capture actually CAME BACK (`isFullCapture`), never on `?full=1` alone — the fallback to the byte history is a stream of successive frames that must still be stripped, and a session with no mux takes it on every load. A capture holding nothing visible returns '' so the byte history survives instead of a blank screen replacing it. ⚠️ A full re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. ⚠️ **A visible capture now REPORTS the geometry it was taken at** (`captureCols`/`captureRows`, #435), because a frame built for a pane taller or wider than the browser is damaged two ways at once (overflow rows clamp onto the last line; a narrower browser wraps every painted row) and nothing in the response used to say so. Both fields are ABSENT when no frame was positioned, so every consumer tests `Number.isFinite`, never truthiness: a `display-message` cursor query that fails makes `capturePaneBuffer` return the raw capture while the route still labels it `mux-visible`. The comparison runs on `mux-visible` ONLY, the replay is capped at one attempt, and a pane that cannot be sized to fit latches in `_geometryRetryUseless` so it is diagnosed once per session rather than on every tab switch. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||
|
||||
**Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ It fires at the END of a gesture, never in `onSelectionChange` (that callback runs per cell crossed, so copying there is one clipboard write per mouse move); it only ARMS `_autoCopyPending`, and a document-level `mouseup` listener flushes. ⚠️ The flush is SYNCHRONOUS inside the handler because both clipboard paths need user activation (Firefox gates `navigator.clipboard.writeText` on it, and the plain-HTTP `execCommand` fallback must run in the gesture's own task); a timer or a wait for `onSelectionChange` loses it, invisibly in Chrome. ⚠️ Touch needs its OWN calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()`: that path `preventDefault()`s its touchend, so no mouseup ever arrives and the toggle would be dead on phones. ⚠️ Unlike `copyTerminalSelection()` it must NOT clear the selection (the text would vanish under the cursor that highlighted it) and must NOT focus the terminal (that opens the on-screen keyboard over it); focus is RESTORED to whatever held it, which only matters for the `execCommand` fallback. Guards are pure in `decideAutoCopy()` (constants.js): off, blank/whitespace-only, and a 1M-char cap (an autoscrolling drag can sweep the whole 50k-line scrollback), refused rather than truncated with a toast pointing at Ctrl+C. Silent on success except once per page load; failures toast, throttled 10s. Tests: `test/terminal-auto-copy.test.ts`.
|
||||
@@ -304,7 +306,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY; `test/entrance-animations.test.ts` pins that property allowlist, plus the rule→keyframes→theme-option chain a style silently does nothing without. ⚠️ **`blur` is the ONE style that puts a `filter` on the terminal container**, against the standing rule, because every alternative was measured against a live xterm and does not work: a `backdrop-filter` veil on `::before` blurs perfectly while STATIC and Chrome silently drops the backdrop the moment ANY animation runs on that pseudo-element (the veil computes `blur(15.3px)` and the text behind it stays razor sharp), and driving the radius from rAF buys the same full-screen blur per frame plus main-thread work. The cost the rule exists to avoid is inherent to blurring a terminal, so the style buys it knowingly: opt-in, OFF by default, one ~520ms run per session open, class straight back off, `will-change` still unset. Worst-case price, headless SwiftShader with no GPU: frame deltas 16.7ms → 33.3ms for the run, against 16.7ms flat for `fade`. Do not generalise it — a second filtered terminal style needs its own measurement. ⚠️ The `blur` connection line animates `filter` too, so both kinds of line hold their glow in **`--line-glow`** and both of its keyframes say `blur(N) var(--line-glow)`: the function lists then match and interpolate, instead of the glow vanishing for the run and popping back (a lineage line's glow is a different colour entirely, set per element). Its 100% frame deliberately omits `opacity` so the endpoint comes from the element's own resting value — 0.9 subagent, 0.72 lineage, 0.95 working — which is what `line-enter-fade`'s hardcoded 0.9 gets wrong. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
|
||||
|
||||
@@ -354,7 +356,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` that stops delivering does not always error, so `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. ⚠️ The 15s server keepalive was an SSE **comment** (`:keepalive`), and comments are **invisible to `EventSource` by spec**, so there was nothing a client could observe: it is now the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), which is exactly why the frame had to change type. ⚠️ Staleness is judged **only while the status is `connected`** and the device is online; that guard is the loop breaker, since a forced `connectSSE()` leaves `connected` immediately and cannot re-fire while a reconnect is in flight. ⚠️ The liveness stamp is applied inside `addListener` itself, so every registered handler (the `_SSE_HANDLER_MAP` wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists **only** to be registered, since `EventSource` drops named events nobody listens for. ⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
|
||||
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7).
|
||||
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7).
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/capture-load-window.browser.test.ts',
|
||||
'test/capture-geometry-retry.browser.test.ts',
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
'test/split-pane-terminal.browser.test.ts',
|
||||
'test/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,198 @@
|
||||
# Split-Pane Sessions — Design Spec
|
||||
|
||||
**Status**: Implemented (v1)
|
||||
**Author**: Claude (session with Tim), 2026-09-15
|
||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||
|
||||
## Problem
|
||||
|
||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||
switching panes re-binds the single xterm instance and the single WebSocket
|
||||
to a different session. Multi-monitor spanning (`scripts/span-codeman.sh` /
|
||||
`span-codeman.ps1`) turned out to solve a different problem: it makes one
|
||||
browser window bigger, but that window still shows one session; floating
|
||||
subagent windows are draggable overlays on top of it, not tiled panes. There
|
||||
is no way today to see two live sessions (e.g. `w1-codeman` and
|
||||
`w1-mcp-memory`) side-by-side in one window, even on a monitor wide enough to
|
||||
fit both.
|
||||
|
||||
## Goal (v1)
|
||||
|
||||
From the active session, open a **second, independent, fully live session**
|
||||
in a pane beside it — draggable divider, side-by-side only. Closing the
|
||||
second pane collapses back to today's normal single-pane view. No
|
||||
persistence: a page reload always returns to single-pane. Floating
|
||||
subagent/Ultracode windows keep their current behavior unchanged (global,
|
||||
unconstrained across the whole viewport, split or not).
|
||||
|
||||
Explicitly out of scope for v1 (v2 candidates, not designed here):
|
||||
- More than 2 panes / grid layouts
|
||||
- Vertical (stacked) splits
|
||||
- Drag-a-tab-to-split as a trigger (v1 trigger is an explicit button + picker)
|
||||
- Persisting the split layout across reload or across devices
|
||||
- Mobile/tablet layouts (viewport is too narrow for this to make sense; gated
|
||||
to desktop widths the same way `home-sessions.js`'s rail is)
|
||||
- Feature parity between the two panes (see "Pane B is deliberately plainer"
|
||||
below)
|
||||
|
||||
## Current architecture (why this isn't a CSS change)
|
||||
|
||||
`terminal-ui.js` is built entirely around **singleton** state: `this.terminal`
|
||||
(one xterm instance), `this._ws`/`this._wsSessionId` (one WebSocket, rebound
|
||||
on every pane switch via `_disconnectWs()` + `_connectWs(newId)`), a
|
||||
`this._xtermSnapshots` map used only to restore scrollback into that one
|
||||
terminal when switching back to a session. Roughly 280 references to this
|
||||
singleton state exist across the file (input handling, resize/fit, sizing-
|
||||
token claims, mobile touch gestures, CJK IME, local-echo overlay wiring,
|
||||
keyboard accessory bar, link providers, etc.).
|
||||
|
||||
Showing two sessions at once therefore requires a second, independently
|
||||
alive xterm + WebSocket pair running concurrently — not a layout change to
|
||||
one shared instance.
|
||||
|
||||
**Related prior art**: `detachSession(id)` (app.js) already opens one session
|
||||
in a genuinely separate browser window (`isSoloWindow` mode) with its own
|
||||
independent WebSocket, and two of those can already be snapped side-by-side
|
||||
today with zero new code. That covers "two sessions visible at once" but not
|
||||
what this spec is for: one Codeman window with two panes and a divider you
|
||||
can drag without leaving your seat, each still a full participant in that
|
||||
window's floating subagent windows, header, and settings. This spec builds
|
||||
past detach, not a duplicate of it.
|
||||
|
||||
**Server-side check (done, not just assumed)**: `MAX_WS_PER_SESSION = 5`
|
||||
(`src/web/routes/ws-routes.ts`), scoped by `clientId:tabNonce`
|
||||
(`ws-connection-registry.ts`). Splitting always opens a *different* session
|
||||
in the second pane (self-splitting is disallowed, see below), so this is two
|
||||
sessions each getting their normal one connection — the existing cap is
|
||||
irrelevant here and needs no server change.
|
||||
|
||||
## Key design decision: Pane B is deliberately plainer than Pane A
|
||||
|
||||
Porting all ~280 singleton behaviors to a second, symmetric pane is not
|
||||
worth it for v1 — most of that code is input-quality-of-life for **mobile/
|
||||
touch** (local-echo overlay, CJK IME textarea, touch gesture handling,
|
||||
keyboard accessory bar), and this feature is desktop-only by nature (a split
|
||||
view needs a wide viewport). So:
|
||||
|
||||
- **Pane A** (the session that was already active when you opened the split)
|
||||
stays exactly what it is today — `this.terminal`, `this._ws`, unchanged
|
||||
code path, zero regression risk.
|
||||
- **Pane B** is a new, smaller `SplitTerminalPane` object: its own xterm
|
||||
instance + fit addon, its own WebSocket to `/ws/sessions/:id/terminal`,
|
||||
resize-on-divider-drag, and plain keyboard input. It does **not** get the
|
||||
local-echo overlay, CJK IME composition, touch/mobile handlers, or the
|
||||
keyboard accessory bar. On a desktop, typing directly into an xterm
|
||||
instance with no overlay is exactly how Codeman behaved before the local-
|
||||
echo overlay existed for touch devices — normal, not degraded, for a
|
||||
keyboard-and-mouse user.
|
||||
|
||||
If this asymmetry actually bothers you in daily use, promoting Pane B to full
|
||||
parity is a scoped v2 (extract the shared logic already once you have two
|
||||
call sites to compare, rather than guessing the right abstraction now).
|
||||
|
||||
One more asymmetry worth naming here rather than discovering by surprise:
|
||||
while both panes accept keyboard input, the global capture-phase shortcut
|
||||
handler (`app.js`) always resolves against Pane A — it has no notion of
|
||||
which pane currently has focus. So Ctrl+L or Ctrl+W typed while Pane B has
|
||||
focus clears or closes Pane A, not the session you were actually typing
|
||||
into. Not fixed for v1, same reasoning as the rest of this section.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `SplitTerminalPane` (new, `terminal-split.js`)
|
||||
|
||||
A small class, one instance per secondary pane:
|
||||
- `constructor(sessionId, mountEl)`
|
||||
- `connect()` — creates the xterm instance (same theme/font config as the
|
||||
primary, read from the same settings so it doesn't visually clash), opens
|
||||
`/ws/sessions/:id/terminal`, wires input → WS, WS → terminal write
|
||||
- `fit()` — calls the fit addon; called on divider drag (rAF-throttled) and
|
||||
on window resize
|
||||
- `destroy()` — disposes the xterm instance, closes the WS cleanly
|
||||
|
||||
No snapshot/scrollback-restore map is needed the way `_xtermSnapshots` exists
|
||||
for Pane A — Pane B is destroyed on close, not hidden-and-restored, since
|
||||
there's no persistence requirement.
|
||||
|
||||
### 2. Split container (layout)
|
||||
|
||||
```
|
||||
.terminal-split-container (flex row, only rendered when split is active)
|
||||
├── .terminal-wrap (existing element, Pane A — untouched)
|
||||
├── .split-divider (new, draggable seam)
|
||||
└── .terminal-pane-b (new, hosts SplitTerminalPane's xterm + a
|
||||
small header: session name + × close button)
|
||||
```
|
||||
|
||||
When not split, `.terminal-wrap` renders exactly as it does today (no
|
||||
wrapping container at all, to keep the no-split path byte-identical to
|
||||
current behavior). Splitting inserts the container and reparents
|
||||
`.terminal-wrap` into it as the first child — same reparenting pattern
|
||||
already used by `applySessionListLayout()` for `#sessionTabs`, so this isn't
|
||||
a new pattern for the codebase.
|
||||
|
||||
Default split is 50/50 (`flex-basis: 50%` each). Divider drag updates both
|
||||
panes' `flex-basis` live (rAF-throttled) and calls `fit()` on **both**
|
||||
terminals per tick, clamped to 20%/80% so neither pane can be dragged into an
|
||||
unusably thin sliver.
|
||||
|
||||
### 3. Trigger UI
|
||||
|
||||
A **"Split"** button (header, opt-in like the other header buttons —
|
||||
`showSplitButton`, default off, same pattern as `showMultiMonitorButton`)
|
||||
opens a small picker listing your other open sessions (reuses
|
||||
`this.sessions`/`sessionOrder`, filtered to exclude the currently active
|
||||
session — you cannot split a session against itself). Picking one:
|
||||
1. Creates the split container, reparents `.terminal-wrap`
|
||||
2. Instantiates `SplitTerminalPane` for the chosen session in `.terminal-pane-b`
|
||||
3. Button state flips to "close split" (or Pane B's own header × does it)
|
||||
|
||||
Closing (via Pane B's × or the header button toggling off):
|
||||
1. `SplitTerminalPane.destroy()`
|
||||
2. Removes `.terminal-split-container`, reparents `.terminal-wrap` back to
|
||||
its original location at 100% width
|
||||
3. Fires a resize/fit on Pane A (same `ResizeObserver`-driven fit already in
|
||||
place today — no new code needed here, it fires naturally once the
|
||||
container's size changes)
|
||||
|
||||
v2 note (not designed): dragging a session tab onto the active pane as an
|
||||
alternate trigger. You confirmed right-click doesn't work today (Codeman
|
||||
doesn't intercept it) and declined a keybind, so v1 is button+picker only.
|
||||
|
||||
### 4. Failure / edge cases
|
||||
|
||||
- **The Pane B session ends or is deleted while split is active** → treat
|
||||
identically to the user closing Pane B manually: destroy the pane, collapse
|
||||
to Pane A at full width.
|
||||
- **The Pane A session ends while split is active** → Pane B is promoted:
|
||||
it becomes the new single full-width pane (reusing today's normal
|
||||
single-pane code path means Pane B's `SplitTerminalPane` must hand off to
|
||||
a real `this.terminal`/`this._ws` binding — simplest correct approach is
|
||||
to just collapse the split and let normal session-select logic reopen
|
||||
Pane B's session as the new primary, rather than trying to promote the
|
||||
lightweight pane object in place).
|
||||
- **Both end** → falls through to today's normal "no active session" /
|
||||
welcome-screen state.
|
||||
- **Subagent/Ultracode floating windows** → no design work needed; they're
|
||||
already positioned independent of `.terminal-wrap`'s layout, so they
|
||||
continue to float over whichever pane(s) are on screen, unconstrained,
|
||||
exactly as today.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `SplitTerminalPane` connect/fit/destroy lifecycle (mock WS, like
|
||||
existing terminal tests use `TEST_PTY_SCRIPT`).
|
||||
- Route/integration: opening two WS connections to two different sessions
|
||||
from one simulated client concurrently — confirms the existing per-session
|
||||
cap and connection registry need no changes.
|
||||
- Browser (Playwright, `test/browser` since this is desktop-viewport-gated
|
||||
UI): open split via button+picker, verify both panes render live output
|
||||
independently, drag divider and confirm both refit, close Pane B and
|
||||
confirm Pane A returns to full width, kill the Pane B session externally
|
||||
and confirm auto-collapse.
|
||||
|
||||
## Open questions for review
|
||||
|
||||
None blocking — the scope-narrowing decisions above (Pane B feature parity,
|
||||
no persistence, side-by-side only, button+picker trigger) came directly from
|
||||
your answers during brainstorming. Flag anything here you want reconsidered.
|
||||
@@ -55,12 +55,14 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
Chips for every optional header control, with a live preview of the resulting header:
|
||||
|
||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Plan Usage, Lifecycle Log, Monitor,
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle Log, Monitor,
|
||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||
Ultracode Windows, Cron.
|
||||
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
||||
New header controls never appear on phones.
|
||||
New header controls never appear on phones. Split is desktop-only regardless of this
|
||||
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||
resizable panes plus their divider have nowhere to go.
|
||||
|
||||
This section also holds background-agent tracking, including whether to track agents for
|
||||
every session or only the active tab.
|
||||
|
||||
@@ -116,6 +116,7 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| Lifecycle Log | Off | Session start, exit, and kill audit trail. |
|
||||
| Cron ⏰ | Off | Scheduled jobs. |
|
||||
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
||||
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
||||
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||
| Admin panel | Multi-user only | User administration. |
|
||||
|
||||
|
||||
@@ -1516,8 +1516,57 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
|
||||
return { startRow, endRow, text, offsetToCell, cellToOffset };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Split-Pane Sessions — pure helpers (divider math, picker list)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Desktop-only, same reasoning and same threshold as HOME_SESSIONS_MIN_WIDTH
|
||||
// (home-sessions.js): two 240px min-width panes plus the divider need ~486px,
|
||||
// which a phone or narrow tablet cannot give them, and the divider has no
|
||||
// touch handlers. A dedicated constant rather than reusing
|
||||
// HOME_SESSIONS_MIN_WIDTH directly — that name lives in home-sessions.js,
|
||||
// which loads AFTER this file (load order 12.56 vs 7.5), so referencing it
|
||||
// from module-evaluation-time code here would be a ReferenceError.
|
||||
const SPLIT_PANE_MIN_WIDTH = 1180;
|
||||
|
||||
function clampDividerPercent(rawPercent, min = 20, max = 80) {
|
||||
if (rawPercent < min) return min;
|
||||
if (rawPercent > max) return max;
|
||||
return rawPercent;
|
||||
}
|
||||
|
||||
function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds) {
|
||||
const result = [];
|
||||
for (const id of sessionOrder) {
|
||||
if (id === excludeId) continue;
|
||||
// A detached (popped-out) session's own window already yields its PTY
|
||||
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
|
||||
// Pane B's SplitTerminalPane._sendResize() has no such check, so letting
|
||||
// one into the picker put its detached window and Pane B in a fight over
|
||||
// the same PTY's dimensions.
|
||||
if (detachedIds?.has?.(id)) continue;
|
||||
const session = sessions.get(id);
|
||||
if (!session) continue;
|
||||
// A session with no PTY attached (exited CLI, a crash-looped session
|
||||
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
||||
// reading its tmux pane. SplitTerminalPane never does selectSession()'s
|
||||
// re-attach POST, so its socket would open onto a pane nothing feeds:
|
||||
// no terminal events, and Session.write() silently drops every keystroke
|
||||
// with no ack either way (Pane B sends no `seq`), so the loss is
|
||||
// invisible — the healthy socket never trips the disconnect banner.
|
||||
if (session.pid === null) continue;
|
||||
result.push({ id, label: session.name || 'Session' });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||
window.CodemanSplitPane = {
|
||||
clampDividerPercent,
|
||||
buildSplitPickerSessions,
|
||||
SPLIT_PANE_MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@
|
||||
'File Viewer': '文件查看器',
|
||||
'Open file viewer': '打开文件查看器',
|
||||
'Open Codeman across all displays': '在所有显示器上打开 {name}',
|
||||
'Split: open a second session beside this one': '分屏:在旁边打开第二个会话',
|
||||
'Split: close the second session': '分屏:关闭第二个会话',
|
||||
'Close split': '关闭分屏',
|
||||
'No other sessions to split with': '没有其他可用于分屏的会话',
|
||||
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
|
||||
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
|
||||
Notifications: '通知',
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
@@ -1924,6 +1925,7 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
||||
</div>
|
||||
@@ -3719,6 +3721,7 @@
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="tab-rail-resize.js"></script>
|
||||
<script defer src="terminal-ui.js"></script>
|
||||
<script defer src="terminal-split.js"></script>
|
||||
<script defer src="respawn-ui.js"></script>
|
||||
<script defer src="ralph-panel.js"></script>
|
||||
<script defer src="orchestrator-panel.js"></script>
|
||||
|
||||
@@ -529,6 +529,11 @@ html.mobile-init .file-browser-panel {
|
||||
.btn-icon-header.btn-approvals {
|
||||
display: none !important;
|
||||
}
|
||||
/* Split-pane is hidden here too, but the AUTHORITATIVE hard gate is the
|
||||
wider `@media (max-width: 1179px)` rule in styles.css — this file only
|
||||
loads up to 1023px, which does not cover the 1024-1179px tablet range
|
||||
the split feature also needs to stay off (two 240px min-width panes plus
|
||||
the divider need ~486px; the divider also has no touch handlers). */
|
||||
|
||||
/* Read My Mind 🧠 header button: never in the phone header; the phone
|
||||
surface is the keyboard-accessory 🧠 key (same `readMyMindEnabled` gate,
|
||||
|
||||
@@ -407,6 +407,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Phone overview home screen: only meaningful under 600px, so the row is
|
||||
@@ -2120,6 +2121,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
@@ -2350,6 +2352,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
showFileViewerButton: _fvb,
|
||||
// Desktop-only header button, per-device, and absent from
|
||||
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
|
||||
// (moving it into displayKeys alone is not the strip; this is).
|
||||
showSplitButton: _ssp,
|
||||
webglRendererEnabled: _wgl,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
// Copy-on-select. Per-device (clipboard access differs by device and by
|
||||
@@ -2742,6 +2748,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showUltracodeAgents: false,
|
||||
ultracodeFloatingWindows: false,
|
||||
showMultiMonitorButton: false,
|
||||
showSplitButton: false,
|
||||
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
||||
// OFF so the phone header stays minimal and the mobile-header-buttons
|
||||
// policy guard keeps passing.
|
||||
@@ -2947,6 +2954,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
|
||||
}
|
||||
|
||||
// Split button — hidden by default, and hard-gated to desktop widths
|
||||
// regardless of the setting (window.CodemanSplitPane.SPLIT_PANE_MIN_WIDTH,
|
||||
// matching HOME_SESSIONS_MIN_WIDTH's JS-check + media-query-backstop
|
||||
// pattern — the CSS in styles.css is the backstop, this is the check).
|
||||
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||
this._applySplitButtonVisibility?.(showSplitButton);
|
||||
|
||||
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||
// from the mobile-header-buttons-policy guard.
|
||||
@@ -3363,6 +3377,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
'sessionLineageLines',
|
||||
'showSplitButton',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
||||
|
||||
@@ -675,6 +675,12 @@ body.tab-rail-resizing * {
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
body.split-pane-resizing,
|
||||
body.split-pane-resizing * {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tab-rail,
|
||||
.tab-rail-resize-handle {
|
||||
@@ -2282,6 +2288,39 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Split-pane header button: hidden by default (opt-in via App Settings →
|
||||
Header & Panels → Header buttons → Split, `showSplitButton`). Pure
|
||||
client-side toggle applied by applyHeaderVisibilitySettings(). */
|
||||
.btn-split--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* While a split is open, the button's own click closes it instead of opening
|
||||
the picker — the accent fill is what tells the user which of its two
|
||||
behaviours the next click will get (aria-pressed carries the same state
|
||||
for assistive tech). */
|
||||
.btn-icon-header.btn-split.split-open {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-split.split-open:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* Split-pane hard desktop gate, independent of the showSplitButton setting:
|
||||
two 240px min-width panes plus the divider need ~486px, the divider has no
|
||||
touch handlers, and mobile.css only loads up to 1023px so it cannot cover
|
||||
the 1024-1179px tablet range on its own. Same threshold and reasoning as
|
||||
HOME_SESSIONS_MIN_WIDTH (home-sessions.js) / SPLIT_PANE_MIN_WIDTH
|
||||
(constants.js) — keep the three in sync. */
|
||||
@media (max-width: 1179px) {
|
||||
.btn-icon-header.btn-split {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-settings {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -2547,6 +2586,7 @@ body.solo-mode .header-system-stats,
|
||||
body.solo-mode .header-tokens,
|
||||
body.solo-mode .btn-notifications,
|
||||
body.solo-mode .btn-multimonitor,
|
||||
body.solo-mode .btn-split,
|
||||
body.solo-mode .header-plan-usage,
|
||||
/* A solo window shows ONE session and has no tab strip to put restored ones in,
|
||||
so offering to rebuild a list of them there is an offer it cannot show the
|
||||
@@ -15299,6 +15339,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
}
|
||||
.main.webview-active .webview-layer { display: flex; }
|
||||
.main.webview-active .terminal-wrap { display: none; }
|
||||
/* A split (Pane A + divider + Pane B) hides as one unit when a web tab is
|
||||
active, mirroring the .terminal-wrap rule above — .terminal-wrap is
|
||||
reparented INSIDE .terminal-split-container while a split is open, so
|
||||
hiding only .terminal-wrap would leave Pane B and the divider stranded on
|
||||
screen over the dashboard iframe. No state is destroyed, so returning to
|
||||
the session tab shows the split intact. */
|
||||
.main.webview-active .terminal-split-container { display: none; }
|
||||
|
||||
.webview-frame {
|
||||
display: none;
|
||||
@@ -18565,3 +18612,128 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
|
||||
}
|
||||
}
|
||||
|
||||
/* Split-Pane Sessions: container inserted only while a split is active.
|
||||
.terminal-wrap (Pane A) is reparented into this as the first child; it
|
||||
keeps every existing rule unchanged since nothing here restyles it. */
|
||||
.terminal-split-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.terminal-split-container > .terminal-wrap {
|
||||
/* flex-shrink 1 (not 0): the divider's own 6px is fixed-width, and Pane A
|
||||
+Pane B's inline flex-basis (openSplitPane/onMove) always sums to 100%,
|
||||
so with flex-shrink 0 on both panes the row is 100% + 6px wide and
|
||||
.main's overflow clips Pane B's right edge by exactly the divider's
|
||||
width. Shrinking lets the two panes give up that 6px between them. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-divider {
|
||||
flex: 0 0 6px;
|
||||
cursor: col-resize;
|
||||
background: var(--border-color, #333);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.split-divider:hover,
|
||||
.split-divider.dragging {
|
||||
background: var(--accent-color, #4a9eff);
|
||||
}
|
||||
|
||||
.terminal-pane-b {
|
||||
/* flex-shrink 1, matching .terminal-wrap above — see its comment. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-pane-b-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.terminal-pane-b-close {
|
||||
/* A native <button> now backs this (keyboard-reachable close), so reset its
|
||||
default chrome back to the plain glyph this rule always drew. */
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0 6px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.terminal-pane-b-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.terminal-pane-b-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Split-picker menu: a small popover listing sessions to split with, appended
|
||||
to document.body and positioned `fixed` by openSplitPicker() (JS sets
|
||||
top/right against the Split header button's own rect). z-index above the
|
||||
header (100) with headroom to spare, matching the sibling .run-mode-menu
|
||||
dropdown's 1000. Dismissed by openSplitPane()'s own inline onclick, or by
|
||||
the click-outside/Escape listeners installed alongside it. */
|
||||
.split-picker-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
padding: 4px;
|
||||
background: var(--floating-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.split-picker-item {
|
||||
/* A native <button> now backs each row (keyboard-reachable picker), so
|
||||
reset its default chrome back to the plain list-row look this always
|
||||
drew — full-width, left-aligned, no border/background of its own. */
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.split-picker-item:hover {
|
||||
background: var(--control-bg-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.split-picker-empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
// src/web/public/terminal-split.js
|
||||
|
||||
/**
|
||||
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
||||
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
|
||||
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
|
||||
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
|
||||
* feature by nature — see docs/split-pane-sessions-plan.md.
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
||||
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
/**
|
||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
||||
* giant synchronous write that blocks the main thread while parsing a long
|
||||
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
||||
* (terminal-ui.js): that one is wired into session-switch generation
|
||||
* counters and the live-output gate this simpler, independently
|
||||
* created/destroyed pane has no equivalent of.
|
||||
*/
|
||||
function writeChunked(terminal, buffer, isDestroyed) {
|
||||
if (!buffer) return;
|
||||
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
||||
terminal.write(buffer);
|
||||
return;
|
||||
}
|
||||
let offset = 0;
|
||||
const writeNext = () => {
|
||||
if (isDestroyed() || !terminal) return;
|
||||
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
||||
offset += chunk.length;
|
||||
terminal.write(chunk);
|
||||
if (offset < buffer.length) {
|
||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
||||
else setTimeout(writeNext, 16);
|
||||
}
|
||||
};
|
||||
writeNext();
|
||||
}
|
||||
|
||||
class SplitTerminalPane {
|
||||
constructor(sessionId, mountEl, opts = {}) {
|
||||
this.sessionId = sessionId;
|
||||
this.mountEl = mountEl;
|
||||
this.sessionMode = opts.mode;
|
||||
this.fontSettings = opts.fontSettings || {};
|
||||
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
||||
// detaching this session AFTER the split is already open must still be
|
||||
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
||||
// fight the split picker already refuses to open at pick time.
|
||||
this.detachedSessions = opts.detachedSessions;
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.ws = null;
|
||||
this._wsReady = false;
|
||||
this._destroyed = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
||||
this.terminal = new Terminal({
|
||||
theme: { ...global.codemanCurrentXtermTheme() },
|
||||
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
||||
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
||||
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'block',
|
||||
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
scrollback: DEFAULT_SCROLLBACK,
|
||||
allowTransparency: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
this.fitAddon = new FitAddon.FitAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.open(this.mountEl);
|
||||
this.fitAddon.fit();
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Pane B has no gates of its own by default, so every app-level chord
|
||||
// that the document capture-phase handler (app.js) only preventDefault()s
|
||||
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
||||
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
||||
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
||||
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
||||
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
||||
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
|
||||
// registry-aware predicates so a rebind or a disable restores plain
|
||||
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
|
||||
// default (plain-text paste): Pane B has no image-paste trap to route it
|
||||
// to, so intercepting it here would only break paste.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||
if (
|
||||
ev.altKey &&
|
||||
!ev.ctrlKey &&
|
||||
!ev.shiftKey &&
|
||||
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
||||
// swallow — in a plain shell session this is the user's own
|
||||
// job-control tool and must reach the PTY, but in every other mode
|
||||
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
||||
// loop dead. Pane B has its own PTY/session and must not send a
|
||||
// suspend into a non-shell one just because the primary pane's own
|
||||
// gate lives elsewhere.
|
||||
if (
|
||||
ev.type === 'keydown' &&
|
||||
ev.key.toLowerCase() === 'z' &&
|
||||
ev.ctrlKey &&
|
||||
!ev.altKey &&
|
||||
!ev.metaKey &&
|
||||
!ev.shiftKey &&
|
||||
this.sessionMode !== 'shell'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
||||
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
||||
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
||||
// newline from a submit. Without this gate, Pane B's onData would
|
||||
// send that bare \r straight over the WS and submit an incomplete
|
||||
// prompt instead of adding a line to it. Targets THIS pane's own
|
||||
// session (this.sessionId), never the primary pane's
|
||||
// activeSessionId, and has no local-echo overlay of its own to flush
|
||||
// first (Pane B is deliberately plainer — see the fileoverview).
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
|
||||
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||
}).catch(() => {
|
||||
/* Best-effort, matching this pane's tolerance elsewhere. */
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
||||
// selection, Ctrl+C copies THIS pane's own selection instead of
|
||||
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
|
||||
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
|
||||
// explicit, never-falls-through copy chord, and the predicate above
|
||||
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
|
||||
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
|
||||
// a branch that assigns c.key only for a couple of special cases
|
||||
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
|
||||
// for Ctrl+Shift+C either way — this is not about an accidental
|
||||
// interrupt byte reaching the PTY (verified live: it does not).
|
||||
// Gating this whole block on hasSelection() (an earlier draft) meant
|
||||
// that with no selection Ctrl+Shift+C skipped straight to `return
|
||||
// true`, silently ceding the keystroke to the BROWSER's own handling
|
||||
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
|
||||
// attempt to copy, unlike Pane A, which always intercepts it.
|
||||
// Re-implemented against this.terminal rather than reusing
|
||||
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
|
||||
// and would copy the wrong pane's selection.
|
||||
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
||||
const raw = this.terminal?.getSelection?.() || '';
|
||||
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
||||
const selection = isColumnSelection ? raw : (global.CodemanCopySelection?.clean?.(raw) ?? raw);
|
||||
if (selection.trim()) {
|
||||
ev.preventDefault();
|
||||
void global.app._copyText?.(selection).then((ok) => {
|
||||
this.terminal?.clearSelection?.();
|
||||
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Nothing worth copying — clear for feedback (a padding-only
|
||||
// selection cleans to '' and this press still falls through to the
|
||||
// PTY as 0x03, matching the primary pane's own rule).
|
||||
if (this.terminal?.hasSelection?.()) {
|
||||
this.terminal.clearSelection?.();
|
||||
global.app.showToast?.('Nothing to copy', 'warning');
|
||||
}
|
||||
// Ctrl+Shift+C never falls through, even with nothing to copy —
|
||||
// matches terminal-ui.js's own ev.shiftKey branch.
|
||||
if (ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Load existing scrollback before going live. The WS below is
|
||||
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
||||
// 'terminal' events), so without this Pane B stays blank until the
|
||||
// target session happens to produce new output. It LOOKED
|
||||
// intermittent rather than always-broken because _sendResize() below
|
||||
// often nudges the shared session's real tmux window to a new size,
|
||||
// and tmux repaints its current screen on resize — that repaint was
|
||||
// getting captured and streamed here, incidentally populating the
|
||||
// pane. When Pane B's computed dimensions happened to already match
|
||||
// the session's last-known size, Session.resize() (session.ts) skips
|
||||
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
||||
await this._loadBuffer();
|
||||
if (this._destroyed) return;
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._wsReady = true;
|
||||
this._sendResize();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.t === 'o') {
|
||||
this.terminal.write(msg.d);
|
||||
} else if (msg.t === 'c') {
|
||||
this.terminal.clear();
|
||||
} else if (msg.t === 'r') {
|
||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
||||
// data was dropped). The primary pane routes this to
|
||||
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
|
||||
// buffer loader for the same reason connect() does.
|
||||
this.terminal.clear();
|
||||
void this._loadBuffer();
|
||||
}
|
||||
} catch {
|
||||
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
||||
}
|
||||
};
|
||||
|
||||
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
|
||||
// must go false on a drop or fit()/_sendResize() silently no-ops on a
|
||||
// closed socket per the WebSocket spec (no exception, no log). No
|
||||
// reconnect logic here — Pane B is deliberately plainer than the
|
||||
// primary pane (see the fileoverview above); a drop just stops
|
||||
// resizing until the parent recreates the pane. But onData already
|
||||
// silently drops keystrokes while _wsReady is false (below), so
|
||||
// without a visible marker a dropped socket left Pane B looking
|
||||
// normal while it quietly ate everything typed into it. v1 scope is
|
||||
// "say so", not reconnect — collapsing the split would lose the
|
||||
// user's place in Pane B's scrollback for a transient blip.
|
||||
this.ws.onclose = () => {
|
||||
this._wsReady = false;
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose fires after onerror — cleanup happens there.
|
||||
};
|
||||
}
|
||||
|
||||
// Fetches and writes the session's current scrollback. Used both by
|
||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
||||
// scoped to `this.activeSessionId` and clears/rewrites the primary
|
||||
// terminal, neither of which applies to this independent pane, so this is
|
||||
// a standalone equivalent rather than a call into it.
|
||||
//
|
||||
// Mirrors the primary pane's own mode check (app.js's selectSession /
|
||||
// _onSessionNeedsRefresh): a shell session can retain hundreds of
|
||||
// thousands of plain scrollback lines, so pulling `?full=1` there parses
|
||||
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
|
||||
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
|
||||
// get one full replay. `fetch` here goes through the global wrapper
|
||||
// (constants.js), which already prefixes CodemanBase — unlike the raw
|
||||
// WebSocket URL above, which does not.
|
||||
async _loadBuffer() {
|
||||
try {
|
||||
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
|
||||
const payload = (await res.json())?.data ?? {};
|
||||
if (payload.terminalBuffer && this.terminal) {
|
||||
writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
||||
}
|
||||
} catch {
|
||||
/* Best-effort — live output still arrives once the socket connects. */
|
||||
}
|
||||
}
|
||||
|
||||
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
||||
// can reflow both panes at the browser's paint rate (rAF) while sending
|
||||
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
||||
// pane's own convention (throttledResize in terminal-ui.js).
|
||||
localFit() {
|
||||
if (!this.fitAddon) return;
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
|
||||
fit() {
|
||||
this.localFit();
|
||||
this._sendResize();
|
||||
}
|
||||
|
||||
_sendResize() {
|
||||
if (!this._wsReady || !this.fitAddon) return;
|
||||
// One PTY cannot hold two sizes (mirrors sendResize's own
|
||||
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
||||
// to its own window AFTER this split was opened, so its own window now
|
||||
// owns the PTY's size and Pane B must stand aside.
|
||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
if (!dims) return;
|
||||
// Send the real proposed dimensions unclamped, matching the primary
|
||||
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
|
||||
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
||||
// A 40/10 floor here misreported Pane B's real width to the PTY at the
|
||||
// divider's own reachable 20% floor position, causing real
|
||||
// output-wrapping bugs.
|
||||
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._destroyed = true;
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
if (this.terminal) {
|
||||
this.terminal.dispose();
|
||||
this.terminal = null;
|
||||
}
|
||||
this.fitAddon = null;
|
||||
}
|
||||
}
|
||||
|
||||
global.SplitTerminalPane = SplitTerminalPane;
|
||||
})(window);
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
||||
* + matchMedia backstop: a JS width check (so openSplitPane() below can
|
||||
* refuse even if a click somehow reaches the button) plus a live listener,
|
||||
* because a window narrowed WHILE the button is showing must hide it
|
||||
* without waiting for a settings save or reload. The CSS `@media
|
||||
* (max-width: 1179px)` rule in styles.css is the backstop for the reverse
|
||||
* direction: it hides the button even if this JS never runs at all.
|
||||
*/
|
||||
_applySplitButtonVisibility(enabled) {
|
||||
this._splitButtonSettingEnabled = enabled;
|
||||
const splitBtn = document.querySelector('.btn-split');
|
||||
const wide = window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
|
||||
// Narrowing past the gate must not leave an open split on screen with no
|
||||
// way to reach the button that would close it — the two 240px min-widths
|
||||
// plus the divider overflow a narrow window and .main clips Pane B's edge.
|
||||
if (!wide && this._splitPane) this.closeSplitPane();
|
||||
if (!splitBtn) return;
|
||||
splitBtn.classList.toggle('btn-split--hidden', !enabled || !wide);
|
||||
if (!this._splitButtonWidthListenerInstalled && window.matchMedia) {
|
||||
this._splitButtonWidthListenerInstalled = true;
|
||||
const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`);
|
||||
mq.addEventListener('change', () => this._applySplitButtonVisibility(this._splitButtonSettingEnabled));
|
||||
}
|
||||
},
|
||||
|
||||
openSplitPicker(event) {
|
||||
// Mirrors toggleRunModeMenu (session-ui.js): stopPropagation on the
|
||||
// OPENING click so it never reaches the outside-click listener this
|
||||
// same call is about to register — without it, a click landing on the
|
||||
// button's own inner <svg> (matched by neither `menu.contains()` nor
|
||||
// the old exact-node check below) bubbled straight through to
|
||||
// `document` and self-closed the menu it just opened.
|
||||
event?.stopPropagation();
|
||||
if (this._splitPane) {
|
||||
this.closeSplitPane();
|
||||
return;
|
||||
}
|
||||
const candidates = window.CodemanSplitPane.buildSplitPickerSessions(
|
||||
this.sessions,
|
||||
this.sessionOrder,
|
||||
this.activeSessionId,
|
||||
this.detachedSessions
|
||||
);
|
||||
// Route a pre-existing menu through the SAME dismiss path used
|
||||
// everywhere else, instead of a raw `.remove()`: a genuinely still-open
|
||||
// menu has live document listeners (see below), and a raw removal left
|
||||
// them attached forever — only the single-slot field below got
|
||||
// overwritten, so every prior pair but the last was orphaned on
|
||||
// `document` with no way to ever find and remove it again.
|
||||
this._dismissSplitPicker();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'splitPickerMenu';
|
||||
menu.className = 'split-picker-menu';
|
||||
if (candidates.length === 0) {
|
||||
menu.innerHTML = '<div class="split-picker-empty">No other sessions to split with</div>';
|
||||
} else {
|
||||
menu.innerHTML = candidates
|
||||
.map(
|
||||
(c) =>
|
||||
// data-i18n-skip: the whole row's text IS a session name — i18n.js
|
||||
// does exact-string lookup over text nodes, and a session
|
||||
// literally named e.g. "Sessions" would otherwise get translated
|
||||
// on zh-CN (see the .session-name skip on the pane header below).
|
||||
`<button type="button" class="split-picker-item" data-i18n-skip data-session-id="${escapeHtml(c.id)}" onclick="app.openSplitPane(${escapeHtml(JSON.stringify(c.id))}); app._dismissSplitPicker();">${escapeHtml(c.label)}</button>`
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
const splitBtn = document.querySelector('.btn-split');
|
||||
if (splitBtn) {
|
||||
const rect = splitBtn.getBoundingClientRect();
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.top = `${rect.bottom + 4}px`;
|
||||
menu.style.right = `${window.innerWidth - rect.right}px`;
|
||||
}
|
||||
|
||||
// Dismiss on outside click or Escape — same one-shot listener pattern as
|
||||
// session-ui.js's other transient popovers (toggleCaseSettings(),
|
||||
// toggleRunModeMenu()). Deferred by a tick so the click that OPENED the
|
||||
// menu (still bubbling) doesn't immediately close it — reinforced by
|
||||
// the button's own stopPropagation() above, which is what actually
|
||||
// stops that same click reaching `document` at all. Picking an item
|
||||
// (above) calls the SAME dismiss method, so these listeners never
|
||||
// outlive the menu either way.
|
||||
//
|
||||
// Self-removing by identity: each handler removes ITSELF (and its
|
||||
// sibling) the moment it fires, rather than leaning solely on the
|
||||
// `this._splitPickerDismissHandlers` field. That field is still kept in
|
||||
// sync (so `_dismissSplitPicker()` called from elsewhere — the picker
|
||||
// item's onclick above, or a still-open menu at the top of this method
|
||||
// — can find and remove the CURRENT pair), but no path here can ever
|
||||
// again leave a pair attached to `document` with nothing referencing it.
|
||||
const closeOnOutsideClick = (e) => {
|
||||
if (menu.contains(e.target) || e.target.closest('.btn-split')) return;
|
||||
document.removeEventListener('click', closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
this._splitPickerDismissHandlers = null;
|
||||
menu.remove();
|
||||
};
|
||||
const closeOnEscape = (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
document.removeEventListener('click', closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
this._splitPickerDismissHandlers = null;
|
||||
menu.remove();
|
||||
};
|
||||
this._splitPickerDismissHandlers = { closeOnOutsideClick, closeOnEscape };
|
||||
setTimeout(() => document.addEventListener('click', closeOnOutsideClick), 0);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
},
|
||||
|
||||
_dismissSplitPicker() {
|
||||
document.getElementById('splitPickerMenu')?.remove();
|
||||
if (this._splitPickerDismissHandlers) {
|
||||
document.removeEventListener('click', this._splitPickerDismissHandlers.closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', this._splitPickerDismissHandlers.closeOnEscape);
|
||||
this._splitPickerDismissHandlers = null;
|
||||
}
|
||||
},
|
||||
|
||||
openSplitPane(sessionId) {
|
||||
// Desktop-only hard gate, independent of the button's own hidden state —
|
||||
// see _applySplitButtonVisibility's comment for why both a JS check and
|
||||
// a CSS backstop exist.
|
||||
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
||||
// No active session means there is no `.terminal-wrap` to split against
|
||||
// (the welcome overlay is showing) — without this, a split opened from
|
||||
// the home screen still created the container and connected Pane B, just
|
||||
// behind the opaque overlay with nothing visible to show for it.
|
||||
if (!this.activeSessionId) return;
|
||||
// A web tab hides `.terminal-wrap`'s container via CSS with nothing
|
||||
// gating the button itself, and `activeSessionId` survives openWebview()
|
||||
// — without this, picking a session opens Pane B's socket behind a
|
||||
// hidden container with nothing on screen to show for it.
|
||||
if (this.activeWebviewId) return;
|
||||
// A stale picker click (opened before switching tabs) or clicking Pane
|
||||
// B's own session tab while split can otherwise land here with
|
||||
// sessionId === activeSessionId: two live WebSockets to the same
|
||||
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
||||
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
|
||||
if (sessionId === this.activeSessionId) return;
|
||||
if (this._splitPane) this.closeSplitPane();
|
||||
|
||||
const wrap = document.querySelector('.terminal-wrap');
|
||||
const parent = wrap.parentElement;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'terminal-split-container';
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'split-divider';
|
||||
|
||||
const paneB = document.createElement('div');
|
||||
paneB.className = 'terminal-pane-b';
|
||||
const session = this.sessions.get(sessionId);
|
||||
paneB.innerHTML = `
|
||||
<div class="terminal-pane-b-header">
|
||||
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
|
||||
<button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">×</button>
|
||||
</div>
|
||||
<div class="terminal-pane-b-container"></div>
|
||||
`;
|
||||
|
||||
parent.insertBefore(container, wrap);
|
||||
container.appendChild(wrap);
|
||||
wrap.style.flexBasis = '50%';
|
||||
container.appendChild(divider);
|
||||
container.appendChild(paneB);
|
||||
paneB.style.flexBasis = '50%';
|
||||
|
||||
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
|
||||
mode: session?.mode,
|
||||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||||
detachedSessions: this.detachedSessions,
|
||||
});
|
||||
this._splitPane.connect().catch(() => {
|
||||
/* Best-effort, matching the primary pane's own tolerance for a failed
|
||||
initial load — live output still arrives once/if the socket connects. */
|
||||
});
|
||||
this._splitSessionId = sessionId;
|
||||
|
||||
// Pane A just went from full width to 50%, but nothing has told its
|
||||
// session's PTY/tmux window about it yet — the passive ResizeObserver in
|
||||
// terminal-ui.js debounces 300ms and would eventually catch up, but
|
||||
// relying on that left the pane showing stale-width content (existing
|
||||
// box-drawing lines, banners) until the user hit "Redraw Terminal".
|
||||
// Force it immediately, mirroring closeSplitPane()'s symmetric call.
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
|
||||
this._installSplitDividerDrag(divider, wrap, paneB);
|
||||
this._updateSplitButtonState(true);
|
||||
},
|
||||
|
||||
closeSplitPane() {
|
||||
if (!this._splitPane) return;
|
||||
this._splitPane.destroy();
|
||||
this._splitPane = null;
|
||||
this._splitSessionId = null;
|
||||
this._updateSplitButtonState(false);
|
||||
|
||||
const container = document.querySelector('.terminal-split-container');
|
||||
if (!container) return;
|
||||
const wrap = container.querySelector('.terminal-wrap');
|
||||
const parent = container.parentElement;
|
||||
wrap.style.flexBasis = '';
|
||||
parent.insertBefore(wrap, container);
|
||||
container.remove();
|
||||
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
},
|
||||
|
||||
// A click on .btn-split does one of two things — open the picker, or
|
||||
// (openSplitPicker's own early return) close an already-open split — and
|
||||
// nothing on the button said which. `.split-open` + aria-pressed give it
|
||||
// the same active-state language as the codebase's other toggle buttons
|
||||
// (keyboard-accessory's Ctrl key, the voice-input mic).
|
||||
_updateSplitButtonState(open) {
|
||||
const btn = document.querySelector('.btn-split');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('split-open', open);
|
||||
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
||||
const title = open ? 'Split: close the second session' : 'Split: open a second session beside this one';
|
||||
btn.title = title;
|
||||
btn.setAttribute('aria-label', title);
|
||||
},
|
||||
|
||||
_installSplitDividerDrag(divider, wrap, paneB) {
|
||||
let dragging = false;
|
||||
let dragRaf = null;
|
||||
let pendingClientX = null;
|
||||
|
||||
// Local-only reflow (flexBasis + both panes' xterm fit, no PTY resize
|
||||
// frame). Coalesced to one call per animation frame below — a raw
|
||||
// mousemove stream fires far faster than the browser repaints, and
|
||||
// without the rAF gate each event did a full xterm reflow on BOTH
|
||||
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
|
||||
// no client-side "dims unchanged" skip), which fanned out into a
|
||||
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
||||
// of each dragging across half a wide viewport.
|
||||
const applyDragPercent = (clientX) => {
|
||||
const container = divider.parentElement;
|
||||
// The split can auto-collapse mid-drag (the other pane's session
|
||||
// ending, or the picker's own close button) — closeSplitPane() removes
|
||||
// `.terminal-split-container` from the DOM, which detaches `divider`
|
||||
// too, so `divider.parentElement` is null on the very next frame and
|
||||
// every drag threw here until mouseup finally removed the listener.
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const rawPercent = ((clientX - rect.left) / rect.width) * 100;
|
||||
const percent = window.CodemanSplitPane.clampDividerPercent(rawPercent);
|
||||
wrap.style.flexBasis = `${percent}%`;
|
||||
paneB.style.flexBasis = `${100 - percent}%`;
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
this._splitPane?.localFit();
|
||||
};
|
||||
|
||||
const onMove = (e) => {
|
||||
if (!dragging) return;
|
||||
pendingClientX = e.clientX;
|
||||
if (dragRaf) return;
|
||||
dragRaf = requestAnimationFrame(() => {
|
||||
dragRaf = null;
|
||||
applyDragPercent(pendingClientX);
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = (e) => {
|
||||
dragging = false;
|
||||
divider.classList.remove('dragging');
|
||||
document.body.classList.remove('split-pane-resizing');
|
||||
try {
|
||||
divider.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* Already released (pointercancel/lostpointercapture beat us here). */
|
||||
}
|
||||
divider.removeEventListener('pointermove', onMove);
|
||||
divider.removeEventListener('pointerup', onUp);
|
||||
divider.removeEventListener('pointercancel', onUp);
|
||||
if (dragRaf) {
|
||||
cancelAnimationFrame(dragRaf);
|
||||
dragRaf = null;
|
||||
applyDragPercent(pendingClientX);
|
||||
}
|
||||
// Send the real PTY resize exactly once here, at drag end, for BOTH
|
||||
// panes — never per-move (matching the codebase's established
|
||||
// trailing-edge debounce convention, see throttledResize in
|
||||
// terminal-ui.js) so a fast drag doesn't flood dozens of intermediate
|
||||
// SIGWINCH/reflow states into scrollback or spawn a `tmux
|
||||
// resize-window` child per frame.
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
this._splitPane?.fit();
|
||||
};
|
||||
|
||||
// Pointer events + setPointerCapture (mirrors tab-rail-resize.js) instead
|
||||
// of mousedown/document-level mousemove: a plain mousedown drag selects
|
||||
// the text under the cursor as it crosses both terminals, and pointer
|
||||
// capture routes move/up straight to `divider` regardless of what's under
|
||||
// the cursor mid-drag, so no document-level listener leak is possible if
|
||||
// the pointer is released off-window. `body.split-pane-resizing` (mirrors
|
||||
// `body.tab-rail-resizing`) locks the cursor/selection for the drag.
|
||||
divider.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
divider.classList.add('dragging');
|
||||
document.body.classList.add('split-pane-resizing');
|
||||
try {
|
||||
divider.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* Capture failed — the drag still works via the listeners below. */
|
||||
}
|
||||
divider.addEventListener('pointermove', onMove);
|
||||
divider.addEventListener('pointerup', onUp);
|
||||
divider.addEventListener('pointercancel', onUp);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const _originalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted;
|
||||
CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||
if (this._splitSessionId === data.id) {
|
||||
this.closeSplitPane();
|
||||
} else if (this._splitPane && this.activeSessionId === data.id) {
|
||||
// Pane A's session ended: promote Pane B by closing the split and
|
||||
// selecting its session as the new (single) active pane. This is an
|
||||
// app-driven selection, not the user clicking a tab, so it must not
|
||||
// spend the promoted session's idle alert (see the Approvals Inbox
|
||||
// acknowledgement rule in CLAUDE.md — only a human opening a session
|
||||
// acknowledges it).
|
||||
const promoted = this._splitSessionId;
|
||||
this.closeSplitPane();
|
||||
// Closing Pane A's own tab (closeSession(), app.js) adds data.id to
|
||||
// _closingSessions BEFORE awaiting the delete, then owns the follow-up
|
||||
// selection itself once the delete lands — same race _onSessionDeleted's
|
||||
// own active-session handoff guards against (see its comment). Selecting
|
||||
// here too would fight it for which tab wins.
|
||||
if (promoted && !this._closingSessions.has(data.id)) {
|
||||
this.selectSession(promoted, { auto: true });
|
||||
}
|
||||
}
|
||||
return _originalOnSessionDeleted.call(this, data);
|
||||
};
|
||||
|
||||
// I2: closes an active split BEFORE the primary pane rebinds to the same
|
||||
// session Pane B is showing (clicking Pane B's own session tab while split,
|
||||
// or any other selectSession() call that targets _splitSessionId). Without
|
||||
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
|
||||
// still attached to — two live WebSockets to one session, each claiming PTY
|
||||
// dimensions via its own `{t:'z',...}` resize frame.
|
||||
const _originalSelectSession = CodemanApp.prototype.selectSession;
|
||||
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
|
||||
if (this._splitPane && this._splitSessionId === sessionId) {
|
||||
this.closeSplitPane();
|
||||
}
|
||||
return _originalSelectSession.call(this, sessionId, ...args);
|
||||
};
|
||||
@@ -1092,6 +1092,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this._localEchoOverlay?.hasPending) {
|
||||
this._localEchoOverlay.rerender();
|
||||
}
|
||||
// Pane B (split view) has its own container and its own fit()/resize
|
||||
// frame — this observer only ever measured Pane A's container, so
|
||||
// without this call Pane B never learned about a window resize, an
|
||||
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
||||
// stayed at whatever size it was last dragged to.
|
||||
this._splitPane?.fit();
|
||||
}, 300); // Trailing-edge: only fire after 300ms of no resize events
|
||||
};
|
||||
|
||||
@@ -5124,6 +5130,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Update overlay font cache and re-render at new cell dimensions
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontSize = size;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5149,6 +5159,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.fitAddon?.fit();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontFamily = resolved;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5200,6 +5214,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
/* pane not laid out yet — its own resize observer refits it */
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontWeight = fontWeight;
|
||||
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
loadFontSize() {
|
||||
@@ -5420,6 +5439,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
this._splitPane.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
|
||||
} catch {}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -99,7 +99,10 @@ describe('App Settings modal structure', () => {
|
||||
for (const [, attrs, body] of previewed) {
|
||||
const kind = attrs.match(/data-preview="([a-z]+)"/)?.[1];
|
||||
expect(['header', 'panel', 'toolbar', 'float']).toContain(kind);
|
||||
expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+"/);
|
||||
// A decimal (e.g. "11.5") is allowed — Split sits between Multi-monitor
|
||||
// (11) and Ultracode Agents (12) in the real header, and Number()
|
||||
// parses it fine for the preview's own sort.
|
||||
expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+(\.\d+)?"/);
|
||||
// A text token replaces the icon for readouts (plan usage, CPU, font size).
|
||||
const hasIcon = body.includes('class="set-chip-ico') || attrs.includes('data-preview-text=');
|
||||
expect(hasIcon, `chip ${body} has nothing to render in the preview`).toBe(true);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// test/split-pane-auto-collapse-unit.test.ts
|
||||
// Port: N/A (no server/browser — loaded via `vm`, like session-close-fallback.test.ts).
|
||||
//
|
||||
// Fast, CI-visible unit coverage for the `_onSessionDeleted` prototype patch in
|
||||
// terminal-split.js (whole-branch review finding I6). The "Pane B ends" branch
|
||||
// already has real-Chromium coverage in test/split-pane-auto-collapse.browser.test.ts,
|
||||
// but that suite is excluded from `npm test` (see Testing in CLAUDE.md), and the
|
||||
// "Pane A ends, Pane B gets promoted" branch had NO coverage anywhere — it is the
|
||||
// one whose correctness depends on exact ordering: `_splitSessionId` must be
|
||||
// captured BEFORE `closeSplitPane()` runs (which nulls it), or the promoted
|
||||
// session id is lost. This file pins that ordering plus the sibling branches
|
||||
// (Pane B ends, unrelated session ends) so a regression fails in the normal CI
|
||||
// gate rather than only in the browser suite nobody runs by default.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const terminalSplitSrc = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
window: {},
|
||||
});
|
||||
// A minimal fake CodemanApp — terminal-split.js only needs `_onSessionDeleted`
|
||||
// and `selectSession` to already exist on the prototype (it wraps both), and
|
||||
// neither wrapped body executes at module-load time (only inside method
|
||||
// calls), so no xterm/WebSocket/CodemanSplitPane globals are needed here.
|
||||
const fakeAppSrc = `
|
||||
class CodemanApp {
|
||||
_onSessionDeleted(data) {
|
||||
(this.__originalDeletedCalls ??= []).push(data);
|
||||
}
|
||||
selectSession(id) {
|
||||
(this.__originalSelectSessionCalls ??= []).push(id);
|
||||
}
|
||||
}
|
||||
`;
|
||||
vm.runInContext(`${fakeAppSrc}\n${terminalSplitSrc}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return (context as { __CodemanApp: new () => unknown }).__CodemanApp as {
|
||||
prototype: {
|
||||
_onSessionDeleted: (this: unknown, data: { id: string }) => unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
|
||||
type TestApp = {
|
||||
activeSessionId: string | null;
|
||||
_splitSessionId: string | null;
|
||||
_splitPane: { destroy: ReturnType<typeof vi.fn> } | null;
|
||||
_closingSessions: Set<string>;
|
||||
closeSplitPane: ReturnType<typeof vi.fn>;
|
||||
selectSession: ReturnType<typeof vi.fn>;
|
||||
__originalDeletedCalls?: Array<{ id: string }>;
|
||||
};
|
||||
|
||||
/** A split-active instance: Pane A === activeSessionId, Pane B === _splitSessionId. */
|
||||
function makeSplitActiveApp(): TestApp {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as TestApp;
|
||||
app.activeSessionId = 'session-a';
|
||||
app._splitSessionId = 'session-b';
|
||||
app._splitPane = { destroy: vi.fn() };
|
||||
// Empty by default: the app's OWN closeSession() is not mid-await for this
|
||||
// delete, so the promotion below is expected to fire. See the dedicated
|
||||
// test further down for the non-empty (_closingSessions owns it) case.
|
||||
app._closingSessions = new Set();
|
||||
// closeSplitPane is mocked but mirrors the REAL implementation's one
|
||||
// observable side effect relevant here: it nulls _splitPane/_splitSessionId.
|
||||
// If the wrapper captured _splitSessionId AFTER calling closeSplitPane
|
||||
// instead of before, this would surface as selectSession(undefined) below.
|
||||
app.closeSplitPane = vi.fn(() => {
|
||||
app._splitPane = null;
|
||||
app._splitSessionId = null;
|
||||
});
|
||||
app.selectSession = vi.fn();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('terminal-split.js _onSessionDeleted wrapper (I6)', () => {
|
||||
it('Pane A ends: closes the split and promotes Pane B via selectSession(ORIGINAL splitSessionId)', () => {
|
||||
const app = makeSplitActiveApp();
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-a' });
|
||||
|
||||
expect(app.closeSplitPane).toHaveBeenCalledTimes(1);
|
||||
// Pinned ordering: selectSession must receive the id _splitSessionId held
|
||||
// BEFORE closeSplitPane ran (which nulls it), not whatever it holds after.
|
||||
// { auto: true } because this is an app-driven promotion, not the user
|
||||
// clicking a tab — it must not spend the promoted session's idle alert.
|
||||
expect(app.selectSession).toHaveBeenCalledWith('session-b', { auto: true });
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-a' }]);
|
||||
});
|
||||
|
||||
it('Pane B ends: closes the split without promoting anything', () => {
|
||||
const app = makeSplitActiveApp();
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-b' });
|
||||
|
||||
expect(app.closeSplitPane).toHaveBeenCalledTimes(1);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-b' }]);
|
||||
});
|
||||
|
||||
it('an unrelated session ending leaves the split untouched', () => {
|
||||
const app = makeSplitActiveApp();
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-c' });
|
||||
|
||||
expect(app.closeSplitPane).not.toHaveBeenCalled();
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).not.toBeNull();
|
||||
expect(app._splitSessionId).toBe('session-b');
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-c' }]);
|
||||
});
|
||||
|
||||
it('the original _onSessionDeleted always fires, split-active or not', () => {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as TestApp;
|
||||
app.activeSessionId = 'session-a';
|
||||
app._splitSessionId = null;
|
||||
app._splitPane = null;
|
||||
app._closingSessions = new Set();
|
||||
app.closeSplitPane = vi.fn();
|
||||
app.selectSession = vi.fn();
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-a' });
|
||||
|
||||
expect(app.closeSplitPane).not.toHaveBeenCalled();
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-a' }]);
|
||||
});
|
||||
|
||||
it('Pane A ends via the user closing its OWN tab: still collapses the split, but skips the promotion', () => {
|
||||
// closeSession() (app.js) adds the id to _closingSessions BEFORE awaiting
|
||||
// the delete, then owns the follow-up selection itself once it lands —
|
||||
// selecting Pane B's session here too would race it for which tab wins.
|
||||
const app = makeSplitActiveApp();
|
||||
app._closingSessions.add('session-a');
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-a' });
|
||||
|
||||
expect(app.closeSplitPane).toHaveBeenCalledTimes(1);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-a' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// test/split-pane-auto-collapse.browser.test.ts
|
||||
/** @fileoverview Real Chromium coverage for split auto-collapse when either session ends (Task 6). */
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3177;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('split-pane auto-collapse in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
async function createShellSession(): Promise<string> {
|
||||
return page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
// POST /api/sessions nests the session under data.session, and mode:'shell'
|
||||
// does not spawn a PTY on creation alone (pid: null, no pane) — an explicit
|
||||
// POST .../shell is what actually starts it (both found and fixed by Task 4's
|
||||
// implementer against this exact pattern; carried forward here so this task
|
||||
// does not rediscover the same two bugs).
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
it('deleting the Pane B session auto-collapses the split', async () => {
|
||||
const idA = await createShellSession();
|
||||
const idB = await createShellSession();
|
||||
|
||||
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
|
||||
|
||||
// Delete Pane B's session from "outside" (simulating the SSE event another
|
||||
// client's delete would produce, by hitting the DELETE route directly).
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, idB);
|
||||
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const collapsed = await page.evaluate(() => ({
|
||||
hasContainer: document.querySelector('.terminal-split-container') === null,
|
||||
splitPaneNulled: (window as any).app._splitPane === null,
|
||||
}));
|
||||
expect(collapsed.hasContainer).toBe(true);
|
||||
expect(collapsed.splitPaneNulled).toBe(true);
|
||||
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, idA);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function loadSplitPaneHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanSplitPane: any }).CodemanSplitPane;
|
||||
}
|
||||
|
||||
describe('CodemanSplitPane.clampDividerPercent', () => {
|
||||
it('passes through a value inside the clamp range', () => {
|
||||
const { clampDividerPercent } = loadSplitPaneHelper();
|
||||
expect(clampDividerPercent(50)).toBe(50);
|
||||
expect(clampDividerPercent(35.5)).toBe(35.5);
|
||||
});
|
||||
|
||||
it('clamps below the floor to the floor', () => {
|
||||
const { clampDividerPercent } = loadSplitPaneHelper();
|
||||
expect(clampDividerPercent(5)).toBe(20);
|
||||
});
|
||||
|
||||
it('clamps above the ceiling to the ceiling', () => {
|
||||
const { clampDividerPercent } = loadSplitPaneHelper();
|
||||
expect(clampDividerPercent(95)).toBe(80);
|
||||
});
|
||||
|
||||
it('honors custom min/max', () => {
|
||||
const { clampDividerPercent } = loadSplitPaneHelper();
|
||||
expect(clampDividerPercent(10, 15, 85)).toBe(15);
|
||||
expect(clampDividerPercent(90, 15, 85)).toBe(85);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodemanSplitPane.buildSplitPickerSessions', () => {
|
||||
it('excludes the active session and preserves tab order', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([
|
||||
['a', { name: 'w1-codeman' }],
|
||||
['b', { name: 'w1-mcp-memory' }],
|
||||
['c', { name: null }],
|
||||
]);
|
||||
const sessionOrder = ['a', 'b', 'c'];
|
||||
const result = buildSplitPickerSessions(sessions, sessionOrder, 'a');
|
||||
expect(result).toEqual([
|
||||
{ id: 'b', label: 'w1-mcp-memory' },
|
||||
{ id: 'c', label: 'Session' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops order entries with no matching session (stale ids)', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([['a', { name: 'w1-codeman' }]]);
|
||||
const sessionOrder = ['a', 'ghost'];
|
||||
const result = buildSplitPickerSessions(sessions, sessionOrder, null);
|
||||
expect(result).toEqual([{ id: 'a', label: 'w1-codeman' }]);
|
||||
});
|
||||
|
||||
it('returns an empty list when only the excluded session exists', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([['a', { name: 'w1-codeman' }]]);
|
||||
const result = buildSplitPickerSessions(sessions, ['a'], 'a');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes a session with no PTY attached (pid === null)', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([
|
||||
['a', { name: 'w1-codeman' }],
|
||||
['b', { name: 'w2-exited', pid: null }],
|
||||
['c', { name: 'w3-alive', pid: 12345 }],
|
||||
]);
|
||||
const result = buildSplitPickerSessions(sessions, ['a', 'b', 'c'], 'a');
|
||||
expect(result).toEqual([{ id: 'c', label: 'w3-alive' }]);
|
||||
});
|
||||
|
||||
it('excludes a detached session even when it also has no PTY', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([
|
||||
['a', { name: 'w1-codeman' }],
|
||||
['b', { name: 'w2-detached', pid: null }],
|
||||
]);
|
||||
const result = buildSplitPickerSessions(sessions, ['a', 'b'], 'a', new Set(['b']));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// test/split-pane-hidden-button-css.test.ts
|
||||
// Port: none (pure static analysis — runs in CI, no browser/server).
|
||||
//
|
||||
// Regression guard for the split-pane whole-branch review finding C1: the
|
||||
// header ships `.btn-split.btn-split--hidden` in index.html (an opt-in
|
||||
// header button, gated behind `showSplitButton`), but no CSS anywhere gave
|
||||
// `--hidden` markers meaning for that class, so the Split button rendered
|
||||
// VISIBLE to every user on every viewport regardless of the setting.
|
||||
//
|
||||
// Every OTHER opt-in header button follows a marker-class pattern: the base
|
||||
// rule is `display:inline-flex !important` and a more-specific
|
||||
// `.btn-x.btn-x--hidden { display: none !important; }` rule hides it
|
||||
// (`.btn-multimonitor--hidden` etc. in styles.css). C1 fixed the missing rule
|
||||
// for `.btn-split--hidden`; this test is the guard so the NEXT such class
|
||||
// fails loudly here instead of shipping invisible-until-noticed, the same
|
||||
// static-parse shape as test/mobile-header-buttons-policy.test.ts (read
|
||||
// first for the parsing conventions this file reuses).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PUBLIC = join(HERE, '../src/web/public');
|
||||
|
||||
/** Every distinct `*--hidden` class token referenced anywhere in index.html. */
|
||||
function loadHiddenMarkerClasses(): Set<string> {
|
||||
const html = readFileSync(join(PUBLIC, 'index.html'), 'utf-8');
|
||||
const classes = new Set<string>();
|
||||
for (const m of html.matchAll(/class="([^"]*)"/g)) {
|
||||
for (const token of m[1].split(/\s+/)) {
|
||||
if (token.endsWith('--hidden')) classes.add(token);
|
||||
}
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `*--hidden` class that has a CSS rule (anywhere — top-level or inside
|
||||
* any at-rule, e.g. a phone-only @media block) whose selector targets that
|
||||
* exact class and whose declarations set `display: none` (with or without
|
||||
* `!important`).
|
||||
*/
|
||||
function loadCssHiddenClasses(cssFile: string): Set<string> {
|
||||
const css = readFileSync(join(PUBLIC, cssFile), 'utf-8');
|
||||
const hidden = new Set<string>();
|
||||
postcss.parse(css).walkRules((rule) => {
|
||||
let hides = false;
|
||||
rule.walkDecls('display', (decl) => {
|
||||
if (decl.value.replace(/!important/i, '').trim() === 'none') hides = true;
|
||||
});
|
||||
if (!hides) return;
|
||||
for (const token of rule.selector.match(/\.[a-z0-9-]*--hidden\b/gi) || []) {
|
||||
hidden.add(token.slice(1));
|
||||
}
|
||||
});
|
||||
return hidden;
|
||||
}
|
||||
|
||||
describe('Every "*--hidden" marker class has a matching CSS hide rule (static guard)', () => {
|
||||
const markerClasses = loadHiddenMarkerClasses();
|
||||
const cssHidden = new Set([...loadCssHiddenClasses('styles.css'), ...loadCssHiddenClasses('mobile.css')]);
|
||||
|
||||
it('finds at least one *--hidden marker class in index.html (sanity)', () => {
|
||||
// If this drops to 0 the parser/markup drifted — fix the parser, don't delete the test.
|
||||
expect(markerClasses.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every "*--hidden" class in index.html has a `display: none` rule in styles.css or mobile.css', () => {
|
||||
for (const cls of markerClasses) {
|
||||
expect(
|
||||
cssHidden.has(cls),
|
||||
`index.html references class "${cls}" (an opt-in-hide marker) but no rule in styles.css or ` +
|
||||
`mobile.css sets "display: none" for it — the element it marks ships VISIBLE regardless of the ` +
|
||||
`setting that is supposed to gate it. Add ".${cls} { display: none !important; }" (see the sibling ` +
|
||||
`.btn-multimonitor--hidden / .btn-redraw-terminal--hidden rules in styles.css for the pattern).`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('locks the split-pane Split button specifically (C1 regression)', () => {
|
||||
expect(markerClasses.has('btn-split--hidden')).toBe(true);
|
||||
expect(cssHidden.has('btn-split--hidden')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/** @fileoverview Real Chromium coverage for split open/close orchestration and the session picker (Task 5). */
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3176;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('split-pane orchestration in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
async function createShellSession(): Promise<string> {
|
||||
return page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
// POST /api/sessions nests the session under data.session, and mode:'shell'
|
||||
// does not spawn a PTY on creation alone (pid: null, no pane) — an explicit
|
||||
// POST .../shell is what actually starts it (both found and fixed by Task 4's
|
||||
// implementer against this exact pattern; carried forward here so this task
|
||||
// does not rediscover the same two bugs).
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
it('opening and closing a split reparents and restores .terminal-wrap', async () => {
|
||||
const idA = await createShellSession();
|
||||
const idB = await createShellSession();
|
||||
|
||||
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||
|
||||
expect(await page.evaluate(() => document.querySelector('.terminal-split-container') === null)).toBe(true);
|
||||
|
||||
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
|
||||
|
||||
const duringSplit = await page.evaluate(() => ({
|
||||
hasContainer: document.querySelector('.terminal-split-container') !== null,
|
||||
wrapIsChildOfContainer: document.querySelector('.terminal-split-container > .terminal-wrap') !== null,
|
||||
hasPaneB: document.querySelector('.terminal-pane-b') !== null,
|
||||
}));
|
||||
expect(duringSplit.hasContainer).toBe(true);
|
||||
expect(duringSplit.wrapIsChildOfContainer).toBe(true);
|
||||
expect(duringSplit.hasPaneB).toBe(true);
|
||||
|
||||
await page.evaluate(() => (window as any).app.closeSplitPane());
|
||||
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const afterClose = await page.evaluate(() => ({
|
||||
hasContainer: document.querySelector('.terminal-split-container') === null,
|
||||
wrapRestored: document.querySelector('.main .terminal-wrap') !== null,
|
||||
}));
|
||||
expect(afterClose.hasContainer).toBe(true);
|
||||
expect(afterClose.wrapRestored).toBe(true);
|
||||
|
||||
await page.evaluate(
|
||||
async (ids) => {
|
||||
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
|
||||
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
|
||||
},
|
||||
{ a: idA, b: idB }
|
||||
);
|
||||
});
|
||||
|
||||
it('the split picker excludes the active session', async () => {
|
||||
const id = await createShellSession();
|
||||
|
||||
await page.evaluate((sid) => (window as any).app.selectSession(sid), id);
|
||||
await page.waitForFunction((sid) => (window as any).app.activeSessionId === sid, id, { timeout: 10000 });
|
||||
|
||||
const pickerExcludesActive = await page.evaluate((sid) => {
|
||||
(window as any).app.openSplitPicker();
|
||||
const items = Array.from(document.querySelectorAll('.split-picker-item'));
|
||||
return !items.some((el) => el.getAttribute('data-session-id') === sid);
|
||||
}, id);
|
||||
expect(pickerExcludesActive).toBe(true);
|
||||
|
||||
await page.evaluate(async (sid) => {
|
||||
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
|
||||
}, id);
|
||||
});
|
||||
|
||||
it('force-resizes Pane A immediately when a split opens', async () => {
|
||||
// Regression guard: opening a split moved Pane A from full width to 50%
|
||||
// in the DOM, but nothing told its session's PTY/tmux window about the
|
||||
// new size — only the passive, 300ms-debounced ResizeObserver in
|
||||
// terminal-ui.js eventually caught up, leaving stale-width content on
|
||||
// screen until the user manually hit "Redraw Terminal". openSplitPane()
|
||||
// now force-resizes Pane A synchronously as part of the same call.
|
||||
const idA = await createShellSession();
|
||||
const idB = await createShellSession();
|
||||
|
||||
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window as any as { app: any };
|
||||
(window as any).__resizeCalls = [];
|
||||
(window as any).__origSendResize = (window as any).app.sendResize;
|
||||
(window as any).app.sendResize = function (...args: any[]) {
|
||||
(window as any).__resizeCalls.push(args);
|
||||
return (window as any).__origSendResize.apply(app.app, args);
|
||||
};
|
||||
});
|
||||
|
||||
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
|
||||
|
||||
const forcedResize = await page.evaluate(
|
||||
(id) =>
|
||||
((window as any).__resizeCalls as Array<[string, { force?: boolean }]>).some(
|
||||
([sessionId, opts]) => sessionId === id && opts?.force === true
|
||||
),
|
||||
idA
|
||||
);
|
||||
expect(forcedResize).toBe(true);
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).app.sendResize = (window as any).__origSendResize;
|
||||
});
|
||||
await page.evaluate(() => (window as any).app.closeSplitPane());
|
||||
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
async (ids) => {
|
||||
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
|
||||
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
|
||||
},
|
||||
{ a: idA, b: idB }
|
||||
);
|
||||
});
|
||||
|
||||
it('force-resizes Pane A once at the end of a divider drag', async () => {
|
||||
// Regression guard: the divider's onMove handler only called
|
||||
// fitAddon.fit() for Pane A — a LOCAL xterm reflow that never told Pane
|
||||
// A's own PTY/tmux window the new size, so existing content stayed laid
|
||||
// out for the pre-drag width. onUp now force-resizes Pane A once, at
|
||||
// drag end (not per-move, to avoid flooding the PTY with SIGWINCHes
|
||||
// during a fast drag).
|
||||
const idA = await createShellSession();
|
||||
const idB = await createShellSession();
|
||||
|
||||
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||
await page.waitForSelector('.split-divider', { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window as any as { app: any };
|
||||
(window as any).__resizeCalls = [];
|
||||
(window as any).__origSendResize = (window as any).app.sendResize;
|
||||
(window as any).app.sendResize = function (...args: any[]) {
|
||||
(window as any).__resizeCalls.push(args);
|
||||
return (window as any).__origSendResize.apply(app.app, args);
|
||||
};
|
||||
});
|
||||
|
||||
const divider = await page.$('.split-divider');
|
||||
const box = await divider!.boundingBox();
|
||||
if (!box) throw new Error('divider has no bounding box');
|
||||
const startX = box.x + box.width / 2;
|
||||
const startY = box.y + box.height / 2;
|
||||
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX + 80, startY, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
|
||||
const forcedResize = await page.evaluate(
|
||||
(id) =>
|
||||
((window as any).__resizeCalls as Array<[string, { force?: boolean }]>).some(
|
||||
([sessionId, opts]) => sessionId === id && opts?.force === true
|
||||
),
|
||||
idA
|
||||
);
|
||||
expect(forcedResize).toBe(true);
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).app.sendResize = (window as any).__origSendResize;
|
||||
});
|
||||
await page.evaluate(() => (window as any).app.closeSplitPane());
|
||||
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
async (ids) => {
|
||||
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
|
||||
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
|
||||
},
|
||||
{ a: idA, b: idB }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// test/split-pane-per-device-setting.test.ts
|
||||
// Port: none (pure static analysis — runs in CI, no browser/server).
|
||||
//
|
||||
// Regression guard for the review finding that landed the blocker: moving
|
||||
// showSplitButton into settings-ui.js's per-device `displayKeys` set is only
|
||||
// HALF of making a setting per-device. The other half is stripping it out of
|
||||
// the object `saveAppSettings()` PUTs to `/api/settings` — displayKeys is a
|
||||
// client-side merge policy, not a wire filter. Without the strip, every save
|
||||
// sent `showSplitButton` in the body, `SettingsUpdateSchema` (.strict()) does
|
||||
// not declare it, the server answered 400 INVALID_INPUT, and because the
|
||||
// call site never checked `res.ok` the UI still reported "Settings saved"
|
||||
// while NOTHING persisted — workspaceHooksEnabled, agentSkillEnabled,
|
||||
// tunnelEnabled, claudeModel, every toggle, on every save, on every device.
|
||||
//
|
||||
// Mirrors test/terminal-auto-copy.test.ts's "keeps the toggle per-device"
|
||||
// guard for autoCopySelection — same three-way rule, same shape of test.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PUBLIC = join(HERE, '../src/web/public');
|
||||
|
||||
function read(file: string): string {
|
||||
return readFileSync(join(PUBLIC, file), 'utf8');
|
||||
}
|
||||
|
||||
describe('showSplitButton stays per-device: display key, stripped from the PUT, absent from the schema', () => {
|
||||
const settingsUi = read('settings-ui.js');
|
||||
const schemas = readFileSync(join(HERE, '../src/web/schemas.ts'), 'utf8');
|
||||
|
||||
it('is in the client-side displayKeys merge policy', () => {
|
||||
const displayKeys = settingsUi.slice(
|
||||
settingsUi.indexOf('const displayKeys = new Set(['),
|
||||
settingsUi.indexOf('])', settingsUi.indexOf('const displayKeys = new Set(['))
|
||||
);
|
||||
expect(displayKeys).toContain("'showSplitButton'");
|
||||
});
|
||||
|
||||
it('is stripped out of the object saveAppSettings() PUTs to the server', () => {
|
||||
// The strip is a destructure: `showSplitButton: _ssp,` pulls the key out
|
||||
// of `settings` so it never reaches `...serverSettings` in the PUT body.
|
||||
expect(settingsUi).toContain('showSplitButton: _ssp,');
|
||||
});
|
||||
|
||||
it('is never declared in the .strict() SettingsUpdateSchema', () => {
|
||||
// Not even in a comment — a mention there reads as "this is a real
|
||||
// field" to the next person grepping schemas.ts for it.
|
||||
expect(schemas).not.toContain('showSplitButton');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
// test/split-pane-picker-listener-leak-unit.test.ts
|
||||
// Port: N/A (no server/browser — loaded via `vm`, like split-pane-auto-collapse-unit.test.ts).
|
||||
//
|
||||
// Fast, CI-visible regression coverage for a listener leak in `openSplitPicker()` /
|
||||
// `_dismissSplitPicker()` (terminal-split.js): repeatedly opening the split picker
|
||||
// (e.g. clicking the `.btn-split` button, whose real clicks land on its inner
|
||||
// <svg>) used to leave a `click`/`keydown` listener pair attached to `document`
|
||||
// on every cycle, because a pre-existing menu was torn down with a raw
|
||||
// `existing.remove()` instead of through `_dismissSplitPicker()`, and the
|
||||
// single-slot `_splitPickerDismissHandlers` field was overwritten rather than
|
||||
// used to clean up the previous pair first.
|
||||
//
|
||||
// This is a `vm`-driven DOM-listener-count assertion rather than a real-browser
|
||||
// interaction test (`test/split-pane-orchestration.browser.test.ts` already
|
||||
// covers the real click-through-svg interaction and is excluded from `npm test`
|
||||
// per CLAUDE.md's Testing section) — it exercises the exact document
|
||||
// addEventListener/removeEventListener calls the fix and the bug both hinge on,
|
||||
// with no xterm/WebSocket/tmux involved.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** A tiny fake DOM element: enough surface for createElement/appendChild/contains/remove/closest. */
|
||||
function makeFakeElement(tag: string) {
|
||||
const children: Array<ReturnType<typeof makeFakeElement>> = [];
|
||||
let id: string | undefined;
|
||||
let parent: ReturnType<typeof makeFakeElement> | null = null;
|
||||
const el = {
|
||||
tag,
|
||||
style: {} as Record<string, string>,
|
||||
innerHTML: '',
|
||||
className: '',
|
||||
get id() {
|
||||
return id;
|
||||
},
|
||||
set id(v: string | undefined) {
|
||||
id = v;
|
||||
},
|
||||
appendChild(child: ReturnType<typeof makeFakeElement>) {
|
||||
children.push(child);
|
||||
(child as { _parent: unknown })._parent = el;
|
||||
return child;
|
||||
},
|
||||
contains(target: unknown): boolean {
|
||||
if (target === el) return true;
|
||||
return children.some((c) => c.contains(target));
|
||||
},
|
||||
// Real Element.closest(): walk up from THIS element (inclusive), matching
|
||||
// a bare class selector like `.btn-split` against a space-separated
|
||||
// className — enough to exercise the fix's `e.target.closest('.btn-split')`
|
||||
// check without a real DOM.
|
||||
closest(selector: string): ReturnType<typeof makeFakeElement> | null {
|
||||
const cls = selector.replace(/^\./, '');
|
||||
let node: ReturnType<typeof makeFakeElement> | null = el;
|
||||
while (node) {
|
||||
if (node.className.split(/\s+/).includes(cls)) return node;
|
||||
node = (node as unknown as { __parent: ReturnType<typeof makeFakeElement> | null }).__parent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
remove() {
|
||||
if (parent) {
|
||||
parent = null;
|
||||
}
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { bottom: 0, right: 0, left: 0, top: 0, width: 0, height: 0 };
|
||||
},
|
||||
set _parent(p: ReturnType<typeof makeFakeElement>) {
|
||||
parent = p;
|
||||
(el as unknown as { __parent: ReturnType<typeof makeFakeElement> }).__parent = p;
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
/** A minimal `document` mock tracking real addEventListener/removeEventListener identity. */
|
||||
function makeFakeDocument() {
|
||||
const elementsById = new Map<string, ReturnType<typeof makeFakeElement>>();
|
||||
const listeners: Record<string, Array<(...args: unknown[]) => unknown>> = {
|
||||
click: [],
|
||||
keydown: [],
|
||||
};
|
||||
|
||||
return {
|
||||
createElement(tag: string) {
|
||||
return makeFakeElement(tag);
|
||||
},
|
||||
body: {
|
||||
appendChild(el: ReturnType<typeof makeFakeElement>) {
|
||||
if (el.id) elementsById.set(el.id, el);
|
||||
},
|
||||
},
|
||||
getElementById(id: string) {
|
||||
const el = elementsById.get(id);
|
||||
if (el) {
|
||||
// Support the real `?.remove()` call site removing it from the registry.
|
||||
const originalRemove = el.remove.bind(el);
|
||||
el.remove = () => {
|
||||
elementsById.delete(id);
|
||||
originalRemove();
|
||||
};
|
||||
}
|
||||
return el;
|
||||
},
|
||||
querySelector(sel: string) {
|
||||
return sel === '.btn-split' ? makeFakeElement('button') : null;
|
||||
},
|
||||
addEventListener(type: string, fn: (...args: unknown[]) => unknown) {
|
||||
(listeners[type] ??= []).push(fn);
|
||||
},
|
||||
removeEventListener(type: string, fn: (...args: unknown[]) => unknown) {
|
||||
listeners[type] = (listeners[type] ?? []).filter((f) => f !== fn);
|
||||
},
|
||||
__listenerCount(type: string) {
|
||||
return (listeners[type] ?? []).length;
|
||||
},
|
||||
// Test-only helpers: fire a snapshot of the currently-registered
|
||||
// listeners (a handler removing itself mid-dispatch must not skip or
|
||||
// double-invoke a sibling — snapshotting avoids that ambiguity here).
|
||||
__dispatchClick(target: unknown) {
|
||||
for (const fn of [...(listeners.click ?? [])]) fn({ target });
|
||||
},
|
||||
__dispatchKeydown(key: string) {
|
||||
for (const fn of [...(listeners.keydown ?? [])]) fn({ key });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadCodemanAppClass(documentMock: ReturnType<typeof makeFakeDocument>) {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const terminalSplitSrc = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
window: { innerWidth: 1024 },
|
||||
document: documentMock,
|
||||
setTimeout,
|
||||
// terminal-split.js's picker markup calls the global `escapeHtml()` helper
|
||||
// (defined in constants.js at runtime); a plain identity stub is enough
|
||||
// here since no candidate labels/ids in this test contain HTML.
|
||||
escapeHtml: (s: unknown) => String(s),
|
||||
});
|
||||
const fakeAppSrc = `
|
||||
class CodemanApp {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
this.sessionOrder = [];
|
||||
this.activeSessionId = 'active-session';
|
||||
this._splitPane = null;
|
||||
}
|
||||
}
|
||||
`;
|
||||
vm.runInContext(
|
||||
`${fakeAppSrc}\nwindow.CodemanSplitPane = { buildSplitPickerSessions: () => [] };\n${terminalSplitSrc}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||
context
|
||||
);
|
||||
return (
|
||||
context as { __CodemanApp: new () => { openSplitPicker: (e?: unknown) => void; _dismissSplitPicker: () => void } }
|
||||
).__CodemanApp;
|
||||
}
|
||||
|
||||
describe('terminal-split.js split-picker document-listener leak (repeated open/close)', () => {
|
||||
let documentMock: ReturnType<typeof makeFakeDocument>;
|
||||
let CodemanApp: ReturnType<typeof loadCodemanAppClass>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
documentMock = makeFakeDocument();
|
||||
CodemanApp = loadCodemanAppClass(documentMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not grow document click/keydown listeners across repeated open cycles with no dismissal', () => {
|
||||
const app = new CodemanApp();
|
||||
|
||||
// Simulates the real failure sequence: the button's onclick re-fires
|
||||
// `openSplitPicker()` on every click (e.g. landing on the button's inner
|
||||
// <svg>) without an intervening outside click or Escape ever resolving.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers(); // flush the deferred `setTimeout(() => addEventListener('click', ...))`
|
||||
}
|
||||
|
||||
// Exactly one pair should be live — the CURRENT menu's — never one per cycle.
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(1);
|
||||
});
|
||||
|
||||
it('drops to zero listeners after the outside-click handler fires', () => {
|
||||
const app = new CodemanApp();
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
|
||||
app._dismissSplitPicker();
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(0);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(0);
|
||||
});
|
||||
|
||||
it('never accumulates listeners across many open→dismiss cycles', () => {
|
||||
const app = new CodemanApp();
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
app._dismissSplitPicker();
|
||||
}
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(0);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(0);
|
||||
});
|
||||
|
||||
it("does not dismiss when the click lands on the split button's own inner icon (closest() match, not exact-node equality)", () => {
|
||||
const app = new CodemanApp();
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
|
||||
// Mirrors the real DOM: the button carries the onclick and the
|
||||
// `.btn-split` class, but the actual click target is its inner <svg>.
|
||||
// The old `e.target !== splitBtn` check matched this (target !== button)
|
||||
// and dismissed the menu the same click had just (re)opened.
|
||||
const button = makeFakeElement('button');
|
||||
button.className = 'btn-split';
|
||||
const svg = button.appendChild(makeFakeElement('svg'));
|
||||
|
||||
documentMock.__dispatchClick(svg);
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(1);
|
||||
});
|
||||
|
||||
it('dismisses and removes both listeners by identity when a genuine outside click fires', () => {
|
||||
const app = new CodemanApp();
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
|
||||
const outside = makeFakeElement('div');
|
||||
documentMock.__dispatchClick(outside);
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(0);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(0);
|
||||
});
|
||||
|
||||
it('dismisses and removes both listeners by identity when Escape fires', () => {
|
||||
const app = new CodemanApp();
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(1);
|
||||
|
||||
documentMock.__dispatchKeydown('Escape');
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(0);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/** @fileoverview Real Chromium + real WebSocket coverage for SplitTerminalPane (Task 4 of the split-pane-sessions plan). */
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3175;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('SplitTerminalPane in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
it('connects, echoes real PTY output, and cleans up on destroy', async () => {
|
||||
const sessionId = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
// Session creation alone leaves pid:null and no pane (per CLAUDE.md's
|
||||
// Testing section) — the shell PTY only spawns once this is called, and
|
||||
// without it the WS opens but no bytes ever flow, and the echo assertion
|
||||
// below would hang until its own timeout for reasons unrelated to
|
||||
// SplitTerminalPane.
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
|
||||
const result = await page.evaluate(async (id) => {
|
||||
const mount = document.createElement('div');
|
||||
mount.style.width = '400px';
|
||||
mount.style.height = '300px';
|
||||
document.body.appendChild(mount);
|
||||
|
||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
||||
pane.connect();
|
||||
|
||||
// Wait for the WS to open, then send a real input frame — testMode's
|
||||
// echo PTY (TEST_PTY_SCRIPT) echoes each byte back exactly once, which
|
||||
// is what proves the WS round-trip actually reaches a real PTY and back,
|
||||
// not just that xterm can render locally-written text.
|
||||
await new Promise((resolve) => {
|
||||
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
|
||||
check();
|
||||
});
|
||||
pane.ws.send(JSON.stringify({ t: 'i', d: 'SPLITPANE_MARKER\r' }));
|
||||
|
||||
const hasEcho = await new Promise((resolve) => {
|
||||
const deadline = Date.now() + 5000;
|
||||
const poll = () => {
|
||||
const buf = pane.terminal.buffer.active;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf.getLine(i)?.translateToString(true).includes('SPLITPANE_MARKER')) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Date.now() > deadline) resolve(false);
|
||||
else setTimeout(poll, 100);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
|
||||
pane.destroy();
|
||||
const cleanedUp = mount.querySelector('.xterm') === null;
|
||||
document.body.removeChild(mount);
|
||||
|
||||
return { hasEcho, cleanedUp };
|
||||
}, sessionId);
|
||||
|
||||
expect(result.hasEcho).toBe(true);
|
||||
expect(result.cleanedUp).toBe(true);
|
||||
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, sessionId);
|
||||
});
|
||||
|
||||
it('shows existing scrollback immediately on connect, before any new output', async () => {
|
||||
// Regression guard: connect() previously only opened the WS and waited for
|
||||
// live 'terminal' events (ws-routes.ts sends nothing on connect), so a pane
|
||||
// opened onto an already-quiet session stayed blank until either new output
|
||||
// arrived or a resize happened to trigger a tmux repaint. Writing a marker
|
||||
// and letting the echo settle BEFORE connect() proves the fetched buffer,
|
||||
// not a live echo, is what populates the pane.
|
||||
const sessionId = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
// Write directly to the session (not through SplitTerminalPane, which
|
||||
// does not exist yet). Poll the real ?full=1 capture (same endpoint
|
||||
// connect() below will use) rather than a fixed delay — the shell's
|
||||
// own startup can race an early write and, on this box, a startup
|
||||
// script issues a `clear` that erases scrollback (modern ncurses
|
||||
// `clear` emits \x1b[3J) if the input lands before the shell is ready.
|
||||
await fetch(`/api/sessions/${id}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: 'PRE_EXISTING_MARKER\r' }),
|
||||
});
|
||||
const deadline = Date.now() + 5000;
|
||||
for (;;) {
|
||||
const res2 = await fetch(`/api/sessions/${id}/terminal?full=1`);
|
||||
const buffer = (await res2.json())?.data?.terminalBuffer ?? '';
|
||||
if (buffer.includes('PRE_EXISTING_MARKER')) break;
|
||||
if (Date.now() > deadline) throw new Error('marker never landed in ?full=1 capture: ' + JSON.stringify(buffer));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
return id;
|
||||
});
|
||||
|
||||
const hasMarker = await page.evaluate(async (id) => {
|
||||
const mount = document.createElement('div');
|
||||
mount.style.width = '400px';
|
||||
mount.style.height = '300px';
|
||||
document.body.appendChild(mount);
|
||||
|
||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
||||
await pane.connect();
|
||||
|
||||
// xterm's write() parses asynchronously (it queues data and processes it
|
||||
// on a later microtask/frame), so the fetched buffer connect() writes is
|
||||
// not necessarily in the rendered buffer the instant connect() resolves.
|
||||
// Poll rather than check once — no new input is sent here, so any pass
|
||||
// still comes from the ?full=1 fetch inside connect(), never a live echo.
|
||||
let found = false;
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!found && Date.now() < deadline) {
|
||||
const buf = pane.terminal.buffer.active;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf.getLine(i)?.translateToString(true).includes('PRE_EXISTING_MARKER')) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
pane.destroy();
|
||||
document.body.removeChild(mount);
|
||||
return found;
|
||||
}, sessionId);
|
||||
|
||||
expect(hasMarker).toBe(true);
|
||||
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, sessionId);
|
||||
});
|
||||
|
||||
it('gates app-level chords out of Pane B instead of forwarding their raw bytes', async () => {
|
||||
// Regression guard for PR #453's Ctrl+K/Alt+1/Alt+B leak: Pane B had no
|
||||
// attachCustomKeyEventHandler of its own, so the document capture-phase
|
||||
// shortcut handler's preventDefault() (which does not stop xterm) left
|
||||
// every one of these chords ALSO writing its raw byte/escape sequence into
|
||||
// Pane B's live PTY on top of whatever the app action did to Pane A.
|
||||
const sessionId = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
|
||||
const result = await page.evaluate(async (id) => {
|
||||
const mount = document.createElement('div');
|
||||
mount.style.width = '400px';
|
||||
mount.style.height = '300px';
|
||||
document.body.appendChild(mount);
|
||||
|
||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
||||
await pane.connect();
|
||||
await new Promise((resolve) => {
|
||||
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
|
||||
check();
|
||||
});
|
||||
|
||||
const sent: string[] = [];
|
||||
const realSend = pane.ws.send.bind(pane.ws);
|
||||
pane.ws.send = (payload: string) => {
|
||||
sent.push(payload);
|
||||
return realSend(payload);
|
||||
};
|
||||
|
||||
pane.terminal.focus();
|
||||
// Dispatch straight at xterm's own textarea, matching how a real
|
||||
// keypress reaches attachCustomKeyEventHandler — page.keyboard.press()
|
||||
// goes through the OS/CDP input pipeline and would also trigger the
|
||||
// app's document-capture handler (opening a real command palette),
|
||||
// which is not what this test is isolating.
|
||||
const textarea = (pane.terminal as any)._core?.textarea || (pane.terminal as any).textarea;
|
||||
const fire = (init: KeyboardEventInit) => {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||
textarea.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
// keyCode is what xterm's evaluateKeyboardEvent switches on to decide
|
||||
// whether to produce a data frame at all — at keyCode 0 (unset) it can
|
||||
// never emit bytes, so the assertion below held regardless of whether
|
||||
// the custom key handler's gate actually fired. Real values (K=75,
|
||||
// 1=49, B=66) are what a real keypress carries.
|
||||
const app = window.app as any;
|
||||
fire({ key: 'k', code: 'KeyK', keyCode: 75, ctrlKey: true }); // command palette
|
||||
fire({ key: '1', code: 'Digit1', keyCode: 49, altKey: true }); // Alt+1 tab switch
|
||||
|
||||
// Ctrl+Z (SIGTSTP): this pane's own sessionMode is undefined (no `mode`
|
||||
// opt passed to the constructor above), so `this.sessionMode !== 'shell'`
|
||||
// holds and the gate must block it, mirroring a non-shell (agent) mode.
|
||||
fire({ key: 'z', code: 'KeyZ', keyCode: 90, ctrlKey: true });
|
||||
|
||||
// Shift+Enter: must never reach the PTY as a bare \r (that would submit
|
||||
// an incomplete prompt instead of inserting a newline) — it goes out as
|
||||
// a POST to /api/sessions/:id/send-key instead.
|
||||
const sendKeyCalls: unknown[] = [];
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.fetch = ((...args: Parameters<typeof fetch>) => {
|
||||
const url = String(args[0]);
|
||||
if (url.includes('/send-key')) {
|
||||
sendKeyCalls.push(args[1] ? JSON.parse((args[1] as RequestInit).body as string) : null);
|
||||
}
|
||||
return realFetch(...args);
|
||||
}) as typeof fetch;
|
||||
fire({ key: 'Enter', code: 'Enter', keyCode: 13, shiftKey: true });
|
||||
window.fetch = realFetch;
|
||||
|
||||
// Smart-copy Ctrl+C: with a real selection in THIS pane's own terminal,
|
||||
// Ctrl+C must copy it (never send 0x03) and must copy Pane B's
|
||||
// selection, not Pane A's. app._copyText is stubbed rather than relying
|
||||
// on a real clipboard, which headless Chromium may refuse permission
|
||||
// for.
|
||||
pane.terminal.write('SPLITPANE_COPY_MARKER');
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
pane.terminal.selectAll();
|
||||
let copiedText: string | null = null;
|
||||
const realCopyText = app._copyText;
|
||||
app._copyText = async (text: string) => {
|
||||
copiedText = text;
|
||||
return true;
|
||||
};
|
||||
fire({ key: 'c', code: 'KeyC', keyCode: 67, ctrlKey: true });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
app._copyText = realCopyText;
|
||||
|
||||
// Ctrl+Shift+C with NO selection: the blanket "no 'i' frames" check
|
||||
// below is NOT what proves this gate works — xterm's own
|
||||
// evaluateKeyboardEvent never emits data for a shifted ctrl-letter in
|
||||
// the first place (verified live: removing the gate entirely still
|
||||
// produces zero WS frames for this exact key), so an absent 'i' frame
|
||||
// is true whether or not the app-level shiftKey branch fires. What the
|
||||
// branch actually buys is `preventDefault()`, so the browser's own
|
||||
// handling of the chord (e.g. Chrome's Inspect-Element binding) is
|
||||
// pre-empted, mirroring Pane A's own "never falls through" contract —
|
||||
// asserted directly via the dispatched event's defaultPrevented.
|
||||
pane.terminal.clearSelection();
|
||||
const ctrlShiftCPrevented = fire({ key: 'c', code: 'KeyC', keyCode: 67, ctrlKey: true, shiftKey: true });
|
||||
|
||||
// Alt+B only reaches shouldToggleSessionSidebarFromShortcut's gate when
|
||||
// the sidebar layout is actually active (app.js:4325) — under the
|
||||
// default header-strip layout the app doesn't treat Alt+B as its own
|
||||
// shortcut either, so Pane A forwards the same `ESC b` to its own PTY.
|
||||
// Assert the gate where it is meant to hold: sidebar layout active.
|
||||
//
|
||||
// Setting only the `data-session-list` attribute is not enough: this
|
||||
// event bubbles (matching how a real keypress reaches xterm), so it
|
||||
// also reaches app.js's OWN document-level capture-phase shortcut
|
||||
// dispatcher, which matches the same Alt+B binding and calls the real
|
||||
// toggleSessionSidebar() — that reads the persisted settings (still
|
||||
// 'header'), re-runs applySessionListLayout(), and resets the
|
||||
// attribute back to 'header' before xterm's own (later, non-capture)
|
||||
// key handler ever sees it. Persisting the setting through the app's
|
||||
// own settings cache keeps the attribute stable across that bubble.
|
||||
const prevSettings = { ...app.loadAppSettingsFromStorage() };
|
||||
app._cachedAppSettings = { ...prevSettings, sessionListLayout: 'sidebar' };
|
||||
app.applySessionListLayout();
|
||||
fire({ key: 'b', code: 'KeyB', keyCode: 66, altKey: true }); // Alt+B sidebar toggle
|
||||
app._cachedAppSettings = prevSettings;
|
||||
app.applySessionListLayout();
|
||||
|
||||
pane.destroy();
|
||||
document.body.removeChild(mount);
|
||||
return { sent, sendKeyCalls, copiedText, ctrlShiftCPrevented };
|
||||
}, sessionId);
|
||||
|
||||
expect(result.sent.every((f) => JSON.parse(f).t !== 'i')).toBe(true);
|
||||
expect(result.sendKeyCalls).toEqual([{ key: 'S-Enter' }]);
|
||||
expect(result.copiedText).toContain('SPLITPANE_COPY_MARKER');
|
||||
expect(result.ctrlShiftCPrevented).toBe(true);
|
||||
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, sessionId);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user