diff --git a/ui/desktop/helper-scripts/README.md b/ui/desktop/helper-scripts/README.md deleted file mode 100644 index e49d5583d..000000000 --- a/ui/desktop/helper-scripts/README.md +++ /dev/null @@ -1,5 +0,0 @@ -Put `goosey` in your $PATH if you want to launch via: - -`goosey .` - -Will open goose GUI from any path you specify \ No newline at end of file diff --git a/ui/desktop/scripts/README.md b/ui/desktop/scripts/README.md new file mode 100644 index 000000000..92c8313fe --- /dev/null +++ b/ui/desktop/scripts/README.md @@ -0,0 +1,9 @@ +# Goosey + +Put `goosey` in your $PATH if you want to launch via: + +``` +goosey . +``` + +This will open goose GUI from any path you specify \ No newline at end of file diff --git a/ui/desktop/add-macos-cert.sh b/ui/desktop/scripts/add-macos-cert.sh similarity index 100% rename from ui/desktop/add-macos-cert.sh rename to ui/desktop/scripts/add-macos-cert.sh diff --git a/ui/desktop/helper-scripts/goosey b/ui/desktop/scripts/goosey similarity index 100% rename from ui/desktop/helper-scripts/goosey rename to ui/desktop/scripts/goosey diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 91d02d236..e35bb5ec3 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,64 +1,146 @@ import React, { useEffect, useState } from 'react'; import { addExtensionFromDeepLink } from './extensions'; -import LauncherWindow from './LauncherWindow'; -import ChatWindow from './ChatWindow'; +import { getStoredModel } from './utils/providerUtils'; +import { getStoredProvider, initializeSystem } from './utils/providerUtils'; +import { useModel } from './components/settings/models/ModelContext'; +import { useRecentModels } from './components/settings/models/RecentModels'; +import { createSelectedModel } from './components/settings/models/utils'; +import { getDefaultModel } from './components/settings/models/hardcoded_stuff'; import ErrorScreen from './components/ErrorScreen'; import { ConfirmationModal } from './components/ui/ConfirmationModal'; -import 'react-toastify/dist/ReactToastify.css'; import { ToastContainer } from 'react-toastify'; -import { ModelProvider } from './components/settings/models/ModelContext'; -import { ActiveKeysProvider } from './components/settings/api_keys/ActiveKeysContext'; import { extractExtensionName } from './components/settings/extensions/utils'; +import WelcomeView from './components/WelcomeView'; +import ChatView from './components/ChatView'; +import SettingsView from './components/settings/SettingsView'; +import MoreModelsView from './components/settings/models/MoreModelsView'; +import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView'; + +import 'react-toastify/dist/ReactToastify.css'; + +export type View = + | 'welcome' + | 'chat' + | 'settings' + | 'moreModels' + | 'configureProviders' + | 'configPage'; + export default function App() { const [fatalError, setFatalError] = useState(null); const [modalVisible, setModalVisible] = useState(false); const [pendingLink, setPendingLink] = useState(null); const [modalMessage, setModalMessage] = useState(''); - const [isInstalling, setIsInstalling] = useState(false); // Track installation progress - const searchParams = new URLSearchParams(window.location.search); - const isLauncher = searchParams.get('window') === 'launcher'; - const navigate = () => console.log('todo - bring back nav'); + const [isInstalling, setIsInstalling] = useState(false); + const [view, setView] = useState('welcome'); + const { switchModel } = useModel(); + const { addRecentModel } = useRecentModels(); // Utility function to extract the command from the link function extractCommand(link: string): string { const url = new URL(link); const cmd = url.searchParams.get('cmd') || 'Unknown Command'; const args = url.searchParams.getAll('arg').map(decodeURIComponent); - return `${cmd} ${args.join(' ')}`.trim(); // Combine the command and arguments + return `${cmd} ${args.join(' ')}`.trim(); } useEffect(() => { - const handleAddExtension = (_, link: string) => { - const command = extractCommand(link); // Extract and format the command + const handleAddExtension = (_: any, link: string) => { + const command = extractCommand(link); const extName = extractExtensionName(link); window.electron.logInfo(`Adding extension from deep link ${link}`); - setPendingLink(link); // Save the link for later use + setPendingLink(link); setModalMessage( `Are you sure you want to install the ${extName} extension?\n\nCommand: ${command}` - ); // Display command - setModalVisible(true); // Show confirmation modal + ); + setModalVisible(true); }; window.electron.on('add-extension', handleAddExtension); - return () => { - // Clean up the event listener when the component unmounts window.electron.off('add-extension', handleAddExtension); }; }, []); + // Keyboard shortcut handler + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'n') { + event.preventDefault(); + window.electron.createChatWindow(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + // Attempt to detect config for a stored provider + useEffect(() => { + const config = window.electron.getConfig(); + const storedProvider = getStoredProvider(config); + if (storedProvider) { + setView('chat'); + } else { + setView('welcome'); + } + }, []); + + // Initialize system if we have a stored provider + useEffect(() => { + const setupStoredProvider = async () => { + const config = window.electron.getConfig(); + + if (config.GOOSE_PROVIDER && config.GOOSE_MODEL) { + window.electron.logInfo( + 'Initializing system with environment: GOOSE_MODEL and GOOSE_PROVIDER as priority.' + ); + await initializeSystem(config.GOOSE_PROVIDER, config.GOOSE_MODEL); + return; + } + const storedProvider = getStoredProvider(config); + const storedModel = getStoredModel(); + if (storedProvider) { + try { + await initializeSystem(storedProvider, storedModel); + + if (!storedModel) { + const modelName = getDefaultModel(storedProvider.toLowerCase()); + const model = createSelectedModel(storedProvider.toLowerCase(), modelName); + switchModel(model); + addRecentModel(model); + } + } catch (error) { + console.error('Failed to initialize with stored provider:', error); + } + } + }; + + setupStoredProvider(); + }, []); + + useEffect(() => { + const handleFatalError = (_: any, errorMessage: string) => { + setFatalError(errorMessage); + }; + + window.electron.on('fatal-error', handleFatalError); + return () => { + window.electron.off('fatal-error', handleFatalError); + }; + }, []); + const handleConfirm = async () => { if (pendingLink && !isInstalling) { - setIsInstalling(true); // Disable further attempts - console.log('Confirming installation for link:', pendingLink); - + setIsInstalling(true); try { - await addExtensionFromDeepLink(pendingLink, navigate); + await addExtensionFromDeepLink(pendingLink, setView); } catch (error) { console.error('Failed to add extension:', error); } finally { - // Always reset states setModalVisible(false); setPendingLink(null); setIsInstalling(false); @@ -69,28 +151,22 @@ export default function App() { const handleCancel = () => { console.log('Cancelled extension installation.'); setModalVisible(false); - setPendingLink(null); // Clear the link if the user cancels + setPendingLink(null); }; - useEffect(() => { - const handleFatalError = (_: any, errorMessage: string) => { - setFatalError(errorMessage); - }; - - // Listen for fatal errors from main process - window.electron.on('fatal-error', handleFatalError); - - return () => { - window.electron.off('fatal-error', handleFatalError); - }; - }, []); - if (fatalError) { return window.electron.reloadApp()} />; } return ( <> + {modalVisible && ( )} - - - {isLauncher ? : } - - - +
+
+
+ {view === 'welcome' && ( + { + setView('chat'); + }} + /> + )} + {view === 'settings' && ( + { + setView('chat'); + }} + setView={setView} + /> + )} + {view === 'moreModels' && ( + { + setView('settings'); + }} + setView={setView} + /> + )} + {view === 'configureProviders' && ( + { + setView('settings'); + }} + /> + )} + {view === 'chat' && } +
+
); } diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx deleted file mode 100644 index e1de74140..000000000 --- a/ui/desktop/src/ChatWindow.tsx +++ /dev/null @@ -1,414 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Message, useChat } from './ai-sdk-fork/useChat'; -import { getApiUrl, getSecretKey } from './config'; -import BottomMenu from './components/BottomMenu'; -import FlappyGoose from './components/FlappyGoose'; -import GooseMessage from './components/GooseMessage'; -import Input from './components/Input'; -import LoadingGoose from './components/LoadingGoose'; -import MoreMenu from './components/MoreMenu'; -import { Card } from './components/ui/card'; -import { ScrollArea, ScrollAreaHandle } from './components/ui/scroll-area'; -import UserMessage from './components/UserMessage'; -import WingToWing, { Working } from './components/WingToWing'; -import { askAi } from './utils/askAI'; -import { getStoredModel, Provider } from './utils/providerUtils'; -import { ChatLayout } from './components/chat_window/ChatLayout'; -import { WelcomeScreen } from './components/welcome_screen/WelcomeScreen'; -import { getStoredProvider, initializeSystem } from './utils/providerUtils'; -import { useModel } from './components/settings/models/ModelContext'; -import { useRecentModels } from './components/settings/models/RecentModels'; -import { createSelectedModel } from './components/settings/models/utils'; -import { getDefaultModel } from './components/settings/models/hardcoded_stuff'; -import Splash from './components/Splash'; -import Settings from './components/settings/Settings'; -import MoreModelsSettings from './components/settings/models/MoreModels'; -import ConfigureProviders from './components/settings/providers/ConfigureProviders'; -import { ConfigPage } from './components/pages/ConfigPage'; - -export interface Chat { - id: number; - title: string; - messages: Array<{ - id: string; - role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool'; - content: string; - }>; -} - -export type View = - | 'welcome' - | 'chat' - | 'settings' - | 'moreModels' - | 'configureProviders' - | 'configPage'; - -// This component is our main chat content. -// We'll move the majority of chat logic here, minus the 'view' state. -export function ChatContent({ - chats, - setChats, - selectedChatId, - setSelectedChatId, - initialQuery, - setProgressMessage, - setWorking, - setView, -}: { - chats: Chat[]; - setChats: React.Dispatch>; - selectedChatId: number; - setSelectedChatId: React.Dispatch>; - initialQuery: string | null; - setProgressMessage: React.Dispatch>; - setWorking: React.Dispatch>; - setView: (view: View) => void; -}) { - const chat = chats.find((c: Chat) => c.id === selectedChatId); - const [messageMetadata, setMessageMetadata] = useState>({}); - const [hasMessages, setHasMessages] = useState(false); - const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); - const [showGame, setShowGame] = useState(false); - const [working, setWorkingLocal] = useState(Working.Idle); - const scrollRef = useRef(null); - - useEffect(() => { - setWorking(working); - }, [working, setWorking]); - - const updateWorking = (newWorking: Working) => { - setWorkingLocal(newWorking); - }; - - const { messages, append, stop, isLoading, error, setMessages } = useChat({ - api: getApiUrl('/reply'), - initialMessages: chat?.messages || [], - onToolCall: ({ toolCall }) => { - updateWorking(Working.Working); - setProgressMessage(`Executing tool: ${toolCall.toolName}`); - }, - onResponse: (response) => { - if (!response.ok) { - setProgressMessage('An error occurred while receiving the response.'); - updateWorking(Working.Idle); - } else { - setProgressMessage('thinking...'); - updateWorking(Working.Working); - } - }, - onFinish: async (message, _) => { - window.electron.stopPowerSaveBlocker(); - setTimeout(() => { - setProgressMessage('Task finished. Click here to expand.'); - updateWorking(Working.Idle); - }, 500); - - const fetchResponses = await askAi(message.content); - setMessageMetadata((prev) => ({ ...prev, [message.id]: fetchResponses })); - - const timeSinceLastInteraction = Date.now() - lastInteractionTime; - window.electron.logInfo('last interaction:' + lastInteractionTime); - if (timeSinceLastInteraction > 60000) { - // 60000ms = 1 minute - window.electron.showNotification({ - title: 'Goose finished the task.', - body: 'Click here to expand.', - }); - } - }, - }); - - // Update chat messages when they change - useEffect(() => { - const updatedChats = chats.map((c) => (c.id === selectedChatId ? { ...c, messages } : c)); - setChats(updatedChats); - }, [messages, selectedChatId]); - - const initialQueryAppended = useRef(false); - useEffect(() => { - if (initialQuery && !initialQueryAppended.current) { - append({ role: 'user', content: initialQuery }); - initialQueryAppended.current = true; - } - }, [initialQuery]); - - useEffect(() => { - if (messages.length > 0) { - setHasMessages(true); - } - }, [messages]); - - // Handle submit - const handleSubmit = (e: React.FormEvent) => { - window.electron.startPowerSaveBlocker(); - const customEvent = e as CustomEvent; - const content = customEvent.detail?.value || ''; - if (content.trim()) { - setLastInteractionTime(Date.now()); - append({ - role: 'user', - content, - }); - if (scrollRef.current?.scrollToBottom) { - scrollRef.current.scrollToBottom(); - } - } - }; - - if (error) { - console.log('Error:', error); - } - - const onStopGoose = () => { - stop(); - setLastInteractionTime(Date.now()); - window.electron.stopPowerSaveBlocker(); - - const lastMessage: Message = messages[messages.length - 1]; - if (lastMessage.role === 'user' && lastMessage.toolInvocations === undefined) { - // Remove the last user message. - if (messages.length > 1) { - setMessages(messages.slice(0, -1)); - } else { - setMessages([]); - } - } else if (lastMessage.role === 'assistant' && lastMessage.toolInvocations !== undefined) { - // Add messaging about interrupted ongoing tool invocations - const newLastMessage: Message = { - ...lastMessage, - toolInvocations: lastMessage.toolInvocations.map((invocation) => { - if (invocation.state !== 'result') { - return { - ...invocation, - result: [ - { - audience: ['user'], - text: 'Interrupted.\n', - type: 'text', - }, - { - audience: ['assistant'], - text: 'Interrupted by the user to make a correction.\n', - type: 'text', - }, - ], - state: 'result', - }; - } else { - return invocation; - } - }), - }; - - const updatedMessages = [...messages.slice(0, -1), newLastMessage]; - setMessages(updatedMessages); - } - }; - - return ( -
-
- {/* Pass setView to MoreMenu so it can switch to settings or other views */} - -
- - {messages.length === 0 ? ( - - ) : ( - - {messages.map((message) => ( -
- {message.role === 'user' ? ( - - ) : ( - - )} -
- ))} - {error && ( -
-
- {error.message || 'Honk! Goose experienced an error while responding'} - {error.status && (Status: {error.status})} -
-
{ - const lastUserMessage = messages.reduceRight( - (found, m) => found || (m.role === 'user' ? m : null), - null - ); - if (lastUserMessage) { - append({ - role: 'user', - content: lastUserMessage.content, - }); - } - }} - > - Retry Last Message -
-
- )} -
- - )} - -
- {isLoading && } - - -
- - - {showGame && setShowGame(false)} />} -
- ); -} - -export default function ChatWindow() { - // We'll add a state controlling which "view" is active. - const [view, setView] = useState('welcome'); - - // Shared function to create a chat window - const openNewChatWindow = () => { - window.electron.createChatWindow(); - }; - const { switchModel } = useModel(); - const { addRecentModel } = useRecentModels(); - - // This will store chat data for the "chat" view. - const [chats, setChats] = useState(() => [ - { - id: 1, - title: 'Chat 1', - messages: [], - }, - ]); - const [selectedChatId, setSelectedChatId] = useState(1); - - // Additional states - const [mode, setMode] = useState<'expanded' | 'compact'>('expanded'); - const [working, setWorking] = useState(Working.Idle); - const [progressMessage, setProgressMessage] = useState(''); - const [initialQuery, setInitialQuery] = useState(null); - - // Keyboard shortcut handler - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key === 'n') { - event.preventDefault(); - openNewChatWindow(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - }, []); - - // Attempt to detect config for a stored provider - useEffect(() => { - const config = window.electron.getConfig(); - const storedProvider = getStoredProvider(config); - if (storedProvider) { - setView('chat'); - } else { - setView('welcome'); - } - }, []); - - // Initialize system if we have a stored provider - useEffect(() => { - const setupStoredProvider = async () => { - const config = window.electron.getConfig(); - - if (config.GOOSE_PROVIDER && config.GOOSE_MODEL) { - window.electron.logInfo( - 'Initializing system with environment: GOOSE_MODEL and GOOSE_PROVIDER as priority.' - ); - await initializeSystem(config.GOOSE_PROVIDER, config.GOOSE_MODEL); - return; - } - const storedProvider = getStoredProvider(config); - const storedModel = getStoredModel(); - if (storedProvider) { - try { - await initializeSystem(storedProvider, storedModel); - - if (!storedModel) { - const modelName = getDefaultModel(storedProvider.toLowerCase()); - const model = createSelectedModel(storedProvider.toLowerCase(), modelName); - switchModel(model); - addRecentModel(model); - } - } catch (error) { - console.error('Failed to initialize with stored provider:', error); - } - } - }; - - setupStoredProvider(); - }, []); - - return ( - - {/* Conditionally render based on `view` */} - {view === 'welcome' && ( - { - setView('chat'); - }} - /> - )} - {view === 'settings' && ( - { - setView('chat'); - }} - setView={setView} - /> - )} - {view === 'moreModels' && ( - { - setView('settings'); - }} - setView={setView} - /> - )} - {view === 'configureProviders' && ( - { - setView('settings'); - }} - setView={setView} - /> - )} - {view === 'chat' && ( - - )} - - ); -} diff --git a/ui/desktop/src/LauncherWindow.tsx b/ui/desktop/src/LauncherWindow.tsx deleted file mode 100644 index ab1ae2450..000000000 --- a/ui/desktop/src/LauncherWindow.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React, { useRef, useState } from 'react'; - -export default function SpotlightWindow() { - const [query, setQuery] = useState(''); - const inputRef = useRef(null); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (query.trim()) { - // Create a new chat window with the query - window.electron.createChatWindow(query); - setQuery(''); - inputRef.current.blur(); - } - }; - - return ( -
-
- setQuery(e.target.value)} - className="w-full bg-transparent text-black text-xl px-4 py-2 outline-none placeholder-gray-400" - placeholder="Type a command..." - autoFocus - /> -
-
- ); -} diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx new file mode 100644 index 000000000..12669ca33 --- /dev/null +++ b/ui/desktop/src/components/ChatView.tsx @@ -0,0 +1,209 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Message, useChat } from '../ai-sdk-fork/useChat'; +import { getApiUrl } from '../config'; +import BottomMenu from './BottomMenu'; +import FlappyGoose from './FlappyGoose'; +import GooseMessage from './GooseMessage'; +import Input from './Input'; +import { type View } from '../App'; +import LoadingGoose from './LoadingGoose'; +import MoreMenu from './MoreMenu'; +import { Card } from './ui/card'; +import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; +import UserMessage from './UserMessage'; +import { askAi } from '../utils/askAI'; +import Splash from './Splash'; +import 'react-toastify/dist/ReactToastify.css'; + +export interface ChatType { + id: number; + title: string; + messages: Array<{ + id: string; + role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool'; + content: string; + }>; +} + +export default function ChatView({ setView }: { setView: (view: View) => void }) { + const [chat, setChat] = useState(() => { + return { + id: 1, + title: 'Chat 1', + messages: [], + }; + }); + const [messageMetadata, setMessageMetadata] = useState>({}); + const [hasMessages, setHasMessages] = useState(false); + const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); + const [showGame, setShowGame] = useState(false); + const scrollRef = useRef(null); + + const { messages, append, stop, isLoading, error, setMessages } = useChat({ + api: getApiUrl('/reply'), + initialMessages: chat?.messages || [], + onFinish: async (message, _) => { + window.electron.stopPowerSaveBlocker(); + + const fetchResponses = await askAi(message.content); + setMessageMetadata((prev) => ({ ...prev, [message.id]: fetchResponses })); + + const timeSinceLastInteraction = Date.now() - lastInteractionTime; + window.electron.logInfo('last interaction:' + lastInteractionTime); + if (timeSinceLastInteraction > 60000) { + // 60000ms = 1 minute + window.electron.showNotification({ + title: 'Goose finished the task.', + body: 'Click here to expand.', + }); + } + }, + }); + + // Update chat messages when they change + useEffect(() => { + setChat({ ...chat, messages }); + }, [messages]); + + useEffect(() => { + if (messages.length > 0) { + setHasMessages(true); + } + }, [messages]); + + // Handle submit + const handleSubmit = (e: React.FormEvent) => { + window.electron.startPowerSaveBlocker(); + const customEvent = e as CustomEvent; + const content = customEvent.detail?.value || ''; + if (content.trim()) { + setLastInteractionTime(Date.now()); + append({ + role: 'user', + content, + }); + if (scrollRef.current?.scrollToBottom) { + scrollRef.current.scrollToBottom(); + } + } + }; + + if (error) { + console.log('Error:', error); + } + + const onStopGoose = () => { + stop(); + setLastInteractionTime(Date.now()); + window.electron.stopPowerSaveBlocker(); + + const lastMessage: Message = messages[messages.length - 1]; + if (lastMessage.role === 'user' && lastMessage.toolInvocations === undefined) { + // Remove the last user message. + if (messages.length > 1) { + setMessages(messages.slice(0, -1)); + } else { + setMessages([]); + } + } else if (lastMessage.role === 'assistant' && lastMessage.toolInvocations !== undefined) { + // Add messaging about interrupted ongoing tool invocations + const newLastMessage: Message = { + ...lastMessage, + toolInvocations: lastMessage.toolInvocations.map((invocation) => { + if (invocation.state !== 'result') { + return { + ...invocation, + result: [ + { + audience: ['user'], + text: 'Interrupted.\n', + type: 'text', + }, + { + audience: ['assistant'], + text: 'Interrupted by the user to make a correction.\n', + type: 'text', + }, + ], + state: 'result', + }; + } else { + return invocation; + } + }), + }; + + const updatedMessages = [...messages.slice(0, -1), newLastMessage]; + setMessages(updatedMessages); + } + }; + + return ( +
+
+ +
+ + {messages.length === 0 ? ( + + ) : ( + + {messages.map((message) => ( +
+ {message.role === 'user' ? ( + + ) : ( + + )} +
+ ))} + {error && ( +
+
+ {error.message || 'Honk! Goose experienced an error while responding'} + {error.status && (Status: {error.status})} +
+
{ + const lastUserMessage = messages.reduceRight( + (found, m) => found || (m.role === 'user' ? m : null), + null + ); + if (lastUserMessage) { + append({ + role: 'user', + content: lastUserMessage.content, + }); + } + }} + > + Retry Last Message +
+
+ )} +
+ + )} + +
+ {isLoading && } + + +
+ + + {showGame && setShowGame(false)} />} +
+ ); +} diff --git a/ui/desktop/src/components/ErrorBoundary.tsx b/ui/desktop/src/components/ErrorBoundary.tsx new file mode 100644 index 000000000..b9864725f --- /dev/null +++ b/ui/desktop/src/components/ErrorBoundary.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +// Capture unhandled promise rejections +window.addEventListener('unhandledrejection', (event) => { + window.electron.logInfo(`[UNHANDLED REJECTION] ${event.reason}`); +}); + +// Capture global errors +window.addEventListener('error', (event) => { + window.electron.logInfo( + `[GLOBAL ERROR] ${event.message} at ${event.filename}:${event.lineno}:${event.colno}` + ); +}); + +export class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean } +> { + constructor(props: { children: React.ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(_: Error) { + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + // Send error to main process + window.electron.logInfo(`[ERROR] ${error.toString()}\n${errorInfo.componentStack}`); + } + + render() { + if (this.state.hasError) { + return

