"use client"; import { useAtomValue, useSetAtom } from "jotai"; import { Plus, X } from "lucide-react"; import { useCallback, useEffect, useRef } from "react"; import { activeTabIdAtom, closeTabAtom, switchTabAtom, type Tab, tabsAtom, } from "@/atoms/tabs/tabs.atom"; import { cn } from "@/lib/utils"; interface TabBarProps { onTabSwitch?: (tab: Tab) => void; onNewChat?: () => void; leftActions?: React.ReactNode; rightActions?: React.ReactNode; className?: string; } // Pure scroll-target calculation (port of opencode's nextTabListScrollLeft). // - When the list shrinks (a tab was closed), do not move the scroll. // - When the list overflows after growing, snap to the right edge so the new tab is visible. function nextTabListScrollLeft(input: { prevScrollWidth: number; scrollWidth: number; clientWidth: number; }) { if (input.scrollWidth <= input.prevScrollWidth) return; if (input.scrollWidth <= input.clientWidth) return; return input.scrollWidth - input.clientWidth; } export function TabBar({ onTabSwitch, onNewChat, leftActions, rightActions, className, }: TabBarProps) { const tabs = useAtomValue(tabsAtom); const activeTabId = useAtomValue(activeTabIdAtom); const switchTab = useSetAtom(switchTabAtom); const closeTab = useSetAtom(closeTabAtom); const scrollRef = useRef(null); const handleTabClick = useCallback( (tab: Tab) => { if (tab.id === activeTabId) return; switchTab(tab.id); onTabSwitch?.(tab); }, [activeTabId, switchTab, onTabSwitch] ); const handleTabClose = useCallback( (e: React.MouseEvent, tabId: string) => { e.stopPropagation(); const fallback = closeTab(tabId); if (fallback) { onTabSwitch?.(fallback); } }, [closeTab, onTabSwitch] ); // React to tab list growth (port of opencode's createFileTabListSync). // Uses a MutationObserver instead of a tab-id effect so the scroll catches // the moment the new tab is added to the DOM, not after activation lands. // Also remaps vertical wheel motion to horizontal scroll. useEffect(() => { const el = scrollRef.current; if (!el) return; let prevScrollWidth = el.scrollWidth; let frame: number | undefined; const update = () => { const left = nextTabListScrollLeft({ prevScrollWidth, scrollWidth: el.scrollWidth, clientWidth: el.clientWidth, }); if (left !== undefined) { el.scrollTo({ left, behavior: "smooth" }); } prevScrollWidth = el.scrollWidth; }; const schedule = () => { if (frame !== undefined) cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { frame = undefined; update(); }); }; const onWheel = (e: WheelEvent) => { if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; el.scrollLeft += e.deltaY > 0 ? 50 : -50; e.preventDefault(); }; el.addEventListener("wheel", onWheel, { passive: false }); const observer = new MutationObserver(schedule); observer.observe(el, { childList: true }); return () => { el.removeEventListener("wheel", onWheel); observer.disconnect(); if (frame !== undefined) cancelAnimationFrame(frame); }; }, []); // When the user activates a tab that's currently off-screen (e.g. clicked // from the sidebar), nudge the scroller minimally so the active tab is in view. useEffect(() => { if (!scrollRef.current || !activeTabId) return; const scroller = scrollRef.current; const activeEl = scroller.querySelector(`[data-tab-id="${activeTabId}"]`); if (!activeEl) return; const viewLeft = scroller.scrollLeft; const viewRight = viewLeft + scroller.clientWidth; const tabLeft = activeEl.offsetLeft; const tabRight = tabLeft + activeEl.offsetWidth; if (tabLeft < viewLeft) { scroller.scrollTo({ left: tabLeft, behavior: "smooth" }); return; } if (tabRight > viewRight) { scroller.scrollTo({ left: tabRight - scroller.clientWidth, behavior: "smooth" }); } }, [activeTabId]); return (
{leftActions ?
{leftActions}
: null}
{tabs.map((tab) => { const isActive = tab.id === activeTabId; return ( ); })} {onNewChat && (
.sticky` rule in tabs.css. "sticky right-0 z-10 flex h-full shrink-0 items-center bg-main-panel pl-3 pr-1", "before:content-[''] before:absolute before:inset-y-0 before:-left-4 before:w-4 before:pointer-events-none", "before:bg-gradient-to-r before:from-transparent before:to-main-panel" )} >
)}
{rightActions ? (
{rightActions}
) : null}
); }