diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index 731e79233..f25613416 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -545,6 +545,20 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { } }, [attachments, editor]); + // Notify parent when emptiness changes due to attachment add/remove. + // Only reacts to attachment changes; text changes are handled by onUpdate. + // We read editor text directly because isEmptyState may include stale + // attachment info (isContentEmpty counts attachments in its input). + useEffect(() => { + if (!editor) return; + const hasText = !!editor.getText().trim(); + const newIsEmpty = !hasText && attachments.length === 0; + if (newIsEmpty !== prevIsEmptyRef.current) { + prevIsEmptyRef.current = newIsEmpty; + callbackRefs.current.onEmptyChange?.(newIsEmpty); + } + }, [attachments, editor]); + // Restore attachments from draft on mount useEffect(() => { setAttachments(draft.restoredAttachments); diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts new file mode 100644 index 000000000..365911f3c --- /dev/null +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts @@ -0,0 +1,173 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockEnrichDescription = vi.hoisted(() => + vi.fn().mockImplementation((desc: string) => Promise.resolve(desc)), +); +const mockGenerateTitle = vi.hoisted(() => vi.fn()); +const mockGetAuthenticatedClient = vi.hoisted(() => vi.fn()); +const mockGetCachedTask = vi.hoisted(() => vi.fn()); +const mockUpdateTask = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockSetQueriesData = vi.hoisted(() => vi.fn()); +const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn()); +const mockPrompts = vi.hoisted(() => ({ value: [] as string[] })); +const mockSessionStoreSetters = vi.hoisted(() => ({ + updateSession: vi.fn(), +})); + +vi.mock("@utils/generateTitle", () => ({ + enrichDescriptionWithFileContent: mockEnrichDescription, + generateTitleAndSummary: mockGenerateTitle, +})); + +vi.mock("@features/auth/hooks/authClient", () => ({ + getAuthenticatedClient: mockGetAuthenticatedClient, +})); + +vi.mock("@utils/queryClient", () => ({ + getCachedTask: mockGetCachedTask, + queryClient: { setQueriesData: mockSetQueriesData }, +})); + +vi.mock("@utils/session", () => ({ + extractUserPromptsFromEvents: () => mockPrompts.value, +})); + +vi.mock("@features/sessions/service/service", () => ({ + getSessionService: () => ({ + updateSessionTaskTitle: mockUpdateSessionTaskTitle, + }), +})); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +vi.mock("@features/sessions/stores/sessionStore", () => { + const fn: any = (selector: any) => + selector({ + taskIdIndex: { "task-1": "run-1" }, + sessions: { "run-1": { events: mockPrompts.value } }, + }); + fn.getState = () => ({ + taskIdIndex: { "task-1": "run-1" }, + sessions: { "run-1": { events: mockPrompts.value } }, + }); + return { + useSessionStore: fn, + sessionStoreSetters: mockSessionStoreSetters, + }; +}); + +import { useChatTitleGenerator } from "./useChatTitleGenerator"; + +const TASK_ID = "task-1"; + +describe("useChatTitleGenerator", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPrompts.value = []; + mockEnrichDescription.mockImplementation((desc: string) => + Promise.resolve(desc), + ); + mockGetAuthenticatedClient.mockResolvedValue({ + updateTask: mockUpdateTask, + }); + mockGetCachedTask.mockReturnValue(undefined); + }); + + it("does not generate when promptCount is 0", () => { + renderHook(() => useChatTitleGenerator(TASK_ID)); + expect(mockGenerateTitle).not.toHaveBeenCalled(); + }); + + it("generates title on first prompt", async () => { + mockGenerateTitle.mockResolvedValue({ + title: "Fix login bug", + summary: "User is fixing a login issue", + }); + mockPrompts.value = ["Fix the login bug"]; + + renderHook(() => useChatTitleGenerator(TASK_ID)); + + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Fix login bug", + }); + }); + }); + + it("allows first generation even when title_manually_set", async () => { + mockGetCachedTask.mockReturnValue({ + id: TASK_ID, + title_manually_set: true, + }); + mockGenerateTitle.mockResolvedValue({ + title: "Auto title", + summary: "", + }); + mockPrompts.value = ["some prompt"]; + + renderHook(() => useChatTitleGenerator(TASK_ID)); + + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Auto title", + }); + }); + }); + + it("calls enrichDescriptionWithFileContent before generating", async () => { + mockEnrichDescription.mockResolvedValue("enriched content"); + mockGenerateTitle.mockResolvedValue({ + title: "Enriched title", + summary: "", + }); + mockPrompts.value = ['']; + + renderHook(() => useChatTitleGenerator(TASK_ID)); + + await waitFor(() => { + expect(mockEnrichDescription).toHaveBeenCalledWith( + '1. ', + ); + expect(mockGenerateTitle).toHaveBeenCalledWith("enriched content"); + }); + }); + + it("updates conversation summary when returned", async () => { + mockGenerateTitle.mockResolvedValue({ + title: "Some title", + summary: "User wants to fix auth", + }); + mockPrompts.value = ["fix auth"]; + + renderHook(() => useChatTitleGenerator(TASK_ID)); + + await waitFor(() => { + expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( + "run-1", + { conversationSummary: "User wants to fix auth" }, + ); + }); + }); + + it("does not update when generateTitleAndSummary returns null", async () => { + mockGenerateTitle.mockResolvedValue(null); + mockPrompts.value = ["some prompt"]; + + renderHook(() => useChatTitleGenerator(TASK_ID)); + + await waitFor(() => { + expect(mockGenerateTitle).toHaveBeenCalled(); + }); + expect(mockUpdateTask).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 ee3749426..3cab24164 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -5,7 +5,10 @@ import { useSessionStore, } from "@features/sessions/stores/sessionStore"; import type { Task } from "@shared/types"; -import { generateTitleAndSummary } from "@utils/generateTitle"; +import { + enrichDescriptionWithFileContent, + generateTitleAndSummary, +} from "@utils/generateTitle"; import { logger } from "@utils/logger"; import { getCachedTask, queryClient } from "@utils/queryClient"; import { extractUserPromptsFromEvents } from "@utils/session"; @@ -32,8 +35,7 @@ export function useChatTitleGenerator(taskId: string): void { if (isGenerating.current) return; if (lastGeneratedAtCount.current === null) { - lastGeneratedAtCount.current = promptCount; - return; + lastGeneratedAtCount.current = 0; } const shouldGenerate = @@ -61,15 +63,22 @@ export function useChatTitleGenerator(taskId: string): void { const promptsForTitle = promptCount === 1 ? allPrompts : allPrompts.slice(-REGENERATE_INTERVAL); - const content = promptsForTitle.map((p, i) => `${i + 1}. ${p}`).join("\n"); + const rawContent = promptsForTitle + .map((p, i) => `${i + 1}. ${p}`) + .join("\n"); const run = async () => { try { + const content = await enrichDescriptionWithFileContent(rawContent); const result = await generateTitleAndSummary(content); if (result) { const { title, summary } = result; if (title) { - if (getCachedTask(taskId)?.title_manually_set) { + const isFirstGeneration = lastGeneratedAtCount.current === 0; + if ( + !isFirstGeneration && + getCachedTask(taskId)?.title_manually_set + ) { log.debug("Skipping auto-title, user renamed task", { taskId }); return; } 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 ae214465e..a024e0616 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -4,10 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockWorkspaceCreate = vi.hoisted(() => vi.fn()); const mockWorkspaceDelete = vi.hoisted(() => vi.fn()); const mockGetTaskDirectory = vi.hoisted(() => vi.fn()); -const mockReadAbsoluteFile = vi.hoisted(() => vi.fn()); const mockReadFileAsBase64 = vi.hoisted(() => vi.fn()); -const mockGetCachedTask = vi.hoisted(() => vi.fn()); - vi.mock("@renderer/trpc", () => ({ trpcClient: { workspace: { @@ -20,7 +17,7 @@ vi.mock("@renderer/trpc", () => ({ vi.mock("@renderer/trpc/client", () => ({ trpcClient: { fs: { - readAbsoluteFile: { query: mockReadAbsoluteFile }, + readAbsoluteFile: { query: vi.fn() }, readFileAsBase64: { query: mockReadFileAsBase64 }, }, }, @@ -49,20 +46,13 @@ vi.mock("@features/panels/store/panelLayoutStore", () => ({ vi.mock("@features/sessions/service/service", () => ({ getSessionService: () => ({ - updateSessionTaskTitle: vi.fn(), + connectToTask: vi.fn(), + disconnectFromTask: vi.fn(), }), })); -const mockGenerateTitleAndSummary = vi.hoisted(() => vi.fn()); vi.mock("@renderer/utils/generateTitle", () => ({ - generateTitleAndSummary: mockGenerateTitleAndSummary, -})); - -vi.mock("@utils/queryClient", () => ({ - queryClient: { - setQueriesData: vi.fn(), - }, - getCachedTask: mockGetCachedTask, + createFileTagRegex: () => //g, })); vi.mock("@utils/logger", () => ({ @@ -114,7 +104,6 @@ describe("TaskCreationSaga", () => { mockWorkspaceCreate.mockResolvedValue(undefined); mockWorkspaceDelete.mockResolvedValue(undefined); mockGetTaskDirectory.mockResolvedValue(null); - mockReadAbsoluteFile.mockResolvedValue(null); mockReadFileAsBase64.mockResolvedValue(null); }); @@ -185,83 +174,6 @@ describe("TaskCreationSaga", () => { ); }); - it("skips auto-title when task has been manually renamed", 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 updateTaskMock = vi.fn(); - - mockGenerateTitleAndSummary.mockResolvedValue({ title: "Auto title" }); - mockGetCachedTask.mockReturnValue({ - id: "task-123", - title_manually_set: true, - }); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: updateTaskMock, - } as never, - }); - - await saga.run({ - content: "Ship the fix", - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "main", - }); - - await vi.waitFor(() => { - expect(mockGenerateTitleAndSummary).toHaveBeenCalled(); - }); - - expect(updateTaskMock).not.toHaveBeenCalled(); - }); - - it("applies auto-title when task has not been manually renamed", 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 updateTaskMock = vi.fn().mockResolvedValue(undefined); - - mockGenerateTitleAndSummary.mockResolvedValue({ title: "Auto title" }); - mockGetCachedTask.mockReturnValue(undefined); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: updateTaskMock, - } as never, - }); - - await saga.run({ - content: "Ship the fix", - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "main", - }); - - await vi.waitFor(() => { - expect(updateTaskMock).toHaveBeenCalledWith("task-123", { - title: "Auto title", - }); - }); - }); - it("uploads initial cloud attachments before starting the run", async () => { const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); @@ -414,6 +326,106 @@ describe("TaskCreationSaga", () => { ); }); + it("sets title from plain text when description has text", 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: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + }); + + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Ship the fix", + description: "Ship the fix", + }), + ); + }); + + it("sets fallback title when description is attachment-only", 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({ + taskDescription: '', + content: '', + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + }); + + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Reading attachment\u2026", + description: '', + }), + ); + }); + + 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); + }); + it("uses user authorship for repo-less cloud tasks with a selected user GitHub integration", async () => { const createdTask = createTask({ repository: null, diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 523066bf0..1e242a515 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -18,7 +18,7 @@ import type { import { Saga, type SagaLogger } from "@posthog/shared"; import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc"; -import { generateTitleAndSummary } from "@renderer/utils/generateTitle"; +import { createFileTagRegex } from "@renderer/utils/generateTitle"; import { getTaskRepository } from "@renderer/utils/repository"; import { type ExecutionMode, @@ -27,41 +27,9 @@ import { } from "@shared/types"; import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud"; import { logger } from "@utils/logger"; -import { getCachedTask, queryClient } from "@utils/queryClient"; const log = logger.scope("task-creation-saga"); -async function generateTaskTitle( - taskId: string, - description: string, - posthogClient: PostHogAPIClient, -): Promise { - if (!description.trim()) return; - - const result = await generateTitleAndSummary(description); - if (!result?.title) return; - const { title } = result; - - if (getCachedTask(taskId)?.title_manually_set) { - log.debug("Skipping auto-title, user renamed task", { taskId }); - return; - } - - try { - await posthogClient.updateTask(taskId, { title }); - - // Update all cached task lists so the sidebar reflects the new title instantly - queryClient.setQueriesData({ queryKey: ["tasks", "list"] }, (old) => - old?.map((task) => (task.id === taskId ? { ...task, title } : task)), - ); - - // Sync to session store so notifications use the updated title - getSessionService().updateSessionTaskTitle(taskId, title); - } catch (error) { - log.error("Failed to save task title", { taskId, error }); - } -} - // Adapt our logger to SagaLogger interface const sagaLogger: SagaLogger = { info: (message, data) => log.info(message, data), @@ -132,15 +100,6 @@ export class TaskCreationSaga extends Saga< ) : await this.createTask(input); - // Fire-and-forget: generate a proper LLM title for new tasks - if (!taskId) { - generateTaskTitle( - task.id, - input.taskDescription ?? input.content ?? "", - this.deps.posthogClient, - ); - } - const repoKey = getTaskRepository(task); const repoPath = input.repoPath ?? @@ -445,8 +404,11 @@ export class TaskCreationSaga extends Saga< return this.step({ name: "task_creation", execute: async () => { + const description = input.taskDescription ?? input.content ?? ""; + const plainText = description.replace(createFileTagRegex(), "").trim(); const result = await this.deps.posthogClient.createTask({ - description: input.taskDescription ?? input.content ?? "", + title: (plainText || "Reading attachment\u2026").slice(0, 255), + description, repository: repository ?? undefined, github_integration: input.workspaceMode === "cloud" && diff --git a/apps/code/src/renderer/utils/generateTitle.test.ts b/apps/code/src/renderer/utils/generateTitle.test.ts new file mode 100644 index 000000000..6ecd85605 --- /dev/null +++ b/apps/code/src/renderer/utils/generateTitle.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockReadAbsoluteFile = vi.hoisted(() => vi.fn()); +const mockLlmPrompt = vi.hoisted(() => vi.fn()); + +vi.mock("@renderer/trpc", () => ({ + trpcClient: { + fs: { + readAbsoluteFile: { query: mockReadAbsoluteFile }, + }, + llmGateway: { + prompt: { mutate: mockLlmPrompt }, + }, + }, +})); + +const mockFetchAuthState = vi.hoisted(() => vi.fn()); +vi.mock("@features/auth/hooks/authQueries", () => ({ + fetchAuthState: mockFetchAuthState, +})); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +import { + enrichDescriptionWithFileContent, + generateTitleAndSummary, +} from "./generateTitle"; + +describe("enrichDescriptionWithFileContent", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns description unchanged when it contains real text", async () => { + const description = "Fix the login bug"; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe(description); + expect(mockReadAbsoluteFile).not.toHaveBeenCalled(); + }); + + it("reads text file content when description only has file tags", async () => { + mockReadAbsoluteFile.mockResolvedValue("const x = 1;\nexport default x;"); + const description = '1. '; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe("const x = 1;\nexport default x;"); + expect(mockReadAbsoluteFile).toHaveBeenCalledWith({ + filePath: "/tmp/code.ts", + }); + }); + + it("handles multiple file tags", async () => { + mockReadAbsoluteFile + .mockResolvedValueOnce("file one") + .mockResolvedValueOnce("file two"); + + const description = + '1. \n2. '; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe("file one\n\nfile two"); + }); + + it("uses filePaths argument over parsed tags", async () => { + mockReadAbsoluteFile.mockResolvedValue("from explicit path"); + const description = '1. '; + const result = await enrichDescriptionWithFileContent(description, [ + "/tmp/explicit.ts", + ]); + expect(result).toBe("from explicit path"); + expect(mockReadAbsoluteFile).toHaveBeenCalledWith({ + filePath: "/tmp/explicit.ts", + }); + }); + + it.each([ + { + label: "binary file", + description: '1. ', + setup: () => {}, + }, + { + label: "read throws", + description: '1. ', + setup: () => mockReadAbsoluteFile.mockRejectedValue(new Error("ENOENT")), + }, + { + label: "read returns null", + description: '1. ', + setup: () => mockReadAbsoluteFile.mockResolvedValue(null), + }, + ])( + "falls back to filename hint -- $label", + async ({ description, setup }) => { + setup(); + const result = await enrichDescriptionWithFileContent(description); + const filename = description.match(/path="[^"]*\/([^"]+)"/)?.[1]; + expect(result).toBe(`[Attached: ${filename}]`); + }, + ); + + it("truncates content longer than 500 chars", async () => { + const longContent = "x".repeat(600); + mockReadAbsoluteFile.mockResolvedValue(longContent); + const description = '1. '; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe("x".repeat(500)); + }); + + it("strips 'Attached files:' lines when checking for real text", async () => { + mockReadAbsoluteFile.mockResolvedValue("content"); + const description = '1. \nAttached files: a.ts'; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe("content"); + }); + + it("returns original description when no file paths found", async () => { + const description = "1. \n2. "; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe(description); + }); + + it("mixes binary and text files", async () => { + mockReadAbsoluteFile.mockResolvedValue("text content"); + const result = await enrichDescriptionWithFileContent("", [ + "/tmp/image.jpg", + "/tmp/code.ts", + ]); + expect(result).toBe("[Attached: image.jpg]\n\ntext content"); + }); +}); + +describe("generateTitleAndSummary", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchAuthState.mockResolvedValue({ status: "authenticated" }); + }); + + it("truncates title to 255 chars", async () => { + const longTitle = "A".repeat(300); + mockLlmPrompt.mockResolvedValue({ + content: `TITLE: ${longTitle}\nSUMMARY: A summary`, + }); + + const result = await generateTitleAndSummary("some content"); + expect(result?.title).toHaveLength(255); + expect(result?.summary).toBe("A summary"); + }); + + it("returns null when not authenticated", async () => { + mockFetchAuthState.mockResolvedValue({ status: "unauthenticated" }); + const result = await generateTitleAndSummary("some content"); + expect(result).toBeNull(); + expect(mockLlmPrompt).not.toHaveBeenCalled(); + }); + + it("strips surrounding quotes from title", async () => { + mockLlmPrompt.mockResolvedValue({ + content: 'TITLE: "Fix login bug"\nSUMMARY: Fixing auth', + }); + + const result = await generateTitleAndSummary("fix the login bug"); + expect(result?.title).toBe("Fix login bug"); + }); + + it("returns null on error", async () => { + mockLlmPrompt.mockRejectedValue(new Error("network error")); + const result = await generateTitleAndSummary("some content"); + expect(result).toBeNull(); + }); +}); diff --git a/apps/code/src/renderer/utils/generateTitle.ts b/apps/code/src/renderer/utils/generateTitle.ts index cca4df5d0..3a3b3911a 100644 --- a/apps/code/src/renderer/utils/generateTitle.ts +++ b/apps/code/src/renderer/utils/generateTitle.ts @@ -4,6 +4,96 @@ import { logger } from "@utils/logger"; const log = logger.scope("title-generator"); +export const createFileTagRegex = () => //g; +const ATTACHED_FILES_REGEX = /^\[?Attached files:.*]?$/gm; +const PASTED_TEXT_SNIPPET_LIMIT = 500; + +const BINARY_EXTENSIONS = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "bmp", + "ico", + "svg", + "mp3", + "mp4", + "wav", + "avi", + "mov", + "mkv", + "pdf", + "zip", + "tar", + "gz", + "rar", + "7z", + "exe", + "dll", + "so", + "dylib", + "wasm", + "ttf", + "otf", + "woff", + "woff2", + "eot", +]); + +function getExtension(filePath: string): string { + const dot = filePath.lastIndexOf("."); + return dot >= 0 ? filePath.slice(dot + 1).toLowerCase() : ""; +} + +function getFileName(filePath: string): string { + const slash = filePath.lastIndexOf("/"); + return slash >= 0 ? filePath.slice(slash + 1) : filePath; +} + +export async function enrichDescriptionWithFileContent( + description: string, + filePaths: string[] = [], +): Promise { + const stripped = description + .replace(createFileTagRegex(), "") + .replace(ATTACHED_FILES_REGEX, "") + .replace(/^\d+\.\s*$/gm, "") + .trim(); + + if (stripped.length > 0) return description; + + const paths = + filePaths.length > 0 + ? filePaths + : [...description.matchAll(createFileTagRegex())].map((m) => m[1]); + + if (paths.length === 0) return description; + + const parts = await Promise.all( + paths.map(async (filePath) => { + if (BINARY_EXTENSIONS.has(getExtension(filePath))) { + return `[Attached: ${getFileName(filePath)}]`; + } + try { + const content = await trpcClient.fs.readAbsoluteFile.query({ + filePath, + }); + if (content) { + return content.length > PASTED_TEXT_SNIPPET_LIMIT + ? content.slice(0, PASTED_TEXT_SNIPPET_LIMIT) + : content; + } + return `[Attached: ${getFileName(filePath)}]`; + } catch { + return `[Attached: ${getFileName(filePath)}]`; + } + }), + ); + + return parts.length > 0 ? parts.join("\n\n") : description; +} + const SYSTEM_PROMPT = `You are a title and summary generator. Output using exactly this format: TITLE: @@ -78,7 +168,11 @@ export async function generateTitleAndSummary( const titleMatch = text.match(/^TITLE:\s*(.+?)(?:\n|$)/m); const summaryMatch = text.match(/SUMMARY:\s*([\s\S]+)$/m); - const title = titleMatch?.[1]?.trim().replace(/^["']|["']$/g, "") ?? ""; + const title = + titleMatch?.[1] + ?.trim() + .replace(/^["']|["']$/g, "") + .slice(0, 255) ?? ""; const summary = summaryMatch?.[1]?.trim() ?? ""; if (!title && !summary) return null; diff --git a/apps/code/vite.shared.mts b/apps/code/vite.shared.mts index c44497ae6..a0eda0f5c 100644 --- a/apps/code/vite.shared.mts +++ b/apps/code/vite.shared.mts @@ -53,6 +53,13 @@ const workspaceAliases: Alias[] = [ find: "@posthog/shared", replacement: path.resolve(__dirname, "../../packages/shared/src/index.ts"), }, + { + find: "@posthog/enricher", + replacement: path.resolve( + __dirname, + "../../packages/enricher/src/index.ts", + ), + }, ]; export const mainAliases: Alias[] = [