fix(worktree): subagent sessions kept when deleting worktree group from sidebar (#1806)

* 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 <bash@opencode.itc.local>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
bashrusakh
2026-06-29 00:42:13 +03:00
committed by GitHub
co-authored by Leonid Skorobogatyy Bohdan Triapitsyn
parent ca64f1a886
commit c184ddf185
4 changed files with 33 additions and 16 deletions
@@ -4,10 +4,12 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import type { Session } from '@opencode-ai/sdk/v2';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context'; import { useSessions } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { checkIsGitRepository } from '@/lib/gitApi'; import { checkIsGitRepository } from '@/lib/gitApi';
import { import {
@@ -228,10 +230,17 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
// Build a set of session IDs that are directly linked // Build a set of session IDs that are directly linked
const directSessionIds = new Set(directSessions.map((s) => s.id)); const directSessionIds = new Set(directSessions.map((s) => s.id));
// Find all subsessions recursively // Search subsessions across all directories, not just the current sync
const findSubsessions = (parentIds: Set<string>): typeof sessions => { // context, so subagent sessions created in other worktrees/project roots
const subsessions = sessions.filter((session) => { // are still included in the delete list.
const parentID = (session as { parentID?: string | null }).parentID; const allKnownSessions = [
...useGlobalSessionsStore.getState().activeSessions,
...useGlobalSessionsStore.getState().archivedSessions,
];
const findSubsessions = (parentIds: Set<string>): Session[] => {
const subsessions = allKnownSessions.filter((session) => {
const parentID = (session as Session & { parentID?: string | null }).parentID;
return parentID && parentIds.has(parentID); return parentID && parentIds.has(parentID);
}); });
if (subsessions.length === 0) { if (subsessions.length === 0) {
@@ -675,12 +675,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
return collected; return collected;
}, []); }, []);
// The "delete all in group" handler closes over the full list of // Flat list of all sessions in this group (including nested children).
// sessions in this group. Memoize so the recursive flatten only runs // Used by both the "delete all archived" button and the "delete worktree"
// when the underlying source group nodes change, not on every render. // button. Memoize so the recursive flatten only runs when the underlying
// source group nodes change, not on every render.
const allGroupSessions = React.useMemo( const allGroupSessions = React.useMemo(
() => (group.isArchivedBucket ? collectGroupSessions(sourceGroupNodes) : []), () => collectGroupSessions(sourceGroupNodes),
[collectGroupSessions, sourceGroupNodes, group.isArchivedBucket], [collectGroupSessions, sourceGroupNodes],
); );
// Precompute the per-folder "delete all sessions in folder" list once // Precompute the per-folder "delete all sessions in folder" list once
@@ -217,13 +217,12 @@ export const useSessionActions = (args: Args) => {
const ids = [session.id, ...descendantIds]; const ids = [session.id, ...descendantIds];
if (shouldHardDelete) { if (shouldHardDelete) {
// The server cascade-deletes all descendant sessions when the parent // Delete root + all descendants individually. If the server
// is removed. Only send the root session delete request; sending // cascade-deletes some children before we get to them, 404 is
// individual requests for each descendant would hit 404 (already // treated as success by deleteSession and no rollback occurs.
// deleted by cascade) and trigger rollback that restores them. const { deletedIds, failedIds } = await args.deleteSessions(ids);
const success = await args.deleteSession(session.id); if (failedIds.length === 0) {
if (success) { const totalDeleted = deletedIds.length;
const totalDeleted = descendantIds.length + 1;
toast.success(totalDeleted === 1 toast.success(totalDeleted === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: totalDeleted }) ? t('sessions.sidebar.bulkActions.deletedSingle', { count: totalDeleted })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: totalDeleted })); : t('sessions.sidebar.bulkActions.deletedPlural', { count: totalDeleted }));
+8
View File
@@ -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 // eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function deleteSession(sessionId: string, _options?: Record<string, unknown>): Promise<boolean> { export async function deleteSession(sessionId: string, _options?: Record<string, unknown>): Promise<boolean> {
const sessionDirectory = getSessionDirectory(sessionId) const sessionDirectory = getSessionDirectory(sessionId)
@@ -484,6 +488,7 @@ export async function deleteSession(sessionId: string, _options?: Record<string,
throw new Error("session.delete failed: server did not confirm deletion") throw new Error("session.delete failed: server did not confirm deletion")
} }
useGlobalSessionsStore.getState().removeSessions([sessionId]) useGlobalSessionsStore.getState().removeSessions([sessionId])
cleanupSessionWorktreeMetadata(sessionId)
return true return true
} catch (error) { } catch (error) {
console.error("[session-actions] deleteSession failed", error) console.error("[session-actions] deleteSession failed", error)
@@ -491,6 +496,7 @@ export async function deleteSession(sessionId: string, _options?: Record<string,
// Subsequent delete attempts for those children return 404; treat as // Subsequent delete attempts for those children return 404; treat as
// success since the session was already deleted by the cascade. // success since the session was already deleted by the cascade.
if ((error as { status?: number })?.status === 404) { if ((error as { status?: number })?.status === 404) {
cleanupSessionWorktreeMetadata(sessionId)
return true return true
} }
restoreSessionListSnapshots(snapshots) restoreSessionListSnapshots(snapshots)
@@ -514,10 +520,12 @@ export async function deleteSessionInDirectory(sessionId: string, directory: str
throw new Error("session.delete failed: server did not confirm deletion") throw new Error("session.delete failed: server did not confirm deletion")
} }
useGlobalSessionsStore.getState().removeSessions([sessionId]) useGlobalSessionsStore.getState().removeSessions([sessionId])
cleanupSessionWorktreeMetadata(sessionId)
return true return true
} catch (error) { } catch (error) {
console.error("[session-actions] deleteSessionInDirectory failed", error) console.error("[session-actions] deleteSessionInDirectory failed", error)
if ((error as { status?: number })?.status === 404) { if ((error as { status?: number })?.status === 404) {
cleanupSessionWorktreeMetadata(sessionId)
return true return true
} }
restoreSessionListSnapshots(snapshots) restoreSessionListSnapshots(snapshots)