From 43c1a6c9ff1f665c0889609af14434802b8540ba Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 8 Jul 2026 10:25:21 -0400 Subject: [PATCH 1/2] fix(sessions): highlight every skill chip in submitted prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Submitted prompts flatten their tags to plain /name text before the thread renders them, but the rendered-message chip parser only matched a slash command anchored to the very start of the string (a non-global `/^\/.../ ` used with a single `.match()`). Any second or later /skill fell through to plain text, so only the first skill in a multi-skill prompt got a chip — even though the live input box highlights them all. Scan for every slash command with a global, non-anchored regex and merge those matches with the existing mention-tag matches (sorted by position) so each /skill, file, folder, and GitHub ref renders its chip regardless of order. Generated-By: PostHog Code Task-Id: 26ac46b1-6c16-4112-ae2a-ddc60b56f1c0 --- .../session-update/parseFileMentions.test.tsx | 49 ++++++++++ .../session-update/parseFileMentions.tsx | 98 +++++++++++++------ 2 files changed, 118 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/session-update/parseFileMentions.test.tsx 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..76435494be --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/parseFileMentions.test.tsx @@ -0,0 +1,49 @@ +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) { From 29ac07433bca5b54861a2c3d8ce6345744d2ba3f Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 8 Jul 2026 10:57:18 -0400 Subject: [PATCH 2/2] style: format test import to satisfy biome ci The quality check runs `biome ci`, which includes formatting. Collapse the multi-line import in the new test to a single line so formatting passes. Generated-By: PostHog Code Task-Id: 26ac46b1-6c16-4112-ae2a-ddc60b56f1c0 --- .../components/session-update/parseFileMentions.test.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 index 76435494be..011d701748 100644 --- a/packages/ui/src/features/sessions/components/session-update/parseFileMentions.test.tsx +++ b/packages/ui/src/features/sessions/components/session-update/parseFileMentions.test.tsx @@ -1,10 +1,7 @@ import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { - hasFileMentions, - parseFileMentions, -} from "./parseFileMentions"; +import { hasFileMentions, parseFileMentions } from "./parseFileMentions"; function renderParts(content: string) { return render({parseFileMentions(content)});