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"
|
"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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user