diff --git a/.gitignore b/.gitignore index 16f234a37..8ed8c0e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,5 @@ plugins/posthog/local-skills/ # Symlinked copies of posthog, to make developing against those APIs easier posthog-sym +.pi-lens/ +Progress.md diff --git a/apps/code/src/renderer/features/sessions/hooks/shouldApplyAutoTitle.test.ts b/apps/code/src/renderer/features/sessions/hooks/shouldApplyAutoTitle.test.ts new file mode 100644 index 000000000..b2639c087 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/hooks/shouldApplyAutoTitle.test.ts @@ -0,0 +1,121 @@ +import type { Task } from "@shared/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock queryClient before importing the module under test +vi.mock("@utils/queryClient", () => ({ + queryClient: { + getQueryData: vi.fn(), + setQueriesData: vi.fn(), + }, +})); + +import { queryClient } from "@utils/queryClient"; +import { shouldApplyAutoTitle } from "./shouldApplyAutoTitle"; + +describe("shouldApplyAutoTitle", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true when title_manually_set is false", () => { + const cachedTasks: Task[] = [ + { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Auto title", + origin_product: "user_created", + description: "", + created_at: "2026-04-01T00:00:00Z", + updated_at: "2026-04-01T00:00:00Z", + title_manually_set: false, + }, + ]; + + vi.mocked(queryClient.getQueryData).mockReturnValue(cachedTasks); + + expect(shouldApplyAutoTitle("task-1")).toBe(true); + }); + + it("returns false when title_manually_set is true", () => { + const cachedTasks: Task[] = [ + { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "My custom title", + origin_product: "user_created", + description: "", + created_at: "2026-04-01T00:00:00Z", + updated_at: "2026-04-01T00:00:00Z", + title_manually_set: true, + }, + ]; + + vi.mocked(queryClient.getQueryData).mockReturnValue(cachedTasks); + + expect(shouldApplyAutoTitle("task-1")).toBe(false); + }); + + it("returns true when task is not found in cache", () => { + const cachedTasks: Task[] = [ + { + id: "task-2", + task_number: 2, + slug: "task-2", + title: "Other task", + origin_product: "user_created", + description: "", + created_at: "2026-04-01T00:00:00Z", + updated_at: "2026-04-01T00:00:00Z", + }, + ]; + + vi.mocked(queryClient.getQueryData).mockReturnValue(cachedTasks); + + expect(shouldApplyAutoTitle("task-1")).toBe(true); + }); + + it("returns true when cache is empty", () => { + vi.mocked(queryClient.getQueryData).mockReturnValue(undefined); + + expect(shouldApplyAutoTitle("task-1")).toBe(true); + }); + + it("detects race condition: user renames during async title generation", async () => { + const taskId = "task-1"; + const manualTitle = "My custom title"; + + // Simulate: at start of generation, title_manually_set is false + const initialTasks: Task[] = [ + { + id: taskId, + task_number: 1, + slug: "task-1", + title: "Initial title", + origin_product: "user_created", + description: "", + created_at: "2026-04-01T00:00:00Z", + updated_at: "2026-04-01T00:00:00Z", + title_manually_set: false, + }, + ]; + + // Simulate: after async generation, user has renamed (title_manually_set: true) + const renamedTasks: Task[] = [ + { + ...initialTasks[0], + title: manualTitle, + title_manually_set: true, + }, + ]; + + // First call (before async): allows generation + vi.mocked(queryClient.getQueryData).mockReturnValueOnce(initialTasks); + expect(shouldApplyAutoTitle(taskId)).toBe(true); + + // Second call (after async): should block - user renamed during generation + vi.mocked(queryClient.getQueryData).mockReturnValueOnce(renamedTasks); + expect(shouldApplyAutoTitle(taskId)).toBe(false); + }); +}); diff --git a/apps/code/src/renderer/features/sessions/hooks/shouldApplyAutoTitle.ts b/apps/code/src/renderer/features/sessions/hooks/shouldApplyAutoTitle.ts new file mode 100644 index 000000000..ce0ca7519 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/hooks/shouldApplyAutoTitle.ts @@ -0,0 +1,16 @@ +import type { Task } from "@shared/types"; +import { queryClient } from "@utils/queryClient"; + +/** + * Check whether the auto-generated title should be applied for a given task. + * This must be called AFTER any async work (e.g., LLM title generation) + * to avoid race conditions where the user manually renames during generation. + */ +export function shouldApplyAutoTitle(taskId: string): boolean { + const cachedTasks = queryClient.getQueryData(["tasks", "list"]); + const cachedTask = cachedTasks?.find((t) => t.id === taskId); + if (cachedTask?.title_manually_set) { + return false; + } + return true; +} diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index d66695126..dffa39339 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -1,4 +1,5 @@ import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { shouldApplyAutoTitle } from "@features/sessions/hooks/shouldApplyAutoTitle"; import { getSessionService } from "@features/sessions/service/service"; import { sessionStoreSetters, @@ -65,9 +66,8 @@ export function useChatTitleGenerator(taskId: string): void { const run = async () => { try { - const cachedTasks = queryClient.getQueryData(["tasks", "list"]); - const cachedTask = cachedTasks?.find((t) => t.id === taskId); - if (cachedTask?.title_manually_set) { + // Early exit if title was manually set before generation started + if (!shouldApplyAutoTitle(taskId)) { log.debug("Skipping auto-title, user renamed task", { taskId }); return; } @@ -76,6 +76,16 @@ export function useChatTitleGenerator(taskId: string): void { if (result) { const { title, summary } = result; if (title) { + // Re-check after async generation to prevent race condition: + // user may have renamed while the LLM was generating the title + if (!shouldApplyAutoTitle(taskId)) { + log.debug( + "Skipping auto-title, user renamed task during generation", + { taskId }, + ); + return; + } + const client = await getAuthenticatedClient(); if (client) { await client.updateTask(taskId, { title });