Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
createThreadJumpHintVisibilityController,
getFallbackThreadIdAfterBulkDelete,
getVisibleSidebarThreadIds,
resolveAdjacentThreadId,
getFallbackThreadIdAfterDelete,
Expand Down Expand Up @@ -865,6 +866,43 @@ describe("getFallbackThreadIdAfterDelete", () => {
});
});

describe("getFallbackThreadIdAfterBulkDelete", () => {
it("returns the top remaining non-archived thread across projects", () => {
const fallbackThreadId = getFallbackThreadIdAfterBulkDelete({
threads: [
{
id: ThreadId.makeUnsafe("thread-1"),
projectId: ProjectId.makeUnsafe("project-1"),
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
latestUserMessageAt: null,
},
{
id: ThreadId.makeUnsafe("thread-2"),
projectId: ProjectId.makeUnsafe("project-2"),
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
archivedAt: null,
latestUserMessageAt: null,
},
{
id: ThreadId.makeUnsafe("thread-3"),
projectId: ProjectId.makeUnsafe("project-3"),
createdAt: "2026-01-03T00:00:00.000Z",
updatedAt: "2026-01-03T00:00:00.000Z",
archivedAt: "2026-01-04T00:00:00.000Z",
latestUserMessageAt: null,
},
],
deletedThreadIds: new Set([ThreadId.makeUnsafe("thread-1"), ThreadId.makeUnsafe("thread-3")]),
sortOrder: "updated_at",
});

expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-2"));
});
});

