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 {
1010import {
1111 detectComposerTrigger,
1212 replaceTextRange,
13 serializeComposerMentionPath,
1314 type ComposerTrigger,
1415} from "@t3tools/shared/composerTrigger";
1516import { TextInputWrapper } from "expo-paste-input";
......@@ -408,7 +409,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
408409
409410 let replacement = "";
410411 if (item.type === "path") {
411 replacement = `@${item.path} `;
412 replacement = `@${serializeComposerMentionPath(item.path)} `;
412413 } else if (item.type === "skill") {
413414 replacement = `$${item.skill.name} `;
414415 } else if (item.type === "slash-command") {
apps/web/src/components/ComposerPromptEditor.tsx+2-1
......@@ -6,6 +6,7 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
66import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
77import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
88import { type ServerProviderSkill } from "@t3tools/contracts";
9import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
910import {
1011 $applyNodeReplacement,
1112 $createRangeSelection,
......@@ -198,7 +199,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
198199 }
199200
200201 override getTextContent(): string {
201 return `@${this.__path}`;
202 return `@${serializeComposerMentionPath(this.__path)}`;
202203 }
203204
204205 override isInline(): true {
apps/web/src/components/chat/ChatComposer.tsx+2-1
......@@ -17,6 +17,7 @@ import {
1717 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1818 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1919} from "@t3tools/contracts";
20import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
2021import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
2122import {
2223 memo,
......@@ -1471,7 +1472,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
14711472 const { snapshot, trigger } = resolveActiveComposerTrigger();
14721473 if (!trigger) return;
14731474 if (item.type === "path") {
1474 const replacement = `@${item.path} `;
1475 const replacement = `@${serializeComposerMentionPath(item.path)} `;
14751476 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
14761477 snapshot.value,
14771478 trigger.rangeEnd,
apps/web/src/composer-editor-mentions.test.ts+26
......@@ -29,6 +29,22 @@ describe("splitPromptIntoComposerSegments", () => {
2929 ]);
3030 });
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
3248 it("splits skill tokens followed by whitespace into skill segments", () => {
3349 expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([
3450 { type: "text", text: "Use " },
......@@ -125,4 +141,14 @@ describe("selectionTouchesMentionBoundary", () => {
125141 ),
126142 ).toBe(true);
127143 });
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 });
128154});
apps/web/src/composer-editor-mentions.ts+19-11
......@@ -21,8 +21,8 @@ export type ComposerPromptSegment =
2121 context: TerminalContextDraft | null;
2222 };
2323
24const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;
2524const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
25const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
2626
2727function rangeIncludesIndex(start: number, end: number, index: number): boolean {
2828 return start <= index && index < end;
......@@ -52,13 +52,18 @@ type InlineTokenMatch =
5252 end: number;
5353 };
5454
55function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
56 const matches: InlineTokenMatch[] = [];
55type MentionTokenMatch = Extract<InlineTokenMatch, { type: "mention" }>;
56
57function collectMentionTokenMatches(text: string): MentionTokenMatch[] {
58 const matches: MentionTokenMatch[] = [];
5759
5860 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
5961 const fullMatch = match[0];
6062 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 ?? "");
6267 const matchIndex = match.index ?? 0;
6368 const start = matchIndex + prefix.length;
6469 const end = start + fullMatch.length - prefix.length;
......@@ -67,6 +72,12 @@ function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
6772 }
6873 }
6974
75 return matches;
76}
77
78function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
79 const matches: InlineTokenMatch[] = collectMentionTokenMatches(text);
80
7081 for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {
7182 const fullMatch = match[0];
7283 const prefix = match[1] ?? "";
......@@ -148,10 +159,10 @@ function forEachPromptTextSlice(
148159
149160function forEachMentionMatch(
150161 prompt: string,
151 visitor: (match: RegExpMatchArray, promptOffset: number) => boolean | void,
162 visitor: (match: MentionTokenMatch, promptOffset: number) => boolean | void,
152163): boolean {
153164 return forEachPromptTextSlice(prompt, (text, promptOffset) => {
154 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
165 for (const match of collectMentionTokenMatches(text)) {
155166 if (visitor(match, promptOffset) === true) {
156167 return true;
157168 }
......@@ -203,11 +214,8 @@ export function selectionTouchesMentionBoundary(
203214 }
204215
205216 return forEachMentionMatch(prompt, (match, promptOffset) => {
206 const fullMatch = match[0];
207 const prefix = match[1] ?? "";
208 const matchIndex = match.index ?? 0;
209 const mentionStart = promptOffset + matchIndex + prefix.length;
210 const mentionEnd = mentionStart + fullMatch.length - prefix.length;
217 const mentionStart = promptOffset + match.start;
218 const mentionEnd = promptOffset + match.end;
211219 const beforeMentionIndex = mentionStart - 1;
212220 const afterMentionIndex = mentionEnd;
213221
apps/web/src/composer-logic.test.ts+20
......@@ -157,6 +157,16 @@ describe("expandCollapsedComposerCursor", () => {
157157 );
158158 });
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
160170 it("allows path trigger detection to close after selecting a mention", () => {
161171 const text = "what's in my @AGENTS.md ";
162172 const collapsedCursorAfterMention = "what's in my ".length + 2;
......@@ -191,6 +201,16 @@ describe("collapseExpandedComposerCursor", () => {
191201 );
192202 });
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
194214 it("keeps replacement cursors aligned when another mention already exists earlier", () => {
195215 const text = "open @AGENTS.md then @src/index.ts ";
196216 const expandedCursor = text.length;
apps/web/src/composer-logic.ts+3-2
......@@ -1,3 +1,4 @@
1import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
12import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
23import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
34
......@@ -54,7 +55,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
5455
5556 for (const segment of segments) {
5657 if (segment.type === "mention") {
57 const expandedLength = segment.path.length + 1;
58 const expandedLength = serializeComposerMentionPath(segment.path).length + 1;
5859 if (remaining <= 1) {
5960 return expandedCursor + (remaining === 0 ? 0 : expandedLength);
6061 }
......@@ -142,7 +143,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number
142143
143144 for (const segment of segments) {
144145 if (segment.type === "mention") {
145 const expandedLength = segment.path.length + 1;
146 const expandedLength = serializeComposerMentionPath(segment.path).length + 1;
146147 if (remaining === 0) {
147148 return collapsedCursor;
148149 }
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 {
88 rangeEnd: number;
99}
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
1120function clampCursor(text: string, cursor: number): number {
1221 if (!Number.isFinite(cursor)) return text.length;
1322 return Math.max(0, Math.min(text.length, Math.floor(cursor)));