343 lines · 13.3 KB · TypeScript
RawDownload
import { scopeProjectRef, scopedThreadKey } from "@t3tools/client-runtime/environment";import {  type AtomCommandResult,  isAtomCommandInterrupted,  settlePromise,  squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import {  canSnooze,  effectiveSettled,  effectiveSnoozed,  type ChangeRequestSettleSource,} from "@t3tools/client-runtime/state/thread-settled";import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";import { useCallback } from "react"; import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze";import {  buildThreadActionMenuItems,  type ThreadActionMenuId,} from "../components/threadActionMenu.logic";import { stackedThreadToast, toastManager } from "../components/ui/toast";import { threadEnvironment } from "../state/threads";import { useAtomCommand } from "../state/use-atom-command";import {  readEnvironmentSupportsPinning,  readEnvironmentSupportsSettlement,  readEnvironmentSupportsSnooze,  readEnvironmentSupportsTitleRegeneration,  readThreadShell,} from "../state/entities";import { readLocalApi } from "../localApi";import { useUiStateStore } from "../uiStateStore";import { useCopyToClipboard } from "./useCopyToClipboard";import { useNewThreadHandler } from "./useHandleNewThread";import { useClientSettings } from "./useSettings";import { useThreadActions } from "./useThreadActions"; function failureToast(title: string, error: unknown) {  toastManager.add(    stackedThreadToast({      type: "error",      title,      description: error instanceof Error ? error.message : "An error occurred.",    }),  );} /** * The per-thread action menu (pin, settle, snooze, rename, copy, delete…) as * a self-contained hook, for surfaces other than the sidebar row — today the * chat header. Renders through the native context-menu bridge and dispatches * through the same mutations the sidebar uses. * * Unlike the sidebar, settle and snooze here never navigate away: the caller * is acting on the thread they are reading, and ChatView's parked-thread * banner already offers the way back. */export function useThreadActionMenu(input: {  readonly threadRef: ScopedThreadRef | null;  /** Fallback for "Copy path" when the thread has no worktree. */  readonly projectCwd: string | null;  /** PR feeding auto-settle classification, as resolved by the caller. */  readonly changeRequest: ChangeRequestSettleSource | null;  readonly onStartRename: () => void;}) {  const { threadRef, projectCwd, changeRequest, onStartRename } = input;  const {    settleThread,    unsettleThread,    snoozeThread,    unsnoozeThread,    pinThread,    unpinThread,    archiveThread,    deleteThread,  } = useThreadActions();  const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {    reportFailure: false,  });  const handleNewThread = useNewThreadHandler();  const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);  const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);  const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);  const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);  const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);  const timestampFormat = useClientSettings((s) => s.timestampFormat);  const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({    onCopy: ({ path }) => {      toastManager.add({ type: "success", title: "Path copied", description: path });    },    onError: (error) => failureToast("Failed to copy path", error),  });  const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({    target: "branch name",    onCopy: ({ branch }) => {      toastManager.add({ type: "success", title: "Branch copied", description: branch });    },    onError: (error) => failureToast("Failed to copy branch", error),  });  const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({    onCopy: ({ threadId }) => {      toastManager.add({ type: "success", title: "Thread ID copied", description: threadId });    },    onError: (error) => failureToast("Failed to copy thread ID", error),  });   const openMenu = useCallback(    (position: { x: number; y: number }) => {      if (threadRef === null) return;      void (async () => {        const api = readLocalApi();        if (!api) return;        // Snapshot at open time — the menu is modal, so state read now is        // what the user is looking at.        const thread = readThreadShell(threadRef);        if (!thread) return;        const now = new Date();        const supports = {          settlement: readEnvironmentSupportsSettlement(threadRef.environmentId),          snooze: readEnvironmentSupportsSnooze(threadRef.environmentId),          pinning: readEnvironmentSupportsPinning(threadRef.environmentId),          titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId),        };        const isRegeneratingTitle = thread.titleRegeneration != null;        const snoozePresets = resolveSnoozePresets(now, timestampFormat);        const items = buildThreadActionMenuItems({          branch: thread.branch ?? null,          isPinned: thread.pinnedAt != null,          isSettled:            supports.settlement &&            effectiveSettled(thread, {              // Minute-quantized like useNowMinute, so this classification              // can never disagree with the sidebar partition or ChatView's              // parked-thread banner within the same minute.              now: `${now.toISOString().slice(0, 16)}:00.000Z`,              autoSettleAfterDays,              autoSettleOnMerge,              changeRequest,            }),          isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),          canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),          isRegeneratingTitle,          isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null,          supports,          snoozePresets,        });        const clicked = await settlePromise(() => api.contextMenu.show(items, position));        if (clicked._tag === "Failure" || clicked.value === null) return;        const action: ThreadActionMenuId = clicked.value;        if (action.startsWith("snooze:")) {          const preset = snoozePresets.find((candidate) => `snooze:${candidate.id}` === action);          if (!preset) return;          const result = await snoozeThread(threadRef, preset.snoozedUntil);          if (result._tag === "Failure") {            if (!isAtomCommandInterrupted(result)) {              failureToast("Failed to snooze thread", squashAtomCommandFailure(result));            }            return;          }          toastManager.add(            stackedThreadToast({              type: "success",              title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`,              timeout: 5_000,              actionProps: {                children: "Undo",                onClick: () => {                  void unsnoozeThread(threadRef).then((undone) => {                    if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) {                      failureToast("Failed to wake thread", squashAtomCommandFailure(undone));                    }                  });                },              },            }),          );          return;        }        const reportFailure = async (          title: string,          run: () => Promise<AtomCommandResult<unknown, unknown>>,        ) => {          const result = await run();          if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {            failureToast(title, squashAtomCommandFailure(result));          }        };        switch (action) {          case "new-thread-on-branch": {            // Explicit branch carry-over: reuse the thread's worktree when it            // has one, otherwise its branch on the local checkout.            const result = await settlePromise(() =>              handleNewThread(scopeProjectRef(threadRef.environmentId, thread.projectId), {                branch: thread.branch,                worktreePath: thread.worktreePath,                envMode: thread.worktreePath ? "worktree" : "local",                startFromOrigin: false,              }),            );            if (result._tag === "Failure") {              failureToast("Could not create thread", squashAtomCommandFailure(result));            }            return;          }          case "settle":            await reportFailure("Failed to settle thread", () => settleThread(threadRef));            return;          case "unsettle":            await reportFailure("Failed to un-settle thread", () => unsettleThread(threadRef));            return;          case "unsnooze":            await reportFailure("Failed to wake thread", () => unsnoozeThread(threadRef));            return;          case "pin":            await reportFailure("Failed to pin thread", () => pinThread(threadRef));            return;          case "unpin":            await reportFailure("Failed to unpin thread", () => unpinThread(threadRef));            return;          case "rename":            onStartRename();            return;          case "regenerate-title":            if (isRegeneratingTitle) return;            await reportFailure("Failed to regenerate thread title", () =>              updateThreadMetadata({                environmentId: threadRef.environmentId,                input: { threadId: threadRef.threadId, regenerateTitle: true },              }),            );            return;          case "mark-unread":            markThreadUnread(scopedThreadKey(threadRef), thread.latestTurn?.completedAt);            return;          case "copy-path": {            const workspacePath = thread.worktreePath ?? projectCwd;            if (!workspacePath) {              toastManager.add(                stackedThreadToast({                  type: "error",                  title: "Path unavailable",                  description: "This thread does not have a workspace path to copy.",                }),              );              return;            }            copyPathToClipboard(workspacePath, { path: workspacePath });            return;          }          case "copy-branch":            if (thread.branch) {              copyBranchToClipboard(thread.branch, { branch: thread.branch });            }            return;          case "copy-thread-id":            copyThreadIdToClipboard(thread.id, { threadId: thread.id });            return;          case "archive": {            if (confirmThreadArchive) {              const confirmed = await settlePromise(() =>                api.dialogs.confirm(`Archive thread "${thread.title}"?`),              );              if (confirmed._tag === "Failure" || !confirmed.value) return;            }            let didArchive = false;            const result = await archiveThread(threadRef, {              onArchived: () => {                didArchive = true;              },            });            if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {              failureToast(                didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread",                squashAtomCommandFailure(result),              );            }            return;          }          case "delete": {            if (confirmThreadDelete) {              const confirmed = await settlePromise(() =>                api.dialogs.confirm(                  [                    `Delete thread "${thread.title}"?`,                    "This permanently clears conversation history for this thread.",                  ].join("\n"),                  { variant: "destructive" },                ),              );              if (confirmed._tag === "Failure" || !confirmed.value) return;            }            const deleted = await deleteThread(threadRef);            if (              deleted._tag === "Failure" &&              !isAtomCommandInterrupted(deleted) &&              // A failure with the thread already gone is worktree cleanup              // failing after a successful delete — deleteThread has toasted              // that itself, and "Failed to delete thread" would be a lie.              readThreadShell(threadRef) !== null            ) {              failureToast("Failed to delete thread", squashAtomCommandFailure(deleted));            }            return;          }          default:            return;        }      })();    },    [      archiveThread,      autoSettleAfterDays,      autoSettleOnMerge,      changeRequest,      confirmThreadArchive,      confirmThreadDelete,      copyBranchToClipboard,      copyPathToClipboard,      copyThreadIdToClipboard,      deleteThread,      handleNewThread,      markThreadUnread,      onStartRename,      pinThread,      projectCwd,      settleThread,      snoozeThread,      threadRef,      timestampFormat,      unpinThread,      unsettleThread,      unsnoozeThread,      updateThreadMetadata,    ],  );   const closeMenu = useCallback(() => {    void readLocalApi()?.contextMenu.close();  }, []);   return { openMenu, closeMenu };}