aicodeman 1.31.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +7 -6
  2. package/dist/web/public/admin-ui.js.gz +0 -0
  3. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  4. package/dist/web/public/{app.8df358cd.js → app.a792bf71.js} +2 -2
  5. package/dist/web/public/app.a792bf71.js.br +0 -0
  6. package/dist/web/public/{app.8df358cd.js.gz → app.a792bf71.js.gz} +0 -0
  7. package/dist/web/public/approvals-ui.js.gz +0 -0
  8. package/dist/web/public/{constants.85ed12f3.js → constants.b26748e9.js} +49 -0
  9. package/dist/web/public/constants.b26748e9.js.br +0 -0
  10. package/dist/web/public/constants.b26748e9.js.gz +0 -0
  11. package/dist/web/public/cron-ui.js.gz +0 -0
  12. package/dist/web/public/entrance-animations.js.gz +0 -0
  13. package/dist/web/public/home-sessions.js.gz +0 -0
  14. package/dist/web/public/host-wake-ui.js.gz +0 -0
  15. package/dist/web/public/i18n.d22be5c4.js +1 -0
  16. package/dist/web/public/i18n.d22be5c4.js.br +0 -0
  17. package/dist/web/public/i18n.d22be5c4.js.gz +0 -0
  18. package/dist/web/public/{image-input.cd4b97c4.js → image-input.a216cf9e.js} +8 -4
  19. package/dist/web/public/image-input.a216cf9e.js.br +0 -0
  20. package/dist/web/public/image-input.a216cf9e.js.gz +0 -0
  21. package/dist/web/public/index.html +14 -11
  22. package/dist/web/public/index.html.br +0 -0
  23. package/dist/web/public/index.html.gz +0 -0
  24. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  25. package/dist/web/public/{keyboard-accessory.2cf04f17.js → keyboard-accessory.c1e7c7b7.js} +280 -15
  26. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.br +0 -0
  27. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  28. package/dist/web/public/mobile-handlers.6f354a87.js.gz +0 -0
  29. package/dist/web/public/mobile-overview.js.gz +0 -0
  30. package/dist/web/public/mobile.6caaa28a.css.gz +0 -0
  31. package/dist/web/public/notification-manager.36ea4624.js.gz +0 -0
  32. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  33. package/dist/web/public/panels-ui.28df5053.js.gz +0 -0
  34. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  35. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  36. package/dist/web/public/readmymind-ui.js.gz +0 -0
  37. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  38. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  39. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  40. package/dist/web/public/session-lineage.js.gz +0 -0
  41. package/dist/web/public/session-ui.7df17fd3.js +88 -0
  42. package/dist/web/public/session-ui.7df17fd3.js.br +0 -0
  43. package/dist/web/public/session-ui.7df17fd3.js.gz +0 -0
  44. package/dist/web/public/settings-ui.df5ffb68.js +78 -0
  45. package/dist/web/public/settings-ui.df5ffb68.js.br +0 -0
  46. package/dist/web/public/settings-ui.df5ffb68.js.gz +0 -0
  47. package/dist/web/public/styles.74f312e5.css +1 -0
  48. package/dist/web/public/styles.74f312e5.css.br +0 -0
  49. package/dist/web/public/styles.74f312e5.css.gz +0 -0
  50. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  51. package/dist/web/public/sw.js.gz +0 -0
  52. package/dist/web/public/tab-rail-resize.42c24949.js.gz +0 -0
  53. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
  54. package/dist/web/public/terminal-split.js +804 -0
  55. package/dist/web/public/terminal-split.js.br +0 -0
  56. package/dist/web/public/terminal-split.js.gz +0 -0
  57. package/dist/web/public/terminal-ui.32fbeaf6.js +2 -0
  58. package/dist/web/public/terminal-ui.32fbeaf6.js.br +0 -0
  59. package/dist/web/public/terminal-ui.32fbeaf6.js.gz +0 -0
  60. package/dist/web/public/ultracode-panel.js.gz +0 -0
  61. package/dist/web/public/ultracode-windows.js.gz +0 -0
  62. package/dist/web/public/upload.html.gz +0 -0
  63. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  64. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  65. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  66. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  67. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  68. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  69. package/dist/web/public/vendor/xterm-predictive-echo.bd6882b8.js.gz +0 -0
  70. package/dist/web/public/vendor/xterm-zerolag-input.6fee72f2.js.gz +0 -0
  71. package/dist/web/public/vendor/xterm.css.gz +0 -0
  72. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  73. package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
  74. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  75. package/dist/web/public/webview-tabs.js.gz +0 -0
  76. package/dist/web/server.d.ts.map +1 -1
  77. package/dist/web/server.js +13 -5
  78. package/dist/web/server.js.map +1 -1
  79. package/package.json +1 -1
  80. package/dist/web/public/app.8df358cd.js.br +0 -0
  81. package/dist/web/public/constants.85ed12f3.js.br +0 -0
  82. package/dist/web/public/constants.85ed12f3.js.gz +0 -0
  83. package/dist/web/public/i18n.83924614.js +0 -1
  84. package/dist/web/public/i18n.83924614.js.br +0 -0
  85. package/dist/web/public/i18n.83924614.js.gz +0 -0
  86. package/dist/web/public/image-input.cd4b97c4.js.br +0 -0
  87. package/dist/web/public/image-input.cd4b97c4.js.gz +0 -0
  88. package/dist/web/public/keyboard-accessory.2cf04f17.js.br +0 -0
  89. package/dist/web/public/keyboard-accessory.2cf04f17.js.gz +0 -0
  90. package/dist/web/public/session-ui.d4aea71a.js +0 -88
  91. package/dist/web/public/session-ui.d4aea71a.js.br +0 -0
  92. package/dist/web/public/session-ui.d4aea71a.js.gz +0 -0
  93. package/dist/web/public/settings-ui.4762f480.js +0 -78
  94. package/dist/web/public/settings-ui.4762f480.js.br +0 -0
  95. package/dist/web/public/settings-ui.4762f480.js.gz +0 -0
  96. package/dist/web/public/styles.e34fea04.css +0 -1
  97. package/dist/web/public/styles.e34fea04.css.br +0 -0
  98. package/dist/web/public/styles.e34fea04.css.gz +0 -0
  99. package/dist/web/public/terminal-ui.35812aa2.js +0 -2
  100. package/dist/web/public/terminal-ui.35812aa2.js.br +0 -0
  101. package/dist/web/public/terminal-ui.35812aa2.js.gz +0 -0
