diff --git a/ee/apps/den-web/app/(den)/_lib/den-org.ts b/ee/apps/den-web/app/(den)/_lib/den-org.ts index c2aaa00c..39bf186f 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-org.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-org.ts @@ -276,6 +276,14 @@ export function getNewSkillRoute(orgSlug: string): string { return `${getSkillHubsRoute(orgSlug)}/skills/new`; } +export function getPluginsRoute(orgSlug: string): string { + return `${getOrgDashboardRoute(orgSlug)}/plugins`; +} + +export function getPluginRoute(orgSlug: string, pluginId: string): string { + return `${getPluginsRoute(orgSlug)}/${encodeURIComponent(pluginId)}`; +} + export function parseOrgListPayload(payload: unknown): { orgs: DenOrgSummary[]; activeOrgId: string | null; diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx index cffc07d8..49c336f1 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx @@ -26,6 +26,7 @@ import { getOrgAccessFlags, getMembersRoute, getOrgDashboardRoute, + getPluginsRoute, getSharedSetupsRoute, getSkillHubsRoute, } from "../../../../_lib/den-org"; @@ -110,6 +111,9 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) { if (pathname.startsWith(getSkillHubsRoute(orgSlug))) { return "Skill Hubs"; } + if (pathname.startsWith(getPluginsRoute(orgSlug))) { + return "Plugins"; + } if (pathname.startsWith(getBillingRoute(orgSlug)) || pathname === "/checkout") { return "Billing"; } diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/plugin-data.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/plugin-data.tsx new file mode 100644 index 00000000..260d850d --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/plugin-data.tsx @@ -0,0 +1,412 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +/** + * Plugin primitives — mirror OpenCode / Claude Code's plugin surface: + * + * A plugin is a *bundle* of reusable pieces that extend an agent runtime: + * - skills natural-language playbooks/instructions agents can load on demand + * - hooks lifecycle callbacks (PreToolUse / PostToolUse / SessionStart, etc.) + * - mcps Model Context Protocol servers that expose external tools/resources + * - agents custom sub-agents with their own system prompt and tool set + * - commands slash-commands that shortcut common workflows + * + * This file models the frontend shape only. Mock data is served through + * React Query so we can swap the queryFn for a real API call later without + * touching any consumers. + */ + +// ── Primitive types ──────────────────────────────────────────────────────── + +export type PluginCategory = + | "integrations" + | "workflows" + | "code-intelligence" + | "output-styles" + | "infrastructure"; + +export type PluginSkill = { + id: string; + name: string; + description: string; +}; + +export type PluginHookEvent = + | "PreToolUse" + | "PostToolUse" + | "SessionStart" + | "SessionEnd" + | "UserPromptSubmit" + | "Notification" + | "Stop"; + +export type PluginHook = { + id: string; + event: PluginHookEvent; + description: string; + matcher?: string | null; +}; + +export type PluginMcpTransport = "stdio" | "http" | "sse"; + +export type PluginMcp = { + id: string; + name: string; + description: string; + transport: PluginMcpTransport; + toolCount: number; +}; + +export type PluginAgent = { + id: string; + name: string; + description: string; +}; + +export type PluginCommand = { + id: string; + name: string; + description: string; +}; + +export type PluginSource = + | { type: "marketplace"; marketplace: string } + | { type: "github"; repo: string } + | { type: "local"; path: string }; + +export type DenPlugin = { + id: string; + name: string; + slug: string; + description: string; + version: string; + author: string; + category: PluginCategory; + installed: boolean; + source: PluginSource; + skills: PluginSkill[]; + hooks: PluginHook[]; + mcps: PluginMcp[]; + agents: PluginAgent[]; + commands: PluginCommand[]; + updatedAt: string; +}; + +// ── Display helpers ──────────────────────────────────────────────────────── + +export function getPluginCategoryLabel(category: PluginCategory): string { + switch (category) { + case "integrations": + return "External Integrations"; + case "workflows": + return "Workflows"; + case "code-intelligence": + return "Code Intelligence"; + case "output-styles": + return "Output Styles"; + case "infrastructure": + return "Infrastructure"; + } +} + +export function formatPluginTimestamp(value: string | null): string { + if (!value) { + return "Recently updated"; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "Recently updated"; + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }).format(date); +} + +export function getPluginPartsSummary(plugin: DenPlugin): string { + const parts: string[] = []; + if (plugin.skills.length > 0) { + parts.push(`${plugin.skills.length} ${plugin.skills.length === 1 ? "Skill" : "Skills"}`); + } + if (plugin.hooks.length > 0) { + parts.push(`${plugin.hooks.length} ${plugin.hooks.length === 1 ? "Hook" : "Hooks"}`); + } + if (plugin.mcps.length > 0) { + parts.push(`${plugin.mcps.length} ${plugin.mcps.length === 1 ? "MCP" : "MCPs"}`); + } + if (plugin.agents.length > 0) { + parts.push(`${plugin.agents.length} ${plugin.agents.length === 1 ? "Agent" : "Agents"}`); + } + if (plugin.commands.length > 0) { + parts.push(`${plugin.commands.length} ${plugin.commands.length === 1 ? "Command" : "Commands"}`); + } + return parts.length > 0 ? parts.join(" · ") : "Empty bundle"; +} + +// ── Mock data ────────────────────────────────────────────────────────────── +// +// These are shaped to mirror real marketplaces (Anthropic's official catalog, +// internal team bundles, etc.) so the UI exercises realistic content. + +const MOCK_PLUGINS: DenPlugin[] = [ + { + id: "plg_github", + name: "GitHub", + slug: "github", + description: + "Work with GitHub repositories, pull requests, issues, and Actions directly from chat. Bundles an MCP server and review workflows.", + version: "1.4.2", + author: "Anthropic", + category: "integrations", + installed: true, + source: { type: "marketplace", marketplace: "claude-plugins-official" }, + skills: [ + { id: "sk_gh_pr", name: "Open Pull Request", description: "Draft PR titles and bodies from a diff." }, + { id: "sk_gh_review", name: "Review Pull Request", description: "Summarize diffs and suggest blocking comments." }, + ], + hooks: [ + { + id: "hk_gh_pre", + event: "PreToolUse", + description: "Redact GitHub tokens from logs before tool execution.", + matcher: "Bash", + }, + ], + mcps: [ + { + id: "mcp_gh", + name: "github-mcp", + description: "Official GitHub MCP server — issues, PRs, releases, Actions.", + transport: "http", + toolCount: 42, + }, + ], + agents: [ + { + id: "ag_gh_reviewer", + name: "pr-reviewer", + description: "Opinionated pull-request reviewer with context-aware suggestions.", + }, + ], + commands: [ + { id: "cmd_gh_pr", name: "/gh:pr", description: "Create a pull request from the current branch." }, + ], + updatedAt: "2026-04-10T12:00:00Z", + }, + { + id: "plg_commit_commands", + name: "Commit Commands", + slug: "commit-commands", + description: + "Git commit, push, and PR-creation workflows. Uses conventional-commit heuristics and follows your repo's commit style.", + version: "0.9.0", + author: "Anthropic", + category: "workflows", + installed: true, + source: { type: "marketplace", marketplace: "claude-plugins-official" }, + skills: [ + { id: "sk_cc_commit", name: "Smart Commit", description: "Stage, group, and commit with a generated message." }, + { id: "sk_cc_push", name: "Push & Open PR", description: "Push current branch and open a PR with autogenerated body." }, + ], + hooks: [], + mcps: [], + agents: [], + commands: [ + { id: "cmd_cc_commit", name: "/commit", description: "Create a commit for staged changes." }, + { id: "cmd_cc_push", name: "/push", description: "Push current branch and track upstream." }, + { id: "cmd_cc_pr", name: "/pr", description: "Open a pull request." }, + ], + updatedAt: "2026-04-07T09:00:00Z", + }, + { + id: "plg_typescript_lsp", + name: "TypeScript LSP", + slug: "typescript-lsp", + description: + "Connects Claude to the TypeScript language server so it can jump to definitions, find references, and surface type errors immediately after edits.", + version: "1.1.0", + author: "Anthropic", + category: "code-intelligence", + installed: false, + source: { type: "marketplace", marketplace: "claude-plugins-official" }, + skills: [], + hooks: [ + { + id: "hk_ts_diag", + event: "PostToolUse", + description: "Run LSP diagnostics after every file edit and report type errors.", + matcher: "Edit|Write", + }, + ], + mcps: [ + { + id: "mcp_ts", + name: "typescript-language-server", + description: "LSP bridge for .ts/.tsx diagnostics and navigation.", + transport: "stdio", + toolCount: 9, + }, + ], + agents: [], + commands: [], + updatedAt: "2026-03-28T16:45:00Z", + }, + { + id: "plg_linear", + name: "Linear", + slug: "linear", + description: + "Create, update, and triage Linear issues without leaving the session. Bundles a Linear MCP and an issue-grooming agent.", + version: "0.6.3", + author: "Anthropic", + category: "integrations", + installed: false, + source: { type: "marketplace", marketplace: "claude-plugins-official" }, + skills: [ + { id: "sk_lin_triage", name: "Triage Inbox", description: "Sweep the inbox and file issues to the right project." }, + ], + hooks: [], + mcps: [ + { + id: "mcp_linear", + name: "linear-mcp", + description: "Linear MCP — issues, cycles, projects, comments.", + transport: "http", + toolCount: 24, + }, + ], + agents: [ + { id: "ag_lin_groomer", name: "linear-groomer", description: "Keeps the backlog tidy and flags stale issues." }, + ], + commands: [ + { id: "cmd_lin_new", name: "/linear:new", description: "File a new issue from the current context." }, + ], + updatedAt: "2026-04-02T18:12:00Z", + }, + { + id: "plg_openwork_release", + name: "OpenWork Release Kit", + slug: "openwork-release-kit", + description: + "Internal plugin that automates OpenWork release prep, orchestrator sidecar builds, and changelog generation. Shipped by OpenWork infra.", + version: "2.3.1", + author: "OpenWork", + category: "workflows", + installed: true, + source: { type: "github", repo: "different-ai/openwork-plugins" }, + skills: [ + { id: "sk_ow_release_prep", name: "Release Prep", description: "Bump versions across app/desktop/orchestrator in lockstep." }, + { id: "sk_ow_changelog", name: "Changelog Drafter", description: "Generate markdown release notes from merged PRs." }, + ], + hooks: [ + { + id: "hk_ow_sessionstart", + event: "SessionStart", + description: "Load the release runbook into context at session start.", + matcher: null, + }, + ], + mcps: [], + agents: [ + { id: "ag_ow_release", name: "release-captain", description: "Drives the full release flow end-to-end." }, + ], + commands: [ + { id: "cmd_ow_release", name: "/release", description: "Run the standardized release workflow." }, + ], + updatedAt: "2026-04-14T08:30:00Z", + }, + { + id: "plg_sentry", + name: "Sentry", + slug: "sentry", + description: + "Connect to Sentry and ingest recent errors into a session. Includes an MCP server and a triage skill that clusters noisy issues.", + version: "0.4.0", + author: "Anthropic", + category: "infrastructure", + installed: false, + source: { type: "marketplace", marketplace: "claude-plugins-official" }, + skills: [ + { id: "sk_sentry_triage", name: "Triage Errors", description: "Cluster Sentry issues and recommend owners." }, + ], + hooks: [], + mcps: [ + { + id: "mcp_sentry", + name: "sentry-mcp", + description: "Sentry MCP — projects, issues, releases, performance.", + transport: "http", + toolCount: 18, + }, + ], + agents: [], + commands: [], + updatedAt: "2026-03-20T11:00:00Z", + }, + { + id: "plg_explanatory_style", + name: "Explanatory Output Style", + slug: "explanatory-output-style", + description: + "Response style that adds educational context around implementation choices, trade-offs, and alternatives.", + version: "1.0.0", + author: "Anthropic", + category: "output-styles", + installed: false, + source: { type: "marketplace", marketplace: "claude-plugins-official" }, + skills: [], + hooks: [ + { + id: "hk_explain_post", + event: "Stop", + description: "Append an 'Implementation notes' section before stopping.", + matcher: null, + }, + ], + mcps: [], + agents: [], + commands: [], + updatedAt: "2026-03-12T14:22:00Z", + }, +]; + +async function fetchMockPlugins(): Promise { + // Simulate network latency so loading states render during development. + await new Promise((resolve) => setTimeout(resolve, 180)); + return MOCK_PLUGINS; +} + +async function fetchMockPlugin(id: string): Promise { + await new Promise((resolve) => setTimeout(resolve, 120)); + return MOCK_PLUGINS.find((plugin) => plugin.id === id) ?? null; +} + +// ── Query hooks ──────────────────────────────────────────────────────────── +// +// Keep the surface identical to what a real API-backed version would return, +// so swapping `queryFn` for `requestJson(...)` later is a one-line change. + +export const pluginQueryKeys = { + all: ["plugins"] as const, + list: () => [...pluginQueryKeys.all, "list"] as const, + detail: (id: string) => [...pluginQueryKeys.all, "detail", id] as const, +}; + +export function usePlugins() { + return useQuery({ + queryKey: pluginQueryKeys.list(), + queryFn: fetchMockPlugins, + }); +} + +export function usePlugin(id: string) { + return useQuery({ + queryKey: pluginQueryKeys.detail(id), + queryFn: () => fetchMockPlugin(id), + enabled: Boolean(id), + }); +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/plugin-detail-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/plugin-detail-screen.tsx new file mode 100644 index 00000000..d97bc4d7 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/plugin-detail-screen.tsx @@ -0,0 +1,302 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, FileText, Puzzle, Server, Terminal, Users, Webhook } from "lucide-react"; +import { PaperMeshGradient } from "@openwork/ui/react"; +import { buttonVariants } from "../../../../_components/ui/button"; +import { getPluginsRoute } from "../../../../_lib/den-org"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; +import { + type DenPlugin, + type PluginHook, + type PluginMcp, + type PluginSkill, + type PluginAgent, + type PluginCommand, + formatPluginTimestamp, + getPluginCategoryLabel, + getPluginPartsSummary, + usePlugin, +} from "./plugin-data"; + +export function PluginDetailScreen({ pluginId }: { pluginId: string }) { + const { orgSlug } = useOrgDashboard(); + const { data: plugin, isLoading, error } = usePlugin(pluginId); + + if (isLoading && !plugin) { + return ( +
+
+ Loading plugin details... +
+
+ ); + } + + if (!plugin) { + return ( +
+
+ {error instanceof Error ? error.message : "That plugin could not be found."} +
+
+ ); + } + + return ( +
+ {/* Nav */} +
+ + + Back + + + +
+ +
+ {/* ── Main card ── */} +
+ {/* Gradient header — seeded by plugin id to match the list card */} +
+
+ +
+
+ +
+
+ +
+ {/* Title + description + meta */} +
+

{plugin.name}

+ + v{plugin.version} + + by {plugin.author} +
+

{plugin.description}

+

+ {getPluginPartsSummary(plugin)} · Updated {formatPluginTimestamp(plugin.updatedAt)} +

+ + {/* Skills */} + renderSkillRow(skill)} + /> + + {/* Hooks */} + renderHookRow(hook)} + /> + + {/* MCP Servers */} + renderMcpRow(mcp)} + /> + + {/* Agents */} + renderAgentRow(agent)} + /> + + {/* Commands */} + renderCommandRow(command)} + /> +
+
+ + {/* ── Sidebar ── */} + +
+
+ ); +} + +// ── Section + row renderers ────────────────────────────────────────────────── + +function PrimitiveSection({ + icon: Icon, + label, + items, + emptyLabel, + render, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + items: T[]; + emptyLabel: string; + render: (item: T) => React.ReactNode; +}) { + return ( +
+

