From 29ba318f0fb178099d95cad3231fd6c6dc361577 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 8 Jan 2026 00:06:02 +0200 Subject: [PATCH] feat: enhance SessionSidebar with worktree metadata and UI updates --- .../src/components/session/SessionSidebar.tsx | 294 ++++-------------- 1 file changed, 55 insertions(+), 239 deletions(-) diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 3eb25a31..53d9875c 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -36,6 +36,7 @@ import { RiDeleteBinLine, RiErrorWarningLine, RiFileCopyLine, + RiGitBranchLine, RiGitRepositoryLine, RiLinkUnlinkM, RiMore2Line, @@ -55,7 +56,6 @@ import { opencodeClient } from '@/lib/opencode/client'; import { checkIsGitRepository } from '@/lib/gitApi'; import { getSafeStorage } from '@/stores/utils/safeStorage'; -const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents'; @@ -102,6 +102,7 @@ const formatProjectLabel = (label: string): string => { type SessionNode = { session: Session; children: SessionNode[]; + worktree: WorktreeMetadata | null; }; type SessionGroup = { @@ -337,17 +338,13 @@ export const SessionSidebar: React.FC = ({ ); const checkingDirectories = React.useRef>(new Set()); const safeStorage = React.useMemo(() => getSafeStorage(), []); - const [collapsedGroups, setCollapsedGroups] = React.useState>(new Set()); const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set()); const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map()); const [expandedSessionGroups, setExpandedSessionGroups] = React.useState>(new Set()); - const [hoveredGroupId, setHoveredGroupId] = React.useState(null); const [hoveredProjectId, setHoveredProjectId] = React.useState(null); const [activeDragId, setActiveDragId] = React.useState(null); - const [stuckHeaders, setStuckHeaders] = React.useState>(new Set()); const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState>(new Set()); - const headerSentinelRefs = React.useRef>(new Map()); const projectHeaderSentinelRefs = React.useRef>(new Map()); const ignoreIntersectionUntil = React.useRef(0); @@ -380,7 +377,6 @@ export const SessionSidebar: React.FC = ({ const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata); const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory); - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { if (typeof window === 'undefined') { @@ -391,13 +387,6 @@ export const SessionSidebar: React.FC = ({ 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); @@ -733,20 +722,6 @@ export const SessionSidebar: React.FC = ({ [collectDescendants, deleteSession, deleteSessions, worktreeMetadata], ); - const handleCreateSessionInGroup = React.useCallback( - (directory: string | null, projectId?: string | null) => { - if (projectId && projectId !== activeProjectId) { - setActiveProject(projectId); - } - setActiveMainTab('chat'); - if (mobileVariant) { - setSessionSwitcherOpen(false); - } - openNewSessionDraft({ directoryOverride: directory ?? null }); - }, - [activeProjectId, openNewSessionDraft, setActiveMainTab, setActiveProject, setSessionSwitcherOpen, mobileVariant], - ); - const handleOpenWorktreeManager = React.useCallback( (projectId?: string | null) => { if (projectId && projectId !== activeProjectId) { @@ -805,15 +780,15 @@ export const SessionSidebar: React.FC = ({ return { session, children: children.map((child) => buildNode(child)), + worktree: worktreeMetadata.get(session.id) ?? null, }; }, - [childrenMap], + [childrenMap, worktreeMetadata], ); const buildGroupedSessions = React.useCallback( (projectSessions: Session[], projectRoot: string | null, availableWorktrees: WorktreeMetadata[]) => { - const groups = new Map(); const normalizedProjectRoot = normalizePath(projectRoot ?? null); const sortedProjectSessions = [...projectSessions].sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0)); @@ -830,14 +805,7 @@ export const SessionSidebar: React.FC = ({ }); childrenMap.forEach((list) => list.sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0))); - const buildProjectNode = (session: Session): SessionNode => { - const children = childrenMap.get(session.id) ?? []; - return { - session, - children: children.map((child) => buildProjectNode(child)), - }; - }; - + // Build worktree lookup map const worktreeByPath = new Map(); availableWorktrees.forEach((meta) => { if (meta.path) { @@ -846,42 +814,31 @@ export const SessionSidebar: React.FC = ({ } }); - const ensureGroup = (session: Session) => { + // Helper to get worktree metadata for a session + const getSessionWorktree = (session: Session): WorktreeMetadata | null => { const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - const sessionWorktreeMeta = worktreeMetadata.get(session.id) ?? null; - const worktree = - sessionWorktreeMeta ?? - (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: [], - }); + if (sessionWorktreeMeta) return sessionWorktreeMeta; + if (sessionDirectory) { + const worktree = worktreeByPath.get(sessionDirectory) ?? null; + // Only count as worktree if it's not the main project root + if (worktree && sessionDirectory !== normalizedProjectRoot) { + return worktree; + } } - return groups.get(key)!; + return null; }; + const buildProjectNode = (session: Session): SessionNode => { + const children = childrenMap.get(session.id) ?? []; + return { + session, + children: children.map((child) => buildProjectNode(child)), + worktree: getSessionWorktree(session), + }; + }; + + // Find root sessions (no parent or parent not in current project) const roots = sortedProjectSessions.filter((session) => { const parentID = (session as Session & { parentID?: string | null }).parentID; if (!parentID) { @@ -890,70 +847,23 @@ export const SessionSidebar: React.FC = ({ return !sessionMap.has(parentID); }); - roots.forEach((session) => { - const group = ensureGroup(session); - const node = buildProjectNode(session); - group.sessions.push(node); - }); + // Build all session nodes into a single flat group sorted by date + const allSessions: SessionNode[] = roots.map((session) => buildProjectNode(session)); - 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 || ''); - }); + // Return single group with all sessions + return [{ + id: 'all', + label: 'All sessions', + description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, homeDirectory) : null, + isMain: true, + worktree: null, + directory: normalizedProjectRoot, + sessions: allSessions, + }]; }, [homeDirectory, worktreeMetadata] ); - 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); @@ -1038,47 +948,6 @@ export const SessionSidebar: React.FC = ({ }); }, [normalizedProjects, getSessionsForProject, buildGroupedSessions, availableWorktreesByProject]); - // Track when sticky headers become "stuck" using sentinel elements - React.useEffect(() => { - if (!isDesktopRuntime) return; - - const observer = new IntersectionObserver( - (entries) => { - // Ignore intersection events shortly after collapse/expand - if (Date.now() < ignoreIntersectionUntil.current) return; - - 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, rootMargin: '-96px 0px 0px 0px' } - ); - - // Small delay to let DOM settle after collapse/expand - const timeoutId = setTimeout(() => { - headerSentinelRefs.current.forEach((el) => { - if (el) observer.observe(el); - }); - }, 50); - - return () => { - clearTimeout(timeoutId); - observer.disconnect(); - }; - }, [isDesktopRuntime, projectSections, collapsedProjects]); - // Track when project sticky headers become "stuck" React.useEffect(() => { if (!isDesktopRuntime) return; @@ -1287,6 +1156,18 @@ export const SessionSidebar: React.FC = ({ {session.share ? ( ) : null} + {node.worktree ? ( + + + + + + + + {node.worktree.branch || node.worktree.label || 'Worktree'} + + + ) : null} {hasSummary && ((additions ?? 0) !== 0 || (deletions ?? 0) !== 0) ? ( +{Math.max(0, additions ?? 0)} @@ -1627,77 +1508,12 @@ export const SessionSidebar: React.FC = ({ sentinelRef={(el) => { projectHeaderSentinelRefs.current.set(projectKey, el); }} > {!isCollapsed ? ( -
- {section.groups.map((group) => { - const groupKey = `${projectKey}:${group.id}`; - return ( -
- {isDesktopRuntime && ( -
{ headerSentinelRefs.current.set(groupKey, el); }} - data-group-id={groupKey} - className="absolute top-0 h-px w-full pointer-events-none" - aria-hidden="true" - /> - )} - - - {!collapsedGroups.has(groupKey) ? ( -
- {renderGroupSessions(group, groupKey, projectKey)} -
- ) : null} -
- ); - })} +
+ {section.groups[0] ? renderGroupSessions(section.groups[0], `${projectKey}:all`, projectKey) : ( +
+ No sessions yet. +
+ )}
) : null}