This commit is contained in:
Greg Saab 2025-10-10 19:08:16 -04:00
commit 3adddfb9f1
8 changed files with 312 additions and 48 deletions

View file

@ -1,4 +1,5 @@
import { useState, memo, useMemo, useCallback } from "react";
import { useState, memo, useMemo, useCallback, useEffect } from "react";
import type React from "react";
import type { JsonValue } from "@/utils/jsonUtils";
import clsx from "clsx";
import { Copy, CheckCheck } from "lucide-react";
@ -101,6 +102,7 @@ const JsonNode = memo(
initialExpandDepth,
isError = false,
}: JsonNodeProps) => {
const { toast } = useToast();
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
const [typeStyleMap] = useState<Record<string, string>>({
number: "text-blue-600",
@ -113,6 +115,52 @@ const JsonNode = memo(
});
const dataType = getDataType(data);
const [copied, setCopied] = useState(false);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (copied) {
timeoutId = setTimeout(() => setCopied(false), 500);
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}, [copied]);
const handleCopyValue = useCallback(
(value: JsonValue) => {
try {
let text: string;
const valueType = getDataType(value);
switch (valueType) {
case "string":
text = value as unknown as string;
break;
case "number":
case "boolean":
text = String(value);
break;
case "null":
text = "null";
break;
case "undefined":
text = "undefined";
break;
default:
text = JSON.stringify(value);
}
navigator.clipboard.writeText(text);
setCopied(true);
} catch (error) {
toast({
title: "Error",
description: `There was an error coping result into the clipboard: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
}
},
[toast],
);
const renderCollapsible = (isArray: boolean) => {
const items = isArray
? (data as JsonValue[])
@ -206,7 +254,7 @@ const JsonNode = memo(
if (!isTooLong) {
return (
<div className="flex mr-1 rounded hover:bg-gray-800/20">
<div className="flex mr-1 rounded hover:bg-gray-800/20 group items-start">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
@ -220,12 +268,28 @@ const JsonNode = memo(
>
"{value}"
</pre>
<Button
variant="ghost"
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleCopyValue(value as unknown as JsonValue);
}}
aria-label={name ? `Copy value of ${name}` : "Copy value"}
title={name ? `Copy value of ${name}` : "Copy value"}
>
{copied ? (
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
) : (
<Copy className="size-4 text-foreground" />
)}
</Button>
</div>
);
}
return (
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
<div className="flex mr-1 rounded group hover:bg-gray-800/20 items-start">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{name}:
@ -241,6 +305,22 @@ const JsonNode = memo(
>
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
</pre>
<Button
variant="ghost"
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleCopyValue(value as unknown as JsonValue);
}}
aria-label={name ? `Copy value of ${name}` : "Copy value"}
title={name ? `Copy value of ${name}` : "Copy value"}
>
{copied ? (
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
) : (
<Copy className="size-4 text-foreground" />
)}
</Button>
</div>
);
};
@ -253,7 +333,7 @@ const JsonNode = memo(
return renderString(data as string);
default:
return (
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20 group">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
@ -262,6 +342,22 @@ const JsonNode = memo(
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
{data === null ? "null" : String(data)}
</span>
<Button
variant="ghost"
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleCopyValue(data as JsonValue);
}}
aria-label={name ? `Copy value of ${name}` : "Copy value"}
title={name ? `Copy value of ${name}` : "Copy value"}
>
{copied ? (
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
) : (
<Copy className="size-4 text-foreground" />
)}
</Button>
</div>
);
}

View file

@ -131,7 +131,9 @@ const PromptsTab = ({
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertDescription className="break-all">
{error}
</AlertDescription>
</Alert>
) : selectedPrompt ? (
<div className="space-y-4">

View file

@ -225,7 +225,9 @@ const ResourcesTab = ({
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertDescription className="break-all">
{error}
</AlertDescription>
</Alert>
) : selectedResource ? (
<JsonView

View file

@ -56,7 +56,7 @@ const SamplingRequest = ({
},
role: {
type: "string",
default: "endTurn",
default: "assistant",
description: "Role of the model",
},
content: {

View file

@ -58,6 +58,8 @@ interface SidebarProps {
setCustomHeaders: (headers: CustomHeadersType) => void;
oauthClientId: string;
setOauthClientId: (id: string) => void;
oauthClientSecret: string;
setOauthClientSecret: (secret: string) => void;
oauthScope: string;
setOauthScope: (scope: string) => void;
onConnect: () => void;
@ -87,6 +89,8 @@ const Sidebar = ({
setCustomHeaders,
oauthClientId,
setOauthClientId,
oauthClientSecret,
setOauthClientSecret,
oauthScope,
setOauthScope,
onConnect,
@ -104,6 +108,7 @@ const Sidebar = ({
const [showAuthConfig, setShowAuthConfig] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
const [showClientSecret, setShowClientSecret] = useState(false);
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
const [copiedServerFile, setCopiedServerFile] = useState(false);
const { toast } = useToast();
@ -555,6 +560,38 @@ const Sidebar = ({
data-testid="oauth-client-id-input"
className="font-mono"
/>
<label className="text-sm font-medium">
Client Secret
</label>
<div className="flex gap-2">
<Input
type={showClientSecret ? "text" : "password"}
placeholder="Client Secret (optional)"
onChange={(e) => setOauthClientSecret(e.target.value)}
value={oauthClientSecret}
data-testid="oauth-client-secret-input"
className="font-mono"
/>
<Button
variant="outline"
size="icon"
className="h-9 w-9 p-0 shrink-0"
onClick={() => setShowClientSecret(!showClientSecret)}
aria-label={
showClientSecret ? "Hide secret" : "Show secret"
}
aria-pressed={showClientSecret}
title={
showClientSecret ? "Hide secret" : "Show secret"
}
>
{showClientSecret ? (
<Eye className="h-4 w-4" aria-hidden="true" />
) : (
<EyeOff className="h-4 w-4" aria-hidden="true" />
)}
</Button>
</div>
<label className="text-sm font-medium">
Redirect URL
</label>

View file

@ -5,6 +5,13 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import {
@ -114,7 +121,7 @@ const ToolsTab = ({
renderItem={(tool) => (
<div className="flex flex-col items-start">
<span className="flex-1">{tool.name}</span>
<span className="text-sm text-gray-500 text-left">
<span className="text-sm text-gray-500 text-left line-clamp-3">
{tool.description}
</span>
</div>
@ -137,10 +144,12 @@ const ToolsTab = ({
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertDescription className="break-all">
{error}
</AlertDescription>
</Alert>
)}
<p className="text-sm text-gray-600 dark:text-gray-400">
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap max-h-48 overflow-y-auto">
{selectedTool.description}
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
@ -208,6 +217,42 @@ const ToolsTab = ({
{prop.description || "Toggle this option"}
</label>
</div>
) : prop.type === "string" && prop.enum ? (
<Select
value={
params[key] === undefined
? ""
: String(params[key])
}
onValueChange={(value) => {
if (value === "") {
setParams({
...params,
[key]: undefined,
});
} else {
setParams({
...params,
[key]: value,
});
}
}}
>
<SelectTrigger id={key} className="mt-1">
<SelectValue
placeholder={
prop.description || "Select an option"
}
/>
</SelectTrigger>
<SelectContent>
{prop.enum.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
) : prop.type === "string" ? (
<Textarea
id={key}

View file

@ -44,6 +44,8 @@ describe("Sidebar", () => {
setSseUrl: jest.fn(),
oauthClientId: "",
setOauthClientId: jest.fn(),
oauthClientSecret: "",
setOauthClientSecret: jest.fn(),
oauthScope: "",
setOauthScope: jest.fn(),
env: {},

View file

@ -680,6 +680,64 @@ describe("ToolsTab", () => {
});
});
describe("Enum Parameters", () => {
const toolWithEnumParam: Tool = {
name: "enumTool",
description: "Tool with enum parameter",
inputSchema: {
type: "object" as const,
properties: {
format: {
type: "string" as const,
enum: ["json", "xml", "csv", "yaml"],
description: "Output format",
},
},
},
};
beforeEach(() => {
// Mock scrollIntoView for Radix UI Select
Element.prototype.scrollIntoView = jest.fn();
});
it("should render enum parameter as dropdown", () => {
renderToolsTab({
tools: [toolWithEnumParam],
selectedTool: toolWithEnumParam,
});
// Should render a select button instead of textarea
const selectTrigger = screen.getByRole("combobox", { name: /format/i });
expect(selectTrigger).toBeInTheDocument();
});
it("should render non-enum string parameter as textarea", () => {
const toolWithStringParam: Tool = {
name: "stringTool",
description: "Tool with regular string parameter",
inputSchema: {
type: "object" as const,
properties: {
text: {
type: "string" as const,
description: "Some text input",
},
},
},
};
renderToolsTab({
tools: [toolWithStringParam],
selectedTool: toolWithStringParam,
});
// Should render textarea, not select
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
});
describe("JSON Validation Integration", () => {
const toolWithJsonParams: Tool = {
name: "jsonTool",