Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions ui/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initialMessage is now directly assigned from routeState.initialMessage instead of being stored in state via useState. This means changes to routeState.initialMessage after the initial render won't be reflected. If the route state can change during the component's lifecycle, this could lead to stale data. Consider whether this behavior is intentional or if the original useState was necessary to preserve the value.

Suggested change
const initialMessage = routeState.initialMessage;
const [initialMessage] = useState(() => routeState.initialMessage);

Copilot uses AI. Check for mistakes.
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;

// Determine which session ID to use:
Expand Down Expand Up @@ -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 <ErrorUI error={new Error(fatalError)} />;
}
Expand All @@ -589,6 +604,7 @@ export function AppInner() {
<div className="relative w-screen h-screen overflow-hidden bg-background-muted flex flex-col">
<div className="titlebar-drag-region" />
<Routes>
<Route path="launcher" element={<LauncherView />} />
<Route
path="welcome"
element={<WelcomeRoute onSelectProvider={() => setDidSelectProvider(true)} />}
Expand Down
11 changes: 9 additions & 2 deletions ui/desktop/src/components/BaseChat2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ interface BaseChatProps {
renderHeader?: () => React.ReactNode;
customChatInputProps?: Record<string, unknown>;
customMainLayoutProps?: Record<string, unknown>;
contentClassName?: string;
disableSearch?: boolean;
showPopularTopics?: boolean;
suppressEmptyState: boolean;
autoSubmit?: boolean;
sessionId: string;
initialMessage?: string;
}
Expand All @@ -44,6 +48,7 @@ function BaseChatContent({
customMainLayoutProps = {},
sessionId,
initialMessage,
autoSubmit = false,
}: BaseChatProps) {
const location = useLocation();
const scrollRef = useRef<ScrollAreaHandle>(null);
Expand Down Expand Up @@ -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 (
<div className="h-full flex flex-col min-h-0">
Expand Down Expand Up @@ -299,7 +306,7 @@ function BaseChatContent({
recipeAccepted={!hasNotAcceptedRecipe}
initialPrompt={initialPrompt}
toolCount={toolCount || 0}
autoSubmit={false}
autoSubmit={shouldAutoSubmit}
{...customChatInputProps}
/>
</div>
Expand Down
44 changes: 44 additions & 0 deletions ui/desktop/src/components/LauncherView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useRef, useState } from 'react';

export default function LauncherView() {
const [query, setQuery] = useState('');
const inputRef = useRef<HTMLInputElement>(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;
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workingDir is retrieved inside the handleSubmit function and cast to string, but window.appConfig?.get('GOOSE_WORKING_DIR') could return undefined or unknown. This could result in passing undefined to createChatWindow which might not handle it correctly. Consider adding a null check or providing a default value.

Suggested change
const workingDir = window.appConfig?.get('GOOSE_WORKING_DIR') as string;
const workingDir = (window.appConfig?.get('GOOSE_WORKING_DIR') ?? '') as string;

Copilot uses AI. Check for mistakes.
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 (
<div className="h-screen w-screen flex bg-transparent overflow-hidden">
<form
onSubmit={handleSubmit}
className="w-full h-full bg-background-default/95 backdrop-blur-lg shadow-2xl border border-border-default"
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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..."
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Corrected capitalization of 'goose' to 'Goose' for consistency with product branding.

Suggested change
placeholder="Ask goose anything..."
placeholder="Ask Goose anything..."

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexhancock, should we consider this suggestion?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no - we want goose as a product name to always be lowercase

autoFocus
/>
</form>
</div>
);
}
130 changes: 102 additions & 28 deletions ui/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
MenuItem,
Notification,
powerSaveBlocker,
screen,
session,
shell,
Tray,
Expand Down Expand Up @@ -483,11 +484,15 @@ let appConfig = {

const windowMap = new Map<number, BrowserWindow>();
const goosedClients = new Map<number, Client>();
const windowPowerSaveBlockers = new Map<number, number>();

// 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

const createChat = async (
app: App,
_query?: string,
initialMessage?: string,
dir?: string,
_version?: string,
resumeSessionId?: string,
Expand Down Expand Up @@ -676,7 +681,7 @@ const createChat = async (
}
if (
appPath === '/' &&
(recipe !== undefined || recipeDeeplink !== undefined || recipeId !== undefined)
(recipe !== undefined || recipeDeeplink !== undefined || recipeId !== undefined || initialMessage)
) {
appPath = '/pair';
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Comment on lines +800 to +802
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The launcher window is positioned at height / 3 vertically. This magic number (1/3) lacks explanation. Consider adding a comment explaining why this specific vertical position was chosen, or extract it as a named constant like LAUNCHER_VERTICAL_POSITION_RATIO = 1/3.

Suggested change
launcherWindow.setPosition(
Math.round(width / 2 - windowBounds.width / 2),
Math.round(height / 3 - windowBounds.height / 2)
// Position the launcher window at one-third of the screen height for optimal visibility and accessibility.
const LAUNCHER_VERTICAL_POSITION_RATIO = 1 / 3;
launcherWindow.setPosition(
Math.round(width / 2 - windowBounds.width / 2),
Math.round(height * LAUNCHER_VERTICAL_POSITION_RATIO - windowBounds.height / 2)

Copilot uses AI. Check for mistakes.
);

// 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;

Expand Down Expand Up @@ -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;
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code retrieves window from event.sender but doesn't check if it's null before accessing window.id on line 1071. Although the optional chaining on line 1071 prevents an error, if window is null, the subsequent code on lines 1074-1078 should not execute. Consider adding an early return: if (!window) return; after line 1070.

Suggested change
const windowId = window?.id;
if (!window) return;
const windowId = window.id;

Copilot uses AI. Check for mistakes.

// 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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
31 changes: 18 additions & 13 deletions ui/desktop/src/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<React.StrictMode>
Expand Down