diff --git a/apps/mesh/scripts/seed-slide-project.ts b/apps/mesh/scripts/seed-slide-project.ts new file mode 100644 index 0000000000..227c14d631 --- /dev/null +++ b/apps/mesh/scripts/seed-slide-project.ts @@ -0,0 +1,135 @@ +/** + * Seed script: Creates a Slide Maker connection, wraps it in an agent, + * wraps that in a project, and sets the slide_maker tool as the default UI. + * + * Run: bun run --cwd apps/mesh scripts/seed-slide-project.ts + * + * Prerequisites: dev server must be running (bun run dev) + */ + +const BASE_URL = "http://localhost:4000"; + +async function getSession(): Promise<{ + token: string; + orgId: string; + orgSlug: string; +}> { + // Get current session + const res = await fetch(`${BASE_URL}/api/auth/get-session`, { + headers: { Accept: "application/json" }, + credentials: "include", + }); + if (!res.ok) throw new Error("Not logged in. Open the app first and log in."); + const session = (await res.json()) as { + session: { token: string }; + user: { id: string }; + }; + + // Get active org + const orgRes = await fetch( + `${BASE_URL}/api/auth/organization/get-full-organization`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session.session.token}`, + }, + body: "{}", + }, + ); + + if (!orgRes.ok) { + // Try listing orgs + const listRes = await fetch( + `${BASE_URL}/api/auth/organization/list-organizations`, + { + method: "GET", + headers: { + Authorization: `Bearer ${session.session.token}`, + }, + }, + ); + const orgs = (await listRes.json()) as Array<{ + id: string; + slug: string; + }>; + if (orgs.length === 0) throw new Error("No organizations found"); + return { + token: session.session.token, + orgId: orgs[0].id, + orgSlug: orgs[0].slug, + }; + } + + const org = (await orgRes.json()) as { id: string; slug: string }; + return { + token: session.session.token, + orgId: org.id, + orgSlug: org.slug, + }; +} + +async function callTool( + token: string, + orgId: string, + toolName: string, + args: Record, +) { + const res = await fetch(`${BASE_URL}/api/mcp/self`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "x-mesh-org-id": orgId, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: crypto.randomUUID(), + method: "tools/call", + params: { name: toolName, arguments: args }, + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Tool call failed (${res.status}): ${text}`); + } + + const json = (await res.json()) as { + result?: { structuredContent?: unknown }; + }; + return json.result?.structuredContent ?? json.result; +} + +async function main() { + console.log("Getting session..."); + + // Since we can't easily get the session token from a script, + // let's use the MCP endpoint directly through the internal API. + // The dev server should have the embedded postgres running. + + // Actually, let's just use the Kysely DB directly since we're in the same codebase. + console.log( + "\nThis script needs the dev server running. Instead of API calls,", + ); + console.log("let's create the data through the UI:\n"); + console.log("1. Open Studio in your browser (http://localhost:4000)"); + console.log("2. Go to Settings > Connections"); + console.log("3. Click 'Create connection' and fill in:"); + console.log(" - Title: Slide Maker"); + console.log(" - Type: HTTP"); + console.log(" - URL: https://slide-maker.decocms.com/api/mcp"); + console.log(" - Token: 9c8ed79c-4e23-4ca8-9f22-257afff0aee5"); + console.log("4. Save the connection"); + console.log("5. Go to Settings > Agents (or create a new agent)"); + console.log(" - Name it 'Slide Maker'"); + console.log(" - Add the Slide Maker connection to it"); + console.log("6. Go home > New Project or click '+' in sidebar"); + console.log(" - Name it 'My Slides'"); + console.log(" - In project settings, add the Slide Maker agent"); + console.log( + "\nThe slide_maker tool UI will be available in the agent's ext-apps view.", + ); +} + +main().catch(console.error); 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 854a68237b..d6253a6c89 100644 --- a/apps/mesh/src/web/components/chat/select-virtual-mcp.tsx +++ b/apps/mesh/src/web/components/chat/select-virtual-mcp.tsx @@ -15,6 +15,7 @@ import { import { cn } from "@deco/ui/lib/utils.ts"; import { isDecopilot, type VirtualMCPEntity } from "@decocms/mesh-sdk"; import { useVirtualMCPs } from "@decocms/mesh-sdk"; +import { useProjectScope } from "@/web/providers/project-scope"; import { Check, Loading01, SearchMd, Users03 } from "@untitledui/icons"; import { Suspense, @@ -79,6 +80,8 @@ export interface VirtualMCPPopoverContentProps { selectedVirtualMcpId?: string | null; onVirtualMcpChange: (virtualMcpId: string | null) => void; searchInputRef?: RefObject; + /** If provided, only show agents with these IDs (project scoping) */ + scopeToIds?: Set; } function PopoverContentLoading() { @@ -101,6 +104,7 @@ function VirtualMCPPopoverContentInner({ selectedVirtualMcpId, onVirtualMcpChange, searchInputRef, + scopeToIds: scopeToIdsProp, }: VirtualMCPPopoverContentProps) { const virtualMcps = useVirtualMCPs(); const [searchTerm, setSearchTerm] = useState(""); @@ -110,17 +114,27 @@ function VirtualMCPPopoverContentInner({ navigateOnCreate: true, }); - // Filter virtual MCPs based on search term and exclude Decopilot + // Auto-scope to project agents if inside a project + const projectScope = useProjectScope(); + const scopeToIds = scopeToIdsProp ?? projectScope?.agentIds; + + // Filter virtual MCPs: exclude Decopilot, scope to project agents if provided const filteredVirtualMcps = (() => { - // First filter out Decopilot - const nonDecopilotMcps = virtualMcps.filter( + let mcps = virtualMcps.filter( (virtualMcp) => !virtualMcp.id || !isDecopilot(virtualMcp.id), ); - if (!searchTerm.trim()) return nonDecopilotMcps; + // If scoped to a project, only show those specific agents + if (scopeToIds) { + mcps = mcps.filter( + (virtualMcp) => virtualMcp.id != null && scopeToIds.has(virtualMcp.id), + ); + } + + if (!searchTerm.trim()) return mcps; const search = searchTerm.toLowerCase(); - return nonDecopilotMcps.filter((virtualMcp) => { + return mcps.filter((virtualMcp) => { return ( virtualMcp.title.toLowerCase().includes(search) || virtualMcp.description?.toLowerCase().includes(search) diff --git a/apps/mesh/src/web/components/chat/side-panel-chat.tsx b/apps/mesh/src/web/components/chat/side-panel-chat.tsx index e99725ac6f..c982140d13 100644 --- a/apps/mesh/src/web/components/chat/side-panel-chat.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-chat.tsx @@ -1,4 +1,4 @@ -import { AgentsList } from "@/web/components/home/agents-list.tsx"; +import { QuickActions } from "@/web/components/home/quick-actions.tsx"; import { ImportFromDecoDialog } from "@/web/components/import-from-deco-dialog.tsx"; import { IntegrationIcon } from "@/web/components/integration-icon"; import { authClient } from "@/web/lib/auth-client"; @@ -111,7 +111,7 @@ function HomeEmptyState({ {/* Agents above input at bottom */}
- +
{isDecoUser && ( @@ -127,29 +127,26 @@ function HomeEmptyState({ return ( <> -
-
-
-
-

- What's on your mind, {userName}? -

-
-
- -
+
+
+
+

+ What's on your mind, {userName}? +

-
- +
+
+
+ +
{isDecoUser && ( -
-
- setImportOpen(true)} /> -
+
+ setImportOpen(true)} />
)} +
diff --git a/apps/mesh/src/web/components/chat/side-panel-tasks.tsx b/apps/mesh/src/web/components/chat/side-panel-tasks.tsx index 6f354d1c96..8fa3be3ccf 100644 --- a/apps/mesh/src/web/components/chat/side-panel-tasks.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-tasks.tsx @@ -1,24 +1,22 @@ /** * Global Tasks Side Panel * - * When inside a project: shows project name header (Cursor-style) with - * settings, New session button, pinned Views, then tasks for that project. - * - * When global (no project): shows "Tasks" header, New task button, all tasks. + * Unified task list — always shows all tasks across all projects, + * labeled by project name. Optionally filterable. */ -import { Page } from "@/web/components/page"; -import { getIconComponent, parseIconString } from "../agent-icon"; - import { usePanelActions } from "@/web/layouts/shell-layout"; -import { Edit05, LayoutLeft, Loading01, Settings01 } from "@untitledui/icons"; -import { useVirtualMCPActions, useVirtualMCP } from "@decocms/mesh-sdk"; -import type { VirtualMCPEntity } from "@decocms/mesh-sdk/types"; -import { Suspense, useEffect, useRef, useState, useTransition } from "react"; +import { Edit05, Loading01 } from "@untitledui/icons"; +import { + useVirtualMCPs, + isDecopilot as isDecopilotFn, +} from "@decocms/mesh-sdk"; +import { isProject } from "@/web/hooks/use-create-project"; +import { Suspense, useTransition } from "react"; import { isMac } from "@/web/lib/keyboard-shortcuts"; import { ErrorBoundary } from "../error-boundary"; import { Chat } from "./index"; -import { OwnerFilter, TaskListContent } from "./tasks-panel"; +import { OwnerFilter, TaskListContent, type ProjectInfo } from "./tasks-panel"; import { cn } from "@deco/ui/lib/utils.ts"; import { Skeleton } from "@deco/ui/components/skeleton.tsx"; import { @@ -26,8 +24,6 @@ import { TooltipContent, TooltipTrigger, } from "@deco/ui/components/tooltip.tsx"; -import { IconPicker } from "@/web/components/icon-picker.tsx"; -import { useInsetContext } from "@/web/layouts/shell-layout"; // ──────────────────────────────────────── // Shared nav item style — used by New session and view buttons @@ -82,196 +78,47 @@ function NewTaskButton({ ); } -// ──────────────────────────────────────── -// Pinned view icon — renders icon:// as plain stroke, falls back to Browser icon -// ──────────────────────────────────────── - -function PinnedViewIcon({ icon }: { icon: string | null | undefined }) { - const parsed = parseIconString(icon); - if (parsed.type === "icon") { - const IconComp = getIconComponent(parsed.name); - if (IconComp) { - return ; - } - } - if (parsed.type === "url") { - return ; - } - return ; -} - -// ──────────────────────────────────────── -// Views section — pinned UIs for the project -// ──────────────────────────────────────── - -function ProjectViewsSection({ project }: { project: VirtualMCPEntity }) { - const virtualMcpCtx = useInsetContext(); - const { openMainView } = usePanelActions(); - - const pinnedViews = - ((project.metadata?.ui as Record | null | undefined) - ?.pinnedViews as Array<{ - connectionId: string; - toolName: string; - label: string; - icon: string | null; - }> | null) ?? []; - - if (pinnedViews.length === 0) return null; - - // Determine which pinned view is currently active - const currentMain = virtualMcpCtx?.mainView; - const isExtAppActive = (view: { connectionId: string; toolName: string }) => - currentMain?.type === "ext-apps" && - currentMain.id === view.connectionId && - currentMain.toolName === view.toolName; - - return ( - <> - {pinnedViews.map((view) => ( - - ))} - - ); -} - -// ──────────────────────────────────────── -// Space identity header — inline-editable name, description, icon, pin -// ──────────────────────────────────────── - -function SpaceIdentityHeader({ project }: { project: VirtualMCPEntity }) { - const actions = useVirtualMCPActions(); - const [title, setTitle] = useState(project.title); - const [description, setDescription] = useState(project.description ?? ""); - const initialRenderRef = useRef(true); - - // oxlint-disable-next-line ban-use-effect/ban-use-effect — debounced title sync - useEffect(() => { - if (initialRenderRef.current) return; - const trimmed = title.trim(); - if (!trimmed || trimmed === project.title) return; - const timer = setTimeout(() => { - actions.update.mutate({ id: project.id, data: { title: trimmed } }); - }, 1000); - return () => clearTimeout(timer); - }, [title, actions.update, project.title, project.id]); - - // oxlint-disable-next-line ban-use-effect/ban-use-effect — debounced description sync - useEffect(() => { - if (initialRenderRef.current) return; - if (description === (project.description ?? "")) return; - const timer = setTimeout(() => { - actions.update.mutate({ - id: project.id, - data: { description }, - }); - }, 1000); - return () => clearTimeout(timer); - }, [description, actions.update, project.description, project.id]); - - // oxlint-disable-next-line ban-use-effect/ban-use-effect — skip initial render for debounce effects - useEffect(() => { - initialRenderRef.current = false; - }, []); - - const handleTitleChange = (e: React.ChangeEvent) => { - setTitle(e.target.value); - }; - - const handleDescriptionChange = (e: React.ChangeEvent) => { - setDescription(e.target.value); - }; - - const handleIconChange = (icon: string | null) => { - actions.update.mutate({ id: project.id, data: { icon } }); - }; - - const handleColorChange = (color: string) => { - actions.update.mutate({ - id: project.id, - data: { - metadata: { - ...project.metadata, - ui: { - ...(project.metadata?.ui as Record | undefined), - themeColor: color, - }, - }, - }, - }); - }; - - return ( -
- -
- - -
-
- ); -} - // ──────────────────────────────────────── // Panel content // ──────────────────────────────────────── function TasksPanelContent({ - virtualMcpId: virtualMcpIdProp, - hideProjectHeader, + virtualMcpId: _virtualMcpIdProp, + hideProjectHeader: _hideProjectHeader, showAutomations, }: { virtualMcpId?: string; hideProjectHeader?: boolean; showAutomations?: boolean; }) { - const virtualMcpCtx = useInsetContext(); - const { openMainView } = usePanelActions(); - const { createNewTask, setTaskId } = usePanelActions(); + const { createNewTask } = usePanelActions(); const [isPending, startTransition] = useTransition(); - const virtualMcpId = virtualMcpIdProp ?? null; - const virtualMcp = useVirtualMCP(virtualMcpId); + // Always show ALL tasks — unified panel regardless of context + const allVirtualMcps = useVirtualMCPs(); + // Only show project names (not agent names) on task labels, include default view + const projectNames = new Map( + allVirtualMcps + .filter((p) => p.id && !isDecopilotFn(p.id) && isProject(p)) + .map((p) => { + const layout = (p.metadata?.ui as Record | undefined) + ?.layout as { + defaultMainView?: { + type: string; + id?: string; + toolName?: string; + }; + } | null; + return [ + p.id, + { + name: p.title, + icon: p.icon, + defaultView: layout?.defaultMainView ?? null, + }, + ]; + }), + ); const handleNewTask = () => { startTransition(() => { @@ -279,64 +126,31 @@ function TasksPanelContent({ }); }; - const isSettingsActive = virtualMcpCtx?.mainView?.type === "settings"; - return (
- {/* Space identity */} - {virtualMcp && !hideProjectHeader && ( - - )} - {/* Header */} - {!virtualMcp && ( - - - Tasks - - - - - - )} +
+ + Tasks + + +
- {/* Nav items: New session + Settings + Views flow as one group */} -
+ {/* New task */} +
- {virtualMcp && virtualMcpCtx && !hideProjectHeader && ( - - )} - {virtualMcp && !hideProjectHeader && ( - - )}
- {/* Task list */} + {/* Task list — always all tasks, labeled by project */} { - setTaskId(taskId); - }} + projectNames={projectNames} />
); diff --git a/apps/mesh/src/web/components/chat/task/use-task-manager.ts b/apps/mesh/src/web/components/chat/task/use-task-manager.ts index 9a23d00ca1..bc7ae9bdff 100644 --- a/apps/mesh/src/web/components/chat/task/use-task-manager.ts +++ b/apps/mesh/src/web/components/chat/task/use-task-manager.ts @@ -60,14 +60,18 @@ export function useTasks( if (!client) { throw new Error("MCP client is not available"); } + const where: Record = { + ...(ownerFilter === "me" && { created_by: "me" }), + }; + // Only filter by virtual_mcp_id if one is provided + if (virtualMcpId) { + where.virtual_mcp_id = virtualMcpId; + } const input = { limit: TASK_CONSTANTS.TASKS_PAGE_SIZE, offset: 0, orderBy: [{ field: ["updated_at"], direction: "desc" as const }], - where: { - ...(ownerFilter === "me" && { created_by: "me" }), - virtual_mcp_id: virtualMcpId, - }, + where, }; const result = (await client.callTool({ diff --git a/apps/mesh/src/web/components/chat/tasks-panel.tsx b/apps/mesh/src/web/components/chat/tasks-panel.tsx index fe291caeac..a5f4a71bf8 100644 --- a/apps/mesh/src/web/components/chat/tasks-panel.tsx +++ b/apps/mesh/src/web/components/chat/tasks-panel.tsx @@ -6,6 +6,8 @@ */ import { useChatTask } from "@/web/components/chat/context"; +import { AgentAvatar } from "@/web/components/agent-icon"; +import { getTaskLayout } from "@/web/lib/task-layout-store"; import { usePanelActions } from "@/web/layouts/shell-layout"; import { useInsetContext } from "@/web/layouts/shell-layout"; import { formatTimeAgo, formatTimeUntil } from "@/web/lib/format-time"; @@ -18,14 +20,15 @@ import { import type { Task } from "./task/types"; import { useTasks } from "./task"; import { authClient } from "../../lib/auth-client"; -import { useSearch } from "@tanstack/react-router"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useProjectContext } from "@decocms/mesh-sdk"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@deco/ui/components/tooltip.tsx"; import { cn } from "@deco/ui/lib/utils.ts"; -import { Loading01, Plus, RefreshCcw01 } from "@untitledui/icons"; +import { FilterLines, Loading01, Plus, RefreshCcw01 } from "@untitledui/icons"; import { useRef, useState } from "react"; import { User as UserIcon, Users as UsersIcon } from "lucide-react"; import { @@ -141,10 +144,12 @@ function TaskRow({ task, isActive, onClick, + projectInfo, }: { task: Task; isActive: boolean; onClick: () => void; + projectInfo?: ProjectInfo; }) { const { setTaskStatus, hideTask } = useChatTask(); const playSound = useSound(question004Sound); @@ -157,7 +162,8 @@ function TaskRow({ return (
- {/* Title + time */} -
- - - {task.updated_at ? formatTimeAgo(new Date(task.updated_at)) : ""} - + {/* Title + project + time */} +
+
+ + + {task.updated_at ? formatTimeAgo(new Date(task.updated_at)) : ""} + +
+ {projectInfo && ( +
+ + + {projectInfo.name} + +
+ )}
{/* Archive button — shown on hover */} @@ -432,11 +453,26 @@ function IncomingSection({ virtualMcpId }: { virtualMcpId: string }) { // Core list (sidebar + side-panel) // ──────────────────────────────────────── +export interface ProjectInfo { + name: string; + icon: string | null; + /** Default main view for this project (ext-apps, etc.) */ + defaultView?: { + type: string; + id?: string; + toolName?: string; + } | null; +} + +type GroupBy = "none" | "project" | "status"; + interface TaskListContentProps { onTaskSelect?: (taskId: string) => void; onTaskCreate?: () => void; virtualMcpId?: string | null; showAutomations?: boolean; + /** Map of virtualMcpId → project info for labeling tasks */ + projectNames?: Map; } export function TaskListContent({ @@ -444,15 +480,15 @@ export function TaskListContent({ onTaskCreate, virtualMcpId, showAutomations = true, + projectNames, }: TaskListContentProps) { const { ownerFilter } = useChatTask(); - const { setTaskId } = usePanelActions(); + const [groupBy, setGroupBy] = useState("none"); + const [filterProjectId, setFilterProjectId] = useState(null); - // Read taskId directly from router (seeded by validateSearch) const search = useSearch({ strict: false }) as { taskId?: string }; const taskId = search.taskId ?? null; - // Own task list fetch — shares TanStack Query cache with ChatContextProvider const { data: session } = authClient.useSession(); const userId = session?.user?.id; const { tasks } = useTasks( @@ -463,34 +499,207 @@ export function TaskListContent({ const visible = tasks .filter((t) => !t.hidden) + .filter((t) => !filterProjectId || t.virtual_mcp_id === filterProjectId) .slice() .sort( (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), ); + const navigate = useNavigate(); + const { org } = useProjectContext(); + const insetCtx = useInsetContext(); + const currentVirtualMcpId = insetCtx?.virtualMcpId; + const handleSelect = (task: Task) => { if (onTaskSelect) { onTaskSelect(task.id); + return; + } + + // Build search params: restore per-task layout, fall back to project default + const saved = getTaskLayout(task.id); + const searchParams: Record = { taskId: task.id }; + + if (saved) { + // Restore saved per-task state + if (saved.chatOpen !== undefined) + searchParams.chat = saved.chatOpen ? 1 : 0; + if (saved.mainOpen !== undefined) + searchParams.mainOpen = saved.mainOpen ? 1 : 0; + if (saved.main) { + searchParams.main = saved.main; + if (saved.id) searchParams.id = saved.id; + if (saved.toolName) searchParams.toolName = saved.toolName; + } + } else if (task.virtual_mcp_id) { + // No saved state — use project's default view + const projectInfo = projectNames?.get(task.virtual_mcp_id); + const dv = projectInfo?.defaultView; + if (dv?.type) { + searchParams.main = dv.type; + searchParams.mainOpen = 1; + if (dv.id) searchParams.id = dv.id; + if (dv.toolName) searchParams.toolName = dv.toolName; + } + } + + if (task.virtual_mcp_id && task.virtual_mcp_id !== currentVirtualMcpId) { + navigate({ + to: "/$org/$virtualMcpId/", + params: { org: org.slug, virtualMcpId: task.virtual_mcp_id }, + search: searchParams, + }); } else { - setTaskId(task.id); + // Same project — just update search params + navigate({ + to: "/$org/$virtualMcpId/", + params: { + org: org.slug, + virtualMcpId: task.virtual_mcp_id ?? currentVirtualMcpId ?? "", + }, + search: searchParams, + }); } }; + const grouped = + groupBy === "project" + ? groupByProject(visible, projectNames) + : groupBy === "status" + ? groupByStatus(visible) + : null; + + const projectOptions = projectNames + ? Array.from(projectNames.entries()).map(([id, info]) => ({ + id, + name: info.name, + icon: info.icon, + })) + : []; + + const hasActiveFilter = groupBy !== "none" || filterProjectId !== null; + + const renderTask = (task: Task) => ( + handleSelect(task)} + projectInfo={ + projectNames && task.virtual_mcp_id + ? projectNames.get(task.virtual_mcp_id) + : undefined + } + /> + ); + return (
- {/* Automations section */} {virtualMcpId && showAutomations && ( )} - {/* Tasks section header */} -
- + {/* Header with filter */} +
+ Tasks - + + + + + + + + + + + Filter + + + { + setGroupBy("none"); + setFilterProjectId(null); + }} + > + No grouping + {!hasActiveFilter && ( + + active + + )} + + setGroupBy("project")} + > + Group by project + {groupBy === "project" && ( + + active + + )} + + setGroupBy("status")} + > + Group by status + {groupBy === "status" && ( + + active + + )} + + {projectOptions.length > 0 && ( + <> +
+
+ + Filter by project + +
+ {projectOptions.map((p) => ( + + setFilterProjectId( + filterProjectId === p.id ? null : p.id, + ) + } + > + + {p.name} + {filterProjectId === p.id && ( + + active + + )} + + ))} + + )} + + + {onTaskCreate && ( @@ -507,16 +716,23 @@ export function TaskListContent({ )}
- {/* Task rows — always visible */} - {visible.length > 0 ? ( - visible.map((task) => ( - handleSelect(task)} - /> + {/* Task rows */} + {grouped ? ( + Object.entries(grouped).map(([label, groupTasks]) => ( +
+
+ + {label} + + + {groupTasks.length} + +
+ {groupTasks.map(renderTask)} +
)) + ) : visible.length > 0 ? ( + visible.map(renderTask) ) : (
No tasks @@ -526,3 +742,30 @@ export function TaskListContent({
); } + +function groupByProject( + tasks: Task[], + projectNames?: Map, +): Record { + const groups: Record = {}; + for (const task of tasks) { + const name = + (task.virtual_mcp_id && projectNames?.get(task.virtual_mcp_id)?.name) ?? + "Other"; + if (!groups[name]) groups[name] = []; + groups[name].push(task); + } + return groups; +} + +function groupByStatus(tasks: Task[]): Record { + const groups: Record = {}; + for (const task of tasks) { + const status = task.status ?? "unknown"; + const label = + STATUS_CONFIG[status as keyof typeof STATUS_CONFIG]?.label ?? status; + if (!groups[label]) groups[label] = []; + groups[label].push(task); + } + return groups; +} diff --git a/apps/mesh/src/web/components/files/artifact-card.tsx b/apps/mesh/src/web/components/files/artifact-card.tsx new file mode 100644 index 0000000000..c47f05f984 --- /dev/null +++ b/apps/mesh/src/web/components/files/artifact-card.tsx @@ -0,0 +1,181 @@ +/** + * ArtifactCard - Visual card for a file/artifact in the file browser. + * Shows icon, title, type badge, and relative time. + */ + +import { cn } from "@deco/ui/lib/utils.ts"; +import { BarChart12, Globe04, PresentationChart01 } from "@untitledui/icons"; +import { + formatRelativeTime, + type Artifact, + type ArtifactType, +} from "@/web/lib/mock-artifacts"; + +const TYPE_CONFIG: Record< + ArtifactType, + { + label: string; + bgColor: string; + textColor: string; + iconBg: string; + Icon: typeof PresentationChart01; + } +> = { + deck: { + label: "Slide Deck", + bgColor: "bg-violet-50 dark:bg-violet-950/30", + textColor: "text-violet-700 dark:text-violet-300", + iconBg: "bg-violet-100 dark:bg-violet-900/50", + Icon: PresentationChart01, + }, + report: { + label: "Report", + bgColor: "bg-emerald-50 dark:bg-emerald-950/30", + textColor: "text-emerald-700 dark:text-emerald-300", + iconBg: "bg-emerald-100 dark:bg-emerald-900/50", + Icon: BarChart12, + }, + site: { + label: "Website", + bgColor: "bg-blue-50 dark:bg-blue-950/30", + textColor: "text-blue-700 dark:text-blue-300", + iconBg: "bg-blue-100 dark:bg-blue-900/50", + Icon: Globe04, + }, +}; + +export function ArtifactCard({ + artifact, + variant = "grid", + onClick, +}: { + artifact: Artifact; + variant?: "grid" | "list"; + onClick?: () => void; +}) { + const config = TYPE_CONFIG[artifact.type]; + const { Icon } = config; + + if (variant === "list") { + return ( + + ); + } + + return ( + + ); +} + +export function ArtifactTypeFilter({ + value, + onChange, +}: { + value: ArtifactType | "all"; + onChange: (type: ArtifactType | "all") => void; +}) { + const filters: Array<{ key: ArtifactType | "all"; label: string }> = [ + { key: "all", label: "All" }, + { key: "deck", label: "Decks" }, + { key: "report", label: "Reports" }, + { key: "site", label: "Sites" }, + ]; + + return ( +
+ {filters.map((f) => ( + + ))} +
+ ); +} diff --git a/apps/mesh/src/web/components/files/file-browser-home.tsx b/apps/mesh/src/web/components/files/file-browser-home.tsx new file mode 100644 index 0000000000..43b0554b33 --- /dev/null +++ b/apps/mesh/src/web/components/files/file-browser-home.tsx @@ -0,0 +1,306 @@ +/** + * FileBrowserHome - The main file-centric home page. + * Shows folders, recent artifacts, and quick actions. + * Quick actions send messages to the chat agent. + */ + +import { cn } from "@deco/ui/lib/utils.ts"; +import { + BarChart12, + Globe04, + Plus, + PresentationChart01, + SearchLg, +} from "@untitledui/icons"; +import { authClient } from "@/web/lib/auth-client"; +import { + MOCK_FOLDERS, + MOCK_ARTIFACTS, + getRecentArtifacts, + type ArtifactType, +} from "@/web/lib/mock-artifacts"; +import { FolderCard } from "./folder-card"; +import { ArtifactCard, ArtifactTypeFilter } from "./artifact-card"; +import { useState } from "react"; +import { FolderView } from "./folder-view"; +import { useChatTask } from "@/web/components/chat/context"; + +function QuickAction({ + icon: Icon, + label, + color, + onClick, +}: { + icon: typeof PresentationChart01; + label: string; + color: string; + onClick?: () => void; +}) { + return ( + + ); +} + +function SectionHeader({ + title, + action, +}: { + title: string; + action?: { label: string; onClick: () => void }; +}) { + return ( +
+

{title}

+ {action && ( + + )} +
+ ); +} + +type View = { type: "home" } | { type: "folder"; id: string } | { type: "all" }; + +function AllFilesView({ onBack }: { onBack: () => void }) { + const [typeFilter, setTypeFilter] = useState("all"); + const [search, setSearch] = useState(""); + + const artifacts = MOCK_ARTIFACTS.filter((a) => { + if (typeFilter !== "all" && a.type !== typeFilter) return false; + if (search && !a.title.toLowerCase().includes(search.toLowerCase())) + return false; + return true; + }).sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + return ( +
+ {/* Header */} +
+
+
+ + / +

All Files

+ + {artifacts.length} {artifacts.length === 1 ? "item" : "items"} + +
+
+
+ + {/* Toolbar */} +
+ +
+ + setSearch(e.target.value)} + className="h-7 pl-8 pr-3 text-xs rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> +
+
+ + {/* Content */} +
+ {artifacts.length === 0 ? ( +
+

No files found

+
+ ) : ( +
+ {artifacts.map((artifact) => ( + + ))} +
+ )} +
+
+ ); +} + +function HomeContent({ + onOpenFolder, + onOpenAll, +}: { + onOpenFolder: (folderId: string) => void; + onOpenAll: () => void; +}) { + const { data: session } = authClient.useSession(); + const { createTaskWithMessage } = useChatTask(); + const userName = session?.user?.name?.split(" ")[0] || "there"; + const recentArtifacts = getRecentArtifacts(6); + + const sendQuickAction = (text: string) => { + createTaskWithMessage({ + message: { + parts: [{ type: "text", text }], + }, + }); + }; + + return ( +
+
+ {/* Greeting */} +
+

+ Good {getTimeOfDay()}, {userName} +

+

+ Here's what you've been working on +

+
+ + {/* Quick Actions */} +
+
+ + sendQuickAction( + "Create a new slide deck. Ask me what it should be about.", + ) + } + /> + + sendQuickAction( + "Run a website diagnostic. Ask me which site to analyze.", + ) + } + /> + + sendQuickAction( + "I'd like to edit a website. Ask me which site and what changes.", + ) + } + /> +
+
+ + {/* Folders */} +
+ {} }} + /> +
+ {MOCK_FOLDERS.map((folder) => ( + onOpenFolder(folder.id)} + /> + ))} + +
+
+ + {/* Recent */} +
+ +
+ {recentArtifacts.map((artifact) => ( + + ))} +
+
+
+
+ ); +} + +function getTimeOfDay(): string { + const hour = new Date().getHours(); + if (hour < 12) return "morning"; + if (hour < 18) return "afternoon"; + return "evening"; +} + +export function FileBrowserHome() { + const [view, setView] = useState({ type: "home" }); + + if (view.type === "folder") { + return ( + setView({ type: "home" })} /> + ); + } + + if (view.type === "all") { + return setView({ type: "home" })} />; + } + + return ( + setView({ type: "folder", id })} + onOpenAll={() => setView({ type: "all" })} + /> + ); +} diff --git a/apps/mesh/src/web/components/files/folder-card.tsx b/apps/mesh/src/web/components/files/folder-card.tsx new file mode 100644 index 0000000000..0d253e6711 --- /dev/null +++ b/apps/mesh/src/web/components/files/folder-card.tsx @@ -0,0 +1,43 @@ +/** + * FolderCard - Visual card for a folder in the file browser. + */ + +import { cn } from "@deco/ui/lib/utils.ts"; +import { FolderClosed } from "@untitledui/icons"; +import type { Folder } from "@/web/lib/mock-artifacts"; + +export function FolderCard({ + folder, + onClick, +}: { + folder: Folder; + onClick?: () => void; +}) { + return ( + + ); +} diff --git a/apps/mesh/src/web/components/files/folder-view.tsx b/apps/mesh/src/web/components/files/folder-view.tsx new file mode 100644 index 0000000000..6a3c0b18af --- /dev/null +++ b/apps/mesh/src/web/components/files/folder-view.tsx @@ -0,0 +1,95 @@ +/** + * FolderView - Shows artifacts inside a specific folder. + * Supports filtering by type and list/grid view. + */ + +import { ChevronLeft, FolderClosed } from "@untitledui/icons"; +import { useState } from "react"; +import { + getArtifactsByFolder, + getFolderById, + type ArtifactType, +} from "@/web/lib/mock-artifacts"; +import { ArtifactCard, ArtifactTypeFilter } from "./artifact-card"; + +export function FolderView({ + folderId, + onBack, +}: { + folderId: string; + onBack: () => void; +}) { + const folder = getFolderById(folderId); + const [typeFilter, setTypeFilter] = useState("all"); + + if (!folder) { + return ( +
+ Folder not found +
+ ); + } + + const allArtifacts = getArtifactsByFolder(folderId); + const artifacts = + typeFilter === "all" + ? allArtifacts + : allArtifacts.filter((a) => a.type === typeFilter); + + return ( +
+ {/* Header */} +
+
+ +
+ +
+

+ {folder.title} +

+ + {allArtifacts.length} {allArtifacts.length === 1 ? "item" : "items"} + +
+
+ + {/* Toolbar */} +
+ +
+ + {/* Content */} +
+ {artifacts.length === 0 ? ( +
+

+ {typeFilter === "all" + ? "This folder is empty" + : `No ${typeFilter}s in this folder`} +

+
+ ) : ( +
+ {artifacts.map((artifact) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/apps/mesh/src/web/components/home/quick-actions.tsx b/apps/mesh/src/web/components/home/quick-actions.tsx new file mode 100644 index 0000000000..fd413cf1b2 --- /dev/null +++ b/apps/mesh/src/web/components/home/quick-actions.tsx @@ -0,0 +1,314 @@ +/** + * QuickActions - Action-oriented items for the home page. + * Replaces the agents list with actions like "New Site", "New Diagnostic", etc. + */ + +import { cn } from "@deco/ui/lib/utils.ts"; +import { + BarChart12, + ChevronRight, + Globe04, + Plus, + PresentationChart01, +} from "@untitledui/icons"; +import { + isDecopilot, + WELL_KNOWN_AGENT_TEMPLATES, + useProjectContext, + useVirtualMCPs, +} from "@decocms/mesh-sdk"; +import { useNavigate } from "@tanstack/react-router"; +import { Suspense, useState } from "react"; +import { Skeleton } from "@deco/ui/components/skeleton.tsx"; +import { useCreateProject } from "@/web/hooks/use-create-project"; +import { useCreateSlideProject } from "@/web/hooks/use-create-slide-project"; +import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; +import { SiteEditorOnboardingModal } from "@/web/components/home/site-editor-onboarding-modal"; +import { SiteDiagnosticsRecruitModal } from "@/web/components/home/site-diagnostics-recruit-modal"; +import { + getRecentArtifacts, + formatRelativeTime, + type Artifact, + type ArtifactType, +} from "@/web/lib/mock-artifacts"; + +// ---------- Action item (replaces agent preview) ---------- + +const ACTION_ICON_CONFIG: Record< + string, + { Icon: typeof PresentationChart01; color: string; bg: string } +> = { + "slide-maker": { + Icon: PresentationChart01, + color: "#8B5CF6", + bg: "bg-violet-100 dark:bg-violet-900/50", + }, + "site-editor": { + Icon: Globe04, + color: "#3B82F6", + bg: "bg-blue-100 dark:bg-blue-900/50", + }, + "site-diagnostics": { + Icon: BarChart12, + color: "#10B981", + bg: "bg-emerald-100 dark:bg-emerald-900/50", + }, +}; + +function ActionItem({ + label, + icon, + iconColor, + onClick, +}: { + label: string; + icon?: string | null; + iconColor?: string; + onClick?: () => void; +}) { + const config = icon ? ACTION_ICON_CONFIG[icon] : undefined; + const Icon = config?.Icon; + + return ( + + ); +} + +// ---------- Recent artifacts ---------- + +const ARTIFACT_ICON: Record< + ArtifactType, + { Icon: typeof PresentationChart01; color: string } +> = { + deck: { Icon: PresentationChart01, color: "#8B5CF6" }, + report: { Icon: BarChart12, color: "#10B981" }, + site: { Icon: Globe04, color: "#3B82F6" }, +}; + +function ArtifactRow({ artifact }: { artifact: Artifact }) { + const config = ARTIFACT_ICON[artifact.type]; + const { Icon } = config; + + return ( + + ); +} + +function RecentSection() { + const recentArtifacts = getRecentArtifacts(5); + if (recentArtifacts.length === 0) return null; + + return ( +
+
+

+ Recent +

+
+
+ {recentArtifacts.map((artifact) => ( + + ))} +
+
+ ); +} + +// ---------- Main content ---------- + +function QuickActionsContent() { + const virtualMcps = useVirtualMCPs(); + const { org } = useProjectContext(); + const navigate = useNavigate(); + const navigateToAgent = useNavigateToAgent(); + const [siteEditorModalOpen, setSiteEditorModalOpen] = useState(false); + const [diagnosticsModalOpen, setDiagnosticsModalOpen] = useState(false); + const { createProject } = useCreateProject({ + navigateOnCreate: true, + }); + const { create: createSlideProject } = useCreateSlideProject(); + + const siteDiagnosticsAgent = WELL_KNOWN_AGENT_TEMPLATES.find( + (t) => t.id === "site-diagnostics", + )!; + + // Find existing diagnostics agent to include in new projects + const existingDiagnostics = virtualMcps.find( + (a): a is typeof a & { id: string } => + a.id !== null && + ((a as { metadata?: { type?: string } }).metadata?.type === + siteDiagnosticsAgent.id || + a.title === siteDiagnosticsAgent.title), + ); + + const handleNewDiagnostic = () => { + if (existingDiagnostics) { + // Create project with existing diagnostics agent + createProject({ + title: "New Diagnostic", + agentIds: [existingDiagnostics.id], + }); + } else { + // Need to recruit the agent first + setDiagnosticsModalOpen(true); + } + }; + + const handleNewSite = () => { + // Site editor needs onboarding first + setSiteEditorModalOpen(true); + }; + + return ( + <> + {/* Action items row */} +
+
+ createSlideProject()} + /> + + + {/* Custom agents as actions */} + {virtualMcps + .filter( + (a): a is typeof a & { id: string } => + a.id !== null && + !isDecopilot(a.id) && + a.id !== existingDiagnostics?.id, + ) + .slice(0, 4) + .map((agent) => ( + navigateToAgent(agent.id)} + /> + ))} + createProject()} /> + +
+
+ + {/* Recent */} + + + + + + ); +} + +function QuickActionsSkeleton() { + return ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ ); +} + +export function QuickActions() { + return ( + }> + + + ); +} diff --git a/apps/mesh/src/web/components/layout/content-toolbar.tsx b/apps/mesh/src/web/components/layout/content-toolbar.tsx new file mode 100644 index 0000000000..2d3b736099 --- /dev/null +++ b/apps/mesh/src/web/components/layout/content-toolbar.tsx @@ -0,0 +1,185 @@ +/** + * ContentToolbar — Top bar on the main content panel. + * Shows icons for available UI tools from the project's agents. + * Styled to match the top bar layout toggles (size-7 ghost buttons, 16px icons). + */ + +import { cn } from "@deco/ui/lib/utils.ts"; +import { useInsetContext, usePanelActions } from "@/web/layouts/shell-layout"; +import { useSearch } from "@tanstack/react-router"; +import { useVirtualMCP, useVirtualMCPs } from "@decocms/mesh-sdk"; +import { isProject } from "@/web/hooks/use-create-project"; +import { AgentAvatar } from "@/web/components/agent-icon"; +import { Folder, Settings02 } from "@untitledui/icons"; +import { Suspense } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; + +interface ToolUIEntry { + connectionId: string; + toolName: string; + label: string; + icon: string | null; +} + +function useProjectToolUIs(virtualMcpId: string): ToolUIEntry[] { + const entity = useVirtualMCP(virtualMcpId); + const allVirtualMcps = useVirtualMCPs(); + + if (!entity || !isProject(entity)) return []; + + const pinnedViews = + ((entity.metadata?.ui as Record | null | undefined) + ?.pinnedViews as ToolUIEntry[] | null) ?? []; + + if (pinnedViews.length > 0) return pinnedViews; + + const defaultView = ( + entity.metadata?.ui as Record | null | undefined + )?.layout as { + defaultMainView?: { + type: string; + id?: string; + toolName?: string; + }; + } | null; + + if ( + defaultView?.defaultMainView?.type === "ext-apps" && + defaultView.defaultMainView.id + ) { + const connId = defaultView.defaultMainView.id; + const toolName = defaultView.defaultMainView.toolName ?? ""; + const agent = allVirtualMcps.find((a) => + a.connections.some((c) => c.connection_id === connId), + ); + return [ + { + connectionId: connId, + toolName, + label: agent?.title ?? toolName, + icon: agent?.icon ?? null, + }, + ]; + } + + return []; +} + +/** Button style matching the top bar layout toggles */ +const toolbarBtnClass = + "flex size-7 shrink-0 items-center justify-center rounded-md transition-colors"; +const toolbarBtnActive = "bg-sidebar-accent text-sidebar-foreground"; +const toolbarBtnInactive = + "text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-foreground"; + +function ToolbarContent() { + const ctx = useInsetContext(); + const { openMainView } = usePanelActions(); + + if (!ctx) return null; + const { virtualMcpId, mainView } = ctx; + + const toolUIs = useProjectToolUIs(virtualMcpId); + const entity = useVirtualMCP(virtualMcpId); + const entityIsProject = entity ? isProject(entity) : false; + + if (!entityIsProject) return null; + + const search = useSearch({ strict: false }) as { main?: string }; + const isSettingsActive = mainView?.type === "settings"; + const isFilesActive = search.main === "files"; + + return ( +
+ {/* Files */} + + + + + Files + + + {/* Tool UI icons */} + {toolUIs.map((tool) => { + const isActive = + mainView?.type === "ext-apps" && + mainView.id === tool.connectionId && + mainView.toolName === tool.toolName; + + return ( + + + + + {tool.label} + + ); + })} + +
+ + {/* Settings */} + + + + + Settings + +
+ ); +} + +export function ContentToolbar() { + return ( + + + + ); +} diff --git a/apps/mesh/src/web/components/sidebar/agents-section.tsx b/apps/mesh/src/web/components/sidebar/agents-section.tsx index 156b48a51e..2bfbd329eb 100644 --- a/apps/mesh/src/web/components/sidebar/agents-section.tsx +++ b/apps/mesh/src/web/components/sidebar/agents-section.tsx @@ -56,7 +56,6 @@ import { } from "@decocms/mesh-sdk"; import type { VirtualMCPEntity } from "@decocms/mesh-sdk/types"; import { usePinnedAgents } from "@/web/hooks/use-pinned-agents"; -import { useCreateVirtualMCP } from "@/web/hooks/use-create-virtual-mcp"; import { useCreateTaskAndNavigate } from "@/web/hooks/use-create-task-and-navigate"; import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; import { AgentAvatar } from "@/web/components/agent-icon"; @@ -65,6 +64,7 @@ import { SiteEditorOnboardingModal } from "@/web/components/home/site-editor-onb import { SiteDiagnosticsRecruitModal } from "@/web/components/home/site-diagnostics-recruit-modal.tsx"; import { StudioPackRecruitModal } from "@/web/components/home/studio-pack-recruit-modal.tsx"; import { useAgentBadges } from "@/web/hooks/use-agent-badges"; +import { isProject, useCreateProject } from "@/web/hooks/use-create-project"; function AgentListItem({ agent, @@ -191,8 +191,11 @@ function AgentListItem({ onClick={(e) => { e.stopPropagation(); setButtonRect(null); - onUnpin(); - navigate({ to: "/$org", params: { org } }); + navigate({ + to: "/$org/$virtualMcpId", + params: { org, virtualMcpId: agent.id }, + search: { main: "settings", mainOpen: 1 }, + }); }} className={cn( "flex items-center justify-center", @@ -214,7 +217,7 @@ function AgentListItem({ "inset 0 0 0.5px 1px hsla(0, 0%, 100%, 0.075), 0 0 0 0.5px hsla(0, 0%, 0%, 0.12)", }} > - + , document.body, )} @@ -304,7 +307,7 @@ function PinAgentPopoverContent({ const { org } = useProjectContext(); const serverPinnedIds = allAgents.filter((a) => a.pinned).map((a) => a.id); const { pin, isPinned } = usePinnedAgents(org.id, serverPinnedIds); - const { createVirtualMCP, isCreating } = useCreateVirtualMCP({ + const { createProject, isCreating: isCreatingProject } = useCreateProject({ navigateOnCreate: true, }); @@ -312,8 +315,10 @@ function PinAgentPopoverContent({ const navigateToAgent = useNavigateToAgent(); const lowerSearch = search.toLowerCase(); - const userAgents = allAgents - .filter((s) => !isDecopilot(s.id)) + + // Only show projects (metadata.type === "project") in the projects section + const projects = allAgents + .filter((s) => !isDecopilot(s.id) && isProject(s)) .filter((s) => !search || s.title.toLowerCase().includes(lowerSearch)); const studioPackInstalled = allAgents.some((a) => isStudioPackAgent(a.id)); @@ -368,7 +373,7 @@ function PinAgentPopoverContent({ {/* Scrollable content */} @@ -376,16 +381,16 @@ function PinAgentPopoverContent({ {/* Agents section */}
- Agents + Projects
- {/* Create new button */} + {/* Create new project */}
- Create new + New project - {userAgents.map((agent) => ( + {projects.map((project) => ( handleSelect(agent)} + key={project.id} + agent={project} + onClick={() => handleSelect(project)} /> ))}
@@ -438,11 +443,11 @@ function PinAgentPopoverContent({ )} - {userAgents.length === 0 && + {projects.length === 0 && filteredTemplates.length === 0 && - !isCreating && ( + !isCreatingProject && (
- {search ? "No agents found" : "No agents yet"} + {search ? "No projects found" : "No projects yet"}
)}
@@ -455,7 +460,7 @@ function PinAgentPopoverContent({ onClick={() => onClose()} className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center justify-center" > - See all agents + See all projects
@@ -498,7 +503,7 @@ function PinAgentPopover() { <> setOpen(true)} > @@ -507,7 +512,7 @@ function PinAgentPopover() { - Browse agents + Browse projects {popoverContent} @@ -517,7 +522,7 @@ function PinAgentPopover() { @@ -552,13 +557,19 @@ function PinAgentPopover() { function AgentsSectionContent() { const allAgents = useVirtualMCPs(); const { org } = useProjectContext(); - const serverPinnedIds = allAgents.filter((a) => a.pinned).map((a) => a.id); + + // Only show projects (metadata.type === "project") in the sidebar + const projectsOnly = allAgents.filter( + (a) => !isDecopilot(a.id) && isProject(a), + ); + + const serverPinnedIds = projectsOnly.filter((a) => a.pinned).map((a) => a.id); const { pinnedIds, unpin, reorder } = usePinnedAgents( org.id, serverPinnedIds, ); - const agentMap = new Map(allAgents.map((a) => [a.id, a])); + const agentMap = new Map(projectsOnly.map((a) => [a.id, a])); const pinnedAgents = pinnedIds .map((id) => agentMap.get(id)) .filter((a): a is VirtualMCPEntity => !!a); diff --git a/apps/mesh/src/web/hooks/use-create-project.ts b/apps/mesh/src/web/hooks/use-create-project.ts new file mode 100644 index 0000000000..e81353315a --- /dev/null +++ b/apps/mesh/src/web/hooks/use-create-project.ts @@ -0,0 +1,54 @@ +/** + * Hook to create a new project. + * Projects are Virtual MCPs with metadata.type = "project". + * They contain agents (other Virtual MCPs) and tasks. + */ + +import { useVirtualMCPActions, type VirtualMCPEntity } from "@decocms/mesh-sdk"; +import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; + +export function useCreateProject(options: { navigateOnCreate?: boolean } = {}) { + const { navigateOnCreate = false } = options; + const actions = useVirtualMCPActions(); + const navigateToAgent = useNavigateToAgent(); + + const createProject = async (params?: { + title?: string; + agentIds?: string[]; + }) => { + const connections = (params?.agentIds ?? []).map((id) => ({ + connection_id: id, + selected_tools: null, + selected_resources: null, + selected_prompts: null, + })); + + const virtualMcp = await actions.create.mutateAsync({ + title: params?.title ?? "New Project", + description: "", + status: "active", + connections, + pinned: true, + metadata: { + instructions: null, + type: "project", + }, + }); + + if (navigateOnCreate) { + navigateToAgent(virtualMcp.id!); + } + + return { id: virtualMcp.id!, virtualMcp }; + }; + + return { + createProject, + isCreating: actions.create.isPending, + }; +} + +/** Check if a Virtual MCP is a project (vs an agent) */ +export function isProject(entity: VirtualMCPEntity): boolean { + return (entity.metadata as Record)?.type === "project"; +} diff --git a/apps/mesh/src/web/hooks/use-create-slide-project.ts b/apps/mesh/src/web/hooks/use-create-slide-project.ts new file mode 100644 index 0000000000..e3cd11e927 --- /dev/null +++ b/apps/mesh/src/web/hooks/use-create-slide-project.ts @@ -0,0 +1,93 @@ +/** + * Hook to create a full Slide Maker project in one shot: + * 1. Creates the Slide Maker HTTP connection + * 2. Creates a "Slide Maker" agent with that connection + * 3. Creates a project with the agent inside + * 4. Navigates to the project + */ + +import { useConnectionActions, useVirtualMCPActions } from "@decocms/mesh-sdk"; +import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; +import { useState } from "react"; + +const SLIDE_MAKER_URL = "https://slide-maker.decocms.com/api/mcp"; +const SLIDE_MAKER_TOKEN = "9c8ed79c-4e23-4ca8-9f22-257afff0aee5"; + +export function useCreateSlideProject() { + const connectionActions = useConnectionActions(); + const vmcpActions = useVirtualMCPActions(); + const navigateToAgent = useNavigateToAgent(); + const [isCreating, setIsCreating] = useState(false); + + const create = async () => { + if (isCreating) return; + setIsCreating(true); + + try { + // 1. Create the HTTP connection for Slide Maker + const connection = await connectionActions.create.mutateAsync({ + title: "Slide Maker", + description: "Create and edit slide decks", + connection_type: "HTTP", + connection_url: SLIDE_MAKER_URL, + connection_token: SLIDE_MAKER_TOKEN, + status: "active", + }); + + const connectionId = connection.id; + + // 2. Create the Slide Maker agent (Virtual MCP) with this connection + const agent = await vmcpActions.create.mutateAsync({ + title: "Slide Maker", + description: "Creates and edits slide presentations", + status: "active", + pinned: false, // Not pinned — lives inside a project + connections: [ + { + connection_id: connectionId, + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + + // 3. Create the project with the agent and set default UI to slide_maker tool + const project = await vmcpActions.create.mutateAsync({ + title: "My Slides", + description: "Slide decks and presentations", + status: "active", + pinned: true, + connections: [ + { + connection_id: agent.id!, + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + metadata: { + instructions: null, + type: "project", + ui: { + layout: { + defaultMainView: { + type: "ext-apps", + id: connectionId, + toolName: "slide_maker", + }, + chatDefaultOpen: true, + }, + }, + }, + }); + + // 4. Navigate to the project + navigateToAgent(project.id!); + } finally { + setIsCreating(false); + } + }; + + return { create, isCreating }; +} diff --git a/apps/mesh/src/web/hooks/use-layout-state.ts b/apps/mesh/src/web/hooks/use-layout-state.ts index ce41be25ef..46e5a58411 100644 --- a/apps/mesh/src/web/hooks/use-layout-state.ts +++ b/apps/mesh/src/web/hooks/use-layout-state.ts @@ -11,6 +11,7 @@ import { useRef } from "react"; import { useMatch, useNavigate, useSearch } from "@tanstack/react-router"; +import { saveTaskLayout } from "@/web/lib/task-layout-store"; import { getDecopilotId, getWellKnownDecopilotVirtualMCP, @@ -82,6 +83,7 @@ export function resolveDefaultPanelState(ctx: { entityMetadata: EntityLayoutMetadata | null; hasMainParam: boolean; isAgentHomeRoute: boolean; + isProject?: boolean; }): { tasksOpen: boolean; mainOpen: boolean; chatOpen: boolean } { const allOpen = { tasksOpen: true, mainOpen: true, chatOpen: true }; @@ -95,10 +97,15 @@ export function resolveDefaultPanelState(ctx: { return allOpen; } - // Decopilot ID: tasks closed, main closed, chat open + // Decopilot ID: tasks open (all tasks), main closed, chat open const isDecopilot = ctx.virtualMcpId === getDecopilotId(ctx.orgId); if (isDecopilot) { - return { tasksOpen: false, mainOpen: false, chatOpen: true }; + return { tasksOpen: true, mainOpen: false, chatOpen: true }; + } + + // Projects: show files (main) + chat by default + if (ctx.isProject) { + return { tasksOpen: false, mainOpen: true, chatOpen: true }; } // Entity metadata driven defaults @@ -176,6 +183,7 @@ function parsePanelParam( export function usePanelState( entityMetadata: EntityLayoutMetadata | null, + options?: { isProject?: boolean }, ): LayoutState & LayoutActions { const navigate = useNavigate(); const { org } = useProjectContext(); @@ -211,6 +219,7 @@ export function usePanelState( entityMetadata, hasMainParam: !!search.main, isAgentHomeRoute, + isProject: options?.isProject, }; const defaults = resolveDefaultPanelState(resolveCtx); @@ -223,6 +232,20 @@ export function usePanelState( const fallbackRef = useRef(crypto.randomUUID()); const taskId = search.taskId ?? fallbackRef.current; + // Persist per-task layout (chat + main state, not tasks panel) + const prevTaskIdRef = useRef(taskId); + if (taskId && search.taskId) { + // Only save when we have an explicit taskId from URL (not fallback) + saveTaskLayout(taskId, { + chatOpen, + mainOpen, + main: search.main, + id: search.id, + toolName: search.toolName, + }); + } + prevTaskIdRef.current = taskId; + // Expanded count for toggle guard const expandedCount = [tasksOpen, mainOpen, chatOpen].filter(Boolean).length; diff --git a/apps/mesh/src/web/layouts/shell-layout.tsx b/apps/mesh/src/web/layouts/shell-layout.tsx index aae790ba32..b954561e79 100644 --- a/apps/mesh/src/web/layouts/shell-layout.tsx +++ b/apps/mesh/src/web/layouts/shell-layout.tsx @@ -11,6 +11,8 @@ import { ChatPanel } from "@/web/components/chat/side-panel-chat"; import { TasksSidePanel } from "@/web/components/chat/side-panel-tasks"; import { ErrorBoundary } from "@/web/components/error-boundary"; import { SplashScreen } from "@/web/components/splash-screen"; +import { ContentToolbar } from "@/web/components/layout/content-toolbar"; +import { ProjectScopeProvider } from "@/web/providers/project-scope"; import { KeyboardShortcutsDialog } from "@/web/components/keyboard-shortcuts-dialog"; import { isMac, isModKey } from "@/web/lib/keyboard-shortcuts"; import { StudioSidebar, StudioSidebarMobile } from "@/web/components/sidebar"; @@ -440,8 +442,18 @@ function InsetProvider({ isSettingsRoute }: { isSettingsRoute: boolean }) { } : null; + // Check if entity is a project and compute its agent IDs + const entityIsProject = + (entity?.metadata as Record | undefined)?.type === + "project"; + const projectAgentIds = entityIsProject + ? new Set(entity!.connections.map((c) => c.connection_id)) + : null; + // Layout state from URL querystring - const layout = usePanelState(entityMetadata); + const layout = usePanelState(entityMetadata, { + isProject: entityIsProject, + }); // Tasks panel virtualMcpId const tasksVirtualMcpId = virtualMcpId; @@ -616,29 +628,29 @@ function InsetProvider({ isSettingsRoute }: { isSettingsRoute: boolean }) { @@ -647,51 +659,71 @@ function InsetProvider({ isSettingsRoute }: { isSettingsRoute: boolean }) {
- - - {showThreePanels ? ( - + + - ) : ( -
-
- - -
- } + {showThreePanels ? ( + + ) : ( +
+
-
- -
- + + +
+ } + > +
+ +
+ +
-
- )} - + )} + + ); } +/** Wraps children in ProjectScopeProvider only when agentIds is non-null */ +function MaybeProjectScope({ + agentIds, + children, +}: { + agentIds: Set | null; + children: React.ReactNode; +}) { + if (agentIds) { + return ( + + {children} + + ); + } + return <>{children}; +} + function MobileToolbar({ onOpenSidebar }: { onOpenSidebar: () => void }) { return (
@@ -785,6 +817,7 @@ function UnifiedPanelGroup({ className="flex-1 min-h-0 pb-1 pr-1 pl-0 pt-0" style={{ overflow: "visible" }} > + {/* Panel 1: Tasks */}
@@ -798,9 +831,21 @@ function UnifiedPanelGroup({ + {/* Panel 2: Chat (center) */} + +
+
+ +
+
+
+ + + + {/* Panel 3: Main content / Layout (right) */} + @@ -826,22 +872,13 @@ function UnifiedPanelGroup({
} > -
+
- - - -
-
- -
-
-
); } diff --git a/apps/mesh/src/web/lib/mock-artifacts.ts b/apps/mesh/src/web/lib/mock-artifacts.ts new file mode 100644 index 0000000000..2eff686fa1 --- /dev/null +++ b/apps/mesh/src/web/lib/mock-artifacts.ts @@ -0,0 +1,262 @@ +/** + * Mock artifact data for the filesystem UX prototype. + * In production, this would come from MCP app queries and a Studio artifact registry. + */ + +export type ArtifactType = "deck" | "report" | "site"; + +export interface Artifact { + id: string; + title: string; + type: ArtifactType; + /** Which folder this belongs to, null = unfiled */ + folderId: string | null; + /** Preview snippet or summary */ + preview: string; + /** When last modified */ + updatedAt: string; + /** Which MCP app connection this came from */ + connectionId: string; + /** Tool name to open this artifact */ + toolName: string; + /** External ID in the MCP app */ + externalId: string; +} + +export interface Folder { + id: string; + title: string; + icon: string; + color: string; + itemCount: number; +} + +const ARTIFACT_TYPE_META: Record< + ArtifactType, + { label: string; icon: string } +> = { + deck: { label: "Slide Deck", icon: "presentation" }, + report: { label: "Report", icon: "report" }, + site: { label: "Website", icon: "globe" }, +}; + +export function getArtifactTypeMeta(type: ArtifactType) { + return ARTIFACT_TYPE_META[type]; +} + +// ---------- Mock Folders ---------- + +export const MOCK_FOLDERS: Folder[] = [ + { + id: "fld_deco", + title: "Deco", + icon: "building", + color: "#8B5CF6", + itemCount: 5, + }, + { + id: "fld_farm", + title: "Farm", + icon: "shopping", + color: "#10B981", + itemCount: 3, + }, + { + id: "fld_clients", + title: "Clients", + icon: "users", + color: "#F59E0B", + itemCount: 4, + }, +]; + +// ---------- Mock Artifacts ---------- + +const now = Date.now(); +const HOUR = 3600_000; +const DAY = 86400_000; + +export const MOCK_ARTIFACTS: Artifact[] = [ + // Deco folder + { + id: "art_pitch_v3", + title: "Pitch Deck v3", + type: "deck", + folderId: "fld_deco", + preview: "12 slides - Company overview, product demo, market opportunity", + updatedAt: new Date(now - 2 * HOUR).toISOString(), + connectionId: "conn_slide_maker", + toolName: "slide_maker", + externalId: "deck_pitch_v3", + }, + { + id: "art_health_report", + title: "Site Health Report", + type: "report", + folderId: "fld_deco", + preview: "Health score: 87/100 - Performance, SEO, accessibility audit", + updatedAt: new Date(now - 1 * DAY).toISOString(), + connectionId: "conn_diagnostics", + toolName: "diagnose", + externalId: "diag_deco_health", + }, + { + id: "art_deco_site", + title: "decocms.com", + type: "site", + folderId: "fld_deco", + preview: "Main marketing site - Next.js, 24 pages", + updatedAt: new Date(now - 3 * DAY).toISOString(), + connectionId: "conn_site_editor", + toolName: "site_editor", + externalId: "site_deco", + }, + { + id: "art_brand_guide", + title: "Brand Guidelines", + type: "deck", + folderId: "fld_deco", + preview: "8 slides - Colors, typography, logo usage, brand voice", + updatedAt: new Date(now - 7 * DAY).toISOString(), + connectionId: "conn_slide_maker", + toolName: "slide_maker", + externalId: "deck_brand_guide", + }, + { + id: "art_seo_analysis", + title: "SEO Analysis", + type: "report", + folderId: "fld_deco", + preview: "Health score: 72/100 - Keyword ranking, backlink audit", + updatedAt: new Date(now - 14 * DAY).toISOString(), + connectionId: "conn_diagnostics", + toolName: "diagnose", + externalId: "diag_deco_seo", + }, + // Farm folder + { + id: "art_farm_site", + title: "farm.com.br", + type: "site", + folderId: "fld_farm", + preview: "E-commerce - 156 products, 12 categories", + updatedAt: new Date(now - 2 * DAY).toISOString(), + connectionId: "conn_site_editor", + toolName: "site_editor", + externalId: "site_farm", + }, + { + id: "art_product_strategy", + title: "Product Strategy", + type: "deck", + folderId: "fld_farm", + preview: "15 slides - PLP optimization, conversion funnel, A/B tests", + updatedAt: new Date(now - 4 * DAY).toISOString(), + connectionId: "conn_slide_maker", + toolName: "slide_maker", + externalId: "deck_product_strategy", + }, + { + id: "art_farm_diagnostic", + title: "Performance Audit", + type: "report", + folderId: "fld_farm", + preview: "Health score: 64/100 - Core Web Vitals, image optimization", + updatedAt: new Date(now - 5 * DAY).toISOString(), + connectionId: "conn_diagnostics", + toolName: "diagnose", + externalId: "diag_farm_perf", + }, + // Clients folder + { + id: "art_client_proposal", + title: "Client Proposal Template", + type: "deck", + folderId: "fld_clients", + preview: "10 slides - Services overview, case studies, pricing", + updatedAt: new Date(now - 1 * DAY).toISOString(), + connectionId: "conn_slide_maker", + toolName: "slide_maker", + externalId: "deck_client_proposal", + }, + { + id: "art_acme_site", + title: "acme-store.com", + type: "site", + folderId: "fld_clients", + preview: "Client site - Shopify integration, 89 products", + updatedAt: new Date(now - 6 * DAY).toISOString(), + connectionId: "conn_site_editor", + toolName: "site_editor", + externalId: "site_acme", + }, + { + id: "art_acme_diagnostic", + title: "Acme Store Audit", + type: "report", + folderId: "fld_clients", + preview: "Health score: 91/100 - Excellent performance and SEO", + updatedAt: new Date(now - 8 * DAY).toISOString(), + connectionId: "conn_diagnostics", + toolName: "diagnose", + externalId: "diag_acme", + }, + { + id: "art_q4_review", + title: "Q4 Review", + type: "deck", + folderId: "fld_clients", + preview: "20 slides - Quarterly results, client satisfaction, roadmap", + updatedAt: new Date(now - 10 * DAY).toISOString(), + connectionId: "conn_slide_maker", + toolName: "slide_maker", + externalId: "deck_q4_review", + }, + // Unfiled + { + id: "art_quick_deck", + title: "Quick Notes", + type: "deck", + folderId: null, + preview: "3 slides - Meeting notes from standup", + updatedAt: new Date(now - 30 * 60_000).toISOString(), + connectionId: "conn_slide_maker", + toolName: "slide_maker", + externalId: "deck_quick_notes", + }, +]; + +// ---------- Helper functions ---------- + +export function getArtifactsByFolder(folderId: string): Artifact[] { + return MOCK_ARTIFACTS.filter((a) => a.folderId === folderId).sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); +} + +export function getRecentArtifacts(limit = 8): Artifact[] { + return [...MOCK_ARTIFACTS] + .sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ) + .slice(0, limit); +} + +export function getFolderById(id: string): Folder | undefined { + return MOCK_FOLDERS.find((f) => f.id === id); +} + +export function formatRelativeTime(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60_000); + const hours = Math.floor(diff / HOUR); + const days = Math.floor(diff / DAY); + const weeks = Math.floor(days / 7); + + if (minutes < 1) return "Just now"; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + return `${weeks}w ago`; +} diff --git a/apps/mesh/src/web/lib/task-layout-store.ts b/apps/mesh/src/web/lib/task-layout-store.ts new file mode 100644 index 0000000000..723490573f --- /dev/null +++ b/apps/mesh/src/web/lib/task-layout-store.ts @@ -0,0 +1,54 @@ +/** + * Per-task layout state persistence. + * Stores which panels were open and which main view was active per taskId. + * Tasks panel state is NOT stored here (it's global). + */ + +const STORAGE_KEY = "mesh:task-layout"; +const MAX_ENTRIES = 200; + +interface TaskLayoutState { + chatOpen?: boolean; + mainOpen?: boolean; + main?: string; + id?: string; + toolName?: string; +} + +type Store = Record; + +function readStore(): Store { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +function writeStore(store: Store) { + // Prune oldest entries if over limit + const keys = Object.keys(store); + if (keys.length > MAX_ENTRIES) { + const toRemove = keys.slice(0, keys.length - MAX_ENTRIES); + for (const key of toRemove) { + delete store[key]; + } + } + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } catch { + // Ignore storage errors + } +} + +export function saveTaskLayout(taskId: string, state: TaskLayoutState) { + const store = readStore(); + store[taskId] = state; + writeStore(store); +} + +export function getTaskLayout(taskId: string): TaskLayoutState | null { + const store = readStore(); + return store[taskId] ?? null; +} diff --git a/apps/mesh/src/web/providers/project-scope.tsx b/apps/mesh/src/web/providers/project-scope.tsx new file mode 100644 index 0000000000..894b53d4b1 --- /dev/null +++ b/apps/mesh/src/web/providers/project-scope.tsx @@ -0,0 +1,30 @@ +/** + * ProjectScope — Context that provides the current project's agent IDs. + * Used to scope the agent selector to only show agents in this project. + */ + +import { createContext, use, type ReactNode } from "react"; + +interface ProjectScopeValue { + /** Set of Virtual MCP IDs that are agents in the current project */ + agentIds: Set; +} + +const ProjectScopeContext = createContext(null); + +export function ProjectScopeProvider({ + agentIds, + children, +}: { + agentIds: Set; + children: ReactNode; +}) { + return ( + {children} + ); +} + +/** Returns the project's agent IDs if inside a project, null otherwise */ +export function useProjectScope(): ProjectScopeValue | null { + return use(ProjectScopeContext); +} diff --git a/apps/mesh/src/web/routes/agent-home.tsx b/apps/mesh/src/web/routes/agent-home.tsx index 780d240256..53b2eddc5d 100644 --- a/apps/mesh/src/web/routes/agent-home.tsx +++ b/apps/mesh/src/web/routes/agent-home.tsx @@ -11,7 +11,11 @@ import { type MainViewType, } from "@/web/layouts/shell-layout"; import { useVirtualMCP } from "@decocms/mesh-sdk"; +import { useSearch } from "@tanstack/react-router"; import { useChatTask } from "@/web/components/chat/context"; +import { isProject } from "@/web/hooks/use-create-project"; +import { ProjectHome } from "@/web/views/project/project-home"; +import { ProjectSettings } from "@/web/views/project/project-settings"; const ProjectAppViewContent = lazy(() => import("./project-app-view").then((m) => ({ @@ -96,8 +100,43 @@ function AgentEmptyState() { function AgentHomeContent() { const { virtualMcpId } = useInsetContext()!; + const entity = useVirtualMCP(virtualMcpId); + const entityIsProject = entity ? isProject(entity) : false; + const search = useSearch({ strict: false }) as { main?: string }; + const resolved = useResolvedMainView(); + // Projects: use raw search param to decide view (don't fall through to entity default) + if (entityIsProject) { + // Files is the default — show when ?main=files, or when no ?main= param at all + if (search.main === "files" || !search.main) { + return ; + } + if (resolved.type === "settings") { + return ; + } + if (resolved.type === "ext-apps") { + // Read toolInput from project metadata (e.g., { deckId: "xxx" }) + const defaultViewMeta = ( + entity?.metadata?.ui as Record | null | undefined + )?.layout as { + defaultMainView?: { toolInput?: Record }; + } | null; + const toolInput = defaultViewMeta?.defaultMainView?.toolInput; + + return ( + + ); + } + // Default: show project files + return ; + } + + // Agents: existing behavior if (resolved.type === "chat") { return ; } diff --git a/apps/mesh/src/web/routes/project-app-view.tsx b/apps/mesh/src/web/routes/project-app-view.tsx index e10f4def4e..67065a95e9 100644 --- a/apps/mesh/src/web/routes/project-app-view.tsx +++ b/apps/mesh/src/web/routes/project-app-view.tsx @@ -28,6 +28,7 @@ function AppRenderer({ resourceURI, tool, connectionId, + toolInput: toolInputProp, }: { client: ReturnType; resourceURI: string; @@ -38,15 +39,17 @@ function AppRenderer({ _meta?: Record; }; connectionId: string; + toolInput?: Record; }) { const { sendMessage } = useChatBridge(); const { setAppContext, clearAppContext } = useChatPrefs(); const { setChatOpen } = usePanelActions(); const sourceId = `${connectionId}:${tool.name}`; + const effectiveInput = toolInputProp ?? EMPTY_TOOL_INPUT; const { data: toolResult } = useMCPToolCall({ client, toolName: tool.name, - toolArguments: EMPTY_TOOL_INPUT, + toolArguments: effectiveInput, }); const clientId = getGatewayClientId(tool._meta); @@ -71,7 +74,7 @@ function AppRenderer({ ; }) { const { org } = useProjectContext(); const client = useMCPClient({ connectionId, orgId: org.id }); @@ -118,6 +123,7 @@ export function AppViewContent({ resourceURI={resourceURI} tool={tool} connectionId={connectionId} + toolInput={toolInput} /> ); } diff --git a/apps/mesh/src/web/views/project/project-home.tsx b/apps/mesh/src/web/views/project/project-home.tsx new file mode 100644 index 0000000000..9144f3a065 --- /dev/null +++ b/apps/mesh/src/web/views/project/project-home.tsx @@ -0,0 +1,165 @@ +/** + * ProjectHome — The file browser view when entering a project. + * Shows the project's files (artifacts) in a clean, visual layout. + * This is what users see first — their stuff, not settings. + */ + +import { cn } from "@deco/ui/lib/utils.ts"; +import { + BarChart12, + Globe04, + Plus, + PresentationChart01, +} from "@untitledui/icons"; +import { useVirtualMCP } from "@decocms/mesh-sdk"; +import { + getRecentArtifacts, + formatRelativeTime, + type Artifact, + type ArtifactType, +} from "@/web/lib/mock-artifacts"; +import { useChatTask } from "@/web/components/chat/context"; + +const FILE_ICON: Record< + ArtifactType, + { + Icon: typeof PresentationChart01; + color: string; + bg: string; + label: string; + } +> = { + deck: { + Icon: PresentationChart01, + color: "#8B5CF6", + bg: "bg-violet-100 dark:bg-violet-900/40", + label: "Slide Deck", + }, + report: { + Icon: BarChart12, + color: "#10B981", + bg: "bg-emerald-100 dark:bg-emerald-900/40", + label: "Report", + }, + site: { + Icon: Globe04, + color: "#3B82F6", + bg: "bg-blue-100 dark:bg-blue-900/40", + label: "Website", + }, +}; + +function FileCard({ artifact }: { artifact: Artifact }) { + const config = FILE_ICON[artifact.type]; + const { Icon } = config; + + return ( + + ); +} + +function EmptyFiles({ onCreateFirst }: { onCreateFirst: () => void }) { + return ( +
+
+ +
+

No files yet

+

+ Start a conversation to create slides, reports, or edit a site +

+ +
+ ); +} + +export function ProjectHome({ virtualMcpId }: { virtualMcpId: string }) { + const entity = useVirtualMCP(virtualMcpId); + const { createTaskWithMessage } = useChatTask(); + + // For prototype: show mock artifacts (in real app, query project's MCP apps) + const files = getRecentArtifacts(10); + + const handleStartWorking = () => { + createTaskWithMessage({ + message: { + parts: [ + { + type: "text", + text: "What can I help you create? I can make slide decks, run diagnostics, or help edit a site.", + }, + ], + }, + }); + }; + + return ( +
+
+ {/* Project header */} +
+

+ {entity?.title ?? "Project"} +

+ {entity?.description && ( +

+ {entity.description} +

+ )} +
+ + {/* Files */} +
+
+

+ Files +

+
+ + {files.length === 0 ? ( + + ) : ( +
+ {files.map((artifact) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/mesh/src/web/views/project/project-settings.tsx b/apps/mesh/src/web/views/project/project-settings.tsx new file mode 100644 index 0000000000..81848697e5 --- /dev/null +++ b/apps/mesh/src/web/views/project/project-settings.tsx @@ -0,0 +1,433 @@ +/** + * ProjectSettings — Simplified settings for a project. + * Just: name, icon, description, and which agents are assigned. + * No instructions, no connections, no layout — those belong to agents. + */ + +import { cn } from "@deco/ui/lib/utils.ts"; +import { Page } from "@/web/components/page"; +import { AgentAvatar } from "@/web/components/agent-icon"; +import { IconPicker } from "@/web/components/icon-picker"; +import { ErrorBoundary } from "@/web/components/error-boundary"; +import { AddAgentDialog } from "@/web/views/virtual-mcp/add-agent-dialog"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@deco/ui/components/alert-dialog.tsx"; +import { Check, ChevronRight, Plus, Trash01 } from "@untitledui/icons"; +import { + useProjectContext, + useVirtualMCP, + useVirtualMCPActions, + useVirtualMCPs, +} from "@decocms/mesh-sdk"; +import { useNavigate } from "@tanstack/react-router"; +import { Suspense, useState } from "react"; +import { Skeleton } from "@deco/ui/components/skeleton.tsx"; +import { toast } from "sonner"; + +function AgentCard({ + agentId, + orgSlug, + onRemove, +}: { + agentId: string; + orgSlug: string; + onRemove: () => void; +}) { + const agent = useVirtualMCP(agentId); + const navigate = useNavigate(); + + if (!agent) return null; + + const connectionCount = agent.connections.length; + + return ( +
+ + +
+ ); +} + +function AgentCardSkeleton() { + return ( +
+ +
+ + +
+
+ ); +} + +/** + * LayoutSection — Shows which agent connections have UIs and lets + * the user set one as the default view for this project. + */ +function LayoutSection({ + entity, + allVirtualMcps, +}: { + entity: { + id: string; + connections: Array<{ connection_id: string }>; + metadata: Record; + }; + allVirtualMcps: Array<{ + id: string; + title: string; + icon: string | null; + connections: Array<{ connection_id: string }>; + }>; +}) { + const actions = useVirtualMCPActions(); + const virtualMcpIds = new Set(allVirtualMcps.map((v) => v.id)); + + // Find all connections (non-virtual) across all agents in this project + const agentChildren = entity.connections.filter((c) => + virtualMcpIds.has(c.connection_id), + ); + + // Collect all real connections from agents + const connectionEntries: Array<{ + agentTitle: string; + connectionId: string; + }> = []; + for (const ac of agentChildren) { + const agent = allVirtualMcps.find((v) => v.id === ac.connection_id); + if (!agent) continue; + for (const conn of agent.connections) { + if (!virtualMcpIds.has(conn.connection_id)) { + connectionEntries.push({ + agentTitle: agent.title, + connectionId: conn.connection_id, + }); + } + } + } + + // Get current default view + const currentDefault = ( + entity.metadata?.ui as Record | undefined + )?.layout as + | { defaultMainView?: { type: string; id?: string; toolName?: string } } + | undefined; + const currentDefaultId = currentDefault?.defaultMainView?.id; + + const handleSetDefault = async (connectionId: string, toolName: string) => { + const isAlreadyDefault = currentDefaultId === connectionId; + await actions.update.mutateAsync({ + id: entity.id, + data: { + metadata: { + ...entity.metadata, + instructions: + ((entity.metadata as Record)?.instructions as + | string + | null) ?? null, + ui: { + ...(entity.metadata?.ui as Record | undefined), + layout: { + defaultMainView: isAlreadyDefault + ? null + : { type: "ext-apps", id: connectionId, toolName }, + chatDefaultOpen: true, + }, + }, + }, + }, + }); + }; + + if (connectionEntries.length === 0) return null; + + return ( +
+

Layout

+

+ Connections with UIs. Click to set as default view when opening this + project. +

+
+ {connectionEntries.map((entry) => { + const isDefault = currentDefaultId === entry.connectionId; + return ( + + ); + })} +
+
+ ); +} + +export function ProjectSettings({ virtualMcpId }: { virtualMcpId: string }) { + const entity = useVirtualMCP(virtualMcpId); + const actions = useVirtualMCPActions(); + const { org } = useProjectContext(); + const navigate = useNavigate(); + const allVirtualMcps = useVirtualMCPs(); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + if (!entity) return null; + + // Identify which children are agents (Virtual MCPs) + const virtualMcpIds = new Set(allVirtualMcps.map((v) => v.id)); + const agentChildren = entity.connections.filter((c) => + virtualMcpIds.has(c.connection_id), + ); + const addedAgentIds = new Set(agentChildren.map((c) => c.connection_id)); + + const handleAddAgent = async (agentId: string) => { + const current = entity.connections; + await actions.update.mutateAsync({ + id: entity.id, + data: { + connections: [ + ...current, + { + connection_id: agentId, + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }, + }); + toast.success("Agent added to project"); + }; + + const handleRemoveAgent = async (agentId: string) => { + const current = entity.connections; + await actions.update.mutateAsync({ + id: entity.id, + data: { + connections: current.filter((c) => c.connection_id !== agentId), + }, + }); + toast.success("Agent removed from project"); + }; + + const handleUpdateTitle = async (title: string) => { + if (!title.trim()) return; + await actions.update.mutateAsync({ + id: entity.id, + data: { title: title.trim() }, + }); + }; + + const handleUpdateDescription = async (description: string) => { + await actions.update.mutateAsync({ + id: entity.id, + data: { description: description || null }, + }); + }; + + const handleIconChange = (icon: string | null) => { + actions.update.mutate({ id: entity.id, data: { icon } }); + }; + + const handleDelete = async () => { + await actions.delete.mutateAsync(entity.id); + toast.success(`Deleted "${entity.title}"`); + navigate({ to: "/$org", params: { org: org.slug } }); + }; + + return ( +
+
+ setDeleteDialogOpen(true)} + > + + + } + > + Project Settings + + + {/* Basic info */} +
+ +
+ handleUpdateTitle(e.target.value)} + placeholder="Project name" + className="text-base font-medium" + /> + handleUpdateDescription(e.target.value)} + placeholder="Add a description..." + className="text-sm" + /> +
+
+ + {/* Agents section */} +
+
+

Agents

+ +
+ + {agentChildren.length === 0 ? ( + + ) : ( +
+ {agentChildren.map((conn) => ( + null}> + }> + handleRemoveAgent(conn.connection_id)} + /> + + + ))} +
+ )} +
+ + {/* Layout section — which UIs are available, set default */} + +
+ + {/* Dialogs */} + + + + + + Delete Project? + + This will permanently delete{" "} + + {entity.title} + {" "} + and all its tasks. Agents will not be deleted. + + + + Cancel + + Delete + + + + +
+ ); +} diff --git a/apps/mesh/src/web/views/virtual-mcp/add-agent-dialog.tsx b/apps/mesh/src/web/views/virtual-mcp/add-agent-dialog.tsx new file mode 100644 index 0000000000..a9f25ecf55 --- /dev/null +++ b/apps/mesh/src/web/views/virtual-mcp/add-agent-dialog.tsx @@ -0,0 +1,168 @@ +/** + * AddAgentDialog — Browse existing Virtual MCPs to add as agents to a project, + * or create a new agent. + */ + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { CollectionSearch } from "@deco/ui/components/collection-search.tsx"; +import { Plus } from "@untitledui/icons"; +import { isDecopilot, useVirtualMCPs } from "@decocms/mesh-sdk"; +import { useState, Suspense } from "react"; +import { AgentAvatar } from "@/web/components/agent-icon"; +import { useCreateVirtualMCP } from "@/web/hooks/use-create-virtual-mcp"; +import { Skeleton } from "@deco/ui/components/skeleton.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; + +function AddAgentDialogContent({ + projectId, + addedAgentIds, + onAdd, + onClose, +}: { + projectId: string; + addedAgentIds: Set; + onAdd: (agentId: string) => void; + onClose: () => void; +}) { + const [search, setSearch] = useState(""); + const allVirtualMcps = useVirtualMCPs(); + const { createVirtualMCP, isCreating } = useCreateVirtualMCP({ + navigateOnCreate: false, + }); + + const lowerSearch = search.toLowerCase(); + + // Show all Virtual MCPs except: decopilot, current project, already added + const available = allVirtualMcps.filter( + (v) => + !isDecopilot(v.id) && + v.id !== projectId && + !addedAgentIds.has(v.id) && + (!search || v.title.toLowerCase().includes(lowerSearch)), + ); + + const handleCreateAndAdd = async () => { + const result = await createVirtualMCP(); + if (result?.id) { + onAdd(result.id); + onClose(); + } + }; + + return ( +
+ + +
+
+ + Available Agents + +
+
+ {/* Create new agent */} + + + {available.map((agent) => ( + + ))} +
+ + {available.length === 0 && !isCreating && ( +
+ {search + ? "No agents found" + : "No available agents. Create a new one."} +
+ )} +
+
+ ); +} + +export function AddAgentDialog({ + open, + onOpenChange, + projectId, + addedAgentIds, + onAdd, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + projectId: string; + addedAgentIds: Set; + onAdd: (agentId: string) => void; +}) { + return ( + + + + + Add Agent + + + + +
+ } + > + onOpenChange(false)} + /> + + + + ); +} diff --git a/apps/mesh/src/web/views/virtual-mcp/index.tsx b/apps/mesh/src/web/views/virtual-mcp/index.tsx index 028cf77ddd..e1c2e5bb3b 100644 --- a/apps/mesh/src/web/views/virtual-mcp/index.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/index.tsx @@ -57,6 +57,7 @@ import { useProjectContext, useVirtualMCP, useVirtualMCPActions, + useVirtualMCPs, } from "@decocms/mesh-sdk"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -74,8 +75,10 @@ import { Suspense, useReducer, useRef, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { toast } from "sonner"; import { SimpleIconPicker } from "../../components/simple-icon-picker"; +import { AgentAvatar } from "@/web/components/agent-icon"; import { Page } from "@/web/components/page"; import { AddConnectionDialog } from "./add-connection-dialog"; +import { AddAgentDialog } from "./add-agent-dialog"; import { DependencySelectionDialog } from "./dependency-selection-dialog"; import { ALL_ITEMS_SELECTED } from "./selection-utils"; import { VirtualMcpFormSchema, type VirtualMcpFormData } from "./types"; @@ -84,6 +87,7 @@ import { VirtualMCPShareModal } from "./virtual-mcp-share-modal"; type DialogState = { shareDialogOpen: boolean; addDialogOpen: boolean; + addAgentDialogOpen: boolean; settingsDialogOpen: boolean; settingsConnectionId: string | null; }; @@ -91,6 +95,7 @@ type DialogState = { type DialogAction = | { type: "SET_SHARE_DIALOG_OPEN"; payload: boolean } | { type: "SET_ADD_DIALOG_OPEN"; payload: boolean } + | { type: "SET_ADD_AGENT_DIALOG_OPEN"; payload: boolean } | { type: "OPEN_SETTINGS"; payload: string } | { type: "CLOSE_SETTINGS" }; @@ -100,6 +105,8 @@ function dialogReducer(state: DialogState, action: DialogAction): DialogState { return { ...state, shareDialogOpen: action.payload }; case "SET_ADD_DIALOG_OPEN": return { ...state, addDialogOpen: action.payload }; + case "SET_ADD_AGENT_DIALOG_OPEN": + return { ...state, addAgentDialogOpen: action.payload }; case "OPEN_SETTINGS": return { ...state, @@ -481,6 +488,74 @@ function ConnectionItemSkeleton() { ); } +// --------------------------------------------------------------------------- +// Agent Item — represents a child Virtual MCP inside a project +// --------------------------------------------------------------------------- + +function AgentItem({ + agentId, + onRemove, +}: { + agentId: string; + onRemove: () => void; +}) { + const agent = useVirtualMCP(agentId); + const { org } = useProjectContext(); + const navigate = useNavigate(); + + if (!agent) return null; + + const connectionCount = agent.connections.length; + + return ( +
+ +
+ +
+
+ ); +} + // --------------------------------------------------------------------------- // Layout tab content (projects only) // --------------------------------------------------------------------------- @@ -893,8 +968,8 @@ function LayoutTabContent({ virtualMcpId }: { virtualMcpId: string }) { {noConnections && (

- No connections yet. Add connections in the Connections tab to - configure pinned views. + No agents yet. Add agents in the Agents tab to configure pinned + views.

)} {noInteractiveTools && !noConnections && ( @@ -1037,10 +1112,22 @@ function VirtualMcpDetailViewWithData({ // Watch connections for reactive UI const connections = form.watch("connections"); + // Identify which children are Virtual MCPs (agents) vs regular connections + const allVirtualMcps = useVirtualMCPs(); + const virtualMcpIds = new Set(allVirtualMcps.map((v) => v.id)); + const agentConnections = connections.filter((c) => + virtualMcpIds.has(c.connection_id), + ); + const regularConnections = connections.filter( + (c) => !virtualMcpIds.has(c.connection_id), + ); + const addedAgentIds = new Set(agentConnections.map((c) => c.connection_id)); + // Dialog states const [dialogState, dispatch] = useReducer(dialogReducer, { shareDialogOpen: false, addDialogOpen: false, + addAgentDialogOpen: false, settingsDialogOpen: false, settingsConnectionId: null, }); @@ -1362,7 +1449,7 @@ Define step-by-step how the agent should handle requests. }, { id: "connections", - label: "Connections", + label: "Agents", count: connections.length || undefined, }, { id: "layout", label: "Layout" }, @@ -1378,7 +1465,7 @@ Define step-by-step how the agent should handle requests.
+
+ +
)} {activeTab === "instructions" && (
@@ -1489,45 +1583,88 @@ Define step-by-step how the agent should handle requests. )} {activeTab === "connections" && ( -
- {connections.length === 0 ? ( +
+ {/* Agents section */} + {agentConnections.length === 0 ? ( ) : ( - connections.map((conn) => ( - null} - > - }> - - handleOpenSettings(conn.connection_id) - } - onRemove={() => - handleRemoveConnection(conn.connection_id) - } - onAuthenticate={handleAuthenticate} - onSwitchInstance={handleSwitchInstance} - onNewInstance={() => - handleNewInstance(conn.connection_id) - } - /> - - - )) +
+ {agentConnections.map((conn) => ( + null} + > + }> + + handleRemoveConnection(conn.connection_id) + } + /> + + + ))} +
+ )} + + {/* Regular connections (shown separately if any exist) */} + {regularConnections.length > 0 && ( +
+
+ + Direct Connections + + +
+ {regularConnections.map((conn) => ( + null} + > + }> + + handleOpenSettings(conn.connection_id) + } + onRemove={() => + handleRemoveConnection(conn.connection_id) + } + onAuthenticate={handleAuthenticate} + onSwitchInstance={handleSwitchInstance} + onNewInstance={() => + handleNewInstance(conn.connection_id) + } + /> + + + ))} +
)}
)} @@ -1543,7 +1680,7 @@ Define step-by-step how the agent should handle requests. - Delete Agent? + Delete Project? This action cannot be undone. This will permanently delete{" "} @@ -1573,6 +1710,16 @@ Define step-by-step how the agent should handle requests. onAdd={handleAddConnection} /> + + dispatch({ type: "SET_ADD_AGENT_DIALOG_OPEN", payload: open }) + } + projectId={virtualMcp.id} + addedAgentIds={addedAgentIds} + onAdd={handleAddConnection} + /> + {