From 3f1c76950b2fc81891637a02f71fbf26867b594d Mon Sep 17 00:00:00 2001 From: Vitor Gomes Date: Sat, 28 Feb 2026 09:10:17 +0000 Subject: [PATCH 01/47] chore: bump version to 2.7.7 --- apps/backend/__init__.py | 2 +- apps/frontend/package.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/__init__.py b/apps/backend/__init__.py index b544f95fe0..e85a25083b 100644 --- a/apps/backend/__init__.py +++ b/apps/backend/__init__.py @@ -19,5 +19,5 @@ See README.md for full documentation. """ -__version__ = "2.7.6" +__version__ = "2.7.7" __author__ = "Auto Claude Team" diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 1cf515ed93..697da059d4 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -1,6 +1,6 @@ { "name": "auto-claude-ui", - "version": "2.7.6", + "version": "2.7.7", "type": "module", "description": "Desktop UI for Auto Claude autonomous coding framework", "homepage": "https://github.com/AndyMik90/Auto-Claude", diff --git a/package.json b/package.json index 395f208fc7..de5d7c027f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auto-claude", - "version": "2.7.6", + "version": "2.7.7", "description": "Autonomous multi-agent coding framework powered by Claude AI", "license": "AGPL-3.0", "author": "Auto Claude Team", From edda06f2d15da4392217589455b28d5fc0e38f6f Mon Sep 17 00:00:00 2001 From: Vitor Gomes Date: Sat, 28 Feb 2026 23:49:06 +0000 Subject: [PATCH 02/47] feat: Add Customer flow - Phase 1 Add customer folder creation/selection with auto-initialization and GitHub authentication. Customer folders skip git setup requirements since they don't need git repos. - Add type field ('project' | 'customer') to Project interface - Rewrite AddCustomerModal with create new/open existing folder steps - Auto-initialize .auto-claude/ on customer creation - Skip git check and init dialog for customer-type projects - Trigger GitHubSetupModal after customer folder is ready - Add i18n keys for EN and FR Co-Authored-By: Claude Opus 4.6 --- apps/frontend/src/renderer/App.tsx | 7 + .../renderer/components/AddCustomerModal.tsx | 264 ++++++++++++++++++ .../src/renderer/components/Sidebar.tsx | 108 +++++-- .../components/settings/ProjectSelector.tsx | 22 +- .../src/shared/i18n/locales/en/dialogs.json | 24 ++ .../src/shared/i18n/locales/fr/dialogs.json | 24 ++ apps/frontend/src/shared/types/project.ts | 1 + 7 files changed, 433 insertions(+), 17 deletions(-) create mode 100644 apps/frontend/src/renderer/components/AddCustomerModal.tsx diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index 3e8eddcdef..d8b4f684cc 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -381,6 +381,9 @@ export function App() { // (project update with autoBuildPath may not have propagated yet) if (initSuccess) return; + // Customer folders handle initialization automatically — skip the dialog + if (selectedProject?.type === 'customer') return; + if (selectedProject && !selectedProject.autoBuildPath && skippedInitProjectId !== selectedProject.id) { // Project exists but isn't initialized - show init dialog setPendingProject(selectedProject); @@ -835,6 +838,10 @@ export function App() { onNewTaskClick={() => setIsNewTaskDialogOpen(true)} activeView={activeView} onViewChange={setActiveView} + onCustomerAdded={(project) => { + setGitHubSetupProject(project); + setShowGitHubSetup(true); + }} /> {/* Main content */} diff --git a/apps/frontend/src/renderer/components/AddCustomerModal.tsx b/apps/frontend/src/renderer/components/AddCustomerModal.tsx new file mode 100644 index 0000000000..722dc6270f --- /dev/null +++ b/apps/frontend/src/renderer/components/AddCustomerModal.tsx @@ -0,0 +1,264 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FolderOpen, FolderPlus, ChevronRight, ArrowLeft, RefreshCw } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from './ui/dialog'; +import { Button } from './ui/button'; +import { cn } from '../lib/utils'; +import { useProjectStore, initializeProject } from '../stores/project-store'; +import type { Project } from '../../shared/types'; + +interface AddCustomerModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCustomerAdded?: (project: Project) => void; +} + +type Step = 'choose' | 'create'; + +export function AddCustomerModal({ open, onOpenChange, onCustomerAdded }: AddCustomerModalProps) { + const { t } = useTranslation('dialogs'); + const [error, setError] = useState(null); + const [step, setStep] = useState('choose'); + const [customerName, setCustomerName] = useState(''); + const [location, setLocation] = useState(''); + const [isCreating, setIsCreating] = useState(false); + + useEffect(() => { + if (open) { + setError(null); + setStep('choose'); + setCustomerName(''); + setLocation(''); + setIsCreating(false); + } + }, [open]); + + const registerAndInitCustomer = async (path: string) => { + // Call IPC directly so we can set type: 'customer' BEFORE selectProject + // (addProject auto-selects, which triggers Sidebar git check before type is set) + const result = await window.electronAPI.addProject(path); + if (!result.success || !result.data) return; + + const store = useProjectStore.getState(); + const project = { ...result.data, type: 'customer' as const }; + + // Add with type already set, then select — Sidebar will see type: 'customer' and skip git check + store.addProject(project); + store.selectProject(project.id); + store.openProjectTab(project.id); + + // Auto-initialize .auto-claude/ so the customer has an .env for GitHub token + if (!project.autoBuildPath) { + try { + await initializeProject(project.id); + } catch { + // Non-fatal — user can init later + } + } + onCustomerAdded?.(project); + onOpenChange(false); + }; + + const handleOpenExisting = async () => { + try { + const path = await window.electronAPI.selectDirectory(); + if (path) { + await registerAndInitCustomer(path); + } + } catch (err) { + setError(err instanceof Error ? err.message : t('addCustomer.failedToOpen')); + } + }; + + const handleBrowseLocation = async () => { + try { + const path = await window.electronAPI.selectDirectory(); + if (path) { + setLocation(path); + } + } catch { + // User cancelled + } + }; + + const handleCreateFolder = async () => { + if (!customerName.trim()) { + setError(t('addCustomer.nameRequired')); + return; + } + if (!location) { + setError(t('addCustomer.locationRequired')); + return; + } + + setIsCreating(true); + setError(null); + + try { + const result = await window.electronAPI.createProjectFolder( + location, + customerName.trim(), + false // No git init for customer folders + ); + if (!result.success || !result.data) { + setError(result.error || t('addCustomer.failedToCreate')); + return; + } + await registerAndInitCustomer(result.data.path); + } catch (err) { + setError(err instanceof Error ? err.message : t('addCustomer.failedToCreate')); + } finally { + setIsCreating(false); + } + }; + + const folderPreview = customerName.trim() && location + ? `${location}/${customerName.trim()}` + : null; + + return ( + + + + {t('addCustomer.title')} + + {step === 'choose' + ? t('addCustomer.description') + : t('addCustomer.createNewSubtitle')} + + + + {step === 'choose' && ( +
+ {/* Create New Folder */} + + + {/* Open Existing Folder */} + +
+ )} + + {step === 'create' && ( +
+ {/* Customer Name */} +
+ + { + setCustomerName(e.target.value); + setError(null); + }} + placeholder={t('addCustomer.customerNamePlaceholder')} + className={cn( + 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm', + 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring' + )} + autoFocus + /> +
+ + {/* Location */} +
+ + {t('addCustomer.location')} + +
+
+ {location || t('addCustomer.locationPlaceholder')} +
+ +
+
+ + {/* Folder preview */} + {folderPreview && ( +
+ {t('addCustomer.willCreate')} {folderPreview} +
+ )} + + {/* Actions */} +
+ + +
+
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/Sidebar.tsx b/apps/frontend/src/renderer/components/Sidebar.tsx index 0efe1c0749..ca1eced110 100644 --- a/apps/frontend/src/renderer/components/Sidebar.tsx +++ b/apps/frontend/src/renderer/components/Sidebar.tsx @@ -22,11 +22,20 @@ import { Heart, Wrench, PanelLeft, - PanelLeftClose + PanelLeftClose, + FolderOpen, + Users } from 'lucide-react'; import { Button } from './ui/button'; import { ScrollArea } from './ui/scroll-area'; import { Separator } from './ui/separator'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from './ui/select'; import { Tooltip, TooltipContent, @@ -45,7 +54,7 @@ import { cn } from '../lib/utils'; import { useProjectStore, removeProject, - initializeProject + initializeProject, } from '../stores/project-store'; import { useSettingsStore, saveSettings } from '../stores/settings-store'; import { @@ -54,6 +63,7 @@ import { clearProjectEnvConfig } from '../stores/project-env-store'; import { AddProjectModal } from './AddProjectModal'; +import { AddCustomerModal } from './AddCustomerModal'; import { GitSetupModal } from './GitSetupModal'; import { RateLimitIndicator } from './RateLimitIndicator'; import { ClaudeCodeStatusBadge } from './ClaudeCodeStatusBadge'; @@ -67,6 +77,7 @@ interface SidebarProps { onNewTaskClick: () => void; activeView?: SidebarView; onViewChange?: (view: SidebarView) => void; + onCustomerAdded?: (project: Project) => void; } interface NavItem { @@ -105,14 +116,17 @@ export function Sidebar({ onSettingsClick, onNewTaskClick, activeView = 'kanban', - onViewChange + onViewChange, + onCustomerAdded }: SidebarProps) { const { t } = useTranslation(['navigation', 'dialogs', 'common']); const projects = useProjectStore((state) => state.projects); const selectedProjectId = useProjectStore((state) => state.selectedProjectId); + const selectProject = useProjectStore((state) => state.selectProject); const settings = useSettingsStore((state) => state.settings); const [showAddProjectModal, setShowAddProjectModal] = useState(false); + const [showAddCustomerModal, setShowAddCustomerModal] = useState(false); const [showInitDialog, setShowInitDialog] = useState(false); const [showGitSetupModal, setShowGitSetupModal] = useState(false); const [gitStatus, setGitStatus] = useState(null); @@ -135,20 +149,11 @@ export function Sidebar({ // Track the last loaded project ID to avoid redundant loads const lastLoadedProjectIdRef = useRef(null); - // Compute visible nav items based on GitHub/GitLab enabled state from store + // Compute visible nav items — GitHub/GitLab always shown so users can configure credentials const visibleNavItems = useMemo(() => { - const items = [...baseNavItems]; - - if (githubEnabled) { - items.push(...githubNavItems); - } - - if (gitlabEnabled) { - items.push(...gitlabNavItems); - } - + const items = [...baseNavItems, ...githubNavItems, ...gitlabNavItems]; return items; - }, [githubEnabled, gitlabEnabled]); + }, []); // Load envConfig when project changes to ensure store is populated useEffect(() => { @@ -212,10 +217,15 @@ export function Sidebar({ return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedProjectId, onViewChange, visibleNavItems]); - // Check git status when project changes + // Check git status when project changes (skip for customer-type projects) useEffect(() => { const checkGit = async () => { if (selectedProject) { + // Customer folders don't require git + if (selectedProject.type === 'customer') { + setGitStatus(null); + return; + } try { const result = await window.electronAPI.checkGitStatus(selectedProject.path); if (result.success && result.data) { @@ -399,6 +409,63 @@ export function Sidebar({ {t('sections.project')} )} + + {/* Project Selector Dropdown */} + {!isCollapsed ? ( +
+ +
+ ) : ( + + + + + + {selectedProject?.name ?? t('navigation:projectSelector.placeholder')} + + + )} + @@ -569,6 +636,15 @@ export function Sidebar({ onProjectAdded={handleProjectAdded} /> + {/* Add Customer Modal */} + { + onCustomerAdded?.(project); + }} + /> + {/* Git Setup Modal */} state.projects); const [showAddModal, setShowAddModal] = useState(false); + const [showAddCustomerModal, setShowAddCustomerModal] = useState(false); const [open, setOpen] = useState(false); const handleValueChange = (value: string) => { if (value === '__add_new__') { setShowAddModal(true); setOpen(false); + } else if (value === '__add_customer__') { + setShowAddCustomerModal(true); + setOpen(false); } else { onProjectChange(value || null); setOpen(false); @@ -93,6 +98,12 @@ export function ProjectSelector({ Add Project... + +
+ + Add Customer... +
+
@@ -116,6 +127,15 @@ export function ProjectSelector({ onProjectAdded?.(project, needsInit); }} /> + + { + onProjectChange(project.id); + onProjectAdded?.(project, false); + }} + /> ); } diff --git a/apps/frontend/src/shared/i18n/locales/en/dialogs.json b/apps/frontend/src/shared/i18n/locales/en/dialogs.json index 74ba84802f..872be75e25 100644 --- a/apps/frontend/src/shared/i18n/locales/en/dialogs.json +++ b/apps/frontend/src/shared/i18n/locales/en/dialogs.json @@ -134,6 +134,30 @@ "openExistingAriaLabel": "Open existing project folder", "createNewAriaLabel": "Create new project" }, + "addCustomer": { + "title": "Add Customer", + "description": "Create a new customer folder or select an existing one", + "createNew": "Create New Folder", + "createNewDescription": "Start fresh with a new customer folder", + "createNewSubtitle": "Set up a new customer folder", + "createNewAriaLabel": "Create new customer folder", + "openExisting": "Open Existing Folder", + "openExistingDescription": "Browse to an existing customer folder on your computer", + "openExistingAriaLabel": "Open existing customer folder", + "customerName": "Customer Name", + "customerNamePlaceholder": "e.g., Acme Corp", + "location": "Location", + "locationPlaceholder": "Select a folder...", + "browse": "Browse", + "willCreate": "Will create:", + "back": "Back", + "creating": "Creating...", + "createCustomer": "Create Customer", + "nameRequired": "Please enter a customer name", + "locationRequired": "Please select a location", + "failedToOpen": "Failed to open customer folder", + "failedToCreate": "Failed to create customer folder" + }, "customModel": { "title": "Custom Model Configuration", "description": "Configure the model and thinking level for this chat session.", diff --git a/apps/frontend/src/shared/i18n/locales/fr/dialogs.json b/apps/frontend/src/shared/i18n/locales/fr/dialogs.json index 87a2f6a918..6711f78de7 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/dialogs.json +++ b/apps/frontend/src/shared/i18n/locales/fr/dialogs.json @@ -134,6 +134,30 @@ "openExistingAriaLabel": "Ouvrir un dossier de projet existant", "createNewAriaLabel": "Créer un nouveau projet" }, + "addCustomer": { + "title": "Ajouter un client", + "description": "Créer un nouveau dossier client ou sélectionner un existant", + "createNew": "Créer un nouveau dossier", + "createNewDescription": "Commencer avec un nouveau dossier client", + "createNewSubtitle": "Configurer un nouveau dossier client", + "createNewAriaLabel": "Créer un nouveau dossier client", + "openExisting": "Ouvrir un dossier existant", + "openExistingDescription": "Parcourir vers un dossier client existant sur votre ordinateur", + "openExistingAriaLabel": "Ouvrir un dossier client existant", + "customerName": "Nom du client", + "customerNamePlaceholder": "ex. Acme Corp", + "location": "Emplacement", + "locationPlaceholder": "Sélectionner un dossier...", + "browse": "Parcourir", + "willCreate": "Va créer :", + "back": "Retour", + "creating": "Création en cours...", + "createCustomer": "Créer le client", + "nameRequired": "Veuillez entrer un nom de client", + "locationRequired": "Veuillez sélectionner un emplacement", + "failedToOpen": "Échec de l'ouverture du dossier client", + "failedToCreate": "Échec de la création du dossier client" + }, "customModel": { "title": "Configuration du modèle personnalisé", "description": "Configurez le modèle et le niveau de réflexion pour cette session de chat.", diff --git a/apps/frontend/src/shared/types/project.ts b/apps/frontend/src/shared/types/project.ts index 30bca7de2c..a02f4d05dd 100644 --- a/apps/frontend/src/shared/types/project.ts +++ b/apps/frontend/src/shared/types/project.ts @@ -10,6 +10,7 @@ export interface Project { settings: ProjectSettings; createdAt: Date; updatedAt: Date; + type?: 'project' | 'customer'; } export interface ProjectSettings { From f540922da9d2105fb8cbbcd5afd91a0b9ea19eba Mon Sep 17 00:00:00 2001 From: Vitor Gomes Date: Sat, 28 Feb 2026 23:49:16 +0000 Subject: [PATCH 03/47] chore: bump version to 2.7.8 --- apps/backend/__init__.py | 2 +- apps/frontend/package.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/__init__.py b/apps/backend/__init__.py index e85a25083b..9bf73df1ba 100644 --- a/apps/backend/__init__.py +++ b/apps/backend/__init__.py @@ -19,5 +19,5 @@ See README.md for full documentation. """ -__version__ = "2.7.7" +__version__ = "2.7.8" __author__ = "Auto Claude Team" diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 697da059d4..90c05b8577 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -1,6 +1,6 @@ { "name": "auto-claude-ui", - "version": "2.7.7", + "version": "2.7.8", "type": "module", "description": "Desktop UI for Auto Claude autonomous coding framework", "homepage": "https://github.com/AndyMik90/Auto-Claude", diff --git a/package.json b/package.json index de5d7c027f..df0a3273f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auto-claude", - "version": "2.7.7", + "version": "2.7.8", "description": "Autonomous multi-agent coding framework powered by Claude AI", "license": "AGPL-3.0", "author": "Auto Claude Team", From 0e5c7e6e165c33954d7cb0b47564fdbb9a837970 Mon Sep 17 00:00:00 2001 From: Vitor Gomes Date: Sat, 28 Feb 2026 23:54:46 +0000 Subject: [PATCH 04/47] fix: customer init skips git check, creates .auto-claude directly The full initializeProject requires git, which customer folders don't have. Use createProjectFolder to create .auto-claude/ directly instead. Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/components/AddCustomerModal.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/renderer/components/AddCustomerModal.tsx b/apps/frontend/src/renderer/components/AddCustomerModal.tsx index 722dc6270f..482561b46d 100644 --- a/apps/frontend/src/renderer/components/AddCustomerModal.tsx +++ b/apps/frontend/src/renderer/components/AddCustomerModal.tsx @@ -10,7 +10,7 @@ import { } from './ui/dialog'; import { Button } from './ui/button'; import { cn } from '../lib/utils'; -import { useProjectStore, initializeProject } from '../stores/project-store'; +import { useProjectStore } from '../stores/project-store'; import type { Project } from '../../shared/types'; interface AddCustomerModalProps { @@ -53,12 +53,15 @@ export function AddCustomerModal({ open, onOpenChange, onCustomerAdded }: AddCus store.selectProject(project.id); store.openProjectTab(project.id); - // Auto-initialize .auto-claude/ so the customer has an .env for GitHub token + // Create .auto-claude/ folder so the customer has an .env for GitHub token. + // We use createProjectFolder instead of initializeProject because the full + // initializer requires git (which customer folders don't have). if (!project.autoBuildPath) { try { - await initializeProject(project.id); + await window.electronAPI.createProjectFolder(path, '.auto-claude', false); + store.updateProject(project.id, { autoBuildPath: '.auto-claude' }); } catch { - // Non-fatal — user can init later + // Non-fatal — user can configure later } } onCustomerAdded?.(project); From 308eb74e96c8c1dff50b10c60fea1baf8b309287 Mon Sep 17 00:00:00 2001 From: Vitor Gomes Date: Sat, 28 Feb 2026 23:58:30 +0000 Subject: [PATCH 05/47] fix: pass updated project with autoBuildPath to GitHubSetupModal The project object passed to onCustomerAdded was stale (missing autoBuildPath). Now reads the updated project from the store after .auto-claude/ creation. Co-Authored-By: Claude Opus 4.6 --- apps/frontend/src/renderer/components/AddCustomerModal.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/renderer/components/AddCustomerModal.tsx b/apps/frontend/src/renderer/components/AddCustomerModal.tsx index 482561b46d..ac01820881 100644 --- a/apps/frontend/src/renderer/components/AddCustomerModal.tsx +++ b/apps/frontend/src/renderer/components/AddCustomerModal.tsx @@ -64,7 +64,10 @@ export function AddCustomerModal({ open, onOpenChange, onCustomerAdded }: AddCus // Non-fatal — user can configure later } } - onCustomerAdded?.(project); + + // Read updated project from store (has autoBuildPath set) + const updatedProject = store.projects.find(p => p.id === project.id) || project; + onCustomerAdded?.(updatedProject); onOpenChange(false); }; From 1ec12adcc0b2ca0235dca03760c206db008c2dfd Mon Sep 17 00:00:00 2001 From: Vitor Gomes Date: Sun, 1 Mar 2026 00:05:38 +0000 Subject: [PATCH 06/47] feat: add initializeCustomerProject IPC for git-free .auto-claude setup Customer folders don't have git, so the regular initializeProject fails. Add a dedicated IPC handler that creates .auto-claude/ and persists autoBuildPath in the main process project store without git checks. Co-Authored-By: Claude Opus 4.6 --- .../src/main/ipc-handlers/project-handlers.ts | 29 +++++++++++++++++++ apps/frontend/src/preload/api/project-api.ts | 4 +++ .../renderer/components/AddCustomerModal.tsx | 11 +++---- .../src/renderer/lib/mocks/project-mock.ts | 5 ++++ apps/frontend/src/shared/constants/ipc.ts | 1 + apps/frontend/src/shared/types/ipc.ts | 1 + 6 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/main/ipc-handlers/project-handlers.ts b/apps/frontend/src/main/ipc-handlers/project-handlers.ts index 20c5403bd4..f5ed15b10d 100644 --- a/apps/frontend/src/main/ipc-handlers/project-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/project-handlers.ts @@ -494,6 +494,35 @@ export function registerProjectHandlers( } ); + // Initialize customer project — creates .auto-claude/ without requiring git + ipcMain.handle( + IPC_CHANNELS.PROJECT_INIT_CUSTOMER, + async (_, projectId: string): Promise> => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const path = require('path'); + const fs = require('fs'); + const dotAutoClaude = path.join(project.path, '.auto-claude'); + + if (!fs.existsSync(dotAutoClaude)) { + fs.mkdirSync(dotAutoClaude, { recursive: true }); + } + + projectStore.updateAutoBuildPath(projectId, '.auto-claude'); + return { success: true, data: { success: true } }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + // PROJECT_CHECK_VERSION now just checks if project is initialized // Version tracking for .auto-claude is removed since it only contains data ipcMain.handle( diff --git a/apps/frontend/src/preload/api/project-api.ts b/apps/frontend/src/preload/api/project-api.ts index b37face307..4f81de56b1 100644 --- a/apps/frontend/src/preload/api/project-api.ts +++ b/apps/frontend/src/preload/api/project-api.ts @@ -33,6 +33,7 @@ export interface ProjectAPI { settings: Partial ) => Promise; initializeProject: (projectId: string) => Promise>; + initializeCustomerProject: (projectId: string) => Promise>; checkProjectVersion: (projectId: string) => Promise>; // Tab State (persisted in main process for reliability) @@ -169,6 +170,9 @@ export const createProjectAPI = (): ProjectAPI => ({ initializeProject: (projectId: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_INITIALIZE, projectId), + initializeCustomerProject: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_INIT_CUSTOMER, projectId), + checkProjectVersion: (projectId: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_CHECK_VERSION, projectId), diff --git a/apps/frontend/src/renderer/components/AddCustomerModal.tsx b/apps/frontend/src/renderer/components/AddCustomerModal.tsx index ac01820881..deaa54f287 100644 --- a/apps/frontend/src/renderer/components/AddCustomerModal.tsx +++ b/apps/frontend/src/renderer/components/AddCustomerModal.tsx @@ -53,13 +53,14 @@ export function AddCustomerModal({ open, onOpenChange, onCustomerAdded }: AddCus store.selectProject(project.id); store.openProjectTab(project.id); - // Create .auto-claude/ folder so the customer has an .env for GitHub token. - // We use createProjectFolder instead of initializeProject because the full - // initializer requires git (which customer folders don't have). + // Create .auto-claude/ and persist autoBuildPath via dedicated customer IPC. + // We can't use initializeProject because it requires git (customers don't have git). if (!project.autoBuildPath) { try { - await window.electronAPI.createProjectFolder(path, '.auto-claude', false); - store.updateProject(project.id, { autoBuildPath: '.auto-claude' }); + const initResult = await window.electronAPI.initializeCustomerProject(project.id); + if (initResult.success) { + store.updateProject(project.id, { autoBuildPath: '.auto-claude' }); + } } catch { // Non-fatal — user can configure later } diff --git a/apps/frontend/src/renderer/lib/mocks/project-mock.ts b/apps/frontend/src/renderer/lib/mocks/project-mock.ts index 153600e098..dde16c1502 100644 --- a/apps/frontend/src/renderer/lib/mocks/project-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/project-mock.ts @@ -33,6 +33,11 @@ export const projectMock = { data: { success: true, version: '1.0.0', wasUpdate: false } }), + initializeCustomerProject: async () => ({ + success: true, + data: { success: true } + }), + checkProjectVersion: async () => ({ success: true, data: { diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 48b3e95c22..4bd6726a9a 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -10,6 +10,7 @@ export const IPC_CHANNELS = { PROJECT_LIST: 'project:list', PROJECT_UPDATE_SETTINGS: 'project:updateSettings', PROJECT_INITIALIZE: 'project:initialize', + PROJECT_INIT_CUSTOMER: 'project:initCustomer', PROJECT_CHECK_VERSION: 'project:checkVersion', // Tab state operations (persisted in main process) diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 532722db53..61238312dd 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -184,6 +184,7 @@ export interface ElectronAPI { getProjects: () => Promise>; updateProjectSettings: (projectId: string, settings: Partial) => Promise; initializeProject: (projectId: string) => Promise>; + initializeCustomerProject: (projectId: string) => Promise>; checkProjectVersion: (projectId: string) => Promise>; // Tab State (persisted in main process for reliability) From 98727e28dd2e933ca5fc2c834cbafe4c7fd0fca8 Mon Sep 17 00:00:00 2001 From: Vitor Gomes Date: Sun, 1 Mar 2026 11:29:54 +0000 Subject: [PATCH 07/47] feat: multi-repo GitHub Issues for Customer projects When a Customer tab is active, GitHub Issues now shows issues from ALL child repositories. Includes repo filter dropdown, per-issue repo badge, and aggregated view sorted by update date. Token comes from Customer parent .env, each child repo contributes its own GITHUB_REPO. Also includes prior uncommitted work: Customer flow improvements, GitHub integration settings, i18n additions, and UI refinements. Co-Authored-By: Claude Opus 4.6 --- apps/backend/phase_config.py | 3 +- .../github/customer-github-handlers.ts | 312 ++++++++++++++ .../src/main/ipc-handlers/github/index.ts | 2 + .../ipc-handlers/github/oauth-handlers.ts | 57 +++ .../src/main/ipc-handlers/project-handlers.ts | 6 +- .../main/ipc-handlers/settings-handlers.ts | 52 ++- apps/frontend/src/main/project-store.ts | 55 ++- .../src/preload/api/modules/github-api.ts | 40 +- apps/frontend/src/preload/api/project-api.ts | 6 +- apps/frontend/src/renderer/App.tsx | 55 ++- .../renderer/components/AddCustomerModal.tsx | 7 +- .../components/CustomerReposModal.tsx | 246 +++++++++++ .../renderer/components/EnvConfigModal.tsx | 38 +- .../src/renderer/components/GitHubIssues.tsx | 90 ++-- .../renderer/components/GitHubSetupModal.tsx | 21 +- .../src/renderer/components/Insights.tsx | 2 +- .../src/renderer/components/Sidebar.tsx | 109 ++--- .../components/SortableProjectTab.tsx | 8 +- .../components/context/MemoriesTab.tsx | 4 +- .../components/context/MemoryCard.tsx | 24 +- .../github-issues/components/IssueList.tsx | 6 +- .../components/IssueListItem.tsx | 7 +- .../components/RepoFilterDropdown.tsx | 40 ++ .../hooks/useMultiRepoGitHubIssues.ts | 223 ++++++++++ .../components/github-issues/types/index.ts | 3 + .../renderer/components/ideation/Ideation.tsx | 12 +- .../components/settings/DisplaySettings.tsx | 10 +- .../components/settings/ProjectSelector.tsx | 26 +- .../components/settings/ThemeSelector.tsx | 16 +- .../integrations/GitHubIntegration.tsx | 404 ++++++++++++++---- .../integrations/LinearIntegration.tsx | 44 +- .../settings/sections/SectionRouter.tsx | 7 +- .../components/task-detail/TaskProgress.tsx | 18 +- .../frontend/src/renderer/lib/browser-mock.ts | 4 + .../renderer/lib/mocks/integration-mock.ts | 20 + apps/frontend/src/shared/constants/ipc.ts | 6 + apps/frontend/src/shared/constants/models.ts | 6 +- .../src/shared/i18n/locales/en/common.json | 18 + .../src/shared/i18n/locales/en/dialogs.json | 17 + .../shared/i18n/locales/en/navigation.json | 11 + .../src/shared/i18n/locales/en/settings.json | 78 +++- .../src/shared/i18n/locales/en/tasks.json | 10 + .../src/shared/i18n/locales/fr/common.json | 18 + .../src/shared/i18n/locales/fr/dialogs.json | 17 + .../shared/i18n/locales/fr/navigation.json | 11 + .../src/shared/i18n/locales/fr/settings.json | 78 +++- .../src/shared/i18n/locales/fr/tasks.json | 10 + .../frontend/src/shared/types/integrations.ts | 18 + apps/frontend/src/shared/types/ipc.ts | 3 +- package-lock.json | 6 +- 50 files changed, 1994 insertions(+), 290 deletions(-) create mode 100644 apps/frontend/src/main/ipc-handlers/github/customer-github-handlers.ts create mode 100644 apps/frontend/src/renderer/components/CustomerReposModal.tsx create mode 100644 apps/frontend/src/renderer/components/github-issues/components/RepoFilterDropdown.tsx create mode 100644 apps/frontend/src/renderer/components/github-issues/hooks/useMultiRepoGitHubIssues.ts diff --git a/apps/backend/phase_config.py b/apps/backend/phase_config.py index ed7542b5d8..0a75bcea62 100644 --- a/apps/backend/phase_config.py +++ b/apps/backend/phase_config.py @@ -20,7 +20,8 @@ "opus": "claude-opus-4-6", "opus-1m": "claude-opus-4-6", "opus-4.5": "claude-opus-4-5-20251101", - "sonnet": "claude-sonnet-4-5-20250929", + "sonnet": "claude-sonnet-4-6", + "sonnet-4.5": "claude-sonnet-4-5-20250929", "haiku": "claude-haiku-4-5-20251001", } diff --git a/apps/frontend/src/main/ipc-handlers/github/customer-github-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/customer-github-handlers.ts new file mode 100644 index 0000000000..90df27cfb2 --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/github/customer-github-handlers.ts @@ -0,0 +1,312 @@ +/** + * Multi-repo GitHub Issues handlers for Customer projects. + * + * A Customer project aggregates issues from multiple child repositories. + * The GitHub token comes from the customer's own .env while each child + * repository supplies its own GITHUB_REPO value. + */ + +import { existsSync, readFileSync } from 'fs'; +import path from 'path'; +import { ipcMain } from 'electron'; +import { IPC_CHANNELS } from '../../../shared/constants'; +import type { IPCResult, GitHubIssue, MultiRepoGitHubStatus, MultiRepoIssuesResult } from '../../../shared/types'; +import { projectStore } from '../../project-store'; +import { getGitHubConfig, githubFetch, normalizeRepoReference } from './utils'; +import type { GitHubAPIIssue } from './types'; +import { parseEnvFile } from '../utils'; +import { debugLog } from '../../../shared/utils/debug-logger'; + +// ──────────────────────────────────────────────────────────────────────────── +// Shared helper +// ──────────────────────────────────────────────────────────────────────────── + +interface CustomerRepo { + projectId: string; + repoFullName: string; +} + +interface CustomerGitHubConfig { + token: string; + repos: CustomerRepo[]; +} + +/** + * Resolve the GitHub token and child-repo list for a Customer project. + * + * Token resolution order: + * 1. GITHUB_TOKEN from the customer's .env + * 2. Fallback to `getGitHubConfig(customer)?.token` (which also tries `gh` CLI) + * + * Each child repo's GITHUB_REPO is read from its own .env via `getGitHubConfig`. + */ +function getCustomerGitHubConfig(customerId: string): CustomerGitHubConfig | null { + const customer = projectStore.getProject(customerId); + if (!customer) { + debugLog('[Customer GitHub] Customer project not found:', customerId); + return null; + } + + if (customer.type !== 'customer') { + debugLog('[Customer GitHub] Project is not a customer:', customerId); + return null; + } + + // 1. Resolve token from customer's .env + let token: string | undefined; + + if (customer.autoBuildPath) { + const envPath = path.join(customer.path, customer.autoBuildPath, '.env'); + if (existsSync(envPath)) { + try { + const content = readFileSync(envPath, 'utf-8'); + const vars = parseEnvFile(content); + token = vars['GITHUB_TOKEN']; + } catch { + // ignore read errors, fall through to fallback + } + } + } + + // Fallback: try getGitHubConfig which also checks gh CLI + if (!token) { + const fallbackConfig = getGitHubConfig(customer); + token = fallbackConfig?.token; + } + + if (!token) { + debugLog('[Customer GitHub] No GitHub token found for customer:', customerId); + return null; + } + + // 2. Discover child repos + const allProjects = projectStore.getProjects(); + const childProjects = allProjects.filter( + (p) => p.id !== customer.id && p.path.startsWith(customer.path + '/') + ); + + const repos: CustomerRepo[] = []; + + for (const child of childProjects) { + const childConfig = getGitHubConfig(child); + if (childConfig?.repo) { + const normalized = normalizeRepoReference(childConfig.repo); + if (normalized) { + repos.push({ projectId: child.id, repoFullName: normalized }); + } + } + } + + debugLog('[Customer GitHub] Resolved config:', { + customerId, + hasToken: !!token, + repoCount: repos.length, + }); + + return { token, repos }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Transform helper (duplicated from issue-handlers.ts since it is not exported) +// ──────────────────────────────────────────────────────────────────────────── + +function transformIssue(issue: GitHubAPIIssue, repoFullName: string): GitHubIssue { + return { + id: issue.id, + number: issue.number, + title: issue.title, + body: issue.body, + state: issue.state, + labels: issue.labels, + assignees: issue.assignees.map((a) => ({ + login: a.login, + avatarUrl: a.avatar_url, + })), + author: { + login: issue.user.login, + avatarUrl: issue.user.avatar_url, + }, + milestone: issue.milestone, + createdAt: issue.created_at, + updatedAt: issue.updated_at, + closedAt: issue.closed_at, + commentsCount: issue.comments, + url: issue.url, + htmlUrl: issue.html_url, + repoFullName, + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Handler 1: Check multi-repo connection +// ──────────────────────────────────────────────────────────────────────────── + +function registerCheckMultiRepoConnection(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_CHECK_MULTI_REPO_CONNECTION, + async (_, customerId: string): Promise> => { + debugLog('[Customer GitHub] checkMultiRepoConnection called', { customerId }); + + const config = getCustomerGitHubConfig(customerId); + if (!config) { + return { + success: true, + data: { + connected: false, + repos: [], + error: 'No GitHub token configured for this customer', + }, + }; + } + + return { + success: true, + data: { + connected: true, + repos: config.repos, + }, + }; + } + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Handler 2: Get issues across all child repos +// ──────────────────────────────────────────────────────────────────────────── + +function registerGetMultiRepoIssues(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_MULTI_REPO_ISSUES, + async ( + _, + customerId: string, + state: 'open' | 'closed' | 'all' = 'open', + page: number = 1 + ): Promise> => { + debugLog('[Customer GitHub] getMultiRepoIssues called', { customerId, state, page }); + + const config = getCustomerGitHubConfig(customerId); + if (!config) { + return { success: false, error: 'No GitHub configuration found for this customer' }; + } + + if (config.repos.length === 0) { + return { + success: true, + data: { issues: [], repos: [], hasMore: false }, + }; + } + + try { + const allRepoNames = config.repos.map((r) => r.repoFullName); + + // Fetch issues from all repos in parallel + const settledResults = await Promise.allSettled( + config.repos.map(async (repo) => { + const endpoint = `/repos/${repo.repoFullName}/issues?state=${state}&per_page=50&sort=updated&page=${page}`; + const data = await githubFetch(config.token, endpoint); + return { repoFullName: repo.repoFullName, data }; + }) + ); + + const allIssues: GitHubIssue[] = []; + + for (const result of settledResults) { + if (result.status === 'fulfilled') { + const { repoFullName, data } = result.value; + if (Array.isArray(data)) { + const issuesOnly = (data as GitHubAPIIssue[]).filter( + (item) => !item.pull_request + ); + const transformed = issuesOnly.map((issue) => + transformIssue(issue, repoFullName) + ); + allIssues.push(...transformed); + } + } else { + debugLog('[Customer GitHub] Failed to fetch from repo:', result.reason); + } + } + + // Sort by updatedAt descending + allIssues.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + + debugLog('[Customer GitHub] Returning', allIssues.length, 'issues from', allRepoNames.length, 'repos'); + + return { + success: true, + data: { + issues: allIssues, + repos: allRepoNames, + hasMore: false, + }, + }; + } catch (error) { + debugLog('[Customer GitHub] Error fetching multi-repo issues:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch multi-repo issues', + }; + } + } + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Handler 3: Get single issue detail from a specific repo +// ──────────────────────────────────────────────────────────────────────────── + +function registerGetMultiRepoIssueDetail(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_MULTI_REPO_ISSUE_DETAIL, + async ( + _, + customerId: string, + repoFullName: string, + issueNumber: number + ): Promise> => { + debugLog('[Customer GitHub] getMultiRepoIssueDetail called', { + customerId, + repoFullName, + issueNumber, + }); + + const config = getCustomerGitHubConfig(customerId); + if (!config) { + return { success: false, error: 'No GitHub configuration found for this customer' }; + } + + try { + const issue = (await githubFetch( + config.token, + `/repos/${repoFullName}/issues/${issueNumber}` + )) as GitHubAPIIssue; + + const result = transformIssue(issue, repoFullName); + + return { success: true, data: result }; + } catch (error) { + debugLog('[Customer GitHub] Error fetching issue detail:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch issue detail', + }; + } + } + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Public registration +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Register all Customer multi-repo GitHub IPC handlers + */ +export function registerCustomerGitHubHandlers(): void { + registerCheckMultiRepoConnection(); + registerGetMultiRepoIssues(); + registerGetMultiRepoIssueDetail(); +} diff --git a/apps/frontend/src/main/ipc-handlers/github/index.ts b/apps/frontend/src/main/ipc-handlers/github/index.ts index 02616cda01..a0b327efbd 100644 --- a/apps/frontend/src/main/ipc-handlers/github/index.ts +++ b/apps/frontend/src/main/ipc-handlers/github/index.ts @@ -25,6 +25,7 @@ import { registerGithubOAuthHandlers } from './oauth-handlers'; import { registerAutoFixHandlers } from './autofix-handlers'; import { registerPRHandlers } from './pr-handlers'; import { registerTriageHandlers } from './triage-handlers'; +import { registerCustomerGitHubHandlers } from './customer-github-handlers'; /** * Register all GitHub-related IPC handlers @@ -42,6 +43,7 @@ export function registerGithubHandlers( registerAutoFixHandlers(agentManager, getMainWindow); registerPRHandlers(getMainWindow); registerTriageHandlers(getMainWindow); + registerCustomerGitHubHandlers(); } // Re-export utilities for potential external use diff --git a/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts index 37adb6bb2d..28a3456b82 100644 --- a/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts @@ -887,6 +887,62 @@ export function registerListGitHubOrgs(): void { ); } +/** + * Clone a GitHub repository into a target directory + */ +export function registerCloneGitHubRepo(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_CLONE_REPO, + async ( + _event: Electron.IpcMainInvokeEvent, + repoFullName: string, + targetDir: string + ): Promise> => { + debugLog('cloneGitHubRepo handler called', { repoFullName, targetDir }); + try { + const path = require('path'); + const fs = require('fs'); + + // Extract repo name from fullName (owner/repo -> repo) + const repoName = repoFullName.split('/').pop() || repoFullName; + const clonePath = path.join(targetDir, repoName); + + // Check if directory already exists + if (fs.existsSync(clonePath)) { + return { + success: false, + error: `Directory already exists: ${clonePath}` + }; + } + + // Clone using gh CLI (uses authenticated session) + debugLog(`Running: gh repo clone ${repoFullName} ${clonePath}`); + execSync( + `gh repo clone ${repoFullName} "${clonePath}"`, + { + encoding: 'utf-8', + stdio: 'pipe', + env: getAugmentedEnv(), + timeout: 120000 // 2 minute timeout for large repos + } + ); + + debugLog('Clone successful:', clonePath); + return { + success: true, + data: { path: clonePath, name: repoName } + }; + } catch (error) { + debugLog('Failed to clone repo:', error instanceof Error ? error.message : error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to clone repository' + }; + } + } + ); +} + /** * Register all GitHub OAuth handlers */ @@ -903,5 +959,6 @@ export function registerGithubOAuthHandlers(): void { registerCreateGitHubRepo(); registerAddGitRemote(); registerListGitHubOrgs(); + registerCloneGitHubRepo(); debugLog('GitHub OAuth handlers registered'); } diff --git a/apps/frontend/src/main/ipc-handlers/project-handlers.ts b/apps/frontend/src/main/ipc-handlers/project-handlers.ts index f5ed15b10d..bfb3506253 100644 --- a/apps/frontend/src/main/ipc-handlers/project-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/project-handlers.ts @@ -299,14 +299,14 @@ export function registerProjectHandlers( ipcMain.handle( IPC_CHANNELS.PROJECT_ADD, - async (_, projectPath: string): Promise> => { + async (_, projectPath: string, type?: 'project' | 'customer'): Promise> => { try { // Validate path exists if (!existsSync(projectPath)) { return { success: false, error: 'Directory does not exist' }; } - const project = projectStore.addProject(projectPath); + const project = projectStore.addProject(projectPath, undefined, type); return { success: true, data: project }; } catch (error) { return { @@ -513,6 +513,8 @@ export function registerProjectHandlers( } projectStore.updateAutoBuildPath(projectId, '.auto-claude'); + // Ensure customer type is persisted (safety net for projects created before type persistence) + projectStore.updateProjectType(projectId, 'customer'); return { success: true, data: { success: true } }; } catch (error) { return { diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 697711049a..8c656bcbc4 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -22,6 +22,8 @@ import { setUpdateChannel, setUpdateChannelWithDowngradeCheck } from '../app-upd import { getSettingsPath, readSettingsFile } from '../settings-utils'; import { configureTools, getToolPath, getToolInfo, isPathFromWrongPlatform, preWarmToolCache } from '../cli-tool-manager'; import { parseEnvFile } from './utils'; +import { getClaudeProfileManager } from '../claude-profile-manager'; +import { getCredentialsFromKeychain } from '../claude-profile/credential-utils'; const settingsPath = getSettingsPath(); @@ -800,6 +802,34 @@ export function registerSettingsHandlers( } }; } + // Check Keychain credentials — try profile manager first, then direct Keychain + let hasKeychainToken = false; + try { + const profileManager = getClaudeProfileManager(); + hasKeychainToken = profileManager.hasValidAuth(); + } catch { + // Profile manager may not be initialized yet + } + // Fallback: check Keychain directly (default config dir) + if (!hasKeychainToken) { + try { + const creds = getCredentialsFromKeychain(); + hasKeychainToken = !!creds.token; + } catch { + // Keychain access failed + } + } + + if (hasKeychainToken) { + return { + success: true, + data: { + hasToken: true, + sourcePath: isProduction ? app.getPath('userData') : undefined + } + }; + } + return { success: true, data: { @@ -820,8 +850,26 @@ export function registerSettingsHandlers( hasEnvToken = !!token && token.length > 0; } - // Token exists if either source .env has it OR global settings has it - const hasToken = hasEnvToken || hasGlobalToken; + // Check Keychain credentials — try profile manager first, then direct Keychain + let hasKeychainToken = false; + try { + const profileManager = getClaudeProfileManager(); + hasKeychainToken = profileManager.hasValidAuth(); + } catch { + // Profile manager may not be initialized yet + } + // Fallback: check Keychain directly (default config dir) + if (!hasKeychainToken) { + try { + const creds = getCredentialsFromKeychain(); + hasKeychainToken = !!creds.token; + } catch { + // Keychain access failed + } + } + + // Token exists if source .env, global settings, OR Keychain has it + const hasToken = hasEnvToken || hasGlobalToken || hasKeychainToken; return { success: true, diff --git a/apps/frontend/src/main/project-store.ts b/apps/frontend/src/main/project-store.ts index cca93eeeb0..6b86f0f9f6 100644 --- a/apps/frontend/src/main/project-store.ts +++ b/apps/frontend/src/main/project-store.ts @@ -68,6 +68,10 @@ export class ProjectStore { createdAt: new Date(p.createdAt), updatedAt: new Date(p.updatedAt) })); + // Migration: auto-detect customer projects created before type persistence + if (this.migrateCustomerTypes(data.projects)) { + writeFileAtomicSync(this.storePath, JSON.stringify(data, null, 2)); + } return data; } catch { return { projects: [], settings: {} }; @@ -76,6 +80,39 @@ export class ProjectStore { return { projects: [], settings: {} }; } + /** + * Migration: detect customer projects that were created before type persistence. + * A customer project is identified by having child projects nested inside its path + * and no .git directory (customer folders are plain directories, not git repos). + * Returns true if any projects were migrated. + */ + private migrateCustomerTypes(projects: Project[]): boolean { + let changed = false; + const allPaths = projects.map(p => p.path); + + for (const project of projects) { + if (project.type) continue; // Already has type, skip + + // Check if this project has children (other projects nested inside its path) + const hasChildren = allPaths.some(otherPath => + otherPath !== project.path && otherPath.startsWith(project.path + '/') + ); + + if (hasChildren) { + // A project with children and no git is a customer folder + const gitPath = path.join(project.path, '.git'); + if (!existsSync(gitPath)) { + project.type = 'customer'; + project.updatedAt = new Date(); + changed = true; + console.warn(`[ProjectStore] Migration: Marked "${project.name}" as customer (has child projects, no git)`); + } + } + } + + return changed; + } + /** * Save store to disk */ @@ -86,7 +123,7 @@ export class ProjectStore { /** * Add a new project */ - addProject(projectPath: string, name?: string): Project { + addProject(projectPath: string, name?: string, type?: 'project' | 'customer'): Project { // CRITICAL: Normalize to absolute path for dev mode compatibility // This prevents path resolution issues after app restart const absolutePath = ensureAbsolutePath(projectPath); @@ -118,7 +155,8 @@ export class ProjectStore { autoBuildPath, settings: { ...DEFAULT_PROJECT_SETTINGS }, createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), + ...(type && { type }) }; this.data.projects.push(project); @@ -140,6 +178,19 @@ export class ProjectStore { return project; } + /** + * Update project type (e.g., 'customer') + */ + updateProjectType(projectId: string, type: 'project' | 'customer'): Project | undefined { + const project = this.data.projects.find((p) => p.id === projectId); + if (project) { + project.type = type; + project.updatedAt = new Date(); + this.save(); + } + return project; + } + /** * Remove a project */ diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index c2115eb110..2b1f47e089 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -10,7 +10,9 @@ import type { VersionSuggestion, PaginatedIssuesResult, PRStatusUpdate, - PollingMetadata + PollingMetadata, + MultiRepoGitHubStatus, + MultiRepoIssuesResult } from '../../../shared/types'; import { createIpcListener, invokeIpc, sendIpc, IpcListenerCleanup } from './ipc-utils'; @@ -166,6 +168,20 @@ export interface GitHubAPI { getGitHubIssue: (projectId: string, issueNumber: number) => Promise>; getIssueComments: (projectId: string, issueNumber: number) => Promise>; checkGitHubConnection: (projectId: string) => Promise>; + + // Customer multi-repo operations + checkMultiRepoConnection: (customerId: string) => Promise>; + getMultiRepoIssues: ( + customerId: string, + state?: 'open' | 'closed' | 'all', + page?: number + ) => Promise>; + getMultiRepoIssueDetail: ( + customerId: string, + repoFullName: string, + issueNumber: number + ) => Promise>; + investigateGitHubIssue: (projectId: string, issueNumber: number, selectedCommentIds?: number[]) => void; importGitHubIssues: (projectId: string, issueNumbers: number[]) => Promise>; createGitHubRelease: ( @@ -185,6 +201,7 @@ export interface GitHubAPI { getGitHubToken: () => Promise>; getGitHubUser: () => Promise>; listGitHubUserRepos: () => Promise }>>; + cloneGitHubRepo: (repoFullName: string, targetDir: string) => Promise>; // OAuth event listener - receives device code immediately when extracted onGitHubAuthDeviceCode: ( @@ -554,6 +571,24 @@ export const createGitHubAPI = (): GitHubAPI => ({ checkGitHubConnection: (projectId: string): Promise> => invokeIpc(IPC_CHANNELS.GITHUB_CHECK_CONNECTION, projectId), + // Customer multi-repo operations + checkMultiRepoConnection: (customerId: string): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_CHECK_MULTI_REPO_CONNECTION, customerId), + + getMultiRepoIssues: ( + customerId: string, + state?: 'open' | 'closed' | 'all', + page?: number + ): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_GET_MULTI_REPO_ISSUES, customerId, state, page), + + getMultiRepoIssueDetail: ( + customerId: string, + repoFullName: string, + issueNumber: number + ): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_GET_MULTI_REPO_ISSUE_DETAIL, customerId, repoFullName, issueNumber), + investigateGitHubIssue: (projectId: string, issueNumber: number, selectedCommentIds?: number[]): void => sendIpc(IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE, projectId, issueNumber, selectedCommentIds), @@ -590,6 +625,9 @@ export const createGitHubAPI = (): GitHubAPI => ({ listGitHubUserRepos: (): Promise }>> => invokeIpc(IPC_CHANNELS.GITHUB_LIST_USER_REPOS), + cloneGitHubRepo: (repoFullName: string, targetDir: string): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_CLONE_REPO, repoFullName, targetDir), + // OAuth event listener - receives device code immediately when extracted (during auth process) onGitHubAuthDeviceCode: ( callback: (data: { deviceCode: string; authUrl: string; browserOpened: boolean }) => void diff --git a/apps/frontend/src/preload/api/project-api.ts b/apps/frontend/src/preload/api/project-api.ts index 4f81de56b1..e71f526cb2 100644 --- a/apps/frontend/src/preload/api/project-api.ts +++ b/apps/frontend/src/preload/api/project-api.ts @@ -25,7 +25,7 @@ export interface TabState { export interface ProjectAPI { // Project Management - addProject: (projectPath: string) => Promise>; + addProject: (projectPath: string, type?: 'project' | 'customer') => Promise>; removeProject: (projectId: string) => Promise; getProjects: () => Promise>; updateProjectSettings: ( @@ -152,8 +152,8 @@ export interface ProjectAPI { export const createProjectAPI = (): ProjectAPI => ({ // Project Management - addProject: (projectPath: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.PROJECT_ADD, projectPath), + addProject: (projectPath: string, type?: 'project' | 'customer'): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_ADD, projectPath, type), removeProject: (projectId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_REMOVE, projectId), diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index d8b4f684cc..9bb682414d 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -55,6 +55,7 @@ import { OnboardingWizard } from './components/onboarding'; import { AppUpdateNotification } from './components/AppUpdateNotification'; import { ProactiveSwapListener } from './components/ProactiveSwapListener'; import { GitHubSetupModal } from './components/GitHubSetupModal'; +import { CustomerReposModal } from './components/CustomerReposModal'; import { useProjectStore, loadProjects, addProject, initializeProject, removeProject } from './stores/project-store'; import { useTaskStore, loadTasks } from './stores/task-store'; import { useSettingsStore, loadSettings, loadProfiles, saveSettings } from './stores/settings-store'; @@ -159,6 +160,10 @@ export function App() { const [showGitHubSetup, setShowGitHubSetup] = useState(false); const [gitHubSetupProject, setGitHubSetupProject] = useState(null); + // Customer repos modal state (shown after customer GitHub auth) + const [showCustomerRepos, setShowCustomerRepos] = useState(false); + const [customerReposProject, setCustomerReposProject] = useState(null); + // Remove project confirmation state const [showRemoveProjectDialog, setShowRemoveProjectDialog] = useState(false); const [removeProjectError, setRemoveProjectError] = useState(null); @@ -778,18 +783,26 @@ export function App() { // - Claude token: for Claude AI access (run.py, roadmap, etc.) // The user needs to separately authenticate with Claude using 'claude setup-token' - // Update project env config with GitHub settings - await window.electronAPI.updateProjectEnv(gitHubSetupProject.id, { - githubEnabled: true, - githubToken: settings.githubToken, // GitHub token for repo access - githubRepo: settings.githubRepo, - githubAuthMethod: settings.githubAuthMethod // Track how user authenticated - }); - - // Update project settings with mainBranch - await window.electronAPI.updateProjectSettings(gitHubSetupProject.id, { - mainBranch: settings.mainBranch - }); + if (gitHubSetupProject.type === 'customer') { + // Customer flow: only save the GitHub token (no repo/branch needed) + await window.electronAPI.updateProjectEnv(gitHubSetupProject.id, { + githubEnabled: true, + githubToken: settings.githubToken, + githubAuthMethod: settings.githubAuthMethod + }); + } else { + // Regular project flow: save token + repo + branch + await window.electronAPI.updateProjectEnv(gitHubSetupProject.id, { + githubEnabled: true, + githubToken: settings.githubToken, + githubRepo: settings.githubRepo, + githubAuthMethod: settings.githubAuthMethod + }); + + await window.electronAPI.updateProjectSettings(gitHubSetupProject.id, { + mainBranch: settings.mainBranch + }); + } // Refresh projects to get updated data await loadProjects(); @@ -797,6 +810,12 @@ export function App() { console.error('Failed to save GitHub settings:', error); } + // For customers, open the repos modal to clone repositories + if (gitHubSetupProject.type === 'customer') { + setCustomerReposProject(gitHubSetupProject); + setShowCustomerRepos(true); + } + setShowGitHubSetup(false); setGitHubSetupProject(null); }; @@ -1111,6 +1130,18 @@ export function App() { /> )} + {/* Customer Repos Modal - clone GitHub repos into customer folder */} + {customerReposProject && ( + { + setShowCustomerRepos(open); + if (!open) setCustomerReposProject(null); + }} + customer={customerReposProject} + /> + )} + {/* Remove Project Confirmation Dialog */} { if (!open) handleCancelRemoveProject(); diff --git a/apps/frontend/src/renderer/components/AddCustomerModal.tsx b/apps/frontend/src/renderer/components/AddCustomerModal.tsx index deaa54f287..cf905d6001 100644 --- a/apps/frontend/src/renderer/components/AddCustomerModal.tsx +++ b/apps/frontend/src/renderer/components/AddCustomerModal.tsx @@ -40,13 +40,12 @@ export function AddCustomerModal({ open, onOpenChange, onCustomerAdded }: AddCus }, [open]); const registerAndInitCustomer = async (path: string) => { - // Call IPC directly so we can set type: 'customer' BEFORE selectProject - // (addProject auto-selects, which triggers Sidebar git check before type is set) - const result = await window.electronAPI.addProject(path); + // Pass type: 'customer' through IPC so it's persisted to disk (projects.json) + const result = await window.electronAPI.addProject(path, 'customer'); if (!result.success || !result.data) return; const store = useProjectStore.getState(); - const project = { ...result.data, type: 'customer' as const }; + const project = result.data; // Add with type already set, then select — Sidebar will see type: 'customer' and skip git check store.addProject(project); diff --git a/apps/frontend/src/renderer/components/CustomerReposModal.tsx b/apps/frontend/src/renderer/components/CustomerReposModal.tsx new file mode 100644 index 0000000000..bd83685d27 --- /dev/null +++ b/apps/frontend/src/renderer/components/CustomerReposModal.tsx @@ -0,0 +1,246 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Github, Download, CheckCircle2, Loader2, Lock, Globe, Search, X, FolderGit2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from './ui/dialog'; +import { Button } from './ui/button'; +import { cn } from '../lib/utils'; +import { useProjectStore } from '../stores/project-store'; +import type { Project } from '../../shared/types'; + +interface CustomerReposModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + customer: Project; +} + +interface RepoItem { + fullName: string; + description: string | null; + isPrivate: boolean; +} + +type CloneStatus = 'idle' | 'cloning' | 'done' | 'error'; + +export function CustomerReposModal({ open, onOpenChange, customer }: CustomerReposModalProps) { + const { t } = useTranslation('dialogs'); + const [repos, setRepos] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [cloneStatuses, setCloneStatuses] = useState>({}); + const [cloneErrors, setCloneErrors] = useState>({}); + + useEffect(() => { + if (open) { + setSearch(''); + setCloneStatuses({}); + setCloneErrors({}); + loadRepos(); + } + }, [open]); + + const loadRepos = async () => { + setIsLoading(true); + setError(null); + try { + const result = await window.electronAPI.listGitHubUserRepos(); + if (result.success && result.data) { + setRepos(result.data.repos); + } else { + setError(result.error || t('customerRepos.failedToLoad')); + } + } catch (err) { + setError(err instanceof Error ? err.message : t('customerRepos.failedToLoad')); + } finally { + setIsLoading(false); + } + }; + + const handleClone = async (repo: RepoItem) => { + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'cloning' })); + setCloneErrors(prev => { + const next = { ...prev }; + delete next[repo.fullName]; + return next; + }); + + try { + const result = await window.electronAPI.cloneGitHubRepo(repo.fullName, customer.path); + if (!result.success || !result.data) { + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'error' })); + setCloneErrors(prev => ({ ...prev, [repo.fullName]: result.error || t('customerRepos.cloneFailed') })); + return; + } + + // Register the cloned repo as a project + const addResult = await window.electronAPI.addProject(result.data.path); + if (addResult.success && addResult.data) { + const store = useProjectStore.getState(); + store.addProject(addResult.data); + } + + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'done' })); + } catch (err) { + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'error' })); + setCloneErrors(prev => ({ + ...prev, + [repo.fullName]: err instanceof Error ? err.message : t('customerRepos.cloneFailed') + })); + } + }; + + const filteredRepos = repos.filter(repo => + repo.fullName.toLowerCase().includes(search.toLowerCase()) || + (repo.description && repo.description.toLowerCase().includes(search.toLowerCase())) + ); + + const clonedCount = Object.values(cloneStatuses).filter(s => s === 'done').length; + + return ( + + + + + + {t('customerRepos.title')} + + + {t('customerRepos.description', { name: customer.name })} + + + + {/* Search */} +
+ + setSearch(e.target.value)} + placeholder={t('customerRepos.searchPlaceholder')} + className={cn( + 'w-full rounded-md border border-input bg-background pl-9 pr-9 py-2 text-sm', + 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring' + )} + /> + {search && ( + + )} +
+ + {/* Repo list */} +
+ {isLoading && ( +
+ + {t('customerRepos.loading')} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {!isLoading && !error && filteredRepos.length === 0 && ( +
+ {search ? t('customerRepos.noResults') : t('customerRepos.noRepos')} +
+ )} + + {filteredRepos.map((repo) => { + const status = cloneStatuses[repo.fullName] || 'idle'; + const cloneError = cloneErrors[repo.fullName]; + + return ( +
+ +
+
+ {repo.fullName} + {repo.isPrivate ? ( + + ) : ( + + )} +
+ {repo.description && ( +

+ {repo.description} +

+ )} + {cloneError && ( +

{cloneError}

+ )} +
+ +
+ {status === 'idle' && ( + + )} + {status === 'cloning' && ( + + )} + {status === 'done' && ( + + + {t('customerRepos.cloned')} + + )} + {status === 'error' && ( + + )} +
+
+ ); + })} +
+ + {/* Footer */} +
+ + {clonedCount > 0 && t('customerRepos.clonedCount', { count: clonedCount })} + + +
+
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/EnvConfigModal.tsx b/apps/frontend/src/renderer/components/EnvConfigModal.tsx index e22112a920..acc881c87d 100644 --- a/apps/frontend/src/renderer/components/EnvConfigModal.tsx +++ b/apps/frontend/src/renderer/components/EnvConfigModal.tsx @@ -64,6 +64,7 @@ export function EnvConfigModal({ id: string; name: string; oauthToken?: string; + configDir?: string; email?: string; isDefault: boolean; }>>([]); @@ -153,33 +154,44 @@ export function EnvConfigModal({ setError(null); try { - // Get the selected profile's token const profile = claudeProfiles.find(p => p.id === selectedProfileId); - if (!profile?.oauthToken) { - setError('Selected profile does not have a valid token'); + if (!profile) { + setError('Profile not found'); setIsSaving(false); return; } - // Save the token to auto-claude .env - const result = await window.electronAPI.updateSourceEnv({ - claudeOAuthToken: profile.oauthToken - }); - - if (result.success) { + // Try to use profile's oauthToken if available (legacy path) + if (profile.oauthToken) { + const result = await window.electronAPI.updateSourceEnv({ + claudeOAuthToken: profile.oauthToken + }); + + if (result.success) { + setSuccess(true); + setHasExistingToken(true); + setTimeout(() => { + onConfigured?.(); + onOpenChange(false); + }, 1500); + } else { + setError(result.error || 'Failed to save token'); + } + } else if (profile.configDir || profile.isDefault) { + // Profile uses Keychain-based auth (modern path) + // The profile is authenticated via OS Keychain, no need to copy token to .env + // The main process will resolve credentials from the active profile's Keychain setSuccess(true); setHasExistingToken(true); - - // Notify parent setTimeout(() => { onConfigured?.(); onOpenChange(false); }, 1500); } else { - setError(result.error || 'Failed to save token'); + setError('Selected profile does not have valid credentials. Please re-authenticate.'); } } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : 'Failed to use profile'); } finally { setIsSaving(false); } diff --git a/apps/frontend/src/renderer/components/GitHubIssues.tsx b/apps/frontend/src/renderer/components/GitHubIssues.tsx index 410b60d485..520893695e 100644 --- a/apps/frontend/src/renderer/components/GitHubIssues.tsx +++ b/apps/frontend/src/renderer/components/GitHubIssues.tsx @@ -7,6 +7,7 @@ import { useIssueFiltering, useAutoFix, } from "./github-issues/hooks"; +import { useMultiRepoGitHubIssues } from "./github-issues/hooks/useMultiRepoGitHubIssues"; import { useAnalyzePreview } from "./github-issues/hooks/useAnalyzePreview"; import { NotConnectedState, @@ -17,6 +18,7 @@ import { InvestigationDialog, BatchReviewWizard, } from "./github-issues/components"; +import { RepoFilterDropdown } from "./github-issues/components/RepoFilterDropdown"; import { GitHubSetupModal } from "./GitHubSetupModal"; import type { GitHubIssue } from "../../shared/types"; import type { GitHubIssuesProps } from "./github-issues/types"; @@ -27,6 +29,15 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP const selectedProject = projects.find((p) => p.id === selectedProjectId); const tasks = useTaskStore((state) => state.tasks); + const isCustomer = selectedProject?.type === 'customer'; + + // Single-repo hook (active when NOT a customer) + const singleRepo = useGitHubIssues(isCustomer ? undefined : selectedProject?.id); + + // Multi-repo hook (active when IS a customer) + const multiRepo = useMultiRepoGitHubIssues(isCustomer ? selectedProject?.id : undefined); + + // Select the active hook's data const { syncStatus, isLoading, @@ -44,14 +55,14 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP handleLoadMore, handleSearchStart, handleSearchClear, - } = useGitHubIssues(selectedProject?.id); + } = isCustomer ? multiRepo : singleRepo; const { investigationStatus, lastInvestigationResult, startInvestigation, resetInvestigationStatus, - } = useGitHubInvestigation(selectedProject?.id); + } = useGitHubInvestigation(isCustomer ? undefined : selectedProject?.id); const { searchQuery, setSearchQuery, filteredIssues, isSearchActive } = useIssueFiltering( getFilteredIssues(), @@ -68,9 +79,9 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP batchProgress, toggleAutoFix, checkForNewIssues, - } = useAutoFix(selectedProject?.id); + } = useAutoFix(isCustomer ? undefined : selectedProject?.id); - // Analyze & Group Issues (proactive workflow) + // Analyze & Group Issues (proactive workflow) - disabled for customer multi-repo const { isWizardOpen, isAnalyzing, @@ -82,7 +93,7 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP closeWizard, startAnalysis, approveBatches, - } = useAnalyzePreview({ projectId: selectedProject?.id || "" }); + } = useAnalyzePreview({ projectId: isCustomer ? "" : (selectedProject?.id || "") }); const [showInvestigateDialog, setShowInvestigateDialog] = useState(false); const [selectedIssueForInvestigation, setSelectedIssueForInvestigation] = @@ -135,6 +146,15 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP resetInvestigationStatus(); }, [resetInvestigationStatus]); + // Derive header repo name + const headerRepoName = isCustomer + ? (multiRepo.repos.length > 0 + ? (multiRepo.selectedRepo === 'all' + ? `${multiRepo.repos.length} repos` + : multiRepo.selectedRepo) + : '') + : (singleRepo.syncStatus?.repoFullName ?? ""); + // Not connected state if (!syncStatus?.connected) { return ; @@ -144,7 +164,7 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
{/* Header */} + {/* Repo filter dropdown for multi-repo mode */} + {isCustomer && multiRepo.repos.length > 1 && ( +
+ +
+ )} + {/* Content */}
{/* Issue List */} @@ -176,6 +207,7 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP onLoadMore={!isSearchActive ? handleLoadMore : undefined} onRetry={handleRefresh} onOpenSettings={onOpenSettings} + showRepoBadge={isCustomer} />
@@ -193,8 +225,8 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP linkedTaskId={issueToTaskMap.get(selectedIssue.number)} onViewTask={onNavigateToTask} projectId={selectedProject?.id} - autoFixConfig={autoFixConfig} - autoFixQueueItem={getAutoFixQueueItem(selectedIssue.number)} + autoFixConfig={isCustomer ? null : autoFixConfig} + autoFixQueueItem={isCustomer ? null : getAutoFixQueueItem(selectedIssue.number)} /> ) : ( @@ -213,22 +245,24 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP projectId={selectedProject?.id} /> - {/* Batch Review Wizard (Proactive workflow) */} - + {/* Batch Review Wizard (Proactive workflow) - not available in multi-repo mode */} + {!isCustomer && ( + + )} {/* GitHub Setup Modal - shown when GitHub module is not configured */} - {selectedProject && ( + {selectedProject && !isCustomer && ( { setGithubToken(token); + // For customers, we only need the GitHub token — skip repo/branch/claude steps + if (project.type === 'customer') { + onComplete({ + githubToken: token, + githubRepo: '', + mainBranch: '', + githubAuthMethod: 'oauth' + }); + return; + } + // Check if Claude is already authenticated before showing auth step try { const profilesResult = await window.electronAPI.getClaudeProfiles(); diff --git a/apps/frontend/src/renderer/components/Insights.tsx b/apps/frontend/src/renderer/components/Insights.tsx index b7133ef8af..488d65397a 100644 --- a/apps/frontend/src/renderer/components/Insights.tsx +++ b/apps/frontend/src/renderer/components/Insights.tsx @@ -564,7 +564,7 @@ export function Insights({ projectId }: InsightsProps) { onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - placeholder="Ask about your codebase..." + placeholder={t('insights.placeholder')} className={cn( 'min-h-[80px] resize-none', isDragOver && 'border-primary ring-2 ring-primary/20' diff --git a/apps/frontend/src/renderer/components/Sidebar.tsx b/apps/frontend/src/renderer/components/Sidebar.tsx index ca1eced110..bf5b46263b 100644 --- a/apps/frontend/src/renderer/components/Sidebar.tsx +++ b/apps/frontend/src/renderer/components/Sidebar.tsx @@ -23,8 +23,7 @@ import { Wrench, PanelLeft, PanelLeftClose, - FolderOpen, - Users + FolderOpen } from 'lucide-react'; import { Button } from './ui/button'; import { ScrollArea } from './ui/scroll-area'; @@ -135,6 +134,28 @@ export function Sidebar({ const selectedProject = projects.find((p) => p.id === selectedProjectId); + // Determine customer context: the parent customer for the selected project + // - If selected is a customer → that customer + // - If selected is a child of a customer → the parent customer + // - Otherwise → null (regular project, no repo dropdown) + const customerContext = useMemo(() => { + if (!selectedProject) return null; + if (selectedProject.type === 'customer') return selectedProject; + // Check if selected project is inside a customer's folder + const parentCustomer = projects.find( + p => p.type === 'customer' && selectedProject.path.startsWith(p.path + '/') + ); + return parentCustomer ?? null; + }, [selectedProject, projects]); + + // Child repos belonging to the current customer context + const customerChildRepos = useMemo(() => { + if (!customerContext) return []; + return projects.filter( + p => p.id !== customerContext.id && p.path.startsWith(customerContext.path + '/') + ); + }, [customerContext, projects]); + // Sidebar collapsed state from settings const isCollapsed = settings.sidebarCollapsed ?? false; @@ -149,11 +170,24 @@ export function Sidebar({ // Track the last loaded project ID to avoid redundant loads const lastLoadedProjectIdRef = useRef(null); - // Compute visible nav items — GitHub/GitLab always shown so users can configure credentials + // When the selected project is a child repo of a customer (selected via dropdown), + // hide GitHub/GitLab nav items — they should not be influenced by the dropdown + const isCustomerChildRepo = !!(customerContext && selectedProject && selectedProject.type !== 'customer'); + + // Compute visible nav items — show GitHub OR GitLab based on what's configured const visibleNavItems = useMemo(() => { - const items = [...baseNavItems, ...githubNavItems, ...gitlabNavItems]; + const items = [...baseNavItems]; + // Don't show GitHub/GitLab items for child repos selected via customer dropdown + if (isCustomerChildRepo) return items; + if (githubEnabled && !gitlabEnabled) { + items.push(...githubNavItems); + } else if (gitlabEnabled && !githubEnabled) { + items.push(...gitlabNavItems); + } else if (githubEnabled && gitlabEnabled) { + items.push(...githubNavItems, ...gitlabNavItems); + } return items; - }, []); + }, [githubEnabled, gitlabEnabled, isCustomerChildRepo]); // Load envConfig when project changes to ensure store is populated useEffect(() => { @@ -217,6 +251,9 @@ export function Sidebar({ return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedProjectId, onViewChange, visibleNavItems]); + // Track which project IDs had git modal dismissed to avoid re-showing + const gitModalDismissedRef = useRef>(new Set()); + // Check git status when project changes (skip for customer-type projects) useEffect(() => { const checkGit = async () => { @@ -231,7 +268,8 @@ export function Sidebar({ if (result.success && result.data) { setGitStatus(result.data); // Show git setup modal if project is not a git repo or has no commits - if (!result.data.isGitRepo || !result.data.hasCommits) { + // but only if user hasn't already dismissed it for this project + if ((!result.data.isGitRepo || !result.data.hasCommits) && !gitModalDismissedRef.current.has(selectedProject.id)) { setShowGitSetupModal(true); } } @@ -243,7 +281,7 @@ export function Sidebar({ } }; checkGit(); - }, [selectedProject]); + }, [selectedProjectId]); const handleProjectAdded = (project: Project, needsInit: boolean) => { if (needsInit) { @@ -410,60 +448,27 @@ export function Sidebar({ )} - {/* Project Selector Dropdown */} - {!isCollapsed ? ( + {/* Repo Selector Dropdown — only visible in customer context */} + {customerContext && customerChildRepos.length > 0 && !isCollapsed && (
- ) : ( - - - - - - {selectedProject?.name ?? t('navigation:projectSelector.placeholder')} - - )}