"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 type { AgentProfileOption } from "@/lib/state/slices"; 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 { useAgentProfileOptions } from "@/components/task-create-dialog-options"; 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, useSessionRepoName, useTaskCreateDialogData, computeIsTaskStarted, type DialogFormState, type TaskCreateDialogInitialValues, } from "@/components/task-create-dialog-state"; 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; } type DialogFormBodyProps = { isSessionMode: boolean; isCreateMode: boolean; isEditMode: boolean; isTaskStarted: boolean; isPassthroughProfile: boolean; initialDescription: string; hasDescription: boolean; workspaceId: string | null; onJiraImport?: (ticket: JiraTicket) => void; onLinearImport?: (issue: LinearIssue) => void; agentProfileOptions: ReturnType; executorProfileOptions: Array<{ value: string; label: string; renderLabel?: () => React.ReactNode; }>; agentProfiles: AgentProfileOption[]; agentProfilesLoading: boolean; executorsLoading: boolean; isCreatingSession: boolean; workflows: unknown[]; snapshots: unknown; effectiveWorkflowId: string | null; fs: DialogFormState; handleKeyDown: ReturnType; onTaskNameChange: (v: string) => void; onRowRepositoryChange: (key: string, value: string) => void; onRowBranchChange: (key: string, value: string) => void; onAgentProfileChange: (v: string) => void; onExecutorProfileChange: (v: string) => void; onWorkflowChange: (v: string) => void; onToggleGitHubUrl?: () => void; onGitHubUrlChange: (v: string) => void; onToggleFreshBranch: (enabled: boolean) => void; enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean }; workflowAgentLocked: boolean; /** Workspace repositories — driven into the chip row for repo + branch picks. */ repositories: Repository[]; /** Computed in the parent: single-row + local executor + not URL mode. */ freshBranchAvailable: boolean; /** * True when the selected executor profile runs locally on the host. Used * to lock the per-row branch pill (the user's checkout dictates the * branch for local execution; fresh-branch mode unlocks it). */ isLocalExecutor: boolean; noCompatibleAgent: boolean; executorProfileName: string | null; /** Optional render slot above the description editor. */ aboveDescriptionSlot?: React.ReactNode; /** Optional render slot inside the dialog body (rendered above the chip row). */ extraFormSlot?: React.ReactNode; /** Optional render slot at the bottom of the dialog body (above the footer). */ bottomSlot?: React.ReactNode; /** Optional override for the description placeholder. */ descriptionPlaceholder?: string; }; 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} />
); } 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"]; repositoryLocalPath: string; isSessionMode: boolean; isEditMode: boolean; }; function useSubmitHandlersWiring({ props, fs, computed, 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, 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, }); } /** * 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 hasAllBranchesSelected(fs: DialogFormState): boolean { if (fs.useGitHubUrl) return !!fs.githubBranch; return fs.repositories.length > 0 && fs.repositories.every((r) => !!r.branch); } 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, }); const handlers = useDialogHandlers(fs, repositories); const submitHandlers = useSubmitHandlersWiring({ props, fs, computed, repositoryLocalPath, isSessionMode, isEditMode, }); const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => { submitHandlers.handleSubmit(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, enhance: useEnhanceForDialog(fs), handleJiraImport: useJiraImportHandler(fs), handleLinearImport: useLinearImportHandler(fs), }; } export function TaskCreateDialog(props: TaskCreateDialogProps) { const { open, onOpenChange, initialValues, workspaceId } = props; const setup = useTaskCreateDialogSetup(props); const { fs, isSessionMode, isEditMode, isCreateMode, isTaskStarted } = setup; const { sessionRepoName, workflows, agentProfiles, snapshots, repositories } = setup; const { computed, handlers, handleKeyDown, freshBranchAvailable } = setup; const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers; const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers; const { handleJiraImport, handleLinearImport } = setup; 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)} /> ); }