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:
timkjr
2026-09-20 13:10:32 -05:00
co-authored by Claude Sonnet 5
parent 165cfb52d6
commit 2e3e245cc6
7 changed files with 264 additions and 42 deletions
@@ -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' }]);
});
});