2026-05-15 18:16:03 +03:00
|
|
|
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';
|
2026-08-26 10:59:04 +03:00
|
|
|
import {
|
|
|
|
|
findSwitcherItemAncestorIds,
|
|
|
|
|
useSwitcherItems,
|
|
|
|
|
type SwitcherItem,
|
|
|
|
|
} from '@/components/session/sidebar/shell/useSwitcherItems';
|
2026-05-15 18:16:03 +03:00
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
|
|
|
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
2026-06-12 12:38:31 +03:00
|
|
|
import { formatSessionCompactDateLabel } from './sidebar/utils';
|
|
|
|
|
import type { SessionNode } from './sidebar/types';
|
2026-05-15 18:16:03 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
|
|
|
|
|
type SecondaryMeta = SwitcherItem['secondaryMeta'];
|
|
|
|
|
|
2026-05-15 20:22:01 +03:00
|
|
|
type SwitcherVariant = 'default' | 'compact';
|
2026-08-26 10:59:04 +03:00
|
|
|
const NEW_SESSION_SWITCHER_TARGET = 'new-session';
|
2026-05-15 20:22:01 +03:00
|
|
|
|
2026-05-15 18:16:03 +03:00
|
|
|
type SessionSwitcherDropdownProps = {
|
|
|
|
|
children: React.ReactNode;
|
2026-05-15 20:22:01 +03:00
|
|
|
variant?: SwitcherVariant;
|
|
|
|
|
scopeProjectId?: string | null;
|
|
|
|
|
align?: 'start' | 'center' | 'end';
|
2026-05-15 18:16:03 +03:00
|
|
|
};
|
|
|
|
|
|
2026-05-15 20:22:01 +03:00
|
|
|
export function SessionSwitcherDropdown({
|
|
|
|
|
children,
|
|
|
|
|
variant = 'default',
|
|
|
|
|
scopeProjectId = null,
|
|
|
|
|
align = 'start',
|
|
|
|
|
}: SessionSwitcherDropdownProps): React.ReactElement {
|
2026-05-15 18:16:03 +03:00
|
|
|
const isOpen = useUIStore((state) => state.isSessionDropdownOpen);
|
|
|
|
|
const setOpen = useUIStore((state) => state.setSessionDropdownOpen);
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-26 10:59:04 +03:00
|
|
|
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false} disableGlobalShortcuts>
|
2026-05-15 18:16:03 +03:00
|
|
|
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent
|
2026-05-15 20:22:01 +03:00
|
|
|
align={align}
|
2026-05-15 18:16:03 +03:00
|
|
|
sideOffset={6}
|
2026-05-15 20:22:01 +03:00
|
|
|
className={cn(
|
|
|
|
|
'overflow-hidden p-1 max-w-[calc(100vw-32px)]',
|
|
|
|
|
variant === 'compact' ? 'w-[280px]' : 'w-[360px]',
|
|
|
|
|
)}
|
2026-05-15 18:16:03 +03:00
|
|
|
>
|
2026-05-15 20:22:01 +03:00
|
|
|
{isOpen ? (
|
|
|
|
|
<SwitcherContent
|
|
|
|
|
onSelect={() => setOpen(false)}
|
|
|
|
|
variant={variant}
|
|
|
|
|
scopeProjectId={scopeProjectId}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
2026-05-15 18:16:03 +03:00
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 20:22:01 +03:00
|
|
|
type SwitcherContentProps = {
|
|
|
|
|
onSelect: () => void;
|
|
|
|
|
variant: SwitcherVariant;
|
|
|
|
|
scopeProjectId: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
|
2026-08-26 10:59:04 +03:00
|
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
|
|
|
const isNewSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open === true);
|
|
|
|
|
const items = useSwitcherItems(true, { scopeProjectId, currentSessionId });
|
2026-05-20 19:30:10 +03:00
|
|
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
2026-05-15 18:16:03 +03:00
|
|
|
const { t } = useI18n();
|
|
|
|
|
|
2026-05-20 19:30:10 +03:00
|
|
|
const handleNewSession = React.useCallback(() => {
|
|
|
|
|
onSelect();
|
|
|
|
|
openNewSessionDraft();
|
2026-08-24 16:36:41 +03:00
|
|
|
}, [onSelect, openNewSessionDraft]);
|
2026-05-20 19:30:10 +03:00
|
|
|
|
2026-05-15 18:16:03 +03:00
|
|
|
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
|
2026-08-26 10:59:04 +03:00
|
|
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
|
const initialFocusCompleteRef = React.useRef(false);
|
|
|
|
|
const initialTarget = isNewSessionDraftOpen ? NEW_SESSION_SWITCHER_TARGET : currentSessionId;
|
2026-05-15 18:16:03 +03:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-08-26 10:59:04 +03:00
|
|
|
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<HTMLElement>('[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]);
|
|
|
|
|
|
2026-05-15 18:16:03 +03:00
|
|
|
return (
|
2026-08-26 10:59:04 +03:00
|
|
|
<div ref={contentRef} className="max-h-[60vh] overflow-y-auto">
|
2026-05-20 19:30:10 +03:00
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
<BaseMenu.Item
|
2026-08-26 10:59:04 +03:00
|
|
|
data-switcher-item-id={NEW_SESSION_SWITCHER_TARGET}
|
2026-05-20 19:30:10 +03:00
|
|
|
onClick={handleNewSession}
|
|
|
|
|
className={cn(
|
|
|
|
|
'group relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
|
|
|
|
|
'data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="chat-new" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
|
|
|
|
<span className="truncate text-[14px] font-normal leading-tight text-foreground">
|
|
|
|
|
{t('sessions.sidebar.header.actions.newSession')}
|
|
|
|
|
</span>
|
|
|
|
|
</BaseMenu.Item>
|
|
|
|
|
{items.length === 0 ? (
|
|
|
|
|
<div className="px-3 py-4 text-center typography-meta text-muted-foreground">
|
|
|
|
|
{t('sessions.switcher.empty')}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
items.map((item) => (
|
2026-05-15 18:16:03 +03:00
|
|
|
<SwitcherNode
|
|
|
|
|
key={item.node.session.id}
|
|
|
|
|
item={item}
|
|
|
|
|
depth={0}
|
2026-05-15 20:22:01 +03:00
|
|
|
variant={variant}
|
2026-05-15 18:16:03 +03:00
|
|
|
expandedParents={expandedParents}
|
|
|
|
|
toggleParent={toggleParent}
|
|
|
|
|
closeDropdown={onSelect}
|
|
|
|
|
/>
|
2026-05-20 19:30:10 +03:00
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-05-15 18:16:03 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SwitcherNodeProps = {
|
|
|
|
|
item: { node: SessionNode; projectId: string | null; groupDirectory: string | null; secondaryMeta: SecondaryMeta };
|
|
|
|
|
depth: number;
|
2026-05-15 20:22:01 +03:00
|
|
|
variant: SwitcherVariant;
|
2026-05-15 18:16:03 +03:00
|
|
|
expandedParents: Set<string>;
|
|
|
|
|
toggleParent: (sessionId: string) => void;
|
|
|
|
|
closeDropdown: () => void;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-15 20:22:01 +03:00
|
|
|
function SwitcherNode({ item, depth, variant, expandedParents, toggleParent, closeDropdown }: SwitcherNodeProps): React.ReactElement {
|
2026-05-15 18:16:03 +03:00
|
|
|
const { node, secondaryMeta } = item;
|
|
|
|
|
const session = node.session;
|
|
|
|
|
const hasChildren = node.children.length > 0;
|
|
|
|
|
const isExpanded = expandedParents.has(session.id);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<SwitcherRow
|
|
|
|
|
session={session}
|
|
|
|
|
depth={depth}
|
2026-05-15 20:22:01 +03:00
|
|
|
variant={variant}
|
2026-05-15 18:16:03 +03:00
|
|
|
secondaryMeta={secondaryMeta}
|
|
|
|
|
hasChildren={hasChildren}
|
|
|
|
|
isExpanded={isExpanded}
|
|
|
|
|
onToggleExpand={hasChildren ? () => toggleParent(session.id) : undefined}
|
|
|
|
|
closeDropdown={closeDropdown}
|
|
|
|
|
/>
|
|
|
|
|
{hasChildren && isExpanded
|
|
|
|
|
? node.children.map((childNode) => (
|
|
|
|
|
<SwitcherNode
|
|
|
|
|
key={childNode.session.id}
|
|
|
|
|
item={{ node: childNode, projectId: item.projectId, groupDirectory: item.groupDirectory, secondaryMeta }}
|
|
|
|
|
depth={depth + 1}
|
2026-05-15 20:22:01 +03:00
|
|
|
variant={variant}
|
2026-05-15 18:16:03 +03:00
|
|
|
expandedParents={expandedParents}
|
|
|
|
|
toggleParent={toggleParent}
|
|
|
|
|
closeDropdown={closeDropdown}
|
|
|
|
|
/>
|
|
|
|
|
))
|
|
|
|
|
: null}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SwitcherRowProps = {
|
|
|
|
|
session: Session;
|
|
|
|
|
depth: number;
|
2026-05-15 20:22:01 +03:00
|
|
|
variant: SwitcherVariant;
|
2026-05-15 18:16:03 +03:00
|
|
|
secondaryMeta: SecondaryMeta;
|
|
|
|
|
hasChildren: boolean;
|
|
|
|
|
isExpanded: boolean;
|
|
|
|
|
onToggleExpand?: () => void;
|
|
|
|
|
closeDropdown: () => void;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-15 20:22:01 +03:00
|
|
|
function SwitcherRow({ session, depth, variant, secondaryMeta, hasChildren, isExpanded, onToggleExpand, closeDropdown }: SwitcherRowProps): React.ReactElement {
|
2026-05-15 18:16:03 +03:00
|
|
|
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 (
|
|
|
|
|
<BaseMenu.Item
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
if ((event.target as HTMLElement | null)?.closest('[data-switcher-expand]')) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
handleSelect();
|
|
|
|
|
}}
|
|
|
|
|
data-slot="session-switcher-item"
|
2026-08-26 10:59:04 +03:00
|
|
|
data-switcher-item-id={session.id}
|
2026-05-15 18:16:03 +03:00
|
|
|
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 }}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
2026-05-15 20:22:01 +03:00
|
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
|
|
|
{variant === 'compact' && hasChildren ? (
|
2026-05-15 18:16:03 +03:00
|
|
|
<span
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={-1}
|
|
|
|
|
data-switcher-expand
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
onToggleExpand?.();
|
|
|
|
|
}}
|
2026-05-15 20:22:01 +03:00
|
|
|
className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center rounded text-muted-foreground/70 hover:text-foreground"
|
2026-05-15 18:16:03 +03:00
|
|
|
aria-label={isExpanded ? t('sessions.sidebar.session.subsessions.collapse') : t('sessions.sidebar.session.subsessions.expand')}
|
|
|
|
|
>
|
2026-05-15 20:22:01 +03:00
|
|
|
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
|
2026-05-15 18:16:03 +03:00
|
|
|
</span>
|
|
|
|
|
) : null}
|
2026-05-15 20:22:01 +03:00
|
|
|
<span className={cn('truncate text-[14px] font-normal leading-tight', isActive ? 'text-primary' : 'text-foreground')}>
|
|
|
|
|
{sessionTitle}
|
|
|
|
|
</span>
|
2026-05-15 18:16:03 +03:00
|
|
|
</div>
|
2026-05-15 20:22:01 +03:00
|
|
|
{variant === 'default' ? (
|
|
|
|
|
<div
|
|
|
|
|
className="flex min-w-0 items-center gap-1.5 truncate text-muted-foreground/70 leading-tight"
|
|
|
|
|
style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}
|
|
|
|
|
>
|
|
|
|
|
{hasChildren ? (
|
|
|
|
|
<span
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={-1}
|
|
|
|
|
data-switcher-expand
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
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 ? <Icon name="arrow-down-s" className="h-3 w-3" /> : <Icon name="arrow-right-s" className="h-3 w-3" />}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
<span className="flex-shrink-0">{timeLabel}</span>
|
|
|
|
|
{projectLabel ? <span className="truncate">{projectLabel}</span> : null}
|
|
|
|
|
{branchLabel ? (
|
|
|
|
|
<span className="inline-flex min-w-0 items-center gap-0.5">
|
|
|
|
|
<Icon name="git-branch" className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" />
|
|
|
|
|
<span className="truncate">{branchLabel}</span>
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-05-15 18:16:03 +03:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isStreaming || showUnreadDot ? (
|
|
|
|
|
<span className="flex h-3 w-3 flex-shrink-0 items-center justify-center self-center">
|
|
|
|
|
{isStreaming ? (
|
|
|
|
|
<span
|
|
|
|
|
className="h-1.5 w-1.5 rounded-full bg-primary animate-busy-pulse"
|
|
|
|
|
aria-label={t('sessions.sidebar.session.status.active')}
|
|
|
|
|
title={t('sessions.sidebar.session.status.active')}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<span
|
|
|
|
|
className="h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
|
|
|
|
|
aria-label={t('sessions.sidebar.session.status.unread')}
|
|
|
|
|
title={t('sessions.sidebar.session.status.unread')}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</BaseMenu.Item>
|
|
|
|
|
);
|
|
|
|
|
}
|