From 843677d54484b0cf558803d31c6d5c4a148f012a Mon Sep 17 00:00:00 2001 From: marcelo Date: Sat, 25 Apr 2026 11:23:42 -0700 Subject: [PATCH 1/2] Move host context to preview surface --- mcpjam-inspector/client/src/App.tsx | 18 +- .../client/src/components/ServersTab.tsx | 7 +- .../client/src/components/ViewsTab.tsx | 14 +- .../__tests__/chatgpt-app-renderer.test.tsx | 8 +- .../chat-v2/thread/chatgpt-app-renderer.tsx | 6 +- .../__tests__/mcp-apps-renderer.test.tsx | 46 +- .../thread/mcp-apps/mcp-apps-renderer.tsx | 6 +- .../parts/__tests__/display-modes.test.tsx | 32 +- .../chat-v2/thread/parts/tool-part.tsx | 4 +- .../client-config/ClientConfigTab.tsx | 33 +- .../WorkspaceClientConfigSync.tsx | 48 +- .../__tests__/ClientConfigTab.test.tsx | 30 +- .../WorkspaceClientConfigSync.test.tsx | 35 +- .../evals/use-eval-trace-tool-context.ts | 8 +- .../shared/DisplayContextHeader.tsx | 962 ------------------ .../components/shared/HostContextDialog.tsx | 120 +++ .../components/shared/HostContextHeader.tsx | 478 +++++++++ ...er.test.tsx => HostContextHeader.test.tsx} | 222 ++-- ...constants.ts => host-context-constants.ts} | 6 +- ...ies.tsx => host-context-picker-bodies.tsx} | 30 +- .../ui-playground/AppBuilderTab.tsx | 10 + .../ui-playground/PlaygroundMain.tsx | 51 +- .../ui-playground/SafeAreaEditor.tsx | 6 +- .../__tests__/PlaygroundMain.test.tsx | 99 +- .../hooks/__tests__/use-app-state.test.tsx | 1 + .../hooks/__tests__/use-server-state.test.tsx | 52 +- .../__tests__/use-workspace-state.test.tsx | 64 +- .../client/src/hooks/use-app-state.ts | 1 + .../client/src/hooks/use-server-state.ts | 9 +- ...se-workspace-client-config-sync-pending.ts | 17 + .../client/src/hooks/use-workspace-state.ts | 146 ++- .../client/src/lib/client-config.ts | 119 ++- .../client/src/stores/client-config-store.ts | 107 +- .../client/src/stores/host-context-store.ts | 285 ++++++ .../client/src/test/mocks/stores.ts | 61 +- 35 files changed, 1712 insertions(+), 1429 deletions(-) delete mode 100644 mcpjam-inspector/client/src/components/shared/DisplayContextHeader.tsx create mode 100644 mcpjam-inspector/client/src/components/shared/HostContextDialog.tsx create mode 100644 mcpjam-inspector/client/src/components/shared/HostContextHeader.tsx rename mcpjam-inspector/client/src/components/shared/__tests__/{DisplayContextHeader.test.tsx => HostContextHeader.test.tsx} (53%) rename mcpjam-inspector/client/src/components/shared/{display-context-constants.ts => host-context-constants.ts} (89%) rename mcpjam-inspector/client/src/components/shared/{display-context-picker-bodies.tsx => host-context-picker-bodies.tsx} (88%) create mode 100644 mcpjam-inspector/client/src/hooks/use-workspace-client-config-sync-pending.ts create mode 100644 mcpjam-inspector/client/src/stores/host-context-store.ts diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index 65b599ff9..e9978c592 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -166,7 +166,7 @@ import { import { getEffectiveWorkspaceClientCapabilities } from "./lib/client-config"; import { buildEvalsHash } from "./lib/evals-router"; import { withTestingSurface } from "./lib/testing-surface"; -import { useClientConfigStore } from "./stores/client-config-store"; +import { useWorkspaceClientConfigSyncPending } from "./hooks/use-workspace-client-config-sync-pending"; import { ingestOAuthTraceLogs } from "./stores/traffic-log-store"; import { clearGuestSession } from "./lib/guest-session"; @@ -663,6 +663,7 @@ export default function App() { handleLeaveWorkspace, handleUpdateWorkspace, handleUpdateClientConfig, + handleUpdateHostContext, handleDeleteWorkspace, handleWorkspaceShared, saveServerConfigWithoutConnecting, @@ -852,11 +853,8 @@ export default function App() { // Get the Convex workspace ID from the active workspace const activeWorkspace = workspaces[activeWorkspaceId]; - const isClientConfigSyncPending = useClientConfigStore( - (state) => - state.isAwaitingRemoteEcho && - state.pendingWorkspaceId === activeWorkspaceId, - ); + const isClientConfigSyncPending = + useWorkspaceClientConfigSyncPending(activeWorkspaceId); const hostedClientCapabilities = getEffectiveWorkspaceClientCapabilities( activeWorkspace?.clientConfig, ) as Record; @@ -1837,7 +1835,11 @@ export default function App() { ) ) : null)} {activeTab === "views" && ( - + )} {activeTab === "conformance" && ( @@ -1975,10 +1977,12 @@ export default function App() { serverConfig={selectedMCPConfig} serverName={appState.selectedServer} servers={workspaceServers} + activeWorkspaceId={activeWorkspaceId} isAuthenticated={isAuthenticated} isAuthLoading={isAuthLoading} isServerSyncing={isSelectedServerSyncing} onConnect={handleConnect} + onSaveHostContext={handleUpdateHostContext} ensureServersReady={ensureServersReady} onOnboardingChange={setAppBuilderOnboarding} playgroundServerSelectorProps={playgroundServerSelectorProps} diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index 41c477360..23d33c05b 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -29,7 +29,7 @@ import { DialogDescription, DialogTitle, } from "@mcpjam/design-system/dialog"; -import type { WorkspaceClientConfig } from "@/lib/client-config"; +import type { WorkspaceConnectionConfigDraft } from "@/lib/client-config"; import { ServerDetailModal, type ServerDetailTab, @@ -513,7 +513,7 @@ interface ServersTabProps { onNavigateToRegistry?: () => void; onSaveClientConfig?: ( workspaceId: string, - clientConfig: WorkspaceClientConfig | undefined + clientConfig: WorkspaceConnectionConfigDraft | undefined ) => Promise; } @@ -1551,8 +1551,7 @@ export function ServersTab({ Connection Settings - Edit workspace connection settings, client capabilities, and host - context. + Edit workspace connection settings and client capabilities.
Promise; selectedServer?: string; } @@ -49,7 +55,11 @@ function safeSerializeForCompare(value: unknown): string { } } -export function ViewsTab({ selectedServer }: ViewsTabProps) { +export function ViewsTab({ + activeWorkspaceId = null, + onSaveHostContext, + selectedServer, +}: ViewsTabProps) { const { isAuthenticated, isLoading } = useConvexAuth(); const posthog = usePostHog(); const appState = useSharedAppState(); @@ -1116,8 +1126,10 @@ export function ViewsTab({ selectedServer }: ViewsTabProps) {
) : ( ({ selector(mockPlaygroundStoreState), })); -vi.mock("@/stores/client-config-store", () => ({ - useClientConfigStore: ( - selector: (state: { draftConfig?: { hostContext?: Record } }) => unknown, - ) => selector({ draftConfig: undefined }), +vi.mock("@/stores/host-context-store", () => ({ + useHostContextStore: ( + selector: (state: { draftHostContext: Record }) => unknown, + ) => selector({ draftHostContext: {} }), })); vi.mock("@/stores/traffic-log-store", () => ({ diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx index 26771c8b9..5e47aee7f 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx @@ -39,7 +39,7 @@ import { loadLocalChatGptWidget, type WidgetCspData, } from "./chatgpt-widget-loaders"; -import { useClientConfigStore } from "@/stores/client-config-store"; +import { useHostContextStore } from "@/stores/host-context-store"; import { extractHostDeviceCapabilities, extractHostLocale, @@ -626,9 +626,7 @@ export function ChatGPTAppRenderer({ const rootRef = useRef(null); const inlineWidthRef = useRef(undefined); const themeMode = usePreferencesStore((s) => s.themeMode); - const draftHostContext = useClientConfigStore( - (s) => s.draftConfig?.hostContext, - ); + const draftHostContext = useHostContextStore((s) => s.draftHostContext); // Get locale and time zone from playground store, fallback to browser settings const playgroundLocale = useUIPlaygroundStore((s) => s.globals.locale); const playgroundTimeZone = useUIPlaygroundStore((s) => s.globals.timeZone); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx index 082562aa2..e843b3f83 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx @@ -70,14 +70,8 @@ const { }; }); -const mockClientConfigStoreState = { - draftConfig: undefined as - | { - version: 1; - clientCapabilities: Record; - hostContext: Record; - } - | undefined, +const mockHostContextStoreState = { + draftHostContext: {} as Record, }; const mockPreferencesState = { @@ -149,8 +143,8 @@ vi.mock("@/stores/ui-playground-store", () => ({ selector(mockPlaygroundStoreState), })); -vi.mock("@/stores/client-config-store", () => ({ - useClientConfigStore: (selector: any) => selector(mockClientConfigStoreState), +vi.mock("@/stores/host-context-store", () => ({ + useHostContextStore: (selector: any) => selector(mockHostContextStoreState), })); vi.mock("@/stores/traffic-log-store", () => ({ @@ -214,7 +208,7 @@ const baseProps = { describe("MCPAppsRenderer tool input streaming", () => { beforeEach(() => { vi.clearAllMocks(); - mockClientConfigStoreState.draftConfig = undefined; + mockHostContextStoreState.draftHostContext = {}; Object.assign(mockPlaygroundStoreState, { isPlaygroundActive: false, mcpAppsCspMode: "permissive", @@ -323,15 +317,11 @@ describe("MCPAppsRenderer tool input streaming", () => { }); it("clamps configured host display modes before sending host context", async () => { - mockClientConfigStoreState.draftConfig = { - version: 1, - clientCapabilities: {}, - hostContext: { - displayMode: "fullscreen", - availableDisplayModes: ["inline"], - locale: "fr-FR", - timeZone: "Europe/Paris", - }, + mockHostContextStoreState.draftHostContext = { + displayMode: "fullscreen", + availableDisplayModes: ["inline"], + locale: "fr-FR", + timeZone: "Europe/Paris", }; render(); @@ -425,16 +415,12 @@ describe("MCPAppsRenderer tool input streaming", () => { ); }); - mockClientConfigStoreState.draftConfig = { - version: 1, - clientCapabilities: {}, - hostContext: { - locale: "es-ES", - timeZone: "Europe/Madrid", - deviceCapabilities: { - hover: false, - touch: true, - }, + mockHostContextStoreState.draftHostContext = { + locale: "es-ES", + timeZone: "Europe/Madrid", + deviceCapabilities: { + hover: false, + touch: true, }, }; diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx index bededaaaa..a32f3c8aa 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx @@ -70,7 +70,7 @@ import type { CheckoutSession } from "@/shared/acp-types"; import { listResources, readResource } from "@/lib/apis/mcp-resources-api"; import { listPrompts } from "@/lib/apis/mcp-prompts-api"; import { useChatboxHostStyle } from "@/contexts/chatbox-host-style-context"; -import { useClientConfigStore } from "@/stores/client-config-store"; +import { useHostContextStore } from "@/stores/host-context-store"; import { clampDisplayModeToAvailableModes, extractHostDisplayMode, @@ -180,9 +180,7 @@ export function MCPAppsRenderer({ const themeMode = usePreferencesStore((s) => s.themeMode); const sharedHostStyle = usePreferencesStore((s) => s.hostStyle); const chatboxHostStyle = useChatboxHostStyle(); - const draftHostContext = useClientConfigStore( - (s) => s.draftConfig?.hostContext, - ); + const draftHostContext = useHostContextStore((s) => s.draftHostContext); const baseHostContext = useMemo( () => draftHostContext && diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx index 171898b1a..a70008eda 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/__tests__/display-modes.test.tsx @@ -2,8 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ToolPart } from "../tool-part"; -import { useClientConfigStore } from "@/stores/client-config-store"; -import { storePresets } from "@/test/mocks"; +import { useHostContextStore } from "@/stores/host-context-store"; // Mock lucide-react icons vi.mock("lucide-react", () => { @@ -92,7 +91,19 @@ describe("ToolPart display mode controls", () => { beforeEach(() => { vi.clearAllMocks(); onDisplayModeChange = vi.fn(); - useClientConfigStore.setState(storePresets.clientConfig()); + useHostContextStore.setState({ + activeWorkspaceId: null, + defaultHostContext: {}, + savedHostContext: undefined, + draftHostContext: {}, + hostContextText: "{}", + hostContextError: null, + isSaving: false, + isDirty: false, + pendingWorkspaceId: null, + pendingSavedHostContext: undefined, + isAwaitingRemoteEcho: false, + }); }); const renderWithDisplayModes = ( @@ -162,9 +173,18 @@ describe("ToolPart display mode controls", () => { }); it("disables modes that the host does not advertise even when the app supports them", () => { - useClientConfigStore.setState( - storePresets.clientConfigWithHostDisplayModes(["inline"]), - ); + useHostContextStore.setState({ + draftHostContext: { + availableDisplayModes: ["inline"], + }, + hostContextText: JSON.stringify( + { + availableDisplayModes: ["inline"], + }, + null, + 2, + ), + }); renderWithDisplayModes(["inline", "pip", "fullscreen"]); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx index c215830b0..441e76937 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx @@ -39,7 +39,7 @@ import { CspDebugPanel } from "../csp-debug-panel"; import { JsonEditor } from "@/components/ui/json-editor"; import { cn } from "@/lib/chat-utils"; import { TextPart } from "./text-part"; -import { useClientConfigStore } from "@/stores/client-config-store"; +import { useHostContextStore } from "@/stores/host-context-store"; import { extractHostDisplayModes } from "@/lib/client-config"; import { useChatboxHostTheme } from "@/contexts/chatbox-host-style-context"; @@ -167,7 +167,7 @@ export function ToolPart({ const widgetDebugInfo = useWidgetDebugStore((s) => toolCallId ? s.widgets.get(toolCallId) : undefined, ); - const hostContext = useClientConfigStore((s) => s.draftConfig?.hostContext); + const hostContext = useHostContextStore((s) => s.draftHostContext); const hostAvailableDisplayModes = useMemo( () => extractHostDisplayModes(hostContext), [hostContext], diff --git a/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx b/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx index 63b955ecc..3a2dfdd0e 100644 --- a/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx +++ b/mcpjam-inspector/client/src/components/client-config/ClientConfigTab.tsx @@ -7,8 +7,9 @@ import type { Workspace } from "@/state/app-types"; import { getEffectiveServerClientCapabilities, workspaceClientCapabilitiesNeedReconnect, - type WorkspaceClientConfig, + type WorkspaceConnectionConfigDraft, } from "@/lib/client-config"; +import { useWorkspaceClientConfigSyncPending } from "@/hooks/use-workspace-client-config-sync-pending"; import { useClientConfigStore } from "@/stores/client-config-store"; /** Toolbar (~34px) + status bar (~28px) + a bit of breathing room. */ @@ -56,7 +57,7 @@ interface ClientConfigTabProps { workspace?: Workspace; onSaveClientConfig: ( workspaceId: string, - clientConfig: WorkspaceClientConfig | undefined, + clientConfig: WorkspaceConnectionConfigDraft | undefined, ) => Promise; } @@ -72,14 +73,12 @@ export function ClientConfigTab({ const clientCapabilitiesText = useClientConfigStore( (s) => s.clientCapabilitiesText, ); - const hostContextText = useClientConfigStore((s) => s.hostContextText); const connectionDefaultsError = useClientConfigStore( (s) => s.connectionDefaultsError, ); const clientCapabilitiesError = useClientConfigStore( (s) => s.clientCapabilitiesError, ); - const hostContextError = useClientConfigStore((s) => s.hostContextError); const isDirty = useClientConfigStore((s) => s.isDirty); const isSaving = useClientConfigStore((s) => s.isSaving); const setSectionText = useClientConfigStore((s) => s.setSectionText); @@ -88,6 +87,7 @@ export function ClientConfigTab({ ); const resetToBaseline = useClientConfigStore((s) => s.resetToBaseline); const failSave = useClientConfigStore((s) => s.failSave); + const syncPending = useWorkspaceClientConfigSyncPending(activeWorkspaceId); const connectionDefaultsHeight = useContentSizedJsonHeight( connectionDefaultsText, @@ -101,12 +101,6 @@ export function ClientConfigTab({ 36, 0.5, ); - const hostContextHeight = useContentSizedJsonHeight( - hostContextText, - 7.5, - 32, - 0.45, - ); const reconnectServers = useMemo(() => { if (!workspace) { @@ -135,11 +129,7 @@ export function ClientConfigTab({ if (!draftConfig) { return; } - if ( - connectionDefaultsError || - clientCapabilitiesError || - hostContextError - ) { + if (connectionDefaultsError || clientCapabilitiesError) { toast.error("Fix JSON validation errors before saving."); return; } @@ -167,13 +157,6 @@ export function ClientConfigTab({ error: clientCapabilitiesError, height: clientCapabilitiesHeight, }, - { - key: "hostContext" as const, - title: "Host context", - text: hostContextText, - error: hostContextError, - height: hostContextHeight, - }, ]; return ( @@ -192,7 +175,7 @@ export function ClientConfigTab({ variant="outline" size="sm" onClick={resetToBaseline} - disabled={isSaving || !isDirty} + disabled={isSaving || syncPending || !isDirty} className="h-7 text-xs" > Reset @@ -200,7 +183,7 @@ export function ClientConfigTab({ - - - -

Device

-
- - -
- {/* Preset devices */} - {( - Object.entries(PRESET_DEVICE_CONFIGS) as [ - Exclude, - (typeof PRESET_DEVICE_CONFIGS)[Exclude< - DeviceType, - "custom" - >], - ][] - ).map(([type, config]) => { - const Icon = config.icon; - const isSelected = deviceType === type; - return ( - - ); - })} - - {/* Custom option */} - - - {/* Custom dimension inputs - only show when custom is selected */} - {deviceType === "custom" && ( -
-
- - { - const val = parseInt(e.target.value) || 100; - setCustomViewport({ - width: Math.max(100, Math.min(2560, val)), - }); - }} - className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> -
-
- - { - const val = parseInt(e.target.value) || 100; - setCustomViewport({ - height: Math.max(100, Math.min(2560, val)), - }); - }} - className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> -
-
- )} -
-
- - - {/* Locale selector */} - - - - - - - - -

Locale

-
-
- -
- {LOCALE_OPTIONS.map((option) => ( - - ))} -
-
-
- - {/* CSP mode selector - uses protocol-aware store */} - - - - - - - - -

CSP

-
-
- -
- {CSP_MODE_OPTIONS.map((option) => ( - - ))} -
-
-
- - {/* Capabilities toggles */} -
- - - - - -

Hover

-

- {capabilities.hover ? "Enabled" : "Disabled"} -

-
-
- - - - - -

Touch

-

- {capabilities.touch ? "Enabled" : "Disabled"} -

-
-
-
- - {/* Safe area editor */} - - -
- -
-
- -

Safe Area

-
-
- - )} - - {/* MCP Apps controls (SEP-1865) */} - {showMCPAppsControls && ( - <> - {/* Device type selector with custom dimensions */} - - - - - - - - -

Device

-
-
- -
- {/* Preset devices */} - {( - Object.entries(PRESET_DEVICE_CONFIGS) as [ - Exclude, - (typeof PRESET_DEVICE_CONFIGS)[Exclude< - DeviceType, - "custom" - >], - ][] - ).map(([type, config]) => { - const Icon = config.icon; - const isSelected = deviceType === type; - return ( - - ); - })} - - {/* Custom option */} - - - {/* Custom dimension inputs - only show when custom is selected */} - {deviceType === "custom" && ( -
-
- - { - const val = parseInt(e.target.value) || 100; - setCustomViewport({ - width: Math.max(100, Math.min(2560, val)), - }); - }} - className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> -
-
- - { - const val = parseInt(e.target.value) || 100; - setCustomViewport({ - height: Math.max(100, Math.min(2560, val)), - }); - }} - className="h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> -
-
- )} -
-
-
- - {/* Locale selector */} - - - - - - - - -

Locale

-
-
- -
- {LOCALE_OPTIONS.map((option) => ( - - ))} -
-
-
- - {/* Timezone selector (SEP-1865) */} - - - - - - - - -

Timezone

-
-
- -
- {TIMEZONE_OPTIONS.map((option) => ( - - ))} -
-
-
- - {/* CSP mode selector */} - - - - - - - - -

CSP

-
-
- -
- {CSP_MODE_OPTIONS.map((option) => ( - - ))} -
-
-
- - {/* Capabilities toggles */} -
- - - - - -

Hover

-

- {capabilities.hover ? "Enabled" : "Disabled"} -

-
-
- - - - - -

Touch

-

- {capabilities.touch ? "Enabled" : "Disabled"} -

-
-
-
- - {/* Safe area editor */} - - -
- -
-
- -

Safe Area

-
-
- - )} - - {/* Host style toggle (Claude / ChatGPT) */} - {(showChatGPTControls || showMCPAppsControls) && ( - - -
-
- -
- - -
-
- Host Styles -
- )} - - {/* Theme toggle */} - {showThemeToggle && ( - - - - - - {effectiveThemeMode === "dark" ? "Light mode" : "Dark mode"} - - - )} - - - ); -} diff --git a/mcpjam-inspector/client/src/components/shared/HostContextDialog.tsx b/mcpjam-inspector/client/src/components/shared/HostContextDialog.tsx new file mode 100644 index 000000000..3771d4fcb --- /dev/null +++ b/mcpjam-inspector/client/src/components/shared/HostContextDialog.tsx @@ -0,0 +1,120 @@ +import { toast } from "sonner"; +import { AlertTriangle, RotateCcw, Save } from "lucide-react"; +import { Button } from "@mcpjam/design-system/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@mcpjam/design-system/dialog"; +import { JsonEditor } from "@/components/ui/json-editor"; +import type { WorkspaceHostContextDraft } from "@/lib/client-config"; +import { useWorkspaceClientConfigSyncPending } from "@/hooks/use-workspace-client-config-sync-pending"; +import { useHostContextStore } from "@/stores/host-context-store"; + +interface HostContextDialogProps { + activeWorkspaceId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSaveHostContext?: ( + workspaceId: string, + hostContext: WorkspaceHostContextDraft, + ) => Promise; +} + +export function HostContextDialog({ + activeWorkspaceId, + open, + onOpenChange, + onSaveHostContext, +}: HostContextDialogProps) { + const draftHostContext = useHostContextStore((state) => state.draftHostContext); + const hostContextText = useHostContextStore((state) => state.hostContextText); + const hostContextError = useHostContextStore((state) => state.hostContextError); + const isDirty = useHostContextStore((state) => state.isDirty); + const isSaving = useHostContextStore((state) => state.isSaving); + const setHostContextText = useHostContextStore( + (state) => state.setHostContextText, + ); + const resetToBaseline = useHostContextStore((state) => state.resetToBaseline); + const failSave = useHostContextStore((state) => state.failSave); + const syncPending = useWorkspaceClientConfigSyncPending(activeWorkspaceId); + + const handleSave = async () => { + if (!activeWorkspaceId || !onSaveHostContext || hostContextError) { + return; + } + + try { + await onSaveHostContext(activeWorkspaceId, draftHostContext); + toast.success("Host context saved."); + onOpenChange(false); + } catch { + failSave(); + } + }; + + return ( + + + + Host Context + + Edit the persisted `hostContext` payload used for preview/runtime + host data. + + + +
+ {hostContextError && ( +
+ + {hostContextError} +
+ )} + +
+ +
+
+ + + + + +
+
+ ); +} diff --git a/mcpjam-inspector/client/src/components/shared/HostContextHeader.tsx b/mcpjam-inspector/client/src/components/shared/HostContextHeader.tsx new file mode 100644 index 000000000..3f18f7721 --- /dev/null +++ b/mcpjam-inspector/client/src/components/shared/HostContextHeader.tsx @@ -0,0 +1,478 @@ +/** + * HostContextHeader + * + * Reusable preview/runtime controls for host context, adjacent preview chrome, + * and CSP mode. Host context edits are live draft changes; persistence happens + * through the Host Context dialog. + */ + +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import { + Clock, + Globe, + Hand, + Moon, + MousePointer2, + Palette, + Shield, + Sun, +} from "lucide-react"; +import { Button } from "@mcpjam/design-system/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@mcpjam/design-system/popover"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@mcpjam/design-system/tooltip"; +import type { WorkspaceHostContextDraft } from "@/lib/client-config"; +import { + extractEffectiveHostDisplayMode, + extractHostDeviceCapabilities, + extractHostDisplayModes, + extractHostLocale, + extractHostTheme, + extractHostTimeZone, +} from "@/lib/client-config"; +import { UIType } from "@/lib/mcp-ui/mcp-apps-utils"; +import { cn } from "@/lib/utils"; +import { useHostContextStore } from "@/stores/host-context-store"; +import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; +import { useUIPlaygroundStore } from "@/stores/ui-playground-store"; +import { useWidgetDebugStore } from "@/stores/widget-debug-store"; +import { SafeAreaEditor } from "@/components/ui-playground/SafeAreaEditor"; +import { HostContextDialog } from "@/components/shared/HostContextDialog"; +import { + PRESET_DEVICE_CONFIGS, + TIMEZONE_OPTIONS, +} from "@/components/shared/host-context-constants"; +import { + CspPickerBody, + DevicePickerBody, + LocalePickerBody, + TimezonePickerBody, +} from "@/components/shared/host-context-picker-bodies"; + +export { PRESET_DEVICE_CONFIGS } from "@/components/shared/host-context-constants"; + +const CUSTOM_DEVICE_BASE = { + label: "Custom", +}; + +const DISPLAY_MODE_LABELS = { + inline: "Inline", + pip: "PiP", + fullscreen: "Fullscreen", +} as const; + +export interface HostContextHeaderProps { + activeWorkspaceId: string | null; + onSaveHostContext?: ( + workspaceId: string, + hostContext: WorkspaceHostContextDraft, + ) => Promise; + protocol: UIType | null; + showThemeToggle?: boolean; + className?: string; +} + +export function HostContextHeader({ + activeWorkspaceId, + onSaveHostContext, + protocol, + showThemeToggle = false, + className, +}: HostContextHeaderProps) { + const [devicePopoverOpen, setDevicePopoverOpen] = useState(false); + const [localePopoverOpen, setLocalePopoverOpen] = useState(false); + const [cspPopoverOpen, setCspPopoverOpen] = useState(false); + const [timezonePopoverOpen, setTimezonePopoverOpen] = useState(false); + const [hostContextDialogOpen, setHostContextDialogOpen] = useState(false); + + const widthInputId = useId(); + const heightInputId = useId(); + + const deviceType = useUIPlaygroundStore((state) => state.deviceType); + const setDeviceType = useUIPlaygroundStore((state) => state.setDeviceType); + const customViewport = useUIPlaygroundStore((state) => state.customViewport); + const setCustomViewport = useUIPlaygroundStore( + (state) => state.setCustomViewport, + ); + const cspMode = useUIPlaygroundStore((state) => state.cspMode); + const setCspMode = useUIPlaygroundStore((state) => state.setCspMode); + const mcpAppsCspMode = useUIPlaygroundStore((state) => state.mcpAppsCspMode); + const setMcpAppsCspMode = useUIPlaygroundStore( + (state) => state.setMcpAppsCspMode, + ); + + const draftHostContext = useHostContextStore( + (state) => state.draftHostContext, + ); + const patchHostContext = useHostContextStore((state) => state.patchHostContext); + const hostContextDirty = useHostContextStore((state) => state.isDirty); + + const themeMode = usePreferencesStore((state) => state.themeMode); + const hostStyle = usePreferencesStore((state) => state.hostStyle); + const setHostStyle = usePreferencesStore((state) => state.setHostStyle); + + const usesMcpAppsCsp = + protocol === UIType.MCP_APPS || + protocol === UIType.OPENAI_SDK_AND_MCP_APPS; + const activeCspMode = usesMcpAppsCsp ? mcpAppsCspMode : cspMode; + const setActiveCspMode = usesMcpAppsCsp ? setMcpAppsCspMode : setCspMode; + + const violationCount = useWidgetDebugStore((state) => + Array.from(state.widgets.values()).reduce( + (sum, widget) => sum + (widget.csp?.violations?.length ?? 0), + 0, + ), + ); + const [shouldBlink, setShouldBlink] = useState(false); + const prevViolationCount = useRef(violationCount); + + useEffect(() => { + if (violationCount > prevViolationCount.current) { + setShouldBlink(true); + } + prevViolationCount.current = violationCount; + }, [violationCount]); + + const fallbackLocale = navigator.language || "en-US"; + const fallbackTimeZone = + Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + + const deviceConfig = useMemo(() => { + if (deviceType === "custom") { + return { + ...CUSTOM_DEVICE_BASE, + width: customViewport.width, + height: customViewport.height, + }; + } + + return PRESET_DEVICE_CONFIGS[deviceType]; + }, [customViewport, deviceType]); + const DeviceIcon = deviceType === "custom" ? null : deviceConfig.icon; + + const locale = extractHostLocale(draftHostContext, fallbackLocale); + const timeZone = extractHostTimeZone(draftHostContext, fallbackTimeZone); + const capabilities = extractHostDeviceCapabilities(draftHostContext); + const effectiveThemeMode = extractHostTheme(draftHostContext) ?? themeMode; + const displayMode = extractEffectiveHostDisplayMode(draftHostContext); + const availableDisplayModes = extractHostDisplayModes(draftHostContext); + + const handleCapabilityToggle = useCallback( + (key: "hover" | "touch") => { + const nextCapabilities = { + hover: key === "hover" ? !capabilities.hover : capabilities.hover, + touch: key === "touch" ? !capabilities.touch : capabilities.touch, + }; + patchHostContext({ deviceCapabilities: nextCapabilities }); + }, + [capabilities, patchHostContext], + ); + + const handleThemeChange = useCallback(() => { + patchHostContext({ + theme: effectiveThemeMode === "dark" ? "light" : "dark", + }); + }, [effectiveThemeMode, patchHostContext]); + + return ( +
+
+ + + + + + + + +

Device

+
+
+ + setDevicePopoverOpen(false)} + /> + +
+ + + + + + + + + +

Locale

+
+
+ + setLocalePopoverOpen(false)} + /> + +
+ + + + + + + + + +

Timezone

+
+
+ + setTimezonePopoverOpen(false)} + /> + +
+ + + +
+ + Display + + {DISPLAY_MODE_LABELS[displayMode]} +
+
+ + Available:{" "} + {availableDisplayModes + .map((mode) => DISPLAY_MODE_LABELS[mode]) + .join(", ")} + +
+ + + + + + + + + +

CSP

+
+
+ + setCspPopoverOpen(false)} + /> + +
+ +
+ + + + + +

