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;