From 51682dc7af999ea9472e76f73a2076686137b678 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 2 Jun 2026 15:37:23 +0300 Subject: [PATCH] feat: persist mobile sessions tree state and fix root sessions layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist project/worktree expand state across restarts and decouple it from the active directory — projects default expanded, worktree groups collapsed, and the user's explicit toggles are remembered. Selecting a session now switches the active project (not just the draft), following the session's directory. Replace all-or-nothing session pagination with incremental +7 loading that resets when a group or project is toggled, matching the desktop sidebar. Always render root (project-level) sessions as a flat list above worktree groups, and vertically center the session row status dot. --- packages/ui/src/apps/MobileSessionsSheet.tsx | 362 +++++++++--------- .../src/stores/useMobileSessionTreeStore.ts | 35 ++ 2 files changed, 223 insertions(+), 174 deletions(-) create mode 100644 packages/ui/src/stores/useMobileSessionTreeStore.ts diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index 8fb2e576..e0c6786a 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -4,6 +4,7 @@ import { RiArrowDownLine, RiArrowDownSLine, RiArrowUpLine, + RiArrowUpSLine, RiCheckLine, RiCloseLine, RiDeleteBinLine, @@ -46,6 +47,7 @@ import { cn } from '@/lib/utils'; import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useAllLiveSessions } from '@/sync/sync-context'; @@ -92,6 +94,12 @@ type ProjectNode = { 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; + const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); @@ -251,19 +259,19 @@ const SessionRow: React.FC<{ > @@ -286,10 +294,9 @@ const SessionRow: React.FC<{ }; const ShowMoreRow: React.FC<{ - remaining: number; indent: number; onClick: () => void; -}> = ({ remaining, indent, onClick }) => { +}> = ({ indent, onClick }) => { const { t } = useI18n(); return ( + ); +}; + +const ShowFewerRow: React.FC<{ + indent: number; + onClick: () => void; +}> = ({ indent, onClick }) => { + const { t } = useI18n(); + return ( + ); }; @@ -426,6 +451,10 @@ export const MobileSessionsSheet: React.FC = ({ open, 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 [query, setQuery] = React.useState(''); const [directoryDialogOpen, setDirectoryDialogOpen] = React.useState(false); const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false); @@ -434,21 +463,18 @@ export const MobileSessionsSheet: React.FC = ({ open, const [gitProjectPaths, setGitProjectPaths] = React.useState>(new Set()); const [editingOrder, setEditingOrder] = React.useState(false); const [confirmingDeleteId, setConfirmingDeleteId] = React.useState(null); - // Buckets the user explicitly expanded past the SESSIONS_PER_BUCKET cap. Key: `${projectId}::${bucketKey}`. - const [expandedBucketsAll, setExpandedBucketsAll] = React.useState>(new Set()); - // User overrides: true = user explicitly expanded, false = user explicitly collapsed, - // missing key = use the default rule (active project/worktree expanded). - const [projectOverrides, setProjectOverrides] = React.useState>(new Map()); - const [worktreeOverrides, setWorktreeOverrides] = React.useState>(new Map()); + // Per-bucket count of sessions revealed past the default page. Ephemeral — + // resets when the sheet closes or when a group/project is toggled. Expand + // state itself lives in useMobileSessionTreeStore (persisted). + // Key: `${projectId}::${bucketKey}`. + const [visibleCountByBucket, setVisibleCountByBucket] = React.useState>(new Map()); React.useEffect(() => { if (!open) { setQuery(''); - setProjectOverrides(new Map()); - setWorktreeOverrides(new Map()); setEditingOrder(false); setConfirmingDeleteId(null); - setExpandedBucketsAll(new Set()); + setVisibleCountByBucket(new Map()); return; } void refreshGlobalSessions(liveSessions); @@ -590,50 +616,98 @@ export const MobileSessionsSheet: React.FC = ({ open, return matched?.path ?? node.project.path; }; - // Default rule for a project: active project expanded; non-empty query expands any node - // that contains a matching session. User override (true/false) wins over defaults. - const isProjectExpanded = (node: ProjectNode): boolean => { - const override = projectOverrides.get(node.project.id); - if (override !== undefined) return override; - if (normalizedQuery) { - return node.buckets.some((bucket) => - bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)), - ); - } - return node.isActive; - }; + // 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 => { - const key = `${node.project.id}::${bucket.key}`; - const override = worktreeOverrides.get(key); - if (override !== undefined) return override; - if (normalizedQuery) { - return bucket.sessions.some((session) => - sessionMatchesQuery(session, node.project.label, normalizedQuery), - ); - } - return findActiveWorktreePath(node) === bucket.path; - }; + const isWorktreeExpanded = (node: ProjectNode, bucket: WorktreeBucket): boolean => + worktreeExpandedMap[`${node.project.id}::${bucket.key}`] ?? false; - const toggleProject = (projectId: string, currentlyExpanded: boolean) => { - setProjectOverrides((previous) => { + const resetBucketVisibleCount = (bucketKey: string) => { + setVisibleCountByBucket((previous) => { + if (!previous.has(bucketKey)) return previous; const next = new Map(previous); - next.set(projectId, !currentlyExpanded); + 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 flat list of a bucket's sessions. Used for both root/project-level + // sessions and the sessions nested under a worktree group. + const renderBucketSessions = (node: ProjectNode, bucket: WorktreeBucket, indent: number) => { + const bucketKey = `${node.project.id}::${bucket.key}`; + const visibleCount = visibleCountByBucket.get(bucketKey) ?? SESSIONS_PER_BUCKET; + const visibleSessions = bucket.sessions.slice(0, visibleCount); + const remaining = bucket.sessions.length - visibleSessions.length; + const canShowFewer = bucket.sessions.length > SESSIONS_PER_BUCKET && remaining === 0; + return ( +
+ {visibleSessions.map((session) => ( + handleSelectSession(session)} + /> + ))} + {remaining > 0 ? ( + showMoreBucketSessions(bucketKey, visibleSessions.length)} /> + ) : null} + {canShowFewer ? ( + resetBucketVisibleCount(bucketKey)} /> + ) : null} +
+ ); + }; + + // Toggling resets the visible-session count for the affected buckets so a + // re-expanded group starts from the default page again. + const toggleProject = (projectId: string, currentlyExpanded: boolean) => { + setProjectExpanded(projectId, !currentlyExpanded); + resetProjectVisibleCounts(projectId); + }; + const toggleWorktree = (projectId: string, bucketKey: string, currentlyExpanded: boolean) => { - const key = `${projectId}::${bucketKey}`; - setWorktreeOverrides((previous) => { - const next = new Map(previous); - next.set(key, !currentlyExpanded); - return next; - }); + setWorktreeExpanded(`${projectId}::${bucketKey}`, !currentlyExpanded); + resetBucketVisibleCount(`${projectId}::${bucketKey}`); }; const handleSelectSession = (session: Session) => { - void setCurrentSession(session.id, getSessionDirectory(session) || null); + 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 = projectsMeta.find((entry) => { + if (pathBelongsToRoot(directory ?? '', entry.path)) return true; + return entry.worktrees.some((worktree) => pathBelongsToRoot(directory ?? '', worktree.path)); + }); + if (project) setActiveProjectIdOnly(project.id); + void setCurrentSession(session.id, directory); onOpenChange(false); }; @@ -959,10 +1033,6 @@ export const MobileSessionsSheet: React.FC = ({ open, ), ) : node.buckets; - const showWorktreeLevel = node.buckets.length > 1; - // Align session title left edge with the parent label's letters. - // Project label sits at ≈52px; worktree label at ≈68px. SessionRow adds 16px (dot + gap) on top of indent. - const sessionsIndent = showWorktreeLevel ? 52 : 36; const activeWorktreePath = findActiveWorktreePath(node); return (
= ({ open, {projectExpanded ? (
- {showWorktreeLevel - ? buckets.map((bucket) => { - const worktreeExpanded = isWorktreeExpanded(node, bucket); - const isActiveWt = activeWorktreePath === bucket.path; - return ( -
- - {worktreeExpanded ? ( - (() => { - const bucketKey = `${node.project.id}::${bucket.key}`; - const showAll = Boolean(normalizedQuery) || expandedBucketsAll.has(bucketKey); - const visibleSessions = showAll - ? bucket.sessions - : bucket.sessions.slice(0, SESSIONS_PER_BUCKET); - const remaining = bucket.sessions.length - visibleSessions.length; - return ( -
- {visibleSessions.map((session) => ( - handleSelectSession(session)} - /> - ))} - {remaining > 0 ? ( - - setExpandedBucketsAll((previous) => { - const next = new Set(previous); - next.add(bucketKey); - return next; - }) - } - /> - ) : null} -
- ); - })() - ) : null} -
- ); - }) - : (() => { - const bucket = buckets[0]; - if (!bucket) return null; - const bucketKey = `${node.project.id}::${bucket.key}`; - const showAll = Boolean(normalizedQuery) || expandedBucketsAll.has(bucketKey); - const visibleSessions = showAll - ? bucket.sessions - : bucket.sessions.slice(0, SESSIONS_PER_BUCKET); - const remaining = bucket.sessions.length - visibleSessions.length; - return ( -
- {visibleSessions.map((session) => ( - handleSelectSession(session)} - /> - ))} - {remaining > 0 ? ( - - setExpandedBucketsAll((previous) => { - const next = new Set(previous); - next.add(bucketKey); - return next; - }) + {(() => { + // 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 ( +
+
- ); - })()} + style={{ touchAction: 'manipulation' }} + > + + + + {bucket.label} + + {isActiveWt ? ( + + ) : null} + + {bucket.sessions.length} + + + {worktreeExpanded + ? renderBucketSessions(node, bucket, WORKTREE_SESSION_INDENT) + : null} +
+ ); + })} + + ); + })()}
) : null}
diff --git a/packages/ui/src/stores/useMobileSessionTreeStore.ts b/packages/ui/src/stores/useMobileSessionTreeStore.ts new file mode 100644 index 00000000..e9e9bec2 --- /dev/null +++ b/packages/ui/src/stores/useMobileSessionTreeStore.ts @@ -0,0 +1,35 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +/** + * Expand/collapse state for the mobile sessions sheet tree. + * + * Stores only explicit user overrides, keyed by project id (projects) and + * `${projectId}::${bucketKey}` (worktree groups). A missing key means "use the + * default": projects start expanded, worktree groups start collapsed. The + * user's choice is remembered across app restarts and is intentionally + * decoupled from the active directory/session — selecting a session no longer + * forces a project open or closed. + */ +type MobileSessionTreeStore = { + projectExpanded: Record; + worktreeExpanded: Record; + setProjectExpanded: (projectId: string, expanded: boolean) => void; + setWorktreeExpanded: (key: string, expanded: boolean) => void; +}; + +export const useMobileSessionTreeStore = create()( + persist( + (set) => ({ + projectExpanded: {}, + worktreeExpanded: {}, + setProjectExpanded: (projectId, expanded) => + set((state) => ({ projectExpanded: { ...state.projectExpanded, [projectId]: expanded } })), + setWorktreeExpanded: (key, expanded) => + set((state) => ({ worktreeExpanded: { ...state.worktreeExpanded, [key]: expanded } })), + }), + { + name: 'mobile-session-tree', + }, + ), +);