diff --git a/.github/workflows/ci-dsh-design-studio.yml b/.github/workflows/ci-dsh-design-studio.yml new file mode 100644 index 000000000..aa24424cc --- /dev/null +++ b/.github/workflows/ci-dsh-design-studio.yml @@ -0,0 +1,96 @@ +name: DeepSeek Harness Design Studio plugin + +on: + push: + branches: [main, dev] + paths: + - "plugins/deepseek-harness/design-studio/**" + - "packages/design-studio/**" + - "apps/app/src/react-app/domains/session/design/**" + - ".github/workflows/ci-dsh-design-studio.yml" + tags: + - "deepseek-idesign-v*" + pull_request: + branches: [main, dev] + paths: + - "plugins/deepseek-harness/design-studio/**" + - "packages/design-studio/**" + - "apps/app/src/react-app/domains/session/design/**" + - ".github/workflows/ci-dsh-design-studio.yml" + +permissions: + contents: read + +defaults: + run: + working-directory: plugins/deepseek-harness/design-studio + +jobs: + check: + name: Build and inspect package + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.4.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + cache: pnpm + + - name: Install iPolloWork workspace + run: pnpm --dir ../../.. install --frozen-lockfile + + - name: Install plugin workspace + run: pnpm install --frozen-lockfile + + - name: Typecheck and build + run: pnpm run check && pnpm run build + + - name: Dry-run publish + run: npm publish --dry-run --access public + + publish: + name: Publish to npm + needs: check + if: startsWith(github.ref, 'refs/tags/deepseek-idesign-v') + runs-on: ubuntu-latest + + permissions: + contents: read + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.4.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + cache: pnpm + + - name: Install iPolloWork workspace + run: pnpm --dir ../../.. install --frozen-lockfile + + - name: Install plugin workspace + run: pnpm install --frozen-lockfile + + - name: Publish + run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/apps/app/package.json b/apps/app/package.json index 5bfe2624f..d809c81c8 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -46,6 +46,7 @@ "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/ibm-plex-sans": "^5.2.8", "@heroicons/react": "^2.2.0", + "@ipollowork/design-studio": "workspace:*", "@ipollowork/types": "workspace:*", "@ipollowork/ui": "workspace:*", "@lexical/react": "^0.35.0", diff --git a/apps/app/src/app/lib/den-types.ts b/apps/app/src/app/lib/den-types.ts index bb6b4ab0b..a84314222 100644 --- a/apps/app/src/app/lib/den-types.ts +++ b/apps/app/src/app/lib/den-types.ts @@ -106,6 +106,35 @@ export type DenOrgMarketplace = { updatedAt: string | null; }; +export type DenMarketplacePlugin = { + pluginId: string; + name: string; + description: string; + category: string; + publisher: string; + icon: Record | null; + version: string; + manifest: iPolloWorkExtensionManifest; + pointsCost: number; + acquired: boolean; + featured: boolean; + digest: string; + size: number; + updatedAt: string; +}; + +export type DenMarketplaceAcquireResult = { + acquired: true; + spentPoints: number; + balance: number | null; +}; + +export type DenMarketplacePluginDownload = { + bytes: Uint8Array; + fileName: string; + digest: string | null; +}; + export type DenOrgPluginResolved = { plugin: DenOrgPlugin; memberships: DenPluginMembership[]; diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index 2ba53b8b8..addf97fac 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -18,6 +18,7 @@ import { } from "./den-session-events"; import { desktopFetch, + desktopFetchBinaryViaMain, desktopFetchViaMain, getDesktopBootstrapConfig as getDesktopBootstrapConfigFromShell, setDesktopBootstrapConfig as setDesktopBootstrapConfigInShell, @@ -59,6 +60,9 @@ export const DEN_INFERENCE_PATH = "/dashboard/inference"; export type * from "./den-types"; import type { DenOrgExtensionProjection, + DenMarketplaceAcquireResult, + DenMarketplacePlugin, + DenMarketplacePluginDownload, DenOrgMarketplace, DenOrgPlugin, DenOrgPluginResolved, @@ -1279,6 +1283,48 @@ function parseiPolloWorkExtensionManifest(value: unknown): iPolloWorkExtensionMa return result.success ? result.manifest : null; } +function parseMarketplacePlugin(value: unknown): DenMarketplacePlugin | null { + if (!isRecord(value)) return null; + const manifest = parseiPolloWorkExtensionManifest(value.manifest); + if ( + !manifest + || typeof value.pluginId !== "string" + || typeof value.name !== "string" + || typeof value.description !== "string" + || typeof value.category !== "string" + || typeof value.publisher !== "string" + || typeof value.version !== "string" + || typeof value.pointsCost !== "number" + || typeof value.acquired !== "boolean" + || typeof value.featured !== "boolean" + || typeof value.digest !== "string" + || typeof value.size !== "number" + || typeof value.updatedAt !== "string" + ) return null; + return { + pluginId: value.pluginId, + name: value.name, + description: value.description, + category: value.category, + publisher: value.publisher, + icon: isRecord(value.icon) ? value.icon : null, + version: value.version, + manifest, + pointsCost: value.pointsCost, + acquired: value.acquired, + featured: value.featured, + digest: value.digest, + size: value.size, + updatedAt: value.updatedAt, + }; +} + +function parseMarketplaceAcquireResult(value: unknown): DenMarketplaceAcquireResult | null { + if (!isRecord(value) || value.acquired !== true || typeof value.spentPoints !== "number") return null; + if (value.balance !== null && typeof value.balance !== "number") return null; + return { acquired: true, spentPoints: value.spentPoints, balance: value.balance }; +} + function parseDenExtensionProjection(value: unknown): DenOrgExtensionProjection | null { if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string") return null; const sourceFormat = parseExtensionSourceFormat(value.sourceFormat); @@ -1611,6 +1657,51 @@ async function requestJson( return raw.json as T; } +async function requestBinary( + input: string | DenBaseUrls, + path: string, + options: DenRequestOptions = {}, +): Promise { + const baseUrls = typeof input === "string" ? resolveDenBaseUrls(input) : input; + const url = `${resolveRequestBaseUrl(baseUrls, path)}${path}`; + const headers: Record = { Accept: "application/zip" }; + const token = options.token?.trim() ?? ""; + if (token) headers.Authorization = `Bearer ${token}`; + + const fetchImpl: FetchLike = isDesktopRuntime() + ? (requestInput, init) => desktopFetchBinaryViaMain(requestInput, init, options.timeoutMs ?? DEFAULT_DEN_TIMEOUT_MS) + : globalThis.fetch; + const response = await fetchWithTimeout(fetchImpl, url, { + method: options.method ?? "GET", + headers, + credentials: "include", + }, options.timeoutMs ?? DEFAULT_DEN_TIMEOUT_MS); + if (response.ok) return response; + + const text = await response.text(); + let payload: unknown = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = null; + } + const code = isRecord(payload) && typeof payload.error === "string" ? payload.error : "request_failed"; + throw new DenApiError(response.status, code, getErrorMessage(payload, `Request failed with ${response.status}.`)); +} + +function responseFileName(response: Response, fallback: string): string { + const disposition = response.headers.get("Content-Disposition") ?? ""; + const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/iu)?.[1]; + if (encoded) { + try { + return decodeURIComponent(encoded); + } catch { + return fallback; + } + } + return disposition.match(/filename="([^"]+)"/u)?.[1] ?? fallback; +} + async function ensureActiveOrganization( baseUrls: DenBaseUrls, token: string | null, @@ -1899,6 +1990,47 @@ export function createDenClient(options: { baseUrl: string; token?: string | nul ); }, + async listMarketplacePlugins(): Promise { + const payload = await requestJson(baseUrls, "/api/v1/marketplace/plugins", { token }); + if (!isRecord(payload) || !Array.isArray(payload.items)) { + throw new DenApiError(500, "invalid_marketplace_payload", "Marketplace response was invalid."); + } + const items = payload.items.map(parseMarketplacePlugin); + if (items.some((item) => item === null)) { + throw new DenApiError(500, "invalid_marketplace_payload", "Marketplace response contained an invalid plugin package."); + } + return items as DenMarketplacePlugin[]; + }, + + async getMarketplacePlugin(pluginId: string): Promise { + const payload = await requestJson(baseUrls, `/api/v1/marketplace/plugins/${encodeURIComponent(pluginId)}`, { token }); + const item = isRecord(payload) ? parseMarketplacePlugin(payload.item) : null; + if (!item) throw new DenApiError(500, "invalid_marketplace_payload", "Marketplace plugin response was invalid."); + return item; + }, + + async acquireMarketplacePlugin(pluginId: string): Promise { + const payload = await requestJson(baseUrls, `/api/v1/marketplace/plugins/${encodeURIComponent(pluginId)}/acquire`, { + method: "POST", + token, + }); + const result = parseMarketplaceAcquireResult(payload); + if (!result) throw new DenApiError(500, "invalid_marketplace_payload", "Marketplace purchase response was invalid."); + return result; + }, + + async downloadMarketplacePlugin(pluginId: string): Promise { + const response = await requestBinary(baseUrls, `/api/v1/marketplace/plugins/${encodeURIComponent(pluginId)}/download`, { + token, + timeoutMs: 30_000, + }); + return { + bytes: new Uint8Array(await response.arrayBuffer()), + fileName: responseFileName(response, `${pluginId.replaceAll("/", "-")}.ipollowork-plugin`), + digest: response.headers.get("X-iPollo-Artifact-SHA256"), + }; + }, + async listOrgMarketplaces(orgId: string): Promise { const payload = await requestJson( baseUrls, diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 5ecb262c1..a8616be1f 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -88,6 +88,29 @@ export default { "plugin_platform.default_category": "Service plugin", "plugin_platform.category_design_development": "Design and development", "plugin_platform.capability_summary": "{apps} apps · {skills} skills · {more} additional capabilities", + "plugin_library.navigation_label": "Extension type", + "plugin_library.plugins_tab": "Plugins", + "plugin_library.skills_tab": "Skills", + "plugin_library.title": "Plugins", + "plugin_library.description": "Add tools, skills, and services to agents through complete capability packages.", + "plugin_library.add": "Add", + "plugin_library.search": "Search plugins", + "plugin_library.installed": "Installed", + "plugin_library.open_plugin": "Open {name}", + "plugin_library.source_label": "Plugin source", + "plugin_library.marketplace": "Marketplace", + "plugin_library.personal": "Personal", + "plugin_library.personal_title": "Personal plugins", + "plugin_library.personal_description": "Manage complete plugin packages installed or imported into this workspace.", + "plugin_library.personal_empty": "No personal plugins yet.", + "plugin_library.featured": "Featured", + "plugin_library.category.ai-agents": "AI Agents & Automation", + "plugin_library.category.development-operations": "Development & Operations", + "plugin_library.category.design-creative": "Design & Creative", + "plugin_library.category.productivity-collaboration": "Productivity & Collaboration", + "plugin_library.category.business-operations": "Business & Operations", + "plugin_library.category.finance": "Finance", + "plugin_library.category.other": "Other", "design.export.download": "Download", "design.export.download_pdf": "Download PDF", "design.export.download_pptx": "Download PPTX", @@ -1306,7 +1329,7 @@ export default { "extensions.filter_apps": "Apps", "extensions.filter_plugins": "Plugins", "extensions.marketplace_active_cloud_label": "Active · runs in cloud", - "extensions.marketplace_description": "Browse built-in iPolloWork extensions and organization marketplace extensions. Claude-compatible plugins are normalized into iPolloWork extensions with installable resources such as skills, MCPs, commands, or tools.", + "extensions.marketplace_description": "Browse complete plugin capability packages from iPolloWork Cloud, then purchase or install them into My Extensions.", "extensions.marketplace_local_description": "Desktop-only and unsynced marketplace items stay installable here. Cloud-runnable apps are listed in Connect.", "extensions.marketplace_local_title": "From your marketplace — installs on this machine", "extensions.marketplace_runs_in_cloud": "Runs in cloud", @@ -2195,10 +2218,10 @@ export default { "settings.tab_description_appearance": "Adjust how iPolloWork looks across desktop, system theme, and app frame.", "settings.tab_description_cloud_account": "Sign in and configure your personal cloud workspace.", "settings.tab_description_connect": "Use cloud-managed MCP connections shared by your organization.", - "settings.tab_description_cloud_marketplaces": "Browse and import plugins from your organization's marketplaces.", + "settings.tab_description_cloud_marketplaces": "Browse complete plugin capability packages from iPolloWork Cloud.", "settings.tab_description_cloud_providers": "Import and manage LLM provider keys from your organization.", "settings.tab_description_debug": "Review runtime diagnostics, logs, and low-level debugging utilities.", - "settings.tab_description_extensions": "Manage MCP apps and OpenCode plugins for this workspace.", + "settings.tab_description_extensions": "Manage complete plugin capability packages and skills for this workspace.", "settings.tab_description_general": "Connect providers, choose the default model, authorize folders, and control the selected iPolloWork workspace plus its runtime connection.", "settings.environment.add_button": "Add variable", "settings.environment.add_title": "Add environment variable", @@ -2861,8 +2884,22 @@ export default { "settings.ai.not_connected": "Not connected", "settings.ai.models_connect_description": "Hosted frontier models without managing API keys.", "settings.extensions.opencode_plugins": "OpenCode Plugins", - "settings.marketplace.signin_hint": "You can use iPolloWork without an account. Sign in to iPolloWork Cloud to load Marketplace content, including built-in extensions and organization marketplaces.", - "settings.marketplace.loading": "Loading marketplace extensions…", + "settings.marketplace.signin_title": "Sign in to browse the Plugin Marketplace", + "settings.marketplace.signin_hint": "Marketplace content comes from your iPolloWork Cloud account. Sign in to browse, purchase, and install complete plugin capability packages.", + "settings.marketplace.loading": "Loading the Plugin Marketplace…", + "settings.marketplace.load_failed": "Unable to load the Plugin Marketplace.", + "settings.marketplace.install_failed": "Unable to install this plugin package.", + "settings.marketplace.digest_mismatch": "Plugin package integrity verification failed. Download it again.", + "settings.marketplace.buy_install": "Buy & install · {points} iPoints", + "settings.marketplace.installed": "Installed", + "settings.marketplace.featured": "Featured", + "settings.marketplace.free": "Free", + "settings.marketplace.back": "Back to Marketplace", + "settings.marketplace.capabilities": "Package capabilities", + "settings.marketplace.package_info": "Package information", + "settings.marketplace.size": "Size", + "settings.marketplace.resources": "Capabilities", + "settings.marketplace.permissions": "Permissions", "settings.marketplace.working": "Working…", "settings.marketplace.search": "Search marketplace extensions…", "settings.marketplace.filter_all": "All", @@ -2872,7 +2909,7 @@ export default { "settings.marketplace.marketplace_label": "Marketplace", "settings.marketplace.all_marketplaces": "All marketplaces", "settings.marketplace.signin_empty": "Sign in to view marketplace extensions.", - "settings.marketplace.empty": "No marketplace extensions are available yet.", + "settings.marketplace.empty": "No plugin capability packages are available yet.", "settings.marketplace.choose_org": "Choose an organization to view marketplace extensions.", "settings.marketplace.no_match": "No marketplace extensions match your search or filters.", "settings.marketplace.composition": "Composition", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index bed01f982..27d7edc05 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -91,6 +91,29 @@ export default { "plugin_platform.default_category": "服务插件", "plugin_platform.category_design_development": "设计与开发", "plugin_platform.capability_summary": "{apps} 个应用 · {skills} 个技能 · {more} 项其他能力", + "plugin_library.navigation_label": "扩展类型", + "plugin_library.plugins_tab": "插件", + "plugin_library.skills_tab": "技能", + "plugin_library.title": "插件", + "plugin_library.description": "通过完整能力包为智能体添加工具、技能和服务。", + "plugin_library.add": "添加", + "plugin_library.search": "搜索插件", + "plugin_library.installed": "已安装", + "plugin_library.open_plugin": "打开{name}", + "plugin_library.source_label": "插件来源", + "plugin_library.marketplace": "市场", + "plugin_library.personal": "个人", + "plugin_library.personal_title": "个人插件", + "plugin_library.personal_description": "管理当前工作区安装或导入的完整插件能力包。", + "plugin_library.personal_empty": "还没有个人插件。", + "plugin_library.featured": "精选", + "plugin_library.category.ai-agents": "AI Agent 与自动化", + "plugin_library.category.development-operations": "开发与运维", + "plugin_library.category.design-creative": "设计与创作", + "plugin_library.category.productivity-collaboration": "效率与协作", + "plugin_library.category.business-operations": "商业与运营", + "plugin_library.category.finance": "金融", + "plugin_library.category.other": "其他", "design.export.download": "下载", "design.export.download_pdf": "下载 PDF", "design.export.download_pptx": "下载 PPTX", @@ -1857,16 +1880,16 @@ export default { "settings.tab_advanced": "高级", "settings.tab_appearance": "外观", "settings.tab_cloud_account": "账号", - "settings.tab_cloud_marketplaces": "市场与插件", + "settings.tab_cloud_marketplaces": "插件市场", "settings.tab_cloud_providers": "云端提供商", "settings.tab_debug": "调试", "settings.tab_description_advanced": "检查运行时健康状态、连接状态和面向开发者的控制项。", "settings.tab_description_appearance": "调整iPolloWork在桌面、系统主题和应用外观方面的显示效果。", "settings.tab_description_debug": "查看运行时诊断信息、日志和底层调试工具。", "settings.tab_description_cloud_account": "登录并配置你的个人云端空间。", - "settings.tab_description_cloud_marketplaces": "浏览并导入组织市场中的插件。", + "settings.tab_description_cloud_marketplaces": "浏览来自 iPolloWork Cloud 的完整插件能力包。", "settings.tab_description_cloud_providers": "导入并管理组织提供的 LLM 提供商密钥。", - "settings.tab_description_extensions": "管理此工作区的MCP应用和OpenCode插件。", + "settings.tab_description_extensions": "管理此工作区的完整插件能力包与技能。", "settings.tab_description_general": "连接提供商、选择默认模型、授权文件夹,以及控制所选iPolloWork工作区和运行时连接。", "settings.tab_description_environment": "保存本机 agents、skills 和 MCP servers 使用的 API keys 与 tokens。Secret 只保留在这台设备上。", "settings.authorization.title": "授权中心", @@ -2314,7 +2337,7 @@ export default { "extensions.connect_marketplace_hint": "市场内容现已迁移至 Connect。", "extensions.connect_marketplace_split_hint": "可在云端运行的市场应用位于 Connect;此市场标签页仅显示安装在本机的项目。", "extensions.marketplace_active_cloud_label": "已启用 · 在云端运行", - "extensions.marketplace_description": "浏览内置 iPolloWork 扩展和组织市场扩展。兼容 Claude 的插件会被标准化为 iPolloWork 扩展,可安装 skills、MCP、命令或工具等资源。", + "extensions.marketplace_description": "浏览来自 iPolloWork Cloud 的完整插件能力包,购买或安装后统一加入我的扩展。", "extensions.marketplace_local_description": "仅限桌面端及未同步的市场项目仍可在这里安装;可在云端运行的应用列在 Connect 中。", "extensions.marketplace_local_title": "来自你的市场 — 安装到此设备", "extensions.marketplace_runs_in_cloud": "在云端运行", @@ -2880,8 +2903,22 @@ export default { "settings.ai.not_connected": "未连接", "settings.ai.models_connect_description": "无需管理 API 密钥的托管前沿模型。", "settings.extensions.opencode_plugins": "OpenCode 插件", - "settings.marketplace.signin_hint": "无需账号也可使用 iPolloWork。登录 iPolloWork Cloud 后,可加载市场内容,包括内置扩展和组织市场。", - "settings.marketplace.loading": "正在加载市场扩展…", + "settings.marketplace.signin_title": "登录后浏览插件市场", + "settings.marketplace.signin_hint": "市场内容来自你的 iPolloWork Cloud 账号。登录后可查看、购买并安装完整插件能力包。", + "settings.marketplace.loading": "正在加载插件市场…", + "settings.marketplace.load_failed": "无法加载插件市场。", + "settings.marketplace.install_failed": "无法安装这个插件包。", + "settings.marketplace.digest_mismatch": "插件包完整性校验失败,请重新下载。", + "settings.marketplace.buy_install": "购买并安装 · {points} iPoints", + "settings.marketplace.installed": "已安装", + "settings.marketplace.featured": "推荐", + "settings.marketplace.free": "免费", + "settings.marketplace.back": "返回市场", + "settings.marketplace.capabilities": "能力包内容", + "settings.marketplace.package_info": "插件包信息", + "settings.marketplace.size": "大小", + "settings.marketplace.resources": "能力", + "settings.marketplace.permissions": "权限", "settings.marketplace.working": "处理中…", "settings.marketplace.search": "搜索市场扩展…", "settings.marketplace.filter_all": "全部", @@ -2891,7 +2928,7 @@ export default { "settings.marketplace.marketplace_label": "市场", "settings.marketplace.all_marketplaces": "所有市场", "settings.marketplace.signin_empty": "登录后查看市场扩展。", - "settings.marketplace.empty": "暂时没有可用的市场扩展。", + "settings.marketplace.empty": "暂时没有可用的插件能力包。", "settings.marketplace.choose_org": "选择一个组织以查看市场扩展。", "settings.marketplace.no_match": "没有符合搜索或筛选条件的市场扩展。", "settings.marketplace.composition": "组成", diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 2f90ee329..394479d4f 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -89,7 +89,7 @@ import { isCollectibleArtifactTarget, isLocalhostBrowserTarget, isOpenableFileTa import type { OpenTargetOptions } from "@/lib/target-provider"; import { VoicePanel } from "../voice/voice-panel"; import { DesignPanel } from "../design/design-panel"; -import { designAiSelectionToken, type DesignAiSelectionContext } from "../design/design-ai-selection"; +import { designAiSelectionToken, type DesignAiSelectionContext } from "@ipollowork/design-studio"; import { useDesignAiSelectionStore } from "../design/design-ai-selection-store"; import { waitForTemplateEntrySurface } from "../templates/template-entry-route"; import { loadTemplateSession } from "../templates/template-session-probe"; diff --git a/apps/app/src/react-app/domains/session/design/design-ai-selection-store.ts b/apps/app/src/react-app/domains/session/design/design-ai-selection-store.ts index de380218c..5801fb1b5 100644 --- a/apps/app/src/react-app/domains/session/design/design-ai-selection-store.ts +++ b/apps/app/src/react-app/domains/session/design/design-ai-selection-store.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; -import type { DesignAiSelectionContext, DesignAiUndoCheckpoint } from "./design-ai-selection"; +import type { DesignAiSelectionContext, DesignAiUndoCheckpoint } from "@ipollowork/design-studio"; type DesignAiSelectionStatus = "pending" | "running" | "completing" | "completed" | "failed"; diff --git a/apps/app/src/react-app/domains/session/design/design-panel.tsx b/apps/app/src/react-app/domains/session/design/design-panel.tsx index 6aa1e7d72..0b0dbf433 100644 --- a/apps/app/src/react-app/domains/session/design/design-panel.tsx +++ b/apps/app/src/react-app/domains/session/design/design-panel.tsx @@ -3,7 +3,12 @@ import * as React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Check, ChevronLeft, ChevronRight, Code2, Focus, Loader2, Minus, Monitor, MousePointer2, Palette, Plus, Save, Share2, SlidersHorizontal, Smartphone, Undo2 } from "lucide-react"; -import type { iPolloWorkServerClient } from "@/app/lib/ipollowork-server"; +import { + IPOLLOWORK_DESIGN_STUDIO_FEATURES, + type DesignAiSelectionContext, + type DesignStudioClient, + type DesignStudioFeatures, +} from "@ipollowork/design-studio"; import { pickLocalImageFile, readLocalImageAsDataUrl } from "@/app/lib/desktop"; import { downloadBlobAsFile } from "@/app/lib/download"; import { Button } from "@/components/ui/button"; @@ -22,7 +27,6 @@ import { toast } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; import { isPptxCompatibleTemplate } from "@ipollowork/types/templates"; import { ConfirmModal } from "@/react-app/design-system/modals/confirm-modal"; -import type { DesignAiSelectionContext } from "./design-ai-selection"; import { useDesignAiSelectionStore } from "./design-ai-selection-store"; import { buildDesignPreviewDocument, @@ -118,11 +122,12 @@ import { type DesignPanelProps = { sessionId: string; - client: iPolloWorkServerClient | null; + client: DesignStudioClient | null; workspaceId: string | null; isRemoteWorkspace?: boolean; initialPath?: string; expanded?: boolean; + features?: DesignStudioFeatures; onAskAi: (context: DesignAiSelectionContext) => void; onSaveAsTemplate?: () => void; }; @@ -221,7 +226,7 @@ function arrayBufferToPreviewDataUrl(data: ArrayBuffer, contentType: string | nu async function hydrateDesignPreviewAssets( source: string, - input: { client: iPolloWorkServerClient | null; workspaceId: string | null; activePagePath: string }, + input: { client: DesignStudioClient | null; workspaceId: string | null; activePagePath: string }, ): Promise { if (!input.client || !input.workspaceId || !input.activePagePath || typeof DOMParser === "undefined") { return { source, objectUrls: [] }; @@ -529,6 +534,7 @@ export function DesignPanel({ isRemoteWorkspace = false, initialPath, expanded = false, + features = IPOLLOWORK_DESIGN_STUDIO_FEATURES, onAskAi, onSaveAsTemplate, }: DesignPanelProps) { @@ -538,6 +544,7 @@ export function DesignPanel({ const previewViewportRef = React.useRef(null); const presentationPanRef = React.useRef(null); const imageInputRef = React.useRef(null); + const imageInputIntentRef = React.useRef<"replacement" | "element-background" | "design-background">("replacement"); const designTokenDraftRef = React.useRef(""); const designTokenSaveTimerRef = React.useRef(null); const templateQuery = useQuery({ @@ -1661,8 +1668,8 @@ export function DesignPanel({ const fontSize = Math.max(1, Math.round(Number.parseFloat(selection?.styles.fontSize || "16") || 16)); const setFontSize = (next: number, remember = false) => applyField("fontSize", `${Math.max(1, Math.min(240, next))}px`, remember); - const replaceImageFromFile = async (file: File | undefined) => { - if (!file || !selection || selection.tag !== "img") return; + const applyBrowserImage = async (file: File | undefined) => { + if (!file) return; if (!file.type.startsWith("image/")) { toast.error("Choose an image file to replace this image."); return; @@ -1673,9 +1680,25 @@ export function DesignPanel({ } try { const result = await imageFileToPortableDataUrl(file); - rememberHistory(); - applyField("src", result, false); - toast.success("Image replaced in the design."); + if (imageInputIntentRef.current === "replacement") { + if (!selection || selection.tag !== "img") return; + rememberHistory(); + applyField("src", result, false); + toast.success("Image replaced in the design."); + } else if (imageInputIntentRef.current === "element-background") { + if (!selection || selection.tag === "img") return; + applyStyleFields({ backgroundColor: "transparent", backgroundImage: `url(\"${result}\")` }); + toast.success("Image added as the fill."); + } else { + handleDesignTokenChange("--ipw-bg-image", `url(\"${result}\")`); + handleDesignTokenChange("--ipw-bg-gradient", "none"); + handleDesignTokenChange("--ipw-bg-overlay", "linear-gradient(rgba(28,27,26,.45), rgba(28,27,26,.45))"); + handleDesignTokenChange("--ipw-bg-overlay-opacity", "0.45"); + handleDesignTokenChange("--ipw-bg-mode", "image"); + handleDesignTokenChange("--ipw-bg-size", "cover"); + handleDesignTokenChange("--ipw-bg-position", "50% 50%"); + toast.success("Background image applied."); + } } catch { toast.error("Could not prepare that image. Try PNG, JPG, or WebP."); } @@ -1696,6 +1719,7 @@ export function DesignPanel({ return; } if (typeof window !== "undefined" && window.__IPOLLOWORK_ELECTRON__?.invokeDesktop) return; + imageInputIntentRef.current = "replacement"; imageInputRef.current?.click(); }; @@ -1787,7 +1811,12 @@ export function DesignPanel({ const chooseBackgroundImage = async () => { if (!selection || selection.tag === "img") return; const pickedPath = await pickLocalImageFile("选择填充图片"); - if (!pickedPath) return; + if (!pickedPath) { + if (typeof window !== "undefined" && window.__IPOLLOWORK_ELECTRON__?.invokeDesktop) return; + imageInputIntentRef.current = "element-background"; + imageInputRef.current?.click(); + return; + } const dataUrl = await readLocalImageAsDataUrl(pickedPath); if (!dataUrl) { toast.error("Could not prepare that image. Try PNG, JPG, or WebP."); @@ -1799,7 +1828,12 @@ export function DesignPanel({ const chooseDesignSystemBackgroundImage = async () => { const pickedPath = await pickLocalImageFile("选择全局背景图片"); - if (!pickedPath) return; + if (!pickedPath) { + if (typeof window !== "undefined" && window.__IPOLLOWORK_ELECTRON__?.invokeDesktop) return; + imageInputIntentRef.current = "design-background"; + imageInputRef.current?.click(); + return; + } const dataUrl = await readLocalImageAsDataUrl(pickedPath); if (!dataUrl) { toast.error("Could not prepare that image. Try PNG, JPG, or WebP."); @@ -1942,9 +1976,9 @@ export function DesignPanel({ type="file" accept={LOCAL_IMAGE_ACCEPT} className="sr-only" - aria-label="Choose replacement image" + aria-label="Choose design image" onChange={(event) => { - replaceImageFromFile(event.currentTarget.files?.[0]); + void applyBrowserImage(event.currentTarget.files?.[0]); event.currentTarget.value = ""; }} /> @@ -2086,7 +2120,7 @@ export function DesignPanel({ > {saveMutation.isPending ? : dirty ? : } - {!compactToolbar ? ( + {!compactToolbar && features.publish ? ( - ) : null} - - {t("den.refresh")} - - - - - {!isSignedIn ? ( - -
- {t("settings.marketplace.signin_hint")} - -
-
- ) : null} - - {actionError ?? extensions.cloudOrgMarketplacesStatus() ? ( - {actionError ?? extensions.cloudOrgMarketplacesStatus()} - ) : null} - - {busy ? ( - {t("settings.marketplace.loading")} - ) : null} - - {removedUpstreamPlugins.map((plugin) => ( - -
- {t("extensions.removed_upstream_notice", { name: plugin.name })} - -
-
- ))} - -
- setSearch(event.currentTarget.value)} - placeholder={t("settings.marketplace.search")} - /> -
- {(["all", "available", "installed", "update_available"] as const).map((filter) => ( - - ))} -
- - {t("settings.marketplace.filters")} - -
- -
-
-
-
- - {!busy && displayRows.length === 0 ? ( - - {!isSignedIn ? t("settings.marketplace.signin_empty") : activeOrgId ? t("settings.marketplace.empty") : t("settings.marketplace.choose_org")} - - ) : null} - - {displayRows.length > 0 && visibleRows.length === 0 ? ( - {t("settings.marketplace.no_match")} - ) : null} - - {visibleRows.length > 0 ? ( -
- {visibleRows.map((row) => { - const pluginName = row.source === "cloud" ? row.plugin.name : row.source === "built-in" ? row.entry.name : row.item.name; - const isHighlighted = highlightPluginName != null && pluginName === highlightPluginName; - return ( - - ); - })} -
- ) : null} - - {detailRow?.source === "cloud" ? ( - setDetailRow(null)} - onConnectOrgMcp={onConnectOrgMcp} - onImportPlugin={importPlugin} - connectEnabled={connectEnabled} - onRemovePlugin={removePlugin} - /> - ) : detailRow?.source === "built-in" ? ( - setDetailRow(null)} - /> - ) : detailRow?.source === "org-mcp" ? ( - setDetailRow(null)} - onConnect={onConnectOrgMcp} - onDisconnect={onDisconnectOrgMcp} - /> - ) : null} - - ); - - return embedded ? content : ( - - - {content} - - ); -} - -function actionLabelForStatus(status: MarketplacePackageStatus) { - switch (status) { - case "installed": - return "View details"; - case "update_available": - return "Update available"; - default: - return "Add"; - } -} - -function MarketplaceCard(props: { - actionId: string | null; - row: MarketplaceRow; - onOpenDetail: (row: MarketplaceRow) => void; - onUpdatePlugin: (marketplaceId: string | null, plugin: DenOrgPlugin) => void | Promise; - connectEnabled?: boolean; - orgMcpConnectingId: string | null; - orgMcpDisconnectingId: string | null; - onDisconnectOrgMcp?: (connectionId: string) => void; - builtInDisabled: boolean; - builtInConnectingName: string | null; - highlighted?: boolean; -}) { - const { actionId, row, onOpenDetail, onUpdatePlugin } = props; - const highlightRef = React.useRef(null); + }, [client, cloud.client, cloud.isSignedIn, workspaceId]); React.useEffect(() => { - if (props.highlighted && highlightRef.current) { - highlightRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); + void refresh(); + }, [refresh]); + + const install = React.useCallback(async (item: DenMarketplacePlugin) => { + if (!client || !workspaceId || installed[item.pluginId]?.version === item.version) return; + setBusyId(item.pluginId); + setError(null); + try { + await cloud.client.acquireMarketplacePlugin(item.pluginId); + const download = await cloud.client.downloadMarketplacePlugin(item.pluginId); + if (download.digest && await sha256Hex(download.bytes) !== download.digest.toLowerCase()) { + throw new Error(t("settings.marketplace.digest_mismatch")); + } + const file = new File([archiveBuffer(download.bytes)], download.fileName, { type: "application/zip" }); + const upload = await readPluginPackageArchive(file); + await client.validatePluginPackageUpload(workspaceId, upload); + await client.importPluginPackage(workspaceId, upload); + await refresh(); + await onInstalled?.(item.pluginId); + } catch (cause) { + setError(formatPluginPlatformError(cause, t("settings.marketplace.install_failed"))); + } finally { + setBusyId(null); } - }, [props.highlighted]); - - const highlightClass = props.highlighted - ? "ring-2 ring-primary ring-offset-2 ring-offset-dls-background rounded-2xl transition-shadow" - : ""; - - if (row.source === "built-in") { - const actionBusy = props.builtInConnectingName === row.entry.name; - const entryUrl = typeof row.entry.url === "string" ? row.entry.url : undefined; + }, [client, cloud.client, installed, onInstalled, refresh, workspaceId]); + + const localizedItems = React.useMemo(() => items.map((item) => ({ + ...item, + manifest: localizePluginPackageManifest(item.manifest, locale), + })), [items, locale]); + const filteredItems = React.useMemo(() => { + const query = search.trim().toLocaleLowerCase(); + if (!query) return localizedItems; + return localizedItems.filter((item) => [item.manifest.name, item.manifest.description, item.publisher, item.category] + .some((value) => value.toLocaleLowerCase().includes(query))); + }, [localizedItems, search]); + const selected = localizedItems.find((item) => item.pluginId === selectedId) ?? null; + const featuredItems = filteredItems.filter((item) => item.featured); + const categorySections = MARKETPLACE_CATEGORY_IDS.map((categoryId) => ({ + categoryId, + items: filteredItems.filter((item) => !item.featured && resolveMarketplaceCategory(item) === categoryId), + })).filter((section) => section.items.length > 0); + + if (!shouldShowMarketplaceRows(cloud.isSignedIn)) { return ( -
- onOpenDetail(row)} - /> +
+
+ +

{t("settings.marketplace.signin_title")}

+

{t("settings.marketplace.signin_hint")}

+ +
); } - if (row.source === "org-mcp") { - const actionBusy = props.orgMcpConnectingId === row.connection.id; - const disconnecting = props.orgMcpDisconnectingId === row.connection.id; - const canDisconnect = canDisconnectNativeProviderAccount(row.connection); - const ready = isOrgMcpConnectionReady(row.connection); + if (selected) { + const manifest = selected.manifest; + const iconUrl = resolveExtensionIconUrl({ iconSrc: manifest.icon?.src, iconSlug: manifest.icon?.simpleIconSlug }); + const localPackage = installed[selected.pluginId]; + const resources = Object.entries(manifest.resources.reduce>((groups, resource) => { + (groups[resource.type] ??= []).push(resource); + return groups; + }, {})); return ( -
- onOpenDetail(row)} - /> - {canDisconnect ? ( - - ) : null} -
+ )} + > +
+ v{selected.version} + {selected.publisher} + {selected.pointsCost === 0 ? t("settings.marketplace.free") : `${selected.pointsCost} iPoints`} +
+
+
+

{t("settings.marketplace.capabilities")}

+
+ {resources.map(([type, entries]) => ( +
+
{type}
+
{entries.map((resource) =>
{resource.label ?? resource.id}
)}
+
+ ))} +
+ {manifest.setup?.instructions ? {manifest.setup.instructions} : null} +
+
+
{t("settings.marketplace.package_info")}
+
{t("settings.marketplace.size")}{formatBytes(selected.size)}
+
{t("settings.marketplace.resources")}{manifest.resources.length}
+
{t("settings.marketplace.permissions")}{manifest.permissions?.length ?? 0}
+
{selected.digest}
+
+
+ {error ?
{error}
: null} + ); } - const actionBusy = actionId === row.plugin.id; - const manifest = row.plugin.extension?.manifest; - const cloudBuiltIn = isCloudBuiltInPlugin(row.plugin); - const updateAvailable = !cloudBuiltIn && row.status === "update_available"; - const deliveryAction = resolveMarketplaceDeliveryAction({ - connectEnabled: props.connectEnabled === true && !isDesktopInstallableMarketplacePlugin(row.plugin), - importedLocally: Boolean(row.imported), - }); - const cloudDelivery = deliveryAction !== "install"; - const needsSetup = Boolean(row.imported && row.item?.setupState === "needs_setup"); - return ( -
- onOpenDetail(row)} - /> - {updateAvailable && !cloudDelivery ? ( - +
+ {!embedded ? ( + <> +
+
+

{t("extensions.marketplace_title")}

+

{t("extensions.marketplace_description")}

+
+ +
+ setLocalSearch(event.currentTarget.value)} placeholder={t("settings.marketplace.search")} /> + ) : null} -
- ); -} -function BuiltInMarketplaceDetailModal(props: { - row: BuiltInMarketplaceRow; - disabled: boolean; - connecting: boolean; - configSlot: React.ReactNode | null; - onSetEnabled?: (entry: McpDirectoryInfo, enabled: boolean) => void; - onClose: () => void; -}) { - const { row, disabled, connecting, configSlot, onClose, onSetEnabled } = props; - const entry = row.entry; - const toggleControlled = isToggleControlledExtension(entry); - return ( - resource.label ?? resource.id) ?? []} - contributionLabels={entry.extensionManifest?.contributions?.map((contribution) => contribution.label ?? contribution.ref ?? contribution.type) ?? []} - configSlot={configSlot} - showEnablementCard={false} - connectLabel="Enable" - connectingLabel="Enabling..." - uninstallLabel="Disable" - onConnect={!disabled && toggleControlled && !row.active && onSetEnabled ? () => onSetEnabled(entry, true) : undefined} - onUninstall={!disabled && toggleControlled && row.active && onSetEnabled ? () => onSetEnabled(entry, false) : undefined} - /> - ); -} + {loading && items.length === 0 ? {t("settings.marketplace.loading")} : null} + {!loading && filteredItems.length === 0 ? {search ? t("settings.marketplace.no_match") : t("settings.marketplace.empty")} : null} + + {featuredItems.length > 0 ? ( + + ) : null} -function OrgMcpConnectionDetailModal(props: { - row: OrgMcpMarketplaceRow; - connecting: boolean; - disconnecting: boolean; - onClose: () => void; - onConnect?: (connectionId: string) => void; - onDisconnect?: (connectionId: string) => void; -}) { - const { row, connecting, onClose, onConnect, onDisconnect } = props; - const ready = isOrgMcpConnectionReady(row.connection); - const canDisconnect = canDisconnectNativeProviderAccount(row.connection); - return ( - onConnect(row.connection.id) : undefined} - onUninstall={canDisconnect && onDisconnect ? () => onDisconnect(row.connection.id) : undefined} - configSlot={( -
-
- {t("mcp.org_connection_managed_label")} - {row.connection.credentialMode === "shared" ? t("settings.marketplace.organization_account") : t("settings.marketplace.your_account")} - MCP -
- - {t("settings.marketplace.organization_connection_notice")} - -
- )} - /> + {categorySections.map((section) => ( + + ))} + + {error ?
{error}
: null} + ); } -function MarketplacePackageDetailModal(props: { - actionId: string | null; - row: MarketplacePackageRow; - resolved: DenOrgPluginResolved | null; - resolving: boolean; - resolveError: string | null; - connectEnabled?: boolean; - orgMcpConnections: DenExternalMcpConnection[]; - orgMcpConnectingId: string | null; - onClose: () => void; - onConnectOrgMcp?: (connectionId: string) => void; - onImportPlugin: (marketplaceId: string | null, plugin: DenOrgPlugin) => void | Promise; - onRemovePlugin: (pluginId: string, pluginName: string) => void | Promise; -}) { - const { - actionId, - row, - resolved, - resolving, - resolveError, - orgMcpConnections, - orgMcpConnectingId, - onClose, - onConnectOrgMcp, - onImportPlugin, - onRemovePlugin, - } = props; - const actionBusy = actionId === row.plugin.id; - const cloudBuiltIn = isCloudBuiltInPlugin(row.plugin); - const manifest = row.plugin.extension?.manifest; - const deliveryAction = resolveMarketplaceDeliveryAction({ - connectEnabled: props.connectEnabled === true && !isDesktopInstallableMarketplacePlugin(row.plugin), - importedLocally: Boolean(row.imported), - }); - const cloudDelivery = deliveryAction !== "install"; - const needsSetup = Boolean(row.imported && row.item?.setupState === "needs_setup"); - const canAddOrUpdate = !cloudDelivery && !cloudBuiltIn && (row.status === "available" || row.status === "update_available"); - const importedExternalConnectionIds = row.imported?.files.flatMap((file) => file.externalMcpConnectionId ? [file.externalMcpConnectionId] : []) ?? []; - const importedConnections = [...new Set(importedExternalConnectionIds)].flatMap((connectionId) => { - const connection = orgMcpConnections.find((entry) => entry.id === connectionId); - return connection ? [connection] : []; - }); - const missingImportedConnectionCount = new Set(importedExternalConnectionIds).size - importedConnections.length; +type MarketplaceSectionProps = { + title: string; + items: DenMarketplacePlugin[]; + installed: Record; + busyId: string | null; + client: iPolloWorkServerClient | null; + workspaceId: string | null; + onOpen: (pluginId: string) => void; + onOpenInstalled?: (pluginId: string) => void; + onInstall: (item: DenMarketplacePlugin) => Promise; +}; +function MarketplaceSection(props: MarketplaceSectionProps) { return ( - resource.label ?? resource.id) ?? []} - contributionLabels={manifest?.contributions?.map((contribution) => contribution.label ?? contribution.ref ?? contribution.type) ?? []} - onConnect={canAddOrUpdate ? () => void onImportPlugin(row.marketplaceId, row.plugin) : undefined} - onUninstall={!cloudBuiltIn && row.imported ? () => void onRemovePlugin(row.plugin.id, row.plugin.name) : undefined} - configSlot={( -
-
- - {needsSetup ? "Needs setup" : cloudDelivery ? t("extensions.marketplace_active_cloud_label") : cloudBuiltIn ? "Built-in" : statusLabel(row.status)} - - {row.marketplaceName} - {row.counts.map((label) => {label})} -
-
-
{t("settings.marketplace.composition")}
-
- {row.composition.map((entry) => ( -
- {entry.label} - {entry.count} -
- ))} -
-
- {resolveError ? ( - {resolveError} - ) : null} - {resolving ? ( - {t("settings.marketplace.loading_contents")} - ) : null} - {missingImportedConnectionCount > 0 ? ( - - You do not have access to {missingImportedConnectionCount === 1 ? "one required MCP connection" : `${missingImportedConnectionCount} required MCP connections`}. Ask an admin to update the connection sharing settings. - - ) : null} - {importedConnections.length > 0 ? ( -
-
{t("settings.marketplace.cloud_mcp_connections")}
-
- {importedConnections.map((connection) => { - const ready = isOrgMcpConnectionReady(connection); - const needsMemberConnect = connection.credentialMode === "per_member" && !connection.connectedForMe; - const connecting = orgMcpConnectingId === connection.id; - return ( -
-
-
{connection.name}
-
{connection.url}
-
-
- {ready ? "Ready" : needsMemberConnect ? "Needs setup" : "Waiting for admin"} - {needsMemberConnect && onConnectOrgMcp ? ( - - ) : null} -
-
- ); - })} -
-
- ) : null} - {resolved ? ( -
-
{t("settings.marketplace.extension_contents")}
- {resolved.memberships.length > 0 ? resolved.memberships.map((membership) => { - const object = membership.configObject; - const version = object?.latestVersion ?? null; - if (!object) return null; - const preview = version?.rawSourceText?.trim().slice(0, 600) ?? ""; - return ( -
- - {object.objectType} {object.title} - -
- {object.description ?
{object.description}
: null} - {object.currentRelativePath ?
{object.currentRelativePath}
: null} - {preview ? ( -
-                          {preview}
-                        
- ) : null} -
-
- ); - }) : ( - {t("settings.marketplace.no_contents")} - )} -
- ) : null} - {row.imported?.files.length ? ( -
- Installed files: {row.imported.files.map((file) => `${file.title} (${file.objectType})`).join(", ")} -
- ) : null} -
- )} - /> +
+

{props.title}

+
+ {props.items.map((item) => { + const localPackage = props.installed[item.pluginId]; + return ( + 0 + ? {item.pointsCost} iPoints + : null} + status={localPackage ? (localPackage.version === item.version ? t("settings.marketplace.installed") : t("extensions.update_available")) : item.publisher} + actionBusy={props.busyId === item.pluginId} + actionDisabled={!props.client || !props.workspaceId || props.busyId !== null || localPackage?.version === item.version} + actionLabel={<>{props.busyId === item.pluginId ? : null}{actionLabel(item, localPackage)}} + onOpen={() => localPackage && props.onOpenInstalled + ? props.onOpenInstalled(item.pluginId) + : props.onOpen(item.pluginId)} + onAction={() => void props.onInstall(item)} + /> + ); + })} +
+
); } diff --git a/apps/app/src/react-app/domains/settings/pages/extensions-view.tsx b/apps/app/src/react-app/domains/settings/pages/extensions-view.tsx index a303687ca..fe0c50ddb 100644 --- a/apps/app/src/react-app/domains/settings/pages/extensions-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/extensions-view.tsx @@ -1,11 +1,9 @@ /** @jsxImportSource react */ import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { Building2, Cpu, Loader2, Package } from "lucide-react"; +import { Building2, Cpu, Loader2, Package, RefreshCw } from "lucide-react"; import { t } from "../../../../i18n"; import { Button } from "@/components/ui/button"; -import { useConnectEnabled } from "@/react-app/domains/cloud/desktop-config-provider"; -import { shouldShowExtensionsMarketplacePane } from "@/react-app/domains/settings/connect-delivery"; import { toast } from "@/components/ui/sonner"; import { readActiveWorkContextId, type WorkContextId } from "@/app/lib/work-context"; import { @@ -42,7 +40,6 @@ type SuggestedPlugin = { export type ExtensionsViewProps = { busy: boolean; selectedWorkspaceRoot: string; - isRemoteWorkspace: boolean; canEditPlugins: boolean; canUseGlobalScope: boolean; accessHint?: string | null; @@ -50,22 +47,12 @@ export type ExtensionsViewProps = { extensions: PluginsExtensionsStore; client: iPolloWorkServerClient | null; workspaceId: string | null; - mcpConnectedAppsCount: number; - /** The MCP view (quick-connect grid + configured servers). Skills are injected into it. */ - mcpView: ReactNode; - /** Independently packaged plugins and their plugin-owned authorization. */ - pluginPackagesView?: ReactNode; - /** Organization marketplace content, rendered in the same Extensions pane. */ - cloudMarketplaceView?: ReactNode; - onRefresh: () => void; - onOpenConnect?: () => void; - initialSection?: ExtensionsSection; - setSectionRoute?: (tab: "mcp" | "skills" | "plugins") => void; - showHeader?: boolean; + pluginPackagesView: ReactNode; + skillsView: ReactNode; + activeTab: "plugins" | "skills"; }; export function ExtensionsView(props: ExtensionsViewProps) { - const [view, setView] = useState<"my" | "marketplace">("my"); const activeEnterprise = useActiveEnterpriseConnection(); const [resourceScope, setResourceScope] = useState(() => readActiveWorkContextId()); const [enterpriseResources, setEnterpriseResources] = useState([]); @@ -74,16 +61,12 @@ export function ExtensionsView(props: ExtensionsViewProps) { const [enterpriseError, setEnterpriseError] = useState(null); const [enterpriseBusyId, setEnterpriseBusyId] = useState(null); const [enterpriseRefreshRevision, setEnterpriseRefreshRevision] = useState(0); - const connectEnabled = useConnectEnabled(); - const showMarketplacePane = shouldShowExtensionsMarketplacePane(connectEnabled); - const activeView = showMarketplacePane ? view : "my"; - const pluginCount = useMemo( - () => props.extensions.pluginList().length, - [props.extensions], - ); + const pluginCount = useMemo(() => props.extensions.pluginList().length, [props.extensions]); + useEffect(() => { setResourceScope(readActiveWorkContextId()); }, [activeEnterprise?.id]); + useEffect(() => { if (!activeEnterprise || resourceScope === "personal") { setEnterpriseResources([]); @@ -113,6 +96,7 @@ export function ExtensionsView(props: ExtensionsViewProps) { }); return () => { current = false; }; }, [activeEnterprise, enterpriseRefreshRevision, props.client, props.workspaceId, resourceScope]); + const installEnterpriseExtension = async (resource: EnterpriseResource) => { if (!activeEnterprise || !props.client || !props.workspaceId || !resource.latestVersion) return; setEnterpriseBusyId(resource.id); @@ -135,30 +119,31 @@ export function ExtensionsView(props: ExtensionsViewProps) { }; return ( -
-
-
- - {props.mcpConnectedAppsCount > 0 ? ( -
-
- - {t("extensions.app_count", { count: props.mcpConnectedAppsCount })} - -
- ) : null} +
+ {activeEnterprise ? ( +
+
+ + {resourceScope !== "personal" ? ( + + ) : null} +
- -
+ ) : null} {resourceScope !== "personal" ? ( enterpriseLoading ? ( -
{Array.from({ length: 4 }, (_, index) =>
)}
+
+ {Array.from({ length: 4 }, (_, index) =>
)} +
) : enterpriseError ? (
{enterpriseError}
) : enterpriseResources.length ? ( @@ -171,62 +156,33 @@ export function ExtensionsView(props: ExtensionsViewProps) { : installedVersion ? t("template_market.update") : t("enterprise_connection.install_from_enterprise"); return (
-

{resource.name}

{resource.description}

-
{resource.enterpriseCategory}{resource.latestVersion ? ` · v${resource.latestVersion.version}` : ""}
+
+
+
+

{resource.name}

+

{resource.description}

+
+
+
+ {resource.enterpriseCategory}{resource.latestVersion ? ` · v${resource.latestVersion.version}` : ""} + +
); })}
) : ( -
{t("enterprise_connection.enterprise_extensions_empty")}
+
+ {t("enterprise_connection.enterprise_extensions_empty")} +
) - ) : <> - - {connectEnabled === true ? ( -
- {t("extensions.connect_marketplace_split_hint")} - -
- ) : null} - - {showMarketplacePane ? ( -
- - -
- ) : ( -
- {t("extensions.connect_marketplace_hint")} - -
- )} - - {activeView === "my" ? ( - <> + ) : props.activeTab === "skills" ? props.skillsView : ( +
{props.pluginPackagesView} - - {/* Runtime extensions: MCPs + skills + marketplace imports in one view */} - {props.mcpView} - - {/* OpenCode plugins -- advanced, collapsed */} {pluginCount > 0 ? ( -
+
{t("settings.extensions.opencode_plugins")} @@ -245,13 +201,8 @@ export function ExtensionsView(props: ExtensionsViewProps) {
) : null} - - ) : props.cloudMarketplaceView ?? ( -
- {t("extensions.marketplace_unavailable")}
)} - }
); } diff --git a/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx b/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx index f203fab84..5831090d1 100644 --- a/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx @@ -26,7 +26,6 @@ import { import { isBuiltIniPolloWorkExtension, getMcpServerName, type McpDirectoryInfo } from "../../../../app/constants"; import { evaluateEnablement } from "../../../../app/enablement"; import type { EnablementResult } from "../../../../app/extensions"; -import type { CloudImportedPlugin } from "../../../../app/cloud/import-state"; import { ExtensionCard } from "../../../design-system/extension-card"; import { ExtensionDetailModal } from "../../../design-system/extension-detail-modal"; import { @@ -91,12 +90,8 @@ export type McpViewProps = { isRemoteWorkspace: boolean; /** Installed skills to render alongside MCPs in the grid. */ installedSkills?: SkillItem[]; - /** Installed marketplace packages to render alongside runtime extensions. */ - installedPlugins?: CloudImportedPlugin[]; /** Uninstall a skill by name. */ uninstallSkill?: (name: string) => void; - /** Remove an imported marketplace package by plugin id. */ - removeCloudPlugin?: (pluginId: string) => void | Promise; /** Read skill content by name. */ readSkill?: (name: string) => Promise<{ content: string } | null>; readConfigFile?: (scope: "project" | "global") => Promise; @@ -237,7 +232,7 @@ function isToggleOnlyExtension(entry: McpDirectoryInfo) { ) === true; } -type ExtensionFilter = "all" | "mcp" | "skill" | "plugin"; +type ExtensionFilter = "all" | "mcp" | "skill"; export function McpView(props: McpViewProps) { const showHeader = props.showHeader !== false; @@ -245,7 +240,6 @@ export function McpView(props: McpViewProps) { const [detailEntry, setDetailEntry] = useState(null); const [detailSkill, setDetailSkill] = useState(null); const [detailSkillContent, setDetailSkillContent] = useState(null); - const [detailPlugin, setDetailPlugin] = useState(null); const [detailOrgMcpItem, setDetailOrgMcpItem] = useState(null); const [ipolloworkUiMcpCommand, setiPolloWorkUiMcpCommand] = useState(null); const [ipolloworkUiMcpEnvironment, setiPolloWorkUiMcpEnvironment] = useState | null>(null); @@ -447,8 +441,7 @@ export function McpView(props: McpViewProps) { (entry) => resolveStatus(entry) === "connected", ).length; const hiddenCount = quickConnectList.filter((entry) => isiPolloWorkExtensionHidden(entry)).length + - (props.installedSkills ?? []).filter((skill) => isiPolloWorkExtensionHidden(getSkillHiddenId(skill))).length + - (props.installedPlugins ?? []).filter((plugin) => isiPolloWorkExtensionHidden(`plugin:${plugin.pluginId}`)).length; + (props.installedSkills ?? []).filter((skill) => isiPolloWorkExtensionHidden(getSkillHiddenId(skill))).length; const policyHiddenBuiltInCount = props.builtInExtensionsDisabled ? quickConnectList.filter((entry) => isBuiltIniPolloWorkExtension(entry) && !isiPolloWorkExtensionHidden(entry)).length : 0; @@ -592,18 +585,6 @@ export function McpView(props: McpViewProps) { }) } locale={locale} - installedPlugins={ - (props.installedPlugins ?? []).filter((plugin) => { - if (!showHidden && isiPolloWorkExtensionHidden(`plugin:${plugin.pluginId}`)) return false; - if (filter === "mcp" || filter === "skill") return false; - if (!search.trim()) return true; - const q = search.toLowerCase(); - return [plugin.name, plugin.description ?? "", ...plugin.files.map((file) => `${file.title} ${file.objectType} ${file.path}`)] - .join(" ") - .toLowerCase() - .includes(q); - }) - } installedOrgMcpItems={ (props.installedOrgMcpItems ?? []).filter((item) => { if (!isOrgMcpConnectionItem(item)) return false; @@ -617,7 +598,6 @@ export function McpView(props: McpViewProps) { connectingName={props.mcpConnectingName} isEntryHidden={(entry) => isiPolloWorkExtensionHidden(entry)} isSkillHidden={(skill) => isiPolloWorkExtensionHidden(getSkillHiddenId(skill))} - isPluginHidden={(plugin) => isiPolloWorkExtensionHidden(`plugin:${plugin.pluginId}`)} disabledReasonForEntry={(entry) => props.builtInExtensionsDisabled && isBuiltIniPolloWorkExtension(entry) ? t("settings.extensions.disabled_by_organization") @@ -647,7 +627,6 @@ export function McpView(props: McpViewProps) { }); } }} - onPluginDetail={setDetailPlugin} onOrgMcpDetail={setDetailOrgMcpItem} orgMcpDisconnectingId={props.orgMcpDisconnectingId ?? null} disconnectOrgMcp={props.disconnectOrgMcp} @@ -826,27 +805,6 @@ export function McpView(props: McpViewProps) { ); })() : null} - {detailPlugin ? (() => { - const hidden = isiPolloWorkExtensionHidden(`plugin:${detailPlugin.pluginId}`); - return ( - setDetailPlugin(null)} - name={detailPlugin.name} - description={detailPlugin.description ?? "Marketplace extension installed in this workspace."} - kind="extension" - connected={true} - hidden={hidden} - onUninstall={props.removeCloudPlugin ? () => { - void props.removeCloudPlugin?.(detailPlugin.pluginId); - setDetailPlugin(null); - } : undefined} - onHide={() => setiPolloWorkExtensionHidden(`plugin:${detailPlugin.pluginId}`, true)} - onShow={() => setiPolloWorkExtensionHidden(`plugin:${detailPlugin.pluginId}`, false)} - /> - ); - })() : null} - {detailOrgMcpItem && isOrgMcpConnectionItem(detailOrgMcpItem) ? (() => { const connection = detailOrgMcpItem.orgMcpConnection; const canDisconnect = canDisconnectNativeProviderAccount(connection); @@ -923,14 +881,12 @@ function McpCustomAppCard(props: { onOpen: () => void; onOpenGithubImport?: () = function McpQuickConnectSection(props: { entries: McpDirectoryInfo[]; installedSkills?: SkillItem[]; - installedPlugins?: CloudImportedPlugin[]; installedOrgMcpItems?: ExtensionItem[]; locale: Language; busy: boolean; connectingName: string | null; isEntryHidden: (entry: McpDirectoryInfo) => boolean; isSkillHidden: (skill: SkillItem) => boolean; - isPluginHidden: (plugin: CloudImportedPlugin) => boolean; disabledReasonForEntry: (entry: McpDirectoryInfo) => string | null; isConfigured: (entry: McpDirectoryInfo) => boolean; enablementForEntry?: (entry: McpDirectoryInfo) => { active: boolean; results: EnablementResult[] } | null; @@ -938,7 +894,6 @@ function McpQuickConnectSection(props: { onConnect: (entry: McpDirectoryInfo) => void; onDetail: (entry: McpDirectoryInfo) => void; onSkillDetail?: (skill: SkillItem) => void; - onPluginDetail?: (plugin: CloudImportedPlugin) => void; onOrgMcpDetail?: (item: ExtensionItem) => void; orgMcpDisconnectingId: string | null; disconnectOrgMcp?: (connectionId: string) => void; @@ -1001,23 +956,6 @@ function McpQuickConnectSection(props: { ); })} - {(props.installedPlugins ?? []).map((plugin) => { - const hidden = props.isPluginHidden(plugin); - const fileCount = plugin.files.length; - return ( -