From 34706228bb772448ef057c0ce82b14c765ae49d0 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Sat, 11 Jul 2026 19:18:31 -0400 Subject: [PATCH] feat(ui): implement recursive nested session expansion (#478) 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 --------- Co-authored-by: Pascal André --- packages/ui/src/App.tsx | 9 +- .../shell/useInstanceSessionContext.ts | 21 +- packages/ui/src/components/message-block.tsx | 9 +- .../components/permission-approval-modal.tsx | 8 +- packages/ui/src/components/session-list.tsx | 235 ++++++------- .../src/components/session/session-view.tsx | 11 +- packages/ui/src/lib/keyboard.ts | 5 +- packages/ui/src/stores/session-events.ts | 18 +- packages/ui/src/stores/session-state.ts | 308 ++++++------------ packages/ui/src/stores/session-tree.test.ts | 109 +++++++ packages/ui/src/stores/session-tree.ts | 161 +++++++++ packages/ui/src/stores/sessions.ts | 18 +- packages/ui/src/stores/worktrees.ts | 6 +- .../ui/src/styles/panels/session-layout.css | 18 +- 14 files changed, 539 insertions(+), 397 deletions(-) create mode 100644 packages/ui/src/stores/session-tree.test.ts create mode 100644 packages/ui/src/stores/session-tree.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 866a896b..9fed3e3a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -42,6 +42,7 @@ import { } from "./stores/instances" import { getSessions, + getSessionRoot, activeSessionId, setActiveParentSession, clearActiveParentSession, @@ -408,12 +409,8 @@ const App: Component = () => { return } - const parentSessionId = session.parentId ?? session.id - const parentSession = sessions.find((s) => s.id === parentSessionId) - - if (!parentSession || parentSession.parentId !== null) { - return - } + const parentSession = getSessionRoot(instanceId, sessionId) + if (!parentSession) return clearActiveParentSession(instanceId) diff --git a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts index 9b84ba86..3544ac48 100644 --- a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts +++ b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts @@ -1,4 +1,4 @@ -import { batch, createMemo, type Accessor } from "solid-js" +import { createMemo, type Accessor } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" import type { Session } from "../../../types/session" import { @@ -8,8 +8,8 @@ import { getSessionInfo, getSessionThreads, sessions, - setActiveParentSession, setActiveSession, + setActiveSessionFromList, } from "../../../stores/sessions" import { messageStoreBus } from "../../../stores/message-v2/bus" import { getBackgroundProcesses } from "../../../stores/background-processes" @@ -131,21 +131,8 @@ export function useInstanceSessionContext(options: InstanceSessionContextOptions return } - const session = allInstanceSessions().get(sessionId) - if (!session) return - - if (session.parentId === null) { - setActiveParentSession(instanceId, sessionId) - return - } - - const parentId = session.parentId - if (!parentId) return - - batch(() => { - setActiveParentSession(instanceId, parentId) - setActiveSession(instanceId, sessionId) - }) + if (!allInstanceSessions().has(sessionId)) return + setActiveSessionFromList(instanceId, sessionId) } return { diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index b54c8678..c63449e4 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -8,7 +8,7 @@ import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../s import type { MessageRecord } from "../stores/message-v2/types" import { messageStoreBus } from "../stores/message-v2/bus" import { formatTokenTotal } from "../lib/formatters" -import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" +import { ensureSessionAncestorsExpanded, sessions, setActiveSessionFromList } from "../stores/sessions" import { selectInstanceTab } from "../stores/app-tabs" import { showAlertDialog } from "../stores/alerts" import { deleteMessage } from "../stores/session-actions" @@ -135,11 +135,8 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string function navigateToTaskSession(location: TaskSessionLocation) { selectInstanceTab(location.instanceId) - const parentToActivate = location.parentId ?? location.sessionId - setActiveParentSession(location.instanceId, parentToActivate) - if (location.parentId) { - setActiveSession(location.instanceId, location.sessionId) - } + ensureSessionAncestorsExpanded(location.instanceId, location.sessionId) + setActiveSessionFromList(location.instanceId, location.sessionId) } interface CachedBlockEntry { diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index d9329286..b2de5f11 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -11,7 +11,7 @@ import { getQuestionEnqueuedAtForInstance, sendPermissionResponse, } from "../stores/instances" -import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" +import { ensureSessionAncestorsExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants" @@ -262,10 +262,8 @@ const PermissionApprovalModal: Component = (props) function handleGoToSession(sessionId: string) { if (!sessionId) return - const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) - const parentId = session?.parentId ?? session?.id - if (parentId) { - ensureSessionParentExpanded(props.instanceId, parentId) + if (sessionStateSessions().get(props.instanceId)?.has(sessionId)) { + ensureSessionAncestorsExpanded(props.instanceId, sessionId) } setActiveSessionFromList(props.instanceId, sessionId) diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index aad207a8..7f144076 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, + ensureSessionAncestorsExpanded, getVisibleSessionIds, - isSessionParentExpanded, + isSessionExpanded, loadMessages, loading, renameSession, sessions as sessionStateSessions, setActiveSessionFromList, - toggleSessionParentExpanded, + toggleSessionExpanded, loadMoreSessions, searchSessions, getSessionHasMore, @@ -29,6 +29,7 @@ import { isSessionSearchLoading, } from "../stores/sessions" import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees" +import { collectSessionThreadIds, findSessionThread, sortSessionIdsDeepestFirst } from "../stores/session-tree" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useConfig } from "../stores/preferences" @@ -150,6 +151,16 @@ const SessionList: Component = (props) => { return sessionId.toLowerCase().includes(query) } + const filterThreadTree = (thread: SessionThread, query: string): SessionThread | null => { + const matchingChildren: SessionThread[] = [] + for (const child of thread.children) { + const filteredChild = filterThreadTree(child, query) + if (filteredChild !== null) matchingChildren.push(filteredChild) + } + if (!sessionMatchesQuery(thread.session.id, query) && matchingChildren.length === 0) return null + return { ...thread, children: matchingChildren } + } + const filteredThreads = createMemo(() => { const query = normalizedQuery() if (!query) return props.threads @@ -160,29 +171,23 @@ const SessionList: Component = (props) => { return getSessionSearchThreads(props.instanceId) } - 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 }) @@ -206,14 +211,13 @@ 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) + ensureSessionAncestorsExpanded(props.instanceId, session.id) } props.onSelect(sessionId) @@ -360,21 +364,14 @@ 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)] + const getSelectableThreadIds = (sessionId: string): string[] => { + const source = normalizedQuery() ? filteredThreads() : props.threads + const thread = findSessionThread(source, sessionId) + return thread ? collectSessionThreadIds([thread]) : [sessionId] } 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 collectSessionThreadIds(threads) } const handleToggleSelectAll = (checked: boolean) => { @@ -433,8 +430,9 @@ const SessionList: Component = (props) => { } } + const deletionOrder = sortSessionIdsDeepestFirst(sessionStateSessions().get(props.instanceId) ?? new Map(), selected) let failed = 0 - for (const sessionId of selected) { + for (const sessionId of deletionOrder) { try { // eslint-disable-next-line no-await-in-loop await deleteSession(props.instanceId, sessionId) @@ -457,37 +455,34 @@ 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) { @@ -503,20 +498,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, ) @@ -535,14 +530,10 @@ 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] } - } - - const ids = getSelectableThreadIds(rowProps.sessionId) + const ids = rowProps.hasChildren ? getSelectableThreadIds(sessionId()) : [sessionId()] const selected = selectedSessionIds() const selectedInGroup = ids.reduce((count, id) => (selected.has(id) ? count + 1 : count), 0) return { @@ -558,12 +549,23 @@ const SessionList: Component = (props) => { rowCheckboxEl.indeterminate = parentGroupState().indeterminate }) + const nestedStyle = () => { + if (!isChild()) return undefined + const visualDepth = Math.min(rowProps.depth, 6) + const indent = 1.375 + visualDepth * 0.875 + return { + "--session-indent": `${indent}rem`, + "--session-connector-offset": `${indent - 0.875}rem`, + } + } + return (