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:
@@ -46,6 +46,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 { useMobileSessionExpansionStore } from '@/stores/useMobileSessionExpansionStore';
|
||||||
import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore';
|
import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { orderWorktrees, useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore';
|
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.
|
// worktree sessions sit one level deeper. SessionRow adds 16px (dot + gap) on top.
|
||||||
const PROJECT_SESSION_INDENT = 36;
|
const PROJECT_SESSION_INDENT = 36;
|
||||||
const WORKTREE_SESSION_INDENT = 52;
|
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 =>
|
const normalizePath = (value?: string | null): string =>
|
||||||
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||||
@@ -248,6 +254,10 @@ const SessionRow: React.FC<{
|
|||||||
contextLabel?: string;
|
contextLabel?: string;
|
||||||
/** When true, the row shows the two-step archive confirmation. */
|
/** When true, the row shows the two-step archive confirmation. */
|
||||||
confirmingArchive?: boolean;
|
confirmingArchive?: boolean;
|
||||||
|
/** When true, a chevron is shown in the left gutter to toggle nested subsessions. */
|
||||||
|
hasChildren?: boolean;
|
||||||
|
expanded?: boolean;
|
||||||
|
onToggleChildren?: () => void;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
/** When provided, an archive affordance is shown; first tap arms confirm, X cancels. */
|
/** When provided, an archive affordance is shown; first tap arms confirm, X cancels. */
|
||||||
onRequestArchive?: () => void;
|
onRequestArchive?: () => void;
|
||||||
@@ -258,6 +268,9 @@ const SessionRow: React.FC<{
|
|||||||
indent,
|
indent,
|
||||||
contextLabel,
|
contextLabel,
|
||||||
confirmingArchive = false,
|
confirmingArchive = false,
|
||||||
|
hasChildren = false,
|
||||||
|
expanded = false,
|
||||||
|
onToggleChildren,
|
||||||
onSelect,
|
onSelect,
|
||||||
onRequestArchive,
|
onRequestArchive,
|
||||||
onConfirmArchive,
|
onConfirmArchive,
|
||||||
@@ -268,11 +281,27 @@ const SessionRow: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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)]',
|
active && !confirmingArchive && 'bg-[color-mix(in_srgb,var(--primary)_10%,transparent)]',
|
||||||
confirmingArchive && 'bg-[color-mix(in_srgb,var(--destructive)_8%,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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -489,6 +518,8 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
const setProjectExpanded = useMobileSessionTreeStore((state) => state.setProjectExpanded);
|
const setProjectExpanded = useMobileSessionTreeStore((state) => state.setProjectExpanded);
|
||||||
const setWorktreeExpanded = useMobileSessionTreeStore((state) => state.setWorktreeExpanded);
|
const setWorktreeExpanded = useMobileSessionTreeStore((state) => state.setWorktreeExpanded);
|
||||||
const worktreeOrderByProject = useWorktreeOrderStore((state) => state.orderByProject);
|
const worktreeOrderByProject = useWorktreeOrderStore((state) => state.orderByProject);
|
||||||
|
const expandedParents = useMobileSessionExpansionStore((state) => state.expandedParents);
|
||||||
|
const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
|
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
|
||||||
const [confirmingArchiveSessionId, setConfirmingArchiveSessionId] = 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
|
// Paginated, tree-aware list of a bucket's sessions: top-level sessions paginate,
|
||||||
// sessions and the sessions nested under a worktree group.
|
// 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 renderBucketSessions = (node: ProjectNode, bucket: WorktreeBucket, indent: number) => {
|
||||||
const bucketKey = `${node.project.id}::${bucket.key}`;
|
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 visibleCount = visibleCountByBucket.get(bucketKey) ?? SESSIONS_PER_BUCKET;
|
||||||
const visibleSessions = bucket.sessions.slice(0, visibleCount);
|
const visibleRoots = roots.slice(0, visibleCount);
|
||||||
const remaining = bucket.sessions.length - visibleSessions.length;
|
const remaining = roots.length - visibleRoots.length;
|
||||||
const canShowFewer = bucket.sessions.length > SESSIONS_PER_BUCKET && remaining === 0;
|
const canShowFewer = roots.length > SESSIONS_PER_BUCKET && remaining === 0;
|
||||||
return (
|
|
||||||
<div>
|
const renderNode = (session: Session, rowIndent: number): React.ReactNode => {
|
||||||
{visibleSessions.map((session) => (
|
const children = childrenByParent.get(session.id) ?? [];
|
||||||
|
const hasChildren = children.length > 0;
|
||||||
|
const expanded = Boolean(expandedParents[session.id]);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={session.id}>
|
||||||
<SessionRow
|
<SessionRow
|
||||||
key={session.id}
|
|
||||||
session={session}
|
session={session}
|
||||||
active={currentSessionId === session.id}
|
active={currentSessionId === session.id}
|
||||||
indent={indent}
|
indent={rowIndent}
|
||||||
|
hasChildren={hasChildren}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggleChildren={hasChildren ? () => toggleParent(session.id) : undefined}
|
||||||
confirmingArchive={confirmingArchiveSessionId === session.id}
|
confirmingArchive={confirmingArchiveSessionId === session.id}
|
||||||
onSelect={() => handleSelectSession(session)}
|
onSelect={() => handleSelectSession(session)}
|
||||||
onRequestArchive={() => handleRequestArchive(session.id)}
|
onRequestArchive={() => handleRequestArchive(session.id)}
|
||||||
onConfirmArchive={() => void handleConfirmArchive(session)}
|
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 ? (
|
{remaining > 0 ? (
|
||||||
<ShowMoreRow indent={indent} onClick={() => showMoreBucketSessions(bucketKey, visibleSessions.length)} />
|
<ShowMoreRow indent={indent} onClick={() => showMoreBucketSessions(bucketKey, visibleRoots.length)} />
|
||||||
) : null}
|
) : null}
|
||||||
{canShowFewer ? (
|
{canShowFewer ? (
|
||||||
<ShowFewerRow indent={indent} onClick={() => resetBucketVisibleCount(bucketKey)} />
|
<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 };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user