Chat sessions without a project were dropped from the mobile session tree because no registered project owned their directory. The sheet now partitions sessions like the desktop sidebar and shows Chats as a collapsible section above the project tree, with the same rows, swipe actions and paging; search results label them "Chats" instead of the raw directory name. Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
2119 lines
93 KiB
TypeScript
2119 lines
93 KiB
TypeScript
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 { CHAT_DRAFT_PROJECT_ID, isChatDirectoryPath } from '@/lib/chatDirectories';
|
|
import { partitionSidebarSessions } from '@/components/session/sidebar/list/sessionCollection';
|
|
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<string>();
|
|
|
|
// 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<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 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>
|
|
);
|
|
};
|
|
|
|
// 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<HTMLDivElement>(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 (
|
|
<div
|
|
className="relative overflow-hidden"
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
onTouchCancel={handleTouchEnd}
|
|
style={{ touchAction: 'pan-y' }}
|
|
>
|
|
<div className="absolute inset-y-0 right-0 flex items-stretch" style={{ width: actionsWidth }} aria-hidden={!revealed}>
|
|
{actions}
|
|
</div>
|
|
<div ref={contentRef} className="relative flex w-full items-center bg-background">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/** 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 (
|
|
<form
|
|
// Fixed 36px: the session row's real height is NOT Tailwind's min-h-10 —
|
|
// mobile.css's global button touch-target rule (min-height: 36px) wins
|
|
// that specificity fight, so single-line rows resolve to 36px. Pin the
|
|
// rename state to the same 36px.
|
|
className="flex h-9 min-w-0 flex-1 items-center gap-2 pr-2"
|
|
style={{ paddingLeft: indent }}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
commit();
|
|
}}
|
|
>
|
|
<input
|
|
autoFocus
|
|
value={value}
|
|
onChange={(event) => 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"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
aria-label={t('sessions.sidebar.session.rename.save')}
|
|
className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
// Inline mins beat mobile.css's global 36px button touch-target floor
|
|
// so the controls fit the 40px row.
|
|
style={{ touchAction: 'manipulation', minHeight: 0, minWidth: 0 }}
|
|
>
|
|
<Icon name="check" className="size-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
aria-label={t('sessions.sidebar.session.rename.cancel')}
|
|
className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
style={{ touchAction: 'manipulation', minHeight: 0, minWidth: 0 }}
|
|
>
|
|
<Icon name="close" className="size-4" />
|
|
</button>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
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<HTMLDivElement>(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 (
|
|
<div
|
|
data-active-session={active || undefined}
|
|
className="relative overflow-hidden"
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
onTouchCancel={handleTouchEnd}
|
|
// Vertical panning stays native; horizontal moves reach the swipe handler.
|
|
style={swipeEnabled ? { touchAction: 'pan-y' } : undefined}
|
|
>
|
|
{swipeEnabled ? (
|
|
<div
|
|
className="absolute inset-y-0 right-0 flex items-stretch"
|
|
style={{ width: ROW_ACTIONS_WIDTH }}
|
|
aria-hidden={!revealed}
|
|
>
|
|
{/* Icon-only actions on the row's own background — they read as the
|
|
row extending to reveal extra controls, not a separate panel. */}
|
|
<button
|
|
type="button"
|
|
tabIndex={revealed ? 0 : -1}
|
|
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
|
aria-label={t('mobile.sessions.renameSessionAria', { title })}
|
|
onClick={onRequestRename}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<RiEdit2Line className="size-[18px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
tabIndex={revealed ? 0 : -1}
|
|
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
|
aria-label={t('mobile.sessions.archiveSessionAria', { title })}
|
|
onClick={onArchive}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<RiArchiveLine className="size-[18px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
tabIndex={revealed ? 0 : -1}
|
|
className={cn(
|
|
'flex flex-1 items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-destructive',
|
|
confirmingDelete
|
|
? 'rounded-lg bg-destructive text-destructive-foreground'
|
|
: 'text-[var(--status-error)] active:opacity-80',
|
|
)}
|
|
aria-label={confirmingDelete
|
|
? t('mobile.sessions.confirmDeleteSessionAria', { title })
|
|
: t('mobile.sessions.deleteSessionAria', { title })}
|
|
onClick={confirmingDelete ? onConfirmDelete : onRequestDelete}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<RiDeleteBinLine className="size-[18px]" />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
<div
|
|
ref={contentRef}
|
|
className={cn(
|
|
'relative flex items-center gap-1 transition-colors',
|
|
// Swipeable rows need an OPAQUE background so the action buttons stay
|
|
// hidden behind the content until it slides; plain rows (search
|
|
// results on an elevated card) keep the translucent treatment.
|
|
swipeEnabled && 'bg-background',
|
|
active && (swipeEnabled
|
|
? 'bg-[color-mix(in_srgb,var(--primary)_10%,var(--background))]'
|
|
: 'bg-[color-mix(in_srgb,var(--primary)_10%,transparent)]'),
|
|
)}
|
|
>
|
|
{/* Left gutter slot: live activity indicator takes priority over the
|
|
subsession chevron — same position, so rows never shift. When the
|
|
row has children the slot still toggles them either way. */}
|
|
{isStreaming || showUnreadDot || (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')}
|
|
disabled={!hasChildren || !onToggleChildren}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onToggleChildren?.();
|
|
}}
|
|
>
|
|
{isStreaming || showUnreadDot ? (
|
|
<span
|
|
className={cn(
|
|
'size-1.5 rounded-full',
|
|
isStreaming ? 'bg-primary' : 'bg-[var(--status-info)]',
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
) : (
|
|
<RiArrowDownSLine className={cn('size-[18px] transition-transform duration-150', expanded ? 'rotate-0' : '-rotate-90')} />
|
|
)}
|
|
</button>
|
|
) : null}
|
|
{renaming && onSubmitRename && onCancelRename ? (
|
|
<SessionRenameForm
|
|
initialTitle={title}
|
|
indent={indent}
|
|
onSubmit={onSubmitRename}
|
|
onCancel={onCancelRename}
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
// Single-line rows: fixed h-9 (36px) to match SessionRenameForm
|
|
// exactly — min-h-* utilities lose the specificity fight against
|
|
// mobile.css's global 36px button floor anyway, so make the real
|
|
// height explicit. Two-line rows (search results with a context
|
|
// subtitle) keep flexible height.
|
|
className={cn(
|
|
'flex min-w-0 flex-1 items-center gap-2.5 pr-3.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
|
|
contextLabel ? 'min-h-10 py-1' : 'h-9',
|
|
)}
|
|
style={{ paddingLeft: indent, touchAction: 'manipulation' }}
|
|
onClick={() => {
|
|
// A tap while the actions are out just closes them.
|
|
if (revealedRef.current) {
|
|
onRevealedChange?.(false);
|
|
return;
|
|
}
|
|
onSelect();
|
|
}}
|
|
>
|
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
<span className="flex items-center gap-2.5">
|
|
<span
|
|
className={cn(
|
|
'block min-w-0 flex-1 truncate typography-ui-label',
|
|
active ? 'text-primary' : 'text-foreground',
|
|
)}
|
|
>
|
|
{title}
|
|
</span>
|
|
{/* The elapsed turn takes the time slot while it matters, then
|
|
hands it back to the relative timestamp. */}
|
|
{showActivityDuration ? (
|
|
<SessionActivityDuration
|
|
sessionId={session.id}
|
|
running={isStreaming}
|
|
className="typography-micro"
|
|
/>
|
|
) : 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">{contextLabel}</span>
|
|
) : null}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ShowMoreRow: React.FC<{
|
|
indent: number;
|
|
onClick: () => void;
|
|
}> = ({ indent, onClick }) => {
|
|
const { t } = useI18n();
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="flex min-h-9 w-full items-center gap-2 py-1 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-9 w-full items-center gap-2 py-1 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>
|
|
);
|
|
};
|
|
|
|
/** 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 (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{ transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 10 : 1 }}
|
|
className={cn(
|
|
'flex items-center gap-1 rounded-xl bg-background px-1 py-1',
|
|
isDragging && 'shadow-lg shadow-black/20',
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex size-8 shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-muted-foreground/70 transition-colors hover:text-foreground active:cursor-grabbing"
|
|
aria-label={t('mobile.sessions.dragHandleAria', { label })}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<RiDragMove2Line className="size-4" />
|
|
</button>
|
|
<Icon name="git-branch" className="size-4 shrink-0 text-muted-foreground" />
|
|
<span className="block min-w-0 flex-1 truncate typography-ui-label font-bold text-muted-foreground">{label}</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/** 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 (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{ transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 10 : 1 }}
|
|
className={cn(
|
|
'rounded-2xl border border-border/70 bg-[var(--surface-elevated)] px-1.5 py-1.5 transition-colors',
|
|
isDragging && 'shadow-lg shadow-black/20',
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<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>
|
|
<button
|
|
type="button"
|
|
className="flex min-h-9 min-w-0 flex-1 items-center gap-2 rounded-xl px-1 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
|
onClick={onToggleExpanded}
|
|
aria-expanded={expanded}
|
|
aria-label={expanded
|
|
? t('sessions.sidebar.group.collapseAria', { label: project.label })
|
|
: t('sessions.sidebar.group.expandAria', { label: project.label })}
|
|
disabled={!hasWorktrees}
|
|
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">{totalSessions}</span>
|
|
{hasWorktrees ? (
|
|
<RiArrowDownSLine
|
|
className={cn('size-4 shrink-0 text-muted-foreground transition-transform', expanded && 'rotate-180')}
|
|
/>
|
|
) : null}
|
|
</button>
|
|
</div>
|
|
{expanded && hasWorktrees ? (
|
|
<DndContext sensors={worktreeSensors} collisionDetection={closestCenter} onDragEnd={handleWorktreeDragEnd}>
|
|
<SortableContext
|
|
items={project.worktrees.map((worktree) => normalizePath(worktree.path))}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="mt-1 flex flex-col gap-0.5 pl-3">
|
|
{project.worktrees.map((worktree) => (
|
|
<SortableWorktreeReorderRow key={normalizePath(worktree.path)} worktree={worktree} />
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ 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<string | null>(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<string | null>(null);
|
|
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = React.useState<string | null>(null);
|
|
const [renamingSessionId, setRenamingSessionId] = React.useState<string | null>(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<string | null>(null);
|
|
const [confirmingRemoveProjectId, setConfirmingRemoveProjectId] = React.useState<string | null>(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<string | null>(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<Map<string, WorktreeMetadata[]>>(
|
|
() => new Map(useSessionUIStore.getState().availableWorktreesByProject),
|
|
);
|
|
const [gitProjectPaths, setGitProjectPaths] = React.useState<Set<string>>(() => {
|
|
const seeded = new Set<string>();
|
|
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<Set<string>>(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<Map<string, number>>(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<string, WorktreeMetadata[]>();
|
|
const nextGitProjectPaths = new Set<string>();
|
|
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<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);
|
|
}
|
|
// 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]);
|
|
|
|
// Managed Chats (sessions under ~/.config/openchamber/chats) are not owned
|
|
// by any registered project; they get their own section above the project
|
|
// tree, the same split the desktop sidebar makes. Temporary /btw forks are
|
|
// dropped here as well.
|
|
const { projectSessions, chatSessions } = React.useMemo(
|
|
() => partitionSidebarSessions(sessions, false),
|
|
[sessions],
|
|
);
|
|
const chatsBucket = React.useMemo<WorktreeBucket>(() => ({
|
|
key: CHAT_DRAFT_PROJECT_ID,
|
|
label: '',
|
|
path: '',
|
|
worktree: null,
|
|
sessions: orderSessionsByLifecycleScopes(chatSessions, pinnedSessionIds, sessionOrderRanks),
|
|
}), [chatSessions, pinnedSessionIds, sessionOrderRanks]);
|
|
const chatsBucketKey = `${CHAT_DRAFT_PROJECT_ID}::${CHAT_DRAFT_PROJECT_ID}`;
|
|
const chatRootCount = React.useMemo(
|
|
() => chatSessions.filter((session) => !getParentId(session)).length,
|
|
[chatSessions],
|
|
);
|
|
|
|
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<HTMLDivElement>(null);
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
const frame = window.requestAnimationFrame(() => {
|
|
const root = contentRootRef.current;
|
|
if (!root) return;
|
|
const target = root.querySelector<HTMLElement>('[data-active-session="true"]')
|
|
?? root.querySelector<HTMLElement>('[data-active-project="true"]');
|
|
target?.scrollIntoView({ block: 'center' });
|
|
});
|
|
return () => window.cancelAnimationFrame(frame);
|
|
}, [open]);
|
|
|
|
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 projectSessions) {
|
|
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, projectSessions, projectsMeta, sessionOrderRanks]);
|
|
|
|
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 = (bucketKey: string, bucket: WorktreeBucket, indent: number) => {
|
|
|
|
// 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}
|
|
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}
|
|
</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);
|
|
// 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);
|
|
if (isChatDirectoryPath(directory)) return t('mobile.sessions.section.chats');
|
|
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, t],
|
|
);
|
|
|
|
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<ProjectMeta & { sessionCount: number }>;
|
|
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 ? (
|
|
<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;
|
|
|
|
// 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 = (
|
|
<div ref={contentRootRef} className="flex min-h-0 flex-1 flex-col">
|
|
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto pb-4">
|
|
{/* 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. */}
|
|
<div className={cn('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>
|
|
{projectsMeta.length === 0 && chatSessions.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/70 bg-[var(--surface-elevated)]">
|
|
{searchSessionMatches.map((session, index) => (
|
|
<div key={session.id} className={cn(index > 0 && 'border-t border-border/70')}>
|
|
<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/70 bg-[var(--surface-elevated)]">
|
|
{searchProjectMatches.map((project, index) => (
|
|
<div
|
|
key={project.id}
|
|
className={cn('flex items-center', index > 0 && 'border-t border-border/70')}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex min-h-12 min-w-0 flex-1 items-center gap-3 px-3 py-1.5 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}
|
|
expanded={reorderExpandedProjects.has(project.id)}
|
|
onToggleExpanded={() => toggleReorderProjectExpanded(project.id)}
|
|
onReorderWorktrees={(orderedPaths) => setWorktreeOrder(project.id, orderedPaths)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col">
|
|
{(() => {
|
|
const chatsExpanded = projectExpandedMap[CHAT_DRAFT_PROJECT_ID] ?? true;
|
|
const chatsLabel = t('mobile.sessions.section.chats');
|
|
return (
|
|
<section>
|
|
<div className="flex min-h-12 w-full items-center">
|
|
<button
|
|
type="button"
|
|
className="flex min-h-12 min-w-0 flex-1 items-center gap-2 px-3 py-1.5 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={() => {
|
|
if (revealedRowId) {
|
|
handleRowKeyRevealedChange(revealedRowId, false);
|
|
return;
|
|
}
|
|
toggleProject(CHAT_DRAFT_PROJECT_ID, chatsExpanded);
|
|
}}
|
|
aria-expanded={chatsExpanded}
|
|
aria-label={
|
|
chatsExpanded
|
|
? t('sessions.sidebar.group.collapseAria', { label: chatsLabel })
|
|
: t('sessions.sidebar.group.expandAria', { label: chatsLabel })
|
|
}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<span className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[var(--surface-muted)] text-muted-foreground">
|
|
<Icon name="chat-4" className="size-4" />
|
|
</span>
|
|
<span className="block min-w-0 flex-1 truncate typography-ui-label font-semibold text-foreground">
|
|
{chatsLabel}
|
|
</span>
|
|
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
|
|
{chatRootCount}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
{chatsExpanded ? (
|
|
<div className="pb-2">
|
|
{chatsBucket.sessions.length > 0 ? (
|
|
renderBucketSessions(chatsBucketKey, chatsBucket, PROJECT_SESSION_INDENT)
|
|
) : (
|
|
<p className="px-3 pb-1 typography-micro text-muted-foreground" style={{ paddingLeft: PROJECT_SESSION_INDENT }}>
|
|
{t('sessions.sidebar.activity.chatsEmpty')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
})()}
|
|
{orderedNodes.map((node) => {
|
|
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="border-t border-border/70"
|
|
>
|
|
<MobileSwipeActionsRow
|
|
actionsWidth={96}
|
|
revealed={revealedRowId === `project:${node.project.id}`}
|
|
onRevealedChange={(nextRevealed) => handleRowKeyRevealedChange(`project:${node.project.id}`, nextRevealed)}
|
|
actions={(
|
|
<>
|
|
<button
|
|
type="button"
|
|
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
|
|
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
|
aria-label={t('mobile.sessions.editProjectAria', { label: node.project.label })}
|
|
onClick={() => {
|
|
setRevealedRowId(null);
|
|
setEditingProjectId(node.project.id);
|
|
}}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<RiEdit2Line className="size-[18px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
|
|
className={cn(
|
|
'flex flex-1 items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-destructive',
|
|
confirmingRemoveProjectId === node.project.id
|
|
? 'rounded-lg bg-destructive text-destructive-foreground'
|
|
: 'text-[var(--status-error)] active:opacity-80',
|
|
)}
|
|
aria-label={confirmingRemoveProjectId === node.project.id
|
|
? t('mobile.sessions.confirmRemoveProjectAria', { label: node.project.label })
|
|
: t('mobile.sessions.removeProjectAria', { label: node.project.label })}
|
|
onClick={() => {
|
|
if (confirmingRemoveProjectId === node.project.id) {
|
|
setRevealedRowId(null);
|
|
setConfirmingRemoveProjectId(null);
|
|
removeProject(node.project.id);
|
|
toast.success(t('mobile.sessions.toast.projectRemoved', { label: node.project.label }));
|
|
return;
|
|
}
|
|
setConfirmingRemoveProjectId(node.project.id);
|
|
}}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<RiDeleteBinLine className="size-[18px]" />
|
|
</button>
|
|
</>
|
|
)}
|
|
>
|
|
<div data-active-project={node.isActive || undefined} className="flex min-h-12 w-full items-center">
|
|
<button
|
|
type="button"
|
|
className="flex min-h-12 min-w-0 flex-1 items-center gap-2 px-3 py-1.5 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={() => {
|
|
if (revealedRowId) {
|
|
handleRowKeyRevealedChange(revealedRowId, false);
|
|
return;
|
|
}
|
|
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>
|
|
</MobileSwipeActionsRow>
|
|
|
|
{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.project.id}::${rootBucket.key}`, rootBucket, PROJECT_SESSION_INDENT)
|
|
: null}
|
|
{worktreeBuckets.map((bucket) => {
|
|
const worktreeExpanded = isWorktreeExpanded(node, bucket);
|
|
const isActiveWt = activeWorktreePath === bucket.path;
|
|
return (
|
|
<div key={bucket.key}>
|
|
<MobileSwipeActionsRow
|
|
actionsWidth={48}
|
|
revealed={revealedRowId === `wt:${bucket.key}`}
|
|
onRevealedChange={(nextRevealed) => handleRowKeyRevealedChange(`wt:${bucket.key}`, nextRevealed)}
|
|
actions={(
|
|
<button
|
|
type="button"
|
|
tabIndex={revealedRowId === `wt:${bucket.key}` ? 0 : -1}
|
|
className="flex flex-1 items-center justify-center text-[var(--status-error)] transition-colors active:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-destructive"
|
|
aria-label={t('mobile.projectEdit.deleteWorktreeAria', { label: bucket.label })}
|
|
onClick={() => {
|
|
setRevealedRowId(null);
|
|
if (bucket.worktree) {
|
|
setWorktreeToDelete({ project: node.project, worktree: bucket.worktree });
|
|
}
|
|
}}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<RiDeleteBinLine className="size-[18px]" />
|
|
</button>
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex min-h-10 w-full items-center gap-2 px-3 py-1 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={() => {
|
|
if (revealedRowId) {
|
|
handleRowKeyRevealedChange(revealedRowId, false);
|
|
return;
|
|
}
|
|
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' }}
|
|
>
|
|
{/* Desktop visual language: muted semibold
|
|
branch label + git-branch icon, so
|
|
worktree headers recede while plain-
|
|
foreground session titles stand out. */}
|
|
<Icon
|
|
name="git-branch"
|
|
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-bold',
|
|
isActiveWt ? 'text-foreground' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{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>
|
|
</MobileSwipeActionsRow>
|
|
{worktreeExpanded
|
|
? renderBucketSessions(`${node.project.id}::${bucket.key}`, bucket, PROJECT_SESSION_INDENT)
|
|
: null}
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</ScrollShadow>
|
|
|
|
{/* 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 ? (
|
|
<div
|
|
className="flex shrink-0 items-center justify-between gap-2 border-t border-border/70 px-2 pt-1.5"
|
|
style={{ paddingBottom: 'calc(0.375rem + var(--oc-safe-area-bottom, 0px))' }}
|
|
>
|
|
{footer.instanceLabel && footer.onOpenInstances ? (
|
|
<Button
|
|
type="button"
|
|
variant="info"
|
|
size="lg"
|
|
className="min-w-0 shrink justify-start"
|
|
onClick={footer.onOpenInstances}
|
|
aria-label={t('mobile.menu.instances')}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<Icon name="server" className="size-[18px]" />
|
|
<span className="block min-w-0 truncate">{footer.instanceLabel}</span>
|
|
</Button>
|
|
) : (
|
|
<div className="min-w-0 flex-1" />
|
|
)}
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{footer.onOpenUpdate ? (
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
size="lg"
|
|
className="w-10 px-0"
|
|
onClick={footer.onOpenUpdate}
|
|
aria-label={t('mobile.menu.update')}
|
|
title={t('mobile.menu.update')}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<Icon name="download" className="size-5" />
|
|
<span className="absolute right-2 top-2 inline-flex size-2 rounded-full bg-primary" aria-hidden />
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
size="lg"
|
|
className="w-10 px-0"
|
|
onClick={footer.onOpenSettings}
|
|
aria-label={t('mobile.menu.settings')}
|
|
title={t('mobile.menu.settings')}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<Icon name="settings-3" className="size-5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<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)}
|
|
/>
|
|
{worktreeToDelete ? (
|
|
<MobileDeleteWorktreeDialog
|
|
open
|
|
project={{ id: worktreeToDelete.project.id, path: worktreeToDelete.project.path }}
|
|
worktree={worktreeToDelete.worktree}
|
|
onClose={() => setWorktreeToDelete(null)}
|
|
onDeleted={() => setWorktreeRefreshKey((value) => value + 1)}
|
|
/>
|
|
) : null}
|
|
</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/70 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 (
|
|
<MobileSessionsDrawerContainer
|
|
open={open}
|
|
onClose={() => onOpenChange(false)}
|
|
ariaLabel={t('mobile.sessions.sheet.title')}
|
|
>
|
|
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3">
|
|
<button
|
|
type="button"
|
|
className="-ml-1 flex size-10 shrink-0 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.surface.closeAria')}
|
|
onClick={() => onOpenChange(false)}
|
|
style={{ touchAction: 'manipulation' }}
|
|
>
|
|
<Icon name="close" className="size-5" />
|
|
</button>
|
|
<h2 className="min-w-0 flex-1 truncate px-1 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}
|
|
</MobileSessionsDrawerContainer>
|
|
);
|
|
};
|
|
|
|
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<HTMLElement | null>(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(
|
|
<section
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={ariaLabel}
|
|
aria-hidden={!open}
|
|
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
|
style={{
|
|
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
|
// Settled state drops the transform entirely so the drawer isn't kept
|
|
// on a compositing layer (iOS clips those to the safe-area viewport).
|
|
transform: entered ? 'none' : 'translateX(-100%)',
|
|
transition: `transform ${DRAWER_ENTER_DURATION_MS}ms ${DRAWER_EASING}`,
|
|
visibility: visible ? 'visible' : 'hidden',
|
|
pointerEvents: open ? 'auto' : 'none',
|
|
}}
|
|
>
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
{children}
|
|
</div>
|
|
</section>,
|
|
rootRef.current,
|
|
);
|
|
};
|
|
|
|
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>
|
|
);
|