281 lines · 10.2 KB · TypeScript
RawDownload
import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";import { useQueryClient } from "@tanstack/react-query";import { useRouter } from "@tanstack/react-router";import { useCallback, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";import { useComposerDraftStore } from "../composerDraftStore";import { useNewThreadHandler } from "./useHandleNewThread";import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";import { invalidateGitQueries } from "../lib/gitReactQuery";import { newCommandId } from "../lib/utils";import { readLocalApi } from "../localApi";import {  selectProjectByRef,  selectThreadByRef,  selectThreadsForEnvironment,  useStore,} from "../store";import { useTerminalStateStore } from "../terminalStateStore";import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";import { stackedThreadToast, toastManager } from "../components/ui/toast";import { useSettings } from "./useSettings"; export function useThreadActions() {  const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder);  const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete);  const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);  const clearProjectDraftThreadById = useComposerDraftStore(    (store) => store.clearProjectDraftThreadById,  );  const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);  const router = useRouter();  const { handleNewThread } = useNewThreadHandler();  // Keep a ref so archiveThread can call handleNewThread without appearing in  // its dependency array — handleNewThread is inherently unstable (depends on  // the projects list) and would otherwise cascade new references into every  // sidebar row via archiveThread → attemptArchiveThread.  const handleNewThreadRef = useRef(handleNewThread);  handleNewThreadRef.current = handleNewThread;  const queryClient = useQueryClient();   const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {    const state = useStore.getState();    const thread = selectThreadByRef(state, target);    if (!thread) {      return null;    }    return {      thread,      threadRef: target,    };  }, []);  const getCurrentRouteThreadRef = useCallback(() => {    const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};    return resolveThreadRouteRef(currentRouteParams);  }, [router]);   const archiveThread = useCallback(    async (target: ScopedThreadRef) => {      const api = readEnvironmentApi(target.environmentId);      if (!api) return;      const resolved = resolveThreadTarget(target);      if (!resolved) return;      const { thread, threadRef } = resolved;      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: threadRef.threadId,      });      const currentRouteThreadRef = getCurrentRouteThreadRef();       if (        currentRouteThreadRef?.threadId === threadRef.threadId &&        currentRouteThreadRef.environmentId === threadRef.environmentId      ) {        await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId));      }    },    [getCurrentRouteThreadRef, resolveThreadTarget],  );   const unarchiveThread = useCallback(async (target: ScopedThreadRef) => {    const api = readEnvironmentApi(target.environmentId);    if (!api) return;    await api.orchestration.dispatchCommand({      type: "thread.unarchive",      commandId: newCommandId(),      threadId: target.threadId,    });  }, []);   const deleteThread = useCallback(    async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => {      const api = readEnvironmentApi(target.environmentId);      if (!api) return;      const resolved = resolveThreadTarget(target);      if (!resolved) return;      const { thread, threadRef } = resolved;      const state = useStore.getState();      const threads = selectThreadsForEnvironment(state, threadRef.environmentId);      const threadProject = selectProjectByRef(state, {        environmentId: threadRef.environmentId,        projectId: thread.projectId,      });      const deletedIds =        opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0          ? new Set<ThreadId>(              [...opts.deletedThreadKeys].flatMap((threadKey) => {                const ref = parseScopedThreadKey(threadKey);                return ref && ref.environmentId === threadRef.environmentId ? [ref.threadId] : [];              }),            )          : undefined;      const survivingThreads =        deletedIds && deletedIds.size > 0          ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id))          : threads;      const orphanedWorktreePath = getOrphanedWorktreePathForThread(        survivingThreads,        threadRef.threadId,      );      const displayWorktreePath = orphanedWorktreePath        ? formatWorktreePathForDisplay(orphanedWorktreePath)        : null;      const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined;      const localApi = readLocalApi();      const shouldDeleteWorktree =        canDeleteWorktree &&        localApi &&        (await localApi.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: threadRef.threadId,            createdAt: new Date().toISOString(),          })          .catch(() => undefined);      }       try {        await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true });      } catch {        // Terminal may already be closed.      }       const deletedThreadIds = deletedIds ?? new Set<ThreadId>();      const currentRouteThreadRef = getCurrentRouteThreadRef();      const shouldNavigateToFallback =        currentRouteThreadRef?.threadId === threadRef.threadId &&        currentRouteThreadRef.environmentId === threadRef.environmentId;      const fallbackThreadId = getFallbackThreadIdAfterDelete({        threads,        deletedThreadId: threadRef.threadId,        deletedThreadIds,        sortOrder: sidebarThreadSortOrder,      });      await api.orchestration.dispatchCommand({        type: "thread.delete",        commandId: newCommandId(),        threadId: threadRef.threadId,      });      clearComposerDraftForThread(threadRef);      clearProjectDraftThreadById(        scopeProjectRef(threadRef.environmentId, thread.projectId),        threadRef,      );      clearTerminalState(threadRef);       if (shouldNavigateToFallback) {        if (fallbackThreadId) {          const fallbackThread = selectThreadByRef(            useStore.getState(),            scopeThreadRef(threadRef.environmentId, fallbackThreadId),          );          if (fallbackThread) {            await router.navigate({              to: "/$environmentId/$threadId",              params: buildThreadRouteParams(                scopeThreadRef(fallbackThread.environmentId, fallbackThread.id),              ),              replace: true,            });          } else {            await router.navigate({ to: "/", replace: true });          }        } else {          await router.navigate({ to: "/", replace: true });        }      }       if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {        return;      }       try {        await ensureEnvironmentApi(threadRef.environmentId).vcs.removeWorktree({          cwd: threadProject.cwd,          path: orphanedWorktreePath,          force: true,        });        await invalidateGitQueries(queryClient, {          environmentId: threadRef.environmentId,        });      } catch (error) {        const message = error instanceof Error ? error.message : "Unknown error removing worktree.";        console.error("Failed to remove orphaned worktree after thread deletion", {          threadId: threadRef.threadId,          projectCwd: threadProject.cwd,          worktreePath: orphanedWorktreePath,          error,        });        toastManager.add(          stackedThreadToast({            type: "error",            title: "Thread deleted, but worktree removal failed",            description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`,          }),        );      }    },    [      clearComposerDraftForThread,      clearProjectDraftThreadById,      clearTerminalState,      getCurrentRouteThreadRef,      router,      queryClient,      resolveThreadTarget,      sidebarThreadSortOrder,    ],  );   const confirmAndDeleteThread = useCallback(    async (target: ScopedThreadRef) => {      const api = readEnvironmentApi(target.environmentId);      if (!api) return;      const localApi = readLocalApi();      const resolved = resolveThreadTarget(target);      if (!resolved) return;      const { thread } = resolved;       if (confirmThreadDelete && localApi) {        const confirmed = await localApi.dialogs.confirm(          [            `Delete thread "${thread.title}"?`,            "This permanently clears conversation history for this thread.",          ].join("\n"),        );        if (!confirmed) {          return;        }      }       await deleteThread(target);    },    [confirmThreadDelete, deleteThread, resolveThreadTarget],  );   return {    archiveThread,    unarchiveThread,    deleteThread,    confirmAndDeleteThread,  };}