Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/renderer/analytics/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
37 changes: 37 additions & 0 deletions src/renderer/components/composer/McpMentionChip.ts
Original file line number Diff line number Diff line change
@@ -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 =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></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;
}
11 changes: 9 additions & 2 deletions src/renderer/components/composer/MentionInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MentionInputHandle>();
render(
createElement(MentionInput, {
...baseProps,
ref,
mcpMentions: [
{
id: "browser",
Expand All @@ -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();
});

Expand Down
23 changes: 17 additions & 6 deletions src/renderer/components/composer/MentionInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 }));
}
}
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions src/renderer/components/composer/serializeMentions.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"));
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/components/composer/serializeMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
11 changes: 10 additions & 1 deletion src/renderer/components/find/chatFindMatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions src/renderer/components/find/findMatching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
14 changes: 14 additions & 0 deletions src/renderer/components/thread/ChatPane/ChatPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 "";
})
Expand Down Expand Up @@ -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(
<UserMessageSlashChip
key={`mcp-${index}-${block.name}`}
icon={<Plug aria-hidden="true" />}
label={block.name}
mcpName={block.name}
/>,
);
return;
}

if (block.kind === "file") {
if (block.source === "attachment") return;
if (remainingSkip >= block.path.length) {
Expand Down Expand Up @@ -365,15 +384,18 @@ function UserMessageSlashChip({
icon,
label,
skillName,
mcpName,
}: {
icon: ReactNode;
label: string;
skillName?: string;
mcpName?: string;
}) {
return (
<span
className="poracode-slash-chip mr-1.5"
{...(skillName ? { "data-skill-name": skillName } : {})}
{...(mcpName ? { "data-mcp-name": mcpName } : {})}
>
<span className="poracode-slash-chip__slash">{icon}</span>
<span className="poracode-slash-chip__name">{label}</span>
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2571,7 +2571,8 @@ html[data-app-unfocused] .poracode-provider-icon--finished {
cursor: default;
}

.poracode-slash-chip[data-skill-name] {
.poracode-slash-chip[data-skill-name],
.poracode-slash-chip[data-mcp-name] {
gap: 0.25em;
}

Expand Down
1 change: 1 addition & 0 deletions src/shared/contracts/runtimeEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export type RuntimeContentStreamKind = z.infer<typeof runtimeContentStreamKindSc
export const canonicalContentBlockSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text"), text: z.string() }),
z.object({ kind: z.literal("skill"), name: z.string(), invocation: z.string() }),
z.object({ kind: z.literal("mcp"), name: z.string() }),
z.object({
kind: z.literal("image"),
mimeType: z.string(),
Expand Down
1 change: 1 addition & 0 deletions src/shared/contracts/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export const promptSegmentSchema = z.discriminatedUnion("kind", [
provider: z.string().min(1),
scope: z.enum(["global", "project"]),
}),
z.object({ kind: z.literal("mcp"), id: z.string().min(1), name: z.string().min(1) }),
]);
export type PromptSegment = z.infer<typeof promptSegmentSchema>;

Expand Down
12 changes: 12 additions & 0 deletions src/shared/promptContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ describe("buildPromptContentBlocks", () => {
},
]);
});

it("preserves MCP mention segments as mcp blocks for badge rendering", () => {
expect(
buildPromptContentBlocks("@Browser open the page", [
{ kind: "mcp", id: "browser", name: "Browser" },
{ kind: "text", content: " open the page" },
]),
).toEqual([
{ kind: "mcp", name: "Browser" },
{ kind: "text", text: " open the page" },
]);
});
});

describe("toFileUrl", () => {
Expand Down
7 changes: 7 additions & 0 deletions src/shared/promptContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export function inlinePromptSegmentText(segment: PromptSegment): string {
return `@${segment.path}`;
case "skill":
return segment.invocation;
case "mcp":
return `@${segment.name}`;
case "text":
return segment.content;
}
Expand Down Expand Up @@ -171,6 +173,11 @@ export function buildPromptContentBlocks(
continue;
}

if (segment.kind === "mcp") {
content.push({ kind: "mcp", name: segment.name });
continue;
}

const name = fileNameFromPath(segment.path);
if (segment.kind === "attachment") {
if (isImagePath(segment.path, segment.mimeType)) {
Expand Down
4 changes: 4 additions & 0 deletions src/supervisor/agents/claude/canonicalMapping/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export function buildPromptContentBlocks(
if (segment.content.length > 0) blocks.push({ kind: "text", text: segment.content });
continue;
}
if (segment.kind === "mcp") {
blocks.push({ kind: "mcp", name: segment.name });
continue;
}
blocks.push({
kind: "file",
path: segment.path,
Expand Down
Loading