describe("sortProjectsForSidebar", () => {
it("sorts projects by the most recent user message across their threads", () => {
const projects = [
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,23 @@ export function getFallbackThreadIdAfterDelete<
);
}

export function getFallbackThreadIdAfterBulkDelete<
T extends Pick<Thread, "id" | "createdAt" | "updatedAt" | "archivedAt"> & SidebarThreadSortInput,
>(input: {
threads: readonly T[];
deletedThreadIds: ReadonlySet<T["id"]>;
sortOrder: SidebarThreadSortOrder;
}): T["id"] | null {
const { deletedThreadIds, sortOrder, threads } = input;

return (
sortThreadsForSidebar(
threads.filter((thread) => !deletedThreadIds.has(thread.id) && thread.archivedAt === null),
sortOrder,
)[0]?.id ?? null
);
}

export function getProjectSortTimestamp(
project: SidebarProject,
projectThreads: readonly SidebarThreadSortInput[],
Expand Down
175 changes: 137 additions & 38 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import { isNonEmpty as isNonEmptyString } from "effect/String";
import {
getVisibleSidebarThreadIds,
getVisibleThreadsForProject,
getFallbackThreadIdAfterBulkDelete,
resolveAdjacentThreadId,
isContextMenuPointerDown,
resolveProjectStatusIndicator,
Expand All @@ -131,6 +132,15 @@ import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
import { useServerKeybindings } from "../rpc/serverState";
import { useSidebarThreadSummaryById } from "../storeSelectors";
import type { Project } from "../types";
import {
AlertDialog,
AlertDialogClose,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogPopup,
AlertDialogTitle,
} from "./ui/alert-dialog";
const THREAD_PREVIEW_LIMIT = 6;
const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
updated_at: "Last user message",
Expand Down Expand Up @@ -163,6 +173,12 @@ interface PrStatusIndicator {
}

type ThreadPr = GitStatusResult["pr"];
interface PendingProjectDelete {
projectId: ProjectId;
projectName: string;
threadIds: ThreadId[];
threadCount: number;
}

function ThreadStatusLabel({
status,
Expand Down Expand Up @@ -719,6 +735,10 @@ export default function Sidebar() {
const suppressProjectClickAfterDragRef = useRef(false);
const suppressProjectClickForContextMenuRef = useRef(false);
const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
const [pendingProjectDelete, setPendingProjectDelete] = useState<PendingProjectDelete | null>(
null,
);
const [isDeletingProject, setIsDeletingProject] = useState(false);
const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds);
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
Expand Down Expand Up @@ -1251,50 +1271,94 @@ export default function Sidebar() {
return;
}
if (clicked !== "delete") return;
const projectThreadIds = [...(threadIdsByProjectId[projectId] ?? [])];
setPendingProjectDelete({
projectId,
projectName: project.name,
threadIds: projectThreadIds,
threadCount: projectThreadIds.length,
});
Comment on lines +1274 to +1280
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PendingProjectDelete snapshots threadIds/threadCount at context-menu time. If threads are created/moved/deleted before the user confirms, this list can become stale (e.g., leaving threads behind or causing project.delete to fail because the project is no longer empty, and the dialog count can be inaccurate). Prefer recomputing the current thread IDs for the project at confirm-time (or store only projectId/projectName and derive the rest).

Copilot uses AI. Check for mistakes.
},
[copyPathToClipboard, projects, threadIdsByProjectId],
);

const projectThreadIds = threadIdsByProjectId[projectId] ?? [];
if (projectThreadIds.length > 0) {
toastManager.add({
type: "warning",
title: "Project is not empty",
description: "Delete all threads in this project before removing it.",
const confirmProjectDelete = useCallback(async () => {
const api = readNativeApi();
const pendingDelete = pendingProjectDelete;
if (!api || !pendingDelete || isDeletingProject) return;

setIsDeletingProject(true);
try {
const deletedThreadIds = new Set<ThreadId>(pendingDelete.threadIds);
const shouldNavigateAway =
(routeThreadId !== null && deletedThreadIds.has(routeThreadId)) ||
(routeThreadId === null && activeDraftThread?.projectId === pendingDelete.projectId);
const fallbackThreadId = shouldNavigateAway
? getFallbackThreadIdAfterBulkDelete({
threads: Object.values(sidebarThreadsById),
deletedThreadIds,
sortOrder: appSettings.sidebarThreadSortOrder,
})
: null;

for (const threadId of pendingDelete.threadIds) {
await deleteThread(threadId, {
deletedThreadIds,
skipWorktreeConfirm: true,
suppressNavigation: true,
});
return;
}

const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`);
if (!confirmed) return;

try {
const projectDraftThread = getDraftThreadByProjectId(projectId);
if (projectDraftThread) {
clearComposerDraftForThread(projectDraftThread.threadId);
const projectDraftThread = getDraftThreadByProjectId(pendingDelete.projectId);
if (projectDraftThread) {
clearComposerDraftForThread(projectDraftThread.threadId);
}
clearProjectDraftThreadId(pendingDelete.projectId);
await api.orchestration.dispatchCommand({
type: "project.delete",
commandId: newCommandId(),
projectId: pendingDelete.projectId,
});
removeFromSelection(pendingDelete.threadIds);

if (shouldNavigateAway) {
if (fallbackThreadId) {
await navigate({
to: "/$threadId",
params: { threadId: fallbackThreadId },
replace: true,
});
} else {
await navigate({ to: "/", replace: true });
}
clearProjectDraftThreadId(projectId);
await api.orchestration.dispatchCommand({
type: "project.delete",
commandId: newCommandId(),
projectId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error removing project.";
console.error("Failed to remove project", { projectId, error });
toastManager.add({
type: "error",
title: `Failed to remove "${project.name}"`,
description: message,
});
}
},
[
clearComposerDraftForThread,
clearProjectDraftThreadId,
copyPathToClipboard,
getDraftThreadByProjectId,
projects,
threadIdsByProjectId,
],
);

setPendingProjectDelete(null);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error removing project.";
console.error("Failed to remove project", { projectId: pendingDelete.projectId, error });
toastManager.add({
type: "error",
title: `Failed to remove "${pendingDelete.projectName}"`,
description: message,
});
} finally {
setIsDeletingProject(false);
}
}, [
activeDraftThread?.projectId,
appSettings.sidebarThreadSortOrder,
clearComposerDraftForThread,
clearProjectDraftThreadId,
deleteThread,
getDraftThreadByProjectId,
isDeletingProject,
navigate,
pendingProjectDelete,
removeFromSelection,
routeThreadId,
sidebarThreadsById,
]);

const projectDnDSensors = useSensors(
useSensor(PointerSensor, {
Expand Down Expand Up @@ -1994,6 +2058,41 @@ export default function Sidebar() {

return (
<>
<AlertDialog
open={pendingProjectDelete !== null}
onOpenChange={(open) => {
if (!open && !isDeletingProject) {
setPendingProjectDelete(null);
}
}}
>
<AlertDialogPopup>
<AlertDialogHeader>
<AlertDialogTitle>
Remove project "{pendingProjectDelete?.projectName}"?
</AlertDialogTitle>
<AlertDialogDescription>
{pendingProjectDelete
? pendingProjectDelete.threadCount > 0
? `This permanently deletes the project and its ${pendingProjectDelete.threadCount} ${pendingProjectDelete.threadCount === 1 ? "chat" : "chats"}.`
: "This permanently deletes the project."
: ""}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogClose render={<Button variant="outline" disabled={isDeletingProject} />}>
Cancel
</AlertDialogClose>
<Button
variant="destructive"
onClick={() => void confirmProjectDelete()}
disabled={isDeletingProject}
>
{isDeletingProject ? "Removing..." : "Remove project"}
</Button>
</AlertDialogFooter>
</AlertDialogPopup>
</AlertDialog>
{isElectron ? (
<SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
{wordmark}
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/hooks/useThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,14 @@ export function useThreadActions() {
}, []);

const deleteThread = useCallback(
async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => {
async (
threadId: ThreadId,
opts: {
deletedThreadIds?: ReadonlySet<ThreadId>;
skipWorktreeConfirm?: boolean;
suppressNavigation?: boolean;
} = {},
) => {
const api = readNativeApi();
if (!api) return;
const { projects, threads } = useStore.getState();
Expand All @@ -83,6 +90,7 @@ export function useThreadActions() {
: null;
const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined;
const shouldDeleteWorktree =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium hooks/useThreadActions.ts:92

When opts.skipWorktreeConfirm is true, the worktree is never deleted. The expression !opts.skipWorktreeConfirm && canDeleteWorktree && (await api.dialogs.confirm(...)) evaluates to false immediately when skipWorktreeConfirm is set, so shouldDeleteWorktree becomes false and the worktree cleanup is skipped entirely. For batch deletions this leaves orphaned worktrees behind even though the option name suggests only the confirmation dialog should be bypassed. Consider changing the logic to canDeleteWorktree && (opts.skipWorktreeConfirm || (await api.dialogs.confirm(...))) so the worktree is deleted without prompting when the flag is set.

🤖 Copy this AI Prompt to have your agent fix this:
In file apps/web/src/hooks/useThreadActions.ts around line 92:

When `opts.skipWorktreeConfirm` is `true`, the worktree is never deleted. The expression `!opts.skipWorktreeConfirm && canDeleteWorktree && (await api.dialogs.confirm(...))` evaluates to `false` immediately when `skipWorktreeConfirm` is set, so `shouldDeleteWorktree` becomes `false` and the worktree cleanup is skipped entirely. For batch deletions this leaves orphaned worktrees behind even though the option name suggests only the confirmation dialog should be bypassed. Consider changing the logic to `canDeleteWorktree && (opts.skipWorktreeConfirm || (await api.dialogs.confirm(...)))` so the worktree is deleted without prompting when the flag is set.

Evidence trail:
apps/web/src/hooks/useThreadActions.ts lines 92-102 (shouldDeleteWorktree definition with `!opts.skipWorktreeConfirm && canDeleteWorktree && (await api.dialogs.confirm(...))`), lines 150-152 (early return when shouldDeleteWorktree is false), lines 154-159 (worktree deletion via removeWorktreeMutation.mutateAsync only reached if shouldDeleteWorktree is true). Commit: REVIEWED_COMMIT

!opts.skipWorktreeConfirm &&
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skipWorktreeConfirm skips deletion, not just confirmation

Medium Severity

The skipWorktreeConfirm flag short-circuits shouldDeleteWorktree to false, which skips the actual worktree removal entirely — not just the confirmation dialog. When bulk-deleting a project's threads, any orphaned git worktrees are silently left on disk. The option name implies skipping only the prompt while still performing cleanup, but the implementation prevents both.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1e0acdd. Configure here.

canDeleteWorktree &&
(await api.dialogs.confirm(
Comment on lines 91 to 95
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skipWorktreeConfirm currently prevents worktree deletion entirely (because shouldDeleteWorktree becomes false), not just skipping the confirmation UI. If the intent is to delete orphaned worktrees without prompting during bulk deletion, adjust the boolean logic (or rename the option to reflect that it disables deletion). Otherwise, bulk project deletion will leave orphaned worktrees behind with no cleanup path in this flow.

Copilot uses AI. Check for mistakes.
[
Expand Down Expand Up @@ -127,7 +135,7 @@ export function useThreadActions() {
clearProjectDraftThreadById(thread.projectId, thread.id);
clearTerminalState(threadId);

if (shouldNavigateToFallback) {
if (shouldNavigateToFallback && !opts.suppressNavigation) {
if (fallbackThreadId) {
await navigate({
to: "/$threadId",
Expand Down
Loading