diff --git a/src/renderer/analytics/posthog.ts b/src/renderer/analytics/posthog.ts index 0334875e..a05b48d1 100644 --- a/src/renderer/analytics/posthog.ts +++ b/src/renderer/analytics/posthog.ts @@ -47,7 +47,7 @@ function appSummaryProperties(): ProductAnalyticsProperties { function segmentProperties( segments: readonly PromptSegment[] | undefined, ): ProductAnalyticsProperties { - const counts = { text: 0, file: 0, attachment: 0, skill: 0 }; + const counts = { text: 0, file: 0, attachment: 0, skill: 0, mcp: 0 }; for (const segment of segments ?? []) { counts[segment.kind] += 1; } diff --git a/src/renderer/components/composer/McpMentionChip.ts b/src/renderer/components/composer/McpMentionChip.ts new file mode 100644 index 00000000..09540148 --- /dev/null +++ b/src/renderer/components/composer/McpMentionChip.ts @@ -0,0 +1,37 @@ +/** + * Inline, non-editable badge representing an `@`-mentioned MCP server (Browser, + * Crossagents, Computer Use, …) inside the composer's contentEditable. The chip + * carries the server `id`/`name` on its dataset so {@link serializeToSegments} + * can round-trip it to an `mcp` prompt segment, which flattens back to the + * `@Name` directive the agent reads for the turn. The glyph mirrors the `Plug` + * icon the app uses for MCP plugins elsewhere. + */ +export interface McpMentionChipInput { + id: string; + name: string; +} + +// lucide `plug`, inlined for the DOM chip (mirrors how the skill chip inlines +// its glyph SVG); the React user-message badge renders the `Plug` component. +const PLUG_ICON_SVG = + ''; + +export function createMcpMentionChipElement(input: McpMentionChipInput): HTMLSpanElement { + const chip = document.createElement("span"); + chip.contentEditable = "false"; + chip.dataset.mcpId = input.id; + chip.dataset.mcpName = input.name; + chip.className = "poracode-slash-chip"; + + const glyph = document.createElement("span"); + glyph.className = "poracode-slash-chip__slash"; + glyph.innerHTML = PLUG_ICON_SVG; + chip.appendChild(glyph); + + const name = document.createElement("span"); + name.className = "poracode-slash-chip__name"; + name.textContent = input.name; + chip.appendChild(name); + + return chip; +} diff --git a/src/renderer/components/composer/MentionInput.test.ts b/src/renderer/components/composer/MentionInput.test.ts index e5583e91..09dbcc04 100644 --- a/src/renderer/components/composer/MentionInput.test.ts +++ b/src/renderer/components/composer/MentionInput.test.ts @@ -168,11 +168,13 @@ describe("MCP mention selection", () => { onSubmit: vi.fn<(segments: PromptSegment[]) => void>(), }; - it("keeps an enabled MCP mention in the prompt for the agent", () => { + it("inserts an enabled MCP mention as a badge that flattens to the agent directive", () => { const onMcpMentionSelect = vi.fn<(id: string) => void>(); + const ref = createRef(); render( createElement(MentionInput, { ...baseProps, + ref, mcpMentions: [ { id: "browser", @@ -189,7 +191,12 @@ describe("MCP mention selection", () => { const editor = typeMention("bro"); fireEvent.keyDown(editor, { key: "Enter" }); - expect(editor).toHaveTextContent("@Browser"); + const chip = editor.querySelector("[data-mcp-name]"); + expect(chip).not.toBeNull(); + expect(chip).toHaveAttribute("data-mcp-id", "browser"); + expect(chip).toHaveAttribute("data-mcp-name", "Browser"); + // The badge still flattens to the `@Browser` directive the agent reads. + expect(ref.current?.serialize()).toBe("@Browser"); expect(onMcpMentionSelect).not.toHaveBeenCalled(); }); diff --git a/src/renderer/components/composer/MentionInput.tsx b/src/renderer/components/composer/MentionInput.tsx index f7eda8f9..fb82c830 100644 --- a/src/renderer/components/composer/MentionInput.tsx +++ b/src/renderer/components/composer/MentionInput.tsx @@ -8,6 +8,7 @@ import type { } from "@/shared/contracts"; import { fileNameFromPath, skillSegmentFromSlashCommand } from "@/shared/promptContent"; import { createChipElement, type FileMentionData } from "./FileMentionChip"; +import { createMcpMentionChipElement } from "./McpMentionChip"; import { createSlashCommandChipElement } from "./SlashCommandChip"; import { MentionPopover, type MentionEntry } from "./MentionPopover"; import { useDebouncedFileSearch } from "./useDebouncedFileSearch"; @@ -153,7 +154,7 @@ function detectTriggerRange(triggerChar: string): Range | null { } function hasEditorContent(editor: HTMLDivElement): boolean { - if (editor.querySelector("[data-mention-path], [data-slash-command]")) return true; + if (editor.querySelector("[data-mention-path], [data-slash-command], [data-mcp-id]")) return true; return (editor.textContent ?? "").trim().length > 0; } @@ -212,6 +213,8 @@ function appendPromptSegments(parent: Node, segments: PromptSegment[]): void { parent.appendChild( createSlashCommandChipElement({ id: segment.name, ...skillChipDataset(segment) }), ); + } else if (segment.kind === "mcp") { + parent.appendChild(createMcpMentionChipElement({ id: segment.id, name: segment.name })); } } } @@ -566,10 +569,14 @@ export const MentionInput = forwardRef< range.deleteContents(); if (entry.enabled) { - const mentionText = document.createTextNode(`@${entry.name} `); - range.insertNode(mentionText); + const chip = createMcpMentionChipElement({ id: entry.path, name: entry.name }); + range.insertNode(chip); + // Trailing nbsp keeps the caret visually separate from the chip, matching + // the file mention chip. + const space = document.createTextNode(" "); + chip.after(space); const nextRange = document.createRange(); - nextRange.setStartAfter(mentionText); + nextRange.setStartAfter(space); nextRange.collapse(true); sel.removeAllRanges(); sel.addRange(nextRange); @@ -666,7 +673,7 @@ export const MentionInput = forwardRef< if (node.nodeType === Node.TEXT_NODE && offset === 0) { const prev = node.previousSibling as HTMLElement | null; - if (prev?.dataset?.mentionPath || prev?.dataset?.slashCommand) { + if (prev?.dataset?.mentionPath || prev?.dataset?.slashCommand || prev?.dataset?.mcpId) { e.preventDefault(); prev.remove(); notifyTextChange(); @@ -676,7 +683,11 @@ export const MentionInput = forwardRef< if (node.nodeType === Node.ELEMENT_NODE && offset > 0) { const child = node.childNodes[offset - 1] as HTMLElement | undefined; - if (child?.dataset?.mentionPath || child?.dataset?.slashCommand) { + if ( + child?.dataset?.mentionPath || + child?.dataset?.slashCommand || + child?.dataset?.mcpId + ) { e.preventDefault(); child.remove(); notifyTextChange(); diff --git a/src/renderer/components/composer/serializeMentions.test.ts b/src/renderer/components/composer/serializeMentions.test.ts index ed9cd7c4..c822f234 100644 --- a/src/renderer/components/composer/serializeMentions.test.ts +++ b/src/renderer/components/composer/serializeMentions.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { PromptSegment } from "@/shared/contracts"; +import { createMcpMentionChipElement } from "./McpMentionChip"; import { createSlashCommandChipElement } from "./SlashCommandChip"; import { rebuildEditedPromptSegments, @@ -66,6 +67,17 @@ describe("serializeComposerContent", () => { expect(serializeComposerContent(container)).toBe("check @src/main.ts please"); }); + it("serializes an MCP mention chip as an mcp segment that flattens to @name", () => { + container.appendChild(createMcpMentionChipElement({ id: "browser", name: "Browser" })); + container.appendChild(document.createTextNode(" open the page")); + + expect(serializeToSegments(container)).toEqual([ + { kind: "mcp", id: "browser", name: "Browser" }, + { kind: "text", content: " open the page" }, + ]); + expect(serializeComposerContent(container)).toBe("@Browser open the page"); + }); + it("serializes BR as newline", () => { container.appendChild(document.createTextNode("line one")); container.appendChild(document.createElement("br")); diff --git a/src/renderer/components/composer/serializeMentions.ts b/src/renderer/components/composer/serializeMentions.ts index ae52a492..0138c223 100644 --- a/src/renderer/components/composer/serializeMentions.ts +++ b/src/renderer/components/composer/serializeMentions.ts @@ -92,6 +92,12 @@ export function serializeToSegments(container: HTMLDivElement): PromptSegment[] return; } + if (el.dataset.mcpId && el.dataset.mcpName) { + flushText(); + segments.push({ kind: "mcp", id: el.dataset.mcpId, name: el.dataset.mcpName }); + return; + } + if (el.dataset.slashCommand) { if ( el.dataset.skillName && diff --git a/src/renderer/components/find/chatFindMatches.ts b/src/renderer/components/find/chatFindMatches.ts index c1ce8434..7ecb97be 100644 --- a/src/renderer/components/find/chatFindMatches.ts +++ b/src/renderer/components/find/chatFindMatches.ts @@ -25,7 +25,13 @@ function blocksToText(payload: unknown): string { let out = ""; for (const block of blocks) { if (!block || typeof block !== "object") continue; - const record = block as { kind?: unknown; text?: unknown; path?: unknown; source?: unknown }; + const record = block as { + kind?: unknown; + text?: unknown; + path?: unknown; + source?: unknown; + name?: unknown; + }; if (record.kind === "text" && typeof record.text === "string") { out += record.text; } else if ( @@ -34,6 +40,9 @@ function blocksToText(payload: unknown): string { record.source !== "attachment" ) { out += record.path; + } else if (record.kind === "mcp" && typeof record.name === "string") { + // Keep the badge's `@Name` directive findable in the transcript. + out += `@${record.name}`; } } return out; diff --git a/src/renderer/components/find/findMatching.test.ts b/src/renderer/components/find/findMatching.test.ts index 9e48da27..a8f8c78b 100644 --- a/src/renderer/components/find/findMatching.test.ts +++ b/src/renderer/components/find/findMatching.test.ts @@ -46,6 +46,22 @@ describe("getChatItemSearchText", () => { it("reads user text from payload content blocks", () => { expect(getChatItemSearchText(user("u", "a prompt"))).toBe("a prompt"); }); + + it("makes an MCP mention badge findable by its @name directive", () => { + const item: RuntimeChatItem = { + id: "u", + type: "user_message", + state: "completed", + streams: {}, + payload: { + content: [ + { kind: "mcp", name: "Browser" }, + { kind: "text", text: " open" }, + ], + }, + }; + expect(getChatItemSearchText(item)).toBe("@Browser open"); + }); }); describe("collectChatMatches", () => { diff --git a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx index c082b932..37eaebbf 100644 --- a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx +++ b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx @@ -1129,6 +1129,20 @@ describe("ChatPane", () => { expect(screen.queryByText("$simplify")).not.toBeInTheDocument(); }); + it("renders MCP mentions in user messages as badges", async () => { + const thread = makeThread(); + seedUserMessageContent(thread.id, [{ kind: "mcp", name: "Browser" }]); + + const { container } = renderChatPane(thread); + await waitFor(() => expect(hydrateThreadRuntimeItems).toHaveBeenCalledWith(thread.id)); + + const badge = container.querySelector('[data-mcp-name="Browser"]'); + expect(badge).toHaveTextContent("Browser"); + expect(badge?.querySelector("svg")).toBeInTheDocument(); + // The raw `@Browser` directive text is replaced by the badge. + expect(screen.queryByText("@Browser")).not.toBeInTheDocument(); + }); + it("copies user message text from the inline action", async () => { const thread = makeThread(); seedUserMessage(thread.id, "Copy this prompt"); diff --git a/src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx b/src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx index 6d724250..51dbc9ef 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx @@ -1,7 +1,7 @@ import { memo, type ReactNode, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; import { Link, Surface, Tooltip } from "@heroui/react"; import { useLingui } from "@lingui/react/macro"; -import { ChevronDown, ChevronUp, Sparkles } from "lucide-react"; +import { ChevronDown, ChevronUp, Plug, Sparkles } from "lucide-react"; import type { CanonicalContentBlock, MessageItemPayload } from "@/shared/contracts"; import { AttachmentBar } from "@/renderer/components/composer/AttachmentBar"; import { openAttachmentLightbox } from "@/renderer/components/composer/ImageLightbox"; @@ -71,7 +71,10 @@ export const UserMessage = memo(function UserMessage({ item, checkpointRevert }: const text = body; const commandPrefixLength = slashCommand ? rawText.length - body.length : 0; const hasInlineContent = content.some( - (block) => block.kind === "skill" || (block.kind === "file" && block.source !== "attachment"), + (block) => + block.kind === "skill" || + block.kind === "mcp" || + (block.kind === "file" && block.source !== "attachment"), ); const attachments = enrichWithSelectorPayloads( buildUserPromptAttachments(content), @@ -295,6 +298,7 @@ function buildUserPromptText(content: CanonicalContentBlock[]): string { .map((block) => { if (block.kind === "text") return block.text; if (block.kind === "skill") return block.invocation; + if (block.kind === "mcp") return `@${block.name}`; if (block.kind === "file" && block.source !== "attachment") return block.path; return ""; }) @@ -338,6 +342,21 @@ function renderUserMessageInlineContent( return; } + if (block.kind === "mcp") { + // An @-mention is never part of a leading /slash-command prefix, so — + // unlike the skill/file branches — an mcp block can never fall inside the + // skipped region and needs no remainingSkip handling. + nodes.push( +