Hover

+

+ {capabilities.hover ? "Enabled" : "Disabled"} +

+
+
+ + + + + + +

Touch

+

+ {capabilities.touch ? "Enabled" : "Disabled"} +

+
+
+
+ + + +
+ +
+
+ +

Safe Area

+
+
+ + + + + + +

Host Context

+

+ Edit raw `hostContext` JSON +

+
+
+ + + +
+
+ +
+ + +
+
+ Host Styles +
+ + {showThemeToggle ? ( + + + + + + {effectiveThemeMode === "dark" ? "Light mode" : "Dark mode"} + + + ) : null} +
+ + +
+ ); +} diff --git a/mcpjam-inspector/client/src/components/shared/__tests__/DisplayContextHeader.test.tsx b/mcpjam-inspector/client/src/components/shared/__tests__/HostContextHeader.test.tsx similarity index 53% rename from mcpjam-inspector/client/src/components/shared/__tests__/DisplayContextHeader.test.tsx rename to mcpjam-inspector/client/src/components/shared/__tests__/HostContextHeader.test.tsx index 727ef0798..a34f3e78c 100644 --- a/mcpjam-inspector/client/src/components/shared/__tests__/DisplayContextHeader.test.tsx +++ b/mcpjam-inspector/client/src/components/shared/__tests__/HostContextHeader.test.tsx @@ -1,6 +1,46 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -import { DisplayContextHeader } from "../DisplayContextHeader"; +import { HostContextHeader } from "../HostContextHeader"; + +const { + mockPreferencesState, + mockUIPlaygroundStore, + mockHostContextState, + mockPatchHostContext, +} = vi.hoisted(() => ({ + mockPreferencesState: { + themeMode: "light", + hostStyle: "claude", + setThemeMode: vi.fn(), + setHostStyle: vi.fn(), + }, + mockUIPlaygroundStore: { + deviceType: "desktop", + setDeviceType: vi.fn(), + customViewport: { width: 1280, height: 800 }, + setCustomViewport: vi.fn(), + cspMode: "widget-declared", + setCspMode: vi.fn(), + mcpAppsCspMode: "widget-declared", + setMcpAppsCspMode: vi.fn(), + }, + mockHostContextState: { + draftHostContext: { + locale: "en-US", + timeZone: "UTC", + theme: "dark", + displayMode: "inline", + availableDisplayModes: ["inline", "pip", "fullscreen"], + deviceCapabilities: { + hover: true, + touch: false, + }, + } as Record, + patchHostContext: vi.fn(), + isDirty: false, + }, + mockPatchHostContext: vi.fn(), +})); vi.mock("lucide-react", () => ({ Smartphone: () => , @@ -13,7 +53,6 @@ vi.mock("lucide-react", () => ({ Shield: () => , MousePointer2: () => , Hand: () => , - Settings2: () => , Palette: () => , })); @@ -45,54 +84,29 @@ vi.mock("@mcpjam/design-system/popover", () => ({ ), })); -vi.mock("@mcpjam/design-system/input", () => ({ - Input: (props: any) => , -})); - -vi.mock("@mcpjam/design-system/label", () => ({ - Label: ({ children, ...props }: any) => , -})); - vi.mock("@/components/ui-playground/SafeAreaEditor", () => ({ SafeAreaEditor: () =>
, })); -vi.mock("@/lib/mcp-ui/mcp-apps-utils", () => ({ - UIType: { - OPENAI_SDK: "openai-apps", - MCP_APPS: "mcp-apps", - OPENAI_SDK_AND_MCP_APPS: "both", - }, +vi.mock("@/components/shared/HostContextDialog", () => ({ + HostContextDialog: ({ open }: { open: boolean }) => + open ?
: null, })); -const { - mockUpdateThemeMode, - mockPreferencesState, - mockUIPlaygroundStore, - mockPatchHostContext, -} = vi.hoisted(() => ({ - mockUpdateThemeMode: vi.fn(), - mockPreferencesState: { - themeMode: "light", - hostStyle: "claude", - setThemeMode: vi.fn(), - setHostStyle: vi.fn(), +vi.mock("@/components/shared/host-context-constants", () => ({ + PRESET_DEVICE_CONFIGS: { + mobile: { width: 375, height: 667, label: "Phone", icon: () => null }, + tablet: { width: 768, height: 1024, label: "Tablet", icon: () => null }, + desktop: { width: 1280, height: 800, label: "Desktop", icon: () => null }, }, - mockUIPlaygroundStore: { - deviceType: "desktop", - setDeviceType: vi.fn(), - customViewport: { width: 1280, height: 800 }, - setCustomViewport: vi.fn(), - cspMode: "widget-declared", - setCspMode: vi.fn(), - mcpAppsCspMode: "widget-declared", - setMcpAppsCspMode: vi.fn(), - }, - mockPatchHostContext: vi.fn(), + TIMEZONE_OPTIONS: [{ zone: "UTC", label: "UTC" }], })); -vi.mock("@/lib/theme-utils", () => ({ - updateThemeMode: mockUpdateThemeMode, +vi.mock("@/components/shared/host-context-picker-bodies", () => ({ + CspPickerBody: () =>
, + DevicePickerBody: () =>
, + LocalePickerBody: () =>
, + TimezonePickerBody: () =>
, })); vi.mock("@/stores/preferences/preferences-provider", () => ({ @@ -103,11 +117,6 @@ vi.mock("@/stores/preferences/preferences-provider", () => ({ vi.mock("@/stores/ui-playground-store", () => ({ useUIPlaygroundStore: (selector: any) => selector ? selector(mockUIPlaygroundStore) : mockUIPlaygroundStore, - DEVICE_VIEWPORT_CONFIGS: { - mobile: { width: 375, height: 667 }, - tablet: { width: 768, height: 1024 }, - desktop: { width: 1280, height: 800 }, - }, })); vi.mock("@/stores/widget-debug-store", () => ({ @@ -119,88 +128,91 @@ vi.mock("@/stores/widget-debug-store", () => ({ }, })); -vi.mock("@/stores/client-config-store", () => ({ - useClientConfigStore: (selector: any) => +vi.mock("@/stores/host-context-store", () => ({ + useHostContextStore: (selector: any) => selector ? selector({ - draftConfig: { - hostContext: { - locale: "en-US", - timeZone: "UTC", - displayMode: "inline", - }, - }, + draftHostContext: mockHostContextState.draftHostContext, patchHostContext: mockPatchHostContext, + isDirty: mockHostContextState.isDirty, }) : { - draftConfig: { - hostContext: { - locale: "en-US", - timeZone: "UTC", - displayMode: "inline", - }, - }, + draftHostContext: mockHostContextState.draftHostContext, patchHostContext: mockPatchHostContext, + isDirty: mockHostContextState.isDirty, }, })); +vi.mock("@/lib/mcp-ui/mcp-apps-utils", () => ({ + UIType: { + OPENAI_SDK: "openai-apps", + MCP_APPS: "mcp-apps", + OPENAI_SDK_AND_MCP_APPS: "both", + }, +})); + vi.mock("@/lib/client-config", () => ({ - clampDisplayModeToAvailableModes: vi - .fn() - .mockImplementation((displayMode) => displayMode), - extractEffectiveHostDisplayMode: vi.fn().mockReturnValue("inline"), - extractHostDeviceCapabilities: vi.fn().mockReturnValue({ - hover: true, - touch: false, - }), - extractHostDisplayModes: vi - .fn() - .mockReturnValue(["inline", "pip", "fullscreen"]), - extractHostLocale: vi.fn().mockReturnValue("en-US"), - extractHostTimeZone: vi.fn().mockReturnValue("UTC"), + extractEffectiveHostDisplayMode: (hostContext: Record) => + hostContext.displayMode ?? "inline", + extractHostDeviceCapabilities: (hostContext: Record) => + hostContext.deviceCapabilities ?? { + hover: true, + touch: false, + }, + extractHostDisplayModes: (hostContext: Record) => + hostContext.availableDisplayModes ?? ["inline", "pip", "fullscreen"], + extractHostLocale: ( + hostContext: Record, + fallback: string, + ) => hostContext.locale ?? fallback, + extractHostTheme: (hostContext: Record) => + hostContext.theme, + extractHostTimeZone: ( + hostContext: Record, + fallback: string, + ) => hostContext.timeZone ?? fallback, })); -describe("DisplayContextHeader", () => { +describe("HostContextHeader", () => { beforeEach(() => { vi.clearAllMocks(); mockPreferencesState.themeMode = "light"; mockPreferencesState.hostStyle = "claude"; + mockHostContextState.draftHostContext = { + locale: "en-US", + timeZone: "UTC", + theme: "dark", + displayMode: "inline", + availableDisplayModes: ["inline", "pip", "fullscreen"], + deviceCapabilities: { + hover: true, + touch: false, + }, + }; + mockHostContextState.isDirty = false; }); - it("uses the local theme override without writing global theme state", () => { - const onThemeToggleOverride = vi.fn(); - + it("writes theme changes through hostContext instead of global preferences", () => { render( - , ); expect(screen.getByTestId("icon-sun")).toBeInTheDocument(); - fireEvent.click(screen.getByTestId("display-context-theme-toggle")); + fireEvent.click(screen.getByTestId("host-context-theme-toggle")); - expect(onThemeToggleOverride).toHaveBeenCalledTimes(1); + expect(mockPatchHostContext).toHaveBeenCalledWith({ theme: "light" }); expect(mockPreferencesState.setThemeMode).not.toHaveBeenCalled(); - expect(mockUpdateThemeMode).not.toHaveBeenCalled(); - }); - - it("falls back to the global theme toggle when no override props are passed", () => { - render(); - - expect(screen.getByTestId("icon-moon")).toBeInTheDocument(); - - fireEvent.click(screen.getByTestId("display-context-theme-toggle")); - - expect(mockPreferencesState.setThemeMode).toHaveBeenCalledWith("dark"); - expect(mockUpdateThemeMode).toHaveBeenCalledWith("dark"); }); it("writes Claude and ChatGPT host-style selections through shared preferences", () => { - render(); + render( + , + ); fireEvent.click(screen.getByRole("button", { name: "Claude" })); fireEvent.click(screen.getByRole("button", { name: "ChatGPT" })); @@ -214,4 +226,20 @@ describe("DisplayContextHeader", () => { "chatgpt", ); }); + + it("surfaces unsaved state and opens the raw host context dialog", () => { + mockHostContextState.isDirty = true; + + render( + , + ); + + expect(screen.getByTestId("host-context-trigger")).toHaveTextContent( + "Unsaved", + ); + + fireEvent.click(screen.getByTestId("host-context-trigger")); + + expect(screen.getByTestId("host-context-dialog")).toBeInTheDocument(); + }); }); diff --git a/mcpjam-inspector/client/src/components/shared/display-context-constants.ts b/mcpjam-inspector/client/src/components/shared/host-context-constants.ts similarity index 89% rename from mcpjam-inspector/client/src/components/shared/display-context-constants.ts rename to mcpjam-inspector/client/src/components/shared/host-context-constants.ts index 8763fdf42..b7f47c75f 100644 --- a/mcpjam-inspector/client/src/components/shared/display-context-constants.ts +++ b/mcpjam-inspector/client/src/components/shared/host-context-constants.ts @@ -1,5 +1,5 @@ /** - * Static option lists and preset device metadata for display context pickers. + * Static option lists and preset device metadata for host context controls. */ import type { ComponentType } from "react"; @@ -10,7 +10,6 @@ import { type DeviceType, } from "@/stores/ui-playground-store"; -/** Device frame configurations - extends shared viewport config with UI properties */ export const PRESET_DEVICE_CONFIGS: Record< Exclude, { @@ -37,7 +36,6 @@ export const PRESET_DEVICE_CONFIGS: Record< }, }; -/** Common BCP 47 locales for testing (per OpenAI Apps SDK spec) */ export const LOCALE_OPTIONS = [ { code: "en-US", label: "English (US)" }, { code: "en-GB", label: "English (UK)" }, @@ -57,7 +55,6 @@ export const LOCALE_OPTIONS = [ { code: "nl-NL", label: "Nederlands" }, ] as const; -/** Common IANA timezones for testing (per SEP-1865 MCP Apps spec) */ export const TIMEZONE_OPTIONS = [ { zone: "America/New_York", label: "New York", offset: "UTC-5/-4" }, { zone: "America/Chicago", label: "Chicago", offset: "UTC-6/-5" }, @@ -80,7 +77,6 @@ export const TIMEZONE_OPTIONS = [ { zone: "UTC", label: "UTC", offset: "UTC+0" }, ] as const; -/** CSP mode options for widget sandbox */ export const CSP_MODE_OPTIONS: { mode: CspMode; label: string; diff --git a/mcpjam-inspector/client/src/components/shared/display-context-picker-bodies.tsx b/mcpjam-inspector/client/src/components/shared/host-context-picker-bodies.tsx similarity index 88% rename from mcpjam-inspector/client/src/components/shared/display-context-picker-bodies.tsx rename to mcpjam-inspector/client/src/components/shared/host-context-picker-bodies.tsx index aa71375e2..ff2cdcd7c 100644 --- a/mcpjam-inspector/client/src/components/shared/display-context-picker-bodies.tsx +++ b/mcpjam-inspector/client/src/components/shared/host-context-picker-bodies.tsx @@ -1,5 +1,5 @@ /** - * Shared picker bodies for DisplayContextHeader (inline popovers + compact panel). + * Shared picker bodies for HostContextHeader popovers. */ import { Settings2 } from "lucide-react"; @@ -10,7 +10,7 @@ import { LOCALE_OPTIONS, TIMEZONE_OPTIONS, CSP_MODE_OPTIONS, -} from "@/components/shared/display-context-constants"; +} from "@/components/shared/host-context-constants"; import type { CustomViewport, CspMode, @@ -32,9 +32,9 @@ export function DevicePickerBody({ onPickPreset, }: { deviceType: DeviceType; - setDeviceType: (t: DeviceType) => void; + setDeviceType: (type: DeviceType) => void; customViewport: CustomViewport; - setCustomViewport: (v: Partial) => void; + setCustomViewport: (viewport: Partial) => void; widthInputId: string; heightInputId: string; onPickPreset?: () => void; @@ -60,7 +60,7 @@ export function DevicePickerBody({ {config.label} - {config.width}×{config.height} + {config.width}x{config.height} ); @@ -77,7 +77,7 @@ export function DevicePickerBody({ Custom - {customViewport.width}×{customViewport.height} + {customViewport.width}x{customViewport.height} @@ -97,10 +97,10 @@ export function DevicePickerBody({ max={2560} defaultValue={customViewport.width} key={`${widthInputId}-w-${customViewport.width}`} - onBlur={(e) => { - const val = parseInt(e.target.value) || 100; + onBlur={(event) => { + const value = parseInt(event.target.value, 10) || 100; setCustomViewport({ - width: Math.max(100, Math.min(2560, val)), + width: Math.max(100, Math.min(2560, value)), }); }} className="h-7 text-xs [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" @@ -120,10 +120,10 @@ export function DevicePickerBody({ max={2560} defaultValue={customViewport.height} key={`${heightInputId}-h-${customViewport.height}`} - onBlur={(e) => { - const val = parseInt(e.target.value) || 100; + onBlur={(event) => { + const value = parseInt(event.target.value, 10) || 100; setCustomViewport({ - height: Math.max(100, Math.min(2560, val)), + height: Math.max(100, Math.min(2560, value)), }); }} className="h-7 text-xs [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" @@ -141,7 +141,7 @@ export function LocalePickerBody({ onSelectLocale, }: { locale: string; - patchHostContext: (p: { locale: string }) => void; + patchHostContext: (patch: { locale: string }) => void; onSelectLocale?: () => void; }) { return ( @@ -174,7 +174,7 @@ export function TimezonePickerBody({ onSelectZone, }: { timeZone: string; - patchHostContext: (p: { timeZone: string }) => void; + patchHostContext: (patch: { timeZone: string }) => void; onSelectZone?: () => void; }) { return ( @@ -207,7 +207,7 @@ export function CspPickerBody({ onSelectMode, }: { activeCspMode: CspMode; - setActiveCspMode: (m: CspMode) => void; + setActiveCspMode: (mode: CspMode) => void; onSelectMode?: () => void; }) { return ( diff --git a/mcpjam-inspector/client/src/components/ui-playground/AppBuilderTab.tsx b/mcpjam-inspector/client/src/components/ui-playground/AppBuilderTab.tsx index e63c74884..e38d9521d 100644 --- a/mcpjam-inspector/client/src/components/ui-playground/AppBuilderTab.tsx +++ b/mcpjam-inspector/client/src/components/ui-playground/AppBuilderTab.tsx @@ -30,6 +30,7 @@ import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; import { listTools } from "@/lib/apis/mcp-tools-api"; import { generateFormFieldsFromSchema } from "@/lib/tool-form"; import type { MCPServerConfig } from "@mcpjam/sdk/browser"; +import type { WorkspaceHostContextDraft } from "@/lib/client-config"; import { detectEnvironment, detectPlatform } from "@/lib/PosthogUtils"; import { usePostHog } from "posthog-js/react"; import { motion, useReducedMotion } from "framer-motion"; @@ -55,6 +56,7 @@ import { toast } from "sonner"; import type { PlaygroundServerSelectorProps } from "@/components/ActiveServerSelector"; interface AppBuilderTabProps { + activeWorkspaceId?: string | null; serverConfig?: MCPServerConfig; serverName?: string; servers?: Record; @@ -68,6 +70,10 @@ interface AppBuilderTabProps { */ isServerSyncing?: boolean; onConnect?: (formData: ServerFormData) => void; + onSaveHostContext?: ( + workspaceId: string, + hostContext: WorkspaceHostContextDraft, + ) => Promise; ensureServersReady?: ( serverNames: string[], ) => Promise; @@ -89,6 +95,7 @@ const APP_BUILDER_FIRST_RUN_PROMPT = "Draw me an MCP architecture diagram"; const SIDEBAR_EASE: [number, number, number, number] = [0.4, 0, 0.2, 1]; export function AppBuilderTab({ + activeWorkspaceId = null, serverConfig, serverName, servers = {}, @@ -96,6 +103,7 @@ export function AppBuilderTab({ isAuthLoading = false, isServerSyncing = false, onConnect, + onSaveHostContext, ensureServersReady, onOnboardingChange, playgroundServerSelectorProps, @@ -440,7 +448,9 @@ export function AppBuilderTab({ className="min-h-0 min-w-0 overflow-hidden" > Promise; + onSaveHostContext?: ( + workspaceId: string, + hostContext: WorkspaceHostContextDraft, + ) => Promise; enableMultiModelChat?: boolean; onWidgetStateChange?: (toolCallId: string, state: unknown) => void; playgroundServerSelectorProps?: PlaygroundServerSelectorProps; @@ -205,8 +214,10 @@ function InvokingIndicator({ } export function PlaygroundMain({ + activeWorkspaceId = null, serverName, ensureServersReady, + onSaveHostContext, enableMultiModelChat = false, onWidgetStateChange, playgroundServerSelectorProps, @@ -216,7 +227,7 @@ export function PlaygroundMain({ pendingExecution, onExecutionInjected, toolRenderOverrides: externalToolRenderOverrides = {}, - // Device/locale/timezone props are now managed via the store by DisplayContextHeader + // Device/locale/timezone props are now managed via the store by HostContextHeader // These are kept for backward compatibility but are no longer used deviceType: _deviceType = "mobile", onDeviceTypeChange: _onDeviceTypeChange, @@ -293,11 +304,11 @@ export function PlaygroundMain({ const lastMultiLeadIdRef = useRef(null); const prevCompareModelIdsRef = useRef>(new Set()); const multiAddColumnSeqRef = useRef(0); - // Device config from store (managed by DisplayContextHeader) + // Device config from store (managed by HostContextHeader) const storeDeviceType = useUIPlaygroundStore((s) => s.deviceType); const customViewport = useUIPlaygroundStore((s) => s.customViewport); - const hostContext = useClientConfigStore((s) => s.draftConfig?.hostContext); - const patchHostContext = useClientConfigStore((s) => s.patchHostContext); + const hostContext = useHostContextStore((s) => s.draftHostContext); + const patchHostContext = useHostContextStore((s) => s.patchHostContext); // Device config for frame sizing const deviceConfig = useMemo(() => { @@ -434,9 +445,8 @@ export function PlaygroundMain({ (s) => s.themeMode, ) as ThreadThemeMode; const themePreset = usePreferencesStore((s) => s.themePreset); - const [threadThemeOverride, setThreadThemeOverride] = - useState(null); - const effectiveThreadTheme = threadThemeOverride ?? globalThemeMode; + const effectiveThreadTheme = + extractHostTheme(hostContext) ?? globalThemeMode; const chatBg = hostStyle === "chatgpt" ? CHATGPT_CHAT_BACKGROUND @@ -445,19 +455,6 @@ export function PlaygroundMain({ const displayMode = extractEffectiveHostDisplayMode(hostContext) ?? displayModeProp; - // The App Builder theme toggle is intentionally local to the emulated thread - // and composer surface. It should not change MCPJam's global theme or leak - // into other tabs. - const toggleLocalThreadTheme = useCallback(() => { - setThreadThemeOverride((currentThemeOverride) => { - const currentTheme = currentThemeOverride ?? globalThemeMode; - const nextTheme: ThreadThemeMode = - currentTheme === "dark" ? "light" : "dark"; - - return nextTheme === globalThemeMode ? null : nextTheme; - }); - }, [globalThemeMode]); - const handleDisplayModeChange = useCallback( (mode: DisplayMode) => { patchHostContext({ displayMode: mode }); @@ -1504,11 +1501,11 @@ export function PlaygroundMain({ data-testid="playground-main-header" >
-
diff --git a/mcpjam-inspector/client/src/components/ui-playground/SafeAreaEditor.tsx b/mcpjam-inspector/client/src/components/ui-playground/SafeAreaEditor.tsx index bea3e7c94..9086984b0 100644 --- a/mcpjam-inspector/client/src/components/ui-playground/SafeAreaEditor.tsx +++ b/mcpjam-inspector/client/src/components/ui-playground/SafeAreaEditor.tsx @@ -12,7 +12,7 @@ import { type SafeAreaPreset, } from "@/stores/ui-playground-store"; import { cn } from "@/lib/utils"; -import { useClientConfigStore } from "@/stores/client-config-store"; +import { useHostContextStore } from "@/stores/host-context-store"; import { extractHostSafeAreaInsets, type HostSafeAreaInsets, @@ -150,8 +150,8 @@ function InsetInput({ export function SafeAreaEditor() { const safeAreaPreset = useUIPlaygroundStore((s) => s.safeAreaPreset); const setSafeAreaPreset = useUIPlaygroundStore((s) => s.setSafeAreaPreset); - const hostContext = useClientConfigStore((s) => s.draftConfig?.hostContext); - const patchHostContext = useClientConfigStore((s) => s.patchHostContext); + const hostContext = useHostContextStore((s) => s.draftHostContext); + const patchHostContext = useHostContextStore((s) => s.patchHostContext); const safeAreaInsets = extractHostSafeAreaInsets(hostContext); const handleInsetChange = (key: keyof HostSafeAreaInsets, value: number) => { diff --git a/mcpjam-inspector/client/src/components/ui-playground/__tests__/PlaygroundMain.test.tsx b/mcpjam-inspector/client/src/components/ui-playground/__tests__/PlaygroundMain.test.tsx index 5a0107079..bbcf5e791 100644 --- a/mcpjam-inspector/client/src/components/ui-playground/__tests__/PlaygroundMain.test.tsx +++ b/mcpjam-inspector/client/src/components/ui-playground/__tests__/PlaygroundMain.test.tsx @@ -9,6 +9,7 @@ import { } from "@testing-library/react"; import { PlaygroundMain } from "../PlaygroundMain"; import { DEFAULT_CHAT_COMPOSER_PLACEHOLDER } from "@/components/chat-v2/shared/chat-helpers"; +import { useHostContextStore } from "@/stores/host-context-store"; vi.mock("framer-motion", async (importOriginal) => { const actual = await importOriginal(); @@ -426,13 +427,6 @@ vi.mock("@/stores/preferences/preferences-provider", () => ({ selector ? selector(mockPreferencesState) : mockPreferencesState, })); -// Mock theme-utils -const mockUpdateThemeMode = vi.fn(); - -vi.mock("@/lib/theme-utils", () => ({ - updateThemeMode: mockUpdateThemeMode, -})); - // Mock UI Playground store const mockUIPlaygroundStore = { deviceType: "mobile", @@ -458,28 +452,16 @@ vi.mock("@/stores/ui-playground-store", () => ({ }, })); -// Mock DisplayContextHeader which exports PRESET_DEVICE_CONFIGS -vi.mock("@/components/shared/DisplayContextHeader", () => ({ - DisplayContextHeader: ({ +// Mock HostContextHeader which exports PRESET_DEVICE_CONFIGS +vi.mock("@/components/shared/HostContextHeader", () => ({ + HostContextHeader: ({ showThemeToggle, - themeModeOverride, - onThemeToggleOverride, }: { showThemeToggle?: boolean; - themeModeOverride?: string; - onThemeToggleOverride?: () => void; }) => ( -
+
{showThemeToggle ? ( - + ) : null}
), @@ -563,6 +545,19 @@ describe("PlaygroundMain", () => { mockPreferencesState.themeMode = "light"; mockPreferencesState.themePreset = "soft-pop"; mockPreferencesState.hostStyle = "claude"; + useHostContextStore.setState({ + activeWorkspaceId: null, + defaultHostContext: {}, + savedHostContext: undefined, + draftHostContext: {}, + hostContextText: "{}", + hostContextError: null, + isSaving: false, + isDirty: false, + pendingWorkspaceId: null, + pendingSavedHostContext: undefined, + isAwaitingRemoteEcho: false, + }); mockSharedAppState.servers["test-server"] = { connectionStatus: "connected", }; @@ -608,16 +603,14 @@ describe("PlaygroundMain", () => { it("renders device controls", () => { render(); - // Device controls are rendered by DisplayContextHeader (mocked) - expect(screen.getByTestId("display-context-header")).toBeInTheDocument(); + // Device controls are rendered by HostContextHeader (mocked) + expect(screen.getByTestId("host-context-header")).toBeInTheDocument(); }); it("renders theme toggle button", () => { render(); - expect( - screen.getByTestId("display-context-theme-toggle"), - ).toBeInTheDocument(); + expect(screen.getByTestId("host-context-theme-toggle")).toBeInTheDocument(); }); it("passes the requested loading indicator variant to Thread", () => { @@ -640,61 +633,49 @@ describe("PlaygroundMain", () => { }); }); - describe("thread theme override", () => { - it("scopes theme changes to the thread shell and composer surface", () => { + describe("thread theme from host context", () => { + it("scopes hostContext theme changes to the thread shell and composer surface", () => { render(); const header = screen.getByTestId("playground-main-header"); - const displayContextHeader = screen.getByTestId("display-context-header"); const threadShell = screen.getByTestId("playground-thread-shell"); - expect(displayContextHeader).toHaveAttribute( - "data-theme-mode-override", - "light", - ); expect(threadShell).toHaveAttribute("data-host-style", "claude"); expect(threadShell).toHaveAttribute("data-theme-preset", "soft-pop"); expect(threadShell).toHaveAttribute("data-thread-theme", "light"); expect(threadShell).not.toHaveClass("dark"); expect(header).not.toHaveClass("dark"); - fireEvent.click(screen.getByTestId("display-context-theme-toggle")); + act(() => { + useHostContextStore.getState().patchHostContext({ theme: "dark" }); + }); - expect(displayContextHeader).toHaveAttribute( - "data-theme-mode-override", - "dark", - ); expect(threadShell).toHaveAttribute("data-thread-theme", "dark"); expect(threadShell).toHaveClass("dark"); expect(header).not.toHaveClass("dark"); expect(mockPreferencesState.setThemeMode).not.toHaveBeenCalled(); - expect(mockUpdateThemeMode).not.toHaveBeenCalled(); }); - it("resets the local thread theme override on remount", () => { - const firstRender = render(); + it("falls back to the global theme when hostContext.theme is removed", () => { + render(); - fireEvent.click(screen.getByTestId("display-context-theme-toggle")); + act(() => { + useHostContextStore.getState().patchHostContext({ theme: "dark" }); + }); expect(screen.getByTestId("playground-thread-shell")).toHaveAttribute( "data-thread-theme", "dark", ); - firstRender.unmount(); - - render(); + act(() => { + useHostContextStore.getState().setHostContextText("{}"); + }); - expect(screen.getByTestId("display-context-header")).toHaveAttribute( - "data-theme-mode-override", - "light", - ); expect(screen.getByTestId("playground-thread-shell")).toHaveAttribute( "data-thread-theme", "light", ); - expect(screen.getByTestId("playground-thread-shell")).not.toHaveClass( - "dark", - ); + expect(screen.getByTestId("playground-thread-shell")).not.toHaveClass("dark"); }); }); @@ -1589,8 +1570,8 @@ describe("PlaygroundMain", () => { it("renders with default mobile device type", () => { render(); - // Device controls are rendered by DisplayContextHeader (mocked) - expect(screen.getByTestId("display-context-header")).toBeInTheDocument(); + // Device controls are rendered by HostContextHeader (mocked) + expect(screen.getByTestId("host-context-header")).toBeInTheDocument(); }); it("renders with device frame using mobile dimensions", () => { @@ -1606,8 +1587,8 @@ describe("PlaygroundMain", () => { it("shows display context header for locale controls", () => { render(); - // Locale controls are rendered by DisplayContextHeader (mocked) - expect(screen.getByTestId("display-context-header")).toBeInTheDocument(); + // Locale controls are rendered by HostContextHeader (mocked) + expect(screen.getByTestId("host-context-header")).toBeInTheDocument(); }); }); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-app-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-app-state.test.tsx index 072cea20a..f49e6032b 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-app-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-app-state.test.tsx @@ -28,6 +28,7 @@ const { handleCreateWorkspace: vi.fn(), handleUpdateWorkspace: vi.fn(), handleUpdateClientConfig: vi.fn(), + handleUpdateHostContext: vi.fn(), handleDeleteWorkspace: vi.fn(), handleDuplicateWorkspace: vi.fn(), handleSetDefaultWorkspace: vi.fn(), diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx index cded5f53f..256553254 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx @@ -4,6 +4,7 @@ import type { AppState, AppAction } from "@/state/app-types"; import { CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE } from "@/lib/client-config"; import type { WorkspaceClientConfig } from "@/lib/client-config"; import { useClientConfigStore } from "@/stores/client-config-store"; +import { useHostContextStore } from "@/stores/host-context-store"; import { useServerState } from "../use-server-state"; const { @@ -186,16 +187,29 @@ describe("useServerState OAuth callback failures", () => { defaultConfig: null, savedConfig: undefined, draftConfig: null, + connectionDefaultsText: "{}", clientCapabilitiesText: "{}", - hostContextText: "{}", clientCapabilitiesError: null, - hostContextError: null, + connectionDefaultsError: null, isSaving: false, isDirty: false, pendingWorkspaceId: null, pendingSavedConfig: undefined, isAwaitingRemoteEcho: false, }); + useHostContextStore.setState({ + activeWorkspaceId: null, + defaultHostContext: {}, + savedHostContext: undefined, + draftHostContext: {}, + hostContextText: "{}", + hostContextError: null, + isSaving: false, + isDirty: false, + pendingWorkspaceId: null, + pendingSavedHostContext: undefined, + isAwaitingRemoteEcho: false, + }); getStoredTokensMock.mockReturnValue(undefined); testConnectionMock.mockResolvedValue({ success: true, @@ -708,16 +722,29 @@ describe("useServerState auth mode regressions", () => { defaultConfig: null, savedConfig: undefined, draftConfig: null, + connectionDefaultsText: "{}", clientCapabilitiesText: "{}", - hostContextText: "{}", clientCapabilitiesError: null, - hostContextError: null, + connectionDefaultsError: null, isSaving: false, isDirty: false, pendingWorkspaceId: null, pendingSavedConfig: undefined, isAwaitingRemoteEcho: false, }); + useHostContextStore.setState({ + activeWorkspaceId: null, + defaultHostContext: {}, + savedHostContext: undefined, + draftHostContext: {}, + hostContextText: "{}", + hostContextError: null, + isSaving: false, + isDirty: false, + pendingWorkspaceId: null, + pendingSavedHostContext: undefined, + isAwaitingRemoteEcho: false, + }); getStoredTokensMock.mockReturnValue(undefined); testConnectionMock.mockResolvedValue({ success: true, @@ -847,16 +874,29 @@ describe("useServerState authenticated fallback persistence", () => { defaultConfig: null, savedConfig: undefined, draftConfig: null, + connectionDefaultsText: "{}", clientCapabilitiesText: "{}", - hostContextText: "{}", clientCapabilitiesError: null, - hostContextError: null, + connectionDefaultsError: null, isSaving: false, isDirty: false, pendingWorkspaceId: null, pendingSavedConfig: undefined, isAwaitingRemoteEcho: false, }); + useHostContextStore.setState({ + activeWorkspaceId: null, + defaultHostContext: {}, + savedHostContext: undefined, + draftHostContext: {}, + hostContextText: "{}", + hostContextError: null, + isSaving: false, + isDirty: false, + pendingWorkspaceId: null, + pendingSavedHostContext: undefined, + isAwaitingRemoteEcho: false, + }); getStoredTokensMock.mockReturnValue(undefined); testConnectionMock.mockResolvedValue({ success: true, diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx index fa2fb4c2e..eb6f8b7e4 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx @@ -4,7 +4,11 @@ import { toast } from "sonner"; import type { AppAction, AppState, Workspace } from "@/state/app-types"; import { useWorkspaceState } from "../use-workspace-state"; import { useClientConfigStore } from "@/stores/client-config-store"; -import type { WorkspaceClientConfig } from "@/lib/client-config"; +import { useHostContextStore } from "@/stores/host-context-store"; +import type { + WorkspaceClientConfig, + WorkspaceConnectionConfigDraft, +} from "@/lib/client-config"; const { createWorkspaceMock, @@ -212,16 +216,29 @@ describe("useWorkspaceState automatic workspace creation", () => { defaultConfig: null, savedConfig: undefined, draftConfig: null, + connectionDefaultsText: "{}", clientCapabilitiesText: "{}", - hostContextText: "{}", clientCapabilitiesError: null, - hostContextError: null, + connectionDefaultsError: null, isSaving: false, isDirty: false, pendingWorkspaceId: null, pendingSavedConfig: undefined, isAwaitingRemoteEcho: false, }); + useHostContextStore.setState({ + activeWorkspaceId: null, + defaultHostContext: {}, + savedHostContext: undefined, + draftHostContext: {}, + hostContextText: "{}", + hostContextError: null, + isSaving: false, + isDirty: false, + pendingWorkspaceId: null, + pendingSavedHostContext: undefined, + isAwaitingRemoteEcho: false, + }); }); it("ensures one initial workspace per empty organization and dedupes rerenders", async () => { @@ -585,13 +602,25 @@ describe("useWorkspaceState automatic workspace creation", () => { }); it("keeps authenticated client-config saves pending until the remote echo arrives", async () => { - const savedConfig: WorkspaceClientConfig = { + const savedConfig: WorkspaceConnectionConfigDraft = { version: 1, + connectionDefaults: { + headers: {}, + requestTimeout: 10000, + }, clientCapabilities: { experimental: { inspectorProfile: true, }, }, + }; + const expectedPersistedClientConfig: WorkspaceClientConfig = { + version: 1, + connectionDefaults: { + headers: {}, + requestTimeout: 10000, + }, + clientCapabilities: savedConfig.clientCapabilities, hostContext: {}, }; @@ -624,7 +653,7 @@ describe("useWorkspaceState automatic workspace creation", () => { await waitFor(() => { expect(updateClientConfigMock).toHaveBeenCalledWith({ workspaceId: "remote-1", - clientConfig: savedConfig, + clientConfig: expectedPersistedClientConfig, }); }); @@ -634,7 +663,7 @@ describe("useWorkspaceState automatic workspace creation", () => { workspaceQueryState.allWorkspaces = [ { ...workspaceQueryState.allWorkspaces[0], - clientConfig: savedConfig, + clientConfig: expectedPersistedClientConfig, }, ]; workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; @@ -1045,14 +1074,17 @@ describe("useWorkspaceState automatic workspace creation", () => { it("fails authenticated client-config saves when the remote echo times out", async () => { vi.useFakeTimers(); - const savedConfig: WorkspaceClientConfig = { + const savedConfig: WorkspaceConnectionConfigDraft = { version: 1, + connectionDefaults: { + headers: {}, + requestTimeout: 10000, + }, clientCapabilities: { experimental: { inspectorProfile: true, }, }, - hostContext: {}, }; workspaceQueryState.allWorkspaces = [ @@ -1378,13 +1410,25 @@ describe("useWorkspaceState automatic workspace creation", () => { }); it("rejects authenticated client-config saves when the hook unmounts mid-sync", async () => { - const savedConfig: WorkspaceClientConfig = { + const savedConfig: WorkspaceConnectionConfigDraft = { version: 1, + connectionDefaults: { + headers: {}, + requestTimeout: 10000, + }, clientCapabilities: { experimental: { inspectorProfile: true, }, }, + }; + const expectedPersistedClientConfig: WorkspaceClientConfig = { + version: 1, + connectionDefaults: { + headers: {}, + requestTimeout: 10000, + }, + clientCapabilities: savedConfig.clientCapabilities, hostContext: {}, }; @@ -1415,7 +1459,7 @@ describe("useWorkspaceState automatic workspace creation", () => { await waitFor(() => { expect(updateClientConfigMock).toHaveBeenCalledWith({ workspaceId: "remote-1", - clientConfig: savedConfig, + clientConfig: expectedPersistedClientConfig, }); }); diff --git a/mcpjam-inspector/client/src/hooks/use-app-state.ts b/mcpjam-inspector/client/src/hooks/use-app-state.ts index 6ac11bcc8..05919d1e9 100644 --- a/mcpjam-inspector/client/src/hooks/use-app-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-app-state.ts @@ -596,6 +596,7 @@ export function useAppState({ handleCreateWorkspace: workspaceState.handleCreateWorkspace, handleUpdateWorkspace: workspaceState.handleUpdateWorkspace, handleUpdateClientConfig: workspaceState.handleUpdateClientConfig, + handleUpdateHostContext: workspaceState.handleUpdateHostContext, handleDeleteWorkspace: workspaceState.handleDeleteWorkspace, handleLeaveWorkspace, handleDuplicateWorkspace: workspaceState.handleDuplicateWorkspace, diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts index 83bec4803..067dcb14c 100644 --- a/mcpjam-inspector/client/src/hooks/use-server-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts @@ -42,7 +42,7 @@ import { } from "@/lib/apis/web/context"; import type { OAuthTestProfile } from "@/lib/oauth/profile"; import { authFetch } from "@/lib/session-token"; -import { useClientConfigStore } from "@/stores/client-config-store"; +import { useWorkspaceClientConfigSyncPending } from "./use-workspace-client-config-sync-pending"; import { useUIPlaygroundStore } from "@/stores/ui-playground-store"; import { useServerMutations, type RemoteServer } from "./useWorkspaces"; import { @@ -523,11 +523,8 @@ export function useServerState({ latestEffectiveServersRef.current = effectiveServers; }, [effectiveServers]); - const isClientConfigSyncPending = useClientConfigStore( - (state) => - state.isAwaitingRemoteEcho && - state.pendingWorkspaceId === effectiveActiveWorkspaceId, - ); + const isClientConfigSyncPending = + useWorkspaceClientConfigSyncPending(effectiveActiveWorkspaceId); const workspaceConnectionDefaults = useMemo( () => getEffectiveWorkspaceConnectionDefaults(activeWorkspace?.clientConfig), diff --git a/mcpjam-inspector/client/src/hooks/use-workspace-client-config-sync-pending.ts b/mcpjam-inspector/client/src/hooks/use-workspace-client-config-sync-pending.ts new file mode 100644 index 000000000..794daaf89 --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/use-workspace-client-config-sync-pending.ts @@ -0,0 +1,17 @@ +import { useClientConfigStore } from "@/stores/client-config-store"; +import { useHostContextStore } from "@/stores/host-context-store"; + +export function useWorkspaceClientConfigSyncPending( + workspaceId: string | null | undefined, +) { + const connectionSyncPending = useClientConfigStore( + (state) => + state.isAwaitingRemoteEcho && state.pendingWorkspaceId === workspaceId, + ); + const hostContextSyncPending = useHostContextStore( + (state) => + state.isAwaitingRemoteEcho && state.pendingWorkspaceId === workspaceId, + ); + + return connectionSyncPending || hostContextSyncPending; +} diff --git a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts index 4208286eb..c47e40073 100644 --- a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts @@ -18,11 +18,17 @@ import { serializeServersForSharing, } from "@/lib/workspace-serialization"; import { + composeWorkspaceClientConfig, + pickWorkspaceConnectionConfig, + pickWorkspaceHostContext, stableStringifyJson, type WorkspaceClientConfig, + type WorkspaceConnectionConfigDraft, + type WorkspaceHostContextDraft, } from "@/lib/client-config"; import { getBillingErrorMessage } from "@/lib/billing-entitlements"; import { useClientConfigStore } from "@/stores/client-config-store"; +import { useHostContextStore } from "@/stores/host-context-store"; import { useOrganizationBillingStatus } from "./useOrganizationBilling"; const CLIENT_CONFIG_SYNC_ECHO_TIMEOUT_MS = 10000; @@ -45,6 +51,16 @@ interface PendingClientConfigSync { timeoutId: ReturnType; } +interface ClientConfigSaveController { + beginSave: (input: { + workspaceId: string; + savedConfig: T | undefined; + awaitRemoteEcho: boolean; + }) => void; + markSaved: (savedConfig: T | undefined) => void; + failSave: () => void; +} + interface LoggerLike { info: (message: string, meta?: Record) => void; warn: (message: string, meta?: Record) => void; @@ -826,17 +842,23 @@ export function useWorkspaceState({ ], ); - const handleUpdateClientConfig = useCallback( - async ( - workspaceId: string, - clientConfig: WorkspaceClientConfig | undefined, - ): Promise => { - const clientConfigStore = useClientConfigStore.getState(); + const persistWorkspaceClientConfig = useCallback( + async ({ + workspaceId, + clientConfig, + savedSlice, + controller, + }: { + workspaceId: string; + clientConfig: WorkspaceClientConfig | undefined; + savedSlice: T | undefined; + controller: ClientConfigSaveController; + }): Promise => { const awaitRemoteEcho = isAuthenticated && !useLocalFallback; - clientConfigStore.beginSave({ + controller.beginSave({ workspaceId, - savedConfig: clientConfig, + savedConfig: savedSlice, awaitRemoteEcho, }); @@ -871,7 +893,7 @@ export function useWorkspaceState({ await remoteEchoPromise; } catch (error) { clearPendingClientConfigSync(); - clientConfigStore.failSave(); + controller.failSave(); const errorMessage = error instanceof Error ? error.message : "Unknown error"; logger.error("Failed to update workspace client config", { @@ -889,18 +911,119 @@ export function useWorkspaceState({ workspaceId, updates: { clientConfig }, }); - clientConfigStore.markSaved(clientConfig); + controller.markSaved(savedSlice); }, [ isAuthenticated, useLocalFallback, - convexUpdateClientConfig, clearPendingClientConfigSync, + convexUpdateClientConfig, logger, dispatch, ], ); + const resolvePersistedConnectionConfig = useCallback( + (workspaceId: string): WorkspaceConnectionConfigDraft => { + const clientConfigStore = useClientConfigStore.getState(); + const workspaceClientConfig = effectiveWorkspaces[workspaceId]?.clientConfig; + + return ( + clientConfigStore.savedConfig ?? + clientConfigStore.defaultConfig ?? + pickWorkspaceConnectionConfig(workspaceClientConfig) + ); + }, + [effectiveWorkspaces], + ); + + const resolvePersistedHostContext = useCallback( + (workspaceId: string): WorkspaceHostContextDraft => { + const hostContextStore = useHostContextStore.getState(); + const workspaceClientConfig = effectiveWorkspaces[workspaceId]?.clientConfig; + + return ( + hostContextStore.savedHostContext ?? + hostContextStore.defaultHostContext ?? + pickWorkspaceHostContext(workspaceClientConfig) + ); + }, + [effectiveWorkspaces], + ); + + const handleUpdateClientConfig = useCallback( + async ( + workspaceId: string, + connectionConfig: WorkspaceConnectionConfigDraft | undefined, + ): Promise => { + const clientConfigStore = useClientConfigStore.getState(); + const workspaceClientConfig = effectiveWorkspaces[workspaceId]?.clientConfig; + const connectionConfigToPersist = + connectionConfig ?? + clientConfigStore.draftConfig ?? + resolvePersistedConnectionConfig(workspaceId); + const clientConfig = composeWorkspaceClientConfig({ + connectionConfig: connectionConfigToPersist, + hostContext: resolvePersistedHostContext(workspaceId), + fallback: workspaceClientConfig ?? null, + }); + + await persistWorkspaceClientConfig({ + workspaceId, + clientConfig, + savedSlice: connectionConfigToPersist, + controller: clientConfigStore, + }); + }, + [ + effectiveWorkspaces, + persistWorkspaceClientConfig, + resolvePersistedConnectionConfig, + resolvePersistedHostContext, + ], + ); + + const handleUpdateHostContext = useCallback( + async ( + workspaceId: string, + hostContext: WorkspaceHostContextDraft | undefined, + ): Promise => { + const hostContextStore = useHostContextStore.getState(); + const workspaceClientConfig = effectiveWorkspaces[workspaceId]?.clientConfig; + const hostContextToPersist = + hostContext ?? + hostContextStore.draftHostContext ?? + resolvePersistedHostContext(workspaceId); + const clientConfig = composeWorkspaceClientConfig({ + connectionConfig: resolvePersistedConnectionConfig(workspaceId), + hostContext: hostContextToPersist, + fallback: workspaceClientConfig ?? null, + }); + + await persistWorkspaceClientConfig({ + workspaceId, + clientConfig, + savedSlice: hostContextToPersist, + controller: { + beginSave: ({ workspaceId, savedConfig, awaitRemoteEcho }) => + hostContextStore.beginSave({ + workspaceId, + savedHostContext: savedConfig, + awaitRemoteEcho, + }), + markSaved: (savedConfig) => hostContextStore.markSaved(savedConfig), + failSave: () => hostContextStore.failSave(), + }, + }); + }, + [ + effectiveWorkspaces, + persistWorkspaceClientConfig, + resolvePersistedConnectionConfig, + resolvePersistedHostContext, + ], + ); + const handleDeleteWorkspace = useCallback( async (workspaceId: string): Promise => { // If deleting the active workspace, switch to another first @@ -1198,6 +1321,7 @@ export function useWorkspaceState({ handleCreateWorkspace, handleUpdateWorkspace, handleUpdateClientConfig, + handleUpdateHostContext, handleDeleteWorkspace, handleDuplicateWorkspace, handleSetDefaultWorkspace, diff --git a/mcpjam-inspector/client/src/lib/client-config.ts b/mcpjam-inspector/client/src/lib/client-config.ts index fed103290..c20e79ef2 100644 --- a/mcpjam-inspector/client/src/lib/client-config.ts +++ b/mcpjam-inspector/client/src/lib/client-config.ts @@ -12,6 +12,14 @@ export type WorkspaceClientConfig = { hostContext: Record; }; +export type WorkspaceConnectionConfigDraft = { + version: 1; + connectionDefaults?: WorkspaceConnectionDefaults; + clientCapabilities: Record; +}; + +export type WorkspaceHostContextDraft = Record; + export type WorkspaceConnectionDefaults = { headers: Record; requestTimeout: number; @@ -61,14 +69,25 @@ export function buildDefaultWorkspaceConnectionDefaults(): WorkspaceConnectionDe }; } -export function buildDefaultHostContext(args: { +export function buildDefaultWorkspaceConnectionConfig(): WorkspaceConnectionConfigDraft { + return { + version: 1, + connectionDefaults: buildDefaultWorkspaceConnectionDefaults(), + clientCapabilities: getDefaultClientCapabilities() as Record< + string, + unknown + >, + }; +} + +export function buildDefaultWorkspaceHostContext(args: { theme: "light" | "dark"; displayMode: HostDisplayMode; locale: string; timeZone: string; deviceCapabilities: HostDeviceCapabilities; safeAreaInsets: HostSafeAreaInsets; -}): Record { +}): WorkspaceHostContextDraft { return { theme: args.theme, displayMode: args.displayMode, @@ -80,6 +99,8 @@ export function buildDefaultHostContext(args: { }; } +export const buildDefaultHostContext = buildDefaultWorkspaceHostContext; + export function buildDefaultWorkspaceClientConfig(args: { theme: "light" | "dark"; displayMode: HostDisplayMode; @@ -88,15 +109,10 @@ export function buildDefaultWorkspaceClientConfig(args: { deviceCapabilities: HostDeviceCapabilities; safeAreaInsets: HostSafeAreaInsets; }): WorkspaceClientConfig { - return { - version: 1, - connectionDefaults: buildDefaultWorkspaceConnectionDefaults(), - clientCapabilities: getDefaultClientCapabilities() as Record< - string, - unknown - >, - hostContext: buildDefaultHostContext(args), - }; + return composeWorkspaceClientConfig({ + connectionConfig: buildDefaultWorkspaceConnectionConfig(), + hostContext: buildDefaultWorkspaceHostContext(args), + }); } export function isWorkspaceClientConfig( @@ -124,14 +140,83 @@ export function sanitizeWorkspaceClientConfig( return fallback; } + return composeWorkspaceClientConfig({ + connectionConfig: { + version: 1, + connectionDefaults: sanitizeWorkspaceConnectionDefaults( + value.connectionDefaults, + fallback.connectionDefaults, + ), + clientCapabilities: sanitizeWorkspaceClientCapabilities( + value.clientCapabilities, + fallback.clientCapabilities, + ), + }, + hostContext: sanitizeWorkspaceHostContext(value.hostContext, fallback.hostContext), + }); +} + +export function sanitizeWorkspaceClientCapabilities( + value: unknown, + fallback: Record = getDefaultClientCapabilities() as Record< + string, + unknown + >, +): Record { + return isRecord(value) ? value : fallback; +} + +export function sanitizeWorkspaceHostContext( + value: unknown, + fallback: WorkspaceHostContextDraft = {}, +): WorkspaceHostContextDraft { + return isRecord(value) ? value : fallback; +} + +export function pickWorkspaceConnectionConfig( + workspaceClientConfig?: WorkspaceClientConfig | null, +): WorkspaceConnectionConfigDraft { + return { + version: 1, + connectionDefaults: sanitizeWorkspaceConnectionDefaults( + workspaceClientConfig?.connectionDefaults, + ), + clientCapabilities: sanitizeWorkspaceClientCapabilities( + workspaceClientConfig?.clientCapabilities, + ), + }; +} + +export function pickWorkspaceHostContext( + workspaceClientConfig?: WorkspaceClientConfig | null, + fallback: WorkspaceHostContextDraft = {}, +): WorkspaceHostContextDraft { + return sanitizeWorkspaceHostContext(workspaceClientConfig?.hostContext, fallback); +} + +export function composeWorkspaceClientConfig(args: { + connectionConfig?: WorkspaceConnectionConfigDraft | null; + hostContext?: WorkspaceHostContextDraft | null; + fallback?: WorkspaceClientConfig | null; +}): WorkspaceClientConfig { + const fallback = args.fallback ?? null; + const fallbackConnectionConfig = pickWorkspaceConnectionConfig(fallback); + const fallbackHostContext = pickWorkspaceHostContext(fallback); + + const connectionConfig = args.connectionConfig ?? fallbackConnectionConfig; + const hostContext = args.hostContext ?? fallbackHostContext; + return { version: 1, connectionDefaults: sanitizeWorkspaceConnectionDefaults( - value.connectionDefaults, - fallback.connectionDefaults, + connectionConfig.connectionDefaults, + fallbackConnectionConfig.connectionDefaults, ), - clientCapabilities: value.clientCapabilities, - hostContext: value.hostContext, + clientCapabilities: sanitizeWorkspaceClientCapabilities( + connectionConfig.clientCapabilities, + fallbackConnectionConfig.clientCapabilities, + ), + hostContext: sanitizeWorkspaceHostContext(hostContext, fallbackHostContext), }; } @@ -412,7 +497,7 @@ function normalizeWorkspaceConnectionHeaders( key.toLowerCase() !== "authorization" && typeof value === "string", ), - ); + ) as Record; } function normalizeExplicitConnectionHeaders( @@ -426,7 +511,7 @@ function normalizeExplicitConnectionHeaders( Object.entries(headers).filter( ([key, value]) => key.trim() !== "" && typeof value === "string", ), - ); + ) as Record; } function normalizeWorkspaceRequestTimeout( diff --git a/mcpjam-inspector/client/src/stores/client-config-store.ts b/mcpjam-inspector/client/src/stores/client-config-store.ts index 59d303e41..e15703519 100644 --- a/mcpjam-inspector/client/src/stores/client-config-store.ts +++ b/mcpjam-inspector/client/src/stores/client-config-store.ts @@ -1,44 +1,43 @@ import { create } from "zustand"; import { + buildDefaultWorkspaceConnectionConfig, buildDefaultWorkspaceConnectionDefaults, + pickWorkspaceConnectionConfig, stableStringifyJson, - type WorkspaceClientConfig, + type WorkspaceConnectionConfigDraft, type WorkspaceConnectionDefaults, } from "@/lib/client-config"; -type JsonSection = "connectionDefaults" | "clientCapabilities" | "hostContext"; +type JsonSection = "connectionDefaults" | "clientCapabilities"; interface ClientConfigStoreState { activeWorkspaceId: string | null; - defaultConfig: WorkspaceClientConfig | null; - savedConfig: WorkspaceClientConfig | undefined; - draftConfig: WorkspaceClientConfig | null; + defaultConfig: WorkspaceConnectionConfigDraft | null; + savedConfig: WorkspaceConnectionConfigDraft | undefined; + draftConfig: WorkspaceConnectionConfigDraft | null; connectionDefaultsText: string; clientCapabilitiesText: string; - hostContextText: string; connectionDefaultsError: string | null; clientCapabilitiesError: string | null; - hostContextError: string | null; isSaving: boolean; isDirty: boolean; pendingWorkspaceId: string | null; - pendingSavedConfig: WorkspaceClientConfig | undefined; + pendingSavedConfig: WorkspaceConnectionConfigDraft | undefined; isAwaitingRemoteEcho: boolean; loadWorkspaceConfig: (input: { workspaceId: string | null; - defaultConfig: WorkspaceClientConfig | null; - savedConfig?: WorkspaceClientConfig; + defaultConfig: WorkspaceConnectionConfigDraft | null; + savedConfig?: WorkspaceConnectionConfigDraft; }) => void; setSectionText: (section: JsonSection, text: string) => void; - patchHostContext: (patch: Record) => void; resetSectionToDefault: (section: JsonSection) => void; resetToBaseline: () => void; beginSave: (input: { workspaceId: string; - savedConfig: WorkspaceClientConfig | undefined; + savedConfig: WorkspaceConnectionConfigDraft | undefined; awaitRemoteEcho: boolean; }) => void; - markSaved: (savedConfig: WorkspaceClientConfig | undefined) => void; + markSaved: (savedConfig: WorkspaceConnectionConfigDraft | undefined) => void; failSave: () => void; } @@ -50,7 +49,6 @@ function createInitialState(): Omit< ClientConfigStoreState, | "loadWorkspaceConfig" | "setSectionText" - | "patchHostContext" | "resetSectionToDefault" | "resetToBaseline" | "beginSave" @@ -64,10 +62,8 @@ function createInitialState(): Omit< draftConfig: null, connectionDefaultsText: "{}", clientCapabilitiesText: "{}", - hostContextText: "{}", connectionDefaultsError: null, clientCapabilitiesError: null, - hostContextError: null, isSaving: false, isDirty: false, pendingWorkspaceId: null, @@ -99,35 +95,27 @@ function computeDirtyState( } function normalizeConfigForEditing( - config: WorkspaceClientConfig | null | undefined, - defaultConfig: WorkspaceClientConfig | null, -): WorkspaceClientConfig | null | undefined { + config: WorkspaceConnectionConfigDraft | null | undefined, +): WorkspaceConnectionConfigDraft | null | undefined { if (!config) { return config; } - return { - ...config, - connectionDefaults: - config.connectionDefaults ?? - defaultConfig?.connectionDefaults ?? - buildDefaultWorkspaceConnectionDefaults(), - }; + return pickWorkspaceConnectionConfig({ + version: 1, + connectionDefaults: config.connectionDefaults, + clientCapabilities: config.clientCapabilities, + hostContext: {}, + }); } function resetFromConfig( workspaceId: string | null, - defaultConfig: WorkspaceClientConfig | null, - savedConfig?: WorkspaceClientConfig, + defaultConfig: WorkspaceConnectionConfigDraft | null, + savedConfig?: WorkspaceConnectionConfigDraft, ) { - const normalizedDefaultConfig = normalizeConfigForEditing( - defaultConfig, - defaultConfig, - ) ?? null; - const normalizedSavedConfig = normalizeConfigForEditing( - savedConfig, - normalizedDefaultConfig, - ); + const normalizedDefaultConfig = normalizeConfigForEditing(defaultConfig) ?? null; + const normalizedSavedConfig = normalizeConfigForEditing(savedConfig); const baseline = normalizedSavedConfig ?? normalizedDefaultConfig; return { activeWorkspaceId: workspaceId, @@ -138,10 +126,8 @@ function resetFromConfig( baseline?.connectionDefaults ?? buildDefaultWorkspaceConnectionDefaults(), ), clientCapabilitiesText: stringifyJson(baseline?.clientCapabilities ?? {}), - hostContextText: stringifyJson(baseline?.hostContext ?? {}), connectionDefaultsError: null, clientCapabilitiesError: null, - hostContextError: null, pendingWorkspaceId: null, pendingSavedConfig: undefined, isAwaitingRemoteEcho: false, @@ -156,7 +142,7 @@ function isPendingRemoteEchoMatch( "isAwaitingRemoteEcho" | "pendingWorkspaceId" | "pendingSavedConfig" >, workspaceId: string | null, - savedConfig?: WorkspaceClientConfig, + savedConfig?: WorkspaceConnectionConfigDraft, ) { return ( state.isAwaitingRemoteEcho && @@ -229,11 +215,6 @@ function getSectionFieldNames(section: JsonSection) { textField: "clientCapabilitiesText" as const, errorField: "clientCapabilitiesError" as const, }; - case "hostContext": - return { - textField: "hostContextText" as const, - errorField: "hostContextError" as const, - }; } } @@ -246,7 +227,7 @@ function setSectionValue( return state; } - const nextDraftConfig: WorkspaceClientConfig = { + const nextDraftConfig: WorkspaceConnectionConfigDraft = { ...state.draftConfig, [section]: section === "connectionDefaults" @@ -270,12 +251,8 @@ export const useClientConfigStore = create( loadWorkspaceConfig: ({ workspaceId, defaultConfig, savedConfig }) => { const state = get(); - const normalizedDefaultConfig = - normalizeConfigForEditing(defaultConfig, defaultConfig) ?? null; - const normalizedSavedConfig = normalizeConfigForEditing( - savedConfig, - normalizedDefaultConfig, - ); + const normalizedDefaultConfig = normalizeConfigForEditing(defaultConfig) ?? null; + const normalizedSavedConfig = normalizeConfigForEditing(savedConfig); const shouldApplyPendingRemoteEcho = isPendingRemoteEchoMatch( state, workspaceId, @@ -289,6 +266,7 @@ export const useClientConfigStore = create( ) { return; } + const sameWorkspace = state.activeWorkspaceId === workspaceId; const sameDefault = stableStringifyJson(state.defaultConfig) === @@ -339,33 +317,10 @@ export const useClientConfigStore = create( }); }, - patchHostContext: (patch) => { - set((state) => { - const currentHostContext = state.draftConfig?.hostContext ?? {}; - const nextHostContext = { - ...currentHostContext, - ...patch, - }; - const nextState = setSectionValue( - state, - "hostContext", - nextHostContext, - ); - return { - ...nextState, - hostContextText: stringifyJson(nextHostContext), - hostContextError: null, - }; - }); - }, - resetSectionToDefault: (section) => { set((state) => { - const defaultConfig = state.defaultConfig; - if (!defaultConfig) { - return {}; - } - + const defaultConfig = + state.defaultConfig ?? buildDefaultWorkspaceConnectionConfig(); const nextValue = defaultConfig[section]; const nextState = setSectionValue(state, section, nextValue); const { textField, errorField } = getSectionFieldNames(section); diff --git a/mcpjam-inspector/client/src/stores/host-context-store.ts b/mcpjam-inspector/client/src/stores/host-context-store.ts new file mode 100644 index 000000000..53ebbca55 --- /dev/null +++ b/mcpjam-inspector/client/src/stores/host-context-store.ts @@ -0,0 +1,285 @@ +import { create } from "zustand"; +import { + pickWorkspaceHostContext, + stableStringifyJson, + type WorkspaceHostContextDraft, +} from "@/lib/client-config"; + +interface HostContextStoreState { + activeWorkspaceId: string | null; + defaultHostContext: WorkspaceHostContextDraft; + savedHostContext: WorkspaceHostContextDraft | undefined; + draftHostContext: WorkspaceHostContextDraft; + hostContextText: string; + hostContextError: string | null; + isSaving: boolean; + isDirty: boolean; + pendingWorkspaceId: string | null; + pendingSavedHostContext: WorkspaceHostContextDraft | undefined; + isAwaitingRemoteEcho: boolean; + loadWorkspaceHostContext: (input: { + workspaceId: string | null; + defaultHostContext: WorkspaceHostContextDraft; + savedHostContext?: WorkspaceHostContextDraft; + }) => void; + setHostContextText: (text: string) => void; + patchHostContext: (patch: Record) => void; + resetToBaseline: () => void; + beginSave: (input: { + workspaceId: string; + savedHostContext: WorkspaceHostContextDraft | undefined; + awaitRemoteEcho: boolean; + }) => void; + markSaved: ( + savedHostContext: WorkspaceHostContextDraft | undefined, + ) => void; + failSave: () => void; +} + +function stringifyJson(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +function createInitialState(): Omit< + HostContextStoreState, + | "loadWorkspaceHostContext" + | "setHostContextText" + | "patchHostContext" + | "resetToBaseline" + | "beginSave" + | "markSaved" + | "failSave" +> { + return { + activeWorkspaceId: null, + defaultHostContext: {}, + savedHostContext: undefined, + draftHostContext: {}, + hostContextText: "{}", + hostContextError: null, + isSaving: false, + isDirty: false, + pendingWorkspaceId: null, + pendingSavedHostContext: undefined, + isAwaitingRemoteEcho: false, + }; +} + +function computeBaselineHostContext( + state: Pick, +) { + return state.savedHostContext ?? state.defaultHostContext; +} + +function computeDirtyState( + state: Pick< + HostContextStoreState, + "defaultHostContext" | "savedHostContext" | "draftHostContext" + >, +) { + return ( + stableStringifyJson(state.draftHostContext) !== + stableStringifyJson(computeBaselineHostContext(state)) + ); +} + +function resetFromHostContext( + workspaceId: string | null, + defaultHostContext: WorkspaceHostContextDraft, + savedHostContext?: WorkspaceHostContextDraft, +) { + const normalizedDefaultHostContext = pickWorkspaceHostContext( + { version: 1, clientCapabilities: {}, hostContext: defaultHostContext }, + {}, + ); + const normalizedSavedHostContext = + savedHostContext === undefined + ? undefined + : pickWorkspaceHostContext( + { version: 1, clientCapabilities: {}, hostContext: savedHostContext }, + {}, + ); + const baseline = normalizedSavedHostContext ?? normalizedDefaultHostContext; + + return { + activeWorkspaceId: workspaceId, + defaultHostContext: normalizedDefaultHostContext, + savedHostContext: normalizedSavedHostContext, + draftHostContext: baseline, + hostContextText: stringifyJson(baseline), + hostContextError: null, + pendingWorkspaceId: null, + pendingSavedHostContext: undefined, + isAwaitingRemoteEcho: false, + isSaving: false, + isDirty: false, + }; +} + +function isPendingRemoteEchoMatch( + state: Pick< + HostContextStoreState, + "isAwaitingRemoteEcho" | "pendingWorkspaceId" | "pendingSavedHostContext" + >, + workspaceId: string | null, + savedHostContext?: WorkspaceHostContextDraft, +) { + return ( + state.isAwaitingRemoteEcho && + state.pendingWorkspaceId === workspaceId && + stableStringifyJson(state.pendingSavedHostContext) === + stableStringifyJson(savedHostContext) + ); +} + +function parseRecordJson(text: string): WorkspaceHostContextDraft { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Value must be a JSON object"); + } + return parsed as WorkspaceHostContextDraft; +} + +export const useHostContextStore = create((set, get) => ({ + ...createInitialState(), + + loadWorkspaceHostContext: ({ + workspaceId, + defaultHostContext, + savedHostContext, + }) => { + const state = get(); + const normalizedDefaultHostContext = pickWorkspaceHostContext( + { version: 1, clientCapabilities: {}, hostContext: defaultHostContext }, + {}, + ); + const normalizedSavedHostContext = + savedHostContext === undefined + ? undefined + : pickWorkspaceHostContext( + { version: 1, clientCapabilities: {}, hostContext: savedHostContext }, + {}, + ); + const shouldApplyPendingRemoteEcho = isPendingRemoteEchoMatch( + state, + workspaceId, + normalizedSavedHostContext, + ); + + if ( + state.isDirty && + state.activeWorkspaceId === workspaceId && + !shouldApplyPendingRemoteEcho + ) { + return; + } + + const sameWorkspace = state.activeWorkspaceId === workspaceId; + const sameDefault = + stableStringifyJson(state.defaultHostContext) === + stableStringifyJson(normalizedDefaultHostContext); + const sameSaved = + stableStringifyJson(state.savedHostContext) === + stableStringifyJson(normalizedSavedHostContext); + + if ( + sameWorkspace && + sameDefault && + sameSaved && + !shouldApplyPendingRemoteEcho + ) { + return; + } + + set( + resetFromHostContext( + workspaceId, + normalizedDefaultHostContext, + normalizedSavedHostContext, + ), + ); + }, + + setHostContextText: (text) => { + set((state) => { + try { + const nextHostContext = parseRecordJson(text); + return { + draftHostContext: nextHostContext, + hostContextText: text, + hostContextError: null, + isDirty: computeDirtyState({ + defaultHostContext: state.defaultHostContext, + savedHostContext: state.savedHostContext, + draftHostContext: nextHostContext, + }), + }; + } catch (error) { + return { + hostContextText: text, + hostContextError: + error instanceof Error ? error.message : "Invalid JSON", + }; + } + }); + }, + + patchHostContext: (patch) => { + set((state) => { + const nextHostContext = { + ...state.draftHostContext, + ...patch, + }; + return { + draftHostContext: nextHostContext, + hostContextText: stringifyJson(nextHostContext), + hostContextError: null, + isDirty: computeDirtyState({ + defaultHostContext: state.defaultHostContext, + savedHostContext: state.savedHostContext, + draftHostContext: nextHostContext, + }), + }; + }); + }, + + resetToBaseline: () => { + set((state) => + resetFromHostContext( + state.activeWorkspaceId, + state.defaultHostContext, + state.savedHostContext, + ), + ); + }, + + beginSave: ({ workspaceId, savedHostContext, awaitRemoteEcho }) => + set({ + isSaving: true, + pendingWorkspaceId: awaitRemoteEcho ? workspaceId : null, + pendingSavedHostContext: awaitRemoteEcho ? savedHostContext : undefined, + isAwaitingRemoteEcho: awaitRemoteEcho, + }), + + markSaved: (savedHostContext) => + set((state) => ({ + savedHostContext, + isSaving: false, + pendingWorkspaceId: null, + pendingSavedHostContext: undefined, + isAwaitingRemoteEcho: false, + isDirty: computeDirtyState({ + defaultHostContext: state.defaultHostContext, + savedHostContext, + draftHostContext: state.draftHostContext, + }), + })), + + failSave: () => + set({ + isSaving: false, + pendingWorkspaceId: null, + pendingSavedHostContext: undefined, + isAwaitingRemoteEcho: false, + }), +})); diff --git a/mcpjam-inspector/client/src/test/mocks/stores.ts b/mcpjam-inspector/client/src/test/mocks/stores.ts index afff3282e..c989749f1 100644 --- a/mcpjam-inspector/client/src/test/mocks/stores.ts +++ b/mcpjam-inspector/client/src/test/mocks/stores.ts @@ -6,7 +6,9 @@ import { vi } from "vitest"; import type { AppState, ServerWithName, Workspace } from "@/state/app-types"; import type { HostDisplayMode, + WorkspaceConnectionConfigDraft, WorkspaceClientConfig, + WorkspaceHostContextDraft, } from "@/lib/client-config"; import { createServer, createWorkspace } from "../factories"; @@ -158,14 +160,23 @@ export function createMockWorkspaceMutations(overrides = {}) { export type MockClientConfigStoreState = { activeWorkspaceId: string | null; - defaultConfig: WorkspaceClientConfig | null; - savedConfig: WorkspaceClientConfig | undefined; - draftConfig: WorkspaceClientConfig | null; + defaultConfig: WorkspaceConnectionConfigDraft | null; + savedConfig: WorkspaceConnectionConfigDraft | undefined; + draftConfig: WorkspaceConnectionConfigDraft | null; connectionDefaultsText: string; clientCapabilitiesText: string; - hostContextText: string; connectionDefaultsError: string | null; clientCapabilitiesError: string | null; + isSaving: boolean; + isDirty: boolean; +}; + +export type MockHostContextStoreState = { + activeWorkspaceId: string | null; + defaultHostContext: WorkspaceHostContextDraft; + savedHostContext: WorkspaceHostContextDraft | undefined; + draftHostContext: WorkspaceHostContextDraft; + hostContextText: string; hostContextError: string | null; isSaving: boolean; isDirty: boolean; @@ -187,6 +198,17 @@ export function createMockWorkspaceClientConfig( }; } +export function createMockWorkspaceConnectionConfig( + overrides: Partial = {}, +): WorkspaceConnectionConfigDraft { + return { + version: 1, + connectionDefaults: + overrides.connectionDefaults ?? { headers: {}, requestTimeout: 10000 }, + clientCapabilities: overrides.clientCapabilities ?? {}, + }; +} + export function createMockClientConfigStoreState( overrides: Partial = {}, ): MockClientConfigStoreState { @@ -204,9 +226,26 @@ export function createMockClientConfigStoreState( clientCapabilitiesText: stringifyJson( draftConfig?.clientCapabilities ?? {}, ), - hostContextText: stringifyJson(draftConfig?.hostContext ?? {}), connectionDefaultsError: null, clientCapabilitiesError: null, + isSaving: false, + isDirty: false, + ...overrides, + }; +} + +export function createMockHostContextStoreState( + overrides: Partial = {}, +): MockHostContextStoreState { + const draftHostContext = + overrides.draftHostContext === undefined ? {} : overrides.draftHostContext; + + return { + activeWorkspaceId: null, + defaultHostContext: {}, + savedHostContext: undefined, + draftHostContext, + hostContextText: stringifyJson(draftHostContext), hostContextError: null, isSaving: false, isDirty: false, @@ -294,15 +333,13 @@ export const storePresets = { clientConfig: (overrides: Partial = {}) => createMockClientConfigStoreState(overrides), - /** Client config with specific host-advertised display modes */ - clientConfigWithHostDisplayModes: ( + /** Host context with specific host-advertised display modes */ + hostContextWithDisplayModes: ( availableDisplayModes: HostDisplayMode[], - overrides: Partial = {}, + overrides: Partial = {}, ) => - createMockClientConfigStoreState({ - draftConfig: createMockWorkspaceClientConfig({ - hostContext: { availableDisplayModes }, - }), + createMockHostContextStoreState({ + draftHostContext: { availableDisplayModes }, ...overrides, }), }; From d0cc706f0b2117b3f7807f82e2cc9e279418da3e Mon Sep 17 00:00:00 2001 From: marcelo Date: Sat, 25 Apr 2026 16:02:34 -0700 Subject: [PATCH 2/2] Fix workspace client config save scoping --- .../__tests__/mcp-apps-renderer.test.tsx | 61 ++- .../thread/mcp-apps/mcp-apps-renderer.tsx | 51 ++- .../__tests__/use-workspace-state.test.tsx | 345 +++++++++++++++++ .../client/src/hooks/use-workspace-state.ts | 349 ++++++++++++++---- 4 files changed, 720 insertions(+), 86 deletions(-) diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx index e843b3f83..33b639c37 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx @@ -9,12 +9,14 @@ import React from "react"; // vi.hoisted runs before imports, letting us capture bridge instances. const { mockBridge, + mockAppBridgeCtor, mockPostMessageTransport, triggerReady, stableStoreFns, mockSandboxPostMessage, sandboxedIframePropsRef, sandboxProxyBehaviorRef, + appBridgeArgsRef, } = vi.hoisted(() => { const bridge = { sendToolInput: vi.fn(), @@ -40,6 +42,18 @@ const { onrequestdisplaymode: null as any, onupdatemodelcontext: null as any, }; + const appBridgeArgsRef = { current: null as any }; + const mockAppBridgeCtor = vi + .fn() + .mockImplementation((client, hostInfo, hostCapabilities, options) => { + appBridgeArgsRef.current = { + client, + hostInfo, + hostCapabilities, + options, + }; + return bridge; + }); // Stable function references for store selectors — prevents useEffect deps // from changing on every render, which would teardown/reinitialize the bridge. @@ -56,10 +70,12 @@ const { return { mockBridge: bridge, + mockAppBridgeCtor, mockPostMessageTransport: vi.fn(), mockSandboxPostMessage: vi.fn(), sandboxedIframePropsRef: { current: null as any }, sandboxProxyBehaviorRef: { current: { autoReady: true } }, + appBridgeArgsRef, stableStoreFns: stableFns, /** Simulate the widget completing initialization. */ triggerReady: () => { @@ -91,7 +107,7 @@ const mockPlaygroundStoreState = { // ── Module mocks ─────────────────────────────────────────────────────────── vi.mock("@modelcontextprotocol/ext-apps/app-bridge", () => ({ - AppBridge: vi.fn().mockImplementation(() => mockBridge), + AppBridge: mockAppBridgeCtor, PostMessageTransport: mockPostMessageTransport, })); @@ -226,10 +242,12 @@ describe("MCPAppsRenderer tool input streaming", () => { mockBridge.setHostContext.mockClear(); mockBridge.close.mockClear().mockResolvedValue(undefined); mockBridge.teardownResource.mockClear().mockResolvedValue({}); + mockAppBridgeCtor.mockClear(); mockBridge.oninitialized = null; mockSandboxPostMessage.mockClear(); sandboxedIframePropsRef.current = null; sandboxProxyBehaviorRef.current.autoReady = true; + appBridgeArgsRef.current = null; vi.mocked(global.fetch).mockResolvedValue({ ok: true, @@ -347,6 +365,47 @@ describe("MCPAppsRenderer tool input streaming", () => { }); }); + it("filters non-standard host style variables out of the initialize payload", async () => { + mockHostContextStoreState.draftHostContext = { + styles: { + variables: { + "--font-sans": "Custom Sans", + "--mcpjam-theme-preset": "soft-pop", + "--totally-unknown": "ignore-me", + }, + }, + }; + + render(); + + await vi.waitFor(() => { + expect(mockBridge.connect).toHaveBeenCalled(); + }); + + expect( + appBridgeArgsRef.current?.options?.hostContext?.styles?.variables, + ).toEqual({ + "--font-sans": "Custom Sans", + }); + + await act(async () => { + triggerReady(); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect(mockBridge.setHostContext).toHaveBeenLastCalledWith( + expect.objectContaining({ + styles: expect.objectContaining({ + variables: { + "--font-sans": "Custom Sans", + }, + }), + }), + ); + }); + }); + it("anchors desktop playground PiP to the playground shell instead of the viewport", async () => { Object.assign(mockPlaygroundStoreState, { isPlaygroundActive: true, diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx index d3819f047..c48273f35 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx @@ -86,8 +86,37 @@ const DEFAULT_INPUT_SCHEMA = { type: "object" } as const; const SUPPRESSED_UI_LOG_METHODS = new Set(["ui/notifications/size-changed"]); const PIP_MAX_HEIGHT = "min(40vh, 600px)"; +const VALID_HOST_STYLE_VARIABLE_KEYS = new Set([ + ...Object.keys(getClaudeDesktopStyleVariables("light")), + ...Object.keys(getChatGPTStyleVariables("light")), +]); type DisplayMode = "inline" | "pip" | "fullscreen"; +type HostStyleVariables = NonNullable< + NonNullable["variables"] +>; + +function sanitizeHostStyleVariables( + variables: unknown, +): HostStyleVariables | undefined { + if (!variables || typeof variables !== "object" || Array.isArray(variables)) { + return undefined; + } + + const sanitized: Record = {}; + for (const [key, value] of Object.entries(variables)) { + if (!VALID_HOST_STYLE_VARIABLE_KEYS.has(key)) { + continue; + } + if (typeof value === "string" || value === undefined) { + sanitized[key] = value; + } + } + + return Object.keys(sanitized).length > 0 + ? (sanitized as HostStyleVariables) + : undefined; +} // CSP and permissions metadata types are now imported from SDK @@ -178,7 +207,6 @@ export function MCPAppsRenderer({ }: MCPAppsRendererProps) { const sandboxRef = useRef(null); const themeMode = usePreferencesStore((s) => s.themeMode); - const themePreset = usePreferencesStore((s) => s.themePreset); const sharedHostStyle = usePreferencesStore((s) => s.hostStyle); const chatboxHostStyle = useChatboxHostStyle(); const draftHostContext = useHostContextStore((s) => s.draftHostContext); @@ -770,21 +798,20 @@ export function MCPAppsRenderer({ !Array.isArray(baseHostContext.styles) ? (baseHostContext.styles as McpUiHostContext["styles"]) : undefined; + // The SDK validates styles.variables against the SEP key enum, so strip + // host-specific custom properties before they enter ui/initialize. + const configuredStyleVariables = useMemo( + () => sanitizeHostStyleVariables(configuredStyles?.variables), + [configuredStyles?.variables], + ); const mergedStyleVariables = useMemo(() => { - const configuredVariables = - configuredStyles?.variables && - typeof configuredStyles.variables === "object" && - !Array.isArray(configuredStyles.variables) - ? configuredStyles.variables - : undefined; - return { - ...(configuredVariables && Object.keys(configuredVariables).length > 0 - ? configuredVariables + ...(configuredStyleVariables && + Object.keys(configuredStyleVariables).length > 0 + ? configuredStyleVariables : styleVariables), - "--mcpjam-theme-preset": themePreset, }; - }, [configuredStyles?.variables, styleVariables, themePreset]); + }, [configuredStyleVariables, styleVariables]); const mergedStyles = useMemo( () => ({ ...configuredStyles, diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx index eb6f8b7e4..b86a82a9a 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx @@ -8,6 +8,7 @@ import { useHostContextStore } from "@/stores/host-context-store"; import type { WorkspaceClientConfig, WorkspaceConnectionConfigDraft, + WorkspaceHostContextDraft, } from "@/lib/client-config"; const { @@ -676,6 +677,350 @@ describe("useWorkspaceState automatic workspace creation", () => { await savePromise; }); + it("composes host-context saves with the target workspace connection config", async () => { + const remoteOneClientConfig: WorkspaceClientConfig = { + version: 1, + connectionDefaults: { + headers: { "x-workspace": "one" }, + requestTimeout: 1111, + }, + clientCapabilities: { + experimental: { + workspaceOne: true, + }, + }, + hostContext: { + locale: "en-US", + }, + }; + const remoteTwoClientConfig: WorkspaceClientConfig = { + version: 1, + connectionDefaults: { + headers: { "x-workspace": "two" }, + requestTimeout: 2222, + }, + clientCapabilities: { + experimental: { + workspaceTwo: true, + }, + }, + hostContext: { + locale: "en-GB", + }, + }; + const savedHostContext: WorkspaceHostContextDraft = { + theme: "dark", + }; + const expectedPersistedClientConfig: WorkspaceClientConfig = { + ...remoteTwoClientConfig, + hostContext: savedHostContext, + }; + + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace 1", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + clientConfig: remoteOneClientConfig, + }, + { + _id: "remote-2", + name: "Remote workspace 2", + servers: {}, + ownerId: "user-1", + createdAt: 2, + updatedAt: 2, + clientConfig: remoteTwoClientConfig, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + useClientConfigStore.setState({ + activeWorkspaceId: "remote-1", + savedConfig: { + version: 1, + connectionDefaults: { + headers: { "x-workspace": "stale" }, + requestTimeout: 9999, + }, + clientCapabilities: { + experimental: { + stale: true, + }, + }, + }, + defaultConfig: null, + draftConfig: { + version: 1, + connectionDefaults: { + headers: { "x-workspace": "draft" }, + requestTimeout: 7777, + }, + clientCapabilities: { + experimental: { + draft: true, + }, + }, + }, + }); + + const appState = createAppState({ + default: createSyntheticDefaultWorkspace(), + }); + const { result, rerender } = renderUseWorkspaceState({ appState }); + + const savePromise = result.current.handleUpdateHostContext( + "remote-2", + savedHostContext, + ); + + await waitFor(() => { + expect(updateClientConfigMock).toHaveBeenCalledWith({ + workspaceId: "remote-2", + clientConfig: expectedPersistedClientConfig, + }); + }); + + workspaceQueryState.allWorkspaces = [ + workspaceQueryState.allWorkspaces[0], + { + ...workspaceQueryState.allWorkspaces[1], + clientConfig: expectedPersistedClientConfig, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + rerender({ organizationId: undefined }); + + await savePromise; + }); + + it("composes connection-config saves with the target workspace host context", async () => { + const remoteOneClientConfig: WorkspaceClientConfig = { + version: 1, + connectionDefaults: { + headers: { "x-workspace": "one" }, + requestTimeout: 1111, + }, + clientCapabilities: { + experimental: { + workspaceOne: true, + }, + }, + hostContext: { + locale: "en-US", + }, + }; + const remoteTwoHostContext: WorkspaceHostContextDraft = { + locale: "en-GB", + theme: "light", + }; + const remoteTwoClientConfig: WorkspaceClientConfig = { + version: 1, + connectionDefaults: { + headers: { "x-workspace": "two" }, + requestTimeout: 2222, + }, + clientCapabilities: { + experimental: { + workspaceTwo: true, + }, + }, + hostContext: remoteTwoHostContext, + }; + const savedConnectionConfig: WorkspaceConnectionConfigDraft = { + version: 1, + connectionDefaults: { + headers: { "x-workspace": "updated" }, + requestTimeout: 3333, + }, + clientCapabilities: { + experimental: { + updated: true, + }, + }, + }; + const expectedPersistedClientConfig: WorkspaceClientConfig = { + version: 1, + connectionDefaults: savedConnectionConfig.connectionDefaults, + clientCapabilities: savedConnectionConfig.clientCapabilities, + hostContext: remoteTwoHostContext, + }; + + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace 1", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + clientConfig: remoteOneClientConfig, + }, + { + _id: "remote-2", + name: "Remote workspace 2", + servers: {}, + ownerId: "user-1", + createdAt: 2, + updatedAt: 2, + clientConfig: remoteTwoClientConfig, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + useHostContextStore.setState({ + activeWorkspaceId: "remote-1", + savedHostContext: { + locale: "stale-locale", + theme: "dark", + }, + defaultHostContext: {}, + draftHostContext: { + locale: "draft-locale", + theme: "dark", + }, + }); + + const appState = createAppState({ + default: createSyntheticDefaultWorkspace(), + }); + const { result, rerender } = renderUseWorkspaceState({ appState }); + + const savePromise = result.current.handleUpdateClientConfig( + "remote-2", + savedConnectionConfig, + ); + + await waitFor(() => { + expect(updateClientConfigMock).toHaveBeenCalledWith({ + workspaceId: "remote-2", + clientConfig: expectedPersistedClientConfig, + }); + }); + + workspaceQueryState.allWorkspaces = [ + workspaceQueryState.allWorkspaces[0], + { + ...workspaceQueryState.allWorkspaces[1], + clientConfig: expectedPersistedClientConfig, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + rerender({ organizationId: undefined }); + + await savePromise; + }); + + it("keeps a newer workspace save pending when an older save times out", async () => { + vi.useFakeTimers(); + + const firstSavedConfig: WorkspaceConnectionConfigDraft = { + version: 1, + connectionDefaults: { + headers: { "x-workspace": "one" }, + requestTimeout: 1111, + }, + clientCapabilities: { + experimental: { + workspaceOne: true, + }, + }, + }; + const secondSavedConfig: WorkspaceConnectionConfigDraft = { + version: 1, + connectionDefaults: { + headers: { "x-workspace": "two" }, + requestTimeout: 2222, + }, + clientCapabilities: { + experimental: { + workspaceTwo: true, + }, + }, + }; + const secondPersistedClientConfig: WorkspaceClientConfig = { + version: 1, + connectionDefaults: secondSavedConfig.connectionDefaults, + clientCapabilities: secondSavedConfig.clientCapabilities, + hostContext: {}, + }; + + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace 1", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + clientConfig: undefined, + }, + { + _id: "remote-2", + name: "Remote workspace 2", + servers: {}, + ownerId: "user-1", + createdAt: 2, + updatedAt: 2, + clientConfig: undefined, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + + const appState = createAppState({ + default: createSyntheticDefaultWorkspace(), + }); + const { result, rerender } = renderUseWorkspaceState({ appState }); + + const firstSavePromise = result.current.handleUpdateClientConfig( + "remote-1", + firstSavedConfig, + ); + const firstSaveError = firstSavePromise.catch((error) => error); + + await Promise.resolve(); + expect(updateClientConfigMock).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + + const secondSavePromise = result.current.handleUpdateClientConfig( + "remote-2", + secondSavedConfig, + ); + + await Promise.resolve(); + expect(updateClientConfigMock).toHaveBeenCalledTimes(2); + expect(useClientConfigStore.getState().pendingWorkspaceId).toBe("remote-2"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + + await expect(firstSaveError).resolves.toBeInstanceOf(Error); + await expect(firstSavePromise).rejects.toThrow( + "Timed out waiting for workspace client config to sync.", + ); + expect(useClientConfigStore.getState().pendingWorkspaceId).toBe("remote-2"); + expect(useClientConfigStore.getState().isAwaitingRemoteEcho).toBe(true); + + workspaceQueryState.allWorkspaces = [ + workspaceQueryState.allWorkspaces[0], + { + ...workspaceQueryState.allWorkspaces[1], + clientConfig: secondPersistedClientConfig, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + rerender({ organizationId: undefined }); + + await secondSavePromise; + }); + it("treats the authenticated zero-org state as empty remote workspaces and clears stale synced selection", async () => { workspaceQueryState.allWorkspaces = [ { diff --git a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts index c47e40073..bd18b0db1 100644 --- a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts @@ -44,6 +44,7 @@ function buildLocalWorkspaceId() { } interface PendingClientConfigSync { + id: string; workspaceId: string; expectedSerializedConfig: string; resolve: () => void; @@ -51,14 +52,29 @@ interface PendingClientConfigSync { timeoutId: ReturnType; } +const WORKSPACE_CLIENT_CONFIG_SYNC_INTERRUPTED_ERROR_MESSAGE = + "Workspace client config sync was interrupted."; +const WORKSPACE_CLIENT_CONFIG_SYNC_TIMEOUT_ERROR_MESSAGE = + "Timed out waiting for workspace client config to sync."; +const WORKSPACE_CLIENT_CONFIG_SYNC_SUPERSEDED_ERROR_MESSAGE = + "Workspace client config sync was superseded by a newer save."; + interface ClientConfigSaveController { beginSave: (input: { workspaceId: string; savedConfig: T | undefined; awaitRemoteEcho: boolean; }) => void; - markSaved: (savedConfig: T | undefined) => void; - failSave: () => void; + markSaved: (input: { + workspaceId: string; + savedConfig: T | undefined; + awaitRemoteEcho: boolean; + }) => void; + failSave: (input: { + workspaceId: string; + savedConfig: T | undefined; + awaitRemoteEcho: boolean; + }) => void; } interface LoggerLike { @@ -79,6 +95,27 @@ function isSyntheticDefaultWorkspace(workspace: Workspace) { ); } +function doesStoreSliceBelongToWorkspace( + activeWorkspaceId: string | null, + workspaceId: string, +) { + return activeWorkspaceId === workspaceId; +} + +function canApplyStoreSaveState( + activeWorkspaceId: string | null, + workspaceId: string, +) { + return activeWorkspaceId === null || activeWorkspaceId === workspaceId; +} + +function isSupersededClientConfigSyncError(error: unknown) { + return ( + error instanceof Error && + error.message === WORKSPACE_CLIENT_CONFIG_SYNC_SUPERSEDED_ERROR_MESSAGE + ); +} + export interface UseWorkspaceStateParams { appState: AppState; dispatch: Dispatch; @@ -153,8 +190,12 @@ export function useWorkspaceState({ const migrationErrorNotifiedRef = useRef(new Set()); const [useLocalFallback, setUseLocalFallback] = useState(false); const convexTimeoutRef = useRef(null); - const pendingClientConfigSyncRef = useRef( - null, + const pendingClientConfigSyncRef = useRef< + Map + >(new Map()); + const pendingClientConfigSyncIdRef = useRef(0); + const pendingClientConfigSyncByWorkspaceRef = useRef>( + new Map(), ); const CONVEX_TIMEOUT_MS = 10000; const shouldTreatRemoteWorkspacesAsEmpty = @@ -179,19 +220,43 @@ export function useWorkspaceState({ isAuthenticated, }); - const clearPendingClientConfigSync = useCallback((error?: Error) => { - const pending = pendingClientConfigSyncRef.current; - if (!pending) { + const clearPendingClientConfigSync = useCallback( + (pendingId: string, error?: Error) => { + const pending = pendingClientConfigSyncRef.current.get(pendingId); + if (!pending) { + return; + } + + clearTimeout(pending.timeoutId); + pendingClientConfigSyncRef.current.delete(pendingId); + if ( + pendingClientConfigSyncByWorkspaceRef.current.get(pending.workspaceId) === + pendingId + ) { + pendingClientConfigSyncByWorkspaceRef.current.delete( + pending.workspaceId, + ); + } + + if (error) { + pending.reject(error); + } + }, + [], + ); + + const clearAllPendingClientConfigSyncs = useCallback((error?: Error) => { + const pendingIds = Array.from( + pendingClientConfigSyncRef.current.keys(), + ); + if (pendingIds.length === 0) { return; } - clearTimeout(pending.timeoutId); - pendingClientConfigSyncRef.current = null; - - if (error) { - pending.reject(error); + for (const pendingId of pendingIds) { + clearPendingClientConfigSync(pendingId, error); } - }, []); + }, [clearPendingClientConfigSync]); useEffect(() => { if (!isAuthenticated) { @@ -257,11 +322,11 @@ export function useWorkspaceState({ return; } - clearPendingClientConfigSync( - new Error("Workspace client config sync was interrupted."), + clearAllPendingClientConfigSyncs( + new Error(WORKSPACE_CLIENT_CONFIG_SYNC_INTERRUPTED_ERROR_MESSAGE), ); }, [ - clearPendingClientConfigSync, + clearAllPendingClientConfigSyncs, isAuthenticated, shouldTreatRemoteWorkspacesAsEmpty, useLocalFallback, @@ -269,11 +334,11 @@ export function useWorkspaceState({ useEffect(() => { return () => { - clearPendingClientConfigSync( - new Error("Workspace client config sync was interrupted."), + clearAllPendingClientConfigSyncs( + new Error(WORKSPACE_CLIENT_CONFIG_SYNC_INTERRUPTED_ERROR_MESSAGE), ); }; - }, [clearPendingClientConfigSync]); + }, [clearAllPendingClientConfigSyncs]); useEffect(() => { if (!shouldTreatRemoteWorkspacesAsEmpty || !convexActiveWorkspaceId) { @@ -333,24 +398,24 @@ export function useWorkspaceState({ }, [remoteWorkspaces, convexActiveWorkspaceId, activeWorkspaceServersFlat]); useEffect(() => { - const pending = pendingClientConfigSyncRef.current; - if (!pending) { + if (pendingClientConfigSyncRef.current.size === 0) { return; } - const syncedClientConfig = - convexWorkspaces[pending.workspaceId]?.clientConfig ?? undefined; - if ( - stringifyWorkspaceClientConfig(syncedClientConfig) !== - pending.expectedSerializedConfig - ) { - return; - } + for (const [pendingId, pending] of pendingClientConfigSyncRef.current) { + const syncedClientConfig = + convexWorkspaces[pending.workspaceId]?.clientConfig; + if ( + stringifyWorkspaceClientConfig(syncedClientConfig) !== + pending.expectedSerializedConfig + ) { + continue; + } - clearTimeout(pending.timeoutId); - pendingClientConfigSyncRef.current = null; - pending.resolve(); - }, [convexWorkspaces]); + clearPendingClientConfigSync(pendingId); + pending.resolve(); + } + }, [clearPendingClientConfigSync, convexWorkspaces]); const scopedLocalWorkspaces = useMemo((): Record => { if (!shouldScopeLocalFallbackByOrganization) { @@ -863,26 +928,41 @@ export function useWorkspaceState({ }); if (awaitRemoteEcho) { + const pendingSyncId = `workspace-client-config-sync-${pendingClientConfigSyncIdRef.current++}`; const remoteEchoPromise = new Promise((resolve, reject) => { - clearPendingClientConfigSync(); + const supersededPendingId = + pendingClientConfigSyncByWorkspaceRef.current.get(workspaceId); + if (supersededPendingId) { + clearPendingClientConfigSync( + supersededPendingId, + new Error(WORKSPACE_CLIENT_CONFIG_SYNC_SUPERSEDED_ERROR_MESSAGE), + ); + } const timeoutId = setTimeout(() => { - pendingClientConfigSyncRef.current = null; - reject( - new Error( - "Timed out waiting for workspace client config to sync.", - ), - ); + pendingClientConfigSyncRef.current.delete(pendingSyncId); + if ( + pendingClientConfigSyncByWorkspaceRef.current.get(workspaceId) === + pendingSyncId + ) { + pendingClientConfigSyncByWorkspaceRef.current.delete(workspaceId); + } + reject(new Error(WORKSPACE_CLIENT_CONFIG_SYNC_TIMEOUT_ERROR_MESSAGE)); }, CLIENT_CONFIG_SYNC_ECHO_TIMEOUT_MS); - pendingClientConfigSyncRef.current = { + pendingClientConfigSyncByWorkspaceRef.current.set( + workspaceId, + pendingSyncId, + ); + pendingClientConfigSyncRef.current.set(pendingSyncId, { + id: pendingSyncId, workspaceId, expectedSerializedConfig: stringifyWorkspaceClientConfig(clientConfig), resolve, reject, timeoutId, - }; + }); }); try { @@ -892,15 +972,21 @@ export function useWorkspaceState({ }); await remoteEchoPromise; } catch (error) { - clearPendingClientConfigSync(); - controller.failSave(); - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; - logger.error("Failed to update workspace client config", { - error: errorMessage, + clearPendingClientConfigSync(pendingSyncId); + controller.failSave({ workspaceId, + savedConfig: savedSlice, + awaitRemoteEcho, }); - toast.error(errorMessage); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + if (!isSupersededClientConfigSyncError(error)) { + logger.error("Failed to update workspace client config", { + error: errorMessage, + workspaceId, + }); + toast.error(errorMessage); + } throw error instanceof Error ? error : new Error(errorMessage); } return; @@ -911,7 +997,11 @@ export function useWorkspaceState({ workspaceId, updates: { clientConfig }, }); - controller.markSaved(savedSlice); + controller.markSaved({ + workspaceId, + savedConfig: savedSlice, + awaitRemoteEcho, + }); }, [ isAuthenticated, @@ -927,12 +1017,14 @@ export function useWorkspaceState({ (workspaceId: string): WorkspaceConnectionConfigDraft => { const clientConfigStore = useClientConfigStore.getState(); const workspaceClientConfig = effectiveWorkspaces[workspaceId]?.clientConfig; + const scopedStoreConfig = doesStoreSliceBelongToWorkspace( + clientConfigStore.activeWorkspaceId, + workspaceId, + ) + ? clientConfigStore.savedConfig ?? clientConfigStore.defaultConfig + : undefined; - return ( - clientConfigStore.savedConfig ?? - clientConfigStore.defaultConfig ?? - pickWorkspaceConnectionConfig(workspaceClientConfig) - ); + return scopedStoreConfig ?? pickWorkspaceConnectionConfig(workspaceClientConfig); }, [effectiveWorkspaces], ); @@ -941,16 +1033,122 @@ export function useWorkspaceState({ (workspaceId: string): WorkspaceHostContextDraft => { const hostContextStore = useHostContextStore.getState(); const workspaceClientConfig = effectiveWorkspaces[workspaceId]?.clientConfig; + const scopedStoreHostContext = doesStoreSliceBelongToWorkspace( + hostContextStore.activeWorkspaceId, + workspaceId, + ) + ? hostContextStore.savedHostContext ?? hostContextStore.defaultHostContext + : undefined; - return ( - hostContextStore.savedHostContext ?? - hostContextStore.defaultHostContext ?? - pickWorkspaceHostContext(workspaceClientConfig) - ); + return scopedStoreHostContext ?? pickWorkspaceHostContext(workspaceClientConfig); }, [effectiveWorkspaces], ); + const connectionConfigSaveController = useMemo< + ClientConfigSaveController + >( + () => ({ + beginSave: ({ workspaceId, savedConfig, awaitRemoteEcho }) => { + const state = useClientConfigStore.getState(); + if (!canApplyStoreSaveState(state.activeWorkspaceId, workspaceId)) { + return; + } + + useClientConfigStore.getState().beginSave({ + workspaceId, + savedConfig, + awaitRemoteEcho, + }); + }, + markSaved: ({ workspaceId, savedConfig, awaitRemoteEcho }) => { + const state = useClientConfigStore.getState(); + if (!canApplyStoreSaveState(state.activeWorkspaceId, workspaceId)) { + return; + } + if ( + awaitRemoteEcho && + (state.pendingWorkspaceId !== workspaceId || + stableStringifyJson(state.pendingSavedConfig) !== + stableStringifyJson(savedConfig)) + ) { + return; + } + + useClientConfigStore.getState().markSaved(savedConfig); + }, + failSave: ({ workspaceId, savedConfig, awaitRemoteEcho }) => { + const state = useClientConfigStore.getState(); + if (!canApplyStoreSaveState(state.activeWorkspaceId, workspaceId)) { + return; + } + if ( + awaitRemoteEcho && + (state.pendingWorkspaceId !== workspaceId || + stableStringifyJson(state.pendingSavedConfig) !== + stableStringifyJson(savedConfig)) + ) { + return; + } + + useClientConfigStore.getState().failSave(); + }, + }), + [], + ); + + const hostContextSaveController = useMemo< + ClientConfigSaveController + >( + () => ({ + beginSave: ({ workspaceId, savedConfig, awaitRemoteEcho }) => { + const state = useHostContextStore.getState(); + if (!canApplyStoreSaveState(state.activeWorkspaceId, workspaceId)) { + return; + } + + useHostContextStore.getState().beginSave({ + workspaceId, + savedHostContext: savedConfig, + awaitRemoteEcho, + }); + }, + markSaved: ({ workspaceId, savedConfig, awaitRemoteEcho }) => { + const state = useHostContextStore.getState(); + if (!canApplyStoreSaveState(state.activeWorkspaceId, workspaceId)) { + return; + } + if ( + awaitRemoteEcho && + (state.pendingWorkspaceId !== workspaceId || + stableStringifyJson(state.pendingSavedHostContext) !== + stableStringifyJson(savedConfig)) + ) { + return; + } + + useHostContextStore.getState().markSaved(savedConfig); + }, + failSave: ({ workspaceId, savedConfig, awaitRemoteEcho }) => { + const state = useHostContextStore.getState(); + if (!canApplyStoreSaveState(state.activeWorkspaceId, workspaceId)) { + return; + } + if ( + awaitRemoteEcho && + (state.pendingWorkspaceId !== workspaceId || + stableStringifyJson(state.pendingSavedHostContext) !== + stableStringifyJson(savedConfig)) + ) { + return; + } + + useHostContextStore.getState().failSave(); + }, + }), + [], + ); + const handleUpdateClientConfig = useCallback( async ( workspaceId: string, @@ -958,9 +1156,15 @@ export function useWorkspaceState({ ): Promise => { const clientConfigStore = useClientConfigStore.getState(); const workspaceClientConfig = effectiveWorkspaces[workspaceId]?.clientConfig; + const scopedDraftConfig = doesStoreSliceBelongToWorkspace( + clientConfigStore.activeWorkspaceId, + workspaceId, + ) + ? clientConfigStore.draftConfig + : undefined; const connectionConfigToPersist = connectionConfig ?? - clientConfigStore.draftConfig ?? + scopedDraftConfig ?? resolvePersistedConnectionConfig(workspaceId); const clientConfig = composeWorkspaceClientConfig({ connectionConfig: connectionConfigToPersist, @@ -972,11 +1176,12 @@ export function useWorkspaceState({ workspaceId, clientConfig, savedSlice: connectionConfigToPersist, - controller: clientConfigStore, + controller: connectionConfigSaveController, }); }, [ effectiveWorkspaces, + connectionConfigSaveController, persistWorkspaceClientConfig, resolvePersistedConnectionConfig, resolvePersistedHostContext, @@ -990,9 +1195,15 @@ export function useWorkspaceState({ ): Promise => { const hostContextStore = useHostContextStore.getState(); const workspaceClientConfig = effectiveWorkspaces[workspaceId]?.clientConfig; + const scopedDraftHostContext = doesStoreSliceBelongToWorkspace( + hostContextStore.activeWorkspaceId, + workspaceId, + ) + ? hostContextStore.draftHostContext + : undefined; const hostContextToPersist = hostContext ?? - hostContextStore.draftHostContext ?? + scopedDraftHostContext ?? resolvePersistedHostContext(workspaceId); const clientConfig = composeWorkspaceClientConfig({ connectionConfig: resolvePersistedConnectionConfig(workspaceId), @@ -1004,20 +1215,12 @@ export function useWorkspaceState({ workspaceId, clientConfig, savedSlice: hostContextToPersist, - controller: { - beginSave: ({ workspaceId, savedConfig, awaitRemoteEcho }) => - hostContextStore.beginSave({ - workspaceId, - savedHostContext: savedConfig, - awaitRemoteEcho, - }), - markSaved: (savedConfig) => hostContextStore.markSaved(savedConfig), - failSave: () => hostContextStore.failSave(), - }, + controller: hostContextSaveController, }); }, [ effectiveWorkspaces, + hostContextSaveController, persistWorkspaceClientConfig, resolvePersistedConnectionConfig, resolvePersistedHostContext,