fix: stop terminal open/switch from rewriting state per output chunk (#2536)

Opening a terminal rebuilt the WASM terminal twice and rewrote the
persisted session snapshot on every streamed output chunk, so the cost
grew with each open terminal and could crash under load.

- Move PTY scrollback to a standalone buffers map keyed by directory and
  tab id; output no longer touches sessions, so the tab strip, the
  project-action monitor and the persist projection stay referentially
  stable while chunks stream.
- Memoize partialize and add a dedup storage adapter that skips writes
  when the persisted projection is unchanged.
- Reuse the idle terminal WebSocket across tab switches (15s grace)
  instead of re-authenticating and replaying the snapshot on every attach.
- Key the viewport by directory + tab only so createSession no longer
  tears down and rebuilds the Ghostty terminal.
- Reset the terminal in place on replay discontinuities instead of
  bumping the renderer generation.
- Scan the chunk array from the end (O(1) per write) instead of findIndex.

Adds regression tests for the buffer map, socket reuse and viewport key,
and documents the store invariants.
This commit is contained in:
Serhii Dziupin
2026-07-30 13:48:24 +03:00
committed by GitHub
parent 0d6ecbfc12
commit f1e52b0cbc
9 changed files with 460 additions and 102 deletions
@@ -308,8 +308,9 @@ export const ProjectActionsButton = ({
React.useEffect(() => {
const monitorRuns = () => {
const terminalSessions = useTerminalStore.getState().sessions;
const currentRuns = useTerminalStore.getState().projectActionRuns;
const terminalStore = useTerminalStore.getState();
const terminalSessions = terminalStore.sessions;
const currentRuns = terminalStore.projectActionRuns;
for (const [runKey, entry] of Object.entries(currentRuns)) {
const directoryState = terminalSessions.get(entry.directory);
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
@@ -321,9 +322,10 @@ export const ProjectActionsButton = ({
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false };
urlWatchByRunKeyRef.current[runKey] = watch;
const action = displayActions.find((item) => item.id === entry.actionId);
if (!action || !Array.isArray(tab.bufferChunks) || tab.bufferChunks.length === 0) continue;
const bufferChunks = terminalStore.getBuffer(entry.directory, entry.tabId).chunks;
if (!action || bufferChunks.length === 0) continue;
const nextChunks = tab.bufferChunks.filter((chunk) => watch.lastSeenChunkId === null || chunk.id > watch.lastSeenChunkId);
const nextChunks = bufferChunks.filter((chunk) => watch.lastSeenChunkId === null || chunk.id > watch.lastSeenChunkId);
if (nextChunks.length === 0) continue;
const combined = nextChunks.map((chunk) => chunk.data).join('');
@@ -364,7 +366,7 @@ export const ProjectActionsButton = ({
monitorRuns();
return useTerminalStore.subscribe((state, previousState) => {
if (state.sessions !== previousState.sessions) monitorRuns();
if (state.sessions !== previousState.sessions || state.buffers !== previousState.buffers) monitorRuns();
});
}, [displayActions, openContextPreview, openExternal, projectActionRuns, removeProjectActionRun, setTabPreviewUrl, t, updateProjectActionRunStatus]);