From 6365cc137c329c0dae5980b46161cabbfcfe00ca Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 19:38:26 +0100 Subject: [PATCH 01/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20implemen?= =?UTF-8?q?t=20Phase=201=20folder-pattern=20compliance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename App.tsx to app.tsx (kebab-case naming convention) - Add barrel exports (index.ts) for src/hooks/ - Add barrel exports (index.ts) for src/components/dialogs/ - Add barrel exports (index.ts) for src/components/layout/ - Update renderer.tsx import to use lowercase app.tsx This is Phase 1 of folder-pattern.md compliance: establishing proper file naming conventions and barrel export patterns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/ui/src/{App.tsx => app.tsx} | 20 ++++++++++---------- apps/ui/src/components/dialogs/index.ts | 2 ++ apps/ui/src/components/layout/index.ts | 1 + apps/ui/src/hooks/index.ts | 9 +++++++++ apps/ui/src/renderer.tsx | 8 ++++---- 5 files changed, 26 insertions(+), 14 deletions(-) rename apps/ui/src/{App.tsx => app.tsx} (52%) create mode 100644 apps/ui/src/components/dialogs/index.ts create mode 100644 apps/ui/src/components/layout/index.ts create mode 100644 apps/ui/src/hooks/index.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/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts new file mode 100644 index 000000000..904c7a21e --- /dev/null +++ b/apps/ui/src/components/dialogs/index.ts @@ -0,0 +1,2 @@ +export { BoardBackgroundModal } from './board-background-modal'; +export { FileBrowserDialog } from './file-browser-dialog'; 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/hooks/index.ts b/apps/ui/src/hooks/index.ts new file mode 100644 index 000000000..b18a85e60 --- /dev/null +++ b/apps/ui/src/hooks/index.ts @@ -0,0 +1,9 @@ +export { useAutoMode } from './use-auto-mode'; +export { useBoardBackgroundSettings } from './use-board-background-settings'; +export { useElectronAgent } from './use-electron-agent'; +export { useKeyboardShortcuts } from './use-keyboard-shortcuts'; +export { useMessageQueue } from './use-message-queue'; +export { useResponsiveKanban } from './use-responsive-kanban'; +export { useScrollTracking } from './use-scroll-tracking'; +export { useSettingsMigration } from './use-settings-migration'; +export { useWindowState } from './use-window-state'; diff --git a/apps/ui/src/renderer.tsx b/apps/ui/src/renderer.tsx index 9a58d97d7..86054d5a2 100644 --- a/apps/ui/src/renderer.tsx +++ b/apps/ui/src/renderer.tsx @@ -1,8 +1,8 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App"; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './app'; -createRoot(document.getElementById("app")!).render( +createRoot(document.getElementById('app')!).render( From e47b34288b733b493de15c66d774c0bb1a906e61 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 19:43:17 +0100 Subject: [PATCH 02/12] =?UTF-8?q?=F0=9F=97=82=EF=B8=8F=20refactor:=20imple?= =?UTF-8?q?ment=20Phase=202=20folder-pattern=20compliance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move dialogs to src/components/dialogs/ folder: - delete-session-dialog.tsx - delete-all-archived-sessions-dialog.tsx - new-project-modal.tsx - workspace-picker-modal.tsx - Update all imports to reference new dialog locations - Create barrel export (index.ts) for board-view/components/kanban-card/ - Create barrel exports (index.ts) for all 11 settings-view subfolders: - api-keys/, api-keys/hooks/, appearance/, audio/, cli-status/ - components/, config/, danger-zone/, feature-defaults/ - keyboard-shortcuts/, shared/ This is Phase 2 of folder-pattern.md compliance: organizing dialogs and establishing consistent barrel export patterns across all view subfolders. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../delete-all-archived-sessions-dialog.tsx | 10 +- .../{ => dialogs}/delete-session-dialog.tsx | 14 +- apps/ui/src/components/dialogs/index.ts | 4 + .../{ => dialogs}/new-project-modal.tsx | 191 ++++++-------- .../{ => dialogs}/workspace-picker-modal.tsx | 32 +-- apps/ui/src/components/layout/sidebar.tsx | 2 +- apps/ui/src/components/session-manager.tsx | 240 ++++++++---------- .../components/kanban-card/index.ts | 7 + .../settings-view/api-keys/hooks/index.ts | 1 + .../views/settings-view/api-keys/index.ts | 4 + .../views/settings-view/appearance/index.ts | 1 + .../views/settings-view/audio/index.ts | 1 + .../views/settings-view/cli-status/index.ts | 1 + .../views/settings-view/components/index.ts | 4 + .../views/settings-view/config/index.ts | 2 + .../views/settings-view/danger-zone/index.ts | 1 + .../settings-view/feature-defaults/index.ts | 1 + .../settings-view/keyboard-shortcuts/index.ts | 1 + .../views/settings-view/shared/index.ts | 2 + apps/ui/src/components/views/welcome-view.tsx | 191 ++++++-------- 20 files changed, 304 insertions(+), 406 deletions(-) rename apps/ui/src/components/{ => dialogs}/delete-all-archived-sessions-dialog.tsx (89%) rename apps/ui/src/components/{ => dialogs}/delete-session-dialog.tsx (78%) rename apps/ui/src/components/{ => dialogs}/new-project-modal.tsx (70%) rename apps/ui/src/components/{ => dialogs}/workspace-picker-modal.tsx (85%) create mode 100644 apps/ui/src/components/views/board-view/components/kanban-card/index.ts create mode 100644 apps/ui/src/components/views/settings-view/api-keys/hooks/index.ts create mode 100644 apps/ui/src/components/views/settings-view/api-keys/index.ts create mode 100644 apps/ui/src/components/views/settings-view/appearance/index.ts create mode 100644 apps/ui/src/components/views/settings-view/audio/index.ts create mode 100644 apps/ui/src/components/views/settings-view/cli-status/index.ts create mode 100644 apps/ui/src/components/views/settings-view/components/index.ts create mode 100644 apps/ui/src/components/views/settings-view/config/index.ts create mode 100644 apps/ui/src/components/views/settings-view/danger-zone/index.ts create mode 100644 apps/ui/src/components/views/settings-view/feature-defaults/index.ts create mode 100644 apps/ui/src/components/views/settings-view/keyboard-shortcuts/index.ts create mode 100644 apps/ui/src/components/views/settings-view/shared/index.ts 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/index.ts b/apps/ui/src/components/dialogs/index.ts index 904c7a21e..4cadb26d9 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -1,2 +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/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index cca6aa228..3cafe0207 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -72,7 +72,7 @@ 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 { 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/views/board-view/components/kanban-card/index.ts b/apps/ui/src/components/views/board-view/components/kanban-card/index.ts new file mode 100644 index 000000000..a8b7a36a5 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/kanban-card/index.ts @@ -0,0 +1,7 @@ +export { AgentInfoPanel } from './agent-info-panel'; +export { CardActions } from './card-actions'; +export { CardBadges, PriorityBadges } from './card-badges'; +export { CardContentSections } from './card-content-sections'; +export { CardHeaderSection } from './card-header'; +export { KanbanCard } from './kanban-card'; +export { SummaryDialog } from './summary-dialog'; diff --git a/apps/ui/src/components/views/settings-view/api-keys/hooks/index.ts b/apps/ui/src/components/views/settings-view/api-keys/hooks/index.ts new file mode 100644 index 000000000..7b953096a --- /dev/null +++ b/apps/ui/src/components/views/settings-view/api-keys/hooks/index.ts @@ -0,0 +1 @@ +export { useApiKeyManagement } from './use-api-key-management'; diff --git a/apps/ui/src/components/views/settings-view/api-keys/index.ts b/apps/ui/src/components/views/settings-view/api-keys/index.ts new file mode 100644 index 000000000..0a51c82fe --- /dev/null +++ b/apps/ui/src/components/views/settings-view/api-keys/index.ts @@ -0,0 +1,4 @@ +export { ApiKeyField } from './api-key-field'; +export { ApiKeysSection } from './api-keys-section'; +export { AuthenticationStatusDisplay } from './authentication-status-display'; +export { SecurityNotice } from './security-notice'; diff --git a/apps/ui/src/components/views/settings-view/appearance/index.ts b/apps/ui/src/components/views/settings-view/appearance/index.ts new file mode 100644 index 000000000..9273561e6 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/appearance/index.ts @@ -0,0 +1 @@ +export { AppearanceSection } from './appearance-section'; diff --git a/apps/ui/src/components/views/settings-view/audio/index.ts b/apps/ui/src/components/views/settings-view/audio/index.ts new file mode 100644 index 000000000..a6d19cf6a --- /dev/null +++ b/apps/ui/src/components/views/settings-view/audio/index.ts @@ -0,0 +1 @@ +export { AudioSection } from './audio-section'; diff --git a/apps/ui/src/components/views/settings-view/cli-status/index.ts b/apps/ui/src/components/views/settings-view/cli-status/index.ts new file mode 100644 index 000000000..a6d7cf876 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/cli-status/index.ts @@ -0,0 +1 @@ +export { ClaudeCliStatus } from './claude-cli-status'; diff --git a/apps/ui/src/components/views/settings-view/components/index.ts b/apps/ui/src/components/views/settings-view/components/index.ts new file mode 100644 index 000000000..de388fad9 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/components/index.ts @@ -0,0 +1,4 @@ +export { DeleteProjectDialog } from './delete-project-dialog'; +export { KeyboardMapDialog } from './keyboard-map-dialog'; +export { SettingsHeader } from './settings-header'; +export { SettingsNavigation } from './settings-navigation'; diff --git a/apps/ui/src/components/views/settings-view/config/index.ts b/apps/ui/src/components/views/settings-view/config/index.ts new file mode 100644 index 000000000..9591a88df --- /dev/null +++ b/apps/ui/src/components/views/settings-view/config/index.ts @@ -0,0 +1,2 @@ +export { NAV_ITEMS } from './navigation'; +export type { NavigationItem } from './navigation'; diff --git a/apps/ui/src/components/views/settings-view/danger-zone/index.ts b/apps/ui/src/components/views/settings-view/danger-zone/index.ts new file mode 100644 index 000000000..f4426f7eb --- /dev/null +++ b/apps/ui/src/components/views/settings-view/danger-zone/index.ts @@ -0,0 +1 @@ +export { DangerZoneSection } from './danger-zone-section'; diff --git a/apps/ui/src/components/views/settings-view/feature-defaults/index.ts b/apps/ui/src/components/views/settings-view/feature-defaults/index.ts new file mode 100644 index 000000000..95d001238 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/feature-defaults/index.ts @@ -0,0 +1 @@ +export { FeatureDefaultsSection } from './feature-defaults-section'; diff --git a/apps/ui/src/components/views/settings-view/keyboard-shortcuts/index.ts b/apps/ui/src/components/views/settings-view/keyboard-shortcuts/index.ts new file mode 100644 index 000000000..5db1d7b52 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/keyboard-shortcuts/index.ts @@ -0,0 +1 @@ +export { KeyboardShortcutsSection } from './keyboard-shortcuts-section'; diff --git a/apps/ui/src/components/views/settings-view/shared/index.ts b/apps/ui/src/components/views/settings-view/shared/index.ts new file mode 100644 index 000000000..9f0b55053 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/shared/index.ts @@ -0,0 +1,2 @@ +export type { Theme } from '@/config/theme-options'; +export type { CliStatus, KanbanDetailLevel, Project, ApiKeys } from './types'; diff --git a/apps/ui/src/components/views/welcome-view.tsx b/apps/ui/src/components/views/welcome-view.tsx index e9948f292..00c3899a2 100644 --- a/apps/ui/src/components/views/welcome-view.tsx +++ b/apps/ui/src/components/views/welcome-view.tsx @@ -1,6 +1,5 @@ - -import { useState, useCallback } from "react"; -import { Button } from "@/components/ui/button"; +import { useState, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, @@ -8,10 +7,10 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { useAppStore, type ThemeMode } from "@/store/app-store"; -import { getElectronAPI, type Project } from "@/lib/electron"; -import { initializeProject } from "@/lib/project-init"; +} from '@/components/ui/dialog'; +import { useAppStore, type ThemeMode } from '@/store/app-store'; +import { getElectronAPI, type Project } from '@/lib/electron'; +import { initializeProject } from '@/lib/project-init'; import { FolderOpen, Plus, @@ -21,19 +20,19 @@ import { MessageSquare, ChevronDown, Loader2, -} from "lucide-react"; +} from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { toast } from "sonner"; -import { WorkspacePickerModal } from "@/components/workspace-picker-modal"; -import { NewProjectModal } from "@/components/new-project-modal"; -import { getHttpApiClient } from "@/lib/http-api-client"; -import type { StarterTemplate } from "@/lib/templates"; -import { useNavigate } from "@tanstack/react-router"; +} from '@/components/ui/dropdown-menu'; +import { toast } from 'sonner'; +import { WorkspacePickerModal } from '@/components/dialogs/workspace-picker-modal'; +import { NewProjectModal } from '@/components/dialogs/new-project-modal'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import type { StarterTemplate } from '@/lib/templates'; +import { useNavigate } from '@tanstack/react-router'; export function WelcomeView() { const { @@ -66,24 +65,24 @@ export function WelcomeView() { const api = getElectronAPI(); if (!api.autoMode?.analyzeProject) { - console.log("[Welcome] Auto mode API not available, skipping analysis"); + console.log('[Welcome] Auto mode API not available, skipping analysis'); return; } setIsAnalyzing(true); try { - console.log("[Welcome] Starting project analysis for:", projectPath); + console.log('[Welcome] Starting project analysis for:', projectPath); const result = await api.autoMode.analyzeProject(projectPath); if (result.success) { - toast.success("Project analyzed", { - description: "AI agent has analyzed your project structure", + toast.success('Project analyzed', { + description: 'AI agent has analyzed your project structure', }); } else { - console.error("[Welcome] Project analysis failed:", result.error); + console.error('[Welcome] Project analysis failed:', result.error); } } catch (error) { - console.error("[Welcome] Failed to analyze project:", error); + console.error('[Welcome] Failed to analyze project:', error); } finally { setIsAnalyzing(false); } @@ -100,8 +99,8 @@ export function WelcomeView() { const initResult = await initializeProject(path); if (!initResult.success) { - toast.error("Failed to initialize project", { - description: initResult.error || "Unknown error occurred", + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', }); return; } @@ -126,26 +125,23 @@ export function WelcomeView() { setShowInitDialog(true); // Kick off agent to analyze the project and update app_spec.txt - console.log( - "[Welcome] Project initialized, created files:", - initResult.createdFiles - ); - console.log("[Welcome] Kicking off project analysis agent..."); + console.log('[Welcome] Project initialized, created files:', initResult.createdFiles); + console.log('[Welcome] Kicking off project analysis agent...'); // Start analysis in background (don't await, let it run async) analyzeProject(path); } else { - toast.success("Project opened", { + toast.success('Project opened', { description: `Opened ${name}`, }); } // Navigate to the board view - navigate({ to: "/board" }); + navigate({ to: '/board' }); } catch (error) { - console.error("[Welcome] Failed to open project:", error); - toast.error("Failed to open project", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('[Welcome] Failed to open project:', error); + toast.error('Failed to open project', { + description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsOpening(false); @@ -178,21 +174,19 @@ export function WelcomeView() { if (!result.canceled && result.filePaths[0]) { const path = result.filePaths[0]; // Extract folder name from path (works on both Windows and Mac/Linux) - const name = - path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; + const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; await initializeAndOpenProject(path, name); } } } catch (error) { - console.error("[Welcome] Failed to check workspace config:", error); + console.error('[Welcome] Failed to check workspace config:', error); // Fall back to current behavior on error const api = getElectronAPI(); const result = await api.openDirectory(); if (!result.canceled && result.filePaths[0]) { const path = result.filePaths[0]; - const name = - path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; + const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; await initializeAndOpenProject(path, name); } } @@ -224,16 +218,13 @@ export function WelcomeView() { }; const handleInteractiveMode = () => { - navigate({ to: "/interview" }); + navigate({ to: '/interview' }); }; /** * Create a blank project with just .automaker directory structure */ - const handleCreateBlankProject = async ( - projectName: string, - parentDir: string - ) => { + const handleCreateBlankProject = async (projectName: string, parentDir: string) => { setIsCreating(true); try { const api = getElectronAPI(); @@ -242,7 +233,7 @@ export function WelcomeView() { // Validate that parent directory exists const parentExists = await api.exists(parentDir); if (!parentExists) { - toast.error("Parent directory does not exist", { + toast.error('Parent directory does not exist', { description: `Cannot create project in non-existent directory: ${parentDir}`, }); return; @@ -251,7 +242,7 @@ export function WelcomeView() { // Verify parent is actually a directory const parentStat = await api.stat(parentDir); if (parentStat && !parentStat.isDirectory) { - toast.error("Parent path is not a directory", { + toast.error('Parent path is not a directory', { description: `${parentDir} is not a directory`, }); return; @@ -260,8 +251,8 @@ export function WelcomeView() { // 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", + toast.error('Failed to create project directory', { + description: mkdirResult.error || 'Unknown error occurred', }); return; } @@ -270,8 +261,8 @@ export function WelcomeView() { const initResult = await initializeProject(projectPath); if (!initResult.success) { - toast.error("Failed to initialize project", { - description: initResult.error || "Unknown error occurred", + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', }); return; } @@ -313,7 +304,7 @@ export function WelcomeView() { setCurrentProject(project); setShowNewProjectModal(false); - toast.success("Project created", { + toast.success('Project created', { description: `Created ${projectName} with .automaker directory`, }); @@ -326,9 +317,9 @@ export function WelcomeView() { }); setShowInitDialog(true); } catch (error) { - console.error("Failed to create project:", error); - toast.error("Failed to create project", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('Failed to create project:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsCreating(false); @@ -356,8 +347,8 @@ export function WelcomeView() { ); if (!cloneResult.success || !cloneResult.projectPath) { - toast.error("Failed to clone template", { - description: cloneResult.error || "Unknown error occurred", + toast.error('Failed to clone template', { + description: cloneResult.error || 'Unknown error occurred', }); return; } @@ -368,8 +359,8 @@ export function WelcomeView() { const initResult = await initializeProject(projectPath); if (!initResult.success) { - toast.error("Failed to initialize project", { - description: initResult.error || "Unknown error occurred", + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', }); return; } @@ -387,15 +378,11 @@ export function WelcomeView() { - ${template.techStack - .map((tech) => `${tech}`) - .join("\n ")} + ${template.techStack.map((tech) => `${tech}`).join('\n ')} - ${template.features - .map((feature) => `${feature}`) - .join("\n ")} + ${template.features.map((feature) => `${feature}`).join('\n ')} @@ -415,7 +402,7 @@ export function WelcomeView() { setCurrentProject(project); setShowNewProjectModal(false); - toast.success("Project created from template", { + toast.success('Project created from template', { description: `Created ${projectName} from ${template.name}`, }); @@ -431,9 +418,9 @@ export function WelcomeView() { // Kick off project analysis analyzeProject(projectPath); } catch (error) { - console.error("Failed to create project from template:", error); - toast.error("Failed to create project", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('Failed to create project from template:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsCreating(false); @@ -454,15 +441,11 @@ export function WelcomeView() { const api = getElectronAPI(); // Clone the repository - const cloneResult = await httpClient.templates.clone( - repoUrl, - projectName, - parentDir - ); + 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", + toast.error('Failed to clone repository', { + description: cloneResult.error || 'Unknown error occurred', }); return; } @@ -473,8 +456,8 @@ export function WelcomeView() { const initResult = await initializeProject(projectPath); if (!initResult.success) { - toast.error("Failed to initialize project", { - description: initResult.error || "Unknown error occurred", + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', }); return; } @@ -516,7 +499,7 @@ export function WelcomeView() { setCurrentProject(project); setShowNewProjectModal(false); - toast.success("Project created from repository", { + toast.success('Project created from repository', { description: `Created ${projectName} from ${repoUrl}`, }); @@ -532,9 +515,9 @@ export function WelcomeView() { // Kick off project analysis analyzeProject(projectPath); } catch (error) { - console.error("Failed to create project from custom URL:", error); - toast.error("Failed to create project", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('Failed to create project from custom URL:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsCreating(false); @@ -587,12 +570,9 @@ export function WelcomeView() {
-

- New Project -

+

New Project

- Create a new project from scratch with AI-powered - development + Create a new project from scratch with AI-powered development

@@ -608,10 +588,7 @@ export function WelcomeView() { - + Quick Setup @@ -640,9 +617,7 @@ export function WelcomeView() {
-

- Open Project -

+

Open Project

Open an existing project folder to continue working

@@ -667,9 +642,7 @@ export function WelcomeView() {
-

- Recent Projects -

+

Recent Projects

{recentProjects.map((project, index) => ( @@ -695,9 +668,7 @@ export function WelcomeView() {

{project.lastOpened && (

- {new Date( - project.lastOpened - ).toLocaleDateString()} + {new Date(project.lastOpened).toLocaleDateString()}

)}
@@ -715,9 +686,7 @@ export function WelcomeView() {
-

- No projects yet -

+

No projects yet

Get started by creating a new project or opening an existing one

@@ -747,9 +716,7 @@ export function WelcomeView() {
- {initStatus?.isNewProject - ? "Project Initialized" - : "Project Updated"} + {initStatus?.isNewProject ? 'Project Initialized' : 'Project Updated'} {initStatus?.isNewProject @@ -759,9 +726,7 @@ export function WelcomeView() {
-

- Created files: -

+

Created files:

    {initStatus?.createdFiles.map((file) => (
  • ) : (

    - Tip: Edit the{" "} + Tip: Edit the{' '} app_spec.txt - {" "} - file to describe your project. The AI agent will use this to - understand your project structure. + {' '} + file to describe your project. The AI agent will use this to understand your + project structure.

    )}
@@ -826,9 +791,7 @@ export function WelcomeView() { >
-

- Initializing project... -

+

Initializing project...

)} From 7e8995df243b5fd28c505a8f95b966e2cdd379bf Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 19:51:04 +0100 Subject: [PATCH 03/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20implemen?= =?UTF-8?q?t=20Phase=203=20sidebar=20refactoring=20(partial)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract inline components and organize sidebar structure: - Create sidebar/ subfolder structure (components/, hooks/, dialogs/) - Extract types.ts: NavSection, NavItem, component prop interfaces - Extract constants.ts: theme options, feature flags - Extract 3 inline components into separate files: - sortable-project-item.tsx (drag-and-drop project item) - theme-menu-item.tsx (memoized theme selector) - bug-report-button.tsx (reusable bug report button) - Update sidebar.tsx to import from extracted modules - Reduce sidebar.tsx from 2323 to 2187 lines (-136 lines) This is Phase 3 (partial) of folder-pattern.md compliance: breaking down the monolithic sidebar.tsx into maintainable, reusable components. Further refactoring (hooks extraction, dialog extraction) can be done incrementally to avoid disrupting functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/ui/src/components/layout/sidebar.tsx | 167 ++---------------- .../sidebar/components/bug-report-button.tsx | 23 +++ .../layout/sidebar/components/index.ts | 3 + .../components/sortable-project-item.tsx | 54 ++++++ .../sidebar/components/theme-menu-item.tsx | 27 +++ .../components/layout/sidebar/constants.ts | 24 +++ .../ui/src/components/layout/sidebar/types.ts | 36 ++++ 7 files changed, 178 insertions(+), 156 deletions(-) create mode 100644 apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/index.ts create mode 100644 apps/ui/src/components/layout/sidebar/components/sortable-project-item.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/theme-menu-item.tsx create mode 100644 apps/ui/src/components/layout/sidebar/constants.ts create mode 100644 apps/ui/src/components/layout/sidebar/types.ts diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 3cafe0207..39ffef973 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -83,159 +83,18 @@ import { useSensors, closestCenter, } from '@dnd-kit/core'; -import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; 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 type { NavSection, NavItem } from './sidebar/types'; +import { SortableProjectItem, ThemeMenuItem, BugReportButton } from './sidebar/components'; +import { + PROJECT_DARK_THEMES, + PROJECT_LIGHT_THEMES, + SIDEBAR_FEATURE_FLAGS, +} from './sidebar/constants'; export function Sidebar() { const navigate = useNavigate(); @@ -267,12 +126,8 @@ export function Sidebar() { } = 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(); 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..68a413c43 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx @@ -0,0 +1,23 @@ +import { Bug } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { BugReportButtonProps } from '../types'; + +export function BugReportButton({ sidebarExpanded, onClick }: BugReportButtonProps) { + 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..ecc7861eb --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/index.ts @@ -0,0 +1,3 @@ +export { SortableProjectItem } from './sortable-project-item'; +export { ThemeMenuItem } from './theme-menu-item'; +export { BugReportButton } from './bug-report-button'; 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/types.ts b/apps/ui/src/components/layout/sidebar/types.ts new file mode 100644 index 000000000..e76e4917c --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/types.ts @@ -0,0 +1,36 @@ +import type { Project } from '@/lib/electron'; + +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; +} + +export interface BugReportButtonProps { + sidebarExpanded: boolean; + onClick: () => void; +} From 7fac115a361e87e931dc702413d7dd41a88c0bf5 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 20:01:26 +0100 Subject: [PATCH 04/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20extract?= =?UTF-8?q?=20Phase=201=20hooks=20from=20sidebar=20(2187=E2=86=922099=20li?= =?UTF-8?q?nes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract 3 simple hooks with no UI dependencies: - use-theme-preview.ts: Debounced theme preview on hover - use-sidebar-auto-collapse.ts: Auto-collapse on small screens - use-drag-and-drop.ts: Project reordering drag-and-drop Benefits: - Reduced sidebar.tsx by 88 lines (-4%) - Improved testability (hooks can be tested in isolation) - Removed unused imports (DragEndEvent, PointerSensor, useSensor, useSensors) - Created hooks/ barrel export pattern Next steps: Extract 10+ remaining hooks and 10+ UI sections to reach target of 200-300 lines (current: 2099 lines, need to reduce ~1800 more) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/ui/src/components/layout/sidebar.tsx | 96 ++----------------- .../components/layout/sidebar/hooks/index.ts | 3 + .../layout/sidebar/hooks/use-drag-and-drop.ts | 41 ++++++++ .../hooks/use-sidebar-auto-collapse.ts | 30 ++++++ .../layout/sidebar/hooks/use-theme-preview.ts | 53 ++++++++++ 5 files changed, 134 insertions(+), 89 deletions(-) create mode 100644 apps/ui/src/components/layout/sidebar/hooks/index.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 39ffef973..adc68ac2a 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -75,14 +75,7 @@ import { DeleteProjectDialog } from '@/components/views/settings-view/components 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 { DndContext, closestCenter } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { getHttpApiClient } from '@/lib/http-api-client'; import type { StarterTemplate } from '@/lib/templates'; @@ -95,6 +88,7 @@ import { PROJECT_LIGHT_THEMES, SIDEBAR_FEATURE_FLAGS, } from './sidebar/constants'; +import { useThemePreview, useSidebarAutoCollapse, useDragAndDrop } from './sidebar/hooks'; export function Sidebar() { const navigate = useNavigate(); @@ -164,45 +158,8 @@ export function Sidebar() { 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); - } - }; - }, []); + // Debounced preview theme handlers + const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); // Derive isCreatingSpec from store state const isCreatingSpec = specCreatingForProject !== null; @@ -212,23 +169,7 @@ export function Sidebar() { 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]); + useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); // Filtered projects based on search query const filteredProjects = useMemo(() => { @@ -262,31 +203,8 @@ export function Sidebar() { } }, [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] - ); + // Drag-and-drop for project reordering + const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); // Subscribe to spec regeneration events useEffect(() => { 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..0255a7e51 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/index.ts @@ -0,0 +1,3 @@ +export { useThemePreview } from './use-theme-preview'; +export { useSidebarAutoCollapse } from './use-sidebar-auto-collapse'; +export { useDragAndDrop } from './use-drag-and-drop'; 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-sidebar-auto-collapse.ts b/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts new file mode 100644 index 000000000..994da0880 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; + +interface UseSidebarAutoCollapseProps { + sidebarOpen: boolean; + toggleSidebar: () => void; +} + +export function useSidebarAutoCollapse({ + sidebarOpen, + toggleSidebar, +}: UseSidebarAutoCollapseProps) { + // 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]); +} 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, + }; +} From b641884c37b9510ef6608bb0c8dfe01fe28b85a6 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 20:20:50 +0100 Subject: [PATCH 05/12] refactor: enhance sidebar functionality with new hooks and components - Introduced new hooks: useRunningAgents, useTrashOperations, useProjectPicker, useSpecRegeneration, and useNavigation for improved state management and functionality. - Created CollapseToggleButton component for sidebar collapse functionality, enhancing UI responsiveness. - Refactored sidebar.tsx to utilize the new hooks and components, improving code organization and maintainability. - Updated sidebar structure to streamline project selection and navigation processes. This refactor aims to enhance user experience and maintainability by modularizing functionality and improving the sidebar's responsiveness. --- apps/ui/src/components/layout/sidebar.tsx | 505 +++--------------- .../components/collapse-toggle-button.tsx | 60 +++ .../layout/sidebar/components/index.ts | 1 + .../components/layout/sidebar/hooks/index.ts | 5 + .../layout/sidebar/hooks/use-navigation.ts | 211 ++++++++ .../sidebar/hooks/use-project-picker.ts | 105 ++++ .../sidebar/hooks/use-running-agents.ts | 53 ++ .../sidebar/hooks/use-spec-regeneration.ts | 78 +++ .../sidebar/hooks/use-trash-operations.ts | 92 ++++ 9 files changed, 679 insertions(+), 431 deletions(-) create mode 100644 apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-spec-regeneration.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-trash-operations.ts diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index adc68ac2a..0fa7ce548 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -1,4 +1,4 @@ -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'; @@ -6,9 +6,6 @@ import { FolderOpen, Plus, Settings, - FileText, - LayoutGrid, - Bot, Folder, X, PanelLeft, @@ -16,12 +13,10 @@ import { ChevronDown, Redo2, Check, - BookOpen, GripVertical, RotateCcw, Trash2, Undo2, - UserCircle, MoreVertical, Palette, Monitor, @@ -31,7 +26,6 @@ import { Recycle, Sparkles, Loader2, - Terminal, Rocket, Zap, CheckCircle2, @@ -61,16 +55,11 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { - useKeyboardShortcuts, - useKeyboardShortcutsConfig, - KeyboardShortcut, -} from '@/hooks/use-keyboard-shortcuts'; +import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { getElectronAPI, Project, TrashedProject, RunningAgent } 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/dialogs/new-project-modal'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; @@ -81,14 +70,27 @@ import { getHttpApiClient } from '@/lib/http-api-client'; import type { StarterTemplate } from '@/lib/templates'; // Local imports from subfolder -import type { NavSection, NavItem } from './sidebar/types'; -import { SortableProjectItem, ThemeMenuItem, BugReportButton } from './sidebar/components'; +import { + SortableProjectItem, + ThemeMenuItem, + BugReportButton, + CollapseToggleButton, +} from './sidebar/components'; import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES, SIDEBAR_FEATURE_FLAGS, } from './sidebar/constants'; -import { useThemePreview, useSidebarAutoCollapse, useDragAndDrop } from './sidebar/hooks'; +import { + useThemePreview, + useSidebarAutoCollapse, + useDragAndDrop, + useRunningAgents, + useTrashOperations, + useProjectPicker, + useSpecRegeneration, + useNavigation, +} from './sidebar/hooks'; export function Sidebar() { const navigate = useNavigate(); @@ -128,18 +130,11 @@ export function Sidebar() { // State for project picker dropdown 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); @@ -165,132 +160,56 @@ export function Sidebar() { const isCreatingSpec = specCreatingForProject !== null; const creatingSpecProjectPath = specCreatingForProject; - // Ref for project search input - const projectSearchInputRef = useRef(null); - // Auto-collapse sidebar on small screens useSidebarAutoCollapse({ 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]); + // Project picker with search and keyboard navigation + const { + projectSearchQuery, + setProjectSearchQuery, + selectedProjectIndex, + setSelectedProjectIndex, + projectSearchInputRef, + filteredProjects, + selectHighlightedProject, + } = useProjectPicker({ + projects, + isProjectPickerOpen, + setIsProjectPickerOpen, + setCurrentProject, + }); // Drag-and-drop for project reordering const { sensors, handleDragEnd } = useDragAndDrop({ 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]); + // Running agents count + const { runningAgentsCount } = useRunningAgents(); - // 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(); - } - }); + // Trash operations + const { + activeTrashId, + isEmptyingTrash, + handleRestoreProject, + handleDeleteProjectFromDisk, + handleEmptyTrash, + } = useTrashOperations({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + trashedProjects, + }); - return () => { - unsubscribe(); - }; - }, [fetchRunningAgentsCount]); + // Spec regeneration events + useSpecRegeneration({ + creatingSpecProjectPath, + setupProjectPath, + setSpecCreatingForProject, + setShowSetupDialog, + setProjectOverview, + setSetupProjectPath, + setNewProjectName, + setNewProjectPath, + }); // Handle creating initial spec for new project const handleCreateInitialSpec = useCallback(async () => { @@ -711,261 +630,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); @@ -990,49 +671,11 @@ export function Sidebar() { )} data-testid="sidebar" > - {/* Floating Collapse Toggle Button - Desktop only - At border intersection */} - +
{/* Logo */} 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 index ecc7861eb..0e320be98 100644 --- a/apps/ui/src/components/layout/sidebar/components/index.ts +++ b/apps/ui/src/components/layout/sidebar/components/index.ts @@ -1,3 +1,4 @@ export { SortableProjectItem } from './sortable-project-item'; export { ThemeMenuItem } from './theme-menu-item'; export { BugReportButton } from './bug-report-button'; +export { CollapseToggleButton } from './collapse-toggle-button'; diff --git a/apps/ui/src/components/layout/sidebar/hooks/index.ts b/apps/ui/src/components/layout/sidebar/hooks/index.ts index 0255a7e51..c5cca3b87 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/index.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/index.ts @@ -1,3 +1,8 @@ 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'; 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-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-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-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-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, + }; +} From aafd0b39913f83c098f7ffa8a93ab2266cb9825d Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 20:29:16 +0100 Subject: [PATCH 06/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20extract?= =?UTF-8?q?=20UI=20components=20from=20sidebar=20for=20better=20maintainab?= =?UTF-8?q?ility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract logo, header, actions, and navigation into separate components: - AutomakerLogo: SVG logo with collapsed/expanded states - SidebarHeader: Logo section with bug report button - ProjectActions: New/Open/Trash action buttons - SidebarNavigation: Navigation items with active states Reduces sidebar.tsx from 1551 to 1442 lines (-109 lines) Improves code organization and component reusability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/ui/src/components/layout/sidebar.tsx | 340 ++---------------- .../sidebar/components/automaker-logo.tsx | 117 ++++++ .../layout/sidebar/components/index.ts | 4 + .../sidebar/components/project-actions.tsx | 91 +++++ .../sidebar/components/sidebar-header.tsx | 40 +++ .../sidebar/components/sidebar-navigation.tsx | 140 ++++++++ 6 files changed, 414 insertions(+), 318 deletions(-) create mode 100644 apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/project-actions.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 0fa7ce548..2b8c1e8a1 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -3,13 +3,9 @@ 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, Folder, X, - PanelLeft, - PanelLeftClose, ChevronDown, Redo2, Check, @@ -21,9 +17,7 @@ import { Palette, Monitor, Search, - Bug, Activity, - Recycle, Sparkles, Loader2, Rocket, @@ -75,6 +69,9 @@ import { ThemeMenuItem, BugReportButton, CollapseToggleButton, + SidebarHeader, + ProjectActions, + SidebarNavigation, } from './sidebar/components'; import { PROJECT_DARK_THEMES, @@ -678,204 +675,21 @@ export function Sidebar() { />
- {/* 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 */} @@ -1163,123 +977,13 @@ export function Sidebar() {
)} - {/* Nav Items - Scrollable */} - +
{/* Bottom Section - Running Agents / Bug Report / Settings */} diff --git a/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx b/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx new file mode 100644 index 000000000..66345b92e --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx @@ -0,0 +1,117 @@ +import type { NavigateOptions } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; + +interface AutomakerLogoProps { + sidebarOpen: boolean; + navigate: (opts: NavigateOptions) => 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/index.ts b/apps/ui/src/components/layout/sidebar/components/index.ts index 0e320be98..31ecd8525 100644 --- a/apps/ui/src/components/layout/sidebar/components/index.ts +++ b/apps/ui/src/components/layout/sidebar/components/index.ts @@ -2,3 +2,7 @@ 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'; 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/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx new file mode 100644 index 000000000..89352f15b --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -0,0 +1,40 @@ +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; + handleBugReportClick: () => void; +} + +export function SidebarHeader({ sidebarOpen, navigate, handleBugReportClick }: 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 ( + + ); +} From a40bb6df24e04e986e0b9544c51a9ac62a2ef871 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 21:23:04 +0100 Subject: [PATCH 07/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20streamli?= =?UTF-8?q?ne=20sidebar=20component=20structure=20and=20enhance=20function?= =?UTF-8?q?ality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extracted new components: ProjectSelectorWithOptions, SidebarFooter, TrashDialog, and OnboardingDialog to improve code organization and reusability. - Introduced new hooks: useProjectCreation, useSetupDialog, and useTrashDialog for better state management and modularity. - Updated sidebar.tsx to utilize the new components and hooks, reducing complexity and improving maintainability. - Enhanced project creation and setup processes with dedicated dialogs and streamlined user interactions. This refactor aims to enhance the user experience and maintainability of the sidebar by modularizing functionality and improving the overall structure. --- apps/ui/src/components/layout/sidebar.tsx | 1255 ++--------------- .../layout/sidebar/components/index.ts | 2 + .../project-selector-with-options.tsx | 374 +++++ .../sidebar/components/sidebar-footer.tsx | 269 ++++ .../layout/sidebar/dialogs/index.ts | 2 + .../sidebar/dialogs/onboarding-dialog.tsx | 122 ++ .../layout/sidebar/dialogs/trash-dialog.tsx | 116 ++ .../components/layout/sidebar/hooks/index.ts | 4 + .../sidebar/hooks/use-project-creation.ts | 175 +++ .../layout/sidebar/hooks/use-project-theme.ts | 25 + .../layout/sidebar/hooks/use-setup-dialog.ts | 147 ++ .../layout/sidebar/hooks/use-trash-dialog.ts | 40 + 12 files changed, 1371 insertions(+), 1160 deletions(-) create mode 100644 apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx create mode 100644 apps/ui/src/components/layout/sidebar/dialogs/index.ts create mode 100644 apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx create mode 100644 apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 2b8c1e8a1..e59c67441 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -1,92 +1,35 @@ 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 { - Settings, - Folder, - X, - ChevronDown, - Redo2, - Check, - GripVertical, - RotateCcw, - Trash2, - Undo2, - MoreVertical, - Palette, - Monitor, - Search, - Activity, - Sparkles, - Loader2, - 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 { useAppStore, type ThemeMode } from '@/store/app-store'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; -import { getElectronAPI, Project, TrashedProject, RunningAgent } from '@/lib/electron'; +import { getElectronAPI } from '@/lib/electron'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { toast } from 'sonner'; -import { themeOptions } from '@/config/theme-options'; import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog'; 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, closestCenter } from '@dnd-kit/core'; -import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { getHttpApiClient } from '@/lib/http-api-client'; -import type { StarterTemplate } from '@/lib/templates'; // Local imports from subfolder import { - SortableProjectItem, - ThemeMenuItem, - BugReportButton, CollapseToggleButton, SidebarHeader, ProjectActions, SidebarNavigation, + ProjectSelectorWithOptions, + SidebarFooter, } from './sidebar/components'; +import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; +import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants'; import { - PROJECT_DARK_THEMES, - PROJECT_LIGHT_THEMES, - SIDEBAR_FEATURE_FLAGS, -} from './sidebar/constants'; -import { - useThemePreview, useSidebarAutoCollapse, - useDragAndDrop, useRunningAgents, - useTrashOperations, - useProjectPicker, useSpecRegeneration, useNavigation, + useProjectCreation, + useSetupDialog, + useTrashDialog, + useProjectTheme, } from './sidebar/hooks'; export function Sidebar() { @@ -100,19 +43,12 @@ export function Sidebar() { sidebarOpen, projectHistory, upsertAndSetCurrentProject, - setCurrentProject, toggleSidebar, restoreTrashedProject, deleteTrashedProject, emptyTrash, - reorderProjects, cyclePrevProject, cycleNextProject, - clearProjectHistory, - setProjectTheme, - setTheme, - setPreviewTheme, - theme: globalTheme, moveProjectToTrash, specCreatingForProject, setSpecCreatingForProject, @@ -125,33 +61,61 @@ export function Sidebar() { // 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 [showTrashDialog, setShowTrashDialog] = useState(false); // State for delete project confirmation dialog const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); - // 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(''); + // Project theme management (must come before useProjectCreation which uses globalTheme) + const { globalTheme } = useProjectTheme(); - // 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); + // Project creation state and handlers + const { + showNewProjectModal, + setShowNewProjectModal, + isCreatingProject, + showOnboardingDialog, + setShowOnboardingDialog, + newProjectName, + setNewProjectName, + newProjectPath, + setNewProjectPath, + handleCreateBlankProject, + handleCreateFromTemplate, + handleCreateFromCustomUrl, + } = useProjectCreation({ + trashedProjects, + currentProject, + globalTheme, + upsertAndSetCurrentProject, + }); - // Debounced preview theme handlers - const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); + // Setup dialog state and handlers + const { + showSetupDialog, + setShowSetupDialog, + setupProjectPath, + setSetupProjectPath, + projectOverview, + setProjectOverview, + generateFeatures, + setGenerateFeatures, + analyzeProject, + setAnalyzeProject, + featureCount, + setFeatureCount, + handleCreateInitialSpec, + handleSkipSetup, + handleOnboardingGenerateSpec, + handleOnboardingSkip, + } = useSetupDialog({ + setSpecCreatingForProject, + newProjectPath, + setNewProjectName, + setNewProjectPath, + setShowOnboardingDialog, + }); // Derive isCreatingSpec from store state const isCreatingSpec = specCreatingForProject !== null; @@ -160,36 +124,19 @@ export function Sidebar() { // Auto-collapse sidebar on small screens useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); - // Project picker with search and keyboard navigation - const { - projectSearchQuery, - setProjectSearchQuery, - selectedProjectIndex, - setSelectedProjectIndex, - projectSearchInputRef, - filteredProjects, - selectHighlightedProject, - } = useProjectPicker({ - projects, - isProjectPickerOpen, - setIsProjectPickerOpen, - setCurrentProject, - }); - - // Drag-and-drop for project reordering - const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); - // Running agents count const { runningAgentsCount } = useRunningAgents(); - // Trash operations + // Trash dialog and operations const { + showTrashDialog, + setShowTrashDialog, activeTrashId, isEmptyingTrash, handleRestoreProject, handleDeleteProjectFromDisk, handleEmptyTrash, - } = useTrashOperations({ + } = useTrashDialog({ restoreTrashedProject, deleteTrashedProject, emptyTrash, @@ -208,355 +155,6 @@ export function Sidebar() { setNewProjectPath, }); - // 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); - 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 - ); - - 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', - }); - } - }, [ - 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]); - - // 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. - - - - - - - - - - - - - -` - ); - - 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 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] - ); - // Handle bug report button click const handleBugReportClick = useCallback(() => { const api = getElectronAPI(); @@ -597,7 +195,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); @@ -692,290 +290,12 @@ export function Sidebar() { /> )} - {/* 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 - -
-
- )} -
- )} +
- {/* 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; + 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/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 index c5cca3b87..7a047f8a9 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/index.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/index.ts @@ -6,3 +6,7 @@ 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-project-creation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts new file mode 100644 index 000000000..3d75fabb0 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts @@ -0,0 +1,175 @@ +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 basic XML structure + const api = getElectronAPI(); + await api.fs.writeFile( + `${projectPath}/app_spec.txt`, + `\n\n ${projectName}\n Add your project description here\n` + ); + + // 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); + + // Finalize project setup + await finalizeProjectCreation(projectPath, projectName); + } 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); + } + }, + [finalizeProjectCreation] + ); + + /** + * 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); + + // Finalize project setup + await finalizeProjectCreation(projectPath, projectName); + } 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); + } + }, + [finalizeProjectCreation] + ); + + 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-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-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-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, + }; +} From 9ea80123fd166bb6ba97cafefbc03adef57a0e26 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 21:44:02 +0100 Subject: [PATCH 08/12] =?UTF-8?q?=E2=9C=A8=20update:=20enhance=20WikiView?= =?UTF-8?q?=20component=20with=20improved=20type=20definitions=20and=20doc?= =?UTF-8?q?umentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated type imports for `icon` and `content` in the `WikiSection` interface to use `ElementType` and `ReactNode` for better clarity and type safety. - Expanded the content description in the WikiView to include shared libraries and updated technology stack details. - Revised the directory structure representation for clarity and completeness, reflecting the current organization of the codebase. - Adjusted file paths in the feature list for better accuracy and organization. These changes aim to improve the documentation and type safety within the WikiView component, enhancing developer experience and understanding of the project structure. --- apps/ui/src/components/views/wiki-view.tsx | 322 ++++++++++----------- 1 file changed, 157 insertions(+), 165 deletions(-) diff --git a/apps/ui/src/components/views/wiki-view.tsx b/apps/ui/src/components/views/wiki-view.tsx index fed946faf..7192c9b4b 100644 --- a/apps/ui/src/components/views/wiki-view.tsx +++ b/apps/ui/src/components/views/wiki-view.tsx @@ -1,5 +1,4 @@ -import { useState } from "react"; -import { cn } from "@/lib/utils"; +import { useState, type ReactNode, type ElementType } from 'react'; import { ChevronDown, ChevronRight, @@ -13,7 +12,6 @@ import { PlayCircle, Bot, LayoutGrid, - FileText, Terminal, Palette, Keyboard, @@ -23,13 +21,13 @@ import { TestTube, Brain, Users, -} from "lucide-react"; +} from 'lucide-react'; interface WikiSection { id: string; title: string; - icon: React.ElementType; - content: React.ReactNode; + icon: ElementType; + content: ReactNode; } function CollapsibleSection({ @@ -52,9 +50,7 @@ function CollapsibleSection({
- - {section.title} - + {section.title} {isOpen ? ( ) : ( @@ -90,7 +86,7 @@ function CodeBlock({ children, title }: { children: string; title?: string }) { function FeatureList({ items, }: { - items: { icon: React.ElementType; title: string; description: string }[]; + items: { icon: ElementType; title: string; description: string }[]; }) { return (
@@ -105,12 +101,8 @@ function FeatureList({
-
- {item.title} -
-
- {item.description} -
+
{item.title}
+
{item.description}
); @@ -120,9 +112,7 @@ function FeatureList({ } export function WikiView() { - const [openSections, setOpenSections] = useState>( - new Set(["overview"]) - ); + const [openSections, setOpenSections] = useState>(new Set(['overview'])); const toggleSection = (id: string) => { setOpenSections((prev) => { @@ -146,66 +136,66 @@ export function WikiView() { const sections: WikiSection[] = [ { - id: "overview", - title: "Project Overview", + id: 'overview', + title: 'Project Overview', icon: Rocket, content: (

- Automaker is an - autonomous AI development studio that helps developers build - software faster using AI agents. + Automaker is an autonomous AI development + studio that helps developers build software faster using AI agents.

- At its core, Automaker provides a visual Kanban board to manage - features. When you're ready, AI agents automatically implement those - features in your codebase, complete with git worktree isolation for - safe parallel development. + At its core, Automaker provides a visual Kanban board to manage features. When you're + ready, AI agents automatically implement those features in your codebase, complete with + git worktree isolation for safe parallel development.

- Think of it as having a team of AI developers that can work on - multiple features simultaneously while you focus on the bigger - picture. + Think of it as having a team of AI developers that can work on multiple features + simultaneously while you focus on the bigger picture.

), }, { - id: "architecture", - title: "Architecture", + id: 'architecture', + title: 'Architecture', icon: Layers, content: (
-

Automaker is built as a monorepo with two main applications:

+

Automaker is built as a monorepo with two main applications and shared libraries:

  • - apps/ui - Next.js + + apps/ui - React + TanStack Router + Electron frontend for the desktop application
  • - apps/server - Express - backend handling API requests and agent orchestration + apps/server - Express backend handling + API requests and agent orchestration +
  • +
  • + libs/ - Shared packages for types, + utilities, and common logic used across apps

Key Technologies:

    -
  • Electron wraps Next.js for cross-platform desktop support
  • -
  • - Real-time communication via WebSocket for live agent updates -
  • +
  • Electron + React + TanStack Router for cross-platform desktop support
  • +
  • Real-time communication via WebSocket for live agent updates
  • State management with Zustand for reactive UI updates
  • Claude Agent SDK for AI capabilities
  • +
  • Shared monorepo packages (@automaker/*) for code reuse
), }, { - id: "features", - title: "Key Features", + id: 'features', + title: 'Key Features', icon: Sparkles, content: (
@@ -213,73 +203,69 @@ export function WikiView() { items={[ { icon: LayoutGrid, - title: "Kanban Board", + title: 'Kanban Board', description: - "4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.", + '4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.', }, { icon: Bot, - title: "AI Agent Integration", + title: 'AI Agent Integration', description: - "Powered by Claude via the Agent SDK with full file, bash, and git access.", + 'Powered by Claude via the Agent SDK with full file, bash, and git access.', }, { icon: Cpu, - title: "Multi-Model Support", + title: 'Multi-Model Support', description: - "Claude Haiku/Sonnet/Opus models. Choose the right model for each task.", + 'Claude Haiku/Sonnet/Opus models. Choose the right model for each task.', }, { icon: Brain, - title: "Extended Thinking", + title: 'Extended Thinking', description: - "Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.", + 'Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.', }, { icon: Zap, - title: "Real-time Streaming", - description: - "Watch AI agents work in real-time with live output streaming.", + title: 'Real-time Streaming', + description: 'Watch AI agents work in real-time with live output streaming.', }, { icon: GitBranch, - title: "Git Worktree Isolation", + title: 'Git Worktree Isolation', description: - "Each feature runs in its own git worktree for safe parallel development.", + 'Each feature runs in its own git worktree for safe parallel development.', }, { icon: Users, - title: "AI Profiles", + title: 'AI Profiles', description: - "Pre-configured model + thinking level combinations for different task types.", + 'Pre-configured model + thinking level combinations for different task types.', }, { icon: Terminal, - title: "Integrated Terminal", - description: - "Built-in terminal with tab support and split panes.", + title: 'Integrated Terminal', + description: 'Built-in terminal with tab support and split panes.', }, { icon: Keyboard, - title: "Keyboard Shortcuts", - description: "Fully customizable shortcuts for power users.", + title: 'Keyboard Shortcuts', + description: 'Fully customizable shortcuts for power users.', }, { icon: Palette, - title: "14 Themes", - description: - "From light to dark, retro to synthwave - pick your style.", + title: '14 Themes', + description: 'From light to dark, retro to synthwave - pick your style.', }, { icon: Image, - title: "Image Support", - description: "Attach images to features for visual context.", + title: 'Image Support', + description: 'Attach images to features for visual context.', }, { icon: TestTube, - title: "Test Integration", - description: - "Automatic test running and TDD support for quality assurance.", + title: 'Test Integration', + description: 'Automatic test running and TDD support for quality assurance.', }, ]} /> @@ -287,26 +273,23 @@ export function WikiView() { ), }, { - id: "data-flow", - title: "How It Works (Data Flow)", + id: 'data-flow', + title: 'How It Works (Data Flow)', icon: GitBranch, content: (
-

- Here's what happens when you use Automaker to implement a feature: -

+

Here's what happens when you use Automaker to implement a feature:

  1. Create Feature

    - Add a new feature card to the Kanban board with description and - steps + Add a new feature card to the Kanban board with description and steps

  2. Feature Saved

    - Feature saved to{" "} + Feature saved to{' '} .automaker/features/{id}/feature.json @@ -315,15 +298,13 @@ export function WikiView() {

  3. Start Work

    - Drag to "In Progress" or enable auto mode to start - implementation + Drag to "In Progress" or enable auto mode to start implementation

  4. Git Worktree Created

    - Backend AutoModeService creates isolated git worktree (if - enabled) + Backend AutoModeService creates isolated git worktree (if enabled)

  5. @@ -355,38 +336,64 @@ export function WikiView() { ), }, { - id: "structure", - title: "Project Structure", + id: 'structure', + title: 'Project Structure', icon: FolderTree, content: (
    -

    - The Automaker codebase is organized as follows: -

    +

    The Automaker codebase is organized as follows:

    - {`/automaker/ -├── apps/ -│ ├── app/ # Frontend (Next.js + Electron) -│ │ ├── electron/ # Electron main process -│ │ └── src/ -│ │ ├── app/ # Next.js App Router pages -│ │ ├── components/ # React components -│ │ ├── store/ # Zustand state management -│ │ ├── hooks/ # Custom React hooks -│ │ └── lib/ # Utilities and helpers -│ └── server/ # Backend (Express) -│ └── src/ -│ ├── routes/ # API endpoints -│ └── services/ # Business logic (AutoModeService, etc.) -├── docs/ # Documentation -└── package.json # Workspace root`} + {`automaker/ +├─ apps/ +│ ├─ ui/ Frontend (React + Electron) +│ │ └─ src/ +│ │ ├─ routes/ TanStack Router pages +│ │ ├─ components/ +│ │ │ ├─ layout/ Layout components (sidebar, etc.) +│ │ │ ├─ views/ View components (board, agent, etc.) +│ │ │ ├─ dialogs/ Dialog components +│ │ │ └─ ui/ shadcn/ui components +│ │ ├─ store/ Zustand state management +│ │ ├─ hooks/ Custom React hooks +│ │ ├─ lib/ Utilities and helpers +│ │ ├─ config/ App configuration files +│ │ ├─ contexts/ React context providers +│ │ ├─ styles/ CSS styles and theme definitions +│ │ ├─ types/ TypeScript type definitions +│ │ ├─ utils/ Utility functions +│ │ ├─ main.ts Electron main process entry +│ │ ├─ preload.ts Electron preload script +│ │ └─ renderer.tsx React renderer entry +│ │ +│ └─ server/ Backend (Express) +│ └─ src/ +│ ├─ routes/ API endpoints +│ ├─ services/ Business logic (AutoModeService, etc.) +│ ├─ lib/ Library utilities +│ ├─ middleware/ Express middleware +│ ├─ providers/ AI provider implementations +│ ├─ types/ TypeScript type definitions +│ └─ index.ts Server entry point +│ +├─ libs/ Shared packages (monorepo) +│ ├─ types/ TypeScript type definitions +│ ├─ utils/ Common utilities (logging, errors) +│ ├─ prompts/ AI prompt templates +│ ├─ platform/ Platform & path utilities +│ ├─ model-resolver/ Claude model resolution +│ ├─ dependency-resolver/ Feature dependency ordering +│ └─ git-utils/ Git operations & parsing +│ +├─ docs/ Documentation +└─ package.json Workspace root +`}
    ), }, { - id: "components", - title: "Key Components", + id: 'components', + title: 'Key Components', icon: Component, content: (
    @@ -394,33 +401,36 @@ export function WikiView() {
    {[ { - file: "sidebar.tsx", - desc: "Main navigation with project picker and view switching", + file: 'layout/sidebar.tsx', + desc: 'Main navigation with project picker and view switching', + }, + { + file: 'views/board-view.tsx', + desc: 'Kanban board with drag-and-drop cards', }, { - file: "board-view.tsx", - desc: "Kanban board with drag-and-drop cards", + file: 'views/agent-view.tsx', + desc: 'AI chat interface for conversational development', }, { - file: "agent-view.tsx", - desc: "AI chat interface for conversational development", + file: 'views/spec-view/', + desc: 'Project specification editor with AI generation', }, - { file: "spec-view.tsx", desc: "Project specification editor" }, { - file: "context-view.tsx", - desc: "Context file manager for AI context", + file: 'views/context-view.tsx', + desc: 'Context file manager for AI context', }, { - file: "terminal-view.tsx", - desc: "Integrated terminal with splits and tabs", + file: 'views/terminal-view/', + desc: 'Integrated terminal with splits and tabs', }, { - file: "profiles-view.tsx", - desc: "AI profile management (model + thinking presets)", + file: 'views/profiles-view.tsx', + desc: 'AI profile management (model + thinking presets)', }, { - file: "app-store.ts", - desc: "Central Zustand state management", + file: 'store/app-store.ts', + desc: 'Central Zustand state management', }, ].map((item) => (
    {item.file} - - {item.desc} - + {item.desc}
    ))}
    @@ -440,31 +448,28 @@ export function WikiView() { ), }, { - id: "configuration", - title: "Configuration", + id: 'configuration', + title: 'Configuration', icon: Settings, content: (

    - Automaker stores project configuration in the{" "} - - .automaker/ - {" "} - directory: + Automaker stores project configuration in the{' '} + .automaker/ directory:

    {[ { - file: "app_spec.txt", - desc: "Project specification describing your app for AI context", + file: 'app_spec.txt', + desc: 'Project specification describing your app for AI context', }, { - file: "context/", - desc: "Additional context files (docs, examples) for AI", + file: 'context/', + desc: 'Additional context files (docs, examples) for AI', }, { - file: "features/", - desc: "Feature definitions with descriptions and steps", + file: 'features/', + desc: 'Feature definitions with descriptions and steps', }, ].map((item) => (
    {item.file} - - {item.desc} - + {item.desc}
    ))}
    -

    - Tip: App Spec Best Practices -

    +

    Tip: App Spec Best Practices

    • Include your tech stack and key dependencies
    • Describe the project structure and conventions
    • @@ -495,8 +496,8 @@ export function WikiView() { ), }, { - id: "getting-started", - title: "Getting Started", + id: 'getting-started', + title: 'Getting Started', icon: PlayCircle, content: (
      @@ -505,43 +506,38 @@ export function WikiView() {
    • Create or Open a Project

      - Use the sidebar to create a new project or open an existing - folder + Use the sidebar to create a new project or open an existing folder

    • Write an App Spec

      - Go to Spec Editor and describe your project. This helps AI - understand your codebase. + Go to Spec Editor and describe your project. This helps AI understand your codebase.

    • Add Context (Optional)

      - Add relevant documentation or examples to the Context view for - better AI results + Add relevant documentation or examples to the Context view for better AI results

    • Create Features

      - Add feature cards to your Kanban board with clear descriptions - and implementation steps + Add feature cards to your Kanban board with clear descriptions and implementation + steps

    • Configure AI Profile

      - Choose an AI profile or customize model/thinking settings per - feature + Choose an AI profile or customize model/thinking settings per feature

    • Start Implementation

      - Drag features to "In Progress" or enable auto mode to let AI - work + Drag features to "In Progress" or enable auto mode to let AI work

    • @@ -555,16 +551,12 @@ export function WikiView() {

      Pro Tips:

      • - Use keyboard shortcuts for faster navigation (press{" "} - ?{" "} - to see all) -
      • -
      • - Enable git worktree isolation for parallel feature development + Use keyboard shortcuts for faster navigation (press{' '} + ? to see all)
      • +
      • Enable git worktree isolation for parallel feature development
      • - Start with "Quick Edit" profile for simple tasks, use "Heavy - Task" for complex work + Start with "Quick Edit" profile for simple tasks, use "Heavy Task" for complex work
      • Keep your app spec up to date as your project evolves
      From 7b1b2fa463b3424c15fe7b2fae335226ef1b53f3 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 22:16:59 +0100 Subject: [PATCH 09/12] fix: project creation process with structured app_spec.txt - Updated the project creation logic to write a detailed app_spec.txt file in XML format, including project name, overview, technology stack, core capabilities, and implemented features. - Improved handling for projects created from templates and custom repositories, ensuring relevant information is captured in the app_spec.txt. - Enhanced user feedback with success messages upon project creation, improving overall user experience. These changes aim to provide a clearer project structure and facilitate better integration with AI analysis tools. --- .../sidebar/hooks/use-project-creation.ts | 122 ++++++++++++++++-- 1 file changed, 113 insertions(+), 9 deletions(-) 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 index 3d75fabb0..c50c3d76b 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts @@ -37,11 +37,31 @@ export function useProjectCreation({ // Initialize .automaker directory structure await initializeProject(projectPath); - // Write initial app_spec.txt with basic XML structure + // 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}/app_spec.txt`, - `\n\n ${projectName}\n Add your project description here\n` + `${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 @@ -112,8 +132,50 @@ export function useProjectCreation({ // Clone template repository await api.git.clone(template.githubUrl, projectPath); - // Finalize project setup - await finalizeProjectCreation(projectPath, projectName); + // 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', { @@ -123,7 +185,7 @@ export function useProjectCreation({ setIsCreatingProject(false); } }, - [finalizeProjectCreation] + [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] ); /** @@ -139,8 +201,50 @@ export function useProjectCreation({ // Clone custom repository await api.git.clone(repoUrl, projectPath); - // Finalize project setup - await finalizeProjectCreation(projectPath, projectName); + // 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', { @@ -150,7 +254,7 @@ export function useProjectCreation({ setIsCreatingProject(false); } }, - [finalizeProjectCreation] + [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] ); return { From 43c93fe19a7345f2139422120d5fa73935ce8b4d Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 22:41:17 +0100 Subject: [PATCH 10/12] chore: remove pnpm-lock.yaml and add tests for ClaudeUsageService - Deleted the pnpm-lock.yaml file as part of project cleanup. - Introduced comprehensive unit tests for the ClaudeUsageService, covering methods for checking CLI availability, parsing reset times, and handling usage output. - Enhanced test coverage for both macOS and Windows environments, ensuring robust functionality across platforms. These changes aim to streamline project dependencies and improve the reliability of the Claude usage tracking service through thorough testing. --- .../services/claude-usage-service.test.ts | 637 ++++++++++++++++++ pnpm-lock.yaml | 70 -- 2 files changed, 637 insertions(+), 70 deletions(-) create mode 100644 apps/server/tests/unit/services/claude-usage-service.test.ts delete mode 100644 pnpm-lock.yaml 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..ed1ef69fc --- /dev/null +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -0,0 +1,637 @@ +import { describe, it, expect, vi, beforeEach } 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; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + windowsService.fetchUsageData(); + + // Simulate seeing usage data + dataCallback!(mockOutput); + + // Advance time to trigger escape key sending + vi.advanceTimersByTime(2100); + + expect(mockPty.write).toHaveBeenCalledWith('\x1b'); + + 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/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 06e3abdfe..000000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,70 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - cross-spawn: - specifier: ^7.0.6 - version: 7.0.6 - tree-kill: - specifier: ^1.2.2 - version: 1.2.2 - -packages: - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - -snapshots: - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - isexe@2.0.0: {} - - path-key@3.1.1: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - tree-kill@1.2.2: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 From 26236d3d5b0c5fccfd0cb605aab8b5fcfee731f1 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 23:08:08 +0100 Subject: [PATCH 11/12] feat: enhance ESLint configuration and improve component error handling - Updated ESLint configuration to include support for `.mjs` and `.cjs` file types, adding necessary global variables for Node.js and browser environments. - Introduced a new `vite-env.d.ts` file to define environment variables for Vite, improving type safety. - Refactored error handling in `file-browser-dialog.tsx`, `description-image-dropzone.tsx`, and `feature-image-upload.tsx` to omit error parameters, simplifying the catch blocks. - Removed unused bug report button functionality from the sidebar, streamlining the component structure. - Adjusted various components to improve code readability and maintainability, including updates to type imports and component props. These changes aim to enhance the development experience by improving linting support and simplifying error handling across components. --- apps/ui/eslint.config.mjs | 76 +++ .../dialogs/file-browser-dialog.tsx | 112 ++-- apps/ui/src/components/layout/sidebar.tsx | 12 +- .../sidebar/components/bug-report-button.tsx | 16 +- .../project-selector-with-options.tsx | 2 +- .../sidebar/components/sidebar-header.tsx | 7 +- .../ui/src/components/layout/sidebar/types.ts | 6 +- apps/ui/src/components/ui/accordion.tsx | 138 +++-- .../ui/description-image-dropzone.tsx | 186 ++++--- .../components/ui/feature-image-upload.tsx | 77 +-- apps/ui/src/components/ui/image-drop-zone.tsx | 238 +++++---- apps/ui/src/components/ui/sheet.tsx | 73 ++- apps/ui/src/components/views/agent-view.tsx | 304 +++++------ .../ui/src/components/views/analysis-view.tsx | 503 +++++++----------- .../views/board-view/board-header.tsx | 26 +- .../kanban-card/agent-info-panel.tsx | 130 ++--- .../board-view/dialogs/agent-output-modal.tsx | 193 ++++--- .../dialogs/edit-feature-dialog.tsx | 179 +++---- .../views/board-view/kanban-board.tsx | 67 +-- apps/ui/src/components/views/context-view.tsx | 255 ++++----- .../authentication-status-display.tsx | 44 +- .../components/delete-project-dialog.tsx | 17 +- .../setup-view/steps/claude-setup-step.tsx | 362 +++++-------- .../views/setup-view/steps/complete-step.tsx | 26 +- .../views/setup-view/steps/welcome-step.tsx | 14 +- .../ui/src/components/views/terminal-view.tsx | 228 ++++---- apps/ui/src/components/views/welcome-view.tsx | 20 +- apps/ui/src/config/api-providers.ts | 34 +- apps/ui/src/lib/file-picker.ts | 109 ++-- apps/ui/src/lib/http-api-client.ts | 385 ++++++-------- apps/ui/src/lib/utils.ts | 18 +- apps/ui/src/lib/workspace-config.ts | 23 +- apps/ui/src/routes/__root.tsx | 2 +- apps/ui/src/vite-env.d.ts | 11 + apps/ui/tests/feature-lifecycle.spec.ts | 225 +++----- apps/ui/tests/spec-editor-persistence.spec.ts | 161 +++--- apps/ui/tests/utils/components/toasts.ts | 24 +- apps/ui/tests/utils/git/worktree.ts | 158 +++--- apps/ui/tests/utils/views/board.ts | 94 +--- apps/ui/tests/utils/views/setup.ts | 37 +- 40 files changed, 2009 insertions(+), 2583 deletions(-) create mode 100644 apps/ui/src/vite-env.d.ts 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/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/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index e59c67441..16b1e5cbe 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -155,12 +155,6 @@ export function Sidebar() { setNewProjectPath, }); - // Handle bug report button click - const handleBugReportClick = useCallback(() => { - const api = getElectronAPI(); - api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); - }, []); - /** * Opens the system folder selection dialog and initializes the selected project. * Used by both the 'O' keyboard shortcut and the folder icon button. @@ -273,11 +267,7 @@ export function Sidebar() { />
    - + {/* Project Actions - Moved above project selector */} {sidebarOpen && ( 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 index 68a413c43..8139dc559 100644 --- a/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx +++ b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx @@ -1,11 +1,21 @@ import { Bug } from 'lucide-react'; import { cn } from '@/lib/utils'; -import type { BugReportButtonProps } from '../types'; +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'); + }, []); -export function BugReportButton({ sidebarExpanded, onClick }: BugReportButtonProps) { return (
    {/* Bug Report Button - Collapsed sidebar version */} {!sidebarOpen && (
    - +
    )} diff --git a/apps/ui/src/components/layout/sidebar/types.ts b/apps/ui/src/components/layout/sidebar/types.ts index e76e4917c..4d9ecc359 100644 --- a/apps/ui/src/components/layout/sidebar/types.ts +++ b/apps/ui/src/components/layout/sidebar/types.ts @@ -1,4 +1,5 @@ import type { Project } from '@/lib/electron'; +import type React from 'react'; export interface NavSection { label?: string; @@ -29,8 +30,3 @@ export interface ThemeMenuItemProps { onPreviewEnter: (value: string) => void; onPreviewLeave: (e: React.PointerEvent) => void; } - -export interface BugReportButtonProps { - sidebarExpanded: boolean; - onClick: () => void; -} 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 && (