From 4a879307a3ca6387891f10023dbc8157f683f404 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 28 May 2026 19:04:11 -0700 Subject: [PATCH 1/5] fix task titel generation --- .../hooks/useChatTitleGenerator.test.ts | 114 ++++++++++++++++-- .../sessions/hooks/useChatTitleGenerator.ts | 81 +++++++++---- .../sessions/hooks/useSessionConnection.ts | 2 +- .../src/renderer/hooks/useTaskContextMenu.ts | 2 +- 4 files changed, 167 insertions(+), 32 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts index a68a1d52f..af5f41113 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts @@ -1,3 +1,4 @@ +import type { Task } from "@shared/types"; import { renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -7,6 +8,7 @@ const mockEnrichDescription = vi.hoisted(() => const mockGenerateTitle = vi.hoisted(() => vi.fn()); const mockGetAuthenticatedClient = vi.hoisted(() => vi.fn()); const mockGetCachedTask = vi.hoisted(() => vi.fn()); +const mockIsAuthenticated = vi.hoisted(() => ({ value: true })); const mockUpdateTask = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const mockSetQueriesData = vi.hoisted(() => vi.fn()); const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn()); @@ -24,6 +26,20 @@ vi.mock("@features/auth/hooks/authClient", () => ({ getAuthenticatedClient: mockGetAuthenticatedClient, })); +vi.mock("@features/auth/hooks/authQueries", () => ({ + useAuthStateValue: ( + selector: (state: { + status: string; + cloudRegion: string | null; + }) => unknown, + ) => + selector( + mockIsAuthenticated.value + ? { status: "authenticated", cloudRegion: "us-east-1" } + : { status: "anonymous", cloudRegion: null }, + ), +})); + vi.mock("@utils/queryClient", () => ({ getCachedTask: mockGetCachedTask, queryClient: { setQueriesData: mockSetQueriesData }, @@ -69,24 +85,63 @@ import { useChatTitleGenerator } from "./useChatTitleGenerator"; const TASK_ID = "task-1"; +function createTask(overrides: Partial = {}): Task { + return { + id: TASK_ID, + task_number: 1, + slug: "task-1", + title: "Fix the login bug", + description: "Fix the login bug", + created_at: "2026-05-28T00:00:00.000Z", + updated_at: "2026-05-28T00:00:00.000Z", + origin_product: "user_created", + ...overrides, + }; +} + describe("useChatTitleGenerator", () => { beforeEach(() => { vi.clearAllMocks(); + mockIsAuthenticated.value = true; mockPrompts.value = []; mockEnrichDescription.mockImplementation((desc: string) => Promise.resolve(desc), ); + mockGetCachedTask.mockReturnValue(undefined); mockGetAuthenticatedClient.mockResolvedValue({ updateTask: mockUpdateTask, }); - mockGetCachedTask.mockReturnValue(undefined); }); - it("does not generate when promptCount is 0", () => { - renderHook(() => useChatTitleGenerator(TASK_ID)); + it("does not generate when promptCount is 0 and the task already has a custom title", () => { + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "Custom task title", + }), + ), + ); expect(mockGenerateTitle).not.toHaveBeenCalled(); }); + it("generates title from the saved task description before prompt events arrive", async () => { + mockGenerateTitle.mockResolvedValue({ + title: "Fix login bug", + summary: "User is fixing a login issue", + }); + + renderHook(() => useChatTitleGenerator(createTask())); + + await waitFor(() => { + expect(mockEnrichDescription).toHaveBeenCalledWith("Fix the login bug"); + }); + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Fix login bug", + }); + }); + }); + it("generates title on first prompt", async () => { mockGenerateTitle.mockResolvedValue({ title: "Fix login bug", @@ -94,7 +149,13 @@ describe("useChatTitleGenerator", () => { }); mockPrompts.value = ["Fix the login bug"]; - renderHook(() => useChatTitleGenerator(TASK_ID)); + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "Raw prompt title", + }), + ), + ); await waitFor(() => { expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { @@ -131,7 +192,15 @@ describe("useChatTitleGenerator", () => { }); mockPrompts.value = ["fix auth"]; - renderHook(() => useChatTitleGenerator(TASK_ID)); + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "Custom auth title", + description: "fix auth", + title_manually_set: true, + }), + ), + ); await waitFor(() => { expect(mockGenerateTitle).toHaveBeenCalled(); @@ -159,7 +228,14 @@ describe("useChatTitleGenerator", () => { }); mockPrompts.value = ['']; - renderHook(() => useChatTitleGenerator(TASK_ID)); + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "Code file prompt", + description: "Code file prompt", + }), + ), + ); await waitFor(() => { expect(mockEnrichDescription).toHaveBeenCalledWith( @@ -176,7 +252,14 @@ describe("useChatTitleGenerator", () => { }); mockPrompts.value = ["fix auth"]; - renderHook(() => useChatTitleGenerator(TASK_ID)); + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "Auth prompt", + description: "fix auth", + }), + ), + ); await waitFor(() => { expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( @@ -190,11 +273,26 @@ describe("useChatTitleGenerator", () => { mockGenerateTitle.mockResolvedValue(null); mockPrompts.value = ["some prompt"]; - renderHook(() => useChatTitleGenerator(TASK_ID)); + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "Some prompt", + description: "some prompt", + }), + ), + ); await waitFor(() => { expect(mockGenerateTitle).toHaveBeenCalled(); }); expect(mockUpdateTask).not.toHaveBeenCalled(); }); + + it("waits for authentication before generating", () => { + mockIsAuthenticated.value = false; + + renderHook(() => useChatTitleGenerator(createTask())); + + expect(mockGenerateTitle).not.toHaveBeenCalled(); + }); }); diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index 22f748019..b330ae9ea 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -1,4 +1,6 @@ import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { xmlToPlainText } from "@features/message-editor/utils/content"; import { getSessionService } from "@features/sessions/service/service"; import { sessionStoreSetters, @@ -19,9 +21,20 @@ const log = logger.scope("chat-title-generator"); const REGENERATE_INTERVAL = 7; -export function useChatTitleGenerator(taskId: string): void { +function isProvisionalTaskTitle(task: Task): boolean { + const plainText = xmlToPlainText(task.description).trim(); + const fallbackTitle = (plainText || "Untitled").slice(0, 255); + return !task.title_manually_set && task.title === fallbackTitle; +} + +export function useChatTitleGenerator(task: Task): void { + const taskId = task.id; const lastGeneratedAtCount = useRef(null); + const initialDescriptionHandled = useRef(false); const isGenerating = useRef(false); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated" && !!state.cloudRegion, + ); const promptCount = useSessionStore((state) => { const taskRunId = state.taskIdIndex[taskId]; @@ -32,41 +45,47 @@ export function useChatTitleGenerator(taskId: string): void { }); useEffect(() => { - if (promptCount === 0) return; + if (!isAuthenticated) return; if (isGenerating.current) return; if (lastGeneratedAtCount.current === null) { lastGeneratedAtCount.current = 0; } - const shouldGenerate = + const shouldGenerateFromPrompts = (promptCount === 1 && lastGeneratedAtCount.current === 0) || (promptCount > 1 && promptCount - lastGeneratedAtCount.current >= REGENERATE_INTERVAL); - if (!shouldGenerate) return; + const shouldGenerateFromTaskDescription = + promptCount === 0 && + !initialDescriptionHandled.current && + task.description.trim().length > 0 && + isProvisionalTaskTitle(task); + + if (!shouldGenerateFromPrompts && !shouldGenerateFromTaskDescription) { + return; + } isGenerating.current = true; const state = useSessionStore.getState(); const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) { - isGenerating.current = false; - return; - } - const session = state.sessions[taskRunId]; - if (!session?.events) { - isGenerating.current = false; - return; - } + const session = taskRunId ? state.sessions[taskRunId] : undefined; + let rawContent = task.description; + + if (shouldGenerateFromPrompts) { + if (!session?.events) { + isGenerating.current = false; + return; + } - const allPrompts = extractUserPromptsFromEvents(session.events); - const promptsForTitle = - promptCount === 1 ? allPrompts : allPrompts.slice(-REGENERATE_INTERVAL); + const allPrompts = extractUserPromptsFromEvents(session.events); + const promptsForTitle = + promptCount === 1 ? allPrompts : allPrompts.slice(-REGENERATE_INTERVAL); - const rawContent = promptsForTitle - .map((p, i) => `${i + 1}. ${p}`) - .join("\n"); + rawContent = promptsForTitle.map((p, i) => `${i + 1}. ${p}`).join("\n"); + } const run = async () => { try { @@ -104,7 +123,7 @@ export function useChatTitleGenerator(taskId: string): void { } } - if (summary) { + if (summary && taskRunId) { sessionStoreSetters.updateSession(taskRunId, { conversationSummary: result.summary, }); @@ -118,11 +137,29 @@ export function useChatTitleGenerator(taskId: string): void { } catch (error) { log.error("Failed to update task title", { taskId, error }); } finally { - lastGeneratedAtCount.current = promptCount; + if (shouldGenerateFromPrompts) { + lastGeneratedAtCount.current = promptCount; + } + if (shouldGenerateFromTaskDescription) { + initialDescriptionHandled.current = true; + lastGeneratedAtCount.current = Math.max( + lastGeneratedAtCount.current ?? 0, + 1, + ); + } isGenerating.current = false; } }; run(); - }, [promptCount, taskId]); + }, [ + isAuthenticated, + promptCount, + task.id, + task.description, + task.title, + task.title_manually_set, + task, + taskId, + ]); } diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts index 7de8a5f2a..764c2af78 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts @@ -36,7 +36,7 @@ export function useSessionConnection({ const { isOnline } = useConnectivity(); const cloudAuthState = useAuthStateValue((state) => state); - useChatTitleGenerator(taskId); + useChatTitleGenerator(task); useEffect(() => { const taskRunId = session?.taskRunId; diff --git a/apps/code/src/renderer/hooks/useTaskContextMenu.ts b/apps/code/src/renderer/hooks/useTaskContextMenu.ts index c57a2725d..31c48107a 100644 --- a/apps/code/src/renderer/hooks/useTaskContextMenu.ts +++ b/apps/code/src/renderer/hooks/useTaskContextMenu.ts @@ -113,7 +113,7 @@ export function useTaskContextMenu() { log.error("Failed to show context menu", error); } }, - [deleteWithConfirm, archiveTask, suspendTask, restoreTask], + [archiveTask, deleteWithConfirm, restoreTask, suspendTask], ); return { From 39a3637dfab48084b5e2d1edea7812fb62a28828 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 28 May 2026 19:15:52 -0700 Subject: [PATCH 2/5] Fix task auto-title and rename handling --- .../hooks/useChatTitleGenerator.test.ts | 58 +++++++++- .../sessions/hooks/useChatTitleGenerator.ts | 30 ++++- .../sidebar/components/SidebarMenu.tsx | 44 ++------ .../sidebar/components/TaskListView.tsx | 14 ++- .../task-detail/components/TaskDetail.tsx | 28 +---- .../renderer/features/tasks/hooks/useTasks.ts | 82 ++++++++++++++ .../renderer/sagas/task/task-creation.test.ts | 104 +----------------- .../src/renderer/sagas/task/task-creation.ts | 3 - 8 files changed, 190 insertions(+), 173 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts index af5f41113..91a243f9d 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts @@ -142,6 +142,53 @@ describe("useChatTitleGenerator", () => { }); }); + it("generates title when the task has no title yet", async () => { + mockGenerateTitle.mockResolvedValue({ + title: "Fix login bug", + summary: "User is fixing a login issue", + }); + + renderHook(() => + useChatTitleGenerator( + createTask({ + title: "", + }), + ), + ); + + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Fix login bug", + }); + }); + }); + + it("does not treat fallback titles as a manual rename", async () => { + mockGenerateTitle.mockResolvedValue({ + title: "Fix login bug", + summary: "User is fixing a login issue", + }); + mockGetCachedTask.mockReturnValue( + createTask({ + title_manually_set: true, + }), + ); + + renderHook(() => + useChatTitleGenerator( + createTask({ + title_manually_set: true, + }), + ), + ); + + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Fix login bug", + }); + }); + }); + it("generates title on first prompt", async () => { mockGenerateTitle.mockResolvedValue({ title: "Fix login bug", @@ -182,10 +229,13 @@ describe("useChatTitleGenerator", () => { ])( "skips title update when title_manually_set ($name)", async ({ summary, expectsSummaryUpdate }) => { - mockGetCachedTask.mockReturnValue({ - id: TASK_ID, - title_manually_set: true, - }); + mockGetCachedTask.mockReturnValue( + createTask({ + title: "Custom auth title", + description: "fix auth", + title_manually_set: true, + }), + ); mockGenerateTitle.mockResolvedValue({ title: "Auto title", summary, diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index b330ae9ea..bc5fbe6af 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -21,10 +21,32 @@ const log = logger.scope("chat-title-generator"); const REGENERATE_INTERVAL = 7; +function getFallbackTaskTitle(description: string): string { + const plainText = xmlToPlainText(description).trim(); + return (plainText || "Untitled").slice(0, 255); +} + +function isPlaceholderTaskTitle( + task: Pick, +): boolean { + if (task.title.trim().length === 0) { + return true; + } + + const fallbackTitle = getFallbackTaskTitle(task.description); + return task.title === fallbackTitle; +} + +function isAutoTitleLocked(task: Task | undefined): boolean { + if (!task?.title_manually_set) { + return false; + } + + return !isPlaceholderTaskTitle(task); +} + function isProvisionalTaskTitle(task: Task): boolean { - const plainText = xmlToPlainText(task.description).trim(); - const fallbackTitle = (plainText || "Untitled").slice(0, 255); - return !task.title_manually_set && task.title === fallbackTitle; + return isPlaceholderTaskTitle(task); } export function useChatTitleGenerator(task: Task): void { @@ -93,7 +115,7 @@ export function useChatTitleGenerator(task: Task): void { const result = await generateTitleAndSummary(content); if (result) { const { title, summary } = result; - const titleLocked = !!getCachedTask(taskId)?.title_manually_set; + const titleLocked = isAutoTitleLocked(getCachedTask(taskId)); if (title && titleLocked) { log.debug("Skipping auto-title, user renamed task", { taskId }); diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 7ef074b32..24181bc92 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -6,17 +6,15 @@ import { INBOX_PIPELINE_STATUS_FILTER, INBOX_REFETCH_INTERVAL_MS, } from "@features/inbox/utils/inboxConstants"; -import { getSessionService } from "@features/sessions/service/service"; import { archiveTasksImperative, useArchiveTask, } from "@features/tasks/hooks/useArchiveTask"; -import { useTasks, useUpdateTask } from "@features/tasks/hooks/useTasks"; +import { useRenameTask, useTasks } from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; import { useTaskContextMenu } from "@hooks/useTaskContextMenu"; import { ScrollArea, Separator } from "@posthog/quill"; import { Box, Flex } from "@radix-ui/themes"; -import type { Schemas } from "@renderer/api/generated"; import { trpcClient } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; import { useCommandMenuStore } from "@stores/commandMenuStore"; @@ -62,6 +60,7 @@ function SidebarMenuComponent() { const { showContextMenu, editingTaskId, setEditingTaskId } = useTaskContextMenu(); const { archiveTask } = useArchiveTask(); + const { renameTask } = useRenameTask(); const { togglePin } = usePinnedTasks(); const sidebarData = useSidebarData({ @@ -300,8 +299,6 @@ function SidebarMenuComponent() { await archiveTask({ taskId }); }; - const updateTask = useUpdateTask(); - const handleArchivePrior = useCallback( async (taskId: string) => { const allVisible = [...sidebarData.pinnedTasks, ...sidebarData.flatTasks]; @@ -333,8 +330,6 @@ function SidebarMenuComponent() { }, [sidebarData.pinnedTasks, sidebarData.flatTasks, queryClient], ); - const log = logger.scope("sidebar-menu"); - const handleTaskDoubleClick = useCallback( (taskId: string) => { setEditingTaskId(taskId); @@ -343,43 +338,20 @@ function SidebarMenuComponent() { ); const handleTaskEditSubmit = useCallback( - async (taskId: string, newTitle: string) => { + async (taskId: string, currentTitle: string, newTitle: string) => { setEditingTaskId(null); - // Optimistically update task title in all cached task lists - queryClient.setQueriesData( - { queryKey: ["tasks", "list"] }, - (old) => - old?.map((task) => - task.id === taskId - ? { ...task, title: newTitle, title_manually_set: true } - : task, - ), - ); - queryClient.setQueriesData( - { queryKey: ["tasks", "summaries"] }, - (old) => - old?.map((task) => - task.id === taskId ? { ...task, title: newTitle } : task, - ), - ); - - // Sync to session store so notifications use the updated title - getSessionService().updateSessionTaskTitle(taskId, newTitle); - try { - await updateTask.mutateAsync({ + await renameTask({ taskId, - updates: { title: newTitle, title_manually_set: true }, + currentTitle, + newTitle, }); } catch (error) { - log.error("Failed to rename task", error); - // Refetch to revert optimistic update on failure - queryClient.invalidateQueries({ queryKey: ["tasks", "list"] }); - queryClient.invalidateQueries({ queryKey: ["tasks", "summaries"] }); + logger.scope("sidebar-menu").error("Failed to rename task", error); } }, - [setEditingTaskId, updateTask, queryClient, log], + [renameTask, setEditingTaskId], ); const handleTaskEditCancel = useCallback(() => { diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index 7fea8db2c..9a3b17f17 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -50,7 +50,11 @@ interface TaskListViewProps { ) => void; onTaskArchive: (taskId: string) => void; onTaskTogglePin: (taskId: string) => void; - onTaskEditSubmit: (taskId: string, newTitle: string) => void; + onTaskEditSubmit: ( + taskId: string, + currentTitle: string, + newTitle: string, + ) => void; onTaskEditCancel: () => void; hasMore: boolean; } @@ -346,7 +350,9 @@ export function TaskListView({ } onArchive={() => onTaskArchive(task.id)} onTogglePin={() => onTaskTogglePin(task.id)} - onEditSubmit={(newTitle) => onTaskEditSubmit(task.id, newTitle)} + onEditSubmit={(newTitle) => + onTaskEditSubmit(task.id, task.title, newTitle) + } onEditCancel={onTaskEditCancel} timestamp={task[timestampKey]} /> @@ -460,7 +466,7 @@ export function TaskListView({ onArchive={() => onTaskArchive(task.id)} onTogglePin={() => onTaskTogglePin(task.id)} onEditSubmit={(newTitle) => - onTaskEditSubmit(task.id, newTitle) + onTaskEditSubmit(task.id, task.title, newTitle) } onEditCancel={onTaskEditCancel} timestamp={task[timestampKey]} @@ -494,7 +500,7 @@ export function TaskListView({ onArchive={() => onTaskArchive(task.id)} onTogglePin={() => onTaskTogglePin(task.id)} onEditSubmit={(newTitle) => - onTaskEditSubmit(task.id, newTitle) + onTaskEditSubmit(task.id, task.title, newTitle) } onEditCancel={onTaskEditCancel} timestamp={task[timestampKey]} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx b/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx index 45f17ada1..9231408c4 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx @@ -10,10 +10,9 @@ import { parseTabId, } from "@features/panels/store/panelStoreHelpers"; import { MIN_CHAT_WIDTH } from "@features/sessions/constants"; -import { getSessionService } from "@features/sessions/service/service"; import { useCwd } from "@features/sidebar/hooks/useCwd"; import { useTaskData } from "@features/task-detail/hooks/useTaskData"; -import { useUpdateTask } from "@features/tasks/hooks/useTasks"; +import { useRenameTask } from "@features/tasks/hooks/useTasks"; import { useWorkspaceEvents } from "@features/workspace/hooks"; import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; import { useBlurOnEscape } from "@hooks/useBlurOnEscape"; @@ -21,7 +20,6 @@ import { useFileWatcher } from "@hooks/useFileWatcher"; import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import type { Task } from "@shared/types"; -import { useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys, useHotkeysContext } from "react-hotkeys-hook"; @@ -88,37 +86,23 @@ export function TaskDetail({ task: initialTask }: TaskDetailProps) { useWorkspaceEvents(taskId); const [isEditingTitle, setIsEditingTitle] = useState(false); - const updateTask = useUpdateTask(); - const queryClient = useQueryClient(); + const { renameTask } = useRenameTask(); const handleTitleEditSubmit = useCallback( async (newTitle: string) => { setIsEditingTitle(false); - queryClient.setQueriesData( - { queryKey: ["tasks", "list"] }, - (old) => - old?.map((t) => - t.id === taskId - ? { ...t, title: newTitle, title_manually_set: true } - : t, - ), - ); - - getSessionService().updateSessionTaskTitle(taskId, newTitle); - try { - await updateTask.mutateAsync({ + await renameTask({ taskId, - updates: { title: newTitle, title_manually_set: true }, + currentTitle: task.title, + newTitle, }); } catch (error) { log.error("Failed to rename task", error); - getSessionService().updateSessionTaskTitle(taskId, task.title); - queryClient.invalidateQueries({ queryKey: ["tasks", "list"] }); } }, - [taskId, task.title, updateTask, queryClient], + [renameTask, task.title, taskId], ); const handleTitleEditCancel = useCallback(() => { diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index 9643f8ccf..f715716e3 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -1,3 +1,4 @@ +import { getSessionService } from "@features/sessions/service/service"; import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; @@ -167,6 +168,87 @@ export function useUpdateTask() { ); } +export function useRenameTask() { + const queryClient = useQueryClient(); + const updateTask = useUpdateTask(); + + const renameTask = useCallback( + async ({ + taskId, + currentTitle, + newTitle, + }: { + taskId: string; + currentTitle: string; + newTitle: string; + }) => { + const previousListQueries = queryClient.getQueriesData({ + queryKey: taskKeys.lists(), + }); + const previousSummaryQueries = queryClient.getQueriesData< + Schemas.TaskSummary[] + >({ + queryKey: [...taskKeys.all, "summaries"], + }); + const previousDetail = queryClient.getQueryData( + taskKeys.detail(taskId), + ); + + queryClient.setQueriesData( + { queryKey: taskKeys.lists() }, + (old) => + old?.map((task) => + task.id === taskId + ? { ...task, title: newTitle, title_manually_set: true } + : task, + ), + ); + queryClient.setQueriesData( + { queryKey: [...taskKeys.all, "summaries"] }, + (old) => + old?.map((task) => + task.id === taskId ? { ...task, title: newTitle } : task, + ), + ); + + if (previousDetail) { + queryClient.setQueryData(taskKeys.detail(taskId), { + ...previousDetail, + title: newTitle, + title_manually_set: true, + }); + } + + getSessionService().updateSessionTaskTitle(taskId, newTitle); + + try { + await updateTask.mutateAsync({ + taskId, + updates: { title: newTitle, title_manually_set: true }, + }); + } catch (error) { + for (const [queryKey, data] of previousListQueries) { + queryClient.setQueryData(queryKey, data); + } + for (const [queryKey, data] of previousSummaryQueries) { + queryClient.setQueryData(queryKey, data); + } + if (previousDetail) { + queryClient.setQueryData(taskKeys.detail(taskId), previousDetail); + } + getSessionService().updateSessionTaskTitle(taskId, currentTitle); + throw error; + } + }, + [queryClient, updateTask], + ); + + return { + renameTask, + isPending: updateTask.isPending, + }; +} + interface DeleteTaskOptions { taskId: string; taskTitle: string; diff --git a/apps/code/src/renderer/sagas/task/task-creation.test.ts b/apps/code/src/renderer/sagas/task/task-creation.test.ts index 79961b7a1..d31e22c14 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -371,7 +371,7 @@ describe("TaskCreationSaga", () => { ); }); - it("sets title from plain text when description has text", async () => { + it("does not prefill a task title from the prompt", async () => { const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); const createTaskMock = vi.fn().mockResolvedValue(createdTask); @@ -399,13 +399,13 @@ describe("TaskCreationSaga", () => { expect(createTaskMock).toHaveBeenCalledWith( expect.objectContaining({ - title: "Ship the fix", description: "Ship the fix", }), ); + expect(createTaskMock.mock.calls[0]?.[0]).not.toHaveProperty("title"); }); - it("renders attachment-only description as @mention title", async () => { + it("does not prefill a task title for attachment-only prompts", async () => { const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); const createTaskMock = vi.fn().mockResolvedValue(createdTask); @@ -433,106 +433,10 @@ describe("TaskCreationSaga", () => { expect(createTaskMock).toHaveBeenCalledWith( expect.objectContaining({ - title: "@tmp/code.ts", description: '', }), ); - }); - - it("falls back to Untitled when description is empty", async () => { - const createdTask = createTask(); - const startedTask = createTask({ latest_run: createRun() }); - const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - }); - - await saga.run({ - content: " ", - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "main", - }); - - expect(createTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ title: "Untitled" }), - ); - }); - - it("renders folder mentions as readable @mention in title", async () => { - const createdTask = createTask(); - const startedTask = createTask({ latest_run: createRun() }); - const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - }); - - await saga.run({ - content: - 'look at and tell me what you see', - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "main", - }); - - expect(createTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - title: "look at @products/agentic_tests and tell me what you see", - }), - ); - }); - - it("truncates title to 255 chars", async () => { - const longText = "x".repeat(300); - const createdTask = createTask(); - const startedTask = createTask({ latest_run: createRun() }); - const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - }); - - await saga.run({ - content: longText, - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "main", - }); - - const calledTitle = createTaskMock.mock.calls[0][0].title; - expect(calledTitle).toHaveLength(255); + expect(createTaskMock.mock.calls[0]?.[0]).not.toHaveProperty("title"); }); it("uses user authorship for repo-less cloud tasks with a selected user GitHub integration", async () => { diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 582c0b94a..3b7ad84d1 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -1,5 +1,4 @@ import { buildPromptBlocks } from "@features/editor/utils/prompt-builder"; -import { xmlToPlainText } from "@features/message-editor/utils/content"; import { DEFAULT_PANEL_IDS } from "@features/panels/constants/panelConstants"; import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; import { useProvisioningStore } from "@features/provisioning/stores/provisioningStore"; @@ -401,9 +400,7 @@ export class TaskCreationSaga extends Saga< name: "task_creation", execute: async () => { const description = input.taskDescription ?? input.content ?? ""; - const plainText = xmlToPlainText(description).trim(); const result = await this.deps.posthogClient.createTask({ - title: (plainText || "Untitled").slice(0, 255), description, repository: repository ?? undefined, github_integration: From 02e420da5fd2acb88874d4e59ae991d0dc6e747b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 28 May 2026 19:31:27 -0700 Subject: [PATCH 3/5] clean up auto-title path and add rename tests --- .../hooks/useChatTitleGenerator.test.ts | 8 +- .../sessions/hooks/useChatTitleGenerator.ts | 33 +-- .../sidebar/components/SidebarMenu.tsx | 8 +- .../renderer/features/tasks/hooks/taskKeys.ts | 15 ++ .../features/tasks/hooks/useTasks.test.tsx | 205 ++++++++++++++++++ .../renderer/features/tasks/hooks/useTasks.ts | 24 +- 6 files changed, 244 insertions(+), 49 deletions(-) create mode 100644 apps/code/src/renderer/features/tasks/hooks/taskKeys.ts create mode 100644 apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts index 91a243f9d..4784d1e11 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts @@ -11,6 +11,7 @@ const mockGetCachedTask = vi.hoisted(() => vi.fn()); const mockIsAuthenticated = vi.hoisted(() => ({ value: true })); const mockUpdateTask = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const mockSetQueriesData = vi.hoisted(() => vi.fn()); +const mockSetQueryData = vi.hoisted(() => vi.fn()); const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn()); const mockPrompts = vi.hoisted(() => ({ value: [] as string[] })); const mockSessionStoreSetters = vi.hoisted(() => ({ @@ -42,7 +43,10 @@ vi.mock("@features/auth/hooks/authQueries", () => ({ vi.mock("@utils/queryClient", () => ({ getCachedTask: mockGetCachedTask, - queryClient: { setQueriesData: mockSetQueriesData }, + queryClient: { + setQueriesData: mockSetQueriesData, + setQueryData: mockSetQueryData, + }, })); vi.mock("@utils/session", () => ({ @@ -163,7 +167,7 @@ describe("useChatTitleGenerator", () => { }); }); - it("does not treat fallback titles as a manual rename", async () => { + it("regenerates title when title_manually_set is true but the title still matches the fallback", async () => { mockGenerateTitle.mockResolvedValue({ title: "Fix login bug", summary: "User is fixing a login issue", diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index bc5fbe6af..71c9b523f 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -6,6 +6,7 @@ import { sessionStoreSetters, useSessionStore, } from "@features/sessions/stores/sessionStore"; +import { taskKeys } from "@features/tasks/hooks/taskKeys"; import type { Schemas } from "@renderer/api/generated"; import type { Task } from "@shared/types"; import { @@ -45,13 +46,9 @@ function isAutoTitleLocked(task: Task | undefined): boolean { return !isPlaceholderTaskTitle(task); } -function isProvisionalTaskTitle(task: Task): boolean { - return isPlaceholderTaskTitle(task); -} - export function useChatTitleGenerator(task: Task): void { const taskId = task.id; - const lastGeneratedAtCount = useRef(null); + const lastGeneratedAtCount = useRef(0); const initialDescriptionHandled = useRef(false); const isGenerating = useRef(false); const isAuthenticated = useAuthStateValue( @@ -70,10 +67,6 @@ export function useChatTitleGenerator(task: Task): void { if (!isAuthenticated) return; if (isGenerating.current) return; - if (lastGeneratedAtCount.current === null) { - lastGeneratedAtCount.current = 0; - } - const shouldGenerateFromPrompts = (promptCount === 1 && lastGeneratedAtCount.current === 0) || (promptCount > 1 && @@ -83,7 +76,7 @@ export function useChatTitleGenerator(task: Task): void { promptCount === 0 && !initialDescriptionHandled.current && task.description.trim().length > 0 && - isProvisionalTaskTitle(task); + isPlaceholderTaskTitle(task); if (!shouldGenerateFromPrompts && !shouldGenerateFromTaskDescription) { return; @@ -124,19 +117,22 @@ export function useChatTitleGenerator(task: Task): void { if (client) { await client.updateTask(taskId, { title }); queryClient.setQueriesData( - { queryKey: ["tasks", "list"] }, + { queryKey: taskKeys.lists() }, (old) => old?.map((task) => task.id === taskId ? { ...task, title } : task, ), ); queryClient.setQueriesData( - { queryKey: ["tasks", "summaries"] }, + { queryKey: taskKeys.allSummaries() }, (old) => old?.map((task) => task.id === taskId ? { ...task, title } : task, ), ); + queryClient.setQueryData(taskKeys.detail(taskId), (old) => + old ? { ...old, title } : old, + ); getSessionService().updateSessionTaskTitle(taskId, title); log.debug("Updated task title from conversation", { taskId, @@ -165,7 +161,7 @@ export function useChatTitleGenerator(task: Task): void { if (shouldGenerateFromTaskDescription) { initialDescriptionHandled.current = true; lastGeneratedAtCount.current = Math.max( - lastGeneratedAtCount.current ?? 0, + lastGeneratedAtCount.current, 1, ); } @@ -174,14 +170,5 @@ export function useChatTitleGenerator(task: Task): void { }; run(); - }, [ - isAuthenticated, - promptCount, - task.id, - task.description, - task.title, - task.title_manually_set, - task, - taskId, - ]); + }, [isAuthenticated, promptCount, taskId, task]); } diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 24181bc92..89ff09e1c 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -37,6 +37,8 @@ import { SkillsItem } from "./items/SkillsItem"; import { SidebarItem } from "./SidebarItem"; import { TaskListView } from "./TaskListView"; +const log = logger.scope("sidebar-menu"); + function SidebarMenuComponent() { const { view, @@ -238,9 +240,7 @@ function SidebarMenuComponent() { } } } catch (error) { - logger - .scope("sidebar-menu") - .error("Failed to show bulk context menu", error); + log.error("Failed to show bulk context menu", error); } }, [queryClient, clearSelection], @@ -348,7 +348,7 @@ function SidebarMenuComponent() { newTitle, }); } catch (error) { - logger.scope("sidebar-menu").error("Failed to rename task", error); + log.error("Failed to rename task", error); } }, [renameTask, setEditingTaskId], diff --git a/apps/code/src/renderer/features/tasks/hooks/taskKeys.ts b/apps/code/src/renderer/features/tasks/hooks/taskKeys.ts new file mode 100644 index 000000000..894907235 --- /dev/null +++ b/apps/code/src/renderer/features/tasks/hooks/taskKeys.ts @@ -0,0 +1,15 @@ +export const taskKeys = { + all: ["tasks"] as const, + lists: () => [...taskKeys.all, "list"] as const, + list: (filters?: { + repository?: string; + createdBy?: number; + originProduct?: string; + internal?: boolean; + }) => [...taskKeys.lists(), filters] as const, + allSummaries: () => [...taskKeys.all, "summaries"] as const, + summaries: (ids: string[]) => + [...taskKeys.allSummaries(), [...ids].sort()] as const, + details: () => [...taskKeys.all, "detail"] as const, + detail: (id: string) => [...taskKeys.details(), id] as const, +}; diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx b/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx new file mode 100644 index 000000000..8d6ebff65 --- /dev/null +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx @@ -0,0 +1,205 @@ +import type { Schemas } from "@renderer/api/generated"; +import type { Task } from "@shared/types"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import { act, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdateTask = vi.hoisted(() => vi.fn()); +const mockClient = vi.hoisted(() => ({ updateTask: mockUpdateTask })); +const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn()); + +vi.mock("@features/auth/hooks/authClient", () => ({ + useOptionalAuthenticatedClient: () => mockClient, +})); + +vi.mock("@features/sessions/service/service", () => ({ + getSessionService: () => ({ + updateSessionTaskTitle: mockUpdateSessionTaskTitle, + }), +})); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: {}, +})); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +import { taskKeys } from "./taskKeys"; +import { useRenameTask } from "./useTasks"; + +const TASK_ID = "task-1"; +const OTHER_TASK_ID = "task-2"; + +function createTask(overrides: Partial = {}): Task { + return { + id: TASK_ID, + task_number: 1, + slug: "task-1", + title: "Original title", + description: "Original description", + created_at: "2026-05-28T00:00:00.000Z", + updated_at: "2026-05-28T00:00:00.000Z", + origin_product: "user_created", + ...overrides, + }; +} + +function createSummary(overrides: Partial = {}) { + return { + id: TASK_ID, + title: "Original title", + ...overrides, + } as Schemas.TaskSummary; +} + +function renderRenameHook() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const result = renderHook(() => useRenameTask(), { wrapper }); + return { ...result, queryClient }; +} + +describe("useRenameTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("applies the new title optimistically to list, summaries, and detail caches", async () => { + mockUpdateTask.mockResolvedValue(undefined); + const { result, queryClient } = renderRenameHook(); + + const listKey = taskKeys.list(); + const summaryKey = taskKeys.summaries([TASK_ID]); + const detailKey = taskKeys.detail(TASK_ID); + queryClient.setQueryData(listKey, [ + createTask(), + createTask({ id: OTHER_TASK_ID, title: "Other" }), + ]); + queryClient.setQueryData(summaryKey, [ + createSummary(), + createSummary({ id: OTHER_TASK_ID, title: "Other" }), + ]); + queryClient.setQueryData(detailKey, createTask()); + + await act(async () => { + await result.current.renameTask({ + taskId: TASK_ID, + currentTitle: "Original title", + newTitle: "Renamed", + }); + }); + + const list = queryClient.getQueryData(listKey); + expect(list?.find((t) => t.id === TASK_ID)).toMatchObject({ + title: "Renamed", + title_manually_set: true, + }); + expect(list?.find((t) => t.id === OTHER_TASK_ID)).toMatchObject({ + title: "Other", + }); + + const summaries = + queryClient.getQueryData(summaryKey); + expect(summaries?.find((t) => t.id === TASK_ID)?.title).toBe("Renamed"); + expect(summaries?.find((t) => t.id === OTHER_TASK_ID)?.title).toBe("Other"); + + const detail = queryClient.getQueryData(detailKey); + expect(detail).toMatchObject({ + title: "Renamed", + title_manually_set: true, + }); + + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Renamed", + title_manually_set: true, + }); + expect(mockUpdateSessionTaskTitle).toHaveBeenCalledWith(TASK_ID, "Renamed"); + }); + + it("rolls back all caches and notifies the session service with the original title on failure", async () => { + const failure = new Error("network down"); + mockUpdateTask.mockRejectedValue(failure); + const { result, queryClient } = renderRenameHook(); + + const listKey = taskKeys.list(); + const summaryKey = taskKeys.summaries([TASK_ID]); + const detailKey = taskKeys.detail(TASK_ID); + queryClient.setQueryData(listKey, [createTask()]); + queryClient.setQueryData(summaryKey, [ + createSummary(), + ]); + queryClient.setQueryData(detailKey, createTask()); + + let caught: unknown; + await act(async () => { + try { + await result.current.renameTask({ + taskId: TASK_ID, + currentTitle: "Original title", + newTitle: "Renamed", + }); + } catch (error) { + caught = error; + } + }); + expect(caught).toBe(failure); + + expect(queryClient.getQueryData(listKey)?.[0].title).toBe( + "Original title", + ); + expect( + queryClient.getQueryData(listKey)?.[0].title_manually_set, + ).toBeUndefined(); + expect( + queryClient.getQueryData(summaryKey)?.[0].title, + ).toBe("Original title"); + expect(queryClient.getQueryData(detailKey)?.title).toBe( + "Original title", + ); + + expect(mockUpdateSessionTaskTitle).toHaveBeenNthCalledWith( + 1, + TASK_ID, + "Renamed", + ); + expect(mockUpdateSessionTaskTitle).toHaveBeenNthCalledWith( + 2, + TASK_ID, + "Original title", + ); + }); + + it("does not write to the detail cache when no detail entry exists", async () => { + mockUpdateTask.mockResolvedValue(undefined); + const { result, queryClient } = renderRenameHook(); + + queryClient.setQueryData(taskKeys.list(), [createTask()]); + + await act(async () => { + await result.current.renameTask({ + taskId: TASK_ID, + currentTitle: "Original title", + newTitle: "Renamed", + }); + }); + + expect(queryClient.getQueryData(taskKeys.detail(TASK_ID))).toBeUndefined(); + expect(queryClient.getQueryData(taskKeys.list())?.[0].title).toBe( + "Renamed", + ); + }); +}); diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index f715716e3..64a57000e 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -1,5 +1,6 @@ import { getSessionService } from "@features/sessions/service/service"; import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; +import { taskKeys } from "@features/tasks/hooks/taskKeys"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; @@ -17,21 +18,6 @@ const log = logger.scope("tasks"); const TASK_LIST_POLL_INTERVAL_MS = 30_000; -const taskKeys = { - all: ["tasks"] as const, - lists: () => [...taskKeys.all, "list"] as const, - list: (filters?: { - repository?: string; - createdBy?: number; - originProduct?: string; - internal?: boolean; - }) => [...taskKeys.lists(), filters] as const, - summaries: (ids: string[]) => - [...taskKeys.all, "summaries", [...ids].sort()] as const, - details: () => [...taskKeys.all, "detail"] as const, - detail: (id: string) => [...taskKeys.details(), id] as const, -}; - export function useTasks( filters?: { repository?: string; @@ -160,9 +146,7 @@ export function useUpdateTask() { onSuccess: (_, { taskId }) => { queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); queryClient.invalidateQueries({ queryKey: taskKeys.detail(taskId) }); - queryClient.invalidateQueries({ - queryKey: [...taskKeys.all, "summaries"], - }); + queryClient.invalidateQueries({ queryKey: taskKeys.allSummaries() }); }, }, ); @@ -188,7 +172,7 @@ export function useRenameTask() { const previousSummaryQueries = queryClient.getQueriesData< Schemas.TaskSummary[] >({ - queryKey: [...taskKeys.all, "summaries"], + queryKey: taskKeys.allSummaries(), }); const previousDetail = queryClient.getQueryData( taskKeys.detail(taskId), @@ -204,7 +188,7 @@ export function useRenameTask() { ), ); queryClient.setQueriesData( - { queryKey: [...taskKeys.all, "summaries"] }, + { queryKey: taskKeys.allSummaries() }, (old) => old?.map((task) => task.id === taskId ? { ...task, title: newTitle } : task, From 37da07021cc11bd1564b2cd71968db19ba1ec0d3 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 28 May 2026 19:54:47 -0700 Subject: [PATCH 4/5] fixes --- .../sessions/hooks/useChatTitleGenerator.ts | 6 +- .../features/tasks/hooks/useTasks.test.tsx | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index 71c9b523f..6d797512f 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -108,7 +108,7 @@ export function useChatTitleGenerator(task: Task): void { const result = await generateTitleAndSummary(content); if (result) { const { title, summary } = result; - const titleLocked = isAutoTitleLocked(getCachedTask(taskId)); + const titleLocked = isAutoTitleLocked(getCachedTask(taskId) ?? task); if (title && titleLocked) { log.debug("Skipping auto-title, user renamed task", { taskId }); @@ -160,10 +160,6 @@ export function useChatTitleGenerator(task: Task): void { } if (shouldGenerateFromTaskDescription) { initialDescriptionHandled.current = true; - lastGeneratedAtCount.current = Math.max( - lastGeneratedAtCount.current, - 1, - ); } isGenerating.current = false; } diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx b/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx index 8d6ebff65..12181d777 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx @@ -183,6 +183,63 @@ describe("useRenameTask", () => { ); }); + it("skips rollback when a newer rename has advanced the title past ours", async () => { + const failure = new Error("network down"); + mockUpdateTask.mockRejectedValue(failure); + const { result, queryClient } = renderRenameHook(); + + const listKey = taskKeys.list(); + const summaryKey = taskKeys.summaries([TASK_ID]); + const detailKey = taskKeys.detail(TASK_ID); + queryClient.setQueryData(listKey, [createTask()]); + queryClient.setQueryData(summaryKey, [ + createSummary(), + ]); + queryClient.setQueryData(detailKey, createTask()); + + const renamePromise = result.current.renameTask({ + taskId: TASK_ID, + currentTitle: "Original title", + newTitle: "First rename", + }); + + queryClient.setQueryData(listKey, [ + createTask({ title: "Second rename", title_manually_set: true }), + ]); + queryClient.setQueryData(summaryKey, [ + createSummary({ title: "Second rename" }), + ]); + queryClient.setQueryData( + detailKey, + createTask({ title: "Second rename", title_manually_set: true }), + ); + + let caught: unknown; + await act(async () => { + try { + await renamePromise; + } catch (error) { + caught = error; + } + }); + expect(caught).toBe(failure); + + expect(queryClient.getQueryData(listKey)?.[0].title).toBe( + "Second rename", + ); + expect( + queryClient.getQueryData(summaryKey)?.[0].title, + ).toBe("Second rename"); + expect(queryClient.getQueryData(detailKey)?.title).toBe( + "Second rename", + ); + + expect(mockUpdateSessionTaskTitle).not.toHaveBeenCalledWith( + TASK_ID, + "Original title", + ); + }); + it("does not write to the detail cache when no detail entry exists", async () => { mockUpdateTask.mockResolvedValue(undefined); const { result, queryClient } = renderRenameHook(); From 209767ad23d3797c521d83923f1fdb17e23764bb Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 28 May 2026 20:15:34 -0700 Subject: [PATCH 5/5] fixes --- .../renderer/features/tasks/hooks/useTasks.ts | 59 +++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index 64a57000e..d598060df 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -18,6 +18,20 @@ const log = logger.scope("tasks"); const TASK_LIST_POLL_INTERVAL_MS = 30_000; +function getTaskTitle( + tasks: Task[] | undefined, + taskId: string, +): string | undefined { + return tasks?.find((task) => task.id === taskId)?.title; +} + +function getTaskSummaryTitle( + summaries: Schemas.TaskSummary[] | undefined, + taskId: string, +): string | undefined { + return summaries?.find((summary) => summary.id === taskId)?.title; +} + export function useTasks( filters?: { repository?: string; @@ -211,16 +225,53 @@ export function useRenameTask() { updates: { title: newTitle, title_manually_set: true }, }); } catch (error) { + const shouldRollbackSessionTitle = + queryClient.getQueryData(taskKeys.detail(taskId))?.title === + newTitle || + queryClient + .getQueriesData({ + queryKey: taskKeys.lists(), + }) + .some(([, tasks]) => getTaskTitle(tasks, taskId) === newTitle); + for (const [queryKey, data] of previousListQueries) { - queryClient.setQueryData(queryKey, data); + queryClient.setQueryData(queryKey, (current) => { + if (!current) { + return data; + } + + return getTaskTitle(current, taskId) === newTitle ? data : current; + }); } for (const [queryKey, data] of previousSummaryQueries) { - queryClient.setQueryData(queryKey, data); + queryClient.setQueryData( + queryKey, + (current) => { + if (!current) { + return data; + } + + return getTaskSummaryTitle(current, taskId) === newTitle + ? data + : current; + }, + ); } if (previousDetail) { - queryClient.setQueryData(taskKeys.detail(taskId), previousDetail); + queryClient.setQueryData( + taskKeys.detail(taskId), + (current) => { + if (!current) { + return previousDetail; + } + + return current.title === newTitle ? previousDetail : current; + }, + ); + } + if (shouldRollbackSessionTitle) { + getSessionService().updateSessionTaskTitle(taskId, currentTitle); } - getSessionService().updateSessionTaskTitle(taskId, currentTitle); throw error; } },