Vibe mcp apps (#6569)
Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
import { Button } from '../ui/button';
|
||||
import { Play } from 'lucide-react';
|
||||
import { GooseApp, listApps } from '../../api';
|
||||
import { Download, Play, Upload } from 'lucide-react';
|
||||
import { exportApp, GooseApp, importApp, listApps } from '../../api';
|
||||
import { useChatContext } from '../../contexts/ChatContext';
|
||||
import { formatAppName } from '../../utils/conversionUtils';
|
||||
|
||||
const GridLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
@@ -73,6 +74,32 @@ export default function AppsView() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePlatformEvent = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const eventData = customEvent.detail;
|
||||
|
||||
if (eventData?.extension === 'apps') {
|
||||
const eventSessionId = eventData.sessionId || sessionId;
|
||||
|
||||
// Refresh apps list to get latest state
|
||||
if (eventSessionId) {
|
||||
listApps({
|
||||
throwOnError: false,
|
||||
query: { session_id: eventSessionId },
|
||||
}).then((response) => {
|
||||
if (response.data?.apps) {
|
||||
setApps(response.data.apps);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('platform-event', handlePlatformEvent);
|
||||
return () => window.removeEventListener('platform-event', handlePlatformEvent);
|
||||
}, [sessionId]);
|
||||
|
||||
const loadApps = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
@@ -104,6 +131,59 @@ export default function AppsView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadApp = async (app: GooseApp) => {
|
||||
try {
|
||||
const response = await exportApp({
|
||||
throwOnError: true,
|
||||
path: { name: app.name },
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
const blob = new Blob([response.data as string], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${app.name}.html`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export app:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to export app');
|
||||
}
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleUploadApp = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
await importApp({
|
||||
throwOnError: true,
|
||||
body: { html: text },
|
||||
});
|
||||
|
||||
const response = await listApps({
|
||||
throwOnError: true,
|
||||
});
|
||||
setApps(response.data?.apps || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to import app:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to import app');
|
||||
}
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
// Only show error-only UI if we have no apps to display
|
||||
if (error && apps.length === 0) {
|
||||
return (
|
||||
@@ -119,14 +199,36 @@ export default function AppsView() {
|
||||
return (
|
||||
<MainPanelLayout>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".html"
|
||||
onChange={handleUploadApp}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<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>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Import App
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-text-muted mb-2">
|
||||
Applications from your MCP servers and Apps build by goose itself. You can ask it to
|
||||
create new apps through the chat interface and they will appear here.
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
⚠️ Experimental feature - may change or be removed at any time
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Applications from your MCP servers that can run in standalone windows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,41 +242,58 @@ export default function AppsView() {
|
||||
<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.
|
||||
Open a chat and ask goose for the app you want to have. It can build one for you
|
||||
and that will appear here. Or if somebody shared an app, you can import it using
|
||||
the button above.
|
||||
</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>
|
||||
)}
|
||||
{apps.map((app) => {
|
||||
const isCustomApp = app.mcpServers?.includes('apps') ?? false;
|
||||
return (
|
||||
<div
|
||||
key={`${app.uri}-${app.mcpServers?.join(',')}`}
|
||||
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">
|
||||
{formatAppName(app.name)}
|
||||
</h3>
|
||||
{app.description && (
|
||||
<p className="text-sm text-text-muted mb-2">{app.description}</p>
|
||||
)}
|
||||
{app.mcpServers && app.mcpServers.length > 0 && (
|
||||
<span className="inline-block px-2 py-1 text-xs bg-background-subtle text-text-muted rounded">
|
||||
{isCustomApp ? 'Custom app' : app.mcpServers.join(', ')}
|
||||
</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>
|
||||
{isCustomApp && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadApp(app)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user