diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 572e79e..1f13444 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,6 +25,11 @@ jobs: # Working around https://github.com/npm/cli/issues/4828 # - run: npm ci - run: npm install --no-package-lock + + - name: Run client tests + working-directory: ./client + run: npm test + - run: npm run build publish: diff --git a/.gitignore b/.gitignore index 13dde6d..c2fbf42 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ client/tsconfig.node.tsbuildinfo .vscode bin/build cli/build -test-output \ No newline at end of file +test-output diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ad5699..ed28e9d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,13 +7,13 @@ Thanks for your interest in contributing! This guide explains how to get involve 1. Fork the repository and clone it locally 2. Install dependencies with `npm install` 3. Run `npm run dev` to start both client and server in development mode -4. Use the web UI at http://localhost:5173 to interact with the inspector +4. Use the web UI at http://127.0.0.1:5173 to interact with the inspector ## Development Process & Pull Requests 1. Create a new branch for your changes -2. Make your changes following existing code style and conventions -3. Test changes locally +2. Make your changes following existing code style and conventions. You can run `npm run prettier-check` and `npm run prettier-fix` as applicable. +3. Test changes locally by running `npm test` 4. Update documentation as needed 5. Use clear commit messages explaining your changes 6. Verify all changes work as expected diff --git a/client/jest.config.cjs b/client/jest.config.cjs new file mode 100644 index 0000000..c360e72 --- /dev/null +++ b/client/jest.config.cjs @@ -0,0 +1,33 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "jsdom", + moduleNameMapper: { + "^@/(.*)$": "/src/$1", + "\\.css$": "/src/__mocks__/styleMock.js", + }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + jsx: "react-jsx", + tsconfig: "tsconfig.jest.json", + }, + ], + }, + extensionsToTreatAsEsm: [".ts", ".tsx"], + testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", + // Exclude directories and files that don't need to be tested + testPathIgnorePatterns: [ + "/node_modules/", + "/dist/", + "/bin/", + "\\.config\\.(js|ts|cjs|mjs)$", + ], + // Exclude the same patterns from coverage reports + coveragePathIgnorePatterns: [ + "/node_modules/", + "/dist/", + "/bin/", + "\\.config\\.(js|ts|cjs|mjs)$", + ], +}; diff --git a/client/package.json b/client/package.json index 1b7d4cb..9eff88c 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.5.1", + "version": "0.7.0", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -18,12 +18,14 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "jest --config jest.config.cjs", + "test:watch": "jest --config jest.config.cjs --watch" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", - "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.3", @@ -35,8 +37,8 @@ "clsx": "^2.1.1", "cmdk": "^1.0.4", "lucide-react": "^0.447.0", - "prismjs": "^1.29.0", "pkce-challenge": "^4.1.0", + "prismjs": "^1.30.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-simple-code-editor": "^0.14.1", @@ -48,18 +50,25 @@ }, "devDependencies": { "@eslint/js": "^9.11.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@types/jest": "^29.5.14", "@types/node": "^22.7.5", "@types/react": "^18.3.10", "@types/react-dom": "^18.3.0", "@types/serve-handler": "^6.1.4", "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", + "co": "^4.6.0", "eslint": "^9.11.1", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.12", "globals": "^15.9.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.13", + "ts-jest": "^29.2.6", "typescript": "^5.5.3", "typescript-eslint": "^8.7.0", "vite": "^5.4.8" diff --git a/client/src/App.tsx b/client/src/App.tsx index a9adea5..c29ef71 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -15,6 +15,7 @@ import { Root, ServerNotification, Tool, + LoggingLevel, } from "@modelcontextprotocol/sdk/types.js"; import React, { Suspense, useEffect, useRef, useState } from "react"; import { useConnection } from "./lib/hooks/useConnection"; @@ -44,23 +45,16 @@ import RootsTab from "./components/RootsTab"; import SamplingTab, { PendingRequest } from "./components/SamplingTab"; import Sidebar from "./components/Sidebar"; import ToolsTab from "./components/ToolsTab"; +import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants"; +import { InspectorConfig } from "./lib/configurationTypes"; const params = new URLSearchParams(window.location.search); const PROXY_PORT = params.get("proxyPort") ?? "3000"; -const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`; +const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`; +const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; const App = () => { // Handle OAuth callback route - if (window.location.pathname === "/oauth/callback") { - const OAuthCallback = React.lazy( - () => import("./components/OAuthCallback"), - ); - return ( - Loading...}> - - - ); - } const [resources, setResources] = useState([]); const [resourceTemplates, setResourceTemplates] = useState< ResourceTemplate[] @@ -91,6 +85,7 @@ const App = () => { (localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio" ); }); + const [logLevel, setLogLevel] = useState("debug"); const [notifications, setNotifications] = useState([]); const [stdErrNotifications, setStdErrNotifications] = useState< StdErrNotification[] @@ -98,6 +93,14 @@ const App = () => { const [roots, setRoots] = useState([]); const [env, setEnv] = useState>({}); + const [config, setConfig] = useState(() => { + const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY); + return savedConfig ? JSON.parse(savedConfig) : DEFAULT_INSPECTOR_CONFIG; + }); + const [bearerToken, setBearerToken] = useState(() => { + return localStorage.getItem("lastBearerToken") || ""; + }); + const [pendingSampleRequests, setPendingSampleRequests] = useState< Array< PendingRequest & { @@ -109,25 +112,13 @@ const App = () => { const nextRequestId = useRef(0); const rootsRef = useRef([]); - const handleApproveSampling = (id: number, result: CreateMessageResult) => { - setPendingSampleRequests((prev) => { - const request = prev.find((r) => r.id === id); - request?.resolve(result); - return prev.filter((r) => r.id !== id); - }); - }; - - const handleRejectSampling = (id: number) => { - setPendingSampleRequests((prev) => { - const request = prev.find((r) => r.id === id); - request?.reject(new Error("Sampling request rejected")); - return prev.filter((r) => r.id !== id); - }); - }; - const [selectedResource, setSelectedResource] = useState( null, ); + const [resourceSubscriptions, setResourceSubscriptions] = useState< + Set + >(new Set()); + const [selectedPrompt, setSelectedPrompt] = useState(null); const [selectedTool, setSelectedTool] = useState(null); const [nextResourceCursor, setNextResourceCursor] = useState< @@ -160,7 +151,9 @@ const App = () => { args, sseUrl, env, + bearerToken, proxyServerUrl: PROXY_SERVER_URL, + requestTimeout: config.MCP_SERVER_REQUEST_TIMEOUT.value as number, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); }, @@ -195,6 +188,14 @@ const App = () => { localStorage.setItem("lastTransportType", transportType); }, [transportType]); + useEffect(() => { + localStorage.setItem("lastBearerToken", bearerToken); + }, [bearerToken]); + + useEffect(() => { + localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config)); + }, [config]); + // Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback) useEffect(() => { const serverUrl = params.get("serverUrl"); @@ -210,7 +211,7 @@ const App = () => { // Connect to the server connectMcpServer(); } - }, []); + }, [connectMcpServer]); useEffect(() => { fetch(`${PROXY_SERVER_URL}/config`) @@ -239,6 +240,22 @@ const App = () => { } }, []); + const handleApproveSampling = (id: number, result: CreateMessageResult) => { + setPendingSampleRequests((prev) => { + const request = prev.find((r) => r.id === id); + request?.resolve(result); + return prev.filter((r) => r.id !== id); + }); + }; + + const handleRejectSampling = (id: number) => { + setPendingSampleRequests((prev) => { + const request = prev.find((r) => r.id === id); + request?.reject(new Error("Sampling request rejected")); + return prev.filter((r) => r.id !== id); + }); + }; + const clearError = (tabKey: keyof typeof errors) => { setErrors((prev) => ({ ...prev, [tabKey]: null })); }; @@ -308,6 +325,38 @@ const App = () => { setResourceContent(JSON.stringify(response, null, 2)); }; + const subscribeToResource = async (uri: string) => { + if (!resourceSubscriptions.has(uri)) { + await makeRequest( + { + method: "resources/subscribe" as const, + params: { uri }, + }, + z.object({}), + "resources", + ); + const clone = new Set(resourceSubscriptions); + clone.add(uri); + setResourceSubscriptions(clone); + } + }; + + const unsubscribeFromResource = async (uri: string) => { + if (resourceSubscriptions.has(uri)) { + await makeRequest( + { + method: "resources/unsubscribe" as const, + params: { uri }, + }, + z.object({}), + "resources", + ); + const clone = new Set(resourceSubscriptions); + clone.delete(uri); + setResourceSubscriptions(clone); + } + }; + const listPrompts = async () => { const response = await makeRequest( { @@ -368,6 +417,28 @@ const App = () => { await sendNotification({ method: "notifications/roots/list_changed" }); }; + const sendLogLevelRequest = async (level: LoggingLevel) => { + await makeRequest( + { + method: "logging/setLevel" as const, + params: { level }, + }, + z.object({}), + ); + setLogLevel(level); + }; + + if (window.location.pathname === "/oauth/callback") { + const OAuthCallback = React.lazy( + () => import("./components/OAuthCallback"), + ); + return ( + Loading...}> + + + ); + } + return (
{ setSseUrl={setSseUrl} env={env} setEnv={setEnv} + config={config} + setConfig={setConfig} + bearerToken={bearerToken} + setBearerToken={setBearerToken} onConnect={connectMcpServer} stdErrNotifications={stdErrNotifications} + logLevel={logLevel} + sendLogLevelRequest={sendLogLevelRequest} + loggingSupported={!!serverCapabilities?.logging || false} />
@@ -485,6 +563,18 @@ const App = () => { clearError("resources"); setSelectedResource(resource); }} + resourceSubscriptionsSupported={ + serverCapabilities?.resources?.subscribe || false + } + resourceSubscriptions={resourceSubscriptions} + subscribeToResource={(uri) => { + clearError("resources"); + subscribeToResource(uri); + }} + unsubscribeFromResource={(uri) => { + clearError("resources"); + unsubscribeFromResource(uri); + }} handleCompletion={handleCompletion} completionsSupported={completionsSupported} resourceContent={resourceContent} diff --git a/client/src/__mocks__/styleMock.js b/client/src/__mocks__/styleMock.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/client/src/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index a15b57e..f5b0d63 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -1,26 +1,36 @@ -import { useState } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import JsonEditor from "./JsonEditor"; +import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils"; +import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils"; export type JsonValue = | string | number | boolean | null + | undefined | JsonValue[] | { [key: string]: JsonValue }; export type JsonSchemaType = { - type: "string" | "number" | "integer" | "boolean" | "array" | "object"; + type: + | "string" + | "number" + | "integer" + | "boolean" + | "array" + | "object" + | "null"; description?: string; + required?: boolean; + default?: JsonValue; properties?: Record; items?: JsonSchemaType; }; -type JsonObject = { [key: string]: JsonValue }; - interface DynamicJsonFormProps { schema: JsonSchemaType; value: JsonValue; @@ -28,13 +38,6 @@ interface DynamicJsonFormProps { maxDepth?: number; } -const formatFieldLabel = (key: string): string => { - return key - .replace(/([A-Z])/g, " $1") // Insert space before capital letters - .replace(/_/g, " ") // Replace underscores with spaces - .replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter -}; - const DynamicJsonForm = ({ schema, value, @@ -43,29 +46,80 @@ const DynamicJsonForm = ({ }: DynamicJsonFormProps) => { const [isJsonMode, setIsJsonMode] = useState(false); const [jsonError, setJsonError] = useState(); + // Store the raw JSON string to allow immediate feedback during typing + // while deferring parsing until the user stops typing + const [rawJsonValue, setRawJsonValue] = useState( + JSON.stringify(value ?? generateDefaultValue(schema), null, 2), + ); - const generateDefaultValue = (propSchema: JsonSchemaType): JsonValue => { - switch (propSchema.type) { - case "string": - return ""; - case "number": - case "integer": - return 0; - case "boolean": - return false; - case "array": - return []; - case "object": { - const obj: JsonObject = {}; - if (propSchema.properties) { - Object.entries(propSchema.properties).forEach(([key, prop]) => { - obj[key] = generateDefaultValue(prop); - }); - } - return obj; + // Use a ref to manage debouncing timeouts to avoid parsing JSON + // on every keystroke which would be inefficient and error-prone + const timeoutRef = useRef>(); + + // Debounce JSON parsing and parent updates to handle typing gracefully + const debouncedUpdateParent = useCallback( + (jsonString: string) => { + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); } - default: - return null; + + // Set a new timeout + timeoutRef.current = setTimeout(() => { + try { + const parsed = JSON.parse(jsonString); + onChange(parsed); + setJsonError(undefined); + } catch { + // Don't set error during normal typing + } + }, 300); + }, + [onChange, setJsonError], + ); + + // Update rawJsonValue when value prop changes + useEffect(() => { + if (!isJsonMode) { + setRawJsonValue( + JSON.stringify(value ?? generateDefaultValue(schema), null, 2), + ); + } + }, [value, schema, isJsonMode]); + + const handleSwitchToFormMode = () => { + if (isJsonMode) { + // When switching to Form mode, ensure we have valid JSON + try { + const parsed = JSON.parse(rawJsonValue); + // Update the parent component's state with the parsed value + onChange(parsed); + // Switch to form mode + setIsJsonMode(false); + } catch (err) { + setJsonError(err instanceof Error ? err.message : "Invalid JSON"); + } + } else { + // Update raw JSON value when switching to JSON mode + setRawJsonValue( + JSON.stringify(value ?? generateDefaultValue(schema), null, 2), + ); + setIsJsonMode(true); + } + }; + + const formatJson = () => { + try { + const jsonStr = rawJsonValue.trim(); + if (!jsonStr) { + return; + } + const formatted = JSON.stringify(JSON.parse(jsonStr), null, 2); + setRawJsonValue(formatted); + debouncedUpdateParent(formatted); + setJsonError(undefined); + } catch (err) { + setJsonError(err instanceof Error ? err.message : "Invalid JSON"); } }; @@ -103,21 +157,68 @@ const DynamicJsonForm = ({ switch (propSchema.type) { case "string": + return ( + { + const val = e.target.value; + // Allow clearing non-required fields by setting undefined + // This preserves the distinction between empty string and unset + if (!val && !propSchema.required) { + handleFieldChange(path, undefined); + } else { + handleFieldChange(path, val); + } + }} + placeholder={propSchema.description} + required={propSchema.required} + /> + ); case "number": + return ( + { + const val = e.target.value; + // Allow clearing non-required number fields + // This preserves the distinction between 0 and unset + if (!val && !propSchema.required) { + handleFieldChange(path, undefined); + } else { + const num = Number(val); + if (!isNaN(num)) { + handleFieldChange(path, num); + } + } + }} + placeholder={propSchema.description} + required={propSchema.required} + /> + ); case "integer": return ( - handleFieldChange( - path, - propSchema.type === "string" - ? e.target.value - : Number(e.target.value), - ) - } + type="number" + step="1" + value={(currentValue as number)?.toString() ?? ""} + onChange={(e) => { + const val = e.target.value; + // Allow clearing non-required integer fields + // This preserves the distinction between 0 and unset + if (!val && !propSchema.required) { + handleFieldChange(path, undefined); + } else { + const num = Number(val); + // Only update if it's a valid integer + if (!isNaN(num) && Number.isInteger(num)) { + handleFieldChange(path, num); + } + } + }} placeholder={propSchema.description} + required={propSchema.required} /> ); case "boolean": @@ -127,25 +228,53 @@ const DynamicJsonForm = ({ checked={(currentValue as boolean) ?? false} onChange={(e) => handleFieldChange(path, e.target.checked)} className="w-4 h-4" + required={propSchema.required} /> ); - case "object": - if (!propSchema.properties) return null; - return ( -
- {Object.entries(propSchema.properties).map(([key, prop]) => ( -
- - {renderFormFields( - prop, - (currentValue as JsonObject)?.[key], - [...path, key], - depth + 1, - )} -
- ))} -
- ); + case "object": { + // Handle case where we have a value but no schema properties + const objectValue = (currentValue as JsonObject) || {}; + + // If we have schema properties, use them to render fields + if (propSchema.properties) { + return ( +
+ {Object.entries(propSchema.properties).map(([key, prop]) => ( +
+ + {renderFormFields( + prop, + objectValue[key], + [...path, key], + depth + 1, + )} +
+ ))} +
+ ); + } + // If we have a value but no schema properties, render fields based on the value + else if (Object.keys(objectValue).length > 0) { + return ( +
+ {Object.entries(objectValue).map(([key, value]) => ( +
+ + + handleFieldChange([...path, key], e.target.value) + } + /> +
+ ))} +
+ ); + } + // If we have neither schema properties nor value, return null + return null; + } case "array": { const arrayValue = Array.isArray(currentValue) ? currentValue : []; if (!propSchema.items) return null; @@ -187,9 +316,12 @@ const DynamicJsonForm = ({ variant="outline" size="sm" onClick={() => { + const defaultValue = generateDefaultValue( + propSchema.items as JsonSchemaType, + ); handleFieldChange(path, [ ...arrayValue, - generateDefaultValue(propSchema.items as JsonSchemaType), + defaultValue ?? null, ]); }} title={ @@ -215,139 +347,70 @@ const DynamicJsonForm = ({ return; } - const updateArray = ( - array: JsonValue[], - path: string[], - value: JsonValue, - ): JsonValue[] => { - const [index, ...restPath] = path; - const arrayIndex = Number(index); - - // Validate array index - if (isNaN(arrayIndex)) { - console.error(`Invalid array index: ${index}`); - return array; - } - - // Check array bounds - if (arrayIndex < 0) { - console.error(`Array index out of bounds: ${arrayIndex} < 0`); - return array; - } - - const newArray = [...array]; - - if (restPath.length === 0) { - newArray[arrayIndex] = value; - } else { - // Ensure index position exists - if (arrayIndex >= array.length) { - console.warn(`Extending array to index ${arrayIndex}`); - newArray.length = arrayIndex + 1; - newArray.fill(null, array.length, arrayIndex); - } - newArray[arrayIndex] = updateValue( - newArray[arrayIndex], - restPath, - value, - ); - } - return newArray; - }; - - const updateObject = ( - obj: JsonObject, - path: string[], - value: JsonValue, - ): JsonObject => { - const [key, ...restPath] = path; - - // Validate object key - if (typeof key !== "string") { - console.error(`Invalid object key: ${key}`); - return obj; - } - - const newObj = { ...obj }; - - if (restPath.length === 0) { - newObj[key] = value; - } else { - // Ensure key exists - if (!(key in newObj)) { - console.warn(`Creating new key in object: ${key}`); - newObj[key] = {}; - } - newObj[key] = updateValue(newObj[key], restPath, value); - } - return newObj; - }; - - const updateValue = ( - current: JsonValue, - path: string[], - value: JsonValue, - ): JsonValue => { - if (path.length === 0) return value; - - try { - if (!current) { - current = !isNaN(Number(path[0])) ? [] : {}; - } - - // Type checking - if (Array.isArray(current)) { - return updateArray(current, path, value); - } else if (typeof current === "object" && current !== null) { - return updateObject(current, path, value); - } else { - console.error( - `Cannot update path ${path.join(".")} in non-object/array value:`, - current, - ); - return current; - } - } catch (error) { - console.error(`Error updating value at path ${path.join(".")}:`, error); - return current; - } - }; - try { - const newValue = updateValue(value, path, fieldValue); + const newValue = updateValueAtPath(value, path, fieldValue); onChange(newValue); } catch (error) { console.error("Failed to update form value:", error); - // Keep the original value unchanged onChange(value); } }; + const shouldUseJsonMode = + schema.type === "object" && + (!schema.properties || Object.keys(schema.properties).length === 0); + + useEffect(() => { + if (shouldUseJsonMode && !isJsonMode) { + setIsJsonMode(true); + } + }, [shouldUseJsonMode, isJsonMode]); + return (
-
- + )} +
{isJsonMode ? ( { - try { - onChange(JSON.parse(newValue)); - setJsonError(undefined); - } catch (err) { - setJsonError(err instanceof Error ? err.message : "Invalid JSON"); - } + // Always update local state + setRawJsonValue(newValue); + + // Use the debounced function to attempt parsing and updating parent + debouncedUpdateParent(newValue); }} error={jsonError} /> + ) : // If schema type is object but value is not an object or is empty, and we have actual JSON data, + // render a simple representation of the JSON data + schema.type === "object" && + (typeof value !== "object" || + value === null || + Object.keys(value).length === 0) && + rawJsonValue && + rawJsonValue !== "{}" ? ( +
+

+ Form view not available for this JSON structure. Using simplified + view: +

+
+            {rawJsonValue}
+          
+

+ Use JSON mode for full editing capabilities. +

+
) : ( renderFormFields(schema, value) )} diff --git a/client/src/components/History.tsx b/client/src/components/History.tsx index 532d3b1..b03d1f4 100644 --- a/client/src/components/History.tsx +++ b/client/src/components/History.tsx @@ -1,6 +1,7 @@ import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import { Copy } from "lucide-react"; import { useState } from "react"; +import JsonView from "./JsonView"; const HistoryAndNotifications = ({ requestHistory, @@ -74,9 +75,9 @@ const HistoryAndNotifications = ({
-
-                          {JSON.stringify(JSON.parse(request.request), null, 2)}
-                        
+
+ +
{request.response && (
@@ -91,13 +92,9 @@ const HistoryAndNotifications = ({
-
-                            {JSON.stringify(
-                              JSON.parse(request.response),
-                              null,
-                              2,
-                            )}
-                          
+
+ +
)} @@ -146,9 +143,11 @@ const HistoryAndNotifications = ({
-
-                        {JSON.stringify(notification, null, 2)}
-                      
+
+ +
)} diff --git a/client/src/components/JsonEditor.tsx b/client/src/components/JsonEditor.tsx index 2fb7e26..215d66b 100644 --- a/client/src/components/JsonEditor.tsx +++ b/client/src/components/JsonEditor.tsx @@ -1,8 +1,8 @@ +import { useState, useEffect } from "react"; import Editor from "react-simple-code-editor"; import Prism from "prismjs"; import "prismjs/components/prism-json"; import "prismjs/themes/prism.css"; -import { Button } from "@/components/ui/button"; interface JsonEditorProps { value: string; @@ -10,34 +10,40 @@ interface JsonEditorProps { error?: string; } -const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => { - const formatJson = (json: string): string => { - try { - return JSON.stringify(JSON.parse(json), null, 2); - } catch { - return json; - } +const JsonEditor = ({ + value, + onChange, + error: externalError, +}: JsonEditorProps) => { + const [editorContent, setEditorContent] = useState(value || ""); + const [internalError, setInternalError] = useState( + undefined, + ); + + useEffect(() => { + setEditorContent(value || ""); + }, [value]); + + const handleEditorChange = (newContent: string) => { + setEditorContent(newContent); + setInternalError(undefined); + onChange(newContent); }; + const displayError = internalError || externalError; + return ( -
-
- -
+
Prism.highlight(code, Prism.languages.json, "json") } @@ -51,7 +57,9 @@ const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => { className="w-full" />
- {error &&

{error}

} + {displayError && ( +

{displayError}

+ )}
); }; diff --git a/client/src/components/JsonView.tsx b/client/src/components/JsonView.tsx new file mode 100644 index 0000000..e2922f0 --- /dev/null +++ b/client/src/components/JsonView.tsx @@ -0,0 +1,228 @@ +import { useState, memo } from "react"; +import { JsonValue } from "./DynamicJsonForm"; +import clsx from "clsx"; + +interface JsonViewProps { + data: unknown; + name?: string; + initialExpandDepth?: number; +} + +function tryParseJson(str: string): { success: boolean; data: JsonValue } { + const trimmed = str.trim(); + if ( + !(trimmed.startsWith("{") && trimmed.endsWith("}")) && + !(trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + return { success: false, data: str }; + } + try { + return { success: true, data: JSON.parse(str) }; + } catch { + return { success: false, data: str }; + } +} + +const JsonView = memo( + ({ data, name, initialExpandDepth = 3 }: JsonViewProps) => { + const normalizedData = + typeof data === "string" + ? tryParseJson(data).success + ? tryParseJson(data).data + : data + : data; + + return ( +
+ +
+ ); + }, +); + +JsonView.displayName = "JsonView"; + +interface JsonNodeProps { + data: JsonValue; + name?: string; + depth: number; + initialExpandDepth: number; +} + +const JsonNode = memo( + ({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => { + const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth); + + const getDataType = (value: JsonValue): string => { + if (Array.isArray(value)) return "array"; + if (value === null) return "null"; + return typeof value; + }; + + const dataType = getDataType(data); + + const typeStyleMap: Record = { + number: "text-blue-600", + boolean: "text-amber-600", + null: "text-purple-600", + undefined: "text-gray-600", + string: "text-green-600 break-all whitespace-pre-wrap", + default: "text-gray-700", + }; + + const renderCollapsible = (isArray: boolean) => { + const items = isArray + ? (data as JsonValue[]) + : Object.entries(data as Record); + const itemCount = items.length; + const isEmpty = itemCount === 0; + + const symbolMap = { + open: isArray ? "[" : "{", + close: isArray ? "]" : "}", + collapsed: isArray ? "[ ... ]" : "{ ... }", + empty: isArray ? "[]" : "{}", + }; + + if (isEmpty) { + return ( +
+ {name && ( + + {name}: + + )} + {symbolMap.empty} +
+ ); + } + + return ( +
+
setIsExpanded(!isExpanded)} + > + {name && ( + + {name}: + + )} + {isExpanded ? ( + + {symbolMap.open} + + ) : ( + <> + + {symbolMap.collapsed} + + + {itemCount} {itemCount === 1 ? "item" : "items"} + + + )} +
+ {isExpanded && ( + <> +
+ {isArray + ? (items as JsonValue[]).map((item, index) => ( +
+ +
+ )) + : (items as [string, JsonValue][]).map(([key, value]) => ( +
+ +
+ ))} +
+
+ {symbolMap.close} +
+ + )} +
+ ); + }; + + const renderString = (value: string) => { + const maxLength = 100; + const isTooLong = value.length > maxLength; + + if (!isTooLong) { + return ( +
+ {name && ( + + {name}: + + )} +
"{value}"
+
+ ); + } + + return ( +
+ {name && ( + + {name}: + + )} +
 setIsExpanded(!isExpanded)}
+            title={isExpanded ? "Click to collapse" : "Click to expand"}
+          >
+            {isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
+          
+
+ ); + }; + + switch (dataType) { + case "object": + case "array": + return renderCollapsible(dataType === "array"); + case "string": + return renderString(data as string); + default: + return ( +
+ {name && ( + + {name}: + + )} + + {data === null ? "null" : String(data)} + +
+ ); + } + }, +); + +JsonNode.displayName = "JsonNode"; + +export default JsonView; diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index f88b16f..b42cf77 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { Combobox } from "@/components/ui/combobox"; import { Label } from "@/components/ui/label"; import { TabsContent } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; + import { ListPromptsResult, PromptReference, @@ -13,6 +13,7 @@ import { AlertCircle } from "lucide-react"; import { useEffect, useState } from "react"; import ListPane from "./ListPane"; import { useCompletionState } from "@/lib/hooks/useCompletionState"; +import JsonView from "./JsonView"; export type Prompt = { name: string; @@ -151,11 +152,9 @@ const PromptsTab = ({ Get Prompt {promptContent && ( -