Files
Codeman/docs/superpowers/plans/2026-09-15-split-pane-sessions.md
T
timkjrandClaude Sonnet 5 d0f9bdd251 docs: fix plan wording and add execution-environment note
Global Constraints previously read as if local-echo/CJK/accessory-bar
were desktop features; they are mobile-only, and split-pane is the
desktop-only side of that equation. Also names the exact spec section
instead of a loose paraphrase, and adds a worktree/branch note so an
executing subagent knows where this plan runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:22 -05:00

970 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```typescript
// test/routes/system-routes-split-button-setting.test.ts
import { describe, it, expect } from 'vitest';
import { buildTestApp } from './_route-test-utils';
describe('showSplitButton setting', () => {
it('round-trips through PUT and GET /api/settings', async () => {
const app = await buildTestApp();
const putRes = await app.inject({
method: 'PUT',
url: '/api/settings',
payload: { showSplitButton: true },
});
expect(putRes.statusCode).toBe(200);
const getRes = await app.inject({ method: 'GET', url: '/api/settings' });
const body = JSON.parse(getRes.body);
expect(body.data.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
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker()" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one"><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>
```
In `index.html`'s App Settings → Header & Panels checkbox list (find the `appSettingsShowMultiMonitorButton` chip, `id="appSettingsShowMultiMonitorButton"`), add a sibling chip immediately after its closing `</label>`:
```html
<label class="set-chip" data-preview="header" data-preview-order="12"><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>
```
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 `<script>` tag)
- Modify: `CLAUDE.md` (append `terminal-split.js` to the Frontend load-order list)
- Test: `test/browser/split-pane-terminal.test.ts` (new, Playwright — this is browser-only behavior: a real xterm instance and a real WebSocket, which is exactly what `test/browser` exists for per the Testing section of CLAUDE.md)
**Interfaces:**
- Consumes: global `window.CodemanTerminalFont.resolve()` / `.resolveWeights()`, `window.codemanCurrentXtermTheme()`, `window.codemanCurrentSkinIsLight()` (all already attached to `window` by `terminal-ui.js`), global `Terminal`/`FitAddon` (vendor libs, already loaded before this script per load order)
- Produces: `class SplitTerminalPane { constructor(sessionId, mountEl); connect(): void; fit(): void; destroy(): void; }`, exposed as `window.SplitTerminalPane`
- [ ] **Step 1: Write the failing test**
```typescript
// test/browser/split-pane-terminal.test.ts
import { test, expect } from '@playwright/test';
// Assumes a live dev server is reachable at BASE_URL (see docs/browser-testing-guide.md
// for the standard live-server fixture other test/browser specs use).
const BASE_URL = process.env.CODEMAN_TEST_URL || 'http://localhost:3151';
test('SplitTerminalPane connects, renders output, and cleans up on destroy', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
// Create a throwaway shell session to point Pane B at.
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 body = await res.json();
return body.data.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.SplitTerminalPane(id, mount);
pane.connect();
// Wait for the WS to open and at least one output frame to render.
await new Promise((resolve) => setTimeout(resolve, 1500));
const hasContent = mount.querySelector('.xterm-rows') !== null;
pane.destroy();
const cleanedUp = mount.querySelector('.xterm') === null;
document.body.removeChild(mount);
return { hasContent, cleanedUp };
}, sessionId);
expect(result.hasContent).toBe(true);
expect(result.cleanedUp).toBe(true);
await page.evaluate(async (id) => {
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
}, sessionId);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test:browser -- test/browser/split-pane-terminal.test.ts`
Expected: FAIL — `window.SplitTerminalPane` is undefined.
- [ ] **Step 3: Write minimal implementation**
```javascript
// 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/superpowers/specs/2026-09-15-split-pane-sessions-design.md.
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency terminal-ui.js (window.CodemanTerminalFont, codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
*/
(function (global) {
class SplitTerminalPane {
constructor(sessionId, mountEl) {
this.sessionId = sessionId;
this.mountEl = mountEl;
this.terminal = null;
this.fitAddon = null;
this.ws = null;
this._wsReady = false;
}
connect() {
this.terminal = new Terminal({
theme: { ...global.codemanCurrentXtermTheme() },
fontFamily: global.CodemanTerminalFont.resolve(),
...global.CodemanTerminalFont.resolveWeights({}),
fontSize: 14,
lineHeight: 1.2,
cursorBlink: false,
cursorStyle: 'block',
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
scrollback: 5000,
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 }));
}
});
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();
}
} catch {
/* Malformed frame — ignore, matches primary pane's tolerance. */
}
};
}
fit() {
if (!this.fitAddon) return;
this.fitAddon.fit();
this._sendResize();
}
_sendResize() {
if (!this._wsReady || !this.fitAddon) return;
const dims = this.fitAddon.proposeDimensions();
if (!dims) return;
const cols = Math.max(dims.cols, 40);
const rows = Math.max(dims.rows, 10);
this.ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: 'desktop' }));
}
destroy() {
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);
```
In `src/web/public/index.html`, find the `<script src="terminal-ui.js">` tag and add immediately after it:
```html
<script src="terminal-split.js"></script>
```
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)`).
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test:browser -- test/browser/split-pane-terminal.test.ts`
Expected: PASS
- [ ] **Step 5: Run the full gate to check for regressions**
Run: `npm test`
Expected: PASS (this task adds no vitest-visible surface, so this just confirms nothing broke)
- [ ] **Step 6: Commit**
```bash
git add src/web/public/terminal-split.js src/web/public/index.html CLAUDE.md test/browser/split-pane-terminal.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/browser/split-pane-orchestration.test.ts` (new, Playwright)
**Interfaces:**
- Consumes: `window.SplitTerminalPane` (Task 4), `window.CodemanSplitPane.buildSplitPickerSessions` (Task 1), `app.sessions` (`Map<string, {name, ...}>`), `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`)
- [ ] **Step 1: Write the failing test**
```typescript
// test/browser/split-pane-orchestration.test.ts
import { test, expect } from '@playwright/test';
const BASE_URL = process.env.CODEMAN_TEST_URL || 'http://localhost:3151';
test('opening and closing a split reparents and restores .terminal-wrap', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000); // let SSE/handleInit settle
const sessionIds = await page.evaluate(async () => {
const create = 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;
};
return { a: await create(), b: await create() };
});
await page.evaluate((id) => window.app.selectSession(id), sessionIds.a);
await page.waitForTimeout(500);
const beforeSplit = await page.evaluate(
() => document.querySelector('.terminal-split-container') === null
);
expect(beforeSplit).toBe(true);
await page.evaluate((id) => window.app.openSplitPane(id), sessionIds.b);
await page.waitForTimeout(1000);
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.app.closeSplitPane());
await page.waitForTimeout(500);
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' });
}, sessionIds);
});
test('the split picker excludes the active session', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
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' }),
});
return (await res.json()).data.id;
});
await page.evaluate((id) => window.app.selectSession(id), sessionId);
await page.waitForTimeout(500);
const pickerExcludesActive = await page.evaluate((id) => {
window.app.openSplitPicker();
const items = Array.from(document.querySelectorAll('.split-picker-item'));
return !items.some((el) => el.getAttribute('data-session-id') === id);
}, sessionId);
expect(pickerExcludesActive).toBe(true);
await page.evaluate(async (id) => {
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
}, sessionId);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test:browser -- test/browser/split-pane-orchestration.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 = '<div class="split-picker-empty">No other sessions to split with</div>';
} else {
menu.innerHTML = candidates
.map(
(c) =>
`<div class="split-picker-item" data-session-id="${escapeHtml(c.id)}" onclick="app.openSplitPane(${escapeHtml(JSON.stringify(c.id))}); document.getElementById('splitPickerMenu')?.remove();">${escapeHtml(c.label)}</div>`
)
.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 = `
<div class="terminal-pane-b-header">
<span>${escapeHtml(session?.name || 'Session')}</span>
<span class="terminal-pane-b-close" onclick="app.closeSplitPane()">&times;</span>
</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'));
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/browser/split-pane-orchestration.test.ts`
Expected: PASS
- [ ] **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 test/browser/split-pane-orchestration.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/browser/split-pane-auto-collapse.test.ts` (new, Playwright)
**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.
- [ ] **Step 1: Write the failing test**
```typescript
// test/browser/split-pane-auto-collapse.test.ts
import { test, expect } from '@playwright/test';
const BASE_URL = process.env.CODEMAN_TEST_URL || 'http://localhost:3151';
test('deleting the Pane B session auto-collapses the split', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
const sessionIds = await page.evaluate(async () => {
const create = 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;
};
return { a: await create(), b: await create() };
});
await page.evaluate((id) => window.app.selectSession(id), sessionIds.a);
await page.waitForTimeout(500);
await page.evaluate((id) => window.app.openSplitPane(id), sessionIds.b);
await page.waitForTimeout(1000);
// 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' });
}, sessionIds.b);
await page.waitForTimeout(1000);
const collapsed = await page.evaluate(() => ({
hasContainer: document.querySelector('.terminal-split-container') === null,
splitPaneNulled: window.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' });
}, sessionIds.a);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test:browser -- test/browser/split-pane-auto-collapse.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/browser/split-pane-auto-collapse.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 test/browser/split-pane-auto-collapse.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).