feat: Enhance ResourceLinkView with expandable resource content view

Add click-to-expand functionality for inline resource content display
This commit is contained in:
Nandha Reddy 2025-07-09 05:15:46 +10:00
commit 0dc7a50218
No known key found for this signature in database
GPG key ID: 09ED40FC0F563333
4 changed files with 126 additions and 90 deletions

View file

@ -80,6 +80,9 @@ const App = () => {
ResourceTemplate[] ResourceTemplate[]
>([]); >([]);
const [resourceContent, setResourceContent] = useState<string>(""); const [resourceContent, setResourceContent] = useState<string>("");
const [resourceContentMap, setResourceContentMap] = useState<
Record<string, string>
>({});
const [prompts, setPrompts] = useState<Prompt[]>([]); const [prompts, setPrompts] = useState<Prompt[]>([]);
const [promptContent, setPromptContent] = useState<string>(""); const [promptContent, setPromptContent] = useState<string>("");
const [tools, setTools] = useState<Tool[]>([]); const [tools, setTools] = useState<Tool[]>([]);
@ -461,7 +464,12 @@ const App = () => {
ReadResourceResultSchema, ReadResourceResultSchema,
"resources", "resources",
); );
setResourceContent(JSON.stringify(response, null, 2)); const content = JSON.stringify(response, null, 2);
setResourceContent(content);
setResourceContentMap((prev) => ({
...prev,
[uri]: content,
}));
}; };
const subscribeToResource = async (uri: string) => { const subscribeToResource = async (uri: string) => {
@ -863,6 +871,11 @@ const App = () => {
toolResult={toolResult} toolResult={toolResult}
nextCursor={nextToolCursor} nextCursor={nextToolCursor}
error={errors.tools} error={errors.tools}
resourceContent={resourceContentMap}
onReadResource={(uri: string) => {
clearError("resources");
readResource(uri);
}}
/> />
<ConsoleTab /> <ConsoleTab />
<PingTab <PingTab

View file

@ -1,104 +1,112 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useMemo, memo } from "react";
import { Copy, CheckCheck } from "lucide-react"; import JsonView from "./JsonView";
import { Button } from "@/components/ui/button";
import { useToast } from "@/lib/hooks/useToast";
interface ResourceLinkViewProps { interface ResourceLinkViewProps {
uri: string; uri: string;
name?: string; name?: string;
description?: string; description?: string;
mimeType?: string; mimeType?: string;
resourceContent: string;
onReadResource?: (uri: string) => void;
} }
const ResourceLinkView = ({ const ResourceLinkView = memo(
uri, ({
name, uri,
description, name,
mimeType, description,
}: ResourceLinkViewProps) => { mimeType,
const { toast } = useToast(); resourceContent,
const [copied, setCopied] = useState(false); onReadResource,
}: ResourceLinkViewProps) => {
const [{ expanded, loading }, setState] = useState({
expanded: false,
loading: false,
});
useEffect(() => { const expandedContent = useMemo(
let timeoutId: NodeJS.Timeout; () =>
if (copied) { expanded && resourceContent ? (
timeoutId = setTimeout(() => { <div className="mt-2">
setCopied(false); <div className="flex justify-between items-center mb-1">
}, 500); <span className="font-semibold text-green-600">Resource:</span>
} </div>
return () => { <JsonView data={resourceContent} className="bg-background" />
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [copied]);
const handleCopyUri = useCallback(() => {
try {
navigator.clipboard.writeText(uri);
setCopied(true);
} catch (error) {
toast({
title: "Error",
description: `There was an error copying URI to clipboard: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
}
}, [uri, toast]);
const displayName = name || new URL(uri).pathname.split("/").pop() || uri;
return (
<div
className="p-4 border rounded relative bg-gray-50 dark:bg-gray-800"
role="article"
aria-label={`Resource link: ${displayName}`}
>
<Button
size="icon"
variant="ghost"
className="absolute top-2 right-2"
onClick={handleCopyUri}
>
{copied ? (
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
) : (
<Copy className="size-4 text-foreground" />
)}
</Button>
<div className="pr-10">
<div className="flex items-start justify-between gap-2 mb-2">
<a
href={uri}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 rounded px-1 py-0.5 break-all font-mono flex-1 min-w-0"
aria-label={`Open resource: ${uri}`}
>
{uri}
</a>
{mimeType && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 flex-shrink-0">
{mimeType}
</span>
)}
</div>
{name && (
<div className="font-semibold text-sm text-gray-900 dark:text-gray-100 mb-1">
{name}
</div> </div>
)} ) : null,
[expanded, resourceContent],
);
{description && ( const handleClick = useCallback(() => {
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed"> if (!onReadResource) return;
{description} if (!expanded) {
</p> setState((prev) => ({ ...prev, expanded: true, loading: true }));
)} onReadResource(uri);
setState((prev) => ({ ...prev, loading: false }));
} else {
setState((prev) => ({ ...prev, expanded: false }));
}
}, [expanded, onReadResource, uri]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if ((e.key === "Enter" || e.key === " ") && onReadResource) {
e.preventDefault();
handleClick();
}
},
[handleClick, onReadResource],
);
return (
<div className="text-sm text-foreground bg-secondary py-2 px-3 rounded">
<div
className="flex justify-between items-center cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 rounded"
onClick={onReadResource ? handleClick : undefined}
onKeyDown={onReadResource ? handleKeyDown : undefined}
tabIndex={onReadResource ? 0 : -1}
role="button"
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline px-1 py-0.5 break-all font-mono flex-1 min-w-0">
{uri}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{mimeType && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{mimeType}
</span>
)}
{onReadResource && (
<span className="ml-2 flex-shrink-0" aria-hidden="true">
{loading ? (
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
) : (
<span>{expanded ? "▼" : "▶"}</span>
)}
</span>
)}
</div>
</div>
{name && (
<div className="font-semibold text-sm text-gray-900 dark:text-gray-100 mb-1">
{name}
</div>
)}
{description && (
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{description}
</p>
)}
</div>
</div>
{expandedContent}
</div> </div>
</div> );
); },
}; );
export default ResourceLinkView; export default ResourceLinkView;

View file

@ -10,6 +10,8 @@ import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
interface ToolResultsProps { interface ToolResultsProps {
toolResult: CompatibilityCallToolResult | null; toolResult: CompatibilityCallToolResult | null;
selectedTool: Tool | null; selectedTool: Tool | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
} }
const checkContentCompatibility = ( const checkContentCompatibility = (
@ -62,7 +64,12 @@ const checkContentCompatibility = (
} }
}; };
const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => { const ToolResults = ({
toolResult,
selectedTool,
resourceContent,
onReadResource,
}: ToolResultsProps) => {
if (!toolResult) return null; if (!toolResult) return null;
if ("content" in toolResult) { if ("content" in toolResult) {
@ -207,6 +214,8 @@ const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
name={item.name} name={item.name}
description={item.description} description={item.description}
mimeType={item.mimeType} mimeType={item.mimeType}
resourceContent={resourceContent[item.uri] || ""}
onReadResource={onReadResource}
/> />
)} )}
</div> </div>

View file

@ -28,6 +28,8 @@ const ToolsTab = ({
setSelectedTool, setSelectedTool,
toolResult, toolResult,
nextCursor, nextCursor,
resourceContent,
onReadResource,
}: { }: {
tools: Tool[]; tools: Tool[];
listTools: () => void; listTools: () => void;
@ -38,6 +40,8 @@ const ToolsTab = ({
toolResult: CompatibilityCallToolResult | null; toolResult: CompatibilityCallToolResult | null;
nextCursor: ListToolsResult["nextCursor"]; nextCursor: ListToolsResult["nextCursor"];
error: string | null; error: string | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
}) => { }) => {
const [params, setParams] = useState<Record<string, unknown>>({}); const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = useState(false); const [isToolRunning, setIsToolRunning] = useState(false);
@ -267,6 +271,8 @@ const ToolsTab = ({
<ToolResults <ToolResults
toolResult={toolResult} toolResult={toolResult}
selectedTool={selectedTool} selectedTool={selectedTool}
resourceContent={resourceContent}
onReadResource={onReadResource}
/> />
</div> </div>
) : ( ) : (