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) {