@@ -0,0 +1,804 @@
1
+ // src/web/public/terminal-split.js
2
+
3
+ /**
4
+ * @fileoverview SplitTerminalPane — a second, independent live terminal pane
5
+ * ("Pane B") for split-view sessions. Deliberately plainer than the primary
6
+ * pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
7
+ * CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
8
+ * feature by nature — see docs/split-pane-sessions-plan.md.
9
+ *
10
+ * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
11
+ * @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
12
+ * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
13
+ * @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
14
+ */
15
+
16
+ (function (global) {
17
+ /**
18
+ * Minimal chunked write for Pane B's own xterm instance — write() in
19
+ * TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
20
+ * giant synchronous write that blocks the main thread while parsing a long
21
+ * scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
22
+ * (terminal-ui.js): that one is wired into session-switch generation
23
+ * counters and the live-output gate this simpler, independently
24
+ * created/destroyed pane has no equivalent of.
25
+ */
26
+ function writeChunked(terminal, buffer, isDestroyed) {
27
+ if (!buffer) return Promise.resolve();
28
+ if (buffer.length <= TERMINAL_CHUNK_SIZE) {
29
+ terminal.write(buffer);
30
+ return Promise.resolve();
31
+ }
32
+ // Resolves once the LAST chunk is written (or the pane was destroyed
33
+ // mid-replay), so _loadBuffer() below can hold its single-flight flag
34
+ // across the whole replay rather than just the fetch that precedes it.
35
+ return new Promise((resolve) => {
36
+ let offset = 0;
37
+ const writeNext = () => {
38
+ if (isDestroyed() || !terminal) {
39
+ resolve();
40
+ return;
41
+ }
42
+ const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
43
+ offset += chunk.length;
44
+ terminal.write(chunk);
45
+ if (offset < buffer.length) {
46
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
47
+ else setTimeout(writeNext, 16);
48
+ } else {
49
+ resolve();
50
+ }
51
+ };
52
+ writeNext();
53
+ });
54
+ }
55
+
56
+ class SplitTerminalPane {
57
+ constructor(sessionId, mountEl, opts = {}) {
58
+ this.sessionId = sessionId;
59
+ this.mountEl = mountEl;
60
+ this.sessionMode = opts.mode;
61
+ this.fontSettings = opts.fontSettings || {};
62
+ // Live reference (not a snapshot) to the app's detachedSessions Set —
63
+ // detaching this session AFTER the split is already open must still be
64
+ // seen by _sendResize() below, or it re-creates the exact PTY-size
65
+ // fight the split picker already refuses to open at pick time.
66
+ this.detachedSessions = opts.detachedSessions;
67
+ this.terminal = null;
68
+ this.fitAddon = null;
69
+ this.ws = null;
70
+ this._wsReady = false;
71
+ this._destroyed = false;
72
+ // Single-flight state for _loadBuffer()/_refreshBuffer() below.
73
+ this._bufferLoading = false;
74
+ this._bufferRefreshPending = false;
75
+ }
76
+
77
+ async connect() {
78
+ const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
79
+ this.terminal = new Terminal({
80
+ theme: { ...global.codemanCurrentXtermTheme() },
81
+ fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
82
+ ...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
83
+ fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
84
+ lineHeight: 1.2,
85
+ cursorBlink: false,
86
+ cursorStyle: 'block',
87
+ minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
88
+ scrollback: DEFAULT_SCROLLBACK,
89
+ allowTransparency: true,
90
+ allowProposedApi: true,
91
+ });
92
+
93
+ this.fitAddon = new FitAddon.FitAddon();
94
+ this.terminal.loadAddon(this.fitAddon);
95
+ this.terminal.open(this.mountEl);
96
+ this.fitAddon.fit();
97
+
98
+ this.terminal.onData((data) => {
99
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
100
+ this.ws.send(JSON.stringify({ t: 'i', d: data }));
101
+ }
102
+ });
103
+
104
+ // Pane B has no gates of its own by default, so every app-level chord
105
+ // that the document capture-phase handler (app.js) only preventDefault()s
106
+ // — never stopPropagation()s — reaches xterm here too and writes its raw
107
+ // byte/escape sequence into THIS session's PTY on top of whatever the app
108
+ // action already did to Pane A (COD-153; mirrors the primary pane's own
109
+ // gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
110
+ // Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
111
+ // newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
112
+ // registry-aware predicates so a rebind or a disable restores plain
113
+ // terminal behavior here too. Ctrl+V is deliberately left on xterm's own
114
+ // default (plain-text paste): Pane B has no image-paste trap to route it
115
+ // to, so intercepting it here would only break paste.
116
+ this.terminal.attachCustomKeyEventHandler((ev) => {
117
+ if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
118
+ if (
119
+ ev.altKey &&
120
+ !ev.ctrlKey &&
121
+ !ev.shiftKey &&
122
+ /^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
123
+ ) {
124
+ return false;
125
+ }
126
+ if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
127
+ return false;
128
+ }
129
+ if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
130
+ return false;
131
+ }
132
+ // Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
133
+ // swallow — in a plain shell session this is the user's own
134
+ // job-control tool and must reach the PTY, but in every other mode
135
+ // (claude/omp/pi/codex/...) it silently stops an unattended agent
136
+ // loop dead. Pane B has its own PTY/session and must not send a
137
+ // suspend into a non-shell one just because the primary pane's own
138
+ // gate lives elsewhere.
139
+ if (
140
+ ev.type === 'keydown' &&
141
+ ev.key.toLowerCase() === 'z' &&
142
+ ev.ctrlKey &&
143
+ !ev.altKey &&
144
+ !ev.metaKey &&
145
+ !ev.shiftKey &&
146
+ this.sessionMode !== 'shell'
147
+ ) {
148
+ return false;
149
+ }
150
+ // Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
151
+ // Mirrors terminal-ui.js's own handling — xterm sends plain \r for
152
+ // every Enter variant, so an Ink app (Claude Code) can't tell a
153
+ // newline from a submit. Without this gate, Pane B's onData would
154
+ // send that bare \r straight over the WS and submit an incomplete
155
+ // prompt instead of adding a line to it. Targets THIS pane's own
156
+ // session (this.sessionId), never the primary pane's
157
+ // activeSessionId, and has no local-echo overlay of its own to flush
158
+ // first (Pane B is deliberately plainer — see the fileoverview).
159
+ if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
160
+ fetch(`/api/sessions/${this.sessionId}/send-key`, {
161
+ method: 'POST',
162
+ headers: { 'Content-Type': 'application/json' },
163
+ body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
164
+ }).catch(() => {
165
+ /* Best-effort, matching this pane's tolerance elsewhere. */
166
+ });
167
+ return false;
168
+ }
169
+ // Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
170
+ // selection, Ctrl+C copies THIS pane's own selection instead of
171
+ // sending ^C; with none, plain Ctrl+C must fall through unchanged or
172
+ // the interrupt key is lost. Ctrl+Shift+C is different: it is the
173
+ // explicit, never-falls-through copy chord, and the predicate above
174
+ // does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
175
+ // xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
176
+ // a branch that assigns c.key only for a couple of special cases
177
+ // ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
178
+ // for Ctrl+Shift+C either way — this is not about an accidental
179
+ // interrupt byte reaching the PTY (verified live: it does not).
180
+ // Gating this whole block on hasSelection() (an earlier draft) meant
181
+ // that with no selection Ctrl+Shift+C skipped straight to `return
182
+ // true`, silently ceding the keystroke to the BROWSER's own handling
183
+ // (e.g. Chrome's Inspect-Element binding) with no feedback and no
184
+ // attempt to copy, unlike Pane A, which always intercepts it.
185
+ // Re-implemented against this.terminal rather than reusing
186
+ // app.copyTerminalSelection(), which reads app.terminal — Pane A's —
187
+ // and would copy the wrong pane's selection.
188
+ if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
189
+ const raw = this.terminal?.getSelection?.() || '';
190
+ const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
191
+ const selection = isColumnSelection ? raw : (global.CodemanCopySelection?.clean?.(raw) ?? raw);
192
+ if (selection.trim()) {
193
+ ev.preventDefault();
194
+ void global.app._copyText?.(selection).then((ok) => {
195
+ this.terminal?.clearSelection?.();
196
+ global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
197
+ });
198
+ return false;
199
+ }
200
+ // Nothing worth copying — clear for feedback (a padding-only
201
+ // selection cleans to '' and this press still falls through to the
202
+ // PTY as 0x03, matching the primary pane's own rule).
203
+ if (this.terminal?.hasSelection?.()) {
204
+ this.terminal.clearSelection?.();
205
+ global.app.showToast?.('Nothing to copy', 'warning');
206
+ }
207
+ // Ctrl+Shift+C never falls through, even with nothing to copy —
208
+ // matches terminal-ui.js's own ev.shiftKey branch.
209
+ if (ev.shiftKey) {
210
+ ev.preventDefault();
211
+ return false;
212
+ }
213
+ }
214
+ return true;
215
+ });
216
+
217
+ // Load existing scrollback before going live. The WS below is
218
+ // subscribe-only (ws-routes.ts sends nothing on connect, only future
219
+ // 'terminal' events), so without this Pane B stays blank until the
220
+ // target session happens to produce new output. It LOOKED
221
+ // intermittent rather than always-broken because _sendResize() below
222
+ // often nudges the shared session's real tmux window to a new size,
223
+ // and tmux repaints its current screen on resize — that repaint was
224
+ // getting captured and streamed here, incidentally populating the
225
+ // pane. When Pane B's computed dimensions happened to already match
226
+ // the session's last-known size, Session.resize() (session.ts) skips
227
+ // the resize as a no-op, no repaint fires, and the pane stayed blank.
228
+ // The await covers the whole chunked replay, not just the fetch, so a
229
+ // live frame from the socket below can never land in the middle of it.
230
+ await this._loadBuffer();
231
+ if (this._destroyed) return;
232
+
233
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
234
+ const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
235
+ this.ws = new WebSocket(url);
236
+
237
+ this.ws.onopen = () => {
238
+ this._wsReady = true;
239
+ this._sendResize();
240
+ };
241
+
242
+ this.ws.onmessage = (event) => {
243
+ try {
244
+ const msg = JSON.parse(event.data);
245
+ if (msg.t === 'o') {
246
+ this.terminal.write(msg.d);
247
+ } else if (msg.t === 'c') {
248
+ this.terminal.clear();
249
+ } else if (msg.t === 'r') {
250
+ // Server-triggered refresh (SSE backpressure cleared, terminal
251
+ // data was dropped). The primary pane routes this to
252
+ // _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
253
+ // buffer loader for the same reason connect() does.
254
+ this._refreshBuffer();
255
+ }
256
+ } catch {
257
+ /* Malformed frame — ignore, matches primary pane's tolerance. */
258
+ }
259
+ };
260
+
261
+ // Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
262
+ // must go false on a drop or fit()/_sendResize() silently no-ops on a
263
+ // closed socket per the WebSocket spec (no exception, no log). No
264
+ // reconnect logic here — Pane B is deliberately plainer than the
265
+ // primary pane (see the fileoverview above); a drop just stops
266
+ // resizing until the parent recreates the pane. But onData already
267
+ // silently drops keystrokes while _wsReady is false (below), so
268
+ // without a visible marker a dropped socket left Pane B looking
269
+ // normal while it quietly ate everything typed into it. v1 scope is
270
+ // "say so", not reconnect — collapsing the split would lose the
271
+ // user's place in Pane B's scrollback for a transient blip.
272
+ this.ws.onclose = () => {
273
+ this._wsReady = false;
274
+ this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
275
+ };
276
+
277
+ this.ws.onerror = () => {
278
+ // onclose fires after onerror — cleanup happens there.
279
+ };
280
+ }
281
+
282
+ // Fetches and writes the session's current scrollback. Used both by
283
+ // connect() (initial load) and by the `{t:'r'}` server-refresh frame
284
+ // (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
285
+ // scoped to `this.activeSessionId` and clears/rewrites the primary
286
+ // terminal, neither of which applies to this independent pane, so this is
287
+ // a standalone equivalent rather than a call into it.
288
+ //
289
+ // Mirrors the primary pane's own mode check (app.js's selectSession /
290
+ // _onSessionNeedsRefresh): a shell session can retain hundreds of
291
+ // thousands of plain scrollback lines, so pulling `?full=1` there parses
292
+ // an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
293
+ // into a 50000-line xterm on every load. Non-shell (TUI) sessions still
294
+ // get one full replay. `fetch` here goes through the global wrapper
295
+ // (constants.js), which already prefixes CodemanBase — unlike the raw
296
+ // WebSocket URL above, which does not.
297
+ //
298
+ // Single-flight: the flag is held across the fetch AND the chunked write
299
+ // (writeChunked resolves after its last chunk), so two replays can never
300
+ // interleave their chunks into one terminal. A second call while one is
301
+ // in flight is dropped here; _refreshBuffer() is the caller that queues
302
+ // a trailing re-run instead.
303
+ async _loadBuffer() {
304
+ if (this._bufferLoading) return;
305
+ this._bufferLoading = true;
306
+ try {
307
+ const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
308
+ const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
309
+ const payload = (await res.json())?.data ?? {};
310
+ if (payload.terminalBuffer && this.terminal) {
311
+ await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
312
+ }
313
+ } catch {
314
+ /* Best-effort — live output still arrives once the socket connects. */
315
+ } finally {
316
+ this._bufferLoading = false;
317
+ }
318
+ if (this._bufferRefreshPending && !this._destroyed) {
319
+ this._bufferRefreshPending = false;
320
+ this._refreshBuffer();
321
+ }
322
+ }
323
+
324
+ // The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
325
+ // frames in a row used to start two concurrent replays, each clearing
326
+ // the terminal under the other's chunked write. A refresh that arrives
327
+ // mid-replay is COALESCED into one trailing re-run rather than ignored:
328
+ // the in-flight fetch may predate the drop the new frame is reporting,
329
+ // and no further frame is coming to correct stale content.
330
+ _refreshBuffer() {
331
+ if (this._bufferLoading) {
332
+ this._bufferRefreshPending = true;
333
+ return;
334
+ }
335
+ this.terminal?.clear();
336
+ void this._loadBuffer();
337
+ }
338
+
339
+ // Local reflow only — no PTY resize frame. Split out so a divider drag
340
+ // can reflow both panes at the browser's paint rate (rAF) while sending
341
+ // the actual `{t:'z'}` resize once, at drag end, matching the primary
342
+ // pane's own convention (throttledResize in terminal-ui.js).
343
+ localFit() {
344
+ if (!this.fitAddon) return;
345
+ this.fitAddon.fit();
346
+ }
347
+
348
+ fit() {
349
+ this.localFit();
350
+ this._sendResize();
351
+ }
352
+
353
+ _sendResize() {
354
+ if (!this._wsReady || !this.fitAddon) return;
355
+ // One PTY cannot hold two sizes (mirrors sendResize's own
356
+ // detachedElsewhere yield in terminal-ui.js): the session got detached
357
+ // to its own window AFTER this split was opened, so its own window now
358
+ // owns the PTY's size and Pane B must stand aside.
359
+ if (this.detachedSessions?.has(this.sessionId)) return;
360
+ const dims = this.fitAddon.proposeDimensions();
361
+ if (!dims) return;
362
+ // Send the real proposed dimensions unclamped, matching the primary
363
+ // pane's convention (terminal-ui.js's getTerminalDimensions()) — the
364
+ // server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
365
+ // A 40/10 floor here misreported Pane B's real width to the PTY at the
366
+ // divider's own reachable 20% floor position, causing real
367
+ // output-wrapping bugs.
368
+ this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
369
+ }
370
+
371
+ destroy() {
372
+ this._destroyed = true;
373
+ if (this.ws) {
374
+ this.ws.onopen = null;
375
+ this.ws.onmessage = null;
376
+ // onclose fires asynchronously AFTER close(); without this it ran
377
+ // its "disconnected" write against a pane already torn down.
378
+ this.ws.onclose = null;
379
+ this.ws.onerror = null;
380
+ this.ws.close();
381
+ this.ws = null;
382
+ }
383
+ if (this.terminal) {
384
+ this.terminal.dispose();
385
+ this.terminal = null;
386
+ }
387
+ this.fitAddon = null;
388
+ }
389
+ }
390
+
391
+ global.SplitTerminalPane = SplitTerminalPane;
392
+ })(window);
393
+
394
+ Object.assign(CodemanApp.prototype, {
395
+ /**
396
+ * Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
397
+ * + matchMedia backstop: a JS width check (so openSplitPane() below can
398
+ * refuse even if a click somehow reaches the button) plus a live listener,
399
+ * because a window narrowed WHILE the button is showing must hide it
400
+ * without waiting for a settings save or reload. The CSS `@media
401
+ * (max-width: 1179px)` rule in styles.css is the backstop for the reverse
402
+ * direction: it hides the button even if this JS never runs at all.
403
+ */
404
+ _applySplitButtonVisibility(enabled) {
405
+ this._splitButtonSettingEnabled = enabled;
406
+ const splitBtn = document.querySelector('.btn-split');
407
+ const wide = window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
408
+ // Narrowing past the gate must not leave an open split on screen with no
409
+ // way to reach the button that would close it — the two 240px min-widths
410
+ // plus the divider overflow a narrow window and .main clips Pane B's edge.
411
+ if (!wide && this._splitPane) this.closeSplitPane();
412
+ if (!splitBtn) return;
413
+ splitBtn.classList.toggle('btn-split--hidden', !enabled || !wide);
414
+ if (!this._splitButtonWidthListenerInstalled && window.matchMedia) {
415
+ this._splitButtonWidthListenerInstalled = true;
416
+ const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`);
417
+ mq.addEventListener('change', () => this._applySplitButtonVisibility(this._splitButtonSettingEnabled));
418
+ }
419
+ },
420
+
421
+ openSplitPicker(event) {
422
+ // Mirrors toggleRunModeMenu (session-ui.js): stopPropagation on the
423
+ // OPENING click so it never reaches the outside-click listener this
424
+ // same call is about to register — without it, a click landing on the
425
+ // button's own inner <svg> (matched by neither `menu.contains()` nor
426
+ // the old exact-node check below) bubbled straight through to
427
+ // `document` and self-closed the menu it just opened.
428
+ event?.stopPropagation();
429
+ if (this._splitPane) {
430
+ this.closeSplitPane();
431
+ return;
432
+ }
433
+ const candidates = window.CodemanSplitPane.buildSplitPickerSessions(
434
+ this.sessions,
435
+ this.sessionOrder,
436
+ this.activeSessionId,
437
+ this.detachedSessions
438
+ );
439
+ // Route a pre-existing menu through the SAME dismiss path used
440
+ // everywhere else, instead of a raw `.remove()`: a genuinely still-open
441
+ // menu has live document listeners (see below), and a raw removal left
442
+ // them attached forever — only the single-slot field below got
443
+ // overwritten, so every prior pair but the last was orphaned on
444
+ // `document` with no way to ever find and remove it again.
445
+ this._dismissSplitPicker();
446
+
447
+ const menu = document.createElement('div');
448
+ menu.id = 'splitPickerMenu';
449
+ menu.className = 'split-picker-menu';
450
+ if (candidates.length === 0) {
451
+ menu.innerHTML = '<div class="split-picker-empty">No other sessions to split with</div>';
452
+ } else {
453
+ menu.innerHTML = candidates
454
+ .map(
455
+ (c) =>
456
+ // data-i18n-skip: the whole row's text IS a session name — i18n.js
457
+ // does exact-string lookup over text nodes, and a session
458
+ // literally named e.g. "Sessions" would otherwise get translated
459
+ // on zh-CN (see the .session-name skip on the pane header below).
460
+ `<button type="button" class="split-picker-item" data-i18n-skip onclick="app.openSplitPane(${escapeHtml(JSON.stringify(c.id))}); app._dismissSplitPicker();">${escapeHtml(c.label)}</button>`
461
+ )
462
+ .join('');
463
+ }
464
+ document.body.appendChild(menu);
465
+ const splitBtn = document.querySelector('.btn-split');
466
+ if (splitBtn) {
467
+ const rect = splitBtn.getBoundingClientRect();
468
+ menu.style.position = 'fixed';
469
+ menu.style.top = `${rect.bottom + 4}px`;
470
+ menu.style.right = `${window.innerWidth - rect.right}px`;
471
+ }
472
+
473
+ // Dismiss on outside click or Escape — same one-shot listener pattern as
474
+ // session-ui.js's other transient popovers (toggleCaseSettings(),
475
+ // toggleRunModeMenu()). Deferred by a tick so the click that OPENED the
476
+ // menu (still bubbling) doesn't immediately close it — reinforced by
477
+ // the button's own stopPropagation() above, which is what actually
478
+ // stops that same click reaching `document` at all. Picking an item
479
+ // (above) calls the SAME dismiss method, so these listeners never
480
+ // outlive the menu either way.
481
+ //
482
+ // Self-removing by identity: each handler removes ITSELF (and its
483
+ // sibling) the moment it fires, rather than leaning solely on the
484
+ // `this._splitPickerDismissHandlers` field. That field is still kept in
485
+ // sync (so `_dismissSplitPicker()` called from elsewhere — the picker
486
+ // item's onclick above, or a still-open menu at the top of this method
487
+ // — can find and remove the CURRENT pair), but no path here can ever
488
+ // again leave a pair attached to `document` with nothing referencing it.
489
+ const closeOnOutsideClick = (e) => {
490
+ if (menu.contains(e.target) || e.target.closest('.btn-split')) return;
491
+ document.removeEventListener('click', closeOnOutsideClick);
492
+ document.removeEventListener('keydown', closeOnEscape);
493
+ this._splitPickerDismissHandlers = null;
494
+ menu.remove();
495
+ };
496
+ const closeOnEscape = (e) => {
497
+ if (e.key !== 'Escape') return;
498
+ document.removeEventListener('click', closeOnOutsideClick);
499
+ document.removeEventListener('keydown', closeOnEscape);
500
+ this._splitPickerDismissHandlers = null;
501
+ menu.remove();
502
+ };
503
+ this._splitPickerDismissHandlers = { closeOnOutsideClick, closeOnEscape };
504
+ setTimeout(() => document.addEventListener('click', closeOnOutsideClick), 0);
505
+ document.addEventListener('keydown', closeOnEscape);
506
+ },
507
+
508
+ _dismissSplitPicker() {
509
+ document.getElementById('splitPickerMenu')?.remove();
510
+ if (this._splitPickerDismissHandlers) {
511
+ document.removeEventListener('click', this._splitPickerDismissHandlers.closeOnOutsideClick);
512
+ document.removeEventListener('keydown', this._splitPickerDismissHandlers.closeOnEscape);
513
+ this._splitPickerDismissHandlers = null;
514
+ }
515
+ },
516
+
517
+ openSplitPane(sessionId) {
518
+ // Desktop-only hard gate, independent of the button's own hidden state —
519
+ // see _applySplitButtonVisibility's comment for why both a JS check and
520
+ // a CSS backstop exist.
521
+ if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
522
+ // No active session means there is no `.terminal-wrap` to split against
523
+ // (the welcome overlay is showing) — without this, a split opened from
524
+ // the home screen still created the container and connected Pane B, just
525
+ // behind the opaque overlay with nothing visible to show for it.
526
+ if (!this.activeSessionId) return;
527
+ // A web tab hides `.terminal-wrap`'s container via CSS with nothing
528
+ // gating the button itself, and `activeSessionId` survives openWebview()
529
+ // — without this, picking a session opens Pane B's socket behind a
530
+ // hidden container with nothing on screen to show for it.
531
+ if (this.activeWebviewId) return;
532
+ // A stale picker click (opened before switching tabs) or clicking Pane
533
+ // B's own session tab while split can otherwise land here with
534
+ // sessionId === activeSessionId: two live WebSockets to the same
535
+ // session, each independently claiming PTY dimensions via its own `{t:'z',...}`
536
+ // resize frame. Refuse before creating any DOM or SplitTerminalPane.
537
+ if (sessionId === this.activeSessionId) return;
538
+ // The picker's own exclusions (buildSplitPickerSessions in constants.js),
539
+ // re-applied here: the menu can sit open while a listed session's CLI
540
+ // exits (pid → null) or gets popped out to its own window, and nothing
541
+ // re-runs the picker filter for a row that already rendered. Same
542
+ // outcome as the picker gives such a session (not offered, silently):
543
+ // one with no PTY has nothing reading its pane, so Pane B would show
544
+ // nothing and drop every keystroke behind a healthy-looking socket, and
545
+ // a detached session's own window already owns its PTY size.
546
+ const session = this.sessions.get(sessionId);
547
+ if (!session || session.pid === null) return;
548
+ if (this.detachedSessions?.has?.(sessionId)) return;
549
+ if (this._splitPane) this.closeSplitPane();
550
+
551
+ const wrap = document.querySelector('.terminal-wrap');
552
+ const parent = wrap.parentElement;
553
+
554
+ const container = document.createElement('div');
555
+ container.className = 'terminal-split-container';
556
+
557
+ const divider = document.createElement('div');
558
+ divider.className = 'split-divider';
559
+
560
+ const paneB = document.createElement('div');
561
+ paneB.className = 'terminal-pane-b';
562
+ paneB.innerHTML = `
563
+ <div class="terminal-pane-b-header">
564
+ <span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
565
+ <button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">&times;</button>
566
+ </div>
567
+ <div class="terminal-pane-b-container"></div>
568
+ `;
569
+
570
+ parent.insertBefore(container, wrap);
571
+ container.appendChild(wrap);
572
+ wrap.style.flexBasis = '50%';
573
+ container.appendChild(divider);
574
+ container.appendChild(paneB);
575
+ paneB.style.flexBasis = '50%';
576
+
577
+ this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
578
+ mode: session?.mode,
579
+ fontSettings: this.loadAppSettingsFromStorage?.() || {},
580
+ detachedSessions: this.detachedSessions,
581
+ });
582
+ this._splitPane.connect().catch(() => {
583
+ /* Best-effort, matching the primary pane's own tolerance for a failed
584
+ initial load — live output still arrives once/if the socket connects. */
585
+ });
586
+ this._splitSessionId = sessionId;
587
+
588
+ // Pane A just went from full width to 50%, but nothing has told its
589
+ // session's PTY/tmux window about it yet — the passive ResizeObserver in
590
+ // terminal-ui.js debounces 300ms and would eventually catch up, but
591
+ // relying on that left the pane showing stale-width content (existing
592
+ // box-drawing lines, banners) until the user hit "Redraw Terminal".
593
+ // Force it immediately, mirroring closeSplitPane()'s symmetric call.
594
+ this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
595
+
596
+ this._installSplitDividerDrag(divider, wrap, paneB);
597
+ this._updateSplitButtonState(true);
598
+ },
599
+
600
+ closeSplitPane(options = {}) {
601
+ if (!this._splitPane) return;
602
+ // A split can collapse MID-DRAG (either session ending, the window
603
+ // narrowing past the gate, a click on Pane B's own tab). The drag's own
604
+ // onUp is what normally clears `body.split-pane-resizing` (a col-resize
605
+ // cursor plus user-select:none on EVERY element, styles.css), and it
606
+ // relied on pointer capture routing pointerup back to a divider this
607
+ // method detaches below, so a mid-drag collapse left the whole page
608
+ // locked in resize mode until a reload. Tear the drag down first.
609
+ this._splitDividerDragTeardown?.();
610
+ this._splitDividerDragTeardown = null;
611
+ this._splitPane.destroy();
612
+ this._splitPane = null;
613
+ this._splitSessionId = null;
614
+ this._updateSplitButtonState(false);
615
+
616
+ const container = document.querySelector('.terminal-split-container');
617
+ if (!container) return;
618
+ const wrap = container.querySelector('.terminal-wrap');
619
+ const parent = container.parentElement;
620
+ wrap.style.flexBasis = '';
621
+ parent.insertBefore(wrap, container);
622
+ container.remove();
623
+
624
+ if (this.fitAddon) this.fitAddon.fit();
625
+ // The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
626
+ // while activeSessionId is still the id the server just removed, so a
627
+ // resize from here would be aimed at a session that no longer exists;
628
+ // the promoted session gets its own resize from selectSession().
629
+ if (!options.skipPrimaryResize) {
630
+ this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
631
+ }
632
+ },
633
+
634
+ // A click on .btn-split does one of two things — open the picker, or
635
+ // (openSplitPicker's own early return) close an already-open split — and
636
+ // nothing on the button said which. `.split-open` + aria-pressed give it
637
+ // the same active-state language as the codebase's other toggle buttons
638
+ // (keyboard-accessory's Ctrl key, the voice-input mic).
639
+ _updateSplitButtonState(open) {
640
+ const btn = document.querySelector('.btn-split');
641
+ if (!btn) return;
642
+ btn.classList.toggle('split-open', open);
643
+ btn.setAttribute('aria-pressed', open ? 'true' : 'false');
644
+ const title = open ? 'Split: close the second session' : 'Split: open a second session beside this one';
645
+ btn.title = title;
646
+ btn.setAttribute('aria-label', title);
647
+ },
648
+
649
+ _installSplitDividerDrag(divider, wrap, paneB) {
650
+ let dragging = false;
651
+ let dragRaf = null;
652
+ let pendingClientX = null;
653
+ let capturedPointerId = null;
654
+
655
+ // Local-only reflow (flexBasis + both panes' xterm fit, no PTY resize
656
+ // frame). Coalesced to one call per animation frame below — a raw
657
+ // mousemove stream fires far faster than the browser repaints, and
658
+ // without the rAF gate each event did a full xterm reflow on BOTH
659
+ // panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
660
+ // no client-side "dims unchanged" skip), which fanned out into a
661
+ // `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
662
+ // of each dragging across half a wide viewport.
663
+ const applyDragPercent = (clientX) => {
664
+ const container = divider.parentElement;
665
+ // The split can auto-collapse mid-drag (the other pane's session
666
+ // ending, or the picker's own close button) — closeSplitPane() removes
667
+ // `.terminal-split-container` from the DOM, which detaches `divider`
668
+ // too, so `divider.parentElement` is null on the very next frame and
669
+ // every drag threw here until mouseup finally removed the listener.
670
+ if (!container) return;
671
+ const rect = container.getBoundingClientRect();
672
+ const rawPercent = ((clientX - rect.left) / rect.width) * 100;
673
+ const percent = window.CodemanSplitPane.clampDividerPercent(rawPercent);
674
+ wrap.style.flexBasis = `${percent}%`;
675
+ paneB.style.flexBasis = `${100 - percent}%`;
676
+ if (this.fitAddon) this.fitAddon.fit();
677
+ this._splitPane?.localFit();
678
+ };
679
+
680
+ const onMove = (e) => {
681
+ if (!dragging) return;
682
+ pendingClientX = e.clientX;
683
+ if (dragRaf) return;
684
+ dragRaf = requestAnimationFrame(() => {
685
+ dragRaf = null;
686
+ applyDragPercent(pendingClientX);
687
+ });
688
+ };
689
+
690
+ // Everything pointerdown ARMS, undone in one place: the body-level
691
+ // cursor/selection lock, the divider's dragging class, pointer capture,
692
+ // the move/up/cancel listeners and a queued reflow frame. Shared by onUp
693
+ // (a normal drag end) and by closeSplitPane(), via the teardown handle
694
+ // stored below, for a split that collapses mid-drag: the pointerup that
695
+ // would have run onUp is routed by pointer capture to a divider
696
+ // closeSplitPane() has detached, so it never arrives. Idempotent, since
697
+ // the teardown runs whether or not a drag is in progress.
698
+ const endDrag = () => {
699
+ dragging = false;
700
+ divider.classList.remove('dragging');
701
+ document.body.classList.remove('split-pane-resizing');
702
+ if (capturedPointerId !== null) {
703
+ try {
704
+ divider.releasePointerCapture(capturedPointerId);
705
+ } catch {
706
+ /* Already released (pointercancel/lostpointercapture beat us here). */
707
+ }
708
+ capturedPointerId = null;
709
+ }
710
+ divider.removeEventListener('pointermove', onMove);
711
+ divider.removeEventListener('pointerup', onUp);
712
+ divider.removeEventListener('pointercancel', onUp);
713
+ if (dragRaf) {
714
+ cancelAnimationFrame(dragRaf);
715
+ dragRaf = null;
716
+ }
717
+ };
718
+
719
+ const onUp = () => {
720
+ // A reflow frame still queued at release carries the final pointer
721
+ // position; apply it once, synchronously, so the panes end where the
722
+ // pointer did rather than one frame short.
723
+ const hadQueuedFrame = dragRaf !== null;
724
+ endDrag();
725
+ if (hadQueuedFrame) applyDragPercent(pendingClientX);
726
+ // Send the real PTY resize exactly once here, at drag end, for BOTH
727
+ // panes — never per-move (matching the codebase's established
728
+ // trailing-edge debounce convention, see throttledResize in
729
+ // terminal-ui.js) so a fast drag doesn't flood dozens of intermediate
730
+ // SIGWINCH/reflow states into scrollback or spawn a `tmux
731
+ // resize-window` child per frame.
732
+ this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
733
+ this._splitPane?.fit();
734
+ };
735
+
736
+ // Pointer events + setPointerCapture (mirrors tab-rail-resize.js) instead
737
+ // of mousedown/document-level mousemove: a plain mousedown drag selects
738
+ // the text under the cursor as it crosses both terminals, and pointer
739
+ // capture routes move/up straight to `divider` regardless of what's under
740
+ // the cursor mid-drag, so no document-level listener leak is possible if
741
+ // the pointer is released off-window. `body.split-pane-resizing` (mirrors
742
+ // `body.tab-rail-resizing`) locks the cursor/selection for the drag.
743
+ divider.addEventListener('pointerdown', (e) => {
744
+ if (e.button !== 0) return;
745
+ e.preventDefault();
746
+ dragging = true;
747
+ divider.classList.add('dragging');
748
+ document.body.classList.add('split-pane-resizing');
749
+ try {
750
+ divider.setPointerCapture(e.pointerId);
751
+ capturedPointerId = e.pointerId;
752
+ } catch {
753
+ /* Capture failed — the drag still works via the listeners below. */
754
+ }
755
+ divider.addEventListener('pointermove', onMove);
756
+ divider.addEventListener('pointerup', onUp);
757
+ divider.addEventListener('pointercancel', onUp);
758
+ });
759
+ this._splitDividerDragTeardown = endDrag;
760
+ },
761
+ });
762
+
763
+ const _originalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted;
764
+ CodemanApp.prototype._onSessionDeleted = function (data) {
765
+ if (this._splitSessionId === data.id) {
766
+ this.closeSplitPane();
767
+ } else if (this._splitPane && this.activeSessionId === data.id) {
768
+ // Pane A's session ended: promote Pane B by closing the split and
769
+ // selecting its session as the new (single) active pane. This is an
770
+ // app-driven selection, not the user clicking a tab, so it must not
771
+ // spend the promoted session's idle alert (see the Approvals Inbox
772
+ // acknowledgement rule in CLAUDE.md — only a human opening a session
773
+ // acknowledges it).
774
+ const promoted = this._splitSessionId;
775
+ // activeSessionId is still data.id here (the original handler below is
776
+ // what retires it), so closeSplitPane()'s closing resize would be aimed
777
+ // at the session the server just removed. Skip it; selectSession() sizes
778
+ // the promoted session itself.
779
+ this.closeSplitPane({ skipPrimaryResize: true });
780
+ // Closing Pane A's own tab (closeSession(), app.js) adds data.id to
781
+ // _closingSessions BEFORE awaiting the delete, then owns the follow-up
782
+ // selection itself once the delete lands — same race _onSessionDeleted's
783
+ // own active-session handoff guards against (see its comment). Selecting
784
+ // here too would fight it for which tab wins.
785
+ if (promoted && !this._closingSessions.has(data.id)) {
786
+ this.selectSession(promoted, { auto: true });
787
+ }
788
+ }
789
+ return _originalOnSessionDeleted.call(this, data);
790
+ };
791
+
792
+ // I2: closes an active split BEFORE the primary pane rebinds to the same
793
+ // session Pane B is showing (clicking Pane B's own session tab while split,
794
+ // or any other selectSession() call that targets _splitSessionId). Without
795
+ // this, Pane A rebinds to a session that Pane B's independent WebSocket is
796
+ // still attached to — two live WebSockets to one session, each claiming PTY
797
+ // dimensions via its own `{t:'z',...}` resize frame.
798
+ const _originalSelectSession = CodemanApp.prototype.selectSession;
799
+ CodemanApp.prototype.selectSession = function (sessionId, ...args) {
800
+ if (this._splitPane && this._splitSessionId === sessionId) {
801
+ this.closeSplitPane();
802
+ }
803
+ return _originalSelectSession.call(this, sessionId, ...args);
804
+ };