diff --git a/ui/desktop/bundle.py b/ui/desktop/bundle.py new file mode 100755 index 000000000..6fe4a65f2 --- /dev/null +++ b/ui/desktop/bundle.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import re +from pathlib import Path +from typing import Dict, Union + +def replace_env_macro(provider_type: str, host: str, model: str) -> bool: + """ + Replace content between environment macro markers with formatted environment variables. + + Args: + provider_type (str): The type of provider (e.g., 'databricks') + host (str): The host URL + model (str): The model name + + Returns: + bool: True if successful, False otherwise + """ + file_path = './src/main.ts' + + try: + # Read the file content + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Format the environment variables + formatted_vars = [ + f" process.env.GOOSE_PROVIDER__TYPE = '{provider_type}';", + f" process.env.GOOSE_PROVIDER__HOST = '{host}';", + f" process.env.GOOSE_PROVIDER__MODEL = '{model}';" + ] + + replacement_content = "\n".join(formatted_vars) + replacement_content += "\n return true;" + + # Define the pattern to match content between markers + pattern = r'//{env-macro-start}//.*?//{env-macro-end}//' + flags = re.DOTALL # Allow matching across multiple lines + + # Create the replacement string with the markers and new content + replacement = f"//{{env-macro-start}}//\n{replacement_content}\n//{{env-macro-end}}//" + + # Perform the replacement + new_content, count = re.subn(pattern, replacement, content, flags=flags) + + if count == 0: + print(f"Error: Could not find macro markers in {file_path}") + return False + + # Write the modified content back to the file + with open(file_path, 'w', encoding='utf-8') as f: + f.write(new_content) + + print(f"Successfully updated {file_path}") + return True + + except Exception as e: + print(f"Error processing file {file_path}: {str(e)}") + return False + +# Example usage +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser(description='Update environment variables in main.ts') + parser.add_argument('--type', required=True, help='Provider type (e.g., databricks)') + parser.add_argument('--host', required=True, help='Host URL') + parser.add_argument('--model', required=True, help='Model name') + + args = parser.parse_args() + + success = replace_env_macro( + provider_type=args.type, + host=args.host, + model=args.model + ) + + if not success: + print("Failed to update environment variables") + exit(1) \ No newline at end of file diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index fc7820afa..76f34b80e 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -8,7 +8,6 @@ import Splash from './components/Splash'; import GooseMessage from './components/GooseMessage'; import UserMessage from './components/UserMessage'; import Input from './components/Input'; -import Tabs from './components/Tabs'; import MoreMenu from './components/MoreMenu'; import { Bird } from './components/ui/icons'; import LoadingGoose from './components/LoadingGoose'; @@ -140,17 +139,9 @@ function ChatContent({ return (
-
- -
{ stop(); }} @@ -225,6 +216,25 @@ function ChatContent({ } export default function ChatWindow() { + // Add keyboard shortcut handler + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Check for Command+N (Mac) or Control+N (Windows/Linux) + if ((event.metaKey || event.ctrlKey) && event.key === 'n') { + event.preventDefault(); // Prevent default browser behavior + window.electron.createChatWindow(); + } + }; + + // Add event listener + window.addEventListener('keydown', handleKeyDown); + + // Cleanup + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + // Check if API key is missing from the window arguments const apiCredsMissing = window.electron.getConfig().apiCredsMissing; @@ -262,6 +272,7 @@ export default function ChatWindow() { return (
+
{apiCredsMissing ? (
diff --git a/ui/desktop/src/components/Tabs.tsx b/ui/desktop/src/components/Tabs.tsx deleted file mode 100644 index bee1ded78..000000000 --- a/ui/desktop/src/components/Tabs.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { useNavigate } from 'react-router-dom' - -import Plus from './ui/Plus'; -import X from './ui/X'; - -// Extending React CSSProperties to include custom webkit property -declare module 'react' { - interface CSSProperties { - WebkitAppRegion?: string; // Now TypeScript knows about WebkitAppRegion - } -} - -// Core layout constants -const TAB_MIN_WIDTH = 80; // Tabs won't shrink smaller than this -const TAB_MAX_WIDTH = 140; // Default/maximum tab width -const CONTAINER_PADDING = 50; // Left margin for entire tab container - -// Calculate how wide each tab should be based on available space -function calculateTabWidths(containerWidth: number, tabCount: number) { - // Remove left margin from available space - const availableWidth = containerWidth - CONTAINER_PADDING; - - // Case 1: Plenty of space - use maximum tab width - if (tabCount * TAB_MAX_WIDTH <= availableWidth) { - return { - tabWidth: TAB_MAX_WIDTH, - needsScroll: false - }; - } - - // Case 2: Calculate if tabs need to shrink - const idealWidth = availableWidth / tabCount; - - // Case 3: Not enough space even at minimum width - if (idealWidth < TAB_MIN_WIDTH) { - return { - tabWidth: TAB_MIN_WIDTH, - needsScroll: true // Enable horizontal scrolling - }; - } - - // Case 4: Shrink tabs to fit exactly - return { - tabWidth: Math.floor(idealWidth), - needsScroll: false - }; -} - -export default function Tabs({ chats, selectedChatId, setSelectedChatId, setChats }) { - // Track container width for responsive tab sizing - const containerRef = useRef(null); - const [tabWidth, setTabWidth] = useState(TAB_MAX_WIDTH); - const [needsScroll, setNeedsScroll] = useState(false); - - // Recalculate tab widths when window resizes or chat count changes - useEffect(() => { - const updateTabWidths = () => { - if (containerRef.current) { - const { tabWidth: newWidth, needsScroll: newNeedsScroll } = calculateTabWidths( - containerRef.current.offsetWidth, - chats.length - ); - setTabWidth(newWidth); - setNeedsScroll(newNeedsScroll); - } - }; - - updateTabWidths(); - window.addEventListener('resize', updateTabWidths); - return () => window.removeEventListener('resize', updateTabWidths); - }, [chats.length]); - - // Add this effect after the existing resize effect - useEffect(() => { - // Scroll to end whenever chats list changes - if (containerRef.current) { - containerRef.current.scrollLeft = containerRef.current.scrollWidth; - } - }, [chats]); // Trigger when chats array changes - - // Add this effect alongside the other useEffects - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const handleWheel = (e: WheelEvent) => { - // Prevent the default vertical scroll - e.preventDefault(); - - // Convert vertical scroll to horizontal - // You can adjust the multiplier (30) to change scroll speed - container.scrollLeft += e.deltaY * 0.5; - }; - - // Add event listener - container.addEventListener('wheel', handleWheel, { passive: false }); - - // Cleanup - return () => { - container.removeEventListener('wheel', handleWheel); - }; - }, []); // Empty dependency array since we only need to set this up once - - // SVG path generator for tab shape - const generatePath = (width: number) => { - const curve = Math.min(25, width * 0.2); // Scale curve with width - const innerWidth = width - (curve * 2); - - return ` - M${curve} 11 - C${curve} 4.92487 ${curve + 4.9249} 0 ${curve + 11} 0 - H${curve + innerWidth - 11} - C${curve + innerWidth - 4.9249} 0 ${curve + innerWidth} 4.92487 ${curve + innerWidth} 11 - V13 - C${curve + innerWidth} 19.0751 ${curve + innerWidth + 4.925} 24 ${curve + innerWidth + 11} 24 - H${width + 11.5}H0H${curve - 11} - C${curve - 4.9249} 24 ${curve} 19.0751 ${curve} 13 - V11Z - `; - }; - - // Navigation functions - const navigate = useNavigate() - const navigateChat = (chatId: number) => { - setSelectedChatId(chatId) - navigate(`/chat/${chatId}`) - } - - // Tab management functions - const addChat = () => { - const newChatId = chats[chats.length-1].id + 1; - const newChat = { - id: newChatId, - title: `Chat ${newChatId}`, - messages: [], - }; - setChats([...chats, newChat]); - navigateChat(newChatId); - }; - - const removeChat = (chatId: number) => { - const updatedChats = chats.filter((chat: any) => chat.id !== chatId); - setChats(updatedChats); - navigateChat(updatedChats[0].id); - }; - - return ( - // Outer container - full width with relative positioning for scroll buttons -
- {/* Left scroll button - only visible when tabs overflow */} - {needsScroll && ( - - )} - - {/* Main tabs container - includes 50px left margin */} -
- {/* Individual tab rendering */} - {chats.map((chat) => ( -
navigateChat(chat.id)} - onKeyDown={(e) => e.key === "Enter" && navigateChat(chat.id)} - tabIndex={0} - role="tab" - aria-selected={selectedChatId === chat.id} - > - {/* SVG Background - creates the tab shape */} - - - - - {/* Tab content container - holds title and X button */} - {/* Adjusted padding on the left side and reduced margin for the close button */} -
- - {chat.title.length > 8 ? `${chat.title.slice(0, 7)}...` : chat.title} - {chats.length > 1 && ( - - )} - -
-
- ))} - - {/* New tab button - fixed width, positioned after last tab */} - -
- - {/* Right scroll button - only visible when tabs overflow */} - {needsScroll && ( - - )} -
- ); -} \ No newline at end of file diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index a78a65696..25d7e5b8a 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -1,76 +1,100 @@ import path from 'node:path'; -import { spawn } from 'child_process'; +import { execSync, spawn } from 'child_process'; import { existsSync } from 'fs'; import log from './utils/logger'; import os from 'node:os'; +import { createServer } from 'net'; +import { loadZshEnv } from './utils/loadEnv'; -// Start the goosed binary -export const start = (app) => { - // In development, the binary is in src/bin - // In production, it will be in the resources/bin directory + +// Find an available port to start goosed on +export const findAvailablePort = (): Promise => { + return new Promise((resolve, reject) => { + const server = createServer(); + + + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as { port: number }; + server.close(() => { + log.info(`Found available port: ${port}`); + resolve(port); + }); + }); + }); +}; + +// Goose process manager. Take in the app, port, and directory to start goosed in. +export const startGoosed = async (app, dir=null): Promise => { + // In will use this later to determine if we should start process const isDev = process.env.NODE_ENV === 'development'; + let goosedPath: string; - if (isDev) { + if (isDev && !app.isPackaged) { + if (process.env.VITE_START_EMBEDDED_SERVER === 'no') { + log.info('Skipping starting goosed in development mode'); + return 3000; + } // In development, use the absolute path from the project root goosedPath = path.join(process.cwd(), 'src', 'bin', process.platform === 'win32' ? 'goosed.exe' : 'goosed'); } else { // In production, use the path relative to the app resources goosedPath = path.join(process.resourcesPath, 'bin', process.platform === 'win32' ? 'goosed.exe' : 'goosed'); } + const port = await findAvailablePort(); - log.info(`Starting goosed from: ${goosedPath}`); + // in case we want it + //const isPackaged = app.isPackaged; - // Check if the binary exists - if (!existsSync(goosedPath)) { - log.error(`goosed binary not found at path: ${goosedPath}`); - return; - } - + // we default to running goosed in home dir - if not specified const homeDir = os.homedir(); - - // Optional: Ensure the home directory exists (it should, but for safety) - if (!existsSync(homeDir)) { - log.error(`Home directory does not exist: ${homeDir}`); - return; + if (!dir) { + dir = homeDir; } - console.log("Starting goosed in: ", homeDir); - + log.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${dir}` ); + // Define additional environment variables const additionalEnv = { // Set HOME for UNIX-like systems HOME: homeDir, // Set USERPROFILE for Windows USERPROFILE: homeDir, + + // start with the port specified + GOOSE_SERVER__PORT: port, }; // Merge parent environment with additional environment variables const env = { ...process.env, ...additionalEnv }; // Spawn the goosed process with the user's home directory as cwd - const goosedProcess = spawn(goosedPath, [], { cwd: homeDir, env: env }); - + const goosedProcess = spawn(goosedPath, [], { cwd: dir, env: env }); goosedProcess.stdout.on('data', (data) => { - log.info(`goosed stdout: ${data.toString()}`); + log.info(`goosed stdout for port ${port} and dir ${dir}: ${data.toString()}`); }); goosedProcess.stderr.on('data', (data) => { - log.error(`goosed stderr: ${data.toString()}`); + log.error(`goosed stderr for port ${port} and dir ${dir}: ${data.toString()}`); }); goosedProcess.on('close', (code) => { - log.info(`goosed process exited with code ${code}`); + log.info(`goosed process exited with code ${code} for port ${port} and dir ${dir}`); }); goosedProcess.on('error', (err) => { - log.error('Failed to start goosed:', err); + log.error(`Failed to start goosed on port ${port} and dir ${dir}`, err); }); // Ensure goosed is terminated when the app quits + // TODO will need to do it at tab level next app.on('will-quit', () => { log.info('App quitting, terminating goosed server'); goosedProcess.kill(); }); -}; \ No newline at end of file + + return port; +}; + + diff --git a/ui/desktop/src/index.css b/ui/desktop/src/index.css index 769f60a93..3e291221a 100644 --- a/ui/desktop/src/index.css +++ b/ui/desktop/src/index.css @@ -18,6 +18,19 @@ } @layer components { + .titlebar-drag-region { + -webkit-app-region: drag; + height: 32px; + width: 100%; + position: fixed; + top: 0; + left: 0; + z-index: 50; + } + + .no-drag { + -webkit-app-region: no-drag; + } .ask-goose-type { color: #000; font-family: "Square Sans Mono"; diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 6560ca332..eea604b06 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -2,10 +2,9 @@ import 'dotenv/config'; import { loadZshEnv } from './utils/loadEnv'; import { app, BrowserWindow, Tray, Menu, globalShortcut, ipcMain, Notification } from 'electron'; import path from 'node:path'; -import { start as startGoosed } from './goosed'; +import { findAvailablePort, startGoosed } from './goosed'; import started from "electron-squirrel-startup"; import log from './utils/logger'; -import { getPort } from './utils/portUtils'; import { exec } from 'child_process'; // Handle creating/removing shortcuts on Windows when installing/uninstalling. @@ -15,6 +14,10 @@ declare var MAIN_WINDOW_VITE_DEV_SERVER_URL: string; declare var MAIN_WINDOW_VITE_NAME: string; const checkApiCredentials = () => { + + loadZshEnv(app.isPackaged); + + //{env-macro-start}// const isDatabricksConfigValid = process.env.GOOSE_PROVIDER__TYPE === 'databricks' && process.env.GOOSE_PROVIDER__HOST && @@ -27,12 +30,13 @@ const checkApiCredentials = () => { process.env.GOOSE_PROVIDER__API_KEY; return isDatabricksConfigValid || isOpenAIDirectConfigValid + //{env-macro-end}// }; let appConfig = { - GOOSE_SERVER__PORT: 3000, + apiCredsMissing: !checkApiCredentials(), GOOSE_API_HOST: 'http://127.0.0.1', - apiCredsMissing: !checkApiCredentials() + GOOSE_SERVER__PORT: 0, }; const createLauncher = () => { @@ -81,20 +85,22 @@ const createLauncher = () => { let windowCounter = 0; const windowMap = new Map(); -const createChat = (query?: string) => { +const createChat = async (app, query?: string) => { + + const port = await startGoosed(app); const mainWindow = new BrowserWindow({ titleBarStyle: 'hidden', trafficLightPosition: { x: 16, y: 18 }, width: 650, height: 800, - minWidth: 530, + minWidth: 650, minHeight: 800, transparent: true, useContentSize: true, icon: path.join(__dirname, '../images/icon'), webPreferences: { preload: path.join(__dirname, 'preload.js'), - additionalArguments: [JSON.stringify(appConfig)], + additionalArguments: [JSON.stringify({ ...appConfig, GOOSE_SERVER__PORT: port })], }, }); @@ -195,36 +201,21 @@ const showWindow = () => { app.whenReady().then(async () => { // Load zsh environment variables in production mode only - const isProduction = app.isPackaged; - loadZshEnv(isProduction); - - // Get the server startup configuration - const shouldStartServer = (process.env.VITE_START_EMBEDDED_SERVER || 'yes').toLowerCase() === 'yes'; - if (shouldStartServer) { - log.info('Starting embedded goosed server'); - const port = await getPort(); - process.env.GOOSE_SERVER__PORT = port.toString(); - appConfig = { ...appConfig, GOOSE_SERVER__PORT: process.env.GOOSE_SERVER__PORT }; - startGoosed(app); - } else { - log.info('Skipping embedded server startup (disabled by configuration)'); - } - createTray(); - createChat(); + createChat(app); // Show launcher input on key combo globalShortcut.register('Control+Alt+Command+G', createLauncher); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { - createChat(); + createChat(app); } }); ipcMain.on('create-chat-window', (_, query) => { - createChat(query); + createChat(app, query); }); ipcMain.on('notify', (event, data) => { diff --git a/ui/desktop/src/preload.js b/ui/desktop/src/preload.js index ca85e55f5..8fbec3efd 100644 --- a/ui/desktop/src/preload.js +++ b/ui/desktop/src/preload.js @@ -1,7 +1,5 @@ const { contextBridge, ipcRenderer } = require('electron') -let windowId = null; - const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}'); diff --git a/ui/desktop/src/utils/portUtils.ts b/ui/desktop/src/utils/portUtils.ts deleted file mode 100644 index 1bb8f3048..000000000 --- a/ui/desktop/src/utils/portUtils.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createServer } from 'net'; -import log from './logger'; - -export const findAvailablePort = (): Promise => { - return new Promise((resolve, reject) => { - const server = createServer(); - - - server.listen(0, '127.0.0.1', () => { - const { port } = server.address() as { port: number }; - server.close(() => { - log.info(`Found available port: ${port}`); - resolve(port); - }); - }); - }); -}; - -// Cache the port once found to ensure consistency -let cachedPort: number | null = null; - -export const getPort = async (): Promise => { - if (cachedPort !== null) { - return cachedPort; - } - - try { - cachedPort = await findAvailablePort(); - return cachedPort; - } catch (error) { - log.error('Error finding available port:', error); - throw error; - } -}; \ No newline at end of file