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
+2 -13
View File
@@ -104,6 +104,7 @@ vi.mock('./contexts/ChatContext', () => ({
recipeConfig: null,
},
setChat: vi.fn(),
setPairChat: vi.fn(), // Keep this from HEAD
resetChat: vi.fn(),
hasActiveSession: false,
setRecipeConfig: vi.fn(),
@@ -115,6 +116,7 @@ vi.mock('./contexts/ChatContext', () => ({
clearDraft: vi.fn(),
contextKey: 'hub',
}),
DEFAULT_CHAT_TITLE: 'New Chat', // Keep this from HEAD
}));
vi.mock('./contexts/DraftContext', () => ({
@@ -137,19 +139,6 @@ vi.mock('./components/AnnouncementModal', () => ({
default: () => null,
}));
vi.mock('./hooks/useChat', () => ({
useChat: () => ({
chat: {
id: 'test-id',
title: 'Test Chat',
messages: [],
messageHistoryIndex: 0,
recipeConfig: null,
},
setChat: vi.fn(),
}),
}));
// Mock react-router-dom to avoid HashRouter issues in tests
vi.mock('react-router-dom', () => ({
HashRouter: ({ children }: { children: React.ReactNode }) => <>{children}</>,
+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}
/>
)}
+1 -1
View File
@@ -56,7 +56,7 @@ import {
ChatContextManagerProvider,
useChatContextManager,
} from './context_management/ChatContextManager';
import { type View, ViewOptions } from '../App';
import { View, ViewOptions } from '../utils/navigationUtils';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import ChatInput from './ChatInput';
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
+4 -2
View File
@@ -2,7 +2,7 @@ import React, { useRef, useState, useEffect, useMemo } from 'react';
import { FolderKey, ScrollText } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip';
import { Button } from './ui/button';
import type { View } from '../App';
import type { View } from '../utils/navigationUtils';
import Stop from './ui/Stop';
import { Attach, Send, Close, Microphone } from './icons';
import { ChatState } from '../types/chatState';
@@ -107,7 +107,9 @@ export default function ChatInput({
// Derived state - chatState != Idle means we're in some form of loading state
const isLoading = chatState !== ChatState.Idle;
const { alerts, addAlert, clearAlerts } = useAlerts();
const dropdownRef = useRef<HTMLDivElement | null>(null);
const dropdownRef: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(
null
) as React.RefObject<HTMLDivElement>;
const toolCount = useToolCount();
const { isLoadingCompaction, handleManualCompaction } = useChatContextManager();
const { getProviders, read } = useConfig();
@@ -12,7 +12,7 @@ import {
SidebarSeparator,
} from '../ui/sidebar';
import { ChatSmart, Gear } from '../icons';
import { ViewOptions, View } from '../../App';
import { ViewOptions, View } from '../../utils/navigationUtils';
import { useChatContext } from '../../contexts/ChatContext';
import { DEFAULT_CHAT_TITLE } from '../../contexts/ChatContext';
@@ -1,7 +1,7 @@
import React from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import AppSidebar from '../GooseSidebar/AppSidebar';
import { View, ViewOptions } from '../../App';
import { View, ViewOptions } from '../../utils/navigationUtils';
import { AppWindowMac, AppWindow } from 'lucide-react';
import { Button } from '../ui/button';
import { Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar';
@@ -20,7 +20,7 @@ interface SearchBarProps {
currentIndex: number;
};
/** Optional ref for the search input element */
inputRef?: React.RefObject<HTMLInputElement | null>;
inputRef?: React.RefObject<HTMLInputElement>;
/** Initial search term */
initialSearchTerm?: string;
}
@@ -44,7 +44,9 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
count: number;
} | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const searchInputRef: React.RefObject<HTMLInputElement> = React.useRef<HTMLInputElement>(
null
) as React.RefObject<HTMLInputElement>;
const highlighterRef = React.useRef<SearchHighlighter | null>(null);
const containerRef = React.useRef<SearchContainerElement | null>(null);
const lastSearchRef = React.useRef<{ term: string; caseSensitive: boolean }>({
@@ -1,4 +1,4 @@
import { View, ViewOptions } from '../../App';
import { View, ViewOptions } from '../../utils/navigationUtils';
import ExtensionsSection from '../settings/extensions/ExtensionsSection';
import { ExtensionConfig } from '../../api';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
+2 -1
View File
@@ -21,7 +21,7 @@
import { useState } from 'react';
import FlappyGoose from './FlappyGoose';
import { type View, ViewOptions } from '../App';
import { SessionInsights } from './sessions/SessionsInsights';
import ChatInput from './ChatInput';
import { generateSessionId } from '../sessions';
@@ -31,6 +31,7 @@ import 'react-toastify/dist/ReactToastify.css';
import { ChatType } from '../types/chat';
import { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext';
import { View, ViewOptions } from '../utils/navigationUtils';
export default function Hub({
chat: _chat,
+1 -1
View File
@@ -26,7 +26,7 @@
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { type View, ViewOptions } from '../App';
import { View, ViewOptions } from '../utils/navigationUtils';
import BaseChat from './BaseChat';
import { useRecipeManager } from '../hooks/useRecipeManager';
import { useIsMobile } from '../hooks/use-mobile';
@@ -4,7 +4,7 @@ import { fetchSessions, updateSessionMetadata, type Session } from '../../sessio
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import { View, ViewOptions } from '../../App';
import { View, ViewOptions } from '../../utils/navigationUtils';
import { formatMessageTimestamp } from '../../utils/timeUtils';
import { SearchView } from '../conversation/SearchView';
import { SearchHighlighter } from '../../utils/searchHighlighter';
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, ViewOptions } from '../../App';
import { View, ViewOptions } from '../../utils/navigationUtils';
import { fetchSessionDetails, type SessionDetails } from '../../sessions';
import SessionListView from './SessionListView';
import SessionHistoryView from './SessionHistoryView';
@@ -1,6 +1,6 @@
import { ScrollArea } from '../ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import type { View, ViewOptions } from '../../App';
import { View, ViewOptions } from '../../utils/navigationUtils';
import ModelsSection from './models/ModelsSection';
import SessionSharingSection from './sessions/SessionSharingSection';
import AppSettingsSection from './app/AppSettingsSection';
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import type { View } from '../../../App';
import { View } from '../../../utils/navigationUtils';
import ModelSettingsButtons from './subcomponents/ModelSettingsButtons';
import { useConfig } from '../../ConfigContext';
import {
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { AddModelModal } from '../subcomponents/AddModelModal';
import { LeadWorkerSettings } from '../subcomponents/LeadWorkerSettings';
import { View } from '../../../../App';
import { View } from '../../../../utils/navigationUtils';
import {
DropdownMenu,
DropdownMenuContent,
@@ -22,12 +22,13 @@ import { toastSuccess, toastError } from '../../../../toasts';
import ViewRecipeModal from '../../../ViewRecipeModal';
interface ModelsBottomBarProps {
dropdownRef: React.RefObject<HTMLDivElement | null>;
dropdownRef: React.RefObject<HTMLDivElement>;
setView: (view: View) => void;
alerts: Alert[];
recipeConfig?: Recipe | null;
hasMessages?: boolean; // Add prop to know if there are messages to create a recipe from
}
export default function ModelsBottomBar({
dropdownRef,
setView,
@@ -15,7 +15,7 @@ import { Input } from '../../../ui/input';
import { Select } from '../../../ui/Select';
import { useConfig } from '../../../ConfigContext';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import type { View } from '../../../../App';
import type { View } from '../../../../utils/navigationUtils';
import Model, { getProviderMetadata } from '../modelInterface';
import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils';
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Button } from '../../../ui/button';
import { AddModelModal } from './AddModelModal';
import type { View } from '../../../../App';
import type { View } from '../../../../utils/navigationUtils';
import { shouldShowPredefinedModels } from '../predefinedModelsUtils';
interface ConfigureModelButtonsProps {
@@ -1,7 +1,7 @@
import { Button } from '../../ui/button';
import { RefreshCw } from 'lucide-react';
import { useConfig } from '../../ConfigContext';
import { View, ViewOptions } from '../../../App';
import { View, ViewOptions } from '../../../utils/navigationUtils';
interface ResetProviderSectionProps {
setView: (view: View, viewOptions?: ViewOptions) => void;
+2 -1
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { ChatType } from '../types/chat';
import { fetchSessionDetails, generateSessionId } from '../sessions';
import { View, ViewOptions } from '../App';
import { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext';
import { View, ViewOptions } from '../utils/navigationUtils';
type UseChatArgs = {
setIsLoadingSession: (isLoading: boolean) => void;
+13 -16
View File
@@ -1,3 +1,6 @@
/**
* @vitest-environment jsdom
*/
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useChatEngine } from './useChatEngine';
@@ -19,30 +22,24 @@ describe('useChatEngine', () => {
let mockUseMessageStream: Mock;
beforeEach(async () => {
// Mock the global window object more completely for the React testing environment
const mockWindow = {
appConfig: {
// Mock the appConfig and electron APIs on the existing window object
Object.defineProperty(window, 'appConfig', {
value: {
get: vi.fn((key: string) => {
if (key === 'GOOSE_API_HOST') return 'http://localhost';
if (key === 'GOOSE_PORT') return '8000';
return null;
}),
},
electron: {
writable: true,
});
Object.defineProperty(window, 'electron', {
value: {
logInfo: vi.fn(),
},
setTimeout: vi.fn((fn: () => void) => {
fn(); // Execute immediately for tests
return 123;
}),
clearTimeout: vi.fn(),
dispatchEvent: vi.fn(),
CustomEvent: vi.fn(),
// Add basic browser objects required by React Testing Library
HTMLElement: class MockHTMLElement {},
Event: class MockEvent {},
};
vi.stubGlobal('window', mockWindow);
writable: true,
});
// Dynamically import the hook so we can get a reference to the mock
const { useMessageStream } = await import('./useMessageStream');
+2 -1
View File
@@ -1,11 +1,12 @@
import { fetchSharedSessionDetails, SharedSessionDetails } from './sharedSessions';
import { type View } from './App';
import { View } from './utils/navigationUtils';
export interface SessionLinksViewOptions {
sessionDetails?: SharedSessionDetails | null;
error?: string;
shareToken?: string;
baseUrl?: string;
[key: string]: unknown;
}
+212
View File
@@ -0,0 +1,212 @@
import { ChatType } from '../types/chat';
import { Recipe } from '../recipe';
import { initializeSystem } from './providerUtils';
import { initializeCostDatabase } from './costDatabase';
import {
type ExtensionConfig,
type FixedExtensionEntry,
MalformedConfigError,
} from '../components/ConfigContext';
import { backupConfig, initConfig, readAllConfig, recoverConfig, validateConfig } from '../api';
import { COST_TRACKING_ENABLED } from '../updates';
interface InitializationDependencies {
getExtensions?: (b: boolean) => Promise<FixedExtensionEntry[]>;
addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
setPairChat: (chat: ChatType | ((prev: ChatType) => ChatType)) => void;
provider: string;
model: string;
}
export const initializeApp = async ({
getExtensions,
addExtension,
setPairChat,
provider,
model,
}: InitializationDependencies) => {
console.log(`Initializing app`);
const urlParams = new URLSearchParams(window.location.search);
const viewType = urlParams.get('view');
const resumeSessionId = urlParams.get('resumeSessionId');
const recipeConfig = window.appConfig.get('recipe');
if (resumeSessionId) {
console.log('Session resume detected, letting useChat hook handle navigation');
await initializeForSessionResume({ getExtensions, addExtension, provider, model });
return;
}
if (recipeConfig && typeof recipeConfig === 'object') {
console.log('Recipe deeplink detected, initializing system for recipe');
await initializeForRecipe({
recipeConfig: recipeConfig as Recipe,
getExtensions,
addExtension,
setPairChat,
provider,
model,
});
return;
}
if (viewType) {
handleViewTypeDeepLink(viewType, recipeConfig);
return;
}
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);
await handleConfigRecovery();
}
if (provider && model) {
try {
const initPromises = [
initializeSystem(provider, model, {
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({}, '', '#/');
};
const initializeForSessionResume = async ({
getExtensions,
addExtension,
provider,
model,
}: Pick<InitializationDependencies, 'getExtensions' | 'addExtension' | 'provider' | 'model'>) => {
await initConfig();
await readAllConfig({ throwOnError: true });
await initializeSystem(provider, model, {
getExtensions,
addExtension,
});
};
const initializeForRecipe = async ({
recipeConfig,
getExtensions,
addExtension,
setPairChat,
provider,
model,
}: Pick<
InitializationDependencies,
'getExtensions' | 'addExtension' | 'setPairChat' | 'provider' | 'model'
> & {
recipeConfig: Recipe;
}) => {
await initConfig();
await readAllConfig({ throwOnError: true });
await initializeSystem(provider, model, {
getExtensions,
addExtension,
});
setPairChat((prevChat) => ({
...prevChat,
recipeConfig: recipeConfig,
title: recipeConfig?.title || 'Recipe Chat',
messages: [],
messageHistoryIndex: 0,
}));
window.location.hash = '#/pair';
window.history.replaceState(
{
recipeConfig: recipeConfig,
resetChat: true,
},
'',
'#/pair'
);
};
const handleViewTypeDeepLink = (viewType: string, recipeConfig: unknown) => {
if (viewType === 'recipeEditor' && recipeConfig) {
window.location.hash = '#/recipe-editor';
window.history.replaceState({ config: recipeConfig }, '', '#/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);
}
}
};
const handleConfigRecovery = async () => {
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);
}
}
console.log('Attempting config recovery...');
try {
await validateConfig({ throwOnError: true });
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();
}
}
};
+80
View File
@@ -0,0 +1,80 @@
import { NavigateFunction } from 'react-router-dom';
export type View =
| 'welcome'
| 'chat'
| 'pair'
| 'settings'
| 'extensions'
| 'moreModels'
| 'configureProviders'
| 'configPage'
| 'ConfigureProviders'
| 'settingsV2'
| 'sessions'
| 'schedules'
| 'sharedSession'
| 'loading'
| 'recipeEditor'
| 'recipes'
| 'permission';
export type ViewOptions = {
extensionId?: string;
showEnvVars?: boolean;
deepLinkConfig?: unknown;
resumedSession?: unknown;
sessionDetails?: unknown;
error?: string;
shareToken?: string;
baseUrl?: string;
config?: unknown;
parentView?: View;
parentViewOptions?: ViewOptions;
[key: string]: unknown;
};
export const createNavigationHandler = (navigate: NavigateFunction) => {
return (view: View, options?: ViewOptions) => {
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', { state: options });
break;
case 'schedules':
navigate('/schedules', { state: options });
break;
case 'recipes':
navigate('/recipes', { state: options });
break;
case 'permission':
navigate('/permission', { state: options });
break;
case 'ConfigureProviders':
navigate('/configure-providers', { state: options });
break;
case 'sharedSession':
navigate('/shared-session', { state: options });
break;
case 'recipeEditor':
navigate('/recipe-editor', { state: options });
break;
case 'welcome':
navigate('/welcome', { state: options });
break;
case 'extensions':
navigate('/extensions', { state: options });
break;
default:
navigate('/', { state: options });
}
};
};