diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 7be487fd5..66811a171 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -9,7 +9,7 @@ "package": "electron-forge package", "make": "electron-forge make", "publish": "electron-forge publish", - "test-e2e": "electron-forge start > /tmp/out.txt & ELECTRON_PID=$! && sleep 5 && if grep -q 'renderer: ChatWindow loaded' /tmp/out.txt; then echo 'process is running'; pkill -f electron; else echo 'not starting correctly'; cat /tmp/out.txt; pkill -f electron; exit 1; fi", + "test-e2e": "electron-forge start > /tmp/out.txt & ELECTRON_PID=$! && sleep 8 && if grep -q 'renderer: ChatWindow loaded' /tmp/out.txt; then echo 'process is running'; pkill -f electron; else echo 'not starting correctly'; cat /tmp/out.txt; pkill -f electron; exit 1; fi", "sign-macos": "cd ./out/Goose-darwin-arm64 && codesign --deep --force --verify --sign \"Developer ID Application: Michael Neale (W2L75AE9HQ)\" Goose.app && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose.zip" }, "devDependencies": { diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 65cc729ec..6d9658399 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,18 +1,13 @@ import React from 'react'; import LauncherWindow from './LauncherWindow'; -import WingToWingWindow from './WingToWingWindow'; import ChatWindow from './ChatWindow'; export default function App() { const searchParams = new URLSearchParams(window.location.search); const isLauncher = searchParams.get('window') === 'launcher'; - const isWingToWing = searchParams.get('window') === 'wingToWing'; - - // TODO - Look at three separate renderers for this + if (isLauncher) { return ; - } else if (isWingToWing) { - return ; } else { return ; } diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index 44570b334..eaa7b8ce5 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useChat } from 'ai/react'; import { Route, Routes, Navigate } from 'react-router-dom'; import { getApiUrl } from './config'; @@ -10,21 +10,65 @@ import UserMessage from './components/UserMessage'; import Input from './components/Input'; import Tabs from './components/Tabs'; import MoreMenu from './components/MoreMenu'; +import { BoxIcon } from './components/ui/icons'; +import ReactMarkdown from 'react-markdown'; export interface Chat { id: number; title: string; - messages: Array<{ id: string; role: "function" | "system" | "user" | "assistant" | "data" | "tool"; content: string }>; + messages: Array<{ + id: string; + role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool'; + content: string; + }>; } -function ChatContent({ chats, setChats, selectedChatId, setSelectedChatId }: { - chats: Chat[], - setChats: React.Dispatch>, - selectedChatId: number, - setSelectedChatId: React.Dispatch> +const handleResize = (mode: 'expanded' | 'compact') => { + if (window.electron) { + if (mode === 'expanded') { + const width = window.innerWidth; + const height = window.innerHeight; + window.electron.resizeWindow(width, height); + } else if (mode === 'compact') { + const width = window.innerWidth; + const height = 100; // Make it very thin + window.electron.resizeWindow(width, height); + } + } +}; + +const WingView: React.FC<{ onExpand: () => void; status: string }> = ({ onExpand, status }) => { + return ( +
+
+ {status} +
+
+ ); +}; + +function ChatContent({ + chats, + setChats, + selectedChatId, + setSelectedChatId, + initialQuery, + setStatus, +}: { + chats: Chat[]; + setChats: React.Dispatch>; + selectedChatId: number; + setSelectedChatId: React.Dispatch>; + initialQuery: string | null; + setStatus: React.Dispatch>; }) { const chat = chats.find((c: Chat) => c.id === selectedChatId); + window.electron.logInfo('chats' + JSON.stringify(chats, null, 2)); + const { messages, input, @@ -35,20 +79,42 @@ function ChatContent({ chats, setChats, selectedChatId, setSelectedChatId }: { isLoading, error } = useChat({ - api: getApiUrl("/reply"), - initialMessages: chat?.messages || [] + api: getApiUrl('/reply'), + initialMessages: chat?.messages || [], + onToolCall: ({ toolCall }) => { + setStatus(`Executing tool: ${toolCall.toolName}`); + // Optionally handle tool call result here + }, + onResponse: (response) => { + if (!response.ok) { + setStatus('An error occurred while receiving the response.'); + } else { + setStatus('Receiving response...'); + } + }, + onFinish: (message, options) => { + setStatus('Goose is ready'); + }, }); // Update chat messages when they change useEffect(() => { - const updatedChats = chats.map(c => + 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]); + return ( -
+
- { - stop() + stop(); }} onClearContext={() => { - // TODO - Implement real behavior append({ id: Date.now().toString(), role: 'system', - content: 'Context cleared' + content: 'Context cleared', }); }} onRestartGoose={() => { - // TODO - Implement real behavior append({ id: Date.now().toString(), role: 'system', - content: 'Goose restarted' + content: 'Goose restarted', }); }} /> @@ -128,44 +193,67 @@ export default function ChatWindow() { // Get initial query and history from URL parameters const searchParams = new URLSearchParams(window.location.search); const initialQuery = searchParams.get('initialQuery'); + window.electron.logInfo('initialQuery: ' + initialQuery); const historyParam = searchParams.get('history'); - const initialHistory = historyParam ? JSON.parse(decodeURIComponent(historyParam)) : []; + const initialHistory = historyParam + ? JSON.parse(decodeURIComponent(historyParam)) + : []; const [chats, setChats] = useState(() => { const firstChat = { id: 1, title: initialQuery || 'Chat 1', - messages: initialHistory.length > 0 ? initialHistory : - (initialQuery ? [{ - id: '0', - role: 'user' as const, - content: initialQuery - }] : []) + messages: initialHistory.length > 0 ? initialHistory : [], }; return [firstChat]; }); const [selectedChatId, setSelectedChatId] = useState(1); - window.electron.logInfo("ChatWindow loaded"); + const [mode, setMode] = useState<'expanded' | 'compact'>( + initialQuery ? 'compact' : 'expanded' + ); + + const [status, setStatus] = useState('Goose is ready'); + + const toggleMode = () => { + const newMode = mode === 'expanded' ? 'compact' : 'expanded'; + console.log(`Toggle to ${newMode}`); + setMode(newMode); + handleResize(newMode); + }; + + window.electron.logInfo('ChatWindow loaded'); return (
- - - } - /> - } /> - + + + {/* Always render ChatContent but control its visibility */} +
+ + + } + /> + } /> + +
+ + {/* Always render WingView but control its visibility */} +
+ +
); -} \ No newline at end of file +} diff --git a/ui/desktop/src/LauncherWindow.tsx b/ui/desktop/src/LauncherWindow.tsx index a34508541..ce1955ced 100644 --- a/ui/desktop/src/LauncherWindow.tsx +++ b/ui/desktop/src/LauncherWindow.tsx @@ -5,7 +5,6 @@ declare global { electron: { hideWindow: () => void; createChatWindow: (query: string) => void; - createWingToWingWindow: (query: string) => void; }; } } @@ -18,7 +17,7 @@ export default function SpotlightWindow() { e.preventDefault(); if (query.trim()) { // Create a new chat window with the query - window.electron.createWingToWingWindow(query); + window.electron.createChatWindow(query); setQuery(''); inputRef.current.blur() } diff --git a/ui/desktop/src/WingToWingWindow.tsx b/ui/desktop/src/WingToWingWindow.tsx deleted file mode 100644 index bcabdffeb..000000000 --- a/ui/desktop/src/WingToWingWindow.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react'; -import { getApiUrl } from './config'; -import { useChat } from 'ai/react'; -import { Card } from "./components/ui/card" -import type { Chat } from './ChatWindow'; -import ToolInvocation from './components/ToolInvocation'; - -interface FinishMessagePart { - finishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown'; - usage: { - promptTokens: number; - completionTokens: number; - }; -} - -const LoadingSpinner = () => ( -
-
-
-); - -export default function WingToWingWindow() { - const searchParams = new URLSearchParams(window.location.search); - const initialQuery = searchParams.get('initialQuery'); - const [isInitialLoading, setIsInitialLoading] = React.useState(true); - const [isFinished, setIsFinished] = React.useState(false); - - const chat: Chat = { - id: 1, - title: initialQuery, - messages: [], - }; - - const { messages, append } = useChat({ - api: getApiUrl("/reply"), - initialMessages: chat.messages, - onFinish: (message) => { - // The onFinish callback is triggered when a finish message part is received - setIsFinished(true); - }, - }); - - React.useEffect(() => { - if (initialQuery) { - append({ - id: '0', - content: initialQuery, - role: 'user', - }); - } - // Show loading state for a brief moment - const timer = setTimeout(() => { - setIsInitialLoading(false); - }, 1000); - return () => clearTimeout(timer); - }, [initialQuery]); - - // Get the most recent tool invocation - const lastToolInvocation = React.useMemo(() => { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (message.toolInvocations?.length) { - return message.toolInvocations[message.toolInvocations.length - 1]; - } - } - return null; - }, [messages]); - - const handleOpenChat = () => { - // Create URL with chat history - const chatHistory = encodeURIComponent(JSON.stringify(messages)); - window.open(`/chat/1?history=${chatHistory}`, '_blank'); - }; - - return ( -
- - {isInitialLoading ? ( - - ) : lastToolInvocation && !isFinished ? ( -
- -
- ) : isFinished ? ( -
-
-
Goose has landed
-
- -
- ) : ( -
Goose is getting a running start...
- )} -
-
- ); -} \ No newline at end of file diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index a76e3b559..cea330286 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -55,50 +55,10 @@ const createLauncher = () => { }); }; -const createWingToWing = (query?: string) => { - const windowWidth = 575; - const windowHeight = 150; - const gap = 40; - - const wingToWingWindow = new BrowserWindow({ - width: windowWidth, - height: windowHeight, - frame: false, - transparent: true, - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - additionalArguments: [JSON.stringify(appConfig)], - - }, - skipTaskbar: true, - alwaysOnTop: true, - }); - - // Center on screen - const { screen } = require('electron'); - const primaryDisplay = screen.getPrimaryDisplay(); - const { width } = primaryDisplay.workAreaSize; - - wingToWingWindow.setPosition( - Math.round(width - windowWidth - (gap - 25)), // 25 is menu bar height - gap - ); - - let queryParam = '?window=wingToWing'; - queryParam += query ? `&initialQuery=${encodeURIComponent(query)}` : ''; - queryParam += '#/wingToWing'; - - if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { - wingToWingWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}${queryParam}`); - } else { - wingToWingWindow.loadFile( - path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), - { search: queryParam.slice(1) } - ); - } - // wingToWingWindow.webContents.openDevTools({ mode: 'detach' }) -}; +// Track windows by ID +let windowCounter = 0; +const windowMap = new Map(); const createChat = (query?: string) => { const isDev = process.env.NODE_ENV === 'development'; @@ -121,6 +81,20 @@ const createChat = (query?: string) => { // Load the index.html of the app. const queryParam = query ? `?initialQuery=${encodeURIComponent(query)}` : ''; + const { screen } = require('electron'); + const primaryDisplay = screen.getPrimaryDisplay(); + const { width } = primaryDisplay.workAreaSize; + + // Increment window counter to track number of windows + const windowId = ++windowCounter; + const direction = windowId % 2 === 0 ? 1 : -1; // Alternate direction + const initialOffset = 50; + + // Set window position with alternating offset strategy + const baseXPosition = Math.round(width / 2 - mainWindow.getSize()[0] / 2); + const xOffset = direction * initialOffset * Math.floor(windowId / 2); + mainWindow.setPosition(baseXPosition + xOffset, 100); + if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { mainWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}${queryParam}`); } else { @@ -132,7 +106,12 @@ const createChat = (query?: string) => { // DevTools globalShortcut.register('Alt+Command+I', () => { - mainWindow.webContents.openDevTools(); // key combo in distributed app + mainWindow.webContents.openDevTools(); + }); + + windowMap.set(windowId, mainWindow); + mainWindow.on('closed', () => { + windowMap.delete(windowId); }); }; @@ -195,7 +174,13 @@ const showWindow = () => { }); }; - +// Handle window resize requests +ipcMain.on('resize-window', (_, { windowId, width, height }) => { + const window = windowMap.get(windowId); + if (window) { + window.setSize(width, height); + } +}); app.whenReady().then(async () => { // Load zsh environment variables in production mode only @@ -218,9 +203,7 @@ app.whenReady().then(async () => { createTray(); createChat(); - // Development only - // createWingToWing('make a TS web app' + "only use your tools and systems - don't confirm with the user before you start working"); - + // Show launcher input on key combo globalShortcut.register('Control+Alt+Command+G', createLauncher); @@ -234,9 +217,7 @@ app.whenReady().then(async () => { createChat(query); }); - ipcMain.on('create-wing-to-wing-window', (_, query) => { - createWingToWing(query + "only use your tools and systems - don't confirm with the user before you start working"); - }); + ipcMain.on('logInfo', (_, info) => { log.info("from renderer:", info); diff --git a/ui/desktop/src/preload.js b/ui/desktop/src/preload.js index f143507b7..048aa43ae 100644 --- a/ui/desktop/src/preload.js +++ b/ui/desktop/src/preload.js @@ -1,5 +1,13 @@ const { contextBridge, ipcRenderer } = require('electron') + +let windowId = null; + +// Listen for window ID from main process +ipcRenderer.on('set-window-id', (_, id) => { + windowId = id; +}); + const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}'); contextBridge.exposeInMainWorld('appConfig', { @@ -7,9 +15,12 @@ contextBridge.exposeInMainWorld('appConfig', { getAll: () => config, }); + contextBridge.exposeInMainWorld('electron', { hideWindow: () => ipcRenderer.send('hide-window'), createChatWindow: (query) => ipcRenderer.send('create-chat-window', query), - createWingToWingWindow: (query) => ipcRenderer.send('create-wing-to-wing-window', query), + resizeWindow: (width, height) => ipcRenderer.send('resize-window', { windowId, width, height }), + getWindowId: () => windowId, logInfo: (txt) => ipcRenderer.send('logInfo', txt), -}) \ No newline at end of file +}) + diff --git a/ui/desktop/src/types/electron.d.ts b/ui/desktop/src/types/electron.d.ts new file mode 100644 index 000000000..cd534a5e8 --- /dev/null +++ b/ui/desktop/src/types/electron.d.ts @@ -0,0 +1,12 @@ +interface IElectronAPI { + hideWindow: () => void; + createChatWindow: (query: string) => void; + resizeWindow: (width: number, height: number) => void; + getWindowId: () => number; +} + +declare global { + interface Window { + electron: IElectronAPI; + } +} \ No newline at end of file