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 } = {}) => { 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( [...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(); 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, }; }