import { useAtomSet } from "@effect/atom-react"; import { ThreadId } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; import { useCallback } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "./useHandleNewThread"; import { newCommandId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; import { gitRemoveWorktreeMutationAtom } from "../rpc/gitAtoms"; import { REACTIVITY_KEYS } from "../rpc/client"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; export function useThreadActions() { const appSettings = useSettings(); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, ); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); const navigate = useNavigate(); const { handleNewThread } = useHandleNewThread(); const removeWorktree = useAtomSet(gitRemoveWorktreeMutationAtom, { mode: "promise" }); const archiveThread = useCallback( async (threadId: ThreadId) => { const api = readNativeApi(); if (!api) return; const thread = useStore.getState().threads.find((entry) => entry.id === threadId); if (!thread) return; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { throw new Error("Cannot archive a running thread."); } await api.orchestration.dispatchCommand({ type: "thread.archive", commandId: newCommandId(), threadId, }); if (routeThreadId === threadId) { await handleNewThread(thread.projectId); } }, [handleNewThread, routeThreadId], ); const unarchiveThread = useCallback(async (threadId: ThreadId) => { const api = readNativeApi(); if (!api) return; await api.orchestration.dispatchCommand({ type: "thread.unarchive", commandId: newCommandId(), threadId, }); }, []); const deleteThread = useCallback( async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet } = {}) => { const api = readNativeApi(); if (!api) return; const { projects, threads } = useStore.getState(); const thread = threads.find((entry) => entry.id === threadId); if (!thread) return; const threadProject = projects.find((project) => project.id === thread.projectId); const deletedIds = opts.deletedThreadIds; const survivingThreads = deletedIds && deletedIds.size > 0 ? threads.filter((entry) => entry.id === threadId || !deletedIds.has(entry.id)) : threads; const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId); const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; const shouldDeleteWorktree = canDeleteWorktree && (await api.dialogs.confirm( [ "This thread is the only one linked to this worktree:", displayWorktreePath ?? orphanedWorktreePath, "", "Delete the worktree too?", ].join("\n"), )); if (thread.session && thread.session.status !== "closed") { await api.orchestration .dispatchCommand({ type: "thread.session.stop", commandId: newCommandId(), threadId, createdAt: new Date().toISOString(), }) .catch(() => undefined); } try { await api.terminal.close({ threadId, deleteHistory: true }); } catch { // Terminal may already be closed. } const deletedThreadIds = opts.deletedThreadIds ?? new Set(); const shouldNavigateToFallback = routeThreadId === threadId; const fallbackThreadId = getFallbackThreadIdAfterDelete({ threads, deletedThreadId: threadId, deletedThreadIds, sortOrder: appSettings.sidebarThreadSortOrder, }); await api.orchestration.dispatchCommand({ type: "thread.delete", commandId: newCommandId(), threadId, }); clearComposerDraftForThread(threadId); clearProjectDraftThreadById(thread.projectId, thread.id); clearTerminalState(threadId); if (shouldNavigateToFallback) { if (fallbackThreadId) { await navigate({ to: "/$threadId", params: { threadId: fallbackThreadId }, replace: true, }); } else { await navigate({ to: "/", replace: true }); } } if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { return; } try { await removeWorktree({ payload: { cwd: threadProject.cwd, path: orphanedWorktreePath, force: true, }, reactivityKeys: [ REACTIVITY_KEYS.git(threadProject.cwd), REACTIVITY_KEYS.project(threadProject.cwd), ], }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error removing worktree."; console.error("Failed to remove orphaned worktree after thread deletion", { threadId, projectCwd: threadProject.cwd, worktreePath: orphanedWorktreePath, error, }); toastManager.add({ type: "error", title: "Thread deleted, but worktree removal failed", description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, }); } }, [ clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalState, appSettings.sidebarThreadSortOrder, navigate, removeWorktree, routeThreadId, ], ); const confirmAndDeleteThread = useCallback( async (threadId: ThreadId) => { const api = readNativeApi(); if (!api) return; const thread = useStore.getState().threads.find((entry) => entry.id === threadId); if (!thread) return; if (appSettings.confirmThreadDelete) { const confirmed = await api.dialogs.confirm( [ `Delete thread "${thread.title}"?`, "This permanently clears conversation history for this thread.", ].join("\n"), ); if (!confirmed) { return; } } await deleteThread(threadId); }, [appSettings.confirmThreadDelete, deleteThread], ); return { archiveThread, unarchiveThread, deleteThread, confirmAndDeleteThread, }; }