diff --git a/ui/desktop/.eslintrc.json b/ui/desktop/.eslintrc.json new file mode 100644 index 00000000..5603e4fd --- /dev/null +++ b/ui/desktop/.eslintrc.json @@ -0,0 +1,35 @@ +{ + "root": true, + "env": { + "browser": true, + "es2020": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + "plugin:react/recommended" + ], + "ignorePatterns": ["dist", ".eslintrc.json"], + "parser": "@typescript-eslint/parser", + "plugins": ["react-refresh", "@typescript-eslint", "react"], + "rules": { + "react-refresh/only-export-components": [ + "warn", + { "allowConstantExport": true } + ], + "@typescript-eslint/no-unused-vars": ["warn", { + "varsIgnorePattern": "^React$|^_", + "argsIgnorePattern": "^_", + "ignoreRestSiblings": true + }], + "react/react-in-jsx-scope": "off", + "react/jsx-uses-react": "off", + "react/jsx-uses-vars": "error" + }, + "settings": { + "react": { + "version": "detect" + } + } +} diff --git a/ui/desktop/index.html b/ui/desktop/index.html index a871050a..564e1f0e 100644 --- a/ui/desktop/index.html +++ b/ui/desktop/index.html @@ -3,20 +3,28 @@ Goose + -
diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 36268ec7..b702e46c 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -62,6 +62,7 @@ "@electron-forge/plugin-fuses": "^7.5.0", "@electron-forge/plugin-vite": "^7.5.0", "@electron/fuses": "^1.8.0", + "@electron/remote": "^2.1.2", "@eslint/js": "^8.56.0", "@hey-api/openapi-ts": "^0.64.4", "@modelcontextprotocol/sdk": "^1.8.0", @@ -69,6 +70,7 @@ "@tailwindcss/typography": "^0.5.15", "@types/cors": "^2.8.17", "@types/electron-squirrel-startup": "^1.0.2", + "@types/electron-window-state": "^2.0.34", "@types/express": "^5.0.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", @@ -1439,6 +1441,16 @@ "node": ">=12.13.0" } }, + "node_modules/@electron/remote": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@electron/remote/-/remote-2.1.2.tgz", + "integrity": "sha512-EPwNx+nhdrTBxyCqXt/pftoQg/ybtWDW3DUWHafejvnB1ZGGfMpv6e15D8KeempocjXe78T7WreyGGb3mlZxdA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "electron": ">= 13.0.0" + } + }, "node_modules/@electron/universal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", @@ -4696,6 +4708,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/electron-window-state": { + "version": "2.0.34", + "resolved": "https://registry.npmjs.org/@types/electron-window-state/-/electron-window-state-2.0.34.tgz", + "integrity": "sha512-pOJfzi9uoBF9kJa47EXQCizR8g/7gnKs9jLJMMHAWv+oVYVM6Nmgt8/I85z+cdahc9Nn4/f+mHZ7m0sWg0Prbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "electron": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index b9605706..57c35c2e 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -5,6 +5,7 @@ "description": "Goose App", "main": ".vite/build/main.js", "scripts": { + "typecheck": "tsc --noEmit", "generate-api": "openapi-ts", "start-gui": "npm run generate-api && electron-forge start", "start": "cd ../.. && just run-ui", @@ -19,8 +20,8 @@ "test-e2e:ui": "npm run generate-api && playwright test --ui", "test-e2e:debug": "npm run generate-api && playwright test --debug", "test-e2e:report": "playwright show-report", - "lint": "eslint \"src/**/*.{ts,tsx}\" --fix", - "lint:check": "eslint \"src/**/*.{ts,tsx}\"", + "lint": "eslint \"src/**/*.{ts,tsx}\" --fix --no-warn-ignored", + "lint:check": "eslint \"src/**/*.{ts,tsx}\" --max-warnings 0 --no-warn-ignored", "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"", "prepare": "cd ../.. && husky install", @@ -36,6 +37,7 @@ "@electron-forge/plugin-fuses": "^7.5.0", "@electron-forge/plugin-vite": "^7.5.0", "@electron/fuses": "^1.8.0", + "@electron/remote": "^2.1.2", "@eslint/js": "^8.56.0", "@hey-api/openapi-ts": "^0.64.4", "@modelcontextprotocol/sdk": "^1.8.0", @@ -43,6 +45,7 @@ "@tailwindcss/typography": "^0.5.15", "@types/cors": "^2.8.17", "@types/electron-squirrel-startup": "^1.0.2", + "@types/electron-window-state": "^2.0.34", "@types/express": "^5.0.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", @@ -63,7 +66,7 @@ "license": "Apache-2.0", "lint-staged": { "src/**/*.{ts,tsx}": [ - "eslint --fix", + "eslint --fix --max-warnings 0 --no-warn-ignored", "prettier --write" ], "src/**/*.{css,json}": [ diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 0860eecf..cd13ee1e 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; +import { IpcRendererEvent } from 'electron'; import { addExtensionFromDeepLink } from './extensions'; import { openSharedSessionFromDeepLink } from './sessionLinks'; import { getStoredModel } from './utils/providerUtils'; @@ -15,7 +16,6 @@ import { settingsV2Enabled } from './flags'; import { extractExtensionName } from './components/settings/extensions/utils'; import { GoosehintsModal } from './components/GoosehintsModal'; import { SessionDetails } from './sessions'; -import { SharedSessionDetails } from './sharedSessions'; import WelcomeView from './components/WelcomeView'; import ChatView from './components/ChatView'; @@ -48,14 +48,15 @@ export type View = | 'sharedSession' | 'loading'; +export type ViewOptions = + | SettingsViewOptions + | { resumedSession?: SessionDetails } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | Record; + export type ViewConfig = { view: View; - viewOptions?: - | SettingsViewOptions - | { - resumedSession?: SessionDetails; - } - | Record; + viewOptions?: ViewOptions; }; export default function App() { @@ -78,6 +79,12 @@ export default function App() { return `${cmd} ${args.join(' ')}`.trim(); } + const setView = (view: View, viewOptions: ViewOptions = {}) => { + console.log(`Setting view to: ${view}`, viewOptions); + setInternalView({ view, viewOptions }); + }; + + // Single initialization effect that handles both v1 and v2 settings useEffect(() => { if (!settingsV2Enabled) { return; @@ -126,7 +133,9 @@ export default function App() { setView('welcome'); } } catch (error) { - setFatalError(`${error.message || 'Unknown error'}`); + setFatalError( + `Initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); setView('welcome'); } @@ -136,18 +145,12 @@ export default function App() { initializeApp().catch((error) => { console.error('Unhandled error in initialization:', error); - setFatalError(`${error.message || 'Unknown error'}`); + setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`); }); - }, []); - - const setView = (view: View, viewOptions: Record = {}) => { - console.log(`Setting view to: ${view}`, viewOptions); - setInternalView({ view, viewOptions }); - }; + }, [read, getExtensions, addExtension]); const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); const [isLoadingSession, setIsLoadingSession] = useState(false); - const [sharedSession, setSharedSession] = useState(null); const [sharedSessionError, setSharedSessionError] = useState(null); const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false); const { chat, setChat } = useChat({ setView, setIsLoadingSession }); @@ -158,13 +161,15 @@ export default function App() { window.electron.reactReady(); } catch (error) { console.error('Error sending reactReady:', error); - setFatalError(`React ready notification failed: ${error.message}`); + setFatalError( + `React ready notification failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); } }, []); // Handle shared session deep links useEffect(() => { - const handleOpenSharedSession = async (_: any, link: string) => { + const handleOpenSharedSession = async (_event: IpcRendererEvent, link: string) => { window.electron.logInfo(`Opening shared session from deep link ${link}`); setIsLoadingSharedSession(true); setSharedSessionError(null); @@ -196,7 +201,7 @@ export default function App() { try { const workingDir = window.appConfig.get('GOOSE_WORKING_DIR'); console.log(`Creating new chat window with working dir: ${workingDir}`); - window.electron.createChatWindow(undefined, workingDir); + window.electron.createChatWindow(undefined, workingDir as string); } catch (error) { console.error('Error creating new window:', error); } @@ -211,7 +216,7 @@ export default function App() { useEffect(() => { console.log('Setting up fatal error handler'); - const handleFatalError = (_: any, errorMessage: string) => { + const handleFatalError = (_event: IpcRendererEvent, errorMessage: string) => { console.error('Encountered a fatal error: ', errorMessage); // Log additional context that might help diagnose the issue console.error('Current view:', view); @@ -227,7 +232,7 @@ export default function App() { useEffect(() => { console.log('Setting up view change handler'); - const handleSetView = (_, newView) => { + const handleSetView = (_event: IpcRendererEvent, newView: View) => { console.log(`Received view change request to: ${newView}`); setView(newView); }; @@ -248,7 +253,7 @@ export default function App() { // TODO: modify useEffect(() => { console.log('Setting up extension handler'); - const handleAddExtension = (_: any, link: string) => { + const handleAddExtension = (_event: IpcRendererEvent, link: string) => { try { console.log(`Received add-extension event with link: ${link}`); const command = extractCommand(link); @@ -298,7 +303,8 @@ export default function App() { setPendingLink(null); }; - // TODO: remove + // TODO: remove -- careful removal of these and the useEffect below breaks + // reloading to chat view using stored provider const { switchModel } = useModel(); // TODO: remove const { addRecentModel } = useRecentModels(); // TODO: remove @@ -323,9 +329,9 @@ export default function App() { } else { setView('welcome'); } - } catch (err) { - console.error('DETECTION ERROR:', err); - setFatalError(`Config detection error: ${err.message || 'Unknown error'}`); + } catch (error) { + console.error('DETECTION ERROR:', error); + setFatalError(`Config detection error: ${error.message || 'Unknown error'}`); } }; @@ -360,18 +366,19 @@ export default function App() { setFatalError(`Initialization failed: ${error.message || 'Unknown error'}`); } } - } catch (err) { - console.error('SETUP ERROR:', err); - setFatalError(`Setup error: ${err.message || 'Unknown error'}`); + } catch (error) { + console.error('SETUP ERROR:', error); + setFatalError(`Setup error: ${error.message || 'Unknown error'}`); } }; // Execute the functions with better error handling detectStoredProvider(); - setupStoredProvider().catch((err) => { - console.error('ASYNC SETUP ERROR:', err); - setFatalError(`Async setup error: ${err.message || 'Unknown error'}`); + setupStoredProvider().catch((error) => { + console.error('ASYNC SETUP ERROR:', error); + setFatalError(`Async setup error: ${error.message || 'Unknown error'}`); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (fatalError) { @@ -472,12 +479,12 @@ export default function App() { {view === 'sessions' && } {view === 'sharedSession' && ( setView('sessions')} onRetry={async () => { - if (viewOptions.shareToken && viewOptions.baseUrl) { + if (viewOptions?.shareToken && viewOptions?.baseUrl) { setIsLoadingSharedSession(true); try { await openSharedSessionFromDeepLink( @@ -498,7 +505,7 @@ export default function App() { {isGoosehintsModalOpen && ( )} diff --git a/ui/desktop/src/components/BottomMenu.tsx b/ui/desktop/src/components/BottomMenu.tsx index 6cd23cf7..4a1bc1b8 100644 --- a/ui/desktop/src/components/BottomMenu.tsx +++ b/ui/desktop/src/components/BottomMenu.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; import { useModel } from './settings/models/ModelContext'; -import { useRecentModels } from './settings/models/RecentModels'; // Hook for recent models import { Sliders } from 'lucide-react'; import { ModelRadioList } from './settings/models/ModelRadioList'; import { Document, ChevronUp, ChevronDown } from './icons'; @@ -19,7 +18,6 @@ export default function BottomMenu({ }) { const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); const { currentModel } = useModel(); - const { recentModels } = useRecentModels(); // Get recent models const dropdownRef = useRef(null); // Add effect to handle clicks outside diff --git a/ui/desktop/src/components/BottomMenuModeSelection.tsx b/ui/desktop/src/components/BottomMenuModeSelection.tsx index f99d480c..9f2eaeaa 100644 --- a/ui/desktop/src/components/BottomMenuModeSelection.tsx +++ b/ui/desktop/src/components/BottomMenuModeSelection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import { getApiUrl, getSecretKey } from '../config'; import { ChevronDown, ChevronUp } from './icons'; import { @@ -16,35 +16,35 @@ export const BottomMenuModeSelection = () => { const gooseModeDropdownRef = useRef(null); const { read, upsert } = useConfig(); - useEffect(() => { - const fetchCurrentMode = async () => { - try { - if (!settingsV2Enabled) { - const response = await fetch(getApiUrl('/configs/get?key=GOOSE_MODE'), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - }); + const fetchCurrentMode = useCallback(async () => { + try { + if (!settingsV2Enabled) { + const response = await fetch(getApiUrl('/configs/get?key=GOOSE_MODE'), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + }); - if (response.ok) { - const { value } = await response.json(); - if (value) { - setGooseMode(value); - } + if (response.ok) { + const { value } = await response.json(); + if (value) { + setGooseMode(value); } - } else { - const mode = (await read('GOOSE_MODE', false)) as string; - setGooseMode(mode); } - } catch (error) { - console.error('Error fetching current mode:', error); + } else { + const mode = (await read('GOOSE_MODE', false)) as string; + setGooseMode(mode); } - }; + } catch (error) { + console.error('Error fetching current mode:', error); + } + }, [read]); + useEffect(() => { fetchCurrentMode(); - }, []); + }, [fetchCurrentMode]); useEffect(() => { const handleEsc = (event: KeyboardEvent) => { diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index cd2599c8..4826257a 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -4,7 +4,7 @@ import BottomMenu from './BottomMenu'; import FlappyGoose from './FlappyGoose'; import GooseMessage from './GooseMessage'; import Input from './Input'; -import { type View } from '../App'; +import { type View, ViewOptions } from '../App'; import LoadingGoose from './LoadingGoose'; import MoreMenuLayout from './more_menu/MoreMenuLayout'; import { Card } from './ui/card'; @@ -22,11 +22,9 @@ import { ToolCall, ToolCallResult, ToolRequestMessageContent, - ToolResponse, ToolResponseMessageContent, ToolConfirmationRequestMessageContent, getTextContent, - createAssistantMessage, } from '../types/message'; export interface ChatType { @@ -38,6 +36,26 @@ export interface ChatType { messages: Message[]; } +interface GeneratedBotConfig { + id: string; + name: string; + description: string; + instructions: string; + activities: string[]; +} + +// 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; +}; + export default function ChatView({ chat, setChat, @@ -46,16 +64,17 @@ export default function ChatView({ }: { chat: ChatType; setChat: (chat: ChatType) => void; - setView: (view: View, viewOptions?: Record) => void; + setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; }) { - const [messageMetadata, setMessageMetadata] = useState>({}); + // Disabled askAi calls to save costs + // const [messageMetadata, setMessageMetadata] = useState>({}); const [hasMessages, setHasMessages] = useState(false); const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); const [showGame, setShowGame] = useState(false); const [waitingForAgentResponse, setWaitingForAgentResponse] = useState(false); const [showShareableBotModal, setshowShareableBotModal] = useState(false); - const [generatedBotConfig, setGeneratedBotConfig] = useState(null); + const [generatedBotConfig, setGeneratedBotConfig] = useState(null); const scrollRef = useRef(null); // Get botConfig directly from appConfig @@ -76,7 +95,7 @@ export default function ChatView({ api: getApiUrl('/reply'), initialMessages: chat.messages, body: { session_id: chat.id, session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR') }, - onFinish: async (message, _reason) => { + onFinish: async (_message, _reason) => { window.electron.stopPowerSaveBlocker(); // Disabled askAi calls to save costs @@ -94,7 +113,7 @@ export default function ChatView({ }); } }, - onToolCall: (toolCall) => { + onToolCall: (toolCall: string) => { // Handle tool calls if needed console.log('Tool call received:', toolCall); // Implement tool call handling logic here @@ -213,7 +232,7 @@ export default function ChatView({ const updatedChat = { ...prevChat, messages }; return updatedChat; }); - }, [messages]); + }, [messages, setChat]); useEffect(() => { if (messages.length > 0) { @@ -304,8 +323,6 @@ export default function ChatView({ content: [], }; - // get the last tool's name or just "tool" - const lastToolName = toolRequests.at(-1)?.[1].value?.name ?? 'tool'; const notification = 'Interrupted by the user to make a correction'; // generate a response saying it was interrupted for each tool request @@ -349,17 +366,6 @@ export default function ChatView({ return true; }); - const isUserMessage = (message: Message) => { - if (message.role === 'assistant') { - return false; - } - - if (message.content.every((c) => c.type === 'toolConfirmationRequest')) { - return false; - } - return true; - }; - const commandHistory = useMemo(() => { return filteredMessages .reduce((history, message) => { @@ -372,7 +378,7 @@ export default function ChatView({ return history; }, []) .reverse(); - }, [filteredMessages, isUserMessage]); + }, [filteredMessages]); return (
@@ -398,7 +404,7 @@ export default function ChatView({ messageHistoryIndex={chat?.messageHistoryIndex} message={message} messages={messages} - metadata={messageMetadata[message.id || '']} + // metadata={messageMetadata[message.id || '']} append={(text) => append(createUserMessage(text))} appendMessage={(newMessage) => { const updatedMessages = [...messages, newMessage]; diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 9d81c5e2..9359de80 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'; +import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; import { readAllConfig, readConfig, @@ -70,6 +70,115 @@ export const ConfigProvider: React.FC = ({ children }) => { const [providersList, setProvidersList] = useState([]); const [extensionsList, setExtensionsList] = useState([]); + const reloadConfig = useCallback(async () => { + const response = await readAllConfig(); + setConfig(response.data.config || {}); + }, []); + + const upsert = useCallback( + async (key: string, value: unknown, isSecret: boolean = false) => { + const query: UpsertConfigQuery = { + key: key, + value: value, + is_secret: isSecret, + }; + await upsertConfig({ + body: query, + }); + await reloadConfig(); + }, + [reloadConfig] + ); + + const read = useCallback(async (key: string, is_secret: boolean = false) => { + const query: ConfigKeyQuery = { key: key, is_secret: is_secret }; + const response = await readConfig({ + body: query, + }); + return response.data; + }, []); + + const remove = useCallback( + async (key: string, is_secret: boolean) => { + const query: ConfigKeyQuery = { key: key, is_secret: is_secret }; + await removeConfig({ + body: query, + }); + await reloadConfig(); + }, + [reloadConfig] + ); + + const addExtension = useCallback( + async (name: string, config: ExtensionConfig, enabled: boolean) => { + // remove shims if present + if (config.type === 'stdio') { + config.cmd = removeShims(config.cmd); + } + const query: ExtensionQuery = { name, config, enabled }; + await apiAddExtension({ + body: query, + }); + await reloadConfig(); + }, + [reloadConfig] + ); + + const removeExtension = useCallback( + async (name: string) => { + await apiRemoveExtension({ path: { name: name } }); + await reloadConfig(); + }, + [reloadConfig] + ); + + const getExtensions = useCallback( + async (forceRefresh = false): Promise => { + if (forceRefresh || extensionsList.length === 0) { + const result = await apiGetExtensions(); + + if (result.response.status === 422) { + throw new MalformedConfigError(); + } + + if (result.error && !result.data) { + console.log(result.error); + return extensionsList; + } + + const extensionResponse: ExtensionResponse = result.data; + setExtensionsList(extensionResponse.extensions); + return extensionResponse.extensions; + } + return extensionsList; + }, + [extensionsList] + ); + + const toggleExtension = useCallback( + async (name: string) => { + const exts = await getExtensions(true); + const extension = exts.find((ext) => ext.name === name); + + if (extension) { + await addExtension(name, extension, !extension.enabled); + } + }, + [addExtension, getExtensions] + ); + + const getProviders = useCallback( + async (forceRefresh = false): Promise => { + if (forceRefresh || providersList.length === 0) { + const response = await providers(); + setProvidersList(response.data); + return response.data; + } + return providersList; + }, + [providersList] + ); + useEffect(() => { // Load all configuration data and providers on mount (async () => { @@ -95,100 +204,6 @@ export const ConfigProvider: React.FC = ({ children }) => { })(); }, []); - const reloadConfig = async () => { - const response = await readAllConfig(); - setConfig(response.data.config || {}); - }; - - const upsert = async (key: string, value: unknown, isSecret: boolean = false) => { - const query: UpsertConfigQuery = { - key: key, - value: value, - is_secret: isSecret, - }; - await upsertConfig({ - body: query, - }); - await reloadConfig(); - }; - - const read = async (key: string, is_secret: boolean = false) => { - const query: ConfigKeyQuery = { key: key, is_secret: is_secret }; - const response = await readConfig({ - body: query, - }); - return response.data; - }; - - const remove = async (key: string, is_secret: boolean) => { - const query: ConfigKeyQuery = { key: key, is_secret: is_secret }; - await removeConfig({ - body: query, - }); - await reloadConfig(); - }; - - const addExtension = async (name: string, config: ExtensionConfig, enabled: boolean) => { - // remove shims if present - if (config.type == 'stdio') { - config.cmd = removeShims(config.cmd); - } - const query: ExtensionQuery = { name, config, enabled }; - await apiAddExtension({ - body: query, - }); - await reloadConfig(); - }; - - const removeExtension = async (name: string) => { - await apiRemoveExtension({ path: { name: name } }); - await reloadConfig(); - }; - - const toggleExtension = async (name: string) => { - // Get current extensions to find the one we need to toggle - const exts = await getExtensions(true); - const extension = exts.find((ext) => ext.name === name); - - if (extension) { - // Toggle the enabled state and update using addExtension - await addExtension(name, extension, !extension.enabled); - } - }; - - const getProviders = async (forceRefresh = false): Promise => { - if (forceRefresh || providersList.length === 0) { - // If a refresh is forced or we don't have providers yet - const response = await providers(); - setProvidersList(response.data); - return response.data; - } - // Otherwise return the cached providers - return providersList; - }; - - const getExtensions = async (forceRefresh = false): Promise => { - // If a refresh is forced, or we don't have providers yet - if (forceRefresh || extensionsList.length === 0) { - const result = await apiGetExtensions(); - - if (result.response.status === 422) { - throw new MalformedConfigError(); - } - - if (result.error && !result.data) { - console.log(result.error); - return; - } - - const extensionResponse: ExtensionResponse = result.data; - setExtensionsList(extensionResponse.extensions); - return extensionResponse.extensions; - } - // Otherwise return the cached providers - return extensionsList; - }; - const contextValue = useMemo( () => ({ config, diff --git a/ui/desktop/src/components/FlappyGoose.tsx b/ui/desktop/src/components/FlappyGoose.tsx index 8b9a7a1c..3cd8b589 100644 --- a/ui/desktop/src/components/FlappyGoose.tsx +++ b/ui/desktop/src/components/FlappyGoose.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; declare var requestAnimationFrame: (callback: FrameRequestCallback) => number; declare class HTMLCanvasElement {} @@ -51,57 +51,18 @@ const FlappyGoose: React.FC = ({ onClose }) => { const OBSTACLE_WIDTH = 40; const FLAP_DURATION = 150; - const safeRequestAnimationFrame = (callback: FrameRequestCallback) => { + const safeRequestAnimationFrame = useCallback((callback: FrameRequestCallback) => { if (typeof window !== 'undefined' && typeof requestAnimationFrame !== 'undefined') { requestAnimationFrame(callback); } - }; - - // Load goose images - useEffect(() => { - const frames = [svg1, svg7]; - frames.forEach((src, index) => { - const img = new Image(); - img.src = src; - img.onload = () => { - framesLoaded.current += 1; - if (framesLoaded.current === frames.length) { - setImagesReady(true); - } - }; - gooseImages.current[index] = img; - }); }, []); - const startGame = () => { - if (gameState.current.running || !imagesReady || typeof window === 'undefined') return; + const handleGameOver = useCallback(() => { + gameState.current.running = false; + setGameOver(true); + }, []); - gameState.current = { - gooseY: CANVAS_HEIGHT / 3, - velocity: 0, - obstacles: [], - gameLoop: 0, - running: true, - score: 0, - isFlapping: false, - flapEndTime: 0, - }; - setGameOver(false); - setDisplayScore(0); - safeRequestAnimationFrame(gameLoop); - }; - - const flap = () => { - if (gameOver) { - startGame(); - return; - } - gameState.current.velocity = FLAP_FORCE; - gameState.current.isFlapping = true; - gameState.current.flapEndTime = Date.now() + FLAP_DURATION; - }; - - const gameLoop = () => { + const gameLoop = useCallback(() => { if (!gameState.current.running || !imagesReady) return; const canvas = canvasRef.current; if (!canvas) return; @@ -209,12 +170,63 @@ const FlappyGoose: React.FC = ({ onClose }) => { gameState.current.gameLoop++; safeRequestAnimationFrame(gameLoop); - }; + }, [ + CANVAS_HEIGHT, + CANVAS_WIDTH, + GOOSE_SIZE, + GOOSE_X, + GRAVITY, + OBSTACLE_GAP, + OBSTACLE_SPEED, + OBSTACLE_WIDTH, + handleGameOver, + imagesReady, + safeRequestAnimationFrame, + ]); - const handleGameOver = () => { - gameState.current.running = false; - setGameOver(true); - }; + const startGame = useCallback(() => { + if (gameState.current.running || !imagesReady || typeof window === 'undefined') return; + + gameState.current = { + gooseY: CANVAS_HEIGHT / 3, + velocity: 0, + obstacles: [], + gameLoop: 0, + running: true, + score: 0, + isFlapping: false, + flapEndTime: 0, + }; + setGameOver(false); + setDisplayScore(0); + safeRequestAnimationFrame(gameLoop); + }, [CANVAS_HEIGHT, imagesReady, safeRequestAnimationFrame, gameLoop]); + + const flap = useCallback(() => { + if (gameOver) { + startGame(); + return; + } + gameState.current.velocity = FLAP_FORCE; + gameState.current.isFlapping = true; + gameState.current.flapEndTime = Date.now() + FLAP_DURATION; + }, [FLAP_DURATION, FLAP_FORCE, gameOver, startGame]); + + // Load goose images + useEffect(() => { + const frames = [svg1, svg7]; + frames.forEach((src, index) => { + const img = new Image(); + img.src = src; + img.onload = () => { + framesLoaded.current += 1; + if (framesLoaded.current === frames.length) { + setImagesReady(true); + } + }; + gooseImages.current[index] = img; + }); + }, []); useEffect(() => { const canvas = canvasRef.current; @@ -240,7 +252,7 @@ const FlappyGoose: React.FC = ({ onClose }) => { window.removeEventListener('keydown', handleKeyPress); gameState.current.running = false; }; - }, [imagesReady]); + }, [CANVAS_HEIGHT, CANVAS_WIDTH, flap, imagesReady, startGame]); return (
{ // If the message is the last message in the resumed session and has tool confirmation, it means the tool confirmation // is broken or cancelled, to contonue use the session, we need to append a tool response to avoid mismatch tool result error. - if (messageIndex == messageHistoryIndex - 1 && hasToolConfirmation) { + if ( + messageIndex === messageHistoryIndex - 1 && + hasToolConfirmation && + toolConfirmationContent + ) { appendMessage( createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.') ); } - }, []); + }, [ + messageIndex, + messageHistoryIndex, + hasToolConfirmation, + toolConfirmationContent, + appendMessage, + ]); return (
diff --git a/ui/desktop/src/components/Input.tsx b/ui/desktop/src/components/Input.tsx index bf8492a3..248923be 100644 --- a/ui/desktop/src/components/Input.tsx +++ b/ui/desktop/src/components/Input.tsx @@ -42,36 +42,33 @@ export default function Input({ } }, []); - // Debounced function to update actual value - const debouncedSetValue = useCallback( - debounce((val: string) => { - setValue(val); - }, 150), - [] - ); - - // Debounced autosize function - const debouncedAutosize = useCallback( - debounce((textArea: HTMLTextAreaElement, value: string) => { - textArea.style.height = '0px'; // Reset height - const scrollHeight = textArea.scrollHeight; - textArea.style.height = Math.min(scrollHeight, maxHeight) + 'px'; - }, 150), - [] - ); - - const useAutosizeTextArea = (textAreaRef: HTMLTextAreaElement | null, value: string) => { - useEffect(() => { - if (textAreaRef) { - debouncedAutosize(textAreaRef, value); - } - }, [textAreaRef, value]); - }; - const minHeight = '1rem'; const maxHeight = 10 * 24; - useAutosizeTextArea(textAreaRef.current, displayValue); + // Debounced function to update actual value + const debouncedSetValue = useCallback((val: string) => { + debounce((value: string) => { + setValue(value); + }, 150)(val); + }, []); + + // Debounced autosize function + const debouncedAutosize = useCallback( + (textArea: HTMLTextAreaElement) => { + debounce((element: HTMLTextAreaElement) => { + element.style.height = '0px'; // Reset height + const scrollHeight = element.scrollHeight; + element.style.height = Math.min(scrollHeight, maxHeight) + 'px'; + }, 150)(textArea); + }, + [maxHeight] + ); + + useEffect(() => { + if (textAreaRef.current) { + debouncedAutosize(textAreaRef.current); + } + }, [debouncedAutosize, displayValue]); const handleChange = (evt: React.ChangeEvent) => { const val = evt.target.value; @@ -82,17 +79,17 @@ export default function Input({ // Cleanup debounced functions on unmount useEffect(() => { return () => { - debouncedSetValue.cancel(); - debouncedAutosize.cancel(); + debouncedSetValue.cancel?.(); + debouncedAutosize.cancel?.(); }; - }, []); + }, [debouncedSetValue, debouncedAutosize]); // Handlers for composition events, which are crucial for proper IME behavior - const handleCompositionStart = (evt: React.CompositionEvent) => { + const handleCompositionStart = () => { setIsComposing(true); }; - const handleCompositionEnd = (evt: React.CompositionEvent) => { + const handleCompositionEnd = () => { setIsComposing(false); }; @@ -118,7 +115,7 @@ export default function Input({ } } - if (newIndex == historyIndex) { + if (newIndex === historyIndex) { return; } @@ -231,7 +228,7 @@ export default function Input({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); - onStop(); + onStop?.(); }} className="absolute right-2 top-1/2 -translate-y-1/2 [&_svg]:size-5 text-textSubtle hover:text-textStandard" > diff --git a/ui/desktop/src/components/MarkdownContent.tsx b/ui/desktop/src/components/MarkdownContent.tsx index 26c63fe3..ca7fe384 100644 --- a/ui/desktop/src/components/MarkdownContent.tsx +++ b/ui/desktop/src/components/MarkdownContent.tsx @@ -4,15 +4,14 @@ import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { oneLight } from 'react-syntax-highlighter/dist/cjs/styles/prism'; import { Check, Copy } from './icons'; import { visit } from 'unist-util-visit'; function rehypeinlineCodeProperty() { return function (tree) { if (!tree) return; - visit(tree, 'element', function (node, index, parent) { - if (node.tagName == 'code' && parent && parent.tagName === 'pre') { + visit(tree, 'element', function (node) { + if (node.tagName == 'code' && node.parent && node.parent.tagName === 'pre') { node.properties.inlinecode = 'false'; } else { node.properties.inlinecode = 'true'; @@ -75,8 +74,6 @@ const CodeBlock = ({ language, children }: { language: string; children: string }; export default function MarkdownContent({ content, className = '' }: MarkdownContentProps) { - // Determine whether dark mode is enabled - const isDarkMode = document.documentElement.classList.contains('dark'); return (
, - code({ node, className, children, inlinecode, ...props }) { + a: ({ ...props }) => , + code({ className, children, inlinecode, ...props }) { const match = /language-(\w+)/.exec(className || 'language-text'); return inlinecode == 'false' && match ? ( {String(children).replace(/\n$/, '')} diff --git a/ui/desktop/src/components/ToolCallArguments.tsx b/ui/desktop/src/components/ToolCallArguments.tsx index 3c06736a..dbff369e 100644 --- a/ui/desktop/src/components/ToolCallArguments.tsx +++ b/ui/desktop/src/components/ToolCallArguments.tsx @@ -2,8 +2,16 @@ import { ChevronUp } from 'lucide-react'; import React, { useState } from 'react'; import MarkdownContent from './MarkdownContent'; +type ToolCallArgumentValue = + | string + | number + | boolean + | null + | ToolCallArgumentValue[] + | { [key: string]: ToolCallArgumentValue }; + interface ToolCallArgumentsProps { - args: Record; + args: Record; } export function ToolCallArguments({ args }: ToolCallArgumentsProps) { @@ -13,7 +21,7 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) { setExpandedKeys((prev) => ({ ...prev, [key]: !prev[key] })); }; - const renderValue = (key: string, value: any) => { + const renderValue = (key: string, value: ToolCallArgumentValue) => { if (typeof value === 'string') { const needsExpansion = value.length > 60; const isExpanded = expandedKeys[key]; @@ -45,18 +53,12 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) { onClick={() => toggleKey(key)} className="text-sm hover:opacity-75 text-textStandard" > - {/* {isExpanded ? '▼ ' : '▶ '} */}
- {/* {isExpanded && ( -
- -
- )} */}
); } diff --git a/ui/desktop/src/components/conversation/SearchBar.tsx b/ui/desktop/src/components/conversation/SearchBar.tsx index 26861c05..595da07a 100644 --- a/ui/desktop/src/components/conversation/SearchBar.tsx +++ b/ui/desktop/src/components/conversation/SearchBar.tsx @@ -44,21 +44,25 @@ export const SearchBar: React.FC = ({ // Create debounced search function const debouncedSearch = useCallback( - debounce((term: string, caseSensitive: boolean) => { - onSearch(term, caseSensitive); - }, 150), - [] + (term: string, isCaseSensitive: boolean) => { + debounce((searchTerm: string, caseSensitive: boolean) => { + onSearch(searchTerm, caseSensitive); + }, 150)(term, isCaseSensitive); + }, + [onSearch] ); useEffect(() => { inputRef.current?.focus(); - - // Cleanup debounced function - return () => { - debouncedSearch.cancel(); - }; }, []); + // Cleanup debounced function on unmount + useEffect(() => { + return () => { + debouncedSearch.cancel?.(); + }; + }, [debouncedSearch]); + const handleSearch = (event: React.ChangeEvent) => { const value = event.target.value; setDisplayTerm(value); // Update display immediately @@ -93,7 +97,7 @@ export const SearchBar: React.FC = ({ const handleClose = () => { setIsExiting(true); - debouncedSearch.cancel(); // Cancel any pending searches + debouncedSearch.cancel?.(); // Cancel any pending searches setTimeout(() => { onClose(); }, 150); // Match animation duration diff --git a/ui/desktop/src/components/conversation/SearchView.tsx b/ui/desktop/src/components/conversation/SearchView.tsx index a08e6562..8f5d53a1 100644 --- a/ui/desktop/src/components/conversation/SearchView.tsx +++ b/ui/desktop/src/components/conversation/SearchView.tsx @@ -36,20 +36,25 @@ export const SearchView: React.FC> = ({ // Create debounced highlight function const debouncedHighlight = useCallback( - debounce((term: string, caseSensitive: boolean, highlighter: SearchHighlighter) => { - const highlights = highlighter.highlight(term, caseSensitive); - const count = highlights.length; + (term: string, caseSensitive: boolean, highlighter: SearchHighlighter) => { + debounce( + (searchTerm: string, isCaseSensitive: boolean, searchHighlighter: SearchHighlighter) => { + const highlights = searchHighlighter.highlight(searchTerm, isCaseSensitive); + const count = highlights.length; - if (count > 0) { - setSearchResults({ - currentIndex: 1, - count, - }); - highlighter.setCurrentMatch(0, true); // Explicitly scroll when setting initial match - } else { - setSearchResults(null); - } - }, 150), + if (count > 0) { + setSearchResults({ + currentIndex: 1, + count, + }); + searchHighlighter.setCurrentMatch(0, true); // Explicitly scroll when setting initial match + } else { + setSearchResults(null); + } + }, + 150 + )(term, caseSensitive, highlighter); + }, [] ); @@ -60,9 +65,9 @@ export const SearchView: React.FC> = ({ highlighterRef.current.destroy(); highlighterRef.current = null; } - debouncedHighlight.cancel(); + debouncedHighlight.cancel?.(); }; - }, []); + }, [debouncedHighlight]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -162,7 +167,7 @@ export const SearchView: React.FC> = ({ highlighterRef.current.clearHighlights(); } // Cancel any pending highlight operations - debouncedHighlight.cancel(); + debouncedHighlight.cancel?.(); }; return ( diff --git a/ui/desktop/src/components/more_menu/MoreMenu.tsx b/ui/desktop/src/components/more_menu/MoreMenu.tsx index 12a1d207..973efd45 100644 --- a/ui/desktop/src/components/more_menu/MoreMenu.tsx +++ b/ui/desktop/src/components/more_menu/MoreMenu.tsx @@ -1,20 +1,10 @@ -import { - Popover, - PopoverContent, - PopoverPortal, - PopoverTrigger, -} from '../../components/ui/popover'; +import { Popover, PopoverContent, PopoverPortal, PopoverTrigger } from '../ui/popover'; import React, { useEffect, useState } from 'react'; import { ChatSmart, Idea, More, Refresh, Time, Send } from '../icons'; import { FolderOpen, Moon, Sliders, Sun } from 'lucide-react'; -import { View } from '../../App'; import { useConfig } from '../ConfigContext'; import { settingsV2Enabled } from '../../flags'; - -interface VersionInfo { - current_version: string; - available_versions: string[]; -} +import { ViewOptions, View } from '../../App'; interface MenuButtonProps { onClick: () => void; @@ -105,13 +95,11 @@ export default function MoreMenu({ setView, setIsGoosehintsModalOpen, }: { - setView: (view: View) => void; + setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; }) { const [open, setOpen] = useState(false); const { remove } = useConfig(); - const [versions, setVersions] = useState(null); - const [showVersions, setShowVersions] = useState(false); const [themeMode, setThemeMode] = useState<'light' | 'dark' | 'system'>(() => { const savedUseSystemTheme = localStorage.getItem('use_system_theme') === 'true'; if (savedUseSystemTheme) { @@ -129,27 +117,6 @@ export default function MoreMenu({ return themeMode === 'dark'; }); - useEffect(() => { - // Fetch available versions when the menu opens - const fetchVersions = async () => { - try { - const port = window.appConfig.get('GOOSE_PORT'); - const response = await fetch(`http://127.0.0.1:${port}/agent/versions`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); - setVersions(data); - } catch (error) { - console.error('Failed to fetch versions:', error); - } - }; - - if (open) { - fetchVersions(); - } - }, [open]); - useEffect(() => { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); @@ -196,6 +163,8 @@ export default function MoreMenu({ diff --git a/ui/desktop/src/components/more_menu/MoreMenuLayout.tsx b/ui/desktop/src/components/more_menu/MoreMenuLayout.tsx index 91f5a485..f697b690 100644 --- a/ui/desktop/src/components/more_menu/MoreMenuLayout.tsx +++ b/ui/desktop/src/components/more_menu/MoreMenuLayout.tsx @@ -1,12 +1,12 @@ import MoreMenu from './MoreMenu'; import React from 'react'; -import type { View } from '../../App'; +import { View, ViewOptions } from '../../App'; export default function MoreMenuLayout({ setView, setIsGoosehintsModalOpen, }: { - setView: (view: View, viewOptions?: Record) => void; + setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; }) { return ( diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index d67e87ec..8601229c 100644 --- a/ui/desktop/src/components/sessions/SessionHistoryView.tsx +++ b/ui/desktop/src/components/sessions/SessionHistoryView.tsx @@ -39,7 +39,6 @@ const SessionHistoryView: React.FC = ({ const [isSharing, setIsSharing] = useState(false); const [isCopied, setIsCopied] = useState(false); const [canShare, setCanShare] = useState(false); - const [shareError, setShareError] = useState(null); useEffect(() => { const savedSessionConfig = localStorage.getItem('session_sharing_config'); @@ -58,7 +57,6 @@ const SessionHistoryView: React.FC = ({ const handleShare = async () => { setIsSharing(true); - setShareError(null); try { // Get the session sharing configuration from localStorage @@ -87,7 +85,6 @@ const SessionHistoryView: React.FC = ({ setIsShareModalOpen(true); } catch (error) { console.error('Error sharing session:', error); - setShareError(error instanceof Error ? error.message : 'Unknown error occurred'); toast.error( `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}` ); diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 616b731b..5ad2cec7 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState } from 'react'; -import { ViewConfig } from '../../App'; import { MessageSquareText, Target, @@ -14,9 +13,10 @@ import { Card } from '../ui/card'; import { Button } from '../ui/button'; import BackButton from '../ui/BackButton'; import { ScrollArea } from '../ui/scroll-area'; +import { View, ViewOptions } from '../../App'; interface SessionListViewProps { - setView: (view: ViewConfig['view'], viewOptions?: Record) => void; + setView: (view: View, viewOptions?: ViewOptions) => void; onSelectSession: (sessionId: string) => void; } diff --git a/ui/desktop/src/components/sessions/SessionsView.tsx b/ui/desktop/src/components/sessions/SessionsView.tsx index 8e8d9d6b..c28cd7be 100644 --- a/ui/desktop/src/components/sessions/SessionsView.tsx +++ b/ui/desktop/src/components/sessions/SessionsView.tsx @@ -1,12 +1,12 @@ import React, { useState } from 'react'; -import { ViewConfig } from '../../App'; +import { View, ViewOptions } from '../../App'; import { fetchSessionDetails, type SessionDetails } from '../../sessions'; import SessionListView from './SessionListView'; import SessionHistoryView from './SessionHistoryView'; import { toastError } from '../../toasts'; interface SessionsViewProps { - setView: (view: ViewConfig['view'], viewOptions?: Record) => void; + setView: (view: View, viewOptions?: ViewOptions) => void; } const SessionsView: React.FC = ({ setView }) => { diff --git a/ui/desktop/src/components/settings/OllamaBattleGame.tsx b/ui/desktop/src/components/settings/OllamaBattleGame.tsx index d251ce63..c3b98f21 100644 --- a/ui/desktop/src/components/settings/OllamaBattleGame.tsx +++ b/ui/desktop/src/components/settings/OllamaBattleGame.tsx @@ -23,8 +23,10 @@ interface OllamaBattleGameProps { } export function OllamaBattleGame({ onComplete, _requiredKeys }: OllamaBattleGameProps) { - // Use type assertion for audioRef to avoid DOM lib dependency - const audioRef = useRef(null); + // Use Audio element type for audioRef + const audioRef = useRef<{ play: () => Promise; pause: () => void; volume: number } | null>( + null + ); const [isMuted, setIsMuted] = useState(false); const [battleState, setBattleState] = useState({ diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index 0d048ab3..e0f8c2ea 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { IpcRendererEvent } from 'electron'; import { ScrollArea } from '../ui/scroll-area'; import { Settings as SettingsType } from './types'; import { @@ -13,7 +14,7 @@ import { ConfigureBuiltInExtensionModal } from './extensions/ConfigureBuiltInExt import BackButton from '../ui/BackButton'; import { RecentModelsRadio } from './models/RecentModels'; import { ExtensionItem } from './extensions/ExtensionItem'; -import type { View } from '../../App'; +import { View, ViewOptions } from '../../App'; import { ModeSelection } from './basic/ModeSelection'; import SessionSharingSection from './session/SessionSharingSection'; import { toastSuccess } from '../../toasts'; @@ -59,7 +60,7 @@ export default function SettingsView({ viewOptions, }: { onClose: () => void; - setView: (view: View) => void; + setView: (view: View, viewOptions?: ViewOptions) => void; viewOptions: SettingsViewOptions; }) { const [settings, setSettings] = React.useState(() => { @@ -92,7 +93,7 @@ export default function SettingsView({ // Listen for settings updates from extension storage useEffect(() => { - const handleSettingsUpdate = (_: any) => { + const handleSettingsUpdate = (_event: IpcRendererEvent) => { const saved = localStorage.getItem('user_settings'); if (saved) { let currentSettings = JSON.parse(saved); diff --git a/ui/desktop/src/components/settings/api_keys/utils.tsx b/ui/desktop/src/components/settings/api_keys/utils.tsx index e3847684..8ae62fef 100644 --- a/ui/desktop/src/components/settings/api_keys/utils.tsx +++ b/ui/desktop/src/components/settings/api_keys/utils.tsx @@ -101,8 +101,18 @@ export async function getProvidersList(): Promise { const data = await response.json(); + interface ProviderItem { + id: string; + details?: { + name?: string; + description?: string; + models?: string[]; + required_keys?: string[]; + }; + } + // Format the response into an array of providers - return data.map((item: any) => ({ + return data.map((item: ProviderItem) => ({ id: item.id, // Root-level ID name: item.details?.name || 'Unknown Provider', // Nested name in details description: item.details?.description || 'No description available.', // Nested description diff --git a/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx index 637c2389..db03a5a3 100644 --- a/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx +++ b/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx @@ -4,7 +4,6 @@ import { Button } from '../../ui/button'; import { Input } from '../../ui/input'; import { FullExtensionConfig, DEFAULT_EXTENSION_TIMEOUT } from '../../../extensions'; import { Select } from '../../ui/Select'; -import { createDarkSelectStyles, darkSelectTheme } from '../../ui/select-styles'; import { getApiUrl, getSecretKey } from '../../../config'; import { toastError } from '../../../toasts'; diff --git a/ui/desktop/src/components/settings/models/hardcoded_stuff.tsx b/ui/desktop/src/components/settings/models/hardcoded_stuff.tsx index c682bf34..668974cc 100644 --- a/ui/desktop/src/components/settings/models/hardcoded_stuff.tsx +++ b/ui/desktop/src/components/settings/models/hardcoded_stuff.tsx @@ -1,5 +1,3 @@ -import { Model } from './ModelContext'; - export const openai_models = ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'o1']; export const anthropic_models = [ diff --git a/ui/desktop/src/components/settings/providers/BaseProviderGrid.tsx b/ui/desktop/src/components/settings/providers/BaseProviderGrid.tsx index bce08f64..7f39eb82 100644 --- a/ui/desktop/src/components/settings/providers/BaseProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/BaseProviderGrid.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Check, Plus, Settings, X, Rocket } from 'lucide-react'; import { Button } from '../../ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/Tooltip'; import { Portal } from '@radix-ui/react-portal'; import { required_keys } from '../models/hardcoded_stuff'; -import { useActiveKeys } from '../api_keys/ActiveKeysContext'; -import { getActiveProviders } from '../api_keys/utils'; // Common interfaces and helper functions interface Provider { @@ -67,7 +65,6 @@ function BaseProviderCard({ }: BaseProviderCardProps) { const numRequiredKeys = required_keys[name]?.length || 0; const tooltipText = numRequiredKeys === 1 ? `Add ${name} API Key` : `Add ${name} API Keys`; - const { activeKeys, setActiveKeys } = useActiveKeys(); return (
diff --git a/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx b/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx index ab3c86b6..7c6d9e09 100644 --- a/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx @@ -4,7 +4,6 @@ import { BaseProviderGrid, getProviderDescription } from './BaseProviderGrid'; import { supported_providers, provider_aliases, required_keys } from '../models/hardcoded_stuff'; import { ProviderSetupModal } from '../ProviderSetupModal'; import { getApiUrl, getSecretKey } from '../../../config'; -import { toast } from 'react-toastify'; import { getActiveProviders, isSecretKey } from '../api_keys/utils'; import { useModel } from '../models/ModelContext'; import { Button } from '../../ui/button'; diff --git a/ui/desktop/src/components/settings_v2/SettingsView.tsx b/ui/desktop/src/components/settings_v2/SettingsView.tsx index 78976eff..3d797ec1 100644 --- a/ui/desktop/src/components/settings_v2/SettingsView.tsx +++ b/ui/desktop/src/components/settings_v2/SettingsView.tsx @@ -15,11 +15,9 @@ export type SettingsViewOptions = { export default function SettingsView({ onClose, setView, - viewOptions, }: { onClose: () => void; setView: (view: View) => void; - viewOptions: SettingsViewOptions; }) { return (
diff --git a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx index 86763ff7..d21e1008 100644 --- a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { Button } from '../../ui/button'; import { Plus } from 'lucide-react'; import { GPSIcon } from '../../ui/icons'; @@ -17,39 +17,29 @@ import { activateExtension, deleteExtension, toggleExtension, updateExtension } export default function ExtensionsSection() { const { getExtensions, addExtension, removeExtension } = useConfig(); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [extensions, setExtensions] = useState([]); const [selectedExtension, setSelectedExtension] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); // We don't need errorFormData anymore since we're not reopening modals on failure - const fetchExtensions = async () => { - setLoading(true); - try { - const extensionsList = await getExtensions(true); // Force refresh - // Sort extensions by name to maintain consistent order - const sortedExtensions = [...extensionsList].sort((a, b) => a.name.localeCompare(b.name)); - setExtensions(sortedExtensions); - setError(null); - } catch (err) { - setError('Failed to load extensions'); - console.error('Error loading extensions:', err); - } finally { - setLoading(false); - } - }; + const fetchExtensions = useCallback(async () => { + const extensionsList = await getExtensions(true); // Force refresh + // Sort extensions by name to maintain consistent order + const sortedExtensions = [...extensionsList].sort((a, b) => a.name.localeCompare(b.name)); + setExtensions(sortedExtensions); + }, [getExtensions]); useEffect(() => { fetchExtensions(); - }, []); + }, [fetchExtensions]); const handleExtensionToggle = async (extension: FixedExtensionEntry) => { // If extension is enabled, we are trying to toggle if off, otherwise on const toggleDirection = extension.enabled ? 'toggleOff' : 'toggleOn'; const extensionConfig = extractExtensionConfig(extension); + // eslint-disable-next-line no-useless-catch try { await toggleExtension({ toggle: toggleDirection, diff --git a/ui/desktop/src/components/settings_v2/extensions/agent-api.ts b/ui/desktop/src/components/settings_v2/extensions/agent-api.ts index 29102df5..243a91c2 100644 --- a/ui/desktop/src/components/settings_v2/extensions/agent-api.ts +++ b/ui/desktop/src/components/settings_v2/extensions/agent-api.ts @@ -3,12 +3,17 @@ import { getApiUrl, getSecretKey } from '../../../config'; import { toastService, ToastServiceOptions } from '../../../toasts'; import { replaceWithShims } from './utils'; +interface ApiResponse { + error?: boolean; + message?: string; +} + /** * Makes an API call to the extension endpoints */ export async function extensionApiCall( endpoint: string, - payload: any, + payload: ExtensionConfig | string, options: ToastServiceOptions = {} ): Promise { // Configure toast notifications @@ -118,7 +123,7 @@ function handleErrorResponse( } // Safely parses JSON response -async function parseResponseData(response: Response): Promise { +async function parseResponseData(response: Response): Promise { try { const text = await response.text(); return text ? JSON.parse(text) : { error: false }; diff --git a/ui/desktop/src/components/settings_v2/extensions/extension-manager.ts b/ui/desktop/src/components/settings_v2/extensions/extension-manager.ts index e558f89f..bd0f4113 100644 --- a/ui/desktop/src/components/settings_v2/extensions/extension-manager.ts +++ b/ui/desktop/src/components/settings_v2/extensions/extension-manager.ts @@ -7,6 +7,46 @@ interface ActivateExtensionProps { extensionConfig: ExtensionConfig; } +type ExtensionError = { + message?: string; + code?: number; + name?: string; + stack?: string; +}; + +type RetryOptions = { + retries?: number; + delayMs?: number; + shouldRetry?: (error: ExtensionError, attempt: number) => boolean; + backoffFactor?: number; // multiplier for exponential backoff +}; + +async function retryWithBackoff(fn: () => Promise, options: RetryOptions = {}): Promise { + const { retries = 3, delayMs = 1000, backoffFactor = 1.5, shouldRetry = () => true } = options; + + let attempt = 0; + let lastError: ExtensionError; + + while (attempt <= retries) { + try { + return await fn(); + } catch (err) { + lastError = err as ExtensionError; + attempt++; + + if (attempt > retries || !shouldRetry(lastError, attempt)) { + break; + } + + const waitTime = delayMs * Math.pow(backoffFactor, attempt - 1); + console.warn(`Retry attempt ${attempt} failed. Retrying in ${waitTime}ms...`, err); + await new Promise((res) => setTimeout(res, waitTime)); + } + } + + throw lastError; +} + /** * Activates an extension by adding it to both the config system and the API. * @param props The extension activation properties @@ -43,39 +83,6 @@ export async function activateExtension({ } } -type RetryOptions = { - retries?: number; - delayMs?: number; - shouldRetry?: (error: unknown, attempt: number) => boolean; - backoffFactor?: number; // multiplier for exponential backoff -}; - -async function retryWithBackoff(fn: () => Promise, options: RetryOptions = {}): Promise { - const { retries = 3, delayMs = 1000, backoffFactor = 1.5, shouldRetry = () => true } = options; - - let attempt = 0; - let lastError: unknown; - - while (attempt <= retries) { - try { - return await fn(); - } catch (err) { - lastError = err; - attempt++; - - if (attempt > retries || !shouldRetry(err, attempt)) { - break; - } - - const waitTime = delayMs * Math.pow(backoffFactor, attempt - 1); - console.warn(`Retry attempt ${attempt} failed. Retrying in ${waitTime}ms...`, err); - await new Promise((res) => setTimeout(res, waitTime)); - } - } - - throw lastError; -} - interface AddToAgentOnStartupProps { addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; extensionConfig: ExtensionConfig; @@ -92,7 +99,7 @@ export async function addToAgentOnStartup({ await retryWithBackoff(() => addToAgent(extensionConfig, { silent: true }), { retries: 3, delayMs: 1000, - shouldRetry: (error: any) => + shouldRetry: (error: ExtensionError) => error.message && (error.message.includes('428') || error.message.includes('Precondition Required') || @@ -103,7 +110,7 @@ export async function addToAgentOnStartup({ toastService.error({ title: extensionConfig.name, msg: 'Extension failed to start and will be disabled.', - traceback: finalError, + traceback: finalError as Error, }); try { diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx b/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx index 4f9bd7f4..36fa09cc 100644 --- a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx @@ -5,7 +5,7 @@ interface ExtensionConfigFieldsProps { type: 'stdio' | 'sse' | 'builtin'; full_cmd: string; endpoint: string; - onChange: (key: string, value: any) => void; + onChange: (key: string, value: string) => void; submitAttempted?: boolean; isValid?: boolean; } diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionInfoFields.tsx b/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionInfoFields.tsx index 7433a2df..4dcc91a3 100644 --- a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionInfoFields.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionInfoFields.tsx @@ -1,12 +1,12 @@ import { Input } from '../../../ui/input'; import { Select } from '../../../ui/Select'; -import React, { useState } from 'react'; +import React from 'react'; interface ExtensionInfoFieldsProps { name: string; type: 'stdio' | 'sse' | 'builtin'; description: string; - onChange: (key: string, value: any) => void; + onChange: (key: string, value: string) => void; submitAttempted: boolean; } diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionTimeoutField.tsx b/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionTimeoutField.tsx index 7581db75..1a0aae42 100644 --- a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionTimeoutField.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionTimeoutField.tsx @@ -1,10 +1,9 @@ import { Input } from '../../../ui/input'; -import Select from 'react-select'; -import React, { useState } from 'react'; +import React from 'react'; interface ExtensionTimeoutFieldProps { timeout: number; - onChange: (key: string, value: any) => void; + onChange: (key: string, value: string | number) => void; submitAttempted: boolean; } diff --git a/ui/desktop/src/components/settings_v2/extensions/utils.ts b/ui/desktop/src/components/settings_v2/extensions/utils.ts index dc7989b7..f06faa26 100644 --- a/ui/desktop/src/components/settings_v2/extensions/utils.ts +++ b/ui/desktop/src/components/settings_v2/extensions/utils.ts @@ -1,6 +1,5 @@ // Default extension timeout in seconds // TODO: keep in sync with rust better -import * as module from 'node:module'; export const DEFAULT_EXTENSION_TIMEOUT = 300; @@ -132,7 +131,8 @@ export function combineCmdAndArgs(cmd: string, args: string[]): string { * @returns The ExtensionConfig portion of the object */ export function extractExtensionConfig(fixedEntry: FixedExtensionEntry): ExtensionConfig { - const { enabled, ...extensionConfig } = fixedEntry; + // todo: enabled not used? + const { ...extensionConfig } = fixedEntry; return extensionConfig; } diff --git a/ui/desktop/src/components/settings_v2/mode/ModeSection.tsx b/ui/desktop/src/components/settings_v2/mode/ModeSection.tsx index bf462220..7d89bdff 100644 --- a/ui/desktop/src/components/settings_v2/mode/ModeSection.tsx +++ b/ui/desktop/src/components/settings_v2/mode/ModeSection.tsx @@ -1,10 +1,6 @@ import React, { useEffect, useState } from 'react'; import { getApiUrl, getSecretKey } from '../../../config'; import { all_goose_modes, filterGooseModes, ModeSelectionItem } from './ModeSelectionItem'; -import ExtensionList from '@/src/components/settings_v2/extensions/subcomponents/ExtensionList'; -import { Button } from '@/src/components/ui/button'; -import { Plus } from 'lucide-react'; -import { GPSIcon } from '@/src/components/ui/icons'; export const ModeSection = () => { const [currentMode, setCurrentMode] = useState('auto'); diff --git a/ui/desktop/src/components/settings_v2/models/ModelsSection.tsx b/ui/desktop/src/components/settings_v2/models/ModelsSection.tsx index c8b3f6c7..a96c2fb5 100644 --- a/ui/desktop/src/components/settings_v2/models/ModelsSection.tsx +++ b/ui/desktop/src/components/settings_v2/models/ModelsSection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import type { View } from '../../../App'; import ModelSettingsButtons from './subcomponents/ModelSettingsButtons'; import { useConfig } from '../../ConfigContext'; @@ -16,7 +16,7 @@ export default function ModelsSection({ setView }: ModelsSectionProps) { const { read, getProviders } = useConfig(); // Function to load model data - const loadModelData = async () => { + const loadModelData = useCallback(async () => { try { const gooseModel = (await read('GOOSE_MODEL', false)) as string; const gooseProvider = (await read('GOOSE_PROVIDER', false)) as string; @@ -40,7 +40,7 @@ export default function ModelsSection({ setView }: ModelsSectionProps) { } catch (error) { console.error('Error loading model data:', error); } - }; + }, [read, getProviders]); useEffect(() => { // Initial load @@ -55,7 +55,7 @@ export default function ModelsSection({ setView }: ModelsSectionProps) { return () => { clearInterval(interval); }; - }, []); + }, [loadModelData]); return (
diff --git a/ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx b/ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx index 4e9c7941..ba212bc5 100644 --- a/ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx +++ b/ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx @@ -4,10 +4,10 @@ import React, { useEffect, useState, useRef } from 'react'; import { useConfig } from '../../../ConfigContext'; import { getCurrentModelAndProviderForDisplay } from '../index'; import { AddModelModal } from '../subcomponents/AddModelModal'; -import type { View } from '../../../../App'; +import { View } from '../../../../App'; interface ModelsBottomBarProps { - dropdownRef: any; + dropdownRef: React.RefObject; setView: (view: View) => void; } export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBarProps) { diff --git a/ui/desktop/src/components/settings_v2/models/model_list/BaseModelsList.tsx b/ui/desktop/src/components/settings_v2/models/model_list/BaseModelsList.tsx index 37b1c1fd..12512fda 100644 --- a/ui/desktop/src/components/settings_v2/models/model_list/BaseModelsList.tsx +++ b/ui/desktop/src/components/settings_v2/models/model_list/BaseModelsList.tsx @@ -72,7 +72,7 @@ export function BaseModelsList({ return () => { isMounted = false; }; - }, [read]); + }, [read, modelList, upsert]); const handleModelSelection = async (model: Model) => { await changeModel({ model: model, writeToConfig: upsert, getExtensions, addExtension }); diff --git a/ui/desktop/src/components/settings_v2/models/model_list/recentModels.ts b/ui/desktop/src/components/settings_v2/models/model_list/recentModels.ts index 07100b78..9490d06a 100644 --- a/ui/desktop/src/components/settings_v2/models/model_list/recentModels.ts +++ b/ui/desktop/src/components/settings_v2/models/model_list/recentModels.ts @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import Model from '../modelInterface'; const MAX_RECENT_MODELS = 3; diff --git a/ui/desktop/src/components/settings_v2/models/subcomponents/AddModelModal.tsx b/ui/desktop/src/components/settings_v2/models/subcomponents/AddModelModal.tsx index 8fb88a23..fac53168 100644 --- a/ui/desktop/src/components/settings_v2/models/subcomponents/AddModelModal.tsx +++ b/ui/desktop/src/components/settings_v2/models/subcomponents/AddModelModal.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import { ArrowLeftRight, ExternalLink, Plus } from 'lucide-react'; +import React, { useEffect, useState, useCallback } from 'react'; +import { ArrowLeftRight, ExternalLink } from 'lucide-react'; import Modal from '../../../Modal'; import { Button } from '../../../ui/button'; @@ -11,7 +11,7 @@ import { changeModel } from '../index'; import type { View } from '../../../../App'; import Model, { getProviderMetadata } from '../modelInterface'; -const ModalButtons = ({ onSubmit, onCancel, isValid, validationErrors }) => ( +const ModalButtons = ({ onSubmit, onCancel, _isValid, _validationErrors }) => (