From 2d2da99f306e660ab7f15bda143dd2ff8bd501bb Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 15:20:41 -0700 Subject: [PATCH 1/3] Render at-mentioned chips as @labels in task titles --- .../message-editor/utils/content.test.ts | 25 +++++++++++ .../features/message-editor/utils/content.ts | 4 ++ .../renderer/sagas/task/task-creation.test.ts | 42 ++++++++++++++++--- .../src/renderer/sagas/task/task-creation.ts | 4 +- 4 files changed, 67 insertions(+), 8 deletions(-) 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..8db8a9a48 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,30 @@ 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("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..5b0e195c5 100644 --- a/apps/code/src/renderer/features/message-editor/utils/content.ts +++ b/apps/code/src/renderer/features/message-editor/utils/content.ts @@ -142,6 +142,10 @@ function chipFromTag(tag: string, rawAttrs: string): MentionChip | null { } } +export function xmlToPlainText(xml: string): string { + return contentToPlainText(xmlToContent(xml)); +} + export function xmlToContent(xml: string): EditorContent { const segments: EditorContent["segments"] = []; let lastIndex = 0; 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..826b9594d 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); @@ -438,12 +434,46 @@ describe("TaskCreationSaga", () => { expect(createTaskMock).toHaveBeenCalledWith( expect.objectContaining({ - title: "Reading attachment\u2026", + title: "@tmp/code.ts", description: '', }), ); }); + 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..9a3818e3e 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,7 +401,7 @@ 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), description, From 417065b31351f5861f350c9d2000bbe985c79e7f Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 17:37:24 -0700 Subject: [PATCH 2/3] address review feedback --- .../message-editor/utils/content.test.ts | 19 +++++++++++ .../features/message-editor/utils/content.ts | 8 ++--- .../renderer/sagas/task/task-creation.test.ts | 32 ++++++++++++++++++- .../src/renderer/sagas/task/task-creation.ts | 2 +- apps/code/src/renderer/utils/generateTitle.ts | 18 +++++++---- 5 files changed, 66 insertions(+), 13 deletions(-) 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 8db8a9a48..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 @@ -221,6 +221,25 @@ describe("xmlToContent", () => { 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 5b0e195c5..1b65fc612 100644 --- a/apps/code/src/renderer/features/message-editor/utils/content.ts +++ b/apps/code/src/renderer/features/message-editor/utils/content.ts @@ -142,10 +142,6 @@ function chipFromTag(tag: string, rawAttrs: string): MentionChip | null { } } -export function xmlToPlainText(xml: string): string { - return contentToPlainText(xmlToContent(xml)); -} - export function xmlToContent(xml: string): EditorContent { const segments: EditorContent["segments"] = []; let lastIndex = 0; @@ -173,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 826b9594d..79961b7a1 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -426,7 +426,6 @@ describe("TaskCreationSaga", () => { await saga.run({ taskDescription: '', - content: '', repository: "posthog/posthog", workspaceMode: "cloud", branch: "main", @@ -440,6 +439,37 @@ describe("TaskCreationSaga", () => { ); }); + 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() }); diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 9a3818e3e..582c0b94a 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -403,7 +403,7 @@ export class TaskCreationSaga extends Saga< const description = input.taskDescription ?? input.content ?? ""; 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.ts b/apps/code/src/renderer/utils/generateTitle.ts index 3a3b3911a..b33a7f516 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,22 @@ export async function enrichDescriptionWithFileContent( description: string, filePaths: string[] = [], ): Promise { - const stripped = description - .replace(createFileTagRegex(), "") + const content = xmlToContent(description); + const textOnly = content.segments + .filter((seg) => seg.type === "text") + .map((seg) => (seg.type === "text" ? seg.text : "")) + .join(""); + const stripped = textOnly .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 = content.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; From 1f8e0f5e048e9ba227edc7dabccfa654ffc2d688 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 17:42:54 -0700 Subject: [PATCH 3/3] polish chip-aware title refactor --- .../src/renderer/utils/generateTitle.test.ts | 26 +++++++++++++++++++ apps/code/src/renderer/utils/generateTitle.ts | 22 +++++++--------- 2 files changed, 36 insertions(+), 12 deletions(-) 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 b33a7f516..c2f3b0fa9 100644 --- a/apps/code/src/renderer/utils/generateTitle.ts +++ b/apps/code/src/renderer/utils/generateTitle.ts @@ -55,19 +55,17 @@ export async function enrichDescriptionWithFileContent( description: string, filePaths: string[] = [], ): Promise { - const content = xmlToContent(description); - const textOnly = content.segments - .filter((seg) => seg.type === "text") - .map((seg) => (seg.type === "text" ? seg.text : "")) - .join(""); - const stripped = textOnly + 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 chipFilePaths = content.segments.flatMap((seg) => + const chipFilePaths = parsed.segments.flatMap((seg) => seg.type === "chip" && seg.chip.type === "file" ? [seg.chip.id] : [], ); const paths = filePaths.length > 0 ? filePaths : chipFilePaths; @@ -80,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 {