refactor: Component hierarchy and code cleanup (#1319)

This commit is contained in:
Alex Hancock
2025-02-21 11:29:07 -05:00
committed by GitHub
parent bfe23765b5
commit 81a0334aa1
28 changed files with 475 additions and 823 deletions
+209
View File
@@ -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<ChatType>(() => {
return {
id: 1,
title: 'Chat 1',
messages: [],
};
});
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 scrollRef = useRef<ScrollAreaHandle>(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 (
<div className="flex flex-col w-full h-screen items-center justify-center">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle border-b border-borderSubtle">
<MoreMenu setView={setView} />
</div>
<Card className="flex flex-col flex-1 rounded-none h-[calc(100vh-95px)] w-full bg-bgApp mt-0 border-none relative">
{messages.length === 0 ? (
<Splash append={append} />
) : (
<ScrollArea ref={scrollRef} className="flex-1 px-4" autoScroll>
{messages.map((message) => (
<div key={message.id} className="mt-[16px]">
{message.role === 'user' ? (
<UserMessage message={message} />
) : (
<GooseMessage
message={message}
messages={messages}
metadata={messageMetadata[message.id]}
append={append}
/>
)}
</div>
))}
{error && (
<div className="flex flex-col items-center justify-center p-4">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
{error.message || 'Honk! Goose experienced an error while responding'}
{error.status && <span className="ml-2">(Status: {error.status})</span>}
</div>
<div
className="px-3 py-2 mt-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
onClick={async () => {
const lastUserMessage = messages.reduceRight(
(found, m) => found || (m.role === 'user' ? m : null),
null
);
if (lastUserMessage) {
append({
role: 'user',
content: lastUserMessage.content,
});
}
}}
>
Retry Last Message
</div>
</div>
)}
<div className="block h-16" />
</ScrollArea>
)}
<div className="relative">
{isLoading && <LoadingGoose />}
<Input
handleSubmit={handleSubmit}
disabled={isLoading}
isLoading={isLoading}
onStop={onStopGoose}
/>
<BottomMenu hasMessages={hasMessages} setView={setView} />
</div>
</Card>
{showGame && <FlappyGoose onClose={() => setShowGame(false)} />}
</div>
);
}
@@ -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 <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
+2 -2
View File
@@ -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<VersionInfo | null>(null);
@@ -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;
@@ -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 (
<Card
className={`flex flex-col items-center justify-center p-8 space-y-6 bg-bgApp w-full h-full ${className}`}
>
<div className="w-16 h-16">
<Bird />
</div>
<div className="text-center space-y-4">
<h2 className="text-2xl font-semibold text-gray-800 dark:text-white/70">
Welcome to Goose 1.0 <b>beta</b>! 🎉
</h2>
<div className="whitespace-pre-wrap text-gray-600 dark:text-white/50">
Goose is your AI-powered agent.
<br />
<br />
<b>
{' '}
Warning: During the beta, your chats are not saved - closing the window <br />
or closing the app will lose your history. <br />
</b>
<br />
<br />
Try +N for a new window, or +O to work on a specific directory.
</div>
<button
onClick={onDismiss}
className="mt-6 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
>
Get Started
</button>
</div>
</Card>
);
}
@@ -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 (
<div className="h-screen w-full select-none bg-white dark:bg-black">
{/* Draggable title bar region */}
-35
View File
@@ -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<WingToWingProps> = ({ onExpand, progressMessage, working }) => {
return (
<div
onClick={onExpand}
className="flex items-center w-full h-28 bg-gradient-to-r from-gray-100 via-gray-200 to-gray-300 shadow-md rounded-lg p-4 cursor-pointer hover:shadow-lg transition-all duration-200"
>
{working === Working.Working && (
<div className="w-10 h-10 mr-4 flex-shrink-0">
<Bird />
</div>
)}
{/* Status Text */}
<div className="flex flex-col text-left">
<span className="text-sm text-gray-600 font-medium">{progressMessage}</span>
</div>
</div>
);
};
export default WingToWing;
@@ -1,8 +0,0 @@
import React from 'react';
export const ChatLayout = ({ children, mode }) => (
<div className="relative w-screen h-screen overflow-hidden bg-bgApp flex flex-col">
<div className="titlebar-drag-region" />
<div style={{ display: mode === 'expanded' ? 'block' : 'none' }}>{children}</div>
</div>
);
@@ -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,
}: {
@@ -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 (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
@@ -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 (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>