From 0d80524f10a17e5b6c8edb0a774f20c4613938d2 Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 23 Mar 2026 12:32:33 +0100 Subject: [PATCH] fix: prevent duplicate terminal output on tab switch to busy sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the tab-switching corruption bug: 1. _finishBufferLoad() now discards queued SSE events instead of flushing them. The loaded API buffer is the source of truth — queued events overlap with it, and flushing them writes duplicate Ink cursor-up redraws that corrupt the terminal display (garbled text, wrong cursor positions). 2. Skip stale cache write for busy sessions. When a session is actively working, the cache is always outdated — writing it first and then rewriting with the fresh API buffer caused a jarring double-render flash. Now busy sessions get a single clean clear+write transition. Co-Authored-By: Claude Opus 4.6 --- src/web/public/app.js | 22 +++++++++++++++------- src/web/public/terminal-ui.js | 14 +++++++------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 39bf4a1b..6bc54847 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2153,11 +2153,14 @@ class CodemanApp { // canvas may be at stale dimensions — content would render at wrong width. if (this.fitAddon) this.fitAddon.fit(); - // Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls. - // Direct terminal.write() of large cached buffers (256KB+) can block the main thread - // for 5+ seconds while the WebGL renderer processes ReadPixels synchronously. + // Instant cache restore for IDLE sessions only. + // For busy sessions, the cache is always stale — writing it first causes a + // jarring double-render: stale content appears, then the terminal flashes + // blank and rewrites with fresh data. Skip the cache and write the fresh + // buffer once for a single clean transition. const cachedBuffer = this.terminalBufferCache.get(sessionId); - if (cachedBuffer) { + const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working'); + if (cachedBuffer && !sessionIsBusy) { _crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`); this.terminal.clear(); this.terminal.reset(); @@ -2165,6 +2168,11 @@ class CodemanApp { if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; } this.terminal.scrollToBottom(); _crashDiag.log('CACHE_DONE'); + } else if (sessionIsBusy) { + // Clear stale content immediately — fresh buffer is being fetched + this.terminal.clear(); + this.terminal.reset(); + _crashDiag.log('CACHE_SKIP_BUSY'); } _crashDiag.log('FETCH_START'); @@ -2206,9 +2214,9 @@ class CodemanApp { this.terminal.reset(); } - // Buffer load complete — unblock live SSE writes and flush any queued events. - // chunkedTerminalWrite calls _finishBufferLoad internally, but if we skipped - // the chunked write (small buffer, cache hit, or empty), we must call it here. + // Buffer load complete — unblock live SSE writes (queued events are discarded + // to prevent duplicate content). chunkedTerminalWrite calls _finishBufferLoad + // internally, but if we skipped the write (cache hit or empty), call it here. if (this._isLoadingBuffer) { this._finishBufferLoad(); } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index df00b1b4..5c9b1a3b 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1109,18 +1109,18 @@ Object.assign(CodemanApp.prototype, { }, /** - * Complete a buffer load: unblock live SSE writes and flush any queued events. + * Complete a buffer load: unblock live SSE writes. * Called when chunkedTerminalWrite finishes (or is skipped for empty buffers). + * + * Queued SSE events are DISCARDED, not flushed. The loaded buffer from the API + * is the source of truth up to the response timestamp. SSE events queued during + * the fetch+write overlap with the buffer — flushing them writes duplicate data + * (especially Ink cursor-up redraws), corrupting the terminal display. + * After unblocking, new SSE/WS events deliver subsequent output normally. */ _finishBufferLoad() { - const queue = this._loadBufferQueue; this._isLoadingBuffer = false; this._loadBufferQueue = null; - if (queue && queue.length > 0) { - for (const data of queue) { - this.batchTerminalWrite(data); - } - } },