diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 00196d63497ea..fea59bc60c164 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -9,7 +9,11 @@ import { serializeAssistantCitation, withAssistantCitationComment, } from "@t3tools/shared/assistantCitations"; -import { isCurrencyAmountQuery, isPathWord } from "@t3tools/shared/composerTrigger"; +import { + detectComposerPullRequestTrigger, + isCurrencyAmountQuery, + isPathWord, +} from "@t3tools/shared/composerTrigger"; import { splitPromptIntoComposerSegments, type ComposerPromptSegment, @@ -261,15 +265,8 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - const pullRequestMatch = /^#([\p{L}\p{N}][\p{L}\p{N}_-]*)?$/u.exec(token); - if (pullRequestMatch) { - return { - kind: "pull-request", - query: pullRequestMatch[1] ?? "", - rangeStart: tokenStart, - rangeEnd: cursor, - }; - } + const pullRequestTrigger = detectComposerPullRequestTrigger(token, cursor); + if (pullRequestTrigger) return pullRequestTrigger; const skillPrefix = /^\p{Sc}/u.exec(token); if (skillPrefix) { if (isCurrencyAmountQuery(token.slice(skillPrefix[0].length))) return null; diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index 6f27538f3728a..4a414dd2fbca8 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -78,6 +78,20 @@ describe("labels and reference links", () => { for (const occurrence of occurrences) { expect(text.slice(occurrence.start, occurrence.end)).toBe(occurrence.source); } + const reference = "[#30296](t3-context://v1/review-comment/ctx_pr)"; + for (const prefix of ["See [", "See [["]) { + expect(collectComposerContextReferences(`${prefix}${reference} ]`)).toEqual([ + { + kind: "review-comment", + contextId: "ctx_pr", + label: "#30296", + image: false, + source: reference, + start: prefix.length, + end: prefix.length + reference.length, + }, + ]); + } }); it("recognizes a prompt made only of image references", () => { diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index a7aeb1373ae10..a4367ff6402b3 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -18,7 +18,7 @@ const CONTEXT_KIND_PATTERN = /^[a-z][a-z0-9-]{0,39}$/; const CONTEXT_ID_PATTERN = /^[a-z0-9_-]{1,128}$/i; const MAX_LINK_LABEL_LENGTH = 512; const CONTEXT_LINK = new RegExp( - String.raw`(!?)\[([^\]\n]{0,${MAX_LINK_LABEL_LENGTH}})\]\((${COMPOSER_CONTEXT_HREF_PREFIX}[^\s)]{1,200})\)`, + String.raw`(!?)\[([^[\]\n]{0,${MAX_LINK_LABEL_LENGTH}})\]\((${COMPOSER_CONTEXT_HREF_PREFIX}[^\s)]{1,200})\)`, "g", ); diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 72ff3db848cf5..4241903a92d1a 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { detectComposerTrigger, parseSideChatCommand, + replaceTextRange, serializeComposerFileLink, } from "./composerTrigger.ts"; @@ -46,6 +47,54 @@ describe("detectComposerTrigger", () => { (text) => expect(detectComposerTrigger(text, text.length)).toBeNull(), ); + it.each([ + ["#", "", "", ""], + ["Compare #8737", "Compare ", "8737", ""], + ["(#", "(", "", ")"], + ["Compare (#8737", "Compare (", "8737", ")"], + ["Compare(#fix-name", "Compare(", "fix-name", ")"], + ["((#fix_name", "((", "fix_name", "))"], + ["(#修正", "(", "修正", ")"], + ["[#", "[", "", "]"], + ["Compare [#8737", "Compare [", "8737", "]"], + ["{#fix-name", "{", "fix-name", "}"], + ["【#修正", "【", "修正", "】"], + ["(#8737", "(", "8737", ")"], + ])( + "detects the pull request in %j without replacing surrounding brackets", + (text, prefix, query, suffix) => { + const prompt = `${text}${suffix}`; + const trigger = detectComposerTrigger(prompt, text.length); + expect(trigger).toEqual({ + kind: "pull-request", + query, + rangeStart: prefix.length, + rangeEnd: text.length, + }); + expect(replaceTextRange(prompt, trigger!.rangeStart, trigger!.rangeEnd, "PR")).toEqual({ + text: `${prefix}PR${suffix}`, + cursor: prefix.length + 2, + }); + }, + ); + + it.each([ + "# Heading", + "issue#123", + "https://example.com/#123", + "(issue#123", + "(##123", + "(#123)", + "(#fix.name", + "[issue#123", + "[#123]", + "[#_fix", + "]#123", + ".#123", + ])("does not detect a pull request in %j", (text) => { + expect(detectComposerTrigger(text, text.length)).toBeNull(); + }); + it.each(["$", "€", "𑿝"])("detects %s skill prefixes and their source range", (prefix) => { const text = `Use ${prefix}review`; expect(detectComposerTrigger(text, text.length)).toEqual({ diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index 80292492154a8..cc15fb0f27949 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -68,6 +68,18 @@ export function isCurrencyAmountQuery(query: string): boolean { return /^\.?[0-9][0-9,._]*(?:[kKmMbBtT]|[eE][0-9]+)?[.,;:!?)]*$/.test(query); } +export function detectComposerPullRequestTrigger(token: string, cursor: number) { + const match = /(?:^|\p{Ps})#([\p{L}\p{N}][\p{L}\p{N}_-]*)?$/u.exec(token); + if (!match) return null; + const query = match[1] ?? ""; + return { + kind: "pull-request" as const, + query, + rangeStart: cursor - query.length - 1, + rangeEnd: cursor, + }; +} + export function detectComposerTrigger( text: string, cursorInput: number, @@ -128,14 +140,8 @@ export function detectComposerTrigger( rangeEnd: cursor, }; } - const pullRequestMatch = /^#([\p{L}\p{N}][\p{L}\p{N}_-]*)?$/u.exec(token); - if (pullRequestMatch) - return { - kind: "pull-request", - query: pullRequestMatch[1] ?? "", - rangeStart: tokenStart, - rangeEnd: cursor, - }; + const pullRequestTrigger = detectComposerPullRequestTrigger(token, cursor); + if (pullRequestTrigger) return pullRequestTrigger; const skillPrefix = /^\p{Sc}/u.exec(token); if (skillPrefix) { if (isCurrencyAmountQuery(token.slice(skillPrefix[0].length))) return null;