diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 6204c9d2b..1c9bf6e7b 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -83,6 +83,7 @@ import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, + SidebarRail, useSidebar, } from '@/components/ui/sidebar'; import { @@ -726,7 +727,12 @@ function NavItems({ )} /> ) : null} - {item.name} + {item.name} + {isBot && item.legacyAdapter && ( + + {t('bots.legacyAdapterBadge')} + + )} ); } @@ -789,7 +795,14 @@ function NavItems({ )} /> ) : null} - {item.name} + + {item.name} + + {isBot && item.legacyAdapter && ( + + {t('bots.legacyAdapterBadge')} + + )} {item.kind && ( + diff --git a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx index 2d2fed659..ccfe07e58 100644 --- a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx +++ b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx @@ -19,6 +19,7 @@ export interface SidebarEntityItem { updatedAt?: string; // ISO timestamp for sorting by most recently edited // Bot-specific fields enabled?: boolean; + legacyAdapter?: boolean; // MCP-specific fields runtimeStatus?: 'connecting' | 'connected' | 'error'; // Plugin-specific fields @@ -117,7 +118,15 @@ export function SidebarDataProvider({ const refreshBots = useCallback(async () => { try { - const resp = await httpClient.getBots(); + const [resp, adaptersResp] = await Promise.all([ + httpClient.getBots(), + httpClient.getAdapters().catch(() => ({ adapters: [] })), + ]); + const legacyAdapterNames = new Set( + adaptersResp.adapters + .filter((adapter) => adapter.spec.legacy) + .map((adapter) => adapter.name), + ); setBots( resp.bots.map((bot) => ({ id: bot.uuid || '', @@ -126,6 +135,7 @@ export function SidebarDataProvider({ iconURL: httpClient.getAdapterIconURL(bot.adapter), updatedAt: bot.updated_at, enabled: bot.enable ?? true, + legacyAdapter: legacyAdapterNames.has(bot.adapter), })), ); } catch (error) { diff --git a/web/src/components/ui/sidebar.tsx b/web/src/components/ui/sidebar.tsx index 8fad1287a..95ae80ee2 100644 --- a/web/src/components/ui/sidebar.tsx +++ b/web/src/components/ui/sidebar.tsx @@ -24,15 +24,23 @@ import { } from '@/components/ui/tooltip'; const SIDEBAR_STORAGE_KEY = 'sidebar_state'; -const SIDEBAR_WIDTH = '16rem'; +const SIDEBAR_WIDTH_STORAGE_KEY = 'sidebar_width'; +const SIDEBAR_WIDTH_DEFAULT = 256; +const SIDEBAR_WIDTH_MIN = 220; +const SIDEBAR_WIDTH_MAX = 420; const SIDEBAR_WIDTH_MOBILE = '18rem'; const SIDEBAR_WIDTH_ICON = '3rem'; const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; +const SIDEBAR_RESIZE_DRAG_THRESHOLD = 4; type SidebarContextProps = { state: 'expanded' | 'collapsed'; open: boolean; - setOpen: (open: boolean) => void; + setOpen: (open: boolean | ((open: boolean) => boolean)) => void; + sidebarWidth: number; + setSidebarWidth: (width: number | ((width: number) => number)) => void; + isSidebarResizing: boolean; + setSidebarResizing: (isResizing: boolean) => void; openMobile: boolean; setOpenMobile: (open: boolean) => void; isMobile: boolean; @@ -50,6 +58,24 @@ function useSidebar() { return context; } +function clampSidebarWidth(width: number) { + if (!Number.isFinite(width)) return SIDEBAR_WIDTH_DEFAULT; + + return Math.min( + SIDEBAR_WIDTH_MAX, + Math.max(SIDEBAR_WIDTH_MIN, Math.round(width)), + ); +} + +function getStoredSidebarWidth() { + if (typeof window === 'undefined') return SIDEBAR_WIDTH_DEFAULT; + + const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY); + if (stored === null) return SIDEBAR_WIDTH_DEFAULT; + + return clampSidebarWidth(Number.parseInt(stored, 10)); +} + function SidebarProvider({ defaultOpen = true, open: openProp, @@ -65,6 +91,10 @@ function SidebarProvider({ }) { const isMobile = useIsMobile(); const [openMobile, setOpenMobile] = React.useState(false); + const [sidebarWidth, _setSidebarWidth] = React.useState( + getStoredSidebarWidth, + ); + const [isSidebarResizing, setSidebarResizing] = React.useState(false); // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. @@ -93,6 +123,23 @@ function SidebarProvider({ [setOpenProp, open], ); + const setSidebarWidth = React.useCallback( + (value: number | ((width: number) => number)) => { + _setSidebarWidth((currentWidth) => { + const nextWidth = + typeof value === 'function' ? value(currentWidth) : value; + const clampedWidth = clampSidebarWidth(nextWidth); + + if (typeof window !== 'undefined') { + localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clampedWidth)); + } + + return clampedWidth; + }); + }, + [], + ); + // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); @@ -123,12 +170,28 @@ function SidebarProvider({ state, open, setOpen, + sidebarWidth, + setSidebarWidth, + isSidebarResizing, + setSidebarResizing, isMobile, openMobile, setOpenMobile, toggleSidebar, }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + [ + state, + open, + setOpen, + sidebarWidth, + setSidebarWidth, + isSidebarResizing, + setSidebarResizing, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + ], ); return ( @@ -138,7 +201,7 @@ function SidebarProvider({ data-slot="sidebar-wrapper" style={ { - '--sidebar-width': SIDEBAR_WIDTH, + '--sidebar-width': `${sidebarWidth}px`, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, ...style, } as React.CSSProperties @@ -168,7 +231,8 @@ function Sidebar({ variant?: 'sidebar' | 'floating' | 'inset'; collapsible?: 'offcanvas' | 'icon' | 'none'; }) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + const { isMobile, state, openMobile, setOpenMobile, isSidebarResizing } = + useSidebar(); if (collapsible === 'none') { return ( @@ -224,6 +288,7 @@ function Sidebar({ data-slot="sidebar-gap" className={cn( 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', + isSidebarResizing && 'transition-none', 'group-data-[collapsible=offcanvas]:w-0', 'group-data-[side=right]:rotate-180', variant === 'floating' || variant === 'inset' @@ -235,6 +300,7 @@ function Sidebar({ data-slot="sidebar-container" className={cn( 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex', + isSidebarResizing && 'transition-none', side === 'left' ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', @@ -284,8 +350,132 @@ function SidebarTrigger({ ); } -function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { - const { toggleSidebar } = useSidebar(); +type SidebarResizeDragState = { + pointerId: number; + startX: number; + startWidth: number; + side: 'left' | 'right'; + hasDragged: boolean; + captureTarget: HTMLElement; + previousCursor: string; + previousUserSelect: string; +}; + +function getSidebarSide(element: HTMLElement): 'left' | 'right' { + const sidebarRoot = element.closest('[data-slot="sidebar"]'); + return sidebarRoot?.getAttribute('data-side') === 'right' ? 'right' : 'left'; +} + +function restoreSidebarResizeBodyState(dragState: SidebarResizeDragState) { + document.body.style.cursor = dragState.previousCursor; + document.body.style.userSelect = dragState.previousUserSelect; +} + +function SidebarRail({ + className, + onClick, + onPointerCancel, + onPointerDown, + onPointerMove, + onPointerUp, + ...props +}: React.ComponentProps<'button'>) { + const { + setOpen, + sidebarWidth, + setSidebarResizing, + setSidebarWidth, + toggleSidebar, + } = useSidebar(); + const dragStateRef = React.useRef(null); + const removeDragListenersRef = React.useRef<(() => void) | null>(null); + const ignoreNextClickRef = React.useRef(false); + + React.useEffect(() => { + return () => { + removeDragListenersRef.current?.(); + removeDragListenersRef.current = null; + + if (dragStateRef.current) { + restoreSidebarResizeBodyState(dragStateRef.current); + dragStateRef.current = null; + } + setSidebarResizing(false); + }; + }, [setSidebarResizing]); + + function clearDragState() { + const dragState = dragStateRef.current; + if (!dragState) return null; + + removeDragListenersRef.current?.(); + removeDragListenersRef.current = null; + + if (dragState.captureTarget.hasPointerCapture(dragState.pointerId)) { + dragState.captureTarget.releasePointerCapture(dragState.pointerId); + } + + restoreSidebarResizeBodyState(dragState); + dragStateRef.current = null; + setSidebarResizing(false); + + return dragState; + } + + function handleResizePointerMove(event: PointerEvent | React.PointerEvent) { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + + const delta = + dragState.side === 'left' + ? event.clientX - dragState.startX + : dragState.startX - event.clientX; + + if ( + !dragState.hasDragged && + Math.abs(delta) >= SIDEBAR_RESIZE_DRAG_THRESHOLD + ) { + dragState.hasDragged = true; + setSidebarResizing(true); + setOpen(true); + } + + if (!dragState.hasDragged) return; + + event.preventDefault(); + setSidebarWidth(dragState.startWidth + delta); + } + + function handleResizePointerEnd(event: PointerEvent | React.PointerEvent) { + const currentDragState = dragStateRef.current; + if (!currentDragState || currentDragState.pointerId !== event.pointerId) + return; + + const dragState = clearDragState(); + if (!dragState) return; + + ignoreNextClickRef.current = dragState.hasDragged; + if (dragState.hasDragged) { + event.preventDefault(); + window.setTimeout(() => { + ignoreNextClickRef.current = false; + }, 0); + } + } + + function handleResizePointerCancel(event: PointerEvent | React.PointerEvent) { + const currentDragState = dragStateRef.current; + if (!currentDragState || currentDragState.pointerId !== event.pointerId) + return; + + const dragState = clearDragState(); + if (dragState?.hasDragged) { + ignoreNextClickRef.current = true; + window.setTimeout(() => { + ignoreNextClickRef.current = false; + }, 0); + } + } return (