`), `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 = '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 = `
`;
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).