import React from 'react'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessions, useAllSessionStatuses } from '@/sync/sync-context'; import { useConfigStore } from '@/stores/useConfigStore'; 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 { getAgentColor } from '@/lib/agentColors'; import { RiLoader4Line, RiAddLine, RiDragMove2Line, RiDeleteBinLine, RiEditLine, RiArrowUpLine, RiArrowDownLine, } from '@remixicon/react'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { sessionEvents } from '@/lib/sessionEvents'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog'; import { useDrawerSwipe } from '@/hooks/useDrawerSwipe'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useNotificationStore } from '@/sync/notification-store'; import { useI18n } from '@/lib/i18n'; interface MobileSessionStatusBarProps { onSessionSwitch?: (sessionId: string) => void; } interface SessionWithStatus extends Session { _statusType?: 'busy' | 'retry' | 'idle'; _hasRunningChildren?: boolean; _runningChildrenCount?: number; _childIndicators?: Array<{ session: Session; isRunning: boolean }>; } // Normalize path for comparison const normalize = (value: string): string => { if (!value) return ''; const replaced = value.replace(/\\/g, '/'); return replaced === '/' ? '/' : replaced.replace(/\/+$/, ''); }; 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( agents: Array<{ name: string }>, sessionStatus: Record | undefined ) { const getSessionAgentName = React.useCallback((session: Session): string => { const agent = (session as { agent?: string }).agent; if (agent) return agent; const sessionAgentSelection = useSelectionStore.getState().getSessionAgentSelection(session.id); if (sessionAgentSelection) return sessionAgentSelection; return agents[0]?.name ?? 'agent'; }, [agents]); const getSessionTitle = React.useCallback((session: Session): string => { const title = session.title; if (title && title.trim()) return title; return 'New session'; }, []); const isRunning = React.useCallback((sessionId: string): boolean => { const status = sessionStatus?.[sessionId]; return status?.type === 'busy' || status?.type === 'retry'; }, [sessionStatus]); const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount); const needsAttention = React.useCallback((sessionId: string): boolean => { return (unseenCounts[sessionId] ?? 0) > 0; }, [unseenCounts]); return { getSessionAgentName, getSessionTitle, isRunning, needsAttention }; } // Hook to calculate project status indicators 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); const projectStatusMap = 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) { const list = getSessionsByDirectory(dir); for (const session of list) { if (!session?.id || seen.has(session.id)) { continue; } seen.add(session.id); const statusType = getStatusType(session.id); if (statusType === 'busy' || statusType === 'retry') { 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]); return projectStatusMap; } 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} ); } function SessionItem({ session, isCurrent, getSessionAgentName, getSessionTitle, onClick, onDoubleClick, needsAttention }: { session: SessionWithStatus; isCurrent: boolean; getSessionAgentName: (s: Session) => string; getSessionTitle: (s: Session) => string; onClick: () => void; onDoubleClick?: () => void; needsAttention: (sessionId: string) => boolean; }) { const agentName = getSessionAgentName(session); const agentColor = getAgentColor(agentName); const extraCount = (session._runningChildrenCount || 0) + (session._statusType !== 'idle' ? 1 : 0) - 1 - (session._childIndicators?.length || 0); return ( ); } function TokenUsageIndicator({ contextUsage }: { contextUsage: SessionContextUsage | null }) { if (!contextUsage || contextUsage.totalTokens === 0) return null; const percentage = Math.min(contextUsage.percentage, 999); const colorClass = percentage >= 90 ? 'text-[var(--status-error)]' : percentage >= 75 ? 'text-[var(--status-warning)]' : 'text-[var(--status-success)]'; return ( {percentage.toFixed(1)}% ); } interface SessionStatusHeaderProps { currentSessionTitle: string; currentProjectLabel?: string; currentProjectIcon?: string | null; currentProjectIconImageUrl?: string | null; currentProjectIconBackground?: string | null; currentProjectColor?: string | null; onToggle: () => void; isExpanded?: boolean; childIndicators?: Array<{ session: Session; isRunning: boolean }>; } function SessionStatusHeader({ currentSessionTitle, currentProjectLabel, currentProjectIcon, currentProjectIconImageUrl, currentProjectIconBackground, currentProjectColor, onToggle, isExpanded = false, childIndicators = [] }: SessionStatusHeaderProps) { const [imageFailed, setImageFailed] = React.useState(false); const ProjectIcon = currentProjectIcon ? PROJECT_ICON_MAP[currentProjectIcon] : null; const imageUrl = !imageFailed ? currentProjectIconImageUrl : null; const projectColorVar = currentProjectColor ? (PROJECT_COLOR_MAP[currentProjectColor] ?? null) : null; const extraCount = childIndicators.length > 3 ? childIndicators.length - 3 : 0; React.useEffect(() => { setImageFailed(false); }, [currentProjectIconImageUrl]); return ( ); } // Hook for long press with movement detection function useLongPress( onLongPress: () => void, onClick: () => void, ms = 500 ) { const timerRef = React.useRef(null); const isLongPress = React.useRef(false); const startPosRef = React.useRef<{ x: number; y: number } | null>(null); const hasMovedRef = React.useRef(false); const MOVE_THRESHOLD = 10; // pixels const start = React.useCallback((clientX: number, clientY: number) => { isLongPress.current = false; hasMovedRef.current = false; startPosRef.current = { x: clientX, y: clientY }; timerRef.current = setTimeout(() => { if (!hasMovedRef.current) { isLongPress.current = true; onLongPress(); } }, ms); }, [onLongPress, ms]); const move = React.useCallback((clientX: number, clientY: number) => { if (!startPosRef.current) return; const dx = Math.abs(clientX - startPosRef.current.x); const dy = Math.abs(clientY - startPosRef.current.y); if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) { hasMovedRef.current = true; if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } } }, []); const end = React.useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } startPosRef.current = null; }, []); const handleClick = React.useCallback(() => { if (!isLongPress.current) { onClick(); } }, [onClick]); return { onMouseDown: (e: React.MouseEvent) => start(e.clientX, e.clientY), onMouseUp: end, onMouseLeave: end, onMouseMove: (e: React.MouseEvent) => move(e.clientX, e.clientY), onTouchStart: (e: React.TouchEvent) => { const touch = e.touches[0]; start(touch.clientX, touch.clientY); }, onTouchMove: (e: React.TouchEvent) => { const touch = e.touches[0]; move(touch.clientX, touch.clientY); }, onTouchEnd: end, onClick: handleClick, }; } // Sortable project item for edit panel interface SortableProjectItemProps { project: ProjectEntry; isFirst: boolean; isLast: boolean; onMoveUp: () => void; onMoveDown: () => void; onEdit: () => void; onDelete: () => void; formatProjectLabel: (project: ProjectEntry) => string; } function SortableProjectItem({ project, isFirst, isLast, onMoveUp, onMoveDown, onEdit, onDelete, formatProjectLabel, }: SortableProjectItemProps) { const { currentTheme } = useThemeSystem(); const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: project.id }); const style = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 10 : 1, }; const [imageFailed, setImageFailed] = React.useState(false); const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const projectIconImageUrl = !imageFailed ? getProjectIconImageUrl(project, { themeVariant: currentTheme.metadata.variant, iconColor: currentTheme.colors.surface.foreground, }) : null; const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null; return (
{/* Drag handle */} {/* Project info */}
{projectIconImageUrl ? ( setImageFailed(true)} /> ) : ProjectIcon ? ( ) : (
)} {formatProjectLabel(project)}
{/* Actions */}
{/* Move up/down buttons (for non-drag sorting) */}
{/* Edit button */} {/* Delete button */}
); } // Project edit panel for mobile interface ProjectEditPanelProps { isOpen: boolean; onClose: () => void; projects: ProjectEntry[]; onReorder: (fromIndex: number, toIndex: number) => void; onEdit: (project: ProjectEntry) => void; onDelete: (project: ProjectEntry) => void; homeDirectory: string | null; } function ProjectEditPanel({ isOpen, onClose, projects, onReorder, onEdit, onDelete, homeDirectory, }: ProjectEditPanelProps) { const { t } = useI18n(); const [localProjects, setLocalProjects] = React.useState(projects); React.useEffect(() => { setLocalProjects(projects); }, [projects, isOpen]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { const oldIndex = localProjects.findIndex((p) => p.id === active.id); const newIndex = localProjects.findIndex((p) => p.id === over.id); setLocalProjects((items) => arrayMove(items, oldIndex, newIndex)); onReorder(oldIndex, newIndex); } }; const handleMoveUp = (index: number) => { if (index > 0) { setLocalProjects((items) => arrayMove(items, index, index - 1)); onReorder(index, index - 1); } }; const handleMoveDown = (index: number) => { if (index < localProjects.length - 1) { setLocalProjects((items) => arrayMove(items, index, index + 1)); onReorder(index, index + 1); } }; const formatProjectLabel = (project: ProjectEntry): string => { return project.label?.trim() || formatDirectoryName(project.path, homeDirectory) || project.path; }; return ( {t('chat.mobileStatus.editProjects.footer')}

} >
p.id)} strategy={verticalListSortingStrategy} > {localProjects.map((project, index) => ( handleMoveUp(index)} onMoveDown={() => handleMoveDown(index)} onEdit={() => onEdit(project)} onDelete={() => onDelete(project)} formatProjectLabel={formatProjectLabel} /> ))} {localProjects.length === 0 && (
{t('chat.mobileStatus.editProjects.empty')}
)}
); } // Project button component with long press support interface ProjectButtonProps { project: ProjectEntry; isActive: boolean; status: { hasRunning: boolean; hasUnread: boolean }; projectColorVar: string | null; onProjectSwitch: () => void; onOpenEditPanel?: () => void; formatProjectLabel: (project: ProjectEntry) => string; } function ProjectButton({ project, isActive, status, projectColorVar, onProjectSwitch, onOpenEditPanel, formatProjectLabel, }: ProjectButtonProps) { const { currentTheme } = useThemeSystem(); const [imageFailed, setImageFailed] = React.useState(false); const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const projectIconImageUrl = !imageFailed ? getProjectIconImageUrl(project, { themeVariant: currentTheme.metadata.variant, iconColor: currentTheme.colors.surface.foreground, }) : null; React.useEffect(() => { setImageFailed(false); }, [project.id, project.iconImage?.updatedAt]); const longPressHandlers = useLongPress( () => { if (onOpenEditPanel) { onOpenEditPanel(); } }, onProjectSwitch, 600 ); return ( ); } // Project bar component for expanded view interface ProjectBarProps { projects: ProjectEntry[]; activeProjectId: string | null; getProjectStatus: (path: string) => { hasRunning: boolean; hasUnread: boolean }; onProjectSwitch: (projectId: string) => void; onAddProject: () => void; onRemoveProject?: (projectId: string) => void; homeDirectory: string | null; } function ProjectBar({ projects, activeProjectId, getProjectStatus, onProjectSwitch, onAddProject, onRemoveProject, homeDirectory }: ProjectBarProps) { const { t } = useI18n(); const scrollRef = React.useRef(null); const [editPanelOpen, setEditPanelOpen] = React.useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); const [projectToDelete, setProjectToDelete] = React.useState(null); const reorderProjects = useProjectsStore((state) => state.reorderProjects); // Scroll active project into view React.useEffect(() => { if (scrollRef.current && activeProjectId) { const activeElement = scrollRef.current.querySelector(`[data-project-id="${activeProjectId}"]`); if (activeElement) { activeElement.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); } } }, [activeProjectId]); const handleOpenEditPanel = () => { setEditPanelOpen(true); }; const handleReorder = (fromIndex: number, toIndex: number) => { reorderProjects(fromIndex, toIndex); }; const [editingProject, setEditingProject] = React.useState(null); const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta); const handleEditProject = (project: ProjectEntry) => { setEditingProject(project); }; const handleSaveProjectEdit = (data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => { if (editingProject) { updateProjectMeta(editingProject.id, data); } setEditingProject(null); }; const handleDeleteProject = (project: ProjectEntry) => { setProjectToDelete(project); setDeleteDialogOpen(true); }; const handleConfirmDelete = () => { if (projectToDelete && onRemoveProject) { onRemoveProject(projectToDelete.id); } setDeleteDialogOpen(false); setProjectToDelete(null); }; if (projects.length === 0) { return (
{t('chat.mobileStatus.projects.empty')}
); } const formatProjectLabel = (project: ProjectEntry): string => { return project.label?.trim() || formatDirectoryName(project.path, homeDirectory) || project.path; }; // Handle touch events to prevent drawer swipe when scrolling project bar const handleTouchStart = (e: React.TouchEvent) => { // Store initial touch position for this component (e.currentTarget as HTMLElement).dataset.touchStartX = String(e.touches[0].clientX); (e.currentTarget as HTMLElement).dataset.touchStartY = String(e.touches[0].clientY); }; const handleTouchMove = (e: React.TouchEvent) => { const target = e.currentTarget as HTMLElement; const startX = Number(target.dataset.touchStartX || 0); const startY = Number(target.dataset.touchStartY || 0); const deltaX = e.touches[0].clientX - startX; const deltaY = e.touches[0].clientY - startY; // If horizontal scroll dominates, prevent default to stop drawer gesture if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 5) { e.stopPropagation(); } }; const handleTouchEnd = (e: React.TouchEvent) => { // Clean up const target = e.currentTarget as HTMLElement; delete target.dataset.touchStartX; delete target.dataset.touchStartY; }; return (
{projects.map((project) => { const isActive = project.id === activeProjectId; const status = getProjectStatus(project.path); const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null; return ( onProjectSwitch(project.id)} onOpenEditPanel={handleOpenEditPanel} formatProjectLabel={formatProjectLabel} /> ); })}
{/* Add project button */} {/* Delete confirmation dialog */} {t('chat.mobileStatus.projects.removeTitle')} {t('chat.mobileStatus.projects.removeDescriptionPrefix')} {projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)}? {/* Project edit panel */} setEditPanelOpen(false)} projects={projects} onReorder={handleReorder} onEdit={handleEditProject} onDelete={handleDeleteProject} homeDirectory={homeDirectory} /> {/* Project edit dialog */} {editingProject && ( { if (!open) setEditingProject(null); }} projectId={editingProject.id} projectName={editingProject.label || formatDirectoryName(editingProject.path, homeDirectory)} projectPath={editingProject.path} initialIcon={editingProject.icon} initialColor={editingProject.color} initialIconBackground={editingProject.iconBackground} onSave={handleSaveProjectEdit} /> )}
); } function CollapsedView({ runningCount, unreadCount, currentSessionTitle, currentProjectLabel, currentProjectIcon, currentProjectIconImageUrl, currentProjectIconBackground, currentProjectColor, onToggle, onNewSession, contextUsage, childIndicators = [], }: { runningCount: number; unreadCount: number; currentSessionTitle: string; currentProjectLabel?: string; currentProjectIcon?: string | null; currentProjectIconImageUrl?: string | null; currentProjectIconBackground?: string | null; currentProjectColor?: string | null; onToggle: () => void; onNewSession: () => void; contextUsage: SessionContextUsage | null; childIndicators?: Array<{ session: Session; isRunning: boolean }>; }) { const { t } = useI18n(); const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe(); return (
); } function ExpandedView({ sessions, currentSessionId, runningCount, unreadCount, currentSessionTitle, currentProjectLabel, currentProjectIcon, currentProjectIconImageUrl, currentProjectIconBackground, currentProjectColor, isExpanded, onToggleCollapse, onNewSession, onSessionClick, onSessionDoubleClick, onProjectSwitch, onAddProject, onRemoveProject, getSessionAgentName, getSessionTitle, needsAttention, contextUsage, projects, activeProjectId, getProjectStatus, homeDirectory, childIndicators = [], }: { sessions: SessionWithStatus[]; currentSessionId: string; runningCount: number; unreadCount: number; currentSessionTitle: string; currentProjectLabel?: string; currentProjectIcon?: string | null; currentProjectIconImageUrl?: string | null; currentProjectIconBackground?: string | null; currentProjectColor?: string | null; isExpanded: boolean; onToggleCollapse: () => void; onNewSession: () => void; onSessionClick: (id: string) => void; onSessionDoubleClick?: () => void; onProjectSwitch: (projectId: string) => void; onAddProject: () => void; onRemoveProject?: (projectId: string) => void; getSessionAgentName: (s: Session) => string; getSessionTitle: (s: Session) => string; needsAttention: (sessionId: string) => boolean; contextUsage: SessionContextUsage | null; projects: ProjectEntry[]; activeProjectId: string | null; getProjectStatus: (path: string) => { hasRunning: boolean; hasUnread: boolean }; homeDirectory: string | null; childIndicators?: Array<{ session: Session; isRunning: boolean }>; }) { const { t } = useI18n(); const containerRef = React.useRef(null); const [collapsedHeight, setCollapsedHeight] = React.useState(null); const [hasMeasured, setHasMeasured] = React.useState(false); const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe(); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); React.useEffect(() => { if (containerRef.current && !hasMeasured && !isExpanded) { setCollapsedHeight(containerRef.current.offsetHeight); setHasMeasured(true); } }, [hasMeasured, isExpanded]); // Filter sessions by active project const filteredSessions = React.useMemo(() => { if (!activeProjectId) return sessions; const activeProject = projects.find(p => p.id === activeProjectId); if (!activeProject) return sessions; const projectRoot = normalize(activeProject.path); const projectDirs = new Set([projectRoot]); // Add worktrees 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) projectDirs.add(normalized); } } return sessions.filter(session => { const sessionDir = normalize((session as { directory?: string | null }).directory ?? ''); return projectDirs.has(sessionDir); }); }, [sessions, activeProjectId, projects, availableWorktreesByProject]); const previewHeight = collapsedHeight ?? undefined; const displaySessions = hasMeasured || isExpanded ? filteredSessions.filter(s => s.id !== currentSessionId) : filteredSessions.slice(0, 3); return (
{/* Header row */}
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleCollapse(); } }} >
{/* Project switcher bar */} {/* Sessions list */}
{displaySessions.length === 0 ? (
{t('chat.mobileStatus.noSessionsInProject')}
) : ( displaySessions.map((session) => ( onSessionClick(session.id)} onDoubleClick={onSessionDoubleClick} needsAttention={needsAttention} /> )) )}
); } export const MobileSessionStatusBar: React.FC = ({ onSessionSwitch, }) => { const { t } = useI18n(); const { currentTheme } = useThemeSystem(); const sessions = useSessions(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const sessionStatus = useAllSessionStatuses(); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const getContextUsage = useSessionUIStore((state) => state.getContextUsage); const agents = useConfigStore((state) => state.agents); const getCurrentModel = useConfigStore((state) => state.getCurrentModel); const isMobile = useUIStore((state) => state.isMobile); const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar); const isMobileSessionStatusBarCollapsed = useUIStore((state) => state.isMobileSessionStatusBarCollapsed); const setIsMobileSessionStatusBarCollapsed = useUIStore((state) => state.setIsMobileSessionStatusBarCollapsed); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); // Project store const projects = useProjectsStore((state) => state.projects); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const setActiveProject = useProjectsStore((state) => state.setActiveProject); const removeProject = useProjectsStore((state) => state.removeProject); const getActiveProject = useProjectsStore((state) => state.getActiveProject); // Directory store const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus); const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus); const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId); const currentSession = sessions.find((s) => s.id === currentSessionId); const currentSessionTitle = currentSession ? getSessionTitle(currentSession) : t('chat.mobileStatus.swipeHint'); // Calculate current session's child indicators const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId); const currentSessionChildIndicators = currentSessionWithStatus?._childIndicators ?? []; const activeProject = getActiveProject(); const currentProjectLabel = activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory); const currentProjectIcon = activeProject?.icon; const currentProjectIconImageUrl = activeProject ? getProjectIconImageUrl(activeProject, { themeVariant: currentTheme.metadata.variant, iconColor: currentTheme.colors.surface.foreground, }) : null; const currentProjectIconBackground = activeProject?.iconBackground ?? null; const currentProjectColor = activeProject?.color; // Calculate token usage for current session const currentModel = getCurrentModel(); const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null ? (currentModel.limit as Record) : null; const contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0); const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0); const contextUsage = getContextUsage(contextLimit, outputLimit); const [isExpanded, setIsExpanded] = React.useState(false); if (!isMobile || !showMobileSessionStatusBar || totalCount === 0) { return null; } const handleSessionClick = (sessionId: string) => { setCurrentSession(sessionId); onSessionSwitch?.(sessionId); setIsExpanded(false); }; const handleSessionDoubleClick = () => { // On double-tap, switch to the Chat tab setActiveMainTab('chat'); }; const handleCreateSession = () => { openNewSessionDraft(); }; const handleProjectSwitch = (projectId: string) => { if (projectId !== activeProjectId) { setActiveProject(projectId); } }; const handleAddProject = () => { sessionEvents.requestDirectoryDialog(); }; if (isMobileSessionStatusBarCollapsed) { return ( setIsMobileSessionStatusBarCollapsed(false)} onNewSession={handleCreateSession} contextUsage={contextUsage} childIndicators={currentSessionChildIndicators} /> ); } return ( { setIsMobileSessionStatusBarCollapsed(true); setIsExpanded(false); }} onNewSession={handleCreateSession} onSessionClick={handleSessionClick} onSessionDoubleClick={handleSessionDoubleClick} onProjectSwitch={handleProjectSwitch} onAddProject={handleAddProject} onRemoveProject={removeProject} getSessionAgentName={getSessionAgentName} getSessionTitle={getSessionTitle} needsAttention={needsAttention} contextUsage={contextUsage} projects={projects} activeProjectId={activeProjectId} getProjectStatus={getProjectStatus} homeDirectory={homeDirectory} childIndicators={currentSessionChildIndicators} /> ); };