diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 2e4e7039..a4b2e076 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -404,7 +404,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se ### Split-pane sessions -**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): 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; `.btn-split` is hard-hidden on phones in mobile.css regardless of the setting, and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. 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), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, 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(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without an explicit `this._splitPane?.fit()` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket leaves Pane B visibly dead (a message written into its own xterm buffer) rather than silently swallowing keystrokes with nothing on screen to explain why — there is no reconnect logic for v1, matching the "deliberately plainer than Pane A" design. 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/split-pane-sessions-plan.md`. +**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): 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; `.btn-split` is hard-hidden on phones in mobile.css regardless of the setting, and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. 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), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, 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(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without an explicit `this._splitPane?.fit()` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket leaves Pane B visibly dead (a message written into its own xterm buffer) rather than silently swallowing keystrokes with nothing on screen to explain why — there is no reconnect logic for v1, matching the "deliberately plainer than Pane A" design. 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. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same three app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+V stays on xterm's own default paste, since Pane B has no image-paste trap to route it to. Pane B's input frames deliberately carry no `cid`/`seq` (`ws-routes.ts` supports that), matching the no-overlay/no-IME "deliberately plainer" list above, since it has no exactly-once delivery layer to key them against. Design: `docs/split-pane-sessions-plan.md`. ### Gesture control: the setting diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index af1554ff..9a496a92 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -73,6 +73,10 @@ 'File Viewer': '文件查看器', 'Open file viewer': '打开文件查看器', 'Open Codeman across all displays': '在所有显示器上打开 {name}', + 'Split: open a second session beside this one': '分屏:在旁边打开第二个会话', + 'Split: close the second session': '分屏:关闭第二个会话', + 'Close split': '关闭分屏', + 'No other sessions to split with': '没有其他可用于分屏的会话', 'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体', 'Open ultracode workflow agents': '打开 Ultracode 工作流智能体', Notifications: '通知', diff --git a/src/web/public/index.html b/src/web/public/index.html index 4c970475..dd972edc 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1925,7 +1925,7 @@ - + diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 0e554240..74f223fc 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -675,6 +675,12 @@ body.tab-rail-resizing * { user-select: none !important; } +body.split-pane-resizing, +body.split-pane-resizing * { + cursor: col-resize !important; + user-select: none !important; +} + @media (prefers-reduced-motion: reduce) { .tab-rail, .tab-rail-resize-handle { @@ -18662,6 +18668,12 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle } .terminal-pane-b-close { + /* A native ` ) .join(''); } @@ -340,6 +393,11 @@ Object.assign(CodemanApp.prototype, { // the home screen still created the container and connected Pane B, just // behind the opaque overlay with nothing visible to show for it. if (!this.activeSessionId) return; + // A web tab hides `.terminal-wrap`'s container via CSS with nothing + // gating the button itself, and `activeSessionId` survives openWebview() + // — without this, picking a session opens Pane B's socket behind a + // hidden container with nothing on screen to show for it. + if (this.activeWebviewId) return; // A stale picker click (opened before switching tabs) or clicking Pane // B's own session tab while split can otherwise land here with // sessionId === activeSessionId: two live WebSockets to the same @@ -363,7 +421,7 @@ Object.assign(CodemanApp.prototype, { paneB.innerHTML = `
${escapeHtml(session?.name || 'Session')} - × +
`; @@ -472,11 +530,18 @@ Object.assign(CodemanApp.prototype, { }); }; - const onUp = () => { + const onUp = (e) => { dragging = false; divider.classList.remove('dragging'); - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); + document.body.classList.remove('split-pane-resizing'); + try { + divider.releasePointerCapture(e.pointerId); + } catch { + /* Already released (pointercancel/lostpointercapture beat us here). */ + } + divider.removeEventListener('pointermove', onMove); + divider.removeEventListener('pointerup', onUp); + divider.removeEventListener('pointercancel', onUp); if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; @@ -492,11 +557,27 @@ Object.assign(CodemanApp.prototype, { this._splitPane?.fit(); }; - divider.addEventListener('mousedown', () => { + // Pointer events + setPointerCapture (mirrors tab-rail-resize.js) instead + // of mousedown/document-level mousemove: a plain mousedown drag selects + // the text under the cursor as it crosses both terminals, and pointer + // capture routes move/up straight to `divider` regardless of what's under + // the cursor mid-drag, so no document-level listener leak is possible if + // the pointer is released off-window. `body.split-pane-resizing` (mirrors + // `body.tab-rail-resizing`) locks the cursor/selection for the drag. + divider.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + e.preventDefault(); dragging = true; divider.classList.add('dragging'); - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); + document.body.classList.add('split-pane-resizing'); + try { + divider.setPointerCapture(e.pointerId); + } catch { + /* Capture failed — the drag still works via the listeners below. */ + } + divider.addEventListener('pointermove', onMove); + divider.addEventListener('pointerup', onUp); + divider.addEventListener('pointercancel', onUp); }); }, }); diff --git a/test/app-settings-structure.test.ts b/test/app-settings-structure.test.ts index cdbbe1aa..813f3610 100644 --- a/test/app-settings-structure.test.ts +++ b/test/app-settings-structure.test.ts @@ -99,7 +99,10 @@ describe('App Settings modal structure', () => { for (const [, attrs, body] of previewed) { const kind = attrs.match(/data-preview="([a-z]+)"/)?.[1]; expect(['header', 'panel', 'toolbar', 'float']).toContain(kind); - expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+"/); + // A decimal (e.g. "11.5") is allowed — Split sits between Multi-monitor + // (11) and Ultracode Agents (12) in the real header, and Number() + // parses it fine for the preview's own sort. + expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+(\.\d+)?"/); // A text token replaces the icon for readouts (plan usage, CPU, font size). const hasIcon = body.includes('class="set-chip-ico') || attrs.includes('data-preview-text='); expect(hasIcon, `chip ${body} has nothing to render in the preview`).toBe(true); diff --git a/test/split-pane-terminal.browser.test.ts b/test/split-pane-terminal.browser.test.ts index f747c0b0..4c168943 100644 --- a/test/split-pane-terminal.browser.test.ts +++ b/test/split-pane-terminal.browser.test.ts @@ -167,4 +167,67 @@ describe('SplitTerminalPane in a real browser', () => { await fetch(`/api/sessions/${id}`, { method: 'DELETE' }); }, sessionId); }); + + it('gates app-level chords out of Pane B instead of forwarding their raw bytes', async () => { + // Regression guard for PR #453's Ctrl+K/Alt+1/Alt+B leak: Pane B had no + // attachCustomKeyEventHandler of its own, so the document capture-phase + // shortcut handler's preventDefault() (which does not stop xterm) left + // every one of these chords ALSO writing its raw byte/escape sequence into + // Pane B's live PTY on top of whatever the app action did to Pane A. + 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 id = (await res.json()).data.session.id; + await fetch(`/api/sessions/${id}/shell`, { method: 'POST' }); + return id; + }); + + const sentFrames = 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); + await pane.connect(); + await new Promise((resolve) => { + const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100)); + check(); + }); + + const sent: string[] = []; + const realSend = pane.ws.send.bind(pane.ws); + pane.ws.send = (payload: string) => { + sent.push(payload); + return realSend(payload); + }; + + pane.terminal.focus(); + // Dispatch straight at xterm's own textarea, matching how a real + // keypress reaches attachCustomKeyEventHandler — page.keyboard.press() + // goes through the OS/CDP input pipeline and would also trigger the + // app's document-capture handler (opening a real command palette), + // which is not what this test is isolating. + const textarea = (pane.terminal as any)._core?.textarea || (pane.terminal as any).textarea; + const fire = (init: KeyboardEventInit) => { + textarea.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })); + }; + fire({ key: 'k', code: 'KeyK', ctrlKey: true }); // command palette + fire({ key: '1', code: 'Digit1', altKey: true }); // Alt+1 tab switch + fire({ key: 'b', code: 'KeyB', altKey: true }); // Alt+B sidebar toggle + + pane.destroy(); + document.body.removeChild(mount); + return sent; + }, sessionId); + + expect(sentFrames.every((f) => JSON.parse(f).t !== 'i')).toBe(true); + + await page.evaluate(async (id) => { + await fetch(`/api/sessions/${id}`, { method: 'DELETE' }); + }, sessionId); + }); });