import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; import { readLocalApi } from "../localApi"; import { readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, readEnvironmentThreadRefs, readProject, readThreadShell, } from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( "ThreadArchiveBlockedError", { environmentId: EnvironmentId, threadId: ThreadId, }, ) { override get message(): string { return "Cannot archive a running thread."; } } export class ThreadSettlementUnsupportedError extends Schema.TaggedErrorClass()( "ThreadSettlementUnsupportedError", { environmentId: EnvironmentId, threadId: ThreadId, }, ) { override get message(): string { return "This environment's server does not support settling yet. Update the server to use Settle."; } } export class ThreadSettleBlockedError extends Schema.TaggedErrorClass()( "ThreadSettleBlockedError", { environmentId: EnvironmentId, threadId: ThreadId, }, ) { override get message(): string { return "This thread still needs attention. Resolve or interrupt it first, then try again."; } } export class ThreadSnoozeUnsupportedError extends Schema.TaggedErrorClass()( "ThreadSnoozeUnsupportedError", { environmentId: EnvironmentId, threadId: ThreadId, }, ) { override get message(): string { return "This environment's server does not support snoozing yet. Update the server to use Snooze."; } } export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass()( "ThreadSnoozeBlockedError", { environmentId: EnvironmentId, threadId: ThreadId, }, ) { override get message(): string { return "This thread is waiting on you. Respond to the pending request before snoozing it."; } } export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false, }); const unarchiveThreadMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false, }); const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false, }); const settleThreadMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false, }); const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false, }); const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false, }); const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false, }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, }); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, ); const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState); 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 resolveThreadTarget = useCallback((target: ScopedThreadRef) => { const thread = readThreadShell(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, opts: { onArchived?: () => void } = {}) => { const resolved = resolveThreadTarget(target); if (!resolved) return AsyncResult.success(undefined); const { thread, threadRef } = resolved; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { return AsyncResult.failure( Cause.fail( new ThreadArchiveBlockedError({ environmentId: threadRef.environmentId, threadId: threadRef.threadId, }), ), ); } const currentRouteThreadRef = getCurrentRouteThreadRef(); const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; const archiveResult = await archiveThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, }); if (archiveResult._tag === "Failure") { return archiveResult; } refreshArchivedThreadsForEnvironment(threadRef.environmentId); opts.onArchived?.(); if (shouldNavigateToDraft) { const navigationResult = await settlePromise(() => handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), ); if (navigationResult._tag === "Failure") { return navigationResult; } return archiveResult; } return archiveResult; }, [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], ); const unarchiveThread = useCallback( async (target: ScopedThreadRef) => { const result = await unarchiveThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); if (result._tag === "Success") { refreshArchivedThreadsForEnvironment(target.environmentId); } return result; }, [unarchiveThreadMutation], ); const deleteThread = useCallback( async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { const resolved = resolveThreadTarget(target); if (!resolved) { // Thread not in main store (e.g. archived thread) — dispatch delete directly. const result = await deleteThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); if (result._tag === "Success") { refreshArchivedThreadsForEnvironment(target.environmentId); } return result; } const { thread, threadRef } = resolved; const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { const shell = readThreadShell(ref); return shell === null ? [] : [shell]; }); const threadProject = readProject({ 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 !== null; const localApi = readLocalApi(); let shouldDeleteWorktree = false; if (canDeleteWorktree && localApi) { const confirmationResult = await settlePromise(() => localApi.dialogs.confirm( [ "This thread is the only one linked to this worktree:", displayWorktreePath ?? orphanedWorktreePath, "", "Delete the worktree too?", ].join("\n"), ), ); if (confirmationResult._tag === "Failure") { return confirmationResult; } shouldDeleteWorktree = confirmationResult.value; } if (thread.session && thread.session.status !== "stopped") { await stopThreadSession({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, }); } await closeTerminal({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId, deleteHistory: true }, }); 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, }); const deleteResult = await deleteThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, }); if (deleteResult._tag === "Failure") { return deleteResult; } refreshArchivedThreadsForEnvironment(threadRef.environmentId); clearComposerDraftForThread(threadRef); clearProjectDraftThreadById( scopeProjectRef(threadRef.environmentId, thread.projectId), threadRef, ); clearTerminalUiState(threadRef); if (shouldNavigateToFallback) { if (fallbackThreadId) { const fallbackThread = readThreadShell( scopeThreadRef(threadRef.environmentId, fallbackThreadId), ); if (fallbackThread) { const navigationResult = await settlePromise(() => router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams( scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), ), replace: true, }), ); if (navigationResult._tag === "Failure") { return navigationResult; } } else { const navigationResult = await settlePromise(() => router.navigate({ to: "/", replace: true }), ); if (navigationResult._tag === "Failure") { return navigationResult; } } } else { const navigationResult = await settlePromise(() => router.navigate({ to: "/", replace: true }), ); if (navigationResult._tag === "Failure") { return navigationResult; } } } if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { return deleteResult; } const removeResult = await removeWorktree({ environmentId: threadRef.environmentId, input: { cwd: threadProject.workspaceRoot, path: orphanedWorktreePath, force: true, }, }); const refreshResult = removeResult._tag === "Success" ? await refreshVcsStatus({ environmentId: threadRef.environmentId, input: { cwd: threadProject.workspaceRoot }, }) : null; const cleanupFailure = removeResult._tag === "Failure" ? removeResult : refreshResult?._tag === "Failure" ? refreshResult : null; if (cleanupFailure) { const error = squashAtomCommandFailure(cleanupFailure); 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.workspaceRoot, worktreePath: orphanedWorktreePath, error, }); toastManager.add( stackedThreadToast({ type: "error", title: "Thread deleted, but worktree removal failed", description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, }), ); return cleanupFailure; } return deleteResult; }, [ clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalUiState, closeTerminal, deleteThreadMutation, getCurrentRouteThreadRef, refreshVcsStatus, removeWorktree, router, resolveThreadTarget, sidebarThreadSortOrder, stopThreadSession, ], ); const settleThread = useCallback( async (target: ScopedThreadRef) => { // Version skew: never send the command to a server that predates it — // the raw protocol rejection would read as a random failure. if (!readEnvironmentSupportsSettlement(target.environmentId)) { return AsyncResult.failure( Cause.fail( new ThreadSettlementUnsupportedError({ environmentId: target.environmentId, threadId: target.threadId, }), ), ); } const resolved = resolveThreadTarget(target); // Settle may only target what effectiveSettled could classify as // settled: not starting/running sessions, not threads waiting on // approvals or user input. Anything else would hide live work. if (resolved && !canSettle(resolved.thread, { now: new Date().toISOString() })) { return AsyncResult.failure( Cause.fail( new ThreadSettleBlockedError({ environmentId: resolved.threadRef.environmentId, threadId: resolved.threadRef.threadId, }), ), ); } // Settle is a high-frequency lifecycle action and stays silent — no // toast. return settleThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); }, [resolveThreadTarget, settleThreadMutation], ); const unsettleThread = useCallback( async (target: ScopedThreadRef) => { if (!readEnvironmentSupportsSettlement(target.environmentId)) { return AsyncResult.failure( Cause.fail( new ThreadSettlementUnsupportedError({ environmentId: target.environmentId, threadId: target.threadId, }), ), ); } // reason "user" pins the thread active: auto-settle (PR merged / // inactivity) stays suppressed until real activity clears the pin. return unsettleThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId, reason: "user" }, }); }, [unsettleThreadMutation], ); const snoozeThread = useCallback( async (target: ScopedThreadRef, snoozedUntil: string) => { // Version skew: never send the command to a server that predates it. if (!readEnvironmentSupportsSnooze(target.environmentId)) { return AsyncResult.failure( Cause.fail( new ThreadSnoozeUnsupportedError({ environmentId: target.environmentId, threadId: target.threadId, }), ), ); } const resolved = resolveThreadTarget(target); // Blocked-on-you work and queued turns can't be snoozed away — // client-side twin of the server invariants so the UI rejects before // a round trip. if (resolved && !canSnooze(resolved.thread, { now: new Date().toISOString() })) { return AsyncResult.failure( Cause.fail( new ThreadSnoozeBlockedError({ environmentId: resolved.threadRef.environmentId, threadId: resolved.threadRef.threadId, }), ), ); } return snoozeThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId, snoozedUntil }, }); }, [resolveThreadTarget, snoozeThreadMutation], ); const unsnoozeThread = useCallback( async (target: ScopedThreadRef) => { if (!readEnvironmentSupportsSnooze(target.environmentId)) { return AsyncResult.failure( Cause.fail( new ThreadSnoozeUnsupportedError({ environmentId: target.environmentId, threadId: target.threadId, }), ), ); } return unsnoozeThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId, reason: "user" }, }); }, [unsnoozeThreadMutation], ); const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); if (confirmThreadDelete && localApi) { const title = resolved?.thread.title ?? "this thread"; const confirmationResult = await settlePromise(() => localApi.dialogs.confirm( [ `Delete thread "${title}"?`, "This permanently clears conversation history for this thread.", ].join("\n"), ), ); if (confirmationResult._tag === "Failure") { return confirmationResult; } if (!confirmationResult.value) { return AsyncResult.success(undefined); } } return deleteThread(target); }, [confirmThreadDelete, deleteThread, resolveThreadTarget], ); return useMemo( () => ({ archiveThread, unarchiveThread, deleteThread, confirmAndDeleteThread, settleThread, unsettleThread, snoozeThread, unsnoozeThread, }), [ archiveThread, confirmAndDeleteThread, deleteThread, settleThread, snoozeThread, unarchiveThread, unsettleThread, unsnoozeThread, ], ); }