Skip to content

Commit 3d63012

Browse files
authored
feat: navigate tab (#219)
1 parent ff6fe9c commit 3d63012

File tree

1 file changed

+99
-33
lines changed

1 file changed

+99
-33
lines changed

frontend/src/components/tool/ResultsTabs.tsx

Lines changed: 99 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useMemo } from 'react';
1+
import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
2+
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
23
import { cn } from '../../lib/utils';
34
import { ResultsTable } from './ResultsTable';
45
import type { ResultTab } from './types';
@@ -28,11 +29,41 @@ export function ResultsTabs({
2829
onTabClose,
2930
isLoading,
3031
}: ResultsTabsProps) {
32+
const tabsContainerRef = useRef<HTMLDivElement>(null);
33+
const [canScrollLeft, setCanScrollLeft] = useState(false);
34+
const [canScrollRight, setCanScrollRight] = useState(false);
35+
3136
const activeTab = useMemo(
3237
() => tabs.find((tab) => tab.id === activeTabId),
3338
[tabs, activeTabId]
3439
);
3540

41+
const updateScrollButtons = useCallback(() => {
42+
const container = tabsContainerRef.current;
43+
if (!container) return;
44+
45+
const { scrollLeft, scrollWidth, clientWidth } = container;
46+
setCanScrollLeft(scrollLeft > 0);
47+
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 1);
48+
}, []);
49+
50+
useEffect(() => {
51+
updateScrollButtons();
52+
window.addEventListener('resize', updateScrollButtons);
53+
return () => window.removeEventListener('resize', updateScrollButtons);
54+
}, [updateScrollButtons, tabs]);
55+
56+
const scroll = (direction: 'left' | 'right') => {
57+
const container = tabsContainerRef.current;
58+
if (!container) return;
59+
60+
const scrollAmount = 150;
61+
container.scrollBy({
62+
left: direction === 'left' ? -scrollAmount : scrollAmount,
63+
behavior: 'smooth',
64+
});
65+
};
66+
3667
// Loading state (no tabs yet)
3768
if (isLoading && tabs.length === 0) {
3869
return (
@@ -56,44 +87,79 @@ export function ResultsTabs({
5687
return (
5788
<div className="space-y-2">
5889
{/* Tab bar */}
59-
<div className="flex items-center gap-1 border-b border-border overflow-x-auto overflow-y-hidden">
60-
{tabs.map((tab) => (
90+
<div className="relative flex items-center border-b border-border">
91+
{/* Left scroll button */}
92+
{canScrollLeft && (
6193
<button
62-
key={tab.id}
6394
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-
)}
95+
onClick={() => scroll('left')}
96+
className="absolute left-0 z-10 flex items-center justify-center w-6 h-full bg-background hover:bg-muted cursor-pointer"
97+
aria-label="Scroll tabs left"
7298
>
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 === ' ') {
99+
<ChevronLeftIcon className="w-4 h-4 text-muted-foreground" />
100+
</button>
101+
)}
102+
103+
{/* Tabs container */}
104+
<div
105+
ref={tabsContainerRef}
106+
onScroll={updateScrollButtons}
107+
className={cn(
108+
"flex items-center gap-1 overflow-hidden",
109+
canScrollLeft && "pl-6",
110+
canScrollRight && "pr-6"
111+
)}
112+
>
113+
{tabs.map((tab) => (
114+
<button
115+
key={tab.id}
116+
type="button"
117+
onClick={() => onTabSelect(tab.id)}
118+
className={cn(
119+
'group flex items-center gap-1.5 px-3 py-1.5 text-sm whitespace-nowrap',
120+
'border-b-2 -mb-px transition-colors cursor-pointer',
121+
tab.id === activeTabId
122+
? 'border-primary text-foreground'
123+
: 'border-transparent text-muted-foreground hover:text-foreground'
124+
)}
125+
>
126+
<span>{formatTimestamp(tab.timestamp)}</span>
127+
{tab.error && (
128+
<span className="w-1.5 h-1.5 rounded-full bg-destructive" aria-label="Error" />
129+
)}
130+
<span
131+
role="button"
132+
tabIndex={0}
133+
aria-label="Close tab"
134+
onClick={(e) => {
87135
e.stopPropagation();
88136
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>
137+
}}
138+
onKeyDown={(e) => {
139+
if (e.key === 'Enter' || e.key === ' ') {
140+
e.stopPropagation();
141+
onTabClose(tab.id);
142+
}
143+
}}
144+
className="opacity-0 group-hover:opacity-100 hover:bg-muted rounded p-0.5 transition-opacity"
145+
>
146+
<XIcon className="w-3 h-3" />
147+
</span>
148+
</button>
149+
))}
150+
</div>
151+
152+
{/* Right scroll button */}
153+
{canScrollRight && (
154+
<button
155+
type="button"
156+
onClick={() => scroll('right')}
157+
className="absolute right-0 z-10 flex items-center justify-center w-6 h-full bg-background hover:bg-muted cursor-pointer"
158+
aria-label="Scroll tabs right"
159+
>
160+
<ChevronRightIcon className="w-4 h-4 text-muted-foreground" />
95161
</button>
96-
))}
162+
)}
97163
</div>
98164

99165
{/* Active tab content */}

0 commit comments

Comments
 (0)