mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: simplify navigation, make reload work (#4498)
This commit is contained in:
@@ -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",
|
||||
|
||||
+31
-31
@@ -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(<App />);
|
||||
render(<AppInner />);
|
||||
|
||||
// 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(<App />);
|
||||
render(<AppInner />);
|
||||
|
||||
// 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(<App />);
|
||||
render(<AppInner />);
|
||||
|
||||
// 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(<App />);
|
||||
render(<AppInner />);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
+150
-235
@@ -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 (
|
||||
<Pair
|
||||
chat={chat}
|
||||
@@ -132,24 +140,18 @@ const RecipesRoute = () => {
|
||||
};
|
||||
|
||||
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<string | null>(null);
|
||||
const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false);
|
||||
const [agentWaitingMessage, setAgentWaitingMessage] = useState<string | null>(null);
|
||||
const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false);
|
||||
const [sharedSessionError, setSharedSessionError] = useState<string | null>(null);
|
||||
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 [recipeFromAppConfig, setRecipeFromAppConfig] = useState<Recipe | null>(
|
||||
(window.appConfig?.get('recipe') as Recipe) || null
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const location = useLocation();
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const viewType = urlParams.get('view') || null;
|
||||
const resumeSessionId = urlParams.get('resumeSessionId') || null;
|
||||
|
||||
setViewType(viewType);
|
||||
setResumeSessionId(resumeSessionId);
|
||||
setDidSyncUrlParams(true);
|
||||
}, []);
|
||||
|
||||
const [chat, _setChat] = useState<ChatType>({
|
||||
const [chat, setChat] = useState<ChatType>({
|
||||
sessionId: generateSessionId(),
|
||||
title: 'Pair Chat',
|
||||
messages: [],
|
||||
@@ -342,22 +328,17 @@ export default function App() {
|
||||
recipeConfig: null,
|
||||
});
|
||||
|
||||
const setChat = useCallback<typeof _setChat>(
|
||||
(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 (loadingHub) {
|
||||
(async () => {
|
||||
try {
|
||||
await loadCurrentChat({
|
||||
setAgentWaitingMessage,
|
||||
setIsExtensionsLoading,
|
||||
});
|
||||
} 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<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,
|
||||
]);
|
||||
}, [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 (
|
||||
<DraftProvider>
|
||||
<ModelAndProviderProvider>
|
||||
<HashRouter>
|
||||
<ToastContainer
|
||||
aria-label="Toast notifications"
|
||||
toastClassName={() =>
|
||||
`relative min-h-16 mb-4 p-2 rounded-lg
|
||||
<>
|
||||
<ToastContainer
|
||||
aria-label="Toast notifications"
|
||||
toastClassName={() =>
|
||||
`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
|
||||
/>
|
||||
<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} />
|
||||
<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)} />}
|
||||
/>
|
||||
<Route path="configure-providers" element={<ConfigureProvidersRoute />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProviderGuard didSelectProvider={didSelectProvider}>
|
||||
<ChatProvider
|
||||
chat={chat}
|
||||
setChat={setChat}
|
||||
contextKey="hub"
|
||||
agentWaitingMessage={agentWaitingMessage}
|
||||
>
|
||||
<AppLayout setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} />
|
||||
</ChatProvider>
|
||||
</ProviderGuard>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<HubRouteWrapper
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
isExtensionsLoading={isExtensionsLoading}
|
||||
resetChat={resetChatIfNecessary}
|
||||
/>
|
||||
}
|
||||
<Route path="configure-providers" element={<ConfigureProvidersRoute />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProviderGuard didSelectProvider={didSelectProvider}>
|
||||
<ChatProvider
|
||||
chat={chat}
|
||||
setChat={setChat}
|
||||
contextKey="hub"
|
||||
agentWaitingMessage={agentWaitingMessage}
|
||||
>
|
||||
<AppLayout setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} />
|
||||
</ChatProvider>
|
||||
</ProviderGuard>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<AnnouncementModal />
|
||||
</ModelAndProviderProvider>
|
||||
|
||||
@@ -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<string | null>(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
|
||||
|
||||
@@ -49,12 +49,16 @@ export function useAgent(): UseAgentReturn {
|
||||
const [agentState, setAgentState] = useState<AgentState>(AgentState.UNINITIALIZED);
|
||||
const [sessionId, setSessionId] = useState<string | 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 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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+39
-35
@@ -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<number, number>(); // windowId -> blocke
|
||||
|
||||
const createChat = async (
|
||||
app: App,
|
||||
query?: string,
|
||||
_query?: string,
|
||||
dir?: string,
|
||||
_version?: string,
|
||||
resumeSessionId?: string,
|
||||
@@ -679,43 +680,46 @@ 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)}`;
|
||||
}
|
||||
|
||||
// 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 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) {
|
||||
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 appPath = '/';
|
||||
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',
|
||||
};
|
||||
|
||||
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
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
|
||||
Reference in New Issue
Block a user