Skip to content

Commit 708f8da

Browse files
mrkaye97abelanger5Copilot
authored
Feat: Log Search Frontend, Part I (#2830)
* feat: initial work on new logs component * feat: improve autocomplete, refactor a bit * refactor: remove some unused stuff * refactor: more cleanup * fix: on click handler * refactor: remove duped data, do some cleanup * chore: simplify a bunch * fix: pass level through * chore: cleanup * fix: tsc * chore: remove a bunch more stuff * chore: comments * fix: revert existing log component, add new filterable one * refactor: create use logs hook, simplify step-run-logs.tsx * chore: rename * chore: rename * chore: fix tsc * feat: add a flag to enable the new experience * chore: lint * feat: provider * chore: lint * fix: cursor jumping * feat: try to use ghostty-web for rendering log lines instead of ansiToHtml (#2831) * feat: try to use ghostty-web for rendering log lines instead of ansiToHtml * feat: light theme and a bunch of perf improvements * integrate log viewer with terminal viewer * fix: lint * pr review comments * fix: start getting the log viewer scrolling properly again * fix: more scroll improvements * feat: replace ghostty * chore: cleanup * fix: dynamic height and width * chore: lint * chore: format css * chore: remove unused ref and styles * chore: cleanup * fix: terminal styling * fix: scroll behavior / polling behavior while tasks are running still * fix: jump to top on search change * fix: improve loading / empty states * chore: lint * fix: status * fix: enter to submit search * fix: remove color var to fix ansi highlighting * feat: expand / collapse on click * chore: rm unused code * Add edge detection for scroll callbacks to prevent repeated API calls (#2844) * Initial plan * Add edge detection for scroll callbacks to prevent spam Co-authored-by: mrkaye97 <[email protected]> * Add clarifying comments about region state reset behavior Co-authored-by: mrkaye97 <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: mrkaye97 <[email protected]> * Fix polling loop to prevent overlapping requests in log search (#2845) * Initial plan * Fix polling loop to prevent overlapping requests using setTimeout chaining and in-flight guard Co-authored-by: mrkaye97 <[email protected]> * Use useRef hooks for polling state and timeout to persist across renders Co-authored-by: mrkaye97 <[email protected]> * Add proper cleanup to clear timeout and reset polling state Co-authored-by: mrkaye97 <[email protected]> * Add clarifying comment about polling state cleanup Co-authored-by: mrkaye97 <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: mrkaye97 <[email protected]> * chore: lint * fix: dedupe log lines * fix: remove duped component * fix: pass order by direction --------- Co-authored-by: abelanger5 <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent e6bfb09 commit 708f8da

File tree

17 files changed

+1185
-561
lines changed

17 files changed

+1185
-561
lines changed

frontend/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@heroicons/react": "^2.2.0",
2626
"@hookform/resolvers": "^3.10.0",
2727
"@lukemorales/query-key-factory": "^1.3.4",
28+
"@melloware/react-logviewer": "^6.3.5",
2829
"@monaco-editor/react": "^4.7.0",
2930
"@radix-ui/react-accordion": "^1.2.3",
3031
"@radix-ui/react-avatar": "^1.1.3",

frontend/app/pnpm-lock.yaml

Lines changed: 69 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { cn } from '@/lib/utils';
2+
import { LazyLog, ScrollFollow } from '@melloware/react-logviewer';
3+
import { useCallback, useRef } from 'react';
4+
5+
interface TerminalProps {
6+
logs: string;
7+
autoScroll?: boolean;
8+
onScrollToTop?: () => void;
9+
onScrollToBottom?: () => void;
10+
onAtTopChange?: (atTop: boolean) => void;
11+
className?: string;
12+
}
13+
14+
function Terminal({
15+
logs,
16+
autoScroll = false,
17+
onScrollToTop,
18+
onScrollToBottom,
19+
onAtTopChange,
20+
className,
21+
}: TerminalProps) {
22+
const lastScrollTopRef = useRef(0);
23+
const wasAtTopRef = useRef(true);
24+
const wasInTopRegionRef = useRef(false);
25+
const wasInBottomRegionRef = useRef(false);
26+
27+
const handleLineClick = useCallback(
28+
(event: React.MouseEvent<HTMLSpanElement>) => {
29+
const lineElement = (event.target as HTMLElement).closest('.log-line');
30+
if (lineElement) {
31+
lineElement.classList.toggle('expanded');
32+
}
33+
},
34+
[],
35+
);
36+
37+
const handleScroll = useCallback(
38+
({
39+
scrollTop,
40+
scrollHeight,
41+
clientHeight,
42+
}: {
43+
scrollTop: number;
44+
scrollHeight: number;
45+
clientHeight: number;
46+
}) => {
47+
const scrollableHeight = scrollHeight - clientHeight;
48+
if (scrollableHeight <= 0) {
49+
return;
50+
}
51+
52+
const scrollPercentage = scrollTop / scrollableHeight;
53+
const isScrollingUp = scrollTop < lastScrollTopRef.current;
54+
const isScrollingDown = scrollTop > lastScrollTopRef.current;
55+
56+
const isAtTop = scrollPercentage < 0.05;
57+
if (onAtTopChange && isAtTop !== wasAtTopRef.current) {
58+
wasAtTopRef.current = isAtTop;
59+
onAtTopChange(isAtTop);
60+
}
61+
62+
// Near top (newest logs with newest-first) - for running tasks
63+
// Only fire when entering the region (edge detection)
64+
// The region is defined by both scroll direction AND position, so changing
65+
// direction automatically resets the region state
66+
const isInTopRegion = isScrollingUp && scrollPercentage < 0.3;
67+
if (isInTopRegion && !wasInTopRegionRef.current && onScrollToTop) {
68+
onScrollToTop();
69+
}
70+
wasInTopRegionRef.current = isInTopRegion;
71+
72+
// Near bottom (older logs with newest-first) - for infinite scroll
73+
// Only fire when entering the region (edge detection)
74+
// The region is defined by both scroll direction AND position, so changing
75+
// direction automatically resets the region state
76+
const isInBottomRegion = isScrollingDown && scrollPercentage > 0.7;
77+
if (
78+
isInBottomRegion &&
79+
!wasInBottomRegionRef.current &&
80+
onScrollToBottom
81+
) {
82+
onScrollToBottom();
83+
}
84+
wasInBottomRegionRef.current = isInBottomRegion;
85+
86+
lastScrollTopRef.current = scrollTop;
87+
},
88+
[onScrollToTop, onScrollToBottom, onAtTopChange],
89+
);
90+
91+
return (
92+
<div
93+
className={cn(
94+
'terminal-root h-[500px] md:h-[600px] rounded-md w-full overflow-hidden',
95+
className,
96+
)}
97+
>
98+
<ScrollFollow
99+
startFollowing={autoScroll}
100+
render={({ follow, onScroll }) => (
101+
<LazyLog
102+
text={logs}
103+
follow={follow}
104+
onScroll={(args) => {
105+
onScroll(args);
106+
handleScroll(args);
107+
}}
108+
onLineContentClick={handleLineClick}
109+
selectableLines
110+
/>
111+
)}
112+
/>
113+
</div>
114+
);
115+
}
116+
117+
export default Terminal;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { AutocompleteSuggestion, LOG_LEVELS } from './types';
2+
3+
const LEVEL_DESCRIPTIONS: Record<string, string> = {
4+
error: 'Error messages',
5+
warn: 'Warning messages',
6+
info: 'Informational messages',
7+
debug: 'Debug messages',
8+
};
9+
10+
export type AutocompleteMode = 'key' | 'value' | 'none';
11+
12+
export interface AutocompleteState {
13+
mode: AutocompleteMode;
14+
suggestions: AutocompleteSuggestion[];
15+
}
16+
17+
export function getAutocomplete(query: string): AutocompleteState {
18+
const trimmed = query.trimEnd();
19+
const lastWord = trimmed.split(' ').pop() || '';
20+
21+
if (lastWord.startsWith('level:')) {
22+
const partial = lastWord.slice(6).toLowerCase();
23+
const suggestions = LOG_LEVELS.filter((level) =>
24+
level.startsWith(partial),
25+
).map((level) => ({
26+
type: 'value' as const,
27+
label: level,
28+
value: level,
29+
description: LEVEL_DESCRIPTIONS[level],
30+
}));
31+
return { mode: 'value', suggestions };
32+
}
33+
34+
if ('level:'.startsWith(lastWord.toLowerCase()) && lastWord.length > 0) {
35+
return {
36+
mode: 'key',
37+
suggestions: [
38+
{
39+
type: 'key',
40+
label: 'level',
41+
value: 'level:',
42+
description: 'Filter by log level',
43+
},
44+
],
45+
};
46+
}
47+
48+
if (trimmed === '' || query.endsWith(' ')) {
49+
return {
50+
mode: 'key',
51+
suggestions: [
52+
{
53+
type: 'key',
54+
label: 'level',
55+
value: 'level:',
56+
description: 'Filter by log level',
57+
},
58+
],
59+
};
60+
}
61+
62+
return { mode: 'none', suggestions: [] };
63+
}
64+
65+
export function applySuggestion(
66+
query: string,
67+
suggestion: AutocompleteSuggestion,
68+
): string {
69+
const trimmed = query.trimEnd();
70+
const words = trimmed.split(' ');
71+
const lastWord = words.pop() || '';
72+
73+
if (suggestion.type === 'value') {
74+
const prefix = lastWord.slice(0, lastWord.indexOf(':') + 1);
75+
words.push(prefix + suggestion.value);
76+
} else {
77+
if (lastWord && 'level:'.startsWith(lastWord.toLowerCase())) {
78+
words.push(suggestion.value);
79+
} else {
80+
words.push(lastWord, suggestion.value);
81+
}
82+
}
83+
84+
return words.filter(Boolean).join(' ');
85+
}

0 commit comments

Comments
 (0)