Files
openchamber/packages/ui/src/components/session/SessionSwitcherDropdown.tsx
T
Bohdan Triapitsyn 471ae69b1a refactor(ui): full-width header with framed chat shell
Header now spans the full window width above the [sidebar | chat | right-sidebar] row instead of nesting inside the central column. The chat area becomes a self-contained framed window with its own border and rounded corners on all four sides, and sidebars sit flush against the header sharing its bg-sidebar so the seam is invisible.

Removed the duplicated shell controls the old layout needed to fake header-height inside sidebars: portal host on RightSidebar, paddingTop reservation, top drag overlay, duplicated layout-left / chat-new buttons in SidebarHeader, the showDesktopSidebarChrome block in SessionSidebar, and the conditional traffic-lights inset on the desktop header. Mac WCO inset now lives only on the header.

Moved the new-session action into the SessionSwitcher dropdown as its first item, removed the standalone chat-new button from the header, relocated scheduled-tasks into the left action group of the sidebar header, and bumped ContextPanel tab strip to h-10 to balance the more prominent header.
2026-05-21 15:45:44 +03:00

369 lines
14 KiB
TypeScript

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 { 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, 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,
variant = 'default',
scopeProjectId = null,
align = 'start',
}: 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={align}
sideOffset={6}
className={cn(
'overflow-hidden p-1 max-w-[calc(100vw-32px)]',
variant === 'compact' ? 'w-[280px]' : 'w-[360px]',
)}
>
{isOpen ? (
<SwitcherContent
onSelect={() => setOpen(false)}
variant={variant}
scopeProjectId={scopeProjectId}
/>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
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 openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const { t } = useI18n();
const handleNewSession = React.useCallback(() => {
setActiveMainTab('chat');
onSelect();
openNewSessionDraft();
}, [onSelect, openNewSessionDraft, setActiveMainTab]);
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) => {
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">
<div className="space-y-0.5">
<BaseMenu.Item
onClick={handleNewSession}
className={cn(
'group relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
'data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover',
)}
>
<Icon name="chat-new" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="truncate text-[14px] font-normal leading-tight text-foreground">
{t('sessions.sidebar.header.actions.newSession')}
</span>
</BaseMenu.Item>
{items.length === 0 ? (
<div className="px-3 py-4 text-center typography-meta text-muted-foreground">
{t('sessions.switcher.empty')}
</div>
) : (
items.map((item) => (
<SwitcherNode
key={item.node.session.id}
item={item}
depth={0}
variant={variant}
expandedParents={expandedParents}
toggleParent={toggleParent}
closeDropdown={onSelect}
/>
))
)}
</div>
</div>
);
}
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, variant, 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}
variant={variant}
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}
variant={variant}
expandedParents={expandedParents}
toggleParent={toggleParent}
closeDropdown={closeDropdown}
/>
))
: null}
</>
);
}
type SwitcherRowProps = {
session: Session;
depth: number;
variant: SwitcherVariant;
secondaryMeta: SecondaryMeta;
hasChildren: boolean;
isExpanded: boolean;
onToggleExpand?: () => void;
closeDropdown: () => void;
};
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);
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">
<div className="flex min-w-0 items-center gap-1.5">
{variant === 'compact' && hasChildren ? (
<span
role="button"
tabIndex={-1}
data-switcher-expand
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onToggleExpand?.();
}}
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.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 ? (
<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>
);
}