feat: improve sidebar usability

This commit is contained in:
Junyan Qin
2026-06-30 22:18:33 +08:00
committed by huanghuoguoguo
parent 6dd296612b
commit d0f8f080e9
3 changed files with 291 additions and 15 deletions
@@ -83,6 +83,7 @@ import {
SidebarMenuSub, SidebarMenuSub,
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
SidebarRail,
useSidebar, useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { import {
@@ -726,7 +727,12 @@ function NavItems({
)} )}
/> />
) : null} ) : null}
<span className="truncate">{item.name}</span> <span className="min-w-0 flex-1 truncate">{item.name}</span>
{isBot && item.legacyAdapter && (
<span className="ml-auto shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
{t('bots.legacyAdapterBadge')}
</span>
)}
</button> </button>
); );
} }
@@ -789,7 +795,14 @@ function NavItems({
)} )}
/> />
) : null} ) : null}
<span className="truncate">{item.name}</span> <span className="min-w-0 flex-1 truncate">
{item.name}
</span>
{isBot && item.legacyAdapter && (
<span className="shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
{t('bots.legacyAdapterBadge')}
</span>
)}
{item.kind && ( {item.kind && (
<span <span
className="ml-auto flex shrink-0 items-center text-muted-foreground" className="ml-auto flex shrink-0 items-center text-muted-foreground"
@@ -2241,6 +2254,7 @@ export default function HomeSidebar({
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarFooter> </SidebarFooter>
<SidebarRail />
</Sidebar> </Sidebar>
<Dialog open={feedbackOpen} onOpenChange={setFeedbackOpen}> <Dialog open={feedbackOpen} onOpenChange={setFeedbackOpen}>
@@ -19,6 +19,7 @@ export interface SidebarEntityItem {
updatedAt?: string; // ISO timestamp for sorting by most recently edited updatedAt?: string; // ISO timestamp for sorting by most recently edited
// Bot-specific fields // Bot-specific fields
enabled?: boolean; enabled?: boolean;
legacyAdapter?: boolean;
// MCP-specific fields // MCP-specific fields
runtimeStatus?: 'connecting' | 'connected' | 'error'; runtimeStatus?: 'connecting' | 'connected' | 'error';
// Plugin-specific fields // Plugin-specific fields
@@ -117,7 +118,15 @@ export function SidebarDataProvider({
const refreshBots = useCallback(async () => { const refreshBots = useCallback(async () => {
try { 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( setBots(
resp.bots.map((bot) => ({ resp.bots.map((bot) => ({
id: bot.uuid || '', id: bot.uuid || '',
@@ -126,6 +135,7 @@ export function SidebarDataProvider({
iconURL: httpClient.getAdapterIconURL(bot.adapter), iconURL: httpClient.getAdapterIconURL(bot.adapter),
updatedAt: bot.updated_at, updatedAt: bot.updated_at,
enabled: bot.enable ?? true, enabled: bot.enable ?? true,
legacyAdapter: legacyAdapterNames.has(bot.adapter),
})), })),
); );
} catch (error) { } catch (error) {
+264 -12
View File
@@ -24,15 +24,23 @@ import {
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
const SIDEBAR_STORAGE_KEY = 'sidebar_state'; 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_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '3rem'; const SIDEBAR_WIDTH_ICON = '3rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
const SIDEBAR_RESIZE_DRAG_THRESHOLD = 4;
type SidebarContextProps = { type SidebarContextProps = {
state: 'expanded' | 'collapsed'; state: 'expanded' | 'collapsed';
open: boolean; 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; openMobile: boolean;
setOpenMobile: (open: boolean) => void; setOpenMobile: (open: boolean) => void;
isMobile: boolean; isMobile: boolean;
@@ -50,6 +58,24 @@ function useSidebar() {
return context; 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({ function SidebarProvider({
defaultOpen = true, defaultOpen = true,
open: openProp, open: openProp,
@@ -65,6 +91,10 @@ function SidebarProvider({
}) { }) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false); 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. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
@@ -93,6 +123,23 @@ function SidebarProvider({
[setOpenProp, open], [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. // Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
@@ -123,12 +170,28 @@ function SidebarProvider({
state, state,
open, open,
setOpen, setOpen,
sidebarWidth,
setSidebarWidth,
isSidebarResizing,
setSidebarResizing,
isMobile, isMobile,
openMobile, openMobile,
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}), }),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], [
state,
open,
setOpen,
sidebarWidth,
setSidebarWidth,
isSidebarResizing,
setSidebarResizing,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
],
); );
return ( return (
@@ -138,7 +201,7 @@ function SidebarProvider({
data-slot="sidebar-wrapper" data-slot="sidebar-wrapper"
style={ style={
{ {
'--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width': `${sidebarWidth}px`,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style, ...style,
} as React.CSSProperties } as React.CSSProperties
@@ -168,7 +231,8 @@ function Sidebar({
variant?: 'sidebar' | 'floating' | 'inset'; variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none'; collapsible?: 'offcanvas' | 'icon' | 'none';
}) { }) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); const { isMobile, state, openMobile, setOpenMobile, isSidebarResizing } =
useSidebar();
if (collapsible === 'none') { if (collapsible === 'none') {
return ( return (
@@ -224,6 +288,7 @@ function Sidebar({
data-slot="sidebar-gap" data-slot="sidebar-gap"
className={cn( className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
isSidebarResizing && 'transition-none',
'group-data-[collapsible=offcanvas]:w-0', 'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180', 'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset' variant === 'floating' || variant === 'inset'
@@ -235,6 +300,7 @@ function Sidebar({
data-slot="sidebar-container" data-slot="sidebar-container"
className={cn( className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex', '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' side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[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'>) { type SidebarResizeDragState = {
const { toggleSidebar } = useSidebar(); 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<SidebarResizeDragState | null>(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 ( return (
<button <button
@@ -293,12 +483,74 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
data-slot="sidebar-rail" data-slot="sidebar-rail"
aria-label="Toggle Sidebar" aria-label="Toggle Sidebar"
tabIndex={-1} tabIndex={-1}
onClick={toggleSidebar} onClick={(event) => {
title="Toggle Sidebar" onClick?.(event);
if (event.defaultPrevented) return;
if (ignoreNextClickRef.current) {
ignoreNextClickRef.current = false;
event.preventDefault();
return;
}
toggleSidebar();
}}
onPointerDown={(event) => {
onPointerDown?.(event);
if (event.defaultPrevented || event.button !== 0) return;
const target = event.currentTarget;
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: sidebarWidth,
side: getSidebarSide(target),
hasDragged: false,
captureTarget: target,
previousCursor: document.body.style.cursor,
previousUserSelect: document.body.style.userSelect,
};
target.setPointerCapture(event.pointerId);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const handleDocumentPointerMove = (pointerEvent: PointerEvent) =>
handleResizePointerMove(pointerEvent);
const handleDocumentPointerEnd = (pointerEvent: PointerEvent) =>
handleResizePointerEnd(pointerEvent);
const handleDocumentPointerCancel = (pointerEvent: PointerEvent) =>
handleResizePointerCancel(pointerEvent);
document.addEventListener('pointermove', handleDocumentPointerMove);
document.addEventListener('pointerup', handleDocumentPointerEnd);
document.addEventListener('pointercancel', handleDocumentPointerCancel);
removeDragListenersRef.current = () => {
document.removeEventListener(
'pointermove',
handleDocumentPointerMove,
);
document.removeEventListener('pointerup', handleDocumentPointerEnd);
document.removeEventListener(
'pointercancel',
handleDocumentPointerCancel,
);
};
}}
onPointerMove={(event) => {
onPointerMove?.(event);
}}
onPointerUp={(event) => {
onPointerUp?.(event);
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
}}
title="Drag to resize or click to toggle sidebar"
className={cn( className={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex', 'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 cursor-col-resize touch-none transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize', '[[data-state=collapsed]_&]:cursor-pointer',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full', 'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',