feat: persist mobile sessions tree state and fix root sessions layout

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.
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 15:37:23 +03:00
parent 23d732cf9c
commit 51682dc7af
2 changed files with 223 additions and 174 deletions
+188 -174
View File
@@ -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<{
>
<button
type="button"
className="flex min-h-12 min-w-0 flex-1 items-start gap-2.5 py-2 pr-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
className="flex min-h-12 min-w-0 flex-1 items-center gap-2.5 py-2 pr-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
style={{ paddingLeft: indent, touchAction: 'manipulation' }}
onClick={onSelect}
>
<span
className={cn(
'mt-1.5 size-1.5 shrink-0 rounded-full',
active ? 'bg-primary' : 'bg-muted-foreground/30',
)}
aria-hidden
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2">
<span className="flex items-center gap-2.5">
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
active ? 'bg-primary' : 'bg-muted-foreground/30',
)}
aria-hidden
/>
<span
className={cn(
'block min-w-0 flex-1 truncate typography-ui-label',
@@ -277,7 +285,7 @@ const SessionRow: React.FC<{
) : null}
</span>
{contextLabel ? (
<span className="block truncate typography-micro text-muted-foreground">{contextLabel}</span>
<span className="block truncate typography-micro text-muted-foreground pl-4">{contextLabel}</span>
) : null}
</span>
</button>
@@ -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 (
<button
@@ -299,7 +306,25 @@ const ShowMoreRow: React.FC<{
onClick={onClick}
>
<RiArrowDownSLine className="size-4" />
<span className="typography-micro">{t('mobile.sessions.showMore', { count: remaining })}</span>
<span className="typography-micro">{t('sessions.sidebar.group.showMore')}</span>
</button>
);
};
const ShowFewerRow: React.FC<{
indent: number;
onClick: () => void;
}> = ({ indent, onClick }) => {
const { t } = useI18n();
return (
<button
type="button"
className="flex min-h-10 w-full items-center gap-2 py-1.5 pr-3 text-left text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
style={{ paddingLeft: indent, touchAction: 'manipulation' }}
onClick={onClick}
>
<RiArrowUpSLine className="size-4" />
<span className="typography-micro">{t('sessions.sidebar.group.showFewer')}</span>
</button>
);
};
@@ -426,6 +451,10 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ 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<MobileSessionsSheetProps> = ({ open,
const [gitProjectPaths, setGitProjectPaths] = React.useState<Set<string>>(new Set());
const [editingOrder, setEditingOrder] = React.useState(false);
const [confirmingDeleteId, setConfirmingDeleteId] = React.useState<string | null>(null);
// Buckets the user explicitly expanded past the SESSIONS_PER_BUCKET cap. Key: `${projectId}::${bucketKey}`.
const [expandedBucketsAll, setExpandedBucketsAll] = React.useState<Set<string>>(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<Map<string, boolean>>(new Map());
const [worktreeOverrides, setWorktreeOverrides] = React.useState<Map<string, boolean>>(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<Map<string, number>>(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<MobileSessionsSheetProps> = ({ 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 (
<div>
{visibleSessions.map((session) => (
<SessionRow
key={session.id}
session={session}
active={currentSessionId === session.id}
indent={indent}
onSelect={() => handleSelectSession(session)}
/>
))}
{remaining > 0 ? (
<ShowMoreRow indent={indent} onClick={() => showMoreBucketSessions(bucketKey, visibleSessions.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) => {
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<MobileSessionsSheetProps> = ({ 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 (
<section
@@ -1001,122 +1071,66 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
{projectExpanded ? (
<div className="pb-2">
{showWorktreeLevel
? buckets.map((bucket) => {
const worktreeExpanded = isWorktreeExpanded(node, bucket);
const isActiveWt = activeWorktreePath === bucket.path;
return (
<div key={bucket.key}>
<button
type="button"
className="flex min-h-11 w-full items-center gap-2 py-1.5 pl-4 pr-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
onClick={() => toggleWorktree(node.project.id, bucket.key, worktreeExpanded)}
aria-expanded={worktreeExpanded}
aria-label={
worktreeExpanded
? t('sessions.sidebar.group.collapseAria', { label: bucket.label })
: t('sessions.sidebar.group.expandAria', { label: bucket.label })
}
style={{ touchAction: 'manipulation' }}
>
<ChevronToggle expanded={worktreeExpanded} />
<Icon
name="node-tree"
className={cn(
'size-4 shrink-0',
isActiveWt ? 'text-primary' : 'text-muted-foreground',
)}
/>
<span
className={cn(
'block min-w-0 flex-1 truncate typography-ui-label',
isActiveWt ? 'text-foreground' : 'text-foreground/90',
)}
>
{bucket.label}
</span>
{isActiveWt ? (
<ActiveDot ariaLabel={t('mobile.sessions.activeWorktreeAria')} />
) : null}
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
{bucket.sessions.length}
</span>
</button>
{worktreeExpanded ? (
(() => {
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 (
<div>
{visibleSessions.map((session) => (
<SessionRow
key={session.id}
session={session}
active={currentSessionId === session.id}
indent={sessionsIndent}
onSelect={() => handleSelectSession(session)}
/>
))}
{remaining > 0 ? (
<ShowMoreRow
remaining={remaining}
indent={sessionsIndent}
onClick={() =>
setExpandedBucketsAll((previous) => {
const next = new Set(previous);
next.add(bucketKey);
return next;
})
}
/>
) : null}
</div>
);
})()
) : null}
</div>
);
})
: (() => {
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 (
<div>
{visibleSessions.map((session) => (
<SessionRow
key={session.id}
session={session}
active={currentSessionId === session.id}
indent={sessionsIndent}
onSelect={() => handleSelectSession(session)}
/>
))}
{remaining > 0 ? (
<ShowMoreRow
remaining={remaining}
indent={sessionsIndent}
onClick={() =>
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 (
<div key={bucket.key}>
<button
type="button"
className="flex min-h-11 w-full items-center gap-2 py-1.5 pl-4 pr-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
onClick={() => toggleWorktree(node.project.id, bucket.key, worktreeExpanded)}
aria-expanded={worktreeExpanded}
aria-label={
worktreeExpanded
? t('sessions.sidebar.group.collapseAria', { label: bucket.label })
: t('sessions.sidebar.group.expandAria', { label: bucket.label })
}
/>
) : null}
</div>
);
})()}
style={{ touchAction: 'manipulation' }}
>
<ChevronToggle expanded={worktreeExpanded} />
<Icon
name="node-tree"
className={cn(
'size-4 shrink-0',
isActiveWt ? 'text-primary' : 'text-muted-foreground',
)}
/>
<span
className={cn(
'block min-w-0 flex-1 truncate typography-ui-label',
isActiveWt ? 'text-foreground' : 'text-foreground/90',
)}
>
{bucket.label}
</span>
{isActiveWt ? (
<ActiveDot ariaLabel={t('mobile.sessions.activeWorktreeAria')} />
) : null}
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
{bucket.sessions.length}
</span>
</button>
{worktreeExpanded
? renderBucketSessions(node, bucket, WORKTREE_SESSION_INDENT)
: null}
</div>
);
})}
</>
);
})()}
</div>
) : null}
</section>
@@ -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<string, boolean>;
worktreeExpanded: Record<string, boolean>;
setProjectExpanded: (projectId: string, expanded: boolean) => void;
setWorktreeExpanded: (key: string, expanded: boolean) => void;
};
export const useMobileSessionTreeStore = create<MobileSessionTreeStore>()(
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',
},
),
);