authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 14:28:10-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 16:35:17-08:00
log748ea2ae205122f8571287273599c5767bff1d55
tree73f1d6317fe4c9b3df7a8a504d77be61a9444482
parent56a09ad1aa6334101047aa1f7da9882e38b837bf

Block deletion of projects that still have threads

- Show warning toast when attempting to delete a non-empty project - Simplify confirmation copy to emphasize irreversible deletion - Remove thread/worktree/session cleanup from project delete flow

1 files changed, 11 insertions(+), 54 deletions(-)

apps/web/src/components/Sidebar.tsx+11-54
...@@ -344,19 +344,17 @@ export default function Sidebar() {...@@ -344,19 +344,17 @@ export default function Sidebar() {
344 if (!project) return;344 if (!project) return;
345345
346 const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);346 const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);
347 const linkedWorktreeCount = new Set(347 if (projectThreads.length > 0) {
348 projectThreads348 toastManager.add({
349 .map((thread) => thread.worktreePath?.trim() ?? "")349 type: "warning",
350 .filter((worktreePath) => worktreePath.length > 0),350 title: "Project is not empty",
351 ).size;351 description: "Delete all threads in this project before deleting it.",
352 });
353 return;
354 }
355
352 const confirmed = await api.dialogs.confirm(356 const confirmed = await api.dialogs.confirm(
353 [357 [`Delete project "${project.name}"?`, "This action cannot be undone."].join("\n"),
354 `Delete project "${project.name}"?`,
355 `This will delete ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}.`,
356 linkedWorktreeCount > 0
357 ? `Linked worktrees to remove: ${linkedWorktreeCount}.`
358 : "No linked worktrees detected.",
359 ].join("\n"),
360 );358 );
361 if (!confirmed) return;359 if (!confirmed) return;
362360
...@@ -376,50 +374,9 @@ export default function Sidebar() {...@@ -376,50 +374,9 @@ export default function Sidebar() {
376 }374 }
377 }375 }
378376
379 const retainedThreads = state.threads.filter((thread) => thread.projectId !== projectId);
380 const removedWorktreePaths = new Set<string>();
381 await Promise.all(
382 projectThreads.map(async (thread) => {
383 const orphanedWorktreePath = getOrphanedWorktreePathForThread(
384 [thread, ...retainedThreads],
385 thread.id,
386 );
387 if (orphanedWorktreePath && !removedWorktreePaths.has(orphanedWorktreePath)) {
388 removedWorktreePaths.add(orphanedWorktreePath);
389 try {
390 await removeWorktreeMutation.mutateAsync({
391 cwd: project.cwd,
392 path: orphanedWorktreePath,
393 });
394 } catch {
395 // Worktree deletion is best-effort and should not block project deletion.
396 }
397 }
398
399 if (thread.session?.sessionId) {
400 try {
401 await api.providers.stopSession({
402 sessionId: thread.session.sessionId,
403 });
404 } catch {
405 // Session may already be stopped
406 }
407 }
408
409 try {
410 await api.terminal.close({
411 threadId: thread.id,
412 deleteHistory: true,
413 });
414 } catch {
415 // Terminal may already be closed
416 }
417 }),
418 );
419
420 dispatch({ type: "DELETE_PROJECT", projectId });377 dispatch({ type: "DELETE_PROJECT", projectId });
421 },378 },
422 [api, dispatch, removeWorktreeMutation, state.projects, state.threads],379 [api, dispatch, state.projects, state.threads],
423 );380 );
424381
425 useEffect(() => {382 useEffect(() => {