diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index d33dcba2b..8fbbd956c 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -701,6 +701,117 @@ export const chatsRouter = router({ return db.delete(chats).where(eq(chats.id, input.id)).returning().get() }), + /** + * Delete all archived chats permanently (and their worktrees). + */ + deleteAllArchived: publicProcedure + .input(z.object({}).default({})) + .mutation(async () => { + const db = getDatabase() + + const archived = db + .select({ + id: chats.id, + branch: chats.branch, + worktreePath: chats.worktreePath, + projectId: chats.projectId, + }) + .from(chats) + .where(isNotNull(chats.archivedAt)) + .all() + + if (archived.length === 0) return [] + + const archivedIds = archived.map((c) => c.id) + + const subChatIds = db + .select({ id: subChats.id }) + .from(subChats) + .where(inArray(subChats.chatId, archivedIds)) + .all() + .map((row) => row.id) + if (subChatIds.length > 0) { + abortClaudeSessionsForSubChats(subChatIds) + } + + const worktreeChats = archived.filter( + (c) => c.branch != null && c.worktreePath != null, + ) + + if (worktreeChats.length > 0) { + const projectIds = Array.from( + new Set(worktreeChats.map((c) => c.projectId)), + ) + const projectRows = db + .select() + .from(projects) + .where(inArray(projects.id, projectIds)) + .all() + const projectPathById = new Map( + projectRows.map((p) => [p.id, p.path]), + ) + + Promise.allSettled( + worktreeChats.map((c) => { + const projectPath = projectPathById.get(c.projectId) + if (!projectPath || !c.worktreePath) { + return Promise.resolve({ success: false, error: "missing-project" }) + } + return removeWorktree(projectPath, c.worktreePath) + }), + ) + .then((results) => { + const failures = results.filter( + (r) => r.status === "rejected" || (r.status === "fulfilled" && !r.value.success), + ).length + if (failures > 0) { + console.warn( + `[chats.deleteAllArchived] ${failures}/${worktreeChats.length} worktree removals failed`, + ) + } else { + console.log( + `[chats.deleteAllArchived] Removed ${worktreeChats.length} worktree(s)`, + ) + } + }) + .catch((error) => { + console.error(`[chats.deleteAllArchived] Worktree removal error:`, error) + }) + + Promise.allSettled( + worktreeChats.map((c) => terminalManager.killByWorkspaceId(c.id)), + ) + .then((results) => { + const totalKilled = results.reduce((sum, r) => { + if (r.status === "fulfilled") return sum + r.value.killed + return sum + }, 0) + if (totalKilled > 0) { + console.log( + `[chats.deleteAllArchived] Killed ${totalKilled} terminal session(s)`, + ) + } + }) + .catch((error) => { + console.error(`[chats.deleteAllArchived] Terminal cleanup error:`, error) + }) + } + + for (const c of archived) { + trackWorkspaceDeleted(c.id) + if (c.worktreePath) { + gitCache.invalidateStatus(c.worktreePath) + gitCache.invalidateParsedDiff(c.worktreePath) + } + } + + return db + .delete(chats) + .where(inArray(chats.id, archivedIds)) + .returning() + .all() + }), + // ============ Sub-chat procedures ============ /** diff --git a/src/renderer/components/confirm-archive-dialog.tsx b/src/renderer/components/confirm-archive-dialog.tsx index 4f0dba731..2f00d4fb0 100644 --- a/src/renderer/components/confirm-archive-dialog.tsx +++ b/src/renderer/components/confirm-archive-dialog.tsx @@ -2,15 +2,12 @@ import { AnimatePresence, motion } from "motion/react" import { useEffect, useState, useRef, useCallback } from "react" import { createPortal } from "react-dom" import { Button } from "./ui/button" -import { Checkbox } from "./ui/checkbox" interface ConfirmArchiveDialogProps { isOpen: boolean onClose: () => void - onConfirm: (deleteWorktree: boolean) => void + onConfirm: () => void activeProcessCount: number - hasWorktree: boolean - uncommittedCount: number } const EASING_CURVE = [0.55, 0.055, 0.675, 0.19] as const @@ -21,16 +18,10 @@ export function ConfirmArchiveDialog({ onClose, onConfirm, activeProcessCount, - hasWorktree, - uncommittedCount, }: ConfirmArchiveDialogProps) { const [mounted, setMounted] = useState(false) - const [deleteWorktree, setDeleteWorktree] = useState(false) const openAtRef = useRef(0) const confirmButtonRef = useRef(null) - // Use ref to avoid re-registering keydown listener when checkbox changes - const deleteWorktreeRef = useRef(deleteWorktree) - deleteWorktreeRef.current = deleteWorktree useEffect(() => { setMounted(true) @@ -39,8 +30,6 @@ export function ConfirmArchiveDialog({ useEffect(() => { if (isOpen) { openAtRef.current = performance.now() - // Reset checkbox when dialog opens - setDeleteWorktree(false) } }, [isOpen]) @@ -59,7 +48,7 @@ export function ConfirmArchiveDialog({ const handleConfirm = useCallback(() => { const canInteract = performance.now() - openAtRef.current > INTERACTION_DELAY_MS if (!canInteract) return - onConfirm(deleteWorktreeRef.current) + onConfirm() onClose() }, [onConfirm, onClose]) @@ -87,7 +76,6 @@ export function ConfirmArchiveDialog({ if (!portalTarget) return null const hasProcesses = activeProcessCount > 0 - const showWarning = deleteWorktree && uncommittedCount > 0 return createPortal( @@ -128,35 +116,11 @@ export function ConfirmArchiveDialog({ Archive Workspace - {/* Active processes warning */} {hasProcesses && ( -

