Skip to content
Merged
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
111 changes: 111 additions & 0 deletions src/main/lib/trpc/routers/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ============

/**
Expand Down
42 changes: 3 additions & 39 deletions src/renderer/components/confirm-archive-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<number>(0)
const confirmButtonRef = useRef<HTMLButtonElement>(null)
// Use ref to avoid re-registering keydown listener when checkbox changes
const deleteWorktreeRef = useRef(deleteWorktree)
deleteWorktreeRef.current = deleteWorktree

useEffect(() => {
setMounted(true)
Expand All @@ -39,8 +30,6 @@ export function ConfirmArchiveDialog({
useEffect(() => {
if (isOpen) {
openAtRef.current = performance.now()
// Reset checkbox when dialog opens
setDeleteWorktree(false)
}
}, [isOpen])

Expand All @@ -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])

Expand Down Expand Up @@ -87,7 +76,6 @@ export function ConfirmArchiveDialog({
if (!portalTarget) return null

const hasProcesses = activeProcessCount > 0
const showWarning = deleteWorktree && uncommittedCount > 0

return createPortal(
<AnimatePresence mode="wait" initial={false}>
Expand Down Expand Up @@ -128,35 +116,11 @@ export function ConfirmArchiveDialog({
Archive Workspace
</h2>

{/* Active processes warning */}
{hasProcesses && (
<p className="text-sm text-muted-foreground mb-4">
<p className="text-sm text-muted-foreground">
{activeProcessCount} running {activeProcessCount === 1 ? "process" : "processes"} will be stopped.
</p>
)}

{/* Worktree checkbox */}
{hasWorktree && (
<div className="space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<Checkbox
checked={deleteWorktree}
onCheckedChange={(checked) => setDeleteWorktree(checked === true)}
className="mt-0.5"
/>
<span className="text-sm select-none">
Delete worktree to free disk space
</span>
</label>

{/* Uncommitted changes warning */}
{showWarning && (
<p className="text-sm text-amber-600 dark:text-amber-500 ml-7">
{uncommittedCount} uncommitted {uncommittedCount === 1 ? "change" : "changes"} will be lost
</p>
)}
</div>
)}
</div>

{/* Footer with buttons */}
Expand Down
54 changes: 54 additions & 0 deletions src/renderer/components/confirm-delete-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogBody>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogBody>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700 text-white"
>
{isDeleting ? "Deleting..." : confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
59 changes: 58 additions & 1 deletion src/renderer/features/agents/main/active-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
GitFork,
ListTree,
TerminalSquare,
Trash2,
X as XIcon,
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}
/>
Expand Down Expand Up @@ -7780,7 +7803,7 @@ Make sure to preserve all functionality from both branches when resolving confli
<Button
variant="ghost"
onClick={handleRestoreWorkspace}
disabled={restoreWorkspaceMutation.isPending}
disabled={restoreWorkspaceMutation.isPending || deleteWorkspaceMutation.isPending}
className="h-6 px-2 gap-1.5 hover:bg-foreground/10 transition-colors text-foreground flex-shrink-0 rounded-md ml-2 flex items-center"
aria-label="Restore workspace"
style={{
Expand All @@ -7798,6 +7821,30 @@ Make sure to preserve all functionality from both branches when resolving confli
</TooltipContent>
</Tooltip>
)}
{/* Delete Button - shows when viewing archived workspace (desktop only) */}
{!isMobileFullscreen && isArchived && (
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={handleDeleteWorkspace}
disabled={restoreWorkspaceMutation.isPending || deleteWorkspaceMutation.isPending}
className="h-6 px-2 gap-1.5 hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-500 transition-colors text-foreground flex-shrink-0 rounded-md ml-1 flex items-center"
aria-label="Delete workspace"
style={{
// @ts-expect-error - WebKit-specific property
WebkitAppRegion: "no-drag",
}}
>
<Trash2 className="h-4 w-4" />
<span className="text-xs">Delete</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
Delete workspace permanently
</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
Expand Down Expand Up @@ -8286,6 +8333,16 @@ Make sure to preserve all functionality from both branches when resolving confli
remoteSubChatId={activeSubChatId}
/>

{/* Delete Workspace Confirmation Dialog */}
<ConfirmDeleteDialog
open={confirmDeleteWorkspaceOpen}
onOpenChange={setConfirmDeleteWorkspaceOpen}
title="Delete Workspace"
description="Delete this archived workspace? This removes the workspace and its worktree permanently and cannot be undone."
onConfirm={handleConfirmDeleteWorkspace}
isDeleting={deleteWorkspaceMutation.isPending}
/>

{/* Unified Details Sidebar - combines all right sidebars into one (rightmost) */}
{/* Show for both local (worktreePath) and remote (sandboxId) chats */}
{isUnifiedSidebarEnabled && !isMobileFullscreen && (worktreePath || sandboxId) && (
Expand Down
Loading