feat: auto-refresh marketplace data when organization settings change (#6446)

- Added organizationSettingsVersion to ExtensionState type
- Modified ClineProvider to include version in state sent to webview
- Updated CloudService callback to trigger marketplace refresh on settings change
- Added logic to MarketplaceView to detect version changes and request data refresh
- Added comprehensive tests for the new functionality

This ensures marketplace items (MCPs and modes) stay in sync when organization settings are updated in the cloud.

---------

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: John Richmond <5629+jr@users.noreply.github.com>
This commit is contained in:
roomote[bot] 2025-07-30 13:52:28 -07:00 committed by GitHub
parent 75f93c41cf
commit 2217a3d548
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 170 additions and 57 deletions

View file

@ -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,

View file

@ -313,6 +313,7 @@ export type ExtensionState = Pick<
cloudApiUrl?: string
sharingEnabled: boolean
organizationAllowList: OrganizationAllowList
organizationSettingsVersion?: number
autoCondenseContext: boolean
autoCondenseContextPercent: number

View file

@ -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<number>(
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(() => {

View file

@ -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 }) => (
<div data-testid="marketplace-list-view">MarketplaceListView - {filterByType}</div>
),
}))
// Mock Tab components to avoid ExtensionStateContext dependency
vi.mock("@/components/common/Tab", () => ({
Tab: ({ children, ...props }: any) => <div {...props}>{children}</div>,
TabHeader: ({ children, ...props }: any) => <div {...props}>{children}</div>,
TabContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
TabList: ({ children, ...props }: any) => <div {...props}>{children}</div>,
TabTrigger: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}))
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(<MarketplaceView stateManager={mockStateManager} onDone={mockOnDone} />)
it("should trigger fetchMarketplaceData when organization settings version changes", async () => {
const { rerender } = render(
<ExtensionStateContext.Provider value={mockExtensionState}>
<MarketplaceView stateManager={stateManager} />
</ExtensionStateContext.Provider>,
)
expect(screen.getByText("marketplace:title")).toBeInTheDocument()
expect(screen.getByText("marketplace:done")).toBeInTheDocument()
// 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(
<ExtensionStateContext.Provider value={mockExtensionState}>
<MarketplaceView stateManager={stateManager} />
</ExtensionStateContext.Provider>,
)
// 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(<MarketplaceView stateManager={mockStateManager} onDone={mockOnDone} />)
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(
<ExtensionStateContext.Provider value={mockExtensionState}>
<MarketplaceView stateManager={stateManager} />
</ExtensionStateContext.Provider>,
)
// Clear any initial calls
vi.clearAllMocks()
// Update to a defined version
mockExtensionState = {
...mockExtensionState,
organizationSettingsVersion: 1,
}
rerender(
<ExtensionStateContext.Provider value={mockExtensionState}>
<MarketplaceView stateManager={stateManager} />
</ExtensionStateContext.Provider>,
)
// Should trigger fetch when transitioning from -1 to 1
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "fetchMarketplaceData",
})
})
})
it("renders tab buttons", () => {
render(<MarketplaceView stateManager={mockStateManager} onDone={mockOnDone} />)
it("should not trigger fetchMarketplaceData when organization settings version remains the same", async () => {
const { rerender } = render(
<ExtensionStateContext.Provider value={mockExtensionState}>
<MarketplaceView stateManager={stateManager} />
</ExtensionStateContext.Provider>,
)
expect(screen.getByText("MCP")).toBeInTheDocument()
expect(screen.getByText("Modes")).toBeInTheDocument()
})
// Re-render with same version
rerender(
<ExtensionStateContext.Provider value={mockExtensionState}>
<MarketplaceView stateManager={stateManager} />
</ExtensionStateContext.Provider>,
)
it("renders MarketplaceListView", () => {
render(<MarketplaceView stateManager={mockStateManager} onDone={mockOnDone} />)
expect(screen.getByTestId("marketplace-list-view")).toBeInTheDocument()
// Should not trigger fetch when version hasn't changed
await waitFor(() => {
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "fetchMarketplaceData",
})
})
})
})

View file

@ -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

View file

@ -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 ?? {},