diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84f8b7ee509..27ef7fd1a5b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6035,6 +6035,8 @@ function ChatViewContent(props: ChatViewProps) { activeThreadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} + isServerThread={isServerThread} + changeRequestState={activeThreadPr?.state ?? null} activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} openInCwd={gitCwd} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 89419d428f6..590ca9cb583 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -108,6 +108,7 @@ import { import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; +import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, @@ -2539,62 +2540,21 @@ export default function SidebarV2() { const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); const clicked = await settlePromise(() => api.contextMenu.show( - [ - ...(thread.branch - ? [ - { - id: "new-thread-on-branch", - label: `New thread on ${thread.branch}`, - }, - ] - : []), - ...(supportsPinning - ? [ - isPinned - ? { id: "unpin", label: "Unpin thread" } - : { id: "pin", label: "Pin thread" }, - ] - : []), - // Both lifecycle actions stay available on pinned threads: - // settling clears the pin ("done" beats "keep on top"), and - // snoozing hides the card until wake with the pin intact. - ...(supportsSettlement - ? [ - isSettled - ? { id: "unsettle", label: "Un-settle thread" } - : { id: "settle", label: "Settle thread" }, - ] - : []), - ...(supportsSnooze - ? [ - isSnoozed - ? { id: "unsnooze", label: "Wake thread" } - : { - id: "snooze", - label: "Snooze", - disabled: !canSnooze(thread, { now: new Date().toISOString() }), - children: snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}`, - label: `${preset.label} (${preset.whenLabel})`, - })), - }, - ] - : []), - { id: "rename", label: "Rename thread" }, - ...(supportsTitleRegeneration - ? [ - { - id: "regenerate-title", - label: isRegeneratingTitle ? "Regenerating…" : "Regenerate title", - disabled: isRegeneratingTitle, - }, - ] - : []), - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-path", label: "Copy path", icon: "copy" }, - ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []), - { id: "delete", label: "Delete", destructive: true, icon: "trash" }, - ], + buildThreadActionMenuItems({ + branch: thread.branch ?? null, + isPinned, + isSettled, + isSnoozed, + canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), + isRegeneratingTitle, + supports: { + settlement: supportsSettlement, + snooze: supportsSnooze, + pinning: supportsPinning, + titleRegeneration: supportsTitleRegeneration, + }, + snoozePresets, + }), position, ), ); diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index d716092fc3e..94fe070ee3d 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { shouldShowOpenInPicker } from "./ChatHeader"; +import { resolveRenameCommit, shouldShowOpenInPicker } from "./ChatHeader"; describe("shouldShowOpenInPicker", () => { const primaryEnvironmentId = EnvironmentId.make("environment-primary"); @@ -46,3 +46,24 @@ describe("shouldShowOpenInPicker", () => { ).toBe(false); }); }); + +describe("resolveRenameCommit", () => { + it("commits a trimmed changed title", () => { + expect(resolveRenameCommit({ title: " New title ", originalTitle: "Old" })).toEqual({ + action: "commit", + title: "New title", + }); + }); + + it("rejects empty and whitespace-only titles", () => { + expect(resolveRenameCommit({ title: " ", originalTitle: "Old" })).toEqual({ + action: "reject-empty", + }); + }); + + it("no-ops when the trimmed title is unchanged", () => { + expect(resolveRenameCommit({ title: " Old ", originalTitle: "Old" })).toEqual({ + action: "noop", + }); + }); +}); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index b11e2136770..68e1743bccb 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -6,10 +6,25 @@ import { type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { memo } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled"; +import { ChevronDownIcon } from "lucide-react"; +import { + memo, + useCallback, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, @@ -17,6 +32,9 @@ import ProjectScriptsControl, { import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; +import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import { ProjectFavicon } from "../ProjectFavicon"; import { cn } from "~/lib/utils"; @@ -25,6 +43,10 @@ interface ChatHeaderProps { activeThreadId: ThreadId; draftId?: DraftId; activeThreadTitle: string; + /** Drafts have no server thread yet, so the title carries no action menu. */ + isServerThread: boolean; + /** PR state feeding the settled classification, resolved by ChatView. */ + changeRequestState: ChangeRequestStateLike | null; activeProjectName: string | undefined; activeProjectCwd: string | null; openInCwd: string | null; @@ -44,6 +66,20 @@ interface ChatHeaderProps { onDeleteProjectScript: (scriptId: string) => Promise; } +/** + * Rename commit rule shared with the sidebar's inline rename: trim, reject + * empty (the caller toasts), and skip the mutation when nothing changed. + */ +export function resolveRenameCommit(input: { + readonly title: string; + readonly originalTitle: string; +}): { action: "commit"; title: string } | { action: "reject-empty" } | { action: "noop" } { + const trimmed = input.title.trim(); + if (trimmed.length === 0) return { action: "reject-empty" }; + if (trimmed === input.originalTitle) return { action: "noop" }; + return { action: "commit", title: trimmed }; +} + export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; @@ -61,6 +97,8 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadId, draftId, activeThreadTitle, + isServerThread, + changeRequestState, activeProjectName, activeProjectCwd, openInCwd, @@ -86,8 +124,91 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, primaryEnvironmentId, }); + const activeThreadRef = useMemo( + () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), + [activeThreadEnvironmentId, activeThreadId], + ); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + // Inline rename, keyed by thread: navigating away drops an in-progress + // rename instead of committing stale text. Cleared on thread change (not + // just hidden) so returning to the thread doesn't revive the old draft. + const [renaming, setRenaming] = useState<{ threadId: ThreadId; title: string } | null>(null); + if (renaming !== null && renaming.threadId !== activeThreadId) { + setRenaming(null); + } + const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null; + const renameCommittedRef = useRef(false); + const startRename = useCallback(() => { + renameCommittedRef.current = false; + setRenaming({ threadId: activeThreadId, title: activeThreadTitle }); + }, [activeThreadId, activeThreadTitle]); + const commitRename = useCallback( + (title: string) => { + setRenaming(null); + const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); + if (resolution.action === "reject-empty") { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (resolution.action === "noop") return; + void updateThreadMetadata({ + environmentId: activeThreadEnvironmentId, + input: { threadId: activeThreadId, title: resolution.title }, + }).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }); + }, + [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], + ); + const { openMenu } = useThreadActionMenu({ + threadRef: isServerThread ? activeThreadRef : null, + projectCwd: activeProjectCwd, + changeRequestState, + onStartRename: startRename, + }); + const titleButtonRef = useRef(null); + const openMenuFromTitle = useCallback(() => { + const rect = titleButtonRef.current?.getBoundingClientRect(); + if (!rect) return; + openMenu({ x: rect.left, y: rect.bottom + 4 }); + }, [openMenu]); + const handleHeaderContextMenu = useCallback( + (event: ReactMouseEvent) => { + if (!isServerThread || renamingTitle !== null) return; + // The right-side controls (git, scripts, open-in) keep their own + // behavior; only the breadcrumb area opens the thread menu. + if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) return; + event.preventDefault(); + openMenu({ x: event.clientX, y: event.clientY }); + }, + [isServerThread, openMenu, renamingTitle], + ); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "Enter") { + renameCommittedRef.current = true; + commitRename(event.currentTarget.value); + } else if (event.key === "Escape") { + renameCommittedRef.current = true; + setRenaming(null); + } + }, + [commitRename], + ); return ( -
+
{/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone @@ -119,19 +240,58 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} - - + {renamingTitle !== null ? ( + { + if (renameCommittedRef.current) return; + commitRename(event.currentTarget.value); + }} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + /> + ) : isServerThread ? ( + + + } + > +

{activeThreadTitle}

- } - /> - {activeThreadTitle} -
+ +
+ {activeThreadTitle} +
+ ) : ( + + + {activeThreadTitle} + + } + /> + {activeThreadTitle} + + )}
item.id); +} + +describe("buildThreadActionMenuItems", () => { + it("hides lifecycle items when the environment lacks the capabilities", () => { + expect( + ids({ + ...baseState, + supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, + }), + ).toEqual(["rename", "mark-unread", "copy-path", "delete"]); + }); + + it("includes branch items only for threads with a branch", () => { + const withBranch = ids({ ...baseState, branch: "feat/menu" }); + expect(withBranch).toContain("new-thread-on-branch"); + expect(withBranch).toContain("copy-branch"); + expect(ids(baseState)).not.toContain("new-thread-on-branch"); + expect(ids(baseState)).not.toContain("copy-branch"); + }); + + it("flips lifecycle labels with thread state", () => { + expect(ids({ ...baseState, isPinned: true, isSettled: true, isSnoozed: true })).toEqual( + expect.arrayContaining(["unpin", "unsettle", "unsnooze"]), + ); + expect(ids(baseState)).toEqual(expect.arrayContaining(["pin", "settle", "snooze"])); + }); + + it("disables snooze when the thread cannot snooze, keeping presets visible", () => { + const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find( + (item) => item.id === "snooze", + ); + expect(snooze?.disabled).toBe(true); + expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour"]); + }); + + it("disables title regeneration while one is in flight", () => { + const item = buildThreadActionMenuItems({ ...baseState, isRegeneratingTitle: true }).find( + (candidate) => candidate.id === "regenerate-title", + ); + expect(item).toMatchObject({ label: "Regenerating…", disabled: true }); + }); + + it("marks delete as destructive and keeps it last", () => { + const items = buildThreadActionMenuItems({ ...baseState, branch: "main" }); + expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true }); + }); +}); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts new file mode 100644 index 00000000000..66aaf3debf5 --- /dev/null +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -0,0 +1,105 @@ +import type { ContextMenuItem } from "@t3tools/contracts"; +import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; + +/** + * Ids for the per-thread action menu. Snooze presets are dispatched as + * `snooze:` so the union stays closed while the preset list + * remains data-driven. + */ +export type ThreadActionMenuId = + | "new-thread-on-branch" + | "pin" + | "unpin" + | "settle" + | "unsettle" + | "snooze" + | `snooze:${string}` + | "unsnooze" + | "rename" + | "regenerate-title" + | "mark-unread" + | "copy-path" + | "copy-branch" + | "delete"; + +export interface ThreadActionMenuState { + readonly branch: string | null; + readonly isPinned: boolean; + readonly isSettled: boolean; + readonly isSnoozed: boolean; + readonly canSnoozeNow: boolean; + readonly isRegeneratingTitle: boolean; + readonly supports: { + readonly settlement: boolean; + readonly snooze: boolean; + readonly pinning: boolean; + readonly titleRegeneration: boolean; + }; + readonly snoozePresets: ReadonlyArray; +} + +/** + * Single source for the per-thread action menu: the sidebar row's right-click + * menu and the chat header menu both render exactly this list, so labels, + * ordering, and capability gating cannot drift between the two surfaces. + */ +export function buildThreadActionMenuItems( + state: ThreadActionMenuState, +): ReadonlyArray> { + return [ + ...(state.branch + ? [ + { + id: "new-thread-on-branch" as const, + label: `New thread on ${state.branch}`, + }, + ] + : []), + ...(state.supports.pinning + ? [ + state.isPinned + ? { id: "unpin" as const, label: "Unpin thread" } + : { id: "pin" as const, label: "Pin thread" }, + ] + : []), + // Both lifecycle actions stay available on pinned threads: settling + // clears the pin ("done" beats "keep on top"), and snoozing hides the + // card until wake with the pin intact. + ...(state.supports.settlement + ? [ + state.isSettled + ? { id: "unsettle" as const, label: "Un-settle thread" } + : { id: "settle" as const, label: "Settle thread" }, + ] + : []), + ...(state.supports.snooze + ? [ + state.isSnoozed + ? { id: "unsnooze" as const, label: "Wake thread" } + : { + id: "snooze" as const, + label: "Snooze", + disabled: !state.canSnoozeNow, + children: state.snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}` as const, + label: `${preset.label} (${preset.whenLabel})`, + })), + }, + ] + : []), + { id: "rename", label: "Rename thread" }, + ...(state.supports.titleRegeneration + ? [ + { + id: "regenerate-title" as const, + label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", + disabled: state.isRegeneratingTitle, + }, + ] + : []), + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy path", icon: "copy" }, + ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ]; +} diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts new file mode 100644 index 00000000000..85ffde776b4 --- /dev/null +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -0,0 +1,299 @@ +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 ChangeRequestStateLike, +} from "@t3tools/client-runtime/state/thread-settled"; +import type { ScopedThreadRef } 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 state feeding auto-settle classification, as resolved by the caller. */ + readonly changeRequestState: ChangeRequestStateLike | null; + readonly onStartRename: () => void; +}) { + const { threadRef, projectCwd, changeRequestState, onStartRename } = input; + const { + settleThread, + unsettleThread, + snoozeThread, + unsnoozeThread, + pinThread, + unpinThread, + 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 confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + 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 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, + changeRequestState, + }), + isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), + canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), + isRegeneratingTitle, + 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>, + ) => { + 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 "delete": { + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ), + ); + 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; + } + })(); + }, + [ + autoSettleAfterDays, + changeRequestState, + confirmThreadDelete, + copyBranchToClipboard, + copyPathToClipboard, + deleteThread, + handleNewThread, + markThreadUnread, + onStartRename, + pinThread, + projectCwd, + settleThread, + snoozeThread, + threadRef, + timestampFormat, + unpinThread, + unsettleThread, + unsnoozeThread, + updateThreadMetadata, + ], + ); + + return { openMenu }; +} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 3f82973045c..c0018b24935 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -250,6 +250,15 @@ export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): bo ); } +/** Whether the environment's server understands thread title regeneration. + Same version-skew contract as settlement. */ +export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadTitleRegeneration === true + ); +} + export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); }