diff --git a/client/app.js b/client/app.js index 1133fa74..e67ebea7 100644 --- a/client/app.js +++ b/client/app.js @@ -60,6 +60,7 @@ class ClaudeOrchestrator { this.simpleModeStartupTriggered = false; this.desktopDevtoolsKeydownHandler = null; this.currentLayout = '2x4'; + this._wasMobileLayout = this.isMobileLayout(); this.serverStatuses = new Map(); // Track server running status this.serverPorts = new Map(); // Track server ports this.githubLinks = new Map(); // Track GitHub PR/branch links per session @@ -163,6 +164,34 @@ class ClaudeOrchestrator { } } + syncDesktopMobileLayoutState() { + const nowMobile = this.isMobileLayout(); + const becameMobile = !this._wasMobileLayout && nowMobile; + const becameDesktop = this._wasMobileLayout && !nowMobile; + if (!becameMobile && !becameDesktop) { + this._wasMobileLayout = nowMobile; + return; + } + + this._wasMobileLayout = nowMobile; + + if (becameMobile) { + // On mobile layouts, always start from a closed sidebar state. + this.closeSidebar(); + this.updateSidebarToggleIcon(); + return; + } + + // Desktop cleanup: clear any transient mobile-open class and restore + // persisted desktop preferences (instead of inheriting mobile behavior). + if (document.body.classList.contains('sidebar-open')) { + document.body.classList.remove('sidebar-open'); + } + this.applySidebarDesktopCollapsedFromPrefs(); + this.syncSidebarBackdrop(); + this.updateSidebarToggleIcon(); + } + isEditableEventTarget(target) { const el = target && target.nodeType === 1 ? target : null; if (!el) return false; @@ -2832,9 +2861,14 @@ class ClaudeOrchestrator { // Handle window resize to fix blank terminals let resizeTimeout; window.addEventListener('resize', () => { + const prevLayout = this._wasMobileLayout; + this.syncDesktopMobileLayoutState(); this.updateSidebarToggleIcon(); clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { + if (this._wasMobileLayout !== prevLayout) { + this.updateTerminalGrid(); + } // Refit all terminals (not just activeView — covers newly started sessions) for (const sessionId of this.terminalManager.terminals.keys()) { this.terminalManager.fitTerminal(sessionId); diff --git a/client/styles.css b/client/styles.css index cea68fdf..c7779e22 100644 --- a/client/styles.css +++ b/client/styles.css @@ -2036,6 +2036,9 @@ header { .terminal .xterm-viewport { overflow-y: auto !important; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; } /* Buttons */ diff --git a/client/terminal.js b/client/terminal.js index 9bb5fe86..0853ebb7 100644 --- a/client/terminal.js +++ b/client/terminal.js @@ -24,6 +24,9 @@ class TerminalManager { this.terminalScrollStates = new Map(); this.userScrolling = new Map(); this.ephemeralLineState = new Map(); + this.pendingOutput = new Map(); + this.historyLoading = new Set(); + this.historyLoaded = new Set(); // Guardrail: never resize the PTY to tiny dimensions (can hard-wrap output irreversibly). this.lastGoodPtyDimensions = new Map(); // sessionId -> { cols, rows } @@ -347,6 +350,26 @@ class TerminalManager { // Delayed refit catches cases where xterm renderer hasn't measured char dimensions yet setTimeout(() => this.fitTerminal(sessionId), 300); setTimeout(() => this.fitTerminal(sessionId), 1000); + this.ensureTerminalHistoryLoaded(sessionId, terminal); + const viewport = terminalElement.querySelector('.xterm-viewport'); + if (viewport) { + this.terminalScrollStates.set(sessionId, this.terminalScrollStates.get(sessionId) || {}); + const scrollState = this.terminalScrollStates.get(sessionId); + const markScrolling = () => { + this.userScrolling.set(sessionId, true); + if (scrollState.scrollTimer) clearTimeout(scrollState.scrollTimer); + scrollState.scrollTimer = setTimeout(() => this.checkScrollPosition(sessionId), 180); + }; + + viewport.addEventListener('scroll', markScrolling, { passive: true }); + viewport.addEventListener('touchstart', markScrolling, { passive: true }); + viewport.addEventListener('touchmove', markScrolling, { passive: true }); + viewport.addEventListener('touchend', () => { + if (scrollState.scrollTimer) clearTimeout(scrollState.scrollTimer); + scrollState.scrollTimer = setTimeout(() => this.checkScrollPosition(sessionId), 180); + }, { passive: true }); + this.terminalScrollStates.set(sessionId, scrollState); + } }); }); @@ -403,14 +426,19 @@ class TerminalManager { }); // Track user scrolling with mouse wheel - terminalElement.addEventListener('wheel', (e) => { + terminalElement.addEventListener('wheel', () => { // User is scrolling, mark as user interaction + const scrollState = this.terminalScrollStates.get(sessionId) || {}; + this.terminalScrollStates.set(sessionId, scrollState); + this.userScrolling.set(sessionId, true); - - // Clear user scrolling flag after a short delay - setTimeout(() => { + if (scrollState.scrollTimer) { + clearTimeout(scrollState.scrollTimer); + } + scrollState.scrollTimer = setTimeout(() => { this.checkScrollPosition(sessionId); - }, 100); + }, 180); + this.terminalScrollStates.set(sessionId, scrollState); }); // Track scrollbar dragging @@ -867,17 +895,14 @@ class TerminalManager { } handleOutput(sessionId, data) { + const safeData = typeof data === 'string' ? data : ''; const terminal = this.terminals.get(sessionId); if (!terminal) { // Check if DOM element exists before trying to create terminal const terminalElement = this.getTerminalElement(sessionId); if (!terminalElement) { // Buffer early output instead of ignoring it - if (!this.pendingOutput) this.pendingOutput = new Map(); - if (!this.pendingOutput.has(sessionId)) { - this.pendingOutput.set(sessionId, []); - } - this.pendingOutput.get(sessionId).push(data); + this.queuePendingOutput(sessionId, safeData); // Try again in a short while setTimeout(() => this.handleOutput(sessionId, ''), 100); @@ -887,6 +912,11 @@ class TerminalManager { // Create terminal if DOM element exists const sessionInfo = this.orchestrator.sessions.get(sessionId) || {}; this.createTerminal(sessionId, sessionInfo); + this.ensureTerminalHistoryLoaded(sessionId, this.terminals.get(sessionId)); + if (this.historyLoading.has(sessionId)) { + this.queuePendingOutput(sessionId, safeData); + return; + } // Apply any buffered output if (this.pendingOutput && this.pendingOutput.has(sessionId)) { @@ -901,19 +931,23 @@ class TerminalManager { // Try again with current data const newTerminal = this.terminals.get(sessionId); - if (newTerminal && data) { - const normalized = this.normalizeOutput(sessionId, data); + if (newTerminal && safeData) { + const normalized = this.normalizeOutput(sessionId, safeData); if (normalized) { newTerminal.write(normalized); } } return; } + if (this.historyLoading.has(sessionId)) { + this.queuePendingOutput(sessionId, safeData); + return; + } // Check if user is manually scrolling const isUserScrolling = this.userScrolling.get(sessionId) || false; - const normalized = this.normalizeOutput(sessionId, data); + const normalized = this.normalizeOutput(sessionId, safeData); if (!normalized) { return; } @@ -989,6 +1023,72 @@ class TerminalManager { } } } + + queuePendingOutput(sessionId, data) { + if (!data) return; + if (!this.pendingOutput.has(sessionId)) { + this.pendingOutput.set(sessionId, []); + } + this.pendingOutput.get(sessionId).push(data); + } + + flushPendingOutput(sessionId) { + const outputs = this.pendingOutput.get(sessionId); + if (!outputs || !outputs.length) { + return; + } + this.pendingOutput.delete(sessionId); + outputs.forEach((output) => { + if (output) { + this.handleOutput(sessionId, output); + } + }); + } + + async ensureTerminalHistoryLoaded(sessionId, terminal = null) { + const normalizedSessionId = String(sessionId || '').trim(); + if (!normalizedSessionId) { + return; + } + if (this.historyLoaded.has(normalizedSessionId)) { + this.flushPendingOutput(normalizedSessionId); + return; + } + if (this.historyLoading.has(normalizedSessionId)) { + return; + } + + const targetTerminal = terminal || this.terminals.get(normalizedSessionId); + if (!targetTerminal) { + return; + } + + this.historyLoading.add(normalizedSessionId); + try { + const response = await fetch(`/api/sessions/${encodeURIComponent(normalizedSessionId)}/log?tailChars=20000`); + if (!response.ok) { + return; + } + + const payload = await response.json().catch(() => ({})); + const log = typeof payload?.log === 'string' ? payload.log : ''; + if (log) { + const normalizedLog = this.normalizeOutput(normalizedSessionId, log); + if (normalizedLog && !targetTerminal._core?.disposed) { + targetTerminal.write(normalizedLog); + if (this.orchestrator?.settings?.autoScroll !== false) { + targetTerminal.scrollToBottom(); + } + } + } + } catch (error) { + console.warn(`Failed to load terminal history for ${normalizedSessionId}`, error); + } finally { + this.historyLoading.delete(normalizedSessionId); + this.historyLoaded.add(normalizedSessionId); + this.flushPendingOutput(normalizedSessionId); + } + } showSearch(sessionId) { const searchAddon = this.searchAddons.get(sessionId); @@ -1382,8 +1482,15 @@ class TerminalManager { this.suggestTimers.delete(sessionId); // Clean up scroll state + const scrollState = this.terminalScrollStates.get(sessionId); + if (scrollState?.scrollTimer) { + clearTimeout(scrollState.scrollTimer); + } this.terminalScrollStates.delete(sessionId); this.userScrolling.delete(sessionId); + this.historyLoading.delete(sessionId); + this.historyLoaded.delete(sessionId); + this.pendingOutput.delete(sessionId); // Clean up addons this.fitAddons.delete(sessionId);