From 759f1b7983ddf26ff1aeb0179dabe70f46d572bb Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 02:06:27 -0300 Subject: [PATCH 01/23] feat(home): add filesystem UX with folders and artifact browser Replace the agent-centric org home with a file-browser home that shows folders and recent artifacts. Users think in files and folders, not agents. - Add mock artifact data (slides, reports, sites) and folder definitions - Add ArtifactCard, FolderCard, FileBrowserHome, FolderView components - Change decopilot home to show file browser in main panel + chat sidebar - Conditionally render home chat variant only when main panel is closed Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/files/artifact-card.tsx | 181 ++++++++++++ .../components/files/file-browser-home.tsx | 182 ++++++++++++ .../src/web/components/files/folder-card.tsx | 43 +++ .../src/web/components/files/folder-view.tsx | 95 +++++++ apps/mesh/src/web/hooks/use-layout-state.ts | 4 +- apps/mesh/src/web/layouts/shell-layout.tsx | 8 +- apps/mesh/src/web/lib/mock-artifacts.ts | 262 ++++++++++++++++++ apps/mesh/src/web/routes/agent-home.tsx | 13 +- 8 files changed, 783 insertions(+), 5 deletions(-) create mode 100644 apps/mesh/src/web/components/files/artifact-card.tsx create mode 100644 apps/mesh/src/web/components/files/file-browser-home.tsx create mode 100644 apps/mesh/src/web/components/files/folder-card.tsx create mode 100644 apps/mesh/src/web/components/files/folder-view.tsx create mode 100644 apps/mesh/src/web/lib/mock-artifacts.ts 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..9f79df89f3 --- /dev/null +++ b/apps/mesh/src/web/components/files/file-browser-home.tsx @@ -0,0 +1,182 @@ +/** + * FileBrowserHome - The main file-centric home page. + * Shows folders, recent artifacts, and quick actions. + */ + +import { cn } from "@deco/ui/lib/utils.ts"; +import { + BarChart12, + Globe04, + Plus, + PresentationChart01, +} from "@untitledui/icons"; +import { authClient } from "@/web/lib/auth-client"; +import { MOCK_FOLDERS, getRecentArtifacts } from "@/web/lib/mock-artifacts"; +import { FolderCard } from "./folder-card"; +import { ArtifactCard } from "./artifact-card"; +import { useState } from "react"; +import { FolderView } from "./folder-view"; + +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 && ( + + )} +
+ ); +} + +function HomeContent({ + onOpenFolder, +}: { + onOpenFolder: (folderId: string) => void; +}) { + const { data: session } = authClient.useSession(); + const userName = session?.user?.name?.split(" ")[0] || "there"; + const recentArtifacts = getRecentArtifacts(6); + + return ( +
+
+ {/* Greeting */} +
+

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

+

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

+
+ + {/* Quick Actions */} +
+
+ + + +
+
+ + {/* 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 [openFolderId, setOpenFolderId] = useState(null); + + if (openFolderId) { + return ( + setOpenFolderId(null)} + /> + ); + } + + return ; +} 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/hooks/use-layout-state.ts b/apps/mesh/src/web/hooks/use-layout-state.ts index ce41be25ef..d33e7c7240 100644 --- a/apps/mesh/src/web/hooks/use-layout-state.ts +++ b/apps/mesh/src/web/hooks/use-layout-state.ts @@ -95,10 +95,10 @@ export function resolveDefaultPanelState(ctx: { return allOpen; } - // Decopilot ID: tasks closed, main closed, chat open + // Decopilot ID: tasks closed, main open (file browser), chat open const isDecopilot = ctx.virtualMcpId === getDecopilotId(ctx.orgId); if (isDecopilot) { - return { tasksOpen: false, mainOpen: false, chatOpen: true }; + return { tasksOpen: false, mainOpen: true, chatOpen: true }; } // Entity metadata driven defaults diff --git a/apps/mesh/src/web/layouts/shell-layout.tsx b/apps/mesh/src/web/layouts/shell-layout.tsx index aae790ba32..24a9961980 100644 --- a/apps/mesh/src/web/layouts/shell-layout.tsx +++ b/apps/mesh/src/web/layouts/shell-layout.tsx @@ -838,7 +838,9 @@ function UnifiedPanelGroup({
- +
@@ -856,7 +858,9 @@ function MobileAgentContent({ return (
{isDecopilot || !mainOpen ? ( - + ) : ( )} 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/routes/agent-home.tsx b/apps/mesh/src/web/routes/agent-home.tsx index 780d240256..c4462ff100 100644 --- a/apps/mesh/src/web/routes/agent-home.tsx +++ b/apps/mesh/src/web/routes/agent-home.tsx @@ -10,8 +10,13 @@ import { type MainView, type MainViewType, } from "@/web/layouts/shell-layout"; -import { useVirtualMCP } from "@decocms/mesh-sdk"; +import { + useVirtualMCP, + getDecopilotId, + useProjectContext, +} from "@decocms/mesh-sdk"; import { useChatTask } from "@/web/components/chat/context"; +import { FileBrowserHome } from "@/web/components/files/file-browser-home"; const ProjectAppViewContent = lazy(() => import("./project-app-view").then((m) => ({ @@ -96,9 +101,15 @@ function AgentEmptyState() { function AgentHomeContent() { const { virtualMcpId } = useInsetContext()!; + const { org } = useProjectContext(); const resolved = useResolvedMainView(); + const isDecopilot = virtualMcpId === getDecopilotId(org.id); if (resolved.type === "chat") { + // Decopilot shows file browser as the main view + if (isDecopilot) { + return ; + } return ; } From a94362b2e7d75c276d42d4673b599ae3f582449a Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 02:08:33 -0300 Subject: [PATCH 02/23] feat(home): add quick actions, all-files view, and search - Wire quick actions to create chat tasks with relevant prompts - Add "All Files" view with search and type filtering - Add "See all" link from recent section Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/files/file-browser-home.tsx | 146 ++++++++++++++++-- 1 file changed, 135 insertions(+), 11 deletions(-) diff --git a/apps/mesh/src/web/components/files/file-browser-home.tsx b/apps/mesh/src/web/components/files/file-browser-home.tsx index 9f79df89f3..43b0554b33 100644 --- a/apps/mesh/src/web/components/files/file-browser-home.tsx +++ b/apps/mesh/src/web/components/files/file-browser-home.tsx @@ -1,6 +1,7 @@ /** * 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"; @@ -9,13 +10,20 @@ import { Globe04, Plus, PresentationChart01, + SearchLg, } from "@untitledui/icons"; import { authClient } from "@/web/lib/auth-client"; -import { MOCK_FOLDERS, getRecentArtifacts } from "@/web/lib/mock-artifacts"; +import { + MOCK_FOLDERS, + MOCK_ARTIFACTS, + getRecentArtifacts, + type ArtifactType, +} from "@/web/lib/mock-artifacts"; import { FolderCard } from "./folder-card"; -import { ArtifactCard } from "./artifact-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, @@ -68,15 +76,103 @@ function SectionHeader({ ); } +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 (
@@ -97,13 +193,32 @@ function HomeContent({ icon={PresentationChart01} label="New Slide Deck" color="#8B5CF6" + onClick={() => + 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.", + ) + } /> -
@@ -143,7 +258,10 @@ function HomeContent({ {/* Recent */}
- +
{recentArtifacts.map((artifact) => ( (null); + const [view, setView] = useState({ type: "home" }); - if (openFolderId) { + if (view.type === "folder") { return ( - setOpenFolderId(null)} - /> + setView({ type: "home" })} /> ); } - return ; + if (view.type === "all") { + return setView({ type: "home" })} />; + } + + return ( + setView({ type: "folder", id })} + onOpenAll={() => setView({ type: "all" })} + /> + ); } From 944cc437e8e6901c228a6f6eda73a867dfa865e2 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 06:35:56 -0300 Subject: [PATCH 03/23] feat(home): replace agents list with action items + files section Keep the home layout the same (chat-first, full width) but reframe agent icons as action items (New Site, New Diagnostic) and add folders + recent files below. Reverts the file-browser-in-main-panel approach in favor of keeping everything in the chat home view. - Revert decopilot defaults back to chat-only (no main panel) - Create QuickActions component with action items + folders + recent files - Replace AgentsList with QuickActions in HomeEmptyState - Make home scrollable to accommodate the new sections below Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/chat/side-panel-chat.tsx | 38 +- .../src/web/components/home/quick-actions.tsx | 335 ++++++++++++++++++ apps/mesh/src/web/hooks/use-layout-state.ts | 4 +- apps/mesh/src/web/layouts/shell-layout.tsx | 8 +- apps/mesh/src/web/routes/agent-home.tsx | 13 +- 5 files changed, 359 insertions(+), 39 deletions(-) create mode 100644 apps/mesh/src/web/components/home/quick-actions.tsx 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..e11776cf20 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,29 @@ function HomeEmptyState({ return ( <> -
-
-
-
-

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

-
-
- -
+
+ {/* Top spacer to push greeting toward center when content is short */} +
+
+
+

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

-
- +
+
+
+ +
{isDecoUser && ( -
-
- setImportOpen(true)} /> -
+
+ setImportOpen(true)} />
)} + {/* Bottom spacer */} +
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..56831d79da --- /dev/null +++ b/apps/mesh/src/web/components/home/quick-actions.tsx @@ -0,0 +1,335 @@ +/** + * QuickActions - Action-oriented items for the home page. + * Replaces the agents list with actions like "New Slide Deck", "Run Diagnostic", etc. + * Below shows recent files and folders. + */ + +import { cn } from "@deco/ui/lib/utils.ts"; +import { + BarChart12, + ChevronRight, + FolderClosed, + 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 { useCreateVirtualMCP } from "@/web/hooks/use-create-virtual-mcp"; +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 { + MOCK_FOLDERS, + 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 } +> = { + "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 ( + + ); +} + +// ---------- Mini artifact row for recent files ---------- + +const ARTIFACT_TYPE_CONFIG: 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_TYPE_CONFIG[artifact.type]; + const { Icon } = config; + + return ( + + ); +} + +// ---------- Mini folder card ---------- + +function FolderRow({ + folder, +}: { + folder: { id: string; title: string; color: string; itemCount: number }; +}) { + return ( + + ); +} + +// ---------- 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 { createVirtualMCP } = useCreateVirtualMCP({ + navigateOnCreate: true, + }); + + const siteDiagnosticsAgent = WELL_KNOWN_AGENT_TEMPLATES.find( + (t) => t.id === "site-diagnostics", + )!; + + 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 recentArtifacts = getRecentArtifacts(5); + + return ( + <> + {/* Action items row */} +
+
+ setSiteEditorModalOpen(true)} + /> + navigateToAgent(existingDiagnostics.id) + : () => setDiagnosticsModalOpen(true) + } + /> + {/* 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, 3) + .map((agent) => ( + navigateToAgent(agent.id)} + /> + ))} + createVirtualMCP()} /> + +
+
+ + {/* Files section */} +
+ {/* Folders */} + {MOCK_FOLDERS.length > 0 && ( +
+
+

+ Folders +

+
+
+ {MOCK_FOLDERS.map((folder) => ( + + ))} +
+
+ )} + + {/* Recent files */} + {recentArtifacts.length > 0 && ( +
+
+

+ Recent +

+
+
+ {recentArtifacts.map((artifact) => ( + + ))} +
+
+ )} +
+ + + + + ); +} + +function QuickActionsSkeleton() { + return ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ ); +} + +export function QuickActions() { + return ( + }> + + + ); +} diff --git a/apps/mesh/src/web/hooks/use-layout-state.ts b/apps/mesh/src/web/hooks/use-layout-state.ts index d33e7c7240..ce41be25ef 100644 --- a/apps/mesh/src/web/hooks/use-layout-state.ts +++ b/apps/mesh/src/web/hooks/use-layout-state.ts @@ -95,10 +95,10 @@ export function resolveDefaultPanelState(ctx: { return allOpen; } - // Decopilot ID: tasks closed, main open (file browser), chat open + // Decopilot ID: tasks closed, main closed, chat open const isDecopilot = ctx.virtualMcpId === getDecopilotId(ctx.orgId); if (isDecopilot) { - return { tasksOpen: false, mainOpen: true, chatOpen: true }; + return { tasksOpen: false, mainOpen: false, chatOpen: true }; } // Entity metadata driven defaults diff --git a/apps/mesh/src/web/layouts/shell-layout.tsx b/apps/mesh/src/web/layouts/shell-layout.tsx index 24a9961980..aae790ba32 100644 --- a/apps/mesh/src/web/layouts/shell-layout.tsx +++ b/apps/mesh/src/web/layouts/shell-layout.tsx @@ -838,9 +838,7 @@ function UnifiedPanelGroup({
- +
@@ -858,9 +856,7 @@ function MobileAgentContent({ return (
{isDecopilot || !mainOpen ? ( - + ) : ( )} diff --git a/apps/mesh/src/web/routes/agent-home.tsx b/apps/mesh/src/web/routes/agent-home.tsx index c4462ff100..780d240256 100644 --- a/apps/mesh/src/web/routes/agent-home.tsx +++ b/apps/mesh/src/web/routes/agent-home.tsx @@ -10,13 +10,8 @@ import { type MainView, type MainViewType, } from "@/web/layouts/shell-layout"; -import { - useVirtualMCP, - getDecopilotId, - useProjectContext, -} from "@decocms/mesh-sdk"; +import { useVirtualMCP } from "@decocms/mesh-sdk"; import { useChatTask } from "@/web/components/chat/context"; -import { FileBrowserHome } from "@/web/components/files/file-browser-home"; const ProjectAppViewContent = lazy(() => import("./project-app-view").then((m) => ({ @@ -101,15 +96,9 @@ function AgentEmptyState() { function AgentHomeContent() { const { virtualMcpId } = useInsetContext()!; - const { org } = useProjectContext(); const resolved = useResolvedMainView(); - const isDecopilot = virtualMcpId === getDecopilotId(org.id); if (resolved.type === "chat") { - // Decopilot shows file browser as the main view - if (isDecopilot) { - return ; - } return ; } From eae8f7447f922aebe1bc66e7e75c83519d0e7ba8 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 07:19:22 -0300 Subject: [PATCH 04/23] feat(home): restructure around projects, show all tasks with labels - Sidebar: relabel agents as projects throughout (popover, tooltips, labels) - Tasks panel: on org home, show ALL tasks across projects with project name label under each task row - useTasks: support empty virtualMcpId to fetch all tasks unfiltered - Home: remove Folders section, keep action items only - Layout: open tasks panel by default on org home so users see all tasks Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/chat/side-panel-chat.tsx | 7 +- .../web/components/chat/side-panel-tasks.tsx | 24 +++- .../components/chat/task/use-task-manager.ts | 12 +- .../src/web/components/chat/tasks-panel.tsx | 38 ++++-- .../src/web/components/home/quick-actions.tsx | 120 +----------------- .../web/components/sidebar/agents-section.tsx | 14 +- apps/mesh/src/web/hooks/use-layout-state.ts | 4 +- 7 files changed, 71 insertions(+), 148 deletions(-) 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 e11776cf20..c982140d13 100644 --- a/apps/mesh/src/web/components/chat/side-panel-chat.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-chat.tsx @@ -127,9 +127,7 @@ function HomeEmptyState({ return ( <> -
- {/* Top spacer to push greeting toward center when content is short */} -
+

@@ -144,11 +142,10 @@ function HomeEmptyState({

{isDecoUser && ( -
+
setImportOpen(true)} />
)} - {/* Bottom spacer */}
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..156328a4df 100644 --- a/apps/mesh/src/web/components/chat/side-panel-tasks.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-tasks.tsx @@ -12,7 +12,12 @@ 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 { + useVirtualMCPActions, + useVirtualMCP, + useVirtualMCPs, + isDecopilot as isDecopilotFn, +} from "@decocms/mesh-sdk"; import type { VirtualMCPEntity } from "@decocms/mesh-sdk/types"; import { Suspense, useEffect, useRef, useState, useTransition } from "react"; import { isMac } from "@/web/lib/keyboard-shortcuts"; @@ -273,6 +278,20 @@ function TasksPanelContent({ const virtualMcp = useVirtualMCP(virtualMcpId); + // When on org home (decopilot / hideProjectHeader), show ALL tasks + const isGlobalView = hideProjectHeader; + const taskListVirtualMcpId = isGlobalView ? "" : (virtualMcpId ?? ""); + + // Build project names map for labeling tasks in global view + const allProjects = useVirtualMCPs(); + const projectNames = isGlobalView + ? new Map( + allProjects + .filter((p) => p.id && !isDecopilotFn(p.id)) + .map((p) => [p.id, p.title]), + ) + : undefined; + const handleNewTask = () => { startTransition(() => { createNewTask(); @@ -331,12 +350,13 @@ function TasksPanelContent({ {/* Task list */} { 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..e9f60657f4 100644 --- a/apps/mesh/src/web/components/chat/tasks-panel.tsx +++ b/apps/mesh/src/web/components/chat/tasks-panel.tsx @@ -141,10 +141,12 @@ function TaskRow({ task, isActive, onClick, + projectName, }: { task: Task; isActive: boolean; onClick: () => void; + projectName?: string; }) { const { setTaskStatus, hideTask } = useChatTask(); const playSound = useSound(question004Sound); @@ -157,7 +159,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)) : ""} + +
+ {projectName && ( + + {projectName} + + )}
{/* Archive button — shown on hover */} @@ -437,6 +447,8 @@ interface TaskListContentProps { onTaskCreate?: () => void; virtualMcpId?: string | null; showAutomations?: boolean; + /** Map of virtualMcpId → project name for labeling tasks */ + projectNames?: Map; } export function TaskListContent({ @@ -444,6 +456,7 @@ export function TaskListContent({ onTaskCreate, virtualMcpId, showAutomations = true, + projectNames, }: TaskListContentProps) { const { ownerFilter } = useChatTask(); const { setTaskId } = usePanelActions(); @@ -515,6 +528,11 @@ export function TaskListContent({ task={task} isActive={task.id === taskId} onClick={() => handleSelect(task)} + projectName={ + projectNames && task.virtual_mcp_id + ? projectNames.get(task.virtual_mcp_id) + : undefined + } /> )) ) : ( diff --git a/apps/mesh/src/web/components/home/quick-actions.tsx b/apps/mesh/src/web/components/home/quick-actions.tsx index 56831d79da..7944d87cff 100644 --- a/apps/mesh/src/web/components/home/quick-actions.tsx +++ b/apps/mesh/src/web/components/home/quick-actions.tsx @@ -1,14 +1,12 @@ /** * QuickActions - Action-oriented items for the home page. - * Replaces the agents list with actions like "New Slide Deck", "Run Diagnostic", etc. - * Below shows recent files and folders. + * Replaces the agents list with actions like "New Site", "New Diagnostic", etc. */ import { cn } from "@deco/ui/lib/utils.ts"; import { BarChart12, ChevronRight, - FolderClosed, Globe04, Plus, PresentationChart01, @@ -26,13 +24,6 @@ import { useCreateVirtualMCP } from "@/web/hooks/use-create-virtual-mcp"; 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 { - MOCK_FOLDERS, - getRecentArtifacts, - formatRelativeTime, - type Artifact, - type ArtifactType, -} from "@/web/lib/mock-artifacts"; // ---------- Action item (replaces agent preview) ---------- @@ -103,76 +94,6 @@ function ActionItem({ ); } -// ---------- Mini artifact row for recent files ---------- - -const ARTIFACT_TYPE_CONFIG: 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_TYPE_CONFIG[artifact.type]; - const { Icon } = config; - - return ( - - ); -} - -// ---------- Mini folder card ---------- - -function FolderRow({ - folder, -}: { - folder: { id: string; title: string; color: string; itemCount: number }; -}) { - return ( - - ); -} - // ---------- Main content ---------- function QuickActionsContent() { @@ -198,8 +119,6 @@ function QuickActionsContent() { a.title === siteDiagnosticsAgent.title), ); - const recentArtifacts = getRecentArtifacts(5); - return ( <> {/* Action items row */} @@ -227,7 +146,7 @@ function QuickActionsContent() { !isDecopilot(a.id) && a.id !== existingDiagnostics?.id, ) - .slice(0, 3) + .slice(0, 4) .map((agent) => (
- {/* Files section */} -
- {/* Folders */} - {MOCK_FOLDERS.length > 0 && ( -
-
-

- Folders -

-
-
- {MOCK_FOLDERS.map((folder) => ( - - ))} -
-
- )} - - {/* Recent files */} - {recentArtifacts.length > 0 && ( -
-
-

- Recent -

-
-
- {recentArtifacts.map((artifact) => ( - - ))} -
-
- )} -
- {/* Scrollable content */} @@ -376,7 +376,7 @@ function PinAgentPopoverContent({ {/* Agents section */}
- Agents + Projects
@@ -394,7 +394,7 @@ function PinAgentPopoverContent({
- Create new + New project @@ -455,7 +455,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 +498,7 @@ function PinAgentPopover() { <> setOpen(true)} > @@ -507,7 +507,7 @@ function PinAgentPopover() { - Browse agents + Browse projects {popoverContent} @@ -517,7 +517,7 @@ function PinAgentPopover() { diff --git a/apps/mesh/src/web/hooks/use-layout-state.ts b/apps/mesh/src/web/hooks/use-layout-state.ts index ce41be25ef..bbbfabda17 100644 --- a/apps/mesh/src/web/hooks/use-layout-state.ts +++ b/apps/mesh/src/web/hooks/use-layout-state.ts @@ -95,10 +95,10 @@ 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 }; } // Entity metadata driven defaults From fb2af8763965bce7742485791f23fd70d31b074f Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 07:24:05 -0300 Subject: [PATCH 05/23] feat(tasks): unified task panel, sidebar settings hover - Tasks panel is always the same: shows ALL tasks with project labels, regardless of whether you're on org home or inside a project - Remove SpaceIdentityHeader, ProjectViewsSection, PinnedViewIcon from tasks panel (no longer needed with unified view) - Sidebar hover: replace X/unpin icon with Settings icon that navigates to project settings - Sidebar click still creates a new task for that project Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/chat/side-panel-tasks.tsx | 266 ++---------------- .../web/components/sidebar/agents-section.tsx | 9 +- 2 files changed, 28 insertions(+), 247 deletions(-) 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 156328a4df..64b8fd63f3 100644 --- a/apps/mesh/src/web/components/chat/side-panel-tasks.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-tasks.tsx @@ -1,25 +1,17 @@ /** * 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 { Edit05, Loading01 } from "@untitledui/icons"; import { - useVirtualMCPActions, - useVirtualMCP, useVirtualMCPs, isDecopilot as isDecopilotFn, } from "@decocms/mesh-sdk"; -import type { VirtualMCPEntity } from "@decocms/mesh-sdk/types"; -import { Suspense, useEffect, useRef, useState, useTransition } from "react"; +import { Suspense, useTransition } from "react"; import { isMac } from "@/web/lib/keyboard-shortcuts"; import { ErrorBoundary } from "../error-boundary"; import { Chat } from "./index"; @@ -31,8 +23,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 @@ -87,210 +77,29 @@ 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 [isPending, startTransition] = useTransition(); - const virtualMcpId = virtualMcpIdProp ?? null; - const virtualMcp = useVirtualMCP(virtualMcpId); - - // When on org home (decopilot / hideProjectHeader), show ALL tasks - const isGlobalView = hideProjectHeader; - const taskListVirtualMcpId = isGlobalView ? "" : (virtualMcpId ?? ""); - - // Build project names map for labeling tasks in global view + // Always show ALL tasks — unified panel regardless of context const allProjects = useVirtualMCPs(); - const projectNames = isGlobalView - ? new Map( - allProjects - .filter((p) => p.id && !isDecopilotFn(p.id)) - .map((p) => [p.id, p.title]), - ) - : undefined; + const projectNames = new Map( + allProjects + .filter((p) => p.id && !isDecopilotFn(p.id)) + .map((p) => [p.id, p.title]), + ); const handleNewTask = () => { startTransition(() => { @@ -298,59 +107,28 @@ 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 */} { diff --git a/apps/mesh/src/web/components/sidebar/agents-section.tsx b/apps/mesh/src/web/components/sidebar/agents-section.tsx index 77d6f7d281..e349386e5b 100644 --- a/apps/mesh/src/web/components/sidebar/agents-section.tsx +++ b/apps/mesh/src/web/components/sidebar/agents-section.tsx @@ -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, )} From e4f9acc82fed5fcb8be15a38848b7e4a800b6fa5 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 07:28:41 -0300 Subject: [PATCH 06/23] feat(settings): relabel connections as agents inside projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project settings now shows: - Tab: "Agents" instead of "Connections" - Empty state: "No agents yet" - Add dialog: "Add Agent" - Delete: "Delete Project?" - Home: "New project" instead of "Create agent" - Layout tab references updated Connections still exist under agents — the hierarchy is Projects > Agents > Connections. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/web/components/home/quick-actions.tsx | 70 ++++++++++++++++++- .../virtual-mcp/add-connection-dialog.tsx | 2 +- apps/mesh/src/web/views/virtual-mcp/index.tsx | 12 ++-- 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/apps/mesh/src/web/components/home/quick-actions.tsx b/apps/mesh/src/web/components/home/quick-actions.tsx index 7944d87cff..724dbf59cc 100644 --- a/apps/mesh/src/web/components/home/quick-actions.tsx +++ b/apps/mesh/src/web/components/home/quick-actions.tsx @@ -24,6 +24,12 @@ import { useCreateVirtualMCP } from "@/web/hooks/use-create-virtual-mcp"; 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) ---------- @@ -94,6 +100,65 @@ function ActionItem({ ); } +// ---------- 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() { @@ -155,7 +220,7 @@ function QuickActionsContent() { onClick={() => navigateToAgent(agent.id)} /> ))} - createVirtualMCP()} /> + createVirtualMCP()} />
+ {/* Recent */} + + - Add Connection + Add Agent diff --git a/apps/mesh/src/web/views/virtual-mcp/index.tsx b/apps/mesh/src/web/views/virtual-mcp/index.tsx index 028cf77ddd..962fea2a0a 100644 --- a/apps/mesh/src/web/views/virtual-mcp/index.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/index.tsx @@ -893,8 +893,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 && ( @@ -1362,7 +1362,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 +1378,7 @@ Define step-by-step how the agent should handle requests.
- No connections yet. Add one to get started. + No agents yet. Add one to get started. ) : ( @@ -1543,7 +1543,7 @@ Define step-by-step how the agent should handle requests. - Delete Agent? + Delete Project? This action cannot be undone. This will permanently delete{" "} From 403b23c2b06b290f30c63e69c023f6f0479e83fd Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 07:51:14 -0300 Subject: [PATCH 07/23] feat: implement Project > Agents > Connections hierarchy Real structural separation, not just label renames: Sidebar: - Filter to top-level projects only (Virtual MCPs that are NOT children of other Virtual MCPs are shown; nested agents are hidden) - getChildVirtualMcpIds() computes which VMCPs are agents inside projects Project settings (Agents tab): - Partitions children into agents (Virtual MCP children) vs regular connections, rendering AgentItem cards for agents - AgentItem shows avatar, name, connection count, click to navigate to agent's own settings - "Add Agent" button opens AddAgentDialog to browse/create agents - Regular connections shown under "Direct Connections" sub-section AddAgentDialog (new file): - Browse existing Virtual MCPs to add as agents to a project - "Create new" button creates a fresh agent and adds it - Excludes current project, already-added agents, and decopilot Task navigation: - Clicking a task from a different project navigates to that project's route with the task selected (cross-project navigation) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/web/components/chat/tasks-panel.tsx | 17 +- .../web/components/sidebar/agents-section.tsx | 32 ++- .../views/virtual-mcp/add-agent-dialog.tsx | 168 ++++++++++++++ .../virtual-mcp/add-connection-dialog.tsx | 2 +- apps/mesh/src/web/views/virtual-mcp/index.tsx | 217 +++++++++++++++--- 5 files changed, 397 insertions(+), 39 deletions(-) create mode 100644 apps/mesh/src/web/views/virtual-mcp/add-agent-dialog.tsx diff --git a/apps/mesh/src/web/components/chat/tasks-panel.tsx b/apps/mesh/src/web/components/chat/tasks-panel.tsx index e9f60657f4..33cc050709 100644 --- a/apps/mesh/src/web/components/chat/tasks-panel.tsx +++ b/apps/mesh/src/web/components/chat/tasks-panel.tsx @@ -18,7 +18,8 @@ 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, @@ -482,9 +483,23 @@ export function TaskListContent({ 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; + } + // Navigate cross-project if the task belongs to a different project + if (task.virtual_mcp_id && task.virtual_mcp_id !== currentVirtualMcpId) { + navigate({ + to: "/$org/$virtualMcpId/", + params: { org: org.slug, virtualMcpId: task.virtual_mcp_id }, + search: { taskId: task.id }, + }); } else { setTaskId(task.id); } diff --git a/apps/mesh/src/web/components/sidebar/agents-section.tsx b/apps/mesh/src/web/components/sidebar/agents-section.tsx index e349386e5b..949c0a2cbe 100644 --- a/apps/mesh/src/web/components/sidebar/agents-section.tsx +++ b/apps/mesh/src/web/components/sidebar/agents-section.tsx @@ -291,6 +291,23 @@ function AgentGridItem({ ); } +/** + * Compute which Virtual MCP IDs are children of other Virtual MCPs. + * These are "agents inside projects" and should not appear as top-level projects. + */ +function getChildVirtualMcpIds( + allVirtualMcps: VirtualMCPEntity[], +): Set { + const allIds = new Set(allVirtualMcps.map((a) => a.id)); + return new Set( + allVirtualMcps.flatMap((parent) => + parent.connections + .map((c) => c.connection_id) + .filter((id) => allIds.has(id)), + ), + ); +} + function PinAgentPopoverContent({ onClose, onOpenSiteEditorModal, @@ -314,9 +331,13 @@ function PinAgentPopoverContent({ const navigateToNewTask = useCreateTaskAndNavigate(); const navigateToAgent = useNavigateToAgent(); + // Filter to top-level projects only (not agents nested inside other projects) + const childIds = getChildVirtualMcpIds(allAgents); + const lowerSearch = search.toLowerCase(); const userAgents = allAgents .filter((s) => !isDecopilot(s.id)) + .filter((s) => !childIds.has(s.id)) .filter((s) => !search || s.title.toLowerCase().includes(lowerSearch)); const studioPackInstalled = allAgents.some((a) => isStudioPackAgent(a.id)); @@ -555,13 +576,20 @@ function PinAgentPopover() { function AgentsSectionContent() { const allAgents = useVirtualMCPs(); const { org } = useProjectContext(); - const serverPinnedIds = allAgents.filter((a) => a.pinned).map((a) => a.id); + + // Filter to top-level projects only (not agents nested inside other projects) + const childIds = getChildVirtualMcpIds(allAgents); + const topLevelProjects = allAgents.filter((a) => !childIds.has(a.id)); + + const serverPinnedIds = topLevelProjects + .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(topLevelProjects.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/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/add-connection-dialog.tsx b/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx index 5d8b68b989..2b1a0e26dd 100644 --- a/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx @@ -661,7 +661,7 @@ export function AddConnectionDialog({ - Add Agent + Add Connection diff --git a/apps/mesh/src/web/views/virtual-mcp/index.tsx b/apps/mesh/src/web/views/virtual-mcp/index.tsx index 962fea2a0a..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) // --------------------------------------------------------------------------- @@ -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, }); @@ -1438,14 +1525,21 @@ Define step-by-step how the agent should handle requests. }} /> {activeTab === "connections" && ( - +
+ +
)} {activeTab === "instructions" && (
@@ -1489,11 +1583,17 @@ 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) + } + /> + + + ))} +
)}
)} @@ -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} + /> + { From dece9830c2bcf488d5aae43e7ac20bc8ed7e0695 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 08:02:03 -0300 Subject: [PATCH 08/23] feat: separate projects from agents via metadata.type flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects are now distinct from agents: - Projects have metadata.type = "project" and are shown in sidebar - Agents are regular Virtual MCPs without the flag — only visible inside project settings, not in sidebar - New useCreateProject hook creates VMCPs with the project flag - Sidebar + popover only show projects, not agents - Home "New project" uses createProject This means the sidebar is clean — only user-created projects appear. Agents (Site Editor, custom agents, etc.) live inside projects. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/web/components/home/quick-actions.tsx | 6 +- .../web/components/sidebar/agents-section.tsx | 64 +++++++------------ apps/mesh/src/web/hooks/use-create-project.ts | 44 +++++++++++++ 3 files changed, 69 insertions(+), 45 deletions(-) create mode 100644 apps/mesh/src/web/hooks/use-create-project.ts diff --git a/apps/mesh/src/web/components/home/quick-actions.tsx b/apps/mesh/src/web/components/home/quick-actions.tsx index 724dbf59cc..668bde562d 100644 --- a/apps/mesh/src/web/components/home/quick-actions.tsx +++ b/apps/mesh/src/web/components/home/quick-actions.tsx @@ -20,7 +20,7 @@ import { import { useNavigate } from "@tanstack/react-router"; import { Suspense, useState } from "react"; import { Skeleton } from "@deco/ui/components/skeleton.tsx"; -import { useCreateVirtualMCP } from "@/web/hooks/use-create-virtual-mcp"; +import { useCreateProject } from "@/web/hooks/use-create-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"; @@ -168,7 +168,7 @@ function QuickActionsContent() { const navigateToAgent = useNavigateToAgent(); const [siteEditorModalOpen, setSiteEditorModalOpen] = useState(false); const [diagnosticsModalOpen, setDiagnosticsModalOpen] = useState(false); - const { createVirtualMCP } = useCreateVirtualMCP({ + const { createProject } = useCreateProject({ navigateOnCreate: true, }); @@ -220,7 +220,7 @@ function QuickActionsContent() { onClick={() => navigateToAgent(agent.id)} /> ))} - createVirtualMCP()} /> + createProject()} />
- {/* Create new button */} + {/* Create new project */} - {userAgents.map((agent) => ( + {projects.map((project) => ( handleSelect(agent)} + key={project.id} + agent={project} + onClick={() => handleSelect(project)} /> ))}
@@ -462,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"}
)}
@@ -577,19 +558,18 @@ function AgentsSectionContent() { const allAgents = useVirtualMCPs(); const { org } = useProjectContext(); - // Filter to top-level projects only (not agents nested inside other projects) - const childIds = getChildVirtualMcpIds(allAgents); - const topLevelProjects = allAgents.filter((a) => !childIds.has(a.id)); + // Only show projects (metadata.type === "project") in the sidebar + const projectsOnly = allAgents.filter( + (a) => !isDecopilot(a.id) && isProject(a), + ); - const serverPinnedIds = topLevelProjects - .filter((a) => a.pinned) - .map((a) => a.id); + const serverPinnedIds = projectsOnly.filter((a) => a.pinned).map((a) => a.id); const { pinnedIds, unpin, reorder } = usePinnedAgents( org.id, serverPinnedIds, ); - const agentMap = new Map(topLevelProjects.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..beaceaf599 --- /dev/null +++ b/apps/mesh/src/web/hooks/use-create-project.ts @@ -0,0 +1,44 @@ +/** + * 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 () => { + const virtualMcp = await actions.create.mutateAsync({ + title: "New Project", + description: "", + status: "active", + connections: [], + pinned: true, + metadata: { + instructions: null, + type: "project", + }, + }); + + if (navigateOnCreate) { + navigateToAgent(virtualMcp.id!, { search: { main: "settings" } }); + } + + 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"; +} From 0e9f085485d110e766be1f7c69d7f7ab9d527448 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 08:08:19 -0300 Subject: [PATCH 09/23] feat: separate project and agent views with file browser Projects and agents now have completely different UIs: Project home (default when entering a project): - Shows files in a clean visual list (FileCard components) - Project title and description at top - Empty state prompts user to start working - Main panel opens by default (files visible + chat) Project settings (simple): - Just name, icon, description + agents list - Add/remove agents with AgentCard components - Delete project dialog - No instructions, no connections, no layout tabs Agent settings (unchanged): - Instructions, connections, layout tabs - Full VirtualMcpDetailView as before Layout defaults: - Projects: main panel open (files) + chat open - usePanelState accepts isProject option - Shell layout detects project via metadata.type Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/web/hooks/use-layout-state.ts | 8 + apps/mesh/src/web/layouts/shell-layout.tsx | 9 +- apps/mesh/src/web/routes/agent-home.tsx | 23 ++ .../src/web/views/project/project-home.tsx | 165 ++++++++++ .../web/views/project/project-settings.tsx | 307 ++++++++++++++++++ 5 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 apps/mesh/src/web/views/project/project-home.tsx create mode 100644 apps/mesh/src/web/views/project/project-settings.tsx diff --git a/apps/mesh/src/web/hooks/use-layout-state.ts b/apps/mesh/src/web/hooks/use-layout-state.ts index bbbfabda17..96e61f3573 100644 --- a/apps/mesh/src/web/hooks/use-layout-state.ts +++ b/apps/mesh/src/web/hooks/use-layout-state.ts @@ -82,6 +82,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 }; @@ -101,6 +102,11 @@ export function resolveDefaultPanelState(ctx: { 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 const defaultViewType = ctx.entityMetadata?.defaultMainView?.type ?? null; const showMain = @@ -176,6 +182,7 @@ function parsePanelParam( export function usePanelState( entityMetadata: EntityLayoutMetadata | null, + options?: { isProject?: boolean }, ): LayoutState & LayoutActions { const navigate = useNavigate(); const { org } = useProjectContext(); @@ -211,6 +218,7 @@ export function usePanelState( entityMetadata, hasMainParam: !!search.main, isAgentHomeRoute, + isProject: options?.isProject, }; const defaults = resolveDefaultPanelState(resolveCtx); diff --git a/apps/mesh/src/web/layouts/shell-layout.tsx b/apps/mesh/src/web/layouts/shell-layout.tsx index aae790ba32..a8ce685627 100644 --- a/apps/mesh/src/web/layouts/shell-layout.tsx +++ b/apps/mesh/src/web/layouts/shell-layout.tsx @@ -440,8 +440,15 @@ function InsetProvider({ isSettingsRoute }: { isSettingsRoute: boolean }) { } : null; + // Check if entity is a project + const entityIsProject = + (entity?.metadata as Record | undefined)?.type === + "project"; + // Layout state from URL querystring - const layout = usePanelState(entityMetadata); + const layout = usePanelState(entityMetadata, { + isProject: entityIsProject, + }); // Tasks panel virtualMcpId const tasksVirtualMcpId = virtualMcpId; diff --git a/apps/mesh/src/web/routes/agent-home.tsx b/apps/mesh/src/web/routes/agent-home.tsx index 780d240256..b747a4f48b 100644 --- a/apps/mesh/src/web/routes/agent-home.tsx +++ b/apps/mesh/src/web/routes/agent-home.tsx @@ -12,6 +12,9 @@ import { } from "@/web/layouts/shell-layout"; import { useVirtualMCP } from "@decocms/mesh-sdk"; 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 +99,28 @@ function AgentEmptyState() { function AgentHomeContent() { const { virtualMcpId } = useInsetContext()!; + const entity = useVirtualMCP(virtualMcpId); const resolved = useResolvedMainView(); + const entityIsProject = entity ? isProject(entity) : false; + + // Projects show files as default, simplified settings + if (entityIsProject) { + if (resolved.type === "settings") { + return ; + } + if (resolved.type === "ext-apps") { + return ( + + ); + } + // Default: show project files + return ; + } + // Agents: existing behavior if (resolved.type === "chat") { return ; } 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..a6ccffeef5 --- /dev/null +++ b/apps/mesh/src/web/views/project/project-settings.tsx @@ -0,0 +1,307 @@ +/** + * 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 { 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 { 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 ( +
+ +
+ + +
+
+ ); +} + +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)} + /> + + + ))} +
+ )} +
+
+ + {/* Dialogs */} + + + + + + Delete Project? + + This will permanently delete{" "} + + {entity.title} + {" "} + and all its tasks. Agents will not be deleted. + + + + Cancel + + Delete + + + + +
+ ); +} From ed3316a989201f81b5a1742df3c34931029d2188 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 08:09:45 -0300 Subject: [PATCH 10/23] feat(home): quick actions create projects with agents inside - "New Diagnostic" creates a project titled "New Diagnostic" with the diagnostics agent pre-assigned (if recruited), then navigates in - "New Site" opens the site editor onboarding (needs setup first) - "New project" creates a blank project - useCreateProject now accepts title and agentIds params Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/web/components/home/quick-actions.tsx | 27 ++++++++++++++----- apps/mesh/src/web/hooks/use-create-project.ts | 18 ++++++++++--- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/mesh/src/web/components/home/quick-actions.tsx b/apps/mesh/src/web/components/home/quick-actions.tsx index 668bde562d..812bcccd10 100644 --- a/apps/mesh/src/web/components/home/quick-actions.tsx +++ b/apps/mesh/src/web/components/home/quick-actions.tsx @@ -176,6 +176,7 @@ function QuickActionsContent() { (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 && @@ -184,6 +185,24 @@ function QuickActionsContent() { 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 */} @@ -192,16 +211,12 @@ function QuickActionsContent() { setSiteEditorModalOpen(true)} + onClick={handleNewSite} /> navigateToAgent(existingDiagnostics.id) - : () => setDiagnosticsModalOpen(true) - } + onClick={handleNewDiagnostic} /> {/* Custom agents as actions */} {virtualMcps diff --git a/apps/mesh/src/web/hooks/use-create-project.ts b/apps/mesh/src/web/hooks/use-create-project.ts index beaceaf599..e81353315a 100644 --- a/apps/mesh/src/web/hooks/use-create-project.ts +++ b/apps/mesh/src/web/hooks/use-create-project.ts @@ -12,12 +12,22 @@ export function useCreateProject(options: { navigateOnCreate?: boolean } = {}) { const actions = useVirtualMCPActions(); const navigateToAgent = useNavigateToAgent(); - const createProject = async () => { + 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: "New Project", + title: params?.title ?? "New Project", description: "", status: "active", - connections: [], + connections, pinned: true, metadata: { instructions: null, @@ -26,7 +36,7 @@ export function useCreateProject(options: { navigateOnCreate?: boolean } = {}) { }); if (navigateOnCreate) { - navigateToAgent(virtualMcp.id!, { search: { main: "settings" } }); + navigateToAgent(virtualMcp.id!); } return { id: virtualMcp.id!, virtualMcp }; From 3fff99688b1d57a982dc88027c509bca05a61717 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 08:26:23 -0300 Subject: [PATCH 11/23] feat(home): add "New Slides" one-click project creation Clicking "New Slides" on the home page creates everything in one shot: 1. HTTP connection to slide-maker.decocms.com 2. "Slide Maker" agent with that connection 3. "My Slides" project with the agent + default UI set to slide_maker tool 4. Navigates to the project The project's defaultMainView is set to ext-apps/slide_maker so the slide editor opens automatically as the main panel. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/scripts/seed-slide-project.ts | 135 ++++++++++++++++++ .../src/web/components/home/quick-actions.tsx | 12 ++ .../src/web/hooks/use-create-slide-project.ts | 93 ++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 apps/mesh/scripts/seed-slide-project.ts create mode 100644 apps/mesh/src/web/hooks/use-create-slide-project.ts 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/home/quick-actions.tsx b/apps/mesh/src/web/components/home/quick-actions.tsx index 812bcccd10..fd413cf1b2 100644 --- a/apps/mesh/src/web/components/home/quick-actions.tsx +++ b/apps/mesh/src/web/components/home/quick-actions.tsx @@ -21,6 +21,7 @@ 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"; @@ -37,6 +38,11 @@ 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", @@ -171,6 +177,7 @@ function QuickActionsContent() { const { createProject } = useCreateProject({ navigateOnCreate: true, }); + const { create: createSlideProject } = useCreateSlideProject(); const siteDiagnosticsAgent = WELL_KNOWN_AGENT_TEMPLATES.find( (t) => t.id === "site-diagnostics", @@ -208,6 +215,11 @@ function QuickActionsContent() { {/* Action items row */}
+ createSlideProject()} + /> { + 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 }; +} From 87ab3de3306a154874f78fa378d0620d3c942d0e Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 08:37:51 -0300 Subject: [PATCH 12/23] feat(layout): reorder panels [Tasks | Chat | Content] with toolbar Swap chat and main content panels to match Conductor layout: - Panel 1 (left): Tasks - Panel 2 (center): Chat - Panel 3 (right): Main content / Layout Add ContentToolbar at top of the right panel: - Shows icons for available UI tools from the project's agents (reads from pinnedViews or defaultMainView metadata) - Settings icon on the right - Only renders for projects (not agents or decopilot) Toolbar toggle buttons in the top bar reordered to match. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/layout/content-toolbar.tsx | 161 ++++++++++++++++++ apps/mesh/src/web/layouts/shell-layout.tsx | 42 +++-- 2 files changed, 185 insertions(+), 18 deletions(-) create mode 100644 apps/mesh/src/web/components/layout/content-toolbar.tsx 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..cff4b7a67a --- /dev/null +++ b/apps/mesh/src/web/components/layout/content-toolbar.tsx @@ -0,0 +1,161 @@ +/** + * ContentToolbar — Top bar on the main content panel. + * Shows icons for available UI tools from the project's agents. + * Clicking an icon switches the main view to that tool's UI. + */ + +import { cn } from "@deco/ui/lib/utils.ts"; +import { useInsetContext, usePanelActions } from "@/web/layouts/shell-layout"; +import { useVirtualMCP, useVirtualMCPs } from "@decocms/mesh-sdk"; +import { isProject } from "@/web/hooks/use-create-project"; +import { IntegrationIcon } from "@/web/components/integration-icon"; +import { Settings01 } 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 []; + + // Collect pinned views from the project metadata + const pinnedViews = + ((entity.metadata?.ui as Record | null | undefined) + ?.pinnedViews as ToolUIEntry[] | null) ?? []; + + if (pinnedViews.length > 0) return pinnedViews; + + // Fallback: check if the project has a default ext-apps view + 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 ?? ""; + // Find the connection's title for the label + 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 []; +} + +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 isSettingsActive = mainView?.type === "settings"; + + return ( +
+ {/* 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/layouts/shell-layout.tsx b/apps/mesh/src/web/layouts/shell-layout.tsx index a8ce685627..f0edde164a 100644 --- a/apps/mesh/src/web/layouts/shell-layout.tsx +++ b/apps/mesh/src/web/layouts/shell-layout.tsx @@ -11,6 +11,7 @@ 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 { KeyboardShortcutsDialog } from "@/web/components/keyboard-shortcuts-dialog"; import { isMac, isModKey } from "@/web/lib/keyboard-shortcuts"; import { StudioSidebar, StudioSidebarMobile } from "@/web/components/sidebar"; @@ -623,29 +624,29 @@ function InsetProvider({ isSettingsRoute }: { isSettingsRoute: boolean }) { @@ -792,6 +793,7 @@ function UnifiedPanelGroup({ className="flex-1 min-h-0 pb-1 pr-1 pl-0 pt-0" style={{ overflow: "visible" }} > + {/* Panel 1: Tasks */}
@@ -805,9 +807,21 @@ function UnifiedPanelGroup({ + {/* Panel 2: Chat (center) */} + +
+
+ +
+
+
+ + + + {/* Panel 3: Main content / Layout (right) */} + @@ -840,15 +855,6 @@ function UnifiedPanelGroup({
- - - -
-
- -
-
-
); } From c561c1da9f69aece56e02d5fe97ba576e3ae2c29 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 08:40:31 -0300 Subject: [PATCH 13/23] feat(layout): remove content border-radius below toolbar, add Files icon - Remove rounded-[inherit] from content area below ContentToolbar so the UI tool renders edge-to-edge under the toolbar - Add Files icon (first in toolbar) that shows the project file browser - Files icon is active when no specific tool view is selected Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/layout/content-toolbar.tsx | 22 ++++++++++++++++++- apps/mesh/src/web/layouts/shell-layout.tsx | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/mesh/src/web/components/layout/content-toolbar.tsx b/apps/mesh/src/web/components/layout/content-toolbar.tsx index cff4b7a67a..9d41a60894 100644 --- a/apps/mesh/src/web/components/layout/content-toolbar.tsx +++ b/apps/mesh/src/web/components/layout/content-toolbar.tsx @@ -9,7 +9,7 @@ import { useInsetContext, usePanelActions } from "@/web/layouts/shell-layout"; import { useVirtualMCP, useVirtualMCPs } from "@decocms/mesh-sdk"; import { isProject } from "@/web/hooks/use-create-project"; import { IntegrationIcon } from "@/web/components/integration-icon"; -import { Settings01 } from "@untitledui/icons"; +import { File06, Settings01 } from "@untitledui/icons"; import { Suspense } from "react"; import { Tooltip, @@ -81,9 +81,29 @@ function ToolbarContent() { if (!entityIsProject) return null; const isSettingsActive = mainView?.type === "settings"; + const isFilesActive = mainView === null || mainView?.type === "chat"; return (
+ {/* Files */} + + + + + Files + + {/* Tool UI icons */} {toolUIs.map((tool) => { const isActive = diff --git a/apps/mesh/src/web/layouts/shell-layout.tsx b/apps/mesh/src/web/layouts/shell-layout.tsx index f0edde164a..b3482da46c 100644 --- a/apps/mesh/src/web/layouts/shell-layout.tsx +++ b/apps/mesh/src/web/layouts/shell-layout.tsx @@ -848,7 +848,7 @@ function UnifiedPanelGroup({
} > -
+
From 246b6bed14e8e9d777ac5bdc4b37d1a5253ca690 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 08:42:58 -0300 Subject: [PATCH 14/23] feat: thread toolInput from project metadata to MCP app renderer Each project can store tool-specific input in its metadata at metadata.ui.layout.defaultMainView.toolInput. This is passed through to the MCPAppRenderer when the tool UI opens. For slides: a project can store { deckId: "xxx" } so opening the project opens THAT specific deck, not the full deck browser. - AppRenderer accepts optional toolInput prop - AppViewContent threads toolInput to AppRenderer - agent-home.tsx reads toolInput from project metadata for ext-apps view This enables the "each slide deck = its own project" pattern without changing the slide-maker MCP app. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/web/routes/agent-home.tsx | 9 +++++++++ apps/mesh/src/web/routes/project-app-view.tsx | 10 ++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/mesh/src/web/routes/agent-home.tsx b/apps/mesh/src/web/routes/agent-home.tsx index b747a4f48b..fafb4628ef 100644 --- a/apps/mesh/src/web/routes/agent-home.tsx +++ b/apps/mesh/src/web/routes/agent-home.tsx @@ -109,10 +109,19 @@ function AgentHomeContent() { 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 ( ); } 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} /> ); } From 910062ab11f5eeae20384704fbd13088de6fbf84 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 08:46:31 -0300 Subject: [PATCH 15/23] feat(settings): fix toolbar icons, add Layout section to project settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Content toolbar: swap Settings01 (key-like) for Settings02 (gear), File06 for FolderClosed (more recognizable) - Project settings: add Layout section showing connections from agents that may have UIs, click to set as default view for the project - Layout section traverses project → agents → connections to find all real connections and shows them with the agent they belong to Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/layout/content-toolbar.tsx | 6 +- .../web/views/project/project-settings.tsx | 128 +++++++++++++++++- 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/apps/mesh/src/web/components/layout/content-toolbar.tsx b/apps/mesh/src/web/components/layout/content-toolbar.tsx index 9d41a60894..966d0b99f1 100644 --- a/apps/mesh/src/web/components/layout/content-toolbar.tsx +++ b/apps/mesh/src/web/components/layout/content-toolbar.tsx @@ -9,7 +9,7 @@ import { useInsetContext, usePanelActions } from "@/web/layouts/shell-layout"; import { useVirtualMCP, useVirtualMCPs } from "@decocms/mesh-sdk"; import { isProject } from "@/web/hooks/use-create-project"; import { IntegrationIcon } from "@/web/components/integration-icon"; -import { File06, Settings01 } from "@untitledui/icons"; +import { FolderClosed, Settings02 } from "@untitledui/icons"; import { Suspense } from "react"; import { Tooltip, @@ -98,7 +98,7 @@ function ToolbarContent() { : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", )} > - + Files @@ -163,7 +163,7 @@ function ToolbarContent() { : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", )} > - + Settings diff --git a/apps/mesh/src/web/views/project/project-settings.tsx b/apps/mesh/src/web/views/project/project-settings.tsx index a6ccffeef5..81848697e5 100644 --- a/apps/mesh/src/web/views/project/project-settings.tsx +++ b/apps/mesh/src/web/views/project/project-settings.tsx @@ -4,6 +4,7 @@ * 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"; @@ -21,7 +22,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@deco/ui/components/alert-dialog.tsx"; -import { ChevronRight, Plus, Trash01 } from "@untitledui/icons"; +import { Check, ChevronRight, Plus, Trash01 } from "@untitledui/icons"; import { useProjectContext, useVirtualMCP, @@ -105,6 +106,128 @@ function AgentCardSkeleton() { ); } +/** + * 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(); @@ -268,6 +391,9 @@ export function ProjectSettings({ virtualMcpId }: { virtualMcpId: string }) {
)}
+ + {/* Layout section — which UIs are available, set default */} +
{/* Dialogs */} From 387440d5b7f9eba35f06823908f7dc3441f86906 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 08:48:22 -0300 Subject: [PATCH 16/23] fix(layout): Files icon opens project file browser, not the tool UI Files icon now navigates to ?main=files which is handled explicitly in agent-home.tsx to render ProjectHome (file browser) instead of falling through to the entity's defaultMainView (which would show the tool UI again). - ContentToolbar: Files button uses openMainView("files") - agent-home.tsx: check search.main === "files" before resolving default view, render ProjectHome - isFilesActive reads raw search param Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/web/components/layout/content-toolbar.tsx | 6 ++++-- apps/mesh/src/web/routes/agent-home.tsx | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/mesh/src/web/components/layout/content-toolbar.tsx b/apps/mesh/src/web/components/layout/content-toolbar.tsx index 966d0b99f1..f4b475d40e 100644 --- a/apps/mesh/src/web/components/layout/content-toolbar.tsx +++ b/apps/mesh/src/web/components/layout/content-toolbar.tsx @@ -6,6 +6,7 @@ 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 { IntegrationIcon } from "@/web/components/integration-icon"; @@ -80,8 +81,9 @@ function ToolbarContent() { if (!entityIsProject) return null; + const search = useSearch({ strict: false }) as { main?: string }; const isSettingsActive = mainView?.type === "settings"; - const isFilesActive = mainView === null || mainView?.type === "chat"; + const isFilesActive = search.main === "files"; return (
@@ -90,7 +92,7 @@ function ToolbarContent() {
- - - {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 (
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); +} From 6de19afad366bace4ddc64a7deb9054908e45163 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 08:54:21 -0300 Subject: [PATCH 18/23] fix: Files is now the default project view, toolbar matches top bar style Files view: - Projects default to file browser when no ?main= param is set - ?main=files explicitly shows file browser - Entity defaultMainView only applies when ?main=ext-apps is in URL Toolbar styling: - Icons now use same size-7 ghost button style as top bar layout toggles - 16px icon size, sidebar-accent active state, sidebar-foreground colors - Toolbar height matches top bar (h-10) - Tool icons use AgentAvatar at xs size - Settings uses Settings02 (gear) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/layout/content-toolbar.tsx | 54 ++++++++++--------- apps/mesh/src/web/routes/agent-home.tsx | 11 ++-- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/apps/mesh/src/web/components/layout/content-toolbar.tsx b/apps/mesh/src/web/components/layout/content-toolbar.tsx index f4b475d40e..d2dc56113f 100644 --- a/apps/mesh/src/web/components/layout/content-toolbar.tsx +++ b/apps/mesh/src/web/components/layout/content-toolbar.tsx @@ -1,7 +1,7 @@ /** * ContentToolbar — Top bar on the main content panel. * Shows icons for available UI tools from the project's agents. - * Clicking an icon switches the main view to that tool's UI. + * Styled to match the top bar layout toggles (size-7 ghost buttons, 16px icons). */ import { cn } from "@deco/ui/lib/utils.ts"; @@ -9,7 +9,7 @@ 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 { IntegrationIcon } from "@/web/components/integration-icon"; +import { AgentAvatar } from "@/web/components/agent-icon"; import { FolderClosed, Settings02 } from "@untitledui/icons"; import { Suspense } from "react"; import { @@ -31,18 +31,20 @@ function useProjectToolUIs(virtualMcpId: string): ToolUIEntry[] { if (!entity || !isProject(entity)) return []; - // Collect pinned views from the project metadata const pinnedViews = ((entity.metadata?.ui as Record | null | undefined) ?.pinnedViews as ToolUIEntry[] | null) ?? []; if (pinnedViews.length > 0) return pinnedViews; - // Fallback: check if the project has a default ext-apps view const defaultView = ( entity.metadata?.ui as Record | null | undefined )?.layout as { - defaultMainView?: { type: string; id?: string; toolName?: string }; + defaultMainView?: { + type: string; + id?: string; + toolName?: string; + }; } | null; if ( @@ -51,7 +53,6 @@ function useProjectToolUIs(virtualMcpId: string): ToolUIEntry[] { ) { const connId = defaultView.defaultMainView.id; const toolName = defaultView.defaultMainView.toolName ?? ""; - // Find the connection's title for the label const agent = allVirtualMcps.find((a) => a.connections.some((c) => c.connection_id === connId), ); @@ -68,6 +69,13 @@ function useProjectToolUIs(virtualMcpId: string): ToolUIEntry[] { 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(); @@ -86,7 +94,7 @@ function ToolbarContent() { const isFilesActive = search.main === "files"; return ( -
+
{/* Files */} @@ -94,13 +102,11 @@ function ToolbarContent() { type="button" onClick={() => openMainView("files")} className={cn( - "flex items-center justify-center size-7 rounded-md transition-colors", - isFilesActive - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", + toolbarBtnClass, + isFilesActive ? toolbarBtnActive : toolbarBtnInactive, )} > - + Files @@ -120,24 +126,22 @@ function ToolbarContent() { type="button" onClick={() => isActive - ? openMainView("default") + ? openMainView("files") : openMainView("ext-apps", { id: tool.connectionId, toolName: tool.toolName, }) } className={cn( - "flex items-center justify-center size-7 rounded-md transition-colors", - isActive - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", + toolbarBtnClass, + isActive ? toolbarBtnActive : toolbarBtnInactive, )} > - @@ -155,17 +159,15 @@ function ToolbarContent() { type="button" onClick={() => isSettingsActive - ? openMainView("default") + ? openMainView("files") : openMainView("settings") } className={cn( - "flex items-center justify-center size-7 rounded-md transition-colors", - isSettingsActive - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", + toolbarBtnClass, + isSettingsActive ? toolbarBtnActive : toolbarBtnInactive, )} > - + Settings diff --git a/apps/mesh/src/web/routes/agent-home.tsx b/apps/mesh/src/web/routes/agent-home.tsx index 536f4ecd34..53b2eddc5d 100644 --- a/apps/mesh/src/web/routes/agent-home.tsx +++ b/apps/mesh/src/web/routes/agent-home.tsx @@ -101,14 +101,15 @@ function AgentEmptyState() { function AgentHomeContent() { const { virtualMcpId } = useInsetContext()!; const entity = useVirtualMCP(virtualMcpId); - const resolved = useResolvedMainView(); const entityIsProject = entity ? isProject(entity) : false; - - // Projects show files as default, simplified settings 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) { - // Explicit ?main=files → show project file browser - if (search.main === "files") { + // 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") { From d6b4eed0dcebe87ecab942732ddb7f40301871ac Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 12:13:19 -0300 Subject: [PATCH 19/23] feat(tasks): project icons on tasks, filter/group by project or status Task panel improvements: - Each task row shows the project icon + name (AgentAvatar) - Filter icon in header opens dropdown with: - No grouping (default, flat list) - Group by project (accordion sections) - Group by status (accordion sections) - Filter by specific project (shows only that project's tasks) - Active filter state shown with highlighted filter icon - ProjectInfo type carries both name and icon - Grouped view shows section headers with task count Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/chat/side-panel-tasks.tsx | 6 +- .../src/web/components/chat/tasks-panel.tsx | 232 +++++++++++++++--- 2 files changed, 203 insertions(+), 35 deletions(-) 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 64b8fd63f3..c6e62683b2 100644 --- a/apps/mesh/src/web/components/chat/side-panel-tasks.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-tasks.tsx @@ -15,7 +15,7 @@ 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 { @@ -95,10 +95,10 @@ function TasksPanelContent({ // Always show ALL tasks — unified panel regardless of context const allProjects = useVirtualMCPs(); - const projectNames = new Map( + const projectNames = new Map( allProjects .filter((p) => p.id && !isDecopilotFn(p.id)) - .map((p) => [p.id, p.title]), + .map((p) => [p.id, { name: p.title, icon: p.icon }]), ); const handleNewTask = () => { diff --git a/apps/mesh/src/web/components/chat/tasks-panel.tsx b/apps/mesh/src/web/components/chat/tasks-panel.tsx index 33cc050709..3343a1eec5 100644 --- a/apps/mesh/src/web/components/chat/tasks-panel.tsx +++ b/apps/mesh/src/web/components/chat/tasks-panel.tsx @@ -6,6 +6,7 @@ */ import { useChatTask } from "@/web/components/chat/context"; +import { AgentAvatar } from "@/web/components/agent-icon"; import { usePanelActions } from "@/web/layouts/shell-layout"; import { useInsetContext } from "@/web/layouts/shell-layout"; import { formatTimeAgo, formatTimeUntil } from "@/web/lib/format-time"; @@ -26,7 +27,7 @@ import { 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 { @@ -142,12 +143,12 @@ function TaskRow({ task, isActive, onClick, - projectName, + projectInfo, }: { task: Task; isActive: boolean; onClick: () => void; - projectName?: string; + projectInfo?: ProjectInfo; }) { const { setTaskStatus, hideTask } = useChatTask(); const playSound = useSound(question004Sound); @@ -161,7 +162,7 @@ function TaskRow({
- {projectName && ( - - {projectName} - + {projectInfo && ( +
+ + + {projectInfo.name} + +
)}
@@ -443,13 +452,20 @@ function IncomingSection({ virtualMcpId }: { virtualMcpId: string }) { // Core list (sidebar + side-panel) // ──────────────────────────────────────── +export interface ProjectInfo { + name: string; + icon: string | null; +} + +type GroupBy = "none" | "project" | "status"; + interface TaskListContentProps { onTaskSelect?: (taskId: string) => void; onTaskCreate?: () => void; virtualMcpId?: string | null; showAutomations?: boolean; - /** Map of virtualMcpId → project name for labeling tasks */ - projectNames?: Map; + /** Map of virtualMcpId → project info for labeling tasks */ + projectNames?: Map; } export function TaskListContent({ @@ -461,12 +477,12 @@ export function TaskListContent({ }: 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( @@ -477,6 +493,7 @@ export function TaskListContent({ const visible = tasks .filter((t) => !t.hidden) + .filter((t) => !filterProjectId || t.virtual_mcp_id === filterProjectId) .slice() .sort( (a, b) => @@ -493,7 +510,6 @@ export function TaskListContent({ onTaskSelect(task.id); return; } - // Navigate cross-project if the task belongs to a different project if (task.virtual_mcp_id && task.virtual_mcp_id !== currentVirtualMcpId) { navigate({ to: "/$org/$virtualMcpId/", @@ -505,20 +521,143 @@ export function TaskListContent({ } }; + 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 && ( @@ -535,21 +674,23 @@ export function TaskListContent({ )}
- {/* Task rows — always visible */} - {visible.length > 0 ? ( - visible.map((task) => ( - handleSelect(task)} - projectName={ - projectNames && task.virtual_mcp_id - ? projectNames.get(task.virtual_mcp_id) - : undefined - } - /> + {/* Task rows */} + {grouped ? ( + Object.entries(grouped).map(([label, groupTasks]) => ( +
+
+ + {label} + + + {groupTasks.length} + +
+ {groupTasks.map(renderTask)} +
)) + ) : visible.length > 0 ? ( + visible.map(renderTask) ) : (
No tasks @@ -559,3 +700,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; +} From 7b5394af36502b16386c31e9a3183885d5ff3b13 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 12:14:11 -0300 Subject: [PATCH 20/23] fix: swap FolderClosed (blocked icon) for Folder (plain) in toolbar Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/web/components/layout/content-toolbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mesh/src/web/components/layout/content-toolbar.tsx b/apps/mesh/src/web/components/layout/content-toolbar.tsx index d2dc56113f..2d3b736099 100644 --- a/apps/mesh/src/web/components/layout/content-toolbar.tsx +++ b/apps/mesh/src/web/components/layout/content-toolbar.tsx @@ -10,7 +10,7 @@ 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 { FolderClosed, Settings02 } from "@untitledui/icons"; +import { Folder, Settings02 } from "@untitledui/icons"; import { Suspense } from "react"; import { Tooltip, @@ -106,7 +106,7 @@ function ToolbarContent() { isFilesActive ? toolbarBtnActive : toolbarBtnInactive, )} > - + Files From fc9fae9feb52dca0d3b7a4aebc17a6a9da69609a Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 12:19:20 -0300 Subject: [PATCH 21/23] fix: task click navigates to project, labels show projects not agents - Remove onTaskSelect override so TaskListContent's cross-project navigation runs (navigates to /$org/$projectId/?taskId=...) - Filter projectNames to only include entities with metadata.type="project" so task labels show project names, not agent names Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/web/components/chat/side-panel-tasks.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 c6e62683b2..41de0481eb 100644 --- a/apps/mesh/src/web/components/chat/side-panel-tasks.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-tasks.tsx @@ -11,6 +11,7 @@ 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"; @@ -90,14 +91,15 @@ function TasksPanelContent({ hideProjectHeader?: boolean; showAutomations?: boolean; }) { - const { createNewTask, setTaskId } = usePanelActions(); + const { createNewTask } = usePanelActions(); const [isPending, startTransition] = useTransition(); // Always show ALL tasks — unified panel regardless of context - const allProjects = useVirtualMCPs(); + const allVirtualMcps = useVirtualMCPs(); + // Only show project names (not agent names) on task labels const projectNames = new Map( - allProjects - .filter((p) => p.id && !isDecopilotFn(p.id)) + allVirtualMcps + .filter((p) => p.id && !isDecopilotFn(p.id) && isProject(p)) .map((p) => [p.id, { name: p.title, icon: p.icon }]), ); @@ -131,9 +133,6 @@ function TasksPanelContent({ virtualMcpId="" showAutomations={showAutomations} onTaskCreate={handleNewTask} - onTaskSelect={(taskId) => { - setTaskId(taskId); - }} projectNames={projectNames} />
From 195ac45ebfa73b18f71b0ee9b782b92b01c308fc Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 12:41:51 -0300 Subject: [PATCH 22/23] fix: clicking a task restores the project's UI view When clicking a task from the task panel, the navigation now includes the project's defaultMainView (ext-apps/slide_maker etc.) in the URL search params. This means clicking a task from a slides project opens the slide editor, not just the files view. - ProjectInfo now carries defaultView from entity metadata - handleSelect builds search params with main/id/toolName from defaultView - side-panel-tasks reads layout.defaultMainView from each project Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/components/chat/side-panel-tasks.tsx | 21 +++++++++++++++++-- .../src/web/components/chat/tasks-panel.tsx | 20 +++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) 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 41de0481eb..8fa3be3ccf 100644 --- a/apps/mesh/src/web/components/chat/side-panel-tasks.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-tasks.tsx @@ -96,11 +96,28 @@ function TasksPanelContent({ // Always show ALL tasks — unified panel regardless of context const allVirtualMcps = useVirtualMCPs(); - // Only show project names (not agent names) on task labels + // 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) => [p.id, { name: p.title, icon: p.icon }]), + .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 = () => { diff --git a/apps/mesh/src/web/components/chat/tasks-panel.tsx b/apps/mesh/src/web/components/chat/tasks-panel.tsx index 3343a1eec5..e6c01f4da7 100644 --- a/apps/mesh/src/web/components/chat/tasks-panel.tsx +++ b/apps/mesh/src/web/components/chat/tasks-panel.tsx @@ -455,6 +455,12 @@ function IncomingSection({ virtualMcpId }: { virtualMcpId: string }) { 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"; @@ -511,10 +517,22 @@ export function TaskListContent({ return; } if (task.virtual_mcp_id && task.virtual_mcp_id !== currentVirtualMcpId) { + // Restore the project's default UI view when navigating cross-project + const projectInfo = task.virtual_mcp_id + ? projectNames?.get(task.virtual_mcp_id) + : undefined; + const dv = projectInfo?.defaultView; + const searchParams: Record = { taskId: task.id }; + if (dv?.type) { + searchParams.main = dv.type; + searchParams.mainOpen = 1; + if (dv.id) searchParams.id = dv.id; + if (dv.toolName) searchParams.toolName = dv.toolName; + } navigate({ to: "/$org/$virtualMcpId/", params: { org: org.slug, virtualMcpId: task.virtual_mcp_id }, - search: { taskId: task.id }, + search: searchParams, }); } else { setTaskId(task.id); From 90dc4fcb89d1c88693cc98b494d44e40f4de4359 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 8 Apr 2026 12:57:23 -0300 Subject: [PATCH 23/23] feat: per-task layout persistence (chat + content panel state) Each task now remembers its own layout state: - Chat panel open/closed - Content panel open/closed - Which main view was active (files, ext-apps, settings) Tasks panel state is global (not per-task). Implementation: - task-layout-store.ts: localStorage-based store keyed by taskId - usePanelState saves layout whenever URL params change with a taskId - Task click handler reads saved state, falls back to project default - Max 200 entries with automatic pruning Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/web/components/chat/tasks-panel.tsx | 40 +++++++++++--- apps/mesh/src/web/hooks/use-layout-state.ts | 15 ++++++ apps/mesh/src/web/lib/task-layout-store.ts | 54 +++++++++++++++++++ 3 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 apps/mesh/src/web/lib/task-layout-store.ts diff --git a/apps/mesh/src/web/components/chat/tasks-panel.tsx b/apps/mesh/src/web/components/chat/tasks-panel.tsx index e6c01f4da7..a5f4a71bf8 100644 --- a/apps/mesh/src/web/components/chat/tasks-panel.tsx +++ b/apps/mesh/src/web/components/chat/tasks-panel.tsx @@ -7,6 +7,7 @@ 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"; @@ -482,7 +483,6 @@ export function TaskListContent({ projectNames, }: TaskListContentProps) { const { ownerFilter } = useChatTask(); - const { setTaskId } = usePanelActions(); const [groupBy, setGroupBy] = useState("none"); const [filterProjectId, setFilterProjectId] = useState(null); @@ -516,26 +516,50 @@ export function TaskListContent({ onTaskSelect(task.id); return; } - if (task.virtual_mcp_id && task.virtual_mcp_id !== currentVirtualMcpId) { - // Restore the project's default UI view when navigating cross-project - const projectInfo = task.virtual_mcp_id - ? projectNames?.get(task.virtual_mcp_id) - : undefined; + + // 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; - const searchParams: Record = { taskId: task.id }; 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, + }); } }; diff --git a/apps/mesh/src/web/hooks/use-layout-state.ts b/apps/mesh/src/web/hooks/use-layout-state.ts index 96e61f3573..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, @@ -231,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/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; +}