Fix ESLint warnings and and enable max warnings 0 to fail builds (#2101)

This commit is contained in:
Zane
2025-04-09 15:13:53 -07:00
committed by GitHub
parent 0daff53110
commit 8451fb1c89
69 changed files with 968 additions and 685 deletions

35
ui/desktop/.eslintrc.json Normal file
View File

@@ -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"
}
}
}

View File

@@ -3,20 +3,28 @@
<head>
<meta charset="UTF-8" />
<title>Goose</title>
<script>
// Initialize theme before any content loads
(function() {
function initializeTheme() {
const useSystemTheme = localStorage.getItem('use_system_theme') === 'true';
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedTheme = localStorage.getItem('theme');
const isDark = useSystemTheme ? systemPrefersDark : (savedTheme ? savedTheme === 'dark' : systemPrefersDark);
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
// Run immediately
initializeTheme();
})();
</script>
<link href="./src/styles/main.css" rel="stylesheet" />
</head>
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
const useSystemTheme = localStorage.getItem('use_system_theme') === 'true';
const savedTheme = localStorage.getItem('theme');
document.documentElement.classList.toggle(
'dark',
useSystemTheme
? window.matchMedia('(prefers-color-scheme: dark)').matches
: savedTheme === 'dark'
);
</script>
<body>
<div id="root"></div>
<script type="module" src="./src/renderer.tsx"></script>

View File

@@ -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",

View File

@@ -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}": [

View File