Something went wrong.

; + } + + return this.props.children; + } +} diff --git a/ui/desktop/src/components/MoreMenu.tsx b/ui/desktop/src/components/MoreMenu.tsx index 06ece801d..7cab710b8 100644 --- a/ui/desktop/src/components/MoreMenu.tsx +++ b/ui/desktop/src/components/MoreMenu.tsx @@ -1,14 +1,14 @@ import { Popover, PopoverContent, PopoverTrigger, PopoverPortal } from '@radix-ui/react-popover'; import React, { useEffect, useState } from 'react'; import { More } from './icons'; -import type { View } from '../../ChatWindow'; +import type { View } from '../ChatWindow'; interface VersionInfo { current_version: string; available_versions: string[]; } -// Accept setView as a prop from the parent (e.g. ChatContent) +// Accept setView as a prop from the parent (e.g. Chat) export default function MoreMenu({ setView }: { setView: (view: View) => void }) { const [open, setOpen] = useState(false); const [versions, setVersions] = useState(null); diff --git a/ui/desktop/src/components/welcome_screen/ProviderGrid.tsx b/ui/desktop/src/components/ProviderGrid.tsx similarity index 88% rename from ui/desktop/src/components/welcome_screen/ProviderGrid.tsx rename to ui/desktop/src/components/ProviderGrid.tsx index 7a9cc8fea..f9735c758 100644 --- a/ui/desktop/src/components/welcome_screen/ProviderGrid.tsx +++ b/ui/desktop/src/components/ProviderGrid.tsx @@ -3,18 +3,18 @@ import { supported_providers, required_keys, provider_aliases, -} from '../settings/models/hardcoded_stuff'; -import { useActiveKeys } from '../settings/api_keys/ActiveKeysContext'; -import { ProviderSetupModal } from '../settings/ProviderSetupModal'; -import { useModel } from '../settings/models/ModelContext'; -import { useRecentModels } from '../settings/models/RecentModels'; -import { createSelectedModel } from '../settings/models/utils'; -import { getDefaultModel } from '../settings/models/hardcoded_stuff'; -import { initializeSystem } from '../../utils/providerUtils'; -import { getApiUrl, getSecretKey } from '../../config'; +} from './settings/models/hardcoded_stuff'; +import { useActiveKeys } from './settings/api_keys/ActiveKeysContext'; +import { ProviderSetupModal } from './settings/ProviderSetupModal'; +import { useModel } from './settings/models/ModelContext'; +import { useRecentModels } from './settings/models/RecentModels'; +import { createSelectedModel } from './settings/models/utils'; +import { getDefaultModel } from './settings/models/hardcoded_stuff'; +import { initializeSystem } from '../utils/providerUtils'; +import { getApiUrl, getSecretKey } from '../config'; import { toast } from 'react-toastify'; -import { getActiveProviders, isSecretKey } from '../settings/api_keys/utils'; -import { BaseProviderGrid, getProviderDescription } from '../settings/providers/BaseProviderGrid'; +import { getActiveProviders, isSecretKey } from './settings/api_keys/utils'; +import { BaseProviderGrid, getProviderDescription } from './settings/providers/BaseProviderGrid'; interface ProviderGridProps { onSubmit?: () => void; diff --git a/ui/desktop/src/components/WelcomeScreen.tsx b/ui/desktop/src/components/WelcomeScreen.tsx deleted file mode 100644 index 6fcb7fb8c..000000000 --- a/ui/desktop/src/components/WelcomeScreen.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { Card } from './ui/card'; -import { Bird } from './ui/icons'; - -interface WelcomeScreenProps { - className?: string; - onDismiss: () => void; -} - -export function WelcomeScreen({ className, onDismiss }: WelcomeScreenProps) { - return ( - -
- -
-
-

