Move out app init (#4185)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-08-19 16:39:46 -04:00
committed by GitHub
parent df0a6c4d81
commit 70f0173f9d
24 changed files with 378 additions and 547 deletions
+42 -497
View File
@@ -3,14 +3,11 @@ import { IpcRendererEvent } from 'electron';
import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { openSharedSessionFromDeepLink, type SessionLinksViewOptions } from './sessionLinks';
import { type SharedSessionDetails } from './sharedSessions';
import { initializeSystem } from './utils/providerUtils';
import { initializeCostDatabase } from './utils/costDatabase';
import { ErrorUI } from './components/ErrorBoundary';
import { ConfirmationModal } from './components/ui/ConfirmationModal';
import { ToastContainer } from 'react-toastify';
import { extractExtensionName } from './components/settings/extensions/utils';
import { GoosehintsModal } from './components/GoosehintsModal';
import { type ExtensionConfig } from './extensions';
import AnnouncementModal from './components/AnnouncementModal';
import { generateSessionId } from './sessions';
import ProviderGuard from './components/ProviderGuard';
@@ -29,18 +26,10 @@ import { ChatProvider } from './contexts/ChatContext';
import { DraftProvider } from './contexts/DraftContext';
import 'react-toastify/dist/ReactToastify.css';
import { useConfig, MalformedConfigError } from './components/ConfigContext';
import { useConfig } from './components/ConfigContext';
import { ModelAndProviderProvider } from './components/ModelAndProviderContext';
import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings/extensions';
import {
backupConfig,
initConfig,
readAllConfig,
recoverConfig,
validateConfig,
} from './api/sdk.gen';
import PermissionSettingsView from './components/settings/permission/PermissionSetting';
import { COST_TRACKING_ENABLED } from './updates';
import { type SessionDetails } from './sessions';
import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView';
@@ -48,52 +37,9 @@ import { Recipe } from './recipe';
import RecipesView from './components/RecipesView';
import RecipeEditor from './components/RecipeEditor';
export type View =
| 'welcome'
| 'chat'
| 'pair'
| 'settings'
| 'extensions'
| 'moreModels'
| 'configureProviders'
| 'configPage'
| 'ConfigureProviders'
| 'settingsV2'
| 'sessions'
| 'schedules'
| 'sharedSession'
| 'loading'
| 'recipeEditor'
| 'recipes'
| 'permission';
export type ViewOptions = {
// Settings view options
extensionId?: string;
showEnvVars?: boolean;
deepLinkConfig?: ExtensionConfig;
// Session view options
resumedSession?: SessionDetails;
sessionDetails?: SessionDetails;
error?: string;
shareToken?: string;
baseUrl?: string;
// Recipe editor options
config?: unknown;
// Permission view options
parentView?: View;
// Generic options
[key: string]: unknown;
};
export type ViewConfig = {
view: View;
viewOptions?: ViewOptions;
};
// Import the new modules
import { createNavigationHandler, View, ViewOptions } from './utils/navigationUtils';
import { initializeApp } from './utils/appInitialization';
// Route Components
const HubRouteWrapper = ({
@@ -108,6 +54,7 @@ const HubRouteWrapper = ({
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) => {
const navigate = useNavigate();
const setView = createNavigationHandler(navigate);
return (
<Hub
@@ -115,46 +62,7 @@ const HubRouteWrapper = ({
chat={chat}
setChat={setChat}
setPairChat={setPairChat}
setView={(view: View, options?: ViewOptions) => {
// Convert view to route navigation
switch (view) {
case 'chat':
navigate('/');
break;
case 'pair':
navigate('/pair', { state: options });
break;
case 'settings':
navigate('/settings', { state: options });
break;
case 'sessions':
navigate('/sessions');
break;
case 'schedules':
navigate('/schedules');
break;
case 'recipes':
navigate('/recipes');
break;
case 'permission':
navigate('/permission', { state: options });
break;
case 'ConfigureProviders':
navigate('/configure-providers');
break;
case 'sharedSession':
navigate('/shared-session', { state: options });
break;
case 'recipeEditor':
navigate('/recipe-editor', { state: options });
break;
case 'welcome':
navigate('/welcome');
break;
default:
navigate('/');
}
}}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
);
@@ -174,6 +82,7 @@ const PairRouteWrapper = ({
const navigate = useNavigate();
const location = useLocation();
const chatRef = useRef(chat);
const setView = createNavigationHandler(navigate);
// Keep the ref updated with the current chat state
useEffect(() => {
@@ -273,46 +182,7 @@ const PairRouteWrapper = ({
<Pair
chat={chat}
setChat={setChat}
setView={(view: View, options?: ViewOptions) => {
// Convert view to route navigation
switch (view) {
case 'chat':
navigate('/');
break;
case 'pair':
navigate('/pair', { state: options });
break;
case 'settings':
navigate('/settings', { state: options });
break;
case 'sessions':
navigate('/sessions');
break;
case 'schedules':
navigate('/schedules');
break;
case 'recipes':
navigate('/recipes');
break;
case 'permission':
navigate('/permission', { state: options });
break;
case 'ConfigureProviders':
navigate('/configure-providers');
break;
case 'sharedSession':
navigate('/shared-session', { state: options });
break;
case 'recipeEditor':
navigate('/recipe-editor', { state: options });
break;
case 'welcome':
navigate('/welcome');
break;
default:
navigate('/');
}
}}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
);
@@ -321,105 +191,19 @@ const PairRouteWrapper = ({
const SettingsRoute = () => {
const location = useLocation();
const navigate = useNavigate();
const setView = createNavigationHandler(navigate);
// Get viewOptions from location.state or history.state
const viewOptions =
(location.state as SettingsViewOptions) || (window.history.state as SettingsViewOptions) || {};
return (
<SettingsView
onClose={() => navigate('/')}
setView={(view: View, options?: ViewOptions) => {
// Convert view to route navigation
switch (view) {
case 'chat':
navigate('/');
break;
case 'pair':
navigate('/pair');
break;
case 'settings':
navigate('/settings', { state: options });
break;
case 'sessions':
navigate('/sessions');
break;
case 'schedules':
navigate('/schedules');
break;
case 'recipes':
navigate('/recipes');
break;
case 'permission':
navigate('/permission', { state: options });
break;
case 'ConfigureProviders':
navigate('/configure-providers');
break;
case 'sharedSession':
navigate('/shared-session', { state: options });
break;
case 'recipeEditor':
navigate('/recipe-editor', { state: options });
break;
case 'welcome':
navigate('/welcome');
break;
default:
navigate('/');
}
}}
viewOptions={viewOptions}
/>
);
return <SettingsView onClose={() => navigate('/')} setView={setView} viewOptions={viewOptions} />;
};
const SessionsRoute = () => {
const navigate = useNavigate();
const setView = createNavigationHandler(navigate);
return (
<SessionsView
setView={(view: View, options?: ViewOptions) => {
// Convert view to route navigation
switch (view) {
case 'chat':
navigate('/', { state: options });
break;
case 'pair':
navigate('/pair', { state: options });
break;
case 'settings':
navigate('/settings', { state: options });
break;
case 'sessions':
navigate('/sessions');
break;
case 'schedules':
navigate('/schedules');
break;
case 'recipes':
navigate('/recipes');
break;
case 'permission':
navigate('/permission', { state: options });
break;
case 'ConfigureProviders':
navigate('/configure-providers');
break;
case 'sharedSession':
navigate('/shared-session', { state: options });
break;
case 'recipeEditor':
navigate('/recipe-editor', { state: options });
break;
case 'welcome':
navigate('/welcome');
break;
default:
navigate('/');
}
}}
/>
);
return <SessionsView setView={setView} />;
};
const SchedulesRoute = () => {
@@ -548,6 +332,7 @@ const SharedSessionRouteWrapper = ({
}) => {
const location = useLocation();
const navigate = useNavigate();
const setView = createNavigationHandler(navigate);
const historyState = window.history.state;
const sessionDetails = (location.state?.sessionDetails ||
@@ -565,47 +350,7 @@ const SharedSessionRouteWrapper = ({
if (shareToken && baseUrl) {
setIsLoadingSharedSession(true);
try {
await openSharedSessionFromDeepLink(
`goose://sessions/${shareToken}`,
(view: View, _options?: SessionLinksViewOptions) => {
// Convert view to route navigation
switch (view) {
case 'chat':
navigate('/', { state: _options });
break;
case 'pair':
navigate('/pair', { state: _options });
break;
case 'settings':
navigate('/settings', { state: _options });
break;
case 'sessions':
navigate('/sessions');
break;
case 'schedules':
navigate('/schedules');
break;
case 'recipes':
navigate('/recipes');
break;
case 'permission':
navigate('/permission', { state: _options });
break;
case 'ConfigureProviders':
navigate('/configure-providers');
break;
case 'sharedSession':
navigate('/shared-session', { state: _options });
break;
case 'recipeEditor':
navigate('/recipe-editor', { state: _options });
break;
default:
navigate('/');
}
},
baseUrl
);
await openSharedSessionFromDeepLink(`goose://sessions/${shareToken}`, setView, baseUrl);
} catch (error) {
console.error('Failed to retry loading shared session:', error);
} finally {
@@ -668,7 +413,7 @@ export default function App() {
title: 'Pair Chat',
messages: [],
messageHistoryIndex: 0,
recipeConfig: null, // Initialize with no recipe
recipeConfig: null,
});
const { getExtensions, addExtension, read } = useConfig();
@@ -745,220 +490,24 @@ export default function App() {
}
initAttemptedRef.current = true;
console.log(`Initializing app`);
const initialize = async () => {
const config = window.electron.getConfig();
const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER;
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
const urlParams = new URLSearchParams(window.location.search);
const viewType = urlParams.get('view');
const resumeSessionId = urlParams.get('resumeSessionId');
const recipeConfig = window.appConfig.get('recipe');
// Check for session resume first - this takes priority over other navigation
if (resumeSessionId) {
console.log('Session resume detected, letting useChat hook handle navigation');
// Even when resuming a session, we need to initialize the system
const initializeForSessionResume = async () => {
try {
await initConfig();
await readAllConfig({ throwOnError: true });
const config = window.electron.getConfig();
const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER;
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
if (provider && model) {
await initializeSystem(provider as string, model as string, {
getExtensions,
addExtension,
});
} else {
throw new Error('No provider/model configured for session resume');
}
} catch (error) {
console.error('Failed to initialize system for session resume:', error);
setFatalError(
`Failed to initialize system for session resume: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
initializeForSessionResume();
return;
}
// Check for recipe config - this also needs provider initialization
if (recipeConfig && typeof recipeConfig === 'object') {
console.log('Recipe deeplink detected, initializing system for recipe');
const initializeForRecipe = async () => {
try {
await initConfig();
await readAllConfig({ throwOnError: true });
const config = window.electron.getConfig();
const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER;
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
if (provider && model) {
await initializeSystem(provider as string, model as string, {
getExtensions,
addExtension,
});
// Set up the recipe in pair chat after system is initialized
setPairChat((prevChat) => ({
...prevChat,
recipeConfig: recipeConfig as Recipe,
title: (recipeConfig as Recipe)?.title || 'Recipe Chat',
messages: [], // Start fresh for recipe
messageHistoryIndex: 0,
}));
// Navigate to pair view
window.location.hash = '#/pair';
window.history.replaceState(
{
recipeConfig: recipeConfig,
resetChat: true,
},
'',
'#/pair'
);
} else {
throw new Error('No provider/model configured for recipe');
}
} catch (error) {
console.error('Failed to initialize system for recipe:', error);
setFatalError(
`Failed to initialize system for recipe: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
initializeForRecipe();
return;
}
if (viewType) {
if (viewType === 'recipeEditor' && recipeConfig) {
// Handle recipe editor deep link - use hash routing
window.location.hash = '#/recipe-editor';
window.history.replaceState({ config: recipeConfig }, '', '#/recipe-editor');
} else {
// Handle other deep links by redirecting to appropriate route
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);
}
}
return;
}
const initializeApp = async () => {
try {
// Start cost database initialization early (non-blocking) - only if cost tracking is enabled
const costDbPromise = COST_TRACKING_ENABLED
? initializeCostDatabase().catch((error) => {
console.error('Failed to initialize cost database:', error);
})
: (() => {
console.log('Cost tracking disabled, skipping cost database initialization');
return Promise.resolve();
})();
await initConfig();
try {
await readAllConfig({ throwOnError: true });
} catch (error) {
console.warn('Initial config read failed, attempting recovery:', error);
const configVersion = localStorage.getItem('configVersion');
const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3;
if (shouldMigrateExtensions) {
console.log('Performing extension migration...');
try {
await backupConfig({ throwOnError: true });
await initConfig();
} catch (migrationError) {
console.error('Migration failed:', migrationError);
// Continue with recovery attempts
}
}
// Try recovery if migration didn't work or wasn't needed
console.log('Attempting config recovery...');
try {
// Try to validate first (faster than recovery)
await validateConfig({ throwOnError: true });
// If validation passes, try reading again
await readAllConfig({ throwOnError: true });
} catch {
console.log('Config validation failed, attempting recovery...');
try {
await recoverConfig({ throwOnError: true });
await readAllConfig({ throwOnError: true });
} catch {
console.warn('Config recovery failed, reinitializing...');
await initConfig();
}
}
}
const config = window.electron.getConfig();
const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER;
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
if (provider && model) {
try {
// Initialize system in parallel with cost database (if enabled)
const initPromises = [
initializeSystem(provider as string, model as string, {
getExtensions,
addExtension,
}),
];
if (COST_TRACKING_ENABLED) {
initPromises.push(costDbPromise);
}
await Promise.all(initPromises);
} catch (error) {
console.error('Error in system initialization:', error);
if (error instanceof MalformedConfigError) {
throw error;
}
window.location.hash = '#/';
window.history.replaceState({}, '', '#/');
}
} else {
window.location.hash = '#/';
window.history.replaceState({}, '', '#/');
}
} catch (error) {
console.error('Fatal error during initialization:', error);
setFatalError(error instanceof Error ? error.message : 'Unknown error occurred');
}
await initializeApp({
getExtensions,
addExtension,
setPairChat,
provider: provider as string,
model: model as string,
});
};
initializeApp();
initialize().catch((error) => {
console.error('Fatal error during initialization:', error);
setFatalError(error instanceof Error ? error.message : 'Unknown error occurred');
});
}, [getExtensions, addExtension, read, setPairChat]);
useEffect(() => {
@@ -975,7 +524,7 @@ export default function App() {
// Handle navigation to pair view for recipe deeplinks after router is ready
useEffect(() => {
const recipeConfig = window.appConfig.get('recipe');
const recipeConfig = window.appConfig?.get('recipe');
if (
recipeConfig &&
typeof recipeConfig === 'object' &&
@@ -1028,7 +577,7 @@ export default function App() {
return () => {
window.electron.off('open-shared-session', handleOpenSharedSession);
};
}, [setSharedSessionError]);
}, []);
// Handle recipe decode events from main process
useEffect(() => {
@@ -1094,7 +643,7 @@ export default function App() {
if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') {
event.preventDefault();
try {
const workingDir = window.appConfig.get('GOOSE_WORKING_DIR');
const workingDir = window.appConfig?.get('GOOSE_WORKING_DIR');
console.log(`Creating new chat window with working dir: ${workingDir}`);
window.electron.createChatWindow(undefined, workingDir as string);
} catch (error) {
@@ -1111,8 +660,7 @@ export default function App() {
// Prevent default drag and drop behavior globally to avoid opening files in new windows
// but allow our React components to handle drops in designated areas
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preventDefaults = (e: any) => {
const preventDefaults = (e: globalThis.DragEvent) => {
// Only prevent default if we're not over a designated drop zone
const target = e.target as HTMLElement;
const isOverDropZone = target.closest('[data-drop-zone="true"]') !== null;
@@ -1123,15 +671,13 @@ export default function App() {
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleDragOver = (e: any) => {
const handleDragOver = (e: globalThis.DragEvent) => {
// Always prevent default for dragover to allow dropping
e.preventDefault();
e.stopPropagation();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleDrop = (e: any) => {
const handleDrop = (e: globalThis.DragEvent) => {
// Only prevent default if we're not over a designated drop zone
const target = e.target as HTMLElement;
const isOverDropZone = target.closest('[data-drop-zone="true"]') !== null;
@@ -1157,10 +703,9 @@ export default function App() {
}, []);
useEffect(() => {
console.log('Setting up fatal error handler');
const handleFatalError = (_event: IpcRendererEvent, ...args: unknown[]) => {
const errorMessage = args[0] as string;
console.error('Encountered a fatal error: ', errorMessage);
console.error('Encountered a fatal error:', errorMessage);
console.error('Is loading session:', isLoadingSession);
setFatalError(errorMessage);
};
@@ -1171,7 +716,6 @@ export default function App() {
}, [isLoadingSession]);
useEffect(() => {
console.log('Setting up view change handler');
const handleSetView = (_event: IpcRendererEvent, ...args: unknown[]) => {
const newView = args[0] as View;
const section = args[1] as string | undefined;
@@ -1232,7 +776,7 @@ export default function App() {
try {
const allowedCommands = await window.electron.getAllowedExtensions();
if (allowedCommands && allowedCommands.length > 0) {
const isCommandAllowed = allowedCommands.some((allowedCmd) =>
const isCommandAllowed = allowedCommands.some((allowedCmd: string) =>
command.startsWith(allowedCmd)
);
if (!isCommandAllowed) {
@@ -1337,12 +881,13 @@ export default function App() {
return <ErrorUI error={new Error(fatalError)} />;
}
if (isLoadingSession)
if (isLoadingSession) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-textStandard"></div>
</div>
);
}
return (
<DraftProvider>
@@ -1407,7 +952,7 @@ export default function App() {
chat={pairChat}
setChat={setPairChat}
contextKey={`pair-${pairChat.id}`}
key={pairChat.id} // Add key prop to force re-render when chat ID changes
key={pairChat.id}
>
<PairRouteWrapper
chat={pairChat}
@@ -1492,7 +1037,7 @@ export default function App() {
</div>
{isGoosehintsModalOpen && (
<GoosehintsModal
directory={window.appConfig.get('GOOSE_WORKING_DIR') as string}
directory={window.appConfig?.get('GOOSE_WORKING_DIR') as string}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
)}