mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
fix(split-pane): throttle the drag, chunk the scrollback, and the rest of Ark0N's second pass
Two majors:
- The divider drag was unthrottled: every mousemove did a full xterm
reflow on BOTH panes and sent Pane B a {t:'z'} resize frame with no
unchanged-dimensions skip, fanning out into a `tmux resize-window`
child plus a SIGWINCH per event — ~50 of each dragging across half a
wide viewport. SplitTerminalPane.fit() is now split into localFit()
(reflow only) and fit() (reflow + send); the drag coalesces moves
into one localFit() per animation frame via requestAnimationFrame,
and sends the real resize for both panes exactly once, at drag end,
matching the primary pane's own throttledResize convention.
- Pane B pulled the FULL scrollback unchunked for every session mode,
writing it in one terminal.write() call. Mirrors the primary pane's
own mode check (app.js's selectSession): shell sessions get a
bounded 1MiB ?tail= fetch instead of ?full=1, and the fetched buffer
is written through a minimal chunked writer (32KB slices, yielding a
frame between each) instead of one primary-pane chunkedTerminalWrite
this simpler, independently created/destroyed pane has no equivalent
of (no session-switch generation counters or live-output gate).
Smaller items from the same review:
- Pane B now follows live appearance changes (applyTerminalSkin,
applyTerminalFontFamily, applyTerminalFontWeights, setFontSize all
propagate to it, matching the teammateTerminals pattern) and reads
the real codeman-font-size/terminalFontFamily/weights/DEFAULT_SCROLLBACK
settings at construction instead of hardcoding fontSize 14 / scrollback 5000.
- The Pane-B-promotion path now skips selectSession() when
_closingSessions already owns this delete (the user closing Pane A's
own tab), matching _onSessionDeleted's own active-session-handoff guard.
- Detaching a session AFTER a split is already open now yields the PTY
size in _sendResize() too (not just at picker-open time), mirroring
sendResize's own detachedElsewhere guard.
- .btn-split joins the body.solo-mode hide list, next to .btn-multimonitor.
- The split row was 6px wider than its container (two flex-shrink:0
50% panes plus a 6px divider): both panes are now flex-shrink 1.
- Pane B's header and the split-picker rows are marked so i18n.js's
exact-string lookup skips them, matching .session-name elsewhere —
a session literally named e.g. "Sessions" was translatable on zh-CN.
- The Split button now reflects open/closed state via a `.split-open`
accent style, aria-pressed, and a title/aria-label that says which
behaviour the next click gets.
- _splitPane.connect() is no longer an unawaited call with no .catch().
- terminal-split.js's fileoverview pointed at a doc path that was
renamed away in the previous push; @dependency now credits
constants.js for CodemanTerminalFont, not terminal-ui.js.
- index.html's Split settings chip no longer reuses data-preview-order
"12" (already the Ultracode Agents chip's slot in the same "header"
preview group).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
165cfb52d6
commit
2e3e245cc6
@@ -1520,6 +1520,15 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
|
||||
// Split-Pane Sessions — pure helpers (divider math, picker list)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Desktop-only, same reasoning and same threshold as HOME_SESSIONS_MIN_WIDTH
|
||||
// (home-sessions.js): two 240px min-width panes plus the divider need ~486px,
|
||||
// which a phone or narrow tablet cannot give them, and the divider has no
|
||||
// touch handlers. A dedicated constant rather than reusing
|
||||
// HOME_SESSIONS_MIN_WIDTH directly — that name lives in home-sessions.js,
|
||||
// which loads AFTER this file (load order 12.56 vs 7.5), so referencing it
|
||||
// from module-evaluation-time code here would be a ReferenceError.
|
||||
const SPLIT_PANE_MIN_WIDTH = 1180;
|
||||
|
||||
function clampDividerPercent(rawPercent, min = 20, max = 80) {
|
||||
if (rawPercent < min) return min;
|
||||
if (rawPercent > max) return max;
|
||||
@@ -1550,5 +1559,6 @@ if (typeof window !== 'undefined') {
|
||||
window.CodemanSplitPane = {
|
||||
clampDividerPercent,
|
||||
buildSplitPickerSessions,
|
||||
SPLIT_PANE_MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
@@ -1925,7 +1925,7 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="12"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="15"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
||||
</div>
|
||||
|
||||
@@ -526,15 +526,14 @@ html.mobile-init .file-browser-panel {
|
||||
/* Approvals bell: phones answer from the overview's NEEDS YOU rows instead
|
||||
(inline approve/deny in mobile-overview.js); the bell would only crowd the
|
||||
header it was designed to stay out of. */
|
||||
.btn-icon-header.btn-approvals,
|
||||
/* Split-pane: desktop-only by design (two 240px min-width panes plus the
|
||||
divider need ~486px, which a 393px phone cannot give them, and the
|
||||
divider has no touch handlers). This is a hard gate independent of the
|
||||
opt-in showSplitButton setting/marker class, so turning the setting on
|
||||
at a desk can never also put the button in the phone header. */
|
||||
.btn-icon-header.btn-split {
|
||||
.btn-icon-header.btn-approvals {
|
||||
display: none !important;
|
||||
}
|
||||
/* Split-pane is hidden here too, but the AUTHORITATIVE hard gate is the
|
||||
wider `@media (max-width: 1179px)` rule in styles.css — this file only
|
||||
loads up to 1023px, which does not cover the 1024-1179px tablet range
|
||||
the split feature also needs to stay off (two 240px min-width panes plus
|
||||
the divider need ~486px; the divider also has no touch handlers). */
|
||||
|
||||
/* Read My Mind 🧠 header button: never in the phone header; the phone
|
||||
surface is the keyboard-accessory 🧠 key (same `readMyMindEnabled` gate,
|
||||
|
||||
@@ -2289,6 +2289,32 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* While a split is open, the button's own click closes it instead of opening
|
||||
the picker — the accent fill is what tells the user which of its two
|
||||
behaviours the next click will get (aria-pressed carries the same state
|
||||
for assistive tech). */
|
||||
.btn-icon-header.btn-split.split-open {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-split.split-open:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* Split-pane hard desktop gate, independent of the showSplitButton setting:
|
||||
two 240px min-width panes plus the divider need ~486px, the divider has no
|
||||
touch handlers, and mobile.css only loads up to 1023px so it cannot cover
|
||||
the 1024-1179px tablet range on its own. Same threshold and reasoning as
|
||||
HOME_SESSIONS_MIN_WIDTH (home-sessions.js) / SPLIT_PANE_MIN_WIDTH
|
||||
(constants.js) — keep the three in sync. */
|
||||
@media (max-width: 1179px) {
|
||||
.btn-icon-header.btn-split {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-settings {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -2554,6 +2580,7 @@ body.solo-mode .header-system-stats,
|
||||
body.solo-mode .header-tokens,
|
||||
body.solo-mode .btn-notifications,
|
||||
body.solo-mode .btn-multimonitor,
|
||||
body.solo-mode .btn-split,
|
||||
body.solo-mode .header-plan-usage,
|
||||
/* A solo window shows ONE session and has no tab strip to put restored ones in,
|
||||
so offering to rebuild a list of them there is an offer it cannot show the
|
||||
@@ -18592,7 +18619,12 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
}
|
||||
|
||||
.terminal-split-container > .terminal-wrap {
|
||||
flex: 0 0 auto;
|
||||
/* flex-shrink 1 (not 0): the divider's own 6px is fixed-width, and Pane A
|
||||
+Pane B's inline flex-basis (openSplitPane/onMove) always sums to 100%,
|
||||
so with flex-shrink 0 on both panes the row is 100% + 6px wide and
|
||||
.main's overflow clips Pane B's right edge by exactly the divider's
|
||||
width. Shrinking lets the two panes give up that 6px between them. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -18610,7 +18642,8 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
}
|
||||
|
||||
.terminal-pane-b {
|
||||
flex: 0 0 auto;
|
||||
/* flex-shrink 1, matching .terminal-wrap above — see its comment. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -5,18 +5,55 @@
|
||||
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
|
||||
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
|
||||
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
|
||||
* feature by nature — see docs/superpowers/specs/2026-09-15-split-pane-sessions-design.md.
|
||||
* feature by nature — see docs/split-pane-sessions-plan.md.
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency terminal-ui.js (window.CodemanTerminalFont, codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
||||
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
||||
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
/**
|
||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
||||
* giant synchronous write that blocks the main thread while parsing a long
|
||||
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
||||
* (terminal-ui.js): that one is wired into session-switch generation
|
||||
* counters and the live-output gate this simpler, independently
|
||||
* created/destroyed pane has no equivalent of.
|
||||
*/
|
||||
function writeChunked(terminal, buffer, isDestroyed) {
|
||||
if (!buffer) return;
|
||||
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
||||
terminal.write(buffer);
|
||||
return;
|
||||
}
|
||||
let offset = 0;
|
||||
const writeNext = () => {
|
||||
if (isDestroyed() || !terminal) return;
|
||||
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
||||
offset += chunk.length;
|
||||
terminal.write(chunk);
|
||||
if (offset < buffer.length) {
|
||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
||||
else setTimeout(writeNext, 16);
|
||||
}
|
||||
};
|
||||
writeNext();
|
||||
}
|
||||
|
||||
class SplitTerminalPane {
|
||||
constructor(sessionId, mountEl) {
|
||||
constructor(sessionId, mountEl, opts = {}) {
|
||||
this.sessionId = sessionId;
|
||||
this.mountEl = mountEl;
|
||||
this.sessionMode = opts.mode;
|
||||
this.fontSettings = opts.fontSettings || {};
|
||||
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
||||
// detaching this session AFTER the split is already open must still be
|
||||
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
||||
// fight the split picker already refuses to open at pick time.
|
||||
this.detachedSessions = opts.detachedSessions;
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.ws = null;
|
||||
@@ -25,16 +62,17 @@
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
||||
this.terminal = new Terminal({
|
||||
theme: { ...global.codemanCurrentXtermTheme() },
|
||||
fontFamily: global.CodemanTerminalFont.resolve(),
|
||||
...global.CodemanTerminalFont.resolveWeights({}),
|
||||
fontSize: 14,
|
||||
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
||||
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
||||
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'block',
|
||||
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
scrollback: 5000,
|
||||
scrollback: DEFAULT_SCROLLBACK,
|
||||
allowTransparency: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
@@ -61,11 +99,18 @@
|
||||
// pane. When Pane B's computed dimensions happened to already match
|
||||
// the session's last-known size, Session.resize() (session.ts) skips
|
||||
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
||||
//
|
||||
// Mirrors the primary pane's own mode check (app.js's selectSession):
|
||||
// a shell session can retain hundreds of thousands of plain scrollback
|
||||
// lines, so pulling `?full=1` there parses an unbounded, server-capped
|
||||
// (up to terminalBufferMaxBytes, 32MB) body into a 50000-line xterm on
|
||||
// every split. Non-shell (TUI) sessions still get one full replay.
|
||||
try {
|
||||
const res = await fetch(`${window.CodemanBase.base}/api/sessions/${this.sessionId}/terminal?full=1`);
|
||||
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||
const res = await fetch(`${window.CodemanBase.base}/api/sessions/${this.sessionId}/terminal?${query}`);
|
||||
const payload = (await res.json())?.data ?? {};
|
||||
if (payload.terminalBuffer && this.terminal) {
|
||||
this.terminal.write(payload.terminalBuffer);
|
||||
writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
||||
}
|
||||
} catch {
|
||||
/* Best-effort — live output still arrives once the socket below connects. */
|
||||
@@ -115,14 +160,27 @@
|
||||
};
|
||||
}
|
||||
|
||||
fit() {
|
||||
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
||||
// can reflow both panes at the browser's paint rate (rAF) while sending
|
||||
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
||||
// pane's own convention (throttledResize in terminal-ui.js).
|
||||
localFit() {
|
||||
if (!this.fitAddon) return;
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
|
||||
fit() {
|
||||
this.localFit();
|
||||
this._sendResize();
|
||||
}
|
||||
|
||||
_sendResize() {
|
||||
if (!this._wsReady || !this.fitAddon) return;
|
||||
// One PTY cannot hold two sizes (mirrors sendResize's own
|
||||
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
||||
// to its own window AFTER this split was opened, so its own window now
|
||||
// owns the PTY's size and Pane B must stand aside.
|
||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
if (!dims) return;
|
||||
// Send the real proposed dimensions unclamped, matching the primary
|
||||
@@ -154,6 +212,28 @@
|
||||
})(window);
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
||||
* + matchMedia backstop: a JS width check (so openSplitPane() below can
|
||||
* refuse even if a click somehow reaches the button) plus a live listener,
|
||||
* because a window narrowed WHILE the button is showing must hide it
|
||||
* without waiting for a settings save or reload. The CSS `@media
|
||||
* (max-width: 1179px)` rule in styles.css is the backstop for the reverse
|
||||
* direction: it hides the button even if this JS never runs at all.
|
||||
*/
|
||||
_applySplitButtonVisibility(enabled) {
|
||||
this._splitButtonSettingEnabled = enabled;
|
||||
const splitBtn = document.querySelector('.btn-split');
|
||||
if (!splitBtn) return;
|
||||
const wide = window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
|
||||
splitBtn.classList.toggle('btn-split--hidden', !enabled || !wide);
|
||||
if (!this._splitButtonWidthListenerInstalled && window.matchMedia) {
|
||||
this._splitButtonWidthListenerInstalled = true;
|
||||
const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`);
|
||||
mq.addEventListener('change', () => this._applySplitButtonVisibility(this._splitButtonSettingEnabled));
|
||||
}
|
||||
},
|
||||
|
||||
openSplitPicker(event) {
|
||||
// Mirrors toggleRunModeMenu (session-ui.js): stopPropagation on the
|
||||
// OPENING click so it never reaches the outside-click listener this
|
||||
@@ -189,7 +269,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
menu.innerHTML = candidates
|
||||
.map(
|
||||
(c) =>
|
||||
`<div class="split-picker-item" data-session-id="${escapeHtml(c.id)}" onclick="app.openSplitPane(${escapeHtml(JSON.stringify(c.id))}); app._dismissSplitPicker();">${escapeHtml(c.label)}</div>`
|
||||
// data-i18n-skip: the whole row's text IS a session name — i18n.js
|
||||
// does exact-string lookup over text nodes, and a session
|
||||
// literally named e.g. "Sessions" would otherwise get translated
|
||||
// on zh-CN (see the .session-name skip on the pane header below).
|
||||
`<div class="split-picker-item" data-i18n-skip data-session-id="${escapeHtml(c.id)}" onclick="app.openSplitPane(${escapeHtml(JSON.stringify(c.id))}); app._dismissSplitPicker();">${escapeHtml(c.label)}</div>`
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
@@ -247,6 +331,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
openSplitPane(sessionId) {
|
||||
// Desktop-only hard gate, independent of the button's own hidden state —
|
||||
// see _applySplitButtonVisibility's comment for why both a JS check and
|
||||
// a CSS backstop exist.
|
||||
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
||||
// No active session means there is no `.terminal-wrap` to split against
|
||||
// (the welcome overlay is showing) — without this, a split opened from
|
||||
// the home screen still created the container and connected Pane B, just
|
||||
@@ -274,7 +362,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const session = this.sessions.get(sessionId);
|
||||
paneB.innerHTML = `
|
||||
<div class="terminal-pane-b-header">
|
||||
<span>${escapeHtml(session?.name || 'Session')}</span>
|
||||
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
|
||||
<span class="terminal-pane-b-close" onclick="app.closeSplitPane()">×</span>
|
||||
</div>
|
||||
<div class="terminal-pane-b-container"></div>
|
||||
@@ -287,8 +375,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
container.appendChild(paneB);
|
||||
paneB.style.flexBasis = '50%';
|
||||
|
||||
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'));
|
||||
this._splitPane.connect();
|
||||
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
|
||||
mode: session?.mode,
|
||||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||||
detachedSessions: this.detachedSessions,
|
||||
});
|
||||
this._splitPane.connect().catch(() => {
|
||||
/* Best-effort, matching the primary pane's own tolerance for a failed
|
||||
initial load — live output still arrives once/if the socket connects. */
|
||||
});
|
||||
this._splitSessionId = sessionId;
|
||||
|
||||
// Pane A just went from full width to 50%, but nothing has told its
|
||||
@@ -300,6 +395,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
|
||||
this._installSplitDividerDrag(divider, wrap, paneB);
|
||||
this._updateSplitButtonState(true);
|
||||
},
|
||||
|
||||
closeSplitPane() {
|
||||
@@ -307,6 +403,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._splitPane.destroy();
|
||||
this._splitPane = null;
|
||||
this._splitSessionId = null;
|
||||
this._updateSplitButtonState(false);
|
||||
|
||||
const container = document.querySelector('.terminal-split-container');
|
||||
if (!container) return;
|
||||
@@ -320,25 +417,59 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
},
|
||||
|
||||
// A click on .btn-split does one of two things — open the picker, or
|
||||
// (openSplitPicker's own early return) close an already-open split — and
|
||||
// nothing on the button said which. `.split-open` + aria-pressed give it
|
||||
// the same active-state language as the codebase's other toggle buttons
|
||||
// (keyboard-accessory's Ctrl key, the voice-input mic).
|
||||
_updateSplitButtonState(open) {
|
||||
const btn = document.querySelector('.btn-split');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('split-open', open);
|
||||
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
||||
const title = open ? 'Split: close the second session' : 'Split: open a second session beside this one';
|
||||
btn.title = title;
|
||||
btn.setAttribute('aria-label', title);
|
||||
},
|
||||
|
||||
_installSplitDividerDrag(divider, wrap, paneB) {
|
||||
let dragging = false;
|
||||
let dragRaf = null;
|
||||
let pendingClientX = null;
|
||||
|
||||
const onMove = (e) => {
|
||||
if (!dragging) return;
|
||||
// Local-only reflow (flexBasis + both panes' xterm fit, no PTY resize
|
||||
// frame). Coalesced to one call per animation frame below — a raw
|
||||
// mousemove stream fires far faster than the browser repaints, and
|
||||
// without the rAF gate each event did a full xterm reflow on BOTH
|
||||
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
|
||||
// no client-side "dims unchanged" skip), which fanned out into a
|
||||
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
||||
// of each dragging across half a wide viewport.
|
||||
const applyDragPercent = (clientX) => {
|
||||
const container = divider.parentElement;
|
||||
// The split can auto-collapse mid-drag (the other pane's session
|
||||
// ending, or the picker's own close button) — closeSplitPane() removes
|
||||
// `.terminal-split-container` from the DOM, which detaches `divider`
|
||||
// too, so `divider.parentElement` is null on the very next mousemove
|
||||
// and every drag threw here until mouseup finally removed the listener.
|
||||
// too, so `divider.parentElement` is null on the very next frame and
|
||||
// every drag threw here until mouseup finally removed the listener.
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const rawPercent = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const rawPercent = ((clientX - rect.left) / rect.width) * 100;
|
||||
const percent = window.CodemanSplitPane.clampDividerPercent(rawPercent);
|
||||
wrap.style.flexBasis = `${percent}%`;
|
||||
paneB.style.flexBasis = `${100 - percent}%`;
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
this._splitPane?.fit();
|
||||
this._splitPane?.localFit();
|
||||
};
|
||||
|
||||
const onMove = (e) => {
|
||||
if (!dragging) return;
|
||||
pendingClientX = e.clientX;
|
||||
if (dragRaf) return;
|
||||
dragRaf = requestAnimationFrame(() => {
|
||||
dragRaf = null;
|
||||
applyDragPercent(pendingClientX);
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
@@ -346,17 +477,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
divider.classList.remove('dragging');
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
// Pane B force-resizes on every move via _splitPane.fit() (SplitTerminalPane
|
||||
// has no client-side "dims unchanged" skip, so it always reaches the
|
||||
// server). Pane A's onMove above only calls fitAddon.fit() — a LOCAL
|
||||
// xterm reflow that changes how many columns xterm displays but never
|
||||
// tells Pane A's own PTY/tmux window the new size, so existing content
|
||||
// (box-drawing lines, banners) stays laid out for the old width. Fire
|
||||
// once here, at drag end, rather than per-move (matching the codebase's
|
||||
// established trailing-edge debounce convention — see throttledResize
|
||||
// in terminal-ui.js — so a fast drag doesn't flood dozens of
|
||||
// intermediate SIGWINCH/reflow states into scrollback).
|
||||
if (dragRaf) {
|
||||
cancelAnimationFrame(dragRaf);
|
||||
dragRaf = null;
|
||||
applyDragPercent(pendingClientX);
|
||||
}
|
||||
// Send the real PTY resize exactly once here, at drag end, for BOTH
|
||||
// panes — never per-move (matching the codebase's established
|
||||
// trailing-edge debounce convention, see throttledResize in
|
||||
// terminal-ui.js) so a fast drag doesn't flood dozens of intermediate
|
||||
// SIGWINCH/reflow states into scrollback or spawn a `tmux
|
||||
// resize-window` child per frame.
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
this._splitPane?.fit();
|
||||
};
|
||||
|
||||
divider.addEventListener('mousedown', () => {
|
||||
@@ -381,7 +514,14 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||
// acknowledges it).
|
||||
const promoted = this._splitSessionId;
|
||||
this.closeSplitPane();
|
||||
if (promoted) this.selectSession(promoted, { auto: true });
|
||||
// Closing Pane A's own tab (closeSession(), app.js) adds data.id to
|
||||
// _closingSessions BEFORE awaiting the delete, then owns the follow-up
|
||||
// selection itself once the delete lands — same race _onSessionDeleted's
|
||||
// own active-session handoff guards against (see its comment). Selecting
|
||||
// here too would fight it for which tab wins.
|
||||
if (promoted && !this._closingSessions.has(data.id)) {
|
||||
this.selectSession(promoted, { auto: true });
|
||||
}
|
||||
}
|
||||
return _originalOnSessionDeleted.call(this, data);
|
||||
};
|
||||
|
||||
@@ -5130,6 +5130,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Update overlay font cache and re-render at new cell dimensions
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontSize = size;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5155,6 +5159,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.fitAddon?.fit();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontFamily = resolved;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5206,6 +5214,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
/* pane not laid out yet — its own resize observer refits it */
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontWeight = fontWeight;
|
||||
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
loadFontSize() {
|
||||
@@ -5426,6 +5439,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
this._splitPane.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
|
||||
} catch {}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ type TestApp = {
|
||||
activeSessionId: string | null;
|
||||
_splitSessionId: string | null;
|
||||
_splitPane: { destroy: ReturnType<typeof vi.fn> } | null;
|
||||
_closingSessions: Set<string>;
|
||||
closeSplitPane: ReturnType<typeof vi.fn>;
|
||||
selectSession: ReturnType<typeof vi.fn>;
|
||||
__originalDeletedCalls?: Array<{ id: string }>;
|
||||
@@ -62,6 +63,10 @@ function makeSplitActiveApp(): TestApp {
|
||||
app.activeSessionId = 'session-a';
|
||||
app._splitSessionId = 'session-b';
|
||||
app._splitPane = { destroy: vi.fn() };
|
||||
// Empty by default: the app's OWN closeSession() is not mid-await for this
|
||||
// delete, so the promotion below is expected to fire. See the dedicated
|
||||
// test further down for the non-empty (_closingSessions owns it) case.
|
||||
app._closingSessions = new Set();
|
||||
// closeSplitPane is mocked but mirrors the REAL implementation's one
|
||||
// observable side effect relevant here: it nulls _splitPane/_splitSessionId.
|
||||
// If the wrapper captured _splitSessionId AFTER calling closeSplitPane
|
||||
@@ -116,6 +121,7 @@ describe('terminal-split.js _onSessionDeleted wrapper (I6)', () => {
|
||||
app.activeSessionId = 'session-a';
|
||||
app._splitSessionId = null;
|
||||
app._splitPane = null;
|
||||
app._closingSessions = new Set();
|
||||
app.closeSplitPane = vi.fn();
|
||||
app.selectSession = vi.fn();
|
||||
|
||||
@@ -125,4 +131,18 @@ describe('terminal-split.js _onSessionDeleted wrapper (I6)', () => {
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-a' }]);
|
||||
});
|
||||
|
||||
it('Pane A ends via the user closing its OWN tab: still collapses the split, but skips the promotion', () => {
|
||||
// closeSession() (app.js) adds the id to _closingSessions BEFORE awaiting
|
||||
// the delete, then owns the follow-up selection itself once it lands —
|
||||
// selecting Pane B's session here too would race it for which tab wins.
|
||||
const app = makeSplitActiveApp();
|
||||
app._closingSessions.add('session-a');
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-a' });
|
||||
|
||||
expect(app.closeSplitPane).toHaveBeenCalledTimes(1);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-a' }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user