Files
Codeman/docs/superpowers/plans/2026-09-15-split-pane-sessions.md
T
timkjrandClaude Sonnet 5 28d3bd7da8 docs(plan): fix Task 2/4/5/6 tests against real test infrastructure
Preflight scan for SDD execution caught two classes of defect before
dispatch: Task 2's test invented a buildTestApp() helper and response
envelope that don't exist for /api/settings; Tasks 4-6 used
@playwright/test's runner against a test/browser/ directory that
doesn't exist in this codebase. Both corrected against real patterns
found in existing tests (system-routes-settings-partial-put.test.ts,
terminal-copy-shortcut.test.ts, tab-rail-resize.browser.test.ts).

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

49 KiB
Raw Blame History

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

// 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):

// ═══════════════════════════════════════════════════════════════
// 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
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.

// 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<typeof import('node:fs')>();
  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:

    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:

<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>:

<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):

// 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
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):

/* 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
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/split-pane-terminal.browser.test.ts (new)
  • Modify: config/test-suites.ts (register the new test file path in BROWSER_TEST_GLOBS, or npm run test:browser silently never runs it — vitest treats "no files matched" as success, and a glob-less array here means literally nothing runs this file unless it's listed)

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

⚠️ This codebase's browser tests do NOT use @playwright/test's own runner, and there is no test/browser/ directory. They are ordinary vitest describe/it files that import chromium from the raw playwright package and spin up a REAL in-process server via new WebServer(PORT, false, true) (port, https=false, testMode=true — testMode makes TmuxManager/Session spawn a real echo PTY instead of real tmux, per test/terminal-copy-shortcut.test.ts, test/tab-rail-resize.browser.test.ts). Test files live at the top of test/, individually named, and npm run test:browser/CI only run files explicitly listed in config/test-suites.ts's BROWSER_TEST_GLOBS array — there is no directory glob. Port 3175 (checked against every existing const PORT = in test/*.ts and the mobile suite's port constants file at plan-writing time — free).

  • Step 1: Write the failing test
// test/split-pane-terminal.browser.test.ts
/** @fileoverview Real Chromium + real WebSocket coverage for SplitTerminalPane (Task 4 of the split-pane-sessions plan). */
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 = 3175;
const BASE_URL = `http://localhost:${PORT}`;

describe('SplitTerminalPane 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);

  it('connects, echoes real PTY output, and cleans up on destroy', async () => {
    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;
    });

    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 as any).SplitTerminalPane(id, mount);
      pane.connect();

      // Wait for the WS to open, then send a real input frame — testMode's
      // echo PTY (TEST_PTY_SCRIPT) echoes each byte back exactly once, which
      // is what proves the WS round-trip actually reaches a real PTY and back,
      // not just that xterm can render locally-written text.
      await new Promise((resolve) => {
        const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
        check();
      });
      pane.ws.send(JSON.stringify({ t: 'i', d: 'SPLITPANE_MARKER\r' }));

      const hasEcho = await new Promise((resolve) => {
        const deadline = Date.now() + 5000;
        const poll = () => {
          const buf = pane.terminal.buffer.active;
          for (let i = 0; i < buf.length; i++) {
            if (buf.getLine(i)?.translateToString(true).includes('SPLITPANE_MARKER')) {
              resolve(true);
              return;
            }
          }
          if (Date.now() > deadline) resolve(false);
          else setTimeout(poll, 100);
        };
        poll();
      });

      pane.destroy();
      const cleanedUp = mount.querySelector('.xterm') === null;
      document.body.removeChild(mount);

      return { hasEcho, cleanedUp };
    }, sessionId);

    expect(result.hasEcho).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/split-pane-terminal.browser.test.ts Expected: This file is not yet listed in config/test-suites.ts's BROWSER_TEST_GLOBS, so vitest reports 0 files matched and exits 0 (a vacuous "pass" — read the file count, not the color, per CLAUDE.md's Testing section). Register the file in BROWSER_TEST_GLOBS FIRST (add 'test/split-pane-terminal.browser.test.ts', to the array in config/test-suites.ts), then re-run: now it actually executes and FAILs — window.SplitTerminalPane is undefined.

  • Step 3: Write minimal implementation
// 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:

<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)).

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
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<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)

⚠️ 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
// 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<string> {
    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:

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/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
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
// 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<string> {
    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:

  _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:

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
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"):

**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
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.