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, };}
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,
};
}