import React from 'react'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useAllSessionStatuses, useAllLiveSessions } from '@/sync/sync-context'; import { mergeLiveSessionWithGlobalSession, useGlobalSessionsStore, ensureGlobalSessionsLoaded, refreshGlobalSessions } from '@/stores/useGlobalSessionsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import type { Session } from '@opencode-ai/sdk/v2'; import type { ProjectEntry } from '@/lib/api/types'; import { cn, formatDirectoryName } from '@/lib/utils'; import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { Icon } from "@/components/icon/Icon"; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useNotificationStore } from '@/sync/notification-store'; import { useI18n } from '@/lib/i18n'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; interface MobileSessionStatusBarProps { onSessionSwitch?: (sessionId: string) => void; } interface SessionWithStatus extends Session { _statusType?: 'busy' | 'retry' | 'idle'; _hasRunningChildren?: boolean; _runningChildrenCount?: number; _childIndicators?: Array<{ session: Session; isRunning: boolean }>; } // Cross-project session source. Mirrors the dedicated MobileSessionsSheet: // global sessions cover all directories (even unbootstrapped ones), while the // live aggregate (`useAllLiveSessions`) surfaces fresher data and every // bootstrapped directory. Merging both makes other projects' sessions appear. function useAllProjectSessions(): Session[] { const liveSessions = useAllLiveSessions(); const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); return React.useMemo(() => { const liveById = new Map(liveSessions.map((session) => [session.id, session])); const merged = globalActiveSessions.map((session) => { const liveSession = liveById.get(session.id); return liveSession ? mergeLiveSessionWithGlobalSession(liveSession, session) : session; }); const seen = new Set(merged.map((session) => session.id)); for (const session of liveSessions) { if (!seen.has(session.id)) merged.push(session); } return merged; }, [globalActiveSessions, liveSessions]); } // Max sessions shown per (filtered) project list - a "recent" cap applied // after filtering, so each project view shows at most this many. const MAX_RECENT_SESSIONS = 25; // Normalize path for comparison const normalize = (value: string): string => { if (!value) return ''; const replaced = value.replace(/\\/g, '/'); return replaced === '/' ? '/' : replaced.replace(/\/+$/, ''); }; // A session's directory, mirroring the store's canonical resolution. const sessionDirectory = (session: Session): string => { const record = session as Session & { directory?: string | null; project?: { worktree?: string | null } | null; }; return normalize(record.directory ?? record.project?.worktree ?? ''); }; // Prefix-match used to group a session under a project root or worktree. const pathBelongsToRoot = (path: string, root: string): boolean => { const p = normalize(path); const r = normalize(root); return Boolean(p && r && (p === r || p.startsWith(`${r}/`))); }; function useSessionGrouping( sessions: Session[], sessionStatus: Record | undefined ) { const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount); const parentChildMap = React.useMemo(() => { const map = new Map(); const allIds = new Set(sessions.map((s) => s.id)); sessions.forEach((session) => { const parentID = (session as { parentID?: string }).parentID; if (parentID && allIds.has(parentID)) { map.set(parentID, [...(map.get(parentID) || []), session]); } }); return map; }, [sessions]); const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => { const status = sessionStatus?.[sessionId]; if (status?.type === 'busy' || status?.type === 'retry') return status.type; return 'idle'; }, [sessionStatus]); const hasRunningChildren = React.useCallback((sessionId: string): boolean => { const children = parentChildMap.get(sessionId) || []; return children.some((child) => getStatusType(child.id) !== 'idle'); }, [parentChildMap, getStatusType]); const getRunningChildrenCount = React.useCallback((sessionId: string): number => { const children = parentChildMap.get(sessionId) || []; return children.filter((child) => getStatusType(child.id) !== 'idle').length; }, [parentChildMap, getStatusType]); const getChildIndicators = React.useCallback((sessionId: string): Array<{ session: Session; isRunning: boolean }> => { const children = parentChildMap.get(sessionId) || []; return children .filter((child) => getStatusType(child.id) !== 'idle') .map((child) => ({ session: child, isRunning: true })) .slice(0, 3); }, [parentChildMap, getStatusType]); const processedSessions = React.useMemo(() => { const sessionIds = new Set(sessions.map((s) => s.id)); const topLevel = sessions.filter((session) => { const parentID = (session as { parentID?: string }).parentID; return !parentID || !sessionIds.has(parentID); }); const running: SessionWithStatus[] = []; const viewed: SessionWithStatus[] = []; topLevel.forEach((session) => { const statusType = getStatusType(session.id); const hasRunning = hasRunningChildren(session.id); const attention = (unseenCounts[session.id] ?? 0) > 0; const enriched: SessionWithStatus = { ...session, _statusType: statusType, _hasRunningChildren: hasRunning, _runningChildrenCount: getRunningChildrenCount(session.id), _childIndicators: getChildIndicators(session.id), }; if (statusType !== 'idle' || hasRunning) { running.push(enriched); } else if (attention) { running.push(enriched); } else { viewed.push(enriched); } }); const sortByUpdated = (a: Session, b: Session) => { const aTime = (a as unknown as { time?: { updated?: number } }).time?.updated ?? 0; const bTime = (b as unknown as { time?: { updated?: number } }).time?.updated ?? 0; return bTime - aTime; }; running.sort(sortByUpdated); viewed.sort(sortByUpdated); return [...running, ...viewed]; }, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]); const totalRunning = processedSessions.reduce((sum, s) => { const selfRunning = s._statusType !== 'idle' ? 1 : 0; return sum + selfRunning + (s._runningChildrenCount ?? 0); }, 0); const totalUnread = processedSessions.filter((s) => (unseenCounts[s.id] ?? 0) > 0).length; return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length }; } function useSessionHelpers() { const getSessionTitle = React.useCallback((session: Session): string => { const title = session.title; if (title && title.trim()) return title; return 'New session'; }, []); const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount); const needsAttention = React.useCallback((sessionId: string): boolean => { return (unseenCounts[sessionId] ?? 0) > 0; }, [unseenCounts]); return { getSessionTitle, needsAttention }; } // Per-project status indicators (running / unread) for the filter chips. function useProjectStatus( sessions: Session[], sessionStatus: Record | undefined, currentSessionId: string | null ) { const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory); const notifUnseenCounts = useNotificationStore((s) => s.index.session.unseenCount); return React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => { const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => { const status = sessionStatus?.[sessionId]; if (status?.type === 'busy' || status?.type === 'retry') return status.type; return 'idle'; }; const projectRoot = normalize(projectPath); if (!projectRoot) return { hasRunning: false, hasUnread: false }; const dirs: string[] = [projectRoot]; const worktrees = availableWorktreesByProject.get(projectRoot) ?? []; for (const meta of worktrees) { const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null; if (typeof p === 'string' && p.trim()) { const normalized = normalize(p); if (normalized && normalized !== projectRoot) dirs.push(normalized); } } const seen = new Set(); let hasRunning = false; let hasUnread = false; for (const dir of dirs) { for (const session of getSessionsByDirectory(dir)) { if (!session?.id || seen.has(session.id)) continue; seen.add(session.id); if (getStatusType(session.id) !== 'idle') hasRunning = true; if (session.id !== currentSessionId && (notifUnseenCounts[session.id] ?? 0) > 0) hasUnread = true; if (hasRunning && hasUnread) break; } if (hasRunning && hasUnread) break; } return { hasRunning, hasUnread }; }, [getSessionsByDirectory, availableWorktreesByProject, sessionStatus, notifUnseenCounts, currentSessionId]); } // Resolves the project's root directories (root + known worktrees) for // prefix-matching sessions, mirroring the dedicated MobileSessionsSheet. function useProjectRootsResolver() { const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); return React.useCallback((project: ProjectEntry): string[] => { const projectRoot = normalize(project.path); const roots = [projectRoot]; const worktrees = availableWorktreesByProject.get(projectRoot) ?? []; for (const meta of worktrees) { const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null; if (typeof p === 'string' && p.trim()) { const normalized = normalize(p); if (normalized) roots.push(normalized); } } return roots; }, [availableWorktreesByProject]); } function StatusIndicator({ isRunning, needsAttention }: { isRunning: boolean; needsAttention: boolean }) { if (isRunning) { return ; } if (needsAttention) { return
; } return
; } function RunningIndicator({ count }: { count: number }) { if (count === 0) return null; return ( {count} ); } function UnreadIndicator({ count }: { count: number }) { if (count === 0) return null; return (
{count} ); } // A single session row sized for comfortable touch. function SessionItem({ session, isCurrent, getSessionTitle, onClick, needsAttention, }: { session: SessionWithStatus; isCurrent: boolean; getSessionTitle: (s: Session) => string; onClick: () => void; needsAttention: (sessionId: string) => boolean; }) { const attention = needsAttention(session.id); return ( ); } // A project filter pill sized for touch. Selecting it filters // the session list; it does NOT switch the active project. interface ProjectFilterChipProps { label: string; icon?: string | null; project?: Pick | null; iconOptions?: React.ComponentProps['options']; iconBackground?: string | null; colorVar?: string | null; isActive: boolean; status?: { hasRunning: boolean; hasUnread: boolean }; onClick: () => void; } function ProjectFilterChip({ label, icon, project, iconOptions, iconBackground, colorVar, isActive, status, onClick, }: ProjectFilterChipProps) { const projectIconName = icon ? PROJECT_ICON_MAP[icon] : null; const fallbackIcon = projectIconName ? ( ) : null; return ( ); } // The chip that lives in the composer footer and toggles the slide-up sheet. // This is the only persistent affordance; there is no longer a permanent bar. interface MobileSessionPanelTriggerProps { footerIconButtonClass: string; iconSizeClass: string; } export const MobileSessionPanelTrigger: React.FC = ({ footerIconButtonClass, iconSizeClass, }) => { const { t } = useI18n(); const isMobile = useUIStore((state) => state.isMobile); const open = useUIStore((state) => state.mobileSessionPanelOpen); const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen); // Ensure the cross-project session list is loaded once, so the panel reflects // every project, not just the active directory. React.useEffect(() => { if (isMobile) { void ensureGlobalSessionsLoaded(); } }, [isMobile]); if (!isMobile) { return null; } return ( ); }; export const MobileSessionStatusBar: React.FC = ({ onSessionSwitch, }) => { const { t } = useI18n(); const { currentTheme } = useThemeSystem(); const isMobile = useUIStore((state) => state.isMobile); const sessions = useAllProjectSessions(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const sessionStatus = useAllSessionStatuses(); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const open = useUIStore((state) => state.mobileSessionPanelOpen); const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen); const projects = useProjectsStore((state) => state.projects); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const { sessions: sortedSessions, totalRunning, totalUnread } = useSessionGrouping(sessions, sessionStatus); const { getSessionTitle, needsAttention } = useSessionHelpers(); const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId); const resolveProjectRoots = useProjectRootsResolver(); // Project filter, persisted in the UI store so the choice survives closing and // reopening the sheet. Defaults to "All" so sessions from every project are // visible regardless of which session is currently selected. const filterProjectId = useUIStore((state) => state.mobileSessionFilterProjectId); const setFilterProjectId = useUIStore((state) => state.setMobileSessionFilterProjectId); // Refresh the cross-project session list when the panel opens (mirrors the // dedicated MobileSessionsSheet). The active-directory sync only upserts the // current project's sessions, so other projects need this global load. React.useEffect(() => { if (open) { void refreshGlobalSessions(sessions); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const formatProjectLabel = React.useCallback((project: ProjectEntry): string => { return project.label?.trim() || formatDirectoryName(project.path, homeDirectory) || project.path; }, [homeDirectory]); // Filter sessions by the selected project (root + worktrees), using the // store's canonical directory keying. const filteredSessions = React.useMemo(() => { if (!filterProjectId) return sortedSessions; const project = projects.find((p) => p.id === filterProjectId); if (!project) return sortedSessions; const roots = resolveProjectRoots(project); return sortedSessions.filter((session) => { const dir = sessionDirectory(session); return roots.some((root) => pathBelongsToRoot(dir, root)); }); }, [sortedSessions, filterProjectId, projects, resolveProjectRoots]); // Cap to the most recent N (already sorted running-first, then by updated). const visibleSessions = React.useMemo( () => filteredSessions.slice(0, MAX_RECENT_SESSIONS), [filteredSessions], ); const handleSessionClick = (session: SessionWithStatus) => { setCurrentSession(session.id, sessionDirectory(session) || null); onSessionSwitch?.(session.id); setOpen(false); }; // "+" — start a new session draft. Target the project selected in the filter; // for "All", use the most recently active session's directory, falling back to // the store's own default target when there are no sessions. const handleNewChat = React.useCallback(() => { setOpen(false); if (filterProjectId) { const project = projects.find((p) => p.id === filterProjectId); if (project) { openNewSessionDraft({ selectedProjectId: project.id, directoryOverride: project.path }); return; } } const mostRecent = [...sessions].sort((a, b) => { const aTime = (a as { time?: { updated?: number } }).time?.updated ?? 0; const bTime = (b as { time?: { updated?: number } }).time?.updated ?? 0; return bTime - aTime; })[0]; const directory = mostRecent ? sessionDirectory(mostRecent) : ''; openNewSessionDraft(directory ? { directoryOverride: directory } : undefined); }, [filterProjectId, projects, sessions, openNewSessionDraft, setOpen]); const renderHeader = React.useCallback(() => (

{t('mobile.sessions.search.section.sessions')}

{projects.length > 1 && (
setFilterProjectId(null)} /> {projects.map((project) => ( setFilterProjectId(project.id)} /> ))}
)}
), [t, totalRunning, totalUnread, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]); if (!isMobile) { return null; } return ( setOpen(false)} title={t('mobile.sessions.search.section.sessions')} renderHeader={renderHeader} className="h-[72vh]" contentMaxHeightClassName="max-h-full" >
{visibleSessions.length === 0 ? (
{t('chat.mobileStatus.noSessionsInProject')}
) : ( visibleSessions.map((session) => ( handleSessionClick(session)} needsAttention={needsAttention} /> )) )}
); };