diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 03c76f29c6..f29b27e94b 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -629,6 +629,7 @@ export default { "extensions.update_available": "Update available", "extensions.update_button": "Update", "extensions.updating": "Updating...", + "extensions.view_in_extensions": "View in Extensions", "mcp.disable_app": "Disable", "mcp.enable_app": "Enable", "mcp.reloading_status": "Reloading MCP servers…", diff --git a/apps/app/src/react-app/domains/settings/pages/connect-view.tsx b/apps/app/src/react-app/domains/settings/pages/connect-view.tsx index c7fc0df604..5ee1d642d3 100644 --- a/apps/app/src/react-app/domains/settings/pages/connect-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/connect-view.tsx @@ -596,7 +596,7 @@ function ConnectSignInPanel(props: ConnectViewProps) { ); } -function isCloudMarketplaceItem(item: ExtensionItem): item is CloudMarketplaceItem { +export function isCloudMarketplaceItem(item: ExtensionItem): item is CloudMarketplaceItem { return Boolean(item.plugin); } 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 495ad96aa5..e05085d8cd 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 @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { PluginsView, type PluginsExtensionsStore } from "./plugins-view"; export type ExtensionsSection = "all" | "mcp" | "skills" | "plugins"; +export type ExtensionsInventoryFilter = "all" | "mcp" | "skill"; type SuggestedPlugin = { name: string; @@ -37,10 +38,13 @@ export type ExtensionsViewProps = { extensions: PluginsExtensionsStore; mcpConnectedAppsCount: number; /** The MCP view (quick-connect grid + configured servers). Skills are injected into it. */ - mcpView: ReactNode; + mcpView: (routing: { + initialFilter: ExtensionsInventoryFilter; + onFilterChange: (filter: ExtensionsInventoryFilter) => void; + }) => ReactNode; onRefresh: () => void; initialSection?: ExtensionsSection; - setSectionRoute?: (tab: "mcp" | "skills" | "plugins") => void; + setSectionRoute?: (tab: ExtensionsSection) => void; showHeader?: boolean; }; @@ -49,6 +53,14 @@ export function ExtensionsView(props: ExtensionsViewProps) { () => props.extensions.pluginList().length, [props.extensions], ); + const initialFilter = props.initialSection === "mcp" + ? "mcp" + : props.initialSection === "skills" + ? "skill" + : "all"; + const setFilterRoute = (filter: ExtensionsInventoryFilter) => { + props.setSectionRoute?.(filter === "skill" ? "skills" : filter); + }; return (
@@ -72,11 +84,11 @@ export function ExtensionsView(props: ExtensionsViewProps) { {/* Runtime extensions and organization-assigned capabilities share one inventory. */} - {props.mcpView} + {props.mcpView({ initialFilter, onFilterChange: setFilterRoute })} {/* OpenCode plugins -- advanced, collapsed */} {pluginCount > 0 ? ( -
+
OpenCode Plugins 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 3c7deb0a35..12b35d1a45 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 @@ -88,6 +88,8 @@ export type SkillItem = { const getSkillHiddenId = (skill: SkillItem) => `skill:${skill.name}`; +export type ExtensionInventoryFilter = "all" | "mcp" | "skill"; + export type McpViewProps = { busy: boolean; selectedWorkspaceRoot: string; @@ -136,6 +138,8 @@ export type McpViewProps = { installedOrgMcpItems?: ExtensionItem[]; orgMcpDisconnectingId?: string | null; disconnectOrgMcp?: (connectionId: string) => void; + initialFilter?: ExtensionInventoryFilter; + onFilterChange?: (filter: ExtensionInventoryFilter) => void; }; const builtInExtensionDisabledReason = "Disabled by organization"; @@ -245,7 +249,9 @@ function isToggleOnlyExtension(entry: McpDirectoryInfo) { ) === true; } -type ExtensionFilter = "all" | "mcp" | "skill" | "plugin"; +type ExtensionFilter = ExtensionInventoryFilter | "plugin"; + +const extensionInventoryFilters: ExtensionInventoryFilter[] = ["all", "mcp", "skill"]; export function McpView(props: McpViewProps) { const showHeader = props.showHeader !== false; @@ -260,7 +266,7 @@ export function McpView(props: McpViewProps) { const [openworkUiMcpEnvironment, setOpenworkUiMcpEnvironment] = useState | null>(null); const [computerUseMcpCommand, setComputerUseMcpCommand] = useState(null); const [search, setSearch] = useState(""); - const [filter, setFilter] = useState("all"); + const [filter, setFilter] = useState(props.initialFilter ?? "all"); const [showHidden, setShowHidden] = useState(false); const [claudeImportOpen, setClaudeImportOpen] = useState(false); const [, setExtensionStateVersion] = useState(0); @@ -302,6 +308,14 @@ export function McpView(props: McpViewProps) { const configRequestId = useRef(0); const quickConnectList = props.quickConnect; + const setInventoryFilter = (nextFilter: ExtensionInventoryFilter) => { + setFilter(nextFilter); + props.onFilterChange?.(nextFilter); + }; + + useEffect(() => { + setFilter(props.initialFilter ?? "all"); + }, [props.initialFilter]); useEffect(() => { const refresh = () => setExtensionStateVersion((value) => value + 1); @@ -556,12 +570,12 @@ export function McpView(props: McpViewProps) { />
- {(["all", "mcp", "skill"] as const).map((f) => ( + {extensionInventoryFilters.map((f) => ( diff --git a/apps/app/src/react-app/domains/settings/state/extensions-store.ts b/apps/app/src/react-app/domains/settings/state/extensions-store.ts index eaccc3b68a..923fba666f 100644 --- a/apps/app/src/react-app/domains/settings/state/extensions-store.ts +++ b/apps/app/src/react-app/domains/settings/state/extensions-store.ts @@ -1197,7 +1197,7 @@ export function createExtensionsStore(options: { body: `${plugin.name ?? plugin.id} was added to ${marketplaceName}`, dedupeKey: `new-marketplace-plugin:${plugin.id}`, action: { type: "open-extensions-marketplace", pluginName: plugin.name ?? plugin.id }, - actionLabel: "View in Marketplace", + actionLabel: t("extensions.view_in_extensions"), }); } } diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 3e1700c393..95718ee6ed 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -93,7 +93,7 @@ import { MemoryView } from "@/react-app/domains/settings/pages/memory-view"; import { useFeatureFlagsPreferences } from "@/react-app/domains/settings/state/feature-flags-preferences"; import { DebugView } from "@/react-app/domains/settings/pages/debug-view"; import { EnvironmentView } from "@/react-app/domains/settings/pages/environment-view"; -import { ExtensionsView } from "@/react-app/domains/settings/pages/extensions-view"; +import { ExtensionsView, type ExtensionsSection } from "@/react-app/domains/settings/pages/extensions-view"; import { McpView } from "@/react-app/domains/settings/pages/mcp-view"; import { RecoveryView } from "@/react-app/domains/settings/pages/recovery-view"; import { UpdatesView } from "@/react-app/domains/settings/pages/updates-view"; @@ -264,7 +264,7 @@ const SETTINGS_UPDATE_AUTO_DOWNLOAD_KEY = "openwork.react.settings.update-auto-d export function parseSettingsPath(pathname: string): { tab: SettingsTab; redirectPath: string | null; - extensionsSection?: "all" | "mcp" | "plugins"; + extensionsSection?: ExtensionsSection; } { const trimmed = pathname .replace(/^\/workspace\/[^/]+\/settings\/?/, "") @@ -294,7 +294,7 @@ export function parseSettingsPath(pathname: string): { case "memory": return { tab: head, redirectPath: null }; case "skills": - return { tab: "extensions", redirectPath: "extensions/skills", extensionsSection: "all" }; + return { tab: "extensions", redirectPath: "extensions/skills", extensionsSection: "skills" }; case "cloud-marketplaces": return { tab: "extensions", redirectPath: "extensions", extensionsSection: "all" }; case "den": @@ -302,7 +302,7 @@ export function parseSettingsPath(pathname: string): { return { tab: "cloud-account", redirectPath: "cloud-account" }; case "extensions": if (tail === "mcp") return { tab: "extensions", redirectPath: null, extensionsSection: "mcp" }; - if (tail === "skills") return { tab: "extensions", redirectPath: null, extensionsSection: "all" }; + if (tail === "skills") return { tab: "extensions", redirectPath: null, extensionsSection: "skills" }; if (tail === "plugins") return { tab: "extensions", redirectPath: null, extensionsSection: "plugins" }; return { tab: "extensions", redirectPath: null, extensionsSection: "all" }; default: @@ -2261,7 +2261,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { mcpConnectedAppsCount={mcpConnectedAppsCount} initialSection={route.extensionsSection} setSectionRoute={(section) => { - const path = `extensions/${section}`; + const path = section === "all" ? "extensions" : `extensions/${section}`; navigateSettingsPath(path); }} onRefresh={() => { @@ -2276,7 +2276,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { void orgMcpConnections.refresh(); void refreshConnectCapabilities(); }} - mcpView={ + mcpView={({ initialFilter, onFilterChange }) => ( extensionsStore.readSkill(name)} previewClaudePlugin={(url) => extensionsStore.previewClaudePlugin(url)} installClaudePlugin={(url) => extensionsStore.installClaudePlugin(url)} + initialFilter={initialFilter} + onFilterChange={onFilterChange} showHeader={false} /> - } + )} /> ); @@ -2355,6 +2357,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { currentModel={currentCloudMcpModel} onCloudMcpHealthChange={setCloudMcpHealth} orgMcpConnections={orgMcpConnections} + marketplaceItems={extensionItems.cloudPluginItems} /> ); case "memory": diff --git a/apps/app/tests/connect-view.test.ts b/apps/app/tests/connect-view.test.ts index da61a315c7..2ce0dc5097 100644 --- a/apps/app/tests/connect-view.test.ts +++ b/apps/app/tests/connect-view.test.ts @@ -3,7 +3,10 @@ import { fileURLToPath } from "node:url"; import { describe, expect, test } from "bun:test"; import type { OpenworkCloudMcpHealth } from "../src/app/lib/openwork-server"; +import type { ExtensionItem } from "../src/react-app/domains/settings/extension-items"; import { + buildConnectRows, + isCloudMarketplaceItem, readyCloudMcpToolIds, resolveConnectViewState, } from "../src/react-app/domains/settings/pages/connect-view"; @@ -18,6 +21,10 @@ const connectViewSource = readFileSync( fileURLToPath(new URL("../src/react-app/domains/settings/pages/connect-view.tsx", import.meta.url)), "utf8", ); +const settingsRouteSource = readFileSync( + fileURLToPath(new URL("../src/react-app/shell/settings-route.tsx", import.meta.url)), + "utf8", +); describe("resolveConnectViewState", () => { test("shows loading while auth is being checked", () => { @@ -92,6 +99,44 @@ describe("Agent access card helpers", () => { }); describe("Connect cloud-readiness row resolution", () => { + test("routes cloud marketplace items into Connect plugin readiness rows", () => { + const marketplacePluginItem: ExtensionItem = { + id: "marketplace:market_1:plugin_1", + source: "marketplace", + name: "Calendar Helper", + description: "Calendar scheduling skill", + installState: "available", + setupState: "needs_setup", + active: false, + enablement: null, + resources: [], + marketplaceId: "market_1", + marketplaceName: "Operations", + plugin: { + id: "plugin_1", + name: "Calendar Helper", + description: "Calendar scheduling skill", + status: "published", + memberCount: 1, + updatedAt: null, + componentCounts: { skill: 1, mcp: 1 }, + cloudReadiness: { + state: "needs_signin", + hasInstructional: true, + connections: [{ id: "connection_1", name: "Calendar", url: "https://calendar.example.test/mcp" }], + }, + }, + }; + + expect(settingsRouteSource).toContain("marketplaceItems={extensionItems.cloudPluginItems}"); + expect(isCloudMarketplaceItem(marketplacePluginItem)).toBe(true); + const rows = buildConnectRows({ connections: [], items: [marketplacePluginItem], role: "member" }); + expect(rows).toHaveLength(1); + expect(rows[0]?.kind).toBe("plugin"); + expect(rows[0]?.group).toBe("needs_signin"); + expect(rows[0]?.name).toBe("Calendar Helper"); + }); + test("maps plugin readiness states to Connect groups", () => { expect(resolveConnectRowGroup({ state: "needs_signin", hasInstructional: false, connections: [] }, "member")).toBe("needs_signin"); expect(resolveConnectRowGroup({ state: "ready", hasInstructional: true, connections: [] }, "member")).toBe("ready"); diff --git a/apps/app/tests/settings-route.test.ts b/apps/app/tests/settings-route.test.ts index 3ade2ef9a5..3c1e8486e1 100644 --- a/apps/app/tests/settings-route.test.ts +++ b/apps/app/tests/settings-route.test.ts @@ -10,4 +10,10 @@ describe("settings route parsing", () => { redirectPath: null, }); }); + + test("preserves extension section deep links", () => { + expect(parseSettingsPath("/settings/extensions/mcp")).toEqual({ tab: "extensions", redirectPath: null, extensionsSection: "mcp" }); + expect(parseSettingsPath("/settings/extensions/skills")).toEqual({ tab: "extensions", redirectPath: null, extensionsSection: "skills" }); + expect(parseSettingsPath("/settings/extensions/plugins")).toEqual({ tab: "extensions", redirectPath: null, extensionsSection: "plugins" }); + }); });