diff --git a/mcpjam-inspector/.env.local b/mcpjam-inspector/.env.local index 018343b99..007a87403 100644 --- a/mcpjam-inspector/.env.local +++ b/mcpjam-inspector/.env.local @@ -1,7 +1,7 @@ -VITE_CONVEX_URL=https://proper-clownfish-150.convex.cloud -CONVEX_URL=https://proper-clownfish-150.convex.cloud +VITE_CONVEX_URL=https://tough-cassowary-291.convex.cloud +CONVEX_URL=https://tough-cassowary-291.convex.cloud VITE_WORKOS_CLIENT_ID=client_01K4C1TVA6CMQ3G32F1P301A9G VITE_WORKOS_REDIRECT_URI=mcpjam://oauth/callback -CONVEX_HTTP_URL=https://proper-clownfish-150.convex.site +CONVEX_HTTP_URL=https://tough-cassowary-291.convex.site ENVIRONMENT=local -VITE_DISABLE_POSTHOG_LOCAL=true \ No newline at end of file +VITE_DISABLE_POSTHOG_LOCAL=true diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index e1df675bd..fa6cf01ef 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -13,6 +13,7 @@ import { ChatTabV2 } from "./components/ChatTabV2"; import { EvalsTab } from "./components/EvalsTab"; import { CiEvalsTab } from "./components/CiEvalsTab"; import { ViewsTab } from "./components/ViewsTab"; +import { SandboxesTab } from "./components/SandboxesTab"; import { SettingsTab } from "./components/SettingsTab"; import { TracingTab } from "./components/TracingTab"; import { AuthTab } from "./components/AuthTab"; @@ -54,23 +55,55 @@ import { SharedServerChatPage, getSharedPathTokenFromLocation, } from "./components/hosted/SharedServerChatPage"; +import { + SandboxChatPage, + getSandboxPathTokenFromLocation, +} from "./components/hosted/SandboxChatPage"; import { useHostedApiContext } from "./hooks/hosted/use-hosted-api-context"; import { HOSTED_MODE } from "./lib/config"; import { resolveHostedNavigation } from "./lib/hosted-navigation"; import { buildOAuthTokensByServerId } from "./lib/oauth/oauth-tokens"; +import { + clearHostedOAuthPendingState, + getHostedOAuthCallbackContext, + resolveHostedOAuthReturnHash, +} from "./lib/hosted-oauth-callback"; +import { + clearSandboxSignInReturnPath, + readSandboxSession, + readSandboxSignInReturnPath, + writeSandboxSignInReturnPath, +} from "./lib/sandbox-session"; import { clearSharedSignInReturnPath, - hasActiveSharedSession, readSharedServerSession, readSharedSignInReturnPath, slugify, - SHARED_OAUTH_PENDING_KEY, writeSharedSignInReturnPath, readPendingServerAdd, clearPendingServerAdd, } from "./lib/shared-server-session"; +import { + sanitizeHostedOAuthErrorMessage, + writeHostedOAuthResumeMarker, +} from "./lib/hosted-oauth-resume"; import { handleOAuthCallback } from "./lib/oauth/mcp-oauth"; +function getHostedOAuthCallbackErrorMessage(): string { + const params = new URLSearchParams(window.location.search); + const error = params.get("error"); + const description = params.get("error_description"); + + if (error === "access_denied" && !description) { + return "Authorization was cancelled. Try again."; + } + + return sanitizeHostedOAuthErrorMessage( + description || error, + "Authorization could not be completed. Try again.", + ); +} + export default function App() { const [activeTab, setActiveTab] = useState("servers"); const [activeOrganizationId, setActiveOrganizationId] = useState< @@ -88,38 +121,128 @@ export default function App() { isLoading: isWorkOsLoading, } = useAuth(); const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth(); - const [sharedOAuthHandling, setSharedOAuthHandling] = useState(false); + const [hostedOAuthHandling, setHostedOAuthHandling] = useState(() => + HOSTED_MODE ? getHostedOAuthCallbackContext() !== null : false, + ); const [exitedSharedChat, setExitedSharedChat] = useState(false); + const [exitedSandboxChat, setExitedSandboxChat] = useState(false); const sharedPathToken = HOSTED_MODE ? getSharedPathTokenFromLocation() : null; + const sandboxPathToken = HOSTED_MODE + ? getSandboxPathTokenFromLocation() + : null; + const sharedSession = HOSTED_MODE ? readSharedServerSession() : null; + const sandboxSession = HOSTED_MODE ? readSandboxSession() : null; + const currentHashSlug = window.location.hash + .replace(/^#/, "") + .replace(/^\/+/, "") + .split("/")[0]; + const hostedRouteKind = useMemo(() => { + if (!HOSTED_MODE) { + return null; + } + + if (sharedPathToken) { + return "shared" as const; + } + if (sandboxPathToken) { + return "sandbox" as const; + } + + if (sharedSession && sandboxSession) { + if (currentHashSlug === slugify(sharedSession.payload.serverName)) { + return "shared" as const; + } + if (currentHashSlug === slugify(sandboxSession.payload.name)) { + return "sandbox" as const; + } + return null; + } + + if (sharedSession) { + return "shared" as const; + } + if (sandboxSession) { + return "sandbox" as const; + } + + return null; + }, [ + currentHashSlug, + sandboxPathToken, + sandboxSession, + sharedPathToken, + sharedSession, + ]); const isSharedChatRoute = - HOSTED_MODE && - !exitedSharedChat && - (!!sharedPathToken || hasActiveSharedSession()); + HOSTED_MODE && !exitedSharedChat && hostedRouteKind === "shared"; + const isSandboxChatRoute = + HOSTED_MODE && !exitedSandboxChat && hostedRouteKind === "sandbox"; + const isHostedChatRoute = isSharedChatRoute || isSandboxChatRoute; - // Handle shared OAuth callback: detect code + pending flag before normal rendering + // Handle hosted OAuth callback: claim the callback before any hosted page renders. useEffect(() => { + const callbackContext = getHostedOAuthCallbackContext(); + if (!callbackContext) return; + const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get("code"); - if (!code || !localStorage.getItem(SHARED_OAUTH_PENDING_KEY)) return; + const error = urlParams.get("error"); let cancelled = false; - setSharedOAuthHandling(true); + setHostedOAuthHandling(true); - const cleanupOAuth = () => { + const finalizeHostedOAuth = (errorMessage?: string | null) => { if (cancelled) return; - localStorage.removeItem(SHARED_OAUTH_PENDING_KEY); - const storedSession = readSharedServerSession(); - const sharedHash = storedSession - ? slugify(storedSession.payload.serverName) - : "shared"; - window.history.replaceState({}, "", `/#${sharedHash}`); + if (callbackContext.serverName) { + writeHostedOAuthResumeMarker({ + surface: callbackContext.surface, + serverName: callbackContext.serverName, + serverUrl: callbackContext.serverUrl, + errorMessage: + errorMessage && errorMessage.trim() ? errorMessage : null, + }); + } + + clearHostedOAuthPendingState(); + localStorage.removeItem("mcp-oauth-pending"); + localStorage.removeItem("mcp-oauth-return-hash"); + window.history.replaceState( + {}, + "", + `/${resolveHostedOAuthReturnHash(callbackContext)}`, + ); }; + if (error || !code) { + finalizeHostedOAuth(getHostedOAuthCallbackErrorMessage()); + setHostedOAuthHandling(false); + return; + } + handleOAuthCallback(code) - .then(cleanupOAuth) - .catch(cleanupOAuth) + .then((result) => { + if (result.success) { + finalizeHostedOAuth(null); + return; + } + + finalizeHostedOAuth( + sanitizeHostedOAuthErrorMessage( + result.error, + "Authorization could not be completed. Try again.", + ), + ); + }) + .catch((callbackError) => { + finalizeHostedOAuth( + sanitizeHostedOAuthErrorMessage( + callbackError, + "Authorization could not be completed. Try again.", + ), + ); + }) .finally(() => { - if (!cancelled) setSharedOAuthHandling(false); + if (!cancelled) setHostedOAuthHandling(false); }); return () => { @@ -167,9 +290,15 @@ export default function App() { // Let AuthKit + Convex auth settle before leaving /callback. if (!isAuthLoading && isAuthenticated) { + const sandboxReturnPath = readSandboxSignInReturnPath(); const sharedReturnPath = readSharedSignInReturnPath(); + clearSandboxSignInReturnPath(); clearSharedSignInReturnPath(); - window.history.replaceState({}, "", sharedReturnPath ?? "/"); + window.history.replaceState( + {}, + "", + sandboxReturnPath ?? sharedReturnPath ?? "/", + ); setCallbackCompleted(true); setCallbackRecoveryExpired(false); return; @@ -212,7 +341,7 @@ export default function App() { // Auto-add a shared server when returning from SharedServerChatPage via "Open MCPJam" useEffect(() => { - if (isSharedChatRoute) return; + if (isHostedChatRoute) return; if (isLoadingRemoteWorkspaces) return; if (isAuthLoading) return; @@ -233,7 +362,7 @@ export default function App() { oauthScopes: pending.oauthScopes ?? undefined, }); }, [ - isSharedChatRoute, + isHostedChatRoute, isLoadingRemoteWorkspaces, isAuthLoading, workspaceServers, @@ -306,7 +435,7 @@ export default function App() { guestOauthTokensByServerName, isAuthenticated, serverConfigs: guestServerConfigs, - enabled: !isSharedChatRoute, + enabled: !isHostedChatRoute, }); // Compute the set of server names that have saved views @@ -337,6 +466,17 @@ export default function App() { return; } + if (isSandboxChatRoute) { + const storedSession = readSandboxSession(); + if (storedSession) { + const expectedHash = slugify(storedSession.payload.name); + if (window.location.hash !== `#${expectedHash}`) { + window.location.hash = expectedHash; + } + } + return; + } + const resolved = resolveHostedNavigation(target, HOSTED_MODE); if ( @@ -374,12 +514,16 @@ export default function App() { } setActiveTab(resolved.normalizedTab); }, - [isSharedChatRoute, setSelectedMultipleServersToAllServers], + [ + isSandboxChatRoute, + isSharedChatRoute, + setSelectedMultipleServersToAllServers, + ], ); // Sync tab with hash on mount and when hash changes useEffect(() => { - if (isSharedChatRoute) { + if (isHostedChatRoute) { return; } @@ -390,7 +534,7 @@ export default function App() { applyHash(); window.addEventListener("hashchange", applyHash); return () => window.removeEventListener("hashchange", applyHash); - }, [applyNavigation, isSharedChatRoute]); + }, [applyNavigation, isHostedChatRoute]); // Redirect away from tabs hidden by the ci-evals feature flag. // Use strict equality to avoid redirecting while the flag is still loading (undefined). @@ -410,7 +554,7 @@ export default function App() { return ; } - if (sharedOAuthHandling) { + if (hostedOAuthHandling) { return ; } @@ -447,7 +591,7 @@ export default function App() { return ; } - if (isLoading && !isSharedChatRoute) { + if (isLoading && !isHostedChatRoute) { return ; } @@ -459,7 +603,7 @@ export default function App() { hasWorkOsUser: !!workOsUser, isLoadingRemoteWorkspaces, }); - const sharedHostedShellGateState = resolveHostedShellGateState({ + const hostedChatShellGateState = resolveHostedShellGateState({ hostedMode: HOSTED_MODE, isConvexAuthLoading: isAuthLoading, isConvexAuthenticated: isAuthenticated, @@ -554,6 +698,9 @@ export default function App() { onLeaveWorkspace={() => handleLeaveWorkspace(activeWorkspaceId)} /> )} + {activeTab === "sandboxes" && ( + + )} {activeTab === "resources" && (
{ if (sharedPathToken) { writeSharedSignInReturnPath(window.location.pathname); } + if (sandboxPathToken) { + writeSandboxSignInReturnPath(window.location.pathname); + } signIn(); }} > @@ -663,6 +811,11 @@ export default function App() { pathToken={sharedPathToken} onExitSharedChat={() => setExitedSharedChat(true)} /> + ) : isSandboxChatRoute ? ( + setExitedSandboxChat(true)} + /> ) : ( appContent )} diff --git a/mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx b/mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx new file mode 100644 index 000000000..270c90a70 --- /dev/null +++ b/mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx @@ -0,0 +1,300 @@ +import type { ReactNode } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import App from "../App"; +import { + clearHostedOAuthPendingState, + writeHostedOAuthPendingMarker, +} from "../lib/hosted-oauth-callback"; +import { + clearSandboxSession, + writeSandboxSession, +} from "../lib/sandbox-session"; + +const { mockHandleOAuthCallback, mockPosthogCapture, mockUseAppState } = + vi.hoisted(() => ({ + mockHandleOAuthCallback: vi.fn(), + mockPosthogCapture: vi.fn(), + mockUseAppState: vi.fn(() => ({ + appState: { + servers: {}, + selectedServer: undefined, + selectedMultipleServers: [], + }, + isLoading: false, + isLoadingRemoteWorkspaces: false, + workspaceServers: {}, + connectedOrConnectingServerConfigs: {}, + selectedMCPConfig: null, + handleConnect: vi.fn(), + handleDisconnect: vi.fn(), + handleReconnect: vi.fn(), + handleUpdate: vi.fn(), + handleRemoveServer: vi.fn(), + setSelectedServer: vi.fn(), + toggleServerSelection: vi.fn(), + setSelectedMultipleServersToAllServers: vi.fn(), + workspaces: {}, + activeWorkspaceId: "ws_local", + handleSwitchWorkspace: vi.fn(), + handleCreateWorkspace: vi.fn(), + handleUpdateWorkspace: vi.fn(), + handleDeleteWorkspace: vi.fn(), + handleLeaveWorkspace: vi.fn(), + handleWorkspaceShared: vi.fn(), + saveServerConfigWithoutConnecting: vi.fn(), + handleConnectWithTokensFromOAuthFlow: vi.fn(), + handleRefreshTokensFromOAuthFlow: vi.fn(), + })), + })); + +vi.mock("convex/react", () => ({ + useConvexAuth: () => ({ + isAuthenticated: true, + isLoading: false, + }), +})); + +vi.mock("@workos-inc/authkit-react", () => ({ + useAuth: () => ({ + getAccessToken: vi.fn(), + signIn: vi.fn(), + user: null, + isLoading: false, + }), +})); + +vi.mock("posthog-js/react", () => ({ + usePostHog: () => ({ + capture: mockPosthogCapture, + }), + useFeatureFlagEnabled: () => false, +})); + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock("../hooks/use-app-state", () => ({ + useAppState: mockUseAppState, +})); + +vi.mock("../hooks/useViews", () => ({ + useViewQueries: () => ({ viewsByServer: new Map() }), + useWorkspaceServers: () => ({ serversById: new Map() }), +})); + +vi.mock("../hooks/hosted/use-hosted-api-context", () => ({ + useHostedApiContext: vi.fn(), +})); + +vi.mock("../hooks/useElectronOAuth", () => ({ + useElectronOAuth: vi.fn(), +})); + +vi.mock("../hooks/useEnsureDbUser", () => ({ + useEnsureDbUser: vi.fn(), +})); + +vi.mock("../hooks/usePostHogIdentify", () => ({ + usePostHogIdentify: vi.fn(), +})); + +vi.mock("../lib/config", () => ({ + HOSTED_MODE: true, +})); + +vi.mock("../lib/theme-utils", () => ({ + getInitialThemeMode: () => "light", + updateThemeMode: vi.fn(), + getInitialThemePreset: () => "default", + updateThemePreset: vi.fn(), +})); + +vi.mock("../lib/oauth/mcp-oauth", () => ({ + handleOAuthCallback: mockHandleOAuthCallback, +})); + +vi.mock("../components/ServersTab", () => ({ + ServersTab: () =>
, +})); +vi.mock("../components/ToolsTab", () => ({ + ToolsTab: () =>
, +})); +vi.mock("../components/ResourcesTab", () => ({ + ResourcesTab: () =>
, +})); +vi.mock("../components/PromptsTab", () => ({ + PromptsTab: () =>
, +})); +vi.mock("../components/SkillsTab", () => ({ + SkillsTab: () =>
, +})); +vi.mock("../components/LearningTab", () => ({ + LearningTab: () =>
, +})); +vi.mock("../components/TasksTab", () => ({ + TasksTab: () =>
, +})); +vi.mock("../components/ChatTabV2", () => ({ + ChatTabV2: () =>
, +})); +vi.mock("../components/EvalsTab", () => ({ + EvalsTab: () =>
, +})); +vi.mock("../components/CiEvalsTab", () => ({ + CiEvalsTab: () =>
, +})); +vi.mock("../components/ViewsTab", () => ({ + ViewsTab: () =>
, +})); +vi.mock("../components/SandboxesTab", () => ({ + SandboxesTab: () =>
, +})); +vi.mock("../components/SettingsTab", () => ({ + SettingsTab: () =>
, +})); +vi.mock("../components/TracingTab", () => ({ + TracingTab: () =>
, +})); +vi.mock("../components/AuthTab", () => ({ + AuthTab: () =>
, +})); +vi.mock("../components/OAuthFlowTab", () => ({ + OAuthFlowTab: () =>
, +})); +vi.mock("../components/ui-playground/AppBuilderTab", () => ({ + AppBuilderTab: () =>
, +})); +vi.mock("../components/ProfileTab", () => ({ + ProfileTab: () =>
, +})); +vi.mock("../components/OrganizationsTab", () => ({ + OrganizationsTab: () =>
, +})); +vi.mock("../components/SupportTab", () => ({ + SupportTab: () =>
, +})); +vi.mock("../components/oauth/OAuthDebugCallback", () => ({ + default: () =>
, +})); +vi.mock("../components/mcp-sidebar", () => ({ + MCPSidebar: () =>
, +})); +vi.mock("../components/ui/sidebar", () => ({ + SidebarInset: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), + SidebarProvider: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), +})); +vi.mock("../stores/preferences/preferences-provider", () => ({ + PreferencesStoreProvider: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), +})); +vi.mock("../components/ui/sonner", () => ({ + Toaster: () =>
, +})); +vi.mock("../state/app-state-context", () => ({ + AppStateProvider: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), +})); +vi.mock("../components/CompletingSignInLoading", () => ({ + default: () =>
, +})); +vi.mock("../components/LoadingScreen", () => ({ + default: () =>
, +})); +vi.mock("../components/Header", () => ({ + Header: () =>
, +})); +vi.mock("../components/hosted/HostedShellGate", () => ({ + HostedShellGate: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), +})); +vi.mock("../components/hosted/hosted-shell-gate-state", () => ({ + resolveHostedShellGateState: () => "ready", +})); +vi.mock("../components/hosted/SharedServerChatPage", () => ({ + SharedServerChatPage: () => , + getSharedPathTokenFromLocation: () => null, +})); +vi.mock("../components/hosted/SandboxChatPage", () => ({ + SandboxChatPage: () => , + getSandboxPathTokenFromLocation: () => null, +})); + +describe("App hosted OAuth callback handling", () => { + beforeEach(() => { + clearHostedOAuthPendingState(); + clearSandboxSession(); + localStorage.clear(); + sessionStorage.clear(); + vi.stubGlobal("__APP_VERSION__", "test"); + window.history.replaceState({}, "", "/oauth/callback?code=oauth-code"); + mockHandleOAuthCallback.mockReset(); + mockPosthogCapture.mockReset(); + mockHandleOAuthCallback.mockImplementation( + () => new Promise(() => {}), + ); + + writeSandboxSession({ + token: "sandbox-token", + payload: { + workspaceId: "ws_1", + sandboxId: "sbx_1", + name: "Asaan", + description: "Hosted sandbox", + hostStyle: "claude", + mode: "invited_only", + allowGuestAccess: false, + viewerIsWorkspaceMember: true, + systemPrompt: "You are helpful.", + modelId: "openai/gpt-5-mini", + temperature: 0.4, + requireToolApproval: true, + servers: [ + { + serverId: "srv_asana", + serverName: "asana", + useOAuth: true, + serverUrl: "https://mcp.asana.com/sse", + clientId: null, + oauthScopes: null, + }, + ], + }, + }); + writeHostedOAuthPendingMarker({ + surface: "sandbox", + serverName: "asana", + serverUrl: "https://mcp.asana.com/sse", + returnHash: "#asaan", + }); + localStorage.setItem("mcp-oauth-pending", "asana"); + localStorage.setItem("mcp-serverUrl-asana", "https://mcp.asana.com/sse"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows loading before any hosted authorize CTA can render", async () => { + render(); + + expect(screen.getByTestId("hosted-oauth-loading")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Authorize" }), + ).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockHandleOAuthCallback).toHaveBeenCalledWith("oauth-code"); + }); + }); +}); diff --git a/mcpjam-inspector/client/src/components/ChatTabV2.tsx b/mcpjam-inspector/client/src/components/ChatTabV2.tsx index 3188c2c03..b1f0f5670 100644 --- a/mcpjam-inspector/client/src/components/ChatTabV2.tsx +++ b/mcpjam-inspector/client/src/components/ChatTabV2.tsx @@ -14,6 +14,7 @@ import { ElicitationDialog } from "@/components/ElicitationDialog"; import type { DialogElicitation } from "@/components/ToolsTab"; import { ChatInput } from "@/components/chat-v2/chat-input"; import { Thread } from "@/components/chat-v2/thread"; +import { type ReasoningDisplayMode } from "@/components/chat-v2/thread/parts/reasoning-part"; import { ServerWithName } from "@/hooks/use-app-state"; import { MCPJamFreeModelsPrompt } from "@/components/chat-v2/mcpjam-free-models-prompt"; import { usePostHog } from "posthog-js/react"; @@ -42,6 +43,7 @@ import { useSharedAppState } from "@/state/app-state-context"; import { useWorkspaceServers } from "@/hooks/useViews"; import { HOSTED_MODE } from "@/lib/config"; import { buildOAuthTokensByServerId } from "@/lib/oauth/oauth-tokens"; +import type { HostedOAuthRequiredDetails } from "@/lib/hosted-oauth-required"; interface ChatTabProps { connectedOrConnectingServerConfigs: Record; @@ -52,7 +54,13 @@ interface ChatTabProps { hostedSelectedServerIdsOverride?: string[]; hostedOAuthTokensOverride?: Record; hostedShareToken?: string; - onOAuthRequired?: (serverUrl?: string) => void; + hostedSandboxToken?: string; + initialModelId?: string; + initialSystemPrompt?: string; + initialTemperature?: number; + initialRequireToolApproval?: boolean; + reasoningDisplayMode?: ReasoningDisplayMode; + onOAuthRequired?: (details?: HostedOAuthRequiredDetails) => void; } function ScrollToBottomButton() { @@ -82,6 +90,12 @@ export function ChatTabV2({ hostedSelectedServerIdsOverride, hostedOAuthTokensOverride, hostedShareToken, + hostedSandboxToken, + initialModelId, + initialSystemPrompt, + initialTemperature, + initialRequireToolApproval, + reasoningDisplayMode = "inline", onOAuthRequired, }: ChatTabProps) { const { signUp } = useAuth(); @@ -195,6 +209,11 @@ export function ChatTabV2({ hostedSelectedServerIds: effectiveHostedSelectedServerIds, hostedOAuthTokens: effectiveHostedOAuthTokens, hostedShareToken, + hostedSandboxToken, + initialModelId, + initialSystemPrompt, + initialTemperature, + initialRequireToolApproval, minimalMode, onReset: () => { setInput(""); @@ -445,7 +464,20 @@ export function ChatTabV2({ try { const parsed = JSON.parse(msg); if (parsed?.details?.oauthRequired) { - onOAuthRequired(parsed.details.serverUrl); + onOAuthRequired({ + serverUrl: + typeof parsed.details.serverUrl === "string" + ? parsed.details.serverUrl + : null, + serverId: + typeof parsed.details.serverId === "string" + ? parsed.details.serverId + : null, + serverName: + typeof parsed.details.serverName === "string" + ? parsed.details.serverName + : null, + }); return; } } catch { @@ -677,6 +709,7 @@ export function ChatTabV2({ fullscreenChatDisabled={inputDisabled} onToolApprovalResponse={addToolApprovalResponse} minimalMode={minimalMode} + reasoningDisplayMode={reasoningDisplayMode} /> diff --git a/mcpjam-inspector/client/src/components/SandboxesTab.tsx b/mcpjam-inspector/client/src/components/SandboxesTab.tsx new file mode 100644 index 000000000..cd85787ea --- /dev/null +++ b/mcpjam-inspector/client/src/components/SandboxesTab.tsx @@ -0,0 +1,370 @@ +import { useEffect, useState } from "react"; +import { + Copy, + ExternalLink, + Loader2, + MoreHorizontal, + Pencil, + Plus, + Trash2, +} from "lucide-react"; +import { useConvexAuth } from "convex/react"; +import { toast } from "sonner"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { SandboxUsagePanel } from "@/components/sandboxes/SandboxUsagePanel"; +import { SandboxEditor } from "@/components/sandboxes/SandboxEditor"; +import { + useSandbox, + useSandboxList, + useSandboxMutations, + type SandboxListItem, + type SandboxSettings, +} from "@/hooks/useSandboxes"; +import { useWorkspaceServers } from "@/hooks/useViews"; +import { copyToClipboard } from "@/lib/clipboard"; +import { buildSandboxLink } from "@/lib/sandbox-session"; + +interface SandboxesTabProps { + workspaceId: string | null; +} + +type RightPaneView = "usage" | "edit" | "create"; +type SandboxActionTarget = Pick; + +export function SandboxesTab({ workspaceId }: SandboxesTabProps) { + const { isAuthenticated } = useConvexAuth(); + const { sandboxes, isLoading } = useSandboxList({ + isAuthenticated, + workspaceId, + }); + const { servers } = useWorkspaceServers({ + isAuthenticated, + workspaceId, + }); + const { deleteSandbox, duplicateSandbox } = useSandboxMutations(); + + const [selectedSandboxId, setSelectedSandboxId] = useState( + null, + ); + const [rightPaneView, setRightPaneView] = useState("usage"); + + useEffect(() => { + if (!sandboxes || sandboxes.length === 0) { + setSelectedSandboxId(null); + return; + } + + setSelectedSandboxId((current) => { + if ( + current && + sandboxes.some((sandbox) => sandbox.sandboxId === current) + ) { + return current; + } + return sandboxes[0]?.sandboxId ?? null; + }); + }, [sandboxes]); + + const { sandbox: selectedSandbox, isLoading: isSandboxLoading } = useSandbox({ + isAuthenticated, + sandboxId: selectedSandboxId, + }); + + const handleSelectSandbox = (sandboxId: string) => { + setSelectedSandboxId(sandboxId); + setRightPaneView("usage"); + }; + + const handleDelete = async (sandbox: SandboxActionTarget) => { + const shouldDelete = window.confirm( + `Delete "${sandbox.name}"? This will also delete persisted usage history.`, + ); + if (!shouldDelete) return; + + try { + await deleteSandbox({ sandboxId: sandbox.sandboxId }); + toast.success("Sandbox deleted"); + if (sandbox.sandboxId === selectedSandboxId) { + setSelectedSandboxId(null); + setRightPaneView("usage"); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete sandbox", + ); + } + }; + + const handleDuplicate = async (sandbox: SandboxActionTarget) => { + try { + const duplicatedSandbox = (await duplicateSandbox({ + sandboxId: sandbox.sandboxId, + })) as SandboxSettings; + toast.success(`Sandbox duplicated as "${duplicatedSandbox.name}"`); + handleCreated(duplicatedSandbox); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to duplicate sandbox", + ); + } + }; + + const handleCreated = (sandbox: SandboxSettings) => { + setSelectedSandboxId(sandbox.sandboxId); + setRightPaneView("usage"); + }; + + const resolveSandboxLink = (sandbox: SandboxActionTarget) => { + if (sandbox.sandboxId !== selectedSandboxId) { + handleSelectSandbox(sandbox.sandboxId); + return null; + } + + const token = selectedSandbox?.link?.token?.trim(); + return token ? buildSandboxLink(token, sandbox.name) : null; + }; + + const handleOpenSandbox = (sandbox: SandboxActionTarget) => { + const shareLink = resolveSandboxLink(sandbox); + if (!shareLink) { + if (sandbox.sandboxId === selectedSandboxId) { + toast.error("Sandbox link unavailable"); + } + return; + } + + window.open(shareLink, "_blank"); + }; + + const handleCopySandboxLink = async (sandbox: SandboxActionTarget) => { + const shareLink = resolveSandboxLink(sandbox); + if (!shareLink) { + if (sandbox.sandboxId === selectedSandboxId) { + toast.error("Sandbox link unavailable"); + } + return; + } + + const didCopy = await copyToClipboard(shareLink); + if (didCopy) { + toast.success("Sandbox link copied"); + return; + } + + toast.error("Failed to copy link"); + }; + + if (!workspaceId) { + return ( +
+

+ Select a workspace to manage sandboxes. +

+
+ ); + } + + return ( + + +
+
+
+

Sandboxes

+

+ Hosted chat environments +

+
+ +
+ +
+ {isLoading ? ( +
+ +
+ ) : !sandboxes || sandboxes.length === 0 ? ( +
+
+

No sandboxes yet

+

+ Create one to package a prompt, model, and server set into a + hosted environment. +

+
+
+ ) : ( + sandboxes.map((sandbox) => { + const isSelected = sandbox.sandboxId === selectedSandboxId; + return ( +
handleSelectSandbox(sandbox.sandboxId)} + > +
+

+ {sandbox.name} +

+
+ + +
+ + + + + + { + e.stopPropagation(); + handleSelectSandbox(sandbox.sandboxId); + setRightPaneView("edit"); + }} + > + + Edit + + { + e.stopPropagation(); + void handleDuplicate(sandbox); + }} + > + + Duplicate + + + { + e.stopPropagation(); + void handleDelete(sandbox); + }} + > + + Delete + + + +
+ {sandbox.description ? ( +

+ {sandbox.description} +

+ ) : null} + {sandbox.serverNames.length > 0 && ( +
+ {sandbox.serverNames.map((serverName) => ( + + {serverName} + + ))} +
+ )} +
+ ); + }) + )} +
+
+
+ + + + +
+ {rightPaneView === "create" && servers ? ( + setRightPaneView("usage")} + onSaved={handleCreated} + /> + ) : !selectedSandboxId ? ( +
+

+ Select a sandbox to view details. +

+
+ ) : isSandboxLoading || selectedSandbox === undefined ? ( +
+ +
+ ) : !selectedSandbox ? ( +
+

+ Sandbox not found. +

+
+ ) : rightPaneView === "edit" && servers ? ( + setRightPaneView("usage")} + onDeleted={() => { + setSelectedSandboxId(null); + setRightPaneView("usage"); + }} + /> + ) : ( + + )} +
+
+
+ ); +} diff --git a/mcpjam-inspector/client/src/components/__tests__/SandboxesTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/SandboxesTab.test.tsx new file mode 100644 index 000000000..75f5ed0b6 --- /dev/null +++ b/mcpjam-inspector/client/src/components/__tests__/SandboxesTab.test.tsx @@ -0,0 +1,293 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SandboxesTab } from "../SandboxesTab"; +import { buildSandboxLink } from "@/lib/sandbox-session"; + +const mockDeleteSandbox = vi.fn(); +const mockDuplicateSandbox = vi.fn(); +const mockToastSuccess = vi.fn(); +const mockToastError = vi.fn(); +const mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), +}; + +Object.assign(navigator, { clipboard: mockClipboard }); + +const sandboxList = [ + { + sandboxId: "sbx-1", + workspaceId: "ws-1", + name: "Alpha", + description: "Alpha description", + hostStyle: "claude" as const, + mode: "invited_only" as const, + allowGuestAccess: false, + serverCount: 1, + serverNames: ["alpha-server"], + createdAt: 1, + updatedAt: 1, + }, + { + sandboxId: "sbx-2", + workspaceId: "ws-1", + name: "Beta", + description: "Beta description", + hostStyle: "chatgpt" as const, + mode: "invited_only" as const, + allowGuestAccess: false, + serverCount: 1, + serverNames: ["beta-server"], + createdAt: 2, + updatedAt: 2, + }, +]; + +const sandboxDetails: Record = { + "sbx-1": { + ...sandboxList[0], + systemPrompt: "You are Alpha.", + modelId: "gpt-4o-mini", + temperature: 0.4, + requireToolApproval: true, + servers: [ + { + serverId: "server-1", + serverName: "alpha-server", + useOAuth: false, + serverUrl: "https://example.com/alpha", + clientId: null, + oauthScopes: null, + }, + ], + link: { + token: "alpha-token", + path: "/sandbox/alpha/alpha-token", + url: "https://app.mcpjam.com/sandbox/alpha/alpha-token", + rotatedAt: 1, + updatedAt: 1, + }, + members: [], + }, + "sbx-2": { + ...sandboxList[1], + systemPrompt: "You are Beta.", + modelId: "gpt-4o-mini", + temperature: 0.5, + requireToolApproval: false, + servers: [ + { + serverId: "server-2", + serverName: "beta-server", + useOAuth: false, + serverUrl: "https://example.com/beta", + clientId: null, + oauthScopes: null, + }, + ], + link: { + token: "beta-token", + path: "/sandbox/beta/beta-token", + url: "https://app.mcpjam.com/sandbox/beta/beta-token", + rotatedAt: 2, + updatedAt: 2, + }, + members: [], + }, + "sbx-3": { + ...sandboxList[1], + sandboxId: "sbx-3", + name: "Beta (Copy)", + systemPrompt: "You are Beta.", + modelId: "gpt-4o-mini", + temperature: 0.5, + requireToolApproval: false, + servers: [ + { + serverId: "server-2", + serverName: "beta-server", + useOAuth: false, + serverUrl: "https://example.com/beta", + clientId: null, + oauthScopes: null, + }, + ], + link: { + token: "beta-copy-token", + path: "/sandbox/beta-copy/beta-copy-token", + url: "https://app.mcpjam.com/sandbox/beta-copy/beta-copy-token", + rotatedAt: 3, + updatedAt: 3, + }, + members: [], + }, +}; + +vi.mock("convex/react", () => ({ + useConvexAuth: () => ({ + isAuthenticated: true, + }), +})); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => mockToastSuccess(...args), + error: (...args: unknown[]) => mockToastError(...args), + }, +})); + +vi.mock("@/hooks/useViews", () => ({ + useWorkspaceServers: () => ({ + servers: [], + }), +})); + +vi.mock("@/hooks/useSandboxes", () => ({ + useSandboxList: () => ({ + sandboxes: sandboxList, + isLoading: false, + }), + useSandbox: ({ sandboxId }: { sandboxId: string | null }) => ({ + sandbox: sandboxId ? (sandboxDetails[sandboxId] ?? null) : null, + isLoading: false, + }), + useSandboxMutations: () => ({ + deleteSandbox: mockDeleteSandbox, + duplicateSandbox: mockDuplicateSandbox, + }), +})); + +vi.mock("@/components/ui/resizable", () => ({ + ResizablePanelGroup: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ResizablePanel: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ResizableHandle: () =>
, +})); + +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: React.ReactNode; + onClick?: (event: React.MouseEvent) => void; + className?: string; + }) => ( + + ), + DropdownMenuSeparator: () =>
, +})); + +vi.mock("@/components/sandboxes/SandboxUsagePanel", () => ({ + SandboxUsagePanel: ({ sandbox }: { sandbox: { name: string } }) => ( +
{sandbox.name}
+ ), +})); + +vi.mock("@/components/sandboxes/SandboxEditor", () => ({ + SandboxEditor: () =>
Sandbox editor
, +})); + +describe("SandboxesTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDuplicateSandbox.mockResolvedValue(sandboxDetails["sbx-3"]); + mockDeleteSandbox.mockResolvedValue({ deleted: true }); + vi.spyOn(window, "confirm").mockReturnValue(true); + vi.spyOn(window, "open").mockImplementation(() => null); + }); + + it("duplicates the clicked sandbox instead of the currently selected one", async () => { + render(); + + fireEvent.click(screen.getAllByText("Duplicate")[1]!); + + await waitFor(() => { + expect(mockDuplicateSandbox).toHaveBeenCalledWith({ + sandboxId: "sbx-2", + }); + }); + expect(mockToastSuccess).toHaveBeenCalledWith( + 'Sandbox duplicated as "Beta (Copy)"', + ); + }); + + it("deletes the clicked sandbox instead of the currently selected one", async () => { + render(); + + fireEvent.click(screen.getAllByText("Delete")[1]!); + + await waitFor(() => { + expect(mockDeleteSandbox).toHaveBeenCalledWith({ + sandboxId: "sbx-2", + }); + }); + expect(window.confirm).toHaveBeenCalledWith( + 'Delete "Beta"? This will also delete persisted usage history.', + ); + }); + + it("opens the selected sandbox from the icon action", () => { + render(); + + fireEvent.click( + screen.getAllByRole("button", { name: "Open sandbox" })[0]!, + ); + + expect(window.open).toHaveBeenCalledWith( + buildSandboxLink("alpha-token", "Alpha"), + "_blank", + ); + }); + + it("copies the selected sandbox link from the icon action", async () => { + render(); + + fireEvent.click( + screen.getAllByRole("button", { name: "Copy sandbox link" })[0]!, + ); + + await waitFor(() => { + expect(mockClipboard.writeText).toHaveBeenCalledWith( + buildSandboxLink("alpha-token", "Alpha"), + ); + }); + expect(mockToastSuccess).toHaveBeenCalledWith("Sandbox link copied"); + }); + + it("keeps the row action icons visible without hover-only classes", () => { + render(); + + for (const button of screen.getAllByRole("button", { + name: "Copy sandbox link", + })) { + expect(button).not.toHaveClass("opacity-0"); + } + + for (const button of screen.getAllByRole("button", { + name: "Open sandbox", + })) { + expect(button).not.toHaveClass("opacity-0"); + } + + for (const button of screen.getAllByRole("button", { + name: "Sandbox actions", + })) { + expect(button).not.toHaveClass("opacity-0"); + } + }); +}); diff --git a/mcpjam-inspector/client/src/components/chat-v2/__tests__/ChatInput.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/__tests__/ChatInput.test.tsx index 1f7737d24..9fc2a08d1 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/__tests__/ChatInput.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/__tests__/ChatInput.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { ChatInput } from "../chat-input"; +import { SandboxHostStyleProvider } from "@/contexts/sandbox-host-style-context"; import type { ModelDefinition } from "@/shared/types"; // Mock child components @@ -143,9 +144,21 @@ describe("ChatInput", () => { it("renders submit button", () => { render(); - // Submit button exists - const buttons = screen.getAllByRole("button"); - expect(buttons.length).toBeGreaterThan(0); + expect( + screen.getByRole("button", { name: "Send message" }), + ).toBeInTheDocument(); + }); + + it("uses ChatGPT submit styling inside ChatGPT sandboxes", () => { + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Send message" })).toHaveClass( + "bg-[#1f1f1f]", + ); }); }); diff --git a/mcpjam-inspector/client/src/components/chat-v2/__tests__/MessageView.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/__tests__/MessageView.test.tsx index 4db090258..e4936c4dc 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/__tests__/MessageView.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/__tests__/MessageView.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react"; import { MessageView } from "../thread/message-view"; import type { UIMessage } from "@ai-sdk/react"; import type { ModelDefinition } from "@/shared/types"; +import { SandboxHostStyleProvider } from "@/contexts/sandbox-host-style-context"; // Mock PartSwitch vi.mock("../thread/part-switch", () => ({ @@ -31,8 +32,8 @@ vi.mock("@/stores/preferences/preferences-provider", () => ({ })); // Mock chat-helpers -vi.mock("../shared/chat-helpers", () => ({ - getProviderLogoFromModel: () => null, +vi.mock("@/components/chat-v2/shared/chat-helpers", () => ({ + getProviderLogoFromModel: () => "/provider-logo.png", })); describe("MessageView", () => { @@ -143,6 +144,22 @@ describe("MessageView", () => { "assistant", ); }); + + it("uses a neutral placeholder for sandbox assistant avatars", () => { + const message = createMessage({ + role: "assistant", + parts: [{ type: "text", text: "Hello" }], + }); + + render( + + + , + ); + + expect(screen.getByLabelText("Assistant")).toBeInTheDocument(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); }); describe("special messages", () => { diff --git a/mcpjam-inspector/client/src/components/chat-v2/__tests__/PartSwitch.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/__tests__/PartSwitch.test.tsx index 2816a85c3..56589789c 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/__tests__/PartSwitch.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/__tests__/PartSwitch.test.tsx @@ -27,8 +27,20 @@ vi.mock("../thread/parts/tool-part", () => ({ })); vi.mock("../thread/parts/reasoning-part", () => ({ - ReasoningPart: ({ text, state }: { text: string; state: string }) => ( -
+ ReasoningPart: ({ + text, + state, + displayMode, + }: { + text: string; + state: string; + displayMode?: string; + }) => ( +
{text}
), @@ -208,13 +220,55 @@ describe("PartSwitch", () => { }); it("passes state to ReasoningPart", () => { - const part = { type: "reasoning", text: "Done", state: "complete" }; + const part = { type: "reasoning", text: "Done", state: "done" }; render(); expect(screen.getByTestId("reasoning-part")).toHaveAttribute( "data-state", - "complete", + "done", + ); + }); + + it("passes reasoning display mode to ReasoningPart", () => { + const part = { + type: "reasoning", + text: "Hidden in traces", + state: "done", + }; + + render( + , + ); + + expect(screen.getByTestId("reasoning-part")).toHaveAttribute( + "data-display-mode", + "collapsed", + ); + }); + + it("passes collapsible reasoning display mode to ReasoningPart", () => { + const part = { + type: "reasoning", + text: "Owner thread reasoning", + state: "done", + }; + + render( + , + ); + + expect(screen.getByTestId("reasoning-part")).toHaveAttribute( + "data-display-mode", + "collapsible", ); }); }); diff --git a/mcpjam-inspector/client/src/components/chat-v2/__tests__/ThinkingIndicator.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/__tests__/ThinkingIndicator.test.tsx new file mode 100644 index 000000000..62b0352a9 --- /dev/null +++ b/mcpjam-inspector/client/src/components/chat-v2/__tests__/ThinkingIndicator.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ThinkingIndicator } from "../shared/thinking-indicator"; +import { SandboxHostStyleProvider } from "@/contexts/sandbox-host-style-context"; +import type { ModelDefinition } from "@/shared/types"; + +vi.mock("@/stores/preferences/preferences-provider", () => ({ + usePreferencesStore: (selector: (state: { themeMode: "light" }) => unknown) => + selector({ themeMode: "light" }), +})); + +vi.mock("@/components/chat-v2/shared/chat-helpers", () => ({ + getProviderLogoFromModel: () => "/provider-logo.png", +})); + +describe("ThinkingIndicator", () => { + const defaultModel: ModelDefinition = { + id: "gpt-4", + name: "GPT-4", + provider: "openai", + contextWindow: 8192, + maxOutputTokens: 4096, + supportsTools: true, + supportsVision: false, + supportsStreaming: true, + }; + + it("renders the provider logo outside sandboxes", () => { + render(); + + expect(screen.getByRole("img")).toHaveAttribute("alt", "gpt-4 logo"); + }); + + it("renders a neutral placeholder inside sandboxes", () => { + render( + + + , + ); + + expect(screen.getByLabelText("Assistant")).toBeInTheDocument(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); +}); diff --git a/mcpjam-inspector/client/src/components/chat-v2/__tests__/Thread.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/__tests__/Thread.test.tsx index b5d930c68..e3929f278 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/__tests__/Thread.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/__tests__/Thread.test.tsx @@ -4,24 +4,24 @@ import { Thread } from "../thread"; import type { UIMessage } from "@ai-sdk/react"; import type { ModelDefinition } from "@/shared/types"; +const mockMessageView = vi.fn(); + // Mock child components vi.mock("../thread/message-view", () => ({ - MessageView: ({ - message, - model, - }: { - message: UIMessage; - model: ModelDefinition; - }) => ( -
- {model.name} - {message.parts?.map((part, i) => ( - - {(part as any).text || (part as any).type} - - ))} -
- ), + MessageView: (props: { message: UIMessage; model: ModelDefinition }) => { + mockMessageView(props); + const { message, model } = props; + return ( +
+ {model.name} + {message.parts?.map((part, i) => ( + + {(part as any).text || (part as any).type} + + ))} +
+ ); + }, })); vi.mock("../shared/thinking-indicator", () => ({ @@ -134,6 +134,69 @@ describe("Thread", () => { expect(screen.getByTestId("message-model")).toHaveTextContent("GPT-4"); }); + + it("forwards interactive to MessageView", () => { + const messages = [createMessage({ id: "msg-1" })]; + + render( + , + ); + + expect(mockMessageView).toHaveBeenCalledWith( + expect.objectContaining({ + interactive: false, + }), + ); + }); + + it("forwards reasoningDisplayMode to MessageView", () => { + const messages = [createMessage({ id: "msg-1" })]; + + render( + , + ); + + expect(mockMessageView).toHaveBeenCalledWith( + expect.objectContaining({ + reasoningDisplayMode: "collapsed", + }), + ); + }); + + it("forwards hidden reasoningDisplayMode to MessageView", () => { + const messages = [createMessage({ id: "msg-1" })]; + + render( + , + ); + + expect(mockMessageView).toHaveBeenCalledWith( + expect.objectContaining({ + reasoningDisplayMode: "hidden", + }), + ); + }); + + it("keeps interactive and reasoningDisplayMode defaults", () => { + const messages = [createMessage({ id: "msg-1" })]; + + render(); + + expect(mockMessageView).toHaveBeenCalledWith( + expect.objectContaining({ + interactive: true, + reasoningDisplayMode: "inline", + }), + ); + }); }); describe("loading state", () => { diff --git a/mcpjam-inspector/client/src/components/chat-v2/chat-input.tsx b/mcpjam-inspector/client/src/components/chat-v2/chat-input.tsx index d9bab49c2..0da96a4ac 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/chat-input.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/chat-input.tsx @@ -41,6 +41,7 @@ import { MCPPromptResultCard } from "@/components/chat-v2/chat-input/prompts/mcp import type { SkillResult } from "@/components/chat-v2/chat-input/skills/skill-types"; import { SkillResultCard } from "@/components/chat-v2/chat-input/skills/skill-result-card"; import { usePostHog } from "posthog-js/react"; +import { useSandboxHostStyle } from "@/contexts/sandbox-host-style-context"; interface ChatInputProps { value: string; @@ -132,6 +133,7 @@ export function ChatInput({ minimalMode = false, }: ChatInputProps) { const posthog = usePostHog(); + const sandboxHostStyle = useSandboxHostStyle(); const formRef = useRef(null); const containerRef = useRef(null); const textareaRef = useRef(null); @@ -324,13 +326,32 @@ export function ChatInput({ ); }; + const composerClasses = + sandboxHostStyle === "chatgpt" + ? "sandbox-host-composer rounded-[1.75rem] border-transparent bg-[#f4f4f4] shadow-none dark:bg-[#2f2f2f]" + : sandboxHostStyle === "claude" + ? "sandbox-host-composer rounded-[1.35rem] border-[#d7cfbf] bg-[#f5f0e8] shadow-none dark:border-[#4b463d] dark:bg-[#34322e]" + : "rounded-3xl border border-border/40 bg-muted/70"; + const activeSubmitButtonClasses = + sandboxHostStyle === "chatgpt" + ? "bg-[#1f1f1f] text-white hover:bg-[#303030] dark:bg-[#f4f4f4] dark:text-[#1f1f1f] dark:hover:bg-[#e8e8e8]" + : sandboxHostStyle === "claude" + ? "bg-[#e27d47] text-white hover:bg-[#d16f3d] dark:bg-[#d07b53] dark:text-[#fff7f0] dark:hover:bg-[#c06f49]" + : "bg-primary text-primary-foreground hover:bg-primary/90"; + const inactiveSubmitButtonClasses = + sandboxHostStyle === "chatgpt" + ? "bg-[#e7e7e7] text-[#9b9b9b] cursor-not-allowed dark:bg-[#3a3a3a] dark:text-[#8a8a8a]" + : sandboxHostStyle === "claude" + ? "bg-[#ebe5dc] text-[#b6ada0] cursor-not-allowed dark:bg-[#45413b] dark:text-[#8d857a]" + : "bg-muted text-muted-foreground cursor-not-allowed"; + return (
stop()} > @@ -586,13 +608,14 @@ export function ChatInput({ + {isExpanded ? ( +
+          {text}
+        
+ ) : null}
); } 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 4b5ee1461..0182b4420 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 @@ -37,8 +37,10 @@ import { import { CspDebugPanel } from "../csp-debug-panel"; import { JsonEditor } from "@/components/ui/json-editor"; import { cn } from "@/lib/chat-utils"; +import { TextPart } from "./text-part"; type ApprovalVisualState = "pending" | "approved" | "denied"; +type TraceDisplayMode = "markdown" | "json-markdown"; const SAVE_VIEW_BUTTON_USED_KEY = "mcpjam-save-view-button-used"; const SAVE_VIEW_REDIRECTED_KEY = "mcpjam-save-view-redirected"; @@ -134,9 +136,21 @@ export function ToolPart({ const inputData = (part as any).input; const outputData = (part as any).output; const errorText = (part as any).errorText ?? (part as any).error; + const traceDisplayText = + typeof (part as { traceDisplayText?: unknown }).traceDisplayText === + "string" + ? (part as { traceDisplayText: string }).traceDisplayText + : undefined; + const traceDisplayMode = (part as { traceDisplayMode?: TraceDisplayMode }) + .traceDisplayMode; + const hasAttachedTraceDisplay = Boolean( + traceDisplayText && + (traceDisplayMode === "markdown" || traceDisplayMode === "json-markdown"), + ); const hasInput = inputData !== undefined && inputData !== null; const hasOutput = outputData !== undefined && outputData !== null; const hasError = state === "output-error" && !!errorText; + const showRawResult = hasOutput && !hasAttachedTraceDisplay; const widgetDebugInfo = useWidgetDebugStore((s) => toolCallId ? s.widgets.get(toolCallId) : undefined, @@ -403,6 +417,90 @@ export function ToolPart({ ); + const renderToolInput = () => + hasInput ? ( +
+
+ Input +
+
+ +
+
+ ) : null; + + const renderAttachedTraceDisplay = () => + hasAttachedTraceDisplay && traceDisplayText ? ( +
+
+ Result +
+
+ +
+
+ ) : null; + + const renderToolResult = () => + showRawResult ? ( +
+
+ Result +
+
+ +
+
+ ) : null; + + const renderToolError = () => + hasError ? ( +
+
+ Error +
+
+ {errorText} +
+
+ ) : null; + + const renderToolData = () => { + if (!hasInput && !showRawResult && !hasError && !hasAttachedTraceDisplay) { + return ( +
+ No tool details available. +
+ ); + } + + return ( +
+ {renderToolInput()} + {renderAttachedTraceDisplay()} + {renderToolResult()} + {renderToolError()} +
+ ); + }; + return (
{!hideDiagnosticsUI && ( <> - {hasWidgetDebug && activeDebugTab === "data" && ( -
- {hasInput && ( -
-
- Input -
-
- -
-
- )} - {hasOutput && ( -
-
- Result -
-
- -
-
- )} - {hasError && ( -
-
- Error -
-
- {errorText} -
-
- )} - {!hasInput && !hasOutput && !hasError && ( -
- No tool details available. -
- )} -
- )} + {hasWidgetDebug && activeDebugTab === "data" && renderToolData()} {hasWidgetDebug && activeDebugTab === "state" && (
@@ -681,62 +727,7 @@ export function ToolPart({ )}
)} - {!hasWidgetDebug && ( -
- {hasInput && ( -
-
- Input -
-
- -
-
- )} - - {hasOutput && ( -
-
- Result -
-
- -
-
- )} - - {hasError && ( -
-
- Error -
-
- {errorText} -
-
- )} - - {!hasInput && !hasOutput && !hasError && ( -
- No tool details available. -
- )} -
- )} + {!hasWidgetDebug && renderToolData()} )} {needsApproval && approvalVisualState === "pending" && ( diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/user-message-bubble.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/user-message-bubble.tsx index 351edc700..cec549254 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/user-message-bubble.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/user-message-bubble.tsx @@ -5,6 +5,8 @@ * Used by both ChatTabV2's Thread and the UI Playground for consistent styling. */ +import { useSandboxHostStyle } from "@/contexts/sandbox-host-style-context"; + interface UserMessageBubbleProps { children: React.ReactNode; className?: string; @@ -14,9 +16,19 @@ export function UserMessageBubble({ children, className = "", }: UserMessageBubbleProps) { + const sandboxHostStyle = useSandboxHostStyle(); + const bubbleClasses = + sandboxHostStyle === "chatgpt" + ? "sandbox-host-user-bubble rounded-[1.5rem] border-transparent bg-[#f4f4f4] text-[#1f1f1f] shadow-none dark:bg-[#2f2f2f] dark:text-[#f5f5f5]" + : sandboxHostStyle === "claude" + ? "sandbox-host-user-bubble rounded-xl border-[#d9d1c5] bg-[#f5f0e8] text-[#2d2926] shadow-none dark:border-[#4c473f] dark:bg-[#3a3832] dark:text-[#f2ede6]" + : "rounded-xl border border-[#e5e7ec] bg-[#f9fafc] text-[#1f2733] shadow-sm dark:border-[#4a5261] dark:bg-[#2f343e] dark:text-[#e6e8ed]"; + return (
-
+
{children}
diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/widget-replay.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/widget-replay.tsx index f90866fa9..05d6e837b 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/widget-replay.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/widget-replay.tsx @@ -13,6 +13,8 @@ import { readToolResultMeta, readToolResultServerId, } from "@/lib/tool-result-utils"; +import { useSandboxHostStyle } from "@/contexts/sandbox-host-style-context"; +import { getSandboxProtocolOverride } from "@/lib/sandbox-host-style"; import type { DisplayMode } from "@/stores/ui-playground-store"; export interface WidgetReplayProps { @@ -78,9 +80,14 @@ export function WidgetReplay({ displayMode, onDisplayModeChange, onAppSupportedDisplayModesChange, - selectedProtocolOverrideIfBothExists = UIType.OPENAI_SDK, + selectedProtocolOverrideIfBothExists, minimalMode = false, }: WidgetReplayProps) { + const sandboxHostStyle = useSandboxHostStyle(); + const protocolOverride = + selectedProtocolOverrideIfBothExists ?? + getSandboxProtocolOverride(sandboxHostStyle) ?? + UIType.OPENAI_SDK; const effectiveToolMeta = renderOverride?.toolMetadata ?? toolMetadata ?? @@ -98,7 +105,7 @@ export function WidgetReplay({ if ( uiType === UIType.OPENAI_SDK || (uiType === UIType.OPENAI_SDK_AND_MCP_APPS && - selectedProtocolOverrideIfBothExists === UIType.OPENAI_SDK) + protocolOverride === UIType.OPENAI_SDK) ) { if ( toolState !== "output-available" && @@ -151,7 +158,7 @@ export function WidgetReplay({ if ( uiType === UIType.MCP_APPS || (uiType === UIType.OPENAI_SDK_AND_MCP_APPS && - selectedProtocolOverrideIfBothExists === UIType.MCP_APPS) + protocolOverride === UIType.MCP_APPS) ) { if ( toolState !== "output-available" && diff --git a/mcpjam-inspector/client/src/components/connection/AddServerModal.tsx b/mcpjam-inspector/client/src/components/connection/AddServerModal.tsx index b07b30be6..c9eb53a9a 100644 --- a/mcpjam-inspector/client/src/components/connection/AddServerModal.tsx +++ b/mcpjam-inspector/client/src/components/connection/AddServerModal.tsx @@ -25,6 +25,7 @@ interface AddServerModalProps { onClose: () => void; onSubmit: (formData: ServerFormData) => void; initialData?: Partial; + requireHttps?: boolean; } export function AddServerModal({ @@ -32,9 +33,10 @@ export function AddServerModal({ onClose, onSubmit, initialData, + requireHttps, }: AddServerModalProps) { const posthog = usePostHog(); - const formState = useServerForm(); + const formState = useServerForm(undefined, { requireHttps }); // Initialize form with initial data if provided useEffect(() => { diff --git a/mcpjam-inspector/client/src/components/connection/ShareServerDialog.tsx b/mcpjam-inspector/client/src/components/connection/ShareServerDialog.tsx index c6904a3c8..7d397203a 100644 --- a/mcpjam-inspector/client/src/components/connection/ShareServerDialog.tsx +++ b/mcpjam-inspector/client/src/components/connection/ShareServerDialog.tsx @@ -45,8 +45,7 @@ import { useServerShareMutations, useServerShareSettings, } from "@/hooks/useServerShares"; -import { slugify } from "@/lib/shared-server-session"; -import { HOSTED_MODE } from "@/lib/config"; +import { getShareableAppOrigin, slugify } from "@/lib/shared-server-session"; import { ShareUsageDialog } from "./share-usage/ShareUsageDialog"; interface ShareServerDialogProps { @@ -140,9 +139,7 @@ export function ShareServerDialog({ if (!settings?.link?.token) return; try { const slug = slugify(serverName); - const origin = HOSTED_MODE - ? window.location.origin - : "https://app.mcpjam.com"; + const origin = getShareableAppOrigin(); const shareUrl = `${origin}/shared/${slug}/${encodeURIComponent(settings.link.token)}`; await navigator.clipboard.writeText(shareUrl); toast.success("Share link copied"); @@ -471,8 +468,9 @@ export function ShareServerDialog({ isOpen={isOpen && view === "usage"} onClose={onClose} onBackToSettings={() => setView("settings")} - shareId={settings.shareId} - serverName={serverName} + sourceType="serverShare" + sourceId={settings.shareId} + title={serverName} /> )} diff --git a/mcpjam-inspector/client/src/components/connection/hooks/__tests__/use-server-form.test.ts b/mcpjam-inspector/client/src/components/connection/hooks/__tests__/use-server-form.test.ts new file mode 100644 index 000000000..ec98f0cd9 --- /dev/null +++ b/mcpjam-inspector/client/src/components/connection/hooks/__tests__/use-server-form.test.ts @@ -0,0 +1,50 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/use-app-state", () => ({})); +vi.mock("@/lib/config", () => ({ + HOSTED_MODE: false, +})); +vi.mock("@/lib/oauth/mcp-oauth", () => ({ + hasOAuthConfig: vi.fn().mockReturnValue(false), + getStoredTokens: vi.fn().mockReturnValue(null), +})); + +import { useServerForm } from "../use-server-form"; + +describe("useServerForm", () => { + it("rejects malformed HTTP URLs even when HTTPS is optional", () => { + const { result } = renderHook(() => useServerForm()); + + act(() => { + result.current.setName("Test server"); + result.current.setUrl("foo"); + }); + + expect(result.current.validateForm()).toBe("Invalid URL format"); + }); + + it("allows valid HTTP URLs when HTTPS is not required", () => { + const { result } = renderHook(() => useServerForm()); + + act(() => { + result.current.setName("Test server"); + result.current.setUrl("http://example.com/mcp"); + }); + + expect(result.current.validateForm()).toBeNull(); + }); + + it("still enforces HTTPS when explicitly required", () => { + const { result } = renderHook(() => + useServerForm(undefined, { requireHttps: true }), + ); + + act(() => { + result.current.setName("Test server"); + result.current.setUrl("http://example.com/mcp"); + }); + + expect(result.current.validateForm()).toBe("HTTPS is required"); + }); +}); diff --git a/mcpjam-inspector/client/src/components/connection/hooks/use-server-form.ts b/mcpjam-inspector/client/src/components/connection/hooks/use-server-form.ts index b95cbc447..512502eee 100644 --- a/mcpjam-inspector/client/src/components/connection/hooks/use-server-form.ts +++ b/mcpjam-inspector/client/src/components/connection/hooks/use-server-form.ts @@ -4,7 +4,10 @@ import { ServerWithName } from "@/hooks/use-app-state"; import { hasOAuthConfig, getStoredTokens } from "@/lib/oauth/mcp-oauth"; import { HOSTED_MODE } from "@/lib/config"; -export function useServerForm(server?: ServerWithName) { +export function useServerForm( + server?: ServerWithName, + options?: { requireHttps?: boolean }, +) { const [name, setName] = useState(""); const [type, setType] = useState<"stdio" | "http">("http"); const [commandInput, setCommandInput] = useState(""); @@ -184,16 +187,19 @@ export function useServerForm(server?: ServerWithName) { return "URL is required for HTTP servers"; } - // Enforce HTTPS in hosted mode - if (HOSTED_MODE) { - try { - const urlObj = new URL(url.trim()); - if (urlObj.protocol !== "https:") { - return "HTTPS is required in web app"; - } - } catch { - return "Invalid URL format"; - } + let urlObj: URL; + try { + urlObj = new URL(url.trim()); + } catch { + return "Invalid URL format"; + } + + // Enforce HTTPS in hosted mode or when explicitly required + if ( + (HOSTED_MODE || options?.requireHttps) && + urlObj.protocol !== "https:" + ) { + return "HTTPS is required"; } } diff --git a/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageDialog.tsx b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageDialog.tsx index 33bc03b14..86971f0df 100644 --- a/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageDialog.tsx +++ b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageDialog.tsx @@ -14,27 +14,30 @@ import { } from "@/components/ui/resizable"; import { ShareUsageThreadList } from "./ShareUsageThreadList"; import { ShareUsageThreadDetail } from "./ShareUsageThreadDetail"; +import type { SharedChatSourceType } from "@/hooks/useSharedChatThreads"; interface ShareUsageDialogProps { isOpen: boolean; onClose: () => void; onBackToSettings: () => void; - shareId: string; - serverName: string; + sourceType: SharedChatSourceType; + sourceId: string; + title: string; } export function ShareUsageDialog({ isOpen, onClose, onBackToSettings, - shareId, - serverName, + sourceType, + sourceId, + title, }: ShareUsageDialogProps) { const [selectedThreadId, setSelectedThreadId] = useState(null); useEffect(() => { setSelectedThreadId(null); - }, [shareId]); + }, [sourceId]); return ( - Usage — {serverName} + Usage — {title}
@@ -65,7 +68,8 @@ export function ShareUsageDialog({
diff --git a/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadDetail.tsx b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadDetail.tsx index 34f81538b..49d9e8109 100644 --- a/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadDetail.tsx +++ b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadDetail.tsx @@ -89,7 +89,7 @@ export function ShareUsageThreadDetail({ toolCallId: snap.toolCallId, toolName: snap.toolName, protocol: snap.uiType, - serverId: thread.serverId, + serverId: snap.serverId, resourceUri: snap.resourceUri ?? "", toolMetadata, widgetCsp: snap.widgetCsp, @@ -106,8 +106,10 @@ export function ShareUsageThreadDetail({ if (!messages) return null; return adaptTraceToUiMessages({ trace: { messages: messages as any, widgetSnapshots }, + toolResultDisplay: + thread?.sourceType === "sandbox" ? "attached-to-tool" : "sibling-text", }); - }, [messages, widgetSnapshots]); + }, [messages, thread?.sourceType, widgetSnapshots]); const resolvedModel: ModelDefinition = useMemo( () => ({ @@ -161,6 +163,8 @@ export function ShareUsageThreadDetail({ ? `${Math.round(duration / 1000)}s` : `${Math.round(duration / 60000)}m` : null; + const isSandboxThread = thread.sourceType === "sandbox"; + const reasoningDisplayMode = isSandboxThread ? "collapsible" : "collapsed"; return (
@@ -214,8 +218,9 @@ export function ShareUsageThreadDetail({ onExitFullscreen={NOOP} toolRenderOverrides={adaptedTrace.toolRenderOverrides} showSaveViewButton={false} - minimalMode={true} + minimalMode={!isSandboxThread} interactive={false} + reasoningDisplayMode={reasoningDisplayMode} /> ))}
diff --git a/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadList.tsx b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadList.tsx index 4b313d1d8..fcdd8c463 100644 --- a/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadList.tsx +++ b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadList.tsx @@ -3,21 +3,24 @@ import { MessageSquare } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useSharedChatThreadList, + type SharedChatSourceType, type SharedChatThread, } from "@/hooks/useSharedChatThreads"; interface ShareUsageThreadListProps { - shareId: string; + sourceType: SharedChatSourceType; + sourceId: string; selectedThreadId: string | null; onSelectThread: (threadId: string) => void; } export function ShareUsageThreadList({ - shareId, + sourceType, + sourceId, selectedThreadId, onSelectThread, }: ShareUsageThreadListProps) { - const { threads } = useSharedChatThreadList({ shareId }); + const { threads } = useSharedChatThreadList({ sourceType, sourceId }); if (threads === undefined) { return ( diff --git a/mcpjam-inspector/client/src/components/connection/share-usage/__tests__/ShareUsageThreadDetail.test.tsx b/mcpjam-inspector/client/src/components/connection/share-usage/__tests__/ShareUsageThreadDetail.test.tsx new file mode 100644 index 000000000..8b0b081c1 --- /dev/null +++ b/mcpjam-inspector/client/src/components/connection/share-usage/__tests__/ShareUsageThreadDetail.test.tsx @@ -0,0 +1,138 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ShareUsageThreadDetail } from "../ShareUsageThreadDetail"; + +const { mockMessageView, mockAdaptTraceToUiMessages, mockThreadState } = + vi.hoisted(() => ({ + mockMessageView: vi.fn(), + mockAdaptTraceToUiMessages: vi.fn(), + mockThreadState: { + sourceType: "sandbox", + }, + })); + +vi.mock("@/hooks/useSharedChatThreads", () => ({ + useSharedChatThread: () => ({ + thread: { + sourceType: mockThreadState.sourceType, + messagesBlobUrl: "https://storage.example.com/thread.json", + modelId: "openai/gpt-oss-120b", + visitorDisplayName: "Marcelo Jimenez", + messageCount: 2, + startedAt: Date.now() - 1000, + lastActivityAt: Date.now(), + }, + }), + useSharedChatWidgetSnapshots: () => ({ + snapshots: [], + }), +})); + +vi.mock("@/components/evals/trace-viewer-adapter", () => ({ + adaptTraceToUiMessages: (...args: unknown[]) => + mockAdaptTraceToUiMessages(...args), +})); + +vi.mock("@/components/chat-v2/thread/message-view", () => ({ + MessageView: (props: Record) => { + mockMessageView(props); + return
; + }, +})); + +describe("ShareUsageThreadDetail", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.clearAllMocks(); + mockThreadState.sourceType = "sandbox"; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [{ role: "assistant", content: [] }], + } as Response); + mockAdaptTraceToUiMessages.mockReturnValue({ + messages: [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "reasoning", + text: "Collapsed in share usage traces", + state: "done", + }, + ], + }, + ], + toolRenderOverrides: {}, + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("renders formatted share traces with collapsed reasoning", async () => { + render(); + + await waitFor(() => { + expect( + screen.queryByRole("button", { name: "Chat" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Trace" }), + ).not.toBeInTheDocument(); + expect(mockAdaptTraceToUiMessages).toHaveBeenCalledWith( + expect.objectContaining({ + toolResultDisplay: "attached-to-tool", + }), + ); + expect(mockMessageView).toHaveBeenCalledWith( + expect.objectContaining({ + reasoningDisplayMode: "collapsible", + interactive: false, + minimalMode: false, + }), + ); + }); + }); + + it("keeps sandbox threads in chat mode without a toggle", async () => { + render(); + + await waitFor(() => { + expect(mockMessageView).toHaveBeenCalledWith( + expect.objectContaining({ + minimalMode: false, + reasoningDisplayMode: "collapsible", + }), + ); + }); + }); + + it("keeps server share threads in trace mode without a toggle", async () => { + mockThreadState.sourceType = "serverShare"; + + render(); + + await waitFor(() => { + expect( + screen.queryByRole("button", { name: "Chat" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Trace" }), + ).not.toBeInTheDocument(); + expect(mockAdaptTraceToUiMessages).toHaveBeenCalledWith( + expect.objectContaining({ + toolResultDisplay: "sibling-text", + }), + ); + expect(mockMessageView).toHaveBeenCalledWith( + expect.objectContaining({ + minimalMode: true, + reasoningDisplayMode: "collapsed", + }), + ); + }); + }); +}); diff --git a/mcpjam-inspector/client/src/components/evals/__tests__/trace-viewer-adapter.test.ts b/mcpjam-inspector/client/src/components/evals/__tests__/trace-viewer-adapter.test.ts index 0b7602f4a..a1c9055f5 100644 --- a/mcpjam-inspector/client/src/components/evals/__tests__/trace-viewer-adapter.test.ts +++ b/mcpjam-inspector/client/src/components/evals/__tests__/trace-viewer-adapter.test.ts @@ -93,6 +93,98 @@ describe("adaptTraceToUiMessages", () => { expect(msg.parts[2]).toMatchObject({ type: "text" }); }); + it("attaches readable tool output to the tool part in attached-to-tool mode", () => { + const trace: TraceEnvelope = { + messages: [ + { + role: "assistant", + content: [ + { type: "text", text: "Let me do that." }, + { + type: "tool-call", + toolCallId: "call-1", + toolName: "read_me", + input: { id: 42 }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "read_me", + output: { type: "json", value: { text: "result text" } }, + }, + ], + }, + ], + }; + + const result = adaptTraceToUiMessages({ + trace, + toolResultDisplay: "attached-to-tool", + }); + expect(result.messages).toHaveLength(1); + + const msg = result.messages[0]; + expect(msg.parts).toHaveLength(2); + expect(msg.parts[1]).toMatchObject({ + type: "dynamic-tool", + toolCallId: "call-1", + toolName: "read_me", + traceDisplayText: "result text", + traceDisplayMode: "markdown", + }); + expect( + msg.parts.find((part) => part.type === "text" && part !== msg.parts[0]), + ).toBeUndefined(); + }); + + it("attaches structured tool output as json markdown in attached-to-tool mode", () => { + const trace: TraceEnvelope = { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "read_me", + input: {}, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "read_me", + output: { hello: "world" }, + }, + ], + }, + ], + }; + + const result = adaptTraceToUiMessages({ + trace, + toolResultDisplay: "attached-to-tool", + }); + + expect(result.messages[0].parts).toHaveLength(1); + expect(result.messages[0].parts[0]).toMatchObject({ + type: "dynamic-tool", + traceDisplayMode: "json-markdown", + }); + expect((result.messages[0].parts[0] as any).traceDisplayText).toContain( + '"hello": "world"', + ); + }); + // --- Test 2: Multiple tool calls --- it("groups multiple tool-calls and results into a single assistant UIMessage", () => { const trace: TraceEnvelope = { diff --git a/mcpjam-inspector/client/src/components/evals/__tests__/trace-viewer.test.tsx b/mcpjam-inspector/client/src/components/evals/__tests__/trace-viewer.test.tsx index 9f2837a4a..636fbcb24 100644 --- a/mcpjam-inspector/client/src/components/evals/__tests__/trace-viewer.test.tsx +++ b/mcpjam-inspector/client/src/components/evals/__tests__/trace-viewer.test.tsx @@ -59,6 +59,25 @@ const simpleTextTrace = { ], }; +const reasoningTrace = { + messages: [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Thinking through the tool choice.", + state: "done", + }, + { + type: "text", + text: "I should call the server listing tool.", + }, + ], + }, + ], +}; + const toolTrace = { messages: [ { @@ -182,6 +201,16 @@ describe("TraceViewer", () => { ); }); + it("requests collapsed reasoning rendering in formatted trace mode", () => { + render(); + + expect(mockMessageView).toHaveBeenCalledWith( + expect.objectContaining({ + reasoningDisplayMode: "collapsed", + }), + ); + }); + it("forwards ModelDefinition when provided", () => { const model = { id: "gpt-4o", diff --git a/mcpjam-inspector/client/src/components/evals/trace-viewer-adapter.ts b/mcpjam-inspector/client/src/components/evals/trace-viewer-adapter.ts index 050d1117a..cd545464f 100644 --- a/mcpjam-inspector/client/src/components/evals/trace-viewer-adapter.ts +++ b/mcpjam-inspector/client/src/components/evals/trace-viewer-adapter.ts @@ -61,6 +61,9 @@ export interface AdaptedTraceResult { toolRenderOverrides: Record; } +type ToolResultDisplay = "sibling-text" | "attached-to-tool"; +type TraceDisplayMode = "markdown" | "json-markdown"; + interface TraceToolResultEntry { part: TraceContentPart; messageIndex: number; @@ -361,6 +364,34 @@ function createReplayOverride( }).renderOverride; } +function getTraceDisplayAttachment(params: { + displayedOutput: unknown; + adaptedOutput: unknown; + canReplayWidget: boolean; +}): { text: string; mode: TraceDisplayMode } | null { + const extractedText = extractTextFromToolResult(params.displayedOutput); + if (extractedText) { + return { + text: extractedText, + mode: "markdown", + }; + } + + if (params.canReplayWidget) { + return null; + } + + const jsonMarkdown = toMarkdownJson(params.adaptedOutput); + if (!jsonMarkdown) { + return null; + } + + return { + text: jsonMarkdown, + mode: "json-markdown", + }; +} + function buildToolParts(params: { toolCall: TraceContentPart; matchedResult?: TraceContentPart; @@ -371,6 +402,7 @@ function buildToolParts(params: { toolServerMap: ToolServerMap; connectedServerIds: Set; toolRenderOverrides: Record; + toolResultDisplay: ToolResultDisplay; }): UIMessage["parts"] { const toolName = getToolName(params.toolCall); const toolInput = getToolInput(params.toolCall); @@ -422,8 +454,20 @@ function buildToolParts(params: { }; } + const traceDisplayAttachment = + !isError && params.matchedResult + ? getTraceDisplayAttachment({ + displayedOutput, + adaptedOutput, + canReplayWidget, + }) + : null; + const parts: UIMessage["parts"] = []; - const toolPart: DynamicToolUIPart = isError + const toolPart: DynamicToolUIPart & { + traceDisplayText?: string; + traceDisplayMode?: TraceDisplayMode; + } = isError ? { type: "dynamic-tool", toolCallId, @@ -448,6 +492,13 @@ function buildToolParts(params: { state: "input-available", input: toolInput, }; + if ( + params.toolResultDisplay === "attached-to-tool" && + traceDisplayAttachment + ) { + toolPart.traceDisplayText = traceDisplayAttachment.text; + toolPart.traceDisplayMode = traceDisplayAttachment.mode; + } parts.push(toolPart); if (isError) { @@ -462,23 +513,15 @@ function buildToolParts(params: { return parts; } - const extractedText = extractTextFromToolResult(displayedOutput); - if (extractedText) { - parts.push({ - type: "text", - text: extractedText, - }); + if (params.toolResultDisplay === "attached-to-tool") { return parts; } - if (!canReplayWidget) { - const jsonMarkdown = toMarkdownJson(adaptedOutput); - if (jsonMarkdown) { - parts.push({ - type: "text", - text: jsonMarkdown, - }); - } + if (traceDisplayAttachment) { + parts.push({ + type: "text", + text: traceDisplayAttachment.text, + }); } return parts; @@ -507,6 +550,7 @@ function buildAssistantMessage(params: { toolServerMap: ToolServerMap; connectedServerIds: Set; toolRenderOverrides: Record; + toolResultDisplay: ToolResultDisplay; }): { message: UIMessage; extraMessages: UIMessage[] } { const assistantParts = normalizeMessageContent(params.message); const toolResultEntries = params.toolMessages.flatMap( @@ -550,6 +594,7 @@ function buildAssistantMessage(params: { toolServerMap: params.toolServerMap, connectedServerIds: params.connectedServerIds, toolRenderOverrides: params.toolRenderOverrides, + toolResultDisplay: params.toolResultDisplay, }), ); return; @@ -595,6 +640,7 @@ function buildAssistantMessage(params: { toolServerMap: params.toolServerMap, connectedServerIds: params.connectedServerIds, toolRenderOverrides: params.toolRenderOverrides, + toolResultDisplay: params.toolResultDisplay, }), } satisfies UIMessage; }, @@ -633,6 +679,7 @@ function buildOrphanToolMessages(params: { toolServerMap: ToolServerMap; connectedServerIds: Set; toolRenderOverrides: Record; + toolResultDisplay: ToolResultDisplay; }) { return params.toolMessages.flatMap((message, messageOffset) => normalizeMessageContent(message) @@ -669,6 +716,7 @@ function buildOrphanToolMessages(params: { toolServerMap: params.toolServerMap, connectedServerIds: params.connectedServerIds, toolRenderOverrides: params.toolRenderOverrides, + toolResultDisplay: params.toolResultDisplay, }), } satisfies UIMessage; }) @@ -681,6 +729,7 @@ export function adaptTraceToUiMessages(params: { toolsMetadata?: Record>; toolServerMap?: ToolServerMap; connectedServerIds?: string[]; + toolResultDisplay?: ToolResultDisplay; }): AdaptedTraceResult { const { messages, widgetSnapshots } = resolveTraceMessages(params.trace); const widgetSnapshotMap = new Map( @@ -691,6 +740,7 @@ export function adaptTraceToUiMessages(params: { const toolsMetadata = params.toolsMetadata ?? {}; const toolServerMap = params.toolServerMap ?? {}; const connectedServerIds = new Set(params.connectedServerIds ?? []); + const toolResultDisplay = params.toolResultDisplay ?? "sibling-text"; for (let index = 0; index < messages.length; index++) { const message = messages[index]; @@ -717,6 +767,7 @@ export function adaptTraceToUiMessages(params: { toolServerMap, connectedServerIds, toolRenderOverrides, + toolResultDisplay, }); uiMessages.push( adaptedAssistant.message, @@ -743,6 +794,7 @@ export function adaptTraceToUiMessages(params: { toolServerMap, connectedServerIds, toolRenderOverrides, + toolResultDisplay, }), ); index = nextIndex - 1; diff --git a/mcpjam-inspector/client/src/components/evals/trace-viewer.tsx b/mcpjam-inspector/client/src/components/evals/trace-viewer.tsx index 4734d2967..47146fffb 100644 --- a/mcpjam-inspector/client/src/components/evals/trace-viewer.tsx +++ b/mcpjam-inspector/client/src/components/evals/trace-viewer.tsx @@ -147,6 +147,7 @@ export function TraceViewer({ showSaveViewButton={false} minimalMode={true} interactive={false} + reasoningDisplayMode="collapsed" /> ))}
diff --git a/mcpjam-inspector/client/src/components/hosted/SandboxChatPage.tsx b/mcpjam-inspector/client/src/components/hosted/SandboxChatPage.tsx new file mode 100644 index 000000000..cfda68aaf --- /dev/null +++ b/mcpjam-inspector/client/src/components/hosted/SandboxChatPage.tsx @@ -0,0 +1,651 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useAuth } from "@workos-inc/authkit-react"; +import { useConvexAuth } from "convex/react"; +import { Loader2, Link2Off, ShieldX } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { ChatTabV2 } from "@/components/ChatTabV2"; +import type { ServerWithName } from "@/hooks/use-app-state"; +import { useHostedApiContext } from "@/hooks/hosted/use-hosted-api-context"; +import { useHostedOAuthGate } from "@/hooks/hosted/use-hosted-oauth-gate"; +import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; +import { getGuestBearerToken } from "@/lib/guest-session"; +import { getStoredTokens } from "@/lib/oauth/mcp-oauth"; +import { + buildSandboxLink, + clearSandboxSession, + extractSandboxTokenFromPath, + readSandboxSession, + SANDBOX_OAUTH_PENDING_KEY, + type SandboxSession, + writeSandboxSession, + writeSandboxSignInReturnPath, +} from "@/lib/sandbox-session"; +import { isHostedOAuthBusy } from "@/lib/hosted-oauth-resume"; +import type { HostedOAuthRequiredDetails } from "@/lib/hosted-oauth-required"; +import { slugify } from "@/lib/shared-server-session"; +import { SandboxHostStyleProvider } from "@/contexts/sandbox-host-style-context"; +import { getSandboxShellStyle } from "@/lib/sandbox-host-style"; + +interface SandboxChatPageProps { + pathToken?: string | null; + onExitSandboxChat?: () => void; +} + +interface SandboxRouteError { + status: number; + code?: string; + message: string; + rawMessage: string; +} + +type SandboxErrorKind = + | "access_denied" + | "guest_blocked" + | "invalid_link" + | "unexpected"; + +interface SandboxDisplayError { + kind: SandboxErrorKind; + title: string; + message: string; +} + +const INVALID_SANDBOX_LINK_MESSAGE = + "This sandbox link is invalid or expired. Ask the owner to share a new link if you still need access."; +const UNEXPECTED_SANDBOX_ERROR_MESSAGE = + "We couldn't open this sandbox right now. Please try again or open MCPJam."; + +async function getHostedBearerHeader( + getAccessToken: () => Promise, +): Promise { + try { + const workOsToken = await getAccessToken(); + if (workOsToken) { + return `Bearer ${workOsToken}`; + } + } catch { + // Fall through to guest auth. + } + + const guestToken = await getGuestBearerToken(); + return guestToken ? `Bearer ${guestToken}` : null; +} + +function sanitizeSandboxRouteErrorMessage(message: string): string { + const normalized = message.replace(/\s+/g, " ").trim(); + if (!normalized) { + return ""; + } + + const withoutWrapper = normalized.replace(/^Uncaught Error:\s*/i, ""); + return withoutWrapper + .replace(/\s+at\s+(?:async\s+)?[A-Za-z0-9_$./<>-]+(?:\s+\(|$).*/s, "") + .trim(); +} + +function createSandboxRouteError( + status: number, + message: string, + code?: string, +): SandboxRouteError { + const fallbackMessage = `Request failed with status ${status}`; + const rawMessage = message.trim() || fallbackMessage; + const sanitizedMessage = sanitizeSandboxRouteErrorMessage(rawMessage); + + return { + status, + code, + rawMessage, + message: sanitizedMessage || fallbackMessage, + }; +} + +async function readRouteError(response: Response): Promise { + const bodyText = await response.text(); + const trimmedBody = bodyText.trim(); + let code: string | undefined; + let message = trimmedBody; + + try { + const body = (trimmedBody ? JSON.parse(trimmedBody) : null) as { + code?: string; + message?: string; + error?: string; + } | null; + + code = typeof body?.code === "string" ? body.code : undefined; + message = + body?.message || + body?.error || + trimmedBody || + `Request failed with status ${response.status}`; + } catch { + message = trimmedBody || `Request failed with status ${response.status}`; + } + + return createSandboxRouteError(response.status, message, code); +} + +function isSandboxRouteError(error: unknown): error is SandboxRouteError { + return ( + !!error && + typeof error === "object" && + "status" in error && + typeof error.status === "number" && + "message" in error && + typeof error.message === "string" && + "rawMessage" in error && + typeof error.rawMessage === "string" + ); +} + +function getSandboxDisplayError( + error: SandboxRouteError | null, +): SandboxDisplayError { + if (!error) { + return { + kind: "invalid_link", + title: "Sandbox Link Unavailable", + message: INVALID_SANDBOX_LINK_MESSAGE, + }; + } + + const normalizedMessage = error.message.toLowerCase(); + const requiresSignIn = normalizedMessage.includes( + "sign in to access this sandbox", + ); + const isAccessDenied = normalizedMessage.includes("don't have access"); + const isGuestBlocked = + normalizedMessage.includes("guests cannot access") || + normalizedMessage.includes("guest access"); + const isInvalidLink = + error.status === 404 || + error.code === "NOT_FOUND" || + normalizedMessage.includes("invalid or has expired") || + normalizedMessage.includes("invalid or expired"); + + if (requiresSignIn || isAccessDenied) { + return { + kind: "access_denied", + title: "Access Denied", + message: error.message, + }; + } + + if (isGuestBlocked) { + return { + kind: "guest_blocked", + title: "Access Denied", + message: error.message, + }; + } + + if (isInvalidLink) { + return { + kind: "invalid_link", + title: "Sandbox Link Unavailable", + message: INVALID_SANDBOX_LINK_MESSAGE, + }; + } + + return { + kind: "unexpected", + title: "Sandbox Link Unavailable", + message: UNEXPECTED_SANDBOX_ERROR_MESSAGE, + }; +} + +function getSandboxOAuthRowCopy(status: string): { + description: string; + buttonLabel: string | null; +} { + switch (status) { + case "launching": + return { + description: "Opening consent screen…", + buttonLabel: null, + }; + case "resuming": + return { + description: "Finishing authorization…", + buttonLabel: null, + }; + case "verifying": + return { + description: "Verifying access…", + buttonLabel: null, + }; + case "error": + return { + description: "Authorization could not be completed. Try again.", + buttonLabel: "Authorize again", + }; + case "needs_auth": + default: + return { + description: "You'll return here automatically after consent.", + buttonLabel: "Authorize", + }; + } +} + +export function SandboxChatPage({ + pathToken, + onExitSandboxChat, +}: SandboxChatPageProps) { + const { getAccessToken, signIn } = useAuth(); + const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth(); + const themeMode = usePreferencesStore((s) => s.themeMode); + + const [session, setSession] = useState(() => + readSandboxSession(), + ); + const [isResolving, setIsResolving] = useState(!!pathToken); + const [routeError, setRouteError] = useState(null); + + const oauthServers = useMemo(() => session?.payload.servers ?? [], [session]); + const { + oauthStateByServerId, + pendingOAuthServers, + authorizeServer, + markOAuthRequired, + } = useHostedOAuthGate({ + surface: "sandbox", + pendingKey: SANDBOX_OAUTH_PENDING_KEY, + servers: oauthServers, + }); + + const sandboxServerConfigs = useMemo(() => { + if (!session) return {}; + + return Object.fromEntries( + session.payload.servers.map((server) => [ + server.serverName, + { + name: server.serverName, + config: { + url: "https://sandbox-chat.invalid", + } as any, + lastConnectionTime: new Date(), + connectionStatus: "connected", + retryCount: 0, + enabled: true, + } satisfies ServerWithName, + ]), + ); + }, [session]); + + const hostedServerIdsByName = useMemo(() => { + if (!session) return {}; + + return Object.fromEntries( + session.payload.servers.flatMap((server) => [ + [server.serverName, server.serverId], + [server.serverId, server.serverId], + ]), + ); + }, [session]); + + const oauthTokensForChat = useMemo(() => { + if (!session) return undefined; + + const entries = session.payload.servers + .map((server) => { + const token = getStoredTokens(server.serverName)?.access_token; + return token ? ([server.serverId, token] as const) : null; + }) + .filter((entry): entry is readonly [string, string] => + Array.isArray(entry), + ); + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; + }, [oauthStateByServerId, session]); + + useHostedApiContext({ + workspaceId: session?.payload.workspaceId ?? null, + serverIdsByName: hostedServerIdsByName, + getAccessToken, + oauthTokensByServerId: oauthTokensForChat, + sandboxToken: session?.token, + isAuthenticated, + }); + + useEffect(() => { + if (isAuthLoading) { + return; + } + + let cancelled = false; + + const resolve = async () => { + const tokenFromPath = pathToken?.trim() || null; + + if (tokenFromPath) { + setIsResolving(true); + setRouteError(null); + try { + const authorization = await getHostedBearerHeader(getAccessToken); + if (!authorization) { + throw new Error( + "Unable to create a hosted session for this sandbox.", + ); + } + + const response = await fetch("/api/web/sandboxes/bootstrap", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authorization, + }, + body: JSON.stringify({ token: tokenFromPath }), + }); + + if (!response.ok) { + throw await readRouteError(response); + } + + const payload = (await response.json()) as SandboxSession["payload"]; + if (cancelled) return; + + const nextSession: SandboxSession = { + token: tokenFromPath, + payload, + }; + writeSandboxSession(nextSession); + setSession(nextSession); + setRouteError(null); + + const nextSlug = slugify(nextSession.payload.name); + if (window.location.hash !== `#${nextSlug}`) { + window.history.replaceState({}, "", `/#${nextSlug}`); + } + } catch (error) { + if (cancelled) return; + setSession(null); + clearSandboxSession(); + + const nextError = isSandboxRouteError(error) + ? error + : createSandboxRouteError( + 500, + error instanceof Error + ? error.message + : "Unable to open this sandbox.", + ); + const displayError = getSandboxDisplayError(nextError); + + if (displayError.kind === "unexpected") { + console.error("[SandboxChatPage] Failed to bootstrap sandbox", { + status: nextError.status, + code: nextError.code, + message: nextError.message, + rawMessage: nextError.rawMessage, + }); + } + + setRouteError(nextError); + } finally { + if (!cancelled) { + setIsResolving(false); + } + } + return; + } + + const recovered = readSandboxSession(); + if (recovered) { + setSession(recovered); + setRouteError(null); + const recoveredSlug = slugify(recovered.payload.name); + if (window.location.hash !== `#${recoveredSlug}`) { + window.history.replaceState({}, "", `/#${recoveredSlug}`); + } + return; + } + + setSession(null); + setRouteError( + createSandboxRouteError(404, "Invalid or expired sandbox link"), + ); + }; + + void resolve(); + + return () => { + cancelled = true; + }; + }, [getAccessToken, isAuthLoading, pathToken]); + + useEffect(() => { + if (!session) return; + + const expectedHash = slugify(session.payload.name); + const enforceHash = () => { + if (window.location.hash !== `#${expectedHash}`) { + window.location.hash = expectedHash; + } + }; + + enforceHash(); + window.addEventListener("hashchange", enforceHash); + return () => { + window.removeEventListener("hashchange", enforceHash); + }; + }, [session]); + + const handleCopyLink = useCallback(async () => { + const token = session?.token?.trim(); + if (!session || !token) { + toast.error("Sandbox link unavailable"); + return; + } + + if (!navigator.clipboard?.writeText) { + toast.error("Copy is not available in this browser"); + return; + } + + try { + await navigator.clipboard.writeText( + buildSandboxLink(token, session.payload.name), + ); + toast.success("Sandbox link copied"); + } catch { + toast.error("Failed to copy sandbox link"); + } + }, [session]); + + const handleOpenMcpJam = useCallback(() => { + clearSandboxSession(); + window.history.replaceState({}, "", "/#sandboxes"); + onExitSandboxChat?.(); + }, [onExitSandboxChat]); + + const handleSignIn = useCallback(() => { + writeSandboxSignInReturnPath(window.location.pathname); + signIn(); + }, [signIn]); + + const handleOAuthRequired = useCallback( + (details?: HostedOAuthRequiredDetails) => { + markOAuthRequired(details); + }, + [markOAuthRequired], + ); + + const hostStyle = session?.payload.hostStyle ?? "claude"; + const shellStyle = getSandboxShellStyle(hostStyle, themeMode); + const displayError = getSandboxDisplayError(routeError); + const isFinishingOAuth = + pendingOAuthServers.length > 0 && + pendingOAuthServers.every(({ state }) => isHostedOAuthBusy(state.status)); + + const renderContent = () => { + if (isResolving) { + return ( +
+ +
+ ); + } + + if (!session) { + const isAccessDenied = displayError.kind === "access_denied"; + const guestBlocked = displayError.kind === "guest_blocked"; + + return ( +
+
+
+ {isAccessDenied || guestBlocked ? ( + + ) : ( + + )} +
+

+ {displayError.title} +

+

+ {displayError.message} +

+
+ {!isAuthenticated && (isAccessDenied || guestBlocked) ? ( + + ) : null} + +
+
+
+ ); + } + + if (pendingOAuthServers.length > 0) { + return ( +
+
+

+ {isFinishingOAuth + ? "Finishing authorization" + : "Authorization Required"} +

+

+ {isFinishingOAuth + ? "Finishing authorization for the required sandbox servers." + : "Authorize the required sandbox servers to continue."} +

+
+ {pendingOAuthServers.map(({ server, state }) => { + const rowCopy = getSandboxOAuthRowCopy(state.status); + return ( +
+
+

+ {server.serverName} +

+

+ {state.status === "error" && state.errorMessage + ? state.errorMessage + : rowCopy.description} +

+
+ {rowCopy.buttonLabel ? ( + + ) : ( + + )} +
+ ); + })} +
+
+
+ ); + } + + return ( +
+ server.serverName, + )} + minimalMode + reasoningDisplayMode="hidden" + hostedWorkspaceIdOverride={session.payload.workspaceId} + hostedSelectedServerIdsOverride={session.payload.servers.map( + (server) => server.serverId, + )} + hostedOAuthTokensOverride={oauthTokensForChat} + hostedSandboxToken={session.token} + initialModelId={session.payload.modelId} + initialSystemPrompt={session.payload.systemPrompt} + initialTemperature={session.payload.temperature} + initialRequireToolApproval={session.payload.requireToolApproval} + onOAuthRequired={handleOAuthRequired} + /> +
+ ); + }; + + return ( + +
+
+
+

+ {session?.payload.name || "\u00A0"} +

+ +
+ {session ? ( + + ) : null} +
+
+
+ + {renderContent()} +
+
+ ); +} + +export function getSandboxPathTokenFromLocation(): string | null { + return extractSandboxTokenFromPath(window.location.pathname); +} diff --git a/mcpjam-inspector/client/src/components/hosted/SharedServerChatPage.tsx b/mcpjam-inspector/client/src/components/hosted/SharedServerChatPage.tsx index 53f8c1934..c6e1634d1 100644 --- a/mcpjam-inspector/client/src/components/hosted/SharedServerChatPage.tsx +++ b/mcpjam-inspector/client/src/components/hosted/SharedServerChatPage.tsx @@ -2,15 +2,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useConvexAuth } from "convex/react"; import { ConvexError } from "convex/values"; import { useAuth } from "@workos-inc/authkit-react"; -import { Loader2, Link2Off, Lock, ShieldX } from "lucide-react"; +import { Loader2, Link2Off, ShieldX } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { ChatTabV2 } from "@/components/ChatTabV2"; import type { ServerWithName } from "@/hooks/use-app-state"; import { useHostedApiContext } from "@/hooks/hosted/use-hosted-api-context"; +import { useHostedOAuthGate } from "@/hooks/hosted/use-hosted-oauth-gate"; +import { checkHostedServerOAuthRequirement } from "@/lib/apis/web/servers-api"; +import type { HostedOAuthRequiredDetails } from "@/lib/hosted-oauth-required"; import { clearSharedServerSession, extractSharedTokenFromPath, + getShareableAppOrigin, readSharedServerSession, slugify, SHARED_OAUTH_PENDING_KEY, @@ -18,7 +22,7 @@ import { writeSharedServerSession, writePendingServerAdd, } from "@/lib/shared-server-session"; -import { getStoredTokens, initiateOAuth } from "@/lib/oauth/mcp-oauth"; +import { getStoredTokens } from "@/lib/oauth/mcp-oauth"; function extractShareErrorMessage(error: unknown): string { if (error instanceof ConvexError) { @@ -27,7 +31,6 @@ function extractShareErrorMessage(error: unknown): string { : "This shared link is invalid or expired."; } if (error instanceof Error) { - // Legacy fallback: Convex wraps errors as "[CONVEX ...] Uncaught Error: ..." const uncaughtMatch = error.message.match( /Uncaught Error:\s*(.*?)\s*(?:\bat handler\b|$)/s, ); @@ -41,9 +44,49 @@ interface SharedServerChatPageProps { onExitSharedChat?: () => void; } -const OAUTH_PREFLIGHT_TOKEN_RETRY_MS = 250; -const OAUTH_PREFLIGHT_REQUEST_RETRY_MS = 1000; -const OAUTH_PREFLIGHT_VALIDATE_TOKEN_ATTEMPTS = 8; +function getSharedOAuthCopy( + status: string, + serverName: string, +): { + title: string; + description: string; + buttonLabel: string | null; +} { + switch (status) { + case "launching": + return { + title: "Finishing authorization", + description: "Opening the consent screen…", + buttonLabel: null, + }; + case "resuming": + return { + title: "Finishing authorization", + description: "Waiting for the OAuth callback to finish…", + buttonLabel: null, + }; + case "verifying": + return { + title: "Finishing authorization", + description: "Verifying access…", + buttonLabel: null, + }; + case "error": + return { + title: "Authorization Required", + description: + "Authorization could not be completed. Try again to continue.", + buttonLabel: "Authorize again", + }; + case "needs_auth": + default: + return { + title: "Authorization Required", + description: `${serverName} requires authorization to continue. You'll return here automatically after consent.`, + buttonLabel: "Authorize", + }; + } +} export function SharedServerChatPage({ pathToken, @@ -60,20 +103,11 @@ export function SharedServerChatPage({ ); const [isResolving, setIsResolving] = useState(!!pathToken); const [errorMessage, setErrorMessage] = useState(null); - const [needsOAuth, setNeedsOAuth] = useState(false); - const [discoveredServerUrl, setDiscoveredServerUrl] = useState( - null, - ); - const [isCheckingOAuth, setIsCheckingOAuth] = useState(() => { - if (!session) return false; - // Always start as true for OAuth servers — even if tokens exist locally, - // we need to validate them before rendering the chat UI. - if (session.payload.useOAuth) return true; - return true; - }); - const [oauthPreflightError, setOauthPreflightError] = useState( - null, + const [isCheckingOAuthRequirement, setIsCheckingOAuthRequirement] = useState( + () => !!session && !session.payload.useOAuth, ); + const [pendingRuntimeOAuthDetails, setPendingRuntimeOAuthDetails] = + useState(null); const selectedServerName = session?.payload.serverName; const hostedServerIdsByName = useMemo(() => { @@ -85,16 +119,38 @@ export function SharedServerChatPage({ }; }, [session]); - // Build OAuth tokens map early so both useHostedApiContext and ChatTabV2 can use it. - // The global hosted context needs it for widget-content and other direct API calls. + const oauthServers = useMemo(() => { + if (!session) return []; + return [ + { + serverId: session.payload.serverId, + serverName: session.payload.serverName, + useOAuth: session.payload.useOAuth, + serverUrl: session.payload.serverUrl, + clientId: session.payload.clientId, + oauthScopes: session.payload.oauthScopes, + }, + ]; + }, [session]); + + const { + oauthStateByServerId, + pendingOAuthServers, + authorizeServer, + markOAuthRequired, + } = useHostedOAuthGate({ + surface: "shared", + pendingKey: SHARED_OAUTH_PENDING_KEY, + servers: oauthServers, + }); + const oauthTokensForChat = useMemo(() => { if (!session) return undefined; const { serverName, serverId } = session.payload; const tokens = getStoredTokens(serverName); if (!tokens?.access_token) return undefined; return { [serverId]: tokens.access_token }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [session, needsOAuth]); + }, [session, oauthStateByServerId]); useHostedApiContext({ workspaceId: session?.payload.workspaceId ?? null, @@ -204,337 +260,116 @@ export function SharedServerChatPage({ }; }, [session]); - // Preflight OAuth check: validate stored tokens before rendering the chat UI. useEffect(() => { if (!session || isAuthLoading || !isAuthenticated) return; + if (session.payload.useOAuth) { + setIsCheckingOAuthRequirement(false); + return; + } + let cancelled = false; + setIsCheckingOAuthRequirement(true); - const checkOAuth = async () => { - setIsCheckingOAuth(true); - setOauthPreflightError(null); + const discoverOAuthRequirement = async () => { try { - if (session.payload.useOAuth) { - const tokens = getStoredTokens(session.payload.serverName); - - if (!tokens?.access_token) { - setNeedsOAuth(true); - return; - } - - // Tokens exist locally — validate them before rendering the chat UI. - // Keep isCheckingOAuth=true (the spinner) until validation resolves - // so ChatTabV2 doesn't mount and fire requests with an expired token. - { - let bearerToken: string | null | undefined = null; - for ( - let attempt = 1; - attempt <= OAUTH_PREFLIGHT_VALIDATE_TOKEN_ATTEMPTS; - attempt++ - ) { - try { - bearerToken = await getAccessToken(); - } catch {} - - if (cancelled) return; - if (bearerToken) break; - if (attempt < OAUTH_PREFLIGHT_VALIDATE_TOKEN_ATTEMPTS) { - await new Promise((resolve) => - window.setTimeout(resolve, OAUTH_PREFLIGHT_TOKEN_RETRY_MS), - ); - } - } - - if (!bearerToken) { - // Can't validate without a bearer token — trust local tokens. - setNeedsOAuth(false); - return; - } - - if (cancelled) return; - - // Re-read tokens in case they were cleared while waiting for bearer. - const freshTokens = getStoredTokens(session.payload.serverName); - if (!freshTokens?.access_token) { - if (!cancelled) setNeedsOAuth(true); - return; - } - - try { - const validateRes = await fetch("/api/web/servers/validate", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${bearerToken}`, - }, - body: JSON.stringify({ - workspaceId: session.payload.workspaceId, - serverId: session.payload.serverId, - oauthAccessToken: freshTokens.access_token, - accessScope: "chat_v2", - shareToken: session.token, - }), - }); - - if (cancelled) return; - - if (validateRes.ok) { - // Token is valid — allow the chat UI to render. - setNeedsOAuth(false); - } else { - let body: unknown = null; - try { - const textBody = await validateRes.text(); - if (textBody) { - try { - body = JSON.parse(textBody); - } catch { - body = textBody; - } - } - } catch { - body = "Unable to read validation error response body"; - } - - console.error( - "[SharedServerChatPage] Stored OAuth token validation failed", - { - status: validateRes.status, - statusText: validateRes.statusText, - body, - }, - ); - if (!cancelled) { - // Clear the expired/invalid tokens so the auto-close - // polling effect (which watches localStorage) doesn't - // immediately find the stale tokens and flip needsOAuth - // back to false. - localStorage.removeItem( - `mcp-tokens-${session.payload.serverName}`, - ); - setNeedsOAuth(true); - } - } - } catch { - // Network/unexpected error — trust local tokens, don't show modal. - if (!cancelled) { - setNeedsOAuth(false); - } - } - } - + const result = await checkHostedServerOAuthRequirement( + session.payload.serverId, + ); + if (cancelled || !result.useOAuth) { return; } - let warnedMissingToken = false; - - while (!cancelled) { - let token: string | null | undefined = null; - try { - token = await getAccessToken(); - } catch (error) { - if (cancelled) return; - const message = - "OAuth preflight could not retrieve a WorkOS bearer token yet. Retrying..."; - if (!warnedMissingToken) { - console.error("[SharedServerChatPage] " + message, error); - warnedMissingToken = true; - } - setOauthPreflightError(message); - await new Promise((resolve) => - window.setTimeout(resolve, OAUTH_PREFLIGHT_TOKEN_RETRY_MS), - ); - continue; - } - if (cancelled) return; - - if (!token) { - const message = - "OAuth preflight waiting for WorkOS bearer token. Retrying..."; - if (!warnedMissingToken) { - console.warn("[SharedServerChatPage] " + message, { - workspaceId: session.payload.workspaceId, - serverId: session.payload.serverId, - }); - warnedMissingToken = true; - } - setOauthPreflightError(message); - await new Promise((resolve) => - window.setTimeout(resolve, OAUTH_PREFLIGHT_TOKEN_RETRY_MS), - ); - continue; - } - - const res = await fetch("/api/web/servers/check-oauth", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - workspaceId: session.payload.workspaceId, - serverId: session.payload.serverId, - accessScope: "chat_v2", - shareToken: session.token, - }), - }); - - if (cancelled) return; - - if (!res.ok) { - let body: unknown = null; - try { - const textBody = await res.text(); - if (textBody) { - try { - body = JSON.parse(textBody); - } catch { - body = textBody; - } - } - } catch { - body = "Unable to read error response body"; - } - if (cancelled) return; - - const message = `OAuth preflight failed: ${res.status} ${res.statusText}. Retrying...`; - console.error("[SharedServerChatPage] " + message, { + const nextSession: SharedServerSession = { + ...session, + payload: { + ...session.payload, + useOAuth: true, + serverUrl: result.serverUrl ?? session.payload.serverUrl, + }, + }; + writeSharedServerSession(nextSession); + setSession(nextSession); + } catch (error) { + if (!cancelled) { + console.error( + "[SharedServerChatPage] OAuth requirement check failed", + { workspaceId: session.payload.workspaceId, serverId: session.payload.serverId, - status: res.status, - statusText: res.statusText, - body, - }); - setOauthPreflightError(message); - await new Promise((resolve) => - window.setTimeout(resolve, OAUTH_PREFLIGHT_REQUEST_RETRY_MS), - ); - continue; - } - - const data = (await res.json()) as { - useOAuth?: boolean; - serverUrl?: string | null; - }; - if (cancelled) return; - - setOauthPreflightError(null); - - if (data.useOAuth) { - if (data.serverUrl) { - setDiscoveredServerUrl(data.serverUrl); - } - - const nextSession: SharedServerSession = { - ...session, - payload: { - ...session.payload, - useOAuth: true, - serverUrl: data.serverUrl ?? session.payload.serverUrl, - }, - }; - writeSharedServerSession(nextSession); - setSession(nextSession); - - const tokens = getStoredTokens(session.payload.serverName); - if (!tokens?.access_token) { - setNeedsOAuth(true); - } - } - - return; + error, + }, + ); } - } catch (error) { - if (cancelled) return; - const message = "OAuth preflight request failed unexpectedly."; - console.error("[SharedServerChatPage] " + message, error); - setOauthPreflightError(message); } finally { if (!cancelled) { - setIsCheckingOAuth(false); + setIsCheckingOAuthRequirement(false); } } }; - void checkOAuth(); + void discoverOAuthRequirement(); return () => { cancelled = true; }; - }, [session, isAuthLoading, isAuthenticated, getAccessToken]); + }, [isAuthLoading, isAuthenticated, session]); - const handleOAuthRequired = useCallback((serverUrl?: string) => { - if (serverUrl) { - setDiscoveredServerUrl(serverUrl); + useEffect(() => { + if (!pendingRuntimeOAuthDetails || !session?.payload.useOAuth) { + return; } - setNeedsOAuth(true); - }, []); - const handleAuthorize = async () => { - if (!session) return; - const { serverName, clientId, oauthScopes } = session.payload; - const serverUrl = session.payload.serverUrl || discoveredServerUrl; - if (!serverUrl) return; - - localStorage.setItem(SHARED_OAUTH_PENDING_KEY, "true"); - localStorage.setItem("mcp-oauth-return-hash", "#" + slugify(serverName)); - - const result = await initiateOAuth({ - serverName, - serverUrl, - clientId: clientId ?? undefined, - scopes: oauthScopes ?? undefined, + markOAuthRequired({ + serverId: pendingRuntimeOAuthDetails.serverId ?? session.payload.serverId, + serverName: + pendingRuntimeOAuthDetails.serverName ?? session.payload.serverName, + serverUrl: + pendingRuntimeOAuthDetails.serverUrl ?? session.payload.serverUrl, }); + setPendingRuntimeOAuthDetails(null); + }, [markOAuthRequired, pendingRuntimeOAuthDetails, session]); - // If initiateOAuth returns without redirecting (already authorized) - if (result.success) { - localStorage.removeItem(SHARED_OAUTH_PENDING_KEY); - setOauthPreflightError(null); - const initialTokens = getStoredTokens(serverName); - if (initialTokens?.access_token) { - setNeedsOAuth(false); + const handleOAuthRequired = useCallback( + (details?: HostedOAuthRequiredDetails) => { + if (!session) { return; } - // Token writes can lag briefly in some callback paths. Poll briefly. - for (let i = 0; i < 15; i++) { - await new Promise((resolve) => window.setTimeout(resolve, 100)); - const polledTokens = getStoredTokens(serverName); - if (polledTokens?.access_token) { - setNeedsOAuth(false); - return; - } + const nextDetails: HostedOAuthRequiredDetails = { + serverId: details?.serverId ?? session.payload.serverId, + serverName: details?.serverName ?? session.payload.serverName, + serverUrl: details?.serverUrl ?? session.payload.serverUrl, + }; + + setSession((previous) => { + if (!previous) return previous; + const nextSession: SharedServerSession = { + ...previous, + payload: { + ...previous.payload, + useOAuth: true, + serverUrl: nextDetails.serverUrl ?? previous.payload.serverUrl, + }, + }; + writeSharedServerSession(nextSession); + return nextSession; + }); + + if (session.payload.useOAuth) { + markOAuthRequired(nextDetails); + } else { + setPendingRuntimeOAuthDetails(nextDetails); } - } - }; - - // If modal is currently open, auto-close it as soon as a token appears. - useEffect(() => { - if (!needsOAuth || !session?.payload.useOAuth) return; - - const serverName = session.payload.serverName; - const interval = window.setInterval(() => { - const tokens = getStoredTokens(serverName); - if (tokens?.access_token) { - setOauthPreflightError(null); - setNeedsOAuth(false); - } - }, 250); - - const timeout = window.setTimeout(() => { - window.clearInterval(interval); - }, 15_000); - - return () => { - window.clearInterval(interval); - window.clearTimeout(timeout); - }; - }, [needsOAuth, session]); + }, + [markOAuthRequired, session], + ); const handleOpenMcpJam = () => { if (session) { const effectiveServerUrl = - session.payload.serverUrl || discoveredServerUrl; + session.payload.serverUrl ?? + oauthStateByServerId[session.payload.serverId]?.serverUrl; if (effectiveServerUrl) { writePendingServerAdd({ serverName: session.payload.serverName, @@ -552,7 +387,7 @@ export function SharedServerChatPage({ const handleCopyLink = async () => { const token = session?.token?.trim(); - if (!token) { + if (!session || !token) { toast.error("Share link unavailable"); return; } @@ -562,7 +397,7 @@ export function SharedServerChatPage({ return; } - const shareUrl = `${window.location.origin}/shared/${slugify(session.payload.serverName)}/${encodeURIComponent(token)}`; + const shareUrl = `${getShareableAppOrigin()}/shared/${slugify(session.payload.serverName)}/${encodeURIComponent(token)}`; try { await navigator.clipboard.writeText(shareUrl); toast.success("Share link copied"); @@ -572,9 +407,14 @@ export function SharedServerChatPage({ }; const displayServerName = session?.payload.serverName || "\u00A0"; + const activeOAuthServer = pendingOAuthServers[0] ?? null; + const activeOAuthState = activeOAuthServer?.state ?? null; + const activeOAuthCopy = activeOAuthState + ? getSharedOAuthCopy(activeOAuthState.status, displayServerName) + : null; const renderContent = () => { - if (isResolving || isCheckingOAuth) { + if (isResolving || isCheckingOAuthRequirement) { return (
@@ -608,49 +448,45 @@ export function SharedServerChatPage({ ); } - if (needsOAuth) { + if (activeOAuthServer && activeOAuthState && activeOAuthCopy) { return (
-
- -

- Authorization Required + {activeOAuthCopy.title}

- {selectedServerName} requires authorization to continue. + {activeOAuthState.status === "error" && + activeOAuthState.errorMessage + ? activeOAuthState.errorMessage + : activeOAuthCopy.description}

- + {activeOAuthCopy.buttonLabel ? ( + + ) : null}
); } return ( - <> - {oauthPreflightError ? ( -
- OAuth preflight hit an issue. Runtime OAuth detection remains - enabled. -
- ) : null} - -
- -
- +
+ +
); }; @@ -658,26 +494,26 @@ export function SharedServerChatPage({
-

+

{displayServerName}

-
- {session && ( +
+ {session ? ( - )} + ) : null}
diff --git a/mcpjam-inspector/client/src/components/hosted/__tests__/SandboxChatPage.test.tsx b/mcpjam-inspector/client/src/components/hosted/__tests__/SandboxChatPage.test.tsx new file mode 100644 index 000000000..98cc5c8d3 --- /dev/null +++ b/mcpjam-inspector/client/src/components/hosted/__tests__/SandboxChatPage.test.tsx @@ -0,0 +1,554 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SandboxChatPage } from "../SandboxChatPage"; +import { + SANDBOX_SIGN_IN_RETURN_PATH_STORAGE_KEY, + clearSandboxSession, + writeSandboxSession, +} from "@/lib/sandbox-session"; +import { + clearHostedOAuthResumeMarker, + writeHostedOAuthResumeMarker, +} from "@/lib/hosted-oauth-resume"; + +const { + mockConvexAuthState, + mockGetAccessToken, + mockSignIn, + mockGetStoredTokens, + mockInitiateOAuth, + mockValidateHostedServer, + mockChatTabV2, +} = vi.hoisted(() => ({ + mockConvexAuthState: { + isAuthenticated: true, + isLoading: false, + }, + mockGetAccessToken: vi.fn(), + mockSignIn: vi.fn(), + mockGetStoredTokens: vi.fn(), + mockInitiateOAuth: vi.fn(async () => ({ success: false })), + mockValidateHostedServer: vi.fn(), + mockChatTabV2: vi.fn(), +})); + +vi.mock("convex/react", () => ({ + useConvexAuth: () => mockConvexAuthState, +})); + +vi.mock("@workos-inc/authkit-react", () => ({ + useAuth: () => ({ + getAccessToken: mockGetAccessToken, + signIn: mockSignIn, + }), +})); + +vi.mock("@/hooks/hosted/use-hosted-api-context", () => ({ + useHostedApiContext: vi.fn(), +})); + +vi.mock("@/lib/apis/web/servers-api", () => ({ + validateHostedServer: mockValidateHostedServer, +})); + +vi.mock("@/stores/preferences/preferences-provider", () => ({ + usePreferencesStore: (selector: (state: { themeMode: "light" }) => unknown) => + selector({ themeMode: "light" }), +})); + +vi.mock("@/components/ChatTabV2", () => ({ + ChatTabV2: (props: { + onOAuthRequired?: (details?: { + serverUrl?: string | null; + serverId?: string | null; + serverName?: string | null; + }) => void; + reasoningDisplayMode?: string; + }) => { + mockChatTabV2(props); + const { onOAuthRequired } = props; + return ( +
+
+ {onOAuthRequired ? ( + <> + + + + ) : null} +
+ ); + }, +})); + +vi.mock("@/lib/oauth/mcp-oauth", () => ({ + getStoredTokens: mockGetStoredTokens, + initiateOAuth: mockInitiateOAuth, +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe("SandboxChatPage", () => { + function createFetchResponse( + body: unknown, + overrides: Partial<{ + ok: boolean; + status: number; + statusText: string; + }> = {}, + ) { + return { + ok: overrides.ok ?? true, + status: overrides.status ?? 200, + statusText: overrides.statusText ?? "OK", + json: async () => body, + text: async () => + typeof body === "string" ? body : JSON.stringify(body), + headers: new Headers(), + } as Response; + } + + beforeEach(() => { + vi.useRealTimers(); + clearSandboxSession(); + clearHostedOAuthResumeMarker(); + localStorage.clear(); + sessionStorage.clear(); + window.history.replaceState({}, "", "/"); + mockConvexAuthState.isAuthenticated = true; + mockConvexAuthState.isLoading = false; + mockGetAccessToken.mockReset(); + mockSignIn.mockReset(); + mockGetStoredTokens.mockReset(); + mockInitiateOAuth.mockReset(); + mockValidateHostedServer.mockReset(); + mockChatTabV2.mockReset(); + + mockGetAccessToken.mockResolvedValue("workos-token"); + mockGetStoredTokens.mockReturnValue(null); + mockInitiateOAuth.mockResolvedValue({ success: false }); + mockValidateHostedServer.mockResolvedValue({ + success: true, + status: "connected", + initInfo: null, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("applies sandbox host style data attributes while keeping MCPJam branding", async () => { + writeSandboxSession({ + token: "sandbox-token", + payload: { + workspaceId: "ws_1", + sandboxId: "sbx_1", + name: "ChatGPT Sandbox", + description: "Hosted sandbox", + hostStyle: "chatgpt", + mode: "invited_only", + allowGuestAccess: false, + viewerIsWorkspaceMember: true, + systemPrompt: "You are helpful.", + modelId: "openai/gpt-5-mini", + temperature: 0.4, + requireToolApproval: true, + servers: [], + }, + }); + + const { container } = render(); + + expect(await screen.findByTestId("sandbox-chat-tab")).toBeInTheDocument(); + expect( + container.querySelector('[data-host-style="chatgpt"]'), + ).toBeInTheDocument(); + expect(screen.getByAltText("MCPJam")).toBeInTheDocument(); + expect(mockChatTabV2).toHaveBeenCalledWith( + expect.objectContaining({ + reasoningDisplayMode: "hidden", + }), + ); + }); + + it("shows curated copy for an invalid or expired sandbox link", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + createFetchResponse( + { + code: "NOT_FOUND", + message: + "Uncaught Error: This sandbox link is invalid or has expired. at resolveSandboxBootstrapForUser (../../convex/sandboxes.ts:309:14) at async handler (../../convex/sandboxes.ts:1088:6)", + }, + { ok: false, status: 404, statusText: "Not Found" }, + ), + ), + ); + + render(); + + expect( + await screen.findByRole("heading", { name: "Sandbox Link Unavailable" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "This sandbox link is invalid or expired. Ask the owner to share a new link if you still need access.", + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/Uncaught Error:/)).not.toBeInTheDocument(); + expect( + screen.queryByText(/resolveSandboxBootstrapForUser/), + ).not.toBeInTheDocument(); + }); + + it("keeps the access denied sign-in path intact", async () => { + mockConvexAuthState.isAuthenticated = false; + window.history.replaceState({}, "", "/sandbox/test/token-denied"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + createFetchResponse( + { + code: "FORBIDDEN", + message: + "You don't have access to Test Sandbox. This sandbox is invite-only - ask the owner to invite you.", + }, + { ok: false, status: 403, statusText: "Forbidden" }, + ), + ), + ); + + render(); + + expect( + await screen.findByRole("heading", { name: "Access Denied" }), + ).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("button", { + name: "Sign in", + }), + ); + + expect(mockSignIn).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(SANDBOX_SIGN_IN_RETURN_PATH_STORAGE_KEY)).toBe( + "/sandbox/test/token-denied", + ); + }); + + it("shows a generic fallback for unexpected sandbox bootstrap failures", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + createFetchResponse( + { + code: "INTERNAL_ERROR", + message: + "Uncaught Error: Internal database exploded at handler (../../convex/sandboxes.ts:1088:6)", + }, + { ok: false, status: 500, statusText: "Internal Server Error" }, + ), + ), + ); + + render(); + + expect( + await screen.findByRole("heading", { name: "Sandbox Link Unavailable" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "We couldn't open this sandbox right now. Please try again or open MCPJam.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByText(/Internal database exploded/), + ).not.toBeInTheDocument(); + expect(consoleError).toHaveBeenCalledWith( + "[SandboxChatPage] Failed to bootstrap sandbox", + expect.objectContaining({ + status: 500, + code: "INTERNAL_ERROR", + message: "Internal database exploded", + rawMessage: + "Uncaught Error: Internal database exploded at handler (../../convex/sandboxes.ts:1088:6)", + }), + ); + }); + + it("auto-resumes sandbox OAuth after callback completion", async () => { + vi.useFakeTimers(); + let hasToken = false; + mockGetStoredTokens.mockImplementation(() => + hasToken ? { access_token: "sandbox-token" } : null, + ); + + writeSandboxSession({ + token: "sandbox-token", + payload: { + workspaceId: "ws_1", + sandboxId: "sbx_1", + name: "Asana Sandbox", + description: "Hosted sandbox", + hostStyle: "claude", + mode: "invited_only", + allowGuestAccess: false, + viewerIsWorkspaceMember: true, + systemPrompt: "You are helpful.", + modelId: "openai/gpt-5-mini", + temperature: 0.4, + requireToolApproval: true, + servers: [ + { + serverId: "srv_asana", + serverName: "asana", + useOAuth: true, + serverUrl: "https://mcp.asana.com/sse", + clientId: null, + oauthScopes: null, + }, + ], + }, + }); + writeHostedOAuthResumeMarker({ + surface: "sandbox", + serverName: "Asana Production", + serverUrl: "https://mcp.asana.com/sse", + }); + + render(); + + expect( + screen.getByRole("heading", { name: "Finishing authorization" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Authorize" }), + ).not.toBeInTheDocument(); + + await act(async () => { + hasToken = true; + await vi.runAllTimersAsync(); + }); + + expect(screen.getByTestId("sandbox-chat-tab")).toBeInTheDocument(); + expect(mockValidateHostedServer).toHaveBeenCalledWith( + "srv_asana", + "sandbox-token", + ); + expect(mockValidateHostedServer).toHaveBeenCalledTimes(1); + }); + + it("shows curated copy instead of transport details when sandbox OAuth validation fails", async () => { + vi.useFakeTimers(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + mockGetStoredTokens.mockReturnValue({ access_token: "stale-token" }); + mockValidateHostedServer.mockRejectedValue( + new Error( + 'Authentication failed for MCP server "mn70g96re2qn05cxjw7y4y26ah82jzgh": SSE error: SSE error: Non-200 status code (401)', + ), + ); + + writeSandboxSession({ + token: "sandbox-token", + payload: { + workspaceId: "ws_1", + sandboxId: "sbx_1", + name: "Asana Sandbox", + description: "Hosted sandbox", + hostStyle: "claude", + mode: "invited_only", + allowGuestAccess: false, + viewerIsWorkspaceMember: true, + systemPrompt: "You are helpful.", + modelId: "openai/gpt-5-mini", + temperature: 0.4, + requireToolApproval: true, + servers: [ + { + serverId: "srv_asana", + serverName: "asana", + useOAuth: true, + serverUrl: "https://mcp.asana.com/sse", + clientId: null, + oauthScopes: null, + }, + ], + }, + }); + + render(); + + expect( + screen.getByRole("heading", { name: "Finishing authorization" }), + ).toBeInTheDocument(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect( + screen.getByRole("heading", { name: "Authorization Required" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Your authorization expired or was rejected. Authorize again to continue.", + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/SSE error/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Non-200 status code/i)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Authorize again" }), + ).toBeInTheDocument(); + expect(consoleError).toHaveBeenCalledWith( + "[useHostedOAuthGate] OAuth validation failed", + expect.objectContaining({ + surface: "sandbox", + serverId: "srv_asana", + serverName: "asana", + }), + ); + }); + + it("re-enters the sandbox OAuth gate when chat reports OAuth is required", async () => { + mockGetStoredTokens.mockReturnValue({ access_token: "sandbox-token" }); + + writeSandboxSession({ + token: "sandbox-token", + payload: { + workspaceId: "ws_1", + sandboxId: "sbx_1", + name: "Asana Sandbox", + description: "Hosted sandbox", + hostStyle: "claude", + mode: "invited_only", + allowGuestAccess: false, + viewerIsWorkspaceMember: true, + systemPrompt: "You are helpful.", + modelId: "openai/gpt-5-mini", + temperature: 0.4, + requireToolApproval: true, + servers: [ + { + serverId: "srv_asana", + serverName: "asana", + useOAuth: true, + serverUrl: "https://mcp.asana.com/sse", + clientId: null, + oauthScopes: null, + }, + ], + }, + }); + + render(); + + expect(await screen.findByTestId("sandbox-chat-tab")).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("button", { name: "Trigger OAuth" }), + ); + + expect( + screen.getByRole("heading", { name: "Authorization Required" }), + ).toBeInTheDocument(); + expect( + screen.getByText("You'll return here automatically after consent."), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Authorize" }), + ).toBeInTheDocument(); + }); + + it("re-opens auth only for the matching sandbox server when chat includes server details", async () => { + mockGetStoredTokens.mockImplementation((serverName: string) => { + if (serverName === "asana") { + return { access_token: "asana-token" }; + } + if (serverName === "linear") { + return { access_token: "linear-token" }; + } + return null; + }); + + writeSandboxSession({ + token: "sandbox-token", + payload: { + workspaceId: "ws_1", + sandboxId: "sbx_1", + name: "Asana Sandbox", + description: "Hosted sandbox", + hostStyle: "claude", + mode: "invited_only", + allowGuestAccess: false, + viewerIsWorkspaceMember: true, + systemPrompt: "You are helpful.", + modelId: "openai/gpt-5-mini", + temperature: 0.4, + requireToolApproval: true, + servers: [ + { + serverId: "srv_asana", + serverName: "asana", + useOAuth: true, + serverUrl: "https://mcp.asana.com/sse", + clientId: null, + oauthScopes: null, + }, + { + serverId: "srv_linear", + serverName: "linear", + useOAuth: true, + serverUrl: "https://mcp.linear.app/sse", + clientId: null, + oauthScopes: null, + }, + ], + }, + }); + + render(); + + expect(await screen.findByTestId("sandbox-chat-tab")).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("button", { name: "Trigger targeted OAuth" }), + ); + + expect( + screen.getByRole("heading", { name: "Authorization Required" }), + ).toBeInTheDocument(); + expect(screen.getByText("asana")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Authorize again" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("linear")).not.toBeInTheDocument(); + }); +}); diff --git a/mcpjam-inspector/client/src/components/hosted/__tests__/SharedServerChatPage.test.tsx b/mcpjam-inspector/client/src/components/hosted/__tests__/SharedServerChatPage.test.tsx index b0bcb05c3..141653de2 100644 --- a/mcpjam-inspector/client/src/components/hosted/__tests__/SharedServerChatPage.test.tsx +++ b/mcpjam-inspector/client/src/components/hosted/__tests__/SharedServerChatPage.test.tsx @@ -1,19 +1,39 @@ import { act, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { SharedServerChatPage } from "../SharedServerChatPage"; import { clearSharedServerSession, writeSharedServerSession, } from "@/lib/shared-server-session"; +import { + clearHostedOAuthResumeMarker, + writeHostedOAuthResumeMarker, +} from "@/lib/hosted-oauth-resume"; -const mockResolveShareForViewer = vi.fn(); -const mockGetAccessToken = vi.fn(); -const mockClipboardWriteText = vi.fn(); -const mockGetStoredTokens = vi.fn(); -const mockInitiateOAuth = vi.fn(async () => ({ success: false })); -const toastSuccess = vi.fn(); -const toastError = vi.fn(); +const { + mockResolveShareForViewer, + mockGetAccessToken, + mockClipboardWriteText, + mockGetStoredTokens, + mockInitiateOAuth, + mockCheckHostedServerOAuthRequirement, + mockValidateHostedServer, + mockChatTabV2, + toastSuccess, + toastError, +} = vi.hoisted(() => ({ + mockResolveShareForViewer: vi.fn(), + mockGetAccessToken: vi.fn(), + mockClipboardWriteText: vi.fn(), + mockGetStoredTokens: vi.fn(), + mockInitiateOAuth: vi.fn(async () => ({ success: false })), + mockCheckHostedServerOAuthRequirement: vi.fn(), + mockValidateHostedServer: vi.fn(), + mockChatTabV2: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); vi.mock("convex/react", () => ({ useConvexAuth: () => ({ @@ -34,12 +54,51 @@ vi.mock("@/hooks/hosted/use-hosted-api-context", () => ({ })); vi.mock("@/components/ChatTabV2", () => ({ - ChatTabV2: () =>
, + ChatTabV2: (props: { + onOAuthRequired?: (details?: { + serverUrl?: string | null; + serverId?: string | null; + serverName?: string | null; + }) => void; + reasoningDisplayMode?: string; + }) => { + mockChatTabV2(props); + const { onOAuthRequired } = props; + return ( +
+
+ {onOAuthRequired ? ( + <> + + + + ) : null} +
+ ); + }, })); vi.mock("@/lib/oauth/mcp-oauth", () => ({ - getStoredTokens: (...args: unknown[]) => mockGetStoredTokens(...args), - initiateOAuth: (...args: unknown[]) => mockInitiateOAuth(...args), + getStoredTokens: mockGetStoredTokens, + initiateOAuth: mockInitiateOAuth, +})); + +vi.mock("@/lib/apis/web/servers-api", () => ({ + checkHostedServerOAuthRequirement: mockCheckHostedServerOAuthRequirement, + validateHostedServer: mockValidateHostedServer, })); vi.mock("sonner", () => ({ @@ -50,22 +109,12 @@ vi.mock("sonner", () => ({ })); describe("SharedServerChatPage", () => { - function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; - } - function createSharePayload( overrides: Partial<{ workspaceId: string; serverId: string; serverName: string; - mode: "invited_only" | "workspace"; + mode: "any_signed_in_with_link" | "invited_only"; viewerIsWorkspaceMember: boolean; useOAuth: boolean; serverUrl: string | null; @@ -77,7 +126,7 @@ describe("SharedServerChatPage", () => { workspaceId: "ws_1", serverId: "srv_1", serverName: "Server One", - mode: "invited_only" as const, + mode: "any_signed_in_with_link" as const, viewerIsWorkspaceMember: false, useOAuth: false, serverUrl: null, @@ -87,37 +136,35 @@ describe("SharedServerChatPage", () => { }; } - function createFetchResponse( - body: unknown, - overrides: Partial<{ - ok: boolean; - status: number; - statusText: string; - }> = {}, - ) { - return { - ok: overrides.ok ?? true, - status: overrides.status ?? 200, - statusText: overrides.statusText ?? "OK", - json: async () => body, - text: async () => JSON.stringify(body), - headers: new Headers(), - } as Response; - } - beforeEach(() => { + vi.useRealTimers(); clearSharedServerSession(); + clearHostedOAuthResumeMarker(); + localStorage.clear(); + sessionStorage.clear(); mockResolveShareForViewer.mockReset(); mockGetAccessToken.mockReset(); mockClipboardWriteText.mockReset(); mockGetStoredTokens.mockReset(); mockInitiateOAuth.mockReset(); + mockCheckHostedServerOAuthRequirement.mockReset(); + mockValidateHostedServer.mockReset(); + mockChatTabV2.mockReset(); toastSuccess.mockReset(); toastError.mockReset(); mockGetAccessToken.mockResolvedValue("workos-token"); mockGetStoredTokens.mockReturnValue(null); mockInitiateOAuth.mockResolvedValue({ success: false }); + mockCheckHostedServerOAuthRequirement.mockResolvedValue({ + useOAuth: false, + serverUrl: null, + }); + mockValidateHostedServer.mockResolvedValue({ + success: true, + status: "connected", + initInfo: null, + }); mockClipboardWriteText.mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { configurable: true, @@ -127,6 +174,12 @@ describe("SharedServerChatPage", () => { }); }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + it("copies the full shared path link from the header", async () => { writeSharedServerSession({ token: "token 123", @@ -147,79 +200,153 @@ describe("SharedServerChatPage", () => { expect(toastError).not.toHaveBeenCalled(); }); - it("ignores a stale validation network error after the effect is cancelled", async () => { - const deferredValidate = createDeferred(); - vi.mocked(global.fetch).mockImplementation( - async (input: RequestInfo | URL) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - - if (url === "/api/web/servers/validate") { - return deferredValidate.promise; - } - - return createFetchResponse({}); - }, + it("keeps shared server reasoning rendering unchanged", async () => { + writeSharedServerSession({ + token: "token-1", + payload: createSharePayload(), + }); + + render(); + + expect(await screen.findByTestId("shared-chat-tab")).toBeInTheDocument(); + expect(mockChatTabV2).toHaveBeenCalledWith( + expect.not.objectContaining({ + reasoningDisplayMode: "hidden", + }), ); + }); - mockGetStoredTokens.mockImplementation((serverName: string) => { - if (serverName === "OAuth One") { - return { access_token: "expired-token" }; - } - return null; - }); + it("auto-resumes hosted OAuth after callback completion", async () => { + vi.useFakeTimers(); + let hasToken = false; + mockGetStoredTokens.mockImplementation(() => + hasToken ? { access_token: "oauth-token" } : null, + ); writeSharedServerSession({ token: "token-one", payload: createSharePayload({ - workspaceId: "ws_oauth_1", - serverId: "srv_oauth_1", - serverName: "OAuth One", + serverName: "Asana", + serverId: "srv_asana", useOAuth: true, - serverUrl: "https://oauth-one.example.com/mcp", + serverUrl: "https://mcp.asana.com/sse", }), }); + writeHostedOAuthResumeMarker({ + surface: "shared", + serverName: "asana production", + serverUrl: "https://mcp.asana.com/sse", + }); - const { rerender } = render(); + render(); - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - "/api/web/servers/validate", - expect.any(Object), - ); + expect( + screen.getByRole("heading", { name: "Finishing authorization" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Authorize" }), + ).not.toBeInTheDocument(); + + await act(async () => { + hasToken = true; + await vi.runAllTimersAsync(); }); - mockResolveShareForViewer.mockResolvedValueOnce( - createSharePayload({ - workspaceId: "ws_oauth_2", - serverId: "srv_oauth_2", - serverName: "OAuth Two", - useOAuth: true, - serverUrl: "https://oauth-two.example.com/mcp", - }), + expect(screen.getByTestId("shared-chat-tab")).toBeInTheDocument(); + expect(mockValidateHostedServer).toHaveBeenCalledWith( + "srv_asana", + "oauth-token", ); + expect(mockValidateHostedServer).toHaveBeenCalledTimes(1); + }); - rerender(); + it("marks runtime OAuth as required after switching a shared page into OAuth mode", async () => { + mockGetStoredTokens.mockReturnValue({ access_token: "stale-token" }); + + writeSharedServerSession({ + token: "token-runtime", + payload: createSharePayload({ + serverId: "srv_asana", + serverName: "Asana", + useOAuth: false, + serverUrl: null, + }), + }); + + render(); + + expect(await screen.findByTestId("shared-chat-tab")).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("button", { name: "Trigger targeted OAuth" }), + ); expect( await screen.findByRole("heading", { name: "Authorization Required" }), ).toBeInTheDocument(); - expect(screen.queryByTestId("shared-chat-tab")).not.toBeInTheDocument(); + expect( + screen.getByText( + "Asana requires authorization to continue. You'll return here automatically after consent.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Authorize" }), + ).toBeInTheDocument(); + expect(mockValidateHostedServer).not.toHaveBeenCalled(); + }); - await act(async () => { - deferredValidate.reject(new Error("validation request failed")); - await Promise.resolve(); + it("shows an explicit retry CTA when hosted OAuth validation keeps failing", async () => { + vi.useFakeTimers(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + mockGetStoredTokens.mockReturnValue({ access_token: "stale-token" }); + mockValidateHostedServer.mockRejectedValue( + new Error("invalid_token from hosted validation"), + ); + + writeSharedServerSession({ + token: "token-fail", + payload: createSharePayload({ + serverName: "Asana", + serverId: "srv_asana", + useOAuth: true, + serverUrl: "https://mcp.asana.com/sse", + }), }); - await waitFor(() => { - expect( - screen.getByRole("heading", { name: "Authorization Required" }), - ).toBeInTheDocument(); + render(); + + expect( + screen.getByRole("heading", { name: "Finishing authorization" }), + ).toBeInTheDocument(); + + await act(async () => { + await vi.runAllTimersAsync(); }); + + expect( + screen.getByRole("heading", { name: "Authorization Required" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Your authorization expired or was rejected. Authorize again to continue.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByText("invalid_token from hosted validation"), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Authorize again" }), + ).toBeInTheDocument(); expect(screen.queryByTestId("shared-chat-tab")).not.toBeInTheDocument(); + expect(consoleError).toHaveBeenCalledWith( + "[useHostedOAuthGate] OAuth validation failed", + expect.objectContaining({ + surface: "shared", + serverId: "srv_asana", + serverName: "Asana", + }), + ); }); }); diff --git a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx index 40cb979e0..628f27a17 100644 --- a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx +++ b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx @@ -15,6 +15,7 @@ import { MessageCircleQuestionIcon, GitBranch, GraduationCap, + Box, } from "lucide-react"; import { usePostHog, useFeatureFlagEnabled } from "posthog-js/react"; @@ -97,6 +98,11 @@ const navigationSections: NavSection[] = [ url: "#chat-v2", icon: MessageCircle, }, + { + title: "Sandboxes", + url: "#sandboxes", + icon: Box, + }, ], }, { diff --git a/mcpjam-inspector/client/src/components/sandboxes/CreateSandboxDialog.tsx b/mcpjam-inspector/client/src/components/sandboxes/CreateSandboxDialog.tsx new file mode 100644 index 000000000..f34b0f225 --- /dev/null +++ b/mcpjam-inspector/client/src/components/sandboxes/CreateSandboxDialog.tsx @@ -0,0 +1,308 @@ +import { useEffect, useMemo, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { isMCPJamProvidedModel, SUPPORTED_MODELS } from "@/shared/types"; +import type { SandboxSettings } from "@/hooks/useSandboxes"; +import { useSandboxMutations } from "@/hooks/useSandboxes"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; +import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; + +interface WorkspaceServerOption { + _id: string; + name: string; + transportType: "stdio" | "http"; +} + +interface CreateSandboxDialogProps { + isOpen: boolean; + onClose: () => void; + workspaceId: string; + workspaceServers: WorkspaceServerOption[]; + sandbox?: SandboxSettings | null; + onSaved?: (sandbox: SandboxSettings) => void; +} + +const DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant."; + +export function CreateSandboxDialog({ + isOpen, + onClose, + workspaceId, + workspaceServers, + sandbox, + onSaved, +}: CreateSandboxDialogProps) { + const { createSandbox, updateSandbox } = useSandboxMutations(); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT); + const [modelId, setModelId] = useState("openai/gpt-5-mini"); + const [temperature, setTemperature] = useState(0.7); + const [requireToolApproval, setRequireToolApproval] = useState(false); + const [allowGuestAccess, setAllowGuestAccess] = useState(false); + const [selectedServerIds, setSelectedServerIds] = useState([]); + const [isSaving, setIsSaving] = useState(false); + + const availableServers = useMemo( + () => workspaceServers.filter((server) => server.transportType === "http"), + [workspaceServers], + ); + const hostedModels = useMemo( + () => + SUPPORTED_MODELS.filter((model) => + isMCPJamProvidedModel(String(model.id)), + ), + [], + ); + + useEffect(() => { + if (!isOpen) { + return; + } + + setName(sandbox?.name ?? ""); + setDescription(sandbox?.description ?? ""); + setSystemPrompt(sandbox?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT); + setModelId( + sandbox?.modelId ?? + hostedModels[0]?.id?.toString() ?? + "openai/gpt-5-mini", + ); + setTemperature(sandbox?.temperature ?? 0.7); + setRequireToolApproval(sandbox?.requireToolApproval ?? false); + setAllowGuestAccess(sandbox?.allowGuestAccess ?? false); + setSelectedServerIds( + sandbox?.servers.map((server) => server.serverId) ?? [], + ); + }, [hostedModels, isOpen, sandbox]); + + const handleToggleServer = (serverId: string, checked: boolean) => { + setSelectedServerIds((current) => { + if (checked) { + return current.includes(serverId) ? current : [...current, serverId]; + } + return current.filter((id) => id !== serverId); + }); + }; + + const handleSave = async () => { + const trimmedName = name.trim(); + if (!trimmedName) { + toast.error("Sandbox name is required"); + return; + } + if (selectedServerIds.length === 0) { + toast.error("Select at least one HTTP server"); + return; + } + + setIsSaving(true); + try { + const payload = { + name: trimmedName, + description: description.trim() || undefined, + systemPrompt: systemPrompt.trim() || DEFAULT_SYSTEM_PROMPT, + modelId, + temperature, + hostStyle: sandbox?.hostStyle ?? "claude", + requireToolApproval, + allowGuestAccess, + serverIds: selectedServerIds, + }; + + const next = ( + sandbox + ? await updateSandbox({ + sandboxId: sandbox.sandboxId, + ...payload, + }) + : await createSandbox({ + workspaceId, + ...payload, + }) + ) as SandboxSettings; + + onSaved?.(next); + toast.success(sandbox ? "Sandbox updated" : "Sandbox created"); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to save sandbox", + ); + } finally { + setIsSaving(false); + } + }; + + return ( + !open && onClose()}> + + + + {sandbox ? "Edit Sandbox" : "Create Sandbox"} + + + Configure a hosted chat environment with a fixed model, prompt, and + server set. + + + +
+
+ + setName(event.target.value)} + placeholder="Support Assistant Demo" + /> +
+ +
+ +