import React from 'react'; import type { Session } from '@opencode-ai/sdk'; import { toast } from 'sonner'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { RiAddLine, RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLine, RiDeleteBinLine, RiErrorWarningLine, RiFileCopyLine, RiFolder6Line, RiGitRepositoryLine, RiLinkUnlinkM, RiMore2Line, RiPencilAiLine, RiShare2Line, } from '@remixicon/react'; import { sessionEvents } from '@/lib/sessionEvents'; import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useUIStore } from '@/stores/useUIStore'; import type { WorktreeMetadata } from '@/types/worktree'; import { opencodeClient } from '@/lib/opencode/client'; import { checkIsGitRepository } from '@/lib/gitApi'; import { getSafeStorage } from '@/stores/utils/safeStorage'; const WORKTREE_ROOT = '.openchamber'; const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse'; const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents'; const formatDateLabel = (value: string | number) => { const targetDate = new Date(value); const today = new Date(); const isSameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); if (isSameDay(targetDate, today)) { return 'Today'; } if (isSameDay(targetDate, yesterday)) { return 'Yesterday'; } const formatted = targetDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }); return formatted.replace(',', ''); }; const normalizePath = (value?: string | null) => { if (!value) { return null; } const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); return normalized.length === 0 ? '/' : normalized; }; const deriveProjectRoot = (directory: string | null, metadata: Map): string | null => { const normalized = normalizePath(directory); const firstMetadata = Array.from(metadata.values())[0]; if (firstMetadata?.projectDirectory) { return normalizePath(firstMetadata.projectDirectory); } if (!normalized) { return null; } const marker = `/${WORKTREE_ROOT}`; const markerIndex = normalized.indexOf(marker); if (markerIndex > 0) { return normalized.slice(0, markerIndex); } return normalized; }; type SessionNode = { session: Session; children: SessionNode[]; }; type SessionGroup = { id: string; label: string; description: string | null; isMain: boolean; worktree: WorktreeMetadata | null; directory: string | null; sessions: SessionNode[]; }; interface SessionSidebarProps { mobileVariant?: boolean; onSessionSelected?: (sessionId: string) => void; allowReselect?: boolean; hideDirectoryControls?: boolean; } export const SessionSidebar: React.FC = ({ mobileVariant = false, onSessionSelected, allowReselect = false, hideDirectoryControls = false, }) => { const [editingId, setEditingId] = React.useState(null); const [editTitle, setEditTitle] = React.useState(''); const [copiedSessionId, setCopiedSessionId] = React.useState(null); const copyTimeout = React.useRef(null); const [expandedParents, setExpandedParents] = React.useState>(new Set()); const [directoryStatus, setDirectoryStatus] = React.useState>( () => new Map(), ); const checkingDirectories = React.useRef>(new Set()); const safeStorage = React.useMemo(() => getSafeStorage(), []); const [collapsedGroups, setCollapsedGroups] = React.useState>(new Set()); const [isGitRepo, setIsGitRepo] = React.useState(null); const [expandedSessionGroups, setExpandedSessionGroups] = React.useState>(new Set()); const [hoveredGroupId, setHoveredGroupId] = React.useState(null); const [stuckHeaders, setStuckHeaders] = React.useState>(new Set()); const headerSentinelRefs = React.useRef>(new Map()); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const setDirectory = useDirectoryStore((state) => state.setDirectory); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher); const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory); const currentSessionId = useSessionStore((state) => state.currentSessionId); const setCurrentSession = useSessionStore((state) => state.setCurrentSession); const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle); const shareSession = useSessionStore((state) => state.shareSession); const unshareSession = useSessionStore((state) => state.unshareSession); const sessionMemoryState = useSessionStore((state) => state.sessionMemoryState); const sessionActivityPhase = useSessionStore((state) => state.sessionActivityPhase); const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata); const availableWorktrees = useSessionStore((state) => state.availableWorktrees); const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { if (typeof window === 'undefined') { return false; } return typeof window.opencodeDesktop !== 'undefined'; }); React.useEffect(() => { try { const storedGroups = safeStorage.getItem(GROUP_COLLAPSE_STORAGE_KEY); if (storedGroups) { const parsed = JSON.parse(storedGroups); if (Array.isArray(parsed)) { setCollapsedGroups(new Set(parsed.filter((item) => typeof item === 'string'))); } } const storedParents = safeStorage.getItem(SESSION_EXPANDED_STORAGE_KEY); if (storedParents) { const parsed = JSON.parse(storedParents); if (Array.isArray(parsed)) { setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string'))); } } } catch { /* ignored */ } }, [safeStorage]); React.useEffect(() => { if (typeof window === 'undefined') { return; } setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); }, []); const sessions = getSessionsByDirectory(currentDirectory); const sortedSessions = React.useMemo(() => { return [...sessions].sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0)); }, [sessions]); React.useEffect(() => { if (!currentDirectory) { setIsGitRepo(null); return; } let cancelled = false; checkIsGitRepository(currentDirectory) .then((result) => { if (!cancelled) { setIsGitRepo(result); } }) .catch(() => { if (!cancelled) { setIsGitRepo(null); } }); return () => { cancelled = true; }; }, [currentDirectory]); const sessionMap = React.useMemo(() => { return new Map(sortedSessions.map((session) => [session.id, session])); }, [sortedSessions]); const parentMap = React.useMemo(() => { const map = new Map(); sortedSessions.forEach((session) => { const parentID = (session as Session & { parentID?: string | null }).parentID; if (parentID) { map.set(session.id, parentID); } }); return map; }, [sortedSessions]); const childrenMap = React.useMemo(() => { const map = new Map(); sortedSessions.forEach((session) => { const parentID = (session as Session & { parentID?: string | null }).parentID; if (!parentID) { return; } const collection = map.get(parentID) ?? []; collection.push(session); map.set(parentID, collection); }); map.forEach((list) => list.sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0))); return map; }, [sortedSessions]); React.useEffect(() => { if (!currentSessionId) { return; } setExpandedParents((previous) => { const next = new Set(previous); let cursor = parentMap.get(currentSessionId) || null; let changed = false; while (cursor) { if (!next.has(cursor)) { next.add(cursor); changed = true; } cursor = parentMap.get(cursor) || null; } return changed ? next : previous; }); }, [currentSessionId, parentMap]); const projectRoot = React.useMemo( () => deriveProjectRoot(currentDirectory, worktreeMetadata), [currentDirectory, worktreeMetadata], ); React.useEffect(() => { const directories = new Set(); sortedSessions.forEach((session) => { const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null); if (dir) { directories.add(dir); } }); if (projectRoot) { directories.add(projectRoot); } directories.forEach((directory) => { const known = directoryStatus.get(directory); if ((known && known !== 'unknown') || checkingDirectories.current.has(directory)) { return; } checkingDirectories.current.add(directory); opencodeClient .listLocalDirectory(directory) .then(() => { setDirectoryStatus((prev) => { const next = new Map(prev); if (next.get(directory) === 'exists') { return prev; } next.set(directory, 'exists'); return next; }); }) .catch(() => { setDirectoryStatus((prev) => { const next = new Map(prev); if (next.get(directory) === 'missing') { return prev; } next.set(directory, 'missing'); return next; }); }) .finally(() => { checkingDirectories.current.delete(directory); }); }); }, [sortedSessions, projectRoot, directoryStatus]); React.useEffect(() => { return () => { if (copyTimeout.current) { clearTimeout(copyTimeout.current); } }; }, []); const displayDirectory = React.useMemo( () => formatDirectoryName(currentDirectory, homeDirectory), [currentDirectory, homeDirectory], ); const directoryTooltip = React.useMemo( () => formatPathForDisplay(currentDirectory, homeDirectory), [currentDirectory, homeDirectory], ); const emptyState = (

No sessions yet

Create your first session to start coding.

); const handleSessionSelect = React.useCallback( (sessionId: string, disabled?: boolean) => { if (disabled) { return; } if (mobileVariant) { setActiveMainTab('chat'); setSessionSwitcherOpen(false); } if (!allowReselect && sessionId === currentSessionId) { onSessionSelected?.(sessionId); return; } setCurrentSession(sessionId); onSessionSelected?.(sessionId); }, [ allowReselect, currentSessionId, mobileVariant, onSessionSelected, setActiveMainTab, setCurrentSession, setSessionSwitcherOpen, ], ); const handleSaveEdit = React.useCallback(async () => { if (editingId && editTitle.trim()) { await updateSessionTitle(editingId, editTitle.trim()); setEditingId(null); setEditTitle(''); } }, [editingId, editTitle, updateSessionTitle]); const handleCancelEdit = React.useCallback(() => { setEditingId(null); setEditTitle(''); }, []); const handleShareSession = React.useCallback( async (session: Session) => { const result = await shareSession(session.id); if (result && result.share?.url) { toast.success('Session shared', { description: 'You can copy the link from the menu.', }); } else { toast.error('Unable to share session'); } }, [shareSession], ); const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => { navigator.clipboard .writeText(url) .then(() => { setCopiedSessionId(sessionId); if (copyTimeout.current) { clearTimeout(copyTimeout.current); } copyTimeout.current = window.setTimeout(() => { setCopiedSessionId(null); copyTimeout.current = null; }, 2000); }) .catch(() => { toast.error('Failed to copy URL'); }); }, []); const handleUnshareSession = React.useCallback( async (sessionId: string) => { const result = await unshareSession(sessionId); if (result) { toast.success('Session unshared'); } else { toast.error('Unable to unshare session'); } }, [unshareSession], ); const collectDescendants = React.useCallback( (sessionId: string): Session[] => { const collected: Session[] = []; const visit = (id: string) => { const children = childrenMap.get(id) ?? []; children.forEach((child) => { collected.push(child); visit(child.id); }); }; visit(sessionId); return collected; }, [childrenMap], ); const deleteSession = useSessionStore((state) => state.deleteSession); const deleteSessions = useSessionStore((state) => state.deleteSessions); const handleDeleteSession = React.useCallback( async (session: Session) => { const descendants = collectDescendants(session.id); if (descendants.length === 0) { const success = await deleteSession(session.id); if (success) { toast.success('Session deleted'); } else { toast.error('Failed to delete session'); } } else { const ids = [session.id, ...descendants.map((s) => s.id)]; const { deletedIds, failedIds } = await deleteSessions(ids); if (deletedIds.length > 0) { toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`); } if (failedIds.length > 0) { toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`); } } }, [collectDescendants, deleteSession, deleteSessions], ); const handleCreateSessionInGroup = React.useCallback( (directory: string | null) => { setActiveMainTab('chat'); if (mobileVariant) { setSessionSwitcherOpen(false); } openNewSessionDraft({ directoryOverride: directory ?? null }); }, [openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen, mobileVariant], ); const handleOpenWorktreeManager = React.useCallback(() => { sessionEvents.requestCreate({ worktreeMode: 'create' }); }, []); const handleOpenDirectoryDialog = React.useCallback(() => { if (isDesktopRuntime && window.opencodeDesktop?.requestDirectoryAccess) { window.opencodeDesktop .requestDirectoryAccess('') .then((result) => { if (result.success && result.path) { setDirectory(result.path, { showOverlay: true }); } else if (result.error && result.error !== 'Directory selection cancelled') { toast.error('Failed to select directory', { description: result.error, }); } }) .catch((error) => { console.error('Desktop: Error selecting directory:', error); toast.error('Failed to select directory'); }); } else { sessionEvents.requestDirectoryDialog(); } }, [isDesktopRuntime, setDirectory]); const toggleParent = React.useCallback((sessionId: string) => { setExpandedParents((prev) => { const next = new Set(prev); if (next.has(sessionId)) { next.delete(sessionId); } else { next.add(sessionId); } try { safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next))); } catch { /* ignored */ } return next; }); }, [safeStorage]); const buildNode = React.useCallback( (session: Session): SessionNode => { const children = childrenMap.get(session.id) ?? []; return { session, children: children.map((child) => buildNode(child)), }; }, [childrenMap], ); const groupedSessions = React.useMemo(() => { const groups = new Map(); const normalizedProjectRoot = normalizePath(projectRoot ?? null); const worktreeByPath = new Map(); const existingWorktreePaths = new Set(); availableWorktrees.forEach((meta) => { if (meta.path) { const normalized = normalizePath(meta.path) ?? meta.path; existingWorktreePaths.add(normalized); worktreeByPath.set(normalized, meta); } }); const ensureGroup = (session: Session) => { const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); const sessionWorktreeMeta = worktreeMetadata.get(session.id); const sessionWorktreeExists = sessionWorktreeMeta?.path ? existingWorktreePaths.has(normalizePath(sessionWorktreeMeta.path) ?? sessionWorktreeMeta.path) : false; const worktree = (sessionWorktreeExists ? sessionWorktreeMeta : null) ?? (sessionDirectory ? worktreeByPath.get(sessionDirectory) ?? null : null); const isMain = !worktree && ((sessionDirectory && normalizedProjectRoot ? sessionDirectory === normalizedProjectRoot : !sessionDirectory && Boolean(normalizedProjectRoot))); const key = isMain ? 'main' : worktree?.path ?? sessionDirectory ?? session.id; const directory = worktree?.path ?? sessionDirectory ?? normalizedProjectRoot ?? null; if (!groups.has(key)) { const label = isMain ? 'Main workspace' : worktree?.label || worktree?.branch || formatDirectoryName(directory || '', homeDirectory) || 'Worktree'; const description = worktree?.relativePath ? formatPathForDisplay(worktree.relativePath, homeDirectory) : directory ? formatPathForDisplay(directory, homeDirectory) : null; groups.set(key, { id: key, label, description, isMain, worktree, directory, sessions: [], }); } return groups.get(key)!; }; const roots = sortedSessions.filter((session) => { const parentID = (session as Session & { parentID?: string | null }).parentID; if (!parentID) { return true; } return !sessionMap.has(parentID); }); roots.forEach((session) => { const group = ensureGroup(session); const node = buildNode(session); group.sessions.push(node); }); if (!groups.has('main')) { groups.set('main', { id: 'main', label: 'Main workspace', description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, homeDirectory) : null, isMain: true, worktree: null, directory: normalizedProjectRoot, sessions: [], }); } worktreeByPath.forEach((meta, path) => { const key = meta.path; if (!groups.has(key)) { groups.set(key, { id: key, label: meta.label || meta.branch || formatDirectoryName(path, homeDirectory) || 'Worktree', description: meta.relativePath ? formatPathForDisplay(meta.relativePath, homeDirectory) : formatPathForDisplay(path, homeDirectory), isMain: false, worktree: meta, directory: path, sessions: [], }); } }); groups.forEach((group) => { group.sessions.sort((a, b) => (b.session.time?.created || 0) - (a.session.time?.created || 0)); }); return Array.from(groups.values()).sort((a, b) => { if (a.isMain !== b.isMain) { return a.isMain ? -1 : 1; } return (a.label || '').localeCompare(b.label || ''); }); }, [sortedSessions, worktreeMetadata, availableWorktrees, projectRoot, homeDirectory, buildNode, sessionMap]); const toggleGroup = React.useCallback((groupId: string) => { setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(groupId)) { next.delete(groupId); } else { next.add(groupId); } try { safeStorage.setItem(GROUP_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next))); } catch { /* ignored */ } return next; }); }, [safeStorage]); const toggleGroupSessionLimit = React.useCallback((groupId: string) => { setExpandedSessionGroups((prev) => { const next = new Set(prev); if (next.has(groupId)) { next.delete(groupId); } else { next.add(groupId); } return next; }); }, []); // Track when sticky headers become "stuck" using sentinel elements React.useEffect(() => { if (!isDesktopRuntime) return; const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { const groupId = (entry.target as HTMLElement).dataset.groupId; if (!groupId) return; setStuckHeaders((prev) => { const next = new Set(prev); // When sentinel is NOT intersecting, header is stuck if (!entry.isIntersecting) { next.add(groupId); } else { next.delete(groupId); } return next; }); }); }, { threshold: 0 } ); headerSentinelRefs.current.forEach((el) => { if (el) observer.observe(el); }); return () => observer.disconnect(); }, [isDesktopRuntime, groupedSessions]); const renderSessionNode = React.useCallback( (node: SessionNode, depth = 0, groupDirectory?: string | null): React.ReactNode => { const session = node.session; const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? normalizePath(groupDirectory ?? null); const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null; const isMissingDirectory = directoryState === 'missing'; const memoryState = sessionMemoryState.get(session.id); const isActive = currentSessionId === session.id; const sessionTitle = session.title || 'Untitled Session'; const hasChildren = node.children.length > 0; const isExpanded = expandedParents.has(session.id); const additions = session.summary?.additions; const deletions = session.summary?.deletions; const hasSummary = typeof additions === 'number' || typeof deletions === 'number'; if (editingId === session.id) { return (
0 && 'pl-[20px]', )} >
{ event.preventDefault(); handleSaveEdit(); }} > setEditTitle(event.target.value)} className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground" autoFocus placeholder="Rename session" onKeyDown={(event) => { if (event.key === 'Escape') handleCancelEdit(); }} />
{hasChildren ? ( {isExpanded ? ( ) : ( )} ) : null} {formatDateLabel(session.time?.created || Date.now())} {session.share ? ( ) : null} {hasSummary && ((additions ?? 0) !== 0 || (deletions ?? 0) !== 0) ? ( +{Math.max(0, additions ?? 0)} / -{Math.max(0, deletions ?? 0)} ) : null} {hasChildren ? ( {node.children.length} {node.children.length === 1 ? 'task' : 'tasks'} ) : null}
); } const phase = sessionActivityPhase?.get(session.id) ?? 'idle'; const isStreaming = phase === 'busy' || phase === 'cooldown'; const streamingIndicator = (() => { if (!memoryState) return null; if (memoryState.isZombie) { return ; } return null; })(); return (
0 && 'pl-[20px]', )} >
{streamingIndicator} { setEditingId(session.id); setEditTitle(sessionTitle); }} className="[&>svg]:mr-1" > Rename {!session.share ? ( handleShareSession(session)} className="[&>svg]:mr-1"> Share ) : ( <> { if (session.share?.url) { handleCopyShareUrl(session.share.url, session.id); } }} className="[&>svg]:mr-1" > {copiedSessionId === session.id ? ( <> Copied ) : ( <> Copy link )} handleUnshareSession(session.id)} className="[&>svg]:mr-1"> Unshare )} handleDeleteSession(session)} > Remove
{hasChildren && isExpanded ? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory), ) : null}
); }, [ directoryStatus, sessionMemoryState, sessionActivityPhase, currentSessionId, expandedParents, editingId, editTitle, handleSaveEdit, handleCancelEdit, toggleParent, handleSessionSelect, handleShareSession, handleCopyShareUrl, handleUnshareSession, handleDeleteSession, copiedSessionId, mobileVariant, ], ); return (
{!hideDirectoryControls && (
{isGitRepo ? ( <> ) : null}
)} {groupedSessions.length === 0 ? ( emptyState ) : hideDirectoryControls && groupedSessions.length === 1 && groupedSessions[0].isMain ? (
{(() => { const group = groupedSessions[0]; const maxVisible = hideDirectoryControls ? 10 : 7; const totalSessions = group.sessions.length; const isExpanded = expandedSessionGroups.has(group.id); const visibleSessions = isExpanded ? group.sessions : group.sessions.slice(0, maxVisible); const remainingCount = totalSessions - visibleSessions.length; if (totalSessions === 0) { return (
No sessions yet.
); } return ( <> {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory))} {remainingCount > 0 && !isExpanded ? ( ) : null} {isExpanded && totalSessions > maxVisible ? ( ) : null} ); })()}
) : ( groupedSessions.map((group) => (
{/* Sentinel element to detect when header becomes stuck */} {isDesktopRuntime && (
{ headerSentinelRefs.current.set(group.id, el); }} data-group-id={group.id} className="absolute top-0 h-px w-full pointer-events-none" aria-hidden="true" /> )} {} {!collapsedGroups.has(group.id) ? (
{(() => { const isExpanded = expandedSessionGroups.has(group.id); const maxVisible = hideDirectoryControls ? 10 : 7; const totalSessions = group.sessions.length; const visibleSessions = isExpanded ? group.sessions : group.sessions.slice(0, maxVisible); const remainingCount = totalSessions - visibleSessions.length; return ( <> {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory))} {totalSessions === 0 ? (
No sessions in this worktree yet.
) : null} {remainingCount > 0 && !isExpanded ? ( ) : null} {isExpanded && totalSessions > maxVisible ? ( ) : null} ); })()}
) : null}
)) )}
); };