"use client"; import { FormEvent, useCallback } from "react"; import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog"; import type { Task, Repository } from "@/lib/types/http"; import { SHORTCUTS } from "@/lib/keyboard/constants"; import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import { TaskCreateDialogFooter } from "@/components/task-create-dialog-footer"; import { DiscardLocalChangesDialog } from "@/components/discard-local-changes-dialog"; import { DialogHeaderContent } from "@/components/task-create-dialog-header"; import { SessionSelectors, WorkflowSection, DialogPromptSection, } from "@/components/task-create-dialog-form-body"; import { AgentSelector, ExecutorProfileSelector, InlineTaskName, } from "@/components/task-create-dialog-selectors"; import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit"; import { CreateModeSelectors } from "@/components/task-create-dialog-create-mode-selectors"; import { RepoChipsRow } from "@/components/task-create-dialog-repo-chips"; import { useToast } from "@/components/toast-provider"; import { useDialogFormState, useTaskCreateDialogEffects, useDialogHandlers, useLockedFieldSync, useSessionRepoName, useTaskCreateDialogData, computeIsTaskStarted, type DialogFormState, type TaskCreateDialogInitialValues, } from "@/components/task-create-dialog-state"; import type { DialogFormBodyProps } from "@/components/task-create-dialog-types"; interface TaskCreateDialogProps { open: boolean; onOpenChange: (open: boolean) => void; mode?: "create" | "edit" | "session"; workspaceId: string | null; workflowId: string | null; defaultStepId: string | null; steps: Array<{ id: string; title: string; events?: { on_enter?: Array<{ type: string; config?: Record }>; on_turn_complete?: Array<{ type: string; config?: Record }>; }; }>; editingTask?: { id: string; title: string; description?: string; workflowStepId: string; state?: Task["state"]; repositoryId?: string; } | null; onSuccess?: ( task: Task, mode: "create" | "edit", meta?: { taskSessionId?: string | null }, ) => void; onCreateSession?: (data: { prompt: string; agentProfileId: string; executorId: string }) => void; initialValues?: TaskCreateDialogInitialValues; taskId?: string | null; parentTaskId?: string; /** * Pin specific form fields to their initial values (used by feature wrappers * like Improve Kandev that fix the repo + branch + workflow). The current * implementation just passes the locks through; the chip row's first repo * is overwritten on each open. The flags are kept for forward compat with * locking the editor UI itself in a future pass. */ lockedFields?: { repository?: boolean; branch?: boolean; workflow?: boolean }; /** Optional submit hook used by Improve Kandev to wrap the description. */ transformDescriptionBeforeSubmit?: (description: string) => Promise | string; /** Optional override for the description placeholder. */ descriptionPlaceholder?: string; /** Optional render slot above the description editor. */ aboveDescriptionSlot?: React.ReactNode; /** Optional render slot inside the dialog (between body and footer). */ extraFormSlot?: React.ReactNode; /** Optional render slot at the bottom of the dialog footer area. */ bottomSlot?: React.ReactNode; /** * When set, every submit button is disabled and the tooltip surfaces this * exact reason (e.g. an async bootstrap step from a feature wrapper hasn't * completed yet). Takes precedence over the usual missing-field reasons. */ submitBlockedReason?: string | null; } function computeHasAllBranches(fs: DialogFormState): boolean { if (fs.noRepository) return true; if (fs.useGitHubUrl) return !!fs.githubBranch; return fs.repositories.length > 0 && fs.repositories.every((r) => !!r.branch); } function CreateModeBody(props: DialogFormBodyProps) { const { isCreateMode, isEditMode, isTaskStarted, workspaceId, onJiraImport, onLinearImport, agentProfileOptions, executorProfileOptions, agentProfiles, agentProfilesLoading, executorsLoading, isCreatingSession, fs, onTaskNameChange, onRowRepositoryChange, onRowBranchChange, onAgentProfileChange, onExecutorProfileChange, onToggleGitHubUrl, onGitHubUrlChange, onToggleFreshBranch, workflowAgentLocked, repositories, freshBranchAvailable, isLocalExecutor, } = props; const showTaskName = (isCreateMode || isEditMode) && !isTaskStarted; const taskNameAutoFocus = !isEditMode && !fs.useGitHubUrl; return ( <> {showTaskName && ( )} {props.bottomSlot} ); } function SessionModeBody(props: DialogFormBodyProps) { return ( <> ); } function DialogFormBody(props: DialogFormBodyProps) { const { isSessionMode, isCreateMode, isTaskStarted, workflows, snapshots } = props; return (
{isSessionMode ? : } [0]["workflows"]} snapshots={snapshots as Parameters[0]["snapshots"]} effectiveWorkflowId={props.effectiveWorkflowId} onWorkflowChange={props.onWorkflowChange} agentProfiles={props.agentProfiles} workflowLocked={props.workflowLocked} />
); } function useEnhanceForDialog(fs: DialogFormState) { const isConfigured = useIsUtilityConfigured(); const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ sessionId: null, taskTitle: fs.taskName, }); const onEnhance = useCallback(() => { const current = fs.descriptionInputRef.current?.getValue()?.trim(); if (!current) return; enhancePrompt(current, (enhanced) => { fs.descriptionInputRef.current?.setValue(enhanced); fs.setHasDescription(true); }); }, [enhancePrompt, fs]); return { onEnhance, isLoading: isEnhancingPrompt, isConfigured }; } function useJiraImportHandler(fs: DialogFormState) { return useCallback( (ticket: JiraTicket) => { const title = `[${ticket.key}] ${ticket.summary}`; fs.setTaskName(title); fs.setHasTitle(true); const description = ticket.description?.trim() ? `${ticket.description}\n\n---\nJira: ${ticket.url}` : `Jira: ${ticket.url}`; fs.descriptionInputRef.current?.setValue(description); fs.setHasDescription(true); }, [fs], ); } function useLinearImportHandler(fs: DialogFormState) { return useCallback( (issue: LinearIssue) => { const title = `[${issue.identifier}] ${issue.title}`; fs.setTaskName(title); fs.setHasTitle(true); const description = issue.description?.trim() ? `${issue.description}\n\n---\nLinear: ${issue.url}` : `Linear: ${issue.url}`; fs.descriptionInputRef.current?.setValue(description); fs.setHasDescription(true); }, [fs], ); } type SubmitWiringArgs = { props: TaskCreateDialogProps; fs: ReturnType; computed: ReturnType["computed"]; workspaceRepositories: ReturnType["repositories"]; repositoryLocalPath: string; isSessionMode: boolean; isEditMode: boolean; }; function useSubmitHandlersWiring({ props, fs, computed, workspaceRepositories, repositoryLocalPath, isSessionMode, isEditMode, }: SubmitWiringArgs) { const { workspaceId, workflowId, editingTask, onSuccess, onCreateSession, onOpenChange } = props; const { parentTaskId } = props; const taskId = props.taskId ?? null; return useTaskSubmitHandlers({ isSessionMode, isEditMode, isPassthroughProfile: computed.isPassthroughProfile, taskName: fs.taskName, workspaceId, workflowId, effectiveWorkflowId: computed.effectiveWorkflowId, effectiveDefaultStepId: computed.effectiveDefaultStepId, repositories: fs.repositories, discoveredRepositories: fs.discoveredRepositories, workspaceRepositories, useGitHubUrl: fs.useGitHubUrl, githubUrl: fs.githubUrl, githubPrHeadBranch: fs.githubPrHeadBranch, githubBranch: fs.githubBranch, agentProfileId: computed.effectiveAgentProfileId, executorId: fs.executorId, executorProfileId: fs.executorProfileId, editingTask, onSuccess, onCreateSession, onOpenChange, taskId, parentTaskId, descriptionInputRef: fs.descriptionInputRef, setIsCreatingSession: fs.setIsCreatingSession, setIsCreatingTask: fs.setIsCreatingTask, setHasTitle: fs.setHasTitle, setHasDescription: fs.setHasDescription, setTaskName: fs.setTaskName, setRepositories: fs.setRepositories, setGitHubBranch: fs.setGitHubBranch, setAgentProfileId: fs.setAgentProfileId, setExecutorId: fs.setExecutorId, setSelectedWorkflowId: fs.setSelectedWorkflowId, setFetchedSteps: fs.setFetchedSteps, clearDraft: fs.clearDraft, freshBranchEnabled: fs.freshBranchEnabled, isLocalExecutor: computed.isLocalExecutor, repositoryLocalPath, noRepository: fs.noRepository, workspacePath: fs.workspacePath, }); } /** * Resolves the on-disk path for the (single) selected row, used by the * fresh-branch consent flow. Multi-row tasks hide fresh-branch in the UI, * so we only need to resolve a path when there is exactly one row. */ function resolveSingleRowLocalPath(fs: DialogFormState, repositories: Repository[]): string { if (fs.repositories.length !== 1) return ""; const row = fs.repositories[0]; if (row.localPath) return row.localPath; if (row.repositoryId) { return repositories.find((r) => r.id === row.repositoryId)?.local_path ?? ""; } return ""; } function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { const { open, mode = "create", workspaceId, workflowId, defaultStepId } = props; const { editingTask, initialValues } = props; const isSessionMode = mode === "session"; const isEditMode = mode === "edit"; const isCreateMode = mode === "create"; const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask); const fs = useDialogFormState(open, workspaceId, workflowId, initialValues); const { toast } = useToast(); const sessionRepoName = useSessionRepoName(isSessionMode); const { workflows, agentProfiles, executors, snapshots, repositories, repositoriesLoading, computed, } = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs); const repositoryLocalPath = resolveSingleRowLocalPath(fs, repositories); useTaskCreateDialogEffects(fs, { open, workspaceId, workflowId, repositories, repositoriesLoading, agentProfiles, executors, workspaceDefaults: computed.workspaceDefaults, toast, workflows, isLocalExecutor: computed.isLocalExecutor, }); useLockedFieldSync(open, workflowId, initialValues, fs); const handlers = useDialogHandlers(fs, repositories); const submitHandlers = useSubmitHandlersWiring({ props, fs, computed, workspaceRepositories: repositories, repositoryLocalPath, isSessionMode, isEditMode, }); const guardedHandleSubmit = useGuardedSubmit( submitHandlers.handleSubmit, props.submitBlockedReason, ); const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => { guardedHandleSubmit(event as unknown as FormEvent); }); // Fresh-branch is single-row + local executor + not URL mode. The chip row // can hold any number of repos; we hide the toggle whenever the question // ("which repo do we discard local changes in?") becomes ambiguous. const freshBranchAvailable = !fs.useGitHubUrl && computed.isLocalExecutor && fs.repositories.length === 1; return { fs, isSessionMode, isEditMode, isCreateMode, isTaskStarted, sessionRepoName, workflows, agentProfiles, snapshots, repositories, repositoriesLoading, computed, handlers, submitHandlers, handleKeyDown, freshBranchAvailable, guardedHandleSubmit, enhance: useEnhanceForDialog(fs), handleJiraImport: useJiraImportHandler(fs), handleLinearImport: useLinearImportHandler(fs), }; } // Buttons are disabled when submitBlockedReason is set, but the form can still // be submitted via Enter; gate the submit path here so a wrapper's async // bootstrap step always finishes before any task is created. function useGuardedSubmit( handleSubmit: (e: FormEvent) => void, blockedReason: string | null | undefined, ) { const blocked = Boolean(blockedReason); return useCallback( (e: FormEvent) => { if (blocked) e.preventDefault(); else handleSubmit(e); }, [blocked, handleSubmit], ); } // Build the props object passed to DialogFormBody. Extracted from // TaskCreateDialog so the orchestrator stays under the 100-line cap; the // shape just spreads setup + dialog props 1:1 so there's no behaviour change. function buildDialogFormBodyProps( setup: ReturnType, props: TaskCreateDialogProps, ): DialogFormBodyProps { const { fs, computed, handlers } = setup; const repoLocked = !!props.lockedFields?.repository; return { isSessionMode: setup.isSessionMode, isCreateMode: setup.isCreateMode, isEditMode: setup.isEditMode, isTaskStarted: setup.isTaskStarted, onTaskNameChange: handlers.handleTaskNameChange, onRowRepositoryChange: handlers.handleRowRepositoryChange, onRowBranchChange: handlers.handleRowBranchChange, isPassthroughProfile: computed.isPassthroughProfile, initialDescription: fs.currentDefaults.description, hasDescription: fs.hasDescription, workspaceId: props.workspaceId, onJiraImport: setup.handleJiraImport, onLinearImport: setup.handleLinearImport, agentProfileOptions: computed.agentProfileOptions, executorProfileOptions: computed.executorProfileOptions, agentProfiles: setup.agentProfiles, agentProfilesLoading: computed.agentProfilesLoading, executorsLoading: computed.executorsLoading, isCreatingSession: fs.isCreatingSession, workflows: setup.workflows, snapshots: setup.snapshots, effectiveWorkflowId: computed.effectiveWorkflowId ?? null, fs, handleKeyDown: setup.handleKeyDown, onAgentProfileChange: handlers.handleAgentProfileChange, onExecutorProfileChange: handlers.handleExecutorProfileChange, onWorkflowChange: handlers.handleWorkflowChange, onToggleGitHubUrl: repoLocked ? undefined : handlers.handleToggleGitHubUrl, onGitHubUrlChange: handlers.handleGitHubUrlChange, onToggleFreshBranch: handlers.handleToggleFreshBranch, onToggleNoRepository: repoLocked ? undefined : handlers.handleToggleNoRepository, onWorkspacePathChange: handlers.handleWorkspacePathChange, enhance: setup.enhance, workflowAgentLocked: computed.workflowAgentLocked, repositories: setup.repositories, freshBranchAvailable: setup.freshBranchAvailable, isLocalExecutor: computed.isLocalExecutor, noCompatibleAgent: computed.noCompatibleAgent, executorProfileName: computed.selectedExecutorProfileName, extraFormSlot: props.extraFormSlot, aboveDescriptionSlot: props.aboveDescriptionSlot, bottomSlot: props.bottomSlot, descriptionPlaceholder: props.descriptionPlaceholder, workflowLocked: props.lockedFields?.workflow, }; } function buildDialogFooterProps( setup: ReturnType, props: TaskCreateDialogProps, ) { const { fs, computed, submitHandlers } = setup; return { isSessionMode: setup.isSessionMode, isCreateMode: setup.isCreateMode, isEditMode: setup.isEditMode, isTaskStarted: setup.isTaskStarted, isPassthroughProfile: computed.isPassthroughProfile, isCreatingSession: fs.isCreatingSession, isCreatingTask: fs.isCreatingTask, hasTitle: fs.hasTitle, hasDescription: fs.hasDescription, hasRepositorySelection: computed.hasRepositorySelection, hasAllBranches: computeHasAllBranches(fs), agentProfileId: computed.effectiveAgentProfileId, workspaceId: props.workspaceId, effectiveWorkflowId: computed.effectiveWorkflowId ?? null, executorHint: computed.executorHint, noCompatibleAgent: computed.noCompatibleAgent, executorProfileName: computed.selectedExecutorProfileName, onCancel: submitHandlers.handleCancel, onUpdateWithoutAgent: submitHandlers.handleUpdateWithoutAgent, onCreateWithoutAgent: submitHandlers.handleCreateWithoutAgent, onCreateWithPlanMode: submitHandlers.handleCreateWithPlanMode, submitBlockedReason: props.submitBlockedReason, }; } export function TaskCreateDialog(props: TaskCreateDialogProps) { const setup = useTaskCreateDialogSetup(props); return (
); } function PendingDiscardModal({ pending, }: { pending: ReturnType["pendingDiscard"]; }) { if (!pending) return null; return ( { if (!next) pending.resolve(false); }} dirtyFiles={pending.dirtyFiles} repoPath={pending.repoPath} onConfirm={() => pending.resolve(true)} onCancel={() => pending.resolve(false)} /> ); }