Typesafe dialogs (vibe-kanban) (#1304)

* Excellent!  All tasks completed successfully. Let me create a summary document:

##  Typesafe Dialogs Refactoring Complete

### What Was Changed

**1. New Typesafe Modal System** ([lib/modals.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/e9a3-typesafe-dialogs/frontend/src/lib/modals.ts))
- Created `defineModal<ResultType>(component)` to brand dialogs with return types
- Created `showModal(modal, props)` with full type inference for props and return values
- Added typed `hideModal()` and `removeModal()` helpers

**2. Modals Registry** ([components/dialogs/index.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/e9a3-typesafe-dialogs/frontend/src/components/dialogs/index.ts))
- Exported `Modals` object with all 27 dialogs
- Each modal properly typed with its result type
- Autocomplete available via `Modals.*`

**3. ESLint Enforcement** ([frontend/.eslintrc.cjs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/e9a3-typesafe-dialogs/frontend/.eslintrc.cjs))
- Blocks direct `NiceModal.show()` usage
- Blocks `NiceModal.register()` usage
- Restricts `NiceModal` imports to lib/modals.ts, App.tsx, and dialog files

**4. Removed String Registrations** ([main.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/e9a3-typesafe-dialogs/frontend/src/main.tsx))
- Deleted 27 `NiceModal.register('string-id', Component)` calls
- Dialogs now register automatically when used

**5. Migrated All Call Sites** (14+ files)
- Replaced `NiceModal.show('string-id', props)` → `showModal(Modals.X, props)`
- All props and return types now fully typed
- Legacy helper functions maintained for backward compatibility

### Usage Examples

```typescript
// Before (not type-safe):
const result = await NiceModal.show('onboarding');
//  No autocomplete, no type checking, string can be misspelled

// After (fully type-safe):
const result = await showModal(Modals.Onboarding, {});
//  result is typed as OnboardingResult
//  Props are checked at compile time
//  Autocomplete for modal names
```

### Verification

-  **Type check passes**: `pnpm run check`
-  **Lint passes**: 0 errors (113 warnings, none related to this refactor)
-  **ESLint enforces pattern**: Prevents future regressions

The refactoring is complete and all code is now fully typesafe!

* Cleanup script changes for task attempt e9a36404-ba8e-4c84-9cca-1a1b7d68fdf7

* incremental

* ##  Component-Based Registration Complete!

Successfully eliminated the centralized `Modals` registry and migrated to component-based dialog registration.

### What Changed

**1. Each dialog now brands itself** (30 dialog files)
- Added `defineModal<ResultType>()` at the source
- Example: `export const FolderPickerDialog = defineModal<string | null>(FolderPickerDialogImpl)`

**2. Removed centralized bottleneck**
- Deleted `Modals` registry object from [components/dialogs/index.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/e9a3-typesafe-dialogs/frontend/src/components/dialogs/index.ts)
- Removed 100+ lines of duplication
- index.ts now just re-exports for convenience

**3. Fixed circular dependency**
- Removed legacy helpers from [lib/modals.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/e9a3-typesafe-dialogs/frontend/src/lib/modals.ts)
- lib/modals.ts no longer imports from components

**4. Updated all call sites** (24 files)
- Changed from `showModal(Modals.X, props)` → `showModal(XDialog, props)`
- Added direct component imports
- Guided by TypeScript compiler errors

### Benefits Achieved

 **No duplication** - dialogs defined once in their own files
 **No circular deps** - clean import hierarchy
 **Easy to add dialogs** - just brand and use
 **Still fully typesafe** - props and return types inferred
 **Clear imports** - see exactly which dialogs are used
 **Compiler verified** - `pnpm run check` passes
 **Lint clean** - 0 errors

* Cleanup script changes for task attempt e9a36404-ba8e-4c84-9cca-1a1b7d68fdf7

* Refactor dialog calling (vibe-kanban 8586934a)

Instead of `showModal(CreatePRDialog)` it should be `CreatePRDialog.show()`, also we should add the same for `.hide()`.

We should enforce the pattern using eslint, then use the lint errors to guide where to refactor.

* reset merge conflicted dialogs to main

* Fix dialog errors

* fmt
This commit is contained in:
Louis Knight-Webb
2025-11-17 18:23:23 +00:00
committed by GitHub
parent 8fcc6f31b1
commit a2df2334d0
52 changed files with 851 additions and 746 deletions

View File

@@ -34,6 +34,54 @@ module.exports = {
], ],
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/switch-exhaustiveness-check': 'error', '@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 // i18n rule - only active when LINT_I18N=true
'i18next/no-literal-string': i18nCheck 'i18next/no-literal-string': i18nCheck
? [ ? [
@@ -76,5 +124,13 @@ module.exports = {
'@typescript-eslint/switch-exhaustiveness-check': 'off', '@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',
},
},
], ],
}; };

View File

