From 01652d0d11b80a34d5c64ecdba0eeffb20a16709 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 17 Jan 2026 18:43:10 -0500 Subject: [PATCH 01/12] feat: add hostname configuration for web server Introduce APP_HOST variable to allow custom hostname configuration for the web server. Default to localhost if VITE_HOSTNAME is not set. Update relevant URLs and CORS origins to use APP_HOST, enhancing flexibility for local development and deployment. This change improves the application's adaptability to different environments. --- start-automaker.sh | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/start-automaker.sh b/start-automaker.sh index a2d3e54c..94f143ab 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -37,6 +37,11 @@ DEFAULT_SERVER_PORT=3008 WEB_PORT=$DEFAULT_WEB_PORT SERVER_PORT=$DEFAULT_SERVER_PORT +# Hostname configuration +# Use VITE_HOSTNAME if explicitly set, otherwise default to localhost +# Note: Don't use $HOSTNAME as it's a bash built-in containing the machine's hostname +APP_HOST="${VITE_HOSTNAME:-localhost}" + # Extract VERSION from apps/ui/package.json (the actual app version, not monorepo version) if command -v node &> /dev/null; then VERSION="v$(node -p "require('$SCRIPT_DIR/apps/ui/package.json').version" 2>/dev/null || echo "0.11.0")" @@ -850,7 +855,7 @@ launch_sequence() { case "$MODE" in web) - local url="http://localhost:$WEB_PORT" + local url="http://${APP_HOST}:$WEB_PORT" local upad=$(( (TERM_COLS - ${#url} - 10) / 2 )) echo "" printf "%${upad}s${DIM}Opening ${C_SEC}%s${RESET}\n" "" "$url" @@ -1073,9 +1078,14 @@ fi case $MODE in web) export TEST_PORT="$WEB_PORT" - export VITE_SERVER_URL="http://$HOSTNAME:$SERVER_PORT" + export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT" export PORT="$SERVER_PORT" - export CORS_ORIGIN="http://$HOSTNAME:$WEB_PORT,http://127.0.0.1:$WEB_PORT" + # Always include localhost and 127.0.0.1 for local dev, plus custom hostname if different + CORS_ORIGINS="http://localhost:$WEB_PORT,http://127.0.0.1:$WEB_PORT" + if [[ "$APP_HOST" != "localhost" && "$APP_HOST" != "127.0.0.1" ]]; then + CORS_ORIGINS="${CORS_ORIGINS},http://${APP_HOST}:$WEB_PORT" + fi + export CORS_ORIGIN="$CORS_ORIGINS" export VITE_APP_MODE="1" if [ "$PRODUCTION_MODE" = true ]; then @@ -1091,7 +1101,7 @@ case $MODE in max_retries=30 server_ready=false for ((i=0; i /dev/null 2>&1; then + if curl -s "http://localhost:$SERVER_PORT/api/health" > /dev/null 2>&1; then server_ready=true break fi @@ -1147,7 +1157,7 @@ case $MODE in center_print "✓ Server is ready!" "$C_GREEN" echo "" - center_print "The application will be available at: http://localhost:$WEB_PORT" "$C_GREEN" + center_print "The application will be available at: http://${APP_HOST}:$WEB_PORT" "$C_GREEN" echo "" # Start web app with Vite dev server (HMR enabled) From d22deabe797481e9918b413f9c1ea51fa8a8226f Mon Sep 17 00:00:00 2001 From: Kacper Date: Fri, 16 Jan 2026 20:27:53 +0100 Subject: [PATCH 02/12] fix: improve process termination handling for Windows Updated the process termination logic in ClaudeUsageService to handle Windows environments correctly. The code now checks the operating system and calls the appropriate kill method, ensuring consistent behavior across platforms. --- apps/server/src/services/claude-usage-service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 64dceb6a..59f22f20 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -277,9 +277,14 @@ export class ClaudeUsageService { ptyProcess.write('\x1b'); // Send escape key // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s + // Windows doesn't support signals, so just call kill() without args setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { - ptyProcess.kill('SIGTERM'); + if (this.isWindows) { + ptyProcess.kill(); + } else { + ptyProcess.kill('SIGTERM'); + } } }, 2000); } From f0e655f49ae5e90c6444a09fbbbbddc75b79e534 Mon Sep 17 00:00:00 2001 From: Kacper Date: Fri, 16 Jan 2026 20:34:12 +0100 Subject: [PATCH 03/12] fix: unify PTY process termination handling across platforms Refactored the process termination logic in both ClaudeUsageService and TerminalService to use a centralized method for killing PTY processes. This ensures consistent handling of process termination across Windows and Unix-like systems, improving reliability and maintainability of the code. --- .../src/services/claude-usage-service.ts | 28 +++++++++++++------ apps/server/src/services/terminal-service.ts | 25 +++++++++++++++-- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 59f22f20..35c00a20 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -23,6 +23,22 @@ export class ClaudeUsageService { private isWindows = os.platform() === 'win32'; private isLinux = os.platform() === 'linux'; + /** + * Kill a PTY process with platform-specific handling. + * Windows doesn't support Unix signals like SIGTERM, so we call kill() without arguments. + * On Unix-like systems (macOS, Linux), we can specify the signal. + * + * @param ptyProcess - The PTY process to kill + * @param signal - The signal to send on Unix-like systems (default: 'SIGTERM') + */ + private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void { + if (this.isWindows) { + ptyProcess.kill(); + } else { + ptyProcess.kill(signal); + } + } + /** * Check if Claude CLI is available on the system */ @@ -211,7 +227,7 @@ export class ClaudeUsageService { if (!settled) { settled = true; if (ptyProcess && !ptyProcess.killed) { - ptyProcess.kill(); + this.killPtyProcess(ptyProcess); } // Don't fail if we have data - return it instead if (output.includes('Current session')) { @@ -253,7 +269,7 @@ export class ClaudeUsageService { if (!settled) { settled = true; if (ptyProcess && !ptyProcess.killed) { - ptyProcess.kill(); + this.killPtyProcess(ptyProcess); } reject( new Error( @@ -277,14 +293,10 @@ export class ClaudeUsageService { ptyProcess.write('\x1b'); // Send escape key // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s - // Windows doesn't support signals, so just call kill() without args + // Windows doesn't support signals, so killPtyProcess handles platform differences setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { - if (this.isWindows) { - ptyProcess.kill(); - } else { - ptyProcess.kill('SIGTERM'); - } + this.killPtyProcess(ptyProcess); } }, 2000); } diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index c309975c..bd4481a8 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -70,6 +70,23 @@ export class TerminalService extends EventEmitter { private sessions: Map = new Map(); private dataCallbacks: Set = new Set(); private exitCallbacks: Set = new Set(); + private isWindows = os.platform() === 'win32'; + + /** + * Kill a PTY process with platform-specific handling. + * Windows doesn't support Unix signals like SIGTERM/SIGKILL, so we call kill() without arguments. + * On Unix-like systems (macOS, Linux), we can specify the signal. + * + * @param ptyProcess - The PTY process to kill + * @param signal - The signal to send on Unix-like systems (default: 'SIGTERM') + */ + private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void { + if (this.isWindows) { + ptyProcess.kill(); + } else { + ptyProcess.kill(signal); + } + } /** * Detect the best shell for the current platform @@ -477,8 +494,9 @@ export class TerminalService extends EventEmitter { } // First try graceful SIGTERM to allow process cleanup + // On Windows, killPtyProcess calls kill() without signal since Windows doesn't support Unix signals logger.info(`Session ${sessionId} sending SIGTERM`); - session.pty.kill('SIGTERM'); + this.killPtyProcess(session.pty, 'SIGTERM'); // Schedule SIGKILL fallback if process doesn't exit gracefully // The onExit handler will remove session from map when it actually exits @@ -486,7 +504,7 @@ export class TerminalService extends EventEmitter { if (this.sessions.has(sessionId)) { logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`); try { - session.pty.kill('SIGKILL'); + this.killPtyProcess(session.pty, 'SIGKILL'); } catch { // Process may have already exited } @@ -588,7 +606,8 @@ export class TerminalService extends EventEmitter { if (session.flushTimeout) { clearTimeout(session.flushTimeout); } - session.pty.kill(); + // Use platform-specific kill to ensure proper termination on Windows + this.killPtyProcess(session.pty); } catch { // Ignore errors during cleanup } From d96f369b73467d4373b8dfae717d05b0e10a9240 Mon Sep 17 00:00:00 2001 From: Kacper Date: Fri, 16 Jan 2026 20:38:29 +0100 Subject: [PATCH 04/12] test: mock Unix platform for SIGTERM behavior in ClaudeUsageService tests Added a mock for the Unix platform in the SIGTERM test case to ensure proper behavior during testing on non-Windows systems. This change enhances the reliability of the tests by simulating the expected environment for process termination. --- .../server/tests/unit/services/claude-usage-service.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index 4b3f3c94..65699ca6 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -681,7 +681,9 @@ Resets in 2h it('should send SIGTERM after ESC if process does not exit', async () => { vi.useFakeTimers(); - const windowsService = new ClaudeUsageService(); + // Mock Unix platform to test SIGTERM behavior (Windows calls kill() without signal) + vi.mocked(os.platform).mockReturnValue('darwin'); + const ptyService = new ClaudeUsageService(); let dataCallback: Function | undefined; @@ -696,7 +698,7 @@ Resets in 2h }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); - windowsService.fetchUsageData(); + ptyService.fetchUsageData(); // Simulate seeing usage data dataCallback!('Current session\n65% left'); From 0c452a3ebc651690fe391b0ae2aebb345886a9f7 Mon Sep 17 00:00:00 2001 From: Scott Date: Sun, 18 Jan 2026 12:59:46 -0700 Subject: [PATCH 05/12] fix: add cross-platform Node.js launcher for Windows CMD/PowerShell support The `./start-automaker.sh` script doesn't work when invoked from Windows CMD or PowerShell because: 1. The `./` prefix is Unix-style path notation 2. Windows shells don't execute .sh files directly This adds a Node.js launcher (`start-automaker.mjs`) that: - Detects the platform and finds bash (Git Bash, MSYS2, Cygwin, or WSL) - Converts Windows paths to Unix-style for bash compatibility - Passes all arguments through to the original bash script - Provides helpful error messages if bash isn't found The npm scripts now use `node start-automaker.mjs` which works on all platforms while preserving the full functionality of the bash TUI launcher. --- package.json | 4 +- start-automaker.mjs | 127 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 start-automaker.mjs diff --git a/package.json b/package.json index 1c884bc5..0756f868 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "scripts": { "postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs", "fix:lockfile": "node scripts/fix-lockfile-urls.mjs", - "dev": "./start-automaker.sh", - "start": "./start-automaker.sh --production", + "dev": "node start-automaker.mjs", + "start": "node start-automaker.mjs --production", "_dev:web": "npm run dev:web --workspace=apps/ui", "_dev:electron": "npm run dev:electron --workspace=apps/ui", "_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui", diff --git a/start-automaker.mjs b/start-automaker.mjs new file mode 100644 index 00000000..3106f792 --- /dev/null +++ b/start-automaker.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/** + * Cross-platform launcher for Automaker + * Works on Windows (CMD, PowerShell, Git Bash) and Unix (macOS, Linux) + */ + +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { platform } from 'os'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const isWindows = platform() === 'win32'; +const args = process.argv.slice(2); + +/** + * Find bash executable on Windows + */ +function findBashOnWindows() { + const possiblePaths = [ + // Git Bash (most common) + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + // MSYS2 + 'C:\\msys64\\usr\\bin\\bash.exe', + 'C:\\msys32\\usr\\bin\\bash.exe', + // Cygwin + 'C:\\cygwin64\\bin\\bash.exe', + 'C:\\cygwin\\bin\\bash.exe', + // WSL bash (available in PATH on Windows 10+) + 'bash.exe', + ]; + + for (const bashPath of possiblePaths) { + if (bashPath === 'bash.exe') { + // Check if bash is in PATH + try { + const result = spawn.sync?.('where', ['bash.exe'], { stdio: 'pipe' }); + if (result?.status === 0) { + return 'bash.exe'; + } + } catch { + // where command failed, continue checking + } + } else if (existsSync(bashPath)) { + return bashPath; + } + } + + return null; +} + +/** + * Run the bash script + */ +function runBashScript() { + const scriptPath = join(__dirname, 'start-automaker.sh'); + + if (!existsSync(scriptPath)) { + console.error('Error: start-automaker.sh not found'); + process.exit(1); + } + + let bashCmd; + let bashArgs; + + if (isWindows) { + bashCmd = findBashOnWindows(); + + if (!bashCmd) { + console.error('Error: Could not find bash on Windows.'); + console.error('Please install Git for Windows from https://git-scm.com/download/win'); + console.error(''); + console.error('Alternatively, you can run these commands directly:'); + console.error(' npm run dev:web - Web browser mode'); + console.error(' npm run dev:electron - Desktop app mode'); + process.exit(1); + } + + // Convert Windows path to Unix-style for bash + // Handle both C:\path and /c/path styles + let unixPath = scriptPath.replace(/\\/g, '/'); + if (/^[A-Za-z]:/.test(unixPath)) { + // Convert C:/path to /c/path for MSYS/Git Bash + unixPath = '/' + unixPath[0].toLowerCase() + unixPath.slice(2); + } + + bashArgs = [unixPath, ...args]; + } else { + bashCmd = '/bin/bash'; + bashArgs = [scriptPath, ...args]; + } + + const child = spawn(bashCmd, bashArgs, { + stdio: 'inherit', + env: { + ...process.env, + // Ensure proper terminal handling + TERM: process.env.TERM || 'xterm-256color', + }, + // On Windows, we need to use shell for proper signal handling + shell: false, + }); + + child.on('error', (err) => { + if (err.code === 'ENOENT') { + console.error(`Error: Could not find bash at "${bashCmd}"`); + console.error('Please ensure Git Bash or another bash shell is installed.'); + } else { + console.error('Error launching Automaker:', err.message); + } + process.exit(1); + }); + + child.on('exit', (code) => { + process.exit(code ?? 0); + }); + + // Forward signals to child process + process.on('SIGINT', () => child.kill('SIGINT')); + process.on('SIGTERM', () => child.kill('SIGTERM')); +} + +runBashScript(); From 8b5da3195bbaabd41519f061d34d01af5ecf1105 Mon Sep 17 00:00:00 2001 From: Scott Date: Sun, 18 Jan 2026 13:06:13 -0700 Subject: [PATCH 06/12] fix: address PR review feedback - Remove duplicate killPtyProcess method in claude-usage-service.ts - Import and use spawnSync correctly instead of spawn.sync - Fix misleading comment about shell option and signal handling --- apps/server/src/services/claude-usage-service.ts | 16 ---------------- start-automaker.mjs | 10 +++++----- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 9c2f8b78..aebed98b 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -46,22 +46,6 @@ export class ClaudeUsageService { } } - /** - * Kill a PTY process with platform-specific handling. - * Windows doesn't support Unix signals like SIGTERM, so we call kill() without arguments. - * On Unix-like systems (macOS, Linux), we can specify the signal. - * - * @param ptyProcess - The PTY process to kill - * @param signal - The signal to send on Unix-like systems (default: 'SIGTERM') - */ - private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void { - if (this.isWindows) { - ptyProcess.kill(); - } else { - ptyProcess.kill(signal); - } - } - /** * Check if Claude CLI is available on the system */ diff --git a/start-automaker.mjs b/start-automaker.mjs index 3106f792..79ace0dd 100644 --- a/start-automaker.mjs +++ b/start-automaker.mjs @@ -4,7 +4,7 @@ * Works on Windows (CMD, PowerShell, Git Bash) and Unix (macOS, Linux) */ -import { spawn } from 'child_process'; +import { spawn, spawnSync } from 'child_process'; import { existsSync } from 'fs'; import { platform } from 'os'; import { fileURLToPath } from 'url'; @@ -38,12 +38,12 @@ function findBashOnWindows() { if (bashPath === 'bash.exe') { // Check if bash is in PATH try { - const result = spawn.sync?.('where', ['bash.exe'], { stdio: 'pipe' }); + const result = spawnSync('where', ['bash.exe'], { stdio: 'pipe' }); if (result?.status === 0) { return 'bash.exe'; } - } catch { - // where command failed, continue checking + } catch (err) { + // where command failed, continue checking other paths } } else if (existsSync(bashPath)) { return bashPath; @@ -101,7 +101,7 @@ function runBashScript() { // Ensure proper terminal handling TERM: process.env.TERM || 'xterm-256color', }, - // On Windows, we need to use shell for proper signal handling + // shell: false ensures signals are forwarded directly to the child process shell: false, }); From bfc23cdfa168f155a3cf265e67d1d4810b05ef78 Mon Sep 17 00:00:00 2001 From: Scott Date: Sun, 18 Jan 2026 13:12:11 -0700 Subject: [PATCH 07/12] fix: guard signal forwarding against race conditions --- start-automaker.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/start-automaker.mjs b/start-automaker.mjs index 79ace0dd..04569819 100644 --- a/start-automaker.mjs +++ b/start-automaker.mjs @@ -119,9 +119,13 @@ function runBashScript() { process.exit(code ?? 0); }); - // Forward signals to child process - process.on('SIGINT', () => child.kill('SIGINT')); - process.on('SIGTERM', () => child.kill('SIGTERM')); + // Forward signals to child process (guard against race conditions) + process.on('SIGINT', () => { + if (!child.killed) child.kill('SIGINT'); + }); + process.on('SIGTERM', () => { + if (!child.killed) child.kill('SIGTERM'); + }); } runBashScript(); From e3213b142698fad7a5e80c6e51cd3b2c81cca886 Mon Sep 17 00:00:00 2001 From: Scott Date: Sun, 18 Jan 2026 13:30:04 -0700 Subject: [PATCH 08/12] fix: add WSL/Cygwin path translation and improve signal handling - Add convertPathForBash() function that detects bash variant: - Cygwin: /cygdrive/c/path - WSL: /mnt/c/path - MSYS/Git Bash: /c/path - Update exit handler to properly handle signal termination (exit code 1 when killed by signal vs code from child) Addresses remaining CodeRabbit PR #586 recommendations. Co-Authored-By: Claude Opus 4.5 --- start-automaker.mjs | 47 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/start-automaker.mjs b/start-automaker.mjs index 04569819..4be58041 100644 --- a/start-automaker.mjs +++ b/start-automaker.mjs @@ -16,6 +16,36 @@ const __dirname = dirname(__filename); const isWindows = platform() === 'win32'; const args = process.argv.slice(2); +/** + * Convert Windows path to Unix-style for the detected bash variant + * @param {string} windowsPath - Windows-style path (e.g., C:\path\to\file) + * @param {string} bashCmd - Path to bash executable (used to detect variant) + * @returns {string} Unix-style path appropriate for the bash variant + */ +function convertPathForBash(windowsPath, bashCmd) { + let unixPath = windowsPath.replace(/\\/g, '/'); + if (/^[A-Za-z]:/.test(unixPath)) { + const drive = unixPath[0].toLowerCase(); + const pathPart = unixPath.slice(2); + + // Detect bash type from path + if (bashCmd.toLowerCase().includes('cygwin')) { + // Cygwin expects /cygdrive/c/path format + return `/cygdrive/${drive}${pathPart}`; + } else if ( + bashCmd.toLowerCase().includes('system32') || + bashCmd === 'bash.exe' + ) { + // WSL bash is typically in System32 or just 'bash.exe' in PATH + return `/mnt/${drive}${pathPart}`; + } else { + // MSYS2/Git Bash expects /c/path format + return `/${drive}${pathPart}`; + } + } + return unixPath; +} + /** * Find bash executable on Windows */ @@ -80,14 +110,8 @@ function runBashScript() { process.exit(1); } - // Convert Windows path to Unix-style for bash - // Handle both C:\path and /c/path styles - let unixPath = scriptPath.replace(/\\/g, '/'); - if (/^[A-Za-z]:/.test(unixPath)) { - // Convert C:/path to /c/path for MSYS/Git Bash - unixPath = '/' + unixPath[0].toLowerCase() + unixPath.slice(2); - } - + // Convert Windows path to appropriate Unix-style for the detected bash variant + const unixPath = convertPathForBash(scriptPath, bashCmd); bashArgs = [unixPath, ...args]; } else { bashCmd = '/bin/bash'; @@ -115,7 +139,12 @@ function runBashScript() { process.exit(1); }); - child.on('exit', (code) => { + child.on('exit', (code, signal) => { + if (signal) { + // Process was killed by a signal - exit with 1 to indicate abnormal termination + // (Unix convention is 128 + signal number, but we use 1 for simplicity) + process.exit(1); + } process.exit(code ?? 0); }); From 30a2c3d740c7c8210498118f30e6d520b2c2e74a Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 18 Jan 2026 21:36:23 +0100 Subject: [PATCH 09/12] feat: enhance project context menu with theme submenu improvements - Added handlers for theme submenu to manage mouse enter/leave events with a delay, preventing premature closure. - Implemented dynamic positioning for the submenu to avoid viewport overflow, ensuring better visibility. - Updated styles to accommodate new positioning logic and added scroll functionality for theme selection. These changes improve user experience by making the theme selection process more intuitive and visually accessible. --- .../components/project-context-menu.tsx | 91 +++++++++++++++++-- .../project-selector-with-options.tsx | 4 +- 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx index af63af32..4e33729a 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, memo, useCallback } from 'react'; +import { useEffect, useRef, useState, memo, useCallback, useMemo } from 'react'; import type { LucideIcon } from 'lucide-react'; import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react'; import { toast } from 'sonner'; @@ -130,9 +130,76 @@ export function ProjectContextMenu({ const [showThemeSubmenu, setShowThemeSubmenu] = useState(false); const [removeConfirmed, setRemoveConfirmed] = useState(false); const themeSubmenuRef = useRef(null); + const closeTimeoutRef = useRef | null>(null); const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); + // Handler to open theme submenu and cancel any pending close + const handleThemeMenuEnter = useCallback(() => { + // Cancel any pending close timeout + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + setShowThemeSubmenu(true); + }, []); + + // Handler to close theme submenu with a small delay + // This prevents the submenu from closing when mouse crosses the gap between trigger and submenu + const handleThemeMenuLeave = useCallback(() => { + // Add a small delay before closing to allow mouse to reach submenu + closeTimeoutRef.current = setTimeout(() => { + setShowThemeSubmenu(false); + setPreviewTheme(null); + }, 100); // 100ms delay is enough to cross the gap + }, [setPreviewTheme]); + + // Calculate submenu positioning to avoid viewport overflow + // Detects if submenu would overflow and flips it upward if needed + const submenuPosition = useMemo(() => { + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800; + // Estimated submenu height: ~620px for all themes + header + padding + const estimatedSubmenuHeight = 620; + // Extra padding from bottom to ensure full visibility + const collisionPadding = 32; + // The "Project Theme" button is approximately 50px down from the top of the context menu + const themeButtonOffset = 50; + + // Calculate where the submenu's bottom edge would be if positioned normally + const submenuBottomY = position.y + themeButtonOffset + estimatedSubmenuHeight; + + // Check if submenu would overflow bottom of viewport + const wouldOverflowBottom = submenuBottomY > viewportHeight - collisionPadding; + + // If it would overflow, calculate how much to shift it up + if (wouldOverflowBottom) { + // Calculate the offset needed to align submenu bottom with viewport bottom minus padding + const overflowAmount = submenuBottomY - (viewportHeight - collisionPadding); + return { + top: -overflowAmount, + maxHeight: Math.min(estimatedSubmenuHeight, viewportHeight - collisionPadding * 2), + }; + } + + // Default: submenu opens at top of parent (aligned with the theme button) + return { + top: 0, + maxHeight: Math.min( + estimatedSubmenuHeight, + viewportHeight - position.y - themeButtonOffset - collisionPadding + ), + }; + }, [position.y]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + }; + }, []); + useEffect(() => { const handleClickOutside = (event: globalThis.MouseEvent) => { // Don't close if a confirmation dialog is open (dialog is in a portal) @@ -242,11 +309,8 @@ export function ProjectContextMenu({ {/* Theme Submenu Trigger */}
setShowThemeSubmenu(true)} - onMouseLeave={() => { - setShowThemeSubmenu(false); - setPreviewTheme(null); - }} + onMouseEnter={handleThemeMenuEnter} + onMouseLeave={handleThemeMenuLeave} >