import React from 'react'; import { createPortal } from 'react-dom'; import { RiAddLine, RiArchiveLine, RiArrowDownSLine, RiArrowUpSLine, RiCheckLine, RiCloseLine, RiDeleteBinLine, RiDragMove2Line, RiEdit2Line, RiFolder6Line, RiFolderAddLine, RiSearchLine, } from '@remixicon/react'; import type { Session } from '@opencode-ai/sdk/v2/client'; import { DndContext, type DragEndEvent, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from '@dnd-kit/core'; import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { DirectoryExplorerDialog } from '@/components/session/DirectoryExplorerDialog'; import { Icon } from '@/components/icon/Icon'; import { NewWorktreeDialog } from '@/components/session/NewWorktreeDialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { toast } from '@/components/ui'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { getProjectLabel, normalizePath } from './mobilePaths'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useI18n } from '@/lib/i18n'; import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { cn } from '@/lib/utils'; import { listProjectWorktrees, partitionWorktreesByRegisteredProject, } from '@/lib/worktrees/worktreeManager'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { mergeLiveSessionWithGlobalSession, refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useMobileSessionExpansionStore } from '@/stores/useMobileSessionExpansionStore'; import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { orderWorktrees, useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore'; import { EMPTY_SESSION_ORDER_RANKS, orderSessionsByLifecycleScopes, useSessionOrderingStore, } from '@/sync/session-ordering'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useAllLiveSessions, useGlobalSessionStatus } from '@/sync/sync-context'; import { useSessionUnseenCount } from '@/sync/notification-store'; import { useHasSessionActivityDuration } from '@/sync/session-activity-timing'; import { SessionActivityDuration } from '@/components/session/SessionActivityDuration'; import type { WorktreeMetadata } from '@/types/worktree'; import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog'; import { MobileProjectEditSurface } from './MobileProjectEditSurface'; type MobileSessionsSheetProps = { open: boolean; onOpenChange: (open: boolean) => void; /** 'drawer' (default) renders a full-width left drawer over the app; 'sidebar' renders the same content inline for the iPad persistent sidebar. */ variant?: 'drawer' | 'sidebar'; /** App-level footer bar (desktop-sidebar-style): current instance on the left, settings (and, on hosted web, a pending update) on the right. */ footer?: { /** Connected instance label — Capacitor only; null hides the left slot. */ instanceLabel: string | null; onOpenInstances?: () => void; onOpenSettings: () => void; /** Present only while a server update is available (hosted web). */ onOpenUpdate?: () => void; }; }; const EMPTY_PINNED_SESSION_IDS = new Set(); // Pseudo-project key for the collapsible "recent" group's persisted expansion. type ProjectMeta = { id: string; label: string; path: string; icon?: string | null; color?: string | null; iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null; iconBackground?: string | null; isGitRepo: boolean; worktrees: WorktreeMetadata[]; }; type WorktreeBucket = { /** Stable key — usually the worktree path (or project root). */ key: string; /** Display label — branch name when available, else folder name. */ label: string; /** Filesystem path used as `directory` for new sessions started here. */ path: string; /** Underlying worktree metadata, null when this bucket represents the project root. */ worktree: WorktreeMetadata | null; /** Sessions matched into this bucket, sorted by recency desc. */ sessions: Session[]; }; type ProjectNode = { project: ProjectMeta; buckets: WorktreeBucket[]; totalSessions: number; isActive: boolean; }; const SESSIONS_PER_BUCKET = 7; // Left padding for session rows so the title's first letter aligns with its // parent label. Root/project-level sessions align with the project label; // worktree sessions sit one level deeper. SessionRow adds 16px (dot + gap) on top. const PROJECT_SESSION_INDENT = 40; // Extra left padding applied to each nested subsession level. const CHILD_INDENT_STEP = 16; const getParentId = (session: Session): string | null => (session as Session & { parentID?: string | null }).parentID ?? null; const getSessionDirectory = (session: Session): string => { const sessionWithDirectory = session as Session & { directory?: string | null; project?: { worktree?: string | null } | null; }; return normalizePath(sessionWithDirectory.directory ?? sessionWithDirectory.project?.worktree ?? null); }; const getSessionTimestamp = (session: Session): number => { const raw = session.time?.updated ?? session.time?.created; const value = typeof raw === 'number' ? raw : Number(raw); return Number.isFinite(value) && value > 0 ? value : 0; }; const formatRelativeShort = (timestamp: number): string => { if (timestamp <= 0) return ''; const diffMs = Date.now() - timestamp; if (diffMs < 60_000) return 'now'; const minutes = Math.floor(diffMs / 60_000); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); if (days < 7) return `${days}d`; return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(new Date(timestamp)); }; const pathBelongsToRoot = (path: string, root: string): boolean => { const normalizedPath = normalizePath(path); const normalizedRoot = normalizePath(root); return Boolean( normalizedPath && normalizedRoot && (normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`)), ); }; const findExactWorktreeMatch = (project: ProjectMeta, normalizedDirectory: string): WorktreeMetadata | null => ( project.worktrees.find((worktree) => normalizePath(worktree.path) === normalizedDirectory) ?? null ); const projectMatchesExactDirectory = (project: ProjectMeta, normalizedDirectory: string): boolean => ( normalizedDirectory === project.path || Boolean(findExactWorktreeMatch(project, normalizedDirectory)) ); const findExactProjectMatch = (projects: ProjectMeta[], directory: string): ProjectMeta | null => { const normalizedDirectory = normalizePath(directory); if (!normalizedDirectory) return null; return projects.find((project) => projectMatchesExactDirectory(project, normalizedDirectory)) ?? null; }; const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean => matchesRankQuery([session.title, session.id, getSessionDirectory(session), projectLabel], query); const MobileProjectIcon: React.FC<{ project: Pick; size?: 'sm' | 'md'; }> = ({ project, size = 'md' }) => { const { currentTheme } = useThemeSystem(); const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const iconColor = project.color ? PROJECT_COLOR_MAP[project.color] ?? null : null; const containerClasses = size === 'sm' ? 'size-6 rounded-md' : 'size-8 rounded-lg'; const innerClasses = size === 'sm' ? 'size-3.5' : 'size-4'; const fallbackIcon = ProjectIcon ? ( ) : ( ); return ( {project.iconImage ? ( ) : fallbackIcon} ); }; const ActiveDot: React.FC<{ ariaLabel?: string }> = ({ ariaLabel }) => ( ); const NewWorktreeIconButton: React.FC<{ onClick: () => void; className?: string; }> = ({ onClick, className }) => { const { t } = useI18n(); const label = t('sessions.sidebar.project.actions.newWorktree'); return ( ); }; // Width of the swipe-revealed action area (rename + archive + delete buttons). const ROW_ACTIONS_WIDTH = 144; const ROW_SWIPE_SNAP_MS = 180; /** Generic swipe-left-to-reveal wrapper for drawer rows (projects, worktrees). Same gesture mechanics as SessionRow's swipe actions: horizontal intent detection, imperative transform during the drag, snap on release. */ const MobileSwipeActionsRow: React.FC<{ actionsWidth: number; actions: React.ReactNode; revealed: boolean; onRevealedChange: (revealed: boolean) => void; children: React.ReactNode; }> = ({ actionsWidth, actions, revealed, onRevealedChange, children }) => { const contentRef = React.useRef(null); const startRef = React.useRef<{ x: number; y: number } | null>(null); const draggingRef = React.useRef(false); const offsetRef = React.useRef(0); const revealedRef = React.useRef(revealed); const applyOffset = React.useCallback((px: number, animate: boolean) => { const el = contentRef.current; if (!el) return; el.style.transition = animate ? `transform ${ROW_SWIPE_SNAP_MS}ms ease-out` : 'none'; el.style.transform = px === 0 ? 'none' : `translateX(${px}px)`; offsetRef.current = px; }, []); React.useEffect(() => { revealedRef.current = revealed; applyOffset(revealed ? -actionsWidth : 0, true); }, [actionsWidth, applyOffset, revealed]); const handleTouchStart = (event: React.TouchEvent) => { if (event.touches.length !== 1) return; const touch = event.touches[0]; startRef.current = { x: touch.clientX, y: touch.clientY }; draggingRef.current = false; }; const handleTouchMove = (event: React.TouchEvent) => { if (!startRef.current) return; const touch = event.touches[0]; const dx = touch.clientX - startRef.current.x; const dy = touch.clientY - startRef.current.y; if (!draggingRef.current) { if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return; draggingRef.current = true; } const base = revealedRef.current ? -actionsWidth : 0; applyOffset(Math.min(0, Math.max(-actionsWidth, base + dx)), false); }; const handleTouchEnd = () => { startRef.current = null; if (!draggingRef.current) return; draggingRef.current = false; const shouldReveal = offsetRef.current < -actionsWidth / 2; applyOffset(shouldReveal ? -actionsWidth : 0, true); if (shouldReveal !== revealedRef.current) onRevealedChange(shouldReveal); }; return (
{actions}
{children}
); }; /** Inline title editor shown in place of the row content while renaming. Mirrors the desktop sidebar rename: a bare transparent input at the row's own typography (no bordered field — the row keeps its exact height) with explicit save/cancel icon buttons. */ const SessionRenameForm: React.FC<{ initialTitle: string; indent: number; onSubmit: (title: string) => void; onCancel: () => void; }> = ({ initialTitle, indent, onSubmit, onCancel }) => { const { t } = useI18n(); const [value, setValue] = React.useState(initialTitle); const commit = () => { const next = value.trim(); if (!next || next === initialTitle.trim()) { onCancel(); return; } onSubmit(next); }; return (
{ event.preventDefault(); commit(); }} > setValue(event.target.value)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === 'Escape') onCancel(); }} aria-label={t('sessions.sidebar.session.rename.save')} placeholder={t('sessions.sidebar.session.menu.rename')} // 16px prevents the iOS focus zoom; the bare input keeps the row height. // The inline min-height overrides mobile.css's global 36px input // floor, which otherwise makes the rename row taller than the 40px // session row. className="min-w-0 flex-1 bg-transparent text-[16px] typography-ui-label text-foreground outline-none placeholder:text-muted-foreground" style={{ minHeight: 0 }} enterKeyHint="done" />
); }; const SessionRow: React.FC<{ session: Session; active: boolean; indent: number; /** When provided, shown as a small second-line subtitle below the title (e.g. "Project · branch"). */ contextLabel?: string; /** When true, a chevron is shown in the left gutter to toggle nested subsessions. */ hasChildren?: boolean; expanded?: boolean; onToggleChildren?: () => void; onSelect: () => void; /** Swipe-left actions. When omitted, the row is a plain non-swipeable row. */ revealed?: boolean; onRevealedChange?: (revealed: boolean) => void; confirmingDelete?: boolean; onArchive?: () => void; onRequestDelete?: () => void; onConfirmDelete?: () => void; renaming?: boolean; onRequestRename?: () => void; onSubmitRename?: (title: string) => void; onCancelRename?: () => void; }> = ({ session, active, indent, contextLabel, hasChildren = false, expanded = false, onToggleChildren, onSelect, revealed = false, onRevealedChange, confirmingDelete = false, onArchive, onRequestDelete, onConfirmDelete, renaming = false, onRequestRename, onSubmitRename, onCancelRename, }) => { const { t } = useI18n(); const time = formatRelativeShort(getSessionTimestamp(session)); const title = session.title?.trim() || t('mobile.sessions.untitled'); const swipeEnabled = Boolean(onRevealedChange && onArchive); // Live indicators, same conventions as the desktop sidebar: busy/retry → // spinner; unseen activity on a non-active row → attention dot. const liveStatus = useGlobalSessionStatus(session.id); const unseenCount = useSessionUnseenCount(session.id); const statusType = liveStatus?.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 contentRef = React.useRef(null); const startRef = React.useRef<{ x: number; y: number } | null>(null); const draggingRef = React.useRef(false); const offsetRef = React.useRef(0); const revealedRef = React.useRef(revealed); // Imperative transform during the drag (no per-frame re-render); React state // only flips at the snap points via onRevealedChange. const applyOffset = React.useCallback((px: number, animate: boolean) => { const el = contentRef.current; if (!el) return; el.style.transition = animate ? `transform ${ROW_SWIPE_SNAP_MS}ms ease-out` : 'none'; el.style.transform = px === 0 ? 'none' : `translateX(${px}px)`; offsetRef.current = px; }, []); React.useEffect(() => { revealedRef.current = revealed; applyOffset(revealed ? -ROW_ACTIONS_WIDTH : 0, true); }, [applyOffset, revealed]); const handleTouchStart = (event: React.TouchEvent) => { if (!swipeEnabled || event.touches.length !== 1) return; const touch = event.touches[0]; startRef.current = { x: touch.clientX, y: touch.clientY }; draggingRef.current = false; }; const handleTouchMove = (event: React.TouchEvent) => { if (!swipeEnabled || !startRef.current) return; const touch = event.touches[0]; const dx = touch.clientX - startRef.current.x; const dy = touch.clientY - startRef.current.y; if (!draggingRef.current) { if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return; draggingRef.current = true; } const base = revealedRef.current ? -ROW_ACTIONS_WIDTH : 0; const next = Math.min(0, Math.max(-ROW_ACTIONS_WIDTH, base + dx)); applyOffset(next, false); }; const handleTouchEnd = () => { startRef.current = null; if (!draggingRef.current) return; draggingRef.current = false; const shouldReveal = offsetRef.current < -ROW_ACTIONS_WIDTH / 2; applyOffset(shouldReveal ? -ROW_ACTIONS_WIDTH : 0, true); if (shouldReveal !== revealedRef.current) onRevealedChange?.(shouldReveal); }; return (
{swipeEnabled ? (
{/* Icon-only actions on the row's own background — they read as the row extending to reveal extra controls, not a separate panel. */}
) : null}
); }; const ShowMoreRow: React.FC<{ indent: number; onClick: () => void; }> = ({ indent, onClick }) => { const { t } = useI18n(); return ( ); }; const ShowFewerRow: React.FC<{ indent: number; onClick: () => void; }> = ({ indent, onClick }) => { const { t } = useI18n(); return ( ); }; /** One draggable worktree row inside a project's reorder card. */ const SortableWorktreeReorderRow: React.FC<{ worktree: WorktreeMetadata }> = ({ worktree }) => { const { t } = useI18n(); const path = normalizePath(worktree.path); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: path }); const label = worktree.branch || worktree.label || worktree.path; return (
{label}
); }; /** Reorder-mode project card: drag handle reorders projects globally; tapping the rest of the row collapses/expands its worktrees, which reorder within the project through their own nested DndContext. */ const SortableProjectRow: React.FC<{ project: ProjectMeta; totalSessions: number; expanded: boolean; onToggleExpanded: () => void; onReorderWorktrees: (orderedPaths: string[]) => void; }> = ({ project, totalSessions, expanded, onToggleExpanded, onReorderWorktrees }) => { const { t } = useI18n(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: project.id }); const worktreeSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const hasWorktrees = project.worktrees.length > 0; const handleWorktreeDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const paths = project.worktrees.map((worktree) => normalizePath(worktree.path)); const fromIndex = paths.indexOf(String(active.id)); const toIndex = paths.indexOf(String(over.id)); if (fromIndex < 0 || toIndex < 0) return; const next = [...paths]; const [moved] = next.splice(fromIndex, 1); next.splice(toIndex, 0, moved); onReorderWorktrees(next); }; return (
{expanded && hasWorktrees ? ( normalizePath(worktree.path))} strategy={verticalListSortingStrategy} >
{project.worktrees.map((worktree) => ( ))}
) : null}
); }; export const MobileSessionsSheet: React.FC = ({ open, onOpenChange, variant = 'drawer', footer }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); const liveSessions = useAllLiveSessions(); const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); const pinnedSessionIds = useSessionPinnedStore(React.useCallback( (state) => open || variant === 'sidebar' ? state.ids : EMPTY_PINNED_SESSION_IDS, [open, variant], )); const sessionOrderRanks = useSessionOrderingStore(React.useCallback( (state) => open || variant === 'sidebar' ? state.rankById : EMPTY_SESSION_ORDER_RANKS, [open, variant], )); const projects = useProjectsStore((state) => state.projects); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const archiveSession = useSessionUIStore((state) => state.archiveSession); const deleteSession = useSessionUIStore((state) => state.deleteSession); const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const setActiveProject = useProjectsStore((state) => state.setActiveProject); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const reorderProjects = useProjectsStore((state) => state.reorderProjects); const removeProject = useProjectsStore((state) => state.removeProject); const projectExpandedMap = useMobileSessionTreeStore((state) => state.projectExpanded); const worktreeExpandedMap = useMobileSessionTreeStore((state) => state.worktreeExpanded); const setProjectExpanded = useMobileSessionTreeStore((state) => state.setProjectExpanded); const setWorktreeExpanded = useMobileSessionTreeStore((state) => state.setWorktreeExpanded); const worktreeOrderByProject = useWorktreeOrderStore((state) => state.orderByProject); const setWorktreeOrder = useWorktreeOrderStore((state) => state.setWorktreeOrder); const expandedParents = useMobileSessionExpansionStore((state) => state.expandedParents); const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent); const [query, setQuery] = React.useState(''); const [editingProjectId, setEditingProjectId] = React.useState(null); // Swipe-left actions: which row has its actions revealed, and whether its // delete button is armed (two-step). One row at a time. const [revealedSessionId, setRevealedSessionId] = React.useState(null); const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = React.useState(null); const [renamingSessionId, setRenamingSessionId] = React.useState(null); // Swipe-left actions on group headers (`project:{id}` / `wt:{bucketKey}`) — // separate from session rows, but mutually exclusive with them. const [revealedRowId, setRevealedRowId] = React.useState(null); const [confirmingRemoveProjectId, setConfirmingRemoveProjectId] = React.useState(null); const [worktreeToDelete, setWorktreeToDelete] = React.useState<{ project: ProjectMeta; worktree: WorktreeMetadata; } | null>(null); // Bumped to force a re-list of worktrees (e.g. after one is deleted in the editor). const [worktreeRefreshKey, setWorktreeRefreshKey] = React.useState(0); const [directoryDialogOpen, setDirectoryDialogOpen] = React.useState(false); const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false); const [worktreeDialogProjectId, setWorktreeDialogProjectId] = React.useState(null); // Seeded from the app-level worktree discovery (MobileApp populates // availableWorktreesByProject on connect) so the FIRST open already shows // worktrees; the per-open refresh below keeps them fresh without ever // blanking the list. const [worktreesByProject, setWorktreesByProject] = React.useState>( () => new Map(useSessionUIStore.getState().availableWorktreesByProject), ); const [gitProjectPaths, setGitProjectPaths] = React.useState>(() => { const seeded = new Set(); for (const [path, worktrees] of useSessionUIStore.getState().availableWorktreesByProject) { if (worktrees.length > 0) seeded.add(path); } return seeded; }); const [editingOrder, setEditingOrder] = React.useState(false); // Reorder mode collapses projects by default (dragging past 40 worktrees is // painful); tap outside the drag handle to expand one. const [reorderExpandedProjects, setReorderExpandedProjects] = React.useState>(new Set()); // Per-bucket count of sessions revealed past the default page. Ephemeral — // resets when the sheet closes or when a group/project is toggled. Expand // state itself lives in useMobileSessionTreeStore (persisted). // Key: `${projectId}::${bucketKey}`. const [visibleCountByBucket, setVisibleCountByBucket] = React.useState>(new Map()); React.useEffect(() => { if (!open) { setQuery(''); setEditingOrder(false); setReorderExpandedProjects(new Set()); setVisibleCountByBucket(new Map()); setEditingProjectId(null); setRevealedSessionId(null); setConfirmingDeleteSessionId(null); setRenamingSessionId(null); setRevealedRowId(null); setConfirmingRemoveProjectId(null); return; } void refreshGlobalSessions(liveSessions); // intentionally only on open transition — live overlay handles updates after that // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); React.useEffect(() => { if (!editingOrder) setReorderExpandedProjects(new Set()); }, [editingOrder]); React.useEffect(() => { if (!open || projects.length === 0) return; let cancelled = false; const run = async () => { const entries = await Promise.all( projects.map(async (project) => { const path = normalizePath(project.path); if (!path) return null; const isGitRepo = await git.checkIsGitRepository(path).catch(() => false); const worktrees = isGitRepo ? await listProjectWorktrees({ id: project.id, path }).catch(() => []) : []; return [path, worktrees, isGitRepo] as const; }), ); if (cancelled) return; const discoveredWorktreesByProject = new Map(); const nextGitProjectPaths = new Set(); for (const entry of entries) { if (entry) { discoveredWorktreesByProject.set(entry[0], entry[1]); if (entry[2]) nextGitProjectPaths.add(entry[0]); } } setWorktreesByProject(partitionWorktreesByRegisteredProject(projects, discoveredWorktreesByProject)); setGitProjectPaths(nextGitProjectPaths); }; void run(); return () => { cancelled = true; }; }, [git, open, projects, worktreeRefreshKey]); const projectsMeta = React.useMemo( () => projects.map((project) => ({ id: project.id, label: project.label?.trim() || getProjectLabel(project.path), path: normalizePath(project.path), icon: project.icon, color: project.color, iconImage: project.iconImage, iconBackground: project.iconBackground, isGitRepo: gitProjectPaths.has(normalizePath(project.path)), worktrees: orderWorktrees( worktreeOrderByProject[project.id], worktreesByProject.get(normalizePath(project.path)) ?? [], ), })), [gitProjectPaths, projects, worktreeOrderByProject, worktreesByProject], ); /** * Global sessions cover all directories — even unbootstrapped ones — so the tree shows * accurate counts even when a worktree's live store hasn't been hydrated yet. Live * sessions overlay for fresher data on the active directory. */ const sessions = 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 seenIds = new Set(merged.map((session) => session.id)); for (const session of liveSessions) { if (!seenIds.has(session.id)) merged.push(session); } // Archived sessions never show on mobile (no archived view here): the live // overlay can carry them for the active directory, and they'd otherwise // surface in search and then "disappear" once the overlay refreshes. return merged.filter((session) => !session.time?.archived); }, [globalActiveSessions, liveSessions]); const normalizedQuery = query.trim().toLowerCase(); // On open, bring the current session (or at least its project) into view — // the list keeps its scroll position between opens, so a long project list // otherwise lands wherever it was left. Rows carry data-active-* markers. const contentRootRef = React.useRef(null); React.useEffect(() => { if (!open) return; const frame = window.requestAnimationFrame(() => { const root = contentRootRef.current; if (!root) return; const target = root.querySelector('[data-active-session="true"]') ?? root.querySelector('[data-active-project="true"]'); target?.scrollIntoView({ block: 'center' }); }); return () => window.cancelAnimationFrame(frame); }, [open]); const projectNodes = React.useMemo(() => { const nodes: ProjectNode[] = projectsMeta.map((project) => ({ project, buckets: [] as WorktreeBucket[], totalSessions: 0, isActive: project.id === activeProjectId, })); const ensureBucket = (node: ProjectNode, path: string, worktree: WorktreeMetadata | null): WorktreeBucket => { const normalizedBucketPath = normalizePath(path) || node.project.path; const key = normalizedBucketPath || '__root__'; let bucket = node.buckets.find((entry) => entry.key === key); if (!bucket) { bucket = { key, label: worktree?.branch || getProjectLabel(normalizedBucketPath), path: normalizedBucketPath, worktree, sessions: [], }; node.buckets.push(bucket); } return bucket; }; for (const node of nodes) { ensureBucket(node, node.project.path, null); for (const worktree of node.project.worktrees) ensureBucket(node, worktree.path, worktree); } for (const session of sessions) { const directory = getSessionDirectory(session); if (!directory) continue; const normalizedDirectory = normalizePath(directory); const node = nodes.find((entry) => projectMatchesExactDirectory(entry.project, normalizedDirectory)); if (!node) continue; const matchedWorktree = findExactWorktreeMatch(node.project, normalizedDirectory); const bucket = matchedWorktree ? ensureBucket(node, matchedWorktree.path, matchedWorktree) : ensureBucket(node, node.project.path, null); bucket.sessions.push(session); } for (const node of nodes) { for (const bucket of node.buckets) { bucket.sessions = orderSessionsByLifecycleScopes(bucket.sessions, pinnedSessionIds, sessionOrderRanks); for (const session of bucket.sessions) { if (!getParentId(session)) node.totalSessions += 1; } } } return nodes; }, [activeProjectId, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]); const normalizedDirectory = normalizePath(currentDirectory); const findActiveWorktreePath = (node: ProjectNode): string | null => { if (!node.isActive) return null; if (normalizedDirectory === node.project.path) return node.project.path; const matched = node.project.worktrees.find((entry) => pathBelongsToRoot(normalizedDirectory, entry.path)); return matched?.path ?? node.project.path; }; // Expansion is the user's own choice (persisted), independent of the active // directory: projects default to expanded, worktree groups to collapsed. const isProjectExpanded = (node: ProjectNode): boolean => projectExpandedMap[node.project.id] ?? true; // Worktrees default to EXPANDED (desktop parity): their sessions ARE the // content; the header still toggles for users who want them tucked away. const isWorktreeExpanded = (node: ProjectNode, bucket: WorktreeBucket): boolean => worktreeExpandedMap[`${node.project.id}::${bucket.key}`] ?? true; const resetBucketVisibleCount = (bucketKey: string) => { setVisibleCountByBucket((previous) => { if (!previous.has(bucketKey)) return previous; const next = new Map(previous); next.delete(bucketKey); return next; }); }; const resetProjectVisibleCounts = (projectId: string) => { setVisibleCountByBucket((previous) => { let changed = false; const next = new Map(previous); const prefix = `${projectId}::`; for (const key of next.keys()) { if (key.startsWith(prefix)) { next.delete(key); changed = true; } } return changed ? next : previous; }); }; const showMoreBucketSessions = (bucketKey: string, currentVisibleCount: number) => { setVisibleCountByBucket((previous) => { const next = new Map(previous); next.set(bucketKey, currentVisibleCount + SESSIONS_PER_BUCKET); return next; }); }; // Paginated, tree-aware list of a bucket's sessions: top-level sessions paginate, // and a parent with subsessions can be expanded to reveal its children (nested, // recursively). Pagination counts only top-level sessions. const renderBucketSessions = (node: ProjectNode, bucket: WorktreeBucket, indent: number) => { const bucketKey = `${node.project.id}::${bucket.key}`; // Group children by parent within this bucket, and treat sessions whose parent // is not in this bucket as top-level so nothing is hidden. const idsInBucket = new Set(bucket.sessions.map((entry) => entry.id)); const childrenByParent = new Map(); for (const candidate of bucket.sessions) { const parentId = getParentId(candidate); if (parentId && idsInBucket.has(parentId)) { const list = childrenByParent.get(parentId) ?? []; list.push(candidate); childrenByParent.set(parentId, list); } } const roots = bucket.sessions.filter((entry) => { const parentId = getParentId(entry); return !parentId || !idsInBucket.has(parentId); }); const visibleCount = visibleCountByBucket.get(bucketKey) ?? SESSIONS_PER_BUCKET; const visibleRoots = roots.slice(0, visibleCount); const remaining = roots.length - visibleRoots.length; const canShowFewer = roots.length > SESSIONS_PER_BUCKET && remaining === 0; const renderNode = (session: Session, rowIndent: number): React.ReactNode => { const children = childrenByParent.get(session.id) ?? []; const hasChildren = children.length > 0; const expanded = Boolean(expandedParents[session.id]); return ( toggleParent(session.id) : undefined} onSelect={() => handleSelectSession(session)} revealed={revealedSessionId === session.id} onRevealedChange={(nextRevealed) => handleRowRevealedChange(session.id, nextRevealed)} confirmingDelete={confirmingDeleteSessionId === session.id} onArchive={() => void handleArchive(session)} onRequestDelete={() => setConfirmingDeleteSessionId(session.id)} onConfirmDelete={() => void handleConfirmDelete(session)} renaming={renamingSessionId === session.id} onRequestRename={() => handleRequestRename(session.id)} onSubmitRename={(nextTitle) => void handleSubmitRename(session.id, nextTitle)} onCancelRename={() => setRenamingSessionId(null)} /> {hasChildren && expanded ? children.map((child) => renderNode(child, rowIndent + CHILD_INDENT_STEP)) : null} ); }; return (
{visibleRoots.map((session) => renderNode(session, indent))} {remaining > 0 ? ( showMoreBucketSessions(bucketKey, visibleRoots.length)} /> ) : null} {canShowFewer ? ( resetBucketVisibleCount(bucketKey)} /> ) : null}
); }; // Toggling resets the visible-session count for the affected buckets so a // re-expanded group starts from the default page again. const toggleProject = (projectId: string, currentlyExpanded: boolean) => { setProjectExpanded(projectId, !currentlyExpanded); resetProjectVisibleCounts(projectId); }; const toggleWorktree = (projectId: string, bucketKey: string, currentlyExpanded: boolean) => { setWorktreeExpanded(`${projectId}::${bucketKey}`, !currentlyExpanded); resetBucketVisibleCount(`${projectId}::${bucketKey}`); }; const handleSelectSession = (session: Session) => { const directory = getSessionDirectory(session) || null; // Switching session switches the working directory (handled by // setCurrentSession) — also move the active project so the rest of the app // and the active highlight follow the selected session, not just the draft. const project = findExactProjectMatch(projectsMeta, directory ?? ''); if (project) { setActiveProjectIdOnly(project.id); // Expand the session's project (and worktree group) in the tree, so a // session picked from search is actually visible — and the open-time // auto-scroll can land on it — the next time the drawer opens. setProjectExpanded(project.id, true); const worktree = findExactWorktreeMatch(project, normalizePath(directory ?? '')); if (worktree) setWorktreeExpanded(`${project.id}::${normalizePath(worktree.path)}`, true); } void setCurrentSession(session.id, directory); onOpenChange(false); }; // Swipe actions. Revealing a row disarms any pending delete confirm; archive // fires immediately (the swipe itself is the intent), delete stays two-step. const handleRowRevealedChange = (sessionId: string, nextRevealed: boolean) => { setRevealedSessionId(nextRevealed ? sessionId : null); setConfirmingDeleteSessionId(null); setRevealedRowId(null); setConfirmingRemoveProjectId(null); }; // Same contract for group headers (project / worktree rows). const handleRowKeyRevealedChange = (rowKey: string, nextRevealed: boolean) => { setRevealedRowId(nextRevealed ? rowKey : null); setConfirmingRemoveProjectId(null); setRevealedSessionId(null); setConfirmingDeleteSessionId(null); }; const handleArchive = async (session: Session) => { setRevealedSessionId(null); setConfirmingDeleteSessionId(null); const ok = await archiveSession(session.id); if (ok) toast.success(t('sessions.sidebar.session.archive.success')); else toast.error(t('sessions.sidebar.session.archive.error')); }; const handleConfirmDelete = async (session: Session) => { setRevealedSessionId(null); setConfirmingDeleteSessionId(null); const ok = await deleteSession(session.id); if (ok) toast.success(t('sessions.sidebar.session.delete.success')); else toast.error(t('sessions.sidebar.session.delete.error')); }; const handleRequestRename = (sessionId: string) => { setRevealedSessionId(null); setConfirmingDeleteSessionId(null); setRenamingSessionId(sessionId); }; const handleSubmitRename = async (sessionId: string, title: string) => { setRenamingSessionId(null); try { await updateSessionTitle(sessionId, title); } catch { toast.error(t('mobile.sessions.renameError')); } }; const handleStartNewChat = () => { openNewSessionDraft(); onOpenChange(false); }; const handleNewWorktree = (projectId: string) => { setWorktreeDialogProjectId(projectId); setActiveProjectIdOnly(projectId); setNewWorktreeDialogOpen(true); }; const dndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const handleReorderDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const fromIndex = projectsMeta.findIndex((p) => p.id === active.id); const toIndex = projectsMeta.findIndex((p) => p.id === over.id); if (fromIndex < 0 || toIndex < 0) return; reorderProjects(fromIndex, toIndex); }; const toggleReorderProjectExpanded = (projectId: string) => { setReorderExpandedProjects((current) => { const next = new Set(current); if (next.has(projectId)) next.delete(projectId); else next.add(projectId); return next; }); }; /** Short "Project · branch" string shown under the session title in search results. */ const buildSessionContextLabel = React.useCallback( (session: Session): string => { const directory = getSessionDirectory(session); const project = findExactProjectMatch(projectsMeta, directory); if (!project) return getProjectLabel(directory) || directory; const matchedWorktree = findExactWorktreeMatch(project, normalizePath(directory)); if (matchedWorktree?.branch) return `${project.label} · ${matchedWorktree.branch}`; return project.label; }, [projectsMeta], ); const handleSelectProject = (project: ProjectMeta) => { setActiveProject(project.id); onOpenChange(false); }; const filteredNodes = React.useMemo(() => { if (!normalizedQuery) return projectNodes; return projectNodes.filter((node) => { if (matchesRankQuery([node.project.label, node.project.path], normalizedQuery)) return true; return node.buckets.some((bucket) => bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)), ); }); }, [normalizedQuery, projectNodes]); // Preserve the store's project order. Reorder mode persists changes via // useProjectsStore.reorderProjects, which writes back to the same source we render here. const orderedNodes = filteredNodes; // Flat lists used only by the dedicated search-results view. const searchSessionMatches = React.useMemo(() => { if (!normalizedQuery) return [] as Session[]; return orderSessionsByLifecycleScopes( sessions.filter((session) => { // Subsessions are implementation noise in a flat search list — only // top-level sessions are searchable. if (getParentId(session)) return false; const directory = getSessionDirectory(session); const project = findExactProjectMatch(projectsMeta, directory); return sessionMatchesQuery(session, project?.label ?? '', normalizedQuery); }), pinnedSessionIds, sessionOrderRanks, ); }, [normalizedQuery, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]); const searchProjectMatches = React.useMemo(() => { if (!normalizedQuery) return [] as Array; return rankByQuery(projectsMeta, normalizedQuery, (project) => [project.label, project.path]) .map((project) => ({ ...project, sessionCount: sessions.filter((session) => { if (getParentId(session)) return false; const directory = normalizePath(getSessionDirectory(session)); return projectMatchesExactDirectory(project, directory); }).length, })); }, [normalizedQuery, projectsMeta, sessions]); const hasNoMatches = normalizedQuery && searchSessionMatches.length === 0 && searchProjectMatches.length === 0; const canEditOrder = !normalizedQuery && projectsMeta.length > 1; const editToggle = canEditOrder ? ( ) : null; const newChatButton = !editingOrder && projectsMeta.length > 0 ? ( ) : null; const addProjectButton = !editingOrder ? ( ) : null; const trailingActions = newChatButton || addProjectButton || editToggle ? ( <> {newChatButton} {addProjectButton} {editToggle} ) : null; // flex-1 + min-h-0 rather than h-full: both hosts put a fixed-height header // above this, so a 100% height overflows by exactly that header — and the // clipped overflow swallowed the footer. const surfaceContent = (
{/* The search bar scrolls WITH the list (iOS-style): the open-time auto-scroll to the current session naturally tucks it away, and scrolling to the very top brings it back. */}
setQuery(event.target.value)} placeholder={t('mobile.sessions.search.placeholder')} className={cn('h-11 pl-9', query && 'pr-10')} /> {query ? ( ) : null}
{projectsMeta.length === 0 ? ( setDirectoryDialogOpen(true)} > {t('sessions.sidebar.header.actions.addProject')} } /> ) : hasNoMatches ? ( ) : normalizedQuery && !editingOrder ? (
{searchSessionMatches.length > 0 ? (
{t('mobile.sessions.search.section.sessions')} {searchSessionMatches.length}
{searchSessionMatches.map((session, index) => (
0 && 'border-t border-border/70')}> handleSelectSession(session)} />
))}
) : null} {searchProjectMatches.length > 0 ? (
{t('mobile.sessions.search.section.projects')} {searchProjectMatches.length}
{searchProjectMatches.map((project, index) => (
0 && 'border-t border-border/70')} > {project.isGitRepo ? ( handleNewWorktree(project.id)} /> ) : null}
))}
) : null}
) : editingOrder ? (

{t('mobile.sessions.editOrderHint')}

p.id)} strategy={verticalListSortingStrategy} >
{projectsMeta.map((project) => { const node = projectNodes.find((n) => n.project.id === project.id); return ( toggleReorderProjectExpanded(project.id)} onReorderWorktrees={(orderedPaths) => setWorktreeOrder(project.id, orderedPaths)} /> ); })}
) : (
{orderedNodes.map((node, nodeIndex) => { const projectExpanded = isProjectExpanded(node); const buckets = normalizedQuery ? node.buckets.filter((bucket) => bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery), ), ) : node.buckets; const activeWorktreePath = findActiveWorktreePath(node); return (
0 && 'border-t border-border/70')} > handleRowKeyRevealedChange(`project:${node.project.id}`, nextRevealed)} actions={( <> )} >
{node.project.isGitRepo ? ( handleNewWorktree(node.project.id)} /> ) : null}
{projectExpanded ? (
{(() => { // Root (project-level) sessions always render as a flat list // at the top — same as a project without worktrees — never // hidden behind a worktree-style group. const rootBucket = buckets.find((bucket) => bucket.worktree === null); const worktreeBuckets = buckets.filter((bucket) => bucket.worktree !== null); return ( <> {rootBucket && rootBucket.sessions.length > 0 ? renderBucketSessions(node, rootBucket, PROJECT_SESSION_INDENT) : null} {worktreeBuckets.map((bucket) => { const worktreeExpanded = isWorktreeExpanded(node, bucket); const isActiveWt = activeWorktreePath === bucket.path; return (
handleRowKeyRevealedChange(`wt:${bucket.key}`, nextRevealed)} actions={( )} > {worktreeExpanded ? renderBucketSessions(node, bucket, PROJECT_SESSION_INDENT) : null}
); })} ); })()}
) : null}
); })}
)}
{/* App-level footer: instance on the left (Capacitor), settings — plus a pending web update — on the right. Bottom placement keeps the header for list actions and stays thumb-reachable. */} {footer ? (
{footer.instanceLabel && footer.onOpenInstances ? ( ) : (
)}
{footer.onOpenUpdate ? ( ) : null}
) : null} { setNewWorktreeDialogOpen(value); if (!value) setWorktreeDialogProjectId(null); }} onWorktreeCreated={(worktreePath, options) => { if (options?.sessionId) void setCurrentSession(options.sessionId, worktreePath); else openNewSessionDraft({ selectedProjectId: worktreeDialogProjectId, directoryOverride: worktreePath, preserveDirectoryOverride: true, }); onOpenChange(false); }} /> entry.id === editingProjectId) ?? null} onClose={() => setEditingProjectId(null)} onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)} /> {worktreeToDelete ? ( setWorktreeToDelete(null)} onDeleted={() => setWorktreeRefreshKey((value) => value + 1)} /> ) : null}
); if (variant === 'sidebar') { if (!open) return null; return (

{t('mobile.sessions.sheet.title')}

{trailingActions ? (
{trailingActions}
) : null}
{surfaceContent}
); } return ( onOpenChange(false)} ariaLabel={t('mobile.sessions.sheet.title')} >