+ + {items.length === 0 ? label : `${items.length} ${label}`} +

+ + {items.length === 0 ? ( +
+ {emptyLabel} +
+ ) : ( +
{items.map((item) => render(item))}
+ )} +
+ ); +} + +function renderSkillRow(skill: PluginSkill) { + return ( +
+
+

{skill.name}

+

{skill.description}

+
+ Skill +
+ ); +} + +function renderHookRow(hook: PluginHook) { + return ( +
+
+

{hook.event}

+

{hook.description}

+
+ + {hook.matcher ? `matcher: ${hook.matcher}` : "any"} + +
+ ); +} + +function renderMcpRow(mcp: PluginMcp) { + return ( +
+
+

{mcp.name}

+

{mcp.description}

+
+ + {mcp.transport} · {mcp.toolCount} tools + +
+ ); +} + +function renderAgentRow(agent: PluginAgent) { + return ( +
+
+

{agent.name}

+

{agent.description}

+
+ Agent +
+ ); +} + +function renderCommandRow(command: PluginCommand) { + return ( +
+
+

{command.name}

+

{command.description}

+
+ Command +
+ ); +} + +// Satisfy the type parameter of DenPlugin import even if unused at runtime. +// (Keeps the file importable when you wire in edit forms later.) +export type { DenPlugin }; diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/plugins-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/plugins-screen.tsx new file mode 100644 index 00000000..2e12a7ed --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/plugins-screen.tsx @@ -0,0 +1,312 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { + FileText, + Puzzle, + Search, + Server, + Webhook, +} from "lucide-react"; +import { PaperMeshGradient } from "@openwork/ui/react"; +import { UnderlineTabs } from "../../../../_components/ui/tabs"; +import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template"; +import { DenInput } from "../../../../_components/ui/input"; +import { getPluginRoute } from "../../../../_lib/den-org"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; +import { + getPluginCategoryLabel, + getPluginPartsSummary, + usePlugins, +} from "./plugin-data"; + +type PluginView = "plugins" | "skills" | "hooks" | "mcps"; + +const PLUGIN_TABS = [ + { value: "plugins" as const, label: "Plugins", icon: Puzzle }, + { value: "skills" as const, label: "All Skills", icon: FileText }, + { value: "hooks" as const, label: "All Hooks", icon: Webhook }, + { value: "mcps" as const, label: "All MCPs", icon: Server }, +]; + +export function PluginsScreen() { + const { orgSlug } = useOrgDashboard(); + const { data: plugins = [], isLoading, error } = usePlugins(); + const [activeView, setActiveView] = useState("plugins"); + const [query, setQuery] = useState(""); + + const normalizedQuery = query.trim().toLowerCase(); + + const filteredPlugins = useMemo(() => { + if (!normalizedQuery) { + return plugins; + } + + return plugins.filter((plugin) => { + return ( + plugin.name.toLowerCase().includes(normalizedQuery) || + plugin.description.toLowerCase().includes(normalizedQuery) || + plugin.author.toLowerCase().includes(normalizedQuery) || + getPluginCategoryLabel(plugin.category).toLowerCase().includes(normalizedQuery) + ); + }); + }, [normalizedQuery, plugins]); + + const allSkills = useMemo( + () => + plugins.flatMap((plugin) => + plugin.skills.map((skill) => ({ ...skill, pluginId: plugin.id, pluginName: plugin.name })), + ), + [plugins], + ); + + const allHooks = useMemo( + () => + plugins.flatMap((plugin) => + plugin.hooks.map((hook) => ({ ...hook, pluginId: plugin.id, pluginName: plugin.name })), + ), + [plugins], + ); + + const allMcps = useMemo( + () => + plugins.flatMap((plugin) => + plugin.mcps.map((mcp) => ({ ...mcp, pluginId: plugin.id, pluginName: plugin.name })), + ), + [plugins], + ); + + const filteredSkills = useMemo(() => { + if (!normalizedQuery) return allSkills; + return allSkills.filter( + (skill) => + skill.name.toLowerCase().includes(normalizedQuery) || + skill.description.toLowerCase().includes(normalizedQuery) || + skill.pluginName.toLowerCase().includes(normalizedQuery), + ); + }, [normalizedQuery, allSkills]); + + const filteredHooks = useMemo(() => { + if (!normalizedQuery) return allHooks; + return allHooks.filter( + (hook) => + hook.event.toLowerCase().includes(normalizedQuery) || + hook.description.toLowerCase().includes(normalizedQuery) || + hook.pluginName.toLowerCase().includes(normalizedQuery), + ); + }, [normalizedQuery, allHooks]); + + const filteredMcps = useMemo(() => { + if (!normalizedQuery) return allMcps; + return allMcps.filter( + (mcp) => + mcp.name.toLowerCase().includes(normalizedQuery) || + mcp.description.toLowerCase().includes(normalizedQuery) || + mcp.pluginName.toLowerCase().includes(normalizedQuery), + ); + }, [normalizedQuery, allMcps]); + + const searchPlaceholder = + activeView === "plugins" + ? "Search plugins..." + : activeView === "skills" + ? "Search skills..." + : activeView === "hooks" + ? "Search hooks..." + : "Search MCPs..."; + + return ( + +
+
+ +
+ setQuery(event.target.value)} + placeholder={searchPlaceholder} + /> +
+
+
+ + {error ? ( +
+ {error instanceof Error ? error.message : "Failed to load plugins."} +
+ ) : null} + + {isLoading ? ( +
+ Loading plugin catalog... +
+ ) : activeView === "plugins" ? ( + filteredPlugins.length === 0 ? ( + + ) : ( +
+ {filteredPlugins.map((plugin) => ( + + {/* Gradient header */} +
+
+ +
+
+ +
+ {plugin.installed ? ( + + Installed + + ) : null} +
+ + {/* Body */} +
+
+

{plugin.name}

+ v{plugin.version} +
+

{plugin.description}

+ +
+ + {getPluginPartsSummary(plugin)} + + View plugin +
+
+ + ))} +
+ ) + ) : activeView === "skills" ? ( + ({ + id: skill.id, + title: skill.name, + description: skill.description, + tag: skill.pluginName, + href: getPluginRoute(orgSlug, skill.pluginId), + }))} + /> + ) : activeView === "hooks" ? ( + ({ + id: hook.id, + title: hook.event, + description: hook.description, + tag: hook.pluginName, + href: getPluginRoute(orgSlug, hook.pluginId), + }))} + /> + ) : ( + ({ + id: mcp.id, + title: mcp.name, + description: mcp.description, + tag: `${mcp.pluginName} · ${mcp.transport}`, + href: getPluginRoute(orgSlug, mcp.pluginId), + }))} + /> + )} +
+ ); +} + +function EmptyState({ title, description }: { title: string; description: string }) { + return ( +
+

{title}

+

{description}

+
+ ); +} + +type PrimitiveRow = { + id: string; + title: string; + description: string; + tag: string; + href: string; +}; + +function PrimitiveList({ + rows, + unfilteredCount, + emptyLabel, + emptyDescriptionEmpty, + emptyDescriptionFiltered, +}: { + rows: PrimitiveRow[]; + unfilteredCount: number; + emptyLabel: string; + emptyDescriptionEmpty: string; + emptyDescriptionFiltered: string; +}) { + if (rows.length === 0) { + return ( + + ); + } + + return ( +
+ {rows.map((row) => ( + +
+

{row.title}

+ {row.description ? ( +

{row.description}

+ ) : null} +
+ + {row.tag} + + + ))} +
+ ); +} + diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_providers/query-client-provider.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_providers/query-client-provider.tsx new file mode 100644 index 00000000..f5eebee4 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_providers/query-client-provider.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * DashboardQueryClientProvider + * + * Scopes a single QueryClient instance to the org dashboard subtree. + * Keeps den-web's React Query surface narrow — any new dashboard feature + * (plugins, etc.) can use useQuery/useMutation without leaking client state + * across other top-level routes. + */ +export function DashboardQueryClientProvider({ children }: { children: React.ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: false, + retry: 1, + }, + }, + }), + ); + + return {children}; +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/layout.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/layout.tsx index 71cf6c79..87d576e2 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/layout.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/layout.tsx @@ -1,5 +1,6 @@ import { OrgDashboardShell } from "./_components/org-dashboard-shell"; import { OrgDashboardProvider } from "./_providers/org-dashboard-provider"; +import { DashboardQueryClientProvider } from "./_providers/query-client-provider"; export default async function OrgDashboardLayout({ children, @@ -11,8 +12,10 @@ export default async function OrgDashboardLayout({ const { orgSlug } = await params; return ( - - {children} - + + + {children} + + ); } diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/plugins/[pluginId]/page.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/plugins/[pluginId]/page.tsx new file mode 100644 index 00000000..8f345ef9 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/plugins/[pluginId]/page.tsx @@ -0,0 +1,11 @@ +import { PluginDetailScreen } from "../../_components/plugin-detail-screen"; + +export default async function PluginPage({ + params, +}: { + params: Promise<{ pluginId: string }>; +}) { + const { pluginId } = await params; + + return ; +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/plugins/page.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/plugins/page.tsx new file mode 100644 index 00000000..debfe1b1 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/plugins/page.tsx @@ -0,0 +1,5 @@ +import { PluginsScreen } from "../_components/plugins-screen"; + +export default function PluginsPage() { + return ; +} diff --git a/ee/apps/den-web/package.json b/ee/apps/den-web/package.json index 96eb4343..0a8bc9ec 100644 --- a/ee/apps/den-web/package.json +++ b/ee/apps/den-web/package.json @@ -14,6 +14,7 @@ "@openwork-ee/utils": "workspace:*", "@openwork/ui": "workspace:*", "@paper-design/shaders-react": "0.0.72", + "@tanstack/react-query": "^5.96.2", "lucide-react": "^0.577.0", "next": "16.2.1", "react": "19.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f36c373e..d5aec474 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -538,6 +538,9 @@ importers: '@paper-design/shaders-react': specifier: 0.0.72 version: 0.0.72(@types/react@19.2.14)(react@19.2.4) + '@tanstack/react-query': + specifier: ^5.96.2 + version: 5.96.2(react@19.2.4) lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.4)