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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user