import React from 'react'; import { Menu as BaseMenu } from '@base-ui/react/menu'; import type { Session } from '@opencode-ai/sdk/v2'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Icon } from '@/components/icon/Icon'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionStatus } from '@/sync/sync-context'; import { useSessionUnseenCount } from '@/sync/notification-store'; import { findSwitcherItemAncestorIds, useSwitcherItems, type SwitcherItem, } from '@/components/session/sidebar/shell/useSwitcherItems'; import { useUIStore } from '@/stores/useUIStore'; import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { formatSessionCompactDateLabel } from './sidebar/utils'; import type { SessionNode } from './sidebar/types'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; type SecondaryMeta = SwitcherItem['secondaryMeta']; type SwitcherVariant = 'default' | 'compact'; const NEW_SESSION_SWITCHER_TARGET = 'new-session'; type SessionSwitcherDropdownProps = { children: React.ReactNode; variant?: SwitcherVariant; scopeProjectId?: string | null; align?: 'start' | 'center' | 'end'; }; export function SessionSwitcherDropdown({ children, variant = 'default', scopeProjectId = null, align = 'start', }: SessionSwitcherDropdownProps): React.ReactElement { const isOpen = useUIStore((state) => state.isSessionDropdownOpen); const setOpen = useUIStore((state) => state.setSessionDropdownOpen); return ( {children} {isOpen ? ( setOpen(false)} variant={variant} scopeProjectId={scopeProjectId} /> ) : null} ); } type SwitcherContentProps = { onSelect: () => void; variant: SwitcherVariant; scopeProjectId: string | null; }; function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement { const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const isNewSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open === true); const items = useSwitcherItems(true, { scopeProjectId, currentSessionId }); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const { t } = useI18n(); const handleNewSession = React.useCallback(() => { onSelect(); openNewSessionDraft(); }, [onSelect, openNewSessionDraft]); const [expandedParents, setExpandedParents] = React.useState>(new Set()); const contentRef = React.useRef(null); const initialFocusCompleteRef = React.useRef(false); const initialTarget = isNewSessionDraftOpen ? NEW_SESSION_SWITCHER_TARGET : currentSessionId; const toggleParent = React.useCallback((sessionId: string) => { setExpandedParents((prev) => { const next = new Set(prev); if (next.has(sessionId)) { next.delete(sessionId); } else { next.add(sessionId); } return next; }); }, []); React.useLayoutEffect(() => { if (initialFocusCompleteRef.current || !initialTarget) return; const ancestorIds = initialTarget === NEW_SESSION_SWITCHER_TARGET ? [] : findSwitcherItemAncestorIds(items, initialTarget); if (!ancestorIds) return; if (ancestorIds.some((id) => !expandedParents.has(id))) { setExpandedParents((previous) => new Set([...previous, ...ancestorIds])); return; } const animationFrame = requestAnimationFrame(() => { const item = Array.from( contentRef.current?.querySelectorAll('[data-switcher-item-id]') ?? [], ).find((element) => element.dataset.switcherItemId === initialTarget); if (!item) return; item.focus(); item.scrollIntoView({ block: 'nearest' }); initialFocusCompleteRef.current = true; }); return () => cancelAnimationFrame(animationFrame); }, [expandedParents, initialTarget, items]); return (
{t('sessions.sidebar.header.actions.newSession')} {items.length === 0 ? (
{t('sessions.switcher.empty')}
) : ( items.map((item) => ( )) )}
); } type SwitcherNodeProps = { item: { node: SessionNode; projectId: string | null; groupDirectory: string | null; secondaryMeta: SecondaryMeta }; depth: number; variant: SwitcherVariant; expandedParents: Set; toggleParent: (sessionId: string) => void; closeDropdown: () => void; }; function SwitcherNode({ item, depth, variant, expandedParents, toggleParent, closeDropdown }: SwitcherNodeProps): React.ReactElement { const { node, secondaryMeta } = item; const session = node.session; const hasChildren = node.children.length > 0; const isExpanded = expandedParents.has(session.id); return ( <> toggleParent(session.id) : undefined} closeDropdown={closeDropdown} /> {hasChildren && isExpanded ? node.children.map((childNode) => ( )) : null} ); } type SwitcherRowProps = { session: Session; depth: number; variant: SwitcherVariant; secondaryMeta: SecondaryMeta; hasChildren: boolean; isExpanded: boolean; onToggleExpand?: () => void; closeDropdown: () => void; }; function SwitcherRow({ session, depth, variant, secondaryMeta, hasChildren, isExpanded, onToggleExpand, closeDropdown }: SwitcherRowProps): React.ReactElement { const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks); const sessionStatus = useGlobalSessionStatus(session.id); const unseenCount = useSessionUnseenCount(session.id); const isActive = currentSessionId === session.id; const sessionTitle = session.title?.trim() || t('sessions.sidebar.session.untitled'); const isSubtask = Boolean((session as Session & { parentID?: string | null }).parentID); const needsAttention = unseenCount > 0 && (!isSubtask || notifyOnSubtasks); const statusType = sessionStatus?.type ?? 'idle'; const isStreaming = statusType === 'busy' || statusType === 'retry'; const showUnreadDot = !isStreaming && needsAttention && !isActive; const timestamp = session.time?.updated || session.time?.created || Date.now(); const timeLabel = formatSessionCompactDateLabel(timestamp); const projectLabel = secondaryMeta?.projectLabel?.trim() || null; const rawBranchLabel = secondaryMeta?.branchLabel?.trim() || null; const branchLabel = rawBranchLabel && rawBranchLabel !== 'HEAD' ? rawBranchLabel : null; const handleSelect = React.useCallback(() => { if (isActive) { closeDropdown(); return; } const directory = resolveGlobalSessionDirectory(session); setCurrentSession(session.id, directory ?? null); closeDropdown(); }, [closeDropdown, isActive, session, setCurrentSession]); return ( { if ((event.target as HTMLElement | null)?.closest('[data-switcher-expand]')) { event.preventDefault(); return; } handleSelect(); }} data-slot="session-switcher-item" data-switcher-item-id={session.id} className={cn( 'group relative flex w-full cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none', 'data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover', )} style={{ paddingLeft: 8 + depth * 12 }} >
{variant === 'compact' && hasChildren ? ( { event.preventDefault(); event.stopPropagation(); onToggleExpand?.(); }} className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center rounded text-muted-foreground/70 hover:text-foreground" aria-label={isExpanded ? t('sessions.sidebar.session.subsessions.collapse') : t('sessions.sidebar.session.subsessions.expand')} > {isExpanded ? : } ) : null} {sessionTitle}
{variant === 'default' ? (
{hasChildren ? ( { event.preventDefault(); event.stopPropagation(); onToggleExpand?.(); }} className="inline-flex h-3 w-3 flex-shrink-0 items-center justify-center rounded text-muted-foreground/70 hover:text-foreground" aria-label={isExpanded ? t('sessions.sidebar.session.subsessions.collapse') : t('sessions.sidebar.session.subsessions.expand')} > {isExpanded ? : } ) : null} {timeLabel} {projectLabel ? {projectLabel} : null} {branchLabel ? ( {branchLabel} ) : null}
) : null}
{isStreaming || showUnreadDot ? ( {isStreaming ? ( ) : ( )} ) : null}
); }