From c184ddf185d2701abe751ac700aca78b988d8805 Mon Sep 17 00:00:00 2001 From: bashrusakh <127580858+bashrusakh@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:42:13 +1100 Subject: [PATCH] fix(worktree): subagent sessions kept when deleting worktree group from sidebar (#1806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worktree): include sessions when deleting worktree group from sidebar allGroupSessions was guarded by group.isArchivedBucket, returning [] for active worktree groups. This caused the 'delete worktree' button in the sidebar to send an empty session list — SessionDialogs only removed the git worktree directory and skipped archiving any sessions, leaving them orphaned. Remove the guard so all sessions (including recursive children / subagent sessions) are collected regardless of archived state. * fix(sessions): delete all descendants on hard-delete instead of relying on server cascade The previous code sent only the root session ID and assumed the server would cascade-delete all children. If the cascade failed, children were left orphaned. Delete root + descendants individually; 404 responses from already-cascade-deleted children are treated as success. * fix(sessions): clear worktree metadata when deleting a session Deleted sessions kept their worktree attachment in both session-worktree-store and session-ui-store. Clean it up on successful deletion and on 404 (already deleted). * fix(worktree): search subagent sessions across all directories before delete WorktreeSectionContent and BranchPickerDialog used useSessions(), which is scoped to the current sync directory. Subagent sessions created in other worktrees/project roots were missed and left orphaned. Search across active + archived global sessions when collecting descendants. --------- Co-authored-by: Leonid Skorobogatyy Co-authored-by: Bohdan Triapitsyn --- .../openchamber/WorktreeSectionContent.tsx | 17 +++++++++++++---- .../session/sidebar/SessionGroupSection.tsx | 11 ++++++----- .../session/sidebar/hooks/useSessionActions.ts | 13 ++++++------- packages/ui/src/sync/session-actions.ts | 8 ++++++++ 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx index 2edcf0f8..1fee25b1 100644 --- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx +++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx @@ -4,10 +4,12 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Icon } from "@/components/icon/Icon"; +import type { Session } from '@opencode-ai/sdk/v2'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useDeviceInfo } from '@/lib/device'; import { checkIsGitRepository } from '@/lib/gitApi'; import { @@ -228,10 +230,17 @@ export const WorktreeSectionContent: React.FC = ({ // Build a set of session IDs that are directly linked const directSessionIds = new Set(directSessions.map((s) => s.id)); - // Find all subsessions recursively - const findSubsessions = (parentIds: Set): typeof sessions => { - const subsessions = sessions.filter((session) => { - const parentID = (session as { parentID?: string | null }).parentID; + // Search subsessions across all directories, not just the current sync + // context, so subagent sessions created in other worktrees/project roots + // are still included in the delete list. + const allKnownSessions = [ + ...useGlobalSessionsStore.getState().activeSessions, + ...useGlobalSessionsStore.getState().archivedSessions, + ]; + + const findSubsessions = (parentIds: Set): Session[] => { + const subsessions = allKnownSessions.filter((session) => { + const parentID = (session as Session & { parentID?: string | null }).parentID; return parentID && parentIds.has(parentID); }); if (subsessions.length === 0) { diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 0768a099..1e854bca 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -675,12 +675,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { return collected; }, []); - // The "delete all in group" handler closes over the full list of - // sessions in this group. Memoize so the recursive flatten only runs - // when the underlying source group nodes change, not on every render. + // Flat list of all sessions in this group (including nested children). + // Used by both the "delete all archived" button and the "delete worktree" + // button. Memoize so the recursive flatten only runs when the underlying + // source group nodes change, not on every render. const allGroupSessions = React.useMemo( - () => (group.isArchivedBucket ? collectGroupSessions(sourceGroupNodes) : []), - [collectGroupSessions, sourceGroupNodes, group.isArchivedBucket], + () => collectGroupSessions(sourceGroupNodes), + [collectGroupSessions, sourceGroupNodes], ); // Precompute the per-folder "delete all sessions in folder" list once diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts index e7447fae..7338be53 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts @@ -217,13 +217,12 @@ export const useSessionActions = (args: Args) => { const ids = [session.id, ...descendantIds]; if (shouldHardDelete) { - // The server cascade-deletes all descendant sessions when the parent - // is removed. Only send the root session delete request; sending - // individual requests for each descendant would hit 404 (already - // deleted by cascade) and trigger rollback that restores them. - const success = await args.deleteSession(session.id); - if (success) { - const totalDeleted = descendantIds.length + 1; + // Delete root + all descendants individually. If the server + // cascade-deletes some children before we get to them, 404 is + // treated as success by deleteSession and no rollback occurs. + const { deletedIds, failedIds } = await args.deleteSessions(ids); + if (failedIds.length === 0) { + const totalDeleted = deletedIds.length; toast.success(totalDeleted === 1 ? t('sessions.sidebar.bulkActions.deletedSingle', { count: totalDeleted }) : t('sessions.sidebar.bulkActions.deletedPlural', { count: totalDeleted })); diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 05a9092c..8d4fe796 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -466,6 +466,10 @@ function restoreSessionListSnapshots(snapshots: SessionListSnapshot[]): void { } } +function cleanupSessionWorktreeMetadata(sessionId: string): void { + useSessionUIStore.getState().setWorktreeMetadata(sessionId, null) +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars export async function deleteSession(sessionId: string, _options?: Record): Promise { const sessionDirectory = getSessionDirectory(sessionId) @@ -484,6 +488,7 @@ export async function deleteSession(sessionId: string, _options?: Record