@@ -31,9 +31,11 @@ import { ThemeMode } from 'shared/types';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { Loader } from '@/components/ui/loader'; import { Loader } from '@/components/ui/loader';
import NiceModal from '@ebay/nice-modal-react'; import { DisclaimerDialog } from '@/components/dialogs/global/DisclaimerDialog';
import { OnboardingResult } from './components/dialogs/global/OnboardingDialog'; import { OnboardingDialog } from '@/components/dialogs/global/OnboardingDialog';
import { ReleaseNotesDialog } from '@/components/dialogs/global/ReleaseNotesDialog';
import { ClickedElementsProvider } from './contexts/ClickedElementsProvider'; import { ClickedElementsProvider } from './contexts/ClickedElementsProvider';
import NiceModal from '@ebay/nice-modal-react';
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
@@ -64,17 +66,17 @@ function AppContent() {
const showNextStep = async () => { const showNextStep = async () => {
// 1) Disclaimer - first step // 1) Disclaimer - first step
if (!config.disclaimer_acknowledged) { if (!config.disclaimer_acknowledged) {
await NiceModal.show('disclaimer'); await DisclaimerDialog.show();
if (!cancelled) { if (!cancelled) {
await updateAndSaveConfig({ disclaimer_acknowledged: true }); await updateAndSaveConfig({ disclaimer_acknowledged: true });
} }
await NiceModal.hide('disclaimer'); DisclaimerDialog.hide();
return; return;
} }
// 2) Onboarding - configure executor and editor // 2) Onboarding - configure executor and editor
if (!config.onboarding_acknowledged) { if (!config.onboarding_acknowledged) {
const result: OnboardingResult = await NiceModal.show('onboarding'); const result = await OnboardingDialog.show();
if (!cancelled) { if (!cancelled) {
await updateAndSaveConfig({ await updateAndSaveConfig({
onboarding_acknowledged: true, onboarding_acknowledged: true,
@@ -82,17 +84,17 @@ function AppContent() {
editor: result.editor, editor: result.editor,
}); });
} }
await NiceModal.hide('onboarding'); OnboardingDialog.hide();
return; return;
} }
// 3) Release notes - last step // 3) Release notes - last step
if (config.show_release_notes) { if (config.show_release_notes) {
await NiceModal.show('release-notes'); await ReleaseNotesDialog.show();
if (!cancelled) { if (!cancelled) {
await updateAndSaveConfig({ show_release_notes: false }); await updateAndSaveConfig({ show_release_notes: false });
} }
await NiceModal.hide('release-notes'); ReleaseNotesDialog.hide();
return; return;
} }
}; };

View File

@@ -11,7 +11,9 @@ import {
Settings, Settings,
} from 'lucide-react'; } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; 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 { useOpenInEditor } from '@/hooks/useOpenInEditor';
import { useDiffSummary } from '@/hooks/useDiffSummary'; import { useDiffSummary } from '@/hooks/useDiffSummary';
import { useDevServer } from '@/hooks/useDevServer'; import { useDevServer } from '@/hooks/useDevServer';
@@ -93,7 +95,7 @@ export function NextActionCard({
const handleViewLogs = useCallback(() => { const handleViewLogs = useCallback(() => {
if (attemptId) { if (attemptId) {
NiceModal.show('view-processes', { ViewProcessesDialog.show({
attemptId, attemptId,
initialProcessId: latestDevServerProcess?.id, initialProcessId: latestDevServerProcess?.id,
}); });
@@ -106,14 +108,14 @@ export function NextActionCard({
const handleTryAgain = useCallback(() => { const handleTryAgain = useCallback(() => {
if (!attempt?.task_id) return; if (!attempt?.task_id) return;
NiceModal.show('create-attempt', { CreateAttemptDialog.show({
taskId: attempt.task_id, taskId: attempt.task_id,
}); });
}, [attempt?.task_id]); }, [attempt?.task_id]);
const handleGitActions = useCallback(() => { const handleGitActions = useCallback(() => {
if (!attemptId) return; if (!attemptId) return;
NiceModal.show('git-actions', { GitActionsDialog.show({
attemptId, attemptId,
task, task,
projectId: project?.id, projectId: project?.id,

View File

@@ -21,7 +21,7 @@ import type { DraftResponse, TaskAttempt } from 'shared/types';
import { useAttemptExecution } from '@/hooks/useAttemptExecution'; import { useAttemptExecution } from '@/hooks/useAttemptExecution';
import { useUserSystem } from '@/components/config-provider'; import { useUserSystem } from '@/components/config-provider';
import { useBranchStatus } from '@/hooks/useBranchStatus'; import { useBranchStatus } from '@/hooks/useBranchStatus';
import { showModal } from '@/lib/modals'; import { RestoreLogsDialog } from '@/components/dialogs/tasks/RestoreLogsDialog';
import { import {
shouldShowInLogs, shouldShowInLogs,
isCodingAgent, isCodingAgent,
@@ -191,7 +191,7 @@ export function RetryEditorInline({
// Ask user for confirmation // Ask user for confirmation
let modalResult: RestoreLogsDialogResult | undefined; let modalResult: RestoreLogsDialogResult | undefined;
try { try {
modalResult = await showModal<RestoreLogsDialogResult>('restore-logs', { modalResult = await RestoreLogsDialog.show({
targetSha: before, targetSha: before,
targetSubject, targetSubject,
commitsToReset, commitsToReset,

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react'; import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
import { tagsApi } from '@/lib/api'; import { tagsApi } from '@/lib/api';
import { showTagEdit } from '@/lib/modals'; import { TagEditDialog } from '@/components/dialogs/tasks/TagEditDialog';
import type { Tag } from 'shared/types'; import type { Tag } from 'shared/types';
export function TagManager() { export function TagManager() {
@@ -30,7 +30,7 @@ export function TagManager() {
const handleOpenDialog = useCallback( const handleOpenDialog = useCallback(
async (tag?: Tag) => { async (tag?: Tag) => {
try { try {
const result = await showTagEdit({ const result = await TagEditDialog.show({
tag: tag || null, tag: tag || null,
}); });

View File

@@ -7,6 +7,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
import { attemptsApi } from '@/lib/api'; import { attemptsApi } from '@/lib/api';
import type { GhCliSetupError } from 'shared/types'; import type { GhCliSetupError } from 'shared/types';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
@@ -119,7 +120,7 @@ export const GhCliHelpInstructions = ({
); );
}; };
export const GhCliSetupDialog = NiceModal.create<GhCliSetupDialogProps>( const GhCliSetupDialogImpl = NiceModal.create<GhCliSetupDialogProps>(
({ attemptId }) => { ({ attemptId }) => {
const modal = useModal(); const modal = useModal();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -246,3 +247,8 @@ export const GhCliSetupDialog = NiceModal.create<GhCliSetupDialogProps>(
); );
} }
); );
export const GhCliSetupDialog = defineModal<
GhCliSetupDialogProps,
GhCliSetupError | null
>(GhCliSetupDialogImpl);

View File

@@ -9,8 +9,9 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal, type NoProps } from '@/lib/modals';
const DisclaimerDialog = NiceModal.create(() => { const DisclaimerDialogImpl = NiceModal.create<NoProps>(() => {
const modal = useModal(); const modal = useModal();
const handleAccept = () => { const handleAccept = () => {
@@ -60,4 +61,6 @@ const DisclaimerDialog = NiceModal.create(() => {
); );
}); });
export { DisclaimerDialog }; export const DisclaimerDialog = defineModal<void, 'accepted' | void>(
DisclaimerDialogImpl
);

View File

@@ -16,6 +16,7 @@ import { useAuthStatus } from '@/hooks/auth/useAuthStatus';
import { useUserSystem } from '@/components/config-provider'; import { useUserSystem } from '@/components/config-provider';
import type { ProfileResponse } from 'shared/types'; import type { ProfileResponse } from 'shared/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { defineModal, type NoProps } from '@/lib/modals';
type OAuthProvider = 'github' | 'google'; type OAuthProvider = 'github' | 'google';
@@ -25,7 +26,7 @@ type OAuthState =
| { type: 'success'; profile: ProfileResponse } | { type: 'success'; profile: ProfileResponse }
| { type: 'error'; message: string }; | { type: 'error'; message: string };
const OAuthDialog = NiceModal.create(() => { const OAuthDialogImpl = NiceModal.create<NoProps>(() => {
const modal = useModal(); const modal = useModal();
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const { reloadSystem } = useUserSystem(); const { reloadSystem } = useUserSystem();
@@ -303,4 +304,6 @@ const OAuthDialog = NiceModal.create(() => {
); );
}); });
export { OAuthDialog }; export const OAuthDialog = defineModal<void, ProfileResponse | null>(
OAuthDialogImpl
);

View File

@@ -30,13 +30,14 @@ import { useUserSystem } from '@/components/config-provider';
import { toPrettyCase } from '@/utils/string'; import { toPrettyCase } from '@/utils/string';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal, type NoProps } from '@/lib/modals';
export type OnboardingResult = { export type OnboardingResult = {
profile: ExecutorProfileId; profile: ExecutorProfileId;
editor: EditorConfig; editor: EditorConfig;
}; };
const OnboardingDialog = NiceModal.create(() => { const OnboardingDialogImpl = NiceModal.create<NoProps>(() => {
const modal = useModal(); const modal = useModal();
const { profiles, config } = useUserSystem(); const { profiles, config } = useUserSystem();
@@ -227,4 +228,6 @@ const OnboardingDialog = NiceModal.create(() => {
); );
}); });
export { OnboardingDialog }; export const OnboardingDialog = defineModal<void, OnboardingResult>(
OnboardingDialogImpl
);

View File

@@ -11,10 +11,11 @@ import { AlertCircle, ExternalLink } from 'lucide-react';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { useTheme } from '@/components/theme-provider'; import { useTheme } from '@/components/theme-provider';
import { getActualTheme } from '@/utils/theme'; import { getActualTheme } from '@/utils/theme';
import { defineModal, type NoProps } from '@/lib/modals';
const RELEASE_NOTES_BASE_URL = 'https://vibekanban.com/release-notes'; const RELEASE_NOTES_BASE_URL = 'https://vibekanban.com/release-notes';
export const ReleaseNotesDialog = NiceModal.create(() => { const ReleaseNotesDialogImpl = NiceModal.create<NoProps>(() => {
const modal = useModal(); const modal = useModal();
const [iframeError, setIframeError] = useState(false); const [iframeError, setIframeError] = useState(false);
const { theme } = useTheme(); const { theme } = useTheme();
@@ -98,3 +99,7 @@ export const ReleaseNotesDialog = NiceModal.create(() => {
</Dialog> </Dialog>
); );
}); });
export const ReleaseNotesDialog = defineModal<void, void>(
ReleaseNotesDialogImpl
);

View File

@@ -1,6 +1,9 @@
// Global app dialogs // Global app dialogs
export { DisclaimerDialog } from './global/DisclaimerDialog'; export { DisclaimerDialog } from './global/DisclaimerDialog';
export { OnboardingDialog } from './global/OnboardingDialog'; export {
OnboardingDialog,
type OnboardingResult,
} from './global/OnboardingDialog';
export { ReleaseNotesDialog } from './global/ReleaseNotesDialog'; export { ReleaseNotesDialog } from './global/ReleaseNotesDialog';
export { OAuthDialog } from './global/OAuthDialog'; export { OAuthDialog } from './global/OAuthDialog';
@@ -69,6 +72,10 @@ export {
ViewProcessesDialog, ViewProcessesDialog,
type ViewProcessesDialogProps, type ViewProcessesDialogProps,
} from './tasks/ViewProcessesDialog'; } from './tasks/ViewProcessesDialog';
export {
ViewRelatedTasksDialog,
type ViewRelatedTasksDialogProps,
} from './tasks/ViewRelatedTasksDialog';
export { export {
GitActionsDialog, GitActionsDialog,
type GitActionsDialogProps, type GitActionsDialogProps,
@@ -81,6 +88,14 @@ export {
StopShareTaskDialog, StopShareTaskDialog,
type StopShareTaskDialogProps, type StopShareTaskDialogProps,
} from './tasks/StopShareTaskDialog'; } from './tasks/StopShareTaskDialog';
export {
EditBranchNameDialog,
type EditBranchNameDialogResult,
} from './tasks/EditBranchNameDialog';
export { CreateAttemptDialog } from './tasks/CreateAttemptDialog';
// Auth dialogs
export { GhCliSetupDialog } from './auth/GhCliSetupDialog';
// Settings dialogs // Settings dialogs
export { export {

View File

@@ -14,13 +14,14 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { useOrganizationMutations } from '@/hooks/useOrganizationMutations'; import { useOrganizationMutations } from '@/hooks/useOrganizationMutations';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { defineModal, type NoProps } from '@/lib/modals';
export type CreateOrganizationResult = { export type CreateOrganizationResult = {
action: 'created' | 'canceled'; action: 'created' | 'canceled';
organizationId?: string; organizationId?: string;
}; };
export const CreateOrganizationDialog = NiceModal.create(() => { const CreateOrganizationDialogImpl = NiceModal.create<NoProps>(() => {
const modal = useModal(); const modal = useModal();
const { t } = useTranslation('organization'); const { t } = useTranslation('organization');
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -198,3 +199,8 @@ export const CreateOrganizationDialog = NiceModal.create(() => {
</Dialog> </Dialog>
); );
}); });
export const CreateOrganizationDialog = defineModal<
void,
CreateOrganizationResult
>(CreateOrganizationDialogImpl);

View File

@@ -22,6 +22,7 @@ import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { useOrganizationMutations } from '@/hooks/useOrganizationMutations'; import { useOrganizationMutations } from '@/hooks/useOrganizationMutations';
import { MemberRole } from 'shared/types'; import { MemberRole } from 'shared/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { defineModal } from '@/lib/modals';
export type InviteMemberResult = { export type InviteMemberResult = {
action: 'invited' | 'canceled'; action: 'invited' | 'canceled';
@@ -31,7 +32,7 @@ export interface InviteMemberDialogProps {
organizationId: string; organizationId: string;
} }
export const InviteMemberDialog = NiceModal.create<InviteMemberDialogProps>( const InviteMemberDialogImpl = NiceModal.create<InviteMemberDialogProps>(
(props) => { (props) => {
const modal = useModal(); const modal = useModal();
const { organizationId } = props; const { organizationId } = props;
@@ -191,3 +192,8 @@ export const InviteMemberDialog = NiceModal.create<InviteMemberDialogProps>(
); );
} }
); );
export const InviteMemberDialog = defineModal<
InviteMemberDialogProps,
InviteMemberResult
>(InviteMemberDialogImpl);

View File

@@ -26,6 +26,7 @@ import { useAuth } from '@/hooks/auth/useAuth';
import { LoginRequiredPrompt } from '@/components/dialogs/shared/LoginRequiredPrompt'; import { LoginRequiredPrompt } from '@/components/dialogs/shared/LoginRequiredPrompt';
import type { Project } from 'shared/types'; import type { Project } from 'shared/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { defineModal } from '@/lib/modals';
export type LinkProjectResult = { export type LinkProjectResult = {
action: 'linked' | 'canceled'; action: 'linked' | 'canceled';
@@ -39,7 +40,7 @@ interface LinkProjectDialogProps {
type LinkMode = 'existing' | 'create'; type LinkMode = 'existing' | 'create';
export const LinkProjectDialog = NiceModal.create<LinkProjectDialogProps>( const LinkProjectDialogImpl = NiceModal.create<LinkProjectDialogProps>(
({ projectId, projectName }) => { ({ projectId, projectName }) => {
const modal = useModal(); const modal = useModal();
const { t } = useTranslation('projects'); const { t } = useTranslation('projects');
@@ -341,3 +342,8 @@ export const LinkProjectDialog = NiceModal.create<LinkProjectDialogProps>(
); );
} }
); );
export const LinkProjectDialog = defineModal<
LinkProjectDialogProps,
LinkProjectResult
>(LinkProjectDialogImpl);

View File

@@ -18,12 +18,13 @@ import {
import { EditorType, Project } from 'shared/types'; import { EditorType, Project } from 'shared/types';
import { useOpenProjectInEditor } from '@/hooks/useOpenProjectInEditor'; import { useOpenProjectInEditor } from '@/hooks/useOpenProjectInEditor';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
export interface ProjectEditorSelectionDialogProps { export interface ProjectEditorSelectionDialogProps {
selectedProject: Project | null; selectedProject: Project | null;
} }
export const ProjectEditorSelectionDialog = const ProjectEditorSelectionDialogImpl =
NiceModal.create<ProjectEditorSelectionDialogProps>(({ selectedProject }) => { NiceModal.create<ProjectEditorSelectionDialogProps>(({ selectedProject }) => {
const modal = useModal(); const modal = useModal();
const handleOpenInEditor = useOpenProjectInEditor(selectedProject, () => const handleOpenInEditor = useOpenProjectInEditor(selectedProject, () =>
@@ -89,3 +90,8 @@ export const ProjectEditorSelectionDialog =
</Dialog> </Dialog>
); );
}); });
export const ProjectEditorSelectionDialog = defineModal<
ProjectEditorSelectionDialogProps,
EditorType | null
>(ProjectEditorSelectionDialogImpl);

View File

@@ -12,6 +12,7 @@ import { CreateProject } from 'shared/types';
import { generateProjectNameFromPath } from '@/utils/string'; import { generateProjectNameFromPath } from '@/utils/string';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { useProjectMutations } from '@/hooks/useProjectMutations'; import { useProjectMutations } from '@/hooks/useProjectMutations';
import { defineModal } from '@/lib/modals';
export interface ProjectFormDialogProps { export interface ProjectFormDialogProps {
// No props needed - this is only for creating projects now // No props needed - this is only for creating projects now
@@ -19,148 +20,151 @@ export interface ProjectFormDialogProps {
export type ProjectFormDialogResult = 'saved' | 'canceled'; export type ProjectFormDialogResult = 'saved' | 'canceled';
export const ProjectFormDialog = NiceModal.create<ProjectFormDialogProps>( const ProjectFormDialogImpl = NiceModal.create<ProjectFormDialogProps>(() => {
() => { const modal = useModal();
const modal = useModal(); const [name, setName] = useState('');
const [name, setName] = useState(''); const [gitRepoPath, setGitRepoPath] = useState('');
const [gitRepoPath, setGitRepoPath] = useState(''); const [error, setError] = useState('');
const [error, setError] = useState(''); const [repoMode, setRepoMode] = useState<'existing' | 'new'>('existing');
const [repoMode, setRepoMode] = useState<'existing' | 'new'>('existing'); const [parentPath, setParentPath] = useState('');
const [parentPath, setParentPath] = useState(''); const [folderName, setFolderName] = useState('');
const [folderName, setFolderName] = useState('');
const { createProject } = useProjectMutations({ const { createProject } = useProjectMutations({
onCreateSuccess: () => { onCreateSuccess: () => {
modal.resolve('saved' as ProjectFormDialogResult); 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);
modal.hide(); 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) => { createProject.mutate(createData);
if (!open) { };
handleCancel();
} 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 ( createProject.mutate(createData);
<Dialog open={modal.visible} onOpenChange={handleOpenChange}> };
<DialogContent className="overflow-x-hidden">
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
<DialogDescription>Choose your repository source</DialogDescription>
</DialogHeader>
<div className="mx-auto w-full max-w-2xl overflow-x-hidden px-1"> const handleCancel = () => {
<form onSubmit={handleSubmit} className="space-y-4"> // Reset form
<ProjectFormFields setName('');
isEditing={false} setGitRepoPath('');
repoMode={repoMode} setParentPath('');
setRepoMode={setRepoMode} setFolderName('');
gitRepoPath={gitRepoPath} setError('');
handleGitRepoPathChange={handleGitRepoPathChange}
parentPath={parentPath} modal.resolve('canceled' as ProjectFormDialogResult);
setParentPath={setParentPath} modal.hide();
setFolderName={setFolderName} };
setName={setName}
name={name} const handleOpenChange = (open: boolean) => {
setupScript="" if (!open) {
setSetupScript={() => {}} handleCancel();
devScript="" }
setDevScript={() => {}} };
cleanupScript=""
setCleanupScript={() => {}} return (
copyFiles="" <Dialog open={modal.visible} onOpenChange={handleOpenChange}>
setCopyFiles={() => {}} <DialogContent className="overflow-x-hidden">
error={error} <DialogHeader>
setError={setError} <DialogTitle>Create Project</DialogTitle>
projectId={undefined} <DialogDescription>Choose your repository source</DialogDescription>
onCreateProject={handleDirectCreate} </DialogHeader>
/>
{repoMode === 'new' && ( <div className="mx-auto w-full max-w-2xl overflow-x-hidden px-1">
<Button <form onSubmit={handleSubmit} className="space-y-4">
type="submit" <ProjectFormFields
disabled={createProject.isPending || !folderName.trim()} isEditing={false}
className="w-full" repoMode={repoMode}
> setRepoMode={setRepoMode}
{createProject.isPending ? 'Creating...' : 'Create Project'} gitRepoPath={gitRepoPath}
</Button> handleGitRepoPathChange={handleGitRepoPathChange}
)} parentPath={parentPath}
</form> setParentPath={setParentPath}
</div> setFolderName={setFolderName}
</DialogContent> setName={setName}
</Dialog> name={name}
); setupScript=""
} setSetupScript={() => {}}
); devScript=""
setDevScript={() => {}}
cleanupScript=""
setCleanupScript={() => {}}
copyFiles=""
setCopyFiles={() => {}}
error={error}
setError={setError}
projectId={undefined}
onCreateProject={handleDirectCreate}
/>
{repoMode === 'new' && (
<Button
type="submit"
disabled={createProject.isPending || !folderName.trim()}
className="w-full"
>
{createProject.isPending ? 'Creating...' : 'Create Project'}
</Button>
)}
</form>
</div>
</DialogContent>
</Dialog>
);
});
export const ProjectFormDialog = defineModal<
ProjectFormDialogProps,
ProjectFormDialogResult
>(ProjectFormDialogImpl);

View File

@@ -19,6 +19,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
export interface CreateConfigurationDialogProps { export interface CreateConfigurationDialogProps {
executorType: string; executorType: string;
@@ -31,7 +32,7 @@ export type CreateConfigurationResult = {
cloneFrom?: string | null; cloneFrom?: string | null;
}; };
export const CreateConfigurationDialog = const CreateConfigurationDialogImpl =
NiceModal.create<CreateConfigurationDialogProps>( NiceModal.create<CreateConfigurationDialogProps>(
({ executorType, existingConfigs }) => { ({ executorType, existingConfigs }) => {
const modal = useModal(); const modal = useModal();
@@ -156,3 +157,8 @@ export const CreateConfigurationDialog =
); );
} }
); );
export const CreateConfigurationDialog = defineModal<
CreateConfigurationDialogProps,
CreateConfigurationResult
>(CreateConfigurationDialogImpl);

View File

@@ -11,6 +11,7 @@ import {
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
export interface DeleteConfigurationDialogProps { export interface DeleteConfigurationDialogProps {
configName: string; configName: string;
@@ -19,7 +20,7 @@ export interface DeleteConfigurationDialogProps {
export type DeleteConfigurationResult = 'deleted' | 'canceled'; export type DeleteConfigurationResult = 'deleted' | 'canceled';
export const DeleteConfigurationDialog = const DeleteConfigurationDialogImpl =
NiceModal.create<DeleteConfigurationDialogProps>( NiceModal.create<DeleteConfigurationDialogProps>(
({ configName, executorType }) => { ({ configName, executorType }) => {
const modal = useModal(); const modal = useModal();
@@ -92,3 +93,8 @@ export const DeleteConfigurationDialog =
); );
} }
); );
export const DeleteConfigurationDialog = defineModal<
DeleteConfigurationDialogProps,
DeleteConfigurationResult
>(DeleteConfigurationDialogImpl);

View File

@@ -9,7 +9,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { AlertTriangle, Info, CheckCircle, XCircle } from 'lucide-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 { export interface ConfirmDialogProps {
title: string; title: string;
@@ -20,7 +20,7 @@ export interface ConfirmDialogProps {
icon?: boolean; icon?: boolean;
} }
const ConfirmDialog = NiceModal.create<ConfirmDialogProps>((props) => { const ConfirmDialogImpl = NiceModal.create<ConfirmDialogProps>((props) => {
const modal = useModal(); const modal = useModal();
const { const {
title, title,
@@ -83,4 +83,6 @@ const ConfirmDialog = NiceModal.create<ConfirmDialogProps>((props) => {
); );
}); });
export { ConfirmDialog }; export const ConfirmDialog = defineModal<ConfirmDialogProps, ConfirmResult>(
ConfirmDialogImpl
);

View File

@@ -22,6 +22,7 @@ import {
import { fileSystemApi } from '@/lib/api'; import { fileSystemApi } from '@/lib/api';
import { DirectoryEntry, DirectoryListResponse } from 'shared/types'; import { DirectoryEntry, DirectoryListResponse } from 'shared/types';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
export interface FolderPickerDialogProps { export interface FolderPickerDialogProps {
value?: string; value?: string;
@@ -29,7 +30,7 @@ export interface FolderPickerDialogProps {
description?: string; description?: string;
} }
export const FolderPickerDialog = NiceModal.create<FolderPickerDialogProps>( const FolderPickerDialogImpl = NiceModal.create<FolderPickerDialogProps>(
({ ({
value = '', value = '',
title = 'Select Folder', title = 'Select Folder',
@@ -288,3 +289,8 @@ export const FolderPickerDialog = NiceModal.create<FolderPickerDialogProps>(
); );
} }
); );
export const FolderPickerDialog = defineModal<
FolderPickerDialogProps,
string | null
>(FolderPickerDialogImpl);

View File

@@ -1,8 +1,7 @@
import { useCallback, type ComponentProps } from 'react'; import { useCallback, type ComponentProps } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LogIn, type LucideIcon } from 'lucide-react'; import { LogIn, type LucideIcon } from 'lucide-react';
import NiceModal from '@ebay/nice-modal-react'; import { OAuthDialog } from '@/components/dialogs/global/OAuthDialog';
import { OAuthDialog } from '@/components/dialogs';
import { Alert } from '@/components/ui/alert'; import { Alert } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -38,7 +37,7 @@ export function LoginRequiredPrompt({
onAction(); onAction();
return; return;
} }
void NiceModal.show(OAuthDialog); void OAuthDialog.show();
}, [onAction]); }, [onAction]);
const Icon = icon ?? LogIn; const Icon = icon ?? LogIn;

View File

@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
import BranchSelector from '@/components/tasks/BranchSelector'; import BranchSelector from '@/components/tasks/BranchSelector';
import type { GitBranch } from 'shared/types'; import type { GitBranch } from 'shared/types';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
export interface ChangeTargetBranchDialogProps { export interface ChangeTargetBranchDialogProps {
branches: GitBranch[]; branches: GitBranch[];
@@ -23,7 +24,7 @@ export type ChangeTargetBranchDialogResult = {
branchName?: string; branchName?: string;
}; };
export const ChangeTargetBranchDialog = const ChangeTargetBranchDialogImpl =
NiceModal.create<ChangeTargetBranchDialogProps>( NiceModal.create<ChangeTargetBranchDialogProps>(
({ branches, isChangingTargetBranch: isChangingTargetBranch = false }) => { ({ branches, isChangingTargetBranch: isChangingTargetBranch = false }) => {
const modal = useModal(); const modal = useModal();
@@ -100,3 +101,8 @@ export const ChangeTargetBranchDialog =
); );
} }
); );
export const ChangeTargetBranchDialog = defineModal<
ChangeTargetBranchDialogProps,
ChangeTargetBranchDialogResult
>(ChangeTargetBranchDialogImpl);

View File

@@ -24,13 +24,14 @@ import { useProject } from '@/contexts/project-context';
import { useUserSystem } from '@/components/config-provider'; import { useUserSystem } from '@/components/config-provider';
import { paths } from '@/lib/paths'; import { paths } from '@/lib/paths';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
import type { ExecutorProfileId, BaseCodingAgent } from 'shared/types'; import type { ExecutorProfileId, BaseCodingAgent } from 'shared/types';
export interface CreateAttemptDialogProps { export interface CreateAttemptDialogProps {
taskId: string; taskId: string;
} }
export const CreateAttemptDialog = NiceModal.create<CreateAttemptDialogProps>( const CreateAttemptDialogImpl = NiceModal.create<CreateAttemptDialogProps>(
({ taskId }) => { ({ taskId }) => {
const modal = useModal(); const modal = useModal();
const navigate = useNavigateWithSearch(); const navigate = useNavigateWithSearch();
@@ -135,6 +136,7 @@ export const CreateAttemptDialog = NiceModal.create<CreateAttemptDialogProps>(
profile: effectiveProfile, profile: effectiveProfile,
baseBranch: effectiveBranch, baseBranch: effectiveBranch,
}); });
modal.hide(); modal.hide();
} catch (err) { } catch (err) {
console.error('Failed to create attempt:', err); console.error('Failed to create attempt:', err);
@@ -210,3 +212,7 @@ export const CreateAttemptDialog = NiceModal.create<CreateAttemptDialogProps>(
); );
} }
); );
export const CreateAttemptDialog = defineModal<CreateAttemptDialogProps, void>(
CreateAttemptDialogImpl
);

View File

@@ -37,285 +37,310 @@ import type {
} from '@/components/dialogs/auth/GhCliSetupDialog'; } from '@/components/dialogs/auth/GhCliSetupDialog';
import type { GhCliSetupError } from 'shared/types'; import type { GhCliSetupError } from 'shared/types';
import { useUserSystem } from '@/components/config-provider'; import { useUserSystem } from '@/components/config-provider';
const CreatePrDialog = NiceModal.create(() => { import { defineModal } from '@/lib/modals';
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<string | null>(null);
const [ghCliHelp, setGhCliHelp] = useState<GhCliSupportContent | null>(null);
const [branches, setBranches] = useState<GitBranch[]>([]);
const [branchesLoading, setBranchesLoading] = useState(false);
const getGhCliHelpTitle = (variant: GhCliSupportVariant) => interface CreatePRDialogProps {
variant === 'homebrew' attempt: TaskAttempt;
? 'Homebrew is required for automatic setup' task: TaskWithAttemptStatus;
: 'GitHub CLI needs manual setup'; projectId: string;
}
useEffect(() => { const CreatePRDialogImpl = NiceModal.create<CreatePRDialogProps>(
if (!modal.visible || !data || !isLoaded) { ({ attempt, task, projectId }) => {
return; 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<string | null>(null);
const [ghCliHelp, setGhCliHelp] = useState<GhCliSupportContent | null>(
null
);
const [branches, setBranches] = useState<GitBranch[]>([]);
const [branchesLoading, setBranchesLoading] = useState(false);
setPrTitle(`${data.task.title} (vibe-kanban)`); const getGhCliHelpTitle = (variant: GhCliSupportVariant) =>
setPrBody(data.task.description || ''); variant === 'homebrew'
? 'Homebrew is required for automatic setup'
: 'GitHub CLI needs manual setup';
// Always fetch branches for dropdown population useEffect(() => {
if (data.projectId) { if (!modal.visible || !isLoaded) {
setBranchesLoading(true); return;
projectsApi }
.getBranches(data.projectId)
.then((projectBranches) => {
setBranches(projectBranches);
// Set smart default: task target branch OR current branch setPrTitle(`${task.title} (vibe-kanban)`);
if (data.attempt.target_branch) { setPrBody(task.description || '');
setPrBaseBranch(data.attempt.target_branch);
} else { // Always fetch branches for dropdown population
const currentBranch = projectBranches.find((b) => b.is_current); if (projectId) {
if (currentBranch) { setBranchesLoading(true);
setPrBaseBranch(currentBranch.name); 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)
.catch(console.error) .finally(() => setBranchesLoading(false));
.finally(() => setBranchesLoading(false)); }
}
setError(null); // Reset error when opening setError(null); // Reset error when opening
setGhCliHelp(null); setGhCliHelp(null);
}, [modal.visible, data, isLoaded]); }, [modal.visible, isLoaded, task, attempt, projectId]);
const isMacEnvironment = useMemo( const isMacEnvironment = useMemo(
() => environment?.os_type?.toLowerCase().includes('mac'), () => environment?.os_type?.toLowerCase().includes('mac'),
[environment?.os_type] [environment?.os_type]
); );
const handleConfirmCreatePR = useCallback(async () => { const handleConfirmCreatePR = useCallback(async () => {
if (!data?.projectId || !data?.attempt.id) return; if (!projectId || !attempt.id) return;
setError(null); setError(null);
setGhCliHelp(null); setGhCliHelp(null);
setCreatingPR(true); 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); 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); setCreatingPR(false);
modal.hide(); modal.hide();
return; return;
} }
const ui = mapGhCliErrorToUi(setupResult, fallbackMessage, t); setCreatingPR(false);
if (ui.variant) { const defaultGhCliErrorMessage =
setGhCliHelp(ui); result.message || 'Failed to run GitHub CLI setup.';
setError(null);
return; 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); if (result.message) {
setError(ui.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, { const handleCancelCreatePR = useCallback(() => {
title: prTitle, modal.hide();
body: prBody || null, // Reset form to empty state
target_branch: prBaseBranch || null,
});
if (result.success) {
setPrTitle(''); setPrTitle('');
setPrBody(''); setPrBody('');
setPrBaseBranch(''); setPrBaseBranch('');
setCreatingPR(false); }, [modal]);
modal.hide();
return;
}
setCreatingPR(false); return (
<>
const defaultGhCliErrorMessage = <Dialog
result.message || 'Failed to run GitHub CLI setup.'; open={modal.visible}
onOpenChange={() => handleCancelCreatePR()}
const showGhCliSetupDialog = async () => { >
const setupResult = (await NiceModal.show(GhCliSetupDialog, { <DialogContent className="sm:max-w-[525px]">
attemptId: data.attempt.id, <DialogHeader>
})) as GhCliSetupError | null; <DialogTitle>{t('createPrDialog.title')}</DialogTitle>
<DialogDescription>
handleGhCliSetupOutcome(setupResult, defaultGhCliErrorMessage); {t('createPrDialog.description')}
}; </DialogDescription>
</DialogHeader>
if (result.error) { {!isLoaded ? (
switch (result.error) { <div className="flex justify-center py-8">
case GitHubServiceError.GH_CLI_NOT_INSTALLED: { <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
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 (
<>
<Dialog open={modal.visible} onOpenChange={() => handleCancelCreatePR()}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>{t('createPrDialog.title')}</DialogTitle>
<DialogDescription>
{t('createPrDialog.description')}
</DialogDescription>
</DialogHeader>
{!isLoaded ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="pr-title">
{t('createPrDialog.titleLabel')}
</Label>
<Input
id="pr-title"
value={prTitle}
onChange={(e) => setPrTitle(e.target.value)}
placeholder={t('createPrDialog.titlePlaceholder')}
/>
</div> </div>
<div className="space-y-2"> ) : (
<Label htmlFor="pr-body"> <div className="space-y-4 py-4">
{t('createPrDialog.descriptionLabel')} <div className="space-y-2">
</Label> <Label htmlFor="pr-title">
<Textarea {t('createPrDialog.titleLabel')}
id="pr-body" </Label>
value={prBody} <Input
onChange={(e) => setPrBody(e.target.value)} id="pr-title"
placeholder={t('createPrDialog.descriptionPlaceholder')} value={prTitle}
rows={4} onChange={(e) => setPrTitle(e.target.value)}
/> placeholder={t('createPrDialog.titlePlaceholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pr-body">
{t('createPrDialog.descriptionLabel')}
</Label>
<Textarea
id="pr-body"
value={prBody}
onChange={(e) => setPrBody(e.target.value)}
placeholder={t('createPrDialog.descriptionPlaceholder')}
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pr-base">
{t('createPrDialog.baseBranchLabel')}
</Label>
<BranchSelector
branches={branches}
selectedBranch={prBaseBranch}
onBranchSelect={setPrBaseBranch}
placeholder={
branchesLoading
? t('createPrDialog.loadingBranches')
: t('createPrDialog.selectBaseBranch')
}
className={
branchesLoading ? 'opacity-50 cursor-not-allowed' : ''
}
/>
</div>
{ghCliHelp?.variant && (
<Alert variant="default">
<AlertTitle>
{getGhCliHelpTitle(ghCliHelp.variant)}
</AlertTitle>
<AlertDescription className="space-y-3">
<p>{ghCliHelp.message}</p>
<GhCliHelpInstructions
variant={ghCliHelp.variant}
t={t}
/>
</AlertDescription>
</Alert>
)}
{error && <Alert variant="destructive">{error}</Alert>}
</div> </div>
<div className="space-y-2"> )}
<Label htmlFor="pr-base"> <DialogFooter>
{t('createPrDialog.baseBranchLabel')} <Button variant="outline" onClick={handleCancelCreatePR}>
</Label> {t('common:buttons.cancel')}
<BranchSelector </Button>
branches={branches} <Button
selectedBranch={prBaseBranch} onClick={handleConfirmCreatePR}
onBranchSelect={setPrBaseBranch} disabled={creatingPR || !prTitle.trim()}
placeholder={ className="bg-blue-600 hover:bg-blue-700"
branchesLoading >
? t('createPrDialog.loadingBranches') {creatingPR ? (
: t('createPrDialog.selectBaseBranch') <>
} <Loader2 className="mr-2 h-4 w-4 animate-spin" />
className={ {t('createPrDialog.creating')}
branchesLoading ? 'opacity-50 cursor-not-allowed' : '' </>
} ) : (
/> t('createPrDialog.createButton')
</div> )}
{ghCliHelp?.variant && ( </Button>
<Alert variant="default"> </DialogFooter>
<AlertTitle> </DialogContent>
{getGhCliHelpTitle(ghCliHelp.variant)} </Dialog>
</AlertTitle> </>
<AlertDescription className="space-y-3"> );
<p>{ghCliHelp.message}</p> }
<GhCliHelpInstructions variant={ghCliHelp.variant} t={t} /> );
</AlertDescription>
</Alert>
)}
{error && <Alert variant="destructive">{error}</Alert>}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleCancelCreatePR}>
{t('common:buttons.cancel')}
</Button>
<Button
onClick={handleConfirmCreatePR}
disabled={creatingPR || !prTitle.trim()}
className="bg-blue-600 hover:bg-blue-700"
>
{creatingPR ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('createPrDialog.creating')}
</>
) : (
t('createPrDialog.createButton')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
});
export { CreatePrDialog as CreatePRDialog }; export const CreatePRDialog = defineModal<CreatePRDialogProps, void>(
CreatePRDialogImpl
);

View File

@@ -12,13 +12,14 @@ import { Alert } from '@/components/ui/alert';
import { tasksApi } from '@/lib/api'; import { tasksApi } from '@/lib/api';
import type { TaskWithAttemptStatus } from 'shared/types'; import type { TaskWithAttemptStatus } from 'shared/types';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
export interface DeleteTaskConfirmationDialogProps { export interface DeleteTaskConfirmationDialogProps {
task: TaskWithAttemptStatus; task: TaskWithAttemptStatus;
projectId: string; projectId: string;
} }
const DeleteTaskConfirmationDialog = const DeleteTaskConfirmationDialogImpl =
NiceModal.create<DeleteTaskConfirmationDialogProps>(({ task }) => { NiceModal.create<DeleteTaskConfirmationDialogProps>(({ task }) => {
const modal = useModal(); const modal = useModal();
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
@@ -93,4 +94,7 @@ const DeleteTaskConfirmationDialog =
); );
}); });
export { DeleteTaskConfirmationDialog }; export const DeleteTaskConfirmationDialog = defineModal<
DeleteTaskConfirmationDialogProps,
void
>(DeleteTaskConfirmationDialogImpl);

View File

@@ -11,6 +11,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
import { useRenameBranch } from '@/hooks/useRenameBranch'; import { useRenameBranch } from '@/hooks/useRenameBranch';
export interface EditBranchNameDialogProps { export interface EditBranchNameDialogProps {
@@ -23,7 +24,7 @@ export type EditBranchNameDialogResult = {
branchName?: string; branchName?: string;
}; };
export const EditBranchNameDialog = NiceModal.create<EditBranchNameDialogProps>( const EditBranchNameDialogImpl = NiceModal.create<EditBranchNameDialogProps>(
({ attemptId, currentBranchName }) => { ({ attemptId, currentBranchName }) => {
const modal = useModal(); const modal = useModal();
const { t } = useTranslation(['tasks', 'common']); const { t } = useTranslation(['tasks', 'common']);
@@ -136,3 +137,8 @@ export const EditBranchNameDialog = NiceModal.create<EditBranchNameDialogProps>(
); );
} }
); );
export const EditBranchNameDialog = defineModal<
EditBranchNameDialogProps,
EditBranchNameDialogResult
>(EditBranchNameDialogImpl);

View File

@@ -18,77 +18,82 @@ import {
import { EditorType } from 'shared/types'; import { EditorType } from 'shared/types';
import { useOpenInEditor } from '@/hooks/useOpenInEditor'; import { useOpenInEditor } from '@/hooks/useOpenInEditor';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
export interface EditorSelectionDialogProps { export interface EditorSelectionDialogProps {
selectedAttemptId?: string; selectedAttemptId?: string;
filePath?: string; filePath?: string;
} }
export const EditorSelectionDialog = const EditorSelectionDialogImpl = NiceModal.create<EditorSelectionDialogProps>(
NiceModal.create<EditorSelectionDialogProps>( ({ selectedAttemptId, filePath }) => {
({ selectedAttemptId, filePath }) => { const modal = useModal();
const modal = useModal(); const handleOpenInEditor = useOpenInEditor(selectedAttemptId, () =>
const handleOpenInEditor = useOpenInEditor(selectedAttemptId, () => modal.hide()
modal.hide() );
); const [selectedEditor, setSelectedEditor] = useState<EditorType>(
const [selectedEditor, setSelectedEditor] = useState<EditorType>( EditorType.VS_CODE
EditorType.VS_CODE );
);
const handleConfirm = () => { const handleConfirm = () => {
handleOpenInEditor({ editorType: selectedEditor, filePath }); handleOpenInEditor({ editorType: selectedEditor, filePath });
modal.resolve(selectedEditor); modal.resolve(selectedEditor);
modal.hide(); modal.hide();
}; };
const handleCancel = () => { const handleCancel = () => {
modal.resolve(null); modal.resolve(null);
modal.hide(); modal.hide();
}; };
return ( return (
<Dialog <Dialog
open={modal.visible} open={modal.visible}
onOpenChange={(open) => !open && handleCancel()} onOpenChange={(open) => !open && handleCancel()}
> >
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Choose Editor</DialogTitle> <DialogTitle>Choose Editor</DialogTitle>
<DialogDescription> <DialogDescription>
The default editor failed to open. Please select an alternative The default editor failed to open. Please select an alternative
editor to open the task worktree. editor to open the task worktree.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Editor</label> <label className="text-sm font-medium">Editor</label>
<Select <Select
value={selectedEditor} value={selectedEditor}
onValueChange={(value) => onValueChange={(value) =>
setSelectedEditor(value as EditorType) setSelectedEditor(value as EditorType)
} }
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.values(EditorType).map((editor) => ( {Object.values(EditorType).map((editor) => (
<SelectItem key={editor} value={editor}> <SelectItem key={editor} value={editor}>
{editor} {editor}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
</div> </div>
<DialogFooter> </div>
<Button variant="outline" onClick={handleCancel}> <DialogFooter>
Cancel <Button variant="outline" onClick={handleCancel}>
</Button> Cancel
<Button onClick={handleConfirm}>Open Editor</Button> </Button>
</DialogFooter> <Button onClick={handleConfirm}>Open Editor</Button>
</DialogContent> </DialogFooter>
</Dialog> </DialogContent>
); </Dialog>
} );
); }
);
export const EditorSelectionDialog = defineModal<
EditorSelectionDialogProps,
EditorType | null
>(EditorSelectionDialogImpl);

View File

@@ -24,6 +24,7 @@ import type {
TaskWithAttemptStatus, TaskWithAttemptStatus,
} from 'shared/types'; } from 'shared/types';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
export interface GitActionsDialogProps { export interface GitActionsDialogProps {
attemptId: string; attemptId: string;
@@ -97,7 +98,7 @@ function GitActionsDialogContent({
); );
} }
export const GitActionsDialog = NiceModal.create<GitActionsDialogProps>( const GitActionsDialogImpl = NiceModal.create<GitActionsDialogProps>(
({ attemptId, task, projectId: providedProjectId }) => { ({ attemptId, task, projectId: providedProjectId }) => {
const modal = useModal(); const modal = useModal();
const { t } = useTranslation('tasks'); const { t } = useTranslation('tasks');
@@ -159,3 +160,7 @@ export const GitActionsDialog = NiceModal.create<GitActionsDialogProps>(
); );
} }
); );
export const GitActionsDialog = defineModal<GitActionsDialogProps, void>(
GitActionsDialogImpl
);

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -43,7 +44,7 @@ const buildMemberLabel = (member: OrganizationMemberWithProfile): string => {
return member.user_id; return member.user_id;
}; };
export const ReassignDialog = NiceModal.create<ReassignDialogProps>( const ReassignDialogImpl = NiceModal.create<ReassignDialogProps>(
({ sharedTask }) => { ({ sharedTask }) => {
const modal = useModal(); const modal = useModal();
const { userId } = useAuth(); const { userId } = useAuth();
@@ -238,3 +239,8 @@ export const ReassignDialog = NiceModal.create<ReassignDialogProps>(
); );
} }
); );
export const ReassignDialog = defineModal<
ReassignDialogProps,
SharedTaskRecord | null
>(ReassignDialogImpl);

View File

@@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
import BranchSelector from '@/components/tasks/BranchSelector'; import BranchSelector from '@/components/tasks/BranchSelector';
import type { GitBranch } from 'shared/types'; import type { GitBranch } from 'shared/types';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
export interface RebaseDialogProps { export interface RebaseDialogProps {
branches: GitBranch[]; branches: GitBranch[];
@@ -27,7 +28,7 @@ export type RebaseDialogResult = {
upstreamBranch?: string; upstreamBranch?: string;
}; };
export const RebaseDialog = NiceModal.create<RebaseDialogProps>( const RebaseDialogImpl = NiceModal.create<RebaseDialogProps>(
({ ({
branches, branches,
isRebasing = false, isRebasing = false,
@@ -155,3 +156,7 @@ export const RebaseDialog = NiceModal.create<RebaseDialogProps>(
); );
} }
); );
export const RebaseDialog = defineModal<RebaseDialogProps, RebaseDialogResult>(
RebaseDialogImpl
);

View File

@@ -10,6 +10,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { AlertTriangle, GitCommit } from 'lucide-react'; import { AlertTriangle, GitCommit } from 'lucide-react';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
export interface RestoreLogsDialogProps { export interface RestoreLogsDialogProps {
targetSha: string | null; targetSha: string | null;
@@ -35,7 +36,7 @@ export type RestoreLogsDialogResult = {
forceWhenDirty?: boolean; forceWhenDirty?: boolean;
}; };
export const RestoreLogsDialog = NiceModal.create<RestoreLogsDialogProps>( const RestoreLogsDialogImpl = NiceModal.create<RestoreLogsDialogProps>(
({ ({
targetSha, targetSha,
targetSubject, targetSubject,
@@ -383,3 +384,8 @@ export const RestoreLogsDialog = NiceModal.create<RestoreLogsDialogProps>(
); );
} }
); );
export const RestoreLogsDialog = defineModal<
RestoreLogsDialogProps,
RestoreLogsDialogResult
>(RestoreLogsDialogImpl);

View File

@@ -10,12 +10,14 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
import { OAuthDialog } from '@/components/dialogs/global/OAuthDialog';
import { LinkProjectDialog } from '@/components/dialogs/projects/LinkProjectDialog';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useUserSystem } from '@/components/config-provider'; import { useUserSystem } from '@/components/config-provider';
import { Link as LinkIcon, Loader2 } from 'lucide-react'; import { Link as LinkIcon, Loader2 } from 'lucide-react';
import type { TaskWithAttemptStatus } from 'shared/types'; import type { TaskWithAttemptStatus } from 'shared/types';
import { LoginRequiredPrompt } from '@/components/dialogs/shared/LoginRequiredPrompt'; import { LoginRequiredPrompt } from '@/components/dialogs/shared/LoginRequiredPrompt';
import { LinkProjectDialog } from '@/components/dialogs/projects/LinkProjectDialog';
import { useAuth } from '@/hooks'; import { useAuth } from '@/hooks';
import { useProject } from '@/contexts/project-context'; import { useProject } from '@/contexts/project-context';
import { useTaskMutations } from '@/hooks/useTaskMutations'; import { useTaskMutations } from '@/hooks/useTaskMutations';
@@ -24,7 +26,7 @@ export interface ShareDialogProps {
task: TaskWithAttemptStatus; task: TaskWithAttemptStatus;
} }
const ShareDialog = NiceModal.create<ShareDialogProps>(({ task }) => { const ShareDialogImpl = NiceModal.create<ShareDialogProps>(({ task }) => {
const modal = useModal(); const modal = useModal();
const { t } = useTranslation('tasks'); const { t } = useTranslation('tasks');
const { loading: systemLoading } = useUserSystem(); const { loading: systemLoading } = useUserSystem();
@@ -67,7 +69,7 @@ const ShareDialog = NiceModal.create<ShareDialogProps>(({ task }) => {
modal.hide(); modal.hide();
} catch (err) { } catch (err) {
if (getStatus(err) === 401) { if (getStatus(err) === 401) {
void NiceModal.show('oauth'); void OAuthDialog.show();
return; return;
} }
setShareError(getReadableError(err)); setShareError(getReadableError(err));
@@ -77,7 +79,7 @@ const ShareDialog = NiceModal.create<ShareDialogProps>(({ task }) => {
const handleLinkProject = () => { const handleLinkProject = () => {
if (!project) return; if (!project) return;
void NiceModal.show(LinkProjectDialog, { void LinkProjectDialog.show({
projectId: project.id, projectId: project.id,
projectName: project.name, projectName: project.name,
}); });
@@ -169,4 +171,6 @@ const ShareDialog = NiceModal.create<ShareDialogProps>(({ task }) => {
); );
}); });
export { ShareDialog }; export const ShareDialog = defineModal<ShareDialogProps, boolean>(
ShareDialogImpl
);

View File

@@ -10,6 +10,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Alert } from '@/components/ui/alert'; import { Alert } from '@/components/ui/alert';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { SharedTaskRecord } from '@/hooks/useProjectTasks'; import type { SharedTaskRecord } from '@/hooks/useProjectTasks';
import { useTaskMutations } from '@/hooks/useTaskMutations'; import { useTaskMutations } from '@/hooks/useTaskMutations';
@@ -19,7 +20,7 @@ export interface StopShareTaskDialogProps {
sharedTask: SharedTaskRecord; sharedTask: SharedTaskRecord;
} }
const StopShareTaskDialog = NiceModal.create<StopShareTaskDialogProps>( const StopShareTaskDialogImpl = NiceModal.create<StopShareTaskDialogProps>(
({ sharedTask }) => { ({ sharedTask }) => {
const modal = useModal(); const modal = useModal();
const { t } = useTranslation('tasks'); const { t } = useTranslation('tasks');
@@ -130,4 +131,6 @@ const StopShareTaskDialog = NiceModal.create<StopShareTaskDialogProps>(
} }
); );
export { StopShareTaskDialog }; export const StopShareTaskDialog = defineModal<StopShareTaskDialogProps, void>(
StopShareTaskDialogImpl
);

View File

@@ -16,6 +16,7 @@ import { Loader2 } from 'lucide-react';
import { tagsApi } from '@/lib/api'; import { tagsApi } from '@/lib/api';
import type { Tag, CreateTag, UpdateTag } from 'shared/types'; import type { Tag, CreateTag, UpdateTag } from 'shared/types';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
export interface TagEditDialogProps { export interface TagEditDialogProps {
tag?: Tag | null; // null for create mode tag?: Tag | null; // null for create mode
@@ -23,7 +24,7 @@ export interface TagEditDialogProps {
export type TagEditResult = 'saved' | 'canceled'; export type TagEditResult = 'saved' | 'canceled';
export const TagEditDialog = NiceModal.create<TagEditDialogProps>(({ tag }) => { const TagEditDialogImpl = NiceModal.create<TagEditDialogProps>(({ tag }) => {
const modal = useModal(); const modal = useModal();
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -201,3 +202,7 @@ export const TagEditDialog = NiceModal.create<TagEditDialogProps>(({ tag }) => {
</Dialog> </Dialog>
); );
}); });
export const TagEditDialog = defineModal<TagEditDialogProps, TagEditResult>(
TagEditDialogImpl
);

View File

@@ -1,6 +1,7 @@
import { useEffect, useCallback, useRef, useState, useMemo } from 'react'; import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { useForm, useStore } from '@tanstack/react-form'; import { useForm, useStore } from '@tanstack/react-form';
import { Image as ImageIcon } from 'lucide-react'; import { Image as ImageIcon } from 'lucide-react';
@@ -76,7 +77,7 @@ type TaskFormValues = {
autoStart: boolean; autoStart: boolean;
}; };
export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>((props) => { const TaskFormDialogImpl = NiceModal.create<TaskFormDialogProps>((props) => {
const { mode, projectId } = props; const { mode, projectId } = props;
const editMode = mode === 'edit'; const editMode = mode === 'edit';
const modal = useModal(); const modal = useModal();
@@ -622,3 +623,7 @@ export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>((props) => {
</> </>
); );
}); });
export const TaskFormDialog = defineModal<TaskFormDialogProps, void>(
TaskFormDialogImpl
);

View File

@@ -1,4 +1,5 @@
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Dialog, Dialog,
@@ -14,7 +15,7 @@ export interface ViewProcessesDialogProps {
initialProcessId?: string | null; initialProcessId?: string | null;
} }
export const ViewProcessesDialog = NiceModal.create<ViewProcessesDialogProps>( const ViewProcessesDialogImpl = NiceModal.create<ViewProcessesDialogProps>(
({ attemptId, initialProcessId }) => { ({ attemptId, initialProcessId }) => {
const { t } = useTranslation('tasks'); const { t } = useTranslation('tasks');
const modal = useModal(); const modal = useModal();
@@ -53,3 +54,7 @@ export const ViewProcessesDialog = NiceModal.create<ViewProcessesDialogProps>(
); );
} }
); );
export const ViewProcessesDialog = defineModal<ViewProcessesDialogProps, void>(
ViewProcessesDialogImpl
);

View File

@@ -1,4 +1,5 @@
import NiceModal, { useModal } from '@ebay/nice-modal-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Dialog, Dialog,
@@ -20,7 +21,7 @@ export interface ViewRelatedTasksDialogProps {
onNavigateToTask?: (taskId: string) => void; onNavigateToTask?: (taskId: string) => void;
} }
export const ViewRelatedTasksDialog = const ViewRelatedTasksDialogImpl =
NiceModal.create<ViewRelatedTasksDialogProps>( NiceModal.create<ViewRelatedTasksDialogProps>(
({ attemptId, projectId, attempt, onNavigateToTask }) => { ({ attemptId, projectId, attempt, onNavigateToTask }) => {
const modal = useModal(); const modal = useModal();
@@ -166,3 +167,8 @@ export const ViewRelatedTasksDialog =
); );
} }
); );
export const ViewRelatedTasksDialog = defineModal<
ViewRelatedTasksDialogProps,
void
>(ViewRelatedTasksDialogImpl);

View File

@@ -36,8 +36,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import NiceModal from '@ebay/nice-modal-react'; import { OAuthDialog } from '@/components/dialogs/global/OAuthDialog';
import { OAuthDialog } from '@/components/dialogs';
import { useUserSystem } from '@/components/config-provider'; import { useUserSystem } from '@/components/config-provider';
import { oauthApi } from '@/lib/api'; import { oauthApi } from '@/lib/api';
@@ -117,7 +116,7 @@ export function Navbar() {
}; };
const handleOpenOAuth = async () => { const handleOpenOAuth = async () => {
const profile = await NiceModal.show(OAuthDialog); const profile = await OAuthDialog.show();
if (profile) { if (profile) {
await reloadSystem(); await reloadSystem();
} }

View File

@@ -8,7 +8,7 @@ import type { TaskWithAttemptStatus, TaskAttempt } from 'shared/types';
import { NewCardContent } from '../ui/new-card'; import { NewCardContent } from '../ui/new-card';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { PlusIcon } from 'lucide-react'; import { PlusIcon } from 'lucide-react';
import NiceModal from '@ebay/nice-modal-react'; import { CreateAttemptDialog } from '@/components/dialogs/tasks/CreateAttemptDialog';
import MarkdownRenderer from '@/components/ui/markdown-renderer'; import MarkdownRenderer from '@/components/ui/markdown-renderer';
import { DataTable, type ColumnDef } from '@/components/ui/table'; import { DataTable, type ColumnDef } from '@/components/ui/table';
@@ -155,7 +155,7 @@ const TaskPanel = ({ task }: TaskPanelProps) => {
<Button <Button
variant="icon" variant="icon"
onClick={() => onClick={() =>
NiceModal.show('create-attempt', { CreateAttemptDialog.show({
taskId: task.id, taskId: task.id,
}) })
} }

View File

@@ -26,7 +26,7 @@ import { useEffect, useRef } from 'react';
import { useOpenProjectInEditor } from '@/hooks/useOpenProjectInEditor'; import { useOpenProjectInEditor } from '@/hooks/useOpenProjectInEditor';
import { useNavigateWithSearch } from '@/hooks'; import { useNavigateWithSearch } from '@/hooks';
import { projectsApi } from '@/lib/api'; import { projectsApi } from '@/lib/api';
import { showLinkProject } from '@/lib/modals'; import { LinkProjectDialog } from '@/components/dialogs/projects/LinkProjectDialog';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useProjectMutations } from '@/hooks/useProjectMutations'; import { useProjectMutations } from '@/hooks/useProjectMutations';
@@ -94,7 +94,7 @@ function ProjectCard({
const handleLinkProject = async () => { const handleLinkProject = async () => {
try { try {
await showLinkProject({ await LinkProjectDialog.show({
projectId: project.id, projectId: project.id,
projectName: project.name, projectName: project.name,
}); });

View File

@@ -15,7 +15,7 @@ import { useScriptPlaceholders } from '@/hooks/useScriptPlaceholders';
import { CopyFilesField } from './copy-files-field'; import { CopyFilesField } from './copy-files-field';
// Removed collapsible sections for simplicity; show fields always in edit mode // Removed collapsible sections for simplicity; show fields always in edit mode
import { fileSystemApi } from '@/lib/api'; import { fileSystemApi } from '@/lib/api';
import { showFolderPicker } from '@/lib/modals'; import { FolderPickerDialog } from '@/components/dialogs/shared/FolderPickerDialog';
import { DirectoryEntry } from 'shared/types'; import { DirectoryEntry } from 'shared/types';
import { generateProjectNameFromPath } from '@/utils/string'; import { generateProjectNameFromPath } from '@/utils/string';
@@ -244,7 +244,7 @@ export function ProjectFormFields({
className="p-4 border border-dashed cursor-pointer hover:shadow-md transition-shadow rounded-lg bg-card" className="p-4 border border-dashed cursor-pointer hover:shadow-md transition-shadow rounded-lg bg-card"
onClick={async () => { onClick={async () => {
setError(''); setError('');
const selectedPath = await showFolderPicker({ const selectedPath = await FolderPickerDialog.show({
title: 'Select Git Repository', title: 'Select Git Repository',
description: 'Choose an existing git repository', description: 'Choose an existing git repository',
}); });
@@ -341,7 +341,7 @@ export function ProjectFormFields({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={async () => { onClick={async () => {
const selectedPath = await showFolderPicker({ const selectedPath = await FolderPickerDialog.show({
title: 'Select Parent Directory', title: 'Select Parent Directory',
description: 'Choose where to create the new repository', description: 'Choose where to create the new repository',
value: parentPath, value: parentPath,
@@ -381,7 +381,7 @@ export function ProjectFormFields({
type="button" type="button"
variant="outline" variant="outline"
onClick={async () => { onClick={async () => {
const selectedPath = await showFolderPicker({ const selectedPath = await FolderPickerDialog.show({
title: 'Select Git Repository', title: 'Select Git Repository',
description: 'Choose an existing git repository', description: 'Choose an existing git repository',
value: gitRepoPath, value: gitRepoPath,

View File

@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Project } from 'shared/types'; import { Project } from 'shared/types';
import { showProjectForm } from '@/lib/modals'; import { ProjectFormDialog } from '@/components/dialogs/projects/ProjectFormDialog';
import { projectsApi } from '@/lib/api'; import { projectsApi } from '@/lib/api';
import { AlertCircle, Loader2, Plus } from 'lucide-react'; import { AlertCircle, Loader2, Plus } from 'lucide-react';
import ProjectCard from '@/components/projects/ProjectCard.tsx'; import ProjectCard from '@/components/projects/ProjectCard.tsx';
@@ -37,7 +37,7 @@ export function ProjectList() {
const handleCreateProject = async () => { const handleCreateProject = async () => {
try { try {
const result = await showProjectForm(); const result = await ProjectFormDialog.show({});
if (result === 'saved') { if (result === 'saved') {
fetchProjects(); fetchProjects();
} }

View File

@@ -22,8 +22,9 @@ import type {
TaskAttempt, TaskAttempt,
TaskWithAttemptStatus, TaskWithAttemptStatus,
} from 'shared/types'; } from 'shared/types';
import NiceModal from '@ebay/nice-modal-react'; import { ChangeTargetBranchDialog } from '@/components/dialogs/tasks/ChangeTargetBranchDialog';
import { showModal } from '@/lib/modals'; import { RebaseDialog } from '@/components/dialogs/tasks/RebaseDialog';
import { CreatePRDialog } from '@/components/dialogs/tasks/CreatePRDialog';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useGitOperations } from '@/hooks/useGitOperations'; import { useGitOperations } from '@/hooks/useGitOperations';
@@ -75,10 +76,7 @@ function GitOperations({
const handleChangeTargetBranchDialogOpen = async () => { const handleChangeTargetBranchDialogOpen = async () => {
try { try {
const result = await showModal<{ const result = await ChangeTargetBranchDialog.show({
action: 'confirmed' | 'canceled';
branchName: string;
}>('change-target-branch-dialog', {
branches, branches,
isChangingTargetBranch: isChangingTargetBranch, isChangingTargetBranch: isChangingTargetBranch,
}); });
@@ -194,11 +192,7 @@ function GitOperations({
const handleRebaseDialogOpen = async () => { const handleRebaseDialogOpen = async () => {
try { try {
const defaultTargetBranch = selectedAttempt.target_branch; const defaultTargetBranch = selectedAttempt.target_branch;
const result = await showModal<{ const result = await RebaseDialog.show({
action: 'confirmed' | 'canceled';
branchName?: string;
upstreamBranch?: string;
}>('rebase-dialog', {
branches, branches,
isRebasing: rebasing, isRebasing: rebasing,
initialTargetBranch: defaultTargetBranch, initialTargetBranch: defaultTargetBranch,
@@ -226,7 +220,7 @@ function GitOperations({
return; return;
} }
NiceModal.show('create-pr', { CreatePRDialog.show({
attempt: selectedAttempt, attempt: selectedAttempt,
task, task,
projectId, projectId,

View File

@@ -11,10 +11,18 @@ import {
import { MoreHorizontal } from 'lucide-react'; import { MoreHorizontal } from 'lucide-react';
import type { TaskWithAttemptStatus, TaskAttempt } from 'shared/types'; import type { TaskWithAttemptStatus, TaskAttempt } from 'shared/types';
import { useOpenInEditor } from '@/hooks/useOpenInEditor'; import { useOpenInEditor } from '@/hooks/useOpenInEditor';
import NiceModal from '@ebay/nice-modal-react'; import { DeleteTaskConfirmationDialog } from '@/components/dialogs/tasks/DeleteTaskConfirmationDialog';
import { ViewProcessesDialog } from '@/components/dialogs/tasks/ViewProcessesDialog';
import { ViewRelatedTasksDialog } from '@/components/dialogs/tasks/ViewRelatedTasksDialog';
import { CreateAttemptDialog } from '@/components/dialogs/tasks/CreateAttemptDialog';
import { GitActionsDialog } from '@/components/dialogs/tasks/GitActionsDialog';
import { EditBranchNameDialog } from '@/components/dialogs/tasks/EditBranchNameDialog';
import { ShareDialog } from '@/components/dialogs/tasks/ShareDialog';
import { ReassignDialog } from '@/components/dialogs/tasks/ReassignDialog';
import { StopShareTaskDialog } from '@/components/dialogs/tasks/StopShareTaskDialog';
import { useProject } from '@/contexts/project-context'; import { useProject } from '@/contexts/project-context';
import { openTaskForm } from '@/lib/openTaskForm'; import { openTaskForm } from '@/lib/openTaskForm';
import { ViewRelatedTasksDialog } from '@/components/dialogs/tasks/ViewRelatedTasksDialog';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { SharedTaskRecord } from '@/hooks/useProjectTasks'; import type { SharedTaskRecord } from '@/hooks/useProjectTasks';
import { useAuth } from '@/hooks'; import { useAuth } from '@/hooks';
@@ -56,7 +64,7 @@ export function ActionsDropdown({
e.stopPropagation(); e.stopPropagation();
if (!projectId || !task) return; if (!projectId || !task) return;
try { try {
await NiceModal.show('delete-task-confirmation', { await DeleteTaskConfirmationDialog.show({
task, task,
projectId, projectId,
}); });
@@ -74,13 +82,13 @@ export function ActionsDropdown({
const handleViewProcesses = (e: React.MouseEvent) => { const handleViewProcesses = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!attempt?.id) return; if (!attempt?.id) return;
NiceModal.show('view-processes', { attemptId: attempt.id }); ViewProcessesDialog.show({ attemptId: attempt.id });
}; };
const handleViewRelatedTasks = (e: React.MouseEvent) => { const handleViewRelatedTasks = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!attempt?.id || !projectId) return; if (!attempt?.id || !projectId) return;
NiceModal.show(ViewRelatedTasksDialog, { ViewRelatedTasksDialog.show({
attemptId: attempt.id, attemptId: attempt.id,
projectId, projectId,
attempt, attempt,
@@ -95,7 +103,7 @@ export function ActionsDropdown({
const handleCreateNewAttempt = (e: React.MouseEvent) => { const handleCreateNewAttempt = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!task?.id) return; if (!task?.id) return;
NiceModal.show('create-attempt', { CreateAttemptDialog.show({
taskId: task.id, taskId: task.id,
}); });
}; };
@@ -116,7 +124,7 @@ export function ActionsDropdown({
const handleGitActions = (e: React.MouseEvent) => { const handleGitActions = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!attempt?.id || !task) return; if (!attempt?.id || !task) return;
NiceModal.show('git-actions', { GitActionsDialog.show({
attemptId: attempt.id, attemptId: attempt.id,
task, task,
projectId, projectId,
@@ -126,7 +134,7 @@ export function ActionsDropdown({
const handleEditBranchName = (e: React.MouseEvent) => { const handleEditBranchName = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!attempt?.id) return; if (!attempt?.id) return;
NiceModal.show('edit-branch-name-dialog', { EditBranchNameDialog.show({
attemptId: attempt.id, attemptId: attempt.id,
currentBranchName: attempt.branch, currentBranchName: attempt.branch,
}); });
@@ -134,19 +142,19 @@ export function ActionsDropdown({
const handleShare = (e: React.MouseEvent) => { const handleShare = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!task || isShared) return; if (!task || isShared) return;
NiceModal.show('share-task', { task }); ShareDialog.show({ task });
}; };
const handleReassign = (e: React.MouseEvent) => { const handleReassign = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!sharedTask) return; if (!sharedTask) return;
NiceModal.show('reassign-shared-task', { sharedTask }); ReassignDialog.show({ sharedTask });
}; };
const handleStopShare = (e: React.MouseEvent) => { const handleStopShare = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!sharedTask) return; if (!sharedTask) return;
NiceModal.show('stop-share-shared-task', { sharedTask }); StopShareTaskDialog.show({ sharedTask });
}; };
const canReassign = const canReassign =

View File

@@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { attemptsApi } from '@/lib/api'; import { attemptsApi } from '@/lib/api';
import NiceModal from '@ebay/nice-modal-react'; import { EditorSelectionDialog } from '@/components/dialogs/tasks/EditorSelectionDialog';
import type { EditorType } from 'shared/types'; import type { EditorType } from 'shared/types';
type OpenEditorOptions = { type OpenEditorOptions = {
@@ -35,7 +35,7 @@ export function useOpenInEditor(
if (onShowEditorDialog) { if (onShowEditorDialog) {
onShowEditorDialog(); onShowEditorDialog();
} else { } else {
NiceModal.show('editor-selection', { EditorSelectionDialog.show({
selectedAttemptId: attemptId, selectedAttemptId: attemptId,
filePath, filePath,
}); });

View File

@@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { projectsApi } from '@/lib/api'; import { projectsApi } from '@/lib/api';
import NiceModal from '@ebay/nice-modal-react'; import { ProjectEditorSelectionDialog } from '@/components/dialogs/projects/ProjectEditorSelectionDialog';
import type { EditorType, Project } from 'shared/types'; import type { EditorType, Project } from 'shared/types';
export function useOpenProjectInEditor( export function useOpenProjectInEditor(
@@ -24,7 +24,7 @@ export function useOpenProjectInEditor(
if (onShowEditorDialog) { if (onShowEditorDialog) {
onShowEditorDialog(); onShowEditorDialog();
} else { } else {
NiceModal.show('project-editor-selection', { ProjectEditorSelectionDialog.show({
selectedProject: project, selectedProject: project,
}); });
} }

View File

@@ -1,110 +1,41 @@
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import type { import type React from 'react';
FolderPickerDialogProps, import type { NiceModalHocProps } from '@ebay/nice-modal-react';
TagEditDialogProps,
TagEditResult,
ProjectFormDialogProps,
ProjectFormDialogResult,
LinkProjectResult,
} from '@/components/dialogs';
/** // Use this instead of {} to avoid ban-types
* Typed wrapper around NiceModal.show with better TypeScript support export type NoProps = Record<string, never>;
* @param modal - Modal ID (string) or component reference
* @param props - Props to pass to the modal // Map P for component props: void -> NoProps; otherwise P
* @returns Promise that resolves with the modal's result type ComponentProps<P> = [P] extends [void] ? NoProps : P;
*/
export function showModal<T = void>( // Map P for .show() args: void -> []; otherwise [props: P]
modal: string, type ShowArgs<P> = [P] extends [void] ? [] : [props: P];
props: Record<string, unknown> = {}
): Promise<T> { // Modalized component with static show/hide/remove methods
return NiceModal.show<T>(modal, props) as Promise<T>; export type Modalized<P, R> = React.ComponentType<ComponentProps<P>> & {
__modalResult?: R;
show: (...args: ShowArgs<P>) => Promise<R>;
hide: () => void;
remove: () => void;
};
export function defineModal<P, R>(
component: React.ComponentType<ComponentProps<P> & NiceModalHocProps>
): Modalized<P, R> {
const c = component as unknown as Modalized<P, R>;
c.show = ((...args: any[]) =>
NiceModal.show(component as any, args[0])) as Modalized<P, R>['show'];
c.hide = () => NiceModal.hide(component as any);
c.remove = () => NiceModal.remove(component as any);
return c;
} }
/** // Common modal result types for standardization
* Show folder picker dialog
* @param props - Props for folder picker
* @returns Promise that resolves with selected path or null if cancelled
*/
export function showFolderPicker(
props: FolderPickerDialogProps = {}
): Promise<string | null> {
return showModal<string | null>(
'folder-picker',
props as Record<string, unknown>
);
}
/**
* Show task tag edit dialog
* @param props - Props for tag edit dialog
* @returns Promise that resolves with 'saved' or 'canceled'
*/
export function showTagEdit(props: TagEditDialogProps): Promise<TagEditResult> {
return showModal<TagEditResult>('tag-edit', props as Record<string, unknown>);
}
/**
* Show project form dialog
* @param props - Props for project form dialog
* @returns Promise that resolves with 'saved' or 'canceled'
*/
export function showProjectForm(
props: ProjectFormDialogProps = {}
): Promise<ProjectFormDialogResult> {
return showModal<ProjectFormDialogResult>(
'project-form',
props as Record<string, unknown>
);
}
/**
* Show link project dialog
* @param props - Props for link project dialog (projectId and projectName)
* @returns Promise that resolves with link result
*/
export function showLinkProject(props: {
projectId: string;
projectName: string;
}): Promise<LinkProjectResult> {
return showModal<LinkProjectResult>(
'link-project',
props as Record<string, unknown>
);
}
/**
* Hide a modal by ID
*/
export function hideModal(modal: string): void {
NiceModal.hide(modal);
}
/**
* Remove a modal by ID
*/
export function removeModal(modal: string): void {
NiceModal.remove(modal);
}
/**
* Hide all currently visible modals
*/
export function hideAllModals(): void {
// NiceModal doesn't have a direct hideAll, so we'll implement as needed
console.log('Hide all modals - implement as needed');
}
/**
* Common modal result types for standardization
*/
export type ConfirmResult = 'confirmed' | 'canceled'; export type ConfirmResult = 'confirmed' | 'canceled';
export type DeleteResult = 'deleted' | 'canceled'; export type DeleteResult = 'deleted' | 'canceled';
export type SaveResult = 'saved' | 'canceled'; export type SaveResult = 'saved' | 'canceled';
/** // Error handling utility for modal operations
* Error handling utility for modal operations
*/
export function getErrorMessage(error: unknown): string { export function getErrorMessage(error: unknown): string {
if (error instanceof Error) { if (error instanceof Error) {
return error.message; return error.message;

View File

@@ -1,4 +1,4 @@
import NiceModal from '@ebay/nice-modal-react'; import { TaskFormDialog } from '@/components/dialogs/tasks/TaskFormDialog';
import type { TaskFormDialogProps } from '@/components/dialogs/tasks/TaskFormDialog'; import type { TaskFormDialogProps } from '@/components/dialogs/tasks/TaskFormDialog';
/** /**
@@ -6,5 +6,5 @@ import type { TaskFormDialogProps } from '@/components/dialogs/tasks/TaskFormDia
* This replaces the previous TaskFormDialogContainer pattern * This replaces the previous TaskFormDialogContainer pattern
*/ */
export function openTaskForm(props: TaskFormDialogProps) { export function openTaskForm(props: TaskFormDialogProps) {
return NiceModal.show('task-form', props); return TaskFormDialog.show(props);
} }

View File

@@ -6,71 +6,11 @@ import { ClickToComponent } from 'click-to-react-component';
import { VibeKanbanWebCompanion } from 'vibe-kanban-web-companion'; import { VibeKanbanWebCompanion } from 'vibe-kanban-web-companion';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import NiceModal from '@ebay/nice-modal-react';
import i18n from './i18n'; import i18n from './i18n';
import posthog from 'posthog-js'; import posthog from 'posthog-js';
import { PostHogProvider } from 'posthog-js/react'; import { PostHogProvider } from 'posthog-js/react';
// Import modal type definitions // Import modal type definitions
import './types/modals'; import './types/modals';
// Import and register modals
import {
CreatePRDialog,
ConfirmDialog,
DisclaimerDialog,
OnboardingDialog,
ReleaseNotesDialog,
OAuthDialog,
TaskFormDialog,
EditorSelectionDialog,
DeleteTaskConfirmationDialog,
FolderPickerDialog,
TagEditDialog,
ChangeTargetBranchDialog,
RebaseDialog,
CreateConfigurationDialog,
DeleteConfigurationDialog,
ProjectFormDialog,
ProjectEditorSelectionDialog,
RestoreLogsDialog,
ViewProcessesDialog,
GitActionsDialog,
ShareDialog,
ReassignDialog,
StopShareTaskDialog,
CreateOrganizationDialog,
LinkProjectDialog,
} from './components/dialogs';
import { CreateAttemptDialog } from './components/dialogs/tasks/CreateAttemptDialog';
import { EditBranchNameDialog } from './components/dialogs/tasks/EditBranchNameDialog';
// Register modals
NiceModal.register('create-pr', CreatePRDialog);
NiceModal.register('confirm', ConfirmDialog);
NiceModal.register('disclaimer', DisclaimerDialog);
NiceModal.register('onboarding', OnboardingDialog);
NiceModal.register('release-notes', ReleaseNotesDialog);
NiceModal.register('oauth', OAuthDialog);
NiceModal.register('delete-task-confirmation', DeleteTaskConfirmationDialog);
NiceModal.register('task-form', TaskFormDialog);
NiceModal.register('editor-selection', EditorSelectionDialog);
NiceModal.register('folder-picker', FolderPickerDialog);
NiceModal.register('tag-edit', TagEditDialog);
NiceModal.register('change-target-branch-dialog', ChangeTargetBranchDialog);
NiceModal.register('rebase-dialog', RebaseDialog);
NiceModal.register('create-configuration', CreateConfigurationDialog);
NiceModal.register('delete-configuration', DeleteConfigurationDialog);
NiceModal.register('project-form', ProjectFormDialog);
NiceModal.register('project-editor-selection', ProjectEditorSelectionDialog);
NiceModal.register('restore-logs', RestoreLogsDialog);
NiceModal.register('view-processes', ViewProcessesDialog);
NiceModal.register('create-attempt', CreateAttemptDialog);
NiceModal.register('git-actions', GitActionsDialog);
NiceModal.register('edit-branch-name-dialog', EditBranchNameDialog);
NiceModal.register('share-task', ShareDialog);
NiceModal.register('reassign-shared-task', ReassignDialog);
NiceModal.register('stop-share-shared-task', StopShareTaskDialog);
NiceModal.register('create-organization', CreateOrganizationDialog);
NiceModal.register('link-project', LinkProjectDialog);
import { import {
useLocation, useLocation,

View File

@@ -24,7 +24,8 @@ import { Loader2 } from 'lucide-react';
import { ExecutorConfigForm } from '@/components/ExecutorConfigForm'; import { ExecutorConfigForm } from '@/components/ExecutorConfigForm';
import { useProfiles } from '@/hooks/useProfiles'; import { useProfiles } from '@/hooks/useProfiles';
import { useUserSystem } from '@/components/config-provider'; import { useUserSystem } from '@/components/config-provider';
import { showModal } from '@/lib/modals'; import { CreateConfigurationDialog } from '@/components/dialogs/settings/CreateConfigurationDialog';
import { DeleteConfigurationDialog } from '@/components/dialogs/settings/DeleteConfigurationDialog';
export function AgentSettings() { export function AgentSettings() {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
@@ -84,11 +85,7 @@ export function AgentSettings() {
// Open create dialog // Open create dialog
const openCreateDialog = async () => { const openCreateDialog = async () => {
try { try {
const result = await showModal<{ const result = await CreateConfigurationDialog.show({
action: 'created' | 'canceled';
configName?: string;
cloneFrom?: string | null;
}>('create-configuration', {
executorType: selectedExecutorType, executorType: selectedExecutorType,
existingConfigs: Object.keys( existingConfigs: Object.keys(
localParsedProfiles?.executors?.[selectedExecutorType] || {} localParsedProfiles?.executors?.[selectedExecutorType] || {}
@@ -141,13 +138,10 @@ export function AgentSettings() {
// Open delete dialog // Open delete dialog
const openDeleteDialog = async (configName: string) => { const openDeleteDialog = async (configName: string) => {
try { try {
const result = await showModal<'deleted' | 'canceled'>( const result = await DeleteConfigurationDialog.show({
'delete-configuration', configName,
{ executorType: selectedExecutorType,
configName, });
executorType: selectedExecutorType,
}
);
if (result === 'deleted') { if (result === 'deleted') {
await handleDeleteConfiguration(configName); await handleDeleteConfiguration(configName);

View File

@@ -25,15 +25,12 @@ import { useOrganizationMutations } from '@/hooks/useOrganizationMutations';
import { useUserSystem } from '@/components/config-provider'; import { useUserSystem } from '@/components/config-provider';
import { useAuth } from '@/hooks/auth/useAuth'; import { useAuth } from '@/hooks/auth/useAuth';
import { LoginRequiredPrompt } from '@/components/dialogs/shared/LoginRequiredPrompt'; import { LoginRequiredPrompt } from '@/components/dialogs/shared/LoginRequiredPrompt';
import NiceModal from '@ebay/nice-modal-react'; import { CreateOrganizationDialog } from '@/components/dialogs/org/CreateOrganizationDialog';
import { import { InviteMemberDialog } from '@/components/dialogs/org/InviteMemberDialog';
InviteMemberDialog, import type {
type InviteMemberResult, InviteMemberResult,
} from '@/components/dialogs/org/InviteMemberDialog'; CreateOrganizationResult,
import { } from '@/components/dialogs';
CreateOrganizationDialog,
type CreateOrganizationResult,
} from '@/components/dialogs/org/CreateOrganizationDialog';
import { MemberListItem } from '@/components/org/MemberListItem'; import { MemberListItem } from '@/components/org/MemberListItem';
import { PendingInvitationItem } from '@/components/org/PendingInvitationItem'; import { PendingInvitationItem } from '@/components/org/PendingInvitationItem';
import { RemoteProjectItem } from '@/components/org/RemoteProjectItem'; import { RemoteProjectItem } from '@/components/org/RemoteProjectItem';
@@ -176,9 +173,8 @@ export function OrganizationSettings() {
const handleCreateOrganization = async () => { const handleCreateOrganization = async () => {
try { try {
const result: CreateOrganizationResult = await NiceModal.show( const result: CreateOrganizationResult =
CreateOrganizationDialog await CreateOrganizationDialog.show();
);
if (result.action === 'created' && result.organizationId) { if (result.action === 'created' && result.organizationId) {
// No need to refetch - the mutation hook handles cache invalidation // No need to refetch - the mutation hook handles cache invalidation
@@ -195,10 +191,9 @@ export function OrganizationSettings() {
if (!selectedOrgId) return; if (!selectedOrgId) return;
try { try {
const result: InviteMemberResult = await NiceModal.show( const result: InviteMemberResult = await InviteMemberDialog.show({
InviteMemberDialog, organizationId: selectedOrgId,
{ organizationId: selectedOrgId } });
);
if (result.action === 'invited') { if (result.action === 'invited') {
// No need to refetch - the mutation hook handles cache invalidation // No need to refetch - the mutation hook handles cache invalidation

View File

@@ -26,7 +26,7 @@ import { useProjectMutations } from '@/hooks/useProjectMutations';
import { useScriptPlaceholders } from '@/hooks/useScriptPlaceholders'; import { useScriptPlaceholders } from '@/hooks/useScriptPlaceholders';
import { CopyFilesField } from '@/components/projects/copy-files-field'; import { CopyFilesField } from '@/components/projects/copy-files-field';
import { AutoExpandingTextarea } from '@/components/ui/auto-expanding-textarea'; import { AutoExpandingTextarea } from '@/components/ui/auto-expanding-textarea';
import { showFolderPicker } from '@/lib/modals'; import { FolderPickerDialog } from '@/components/dialogs/shared/FolderPickerDialog';
import type { Project, UpdateProject } from 'shared/types'; import type { Project, UpdateProject } from 'shared/types';
interface ProjectFormState { interface ProjectFormState {
@@ -369,7 +369,7 @@ export function ProjectSettings() {
type="button" type="button"
variant="outline" variant="outline"
onClick={async () => { onClick={async () => {
const selectedPath = await showFolderPicker({ const selectedPath = await FolderPickerDialog.show({
title: 'Select Git Repository', title: 'Select Git Repository',
description: 'Choose an existing git repository', description: 'Choose an existing git repository',
value: draft.git_repo_path, value: draft.git_repo_path,