diff --git a/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts b/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts index 5ca7d6beb9..02bd11aaf3 100644 --- a/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts +++ b/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts @@ -13,17 +13,19 @@ import { createReadToolOutputTool } from "./read-tool-output"; import { createReadPromptTool } from "./prompts"; import { createReadResourceTool } from "./resources"; import { createSandboxTool, type VirtualClient } from "./sandbox"; +import { createOpenInAgentTool } from "./open-in-agent"; import { createSubtaskTool } from "./subtask"; import { userAskTool } from "./user-ask"; import { proposePlanTool } from "./propose-plan"; import type { ModelsConfig } from "../types"; -import { MeshProvider } from "@/ai-providers/types"; +import type { MeshProvider } from "@/ai-providers/types"; export interface BuiltinToolParams { /** Provider — null for Claude Code (subtask tool is omitted when null) */ provider: MeshProvider | null; organization: OrganizationScope; models: ModelsConfig; + userId: string; toolApprovalLevel?: ToolApprovalLevel; toolOutputMap: Map; passthroughClient: VirtualClient; @@ -45,6 +47,7 @@ function buildAllTools( provider, organization, models, + userId, toolApprovalLevel = "auto", toolOutputMap, passthroughClient, @@ -76,6 +79,15 @@ function buildAllTools( passthroughClient, toolOutputMap, }), + open_in_agent: createOpenInAgentTool( + writer, + { + organization, + userId, + needsApproval: toolNeedsApproval(toolApprovalLevel, false) !== false, + }, + ctx, + ), }; // subtask requires a provider (LLM calls) — skip when provider is null (Claude Code) if (provider) { @@ -99,6 +111,7 @@ function buildAllTools( sandbox: ReturnType; read_resource: ReturnType; read_prompt: ReturnType; + open_in_agent: ReturnType; }; } diff --git a/apps/mesh/src/api/routes/decopilot/built-in-tools/open-in-agent.ts b/apps/mesh/src/api/routes/decopilot/built-in-tools/open-in-agent.ts new file mode 100644 index 0000000000..476fded18d --- /dev/null +++ b/apps/mesh/src/api/routes/decopilot/built-in-tools/open-in-agent.ts @@ -0,0 +1,106 @@ +/** + * open_in_agent Built-in Tool + * + * Validates the target agent and creates an empty thread (task). + * Returns immediately with a taskId — the frontend starts the actual + * agent run via the standard decopilot/stream endpoint. + */ + +import type { MeshContext, OrganizationScope } from "@/core/mesh-context"; +import type { UIMessageStreamWriter } from "ai"; +import { tool, zodSchema } from "ai"; +import { z } from "zod"; + +const OpenInAgentInputSchema = z.object({ + agent_id: z + .string() + .min(1) + .max(128) + .describe("The ID of the agent (Virtual MCP) to open."), + context: z + .string() + .min(1) + .max(50_000) + .describe( + "The context/task to forward to the agent. Include all relevant information " + + "from the current conversation — the agent will start fresh with only this context.", + ), +}); + +const description = + "Open a task in another agent's UI. Use this when the user @mentions an agent " + + "and wants to hand off work to that agent's specialized interface. " + + "The user will see a clickable card to navigate to the agent.\n\n" + + "Usage notes:\n" + + "- Include full context (conversation summary, tool results, relevant data) in the context field.\n" + + "- The agent starts fresh — it has no access to this conversation.\n" + + "- This is NOT subtask — the work runs in the agent's own UI, not inline."; + +export interface OpenInAgentParams { + organization: OrganizationScope; + userId: string; + needsApproval?: boolean; +} + +const ANNOTATIONS = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, +} as const; + +export function createOpenInAgentTool( + writer: UIMessageStreamWriter, + params: OpenInAgentParams, + ctx: MeshContext, +) { + const { organization, userId, needsApproval } = params; + + return tool({ + description, + inputSchema: zodSchema(OpenInAgentInputSchema), + needsApproval, + execute: async ({ agent_id }, options) => { + const startTime = performance.now(); + try { + const virtualMcp = await ctx.storage.virtualMcps.findById( + agent_id, + organization.id, + ); + + if (!virtualMcp || virtualMcp.organization_id !== organization.id) { + throw new Error("Agent not found"); + } + + if (virtualMcp.status !== "active") { + throw new Error("Agent is not active"); + } + + if (!userId) { + throw new Error("User ID is required to create a thread"); + } + + const taskId = crypto.randomUUID(); + await ctx.storage.threads.create({ + id: taskId, + created_by: userId, + virtual_mcp_id: agent_id, + }); + + return { + success: true, + agent_id: virtualMcp.id, + agent_title: virtualMcp.title, + task_id: taskId, + }; + } finally { + const latencyMs = performance.now() - startTime; + writer.write({ + type: "data-tool-metadata", + id: options.toolCallId, + data: { annotations: ANNOTATIONS, latencyMs }, + }); + } + }, + }); +} diff --git a/apps/mesh/src/api/routes/decopilot/built-in-tools/registration.test.ts b/apps/mesh/src/api/routes/decopilot/built-in-tools/registration.test.ts index a7188cce4d..db3d6ef4d6 100644 --- a/apps/mesh/src/api/routes/decopilot/built-in-tools/registration.test.ts +++ b/apps/mesh/src/api/routes/decopilot/built-in-tools/registration.test.ts @@ -14,6 +14,7 @@ const mockParams: BuiltinToolParams = { connectionId: "conn_test", thinking: { id: "model_test" }, } as never, + userId: "user_test", toolOutputMap: new Map(), passthroughClient: { listTools: () => Promise.resolve({ tools: [] }), diff --git a/apps/mesh/src/api/routes/decopilot/built-in-tools/user-ask.e2e.test.ts b/apps/mesh/src/api/routes/decopilot/built-in-tools/user-ask.e2e.test.ts index 685255787f..034f61de64 100644 --- a/apps/mesh/src/api/routes/decopilot/built-in-tools/user-ask.e2e.test.ts +++ b/apps/mesh/src/api/routes/decopilot/built-in-tools/user-ask.e2e.test.ts @@ -26,6 +26,7 @@ const mockParams: BuiltinToolParams = { connectionId: "conn_test", thinking: { id: "model_test" }, } as never, + userId: "user_test", toolOutputMap: new Map(), passthroughClient: { listTools: () => Promise.resolve({ tools: [] }), diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index 41745a5bc3..01024d5649 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -9,7 +9,7 @@ export const DEFAULT_THREAD_TITLE = "New chat"; export const PARENT_STEP_LIMIT = 30; export const SUBAGENT_STEP_LIMIT = 15; -export const SUBAGENT_EXCLUDED_TOOLS = ["user_ask", "subtask"]; +export const SUBAGENT_EXCLUDED_TOOLS = ["user_ask", "subtask", "open_in_agent"]; /** * Base platform prompt — shared by all agents (decopilot and custom). diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index faba420329..3b4f305b5b 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -409,6 +409,7 @@ async function streamCoreInner( provider, organization, models: input.models, + userId: input.userId, toolApprovalLevel: input.toolApprovalLevel, toolOutputMap, passthroughClient, diff --git a/apps/mesh/src/web/components/chat/derive-parts.ts b/apps/mesh/src/web/components/chat/derive-parts.ts index 9237f397e9..4342744e5f 100644 --- a/apps/mesh/src/web/components/chat/derive-parts.ts +++ b/apps/mesh/src/web/components/chat/derive-parts.ts @@ -198,18 +198,52 @@ export function derivePartsFromTiptapDoc( // Add label to inline text inlineText += mentionName; - // Handle resource mentions (@) vs prompt mentions (/) if (char === "@") { - // Resource mentions: metadata contains ReadResourceResult.contents directly - const contents = (node.attrs.metadata || - []) as ReadResourceResult["contents"]; - parts.push(...resourcesToParts(contents, mentionName)); + // @ mentions can be agents or resources — distinguish by metadata shape + const meta = node.attrs.metadata as + | Record + | unknown[] + | null; + if (meta && !Array.isArray(meta) && "agentId" in meta) { + // Agent mention: instruct the AI to use open_in_agent tool + parts.push({ + type: "text", + text: + `[OPEN IN AGENT: ${(meta as { title?: string }).title ?? node.attrs.name} (agent_id: ${(meta as { agentId: string }).agentId})]\n` + + `Use the open_in_agent tool to hand off this task to the agent above. ` + + `Include the full relevant context from this conversation in the context field.`, + }); + } else if (Array.isArray(meta)) { + // Resource mention: metadata is ReadResourceResult.contents + parts.push( + ...resourcesToParts( + meta as ReadResourceResult["contents"], + mentionName, + ), + ); + } } else { - // Prompt mentions: metadata contains PromptMessage[] - const prompts = (node.attrs.metadata || - node.attrs.prompts || - []) as PromptMessage[]; - parts.push(...promptMessagesToParts(prompts, mentionName)); + // Slash mentions: prompts or resources (both use "/") + // Distinguish by metadata shape: arrays with "role" = prompts, arrays with "uri" = resources + const metadata = node.attrs.metadata || node.attrs.prompts || []; + if ( + Array.isArray(metadata) && + metadata.length > 0 && + "role" in metadata[0] + ) { + // Prompt messages + parts.push( + ...promptMessagesToParts(metadata as PromptMessage[], mentionName), + ); + } else if (Array.isArray(metadata)) { + // Resource contents + parts.push( + ...resourcesToParts( + metadata as ReadResourceResult["contents"], + mentionName, + ), + ); + } } } else if (node.type === "file" && node.attrs) { const fileAttrs = node.attrs as unknown as FileAttrs; diff --git a/apps/mesh/src/web/components/chat/message/assistant.tsx b/apps/mesh/src/web/components/chat/message/assistant.tsx index 79a90c5f4d..ab2156d7d5 100644 --- a/apps/mesh/src/web/components/chat/message/assistant.tsx +++ b/apps/mesh/src/web/components/chat/message/assistant.tsx @@ -14,6 +14,7 @@ import { MessageStatsBar } from "../usage-stats.tsx"; import { MessageTextPart } from "./parts/text-part.tsx"; import { GenericToolCallPart, + OpenInAgentPart, ProposePlanPart, SubtaskPart, UserAskPart, @@ -491,6 +492,14 @@ function MessagePart({ latency={getMeta(part.toolCallId)?.latencySeconds} /> ); + case "tool-open_in_agent": + return ( + + ); case "text": return ( ; + +interface OpenInAgentPartProps { + part: OpenInAgentToolPart; + annotations?: ToolDefinition["annotations"]; + latency?: number; +} + +/** + * Module-level set prevents duplicate stream starts across re-renders + * within the same page session. sessionStorage covers page refreshes. + */ +const startedTasks = new Set(); + +function isAlreadyStarted(taskId: string): boolean { + if (startedTasks.has(taskId)) return true; + try { + return sessionStorage.getItem(`open-in-agent:${taskId}`) === "1"; + } catch { + return false; + } +} + +function markStarted(taskId: string) { + startedTasks.add(taskId); + try { + sessionStorage.setItem(`open-in-agent:${taskId}`, "1"); + } catch { + // sessionStorage might be unavailable + } +} + +export function OpenInAgentPart({ part }: OpenInAgentPartProps) { + const org = useOrg(); + const navigateToAgent = useNavigateToAgent(); + const startFiredRef = useRef(false); + + const agentId = part.input?.agent_id; + const context = part.input?.context; + const agent = useVirtualMCP(agentId); + + const output = part.output as Record | undefined; + const taskId = output?.task_id as string | undefined; + + const rawState = getEffectiveState( + part.state, + "preliminary" in part ? part.preliminary : false, + ); + const isComplete = part.state === "output-available" && !part.preliminary; + const isError = part.state === "output-error"; + const isLoading = rawState === "loading"; + + // Start the agent stream via the standard decopilot/stream endpoint. + // Idempotent: module-level Set (re-renders) + sessionStorage (refreshes). + if ( + isComplete && + taskId && + context && + agentId && + !startFiredRef.current && + !isAlreadyStarted(taskId) + ) { + startFiredRef.current = true; + markStarted(taskId); + + queueMicrotask(() => { + const now = new Date().toISOString(); + fetch(`/api/${org.slug}/decopilot/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + messages: [ + { + id: crypto.randomUUID(), + role: "user", + parts: [{ type: "text", text: context }], + metadata: { + thread_id: taskId, + agent: { id: agentId }, + created_at: now, + }, + }, + ], + thread_id: taskId, + agent: { id: agentId }, + toolApprovalLevel: "auto", + }), + }).catch((err) => + console.error("[open_in_agent] stream start failed:", err), + ); + }); + } + + const title = agent?.title ?? (isError ? "Agent not found" : "Agent"); + + const handleClick = () => { + if (!agentId || !isComplete) return; + navigateToAgent(agentId, { + search: taskId ? { taskId } : undefined, + }); + }; + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleClick(); + } + } + : undefined + } + className={cn( + "flex items-center gap-3 py-2.5 px-1 rounded-md transition-colors", + isComplete && "cursor-pointer [@media(hover:hover)]:hover:bg-accent/30", + isLoading && "shimmer", + )} + > +
+ } + /> +
+ + + {title} + + + {isLoading && } + + {isComplete && ( + <> + Open + + + )} + + {isError && Failed} +
+ ); +} diff --git a/apps/mesh/src/web/components/chat/tiptap/input.tsx b/apps/mesh/src/web/components/chat/tiptap/input.tsx index 44ce7a6357..26ddddb815 100644 --- a/apps/mesh/src/web/components/chat/tiptap/input.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/input.tsx @@ -13,8 +13,8 @@ import { Suspense, useEffect, useImperativeHandle, useRef } from "react"; import type { Metadata } from "../types.ts"; import { FileNode, FileUploader } from "./file"; import { MentionNode } from "./mention"; -import { PromptsMention } from "./mention-prompts.tsx"; -import { ResourcesMention } from "./mention-resources.tsx"; +import { AtMention } from "./mention-at.tsx"; +import { SlashMention } from "./mention-slash.tsx"; import { AiProviderModel } from "@/web/hooks/collections/use-ai-providers.ts"; function buildExtensions(placeholderRef: React.RefObject) { @@ -29,7 +29,7 @@ function buildExtensions(placeholderRef: React.RefObject) { Placeholder.configure({ placeholder: () => placeholderRef.current ?? - "Ask anything, / for prompts, @ for resources...", + "Ask anything, / for prompts, @ for agents & resources...", showOnlyWhenEditable: false, }), MentionNode, @@ -206,14 +206,14 @@ export function TiptapInput({ )} /> - {/* Render prompts dropdown menu (includes dialog) */} + {/* Render slash dropdown menu for prompts + resources (/) */} - + - {/* Render resources dropdown menu */} + {/* Render @ dropdown menu (agents + resources) */} - + {/* Render file upload handler */} diff --git a/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx b/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx new file mode 100644 index 0000000000..6deac69ea8 --- /dev/null +++ b/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx @@ -0,0 +1,252 @@ +/** + * Two-level @ mention: first shows categories (Resources, Agents), + * then drills into items when a category is selected. + */ + +import { KEYS } from "@/web/lib/query-keys"; +import { + isDecopilot, + listResources, + readResource, + useMCPClient, + useProjectContext, + useVirtualMCPs, +} from "@decocms/mesh-sdk"; +import type { ListResourcesResult } from "@modelcontextprotocol/sdk/types.js"; +import { useQueryClient } from "@tanstack/react-query"; +import type { Editor } from "@tiptap/react"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import { BaseItem, insertMention, OnSelectProps, Suggestion } from "./mention"; + +interface AtMentionProps { + editor: Editor; + virtualMcpId: string | null; +} + +type AtMode = "categories" | "agents" | "resources"; + +interface AtItem extends BaseItem { + /** Discriminator for item type */ + kind: "category" | "agent" | "resource"; + /** Agent ID (for agents) */ + agentId?: string; + /** Resource URI (for resources) */ + uri?: string; +} + +const CATEGORY_ITEMS: AtItem[] = [ + { + name: "agents", + title: "Agents", + description: "Mention an agent to hand off work", + kind: "category", + drillable: true, + }, + { + name: "resources", + title: "Resources", + description: "Attach a resource as context", + kind: "category", + drillable: true, + }, +]; + +export const AtMention = ({ editor, virtualMcpId }: AtMentionProps) => { + const queryClient = useQueryClient(); + const { org } = useProjectContext(); + const agents = useVirtualMCPs(); + const client = useMCPClient({ + connectionId: virtualMcpId, + orgId: org.id, + }); + const resourcesQueryKey = KEYS.virtualMcpResources(virtualMcpId, org.id); + + const [mode, setMode] = useState("categories"); + const modeRef = useRef(mode); + modeRef.current = mode; + + // Reset mode when menu closes/opens (query key changes signal re-render) + const queryKey = ["at-mention", org.id, virtualMcpId ?? "default", mode]; + + const handleItemSelect = async ({ + item, + range, + }: OnSelectProps): Promise => { + if (item.kind === "category") { + // Drill into category — keep menu open + setMode(item.name === "agents" ? "agents" : "resources"); + return false; + } + + if (item.kind === "agent" && item.agentId) { + insertMention(editor, range, { + id: item.agentId, + name: item.name, + metadata: { agentId: item.agentId, title: item.name }, + char: "@", + }); + setMode("categories"); + return; + } + + if (item.kind === "resource" && item.uri && client) { + try { + const result = await readResource(client, item.uri); + insertMention(editor, range, { + id: item.uri, + name: item.uri, + metadata: result.contents, + char: "@", + }); + } catch (error) { + console.error("[at-mention] Failed to fetch resource:", error); + toast.error("Failed to load resource. Please try again."); + } + setMode("categories"); + return; + } + }; + + const fetchItems = async (props: { query: string }): Promise => { + const { query } = props; + const currentMode = modeRef.current; + + if (currentMode === "categories") { + if (!query.trim()) return CATEGORY_ITEMS; + + // When typing at the top level, search across both agents and resources + const lq = query.toLowerCase(); + + const matchedAgents: AtItem[] = agents + .filter( + (agent) => + agent.status === "active" && + (!agent.id || !isDecopilot(agent.id)) && + agent.id !== virtualMcpId && + (agent.title.toLowerCase().includes(lq) || + agent.description?.toLowerCase().includes(lq)), + ) + .map((agent) => ({ + name: agent.title, + title: agent.title, + description: agent.description ?? undefined, + icon: agent.icon ?? null, + kind: "agent" as const, + agentId: agent.id, + })); + + const matchedResources: AtItem[] = await (async () => { + if (!client) return []; + let cached = + queryClient.getQueryData(resourcesQueryKey); + if (!cached) { + cached = await queryClient.fetchQuery({ + queryKey: resourcesQueryKey, + queryFn: () => listResources(client), + staleTime: 60000, + }); + } + return (cached?.resources ?? []) + .filter( + (r) => + r.uri.toLowerCase().includes(lq) || + r.name?.toLowerCase().includes(lq) || + r.description?.toLowerCase().includes(lq), + ) + .map((r) => ({ + name: r.name ?? r.uri, + title: r.name, + description: r.description, + kind: "resource" as const, + uri: r.uri, + })); + })(); + + return [...matchedAgents, ...matchedResources]; + } + + if (currentMode === "agents") { + let filtered = agents.filter( + (agent) => + agent.status === "active" && + (!agent.id || !isDecopilot(agent.id)) && + agent.id !== virtualMcpId, + ); + if (query.trim()) { + const lq = query.toLowerCase(); + filtered = filtered.filter( + (a) => + a.title.toLowerCase().includes(lq) || + a.description?.toLowerCase().includes(lq), + ); + } + return filtered.map((agent) => ({ + name: agent.title, + title: agent.title, + description: agent.description ?? undefined, + icon: agent.icon ?? null, + kind: "agent" as const, + agentId: agent.id, + })); + } + + // resources + if (!client) return []; + + let cached = + queryClient.getQueryData(resourcesQueryKey); + if (!cached) { + cached = await queryClient.fetchQuery({ + queryKey: resourcesQueryKey, + queryFn: () => listResources(client), + staleTime: 60000, + }); + } else { + queryClient + .fetchQuery({ + queryKey: resourcesQueryKey, + queryFn: () => listResources(client), + staleTime: 60000, + }) + .catch(() => {}); + } + + let resources = cached?.resources ?? []; + if (query.trim()) { + const lq = query.toLowerCase(); + resources = resources.filter( + (r) => + r.uri.toLowerCase().includes(lq) || + r.name?.toLowerCase().includes(lq) || + r.description?.toLowerCase().includes(lq), + ); + } + + return resources.map((r) => ({ + name: r.name ?? r.uri, + title: r.name, + description: r.description, + kind: "resource" as const, + uri: r.uri, + })); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + setMode("categories"); + } + }; + + return ( + + editor={editor} + char="@" + pluginKey="atDropdownMenu" + queryKey={queryKey} + queryFn={fetchItems} + onSelect={handleItemSelect} + onOpenChange={handleOpenChange} + /> + ); +}; diff --git a/apps/mesh/src/web/components/chat/tiptap/mention-prompts.tsx b/apps/mesh/src/web/components/chat/tiptap/mention-prompts.tsx deleted file mode 100644 index 1ec1bab2d3..0000000000 --- a/apps/mesh/src/web/components/chat/tiptap/mention-prompts.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { - getGatewayClientId, - stripToolNamespace, -} from "@decocms/mcp-utils/aggregate"; -import { KEYS } from "@/web/lib/query-keys"; -import { - getPrompt, - listPrompts, - useMCPClient, - useProjectContext, -} from "@decocms/mesh-sdk"; -import { usePromptConnectionMap } from "@/web/components/chat/use-prompt-connection-map"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import type { - ListPromptsResult, - Prompt, -} from "@modelcontextprotocol/sdk/types.js"; -import { useQueryClient } from "@tanstack/react-query"; -import type { Editor, Range } from "@tiptap/react"; -import { useRef, useState } from "react"; -import { toast } from "sonner"; -import { - PromptArgsDialog, - type PromptArgumentValues, -} from "../dialog-prompt-arguments.tsx"; -import { insertMention, OnSelectProps, Suggestion } from "./mention"; - -interface PromptSelectContext { - range: Range; - item: Prompt; -} - -interface PromptsMentionProps { - editor: Editor; - virtualMcpId: string | null; -} - -/** - * Fetches a prompt and inserts it as a mention node in the editor. - */ -async function fetchAndInsertPrompt( - editor: Editor, - range: Range, - client: Client, - promptName: string, - clientId: string | undefined, - values?: PromptArgumentValues, -) { - try { - const result = await getPrompt(client, promptName, values); - - insertMention(editor, range, { - id: promptName, - name: stripToolNamespace(promptName, clientId), - metadata: result.messages, - char: "/", - }); - } catch (error) { - console.error("[prompt] Failed to fetch prompt:", error); - toast.error("Failed to load prompt. Please try again."); - } -} - -export const PromptsMention = ({ - editor, - virtualMcpId, -}: PromptsMentionProps) => { - const queryClient = useQueryClient(); - const { org } = useProjectContext(); - const client = useMCPClient({ - connectionId: virtualMcpId, - orgId: org.id, - }); - const promptToConnection = usePromptConnectionMap(virtualMcpId, org.id); - const promptToConnectionRef = useRef(promptToConnection); - promptToConnectionRef.current = promptToConnection; - // Use the query key helper which handles null (default virtual MCP) - const queryKey = KEYS.virtualMcpPrompts(virtualMcpId, org.id); - const [activePrompt, setActivePrompt] = useState( - null, - ); - - const handleItemSelect = async ({ item, range }: OnSelectProps) => { - // If prompt has arguments, open dialog - if (item.arguments && item.arguments.length > 0) { - setActivePrompt({ range, item: item }); - return; - } - - // No arguments - fetch and insert directly - if (!client) return; - const clientId = getGatewayClientId(item._meta); - await fetchAndInsertPrompt(editor, range, client, item.name, clientId); - }; - - const handleDialogSubmit = async (values: PromptArgumentValues) => { - if (!activePrompt || !client) return; - - const { range, item: prompt } = activePrompt; - const clientId = getGatewayClientId(prompt._meta); - await fetchAndInsertPrompt( - editor, - range, - client, - prompt.name, - clientId, - values, - ); - setActivePrompt(null); - }; - - const fetchItems = async (props: { query: string }) => { - const { query } = props; - - if (!client) return []; - - // Try to get from cache first (even if stale) - let virtualMcpPrompts = - queryClient.getQueryData(queryKey); - - // If not in cache or we want fresh data, fetch from network - // fetchQuery will use cache if fresh, otherwise fetch - if (!virtualMcpPrompts) { - const result = await queryClient.fetchQuery({ - queryKey, - queryFn: () => listPrompts(client), - staleTime: 60000, // 1 minute - }); - virtualMcpPrompts = result; - } else { - // Prefetch in background to ensure fresh data - queryClient - .fetchQuery({ - queryKey, - queryFn: () => listPrompts(client), - staleTime: 60000, - }) - .catch(() => { - // Ignore errors in background fetch - }); - } - - // Ensure we have prompts (fallback to empty array) - if (!virtualMcpPrompts.prompts) { - return []; - } - - // Filter prompts based on query - let filteredPrompts = virtualMcpPrompts.prompts; - if (query.trim()) { - const lowerQuery = query.toLowerCase(); - filteredPrompts = virtualMcpPrompts.prompts.filter( - (p) => - p.name.toLowerCase().includes(lowerQuery) || - p.title?.toLowerCase().includes(lowerQuery) || - p.description?.toLowerCase().includes(lowerQuery), - ); - } - - return filteredPrompts.map((p) => ({ - ...p, - icon: promptToConnectionRef.current.get(p.name)?.icon ?? null, - })); - }; - - return ( - <> - - editor={editor} - char="/" - pluginKey="promptsDropdownMenu" - queryKey={queryKey} - queryFn={fetchItems} - onSelect={handleItemSelect} - /> - setActivePrompt(null)} - onSubmit={handleDialogSubmit} - /> - - ); -}; diff --git a/apps/mesh/src/web/components/chat/tiptap/mention-resources.tsx b/apps/mesh/src/web/components/chat/tiptap/mention-resources.tsx deleted file mode 100644 index aec61f4e5c..0000000000 --- a/apps/mesh/src/web/components/chat/tiptap/mention-resources.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { KEYS } from "@/web/lib/query-keys"; -import { - listResources, - readResource, - useMCPClient, - useProjectContext, -} from "@decocms/mesh-sdk"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import type { ListResourcesResult } from "@modelcontextprotocol/sdk/types.js"; -import { useQueryClient } from "@tanstack/react-query"; -import type { Editor, Range } from "@tiptap/react"; -import { toast } from "sonner"; -import { BaseItem, insertMention, OnSelectProps, Suggestion } from "./mention"; - -interface ResourcesMentionProps { - editor: Editor; - virtualMcpId: string | null; -} - -interface ResourceItem extends BaseItem { - uri: string; -} - -/** - * Fetches a resource and inserts it as a mention node in the editor. - */ -async function fetchAndInsertResource( - editor: Editor, - range: Range, - client: Client, - resourceUri: string, -) { - try { - const result = await readResource(client, resourceUri); - - insertMention(editor, range, { - id: resourceUri, - name: resourceUri, - metadata: result.contents, - char: "@", - }); - } catch (error) { - console.error("[resource] Failed to fetch resource:", error); - toast.error("Failed to load resource. Please try again."); - } -} - -export const ResourcesMention = ({ - editor, - virtualMcpId, -}: ResourcesMentionProps) => { - const queryClient = useQueryClient(); - const { org } = useProjectContext(); - const client = useMCPClient({ - connectionId: virtualMcpId, - orgId: org.id, - }); - // Use the query key helper which handles null (default virtual MCP) - const queryKey = KEYS.virtualMcpResources(virtualMcpId, org.id); - - const handleItemSelect = async ({ - item, - range, - }: OnSelectProps) => { - if (!client) return; - await fetchAndInsertResource(editor, range, client, item.uri); - }; - - const fetchItems = async (props: { query: string }) => { - const { query } = props; - - if (!client) return []; - - // Try to get from cache first (even if stale) - let virtualMcpResources = - queryClient.getQueryData(queryKey); - - // If not in cache or we want fresh data, fetch from network - // fetchQuery will use cache if fresh, otherwise fetch - if (!virtualMcpResources) { - const result = await queryClient.fetchQuery({ - queryKey, - queryFn: () => listResources(client), - staleTime: 60000, // 1 minute - }); - virtualMcpResources = result; - } else { - // Prefetch in background to ensure fresh data - queryClient - .fetchQuery({ - queryKey, - queryFn: () => listResources(client), - staleTime: 60000, - }) - .catch(() => { - // Ignore errors in background fetch - }); - } - - // Ensure we have resources (fallback to empty array) - if (!virtualMcpResources.resources) { - return []; - } - - // Filter resources based on query - let filteredResources = virtualMcpResources.resources; - if (query.trim()) { - const lowerQuery = query.toLowerCase(); - filteredResources = virtualMcpResources.resources.filter( - (r) => - r.uri.toLowerCase().includes(lowerQuery) || - r.name?.toLowerCase().includes(lowerQuery) || - r.description?.toLowerCase().includes(lowerQuery), - ); - } - - // Map Resource to ResourceItem format (extends BaseItem) - return filteredResources.map((r) => ({ - name: r.name ?? r.uri, - title: r.name, - description: r.description, - uri: r.uri, - })) as ResourceItem[]; - }; - - return ( - - editor={editor} - char="@" - pluginKey="resourcesDropdownMenu" - queryKey={queryKey} - queryFn={fetchItems} - onSelect={handleItemSelect} - /> - ); -}; diff --git a/apps/mesh/src/web/components/chat/tiptap/mention-slash.tsx b/apps/mesh/src/web/components/chat/tiptap/mention-slash.tsx new file mode 100644 index 0000000000..ad54cf69d6 --- /dev/null +++ b/apps/mesh/src/web/components/chat/tiptap/mention-slash.tsx @@ -0,0 +1,291 @@ +/** + * Unified slash (/) mention component that combines prompts and resources + * into a single dropdown. Prompts appear first, followed by resources. + */ + +import { + getGatewayClientId, + stripToolNamespace, +} from "@decocms/mcp-utils/aggregate"; +import { KEYS } from "@/web/lib/query-keys"; +import { + getPrompt, + listPrompts, + listResources, + readResource, + useMCPClient, + useProjectContext, +} from "@decocms/mesh-sdk"; +import { usePromptConnectionMap } from "@/web/components/chat/use-prompt-connection-map"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { + ListPromptsResult, + ListResourcesResult, + Prompt, +} from "@modelcontextprotocol/sdk/types.js"; +import { useQueryClient } from "@tanstack/react-query"; +import type { Editor, Range } from "@tiptap/react"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import { + PromptArgsDialog, + type PromptArgumentValues, +} from "../dialog-prompt-arguments.tsx"; +import { BaseItem, insertMention, OnSelectProps, Suggestion } from "./mention"; + +interface SlashMentionProps { + editor: Editor; + virtualMcpId: string | null; +} + +interface SlashItem extends BaseItem { + kind: "prompt" | "resource"; + /** For resources */ + uri?: string; + /** For prompts - arguments definition */ + arguments?: Prompt["arguments"]; + /** For prompts - MCP metadata */ + _meta?: Prompt["_meta"]; +} + +interface PromptSelectContext { + range: Range; + item: SlashItem; +} + +async function fetchAndInsertPrompt( + editor: Editor, + range: Range, + client: Client, + promptName: string, + clientId: string | undefined, + values?: PromptArgumentValues, +) { + try { + const result = await getPrompt(client, promptName, values); + + insertMention(editor, range, { + id: promptName, + name: stripToolNamespace(promptName, clientId), + metadata: result.messages, + char: "/", + }); + } catch (error) { + console.error("[slash] Failed to fetch prompt:", error); + toast.error("Failed to load prompt. Please try again."); + } +} + +async function fetchAndInsertResource( + editor: Editor, + range: Range, + client: Client, + resourceUri: string, +) { + try { + const result = await readResource(client, resourceUri); + + insertMention(editor, range, { + id: resourceUri, + name: resourceUri, + metadata: result.contents, + char: "/", + }); + } catch (error) { + console.error("[slash] Failed to fetch resource:", error); + toast.error("Failed to load resource. Please try again."); + } +} + +export const SlashMention = ({ editor, virtualMcpId }: SlashMentionProps) => { + const queryClient = useQueryClient(); + const { org } = useProjectContext(); + const client = useMCPClient({ + connectionId: virtualMcpId, + orgId: org.id, + }); + const promptToConnection = usePromptConnectionMap(virtualMcpId, org.id); + const promptToConnectionRef = useRef(promptToConnection); + promptToConnectionRef.current = promptToConnection; + + const promptsQueryKey = KEYS.virtualMcpPrompts(virtualMcpId, org.id); + const resourcesQueryKey = KEYS.virtualMcpResources(virtualMcpId, org.id); + // Combined key for the suggestion dropdown + const queryKey = [ + "slash-mention", + org.id, + virtualMcpId ?? "default", + ] as const; + + const [activePrompt, setActivePrompt] = useState( + null, + ); + + const handleItemSelect = async ({ + item, + range, + }: OnSelectProps) => { + if (!client) return; + + if (item.kind === "prompt") { + // If prompt has arguments, open dialog + if (item.arguments && item.arguments.length > 0) { + setActivePrompt({ range, item }); + return; + } + const clientId = getGatewayClientId(item._meta); + await fetchAndInsertPrompt(editor, range, client, item.name, clientId); + } else { + // Resource + if (item.uri) { + await fetchAndInsertResource(editor, range, client, item.uri); + } + } + }; + + const handleDialogSubmit = async (values: PromptArgumentValues) => { + if (!activePrompt || !client) return; + + const { range, item } = activePrompt; + const clientId = getGatewayClientId(item._meta); + await fetchAndInsertPrompt( + editor, + range, + client, + item.name, + clientId, + values, + ); + setActivePrompt(null); + }; + + const fetchItems = async (props: { query: string }): Promise => { + const { query } = props; + if (!client) return []; + + // Fetch prompts and resources in parallel + const [prompts, resources] = await Promise.all([ + fetchPrompts(queryClient, promptsQueryKey, client), + fetchResources(queryClient, resourcesQueryKey, client), + ]); + + const lowerQuery = query.trim().toLowerCase(); + + // Build prompt items + const promptItems: SlashItem[] = (prompts ?? []) + .filter( + (p) => + !lowerQuery || + p.name.toLowerCase().includes(lowerQuery) || + p.title?.toLowerCase().includes(lowerQuery) || + p.description?.toLowerCase().includes(lowerQuery), + ) + .map((p) => ({ + name: p.name, + title: p.title, + description: p.description, + icon: promptToConnectionRef.current.get(p.name)?.icon ?? null, + kind: "prompt" as const, + arguments: p.arguments, + _meta: p._meta, + })); + + // Build resource items + const resourceItems: SlashItem[] = (resources ?? []) + .filter( + (r) => + !lowerQuery || + r.uri.toLowerCase().includes(lowerQuery) || + r.name?.toLowerCase().includes(lowerQuery) || + r.description?.toLowerCase().includes(lowerQuery), + ) + .map((r) => ({ + name: r.name ?? r.uri, + title: r.name, + description: r.description, + kind: "resource" as const, + uri: r.uri, + })); + + // Prompts first, then resources + return [...promptItems, ...resourceItems]; + }; + + // Build a dialog-compatible prompt object from SlashItem + const dialogPrompt = + activePrompt?.item.kind === "prompt" + ? ({ + name: activePrompt.item.name, + arguments: activePrompt.item.arguments, + description: activePrompt.item.description, + } as Prompt) + : null; + + return ( + <> + + editor={editor} + char="/" + pluginKey="slashDropdownMenu" + queryKey={queryKey} + queryFn={fetchItems} + onSelect={handleItemSelect} + /> + setActivePrompt(null)} + onSubmit={handleDialogSubmit} + /> + + ); +}; + +// ── Helpers ────────────────────────────────────────────────────────────── + +async function fetchPrompts( + queryClient: ReturnType, + queryKey: readonly unknown[], + client: Client, +) { + let cached = queryClient.getQueryData(queryKey); + if (!cached) { + cached = await queryClient.fetchQuery({ + queryKey, + queryFn: () => listPrompts(client), + staleTime: 60000, + }); + } else { + queryClient + .fetchQuery({ + queryKey, + queryFn: () => listPrompts(client), + staleTime: 60000, + }) + .catch(() => {}); + } + return cached?.prompts ?? []; +} + +async function fetchResources( + queryClient: ReturnType, + queryKey: readonly unknown[], + client: Client, +) { + let cached = queryClient.getQueryData(queryKey); + if (!cached) { + cached = await queryClient.fetchQuery({ + queryKey, + queryFn: () => listResources(client), + staleTime: 60000, + }); + } else { + queryClient + .fetchQuery({ + queryKey, + queryFn: () => listResources(client), + staleTime: 60000, + }) + .catch(() => {}); + } + return cached?.resources ?? []; +} diff --git a/apps/mesh/src/web/components/chat/tiptap/mention/hooks.ts b/apps/mesh/src/web/components/chat/tiptap/mention/hooks.ts index fd172b7393..cc9f563334 100644 --- a/apps/mesh/src/web/components/chat/tiptap/mention/hooks.ts +++ b/apps/mesh/src/web/components/chat/tiptap/mention/hooks.ts @@ -26,6 +26,8 @@ export interface BaseItem { name: string; description?: string; icon?: string | null; + /** Show an Enter key hint — indicates this item drills into a submenu */ + drillable?: boolean; } export type OnSelectProps = { @@ -40,8 +42,9 @@ export interface UseSuggestionOptions { queryKey: readonly unknown[]; /** Async function to fetch items based on query */ queryFn: (props: { query: string }) => Promise; - /** Callback executed when a suggestion is selected. Can be async - menu will show loading state until resolved. */ - onSelect: (props: OnSelectProps) => void | Promise; + /** Callback executed when a suggestion is selected. Can be async - menu will show loading state until resolved. + * Return false to keep the menu open (e.g. for drill-in navigation). */ + onSelect: (props: OnSelectProps) => void | false | Promise; } export interface UseSuggestionReturn { @@ -332,10 +335,17 @@ export function useMentionState({ editor, char, pluginKey, + allow: customAllow, + onOpenChange, }: { editor: Editor; char: string; pluginKey: string | PluginKey; + allow?: (props: { + state: unknown; + range: { from: number; to: number }; + }) => boolean; + onOpenChange?: (open: boolean) => void; }) { // Create the reducer state here - this is the source of truth const [state, dispatch] = useReducer(reducer, { @@ -346,6 +356,10 @@ export function useMentionState({ selectedItem: null, }); + // Ref for onOpenChange to avoid stale closures in the plugin + const onOpenChangeRef = useRef(onOpenChange); + onOpenChangeRef.current = onOpenChange; + // Register the suggestion plugin here at the top level // This ensures it's always active even when the menu is closed // eslint-disable-next-line ban-use-effect/ban-use-effect @@ -380,6 +394,11 @@ export function useMentionState({ } } + // Delegate to custom allow if provided + if (customAllow && !customAllow(props)) { + return false; + } + return true; }, @@ -397,6 +416,7 @@ export function useMentionState({ range: props.range, }, }); + onOpenChangeRef.current?.(true); }, onUpdate: (props: SuggestionProps) => { @@ -420,6 +440,7 @@ export function useMentionState({ onExit: () => { dispatch({ type: "ON_EXIT" }); + onOpenChangeRef.current?.(false); }, }), @@ -433,7 +454,7 @@ export function useMentionState({ editor.unregisterPlugin(key); } }; - }, [editor, pluginKey, char, dispatch]); + }, [editor, pluginKey, char, dispatch, customAllow]); return { state, dispatch }; } @@ -485,7 +506,6 @@ export function useSuggestion({ dispatch({ type: "SET_SELECTED_ITEM", payload: item }); try { - // Add logging for debugging selection behavior const { view } = editor; // Calculate the range to use @@ -500,8 +520,15 @@ export function useSuggestion({ } // Call the global onSelect (may be async) - await onItemSelect({ range: rangeToUse, item: item as T }); - } finally { + // Return false to keep the menu open (drill-in navigation) + const result = await onItemSelect({ range: rangeToUse, item: item as T }); + if (result === false) { + // Drill-in: reset selected item but keep menu open + dispatch({ type: "SET_SELECTED_ITEM", payload: null }); + } else { + close(); + } + } catch { close(); } }; diff --git a/apps/mesh/src/web/components/chat/tiptap/mention/node.tsx b/apps/mesh/src/web/components/chat/tiptap/mention/node.tsx index 6c2e4887fc..054e50286c 100644 --- a/apps/mesh/src/web/components/chat/tiptap/mention/node.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/mention/node.tsx @@ -20,7 +20,7 @@ export interface MentionAttrs { name: string; /** Additional metadata (e.g., prompt messages) */ metadata: T; - /** Character that triggered the mention */ + /** Character that triggered the mention ("/" prompts+resources, "@" agents) */ char?: "/" | "@"; } @@ -65,7 +65,7 @@ function MentionNodeView(props: NodeViewProps) { const { name, char } = node.attrs as MentionAttrs; const isSelected = selected && view.editable; - const isResource = char === "@"; + const isAgent = char === "@"; return ( { /** The Tiptap editor instance */ editor: Editor; - /** Trigger character (e.g., "/" for prompts, "@" for resources) */ + /** Trigger character (e.g., "/" for prompts, "@" for resources, "@@" for agents) */ char: string; /** Unique key for the suggestion plugin */ pluginKey: string | PluginKey; @@ -48,8 +49,15 @@ interface SuggestionSelectProps { queryKey: readonly unknown[]; /** Async function to fetch items based on query */ queryFn: (props: { query: string }) => Promise; - /** Callback executed when a suggestion is selected */ - onSelect: (props: OnSelectProps) => void | Promise; + /** Callback executed when a suggestion is selected. Return false to keep menu open (drill-in). */ + onSelect: (props: OnSelectProps) => void | false | Promise; + /** Optional custom allow function to control when suggestion triggers */ + allow?: (props: { + state: unknown; + range: { from: number; to: number }; + }) => boolean; + /** Called when the menu opens or closes */ + onOpenChange?: (open: boolean) => void; } interface MentionItemProps { @@ -64,7 +72,7 @@ interface MentionItemListProps { editor: Editor; queryKey: readonly unknown[]; queryFn: (props: { query: string }) => Promise; - onSelect: (props: OnSelectProps) => void | Promise; + onSelect: (props: OnSelectProps) => void | false | Promise; } /** @@ -137,6 +145,7 @@ const MentionItem = ({ const clientId = getGatewayClientId((item as Record)._meta); const name = item.title || displayToolName(item.name, clientId); const description = item.description || null; + const icon = item.icon; return (
({ isLoading && "pointer-events-none opacity-50", )} > + {icon !== undefined && ( + + )}
@@ -163,6 +175,11 @@ const MentionItem = ({
)}
+ {item.drillable && ( + + ↵ + + )}
); }; @@ -257,11 +274,15 @@ export function Suggestion({ queryKey, queryFn, onSelect, + allow, + onOpenChange, }: SuggestionSelectProps) { const { state, dispatch } = useMentionState({ editor, char, pluginKey, + allow, + onOpenChange, }); // Provide both state and dispatch to children (for useSuggestion) diff --git a/apps/mesh/src/web/components/chat/types.ts b/apps/mesh/src/web/components/chat/types.ts index 61b84c2280..bab90e502d 100644 --- a/apps/mesh/src/web/components/chat/types.ts +++ b/apps/mesh/src/web/components/chat/types.ts @@ -81,6 +81,8 @@ export interface Metadata { system?: string; /** Tiptap document for rich user input (includes prompt tags with resources) */ tiptapDoc?: TiptapDoc; + /** Agent mentions in this message — used to render delegation cards */ + agentMentions?: Array<{ agentId: string; title: string; taskId?: string }>; /** Tool approval level at send time — used for visual treatment (e.g., purple border for plan mode) */ toolApprovalLevel?: ToolApprovalLevel; usage?: { diff --git a/apps/mesh/src/web/lib/query-keys.ts b/apps/mesh/src/web/lib/query-keys.ts index 0ff425ef71..7f7dd5ed50 100644 --- a/apps/mesh/src/web/lib/query-keys.ts +++ b/apps/mesh/src/web/lib/query-keys.ts @@ -182,6 +182,10 @@ export const KEYS = { rawToolName, ] as const, + // Virtual MCP agents (for agent mentions in chat) + virtualMcpAgents: (orgId: string) => + ["virtual-mcp", orgId, "agents"] as const, + // Virtual MCP prompts (for ice breakers in chat) // null virtualMcpId means default virtual MCP virtualMcpPrompts: (virtualMcpId: string | null, orgId: string) =>