diff --git a/ui/desktop/package.json b/ui/desktop/package.json
index 7472fd5ac598..b0b5a4af7cbf 100644
--- a/ui/desktop/package.json
+++ b/ui/desktop/package.json
@@ -39,7 +39,6 @@
"start-alpha-gui": "ALPHA=true npm run start-gui"
},
"dependencies": {
- "@tanstack/react-form": "^0.13.0",
"@ai-sdk/openai": "^2.0.14",
"@ai-sdk/ui-utils": "^1.2.11",
"@mcp-ui/client": "^5.9.0",
diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx
index 2459ea715efd..2da20f9b4431 100644
--- a/ui/desktop/src/App.test.tsx
+++ b/ui/desktop/src/App.test.tsx
@@ -4,9 +4,9 @@
* @vitest-environment jsdom
*/
import React from 'react';
-import { render, waitFor } from '@testing-library/react';
+import { screen, render, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
-import App from './App';
+import { AppInner } from './App';
// Set up globals for jsdom
Object.defineProperty(window, 'location', {
@@ -161,13 +161,19 @@ vi.mock('./components/AnnouncementModal', () => ({
default: () => null,
}));
+// Create mocks that we can track and configure per test
+const mockNavigate = vi.fn();
+const mockSearchParams = new URLSearchParams();
+const mockSetSearchParams = vi.fn();
+
// Mock react-router-dom to avoid HashRouter issues in tests
vi.mock('react-router-dom', () => ({
HashRouter: ({ children }: { children: React.ReactNode }) => <>{children}>,
Routes: ({ children }: { children: React.ReactNode }) => <>{children}>,
Route: ({ element }: { element: React.ReactNode }) => element,
- useNavigate: () => vi.fn(),
+ useNavigate: () => mockNavigate,
useLocation: () => ({ state: null, pathname: '/' }),
+ useSearchParams: () => [mockSearchParams, mockSetSearchParams],
Outlet: () => null,
}));
@@ -216,6 +222,14 @@ Object.defineProperty(window, 'matchMedia', {
describe('App Component - Brand New State', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockNavigate.mockClear();
+ mockSetSearchParams.mockClear();
+
+ // Reset search params
+ mockSearchParams.forEach((_, key) => {
+ mockSearchParams.delete(key);
+ });
+
window.location.hash = '';
window.location.search = '';
window.location.pathname = '/';
@@ -235,21 +249,16 @@ describe('App Component - Brand New State', () => {
GOOSE_ALLOWLIST_WARNING: false,
});
- render();
+ render();
// Wait for initialization
await waitFor(() => {
expect(mockElectron.reactReady).toHaveBeenCalled();
});
- // Check that we navigated to "/" not "/welcome"
- await waitFor(() => {
- // In some environments, the hash might be empty or just "#"
- expect(window.location.hash).toMatch(/^(#\/?|)$/);
- });
-
- // History should have been updated to "/"
- expect(window.history.replaceState).toHaveBeenCalledWith({}, '', '#/');
+ // The app should initialize without any navigation calls since we're already at "/"
+ // No navigate calls should be made when no provider is configured
+ expect(mockNavigate).not.toHaveBeenCalled();
});
it('should handle deep links correctly when app is brand new', async () => {
@@ -260,20 +269,17 @@ describe('App Component - Brand New State', () => {
GOOSE_ALLOWLIST_WARNING: false,
});
- // Simulate a deep link
- window.location.search = '?view=settings';
+ // Set up search params to simulate view=settings deep link
+ mockSearchParams.set('view', 'settings');
- render();
+ render();
// Wait for initialization
await waitFor(() => {
expect(mockElectron.reactReady).toHaveBeenCalled();
});
- // Should redirect to settings route via hash
- await waitFor(() => {
- expect(window.location.hash).toBe('#/settings');
- });
+ expect(screen.getByText(/^Select an AI model provider/)).toBeInTheDocument();
});
it('should not redirect to /welcome when provider is configured', async () => {
@@ -284,18 +290,15 @@ describe('App Component - Brand New State', () => {
GOOSE_ALLOWLIST_WARNING: false,
});
- render();
+ render();
// Wait for initialization
await waitFor(() => {
expect(mockElectron.reactReady).toHaveBeenCalled();
});
- // Should stay at "/" since provider is configured
- await waitFor(() => {
- // In some environments, the hash might be empty or just "#"
- expect(window.location.hash).toMatch(/^(#\/?|)$/);
- });
+ // Should not navigate anywhere since provider is configured and we're already at "/"
+ expect(mockNavigate).not.toHaveBeenCalled();
});
it('should handle config recovery gracefully', async () => {
@@ -310,17 +313,14 @@ describe('App Component - Brand New State', () => {
GOOSE_ALLOWLIST_WARNING: false,
});
- render();
+ render();
// Wait for initialization and recovery
await waitFor(() => {
expect(mockElectron.reactReady).toHaveBeenCalled();
});
- // App should still initialize and navigate to "/"
- await waitFor(() => {
- // In some environments, the hash might be empty or just "#"
- expect(window.location.hash).toMatch(/^(#\/?|)$/);
- });
+ // App should still initialize without any navigation calls
+ expect(mockNavigate).not.toHaveBeenCalled();
});
});
diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx
index 7b1cf2dbc934..17a6f9bde9dd 100644
--- a/ui/desktop/src/App.tsx
+++ b/ui/desktop/src/App.tsx
@@ -1,6 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { IpcRendererEvent } from 'electron';
-import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
+import {
+ HashRouter,
+ Routes,
+ Route,
+ useNavigate,
+ useLocation,
+ useSearchParams,
+} from 'react-router-dom';
import { openSharedSessionFromDeepLink } from './sessionLinks';
import { type SharedSessionDetails } from './sharedSessions';
import { ErrorUI } from './components/ErrorBoundary';
@@ -29,7 +36,6 @@ import { ModelAndProviderProvider } from './components/ModelAndProviderContext';
import PermissionSettingsView from './components/settings/permission/PermissionSetting';
import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView';
-import { Recipe } from './recipe';
import RecipesView from './components/recipes/RecipesView';
import RecipeEditor from './components/recipes/RecipeEditor';
import { createNavigationHandler, View, ViewOptions } from './utils/navigationUtils';
@@ -85,9 +91,11 @@ const PairRouteWrapper = ({
const setView = useMemo(() => createNavigationHandler(navigate), [navigate]);
const routeState =
(location.state as PairRouteState) || (window.history.state as PairRouteState) || {};
- const [resumeSessionId] = useState(routeState.resumeSessionId);
+ const [searchParams] = useSearchParams();
const [initialMessage] = useState(routeState.initialMessage);
+ const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
+
return (
{
};
const RecipeEditorRoute = () => {
- const location = useLocation();
-
// Check for config from multiple sources:
- // 1. Location state (from navigation)
- // 2. localStorage (from "View Recipe" button)
- // 3. Window electron config (from deeplinks)
- let config = location.state?.config;
-
- if (!config) {
- const storedConfig = localStorage.getItem('viewRecipeConfig');
- if (storedConfig) {
- try {
- config = JSON.parse(storedConfig);
- // Clear the stored config after using it
- localStorage.removeItem('viewRecipeConfig');
- } catch (error) {
- console.error('Failed to parse stored recipe config:', error);
- }
+ // 1. localStorage (from "View Recipe" button)
+ // 2. Window electron config (from deeplinks)
+ let config;
+ const storedConfig = localStorage.getItem('viewRecipeConfig');
+ if (storedConfig) {
+ try {
+ config = JSON.parse(storedConfig);
+ // Clear the stored config after using it
+ localStorage.removeItem('viewRecipeConfig');
+ } catch (error) {
+ console.error('Failed to parse stored recipe config:', error);
}
}
@@ -304,37 +306,21 @@ const ExtensionsRoute = () => {
);
};
-export default function App() {
+export function AppInner() {
const [fatalError, setFatalError] = useState(null);
const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false);
const [agentWaitingMessage, setAgentWaitingMessage] = useState(null);
const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false);
const [sharedSessionError, setSharedSessionError] = useState(null);
const [isExtensionsLoading, setIsExtensionsLoading] = useState(false);
-
- const [didSyncUrlParams, setDidSyncUrlParams] = useState(false);
-
- const [viewType, setViewType] = useState(null);
- const [resumeSessionId, setResumeSessionId] = useState(null);
-
const [didSelectProvider, setDidSelectProvider] = useState(false);
- const [recipeFromAppConfig, setRecipeFromAppConfig] = useState(
- (window.appConfig?.get('recipe') as Recipe) || null
- );
-
- useEffect(() => {
- const urlParams = new URLSearchParams(window.location.search);
-
- const viewType = urlParams.get('view') || null;
- const resumeSessionId = urlParams.get('resumeSessionId') || null;
+ const navigate = useNavigate();
- setViewType(viewType);
- setResumeSessionId(resumeSessionId);
- setDidSyncUrlParams(true);
- }, []);
+ const location = useLocation();
+ const [_searchParams, setSearchParams] = useSearchParams();
- const [chat, _setChat] = useState({
+ const [chat, setChat] = useState({
sessionId: generateSessionId(),
title: 'Pair Chat',
messages: [],
@@ -342,22 +328,17 @@ export default function App() {
recipeConfig: null,
});
- const setChat = useCallback(
- (update) => {
- _setChat(update);
- },
- [_setChat]
- );
-
const { addExtension } = useConfig();
const { agentState, loadCurrentChat, resetChat } = useAgent();
const resetChatIfNecessary = useCallback(() => {
if (chat.messages.length > 0) {
- setResumeSessionId(null);
- setRecipeFromAppConfig(null);
+ setSearchParams((prev) => {
+ prev.delete('resumeSessionId');
+ return prev;
+ });
resetChat();
}
- }, [resetChat, chat.messages.length]);
+ }, [chat.messages.length, setSearchParams, resetChat]);
useEffect(() => {
console.log('Sending reactReady signal to Electron');
@@ -372,77 +353,25 @@ export default function App() {
}, []);
// Handle URL parameters and deeplinks on app startup
+ const loadingHub = location.pathname === '/';
useEffect(() => {
- if (!didSyncUrlParams) {
- return;
- }
-
- const stateData: PairRouteState = {
- resumeSessionId: resumeSessionId || undefined,
- };
- (async () => {
- try {
- await loadCurrentChat({
- setAgentWaitingMessage,
- setIsExtensionsLoading,
- recipeConfig: recipeFromAppConfig || undefined,
- ...stateData,
- });
- } catch (e) {
- if (e instanceof NoProviderOrModelError) {
- // the onboarding flow will trigger
- } else {
- throw e;
- }
- }
- })();
-
- if (resumeSessionId || recipeFromAppConfig) {
- window.location.hash = '#/pair';
- window.history.replaceState(stateData, '', '#/pair');
- return;
- }
-
- if (!viewType) {
- if (window.location.hash === '' || window.location.hash === '#') {
- window.location.hash = '#/';
- window.history.replaceState({}, '', '#/');
- }
- } else {
- if (viewType === 'recipeEditor' && recipeFromAppConfig) {
- window.location.hash = '#/recipe-editor';
- window.history.replaceState({ config: recipeFromAppConfig }, '', '#/recipe-editor');
- } else {
- const routeMap: Record = {
- chat: '#/',
- pair: '#/pair',
- settings: '#/settings',
- sessions: '#/sessions',
- schedules: '#/schedules',
- recipes: '#/recipes',
- permission: '#/permission',
- ConfigureProviders: '#/configure-providers',
- sharedSession: '#/shared-session',
- recipeEditor: '#/recipe-editor',
- welcome: '#/welcome',
- };
-
- const route = routeMap[viewType];
- if (route) {
- window.location.hash = route;
- window.history.replaceState({}, '', route);
+ if (loadingHub) {
+ (async () => {
+ try {
+ await loadCurrentChat({
+ setAgentWaitingMessage,
+ setIsExtensionsLoading,
+ });
+ } catch (e) {
+ if (e instanceof NoProviderOrModelError) {
+ // the onboarding flow will trigger
+ } else {
+ throw e;
+ }
}
- }
+ })();
}
- }, [
- recipeFromAppConfig,
- resetChat,
- loadCurrentChat,
- setAgentWaitingMessage,
- didSyncUrlParams,
- resumeSessionId,
- viewType,
- ]);
+ }, [resetChat, loadCurrentChat, setAgentWaitingMessage, navigate, loadingHub]);
useEffect(() => {
const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => {
@@ -451,24 +380,19 @@ export default function App() {
setIsLoadingSharedSession(true);
setSharedSessionError(null);
try {
- await openSharedSessionFromDeepLink(link, (_view: View, _options?: ViewOptions) => {
- // Navigate to shared session view with the session data
- window.location.hash = '#/shared-session';
- if (_options) {
- window.history.replaceState(_options, '', '#/shared-session');
- }
+ await openSharedSessionFromDeepLink(link, (_view: View, options?: ViewOptions) => {
+ navigate('/shared-session', { state: options });
});
} catch (error) {
console.error('Unexpected error opening shared session:', error);
// Navigate to shared session view with error
- window.location.hash = '#/shared-session';
const shareToken = link.replace('goose://sessions/', '');
const options = {
sessionDetails: null,
error: error instanceof Error ? error.message : 'Unknown error',
shareToken,
};
- window.history.replaceState(options, '', '#/shared-session');
+ navigate('/shared-session', { state: options });
} finally {
setIsLoadingSharedSession(false);
}
@@ -477,7 +401,7 @@ export default function App() {
return () => {
window.electron.off('open-shared-session', handleOpenSharedSession);
};
- }, []);
+ }, [navigate]);
useEffect(() => {
console.log('Setting up keyboard shortcuts');
@@ -566,32 +490,15 @@ export default function App() {
);
if (section && newView === 'settings') {
- window.location.hash = `#/settings?section=${section}`;
+ navigate(`/settings?section=${section}`);
} else {
- window.location.hash = `#/${newView}`;
+ navigate(`/${newView}`);
}
};
- const urlParams = new URLSearchParams(window.location.search);
- const viewFromUrl = urlParams.get('view');
- if (viewFromUrl) {
- const windowConfig = window.electron.getConfig();
- if (viewFromUrl === 'recipeEditor') {
- const initialViewOptions = {
- recipeConfig: JSON.stringify(windowConfig?.recipeConfig),
- view: viewFromUrl,
- };
- window.history.replaceState(
- {},
- '',
- `/recipe-editor?${new URLSearchParams(initialViewOptions).toString()}`
- );
- } else {
- window.history.replaceState({}, '', `/${viewFromUrl}`);
- }
- }
+
window.electron.on('set-view', handleSetView);
return () => window.electron.off('set-view', handleSetView);
- }, []);
+ }, [navigate]);
useEffect(() => {
const handleFocusInput = (_event: IpcRendererEvent, ..._args: unknown[]) => {
@@ -611,98 +518,106 @@ export default function App() {
}
return (
-
-
-
-
- `relative min-h-16 mb-4 p-2 rounded-lg
+ <>
+
+ `relative min-h-16 mb-4 p-2 rounded-lg
flex justify-between overflow-hidden cursor-pointer
text-text-on-accent bg-background-inverse
`
- }
- style={{ width: '380px' }}
- className="mt-6"
- position="top-right"
- autoClose={3000}
- closeOnClick
- pauseOnHover
+ }
+ style={{ width: '380px' }}
+ className="mt-6"
+ position="top-right"
+ autoClose={3000}
+ closeOnClick
+ pauseOnHover
+ />
+
+
+
+
+ setDidSelectProvider(true)} />}
/>
-
-
-
-
- setDidSelectProvider(true)} />}
- />
- } />
-
-
-
-
-
- }
- >
-
- }
+ } />
+
+
+
+
+
+ }
+ >
+
-
- }
+ }
+ />
+
- } />
- } />
- } />
- } />
- } />
- } />
-
- }
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
- } />
-
-
-
- {isGoosehintsModalOpen && (
-
- )}
+ } />
+
+
+
+ {isGoosehintsModalOpen && (
+
+ )}
+ >
+ );
+}
+
+export default function App() {
+ return (
+
+
+
+
diff --git a/ui/desktop/src/components/pair.tsx b/ui/desktop/src/components/pair.tsx
index 6a60fec1a732..d95486fe7cb0 100644
--- a/ui/desktop/src/components/pair.tsx
+++ b/ui/desktop/src/components/pair.tsx
@@ -9,6 +9,7 @@ import 'react-toastify/dist/ReactToastify.css';
import { cn } from '../utils';
import { ChatType } from '../types/chat';
+import { useSearchParams } from 'react-router-dom';
export interface PairRouteState {
resumeSessionId?: string;
@@ -45,16 +46,21 @@ export default function Pair({
const [messageToSubmit, setMessageToSubmit] = useState(null);
const [isTransitioningFromHub, setIsTransitioningFromHub] = useState(false);
const [loadingChat, setLoadingChat] = useState(false);
+ const [_searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
const initializeFromState = async () => {
setLoadingChat(true);
try {
const chat = await loadCurrentChat({
- resumeSessionId: resumeSessionId,
+ resumeSessionId,
setAgentWaitingMessage,
});
setChat(chat);
+ setSearchParams((prev) => {
+ prev.set('resumeSessionId', chat.sessionId);
+ return prev;
+ });
} catch (error) {
console.log(error);
setFatalError(`Agent init failure: ${error instanceof Error ? error.message : '' + error}`);
@@ -70,6 +76,7 @@ export default function Pair({
setAgentWaitingMessage,
loadCurrentChat,
resumeSessionId,
+ setSearchParams,
]);
// Followed by sending the initialMessage if we have one. This will happen
diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts
index 584f62951936..93ab150a7dde 100644
--- a/ui/desktop/src/hooks/useAgent.ts
+++ b/ui/desktop/src/hooks/useAgent.ts
@@ -49,12 +49,16 @@ export function useAgent(): UseAgentReturn {
const [agentState, setAgentState] = useState(AgentState.UNINITIALIZED);
const [sessionId, setSessionId] = useState(null);
const initPromiseRef = useRef | null>(null);
+ const [recipeFromAppConfig, setRecipeFromAppConfig] = useState(
+ (window.appConfig.get('recipe') as Recipe) || null
+ );
const { getExtensions, addExtension, read } = useConfig();
const resetChat = useCallback(() => {
setSessionId(null);
setAgentState(AgentState.UNINITIALIZED);
+ setRecipeFromAppConfig(null);
}, []);
const agentIsInitialized = agentState === AgentState.INITIALIZED;
@@ -112,7 +116,7 @@ export function useAgent(): UseAgentReturn {
: await startAgent({
body: {
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
- recipe: initContext.recipeConfig,
+ recipe: recipeFromAppConfig ?? initContext.recipeConfig,
},
throwOnError: true,
});
@@ -179,7 +183,7 @@ export function useAgent(): UseAgentReturn {
initPromiseRef.current = initPromise;
return initPromise;
},
- [getExtensions, addExtension, read, agentIsInitialized, sessionId]
+ [agentIsInitialized, sessionId, read, recipeFromAppConfig, getExtensions, addExtension]
);
return {
diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts
index cea942b13918..42e04994f6e3 100644
--- a/ui/desktop/src/hooks/useRecipeManager.ts
+++ b/ui/desktop/src/hooks/useRecipeManager.ts
@@ -29,7 +29,7 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
const finalRecipeConfig = chat.recipeConfig;
useEffect(() => {
- if (!chatContext?.setRecipeConfig) return;
+ if (!chatContext) return;
// If we have a recipe from navigation state, persist it
if (recipeConfig && !chatContext.chat.recipeConfig) {
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index 8dfcdba7e67e..2a81088dc24e 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -14,6 +14,7 @@ import {
shell,
Tray,
} from 'electron';
+import { pathToFileURL, format as formatUrl, URLSearchParams } from 'node:url';
import { Buffer } from 'node:buffer';
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
@@ -515,7 +516,7 @@ const windowPowerSaveBlockers = new Map(); // windowId -> blocke
const createChat = async (
app: App,
- query?: string,
+ _query?: string,
dir?: string,
_version?: string,
resumeSessionId?: string,
@@ -679,44 +680,47 @@ const createChat = async (
shell.openExternal(url);
});
- // Load the index.html of the app.
- let queryParams = '';
- if (query) {
- queryParams = `?initialQuery=${encodeURIComponent(query)}`;
- }
-
- // Add resumeSessionId to query params if provided
- if (resumeSessionId) {
- queryParams = queryParams
- ? `${queryParams}&resumeSessionId=${encodeURIComponent(resumeSessionId)}`
- : `?resumeSessionId=${encodeURIComponent(resumeSessionId)}`;
- }
+ const windowId = ++windowCounter;
+ 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`));
+
+ let appPath = '/';
+ const routeMap: Record = {
+ chat: '/',
+ pair: '/pair',
+ settings: '/settings',
+ sessions: '/sessions',
+ schedules: '/schedules',
+ recipes: '/recipes',
+ permission: '/permission',
+ ConfigureProviders: '/configure-providers',
+ sharedSession: '/shared-session',
+ recipeEditor: '/recipe-editor',
+ welcome: '/welcome',
+ };
- // Add view type to query params if provided
if (viewType) {
- queryParams = queryParams
- ? `${queryParams}&view=${encodeURIComponent(viewType)}`
- : `?view=${encodeURIComponent(viewType)}`;
+ appPath = routeMap[viewType] || '/';
}
-
- // For recipe deeplinks, navigate directly to pair view
- if (recipe || recipeDeeplink) {
- queryParams = queryParams ? `${queryParams}&view=pair` : `?view=pair`;
+ if (appPath === '/' && (recipe !== undefined || recipeDeeplink !== undefined)) {
+ appPath = '/pair';
}
- // Increment window counter to track number of windows
- const windowId = ++windowCounter;
-
- if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
- mainWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}${queryParams}`);
- } else {
- // In production, we need to use a proper file protocol URL with correct base path
- const indexPath = path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`);
- mainWindow.loadFile(indexPath, {
- search: queryParams ? queryParams.slice(1) : undefined,
- });
+ let searchParams = new URLSearchParams();
+ if (resumeSessionId) {
+ searchParams.set('resumeSessionId', resumeSessionId);
+ if (appPath === '/') {
+ appPath = '/pair';
+ }
}
+ // Goose's react app uses HashRouter, so the path + search params follow a #/
+ url.hash = `${appPath}?${searchParams.toString()}`;
+ let formattedUrl = formatUrl(url);
+ console.log('Opening URL: ', formattedUrl);
+ mainWindow.loadURL(formattedUrl);
+
// 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) {