2026-09-15 23:35:13 -05:00
|
|
|
// src/web/public/terminal-split.js
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
|
|
|
|
* ("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.
|
|
|
|
|
*
|
|
|
|
|
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
|
|
|
|
* @dependency terminal-ui.js (window.CodemanTerminalFont, codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
|
|
|
|
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
(function (global) {
|
|
|
|
|
class SplitTerminalPane {
|
|
|
|
|
constructor(sessionId, mountEl) {
|
|
|
|
|
this.sessionId = sessionId;
|
|
|
|
|
this.mountEl = mountEl;
|
|
|
|
|
this.terminal = null;
|
|
|
|
|
this.fitAddon = null;
|
|
|
|
|
this.ws = null;
|
|
|
|
|
this._wsReady = false;
|
2026-09-18 20:55:09 -05:00
|
|
|
this._destroyed = false;
|
2026-09-15 23:35:13 -05:00
|
|
|
}
|
|
|
|
|
|
2026-09-18 20:55:09 -05:00
|
|
|
async connect() {
|
2026-09-15 23:35:13 -05:00
|
|
|
this.terminal = new Terminal({
|
|
|
|
|
theme: { ...global.codemanCurrentXtermTheme() },
|
|
|
|
|
fontFamily: global.CodemanTerminalFont.resolve(),
|
|
|
|
|
...global.CodemanTerminalFont.resolveWeights({}),
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
lineHeight: 1.2,
|
|
|
|
|
cursorBlink: false,
|
|
|
|
|
cursorStyle: 'block',
|
|
|
|
|
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
|
|
|
|
scrollback: 5000,
|
|
|
|
|
allowTransparency: true,
|
|
|
|
|
allowProposedApi: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.fitAddon = new FitAddon.FitAddon();
|
|
|
|
|
this.terminal.loadAddon(this.fitAddon);
|
|
|
|
|
this.terminal.open(this.mountEl);
|
|
|
|
|
this.fitAddon.fit();
|
|
|
|
|
|
|
|
|
|
this.terminal.onData((data) => {
|
|
|
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-18 20:55:09 -05:00
|
|
|
// Load existing scrollback before going live. The WS below is
|
|
|
|
|
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
|
|
|
|
// 'terminal' events), so without this Pane B stays blank until the
|
|
|
|
|
// target session happens to produce new output. It LOOKED
|
|
|
|
|
// intermittent rather than always-broken because _sendResize() below
|
|
|
|
|
// often nudges the shared session's real tmux window to a new size,
|
|
|
|
|
// and tmux repaints its current screen on resize — that repaint was
|
|
|
|
|
// getting captured and streamed here, incidentally populating the
|
|
|
|
|
// 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.
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${window.CodemanBase.base}/api/sessions/${this.sessionId}/terminal?full=1`);
|
|
|
|
|
const payload = (await res.json())?.data ?? {};
|
|
|
|
|
if (payload.terminalBuffer && this.terminal) {
|
|
|
|
|
this.terminal.write(payload.terminalBuffer);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
/* Best-effort — live output still arrives once the socket below connects. */
|
|
|
|
|
}
|
|
|
|
|
if (this._destroyed) return;
|
|
|
|
|
|
2026-09-15 23:35:13 -05:00
|
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
|
|
|
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
|
|
|
|
|
this.ws = new WebSocket(url);
|
|
|
|
|
|
|
|
|
|
this.ws.onopen = () => {
|
|
|
|
|
this._wsReady = true;
|
|
|
|
|
this._sendResize();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.ws.onmessage = (event) => {
|
|
|
|
|
try {
|
|
|
|
|
const msg = JSON.parse(event.data);
|
|
|
|
|
if (msg.t === 'o') {
|
|
|
|
|
this.terminal.write(msg.d);
|
|
|
|
|
} else if (msg.t === 'c') {
|
|
|
|
|
this.terminal.clear();
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-09-16 10:22:05 -05:00
|
|
|
|
|
|
|
|
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
|
|
|
|
|
// must go false on a drop or fit()/_sendResize() silently no-ops on a
|
|
|
|
|
// closed socket per the WebSocket spec (no exception, no log). No
|
|
|
|
|
// reconnect logic here — Pane B is deliberately plainer than the
|
|
|
|
|
// primary pane (see the fileoverview above); a drop just stops
|
|
|
|
|
// resizing until the parent recreates the pane.
|
|
|
|
|
this.ws.onclose = () => {
|
|
|
|
|
this._wsReady = false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.ws.onerror = () => {
|
|
|
|
|
// onclose fires after onerror — cleanup happens there.
|
|
|
|
|
};
|
2026-09-15 23:35:13 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fit() {
|
|
|
|
|
if (!this.fitAddon) return;
|
|
|
|
|
this.fitAddon.fit();
|
|
|
|
|
this._sendResize();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_sendResize() {
|
|
|
|
|
if (!this._wsReady || !this.fitAddon) return;
|
|
|
|
|
const dims = this.fitAddon.proposeDimensions();
|
|
|
|
|
if (!dims) return;
|
2026-09-16 20:23:30 -05:00
|
|
|
// Send the real proposed dimensions unclamped, matching the primary
|
|
|
|
|
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
|
|
|
|
|
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
|
|
|
|
// A 40/10 floor here misreported Pane B's real width to the PTY at the
|
|
|
|
|
// divider's own reachable 20% floor position, causing real
|
|
|
|
|
// output-wrapping bugs.
|
|
|
|
|
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
|
2026-09-15 23:35:13 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
destroy() {
|
2026-09-18 20:55:09 -05:00
|
|
|
this._destroyed = true;
|
2026-09-15 23:35:13 -05:00
|
|
|
if (this.ws) {
|
|
|
|
|
this.ws.onopen = null;
|
|
|
|
|
this.ws.onmessage = null;
|
|
|
|
|
this.ws.close();
|
|
|
|
|
this.ws = null;
|
|
|
|
|
}
|
|
|
|
|
if (this.terminal) {
|
|
|
|
|
this.terminal.dispose();
|
|
|
|
|
this.terminal = null;
|
|
|
|
|
}
|
|
|
|
|
this.fitAddon = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
global.SplitTerminalPane = SplitTerminalPane;
|
|
|
|
|
})(window);
|
2026-09-16 10:35:48 -05:00
|
|
|
|
|
|
|
|
Object.assign(CodemanApp.prototype, {
|
2026-09-16 20:56:15 -05:00
|
|
|
openSplitPicker(event) {
|
|
|
|
|
// Mirrors toggleRunModeMenu (session-ui.js): stopPropagation on the
|
|
|
|
|
// OPENING click so it never reaches the outside-click listener this
|
|
|
|
|
// same call is about to register — without it, a click landing on the
|
|
|
|
|
// button's own inner <svg> (matched by neither `menu.contains()` nor
|
|
|
|
|
// the old exact-node check below) bubbled straight through to
|
|
|
|
|
// `document` and self-closed the menu it just opened.
|
|
|
|
|
event?.stopPropagation();
|
2026-09-16 10:35:48 -05:00
|
|
|
if (this._splitPane) {
|
|
|
|
|
this.closeSplitPane();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const candidates = window.CodemanSplitPane.buildSplitPickerSessions(
|
|
|
|
|
this.sessions,
|
|
|
|
|
this.sessionOrder,
|
|
|
|
|
this.activeSessionId
|
|
|
|
|
);
|
2026-09-16 20:56:15 -05:00
|
|
|
// Route a pre-existing menu through the SAME dismiss path used
|
|
|
|
|
// everywhere else, instead of a raw `.remove()`: a genuinely still-open
|
|
|
|
|
// menu has live document listeners (see below), and a raw removal left
|
|
|
|
|
// them attached forever — only the single-slot field below got
|
|
|
|
|
// overwritten, so every prior pair but the last was orphaned on
|
|
|
|
|
// `document` with no way to ever find and remove it again.
|
|
|
|
|
this._dismissSplitPicker();
|
2026-09-16 10:35:48 -05:00
|
|
|
|
|
|
|
|
const menu = document.createElement('div');
|
|
|
|
|
menu.id = 'splitPickerMenu';
|
|
|
|
|
menu.className = 'split-picker-menu';
|
|
|
|
|
if (candidates.length === 0) {
|
|
|
|
|
menu.innerHTML = '<div class="split-picker-empty">No other sessions to split with</div>';
|
|
|
|
|
} else {
|
|
|
|
|
menu.innerHTML = candidates
|
|
|
|
|
.map(
|
|
|
|
|
(c) =>
|
2026-09-16 20:23:42 -05:00
|
|
|
`<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>`
|
2026-09-16 10:35:48 -05:00
|
|
|
)
|
|
|
|
|
.join('');
|
|
|
|
|
}
|
|
|
|
|
document.body.appendChild(menu);
|
|
|
|
|
const splitBtn = document.querySelector('.btn-split');
|
|
|
|
|
if (splitBtn) {
|
|
|
|
|
const rect = splitBtn.getBoundingClientRect();
|
|
|
|
|
menu.style.position = 'fixed';
|
|
|
|
|
menu.style.top = `${rect.bottom + 4}px`;
|
|
|
|
|
menu.style.right = `${window.innerWidth - rect.right}px`;
|
|
|
|
|
}
|
2026-09-16 20:23:42 -05:00
|
|
|
|
|
|
|
|
// Dismiss on outside click or Escape — same one-shot listener pattern as
|
|
|
|
|
// session-ui.js's other transient popovers (toggleCaseSettings(),
|
|
|
|
|
// toggleRunModeMenu()). Deferred by a tick so the click that OPENED the
|
2026-09-16 20:56:15 -05:00
|
|
|
// menu (still bubbling) doesn't immediately close it — reinforced by
|
|
|
|
|
// the button's own stopPropagation() above, which is what actually
|
|
|
|
|
// stops that same click reaching `document` at all. Picking an item
|
2026-09-16 20:23:42 -05:00
|
|
|
// (above) calls the SAME dismiss method, so these listeners never
|
|
|
|
|
// outlive the menu either way.
|
2026-09-16 20:56:15 -05:00
|
|
|
//
|
|
|
|
|
// Self-removing by identity: each handler removes ITSELF (and its
|
|
|
|
|
// sibling) the moment it fires, rather than leaning solely on the
|
|
|
|
|
// `this._splitPickerDismissHandlers` field. That field is still kept in
|
|
|
|
|
// sync (so `_dismissSplitPicker()` called from elsewhere — the picker
|
|
|
|
|
// item's onclick above, or a still-open menu at the top of this method
|
|
|
|
|
// — can find and remove the CURRENT pair), but no path here can ever
|
|
|
|
|
// again leave a pair attached to `document` with nothing referencing it.
|
2026-09-16 20:23:42 -05:00
|
|
|
const closeOnOutsideClick = (e) => {
|
2026-09-16 20:56:15 -05:00
|
|
|
if (menu.contains(e.target) || e.target.closest('.btn-split')) return;
|
|
|
|
|
document.removeEventListener('click', closeOnOutsideClick);
|
|
|
|
|
document.removeEventListener('keydown', closeOnEscape);
|
|
|
|
|
this._splitPickerDismissHandlers = null;
|
|
|
|
|
menu.remove();
|
2026-09-16 20:23:42 -05:00
|
|
|
};
|
|
|
|
|
const closeOnEscape = (e) => {
|
2026-09-16 20:56:15 -05:00
|
|
|
if (e.key !== 'Escape') return;
|
|
|
|
|
document.removeEventListener('click', closeOnOutsideClick);
|
|
|
|
|
document.removeEventListener('keydown', closeOnEscape);
|
|
|
|
|
this._splitPickerDismissHandlers = null;
|
|
|
|
|
menu.remove();
|
2026-09-16 20:23:42 -05:00
|
|
|
};
|
|
|
|
|
this._splitPickerDismissHandlers = { closeOnOutsideClick, closeOnEscape };
|
|
|
|
|
setTimeout(() => document.addEventListener('click', closeOnOutsideClick), 0);
|
|
|
|
|
document.addEventListener('keydown', closeOnEscape);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
_dismissSplitPicker() {
|
|
|
|
|
document.getElementById('splitPickerMenu')?.remove();
|
|
|
|
|
if (this._splitPickerDismissHandlers) {
|
|
|
|
|
document.removeEventListener('click', this._splitPickerDismissHandlers.closeOnOutsideClick);
|
|
|
|
|
document.removeEventListener('keydown', this._splitPickerDismissHandlers.closeOnEscape);
|
|
|
|
|
this._splitPickerDismissHandlers = null;
|
|
|
|
|
}
|
2026-09-16 10:35:48 -05:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
openSplitPane(sessionId) {
|
2026-09-16 20:24:23 -05:00
|
|
|
// A stale picker click (opened before switching tabs) or clicking Pane
|
|
|
|
|
// B's own session tab while split can otherwise land here with
|
|
|
|
|
// sessionId === activeSessionId: two live WebSockets to the same
|
|
|
|
|
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
|
|
|
|
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
|
|
|
|
|
if (sessionId === this.activeSessionId) return;
|
2026-09-16 10:35:48 -05:00
|
|
|
if (this._splitPane) this.closeSplitPane();
|
|
|
|
|
|
|
|
|
|
const wrap = document.querySelector('.terminal-wrap');
|
|
|
|
|
const parent = wrap.parentElement;
|
|
|
|
|
|
|
|
|
|
const container = document.createElement('div');
|
|
|
|
|
container.className = 'terminal-split-container';
|
|
|
|
|
|
|
|
|
|
const divider = document.createElement('div');
|
|
|
|
|
divider.className = 'split-divider';
|
|
|
|
|
|
|
|
|
|
const paneB = document.createElement('div');
|
|
|
|
|
paneB.className = 'terminal-pane-b';
|
|
|
|
|
const session = this.sessions.get(sessionId);
|
|
|
|
|
paneB.innerHTML = `
|
|
|
|
|
<div class="terminal-pane-b-header">
|
|
|
|
|
<span>${escapeHtml(session?.name || 'Session')}</span>
|
|
|
|
|
<span class="terminal-pane-b-close" onclick="app.closeSplitPane()">×</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="terminal-pane-b-container"></div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
parent.insertBefore(container, wrap);
|
|
|
|
|
container.appendChild(wrap);
|
|
|
|
|
wrap.style.flexBasis = '50%';
|
|
|
|
|
container.appendChild(divider);
|
|
|
|
|
container.appendChild(paneB);
|
|
|
|
|
paneB.style.flexBasis = '50%';
|
|
|
|
|
|
|
|
|
|
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'));
|
|
|
|
|
this._splitPane.connect();
|
|
|
|
|
this._splitSessionId = sessionId;
|
|
|
|
|
|
2026-09-18 20:55:09 -05:00
|
|
|
// Pane A just went from full width to 50%, but nothing has told its
|
|
|
|
|
// session's PTY/tmux window about it yet — the passive ResizeObserver in
|
|
|
|
|
// terminal-ui.js debounces 300ms and would eventually catch up, but
|
|
|
|
|
// relying on that left the pane showing stale-width content (existing
|
|
|
|
|
// box-drawing lines, banners) until the user hit "Redraw Terminal".
|
|
|
|
|
// Force it immediately, mirroring closeSplitPane()'s symmetric call.
|
|
|
|
|
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
|
|
|
|
|
2026-09-16 10:35:48 -05:00
|
|
|
this._installSplitDividerDrag(divider, wrap, paneB);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
closeSplitPane() {
|
|
|
|
|
if (!this._splitPane) return;
|
|
|
|
|
this._splitPane.destroy();
|
|
|
|
|
this._splitPane = null;
|
|
|
|
|
this._splitSessionId = null;
|
|
|
|
|
|
|
|
|
|
const container = document.querySelector('.terminal-split-container');
|
|
|
|
|
if (!container) return;
|
|
|
|
|
const wrap = container.querySelector('.terminal-wrap');
|
|
|
|
|
const parent = container.parentElement;
|
|
|
|
|
wrap.style.flexBasis = '';
|
|
|
|
|
parent.insertBefore(wrap, container);
|
|
|
|
|
container.remove();
|
|
|
|
|
|
|
|
|
|
if (this.fitAddon) this.fitAddon.fit();
|
|
|
|
|
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
_installSplitDividerDrag(divider, wrap, paneB) {
|
|
|
|
|
let dragging = false;
|
|
|
|
|
|
|
|
|
|
const onMove = (e) => {
|
|
|
|
|
if (!dragging) return;
|
|
|
|
|
const container = divider.parentElement;
|
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
|
|
|
const rawPercent = ((e.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();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onUp = () => {
|
|
|
|
|
dragging = false;
|
|
|
|
|
divider.classList.remove('dragging');
|
|
|
|
|
document.removeEventListener('mousemove', onMove);
|
|
|
|
|
document.removeEventListener('mouseup', onUp);
|
2026-09-18 20:55:09 -05:00
|
|
|
// 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).
|
|
|
|
|
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
2026-09-16 10:35:48 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
divider.addEventListener('mousedown', () => {
|
|
|
|
|
dragging = true;
|
|
|
|
|
divider.classList.add('dragging');
|
|
|
|
|
document.addEventListener('mousemove', onMove);
|
|
|
|
|
document.addEventListener('mouseup', onUp);
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-09-16 10:50:54 -05:00
|
|
|
|
|
|
|
|
const _originalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted;
|
|
|
|
|
CodemanApp.prototype._onSessionDeleted = function (data) {
|
|
|
|
|
if (this._splitSessionId === data.id) {
|
|
|
|
|
this.closeSplitPane();
|
|
|
|
|
} else if (this._splitPane && this.activeSessionId === data.id) {
|
|
|
|
|
// Pane A's session ended: promote Pane B by closing the split and
|
|
|
|
|
// selecting its session as the new (single) active pane.
|
|
|
|
|
const promoted = this._splitSessionId;
|
|
|
|
|
this.closeSplitPane();
|
|
|
|
|
if (promoted) this.selectSession(promoted);
|
|
|
|
|
}
|
|
|
|
|
return _originalOnSessionDeleted.call(this, data);
|
|
|
|
|
};
|
2026-09-16 20:24:23 -05:00
|
|
|
|
|
|
|
|
// I2: closes an active split BEFORE the primary pane rebinds to the same
|
|
|
|
|
// session Pane B is showing (clicking Pane B's own session tab while split,
|
|
|
|
|
// or any other selectSession() call that targets _splitSessionId). Without
|
|
|
|
|
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
|
|
|
|
|
// still attached to — two live WebSockets to one session, each claiming PTY
|
|
|
|
|
// dimensions via its own `{t:'z',...}` resize frame.
|
|
|
|
|
const _originalSelectSession = CodemanApp.prototype.selectSession;
|
|
|
|
|
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
|
|
|
|
|
if (this._splitPane && this._splitSessionId === sessionId) {
|
|
|
|
|
this.closeSplitPane();
|
|
|
|
|
}
|
|
|
|
|
return _originalSelectSession.call(this, sessionId, ...args);
|
|
|
|
|
};
|