Standalone mcp apps (#6458)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { FileText, Clock, Home, Puzzle, History } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { FileText, Clock, Home, Puzzle, History, AppWindow } from 'lucide-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
SidebarContent,
|
||||
@@ -16,6 +16,7 @@ import { ViewOptions, View } from '../../utils/navigationUtils';
|
||||
import { useChatContext } from '../../contexts/ChatContext';
|
||||
import { DEFAULT_CHAT_TITLE } from '../../contexts/ChatContext';
|
||||
import EnvironmentBadge from './EnvironmentBadge';
|
||||
import { listApps } from '../../api';
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
@@ -84,6 +85,13 @@ const menuItems: NavigationEntry[] = [
|
||||
icon: Puzzle,
|
||||
tooltip: 'Manage your extensions',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
path: '/apps',
|
||||
label: 'Apps',
|
||||
icon: AppWindow,
|
||||
tooltip: 'Browse and launch MCP apps',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
type: 'item',
|
||||
@@ -100,6 +108,7 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
|
||||
const chatContext = useChatContext();
|
||||
const lastSessionIdRef = useRef<string | null>(null);
|
||||
const currentSessionId = currentPath === '/pair' ? searchParams.get('resumeSessionId') : null;
|
||||
const [hasApps, setHasApps] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSessionId) {
|
||||
@@ -108,12 +117,19 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
|
||||
}, [currentSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
// setIsVisible(true);
|
||||
}, 100);
|
||||
const checkApps = async () => {
|
||||
try {
|
||||
const response = await listApps({
|
||||
throwOnError: true,
|
||||
});
|
||||
setHasApps((response.data?.apps || []).length > 0);
|
||||
} catch (err) {
|
||||
console.warn('Failed to check for apps:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
checkApps();
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentItem = menuItems.find(
|
||||
@@ -179,10 +195,19 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const visibleMenuItems = menuItems.filter((entry) => {
|
||||
if (entry.type === 'item' && entry.path === '/apps') {
|
||||
return hasApps;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarContent className="pt-16">
|
||||
<SidebarMenu>{menuItems.map((entry, index) => renderMenuItem(entry, index))}</SidebarMenu>
|
||||
<SidebarMenu>
|
||||
{visibleMenuItems.map((entry, index) => renderMenuItem(entry, index))}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="pb-2 flex items-start">
|
||||
|
||||
@@ -24,12 +24,14 @@ import { readResource, callTool } from '../../api';
|
||||
interface McpAppRendererProps {
|
||||
resourceUri: string;
|
||||
extensionName: string;
|
||||
sessionId: string;
|
||||
sessionId?: string | null;
|
||||
toolInput?: ToolInput;
|
||||
toolInputPartial?: ToolInputPartial;
|
||||
toolResult?: ToolResult;
|
||||
toolCancelled?: ToolCancelled;
|
||||
append?: (text: string) => void;
|
||||
fullscreen?: boolean;
|
||||
cachedHtml?: string;
|
||||
}
|
||||
|
||||
interface ResourceData {
|
||||
@@ -47,9 +49,11 @@ export default function McpAppRenderer({
|
||||
toolResult,
|
||||
toolCancelled,
|
||||
append,
|
||||
fullscreen = false,
|
||||
cachedHtml,
|
||||
}: McpAppRendererProps) {
|
||||
const [resource, setResource] = useState<ResourceData>({
|
||||
html: null,
|
||||
html: cachedHtml || null,
|
||||
csp: null,
|
||||
prefersBorder: true,
|
||||
});
|
||||
@@ -57,6 +61,10 @@ export default function McpAppRenderer({
|
||||
const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchResource = async () => {
|
||||
try {
|
||||
const response = await readResource({
|
||||
@@ -73,19 +81,25 @@ export default function McpAppRenderer({
|
||||
| { ui?: { csp?: CspMetadata; prefersBorder?: boolean } }
|
||||
| undefined;
|
||||
|
||||
setResource({
|
||||
html: content.text,
|
||||
csp: meta?.ui?.csp || null,
|
||||
prefersBorder: meta?.ui?.prefersBorder ?? true,
|
||||
});
|
||||
if (content.text !== cachedHtml) {
|
||||
setResource({
|
||||
html: content.text,
|
||||
csp: meta?.ui?.csp || null,
|
||||
prefersBorder: meta?.ui?.prefersBorder ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load resource');
|
||||
if (!cachedHtml) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load resource');
|
||||
} else {
|
||||
console.warn('Failed to fetch fresh resource, using cached version:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchResource();
|
||||
}, [resourceUri, extensionName, sessionId]);
|
||||
}, [resourceUri, extensionName, sessionId, cachedHtml]);
|
||||
|
||||
const handleMcpRequest = useCallback(
|
||||
async (
|
||||
@@ -93,6 +107,12 @@ export default function McpAppRenderer({
|
||||
params: Record<string, unknown> = {},
|
||||
_id?: string | number
|
||||
): Promise<unknown> => {
|
||||
// Methods that require a session
|
||||
const requiresSession = ['tools/call', 'resources/read'];
|
||||
if (requiresSession.includes(method) && !sessionId) {
|
||||
throw new Error('Session not initialized for MCP request');
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case 'ui/open-link': {
|
||||
const { url } = params as McpMethodParams['ui/open-link'];
|
||||
@@ -121,7 +141,7 @@ export default function McpAppRenderer({
|
||||
const fullToolName = `${extensionName}__${name}`;
|
||||
const response = await callTool({
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
session_id: sessionId!,
|
||||
name: fullToolName,
|
||||
arguments: args || {},
|
||||
},
|
||||
@@ -139,7 +159,7 @@ export default function McpAppRenderer({
|
||||
const { uri } = params as McpMethodParams['resources/read'];
|
||||
const response = await readResource({
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
session_id: sessionId!,
|
||||
uri,
|
||||
extension_name: extensionName,
|
||||
},
|
||||
@@ -193,6 +213,33 @@ export default function McpAppRenderer({
|
||||
);
|
||||
}
|
||||
|
||||
if (fullscreen) {
|
||||
return proxyUrl ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={proxyUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
import { Button } from '../ui/button';
|
||||
import { Play } from 'lucide-react';
|
||||
import { GooseApp, listApps } from '../../api';
|
||||
import { useChatContext } from '../../contexts/ChatContext';
|
||||
|
||||
const GridLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
className="grid gap-4 p-1"
|
||||
style={{
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AppsView() {
|
||||
const [apps, setApps] = useState<GooseApp[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const chatContext = useChatContext();
|
||||
const sessionId = chatContext?.chat.sessionId;
|
||||
|
||||
// Load cached apps immediately on mount
|
||||
useEffect(() => {
|
||||
const loadCachedApps = async () => {
|
||||
try {
|
||||
const response = await listApps({
|
||||
throwOnError: true,
|
||||
});
|
||||
const cachedApps = response.data?.apps || [];
|
||||
setApps(cachedApps);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load cached apps:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCachedApps();
|
||||
}, []);
|
||||
|
||||
// When sessionId becomes available, fetch fresh apps and update cache
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const refreshApps = async () => {
|
||||
try {
|
||||
const response = await listApps({
|
||||
throwOnError: true,
|
||||
query: { session_id: sessionId },
|
||||
});
|
||||
const freshApps = response.data?.apps || [];
|
||||
setApps(freshApps);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.warn('Failed to refresh apps:', err);
|
||||
// Don't set error if we already have cached apps
|
||||
if (apps.length === 0) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load apps');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
refreshApps();
|
||||
// apps.length intentionally not in deps: we want to capture the initial apps.length to check
|
||||
// "did we have cached apps when refresh started?" Adding it would cause infinite loop since setApps() changes apps.length
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId]);
|
||||
|
||||
const loadApps = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await listApps({
|
||||
throwOnError: true,
|
||||
query: { session_id: sessionId },
|
||||
});
|
||||
const fetchedApps = response.data?.apps || [];
|
||||
setApps(fetchedApps);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
// Only set error if we don't have apps to show
|
||||
if (apps.length === 0) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load apps');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionId, apps.length]);
|
||||
|
||||
const handleLaunchApp = async (app: GooseApp) => {
|
||||
try {
|
||||
await window.electron.launchApp(app);
|
||||
} catch (err) {
|
||||
console.error('Failed to launch app:', err);
|
||||
// App launch errors shouldn't hide the apps list, just log it
|
||||
}
|
||||
};
|
||||
|
||||
// Only show error-only UI if we have no apps to display
|
||||
if (error && apps.length === 0) {
|
||||
return (
|
||||
<MainPanelLayout>
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<p className="text-red-500 mb-4">Error loading apps: {error}</p>
|
||||
<Button onClick={loadApps}>Retry</Button>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MainPanelLayout>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="bg-background-default px-8 pb-8 pt-16">
|
||||
<div className="flex flex-col page-transition">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h1 className="text-4xl font-light">Apps</h1>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Applications from your MCP servers that can run in standalone windows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-background-subtle px-8 pb-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-text-muted">Loading apps...</p>
|
||||
</div>
|
||||
) : apps.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium mb-2">No apps available</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Install MCP servers that provide UI resources to see apps here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<GridLayout>
|
||||
{apps.map((app) => (
|
||||
<div
|
||||
key={`${app.uri}-${app.mcpServer}`}
|
||||
className="flex flex-col p-4 border border-border-muted rounded-lg bg-background-panel hover:border-border-default transition-colors"
|
||||
>
|
||||
<div className="flex-1 mb-4">
|
||||
<h3 className="font-medium text-text-default mb-2">{app.name}</h3>
|
||||
{app.description && (
|
||||
<p className="text-sm text-text-muted mb-2">{app.description}</p>
|
||||
)}
|
||||
{app.mcpServer && (
|
||||
<span className="inline-block px-2 py-1 text-xs bg-background-subtle text-text-muted rounded">
|
||||
{app.mcpServer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleLaunchApp(app)}
|
||||
className="flex items-center gap-2 flex-1"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Launch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import McpAppRenderer from '../McpApps/McpAppRenderer';
|
||||
import { startAgent, resumeAgent, listApps, stopAgent } from '../../api';
|
||||
|
||||
export default function StandaloneAppView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [cachedHtml, setCachedHtml] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const resourceUri = searchParams.get('resourceUri');
|
||||
const extensionName = searchParams.get('extensionName');
|
||||
const appName = searchParams.get('appName');
|
||||
const workingDir = searchParams.get('workingDir');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCachedHtml() {
|
||||
if (
|
||||
!resourceUri ||
|
||||
!extensionName ||
|
||||
resourceUri === 'undefined' ||
|
||||
extensionName === 'undefined'
|
||||
) {
|
||||
setError('Missing required parameters');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await listApps({
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
const apps = response.data?.apps || [];
|
||||
const cachedApp = apps.find(
|
||||
(app) => app.uri === resourceUri && app.mcpServer === extensionName
|
||||
);
|
||||
|
||||
if (cachedApp?.text) {
|
||||
setCachedHtml(cachedApp.text);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load cached HTML:', err);
|
||||
}
|
||||
}
|
||||
|
||||
loadCachedHtml();
|
||||
}, [resourceUri, extensionName]);
|
||||
|
||||
useEffect(() => {
|
||||
async function initSession() {
|
||||
if (!resourceUri || !extensionName || !workingDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const startResponse = await startAgent({
|
||||
body: { working_dir: workingDir },
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
const sid = startResponse.data.id;
|
||||
|
||||
await resumeAgent({
|
||||
body: {
|
||||
session_id: sid,
|
||||
load_model_and_extensions: true,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
setSessionId(sid);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize session:', err);
|
||||
if (!cachedHtml) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to initialize session');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initSession();
|
||||
}, [resourceUri, extensionName, workingDir, cachedHtml]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appName) {
|
||||
document.title = appName;
|
||||
}
|
||||
}, [appName]);
|
||||
|
||||
// Cleanup session when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (sessionId) {
|
||||
stopAgent({
|
||||
body: { session_id: sessionId },
|
||||
throwOnError: false,
|
||||
}).catch((err: unknown) => {
|
||||
console.warn('Failed to stop agent on unmount:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
if (error && !cachedHtml) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ color: 'var(--text-error, #ef4444)' }}>Failed to Load App</h2>
|
||||
<p style={{ color: 'var(--text-muted, #6b7280)' }}>{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && !cachedHtml) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<p style={{ color: 'var(--text-muted, #6b7280)' }}>Initializing app...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cachedHtml || sessionId) {
|
||||
return (
|
||||
<div style={{ width: '100vw', height: '100vh', overflow: 'hidden' }}>
|
||||
<McpAppRenderer
|
||||
resourceUri={resourceUri!}
|
||||
extensionName={extensionName!}
|
||||
sessionId={sessionId || null}
|
||||
fullscreen={true}
|
||||
cachedHtml={cachedHtml || undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<p style={{ color: 'var(--text-muted, #6b7280)' }}>Initializing app...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user