From 3d344a5ee2bd8e1a85079437d36a688432798e02 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Thu, 9 Apr 2026 19:36:11 -0300 Subject: [PATCH 1/7] feat(chat): add @agent mentions with open_in_agent tool Adds agent mentions to the chat input (`@` trigger) that delegate work to other agents via a new `open_in_agent` built-in tool. Also unifies prompts and resources under a single `/` trigger. - `@` trigger shows agent dropdown with icons (via AgentAvatar) - `/` trigger shows unified prompts + resources dropdown - New `open_in_agent` server-side tool: validates agent, creates thread, saves user message, runs agent in background - Custom tool call renderer with clickable navigation card - Agent mention nodes styled in violet, slash mentions in amber Co-Authored-By: Claude Opus 4.6 (1M context) --- .../routes/decopilot/built-in-tools/index.ts | 17 +- .../decopilot/built-in-tools/open-in-agent.ts | 232 ++++++++++++++ .../built-in-tools/registration.test.ts | 1 + .../built-in-tools/user-ask.e2e.test.ts | 1 + .../src/api/routes/decopilot/constants.ts | 2 +- .../src/api/routes/decopilot/stream-core.ts | 1 + .../src/web/components/chat/derive-parts.ts | 93 +++++- .../web/components/chat/message/assistant.tsx | 9 + .../message/parts/tool-call-part/index.ts | 1 + .../parts/tool-call-part/open-in-agent.tsx | 98 ++++++ .../src/web/components/chat/tiptap/input.tsx | 14 +- .../components/chat/tiptap/mention-agents.tsx | 75 +++++ .../chat/tiptap/mention-prompts.tsx | 183 ----------- .../chat/tiptap/mention-resources.tsx | 136 -------- .../components/chat/tiptap/mention-slash.tsx | 291 ++++++++++++++++++ .../components/chat/tiptap/mention/hooks.ts | 12 +- .../components/chat/tiptap/mention/node.tsx | 8 +- .../chat/tiptap/mention/suggestion.tsx | 14 +- apps/mesh/src/web/components/chat/types.ts | 2 + apps/mesh/src/web/lib/query-keys.ts | 4 + 20 files changed, 850 insertions(+), 344 deletions(-) create mode 100644 apps/mesh/src/api/routes/decopilot/built-in-tools/open-in-agent.ts create mode 100644 apps/mesh/src/web/components/chat/message/parts/tool-call-part/open-in-agent.tsx create mode 100644 apps/mesh/src/web/components/chat/tiptap/mention-agents.tsx delete mode 100644 apps/mesh/src/web/components/chat/tiptap/mention-prompts.tsx delete mode 100644 apps/mesh/src/web/components/chat/tiptap/mention-resources.tsx create mode 100644 apps/mesh/src/web/components/chat/tiptap/mention-slash.tsx 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..9d7aa99e5e 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,6 +13,7 @@ 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"; @@ -24,6 +25,7 @@ export interface BuiltinToolParams { 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, @@ -77,8 +80,19 @@ function buildAllTools( toolOutputMap, }), }; - // subtask requires a provider (LLM calls) — skip when provider is null (Claude Code) + // subtask and open_in_agent require a provider (LLM calls) — skip when provider is null (Claude Code) if (provider) { + tools.open_in_agent = createOpenInAgentTool( + writer, + { + provider, + organization, + userId, + models, + needsApproval: toolNeedsApproval(toolApprovalLevel, false) !== false, + }, + ctx, + ); tools.subtask = createSubtaskTool( writer, { @@ -99,6 +113,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..080f87293b --- /dev/null +++ b/apps/mesh/src/api/routes/decopilot/built-in-tools/open-in-agent.ts @@ -0,0 +1,232 @@ +/** + * open_in_agent Built-in Tool + * + * Creates a task (thread) in a target agent, saves the context as the + * initial user message, and kicks off the agent run in the background. + * Returns immediately with a taskId so the client can navigate to the + * running task in the agent's UI. + */ + +import type { MeshContext, OrganizationScope } from "@/core/mesh-context"; +import { createVirtualClientFrom } from "@/mcp-clients/virtual-mcp"; +import type { UIMessageStreamWriter } from "ai"; +import { stepCountIs, streamText, tool, zodSchema } from "ai"; +import { z } from "zod"; +import { + DEFAULT_MAX_TOKENS, + SUBAGENT_EXCLUDED_TOOLS, + SUBAGENT_STEP_LIMIT, +} from "../constants"; +import { toolsFromMCP } from "../helpers"; +import type { ModelsConfig } from "../types"; +import type { MeshProvider } from "@/ai-providers/types"; +import { createLanguageModel } from "../stream-core"; + +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 { + provider: MeshProvider; + organization: OrganizationScope; + userId: string; + models: ModelsConfig; + needsApproval?: boolean; +} + +const ANNOTATIONS = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} as const; + +export function createOpenInAgentTool( + writer: UIMessageStreamWriter, + params: OpenInAgentParams, + ctx: MeshContext, +) { + const { provider, organization, userId, models, needsApproval } = params; + + return tool({ + description, + inputSchema: zodSchema(OpenInAgentInputSchema), + needsApproval, + execute: async ({ agent_id, context }, options) => { + const startTime = performance.now(); + try { + // 1. Validate agent + 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"); + } + + // 2. Create thread + 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, + }); + + // 3. Save user message to thread + const now = new Date().toISOString(); + const userMessageId = crypto.randomUUID(); + await ctx.storage.threads.saveMessages([ + { + id: userMessageId, + thread_id: taskId, + role: "user" as const, + parts: [{ type: "text", text: context }], + created_at: now, + updated_at: now, + }, + ]); + + // 4. Fire-and-forget: run the agent in the background + runAgentInBackground({ + virtualMcp, + taskId, + context, + provider, + models, + ctx, + }); + + 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 }, + }); + } + }, + }); +} + +/** + * Runs the target agent in the background. Does not block the parent. + * Saves the assistant response to the thread when complete. + */ +function runAgentInBackground(params: { + virtualMcp: NonNullable< + Awaited> + >; + taskId: string; + context: string; + provider: MeshProvider; + models: ModelsConfig; + ctx: MeshContext; +}) { + const { virtualMcp, taskId, context, provider, models, ctx } = params; + + // Use a no-op writer since we're not streaming to the parent + const noopWriter = { + write: () => {}, + merge: () => {}, + } as unknown as UIMessageStreamWriter; + + (async () => { + try { + const mcpClient = await createVirtualClientFrom( + virtualMcp, + ctx, + "passthrough", + ); + + const mcpTools = await toolsFromMCP( + mcpClient, + new Map(), + noopWriter, + "auto", + { disableOutputTruncation: true }, + ); + + const agentTools = Object.fromEntries( + Object.entries(mcpTools).filter( + ([name]) => !SUBAGENT_EXCLUDED_TOOLS.includes(name), + ), + ); + + const serverInstructions = mcpClient.getInstructions(); + + const result = streamText({ + model: createLanguageModel(provider, models.thinking), + system: serverInstructions + ? [{ role: "system" as const, content: serverInstructions }] + : [], + prompt: context, + tools: agentTools, + stopWhen: stepCountIs(SUBAGENT_STEP_LIMIT), + maxOutputTokens: + models.thinking.limits?.maxOutputTokens ?? DEFAULT_MAX_TOKENS, + onError: (error) => { + console.error(`[open_in_agent:${virtualMcp.id}] Error`, error); + }, + }); + + // Wait for completion and save assistant response + const text = await result.text; + const now = new Date().toISOString(); + await ctx.storage.threads.saveMessages([ + { + id: crypto.randomUUID(), + thread_id: taskId, + role: "assistant" as const, + parts: [{ type: "text", text }], + created_at: now, + updated_at: now, + }, + ]); + + mcpClient.close().catch(() => {}); + console.log( + `[open_in_agent] Completed task ${taskId} for agent ${virtualMcp.title}`, + ); + } catch (error) { + console.error( + `[open_in_agent] Background run failed for task ${taskId}:`, + error, + ); + } + })(); +} 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..6eaebebe09 100644 --- a/apps/mesh/src/web/components/chat/derive-parts.ts +++ b/apps/mesh/src/web/components/chat/derive-parts.ts @@ -198,18 +198,43 @@ 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)); + // Agent mentions: instruct the AI to use open_in_agent tool + const meta = node.attrs.metadata as { + agentId?: string; + title?: string; + } | null; + if (meta?.agentId) { + parts.push({ + type: "text", + text: + `[OPEN IN AGENT: ${meta.title ?? node.attrs.name} (agent_id: ${meta.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 { - // 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; @@ -254,3 +279,51 @@ export function tiptapDocToMessages(doc: Metadata["tiptapDoc"]): ChatMessage[] { }, ]; } + +/** + * Agent mention extracted from a tiptap document. + */ +export interface AgentMention { + agentId: string; + title: string; +} + +/** + * Walks a tiptap document and extracts all @agent mentions. + */ +export function extractAgentMentions( + doc: Metadata["tiptapDoc"], +): AgentMention[] { + if (!doc) return []; + + const mentions: AgentMention[] = []; + + const walk = (node: Record) => { + if (!node) return; + + if (node.type === "mention" && node.attrs) { + const attrs = node.attrs as Record; + if (attrs.char === "@") { + const meta = attrs.metadata as { + agentId?: string; + title?: string; + } | null; + if (meta?.agentId) { + mentions.push({ + agentId: meta.agentId, + title: meta.title ?? String(attrs.name ?? ""), + }); + } + } + } + + if ("content" in node && Array.isArray(node.content)) { + for (const child of node.content) { + walk(child as Record); + } + } + }; + + walk(doc as unknown as Record); + return mentions; +} 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; +} + +export function OpenInAgentPart({ part }: OpenInAgentPartProps) { + const navigateToAgent = useNavigateToAgent(); + + const agentId = part.input?.agent_id; + const agent = useVirtualMCP(agentId); + + // task_id comes from the tool's output (may be typed as unknown) + 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"; + + const title = agent?.title ?? (isError ? "Agent not found" : "Agent"); + + const handleClick = () => { + if (!agentId || !isComplete) return; + console.log("[open_in_agent] navigating", { agentId, taskId, output }); + // Navigate directly to the already-running task + 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..104009bbf3 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 { AgentsMention } from "./mention-agents.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 & resources, @ for agents...", 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 agents dropdown menu (@) */} - + {/* Render file upload handler */} diff --git a/apps/mesh/src/web/components/chat/tiptap/mention-agents.tsx b/apps/mesh/src/web/components/chat/tiptap/mention-agents.tsx new file mode 100644 index 0000000000..1621e2ab59 --- /dev/null +++ b/apps/mesh/src/web/components/chat/tiptap/mention-agents.tsx @@ -0,0 +1,75 @@ +import { KEYS } from "@/web/lib/query-keys"; +import { + isDecopilot, + useProjectContext, + useVirtualMCPs, +} from "@decocms/mesh-sdk"; +import type { Editor } from "@tiptap/react"; +import { BaseItem, insertMention, OnSelectProps, Suggestion } from "./mention"; + +interface AgentsMentionProps { + editor: Editor; + virtualMcpId: string | null; +} + +interface AgentItem extends BaseItem { + agentId: string; +} + +export const AgentsMention = ({ editor, virtualMcpId }: AgentsMentionProps) => { + const { org } = useProjectContext(); + const agents = useVirtualMCPs(); + const queryKey = KEYS.virtualMcpAgents(org.id); + + const handleItemSelect = async ({ + item, + range, + }: OnSelectProps) => { + insertMention(editor, range, { + id: item.agentId, + name: item.name, + metadata: { agentId: item.agentId, title: item.name }, + char: "@", + }); + }; + + const fetchItems = async (props: { query: string }) => { + const { query } = props; + + // Filter out Decopilot and the currently active agent + let filtered = agents.filter( + (agent) => + agent.status === "active" && + (!agent.id || !isDecopilot(agent.id)) && + agent.id !== virtualMcpId, + ); + + if (query.trim()) { + const lowerQuery = query.toLowerCase(); + filtered = filtered.filter( + (agent) => + agent.title.toLowerCase().includes(lowerQuery) || + agent.description?.toLowerCase().includes(lowerQuery), + ); + } + + return filtered.map((agent) => ({ + name: agent.title, + title: agent.title, + description: agent.description ?? undefined, + icon: agent.icon ?? null, + agentId: agent.id, + })) as AgentItem[]; + }; + + return ( + + editor={editor} + char="@" + pluginKey="agentsDropdownMenu" + queryKey={queryKey} + queryFn={fetchItems} + onSelect={handleItemSelect} + /> + ); +}; 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..a19c6ded19 100644 --- a/apps/mesh/src/web/components/chat/tiptap/mention/hooks.ts +++ b/apps/mesh/src/web/components/chat/tiptap/mention/hooks.ts @@ -332,10 +332,15 @@ export function useMentionState({ editor, char, pluginKey, + allow: customAllow, }: { editor: Editor; char: string; pluginKey: string | PluginKey; + allow?: (props: { + state: unknown; + range: { from: number; to: number }; + }) => boolean; }) { // Create the reducer state here - this is the source of truth const [state, dispatch] = useReducer(reducer, { @@ -380,6 +385,11 @@ export function useMentionState({ } } + // Delegate to custom allow if provided + if (customAllow && !customAllow(props)) { + return false; + } + return true; }, @@ -433,7 +443,7 @@ export function useMentionState({ editor.unregisterPlugin(key); } }; - }, [editor, pluginKey, char, dispatch]); + }, [editor, pluginKey, char, dispatch, customAllow]); return { state, dispatch }; } 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; @@ -50,6 +51,11 @@ interface SuggestionSelectProps { queryFn: (props: { query: string }) => Promise; /** Callback executed when a suggestion is selected */ onSelect: (props: OnSelectProps) => void | Promise; + /** Optional custom allow function to control when suggestion triggers */ + allow?: (props: { + state: unknown; + range: { from: number; to: number }; + }) => boolean; } interface MentionItemProps { @@ -137,6 +143,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 && ( + + )}
@@ -257,11 +267,13 @@ export function Suggestion({ queryKey, queryFn, onSelect, + allow, }: SuggestionSelectProps) { const { state, dispatch } = useMentionState({ editor, char, pluginKey, + allow, }); // 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) => From 53a2fd322e84db295df938520de3d1dff19460ec Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 10 Apr 2026 11:15:20 -0300 Subject: [PATCH 2/7] feat(chat): rebuild @ as two-level menu with categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @ now shows "Agents" and "Resources" categories first. Selecting a category drills into the items list. Also fixes toolsFromMCP destructuring in open_in_agent background runner. - New AtMention component with category → items drill-in navigation - useSuggestion supports onSelect returning false to keep menu open - @ resources use readResource and insert as mention nodes - derive-parts handles both agent and resource @ mentions - Removed old mention-agents.tsx (replaced by mention-at.tsx) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../decopilot/built-in-tools/open-in-agent.ts | 2 +- .../src/web/components/chat/derive-parts.ts | 23 +- .../src/web/components/chat/tiptap/input.tsx | 8 +- .../components/chat/tiptap/mention-agents.tsx | 75 ------- .../web/components/chat/tiptap/mention-at.tsx | 199 ++++++++++++++++++ .../components/chat/tiptap/mention/hooks.ts | 17 +- .../chat/tiptap/mention/suggestion.tsx | 6 +- 7 files changed, 235 insertions(+), 95 deletions(-) delete mode 100644 apps/mesh/src/web/components/chat/tiptap/mention-agents.tsx create mode 100644 apps/mesh/src/web/components/chat/tiptap/mention-at.tsx 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 index 080f87293b..ce8edb2c84 100644 --- 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 @@ -173,7 +173,7 @@ function runAgentInBackground(params: { "passthrough", ); - const mcpTools = await toolsFromMCP( + const { tools: mcpTools } = await toolsFromMCP( mcpClient, new Map(), noopWriter, diff --git a/apps/mesh/src/web/components/chat/derive-parts.ts b/apps/mesh/src/web/components/chat/derive-parts.ts index 6eaebebe09..37383b4788 100644 --- a/apps/mesh/src/web/components/chat/derive-parts.ts +++ b/apps/mesh/src/web/components/chat/derive-parts.ts @@ -199,19 +199,28 @@ export function derivePartsFromTiptapDoc( inlineText += mentionName; if (char === "@") { - // Agent mentions: instruct the AI to use open_in_agent tool - const meta = node.attrs.metadata as { - agentId?: string; - title?: string; - } | null; - if (meta?.agentId) { + // @ 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.title ?? node.attrs.name} (agent_id: ${meta.agentId})]\n` + + `[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 { // Slash mentions: prompts or resources (both use "/") diff --git a/apps/mesh/src/web/components/chat/tiptap/input.tsx b/apps/mesh/src/web/components/chat/tiptap/input.tsx index 104009bbf3..26ddddb815 100644 --- a/apps/mesh/src/web/components/chat/tiptap/input.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/input.tsx @@ -13,7 +13,7 @@ import { Suspense, useEffect, useImperativeHandle, useRef } from "react"; import type { Metadata } from "../types.ts"; import { FileNode, FileUploader } from "./file"; import { MentionNode } from "./mention"; -import { AgentsMention } from "./mention-agents.tsx"; +import { AtMention } from "./mention-at.tsx"; import { SlashMention } from "./mention-slash.tsx"; import { AiProviderModel } from "@/web/hooks/collections/use-ai-providers.ts"; @@ -29,7 +29,7 @@ function buildExtensions(placeholderRef: React.RefObject) { Placeholder.configure({ placeholder: () => placeholderRef.current ?? - "Ask anything, / for prompts & resources, @ for agents...", + "Ask anything, / for prompts, @ for agents & resources...", showOnlyWhenEditable: false, }), MentionNode, @@ -211,9 +211,9 @@ export function TiptapInput({ - {/* Render agents dropdown menu (@) */} + {/* Render @ dropdown menu (agents + resources) */} - + {/* Render file upload handler */} diff --git a/apps/mesh/src/web/components/chat/tiptap/mention-agents.tsx b/apps/mesh/src/web/components/chat/tiptap/mention-agents.tsx deleted file mode 100644 index 1621e2ab59..0000000000 --- a/apps/mesh/src/web/components/chat/tiptap/mention-agents.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { KEYS } from "@/web/lib/query-keys"; -import { - isDecopilot, - useProjectContext, - useVirtualMCPs, -} from "@decocms/mesh-sdk"; -import type { Editor } from "@tiptap/react"; -import { BaseItem, insertMention, OnSelectProps, Suggestion } from "./mention"; - -interface AgentsMentionProps { - editor: Editor; - virtualMcpId: string | null; -} - -interface AgentItem extends BaseItem { - agentId: string; -} - -export const AgentsMention = ({ editor, virtualMcpId }: AgentsMentionProps) => { - const { org } = useProjectContext(); - const agents = useVirtualMCPs(); - const queryKey = KEYS.virtualMcpAgents(org.id); - - const handleItemSelect = async ({ - item, - range, - }: OnSelectProps) => { - insertMention(editor, range, { - id: item.agentId, - name: item.name, - metadata: { agentId: item.agentId, title: item.name }, - char: "@", - }); - }; - - const fetchItems = async (props: { query: string }) => { - const { query } = props; - - // Filter out Decopilot and the currently active agent - let filtered = agents.filter( - (agent) => - agent.status === "active" && - (!agent.id || !isDecopilot(agent.id)) && - agent.id !== virtualMcpId, - ); - - if (query.trim()) { - const lowerQuery = query.toLowerCase(); - filtered = filtered.filter( - (agent) => - agent.title.toLowerCase().includes(lowerQuery) || - agent.description?.toLowerCase().includes(lowerQuery), - ); - } - - return filtered.map((agent) => ({ - name: agent.title, - title: agent.title, - description: agent.description ?? undefined, - icon: agent.icon ?? null, - agentId: agent.id, - })) as AgentItem[]; - }; - - return ( - - editor={editor} - char="@" - pluginKey="agentsDropdownMenu" - queryKey={queryKey} - queryFn={fetchItems} - onSelect={handleItemSelect} - /> - ); -}; 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..7c93541ce2 --- /dev/null +++ b/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx @@ -0,0 +1,199 @@ +/** + * 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", + }, + { + name: "resources", + title: "Resources", + description: "Attach a resource as context", + kind: "category", + }, +]; + +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; + const lq = query.toLowerCase(); + return CATEGORY_ITEMS.filter( + (c) => + c.name.toLowerCase().includes(lq) || + c.description?.toLowerCase().includes(lq), + ); + } + + 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, + })); + }; + + return ( + + editor={editor} + char="@" + pluginKey="atDropdownMenu" + queryKey={queryKey} + queryFn={fetchItems} + onSelect={handleItemSelect} + /> + ); +}; 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 a19c6ded19..489f1efc75 100644 --- a/apps/mesh/src/web/components/chat/tiptap/mention/hooks.ts +++ b/apps/mesh/src/web/components/chat/tiptap/mention/hooks.ts @@ -40,8 +40,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 { @@ -495,7 +496,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 @@ -510,8 +510,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/suggestion.tsx b/apps/mesh/src/web/components/chat/tiptap/mention/suggestion.tsx index 614455c1e3..13401d8d5b 100644 --- a/apps/mesh/src/web/components/chat/tiptap/mention/suggestion.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/mention/suggestion.tsx @@ -49,8 +49,8 @@ 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; @@ -70,7 +70,7 @@ interface MentionItemListProps { editor: Editor; queryKey: readonly unknown[]; queryFn: (props: { query: string }) => Promise; - onSelect: (props: OnSelectProps) => void | Promise; + onSelect: (props: OnSelectProps) => void | false | Promise; } /** From 1147b4cffbe9d6e5803a26f27a7d31654a2e4d26 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 10 Apr 2026 11:25:37 -0300 Subject: [PATCH 3/7] fix(chat): reset @ menu mode on close + add Enter kbd hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reset mode to "categories" when @ menu closes (onOpenChange callback) - Add ↵ kbd badge on drillable category items - Add onOpenChange prop to Suggestion/useMentionState Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mesh/src/web/components/chat/tiptap/mention-at.tsx | 9 +++++++++ .../src/web/components/chat/tiptap/mention/hooks.ts | 10 ++++++++++ .../web/components/chat/tiptap/mention/suggestion.tsx | 9 +++++++++ 3 files changed, 28 insertions(+) diff --git a/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx b/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx index 7c93541ce2..b0e3b584f3 100644 --- a/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx @@ -41,12 +41,14 @@ const CATEGORY_ITEMS: AtItem[] = [ 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, }, ]; @@ -186,6 +188,12 @@ export const AtMention = ({ editor, virtualMcpId }: AtMentionProps) => { })); }; + const handleOpenChange = (open: boolean) => { + if (!open) { + setMode("categories"); + } + }; + return ( editor={editor} @@ -194,6 +202,7 @@ export const AtMention = ({ editor, virtualMcpId }: AtMentionProps) => { queryKey={queryKey} queryFn={fetchItems} onSelect={handleItemSelect} + onOpenChange={handleOpenChange} /> ); }; 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 489f1efc75..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 = { @@ -334,6 +336,7 @@ export function useMentionState({ char, pluginKey, allow: customAllow, + onOpenChange, }: { editor: Editor; char: string; @@ -342,6 +345,7 @@ export function useMentionState({ 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, { @@ -352,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 @@ -408,6 +416,7 @@ export function useMentionState({ range: props.range, }, }); + onOpenChangeRef.current?.(true); }, onUpdate: (props: SuggestionProps) => { @@ -431,6 +440,7 @@ export function useMentionState({ onExit: () => { dispatch({ type: "ON_EXIT" }); + onOpenChangeRef.current?.(false); }, }), diff --git a/apps/mesh/src/web/components/chat/tiptap/mention/suggestion.tsx b/apps/mesh/src/web/components/chat/tiptap/mention/suggestion.tsx index 13401d8d5b..59f156ecf3 100644 --- a/apps/mesh/src/web/components/chat/tiptap/mention/suggestion.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/mention/suggestion.tsx @@ -56,6 +56,8 @@ interface SuggestionSelectProps { state: unknown; range: { from: number; to: number }; }) => boolean; + /** Called when the menu opens or closes */ + onOpenChange?: (open: boolean) => void; } interface MentionItemProps { @@ -173,6 +175,11 @@ const MentionItem = ({
)}
+ {item.drillable && ( + + ↵ + + )}
); }; @@ -268,12 +275,14 @@ export function Suggestion({ queryFn, onSelect, allow, + onOpenChange, }: SuggestionSelectProps) { const { state, dispatch } = useMentionState({ editor, char, pluginKey, allow, + onOpenChange, }); // Provide both state and dispatch to children (for useSuggestion) From 6bcf839a9da7d1f52aff8c5d520e0ae91788fbe6 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 10 Apr 2026 12:24:18 -0300 Subject: [PATCH 4/7] feat(chat): search both agents and resources when typing after @ When at the top-level category view, typing now searches across both agents and resources simultaneously instead of just filtering the two category names. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/chat/tiptap/mention-at.tsx | 54 +++++++++++++++++-- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx b/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx index b0e3b584f3..6deac69ea8 100644 --- a/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx @@ -114,12 +114,56 @@ export const AtMention = ({ editor, virtualMcpId }: AtMentionProps) => { 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(); - return CATEGORY_ITEMS.filter( - (c) => - c.name.toLowerCase().includes(lq) || - c.description?.toLowerCase().includes(lq), - ); + + 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") { From 3acd317262bbaca5d50e5fe7d95a84ce439e3a0b Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 10 Apr 2026 12:58:10 -0300 Subject: [PATCH 5/7] fix(chat): remove unused extractAgentMentions export to fix knip Co-Authored-By: Claude Sonnet 4.6 --- .../src/web/components/chat/derive-parts.ts | 48 ------------------- 1 file changed, 48 deletions(-) diff --git a/apps/mesh/src/web/components/chat/derive-parts.ts b/apps/mesh/src/web/components/chat/derive-parts.ts index 37383b4788..4342744e5f 100644 --- a/apps/mesh/src/web/components/chat/derive-parts.ts +++ b/apps/mesh/src/web/components/chat/derive-parts.ts @@ -288,51 +288,3 @@ export function tiptapDocToMessages(doc: Metadata["tiptapDoc"]): ChatMessage[] { }, ]; } - -/** - * Agent mention extracted from a tiptap document. - */ -export interface AgentMention { - agentId: string; - title: string; -} - -/** - * Walks a tiptap document and extracts all @agent mentions. - */ -export function extractAgentMentions( - doc: Metadata["tiptapDoc"], -): AgentMention[] { - if (!doc) return []; - - const mentions: AgentMention[] = []; - - const walk = (node: Record) => { - if (!node) return; - - if (node.type === "mention" && node.attrs) { - const attrs = node.attrs as Record; - if (attrs.char === "@") { - const meta = attrs.metadata as { - agentId?: string; - title?: string; - } | null; - if (meta?.agentId) { - mentions.push({ - agentId: meta.agentId, - title: meta.title ?? String(attrs.name ?? ""), - }); - } - } - } - - if ("content" in node && Array.isArray(node.content)) { - for (const child of node.content) { - walk(child as Record); - } - } - }; - - walk(doc as unknown as Record); - return mentions; -} From 9032adb40437853db6d38c00e692b1b0ccb25aca Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Fri, 10 Apr 2026 15:58:34 -0300 Subject: [PATCH 6/7] fix(chat): mark open_in_agent as non-idempotent Each call creates a new thread and fires a background agent run, so retries would produce duplicates. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api/routes/decopilot/built-in-tools/open-in-agent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ce8edb2c84..7f2fe7a536 100644 --- 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 @@ -58,7 +58,7 @@ export interface OpenInAgentParams { const ANNOTATIONS = { readOnlyHint: false, destructiveHint: false, - idempotentHint: true, + idempotentHint: false, openWorldHint: false, } as const; From 4b78d33e2822e7bf94ce98902ac859477513e133 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Fri, 10 Apr 2026 16:26:48 -0300 Subject: [PATCH 7/7] refactor(chat): move open_in_agent stream start to frontend Backend tool now only validates the agent and creates an empty thread. The frontend component starts the agent run via the standard decopilot/stream endpoint on render, with idempotency via a module-level Set (re-renders) and sessionStorage (page refreshes). This removes the bespoke runAgentInBackground server-side logic and lets the existing stream infrastructure handle the agent run. Also moves open_in_agent outside the provider gate so it works with Claude Code agents too. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../routes/decopilot/built-in-tools/index.ts | 14 +- .../decopilot/built-in-tools/open-in-agent.ts | 140 +----------------- .../parts/tool-call-part/open-in-agent.tsx | 75 +++++++++- 3 files changed, 84 insertions(+), 145 deletions(-) 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 9d7aa99e5e..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 @@ -18,7 +18,7 @@ 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) */ @@ -79,20 +79,18 @@ function buildAllTools( passthroughClient, toolOutputMap, }), - }; - // subtask and open_in_agent require a provider (LLM calls) — skip when provider is null (Claude Code) - if (provider) { - tools.open_in_agent = createOpenInAgentTool( + open_in_agent: createOpenInAgentTool( writer, { - provider, organization, userId, - models, needsApproval: toolNeedsApproval(toolApprovalLevel, false) !== false, }, ctx, - ); + ), + }; + // subtask requires a provider (LLM calls) — skip when provider is null (Claude Code) + if (provider) { tools.subtask = createSubtaskTool( writer, { 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 index 7f2fe7a536..476fded18d 100644 --- 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 @@ -1,26 +1,15 @@ /** * open_in_agent Built-in Tool * - * Creates a task (thread) in a target agent, saves the context as the - * initial user message, and kicks off the agent run in the background. - * Returns immediately with a taskId so the client can navigate to the - * running task in the agent's UI. + * 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 { createVirtualClientFrom } from "@/mcp-clients/virtual-mcp"; import type { UIMessageStreamWriter } from "ai"; -import { stepCountIs, streamText, tool, zodSchema } from "ai"; +import { tool, zodSchema } from "ai"; import { z } from "zod"; -import { - DEFAULT_MAX_TOKENS, - SUBAGENT_EXCLUDED_TOOLS, - SUBAGENT_STEP_LIMIT, -} from "../constants"; -import { toolsFromMCP } from "../helpers"; -import type { ModelsConfig } from "../types"; -import type { MeshProvider } from "@/ai-providers/types"; -import { createLanguageModel } from "../stream-core"; const OpenInAgentInputSchema = z.object({ agent_id: z @@ -48,10 +37,8 @@ const description = "- This is NOT subtask — the work runs in the agent's own UI, not inline."; export interface OpenInAgentParams { - provider: MeshProvider; organization: OrganizationScope; userId: string; - models: ModelsConfig; needsApproval?: boolean; } @@ -67,16 +54,15 @@ export function createOpenInAgentTool( params: OpenInAgentParams, ctx: MeshContext, ) { - const { provider, organization, userId, models, needsApproval } = params; + const { organization, userId, needsApproval } = params; return tool({ description, inputSchema: zodSchema(OpenInAgentInputSchema), needsApproval, - execute: async ({ agent_id, context }, options) => { + execute: async ({ agent_id }, options) => { const startTime = performance.now(); try { - // 1. Validate agent const virtualMcp = await ctx.storage.virtualMcps.findById( agent_id, organization.id, @@ -90,10 +76,10 @@ export function createOpenInAgentTool( throw new Error("Agent is not active"); } - // 2. Create thread if (!userId) { throw new Error("User ID is required to create a thread"); } + const taskId = crypto.randomUUID(); await ctx.storage.threads.create({ id: taskId, @@ -101,30 +87,6 @@ export function createOpenInAgentTool( virtual_mcp_id: agent_id, }); - // 3. Save user message to thread - const now = new Date().toISOString(); - const userMessageId = crypto.randomUUID(); - await ctx.storage.threads.saveMessages([ - { - id: userMessageId, - thread_id: taskId, - role: "user" as const, - parts: [{ type: "text", text: context }], - created_at: now, - updated_at: now, - }, - ]); - - // 4. Fire-and-forget: run the agent in the background - runAgentInBackground({ - virtualMcp, - taskId, - context, - provider, - models, - ctx, - }); - return { success: true, agent_id: virtualMcp.id, @@ -142,91 +104,3 @@ export function createOpenInAgentTool( }, }); } - -/** - * Runs the target agent in the background. Does not block the parent. - * Saves the assistant response to the thread when complete. - */ -function runAgentInBackground(params: { - virtualMcp: NonNullable< - Awaited> - >; - taskId: string; - context: string; - provider: MeshProvider; - models: ModelsConfig; - ctx: MeshContext; -}) { - const { virtualMcp, taskId, context, provider, models, ctx } = params; - - // Use a no-op writer since we're not streaming to the parent - const noopWriter = { - write: () => {}, - merge: () => {}, - } as unknown as UIMessageStreamWriter; - - (async () => { - try { - const mcpClient = await createVirtualClientFrom( - virtualMcp, - ctx, - "passthrough", - ); - - const { tools: mcpTools } = await toolsFromMCP( - mcpClient, - new Map(), - noopWriter, - "auto", - { disableOutputTruncation: true }, - ); - - const agentTools = Object.fromEntries( - Object.entries(mcpTools).filter( - ([name]) => !SUBAGENT_EXCLUDED_TOOLS.includes(name), - ), - ); - - const serverInstructions = mcpClient.getInstructions(); - - const result = streamText({ - model: createLanguageModel(provider, models.thinking), - system: serverInstructions - ? [{ role: "system" as const, content: serverInstructions }] - : [], - prompt: context, - tools: agentTools, - stopWhen: stepCountIs(SUBAGENT_STEP_LIMIT), - maxOutputTokens: - models.thinking.limits?.maxOutputTokens ?? DEFAULT_MAX_TOKENS, - onError: (error) => { - console.error(`[open_in_agent:${virtualMcp.id}] Error`, error); - }, - }); - - // Wait for completion and save assistant response - const text = await result.text; - const now = new Date().toISOString(); - await ctx.storage.threads.saveMessages([ - { - id: crypto.randomUUID(), - thread_id: taskId, - role: "assistant" as const, - parts: [{ type: "text", text }], - created_at: now, - updated_at: now, - }, - ]); - - mcpClient.close().catch(() => {}); - console.log( - `[open_in_agent] Completed task ${taskId} for agent ${virtualMcp.title}`, - ); - } catch (error) { - console.error( - `[open_in_agent] Background run failed for task ${taskId}:`, - error, - ); - } - })(); -} diff --git a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/open-in-agent.tsx b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/open-in-agent.tsx index a8d6d177b3..922e20c7ea 100644 --- a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/open-in-agent.tsx +++ b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/open-in-agent.tsx @@ -2,10 +2,11 @@ import { IntegrationIcon } from "@/web/components/integration-icon"; import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; -import { useVirtualMCP, type ToolDefinition } from "@decocms/mesh-sdk"; +import { useOrg, useVirtualMCP, type ToolDefinition } from "@decocms/mesh-sdk"; import { Spinner } from "@deco/ui/components/spinner.tsx"; import { cn } from "@deco/ui/lib/utils.ts"; import { ArrowRight, Users03 } from "@untitledui/icons"; +import { useRef } from "react"; import { getEffectiveState } from "./utils.tsx"; type OpenInAgentToolPart = Extract< @@ -19,13 +20,39 @@ interface OpenInAgentPartProps { 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); - // task_id comes from the tool's output (may be typed as unknown) const output = part.output as Record | undefined; const taskId = output?.task_id as string | undefined; @@ -37,12 +64,52 @@ export function OpenInAgentPart({ part }: OpenInAgentPartProps) { 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; - console.log("[open_in_agent] navigating", { agentId, taskId, output }); - // Navigate directly to the already-running task navigateToAgent(agentId, { search: taskId ? { taskId } : undefined, });