diff --git a/config/test-suites.ts b/config/test-suites.ts
index 599bbdba..f0a3fb1f 100644
--- a/config/test-suites.ts
+++ b/config/test-suites.ts
@@ -31,6 +31,7 @@ export const BROWSER_TEST_GLOBS = [
'test/capture-geometry-retry.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts',
+ 'test/split-pane-orchestration.browser.test.ts',
];
/**
diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js
index b6479e7e..6af60796 100644
--- a/src/web/public/terminal-split.js
+++ b/src/web/public/terminal-split.js
@@ -118,3 +118,126 @@
global.SplitTerminalPane = SplitTerminalPane;
})(window);
+
+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);
+ });
+ },
+});
diff --git a/test/split-pane-orchestration.browser.test.ts b/test/split-pane-orchestration.browser.test.ts
new file mode 100644
index 00000000..ea70cfa7
--- /dev/null
+++ b/test/split-pane-orchestration.browser.test.ts
@@ -0,0 +1,105 @@
+/** @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' }),
+ });
+ // POST /api/sessions nests the session under data.session, and mode:'shell'
+ // does not spawn a PTY on creation alone (pid: null, no pane) — an explicit
+ // POST .../shell is what actually starts it (both found and fixed by Task 4's
+ // implementer against this exact pattern; carried forward here so this task
+ // does not rediscover the same two bugs).
+ const id = (await res.json()).data.session.id;
+ await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
+ return id;
+ });
+ }
+
+ it('opening and closing a split reparents and restores .terminal-wrap', async () => {
+ const idA = await createShellSession();
+ const idB = await createShellSession();
+
+ await page.evaluate((id) => (window as any).app.selectSession(id), idA);
+ await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
+
+ expect(await page.evaluate(() => document.querySelector('.terminal-split-container') === null)).toBe(true);
+
+ await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
+ await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
+
+ const duringSplit = await page.evaluate(() => ({
+ hasContainer: document.querySelector('.terminal-split-container') !== null,
+ wrapIsChildOfContainer: document.querySelector('.terminal-split-container > .terminal-wrap') !== null,
+ hasPaneB: document.querySelector('.terminal-pane-b') !== null,
+ }));
+ expect(duringSplit.hasContainer).toBe(true);
+ expect(duringSplit.wrapIsChildOfContainer).toBe(true);
+ expect(duringSplit.hasPaneB).toBe(true);
+
+ await page.evaluate(() => (window as any).app.closeSplitPane());
+ await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
+ timeout: 10000,
+ });
+
+ const afterClose = await page.evaluate(() => ({
+ hasContainer: document.querySelector('.terminal-split-container') === null,
+ wrapRestored: document.querySelector('.main .terminal-wrap') !== null,
+ }));
+ expect(afterClose.hasContainer).toBe(true);
+ expect(afterClose.wrapRestored).toBe(true);
+
+ await page.evaluate(
+ async (ids) => {
+ await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
+ await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
+ },
+ { a: idA, b: idB }
+ );
+ });
+
+ it('the split picker excludes the active session', async () => {
+ const id = await createShellSession();
+
+ await page.evaluate((sid) => (window as any).app.selectSession(sid), id);
+ await page.waitForFunction((sid) => (window as any).app.activeSessionId === sid, id, { timeout: 10000 });
+
+ const pickerExcludesActive = await page.evaluate((sid) => {
+ (window as any).app.openSplitPicker();
+ const items = Array.from(document.querySelectorAll('.split-picker-item'));
+ return !items.some((el) => el.getAttribute('data-session-id') === sid);
+ }, id);
+ expect(pickerExcludesActive).toBe(true);
+
+ await page.evaluate(async (sid) => {
+ await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
+ }, id);
+ });
+});