diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 866a896b..9fed3e3a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -42,6 +42,7 @@ import { } from "./stores/instances" import { getSessions, + getSessionRoot, activeSessionId, setActiveParentSession, clearActiveParentSession, @@ -408,12 +409,8 @@ const App: Component = () => { return } - const parentSessionId = session.parentId ?? session.id - const parentSession = sessions.find((s) => s.id === parentSessionId) - - if (!parentSession || parentSession.parentId !== null) { - return - } + const parentSession = getSessionRoot(instanceId, sessionId) + if (!parentSession) return clearActiveParentSession(instanceId) diff --git a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts index 9b84ba86..3544ac48 100644 --- a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts +++ b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts @@ -1,4 +1,4 @@ -import { batch, createMemo, type Accessor } from "solid-js" +import { createMemo, type Accessor } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" import type { Session } from "../../../types/session" import { @@ -8,8 +8,8 @@ import { getSessionInfo, getSessionThreads, sessions, - setActiveParentSession, setActiveSession, + setActiveSessionFromList, } from "../../../stores/sessions" import { messageStoreBus } from "../../../stores/message-v2/bus" import { getBackgroundProcesses } from "../../../stores/background-processes" @@ -131,21 +131,8 @@ export function useInstanceSessionContext(options: InstanceSessionContextOptions return } - const session = allInstanceSessions().get(sessionId) - if (!session) return - - if (session.parentId === null) { - setActiveParentSession(instanceId, sessionId) - return - } - - const parentId = session.parentId - if (!parentId) return - - batch(() => { - setActiveParentSession(instanceId, parentId) - setActiveSession(instanceId, sessionId) - }) + if (!allInstanceSessions().has(sessionId)) return + setActiveSessionFromList(instanceId, sessionId) } return { diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index b54c8678..c63449e4 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -8,7 +8,7 @@ import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../s import type { MessageRecord } from "../stores/message-v2/types" import { messageStoreBus } from "../stores/message-v2/bus" import { formatTokenTotal } from "../lib/formatters" -import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" +import { ensureSessionAncestorsExpanded, sessions, setActiveSessionFromList } from "../stores/sessions" import { selectInstanceTab } from "../stores/app-tabs" import { showAlertDialog } from "../stores/alerts" import { deleteMessage } from "../stores/session-actions" @@ -135,11 +135,8 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string function navigateToTaskSession(location: TaskSessionLocation) { selectInstanceTab(location.instanceId) - const parentToActivate = location.parentId ?? location.sessionId - setActiveParentSession(location.instanceId, parentToActivate) - if (location.parentId) { - setActiveSession(location.instanceId, location.sessionId) - } + ensureSessionAncestorsExpanded(location.instanceId, location.sessionId) + setActiveSessionFromList(location.instanceId, location.sessionId) } interface CachedBlockEntry { diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index d9329286..b2de5f11 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -11,7 +11,7 @@ import { getQuestionEnqueuedAtForInstance, sendPermissionResponse, } from "../stores/instances" -import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" +import { ensureSessionAncestorsExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants" @@ -262,10 +262,8 @@ const PermissionApprovalModal: Component = (props) function handleGoToSession(sessionId: string) { if (!sessionId) return - const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) - const parentId = session?.parentId ?? session?.id - if (parentId) { - ensureSessionParentExpanded(props.instanceId, parentId) + if (sessionStateSessions().get(props.instanceId)?.has(sessionId)) { + ensureSessionAncestorsExpanded(props.instanceId, sessionId) } setActiveSessionFromList(props.instanceId, sessionId) diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index aad207a8..7f144076 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -11,15 +11,15 @@ import { useI18n } from "../lib/i18n" import { showConfirmDialog } from "../stores/alerts" import { deleteSession, - ensureSessionParentExpanded, + ensureSessionAncestorsExpanded, getVisibleSessionIds, - isSessionParentExpanded, + isSessionExpanded, loadMessages, loading, renameSession, sessions as sessionStateSessions, setActiveSessionFromList, - toggleSessionParentExpanded, + toggleSessionExpanded, loadMoreSessions, searchSessions, getSessionHasMore, @@ -29,6 +29,7 @@ import { isSessionSearchLoading, } from "../stores/sessions" import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees" +import { collectSessionThreadIds, findSessionThread, sortSessionIdsDeepestFirst } from "../stores/session-tree" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useConfig } from "../stores/preferences" @@ -150,6 +151,16 @@ const SessionList: Component = (props) => { return sessionId.toLowerCase().includes(query) } + const filterThreadTree = (thread: SessionThread, query: string): SessionThread | null => { + const matchingChildren: SessionThread[] = [] + for (const child of thread.children) { + const filteredChild = filterThreadTree(child, query) + if (filteredChild !== null) matchingChildren.push(filteredChild) + } + if (!sessionMatchesQuery(thread.session.id, query) && matchingChildren.length === 0) return null + return { ...thread, children: matchingChildren } + } + const filteredThreads = createMemo(() => { const query = normalizedQuery() if (!query) return props.threads @@ -160,29 +171,23 @@ const SessionList: Component = (props) => { return getSessionSearchThreads(props.instanceId) } - const next: SessionThread[] = [] + const result: SessionThread[] = [] for (const thread of props.threads) { - const parentMatches = sessionMatchesQuery(thread.parent.id, query) - const matchingChildren = thread.children.filter((child) => sessionMatchesQuery(child.id, query)) - - if (!parentMatches && matchingChildren.length === 0) continue - - next.push({ - parent: thread.parent, - children: matchingChildren, - latestUpdated: thread.latestUpdated, - }) + const filtered = filterThreadTree(thread, query) + if (filtered !== null) result.push(filtered) } - - return next + return result }) const allMatchingSessionIds = createMemo(() => { const ids: string[] = [] - for (const thread of filteredThreads()) { - ids.push(thread.parent.id) - for (const child of thread.children) ids.push(child.id) + const collectIds = (threads: SessionThread[]) => { + for (const thread of threads) { + ids.push(thread.session.id) + collectIds(thread.children) + } } + collectIds(filteredThreads()) return ids }) @@ -206,14 +211,13 @@ const SessionList: Component = (props) => { const deleting = loading().deletingSession.get(props.instanceId) return deleting ? deleting.has(sessionId) : false } - const selectSession = (sessionId: string) => { const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) // If the user selects a child session, make sure its parent thread is expanded. // For parent sessions we don't force expansion; user can collapse/expand freely. if (session?.parentId) { - ensureSessionParentExpanded(props.instanceId, session.parentId) + ensureSessionAncestorsExpanded(props.instanceId, session.id) } props.onSelect(sessionId) @@ -360,21 +364,14 @@ const SessionList: Component = (props) => { }) } - const getSelectableThreadIds = (parentId: string): string[] => { - const query = normalizedQuery() - const source = query ? filteredThreads() : props.threads - const thread = source.find((t) => t.parent.id === parentId) - if (!thread) return [parentId] - return [thread.parent.id, ...thread.children.map((c) => c.id)] + const getSelectableThreadIds = (sessionId: string): string[] => { + const source = normalizedQuery() ? filteredThreads() : props.threads + const thread = findSessionThread(source, sessionId) + return thread ? collectSessionThreadIds([thread]) : [sessionId] } const getAllSessionIdsInOrder = (threads: SessionThread[]): string[] => { - const ids: string[] = [] - threads.forEach((thread) => { - ids.push(thread.parent.id) - thread.children.forEach((child) => ids.push(child.id)) - }) - return ids + return collectSessionThreadIds(threads) } const handleToggleSelectAll = (checked: boolean) => { @@ -433,8 +430,9 @@ const SessionList: Component = (props) => { } } + const deletionOrder = sortSessionIdsDeepestFirst(sessionStateSessions().get(props.instanceId) ?? new Map(), selected) let failed = 0 - for (const sessionId of selected) { + for (const sessionId of deletionOrder) { try { // eslint-disable-next-line no-await-in-loop await deleteSession(props.instanceId, sessionId) @@ -457,37 +455,34 @@ const SessionList: Component = (props) => { }) } } - const SessionRow: Component<{ - sessionId: string - isChild?: boolean - isLastChild?: boolean - hasChildren?: boolean + session: SessionThread["session"] + depth: number + isLastChild: boolean + hasChildren: boolean expanded?: boolean onToggleExpand?: () => void }> = (rowProps) => { - const session = createMemo(() => sessionStateSessions().get(props.instanceId)?.get(rowProps.sessionId)) - if (!session()) { - return <> - } + const sessionId = () => rowProps.session.id + const isChild = () => rowProps.depth > 0 const worktreeSlug = createMemo(() => { - if (rowProps.isChild) return "root" - return getWorktreeSlugForParentSession(props.instanceId, rowProps.sessionId) + if (isChild()) return "root" + return getWorktreeSlugForParentSession(props.instanceId, sessionId()) }) const showWorktreeBadge = createMemo(() => { - if (rowProps.isChild) return false + if (isChild()) return false if (getGitRepoStatus(props.instanceId) === false) return false const slug = worktreeSlug() return Boolean(slug) && slug !== "root" }) - const isActive = () => props.activeSessionId === rowProps.sessionId - const title = () => session()?.title || t("sessionList.session.untitled") - const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) - const retry = () => getSessionRetry(props.instanceId, rowProps.sessionId) + const isActive = () => props.activeSessionId === sessionId() + const title = () => rowProps.session.title || t("sessionList.session.untitled") + const status = () => getSessionStatus(props.instanceId, sessionId()) + const retry = () => getSessionRetry(props.instanceId, sessionId()) const statusLabel = () => { const retryState = retry() if (retryState) { @@ -503,20 +498,20 @@ const SessionList: Component = (props) => { return t("sessionList.status.idle") } } - const needsPermission = () => Boolean(session()?.pendingPermission) - const needsQuestion = () => Boolean((session() as any)?.pendingQuestion) + const needsPermission = () => Boolean(rowProps.session.pendingPermission) + const needsQuestion = () => Boolean((rowProps.session as any)?.pendingQuestion) const needsInput = () => needsPermission() || needsQuestion() const statusClassName = () => { if (needsInput()) return "session-permission" const base = `session-${retry() ? "retrying" : status()}` - const fadeClass = getSessionIdleFadeClass(props.instanceId, rowProps.sessionId) + const fadeClass = getSessionIdleFadeClass(props.instanceId, sessionId()) return fadeClass ? `${base} ${fadeClass}` : base } const showStatus = () => needsInput() || shouldShowSessionStatus( props.instanceId, - rowProps.sessionId, + sessionId(), now(), preferences().keepUnseenSubagentIdleStatus, ) @@ -535,14 +530,10 @@ const SessionList: Component = (props) => { }) } - const isSelected = () => selectedSessionIds().has(rowProps.sessionId) + const isSelected = () => selectedSessionIds().has(sessionId()) const parentGroupState = createMemo(() => { - if (rowProps.isChild) { - return { checked: isSelected(), indeterminate: false, ids: [rowProps.sessionId] } - } - - const ids = getSelectableThreadIds(rowProps.sessionId) + const ids = rowProps.hasChildren ? getSelectableThreadIds(sessionId()) : [sessionId()] const selected = selectedSessionIds() const selectedInGroup = ids.reduce((count, id) => (selected.has(id) ? count + 1 : count), 0) return { @@ -558,12 +549,23 @@ const SessionList: Component = (props) => { rowCheckboxEl.indeterminate = parentGroupState().indeterminate }) + const nestedStyle = () => { + if (!isChild()) return undefined + const visualDepth = Math.min(rowProps.depth, 6) + const indent = 1.375 + visualDepth * 0.875 + return { + "--session-indent": `${indent}rem`, + "--session-connector-offset": `${indent - 0.875}rem`, + } + } + return (