diff --git a/apps/code/src/renderer/features/message-editor/utils/content.test.ts b/apps/code/src/renderer/features/message-editor/utils/content.test.ts index 825dd20ef..beb226b2b 100644 --- a/apps/code/src/renderer/features/message-editor/utils/content.test.ts +++ b/apps/code/src/renderer/features/message-editor/utils/content.test.ts @@ -4,6 +4,7 @@ import { type EditorContent, extractFilePaths, xmlToContent, + xmlToPlainText, } from "./content"; describe("xmlToContent", () => { @@ -196,6 +197,49 @@ describe("xmlToContent", () => { expect(extractFilePaths(content)).toEqual(["src/sub", "src/a.ts"]); }); + it("xmlToPlainText renders folder mentions as @mentions", () => { + expect( + xmlToPlainText('look at please'), + ).toBe("look at @products/agentic_tests please"); + }); + + it("xmlToPlainText renders file mentions as @mentions", () => { + expect( + xmlToPlainText('see for details'), + ).toBe("see @foo/bar.ts for details"); + }); + + it("xmlToPlainText renders structured chip types as @label", () => { + expect( + xmlToPlainText( + 'investigate and ', + ), + ).toBe("investigate @err-1 and @flag-2"); + }); + + it("xmlToPlainText leaves plain text untouched", () => { + expect(xmlToPlainText("ship the fix")).toBe("ship the fix"); + }); + + it("xmlToPlainText renders github_pr and github_issue mentions", () => { + expect( + xmlToPlainText( + '', + ), + ).toBe("@#42 - Add login"); + expect( + xmlToPlainText( + '', + ), + ).toBe("@#7"); + }); + + it("xmlToPlainText passes through non-chip XML-like text", () => { + expect(xmlToPlainText("use Array and
tags")).toBe( + "use Array and
tags", + ); + }); + it("round-trips contentToXml for a mix of text and chips", () => { const content: EditorContent = { segments: [ diff --git a/apps/code/src/renderer/features/message-editor/utils/content.ts b/apps/code/src/renderer/features/message-editor/utils/content.ts index 5207e5547..1b65fc612 100644 --- a/apps/code/src/renderer/features/message-editor/utils/content.ts +++ b/apps/code/src/renderer/features/message-editor/utils/content.ts @@ -169,6 +169,10 @@ export function xmlToContent(xml: string): EditorContent { return { segments }; } +export function xmlToPlainText(xml: string): string { + return contentToPlainText(xmlToContent(xml)); +} + export function isContentEmpty( content: EditorContent | null | string, ): boolean { 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 faa63d59d..79961b7a1 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -51,10 +51,6 @@ vi.mock("@features/sessions/service/service", () => ({ }), })); -vi.mock("@renderer/utils/generateTitle", () => ({ - createFileTagRegex: () => //g, -})); - vi.mock("@utils/logger", () => ({ logger: { scope: () => ({ @@ -409,7 +405,7 @@ describe("TaskCreationSaga", () => { ); }); - it("sets fallback title when description is attachment-only", async () => { + it("renders attachment-only description as @mention title", async () => { const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); const createTaskMock = vi.fn().mockResolvedValue(createdTask); @@ -430,7 +426,6 @@ describe("TaskCreationSaga", () => { await saga.run({ taskDescription: '', - content: '', repository: "posthog/posthog", workspaceMode: "cloud", branch: "main", @@ -438,12 +433,77 @@ describe("TaskCreationSaga", () => { expect(createTaskMock).toHaveBeenCalledWith( expect.objectContaining({ - title: "Reading attachment\u2026", + 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(); diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 789b91ea9..582c0b94a 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -1,4 +1,5 @@ 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"; @@ -18,7 +19,6 @@ import type { import { Saga, type SagaLogger } from "@posthog/shared"; import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc"; -import { createFileTagRegex } from "@renderer/utils/generateTitle"; import { getTaskRepository } from "@renderer/utils/repository"; import { type ExecutionMode, @@ -401,9 +401,9 @@ export class TaskCreationSaga extends Saga< name: "task_creation", execute: async () => { const description = input.taskDescription ?? input.content ?? ""; - const plainText = description.replace(createFileTagRegex(), "").trim(); + const plainText = xmlToPlainText(description).trim(); const result = await this.deps.posthogClient.createTask({ - title: (plainText || "Reading attachment\u2026").slice(0, 255), + title: (plainText || "Untitled").slice(0, 255), description, repository: repository ?? undefined, github_integration: diff --git a/apps/code/src/renderer/utils/generateTitle.test.ts b/apps/code/src/renderer/utils/generateTitle.test.ts index 6ecd85605..6f0582226 100644 --- a/apps/code/src/renderer/utils/generateTitle.test.ts +++ b/apps/code/src/renderer/utils/generateTitle.test.ts @@ -135,6 +135,32 @@ describe("enrichDescriptionWithFileContent", () => { ]); expect(result).toBe("[Attached: image.jpg]\n\ntext content"); }); + + it("returns description unchanged for folder-only input", async () => { + const description = ''; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe(description); + expect(mockReadAbsoluteFile).not.toHaveBeenCalled(); + }); + + it("reads file and drops folder for mixed file+folder input", async () => { + mockReadAbsoluteFile.mockResolvedValue("file body"); + const description = + ''; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe("file body"); + expect(mockReadAbsoluteFile).toHaveBeenCalledTimes(1); + expect(mockReadAbsoluteFile).toHaveBeenCalledWith({ + filePath: "/tmp/a.ts", + }); + }); + + it("treats non-chip XML-like text as real content", async () => { + const description = "
hello world
"; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe(description); + expect(mockReadAbsoluteFile).not.toHaveBeenCalled(); + }); }); describe("generateTitleAndSummary", () => { diff --git a/apps/code/src/renderer/utils/generateTitle.ts b/apps/code/src/renderer/utils/generateTitle.ts index 3a3b3911a..c2f3b0fa9 100644 --- a/apps/code/src/renderer/utils/generateTitle.ts +++ b/apps/code/src/renderer/utils/generateTitle.ts @@ -1,10 +1,10 @@ import { fetchAuthState } from "@features/auth/hooks/authQueries"; +import { xmlToContent } from "@features/message-editor/utils/content"; import { trpcClient } from "@renderer/trpc"; 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; @@ -55,18 +55,20 @@ export async function enrichDescriptionWithFileContent( description: string, filePaths: string[] = [], ): Promise { - const stripped = description - .replace(createFileTagRegex(), "") + const parsed = xmlToContent(description); + const stripped = parsed.segments + .flatMap((seg) => (seg.type === "text" ? [seg.text] : [])) + .join("") .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]); + const chipFilePaths = parsed.segments.flatMap((seg) => + seg.type === "chip" && seg.chip.type === "file" ? [seg.chip.id] : [], + ); + const paths = filePaths.length > 0 ? filePaths : chipFilePaths; if (paths.length === 0) return description; @@ -76,13 +78,13 @@ export async function enrichDescriptionWithFileContent( return `[Attached: ${getFileName(filePath)}]`; } try { - const content = await trpcClient.fs.readAbsoluteFile.query({ + const fileContent = await trpcClient.fs.readAbsoluteFile.query({ filePath, }); - if (content) { - return content.length > PASTED_TEXT_SNIPPET_LIMIT - ? content.slice(0, PASTED_TEXT_SNIPPET_LIMIT) - : content; + if (fileContent) { + return fileContent.length > PASTED_TEXT_SNIPPET_LIMIT + ? fileContent.slice(0, PASTED_TEXT_SNIPPET_LIMIT) + : fileContent; } return `[Attached: ${getFileName(filePath)}]`; } catch {