From 1689822578b1c2848dc830b0ee676821a1c1f531 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 9 Jul 2025 16:53:46 +0200 Subject: [PATCH 1/9] Goose Work --- ui/desktop/openapi.json | 2 +- ui/desktop/src/App.tsx | 47 ++++++++- .../src/components/ui/RecipeWarningModal.tsx | 76 +++++++++++++++ ui/desktop/src/main.ts | 17 ++++ ui/desktop/src/utils/recipeHash.ts | 97 +++++++++++++++++++ 5 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 ui/desktop/src/components/ui/RecipeWarningModal.tsx create mode 100644 ui/desktop/src/utils/recipeHash.ts diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 4f80c5ad33b3..30375d6f5d7b 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "1.0.34" + "version": "1.0.35" }, "paths": { "/agent/tools": { diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 14c72ac5692d..03603c5f3c18 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -6,6 +6,7 @@ import { initializeSystem } from './utils/providerUtils'; import { initializeCostDatabase } from './utils/costDatabase'; import { ErrorUI } from './components/ErrorBoundary'; import { ConfirmationModal } from './components/ui/ConfirmationModal'; +import { RecipeWarningModal } from './components/ui/RecipeWarningModal'; import { ToastContainer } from 'react-toastify'; import { toastService } from './toasts'; import { extractExtensionName } from './components/settings/extensions/utils'; @@ -115,7 +116,43 @@ const getInitialView = (): ViewConfig => { export default function App() { const [fatalError, setFatalError] = useState(null); const [modalVisible, setModalVisible] = useState(false); - const [pendingLink, setPendingLink] = useState(null); + const [pendingRecipe, setPendingRecipe] = useState(null); + const [recipeWarningVisible, setRecipeWarningVisible] = useState(false); + + useEffect(() => { + console.log('Setting up new recipe warning handler'); + const handleNewRecipeWarning = (_event: IpcRendererEvent, ...args: unknown[]) => { + const recipe = args[0] as Recipe; + console.log('Received new recipe warning:', recipe); + setPendingRecipe(recipe); + setRecipeWarningVisible(true); + }; + window.electron.on('new-recipe-warning', handleNewRecipeWarning); + return () => { + window.electron.off('new-recipe-warning', handleNewRecipeWarning); + }; + }, []); + + const handleRecipeConfirm = () => { + if (pendingRecipe) { + console.log('Executing new recipe:', pendingRecipe); + window.electron.createChatWindow( + undefined, + undefined, + undefined, + undefined, + pendingRecipe + ); + } + setRecipeWarningVisible(false); + setPendingRecipe(null); + }; + + const handleRecipeCancel = () => { + console.log('Cancelled recipe execution'); + setRecipeWarningVisible(false); + setPendingRecipe(null); + }; const [modalMessage, setModalMessage] = useState(''); const [extensionConfirmLabel, setExtensionConfirmLabel] = useState(''); const [extensionConfirmTitle, setExtensionConfirmTitle] = useState(''); @@ -525,6 +562,14 @@ export default function App() { closeOnClick pauseOnHover /> + {recipeWarningVisible && pendingRecipe && ( + + )} {modalVisible && ( void; + onCancel: () => void; + recipeDetails: { + title?: string; + description?: string; + instructions?: string; + }; +} + +export function RecipeWarningModal({ + isOpen, + onConfirm, + onCancel, + recipeDetails, +}: RecipeWarningModalProps) { + if (!isOpen) return null; + + return ( +
{ + if (e.target === e.currentTarget) onCancel(); + }} + > + +
+

⚠️ New Recipe Warning

+

+ You are about to execute a recipe that you haven't run before. Only proceed if you trust the source of this recipe. +

+ +
+

Recipe Details:

+
+ {recipeDetails.title && ( +

+ Title: {recipeDetails.title} +

+ )} + {recipeDetails.description && ( +

+ Description: {recipeDetails.description} +

+ )} + {recipeDetails.instructions && ( +

+ Instructions: {recipeDetails.instructions} +

+ )} +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index b86e34d3aaaa..629915aaf9dd 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -640,6 +640,23 @@ const createChat = async ( return { action: 'allow' }; }); + // If this is a recipe execution, check if we've seen it before + if (recipeConfig) { + try { + const { checkAndRecordRecipe } = require('./utils/recipeHash'); + const hasSeenBefore = await checkAndRecordRecipe(recipeConfig); + + if (!hasSeenBefore) { + // Send event to renderer to show warning + mainWindow.webContents.send('new-recipe-warning', recipeConfig); + return mainWindow; + } + } catch (error) { + console.error('Error checking recipe hash:', error); + // Continue with execution on error + } + } + // Load the index.html of the app. let queryParams = ''; if (query) { diff --git a/ui/desktop/src/utils/recipeHash.ts b/ui/desktop/src/utils/recipeHash.ts new file mode 100644 index 000000000000..f712111e85fa --- /dev/null +++ b/ui/desktop/src/utils/recipeHash.ts @@ -0,0 +1,97 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'crypto'; +import { app } from 'electron'; + +// File to store recipe hashes +const RECIPE_HASHES_FILE = 'recipe_hashes.json'; + +interface RecipeHash { + hash: string; + firstSeenAt: string; + lastExecutedAt: string; + executionCount: number; +} + +interface RecipeHashes { + [hash: string]: RecipeHash; +} + +/** + * Get the path to the recipe hashes file + */ +export async function getRecipeHashesPath(): Promise { + const userDataPath = app.getPath('userData'); + const hashesPath = path.join(userDataPath, RECIPE_HASHES_FILE); + + // Ensure the directory exists + await fs.mkdir(path.dirname(hashesPath), { recursive: true }); + + return hashesPath; +} + +/** + * Load stored recipe hashes + */ +export async function loadRecipeHashes(): Promise { + try { + const hashesPath = await getRecipeHashesPath(); + const data = await fs.readFile(hashesPath, 'utf8'); + return JSON.parse(data); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + // File doesn't exist yet, return empty object + return {}; + } + throw error; + } +} + +/** + * Save recipe hashes to storage + */ +export async function saveRecipeHashes(hashes: RecipeHashes): Promise { + const hashesPath = await getRecipeHashesPath(); + await fs.writeFile(hashesPath, JSON.stringify(hashes, null, 2)); +} + +/** + * Calculate hash for a recipe configuration + */ +export function calculateRecipeHash(recipeConfig: unknown): string { + const hash = crypto.createHash('sha256'); + hash.update(JSON.stringify(recipeConfig)); + return hash.digest('hex'); +} + +/** + * Check if a recipe has been seen before and record this execution + * Returns true if recipe has been seen before, false if it's new + */ +export async function checkAndRecordRecipe(recipeConfig: unknown): Promise { + const hash = calculateRecipeHash(recipeConfig); + const hashes = await loadRecipeHashes(); + + const now = new Date().toISOString(); + + if (hash in hashes) { + // Update existing recipe hash record + hashes[hash] = { + ...hashes[hash], + lastExecutedAt: now, + executionCount: (hashes[hash].executionCount || 0) + 1 + }; + await saveRecipeHashes(hashes); + return true; + } + + // Record new recipe hash + hashes[hash] = { + hash, + firstSeenAt: now, + lastExecutedAt: now, + executionCount: 1 + }; + await saveRecipeHashes(hashes); + return false; +} From 679adb9d553c369e8face144dbed25a84c19a773 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 14 Jul 2025 14:23:00 +0200 Subject: [PATCH 2/9] fix: wrap continueInitialization in useCallback and move after dependencies --- ui/desktop/src/App.tsx | 1207 ++++++++++++++++++++-------------------- 1 file changed, 615 insertions(+), 592 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 03603c5f3c18..adf3cafe7e47 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,662 +1,685 @@ -import { useEffect, useRef, useState } from 'react'; -import { IpcRendererEvent } from 'electron'; -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 { RecipeWarningModal } from './components/ui/RecipeWarningModal'; -import { ToastContainer } from 'react-toastify'; -import { toastService } from './toasts'; -import { extractExtensionName } from './components/settings/extensions/utils'; -import { GoosehintsModal } from './components/GoosehintsModal'; -import { type ExtensionConfig } from './extensions'; -import { type Recipe } from './recipe'; +import {useEffect, useRef, useState, useCallback} from 'react'; +import {IpcRendererEvent} from 'electron'; +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 {RecipeWarningModal} from './components/ui/RecipeWarningModal'; +import {ToastContainer} from 'react-toastify'; +import {toastService} from './toasts'; +import {extractExtensionName} from './components/settings/extensions/utils'; +import {GoosehintsModal} from './components/GoosehintsModal'; +import {type ExtensionConfig} from './extensions'; +import {type Recipe} from './recipe'; import AnnouncementModal from './components/AnnouncementModal'; import ChatView from './components/ChatView'; import SuspenseLoader from './suspense-loader'; -import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView'; +import SettingsView, {SettingsViewOptions} from './components/settings/SettingsView'; import SessionsView from './components/sessions/SessionsView'; import SharedSessionView from './components/sessions/SharedSessionView'; import SchedulesView from './components/schedule/SchedulesView'; import ProviderSettings from './components/settings/providers/ProviderSettingsPage'; import RecipeEditor from './components/RecipeEditor'; import RecipesView from './components/RecipesView'; -import { useChat } from './hooks/useChat'; +import {useChat} from './hooks/useChat'; import 'react-toastify/dist/ReactToastify.css'; -import { useConfig, MalformedConfigError } from './components/ConfigContext'; -import { ModelAndProviderProvider } from './components/ModelAndProviderContext'; -import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings/extensions'; +import {useConfig, MalformedConfigError} from './components/ConfigContext'; +import {ModelAndProviderProvider} from './components/ModelAndProviderContext'; +import {addExtensionFromDeepLink as addExtensionFromDeepLinkV2} from './components/settings/extensions'; import { - backupConfig, - initConfig, - readAllConfig, - recoverConfig, - validateConfig, + backupConfig, + initConfig, + readAllConfig, + recoverConfig, + validateConfig, } from './api/sdk.gen'; import PermissionSettingsView from './components/settings/permission/PermissionSetting'; -import { type SessionDetails } from './sessions'; +import {type SessionDetails} from './sessions'; +import {recordRecipeHash} from "./utils/recipeHash"; export type View = - | 'welcome' - | 'chat' - | 'settings' - | 'moreModels' - | 'configureProviders' - | 'configPage' - | 'ConfigureProviders' - | 'settingsV2' - | 'sessions' - | 'schedules' - | 'sharedSession' - | 'loading' - | 'recipeEditor' - | 'recipes' - | 'permission'; + | 'welcome' + | 'chat' + | 'settings' + | '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; + // 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; + view: View; + viewOptions?: ViewOptions; }; const getInitialView = (): ViewConfig => { - const urlParams = new URLSearchParams(window.location.search); - const viewFromUrl = urlParams.get('view'); - const windowConfig = window.electron.getConfig(); + const urlParams = new URLSearchParams(window.location.search); + const viewFromUrl = urlParams.get('view'); + const windowConfig = window.electron.getConfig(); + + if (viewFromUrl === 'recipeEditor' && windowConfig?.recipeConfig) { + return { + view: 'recipeEditor', + viewOptions: { + config: windowConfig.recipeConfig, + }, + }; + } - if (viewFromUrl === 'recipeEditor' && windowConfig?.recipeConfig) { - return { - view: 'recipeEditor', - viewOptions: { - config: windowConfig.recipeConfig, - }, - }; - } + if (viewFromUrl) { + return { + view: viewFromUrl as View, + viewOptions: {}, + }; + } - if (viewFromUrl) { return { - view: viewFromUrl as View, - viewOptions: {}, + view: 'loading', + viewOptions: {}, }; - } - - return { - view: 'loading', - viewOptions: {}, - }; }; export default function App() { - const [fatalError, setFatalError] = useState(null); - const [modalVisible, setModalVisible] = useState(false); - const [pendingRecipe, setPendingRecipe] = useState(null); - const [recipeWarningVisible, setRecipeWarningVisible] = useState(false); - - useEffect(() => { - console.log('Setting up new recipe warning handler'); - const handleNewRecipeWarning = (_event: IpcRendererEvent, ...args: unknown[]) => { - const recipe = args[0] as Recipe; - console.log('Received new recipe warning:', recipe); - setPendingRecipe(recipe); - setRecipeWarningVisible(true); - }; - window.electron.on('new-recipe-warning', handleNewRecipeWarning); - return () => { - window.electron.off('new-recipe-warning', handleNewRecipeWarning); + const [fatalError, setFatalError] = useState(null); + const [pendingLink, setPendingLink] = useState(null); + const [modalVisible, setModalVisible] = useState(false); + const [pendingRecipe, setPendingRecipe] = useState(null); + const [recipeWarningVisible, setRecipeWarningVisible] = useState(false); + + useEffect(() => { + console.log('Setting up new recipe warning handler'); + const handleNewRecipeWarning = (_event: IpcRendererEvent, ...args: unknown[]) => { + const recipe = args[0] as Recipe; + console.log('Received new recipe warning:', recipe); + setPendingRecipe(recipe); + setRecipeWarningVisible(true); + }; + window.electron.on('new-recipe-warning', handleNewRecipeWarning); + return () => { + window.electron.off('new-recipe-warning', handleNewRecipeWarning); + }; + }, []); + + useEffect(() => { + const recipeConfig = window.appConfig.get('recipeConfig'); + if (recipeConfig && !recipeWarningVisible && !pendingRecipe) { + setTimeout(() => { + continueInitialization().catch((error: Error) => { + console.error('Error in recipe initialization:', error); + setFatalError(`Recipe initialization failed: ${error.message || 'Unknown error'}`); + }); + }, 100); // Small delay to ensure React is fully ready + } + }, [recipeWarningVisible, pendingRecipe, continueInitialization]); + + const handleRecipeConfirm = async () => { + if (pendingRecipe) { + console.log('Executing new recipe:', pendingRecipe); + await recordRecipeHash(pendingRecipe); + setRecipeWarningVisible(false); + setPendingRecipe(null); + await continueInitialization(); + } }; - }, []); - - const handleRecipeConfirm = () => { - if (pendingRecipe) { - console.log('Executing new recipe:', pendingRecipe); - window.electron.createChatWindow( - undefined, - undefined, - undefined, - undefined, - pendingRecipe - ); - } - setRecipeWarningVisible(false); - setPendingRecipe(null); - }; - - const handleRecipeCancel = () => { - console.log('Cancelled recipe execution'); - setRecipeWarningVisible(false); - setPendingRecipe(null); - }; - const [modalMessage, setModalMessage] = useState(''); - const [extensionConfirmLabel, setExtensionConfirmLabel] = useState(''); - const [extensionConfirmTitle, setExtensionConfirmTitle] = useState(''); - const [{ view, viewOptions }, setInternalView] = useState(getInitialView()); - - const { getExtensions, addExtension, read } = useConfig(); - const initAttemptedRef = useRef(false); - - function extractCommand(link: string): string { - const url = new URL(link); - const cmd = url.searchParams.get('cmd') || 'Unknown Command'; - const args = url.searchParams.getAll('arg').map(decodeURIComponent); - return `${cmd} ${args.join(' ')}`.trim(); - } - - function extractRemoteUrl(link: string): string | null { - const url = new URL(link); - return url.searchParams.get('url'); - } - - const setView = (view: View, viewOptions: ViewOptions = {}) => { - console.log(`Setting view to: ${view}`, viewOptions); - setInternalView({ view, viewOptions }); - }; - - useEffect(() => { - if (initAttemptedRef.current) { - console.log('Initialization already attempted, skipping...'); - return; - } - initAttemptedRef.current = true; - console.log(`Initializing app with settings v2`); + const handleRecipeCancel = () => { + console.log('Cancelled recipe execution'); + setRecipeWarningVisible(false); + setPendingRecipe(null); + // Close the current window since user cancelled recipe execution + window.electron.hideWindow(); + }; + const [modalMessage, setModalMessage] = useState(''); + const [extensionConfirmLabel, setExtensionConfirmLabel] = useState(''); + const [extensionConfirmTitle, setExtensionConfirmTitle] = useState(''); + const [{view, viewOptions}, setInternalView] = useState(getInitialView()); - const urlParams = new URLSearchParams(window.location.search); - const viewType = urlParams.get('view'); - const recipeConfig = window.appConfig.get('recipeConfig'); - - if (viewType) { - if (viewType === 'recipeEditor' && recipeConfig) { - console.log('Setting view to recipeEditor with config:', recipeConfig); - setView('recipeEditor', { config: recipeConfig }); - } else { - setView(viewType as View); - } - return; - } + const {getExtensions, addExtension, read} = useConfig(); + const initAttemptedRef = useRef(false); - const initializeApp = async () => { - try { - // Initialize cost database early to pre-load pricing data - initializeCostDatabase().catch((error) => { - console.error('Failed to initialize cost database:', error); - }); + const continueInitialization = useCallback(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 initConfig(); - try { - await readAllConfig({ throwOnError: true }); - } catch (error) { - const configVersion = localStorage.getItem('configVersion'); - const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3; - if (shouldMigrateExtensions) { - await backupConfig({ throwOnError: true }); - await initConfig(); - } else { - // Config appears corrupted, try recovery - console.warn('Config file appears corrupted, attempting recovery...'); + if (provider && model) { + setView('chat'); try { - // First try to validate the config - try { - await validateConfig({ throwOnError: true }); - // Config is valid but readAllConfig failed for another reason - throw new Error('Unable to read config file, it may be malformed'); - } catch (validateError) { - console.log('Config validation failed, attempting recovery...'); - - // Try to recover the config - try { - const recoveryResult = await recoverConfig({ throwOnError: true }); - console.log('Config recovery result:', recoveryResult); - - // Try to read config again after recovery - try { - await readAllConfig({ throwOnError: true }); - console.log('Config successfully recovered and loaded'); - } catch (retryError) { - console.warn('Config still corrupted after recovery, reinitializing...'); - await initConfig(); - } - } catch (recoverError) { - console.warn('Config recovery failed, reinitializing...'); - await initConfig(); + await initializeSystem(provider as string, model as string, { + getExtensions, + addExtension, + }); + } catch (error) { + console.error('Error in initialization:', error); + if (error instanceof MalformedConfigError) { + throw error; } - } - } catch (recoveryError) { - console.error('Config recovery process failed:', recoveryError); - throw new Error('Unable to read config file, it may be malformed'); + setView('welcome'); } - } + } else { + console.log('Missing required configuration, showing onboarding'); + setView('welcome'); } + }, [read, setView, getExtensions, addExtension]); + + function extractCommand(link: string): string { + const url = new URL(link); + const cmd = url.searchParams.get('cmd') || 'Unknown Command'; + const args = url.searchParams.getAll('arg').map(decodeURIComponent); + return `${cmd} ${args.join(' ')}`.trim(); + } - if (recipeConfig === null) { - setFatalError('Cannot read recipe config. Please check the deeplink and try again.'); - return; + function extractRemoteUrl(link: string): string | null { + const url = new URL(link); + return url.searchParams.get('url'); + } + + const setView = (view: View, viewOptions: ViewOptions = {}) => { + console.log(`Setting view to: ${view}`, viewOptions); + setInternalView({view, viewOptions}); + }; + + useEffect(() => { + if (initAttemptedRef.current) { + console.log('Initialization already attempted, skipping...'); + return; } + initAttemptedRef.current = 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; + console.log(`Initializing app with settings v2`); - if (provider && model) { - setView('chat'); - try { - await initializeSystem(provider as string, model as string, { - getExtensions, - addExtension, - }); - } catch (error) { - console.error('Error in initialization:', error); - if (error instanceof MalformedConfigError) { - throw error; + const urlParams = new URLSearchParams(window.location.search); + const viewType = urlParams.get('view'); + const recipeConfig = window.appConfig.get('recipeConfig'); + + if (viewType) { + if (viewType === 'recipeEditor' && recipeConfig) { + console.log('Setting view to recipeEditor with config:', recipeConfig); + setView('recipeEditor', {config: recipeConfig}); + } else { + setView(viewType as View); } - setView('welcome'); - } - } else { - console.log('Missing required configuration, showing onboarding'); - setView('welcome'); + return; } - } catch (error) { - setFatalError( - `Initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - setView('welcome'); - } - toastService.configure({ silent: false }); - }; - initializeApp().catch((error) => { - console.error('Unhandled error in initialization:', error); - setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`); - }); - }, [read, getExtensions, addExtension]); - - const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); - const [isLoadingSession, setIsLoadingSession] = useState(false); - const [sharedSessionError, setSharedSessionError] = useState(null); - const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false); - const { chat, setChat } = useChat({ setView, setIsLoadingSession }); - - useEffect(() => { - console.log('Sending reactReady signal to Electron'); - try { - window.electron.reactReady(); - } catch (error) { - console.error('Error sending reactReady:', error); - setFatalError( - `React ready notification failed: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } - }, []); - - useEffect(() => { - const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => { - const link = args[0] as string; - window.electron.logInfo(`Opening shared session from deep link ${link}`); - setIsLoadingSharedSession(true); - setSharedSessionError(null); - try { - await openSharedSessionFromDeepLink( - link, - (view: View, options?: SessionLinksViewOptions) => { - setView(view, options as ViewOptions); - } - ); - } catch (error) { - console.error('Unexpected error opening shared session:', error); - setView('sessions'); - } finally { - setIsLoadingSharedSession(false); - } - }; - window.electron.on('open-shared-session', handleOpenSharedSession); - return () => { - window.electron.off('open-shared-session', handleOpenSharedSession); - }; - }, []); - - useEffect(() => { - console.log('Setting up keyboard shortcuts'); - const handleKeyDown = (event: KeyboardEvent) => { - const isMac = window.electron.platform === 'darwin'; - if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') { - event.preventDefault(); + // If we have a recipe config, we might be waiting for a warning to be shown + // Don't continue with initialization yet - wait for the warning event + if (recipeConfig) { + console.log('Recipe config detected, waiting for potential warning...'); + setView('loading'); + return; + } + + const initializeApp = async () => { + try { + // Initialize cost database early to pre-load pricing data + initializeCostDatabase().catch((error) => { + console.error('Failed to initialize cost database:', error); + }); + + await initConfig(); + try { + await readAllConfig({throwOnError: true}); + } catch (error) { + const configVersion = localStorage.getItem('configVersion'); + const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3; + if (shouldMigrateExtensions) { + await backupConfig({throwOnError: true}); + await initConfig(); + } else { + // Config appears corrupted, try recovery + console.warn('Config file appears corrupted, attempting recovery...'); + try { + // First try to validate the config + try { + await validateConfig({throwOnError: true}); + // Config is valid but readAllConfig failed for another reason + throw new Error('Unable to read config file, it may be malformed'); + } catch (validateError) { + console.log('Config validation failed, attempting recovery...'); + + // Try to recover the config + try { + const recoveryResult = await recoverConfig({throwOnError: true}); + console.log('Config recovery result:', recoveryResult); + + // Try to read config again after recovery + try { + await readAllConfig({throwOnError: true}); + console.log('Config successfully recovered and loaded'); + } catch (retryError) { + console.warn('Config still corrupted after recovery, reinitializing...'); + await initConfig(); + } + } catch (recoverError) { + console.warn('Config recovery failed, reinitializing...'); + await initConfig(); + } + } + } catch (recoveryError) { + console.error('Config recovery process failed:', recoveryError); + throw new Error('Unable to read config file, it may be malformed'); + } + } + } + + if (recipeConfig === null) { + setFatalError('Cannot read recipe config. Please check the deeplink and try again.'); + return; + } + + await continueInitialization(); + } catch (error) { + setFatalError( + `Initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + setView('welcome'); + } + toastService.configure({silent: false}); + }; + + initializeApp().catch((error) => { + console.error('Unhandled error in initialization:', error); + setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`); + }); + }, [read, getExtensions, addExtension, continueInitialization]); + + const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); + const [isLoadingSession, setIsLoadingSession] = useState(false); + const [sharedSessionError, setSharedSessionError] = useState(null); + const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false); + const {chat, setChat} = useChat({setView, setIsLoadingSession}); + + useEffect(() => { + console.log('Sending reactReady signal to Electron'); try { - 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); + window.electron.reactReady(); } catch (error) { - console.error('Error creating new window:', error); + console.error('Error sending reactReady:', error); + setFatalError( + `React ready notification failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); } - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - }, []); - - 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('Current view:', view); - console.error('Is loading session:', isLoadingSession); - setFatalError(errorMessage); - }; - window.electron.on('fatal-error', handleFatalError); - return () => { - window.electron.off('fatal-error', handleFatalError); - }; - }, [view, 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; - console.log( - `Received view change request to: ${newView}${section ? `, section: ${section}` : ''}` - ); - - if (section && newView === 'settings') { - setView(newView, { section }); - } else { - setView(newView); - } - }; - const urlParams = new URLSearchParams(window.location.search); - const viewFromUrl = urlParams.get('view'); - if (viewFromUrl) { - const windowConfig = window.electron.getConfig(); - if (viewFromUrl === 'recipeEditor') { - const initialViewOptions = { - recipeConfig: windowConfig?.recipeConfig, - view: viewFromUrl, + }, []); + + useEffect(() => { + const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => { + const link = args[0] as string; + window.electron.logInfo(`Opening shared session from deep link ${link}`); + setIsLoadingSharedSession(true); + setSharedSessionError(null); + try { + await openSharedSessionFromDeepLink( + link, + (view: View, options?: SessionLinksViewOptions) => { + setView(view, options as ViewOptions); + } + ); + } catch (error) { + console.error('Unexpected error opening shared session:', error); + setView('sessions'); + } finally { + setIsLoadingSharedSession(false); + } }; - setView(viewFromUrl, initialViewOptions); - } else { - setView(viewFromUrl as View); - } - } - window.electron.on('set-view', handleSetView); - return () => window.electron.off('set-view', handleSetView); - }, []); - - useEffect(() => { - console.log(`View changed to: ${view}`); - if (view !== 'chat' && view !== 'recipeEditor') { - console.log('Not in chat view, clearing loading session state'); - setIsLoadingSession(false); - } - }, [view]); - - const config = window.electron.getConfig(); - const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING === true ? false : true; - - useEffect(() => { - console.log('Setting up extension handler'); - const handleAddExtension = async (_event: IpcRendererEvent, ...args: unknown[]) => { - const link = args[0] as string; - try { - console.log(`Received add-extension event with link: ${link}`); - const command = extractCommand(link); - const remoteUrl = extractRemoteUrl(link); - const extName = extractExtensionName(link); - window.electron.logInfo(`Adding extension from deep link ${link}`); - setPendingLink(link); - let warningMessage = ''; - let label = 'OK'; - let title = 'Confirm Extension Installation'; - let isBlocked = false; - let useDetailedMessage = false; - if (remoteUrl) { - useDetailedMessage = true; - } else { - try { - const allowedCommands = await window.electron.getAllowedExtensions(); - if (allowedCommands && allowedCommands.length > 0) { - const isCommandAllowed = allowedCommands.some((allowedCmd) => - command.startsWith(allowedCmd) - ); - if (!isCommandAllowed) { - useDetailedMessage = true; - title = '⛔️ Untrusted Extension ⛔️'; - if (STRICT_ALLOWLIST) { - isBlocked = true; - label = 'Extension Blocked'; - warningMessage = - '\n\n⛔️ BLOCKED: This extension command is not in the allowed list. ' + - 'Installation is blocked by your administrator. ' + - 'Please contact your administrator if you need this extension.'; - } else { - label = 'Override and install'; - warningMessage = - '\n\n⚠️ WARNING: This extension command is not in the allowed list. ' + - 'Installing extensions from untrusted sources may pose security risks. ' + - 'Please contact an admin if you are unsure or want to allow this extension.'; + window.electron.on('open-shared-session', handleOpenSharedSession); + return () => { + window.electron.off('open-shared-session', handleOpenSharedSession); + }; + }, []); + + useEffect(() => { + console.log('Setting up keyboard shortcuts'); + const handleKeyDown = (event: KeyboardEvent) => { + const isMac = window.electron.platform === 'darwin'; + if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') { + event.preventDefault(); + try { + 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) { + console.error('Error creating new window:', error); } - } } - } catch (error) { - console.error('Error checking allowlist:', error); - } + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + 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('Current view:', view); + console.error('Is loading session:', isLoadingSession); + setFatalError(errorMessage); + }; + window.electron.on('fatal-error', handleFatalError); + return () => { + window.electron.off('fatal-error', handleFatalError); + }; + }, [view, 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; + console.log( + `Received view change request to: ${newView}${section ? `, section: ${section}` : ''}` + ); + + if (section && newView === 'settings') { + setView(newView, {section}); + } else { + setView(newView); + } + }; + const urlParams = new URLSearchParams(window.location.search); + const viewFromUrl = urlParams.get('view'); + if (viewFromUrl) { + const windowConfig = window.electron.getConfig(); + if (viewFromUrl === 'recipeEditor') { + const initialViewOptions = { + recipeConfig: windowConfig?.recipeConfig, + view: viewFromUrl, + }; + setView(viewFromUrl, initialViewOptions); + } else { + setView(viewFromUrl as View); + } } - if (useDetailedMessage) { - const detailedMessage = remoteUrl - ? `You are about to install the ${extName} extension which connects to:\n\n${remoteUrl}\n\nThis extension will be able to access your conversations and provide additional functionality.` - : `You are about to install the ${extName} extension which runs the command:\n\n${command}\n\nThis extension will be able to access your conversations and provide additional functionality.`; - setModalMessage(`${detailedMessage}${warningMessage}`); - } else { - const messageDetails = `Command: ${command}`; - setModalMessage( - `Are you sure you want to install the ${extName} extension?\n\n${messageDetails}` - ); + window.electron.on('set-view', handleSetView); + return () => window.electron.off('set-view', handleSetView); + }, []); + + useEffect(() => { + console.log(`View changed to: ${view}`); + if (view !== 'chat' && view !== 'recipeEditor') { + console.log('Not in chat view, clearing loading session state'); + setIsLoadingSession(false); } - setExtensionConfirmLabel(label); - setExtensionConfirmTitle(title); - if (isBlocked) { - setPendingLink(null); + }, [view]); + + const config = window.electron.getConfig(); + const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING === true ? false : true; + + useEffect(() => { + console.log('Setting up extension handler'); + const handleAddExtension = async (_event: IpcRendererEvent, ...args: unknown[]) => { + const link = args[0] as string; + try { + console.log(`Received add-extension event with link: ${link}`); + const command = extractCommand(link); + const remoteUrl = extractRemoteUrl(link); + const extName = extractExtensionName(link); + window.electron.logInfo(`Adding extension from deep link ${link}`); + setPendingLink(link); + let warningMessage = ''; + let label = 'OK'; + let title = 'Confirm Extension Installation'; + let isBlocked = false; + let useDetailedMessage = false; + if (remoteUrl) { + useDetailedMessage = true; + } else { + try { + const allowedCommands = await window.electron.getAllowedExtensions(); + if (allowedCommands && allowedCommands.length > 0) { + const isCommandAllowed = allowedCommands.some((allowedCmd) => + command.startsWith(allowedCmd) + ); + if (!isCommandAllowed) { + useDetailedMessage = true; + title = '⛔️ Untrusted Extension ⛔️'; + if (STRICT_ALLOWLIST) { + isBlocked = true; + label = 'Extension Blocked'; + warningMessage = + '\n\n⛔️ BLOCKED: This extension command is not in the allowed list. ' + + 'Installation is blocked by your administrator. ' + + 'Please contact your administrator if you need this extension.'; + } else { + label = 'Override and install'; + warningMessage = + '\n\n⚠️ WARNING: This extension command is not in the allowed list. ' + + 'Installing extensions from untrusted sources may pose security risks. ' + + 'Please contact an admin if you are unsure or want to allow this extension.'; + } + } + } + } catch (error) { + console.error('Error checking allowlist:', error); + } + } + if (useDetailedMessage) { + const detailedMessage = remoteUrl + ? `You are about to install the ${extName} extension which connects to:\n\n${remoteUrl}\n\nThis extension will be able to access your conversations and provide additional functionality.` + : `You are about to install the ${extName} extension which runs the command:\n\n${command}\n\nThis extension will be able to access your conversations and provide additional functionality.`; + setModalMessage(`${detailedMessage}${warningMessage}`); + } else { + const messageDetails = `Command: ${command}`; + setModalMessage( + `Are you sure you want to install the ${extName} extension?\n\n${messageDetails}` + ); + } + setExtensionConfirmLabel(label); + setExtensionConfirmTitle(title); + if (isBlocked) { + setPendingLink(null); + } + setModalVisible(true); + } catch (error) { + console.error('Error handling add-extension event:', error); + } + }; + window.electron.on('add-extension', handleAddExtension); + return () => { + window.electron.off('add-extension', handleAddExtension); + }; + }, [STRICT_ALLOWLIST]); + + useEffect(() => { + const handleFocusInput = (_event: IpcRendererEvent, ..._args: unknown[]) => { + const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement; + if (inputField) { + inputField.focus(); + } + }; + window.electron.on('focus-input', handleFocusInput); + return () => { + window.electron.off('focus-input', handleFocusInput); + }; + }, []); + + const handleConfirm = async () => { + if (pendingLink) { + console.log(`Confirming installation of extension from: ${pendingLink}`); + setModalVisible(false); + try { + await addExtensionFromDeepLinkV2(pendingLink, addExtension, (view: string, options) => { + setView(view as View, options as ViewOptions); + }); + console.log('Extension installation successful'); + } catch (error) { + console.error('Failed to add extension:', error); + } finally { + setPendingLink(null); + } + } else { + console.log('Extension installation blocked by allowlist restrictions'); + setModalVisible(false); } - setModalVisible(true); - } catch (error) { - console.error('Error handling add-extension event:', error); - } - }; - window.electron.on('add-extension', handleAddExtension); - return () => { - window.electron.off('add-extension', handleAddExtension); - }; - }, [STRICT_ALLOWLIST]); - - useEffect(() => { - const handleFocusInput = (_event: IpcRendererEvent, ..._args: unknown[]) => { - const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement; - if (inputField) { - inputField.focus(); - } - }; - window.electron.on('focus-input', handleFocusInput); - return () => { - window.electron.off('focus-input', handleFocusInput); }; - }, []); - - const handleConfirm = async () => { - if (pendingLink) { - console.log(`Confirming installation of extension from: ${pendingLink}`); - setModalVisible(false); - try { - await addExtensionFromDeepLinkV2(pendingLink, addExtension, (view: string, options) => { - setView(view as View, options as ViewOptions); - }); - console.log('Extension installation successful'); - } catch (error) { - console.error('Failed to add extension:', error); - } finally { + + const handleCancel = () => { + console.log('Cancelled extension installation.'); + setModalVisible(false); setPendingLink(null); - } - } else { - console.log('Extension installation blocked by allowlist restrictions'); - setModalVisible(false); - } - }; + }; - const handleCancel = () => { - console.log('Cancelled extension installation.'); - setModalVisible(false); - setPendingLink(null); - }; + if (fatalError) { + return ; + } - if (fatalError) { - return ; - } + if (isLoadingSession) + return ( +
+
+
+ ); - if (isLoadingSession) return ( -
-
-
- ); - - return ( - - - `relative min-h-16 mb-4 p-2 rounded-lg + + + `relative min-h-16 mb-4 p-2 rounded-lg flex justify-between overflow-hidden cursor-pointer text-textProminentInverse bg-bgStandardInverse dark:bg-bgAppInverse ` - } - style={{ width: '380px' }} - className="mt-6" - position="top-right" - autoClose={3000} - closeOnClick - pauseOnHover - /> - {recipeWarningVisible && pendingRecipe && ( - - )} - {modalVisible && ( - - )} -
-
-
- {view === 'loading' && } - {view === 'welcome' && ( - setView('chat')} isOnboarding={true} /> - )} - {view === 'settings' && ( - { - setView('chat'); - }} - setView={setView} - viewOptions={viewOptions as SettingsViewOptions} - /> - )} - {view === 'ConfigureProviders' && ( - setView('chat')} isOnboarding={false} /> - )} - {view === 'chat' && !isLoadingSession && ( - - )} - {view === 'sessions' && } - {view === 'schedules' && setView('chat')} />} - {view === 'sharedSession' && ( - setView('sessions')} - onRetry={async () => { - if (viewOptions?.shareToken && viewOptions?.baseUrl) { - setIsLoadingSharedSession(true); - try { - await openSharedSessionFromDeepLink( - `goose://sessions/${viewOptions.shareToken}`, - (view: View, options?: SessionLinksViewOptions) => { - setView(view, options as ViewOptions); - }, - viewOptions.baseUrl - ); - } catch (error) { - console.error('Failed to retry loading shared session:', error); - } finally { - setIsLoadingSharedSession(false); - } } - }} - /> - )} - {view === 'recipeEditor' && ( - - )} - {view === 'recipes' && setView('chat')} />} - {view === 'permission' && ( - setView((viewOptions as { parentView: View }).parentView)} - /> - )} -
-
- {isGoosehintsModalOpen && ( - - )} - - - ); + {recipeWarningVisible && pendingRecipe && ( + + )} + {modalVisible && ( + + )} +
+
+
+ {view === 'loading' && } + {view === 'welcome' && ( + setView('chat')} isOnboarding={true}/> + )} + {view === 'settings' && ( + { + setView('chat'); + }} + setView={setView} + viewOptions={viewOptions as SettingsViewOptions} + /> + )} + {view === 'ConfigureProviders' && ( + setView('chat')} isOnboarding={false}/> + )} + {view === 'chat' && !isLoadingSession && ( + + )} + {view === 'sessions' && } + {view === 'schedules' && setView('chat')}/>} + {view === 'sharedSession' && ( + setView('sessions')} + onRetry={async () => { + if (viewOptions?.shareToken && viewOptions?.baseUrl) { + setIsLoadingSharedSession(true); + try { + await openSharedSessionFromDeepLink( + `goose://sessions/${viewOptions.shareToken}`, + (view: View, options?: SessionLinksViewOptions) => { + setView(view, options as ViewOptions); + }, + viewOptions.baseUrl + ); + } catch (error) { + console.error('Failed to retry loading shared session:', error); + } finally { + setIsLoadingSharedSession(false); + } + } + }} + /> + )} + {view === 'recipeEditor' && ( + + )} + {view === 'recipes' && setView('chat')}/>} + {view === 'permission' && ( + setView((viewOptions as { parentView: View }).parentView)} + /> + )} +
+
+ {isGoosehintsModalOpen && ( + + )} + + + ); } From fd43f4719d1980f86cb2d7c5bf8d0c7cf7f44ac3 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 14 Jul 2025 15:51:48 +0200 Subject: [PATCH 3/9] WIP --- ui/desktop/src/App.tsx | 1164 ++++++++--------- .../src/components/ui/RecipeWarningModal.tsx | 4 +- ui/desktop/src/utils/recipeHash.ts | 98 +- 3 files changed, 570 insertions(+), 696 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index adf3cafe7e47..14c72ac5692d 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,685 +1,617 @@ -import {useEffect, useRef, useState, useCallback} from 'react'; -import {IpcRendererEvent} from 'electron'; -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 {RecipeWarningModal} from './components/ui/RecipeWarningModal'; -import {ToastContainer} from 'react-toastify'; -import {toastService} from './toasts'; -import {extractExtensionName} from './components/settings/extensions/utils'; -import {GoosehintsModal} from './components/GoosehintsModal'; -import {type ExtensionConfig} from './extensions'; -import {type Recipe} from './recipe'; +import { useEffect, useRef, useState } from 'react'; +import { IpcRendererEvent } from 'electron'; +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 { toastService } from './toasts'; +import { extractExtensionName } from './components/settings/extensions/utils'; +import { GoosehintsModal } from './components/GoosehintsModal'; +import { type ExtensionConfig } from './extensions'; +import { type Recipe } from './recipe'; import AnnouncementModal from './components/AnnouncementModal'; import ChatView from './components/ChatView'; import SuspenseLoader from './suspense-loader'; -import SettingsView, {SettingsViewOptions} from './components/settings/SettingsView'; +import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView'; import SessionsView from './components/sessions/SessionsView'; import SharedSessionView from './components/sessions/SharedSessionView'; import SchedulesView from './components/schedule/SchedulesView'; import ProviderSettings from './components/settings/providers/ProviderSettingsPage'; import RecipeEditor from './components/RecipeEditor'; import RecipesView from './components/RecipesView'; -import {useChat} from './hooks/useChat'; +import { useChat } from './hooks/useChat'; import 'react-toastify/dist/ReactToastify.css'; -import {useConfig, MalformedConfigError} from './components/ConfigContext'; -import {ModelAndProviderProvider} from './components/ModelAndProviderContext'; -import {addExtensionFromDeepLink as addExtensionFromDeepLinkV2} from './components/settings/extensions'; +import { useConfig, MalformedConfigError } from './components/ConfigContext'; +import { ModelAndProviderProvider } from './components/ModelAndProviderContext'; +import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings/extensions'; import { - backupConfig, - initConfig, - readAllConfig, - recoverConfig, - validateConfig, + backupConfig, + initConfig, + readAllConfig, + recoverConfig, + validateConfig, } from './api/sdk.gen'; import PermissionSettingsView from './components/settings/permission/PermissionSetting'; -import {type SessionDetails} from './sessions'; -import {recordRecipeHash} from "./utils/recipeHash"; +import { type SessionDetails } from './sessions'; export type View = - | 'welcome' - | 'chat' - | 'settings' - | 'moreModels' - | 'configureProviders' - | 'configPage' - | 'ConfigureProviders' - | 'settingsV2' - | 'sessions' - | 'schedules' - | 'sharedSession' - | 'loading' - | 'recipeEditor' - | 'recipes' - | 'permission'; + | 'welcome' + | 'chat' + | 'settings' + | '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; + // 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; + view: View; + viewOptions?: ViewOptions; }; const getInitialView = (): ViewConfig => { - const urlParams = new URLSearchParams(window.location.search); - const viewFromUrl = urlParams.get('view'); - const windowConfig = window.electron.getConfig(); - - if (viewFromUrl === 'recipeEditor' && windowConfig?.recipeConfig) { - return { - view: 'recipeEditor', - viewOptions: { - config: windowConfig.recipeConfig, - }, - }; - } + const urlParams = new URLSearchParams(window.location.search); + const viewFromUrl = urlParams.get('view'); + const windowConfig = window.electron.getConfig(); - if (viewFromUrl) { - return { - view: viewFromUrl as View, - viewOptions: {}, - }; - } + if (viewFromUrl === 'recipeEditor' && windowConfig?.recipeConfig) { + return { + view: 'recipeEditor', + viewOptions: { + config: windowConfig.recipeConfig, + }, + }; + } + if (viewFromUrl) { return { - view: 'loading', - viewOptions: {}, + view: viewFromUrl as View, + viewOptions: {}, }; + } + + return { + view: 'loading', + viewOptions: {}, + }; }; export default function App() { - const [fatalError, setFatalError] = useState(null); - const [pendingLink, setPendingLink] = useState(null); - const [modalVisible, setModalVisible] = useState(false); - const [pendingRecipe, setPendingRecipe] = useState(null); - const [recipeWarningVisible, setRecipeWarningVisible] = useState(false); - - useEffect(() => { - console.log('Setting up new recipe warning handler'); - const handleNewRecipeWarning = (_event: IpcRendererEvent, ...args: unknown[]) => { - const recipe = args[0] as Recipe; - console.log('Received new recipe warning:', recipe); - setPendingRecipe(recipe); - setRecipeWarningVisible(true); - }; - window.electron.on('new-recipe-warning', handleNewRecipeWarning); - return () => { - window.electron.off('new-recipe-warning', handleNewRecipeWarning); - }; - }, []); - - useEffect(() => { - const recipeConfig = window.appConfig.get('recipeConfig'); - if (recipeConfig && !recipeWarningVisible && !pendingRecipe) { - setTimeout(() => { - continueInitialization().catch((error: Error) => { - console.error('Error in recipe initialization:', error); - setFatalError(`Recipe initialization failed: ${error.message || 'Unknown error'}`); - }); - }, 100); // Small delay to ensure React is fully ready - } - }, [recipeWarningVisible, pendingRecipe, continueInitialization]); - - const handleRecipeConfirm = async () => { - if (pendingRecipe) { - console.log('Executing new recipe:', pendingRecipe); - await recordRecipeHash(pendingRecipe); - setRecipeWarningVisible(false); - setPendingRecipe(null); - await continueInitialization(); - } - }; + const [fatalError, setFatalError] = useState(null); + const [modalVisible, setModalVisible] = useState(false); + const [pendingLink, setPendingLink] = useState(null); + const [modalMessage, setModalMessage] = useState(''); + const [extensionConfirmLabel, setExtensionConfirmLabel] = useState(''); + const [extensionConfirmTitle, setExtensionConfirmTitle] = useState(''); + const [{ view, viewOptions }, setInternalView] = useState(getInitialView()); + + const { getExtensions, addExtension, read } = useConfig(); + const initAttemptedRef = useRef(false); + + function extractCommand(link: string): string { + const url = new URL(link); + const cmd = url.searchParams.get('cmd') || 'Unknown Command'; + const args = url.searchParams.getAll('arg').map(decodeURIComponent); + return `${cmd} ${args.join(' ')}`.trim(); + } + + function extractRemoteUrl(link: string): string | null { + const url = new URL(link); + return url.searchParams.get('url'); + } + + const setView = (view: View, viewOptions: ViewOptions = {}) => { + console.log(`Setting view to: ${view}`, viewOptions); + setInternalView({ view, viewOptions }); + }; + + useEffect(() => { + if (initAttemptedRef.current) { + console.log('Initialization already attempted, skipping...'); + return; + } + initAttemptedRef.current = true; - const handleRecipeCancel = () => { - console.log('Cancelled recipe execution'); - setRecipeWarningVisible(false); - setPendingRecipe(null); - // Close the current window since user cancelled recipe execution - window.electron.hideWindow(); - }; - const [modalMessage, setModalMessage] = useState(''); - const [extensionConfirmLabel, setExtensionConfirmLabel] = useState(''); - const [extensionConfirmTitle, setExtensionConfirmTitle] = useState(''); - const [{view, viewOptions}, setInternalView] = useState(getInitialView()); + console.log(`Initializing app with settings v2`); - const {getExtensions, addExtension, read} = useConfig(); - const initAttemptedRef = useRef(false); + const urlParams = new URLSearchParams(window.location.search); + const viewType = urlParams.get('view'); + const recipeConfig = window.appConfig.get('recipeConfig'); + + if (viewType) { + if (viewType === 'recipeEditor' && recipeConfig) { + console.log('Setting view to recipeEditor with config:', recipeConfig); + setView('recipeEditor', { config: recipeConfig }); + } else { + setView(viewType as View); + } + return; + } - const continueInitialization = useCallback(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 initializeApp = async () => { + try { + // Initialize cost database early to pre-load pricing data + initializeCostDatabase().catch((error) => { + console.error('Failed to initialize cost database:', error); + }); - if (provider && model) { - setView('chat'); + await initConfig(); + try { + await readAllConfig({ throwOnError: true }); + } catch (error) { + const configVersion = localStorage.getItem('configVersion'); + const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3; + if (shouldMigrateExtensions) { + await backupConfig({ throwOnError: true }); + await initConfig(); + } else { + // Config appears corrupted, try recovery + console.warn('Config file appears corrupted, attempting recovery...'); try { - await initializeSystem(provider as string, model as string, { - getExtensions, - addExtension, - }); - } catch (error) { - console.error('Error in initialization:', error); - if (error instanceof MalformedConfigError) { - throw error; + // First try to validate the config + try { + await validateConfig({ throwOnError: true }); + // Config is valid but readAllConfig failed for another reason + throw new Error('Unable to read config file, it may be malformed'); + } catch (validateError) { + console.log('Config validation failed, attempting recovery...'); + + // Try to recover the config + try { + const recoveryResult = await recoverConfig({ throwOnError: true }); + console.log('Config recovery result:', recoveryResult); + + // Try to read config again after recovery + try { + await readAllConfig({ throwOnError: true }); + console.log('Config successfully recovered and loaded'); + } catch (retryError) { + console.warn('Config still corrupted after recovery, reinitializing...'); + await initConfig(); + } + } catch (recoverError) { + console.warn('Config recovery failed, reinitializing...'); + await initConfig(); } - setView('welcome'); + } + } catch (recoveryError) { + console.error('Config recovery process failed:', recoveryError); + throw new Error('Unable to read config file, it may be malformed'); } - } else { - console.log('Missing required configuration, showing onboarding'); - setView('welcome'); + } } - }, [read, setView, getExtensions, addExtension]); - - function extractCommand(link: string): string { - const url = new URL(link); - const cmd = url.searchParams.get('cmd') || 'Unknown Command'; - const args = url.searchParams.getAll('arg').map(decodeURIComponent); - return `${cmd} ${args.join(' ')}`.trim(); - } - - function extractRemoteUrl(link: string): string | null { - const url = new URL(link); - return url.searchParams.get('url'); - } - - const setView = (view: View, viewOptions: ViewOptions = {}) => { - console.log(`Setting view to: ${view}`, viewOptions); - setInternalView({view, viewOptions}); - }; - useEffect(() => { - if (initAttemptedRef.current) { - console.log('Initialization already attempted, skipping...'); - return; + if (recipeConfig === null) { + setFatalError('Cannot read recipe config. Please check the deeplink and try again.'); + return; } - initAttemptedRef.current = true; - - console.log(`Initializing app with settings v2`); - const urlParams = new URLSearchParams(window.location.search); - const viewType = urlParams.get('view'); - const recipeConfig = window.appConfig.get('recipeConfig'); + 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 (viewType) { - if (viewType === 'recipeEditor' && recipeConfig) { - console.log('Setting view to recipeEditor with config:', recipeConfig); - setView('recipeEditor', {config: recipeConfig}); - } else { - setView(viewType as View); + if (provider && model) { + setView('chat'); + try { + await initializeSystem(provider as string, model as string, { + getExtensions, + addExtension, + }); + } catch (error) { + console.error('Error in initialization:', error); + if (error instanceof MalformedConfigError) { + throw error; } - return; - } - - // If we have a recipe config, we might be waiting for a warning to be shown - // Don't continue with initialization yet - wait for the warning event - if (recipeConfig) { - console.log('Recipe config detected, waiting for potential warning...'); - setView('loading'); - return; + setView('welcome'); + } + } else { + console.log('Missing required configuration, showing onboarding'); + setView('welcome'); } + } catch (error) { + setFatalError( + `Initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + setView('welcome'); + } + toastService.configure({ silent: false }); + }; - const initializeApp = async () => { - try { - // Initialize cost database early to pre-load pricing data - initializeCostDatabase().catch((error) => { - console.error('Failed to initialize cost database:', error); - }); - - await initConfig(); - try { - await readAllConfig({throwOnError: true}); - } catch (error) { - const configVersion = localStorage.getItem('configVersion'); - const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3; - if (shouldMigrateExtensions) { - await backupConfig({throwOnError: true}); - await initConfig(); - } else { - // Config appears corrupted, try recovery - console.warn('Config file appears corrupted, attempting recovery...'); - try { - // First try to validate the config - try { - await validateConfig({throwOnError: true}); - // Config is valid but readAllConfig failed for another reason - throw new Error('Unable to read config file, it may be malformed'); - } catch (validateError) { - console.log('Config validation failed, attempting recovery...'); - - // Try to recover the config - try { - const recoveryResult = await recoverConfig({throwOnError: true}); - console.log('Config recovery result:', recoveryResult); - - // Try to read config again after recovery - try { - await readAllConfig({throwOnError: true}); - console.log('Config successfully recovered and loaded'); - } catch (retryError) { - console.warn('Config still corrupted after recovery, reinitializing...'); - await initConfig(); - } - } catch (recoverError) { - console.warn('Config recovery failed, reinitializing...'); - await initConfig(); - } - } - } catch (recoveryError) { - console.error('Config recovery process failed:', recoveryError); - throw new Error('Unable to read config file, it may be malformed'); - } - } - } - - if (recipeConfig === null) { - setFatalError('Cannot read recipe config. Please check the deeplink and try again.'); - return; - } - - await continueInitialization(); - } catch (error) { - setFatalError( - `Initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - setView('welcome'); - } - toastService.configure({silent: false}); - }; - - initializeApp().catch((error) => { - console.error('Unhandled error in initialization:', error); - setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`); - }); - }, [read, getExtensions, addExtension, continueInitialization]); - - const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); - const [isLoadingSession, setIsLoadingSession] = useState(false); - const [sharedSessionError, setSharedSessionError] = useState(null); - const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false); - const {chat, setChat} = useChat({setView, setIsLoadingSession}); - - useEffect(() => { - console.log('Sending reactReady signal to Electron'); + initializeApp().catch((error) => { + console.error('Unhandled error in initialization:', error); + setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`); + }); + }, [read, getExtensions, addExtension]); + + const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); + const [isLoadingSession, setIsLoadingSession] = useState(false); + const [sharedSessionError, setSharedSessionError] = useState(null); + const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false); + const { chat, setChat } = useChat({ setView, setIsLoadingSession }); + + useEffect(() => { + console.log('Sending reactReady signal to Electron'); + try { + window.electron.reactReady(); + } catch (error) { + console.error('Error sending reactReady:', error); + setFatalError( + `React ready notification failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }, []); + + useEffect(() => { + const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => { + const link = args[0] as string; + window.electron.logInfo(`Opening shared session from deep link ${link}`); + setIsLoadingSharedSession(true); + setSharedSessionError(null); + try { + await openSharedSessionFromDeepLink( + link, + (view: View, options?: SessionLinksViewOptions) => { + setView(view, options as ViewOptions); + } + ); + } catch (error) { + console.error('Unexpected error opening shared session:', error); + setView('sessions'); + } finally { + setIsLoadingSharedSession(false); + } + }; + window.electron.on('open-shared-session', handleOpenSharedSession); + return () => { + window.electron.off('open-shared-session', handleOpenSharedSession); + }; + }, []); + + useEffect(() => { + console.log('Setting up keyboard shortcuts'); + const handleKeyDown = (event: KeyboardEvent) => { + const isMac = window.electron.platform === 'darwin'; + if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') { + event.preventDefault(); try { - window.electron.reactReady(); + 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) { - console.error('Error sending reactReady:', error); - setFatalError( - `React ready notification failed: ${error instanceof Error ? error.message : 'Unknown error'}` - ); + console.error('Error creating new window:', error); } - }, []); - - useEffect(() => { - const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => { - const link = args[0] as string; - window.electron.logInfo(`Opening shared session from deep link ${link}`); - setIsLoadingSharedSession(true); - setSharedSessionError(null); - try { - await openSharedSessionFromDeepLink( - link, - (view: View, options?: SessionLinksViewOptions) => { - setView(view, options as ViewOptions); - } - ); - } catch (error) { - console.error('Unexpected error opening shared session:', error); - setView('sessions'); - } finally { - setIsLoadingSharedSession(false); - } - }; - window.electron.on('open-shared-session', handleOpenSharedSession); - return () => { - window.electron.off('open-shared-session', handleOpenSharedSession); - }; - }, []); - - useEffect(() => { - console.log('Setting up keyboard shortcuts'); - const handleKeyDown = (event: KeyboardEvent) => { - const isMac = window.electron.platform === 'darwin'; - if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') { - event.preventDefault(); - try { - 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) { - console.error('Error creating new window:', error); - } - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - }, []); - - 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('Current view:', view); - console.error('Is loading session:', isLoadingSession); - setFatalError(errorMessage); - }; - window.electron.on('fatal-error', handleFatalError); - return () => { - window.electron.off('fatal-error', handleFatalError); - }; - }, [view, 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; - console.log( - `Received view change request to: ${newView}${section ? `, section: ${section}` : ''}` - ); - - if (section && newView === 'settings') { - setView(newView, {section}); - } else { - setView(newView); - } + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + 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('Current view:', view); + console.error('Is loading session:', isLoadingSession); + setFatalError(errorMessage); + }; + window.electron.on('fatal-error', handleFatalError); + return () => { + window.electron.off('fatal-error', handleFatalError); + }; + }, [view, 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; + console.log( + `Received view change request to: ${newView}${section ? `, section: ${section}` : ''}` + ); + + if (section && newView === 'settings') { + setView(newView, { section }); + } else { + setView(newView); + } + }; + const urlParams = new URLSearchParams(window.location.search); + const viewFromUrl = urlParams.get('view'); + if (viewFromUrl) { + const windowConfig = window.electron.getConfig(); + if (viewFromUrl === 'recipeEditor') { + const initialViewOptions = { + recipeConfig: windowConfig?.recipeConfig, + view: viewFromUrl, }; - const urlParams = new URLSearchParams(window.location.search); - const viewFromUrl = urlParams.get('view'); - if (viewFromUrl) { - const windowConfig = window.electron.getConfig(); - if (viewFromUrl === 'recipeEditor') { - const initialViewOptions = { - recipeConfig: windowConfig?.recipeConfig, - view: viewFromUrl, - }; - setView(viewFromUrl, initialViewOptions); - } else { - setView(viewFromUrl as View); - } - } - window.electron.on('set-view', handleSetView); - return () => window.electron.off('set-view', handleSetView); - }, []); - - useEffect(() => { - console.log(`View changed to: ${view}`); - if (view !== 'chat' && view !== 'recipeEditor') { - console.log('Not in chat view, clearing loading session state'); - setIsLoadingSession(false); - } - }, [view]); - - const config = window.electron.getConfig(); - const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING === true ? false : true; - - useEffect(() => { - console.log('Setting up extension handler'); - const handleAddExtension = async (_event: IpcRendererEvent, ...args: unknown[]) => { - const link = args[0] as string; - try { - console.log(`Received add-extension event with link: ${link}`); - const command = extractCommand(link); - const remoteUrl = extractRemoteUrl(link); - const extName = extractExtensionName(link); - window.electron.logInfo(`Adding extension from deep link ${link}`); - setPendingLink(link); - let warningMessage = ''; - let label = 'OK'; - let title = 'Confirm Extension Installation'; - let isBlocked = false; - let useDetailedMessage = false; - if (remoteUrl) { - useDetailedMessage = true; - } else { - try { - const allowedCommands = await window.electron.getAllowedExtensions(); - if (allowedCommands && allowedCommands.length > 0) { - const isCommandAllowed = allowedCommands.some((allowedCmd) => - command.startsWith(allowedCmd) - ); - if (!isCommandAllowed) { - useDetailedMessage = true; - title = '⛔️ Untrusted Extension ⛔️'; - if (STRICT_ALLOWLIST) { - isBlocked = true; - label = 'Extension Blocked'; - warningMessage = - '\n\n⛔️ BLOCKED: This extension command is not in the allowed list. ' + - 'Installation is blocked by your administrator. ' + - 'Please contact your administrator if you need this extension.'; - } else { - label = 'Override and install'; - warningMessage = - '\n\n⚠️ WARNING: This extension command is not in the allowed list. ' + - 'Installing extensions from untrusted sources may pose security risks. ' + - 'Please contact an admin if you are unsure or want to allow this extension.'; - } - } - } - } catch (error) { - console.error('Error checking allowlist:', error); - } - } - if (useDetailedMessage) { - const detailedMessage = remoteUrl - ? `You are about to install the ${extName} extension which connects to:\n\n${remoteUrl}\n\nThis extension will be able to access your conversations and provide additional functionality.` - : `You are about to install the ${extName} extension which runs the command:\n\n${command}\n\nThis extension will be able to access your conversations and provide additional functionality.`; - setModalMessage(`${detailedMessage}${warningMessage}`); + setView(viewFromUrl, initialViewOptions); + } else { + setView(viewFromUrl as View); + } + } + window.electron.on('set-view', handleSetView); + return () => window.electron.off('set-view', handleSetView); + }, []); + + useEffect(() => { + console.log(`View changed to: ${view}`); + if (view !== 'chat' && view !== 'recipeEditor') { + console.log('Not in chat view, clearing loading session state'); + setIsLoadingSession(false); + } + }, [view]); + + const config = window.electron.getConfig(); + const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING === true ? false : true; + + useEffect(() => { + console.log('Setting up extension handler'); + const handleAddExtension = async (_event: IpcRendererEvent, ...args: unknown[]) => { + const link = args[0] as string; + try { + console.log(`Received add-extension event with link: ${link}`); + const command = extractCommand(link); + const remoteUrl = extractRemoteUrl(link); + const extName = extractExtensionName(link); + window.electron.logInfo(`Adding extension from deep link ${link}`); + setPendingLink(link); + let warningMessage = ''; + let label = 'OK'; + let title = 'Confirm Extension Installation'; + let isBlocked = false; + let useDetailedMessage = false; + if (remoteUrl) { + useDetailedMessage = true; + } else { + try { + const allowedCommands = await window.electron.getAllowedExtensions(); + if (allowedCommands && allowedCommands.length > 0) { + const isCommandAllowed = allowedCommands.some((allowedCmd) => + command.startsWith(allowedCmd) + ); + if (!isCommandAllowed) { + useDetailedMessage = true; + title = '⛔️ Untrusted Extension ⛔️'; + if (STRICT_ALLOWLIST) { + isBlocked = true; + label = 'Extension Blocked'; + warningMessage = + '\n\n⛔️ BLOCKED: This extension command is not in the allowed list. ' + + 'Installation is blocked by your administrator. ' + + 'Please contact your administrator if you need this extension.'; } else { - const messageDetails = `Command: ${command}`; - setModalMessage( - `Are you sure you want to install the ${extName} extension?\n\n${messageDetails}` - ); + label = 'Override and install'; + warningMessage = + '\n\n⚠️ WARNING: This extension command is not in the allowed list. ' + + 'Installing extensions from untrusted sources may pose security risks. ' + + 'Please contact an admin if you are unsure or want to allow this extension.'; } - setExtensionConfirmLabel(label); - setExtensionConfirmTitle(title); - if (isBlocked) { - setPendingLink(null); - } - setModalVisible(true); - } catch (error) { - console.error('Error handling add-extension event:', error); - } - }; - window.electron.on('add-extension', handleAddExtension); - return () => { - window.electron.off('add-extension', handleAddExtension); - }; - }, [STRICT_ALLOWLIST]); - - useEffect(() => { - const handleFocusInput = (_event: IpcRendererEvent, ..._args: unknown[]) => { - const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement; - if (inputField) { - inputField.focus(); - } - }; - window.electron.on('focus-input', handleFocusInput); - return () => { - window.electron.off('focus-input', handleFocusInput); - }; - }, []); - - const handleConfirm = async () => { - if (pendingLink) { - console.log(`Confirming installation of extension from: ${pendingLink}`); - setModalVisible(false); - try { - await addExtensionFromDeepLinkV2(pendingLink, addExtension, (view: string, options) => { - setView(view as View, options as ViewOptions); - }); - console.log('Extension installation successful'); - } catch (error) { - console.error('Failed to add extension:', error); - } finally { - setPendingLink(null); + } } + } catch (error) { + console.error('Error checking allowlist:', error); + } + } + if (useDetailedMessage) { + const detailedMessage = remoteUrl + ? `You are about to install the ${extName} extension which connects to:\n\n${remoteUrl}\n\nThis extension will be able to access your conversations and provide additional functionality.` + : `You are about to install the ${extName} extension which runs the command:\n\n${command}\n\nThis extension will be able to access your conversations and provide additional functionality.`; + setModalMessage(`${detailedMessage}${warningMessage}`); } else { - console.log('Extension installation blocked by allowlist restrictions'); - setModalVisible(false); + const messageDetails = `Command: ${command}`; + setModalMessage( + `Are you sure you want to install the ${extName} extension?\n\n${messageDetails}` + ); + } + setExtensionConfirmLabel(label); + setExtensionConfirmTitle(title); + if (isBlocked) { + setPendingLink(null); } + setModalVisible(true); + } catch (error) { + console.error('Error handling add-extension event:', error); + } }; - - const handleCancel = () => { - console.log('Cancelled extension installation.'); - setModalVisible(false); - setPendingLink(null); + window.electron.on('add-extension', handleAddExtension); + return () => { + window.electron.off('add-extension', handleAddExtension); }; - - if (fatalError) { - return ; + }, [STRICT_ALLOWLIST]); + + useEffect(() => { + const handleFocusInput = (_event: IpcRendererEvent, ..._args: unknown[]) => { + const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement; + if (inputField) { + inputField.focus(); + } + }; + window.electron.on('focus-input', handleFocusInput); + return () => { + window.electron.off('focus-input', handleFocusInput); + }; + }, []); + + const handleConfirm = async () => { + if (pendingLink) { + console.log(`Confirming installation of extension from: ${pendingLink}`); + setModalVisible(false); + try { + await addExtensionFromDeepLinkV2(pendingLink, addExtension, (view: string, options) => { + setView(view as View, options as ViewOptions); + }); + console.log('Extension installation successful'); + } catch (error) { + console.error('Failed to add extension:', error); + } finally { + setPendingLink(null); + } + } else { + console.log('Extension installation blocked by allowlist restrictions'); + setModalVisible(false); } + }; - if (isLoadingSession) - return ( -
-
-
- ); + const handleCancel = () => { + console.log('Cancelled extension installation.'); + setModalVisible(false); + setPendingLink(null); + }; + if (fatalError) { + return ; + } + + if (isLoadingSession) return ( - - - `relative min-h-16 mb-4 p-2 rounded-lg +
+
+
+ ); + + return ( + + + `relative min-h-16 mb-4 p-2 rounded-lg flex justify-between overflow-hidden cursor-pointer text-textProminentInverse bg-bgStandardInverse dark:bg-bgAppInverse ` + } + style={{ width: '380px' }} + className="mt-6" + position="top-right" + autoClose={3000} + closeOnClick + pauseOnHover + /> + {modalVisible && ( + + )} +
+
+
+ {view === 'loading' && } + {view === 'welcome' && ( + setView('chat')} isOnboarding={true} /> + )} + {view === 'settings' && ( + { + setView('chat'); + }} + setView={setView} + viewOptions={viewOptions as SettingsViewOptions} + /> + )} + {view === 'ConfigureProviders' && ( + setView('chat')} isOnboarding={false} /> + )} + {view === 'chat' && !isLoadingSession && ( + + )} + {view === 'sessions' && } + {view === 'schedules' && setView('chat')} />} + {view === 'sharedSession' && ( + setView('sessions')} + onRetry={async () => { + if (viewOptions?.shareToken && viewOptions?.baseUrl) { + setIsLoadingSharedSession(true); + try { + await openSharedSessionFromDeepLink( + `goose://sessions/${viewOptions.shareToken}`, + (view: View, options?: SessionLinksViewOptions) => { + setView(view, options as ViewOptions); + }, + viewOptions.baseUrl + ); + } catch (error) { + console.error('Failed to retry loading shared session:', error); + } finally { + setIsLoadingSharedSession(false); + } } - style={{width: '380px'}} - className="mt-6" - position="top-right" - autoClose={3000} - closeOnClick - pauseOnHover + }} /> - {recipeWarningVisible && pendingRecipe && ( - - )} - {modalVisible && ( - - )} -
-
-
- {view === 'loading' && } - {view === 'welcome' && ( - setView('chat')} isOnboarding={true}/> - )} - {view === 'settings' && ( - { - setView('chat'); - }} - setView={setView} - viewOptions={viewOptions as SettingsViewOptions} - /> - )} - {view === 'ConfigureProviders' && ( - setView('chat')} isOnboarding={false}/> - )} - {view === 'chat' && !isLoadingSession && ( - - )} - {view === 'sessions' && } - {view === 'schedules' && setView('chat')}/>} - {view === 'sharedSession' && ( - setView('sessions')} - onRetry={async () => { - if (viewOptions?.shareToken && viewOptions?.baseUrl) { - setIsLoadingSharedSession(true); - try { - await openSharedSessionFromDeepLink( - `goose://sessions/${viewOptions.shareToken}`, - (view: View, options?: SessionLinksViewOptions) => { - setView(view, options as ViewOptions); - }, - viewOptions.baseUrl - ); - } catch (error) { - console.error('Failed to retry loading shared session:', error); - } finally { - setIsLoadingSharedSession(false); - } - } - }} - /> - )} - {view === 'recipeEditor' && ( - - )} - {view === 'recipes' && setView('chat')}/>} - {view === 'permission' && ( - setView((viewOptions as { parentView: View }).parentView)} - /> - )} -
-
- {isGoosehintsModalOpen && ( - - )} - - - ); + )} + {view === 'recipeEditor' && ( + + )} + {view === 'recipes' && setView('chat')} />} + {view === 'permission' && ( + setView((viewOptions as { parentView: View }).parentView)} + /> + )} +
+
+ {isGoosehintsModalOpen && ( + + )} + + + ); } diff --git a/ui/desktop/src/components/ui/RecipeWarningModal.tsx b/ui/desktop/src/components/ui/RecipeWarningModal.tsx index 5ef1044b7cc8..49af63a17ee8 100644 --- a/ui/desktop/src/components/ui/RecipeWarningModal.tsx +++ b/ui/desktop/src/components/ui/RecipeWarningModal.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Card } from './card'; interface RecipeWarningModalProps { @@ -31,7 +30,8 @@ export function RecipeWarningModal({

⚠️ New Recipe Warning

- You are about to execute a recipe that you haven't run before. Only proceed if you trust the source of this recipe. + You are about to execute a recipe that you haven't run before. Only proceed if you trust + the source of this recipe.

diff --git a/ui/desktop/src/utils/recipeHash.ts b/ui/desktop/src/utils/recipeHash.ts index f712111e85fa..beedb1f3116c 100644 --- a/ui/desktop/src/utils/recipeHash.ts +++ b/ui/desktop/src/utils/recipeHash.ts @@ -3,95 +3,37 @@ import path from 'node:path'; import crypto from 'crypto'; import { app } from 'electron'; -// File to store recipe hashes -const RECIPE_HASHES_FILE = 'recipe_hashes.json'; - -interface RecipeHash { - hash: string; - firstSeenAt: string; - lastExecutedAt: string; - executionCount: number; -} - -interface RecipeHashes { - [hash: string]: RecipeHash; +export async function getRecipeHashesDir(): Promise { + const userDataPath = app.getPath('userData'); + const hashesDir = path.join(userDataPath, 'recipe_hashes'); + await fs.mkdir(hashesDir, { recursive: true }); + return hashesDir; } -/** - * Get the path to the recipe hashes file - */ -export async function getRecipeHashesPath(): Promise { - const userDataPath = app.getPath('userData'); - const hashesPath = path.join(userDataPath, RECIPE_HASHES_FILE); - - // Ensure the directory exists - await fs.mkdir(path.dirname(hashesPath), { recursive: true }); - - return hashesPath; +export function calculateRecipeHash(recipeConfig: unknown): string { + const hash = crypto.createHash('sha256'); + hash.update(JSON.stringify(recipeConfig)); + return hash.digest('hex'); } -/** - * Load stored recipe hashes - */ -export async function loadRecipeHashes(): Promise { +export async function hasAcceptedRecipeBefore(recipeConfig: unknown): Promise { + const hash = calculateRecipeHash(recipeConfig); + const hashFile = path.join(await getRecipeHashesDir(), `${hash}.hash`); try { - const hashesPath = await getRecipeHashesPath(); - const data = await fs.readFile(hashesPath, 'utf8'); - return JSON.parse(data); + await fs.access(hashFile); + return true; } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { - // File doesn't exist yet, return empty object - return {}; + return false; } throw error; } } -/** - * Save recipe hashes to storage - */ -export async function saveRecipeHashes(hashes: RecipeHashes): Promise { - const hashesPath = await getRecipeHashesPath(); - await fs.writeFile(hashesPath, JSON.stringify(hashes, null, 2)); -} - -/** - * Calculate hash for a recipe configuration - */ -export function calculateRecipeHash(recipeConfig: unknown): string { - const hash = crypto.createHash('sha256'); - hash.update(JSON.stringify(recipeConfig)); - return hash.digest('hex'); -} - -/** - * Check if a recipe has been seen before and record this execution - * Returns true if recipe has been seen before, false if it's new - */ -export async function checkAndRecordRecipe(recipeConfig: unknown): Promise { +export async function recordRecipeHash(recipeConfig: unknown): Promise { const hash = calculateRecipeHash(recipeConfig); - const hashes = await loadRecipeHashes(); - - const now = new Date().toISOString(); - - if (hash in hashes) { - // Update existing recipe hash record - hashes[hash] = { - ...hashes[hash], - lastExecutedAt: now, - executionCount: (hashes[hash].executionCount || 0) + 1 - }; - await saveRecipeHashes(hashes); - return true; - } - - // Record new recipe hash - hashes[hash] = { - hash, - firstSeenAt: now, - lastExecutedAt: now, - executionCount: 1 - }; - await saveRecipeHashes(hashes); - return false; + const dir = await getRecipeHashesDir(); + const filePath = path.join(dir, `${hash}.hash`); + await fs.writeFile(filePath, new Date().toISOString()); + return true; } From 4ab310897c7afeed7cfd63858d2a9d40168caa28 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 14 Jul 2025 15:52:09 +0200 Subject: [PATCH 4/9] Undo --- ui/desktop/src/main.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 629915aaf9dd..b86e34d3aaaa 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -640,23 +640,6 @@ const createChat = async ( return { action: 'allow' }; }); - // If this is a recipe execution, check if we've seen it before - if (recipeConfig) { - try { - const { checkAndRecordRecipe } = require('./utils/recipeHash'); - const hasSeenBefore = await checkAndRecordRecipe(recipeConfig); - - if (!hasSeenBefore) { - // Send event to renderer to show warning - mainWindow.webContents.send('new-recipe-warning', recipeConfig); - return mainWindow; - } - } catch (error) { - console.error('Error checking recipe hash:', error); - // Continue with execution on error - } - } - // Load the index.html of the app. let queryParams = ''; if (query) { From 835ede5d1146f9599240ebba1832a482d283a6a6 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 14 Jul 2025 19:18:00 +0200 Subject: [PATCH 5/9] Show the warning in the right place --- ui/desktop/src/components/ChatView.tsx | 69 ++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 6c34d069c53b..cb2c0f66065e 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -37,6 +37,8 @@ import { LocalMessageStorage } from '../utils/localMessageStorage'; import { useModelAndProvider } from './ModelAndProviderContext'; import { getCostForModel } from '../utils/costDatabase'; import { updateSystemPromptWithParameters } from '../utils/providerUtils'; +import { RecipeWarningModal } from './ui/RecipeWarningModal'; +import { hasAcceptedRecipeBefore, recordRecipeHash } from '../utils/recipeHash'; import { Message, createUserMessage, @@ -137,6 +139,8 @@ function ChatContent({ }; }>({}); const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false); + const [isRecipeWarningModalOpen, setIsRecipeWarningModalOpen] = useState(false); + const [recipeAccepted, setRecipeAccepted] = useState(false); const scrollRef = useRef(null); const { currentModel, currentProvider } = useModelAndProvider(); @@ -167,15 +171,37 @@ function ChatContent({ // Get recipeConfig directly from appConfig const recipeConfig = window.appConfig.get('recipeConfig') as Recipe | null; + // Check if recipe has been accepted before + useEffect(() => { + const checkRecipeAcceptance = async () => { + if (recipeConfig) { + try { + const hasAccepted = await hasAcceptedRecipeBefore(recipeConfig); + if (!hasAccepted) { + setIsRecipeWarningModalOpen(true); + } else { + setRecipeAccepted(true); + } + } catch (error) { + console.error('Error checking recipe acceptance:', error); + // If there's an error, assume the recipe hasn't been accepted + setIsRecipeWarningModalOpen(true); + } + } + }; + + checkRecipeAcceptance(); + }, [recipeConfig]); + // Show parameter modal if recipe has parameters and they haven't been set yet useEffect(() => { - if (recipeConfig?.parameters && recipeConfig.parameters.length > 0) { + if (recipeConfig?.parameters && recipeConfig.parameters.length > 0 && recipeAccepted) { // If we have parameters and they haven't been set yet, open the modal. if (!recipeParameters) { setIsParameterModalOpen(true); } } - }, [recipeConfig, recipeParameters]); + }, [recipeConfig, recipeParameters, recipeAccepted]); // Store message in global history when it's added const storeMessageInHistory = useCallback((message: Message) => { @@ -352,7 +378,7 @@ function ChatContent({ // Pre-fill input with recipe prompt instead of auto-sending it const initialPrompt = useMemo(() => { - if (!recipeConfig?.prompt) return ''; + if (!recipeConfig?.prompt || !recipeAccepted) return ''; const hasRequiredParams = recipeConfig.parameters && recipeConfig.parameters.length > 0; @@ -368,7 +394,7 @@ function ChatContent({ // Otherwise, we are waiting for parameters, so the input should be empty. return ''; - }, [recipeConfig, recipeParameters]); + }, [recipeConfig, recipeParameters, recipeAccepted]); // Auto-send the prompt for scheduled executions useEffect(() => { @@ -380,7 +406,8 @@ function ChatContent({ (!hasRequiredParams || recipeParameters) && messages.length === 0 && !isLoading && - readyForAutoUserPrompt + readyForAutoUserPrompt && + recipeAccepted ) { // Substitute parameters if they exist const finalPrompt = recipeParameters @@ -408,6 +435,7 @@ function ChatContent({ messages.length, isLoading, readyForAutoUserPrompt, + recipeAccepted, append, setLastInteractionTime, ]); @@ -424,6 +452,27 @@ function ChatContent({ } }; + const handleRecipeAccept = async () => { + try { + if (recipeConfig) { + await recordRecipeHash(recipeConfig); + setRecipeAccepted(true); + setIsRecipeWarningModalOpen(false); + } + } catch (error) { + console.error('Error recording recipe hash:', error); + // Even if recording fails, we should still allow the user to proceed + setRecipeAccepted(true); + setIsRecipeWarningModalOpen(false); + } + }; + + const handleRecipeCancel = () => { + setIsRecipeWarningModalOpen(false); + // Navigate back or close the window + setView('welcome'); + }; + // Handle submit const handleSubmit = (e: React.FormEvent) => { window.electron.startPowerSaveBlocker(); @@ -899,6 +948,16 @@ function ChatContent({ onClose={() => setIsParameterModalOpen(false)} /> )} +
); From 9c414a313320d2bb5a910790c92de55f68141bdb Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 15 Jul 2025 12:05:18 +0200 Subject: [PATCH 6/9] Move it to ipc --- ui/desktop/src/components/ChatView.tsx | 14 ++++------- ui/desktop/src/preload.ts | 6 +++++ ui/desktop/src/utils/recipeHash.ts | 33 +++++++++++++------------- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index cb2c0f66065e..eaf68defaba0 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -38,7 +38,7 @@ import { useModelAndProvider } from './ModelAndProviderContext'; import { getCostForModel } from '../utils/costDatabase'; import { updateSystemPromptWithParameters } from '../utils/providerUtils'; import { RecipeWarningModal } from './ui/RecipeWarningModal'; -import { hasAcceptedRecipeBefore, recordRecipeHash } from '../utils/recipeHash'; + import { Message, createUserMessage, @@ -62,15 +62,11 @@ export interface ChatType { messages: Message[]; } -// Helper function to determine if a message is a user message const isUserMessage = (message: Message): boolean => { if (message.role === 'assistant') { return false; } - if (message.content.every((c) => c.type === 'toolConfirmationRequest')) { - return false; - } - return true; + return !message.content.every((c) => c.type === 'toolConfirmationRequest'); }; const substituteParameters = (prompt: string, params: Record): string => { @@ -176,7 +172,7 @@ function ChatContent({ const checkRecipeAcceptance = async () => { if (recipeConfig) { try { - const hasAccepted = await hasAcceptedRecipeBefore(recipeConfig); + const hasAccepted = await window.electron.hasAcceptedRecipeBefore(recipeConfig); if (!hasAccepted) { setIsRecipeWarningModalOpen(true); } else { @@ -222,8 +218,6 @@ function ChatContent({ setMessages, input: _input, setInput: _setInput, - handleInputChange: _handleInputChange, - handleSubmit: _submitMessage, updateMessageStreamBody, notifications, currentModelInfo, @@ -455,7 +449,7 @@ function ChatContent({ const handleRecipeAccept = async () => { try { if (recipeConfig) { - await recordRecipeHash(recipeConfig); + await window.electron.recordRecipeHash(recipeConfig); setRecipeAccepted(true); setIsRecipeWarningModalOpen(false); } diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index b1b6218fdd80..e3507f63924e 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -106,6 +106,8 @@ type ElectronAPI = { restartApp: () => void; onUpdaterEvent: (callback: (event: UpdaterEvent) => void) => void; getUpdateState: () => Promise<{ updateAvailable: boolean; latestVersion?: string } | null>; + hasAcceptedRecipeBefore: (recipeConfig: RecipeConfig) => Promise; + recordRecipeHash: (recipeConfig: RecipeConfig) => Promise; }; type AppConfigAPI = { @@ -209,6 +211,10 @@ const electronAPI: ElectronAPI = { getUpdateState: (): Promise<{ updateAvailable: boolean; latestVersion?: string } | null> => { return ipcRenderer.invoke('get-update-state'); }, + hasAcceptedRecipeBefore: (recipeConfig: unknown) => + ipcRenderer.invoke('has-accepted-recipe-before', recipeConfig), + recordRecipeHash: (recipeConfig: unknown) => + ipcRenderer.invoke('record-recipe-hash', recipeConfig), }; const appConfigAPI: AppConfigAPI = { diff --git a/ui/desktop/src/utils/recipeHash.ts b/ui/desktop/src/utils/recipeHash.ts index beedb1f3116c..7c9e0cc6aea0 100644 --- a/ui/desktop/src/utils/recipeHash.ts +++ b/ui/desktop/src/utils/recipeHash.ts @@ -1,39 +1,38 @@ +import { ipcMain, app } from 'electron'; import fs from 'node:fs/promises'; import path from 'node:path'; import crypto from 'crypto'; -import { app } from 'electron'; -export async function getRecipeHashesDir(): Promise { +function calculateRecipeHash(recipeConfig: unknown): string { + const hash = crypto.createHash('sha256'); + hash.update(JSON.stringify(recipeConfig)); + return hash.digest('hex'); +} + +async function getRecipeHashesDir(): Promise { const userDataPath = app.getPath('userData'); const hashesDir = path.join(userDataPath, 'recipe_hashes'); await fs.mkdir(hashesDir, { recursive: true }); return hashesDir; } -export function calculateRecipeHash(recipeConfig: unknown): string { - const hash = crypto.createHash('sha256'); - hash.update(JSON.stringify(recipeConfig)); - return hash.digest('hex'); -} - -export async function hasAcceptedRecipeBefore(recipeConfig: unknown): Promise { +ipcMain.handle('has-accepted-recipe-before', async (_event, recipeConfig) => { const hash = calculateRecipeHash(recipeConfig); const hashFile = path.join(await getRecipeHashesDir(), `${hash}.hash`); try { await fs.access(hashFile); return true; - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + } catch (err) { + if (typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT') { return false; } - throw error; + throw err; } -} +}); -export async function recordRecipeHash(recipeConfig: unknown): Promise { +ipcMain.handle('record-recipe-hash', async (_event, recipeConfig) => { const hash = calculateRecipeHash(recipeConfig); - const dir = await getRecipeHashesDir(); - const filePath = path.join(dir, `${hash}.hash`); + const filePath = path.join(await getRecipeHashesDir(), `${hash}.hash`); await fs.writeFile(filePath, new Date().toISOString()); return true; -} +}); From c8e29dfd3a40157b5925cf5978bb86390fe7f080 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 15 Jul 2025 19:48:26 +0200 Subject: [PATCH 7/9] Link it and make it quit --- ui/desktop/src/components/ChatView.tsx | 3 +-- ui/desktop/src/main.ts | 37 +++++++++++++++----------- ui/desktop/src/preload.ts | 2 ++ 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index eaf68defaba0..159f9343fd9d 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -463,8 +463,7 @@ function ChatContent({ const handleRecipeCancel = () => { setIsRecipeWarningModalOpen(false); - // Navigate back or close the window - setView('welcome'); + window.electron.closeWindow(); }; // Handle submit diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index b86e34d3aaaa..2f6a5d60be3e 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1,19 +1,21 @@ +import type { OpenDialogReturnValue } from 'electron'; +import * as electron from 'electron'; import { app, - session, + App, BrowserWindow, dialog, + Event, + globalShortcut, ipcMain, Menu, MenuItem, Notification, powerSaveBlocker, + session, Tray, - App, - globalShortcut, - Event, } from 'electron'; -import type { OpenDialogReturnValue } from 'electron'; +import './utils/recipeHash'; import { Buffer } from 'node:buffer'; import fs from 'node:fs/promises'; import fsSync from 'node:fs'; @@ -32,20 +34,19 @@ import { EnvToggles, loadSettings, saveSettings, + SchedulingEngine, updateEnvironmentVariables, updateSchedulingEngineEnvironment, - SchedulingEngine, } from './utils/settings'; import * as crypto from 'crypto'; -import * as electron from 'electron'; import * as yaml from 'yaml'; import windowStateKeeper from 'electron-window-state'; import { - setupAutoUpdater, + getUpdateAvailable, registerUpdateIpcHandlers, setTrayRef, + setupAutoUpdater, updateTrayMenu, - getUpdateAvailable, } from './utils/autoUpdater'; import { UPDATES_ENABLED } from './updates'; @@ -1390,8 +1391,7 @@ ipcMain.handle('list-files', async (_event, dirPath, extension) => { // Handle message box dialogs ipcMain.handle('show-message-box', async (_event, options) => { - const result = await dialog.showMessageBox(options); - return result; + return await dialog.showMessageBox(options); }); // Handle allowed extensions list fetching @@ -1819,6 +1819,11 @@ app.whenReady().then(async () => { } }); + ipcMain.on('close-window', () => { + const currentWindow = BrowserWindow.getFocusedWindow(); + currentWindow?.close(); + }); + ipcMain.on('reload-app', (event) => { // Get the window that sent the event const window = BrowserWindow.fromWebContents(event.sender); @@ -1938,11 +1943,11 @@ app.whenReady().then(async () => { * ```yaml: extensions: - - id: slack - command: uvx mcp_slack - - id: knowledge_graph_memory - command: npx -y @modelcontextprotocol/server-memory - ``` + - id: slack + command: uvx mcp_slack + - id: knowledge_graph_memory + command: npx -y @modelcontextprotocol/server-memory + ``` * * @returns A promise that resolves to an array of extension commands that are allowed. */ diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index e3507f63924e..cf7a889aaa7e 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -59,6 +59,7 @@ type ElectronAPI = { viewType?: string ) => void; logInfo: (txt: string) => void; + closeWindow: () => void; showNotification: (data: NotificationData) => void; showMessageBox: (options: MessageBoxOptions) => Promise; openInChrome: (url: string) => void; @@ -139,6 +140,7 @@ const electronAPI: ElectronAPI = { viewType ), logInfo: (txt: string) => ipcRenderer.send('logInfo', txt), + closeWindow: () => ipcRenderer.send('close-window'), showNotification: (data: NotificationData) => ipcRenderer.send('notify', data), showMessageBox: (options: MessageBoxOptions) => ipcRenderer.invoke('show-message-box', options), openInChrome: (url: string) => ipcRenderer.send('open-in-chrome', url), From 1547a3f1369497916146da2f9912bf3704ea69c8 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Sun, 20 Jul 2025 10:56:03 -0700 Subject: [PATCH 8/9] add recipe warning modal --- ui/desktop/src/api/types.gen.ts | 4 + ui/desktop/src/components/BaseChat.tsx | 24 +++++- .../src/components/ui/RecipeWarningModal.tsx | 78 +++++++++++++++++++ ui/desktop/src/hooks/useRecipeManager.ts | 62 +++++++++++++-- ui/desktop/src/main.ts | 1 + ui/desktop/src/preload.ts | 9 +++ ui/desktop/src/utils/recipeHash.ts | 46 +++++++++++ 7 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 ui/desktop/src/components/ui/RecipeWarningModal.tsx create mode 100644 ui/desktop/src/utils/recipeHash.ts diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 071c7bfd557e..45a43b44ba18 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -273,6 +273,10 @@ export type ModelInfo = { * Cost per token for output (optional) */ output_token_cost?: number | null; + /** + * Whether this model supports cache control + */ + supports_cache_control?: boolean | null; }; export type PermissionConfirmationRequest = { diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 247d8bee1116..c20c29de3884 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -60,6 +60,7 @@ import { type View, ViewOptions } from '../App'; import { MainPanelLayout } from './Layout/MainPanelLayout'; import ChatInput from './ChatInput'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; +import { RecipeWarningModal } from './ui/RecipeWarningModal'; import { useChatEngine } from '../hooks/useChatEngine'; import { useRecipeManager } from '../hooks/useRecipeManager'; import { useSessionContinuation } from '../hooks/useSessionContinuation'; @@ -195,6 +196,10 @@ function BaseChatContent({ handleAutoExecution, recipeError, setRecipeError, + isRecipeWarningModalOpen, + recipeAccepted, + handleRecipeAccept, + handleRecipeCancel, } = useRecipeManager(messages, location.state); // Reset recipe usage tracking when recipe changes @@ -356,9 +361,9 @@ function BaseChatContent({ { // Check if we should show splash instead of messages (() => { - // Show splash if we have a recipe and user hasn't started using it yet + // Show splash if we have a recipe and user hasn't started using it yet, and recipe has been accepted const shouldShowSplash = - recipeConfig && !hasStartedUsingRecipe && !suppressEmptyState; + recipeConfig && recipeAccepted && !hasStartedUsingRecipe && !suppressEmptyState; return shouldShowSplash; })() ? ( @@ -377,7 +382,8 @@ function BaseChatContent({ appendWithTracking(text)} /> ) : null} - ) : filteredMessages.length > 0 || (recipeConfig && hasStartedUsingRecipe) ? ( + ) : filteredMessages.length > 0 || + (recipeConfig && recipeAccepted && hasStartedUsingRecipe) ? ( <> {disableSearch ? ( // Render messages without SearchView wrapper when search is disabled @@ -523,6 +529,18 @@ function BaseChatContent({ summaryContent={summaryContent} /> + {/* Recipe Warning Modal */} + + {/* Recipe Error Modal */} {recipeError && (
diff --git a/ui/desktop/src/components/ui/RecipeWarningModal.tsx b/ui/desktop/src/components/ui/RecipeWarningModal.tsx new file mode 100644 index 000000000000..83dac30642c7 --- /dev/null +++ b/ui/desktop/src/components/ui/RecipeWarningModal.tsx @@ -0,0 +1,78 @@ +import { Card } from './card'; + +interface RecipeWarningModalProps { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; + recipeDetails: { + title?: string; + description?: string; + instructions?: string; + }; +} + +export function RecipeWarningModal({ + isOpen, + onConfirm, + onCancel, + recipeDetails, +}: RecipeWarningModalProps) { + if (!isOpen) { + return null; + } + + return ( +
{ + if (e.target === e.currentTarget) onCancel(); + }} + > + +
+

⚠️ New Recipe Warning

+

+ You are about to execute a recipe that you haven't run before. Only proceed if you trust + the source of this recipe. +

+ +
+

Recipe Details:

+
+ {recipeDetails.title && ( +

+ Title: {recipeDetails.title} +

+ )} + {recipeDetails.description && ( +

+ Description: {recipeDetails.description} +

+ )} + {recipeDetails.instructions && ( +

+ Instructions: {recipeDetails.instructions} +

+ )} +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index a461bc2e21f6..8d462d7b2928 100644 --- a/ui/desktop/src/hooks/useRecipeManager.ts +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -16,6 +16,8 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt const [recipeParameters, setRecipeParameters] = useState | null>(null); const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false); const [recipeError, setRecipeError] = useState(null); + const [isRecipeWarningModalOpen, setIsRecipeWarningModalOpen] = useState(false); + const [recipeAccepted, setRecipeAccepted] = useState(false); // Get chat context to access persisted recipe const chatContext = useChatContext(); @@ -72,15 +74,37 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt } }, [chatContext, locationState]); + // Check if recipe has been accepted before + useEffect(() => { + const checkRecipeAcceptance = async () => { + if (recipeConfig) { + try { + const hasAccepted = await window.electron.hasAcceptedRecipeBefore(recipeConfig); + if (!hasAccepted) { + setIsRecipeWarningModalOpen(true); + } else { + setRecipeAccepted(true); + } + } catch (error) { + console.error('Error checking recipe acceptance:', error); + // If there's an error, assume the recipe hasn't been accepted + setIsRecipeWarningModalOpen(true); + } + } + }; + + checkRecipeAcceptance(); + }, [recipeConfig]); + // Show parameter modal if recipe has parameters and they haven't been set yet useEffect(() => { - if (recipeConfig?.parameters && recipeConfig.parameters.length > 0) { + if (recipeConfig?.parameters && recipeConfig.parameters.length > 0 && recipeAccepted) { // If we have parameters and they haven't been set yet, open the modal. if (!recipeParameters) { setIsParameterModalOpen(true); } } - }, [recipeConfig, recipeParameters]); + }, [recipeConfig, recipeParameters, recipeAccepted]); // Set ready for auto user prompt after component initialization useEffect(() => { @@ -101,7 +125,7 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt // Pre-fill input with recipe prompt instead of auto-sending it const initialPrompt = useMemo(() => { - if (!recipeConfig?.prompt) return ''; + if (!recipeConfig?.prompt || !recipeAccepted) return ''; const hasRequiredParams = recipeConfig.parameters && recipeConfig.parameters.length > 0; @@ -117,7 +141,7 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt // Otherwise, we are waiting for parameters, so the input should be empty. return ''; - }, [recipeConfig, recipeParameters]); + }, [recipeConfig, recipeParameters, recipeAccepted]); // Handle parameter submission const handleParameterSubmit = async (inputValues: Record) => { @@ -132,6 +156,28 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt } }; + // Handle recipe acceptance + const handleRecipeAccept = async () => { + try { + if (recipeConfig) { + await window.electron.recordRecipeHash(recipeConfig); + setRecipeAccepted(true); + setIsRecipeWarningModalOpen(false); + } + } catch (error) { + console.error('Error recording recipe hash:', error); + // Even if recording fails, we should still allow the user to proceed + setRecipeAccepted(true); + setIsRecipeWarningModalOpen(false); + } + }; + + // Handle recipe cancellation + const handleRecipeCancel = () => { + setIsRecipeWarningModalOpen(false); + window.electron.closeWindow(); + }; + // Auto-execution handler for scheduled recipes const handleAutoExecution = (append: (message: Message) => void, isLoading: boolean) => { const hasRequiredParams = recipeConfig?.parameters && recipeConfig.parameters.length > 0; @@ -142,7 +188,8 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt (!hasRequiredParams || recipeParameters) && messages.length === 0 && !isLoading && - readyForAutoUserPrompt + readyForAutoUserPrompt && + recipeAccepted ) { // Substitute parameters if they exist const finalPrompt = recipeParameters @@ -249,5 +296,10 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt handleAutoExecution, recipeError, setRecipeError, + isRecipeWarningModalOpen, + setIsRecipeWarningModalOpen, + recipeAccepted, + handleRecipeAccept, + handleRecipeCancel, }; }; diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 1934ac4eded7..28a8e25bdbb8 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -50,6 +50,7 @@ import { } from './utils/autoUpdater'; import { UPDATES_ENABLED } from './updates'; import { Recipe } from './recipe'; +import './utils/recipeHash'; // API URL constructor for main process before window is ready function getApiUrlMain(endpoint: string, dynamicPort: number): string { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 574310d14088..cd701b0d2638 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -105,6 +105,10 @@ type ElectronAPI = { restartApp: () => void; onUpdaterEvent: (callback: (event: UpdaterEvent) => void) => void; getUpdateState: () => Promise<{ updateAvailable: boolean; latestVersion?: string } | null>; + // Recipe warning functions + closeWindow: () => void; + hasAcceptedRecipeBefore: (recipeConfig: Recipe) => Promise; + recordRecipeHash: (recipeConfig: Recipe) => Promise; }; type AppConfigAPI = { @@ -215,6 +219,11 @@ const electronAPI: ElectronAPI = { getUpdateState: (): Promise<{ updateAvailable: boolean; latestVersion?: string } | null> => { return ipcRenderer.invoke('get-update-state'); }, + closeWindow: () => ipcRenderer.send('close-window'), + hasAcceptedRecipeBefore: (recipeConfig: Recipe) => + ipcRenderer.invoke('has-accepted-recipe-before', recipeConfig), + recordRecipeHash: (recipeConfig: Recipe) => + ipcRenderer.invoke('record-recipe-hash', recipeConfig), }; const appConfigAPI: AppConfigAPI = { diff --git a/ui/desktop/src/utils/recipeHash.ts b/ui/desktop/src/utils/recipeHash.ts new file mode 100644 index 000000000000..c0b0e64561fe --- /dev/null +++ b/ui/desktop/src/utils/recipeHash.ts @@ -0,0 +1,46 @@ +import { ipcMain, app, BrowserWindow } from 'electron'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'crypto'; + +function calculateRecipeHash(recipeConfig: unknown): string { + const hash = crypto.createHash('sha256'); + hash.update(JSON.stringify(recipeConfig)); + return hash.digest('hex'); +} + +async function getRecipeHashesDir(): Promise { + const userDataPath = app.getPath('userData'); + const hashesDir = path.join(userDataPath, 'recipe_hashes'); + await fs.mkdir(hashesDir, { recursive: true }); + return hashesDir; +} + +ipcMain.handle('has-accepted-recipe-before', async (_event, recipeConfig) => { + const hash = calculateRecipeHash(recipeConfig); + const hashFile = path.join(await getRecipeHashesDir(), `${hash}.hash`); + try { + await fs.access(hashFile); + return true; + } catch (err) { + if (typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT') { + return false; + } + throw err; + } +}); + +ipcMain.handle('record-recipe-hash', async (_event, recipeConfig) => { + const hash = calculateRecipeHash(recipeConfig); + const filePath = path.join(await getRecipeHashesDir(), `${hash}.hash`); + const timestamp = new Date().toISOString(); + await fs.writeFile(filePath, timestamp); + return true; +}); + +ipcMain.on('close-window', () => { + const currentWindow = BrowserWindow.getFocusedWindow(); + if (currentWindow) { + currentWindow.close(); + } +}); From bbbf994d99459b24f26bcb6e1bca040f3c604fce Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Sun, 20 Jul 2025 11:00:35 -0700 Subject: [PATCH 9/9] fix background of modal --- .../src/components/ui/RecipeWarningModal.tsx | 71 +++++++++---------- 1 file changed, 32 insertions(+), 39 deletions(-) diff --git a/ui/desktop/src/components/ui/RecipeWarningModal.tsx b/ui/desktop/src/components/ui/RecipeWarningModal.tsx index 83dac30642c7..c34a8cf6f1ad 100644 --- a/ui/desktop/src/components/ui/RecipeWarningModal.tsx +++ b/ui/desktop/src/components/ui/RecipeWarningModal.tsx @@ -1,4 +1,12 @@ -import { Card } from './card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './dialog'; +import { Button } from './button'; interface RecipeWarningModalProps { isOpen: boolean; @@ -17,62 +25,47 @@ export function RecipeWarningModal({ onCancel, recipeDetails, }: RecipeWarningModalProps) { - if (!isOpen) { - return null; - } - return ( -
{ - if (e.target === e.currentTarget) onCancel(); - }} - > - -
-

⚠️ New Recipe Warning

-

+

!open && onCancel()}> + + + ⚠️ New Recipe Warning + You are about to execute a recipe that you haven't run before. Only proceed if you trust the source of this recipe. -

+
+
-
-

Recipe Details:

-
+
+
+

Recipe Details:

+
{recipeDetails.title && ( -

+

Title: {recipeDetails.title}

)} {recipeDetails.description && ( -

+

Description: {recipeDetails.description}

)} {recipeDetails.instructions && ( -

+

Instructions: {recipeDetails.instructions}

)}
- -
- - -
- -
+ + + + + + +
); }