feat(ui): implement recursive nested session expansion (#478)

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é <pascalandr@gmail.com>
This commit is contained in:
Joe Huss 2026-07-11 19:18:31 -04:00 committed by GitHub
parent ca06bd99d7
commit 34706228bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 539 additions and 397 deletions

View file

@ -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)

View file

@ -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 {

View file

@ -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 {

View file

@ -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<PermissionApprovalModalProps> = (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)

View file

@ -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<SessionListProps> = (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<SessionThread[]>(() => {
const query = normalizedQuery()
if (!query) return props.threads
@ -160,29 +171,23 @@ const SessionList: Component<SessionListProps> = (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<string[]>(() => {
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<SessionListProps> = (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<SessionListProps> = (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<SessionListProps> = (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<SessionListProps> = (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<SessionListProps> = (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<SessionListProps> = (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<SessionListProps> = (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 (
<div class="session-list-item group">
<button
class={`session-item-base ${rowProps.isChild ? `session-item-child${rowProps.isLastChild ? " session-item-child-last" : ""} session-item-border-assistant session-item-kind-assistant` : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
data-session-id={rowProps.sessionId}
onClick={() => selectSession(rowProps.sessionId)}
class={`session-item-base ${isChild() ? "session-item-nested" : ""} ${isChild() && rowProps.isLastChild ? "session-item-child-last" : ""} ${isChild() ? "session-item-border-assistant session-item-kind-assistant" : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
style={nestedStyle()}
data-session-id={sessionId()}
onClick={() => selectSession(sessionId())}
title={title()}
role="button"
aria-selected={isActive()}
@ -587,15 +589,17 @@ const SessionList: Component<SessionListProps> = (props) => {
/>
</Show>
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
<Show when={isChild()} fallback={<User class="w-4 h-4 flex-shrink-0" />}>
<Bot class="w-4 h-4 flex-shrink-0" />
</Show>
<span class="session-item-title session-item-title--clamp" dir="auto">{title()}</span>
</div>
</div>
<div class="session-item-row session-item-meta">
<div class="flex items-center gap-2 min-w-0">
<Show
when={rowProps.hasChildren && !rowProps.isChild}
fallback={rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />}
when={rowProps.hasChildren}
fallback={<span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />}
>
<span
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
@ -633,7 +637,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="session-item-actions">
<span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => copySessionId(event, rowProps.sessionId)}
onClick={(event) => copySessionId(event, sessionId())}
role="button"
tabIndex={0}
aria-label={t("sessionList.actions.copyId.ariaLabel")}
@ -643,14 +647,14 @@ const SessionList: Component<SessionListProps> = (props) => {
</span>
<span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => handleReloadSession(event, rowProps.sessionId)}
onClick={(event) => handleReloadSession(event, sessionId())}
role="button"
tabIndex={0}
aria-label={t("sessionList.actions.reload.ariaLabel")}
title={t("sessionList.actions.reload.title")}
>
<Show
when={!isSessionReloading(rowProps.sessionId)}
when={!isSessionReloading(sessionId())}
fallback={<RotateCw class="w-3 h-3 animate-spin" />}
>
<RotateCw class="w-3 h-3" />
@ -660,7 +664,7 @@ const SessionList: Component<SessionListProps> = (props) => {
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => {
event.stopPropagation()
openRenameDialog(rowProps.sessionId)
openRenameDialog(sessionId())
}}
role="button"
tabIndex={0}
@ -671,14 +675,14 @@ const SessionList: Component<SessionListProps> = (props) => {
</span>
<span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
onClick={(event) => handleDeleteSession(event, sessionId())}
role="button"
tabIndex={0}
aria-label={t("sessionList.actions.delete.ariaLabel")}
title={t("sessionList.actions.delete.title")}
>
<Show
when={!isSessionDeleting(rowProps.sessionId)}
when={!isSessionDeleting(sessionId())}
fallback={
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
@ -699,28 +703,47 @@ const SessionList: Component<SessionListProps> = (props) => {
</div>
)
}
const activeParentId = createMemo(() => {
const activeId = props.activeSessionId
if (!activeId || activeId === "info") return null
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
if (!activeSession) return null
// Recursive component for rendering a thread and its children
const SessionThreadRow: Component<{
thread: SessionThread
depth?: number
isLastChild?: boolean
}> = (rowProps) => {
const depth = () => rowProps.depth ?? 0
const expanded = () => normalizedQuery() ? true : isSessionExpanded(props.instanceId, rowProps.thread.session.id)
return activeSession.parentId ?? activeSession.id
})
return (
<>
<SessionRow
session={rowProps.thread.session}
depth={depth()}
hasChildren={rowProps.thread.hasChildren}
expanded={expanded()}
onToggleExpand={() => toggleSessionExpanded(props.instanceId, rowProps.thread.session.id)}
isLastChild={Boolean(rowProps.isLastChild)}
/>
<Show when={expanded() && rowProps.thread.children.length > 0}>
<For each={rowProps.thread.children}>
{(childThread, index) => (
<SessionThreadRow
thread={childThread}
depth={depth() + 1}
isLastChild={index() === rowProps.thread.children.length - 1}
/>
)}
</For>
</Show>
</>
)
}
createEffect(() => {
// Keep the active child session visible by ensuring its parent is expanded.
// Don't force-expanding when the active session itself is a parent lets users collapse it.
const activeId = props.activeSessionId
if (!activeId || activeId === "info") return
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
if (!activeSession) return
if (!activeSession.parentId) return
const parentId = activeParentId()
if (!parentId) return
ensureSessionParentExpanded(props.instanceId, parentId)
if (!activeSession?.parentId) return
ensureSessionAncestorsExpanded(props.instanceId, activeId)
})
const listEl = createSignal<HTMLElement | null>(null)
@ -729,7 +752,7 @@ const SessionList: Component<SessionListProps> = (props) => {
if (typeof CSS !== "undefined" && typeof (CSS as any).escape === "function") {
return (CSS as any).escape(value)
}
return value.replace(/\\/g, "\\\\").replace(/\"/g, "\\\"")
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")
}
const scrollActiveIntoView = (sessionId: string) => {
@ -848,33 +871,17 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
<Show when={filteredThreads().length > 0}>
<div class="session-section">
<For each={filteredThreads()}>
{(thread) => {
const expanded = () => (normalizedQuery() ? true : isSessionParentExpanded(props.instanceId, thread.parent.id))
return (
<>
<SessionRow
sessionId={thread.parent.id}
hasChildren={thread.children.length > 0}
expanded={expanded()}
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
/>
<Show when={expanded() && thread.children.length > 0}>
<For each={thread.children}>
{(child, index) => (
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
)}
</For>
</Show>
</>
)
}}
<Show when={filteredThreads().length > 0}>
<div class="session-section">
<For each={filteredThreads()}>
{(thread, index) => (
<SessionThreadRow
thread={thread}
depth={0}
isLastChild={index() === filteredThreads().length - 1}
/>
)}
</For>
<Show when={hasMore() || isFetchingSessions()}>
<div
ref={(el) => setSentinelEl(el)}

View file

@ -8,7 +8,7 @@ import PromptInput from "../prompt-input"
import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar"
import { getAttachments, removeAttachment } from "../../stores/attachments"
import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, getSessionMessagesLoadError, markSessionIdleSeen, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, getSessionMessagesLoadError, markSessionIdleSeen, ensureSessionAncestorsExpanded, setActiveSessionFromList, runShellCommand, abortSession } from "../../stores/sessions"
import { clearSessionIdleFade, IDLE_STATUS_VISIBILITY_MS, getSessionStatus, isSessionBusy as getSessionBusyStatus, markSessionIdleFadeStarted } from "../../stores/session-status"
import { deleteMessage } from "../../stores/session-actions"
import { showAlertDialog } from "../../stores/alerts"
@ -163,7 +163,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
if (currentSession.parentId === null && !keepUnseenSubagentIdleStatus) {
for (const child of props.activeSessions.values()) {
if (child.parentId !== currentSession.id) continue
if (child.id === currentSession.id) continue
if (child.status !== "idle") continue
if (typeof child.idleSince !== "number") continue
entries.push({ id: child.id, idleSince: child.idleSince })
@ -476,11 +476,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
log.error("Failed to rename forked session", error)
})
const parentToActivate = forkedSession.parentId ?? forkedSession.id
setActiveParentSession(props.instanceId, parentToActivate)
if (forkedSession.parentId) {
setActiveSession(props.instanceId, forkedSession.id)
}
ensureSessionAncestorsExpanded(props.instanceId, forkedSession.id)
setActiveSessionFromList(props.instanceId, forkedSession.id)
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))

View file

@ -1,6 +1,6 @@
import { activeInstanceId } from "../stores/instances"
import { selectAppTabByIndex } from "../stores/app-tabs"
import { activeSessionId, setActiveSession, getSessions, activeParentSessionId } from "../stores/sessions"
import { activeSessionId, setActiveSession, getSessionFamily, activeParentSessionId } from "../stores/sessions"
import { keyboardRegistry } from "./keyboard-registry"
import { isMac } from "./keyboard-utils"
@ -48,8 +48,7 @@ export function setupTabKeyboardShortcuts(
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const sessions = getSessions(instanceId)
const sessionFamily = sessions.filter((s) => s.id === parentId || s.parentId === parentId)
const sessionFamily = getSessionFamily(instanceId, parentId)
const allTabs = sessionFamily.map((s) => s.id).concat(["logs"])
if (allTabs[index]) {

View file

@ -61,7 +61,7 @@ import {
type SessionRetryState,
type SessionStatus,
} from "../types/session"
import { ensureSessionParentExpanded, prependSessionListId, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
import { ensureSessionAncestorsExpanded, prependSessionListId, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
import { tGlobal } from "../lib/i18n"
@ -165,7 +165,7 @@ interface TuiToastEvent {
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus, retry?: SessionRetryState | null) {
let parentToExpand: string | null = null
let expandAncestors = false
withSession(instanceId, sessionId, (session) => {
const current = session.status ?? "idle"
@ -183,13 +183,11 @@ function applySessionStatus(instanceId: string, sessionId: string, status: Sessi
// Auto-expand the parent thread when a child session starts working.
// Users can still collapse it; we only expand on the transition.
if (session.parentId && status === "working" && current !== "working") {
parentToExpand = session.parentId
expandAncestors = true
}
})
if (parentToExpand) {
ensureSessionParentExpanded(instanceId, parentToExpand)
}
if (expandAncestors) ensureSessionAncestorsExpanded(instanceId, sessionId)
}
async function fetchSessionInfo(instanceId: string, sessionId: string, directory?: string): Promise<Session | null> {
@ -233,7 +231,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
fetched.retry = fetchedRetry
let updatedInstanceSessions: Map<string, Session> | undefined
let shouldExpandParent: string | null = null
let shouldExpandAncestors = false
setSessions((prev) => {
const next = new Map(prev)
@ -258,16 +256,14 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
updatedInstanceSessions = instanceSessions
if (merged.parentId && merged.status === "working" && (existing?.status ?? "idle") !== "working") {
shouldExpandParent = merged.parentId
shouldExpandAncestors = true
}
return next
})
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
if (shouldExpandParent) {
ensureSessionParentExpanded(instanceId, shouldExpandParent)
}
if (shouldExpandAncestors) ensureSessionAncestorsExpanded(instanceId, sessionId)
return fetched
} catch (error) {

View file

@ -13,6 +13,16 @@ import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces"
import { tGlobal } from "../lib/i18n"
import { computeThreadTotals, type ThreadTotals } from "../lib/thread-totals"
import { applySessionPage, getDefaultSessionPaginationState, type SessionPaginationState } from "./session-pagination-model"
import {
buildSessionThreadsFromMap,
collectVisibleSessionIds,
getDescendantSessionsFromMap,
getSessionAncestorIdsFromMap,
getSessionRootFromMap,
type SessionThread,
} from "./session-tree"
export type { SessionThread } from "./session-tree"
const log = getLogger("session")
@ -28,12 +38,6 @@ export interface SessionInfo {
contextAvailableTokens: number | null
}
export type SessionThread = {
parent: Session
children: Session[]
latestUpdated: number
}
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
const [activeParentSessionId, setActiveParentSessionId] = createSignal<Map<string, string>>(new Map())
@ -53,7 +57,8 @@ const [messageLoadErrors, setMessageLoadErrors] = createSignal<Map<string, Map<s
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
const [threadTotalsByInstance, setThreadTotalsByInstance] = createSignal<Map<string, Map<string, ThreadTotals>>>(new Map())
const [expandedSessionParents, setExpandedSessionParents] = createSignal<Map<string, Set<string>>>(new Map())
// Track expansion state for ANY session that has children (not just top-level parents)
const [expandedSessions, setExpandedSessions] = createSignal<Map<string, Set<string>>>(new Map())
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
@ -315,7 +320,6 @@ messageStoreBus.onSessionCleared((instanceId, sessionId) => {
})
function getDraftKey(instanceId: string, sessionId: string): string {
return `${instanceId}:${sessionId}`
}
@ -454,7 +458,8 @@ function markViewedSessionIdleSeen(
const idsToClear = new Set<string>([sessionId])
if (viewedSession.parentId === null && !keepUnseenSubagentIdleStatus) {
for (const session of instanceSessions.values()) {
if (session.parentId === sessionId) idsToClear.add(session.id)
if (session.id === sessionId) continue
if (getSessionRootFromMap(instanceSessions, session.id)?.id === sessionId) idsToClear.add(session.id)
}
}
@ -513,7 +518,7 @@ function clearActiveParentSession(instanceId: string): void {
}
function setSessionStatus(instanceId: string, sessionId: string, status: SessionStatus): void {
let parentToExpand: string | null = null
let expandAncestors = false
withSession(instanceId, sessionId, (session) => {
if (session.status === status) return false
@ -524,16 +529,12 @@ function setSessionStatus(instanceId: string, sessionId: string, status: Session
session.retry = null
}
// If a child session starts working, auto-expand its parent thread once.
// Users can still collapse it afterwards; we only expand on the transition.
if (session.parentId && status === "working" && previous !== "working") {
parentToExpand = session.parentId
expandAncestors = true
}
})
if (parentToExpand) {
ensureSessionParentExpanded(instanceId, parentToExpand)
}
if (expandAncestors) ensureSessionAncestorsExpanded(instanceId, sessionId)
}
function getActiveParentSession(instanceId: string): Session | null {
@ -568,33 +569,8 @@ function getChildSessions(instanceId: string, parentId: string): Session[] {
}
function getDescendantSessions(instanceId: string, parentId: string): Session[] {
const allSessions = getSessions(instanceId)
const childrenByParent = new Map<string, Session[]>()
for (const session of allSessions) {
if (!session.parentId) continue
const children = childrenByParent.get(session.parentId)
if (children) {
children.push(session)
} else {
childrenByParent.set(session.parentId, [session])
}
}
const descendants: Session[] = []
const stack = [...(childrenByParent.get(parentId) ?? [])]
const seen = new Set<string>()
while (stack.length > 0) {
const session = stack.shift()
if (!session || seen.has(session.id)) continue
seen.add(session.id)
descendants.push(session)
stack.push(...(childrenByParent.get(session.id) ?? []))
}
descendants.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0))
return descendants
const instanceSessions = sessions().get(instanceId)
return instanceSessions ? getDescendantSessionsFromMap(instanceSessions, parentId) : []
}
function getSessionFamily(instanceId: string, parentId: string): Session[] {
@ -611,112 +587,9 @@ function getSessionRoot(instanceId: string, sessionId: string): Session | null {
return getSessionRootFromMap(instanceSessions, sessionId)
}
function getSessionRootFromMap(instanceSessions: Map<string, Session>, sessionId: string): Session | null {
let current = instanceSessions.get(sessionId)
if (!current) return null
const seen = new Set<string>()
while (current.parentId) {
if (seen.has(current.id)) return null
seen.add(current.id)
const parent = instanceSessions.get(current.parentId)
if (!parent) return null
current = parent
}
return current
}
type SessionThreadCacheEntry = {
signature: string
thread: SessionThread
}
type SessionThreadCache = {
byParentId: Map<string, SessionThreadCacheEntry>
}
const sessionThreadCache = new Map<string, SessionThreadCache>()
function getOrCreateSessionThreadCache(instanceId: string): SessionThreadCache {
let cache = sessionThreadCache.get(instanceId)
if (!cache) {
cache = { byParentId: new Map() }
sessionThreadCache.set(instanceId, cache)
}
return cache
}
function buildSessionThreads(instanceId: string, rootIds: string[], childIds?: Set<string>): SessionThread[] {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions || instanceSessions.size === 0 || rootIds.length === 0) {
sessionThreadCache.delete(instanceId)
return []
}
const cache = getOrCreateSessionThreadCache(instanceId)
const seenParents = new Set<string>()
const childrenByRoot = new Map<string, Session[]>()
for (const session of instanceSessions.values()) {
if (!session.parentId) continue
if (childIds && !childIds.has(session.id)) continue
const root = getSessionRootFromMap(instanceSessions, session.id)
if (!root) continue
const children = childrenByRoot.get(root.id)
if (children) {
children.push(session)
} else {
childrenByRoot.set(root.id, [session])
}
}
const threads: SessionThread[] = []
for (const parentId of rootIds) {
const parent = instanceSessions.get(parentId)
if (!parent || parent.parentId !== null) continue
seenParents.add(parent.id)
const children = childrenByRoot.get(parent.id) ?? []
if (children.length > 1) {
children.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0))
}
const parentUpdated = parent.time.updated ?? 0
const latestChild = children[0]?.time.updated ?? 0
const latestUpdated = Math.max(parentUpdated, latestChild)
const childIds = children.map((child) => child.id).join(",")
const signature = `${parentUpdated}:${latestChild}:${childIds}`
const cached = cache.byParentId.get(parent.id)
if (cached && cached.signature === signature) {
threads.push(cached.thread)
} else {
const thread: SessionThread = { parent, children, latestUpdated }
cache.byParentId.set(parent.id, { signature, thread })
threads.push(thread)
}
}
for (const parentId of Array.from(cache.byParentId.keys())) {
if (!seenParents.has(parentId)) {
cache.byParentId.delete(parentId)
}
}
threads.sort((a, b) => {
if (b.latestUpdated !== a.latestUpdated) return b.latestUpdated - a.latestUpdated
const bParentUpdated = b.parent.time.updated ?? 0
const aParentUpdated = a.parent.time.updated ?? 0
if (bParentUpdated !== aParentUpdated) return bParentUpdated - aParentUpdated
return b.parent.id.localeCompare(a.parent.id)
})
return threads
return instanceSessions ? buildSessionThreadsFromMap(instanceSessions, rootIds, childIds) : []
}
function getSessionThreads(instanceId: string): SessionThread[] {
@ -737,7 +610,7 @@ function getSessionSearchThreads(instanceId: string): SessionThread[] {
const session = instanceSessions.get(sessionId)
if (!session) continue
if (session.parentId === null) {
rootIds.push(session.id)
if (!rootIds.includes(session.id)) rootIds.push(session.id)
} else {
childIds.add(session.id)
const root = getSessionRootFromMap(instanceSessions, session.id)
@ -748,20 +621,20 @@ function getSessionSearchThreads(instanceId: string): SessionThread[] {
return buildSessionThreads(instanceId, rootIds, childIds)
}
function isSessionParentExpanded(instanceId: string, parentSessionId: string): boolean {
return Boolean(expandedSessionParents().get(instanceId)?.has(parentSessionId))
function isSessionExpanded(instanceId: string, sessionId: string): boolean {
return Boolean(expandedSessions().get(instanceId)?.has(sessionId))
}
function setSessionParentExpanded(instanceId: string, parentSessionId: string, expanded: boolean): void {
setExpandedSessionParents((prev) => {
function setSessionExpanded(instanceId: string, sessionId: string, expanded: boolean): void {
setExpandedSessions((prev) => {
const next = new Map(prev)
const currentSet = next.get(instanceId) ?? new Set<string>()
const updated = new Set(currentSet)
if (expanded) {
updated.add(parentSessionId)
updated.add(sessionId)
} else {
updated.delete(parentSessionId)
updated.delete(sessionId)
}
if (updated.size === 0) {
@ -774,16 +647,16 @@ function setSessionParentExpanded(instanceId: string, parentSessionId: string, e
})
}
function toggleSessionParentExpanded(instanceId: string, parentSessionId: string): void {
setExpandedSessionParents((prev) => {
function toggleSessionExpanded(instanceId: string, sessionId: string): void {
setExpandedSessions((prev) => {
const next = new Map(prev)
const currentSet = next.get(instanceId) ?? new Set<string>()
const updated = new Set(currentSet)
if (updated.has(parentSessionId)) {
updated.delete(parentSessionId)
if (updated.has(sessionId)) {
updated.delete(sessionId)
} else {
updated.add(parentSessionId)
updated.add(sessionId)
}
next.set(instanceId, updated)
@ -791,45 +664,51 @@ function toggleSessionParentExpanded(instanceId: string, parentSessionId: string
})
}
function ensureSessionParentExpanded(instanceId: string, parentSessionId: string): void {
if (isSessionParentExpanded(instanceId, parentSessionId)) return
setSessionParentExpanded(instanceId, parentSessionId, true)
function ensureSessionExpanded(instanceId: string, sessionId: string): void {
if (isSessionExpanded(instanceId, sessionId)) return
setSessionExpanded(instanceId, sessionId, true)
}
function getSessionAncestorIds(instanceId: string, sessionId: string): string[] {
const instanceSessions = sessions().get(instanceId)
return instanceSessions ? getSessionAncestorIdsFromMap(instanceSessions, sessionId) : []
}
function ensureSessionAncestorsExpanded(instanceId: string, sessionId: string): void {
const ancestorIds = getSessionAncestorIds(instanceId, sessionId)
if (ancestorIds.length === 0) return
setExpandedSessions((prev) => {
const next = new Map(prev)
const expanded = new Set(next.get(instanceId))
let changed = false
for (const ancestorId of ancestorIds) {
if (expanded.has(ancestorId)) continue
expanded.add(ancestorId)
changed = true
}
if (!changed) return prev
next.set(instanceId, expanded)
return next
})
}
function getVisibleSessionIds(instanceId: string): string[] {
const threads = getSessionThreads(instanceId)
if (threads.length === 0) return []
const expanded = expandedSessionParents().get(instanceId)
const ids: string[] = []
for (const thread of threads) {
ids.push(thread.parent.id)
if (expanded?.has(thread.parent.id)) {
for (const child of thread.children) {
ids.push(child.id)
}
}
}
return ids
const expanded = expandedSessions().get(instanceId)
return collectVisibleSessionIds(threads, expanded)
}
function setActiveSessionFromList(instanceId: string, sessionId: string): void {
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) return
if (session.parentId === null) {
setActiveParentSession(instanceId, sessionId)
return
}
const parentId = session.parentId
if (!parentId) return
const root = getSessionRoot(instanceId, sessionId)
if (!root) return
batch(() => {
setActiveParentSession(instanceId, parentId)
setActiveSession(instanceId, sessionId)
setActiveParentSession(instanceId, root.id)
if (session.id !== root.id) setActiveSession(instanceId, session.id)
})
}
@ -891,9 +770,10 @@ function updateThreadTotalsForParent(instanceId: string, parentSessionId: string
}
function updateThreadTotalsForSession(instanceId: string, sessionId: string): void {
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) return
updateThreadTotalsForParent(instanceId, session.parentId ?? session.id)
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions?.has(sessionId)) return
const familyIds = [...getSessionAncestorIdsFromMap(instanceSessions, sessionId), sessionId]
for (const familyId of familyIds) updateThreadTotalsForParent(instanceId, familyId)
}
async function isBlankSession(session: Session, instanceId: string, fetchIfNeeded = false): Promise<boolean> {
@ -908,20 +788,20 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
}
// For a more thorough deep clean, we need to look at actual messages
const instance = instances().get(instanceId)
if (!instance?.client) {
return isFreshSession
}
let messages: any[] = []
try {
const client = getRootClient(instanceId)
const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, session.id)
messages = await requestData<any[]>(
client.session.messages({ sessionID: session.id, ...(workspace ? { workspace } : {}) }),
"session.messages",
)
} catch (error) {
try {
const client = getRootClient(instanceId)
const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, session.id)
messages = await requestData<any[]>(
client.session.messages({ sessionID: session.id, ...(workspace ? { workspace } : {}) }),
"session.messages",
)
} catch (error) {
log.error(`Failed to fetch messages for session ${session.id}`, error)
return isFreshSession
}
@ -935,23 +815,23 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
// Subagent: "blank" (really: finished doing its job) if actually blank...
// ... OR no streaming, no pending perms, no tool parts
if (messages.length === 0) return true
const hasStreaming = messages.some((msg) => {
const info = msg.info.status || msg.status
return info === "streaming" || info === "sending"
})
const lastMessage = messages[messages.length - 1]
const lastParts = lastMessage?.parts || []
const hasToolPart = lastParts.some((part: any) =>
const hasToolPart = lastParts.some((part: any) =>
part.type === "tool" || part.data?.type === "tool"
)
return !hasStreaming && !session.pendingPermission && !hasToolPart
} else {
// Fork: blank if somehow has no messages or at revert point
if (messages.length === 0) return true
const lastMessage = messages[messages.length - 1]
const lastInfo = lastMessage?.info || lastMessage
return lastInfo?.id === session.revert?.messageID
@ -1005,6 +885,13 @@ async function cleanupBlankSessions(instanceId: string, excludeSessionId?: strin
}
}
// Backward compatibility aliases for renamed exports
const expandedSessionParents = expandedSessions
const isSessionParentExpanded = isSessionExpanded
const setSessionParentExpanded = setSessionExpanded
const toggleSessionParentExpanded = toggleSessionExpanded
const ensureSessionParentExpanded = ensureSessionExpanded
export {
sessions,
setSessions,
@ -1039,7 +926,7 @@ export {
markViewedSessionIdleSeen,
setSessionStatus,
setActiveSession,
setActiveParentSession,
clearActiveParentSession,
@ -1054,10 +941,12 @@ export {
getSessionThreads,
getSessionSearchThreads,
getVisibleSessionIds,
isSessionParentExpanded,
setSessionParentExpanded,
toggleSessionParentExpanded,
ensureSessionParentExpanded,
isSessionExpanded,
setSessionExpanded,
toggleSessionExpanded,
ensureSessionExpanded,
getSessionAncestorIds,
ensureSessionAncestorsExpanded,
setActiveSessionFromList,
isSessionBusy,
isSessionMessagesLoading,
@ -1065,6 +954,11 @@ export {
getSessionInfo,
isBlankSession,
cleanupBlankSessions,
expandedSessionParents,
isSessionParentExpanded,
setSessionParentExpanded,
toggleSessionParentExpanded,
ensureSessionParentExpanded,
SESSION_PAGE_SIZE,
sessionPagination,
sessionSearch,

View file

@ -0,0 +1,109 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import type { Session } from "../types/session"
import {
buildSessionThreadsFromMap,
collectSessionThreadIds,
collectVisibleSessionIds,
findSessionThread,
getDescendantSessionsFromMap,
getSessionAncestorIdsFromMap,
getSessionRootFromMap,
sortSessionIdsDeepestFirst,
} from "./session-tree"
function session(id: string, parentId: string | null, updated: number): Session {
return { id, parentId, time: { created: updated, updated } } as Session
}
function sessionMap(definitions: Array<[string, string | null, number]>): Map<string, Session> {
return new Map(definitions.map(([id, parentId, updated]) => [id, session(id, parentId, updated)]))
}
describe("session tree", () => {
it("preserves nesting and sorts siblings by descendant activity", () => {
const sessions = sessionMap([
["root", null, 100],
["older-branch", "root", 200],
["newer-branch", "root", 300],
["deep-active", "older-branch", 500],
])
const [root] = buildSessionThreadsFromMap(sessions, ["root"])
assert.equal(root.latestUpdated, 500)
assert.deepEqual(root.children.map((child) => child.session.id), ["older-branch", "newer-branch"])
assert.equal(root.children[0].children[0].session.id, "deep-active")
assert.equal(root.children[0].children[0].depth, 2)
})
it("includes the complete ancestor path for filtered descendants", () => {
const sessions = sessionMap([
["root", null, 100],
["child", "root", 200],
["grandchild", "child", 300],
["sibling", "root", 400],
])
const [root] = buildSessionThreadsFromMap(sessions, ["root"], new Set(["grandchild"]))
assert.deepEqual(root.children.map((child) => child.session.id), ["child"])
assert.deepEqual(root.children[0].children.map((child) => child.session.id), ["grandchild"])
assert.equal(buildSessionThreadsFromMap(sessions, ["root", "root"]).length, 1)
})
it("only exposes descendants whose full parent path is expanded", () => {
const sessions = sessionMap([
["root", null, 100],
["child", "root", 200],
["grandchild", "child", 300],
])
const threads = buildSessionThreadsFromMap(sessions, ["root"])
assert.deepEqual(collectVisibleSessionIds(threads, undefined), ["root"])
assert.deepEqual(collectVisibleSessionIds(threads, new Set(["root"])), ["root", "child"])
assert.deepEqual(collectVisibleSessionIds(threads, new Set(["root", "child"])), ["root", "child", "grandchild"])
})
it("resolves roots and ancestors across arbitrary depth", () => {
const sessions = sessionMap([
["root", null, 100],
["child", "root", 200],
["grandchild", "child", 300],
])
assert.equal(getSessionRootFromMap(sessions, "grandchild")?.id, "root")
assert.deepEqual(getSessionAncestorIdsFromMap(sessions, "grandchild"), ["root", "child"])
})
it("terminates safely for cycles and missing parents", () => {
const sessions = sessionMap([
["a", "b", 100],
["b", "a", 200],
["orphan", "missing", 300],
])
assert.equal(getSessionRootFromMap(sessions, "a"), null)
assert.equal(getSessionRootFromMap(sessions, "orphan"), null)
assert.deepEqual(getSessionAncestorIdsFromMap(sessions, "a"), [])
assert.deepEqual(getDescendantSessionsFromMap(sessions, "a").map((item) => item.id), ["b"])
assert.deepEqual(buildSessionThreadsFromMap(sessions, ["a", "orphan"]), [])
})
it("collects an intermediate subtree and orders deletion children before parents", () => {
const sessions = sessionMap([
["root", null, 100],
["child", "root", 200],
["grandchild", "child", 300],
["sibling", "root", 400],
])
const threads = buildSessionThreadsFromMap(sessions, ["root"])
const child = findSessionThread(threads, "child")
assert.ok(child)
assert.deepEqual(collectSessionThreadIds([child]), ["child", "grandchild"])
assert.deepEqual(
sortSessionIdsDeepestFirst(sessions, ["root", "child", "grandchild"]),
["grandchild", "child", "root"],
)
})
})

View file

@ -0,0 +1,161 @@
import type { Session } from "../types/session"
export type SessionThread = {
session: Session
children: SessionThread[]
depth: number
hasChildren: boolean
latestUpdated: number
}
export function getSessionRootFromMap(instanceSessions: Map<string, Session>, sessionId: string): Session | null {
let current = instanceSessions.get(sessionId)
if (!current) return null
const seen = new Set<string>()
while (current.parentId) {
if (seen.has(current.id)) return null
seen.add(current.id)
const parent = instanceSessions.get(current.parentId)
if (!parent) return null
current = parent
}
return current
}
export function getSessionAncestorIdsFromMap(instanceSessions: Map<string, Session>, sessionId: string): string[] {
const ancestors: string[] = []
const seen = new Set<string>([sessionId])
let current = instanceSessions.get(sessionId)
while (current?.parentId) {
if (seen.has(current.parentId)) return []
seen.add(current.parentId)
const parent = instanceSessions.get(current.parentId)
if (!parent) return []
ancestors.push(parent.id)
current = parent
}
ancestors.reverse()
return ancestors
}
export function getDescendantSessionsFromMap(instanceSessions: Map<string, Session>, parentId: string): Session[] {
const childrenByParent = new Map<string, Session[]>()
for (const session of instanceSessions.values()) {
if (!session.parentId) continue
const children = childrenByParent.get(session.parentId)
if (children) children.push(session)
else childrenByParent.set(session.parentId, [session])
}
const descendants: Session[] = []
const queue = [...(childrenByParent.get(parentId) ?? [])]
const seen = new Set<string>([parentId])
while (queue.length > 0) {
const session = queue.shift()
if (!session || seen.has(session.id)) continue
seen.add(session.id)
descendants.push(session)
queue.push(...(childrenByParent.get(session.id) ?? []))
}
descendants.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0))
return descendants
}
function buildThread(
session: Session,
childrenByParent: Map<string, Session[]>,
depth: number,
ancestorIds: Set<string>,
): SessionThread | null {
if (ancestorIds.has(session.id)) return null
const nextAncestorIds = new Set(ancestorIds)
nextAncestorIds.add(session.id)
const children: SessionThread[] = []
for (const child of childrenByParent.get(session.id) ?? []) {
const childThread = buildThread(child, childrenByParent, depth + 1, nextAncestorIds)
if (childThread) children.push(childThread)
}
children.sort((a, b) => {
if (b.latestUpdated !== a.latestUpdated) return b.latestUpdated - a.latestUpdated
return b.session.id.localeCompare(a.session.id)
})
let latestUpdated = session.time.updated ?? 0
for (const child of children) latestUpdated = Math.max(latestUpdated, child.latestUpdated)
return { session, children, depth, hasChildren: children.length > 0, latestUpdated }
}
export function buildSessionThreadsFromMap(
instanceSessions: Map<string, Session>,
rootIds: string[],
includedDescendantIds?: Set<string>,
): SessionThread[] {
let includedIds: Set<string> | null = null
if (includedDescendantIds) {
includedIds = new Set(rootIds)
for (const sessionId of includedDescendantIds) {
includedIds.add(sessionId)
for (const ancestorId of getSessionAncestorIdsFromMap(instanceSessions, sessionId)) includedIds.add(ancestorId)
}
}
const childrenByParent = new Map<string, Session[]>()
for (const session of instanceSessions.values()) {
if (!session.parentId || (includedIds && !includedIds.has(session.id))) continue
const children = childrenByParent.get(session.parentId)
if (children) children.push(session)
else childrenByParent.set(session.parentId, [session])
}
const threads: SessionThread[] = []
const seenRootIds = new Set<string>()
for (const rootId of rootIds) {
if (seenRootIds.has(rootId)) continue
seenRootIds.add(rootId)
const root = instanceSessions.get(rootId)
if (!root || root.parentId !== null) continue
const thread = buildThread(root, childrenByParent, 0, new Set())
if (thread) threads.push(thread)
}
threads.sort((a, b) => {
if (b.latestUpdated !== a.latestUpdated) return b.latestUpdated - a.latestUpdated
const updatedDelta = (b.session.time.updated ?? 0) - (a.session.time.updated ?? 0)
return updatedDelta || b.session.id.localeCompare(a.session.id)
})
return threads
}
export function collectVisibleSessionIds(threads: SessionThread[], expanded: Set<string> | undefined): string[] {
const ids: string[] = []
for (const thread of threads) {
ids.push(thread.session.id)
if (expanded?.has(thread.session.id)) ids.push(...collectVisibleSessionIds(thread.children, expanded))
}
return ids
}
export function findSessionThread(threads: SessionThread[], sessionId: string): SessionThread | null {
for (const thread of threads) {
if (thread.session.id === sessionId) return thread
const child = findSessionThread(thread.children, sessionId)
if (child) return child
}
return null
}
export function collectSessionThreadIds(threads: SessionThread[]): string[] {
const ids: string[] = []
for (const thread of threads) {
ids.push(thread.session.id)
ids.push(...collectSessionThreadIds(thread.children))
}
return ids
}
export function sortSessionIdsDeepestFirst(instanceSessions: Map<string, Session>, sessionIds: string[]): string[] {
return [...sessionIds].sort(
(left, right) => getSessionAncestorIdsFromMap(instanceSessions, right).length - getSessionAncestorIdsFromMap(instanceSessions, left).length,
)
}

View file

@ -9,7 +9,8 @@ import {
clearActiveParentSession,
clearInstanceDraftPrompts,
clearSessionDraftPrompt,
ensureSessionParentExpanded,
ensureSessionAncestorsExpanded,
ensureSessionExpanded,
getActiveParentSession,
getActiveSession,
getChildSessions,
@ -28,7 +29,7 @@ import {
getVisibleSessionIds,
isSessionBusy,
isSessionMessagesLoading,
isSessionParentExpanded,
isSessionExpanded,
loading,
markSessionIdleSeen,
markViewedSessionIdleSeen,
@ -39,9 +40,9 @@ import {
setActiveSession,
setActiveSessionFromList,
setSessionDraftPrompt,
setSessionParentExpanded,
setSessionExpanded,
setSessionStatus,
toggleSessionParentExpanded,
toggleSessionExpanded,
clearSessionSearch,
getSessionHasMore,
isSessionSearchLoading,
@ -112,7 +113,8 @@ export {
clearSessionDraftPrompt,
createSession,
deleteSession,
ensureSessionParentExpanded,
ensureSessionAncestorsExpanded,
ensureSessionExpanded,
executeCustomCommand,
renameSession,
runShellCommand,
@ -141,7 +143,7 @@ export {
getVisibleSessionIds,
isSessionBusy,
isSessionMessagesLoading,
isSessionParentExpanded,
isSessionExpanded,
loadMessages,
loading,
markSessionIdleSeen,
@ -154,9 +156,9 @@ export {
setActiveSession,
setActiveSessionFromList,
setSessionDraftPrompt,
setSessionParentExpanded,
setSessionExpanded,
setSessionStatus,
toggleSessionParentExpanded,
toggleSessionExpanded,
updateSessionAgent,
updateSessionModel,
clearSessionSearch,

View file

@ -1,7 +1,7 @@
import { createSignal } from "solid-js"
import type { WorktreeDescriptor, WorktreeMap } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { sessions } from "./session-state"
import { getSessionRoot, sessions } from "./session-state"
import { getLogger } from "../lib/logger"
import { getCodeNomadSessionMetadata, setSessionWorktreeSlugWithClient } from "./session-metadata"
import { getRootClient } from "./opencode-client"
@ -283,9 +283,7 @@ function getDefaultWorktreeSlug(instanceId: string): string {
}
function getParentSessionId(instanceId: string, sessionId: string): string {
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) return sessionId
return session.parentId ?? session.id
return getSessionRoot(instanceId, sessionId)?.id ?? sessionId
}
function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string {

View file

@ -268,36 +268,36 @@ session-sidebar-controls .selector-trigger-primary {
}
.session-item-base.session-item-child {
padding-inline-start: 2.25rem;
.session-item-base.session-item-nested {
padding-inline-start: var(--session-indent);
position: relative;
}
.session-item-base.session-item-child::before {
.session-item-base.session-item-nested::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
inset-inline-start: 1.125rem;
inset-inline-start: var(--session-connector-offset);
width: 1px;
background-color: var(--text-secondary);
opacity: 0.95;
opacity: 0.65;
pointer-events: none;
}
.session-item-base.session-item-child.session-item-child-last::before {
.session-item-base.session-item-nested.session-item-child-last::before {
bottom: 50%;
}
.session-item-base.session-item-child::after {
.session-item-base.session-item-nested::after {
content: "";
position: absolute;
top: 50%;
inset-inline-start: 1.125rem;
inset-inline-start: var(--session-connector-offset);
width: 0.875rem;
height: 1px;
background-color: var(--text-secondary);
opacity: 0.95;
opacity: 0.65;
transform: translateY(-0.5px);
pointer-events: none;
}