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<T> 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.
This commit is contained in:
Brooklyn Nicholson 2026-05-16 20:41:51 -05:00
parent eb68d66ff9
commit cc76ebcc16
4 changed files with 372 additions and 183 deletions

View file

@ -105,13 +105,20 @@ function orderByIds<T>(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<string, SidebarSessionGroup>()
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({
<SidebarMenu className="gap-px">
{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({
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
'flex h-7 w-full cursor-pointer justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--chrome-action-hover) hover:text-foreground hover:transition-none',
'flex h-7 w-full cursor-pointer justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
@ -353,8 +360,8 @@ export function ChatSidebar({
<Button
aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
className={cn(
'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',
agentsGrouped && 'bg-(--ui-bg-tertiary) text-foreground opacity-100'
'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
@ -641,7 +648,7 @@ function SidebarWorkspaceGroup({
{hiddenCount > 0 && (
<button
aria-label={`Show ${nextCount} more in ${group.label}`}
className="ml-auto grid size-5 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
className="ml-auto grid size-5 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
title={`Show ${nextCount} more in ${group.label}`}
type="button"

View file

@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from 'react'
import type * as React from 'react'
import type { ReactNode } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { writeClipboardText } from '@/components/ui/copy-button'
import {
Dialog,
DialogContent,
@ -13,102 +13,144 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Pin } from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
interface SessionActionsMenuProps extends Pick<
React.ComponentProps<typeof DropdownMenuContent>,
'align' | 'sideOffset'
> {
children: ReactNode
title: string
interface SessionActions {
sessionId: string
title: string
pinned?: boolean
onPin?: () => void
onDelete?: () => void
}
export function SessionActionsMenu({
children,
title,
sessionId,
pinned = false,
onPin,
onDelete,
align = 'end',
sideOffset = 6
}: SessionActionsMenuProps) {
type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem
interface ItemSpec {
className?: string
disabled: boolean
icon: string
label: string
onSelect: (event: Event) => void
variant?: 'destructive'
}
function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) {
const [renameOpen, setRenameOpen] = useState(false)
const items: ItemSpec[] = [
{
disabled: !onPin,
icon: 'pin',
label: pinned ? 'Unpin' : 'Pin',
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
},
{
disabled: !sessionId,
icon: 'copy',
label: 'Copy ID',
onSelect: event => {
event.preventDefault()
triggerHaptic('selection')
void writeClipboardText(sessionId).catch(err => notifyError(err, 'Could not copy session ID'))
}
},
{
disabled: !sessionId,
icon: 'cloud-download',
label: 'Export',
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
}
},
{
disabled: !sessionId,
icon: 'edit',
label: 'Rename',
onSelect: () => {
triggerHaptic('selection')
setRenameOpen(true)
}
},
{
className: 'text-destructive focus:text-destructive',
disabled: !onDelete,
icon: 'trash',
label: 'Delete',
onSelect: () => {
triggerHaptic('warning')
onDelete?.()
},
variant: 'destructive'
}
]
const renderItems = (Item: MenuItem) =>
items.map(({ className, disabled, icon, label, onSelect, variant }) => (
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
<Codicon name={icon} size="0.875rem" />
<span>{label}</span>
</Item>
))
const renameDialog = (
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
)
return { renameDialog, renderItems }
}
interface SessionActionsMenuProps
extends SessionActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
}
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
const { renameDialog, renderItems } = useSessionActions(actions)
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-40" sideOffset={sideOffset}>
<DropdownMenuItem
disabled={!onPin}
onSelect={() => {
triggerHaptic('selection')
onPin?.()
}}
>
<Pin className="size-3.5" strokeWidth={1.75} />
<span>{pinned ? 'Unpin' : 'Pin'}</span>
</DropdownMenuItem>
<CopyButton
appearance="menu-item"
disabled={!sessionId}
errorMessage="Could not copy session ID"
label="Copy ID"
text={sessionId}
/>
<DropdownMenuItem
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
}}
>
<Codicon name="cloud-download" size="0.875rem" />
<span>Export</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
setRenameOpen(true)
}}
>
<Codicon name="edit" size="0.875rem" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={!onDelete}
onSelect={() => {
triggerHaptic('warning')
onDelete?.()
}}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</DropdownMenuItem>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${actions.title}`}
className="w-40"
sideOffset={sideOffset}
>
{renderItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
{renameDialog}
</>
)
}
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
interface SessionContextMenuProps extends SessionActions {
children: React.ReactNode
}
export function SessionContextMenu({ children, ...actions }: SessionContextMenuProps) {
const { renameDialog, renderItems } = useSessionActions(actions)
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent aria-label={`Actions for ${actions.title}`} className="w-40">
{renderItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
{renameDialog}
</>
)
}
@ -151,7 +193,7 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: Re
const result = await renameSession(sessionId, next)
const finalTitle = result.title || next || ''
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
notify({ kind: 'success', message: 'Renamed', durationMs: 2_000 })
notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' })
onOpenChange(false)
} catch (err) {
notifyError(err, 'Rename failed')

View file

@ -7,12 +7,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { SessionActionsMenu } from './session-actions-menu'
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
@ -27,23 +22,22 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
dragHandleProps?: React.HTMLAttributes<HTMLElement>
}
const AGE_TICKS: ReadonlyArray<[number, string]> = [
[86_400_000, 'd'],
[3_600_000, 'h'],
[60_000, 'm']
]
function formatAge(seconds: number): string {
const at = seconds * 1000
const delta = Math.max(0, Date.now() - at)
const delta = Math.max(0, Date.now() - seconds * 1000)
if (delta < MINUTE) {
return 'now'
for (const [ms, suffix] of AGE_TICKS) {
if (delta >= ms) {
return `${Math.floor(delta / ms)}${suffix}`
}
}
if (delta < HOUR) {
return `${Math.floor(delta / MINUTE)}m`
}
if (delta < DAY) {
return `${Math.floor(delta / HOUR)}h`
}
return `${Math.floor(delta / DAY)}d`
return 'now'
}
export function SidebarSessionRow({
@ -67,83 +61,86 @@ export function SidebarSessionRow({
const handleLabel = `Reorder ${title}`
return (
<div
className={cn(
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-bg-quinary) hover:transition-none',
isSelected && 'bg-(--ui-bg-tertiary)',
isWorking && 'text-foreground',
dragging && 'z-10 cursor-grabbing opacity-60 shadow-sm',
className
)}
data-working={isWorking ? 'true' : undefined}
ref={ref}
style={style}
{...rest}
>
<button
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onPin()
return
}
onResume()
}}
type="button"
<SessionContextMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<div
className={cn(
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
isSelected && 'bg-(--ui-row-active-background)',
isWorking && 'text-foreground',
dragging && 'z-10 cursor-grabbing opacity-60 shadow-sm',
className
)}
data-working={isWorking ? 'true' : undefined}
ref={ref}
style={style}
{...rest}
>
{reorderable ? (
<span
{...dragHandleProps}
aria-label={handleLabel}
className="relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
onClick={event => event.stopPropagation()}
>
<SidebarRowDot
className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
isWorking={isWorking}
/>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
{isWorking && <span aria-hidden="true" className="arc-border" />}
<button
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onPin()
return
}
onResume()
}}
type="button"
>
{reorderable ? (
<span
{...dragHandleProps}
aria-label={handleLabel}
className="relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
onClick={event => event.stopPropagation()}
>
<SidebarRowDot
className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
isWorking={isWorking}
/>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</span>
) : (
<span className="grid w-3.5 shrink-0 place-items-center overflow-hidden">
<SidebarRowDot isWorking={isWorking} />
</span>
)}
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
) : (
<span className="grid w-3.5 shrink-0 place-items-center overflow-hidden">
<SidebarRowDot isWorking={isWorking} />
</span>
)}
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
</button>
<div className="relative z-2 grid w-[1.375rem] place-items-center">
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
</span>
)}
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<Button
aria-label={`Actions for ${title}`}
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-bg-tertiary) hover:text-foreground focus-visible:bg-(--ui-bg-tertiary) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-bg-tertiary) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon"
title="Session actions"
variant="ghost"
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
</SessionActionsMenu>
</button>
<div className="relative z-2 grid w-[1.375rem] place-items-center">
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
</span>
)}
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<Button
aria-label={`Actions for ${title}`}
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon"
title="Session actions"
variant="ghost"
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
</SessionActionsMenu>
</div>
</div>
</div>
</SessionContextMenu>
)
}
@ -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}

View file

@ -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<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
}
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
}
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
className={cn(
'z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="context-menu-content"
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<ContextMenuPrimitive.Item
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
data-inset={inset}
data-slot="context-menu-item"
data-variant={variant}
{...props}
/>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
className={cn('px-2 py-1 text-xs font-medium text-(--ui-text-tertiary) data-[inset]:pl-7', className)}
data-inset={inset}
data-slot="context-menu-label"
{...props}
/>
)
}
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-(--ui-stroke-tertiary)', className)}
data-slot="context-menu-separator"
{...props}
/>
)
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
className={cn(
"flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)",
className
)}
data-inset={inset}
data-slot="context-menu-sub-trigger"
{...props}
>
{children}
<Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
className={cn(
'z-50 min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="context-menu-sub-content"
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuPortal,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger
}