feat(ui): bring back quick launcher (#5144)
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com> Co-authored-by: Alex Hancock <alexhancock@block.xyz>
This commit is contained in:
+18
-2
@@ -27,6 +27,7 @@ import SchedulesView from './components/schedule/SchedulesView';
|
|||||||
import ProviderSettings from './components/settings/providers/ProviderSettingsPage';
|
import ProviderSettings from './components/settings/providers/ProviderSettingsPage';
|
||||||
import { AppLayout } from './components/Layout/AppLayout';
|
import { AppLayout } from './components/Layout/AppLayout';
|
||||||
import { ChatProvider } from './contexts/ChatContext';
|
import { ChatProvider } from './contexts/ChatContext';
|
||||||
|
import LauncherView from './components/LauncherView';
|
||||||
|
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
import { useConfig } from './components/ConfigContext';
|
import { useConfig } from './components/ConfigContext';
|
||||||
@@ -93,8 +94,7 @@ const PairRouteWrapper = ({
|
|||||||
const routeState =
|
const routeState =
|
||||||
(location.state as PairRouteState) || (window.history.state as PairRouteState) || {};
|
(location.state as PairRouteState) || (window.history.state as PairRouteState) || {};
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [initialMessage] = useState(routeState.initialMessage);
|
const initialMessage = routeState.initialMessage;
|
||||||
|
|
||||||
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
|
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
|
||||||
|
|
||||||
// Determine which session ID to use:
|
// 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) {
|
if (fatalError) {
|
||||||
return <ErrorUI error={new Error(fatalError)} />;
|
return <ErrorUI error={new Error(fatalError)} />;
|
||||||
}
|
}
|
||||||
@@ -589,6 +604,7 @@ export function AppInner() {
|
|||||||
<div className="relative w-screen h-screen overflow-hidden bg-background-muted flex flex-col">
|
<div className="relative w-screen h-screen overflow-hidden bg-background-muted flex flex-col">
|
||||||
<div className="titlebar-drag-region" />
|
<div className="titlebar-drag-region" />
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="launcher" element={<LauncherView />} />
|
||||||
<Route
|
<Route
|
||||||
path="welcome"
|
path="welcome"
|
||||||
element={<WelcomeRoute onSelectProvider={() => setDidSelectProvider(true)} />}
|
element={<WelcomeRoute onSelectProvider={() => setDidSelectProvider(true)} />}
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ interface BaseChatProps {
|
|||||||
renderHeader?: () => React.ReactNode;
|
renderHeader?: () => React.ReactNode;
|
||||||
customChatInputProps?: Record<string, unknown>;
|
customChatInputProps?: Record<string, unknown>;
|
||||||
customMainLayoutProps?: Record<string, unknown>;
|
customMainLayoutProps?: Record<string, unknown>;
|
||||||
|
contentClassName?: string;
|
||||||
|
disableSearch?: boolean;
|
||||||
|
showPopularTopics?: boolean;
|
||||||
suppressEmptyState: boolean;
|
suppressEmptyState: boolean;
|
||||||
|
autoSubmit?: boolean;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
initialMessage?: string;
|
initialMessage?: string;
|
||||||
}
|
}
|
||||||
@@ -44,6 +48,7 @@ function BaseChatContent({
|
|||||||
customMainLayoutProps = {},
|
customMainLayoutProps = {},
|
||||||
sessionId,
|
sessionId,
|
||||||
initialMessage,
|
initialMessage,
|
||||||
|
autoSubmit = false,
|
||||||
}: BaseChatProps) {
|
}: BaseChatProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const scrollRef = useRef<ScrollAreaHandle>(null);
|
const scrollRef = useRef<ScrollAreaHandle>(null);
|
||||||
@@ -186,7 +191,9 @@ function BaseChatContent({
|
|||||||
name: session?.name || 'No Session',
|
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 (
|
return (
|
||||||
<div className="h-full flex flex-col min-h-0">
|
<div className="h-full flex flex-col min-h-0">
|
||||||
@@ -299,7 +306,7 @@ function BaseChatContent({
|
|||||||
recipeAccepted={!hasNotAcceptedRecipe}
|
recipeAccepted={!hasNotAcceptedRecipe}
|
||||||
initialPrompt={initialPrompt}
|
initialPrompt={initialPrompt}
|
||||||
toolCount={toolCount || 0}
|
toolCount={toolCount || 0}
|
||||||
autoSubmit={false}
|
autoSubmit={shouldAutoSubmit}
|
||||||
{...customChatInputProps}
|
{...customChatInputProps}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
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..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+102
-28
@@ -10,6 +10,7 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
Notification,
|
Notification,
|
||||||
powerSaveBlocker,
|
powerSaveBlocker,
|
||||||
|
screen,
|
||||||
session,
|
session,
|
||||||
shell,
|
shell,
|
||||||
Tray,
|
Tray,
|
||||||
@@ -483,11 +484,15 @@ let appConfig = {
|
|||||||
|
|
||||||
const windowMap = new Map<number, BrowserWindow>();
|
const windowMap = new Map<number, BrowserWindow>();
|
||||||
const goosedClients = new Map<number, Client>();
|
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 (
|
const createChat = async (
|
||||||
app: App,
|
app: App,
|
||||||
_query?: string,
|
initialMessage?: string,
|
||||||
dir?: string,
|
dir?: string,
|
||||||
_version?: string,
|
_version?: string,
|
||||||
resumeSessionId?: string,
|
resumeSessionId?: string,
|
||||||
@@ -676,7 +681,7 @@ const createChat = async (
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
appPath === '/' &&
|
appPath === '/' &&
|
||||||
(recipe !== undefined || recipeDeeplink !== undefined || recipeId !== undefined)
|
(recipe !== undefined || recipeDeeplink !== undefined || recipeId !== undefined || initialMessage)
|
||||||
) {
|
) {
|
||||||
appPath = '/pair';
|
appPath = '/pair';
|
||||||
}
|
}
|
||||||
@@ -695,6 +700,11 @@ const createChat = async (
|
|||||||
log.info('Opening URL: ', formattedUrl);
|
log.info('Opening URL: ', formattedUrl);
|
||||||
mainWindow.loadURL(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
|
// Set up local keyboard shortcuts that only work when the window is focused
|
||||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||||
if (input.key === 'r' && input.meta) {
|
if (input.key === 'r' && input.meta) {
|
||||||
@@ -731,6 +741,9 @@ const createChat = async (
|
|||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
windowMap.delete(windowId);
|
windowMap.delete(windowId);
|
||||||
|
|
||||||
|
// Clean up pending initial message
|
||||||
|
pendingInitialMessages.delete(windowId);
|
||||||
|
|
||||||
if (windowPowerSaveBlockers.has(windowId)) {
|
if (windowPowerSaveBlockers.has(windowId)) {
|
||||||
const blockerId = windowPowerSaveBlockers.get(windowId)!;
|
const blockerId = windowPowerSaveBlockers.get(windowId)!;
|
||||||
try {
|
try {
|
||||||
@@ -754,6 +767,65 @@ const createChat = async (
|
|||||||
return mainWindow;
|
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
|
// Track tray instance
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
|
|
||||||
@@ -991,9 +1063,21 @@ process.on('unhandledRejection', (error) => {
|
|||||||
handleFatalError(error instanceof Error ? error : new Error(String(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');
|
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) {
|
if (pendingDeepLink) {
|
||||||
log.info('Processing pending deep link:', pendingDeepLink);
|
log.info('Processing pending deep link:', pendingDeepLink);
|
||||||
handleProtocolUrl(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() {
|
async function appMain() {
|
||||||
// Ensure Windows shims are available before any MCP processes are spawned
|
// Ensure Windows shims are available before any MCP processes are spawned
|
||||||
await ensureWinShims();
|
await ensureWinShims();
|
||||||
@@ -1707,8 +1769,13 @@ async function appMain() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register the default global hotkey
|
try {
|
||||||
registerGlobalHotkey('CommandOrControl+Alt+Shift+G');
|
globalShortcut.register('CommandOrControl+Alt+Shift+G', () => {
|
||||||
|
createLauncher();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error registering launcher hotkey:', e);
|
||||||
|
}
|
||||||
|
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||||
details.requestHeaders['Origin'] = 'http://localhost:5173';
|
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) => {
|
ipcMain.on('notify', (_event, data) => {
|
||||||
try {
|
try {
|
||||||
// Validate notification data
|
// Validate notification data
|
||||||
|
|||||||
+18
-13
@@ -8,20 +8,25 @@ import { client } from './api/client.gen';
|
|||||||
const App = lazy(() => import('./App'));
|
const App = lazy(() => import('./App'));
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
console.log('window created, getting goosed connection info');
|
// Check if we're in the launcher view (doesn't need goosed connection)
|
||||||
const baseUrl = await window.electron.getGoosedHostPort();
|
const isLauncher = window.location.hash === '#/launcher';
|
||||||
if (baseUrl === null) {
|
|
||||||
window.alert('failed to start goose backend process');
|
if (!isLauncher) {
|
||||||
return;
|
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(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
Reference in New Issue
Block a user