From 2abf328db87632026cf7910a5f3eabd1a963bdfc Mon Sep 17 00:00:00 2001 From: timkjr Date: Wed, 16 Sep 2026 10:35:48 -0500 Subject: [PATCH] feat(split-pane): add open/close orchestration, picker, and divider drag Co-Authored-By: Claude Sonnet 5 --- config/test-suites.ts | 1 + src/web/public/terminal-split.js | 123 ++++++++++++++++++ test/split-pane-orchestration.browser.test.ts | 105 +++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 test/split-pane-orchestration.browser.test.ts 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 = ` +
+ ${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); + }); + }, +}); 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); + }); +});