{t('mobile.sessions.sheet.title')}

{trailingActions ? (
{trailingActions}
) : null}
{surfaceContent}
); }; const DRAWER_ROOT_ID = 'mobile-surface-root'; const DRAWER_ENTER_DELAY_MS = 16; // Slightly long, decelerating slide — matches the workspace drawer so both // sides feel like the same piece of chrome. const DRAWER_ENTER_DURATION_MS = 320; const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; /** Full-width left drawer for the phone sessions list: covers the whole app and slides in from the left edge. Closes via the header X, Escape, or the Android back button (handled by MobileShell). Stays MOUNTED while closed (parked off-screen, hidden): the sessions sheet's project/worktree state stays warm, so reopening shows the tree instantly instead of refetching from scratch — and the close slide can actually play instead of the drawer vanishing on unmount. */ const MobileSessionsDrawerContainer: React.FC<{ open: boolean; onClose: () => void; ariaLabel: string; children: React.ReactNode; }> = ({ open, onClose, ariaLabel, children }) => { const rootRef = React.useRef(null); const [entered, setEntered] = React.useState(false); // Kept visible through the exit slide; flipped to hidden once it finishes. const [visible, setVisible] = React.useState(open); const onCloseRef = React.useRef(onClose); React.useEffect(() => { onCloseRef.current = onClose; }, [onClose]); if (typeof document !== 'undefined' && !rootRef.current) { let root = document.getElementById(DRAWER_ROOT_ID); if (!root) { root = document.createElement('div'); root.id = DRAWER_ROOT_ID; document.body.appendChild(root); } rootRef.current = root; } React.useEffect(() => { if (open) { setVisible(true); const id = window.setTimeout(() => setEntered(true), DRAWER_ENTER_DELAY_MS); return () => window.clearTimeout(id); } setEntered(false); const id = window.setTimeout(() => setVisible(false), DRAWER_ENTER_DURATION_MS + 40); return () => window.clearTimeout(id); }, [open]); React.useEffect(() => { if (!open) return; const previousOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') onCloseRef.current(); }; document.addEventListener('keydown', handleKeyDown); return () => { document.body.style.overflow = previousOverflow; document.removeEventListener('keydown', handleKeyDown); }; }, [open]); if (!rootRef.current) return null; return createPortal(
{children}
, rootRef.current, ); }; const MobileSessionsEmpty: React.FC<{ title: string; description?: string; action?: React.ReactNode; }> = ({ title, description, action }) => (

{title}

{description ?

{description}

: null} {action ?
{action}
: null}
);