From 39b4a18f3f4ab7b97021fcff6483eb9519f277d6 Mon Sep 17 00:00:00 2001 From: danielfurt Date: Fri, 23 Jan 2026 11:47:14 -0300 Subject: [PATCH] feat(threads): add virtual MCP association to threads - Add virtual_mcp_id column to threads table with migration - Update thread creation and listing to support virtual MCP filtering - Add virtual MCP selector in chat UI - Update thread storage and API to handle virtual_mcp_id - Add indexes for efficient filtering by virtual_mcp_id - Update chat store to manage virtual MCP selection - Refactor home page to support virtual MCP filtering --- .../029-add-thread-virtual-mcp-id.ts | 39 +++ apps/mesh/migrations/index.ts | 2 + .../src/api/routes/decopilot/conversation.ts | 2 + apps/mesh/src/api/routes/decopilot/memory.ts | 9 +- apps/mesh/src/api/routes/decopilot/routes.ts | 1 + apps/mesh/src/api/routes/decopilot/types.ts | 3 + apps/mesh/src/storage/threads.ts | 18 +- apps/mesh/src/storage/types.ts | 2 + apps/mesh/src/tools/thread/create.ts | 1 + apps/mesh/src/tools/thread/list.ts | 30 +- apps/mesh/src/tools/thread/schema.ts | 9 + apps/mesh/src/web/components/chat/context.tsx | 2 +- .../src/web/components/chat/ice-breakers.tsx | 9 +- .../src/web/components/chat/select-model.tsx | 15 +- .../components/chat/select-virtual-mcp.tsx | 61 +++- .../components/chat/sidebar-chats-section.tsx | 278 ++++++++++++++++++ .../src/web/components/chat/tiptap/input.tsx | 1 + .../src/web/components/home/agents-list.tsx | 207 +++++++++++++ .../src/web/components/integration-icon.tsx | 2 +- apps/mesh/src/web/components/mesh-sidebar.tsx | 6 + apps/mesh/src/web/hooks/use-chat-store.ts | 83 +++++- apps/mesh/src/web/routes/orgs/home/page.tsx | 248 ++++++++-------- 22 files changed, 878 insertions(+), 150 deletions(-) create mode 100644 apps/mesh/migrations/029-add-thread-virtual-mcp-id.ts create mode 100644 apps/mesh/src/web/components/chat/sidebar-chats-section.tsx create mode 100644 apps/mesh/src/web/components/home/agents-list.tsx diff --git a/apps/mesh/migrations/029-add-thread-virtual-mcp-id.ts b/apps/mesh/migrations/029-add-thread-virtual-mcp-id.ts new file mode 100644 index 0000000000..2db50c5c45 --- /dev/null +++ b/apps/mesh/migrations/029-add-thread-virtual-mcp-id.ts @@ -0,0 +1,39 @@ +/** + * Migration: Add virtual_mcp_id to threads table + * + * Associates threads with the virtual MCP (agent) that was used when the thread was created. + * This allows threads to display the correct agent icon and filter threads by agent. + */ + +import { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + // Add virtual_mcp_id column to threads table + await db.schema + .alterTable("threads") + .addColumn("virtual_mcp_id", "text") + .execute(); + + // Create index for efficient filtering by virtual_mcp_id + await db.schema + .createIndex("idx_threads_virtual_mcp_id") + .on("threads") + .columns(["virtual_mcp_id"]) + .execute(); + + // Create composite index for filtering by organization + virtual_mcp_id + await db.schema + .createIndex("idx_threads_org_virtual_mcp_id") + .on("threads") + .columns(["organization_id", "virtual_mcp_id"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + // Drop indexes first + await db.schema.dropIndex("idx_threads_org_virtual_mcp_id").execute(); + await db.schema.dropIndex("idx_threads_virtual_mcp_id").execute(); + + // Drop column + await db.schema.alterTable("threads").dropColumn("virtual_mcp_id").execute(); +} diff --git a/apps/mesh/migrations/index.ts b/apps/mesh/migrations/index.ts index 7dfd7e429e..ca38544790 100644 --- a/apps/mesh/migrations/index.ts +++ b/apps/mesh/migrations/index.ts @@ -27,6 +27,7 @@ import * as migration025addmonitoringvirtualmcpid from "./025-add-monitoring-vir import * as migration026restrictchildconnectiondelete from "./026-restrict-child-connection-delete.ts"; import * as migration027updatemanagementmcpurl from "./027-update-management-mcp-url.ts"; import * as migration028updatemanagementmcptoself from "./028-update-management-mcp-to-self.ts"; +import * as migration029addthreadvirtualmcpid from "./029-add-thread-virtual-mcp-id.ts"; const migrations = { "001-initial-schema": migration001initialschema, @@ -59,6 +60,7 @@ const migrations = { migration026restrictchildconnectiondelete, "027-update-management-mcp-url": migration027updatemanagementmcpurl, "028-update-management-mcp-to-self": migration028updatemanagementmcptoself, + "029-add-thread-virtual-mcp-id": migration029addthreadvirtualmcpid, } satisfies Record; export default migrations; diff --git a/apps/mesh/src/api/routes/decopilot/conversation.ts b/apps/mesh/src/api/routes/decopilot/conversation.ts index 0d052cc8ab..fc6bce16a0 100644 --- a/apps/mesh/src/api/routes/decopilot/conversation.ts +++ b/apps/mesh/src/api/routes/decopilot/conversation.ts @@ -37,6 +37,7 @@ export async function processConversation( messages: UIMessage[]; systemPrompts: string[]; removeFileParts?: boolean; + virtualMcpId?: string | null; }, ): Promise { const userId = ensureUser(ctx); @@ -47,6 +48,7 @@ export async function processConversation( threadId: config.threadId, userId, defaultWindowSize: config.windowSize, + virtualMcpId: config.virtualMcpId, }); // Load thread history diff --git a/apps/mesh/src/api/routes/decopilot/memory.ts b/apps/mesh/src/api/routes/decopilot/memory.ts index 380553e74a..aefb6df6a0 100644 --- a/apps/mesh/src/api/routes/decopilot/memory.ts +++ b/apps/mesh/src/api/routes/decopilot/memory.ts @@ -55,7 +55,8 @@ export async function createMemory( storage: ThreadStoragePort, config: MemoryConfig, ): Promise { - const { threadId, organizationId, userId, defaultWindowSize } = config; + const { threadId, organizationId, userId, defaultWindowSize, virtualMcpId } = + config; let thread: Thread; @@ -64,6 +65,7 @@ export async function createMemory( thread = await storage.create({ id: generatePrefixedId("thrd"), organizationId, + virtualMcpId: virtualMcpId ?? undefined, createdBy: userId, }); } else { @@ -76,10 +78,15 @@ export async function createMemory( thread = await storage.create({ id: existing ? generatePrefixedId("thrd") : threadId, organizationId, + virtualMcpId: virtualMcpId ?? undefined, createdBy: userId, }); } else { + // If existing thread doesn't have virtualMcpId and we're providing one, update it thread = existing; + if (virtualMcpId && !thread.virtualMcpId) { + thread = await storage.update(thread.id, { virtualMcpId }); + } } } diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 5813d26504..88c48c3f30 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -146,6 +146,7 @@ app.post("/:org/decopilot/stream", async (c) => { messages, systemPrompts: [DECOPILOT_BASE_PROMPT], removeFileParts: !modelHasVision, + virtualMcpId: agent.id, }); const shouldGenerateTitle = prunedMessages.length === 1; diff --git a/apps/mesh/src/api/routes/decopilot/types.ts b/apps/mesh/src/api/routes/decopilot/types.ts index 3baa9ad0f6..1742ab65e2 100644 --- a/apps/mesh/src/api/routes/decopilot/types.ts +++ b/apps/mesh/src/api/routes/decopilot/types.ts @@ -56,6 +56,9 @@ export interface MemoryConfig { /** Default window size for pruning */ defaultWindowSize?: number; + + /** Virtual MCP (Agent) ID if routed through an agent */ + virtualMcpId?: string | null; } // ============================================================================ diff --git a/apps/mesh/src/storage/threads.ts b/apps/mesh/src/storage/threads.ts index fdc42a69fd..9e8b1cd71c 100644 --- a/apps/mesh/src/storage/threads.ts +++ b/apps/mesh/src/storage/threads.ts @@ -40,6 +40,7 @@ export class SqlThreadStorage implements ThreadStoragePort { organization_id: data.organizationId, title: data.title, description: data.description ?? null, + virtual_mcp_id: data.virtualMcpId ?? null, created_at: now, updated_at: now, created_by: data.createdBy, @@ -84,6 +85,9 @@ export class SqlThreadStorage implements ThreadStoragePort { if (data.hidden !== undefined) { updateData.hidden = data.hidden; } + if (data.virtualMcpId !== undefined) { + updateData.virtual_mcp_id = data.virtualMcpId; + } await this.db .updateTable("threads") @@ -231,8 +235,17 @@ export class SqlThreadStorage implements ThreadStoragePort { updated_at: Date | string; created_by: string; updated_by: string | null; - hidden: boolean | null; + hidden: boolean | number | null; + virtual_mcp_id?: string | null; }): Thread { + // Convert hidden from number (0/1) to boolean if needed (SQLite returns numbers) + const hidden = + row.hidden === null || row.hidden === undefined + ? null + : typeof row.hidden === "number" + ? row.hidden !== 0 + : row.hidden; + return { id: row.id, organizationId: row.organization_id, @@ -248,7 +261,8 @@ export class SqlThreadStorage implements ThreadStoragePort { : row.updated_at.toISOString(), createdBy: row.created_by, updatedBy: row.updated_by, - hidden: row.hidden, + hidden, + virtualMcpId: row.virtual_mcp_id ?? undefined, }; } diff --git a/apps/mesh/src/storage/types.ts b/apps/mesh/src/storage/types.ts index b60ae26df2..b37a202a71 100644 --- a/apps/mesh/src/storage/types.ts +++ b/apps/mesh/src/storage/types.ts @@ -598,6 +598,7 @@ export interface ThreadTable { title: string; description: string | null; hidden: boolean | null; + virtual_mcp_id: string | null; // Virtual MCP (Agent) ID if routed through an agent created_at: ColumnType; updated_at: ColumnType; created_by: string; // User ID; @@ -614,6 +615,7 @@ export interface Thread { createdBy: string; updatedBy: string | null; hidden: boolean | null; + virtualMcpId?: string | null; // Virtual MCP (Agent) ID if routed through an agent } export interface ThreadMessageTable { diff --git a/apps/mesh/src/tools/thread/create.ts b/apps/mesh/src/tools/thread/create.ts index cfa7284559..ab7841c765 100644 --- a/apps/mesh/src/tools/thread/create.ts +++ b/apps/mesh/src/tools/thread/create.ts @@ -57,6 +57,7 @@ export const COLLECTION_THREADS_CREATE = defineTool({ organizationId: organization.id, title: input.data.title, description: input.data.description, + virtualMcpId: input.data.virtualMcpId ?? undefined, createdBy: userId, }); diff --git a/apps/mesh/src/tools/thread/list.ts b/apps/mesh/src/tools/thread/list.ts index 9a6ce5cb2b..f601709740 100644 --- a/apps/mesh/src/tools/thread/list.ts +++ b/apps/mesh/src/tools/thread/list.ts @@ -37,7 +37,22 @@ export const COLLECTION_THREADS_LIST = defineTool({ outputSchema: ThreadListOutputSchema, handler: async (input, ctx) => { - await ctx.access.check(); + try { + await ctx.access.check(); + } catch (error) { + // Debug: log access check error + if (process.env.NODE_ENV === "development") { + // eslint-disable-next-line no-console + console.error("[COLLECTION_THREADS_LIST] Access check failed:", { + error: error instanceof Error ? error.message : String(error), + userId: ctx.auth.user?.id, + hasUser: !!ctx.auth.user, + hasApiKey: !!ctx.auth.apiKey, + }); + } + throw error; + } + const userId = ctx.auth.user?.id; if (!userId) { throw new Error("User ID required to list threads"); @@ -52,6 +67,19 @@ export const COLLECTION_THREADS_LIST = defineTool({ { limit, offset }, ); + // Debug: log query results + if (process.env.NODE_ENV === "development") { + // eslint-disable-next-line no-console + console.log("[COLLECTION_THREADS_LIST] Query:", { + organizationId: organization.id, + userId, + threadsFound: threads.length, + total, + offset, + limit, + }); + } + const hasMore = offset + limit < total; return { diff --git a/apps/mesh/src/tools/thread/schema.ts b/apps/mesh/src/tools/thread/schema.ts index f1514a0906..93ece9fac5 100644 --- a/apps/mesh/src/tools/thread/schema.ts +++ b/apps/mesh/src/tools/thread/schema.ts @@ -48,6 +48,11 @@ export const ThreadEntitySchema = z.object({ .string() .nullable() .describe("User ID who last updated the thread"), + virtualMcpId: z + .string() + .nullable() + .optional() + .describe("Virtual MCP (Agent) ID if routed through an agent"), }); export type ThreadEntity = z.infer; @@ -60,6 +65,10 @@ export const ThreadCreateDataSchema = z.object({ id: z.string().optional().describe("Optional custom ID for the thread"), title: z.string().describe("Thread title"), description: z.string().nullish().describe("Thread description"), + virtualMcpId: z + .string() + .nullish() + .describe("Virtual MCP (Agent) ID if routed through an agent"), }); export type ThreadCreateData = z.infer; diff --git a/apps/mesh/src/web/components/chat/context.tsx b/apps/mesh/src/web/components/chat/context.tsx index 0f314957ca..4b554aea73 100644 --- a/apps/mesh/src/web/components/chat/context.tsx +++ b/apps/mesh/src/web/components/chat/context.tsx @@ -499,7 +499,7 @@ async function callUpdateThreadTool( return payload.item; } -const ChatContext = createContext(null); +export const ChatContext = createContext(null); /** * Provider component for chat context diff --git a/apps/mesh/src/web/components/chat/ice-breakers.tsx b/apps/mesh/src/web/components/chat/ice-breakers.tsx index 53bd59711c..45386082f1 100644 --- a/apps/mesh/src/web/components/chat/ice-breakers.tsx +++ b/apps/mesh/src/web/components/chat/ice-breakers.tsx @@ -339,8 +339,6 @@ export function IceBreakers({ className }: IceBreakersProps) { const { selectedVirtualMcp } = useChat(); // When selectedVirtualMcp is null, it means default virtual MCP (id is null) const connectionId = selectedVirtualMcp?.id ?? null; - // Use a stable key for ErrorBoundary (null becomes "default") - const errorBoundaryKey = connectionId ?? "default"; return (
- - }> + + } + > diff --git a/apps/mesh/src/web/components/chat/select-model.tsx b/apps/mesh/src/web/components/chat/select-model.tsx index 5cb0b94ce3..41b19c721f 100644 --- a/apps/mesh/src/web/components/chat/select-model.tsx +++ b/apps/mesh/src/web/components/chat/select-model.tsx @@ -15,6 +15,7 @@ import { import { Skeleton } from "@deco/ui/components/skeleton.tsx"; import { cn } from "@deco/ui/lib/utils.ts"; import { + ChevronDown, ChevronSelectorVertical, CurrencyDollar, File06, @@ -336,7 +337,15 @@ function SelectedModelDisplay({ placeholder?: string; }) { if (!model) { - return {placeholder}; + return ( +
+ {placeholder} + +
+ ); } return ( @@ -351,6 +360,10 @@ function SelectedModelDisplay({ {model.title} +
); } diff --git a/apps/mesh/src/web/components/chat/select-virtual-mcp.tsx b/apps/mesh/src/web/components/chat/select-virtual-mcp.tsx index 0c1dc79b2d..f706391d50 100644 --- a/apps/mesh/src/web/components/chat/select-virtual-mcp.tsx +++ b/apps/mesh/src/web/components/chat/select-virtual-mcp.tsx @@ -13,8 +13,13 @@ import { TooltipTrigger, } from "@deco/ui/components/tooltip.tsx"; import { cn } from "@deco/ui/lib/utils.ts"; -import { useVirtualMCPs, type VirtualMCPEntity } from "@decocms/mesh-sdk"; -import { Check, CpuChip02, SearchMd } from "@untitledui/icons"; +import { + getWellKnownDecopilotAgent, + useProjectContext, + useVirtualMCPs, + type VirtualMCPEntity, +} from "@decocms/mesh-sdk"; +import { Check, ChevronDown, CpuChip02, SearchMd } from "@untitledui/icons"; import { useEffect, useRef, @@ -49,7 +54,7 @@ function VirtualMCPItemContent({ name={virtualMcp.title} size="sm" fallbackIcon={virtualMcp.fallbackIcon ?? } - className="size-10 rounded-xl border border-stone-200/60 shadow-sm shrink-0" + className="rounded-xl border border-stone-200/60 shadow-sm shrink-0 aspect-square" /> {/* Text Content */} @@ -211,11 +216,15 @@ export function VirtualMCPSelector({ }: VirtualMCPSelectorProps) { const [open, setOpen] = useState(false); const searchInputRef = useRef(null); + const { org } = useProjectContext(); // Use provided virtual MCPs or fetch from hook const virtualMcpsFromHook = useVirtualMCPs(); const virtualMcps = virtualMcpsProp ?? virtualMcpsFromHook; + // Get default Decopilot agent info + const defaultAgent = getWellKnownDecopilotAgent(org.id); + // Focus search input when dialog opens // oxlint-disable-next-line ban-use-effect/ban-use-effect useEffect(() => { @@ -245,7 +254,7 @@ export function VirtualMCPSelector({ type="button" disabled={disabled} className={cn( - "flex items-center justify-center p-1 rounded-md transition-colors shrink-0", + "flex items-center gap-1.5 px-1.5 py-1 rounded-md transition-colors shrink-0", disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-accent", @@ -254,19 +263,39 @@ export function VirtualMCPSelector({ aria-label={placeholder} > {selectedVirtualMcp ? ( - } - className="size-5 rounded-md" - /> + <> + } + className="rounded-md shrink-0 aspect-square" + /> + + {selectedVirtualMcp.title} + + + ) : ( - Default Agent + <> + } + className="rounded-md shrink-0 aspect-square" + /> + + {defaultAgent.title} + + + )} ); diff --git a/apps/mesh/src/web/components/chat/sidebar-chats-section.tsx b/apps/mesh/src/web/components/chat/sidebar-chats-section.tsx new file mode 100644 index 0000000000..6cd2573628 --- /dev/null +++ b/apps/mesh/src/web/components/chat/sidebar-chats-section.tsx @@ -0,0 +1,278 @@ +import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; +import { + getWellKnownDecopilotAgent, + useProjectContext, + useVirtualMCPs, +} from "@decocms/mesh-sdk"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@deco/ui/components/collapsible.tsx"; +import { + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, + useSidebar, +} from "@deco/ui/components/sidebar.tsx"; +import { Skeleton } from "@deco/ui/components/skeleton.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { + ChevronDown, + ChevronRight, + CpuChip02, + MessageChatSquare, + Trash01, +} from "@untitledui/icons"; +import { Suspense, useState, useContext } from "react"; +import { ErrorBoundary } from "@/web/components/error-boundary"; +import { useNavigate } from "@tanstack/react-router"; +import { useThreads } from "@/web/hooks/use-chat-store"; +import { useLocalStorage } from "@/web/hooks/use-local-storage"; +import { LOCALSTORAGE_KEYS } from "@/web/lib/localstorage-keys"; +import { ChatContext } from "./context"; +import type { Thread } from "./types.ts"; + +/** + * Individual chat thread item content (just the content, not the wrapper) + */ +function ChatThreadItem({ thread }: { thread: Thread }) { + const { org } = useProjectContext(); + const virtualMcps = useVirtualMCPs(); + + // Get agent icon - use thread's virtualMcpId or default to Decopilot + const agent = thread.virtualMcpId + ? virtualMcps.find((v) => v.id === thread.virtualMcpId) + : null; + const defaultAgent = getWellKnownDecopilotAgent(org.id); + const displayAgent = agent ?? defaultAgent; + + return ( + <> + } + className="rounded-md shrink-0 aspect-square" + /> + + {thread.title || "New chat"} + + + ); +} + +/** + * Sidebar chats section content + * Replicates the logic from ThreadHistoryPopover but in sidebar format + */ +function SidebarChatsSectionContent() { + const { org, locator } = useProjectContext(); + const navigate = useNavigate(); + const [isOpen, setIsOpen] = useState(true); + const { state: sidebarState } = useSidebar(); + const isCollapsed = sidebarState === "collapsed"; + + // Try to get threads from Chat context first (like ThreadHistoryPopover does) + const chatContext = useContext(ChatContext); + + // Always call useThreads hook (React hooks rules) + const threadsData = useThreads(); + const [storedActiveThreadId, setStoredActiveThreadId] = + useLocalStorage( + LOCALSTORAGE_KEYS.assistantChatActiveThread(locator) + ":state", + "", + ); + + // If Chat context is available, use it (same as ThreadHistoryPopover) + // Otherwise, fall back to useThreads hook and localStorage + // Prefer threads from database (threadsData) over context, as context may be stale + const threads = + threadsData.threads.length > 0 + ? threadsData.threads + : (chatContext?.threads ?? []); + const activeThreadId = chatContext?.activeThreadId ?? storedActiveThreadId; + const setActiveThreadId = + chatContext?.setActiveThreadId ?? + ((id: string) => { + setStoredActiveThreadId(id); + navigate({ to: "/$org", params: { org: org.id } }); + }); + const hideThread = chatContext?.hideThread; + + const handleThreadClick = (threadId: string) => { + setActiveThreadId(threadId); + + // Navigate to home if not already there + const currentPath = window.location.pathname; + const orgPath = `/${org.id}`; + if (currentPath !== orgPath && currentPath !== `${orgPath}/`) { + navigate({ to: "/$org", params: { org: org.id } }); + } + }; + + const handleHideThread = (threadId: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (hideThread) { + hideThread(threadId); + } + }; + + // Always show the section, even when empty (as requested) + return ( + <> + + + + + + + + + {!isCollapsed && ( + <> + All chats + {isOpen ? ( + + ) : ( + + )} + + )} + + + + + {threads.length === 0 ? ( + +
+ No chats yet +
+
+ ) : ( + threads.map((thread) => { + const isActive = thread.id === activeThreadId; + return ( + +
+ handleThreadClick(thread.id)} + tooltip={thread.title || "New chat"} + > + + + {hideThread && ( + + )} +
+
+ ); + }) + )} +
+
+ + ); +} + +/** + * Skeleton for loading chat threads + */ +function SidebarChatsSectionSkeleton() { + return ( + <> + + +
+ +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + +
+ + +
+
+ ))} + + ); +} + +/** + * Empty state for sidebar chats section + */ +function SidebarChatsSectionEmpty() { + const [isOpen, setIsOpen] = useState(true); + const { state: sidebarState } = useSidebar(); + const isCollapsed = sidebarState === "collapsed"; + + return ( + <> + + + + + + + + + {!isCollapsed && ( + <> + All chats + {isOpen ? ( + + ) : ( + + )} + + )} + + + + + +
+ No chats yet +
+
+
+
+ + ); +} + +/** + * Sidebar chats section - displays all chat threads in a collapsible section + * Always shows the section, even when empty or on error + */ +export function SidebarChatsSection() { + return ( + }> + }> + + + + ); +} diff --git a/apps/mesh/src/web/components/chat/tiptap/input.tsx b/apps/mesh/src/web/components/chat/tiptap/input.tsx index 4d9888d4b2..c6ace74d8f 100644 --- a/apps/mesh/src/web/components/chat/tiptap/input.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/input.tsx @@ -174,6 +174,7 @@ export function TiptapInput({ "[&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[20px] [&_.ProseMirror]:flex-1", "[&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]", "[&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground", + "[&_.ProseMirror_p.is-editor-empty:first-child::before]:opacity-50", "[&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left", "[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none", "[&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0", diff --git a/apps/mesh/src/web/components/home/agents-list.tsx b/apps/mesh/src/web/components/home/agents-list.tsx new file mode 100644 index 0000000000..7a2495b8b0 --- /dev/null +++ b/apps/mesh/src/web/components/home/agents-list.tsx @@ -0,0 +1,207 @@ +/** + * Agents List Component for Home Page + * + * Displays a list of agents (Virtual MCPs) with their icon, name, description, and connections. + * Only shows when the organization has agents. + */ + +import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; +import { + useConnections, + useVirtualMCPs, + useProjectContext, +} from "@decocms/mesh-sdk"; +import { Card } from "@deco/ui/components/card.tsx"; +import { Skeleton } from "@deco/ui/components/skeleton.tsx"; +import { CpuChip02 } from "@untitledui/icons"; +import { Suspense, useContext } from "react"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { ChatContext } from "@/web/components/chat/context"; + +/** + * Individual agent card component + */ +function AgentCard({ + agent, + connectionsMap, +}: { + agent: { + id: string; + title: string; + description: string | null; + icon: string | null; + connections: Array<{ connection_id: string }>; + }; + connectionsMap: Map; +}) { + const chatContext = useContext(ChatContext); + + // Get connection details for this agent + const agentConnections = agent.connections + .map((conn) => { + const connection = connectionsMap.get(conn.connection_id); + return connection + ? { + id: conn.connection_id, + title: connection.title, + icon: connection.icon, + } + : null; + }) + .filter((conn): conn is NonNullable => conn !== null); + + const handleClick = () => { + // Select the agent in the chat context + if (chatContext?.setVirtualMcpId) { + chatContext.setVirtualMcpId(agent.id); + } + }; + + return ( + +
+ {/* Header: Icon + Connections in top right */} +
+ } + /> + {/* Connections - Icons only in top right, overlapping */} + {agentConnections.length > 0 && ( +
+ {agentConnections.map((conn, index) => ( +
+ } + /> +
+ ))} +
+ )} +
+ + {/* Title and Description below icon */} +
+

+ {agent.title} +

+ {agent.description && ( +

+ {agent.description} +

+ )} +
+
+
+ ); +} + +/** + * Agents list content component + */ +function AgentsListContent() { + const virtualMcps = useVirtualMCPs(); + const connections = useConnections(); + + // Filter out the default Decopilot agent (it's not a real agent) + const agents = virtualMcps.filter( + (agent) => !agent.id.startsWith("decopilot-"), + ); + + // Create a map of connections by ID for quick lookup + const connectionsMap = new Map( + connections.map((conn) => [ + conn.id, + { title: conn.title, icon: conn.icon }, + ]), + ); + + // Don't render if no agents + if (agents.length === 0) { + return null; + } + + // Calculate optimal grid columns based on agent count + const getGridCols = () => { + if (agents.length === 1) return "grid-cols-1"; + if (agents.length === 2) return "grid-cols-2"; + if (agents.length <= 4) return "grid-cols-2"; + return "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"; + }; + + return ( +
+

+ Recently used agents +

+
+ {agents.map((agent) => ( + + ))} +
+
+ ); +} + +/** + * Skeleton loader for agents list + */ +function AgentsListSkeleton() { + return ( +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+
+
+ ))} +
+
+ ); +} + +/** + * Agents list component with Suspense boundary + */ +export function AgentsList() { + return ( + }> + + + ); +} diff --git a/apps/mesh/src/web/components/integration-icon.tsx b/apps/mesh/src/web/components/integration-icon.tsx index 87a87a8c2e..d07a77590e 100644 --- a/apps/mesh/src/web/components/integration-icon.tsx +++ b/apps/mesh/src/web/components/integration-icon.tsx @@ -70,7 +70,7 @@ function IntegrationIconStateful({ return (
+ + + + + } /> diff --git a/apps/mesh/src/web/hooks/use-chat-store.ts b/apps/mesh/src/web/hooks/use-chat-store.ts index 3e761a4e5f..238e982a19 100644 --- a/apps/mesh/src/web/hooks/use-chat-store.ts +++ b/apps/mesh/src/web/hooks/use-chat-store.ts @@ -48,17 +48,90 @@ export function useThreads() { offset: pageParam, }; - const result = (await client.callTool({ + const result = await client.callTool({ name: listToolName, arguments: input, - })) as { structuredContent?: unknown }; - const payload = (result.structuredContent ?? - result) as CollectionListOutput; + }); + + // Extract payload - MCP CallToolResult has structuredContent or content + // structuredContent is the parsed JSON, content is the raw text + let payload: CollectionListOutput; + + if (result.isError) { + const errorText = Array.isArray(result.content) + ? result.content + .map((c) => (c.type === "text" ? c.text : "")) + .join("\n") + : "Unknown error"; + + // Debug: log error details + if (process.env.NODE_ENV === "development") { + // eslint-disable-next-line no-console + console.error("[useThreads] Tool call error:", { + errorText, + isError: result.isError, + content: result.content, + contentString: Array.isArray(result.content) + ? result.content + .map((c) => { + if (c.type === "text") return c.text; + return JSON.stringify(c); + }) + .join("\n") + : String(result.content), + }); + } + + // Don't throw - return empty result instead to prevent UI crash + // The ErrorBoundary will handle it gracefully + return { + items: [], + hasMore: false, + totalCount: 0, + }; + } + + if ("structuredContent" in result && result.structuredContent) { + payload = result.structuredContent as CollectionListOutput; + } else if (Array.isArray(result.content) && result.content.length > 0) { + // Fallback: try to parse from content text + const textContent = result.content + .map((c) => (c.type === "text" ? c.text : null)) + .filter(Boolean) + .join(""); + if (textContent) { + try { + payload = JSON.parse(textContent) as CollectionListOutput; + } catch { + throw new Error("Failed to parse tool result"); + } + } else { + throw new Error("Tool result has no content"); + } + } else { + // Direct result (shouldn't happen but handle it) + payload = result as unknown as CollectionListOutput; + } + + // Debug: log query results + if (process.env.NODE_ENV === "development") { + // eslint-disable-next-line no-console + console.log("[useThreads] Query result:", { + hasStructuredContent: "structuredContent" in result, + hasContent: + Array.isArray(result.content) && result.content.length > 0, + payload, + items: payload.items?.length ?? 0, + totalCount: payload.totalCount, + hasMore: payload.hasMore, + offset: pageParam, + }); + } return { items: payload.items ?? [], hasMore: payload.hasMore ?? false, - totalCount: payload.totalCount, + totalCount: payload.totalCount ?? 0, }; }, getNextPageParam: (lastPage, allPages) => { diff --git a/apps/mesh/src/web/routes/orgs/home/page.tsx b/apps/mesh/src/web/routes/orgs/home/page.tsx index 059496b2d3..5d7d0ce0c2 100644 --- a/apps/mesh/src/web/routes/orgs/home/page.tsx +++ b/apps/mesh/src/web/routes/orgs/home/page.tsx @@ -8,25 +8,23 @@ import { Chat, useChat } from "@/web/components/chat/index"; import { TypewriterTitle } from "@/web/components/chat/typewriter-title"; import { ErrorBoundary } from "@/web/components/error-boundary"; -import { useLocalStorage } from "@/web/hooks/use-local-storage"; +import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; import { authClient } from "@/web/lib/auth-client"; -import { useProjectContext } from "@decocms/mesh-sdk"; +import { + getWellKnownDecopilotAgent, + useProjectContext, +} from "@decocms/mesh-sdk"; import { Button } from "@deco/ui/components/button.tsx"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@deco/ui/components/tooltip.tsx"; -import { ViewModeToggle } from "@deco/ui/components/view-mode-toggle.tsx"; -import { GitBranch01, MessageChatSquare, Plus } from "@untitledui/icons"; -import { Suspense } from "react"; -import { - MeshVisualization, - MeshVisualizationSkeleton, - MetricsModeProvider, - MetricsModeSelector, -} from "./mesh-graph.tsx"; +import { MessageChatSquare, Pin01, Plus, Share07 } from "@untitledui/icons"; +import { Suspense, useMemo } from "react"; +import { toast } from "sonner"; import { useThreads } from "@/web/hooks/use-chat-store.ts"; +import { AgentsList } from "@/web/components/home/agents-list.tsx"; /** * Get time-based greeting @@ -39,14 +37,10 @@ function getTimeBasedGreeting(): string { return "Night"; } -// ---------- View Mode Types ---------- - -type HomeViewMode = "chat" | "graph"; - // ---------- Main Content ---------- function HomeContent() { - const { org, locator } = useProjectContext(); + const { org } = useProjectContext(); const { data: session } = authClient.useSession(); const { modelsConnections, @@ -54,17 +48,19 @@ function HomeContent() { activeThreadId, setActiveThreadId, threads, + selectedVirtualMcp, } = useChat(); const activeThread = threads.find((thread) => thread.id === activeThreadId); - // View mode state (chat vs graph) - const [viewMode, setViewMode] = useLocalStorage( - `${locator}:home-view-mode`, - "chat", - ); const userName = session?.user?.name?.split(" ")[0] || "there"; const greeting = getTimeBasedGreeting(); + // Memoize agent calculation to prevent unnecessary recalculations + const displayAgent = useMemo(() => { + const defaultAgent = getWellKnownDecopilotAgent(org.id); + return selectedVirtualMcp ?? defaultAgent; + }, [org.id, selectedVirtualMcp]); + // Show empty state when no LLM binding is found if (modelsConnections.length === 0) { return ( @@ -75,109 +71,125 @@ function HomeContent() { } return ( - - - - - {viewMode === "graph" ? ( - - Summary - - ) : !isChatEmpty && activeThread?.title ? ( - - ) : ( - Chat - )} - - - {viewMode === "graph" && } - {viewMode !== "graph" && ( - <> - - - - - New chat - - - } - > - - - - )} - }, - { value: "graph", icon: }, - ]} + + + + {!isChatEmpty && activeThread?.title ? ( + - - + ) : ( + Chat + )} + + + {!isChatEmpty && ( + <> + + + + + New chat + + + + + + Pin chat + + + + + + Share chat + + + )} + + - {viewMode === "graph" ? ( -
- - Failed to load mesh visualization -
- } - > - }> - - - -
- ) : !isChatEmpty ? ( - <> - - - - - - - - ) : ( -
-
- {/* Greeting */} -
-

- {greeting} {userName}, -

-

- What are we building today? -

-
+ {!isChatEmpty ? ( + <> + + + + + + + + ) : ( +
+
+ {/* Agent Image */} +
+ +
- {/* Chat Input */} - + {/* Greeting */} +
+

+ {greeting} {userName}, +

+

+ What are we building today? +

+
- {/* Ice breakers for selected agent */} - + {/* Chat Input */} +
+
+ + {/* Ice breakers for selected agent */} + +
+ + {/* Agents List - Separate container to allow wider width */} +
+
- )} - - +
+ )} + ); }