329 lines · 11.4 KB · TypeScript
RawDownload
import { ThreadId, type OrchestrationArchivedThreadSummary } from "@t3tools/contracts";import { useMutation, useQueryClient } from "@tanstack/react-query";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 { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";import { orchestrationQueryKeys } from "../lib/orchestrationReactQuery";import { newCommandId } from "../lib/utils";import { readNativeApi } from "../nativeApi";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 syncServerReadModel = useStore((store) => store.syncServerReadModel);  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 queryClient = useQueryClient();  const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));   const refreshActiveSnapshot = useCallback(async () => {    const api = readNativeApi();    if (!api) return;    const snapshot = await api.orchestration.getActiveSnapshot();    syncServerReadModel(snapshot);  }, [syncServerReadModel]);   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,      });      void queryClient.invalidateQueries({ queryKey: orchestrationQueryKeys.archivedThreads() });       if (routeThreadId === threadId) {        await handleNewThread(thread.projectId);      }    },    [handleNewThread, queryClient, routeThreadId],  );   const unarchiveThread = useCallback(    async (threadId: ThreadId) => {      const api = readNativeApi();      if (!api) return;      await api.orchestration.dispatchCommand({        type: "thread.unarchive",        commandId: newCommandId(),        threadId,      });      await refreshActiveSnapshot();      void queryClient.invalidateQueries({ queryKey: orchestrationQueryKeys.archivedThreads() });    },    [queryClient, refreshActiveSnapshot],  );   const deleteThread = useCallback(    async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet<ThreadId> } = {}) => {      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<ThreadId>();      const shouldNavigateToFallback = routeThreadId === threadId;      const fallbackThreadId = getFallbackThreadIdAfterDelete({        threads,        deletedThreadId: threadId,        deletedThreadIds,        sortOrder: appSettings.sidebarThreadSortOrder,      });      await api.orchestration.dispatchCommand({        type: "thread.delete",        commandId: newCommandId(),        threadId,      });      void queryClient.invalidateQueries({ queryKey: orchestrationQueryKeys.archivedThreads() });      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 removeWorktreeMutation.mutateAsync({          cwd: threadProject.cwd,          path: orphanedWorktreePath,          force: true,        });      } 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,      removeWorktreeMutation,      routeThreadId,      queryClient,    ],  );   const deleteArchivedThread = useCallback(    async (      thread: OrchestrationArchivedThreadSummary,      archivedThreads: ReadonlyArray<OrchestrationArchivedThreadSummary>,    ) => {      const api = readNativeApi();      if (!api) return;       const { projects, threads } = useStore.getState();      const threadProject = projects.find((project) => project.id === thread.projectId);       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;        }      }       const normalizedWorktreePath = thread.worktreePath?.trim() || null;      const hasSharedWorktree =        normalizedWorktreePath !== null &&        (threads.some(          (entry) =>            entry.id !== thread.threadId &&            (entry.worktreePath?.trim() || null) === normalizedWorktreePath,        ) ||          archivedThreads.some(            (entry) =>              entry.threadId !== thread.threadId &&              (entry.worktreePath?.trim() || null) === normalizedWorktreePath,          ));      const orphanedWorktreePath = hasSharedWorktree ? null : normalizedWorktreePath;      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"),        ));       try {        await api.terminal.close({ threadId: thread.threadId, deleteHistory: true });      } catch {        // Terminal may already be closed.      }       await api.orchestration.dispatchCommand({        type: "thread.delete",        commandId: newCommandId(),        threadId: thread.threadId,      });      clearComposerDraftForThread(thread.threadId);      clearProjectDraftThreadById(thread.projectId, thread.threadId);      clearTerminalState(thread.threadId);      void queryClient.invalidateQueries({ queryKey: orchestrationQueryKeys.archivedThreads() });       if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {        return;      }       try {        await removeWorktreeMutation.mutateAsync({          cwd: threadProject.cwd,          path: orphanedWorktreePath,          force: true,        });      } catch (error) {        const message = error instanceof Error ? error.message : "Unknown error removing worktree.";        console.error("Failed to remove orphaned worktree after archived thread deletion", {          threadId: thread.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}`,        });      }    },    [      appSettings.confirmThreadDelete,      clearComposerDraftForThread,      clearProjectDraftThreadById,      clearTerminalState,      queryClient,      removeWorktreeMutation,    ],  );   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,    deleteArchivedThread,    confirmAndDeleteThread,  };}