786 lines · 27.2 KB · TypeScript
RawDownload
import {  parseScopedThreadKey,  scopeProjectRef,  scopeThreadRef,  scopedThreadKey,} from "@t3tools/client-runtime/environment";import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";import { canSnooze, threadWokeAt } 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, pinOrderKeyBetween } 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 { releaseComposerDraftUploads } from "../lib/composerDraftUploads";import { readLocalApi } from "../localApi";import {  readEnvironmentSupportsPinning,  readEnvironmentSupportsPinReorder,  readEnvironmentSupportsActiveReorder,  readEnvironmentSupportsSettlement,  readEnvironmentSupportsSnooze,  readEnvironmentThreadRefs,  readProject,  readThreadShell,  readThreadShells,} from "../state/entities";import { useTerminalUiStateStore } from "../terminalUiStateStore";import { useUiStateStore } from "../uiStateStore";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.TaggedError<ThreadArchiveBlockedError>()(  "ThreadArchiveBlockedError",  {    environmentId: EnvironmentId,    threadId: ThreadId,  },) {  override get message(): string {    return "Cannot archive a running thread.";  }} export class ThreadSettlementUnsupportedError extends Schema.TaggedError<ThreadSettlementUnsupportedError>()(  "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 ThreadSnoozeUnsupportedError extends Schema.TaggedError<ThreadSnoozeUnsupportedError>()(  "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.TaggedError<ThreadSnoozeBlockedError>()(  "ThreadSnoozeBlockedError",  {    environmentId: EnvironmentId,    threadId: ThreadId,  },) {  override get message(): string {    return "This thread is waiting on you. Respond to the pending request before snoozing it.";  }} /** Key that sorts before every arranged pinned thread, so a fresh pin lands    at the top of the run. Undefined (keyless, sorts with the legacy block)    when key math can't produce one — pinning must never fail on placement. */function topOfPinnedRunOrderKey(): string | undefined {  let firstKey: string | null = null;  for (const shell of readThreadShells()) {    if (shell.pinnedAt == null || shell.pinOrderKey == null) continue;    if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey;  }  return pinOrderKeyBetween(null, firstKey) ?? undefined;} export class ThreadPinningUnsupportedError extends Schema.TaggedError<ThreadPinningUnsupportedError>()(  "ThreadPinningUnsupportedError",  {    environmentId: EnvironmentId,    threadId: ThreadId,  },) {  override get message(): string {    return "This environment's server does not support pinning yet. Update the server to use Pin.";  }} export class ThreadPinReorderUnsupportedError extends Schema.TaggedError<ThreadPinReorderUnsupportedError>()(  "ThreadPinReorderUnsupportedError",  {    environmentId: EnvironmentId,    threadId: ThreadId,  },) {  override get message(): string {    return "This environment's server does not support reordering pinned threads yet. Update the server to reorder pins.";  }} export class ThreadActiveReorderUnsupportedError extends Schema.TaggedError<ThreadActiveReorderUnsupportedError>()(  "ThreadActiveReorderUnsupportedError",  {    environmentId: EnvironmentId,    threadId: ThreadId,  },) {  override get message(): string {    return "Update this environment's server to reorder active threads.";  }} export async function requestThreadUnpinConfirmation(input: {  enabled: boolean;  title: string;  confirm: ((message: string) => Promise<boolean>) | null;}) {  const { confirm } = input;  if (!input.enabled || confirm === null) {    return AsyncResult.success(true);  }   return settlePromise(() =>    confirm(      [        `Unpin thread "${input.title}"?`,        "This will move the thread out of your pinned section.",      ].join("\n"),    ),  );} /** Report navigation separately so a completed deletion can still finish worktree cleanup. */export async function navigateAfterThreadDeletion(navigate: () => Promise<void>) {  const result = await settlePromise(navigate);  if (result._tag === "Failure") {    const error = squashAtomCommandFailure(result);    toastManager.add(      stackedThreadToast({        type: "error",        title: "Thread deleted, but navigation failed",        description: error instanceof Error ? error.message : "An error occurred.",      }),    );  }} 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 pinThreadMutation = useAtomCommand(threadEnvironment.pin, {    reportFailure: false,  });  const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, {    reportFailure: false,  });  const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, {    reportFailure: false,  });  const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, {    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 confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin);  const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);  const clearProjectDraftThreadById = useComposerDraftStore(    (store) => store.clearProjectDraftThreadById,  );  const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState);  const markThreadVisited = useUiStateStore((state) => state.markThreadVisited);  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;      }      const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() });      if (wokeAt !== null) {        markThreadVisited(scopedThreadKey(threadRef), wokeAt);      }      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, markThreadVisited, 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<string> } = {}) => {      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<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 !== 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"),            { variant: "destructive" },          ),        );        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<ThreadId>();      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);      releaseComposerDraftUploads(threadRef);      clearComposerDraftForThread(threadRef);      clearProjectDraftThreadById(        scopeProjectRef(threadRef.environmentId, thread.projectId),        threadRef,      );      clearTerminalUiState(threadRef);       if (shouldNavigateToFallback) {        const fallbackThread = fallbackThreadId          ? readThreadShell(scopeThreadRef(threadRef.environmentId, fallbackThreadId))          : null;        await navigateAfterThreadDeletion(() =>          fallbackThread            ? router.navigate({                to: "/$environmentId/$threadId",                params: buildThreadRouteParams(                  scopeThreadRef(fallbackThread.environmentId, fallbackThread.id),                ),                replace: true,              })            : router.navigate({ to: "/", replace: true }),        );      }       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 removalFailed = removeResult._tag === "Failure";        const error = squashAtomCommandFailure(cleanupFailure);        const message = error instanceof Error ? error.message : "An error occurred.";        console.error("Worktree cleanup failed after thread deletion", {          threadId: threadRef.threadId,          projectCwd: threadProject.workspaceRoot,          worktreePath: orphanedWorktreePath,          error,        });        toastManager.add(          stackedThreadToast({            type: "error",            title: removalFailed              ? "Failed to delete worktree"              : "Worktree deleted, but Git status refresh failed",            description: removalFailed              ? `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`              : message,          }),        );        // The thread was deleted. Cleanup has its own toast; returning its        // failure would make callers incorrectly report a thread deletion error.      }      return deleteResult;    },    [      clearComposerDraftForThread,      clearProjectDraftThreadById,      clearTerminalUiState,      closeTerminal,      deleteThreadMutation,      getCurrentRouteThreadRef,      refreshVcsStatus,      removeWorktree,      router,      resolveThreadTarget,      sidebarThreadSortOrder,      stopThreadSession,    ],  );   const settleThread = useCallback(

Lines 1–500 of 786.