diff --git a/src/App.tsx b/src/App.tsx index b8fd92bb7..582430937 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import "./styles/compact-tablet.css"; import successSoundUrl from "./assets/success-notification.mp3"; import errorSoundUrl from "./assets/error-notification.mp3"; import { WorktreePrompt } from "./features/workspaces/components/WorktreePrompt"; +import { RenameThreadPrompt } from "./features/threads/components/RenameThreadPrompt"; import { AboutView } from "./features/about/components/AboutView"; import { SettingsView } from "./features/settings/components/SettingsView"; import { DesktopLayout } from "./features/layout/components/DesktopLayout"; @@ -72,6 +73,7 @@ import { useDictationModel } from "./features/dictation/hooks/useDictationModel" import { useDictation } from "./features/dictation/hooks/useDictation"; import { useHoldToDictate } from "./features/dictation/hooks/useHoldToDictate"; import { useQueuedSend } from "./features/threads/hooks/useQueuedSend"; +import { useRenameThreadPrompt } from "./features/threads/hooks/useRenameThreadPrompt"; import { useWorktreePrompt } from "./features/workspaces/hooks/useWorktreePrompt"; import { useUiScaleShortcuts } from "./features/layout/hooks/useUiScaleShortcuts"; import { useWorkspaceSelection } from "./features/workspaces/hooks/useWorkspaceSelection"; @@ -592,6 +594,7 @@ function MainApp() { lastAgentMessageByThread, interruptTurn, removeThread, + renameThread, startThreadForWorkspace, listThreadsForWorkspace, loadOlderThreadsForWorkspace, @@ -616,6 +619,24 @@ function MainApp() { onDebug: addDebugEntry, }); + const { + renamePrompt, + openRenamePrompt, + handleRenamePromptChange, + handleRenamePromptCancel, + handleRenamePromptConfirm, + } = useRenameThreadPrompt({ + threadsByWorkspace, + renameThread, + }); + + const handleRenameThread = useCallback( + (workspaceId: string, threadId: string) => { + openRenamePrompt(workspaceId, threadId); + }, + [openRenamePrompt], + ); + const { activeImages, attachImages, @@ -1166,6 +1187,9 @@ function MainApp() { }); removeImagesForThread(threadId); }, + onRenameThread: (workspaceId, threadId) => { + handleRenameThread(workspaceId, threadId); + }, onDeleteWorkspace: (workspaceId) => { void removeWorkspace(workspaceId); }, @@ -1463,6 +1487,15 @@ function MainApp() { onPlanPanelResizeStart={onPlanPanelResizeStart} /> )} + {renamePrompt && ( + + )} {worktreePrompt && ( void; onSelectThread: (workspaceId: string, threadId: string) => void; onDeleteThread: (workspaceId: string, threadId: string) => void; + onRenameThread: (workspaceId: string, threadId: string) => void; onDeleteWorkspace: (workspaceId: string) => void; onDeleteWorktree: (workspaceId: string) => void; onLoadOlderThreads: (workspaceId: string) => void; @@ -86,6 +87,7 @@ export function Sidebar({ onToggleWorkspaceCollapse, onSelectThread, onDeleteThread, + onRenameThread, onDeleteWorkspace, onDeleteWorktree, onLoadOlderThreads, @@ -114,6 +116,7 @@ export function Sidebar({ const { showThreadMenu, showWorkspaceMenu, showWorktreeMenu } = useSidebarMenus({ onDeleteThread, + onRenameThread, onReloadWorkspaceThreads, onDeleteWorkspace, onDeleteWorktree, diff --git a/src/features/app/hooks/useSidebarMenus.ts b/src/features/app/hooks/useSidebarMenus.ts index 3eff26c34..c59b94c81 100644 --- a/src/features/app/hooks/useSidebarMenus.ts +++ b/src/features/app/hooks/useSidebarMenus.ts @@ -5,6 +5,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; type SidebarMenuHandlers = { onDeleteThread: (workspaceId: string, threadId: string) => void; + onRenameThread: (workspaceId: string, threadId: string) => void; onReloadWorkspaceThreads: (workspaceId: string) => void; onDeleteWorkspace: (workspaceId: string) => void; onDeleteWorktree: (workspaceId: string) => void; @@ -12,6 +13,7 @@ type SidebarMenuHandlers = { export function useSidebarMenus({ onDeleteThread, + onRenameThread, onReloadWorkspaceThreads, onDeleteWorkspace, onDeleteWorktree, @@ -20,6 +22,10 @@ export function useSidebarMenus({ async (event: MouseEvent, workspaceId: string, threadId: string) => { event.preventDefault(); event.stopPropagation(); + const renameItem = await MenuItem.new({ + text: "Rename", + action: () => onRenameThread(workspaceId, threadId), + }); const archiveItem = await MenuItem.new({ text: "Archive", action: () => onDeleteThread(workspaceId, threadId), @@ -30,12 +36,12 @@ export function useSidebarMenus({ await navigator.clipboard.writeText(threadId); }, }); - const menu = await Menu.new({ items: [copyItem, archiveItem] }); + const menu = await Menu.new({ items: [renameItem, copyItem, archiveItem] }); const window = getCurrentWindow(); const position = new LogicalPosition(event.clientX, event.clientY); await menu.popup(position, window); }, - [onDeleteThread], + [onDeleteThread, onRenameThread], ); const showWorkspaceMenu = useCallback( diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 5fd71e37c..c5ef37857 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -96,6 +96,7 @@ type LayoutNodesOptions = { onToggleWorkspaceCollapse: (workspaceId: string, collapsed: boolean) => void; onSelectThread: (workspaceId: string, threadId: string) => void; onDeleteThread: (workspaceId: string, threadId: string) => void; + onRenameThread: (workspaceId: string, threadId: string) => void; onDeleteWorkspace: (workspaceId: string) => void; onDeleteWorktree: (workspaceId: string) => void; onLoadOlderThreads: (workspaceId: string) => void; @@ -329,6 +330,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { onToggleWorkspaceCollapse={options.onToggleWorkspaceCollapse} onSelectThread={options.onSelectThread} onDeleteThread={options.onDeleteThread} + onRenameThread={options.onRenameThread} onDeleteWorkspace={options.onDeleteWorkspace} onDeleteWorktree={options.onDeleteWorktree} onLoadOlderThreads={options.onLoadOlderThreads} diff --git a/src/features/threads/components/RenameThreadPrompt.tsx b/src/features/threads/components/RenameThreadPrompt.tsx new file mode 100644 index 000000000..503ca07f4 --- /dev/null +++ b/src/features/threads/components/RenameThreadPrompt.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from "react"; + +type RenameThreadPromptProps = { + currentName: string; + name: string; + onChange: (value: string) => void; + onCancel: () => void; + onConfirm: () => void; +}; + +export function RenameThreadPrompt({ + currentName, + name, + onChange, + onCancel, + onConfirm, +}: RenameThreadPromptProps) { + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + return ( +
+
+
+
Rename thread
+
+ Current name: "{currentName}" +
+ + onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + if (event.key === "Enter") { + event.preventDefault(); + onConfirm(); + } + }} + /> +
+ + +
+
+
+ ); +} diff --git a/src/features/threads/hooks/useRenameThreadPrompt.ts b/src/features/threads/hooks/useRenameThreadPrompt.ts new file mode 100644 index 000000000..23d9630e8 --- /dev/null +++ b/src/features/threads/hooks/useRenameThreadPrompt.ts @@ -0,0 +1,75 @@ +import { useCallback, useState } from "react"; +import type { ThreadSummary } from "../../../types"; + +type RenamePromptState = { + workspaceId: string; + threadId: string; + name: string; + originalName: string; +}; + +type UseRenameThreadPromptOptions = { + threadsByWorkspace: Record; + renameThread: (workspaceId: string, threadId: string, name: string) => void; +}; + +export function useRenameThreadPrompt({ + threadsByWorkspace, + renameThread, +}: UseRenameThreadPromptOptions) { + const [renamePrompt, setRenamePrompt] = useState( + null, + ); + + const openRenamePrompt = useCallback( + (workspaceId: string, threadId: string) => { + const threads = threadsByWorkspace[workspaceId] ?? []; + const thread = threads.find((entry) => entry.id === threadId); + const currentName = thread?.name || "Thread"; + setRenamePrompt({ + workspaceId, + threadId, + name: currentName, + originalName: currentName, + }); + }, + [threadsByWorkspace], + ); + + const handleRenamePromptChange = useCallback((value: string) => { + setRenamePrompt((prev) => + prev + ? { + ...prev, + name: value, + } + : prev, + ); + }, []); + + const handleRenamePromptCancel = useCallback(() => { + setRenamePrompt(null); + }, []); + + const handleRenamePromptConfirm = useCallback(() => { + setRenamePrompt((prev) => { + if (!prev) { + return prev; + } + const trimmed = prev.name.trim(); + if (!trimmed || trimmed === prev.originalName) { + return null; + } + renameThread(prev.workspaceId, prev.threadId, trimmed); + return null; + }); + }, [renameThread]); + + return { + renamePrompt, + openRenamePrompt, + handleRenamePromptChange, + handleRenamePromptCancel, + handleRenamePromptConfirm, + }; +} diff --git a/src/features/threads/hooks/useThreads.ts b/src/features/threads/hooks/useThreads.ts index db4779f70..e5c90e093 100644 --- a/src/features/threads/hooks/useThreads.ts +++ b/src/features/threads/hooks/useThreads.ts @@ -37,8 +37,10 @@ import { expandCustomPromptText } from "../../../utils/customPrompts"; import { initialState, threadReducer } from "./useThreadsReducer"; const STORAGE_KEY_THREAD_ACTIVITY = "codexmonitor.threadLastUserActivity"; +const STORAGE_KEY_CUSTOM_NAMES = "codexmonitor.threadCustomNames"; type ThreadActivityMap = Record>; +type CustomNamesMap = Record; function loadThreadActivity(): ThreadActivityMap { if (typeof window === "undefined") { @@ -73,6 +75,46 @@ function saveThreadActivity(activity: ThreadActivityMap) { } } +function makeCustomNameKey(workspaceId: string, threadId: string): string { + return `${workspaceId}:${threadId}`; +} + +function loadCustomNames(): CustomNamesMap { + if (typeof window === "undefined") { + return {}; + } + try { + const raw = window.localStorage.getItem(STORAGE_KEY_CUSTOM_NAMES); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw) as CustomNamesMap; + if (!parsed || typeof parsed !== "object") { + return {}; + } + return parsed; + } catch { + return {}; + } +} + +function saveCustomName(workspaceId: string, threadId: string, name: string): void { + if (typeof window === "undefined") { + return; + } + try { + const current = loadCustomNames(); + const key = makeCustomNameKey(workspaceId, threadId); + current[key] = name; + window.localStorage.setItem( + STORAGE_KEY_CUSTOM_NAMES, + JSON.stringify(current), + ); + } catch { + // Best-effort persistence. + } +} + type UseThreadsOptions = { activeWorkspace: WorkspaceInfo | null; onWorkspaceConnected: (id: string) => void; @@ -348,6 +390,26 @@ export function useThreads({ const [state, dispatch] = useReducer(threadReducer, initialState); const loadedThreads = useRef>({}); const threadActivityRef = useRef(loadThreadActivity()); + const customNamesRef = useRef({}); + + useEffect(() => { + if (typeof window === "undefined") { + return undefined; + } + customNamesRef.current = loadCustomNames(); + const handleStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY_CUSTOM_NAMES) { + customNamesRef.current = loadCustomNames(); + } + }; + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + + const getCustomName = useCallback((workspaceId: string, threadId: string) => { + const key = makeCustomNameKey(workspaceId, threadId); + return customNamesRef.current[key]; + }, []); const recordThreadActivity = useCallback( (workspaceId: string, threadId: string, timestamp = Date.now()) => { @@ -610,7 +672,15 @@ export function useThreads({ }) => { dispatch({ type: "ensureThread", workspaceId, threadId }); markProcessing(threadId, true); - dispatch({ type: "appendAgentDelta", workspaceId, threadId, itemId, delta }); + const hasCustomName = Boolean(getCustomName(workspaceId, threadId)); + dispatch({ + type: "appendAgentDelta", + workspaceId, + threadId, + itemId, + delta, + hasCustomName, + }); }, onAgentMessageCompleted: ({ workspaceId, @@ -625,12 +695,14 @@ export function useThreads({ }) => { const timestamp = Date.now(); dispatch({ type: "ensureThread", workspaceId, threadId }); + const hasCustomName = Boolean(getCustomName(workspaceId, threadId)); dispatch({ type: "completeAgentMessage", workspaceId, threadId, itemId, text, + hasCustomName, }); dispatch({ type: "setLastAgentMessage", @@ -769,6 +841,7 @@ export function useThreads({ }), [ activeThreadId, + getCustomName, handleWorkspaceConnected, handleItemUpdate, handleToolOutputDelta, @@ -881,7 +954,8 @@ export function useThreads({ isReviewing: isReviewingFromThread(thread), }); const preview = asString(thread?.preview ?? ""); - if (preview) { + const customName = getCustomName(workspaceId, threadId); + if (!customName && preview) { dispatch({ type: "setThreadName", workspaceId, @@ -920,7 +994,7 @@ export function useThreads({ return null; } }, - [applyCollabThreadLinksFromThread, onDebug, state.itemsByThread], + [applyCollabThreadLinksFromThread, getCustomName, onDebug, state.itemsByThread], ); const listThreadsForWorkspace = useCallback( @@ -1017,16 +1091,19 @@ export function useThreads({ const summaries = uniqueThreads .slice(0, targetCount) .map((thread, index) => { + const id = String(thread?.id ?? ""); const preview = asString(thread?.preview ?? "").trim(); + const customName = getCustomName(workspace.id, id); const fallbackName = `Agent ${index + 1}`; - const name = - preview.length > 0 + const name = customName + ? customName + : preview.length > 0 ? preview.length > 38 ? `${preview.slice(0, 38)}…` : preview : fallbackName; return { - id: String(thread?.id ?? ""), + id, name, updatedAt: getThreadTimestamp(thread), }; @@ -1071,7 +1148,7 @@ export function useThreads({ }); } }, - [onDebug], + [getCustomName, onDebug], ); const loadOlderThreadsForWorkspace = useCallback( @@ -1134,9 +1211,11 @@ export function useThreads({ return; } const preview = asString(thread?.preview ?? "").trim(); + const customName = getCustomName(workspace.id, id); const fallbackName = `Agent ${existing.length + additions.length + 1}`; - const name = - preview.length > 0 + const name = customName + ? customName + : preview.length > 0 ? preview.length > 38 ? `${preview.slice(0, 38)}…` : preview @@ -1186,7 +1265,12 @@ export function useThreads({ }); } }, - [onDebug, state.threadListCursorByWorkspace, state.threadsByWorkspace], + [ + getCustomName, + onDebug, + state.threadListCursorByWorkspace, + state.threadsByWorkspace, + ], ); const ensureThreadForActiveWorkspace = useCallback(async () => { @@ -1228,12 +1312,14 @@ export function useThreads({ finalText = promptExpansion?.expanded ?? messageText; } recordThreadActivity(workspace.id, threadId); + const hasCustomName = Boolean(getCustomName(workspace.id, threadId)); dispatch({ type: "addUserMessage", workspaceId: workspace.id, threadId, text: finalText, images, + hasCustomName, }); markProcessing(threadId, true); safeMessageActivity(); @@ -1310,6 +1396,7 @@ export function useThreads({ collaborationMode, customPrompts, effort, + getCustomName, markProcessing, model, onDebug, @@ -1559,6 +1646,16 @@ export function useThreads({ })(); }, [onDebug]); + const renameThread = useCallback( + (workspaceId: string, threadId: string, newName: string) => { + saveCustomName(workspaceId, threadId, newName); + const key = makeCustomNameKey(workspaceId, threadId); + customNamesRef.current[key] = newName; + dispatch({ type: "setThreadName", workspaceId, threadId, name: newName }); + }, + [dispatch], + ); + useEffect(() => { if (activeWorkspace?.connected) { void refreshAccountRateLimits(activeWorkspace.id); @@ -1584,6 +1681,7 @@ export function useThreads({ refreshAccountRateLimits, interruptTurn, removeThread, + renameThread, startThread, startThreadForWorkspace, listThreadsForWorkspace, diff --git a/src/features/threads/hooks/useThreadsReducer.ts b/src/features/threads/hooks/useThreadsReducer.ts index 9e9fbb274..4f9c9ab16 100644 --- a/src/features/threads/hooks/useThreadsReducer.ts +++ b/src/features/threads/hooks/useThreadsReducer.ts @@ -20,6 +20,10 @@ function formatThreadName(text: string) { : trimmed; } +function looksAutoGeneratedThreadName(name: string) { + return name.startsWith("Agent ") || /^[a-f0-9]{4,8}$/i.test(name); +} + function getAssistantTextForRename( items: ConversationItem[], itemId?: string, @@ -49,12 +53,14 @@ function maybeRenameThreadFromAgent({ threadId, items, itemId, + hasCustomName, threadsByWorkspace, }: { workspaceId: string; threadId: string; items: ConversationItem[]; itemId?: string; + hasCustomName: boolean; threadsByWorkspace: Record; }) { const threads = threadsByWorkspace[workspaceId] ?? []; @@ -67,13 +73,20 @@ function maybeRenameThreadFromAgent({ if (hasUserMessage) { return threadsByWorkspace; } + if (hasCustomName) { + return threadsByWorkspace; + } const nextName = formatThreadName(getAssistantTextForRename(items, itemId)); if (!nextName) { return threadsByWorkspace; } let didChange = false; const nextThreads = threads.map((thread) => { - if (thread.id !== threadId || thread.name === nextName) { + if ( + thread.id !== threadId || + thread.name === nextName || + !looksAutoGeneratedThreadName(thread.name) + ) { return thread; } didChange = true; @@ -128,6 +141,7 @@ export type ThreadAction = threadId: string; text: string; images?: string[]; + hasCustomName: boolean; } | { type: "addAssistantMessage"; threadId: string; text: string } | { type: "setThreadName"; workspaceId: string; threadId: string; name: string } @@ -137,6 +151,7 @@ export type ThreadAction = threadId: string; itemId: string; delta: string; + hasCustomName: boolean; } | { type: "completeAgentMessage"; @@ -144,6 +159,7 @@ export type ThreadAction = threadId: string; itemId: string; text: string; + hasCustomName: boolean; } | { type: "upsertItem"; threadId: string; item: ConversationItem } | { type: "setThreadItems"; threadId: string; items: ConversationItem[] } @@ -459,10 +475,12 @@ export function threadReducer(state: ThreadState, action: ThreadAction): ThreadS if (thread.id !== action.threadId) { return thread; } + const looksAutoGenerated = looksAutoGeneratedThreadName(thread.name); const shouldRename = !hasUserMessage && textValue.length > 0 && - thread.name.startsWith("Agent "); + looksAutoGenerated && + !action.hasCustomName; const nextName = shouldRename && textValue.length > 38 ? `${textValue.slice(0, 38)}…` @@ -541,6 +559,7 @@ export function threadReducer(state: ThreadState, action: ThreadAction): ThreadS threadId: action.threadId, items: updatedItems, itemId: action.itemId, + hasCustomName: action.hasCustomName, threadsByWorkspace: state.threadsByWorkspace, }); return { @@ -575,6 +594,7 @@ export function threadReducer(state: ThreadState, action: ThreadAction): ThreadS threadId: action.threadId, items: updatedItems, itemId: action.itemId, + hasCustomName: action.hasCustomName, threadsByWorkspace: state.threadsByWorkspace, }); return {