You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
622 lines
22 KiB
JavaScript
622 lines
22 KiB
JavaScript
// Main application orchestrator
|
|
(async function () {
|
|
// Check if auth is required
|
|
const authStatus = await fetch('/api/auth/status').then((r) => r.json()).catch(() => ({ authRequired: true }));
|
|
if (authStatus.authRequired) {
|
|
const token = localStorage.getItem('cmux-remote-token');
|
|
if (!token) {
|
|
window.location.href = '/login.html';
|
|
return;
|
|
}
|
|
}
|
|
|
|
// State
|
|
let currentWorkspace = null;
|
|
let currentSurface = null;
|
|
let lastWorkspaces = [];
|
|
|
|
// URL hash helpers — format: #wsRef/surfaceRef
|
|
function getHashSurface() {
|
|
const hash = location.hash.slice(1);
|
|
if (!hash) return null;
|
|
const slash = hash.indexOf('/');
|
|
if (slash === -1) return null;
|
|
return {
|
|
ws: decodeURIComponent(hash.slice(0, slash)),
|
|
surface: decodeURIComponent(hash.slice(slash + 1)),
|
|
};
|
|
}
|
|
|
|
function setHashSurface(wsRef, surfaceRef) {
|
|
history.replaceState(null, '', '#' + encodeURIComponent(wsRef) + '/' + encodeURIComponent(surfaceRef));
|
|
}
|
|
|
|
function findAndSwitch(workspaces, wsRef, surfaceRef) {
|
|
for (const workspace of workspaces) {
|
|
if (workspace.ref === wsRef) {
|
|
for (const pane of workspace.panes) {
|
|
for (const surface of pane.surfaces) {
|
|
if (surface.ref === surfaceRef) {
|
|
switchSurface(wsRef, surfaceRef);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Components
|
|
const ws = new WebSocketClient();
|
|
const terminal = new TerminalView(document.getElementById('terminal-output'));
|
|
const statusDot = document.getElementById('connection-status');
|
|
const terminalContainer = document.getElementById('terminal-container');
|
|
const browserView = document.getElementById('browser-view');
|
|
const terminalOutput = document.getElementById('terminal-output');
|
|
|
|
// ── Image zoom viewer ──────────────────────────────────────────
|
|
class ImageZoomViewer {
|
|
constructor(img, container) {
|
|
this._img = img;
|
|
this._con = container;
|
|
this._scale = 1;
|
|
this._x = 0; this._y = 0;
|
|
this._lastDist = 0;
|
|
this._dragging = false;
|
|
this._lastX = 0; this._lastY = 0;
|
|
|
|
container.addEventListener('touchstart', this._ts.bind(this), { passive: false });
|
|
container.addEventListener('touchmove', this._tm.bind(this), { passive: false });
|
|
container.addEventListener('touchend', () => { this._dragging = false; }, { passive: true });
|
|
container.addEventListener('wheel', this._wheel.bind(this), { passive: false });
|
|
container.addEventListener('mousedown', this._md.bind(this));
|
|
container.addEventListener('mousemove', this._mm.bind(this));
|
|
container.addEventListener('mouseup', this._mu.bind(this));
|
|
container.addEventListener('mouseleave', this._mu.bind(this));
|
|
container.addEventListener('dblclick', () => this.reset());
|
|
}
|
|
|
|
reset() { this._scale = 1; this._x = 0; this._y = 0; this._apply(); }
|
|
|
|
_apply() {
|
|
this._img.style.transform = `translate(${this._x}px, ${this._y}px) scale(${this._scale})`;
|
|
}
|
|
|
|
_clampScale(s) { return Math.min(10, Math.max(0.5, s)); }
|
|
|
|
_ts(e) {
|
|
if (e.touches.length === 2) {
|
|
e.preventDefault();
|
|
this._lastDist = this._dist(e.touches[0], e.touches[1]);
|
|
} else if (e.touches.length === 1) {
|
|
this._dragging = true;
|
|
this._lastX = e.touches[0].clientX;
|
|
this._lastY = e.touches[0].clientY;
|
|
}
|
|
}
|
|
|
|
_tm(e) {
|
|
e.preventDefault();
|
|
if (e.touches.length === 2) {
|
|
const d = this._dist(e.touches[0], e.touches[1]);
|
|
this._scale = this._clampScale(this._scale * (d / this._lastDist));
|
|
this._lastDist = d;
|
|
this._apply();
|
|
} else if (e.touches.length === 1 && this._dragging) {
|
|
this._x += e.touches[0].clientX - this._lastX;
|
|
this._y += e.touches[0].clientY - this._lastY;
|
|
this._lastX = e.touches[0].clientX;
|
|
this._lastY = e.touches[0].clientY;
|
|
this._apply();
|
|
}
|
|
}
|
|
|
|
_wheel(e) {
|
|
e.preventDefault();
|
|
this._scale = this._clampScale(this._scale * (e.deltaY < 0 ? 1.12 : 0.9));
|
|
this._apply();
|
|
}
|
|
|
|
_md(e) { this._dragging = true; this._lastX = e.clientX; this._lastY = e.clientY; this._con.classList.add('dragging'); }
|
|
_mm(e) {
|
|
if (!this._dragging) return;
|
|
this._x += e.clientX - this._lastX;
|
|
this._y += e.clientY - this._lastY;
|
|
this._lastX = e.clientX; this._lastY = e.clientY;
|
|
this._apply();
|
|
}
|
|
_mu() { this._dragging = false; this._con.classList.remove('dragging'); }
|
|
|
|
_dist(t1, t2) {
|
|
return Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
|
|
}
|
|
}
|
|
|
|
const browserLoading = document.getElementById('browser-loading');
|
|
const zoomBtn = document.getElementById('browser-zoom-btn');
|
|
const zoomOverlay = document.getElementById('img-zoom-overlay');
|
|
const zoomContainer = document.getElementById('img-zoom-container');
|
|
const zoomImg = document.getElementById('img-zoom-img');
|
|
const zoomClose = document.getElementById('img-zoom-close');
|
|
let zoomViewer = null;
|
|
|
|
zoomBtn.addEventListener('click', () => {
|
|
zoomImg.src = browserView.src;
|
|
zoomOverlay.hidden = false;
|
|
if (!zoomViewer) {
|
|
zoomViewer = new ImageZoomViewer(zoomImg, zoomContainer);
|
|
} else {
|
|
zoomViewer.reset();
|
|
}
|
|
});
|
|
|
|
zoomClose.addEventListener('click', () => { zoomOverlay.hidden = true; });
|
|
const sidebar = new Sidebar((wsRef, surfaceRef) => {
|
|
switchSurface(wsRef, surfaceRef);
|
|
});
|
|
|
|
const gestures = new GestureHandler(sidebar);
|
|
const theme = new ThemeSwitcher();
|
|
|
|
// Scrollback state
|
|
const PAGE_LINES = 500;
|
|
let scrollbackLines = 0; // 0 = live view
|
|
let inScrollMode = false;
|
|
let scrollbackPending = false;
|
|
// Set once capture-pane stops returning new lines (history exhausted). Without
|
|
// this, a surface with little/no scrollback flashes the loader forever: each
|
|
// wheel-up at the top re-requests, the response adds nothing, the view stays put
|
|
// at scrollTop 0, so the next wheel-up fires again. Reset on surface switch /
|
|
// when live updates resume (more history may exist later).
|
|
let noMoreHistory = false;
|
|
const scrollbackLoading = document.getElementById('scrollback-loading');
|
|
|
|
function requestScrollback(lines) {
|
|
if (!currentWorkspace || !currentSurface) return;
|
|
scrollbackPending = true;
|
|
if (scrollbackLoading) scrollbackLoading.hidden = false;
|
|
ws.requestScroll(currentWorkspace, currentSurface, lines);
|
|
}
|
|
|
|
function exitScrollMode() {
|
|
inScrollMode = false;
|
|
scrollbackLines = 0;
|
|
scrollbackPending = false;
|
|
noMoreHistory = false;
|
|
if (scrollbackLoading) scrollbackLoading.hidden = true;
|
|
terminal.autoScroll = true;
|
|
// Re-subscribe to get fresh live content
|
|
if (currentWorkspace && currentSurface) {
|
|
ws.subscribe(currentWorkspace, currentSurface);
|
|
}
|
|
}
|
|
|
|
// Scroll-driven history: reaching the top (with scroll room) loads older
|
|
// history; reaching the bottom resumes live updates. Entry is explicit (only
|
|
// on an actual scroll-to-top), never automatic — that avoids the freeze the
|
|
// old auto-load-when-fits caused.
|
|
terminalContainer.addEventListener('scroll', () => {
|
|
const c = terminalContainer;
|
|
const atBottom = c.scrollHeight - c.scrollTop - c.clientHeight < 30;
|
|
|
|
if (atBottom) {
|
|
if (inScrollMode) exitScrollMode();
|
|
return;
|
|
}
|
|
|
|
if (c.scrollTop < 40 && !scrollbackPending && terminal.lines.length &&
|
|
currentWorkspace && currentSurface) {
|
|
loadOlderHistory();
|
|
}
|
|
});
|
|
|
|
function loadOlderHistory() {
|
|
if (scrollbackPending || noMoreHistory || !terminal.lines.length ||
|
|
!currentWorkspace || !currentSurface) return;
|
|
inScrollMode = true;
|
|
terminal.autoScroll = false; // pin position; render-fix won't snap to bottom
|
|
scrollbackLines += PAGE_LINES;
|
|
requestScrollback(scrollbackLines);
|
|
}
|
|
|
|
// Desktop has no touch scroll. When the live screen fits the viewport exactly
|
|
// there is no scroll room, so the 'scroll' event never fires and history can't
|
|
// be reached. Catch the wheel-up intent directly to load older history.
|
|
terminalContainer.addEventListener('wheel', (e) => {
|
|
if (e.deltaY < 0 && terminalContainer.scrollTop < 40) {
|
|
loadOlderHistory();
|
|
}
|
|
}, { passive: true });
|
|
|
|
const keyboard = new VirtualKeyboard(
|
|
(text) => {
|
|
if (currentWorkspace && currentSurface) {
|
|
if (inScrollMode) exitScrollMode();
|
|
ws.sendText(currentWorkspace, currentSurface, text);
|
|
}
|
|
},
|
|
(key) => {
|
|
if (key === 'PageUp') {
|
|
if (noMoreHistory) return;
|
|
inScrollMode = true;
|
|
scrollbackLines += PAGE_LINES;
|
|
requestScrollback(scrollbackLines);
|
|
return;
|
|
}
|
|
if (key === 'PageDown') {
|
|
if (inScrollMode) {
|
|
scrollbackLines = Math.max(0, scrollbackLines - PAGE_LINES);
|
|
if (scrollbackLines === 0) {
|
|
exitScrollMode();
|
|
} else {
|
|
requestScrollback(scrollbackLines);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (inScrollMode) exitScrollMode();
|
|
if (currentWorkspace && currentSurface) {
|
|
ws.sendKey(currentWorkspace, currentSurface, key);
|
|
}
|
|
},
|
|
(text) => {
|
|
// onSubmitText: send text + server-side Enter (awaited ordering)
|
|
if (currentWorkspace && currentSurface) {
|
|
if (inScrollMode) exitScrollMode();
|
|
ws.sendTextSubmit(currentWorkspace, currentSurface, text);
|
|
}
|
|
}
|
|
);
|
|
|
|
const claudeKeyboard = new ClaudeKeyboard(
|
|
(text) => {
|
|
if (currentWorkspace && currentSurface) {
|
|
ws.sendText(currentWorkspace, currentSurface, text);
|
|
}
|
|
},
|
|
(key) => {
|
|
if (currentWorkspace && currentSurface) {
|
|
ws.sendKey(currentWorkspace, currentSurface, key);
|
|
}
|
|
}
|
|
);
|
|
|
|
function handleClaudeAction(action, payload) {
|
|
if (!currentWorkspace || !currentSurface) return;
|
|
if (action === 'select') {
|
|
ws.sendText(currentWorkspace, currentSurface, payload + '\n');
|
|
} else if (action === 'mode-toggle') {
|
|
ws.sendKey(currentWorkspace, currentSurface, 'Shift-Tab');
|
|
}
|
|
}
|
|
|
|
// Reload button
|
|
document.getElementById('reload-btn').addEventListener('click', () => location.reload());
|
|
|
|
// Horizontal scroll toggle
|
|
const scrollXCb = document.getElementById('scroll-x-cb');
|
|
scrollXCb.addEventListener('change', () => {
|
|
terminalContainer.classList.toggle('scroll-x', scrollXCb.checked);
|
|
});
|
|
|
|
// Keyboard toggle — restore saved state
|
|
const keyboardToggle = document.getElementById('keyboard-toggle');
|
|
const virtualKeyboardEl = document.getElementById('virtual-keyboard');
|
|
|
|
const savedKbCollapsed = localStorage.getItem('cmux-kb-collapsed');
|
|
if (savedKbCollapsed !== null) {
|
|
virtualKeyboardEl.classList.toggle('collapsed', savedKbCollapsed === 'true');
|
|
} else if (window.innerWidth >= 1024) {
|
|
virtualKeyboardEl.classList.add('collapsed');
|
|
}
|
|
|
|
keyboardToggle.addEventListener('click', () => {
|
|
virtualKeyboardEl.classList.toggle('collapsed');
|
|
localStorage.setItem('cmux-kb-collapsed', virtualKeyboardEl.classList.contains('collapsed'));
|
|
});
|
|
|
|
window.addEventListener('resize', () => {
|
|
if (window.innerWidth < 1024) {
|
|
virtualKeyboardEl.classList.remove('collapsed');
|
|
localStorage.removeItem('cmux-kb-collapsed');
|
|
}
|
|
});
|
|
|
|
// Settings panel
|
|
const settingsBtn = document.getElementById('settings-btn');
|
|
const settingsPanel = document.getElementById('settings-panel');
|
|
const settingsOverlay = document.getElementById('settings-overlay');
|
|
const settingsClose = document.getElementById('settings-close');
|
|
settingsBtn.addEventListener('click', () => {
|
|
settingsPanel.classList.add('open');
|
|
settingsOverlay.classList.add('visible');
|
|
});
|
|
function closeSettings() {
|
|
settingsPanel.classList.remove('open');
|
|
settingsOverlay.classList.remove('visible');
|
|
}
|
|
settingsClose.addEventListener('click', closeSettings);
|
|
settingsOverlay.addEventListener('click', closeSettings);
|
|
|
|
function updateTitle(wsRef, surfaceRef) {
|
|
const el = document.getElementById('app-title');
|
|
if (!el) return;
|
|
let rawTitle = surfaceRef || '';
|
|
for (const workspace of lastWorkspaces) {
|
|
if (workspace.ref === wsRef) {
|
|
for (const pane of workspace.panes) {
|
|
for (const surface of pane.surfaces) {
|
|
if (surface.ref === surfaceRef) {
|
|
rawTitle = surface.title || surface.ref;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const status = Sidebar.parseStatus(rawTitle);
|
|
el.innerHTML = '';
|
|
if (status.state) {
|
|
const dot = document.createElement('span');
|
|
dot.className = 'title-status ' + status.state;
|
|
el.appendChild(dot);
|
|
}
|
|
el.appendChild(document.createTextNode(status.title));
|
|
}
|
|
|
|
|
|
// Connection events
|
|
// The `.stale` veil dims the WHOLE terminal (a 40%-opacity overlay). Showing
|
|
// it on every brief disconnect makes the screen visibly "fade out then back"
|
|
// whenever the socket flaps/reconnects (common on remote/tunnel connections).
|
|
// Debounce it: only dim once a disconnect has lasted long enough that the view
|
|
// is genuinely stale; a fast reconnect clears the pending dim with no flash.
|
|
let staleTimer = null;
|
|
ws.on('connected', () => {
|
|
statusDot.classList.add('connected');
|
|
statusDot.classList.remove('reconnecting');
|
|
statusDot.title = 'Connected';
|
|
if (staleTimer) { clearTimeout(staleTimer); staleTimer = null; }
|
|
terminalContainer.classList.remove('stale');
|
|
});
|
|
|
|
ws.on('disconnected', () => {
|
|
statusDot.classList.remove('connected');
|
|
statusDot.classList.add('reconnecting');
|
|
statusDot.title = 'Reconnecting...';
|
|
if (!staleTimer && !terminalContainer.classList.contains('stale')) {
|
|
staleTimer = setTimeout(() => {
|
|
terminalContainer.classList.add('stale');
|
|
staleTimer = null;
|
|
}, 2500);
|
|
}
|
|
});
|
|
|
|
ws.on('reconnecting', ({ attempt }) => {
|
|
statusDot.title = `Reconnecting (${attempt})...`;
|
|
});
|
|
|
|
ws.on('auth-ok', () => {
|
|
ws.listWorkspaces();
|
|
// Re-subscribe to current surface after reconnect
|
|
if (currentWorkspace && currentSurface) {
|
|
ws.subscribe(currentWorkspace, currentSurface);
|
|
}
|
|
});
|
|
|
|
ws.on('workspaces', (msg) => {
|
|
sidebar.setWorkspaces(msg.workspaces);
|
|
lastWorkspaces = msg.workspaces;
|
|
|
|
// Auto-subscribe only on first load (no surface selected yet)
|
|
if (!currentSurface && msg.workspaces.length > 0) {
|
|
// 1. Try to restore from URL hash
|
|
const saved = getHashSurface();
|
|
if (saved && findAndSwitch(msg.workspaces, saved.ws, saved.surface)) return;
|
|
|
|
// 2. Try to restore from localStorage
|
|
try {
|
|
const ls = JSON.parse(localStorage.getItem('cmux-last-surface') || 'null');
|
|
if (ls && findAndSwitch(msg.workspaces, ls.ws, ls.surface)) return;
|
|
} catch {}
|
|
|
|
// 3. Fall back to first terminal surface
|
|
const firstWs = msg.workspaces[0];
|
|
for (const pane of firstWs.panes) {
|
|
for (const surface of pane.surfaces) {
|
|
if (surface.type === 'terminal') {
|
|
switchSurface(firstWs.ref, surface.ref);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
// 3. Fall back to first surface of any type
|
|
if (firstWs.panes.length > 0 && firstWs.panes[0].surfaces.length > 0) {
|
|
const s = firstWs.panes[0].surfaces[0];
|
|
switchSurface(firstWs.ref, s.ref);
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on('screen', (msg) => {
|
|
if (msg.surface !== currentSurface) return;
|
|
// While viewing loaded history, pause live screen replacement.
|
|
if (inScrollMode && !msg.scrollback) return;
|
|
|
|
if (msg.scrollback) {
|
|
// User-triggered history load (scroll-to-top or PageUp): show it, pause
|
|
// live updates, and preserve the user's reading position. Older lines are
|
|
// prepended, so shift scrollTop by the height added above.
|
|
const prevScrollHeight = terminalContainer.scrollHeight;
|
|
const prevScrollTop = terminalContainer.scrollTop;
|
|
const prevLineCount = terminal.lines.length;
|
|
|
|
terminal.setContent(msg.lines);
|
|
terminal.autoScroll = false;
|
|
scrollbackPending = false;
|
|
if (scrollbackLoading) scrollbackLoading.hidden = true;
|
|
|
|
// capture-pane returned no additional lines → history is exhausted. Stop
|
|
// further requests so the loader doesn't flash on every wheel-up at the top.
|
|
if (msg.lines.length <= prevLineCount) {
|
|
noMoreHistory = true;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
const added = terminalContainer.scrollHeight - prevScrollHeight;
|
|
// Preserve the reading position: keep whatever is on screen exactly where it
|
|
// is and let the freshly-loaded older lines sit ABOVE it, so the view doesn't
|
|
// jump — scrolling up further then reveals the history smoothly. (Older lines
|
|
// are prepended, so shift scrollTop down by the height added above.)
|
|
terminalContainer.scrollTop = prevScrollTop + Math.max(added, 0);
|
|
});
|
|
} else {
|
|
terminal.setContent(msg.lines);
|
|
}
|
|
});
|
|
|
|
ws.on('screen-diff', (msg) => {
|
|
if (msg.surface === currentSurface && !inScrollMode) {
|
|
terminal.applyDiff(msg.patches);
|
|
}
|
|
});
|
|
|
|
ws.on('browser-screenshot', (msg) => {
|
|
if (msg.surface !== currentSurface) return;
|
|
const mime = msg.mime || 'image/jpeg';
|
|
const src = `data:${mime};base64,${msg.imageData}`;
|
|
terminalOutput.hidden = true;
|
|
terminalContainer.classList.add('browser-mode');
|
|
|
|
// Hide loading bar, show image
|
|
browserLoading.hidden = true;
|
|
browserLoading.classList.remove('flash');
|
|
browserView.hidden = false;
|
|
zoomBtn.hidden = false;
|
|
browserView.src = src;
|
|
if (!zoomOverlay.hidden) zoomImg.src = src;
|
|
});
|
|
|
|
async function updateCodexMode(surfaceRef) {
|
|
// Skip if claude mode is already active (mutually exclusive)
|
|
if (document.body.classList.contains('claude-mode')) return;
|
|
try {
|
|
const data = await fetch('/api/codex-surfaces').then(r => r.json());
|
|
if (surfaceRef !== currentSurface) return;
|
|
const isCodex = data.surfaces.includes(surfaceRef);
|
|
document.body.classList.toggle('codex-mode', isCodex);
|
|
if (terminal.setCodexMode) terminal.setCodexMode(isCodex);
|
|
} catch {
|
|
if (surfaceRef !== currentSurface) return;
|
|
document.body.classList.remove('codex-mode');
|
|
if (terminal.setCodexMode) terminal.setCodexMode(false);
|
|
}
|
|
}
|
|
|
|
async function updateClaudeMode(surfaceRef) {
|
|
try {
|
|
const data = await fetch('/api/claude-surfaces').then(r => r.json());
|
|
if (surfaceRef !== currentSurface) return;
|
|
const isClaudeCode = data.surfaces.includes(surfaceRef);
|
|
document.body.classList.toggle('claude-mode', isClaudeCode);
|
|
terminal.setClaudeMode(isClaudeCode, handleClaudeAction);
|
|
if (isClaudeCode) {
|
|
claudeKeyboard.activate();
|
|
} else {
|
|
claudeKeyboard.deactivate();
|
|
}
|
|
} catch {
|
|
if (surfaceRef !== currentSurface) return;
|
|
document.body.classList.remove('claude-mode');
|
|
terminal.setClaudeMode(false, null);
|
|
claudeKeyboard.deactivate();
|
|
}
|
|
}
|
|
|
|
function switchSurface(wsRef, surfaceRef) {
|
|
if (currentSurface) ws.unsubscribe(currentSurface);
|
|
inScrollMode = false;
|
|
scrollbackLines = 0;
|
|
scrollbackPending = false;
|
|
noMoreHistory = false;
|
|
if (scrollbackLoading) scrollbackLoading.hidden = true;
|
|
|
|
currentWorkspace = wsRef;
|
|
currentSurface = surfaceRef;
|
|
updateTitle(wsRef, surfaceRef);
|
|
localStorage.setItem('cmux-last-surface', JSON.stringify({ ws: wsRef, surface: surfaceRef }));
|
|
|
|
// Immediately reset claude/codex mode; update functions will re-enable if needed
|
|
document.body.classList.remove('claude-mode');
|
|
document.body.classList.remove('codex-mode');
|
|
terminal.setClaudeMode(false, null);
|
|
if (terminal.setCodexMode) terminal.setCodexMode(false);
|
|
claudeKeyboard.deactivate();
|
|
|
|
terminal.clear();
|
|
terminal.autoScroll = true;
|
|
browserView.hidden = true;
|
|
browserLoading.hidden = true;
|
|
browserLoading.classList.remove('flash');
|
|
zoomBtn.hidden = true;
|
|
zoomOverlay.hidden = true;
|
|
terminalOutput.hidden = false;
|
|
terminalContainer.classList.remove('browser-mode');
|
|
ws.subscribe(wsRef, surfaceRef);
|
|
sidebar.setActiveSurface(surfaceRef);
|
|
setHashSurface(wsRef, surfaceRef);
|
|
updateClaudeMode(surfaceRef);
|
|
updateCodexMode(surfaceRef);
|
|
|
|
// If this is a browser surface, show loading indicator immediately
|
|
const surfaceInfo = lastWorkspaces.flatMap(w => w.panes.flatMap(p => p.surfaces))
|
|
.find(s => s.ref === surfaceRef);
|
|
if (surfaceInfo?.type === 'browser') {
|
|
terminalOutput.hidden = true;
|
|
terminalContainer.classList.add('browser-mode');
|
|
browserLoading.hidden = false;
|
|
browserLoading.classList.add('flash');
|
|
}
|
|
}
|
|
|
|
// Browser back/forward navigation
|
|
window.addEventListener('hashchange', () => {
|
|
const saved = getHashSurface();
|
|
if (saved && ws.isConnected()) {
|
|
findAndSwitch(lastWorkspaces, saved.ws, saved.surface);
|
|
}
|
|
});
|
|
|
|
// Visibility API: pause polling when hidden, reconnect when visible
|
|
let hiddenSince = 0;
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) {
|
|
hiddenSince = Date.now();
|
|
if (currentSurface) ws.unsubscribe(currentSurface);
|
|
} else {
|
|
const elapsed = hiddenSince ? Date.now() - hiddenSince : 0;
|
|
hiddenSince = 0;
|
|
if (!currentWorkspace || !currentSurface) return;
|
|
|
|
// iOS kills WebSocket connections in background.
|
|
// If hidden >5s or connection is dead, force fresh reconnect.
|
|
if (!ws.isConnected() || elapsed > 5000) {
|
|
ws.reconnectNow();
|
|
} else {
|
|
ws.subscribe(currentWorkspace, currentSurface);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Periodic workspace refresh
|
|
setInterval(() => {
|
|
if (ws.isConnected()) {
|
|
ws.listWorkspaces();
|
|
}
|
|
}, 10000);
|
|
|
|
// Start
|
|
ws.connect(authStatus.authRequired);
|
|
})();
|