mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-26 13:06:32 +00:00
fix(terminal): stop garbling logs after a search filter is cleared
The terminal wrote incrementally by diffing on array length alone: if the new logs were at least as long as what had been written, it appended the tail from the old length onward. That assumes the on-screen prefix never changes — but the same component is fed filtered subsets, so narrowing a search then clearing it grew the array back past the filtered length and appended the full-log tail on top of the still-visible filtered lines, producing a scrambled, partly-duplicated buffer. Track the exact lines last rendered and only take the append fast-path when they are a true prefix of the new array; otherwise clear and rewrite. Pure streaming stays incremental (no clear); any non-prefix change rewrites. Verified with a repro test (red before, green after): full -> filtered -> cleared restores the full set, streaming appends never clear, and a different filter rewrites. 996 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cf68f3a675
commit
140578fd7c
@@ -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(<Terminal logs={full} />);
|
||||
expect(visibleLines()).toEqual(full);
|
||||
|
||||
rerender(<Terminal logs={filtered} />);
|
||||
expect(visibleLines()).toEqual(filtered);
|
||||
|
||||
rerender(<Terminal logs={full} />);
|
||||
expect(visibleLines()).toEqual(full);
|
||||
});
|
||||
|
||||
it('appends streamed lines incrementally without clearing', () => {
|
||||
reset();
|
||||
const { rerender } = render(<Terminal logs={['line-0']} />);
|
||||
|
||||
rerender(<Terminal logs={['line-0', 'line-1']} />);
|
||||
rerender(<Terminal logs={['line-0', 'line-1', 'line-2']} />);
|
||||
|
||||
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(<Terminal logs={full} />);
|
||||
rerender(<Terminal logs={['keep-0', 'keep-2']} />);
|
||||
rerender(<Terminal logs={['drop-1', 'drop-3']} />);
|
||||
|
||||
expect(visibleLines()).toEqual(['drop-1', 'drop-3']);
|
||||
});
|
||||
});
|
||||
@@ -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<string[]>([]);
|
||||
|
||||
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 (
|
||||
|
||||
Reference in New Issue
Block a user