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:
@@ -4,6 +4,7 @@ import {
|
|||||||
RiArrowDownLine,
|
RiArrowDownLine,
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
RiArrowUpLine,
|
RiArrowUpLine,
|
||||||
|
RiArrowUpSLine,
|
||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiCloseLine,
|
RiCloseLine,
|
||||||
RiDeleteBinLine,
|
RiDeleteBinLine,
|
||||||
@@ -46,6 +47,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||||
|
import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useAllLiveSessions } from '@/sync/sync-context';
|
import { useAllLiveSessions } from '@/sync/sync-context';
|
||||||
@@ -92,6 +94,12 @@ type ProjectNode = {
|
|||||||
|
|
||||||
const SESSIONS_PER_BUCKET = 7;
|
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 =>
|
const normalizePath = (value?: string | null): string =>
|
||||||
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||||
|
|
||||||
@@ -251,19 +259,19 @@ const SessionRow: React.FC<{
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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' }}
|
style={{ paddingLeft: indent, touchAction: 'manipulation' }}
|
||||||
onClick={onSelect}
|
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 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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'block min-w-0 flex-1 truncate typography-ui-label',
|
'block min-w-0 flex-1 truncate typography-ui-label',
|
||||||
@@ -277,7 +285,7 @@ const SessionRow: React.FC<{
|
|||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
{contextLabel ? (
|
{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}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -286,10 +294,9 @@ const SessionRow: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ShowMoreRow: React.FC<{
|
const ShowMoreRow: React.FC<{
|
||||||
remaining: number;
|
|
||||||
indent: number;
|
indent: number;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}> = ({ remaining, indent, onClick }) => {
|
}> = ({ indent, onClick }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -299,7 +306,25 @@ const ShowMoreRow: React.FC<{
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<RiArrowDownSLine className="size-4" />
|
<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>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -426,6 +451,10 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||||
const reorderProjects = useProjectsStore((state) => state.reorderProjects);
|
const reorderProjects = useProjectsStore((state) => state.reorderProjects);
|
||||||
const removeProject = useProjectsStore((state) => state.removeProject);
|
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 [query, setQuery] = React.useState('');
|
||||||
const [directoryDialogOpen, setDirectoryDialogOpen] = React.useState(false);
|
const [directoryDialogOpen, setDirectoryDialogOpen] = React.useState(false);
|
||||||
const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = 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 [gitProjectPaths, setGitProjectPaths] = React.useState<Set<string>>(new Set());
|
||||||
const [editingOrder, setEditingOrder] = React.useState(false);
|
const [editingOrder, setEditingOrder] = React.useState(false);
|
||||||
const [confirmingDeleteId, setConfirmingDeleteId] = React.useState<string | null>(null);
|
const [confirmingDeleteId, setConfirmingDeleteId] = React.useState<string | null>(null);
|
||||||
// Buckets the user explicitly expanded past the SESSIONS_PER_BUCKET cap. Key: `${projectId}::${bucketKey}`.
|
// Per-bucket count of sessions revealed past the default page. Ephemeral —
|
||||||
const [expandedBucketsAll, setExpandedBucketsAll] = React.useState<Set<string>>(new Set());
|
// resets when the sheet closes or when a group/project is toggled. Expand
|
||||||
// User overrides: true = user explicitly expanded, false = user explicitly collapsed,
|
// state itself lives in useMobileSessionTreeStore (persisted).
|
||||||
// missing key = use the default rule (active project/worktree expanded).
|
// Key: `${projectId}::${bucketKey}`.
|
||||||
const [projectOverrides, setProjectOverrides] = React.useState<Map<string, boolean>>(new Map());
|
const [visibleCountByBucket, setVisibleCountByBucket] = React.useState<Map<string, number>>(new Map());
|
||||||
const [worktreeOverrides, setWorktreeOverrides] = React.useState<Map<string, boolean>>(new Map());
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
setProjectOverrides(new Map());
|
|
||||||
setWorktreeOverrides(new Map());
|
|
||||||
setEditingOrder(false);
|
setEditingOrder(false);
|
||||||
setConfirmingDeleteId(null);
|
setConfirmingDeleteId(null);
|
||||||
setExpandedBucketsAll(new Set());
|
setVisibleCountByBucket(new Map());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refreshGlobalSessions(liveSessions);
|
void refreshGlobalSessions(liveSessions);
|
||||||
@@ -590,50 +616,98 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
return matched?.path ?? node.project.path;
|
return matched?.path ?? node.project.path;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default rule for a project: active project expanded; non-empty query expands any node
|
// Expansion is the user's own choice (persisted), independent of the active
|
||||||
// that contains a matching session. User override (true/false) wins over defaults.
|
// directory: projects default to expanded, worktree groups to collapsed.
|
||||||
const isProjectExpanded = (node: ProjectNode): boolean => {
|
const isProjectExpanded = (node: ProjectNode): boolean =>
|
||||||
const override = projectOverrides.get(node.project.id);
|
projectExpandedMap[node.project.id] ?? true;
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isWorktreeExpanded = (node: ProjectNode, bucket: WorktreeBucket): boolean => {
|
const isWorktreeExpanded = (node: ProjectNode, bucket: WorktreeBucket): boolean =>
|
||||||
const key = `${node.project.id}::${bucket.key}`;
|
worktreeExpandedMap[`${node.project.id}::${bucket.key}`] ?? false;
|
||||||
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 toggleProject = (projectId: string, currentlyExpanded: boolean) => {
|
const resetBucketVisibleCount = (bucketKey: string) => {
|
||||||
setProjectOverrides((previous) => {
|
setVisibleCountByBucket((previous) => {
|
||||||
|
if (!previous.has(bucketKey)) return previous;
|
||||||
const next = new Map(previous);
|
const next = new Map(previous);
|
||||||
next.set(projectId, !currentlyExpanded);
|
next.delete(bucketKey);
|
||||||
return next;
|
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 toggleWorktree = (projectId: string, bucketKey: string, currentlyExpanded: boolean) => {
|
||||||
const key = `${projectId}::${bucketKey}`;
|
setWorktreeExpanded(`${projectId}::${bucketKey}`, !currentlyExpanded);
|
||||||
setWorktreeOverrides((previous) => {
|
resetBucketVisibleCount(`${projectId}::${bucketKey}`);
|
||||||
const next = new Map(previous);
|
|
||||||
next.set(key, !currentlyExpanded);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectSession = (session: Session) => {
|
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);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -959,10 +1033,6 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: node.buckets;
|
: 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);
|
const activeWorktreePath = findActiveWorktreePath(node);
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -1001,122 +1071,66 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
|
|
||||||
{projectExpanded ? (
|
{projectExpanded ? (
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
{showWorktreeLevel
|
{(() => {
|
||||||
? buckets.map((bucket) => {
|
// Root (project-level) sessions always render as a flat list
|
||||||
const worktreeExpanded = isWorktreeExpanded(node, bucket);
|
// at the top — same as a project without worktrees — never
|
||||||
const isActiveWt = activeWorktreePath === bucket.path;
|
// hidden behind a worktree-style group.
|
||||||
return (
|
const rootBucket = buckets.find((bucket) => bucket.worktree === null);
|
||||||
<div key={bucket.key}>
|
const worktreeBuckets = buckets.filter((bucket) => bucket.worktree !== null);
|
||||||
<button
|
return (
|
||||||
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"
|
{rootBucket && rootBucket.sessions.length > 0
|
||||||
onClick={() => toggleWorktree(node.project.id, bucket.key, worktreeExpanded)}
|
? renderBucketSessions(node, rootBucket, PROJECT_SESSION_INDENT)
|
||||||
aria-expanded={worktreeExpanded}
|
: null}
|
||||||
aria-label={
|
{worktreeBuckets.map((bucket) => {
|
||||||
worktreeExpanded
|
const worktreeExpanded = isWorktreeExpanded(node, bucket);
|
||||||
? t('sessions.sidebar.group.collapseAria', { label: bucket.label })
|
const isActiveWt = activeWorktreePath === bucket.path;
|
||||||
: t('sessions.sidebar.group.expandAria', { label: bucket.label })
|
return (
|
||||||
}
|
<div key={bucket.key}>
|
||||||
style={{ touchAction: 'manipulation' }}
|
<button
|
||||||
>
|
type="button"
|
||||||
<ChevronToggle expanded={worktreeExpanded} />
|
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"
|
||||||
<Icon
|
onClick={() => toggleWorktree(node.project.id, bucket.key, worktreeExpanded)}
|
||||||
name="node-tree"
|
aria-expanded={worktreeExpanded}
|
||||||
className={cn(
|
aria-label={
|
||||||
'size-4 shrink-0',
|
worktreeExpanded
|
||||||
isActiveWt ? 'text-primary' : 'text-muted-foreground',
|
? t('sessions.sidebar.group.collapseAria', { label: bucket.label })
|
||||||
)}
|
: t('sessions.sidebar.group.expandAria', { label: bucket.label })
|
||||||
/>
|
|
||||||
<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;
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
style={{ touchAction: 'manipulation' }}
|
||||||
) : null}
|
>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user