mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-23 19:05:16 +02:00
feat(tabs): persist tabs in localStorage for improved user experience across sessions; enhance syncChatTabAtom to include searchSpaceId; update LayoutDataProvider to support announcements dialog and clean up unused imports
This commit is contained in:
parent
8b625754c1
commit
3cdfe8b5b6
3 changed files with 139 additions and 57 deletions
|
|
@ -36,14 +36,16 @@ const initialState: TabsState = {
|
||||||
// Prevent race conditions where route-sync recreates a just-deleted chat tab.
|
// Prevent race conditions where route-sync recreates a just-deleted chat tab.
|
||||||
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
|
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
|
||||||
|
|
||||||
const sessionStorageAdapter = createJSONStorage<TabsState>(
|
// Persist tabs in localStorage so they survive a hard refresh and let the user
|
||||||
() => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage
|
// keep tabs open across multiple search spaces (browser-like behavior).
|
||||||
|
const localStorageAdapter = createJSONStorage<TabsState>(
|
||||||
|
() => (typeof window !== "undefined" ? localStorage : undefined) as Storage
|
||||||
);
|
);
|
||||||
|
|
||||||
export const tabsStateAtom = atomWithStorage<TabsState>(
|
export const tabsStateAtom = atomWithStorage<TabsState>(
|
||||||
"surfsense:tabs",
|
"surfsense:tabs",
|
||||||
initialState,
|
initialState,
|
||||||
sessionStorageAdapter,
|
localStorageAdapter,
|
||||||
{ getOnInit: true }
|
{ getOnInit: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -72,7 +74,17 @@ export const syncChatTabAtom = atom(
|
||||||
(
|
(
|
||||||
get,
|
get,
|
||||||
set,
|
set,
|
||||||
{ chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string }
|
{
|
||||||
|
chatId,
|
||||||
|
title,
|
||||||
|
chatUrl,
|
||||||
|
searchSpaceId,
|
||||||
|
}: {
|
||||||
|
chatId: number | null;
|
||||||
|
title?: string;
|
||||||
|
chatUrl?: string;
|
||||||
|
searchSpaceId: number;
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
if (chatId && get(deletedChatIdsAtom).has(chatId)) {
|
if (chatId && get(deletedChatIdsAtom).has(chatId)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -87,20 +99,32 @@ export const syncChatTabAtom = atom(
|
||||||
...state,
|
...state,
|
||||||
activeTabId: tabId,
|
activeTabId: tabId,
|
||||||
tabs: state.tabs.map((t) =>
|
tabs: state.tabs.map((t) =>
|
||||||
t.id === tabId ? { ...t, title: title || t.title, chatUrl: chatUrl || t.chatUrl } : t
|
t.id === tabId
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
title: title || t.title,
|
||||||
|
chatUrl: chatUrl || t.chatUrl,
|
||||||
|
searchSpaceId: searchSpaceId ?? t.searchSpaceId,
|
||||||
|
}
|
||||||
|
: t
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If navigating to a new chat (no chatId), ensure there's a "new chat" tab
|
// If navigating to a new chat (no chatId), ensure there's a "new chat" tab
|
||||||
|
// scoped to the current search space.
|
||||||
if (!chatId) {
|
if (!chatId) {
|
||||||
const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new");
|
const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new");
|
||||||
if (hasNewChatTab) {
|
if (hasNewChatTab) {
|
||||||
set(tabsStateAtom, { ...state, activeTabId: "chat-new" });
|
set(tabsStateAtom, {
|
||||||
|
...state,
|
||||||
|
activeTabId: "chat-new",
|
||||||
|
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, searchSpaceId, chatUrl } : t)),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
set(tabsStateAtom, {
|
set(tabsStateAtom, {
|
||||||
tabs: [...state.tabs, INITIAL_CHAT_TAB],
|
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, searchSpaceId, chatUrl }],
|
||||||
activeTabId: "chat-new",
|
activeTabId: "chat-new",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +139,7 @@ export const syncChatTabAtom = atom(
|
||||||
title: title || `Chat ${chatId}`,
|
title: title || `Chat ${chatId}`,
|
||||||
chatId,
|
chatId,
|
||||||
chatUrl,
|
chatUrl,
|
||||||
|
searchSpaceId,
|
||||||
};
|
};
|
||||||
|
|
||||||
let updatedTabs: Tab[];
|
let updatedTabs: Tab[];
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
|
import { AlertTriangle, Inbox, SquareLibrary } from "lucide-react";
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
|
@ -15,18 +15,15 @@ import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import {
|
import {
|
||||||
|
announcementsDialogAtom,
|
||||||
searchSpaceSettingsDialogAtom,
|
searchSpaceSettingsDialogAtom,
|
||||||
teamDialogAtom,
|
teamDialogAtom,
|
||||||
userSettingsDialogAtom,
|
userSettingsDialogAtom,
|
||||||
} from "@/atoms/settings/settings-dialog.atoms";
|
} from "@/atoms/settings/settings-dialog.atoms";
|
||||||
import {
|
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||||
removeChatTabAtom,
|
|
||||||
resetTabsAtom,
|
|
||||||
syncChatTabAtom,
|
|
||||||
type Tab,
|
|
||||||
} from "@/atoms/tabs/tabs.atom";
|
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet";
|
import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet";
|
||||||
|
import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog";
|
||||||
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
|
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
|
||||||
import { TeamDialog } from "@/components/settings/team-dialog";
|
import { TeamDialog } from "@/components/settings/team-dialog";
|
||||||
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
|
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
|
||||||
|
|
@ -105,7 +102,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||||
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
|
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
|
||||||
const syncChatTab = useSetAtom(syncChatTabAtom);
|
const syncChatTab = useSetAtom(syncChatTabAtom);
|
||||||
const resetTabs = useSetAtom(resetTabsAtom);
|
|
||||||
const removeChatTab = useSetAtom(removeChatTabAtom);
|
const removeChatTab = useSetAtom(removeChatTabAtom);
|
||||||
|
|
||||||
// Key used to force-remount the page component (e.g. after deleting the active chat
|
// Key used to force-remount the page component (e.g. after deleting the active chat
|
||||||
|
|
@ -132,11 +128,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unified slide-out panel state (only one can be open at a time)
|
// Unified slide-out panel state (only one can be open at a time)
|
||||||
type SlideoutPanel = "inbox" | "shared" | "private" | "announcements" | null;
|
type SlideoutPanel = "inbox" | "shared" | "private" | null;
|
||||||
const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
|
const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
|
||||||
|
|
||||||
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
|
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
|
||||||
const isAnnouncementsSidebarOpen = activeSlideoutPanel === "announcements";
|
|
||||||
|
|
||||||
// Documents sidebar state (shared atom so Composer can toggle it)
|
// Documents sidebar state (shared atom so Composer can toggle it)
|
||||||
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
|
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
|
||||||
|
|
@ -252,16 +247,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
|
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
|
||||||
const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
|
const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
|
||||||
|
|
||||||
// Reset transient slide-out panels and tabs when switching search spaces.
|
// Reset transient slide-out panels when switching search spaces.
|
||||||
// Use a ref to skip the initial mount — only reset when the space actually changes.
|
// Tabs intentionally persist across spaces — opening tabs from multiple
|
||||||
|
// search spaces is a supported flow (browser-tab semantics).
|
||||||
const prevSearchSpaceIdRef = useRef(searchSpaceId);
|
const prevSearchSpaceIdRef = useRef(searchSpaceId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevSearchSpaceIdRef.current !== searchSpaceId) {
|
if (prevSearchSpaceIdRef.current !== searchSpaceId) {
|
||||||
prevSearchSpaceIdRef.current = searchSpaceId;
|
prevSearchSpaceIdRef.current = searchSpaceId;
|
||||||
setActiveSlideoutPanel(null);
|
setActiveSlideoutPanel(null);
|
||||||
resetTabs();
|
|
||||||
}
|
}
|
||||||
}, [searchSpaceId, resetTabs]);
|
}, [searchSpaceId]);
|
||||||
|
|
||||||
const searchSpaces: SearchSpace[] = useMemo(() => {
|
const searchSpaces: SearchSpace[] = useMemo(() => {
|
||||||
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
|
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
|
||||||
|
|
@ -313,6 +308,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
// Avoid overwriting live SSE-updated tab titles with fallback values.
|
// Avoid overwriting live SSE-updated tab titles with fallback values.
|
||||||
title: chatId ? (thread?.title ?? undefined) : "New Chat",
|
title: chatId ? (thread?.title ?? undefined) : "New Chat",
|
||||||
chatUrl,
|
chatUrl,
|
||||||
|
searchSpaceId: Number(searchSpaceId),
|
||||||
});
|
});
|
||||||
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
|
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
|
||||||
|
|
||||||
|
|
@ -347,6 +343,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
}, [threadsData, searchSpaceId]);
|
}, [threadsData, searchSpaceId]);
|
||||||
|
|
||||||
// Navigation items
|
// Navigation items
|
||||||
|
// Inbox is rendered explicitly below "New chat" in the sidebar (it is also
|
||||||
|
// surfaced in the icon rail's collapsed mode via this list). Announcements
|
||||||
|
// has been moved to the avatar dropdown and is no longer a nav item.
|
||||||
const navItems: NavItem[] = useMemo(
|
const navItems: NavItem[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(
|
(
|
||||||
|
|
@ -366,24 +365,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
isActive: isDocumentsSidebarOpen,
|
isActive: isDocumentsSidebarOpen,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
{
|
|
||||||
title: "Announcements",
|
|
||||||
url: "#announcements",
|
|
||||||
icon: Megaphone,
|
|
||||||
isActive: isAnnouncementsSidebarOpen,
|
|
||||||
badge:
|
|
||||||
announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
|
|
||||||
},
|
|
||||||
] as (NavItem | null)[]
|
] as (NavItem | null)[]
|
||||||
).filter((item): item is NavItem => item !== null),
|
).filter((item): item is NavItem => item !== null),
|
||||||
[
|
[isMobile, isInboxSidebarOpen, isDocumentsSidebarOpen, totalUnreadCount]
|
||||||
isMobile,
|
|
||||||
isInboxSidebarOpen,
|
|
||||||
isDocumentsSidebarOpen,
|
|
||||||
totalUnreadCount,
|
|
||||||
isAnnouncementsSidebarOpen,
|
|
||||||
announcementUnreadCount,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
|
|
@ -401,11 +385,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
|
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
|
||||||
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
||||||
const setTeamDialogOpen = useSetAtom(teamDialogAtom);
|
const setTeamDialogOpen = useSetAtom(teamDialogAtom);
|
||||||
|
const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom);
|
||||||
|
|
||||||
const handleUserSettings = useCallback(() => {
|
const handleUserSettings = useCallback(() => {
|
||||||
setUserSettingsDialog({ open: true, initialTab: "profile" });
|
setUserSettingsDialog({ open: true, initialTab: "profile" });
|
||||||
}, [setUserSettingsDialog]);
|
}, [setUserSettingsDialog]);
|
||||||
|
|
||||||
|
const handleAnnouncements = useCallback(() => {
|
||||||
|
setAnnouncementsDialog(true);
|
||||||
|
}, [setAnnouncementsDialog]);
|
||||||
|
|
||||||
const handleSearchSpaceSettings = useCallback(
|
const handleSearchSpaceSettings = useCallback(
|
||||||
(_space: SearchSpace) => {
|
(_space: SearchSpace) => {
|
||||||
setSearchSpaceSettingsDialog({ open: true, initialTab: "general" });
|
setSearchSpaceSettingsDialog({ open: true, initialTab: "general" });
|
||||||
|
|
@ -519,10 +508,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.url === "#announcements") {
|
|
||||||
setActiveSlideoutPanel((prev) => (prev === "announcements" ? null : "announcements"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(item.url);
|
router.push(item.url);
|
||||||
},
|
},
|
||||||
[router, isMobile, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed]
|
[router, isMobile, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed]
|
||||||
|
|
@ -714,6 +699,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
onSettings={handleSettings}
|
onSettings={handleSettings}
|
||||||
onManageMembers={handleManageMembers}
|
onManageMembers={handleManageMembers}
|
||||||
onUserSettings={handleUserSettings}
|
onUserSettings={handleUserSettings}
|
||||||
|
onAnnouncements={handleAnnouncements}
|
||||||
|
announcementUnreadCount={announcementUnreadCount}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
setTheme={setTheme}
|
setTheme={setTheme}
|
||||||
|
|
@ -901,6 +888,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
|
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
|
||||||
<UserSettingsDialog />
|
<UserSettingsDialog />
|
||||||
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
|
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
|
||||||
|
<AnnouncementsDialog />
|
||||||
|
|
||||||
{/* Agent action log + revert sheet */}
|
{/* Agent action log + revert sheet */}
|
||||||
<ActionLogSheet />
|
<ActionLogSheet />
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,19 @@ interface TabBarProps {
|
||||||
className?: string;
|
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({
|
export function TabBar({
|
||||||
onTabSwitch,
|
onTabSwitch,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
|
|
@ -53,7 +66,56 @@ export function TabBar({
|
||||||
[closeTab, onTabSwitch]
|
[closeTab, onTabSwitch]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep active tab visible with minimal scroll shift.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!scrollRef.current || !activeTabId) return;
|
if (!scrollRef.current || !activeTabId) return;
|
||||||
const scroller = scrollRef.current;
|
const scroller = scrollRef.current;
|
||||||
|
|
@ -75,10 +137,6 @@ export function TabBar({
|
||||||
}
|
}
|
||||||
}, [activeTabId]);
|
}, [activeTabId]);
|
||||||
|
|
||||||
// Keep action slots visible even with one/no tabs
|
|
||||||
const hasAuxActions = !!leftActions || !!rightActions || !!onNewChat;
|
|
||||||
if (tabs.length <= 1 && !hasAuxActions) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -101,7 +159,7 @@ export function TabBar({
|
||||||
data-tab-id={tab.id}
|
data-tab-id={tab.id}
|
||||||
onClick={() => handleTabClick(tab)}
|
onClick={() => handleTabClick(tab)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex h-full w-[150px] items-center px-3 min-h-0 overflow-hidden text-[13px] font-medium rounded-md transition-all duration-150 shrink-0",
|
"group relative flex h-full items-center px-3 min-w-[120px] max-w-[240px] min-h-0 overflow-hidden text-[13px] font-medium rounded-md transition-all duration-150 shrink-0",
|
||||||
isActive
|
isActive
|
||||||
? "bg-muted/60 text-foreground"
|
? "bg-muted/60 text-foreground"
|
||||||
: "bg-transparent text-muted-foreground hover:bg-muted/30 hover:text-foreground"
|
: "bg-transparent text-muted-foreground hover:bg-muted/30 hover:text-foreground"
|
||||||
|
|
@ -133,20 +191,31 @@ export function TabBar({
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-0.5 shrink-0 pr-2">
|
|
||||||
{onNewChat && (
|
{onNewChat && (
|
||||||
<button
|
<div
|
||||||
type="button"
|
className={cn(
|
||||||
onClick={onNewChat}
|
// Solid bg + soft left-fade so tabs scrolling underneath the
|
||||||
className="flex h-8 w-8 items-center justify-center shrink-0 rounded-md text-muted-foreground transition-all duration-150 hover:text-muted-foreground hover:bg-muted/40"
|
// + button get visually masked into the bar's background —
|
||||||
title="New Chat"
|
// 1:1 port of opencode's `> .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"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
onClick={onNewChat}
|
||||||
|
className="flex h-8 w-8 items-center justify-center shrink-0 rounded-md text-muted-foreground transition-all duration-150 hover:text-muted-foreground hover:bg-muted/40"
|
||||||
|
title="New Chat"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{rightActions}
|
|
||||||
</div>
|
</div>
|
||||||
|
{rightActions ? (
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0 pr-2">{rightActions}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue