mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(split-pane): address Ark0N's fourth pass — PTY-less picker exclusion, hollow chord test, remaining key gates
- buildSplitPickerSessions() now excludes any session with pid === null (exited CLI, tripped PTY-exit breaker, a restore that never re-attached). Pane B has no equivalent of selectSession()'s auto re-attach POST, so a split opened onto one had nothing reading its tmux pane: no terminal events ever arrived and Session.write() silently dropped every keystroke with no ack either way, while the socket itself reported healthy. - Fixed the hollow chord regression test: the synthetic keydowns carried no keyCode, which is what xterm's evaluateKeyboardEvent switches on to produce a data frame at all, so the assertion held regardless of whether the gate fired. Adding real keyCodes surfaced a second, real bug in the Alt+B case: the event bubbles to app.js's own document-level shortcut dispatcher, which really toggles the sidebar and resets the layout attribute the gate reads before Pane B's own (later, non-capture) handler ever sees it — fixed by driving the app's real settings cache instead of only the DOM attribute. - Ported the two remaining primary-pane gates with real consequences: Ctrl+Z (SIGTSTP) is swallowed for every non-shell session, matching terminal-ui.js's reasoning (an Ink/TUI agent loop stops dead with no visible output otherwise), and Shift/Ctrl+Enter now POSTs to /api/sessions/:id/send-key for THIS pane's own session instead of letting xterm send a bare \r, which used to submit an incomplete prompt instead of inserting a newline. Smart-copy Ctrl+C is re-implemented against Pane B's own terminal (copying app.copyTerminalSelection() would have copied Pane A's selection instead). - Updated docs/architecture-invariants.md and docs/split-pane-sessions-plan.md to match, and added CLAUDE.md's missing .split-picker-menu z-index entry. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
fafef0aa00
commit
0b3e086334
@@ -356,7 +356,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` that stops delivering does not always error, so `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. ⚠️ The 15s server keepalive was an SSE **comment** (`:keepalive`), and comments are **invisible to `EventSource` by spec**, so there was nothing a client could observe: it is now the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), which is exactly why the frame had to change type. ⚠️ Staleness is judged **only while the status is `connected`** and the device is online; that guard is the loop breaker, since a forced `connectSSE()` leaves `connected` immediately and cannot re-fire while a reconnect is in flight. ⚠️ The liveness stamp is applied inside `addListener` itself, so every registered handler (the `_SSE_HANDLER_MAP` wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists **only** to be registered, since `EventSource` drops named events nobody listens for. ⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
|
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` that stops delivering does not always error, so `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. ⚠️ The 15s server keepalive was an SSE **comment** (`:keepalive`), and comments are **invisible to `EventSource` by spec**, so there was nothing a client could observe: it is now the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), which is exactly why the frame had to change type. ⚠️ Staleness is judged **only while the status is `connected`** and the device is online; that guard is the loop breaker, since a forced `connectSSE()` leaves `connected` immediately and cannot re-fire while a reconnect is in flight. ⚠️ The liveness stamp is applied inside `addListener` itself, so every registered handler (the `_SSE_HANDLER_MAP` wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists **only** to be registered, since `EventSource` drops named events nobody listens for. ⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
|
||||||
|
|
||||||
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7).
|
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7).
|
||||||
|
|
||||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||||
|
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
|
|||||||
|
|
||||||
### Split-pane sessions
|
### 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. ⚠️ 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`.
|
**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 app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — 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+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, tmux `send-keys -H` for a real 0x0a) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xterm's plain `\r` would submit an incomplete prompt instead of adding a line to it. Smart-copy Ctrl+C is re-implemented against `this.terminal` (Pane B's own) rather than reusing `app.copyTerminalSelection()`, which reads Pane A's terminal and would copy the wrong pane's selection. Ctrl+V stays on xterm's own default paste, since Pane B has no image-paste trap to route it to; Ctrl+Shift+C (the explicit copy chord) is left un-ported as lower value. ⚠️ `buildSplitPickerSessions()` also excludes any session with `pid === null` (an exited CLI, a crash-looped session whose breaker tripped, a restore that never re-attached): Pane B has no equivalent of `selectSession()`'s auto re-attach POST, so a pane opened onto one has nothing reading its tmux pane — no `terminal` events ever arrive, and `Session.write()` silently drops every keystroke with no ack either way, so the loss is invisible behind a socket that reports healthy. 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
|
### Gesture control: the setting
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Split-Pane Sessions — Design Spec
|
# Split-Pane Sessions — Design Spec
|
||||||
|
|
||||||
**Status**: Draft, pending review
|
**Status**: Implemented (v1)
|
||||||
**Author**: Claude (session with Tim), 2026-09-15
|
**Author**: Claude (session with Tim), 2026-09-15
|
||||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||||
|
|
||||||
|
|||||||
@@ -1547,6 +1547,14 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
|||||||
if (detachedIds?.has?.(id)) continue;
|
if (detachedIds?.has?.(id)) continue;
|
||||||
const session = sessions.get(id);
|
const session = sessions.get(id);
|
||||||
if (!session) continue;
|
if (!session) continue;
|
||||||
|
// A session with no PTY attached (exited CLI, a crash-looped session
|
||||||
|
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
||||||
|
// reading its tmux pane. SplitTerminalPane never does selectSession()'s
|
||||||
|
// re-attach POST, so its socket would open onto a pane nothing feeds:
|
||||||
|
// no terminal events, and Session.write() silently drops every keystroke
|
||||||
|
// with no ack either way (Pane B sends no `seq`), so the loss is
|
||||||
|
// invisible — the healthy socket never trips the disconnect banner.
|
||||||
|
if (session.pid === null) continue;
|
||||||
result.push({ id, label: session.name || 'Session' });
|
result.push({ id, label: session.name || 'Session' });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -93,11 +93,16 @@
|
|||||||
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
||||||
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
||||||
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
||||||
// gates at terminal-ui.js's attachCustomKeyEventHandler). Routed through
|
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
||||||
// the same registry-aware predicates so a rebind or a disable restores
|
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
||||||
// plain terminal behavior here too. Ctrl+V is deliberately left on
|
// newline, and smart-copy Ctrl+C). Routed through the same registry-aware
|
||||||
// xterm's own default (plain-text paste): Pane B has no image-paste trap
|
// predicates so a rebind or a disable restores plain terminal behavior
|
||||||
// to route it to, so intercepting it here would only break paste.
|
// here too. Ctrl+V is deliberately left on xterm's own default
|
||||||
|
// (plain-text paste): Pane B has no image-paste trap to route it to, so
|
||||||
|
// intercepting it here would only break paste. Ctrl+Shift+C (the
|
||||||
|
// explicit, never-falls-through copy chord) is also left un-ported —
|
||||||
|
// lower value than the plain Ctrl+C case above, since Pane B is rarely
|
||||||
|
// the pane a user is actively selecting text in.
|
||||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||||
if (
|
if (
|
||||||
@@ -114,6 +119,71 @@
|
|||||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
||||||
|
// swallow — in a plain shell session this is the user's own
|
||||||
|
// job-control tool and must reach the PTY, but in every other mode
|
||||||
|
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
||||||
|
// loop dead. Pane B has its own PTY/session and must not send a
|
||||||
|
// suspend into a non-shell one just because the primary pane's own
|
||||||
|
// gate lives elsewhere.
|
||||||
|
if (
|
||||||
|
ev.type === 'keydown' &&
|
||||||
|
ev.key.toLowerCase() === 'z' &&
|
||||||
|
ev.ctrlKey &&
|
||||||
|
!ev.altKey &&
|
||||||
|
!ev.metaKey &&
|
||||||
|
!ev.shiftKey &&
|
||||||
|
this.sessionMode !== 'shell'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
||||||
|
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
||||||
|
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
||||||
|
// newline from a submit. Without this gate, Pane B's onData would
|
||||||
|
// send that bare \r straight over the WS and submit an incomplete
|
||||||
|
// prompt instead of adding a line to it. Targets THIS pane's own
|
||||||
|
// session (this.sessionId), never the primary pane's
|
||||||
|
// activeSessionId, and has no local-echo overlay of its own to flush
|
||||||
|
// first (Pane B is deliberately plainer — see the fileoverview).
|
||||||
|
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
|
||||||
|
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||||
|
}).catch(() => {
|
||||||
|
/* Best-effort, matching this pane's tolerance elsewhere. */
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
||||||
|
// selection, Ctrl+C copies THIS pane's own selection instead of
|
||||||
|
// sending ^C; with none, it must fall through unchanged or the
|
||||||
|
// interrupt key is lost. Re-implemented against this.terminal rather
|
||||||
|
// than reusing app.copyTerminalSelection(), which reads app.terminal
|
||||||
|
// — Pane A's — and would copy the wrong pane's selection.
|
||||||
|
if (
|
||||||
|
ev.type === 'keydown' &&
|
||||||
|
global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev) &&
|
||||||
|
this.terminal?.hasSelection?.()
|
||||||
|
) {
|
||||||
|
const raw = this.terminal.getSelection();
|
||||||
|
const isColumnSelection = this.terminal._core?._selectionService?._activeSelectionMode === 3;
|
||||||
|
const selection = isColumnSelection ? raw : (global.CodemanCopySelection?.clean?.(raw) ?? raw);
|
||||||
|
if (selection.trim()) {
|
||||||
|
ev.preventDefault();
|
||||||
|
void global.app._copyText?.(selection).then((ok) => {
|
||||||
|
this.terminal?.clearSelection?.();
|
||||||
|
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Nothing worth copying — clear for feedback (a padding-only
|
||||||
|
// selection cleans to '' and this press still falls through to the
|
||||||
|
// PTY as 0x03, matching the primary pane's own rule).
|
||||||
|
this.terminal.clearSelection?.();
|
||||||
|
global.app.showToast?.('Nothing to copy', 'warning');
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -64,4 +64,25 @@ describe('CodemanSplitPane.buildSplitPickerSessions', () => {
|
|||||||
const result = buildSplitPickerSessions(sessions, ['a'], 'a');
|
const result = buildSplitPickerSessions(sessions, ['a'], 'a');
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('excludes a session with no PTY attached (pid === null)', () => {
|
||||||
|
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||||
|
const sessions = new Map([
|
||||||
|
['a', { name: 'w1-codeman' }],
|
||||||
|
['b', { name: 'w2-exited', pid: null }],
|
||||||
|
['c', { name: 'w3-alive', pid: 12345 }],
|
||||||
|
]);
|
||||||
|
const result = buildSplitPickerSessions(sessions, ['a', 'b', 'c'], 'a');
|
||||||
|
expect(result).toEqual([{ id: 'c', label: 'w3-alive' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes a detached session even when it also has no PTY', () => {
|
||||||
|
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||||
|
const sessions = new Map([
|
||||||
|
['a', { name: 'w1-codeman' }],
|
||||||
|
['b', { name: 'w2-detached', pid: null }],
|
||||||
|
]);
|
||||||
|
const result = buildSplitPickerSessions(sessions, ['a', 'b'], 'a', new Set(['b']));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
|
|
||||||
const sentFrames = await page.evaluate(async (id) => {
|
const result = await page.evaluate(async (id) => {
|
||||||
const mount = document.createElement('div');
|
const mount = document.createElement('div');
|
||||||
mount.style.width = '400px';
|
mount.style.width = '400px';
|
||||||
mount.style.height = '300px';
|
mount.style.height = '300px';
|
||||||
@@ -215,16 +215,83 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
const fire = (init: KeyboardEventInit) => {
|
const fire = (init: KeyboardEventInit) => {
|
||||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }));
|
textarea.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }));
|
||||||
};
|
};
|
||||||
fire({ key: 'k', code: 'KeyK', ctrlKey: true }); // command palette
|
// keyCode is what xterm's evaluateKeyboardEvent switches on to decide
|
||||||
fire({ key: '1', code: 'Digit1', altKey: true }); // Alt+1 tab switch
|
// whether to produce a data frame at all — at keyCode 0 (unset) it can
|
||||||
fire({ key: 'b', code: 'KeyB', altKey: true }); // Alt+B sidebar toggle
|
// never emit bytes, so the assertion below held regardless of whether
|
||||||
|
// the custom key handler's gate actually fired. Real values (K=75,
|
||||||
|
// 1=49, B=66) are what a real keypress carries.
|
||||||
|
const app = window.app as any;
|
||||||
|
fire({ key: 'k', code: 'KeyK', keyCode: 75, ctrlKey: true }); // command palette
|
||||||
|
fire({ key: '1', code: 'Digit1', keyCode: 49, altKey: true }); // Alt+1 tab switch
|
||||||
|
|
||||||
|
// Ctrl+Z (SIGTSTP): this pane's own sessionMode is undefined (no `mode`
|
||||||
|
// opt passed to the constructor above), so `this.sessionMode !== 'shell'`
|
||||||
|
// holds and the gate must block it, mirroring a non-shell (agent) mode.
|
||||||
|
fire({ key: 'z', code: 'KeyZ', keyCode: 90, ctrlKey: true });
|
||||||
|
|
||||||
|
// Shift+Enter: must never reach the PTY as a bare \r (that would submit
|
||||||
|
// an incomplete prompt instead of inserting a newline) — it goes out as
|
||||||
|
// a POST to /api/sessions/:id/send-key instead.
|
||||||
|
const sendKeyCalls: unknown[] = [];
|
||||||
|
const realFetch = window.fetch.bind(window);
|
||||||
|
window.fetch = ((...args: Parameters<typeof fetch>) => {
|
||||||
|
const url = String(args[0]);
|
||||||
|
if (url.includes('/send-key')) {
|
||||||
|
sendKeyCalls.push(args[1] ? JSON.parse((args[1] as RequestInit).body as string) : null);
|
||||||
|
}
|
||||||
|
return realFetch(...args);
|
||||||
|
}) as typeof fetch;
|
||||||
|
fire({ key: 'Enter', code: 'Enter', keyCode: 13, shiftKey: true });
|
||||||
|
window.fetch = realFetch;
|
||||||
|
|
||||||
|
// Smart-copy Ctrl+C: with a real selection in THIS pane's own terminal,
|
||||||
|
// Ctrl+C must copy it (never send 0x03) and must copy Pane B's
|
||||||
|
// selection, not Pane A's. app._copyText is stubbed rather than relying
|
||||||
|
// on a real clipboard, which headless Chromium may refuse permission
|
||||||
|
// for.
|
||||||
|
pane.terminal.write('SPLITPANE_COPY_MARKER');
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
pane.terminal.selectAll();
|
||||||
|
let copiedText: string | null = null;
|
||||||
|
const realCopyText = app._copyText;
|
||||||
|
app._copyText = async (text: string) => {
|
||||||
|
copiedText = text;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
fire({ key: 'c', code: 'KeyC', keyCode: 67, ctrlKey: true });
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
app._copyText = realCopyText;
|
||||||
|
|
||||||
|
// Alt+B only reaches shouldToggleSessionSidebarFromShortcut's gate when
|
||||||
|
// the sidebar layout is actually active (app.js:4325) — under the
|
||||||
|
// default header-strip layout the app doesn't treat Alt+B as its own
|
||||||
|
// shortcut either, so Pane A forwards the same `ESC b` to its own PTY.
|
||||||
|
// Assert the gate where it is meant to hold: sidebar layout active.
|
||||||
|
//
|
||||||
|
// Setting only the `data-session-list` attribute is not enough: this
|
||||||
|
// event bubbles (matching how a real keypress reaches xterm), so it
|
||||||
|
// also reaches app.js's OWN document-level capture-phase shortcut
|
||||||
|
// dispatcher, which matches the same Alt+B binding and calls the real
|
||||||
|
// toggleSessionSidebar() — that reads the persisted settings (still
|
||||||
|
// 'header'), re-runs applySessionListLayout(), and resets the
|
||||||
|
// attribute back to 'header' before xterm's own (later, non-capture)
|
||||||
|
// key handler ever sees it. Persisting the setting through the app's
|
||||||
|
// own settings cache keeps the attribute stable across that bubble.
|
||||||
|
const prevSettings = { ...app.loadAppSettingsFromStorage() };
|
||||||
|
app._cachedAppSettings = { ...prevSettings, sessionListLayout: 'sidebar' };
|
||||||
|
app.applySessionListLayout();
|
||||||
|
fire({ key: 'b', code: 'KeyB', keyCode: 66, altKey: true }); // Alt+B sidebar toggle
|
||||||
|
app._cachedAppSettings = prevSettings;
|
||||||
|
app.applySessionListLayout();
|
||||||
|
|
||||||
pane.destroy();
|
pane.destroy();
|
||||||
document.body.removeChild(mount);
|
document.body.removeChild(mount);
|
||||||
return sent;
|
return { sent, sendKeyCalls, copiedText };
|
||||||
}, sessionId);
|
}, sessionId);
|
||||||
|
|
||||||
expect(sentFrames.every((f) => JSON.parse(f).t !== 'i')).toBe(true);
|
expect(result.sent.every((f) => JSON.parse(f).t !== 'i')).toBe(true);
|
||||||
|
expect(result.sendKeyCalls).toEqual([{ key: 'S-Enter' }]);
|
||||||
|
expect(result.copiedText).toContain('SPLITPANE_COPY_MARKER');
|
||||||
|
|
||||||
await page.evaluate(async (id) => {
|
await page.evaluate(async (id) => {
|
||||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||||
|
|||||||
Reference in New Issue
Block a user