update ui

This commit is contained in:
Fredrik Jensen 2025-12-05 22:00:38 +01:00
parent 924e78d387
commit 81ff693bf7
22 changed files with 127 additions and 116 deletions

View File

@ -57,9 +57,23 @@ function MyComponent() {
**`createUI(name, entryUrl)`** - Creates a UI component **`createUI(name, entryUrl)`** - Creates a UI component
- `name`: Component identifier - `name`: Component identifier
- `entryUrl`: Path from `import.meta.resolve()` - `entryUrl`: Path to the component entry file
- Returns: `{ component(opts?) }` where opts: `{ props?, frameSize? }` - Returns: `{ component(opts?) }` where opts: `{ props?, frameSize? }`
The `entryUrl` parameter accepts both formats:
```typescript
// ESM (recommended) - using import.meta.resolve()
// Requires "type": "module" in package.json
createUI('dashboard', import.meta.resolve('./MyComponent.tsx'));
// CommonJS - using require.resolve() or absolute paths
createUI('dashboard', require.resolve('./MyComponent.tsx'));
createUI('dashboard', path.join(__dirname, './MyComponent.tsx'));
```
The library automatically converts `file://` URLs to file paths, so both approaches work seamlessly.
### UI (`@mcp-ui/library/ui`) ### UI (`@mcp-ui/library/ui`)
- **`useProps(defaults)`** - Get props passed from the server - **`useProps(defaults)`** - Get props passed from the server

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { generateMockStockData } from './stock-utils'; import { generateMockStockData } from './stock-utils';
import { callTool, sendPrompt, useProps } from '../../library/ui'; import { callTool, sendPrompt, useProps } from '@mcp-ui/library/ui';
// Types for props passed from the tool handler // Types for props passed from the tool handler
@ -74,7 +74,7 @@ Please analyze this portfolio and provide recommendations.`;
{/* Header */} {/* Header */}
<div className="header"> <div className="header">
<div> <div>
<h1>📈 Stock Portfolio!!</h1> <h1>📈 Stock Portfolio</h1>
<p className="subtitle">Timeframe: {props.timeframe} {stocks.length} stocks</p> <p className="subtitle">Timeframe: {props.timeframe} {stocks.length} stocks</p>
</div> </div>
<div className="portfolio-summary"> <div className="portfolio-summary">

View File

@ -2,7 +2,6 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MCP UI Inspector</title> <title>MCP UI Inspector</title>
</head> </head>

View File

@ -17,12 +17,13 @@
.header-brand { .header-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 7px;
color: var(--text-primary); color: var(--white);
cursor: pointer;
} }
.header-brand svg { .header-brand svg {
color: var(--accent-purple); color: var(--white);
} }
.header-brand h1 { .header-brand h1 {
@ -67,8 +68,13 @@
} }
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1; } 0%,
50% { opacity: 0.5; } 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
} }
/* Main layout */ /* Main layout */

View File

@ -4,6 +4,7 @@ import { ToolsPanel } from './components/ToolsPanel'
import { ResultsPane } from './components/ResultsPane' import { ResultsPane } from './components/ResultsPane'
import { useMCP } from './hooks/useMCP' import { useMCP } from './hooks/useMCP'
import './App.css' import './App.css'
import { Eye, Sparkles } from 'lucide-react'
export type Tool = { export type Tool = {
name: string name: string
@ -43,6 +44,7 @@ function App() {
const [selectedTool, setSelectedTool] = useState<Tool | null>(null) const [selectedTool, setSelectedTool] = useState<Tool | null>(null)
const [toolResult, setToolResult] = useState<ToolResult | null>(null) const [toolResult, setToolResult] = useState<ToolResult | null>(null)
const [isExecuting, setIsExecuting] = useState(false) const [isExecuting, setIsExecuting] = useState(false)
const [lastExecution, setLastExecution] = useState<{ toolName: string; params: Record<string, unknown> } | null>(null)
const handleConnect = useCallback(async () => { const handleConnect = useCallback(async () => {
if (isConnected) { if (isConnected) {
@ -57,6 +59,7 @@ function App() {
const handleExecuteTool = useCallback(async (toolName: string, params: Record<string, unknown>) => { const handleExecuteTool = useCallback(async (toolName: string, params: Record<string, unknown>) => {
setIsExecuting(true) setIsExecuting(true)
setToolResult(null) setToolResult(null)
setLastExecution({ toolName, params })
try { try {
const result = await callTool(toolName, params) const result = await callTool(toolName, params)
@ -76,15 +79,16 @@ function App() {
} }
}, [callTool]) }, [callTool])
const handleReload = useCallback(async () => {
if (lastExecution) {
await handleExecuteTool(lastExecution.toolName, lastExecution.params)
}
}, [lastExecution, handleExecuteTool])
return ( return (
<div className="app"> <div className="app">
<header className="header"> <header className="header">
<div className="header-brand"> <div className="header-brand" onClick={() => window.location.reload()}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<h1>MCP UI Inspector</h1> <h1>MCP UI Inspector</h1>
</div> </div>
<div className="header-status"> <div className="header-status">
@ -127,6 +131,7 @@ function App() {
<ResultsPane <ResultsPane
result={toolResult} result={toolResult}
isExecuting={isExecuting} isExecuting={isExecuting}
onReload={lastExecution ? handleReload : undefined}
/> />
</div> </div>
</main> </main>

View File

@ -9,16 +9,17 @@
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
border-radius: 6px; border-radius: 6px;
border: 1px solid var(--border-color); background: var(--bg-black);
background: var(--bg-tertiary);
color: var(--text-primary); color: var(--text-primary);
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
white-space: nowrap; white-space: nowrap;
border: none;
outline: 1px solid rgba(255, 255, 255, 0.15);
} }
.btn:hover:not(:disabled) { .btn:hover:not(:disabled) {
background: var(--bg-hover); box-shadow: 0 0 10px 1px rgba(255, 255, 255, 0.3);
} }
.btn:disabled { .btn:disabled {
@ -28,14 +29,8 @@
/* Primary Variant */ /* Primary Variant */
.btn-primary { .btn-primary {
background: var(--accent-blue); background: var(--white);
border-color: var(--accent-blue); color: black;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #4c9aed;
border-color: #4c9aed;
} }
/* Ghost Variant (icon buttons) */ /* Ghost Variant (icon buttons) */
@ -45,11 +40,6 @@
padding: 6px; padding: 6px;
} }
.btn-ghost:hover:not(:disabled) {
background: var(--bg-hover);
border-color: transparent;
}
/* Loading State */ /* Loading State */
.btn-loading { .btn-loading {
cursor: wait; cursor: wait;

View File

@ -49,13 +49,21 @@
} }
.tab.active { .tab.active {
color: var(--accent-blue); color: var(--white);
border-bottom-color: var(--accent-blue); border-bottom-color: var(--accent-blue);
} }
.tab-badge { .tab-badge {
font-size: 10px; display: inline-block;
color: var(--accent-green); width: 5px;
height: 5px;
margin-left: 5px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
}
.tab-badge.active {
background: var(--accent-blue);
} }
.results-actions { .results-actions {
@ -152,7 +160,9 @@
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
.ui-frame { .ui-frame {
@ -169,7 +179,8 @@
} }
.text-output pre { .text-output pre {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
monospace;
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;
color: var(--text-primary); color: var(--text-primary);

View File

@ -1,5 +1,5 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Monitor, FileText, Clock, AlertTriangle, Maximize2, Minimize2 } from 'lucide-react' import { Monitor, FileText, Clock, AlertTriangle, Maximize2, Minimize2, RotateCw } from 'lucide-react'
import { Button } from './Button' import { Button } from './Button'
import type { ToolResult } from '../App' import type { ToolResult } from '../App'
import './ResultsPane.css' import './ResultsPane.css'
@ -7,37 +7,46 @@ import './ResultsPane.css'
interface ResultsPaneProps { interface ResultsPaneProps {
result: ToolResult | null result: ToolResult | null
isExecuting: boolean isExecuting: boolean
onReload?: () => void
} }
type Tab = 'ui' | 'text' type Tab = 'ui' | 'text'
export function ResultsPane({ result, isExecuting }: ResultsPaneProps) { export function ResultsPane({ result, isExecuting, onReload }: ResultsPaneProps) {
const [activeTab, setActiveTab] = useState<Tab>('ui') const [activeTab, setActiveTab] = useState<Tab>('ui')
const [isFullscreen, setIsFullscreen] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false)
const hasUI = result?.htmlContent != null const hasUI = result?.htmlContent != null
const hasText = result?.textContent != null const hasText = result?.textContent != null
useEffect(() => {
if (hasUI) {
setActiveTab('ui')
} else if (hasText) {
setActiveTab('text')
}
}, [hasText, hasUI])
return ( return (
<div className={`results-pane ${isFullscreen ? 'fullscreen' : ''}`}> <div className={`results-pane ${isFullscreen ? 'fullscreen' : ''}`}>
<div className="panel-header"> <div className="panel-header">
<div className="results-tabs"> <div className="results-tabs">
<Button <button
className={`tab ${activeTab === 'ui' ? 'active' : ''}`} className={`tab ${activeTab === 'ui' ? 'active' : ''}`}
onClick={() => setActiveTab('ui')} onClick={() => setActiveTab('ui')}
icon={<Monitor size={14} />}
> >
UI Output <Monitor size={14} />
{hasUI && <span className="tab-badge"></span>} UI
</Button> <span className={`tab-badge ${hasUI ? 'active' : ''}`} />
<Button </button>
<button
className={`tab ${activeTab === 'text' ? 'active' : ''}`} className={`tab ${activeTab === 'text' ? 'active' : ''}`}
onClick={() => setActiveTab('text')} onClick={() => setActiveTab('text')}
icon={<FileText size={14} />}
> >
Text Response <FileText size={14} />
{hasText && <span className="tab-badge"></span>} Text
</Button> <span className={`tab-badge ${hasText ? 'active' : ''}`} />
</button>
</div> </div>
<div className="results-actions"> <div className="results-actions">
@ -47,6 +56,15 @@ export function ResultsPane({ result, isExecuting }: ResultsPaneProps) {
{result.timestamp.toLocaleTimeString()} {result.timestamp.toLocaleTimeString()}
</span> </span>
)} )}
{onReload && (
<Button
variant="ghost"
onClick={onReload}
disabled={isExecuting}
title="Reload"
icon={<RotateCw size={14} />}
/>
)}
<Button <Button
variant="ghost" variant="ghost"
onClick={() => setIsFullscreen(!isFullscreen)} onClick={() => setIsFullscreen(!isFullscreen)}

View File

@ -21,13 +21,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 12px 16px; padding: 13px 16px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: var(--text-secondary);
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
} }
.sidebar-section-content { .sidebar-section-content {
@ -99,7 +98,9 @@
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
.sidebar-about { .sidebar-about {

View File

@ -62,7 +62,7 @@ export function Sidebar({
{isConnected && sessionId && ( {isConnected && sessionId && (
<div className="session-info"> <div className="session-info">
<div className="session-label">Session ID</div> <div className="session-label">Session ID</div>
<code className="session-id">{sessionId.slice(0, 8)}...</code> <code className="session-id">{sessionId.slice(0, 26)}...</code>
</div> </div>
)} )}
</div> </div>

View File

@ -19,7 +19,7 @@
} }
.tool-count { .tool-count {
background: var(--bg-hover); background: rgba(255, 255, 255, 0.1);
padding: 2px 8px; padding: 2px 8px;
border-radius: 10px; border-radius: 10px;
font-size: 11px; font-size: 11px;
@ -165,7 +165,8 @@
.json-editor { .json-editor {
width: 100%; width: 100%;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
monospace;
font-size: 12px; font-size: 12px;
background: var(--bg-primary); background: var(--bg-primary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@ -240,7 +241,9 @@
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Wrench, Play, ChevronRight, Code2, FileJson } from 'lucide-react' import { Wrench, Play, Circle, CircleDot, Code2, FileJson } from 'lucide-react'
import { Button } from './Button' import { Button } from './Button'
import type { Tool } from '../App' import type { Tool } from '../App'
import './ToolsPanel.css' import './ToolsPanel.css'
@ -148,7 +148,7 @@ export function ToolsPanel({
className={`tool-item ${selectedTool?.name === tool.name ? 'selected' : ''}`} className={`tool-item ${selectedTool?.name === tool.name ? 'selected' : ''}`}
onClick={() => onSelectTool(tool)} onClick={() => onSelectTool(tool)}
> >
<ChevronRight size={14} className="tool-chevron" /> {selectedTool?.name === tool.name ? <CircleDot size={14} className="tool-chevron" /> : <Circle size={14} className="tool-chevron" />}
<div className="tool-info"> <div className="tool-info">
<span className="tool-name">{tool.name}</span> <span className="tool-name">{tool.name}</span>
{tool.description && ( {tool.description && (

View File

@ -8,7 +8,6 @@
--bg-primary: #000000; --bg-primary: #000000;
--bg-secondary: #000000; --bg-secondary: #000000;
--bg-tertiary: #141414; --bg-tertiary: #141414;
--bg-hover: #30363d;
--border-color: #30363d; --border-color: #30363d;
--text-primary: #e6edf3; --text-primary: #e6edf3;
--text-secondary: #8b949e; --text-secondary: #8b949e;
@ -18,6 +17,7 @@
--accent-red: #f85149; --accent-red: #f85149;
--accent-orange: #d29922; --accent-orange: #d29922;
--accent-purple: #a371f7; --accent-purple: #a371f7;
--white: #ffffff;
} }
body { body {
@ -69,51 +69,7 @@ textarea {
input:focus, input:focus,
select:focus, select:focus,
textarea:focus { textarea:focus {
border-color: var(--accent-blue); border-color: var(--white);
}
/* Button styles */
button {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
display: inline-flex;
align-items: center;
gap: 8px;
}
button:hover {
background: var(--bg-hover);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
background: var(--accent-blue);
border-color: var(--accent-blue);
color: #fff;
}
button.primary:hover {
background: #4c9aed;
}
button.success {
background: var(--accent-green);
border-color: var(--accent-green);
color: #fff;
}
button.success:hover {
background: #2ea043;
} }
/* Code/mono text */ /* Code/mono text */

View File

@ -1,10 +1,13 @@
import * as esbuild from 'esbuild'; import * as esbuild from 'esbuild';
// Cache bundled JS per entry path // Cache bundled JS per entry path (only used in production)
const bundleCache = new Map<string, string>(); const bundleCache = new Map<string, string>();
// In development mode, skip cache to allow hot-reloading of component changes
const isDev = process.env.NODE_ENV !== 'production';
export async function bundleComponent(entryPath: string): Promise<string> { export async function bundleComponent(entryPath: string): Promise<string> {
if (bundleCache.has(entryPath)) { if (!isDev && bundleCache.has(entryPath)) {
return bundleCache.get(entryPath)!; return bundleCache.get(entryPath)!;
} }
@ -23,6 +26,11 @@ export async function bundleComponent(entryPath: string): Promise<string> {
}); });
const bundledJS = result.outputFiles[0].text; const bundledJS = result.outputFiles[0].text;
// Only cache in production mode
if (!isDev) {
bundleCache.set(entryPath, bundledJS); bundleCache.set(entryPath, bundledJS);
}
return bundledJS; return bundledJS;
} }