diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 280ab61a061..932442934cd 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1505,6 +1505,7 @@ export class ClineProvider cloudIsAuthenticated, sharingEnabled, organizationAllowList, + organizationSettingsVersion, maxConcurrentFileReads, condensingApiConfigId, customCondensingPrompt, @@ -1617,6 +1618,7 @@ export class ClineProvider cloudIsAuthenticated: cloudIsAuthenticated ?? false, sharingEnabled: sharingEnabled ?? false, organizationAllowList, + organizationSettingsVersion, condensingApiConfigId, customCondensingPrompt, codebaseIndexModels: codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, @@ -1703,6 +1705,19 @@ export class ClineProvider ) } + let organizationSettingsVersion: number = -1 + + try { + if (CloudService.hasInstance()) { + const settings = CloudService.instance.getOrganizationSettings() + organizationSettingsVersion = settings?.version ?? -1 + } + } catch (error) { + console.error( + `[getState] failed to get organization settings version: ${error instanceof Error ? error.message : String(error)}`, + ) + } + // Return the same structure as before return { apiConfiguration: providerSettings, @@ -1786,6 +1801,7 @@ export class ClineProvider cloudIsAuthenticated, sharingEnabled, organizationAllowList, + organizationSettingsVersion, // Explicitly add condensing settings condensingApiConfigId: stateValues.condensingApiConfigId, customCondensingPrompt: stateValues.customCondensingPrompt, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 67f8782e193..1e562bb9ee3 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -313,6 +313,7 @@ export type ExtensionState = Pick< cloudApiUrl?: string sharingEnabled: boolean organizationAllowList: OrganizationAllowList + organizationSettingsVersion?: number autoCondenseContext: boolean autoCondenseContextPercent: number diff --git a/webview-ui/src/components/marketplace/MarketplaceView.tsx b/webview-ui/src/components/marketplace/MarketplaceView.tsx index b47e1aa875f..abfcf87cc5c 100644 --- a/webview-ui/src/components/marketplace/MarketplaceView.tsx +++ b/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react" +import { useState, useEffect, useMemo, useContext } from "react" import { Button } from "@/components/ui/button" import { Tab, TabContent, TabHeader } from "../common/Tab" import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager" @@ -8,6 +8,7 @@ import { vscode } from "@/utils/vscode" import { MarketplaceListView } from "./MarketplaceListView" import { cn } from "@/lib/utils" import { TooltipProvider } from "@/components/ui/tooltip" +import { ExtensionStateContext } from "@/context/ExtensionStateContext" interface MarketplaceViewProps { onDone?: () => void @@ -18,6 +19,20 @@ export function MarketplaceView({ stateManager, onDone, targetTab }: Marketplace const { t } = useAppTranslation() const [state, manager] = useStateManager(stateManager) const [hasReceivedInitialState, setHasReceivedInitialState] = useState(false) + const extensionState = useContext(ExtensionStateContext) + const [lastOrganizationSettingsVersion, setLastOrganizationSettingsVersion] = useState( + extensionState?.organizationSettingsVersion ?? -1, + ) + + useEffect(() => { + const currentVersion = extensionState?.organizationSettingsVersion ?? -1 + if (currentVersion !== lastOrganizationSettingsVersion) { + vscode.postMessage({ + type: "fetchMarketplaceData", + }) + } + setLastOrganizationSettingsVersion(currentVersion) + }, [extensionState?.organizationSettingsVersion, lastOrganizationSettingsVersion]) // Track when we receive the initial state useEffect(() => { diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceView.spec.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceView.spec.tsx index 95b2dea54be..57f7e9eb9d3 100644 --- a/webview-ui/src/components/marketplace/__tests__/MarketplaceView.spec.tsx +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceView.spec.tsx @@ -1,14 +1,13 @@ -import { render, screen } from "@/utils/test-utils" -import userEvent from "@testing-library/user-event" - +import { render, waitFor } from "@testing-library/react" +import { vi, describe, it, expect, beforeEach } from "vitest" import { MarketplaceView } from "../MarketplaceView" import { MarketplaceViewStateManager } from "../MarketplaceViewStateManager" +import { ExtensionStateContext } from "@/context/ExtensionStateContext" +import { vscode } from "@/utils/vscode" vi.mock("@/utils/vscode", () => ({ vscode: { postMessage: vi.fn(), - getState: vi.fn(() => ({})), - setState: vi.fn(), }, })) @@ -18,70 +17,146 @@ vi.mock("@/i18n/TranslationContext", () => ({ }), })) -vi.mock("../useStateManager", () => ({ - useStateManager: () => [ - { - allItems: [], - displayItems: [], - isFetching: false, - activeTab: "mcp", - filters: { type: "", search: "", tags: [] }, - }, - { - transition: vi.fn(), - onStateChange: vi.fn(() => vi.fn()), - }, - ], -})) - -vi.mock("../MarketplaceListView", () => ({ - MarketplaceListView: ({ filterByType }: { filterByType: string }) => ( -
MarketplaceListView - {filterByType}
- ), -})) - -// Mock Tab components to avoid ExtensionStateContext dependency -vi.mock("@/components/common/Tab", () => ({ - Tab: ({ children, ...props }: any) =>
{children}
, - TabHeader: ({ children, ...props }: any) =>
{children}
, - TabContent: ({ children, ...props }: any) =>
{children}
, - TabList: ({ children, ...props }: any) =>
{children}
, - TabTrigger: ({ children, ...props }: any) => , -})) - describe("MarketplaceView", () => { - const mockOnDone = vi.fn() - const mockStateManager = new MarketplaceViewStateManager() + let stateManager: MarketplaceViewStateManager + let mockExtensionState: any beforeEach(() => { vi.clearAllMocks() + stateManager = new MarketplaceViewStateManager() + + // Initialize state manager with some test data + stateManager.transition({ + type: "FETCH_COMPLETE", + payload: { + items: [ + { + id: "test-mcp", + name: "Test MCP", + type: "mcp" as const, + description: "Test MCP server", + tags: ["test"], + content: "Test content", + url: "https://test.com", + author: "Test Author", + }, + ], + }, + }) + + mockExtensionState = { + organizationSettingsVersion: 1, + // Add other required properties for the context + didHydrateState: true, + showWelcome: false, + theme: {}, + mcpServers: [], + filePaths: [], + openedTabs: [], + commands: [], + organizationAllowList: { allowAll: true, providers: {} }, + cloudIsAuthenticated: false, + sharingEnabled: false, + hasOpenedModeSelector: false, + setHasOpenedModeSelector: vi.fn(), + alwaysAllowFollowupQuestions: false, + setAlwaysAllowFollowupQuestions: vi.fn(), + followupAutoApproveTimeoutMs: 60000, + setFollowupAutoApproveTimeoutMs: vi.fn(), + profileThresholds: {}, + setProfileThresholds: vi.fn(), + // ... other required context properties + } }) - it("renders without crashing", () => { - render() - - expect(screen.getByText("marketplace:title")).toBeInTheDocument() - expect(screen.getByText("marketplace:done")).toBeInTheDocument() + it("should trigger fetchMarketplaceData when organization settings version changes", async () => { + const { rerender } = render( + + + , + ) + + // Initial render should not trigger fetch (version hasn't changed) + expect(vscode.postMessage).not.toHaveBeenCalledWith({ + type: "fetchMarketplaceData", + }) + + // Update the organization settings version + mockExtensionState = { + ...mockExtensionState, + organizationSettingsVersion: 2, + } + + // Re-render with updated context + rerender( + + + , + ) + + // Wait for the effect to run + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "fetchMarketplaceData", + }) + }) }) - it("calls onDone when Done button is clicked", async () => { - const user = userEvent.setup() - render() + it("should trigger fetchMarketplaceData when organization settings version changes from -1", async () => { + // Start with -1 version (default) + mockExtensionState = { + ...mockExtensionState, + organizationSettingsVersion: -1, + } - await user.click(screen.getByText("marketplace:done")) - expect(mockOnDone).toHaveBeenCalledTimes(1) - }) + const { rerender } = render( + + + , + ) - it("renders tab buttons", () => { - render() + // Clear any initial calls + vi.clearAllMocks() - expect(screen.getByText("MCP")).toBeInTheDocument() - expect(screen.getByText("Modes")).toBeInTheDocument() + // Update to a defined version + mockExtensionState = { + ...mockExtensionState, + organizationSettingsVersion: 1, + } + + rerender( + + + , + ) + + // Should trigger fetch when transitioning from -1 to 1 + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "fetchMarketplaceData", + }) + }) }) - it("renders MarketplaceListView", () => { - render() - - expect(screen.getByTestId("marketplace-list-view")).toBeInTheDocument() + it("should not trigger fetchMarketplaceData when organization settings version remains the same", async () => { + const { rerender } = render( + + + , + ) + + // Re-render with same version + rerender( + + + , + ) + + // Should not trigger fetch when version hasn't changed + await waitFor(() => { + expect(vscode.postMessage).not.toHaveBeenCalledWith({ + type: "fetchMarketplaceData", + }) + }) }) }) diff --git a/webview-ui/src/components/marketplace/useStateManager.ts b/webview-ui/src/components/marketplace/useStateManager.ts index 697c015cbdc..a1e5a9533cb 100644 --- a/webview-ui/src/components/marketplace/useStateManager.ts +++ b/webview-ui/src/components/marketplace/useStateManager.ts @@ -13,7 +13,10 @@ export function useStateManager(existingManager?: MarketplaceViewStateManager) { prevState.isFetching !== newState.isFetching || prevState.activeTab !== newState.activeTab || JSON.stringify(prevState.allItems) !== JSON.stringify(newState.allItems) || + JSON.stringify(prevState.organizationMcps) !== JSON.stringify(newState.organizationMcps) || JSON.stringify(prevState.displayItems) !== JSON.stringify(newState.displayItems) || + JSON.stringify(prevState.displayOrganizationMcps) !== + JSON.stringify(newState.displayOrganizationMcps) || JSON.stringify(prevState.filters) !== JSON.stringify(newState.filters) return hasChanged ? newState : prevState diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index b156d0193b4..41a7a93670e 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -35,6 +35,7 @@ export interface ExtensionStateContextType extends ExtensionState { openedTabs: Array<{ label: string; isActive: boolean; path?: string }> commands: Command[] organizationAllowList: OrganizationAllowList + organizationSettingsVersion: number cloudIsAuthenticated: boolean sharingEnabled: boolean maxConcurrentFileReads?: number @@ -226,6 +227,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode cloudIsAuthenticated: false, sharingEnabled: false, organizationAllowList: ORGANIZATION_ALLOW_ALL, + organizationSettingsVersion: -1, autoCondenseContext: true, autoCondenseContextPercent: 100, profileThresholds: {}, @@ -392,6 +394,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode screenshotQuality: state.screenshotQuality, routerModels: extensionRouterModels, cloudIsAuthenticated: state.cloudIsAuthenticated ?? false, + organizationSettingsVersion: state.organizationSettingsVersion ?? -1, marketplaceItems, marketplaceInstalledMetadata, profileThresholds: state.profileThresholds ?? {},