From 962df25ff6bfe2d2ab4b7f34838a774a72f50ce8 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Mon, 18 May 2026 03:38:32 -0400 Subject: [PATCH 1/2] feat(ui): implement recursive nested session expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change enables the UI to display nested sessions within nested sessions in a foldable display, recursively, up to 10 levels deep. ## What Changed ### Core Data Structure (session-state.ts) - Redefined `SessionThread` type to support true recursive nesting: - Old: `{ parent: Session, children: Session[], latestUpdated: number }` - New: `{ session: Session, children: SessionThread[], depth: number, hasChildren: boolean, latestUpdated: number }` - Renamed `expandedSessionParents` signal to `expandedSessions` to reflect that ANY session with children can now be expanded, not just top-level parents - Updated `getSessionThreads()` to build a recursive tree structure using new `buildSessionThreadTree()` and `computeThreadSignature()` helpers - Updated `getSessionFamily()` to recursively collect ALL descendants, not just direct children - Updated `getVisibleSessionIds()` with `collectVisibleSessionIds()` helper to recursively collect visible session IDs based on expansion state - Maintained backward compatibility aliases for renamed functions: `isSessionParentExpanded`, `setSessionParentExpanded`, etc. ### Session State Exports (sessions.ts) - Updated imports and exports to use the new function names: `ensureSessionExpanded`, `isSessionExpanded`, `setSessionExpanded`, `toggleSessionExpanded` ### Session Events (session-events.ts) - Updated `ensureSessionParentExpanded` → `ensureSessionExpanded` in auto-expand logic for child sessions that start working ### Permission Modal (permission-approval-modal.tsx) - Updated import and usage of `ensureSessionParentExpanded` → `ensureSessionExpanded` ### UI Rendering (session-list.tsx) - Updated `SessionRow` component to accept `session` object directly instead of `sessionId`, plus `depth` and `isLastChild` props - Derived `isChild` from `depth > 0` instead of explicit prop - Added `depthClass()` for CSS depth-based indentation - Created new `SessionThreadRow` recursive component that: - Renders the current session via SessionRow - If expanded and has children, recursively renders children with increased depth - Updated `filteredThreads` with `subtreeHasMatch()` and `filterThreadTree()` helpers for recursive filtering - Updated `allMatchingSessionIds` with `collectThreadIds()` helper for recursive ID collection - Removed child-specific `Bot` icon - all sessions now use `User` icon - Updated expander visibility to show for ANY session with children, regardless of depth ### Styling (session-layout.css) - Added depth-based CSS classes `.session-item-depth-{1-10}` with: - Progressive indentation: 2.25rem for depth 1, up to 13.5rem for depth 10 - Tree connector styling via `::before` and `::after` pseudo-elements - Proper vertical line handling for last-child at each depth level ### Tests (session-state.test.ts) - Added comprehensive test suite (683 lines) covering: - `getSessionThreads`: empty sessions, single sessions, single-level children, multi-level nested children, sorting, hasChildren computation - `getSessionFamily`: recursive descendant collection - Expansion state: toggle, explicit set, ensure logic - `getVisibleSessionIds`: visibility based on expansion state at multiple levels ## User-Facing Behavior - Nested sessions can now be collapsed/expanded at any depth level - A chevron expander appears on any session that has children - Children are indented based on their nesting depth - Tree lines connect parent-child relationships visually - Expanding a parent auto-expands ancestors when selecting a deeply nested child session ## Edge Cases Handled - Sessions with no children show no expander - Last child at each depth level has shortened vertical tree line - Thread sorting by latestUpdated works correctly with nested updates - Cache invalidation properly tracks thread changes at all depth levels ## Implementation Notes - Depth is limited to 10 levels to prevent excessive indentation - The `hasChildren` flag is computed once during tree building for performance - The Session type's `parentId` field already supported arbitrary nesting - only the UI rendering needed to be updated --- .../components/permission-approval-modal.tsx | 4 +- packages/ui/src/components/session-list.tsx | 259 ++++--- .../stores/__tests__/session-state.test.ts | 683 ++++++++++++++++++ packages/ui/src/stores/session-events.ts | 6 +- packages/ui/src/stores/session-state.ts | 279 ++++--- packages/ui/src/stores/sessions.ts | 16 +- .../ui/src/styles/panels/session-layout.css | 124 +++- 7 files changed, 1165 insertions(+), 206 deletions(-) create mode 100644 packages/ui/src/stores/__tests__/session-state.test.ts diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 4e2aff614..e72640e77 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -10,7 +10,7 @@ import { getQuestionEnqueuedAtForInstance, sendPermissionResponse, } from "../stores/instances" -import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" +import { ensureSessionExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" const LazyToolCall = lazy(() => import("./tool-call")) @@ -249,7 +249,7 @@ const PermissionApprovalModal: Component = (props) const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) const parentId = session?.parentId ?? session?.id if (parentId) { - ensureSessionParentExpanded(props.instanceId, parentId) + ensureSessionExpanded(props.instanceId, parentId) } setActiveSessionFromList(props.instanceId, sessionId) diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index c113a9dd7..281818b38 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -11,15 +11,15 @@ import { useI18n } from "../lib/i18n" import { showConfirmDialog } from "../stores/alerts" import { deleteSession, - ensureSessionParentExpanded, + ensureSessionExpanded, getVisibleSessionIds, - isSessionParentExpanded, + isSessionExpanded, loadMessages, loading, renameSession, sessions as sessionStateSessions, setActiveSessionFromList, - toggleSessionParentExpanded, + toggleSessionExpanded, } from "../stores/sessions" import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees" import { getLogger } from "../lib/logger" @@ -78,33 +78,63 @@ const SessionList: Component = (props) => { return sessionId.toLowerCase().includes(query) } + // Recursively check if any session in a thread subtree matches the query + const subtreeHasMatch = (thread: SessionThread, query: string): boolean => { + if (sessionMatchesQuery(thread.session.id, query)) return true + for (const child of thread.children) { + if (subtreeHasMatch(child, query)) return true + } + return false + } + + // Recursively filter threads while preserving the tree structure + const filterThreadTree = (thread: SessionThread, query: string): SessionThread | null => { + const parentMatches = sessionMatchesQuery(thread.session.id, query) + + if (parentMatches) { + // Parent matches: keep all children (but filter them recursively in case query is deeper) + const filteredChildren: SessionThread[] = [] + for (const child of thread.children) { + const filteredChild = filterThreadTree(child, query) + if (filteredChild !== null) filteredChildren.push(filteredChild) + } + return { ...thread, children: filteredChildren } + } + + // Parent doesn't match: check if any descendant matches + const matchingChildren: SessionThread[] = [] + for (const child of thread.children) { + const filteredChild = filterThreadTree(child, query) + if (filteredChild !== null) matchingChildren.push(filteredChild) + } + + if (matchingChildren.length === 0) return null + + // Return thread with only matching descendants + return { ...thread, children: matchingChildren } + } + const filteredThreads = createMemo(() => { const query = normalizedQuery() if (!query) return props.threads - const next: SessionThread[] = [] + const result: SessionThread[] = [] for (const thread of props.threads) { - const parentMatches = sessionMatchesQuery(thread.parent.id, query) - const matchingChildren = thread.children.filter((child) => sessionMatchesQuery(child.id, query)) - - if (!parentMatches && matchingChildren.length === 0) continue - - next.push({ - parent: thread.parent, - children: matchingChildren, - latestUpdated: thread.latestUpdated, - }) + const filtered = filterThreadTree(thread, query) + if (filtered !== null) result.push(filtered) } - - return next + return result }) const allMatchingSessionIds = createMemo(() => { const ids: string[] = [] - for (const thread of filteredThreads()) { - ids.push(thread.parent.id) - for (const child of thread.children) ids.push(child.id) + const collectIds = (threads: SessionThread[]) => { + for (const thread of threads) { + ids.push(thread.session.id) + collectIds(thread.children) + } } + collectIds(filteredThreads()) return ids }) @@ -128,14 +158,14 @@ const SessionList: Component = (props) => { const deleting = loading().deletingSession.get(props.instanceId) return deleting ? deleting.has(sessionId) : false } - + const selectSession = (sessionId: string) => { const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) // If the user selects a child session, make sure its parent thread is expanded. // For parent sessions we don't force expansion; user can collapse/expand freely. if (session?.parentId) { - ensureSessionParentExpanded(props.instanceId, session.parentId) + ensureSessionExpanded(props.instanceId, session.parentId) } props.onSelect(sessionId) @@ -282,21 +312,25 @@ const SessionList: Component = (props) => { }) } - const getSelectableThreadIds = (parentId: string): string[] => { - const query = normalizedQuery() - const source = query ? filteredThreads() : props.threads - const thread = source.find((t) => t.parent.id === parentId) - if (!thread) return [parentId] - return [thread.parent.id, ...thread.children.map((c) => c.id)] + // Recursively collect all session IDs from a thread tree + const collectThreadIds = (threads: SessionThread[]): string[] => { + const ids: string[] = [] + for (const thread of threads) { + ids.push(thread.session.id) + ids.push(...collectThreadIds(thread.children)) + } + return ids + } + + const getSelectableThreadIds = (rootSessionId: string): string[] => { + const source = normalizedQuery() ? filteredThreads() : props.threads + const rootThread = source.find((t) => t.session.id === rootSessionId) + if (!rootThread) return [rootSessionId] + return collectThreadIds([rootThread]) } const getAllSessionIdsInOrder = (threads: SessionThread[]): string[] => { - const ids: string[] = [] - threads.forEach((thread) => { - ids.push(thread.parent.id) - thread.children.forEach((child) => ids.push(child.id)) - }) - return ids + return collectThreadIds(threads) } const handleToggleSelectAll = (checked: boolean) => { @@ -379,37 +413,35 @@ const SessionList: Component = (props) => { }) } } - + const SessionRow: Component<{ - sessionId: string - isChild?: boolean - isLastChild?: boolean - hasChildren?: boolean + session: SessionThread["session"] + depth: number + isLastChild: boolean + hasChildren: boolean expanded?: boolean onToggleExpand?: () => void }> = (rowProps) => { - const session = createMemo(() => sessionStateSessions().get(props.instanceId)?.get(rowProps.sessionId)) - if (!session()) { - return <> - } + const sessionId = () => rowProps.session.id + const isChild = () => rowProps.depth > 0 const worktreeSlug = createMemo(() => { - if (rowProps.isChild) return "root" - return getWorktreeSlugForParentSession(props.instanceId, rowProps.sessionId) + if (isChild()) return "root" + return getWorktreeSlugForParentSession(props.instanceId, sessionId()) }) const showWorktreeBadge = createMemo(() => { - if (rowProps.isChild) return false + if (isChild()) return false if (getGitRepoStatus(props.instanceId) === false) return false const slug = worktreeSlug() return Boolean(slug) && slug !== "root" }) - const isActive = () => props.activeSessionId === rowProps.sessionId - const title = () => session()?.title || t("sessionList.session.untitled") - const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) - const retry = () => getSessionRetry(props.instanceId, rowProps.sessionId) + const isActive = () => props.activeSessionId === sessionId() + const title = () => rowProps.session.title || t("sessionList.session.untitled") + const status = () => getSessionStatus(props.instanceId, sessionId()) + const retry = () => getSessionRetry(props.instanceId, sessionId()) const statusLabel = () => { const retryState = retry() if (retryState) { @@ -425,20 +457,20 @@ const SessionList: Component = (props) => { return t("sessionList.status.idle") } } - const needsPermission = () => Boolean(session()?.pendingPermission) - const needsQuestion = () => Boolean((session() as any)?.pendingQuestion) + const needsPermission = () => Boolean(rowProps.session.pendingPermission) + const needsQuestion = () => Boolean((rowProps.session as any)?.pendingQuestion) const needsInput = () => needsPermission() || needsQuestion() const statusClassName = () => { if (needsInput()) return "session-permission" const base = `session-${retry() ? "retrying" : status()}` - const fadeClass = getSessionIdleFadeClass(props.instanceId, rowProps.sessionId) + const fadeClass = getSessionIdleFadeClass(props.instanceId, sessionId()) return fadeClass ? `${base} ${fadeClass}` : base } const showStatus = () => needsInput() || shouldShowSessionStatus( props.instanceId, - rowProps.sessionId, + sessionId(), now(), preferences().keepUnseenSubagentIdleStatus, ) @@ -457,14 +489,14 @@ const SessionList: Component = (props) => { }) } - const isSelected = () => selectedSessionIds().has(rowProps.sessionId) + const isSelected = () => selectedSessionIds().has(sessionId()) const parentGroupState = createMemo(() => { - if (rowProps.isChild) { - return { checked: isSelected(), indeterminate: false, ids: [rowProps.sessionId] } + if (isChild()) { + return { checked: isSelected(), indeterminate: false, ids: [sessionId()] } } - const ids = getSelectableThreadIds(rowProps.sessionId) + const ids = getSelectableThreadIds(sessionId()) const selected = selectedSessionIds() const selectedInGroup = ids.reduce((count, id) => (selected.has(id) ? count + 1 : count), 0) return { @@ -480,12 +512,15 @@ const SessionList: Component = (props) => { rowCheckboxEl.indeterminate = parentGroupState().indeterminate }) + // Build depth-based class for indentation + const depthClass = () => isChild() ? `session-item-depth-${Math.min(rowProps.depth, 10)}` : "" + return (