feat: add session switcher to VS Code layout
Open recent sessions from the VS Code header Add compact session switcher mode Limit session prefetch to the active directory
This commit is contained in:
@@ -9,6 +9,8 @@ import { useSessions, useDirectorySync, useSessionMessages, useSessionMessagesRe
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
||||
import { McpDropdown } from '@/components/mcp/McpDropdown';
|
||||
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -422,6 +424,7 @@ export const VSCodeLayout: React.FC = () => {
|
||||
showMcp
|
||||
showContextUsage
|
||||
showRateLimits
|
||||
enableSessionSwitcher
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
@@ -474,6 +477,7 @@ export const VSCodeLayout: React.FC = () => {
|
||||
showMcp
|
||||
showContextUsage
|
||||
showRateLimits
|
||||
enableSessionSwitcher
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
@@ -511,6 +515,7 @@ export const VSCodeLayout: React.FC = () => {
|
||||
showMcp
|
||||
showContextUsage
|
||||
showRateLimits
|
||||
enableSessionSwitcher
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
@@ -535,13 +540,15 @@ interface VSCodeHeaderProps {
|
||||
showMcp?: boolean;
|
||||
showContextUsage?: boolean;
|
||||
showRateLimits?: boolean;
|
||||
enableSessionSwitcher?: boolean;
|
||||
}
|
||||
|
||||
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits }) => {
|
||||
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits, enableSessionSwitcher }) => {
|
||||
const { t } = useI18n();
|
||||
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const currentSessionMessages = useSessionMessages(currentSessionId ?? '');
|
||||
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
|
||||
const quotaResults = useQuotaStore((state) => state.results);
|
||||
@@ -697,7 +704,19 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
||||
<Icon name="arrow-left" className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<h1 className="text-sm font-medium truncate flex-1" title={title}>{title}</h1>
|
||||
{enableSessionSwitcher ? (
|
||||
<SessionSwitcherDropdown variant="compact" scopeProjectId={activeProjectId}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('sessions.switcher.openAria')}
|
||||
className="flex min-w-0 flex-1 items-center 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="text-sm font-medium truncate" title={title}>{title}</span>
|
||||
</button>
|
||||
</SessionSwitcherDropdown>
|
||||
) : (
|
||||
<h1 className="text-sm font-medium truncate flex-1" title={title}>{title}</h1>
|
||||
)}
|
||||
{onNewSession && (
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
|
||||
@@ -11,21 +11,33 @@ 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 { useSync } from '@/sync/use-sync';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
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 { formatSessionCompactDateLabel, normalizePath, 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 SwitcherVariant = 'default' | 'compact';
|
||||
|
||||
type SessionSwitcherDropdownProps = {
|
||||
children: React.ReactNode;
|
||||
variant?: SwitcherVariant;
|
||||
scopeProjectId?: string | null;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
};
|
||||
|
||||
export function SessionSwitcherDropdown({ children }: SessionSwitcherDropdownProps): React.ReactElement {
|
||||
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);
|
||||
|
||||
@@ -33,20 +45,80 @@ export function SessionSwitcherDropdown({ children }: SessionSwitcherDropdownPro
|
||||
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false}>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
align={align}
|
||||
sideOffset={6}
|
||||
className="w-[360px] max-w-[calc(100vw-32px)] overflow-hidden p-1"
|
||||
className={cn(
|
||||
'overflow-hidden p-1 max-w-[calc(100vw-32px)]',
|
||||
variant === 'compact' ? 'w-[280px]' : 'w-[360px]',
|
||||
)}
|
||||
>
|
||||
{isOpen ? <SwitcherContent onSelect={() => setOpen(false)} /> : null}
|
||||
{isOpen ? (
|
||||
<SwitcherContent
|
||||
onSelect={() => setOpen(false)}
|
||||
variant={variant}
|
||||
scopeProjectId={scopeProjectId}
|
||||
/>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitcherContent({ onSelect }: { onSelect: () => void }): React.ReactElement {
|
||||
const items = useSwitcherItems(true);
|
||||
type SwitcherContentProps = {
|
||||
onSelect: () => void;
|
||||
variant: SwitcherVariant;
|
||||
scopeProjectId: string | null;
|
||||
};
|
||||
|
||||
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
|
||||
const items = useSwitcherItems(true, { scopeProjectId });
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const ensureSessionRenderable = useSync().ensureSessionRenderable;
|
||||
const { t } = useI18n();
|
||||
|
||||
const prefetchedRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
// Prefetch only sessions that live in the currently mounted sync directory.
|
||||
// ensureSessionRenderable closes over SyncProvider's directory, so a cross-directory
|
||||
// call would hit the wrong backend context. Switching to such a session re-mounts
|
||||
// SyncProvider anyway, so the prefetch wouldn't have survived either way.
|
||||
const normalizedCurrent = normalizePath(currentDirectory);
|
||||
const queue: string[] = [];
|
||||
for (const item of items) {
|
||||
const id = item.node.session.id;
|
||||
if (id === currentSessionId) continue;
|
||||
if (prefetchedRef.current.has(id)) continue;
|
||||
const sessionDir = normalizePath(resolveGlobalSessionDirectory(item.node.session));
|
||||
if (!sessionDir || sessionDir !== normalizedCurrent) continue;
|
||||
prefetchedRef.current.add(id);
|
||||
queue.push(id);
|
||||
}
|
||||
if (queue.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
const CONCURRENCY = 2;
|
||||
const runNext = async (): Promise<void> => {
|
||||
while (!cancelled) {
|
||||
const id = queue.shift();
|
||||
if (!id) return;
|
||||
try {
|
||||
await ensureSessionRenderable(id);
|
||||
} catch {
|
||||
// best-effort prefetch; ignore errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, () => runNext());
|
||||
void Promise.all(workers);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentDirectory, currentSessionId, ensureSessionRenderable, items]);
|
||||
|
||||
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
|
||||
const toggleParent = React.useCallback((sessionId: string) => {
|
||||
setExpandedParents((prev) => {
|
||||
@@ -73,6 +145,7 @@ function SwitcherContent({ onSelect }: { onSelect: () => void }): React.ReactEle
|
||||
key={item.node.session.id}
|
||||
item={item}
|
||||
depth={0}
|
||||
variant={variant}
|
||||
expandedParents={expandedParents}
|
||||
toggleParent={toggleParent}
|
||||
closeDropdown={onSelect}
|
||||
@@ -87,12 +160,13 @@ function SwitcherContent({ onSelect }: { onSelect: () => void }): React.ReactEle
|
||||
type SwitcherNodeProps = {
|
||||
item: { node: SessionNode; projectId: string | null; groupDirectory: string | null; secondaryMeta: SecondaryMeta };
|
||||
depth: number;
|
||||
variant: SwitcherVariant;
|
||||
expandedParents: Set<string>;
|
||||
toggleParent: (sessionId: string) => void;
|
||||
closeDropdown: () => void;
|
||||
};
|
||||
|
||||
function SwitcherNode({ item, depth, expandedParents, toggleParent, closeDropdown }: SwitcherNodeProps): React.ReactElement {
|
||||
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;
|
||||
@@ -103,6 +177,7 @@ function SwitcherNode({ item, depth, expandedParents, toggleParent, closeDropdow
|
||||
<SwitcherRow
|
||||
session={session}
|
||||
depth={depth}
|
||||
variant={variant}
|
||||
secondaryMeta={secondaryMeta}
|
||||
hasChildren={hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
@@ -115,6 +190,7 @@ function SwitcherNode({ item, depth, expandedParents, toggleParent, closeDropdow
|
||||
key={childNode.session.id}
|
||||
item={{ node: childNode, projectId: item.projectId, groupDirectory: item.groupDirectory, secondaryMeta }}
|
||||
depth={depth + 1}
|
||||
variant={variant}
|
||||
expandedParents={expandedParents}
|
||||
toggleParent={toggleParent}
|
||||
closeDropdown={closeDropdown}
|
||||
@@ -128,6 +204,7 @@ function SwitcherNode({ item, depth, expandedParents, toggleParent, closeDropdow
|
||||
type SwitcherRowProps = {
|
||||
session: Session;
|
||||
depth: number;
|
||||
variant: SwitcherVariant;
|
||||
secondaryMeta: SecondaryMeta;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
@@ -135,7 +212,7 @@ type SwitcherRowProps = {
|
||||
closeDropdown: () => void;
|
||||
};
|
||||
|
||||
function SwitcherRow({ session, depth, secondaryMeta, hasChildren, isExpanded, onToggleExpand, closeDropdown }: SwitcherRowProps): React.ReactElement {
|
||||
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);
|
||||
@@ -188,14 +265,8 @@ function SwitcherRow({ session, depth, secondaryMeta, hasChildren, isExpanded, o
|
||||
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 ? (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{variant === 'compact' && hasChildren ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
@@ -205,28 +276,54 @@ function SwitcherRow({ session, depth, secondaryMeta, hasChildren, isExpanded, o
|
||||
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"
|
||||
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 ? <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>
|
||||
{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" />}
|
||||
</span>
|
||||
) : null}
|
||||
<span className={cn('truncate text-[14px] font-normal leading-tight', isActive ? 'text-primary' : 'text-foreground')}>
|
||||
{sessionTitle}
|
||||
</span>
|
||||
</div>
|
||||
{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}
|
||||
{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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isStreaming || showUnreadDot ? (
|
||||
|
||||
@@ -21,6 +21,10 @@ export type SwitcherItem = {
|
||||
|
||||
const MAX_PARENT_SESSIONS = 7;
|
||||
|
||||
type SwitcherItemsOptions = {
|
||||
scopeProjectId?: string | null;
|
||||
};
|
||||
|
||||
const normalize = (value: string | null | undefined): string | null => {
|
||||
if (!value) return null;
|
||||
const replaced = value.replace(/\\/g, '/');
|
||||
@@ -36,7 +40,8 @@ const formatProjectLabel = (project: { label?: string | null; path: string } | n
|
||||
return segments[segments.length - 1] ?? null;
|
||||
};
|
||||
|
||||
export const useSwitcherItems = (enabled: boolean): SwitcherItem[] => {
|
||||
export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => {
|
||||
const { scopeProjectId = null } = options;
|
||||
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
||||
@@ -84,6 +89,11 @@ export const useSwitcherItems = (enabled: boolean): SwitcherItem[] => {
|
||||
const parents = activeSessions
|
||||
.filter((session) => !session.time?.archived)
|
||||
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
|
||||
.filter((session) => {
|
||||
if (!scopeProjectId) return true;
|
||||
const directory = resolveGlobalSessionDirectory(session);
|
||||
return findProjectForDirectory(directory)?.id === scopeProjectId;
|
||||
})
|
||||
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds))
|
||||
.slice(0, MAX_PARENT_SESSIONS);
|
||||
|
||||
@@ -111,7 +121,7 @@ export const useSwitcherItems = (enabled: boolean): SwitcherItem[] => {
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, pinnedSessionIds]);
|
||||
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, pinnedSessionIds, scopeProjectId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !gitApi) return;
|
||||
|
||||
Reference in New Issue
Block a user