Extract server dropdown into ServerSelect component and simplify ToolCallInfoPanel to read server name from toolCallInfo.serverInfo. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
255 lines
7.4 KiB
TypeScript
255 lines
7.4 KiB
TypeScript
import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo } from "./implementation";
|
|
import styles from "./index.module.css";
|
|
|
|
|
|
// Host passes serversPromise to CallToolPanel
|
|
interface HostProps {
|
|
serversPromise: Promise<ServerInfo[]>;
|
|
}
|
|
function Host({ serversPromise }: HostProps) {
|
|
const [toolCalls, setToolCalls] = useState<ToolCallInfo[]>([]);
|
|
|
|
return (
|
|
<>
|
|
{toolCalls.map((info, i) => (
|
|
<ToolCallInfoPanel key={i} toolCallInfo={info} />
|
|
))}
|
|
<CallToolPanel
|
|
serversPromise={serversPromise}
|
|
addToolCall={(info) => setToolCalls([...toolCalls, info])}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
|
|
// CallToolPanel renders the unified form with Suspense around ServerSelect
|
|
interface CallToolPanelProps {
|
|
serversPromise: Promise<ServerInfo[]>;
|
|
addToolCall: (info: ToolCallInfo) => void;
|
|
}
|
|
function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
|
|
const [selectedServer, setSelectedServer] = useState<ServerInfo | null>(null);
|
|
const [selectedTool, setSelectedTool] = useState("");
|
|
const [inputJson, setInputJson] = useState("{}");
|
|
|
|
const toolNames = selectedServer ? Array.from(selectedServer.tools.keys()) : [];
|
|
|
|
const isValidJson = useMemo(() => {
|
|
try {
|
|
JSON.parse(inputJson);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, [inputJson]);
|
|
|
|
const handleServerSelect = (server: ServerInfo) => {
|
|
setSelectedServer(server);
|
|
const [firstTool] = server.tools.keys();
|
|
setSelectedTool(firstTool ?? "");
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
if (!selectedServer) return;
|
|
const toolCallInfo = callTool(selectedServer, selectedTool, JSON.parse(inputJson));
|
|
addToolCall(toolCallInfo);
|
|
};
|
|
|
|
return (
|
|
<div className={styles.callToolPanel}>
|
|
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
|
<label>
|
|
Server
|
|
<Suspense fallback={<select disabled><option>Loading...</option></select>}>
|
|
<ServerSelect serversPromise={serversPromise} onSelect={handleServerSelect} />
|
|
</Suspense>
|
|
</label>
|
|
<label>
|
|
Tool
|
|
<select
|
|
className={styles.toolSelect}
|
|
value={selectedTool}
|
|
onChange={(e) => setSelectedTool(e.target.value)}
|
|
>
|
|
{selectedServer && toolNames.map((name) => (
|
|
<option key={name} value={name}>{name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Input
|
|
<textarea
|
|
className={styles.toolInput}
|
|
aria-invalid={!isValidJson}
|
|
value={inputJson}
|
|
onChange={(e) => setInputJson(e.target.value)}
|
|
/>
|
|
</label>
|
|
<button type="submit" disabled={!selectedTool || !isValidJson}>
|
|
Call Tool
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
// ServerSelect calls use() and renders the server <select>
|
|
interface ServerSelectProps {
|
|
serversPromise: Promise<ServerInfo[]>;
|
|
onSelect: (server: ServerInfo) => void;
|
|
}
|
|
function ServerSelect({ serversPromise, onSelect }: ServerSelectProps) {
|
|
const servers = use(serversPromise);
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (servers.length > selectedIndex) {
|
|
onSelect(servers[selectedIndex]);
|
|
}
|
|
}, [servers]);
|
|
|
|
if (servers.length === 0) {
|
|
return <select disabled><option>No servers configured</option></select>;
|
|
}
|
|
|
|
return (
|
|
<select
|
|
value={selectedIndex}
|
|
onChange={(e) => {
|
|
const newIndex = Number(e.target.value);
|
|
setSelectedIndex(newIndex);
|
|
onSelect(servers[newIndex]);
|
|
}}
|
|
>
|
|
{servers.map((server, i) => (
|
|
<option key={i} value={i}>{server.name}</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
|
|
interface ToolCallInfoPanelProps {
|
|
toolCallInfo: ToolCallInfo;
|
|
}
|
|
function ToolCallInfoPanel({ toolCallInfo }: ToolCallInfoPanelProps) {
|
|
return (
|
|
<div className={styles.toolCallInfoPanel}>
|
|
<div className={styles.inputInfoPanel}>
|
|
<h2>
|
|
<span>{toolCallInfo.serverInfo.name}</span>
|
|
<span className={styles.toolName}>{toolCallInfo.tool.name}</span>
|
|
</h2>
|
|
<JsonBlock value={toolCallInfo.input} />
|
|
</div>
|
|
<div className={styles.outputInfoPanel}>
|
|
<ErrorBoundary>
|
|
<Suspense fallback="Loading...">
|
|
{
|
|
hasAppHtml(toolCallInfo)
|
|
? <AppIFramePanel toolCallInfo={toolCallInfo} />
|
|
: <ToolResultPanel toolCallInfo={toolCallInfo} />
|
|
}
|
|
</Suspense>
|
|
</ErrorBoundary>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
function JsonBlock({ value }: { value: object }) {
|
|
return (
|
|
<pre className={styles.jsonBlock}>
|
|
<code>{JSON.stringify(value, null, 2)}</code>
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
|
|
interface AppIFramePanelProps {
|
|
toolCallInfo: Required<ToolCallInfo>;
|
|
}
|
|
function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
|
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
const iframe = iframeRef.current!;
|
|
loadSandboxProxy(iframe).then((firstTime) => {
|
|
// The `firstTime` check guards against React Strict Mode's double
|
|
// invocation (mount → unmount → remount simulation in development).
|
|
// Outside of Strict Mode, this `useEffect` runs only once per
|
|
// `toolCallInfo`.
|
|
if (firstTime) {
|
|
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe);
|
|
initializeApp(iframe, appBridge, toolCallInfo);
|
|
}
|
|
});
|
|
}, [toolCallInfo]);
|
|
|
|
return (
|
|
<div className={styles.appIframePanel}>
|
|
<iframe ref={iframeRef} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
interface ToolResultPanelProps {
|
|
toolCallInfo: ToolCallInfo;
|
|
}
|
|
function ToolResultPanel({ toolCallInfo }: ToolResultPanelProps) {
|
|
const result = use(toolCallInfo.resultPromise);
|
|
return <JsonBlock value={result} />;
|
|
}
|
|
|
|
|
|
interface ErrorBoundaryProps {
|
|
children: ReactNode;
|
|
}
|
|
interface ErrorBoundaryState {
|
|
hasError: boolean;
|
|
error: unknown;
|
|
}
|
|
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
state: ErrorBoundaryState = { hasError: false, error: undefined };
|
|
|
|
// Called during render phase - must be pure (no side effects)
|
|
// Note: error is `unknown` because JS allows throwing any value
|
|
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
|
|
return { hasError: true, error };
|
|
}
|
|
|
|
// Called during commit phase - can have side effects (logging, etc.)
|
|
componentDidCatch(error: unknown, errorInfo: ErrorInfo): void {
|
|
log.error("Caught:", error, errorInfo.componentStack);
|
|
}
|
|
|
|
render(): ReactNode {
|
|
if (this.state.hasError) {
|
|
const { error } = this.state;
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return <div className={styles.error}><strong>ERROR:</strong> {message}</div>;
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
|
|
async function connectToAllServers(): Promise<ServerInfo[]> {
|
|
const serverUrlsResponse = await fetch("/api/servers");
|
|
const serverUrls = (await serverUrlsResponse.json()) as string[];
|
|
return Promise.all(serverUrls.map((url) => connectToServer(new URL(url))));
|
|
}
|
|
|
|
createRoot(document.getElementById("root")!).render(
|
|
<StrictMode>
|
|
<ErrorBoundary>
|
|
<Host serversPromise={connectToAllServers()} />
|
|
</ErrorBoundary>
|
|
</StrictMode>,
|
|
);
|