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[] = [