diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 56a6d92e..0c5f2915 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -34,6 +34,54 @@ module.exports = { ], '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/switch-exhaustiveness-check': 'error', + // Enforce typesafe modal pattern + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@ebay/nice-modal-react', + importNames: ['default'], + message: + 'Import NiceModal only in lib/modals.ts or dialog component files. Use DialogName.show(props) instead.', + }, + { + name: '@/lib/modals', + importNames: ['showModal', 'hideModal', 'removeModal'], + message: + 'Do not import showModal/hideModal/removeModal. Use DialogName.show(props) and DialogName.hide() instead.', + }, + ], + }, + ], + 'no-restricted-syntax': [ + 'error', + { + selector: + 'CallExpression[callee.object.name="NiceModal"][callee.property.name="show"]', + message: + 'Do not use NiceModal.show() directly. Use DialogName.show(props) instead.', + }, + { + selector: + 'CallExpression[callee.object.name="NiceModal"][callee.property.name="register"]', + message: + 'Do not use NiceModal.register(). Dialogs are registered automatically.', + }, + { + selector: 'CallExpression[callee.name="showModal"]', + message: + 'Do not use showModal(). Use DialogName.show(props) instead.', + }, + { + selector: 'CallExpression[callee.name="hideModal"]', + message: 'Do not use hideModal(). Use DialogName.hide() instead.', + }, + { + selector: 'CallExpression[callee.name="removeModal"]', + message: 'Do not use removeModal(). Use DialogName.remove() instead.', + }, + ], // i18n rule - only active when LINT_I18N=true 'i18next/no-literal-string': i18nCheck ? [ @@ -76,5 +124,13 @@ module.exports = { '@typescript-eslint/switch-exhaustiveness-check': 'off', }, }, + { + // Allow NiceModal usage in lib/modals.ts, App.tsx (for Provider), and dialog component files + files: ['src/lib/modals.ts', 'src/App.tsx', 'src/components/dialogs/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': 'off', + 'no-restricted-syntax': 'off', + }, + }, ], }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6e797a87..73f2a805 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,9 +31,11 @@ import { ThemeMode } from 'shared/types'; import * as Sentry from '@sentry/react'; import { Loader } from '@/components/ui/loader'; -import NiceModal from '@ebay/nice-modal-react'; -import { OnboardingResult } from './components/dialogs/global/OnboardingDialog'; +import { DisclaimerDialog } from '@/components/dialogs/global/DisclaimerDialog'; +import { OnboardingDialog } from '@/components/dialogs/global/OnboardingDialog'; +import { ReleaseNotesDialog } from '@/components/dialogs/global/ReleaseNotesDialog'; import { ClickedElementsProvider } from './contexts/ClickedElementsProvider'; +import NiceModal from '@ebay/nice-modal-react'; const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); @@ -64,17 +66,17 @@ function AppContent() { const showNextStep = async () => { // 1) Disclaimer - first step if (!config.disclaimer_acknowledged) { - await NiceModal.show('disclaimer'); + await DisclaimerDialog.show(); if (!cancelled) { await updateAndSaveConfig({ disclaimer_acknowledged: true }); } - await NiceModal.hide('disclaimer'); + DisclaimerDialog.hide(); return; } // 2) Onboarding - configure executor and editor if (!config.onboarding_acknowledged) { - const result: OnboardingResult = await NiceModal.show('onboarding'); + const result = await OnboardingDialog.show(); if (!cancelled) { await updateAndSaveConfig({ onboarding_acknowledged: true, @@ -82,17 +84,17 @@ function AppContent() { editor: result.editor, }); } - await NiceModal.hide('onboarding'); + OnboardingDialog.hide(); return; } // 3) Release notes - last step if (config.show_release_notes) { - await NiceModal.show('release-notes'); + await ReleaseNotesDialog.show(); if (!cancelled) { await updateAndSaveConfig({ show_release_notes: false }); } - await NiceModal.hide('release-notes'); + ReleaseNotesDialog.hide(); return; } }; diff --git a/frontend/src/components/NormalizedConversation/NextActionCard.tsx b/frontend/src/components/NormalizedConversation/NextActionCard.tsx index a692d602..a6cd70cf 100644 --- a/frontend/src/components/NormalizedConversation/NextActionCard.tsx +++ b/frontend/src/components/NormalizedConversation/NextActionCard.tsx @@ -11,7 +11,9 @@ import { Settings, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import NiceModal from '@ebay/nice-modal-react'; +import { ViewProcessesDialog } from '@/components/dialogs/tasks/ViewProcessesDialog'; +import { CreateAttemptDialog } from '@/components/dialogs/tasks/CreateAttemptDialog'; +import { GitActionsDialog } from '@/components/dialogs/tasks/GitActionsDialog'; import { useOpenInEditor } from '@/hooks/useOpenInEditor'; import { useDiffSummary } from '@/hooks/useDiffSummary'; import { useDevServer } from '@/hooks/useDevServer'; @@ -93,7 +95,7 @@ export function NextActionCard({ const handleViewLogs = useCallback(() => { if (attemptId) { - NiceModal.show('view-processes', { + ViewProcessesDialog.show({ attemptId, initialProcessId: latestDevServerProcess?.id, }); @@ -106,14 +108,14 @@ export function NextActionCard({ const handleTryAgain = useCallback(() => { if (!attempt?.task_id) return; - NiceModal.show('create-attempt', { + CreateAttemptDialog.show({ taskId: attempt.task_id, }); }, [attempt?.task_id]); const handleGitActions = useCallback(() => { if (!attemptId) return; - NiceModal.show('git-actions', { + GitActionsDialog.show({ attemptId, task, projectId: project?.id, diff --git a/frontend/src/components/NormalizedConversation/RetryEditorInline.tsx b/frontend/src/components/NormalizedConversation/RetryEditorInline.tsx index 45e375e5..4b3aab31 100644 --- a/frontend/src/components/NormalizedConversation/RetryEditorInline.tsx +++ b/frontend/src/components/NormalizedConversation/RetryEditorInline.tsx @@ -21,7 +21,7 @@ import type { DraftResponse, TaskAttempt } from 'shared/types'; import { useAttemptExecution } from '@/hooks/useAttemptExecution'; import { useUserSystem } from '@/components/config-provider'; import { useBranchStatus } from '@/hooks/useBranchStatus'; -import { showModal } from '@/lib/modals'; +import { RestoreLogsDialog } from '@/components/dialogs/tasks/RestoreLogsDialog'; import { shouldShowInLogs, isCodingAgent, @@ -191,7 +191,7 @@ export function RetryEditorInline({ // Ask user for confirmation let modalResult: RestoreLogsDialogResult | undefined; try { - modalResult = await showModal('restore-logs', { + modalResult = await RestoreLogsDialog.show({ targetSha: before, targetSubject, commitsToReset, diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx index 97052547..e86b7102 100644 --- a/frontend/src/components/TagManager.tsx +++ b/frontend/src/components/TagManager.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react'; import { tagsApi } from '@/lib/api'; -import { showTagEdit } from '@/lib/modals'; +import { TagEditDialog } from '@/components/dialogs/tasks/TagEditDialog'; import type { Tag } from 'shared/types'; export function TagManager() { @@ -30,7 +30,7 @@ export function TagManager() { const handleOpenDialog = useCallback( async (tag?: Tag) => { try { - const result = await showTagEdit({ + const result = await TagEditDialog.show({ tag: tag || null, }); diff --git a/frontend/src/components/dialogs/auth/GhCliSetupDialog.tsx b/frontend/src/components/dialogs/auth/GhCliSetupDialog.tsx index cdc24fd2..95520866 100644 --- a/frontend/src/components/dialogs/auth/GhCliSetupDialog.tsx +++ b/frontend/src/components/dialogs/auth/GhCliSetupDialog.tsx @@ -7,6 +7,7 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal } from '@/lib/modals'; import { attemptsApi } from '@/lib/api'; import type { GhCliSetupError } from 'shared/types'; import { useRef, useState } from 'react'; @@ -119,7 +120,7 @@ export const GhCliHelpInstructions = ({ ); }; -export const GhCliSetupDialog = NiceModal.create( +const GhCliSetupDialogImpl = NiceModal.create( ({ attemptId }) => { const modal = useModal(); const { t } = useTranslation(); @@ -246,3 +247,8 @@ export const GhCliSetupDialog = NiceModal.create( ); } ); + +export const GhCliSetupDialog = defineModal< + GhCliSetupDialogProps, + GhCliSetupError | null +>(GhCliSetupDialogImpl); diff --git a/frontend/src/components/dialogs/global/DisclaimerDialog.tsx b/frontend/src/components/dialogs/global/DisclaimerDialog.tsx index 57586766..e0a33526 100644 --- a/frontend/src/components/dialogs/global/DisclaimerDialog.tsx +++ b/frontend/src/components/dialogs/global/DisclaimerDialog.tsx @@ -9,8 +9,9 @@ import { import { Button } from '@/components/ui/button'; import { AlertTriangle } from 'lucide-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal, type NoProps } from '@/lib/modals'; -const DisclaimerDialog = NiceModal.create(() => { +const DisclaimerDialogImpl = NiceModal.create(() => { const modal = useModal(); const handleAccept = () => { @@ -60,4 +61,6 @@ const DisclaimerDialog = NiceModal.create(() => { ); }); -export { DisclaimerDialog }; +export const DisclaimerDialog = defineModal( + DisclaimerDialogImpl +); diff --git a/frontend/src/components/dialogs/global/OAuthDialog.tsx b/frontend/src/components/dialogs/global/OAuthDialog.tsx index 85e466fd..860b0dba 100644 --- a/frontend/src/components/dialogs/global/OAuthDialog.tsx +++ b/frontend/src/components/dialogs/global/OAuthDialog.tsx @@ -16,6 +16,7 @@ import { useAuthStatus } from '@/hooks/auth/useAuthStatus'; import { useUserSystem } from '@/components/config-provider'; import type { ProfileResponse } from 'shared/types'; import { useTranslation } from 'react-i18next'; +import { defineModal, type NoProps } from '@/lib/modals'; type OAuthProvider = 'github' | 'google'; @@ -25,7 +26,7 @@ type OAuthState = | { type: 'success'; profile: ProfileResponse } | { type: 'error'; message: string }; -const OAuthDialog = NiceModal.create(() => { +const OAuthDialogImpl = NiceModal.create(() => { const modal = useModal(); const { t } = useTranslation('common'); const { reloadSystem } = useUserSystem(); @@ -303,4 +304,6 @@ const OAuthDialog = NiceModal.create(() => { ); }); -export { OAuthDialog }; +export const OAuthDialog = defineModal( + OAuthDialogImpl +); diff --git a/frontend/src/components/dialogs/global/OnboardingDialog.tsx b/frontend/src/components/dialogs/global/OnboardingDialog.tsx index 1c5f8381..d57b96a7 100644 --- a/frontend/src/components/dialogs/global/OnboardingDialog.tsx +++ b/frontend/src/components/dialogs/global/OnboardingDialog.tsx @@ -30,13 +30,14 @@ import { useUserSystem } from '@/components/config-provider'; import { toPrettyCase } from '@/utils/string'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal, type NoProps } from '@/lib/modals'; export type OnboardingResult = { profile: ExecutorProfileId; editor: EditorConfig; }; -const OnboardingDialog = NiceModal.create(() => { +const OnboardingDialogImpl = NiceModal.create(() => { const modal = useModal(); const { profiles, config } = useUserSystem(); @@ -227,4 +228,6 @@ const OnboardingDialog = NiceModal.create(() => { ); }); -export { OnboardingDialog }; +export const OnboardingDialog = defineModal( + OnboardingDialogImpl +); diff --git a/frontend/src/components/dialogs/global/ReleaseNotesDialog.tsx b/frontend/src/components/dialogs/global/ReleaseNotesDialog.tsx index 831dfc45..6b4b44b5 100644 --- a/frontend/src/components/dialogs/global/ReleaseNotesDialog.tsx +++ b/frontend/src/components/dialogs/global/ReleaseNotesDialog.tsx @@ -11,10 +11,11 @@ import { AlertCircle, ExternalLink } from 'lucide-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; import { useTheme } from '@/components/theme-provider'; import { getActualTheme } from '@/utils/theme'; +import { defineModal, type NoProps } from '@/lib/modals'; const RELEASE_NOTES_BASE_URL = 'https://vibekanban.com/release-notes'; -export const ReleaseNotesDialog = NiceModal.create(() => { +const ReleaseNotesDialogImpl = NiceModal.create(() => { const modal = useModal(); const [iframeError, setIframeError] = useState(false); const { theme } = useTheme(); @@ -98,3 +99,7 @@ export const ReleaseNotesDialog = NiceModal.create(() => { ); }); + +export const ReleaseNotesDialog = defineModal( + ReleaseNotesDialogImpl +); diff --git a/frontend/src/components/dialogs/index.ts b/frontend/src/components/dialogs/index.ts index 882a3b67..8932454d 100644 --- a/frontend/src/components/dialogs/index.ts +++ b/frontend/src/components/dialogs/index.ts @@ -1,6 +1,9 @@ // Global app dialogs export { DisclaimerDialog } from './global/DisclaimerDialog'; -export { OnboardingDialog } from './global/OnboardingDialog'; +export { + OnboardingDialog, + type OnboardingResult, +} from './global/OnboardingDialog'; export { ReleaseNotesDialog } from './global/ReleaseNotesDialog'; export { OAuthDialog } from './global/OAuthDialog'; @@ -69,6 +72,10 @@ export { ViewProcessesDialog, type ViewProcessesDialogProps, } from './tasks/ViewProcessesDialog'; +export { + ViewRelatedTasksDialog, + type ViewRelatedTasksDialogProps, +} from './tasks/ViewRelatedTasksDialog'; export { GitActionsDialog, type GitActionsDialogProps, @@ -81,6 +88,14 @@ export { StopShareTaskDialog, type StopShareTaskDialogProps, } from './tasks/StopShareTaskDialog'; +export { + EditBranchNameDialog, + type EditBranchNameDialogResult, +} from './tasks/EditBranchNameDialog'; +export { CreateAttemptDialog } from './tasks/CreateAttemptDialog'; + +// Auth dialogs +export { GhCliSetupDialog } from './auth/GhCliSetupDialog'; // Settings dialogs export { diff --git a/frontend/src/components/dialogs/org/CreateOrganizationDialog.tsx b/frontend/src/components/dialogs/org/CreateOrganizationDialog.tsx index 637cf61f..8accf674 100644 --- a/frontend/src/components/dialogs/org/CreateOrganizationDialog.tsx +++ b/frontend/src/components/dialogs/org/CreateOrganizationDialog.tsx @@ -14,13 +14,14 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; import { useOrganizationMutations } from '@/hooks/useOrganizationMutations'; import { useTranslation } from 'react-i18next'; +import { defineModal, type NoProps } from '@/lib/modals'; export type CreateOrganizationResult = { action: 'created' | 'canceled'; organizationId?: string; }; -export const CreateOrganizationDialog = NiceModal.create(() => { +const CreateOrganizationDialogImpl = NiceModal.create(() => { const modal = useModal(); const { t } = useTranslation('organization'); const [name, setName] = useState(''); @@ -198,3 +199,8 @@ export const CreateOrganizationDialog = NiceModal.create(() => { ); }); + +export const CreateOrganizationDialog = defineModal< + void, + CreateOrganizationResult +>(CreateOrganizationDialogImpl); diff --git a/frontend/src/components/dialogs/org/InviteMemberDialog.tsx b/frontend/src/components/dialogs/org/InviteMemberDialog.tsx index 20cb75eb..3be903ad 100644 --- a/frontend/src/components/dialogs/org/InviteMemberDialog.tsx +++ b/frontend/src/components/dialogs/org/InviteMemberDialog.tsx @@ -22,6 +22,7 @@ import NiceModal, { useModal } from '@ebay/nice-modal-react'; import { useOrganizationMutations } from '@/hooks/useOrganizationMutations'; import { MemberRole } from 'shared/types'; import { useTranslation } from 'react-i18next'; +import { defineModal } from '@/lib/modals'; export type InviteMemberResult = { action: 'invited' | 'canceled'; @@ -31,7 +32,7 @@ export interface InviteMemberDialogProps { organizationId: string; } -export const InviteMemberDialog = NiceModal.create( +const InviteMemberDialogImpl = NiceModal.create( (props) => { const modal = useModal(); const { organizationId } = props; @@ -191,3 +192,8 @@ export const InviteMemberDialog = NiceModal.create( ); } ); + +export const InviteMemberDialog = defineModal< + InviteMemberDialogProps, + InviteMemberResult +>(InviteMemberDialogImpl); diff --git a/frontend/src/components/dialogs/projects/LinkProjectDialog.tsx b/frontend/src/components/dialogs/projects/LinkProjectDialog.tsx index ba7a564e..87e55276 100644 --- a/frontend/src/components/dialogs/projects/LinkProjectDialog.tsx +++ b/frontend/src/components/dialogs/projects/LinkProjectDialog.tsx @@ -26,6 +26,7 @@ import { useAuth } from '@/hooks/auth/useAuth'; import { LoginRequiredPrompt } from '@/components/dialogs/shared/LoginRequiredPrompt'; import type { Project } from 'shared/types'; import { useTranslation } from 'react-i18next'; +import { defineModal } from '@/lib/modals'; export type LinkProjectResult = { action: 'linked' | 'canceled'; @@ -39,7 +40,7 @@ interface LinkProjectDialogProps { type LinkMode = 'existing' | 'create'; -export const LinkProjectDialog = NiceModal.create( +const LinkProjectDialogImpl = NiceModal.create( ({ projectId, projectName }) => { const modal = useModal(); const { t } = useTranslation('projects'); @@ -341,3 +342,8 @@ export const LinkProjectDialog = NiceModal.create( ); } ); + +export const LinkProjectDialog = defineModal< + LinkProjectDialogProps, + LinkProjectResult +>(LinkProjectDialogImpl); diff --git a/frontend/src/components/dialogs/projects/ProjectEditorSelectionDialog.tsx b/frontend/src/components/dialogs/projects/ProjectEditorSelectionDialog.tsx index 5e056da4..b68dffbb 100644 --- a/frontend/src/components/dialogs/projects/ProjectEditorSelectionDialog.tsx +++ b/frontend/src/components/dialogs/projects/ProjectEditorSelectionDialog.tsx @@ -18,12 +18,13 @@ import { import { EditorType, Project } from 'shared/types'; import { useOpenProjectInEditor } from '@/hooks/useOpenProjectInEditor'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal } from '@/lib/modals'; export interface ProjectEditorSelectionDialogProps { selectedProject: Project | null; } -export const ProjectEditorSelectionDialog = +const ProjectEditorSelectionDialogImpl = NiceModal.create(({ selectedProject }) => { const modal = useModal(); const handleOpenInEditor = useOpenProjectInEditor(selectedProject, () => @@ -89,3 +90,8 @@ export const ProjectEditorSelectionDialog = ); }); + +export const ProjectEditorSelectionDialog = defineModal< + ProjectEditorSelectionDialogProps, + EditorType | null +>(ProjectEditorSelectionDialogImpl); diff --git a/frontend/src/components/dialogs/projects/ProjectFormDialog.tsx b/frontend/src/components/dialogs/projects/ProjectFormDialog.tsx index 3e7102bb..68168cd5 100644 --- a/frontend/src/components/dialogs/projects/ProjectFormDialog.tsx +++ b/frontend/src/components/dialogs/projects/ProjectFormDialog.tsx @@ -12,6 +12,7 @@ import { CreateProject } from 'shared/types'; import { generateProjectNameFromPath } from '@/utils/string'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; import { useProjectMutations } from '@/hooks/useProjectMutations'; +import { defineModal } from '@/lib/modals'; export interface ProjectFormDialogProps { // No props needed - this is only for creating projects now @@ -19,148 +20,151 @@ export interface ProjectFormDialogProps { export type ProjectFormDialogResult = 'saved' | 'canceled'; -export const ProjectFormDialog = NiceModal.create( - () => { - const modal = useModal(); - const [name, setName] = useState(''); - const [gitRepoPath, setGitRepoPath] = useState(''); - const [error, setError] = useState(''); - const [repoMode, setRepoMode] = useState<'existing' | 'new'>('existing'); - const [parentPath, setParentPath] = useState(''); - const [folderName, setFolderName] = useState(''); +const ProjectFormDialogImpl = NiceModal.create(() => { + const modal = useModal(); + const [name, setName] = useState(''); + const [gitRepoPath, setGitRepoPath] = useState(''); + const [error, setError] = useState(''); + const [repoMode, setRepoMode] = useState<'existing' | 'new'>('existing'); + const [parentPath, setParentPath] = useState(''); + const [folderName, setFolderName] = useState(''); - const { createProject } = useProjectMutations({ - onCreateSuccess: () => { - modal.resolve('saved' as ProjectFormDialogResult); - modal.hide(); - }, - onCreateError: (err) => { - setError(err instanceof Error ? err.message : 'An error occurred'); - }, - }); - - // Auto-populate project name from directory name - const handleGitRepoPathChange = (path: string) => { - setGitRepoPath(path); - - if (path) { - const cleanName = generateProjectNameFromPath(path); - if (cleanName) setName(cleanName); - } - }; - - // Handle direct project creation from repo selection - const handleDirectCreate = async (path: string, suggestedName: string) => { - setError(''); - - const createData: CreateProject = { - name: suggestedName, - git_repo_path: path, - use_existing_repo: true, - setup_script: null, - dev_script: null, - cleanup_script: null, - copy_files: null, - }; - - createProject.mutate(createData); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - - let finalGitRepoPath = gitRepoPath; - if (repoMode === 'new') { - const effectiveParentPath = parentPath.trim(); - const cleanFolderName = folderName.trim(); - finalGitRepoPath = effectiveParentPath - ? `${effectiveParentPath}/${cleanFolderName}`.replace(/\/+/g, '/') - : cleanFolderName; - } - // Auto-populate name from git repo path if not provided - const finalName = - name.trim() || generateProjectNameFromPath(finalGitRepoPath); - - // Creating new project - const createData: CreateProject = { - name: finalName, - git_repo_path: finalGitRepoPath, - use_existing_repo: repoMode === 'existing', - setup_script: null, - dev_script: null, - cleanup_script: null, - copy_files: null, - }; - - createProject.mutate(createData); - }; - - const handleCancel = () => { - // Reset form - setName(''); - setGitRepoPath(''); - setParentPath(''); - setFolderName(''); - setError(''); - - modal.resolve('canceled' as ProjectFormDialogResult); + const { createProject } = useProjectMutations({ + onCreateSuccess: () => { + modal.resolve('saved' as ProjectFormDialogResult); modal.hide(); + }, + onCreateError: (err) => { + setError(err instanceof Error ? err.message : 'An error occurred'); + }, + }); + + // Auto-populate project name from directory name + const handleGitRepoPathChange = (path: string) => { + setGitRepoPath(path); + + if (path) { + const cleanName = generateProjectNameFromPath(path); + if (cleanName) setName(cleanName); + } + }; + + // Handle direct project creation from repo selection + const handleDirectCreate = async (path: string, suggestedName: string) => { + setError(''); + + const createData: CreateProject = { + name: suggestedName, + git_repo_path: path, + use_existing_repo: true, + setup_script: null, + dev_script: null, + cleanup_script: null, + copy_files: null, }; - const handleOpenChange = (open: boolean) => { - if (!open) { - handleCancel(); - } + createProject.mutate(createData); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + let finalGitRepoPath = gitRepoPath; + if (repoMode === 'new') { + const effectiveParentPath = parentPath.trim(); + const cleanFolderName = folderName.trim(); + finalGitRepoPath = effectiveParentPath + ? `${effectiveParentPath}/${cleanFolderName}`.replace(/\/+/g, '/') + : cleanFolderName; + } + // Auto-populate name from git repo path if not provided + const finalName = + name.trim() || generateProjectNameFromPath(finalGitRepoPath); + + // Creating new project + const createData: CreateProject = { + name: finalName, + git_repo_path: finalGitRepoPath, + use_existing_repo: repoMode === 'existing', + setup_script: null, + dev_script: null, + cleanup_script: null, + copy_files: null, }; - return ( - - - - Create Project - Choose your repository source - + createProject.mutate(createData); + }; -
-
- {}} - devScript="" - setDevScript={() => {}} - cleanupScript="" - setCleanupScript={() => {}} - copyFiles="" - setCopyFiles={() => {}} - error={error} - setError={setError} - projectId={undefined} - onCreateProject={handleDirectCreate} - /> - {repoMode === 'new' && ( - - )} - -
-
-
- ); - } -); + const handleCancel = () => { + // Reset form + setName(''); + setGitRepoPath(''); + setParentPath(''); + setFolderName(''); + setError(''); + + modal.resolve('canceled' as ProjectFormDialogResult); + modal.hide(); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + handleCancel(); + } + }; + + return ( + + + + Create Project + Choose your repository source + + +
+
+ {}} + devScript="" + setDevScript={() => {}} + cleanupScript="" + setCleanupScript={() => {}} + copyFiles="" + setCopyFiles={() => {}} + error={error} + setError={setError} + projectId={undefined} + onCreateProject={handleDirectCreate} + /> + {repoMode === 'new' && ( + + )} + +
+
+
+ ); +}); + +export const ProjectFormDialog = defineModal< + ProjectFormDialogProps, + ProjectFormDialogResult +>(ProjectFormDialogImpl); diff --git a/frontend/src/components/dialogs/settings/CreateConfigurationDialog.tsx b/frontend/src/components/dialogs/settings/CreateConfigurationDialog.tsx index b7b5e18f..767848dd 100644 --- a/frontend/src/components/dialogs/settings/CreateConfigurationDialog.tsx +++ b/frontend/src/components/dialogs/settings/CreateConfigurationDialog.tsx @@ -19,6 +19,7 @@ import { } from '@/components/ui/select'; import { Alert, AlertDescription } from '@/components/ui/alert'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal } from '@/lib/modals'; export interface CreateConfigurationDialogProps { executorType: string; @@ -31,7 +32,7 @@ export type CreateConfigurationResult = { cloneFrom?: string | null; }; -export const CreateConfigurationDialog = +const CreateConfigurationDialogImpl = NiceModal.create( ({ executorType, existingConfigs }) => { const modal = useModal(); @@ -156,3 +157,8 @@ export const CreateConfigurationDialog = ); } ); + +export const CreateConfigurationDialog = defineModal< + CreateConfigurationDialogProps, + CreateConfigurationResult +>(CreateConfigurationDialogImpl); diff --git a/frontend/src/components/dialogs/settings/DeleteConfigurationDialog.tsx b/frontend/src/components/dialogs/settings/DeleteConfigurationDialog.tsx index 475b896a..025c14ac 100644 --- a/frontend/src/components/dialogs/settings/DeleteConfigurationDialog.tsx +++ b/frontend/src/components/dialogs/settings/DeleteConfigurationDialog.tsx @@ -11,6 +11,7 @@ import { import { Alert, AlertDescription } from '@/components/ui/alert'; import { Loader2 } from 'lucide-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal } from '@/lib/modals'; export interface DeleteConfigurationDialogProps { configName: string; @@ -19,7 +20,7 @@ export interface DeleteConfigurationDialogProps { export type DeleteConfigurationResult = 'deleted' | 'canceled'; -export const DeleteConfigurationDialog = +const DeleteConfigurationDialogImpl = NiceModal.create( ({ configName, executorType }) => { const modal = useModal(); @@ -92,3 +93,8 @@ export const DeleteConfigurationDialog = ); } ); + +export const DeleteConfigurationDialog = defineModal< + DeleteConfigurationDialogProps, + DeleteConfigurationResult +>(DeleteConfigurationDialogImpl); diff --git a/frontend/src/components/dialogs/shared/ConfirmDialog.tsx b/frontend/src/components/dialogs/shared/ConfirmDialog.tsx index 4eb1eaef..53f1fde5 100644 --- a/frontend/src/components/dialogs/shared/ConfirmDialog.tsx +++ b/frontend/src/components/dialogs/shared/ConfirmDialog.tsx @@ -9,7 +9,7 @@ import { import { Button } from '@/components/ui/button'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; import { AlertTriangle, Info, CheckCircle, XCircle } from 'lucide-react'; -import type { ConfirmResult } from '@/lib/modals'; +import { defineModal, type ConfirmResult } from '@/lib/modals'; export interface ConfirmDialogProps { title: string; @@ -20,7 +20,7 @@ export interface ConfirmDialogProps { icon?: boolean; } -const ConfirmDialog = NiceModal.create((props) => { +const ConfirmDialogImpl = NiceModal.create((props) => { const modal = useModal(); const { title, @@ -83,4 +83,6 @@ const ConfirmDialog = NiceModal.create((props) => { ); }); -export { ConfirmDialog }; +export const ConfirmDialog = defineModal( + ConfirmDialogImpl +); diff --git a/frontend/src/components/dialogs/shared/FolderPickerDialog.tsx b/frontend/src/components/dialogs/shared/FolderPickerDialog.tsx index 2cf69398..7355ed6a 100644 --- a/frontend/src/components/dialogs/shared/FolderPickerDialog.tsx +++ b/frontend/src/components/dialogs/shared/FolderPickerDialog.tsx @@ -22,6 +22,7 @@ import { import { fileSystemApi } from '@/lib/api'; import { DirectoryEntry, DirectoryListResponse } from 'shared/types'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal } from '@/lib/modals'; export interface FolderPickerDialogProps { value?: string; @@ -29,7 +30,7 @@ export interface FolderPickerDialogProps { description?: string; } -export const FolderPickerDialog = NiceModal.create( +const FolderPickerDialogImpl = NiceModal.create( ({ value = '', title = 'Select Folder', @@ -288,3 +289,8 @@ export const FolderPickerDialog = NiceModal.create( ); } ); + +export const FolderPickerDialog = defineModal< + FolderPickerDialogProps, + string | null +>(FolderPickerDialogImpl); diff --git a/frontend/src/components/dialogs/shared/LoginRequiredPrompt.tsx b/frontend/src/components/dialogs/shared/LoginRequiredPrompt.tsx index 50807ef4..174bdfe4 100644 --- a/frontend/src/components/dialogs/shared/LoginRequiredPrompt.tsx +++ b/frontend/src/components/dialogs/shared/LoginRequiredPrompt.tsx @@ -1,8 +1,7 @@ import { useCallback, type ComponentProps } from 'react'; import { useTranslation } from 'react-i18next'; import { LogIn, type LucideIcon } from 'lucide-react'; -import NiceModal from '@ebay/nice-modal-react'; -import { OAuthDialog } from '@/components/dialogs'; +import { OAuthDialog } from '@/components/dialogs/global/OAuthDialog'; import { Alert } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; @@ -38,7 +37,7 @@ export function LoginRequiredPrompt({ onAction(); return; } - void NiceModal.show(OAuthDialog); + void OAuthDialog.show(); }, [onAction]); const Icon = icon ?? LogIn; diff --git a/frontend/src/components/dialogs/tasks/ChangeTargetBranchDialog.tsx b/frontend/src/components/dialogs/tasks/ChangeTargetBranchDialog.tsx index d73f8333..951341fb 100644 --- a/frontend/src/components/dialogs/tasks/ChangeTargetBranchDialog.tsx +++ b/frontend/src/components/dialogs/tasks/ChangeTargetBranchDialog.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'; import BranchSelector from '@/components/tasks/BranchSelector'; import type { GitBranch } from 'shared/types'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal } from '@/lib/modals'; export interface ChangeTargetBranchDialogProps { branches: GitBranch[]; @@ -23,7 +24,7 @@ export type ChangeTargetBranchDialogResult = { branchName?: string; }; -export const ChangeTargetBranchDialog = +const ChangeTargetBranchDialogImpl = NiceModal.create( ({ branches, isChangingTargetBranch: isChangingTargetBranch = false }) => { const modal = useModal(); @@ -100,3 +101,8 @@ export const ChangeTargetBranchDialog = ); } ); + +export const ChangeTargetBranchDialog = defineModal< + ChangeTargetBranchDialogProps, + ChangeTargetBranchDialogResult +>(ChangeTargetBranchDialogImpl); diff --git a/frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx b/frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx index deeef08a..06fedbf1 100644 --- a/frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx +++ b/frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx @@ -24,13 +24,14 @@ import { useProject } from '@/contexts/project-context'; import { useUserSystem } from '@/components/config-provider'; import { paths } from '@/lib/paths'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal } from '@/lib/modals'; import type { ExecutorProfileId, BaseCodingAgent } from 'shared/types'; export interface CreateAttemptDialogProps { taskId: string; } -export const CreateAttemptDialog = NiceModal.create( +const CreateAttemptDialogImpl = NiceModal.create( ({ taskId }) => { const modal = useModal(); const navigate = useNavigateWithSearch(); @@ -135,6 +136,7 @@ export const CreateAttemptDialog = NiceModal.create( profile: effectiveProfile, baseBranch: effectiveBranch, }); + modal.hide(); } catch (err) { console.error('Failed to create attempt:', err); @@ -210,3 +212,7 @@ export const CreateAttemptDialog = NiceModal.create( ); } ); + +export const CreateAttemptDialog = defineModal( + CreateAttemptDialogImpl +); diff --git a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx index bba79bb6..9f56f3de 100644 --- a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx +++ b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx @@ -37,285 +37,310 @@ import type { } from '@/components/dialogs/auth/GhCliSetupDialog'; import type { GhCliSetupError } from 'shared/types'; import { useUserSystem } from '@/components/config-provider'; -const CreatePrDialog = NiceModal.create(() => { - const modal = useModal(); - const { t } = useTranslation('tasks'); - const { isLoaded } = useAuth(); - const { environment } = useUserSystem(); - const data = modal.args as - | { attempt: TaskAttempt; task: TaskWithAttemptStatus; projectId: string } - | undefined; - const [prTitle, setPrTitle] = useState(''); - const [prBody, setPrBody] = useState(''); - const [prBaseBranch, setPrBaseBranch] = useState(''); - const [creatingPR, setCreatingPR] = useState(false); - const [error, setError] = useState(null); - const [ghCliHelp, setGhCliHelp] = useState(null); - const [branches, setBranches] = useState([]); - const [branchesLoading, setBranchesLoading] = useState(false); +import { defineModal } from '@/lib/modals'; - const getGhCliHelpTitle = (variant: GhCliSupportVariant) => - variant === 'homebrew' - ? 'Homebrew is required for automatic setup' - : 'GitHub CLI needs manual setup'; +interface CreatePRDialogProps { + attempt: TaskAttempt; + task: TaskWithAttemptStatus; + projectId: string; +} - useEffect(() => { - if (!modal.visible || !data || !isLoaded) { - return; - } +const CreatePRDialogImpl = NiceModal.create( + ({ attempt, task, projectId }) => { + const modal = useModal(); + const { t } = useTranslation('tasks'); + const { isLoaded } = useAuth(); + const { environment } = useUserSystem(); + const [prTitle, setPrTitle] = useState(''); + const [prBody, setPrBody] = useState(''); + const [prBaseBranch, setPrBaseBranch] = useState(''); + const [creatingPR, setCreatingPR] = useState(false); + const [error, setError] = useState(null); + const [ghCliHelp, setGhCliHelp] = useState( + null + ); + const [branches, setBranches] = useState([]); + const [branchesLoading, setBranchesLoading] = useState(false); - setPrTitle(`${data.task.title} (vibe-kanban)`); - setPrBody(data.task.description || ''); + const getGhCliHelpTitle = (variant: GhCliSupportVariant) => + variant === 'homebrew' + ? 'Homebrew is required for automatic setup' + : 'GitHub CLI needs manual setup'; - // Always fetch branches for dropdown population - if (data.projectId) { - setBranchesLoading(true); - projectsApi - .getBranches(data.projectId) - .then((projectBranches) => { - setBranches(projectBranches); + useEffect(() => { + if (!modal.visible || !isLoaded) { + return; + } - // Set smart default: task target branch OR current branch - if (data.attempt.target_branch) { - setPrBaseBranch(data.attempt.target_branch); - } else { - const currentBranch = projectBranches.find((b) => b.is_current); - if (currentBranch) { - setPrBaseBranch(currentBranch.name); + setPrTitle(`${task.title} (vibe-kanban)`); + setPrBody(task.description || ''); + + // Always fetch branches for dropdown population + if (projectId) { + setBranchesLoading(true); + projectsApi + .getBranches(projectId) + .then((projectBranches) => { + setBranches(projectBranches); + + // Set smart default: task target branch OR current branch + if (attempt.target_branch) { + setPrBaseBranch(attempt.target_branch); + } else { + const currentBranch = projectBranches.find((b) => b.is_current); + if (currentBranch) { + setPrBaseBranch(currentBranch.name); + } } - } - }) - .catch(console.error) - .finally(() => setBranchesLoading(false)); - } + }) + .catch(console.error) + .finally(() => setBranchesLoading(false)); + } - setError(null); // Reset error when opening - setGhCliHelp(null); - }, [modal.visible, data, isLoaded]); + setError(null); // Reset error when opening + setGhCliHelp(null); + }, [modal.visible, isLoaded, task, attempt, projectId]); - const isMacEnvironment = useMemo( - () => environment?.os_type?.toLowerCase().includes('mac'), - [environment?.os_type] - ); + const isMacEnvironment = useMemo( + () => environment?.os_type?.toLowerCase().includes('mac'), + [environment?.os_type] + ); - const handleConfirmCreatePR = useCallback(async () => { - if (!data?.projectId || !data?.attempt.id) return; + const handleConfirmCreatePR = useCallback(async () => { + if (!projectId || !attempt.id) return; - setError(null); - setGhCliHelp(null); - setCreatingPR(true); + setError(null); + setGhCliHelp(null); + setCreatingPR(true); + + const handleGhCliSetupOutcome = ( + setupResult: GhCliSetupError | null, + fallbackMessage: string + ) => { + if (setupResult === null) { + setError(null); + setGhCliHelp(null); + setCreatingPR(false); + modal.hide(); + return; + } + + const ui = mapGhCliErrorToUi(setupResult, fallbackMessage, t); + + if (ui.variant) { + setGhCliHelp(ui); + setError(null); + return; + } - const handleGhCliSetupOutcome = ( - setupResult: GhCliSetupError | null, - fallbackMessage: string - ) => { - if (setupResult === null) { - setError(null); setGhCliHelp(null); + setError(ui.message); + }; + + const result = await attemptsApi.createPR(attempt.id, { + title: prTitle, + body: prBody || null, + target_branch: prBaseBranch || null, + }); + + if (result.success) { + setPrTitle(''); + setPrBody(''); + setPrBaseBranch(''); setCreatingPR(false); modal.hide(); return; } - const ui = mapGhCliErrorToUi(setupResult, fallbackMessage, t); + setCreatingPR(false); - if (ui.variant) { - setGhCliHelp(ui); - setError(null); - return; + const defaultGhCliErrorMessage = + result.message || 'Failed to run GitHub CLI setup.'; + + const showGhCliSetupDialog = async () => { + const setupResult = await GhCliSetupDialog.show({ + attemptId: attempt.id, + }); + + handleGhCliSetupOutcome(setupResult, defaultGhCliErrorMessage); + }; + + if (result.error) { + switch (result.error) { + case GitHubServiceError.GH_CLI_NOT_INSTALLED: { + if (isMacEnvironment) { + await showGhCliSetupDialog(); + } else { + const ui = mapGhCliErrorToUi( + 'SETUP_HELPER_NOT_SUPPORTED', + defaultGhCliErrorMessage, + t + ); + setGhCliHelp(ui.variant ? ui : null); + setError(ui.variant ? null : ui.message); + } + return; + } + case GitHubServiceError.TOKEN_INVALID: { + if (isMacEnvironment) { + await showGhCliSetupDialog(); + } else { + const ui = mapGhCliErrorToUi( + 'SETUP_HELPER_NOT_SUPPORTED', + defaultGhCliErrorMessage, + t + ); + setGhCliHelp(ui.variant ? ui : null); + setError(ui.variant ? null : ui.message); + } + return; + } + case GitHubServiceError.INSUFFICIENT_PERMISSIONS: + setError(t('createPrDialog.errors.insufficientPermissions')); + setGhCliHelp(null); + return; + case GitHubServiceError.REPO_NOT_FOUND_OR_NO_ACCESS: + setError(t('createPrDialog.errors.repoNotFoundOrNoAccess')); + setGhCliHelp(null); + return; + default: + setError( + result.message || t('createPrDialog.errors.failedToCreate') + ); + setGhCliHelp(null); + return; + } } - setGhCliHelp(null); - setError(ui.message); - }; + if (result.message) { + setError(result.message); + setGhCliHelp(null); + } else { + setError(t('createPrDialog.errors.failedToCreate')); + setGhCliHelp(null); + } + }, [ + attempt, + projectId, + prBaseBranch, + prBody, + prTitle, + modal, + isMacEnvironment, + t, + ]); - const result = await attemptsApi.createPR(data.attempt.id, { - title: prTitle, - body: prBody || null, - target_branch: prBaseBranch || null, - }); - - if (result.success) { + const handleCancelCreatePR = useCallback(() => { + modal.hide(); + // Reset form to empty state setPrTitle(''); setPrBody(''); setPrBaseBranch(''); - setCreatingPR(false); - modal.hide(); - return; - } + }, [modal]); - setCreatingPR(false); - - const defaultGhCliErrorMessage = - result.message || 'Failed to run GitHub CLI setup.'; - - const showGhCliSetupDialog = async () => { - const setupResult = (await NiceModal.show(GhCliSetupDialog, { - attemptId: data.attempt.id, - })) as GhCliSetupError | null; - - handleGhCliSetupOutcome(setupResult, defaultGhCliErrorMessage); - }; - - if (result.error) { - switch (result.error) { - case GitHubServiceError.GH_CLI_NOT_INSTALLED: { - if (isMacEnvironment) { - await showGhCliSetupDialog(); - } else { - const ui = mapGhCliErrorToUi( - 'SETUP_HELPER_NOT_SUPPORTED', - defaultGhCliErrorMessage, - t - ); - setGhCliHelp(ui.variant ? ui : null); - setError(ui.variant ? null : ui.message); - } - return; - } - case GitHubServiceError.TOKEN_INVALID: { - if (isMacEnvironment) { - await showGhCliSetupDialog(); - } else { - const ui = mapGhCliErrorToUi( - 'SETUP_HELPER_NOT_SUPPORTED', - defaultGhCliErrorMessage, - t - ); - setGhCliHelp(ui.variant ? ui : null); - setError(ui.variant ? null : ui.message); - } - return; - } - case GitHubServiceError.INSUFFICIENT_PERMISSIONS: - setError(t('createPrDialog.errors.insufficientPermissions')); - setGhCliHelp(null); - return; - case GitHubServiceError.REPO_NOT_FOUND_OR_NO_ACCESS: - setError(t('createPrDialog.errors.repoNotFoundOrNoAccess')); - setGhCliHelp(null); - return; - default: - setError(result.message || t('createPrDialog.errors.failedToCreate')); - setGhCliHelp(null); - return; - } - } - - if (result.message) { - setError(result.message); - setGhCliHelp(null); - } else { - setError(t('createPrDialog.errors.failedToCreate')); - setGhCliHelp(null); - } - }, [data, prBaseBranch, prBody, prTitle, modal, isMacEnvironment]); - - const handleCancelCreatePR = useCallback(() => { - modal.hide(); - // Reset form to empty state - setPrTitle(''); - setPrBody(''); - setPrBaseBranch(''); - }, [modal]); - - // Don't render if no data - if (!data) return null; - - return ( - <> - handleCancelCreatePR()}> - - - {t('createPrDialog.title')} - - {t('createPrDialog.description')} - - - {!isLoaded ? ( -
- -
- ) : ( -
-
- - setPrTitle(e.target.value)} - placeholder={t('createPrDialog.titlePlaceholder')} - /> + return ( + <> + handleCancelCreatePR()} + > + + + {t('createPrDialog.title')} + + {t('createPrDialog.description')} + + + {!isLoaded ? ( +
+
-
- -