Files
AgentHub/client/src/components/config/ConfigTabBar.tsx
T
nntrivi2001 2942ced3ec style(config): give Codex the same tab bar as Claude Code, drop the Cursor tab
The Codex tab strip wrapped onto a second row while Claude Code's stayed
one scrollable row, so two panels that are meant to be twins read as
different components. Both now render the same ConfigTabBar — the
scroller lifted out of CcConfig, with the fade edges and chevrons that
only appear when there is somewhere to scroll. The deferral note in
CodexConfig that predicted this is gone with it.

The bar also carries role="tablist"/"tab" and aria-selected now: Codex
had them, Claude Code did not, and one of the two had to win.

Cursor's placeholder tab goes away with the machinery that served only
it — the optional Panel, the "soon" badge, the coming-soon card, and
their four i18n keys.

Element.scrollBy and friends move to test-setup: jsdom has none of them,
and the tab bar now calls them from both pages.
2026-09-09 14:44:25 +07:00

164 lines
6.0 KiB
TypeScript

/**
* @file ConfigTabBar.tsx
* @description The tab strip both config explorers wear: one horizontally
* scrollable row with fade edges and chevrons that appear only when there is
* something to scroll to.
*
* Shared rather than copied because the two pages must look the same. Codex
* originally wrapped its tabs onto a second row — fine at eight tabs, but it
* read as a different component sitting next to Claude Code's twelve-tab
* scroller. One row that scrolls is the same shape at any tab count.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type ComponentType } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
export interface ConfigTab<K extends string> {
key: K;
icon: ComponentType<{ className?: string }>;
label: string;
/** Badge number, or null for tabs that count nothing (e.g. Overview). */
count: number | null;
}
interface ConfigTabBarProps<K extends string> {
tabs: ConfigTab<K>[];
current: K;
onSelect: (key: K) => void;
/** `data-testid` becomes `<prefix>-<key>`, e.g. "cc-tab-plugins". */
testIdPrefix: string;
ariaLabel: string;
}
export function ConfigTabBar<K extends string>({
tabs,
current,
onSelect,
testIdPrefix,
ariaLabel,
}: ConfigTabBarProps<K>) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Update scroll affordances when content size or scroll position changes.
const updateAffordances = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 1);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
}, []);
useEffect(() => {
updateAffordances();
const el = scrollRef.current;
if (!el) return;
el.addEventListener("scroll", updateAffordances, { passive: true });
const ro = new ResizeObserver(updateAffordances);
ro.observe(el);
return () => {
el.removeEventListener("scroll", updateAffordances);
ro.disconnect();
};
}, [updateAffordances]);
// Scroll the active tab into view when it changes (e.g. user picks a tab
// that's offscreen, or window resize hides the active one).
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const active = el.querySelector<HTMLElement>('[data-tab-active="true"]');
if (!active) return;
const elRect = el.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
if (activeRect.left < elRect.left + 8) {
el.scrollBy({ left: activeRect.left - elRect.left - 16, behavior: "smooth" });
} else if (activeRect.right > elRect.right - 8) {
el.scrollBy({ left: activeRect.right - elRect.right + 16, behavior: "smooth" });
}
}, [current]);
const scrollByButton = (dir: 1 | -1) => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: dir * Math.max(200, el.clientWidth * 0.6), behavior: "smooth" });
};
return (
<div className="relative rounded-xl border border-border bg-surface-1">
{/* Left edge gradient + chevron */}
<div
className={`pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 ${
canScrollLeft ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollLeft && (
<button
onClick={() => scrollByButton(-1)}
aria-label="scroll tabs left"
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<div
ref={scrollRef}
role="tablist"
aria-label={ariaLabel}
className="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: "none" }}
>
{tabs.map(({ key, icon: Icon, label, count }) => {
const active = current === key;
return (
<button
key={key}
data-testid={`${testIdPrefix}-${key}`}
data-tab-active={active ? "true" : undefined}
role="tab"
aria-selected={active}
onClick={() => onSelect(key)}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
active
? "bg-accent/15 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`}
>
<Icon className="w-3.5 h-3.5" />
<span>{label}</span>
{count !== null && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-semibold ${
active ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-secondary"
}`}
>
{count}
</span>
)}
</button>
);
})}
</div>
{/* Right edge gradient + chevron */}
<div
className={`pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 ${
canScrollRight ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollRight && (
<button
onClick={() => scrollByButton(1)}
aria-label="scroll tabs right"
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
);
}