+

{activeProcessCount} running {activeProcessCount === 1 ? "process" : "processes"} will be stopped.

)} - - {/* Worktree checkbox */} - {hasWorktree && ( -
- - - {/* Uncommitted changes warning */} - {showWarning && ( -

- {uncommittedCount} uncommitted {uncommittedCount === 1 ? "change" : "changes"} will be lost -

- )} -
- )} {/* Footer with buttons */} diff --git a/src/renderer/components/confirm-delete-dialog.tsx b/src/renderer/components/confirm-delete-dialog.tsx new file mode 100644 index 000000000..75ebb296f --- /dev/null +++ b/src/renderer/components/confirm-delete-dialog.tsx @@ -0,0 +1,54 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogBody, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "./ui/alert-dialog" + +interface ConfirmDeleteDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: React.ReactNode + confirmLabel?: string + onConfirm: () => void + isDeleting?: boolean +} + +export function ConfirmDeleteDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = "Delete", + onConfirm, + isDeleting = false, +}: ConfirmDeleteDialogProps) { + return ( + + + + {title} + + + {description} + + + Cancel + + {isDeleting ? "Deleting..." : confirmLabel} + + + + + ) +} diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index fe223e5d5..34cd5d142 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -42,6 +42,7 @@ import { GitFork, ListTree, TerminalSquare, + Trash2, X as XIcon, } from "lucide-react" import { AnimatePresence, motion } from "motion/react" @@ -156,6 +157,7 @@ import { type SelectedCommit } from "../atoms" import { BUILTIN_SLASH_COMMANDS } from "../commands" +import { ConfirmDeleteDialog } from "../../../components/confirm-delete-dialog" import { AgentSendButton } from "../components/agent-send-button" import { OpenLocallyDialog } from "../components/open-locally-dialog" import { PreviewSetupHoverCard } from "../components/preview-setup-hover-card" @@ -4967,6 +4969,7 @@ export function ChatView({ const setSubChatUnseenChanges = useSetAtom(agentsSubChatUnseenChangesAtom) const setJustCreatedIds = useSetAtom(justCreatedIdsAtom) const selectedChatId = useAtomValue(selectedAgentChatIdAtom) + const setSelectedChatId = useSetAtom(selectedAgentChatIdAtom) const setUndoStack = useSetAtom(undoStackAtom) const setSelectedFilePath = useSetAtom(selectedDiffFilePathAtom) const setFilteredDiffFiles = useSetAtom(filteredDiffFilesAtom) @@ -5726,6 +5729,25 @@ export function ChatView({ restoreWorkspaceMutation.mutate({ id: chatId }) }, [chatId, restoreWorkspaceMutation]) + // Delete archived workspace mutation + const [confirmDeleteWorkspaceOpen, setConfirmDeleteWorkspaceOpen] = useState(false) + const deleteWorkspaceMutation = trpc.chats.delete.useMutation({ + onSuccess: () => { + trpcUtils.chats.list.invalidate() + trpcUtils.chats.listArchived.invalidate() + setSelectedChatId(null) + }, + }) + + const handleDeleteWorkspace = useCallback(() => { + setConfirmDeleteWorkspaceOpen(true) + }, []) + + const handleConfirmDeleteWorkspace = useCallback(() => { + deleteWorkspaceMutation.mutate({ id: chatId, deleteWorktree: true }) + setConfirmDeleteWorkspaceOpen(false) + }, [chatId, deleteWorkspaceMutation]) + // Check if this workspace is archived const isArchived = !!agentChat?.archivedAt @@ -7613,6 +7635,7 @@ Make sure to preserve all functionality from both branches when resolving confli isTerminalOpen={isTerminalSidebarOpen} isArchived={isArchived} onRestore={handleRestoreWorkspace} + onDelete={handleDeleteWorkspace} onOpenLocally={handleOpenLocally} showOpenLocally={showOpenLocally} /> @@ -7780,7 +7803,7 @@ Make sure to preserve all functionality from both branches when resolving confli + + + Delete workspace permanently + + + )} )} @@ -8286,6 +8333,16 @@ Make sure to preserve all functionality from both branches when resolving confli remoteSubChatId={activeSubChatId} /> + {/* Delete Workspace Confirmation Dialog */} + + {/* Unified Details Sidebar - combines all right sidebars into one (rightmost) */} {/* Show for both local (worktreePath) and remote (sandboxId) chats */} {isUnifiedSidebarEnabled && !isMobileFullscreen && (worktreePath || sandboxId) && ( diff --git a/src/renderer/features/agents/ui/archive-popover.tsx b/src/renderer/features/agents/ui/archive-popover.tsx index 3aa5f345f..cbfa63a93 100644 --- a/src/renderer/features/agents/ui/archive-popover.tsx +++ b/src/renderer/features/agents/ui/archive-popover.tsx @@ -14,6 +14,7 @@ import { useRemoteArchivedChats, useRestoreRemoteChat, } from "../../../lib/hooks/use-remote-chats" +import { Trash2 } from "lucide-react" import { Input } from "../../../components/ui/input" import { SearchIcon, @@ -27,6 +28,12 @@ import { PopoverContent, PopoverTrigger, } from "../../../components/ui/popover" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "../../../components/ui/tooltip" +import { ConfirmDeleteDialog } from "../../../components/confirm-delete-dialog" import { cn } from "../../../lib/utils" // GitHub avatar with loading placeholder @@ -314,6 +321,29 @@ export const ArchivePopover = memo(function ArchivePopover({ trigger }: ArchiveP // Remote restore mutation const remoteRestoreMutation = useRestoreRemoteChat() + // Confirm-delete-all state + mutation (local archived only) + const [confirmDeleteAllOpen, setConfirmDeleteAllOpen] = useState(false) + const deleteAllArchivedMutation = trpc.chats.deleteAllArchived.useMutation({ + onSuccess: (deleted) => { + utils.chats.listArchived.invalidate() + utils.chats.list.invalidate() + if ( + !selectedChatIsRemote && + selectedChatId && + deleted.some((c) => c.id === selectedChatId) + ) { + setSelectedChatId(null) + } + setConfirmDeleteAllOpen(false) + }, + }) + + const handleConfirmDeleteAll = useCallback(() => { + deleteAllArchivedMutation.mutate({}) + }, [deleteAllArchivedMutation]) + + const localArchivedCount = localArchivedChats?.length ?? 0 + // Normalize and merge archived chats from both sources const normalizedChats = useMemo((): NormalizedArchivedChat[] => { const merged: NormalizedArchivedChat[] = [] @@ -504,6 +534,7 @@ export const ArchivePopover = memo(function ArchivePopover({ trigger }: ArchiveP }, [setSearchQuery]) return ( + <> {trigger} + {localArchivedCount > 0 && ( + + + + + + Delete all archived + + + )} @@ -566,5 +615,21 @@ export const ArchivePopover = memo(function ArchivePopover({ trigger }: ArchiveP + + Delete all {localArchivedCount} archived workspace + {localArchivedCount === 1 ? "" : "s"}? This removes them and their + worktrees permanently and cannot be undone. + + } + confirmLabel="Delete all" + onConfirm={handleConfirmDeleteAll} + isDeleting={deleteAllArchivedMutation.isPending} + /> + ) }) diff --git a/src/renderer/features/agents/ui/mobile-chat-header.tsx b/src/renderer/features/agents/ui/mobile-chat-header.tsx index 1e0600a8c..454caa89e 100644 --- a/src/renderer/features/agents/ui/mobile-chat-header.tsx +++ b/src/renderer/features/agents/ui/mobile-chat-header.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo, useState } from "react" import { useAtomValue } from "jotai" import { loadingSubChatsAtom } from "../atoms" -import { Plus, ChevronDown, Play, AlignJustify, FolderDown } from "lucide-react" +import { Plus, ChevronDown, Play, AlignJustify, FolderDown, Trash2 } from "lucide-react" import { IconSpinner, PlanIcon, @@ -43,6 +43,7 @@ interface MobileChatHeaderProps { isTerminalOpen?: boolean isArchived?: boolean onRestore?: () => void + onDelete?: () => void onOpenLocally?: () => void showOpenLocally?: boolean } @@ -60,6 +61,7 @@ export function MobileChatHeader({ isTerminalOpen = false, isArchived = false, onRestore, + onDelete, onOpenLocally, showOpenLocally = false, }: MobileChatHeaderProps) { @@ -296,6 +298,19 @@ export function MobileChatHeader({ Restore )} + + {/* Delete button - only when viewing archived workspace */} + {isArchived && onDelete && ( + + )} ) diff --git a/src/renderer/features/kanban/kanban-view.tsx b/src/renderer/features/kanban/kanban-view.tsx index 4285dd089..dcea5a1df 100644 --- a/src/renderer/features/kanban/kanban-view.tsx +++ b/src/renderer/features/kanban/kanban-view.tsx @@ -64,8 +64,6 @@ export function KanbanView() { const [confirmArchiveDialogOpen, setConfirmArchiveDialogOpen] = useState(false) const [archivingChatId, setArchivingChatId] = useState(null) const [activeProcessCount, setActiveProcessCount] = useState(0) - const [hasWorktree, setHasWorktree] = useState(false) - const [uncommittedCount, setUncommittedCount] = useState(0) // tRPC utils const utils = trpc.useUtils() @@ -365,24 +363,16 @@ export function KanbanView() { // Archive handler with confirmation for active processes const handleArchive = useCallback(async (chatId: string) => { - // Check for active processes and worktree const chat = chats?.find((c) => c.id === chatId) const isLocalMode = !chat?.branch - const [sessionCount, worktreeStatus] = await Promise.all([ - // Local mode: terminals are shared and won't be killed on archive, so skip count - isLocalMode - ? Promise.resolve(0) - : utils.terminal.getActiveSessionCount.fetch({ workspaceId: chatId }), - utils.chats.getWorktreeStatus.fetch({ chatId }), - ]) + // Local mode: terminals are shared and won't be killed on archive, so skip count + const sessionCount = isLocalMode + ? 0 + : await utils.terminal.getActiveSessionCount.fetch({ workspaceId: chatId }) - const needsConfirmation = sessionCount > 0 || worktreeStatus.hasWorktree - - if (needsConfirmation) { + if (sessionCount > 0) { setArchivingChatId(chatId) setActiveProcessCount(sessionCount) - setHasWorktree(worktreeStatus.hasWorktree) - setUncommittedCount(worktreeStatus.uncommittedCount) setConfirmArchiveDialogOpen(true) } else { await archiveChatMutation.mutateAsync({ id: chatId }) @@ -459,8 +449,6 @@ export function KanbanView() { onClose={handleCancelArchive} onConfirm={handleConfirmArchive} activeProcessCount={activeProcessCount} - hasWorktree={hasWorktree} - uncommittedCount={uncommittedCount} /> ) diff --git a/src/renderer/features/sidebar/agents-sidebar.tsx b/src/renderer/features/sidebar/agents-sidebar.tsx index 8463071d8..3093aaabf 100644 --- a/src/renderer/features/sidebar/agents-sidebar.tsx +++ b/src/renderer/features/sidebar/agents-sidebar.tsx @@ -1765,8 +1765,6 @@ export function AgentsSidebar({ const [confirmArchiveDialogOpen, setConfirmArchiveDialogOpen] = useState(false) const [archivingChatId, setArchivingChatId] = useState(null) const [activeProcessCount, setActiveProcessCount] = useState(0) - const [hasWorktree, setHasWorktree] = useState(false) - const [uncommittedCount, setUncommittedCount] = useState(0) // Import sandbox dialog state const [importDialogOpen, setImportDialogOpen] = useState(false) @@ -2773,27 +2771,19 @@ export function AgentsSidebar({ return } - // Fetch both session count and worktree status in parallel + // Only worktree-mode workspaces may have running terminals — skip count for local mode const isLocalMode = !chat?.branch - const [sessionCount, worktreeStatus] = await Promise.all([ - // Local mode: terminals are shared and won't be killed on archive, so skip count - isLocalMode - ? Promise.resolve(0) - : utils.terminal.getActiveSessionCount.fetch({ workspaceId: chatId }), - utils.chats.getWorktreeStatus.fetch({ chatId }), - ]) - - const needsConfirmation = sessionCount > 0 || worktreeStatus.hasWorktree - - if (needsConfirmation) { - // Show confirmation dialog + const sessionCount = isLocalMode + ? 0 + : await utils.terminal.getActiveSessionCount.fetch({ workspaceId: chatId }) + + if (sessionCount > 0) { + // Show confirmation dialog so user is warned about running processes setArchivingChatId(chatId) setActiveProcessCount(sessionCount) - setHasWorktree(worktreeStatus.hasWorktree) - setUncommittedCount(worktreeStatus.uncommittedCount) setConfirmArchiveDialogOpen(true) } else { - // No active processes and no worktree, archive directly + // No active processes, archive directly archiveChatMutation.mutate({ id: chatId }) } }, [ @@ -2801,7 +2791,6 @@ export function AgentsSidebar({ archiveRemoteChatMutation, archiveChatMutation, utils.terminal.getActiveSessionCount, - utils.chats.getWorktreeStatus, selectedChatId, autoAdvanceTarget, previousChatId, @@ -2811,9 +2800,9 @@ export function AgentsSidebar({ ]) // Confirm archive after user accepts dialog (optimistic - closes immediately) - const handleConfirmArchive = useCallback((deleteWorktree: boolean) => { + const handleConfirmArchive = useCallback(() => { if (archivingChatId) { - archiveChatMutation.mutate({ id: archivingChatId, deleteWorktree }) + archiveChatMutation.mutate({ id: archivingChatId }) setArchivingChatId(null) } }, [archiveChatMutation, archivingChatId]) @@ -3568,8 +3557,6 @@ export function AgentsSidebar({ onClose={handleCloseArchiveDialog} onConfirm={handleConfirmArchive} activeProcessCount={activeProcessCount} - hasWorktree={hasWorktree} - uncommittedCount={uncommittedCount} /> {/* Open Locally Dialog */}