- Welcome to Goose 1.0 beta! 🎉 -

-
- Goose is your AI-powered agent. -
-
- - {' '} - Warning: During the beta, your chats are not saved - closing the window
- or closing the app will lose your history.
-
-
-
- Try ⌘+N for a new window, or ⌘+O to work on a specific directory. -
- - -
-
- ); -} diff --git a/ui/desktop/src/components/welcome_screen/WelcomeScreen.tsx b/ui/desktop/src/components/WelcomeView.tsx similarity index 93% rename from ui/desktop/src/components/welcome_screen/WelcomeScreen.tsx rename to ui/desktop/src/components/WelcomeView.tsx index 398ee8549..146b72fc5 100644 --- a/ui/desktop/src/components/welcome_screen/WelcomeScreen.tsx +++ b/ui/desktop/src/components/WelcomeView.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { ProviderGrid } from './ProviderGrid'; -import { ScrollArea } from '../ui/scroll-area'; -import { Button } from '../ui/button'; -import WelcomeGooseLogo from '../WelcomeGooseLogo'; +import { ScrollArea } from './ui/scroll-area'; +import { Button } from './ui/button'; +import WelcomeGooseLogo from './WelcomeGooseLogo'; // Extending React CSSProperties to include custom webkit property declare module 'react' { @@ -15,7 +15,7 @@ interface WelcomeScreenProps { onSubmit?: () => void; } -export function WelcomeScreen({ onSubmit }: WelcomeScreenProps) { +export default function WelcomeScreen({ onSubmit }: WelcomeScreenProps) { return (
{/* Draggable title bar region */} diff --git a/ui/desktop/src/components/WingToWing.tsx b/ui/desktop/src/components/WingToWing.tsx deleted file mode 100644 index bc066e6ba..000000000 --- a/ui/desktop/src/components/WingToWing.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { Bird } from '../components/ui/icons'; - -export enum Working { - Idle = 'Idle', - Working = 'Working', -} - -interface WingToWingProps { - onExpand: () => void; - progressMessage: string; - working: Working; -} - -const WingToWing: React.FC = ({ onExpand, progressMessage, working }) => { - return ( -
- {working === Working.Working && ( -
- -
- )} - - {/* Status Text */} -
- {progressMessage} -
-
- ); -}; - -export default WingToWing; diff --git a/ui/desktop/src/components/chat_window/ChatLayout.tsx b/ui/desktop/src/components/chat_window/ChatLayout.tsx deleted file mode 100644 index 20843d177..000000000 --- a/ui/desktop/src/components/chat_window/ChatLayout.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; - -export const ChatLayout = ({ children, mode }) => ( -
-
-
{children}
-
-); diff --git a/ui/desktop/src/components/chat_window/ChatRoutes.tsx b/ui/desktop/src/components/chat_window/ChatRoutes.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/ui/desktop/src/components/settings/Settings.tsx b/ui/desktop/src/components/settings/SettingsView.tsx similarity index 98% rename from ui/desktop/src/components/settings/Settings.tsx rename to ui/desktop/src/components/settings/SettingsView.tsx index 33d7e45b8..8f16fe2e3 100644 --- a/ui/desktop/src/components/settings/Settings.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -14,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 '../../ChatWindow'; +import type { View } from '../../App'; const EXTENSIONS_DESCRIPTION = 'The Model Context Protocol (MCP) is a system that allows AI models to securely connect with local or remote resources using standard server setups. It works like a client-server setup and expands AI capabilities using three main components: Prompts, Resources, and Tools.'; @@ -46,10 +46,7 @@ const DEFAULT_SETTINGS: SettingsType = { extensions: BUILT_IN_EXTENSIONS, }; -// We'll accept two props: -// onClose: to go back to chat -// setView: to switch to moreModels, configureProviders, etc. -export default function Settings({ +export default function SettingsView({ onClose, setView, }: { diff --git a/ui/desktop/src/components/settings/models/MoreModels.tsx b/ui/desktop/src/components/settings/models/MoreModelsView.tsx similarity index 93% rename from ui/desktop/src/components/settings/models/MoreModels.tsx rename to ui/desktop/src/components/settings/models/MoreModelsView.tsx index 14adbe3f0..1e8c07255 100644 --- a/ui/desktop/src/components/settings/models/MoreModels.tsx +++ b/ui/desktop/src/components/settings/models/MoreModelsView.tsx @@ -3,20 +3,17 @@ import { RecentModels } from './RecentModels'; import { ProviderButtons } from './ProviderButtons'; import BackButton from '../../ui/BackButton'; import { SearchBar } from './Search'; -import { useModel } from './ModelContext'; import { AddModelInline } from './AddModelInline'; import { ScrollArea } from '../../ui/scroll-area'; -import type { View } from '../../../ChatWindow'; +import type { View } from '../../../App'; -export default function MoreModelsPage({ +export default function MoreModelsView({ onClose, setView, }: { onClose: () => void; setView: (view: View) => void; }) { - const { currentModel } = useModel(); - return (
diff --git a/ui/desktop/src/components/settings/providers/ConfigureProviders.tsx b/ui/desktop/src/components/settings/providers/ConfigureProvidersView.tsx similarity index 85% rename from ui/desktop/src/components/settings/providers/ConfigureProviders.tsx rename to ui/desktop/src/components/settings/providers/ConfigureProvidersView.tsx index f2df160fb..796494ecf 100644 --- a/ui/desktop/src/components/settings/providers/ConfigureProviders.tsx +++ b/ui/desktop/src/components/settings/providers/ConfigureProvidersView.tsx @@ -2,15 +2,8 @@ import React from 'react'; import { ScrollArea } from '../../ui/scroll-area'; import BackButton from '../../ui/BackButton'; import { ConfigureProvidersGrid } from './ConfigureProvidersGrid'; -import type { View } from '../../../ChatWindow'; -export default function ConfigureProviders({ - onClose, - setView, -}: { - onClose: () => void; - setView?: (view: View) => void; -}) { +export default function ConfigureProvidersView({ onClose }: { onClose: () => void }) { return (
diff --git a/ui/desktop/src/extensions.tsx b/ui/desktop/src/extensions.ts similarity index 97% rename from ui/desktop/src/extensions.tsx rename to ui/desktop/src/extensions.ts index ec5c0d908..0ef4134fb 100644 --- a/ui/desktop/src/extensions.tsx +++ b/ui/desktop/src/extensions.ts @@ -1,4 +1,5 @@ import { getApiUrl, getSecretKey } from './config'; +import { type View } from './App'; import { toast } from 'react-toastify'; // ExtensionConfig type matching the Rust version @@ -257,7 +258,7 @@ function handleError(message: string, shouldThrow = false): void { } } -export async function addExtensionFromDeepLink(url: string, navigate: any) { +export async function addExtensionFromDeepLink(url: string, setView: (view: View) => void) { if (!url.startsWith('goose://extension')) { handleError( 'Failed to install extension: Invalid URL: URL must use the goose://extension scheme' @@ -343,7 +344,9 @@ export async function addExtensionFromDeepLink(url: string, navigate: any) { // Check if extension requires env vars and go to settings if so if (envVarsRequired(config)) { console.log('Environment variables required, redirecting to settings'); - navigate(`/settings?extensionId=${config.id}&showEnvVars=true`); + setView('settings'); + // TODO - add code which can auto-open the modal on the settings view + // navigate(`/settings?extensionId=${config.id}&showEnvVars=true`); return; } diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index eaca3a091..9e97579f5 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -181,47 +181,6 @@ let appConfig = { secretKey: generateSecretKey(), }; -const createLauncher = () => { - const launcherWindow = new BrowserWindow({ - width: 600, - height: 60, - frame: process.platform === 'darwin' ? false : true, - transparent: false, - webPreferences: { - preload: path.join(__dirname, 'preload.ts'), - additionalArguments: [JSON.stringify(appConfig)], - partition: 'persist:goose', - }, - skipTaskbar: true, - alwaysOnTop: true, - }); - - // Center on screen - const primaryDisplay = electron.screen.getPrimaryDisplay(); - const { width, height } = primaryDisplay.workAreaSize; - const windowBounds = launcherWindow.getBounds(); - - launcherWindow.setPosition( - Math.round(width / 2 - windowBounds.width / 2), - Math.round(height / 3 - windowBounds.height / 2) - ); - - // Load launcher window content - const launcherParams = '?window=launcher#/launcher'; - if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { - launcherWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}${launcherParams}`); - } else { - launcherWindow.loadFile( - path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html${launcherParams}`) - ); - } - - // Destroy window when it loses focus - launcherWindow.on('blur', () => { - launcherWindow.destroy(); - }); -}; - // Track windows by ID let windowCounter = 0; const windowMap = new Map(); @@ -487,9 +446,6 @@ app.whenReady().then(async () => { let openDir = dirPath || (recentDirs.length > 0 ? recentDirs[0] : null); createChat(app, undefined, openDir); - // Show launcher input on key combo - globalShortcut.register('Control+Alt+Command+G', createLauncher); - // Get the existing menu const menu = Menu.getApplicationMenu(); diff --git a/ui/desktop/src/preload.js b/ui/desktop/src/preload.js deleted file mode 100644 index 2b2d0b2f4..000000000 --- a/ui/desktop/src/preload.js +++ /dev/null @@ -1,29 +0,0 @@ -const { contextBridge, ipcRenderer } = require('electron') - -const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}'); - -contextBridge.exposeInMainWorld('appConfig', { - get: (key) => config[key], - getAll: () => config, -}); - -contextBridge.exposeInMainWorld('electron', { - getConfig: () => config, - hideWindow: () => ipcRenderer.send('hide-window'), - directoryChooser: (replace) => ipcRenderer.send('directory-chooser', replace), - createChatWindow: (query, dir, version) => ipcRenderer.send('create-chat-window', query, dir, version), - logInfo: (txt) => ipcRenderer.send('logInfo', txt), - showNotification: (data) => ipcRenderer.send('notify', data), - createWingToWingWindow: (query) => ipcRenderer.send('create-wing-to-wing-window', query), - openInChrome: (url) => ipcRenderer.send('open-in-chrome', url), - fetchMetadata: (url) => ipcRenderer.invoke('fetch-metadata', url), - reloadApp: () => ipcRenderer.send('reload-app'), - checkForOllama: () => ipcRenderer.invoke('check-ollama'), - selectFileOrDirectory: () => ipcRenderer.invoke('select-file-or-directory'), - startPowerSaveBlocker: () => ipcRenderer.invoke('start-power-save-blocker'), - stopPowerSaveBlocker: () => ipcRenderer.invoke('stop-power-save-blocker'), - getBinaryPath: (binaryName) => ipcRenderer.invoke('get-binary-path', binaryName), - on: (channel, callback) => ipcRenderer.on(channel, callback), - off: (channel, callback) => ipcRenderer.off(channel, callback), - send: (key) => ipcRenderer.send(key) -}); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 51105a578..7a2c0281d 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -10,7 +10,6 @@ type ElectronAPI = { createChatWindow: (query?: string, dir?: string, version?: string) => void; logInfo: (txt: string) => void; showNotification: (data: any) => void; - createWingToWingWindow: (query: string) => void; openInChrome: (url: string) => void; fetchMetadata: (url: string) => Promise; reloadApp: () => void; @@ -42,7 +41,6 @@ const electronAPI: ElectronAPI = { ipcRenderer.send('create-chat-window', query, dir, version), logInfo: (txt: string) => ipcRenderer.send('logInfo', txt), showNotification: (data: any) => ipcRenderer.send('notify', data), - createWingToWingWindow: (query: string) => ipcRenderer.send('create-wing-to-wing-window', query), openInChrome: (url: string) => ipcRenderer.send('open-in-chrome', url), fetchMetadata: (url: string) => ipcRenderer.invoke('fetch-metadata', url), reloadApp: () => ipcRenderer.send('reload-app'), diff --git a/ui/desktop/src/renderer.js b/ui/desktop/src/renderer.js deleted file mode 100644 index d5248d94b..000000000 --- a/ui/desktop/src/renderer.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * This file will automatically be loaded by vite and run in the "renderer" context. - * To learn more about the differences between the "main" and the "renderer" context in - * Electron, visit: - * - * https://electronjs.org/docs/tutorial/application-architecture#main-and-renderer-processes - * - * By default, Node.js integration in this file is disabled. When enabling Node.js integration - * in a renderer process, please be aware of potential security implications. You can read - * more about security risks here: - * - * https://electronjs.org/docs/tutorial/security - * - * To enable Node.js integration in this file, open up `main.js` and enable the `nodeIntegration` - * flag: - * - * ``` - * // Create the browser window. - * mainWindow = new BrowserWindow({ - * width: 800, - * height: 600, - * webPreferences: { - * nodeIntegration: true - * } - * }); - * ``` - */ - -import "/styles/main.css"; - -console.log( - '👋 This message is being logged by "renderer.js", included via Vite' -); diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx index 66cb3cd0f..8ac19a843 100644 --- a/ui/desktop/src/renderer.tsx +++ b/ui/desktop/src/renderer.tsx @@ -1,77 +1,21 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import { ModelProvider } from './components/settings/models/ModelContext'; +import { ErrorBoundary } from './components/ErrorBoundary'; +import { ActiveKeysProvider } from './components/settings/api_keys/ActiveKeysContext'; +import { patchConsoleLogging } from './utils'; -// Error Boundary Component -class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> { - constructor(props: { children: React.ReactNode }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError(_: Error) { - return { hasError: true }; - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - // Send error to main process - window.electron.logInfo(`[ERROR] ${error.toString()}\n${errorInfo.componentStack}`); - } - - render() { - if (this.state.hasError) { - return

Something went wrong.

; - } - - return this.props.children; - } -} - -// Set up console interceptors -const originalConsole = { - log: console.log, - error: console.error, - warn: console.warn, - info: console.info, -}; - -// Intercept console methods -console.log = (...args) => { - window.electron.logInfo(`[LOG] ${args.join(' ')}`); - originalConsole.log(...args); -}; - -console.error = (...args) => { - window.electron.logInfo(`[ERROR] ${args.join(' ')}`); - originalConsole.error(...args); -}; - -console.warn = (...args) => { - window.electron.logInfo(`[WARN] ${args.join(' ')}`); - originalConsole.warn(...args); -}; - -console.info = (...args) => { - window.electron.logInfo(`[INFO] ${args.join(' ')}`); - originalConsole.info(...args); -}; - -// Capture unhandled promise rejections -window.addEventListener('unhandledrejection', (event) => { - window.electron.logInfo(`[UNHANDLED REJECTION] ${event.reason}`); -}); - -// Capture global errors -window.addEventListener('error', (event) => { - window.electron.logInfo( - `[GLOBAL ERROR] ${event.message} at ${event.filename}:${event.lineno}:${event.colno}` - ); -}); +patchConsoleLogging(); ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + + + + + ); diff --git a/ui/desktop/src/utils.ts b/ui/desktop/src/utils.ts index b31c8ed37..aefa6beab 100644 --- a/ui/desktop/src/utils.ts +++ b/ui/desktop/src/utils.ts @@ -11,3 +11,33 @@ export function snakeToTitleCase(snake: string): string { .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); } + +export function patchConsoleLogging() { + // Intercept console methods + const originalConsole = { + log: console.log, + error: console.error, + warn: console.warn, + info: console.info, + }; + + console.log = (...args: any[]) => { + window.electron.logInfo(`[LOG] ${args.join(' ')}`); + originalConsole.log(...args); + }; + + console.error = (...args: any[]) => { + window.electron.logInfo(`[ERROR] ${args.join(' ')}`); + originalConsole.error(...args); + }; + + console.warn = (...args: any[]) => { + window.electron.logInfo(`[WARN] ${args.join(' ')}`); + originalConsole.warn(...args); + }; + + console.info = (...args: any[]) => { + window.electron.logInfo(`[INFO] ${args.join(' ')}`); + originalConsole.info(...args); + }; +} diff --git a/ui/desktop/test.json b/ui/desktop/test.json deleted file mode 100644 index 94e95f621..000000000 --- a/ui/desktop/test.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello! ping www.apple.com 10 times." - } - ] - } - ] - } \ No newline at end of file diff --git a/ui/desktop/test.sh b/ui/desktop/test.sh deleted file mode 100755 index 099278f58..000000000 --- a/ui/desktop/test.sh +++ /dev/null @@ -1,7 +0,0 @@ -# run cargo run -p goose-server: https://github.com/block/goose/pull/237 - -curl -N http://localhost:3000/reply \ - -H "Content-Type: application/json" \ - -H "Accept: text/event-stream" \ - -H "x-protocol: data" \ - -d @test.json \ No newline at end of file