feat: simplify navigation, make reload work (#4498)

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