Skip to content

Commit 6d4b2f5

Browse files
committed
feat: navigate tab
1 parent ff6fe9c commit 6d4b2f5

File tree

1 file changed

+114
-33
lines changed

1 file changed

+114
-33
lines changed

frontend/src/components/tool/ResultsTabs.tsx

Lines changed: 114 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo } from 'react';
1+
import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
22
import { cn } from '../../lib/utils';
33
import { ResultsTable } from './ResultsTable';
44
import type { ResultTab } from './types';
@@ -21,18 +21,64 @@ function formatTimestamp(date: Date): string {
2121
});
2222
}
2323

24+
function ChevronLeftIcon({ className }: { className?: string }) {
25+
return (
26+
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
27+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
28+
</svg>
29+
);
30+
}
31+
32+
function ChevronRightIcon({ className }: { className?: string }) {
33+
return (
34+
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
35+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
36+
</svg>
37+
);
38+
}
39+
2440
export function ResultsTabs({
2541
tabs,
2642
activeTabId,
2743
onTabSelect,
2844
onTabClose,
2945
isLoading,
3046
}: ResultsTabsProps) {
47+
const tabsContainerRef = useRef<HTMLDivElement>(null);
48+
const [canScrollLeft, setCanScrollLeft] = useState(false);
49+
const [canScrollRight, setCanScrollRight] = useState(false);
50+
3151
const activeTab = useMemo(
3252
() => tabs.find((tab) => tab.id === activeTabId),
3353
[tabs, activeTabId]
3454
);
3555

56+
const updateScrollButtons = useCallback(() => {
57+
const container = tabsContainerRef.current;
58+
if (!container) return;
59+
60+
const { scrollLeft, scrollWidth, clientWidth } = container;
61+
setCanScrollLeft(scrollLeft > 0);
62+
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 1);
63+
}, []);
64+
65+
useEffect(() => {
66+
updateScrollButtons();
67+
window.addEventListener('resize', updateScrollButtons);
68+
return () => window.removeEventListener('resize', updateScrollButtons);
69+
}, [updateScrollButtons, tabs]);
70+
71+
const scroll = (direction: 'left' | 'right') => {
72+
const container = tabsContainerRef.current;
73+
if (!container) return;
74+
75+
const scrollAmount = 150;
76+
container.scrollBy({
77+
left: direction === 'left' ? -scrollAmount : scrollAmount,
78+
behavior: 'smooth',
79+
});
80+
};
81+
3682
// Loading state (no tabs yet)
3783
if (isLoading && tabs.length === 0) {
3884
return (
@@ -56,44 +102,79 @@ export function ResultsTabs({
56102
return (
57103
<div className="space-y-2">
58104
{/* Tab bar */}
59-
<div className="flex items-center gap-1 border-b border-border overflow-x-auto overflow-y-hidden">
60-
{tabs.map((tab) => (
105+
<div className="relative flex items-center border-b border-border">
106+
{/* Left scroll button */}
107+
{canScrollLeft && (
61108
<button
62-
key={tab.id}
63109
type="button"
64-
onClick={() => onTabSelect(tab.id)}
65-
className={cn(
66-
'group flex items-center gap-1.5 px-3 py-1.5 text-sm whitespace-nowrap',
67-
'border-b-2 -mb-px transition-colors cursor-pointer',
68-
tab.id === activeTabId
69-
? 'border-primary text-foreground'
70-
: 'border-transparent text-muted-foreground hover:text-foreground'
71-
)}
110+
onClick={() => scroll('left')}
111+
className="absolute left-0 z-10 flex items-center justify-center w-6 h-full bg-background hover:bg-muted cursor-pointer"
112+
aria-label="Scroll tabs left"
72113
>
73-
<span>{formatTimestamp(tab.timestamp)}</span>
74-
{tab.error && (
75-
<span className="w-1.5 h-1.5 rounded-full bg-destructive" aria-label="Error" />
76-
)}
77-
<span
78-
role="button"
79-
tabIndex={0}
80-
aria-label="Close tab"
81-
onClick={(e) => {
82-
e.stopPropagation();
83-
onTabClose(tab.id);
84-
}}
85-
onKeyDown={(e) => {
86-
if (e.key === 'Enter' || e.key === ' ') {
114+
<ChevronLeftIcon className="w-4 h-4 text-muted-foreground" />
115+
</button>
116+
)}
117+
118+
{/* Tabs container */}
119+
<div
120+
ref={tabsContainerRef}
121+
onScroll={updateScrollButtons}
122+
className={cn(
123+
"flex items-center gap-1 overflow-hidden",
124+
canScrollLeft && "pl-6",
125+
canScrollRight && "pr-6"
126+
)}
127+
>
128+
{tabs.map((tab) => (
129+
<button
130+
key={tab.id}
131+
type="button"
132+
onClick={() => onTabSelect(tab.id)}
133+
className={cn(
134+
'group flex items-center gap-1.5 px-3 py-1.5 text-sm whitespace-nowrap',
135+
'border-b-2 -mb-px transition-colors cursor-pointer',
136+
tab.id === activeTabId
137+
? 'border-primary text-foreground'
138+
: 'border-transparent text-muted-foreground hover:text-foreground'
139+
)}
140+
>
141+
<span>{formatTimestamp(tab.timestamp)}</span>
142+
{tab.error && (
143+
<span className="w-1.5 h-1.5 rounded-full bg-destructive" aria-label="Error" />
144+
)}
145+
<span
146+
role="button"
147+
tabIndex={0}
148+
aria-label="Close tab"
149+
onClick={(e) => {
87150
e.stopPropagation();
88151
onTabClose(tab.id);
89-
}
90-
}}
91-
className="opacity-0 group-hover:opacity-100 hover:bg-muted rounded p-0.5 transition-opacity"
92-
>
93-
<XIcon className="w-3 h-3" />
94-
</span>
152+
}}
153+
onKeyDown={(e) => {
154+
if (e.key === 'Enter' || e.key === ' ') {
155+
e.stopPropagation();
156+
onTabClose(tab.id);
157+
}
158+
}}
159+
className="opacity-0 group-hover:opacity-100 hover:bg-muted rounded p-0.5 transition-opacity"
160+
>
161+
<XIcon className="w-3 h-3" />
162+
</span>
163+
</button>
164+
))}
165+
</div>
166+
167+
{/* Right scroll button */}
168+
{canScrollRight && (
169+
<button
170+
type="button"
171+
onClick={() => scroll('right')}
172+
className="absolute right-0 z-10 flex items-center justify-center w-6 h-full bg-background hover:bg-muted cursor-pointer"
173+
aria-label="Scroll tabs right"
174+
>
175+
<ChevronRightIcon className="w-4 h-4 text-muted-foreground" />
95176
</button>
96-
))}
177+
)}
97178
</div>
98179

99180
{/* Active tab content */}

0 commit comments

Comments
 (0)