# Split-Pane Sessions Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Let a Codeman window show two live sessions side-by-side in one browser tab, with a draggable divider, without touching the existing single-pane session (Pane A). **Architecture:** Pane A stays exactly what it is today (`this.terminal`/`this._ws`, untouched). A new `SplitTerminalPane` class owns a second, independent xterm instance + WebSocket for Pane B. A new orchestration module (`terminal-split.js`) creates/destroys the split container, reparents the existing `.terminal-wrap`, wires the divider drag, and handles the session picker + auto-collapse edge cases. **Tech Stack:** Vanilla JS (xterm.js, `xterm-addon-fit`), Fastify WebSocket routes (unchanged), Vitest (pure-logic unit tests via `vm`), Playwright (`test/browser`) for live behavior. **Spec:** `docs/superpowers/specs/2026-09-15-split-pane-sessions-design.md` ## Execution Environment This plan is implemented entirely inside the existing worktree at `.worktrees/split-pane-sessions` (branch `feat/split-pane-sessions`), created before brainstorming started — **not** in the main checkout. Every task's commit step assumes `cwd` is that worktree. Before each commit, run `git branch --show-current` and confirm it prints `feat/split-pane-sessions` (CLAUDE.md's worktree/branch-safety rule) — this repo runs multiple Codeman sessions concurrently, so verifying is cheap insurance, not ceremony. All commit steps in this plan already stage explicit paths (never `git add -A`), in line with the same rule. ## Global Constraints - Pane A's existing code path (`this.terminal`, `this._ws`, `_connectWs`, `sendResize`, etc.) is never modified — zero regression risk on the primary pane. - Local-echo overlay, CJK IME, and the keyboard accessory bar are **mobile/touch-only** subsystems in this codebase (`localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`; the accessory bar is phone-toolbar-specific). Pane B gets none of them — not because they're being cut down for desktop, but because split-pane itself is a **desktop-only feature** (it needs a wide viewport), so a mobile-only subsystem has nothing to do there regardless. See the spec's "Key design decision: Pane B is deliberately plainer than Pane A" section for the full reasoning. - No persistence: a page reload always returns to single-pane view. No localStorage key stores split state. - Side-by-side only, exactly 2 panes, draggable divider, default 50/50, clamped 20%–80%. - The "Split" header button follows the existing opt-in header-button pattern: ships with a `btn-split--hidden` marker class, gated by a `showSplitButton` setting (default `false`), so it needs no addition to `test/mobile-header-buttons-policy.test.ts`'s default-visible enumeration (mirrors `showMultiMonitorButton`). - Splitting a session against itself is disallowed — the picker excludes the currently active session. - `MAX_WS_PER_SESSION = 5` (`src/web/routes/ws-routes.ts`) is per-session, and Pane A/B are always different sessions, so no server-side change is needed for the connection cap. --- ### Task 1: Pure helpers — divider clamp math and picker list builder **Files:** - Modify: `src/web/public/constants.js` (append a new `window.CodemanSplitPane` namespace, following the existing `window.CodemanLineage`/`window.CodemanSessionOrder` pattern already in this file) - Test: `test/split-pane-helpers.test.ts` (new) **Interfaces:** - Produces: `window.CodemanSplitPane.clampDividerPercent(rawPercent, min = 20, max = 80)` → `number` - Produces: `window.CodemanSplitPane.buildSplitPickerSessions(sessions, sessionOrder, excludeId)` → `Array<{id: string, label: string}>` - [ ] **Step 1: Write the failing test** ```typescript // test/split-pane-helpers.test.ts 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([]); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test -- test/split-pane-helpers.test.ts` Expected: FAIL — `CodemanSplitPane` is `undefined` (not yet added to `constants.js`). - [ ] **Step 3: Write minimal implementation** Append near the other `window.Codeman*` namespace assignments at the bottom of `src/web/public/constants.js` (same file that already defines `window.CodemanLineage`, `window.CodemanSessionOrder`, etc. — search for `global.CodemanLineage =` to find the right neighborhood): ```javascript // ═══════════════════════════════════════════════════════════════ // Split-Pane Sessions — pure helpers (divider math, picker list) // ═══════════════════════════════════════════════════════════════ function clampDividerPercent(rawPercent, min = 20, max = 80) { if (rawPercent < min) return min; if (rawPercent > max) return max; return rawPercent; } function buildSplitPickerSessions(sessions, sessionOrder, excludeId) { const result = []; for (const id of sessionOrder) { if (id === excludeId) continue; const session = sessions.get(id); if (!session) continue; result.push({ id, label: session.name || 'Session' }); } return result; } global.CodemanSplitPane = { clampDividerPercent, buildSplitPickerSessions, }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm test -- test/split-pane-helpers.test.ts` Expected: PASS (7 tests) - [ ] **Step 5: Commit** ```bash git add src/web/public/constants.js test/split-pane-helpers.test.ts git commit -m "feat(split-pane): add pure divider-clamp and picker-list helpers" ``` --- ### Task 2: `showSplitButton` setting + header button (wiring only, no split behavior yet) **Files:** - Modify: `src/web/schemas.ts` (add one field next to `showMultiMonitorButton`) - Modify: `src/web/public/index.html` (header button markup + App Settings checkbox chip) - Modify: `src/web/public/settings-ui.js` (load/save/defaults/apply, mirroring `showMultiMonitorButton` exactly) - Test: `test/routes/system-routes-split-button-setting.test.ts` (new) **Interfaces:** - Consumes: none (self-contained wiring task) - Produces: a `.btn-split` element in the header (hidden by default via `btn-split--hidden`), toggled by `applyHeaderVisibilitySettings()`, whose `onclick` will be wired to `app.openSplitPicker()` in Task 5 (not yet — for this task, leave the `onclick` attribute pointing at `app.openSplitPicker()`, a no-op stub is fine since it doesn't exist until Task 5; clicking it before Task 5 lands will throw in the console, which is acceptable mid-plan and is fixed by Task 5). - [ ] **Step 1: Write the failing test** This mirrors the existing `test/routes/system-routes-settings-partial-put.test.ts` pattern exactly — there is no `buildTestApp()` helper in this codebase; route tests go through `createRouteTestHarness(registerFn)` from `test/routes/_route-test-utils.ts`, and `GET`/`PUT /api/settings` are NOT wrapped in the `{success,data}` envelope (see `src/web/routes/system-routes.ts:938`, `app.get('/api/settings', ...)` returns the raw settings object directly — read its own comment there for why). `registerSystemRoutes` also drives three watcher singletons on every PUT, so they must be mocked or the route throws. ```typescript // test/routes/system-routes-split-button-setting.test.ts import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; const { subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => { const makeWatcher = () => ({ isRunning: vi.fn(() => false), start: vi.fn(), stop: vi.fn(), getStats: vi.fn(() => ({})), watchSession: vi.fn(), getRecentRunSummaries: vi.fn(() => []), }); return { subagentWatcher: makeWatcher(), imageWatcher: makeWatcher(), workflowRunWatcher: makeWatcher() }; }); vi.mock('node:fs/promises', () => ({ default: { readFile: vi.fn(async () => JSON.stringify({})), writeFile: vi.fn(async () => undefined), }, })); vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) }; }); vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher })); vi.mock('../../src/image-watcher.js', () => ({ imageWatcher })); vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher })); describe('showSplitButton setting', () => { let harness: RouteTestHarness; beforeEach(async () => { harness = await createRouteTestHarness(registerSystemRoutes); }); afterEach(async () => { await harness.app.close(); }); it('round-trips through PUT and GET /api/settings', async () => { const putRes = await harness.app.inject({ method: 'PUT', url: '/api/settings', payload: { showSplitButton: true }, }); expect(putRes.statusCode).toBe(200); const getRes = await harness.app.inject({ method: 'GET', url: '/api/settings' }); const body = JSON.parse(getRes.body); expect(body.showSplitButton).toBe(true); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test -- test/routes/system-routes-split-button-setting.test.ts` Expected: FAIL — `SettingsUpdateSchema` is `.strict()` and rejects the unknown `showSplitButton` key with a 400. - [ ] **Step 3: Write minimal implementation** In `src/web/schemas.ts`, find the line `showMultiMonitorButton: z.boolean().optional(),` and add directly after it: ```typescript showSplitButton: z.boolean().optional(), ``` In `src/web/public/index.html`, find the multi-monitor header button (`class="btn-icon-header btn-multimonitor btn-multimonitor--hidden"`) and add a sibling button immediately after it: ```html ``` In `index.html`'s App Settings → Header & Panels checkbox list (find the `appSettingsShowMultiMonitorButton` chip, `id="appSettingsShowMultiMonitorButton"`), add a sibling chip immediately after its closing ``: ```html ``` In `src/web/public/settings-ui.js`, mirror every `showMultiMonitorButton` line for `showSplitButton`. Four call sites (find each `showMultiMonitorButton` occurrence and add the twin directly after it): ```javascript // In the load function (near appSettingsShowMultiMonitorButton.checked = ...): document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false; // In the save function (near showMultiMonitorButton: document.getElementById(...).checked,): showSplitButton: document.getElementById('appSettingsShowSplitButton').checked, // In the defaults object (near showMultiMonitorButton: false,): showSplitButton: false, // In applyHeaderVisibilitySettings() (near the multiMonitorBtn toggle block): const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false; const splitBtn = document.querySelector('.btn-split'); if (splitBtn) { splitBtn.classList.toggle('btn-split--hidden', !showSplitButton); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm test -- test/routes/system-routes-split-button-setting.test.ts` Expected: PASS - [ ] **Step 5: Run the full gate to check for regressions** Run: `npm test` Expected: PASS (including `test/mobile-header-buttons-policy.test.ts`, which should not flag `.btn-split` since it ships with `btn-split--hidden` and is therefore not in the default-visible enumeration) - [ ] **Step 6: Commit** ```bash git add src/web/schemas.ts src/web/public/index.html src/web/public/settings-ui.js test/routes/system-routes-split-button-setting.test.ts git commit -m "feat(split-pane): add showSplitButton setting and header button" ``` --- ### Task 3: Split layout CSS **Files:** - Modify: `src/web/public/styles.css` (new rules, no existing rules touched) **Interfaces:** - Consumes: none - Produces: CSS classes `.terminal-split-container`, `.split-divider`, `.terminal-pane-b`, `.terminal-pane-b-header`, `.terminal-pane-b-close` that Task 5's orchestration code creates elements with. - [ ] **Step 1: Add the CSS** Append to `src/web/public/styles.css` (place near the end, or near other layout-container rules — exact location doesn't affect behavior since these are new, non-conflicting class names): ```css /* 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: 0 0 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: 0 0 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 { 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; } ``` - [ ] **Step 2: Visually verify (no automated test for pure CSS)** Run: `npm run check:public-assets` (Prettier-checks `src/web/public/**`; catches formatting issues, not behavior) Expected: PASS - [ ] **Step 3: Commit** ```bash git add src/web/public/styles.css git commit -m "feat(split-pane): add split container/divider/pane-b CSS" ``` --- ### Task 4: `SplitTerminalPane` class (Pane B's xterm + WebSocket lifecycle) **Files:** - Create: `src/web/public/terminal-split.js` - Modify: `src/web/public/index.html` (add the ` ``` In `CLAUDE.md`, find the Frontend load-order line (`... → terminal-ui.js(7) → respawn-ui.js(8) → ...`) and insert `terminal-split.js(7.5)` between them, matching the existing `X.Y of 16` `@loadorder` numbering convention already used for other `.5`-numbered modules (e.g. `tab-rail-resize.js(6.5)`). In `config/test-suites.ts`, add `'test/split-pane-terminal.browser.test.ts',` to the `BROWSER_TEST_GLOBS` array (any position — it's a flat list, not order-sensitive). - [ ] **Step 4: Run test to verify it passes** Run: `npm run test:browser -- test/split-pane-terminal.browser.test.ts` Expected: PASS (1 test) - [ ] **Step 5: Run the full gate to check for regressions** Run: `npm test` Expected: PASS (this task adds no vitest-visible surface to the CI gate itself, so this just confirms nothing broke — `test/split-pane-terminal.browser.test.ts` is excluded from `npm test` by the same `BROWSER_TEST_GLOBS` exclusion list, which is why Step 4 uses `test:browser` instead) - [ ] **Step 6: Commit** ```bash git add src/web/public/terminal-split.js src/web/public/index.html CLAUDE.md config/test-suites.ts test/split-pane-terminal.browser.test.ts git commit -m "feat(split-pane): add SplitTerminalPane class for Pane B" ``` --- ### Task 5: Split orchestration — open/close, session picker, reparenting **Files:** - Modify: `src/web/public/terminal-split.js` (add orchestration methods, mixed into `CodemanApp.prototype`) - Test: `test/split-pane-orchestration.browser.test.ts` (new) - Modify: `config/test-suites.ts` (register the new test file in `BROWSER_TEST_GLOBS`, same reason as Task 4) **Interfaces:** - Consumes: `window.SplitTerminalPane` (Task 4), `window.CodemanSplitPane.buildSplitPickerSessions` (Task 1), `app.sessions` (`Map`), `app.sessionOrder` (`string[]`), `app.activeSessionId` (`string | null`) - Produces: `app.openSplitPicker()`, `app.openSplitPane(sessionId)`, `app.closeSplitPane()`, `app._splitPane` (the live `SplitTerminalPane` instance or `null`), `app._splitSessionId` (the Pane B session id or `null`) ⚠️ Same test-infrastructure note as Task 4: vitest + raw `playwright` + a real `WebServer(PORT, false, true)`, file at the top of `test/`, registered by exact path in `config/test-suites.ts`. Port 3176 (checked free at plan-writing time, alongside 3175 used by Task 4 — the two suites never run concurrently since `fileParallelism: false` in `config/vitest.browser.config.ts`, but distinct ports avoid any doubt). - [ ] **Step 1: Write the failing test** ```typescript // test/split-pane-orchestration.browser.test.ts /** @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 { return page.evaluate(async () => { const res = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }), }); return (await res.json()).data.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); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: register `'test/split-pane-orchestration.browser.test.ts',` in `config/test-suites.ts`'s `BROWSER_TEST_GLOBS` first, then `npm run test:browser -- test/split-pane-orchestration.browser.test.ts` Expected: FAIL — `app.openSplitPane`/`closeSplitPane`/`openSplitPicker` are undefined. - [ ] **Step 3: Write minimal implementation** Append to `src/web/public/terminal-split.js`, after the `SplitTerminalPane` class and its `global.SplitTerminalPane = SplitTerminalPane;` line, still inside the same IIFE closure is not required — this part attaches to `CodemanApp.prototype` at module scope like every other UI mixin file: ```javascript Object.assign(CodemanApp.prototype, { openSplitPicker() { if (this._splitPane) { this.closeSplitPane(); return; } const candidates = window.CodemanSplitPane.buildSplitPickerSessions( this.sessions, this.sessionOrder, this.activeSessionId ); const existing = document.getElementById('splitPickerMenu'); if (existing) existing.remove(); const menu = document.createElement('div'); menu.id = 'splitPickerMenu'; menu.className = 'split-picker-menu'; if (candidates.length === 0) { menu.innerHTML = '
No other sessions to split with
'; } else { menu.innerHTML = candidates .map( (c) => `
${escapeHtml(c.label)}
` ) .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`; } }, openSplitPane(sessionId) { 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 = `
${escapeHtml(session?.name || 'Session')} ×
`; 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')); this._splitPane.connect(); this._splitSessionId = sessionId; this._installSplitDividerDrag(divider, wrap, paneB); }, closeSplitPane() { if (!this._splitPane) return; this._splitPane.destroy(); this._splitPane = null; this._splitSessionId = null; 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?.(() => {}); }, _installSplitDividerDrag(divider, wrap, paneB) { let dragging = false; const onMove = (e) => { if (!dragging) return; const container = divider.parentElement; const rect = container.getBoundingClientRect(); const rawPercent = ((e.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?.fit(); }; const onUp = () => { dragging = false; divider.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; divider.addEventListener('mousedown', () => { dragging = true; divider.classList.add('dragging'); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }, }); ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test:browser -- test/split-pane-orchestration.browser.test.ts` Expected: PASS (2 tests) - [ ] **Step 5: Run the full gate to check for regressions** Run: `npm test` Expected: PASS - [ ] **Step 6: Commit** ```bash git add src/web/public/terminal-split.js config/test-suites.ts test/split-pane-orchestration.browser.test.ts git commit -m "feat(split-pane): add open/close orchestration, picker, and divider drag" ``` --- ### Task 6: Auto-collapse on either session ending **Files:** - Modify: `src/web/public/terminal-split.js` (hook into existing SSE session-lifecycle handlers) - Test: `test/split-pane-auto-collapse.browser.test.ts` (new) - Modify: `config/test-suites.ts` (register the new test file in `BROWSER_TEST_GLOBS`, same reason as Tasks 4-5) **Interfaces:** - Consumes: the existing `_onSessionDeleted(data)` handler in `app.js` (find it via `[SSE_EVENTS.SESSION_DELETED, '_onSessionDeleted']` in the `app.js` handler map) — this task wraps it rather than replacing it. - Produces: no new public interface; behavior only. ⚠️ Same test-infrastructure note as Tasks 4-5: vitest + raw `playwright` + a real `WebServer(PORT, false, true)`. Port 3177 (checked free at plan-writing time, alongside 3175/3176 from Tasks 4-5). - [ ] **Step 1: Write the failing test** ```typescript // 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 { return page.evaluate(async () => { const res = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }), }); return (await res.json()).data.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); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: register `'test/split-pane-auto-collapse.browser.test.ts',` in `config/test-suites.ts`'s `BROWSER_TEST_GLOBS` first, then `npm run test:browser -- test/split-pane-auto-collapse.browser.test.ts` Expected: FAIL — deleting Pane B's session leaves the split container in place with a dead `SplitTerminalPane`. - [ ] **Step 3: Write minimal implementation** Append to `src/web/public/terminal-split.js`, inside the same `Object.assign(CodemanApp.prototype, {...})` block from Task 5, add one more method and wrap the existing handler registration. Since `_onSessionDeleted` is defined in `app.js` and this file loads after it (load order 7.5 vs app.js's 6), monkey-patch it here rather than editing `app.js` — this keeps all split-pane logic in one file per the "files that change together live together" principle: ```javascript _wireSplitAutoCollapse() { const original = this._onSessionDeleted.bind(this); this._onSessionDeleted = (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. const promoted = this._splitSessionId; this.closeSplitPane(); if (promoted) this.selectSession(promoted); } original(data); }; }, ``` Then, at the bottom of `terminal-split.js` (module scope, after the `Object.assign` call), wire it once at load time: ```javascript document.addEventListener('DOMContentLoaded', () => { if (window.app && typeof window.app._wireSplitAutoCollapse === 'function') { window.app._wireSplitAutoCollapse(); } }); ``` Note: `window.app` is assigned during `app.js` init, which per load order runs before `terminal-split.js`'s `DOMContentLoaded` listener fires (both are deferred to the same event, and script *evaluation* order — app.js at position 6, terminal-split.js at 7.5 — determines listener *registration* order, so app.js's own init logic that creates `window.app` runs first). If this ordering assumption proves wrong when this step is actually run, the fallback is to call `_wireSplitAutoCollapse()` directly from the end of `terminal-split.js` at module-evaluation time instead of inside `DOMContentLoaded`, since `window.app` is what needs to exist, not the DOM. - [ ] **Step 4: Run test to verify it passes** Run: `npm run test:browser -- test/split-pane-auto-collapse.browser.test.ts` Expected: PASS. If it fails specifically because `window.app._onSessionDeleted` was undefined at wire time, switch the wiring approach per the note in Step 3 (call `_wireSplitAutoCollapse()` at module scope, not inside `DOMContentLoaded`) and re-run. - [ ] **Step 5: Run the full gate to check for regressions** Run: `npm test` Expected: PASS - [ ] **Step 6: Commit** ```bash git add src/web/public/terminal-split.js config/test-suites.ts test/split-pane-auto-collapse.browser.test.ts git commit -m "feat(split-pane): auto-collapse split when either session ends" ``` --- ### Task 7: Documentation — architecture-invariants.md entry **Files:** - Modify: `docs/architecture-invariants.md` (one new section, following the existing pattern for feature write-ups in this file) **Interfaces:** none — documentation only. - [ ] **Step 1: Write the section** Add a new section (placed near other terminal/session-UI entries, e.g. after "Header button visibility"): ```markdown **Split-pane sessions** (`showSplitButton`, header button, default OFF): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession()`, never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. Design: `docs/superpowers/specs/2026-09-15-split-pane-sessions-design.md`. ``` - [ ] **Step 2: Commit** ```bash git add docs/architecture-invariants.md git commit -m "docs: add split-pane sessions architecture-invariants entry" ``` --- ## Self-Review Notes (completed during plan authoring) - **Spec coverage**: side-by-side split (Task 5), draggable divider (Task 5), 50/50 default clamped 20–80% (Task 1 + Task 5), button+picker trigger excluding active session (Task 2 + Task 5), Pane A untouched (Global Constraints + every task explicitly avoids editing `terminal-ui.js`), Pane B feature-reduced (Task 4's fileoverview comment + Global Constraints), no persistence (no task writes a localStorage key), auto-collapse both directions (Task 6), subagent windows unchanged (no task touches `subagent-windows.js` or `ultracode-windows.js` — confirmed nothing in this plan needs to, since they already float independent of `.terminal-wrap`'s layout). - **Placeholder scan**: none found — every step has real code or a real doc paragraph. - **Type/name consistency checked**: `SplitTerminalPane` (class name) used identically in Tasks 4, 5, 6; `_splitPane`/`_splitSessionId` (instance state) used identically in Tasks 5, 6; `openSplitPicker`/`openSplitPane`/`closeSplitPane` (method names) used identically in Tasks 2 (button onclick), 5 (definition), 6 (auto-collapse caller). - **Post-approval fixes (SDD pre-flight, before Task 1 dispatch):** Task 2's test originally invented a `buildTestApp()` helper and a `{data:...}` envelope that don't exist for `/api/settings` — corrected against the real `createRouteTestHarness`/`registerSystemRoutes` pattern in `test/routes/system-routes-settings-partial-put.test.ts`. Tasks 4-6 originally used `@playwright/test`'s own runner against a `test/browser/` directory that does not exist in this codebase — corrected to the real pattern (vitest `describe`/`it` + raw `playwright` package + `new WebServer(PORT, false, true)`, files at the top of `test/`, individually registered in `config/test-suites.ts`'s `BROWSER_TEST_GLOBS`) found in `test/terminal-copy-shortcut.test.ts` and `test/tab-rail-resize.browser.test.ts`. Ports assigned: 3175 (Task 4), 3176 (Task 5), 3177 (Task 6), checked against every existing `const PORT =` in `test/*.ts` plus the mobile suite's port constants at fix-time.