import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { SessionActivityDuration } from '@/components/session/SessionActivityDuration'; import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils'; import { useSwitcherItems } from '@/components/session/sidebar/shell/useSwitcherItems'; import { useTabletLayout } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionUnseenCount } from '@/sync/notification-store'; import { useHasSessionActivityDuration } from '@/sync/session-activity-timing'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionStatus } from '@/sync/sync-context'; const RECENT_SESSIONS_LIMIT = 10; /** Matches the metadata popover's width so both header dropdowns read as a pair. */ const TABLET_POPOVER_WIDTH = 380; const getSessionTitle = (session: Session, fallback: string): string => session.title?.trim() || fallback; /** One switcher row: live status (busy spinner / attention dot), title, "project · branch", compact time. Mirrors the desktop SessionSwitcherDropdown indicator conventions; no subsession chevrons on mobile by design. */ const SwitcherRow: React.FC<{ session: Session; meta: string; active: boolean; onSelect: () => void; }> = ({ session, meta, active, onSelect }) => { const { t } = useI18n(); const status = useGlobalSessionStatus(session.id); const unseenCount = useSessionUnseenCount(session.id); const statusType = status?.type ?? 'idle'; const isStreaming = statusType === 'busy' || statusType === 'retry'; const showUnreadDot = !isStreaming && unseenCount > 0 && !active; const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming); const showActivityDuration = (isStreaming || showUnreadDot) && hasActivityDuration; const timeLabel = formatSessionCompactDateLabel(session.time?.updated ?? session.time?.created ?? 0); return ( ); }; /** Recent-sessions popover under the mobile header, opened by tapping the session title. Same visual family as the metadata/usage overlay. */ export const MobileSessionSwitcher: React.FC<{ open: boolean; onClose: () => void; anchorRef: React.RefObject; }> = ({ open, onClose, anchorRef }) => { const { t } = useI18n(); const panelRef = React.useRef(null); const [shouldRender, setShouldRender] = React.useState(open); const [isExiting, setIsExiting] = React.useState(false); // Tablet: a phone-width sheet stretched across the whole chat column looks // broken — anchor a popover under the title instead. Mirror image of the // metadata/usage popover, which anchors to the ring on the right. const { enabled: isTabletLayout } = useTabletLayout(); const wrapperRef = React.useRef(null); const [anchorLeft, setAnchorLeft] = React.useState(null); // The shell has transformed ancestors, so the fixed wrapper's containing // block is the chat column, NOT the viewport — anchor in the wrapper's own // coordinate space (see SessionMetadataOverlay for the same reasoning). React.useLayoutEffect(() => { if (!open || !isTabletLayout || !shouldRender) return; const compute = () => { const anchorRect = anchorRef.current?.getBoundingClientRect(); const wrapperRect = wrapperRef.current?.getBoundingClientRect(); if (!anchorRect || !wrapperRect) { setAnchorLeft(null); return; } const relativeLeft = anchorRect.left - wrapperRect.left; setAnchorLeft(Math.min( Math.max(relativeLeft, 8), Math.max(8, wrapperRect.width - TABLET_POPOVER_WIDTH - 8), )); }; compute(); // Re-anchor if the chat column shifts while the popover is open (sidebar // toggle/resize, orientation change) — the header buttons move with it. const wrapper = wrapperRef.current; if (typeof ResizeObserver === 'undefined' || !wrapper) return; const observer = new ResizeObserver(compute); observer.observe(wrapper); return () => observer.disconnect(); }, [anchorRef, isTabletLayout, open, shouldRender]); const isPopover = isTabletLayout && anchorLeft !== null; const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const items = useSwitcherItems(open || shouldRender, { maxParents: RECENT_SESSIONS_LIMIT }); React.useEffect(() => { if (open) { // Fresh authoritative snapshot on open — updated stamps re-sort recents // (see raiseSessionOrderingBaselines) while the cached list shows first. void refreshGlobalSessions(); setShouldRender(true); setIsExiting(false); return; } if (!shouldRender) return; setIsExiting(true); const timeoutId = window.setTimeout(() => { setShouldRender(false); setIsExiting(false); }, 140); return () => window.clearTimeout(timeoutId); }, [open, shouldRender]); React.useEffect(() => { if (!open) return; const handleKey = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); }; document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, [onClose, open]); React.useEffect(() => { if (!open) return; const closeIfOutside = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Node)) { onClose(); return; } if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return; onClose(); }; document.addEventListener('pointerdown', closeIfOutside, true); return () => document.removeEventListener('pointerdown', closeIfOutside, true); }, [anchorRef, onClose, open]); const handleSelect = React.useCallback((session: Session) => { void setCurrentSession(session.id, resolveGlobalSessionDirectory(session)); onClose(); }, [onClose, setCurrentSession]); if (!shouldRender) return null; return (
{items.length === 0 ? (

{t('sessions.switcher.empty')}

) : ( items.map((item) => { const session = item.node.session; const meta = [item.secondaryMeta?.projectLabel, item.secondaryMeta?.branchLabel] .filter(Boolean) .join(' · '); return ( { if (item.projectId) setActiveProjectIdOnly(item.projectId); handleSelect(session); }} /> ); }) )}
); };