mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-31 09:55:01 +00:00
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:
parent
ca06bd99d7
commit
34706228bb
14 changed files with 539 additions and 397 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
109
packages/ui/src/stores/session-tree.test.ts
Normal file
109
packages/ui/src/stores/session-tree.test.ts
Normal 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"],
|
||||
)
|
||||
})
|
||||
})
|
||||
161
packages/ui/src/stores/session-tree.ts
Normal file
161
packages/ui/src/stores/session-tree.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue