feat: show subsessions under parent sessions in mobile sheet

Nest child sessions (by parentID) under their parent in the mobile sessions
list, with a chevron in the row's left gutter to expand/collapse them
recursively. Top-level pagination counts only parent sessions; children whose
parent isn't in the same bucket stay top-level so nothing is hidden.

Expansion state lives in an in-memory store that survives closing and
reopening the sheet but resets on a full page reload.
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 17:04:23 +03:00
parent e3c540e9c1
commit 61765ca32c
2 changed files with 101 additions and 13 deletions
+78 -13
View File
@@ -46,6 +46,7 @@ import { cn } from '@/lib/utils';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useMobileSessionExpansionStore } from '@/stores/useMobileSessionExpansionStore';
import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { orderWorktrees, useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore';
@@ -100,6 +101,11 @@ const SESSIONS_PER_BUCKET = 7;
// worktree sessions sit one level deeper. SessionRow adds 16px (dot + gap) on top.
const PROJECT_SESSION_INDENT = 36;
const WORKTREE_SESSION_INDENT = 52;
// Extra left padding applied to each nested subsession level.
const CHILD_INDENT_STEP = 18;
const getParentId = (session: Session): string | null =>
(session as Session & { parentID?: string | null }).parentID ?? null;
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
@@ -248,6 +254,10 @@ const SessionRow: React.FC<{
contextLabel?: string;
/** When true, the row shows the two-step archive confirmation. */
confirmingArchive?: boolean;
/** When true, a chevron is shown in the left gutter to toggle nested subsessions. */
hasChildren?: boolean;
expanded?: boolean;
onToggleChildren?: () => void;
onSelect: () => void;
/** When provided, an archive affordance is shown; first tap arms confirm, X cancels. */
onRequestArchive?: () => void;
@@ -258,6 +268,9 @@ const SessionRow: React.FC<{
indent,
contextLabel,
confirmingArchive = false,
hasChildren = false,
expanded = false,
onToggleChildren,
onSelect,
onRequestArchive,
onConfirmArchive,
@@ -268,11 +281,27 @@ const SessionRow: React.FC<{
return (
<div
className={cn(
'flex items-center gap-1 transition-colors',
'relative flex items-center gap-1 transition-colors',
active && !confirmingArchive && 'bg-[color-mix(in_srgb,var(--primary)_10%,transparent)]',
confirmingArchive && 'bg-[color-mix(in_srgb,var(--destructive)_8%,transparent)]',
)}
>
{hasChildren && onToggleChildren ? (
<button
type="button"
className="absolute z-10 flex w-6 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
style={{ left: Math.max(indent - 32, 2), top: 0, bottom: 0, touchAction: 'manipulation' }}
aria-label={expanded
? t('sessions.sidebar.session.subsessions.collapse')
: t('sessions.sidebar.session.subsessions.expand')}
onClick={(event) => {
event.stopPropagation();
onToggleChildren();
}}
>
<RiArrowDownSLine className={cn('size-[18px] transition-transform duration-150', expanded ? 'rotate-0' : '-rotate-90')} />
</button>
) : null}
<button
type="button"
className={cn(
@@ -489,6 +518,8 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const setProjectExpanded = useMobileSessionTreeStore((state) => state.setProjectExpanded);
const setWorktreeExpanded = useMobileSessionTreeStore((state) => state.setWorktreeExpanded);
const worktreeOrderByProject = useWorktreeOrderStore((state) => state.orderByProject);
const expandedParents = useMobileSessionExpansionStore((state) => state.expandedParents);
const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent);
const [query, setQuery] = React.useState('');
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
const [confirmingArchiveSessionId, setConfirmingArchiveSessionId] = React.useState<string | null>(null);
@@ -699,30 +730,64 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
});
};
// Paginated flat list of a bucket's sessions. Used for both root/project-level
// sessions and the sessions nested under a worktree group.
// Paginated, tree-aware list of a bucket's sessions: top-level sessions paginate,
// and a parent with subsessions can be expanded to reveal its children (nested,
// recursively). Pagination counts only top-level sessions.
const renderBucketSessions = (node: ProjectNode, bucket: WorktreeBucket, indent: number) => {
const bucketKey = `${node.project.id}::${bucket.key}`;
// Group children by parent within this bucket, and treat sessions whose parent
// is not in this bucket as top-level so nothing is hidden.
const idsInBucket = new Set(bucket.sessions.map((entry) => entry.id));
const childrenByParent = new Map<string, Session[]>();
for (const candidate of bucket.sessions) {
const parentId = getParentId(candidate);
if (parentId && idsInBucket.has(parentId)) {
const list = childrenByParent.get(parentId) ?? [];
list.push(candidate);
childrenByParent.set(parentId, list);
}
}
const roots = bucket.sessions.filter((entry) => {
const parentId = getParentId(entry);
return !parentId || !idsInBucket.has(parentId);
});
const visibleCount = visibleCountByBucket.get(bucketKey) ?? SESSIONS_PER_BUCKET;
const 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) => (
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
key={session.id}
session={session}
active={currentSessionId === session.id}
indent={indent}
indent={rowIndent}
hasChildren={hasChildren}
expanded={expanded}
onToggleChildren={hasChildren ? () => toggleParent(session.id) : undefined}
confirmingArchive={confirmingArchiveSessionId === session.id}
onSelect={() => handleSelectSession(session)}
onRequestArchive={() => handleRequestArchive(session.id)}
onConfirmArchive={() => void handleConfirmArchive(session)}
/>
))}
{hasChildren && expanded
? children.map((child) => renderNode(child, rowIndent + CHILD_INDENT_STEP))
: null}
</React.Fragment>
);
};
return (
<div>
{visibleRoots.map((session) => renderNode(session, indent))}
{remaining > 0 ? (
<ShowMoreRow indent={indent} onClick={() => showMoreBucketSessions(bucketKey, visibleSessions.length)} />
<ShowMoreRow indent={indent} onClick={() => showMoreBucketSessions(bucketKey, visibleRoots.length)} />
) : null}
{canShowFewer ? (
<ShowFewerRow indent={indent} onClick={() => resetBucketVisibleCount(bucketKey)} />
@@ -0,0 +1,23 @@
import { create } from 'zustand';
/**
* In-memory expand state for parent sessions (with subsessions) in the mobile
* sessions sheet. Deliberately NOT persisted: it survives closing and
* reopening the sheet (the sheet unmounts on close, but this module-level store
* does not), and resets on a full page reload.
*/
type MobileSessionExpansionStore = {
expandedParents: Record<string, boolean>;
toggleParent: (sessionId: string) => void;
};
export const useMobileSessionExpansionStore = create<MobileSessionExpansionStore>((set) => ({
expandedParents: {},
toggleParent: (sessionId) =>
set((state) => {
const next = { ...state.expandedParents };
if (next[sessionId]) delete next[sessionId];
else next[sessionId] = true;
return { expandedParents: next };
}),
}));