authorgravatar for 46866023+GuilhermeVieiraDev@users.noreply.github.comGuilherme Vieira <46866023+GuilhermeVieiraDev@users.noreply.github.com> 2026-06-04 23:19:06+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-04 15:19:06-07:00
log53042f47f85a986257573773d9854cf59b957514
tree3059cf9a7a943eafe656570a8ea7ae83b50d3449
parent6ce6f678b5d70a8d2b7230ec45cbde04d54f80dc
signature Signed by PGP key B5690EEEBB952194

fix(composer): support spaces in file mentions (#2625)


9 files changed, 100 insertions(+), 16 deletions(-)

apps/mobile/src/features/threads/ThreadComposer.tsx+2-1
...@@ -10,6 +10,7 @@ import type {...@@ -10,6 +10,7 @@ import type {
10import {10import {
11 detectComposerTrigger,11 detectComposerTrigger,
12 replaceTextRange,12 replaceTextRange,
13 serializeComposerMentionPath,
13 type ComposerTrigger,14 type ComposerTrigger,
14} from "@t3tools/shared/composerTrigger";15} from "@t3tools/shared/composerTrigger";
15import { TextInputWrapper } from "expo-paste-input";16import { TextInputWrapper } from "expo-paste-input";
...@@ -408,7 +409,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -408,7 +409,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
408409
409 let replacement = "";410 let replacement = "";
410 if (item.type === "path") {411 if (item.type === "path") {
411 replacement = `@${item.path} `;412 replacement = `@${serializeComposerMentionPath(item.path)} `;
412 } else if (item.type === "skill") {413 } else if (item.type === "skill") {
413 replacement = `$${item.skill.name} `;414 replacement = `$${item.skill.name} `;
414 } else if (item.type === "slash-command") {415 } else if (item.type === "slash-command") {
apps/web/src/components/ComposerPromptEditor.tsx+2-1
...@@ -6,6 +6,7 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";...@@ -6,6 +6,7 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
6import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";6import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
7import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";7import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
8import { type ServerProviderSkill } from "@t3tools/contracts";8import { type ServerProviderSkill } from "@t3tools/contracts";
9import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
9import {10import {
10 $applyNodeReplacement,11 $applyNodeReplacement,
11 $createRangeSelection,12 $createRangeSelection,
...@@ -198,7 +199,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {...@@ -198,7 +199,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
198 }199 }
199200
200 override getTextContent(): string {201 override getTextContent(): string {
201 return `@${this.__path}`;202 return `@${serializeComposerMentionPath(this.__path)}`;
202 }203 }
203204
204 override isInline(): true {205 override isInline(): true {
apps/web/src/components/chat/ChatComposer.tsx+2-1
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,17 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
18 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,18 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
19} from "@t3tools/contracts";19} from "@t3tools/contracts";
20import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
20import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";21import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
21import {22import {
22 memo,23 memo,
...@@ -1471,7 +1472,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1471,7 +1472,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1471 const { snapshot, trigger } = resolveActiveComposerTrigger();1472 const { snapshot, trigger } = resolveActiveComposerTrigger();
1472 if (!trigger) return;1473 if (!trigger) return;
1473 if (item.type === "path") {1474 if (item.type === "path") {
1474 const replacement = `@${item.path} `;1475 const replacement = `@${serializeComposerMentionPath(item.path)} `;
1475 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(1476 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
1476 snapshot.value,1477 snapshot.value,
1477 trigger.rangeEnd,1478 trigger.rangeEnd,
apps/web/src/composer-editor-mentions.test.ts+26
...@@ -29,6 +29,22 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -29,6 +29,22 @@ describe("splitPromptIntoComposerSegments", () => {
29 ]);29 ]);
30 });30 });
3131
32 it("splits quoted mention tokens containing whitespace", () => {
33 expect(splitPromptIntoComposerSegments('Inspect @"My File.md" please')).toEqual([
34 { type: "text", text: "Inspect " },
35 { type: "mention", path: "My File.md" },
36 { type: "text", text: " please" },
37 ]);
38 });
39
40 it("unescapes quoted mention token content", () => {
41 expect(splitPromptIntoComposerSegments('Inspect @"docs/My \\"File\\".md" please')).toEqual([
42 { type: "text", text: "Inspect " },
43 { type: "mention", path: 'docs/My "File".md' },
44 { type: "text", text: " please" },
45 ]);
46 });
47
32 it("splits skill tokens followed by whitespace into skill segments", () => {48 it("splits skill tokens followed by whitespace into skill segments", () => {
33 expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([49 expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([
34 { type: "text", text: "Use " },50 { type: "text", text: "Use " },
...@@ -125,4 +141,14 @@ describe("selectionTouchesMentionBoundary", () => {...@@ -125,4 +141,14 @@ describe("selectionTouchesMentionBoundary", () => {
125 ),141 ),
126 ).toBe(true);142 ).toBe(true);
127 });143 });
144
145 it("returns true when selection includes whitespace after a quoted mention", () => {
146 expect(
147 selectionTouchesMentionBoundary(
148 'hi @"My File.md" there',
149 'hi @"My File.md"'.length,
150 'hi @"My File.md" there'.length,
151 ),
152 ).toBe(true);
153 });
128});154});
apps/web/src/composer-editor-mentions.ts+19-11
...@@ -21,8 +21,8 @@ export type ComposerPromptSegment =...@@ -21,8 +21,8 @@ export type ComposerPromptSegment =
21 context: TerminalContextDraft | null;21 context: TerminalContextDraft | null;
22 };22 };
2323
24const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;
25const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;24const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
25const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
2626
27function rangeIncludesIndex(start: number, end: number, index: number): boolean {27function rangeIncludesIndex(start: number, end: number, index: number): boolean {
28 return start <= index && index < end;28 return start <= index && index < end;
...@@ -52,13 +52,18 @@ type InlineTokenMatch =...@@ -52,13 +52,18 @@ type InlineTokenMatch =
52 end: number;52 end: number;
53 };53 };
5454
55function collectInlineTokenMatches(text: string): InlineTokenMatch[] {55type MentionTokenMatch = Extract<InlineTokenMatch, { type: "mention" }>;
56 const matches: InlineTokenMatch[] = [];56
57function collectMentionTokenMatches(text: string): MentionTokenMatch[] {
58 const matches: MentionTokenMatch[] = [];
5759
58 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {60 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
59 const fullMatch = match[0];61 const fullMatch = match[0];
60 const prefix = match[1] ?? "";62 const prefix = match[1] ?? "";
61 const path = match[2] ?? "";63 const quotedPath = match[2];
64 const unquotedPath = match[3];
65 const path =
66 quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (unquotedPath ?? "");
62 const matchIndex = match.index ?? 0;67 const matchIndex = match.index ?? 0;
63 const start = matchIndex + prefix.length;68 const start = matchIndex + prefix.length;
64 const end = start + fullMatch.length - prefix.length;69 const end = start + fullMatch.length - prefix.length;
...@@ -67,6 +72,12 @@ function collectInlineTokenMatches(text: string): InlineTokenMatch[] {...@@ -67,6 +72,12 @@ function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
67 }72 }
68 }73 }
6974
75 return matches;
76}
77
78function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
79 const matches: InlineTokenMatch[] = collectMentionTokenMatches(text);
80
70 for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {81 for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {
71 const fullMatch = match[0];82 const fullMatch = match[0];
72 const prefix = match[1] ?? "";83 const prefix = match[1] ?? "";
...@@ -148,10 +159,10 @@ function forEachPromptTextSlice(...@@ -148,10 +159,10 @@ function forEachPromptTextSlice(
148159
149function forEachMentionMatch(160function forEachMentionMatch(
150 prompt: string,161 prompt: string,
151 visitor: (match: RegExpMatchArray, promptOffset: number) => boolean | void,162 visitor: (match: MentionTokenMatch, promptOffset: number) => boolean | void,
152): boolean {163): boolean {
153 return forEachPromptTextSlice(prompt, (text, promptOffset) => {164 return forEachPromptTextSlice(prompt, (text, promptOffset) => {
154 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {165 for (const match of collectMentionTokenMatches(text)) {
155 if (visitor(match, promptOffset) === true) {166 if (visitor(match, promptOffset) === true) {
156 return true;167 return true;
157 }168 }
...@@ -203,11 +214,8 @@ export function selectionTouchesMentionBoundary(...@@ -203,11 +214,8 @@ export function selectionTouchesMentionBoundary(
203 }214 }
204215
205 return forEachMentionMatch(prompt, (match, promptOffset) => {216 return forEachMentionMatch(prompt, (match, promptOffset) => {
206 const fullMatch = match[0];217 const mentionStart = promptOffset + match.start;
207 const prefix = match[1] ?? "";218 const mentionEnd = promptOffset + match.end;
208 const matchIndex = match.index ?? 0;
209 const mentionStart = promptOffset + matchIndex + prefix.length;
210 const mentionEnd = mentionStart + fullMatch.length - prefix.length;
211 const beforeMentionIndex = mentionStart - 1;219 const beforeMentionIndex = mentionStart - 1;
212 const afterMentionIndex = mentionEnd;220 const afterMentionIndex = mentionEnd;
213221
apps/web/src/composer-logic.test.ts+20
...@@ -157,6 +157,16 @@ describe("expandCollapsedComposerCursor", () => {...@@ -157,6 +157,16 @@ describe("expandCollapsedComposerCursor", () => {
157 );157 );
158 });158 });
159159
160 it("maps collapsed quoted mention cursor to expanded text cursor", () => {
161 const text = 'what is in @"My File.md" please';
162 const collapsedCursorAfterMention = "what is in ".length + 2;
163 const expandedCursorAfterMention = 'what is in @"My File.md" '.length;
164
165 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterMention)).toBe(
166 expandedCursorAfterMention,
167 );
168 });
169
160 it("allows path trigger detection to close after selecting a mention", () => {170 it("allows path trigger detection to close after selecting a mention", () => {
161 const text = "what's in my @AGENTS.md ";171 const text = "what's in my @AGENTS.md ";
162 const collapsedCursorAfterMention = "what's in my ".length + 2;172 const collapsedCursorAfterMention = "what's in my ".length + 2;
...@@ -191,6 +201,16 @@ describe("collapseExpandedComposerCursor", () => {...@@ -191,6 +201,16 @@ describe("collapseExpandedComposerCursor", () => {
191 );201 );
192 });202 });
193203
204 it("maps expanded quoted mention cursor back to collapsed cursor", () => {
205 const text = 'what is in @"My File.md" please';
206 const collapsedCursorAfterMention = "what is in ".length + 2;
207 const expandedCursorAfterMention = 'what is in @"My File.md" '.length;
208
209 expect(collapseExpandedComposerCursor(text, expandedCursorAfterMention)).toBe(
210 collapsedCursorAfterMention,
211 );
212 });
213
194 it("keeps replacement cursors aligned when another mention already exists earlier", () => {214 it("keeps replacement cursors aligned when another mention already exists earlier", () => {
195 const text = "open @AGENTS.md then @src/index.ts ";215 const text = "open @AGENTS.md then @src/index.ts ";
196 const expandedCursor = text.length;216 const expandedCursor = text.length;
apps/web/src/composer-logic.ts+3-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";2import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
2import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";3import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
34
...@@ -54,7 +55,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)...@@ -54,7 +55,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
5455
55 for (const segment of segments) {56 for (const segment of segments) {
56 if (segment.type === "mention") {57 if (segment.type === "mention") {
57 const expandedLength = segment.path.length + 1;58 const expandedLength = serializeComposerMentionPath(segment.path).length + 1;
58 if (remaining <= 1) {59 if (remaining <= 1) {
59 return expandedCursor + (remaining === 0 ? 0 : expandedLength);60 return expandedCursor + (remaining === 0 ? 0 : expandedLength);
60 }61 }
...@@ -142,7 +143,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number...@@ -142,7 +143,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number
142143
143 for (const segment of segments) {144 for (const segment of segments) {
144 if (segment.type === "mention") {145 if (segment.type === "mention") {
145 const expandedLength = segment.path.length + 1;146 const expandedLength = serializeComposerMentionPath(segment.path).length + 1;
146 if (remaining === 0) {147 if (remaining === 0) {
147 return collapsedCursor;148 return collapsedCursor;
148 }149 }
packages/shared/src/composerTrigger.test.ts created+17
...@@ -0,0 +1,17 @@
1import { describe, expect, it } from "vitest";
2
3import { serializeComposerMentionPath } from "./composerTrigger.ts";
4
5describe("serializeComposerMentionPath", () => {
6 it("keeps simple mention paths unquoted", () => {
7 expect(serializeComposerMentionPath("src/index.ts")).toBe("src/index.ts");
8 });
9
10 it("quotes mention paths containing whitespace", () => {
11 expect(serializeComposerMentionPath("docs/My File.md")).toBe('"docs/My File.md"');
12 });
13
14 it("escapes quoted mention path content", () => {
15 expect(serializeComposerMentionPath('docs/My "File".md')).toBe('"docs/My \\"File\\".md"');
16 });
17});
packages/shared/src/composerTrigger.ts+9
...@@ -8,6 +8,15 @@ export interface ComposerTrigger {...@@ -8,6 +8,15 @@ export interface ComposerTrigger {
8 rangeEnd: number;8 rangeEnd: number;
9}9}
1010
11const SIMPLE_MENTION_PATH_REGEX = /^[^\s@"\\]+$/;
12
13export function serializeComposerMentionPath(path: string): string {
14 if (SIMPLE_MENTION_PATH_REGEX.test(path)) {
15 return path;
16 }
17 return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
18}
19
11function clampCursor(text: string, cursor: number): number {20function clampCursor(text: string, cursor: number): number {
12 if (!Number.isFinite(cursor)) return text.length;21 if (!Number.isFinite(cursor)) return text.length;
13 return Math.max(0, Math.min(text.length, Math.floor(cursor)));22 return Math.max(0, Math.min(text.length, Math.floor(cursor)));