From cc76ebcc163e809261630987a859d4093081ee20 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 16 May 2026 20:41:51 -0500 Subject: [PATCH] feat(sidebar): right-click + drag-reorder sessions and workspaces - Wire right-click on session rows to open the same actions menu; suppresses the OS-native context menu so Windows stops looking awful. - Share dropdown + context menu items via useSessionActions() driving a single declarative ItemSpec[]; render polymorphic over MenuItem. - New shadcn ContextMenu primitive mirroring DropdownMenu styling. - Restore drag-and-drop reordering for Agents (lost during the cwd cleanup) and add reordering of workspace groups via a right-side grab handle. Pinned reorder unchanged. - Generic orderByIds replaces the duplicated session/group orderers; useSortableBindings() hook collapses the two Sortable wrappers. - cursor-pointer on every actionable element; cursor-grab on handles. - KISS pass: baseName() helper, AGE_TICKS table, single WORKSPACE_PAGE constant, flatter SidebarSessionsSection render. --- apps/desktop/src/app/chat/sidebar/index.tsx | 29 ++- .../app/chat/sidebar/session-actions-menu.tsx | 198 +++++++++++------- .../src/app/chat/sidebar/session-row.tsx | 187 ++++++++--------- .../src/components/ui/context-menu.tsx | 141 +++++++++++++ 4 files changed, 372 insertions(+), 183 deletions(-) create mode 100644 apps/desktop/src/components/ui/context-menu.tsx diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 06253583129..0888859ed86 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -105,13 +105,20 @@ function orderByIds(items: T[], getId: (item: T) => string, orderIds: string[ return out } +const baseName = (path: string) => + path + .replace(/[/\\]+$/, '') + .split(/[/\\]/) + .filter(Boolean) + .pop() + function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] { const groups = new Map() for (const session of sessions) { const path = session.cwd?.trim() || '' const id = path || '__no_workspace__' - const label = path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean).pop() || path || 'No workspace' + const label = baseName(path) || path || 'No workspace' const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] } group.sessions.push(session) @@ -168,17 +175,16 @@ export function ChatSidebar({ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ) - const sortedSessions = useMemo( - () => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), - [sessions] - ) + const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions]) const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions]) const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds]) + const visiblePinnedIds = useMemo( () => pinnedSessionIds.filter(id => sessionsById.has(id)), [pinnedSessionIds, sessionsById] ) + const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds]) const pinnedSessions = useMemo( @@ -263,7 +269,7 @@ export function ChatSidebar({ className={cn( 'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none', sidebarOpen - ? 'border-(--sidebar-edge-border) bg-(--glass-sidebar-surface-background) opacity-100' + ? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100' : 'pointer-events-none border-transparent bg-transparent opacity-0' )} collapsible="none" @@ -274,6 +280,7 @@ export function ChatSidebar({ {SIDEBAR_NAV.map(item => { const isInteractive = Boolean(item.action) || Boolean(item.route) + const active = (item.id === 'skills' && currentView === 'skills') || (item.id === 'messaging' && currentView === 'messaging') || @@ -284,9 +291,9 @@ export function ChatSidebar({ { event.stopPropagation() @@ -641,7 +648,7 @@ function SidebarWorkspaceGroup({ {hiddenCount > 0 && ( -
- {!isWorking && ( - - {age} - - )} - - - + +
+ {!isWorking && ( + + {age} + + )} + + + +
- + ) } @@ -153,7 +150,9 @@ function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className aria-label={isWorking ? 'Session running' : undefined} className={cn( 'rounded-full', - isWorking ? 'size-1.5 bg-(--ui-green)' : 'size-1 bg-(--ui-text-quaternary) opacity-80', + isWorking + ? "relative size-1.5 bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] before:absolute before:inset-0 before:animate-ping before:rounded-full before:bg-(--ui-accent) before:opacity-70 before:content-['']" + : 'size-1 bg-(--ui-text-quaternary) opacity-80', className )} role={isWorking ? 'status' : undefined} diff --git a/apps/desktop/src/components/ui/context-menu.tsx b/apps/desktop/src/components/ui/context-menu.tsx new file mode 100644 index 00000000000..0849efdd53c --- /dev/null +++ b/apps/desktop/src/components/ui/context-menu.tsx @@ -0,0 +1,141 @@ +import { ContextMenu as ContextMenuPrimitive } from 'radix-ui' +import * as React from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +function ContextMenu({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuPortal({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuGroup({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuContent({ className, ...props }: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: 'default' | 'destructive' +}) { + return ( + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuPortal, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger +}