Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 53 additions & 11 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,11 @@ class CodemanApp {
this._loadBufferQueue = null; // queued SSE events during buffer load
this._bufferLoadSeq = 0;
this._bufferLoadOwner = null;
// Coalesce repeated needsRefresh signals so full-history fetches cannot
// overlap their destructive clear-and-replay phase. The generation also
// invalidates an older activation of the same session after A -> B -> A.
this._terminalRefreshSessionId = null;
this._terminalRefreshGeneration = 0;

// Flicker filter state (buffers output after screen clears)
this.flickerFilterBuffer = '';
Expand Down Expand Up @@ -1859,7 +1864,11 @@ class CodemanApp {
this._onSessionTerminal(data);
}
_onSSENeedsRefresh(data) {
if (this._wsReady && this._wsSessionId === data?.id) return;
// Session-scoped refreshes for background tabs must not repaint the active
// terminal. Anonymous refreshes come from SSE backpressure recovery; when
// the active WS is complete, that duplicate transport has nothing to repair.
if (data?.id && data.id !== this.activeSessionId) return;
if (this._wsReady && this._wsSessionId === this.activeSessionId) return;
this._onSessionNeedsRefresh(data);
}
_onSSEClearTerminal(data) {
Expand Down Expand Up @@ -2354,13 +2363,21 @@ class CodemanApp {
}
}

async _onSessionNeedsRefresh() {
async _onSessionNeedsRefresh(data = {}) {
// Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption.
if (!this.activeSessionId || !this.terminal) return;
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
if (this._isLoadingBuffer) return;
const sessionId = this.activeSessionId;
const sessionId = data?.id || this.activeSessionId;
if (!sessionId || sessionId !== this.activeSessionId || !this.terminal) return;
Comment thread
tailong-wu marked this conversation as resolved.
// Skip if another buffer load or refresh is already in progress. Without a
// refresh-specific guard, repeated SSE drain signals can overlap multiple
// fetch -> clear -> replay cycles and visibly loop through old history.
if (this._isLoadingBuffer || this._terminalRefreshSessionId === sessionId) return;
const refreshGeneration = ++this._terminalRefreshGeneration;
this._terminalRefreshSessionId = sessionId;
const isCurrentRefresh = () =>
this._terminalRefreshGeneration === refreshGeneration &&
this._terminalRefreshSessionId === sessionId &&
this.activeSessionId === sessionId;
try {
// Recovery should restore the WHOLE picture, so ask for full history
// rather than a tail. Measured on a 900-line shell pane: the tail rewrite
Expand All @@ -2372,15 +2389,18 @@ class CodemanApp {
// the same downgrade guard as the scroll-to-top re-pull and fall back to
// the historical tail there, leaving that case exactly as it was.
let res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
if (!isCurrentRefresh()) return;
let data = (await res.json())?.data ?? {};
if (!isCurrentRefresh()) return;
if (data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
if (!isCurrentRefresh()) return;
data = (await res.json())?.data ?? {};
if (!isCurrentRefresh()) return;
}
// Bail on a tab switch mid-fetch: writing here would paint this session's
// history into the terminal the user is now looking at. The window is two
// fetches wide in the fallback case, so this guard is not optional.
if (this.activeSessionId !== sessionId) return;
// Bail on a tab switch (including A -> B -> A) mid-fetch: writing here
// would paint an earlier activation's history into the current terminal.
if (!isCurrentRefresh()) return;
if (data.terminalBuffer) {
// This refresh is SERVER-triggered, so a user quietly reading scrollback
// did not ask for it and must not be dragged to the bottom by it (#259).
Expand All @@ -2391,6 +2411,7 @@ class CodemanApp {
this.terminal.clear();
this.terminal.reset();
await this.chunkedTerminalWrite(data.terminalBuffer);
if (!isCurrentRefresh()) return;
// A tail fetch can be partial, and the banner would otherwise keep
// describing the pre-refresh buffer (#258).
this._setHistoryTruncation(sessionId, data);
Expand All @@ -2410,6 +2431,15 @@ class CodemanApp {
}
} catch (err) {
console.error('needsRefresh reload failed:', err);
} finally {
// The generation check prevents an A -> B -> A stale request from
// releasing the newer A activation's lock.
if (
this._terminalRefreshGeneration === refreshGeneration &&
this._terminalRefreshSessionId === sessionId
) {
this._terminalRefreshSessionId = null;
}
}
}

Expand Down Expand Up @@ -2703,6 +2733,7 @@ class CodemanApp {
ws.onopen = () => {
// Only mark ready if this is still the intended session
if (this._ws === ws) {
const recoveredFromGap = (this._wsReconnectAttempts || 0) > 0;
this._wsReady = true;
this._wsState = 'connected';
this._wsReconnectAttempts = 0;
Expand All @@ -2716,6 +2747,10 @@ class CodemanApp {
// Flush any durably-queued input over the fresh socket (covers frames a
// prior half-open socket silently dropped, and input typed while offline).
this._onWsReady(sessionId);
// SSE is the output fallback while WS reconnects. Its anonymous drain
// frame may arrive after onopen, when the normal SSE wrapper ignores it;
// recover the gap explicitly once so dropped fallback bytes are restored.
if (recoveredFromGap) this._onSessionNeedsRefresh({ id: sessionId });
}
};

Expand Down Expand Up @@ -3468,6 +3503,8 @@ class CodemanApp {
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
this._terminalRefreshGeneration = (this._terminalRefreshGeneration || 0) + 1;
this._terminalRefreshSessionId = null;
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
// Preserve local echo overlay text across SSE reconnect — just hide until
Expand Down Expand Up @@ -5259,8 +5296,11 @@ class CodemanApp {
}
}

// Close WebSocket for previous session (new one opens after buffer load)
// Close WebSocket for previous session (new one opens after buffer load).
// Reconnect attempts belong to that session; the next tab's first socket is
// a fresh connection and must not run the fallback-gap recovery path.
this._disconnectWs();
this._wsReconnectAttempts = 0;

// Clear CJK input to prevent sending stale text to the wrong session.
// Must go through CjkInput.clear() — a raw value wipe leaves the module's
Expand Down Expand Up @@ -5291,6 +5331,8 @@ class CodemanApp {
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
this._terminalRefreshGeneration = (this._terminalRefreshGeneration || 0) + 1;
this._terminalRefreshSessionId = null;
// Abort any in-flight chunkedTerminalWrite from the previous session.
// Without this, old rAF-scheduled chunks continue writing stale data
// into the terminal, interleaving with the new session's buffer.
Expand Down
53 changes: 50 additions & 3 deletions test/opencode-resize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,11 @@ describe('OpenCode session initial resize', () => {
await route.continue();
});

// Dispatch the needsRefresh event directly on the EventSource
// (this is how the server sends SSE events — as named events)
// Exercise the SSE fallback explicitly. With an active WebSocket this same
// frame is a duplicate and must be ignored by the routing guard.
await page.evaluate((sid: string) => {
const app = (window as unknown as { app: { eventSource: EventSource } }).app;
const app = (window as unknown as { app: { eventSource: EventSource; _wsReady: boolean } }).app;
app._wsReady = false;
if (app.eventSource) {
const event = new MessageEvent('session:needsRefresh', {
data: JSON.stringify({ id: sid }),
Expand All @@ -250,6 +251,52 @@ describe('OpenCode session initial resize', () => {
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
}, sessionId);
});

it('does not replay history for duplicate or background SSE refreshes while WS is active', async () => {
({ context, page } = await freshPage());
await navigateAndWait(page);

const sessionId = await page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', name: 'refresh-routing-test' }),
});
const body = await res.json();
const data = body.data ?? body;
return data.id ?? data.session?.id;
});

await page.evaluate(async (sid: string) => {
const app = (window as unknown as { app: { selectSession: (id: string) => Promise<void> } }).app;
await app.selectSession(sid);
}, sessionId);
await page.waitForFunction(() => (window as unknown as { app: { _wsReady: boolean } }).app._wsReady, undefined, {
timeout: 5000,
});

const terminalRequests: string[] = [];
await page.route('**/api/sessions/*/terminal*', async (route) => {
terminalRequests.push(route.request().url());
await route.continue();
});

await page.evaluate(() => {
const app = (window as unknown as { app: { eventSource: EventSource } }).app;
app.eventSource.dispatchEvent(
new MessageEvent('session:needsRefresh', { data: JSON.stringify({ id: 'background-session' }) })
);
app.eventSource.dispatchEvent(new MessageEvent('session:needsRefresh', { data: '{}' }));
});
await page.waitForTimeout(300);

expect(terminalRequests).toEqual([]);

await page.unroute('**/api/sessions/*/terminal*');
await page.evaluate(async (sid: string) => {
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
}, sessionId);
});
});

describe('OpenCode close modal text', () => {
Expand Down
Loading