From 5e199684472dd23d26c83c19d3b2bb51c13b3a5e Mon Sep 17 00:00:00 2001 From: Maxwell Young Date: Mon, 20 Jul 2026 09:34:39 +1200 Subject: [PATCH 1/2] [codex] keep scoped package references as text PR: #4167 Co-authored-by: codex --- .../components/ComposerPromptEditor.test.ts | 79 +++++++++++++++++++ apps/web/src/composer-editor-mentions.test.ts | 29 ++++++- .../shared/src/composerInlineTokens.test.ts | 48 +++++++++++ packages/shared/src/composerInlineTokens.ts | 5 +- 4 files changed, 158 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 45a2db4731c..0aab8fb0c2d 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -70,4 +70,83 @@ describe("registerComposerInlineTokenPaste", () => { " ", ); }); + + it.each([ + "yarn expo install @expo/ui", + "npm install @jane/foo.js", + "import '@scope/pkg/sub/path'", + ])("leaves scoped package command %s to the plain-text paste fallback", (command) => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor(); + const plainTextFallback = vi.fn((event: ClipboardEvent) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return false; + selection.insertText(event.clipboardData?.getData("text/plain") ?? ""); + return true; + }); + + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 0, + }); + editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); + + const event = new TestClipboardEvent(command); + let handled = false; + editor.update( + () => { + handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, + ); + + expect(handled).toBe(true); + expect(plainTextFallback).toHaveBeenCalledOnce(); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(command); + }); + + it("pastes a canonical scoped folder link as a mention", () => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor(); + const mention = "[sub](@scope/pkg/sub)"; + const plainTextFallback = vi.fn(() => true); + + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 0, + }); + editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); + + const event = new TestClipboardEvent(mention); + let handled = false; + editor.update( + () => { + handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, + ); + + expect(handled).toBe(true); + expect(plainTextFallback).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( + " ", + ); + }); }); diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index d79170769d5..70c97400370 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -22,9 +22,9 @@ describe("splitPromptIntoComposerSegments", () => { }); it("keeps newlines around mention tokens", () => { - expect(splitPromptIntoComposerSegments("one\n@src/index.ts \ntwo")).toEqual([ + expect(splitPromptIntoComposerSegments("one\n@AGENTS.md \ntwo")).toEqual([ { type: "text", text: "one\n" }, - { type: "mention", path: "src/index.ts", source: "@src/index.ts" }, + { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" }, { type: "text", text: " \ntwo" }, ]); }); @@ -71,6 +71,31 @@ describe("splitPromptIntoComposerSegments", () => { ).toEqual([{ type: "text", text: "Read [the docs](https://example.com/docs) first" }]); }); + it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])( + "does not turn scoped package reference %s into file mention segments", + (reference) => { + const prompt = `Install ${reference} next`; + expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]); + }, + ); + + it("keeps IME-composed text containing a scoped package reference as text", () => { + const prompt = "入力 @expo/ui を追加"; + expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]); + }); + + it("turns canonical scoped folder links into file mention segments", () => { + expect(splitPromptIntoComposerSegments("Inspect [sub](@scope/pkg/sub) next")).toEqual([ + { type: "text", text: "Inspect " }, + { + type: "mention", + path: "@scope/pkg/sub", + source: "[sub](@scope/pkg/sub)", + }, + { type: "text", text: " next" }, + ]); + }); + it("decodes reserved path characters from generated links", () => { expect( splitPromptIntoComposerSegments( diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index f99d0b6654e..5a7c14f1725 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -81,4 +81,52 @@ describe("collectComposerInlineTokens", () => { it("ignores normal web links", () => { expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]); }); + + it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])( + "keeps scoped package reference %s as plain text", + (reference) => { + expect(collectComposerInlineTokens(`Install ${reference} next`)).toEqual([]); + }, + ); + + it("keeps scoped package references plain across incomplete input and IME whitespace", () => { + expect(collectComposerInlineTokens("Install @expo/ui")).toEqual([]); + expect(collectComposerInlineTokens("入力 @expo/ui を追加")).toEqual([]); + }); + + it("keeps bare non-scoped file paths as mentions", () => { + expect(collectComposerInlineTokens("Inspect @README.md next")).toEqual([ + { + type: "mention", + value: "README.md", + source: "@README.md", + start: 8, + end: 18, + }, + ]); + }); + + it("keeps canonical file links for scoped paths as mentions", () => { + expect(collectComposerInlineTokens("Inspect [sub](@scope/pkg/sub) next")).toEqual([ + { + type: "mention", + value: "@scope/pkg/sub", + source: "[sub](@scope/pkg/sub)", + start: 8, + end: 29, + }, + ]); + }); + + it("allows ambiguous scoped paths through explicit quoted mentions", () => { + expect(collectComposerInlineTokens('Inspect @"expo/ui" next')).toEqual([ + { + type: "mention", + value: "expo/ui", + source: '@"expo/ui"', + start: 8, + end: 18, + }, + ]); + }); }); diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index aa5e67d6fc8..dda548059df 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -23,6 +23,9 @@ const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; +// Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package. +const SCOPED_PACKAGE_REFERENCE_REGEX = + /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/; function collectMentionTokens(text: string): ComposerInlineToken[] { const matches: ComposerInlineToken[] = []; @@ -60,7 +63,7 @@ function collectMentionTokens(text: string): ComposerInlineToken[] { const prefix = match[1] ?? ""; const quotedPath = match[2]; const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? ""); - if (!path) { + if (!path || (quotedPath === undefined && SCOPED_PACKAGE_REFERENCE_REGEX.test(path))) { continue; } const start = (match.index ?? 0) + prefix.length; From ef9d15a4226555bdd92e4cbddec7be5e7d28453d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 14:30:02 +0200 Subject: [PATCH 2/2] Fix cursor regression test for PR #4167 Co-authored-by: codex --- apps/web/src/composer-logic.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 99ac6bba716..b8ef7443611 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -246,12 +246,21 @@ describe("collapseExpandedComposerCursor", () => { ); }); - it("keeps replacement cursors aligned when another mention already exists earlier", () => { + it("keeps package-like text expanded when another mention already exists earlier", () => { const text = "open @AGENTS.md then @src/index.ts "; const expandedCursor = text.length; const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor); - expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2); + expect(collapsedCursor).toBe("open ".length + 1 + " then @src/index.ts ".length); + expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); + }); + + it("collapses only genuine mentions when package-like text exists earlier", () => { + const text = "install @scope/pkg then @README.md "; + const expandedCursor = text.length; + const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor); + + expect(collapsedCursor).toBe("install @scope/pkg then ".length + 1 + " ".length); expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); });