feat: add session switcher dropdown in header

Open recent sessions from chat headers
Support session switching in mini chat
Share pinned and active session state
This commit is contained in:
Bohdan Triapitsyn
2026-05-15 18:16:09 +03:00
parent 81cb31f1fd
commit b6149614bd
17 changed files with 593 additions and 93 deletions
+17 -12
View File
@@ -63,6 +63,7 @@ import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
import { forceKillTerminal } from '@/lib/terminalApi';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop';
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
import { resolveSessionDiffStats } from '@/components/session/sidebar/utils';
@@ -1892,13 +1893,17 @@ export const Header: React.FC<HeaderProps> = ({
className="mr-2"
/>
)}
{!isNewSessionDraftOpen ? (
<div className="mr-3 min-w-0">
<div className="truncate pl-1 typography-ui-label text-[14px] font-normal leading-tight text-foreground">
{currentSessionTitle}
</div>
{(activeProjectLabel || currentBranchLabel || hasNonZeroSessionChanges) ? (
<div className="flex min-w-0 items-center gap-1.5 truncate pl-1 typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
<SessionSwitcherDropdown>
<button
type="button"
aria-label={t('sessions.switcher.openAria')}
className="app-region-no-drag mr-3 flex min-w-0 flex-col items-start rounded-md px-1 py-0.5 -my-0.5 text-left transition-colors hover:bg-interactive-hover/60 focus-visible:outline-none focus-visible:bg-interactive-hover/60"
>
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
{isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle}
</span>
{(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && (hasNonZeroSessionChanges || worktreeBadgeKind))) ? (
<span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
{activeProjectLabel ? <span className="truncate">{activeProjectLabel}</span> : null}
{currentBranchLabel ? (
<span className="inline-flex min-w-0 items-center gap-0.5">
@@ -1906,14 +1911,14 @@ export const Header: React.FC<HeaderProps> = ({
<span className="truncate">{currentBranchLabel}</span>
</span>
) : null}
{hasNonZeroSessionChanges ? (
{!isNewSessionDraftOpen && hasNonZeroSessionChanges ? (
<span className="inline-flex flex-shrink-0 items-center gap-0 text-[0.92em]">
<span className="text-status-success/80">+{currentSessionChanges.additions}</span>
<span className="text-muted-foreground/60">/</span>
<span className="text-status-error/65">-{currentSessionChanges.deletions}</span>
</span>
) : null}
{worktreeBadgeKind ? (
{!isNewSessionDraftOpen && worktreeBadgeKind ? (
<span className={cn(
"inline-flex min-w-0 items-center gap-0.5",
worktreeBadgeKind === 'attention' || worktreeBadgeKind === 'invalid' || worktreeBadgeKind === 'missing' ? 'text-status-warning' : 'text-muted-foreground/60'
@@ -1922,10 +1927,10 @@ export const Header: React.FC<HeaderProps> = ({
<span className="truncate">{worktreeBadge}</span>
</span>
) : null}
</div>
</span>
) : null}
</div>
) : null}
</button>
</SessionSwitcherDropdown>
{tabs.length > 0 && (
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)]/50 p-1">
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { ChatContainer } from '@/components/chat/ChatContainer';
import { ChatSurfaceProvider } from '@/components/chat/ChatSurfaceContext';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { invokeDesktop, isElectronShell } from '@/lib/desktop';
@@ -129,7 +130,8 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
return segments.at(-1) ?? project.path;
}, [activeProject, directoryLabel, pathMatchedProject]);
const gitBranchForDirectory = useGitBranchLabel(openDirectory || null);
const branchLabel = gitBranchForDirectory || worktreeMetadataBranch || sessionWorktreeMetadata?.branch?.trim() || worktreeAttachment?.branch?.trim() || catalogWorktreeBranch;
const rawBranchLabel = gitBranchForDirectory || worktreeMetadataBranch || sessionWorktreeMetadata?.branch?.trim() || worktreeAttachment?.branch?.trim() || catalogWorktreeBranch;
const branchLabel = rawBranchLabel && rawBranchLabel !== 'HEAD' ? rawBranchLabel : null;
const diffStats = React.useMemo(() => {
return resolveSessionDiffStats(session?.summary as Parameters<typeof resolveSessionDiffStats>[0]);
}, [session?.summary]);
@@ -260,25 +262,35 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
)}
style={dragRegionStyle}
>
<div className="min-w-0 flex-1">
<div className="truncate pl-1 typography-ui-label text-[14px] font-normal leading-tight text-foreground">{title}</div>
<div className="flex min-w-0 items-center gap-1.5 truncate pl-1 typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
<span className="truncate">{projectLabel}</span>
{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}
{hasChanges ? (
<span className="inline-flex flex-shrink-0 items-center gap-0 text-[0.92em]">
<span className="text-status-success/80">+{changes.additions}</span>
<span className="text-muted-foreground/60">/</span>
<span className="text-status-error/65">-{changes.deletions}</span>
</span>
) : null}
</div>
</div>
<SessionSwitcherDropdown>
<button
type="button"
aria-label={t('sessions.switcher.openAria')}
style={noDragRegionStyle}
className="flex min-w-0 max-w-full flex-col items-start rounded-md px-1 py-0.5 text-left transition-colors hover:bg-interactive-hover/60 focus-visible:outline-none focus-visible:bg-interactive-hover/60"
>
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
{title}
</span>
<span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
<span className="truncate">{projectLabel}</span>
{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}
{hasChanges ? (
<span className="inline-flex flex-shrink-0 items-center gap-0 text-[0.92em]">
<span className="text-status-success/80">+{changes.additions}</span>
<span className="text-muted-foreground/60">/</span>
<span className="text-status-error/65">-{changes.deletions}</span>
</span>
) : null}
</span>
</button>
</SessionSwitcherDropdown>
<div className="min-w-0 flex-1" />
{stableContextUsage && stableContextUsage.totalTokens > 0 ? (
<ContextUsageDisplay
totalTokens={stableContextUsage.totalTokens}
@@ -62,14 +62,11 @@ import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore'
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { type SessionGroup, type SessionNode } from './sidebar/types';
import {
type ActiveNowEntry,
addActiveNowSession,
deriveActiveNowSessions,
deriveLiveActiveNowSessions,
persistActiveNowEntries,
pruneActiveNowEntries,
readActiveNowEntries,
} from './sidebar/activitySections';
import { useActiveNowStore } from '@/stores/useActiveNowStore';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import {
compareSessionsByPinnedAndTime,
formatProjectLabel,
@@ -170,7 +167,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
() => new Map(),
);
const safeStorage = React.useMemo(() => getSafeStorage(), []);
const [activeNowEntries, setActiveNowEntries] = React.useState<ActiveNowEntry[]>(() => readActiveNowEntries(safeStorage));
const activeNowEntries = useActiveNowStore((state) => state.entries);
const addActiveNowSessionToStore = useActiveNowStore((state) => state.addSession);
const pruneActiveNowEntriesInStore = useActiveNowStore((state) => state.prune);
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
@@ -183,18 +182,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState<DeleteSessionConfirmState>(null);
const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirmState>(null);
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
const [pinnedSessionIds, setPinnedSessionIds] = React.useState<Set<string>>(() => {
try {
const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY);
if (!raw) {
return new Set();
}
const parsed = JSON.parse(raw) as string[];
return new Set(Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []);
} catch {
return new Set();
}
});
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
const setPinnedSessionIds = useSessionPinnedStore((state) => state.setIds);
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
try {
const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY);
@@ -547,18 +537,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setCollapsedProjects,
});
const togglePinnedSession = React.useCallback((sessionId: string) => {
setPinnedSessionIds((prev) => {
const next = new Set(prev);
if (next.has(sessionId)) {
next.delete(sessionId);
} else {
next.add(sessionId);
}
return next;
});
}, []);
const sortedSessions = React.useMemo(() => {
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds]);
@@ -969,9 +947,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|| section.project.normalizedPath,
);
section.groups.forEach((group) => {
const secondaryMeta = group.branch && group.branch !== projectLabel
? { projectLabel, branchLabel: group.branch }
: { projectLabel, branchLabel: null };
const branchCandidate = group.branch && group.branch !== 'HEAD' && group.branch !== projectLabel
? group.branch
: null;
const secondaryMeta = { projectLabel, branchLabel: branchCandidate };
const visit = (nodes: SessionNode[]) => {
nodes.forEach((node) => {
@@ -1025,15 +1004,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return;
}
setActiveNowEntries((prev) => {
const next = liveActiveSessions.reduce((entries, session) => addActiveNowSession(entries, session.id), prev);
if (next === prev) {
return prev;
}
persistActiveNowEntries(safeStorage, next);
return next;
});
}, [liveActiveSessions, safeStorage, showRecentSection]);
liveActiveSessions.forEach((session) => addActiveNowSessionToStore(session.id));
}, [addActiveNowSessionToStore, liveActiveSessions, showRecentSection]);
React.useEffect(() => {
if (!showRecentSection) {
@@ -1045,14 +1017,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
allKnownSessionsById.set(session.id, session);
});
const pruned = pruneActiveNowEntries(activeNowEntries, allKnownSessionsById);
if (pruned.length === activeNowEntries.length && pruned.every((entry, index) => entry.sessionId === activeNowEntries[index]?.sessionId)) {
return;
}
setActiveNowEntries(pruned);
persistActiveNowEntries(safeStorage, pruned);
}, [activeNowEntries, archivedSessions, safeStorage, sessions, showRecentSection]);
pruneActiveNowEntriesInStore(allKnownSessionsById);
}, [archivedSessions, pruneActiveNowEntriesInStore, sessions, showRecentSection]);
// Prefetch is wired below, after recentSessionIds is computed.
@@ -1077,6 +1043,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
];
}, [activeNowSessions, sessionSidebarMetaById, showRecentSection, t]);
const recentSessionIds = React.useMemo(() => {
return new Set(activeNowSessions.map((session) => session.id));
}, [activeNowSessions]);
@@ -0,0 +1,251 @@
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 { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/hooks/useSwitcherItems';
import { useUIStore } from '@/stores/useUIStore';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { formatSessionCompactDateLabel, resolveSessionDiffStats } from './sidebar/utils';
import type { SessionNode, SessionSummaryMeta } from './sidebar/types';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type SecondaryMeta = SwitcherItem['secondaryMeta'];
type SessionSwitcherDropdownProps = {
children: React.ReactNode;
};
export function SessionSwitcherDropdown({ children }: SessionSwitcherDropdownProps): React.ReactElement {
const isOpen = useUIStore((state) => state.isSessionDropdownOpen);
const setOpen = useUIStore((state) => state.setSessionDropdownOpen);
return (
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="w-[360px] max-w-[calc(100vw-32px)] overflow-hidden p-1"
>
{isOpen ? <SwitcherContent onSelect={() => setOpen(false)} /> : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
function SwitcherContent({ onSelect }: { onSelect: () => void }): React.ReactElement {
const items = useSwitcherItems(true);
const { t } = useI18n();
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
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;
});
}, []);
return (
<div className="max-h-[60vh] overflow-y-auto">
{items.length === 0 ? (
<div className="px-3 py-4 text-center typography-meta text-muted-foreground">
{t('sessions.switcher.empty')}
</div>
) : (
<div className="space-y-0.5">
{items.map((item) => (
<SwitcherNode
key={item.node.session.id}
item={item}
depth={0}
expandedParents={expandedParents}
toggleParent={toggleParent}
closeDropdown={onSelect}
/>
))}
</div>
)}
</div>
);
}
type SwitcherNodeProps = {
item: { node: SessionNode; projectId: string | null; groupDirectory: string | null; secondaryMeta: SecondaryMeta };
depth: number;
expandedParents: Set<string>;
toggleParent: (sessionId: string) => void;
closeDropdown: () => void;
};
function SwitcherNode({ item, depth, 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 (
<>
<SwitcherRow
session={session}
depth={depth}
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}
expandedParents={expandedParents}
toggleParent={toggleParent}
closeDropdown={closeDropdown}
/>
))
: null}
</>
);
}
type SwitcherRowProps = {
session: Session;
depth: number;
secondaryMeta: SecondaryMeta;
hasChildren: boolean;
isExpanded: boolean;
onToggleExpand?: () => void;
closeDropdown: () => void;
};
function SwitcherRow({ session, depth, 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 summary = session.summary as SessionSummaryMeta | undefined;
const diffStats = resolveSessionDiffStats(summary);
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"
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">
<span className={cn('truncate text-[14px] font-normal leading-tight', isActive ? 'text-primary' : 'text-foreground')}>
{sessionTitle}
</span>
<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}
{diffStats ? (
<span className="inline-flex flex-shrink-0 items-center gap-0 text-[0.92em]">
<span className="text-status-success/80">+{diffStats.additions}</span>
<span className="text-muted-foreground/60">/</span>
<span className="text-status-error/65">-{diffStats.deletions}</span>
</span>
) : null}
</div>
</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>
);
}
@@ -39,7 +39,6 @@ export const useSidebarPersistence = (args: Args) => {
safeStorage,
keys,
sessions,
pinnedSessionIds,
setPinnedSessionIds,
groupOrderByProject,
activeSessionByProject,
@@ -135,14 +134,6 @@ export const useSidebarPersistence = (args: Args) => {
});
}, [hasLoadedGlobalSessions, sessions, setPinnedSessionIds]);
React.useEffect(() => {
try {
safeStorage.setItem(keys.sessionPinned, JSON.stringify(Array.from(pinnedSessionIds)));
} catch {
// ignored
}
}, [keys.sessionPinned, pinnedSessionIds, safeStorage]);
React.useEffect(() => {
try {
const serialized = Object.fromEntries(groupOrderByProject.entries());
@@ -0,0 +1,128 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import { useGitAllBranches, useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { SessionNode } from '../types';
import { compareSessionsByPinnedAndTime } from '../utils';
export type SwitcherItem = {
node: SessionNode;
projectId: string | null;
groupDirectory: string | null;
secondaryMeta: {
projectLabel?: string | null;
branchLabel?: string | null;
} | null;
};
const MAX_PARENT_SESSIONS = 7;
const normalize = (value: string | null | undefined): string | null => {
if (!value) return null;
const replaced = value.replace(/\\/g, '/');
if (replaced === '/') return '/';
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
};
const formatProjectLabel = (project: { label?: string | null; path: string } | null): string | null => {
if (!project) return null;
const trimmed = project.label?.trim();
if (trimmed) return trimmed;
const segments = project.path.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] ?? null;
};
export const useSwitcherItems = (enabled: boolean): SwitcherItem[] => {
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
const projects = useProjectsStore((state) => state.projects);
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
const branchesByDirectory = useGitAllBranches();
const ensureGitStatus = useGitStore((state) => state.ensureStatus);
const { git: gitApi } = useRuntimeAPIs();
const normalizedProjects = React.useMemo(
() => projects
.map((project) => ({ ...project, normalizedPath: normalize(project.path) }))
.filter((project) => project.normalizedPath),
[projects],
);
const findProjectForDirectory = React.useCallback(
(directory: string | null) => {
if (!directory) return null;
const matches = normalizedProjects
.filter((project) => directory === project.normalizedPath || directory.startsWith(`${project.normalizedPath}/`))
.sort((a, b) => (b.normalizedPath?.length ?? 0) - (a.normalizedPath?.length ?? 0));
return matches[0] ?? null;
},
[normalizedProjects],
);
const items = React.useMemo<SwitcherItem[]>(() => {
if (!enabled) return [];
const childrenByParent = new Map<string, Session[]>();
for (const session of activeSessions) {
const parentId = (session as Session & { parentID?: string | null }).parentID;
if (!parentId) continue;
if (session.time?.archived) continue;
const bucket = childrenByParent.get(parentId);
if (bucket) {
bucket.push(session);
} else {
childrenByParent.set(parentId, [session]);
}
}
childrenByParent.forEach((list) => {
list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
});
const parents = activeSessions
.filter((session) => !session.time?.archived)
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds))
.slice(0, MAX_PARENT_SESSIONS);
const buildNode = (session: Session): SessionNode => {
const childSessions = childrenByParent.get(session.id) ?? [];
return {
session,
children: childSessions.map((child) => buildNode(child)),
worktree: null,
};
};
return parents.map((session) => {
const directory = resolveGlobalSessionDirectory(session);
const matchedProject = findProjectForDirectory(directory);
const projectLabel = formatProjectLabel(matchedProject);
const branchLabel = directory ? branchesByDirectory.get(directory) ?? null : null;
return {
node: buildNode(session),
projectId: matchedProject?.id ?? null,
groupDirectory: directory,
secondaryMeta: {
projectLabel,
branchLabel: branchLabel && branchLabel !== projectLabel ? branchLabel : null,
},
};
});
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, pinnedSessionIds]);
React.useEffect(() => {
if (!enabled || !gitApi) return;
const seen = new Set<string>();
for (const item of items) {
const dir = item.groupDirectory;
if (!dir || seen.has(dir)) continue;
seen.add(dir);
void ensureGitStatus(dir, gitApi).catch(() => {});
}
}, [enabled, ensureGitStatus, gitApi, items]);
return items;
};