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:
Douwe Osinga
2026-01-22 13:03:44 -05:00
committed by GitHub
parent 13bdff4bb5
commit 01e607a12d
28 changed files with 2198 additions and 294 deletions
+6
View File
@@ -52,6 +52,7 @@ import { getInitialWorkingDir } from './utils/workingDir';
import { usePageViewTracking } from './hooks/useAnalytics';
import { trackOnboardingCompleted, trackErrorWithContext } from './utils/analytics';
import { AppEvents } from './constants/events';
import { registerPlatformEventHandlers } from './utils/platform_events';
function PageViewTracker() {
usePageViewTracking();
@@ -603,6 +604,11 @@ export function AppInner() {
};
}, [navigate]);
// Register platform event handlers for app lifecycle management
useEffect(() => {
return registerPlatformEventHandlers();
}, []);
if (fatalError) {
return <ErrorUI error={errorMessage(fatalError)} />;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+75 -4
View File
@@ -363,11 +363,9 @@ export type GetToolsQuery = {
session_id: string;
};
/**
* A Goose App combining MCP resource data with Goose-specific metadata
*/
export type GooseApp = McpAppResource & (WindowProps | null) & {
mcpServer?: string | null;
mcpServers?: Array<string>;
prd?: string | null;
};
export type Icon = {
@@ -387,6 +385,15 @@ export type ImageContent = {
mimeType: string;
};
export type ImportAppRequest = {
html: string;
};
export type ImportAppResponse = {
message: string;
name: string;
};
export type ImportSessionRequest = {
json: string;
};
@@ -1042,6 +1049,7 @@ export type SystemInfo = {
};
export type SystemNotificationContent = {
data?: unknown;
msg: string;
notificationType: SystemNotificationType;
};
@@ -1348,6 +1356,69 @@ export type CallToolResponses = {
export type CallToolResponse2 = CallToolResponses[keyof CallToolResponses];
export type ExportAppData = {
body?: never;
path: {
/**
* Name of the app to export
*/
name: string;
};
query?: never;
url: '/agent/export_app/{name}';
};
export type ExportAppErrors = {
/**
* App not found
*/
404: ErrorResponse;
/**
* Internal server error
*/
500: ErrorResponse;
};
export type ExportAppError = ExportAppErrors[keyof ExportAppErrors];
export type ExportAppResponses = {
/**
* App HTML exported successfully
*/
200: string;
};
export type ExportAppResponse = ExportAppResponses[keyof ExportAppResponses];
export type ImportAppData = {
body: ImportAppRequest;
path?: never;
query?: never;
url: '/agent/import_app';
};
export type ImportAppErrors = {
/**
* Bad request - Invalid HTML
*/
400: ErrorResponse;
/**
* Internal server error
*/
500: ErrorResponse;
};
export type ImportAppError = ImportAppErrors[keyof ImportAppErrors];
export type ImportAppResponses = {
/**
* App imported successfully
*/
201: ImportAppResponse;
};
export type ImportAppResponse2 = ImportAppResponses[keyof ImportAppResponses];
export type ListAppsData = {
body?: never;
path?: never;
@@ -23,12 +23,13 @@ import {
import { Gear } from '../icons';
import { View, ViewOptions } from '../../utils/navigationUtils';
import { DEFAULT_CHAT_TITLE, useChatContext } from '../../contexts/ChatContext';
import { listSessions, listApps, Session } from '../../api';
import { listSessions, Session } from '../../api';
import { resumeSession, startNewSession, shouldShowNewChatTitle } from '../../sessions';
import { useNavigation } from '../../hooks/useNavigation';
import { SessionIndicators } from '../SessionIndicators';
import { useSidebarSessionStatus } from '../../hooks/useSidebarSessionStatus';
import { getInitialWorkingDir } from '../../utils/workingDir';
import { useConfig } from '../ConfigContext';
interface SidebarProps {
onSelectSession: (sessionId: string) => void;
@@ -60,6 +61,13 @@ const menuItems: NavigationEntry[] = [
icon: FileText,
tooltip: 'Browse your saved recipes',
},
{
type: 'item',
path: '/apps',
label: 'Apps',
icon: AppWindow,
tooltip: 'MCP and custom apps',
},
{
type: 'item',
path: '/schedules',
@@ -74,13 +82,6 @@ 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',
@@ -195,10 +196,13 @@ SessionList.displayName = 'SessionList';
const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
const navigate = useNavigate();
const chatContext = useChatContext();
const configContext = useConfig();
const setView = useNavigation();
const appsExtensionEnabled = !!configContext.extensionsList?.find((ext) => ext.name === 'apps')
?.enabled;
const [searchParams] = useSearchParams();
const [recentSessions, setRecentSessions] = useState<Session[]>([]);
const [hasApps, setHasApps] = useState(false);
const activeSessionId = searchParams.get('resumeSessionId') ?? undefined;
const { getSessionStatus, clearUnread } = useSidebarSessionStatus(activeSessionId);
@@ -251,21 +255,6 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
loadRecentSessions();
}, []);
useEffect(() => {
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);
}
};
checkApps();
}, [currentPath]);
useEffect(() => {
let pollingTimeouts: ReturnType<typeof setTimeout>[] = [];
let isPolling = false;
@@ -477,8 +466,9 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
};
const visibleMenuItems = menuItems.filter((entry) => {
// Filter out Apps if extension is not enabled
if (entry.type === 'item' && entry.path === '/apps') {
return hasApps;
return appsExtensionEnabled;
}
return true;
});
@@ -544,7 +534,6 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
<SidebarSeparator />
{/* Other menu items - filter out Apps if no apps available */}
{visibleMenuItems.map((entry, index) => renderMenuItem(entry, index))}
</SidebarMenu>
</SidebarContent>
+154 -35
View File
@@ -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>
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import McpAppRenderer from '../McpApps/McpAppRenderer';
import { startAgent, resumeAgent, listApps, stopAgent } from '../../api';
import { formatAppName } from '../../utils/conversionUtils';
export default function StandaloneAppView() {
const [searchParams] = useSearchParams();
@@ -35,7 +36,7 @@ export default function StandaloneAppView() {
const apps = response.data?.apps || [];
const cachedApp = apps.find(
(app) => app.uri === resourceUri && app.mcpServer === extensionName
(app) => app.uri === resourceUri && app.mcpServers?.includes(extensionName)
);
if (cachedApp?.text) {
@@ -88,7 +89,7 @@ export default function StandaloneAppView() {
useEffect(() => {
if (appName) {
document.title = appName;
document.title = formatAppName(appName);
}
}, [appName]);
+6 -3
View File
@@ -24,6 +24,7 @@ import {
} from '../types/message';
import { errorMessage } from '../utils/conversionUtils';
import { showExtensionLoadResults } from '../utils/extensionErrorUtils';
import { maybeHandlePlatformEvent } from '../utils/platform_events';
const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
@@ -197,7 +198,8 @@ async function streamFromResponse(
stream: AsyncIterable<MessageEvent>,
initialMessages: Message[],
dispatch: React.Dispatch<StreamAction>,
onFinish: (error?: string) => void
onFinish: (error?: string) => void,
sessionId: string
): Promise<void> {
let currentMessages = initialMessages;
@@ -249,6 +251,7 @@ async function streamFromResponse(
}
case 'Notification': {
dispatch({ type: 'ADD_NOTIFICATION', payload: event as NotificationEvent });
maybeHandlePlatformEvent(event.message, sessionId);
break;
}
case 'Ping':
@@ -540,7 +543,7 @@ export function useChatStream({
signal: abortControllerRef.current.signal,
});
await streamFromResponse(stream, currentMessages, dispatch, onFinish);
await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId);
} catch (error) {
// AbortError is expected when user stops streaming
if (error instanceof Error && error.name === 'AbortError') {
@@ -581,7 +584,7 @@ export function useChatStream({
signal: abortControllerRef.current.signal,
});
await streamFromResponse(stream, currentMessages, dispatch, onFinish);
await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Silently handle abort
+45 -3
View File
@@ -29,6 +29,7 @@ import { expandTilde } from './utils/pathUtils';
import log from './utils/logger';
import { ensureWinShims } from './utils/winShims';
import { addRecentDir, loadRecentDirs } from './utils/recentDirs';
import { formatAppName } from './utils/conversionUtils';
import {
EnvToggles,
loadSettings,
@@ -516,8 +517,8 @@ let appConfig = {
const windowMap = new Map<number, BrowserWindow>();
const goosedClients = new Map<number, Client>();
const appWindows = new Map<string, BrowserWindow>();
// Track power save blockers per window
const windowPowerSaveBlockers = new Map<number, number>(); // windowId -> blockerId
// Track pending initial messages per window
const pendingInitialMessages = new Map<number, string>(); // windowId -> initialMessage
@@ -2484,10 +2485,11 @@ async function appMain() {
const baseUrl = new URL(currentUrl).origin;
const appWindow = new BrowserWindow({
title: gooseApp.name,
title: formatAppName(gooseApp.name),
width: gooseApp.width ?? 800,
height: gooseApp.height ?? 600,
resizable: gooseApp.resizable ?? true,
useContentSize: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
@@ -2498,13 +2500,15 @@ async function appMain() {
});
goosedClients.set(appWindow.id, launchingClient);
appWindows.set(gooseApp.name, appWindow);
appWindow.on('close', () => {
goosedClients.delete(appWindow.id);
appWindows.delete(gooseApp.name);
});
const workingDir = app.getPath('home');
const extensionName = gooseApp.mcpServer ?? '';
const extensionName = gooseApp.mcpServers?.[0] ?? '';
const standaloneUrl =
`${baseUrl}/#/standalone-app?` +
`resourceUri=${encodeURIComponent(gooseApp.uri)}` +
@@ -2519,6 +2523,44 @@ async function appMain() {
throw error;
}
});
ipcMain.handle('refresh-app', async (_event, gooseApp: GooseApp) => {
try {
const appWindow = appWindows.get(gooseApp.name);
if (!appWindow || appWindow.isDestroyed()) {
console.log(`App window for '${gooseApp.name}' not found or destroyed, skipping refresh`);
return;
}
// Bring to front first
if (appWindow.isMinimized()) {
appWindow.restore();
}
appWindow.show();
appWindow.focus();
// Then reload
await appWindow.webContents.reload();
} catch (error) {
console.error('Failed to refresh app:', error);
throw error;
}
});
ipcMain.handle('close-app', async (_event, appName: string) => {
try {
const appWindow = appWindows.get(appName);
if (!appWindow || appWindow.isDestroyed()) {
console.log(`App window for '${appName}' not found or destroyed, skipping close`);
return;
}
appWindow.close();
} catch (error) {
console.error('Failed to close app:', error);
throw error;
}
});
}
app.whenReady().then(async () => {
+4
View File
@@ -138,6 +138,8 @@ type ElectronAPI = {
recordRecipeHash: (recipe: Recipe) => Promise<boolean>;
openDirectoryInExplorer: (directoryPath: string) => Promise<boolean>;
launchApp: (app: GooseApp) => Promise<void>;
refreshApp: (app: GooseApp) => Promise<void>;
closeApp: (appName: string) => Promise<void>;
addRecentDir: (dir: string) => Promise<boolean>;
};
@@ -278,6 +280,8 @@ const electronAPI: ElectronAPI = {
openDirectoryInExplorer: (directoryPath: string) =>
ipcRenderer.invoke('open-directory-in-explorer', directoryPath),
launchApp: (app: GooseApp) => ipcRenderer.invoke('launch-app', app),
refreshApp: (app: GooseApp) => ipcRenderer.invoke('refresh-app', app),
closeApp: (appName: string) => ipcRenderer.invoke('close-app', appName),
addRecentDir: (dir: string) => ipcRenderer.invoke('add-recent-dir', dir),
};
+8
View File
@@ -21,3 +21,11 @@ export function errorMessage(err: Error | unknown, default_value?: string) {
return default_value || String(err);
}
}
export function formatAppName(name: string): string {
return name
.split(/[-_\s]+/)
.filter((word) => word.length > 0)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
+95
View File
@@ -0,0 +1,95 @@
import { listApps, GooseApp } from '../api';
interface PlatformEventData {
extension: string;
sessionId?: string;
[key: string]: unknown;
}
interface AppsEventData extends PlatformEventData {
app_name?: string;
sessionId: string;
}
type PlatformEventHandler = (eventType: string, data: PlatformEventData) => Promise<void>;
async function handleAppsEvent(eventType: string, eventData: PlatformEventData): Promise<void> {
const { app_name, sessionId } = eventData as AppsEventData;
if (!sessionId) {
console.warn('No sessionId in apps platform event, skipping');
return;
}
const response = await listApps({
throwOnError: false,
query: { session_id: sessionId },
});
const apps = response.data?.apps || [];
const targetApp = apps.find((app: GooseApp) => app.name === app_name);
switch (eventType) {
case 'app_created':
if (targetApp) {
await window.electron.launchApp(targetApp).catch((err) => {
console.error('Failed to launch newly created app:', err);
});
}
break;
case 'app_updated':
if (targetApp) {
await window.electron.refreshApp(targetApp).catch((err) => {
console.error('Failed to refresh updated app:', err);
});
}
break;
case 'app_deleted':
if (app_name) {
await window.electron.closeApp(app_name).catch((err) => {
console.error('Failed to close deleted app:', err);
});
}
break;
default:
console.warn(`Unknown apps event type: ${eventType}`);
}
}
const EXTENSION_HANDLERS: Record<string, PlatformEventHandler> = {
apps: handleAppsEvent,
};
export function maybeHandlePlatformEvent(notification: unknown, sessionId: string): void {
if (notification && typeof notification === 'object' && 'method' in notification) {
const msg = notification as { method?: string; params?: unknown };
if (msg.method === 'platform_event' && msg.params) {
window.dispatchEvent(
new CustomEvent('platform-event', {
detail: { ...msg.params, sessionId },
})
);
}
}
}
export function registerPlatformEventHandlers(): () => void {
const handler = (event: Event) => {
const customEvent = event as CustomEvent;
const { extension, event_type, ...data } = customEvent.detail;
const extensionHandler = EXTENSION_HANDLERS[extension];
if (extensionHandler) {
extensionHandler(event_type, { ...data, extension }).catch((err) => {
console.error(`Platform event handler failed for ${extension}:`, err);
});
}
};
window.addEventListener('platform-event', handler);
return () => window.removeEventListener('platform-event', handler);
}