@@ -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<string, any>;
export type ViewConfig = {
view: View;
viewOptions?:
| SettingsViewOptions
| {
resumedSession?: SessionDetails;
}
| Record<string, any>;
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<any, any> = {}) => {
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<SharedSessionDetails | null>(null);
const [sharedSessionError, setSharedSessionError] = useState<string | null>(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' && <SessionsView setView={setView} />}
{view === 'sharedSession' && (
<SharedSessionView
session={viewOptions.sessionDetails}
session={viewOptions?.sessionDetails}
isLoading={isLoadingSharedSession}
error={viewOptions.error || sharedSessionError}
error={viewOptions?.error || sharedSessionError}
onBack={() => 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() {
</div>
{isGoosehintsModalOpen && (
<GoosehintsModal
directory={window.appConfig.get('GOOSE_WORKING_DIR')}
directory={window.appConfig.get('GOOSE_WORKING_DIR') as string}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
)}

View File

@@ -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<HTMLDivElement>(null);
// Add effect to handle clicks outside

View File

@@ -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<HTMLDivElement>(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) => {

View File

@@ -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<any, any>) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({});
// Disabled askAi calls to save costs
// const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({});
const [hasMessages, setHasMessages] = useState(false);
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
const [showGame, setShowGame] = useState(false);
const [waitingForAgentResponse, setWaitingForAgentResponse] = useState(false);
const [showShareableBotModal, setshowShareableBotModal] = useState(false);
const [generatedBotConfig, setGeneratedBotConfig] = useState<any>(null);
const [generatedBotConfig, setGeneratedBotConfig] = useState<GeneratedBotConfig | null>(null);
const scrollRef = useRef<ScrollAreaHandle>(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<string[]>((history, message) => {
@@ -372,7 +378,7 @@ export default function ChatView({
return history;
}, [])
.reverse();
}, [filteredMessages, isUserMessage]);
}, [filteredMessages]);
return (
<div className="flex flex-col w-full h-screen items-center justify-center">
@@ -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];

View File

@@ -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<ConfigProviderProps> = ({ children }) => {
const [providersList, setProvidersList] = useState<ProviderDetails[]>([]);
const [extensionsList, setExtensionsList] = useState<FixedExtensionEntry[]>([]);
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<FixedExtensionEntry[]> => {
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<ProviderDetails[]> => {
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<ConfigProviderProps> = ({ 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<ProviderDetails[]> => {
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<FixedExtensionEntry[]> => {
// 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,

View File

@@ -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<FlappyGooseProps> = ({ 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<FlappyGooseProps> = ({ 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<FlappyGooseProps> = ({ onClose }) => {
window.removeEventListener('keydown', handleKeyPress);
gameState.current.running = false;
};
}, [imagesReady]);
}, [CANVAS_HEIGHT, CANVAS_WIDTH, flap, imagesReady, startGame]);
return (
<div

View File

@@ -77,12 +77,22 @@ export default function GooseMessage({
useEffect(() => {
// 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 (
<div className="goose-message flex w-[90%] justify-start opacity-0 animate-[appear_150ms_ease-in_forwards]">

View File

@@ -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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
const handleCompositionStart = () => {
setIsComposing(true);
};
const handleCompositionEnd = (evt: React.CompositionEvent<HTMLTextAreaElement>) => {
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"
>

View File

@@ -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 (
<div className="w-full overflow-x-hidden">
<ReactMarkdown
@@ -100,8 +97,8 @@ export default function MarkdownContent({ content, className = '' }: MarkdownCon
${className}`}
components={{
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
code({ node, className, children, inlinecode, ...props }) {
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
code({ className, children, inlinecode, ...props }) {
const match = /language-(\w+)/.exec(className || 'language-text');
return inlinecode == 'false' && match ? (
<CodeBlock language={match[1]}>{String(children).replace(/\n$/, '')}</CodeBlock>

View File

@@ -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<string, any>;
args: Record<string, ToolCallArgumentValue>;
}
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 ? '▼ ' : '▶ '} */}
<ChevronUp
className={`h-5 w-5 transition-all origin-center ${!isExpanded ? 'rotate-180' : ''}`}
/>
</button>
</div>
</div>
{/* {isExpanded && (
<div className="mt-2">
<MarkdownContent content={value} />
</div>
)} */}
</div>
);
}

View File

@@ -44,21 +44,25 @@ export const SearchBar: React.FC<SearchBarProps> = ({
// 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<HTMLInputElement>) => {
const value = event.target.value;
setDisplayTerm(value); // Update display immediately
@@ -93,7 +97,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({
const handleClose = () => {
setIsExiting(true);
debouncedSearch.cancel(); // Cancel any pending searches
debouncedSearch.cancel?.(); // Cancel any pending searches
setTimeout(() => {
onClose();
}, 150); // Match animation duration

View File

@@ -36,20 +36,25 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
// 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<PropsWithChildren<SearchViewProps>> = ({
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<PropsWithChildren<SearchViewProps>> = ({
highlighterRef.current.clearHighlights();
}
// Cancel any pending highlight operations
debouncedHighlight.cancel();
debouncedHighlight.cancel?.();
};
return (

View File

@@ -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<VersionInfo | null>(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({
<PopoverTrigger asChild>
<button
className={`z-[100] absolute top-2 right-4 w-[20px] h-[20px] transition-colors cursor-pointer no-drag hover:text-textProminent ${open ? 'text-textProminent' : 'text-textSubtle'}`}
role="button"
aria-label="More options"
>
<More />
</button>

View File

@@ -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<any, any>) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
return (

View File

@@ -39,7 +39,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
const [isSharing, setIsSharing] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const [canShare, setCanShare] = useState(false);
const [shareError, setShareError] = useState<string | null>(null);
useEffect(() => {
const savedSessionConfig = localStorage.getItem('session_sharing_config');
@@ -58,7 +57,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
const handleShare = async () => {
setIsSharing(true);
setShareError(null);
try {
// Get the session sharing configuration from localStorage
@@ -87,7 +85,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
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'}`
);

View File

@@ -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<any, any>) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
onSelectSession: (sessionId: string) => void;
}

View File

@@ -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<any, any>) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
}
const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {

View File

@@ -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<any>(null);
// Use Audio element type for audioRef
const audioRef = useRef<{ play: () => Promise<void>; pause: () => void; volume: number } | null>(
null
);
const [isMuted, setIsMuted] = useState(false);
const [battleState, setBattleState] = useState<BattleState>({

View File

@@ -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<SettingsType>(() => {
@@ -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);

View File

@@ -101,8 +101,18 @@ export async function getProvidersList(): Promise<Provider[]> {
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

View File

@@ -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';

View File

@@ -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 = [

View File

@@ -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 (
<div className="relative h-full p-[2px] overflow-hidden rounded-[9px] group/card bg-borderSubtle hover:bg-transparent hover:duration-300">

View File

@@ -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';

View File

@@ -15,11 +15,9 @@ export type SettingsViewOptions = {
export default function SettingsView({
onClose,
setView,
viewOptions,
}: {
onClose: () => void;
setView: (view: View) => void;
viewOptions: SettingsViewOptions;
}) {
return (
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]">

View File

@@ -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<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [extensions, setExtensions] = useState<FixedExtensionEntry[]>([]);
const [selectedExtension, setSelectedExtension] = useState<FixedExtensionEntry | null>(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,

View File

@@ -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<Response> {
// Configure toast notifications
@@ -118,7 +123,7 @@ function handleErrorResponse(
}
// Safely parses JSON response
async function parseResponseData(response: Response): Promise<any> {
async function parseResponseData(response: Response): Promise<ApiResponse> {
try {
const text = await response.text();
return text ? JSON.parse(text) : { error: false };

View File

@@ -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<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
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<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
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<void>;
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 {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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');

View File

@@ -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 (
<section id="models" className="px-8">

View File

@@ -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<HTMLDivElement>;
setView: (view: View) => void;
}
export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBarProps) {

View File

@@ -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 });

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import Model from '../modelInterface';
const MAX_RECENT_MODELS = 3;

View File

@@ -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 }) => (
<div>
<Button
type="submit"
@@ -51,7 +51,7 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
const [attemptedSubmit, setAttemptedSubmit] = useState(false);
// Validate form data
const validateForm = () => {
const validateForm = useCallback(() => {
const errors = {
provider: '',
model: '',
@@ -71,7 +71,7 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
setValidationErrors(errors);
setIsValid(formIsValid);
return formIsValid;
};
}, [model, provider]);
const onSubmit = async () => {
setAttemptedSubmit(true);
@@ -96,7 +96,7 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
if (attemptedSubmit) {
validateForm();
}
}, [provider, model, attemptedSubmit]);
}, [provider, model, attemptedSubmit, validateForm]);
useEffect(() => {
(async () => {

View File

@@ -72,7 +72,13 @@ const ProviderCards = memo(function ProviderCards({
isOnboarding={isOnboarding}
/>
));
}, [providers, isOnboarding, configureProviderViaModal, onProviderLaunch]);
}, [
providers,
isOnboarding,
configureProviderViaModal,
deleteProviderConfigViaModal,
onProviderLaunch,
]);
return <>{providerCards}</>;
});

View File

@@ -1,6 +1,4 @@
import ProviderDetails from './interfaces/ProviderDetails';
import OllamaForm from './modal/subcomponents/forms/OllamaForm';
import OllamaSubmitHandler from './modal/subcomponents/handlers/OllamaSubmitHandler';
export interface ProviderRegistry {
name: string;

View File

@@ -7,7 +7,6 @@ import { ProviderDetails } from '../../../api/types.gen';
import { initializeSystem } from '../../../utils/providerUtils';
import WelcomeGooseLogo from '../../WelcomeGooseLogo';
import { toastService } from '../../../toasts';
import { toast } from 'react-toastify';
interface ProviderSettingsProps {
onClose: () => void;
@@ -84,7 +83,7 @@ export default function ProviderSettings({ onClose, isOnboarding }: ProviderSett
});
onClose();
},
[onClose, upsert]
[onClose, upsert, getExtensions, addExtension]
);
return (

View File

@@ -1,3 +1,4 @@
import React from 'react';
import ParameterSchema from '../interfaces/ParameterSchema';
import ProviderSetupFormProps from '../modal/interfaces/ProviderSetupFormProps';
@@ -8,5 +9,5 @@ export default interface ProviderDetails {
parameters: ParameterSchema[];
getTags?: (name: string) => string[];
customForm?: React.ComponentType<ProviderSetupFormProps>;
customSubmit?: (e: any) => void;
customSubmit?: (e: React.SyntheticEvent) => void;
}

View File

@@ -1,7 +1,11 @@
interface ProviderMetadata {
[key: string]: string | number | boolean | null;
}
// runtime data per instance
export default interface ProviderState {
id: string;
name: string;
isConfigured: boolean;
metadata: any;
metadata: ProviderMetadata;
}

View File

@@ -1,11 +1,19 @@
import React, { createContext, useContext, useState } from 'react';
import { ProviderDetails } from '../../../../api/types.gen';
interface FormValues {
[key: string]: string | number | boolean | null;
}
interface ModalProps {
onSubmit?: (values: any) => void;
onSubmit?: (values: FormValues) => void;
onCancel?: () => void;
onDelete?: (values: any) => void;
formProps?: any;
onDelete?: (values: FormValues) => void;
formProps?: {
initialValues?: FormValues;
validationSchema?: object;
[key: string]: unknown;
};
}
interface ProviderModalContextType {

View File

@@ -1,10 +1,10 @@
import React from 'react';
import React, { SyntheticEvent } from 'react';
import { Button } from '../../../../ui/button';
import { Trash2, AlertTriangle } from 'lucide-react';
interface ProviderSetupActionsProps {
onCancel: () => void;
onSubmit: (e: any) => void;
onSubmit: (e: SyntheticEvent) => void;
onDelete?: () => void;
showDeleteConfirmation?: boolean;
onConfirmDelete?: () => void;

View File

@@ -1,12 +1,39 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { Input } from '../../../../../ui/input';
import { useConfig } from '../../../../../ConfigContext'; // Adjust this import path as needed
interface ConfigParameter {
name: string;
required: boolean;
secret?: boolean;
default?: string | number | boolean | null;
}
interface ProviderMetadata {
config_keys?: ConfigParameter[];
display_name?: string;
description?: string;
known_models?: string[];
default_model?: string;
[key: string]: string | string[] | ConfigParameter[] | undefined;
}
interface Provider {
metadata: ProviderMetadata;
name: string;
is_configured: boolean;
[key: string]: string | boolean | ProviderMetadata;
}
interface ValidationErrors {
[key: string]: string;
}
interface DefaultProviderSetupFormProps {
configValues: Record<string, any>;
setConfigValues: React.Dispatch<React.SetStateAction<Record<string, any>>>;
provider: any;
validationErrors: any;
configValues: Record<string, string>;
setConfigValues: React.Dispatch<React.SetStateAction<Record<string, string>>>;
provider: Provider;
validationErrors: ValidationErrors;
}
export default function DefaultProviderSetupForm({
@@ -15,61 +42,64 @@ export default function DefaultProviderSetupForm({
provider,
validationErrors = {},
}: DefaultProviderSetupFormProps) {
const parameters = provider.metadata.config_keys || [];
const parameters = useMemo(
() => provider.metadata.config_keys || [],
[provider.metadata.config_keys]
);
const [isLoading, setIsLoading] = useState(true);
const { read } = useConfig();
console.log('configValues default form', configValues);
// Initialize values when the component mounts or provider changes
useEffect(() => {
const loadConfigValues = async () => {
setIsLoading(true);
const newValues = { ...configValues };
const loadConfigValues = useCallback(async () => {
setIsLoading(true);
const newValues = { ...configValues };
// Try to load actual values from config for each parameter that is not secret
for (const parameter of parameters) {
if (parameter.required) {
try {
// Check if there's a stored value in the config system
const configKey = `${parameter.name}`;
const configResponse = await read(configKey, parameter.secret || false);
// Try to load actual values from config for each parameter that is not secret
for (const parameter of parameters) {
if (parameter.required) {
try {
// Check if there's a stored value in the config system
const configKey = `${parameter.name}`;
const configResponse = await read(configKey, parameter.secret || false);
if (configResponse) {
// Use the value from the config provider
newValues[parameter.name] = configResponse;
} else if (
parameter.default !== undefined &&
parameter.default !== null &&
!configValues[parameter.name]
) {
// Fall back to default value if no config value exists
newValues[parameter.name] = parameter.default;
}
} catch (error) {
console.error(`Failed to load config for ${parameter.name}:`, error);
// Fall back to default if read operation fails
if (
parameter.default !== undefined &&
parameter.default !== null &&
!configValues[parameter.name]
) {
newValues[parameter.name] = parameter.default;
}
if (configResponse) {
// Use the value from the config provider
newValues[parameter.name] = String(configResponse);
} else if (
parameter.default !== undefined &&
parameter.default !== null &&
!configValues[parameter.name]
) {
// Fall back to default value if no config value exists
newValues[parameter.name] = String(parameter.default);
}
} catch (error) {
console.error(`Failed to load config for ${parameter.name}:`, error);
// Fall back to default if read operation fails
if (
parameter.default !== undefined &&
parameter.default !== null &&
!configValues[parameter.name]
) {
newValues[parameter.name] = String(parameter.default);
}
}
}
}
// Update state with loaded values
setConfigValues((prev) => ({
...prev,
...newValues,
}));
setIsLoading(false);
};
// Update state with loaded values
setConfigValues((prev) => ({
...prev,
...newValues,
}));
setIsLoading(false);
}, [configValues, parameters, read, setConfigValues]);
loadConfigValues().then();
}, []);
useEffect(() => {
loadConfigValues();
}, [loadConfigValues]);
// Filter parameters to only show required ones
const requiredParameters = useMemo(() => {
@@ -77,7 +107,7 @@ export default function DefaultProviderSetupForm({
}, [parameters]);
// Helper function to generate appropriate placeholder text
const getPlaceholder = (parameter) => {
const getPlaceholder = (parameter: ConfigParameter): string => {
// If default is defined and not null, show it
if (parameter.default !== undefined && parameter.default !== null) {
return `Default: ${parameter.default}`;

View File

@@ -2,8 +2,8 @@ import { PROVIDER_REGISTRY } from '../../../ProviderRegistry';
import { Input } from '../../../../../ui/input';
import React from 'react';
import { useState, useEffect } from 'react';
import { Lock, RefreshCw } from 'lucide-react';
import { useState, useEffect, useCallback } from 'react';
import { RefreshCw } from 'lucide-react';
import CustomRadio from '../../../../../ui/CustomRadio';
export default function OllamaForm({ configValues, setConfigValues, provider }) {
@@ -12,12 +12,15 @@ export default function OllamaForm({ configValues, setConfigValues, provider })
const [isCheckingLocal, setIsCheckingLocal] = useState(false);
const [isLocalAvailable, setIsLocalAvailable] = useState(false);
const handleConnectionTypeChange = (value) => {
setConfigValues((prev) => ({
...prev,
connection_type: value,
}));
};
const handleConnectionTypeChange = useCallback(
(value) => {
setConfigValues((prev) => ({
...prev,
connection_type: value,
}));
},
[setConfigValues]
);
// Function to handle input changes and auto-select/deselect the host radio
const handleInputChange = (paramName, value) => {
@@ -40,7 +43,7 @@ export default function OllamaForm({ configValues, setConfigValues, provider })
}
};
const checkLocalAvailability = async () => {
const checkLocalAvailability = useCallback(async () => {
setIsCheckingLocal(true);
// Dummy implementation - simulates checking local availability
@@ -69,12 +72,12 @@ export default function OllamaForm({ configValues, setConfigValues, provider })
} finally {
setIsCheckingLocal(false);
}
};
}, [configValues.connection_type, handleConnectionTypeChange]);
// Check local availability on initial load
useEffect(() => {
checkLocalAvailability();
}, []);
}, [checkLocalAvailability]);
return (
<div className="mt-4 space-y-4">

View File

@@ -1,6 +1,4 @@
import React from 'react';
import CardActions from './CardActions';
import ConfigurationAction from '../interfaces/ConfigurationAction';
interface CardBodyProps {
children: React.ReactNode;

View File

@@ -23,7 +23,7 @@ export const ProviderCard = memo(function ProviderCard({
const providerMetadata: ProviderMetadata | null = provider?.metadata || null;
// Instead of useEffect for logging, use useMemo to memoize the metadata
const metadata = useMemo(() => providerMetadata, [provider]);
const metadata = useMemo(() => providerMetadata, [providerMetadata]);
if (!metadata) {
return <div>ProviderCard error: No metadata provided</div>;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { Button } from '../../../../ui/button';
import clsx from 'clsx';
import { TooltipWrapper } from './TooltipWrapper';
import { Check, CircleHelp, Plus, RefreshCw, Rocket, Sliders, X } from 'lucide-react';
import { Check, Rocket, Sliders } from 'lucide-react';
interface ActionButtonProps extends React.ComponentProps<typeof Button> {
/** Icon component to render, e.g. `RefreshCw` from lucide-react */

View File

@@ -6,13 +6,11 @@ export function BaseModal({
title,
children,
actions,
onClose,
}: {
isOpen: boolean;
title: string;
children: React.ReactNode;
actions: React.ReactNode; // Buttons for actions
onClose: () => void;
}) {
if (!isOpen) return null;

View File

@@ -1,26 +1,26 @@
import React, { useMemo, useState, useEffect, useRef } from 'react';
import { Buffer } from 'buffer';
import Copy from '../icons/Copy';
import Modal from '../Modal';
import { Card } from '../ui/card';
import { Card } from './card';
interface BotConfig {
instructions?: string;
activities?: string[];
[key: string]: unknown;
}
interface DeepLinkModalProps {
botConfig: any;
botConfig: BotConfig;
onClose: () => void;
onOpen: () => void;
}
// Function to generate a deep link from a bot config
export function generateDeepLink(botConfig: any): string {
export function generateDeepLink(botConfig: BotConfig): string {
const configBase64 = Buffer.from(JSON.stringify(botConfig)).toString('base64');
return `goose://bot?config=${configBase64}`;
}
export function DeepLinkModal({
botConfig: initialBotConfig,
onClose,
onOpen,
}: DeepLinkModalProps) {
export function DeepLinkModal({ botConfig: initialBotConfig, onClose }: DeepLinkModalProps) {
// Create editable state for the bot config
const [botConfig, setBotConfig] = useState(initialBotConfig);
const [instructions, setInstructions] = useState(initialBotConfig.instructions || '');
@@ -61,7 +61,7 @@ export function DeepLinkModal({
instructions,
activities,
});
}, [instructions, activities]);
}, [instructions, activities, botConfig]);
// Handle adding a new activity
const handleAddActivity = () => {

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { getApiUrl, getSecretKey } from './config';
import { type View } from './App';
import { type SettingsViewOptions } from './components/settings/SettingsView';
@@ -6,7 +5,6 @@ import { toast } from 'react-toastify';
import builtInExtensionsData from './built-in-extensions.json';
import { toastError, toastLoading, toastSuccess } from './toasts';
import { Toast } from 'react-toastify/dist/components';
// Hardcoded default extension timeout in seconds
export const DEFAULT_EXTENSION_TIMEOUT = 300;
@@ -214,7 +212,9 @@ export async function loadAndAddStoredExtensions() {
if (userSettingsStr) {
const userSettings = JSON.parse(userSettingsStr);
const enabledExtensions = userSettings.extensions.filter((ext: any) => ext.enabled);
const enabledExtensions = userSettings.extensions.filter(
(ext: FullExtensionConfig) => ext.enabled
);
console.log('Adding extensions from localStorage: ', enabledExtensions);
for (const ext of enabledExtensions) {
await addExtension(ext, true);

View File

@@ -5,7 +5,9 @@ import path from 'node:path';
import { getBinaryPath } from './utils/binaryPath';
import log from './utils/logger';
import { ChildProcessByStdio } from 'node:child_process';
import { Readable } from 'node:stream';
import { Readable, Buffer } from 'node:stream';
import { App } from 'electron';
import type { ProcessEnv } from 'node:process';
// Find an available port to start goosed on
export const findAvailablePort = (): Promise<number> => {
@@ -50,10 +52,20 @@ const checkServerStatus = async (
return false;
};
interface GooseProcessEnv extends ProcessEnv {
HOME: string;
USERPROFILE: string;
APPDATA: string;
LOCALAPPDATA: string;
PATH: string;
GOOSE_PORT: string;
GOOSE_SERVER__SECRET_KEY?: string;
}
export const startGoosed = async (
app,
dir = null,
env = {}
app: App,
dir: string | null = null,
env: Partial<GooseProcessEnv> = {}
): Promise<[number, string, ChildProcessByStdio<null, Readable, Readable>]> => {
// we default to running goosed in home dir - if not specified
const homeDir = os.homedir();
@@ -72,7 +84,7 @@ export const startGoosed = async (
log.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${dir}`);
// Define additional environment variables
const additionalEnv = {
const additionalEnv: GooseProcessEnv = {
// Set HOME for UNIX-like systems
HOME: homeDir,
// Set USERPROFILE for Windows
@@ -82,16 +94,16 @@ export const startGoosed = async (
// Set LOCAL_APPDATA for Windows
LOCALAPPDATA: process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
// Set PATH to include the binary directory
PATH: `${path.dirname(goosedPath)}${path.delimiter}${process.env.PATH}`,
PATH: `${path.dirname(goosedPath)}${path.delimiter}${process.env.PATH || ''}`,
// start with the port specified
GOOSE_PORT: String(port),
GOOSE_SERVER__SECRET_KEY: process.env.GOOSE_SERVER__SECRET_KEY,
// Add any additional environment variables passed in
...env,
};
} as GooseProcessEnv;
// Merge parent environment with additional environment variables
const processEnv = { ...process.env, ...additionalEnv };
const processEnv: GooseProcessEnv = { ...process.env, ...additionalEnv } as GooseProcessEnv;
// Add detailed logging for troubleshooting
log.info(`Process platform: ${process.platform}`);
@@ -111,6 +123,7 @@ export const startGoosed = async (
// Verify binary exists
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs');
const stats = fs.statSync(goosedPath);
log.info(`Binary exists: ${stats.isFile()}`);
@@ -122,7 +135,7 @@ export const startGoosed = async (
const spawnOptions = {
cwd: dir,
env: processEnv,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'],
// Hide terminal window on Windows
windowsHide: true,
// Run detached on Windows only to avoid terminal windows
@@ -142,19 +155,19 @@ export const startGoosed = async (
goosedProcess.unref();
}
goosedProcess.stdout.on('data', (data) => {
goosedProcess.stdout.on('data', (data: Buffer) => {
log.info(`goosed stdout for port ${port} and dir ${dir}: ${data.toString()}`);
});
goosedProcess.stderr.on('data', (data) => {
goosedProcess.stderr.on('data', (data: Buffer) => {
log.error(`goosed stderr for port ${port} and dir ${dir}: ${data.toString()}`);
});
goosedProcess.on('close', (code) => {
goosedProcess.on('close', (code: number | null) => {
log.info(`goosed process exited with code ${code} for port ${port} and dir ${dir}`);
});
goosedProcess.on('error', (err) => {
goosedProcess.on('error', (err: Error) => {
log.error(`Failed to start goosed on port ${port} and dir ${dir}`, err);
throw err; // Propagate the error
});

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';
import { ChatType } from '../components/ChatView';
import { fetchSessionDetails, generateSessionId } from '../sessions';
import { View, ViewOptions } from '../App';
type UseChatArgs = {
setIsLoadingSession: (isLoading: boolean) => void;
setView: (view: string) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
};
export const useChat = ({ setIsLoadingSession, setView }: UseChatArgs) => {
const [chat, setChat] = useState<ChatType>({
@@ -49,6 +50,8 @@ export const useChat = ({ setIsLoadingSession, setView }: UseChatArgs) => {
};
checkForResumeSession();
// todo: rework this to allow for exhaustive deps currently throws app in error loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { chat, setChat };

View File

@@ -1,4 +1,4 @@
declare module '*.json' {
const value: any;
const value: Record<string, unknown>;
export default value;
}

View File

@@ -3,13 +3,13 @@ import {
session,
BrowserWindow,
dialog,
globalShortcut,
ipcMain,
Menu,
MenuItem,
Notification,
powerSaveBlocker,
Tray,
App,
} from 'electron';
import { Buffer } from 'node:buffer';
import started from 'electron-squirrel-startup';
@@ -41,8 +41,8 @@ app.setAsDefaultProtocolClient('goose');
// Triggered when the user opens "goose://..." links
let firstOpenWindow: BrowserWindow;
let pendingDeepLink = null; // Store deep link if sent before React is ready
app.on('open-url', async (event, url) => {
let pendingDeepLink: string | null = null; // Store deep link if sent before React is ready
app.on('open-url', async (_event, url) => {
pendingDeepLink = url;
// Parse the URL to determine the type
@@ -156,13 +156,21 @@ let appConfig = {
let windowCounter = 0;
const windowMap = new Map<number, BrowserWindow>();
interface BotConfig {
id: string;
name: string;
description: string;
instructions: string;
activities: string[];
}
const createChat = async (
app,
app: App,
query?: string,
dir?: string,
version?: string,
resumeSessionId?: string,
botConfig?: any // Bot configuration
botConfig?: BotConfig
) => {
// Apply current environment settings before creating chat
updateEnvironmentVariables(envToggles);
@@ -202,7 +210,7 @@ const createChat = async (
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
// Open all links in external browser
if (url.startsWith('http:') || url.startsWith('https:')) {
require('electron').shell.openExternal(url);
electron.shell.openExternal(url);
return { action: 'deny' };
}
return { action: 'allow' };
@@ -350,7 +358,7 @@ const openDirectoryDialog = async (replaceWindow: boolean = false) => {
if (!result.canceled && result.filePaths.length > 0) {
addRecentDir(result.filePaths[0]);
const currentWindow = BrowserWindow.getFocusedWindow();
const newWindow = await createChat(app, undefined, result.filePaths[0]);
await createChat(app, undefined, result.filePaths[0]);
if (replaceWindow) {
currentWindow.close();
}
@@ -376,7 +384,7 @@ process.on('unhandledRejection', (error) => {
handleFatalError(error instanceof Error ? error : new Error(String(error)));
});
ipcMain.on('react-ready', (event) => {
ipcMain.on('react-ready', () => {
console.log('React ready event received');
if (pendingDeepLink) {
@@ -399,7 +407,7 @@ ipcMain.on('react-ready', (event) => {
});
// Handle directory chooser
ipcMain.handle('directory-chooser', (_, replace: boolean = false) => {
ipcMain.handle('directory-chooser', (_event, replace: boolean = false) => {
return openDirectoryDialog(replace);
});
@@ -447,11 +455,11 @@ ipcMain.handle('check-ollama', async () => {
});
// Handle binary path requests
ipcMain.handle('get-binary-path', (event, binaryName) => {
ipcMain.handle('get-binary-path', (_event, binaryName) => {
return getBinaryPath(app, binaryName);
});
ipcMain.handle('read-file', (event, filePath) => {
ipcMain.handle('read-file', (_event, filePath) => {
return new Promise((resolve) => {
exec(`cat ${filePath}`, (error, stdout, stderr) => {
if (error) {
@@ -467,7 +475,7 @@ ipcMain.handle('read-file', (event, filePath) => {
});
});
ipcMain.handle('write-file', (event, filePath, content) => {
ipcMain.handle('write-file', (_event, filePath, content) => {
return new Promise((resolve) => {
const command = `cat << 'EOT' > ${filePath}
${content}
@@ -513,25 +521,27 @@ app.whenReady().then(async () => {
const menu = Menu.getApplicationMenu();
// App menu
const appMenu = menu.items.find((item) => item.label === 'Goose');
// add Settings to app menu after About
appMenu.submenu.insert(1, new MenuItem({ type: 'separator' }));
appMenu.submenu.insert(
1,
new MenuItem({
label: 'Settings',
accelerator: 'CmdOrCtrl+,',
click() {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) focusedWindow.webContents.send('set-view', 'settings');
},
})
);
appMenu.submenu.insert(1, new MenuItem({ type: 'separator' }));
const appMenu = menu?.items.find((item) => item.label === 'Goose');
if (appMenu?.submenu) {
// add Settings to app menu after About
appMenu.submenu.insert(1, new MenuItem({ type: 'separator' }));
appMenu.submenu.insert(
1,
new MenuItem({
label: 'Settings',
accelerator: 'CmdOrCtrl+,',
click() {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) focusedWindow.webContents.send('set-view', 'settings');
},
})
);
appMenu.submenu.insert(1, new MenuItem({ type: 'separator' }));
}
// Add Environment menu items to View menu
const viewMenu = menu.items.find((item) => item.label === 'View');
if (viewMenu) {
const viewMenu = menu?.items.find((item) => item.label === 'View');
if (viewMenu?.submenu) {
viewMenu.submenu.append(new MenuItem({ type: 'separator' }));
viewMenu.submenu.append(
new MenuItem({
@@ -549,29 +559,29 @@ app.whenReady().then(async () => {
const fileMenu = menu?.items.find((item) => item.label === 'File');
// open goose to specific dir and set that as its working space
fileMenu.submenu.append(
new MenuItem({
label: 'Open Directory...',
accelerator: 'CmdOrCtrl+O',
click: () => openDirectoryDialog(),
})
);
// Add Recent Files submenu
const recentFilesSubmenu = buildRecentFilesMenu();
if (recentFilesSubmenu.length > 0) {
fileMenu.submenu.append(new MenuItem({ type: 'separator' }));
if (fileMenu?.submenu) {
// open goose to specific dir and set that as its working space
fileMenu.submenu.append(
new MenuItem({
label: 'Recent Directories',
submenu: recentFilesSubmenu,
label: 'Open Directory...',
accelerator: 'CmdOrCtrl+O',
click: () => openDirectoryDialog(),
})
);
}
// Add menu items to File menu
if (fileMenu && fileMenu.submenu) {
// Add Recent Files submenu
const recentFilesSubmenu = buildRecentFilesMenu();
if (recentFilesSubmenu.length > 0) {
fileMenu.submenu.append(new MenuItem({ type: 'separator' }));
fileMenu.submenu.append(
new MenuItem({
label: 'Recent Directories',
submenu: recentFilesSubmenu,
})
);
}
// Add menu items to File menu
fileMenu.submenu.append(
new MenuItem({
label: 'New Chat Window',
@@ -611,7 +621,9 @@ app.whenReady().then(async () => {
);
}
Menu.setApplicationMenu(menu);
if (menu) {
Menu.setApplicationMenu(menu);
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
@@ -619,7 +631,7 @@ app.whenReady().then(async () => {
}
});
ipcMain.on('create-chat-window', (_, query, dir, version, resumeSessionId, botConfig) => {
ipcMain.on('create-chat-window', (_event, query, dir, version, resumeSessionId, botConfig) => {
if (!dir?.trim()) {
const recentDirs = loadRecentDirs();
dir = recentDirs.length > 0 ? recentDirs[0] : null;
@@ -627,12 +639,12 @@ app.whenReady().then(async () => {
createChat(app, query, dir, version, resumeSessionId, botConfig);
});
ipcMain.on('notify', (event, data) => {
ipcMain.on('notify', (_event, data) => {
console.log('NOTIFY', data);
new Notification({ title: data.title, body: data.body }).show();
});
ipcMain.on('logInfo', (_, info) => {
ipcMain.on('logInfo', (_event, info) => {
log.info('from renderer:', info);
});
@@ -668,12 +680,12 @@ app.whenReady().then(async () => {
});
// Handle binary path requests
ipcMain.handle('get-binary-path', (event, binaryName) => {
ipcMain.handle('get-binary-path', (_event, binaryName) => {
return getBinaryPath(app, binaryName);
});
// Handle metadata fetching from main process
ipcMain.handle('fetch-metadata', async (_, url) => {
ipcMain.handle('fetch-metadata', async (_event, url) => {
try {
const response = await fetch(url, {
headers: {
@@ -692,7 +704,7 @@ app.whenReady().then(async () => {
}
});
ipcMain.on('open-in-chrome', (_, url) => {
ipcMain.on('open-in-chrome', (_event, url) => {
// On macOS, use the 'open' command with Chrome
if (process.platform === 'darwin') {
spawn('open', ['-a', 'Google Chrome', url]);

View File

@@ -1,11 +1,32 @@
import Electron, { contextBridge, ipcRenderer } from 'electron';
interface BotConfig {
id: string;
name: string;
description: string;
instructions?: string;
activities?: string[];
[key: string]: unknown;
}
interface NotificationData {
title: string;
body: string;
}
interface FileResponse {
file: string;
filePath: string;
error: string | null;
found: boolean;
}
const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}');
// Define the API types in a single place
type ElectronAPI = {
reactReady: () => void;
getConfig: () => Record<string, any>;
getConfig: () => Record<string, unknown>;
hideWindow: () => void;
directoryChooser: (replace?: boolean) => Promise<Electron.OpenDialogReturnValue>;
createChatWindow: (
@@ -13,36 +34,34 @@ type ElectronAPI = {
dir?: string,
version?: string,
resumeSessionId?: string,
botConfig?: any
botConfig?: BotConfig
) => void;
logInfo: (txt: string) => void;
showNotification: (data: any) => void;
showNotification: (data: NotificationData) => void;
openInChrome: (url: string) => void;
fetchMetadata: (url: string) => Promise<any>;
fetchMetadata: (url: string) => Promise<string>;
reloadApp: () => void;
checkForOllama: () => Promise<boolean>;
selectFileOrDirectory: () => Promise<string>;
startPowerSaveBlocker: () => Promise<number>;
stopPowerSaveBlocker: () => Promise<void>;
getBinaryPath: (binaryName: string) => Promise<string>;
readFile: (
directory: string
) => Promise<{ file: string; filePath: string; error: string; found: boolean }>;
readFile: (directory: string) => Promise<FileResponse>;
writeFile: (directory: string, content: string) => Promise<boolean>;
on: (
channel: string,
callback: (event: Electron.IpcRendererEvent, ...args: any[]) => void
callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
) => void;
off: (
channel: string,
callback: (event: Electron.IpcRendererEvent, ...args: any[]) => void
callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
) => void;
emit: (channel: string, ...args: any[]) => void;
emit: (channel: string, ...args: unknown[]) => void;
};
type AppConfigAPI = {
get: (key: string) => any;
getAll: () => Record<string, any>;
get: (key: string) => unknown;
getAll: () => Record<string, unknown>;
};
const electronAPI: ElectronAPI = {
@@ -55,10 +74,10 @@ const electronAPI: ElectronAPI = {
dir?: string,
version?: string,
resumeSessionId?: string,
botConfig?: any
botConfig?: BotConfig
) => ipcRenderer.send('create-chat-window', query, dir, version, resumeSessionId, botConfig),
logInfo: (txt: string) => ipcRenderer.send('logInfo', txt),
showNotification: (data: any) => ipcRenderer.send('notify', data),
showNotification: (data: NotificationData) => ipcRenderer.send('notify', data),
openInChrome: (url: string) => ipcRenderer.send('open-in-chrome', url),
fetchMetadata: (url: string) => ipcRenderer.invoke('fetch-metadata', url),
reloadApp: () => ipcRenderer.send('reload-app'),
@@ -70,13 +89,19 @@ const electronAPI: ElectronAPI = {
readFile: (filePath: string) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('write-file', filePath, content),
on: (channel: string, callback: (event: Electron.IpcRendererEvent, ...args: any[]) => void) => {
on: (
channel: string,
callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
) => {
ipcRenderer.on(channel, callback);
},
off: (channel: string, callback: (event: Electron.IpcRendererEvent, ...args: any[]) => void) => {
off: (
channel: string,
callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
) => {
ipcRenderer.off(channel, callback);
},
emit: (channel: string, ...args: any[]) => {
emit: (channel: string, ...args: unknown[]) => {
ipcRenderer.emit(channel, ...args);
},
};

View File

@@ -1,7 +1,14 @@
import { toast } from 'react-toastify';
import { fetchSharedSessionDetails, SharedSessionDetails } from './sharedSessions';
import { type View } from './App';
interface SessionLinksViewOptions {
sessionDetails?: SharedSessionDetails | null;
error?: string;
shareToken?: string;
baseUrl?: string;
[key: string]: unknown;
}
/**
* Handles opening a shared session from a deep link
* @param url The deep link URL (goose://sessions/:shareToken)
@@ -11,7 +18,7 @@ import { type View } from './App';
*/
export async function openSharedSessionFromDeepLink(
url: string,
setView: (view: View, options?: Record<string, any>) => void,
setView: (view: View, options?: SessionLinksViewOptions) => void,
baseUrl?: string
): Promise<SharedSessionDetails | null> {
try {

View File

@@ -6,7 +6,7 @@ import log from './logger';
export const getBinaryPath = (app: Electron.App, binaryName: string): string => {
const isWindows = process.platform === 'win32';
const possiblePaths = [];
const possiblePaths: string[] = [];
if (isWindows) {
addPaths(isWindows, possiblePaths, `${binaryName}.exe`, app);
addPaths(isWindows, possiblePaths, `${binaryName}.cmd`, app);
@@ -33,7 +33,7 @@ export const getBinaryPath = (app: Electron.App, binaryName: string): string =>
const addPaths = (
isWindows: boolean,
possiblePaths: any[],
possiblePaths: string[],
executableName: string,
app: Electron.App
): void => {

View File

@@ -2,7 +2,7 @@ import { getApiUrl, getSecretKey } from '../config';
import { required_keys } from '../components/settings/models/hardcoded_stuff';
export async function DeleteProviderKeysFromKeychain() {
for (const [provider, keys] of Object.entries(required_keys)) {
for (const [_provider, keys] of Object.entries(required_keys)) {
for (const keyName of keys) {
try {
const deleteResponse = await fetch(getApiUrl('/configs/delete'), {

View File

@@ -16,7 +16,12 @@ import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigC
import { toastService } from '../toasts';
import { ExtensionQuery, addExtension as apiAddExtension } from '../api';
export function getStoredProvider(config: any): string | null {
interface AppConfig {
GOOSE_PROVIDER?: string;
[key: string]: unknown;
}
export function getStoredProvider(config: AppConfig): string | null {
return config.GOOSE_PROVIDER || localStorage.getItem(GOOSE_PROVIDER);
}

View File

@@ -65,8 +65,8 @@ async function selectProvider(mainWindow: any, provider: Provider) {
await mainWindow.waitForSelector('div[class*="bg-bgSubtle border-b border-borderSubtle"]', { timeout: 5000 });
await mainWindow.waitForTimeout(1000); // Give UI time to stabilize
const menuButton = await mainWindow.waitForSelector('button:has(svg)', {
timeout: 5000,
const menuButton = await mainWindow.waitForSelector('button[aria-label="More options"]', {
timeout: 2000,
state: 'visible'
});
await menuButton.click();
@@ -75,7 +75,7 @@ async function selectProvider(mainWindow: any, provider: Provider) {
await mainWindow.waitForTimeout(1000);
// Click "Reset provider and model"
const resetProviderButton = await mainWindow.waitForSelector('button:has-text("Reset provider and model")', { timeout: 5000 });
const resetProviderButton = await mainWindow.waitForSelector('button:has-text("Reset provider and model")', { timeout: 2000 });
await resetProviderButton.click();
// Wait for page to start refreshing
@@ -90,7 +90,7 @@ async function selectProvider(mainWindow: any, provider: Provider) {
}
// Wait for provider selection screen
const heading = await mainWindow.waitForSelector('h2:has-text("Choose a Provider")', { timeout: 10000 });
const heading = await mainWindow.waitForSelector('h2:has-text("Choose a Provider")', { timeout: 2000 });
const headingText = await heading.textContent();
expect(headingText).toBe('Choose a Provider');
@@ -112,7 +112,7 @@ async function selectProvider(mainWindow: any, provider: Provider) {
// Wait for chat interface to appear
const chatTextarea = await mainWindow.waitForSelector('textarea[placeholder*="What can goose help with?"]',
{ timeout: 15000 });
{ timeout: 5000 });
expect(await chatTextarea.isVisible()).toBe(true);
// Take screenshot of chat interface
@@ -220,32 +220,47 @@ test.describe('Goose App', () => {
test.describe('General UI', () => {
test('dark mode toggle', async () => {
console.log('Testing dark mode toggle...');
await selectProvider(mainWindow, providers[0]);
// Click the three dots menu button in the top right
await mainWindow.waitForSelector('div[class*="bg-bgSubtle border-b border-borderSubtle"]');
const menuButton = await mainWindow.waitForSelector('button:has(svg)', { timeout: 10000 });
const menuButton = await mainWindow.waitForSelector('button[aria-label="More options"]', {
timeout: 5000,
state: 'visible'
});
await menuButton.click();
// Find and click the dark mode toggle button
const darkModeButton = await mainWindow.waitForSelector('button:has-text("Dark Mode"), button:has-text("Light Mode")');
const darkModeButton = await mainWindow.waitForSelector('button:has-text("Dark")');
const lightModeButton = await mainWindow.waitForSelector('button:has-text("Light")');
const systemModeButton = await mainWindow.waitForSelector('button:has-text("System")');
// Get initial state
const isDarkMode = await mainWindow.evaluate(() => document.documentElement.classList.contains('dark'));
console.log('Initial dark mode state:', isDarkMode);
if (isDarkMode) {
// Click to toggle to light mode
await lightModeButton.click();
await mainWindow.waitForTimeout(1000);
const newDarkMode = await mainWindow.evaluate(() => document.documentElement.classList.contains('dark'));
expect(newDarkMode).toBe(!isDarkMode);
// Take screenshot to verify and pause to show the change
await mainWindow.screenshot({ path: 'test-results/dark-mode-toggle.png' });
} else {
// Click to toggle to dark mode
await darkModeButton.click();
await mainWindow.waitForTimeout(1000);
const newDarkMode = await mainWindow.evaluate(() => document.documentElement.classList.contains('dark'));
expect(newDarkMode).toBe(!isDarkMode);
}
// check that system mode is clickable
await systemModeButton.click();
// Click to toggle
await darkModeButton.click();
// Verify the change
const newDarkMode = await mainWindow.evaluate(() => document.documentElement.classList.contains('dark'));
expect(newDarkMode).toBe(!isDarkMode);
// Take screenshot to verify and pause to show the change
await mainWindow.screenshot({ path: 'test-results/dark-mode-toggle.png' });
await mainWindow.waitForTimeout(2000); // Pause in dark/light mode
// Toggle back to original state
await darkModeButton.click();
// Toggle back to light mode
await lightModeButton.click();
// Pause to show return to original state
await mainWindow.waitForTimeout(2000);
@@ -331,7 +346,6 @@ test.describe('Goose App', () => {
await chatInput.press('Enter');
// Wait for loading indicator and response
await mainWindow.waitForSelector('.text-textStandard >> text="goose is working on it…"', { timeout: 10000 });
await mainWindow.waitForSelector('.text-textStandard >> text="goose is working on it…"',
{ state: 'hidden', timeout: 30000 });
@@ -383,12 +397,25 @@ test.describe('Goose App', () => {
await mainWindow.reload();
await mainWindow.waitForLoadState('networkidle');
// Debug: Print HTML structure before trying to find menu button
// console.log('Debug: Current HTML structure before menu button selection:');
// const htmlStructure = await mainWindow.evaluate(() => document.documentElement.outerHTML);
// console.log(htmlStructure);
// Take screenshot before attempting to find menu button
await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-before-menu-button.png` });
// Click the menu button (3 dots)
const menuButton = await mainWindow.waitForSelector('button:has(svg)', { timeout: 10000 });
console.log('Attempting to find menu button...');
const menuButton = await mainWindow.waitForSelector('button[aria-label="More options"]', {
timeout: 5000,
state: 'visible'
});
console.log('Menu button found, clicking...');
await menuButton.click();
// Click Advanced Settings
const advancedSettingsButton = await mainWindow.waitForSelector('button:has-text("Advanced Settings")');
// Click Advanced settings
const advancedSettingsButton = await mainWindow.waitForSelector('button:has-text("Advanced settings")');
await advancedSettingsButton.click();
// Wait for settings page and take screenshot
@@ -440,7 +467,7 @@ test.describe('Goose App', () => {
console.log('Extension added successfully');
// Click Exit button to return to chat
const exitButton = await mainWindow.waitForSelector('button:has-text("Exit")', { timeout: 5000 });
const exitButton = await mainWindow.waitForSelector('button:has-text("Back")', { timeout: 5000 });
await exitButton.click();
} catch (error) {
@@ -472,7 +499,7 @@ test.describe('Goose App', () => {
// Wait for loading indicator
const loadingIndicator = await mainWindow.waitForSelector('.text-textStandard >> text="goose is working on it…"',
{ timeout: 10000 });
{ timeout: 30000 });
expect(await loadingIndicator.isVisible()).toBe(true);
// Take screenshot of loading state

View File

@@ -1,11 +1,45 @@
{
"compilerOptions": {
"esModuleInterop": true,
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"jsx": "react",
"lib": ["dom", "esnext"],
"paths": {
"@/*": ["./*"]
}
}
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Electron/Node.js specific */
"types": ["node", "electron", "vite/client"],
"typeRoots": ["./node_modules/@types", "./src/types"],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
/* Source Maps */
"sourceMap": true,
"inlineSources": true,
/* Additional Safety */
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noImplicitReturns": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["node", "electron"],
"target": "ES2020",
"esModuleInterop": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": [
"vite.config.ts",
"vite.main.config.ts",
"vite.preload.config.ts",
"vite.renderer.config.ts",
"forge.config.ts",
"src/main.ts",
"src/preload.ts"
]
}