diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx index a0a3d39b9fd6..338c1e9a1ff4 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -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}, diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 9a15d1068ab2..e071e3595ad3 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -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 ( { - // 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 = ({ { - // 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 ( - 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 navigate('/')} setView={setView} viewOptions={viewOptions} />; }; const SessionsRoute = () => { const navigate = useNavigate(); + const setView = createNavigationHandler(navigate); - return ( - { - // 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 ; }; 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 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 = { - 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'); - } + 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; + + 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 ; } - if (isLoadingSession) + if (isLoadingSession) { return (
); + } return ( @@ -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} > {isGoosehintsModalOpen && ( )} diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index d7ebf70593a4..07a48f58bb56 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -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'; diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 76e1b0858c57..a73898968e9d 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -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(null); + const dropdownRef: React.RefObject = useRef( + null + ) as React.RefObject; const toolCount = useToolCount(); const { isLoadingCompaction, handleManualCompaction } = useChatContextManager(); const { getProviders, read } = useConfig(); diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index db4f3a1fb4fa..1076f6f0a284 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -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'; diff --git a/ui/desktop/src/components/Layout/AppLayout.tsx b/ui/desktop/src/components/Layout/AppLayout.tsx index c3be6073fe8a..47c23c5178ff 100644 --- a/ui/desktop/src/components/Layout/AppLayout.tsx +++ b/ui/desktop/src/components/Layout/AppLayout.tsx @@ -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'; diff --git a/ui/desktop/src/components/conversation/SearchBar.tsx b/ui/desktop/src/components/conversation/SearchBar.tsx index a20d68df70dc..756c55f719ba 100644 --- a/ui/desktop/src/components/conversation/SearchBar.tsx +++ b/ui/desktop/src/components/conversation/SearchBar.tsx @@ -20,7 +20,7 @@ interface SearchBarProps { currentIndex: number; }; /** Optional ref for the search input element */ - inputRef?: React.RefObject; + inputRef?: React.RefObject; /** Initial search term */ initialSearchTerm?: string; } diff --git a/ui/desktop/src/components/conversation/SearchView.tsx b/ui/desktop/src/components/conversation/SearchView.tsx index d5c8e8783c15..b2d35f77d3f5 100644 --- a/ui/desktop/src/components/conversation/SearchView.tsx +++ b/ui/desktop/src/components/conversation/SearchView.tsx @@ -44,7 +44,9 @@ export const SearchView: React.FC> = ({ count: number; } | null>(null); - const searchInputRef = useRef(null); + const searchInputRef: React.RefObject = React.useRef( + null + ) as React.RefObject; const highlighterRef = React.useRef(null); const containerRef = React.useRef(null); const lastSearchRef = React.useRef<{ term: string; caseSensitive: boolean }>({ diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index 6f0b7e7fdb84..1e2c52fd4863 100644 --- a/ui/desktop/src/components/extensions/ExtensionsView.tsx +++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx @@ -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'; diff --git a/ui/desktop/src/components/hub.tsx b/ui/desktop/src/components/hub.tsx index 9ecc64bec7a2..19f6e053ed84 100644 --- a/ui/desktop/src/components/hub.tsx +++ b/ui/desktop/src/components/hub.tsx @@ -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, diff --git a/ui/desktop/src/components/pair.tsx b/ui/desktop/src/components/pair.tsx index 80469d3259aa..f360b4c837bf 100644 --- a/ui/desktop/src/components/pair.tsx +++ b/ui/desktop/src/components/pair.tsx @@ -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'; diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 0d4597088099..b86aae593427 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -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'; diff --git a/ui/desktop/src/components/sessions/SessionsView.tsx b/ui/desktop/src/components/sessions/SessionsView.tsx index 9fa3eb1848e6..8e6ba8a711e7 100644 --- a/ui/desktop/src/components/sessions/SessionsView.tsx +++ b/ui/desktop/src/components/sessions/SessionsView.tsx @@ -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'; diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index 4ed09580a247..e414134e9c1d 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -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'; diff --git a/ui/desktop/src/components/settings/models/ModelsSection.tsx b/ui/desktop/src/components/settings/models/ModelsSection.tsx index 0ee9defa76e5..8e903e141b45 100644 --- a/ui/desktop/src/components/settings/models/ModelsSection.tsx +++ b/ui/desktop/src/components/settings/models/ModelsSection.tsx @@ -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 { diff --git a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx index 2c64b744f28c..74226e697496 100644 --- a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx +++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx @@ -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; + dropdownRef: React.RefObject; 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, diff --git a/ui/desktop/src/components/settings/models/subcomponents/AddModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/AddModelModal.tsx index 0a2ff2622177..35d02c7dd824 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/AddModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/AddModelModal.tsx @@ -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'; diff --git a/ui/desktop/src/components/settings/models/subcomponents/ModelSettingsButtons.tsx b/ui/desktop/src/components/settings/models/subcomponents/ModelSettingsButtons.tsx index fafa8eae9c6a..83b49e97c05e 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/ModelSettingsButtons.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/ModelSettingsButtons.tsx @@ -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 { diff --git a/ui/desktop/src/components/settings/reset_provider/ResetProviderSection.tsx b/ui/desktop/src/components/settings/reset_provider/ResetProviderSection.tsx index 26ecc0cee296..3fae4fb8f8b9 100644 --- a/ui/desktop/src/components/settings/reset_provider/ResetProviderSection.tsx +++ b/ui/desktop/src/components/settings/reset_provider/ResetProviderSection.tsx @@ -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; diff --git a/ui/desktop/src/hooks/useChat.ts b/ui/desktop/src/hooks/useChat.ts index 755313c73d44..975a7fc2b10e 100644 --- a/ui/desktop/src/hooks/useChat.ts +++ b/ui/desktop/src/hooks/useChat.ts @@ -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; diff --git a/ui/desktop/src/hooks/useChatEngine.test.ts b/ui/desktop/src/hooks/useChatEngine.test.ts index 83e87a336037..3c5207286e19 100644 --- a/ui/desktop/src/hooks/useChatEngine.test.ts +++ b/ui/desktop/src/hooks/useChatEngine.test.ts @@ -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'); diff --git a/ui/desktop/src/sessionLinks.ts b/ui/desktop/src/sessionLinks.ts index 09a3f8f88377..565ee9417482 100644 --- a/ui/desktop/src/sessionLinks.ts +++ b/ui/desktop/src/sessionLinks.ts @@ -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; } diff --git a/ui/desktop/src/utils/appInitialization.ts b/ui/desktop/src/utils/appInitialization.ts new file mode 100644 index 000000000000..3313751cf824 --- /dev/null +++ b/ui/desktop/src/utils/appInitialization.ts @@ -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; + addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise; + 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) => { + 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 = { + 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(); + } + } +}; diff --git a/ui/desktop/src/utils/navigationUtils.ts b/ui/desktop/src/utils/navigationUtils.ts new file mode 100644 index 000000000000..dd866b0a3b85 --- /dev/null +++ b/ui/desktop/src/utils/navigationUtils.ts @@ -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 }); + } + }; +};