diff --git a/src/web/public/app.js b/src/web/public/app.js index eaaac438..ac553127 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2150,9 +2150,9 @@ class CodemanApp { this.renderSubagentDetail(); } this.renderSubagentPanel(); - // Update floating window + // Update floating window (debounced — tool_call events fire rapidly) if (this.subagentWindows.has(data.agentId)) { - this.renderSubagentWindowContent(data.agentId); + this.scheduleSubagentWindowRender(data.agentId); } }); @@ -2165,9 +2165,9 @@ class CodemanApp { if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); } - // Update floating window + // Update floating window (debounced) if (this.subagentWindows.has(data.agentId)) { - this.renderSubagentWindowContent(data.agentId); + this.scheduleSubagentWindowRender(data.agentId); } }); @@ -2180,9 +2180,9 @@ class CodemanApp { if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); } - // Update floating window + // Update floating window (debounced) if (this.subagentWindows.has(data.agentId)) { - this.renderSubagentWindowContent(data.agentId); + this.scheduleSubagentWindowRender(data.agentId); } }); @@ -2208,9 +2208,9 @@ class CodemanApp { if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); } - // Update floating window + // Update floating window (debounced) if (this.subagentWindows.has(data.agentId)) { - this.renderSubagentWindowContent(data.agentId); + this.scheduleSubagentWindowRender(data.agentId); } }); @@ -6054,6 +6054,24 @@ class CodemanApp { display.textContent = ''; display.onclick = null; } + // Upload URL row + const uploadRow = document.getElementById('tunnelUploadUrlRow'); + const uploadDisplay = document.getElementById('tunnelUploadUrlDisplay'); + if (!uploadRow || !uploadDisplay) return; + if (url) { + const uploadUrl = url + '/upload.html'; + uploadRow.style.display = ''; + uploadDisplay.textContent = uploadUrl; + uploadDisplay.onclick = () => { + navigator.clipboard.writeText(uploadUrl).then(() => { + this.showToast('Upload URL copied', 'success'); + }); + }; + } else { + uploadRow.style.display = 'none'; + uploadDisplay.textContent = ''; + uploadDisplay.onclick = null; + } } showTunnelQR() { @@ -9213,6 +9231,19 @@ class CodemanApp { } + // Debounced wrapper — coalesces rapid subagent events (tool_call, progress, + // message) into a single DOM update per 100ms per agent window. + scheduleSubagentWindowRender(agentId) { + if (!this._subagentWindowRenderTimeouts) this._subagentWindowRenderTimeouts = new Map(); + if (this._subagentWindowRenderTimeouts.has(agentId)) { + clearTimeout(this._subagentWindowRenderTimeouts.get(agentId)); + } + this._subagentWindowRenderTimeouts.set(agentId, setTimeout(() => { + this._subagentWindowRenderTimeouts.delete(agentId); + scheduleBackground(() => this.renderSubagentWindowContent(agentId)); + }, 100)); + } + renderSubagentWindowContent(agentId) { // Skip if this window has a live terminal (don't overwrite xterm with activity HTML) if (this.teammateTerminals.has(agentId)) return; diff --git a/src/web/server.ts b/src/web/server.ts index aee347ab..9b618640 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -583,10 +583,12 @@ export class WebServer extends EventEmitter { this.sendSSE(reply, 'init', this.getLightState()); // Flush Cloudflare tunnel buffer with padding — ensures the init event // (and any immediately following events) are delivered without proxy delay. - try { - reply.raw.write(SSE_PADDING); - } catch { - /* client gone */ + if (this.tunnelManager.getUrl()) { + try { + reply.raw.write(SSE_PADDING); + } catch { + /* client gone */ + } } req.raw.on('close', () => { @@ -1870,11 +1872,12 @@ export class WebServer extends EventEmitter { this.cachedSessionsList = null; } // Performance optimization: serialize JSON once for all clients. - // Append padding to flush Cloudflare tunnel buffers for all non-terminal events. - // Terminal data is high-volume and already exceeds buffer thresholds naturally. + // Only append Cloudflare tunnel padding when tunnel is actually active — + // direct/Tailscale clients don't need 8KB padding on every event. + const padding = this.tunnelManager.getUrl() ? SSE_PADDING : ''; let message: string; try { - message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + SSE_PADDING; + message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + padding; } catch (err) { // Handle circular references or non-serializable values console.error(`[Server] Failed to serialize SSE event "${event}":`, err); @@ -2144,9 +2147,11 @@ export class WebServer extends EventEmitter { if (!socket || socket.destroyed || !socket.writable) { deadClients.push(client); } else { - // Send padded SSE comment as keep-alive — the padding flushes - // Cloudflare tunnel buffers so subsequent events arrive promptly. - client.raw.write(':keepalive\n' + SSE_PADDING); + // Send SSE comment as keep-alive. Only add padding when tunnel is + // active — it flushes Cloudflare proxy buffers but wastes bandwidth + // for direct/Tailscale connections. + const ka = this.tunnelManager.getUrl() ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n'; + client.raw.write(ka); } } catch { // Error accessing socket means client is dead