diff --git a/frontend/src/components/shared/terminal/terminal.test.tsx b/frontend/src/components/shared/terminal/terminal.test.tsx
new file mode 100644
index 00000000..7d139455
--- /dev/null
+++ b/frontend/src/components/shared/terminal/terminal.test.tsx
@@ -0,0 +1,86 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+const xterm = vi.hoisted(() => {
+ const state = {
+ clear() {
+ state.clearCount += 1;
+ state.visible = '';
+ },
+ clearCount: 0,
+ scrollToBottom() {},
+ visible: '',
+ write(chunk: string) {
+ state.visible += chunk;
+ },
+ };
+
+ return state;
+});
+
+vi.mock('./use-xterm', () => ({
+ useXterm: () => ({
+ clear: xterm.clear,
+ containerRef: { current: null },
+ isReady: true,
+ scrollToBottom: xterm.scrollToBottom,
+ searchAddon: null,
+ write: xterm.write,
+ }),
+}));
+
+vi.mock('./use-terminal-search', () => ({
+ useTerminalSearch: () => ({ findNext: () => {}, findPrevious: () => {} }),
+}));
+
+vi.mock('@/hooks/use-theme', () => ({ useTheme: () => ({ theme: 'dark' }) }));
+
+vi.mock('./terminal-sanitizer', () => ({ processLog: (line: string) => line }));
+
+import Terminal from './terminal';
+
+const visibleLines = () => xterm.visible.split('\r\n').filter(Boolean);
+
+const reset = () => {
+ xterm.visible = '';
+ xterm.clearCount = 0;
+};
+
+describe('Terminal incremental writer', () => {
+ it('restores the full log set after a filter narrows then clears', () => {
+ reset();
+ const full = ['alpha-0', 'bravo-1', 'alpha-2', 'bravo-3', 'alpha-4'];
+ const filtered = ['bravo-1', 'bravo-3'];
+
+ const { rerender } = render();
+ expect(visibleLines()).toEqual(full);
+
+ rerender();
+ expect(visibleLines()).toEqual(filtered);
+
+ rerender();
+ expect(visibleLines()).toEqual(full);
+ });
+
+ it('appends streamed lines incrementally without clearing', () => {
+ reset();
+ const { rerender } = render();
+
+ rerender();
+ rerender();
+
+ expect(visibleLines()).toEqual(['line-0', 'line-1', 'line-2']);
+ expect(xterm.clearCount).toBe(0);
+ });
+
+ it('rewrites when the filter changes to a different, non-prefix set', () => {
+ reset();
+ const full = ['keep-0', 'drop-1', 'keep-2', 'drop-3'];
+
+ const { rerender } = render();
+ rerender();
+ rerender();
+
+ expect(visibleLines()).toEqual(['drop-1', 'drop-3']);
+ });
+});
diff --git a/frontend/src/components/shared/terminal/terminal.tsx b/frontend/src/components/shared/terminal/terminal.tsx
index 5ec710f4..c7c10d64 100644
--- a/frontend/src/components/shared/terminal/terminal.tsx
+++ b/frontend/src/components/shared/terminal/terminal.tsx
@@ -28,8 +28,7 @@ function Terminal({
const { clear, containerRef, isReady, scrollToBottom, searchAddon, write } = useXterm({ theme });
const { findNext, findPrevious } = useTerminalSearch(searchAddon, isReady, searchValue, theme);
- const lastLogIndexRef = useRef(0);
- const prevLogsLengthRef = useRef(0);
+ const renderedLogsRef = useRef([]);
useImperativeHandle(ref, () => ({ findNext, findPrevious }), [findNext, findPrevious]);
@@ -38,20 +37,25 @@ function Terminal({
return;
}
- if (logs.length === 0 && prevLogsLengthRef.current > 0) {
- clear();
- lastLogIndexRef.current = 0;
- prevLogsLengthRef.current = 0;
-
- return;
- }
+ const rendered = renderedLogsRef.current;
if (logs.length === 0) {
+ if (rendered.length > 0) {
+ clear();
+ renderedLogsRef.current = [];
+ }
+
return;
}
- if (logs.length >= lastLogIndexRef.current) {
- const newLogs = logs.slice(lastLogIndexRef.current);
+ // Append the tail only when the on-screen lines are an exact prefix of
+ // the new array. `logs` is also fed filtered subsets (flow-terminal), so
+ // a length-only check reappends the tail over stale lines when it shrinks
+ // then grows back.
+ const isAppend = logs.length >= rendered.length && rendered.every((line, index) => line === logs[index]);
+
+ if (isAppend) {
+ const newLogs = logs.slice(rendered.length);
if (newLogs.length > 0) {
const batch = newLogs.filter(Boolean).map(processLog).join('\r\n');
@@ -73,8 +77,7 @@ function Terminal({
scrollToBottom();
}
- lastLogIndexRef.current = logs.length;
- prevLogsLengthRef.current = logs.length;
+ renderedLogsRef.current = logs;
}, [logs, isReady, write, clear, scrollToBottom]);
return (