Fix TerminalView: URL detection, event listener leak, resize throttle

- Fix broken URL accumulator by using TextDecoder instead of raw
  Uint8Array concatenation that produced numeric strings
- Fix event listener memory leak by using aborted flag pattern to
  ensure cleanup runs even if listen() promises haven't resolved
- Throttle ResizeObserver with requestAnimationFrame to prevent
  hammering the backend during window resize

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 20:42:13 +00:00
parent 82c487184a
commit a03bdccdc7

View File

@@ -97,6 +97,7 @@ export default function TerminalView({ sessionId, active }: Props) {
// Fix: buffer recent output, strip ANSI codes, and after a short // Fix: buffer recent output, strip ANSI codes, and after a short
// debounce check for a URL that spans multiple lines. When found, // debounce check for a URL that spans multiple lines. When found,
// write a single clean clickable copy to the terminal. // write a single clean clickable copy to the terminal.
const textDecoder = new TextDecoder();
let outputBuffer = ""; let outputBuffer = "";
let debounceTimer: ReturnType<typeof setTimeout> | null = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -117,14 +118,14 @@ export default function TerminalView({ sessionId, active }: Props) {
}; };
// Handle backend output -> terminal // Handle backend output -> terminal
let unlistenOutput: (() => void) | null = null; let aborted = false;
let unlistenExit: (() => void) | null = null;
onOutput(sessionId, (data) => { const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return;
term.write(data); term.write(data);
// Accumulate for URL detection // Accumulate for URL detection (data is a Uint8Array, so decode it)
outputBuffer += data; outputBuffer += textDecoder.decode(data);
// Cap buffer size to avoid memory growth // Cap buffer size to avoid memory growth
if (outputBuffer.length > 8192) { if (outputBuffer.length > 8192) {
outputBuffer = outputBuffer.slice(-4096); outputBuffer = outputBuffer.slice(-4096);
@@ -132,27 +133,37 @@ export default function TerminalView({ sessionId, active }: Props) {
if (debounceTimer) clearTimeout(debounceTimer); if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(flushUrlBuffer, 150); debounceTimer = setTimeout(flushUrlBuffer, 150);
}).then((unlisten) => { }).then((unlisten) => {
unlistenOutput = unlisten; if (aborted) unlisten();
return unlisten;
}); });
onExit(sessionId, () => { const exitPromise = onExit(sessionId, () => {
if (aborted) return;
term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n"); term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n");
}).then((unlisten) => { }).then((unlisten) => {
unlistenExit = unlisten; if (aborted) unlisten();
return unlisten;
}); });
// Handle resize // Handle resize (throttled via requestAnimationFrame to avoid excessive calls)
let resizeRafId: number | null = null;
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
if (resizeRafId !== null) return;
resizeRafId = requestAnimationFrame(() => {
resizeRafId = null;
fitAddon.fit(); fitAddon.fit();
resize(sessionId, term.cols, term.rows); resize(sessionId, term.cols, term.rows);
}); });
});
resizeObserver.observe(containerRef.current); resizeObserver.observe(containerRef.current);
return () => { return () => {
aborted = true;
if (debounceTimer) clearTimeout(debounceTimer); if (debounceTimer) clearTimeout(debounceTimer);
inputDisposable.dispose(); inputDisposable.dispose();
unlistenOutput?.(); outputPromise.then((fn) => fn?.());
unlistenExit?.(); exitPromise.then((fn) => fn?.());
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect(); resizeObserver.disconnect();
term.dispose(); term.dispose();
}; };