From 7868f5caf6515543a81ffc58fe941e15e917dcf9 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:00:08 +0200 Subject: [PATCH] Keep email addresses intact when sending with Enter (#6471) * fix: prevent file mention autocomplete from triggering on emails Skip @ when preceded by a word character or +, and reject angle brackets in mention queries so email addresses no longer open the file autocomplete or steal Enter. Closes #1364 * test(app): verify emails send intact without file completion The word-character and plus guard tests previously passed because their queries included trailing spaces. Exercise each guard with the cursor at the end of the email, and cover Enter in the browser alongside normal file completion. Co-authored-by: Jason --------- Co-authored-by: Jason Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../browser/composer-email-mentions.spec.ts | 55 +++++++++++++++ .../utils/file-mention-autocomplete.test.ts | 68 +++++++++++++++++++ .../src/utils/file-mention-autocomplete.ts | 7 +- 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/browser/composer-email-mentions.spec.ts diff --git a/packages/app/e2e/browser/composer-email-mentions.spec.ts b/packages/app/e2e/browser/composer-email-mentions.spec.ts new file mode 100644 index 0000000000..29b0686c20 --- /dev/null +++ b/packages/app/e2e/browser/composer-email-mentions.spec.ts @@ -0,0 +1,55 @@ +import { expect, test, type Page } from "../support/fixtures"; +import { + composerLocator, + expectComposerDraft, + expectComposerVisible, + fillComposerDraft, +} from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +// The popover has no semantic role; its rows are React Native Pressables. +function fileSuggestions(page: Page) { + return page.getByTestId("composer-autocomplete-popover"); +} + +async function selectReadmeMention(page: Page): Promise { + await fillComposerDraft(page, "@README"); + await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible(); + await expect(fileSuggestions(page).getByText("example.com", { exact: true })).toHaveCount(0); + await composerLocator(page).press("Enter"); + await expectComposerDraft(page, '"README.md"'); +} + +async function sendEmailIntact(page: Page, email: string): Promise { + await fillComposerDraft(page, email); + await expect(fileSuggestions(page)).not.toBeVisible(); + await composerLocator(page).press("Enter"); + await expectComposerDraft(page, ""); + await expect(page.getByText(email, { exact: true })).toBeVisible(); +} + +for (const email of [ + "user <12345+user@noreply.example.com>", + "alice@example.com", + "noreply+@example.com", +]) { + test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-email-", + title: "Email mention regression", + repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] }, + }); + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await test.step("complete a workspace file mention with Enter", async () => { + await selectReadmeMention(page); + }); + await test.step("send an email without offering or inserting workspace files", async () => { + await sendEmailIntact(page, email); + }); + } finally { + await agent.cleanup(); + } + }); +} diff --git a/packages/app/src/utils/file-mention-autocomplete.test.ts b/packages/app/src/utils/file-mention-autocomplete.test.ts index 4f818e25e2..c74c821522 100644 --- a/packages/app/src/utils/file-mention-autocomplete.test.ts +++ b/packages/app/src/utils/file-mention-autocomplete.test.ts @@ -48,6 +48,74 @@ describe("findActiveFileMention", () => { }); expect(mention).toBeNull(); }); + + it("returns null for the issue #1364 angle-bracket email example", () => { + const text = "user <12345+user@noreply.example.com>"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null for email addresses (word char before @)", () => { + const text = "alice@example.com"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null for email addresses (+ immediately before @)", () => { + // Local part ends with + so the character at index-1 is '+', not a word char. + const text = "noreply+@example.com"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null for plus-tagged email local parts", () => { + const text = "noreply+tag@example.com"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null when query contains angle brackets", () => { + const text = "check @foo end"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.indexOf(" end"), + }); + expect(mention).toBeNull(); + }); + + it("returns null when query ends with a closing angle bracket", () => { + const text = "check @foo>"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("still detects a file mention after an email in the same input", () => { + const text = "user <12345+user@noreply.example.com> and also @file"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toEqual({ + start: text.lastIndexOf("@"), + end: text.length, + query: "file", + }); + }); }); describe("formatQuotedFileMentionPath", () => { diff --git a/packages/app/src/utils/file-mention-autocomplete.ts b/packages/app/src/utils/file-mention-autocomplete.ts index d19424cfe4..fe09b52f03 100644 --- a/packages/app/src/utils/file-mention-autocomplete.ts +++ b/packages/app/src/utils/file-mention-autocomplete.ts @@ -15,7 +15,7 @@ interface ApplyFileMentionReplacementInput { relativePath: string; } -const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/; +const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"'<>]/; export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null { const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length)); @@ -26,6 +26,11 @@ export function findActiveFileMention(input: FindActiveFileMentionInput): FileMe atIndex >= 0; atIndex = atIndex === 0 ? -1 : beforeCursor.lastIndexOf("@", atIndex - 1) ) { + // Skip @ that looks like part of an email address (local-part char immediately before @). + if (atIndex > 0 && /[\w+]/.test(beforeCursor[atIndex - 1]!)) { + continue; + } + const query = beforeCursor.slice(atIndex + 1); if (INVALID_MENTION_QUERY_CHARS.test(query)) { continue;