diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index d74e1dd8dd85..a32c590f4438 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -27,6 +27,7 @@ import SchedulesView from './components/schedule/SchedulesView'; import ProviderSettings from './components/settings/providers/ProviderSettingsPage'; import { AppLayout } from './components/Layout/AppLayout'; import { ChatProvider } from './contexts/ChatContext'; +import LauncherView from './components/LauncherView'; import 'react-toastify/dist/ReactToastify.css'; import { useConfig } from './components/ConfigContext'; @@ -93,8 +94,7 @@ const PairRouteWrapper = ({ const routeState = (location.state as PairRouteState) || (window.history.state as PairRouteState) || {}; const [searchParams, setSearchParams] = useSearchParams(); - const [initialMessage] = useState(routeState.initialMessage); - + const initialMessage = routeState.initialMessage; const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined; // Determine which session ID to use: @@ -564,6 +564,21 @@ export function AppInner() { }; }, []); + // Handle initial message from launcher + useEffect(() => { + const handleSetInitialMessage = (_event: IpcRendererEvent, ...args: unknown[]) => { + const initialMessage = args[0] as string; + if (initialMessage) { + console.log('Received initial message from launcher:', initialMessage); + navigate('/pair', { state: { initialMessage } }); + } + }; + window.electron.on('set-initial-message', handleSetInitialMessage); + return () => { + window.electron.off('set-initial-message', handleSetInitialMessage); + }; + }, [navigate]); + if (fatalError) { return ; } @@ -589,6 +604,7 @@ export function AppInner() {
+ } /> setDidSelectProvider(true)} />} diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 8dfb8f5c2356..4653eea6c0e8 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -32,7 +32,11 @@ interface BaseChatProps { renderHeader?: () => React.ReactNode; customChatInputProps?: Record; customMainLayoutProps?: Record; + contentClassName?: string; + disableSearch?: boolean; + showPopularTopics?: boolean; suppressEmptyState: boolean; + autoSubmit?: boolean; sessionId: string; initialMessage?: string; } @@ -44,6 +48,7 @@ function BaseChatContent({ customMainLayoutProps = {}, sessionId, initialMessage, + autoSubmit = false, }: BaseChatProps) { const location = useLocation(); const scrollRef = useRef(null); @@ -186,7 +191,9 @@ function BaseChatContent({ name: session?.name || 'No Session', }; - const initialPrompt = messages.length == 0 && recipe?.prompt ? recipe.prompt : ''; + const initialPrompt = + initialMessage || (messages.length == 0 && recipe?.prompt ? recipe.prompt : ''); + const shouldAutoSubmit = autoSubmit || !!initialMessage; return (
@@ -299,7 +306,7 @@ function BaseChatContent({ recipeAccepted={!hasNotAcceptedRecipe} initialPrompt={initialPrompt} toolCount={toolCount || 0} - autoSubmit={false} + autoSubmit={shouldAutoSubmit} {...customChatInputProps} />
diff --git a/ui/desktop/src/components/LauncherView.tsx b/ui/desktop/src/components/LauncherView.tsx new file mode 100644 index 000000000000..60b0ed3f7ebd --- /dev/null +++ b/ui/desktop/src/components/LauncherView.tsx @@ -0,0 +1,44 @@ +import { useRef, useState } from 'react'; + +export default function LauncherView() { + const [query, setQuery] = useState(''); + const inputRef = useRef(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (query.trim()) { + // Create a new chat window with the query + const workingDir = window.appConfig?.get('GOOSE_WORKING_DIR') as string; + window.electron.createChatWindow(query, workingDir); + setQuery(''); + // Don't manually close - the blur handler will close the launcher when the new window takes focus + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Close on Escape + if (e.key === 'Escape') { + window.electron.closeWindow(); + } + }; + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="w-full h-full bg-transparent text-text-default text-xl px-6 outline-none placeholder-text-muted" + placeholder="Ask goose anything..." + autoFocus + /> +
+
+ ); +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 6fd76def0959..4447afd500aa 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -10,6 +10,7 @@ import { MenuItem, Notification, powerSaveBlocker, + screen, session, shell, Tray, @@ -483,11 +484,15 @@ let appConfig = { const windowMap = new Map(); const goosedClients = new Map(); -const windowPowerSaveBlockers = new Map(); + +// Track power save blockers per window +const windowPowerSaveBlockers = new Map(); // windowId -> blockerId +// Track pending initial messages per window +const pendingInitialMessages = new Map(); // windowId -> initialMessage const createChat = async ( app: App, - _query?: string, + initialMessage?: string, dir?: string, _version?: string, resumeSessionId?: string, @@ -676,7 +681,7 @@ const createChat = async ( } if ( appPath === '/' && - (recipe !== undefined || recipeDeeplink !== undefined || recipeId !== undefined) + (recipe !== undefined || recipeDeeplink !== undefined || recipeId !== undefined || initialMessage) ) { appPath = '/pair'; } @@ -695,6 +700,11 @@ const createChat = async ( log.info('Opening URL: ', formattedUrl); mainWindow.loadURL(formattedUrl); + // If we have an initial message, store it to send after React is ready + if (initialMessage) { + pendingInitialMessages.set(mainWindow.id, initialMessage); + } + // Set up local keyboard shortcuts that only work when the window is focused mainWindow.webContents.on('before-input-event', (event, input) => { if (input.key === 'r' && input.meta) { @@ -731,6 +741,9 @@ const createChat = async ( mainWindow.on('closed', () => { windowMap.delete(windowId); + // Clean up pending initial message + pendingInitialMessages.delete(windowId); + if (windowPowerSaveBlockers.has(windowId)) { const blockerId = windowPowerSaveBlockers.get(windowId)!; try { @@ -754,6 +767,65 @@ const createChat = async ( return mainWindow; }; +const createLauncher = () => { + const launcherWindow = new BrowserWindow({ + width: 600, + height: 80, + frame: false, + transparent: process.platform === 'darwin', + backgroundColor: process.platform === 'darwin' ? '#00000000' : '#ffffff', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + nodeIntegration: false, + contextIsolation: true, + additionalArguments: [JSON.stringify(appConfig)], + partition: 'persist:goose', + }, + skipTaskbar: true, + alwaysOnTop: true, + resizable: false, + movable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + hasShadow: true, + vibrancy: process.platform === 'darwin' ? 'window' : undefined, + }); + + // Center on screen + const primaryDisplay = screen.getPrimaryDisplay(); + const { width, height } = primaryDisplay.workAreaSize; + const windowBounds = launcherWindow.getBounds(); + + launcherWindow.setPosition( + Math.round(width / 2 - windowBounds.width / 2), + Math.round(height / 3 - windowBounds.height / 2) + ); + + // Load launcher window content + const url = MAIN_WINDOW_VITE_DEV_SERVER_URL + ? new URL(MAIN_WINDOW_VITE_DEV_SERVER_URL) + : pathToFileURL(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)); + + url.hash = '/launcher'; + launcherWindow.loadURL(formatUrl(url)); + + // Destroy window when it loses focus + launcherWindow.on('blur', () => { + launcherWindow.destroy(); + }); + + // Also destroy on escape key + launcherWindow.webContents.on('before-input-event', (event, input) => { + if (input.key === 'Escape') { + launcherWindow.destroy(); + event.preventDefault(); + } + }); + + return launcherWindow; +}; + // Track tray instance let tray: Tray | null = null; @@ -991,9 +1063,21 @@ process.on('unhandledRejection', (error) => { handleFatalError(error instanceof Error ? error : new Error(String(error))); }); -ipcMain.on('react-ready', () => { +ipcMain.on('react-ready', (event) => { log.info('React ready event received'); + // Get the window that sent the react-ready event + const window = BrowserWindow.fromWebContents(event.sender); + const windowId = window?.id; + + // Send any pending initial message for this window + if (windowId && pendingInitialMessages.has(windowId)) { + const initialMessage = pendingInitialMessages.get(windowId)!; + log.info('Sending pending initial message to window:', initialMessage); + window.webContents.send('set-initial-message', initialMessage); + pendingInitialMessages.delete(windowId); + } + if (pendingDeepLink) { log.info('Processing pending deep link:', pendingDeepLink); handleProtocolUrl(pendingDeepLink); @@ -1630,28 +1714,6 @@ const focusWindow = () => { } }; -const registerGlobalHotkey = (accelerator: string) => { - // Unregister any existing shortcuts first - globalShortcut.unregisterAll(); - - try { - globalShortcut.register(accelerator, () => { - focusWindow(); - }); - - // Check if the shortcut was registered successfully - if (globalShortcut.isRegistered(accelerator)) { - return true; - } else { - console.error('Failed to register global hotkey'); - return false; - } - } catch (e) { - console.error('Error registering global hotkey:', e); - return false; - } -}; - async function appMain() { // Ensure Windows shims are available before any MCP processes are spawned await ensureWinShims(); @@ -1707,8 +1769,13 @@ async function appMain() { }); }); - // Register the default global hotkey - registerGlobalHotkey('CommandOrControl+Alt+Shift+G'); + try { + globalShortcut.register('CommandOrControl+Alt+Shift+G', () => { + createLauncher(); + }); + } catch (e) { + console.error('Error registering launcher hotkey:', e); + } session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { details.requestHeaders['Origin'] = 'http://localhost:5173'; @@ -2004,6 +2071,13 @@ async function appMain() { } ); + ipcMain.on('close-window', (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window && !window.isDestroyed()) { + window.close(); + } + }); + ipcMain.on('notify', (_event, data) => { try { // Validate notification data diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx index 067e14959938..25928db1e502 100644 --- a/ui/desktop/src/renderer.tsx +++ b/ui/desktop/src/renderer.tsx @@ -8,20 +8,25 @@ import { client } from './api/client.gen'; const App = lazy(() => import('./App')); (async () => { - console.log('window created, getting goosed connection info'); - const baseUrl = await window.electron.getGoosedHostPort(); - if (baseUrl === null) { - window.alert('failed to start goose backend process'); - return; + // Check if we're in the launcher view (doesn't need goosed connection) + const isLauncher = window.location.hash === '#/launcher'; + + if (!isLauncher) { + console.log('window created, getting goosed connection info'); + const baseUrl = await window.electron.getGoosedHostPort(); + if (baseUrl === null) { + window.alert('failed to start goose backend process'); + return; + } + console.log('connecting at', baseUrl); + client.setConfig({ + baseUrl, + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': await window.electron.getSecretKey(), + }, + }); } - console.log('connecting at', baseUrl); - client.setConfig({ - baseUrl, - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': await window.electron.getSecretKey(), - }, - }); ReactDOM.createRoot(document.getElementById('root')!).render(