diff --git a/packages/ui/src/features/sessions/components/session-update/parseFileMentions.test.tsx b/packages/ui/src/features/sessions/components/session-update/parseFileMentions.test.tsx
new file mode 100644
index 0000000000..011d701748
--- /dev/null
+++ b/packages/ui/src/features/sessions/components/session-update/parseFileMentions.test.tsx
@@ -0,0 +1,46 @@
+import { Theme } from "@radix-ui/themes";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { hasFileMentions, parseFileMentions } from "./parseFileMentions";
+
+function renderParts(content: string) {
+ return render({parseFileMentions(content)});
+}
+
+describe("parseFileMentions", () => {
+ it("highlights a leading slash command", () => {
+ renderParts("/deploy the app");
+ expect(screen.getByText("/deploy")).toBeInTheDocument();
+ });
+
+ it("highlights every slash command, not just the first", () => {
+ // A submitted prompt using several skills arrives with its tags
+ // already flattened to plain /name text.
+ renderParts("/first do a thing then /second and finally /third");
+ expect(screen.getByText("/first")).toBeInTheDocument();
+ expect(screen.getByText("/second")).toBeInTheDocument();
+ expect(screen.getByText("/third")).toBeInTheDocument();
+ });
+
+ it("highlights a slash command that is not at the start of the string", () => {
+ renderParts("please run /cleanup now");
+ expect(screen.getByText("/cleanup")).toBeInTheDocument();
+ });
+
+ it("does not treat a mid-word slash as a command", () => {
+ renderParts("check the and/or logic");
+ expect(screen.queryByText("/or")).not.toBeInTheDocument();
+ });
+
+ it("renders slash commands alongside file mentions", () => {
+ renderParts('/review then /ship');
+ expect(screen.getByText("/review")).toBeInTheDocument();
+ expect(screen.getByText("app/main.ts")).toBeInTheDocument();
+ expect(screen.getByText("/ship")).toBeInTheDocument();
+ });
+
+ it("detects slash commands anywhere for hasFileMentions", () => {
+ expect(hasFileMentions("run /skill please")).toBe(true);
+ expect(hasFileMentions("no commands here")).toBe(false);
+ });
+});
diff --git a/packages/ui/src/features/sessions/components/session-update/parseFileMentions.tsx b/packages/ui/src/features/sessions/components/session-update/parseFileMentions.tsx
index f50e9bde9d..714d84a966 100644
--- a/packages/ui/src/features/sessions/components/session-update/parseFileMentions.tsx
+++ b/packages/ui/src/features/sessions/components/session-update/parseFileMentions.tsx
@@ -15,7 +15,12 @@ const MENTION_TAG_REGEX =
/|<(github_issue|github_pr)\s+number="([^"]+)"(?:\s+title="([^"]*)")?(?:\s+url="([^"]*)")?\s*\/>|[\s\S]*?<\/error_context>|/g;
const MENTION_TAG_TEST =
/<(?:file\s+path|folder\s+path|github_issue\s+number|github_pr\s+number|error_context\s+label)="[^"]+"/;
-const SLASH_COMMAND_START = /^\/([a-zA-Z][\w-]*)(?=\s|$)/;
+// Matches every slash command in the string, at the start or after whitespace —
+// not just the leading one. Submitted prompts have their tags flattened
+// to plain /name text, so a prompt using several skills arrives here as multiple
+// /name tokens that each need a chip.
+const SLASH_COMMAND_REGEX = /(^|\s)\/([a-zA-Z][\w-]*)(?=\s|$)/g;
+const SLASH_COMMAND_TEST = /(?:^|\s)\/[a-zA-Z][\w-]*(?=\s|$)/;
const inlineComponents: Components = {
...baseComponents,
@@ -42,7 +47,7 @@ export const InlineMarkdown = memo(function InlineMarkdown({
});
export function hasMentionTags(content: string): boolean {
- return MENTION_TAG_TEST.test(content) || SLASH_COMMAND_START.test(content);
+ return MENTION_TAG_TEST.test(content) || SLASH_COMMAND_TEST.test(content);
}
export const hasFileMentions = hasMentionTags;
@@ -88,30 +93,36 @@ export function MentionChip({
);
}
-export function parseMentionTags(content: string): ReactNode[] {
- const parts: ReactNode[] = [];
- let lastIndex = 0;
+interface MentionMatch {
+ start: number;
+ end: number;
+ node: ReactNode;
+}
- const slashMatch = content.match(SLASH_COMMAND_START);
- if (slashMatch) {
- parts.push(
- ,
- );
- lastIndex = slashMatch[0].length;
+function collectMentionMatches(content: string): MentionMatch[] {
+ const matches: MentionMatch[] = [];
+
+ for (const match of content.matchAll(SLASH_COMMAND_REGEX)) {
+ // Group 1 is the leading start-of-string/whitespace boundary; keep it as
+ // surrounding text so only the /command itself becomes a chip.
+ const leading = match[1] ?? "";
+ const start = (match.index ?? 0) + leading.length;
+ matches.push({
+ start,
+ end: start + match[0].length - leading.length,
+ node: (
+
+ ),
+ });
}
for (const match of content.matchAll(MENTION_TAG_REGEX)) {
const matchIndex = match.index ?? 0;
- if (matchIndex < lastIndex) continue;
-
- if (matchIndex > lastIndex) {
- parts.push(
- ,
- );
- }
+ let node: ReactNode = null;
if (match[1]) {
const filePath = unescapeXmlAttr(match[1]);
@@ -119,12 +130,12 @@ export function parseMentionTags(content: string): ReactNode[] {
const fileName = segments.pop() ?? filePath;
const parentDir = segments.pop();
const label = parentDir ? `${parentDir}/${fileName}` : fileName;
- parts.push(
+ node = (
}
label={label}
- />,
+ />
);
} else if (match[2]) {
const kind = match[2] === "github_pr" ? "pr" : "issue";
@@ -134,37 +145,66 @@ export function parseMentionTags(content: string): ReactNode[] {
const label = issueTitle
? `#${issueNumber} - ${issueTitle}`
: `#${issueNumber}`;
- parts.push(
+ node = (
{label}
- ,
+
);
} else if (match[6]) {
- parts.push(
+ node = (
}
label={unescapeXmlAttr(match[6])}
- />,
+ />
);
} else if (match[7]) {
const folderPath = unescapeXmlAttr(match[7]);
const segments = folderPath.split("/").filter(Boolean);
const folderName = segments.pop() ?? folderPath;
- parts.push(
+ node = (
}
label={folderName}
+ />
+ );
+ }
+
+ if (node) {
+ matches.push({
+ start: matchIndex,
+ end: matchIndex + match[0].length,
+ node,
+ });
+ }
+ }
+
+ return matches.sort((a, b) => a.start - b.start);
+}
+
+export function parseMentionTags(content: string): ReactNode[] {
+ const parts: ReactNode[] = [];
+ let lastIndex = 0;
+
+ for (const { start, end, node } of collectMentionMatches(content)) {
+ if (start < lastIndex) continue;
+
+ if (start > lastIndex) {
+ parts.push(
+ ,
);
}
- lastIndex = matchIndex + match[0].length;
+ parts.push(node);
+ lastIndex = end;
}
if (lastIndex < content.length) {