Files
openchamber/packages/ui/src/apps/MobileSessionsSheet.tsx
T
Bohdan Triapitsyn ac93a52e21 feat: iPad split layout for the Capacitor app (#2104)
* fix: open mobile model/agent panels on tablet-width Capacitor shells and keep composer taps from dismissing the keyboard

* feat: add iPadOS-style split layout to the Capacitor app

- classify the Capacitor shell as mobile in device detection so shared
  surfaces (draft starters, panels) stop falling into tablet branches
- add isIPadApp() and useOrientation() helpers
- iPad: persistent full-height sessions sidebar (mobile sessions surface
  inline), Changes/Files in a right sidebar with header shortcut toggles
- animate sidebar open/close like the desktop sidebars and add
  finger-sized drag-resize with persisted widths
- anchor the overflow menu and the usage/metadata popover next to their
  header buttons regardless of open sidebars

* fix: re-anchor metadata popover on layout shifts and untangle sidebar toggle updates

- recompute the iPad metadata popover anchor via a ResizeObserver on its
  wrapper so sidebar toggles/resizes while it is open cannot leave it
  misplaced
- move the portrait right-panel close out of the setIpadSidebarOpen
  updater into plain sequential state updates
2026-07-08 21:52:18 +03:00

1336 lines
56 KiB
TypeScript

import React from 'react';
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 { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useI18n } from '@/lib/i18n';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { cn } from '@/lib/utils';
import { listProjectWorktrees } 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 { orderWorktrees, useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useAllLiveSessions } from '@/sync/sync-context';
import type { WorktreeMetadata } from '@/types/worktree';
import { MobileProjectEditSurface } from './MobileProjectEditSurface';
import { MobileSurfaceShell } from './MobileSurfaceShell';
type MobileSessionsSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 'sheet' (default) wraps the content in the swipe-dismiss MobileSurfaceShell;
'sidebar' renders the same content inline for the iPad persistent sidebar. */
variant?: 'sheet' | 'sidebar';
};
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 = 36;
const WORKTREE_SESSION_INDENT = 52;
// Extra left padding applied to each nested subsession level.
const CHILD_INDENT_STEP = 18;
const getParentId = (session: Session): string | null =>
(session as Session & { parentID?: string | null }).parentID ?? null;
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
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 getProjectLabel = (path: string): string => {
const normalized = normalizePath(path);
if (!normalized) return '';
const segments = normalized.split('/').filter(Boolean);
return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized;
};
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 => {
if (!query) return true;
const haystack = `${session.title ?? ''} ${session.id} ${getSessionDirectory(session)} ${projectLabel}`.toLowerCase();
return haystack.includes(query);
};
const MobileProjectIcon: React.FC<{
project: Pick<ProjectMeta, 'id' | 'icon' | 'color' | 'iconImage' | 'iconBackground'>;
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 ? (
<Icon name={ProjectIcon} className={innerClasses} style={iconColor ? { color: iconColor } : undefined} />
) : (
<RiFolder6Line className={innerClasses} style={iconColor ? { color: iconColor } : undefined} />
);
return (
<span
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden bg-[var(--surface-muted)] text-muted-foreground',
containerClasses,
)}
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
{project.iconImage ? (
<ProjectIconImage
project={{ id: project.id, iconImage: project.iconImage ?? null }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="size-full object-contain"
fallback={fallbackIcon}
/>
) : fallbackIcon}
</span>
);
};
const ChevronToggle: React.FC<{ expanded: boolean }> = ({ expanded }) => (
<span
aria-hidden
className={cn(
'flex size-5 shrink-0 items-center justify-center text-muted-foreground/70 transition-transform duration-150',
expanded ? 'rotate-0' : '-rotate-90',
)}
>
<RiArrowDownSLine className="size-4" />
</span>
);
const ActiveDot: React.FC<{ ariaLabel?: string }> = ({ ariaLabel }) => (
<span
className="inline-block size-1.5 shrink-0 rounded-full bg-primary"
aria-label={ariaLabel}
/>
);
const NewWorktreeIconButton: React.FC<{
onClick: () => void;
className?: string;
}> = ({ onClick, className }) => {
const { t } = useI18n();
const label = t('sessions.sidebar.project.actions.newWorktree');
return (
<button
type="button"
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
className,
)}
aria-label={label}
title={label}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
style={{ touchAction: 'manipulation' }}
>
<Icon name="node-tree" className="size-4" />
</button>
);
};
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, the row shows the two-step archive confirmation. */
confirmingArchive?: boolean;
/** When true, a chevron is shown in the left gutter to toggle nested subsessions. */
hasChildren?: boolean;
expanded?: boolean;
onToggleChildren?: () => void;
onSelect: () => void;
/** When provided, an archive affordance is shown; first tap arms confirm, X cancels. */
onRequestArchive?: () => void;
onConfirmArchive?: () => void;
}> = ({
session,
active,
indent,
contextLabel,
confirmingArchive = false,
hasChildren = false,
expanded = false,
onToggleChildren,
onSelect,
onRequestArchive,
onConfirmArchive,
}) => {
const { t } = useI18n();
const time = formatRelativeShort(getSessionTimestamp(session));
const title = session.title?.trim() || t('mobile.sessions.untitled');
return (
<div
className={cn(
'relative flex items-center gap-1 transition-colors',
active && !confirmingArchive && 'bg-[color-mix(in_srgb,var(--primary)_10%,transparent)]',
confirmingArchive && 'bg-[color-mix(in_srgb,var(--destructive)_8%,transparent)]',
)}
>
{hasChildren && onToggleChildren ? (
<button
type="button"
className="absolute z-10 flex w-6 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
style={{ left: Math.max(indent - 32, 2), top: 0, bottom: 0, touchAction: 'manipulation' }}
aria-label={expanded
? t('sessions.sidebar.session.subsessions.collapse')
: t('sessions.sidebar.session.subsessions.expand')}
onClick={(event) => {
event.stopPropagation();
onToggleChildren();
}}
>
<RiArrowDownSLine className={cn('size-[18px] transition-transform duration-150', expanded ? 'rotate-0' : '-rotate-90')} />
</button>
) : null}
<button
type="button"
className={cn(
'flex min-h-12 min-w-0 flex-1 items-center gap-2.5 py-2 pr-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
confirmingArchive && 'opacity-50',
)}
style={{ paddingLeft: indent, touchAction: 'manipulation' }}
onClick={onSelect}
disabled={confirmingArchive}
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2.5">
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
active ? 'bg-primary' : 'bg-muted-foreground/30',
)}
aria-hidden
/>
<span
className={cn(
'block min-w-0 flex-1 truncate typography-ui-label',
active ? 'text-primary' : 'text-foreground',
)}
>
{title}
</span>
{time ? (
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{time}</span>
) : null}
</span>
{contextLabel ? (
<span className="block truncate typography-micro text-muted-foreground pl-4">{contextLabel}</span>
) : null}
</span>
</button>
{onRequestArchive ? (
<>
{confirmingArchive ? (
<button
type="button"
className="flex h-9 shrink-0 items-center gap-1.5 rounded-xl bg-destructive px-3 text-destructive-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive"
aria-label={t('mobile.sessions.archiveSessionAria', { title })}
onClick={onConfirmArchive}
style={{ touchAction: 'manipulation' }}
>
<RiArchiveLine className="size-4" />
<span className="typography-ui-label">{t('sessions.sidebar.bulkActions.archive')}</span>
</button>
) : null}
<button
type="button"
className="mr-1.5 flex size-9 shrink-0 items-center justify-center rounded-xl text-muted-foreground/70 transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={
confirmingArchive
? t('mobile.sessions.cancelArchiveAria', { title })
: t('mobile.sessions.archiveSessionAria', { title })
}
onClick={onRequestArchive}
style={{ touchAction: 'manipulation' }}
>
{confirmingArchive ? <RiCloseLine className="size-4" /> : <RiArchiveLine className="size-4" />}
</button>
</>
) : null}
</div>
);
};
const ShowMoreRow: React.FC<{
indent: number;
onClick: () => void;
}> = ({ indent, onClick }) => {
const { t } = useI18n();
return (
<button
type="button"
className="flex min-h-10 w-full items-center gap-2 py-1.5 pr-3 text-left text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
style={{ paddingLeft: indent, touchAction: 'manipulation' }}
onClick={onClick}
>
<RiArrowDownSLine className="size-4" />
<span className="typography-micro">{t('sessions.sidebar.group.showMore')}</span>
</button>
);
};
const ShowFewerRow: React.FC<{
indent: number;
onClick: () => void;
}> = ({ indent, onClick }) => {
const { t } = useI18n();
return (
<button
type="button"
className="flex min-h-10 w-full items-center gap-2 py-1.5 pr-3 text-left text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
style={{ paddingLeft: indent, touchAction: 'manipulation' }}
onClick={onClick}
>
<RiArrowUpSLine className="size-4" />
<span className="typography-micro">{t('sessions.sidebar.group.showFewer')}</span>
</button>
);
};
const SortableProjectRow: React.FC<{
project: ProjectMeta;
totalSessions: number;
confirmingDelete: boolean;
onEdit: () => void;
onRequestRemove: () => void;
onConfirmRemove: () => void;
}> = ({
project,
totalSessions,
confirmingDelete,
onEdit,
onRequestRemove,
onConfirmRemove,
}) => {
const { t } = useI18n();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: project.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-1 rounded-2xl border border-border/40 bg-[var(--surface-elevated)] px-1.5 py-1.5 transition-colors',
isDragging && 'shadow-lg shadow-black/20',
confirmingDelete && 'border-destructive/50 bg-[color-mix(in_srgb,var(--destructive)_8%,var(--surface-elevated))]',
)}
>
<button
type="button"
className="flex size-9 shrink-0 cursor-grab touch-none items-center justify-center rounded-xl text-muted-foreground/70 transition-colors hover:text-foreground active:cursor-grabbing"
aria-label={t('mobile.sessions.dragHandleAria', { label: project.label })}
{...attributes}
{...listeners}
>
<RiDragMove2Line className="size-4" />
</button>
<MobileProjectIcon project={project} />
<span className="block min-w-0 flex-1 truncate typography-ui-label text-foreground">{project.label}</span>
{confirmingDelete ? (
<button
type="button"
className="flex h-9 shrink-0 items-center gap-1.5 rounded-xl bg-destructive px-3 text-destructive-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive"
aria-label={t('mobile.sessions.confirmRemoveProjectAria', { label: project.label })}
onClick={onConfirmRemove}
style={{ touchAction: 'manipulation' }}
>
<RiDeleteBinLine className="size-4" />
<span className="typography-ui-label">{t('mobile.sessions.confirmRemoveProject')}</span>
</button>
) : (
<>
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{totalSessions}</span>
<button
type="button"
className="flex size-9 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.sessions.editProjectAria', { label: project.label })}
onClick={onEdit}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-4" />
</button>
</>
)}
<button
type="button"
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-xl transition-colors focus-visible:outline-none focus-visible:ring-2',
confirmingDelete
? 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:ring-primary'
: 'text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:ring-destructive',
)}
aria-label={
confirmingDelete
? t('mobile.sessions.cancelRemoveProjectAria', { label: project.label })
: t('mobile.sessions.removeProjectAria', { label: project.label })
}
onClick={onRequestRemove}
style={{ touchAction: 'manipulation' }}
>
{confirmingDelete ? <RiCloseLine className="size-4" /> : <RiDeleteBinLine className="size-4" />}
</button>
</div>
);
};
export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, onOpenChange, variant = 'sheet' }) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const liveSessions = useAllLiveSessions();
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
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 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 expandedParents = useMobileSessionExpansionStore((state) => state.expandedParents);
const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent);
const [query, setQuery] = React.useState('');
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
const [confirmingArchiveSessionId, setConfirmingArchiveSessionId] = React.useState<string | 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<string | null>(null);
const [worktreesByProject, setWorktreesByProject] = React.useState<Map<string, WorktreeMetadata[]>>(new Map());
const [gitProjectPaths, setGitProjectPaths] = React.useState<Set<string>>(new Set());
const [editingOrder, setEditingOrder] = React.useState(false);
const [confirmingDeleteId, setConfirmingDeleteId] = React.useState<string | null>(null);
// 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<Map<string, number>>(new Map());
React.useEffect(() => {
if (!open) {
setQuery('');
setEditingOrder(false);
setConfirmingDeleteId(null);
setVisibleCountByBucket(new Map());
setEditingProjectId(null);
setConfirmingArchiveSessionId(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) setConfirmingDeleteId(null);
}, [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 next = new Map<string, WorktreeMetadata[]>();
const nextGitProjectPaths = new Set<string>();
for (const entry of entries) {
if (entry) {
next.set(entry[0], entry[1]);
if (entry[2]) nextGitProjectPaths.add(entry[0]);
}
}
setWorktreesByProject(next);
setGitProjectPaths(nextGitProjectPaths);
};
void run();
return () => {
cancelled = true;
};
}, [git, open, projects, worktreeRefreshKey]);
const projectsMeta = React.useMemo<ProjectMeta[]>(
() =>
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);
}
return merged;
}, [globalActiveSessions, liveSessions]);
const normalizedQuery = query.trim().toLowerCase();
const projectNodes = React.useMemo<ProjectNode[]>(() => {
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.sort((a, b) => getSessionTimestamp(b) - getSessionTimestamp(a));
for (const session of bucket.sessions) {
if (!getParentId(session)) node.totalSessions += 1;
}
}
}
return nodes;
}, [activeProjectId, projectsMeta, 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;
const isWorktreeExpanded = (node: ProjectNode, bucket: WorktreeBucket): boolean =>
worktreeExpandedMap[`${node.project.id}::${bucket.key}`] ?? false;
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<string, Session[]>();
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 (
<React.Fragment key={session.id}>
<SessionRow
session={session}
active={currentSessionId === session.id}
indent={rowIndent}
hasChildren={hasChildren}
expanded={expanded}
onToggleChildren={hasChildren ? () => toggleParent(session.id) : undefined}
confirmingArchive={confirmingArchiveSessionId === session.id}
onSelect={() => handleSelectSession(session)}
onRequestArchive={() => handleRequestArchive(session.id)}
onConfirmArchive={() => void handleConfirmArchive(session)}
/>
{hasChildren && expanded
? children.map((child) => renderNode(child, rowIndent + CHILD_INDENT_STEP))
: null}
</React.Fragment>
);
};
return (
<div>
{visibleRoots.map((session) => renderNode(session, indent))}
{remaining > 0 ? (
<ShowMoreRow indent={indent} onClick={() => showMoreBucketSessions(bucketKey, visibleRoots.length)} />
) : null}
{canShowFewer ? (
<ShowFewerRow indent={indent} onClick={() => resetBucketVisibleCount(bucketKey)} />
) : null}
</div>
);
};
// 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);
void setCurrentSession(session.id, directory);
onOpenChange(false);
};
// Two-step archive: first tap arms the confirm on that row, second confirms.
// Only one row can be in the confirming state at a time.
const handleRequestArchive = (sessionId: string) => {
setConfirmingArchiveSessionId((current) => (current === sessionId ? null : sessionId));
};
const handleConfirmArchive = async (session: Session) => {
setConfirmingArchiveSessionId(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 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;
setConfirmingDeleteId(null);
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 handleRequestRemoveProject = (projectId: string) => {
setConfirmingDeleteId((current) => (current === projectId ? null : projectId));
};
const handleConfirmRemoveProject = (project: ProjectMeta) => {
removeProject(project.id);
setConfirmingDeleteId(null);
toast.success(t('mobile.sessions.toast.projectRemoved', { label: project.label }));
};
/** 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 (`${node.project.label} ${node.project.path}`.toLowerCase().includes(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 sessions
.filter((session) => {
const directory = getSessionDirectory(session);
const project = findExactProjectMatch(projectsMeta, directory);
return sessionMatchesQuery(session, project?.label ?? '', normalizedQuery);
})
.sort((a, b) => getSessionTimestamp(b) - getSessionTimestamp(a));
}, [normalizedQuery, projectsMeta, sessions]);
const searchProjectMatches = React.useMemo(() => {
if (!normalizedQuery) return [] as Array<ProjectMeta & { sessionCount: number }>;
return projectsMeta
.filter((project) => `${project.label} ${project.path}`.toLowerCase().includes(normalizedQuery))
.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 ? (
<Button
type="button"
variant="chip"
size="sm"
aria-label={editingOrder ? t('mobile.sessions.doneEditing') : t('mobile.sessions.editOrder')}
aria-pressed={editingOrder}
onClick={() => setEditingOrder((value) => !value)}
style={{ touchAction: 'manipulation' }}
>
{editingOrder ? <RiCheckLine className="size-4" /> : <RiEdit2Line className="size-4" />}
</Button>
) : null;
const newChatButton =
!editingOrder && projectsMeta.length > 0 ? (
<Button
type="button"
variant="default"
size="sm"
aria-label={t('mobile.sessions.newChat')}
onClick={handleStartNewChat}
style={{ touchAction: 'manipulation' }}
>
<RiAddLine className="size-4" />
{t('mobile.sessions.newChat')}
</Button>
) : null;
const addProjectButton = !editingOrder ? (
<Button
type="button"
variant="chip"
size="sm"
aria-label={t('sessions.sidebar.header.actions.addProject')}
title={t('sessions.sidebar.header.actions.addProject')}
onClick={() => setDirectoryDialogOpen(true)}
style={{ touchAction: 'manipulation' }}
>
<RiFolderAddLine className="size-4" />
</Button>
) : null;
const trailingActions =
newChatButton || addProjectButton || editToggle ? (
<>
{newChatButton}
{addProjectButton}
{editToggle}
</>
) : null;
const surfaceContent = (
<div className="flex h-full flex-col">
<div className={cn('shrink-0 px-4 pb-2 pt-1', editingOrder && 'hidden')}>
<div className="relative">
<RiSearchLine className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('mobile.sessions.search.placeholder')}
className={cn('h-11 pl-9', query && 'pr-10')}
/>
{query ? (
<button
type="button"
className="absolute right-1.5 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.sessions.clearSearchAria')}
onClick={() => setQuery('')}
style={{ touchAction: 'manipulation' }}
>
<RiCloseLine className="size-4" />
</button>
) : null}
</div>
</div>
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto pb-4">
{projectsMeta.length === 0 ? (
<MobileSessionsEmpty
title={t('mobile.sessions.empty.noProjectsTitle')}
description={t('mobile.sessions.empty.noProjectsDescription')}
action={
<button
type="button"
className="flex items-center justify-center gap-2 rounded-2xl bg-primary px-5 py-3 typography-ui-label text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
onClick={() => setDirectoryDialogOpen(true)}
>
<RiFolderAddLine className="size-4" />
{t('sessions.sidebar.header.actions.addProject')}
</button>
}
/>
) : hasNoMatches ? (
<MobileSessionsEmpty
title={t('mobile.sessions.empty.searchTitle')}
description={t('mobile.sessions.empty.searchDescription')}
/>
) : normalizedQuery && !editingOrder ? (
<div className="flex flex-col gap-3 px-3 pt-2">
{searchSessionMatches.length > 0 ? (
<section>
<div className="flex items-center justify-between px-1 pb-1.5">
<span className="typography-micro font-semibold uppercase tracking-wider text-muted-foreground">
{t('mobile.sessions.search.section.sessions')}
</span>
<span className="typography-micro text-muted-foreground tabular-nums">
{searchSessionMatches.length}
</span>
</div>
<div className="overflow-hidden rounded-2xl border border-border/40 bg-[var(--surface-elevated)]">
{searchSessionMatches.map((session, index) => (
<div key={session.id} className={cn(index > 0 && 'border-t border-border/30')}>
<SessionRow
session={session}
active={currentSessionId === session.id}
indent={12}
contextLabel={buildSessionContextLabel(session)}
onSelect={() => handleSelectSession(session)}
/>
</div>
))}
</div>
</section>
) : null}
{searchProjectMatches.length > 0 ? (
<section>
<div className="flex items-center justify-between px-1 pb-1.5">
<span className="typography-micro font-semibold uppercase tracking-wider text-muted-foreground">
{t('mobile.sessions.search.section.projects')}
</span>
<span className="typography-micro text-muted-foreground tabular-nums">
{searchProjectMatches.length}
</span>
</div>
<div className="overflow-hidden rounded-2xl border border-border/40 bg-[var(--surface-elevated)]">
{searchProjectMatches.map((project, index) => (
<div
key={project.id}
className={cn('flex items-center', index > 0 && 'border-t border-border/30')}
>
<button
type="button"
className="flex min-h-14 min-w-0 flex-1 items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
onClick={() => handleSelectProject(project)}
style={{ touchAction: 'manipulation' }}
>
<MobileProjectIcon project={project} />
<span className="block min-w-0 flex-1 truncate typography-ui-label text-foreground">
{project.label}
</span>
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
{project.sessionCount}
</span>
</button>
{project.isGitRepo ? (
<NewWorktreeIconButton
className="mr-2"
onClick={() => handleNewWorktree(project.id)}
/>
) : null}
</div>
))}
</div>
</section>
) : null}
</div>
) : editingOrder ? (
<div className="flex flex-col gap-2 px-3 py-2">
<p className="px-1 typography-micro text-muted-foreground">
{t('mobile.sessions.editOrderHint')}
</p>
<DndContext sensors={dndSensors} collisionDetection={closestCenter} onDragEnd={handleReorderDragEnd}>
<SortableContext
items={projectsMeta.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-1.5">
{projectsMeta.map((project) => {
const node = projectNodes.find((n) => n.project.id === project.id);
return (
<SortableProjectRow
key={project.id}
project={project}
totalSessions={node?.totalSessions ?? 0}
confirmingDelete={confirmingDeleteId === project.id}
onEdit={() => setEditingProjectId(project.id)}
onRequestRemove={() => handleRequestRemoveProject(project.id)}
onConfirmRemove={() => handleConfirmRemoveProject(project)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
) : (
<div className="flex flex-col">
{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 (
<section
key={node.project.id}
className={cn(nodeIndex > 0 && 'border-t border-border/30')}
>
<div className="flex min-h-14 w-full items-center">
<button
type="button"
className="flex min-h-14 min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
onClick={() => toggleProject(node.project.id, projectExpanded)}
aria-expanded={projectExpanded}
aria-label={
projectExpanded
? t('sessions.sidebar.group.collapseAria', { label: node.project.label })
: t('sessions.sidebar.group.expandAria', { label: node.project.label })
}
style={{ touchAction: 'manipulation' }}
>
<MobileProjectIcon project={node.project} />
<span className="block min-w-0 flex-1 truncate typography-ui-label font-semibold text-foreground">
{node.project.label}
</span>
{node.isActive ? <ActiveDot ariaLabel={t('mobile.sessions.activeProjectAria')} /> : null}
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
{node.totalSessions}
</span>
</button>
{node.project.isGitRepo ? (
<NewWorktreeIconButton
className="mr-2"
onClick={() => handleNewWorktree(node.project.id)}
/>
) : null}
</div>
{projectExpanded ? (
<div className="pb-2">
{(() => {
// 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 (
<div key={bucket.key}>
<button
type="button"
className="flex min-h-11 w-full items-center gap-2 py-1.5 pl-4 pr-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
onClick={() => toggleWorktree(node.project.id, bucket.key, worktreeExpanded)}
aria-expanded={worktreeExpanded}
aria-label={
worktreeExpanded
? t('sessions.sidebar.group.collapseAria', { label: bucket.label })
: t('sessions.sidebar.group.expandAria', { label: bucket.label })
}
style={{ touchAction: 'manipulation' }}
>
<ChevronToggle expanded={worktreeExpanded} />
<Icon
name="node-tree"
className={cn(
'size-4 shrink-0',
isActiveWt ? 'text-primary' : 'text-muted-foreground',
)}
/>
<span
className={cn(
'block min-w-0 flex-1 truncate typography-ui-label font-semibold',
isActiveWt ? 'text-foreground' : 'text-foreground/90',
)}
>
{bucket.label}
</span>
{isActiveWt ? (
<ActiveDot ariaLabel={t('mobile.sessions.activeWorktreeAria')} />
) : null}
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
{bucket.sessions.length}
</span>
</button>
{worktreeExpanded
? renderBucketSessions(node, bucket, WORKTREE_SESSION_INDENT)
: null}
</div>
);
})}
</>
);
})()}
</div>
) : null}
</section>
);
})}
</div>
)}
</ScrollShadow>
<DirectoryExplorerDialog open={directoryDialogOpen} onOpenChange={setDirectoryDialogOpen} />
<NewWorktreeDialog
open={newWorktreeDialogOpen}
onOpenChange={(value) => {
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);
}}
/>
<MobileProjectEditSurface
open={editingProjectId !== null}
project={projectsMeta.find((entry) => entry.id === editingProjectId) ?? null}
onClose={() => setEditingProjectId(null)}
onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)}
/>
</div>
);
if (variant === 'sidebar') {
if (!open) return null;
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center justify-between gap-2 border-b border-border/30 px-4">
<h2 className="truncate typography-ui-label font-semibold text-foreground">
{t('mobile.sessions.sheet.title')}
</h2>
{trailingActions ? (
<div className="flex shrink-0 items-center gap-2">{trailingActions}</div>
) : null}
</div>
{surfaceContent}
</div>
);
}
return (
<MobileSurfaceShell
open={open}
onClose={() => onOpenChange(false)}
ariaLabel={t('mobile.sessions.sheet.title')}
title={t('mobile.sessions.sheet.title')}
trailing={trailingActions}
>
{surfaceContent}
</MobileSurfaceShell>
);
};
const MobileSessionsEmpty: React.FC<{
title: string;
description?: string;
action?: React.ReactNode;
}> = ({ title, description, action }) => (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="typography-ui-label text-foreground">{title}</p>
{description ? <p className="typography-meta text-muted-foreground">{description}</p> : null}
{action ? <div className="pt-2">{action}</div> : null}
</div>
);