diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 7951655a2daf5..840c983b4734b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -39,6 +39,7 @@ import { AuthSourceControlWriteScope, AuthFilesystemReadScope, type DesktopWslState, + type ContextMenuItem, type EnvironmentId, type FilesystemBrowseResult, SourceControlProviderKind, @@ -65,8 +66,25 @@ import { SquarePenIcon, TextSearchIcon, UserPlusIcon, + ArchiveIcon, + CircleCheckIcon, + ClockIcon, + CopyIcon, + FolderTreeIcon, + GitBranchIcon, + HashIcon, + MailOpenIcon, + MessageSquarePlusIcon, + PencilIcon, + PinIcon, + PinOffIcon, + RefreshCwIcon, + TimerIcon, + TrashIcon, + type LucideIcon, } from "lucide-react"; import { requestThreadFindOpen } from "./chat/threadFindActionBus"; +import { requestThreadRenameStart } from "./chat/threadRenameActionBus"; import { useDeferredValue, useEffect, @@ -84,6 +102,8 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useThreadActionMenu } from "../hooks/useThreadActionMenu"; +import type { ThreadActionMenuId } from "./threadActionMenu.logic"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { @@ -240,6 +260,97 @@ import { threadRecentUse } from "@t3tools/shared/paletteSearch"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +const THREAD_ACTION_ICONS: Record = { + archive: ArchiveIcon, + "circle-check": CircleCheckIcon, + clock: ClockIcon, + copy: CopyIcon, + folder: FolderIcon, + "folder-tree": FolderTreeIcon, + "git-branch": GitBranchIcon, + hash: HashIcon, + "mail-open": MailOpenIcon, + "message-square-plus": MessageSquarePlusIcon, + pencil: PencilIcon, + pin: PinIcon, + "pin-off": PinOffIcon, + "refresh-cw": RefreshCwIcon, + settings: SettingsIcon, + timer: TimerIcon, + trash: TrashIcon, +}; + +// The context menu opens on a thread, so its terse labels need the target named once they stand alone in the palette. +const THREAD_ACTION_PALETTE_TITLES: Partial> = { + snooze: "Snooze thread", + "mark-unread": "Mark thread unread", + copy: "Copy thread details", + delete: "Delete thread", +}; + +function buildThreadMenuPaletteItems( + items: ReadonlyArray>, + runAction: (action: ThreadActionMenuId) => Promise, +): Array { + return items.flatMap((item) => { + // The palette already lists project settings and guest sharing for the current thread. + if (item.id === "project-settings" || item.id === "share-guest") return []; + const title = THREAD_ACTION_PALETTE_TITLES[item.id] ?? item.label; + const Icon = item.icon ? THREAD_ACTION_ICONS[item.icon] : undefined; + const icon = Icon ? : null; + const searchTerms = [title, item.label, "thread"]; + if (!item.children) { + return [ + { + kind: "action", + value: `thread-action:${item.id}`, + searchTerms, + title, + icon, + ...(item.disabled ? { disabled: true } : {}), + run: () => runAction(item.id), + }, + ]; + } + return [ + { + kind: "submenu", + value: `thread-action:${item.id}`, + searchTerms, + title, + icon, + addonIcon: Icon ? : null, + ...(item.disabled ? { disabled: true } : {}), + groups: [ + { + value: `thread-action:${item.id}`, + label: title, + items: item.children.map((child) => { + const ChildIcon = (child.icon ? THREAD_ACTION_ICONS[child.icon] : undefined) ?? Icon; + return { + kind: "action", + value: `thread-action:${child.id}`, + searchTerms: [child.label], + title: child.label, + icon: ChildIcon ? : null, + ...(child.disabled ? { disabled: true } : {}), + ...(child.checked + ? { + titleTrailingContent: ( + Current + ), + } + : {}), + run: () => runAction(child.id), + }; + }), + }, + ], + }, + ]; + }); +} + export function CommandPalette({ children }: { children: ReactNode }) { const navigate = useNavigate(); const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); @@ -728,6 +839,15 @@ function OpenCommandPaletteDialog(props: { }; const { projectCwdById, projectByKey, projectTitleById } = useProjectLookups(); + const activeThreadActionMenu = useThreadActionMenu({ + threadRef: activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null, + projectCwd: activeThread + ? (projectByKey.get(`${activeThread.environmentId}:${activeThread.projectId}`) + ?.workspaceRoot ?? null) + : null, + onStartRename: requestThreadRenameStart, + canShareWithGuest: canShareActiveThreadWithGuest, + }); const activeThreadId = activeThread?.id; const supportsThreadFind = @@ -1415,6 +1535,12 @@ function OpenCommandPaletteDialog(props: { if (refreshed._tag === "Failure") throw squashAtomCommandFailure(refreshed); }, }); + const { items: threadMenuItems, snoozePresets } = activeThreadActionMenu.buildMenu(thread); + actionItems.push( + ...buildThreadMenuPaletteItems(threadMenuItems, (action) => + activeThreadActionMenu.runAction(action, snoozePresets), + ), + ); } actionItems.push({ diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 32028c06af66f..f63a175a567bb 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -19,10 +19,12 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import { isTrailingDoubleClick } from "../Sidebar.logic"; +import { subscribeThreadRenameStart } from "./threadRenameActionBus"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; import { readLocalApi } from "~/localApi"; +import { useRightPanelStore } from "~/rightPanelStore"; import { threadEnvironment } from "../../state/threads"; import { serverEnvironment } from "../../state/server"; import { canShareWithGuestAtom } from "./guestShareAvailability"; @@ -195,6 +197,15 @@ export const ChatHeader = memo(function ChatHeader({ onStartRename: startRename, canShareWithGuest, }); + useEffect( + () => + subscribeThreadRenameStart(() => { + // A maximized right panel collapses the chat column, which would hide the title input. + useRightPanelStore.getState().setMaximized(activeThreadRef, false); + startRename(); + }), + [activeThreadRef, startRename], + ); const titleButtonRef = useRef(null); const titleMenuTimerRef = useRef(null); const cancelPendingTitleMenu = () => { diff --git a/apps/web/src/components/chat/threadRenameActionBus.ts b/apps/web/src/components/chat/threadRenameActionBus.ts new file mode 100644 index 0000000000000..689df21c95271 --- /dev/null +++ b/apps/web/src/components/chat/threadRenameActionBus.ts @@ -0,0 +1,10 @@ +const THREAD_RENAME_START_EVENT = "t3:thread-rename-start"; + +export function requestThreadRenameStart(): void { + window.dispatchEvent(new Event(THREAD_RENAME_START_EVENT)); +} + +export function subscribeThreadRenameStart(listener: () => void): () => void { + window.addEventListener(THREAD_RENAME_START_EVENT, listener); + return () => window.removeEventListener(THREAD_RENAME_START_EVENT, listener); +} diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 0804fc007e1a6..5d41f9fa0219d 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -1,13 +1,18 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { requestCustomSnooze } from "../components/CustomSnoozeDialog"; import { openGuestShareDialog } from "../components/chat/guestShareDialogState"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { type AtomCommandResult, isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { + canSnooze, + effectiveSnoozed, + type SnoozePreset, +} from "@t3tools/client-runtime/state/thread-settled"; import { AuthOrchestrationOperateScope, type ScopedThreadRef, @@ -113,213 +118,230 @@ export function useThreadActionMenu(input: { onError: (error) => failureToast("Failed to copy thread ID", error), }); - const openMenu = (position: { x: number; y: number }) => { + const buildMenu = (thread: EnvironmentThreadShell) => { + const now = new Date(); + const supports = { + settlement: readEnvironmentSupportsSettlement(thread.environmentId), + autoSettleOptOut: readEnvironmentSupportsAutoSettleOptOut(thread.environmentId), + snooze: readEnvironmentSupportsSnooze(thread.environmentId), + pinning: readEnvironmentSupportsPinning(thread.environmentId), + titleRegeneration: readEnvironmentSupportsTitleRegeneration(thread.environmentId), + }; + const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const items = buildThreadActionMenuItems({ + canOperate: readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope), + branch: thread.branch ?? null, + projectFilter: null, + isPinned: thread.pinnedAt != null, + isSettled: supports.settlement && thread.settledOverride === "settled", + autoSettleEnabled: thread.autoSettleDisabledAt == null, + isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), + canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), + isRegeneratingTitle: thread.titleRegeneration != null, + isRunning: !threadRuntimeCanArchive(thread.runtime), + supports, + snoozePresets, + canShareWithGuest, + }); + return { items, snoozePresets }; + }; + + // Snooze choices resolve against the presets the menu showed, not a later recomputation. + const runAction = async ( + action: ThreadActionMenuId, + snoozePresets: ReadonlyArray, + ) => { if (threadRef === null) return; - void (async () => { - const api = readLocalApi(); - if (!api) return; - const thread = readThreadShell(threadRef); - if (!thread) return; - const now = new Date(); - const supports = { - settlement: readEnvironmentSupportsSettlement(threadRef.environmentId), - autoSettleOptOut: readEnvironmentSupportsAutoSettleOptOut(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({ - canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), - branch: thread.branch ?? null, - projectFilter: null, - isPinned: thread.pinnedAt != null, - isSettled: supports.settlement && thread.settledOverride === "settled", - autoSettleEnabled: thread.autoSettleDisabledAt == null, - isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), - canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), - isRegeneratingTitle, - isRunning: !threadRuntimeCanArchive(thread.runtime), - supports, - snoozePresets, - canShareWithGuest, - }); - const clicked = await settlePromise(() => api.contextMenu.show(items, position)); - if (clicked._tag === "Failure" || clicked.value === null) return; - const action: ThreadActionMenuId = clicked.value; - if ( - threadActionRequiresOperate(action) && - !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) - ) { - failureToast( - "Thread action unavailable", - new Error("This connection cannot change threads."), + const api = readLocalApi(); + if (!api) return; + const thread = readThreadShell(threadRef); + if (!thread) return; + const isRegeneratingTitle = thread.titleRegeneration != null; + if ( + threadActionRequiresOperate(action) && + !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) + ) { + failureToast( + "Thread action unavailable", + new Error("This connection cannot change threads."), + ); + return; + } + if (action.startsWith("snooze:")) { + const preset = + action === "snooze:custom" + ? await requestCustomSnooze() + : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); + if (!preset) return; + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); + } + 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 "project-settings": { + const project = projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.projectId, ); + if (!project) return; + const projectKey = + logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey }, + }); return; } - if (action.startsWith("snooze:")) { - const preset = - action === "snooze:custom" - ? await requestCustomSnooze() - : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); - if (!preset) return; - const result = await snoozeThread(threadRef, preset.snoozedUntil); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); + case "new-thread-on-branch": { + 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; } - const reportFailure = async ( - title: string, - run: () => Promise>, - ) => { - const result = await run(); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - failureToast(title, squashAtomCommandFailure(result)); - } - }; - switch (action) { - case "project-settings": { - const project = projects.find( - (candidate) => - candidate.environmentId === thread.environmentId && candidate.id === thread.projectId, - ); - if (!project) return; - const projectKey = - logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? - deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); - void router.navigate({ - to: "/projects/$projectKey", - params: { projectKey }, - }); - return; - } - case "new-thread-on-branch": { - const result = await settlePromise(() => - handleNewThread(scopeProjectRef(threadRef.environmentId, thread.projectId), { - branch: thread.branch, - worktreePath: thread.worktreePath, - envMode: thread.worktreePath ? "worktree" : "local", - startFromOrigin: false, + 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", () => confirmAndUnpinThread(threadRef)); + return; + } + case "auto-settle:enabled": + case "auto-settle:disabled": + await reportFailure("Failed to update auto-settle", () => + setThreadAutoSettle(threadRef, action === "auto-settle:enabled"), + ); + 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(threadRef); + return; + case "share-guest": + openGuestShareDialog(threadRef); + 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.", }), ); - 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", () => confirmAndUnpinThread(threadRef)); - return; + copyPathToClipboard(workspacePath, { path: workspacePath }); + return; + } + case "copy-branch": + if (thread.branch) { + copyBranchToClipboard(thread.branch, { branch: thread.branch }); } - case "auto-settle:enabled": - case "auto-settle:disabled": - await reportFailure("Failed to update auto-settle", () => - setThreadAutoSettle(threadRef, action === "auto-settle:enabled"), + 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}"?`), ); - 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 }, - }), + 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 "mark-unread": - markThreadUnread(threadRef); - return; - case "share-guest": - openGuestShareDialog(threadRef); - 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; + 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; } - 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 missing thread means cleanup failed after deletion; deleteThread already toasted that failure. - readThreadShell(threadRef) !== null - ) { - failureToast("Failed to delete thread", squashAtomCommandFailure(deleted)); - } - return; + const deleted = await deleteThread(threadRef); + if ( + deleted._tag === "Failure" && + !isAtomCommandInterrupted(deleted) && + // A missing thread means cleanup failed after deletion; deleteThread already toasted that failure. + readThreadShell(threadRef) !== null + ) { + failureToast("Failed to delete thread", squashAtomCommandFailure(deleted)); } - default: - return; + return; } + default: + return; + } + }; + + const openMenu = (position: { x: number; y: number }) => { + if (threadRef === null) return; + void (async () => { + const api = readLocalApi(); + if (!api) return; + const thread = readThreadShell(threadRef); + if (!thread) return; + const { items, snoozePresets } = buildMenu(thread); + const clicked = await settlePromise(() => api.contextMenu.show(items, position)); + if (clicked._tag === "Failure" || clicked.value === null) return; + await runAction(clicked.value, snoozePresets); })(); }; @@ -327,5 +349,5 @@ export function useThreadActionMenu(input: { void readLocalApi()?.contextMenu.close(); }; - return { openMenu, closeMenu }; + return { openMenu, closeMenu, buildMenu, runAction }; } diff --git a/scripts/effect-budget.json b/scripts/effect-budget.json index 44c55fb275201..ceac5ec906bd6 100644 --- a/scripts/effect-budget.json +++ b/scripts/effect-budget.json @@ -226,7 +226,7 @@ "apps/web/src/components/chat/ChangedFilesTree.tsx": 1, "apps/web/src/components/chat/ChatCanvas.tsx": 1, "apps/web/src/components/chat/ChatComposer.tsx": 26, - "apps/web/src/components/chat/ChatHeader.tsx": 2, + "apps/web/src/components/chat/ChatHeader.tsx": 3, "apps/web/src/components/chat/ChatMarkdownMedia.tsx": 1, "apps/web/src/components/chat/ChatOverlayHosts.tsx": 1, "apps/web/src/components/chat/CommandOutputPanel.tsx": 1,