diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx
index b6c2475f02..12e8313bee 100644
--- a/ui/desktop/src/App.tsx
+++ b/ui/desktop/src/App.tsx
@@ -28,6 +28,7 @@ import ProviderSettings from './components/settings/providers/ProviderSettingsPa
import { AppLayout } from './components/Layout/AppLayout';
import { ChatProvider } from './contexts/ChatContext';
import { DraftProvider } from './contexts/DraftContext';
+import LauncherView from './components/LauncherView';
import 'react-toastify/dist/ReactToastify.css';
import { useConfig } from './components/ConfigContext';
@@ -89,8 +90,7 @@ const PairRouteWrapper = ({
const routeState =
(location.state as PairRouteState) || (window.history.state as PairRouteState) || {};
const [searchParams] = useSearchParams();
- const [initialMessage] = useState(routeState.initialMessage);
-
+ const initialMessage = routeState.initialMessage;
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
return (
@@ -485,6 +485,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 ;
}
@@ -510,6 +525,7 @@ export function AppInner() {
+ } />
setDidSelectProvider(true)} />}
diff --git a/ui/desktop/src/components/LauncherView.tsx b/ui/desktop/src/components/LauncherView.tsx
new file mode 100644
index 0000000000..77e91147c9
--- /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 (
+
+
+
+ );
+}
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index eaeb980e38..5e63788bab 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,
@@ -502,7 +503,7 @@ const windowPowerSaveBlockers = new Map(); // windowId -> blocke
const createChat = async (
app: App,
- _query?: string,
+ initialMessage?: string,
dir?: string,
_version?: string,
resumeSessionId?: string,
@@ -703,6 +704,13 @@ const createChat = async (
log.info('Opening URL: ', formattedUrl);
mainWindow.loadURL(formattedUrl);
+ // If we have an initial message, send it to the window once it's ready
+ if (initialMessage) {
+ mainWindow.webContents.once('did-finish-load', () => {
+ mainWindow.webContents.send('set-initial-message', 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) {
@@ -793,6 +801,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;
@@ -1659,28 +1726,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();
@@ -1736,8 +1781,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';
@@ -1990,6 +2040,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 067e149599..25928db1e5 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(