diff --git a/CLAUDE.md b/CLAUDE.md index 2f5b752a..f5c20fa0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1502 (must match `package.json` for npm publish) +**Version**: 0.1503 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index 4fd4fa65..dc836aab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1502", + "version": "0.1503", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/session.ts b/src/session.ts index cf78c6e9..52bf0745 100644 --- a/src/session.ts +++ b/src/session.ts @@ -754,14 +754,25 @@ export class Session extends EventEmitter { } toDetailedState() { + return { + ...this.toLightDetailedState(), + textOutput: this._textOutput.value, + terminalBuffer: this._terminalBuffer.value, + }; + } + + /** + * Lightweight detailed state that excludes heavy buffers (textOutput, terminalBuffer). + * Use for SSE session:updated broadcasts where buffers aren't needed. + * Full buffers are fetched on-demand via /api/sessions/:id/terminal. + */ + toLightDetailedState() { return { ...this.toState(), name: this._name, mode: this.mode, claudeSessionId: this._claudeSessionId, totalCost: this._totalCost, - textOutput: this._textOutput.value, - terminalBuffer: this._terminalBuffer.value, messageCount: this._messages.length, isWorking: this._isWorking, lastPromptTime: this._lastPromptTime, diff --git a/src/web/public/app.js b/src/web/public/app.js index 387b4efc..4cd5fda0 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1171,6 +1171,7 @@ class ClaudemanApp { this.teammatePanesByName = new Map(); // Map this.teammateTerminals = new Map(); // Map + this.terminalBufferCache = new Map(); // Map — client-side cache for instant tab re-visits (max 20) this.ralphStatePanelCollapsed = true; // Default to collapsed this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel @@ -1549,6 +1550,7 @@ class ClaudemanApp { // PTY/Ink handles all character echoing to avoid desync ("typing visible below" bug). this._pendingInput = ''; this._inputFlushTimeout = null; + this._lastKeystrokeTime = 0; const flushInput = () => { this._inputFlushTimeout = null; @@ -1580,10 +1582,23 @@ class ClaudemanApp { return; } - // Regular chars — flush on next microtask to coalesce rapid keystrokes - // (e.g. paste) while keeping latency near-zero for single keystrokes. - if (!this._inputFlushTimeout) { - this._inputFlushTimeout = setTimeout(flushInput, 0); + // Regular chars — flush immediately if typed after a gap (>50ms), + // otherwise batch via microtask to coalesce rapid keystrokes (paste). + const now = performance.now(); + if (now - this._lastKeystrokeTime > 50) { + // Single char after a gap — send immediately, no setTimeout latency + if (this._inputFlushTimeout) { + clearTimeout(this._inputFlushTimeout); + this._inputFlushTimeout = null; + } + this._lastKeystrokeTime = now; + flushInput(); + } else { + // Rapid sequence (paste or fast typing) — coalesce via microtask + this._lastKeystrokeTime = now; + if (!this._inputFlushTimeout) { + this._inputFlushTimeout = setTimeout(flushInput, 0); + } } } }); @@ -2973,22 +2988,31 @@ class ClaudemanApp { return; } - // Chain sends sequentially to preserve keystroke ordering - this._inputSendChain = this._inputSendChain.then(async () => { - try { - const resp = await fetch(`/api/sessions/${sessionId}/input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input }) - }); + // Chain on dispatch only — wait for the previous request to be sent before + // dispatching the next one (preserves keystroke ordering), but don't wait + // for the server's response. The server handles writeViaScreen as + // fire-and-forget anyway, so the HTTP response carries no useful data + // beyond success/failure for retry purposes. + this._inputSendChain = this._inputSendChain.then(() => { + const fetchPromise = fetch(`/api/sessions/${sessionId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input }), + keepalive: input.length < 65536, + }); + + // Handle response asynchronously — don't block next keystroke on response + fetchPromise.then(resp => { if (!resp.ok) { this._enqueueInput(sessionId, input); } else { this.clearPendingHooks(sessionId); } - } catch { + }).catch(() => { this._enqueueInput(sessionId, input); - } + }); + + // Return immediately after fetch is dispatched (don't await response) }); } @@ -3097,6 +3121,7 @@ class ClaudemanApp { this.sessions.clear(); this.ralphStates.clear(); this.terminalBuffers.clear(); + this.terminalBufferCache.clear(); this.projectInsights.clear(); // Clear all idle timers to prevent stale timers from firing for (const timer of this.idleTimers.values()) { @@ -3284,6 +3309,20 @@ class ClaudemanApp { }, 100); } + /** Toggle .active class on tabs immediately (no debounce). Used by selectSession(). */ + _updateActiveTabImmediate(sessionId) { + const container = this.$('sessionTabs'); + if (!container) return; + const tabs = container.querySelectorAll('.session-tab[data-id]'); + for (const tab of tabs) { + if (tab.dataset.id === sessionId) { + tab.classList.add('active'); + } else { + tab.classList.remove('active'); + } + } + } + _renderSessionTabsImmediate() { const container = this.$('sessionTabs'); const existingTabs = container.querySelectorAll('.session-tab[data-id]'); @@ -3831,6 +3870,8 @@ class ClaudemanApp { this.hideWelcome(); // Clear idle hooks on view, but keep action hooks until user interacts this.clearPendingHooks(sessionId, 'idle_prompt'); + // Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status + this._updateActiveTabImmediate(sessionId); this.renderSessionTabs(); // Glow the newly-active tab @@ -3857,9 +3898,19 @@ class ClaudemanApp { } // Load terminal buffer for this session - // Use tail mode for faster initial load (256KB is enough for recent visible content) + // Show cached content instantly while fetching fresh data in background. + // Use tail mode for faster initial load (256KB is enough for recent visible content). // Fetch runs in parallel with fade-out; we wait for both before clearing terminal. try { + // Instant cache restore — show previous buffer immediately while network fetch runs + const cachedBuffer = this.terminalBufferCache.get(sessionId); + if (cachedBuffer) { + this.terminal.clear(); + this.terminal.reset(); + this.terminal.write(cachedBuffer); + this.terminal.scrollToBottom(); + } + const tailSize = 256 * 1024; const [res] = await Promise.all([ fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`), @@ -3880,61 +3931,76 @@ class ClaudemanApp { if (selectGen !== this._selectGeneration) return; // stale — skip post-write UI updates // Ensure terminal is scrolled to bottom after buffer load this.terminal.scrollToBottom(); + + // Update cache (cap at 20 entries) + this.terminalBufferCache.set(sessionId, data.terminalBuffer); + if (this.terminalBufferCache.size > 20) { + // Evict oldest entry (first key in Map iteration order) + const oldest = this.terminalBufferCache.keys().next().value; + this.terminalBufferCache.delete(oldest); + } } // Send resize and Ctrl+L to trigger Claude to redraw at correct size await this.sendResize(sessionId); - // Update respawn banner - if (this.respawnStatus[sessionId]) { - this.showRespawnBanner(); - this.updateRespawnBanner(this.respawnStatus[sessionId].state); - document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0; - // Update countdown timers and action log for this session - this.updateCountdownTimerDisplay(); - this.updateActionLogDisplay(); - if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) { - this.startCountdownInterval(); + // Defer secondary panel updates so they don't block the main thread + // after terminal content is already visible. + const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16); + idleCb(() => { + // Guard against stale generation — user may have switched tabs again + if (selectGen !== this._selectGeneration) return; + + // Update respawn banner + if (this.respawnStatus[sessionId]) { + this.showRespawnBanner(); + this.updateRespawnBanner(this.respawnStatus[sessionId].state); + document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0; + this.updateCountdownTimerDisplay(); + this.updateActionLogDisplay(); + if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) { + this.startCountdownInterval(); + } + } else { + this.hideRespawnBanner(); + this.stopCountdownInterval(); } - } else { - this.hideRespawnBanner(); - this.stopCountdownInterval(); - } - // Update task panel if open - const taskPanel = document.getElementById('taskPanel'); - if (taskPanel && taskPanel.classList.contains('open')) { - this.renderTaskPanel(); - } - - // Update ralph state panel for this session - const session = this.sessions.get(sessionId); - if (session && (session.ralphLoop || session.ralphTodos)) { - this.updateRalphState(sessionId, { - loop: session.ralphLoop, - todos: session.ralphTodos - }); - } - this.renderRalphStatePanel(); - - // Update CLI info bar (mobile - shows Claude version/model) - this.updateCliInfoDisplay(); - - // Update project insights panel for this session - this.renderProjectInsightsPanel(); - - // Update subagent window visibility for active session - this.updateSubagentWindowVisibility(); - - // Load file browser if enabled - const settings = this.loadAppSettingsFromStorage(); - if (settings.showFileBrowser) { - const fileBrowserPanel = this.$('fileBrowserPanel'); - if (fileBrowserPanel) { - fileBrowserPanel.classList.add('visible'); - this.loadFileBrowser(sessionId); + // Update task panel if open + const taskPanel = document.getElementById('taskPanel'); + if (taskPanel && taskPanel.classList.contains('open')) { + this.renderTaskPanel(); } - } + + // Update ralph state panel for this session + const curSession = this.sessions.get(sessionId); + if (curSession && (curSession.ralphLoop || curSession.ralphTodos)) { + this.updateRalphState(sessionId, { + loop: curSession.ralphLoop, + todos: curSession.ralphTodos + }); + } + this.renderRalphStatePanel(); + + // Update CLI info bar (mobile - shows Claude version/model) + this.updateCliInfoDisplay(); + + // Update project insights panel for this session + this.renderProjectInsightsPanel(); + + // Update subagent window visibility for active session + this.updateSubagentWindowVisibility(); + + // Load file browser if enabled + const settings = this.loadAppSettingsFromStorage(); + if (settings.showFileBrowser) { + const fileBrowserPanel = this.$('fileBrowserPanel'); + if (fileBrowserPanel) { + fileBrowserPanel.classList.add('visible'); + this.loadFileBrowser(sessionId); + } + } + }); this.terminal.focus(); this.terminal.scrollToBottom(); @@ -3964,6 +4030,7 @@ class ClaudemanApp { this.saveSessionOrder(); } this.terminalBuffers.delete(sessionId); + this.terminalBufferCache.delete(sessionId); this.ralphStates.delete(sessionId); this.clearCountdownTimers(sessionId); @@ -7548,10 +7615,13 @@ class ClaudemanApp { } updateRespawnTokens(tokens) { - const tokensEl = this.$('respawnTokens'); - // Support both old format (number) and new format (object with input/output/total) + // Skip if tokens haven't changed (avoid unnecessary DOM writes) const isObject = tokens && typeof tokens === 'object'; const total = isObject ? tokens.total : tokens; + if (total === this._lastRespawnTokenTotal) return; + this._lastRespawnTokenTotal = total; + + const tokensEl = this.$('respawnTokens'); const input = isObject ? (tokens.input || 0) : Math.round(total * 0.6); const output = isObject ? (tokens.output || 0) : Math.round(total * 0.4); @@ -7801,6 +7871,7 @@ class ClaudemanApp { await fetch('/api/sessions', { method: 'DELETE' }); this.sessions.clear(); this.terminalBuffers.clear(); + this.terminalBufferCache.clear(); this.activeSessionId = null; try { localStorage.removeItem('claudeman-active-session'); } catch {} this.respawnStatus = {}; @@ -8029,6 +8100,18 @@ class ClaudemanApp { } updateTokens() { + // Debounce at 200ms — token display is non-critical and shouldn't + // compete with input handling on the main thread + if (this._updateTokensTimeout) { + clearTimeout(this._updateTokensTimeout); + } + this._updateTokensTimeout = setTimeout(() => { + this._updateTokensTimeout = null; + this._updateTokensImmediate(); + }, 200); + } + + _updateTokensImmediate() { // Use global stats if available (includes deleted sessions) let totalInput = 0; let totalOutput = 0; @@ -9363,20 +9446,27 @@ class ClaudemanApp { } loadAppSettingsFromStorage() { + // Return cached settings if available (avoids synchronous localStorage + JSON.parse + // on every SSE event — critical for input responsiveness) + if (this._cachedAppSettings) return this._cachedAppSettings; try { const key = this.getSettingsStorageKey(); const saved = localStorage.getItem(key); if (saved) { - return JSON.parse(saved); + this._cachedAppSettings = JSON.parse(saved); + return this._cachedAppSettings; } } catch (err) { console.error('Failed to load app settings:', err); } // Return device-specific defaults - return this.getDefaultSettings(); + this._cachedAppSettings = this.getDefaultSettings(); + return this._cachedAppSettings; } saveAppSettingsToStorage(settings) { + // Invalidate cache on save + this._cachedAppSettings = settings; try { const key = this.getSettingsStorageKey(); localStorage.setItem(key, JSON.stringify(settings)); @@ -11740,6 +11830,12 @@ class ClaudemanApp { const newName = this.getSessionName(session); + // Skip iteration if name hasn't changed (avoids O(n) loop on every session:updated) + const cachedName = this._parentNameCache?.get(sessionId); + if (cachedName === newName) return; + if (!this._parentNameCache) this._parentNameCache = new Map(); + this._parentNameCache.set(sessionId, newName); + for (const [agentId, storedSessionId] of this.subagentParentMap) { if (storedSessionId === sessionId) { const agent = this.subagents.get(agentId); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 39d70c1e..b7792596 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -131,6 +131,9 @@ body { flex-shrink: 0; position: relative; z-index: 100; /* Above main content so dropdowns work */ + /* Performance: isolate header from terminal reflow — tab badge updates + and token counter changes shouldn't trigger terminal layout recalculation */ + contain: layout style paint; } .header-brand { @@ -204,7 +207,7 @@ body { font-size: 0.75rem; cursor: pointer; white-space: nowrap; - transition: all 0.15s; + transition: background 0.05s, border-color 0.05s, color 0.05s, box-shadow 0.05s; } .session-tab:hover { @@ -228,7 +231,7 @@ body { /* Tab switch feedback: bright green glow on the newly-active tab */ .session-tab.tab-glow { - animation: tab-glow 1s ease-out forwards; + animation: tab-glow 0.35s ease-out forwards; } @keyframes tab-glow { @@ -288,7 +291,7 @@ body { cursor: default; border-radius: 3px; /* Performance: use transform for GPU acceleration */ - transition: opacity 0.15s ease-out, width 0.15s ease-out, padding 0.15s ease-out; + transition: opacity 0.05s ease-out, width 0.05s ease-out, padding 0.05s ease-out; } .session-tab:hover .tab-close { @@ -1674,6 +1677,10 @@ body { Do NOT use contain:strict — size containment breaks xterm.js FitAddon dimension calculations. */ contain: layout paint; + /* Performance: promote to own compositor layer for isolation from + header/toolbar/panel repaints. Eliminates compositing stalls + when surrounding UI elements update during typing. */ + will-change: contents; } /* Tab switch: green-tinted overlay fades in over old content, fades out to reveal new. Animated via Web Animations API (not CSS keyframes) for reliable GPU compositing. @@ -1812,6 +1819,8 @@ body { background: var(--bg-card); border-top: 1px solid var(--border); flex-shrink: 0; + /* Performance: isolate from terminal — toolbar updates shouldn't trigger terminal reflow */ + contain: layout style paint; } .toolbar-left, diff --git a/src/web/server.ts b/src/web/server.ts index ee86df87..0e5827df 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -4314,7 +4314,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; private getSessionStateWithRespawn(session: Session) { const controller = this.respawnControllers.get(session.id); return { - ...session.toDetailedState(), + ...session.toLightDetailedState(), respawnEnabled: controller?.getConfig()?.enabled ?? false, respawnConfig: controller?.getConfig() ?? null, respawn: controller?.getStatus() ?? null, @@ -4327,15 +4327,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; * on-demand when switching tabs via /api/sessions/:id/buffer */ private getLightSessionsState() { - return Array.from(this.sessions.values()).map(s => { - const state = this.getSessionStateWithRespawn(s); - return { - ...state, - // Exclude full buffers - they're fetched on-demand - terminalBuffer: '', - textOutput: '', - }; - }); + // getSessionStateWithRespawn already uses toLightDetailedState() which + // excludes terminalBuffer and textOutput — no extra stripping needed + return Array.from(this.sessions.values()).map(s => this.getSessionStateWithRespawn(s)); } // Clean up old completed scheduled runs