diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts new file mode 100644 index 000000000..983e5806d --- /dev/null +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -0,0 +1,644 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ClaudeUsageService } from '@/services/claude-usage-service.js'; +import { spawn } from 'child_process'; +import * as pty from 'node-pty'; +import * as os from 'os'; + +vi.mock('child_process'); +vi.mock('node-pty'); +vi.mock('os'); + +describe('claude-usage-service.ts', () => { + let service: ClaudeUsageService; + let mockSpawnProcess: any; + let mockPtyProcess: any; + + beforeEach(() => { + vi.clearAllMocks(); + service = new ClaudeUsageService(); + + // Mock spawn process for isAvailable and Mac commands + mockSpawnProcess = { + on: vi.fn(), + kill: vi.fn(), + stdout: { + on: vi.fn(), + }, + stderr: { + on: vi.fn(), + }, + }; + + // Mock PTY process for Windows + mockPtyProcess = { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + }; + + vi.mocked(spawn).mockReturnValue(mockSpawnProcess as any); + vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess); + }); + + describe('isAvailable', () => { + it('should return true when Claude CLI is available', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + + // Simulate successful which/where command + mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === 'close') { + callback(0); // Exit code 0 = found + } + return mockSpawnProcess; + }); + + const result = await service.isAvailable(); + + expect(result).toBe(true); + expect(spawn).toHaveBeenCalledWith('which', ['claude']); + }); + + it('should return false when Claude CLI is not available', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + + mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === 'close') { + callback(1); // Exit code 1 = not found + } + return mockSpawnProcess; + }); + + const result = await service.isAvailable(); + + expect(result).toBe(false); + }); + + it('should return false on error', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + + mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === 'error') { + callback(new Error('Command failed')); + } + return mockSpawnProcess; + }); + + const result = await service.isAvailable(); + + expect(result).toBe(false); + }); + + it("should use 'where' command on Windows", async () => { + vi.mocked(os.platform).mockReturnValue('win32'); + const windowsService = new ClaudeUsageService(); // Create new service after platform mock + + mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === 'close') { + callback(0); + } + return mockSpawnProcess; + }); + + await windowsService.isAvailable(); + + expect(spawn).toHaveBeenCalledWith('where', ['claude']); + }); + }); + + describe('stripAnsiCodes', () => { + it('should strip ANSI color codes from text', () => { + const service = new ClaudeUsageService(); + const input = '\x1B[31mRed text\x1B[0m Normal text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Red text Normal text'); + }); + + it('should handle text without ANSI codes', () => { + const service = new ClaudeUsageService(); + const input = 'Plain text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Plain text'); + }); + }); + + describe('parseResetTime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T10:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should parse duration format with hours and minutes', () => { + const service = new ClaudeUsageService(); + const text = 'Resets in 2h 15m'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const expected = new Date('2025-01-15T12:15:00Z'); + expect(new Date(result)).toEqual(expected); + }); + + it('should parse duration format with only minutes', () => { + const service = new ClaudeUsageService(); + const text = 'Resets in 30m'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const expected = new Date('2025-01-15T10:30:00Z'); + expect(new Date(result)).toEqual(expected); + }); + + it('should parse simple time format (AM)', () => { + const service = new ClaudeUsageService(); + const text = 'Resets 11am'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + // Should be today at 11am, or tomorrow if already passed + const resultDate = new Date(result); + expect(resultDate.getHours()).toBe(11); + expect(resultDate.getMinutes()).toBe(0); + }); + + it('should parse simple time format (PM)', () => { + const service = new ClaudeUsageService(); + const text = 'Resets 3pm'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const resultDate = new Date(result); + expect(resultDate.getHours()).toBe(15); + expect(resultDate.getMinutes()).toBe(0); + }); + + it('should parse date format with month, day, and time', () => { + const service = new ClaudeUsageService(); + const text = 'Resets Dec 22 at 8pm'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'weekly'); + + const resultDate = new Date(result); + expect(resultDate.getMonth()).toBe(11); // December = 11 + expect(resultDate.getDate()).toBe(22); + expect(resultDate.getHours()).toBe(20); + }); + + it('should parse date format with comma separator', () => { + const service = new ClaudeUsageService(); + const text = 'Resets Jan 15, 3:30pm'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'weekly'); + + const resultDate = new Date(result); + expect(resultDate.getMonth()).toBe(0); // January = 0 + expect(resultDate.getDate()).toBe(15); + expect(resultDate.getHours()).toBe(15); + expect(resultDate.getMinutes()).toBe(30); + }); + + it('should handle 12am correctly', () => { + const service = new ClaudeUsageService(); + const text = 'Resets 12am'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const resultDate = new Date(result); + expect(resultDate.getHours()).toBe(0); + }); + + it('should handle 12pm correctly', () => { + const service = new ClaudeUsageService(); + const text = 'Resets 12pm'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const resultDate = new Date(result); + expect(resultDate.getHours()).toBe(12); + }); + + it('should return default reset time for unparseable text', () => { + const service = new ClaudeUsageService(); + const text = 'Invalid reset text'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + // @ts-expect-error - accessing private method for testing + const defaultResult = service.getDefaultResetTime('session'); + + expect(result).toBe(defaultResult); + }); + }); + + describe('getDefaultResetTime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T10:00:00Z')); // Wednesday + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return session default (5 hours from now)', () => { + const service = new ClaudeUsageService(); + // @ts-expect-error - accessing private method for testing + const result = service.getDefaultResetTime('session'); + + const expected = new Date('2025-01-15T15:00:00Z'); + expect(new Date(result)).toEqual(expected); + }); + + it('should return weekly default (next Monday at noon)', () => { + const service = new ClaudeUsageService(); + // @ts-expect-error - accessing private method for testing + const result = service.getDefaultResetTime('weekly'); + + const resultDate = new Date(result); + // Next Monday from Wednesday should be 5 days away + expect(resultDate.getDay()).toBe(1); // Monday + expect(resultDate.getHours()).toBe(12); + expect(resultDate.getMinutes()).toBe(59); + }); + }); + + describe('parseSection', () => { + it('should parse section with percentage left', () => { + const service = new ClaudeUsageService(); + const lines = ['Current session', '████████████████░░░░ 65% left', 'Resets in 2h 15m']; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'Current session', 'session'); + + expect(result.percentage).toBe(35); // 100 - 65 = 35% used + expect(result.resetText).toBe('Resets in 2h 15m'); + }); + + it('should parse section with percentage used', () => { + const service = new ClaudeUsageService(); + const lines = [ + 'Current week (all models)', + '██████████░░░░░░░░░░ 40% used', + 'Resets Jan 15, 3:30pm', + ]; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'Current week (all models)', 'weekly'); + + expect(result.percentage).toBe(40); // Already in % used + }); + + it('should return zero percentage when section not found', () => { + const service = new ClaudeUsageService(); + const lines = ['Some other text', 'No matching section']; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'Current session', 'session'); + + expect(result.percentage).toBe(0); + }); + + it('should strip timezone from reset text', () => { + const service = new ClaudeUsageService(); + const lines = ['Current session', '65% left', 'Resets 3pm (America/Los_Angeles)']; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'Current session', 'session'); + + expect(result.resetText).toBe('Resets 3pm'); + expect(result.resetText).not.toContain('America/Los_Angeles'); + }); + + it('should handle case-insensitive section matching', () => { + const service = new ClaudeUsageService(); + const lines = ['CURRENT SESSION', '65% left', 'Resets in 2h']; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'current session', 'session'); + + expect(result.percentage).toBe(35); + }); + }); + + describe('parseUsageOutput', () => { + it('should parse complete usage output', () => { + const service = new ClaudeUsageService(); + const output = ` +Claude Code v1.0.27 + +Current session +████████████████░░░░ 65% left +Resets in 2h 15m + +Current week (all models) +██████████░░░░░░░░░░ 35% left +Resets Jan 15, 3:30pm (America/Los_Angeles) + +Current week (Sonnet only) +████████████████████ 80% left +Resets Jan 15, 3:30pm (America/Los_Angeles) +`; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sessionPercentage).toBe(35); // 100 - 65 + expect(result.weeklyPercentage).toBe(65); // 100 - 35 + expect(result.sonnetWeeklyPercentage).toBe(20); // 100 - 80 + expect(result.sessionResetText).toContain('Resets in 2h 15m'); + expect(result.weeklyResetText).toContain('Resets Jan 15, 3:30pm'); + expect(result.userTimezone).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone); + }); + + it('should handle output with ANSI codes', () => { + const service = new ClaudeUsageService(); + const output = ` +\x1B[1mClaude Code v1.0.27\x1B[0m + +\x1B[1mCurrent session\x1B[0m +\x1B[32m████████████████░░░░\x1B[0m 65% left +Resets in 2h 15m +`; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sessionPercentage).toBe(35); + }); + + it('should handle Opus section name', () => { + const service = new ClaudeUsageService(); + const output = ` +Current session +65% left +Resets in 2h + +Current week (all models) +35% left +Resets Jan 15, 3pm + +Current week (Opus) +90% left +Resets Jan 15, 3pm +`; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sonnetWeeklyPercentage).toBe(10); // 100 - 90 + }); + + it('should set default values for missing sections', () => { + const service = new ClaudeUsageService(); + const output = 'Claude Code v1.0.27'; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sessionPercentage).toBe(0); + expect(result.weeklyPercentage).toBe(0); + expect(result.sonnetWeeklyPercentage).toBe(0); + expect(result.sessionTokensUsed).toBe(0); + expect(result.sessionLimit).toBe(0); + expect(result.costUsed).toBeNull(); + expect(result.costLimit).toBeNull(); + expect(result.costCurrency).toBeNull(); + }); + }); + + describe('executeClaudeUsageCommandMac', () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.spyOn(process, 'env', 'get').mockReturnValue({ HOME: '/Users/testuser' }); + }); + + it('should execute expect script and return output', async () => { + const mockOutput = ` +Current session +65% left +Resets in 2h +`; + + let stdoutCallback: Function; + let closeCallback: Function; + + mockSpawnProcess.stdout = { + on: vi.fn((event: string, callback: Function) => { + if (event === 'data') { + stdoutCallback = callback; + } + }), + }; + mockSpawnProcess.stderr = { + on: vi.fn(), + }; + mockSpawnProcess.on = vi.fn((event: string, callback: Function) => { + if (event === 'close') { + closeCallback = callback; + } + return mockSpawnProcess; + }); + + const promise = service.fetchUsageData(); + + // Simulate stdout data + stdoutCallback!(Buffer.from(mockOutput)); + + // Simulate successful close + closeCallback!(0); + + const result = await promise; + + expect(result.sessionPercentage).toBe(35); // 100 - 65 + expect(spawn).toHaveBeenCalledWith( + 'expect', + expect.arrayContaining(['-c']), + expect.any(Object) + ); + }); + + it('should handle authentication errors', async () => { + const mockOutput = 'token_expired'; + + let stdoutCallback: Function; + let closeCallback: Function; + + mockSpawnProcess.stdout = { + on: vi.fn((event: string, callback: Function) => { + if (event === 'data') { + stdoutCallback = callback; + } + }), + }; + mockSpawnProcess.stderr = { + on: vi.fn(), + }; + mockSpawnProcess.on = vi.fn((event: string, callback: Function) => { + if (event === 'close') { + closeCallback = callback; + } + return mockSpawnProcess; + }); + + const promise = service.fetchUsageData(); + + stdoutCallback!(Buffer.from(mockOutput)); + closeCallback!(1); + + await expect(promise).rejects.toThrow('Authentication required'); + }); + + it('should handle timeout', async () => { + vi.useFakeTimers(); + + mockSpawnProcess.stdout = { + on: vi.fn(), + }; + mockSpawnProcess.stderr = { + on: vi.fn(), + }; + mockSpawnProcess.on = vi.fn(() => mockSpawnProcess); + mockSpawnProcess.kill = vi.fn(); + + const promise = service.fetchUsageData(); + + // Advance time past timeout (30 seconds) + vi.advanceTimersByTime(31000); + + await expect(promise).rejects.toThrow('Command timed out'); + + vi.useRealTimers(); + }); + }); + + describe('executeClaudeUsageCommandWindows', () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue('win32'); + vi.mocked(os.homedir).mockReturnValue('C:\\Users\\testuser'); + vi.spyOn(process, 'env', 'get').mockReturnValue({ USERPROFILE: 'C:\\Users\\testuser' }); + }); + + it('should use node-pty on Windows and return output', async () => { + const windowsService = new ClaudeUsageService(); // Create new service for Windows platform + const mockOutput = ` +Current session +65% left +Resets in 2h +`; + + let dataCallback: Function | undefined; + let exitCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn((callback: Function) => { + exitCallback = callback; + }), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = windowsService.fetchUsageData(); + + // Simulate data + dataCallback!(mockOutput); + + // Simulate successful exit + exitCallback!({ exitCode: 0 }); + + const result = await promise; + + expect(result.sessionPercentage).toBe(35); + expect(pty.spawn).toHaveBeenCalledWith( + 'cmd.exe', + ['/c', 'claude', '/usage'], + expect.any(Object) + ); + }); + + it('should send escape key after seeing usage data', async () => { + vi.useFakeTimers(); + const windowsService = new ClaudeUsageService(); + + const mockOutput = 'Current session\n65% left'; + + let dataCallback: Function | undefined; + let exitCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn((callback: Function) => { + exitCallback = callback; + }), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = windowsService.fetchUsageData(); + + // Simulate seeing usage data + dataCallback!(mockOutput); + + // Advance time to trigger escape key sending + vi.advanceTimersByTime(2100); + + expect(mockPty.write).toHaveBeenCalledWith('\x1b'); + + // Complete the promise to avoid unhandled rejection + exitCallback!({ exitCode: 0 }); + await promise; + + vi.useRealTimers(); + }); + + it('should handle authentication errors on Windows', async () => { + const windowsService = new ClaudeUsageService(); + let dataCallback: Function | undefined; + let exitCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn((callback: Function) => { + exitCallback = callback; + }), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = windowsService.fetchUsageData(); + + dataCallback!('authentication_error'); + exitCallback!({ exitCode: 1 }); + + await expect(promise).rejects.toThrow('Authentication required'); + }); + + it('should handle timeout on Windows', async () => { + vi.useFakeTimers(); + const windowsService = new ClaudeUsageService(); + + const mockPty = { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = windowsService.fetchUsageData(); + + vi.advanceTimersByTime(31000); + + await expect(promise).rejects.toThrow('Command timed out'); + expect(mockPty.kill).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + }); +}); diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 150f0bad1..0b7d6f0e0 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -5,6 +5,18 @@ import tsParser from "@typescript-eslint/parser"; const eslintConfig = defineConfig([ js.configs.recommended, + { + files: ["**/*.mjs", "**/*.cjs"], + languageOptions: { + globals: { + console: "readonly", + process: "readonly", + require: "readonly", + __dirname: "readonly", + __filename: "readonly", + }, + }, + }, { files: ["**/*.ts", "**/*.tsx"], languageOptions: { @@ -13,6 +25,70 @@ const eslintConfig = defineConfig([ ecmaVersion: "latest", sourceType: "module", }, + globals: { + // Browser/DOM APIs + window: "readonly", + document: "readonly", + navigator: "readonly", + Navigator: "readonly", + localStorage: "readonly", + sessionStorage: "readonly", + fetch: "readonly", + WebSocket: "readonly", + File: "readonly", + FileList: "readonly", + FileReader: "readonly", + Blob: "readonly", + atob: "readonly", + crypto: "readonly", + prompt: "readonly", + confirm: "readonly", + getComputedStyle: "readonly", + requestAnimationFrame: "readonly", + // DOM Element Types + HTMLElement: "readonly", + HTMLInputElement: "readonly", + HTMLDivElement: "readonly", + HTMLButtonElement: "readonly", + HTMLSpanElement: "readonly", + HTMLTextAreaElement: "readonly", + HTMLHeadingElement: "readonly", + HTMLParagraphElement: "readonly", + HTMLImageElement: "readonly", + Element: "readonly", + // Event Types + Event: "readonly", + KeyboardEvent: "readonly", + DragEvent: "readonly", + PointerEvent: "readonly", + CustomEvent: "readonly", + ClipboardEvent: "readonly", + WheelEvent: "readonly", + DataTransfer: "readonly", + // Web APIs + ResizeObserver: "readonly", + AbortSignal: "readonly", + Audio: "readonly", + ScrollBehavior: "readonly", + // Timers + setTimeout: "readonly", + setInterval: "readonly", + clearTimeout: "readonly", + clearInterval: "readonly", + // Node.js (for scripts and Electron) + process: "readonly", + require: "readonly", + __dirname: "readonly", + __filename: "readonly", + NodeJS: "readonly", + // React + React: "readonly", + JSX: "readonly", + // Electron + Electron: "readonly", + // Console + console: "readonly", + }, }, plugins: { "@typescript-eslint": ts, diff --git a/apps/ui/src/App.tsx b/apps/ui/src/app.tsx similarity index 52% rename from apps/ui/src/App.tsx rename to apps/ui/src/app.tsx index a38de6b24..50380095d 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/app.tsx @@ -1,15 +1,15 @@ -import { useState, useCallback } from "react"; -import { RouterProvider } from "@tanstack/react-router"; -import { router } from "./utils/router"; -import { SplashScreen } from "./components/splash-screen"; -import { useSettingsMigration } from "./hooks/use-settings-migration"; -import "./styles/global.css"; -import "./styles/theme-imports"; +import { useState, useCallback } from 'react'; +import { RouterProvider } from '@tanstack/react-router'; +import { router } from './utils/router'; +import { SplashScreen } from './components/splash-screen'; +import { useSettingsMigration } from './hooks/use-settings-migration'; +import './styles/global.css'; +import './styles/theme-imports'; export default function App() { const [showSplash, setShowSplash] = useState(() => { // Only show splash once per session - if (sessionStorage.getItem("automaker-splash-shown")) { + if (sessionStorage.getItem('automaker-splash-shown')) { return false; } return true; @@ -18,11 +18,11 @@ export default function App() { // Run settings migration on startup (localStorage -> file storage) const migrationState = useSettingsMigration(); if (migrationState.migrated) { - console.log("[App] Settings migrated to file storage"); + console.log('[App] Settings migrated to file storage'); } const handleSplashComplete = useCallback(() => { - sessionStorage.setItem("automaker-splash-shown", "true"); + sessionStorage.setItem('automaker-splash-shown', 'true'); setShowSplash(false); }, []); diff --git a/apps/ui/src/components/delete-all-archived-sessions-dialog.tsx b/apps/ui/src/components/dialogs/delete-all-archived-sessions-dialog.tsx similarity index 89% rename from apps/ui/src/components/delete-all-archived-sessions-dialog.tsx rename to apps/ui/src/components/dialogs/delete-all-archived-sessions-dialog.tsx index 66b0bae6b..358b99da2 100644 --- a/apps/ui/src/components/delete-all-archived-sessions-dialog.tsx +++ b/apps/ui/src/components/dialogs/delete-all-archived-sessions-dialog.tsx @@ -1,4 +1,3 @@ - import { Dialog, DialogContent, @@ -6,9 +5,9 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Trash2 } from "lucide-react"; +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Trash2 } from 'lucide-react'; interface DeleteAllArchivedSessionsDialogProps { open: boolean; @@ -29,8 +28,7 @@ export function DeleteAllArchivedSessionsDialog({ Delete All Archived Sessions - Are you sure you want to delete all archived sessions? This action - cannot be undone. + Are you sure you want to delete all archived sessions? This action cannot be undone. {archivedCount > 0 && ( {archivedCount} session(s) will be deleted. diff --git a/apps/ui/src/components/delete-session-dialog.tsx b/apps/ui/src/components/dialogs/delete-session-dialog.tsx similarity index 78% rename from apps/ui/src/components/delete-session-dialog.tsx rename to apps/ui/src/components/dialogs/delete-session-dialog.tsx index e40cbed87..108620129 100644 --- a/apps/ui/src/components/delete-session-dialog.tsx +++ b/apps/ui/src/components/dialogs/delete-session-dialog.tsx @@ -1,6 +1,6 @@ -import { MessageSquare } from "lucide-react"; -import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; -import type { SessionListItem } from "@/types/electron"; +import { MessageSquare } from 'lucide-react'; +import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'; +import type { SessionListItem } from '@/types/electron'; interface DeleteSessionDialogProps { open: boolean; @@ -38,12 +38,8 @@ export function DeleteSessionDialog({
-

- {session.name} -

-

- {session.messageCount} messages -

+

{session.name}

+

{session.messageCount} messages

)} diff --git a/apps/ui/src/components/dialogs/file-browser-dialog.tsx b/apps/ui/src/components/dialogs/file-browser-dialog.tsx index b6a05ab00..dc9c1c2e7 100644 --- a/apps/ui/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/ui/src/components/dialogs/file-browser-dialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback } from 'react'; import { FolderOpen, Folder, @@ -9,7 +9,7 @@ import { CornerDownLeft, Clock, X, -} from "lucide-react"; +} from 'lucide-react'; import { Dialog, DialogContent, @@ -17,14 +17,11 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { getJSON, setJSON } from "@/lib/storage"; -import { - getDefaultWorkspaceDirectory, - saveLastProjectDirectory, -} from "@/lib/workspace-config"; +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { getJSON, setJSON } from '@/lib/storage'; +import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; interface DirectoryEntry { name: string; @@ -50,7 +47,7 @@ interface FileBrowserDialogProps { initialPath?: string; } -const RECENT_FOLDERS_KEY = "file-browser-recent-folders"; +const RECENT_FOLDERS_KEY = 'file-browser-recent-folders'; const MAX_RECENT_FOLDERS = 5; function getRecentFolders(): string[] { @@ -76,18 +73,18 @@ export function FileBrowserDialog({ open, onOpenChange, onSelect, - title = "Select Project Directory", - description = "Navigate to your project folder or paste a path directly", + title = 'Select Project Directory', + description = 'Navigate to your project folder or paste a path directly', initialPath, }: FileBrowserDialogProps) { - const [currentPath, setCurrentPath] = useState(""); - const [pathInput, setPathInput] = useState(""); + const [currentPath, setCurrentPath] = useState(''); + const [pathInput, setPathInput] = useState(''); const [parentPath, setParentPath] = useState(null); const [directories, setDirectories] = useState([]); const [drives, setDrives] = useState([]); const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [warning, setWarning] = useState(""); + const [error, setError] = useState(''); + const [warning, setWarning] = useState(''); const [recentFolders, setRecentFolders] = useState([]); const pathInputRef = useRef(null); @@ -98,28 +95,24 @@ export function FileBrowserDialog({ } }, [open]); - const handleRemoveRecent = useCallback( - (e: React.MouseEvent, path: string) => { - e.stopPropagation(); - const updated = removeRecentFolder(path); - setRecentFolders(updated); - }, - [] - ); + const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => { + e.stopPropagation(); + const updated = removeRecentFolder(path); + setRecentFolders(updated); + }, []); const browseDirectory = useCallback(async (dirPath?: string) => { setLoading(true); - setError(""); - setWarning(""); + setError(''); + setWarning(''); try { // Get server URL from environment or default - const serverUrl = - import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; + const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; const response = await fetch(`${serverUrl}/api/fs/browse`, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dirPath }), }); @@ -131,14 +124,12 @@ export function FileBrowserDialog({ setParentPath(result.parentPath); setDirectories(result.directories); setDrives(result.drives || []); - setWarning(result.warning || ""); + setWarning(result.warning || ''); } else { - setError(result.error || "Failed to browse directory"); + setError(result.error || 'Failed to browse directory'); } } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to load directories" - ); + setError(err instanceof Error ? err.message : 'Failed to load directories'); } finally { setLoading(false); } @@ -154,12 +145,12 @@ export function FileBrowserDialog({ // Reset current path when dialog closes useEffect(() => { if (!open) { - setCurrentPath(""); - setPathInput(""); + setCurrentPath(''); + setPathInput(''); setParentPath(null); setDirectories([]); - setError(""); - setWarning(""); + setError(''); + setWarning(''); } }, [open]); @@ -189,7 +180,7 @@ export function FileBrowserDialog({ // No default directory, browse home directory browseDirectory(); } - } catch (err) { + } catch { // If config fetch fails, try initialPath or fall back to home directory if (initialPath) { setPathInput(initialPath); @@ -230,7 +221,7 @@ export function FileBrowserDialog({ }; const handlePathInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { + if (e.key === 'Enter') { e.preventDefault(); handleGoToPath(); } @@ -252,7 +243,7 @@ export function FileBrowserDialog({ const handleKeyDown = (e: KeyboardEvent) => { // Check for Command+Enter (Mac) or Ctrl+Enter (Windows/Linux) - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (currentPath && !loading) { handleSelect(); @@ -260,8 +251,8 @@ export function FileBrowserDialog({ } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); }, [open, currentPath, loading, handleSelect]); // Helper to get folder name from path @@ -326,9 +317,7 @@ export function FileBrowserDialog({ title={folder} > - - {getFolderName(folder)} - + {getFolderName(folder)} ))} @@ -388,7 +375,7 @@ export function FileBrowserDialog({ )}
- {currentPath || "Loading..."} + {currentPath || 'Loading...'}
@@ -396,9 +383,7 @@ export function FileBrowserDialog({
{loading && (
-
- Loading directories... -
+
Loading directories...
)} @@ -416,9 +401,7 @@ export function FileBrowserDialog({ {!loading && !error && !warning && directories.length === 0 && (
-
- No subdirectories found -
+
No subdirectories found
)} @@ -440,8 +423,8 @@ export function FileBrowserDialog({
- Paste a full path above, or click on folders to navigate. Press - Enter or click Go to jump to a path. + Paste a full path above, or click on folders to navigate. Press Enter or click Go to + jump to a path.
@@ -458,10 +441,9 @@ export function FileBrowserDialog({ Select Current Folder - {typeof navigator !== "undefined" && - navigator.platform?.includes("Mac") - ? "⌘" - : "Ctrl"} + {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') + ? '⌘' + : 'Ctrl'} +↵ diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts new file mode 100644 index 000000000..4cadb26d9 --- /dev/null +++ b/apps/ui/src/components/dialogs/index.ts @@ -0,0 +1,6 @@ +export { BoardBackgroundModal } from './board-background-modal'; +export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-dialog'; +export { DeleteSessionDialog } from './delete-session-dialog'; +export { FileBrowserDialog } from './file-browser-dialog'; +export { NewProjectModal } from './new-project-modal'; +export { WorkspacePickerModal } from './workspace-picker-modal'; diff --git a/apps/ui/src/components/new-project-modal.tsx b/apps/ui/src/components/dialogs/new-project-modal.tsx similarity index 70% rename from apps/ui/src/components/new-project-modal.tsx rename to apps/ui/src/components/dialogs/new-project-modal.tsx index 93eef7638..042b2ad7f 100644 --- a/apps/ui/src/components/new-project-modal.tsx +++ b/apps/ui/src/components/dialogs/new-project-modal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from 'react'; import { Dialog, DialogContent, @@ -6,13 +6,13 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { HotkeyButton } from "@/components/ui/hotkey-button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Badge } from "@/components/ui/badge"; +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { HotkeyButton } from '@/components/ui/hotkey-button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; import { FolderPlus, FolderOpen, @@ -22,15 +22,12 @@ import { Loader2, Link, Folder, -} from "lucide-react"; -import { starterTemplates, type StarterTemplate } from "@/lib/templates"; -import { getElectronAPI } from "@/lib/electron"; -import { cn } from "@/lib/utils"; -import { useFileBrowser } from "@/contexts/file-browser-context"; -import { - getDefaultWorkspaceDirectory, - saveLastProjectDirectory, -} from "@/lib/workspace-config"; +} from 'lucide-react'; +import { starterTemplates, type StarterTemplate } from '@/lib/templates'; +import { getElectronAPI } from '@/lib/electron'; +import { cn } from '@/lib/utils'; +import { useFileBrowser } from '@/contexts/file-browser-context'; +import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; interface ValidationErrors { projectName?: boolean; @@ -42,20 +39,13 @@ interface ValidationErrors { interface NewProjectModalProps { open: boolean; onOpenChange: (open: boolean) => void; - onCreateBlankProject: ( - projectName: string, - parentDir: string - ) => Promise; + onCreateBlankProject: (projectName: string, parentDir: string) => Promise; onCreateFromTemplate: ( template: StarterTemplate, projectName: string, parentDir: string ) => Promise; - onCreateFromCustomUrl: ( - repoUrl: string, - projectName: string, - parentDir: string - ) => Promise; + onCreateFromCustomUrl: (repoUrl: string, projectName: string, parentDir: string) => Promise; isCreating: boolean; } @@ -67,14 +57,13 @@ export function NewProjectModal({ onCreateFromCustomUrl, isCreating, }: NewProjectModalProps) { - const [activeTab, setActiveTab] = useState<"blank" | "template">("blank"); - const [projectName, setProjectName] = useState(""); - const [workspaceDir, setWorkspaceDir] = useState(""); + const [activeTab, setActiveTab] = useState<'blank' | 'template'>('blank'); + const [projectName, setProjectName] = useState(''); + const [workspaceDir, setWorkspaceDir] = useState(''); const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false); - const [selectedTemplate, setSelectedTemplate] = - useState(null); + const [selectedTemplate, setSelectedTemplate] = useState(null); const [useCustomUrl, setUseCustomUrl] = useState(false); - const [customUrl, setCustomUrl] = useState(""); + const [customUrl, setCustomUrl] = useState(''); const [errors, setErrors] = useState({}); const { openFileBrowser } = useFileBrowser(); @@ -89,7 +78,7 @@ export function NewProjectModal({ } }) .catch((error) => { - console.error("Failed to get default workspace directory:", error); + console.error('Failed to get default workspace directory:', error); }) .finally(() => { setIsLoadingWorkspace(false); @@ -100,11 +89,11 @@ export function NewProjectModal({ // Reset form when modal closes useEffect(() => { if (!open) { - setProjectName(""); + setProjectName(''); setSelectedTemplate(null); setUseCustomUrl(false); - setCustomUrl(""); - setActiveTab("blank"); + setCustomUrl(''); + setActiveTab('blank'); setErrors({}); } }, [open]); @@ -117,10 +106,7 @@ export function NewProjectModal({ }, [projectName, errors.projectName]); useEffect(() => { - if ( - (selectedTemplate || (useCustomUrl && customUrl)) && - errors.templateSelection - ) { + if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) { setErrors((prev) => ({ ...prev, templateSelection: false })); } }, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]); @@ -145,7 +131,7 @@ export function NewProjectModal({ } // Check template selection (only for template tab) - if (activeTab === "template") { + if (activeTab === 'template') { if (useCustomUrl) { if (!customUrl.trim()) { newErrors.customUrl = true; @@ -164,7 +150,7 @@ export function NewProjectModal({ // Clear errors and proceed setErrors({}); - if (activeTab === "blank") { + if (activeTab === 'blank') { await onCreateBlankProject(projectName, workspaceDir); } else if (useCustomUrl && customUrl) { await onCreateFromCustomUrl(customUrl, projectName, workspaceDir); @@ -181,7 +167,7 @@ export function NewProjectModal({ const handleSelectTemplate = (template: StarterTemplate) => { setSelectedTemplate(template); setUseCustomUrl(false); - setCustomUrl(""); + setCustomUrl(''); }; const handleToggleCustomUrl = () => { @@ -193,9 +179,8 @@ export function NewProjectModal({ const handleBrowseDirectory = async () => { const selectedPath = await openFileBrowser({ - title: "Select Base Project Directory", - description: - "Choose the parent directory where your project will be created", + title: 'Select Base Project Directory', + description: 'Choose the parent directory where your project will be created', initialPath: workspaceDir || undefined, }); if (selectedPath) { @@ -211,15 +196,12 @@ export function NewProjectModal({ // Use platform-specific path separator const pathSep = - typeof window !== "undefined" && (window as any).electronAPI - ? navigator.platform.indexOf("Win") !== -1 - ? "\\" - : "/" - : "/"; - const projectPath = - workspaceDir && projectName - ? `${workspaceDir}${pathSep}${projectName}` - : ""; + typeof window !== 'undefined' && (window as any).electronAPI + ? navigator.platform.indexOf('Win') !== -1 + ? '\\' + : '/' + : '/'; + const projectPath = workspaceDir && projectName ? `${workspaceDir}${pathSep}${projectName}` : ''; return ( @@ -228,9 +210,7 @@ export function NewProjectModal({ data-testid="new-project-modal" > - - Create New Project - + Create New Project Start with a blank project or choose from a starter template. @@ -241,13 +221,9 @@ export function NewProjectModal({
setProjectName(e.target.value)} className={cn( - "bg-input text-foreground placeholder:text-muted-foreground", + 'bg-input text-foreground placeholder:text-muted-foreground', errors.projectName - ? "border-red-500 focus:border-red-500 focus:ring-red-500/20" - : "border-border" + ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' + : 'border-border' )} data-testid="project-name-input" autoFocus /> - {errors.projectName && ( -

Project name is required

- )} + {errors.projectName &&

Project name is required

}
{/* Workspace Directory Display */}
{isLoadingWorkspace ? ( - "Loading workspace..." + 'Loading workspace...' ) : workspaceDir ? ( <> - Will be created at:{" "} + Will be created at:{' '} {projectPath || workspaceDir} @@ -305,7 +279,7 @@ export function NewProjectModal({ setActiveTab(v as "blank" | "template")} + onValueChange={(v) => setActiveTab(v as 'blank' | 'template')} className="flex-1 flex flex-col overflow-hidden" > @@ -323,9 +297,8 @@ export function NewProjectModal({

- Create an empty project with the standard .automaker directory - structure. Perfect for starting from scratch or importing an - existing codebase. + Create an empty project with the standard .automaker directory structure. Perfect + for starting from scratch or importing an existing codebase.

@@ -342,18 +315,18 @@ export function NewProjectModal({ {/* Preset Templates */}
{starterTemplates.map((template) => (
handleSelectTemplate(template)} data-testid={`template-${template.id}`} @@ -361,13 +334,10 @@ export function NewProjectModal({
-

- {template.name} -

- {selectedTemplate?.id === template.id && - !useCustomUrl && ( - - )} +

{template.name}

+ {selectedTemplate?.id === template.id && !useCustomUrl && ( + + )}

{template.description} @@ -376,11 +346,7 @@ export function NewProjectModal({ {/* Tech Stack */}

{template.techStack.slice(0, 6).map((tech) => ( - + {tech} ))} @@ -394,7 +360,7 @@ export function NewProjectModal({ {/* Key Features */}
Features: - {template.features.slice(0, 3).join(" · ")} + {template.features.slice(0, 3).join(' · ')} {template.features.length > 3 && ` · +${template.features.length - 3} more`}
@@ -419,47 +385,38 @@ export function NewProjectModal({ {/* Custom URL Option */}
-

- Custom GitHub URL -

- {useCustomUrl && ( - - )} +

Custom GitHub URL

+ {useCustomUrl && }

Clone any public GitHub repository as a starting point.

{useCustomUrl && ( -
e.stopPropagation()} - className="space-y-1" - > +
e.stopPropagation()} className="space-y-1"> setCustomUrl(e.target.value)} className={cn( - "bg-input text-foreground placeholder:text-muted-foreground", + 'bg-input text-foreground placeholder:text-muted-foreground', errors.customUrl - ? "border-red-500 focus:border-red-500 focus:ring-red-500/20" - : "border-border" + ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' + : 'border-border' )} data-testid="custom-url-input" /> {errors.customUrl && ( -

- GitHub URL is required -

+

GitHub URL is required

)}
)} @@ -482,14 +439,14 @@ export function NewProjectModal({ onClick={validateAndCreate} disabled={isCreating} className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0" - hotkey={{ key: "Enter", cmdCtrl: true }} + hotkey={{ key: 'Enter', cmdCtrl: true }} hotkeyActive={open} data-testid="confirm-create-project" > {isCreating ? ( <> - {activeTab === "template" ? "Cloning..." : "Creating..."} + {activeTab === 'template' ? 'Cloning...' : 'Creating...'} ) : ( <>Create Project diff --git a/apps/ui/src/components/workspace-picker-modal.tsx b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx similarity index 85% rename from apps/ui/src/components/workspace-picker-modal.tsx rename to apps/ui/src/components/dialogs/workspace-picker-modal.tsx index 2f3303a26..4f2874655 100644 --- a/apps/ui/src/components/workspace-picker-modal.tsx +++ b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx @@ -1,5 +1,4 @@ - -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback } from 'react'; import { Dialog, DialogContent, @@ -7,10 +6,10 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Folder, Loader2, FolderOpen, AlertCircle } from "lucide-react"; -import { getHttpApiClient } from "@/lib/http-api-client"; +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react'; +import { getHttpApiClient } from '@/lib/http-api-client'; interface WorkspaceDirectory { name: string; @@ -23,11 +22,7 @@ interface WorkspacePickerModalProps { onSelect: (path: string, name: string) => void; } -export function WorkspacePickerModal({ - open, - onOpenChange, - onSelect, -}: WorkspacePickerModalProps) { +export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) { const [isLoading, setIsLoading] = useState(false); const [directories, setDirectories] = useState([]); const [error, setError] = useState(null); @@ -43,10 +38,10 @@ export function WorkspacePickerModal({ if (result.success && result.directories) { setDirectories(result.directories); } else { - setError(result.error || "Failed to load directories"); + setError(result.error || 'Failed to load directories'); } } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load directories"); + setError(err instanceof Error ? err.message : 'Failed to load directories'); } finally { setIsLoading(false); } @@ -90,12 +85,7 @@ export function WorkspacePickerModal({

{error}

-
@@ -128,9 +118,7 @@ export function WorkspacePickerModal({

{dir.name}

-

- {dir.path} -

+

{dir.path}

))} diff --git a/apps/ui/src/components/layout/index.ts b/apps/ui/src/components/layout/index.ts new file mode 100644 index 000000000..bfed62466 --- /dev/null +++ b/apps/ui/src/components/layout/index.ts @@ -0,0 +1 @@ +export { Sidebar } from './sidebar'; diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index cca6aa228..16b1e5cbe 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -1,241 +1,36 @@ -import { useState, useMemo, useEffect, useCallback, useRef, memo } from 'react'; +import { useState, useCallback } from 'react'; import { useNavigate, useLocation } from '@tanstack/react-router'; import { cn } from '@/lib/utils'; -import { useAppStore, formatShortcut, type ThemeMode } from '@/store/app-store'; -import { - FolderOpen, - Plus, - Settings, - FileText, - LayoutGrid, - Bot, - Folder, - X, - PanelLeft, - PanelLeftClose, - ChevronDown, - Redo2, - Check, - BookOpen, - GripVertical, - RotateCcw, - Trash2, - Undo2, - UserCircle, - MoreVertical, - Palette, - Monitor, - Search, - Bug, - Activity, - Recycle, - Sparkles, - Loader2, - Terminal, - Rocket, - Zap, - CheckCircle2, - ArrowRight, - Moon, - Sun, -} from 'lucide-react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuLabel, -} from '@/components/ui/dropdown-menu'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { - useKeyboardShortcuts, - useKeyboardShortcutsConfig, - KeyboardShortcut, -} from '@/hooks/use-keyboard-shortcuts'; -import { getElectronAPI, Project, TrashedProject, RunningAgent } from '@/lib/electron'; +import { useAppStore, type ThemeMode } from '@/store/app-store'; +import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; +import { getElectronAPI } from '@/lib/electron'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { toast } from 'sonner'; -import { themeOptions } from '@/config/theme-options'; -import type { SpecRegenerationEvent } from '@/types/electron'; import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog'; -import { NewProjectModal } from '@/components/new-project-modal'; +import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; -import type { FeatureCount } from '@/components/views/spec-view/types'; -import { - DndContext, - DragEndEvent, - PointerSensor, - useSensor, - useSensors, - closestCenter, -} from '@dnd-kit/core'; -import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { getHttpApiClient } from '@/lib/http-api-client'; -import type { StarterTemplate } from '@/lib/templates'; - -interface NavSection { - label?: string; - items: NavItem[]; -} - -interface NavItem { - id: string; - label: string; - icon: any; - shortcut?: string; -} - -// Sortable Project Item Component -interface SortableProjectItemProps { - project: Project; - currentProjectId: string | undefined; - isHighlighted: boolean; - onSelect: (project: Project) => void; -} - -function SortableProjectItem({ - project, - currentProjectId, - isHighlighted, - onSelect, -}: SortableProjectItemProps) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: project.id, - }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( -
- {/* Drag Handle */} - - {/* Project content - clickable area */} -
onSelect(project)}> - - {project.name} - {currentProjectId === project.id && } -
-
- ); -} - -// Theme options for project theme selector - derived from the shared config -import { darkThemes, lightThemes } from '@/config/theme-options'; - -const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({ - value: opt.value, - label: opt.label, - icon: opt.Icon, - color: opt.color, -})); - -const PROJECT_LIGHT_THEMES = lightThemes.map((opt) => ({ - value: opt.value, - label: opt.label, - icon: opt.Icon, - color: opt.color, -})); - -// Memoized theme menu item to prevent re-renders during hover -interface ThemeMenuItemProps { - option: { - value: string; - label: string; - icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; - color: string; - }; - onPreviewEnter: (value: string) => void; - onPreviewLeave: (e: React.PointerEvent) => void; -} - -const ThemeMenuItem = memo(function ThemeMenuItem({ - option, - onPreviewEnter, - onPreviewLeave, -}: ThemeMenuItemProps) { - const Icon = option.icon; - return ( -
onPreviewEnter(option.value)} - onPointerLeave={onPreviewLeave} - > - - - {option.label} - -
- ); -}); - -// Reusable Bug Report Button Component -const BugReportButton = ({ - sidebarExpanded, - onClick, -}: { - sidebarExpanded: boolean; - onClick: () => void; -}) => { - return ( - - ); -}; +// Local imports from subfolder +import { + CollapseToggleButton, + SidebarHeader, + ProjectActions, + SidebarNavigation, + ProjectSelectorWithOptions, + SidebarFooter, +} from './sidebar/components'; +import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; +import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants'; +import { + useSidebarAutoCollapse, + useRunningAgents, + useSpecRegeneration, + useNavigation, + useProjectCreation, + useSetupDialog, + useTrashDialog, + useProjectTheme, +} from './sidebar/hooks'; export function Sidebar() { const navigate = useNavigate(); @@ -248,631 +43,117 @@ export function Sidebar() { sidebarOpen, projectHistory, upsertAndSetCurrentProject, - setCurrentProject, toggleSidebar, restoreTrashedProject, deleteTrashedProject, emptyTrash, - reorderProjects, cyclePrevProject, cycleNextProject, - clearProjectHistory, - setProjectTheme, - setTheme, - setPreviewTheme, - theme: globalTheme, moveProjectToTrash, specCreatingForProject, setSpecCreatingForProject, } = useAppStore(); // Environment variable flags for hiding sidebar items - const hideTerminal = import.meta.env.VITE_HIDE_TERMINAL === 'true'; - const hideWiki = import.meta.env.VITE_HIDE_WIKI === 'true'; - const hideRunningAgents = import.meta.env.VITE_HIDE_RUNNING_AGENTS === 'true'; - const hideContext = import.meta.env.VITE_HIDE_CONTEXT === 'true'; - const hideSpecEditor = import.meta.env.VITE_HIDE_SPEC_EDITOR === 'true'; - const hideAiProfiles = import.meta.env.VITE_HIDE_AI_PROFILES === 'true'; + const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } = + SIDEBAR_FEATURE_FLAGS; // Get customizable keyboard shortcuts const shortcuts = useKeyboardShortcutsConfig(); - // State for project picker dropdown + // State for project picker (needed for keyboard shortcuts) const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false); - const [projectSearchQuery, setProjectSearchQuery] = useState(''); - const [selectedProjectIndex, setSelectedProjectIndex] = useState(0); - const [showTrashDialog, setShowTrashDialog] = useState(false); - const [activeTrashId, setActiveTrashId] = useState(null); - const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); // State for delete project confirmation dialog const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); - // State for running agents count - const [runningAgentsCount, setRunningAgentsCount] = useState(0); - - // State for new project modal - const [showNewProjectModal, setShowNewProjectModal] = useState(false); - const [isCreatingProject, setIsCreatingProject] = useState(false); - - // State for new project onboarding dialog - const [showOnboardingDialog, setShowOnboardingDialog] = useState(false); - const [newProjectName, setNewProjectName] = useState(''); - const [newProjectPath, setNewProjectPath] = useState(''); - - // State for new project setup dialog - const [showSetupDialog, setShowSetupDialog] = useState(false); - const [setupProjectPath, setSetupProjectPath] = useState(''); - const [projectOverview, setProjectOverview] = useState(''); - const [generateFeatures, setGenerateFeatures] = useState(true); - const [analyzeProject, setAnalyzeProject] = useState(true); - const [featureCount, setFeatureCount] = useState(50); - const [showSpecIndicator, setShowSpecIndicator] = useState(true); - - // Debounced preview theme handlers to prevent excessive re-renders - const previewTimeoutRef = useRef | null>(null); - - const handlePreviewEnter = useCallback( - (value: string) => { - // Clear any pending timeout - if (previewTimeoutRef.current) { - clearTimeout(previewTimeoutRef.current); - } - // Small delay to debounce rapid hover changes - previewTimeoutRef.current = setTimeout(() => { - setPreviewTheme(value as ThemeMode); - }, 16); // ~1 frame delay - }, - [setPreviewTheme] - ); - - const handlePreviewLeave = useCallback( - (e: React.PointerEvent) => { - const relatedTarget = e.relatedTarget as HTMLElement; - if (!relatedTarget?.closest('[data-testid^="project-theme-"]')) { - // Clear any pending timeout - if (previewTimeoutRef.current) { - clearTimeout(previewTimeoutRef.current); - } - setPreviewTheme(null); - } - }, - [setPreviewTheme] - ); - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (previewTimeoutRef.current) { - clearTimeout(previewTimeoutRef.current); - } - }; - }, []); - - // Derive isCreatingSpec from store state - const isCreatingSpec = specCreatingForProject !== null; - const creatingSpecProjectPath = specCreatingForProject; - - // Ref for project search input - const projectSearchInputRef = useRef(null); - - // Auto-collapse sidebar on small screens - useEffect(() => { - const mediaQuery = window.matchMedia('(max-width: 1024px)'); // lg breakpoint - - const handleResize = () => { - if (mediaQuery.matches && sidebarOpen) { - // Auto-collapse on small screens - toggleSidebar(); - } - }; - - // Check on mount - handleResize(); - - // Listen for changes - mediaQuery.addEventListener('change', handleResize); - return () => mediaQuery.removeEventListener('change', handleResize); - }, [sidebarOpen, toggleSidebar]); - - // Filtered projects based on search query - const filteredProjects = useMemo(() => { - if (!projectSearchQuery.trim()) { - return projects; - } - const query = projectSearchQuery.toLowerCase(); - return projects.filter((project) => project.name.toLowerCase().includes(query)); - }, [projects, projectSearchQuery]); - - // Reset selection when filtered results change - useEffect(() => { - setSelectedProjectIndex(0); - }, [filteredProjects.length, projectSearchQuery]); - - // Reset search query when dropdown closes - useEffect(() => { - if (!isProjectPickerOpen) { - setProjectSearchQuery(''); - setSelectedProjectIndex(0); - } - }, [isProjectPickerOpen]); - - // Focus the search input when dropdown opens - useEffect(() => { - if (isProjectPickerOpen) { - // Small delay to ensure the dropdown is rendered - setTimeout(() => { - projectSearchInputRef.current?.focus(); - }, 0); - } - }, [isProjectPickerOpen]); - - // Sensors for drag-and-drop - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 5, // Small distance to start drag - }, - }) - ); - - // Handle drag end for reordering projects - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - const oldIndex = projects.findIndex((p) => p.id === active.id); - const newIndex = projects.findIndex((p) => p.id === over.id); - - if (oldIndex !== -1 && newIndex !== -1) { - reorderProjects(oldIndex, newIndex); - } - } - }, - [projects, reorderProjects] - ); - - // Subscribe to spec regeneration events - useEffect(() => { - const api = getElectronAPI(); - if (!api.specRegeneration) return; - - const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => { - console.log( - '[Sidebar] Spec regeneration event:', - event.type, - 'for project:', - event.projectPath - ); - - // Only handle events for the project we're currently setting up - if (event.projectPath !== creatingSpecProjectPath && event.projectPath !== setupProjectPath) { - console.log('[Sidebar] Ignoring event - not for project being set up'); - return; - } - - if (event.type === 'spec_regeneration_complete') { - setSpecCreatingForProject(null); - setShowSetupDialog(false); - setProjectOverview(''); - setSetupProjectPath(''); - // Clear onboarding state if we came from onboarding - setNewProjectName(''); - setNewProjectPath(''); - toast.success('App specification created', { - description: 'Your project is now set up and ready to go!', - }); - } else if (event.type === 'spec_regeneration_error') { - setSpecCreatingForProject(null); - toast.error('Failed to create specification', { - description: event.error, - }); - } - }); - - return () => { - unsubscribe(); - }; - }, [creatingSpecProjectPath, setupProjectPath, setSpecCreatingForProject]); - - // Fetch running agents count function - used for initial load and event-driven updates - const fetchRunningAgentsCount = useCallback(async () => { - try { - const api = getElectronAPI(); - if (api.runningAgents) { - const result = await api.runningAgents.getAll(); - if (result.success && result.runningAgents) { - setRunningAgentsCount(result.runningAgents.length); - } - } - } catch (error) { - console.error('[Sidebar] Error fetching running agents count:', error); - } - }, []); - - // Subscribe to auto-mode events to update running agents count in real-time - useEffect(() => { - const api = getElectronAPI(); - if (!api.autoMode) { - // If autoMode is not available, still fetch initial count - fetchRunningAgentsCount(); - return; - } - - // Initial fetch on mount - fetchRunningAgentsCount(); - - const unsubscribe = api.autoMode.onEvent((event) => { - // When a feature starts, completes, or errors, refresh the count - if ( - event.type === 'auto_mode_feature_complete' || - event.type === 'auto_mode_error' || - event.type === 'auto_mode_feature_start' - ) { - fetchRunningAgentsCount(); - } - }); - - return () => { - unsubscribe(); - }; - }, [fetchRunningAgentsCount]); - - // Handle creating initial spec for new project - const handleCreateInitialSpec = useCallback(async () => { - if (!setupProjectPath || !projectOverview.trim()) return; + // Project theme management (must come before useProjectCreation which uses globalTheme) + const { globalTheme } = useProjectTheme(); - // Set store state immediately so the loader shows up right away - setSpecCreatingForProject(setupProjectPath); - setShowSpecIndicator(true); - setShowSetupDialog(false); - - try { - const api = getElectronAPI(); - if (!api.specRegeneration) { - toast.error('Spec regeneration not available'); - setSpecCreatingForProject(null); - return; - } - const result = await api.specRegeneration.create( - setupProjectPath, - projectOverview.trim(), - generateFeatures, - analyzeProject, - generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features - ); + // Project creation state and handlers + const { + showNewProjectModal, + setShowNewProjectModal, + isCreatingProject, + showOnboardingDialog, + setShowOnboardingDialog, + newProjectName, + setNewProjectName, + newProjectPath, + setNewProjectPath, + handleCreateBlankProject, + handleCreateFromTemplate, + handleCreateFromCustomUrl, + } = useProjectCreation({ + trashedProjects, + currentProject, + globalTheme, + upsertAndSetCurrentProject, + }); - if (!result.success) { - console.error('[Sidebar] Failed to start spec creation:', result.error); - setSpecCreatingForProject(null); - toast.error('Failed to create specification', { - description: result.error, - }); - } else { - // Show processing toast to inform user - toast.info('Generating app specification...', { - description: "This may take a minute. You'll be notified when complete.", - }); - } - // If successful, we'll wait for the events to update the state - } catch (error) { - console.error('[Sidebar] Failed to create spec:', error); - setSpecCreatingForProject(null); - toast.error('Failed to create specification', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } - }, [ + // Setup dialog state and handlers + const { + showSetupDialog, + setShowSetupDialog, setupProjectPath, + setSetupProjectPath, projectOverview, + setProjectOverview, generateFeatures, + setGenerateFeatures, analyzeProject, + setAnalyzeProject, featureCount, + setFeatureCount, + handleCreateInitialSpec, + handleSkipSetup, + handleOnboardingGenerateSpec, + handleOnboardingSkip, + } = useSetupDialog({ setSpecCreatingForProject, - ]); - - // Handle skipping setup - const handleSkipSetup = useCallback(() => { - setShowSetupDialog(false); - setProjectOverview(''); - setSetupProjectPath(''); - // Clear onboarding state if we came from onboarding - if (newProjectPath) { - setNewProjectName(''); - setNewProjectPath(''); - } - toast.info('Setup skipped', { - description: 'You can set up your app_spec.txt later from the Spec view.', - }); - }, [newProjectPath]); - - // Handle onboarding dialog - generate spec - const handleOnboardingGenerateSpec = useCallback(() => { - setShowOnboardingDialog(false); - // Navigate to the setup dialog flow - setSetupProjectPath(newProjectPath); - setProjectOverview(''); - setShowSetupDialog(true); - }, [newProjectPath]); - - // Handle onboarding dialog - skip - const handleOnboardingSkip = useCallback(() => { - setShowOnboardingDialog(false); - setNewProjectName(''); - setNewProjectPath(''); - toast.info('You can generate your app_spec.txt anytime from the Spec view', { - description: 'Your project is ready to use!', - }); - }, []); - - /** - * Create a blank project with just .automaker directory structure - */ - const handleCreateBlankProject = useCallback( - async (projectName: string, parentDir: string) => { - setIsCreatingProject(true); - try { - const api = getElectronAPI(); - const projectPath = `${parentDir}/${projectName}`; - - // Create project directory - const mkdirResult = await api.mkdir(projectPath); - if (!mkdirResult.success) { - toast.error('Failed to create project directory', { - description: mkdirResult.error || 'Unknown error occurred', - }); - return; - } - - // Initialize .automaker directory with all necessary files - const initResult = await initializeProject(projectPath); - - if (!initResult.success) { - toast.error('Failed to initialize project', { - description: initResult.error || 'Unknown error occurred', - }); - return; - } - - // Update the app_spec.txt with the project name - // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts - await api.writeFile( - `${projectPath}/.automaker/app_spec.txt`, - ` - ${projectName} - - - Describe your project here. This file will be analyzed by an AI agent - to understand your project structure and tech stack. - - - - - - - - - - - - - -` - ); - - const trashedProject = trashedProjects.find((p) => p.path === projectPath); - const effectiveTheme = - (trashedProject?.theme as ThemeMode | undefined) || - (currentProject?.theme as ThemeMode | undefined) || - globalTheme; - const project = upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); - - setShowNewProjectModal(false); - - // Show onboarding dialog for new project - setNewProjectName(projectName); - setNewProjectPath(projectPath); - setShowOnboardingDialog(true); - - toast.success('Project created', { - description: `Created ${projectName} with .automaker directory`, - }); - } catch (error) { - console.error('[Sidebar] Failed to create project:', error); - toast.error('Failed to create project', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setIsCreatingProject(false); - } - }, - [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] - ); - - /** - * Create a project from a GitHub starter template - */ - const handleCreateFromTemplate = useCallback( - async (template: StarterTemplate, projectName: string, parentDir: string) => { - setIsCreatingProject(true); - try { - const httpClient = getHttpApiClient(); - const api = getElectronAPI(); - - // Clone the template repository - const cloneResult = await httpClient.templates.clone( - template.repoUrl, - projectName, - parentDir - ); - - if (!cloneResult.success || !cloneResult.projectPath) { - toast.error('Failed to clone template', { - description: cloneResult.error || 'Unknown error occurred', - }); - return; - } - - const projectPath = cloneResult.projectPath; - - // Initialize .automaker directory with all necessary files - const initResult = await initializeProject(projectPath); - - if (!initResult.success) { - toast.error('Failed to initialize project', { - description: initResult.error || 'Unknown error occurred', - }); - return; - } - - // Update the app_spec.txt with template-specific info - // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts - await api.writeFile( - `${projectPath}/.automaker/app_spec.txt`, - ` - ${projectName} - - - This project was created from the "${template.name}" starter template. - ${template.description} - - - - ${template.techStack.map((tech) => `${tech}`).join('\n ')} - - - - ${template.features.map((feature) => `${feature}`).join('\n ')} - - - - - -` - ); - - const trashedProject = trashedProjects.find((p) => p.path === projectPath); - const effectiveTheme = - (trashedProject?.theme as ThemeMode | undefined) || - (currentProject?.theme as ThemeMode | undefined) || - globalTheme; - const project = upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); - - setShowNewProjectModal(false); - - // Show onboarding dialog for new project - setNewProjectName(projectName); - setNewProjectPath(projectPath); - setShowOnboardingDialog(true); - - toast.success('Project created from template', { - description: `Created ${projectName} from ${template.name}`, - }); - } catch (error) { - console.error('[Sidebar] Failed to create project from template:', error); - toast.error('Failed to create project', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setIsCreatingProject(false); - } - }, - [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] - ); - - /** - * Create a project from a custom GitHub URL - */ - const handleCreateFromCustomUrl = useCallback( - async (repoUrl: string, projectName: string, parentDir: string) => { - setIsCreatingProject(true); - try { - const httpClient = getHttpApiClient(); - const api = getElectronAPI(); - - // Clone the repository - const cloneResult = await httpClient.templates.clone(repoUrl, projectName, parentDir); - - if (!cloneResult.success || !cloneResult.projectPath) { - toast.error('Failed to clone repository', { - description: cloneResult.error || 'Unknown error occurred', - }); - return; - } - - const projectPath = cloneResult.projectPath; - - // Initialize .automaker directory with all necessary files - const initResult = await initializeProject(projectPath); - - if (!initResult.success) { - toast.error('Failed to initialize project', { - description: initResult.error || 'Unknown error occurred', - }); - return; - } - - // Update the app_spec.txt with basic info - // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts - await api.writeFile( - `${projectPath}/.automaker/app_spec.txt`, - ` - ${projectName} - - - This project was cloned from ${repoUrl}. - The AI agent will analyze the project structure. - - - - - - - - - - - - - -` - ); + newProjectPath, + setNewProjectName, + setNewProjectPath, + setShowOnboardingDialog, + }); - const trashedProject = trashedProjects.find((p) => p.path === projectPath); - const effectiveTheme = - (trashedProject?.theme as ThemeMode | undefined) || - (currentProject?.theme as ThemeMode | undefined) || - globalTheme; - const project = upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); + // Derive isCreatingSpec from store state + const isCreatingSpec = specCreatingForProject !== null; + const creatingSpecProjectPath = specCreatingForProject; - setShowNewProjectModal(false); + // Auto-collapse sidebar on small screens + useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); - // Show onboarding dialog for new project - setNewProjectName(projectName); - setNewProjectPath(projectPath); - setShowOnboardingDialog(true); + // Running agents count + const { runningAgentsCount } = useRunningAgents(); - toast.success('Project created from repository', { - description: `Created ${projectName} from ${repoUrl}`, - }); - } catch (error) { - console.error('[Sidebar] Failed to create project from URL:', error); - toast.error('Failed to create project', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setIsCreatingProject(false); - } - }, - [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] - ); + // Trash dialog and operations + const { + showTrashDialog, + setShowTrashDialog, + activeTrashId, + isEmptyingTrash, + handleRestoreProject, + handleDeleteProjectFromDisk, + handleEmptyTrash, + } = useTrashDialog({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + trashedProjects, + }); - // Handle bug report button click - const handleBugReportClick = useCallback(() => { - const api = getElectronAPI(); - api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); - }, []); + // Spec regeneration events + useSpecRegeneration({ + creatingSpecProjectPath, + setupProjectPath, + setSpecCreatingForProject, + setShowSetupDialog, + setProjectOverview, + setSetupProjectPath, + setNewProjectName, + setNewProjectPath, + }); /** * Opens the system folder selection dialog and initializes the selected project. @@ -908,7 +189,7 @@ export function Sidebar() { (trashedProject?.theme as ThemeMode | undefined) || (currentProject?.theme as ThemeMode | undefined) || globalTheme; - const project = upsertAndSetCurrentProject(path, name, effectiveTheme); + upsertAndSetCurrentProject(path, name, effectiveTheme); // Check if app_spec.txt exists const specExists = await hasAppSpec(path); @@ -938,261 +219,23 @@ export function Sidebar() { } }, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]); - const handleRestoreProject = useCallback( - (projectId: string) => { - restoreTrashedProject(projectId); - toast.success('Project restored', { - description: 'Added back to your project list.', - }); - setShowTrashDialog(false); - }, - [restoreTrashedProject] - ); - - const handleDeleteProjectFromDisk = useCallback( - async (trashedProject: TrashedProject) => { - const confirmed = window.confirm( - `Delete "${trashedProject.name}" from disk?\nThis sends the folder to your system Trash.` - ); - if (!confirmed) return; - - setActiveTrashId(trashedProject.id); - try { - const api = getElectronAPI(); - if (!api.trashItem) { - throw new Error('System Trash is not available in this build.'); - } - - const result = await api.trashItem(trashedProject.path); - if (!result.success) { - throw new Error(result.error || 'Failed to delete project folder'); - } - - deleteTrashedProject(trashedProject.id); - toast.success('Project folder sent to system Trash', { - description: trashedProject.path, - }); - } catch (error) { - console.error('[Sidebar] Failed to delete project from disk:', error); - toast.error('Failed to delete project folder', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setActiveTrashId(null); - } - }, - [deleteTrashedProject] - ); - - const handleEmptyTrash = useCallback(() => { - if (trashedProjects.length === 0) { - setShowTrashDialog(false); - return; - } - - const confirmed = window.confirm( - 'Clear all projects from recycle bin? This does not delete folders from disk.' - ); - if (!confirmed) return; - - setIsEmptyingTrash(true); - try { - emptyTrash(); - toast.success('Recycle bin cleared'); - setShowTrashDialog(false); - } finally { - setIsEmptyingTrash(false); - } - }, [emptyTrash, trashedProjects.length]); - - const navSections: NavSection[] = useMemo(() => { - const allToolsItems: NavItem[] = [ - { - id: 'spec', - label: 'Spec Editor', - icon: FileText, - shortcut: shortcuts.spec, - }, - { - id: 'context', - label: 'Context', - icon: BookOpen, - shortcut: shortcuts.context, - }, - { - id: 'profiles', - label: 'AI Profiles', - icon: UserCircle, - shortcut: shortcuts.profiles, - }, - ]; - - // Filter out hidden items - const visibleToolsItems = allToolsItems.filter((item) => { - if (item.id === 'spec' && hideSpecEditor) { - return false; - } - if (item.id === 'context' && hideContext) { - return false; - } - if (item.id === 'profiles' && hideAiProfiles) { - return false; - } - return true; - }); - - // Build project items - Terminal is conditionally included - const projectItems: NavItem[] = [ - { - id: 'board', - label: 'Kanban Board', - icon: LayoutGrid, - shortcut: shortcuts.board, - }, - { - id: 'agent', - label: 'Agent Runner', - icon: Bot, - shortcut: shortcuts.agent, - }, - ]; - - // Add Terminal to Project section if not hidden - if (!hideTerminal) { - projectItems.push({ - id: 'terminal', - label: 'Terminal', - icon: Terminal, - shortcut: shortcuts.terminal, - }); - } - - return [ - { - label: 'Project', - items: projectItems, - }, - { - label: 'Tools', - items: visibleToolsItems, - }, - ]; - }, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]); - - // Handle selecting the currently highlighted project - const selectHighlightedProject = useCallback(() => { - if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) { - setCurrentProject(filteredProjects[selectedProjectIndex]); - setIsProjectPickerOpen(false); - } - }, [filteredProjects, selectedProjectIndex, setCurrentProject]); - - // Handle keyboard events when project picker is open - useEffect(() => { - if (!isProjectPickerOpen) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsProjectPickerOpen(false); - } else if (event.key === 'Enter') { - event.preventDefault(); - selectHighlightedProject(); - } else if (event.key === 'ArrowDown') { - event.preventDefault(); - setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev)); - } else if (event.key === 'ArrowUp') { - event.preventDefault(); - setSelectedProjectIndex((prev) => (prev > 0 ? prev - 1 : prev)); - } else if (event.key.toLowerCase() === 'p' && !event.metaKey && !event.ctrlKey) { - // Toggle off when P is pressed (not with modifiers) while dropdown is open - // Only if not typing in the search input - if (document.activeElement !== projectSearchInputRef.current) { - event.preventDefault(); - setIsProjectPickerOpen(false); - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isProjectPickerOpen, selectHighlightedProject, filteredProjects.length]); - - // Build keyboard shortcuts for navigation - const navigationShortcuts: KeyboardShortcut[] = useMemo(() => { - const shortcutsList: KeyboardShortcut[] = []; - - // Sidebar toggle shortcut - always available - shortcutsList.push({ - key: shortcuts.toggleSidebar, - action: () => toggleSidebar(), - description: 'Toggle sidebar', - }); - - // Open project shortcut - opens the folder selection dialog directly - shortcutsList.push({ - key: shortcuts.openProject, - action: () => handleOpenFolder(), - description: 'Open folder selection dialog', - }); - - // Project picker shortcut - only when we have projects - if (projects.length > 0) { - shortcutsList.push({ - key: shortcuts.projectPicker, - action: () => setIsProjectPickerOpen((prev) => !prev), - description: 'Toggle project picker', - }); - } - - // Project cycling shortcuts - only when we have project history - if (projectHistory.length > 1) { - shortcutsList.push({ - key: shortcuts.cyclePrevProject, - action: () => cyclePrevProject(), - description: 'Cycle to previous project (MRU)', - }); - shortcutsList.push({ - key: shortcuts.cycleNextProject, - action: () => cycleNextProject(), - description: 'Cycle to next project (LRU)', - }); - } - - // Only enable nav shortcuts if there's a current project - if (currentProject) { - navSections.forEach((section) => { - section.items.forEach((item) => { - if (item.shortcut) { - shortcutsList.push({ - key: item.shortcut, - action: () => navigate({ to: `/${item.id}` as const }), - description: `Navigate to ${item.label}`, - }); - } - }); - }); - - // Add settings shortcut - shortcutsList.push({ - key: shortcuts.settings, - action: () => navigate({ to: '/settings' }), - description: 'Navigate to Settings', - }); - } - - return shortcutsList; - }, [ + // Navigation sections and keyboard shortcuts (defined after handlers) + const { navSections, navigationShortcuts } = useNavigation({ shortcuts, + hideSpecEditor, + hideContext, + hideTerminal, + hideAiProfiles, currentProject, + projects, + projectHistory, navigate, toggleSidebar, - projects.length, handleOpenFolder, - projectHistory.length, + setIsProjectPickerOpen, cyclePrevProject, cycleNextProject, - navSections, - ]); + }); // Register keyboard shortcuts useKeyboardShortcuts(navigationShortcuts); @@ -1217,974 +260,62 @@ export function Sidebar() { )} data-testid="sidebar" > - {/* Floating Collapse Toggle Button - Desktop only - At border intersection */} - +
- {/* Logo */} -
-
navigate({ to: '/' })} - data-testid="logo-button" - > - {!sidebarOpen ? ( -
- - - - - - - - - - - - - - - - - -
- ) : ( -
- - - - - - - - - - - - - - - - - - - automaker. - -
- )} -
- {/* Bug Report Button - Inside logo container when expanded */} - {sidebarOpen && } -
- - {/* Bug Report Button - Collapsed sidebar version */} - {!sidebarOpen && ( -
- -
- )} + {/* Project Actions - Moved above project selector */} {sidebarOpen && ( -
- - - -
+ )} - {/* Project Selector with Cycle Buttons */} - {sidebarOpen && projects.length > 0 && ( -
- - - - - - {/* Search input for type-ahead filtering */} -
-
- - setProjectSearchQuery(e.target.value)} - className={cn( - 'w-full h-9 pl-8 pr-3 text-sm rounded-lg', - 'border border-border bg-background/50', - 'text-foreground placeholder:text-muted-foreground', - 'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50', - 'transition-all duration-200' - )} - data-testid="project-search-input" - /> -
-
- - {filteredProjects.length === 0 ? ( -
- No projects found -
- ) : ( - - p.id)} - strategy={verticalListSortingStrategy} - > -
- {filteredProjects.map((project, index) => ( - { - setCurrentProject(p); - setIsProjectPickerOpen(false); - }} - /> - ))} -
-
-
- )} - - {/* Keyboard hint */} -
-

- arrow navigate{' '} - |{' '} - enter select{' '} - |{' '} - esc close -

-
-
-
- - {/* Project Options Menu - theme and history */} - {currentProject && ( - { - // Clear preview theme when the menu closes - if (!open) { - setPreviewTheme(null); - } - }} - > - - - - - {/* Project Theme Submenu */} - - - - Project Theme - {currentProject.theme && ( - - {currentProject.theme} - - )} - - { - // Clear preview theme when leaving the dropdown - setPreviewTheme(null); - }} - > - {/* Use Global Option */} - { - if (currentProject) { - setPreviewTheme(null); - if (value !== '') { - setTheme(value as any); - } else { - setTheme(globalTheme); - } - setProjectTheme( - currentProject.id, - value === '' ? null : (value as any) - ); - } - }} - > -
handlePreviewEnter(globalTheme)} - onPointerLeave={() => setPreviewTheme(null)} - > - - - Use Global - - ({globalTheme}) - - -
- - {/* Two Column Layout */} -
- {/* Dark Themes Column */} -
-
- - Dark -
-
- {PROJECT_DARK_THEMES.map((option) => ( - - ))} -
-
- {/* Light Themes Column */} -
-
- - Light -
-
- {PROJECT_LIGHT_THEMES.map((option) => ( - - ))} -
-
-
-
-
-
- - {/* Project History Section - only show when there's history */} - {projectHistory.length > 1 && ( - <> - - - Project History - - - - Previous - - {formatShortcut(shortcuts.cyclePrevProject, true)} - - - - - Next - - {formatShortcut(shortcuts.cycleNextProject, true)} - - - - - Clear history - - - )} - - {/* Move to Trash Section */} - - setShowDeleteProjectDialog(true)} - className="text-destructive focus:text-destructive focus:bg-destructive/10" - data-testid="move-project-to-trash" - > - - Move to Trash - -
-
- )} -
- )} - - {/* Nav Items - Scrollable */} - + + +
- {/* Bottom Section - Running Agents / Bug Report / Settings */} -
- {/* Wiki Link */} - {!hideWiki && ( -
- -
- )} - {/* Running Agents Link */} - {!hideRunningAgents && ( -
- -
- )} - {/* Settings Link */} -
- -
-
- - - - Recycle Bin - - Restore projects to the sidebar or delete their folders using your system Trash. - - - - {trashedProjects.length === 0 ? ( -

Recycle bin is empty.

- ) : ( -
- {trashedProjects.map((project) => ( -
-
-

{project.name}

-

{project.path}

-

- Trashed {new Date(project.trashedAt).toLocaleString()} -

-
-
- - - -
-
- ))} -
- )} - - - - {trashedProjects.length > 0 && ( - - )} - -
-
+ + {/* New Project Setup Dialog */} - {/* New Project Onboarding Dialog */} - { - if (!open) { - handleOnboardingSkip(); - } - }} - > - - -
-
- -
-
- Welcome to {newProjectName}! - - Your new project is ready. Let's get you started. - -
-
-
- -
- {/* Main explanation */} -
-

- Would you like to auto-generate your app_spec.txt? This file helps - describe your project and is used to pre-populate your backlog with features to work - on. -

-
- - {/* Benefits list */} -
-
- -
-

Pre-populate your backlog

-

- Automatically generate features based on your project specification -

-
-
-
- -
-

Better AI assistance

-

- Help AI agents understand your project structure and tech stack -

-
-
-
- -
-

Project documentation

-

- Keep a clear record of your project's capabilities and features -

-
-
-
- - {/* Info box */} -
-

- Tip: You can always generate or edit - your app_spec.txt later from the Spec Editor in the sidebar. -

-
-
- - - - - -
-
+ onOpenChange={setShowOnboardingDialog} + newProjectName={newProjectName} + onSkip={handleOnboardingSkip} + onGenerateSpec={handleOnboardingGenerateSpec} + /> {/* Delete Project Confirmation Dialog */} void; +} + +export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) { + return ( +
navigate({ to: '/' })} + data-testid="logo-button" + > + {!sidebarOpen ? ( +
+ + + + + + + + + + + + + + + + + +
+ ) : ( +
+ + + + + + + + + + + + + + + + + + + automaker. + +
+ )} +
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx new file mode 100644 index 000000000..8139dc559 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx @@ -0,0 +1,33 @@ +import { Bug } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; + +interface BugReportButtonProps { + sidebarExpanded: boolean; +} + +export function BugReportButton({ sidebarExpanded }: BugReportButtonProps) { + const handleBugReportClick = useCallback(() => { + const api = getElectronAPI(); + api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + }, []); + + return ( + + ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx b/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx new file mode 100644 index 000000000..4c09056b9 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx @@ -0,0 +1,60 @@ +import { PanelLeft, PanelLeftClose } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; + +interface CollapseToggleButtonProps { + sidebarOpen: boolean; + toggleSidebar: () => void; + shortcut: string; +} + +export function CollapseToggleButton({ + sidebarOpen, + toggleSidebar, + shortcut, +}: CollapseToggleButtonProps) { + return ( + + ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/index.ts b/apps/ui/src/components/layout/sidebar/components/index.ts new file mode 100644 index 000000000..f559795ce --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/index.ts @@ -0,0 +1,10 @@ +export { SortableProjectItem } from './sortable-project-item'; +export { ThemeMenuItem } from './theme-menu-item'; +export { BugReportButton } from './bug-report-button'; +export { CollapseToggleButton } from './collapse-toggle-button'; +export { AutomakerLogo } from './automaker-logo'; +export { SidebarHeader } from './sidebar-header'; +export { ProjectActions } from './project-actions'; +export { SidebarNavigation } from './sidebar-navigation'; +export { ProjectSelectorWithOptions } from './project-selector-with-options'; +export { SidebarFooter } from './sidebar-footer'; diff --git a/apps/ui/src/components/layout/sidebar/components/project-actions.tsx b/apps/ui/src/components/layout/sidebar/components/project-actions.tsx new file mode 100644 index 000000000..3730afe79 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/project-actions.tsx @@ -0,0 +1,91 @@ +import { Plus, FolderOpen, Recycle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; +import type { TrashedProject } from '@/lib/electron'; + +interface ProjectActionsProps { + setShowNewProjectModal: (show: boolean) => void; + handleOpenFolder: () => void; + setShowTrashDialog: (show: boolean) => void; + trashedProjects: TrashedProject[]; + shortcuts: { + openProject: string; + }; +} + +export function ProjectActions({ + setShowNewProjectModal, + handleOpenFolder, + setShowTrashDialog, + trashedProjects, + shortcuts, +}: ProjectActionsProps) { + return ( +
+ + + +
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx new file mode 100644 index 000000000..d5b248998 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx @@ -0,0 +1,374 @@ +import { + Folder, + ChevronDown, + MoreVertical, + Palette, + Monitor, + Moon, + Sun, + Undo2, + Redo2, + RotateCcw, + Trash2, + Search, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuLabel, +} from '@/components/ui/dropdown-menu'; +import { DndContext, closestCenter } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { SortableProjectItem, ThemeMenuItem } from './'; +import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '../constants'; +import { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks'; +import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; + +interface ProjectSelectorWithOptionsProps { + sidebarOpen: boolean; + isProjectPickerOpen: boolean; + setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; + setShowDeleteProjectDialog: (show: boolean) => void; +} + +export function ProjectSelectorWithOptions({ + sidebarOpen, + isProjectPickerOpen, + setIsProjectPickerOpen, + setShowDeleteProjectDialog, +}: ProjectSelectorWithOptionsProps) { + // Get data from store + const { + projects, + currentProject, + projectHistory, + setCurrentProject, + reorderProjects, + cyclePrevProject, + cycleNextProject, + clearProjectHistory, + } = useAppStore(); + + // Get keyboard shortcuts + const shortcuts = useKeyboardShortcutsConfig(); + const { + projectSearchQuery, + setProjectSearchQuery, + selectedProjectIndex, + projectSearchInputRef, + filteredProjects, + } = useProjectPicker({ + projects, + isProjectPickerOpen, + setIsProjectPickerOpen, + setCurrentProject, + }); + + // Drag-and-drop handlers + const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); + + // Theme management + const { + globalTheme, + setTheme, + setProjectTheme, + setPreviewTheme, + handlePreviewEnter, + handlePreviewLeave, + } = useProjectTheme(); + + if (!sidebarOpen || projects.length === 0) { + return null; + } + + return ( +
+ + + + + + {/* Search input for type-ahead filtering */} +
+
+ + setProjectSearchQuery(e.target.value)} + className={cn( + 'w-full h-9 pl-8 pr-3 text-sm rounded-lg', + 'border border-border bg-background/50', + 'text-foreground placeholder:text-muted-foreground', + 'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50', + 'transition-all duration-200' + )} + data-testid="project-search-input" + /> +
+
+ + {filteredProjects.length === 0 ? ( +
+ No projects found +
+ ) : ( + + p.id)} + strategy={verticalListSortingStrategy} + > +
+ {filteredProjects.map((project, index) => ( + { + setCurrentProject(p); + setIsProjectPickerOpen(false); + }} + /> + ))} +
+
+
+ )} + + {/* Keyboard hint */} +
+

+ arrow navigate{' '} + |{' '} + enter select{' '} + |{' '} + esc close +

+
+
+
+ + {/* Project Options Menu - theme and history */} + {currentProject && ( + { + // Clear preview theme when the menu closes + if (!open) { + setPreviewTheme(null); + } + }} + > + + + + + {/* Project Theme Submenu */} + + + + Project Theme + {currentProject.theme && ( + + {currentProject.theme} + + )} + + { + // Clear preview theme when leaving the dropdown + setPreviewTheme(null); + }} + > + {/* Use Global Option */} + { + if (currentProject) { + setPreviewTheme(null); + if (value !== '') { + setTheme(value as ThemeMode); + } else { + setTheme(globalTheme); + } + setProjectTheme( + currentProject.id, + value === '' ? null : (value as ThemeMode) + ); + } + }} + > +
handlePreviewEnter(globalTheme)} + onPointerLeave={() => setPreviewTheme(null)} + > + + + Use Global + + ({globalTheme}) + + +
+ + {/* Two Column Layout */} +
+ {/* Dark Themes Column */} +
+
+ + Dark +
+
+ {PROJECT_DARK_THEMES.map((option) => ( + + ))} +
+
+ {/* Light Themes Column */} +
+
+ + Light +
+
+ {PROJECT_LIGHT_THEMES.map((option) => ( + + ))} +
+
+
+
+
+
+ + {/* Project History Section - only show when there's history */} + {projectHistory.length > 1 && ( + <> + + + Project History + + + + Previous + + {formatShortcut(shortcuts.cyclePrevProject, true)} + + + + + Next + + {formatShortcut(shortcuts.cycleNextProject, true)} + + + + + Clear history + + + )} + + {/* Move to Trash Section */} + + setShowDeleteProjectDialog(true)} + className="text-destructive focus:text-destructive focus:bg-destructive/10" + data-testid="move-project-to-trash" + > + + Move to Trash + +
+
+ )} +
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx new file mode 100644 index 000000000..664797b6b --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx @@ -0,0 +1,269 @@ +import type { NavigateOptions } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; +import { BookOpen, Activity, Settings } from 'lucide-react'; + +interface SidebarFooterProps { + sidebarOpen: boolean; + isActiveRoute: (id: string) => boolean; + navigate: (opts: NavigateOptions) => void; + hideWiki: boolean; + hideRunningAgents: boolean; + runningAgentsCount: number; + shortcuts: { + settings: string; + }; +} + +export function SidebarFooter({ + sidebarOpen, + isActiveRoute, + navigate, + hideWiki, + hideRunningAgents, + runningAgentsCount, + shortcuts, +}: SidebarFooterProps) { + return ( +
+ {/* Wiki Link */} + {!hideWiki && ( +
+ +
+ )} + {/* Running Agents Link */} + {!hideRunningAgents && ( +
+ +
+ )} + {/* Settings Link */} +
+ +
+
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx new file mode 100644 index 000000000..093474c0b --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -0,0 +1,39 @@ +import type { NavigateOptions } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { AutomakerLogo } from './automaker-logo'; +import { BugReportButton } from './bug-report-button'; + +interface SidebarHeaderProps { + sidebarOpen: boolean; + navigate: (opts: NavigateOptions) => void; +} + +export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) { + return ( + <> + {/* Logo */} +
+ + {/* Bug Report Button - Inside logo container when expanded */} + {sidebarOpen && } +
+ + {/* Bug Report Button - Collapsed sidebar version */} + {!sidebarOpen && ( +
+ +
+ )} + + ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx new file mode 100644 index 000000000..4e0f7cf11 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -0,0 +1,140 @@ +import type { NavigateOptions } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; +import type { NavSection } from '../types'; +import type { Project } from '@/lib/electron'; + +interface SidebarNavigationProps { + currentProject: Project | null; + sidebarOpen: boolean; + navSections: NavSection[]; + isActiveRoute: (id: string) => boolean; + navigate: (opts: NavigateOptions) => void; +} + +export function SidebarNavigation({ + currentProject, + sidebarOpen, + navSections, + isActiveRoute, + navigate, +}: SidebarNavigationProps) { + return ( + + ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/sortable-project-item.tsx b/apps/ui/src/components/layout/sidebar/components/sortable-project-item.tsx new file mode 100644 index 000000000..9d1e567e0 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/sortable-project-item.tsx @@ -0,0 +1,54 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Folder, Check, GripVertical } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { SortableProjectItemProps } from '../types'; + +export function SortableProjectItem({ + project, + currentProjectId, + isHighlighted, + onSelect, +}: SortableProjectItemProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: project.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+ {/* Drag Handle */} + + + {/* Project content - clickable area */} +
onSelect(project)}> + + {project.name} + {currentProjectId === project.id && } +
+
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/theme-menu-item.tsx b/apps/ui/src/components/layout/sidebar/components/theme-menu-item.tsx new file mode 100644 index 000000000..5d9749b2a --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/theme-menu-item.tsx @@ -0,0 +1,27 @@ +import { memo } from 'react'; +import { DropdownMenuRadioItem } from '@/components/ui/dropdown-menu'; +import type { ThemeMenuItemProps } from '../types'; + +export const ThemeMenuItem = memo(function ThemeMenuItem({ + option, + onPreviewEnter, + onPreviewLeave, +}: ThemeMenuItemProps) { + const Icon = option.icon; + return ( +
onPreviewEnter(option.value)} + onPointerLeave={onPreviewLeave} + > + + + {option.label} + +
+ ); +}); diff --git a/apps/ui/src/components/layout/sidebar/constants.ts b/apps/ui/src/components/layout/sidebar/constants.ts new file mode 100644 index 000000000..4beca9530 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/constants.ts @@ -0,0 +1,24 @@ +import { darkThemes, lightThemes } from '@/config/theme-options'; + +export const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({ + value: opt.value, + label: opt.label, + icon: opt.Icon, + color: opt.color, +})); + +export const PROJECT_LIGHT_THEMES = lightThemes.map((opt) => ({ + value: opt.value, + label: opt.label, + icon: opt.Icon, + color: opt.color, +})); + +export const SIDEBAR_FEATURE_FLAGS = { + hideTerminal: import.meta.env.VITE_HIDE_TERMINAL === 'true', + hideWiki: import.meta.env.VITE_HIDE_WIKI === 'true', + hideRunningAgents: import.meta.env.VITE_HIDE_RUNNING_AGENTS === 'true', + hideContext: import.meta.env.VITE_HIDE_CONTEXT === 'true', + hideSpecEditor: import.meta.env.VITE_HIDE_SPEC_EDITOR === 'true', + hideAiProfiles: import.meta.env.VITE_HIDE_AI_PROFILES === 'true', +} as const; diff --git a/apps/ui/src/components/layout/sidebar/dialogs/index.ts b/apps/ui/src/components/layout/sidebar/dialogs/index.ts new file mode 100644 index 000000000..9b9235df5 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/dialogs/index.ts @@ -0,0 +1,2 @@ +export { TrashDialog } from './trash-dialog'; +export { OnboardingDialog } from './onboarding-dialog'; diff --git a/apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx b/apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx new file mode 100644 index 000000000..4a9e35589 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx @@ -0,0 +1,122 @@ +import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +interface OnboardingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + newProjectName: string; + onSkip: () => void; + onGenerateSpec: () => void; +} + +export function OnboardingDialog({ + open, + onOpenChange, + newProjectName, + onSkip, + onGenerateSpec, +}: OnboardingDialogProps) { + return ( + { + if (!isOpen) { + onSkip(); + } + onOpenChange(isOpen); + }} + > + + +
+
+ +
+
+ Welcome to {newProjectName}! + + Your new project is ready. Let's get you started. + +
+
+
+ +
+ {/* Main explanation */} +
+

+ Would you like to auto-generate your app_spec.txt? This file helps + describe your project and is used to pre-populate your backlog with features to work + on. +

+
+ + {/* Benefits list */} +
+
+ +
+

Pre-populate your backlog

+

+ Automatically generate features based on your project specification +

+
+
+
+ +
+

Better AI assistance

+

+ Help AI agents understand your project structure and tech stack +

+
+
+
+ +
+

Project documentation

+

+ Keep a clear record of your project's capabilities and features +

+
+
+
+ + {/* Info box */} +
+

+ Tip: You can always generate or edit your + app_spec.txt later from the Spec Editor in the sidebar. +

+
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx b/apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx new file mode 100644 index 000000000..bb2314367 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx @@ -0,0 +1,116 @@ +import { X, Trash2, Undo2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import type { TrashedProject } from '@/lib/electron'; + +interface TrashDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trashedProjects: TrashedProject[]; + activeTrashId: string | null; + handleRestoreProject: (id: string) => void; + handleDeleteProjectFromDisk: (project: TrashedProject) => void; + deleteTrashedProject: (id: string) => void; + handleEmptyTrash: () => void; + isEmptyingTrash: boolean; +} + +export function TrashDialog({ + open, + onOpenChange, + trashedProjects, + activeTrashId, + handleRestoreProject, + handleDeleteProjectFromDisk, + deleteTrashedProject, + handleEmptyTrash, + isEmptyingTrash, +}: TrashDialogProps) { + return ( + + + + Recycle Bin + + Restore projects to the sidebar or delete their folders using your system Trash. + + + + {trashedProjects.length === 0 ? ( +

Recycle bin is empty.

+ ) : ( +
+ {trashedProjects.map((project) => ( +
+
+

{project.name}

+

{project.path}

+

+ Trashed {new Date(project.trashedAt).toLocaleString()} +

+
+
+ + + +
+
+ ))} +
+ )} + + + + {trashedProjects.length > 0 && ( + + )} + +
+
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/index.ts b/apps/ui/src/components/layout/sidebar/hooks/index.ts new file mode 100644 index 000000000..7a047f8a9 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/index.ts @@ -0,0 +1,12 @@ +export { useThemePreview } from './use-theme-preview'; +export { useSidebarAutoCollapse } from './use-sidebar-auto-collapse'; +export { useDragAndDrop } from './use-drag-and-drop'; +export { useRunningAgents } from './use-running-agents'; +export { useTrashOperations } from './use-trash-operations'; +export { useProjectPicker } from './use-project-picker'; +export { useSpecRegeneration } from './use-spec-regeneration'; +export { useNavigation } from './use-navigation'; +export { useProjectCreation } from './use-project-creation'; +export { useSetupDialog } from './use-setup-dialog'; +export { useTrashDialog } from './use-trash-dialog'; +export { useProjectTheme } from './use-project-theme'; diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts b/apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts new file mode 100644 index 000000000..570264a4b --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useSensors, useSensor, PointerSensor, type DragEndEvent } from '@dnd-kit/core'; +import type { Project } from '@/lib/electron'; + +interface UseDragAndDropProps { + projects: Project[]; + reorderProjects: (oldIndex: number, newIndex: number) => void; +} + +export function useDragAndDrop({ projects, reorderProjects }: UseDragAndDropProps) { + // Sensors for drag-and-drop + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 5, // Small distance to start drag + }, + }) + ); + + // Handle drag end for reordering projects + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = projects.findIndex((p) => p.id === active.id); + const newIndex = projects.findIndex((p) => p.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + reorderProjects(oldIndex, newIndex); + } + } + }, + [projects, reorderProjects] + ); + + return { + sensors, + handleDragEnd, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts new file mode 100644 index 000000000..3148ede0f --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -0,0 +1,211 @@ +import { useMemo } from 'react'; +import type { NavigateOptions } from '@tanstack/react-router'; +import { FileText, LayoutGrid, Bot, BookOpen, UserCircle, Terminal } from 'lucide-react'; +import type { NavSection, NavItem } from '../types'; +import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; +import type { Project } from '@/lib/electron'; + +interface UseNavigationProps { + shortcuts: { + toggleSidebar: string; + openProject: string; + projectPicker: string; + cyclePrevProject: string; + cycleNextProject: string; + spec: string; + context: string; + profiles: string; + board: string; + agent: string; + terminal: string; + settings: string; + }; + hideSpecEditor: boolean; + hideContext: boolean; + hideTerminal: boolean; + hideAiProfiles: boolean; + currentProject: Project | null; + projects: Project[]; + projectHistory: string[]; + navigate: (opts: NavigateOptions) => void; + toggleSidebar: () => void; + handleOpenFolder: () => void; + setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; + cyclePrevProject: () => void; + cycleNextProject: () => void; +} + +export function useNavigation({ + shortcuts, + hideSpecEditor, + hideContext, + hideTerminal, + hideAiProfiles, + currentProject, + projects, + projectHistory, + navigate, + toggleSidebar, + handleOpenFolder, + setIsProjectPickerOpen, + cyclePrevProject, + cycleNextProject, +}: UseNavigationProps) { + // Build navigation sections + const navSections: NavSection[] = useMemo(() => { + const allToolsItems: NavItem[] = [ + { + id: 'spec', + label: 'Spec Editor', + icon: FileText, + shortcut: shortcuts.spec, + }, + { + id: 'context', + label: 'Context', + icon: BookOpen, + shortcut: shortcuts.context, + }, + { + id: 'profiles', + label: 'AI Profiles', + icon: UserCircle, + shortcut: shortcuts.profiles, + }, + ]; + + // Filter out hidden items + const visibleToolsItems = allToolsItems.filter((item) => { + if (item.id === 'spec' && hideSpecEditor) { + return false; + } + if (item.id === 'context' && hideContext) { + return false; + } + if (item.id === 'profiles' && hideAiProfiles) { + return false; + } + return true; + }); + + // Build project items - Terminal is conditionally included + const projectItems: NavItem[] = [ + { + id: 'board', + label: 'Kanban Board', + icon: LayoutGrid, + shortcut: shortcuts.board, + }, + { + id: 'agent', + label: 'Agent Runner', + icon: Bot, + shortcut: shortcuts.agent, + }, + ]; + + // Add Terminal to Project section if not hidden + if (!hideTerminal) { + projectItems.push({ + id: 'terminal', + label: 'Terminal', + icon: Terminal, + shortcut: shortcuts.terminal, + }); + } + + return [ + { + label: 'Project', + items: projectItems, + }, + { + label: 'Tools', + items: visibleToolsItems, + }, + ]; + }, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]); + + // Build keyboard shortcuts for navigation + const navigationShortcuts: KeyboardShortcut[] = useMemo(() => { + const shortcutsList: KeyboardShortcut[] = []; + + // Sidebar toggle shortcut - always available + shortcutsList.push({ + key: shortcuts.toggleSidebar, + action: () => toggleSidebar(), + description: 'Toggle sidebar', + }); + + // Open project shortcut - opens the folder selection dialog directly + shortcutsList.push({ + key: shortcuts.openProject, + action: () => handleOpenFolder(), + description: 'Open folder selection dialog', + }); + + // Project picker shortcut - only when we have projects + if (projects.length > 0) { + shortcutsList.push({ + key: shortcuts.projectPicker, + action: () => setIsProjectPickerOpen((prev) => !prev), + description: 'Toggle project picker', + }); + } + + // Project cycling shortcuts - only when we have project history + if (projectHistory.length > 1) { + shortcutsList.push({ + key: shortcuts.cyclePrevProject, + action: () => cyclePrevProject(), + description: 'Cycle to previous project (MRU)', + }); + shortcutsList.push({ + key: shortcuts.cycleNextProject, + action: () => cycleNextProject(), + description: 'Cycle to next project (LRU)', + }); + } + + // Only enable nav shortcuts if there's a current project + if (currentProject) { + navSections.forEach((section) => { + section.items.forEach((item) => { + if (item.shortcut) { + shortcutsList.push({ + key: item.shortcut, + action: () => navigate({ to: `/${item.id}` as const }), + description: `Navigate to ${item.label}`, + }); + } + }); + }); + + // Add settings shortcut + shortcutsList.push({ + key: shortcuts.settings, + action: () => navigate({ to: '/settings' }), + description: 'Navigate to Settings', + }); + } + + return shortcutsList; + }, [ + shortcuts, + currentProject, + navigate, + toggleSidebar, + projects.length, + handleOpenFolder, + projectHistory.length, + cyclePrevProject, + cycleNextProject, + navSections, + setIsProjectPickerOpen, + ]); + + return { + navSections, + navigationShortcuts, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts new file mode 100644 index 000000000..c50c3d76b --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts @@ -0,0 +1,279 @@ +import { useState, useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; +import { initializeProject } from '@/lib/project-init'; +import { toast } from 'sonner'; +import type { StarterTemplate } from '@/lib/templates'; +import type { ThemeMode } from '@/store/app-store'; +import type { TrashedProject, Project } from '@/lib/electron'; + +interface UseProjectCreationProps { + trashedProjects: TrashedProject[]; + currentProject: Project | null; + globalTheme: ThemeMode; + upsertAndSetCurrentProject: (path: string, name: string, theme: ThemeMode) => Project; +} + +export function useProjectCreation({ + trashedProjects, + currentProject, + globalTheme, + upsertAndSetCurrentProject, +}: UseProjectCreationProps) { + // Modal state + const [showNewProjectModal, setShowNewProjectModal] = useState(false); + const [isCreatingProject, setIsCreatingProject] = useState(false); + + // Onboarding state + const [showOnboardingDialog, setShowOnboardingDialog] = useState(false); + const [newProjectName, setNewProjectName] = useState(''); + const [newProjectPath, setNewProjectPath] = useState(''); + + /** + * Common logic for all project creation flows + */ + const finalizeProjectCreation = useCallback( + async (projectPath: string, projectName: string) => { + try { + // Initialize .automaker directory structure + await initializeProject(projectPath); + + // Write initial app_spec.txt with proper XML structure + // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts + const api = getElectronAPI(); + await api.fs.writeFile( + `${projectPath}/.automaker/app_spec.txt`, + ` + ${projectName} + + + Describe your project here. This file will be analyzed by an AI agent + to understand your project structure and tech stack. + + + + + + + + + + + + + +` + ); + + // Determine theme: try trashed project theme, then current project theme, then global + const trashedProject = trashedProjects.find((p) => p.path === projectPath); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + + upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); + + setShowNewProjectModal(false); + + // Show onboarding dialog for new project + setNewProjectName(projectName); + setNewProjectPath(projectPath); + setShowOnboardingDialog(true); + + toast.success('Project created successfully'); + } catch (error) { + console.error('[ProjectCreation] Failed to finalize project:', error); + toast.error('Failed to initialize project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + throw error; + } + }, + [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] + ); + + /** + * Create a blank project with .automaker structure + */ + const handleCreateBlankProject = useCallback( + async (projectName: string, parentDir: string) => { + setIsCreatingProject(true); + try { + const api = getElectronAPI(); + const projectPath = `${parentDir}/${projectName}`; + + // Create project directory + await api.fs.createFolder(projectPath); + + // Finalize project setup + await finalizeProjectCreation(projectPath, projectName); + } catch (error) { + console.error('[ProjectCreation] Failed to create blank project:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreatingProject(false); + } + }, + [finalizeProjectCreation] + ); + + /** + * Create project from a starter template + */ + const handleCreateFromTemplate = useCallback( + async (template: StarterTemplate, projectName: string, parentDir: string) => { + setIsCreatingProject(true); + try { + const api = getElectronAPI(); + const projectPath = `${parentDir}/${projectName}`; + + // Clone template repository + await api.git.clone(template.githubUrl, projectPath); + + // Initialize .automaker directory structure + await initializeProject(projectPath); + + // Write app_spec.txt with template-specific info + await api.fs.writeFile( + `${projectPath}/.automaker/app_spec.txt`, + ` + ${projectName} + + + This project was created from the "${template.name}" starter template. + ${template.description} + + + + ${template.techStack.map((tech) => `${tech}`).join('\n ')} + + + + ${template.features.map((feature) => `${feature}`).join('\n ')} + + + + + +` + ); + + // Determine theme + const trashedProject = trashedProjects.find((p) => p.path === projectPath); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + + upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); + setShowNewProjectModal(false); + setNewProjectName(projectName); + setNewProjectPath(projectPath); + setShowOnboardingDialog(true); + + toast.success('Project created from template', { + description: `Created ${projectName} from ${template.name}`, + }); + } catch (error) { + console.error('[ProjectCreation] Failed to create from template:', error); + toast.error('Failed to create project from template', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreatingProject(false); + } + }, + [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] + ); + + /** + * Create project from a custom GitHub URL + */ + const handleCreateFromCustomUrl = useCallback( + async (repoUrl: string, projectName: string, parentDir: string) => { + setIsCreatingProject(true); + try { + const api = getElectronAPI(); + const projectPath = `${parentDir}/${projectName}`; + + // Clone custom repository + await api.git.clone(repoUrl, projectPath); + + // Initialize .automaker directory structure + await initializeProject(projectPath); + + // Write app_spec.txt with custom URL info + await api.fs.writeFile( + `${projectPath}/.automaker/app_spec.txt`, + ` + ${projectName} + + + This project was cloned from ${repoUrl}. + The AI agent will analyze the project structure. + + + + + + + + + + + + + +` + ); + + // Determine theme + const trashedProject = trashedProjects.find((p) => p.path === projectPath); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + + upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); + setShowNewProjectModal(false); + setNewProjectName(projectName); + setNewProjectPath(projectPath); + setShowOnboardingDialog(true); + + toast.success('Project created from repository', { + description: `Created ${projectName} from ${repoUrl}`, + }); + } catch (error) { + console.error('[ProjectCreation] Failed to create from custom URL:', error); + toast.error('Failed to create project from URL', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreatingProject(false); + } + }, + [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] + ); + + return { + // Modal state + showNewProjectModal, + setShowNewProjectModal, + isCreatingProject, + + // Onboarding state + showOnboardingDialog, + setShowOnboardingDialog, + newProjectName, + setNewProjectName, + newProjectPath, + setNewProjectPath, + + // Handlers + handleCreateBlankProject, + handleCreateFromTemplate, + handleCreateFromCustomUrl, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts new file mode 100644 index 000000000..7a8566dcd --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts @@ -0,0 +1,105 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import type { Project } from '@/lib/electron'; + +interface UseProjectPickerProps { + projects: Project[]; + isProjectPickerOpen: boolean; + setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; + setCurrentProject: (project: Project) => void; +} + +export function useProjectPicker({ + projects, + isProjectPickerOpen, + setIsProjectPickerOpen, + setCurrentProject, +}: UseProjectPickerProps) { + const [projectSearchQuery, setProjectSearchQuery] = useState(''); + const [selectedProjectIndex, setSelectedProjectIndex] = useState(0); + const projectSearchInputRef = useRef(null); + + // Filtered projects based on search query + const filteredProjects = useMemo(() => { + if (!projectSearchQuery.trim()) { + return projects; + } + const query = projectSearchQuery.toLowerCase(); + return projects.filter((project) => project.name.toLowerCase().includes(query)); + }, [projects, projectSearchQuery]); + + // Reset selection when filtered results change + useEffect(() => { + setSelectedProjectIndex(0); + }, [filteredProjects.length, projectSearchQuery]); + + // Reset search query when dropdown closes + useEffect(() => { + if (!isProjectPickerOpen) { + setProjectSearchQuery(''); + setSelectedProjectIndex(0); + } + }, [isProjectPickerOpen]); + + // Focus the search input when dropdown opens + useEffect(() => { + if (isProjectPickerOpen) { + // Small delay to ensure the dropdown is rendered + setTimeout(() => { + projectSearchInputRef.current?.focus(); + }, 0); + } + }, [isProjectPickerOpen]); + + // Handle selecting the currently highlighted project + const selectHighlightedProject = useCallback(() => { + if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) { + setCurrentProject(filteredProjects[selectedProjectIndex]); + setIsProjectPickerOpen(false); + } + }, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]); + + // Handle keyboard events when project picker is open + useEffect(() => { + if (!isProjectPickerOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsProjectPickerOpen(false); + } else if (event.key === 'Enter') { + event.preventDefault(); + selectHighlightedProject(); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev)); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setSelectedProjectIndex((prev) => (prev > 0 ? prev - 1 : prev)); + } else if (event.key.toLowerCase() === 'p' && !event.metaKey && !event.ctrlKey) { + // Toggle off when P is pressed (not with modifiers) while dropdown is open + // Only if not typing in the search input + if (document.activeElement !== projectSearchInputRef.current) { + event.preventDefault(); + setIsProjectPickerOpen(false); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + isProjectPickerOpen, + selectHighlightedProject, + filteredProjects.length, + setIsProjectPickerOpen, + ]); + + return { + projectSearchQuery, + setProjectSearchQuery, + selectedProjectIndex, + setSelectedProjectIndex, + projectSearchInputRef, + filteredProjects, + selectHighlightedProject, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts new file mode 100644 index 000000000..b80e605dc --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts @@ -0,0 +1,25 @@ +import { useAppStore } from '@/store/app-store'; +import { useThemePreview } from './use-theme-preview'; + +/** + * Hook that manages project theme state and preview handlers + */ +export function useProjectTheme() { + // Get theme-related values from store + const { theme: globalTheme, setTheme, setProjectTheme, setPreviewTheme } = useAppStore(); + + // Get debounced preview handlers + const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); + + return { + // Theme state + globalTheme, + setTheme, + setProjectTheme, + setPreviewTheme, + + // Preview handlers + handlePreviewEnter, + handlePreviewLeave, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts b/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts new file mode 100644 index 000000000..7431e9340 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts @@ -0,0 +1,53 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; + +export function useRunningAgents() { + const [runningAgentsCount, setRunningAgentsCount] = useState(0); + + // Fetch running agents count function - used for initial load and event-driven updates + const fetchRunningAgentsCount = useCallback(async () => { + try { + const api = getElectronAPI(); + if (api.runningAgents) { + const result = await api.runningAgents.getAll(); + if (result.success && result.runningAgents) { + setRunningAgentsCount(result.runningAgents.length); + } + } + } catch (error) { + console.error('[Sidebar] Error fetching running agents count:', error); + } + }, []); + + // Subscribe to auto-mode events to update running agents count in real-time + useEffect(() => { + const api = getElectronAPI(); + if (!api.autoMode) { + // If autoMode is not available, still fetch initial count + fetchRunningAgentsCount(); + return; + } + + // Initial fetch on mount + fetchRunningAgentsCount(); + + const unsubscribe = api.autoMode.onEvent((event) => { + // When a feature starts, completes, or errors, refresh the count + if ( + event.type === 'auto_mode_feature_complete' || + event.type === 'auto_mode_error' || + event.type === 'auto_mode_feature_start' + ) { + fetchRunningAgentsCount(); + } + }); + + return () => { + unsubscribe(); + }; + }, [fetchRunningAgentsCount]); + + return { + runningAgentsCount, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts b/apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts new file mode 100644 index 000000000..8a94fd189 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts @@ -0,0 +1,147 @@ +import { useState, useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; +import { toast } from 'sonner'; +import type { FeatureCount } from '@/components/views/spec-view/types'; + +interface UseSetupDialogProps { + setSpecCreatingForProject: (path: string | null) => void; + newProjectPath: string; + setNewProjectName: (name: string) => void; + setNewProjectPath: (path: string) => void; + setShowOnboardingDialog: (show: boolean) => void; +} + +export function useSetupDialog({ + setSpecCreatingForProject, + newProjectPath, + setNewProjectName, + setNewProjectPath, + setShowOnboardingDialog, +}: UseSetupDialogProps) { + // Setup dialog state + const [showSetupDialog, setShowSetupDialog] = useState(false); + const [setupProjectPath, setSetupProjectPath] = useState(''); + const [projectOverview, setProjectOverview] = useState(''); + const [generateFeatures, setGenerateFeatures] = useState(true); + const [analyzeProject, setAnalyzeProject] = useState(true); + const [featureCount, setFeatureCount] = useState(50); + + /** + * Handle creating initial spec for new project + */ + const handleCreateInitialSpec = useCallback(async () => { + if (!setupProjectPath || !projectOverview.trim()) return; + + // Set store state immediately so the loader shows up right away + setSpecCreatingForProject(setupProjectPath); + setShowSetupDialog(false); + + try { + const api = getElectronAPI(); + if (!api.specRegeneration) { + toast.error('Spec regeneration not available'); + setSpecCreatingForProject(null); + return; + } + + const result = await api.specRegeneration.create( + setupProjectPath, + projectOverview.trim(), + generateFeatures, + analyzeProject, + generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features + ); + + if (!result.success) { + console.error('[SetupDialog] Failed to start spec creation:', result.error); + setSpecCreatingForProject(null); + toast.error('Failed to create specification', { + description: result.error, + }); + } else { + // Show processing toast to inform user + toast.info('Generating app specification...', { + description: "This may take a minute. You'll be notified when complete.", + }); + } + // If successful, we'll wait for the events to update the state + } catch (error) { + console.error('[SetupDialog] Failed to create spec:', error); + setSpecCreatingForProject(null); + toast.error('Failed to create specification', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, [ + setupProjectPath, + projectOverview, + generateFeatures, + analyzeProject, + featureCount, + setSpecCreatingForProject, + ]); + + /** + * Handle skipping setup + */ + const handleSkipSetup = useCallback(() => { + setShowSetupDialog(false); + setProjectOverview(''); + setSetupProjectPath(''); + + // Clear onboarding state if we came from onboarding + if (newProjectPath) { + setNewProjectName(''); + setNewProjectPath(''); + } + + toast.info('Setup skipped', { + description: 'You can set up your app_spec.txt later from the Spec view.', + }); + }, [newProjectPath, setNewProjectName, setNewProjectPath]); + + /** + * Handle onboarding dialog - generate spec + */ + const handleOnboardingGenerateSpec = useCallback(() => { + setShowOnboardingDialog(false); + // Navigate to the setup dialog flow + setSetupProjectPath(newProjectPath); + setProjectOverview(''); + setShowSetupDialog(true); + }, [newProjectPath, setShowOnboardingDialog]); + + /** + * Handle onboarding dialog - skip + */ + const handleOnboardingSkip = useCallback(() => { + setShowOnboardingDialog(false); + setNewProjectName(''); + setNewProjectPath(''); + toast.info('You can generate your app_spec.txt anytime from the Spec view', { + description: 'Your project is ready to use!', + }); + }, [setShowOnboardingDialog, setNewProjectName, setNewProjectPath]); + + return { + // State + showSetupDialog, + setShowSetupDialog, + setupProjectPath, + setSetupProjectPath, + projectOverview, + setProjectOverview, + generateFeatures, + setGenerateFeatures, + analyzeProject, + setAnalyzeProject, + featureCount, + setFeatureCount, + + // Handlers + handleCreateInitialSpec, + handleSkipSetup, + handleOnboardingGenerateSpec, + handleOnboardingSkip, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts b/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts new file mode 100644 index 000000000..9da2954e4 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef } from 'react'; + +interface UseSidebarAutoCollapseProps { + sidebarOpen: boolean; + toggleSidebar: () => void; +} + +export function useSidebarAutoCollapse({ + sidebarOpen, + toggleSidebar, +}: UseSidebarAutoCollapseProps) { + const isMountedRef = useRef(false); + + // Auto-collapse sidebar on small screens + useEffect(() => { + const mediaQuery = window.matchMedia('(max-width: 1024px)'); // lg breakpoint + + const handleResize = () => { + if (mediaQuery.matches && sidebarOpen) { + // Auto-collapse on small screens + toggleSidebar(); + } + }; + + // Check on mount only + if (!isMountedRef.current) { + isMountedRef.current = true; + handleResize(); + } + + // Listen for changes + mediaQuery.addEventListener('change', handleResize); + return () => mediaQuery.removeEventListener('change', handleResize); + }, [sidebarOpen, toggleSidebar]); +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-spec-regeneration.ts b/apps/ui/src/components/layout/sidebar/hooks/use-spec-regeneration.ts new file mode 100644 index 000000000..5337a6039 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-spec-regeneration.ts @@ -0,0 +1,78 @@ +import { useEffect } from 'react'; +import { toast } from 'sonner'; +import { getElectronAPI } from '@/lib/electron'; +import type { SpecRegenerationEvent } from '@/types/electron'; + +interface UseSpecRegenerationProps { + creatingSpecProjectPath: string | null; + setupProjectPath: string; + setSpecCreatingForProject: (path: string | null) => void; + setShowSetupDialog: (show: boolean) => void; + setProjectOverview: (overview: string) => void; + setSetupProjectPath: (path: string) => void; + setNewProjectName: (name: string) => void; + setNewProjectPath: (path: string) => void; +} + +export function useSpecRegeneration({ + creatingSpecProjectPath, + setupProjectPath, + setSpecCreatingForProject, + setShowSetupDialog, + setProjectOverview, + setSetupProjectPath, + setNewProjectName, + setNewProjectPath, +}: UseSpecRegenerationProps) { + // Subscribe to spec regeneration events + useEffect(() => { + const api = getElectronAPI(); + if (!api.specRegeneration) return; + + const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => { + console.log( + '[Sidebar] Spec regeneration event:', + event.type, + 'for project:', + event.projectPath + ); + + // Only handle events for the project we're currently setting up + if (event.projectPath !== creatingSpecProjectPath && event.projectPath !== setupProjectPath) { + console.log('[Sidebar] Ignoring event - not for project being set up'); + return; + } + + if (event.type === 'spec_regeneration_complete') { + setSpecCreatingForProject(null); + setShowSetupDialog(false); + setProjectOverview(''); + setSetupProjectPath(''); + // Clear onboarding state if we came from onboarding + setNewProjectName(''); + setNewProjectPath(''); + toast.success('App specification created', { + description: 'Your project is now set up and ready to go!', + }); + } else if (event.type === 'spec_regeneration_error') { + setSpecCreatingForProject(null); + toast.error('Failed to create specification', { + description: event.error, + }); + } + }); + + return () => { + unsubscribe(); + }; + }, [ + creatingSpecProjectPath, + setupProjectPath, + setSpecCreatingForProject, + setShowSetupDialog, + setProjectOverview, + setSetupProjectPath, + setNewProjectName, + setNewProjectPath, + ]); +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts b/apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts new file mode 100644 index 000000000..46c25e93b --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts @@ -0,0 +1,53 @@ +import { useRef, useCallback, useEffect } from 'react'; +import type { ThemeMode } from '@/store/app-store'; + +interface UseThemePreviewProps { + setPreviewTheme: (theme: ThemeMode | null) => void; +} + +export function useThemePreview({ setPreviewTheme }: UseThemePreviewProps) { + // Debounced preview theme handlers to prevent excessive re-renders + const previewTimeoutRef = useRef | null>(null); + + const handlePreviewEnter = useCallback( + (value: string) => { + // Clear any pending timeout + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + } + // Small delay to debounce rapid hover changes + previewTimeoutRef.current = setTimeout(() => { + setPreviewTheme(value as ThemeMode); + }, 16); // ~1 frame delay + }, + [setPreviewTheme] + ); + + const handlePreviewLeave = useCallback( + (e: React.PointerEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget?.closest('[data-testid^="project-theme-"]')) { + // Clear any pending timeout + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + } + setPreviewTheme(null); + } + }, + [setPreviewTheme] + ); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + } + }; + }, []); + + return { + handlePreviewEnter, + handlePreviewLeave, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts b/apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts new file mode 100644 index 000000000..74c1ee9b5 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts @@ -0,0 +1,40 @@ +import { useState } from 'react'; +import { useTrashOperations } from './use-trash-operations'; +import type { TrashedProject } from '@/lib/electron'; + +interface UseTrashDialogProps { + restoreTrashedProject: (projectId: string) => void; + deleteTrashedProject: (projectId: string) => void; + emptyTrash: () => void; + trashedProjects: TrashedProject[]; +} + +/** + * Hook that combines trash operations with dialog state management + */ +export function useTrashDialog({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + trashedProjects, +}: UseTrashDialogProps) { + // Dialog state + const [showTrashDialog, setShowTrashDialog] = useState(false); + + // Reuse existing trash operations logic + const trashOperations = useTrashOperations({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + trashedProjects, + }); + + return { + // Dialog state + showTrashDialog, + setShowTrashDialog, + + // Trash operations (spread from existing hook) + ...trashOperations, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-trash-operations.ts b/apps/ui/src/components/layout/sidebar/hooks/use-trash-operations.ts new file mode 100644 index 000000000..bb0dc5714 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-trash-operations.ts @@ -0,0 +1,92 @@ +import { useState, useCallback } from 'react'; +import { toast } from 'sonner'; +import { getElectronAPI, type TrashedProject } from '@/lib/electron'; + +interface UseTrashOperationsProps { + restoreTrashedProject: (projectId: string) => void; + deleteTrashedProject: (projectId: string) => void; + emptyTrash: () => void; + trashedProjects: TrashedProject[]; +} + +export function useTrashOperations({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + trashedProjects, +}: UseTrashOperationsProps) { + const [activeTrashId, setActiveTrashId] = useState(null); + const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); + + const handleRestoreProject = useCallback( + (projectId: string) => { + restoreTrashedProject(projectId); + toast.success('Project restored', { + description: 'Added back to your project list.', + }); + }, + [restoreTrashedProject] + ); + + const handleDeleteProjectFromDisk = useCallback( + async (trashedProject: TrashedProject) => { + const confirmed = window.confirm( + `Delete "${trashedProject.name}" from disk?\nThis sends the folder to your system Trash.` + ); + if (!confirmed) return; + + setActiveTrashId(trashedProject.id); + try { + const api = getElectronAPI(); + if (!api.trashItem) { + throw new Error('System Trash is not available in this build.'); + } + + const result = await api.trashItem(trashedProject.path); + if (!result.success) { + throw new Error(result.error || 'Failed to delete project folder'); + } + + deleteTrashedProject(trashedProject.id); + toast.success('Project folder sent to system Trash', { + description: trashedProject.path, + }); + } catch (error) { + console.error('[Sidebar] Failed to delete project from disk:', error); + toast.error('Failed to delete project folder', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setActiveTrashId(null); + } + }, + [deleteTrashedProject] + ); + + const handleEmptyTrash = useCallback(() => { + if (trashedProjects.length === 0) { + return; + } + + const confirmed = window.confirm( + 'Clear all projects from recycle bin? This does not delete folders from disk.' + ); + if (!confirmed) return; + + setIsEmptyingTrash(true); + try { + emptyTrash(); + toast.success('Recycle bin cleared'); + } finally { + setIsEmptyingTrash(false); + } + }, [emptyTrash, trashedProjects.length]); + + return { + activeTrashId, + isEmptyingTrash, + handleRestoreProject, + handleDeleteProjectFromDisk, + handleEmptyTrash, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/types.ts b/apps/ui/src/components/layout/sidebar/types.ts new file mode 100644 index 000000000..4d9ecc359 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/types.ts @@ -0,0 +1,32 @@ +import type { Project } from '@/lib/electron'; +import type React from 'react'; + +export interface NavSection { + label?: string; + items: NavItem[]; +} + +export interface NavItem { + id: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + shortcut?: string; +} + +export interface SortableProjectItemProps { + project: Project; + currentProjectId: string | undefined; + isHighlighted: boolean; + onSelect: (project: Project) => void; +} + +export interface ThemeMenuItemProps { + option: { + value: string; + label: string; + icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; + color: string; + }; + onPreviewEnter: (value: string) => void; + onPreviewLeave: (e: React.PointerEvent) => void; +} diff --git a/apps/ui/src/components/session-manager.tsx b/apps/ui/src/components/session-manager.tsx index c255c27f3..f8452aa18 100644 --- a/apps/ui/src/components/session-manager.tsx +++ b/apps/ui/src/components/session-manager.tsx @@ -1,10 +1,9 @@ - -import { useState, useEffect } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { HotkeyButton } from "@/components/ui/hotkey-button"; -import { Input } from "@/components/ui/input"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { HotkeyButton } from '@/components/ui/hotkey-button'; +import { Input } from '@/components/ui/input'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Plus, MessageSquare, @@ -15,66 +14,66 @@ import { X, ArchiveRestore, Loader2, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import type { SessionListItem } from "@/types/electron"; -import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts"; -import { getElectronAPI } from "@/lib/electron"; -import { DeleteSessionDialog } from "@/components/delete-session-dialog"; -import { DeleteAllArchivedSessionsDialog } from "@/components/delete-all-archived-sessions-dialog"; +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { SessionListItem } from '@/types/electron'; +import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; +import { getElectronAPI } from '@/lib/electron'; +import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog'; +import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog'; // Random session name generator const adjectives = [ - "Swift", - "Bright", - "Clever", - "Dynamic", - "Eager", - "Focused", - "Gentle", - "Happy", - "Inventive", - "Jolly", - "Keen", - "Lively", - "Mighty", - "Noble", - "Optimal", - "Peaceful", - "Quick", - "Radiant", - "Smart", - "Tranquil", - "Unique", - "Vibrant", - "Wise", - "Zealous", + 'Swift', + 'Bright', + 'Clever', + 'Dynamic', + 'Eager', + 'Focused', + 'Gentle', + 'Happy', + 'Inventive', + 'Jolly', + 'Keen', + 'Lively', + 'Mighty', + 'Noble', + 'Optimal', + 'Peaceful', + 'Quick', + 'Radiant', + 'Smart', + 'Tranquil', + 'Unique', + 'Vibrant', + 'Wise', + 'Zealous', ]; const nouns = [ - "Agent", - "Builder", - "Coder", - "Developer", - "Explorer", - "Forge", - "Garden", - "Helper", - "Innovator", - "Journey", - "Kernel", - "Lighthouse", - "Mission", - "Navigator", - "Oracle", - "Project", - "Quest", - "Runner", - "Spark", - "Task", - "Unicorn", - "Voyage", - "Workshop", + 'Agent', + 'Builder', + 'Coder', + 'Developer', + 'Explorer', + 'Forge', + 'Garden', + 'Helper', + 'Innovator', + 'Journey', + 'Kernel', + 'Lighthouse', + 'Mission', + 'Navigator', + 'Oracle', + 'Project', + 'Quest', + 'Runner', + 'Spark', + 'Task', + 'Unicorn', + 'Voyage', + 'Workshop', ]; function generateRandomSessionName(): string { @@ -101,19 +100,15 @@ export function SessionManager({ }: SessionManagerProps) { const shortcuts = useKeyboardShortcutsConfig(); const [sessions, setSessions] = useState([]); - const [activeTab, setActiveTab] = useState<"active" | "archived">("active"); + const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active'); const [editingSessionId, setEditingSessionId] = useState(null); - const [editingName, setEditingName] = useState(""); + const [editingName, setEditingName] = useState(''); const [isCreating, setIsCreating] = useState(false); - const [newSessionName, setNewSessionName] = useState(""); - const [runningSessions, setRunningSessions] = useState>( - new Set() - ); + const [newSessionName, setNewSessionName] = useState(''); + const [runningSessions, setRunningSessions] = useState>(new Set()); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [sessionToDelete, setSessionToDelete] = - useState(null); - const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = - useState(false); + const [sessionToDelete, setSessionToDelete] = useState(null); + const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false); // Check running state for all sessions const checkRunningSessions = async (sessionList: SessionListItem[]) => { @@ -131,10 +126,7 @@ export function SessionManager({ } } catch (err) { // Ignore errors for individual session checks - console.warn( - `[SessionManager] Failed to check running state for ${session.id}:`, - err - ); + console.warn(`[SessionManager] Failed to check running state for ${session.id}:`, err); } } @@ -180,14 +172,10 @@ export function SessionManager({ const sessionName = newSessionName.trim() || generateRandomSessionName(); - const result = await api.sessions.create( - sessionName, - projectPath, - projectPath - ); + const result = await api.sessions.create(sessionName, projectPath, projectPath); if (result.success && result.session?.id) { - setNewSessionName(""); + setNewSessionName(''); setIsCreating(false); await loadSessions(); onSelectSession(result.session.id); @@ -201,11 +189,7 @@ export function SessionManager({ const sessionName = generateRandomSessionName(); - const result = await api.sessions.create( - sessionName, - projectPath, - projectPath - ); + const result = await api.sessions.create(sessionName, projectPath, projectPath); if (result.success && result.session?.id) { await loadSessions(); @@ -234,7 +218,7 @@ export function SessionManager({ if (result.success) { setEditingSessionId(null); - setEditingName(""); + setEditingName(''); await loadSessions(); } }; @@ -243,7 +227,7 @@ export function SessionManager({ const handleArchiveSession = async (sessionId: string) => { const api = getElectronAPI(); if (!api?.sessions) { - console.error("[SessionManager] Sessions API not available"); + console.error('[SessionManager] Sessions API not available'); return; } @@ -256,10 +240,10 @@ export function SessionManager({ } await loadSessions(); } else { - console.error("[SessionManager] Archive failed:", result.error); + console.error('[SessionManager] Archive failed:', result.error); } } catch (error) { - console.error("[SessionManager] Archive error:", error); + console.error('[SessionManager] Archive error:', error); } }; @@ -267,7 +251,7 @@ export function SessionManager({ const handleUnarchiveSession = async (sessionId: string) => { const api = getElectronAPI(); if (!api?.sessions) { - console.error("[SessionManager] Sessions API not available"); + console.error('[SessionManager] Sessions API not available'); return; } @@ -276,10 +260,10 @@ export function SessionManager({ if (result.success) { await loadSessions(); } else { - console.error("[SessionManager] Unarchive failed:", result.error); + console.error('[SessionManager] Unarchive failed:', result.error); } } catch (error) { - console.error("[SessionManager] Unarchive error:", error); + console.error('[SessionManager] Unarchive error:', error); } }; @@ -324,8 +308,7 @@ export function SessionManager({ const activeSessions = sessions.filter((s) => !s.isArchived); const archivedSessions = sessions.filter((s) => s.isArchived); - const displayedSessions = - activeTab === "active" ? activeSessions : archivedSessions; + const displayedSessions = activeTab === 'active' ? activeSessions : archivedSessions; return ( @@ -337,8 +320,8 @@ export function SessionManager({ size="sm" onClick={() => { // Switch to active tab if on archived tab - if (activeTab === "archived") { - setActiveTab("active"); + if (activeTab === 'archived') { + setActiveTab('active'); } handleQuickCreateSession(); }} @@ -354,9 +337,7 @@ export function SessionManager({ - setActiveTab(value as "active" | "archived") - } + onValueChange={(value) => setActiveTab(value as 'active' | 'archived')} className="w-full" > @@ -372,10 +353,7 @@ export function SessionManager({ - + {/* Create new session */} {isCreating && (
@@ -385,10 +363,10 @@ export function SessionManager({ value={newSessionName} onChange={(e) => setNewSessionName(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") handleCreateSession(); - if (e.key === "Escape") { + if (e.key === 'Enter') handleCreateSession(); + if (e.key === 'Escape') { setIsCreating(false); - setNewSessionName(""); + setNewSessionName(''); } }} autoFocus @@ -401,7 +379,7 @@ export function SessionManager({ variant="ghost" onClick={() => { setIsCreating(false); - setNewSessionName(""); + setNewSessionName(''); }} > @@ -411,7 +389,7 @@ export function SessionManager({ )} {/* Delete All Archived button - shown at the top of archived sessions */} - {activeTab === "archived" && archivedSessions.length > 0 && ( + {activeTab === 'archived' && archivedSessions.length > 0 && (
)} diff --git a/apps/ui/src/components/ui/accordion.tsx b/apps/ui/src/components/ui/accordion.tsx index 0c8b61018..3cb256b3d 100644 --- a/apps/ui/src/components/ui/accordion.tsx +++ b/apps/ui/src/components/ui/accordion.tsx @@ -1,9 +1,10 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ -import * as React from "react"; -import { ChevronDown } from "lucide-react"; -import { cn } from "@/lib/utils"; +import * as React from 'react'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; -type AccordionType = "single" | "multiple"; +type AccordionType = 'single' | 'multiple'; interface AccordionContextValue { type: AccordionType; @@ -12,12 +13,10 @@ interface AccordionContextValue { collapsible?: boolean; } -const AccordionContext = React.createContext( - null -); +const AccordionContext = React.createContext(null); interface AccordionProps extends React.HTMLAttributes { - type?: "single" | "multiple"; + type?: 'single' | 'multiple'; value?: string | string[]; defaultValue?: string | string[]; onValueChange?: (value: string | string[]) => void; @@ -27,7 +26,7 @@ interface AccordionProps extends React.HTMLAttributes { const Accordion = React.forwardRef( ( { - type = "single", + type = 'single', value, defaultValue, onValueChange, @@ -38,13 +37,11 @@ const Accordion = React.forwardRef( }, ref ) => { - const [internalValue, setInternalValue] = React.useState( - () => { - if (value !== undefined) return value; - if (defaultValue !== undefined) return defaultValue; - return type === "single" ? "" : []; - } - ); + const [internalValue, setInternalValue] = React.useState(() => { + if (value !== undefined) return value; + if (defaultValue !== undefined) return defaultValue; + return type === 'single' ? '' : []; + }); const currentValue = value !== undefined ? value : internalValue; @@ -52,9 +49,9 @@ const Accordion = React.forwardRef( (itemValue: string) => { let newValue: string | string[]; - if (type === "single") { + if (type === 'single') { if (currentValue === itemValue && collapsible) { - newValue = ""; + newValue = ''; } else if (currentValue === itemValue && !collapsible) { return; } else { @@ -91,27 +88,21 @@ const Accordion = React.forwardRef( return ( -
+
{children}
); } ); -Accordion.displayName = "Accordion"; +Accordion.displayName = 'Accordion'; interface AccordionItemContextValue { value: string; isOpen: boolean; } -const AccordionItemContext = - React.createContext(null); +const AccordionItemContext = React.createContext(null); interface AccordionItemProps extends React.HTMLAttributes { value: string; @@ -122,25 +113,22 @@ const AccordionItem = React.forwardRef( const accordionContext = React.useContext(AccordionContext); if (!accordionContext) { - throw new Error("AccordionItem must be used within an Accordion"); + throw new Error('AccordionItem must be used within an Accordion'); } const isOpen = Array.isArray(accordionContext.value) ? accordionContext.value.includes(value) : accordionContext.value === value; - const contextValue = React.useMemo( - () => ({ value, isOpen }), - [value, isOpen] - ); + const contextValue = React.useMemo(() => ({ value, isOpen }), [value, isOpen]); return (
{children} @@ -149,47 +137,45 @@ const AccordionItem = React.forwardRef( ); } ); -AccordionItem.displayName = "AccordionItem"; +AccordionItem.displayName = 'AccordionItem'; -interface AccordionTriggerProps - extends React.ButtonHTMLAttributes {} +interface AccordionTriggerProps extends React.ButtonHTMLAttributes {} -const AccordionTrigger = React.forwardRef< - HTMLButtonElement, - AccordionTriggerProps ->(({ className, children, ...props }, ref) => { - const accordionContext = React.useContext(AccordionContext); - const itemContext = React.useContext(AccordionItemContext); +const AccordionTrigger = React.forwardRef( + ({ className, children, ...props }, ref) => { + const accordionContext = React.useContext(AccordionContext); + const itemContext = React.useContext(AccordionItemContext); - if (!accordionContext || !itemContext) { - throw new Error("AccordionTrigger must be used within an AccordionItem"); - } + if (!accordionContext || !itemContext) { + throw new Error('AccordionTrigger must be used within an AccordionItem'); + } - const { onValueChange } = accordionContext; - const { value, isOpen } = itemContext; - - return ( -
- -
- ); -}); -AccordionTrigger.displayName = "AccordionTrigger"; + const { onValueChange } = accordionContext; + const { value, isOpen } = itemContext; + + return ( +
+ +
+ ); + } +); +AccordionTrigger.displayName = 'AccordionTrigger'; interface AccordionContentProps extends React.HTMLAttributes {} @@ -200,7 +186,7 @@ const AccordionContent = React.forwardRef const [height, setHeight] = React.useState(undefined); if (!itemContext) { - throw new Error("AccordionContent must be used within an AccordionItem"); + throw new Error('AccordionContent must be used within an AccordionItem'); } const { isOpen } = itemContext; @@ -220,16 +206,16 @@ const AccordionContent = React.forwardRef return (
-
+
{children}
@@ -237,6 +223,6 @@ const AccordionContent = React.forwardRef ); } ); -AccordionContent.displayName = "AccordionContent"; +AccordionContent.displayName = 'AccordionContent'; export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/ui/src/components/ui/description-image-dropzone.tsx b/apps/ui/src/components/ui/description-image-dropzone.tsx index af3f90196..7020ca757 100644 --- a/apps/ui/src/components/ui/description-image-dropzone.tsx +++ b/apps/ui/src/components/ui/description-image-dropzone.tsx @@ -1,10 +1,9 @@ - -import React, { useState, useRef, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { ImageIcon, X, Loader2 } from "lucide-react"; -import { Textarea } from "@/components/ui/textarea"; -import { getElectronAPI } from "@/lib/electron"; -import { useAppStore, type FeatureImagePath } from "@/store/app-store"; +import React, { useState, useRef, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { ImageIcon, X, Loader2 } from 'lucide-react'; +import { Textarea } from '@/components/ui/textarea'; +import { getElectronAPI } from '@/lib/electron'; +import { useAppStore, type FeatureImagePath } from '@/store/app-store'; // Map to store preview data by image ID (persisted across component re-mounts) export type ImagePreviewMap = Map; @@ -26,13 +25,7 @@ interface DescriptionImageDropZoneProps { error?: boolean; // Show error state with red border } -const ACCEPTED_IMAGE_TYPES = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", -]; +const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB export function DescriptionImageDropZone({ @@ -40,7 +33,7 @@ export function DescriptionImageDropZone({ onChange, images, onImagesChange, - placeholder = "Describe the feature...", + placeholder = 'Describe the feature...', className, disabled = false, maxFiles = 5, @@ -59,71 +52,76 @@ export function DescriptionImageDropZone({ // Determine which preview map to use - prefer parent-controlled state const previewImages = previewMap !== undefined ? previewMap : localPreviewImages; - const setPreviewImages = useCallback((updater: Map | ((prev: Map) => Map)) => { - if (onPreviewMapChange) { - const currentMap = previewMap !== undefined ? previewMap : localPreviewImages; - const newMap = typeof updater === 'function' ? updater(currentMap) : updater; - onPreviewMapChange(newMap); - } else { - setLocalPreviewImages((prev) => { - const newMap = typeof updater === 'function' ? updater(prev) : updater; - return newMap; - }); - } - }, [onPreviewMapChange, previewMap, localPreviewImages]); + const setPreviewImages = useCallback( + (updater: Map | ((prev: Map) => Map)) => { + if (onPreviewMapChange) { + const currentMap = previewMap !== undefined ? previewMap : localPreviewImages; + const newMap = typeof updater === 'function' ? updater(currentMap) : updater; + onPreviewMapChange(newMap); + } else { + setLocalPreviewImages((prev) => { + const newMap = typeof updater === 'function' ? updater(prev) : updater; + return newMap; + }); + } + }, + [onPreviewMapChange, previewMap, localPreviewImages] + ); const fileInputRef = useRef(null); const currentProject = useAppStore((state) => state.currentProject); // Construct server URL for loading saved images - const getImageServerUrl = useCallback((imagePath: string): string => { - const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; - const projectPath = currentProject?.path || ""; - return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`; - }, [currentProject?.path]); + const getImageServerUrl = useCallback( + (imagePath: string): string => { + const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; + const projectPath = currentProject?.path || ''; + return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`; + }, + [currentProject?.path] + ); const fileToBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { - if (typeof reader.result === "string") { + if (typeof reader.result === 'string') { resolve(reader.result); } else { - reject(new Error("Failed to read file as base64")); + reject(new Error('Failed to read file as base64')); } }; - reader.onerror = () => reject(new Error("Failed to read file")); + reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsDataURL(file); }); }; - const saveImageToTemp = useCallback(async ( - base64Data: string, - filename: string, - mimeType: string - ): Promise => { - try { - const api = getElectronAPI(); - // Check if saveImageToTemp method exists - if (!api.saveImageToTemp) { - // Fallback path when saveImageToTemp is not available - console.log("[DescriptionImageDropZone] Using fallback path for image"); - return `.automaker/images/${Date.now()}_${filename}`; - } + const saveImageToTemp = useCallback( + async (base64Data: string, filename: string, mimeType: string): Promise => { + try { + const api = getElectronAPI(); + // Check if saveImageToTemp method exists + if (!api.saveImageToTemp) { + // Fallback path when saveImageToTemp is not available + console.log('[DescriptionImageDropZone] Using fallback path for image'); + return `.automaker/images/${Date.now()}_${filename}`; + } - // Get projectPath from the store if available - const projectPath = currentProject?.path; - const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath); - if (result.success && result.path) { - return result.path; + // Get projectPath from the store if available + const projectPath = currentProject?.path; + const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath); + if (result.success && result.path) { + return result.path; + } + console.error('[DescriptionImageDropZone] Failed to save image:', result.error); + return null; + } catch (error) { + console.error('[DescriptionImageDropZone] Error saving image:', error); + return null; } - console.error("[DescriptionImageDropZone] Failed to save image:", result.error); - return null; - } catch (error) { - console.error("[DescriptionImageDropZone] Error saving image:", error); - return null; - } - }, [currentProject?.path]); + }, + [currentProject?.path] + ); const processFiles = useCallback( async (files: FileList) => { @@ -137,18 +135,14 @@ export function DescriptionImageDropZone({ for (const file of Array.from(files)) { // Validate file type if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push( - `${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.` - ); + errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); continue; } // Validate file size if (file.size > maxFileSize) { const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push( - `${file.name}: File too large. Maximum size is ${maxSizeMB}MB.` - ); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); continue; } @@ -176,13 +170,13 @@ export function DescriptionImageDropZone({ } else { errors.push(`${file.name}: Failed to save image.`); } - } catch (error) { + } catch { errors.push(`${file.name}: Failed to process image.`); } } if (errors.length > 0) { - console.warn("Image upload errors:", errors); + console.warn('Image upload errors:', errors); } if (newImages.length > 0) { @@ -192,7 +186,16 @@ export function DescriptionImageDropZone({ setIsProcessing(false); }, - [disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp] + [ + disabled, + isProcessing, + images, + maxFiles, + maxFileSize, + onImagesChange, + previewImages, + saveImageToTemp, + ] ); const handleDrop = useCallback( @@ -236,7 +239,7 @@ export function DescriptionImageDropZone({ } // Reset the input so the same file can be selected again if (fileInputRef.current) { - fileInputRef.current.value = ""; + fileInputRef.current.value = ''; } }, [processFiles] @@ -276,17 +279,15 @@ export function DescriptionImageDropZone({ const item = clipboardItems[i]; // Check if the item is an image - if (item.type.startsWith("image/")) { + if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { // Generate a filename for pasted images since they don't have one - const extension = item.type.split("/")[1] || "png"; - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const renamedFile = new File( - [file], - `pasted-image-${timestamp}.${extension}`, - { type: file.type } - ); + const extension = item.type.split('/')[1] || 'png'; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const renamedFile = new File([file], `pasted-image-${timestamp}.${extension}`, { + type: file.type, + }); imageFiles.push(renamedFile); } } @@ -307,13 +308,13 @@ export function DescriptionImageDropZone({ ); return ( -
+
{/* Hidden file input */} {/* Drag overlay */} {isDragOver && !disabled && ( @@ -355,17 +352,14 @@ export function DescriptionImageDropZone({ disabled={disabled} autoFocus={autoFocus} aria-invalid={error} - className={cn( - "min-h-[120px]", - isProcessing && "opacity-50 pointer-events-none" - )} + className={cn('min-h-[120px]', isProcessing && 'opacity-50 pointer-events-none')} data-testid="feature-description-input" />
{/* Hint text */}

- Paste, drag and drop images, or{" "} + Paste, drag and drop images, or{' '} {" "} + {' '} to attach context images

@@ -390,7 +384,7 @@ export function DescriptionImageDropZone({

- {images.length} image{images.length > 1 ? "s" : ""} attached + {images.length} image{images.length > 1 ? 's' : ''} attached

))} diff --git a/apps/ui/src/components/ui/feature-image-upload.tsx b/apps/ui/src/components/ui/feature-image-upload.tsx index a16dfcb66..0cb5403cf 100644 --- a/apps/ui/src/components/ui/feature-image-upload.tsx +++ b/apps/ui/src/components/ui/feature-image-upload.tsx @@ -1,7 +1,6 @@ - -import React, { useState, useRef, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { ImageIcon, X, Upload } from "lucide-react"; +import React, { useState, useRef, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { ImageIcon, X, Upload } from 'lucide-react'; export interface FeatureImage { id: string; @@ -20,13 +19,7 @@ interface FeatureImageUploadProps { disabled?: boolean; } -const ACCEPTED_IMAGE_TYPES = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", -]; +const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB export function FeatureImageUpload({ @@ -45,13 +38,13 @@ export function FeatureImageUpload({ return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { - if (typeof reader.result === "string") { + if (typeof reader.result === 'string') { resolve(reader.result); } else { - reject(new Error("Failed to read file as base64")); + reject(new Error('Failed to read file as base64')); } }; - reader.onerror = () => reject(new Error("Failed to read file")); + reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsDataURL(file); }); }; @@ -67,18 +60,14 @@ export function FeatureImageUpload({ for (const file of Array.from(files)) { // Validate file type if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push( - `${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.` - ); + errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); continue; } // Validate file size if (file.size > maxFileSize) { const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push( - `${file.name}: File too large. Maximum size is ${maxSizeMB}MB.` - ); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); continue; } @@ -98,13 +87,13 @@ export function FeatureImageUpload({ size: file.size, }; newImages.push(imageAttachment); - } catch (error) { + } catch { errors.push(`${file.name}: Failed to process image.`); } } if (errors.length > 0) { - console.warn("Image upload errors:", errors); + console.warn('Image upload errors:', errors); } if (newImages.length > 0) { @@ -157,7 +146,7 @@ export function FeatureImageUpload({ } // Reset the input so the same file can be selected again if (fileInputRef.current) { - fileInputRef.current.value = ""; + fileInputRef.current.value = ''; } }, [processFiles] @@ -180,22 +169,14 @@ export function FeatureImageUpload({ onImagesChange([]); }, [onImagesChange]); - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; - }; - return ( -
+
{/* Hidden file input */}
{isProcessing ? ( @@ -237,13 +215,10 @@ export function FeatureImageUpload({ )}

- {isDragOver && !disabled - ? "Drop images here" - : "Click or drag images here"} + {isDragOver && !disabled ? 'Drop images here' : 'Click or drag images here'}

- Up to {maxFiles} images, max{" "} - {Math.round(maxFileSize / (1024 * 1024))}MB each + Up to {maxFiles} images, max {Math.round(maxFileSize / (1024 * 1024))}MB each

@@ -253,7 +228,7 @@ export function FeatureImageUpload({

- {images.length} image{images.length > 1 ? "s" : ""} selected + {images.length} image{images.length > 1 ? 's' : ''} selected

))} diff --git a/apps/ui/src/components/ui/image-drop-zone.tsx b/apps/ui/src/components/ui/image-drop-zone.tsx index 5494bdc32..04e534917 100644 --- a/apps/ui/src/components/ui/image-drop-zone.tsx +++ b/apps/ui/src/components/ui/image-drop-zone.tsx @@ -1,8 +1,7 @@ - -import React, { useState, useRef, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { ImageIcon, X, Upload } from "lucide-react"; -import type { ImageAttachment } from "@/store/app-store"; +import React, { useState, useRef, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { ImageIcon, X, Upload } from 'lucide-react'; +import type { ImageAttachment } from '@/store/app-store'; interface ImageDropZoneProps { onImagesSelected: (images: ImageAttachment[]) => void; @@ -35,88 +34,100 @@ export function ImageDropZone({ const selectedImages = images ?? internalImages; // Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state - const updateImages = useCallback((newImages: ImageAttachment[]) => { - if (images === undefined) { - setInternalImages(newImages); - } - onImagesSelected(newImages); - }, [images, onImagesSelected]); + const updateImages = useCallback( + (newImages: ImageAttachment[]) => { + if (images === undefined) { + setInternalImages(newImages); + } + onImagesSelected(newImages); + }, + [images, onImagesSelected] + ); - const processFiles = useCallback(async (files: FileList) => { - if (disabled || isProcessing) return; + const processFiles = useCallback( + async (files: FileList) => { + if (disabled || isProcessing) return; - setIsProcessing(true); - const newImages: ImageAttachment[] = []; - const errors: string[] = []; + setIsProcessing(true); + const newImages: ImageAttachment[] = []; + const errors: string[] = []; - for (const file of Array.from(files)) { - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); - continue; - } + for (const file of Array.from(files)) { + // Validate file type + if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { + errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); + continue; + } - // Validate file size - if (file.size > maxFileSize) { - const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); - continue; - } + // Validate file size + if (file.size > maxFileSize) { + const maxSizeMB = maxFileSize / (1024 * 1024); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); + continue; + } - // Check if we've reached max files - if (newImages.length + selectedImages.length >= maxFiles) { - errors.push(`Maximum ${maxFiles} images allowed.`); - break; - } + // Check if we've reached max files + if (newImages.length + selectedImages.length >= maxFiles) { + errors.push(`Maximum ${maxFiles} images allowed.`); + break; + } - try { - const base64 = await fileToBase64(file); - const imageAttachment: ImageAttachment = { - id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - data: base64, - mimeType: file.type, - filename: file.name, - size: file.size, - }; - newImages.push(imageAttachment); - } catch (error) { - errors.push(`${file.name}: Failed to process image.`); + try { + const base64 = await fileToBase64(file); + const imageAttachment: ImageAttachment = { + id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + data: base64, + mimeType: file.type, + filename: file.name, + size: file.size, + }; + newImages.push(imageAttachment); + } catch { + errors.push(`${file.name}: Failed to process image.`); + } } - } - if (errors.length > 0) { - console.warn('Image upload errors:', errors); - // You could show these errors to the user via a toast or notification - } + if (errors.length > 0) { + console.warn('Image upload errors:', errors); + // You could show these errors to the user via a toast or notification + } - if (newImages.length > 0) { - const allImages = [...selectedImages, ...newImages]; - updateImages(allImages); - } + if (newImages.length > 0) { + const allImages = [...selectedImages, ...newImages]; + updateImages(allImages); + } - setIsProcessing(false); - }, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]); + setIsProcessing(false); + }, + [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages] + ); - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); - if (disabled) return; + if (disabled) return; - const files = e.dataTransfer.files; - if (files.length > 0) { - processFiles(files); - } - }, [disabled, processFiles]); + const files = e.dataTransfer.files; + if (files.length > 0) { + processFiles(files); + } + }, + [disabled, processFiles] + ); - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!disabled) { - setIsDragOver(true); - } - }, [disabled]); + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) { + setIsDragOver(true); + } + }, + [disabled] + ); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -124,16 +135,19 @@ export function ImageDropZone({ setIsDragOver(false); }, []); - const handleFileSelect = useCallback((e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - processFiles(files); - } - // Reset the input so the same file can be selected again - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }, [processFiles]); + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + processFiles(files); + } + // Reset the input so the same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, + [processFiles] + ); const handleBrowseClick = useCallback(() => { if (!disabled && fileInputRef.current) { @@ -141,17 +155,20 @@ export function ImageDropZone({ } }, [disabled]); - const removeImage = useCallback((imageId: string) => { - const updated = selectedImages.filter(img => img.id !== imageId); - updateImages(updated); - }, [selectedImages, updateImages]); + const removeImage = useCallback( + (imageId: string) => { + const updated = selectedImages.filter((img) => img.id !== imageId); + updateImages(updated); + }, + [selectedImages, updateImages] + ); const clearAllImages = useCallback(() => { updateImages([]); }, [updateImages]); return ( -
+
{/* Hidden file input */} {children || (
-
+
{isProcessing ? ( ) : ( @@ -191,10 +208,13 @@ export function ImageDropZone({ )}

- {isDragOver && !disabled ? "Drop your images here" : "Drag images here or click to browse"} + {isDragOver && !disabled + ? 'Drop your images here' + : 'Drag images here or click to browse'}

- {maxFiles > 1 ? `Up to ${maxFiles} images` : "1 image"}, max {Math.round(maxFileSize / (1024 * 1024))}MB each + {maxFiles > 1 ? `Up to ${maxFiles} images` : '1 image'}, max{' '} + {Math.round(maxFileSize / (1024 * 1024))}MB each

{!disabled && (