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 && (
-
- {/* Scroll left button */}
-
- )}
-
- {/* 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 && (
- {
- e.stopPropagation();
- removeChat(chat.id);
- }}
- className="align-middle ml-[20px] w-[16px] h-[16px]"
- style={{ WebkitAppRegion: 'no-drag' }}
- >
-
-
- )}
-
-
-
- ))}
-
- {/* New tab button - fixed width, positioned after last tab */}
-
-
-
-
-
- {/* Right scroll button - only visible when tabs overflow */}
- {needsScroll && (
-
- {/* Scroll right button */}
-
- )}
-
- );
-}
\ 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