Files
kandev/apps/web/components/task-create-dialog.tsx
T

548 lines
19 KiB
TypeScript

"use client";
import { FormEvent } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog";
import type { Task } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices";
import { SHORTCUTS } from "@/lib/keyboard/constants";
import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut";
import { TaskCreateDialogFooter } from "@/components/task-create-dialog-footer";
import {
CreateEditSelectors,
SessionSelectors,
WorkflowSection,
} from "@/components/task-create-dialog-form-body";
import {
useRepositoryOptions,
useBranchOptions,
useAgentProfileOptions,
} from "@/components/task-create-dialog-options";
import {
RepositorySelector,
BranchSelector,
AgentSelector,
ExecutorProfileSelector,
InlineTaskName,
TaskFormInputs,
} from "@/components/task-create-dialog-selectors";
import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit";
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<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
};
}>;
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;
}
function getRepositoryPlaceholder(
workspaceId: string | null,
repositoriesLoading: boolean,
discoverReposLoading: boolean,
) {
if (!workspaceId) return "Select workspace first";
if (repositoriesLoading || discoverReposLoading) return "Loading...";
return "Select repository";
}
type DialogHeaderContentProps = {
isCreateMode: boolean;
isEditMode: boolean;
isTaskStarted: boolean;
sessionRepoName?: string;
initialTitle?: string;
taskName: string;
repositoryId: string;
discoveredRepoPath: string;
workspaceId: string | null;
repositoriesLoading: boolean;
discoverReposLoading: boolean;
headerRepositoryOptions: ReturnType<typeof useRepositoryOptions>["headerRepositoryOptions"];
onRepositoryChange: (v: string) => void;
onTaskNameChange: (v: string) => void;
useGitHubUrl: boolean;
githubUrl: string;
githubUrlError: string | null;
onToggleGitHubUrl: () => void;
onGitHubUrlChange: (v: string) => void;
};
function RepoSourceInput({
useGitHubUrl,
githubUrl,
githubUrlError,
onGitHubUrlChange,
isTaskStarted,
headerRepositoryOptions,
repositoryId,
discoveredRepoPath,
onRepositoryChange,
workspaceId,
repositoriesLoading,
discoverReposLoading,
}: Pick<
DialogHeaderContentProps,
| "useGitHubUrl"
| "githubUrl"
| "githubUrlError"
| "onGitHubUrlChange"
| "isTaskStarted"
| "headerRepositoryOptions"
| "repositoryId"
| "discoveredRepoPath"
| "onRepositoryChange"
| "workspaceId"
| "repositoriesLoading"
| "discoverReposLoading"
>): React.ReactNode {
if (useGitHubUrl) {
return (
<div className="relative">
<input
type="text"
value={githubUrl}
onChange={(e) => onGitHubUrlChange(e.target.value)}
placeholder="github.com/owner/repo"
data-testid="github-url-input"
aria-label="GitHub repository URL"
aria-invalid={!!githubUrlError}
aria-describedby={githubUrlError ? "github-url-error" : undefined}
size={Math.max((githubUrl || "").length + 1, "github.com/owner/repo".length)}
className={`bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 h-7 rounded-md px-2 hover:bg-muted focus:bg-muted transition-colors placeholder:text-muted-foreground ${githubUrlError ? "text-destructive" : ""}`}
disabled={isTaskStarted}
autoFocus
/>
{githubUrlError && (
<div
id="github-url-error"
role="alert"
className="absolute left-0 top-full mt-1 z-50 rounded-md border bg-popover px-2 py-1 text-[11px] text-destructive shadow-md whitespace-nowrap"
data-testid="github-url-error"
>
{githubUrlError}
</div>
)}
</div>
);
}
return (
<RepositorySelector
options={headerRepositoryOptions}
value={repositoryId || discoveredRepoPath}
onValueChange={onRepositoryChange}
placeholder={getRepositoryPlaceholder(workspaceId, repositoriesLoading, discoverReposLoading)}
searchPlaceholder="Search repositories..."
emptyMessage={
repositoriesLoading || discoverReposLoading
? "Loading repositories..."
: "No repositories found."
}
disabled={isTaskStarted || !workspaceId || repositoriesLoading || discoverReposLoading}
triggerClassName="w-auto text-sm"
/>
);
}
function DialogHeaderContent(props: DialogHeaderContentProps) {
const { isCreateMode, isEditMode, isTaskStarted, sessionRepoName, initialTitle } = props;
const { taskName, onTaskNameChange, useGitHubUrl, onToggleGitHubUrl } = props;
if (isCreateMode || isEditMode) {
return (
<>
<DialogTitle asChild>
<div className="flex items-center gap-1 text-sm font-medium min-w-0">
<RepoSourceInput {...props} />
<span className="text-muted-foreground mr-2">/</span>
<InlineTaskName
value={taskName}
onChange={onTaskNameChange}
autoFocus={!isEditMode && !useGitHubUrl}
/>
</div>
</DialogTitle>
{!isTaskStarted && (
<div className="flex items-center gap-2 pl-2">
<button
type="button"
onClick={onToggleGitHubUrl}
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
data-testid="toggle-github-url"
>
{useGitHubUrl ? "or select a repository" : "or paste a GitHub URL"}
</button>
</div>
)}
</>
);
}
return (
<DialogTitle asChild>
<div className="flex items-center gap-1 min-w-0 text-sm font-medium">
{sessionRepoName && (
<>
<span className="truncate text-muted-foreground">{sessionRepoName}</span>
<span className="text-muted-foreground mx-0.5">/</span>
</>
)}
<span className="truncate">{initialTitle || "Task"}</span>
<span className="text-muted-foreground mx-0.5">/</span>
<span className="text-muted-foreground whitespace-nowrap">new session</span>
</div>
</DialogTitle>
);
}
type DialogFormBodyProps = {
open: boolean;
isSessionMode: boolean;
isCreateMode: boolean;
isTaskStarted: boolean;
isPassthroughProfile: boolean;
initialDescription: string;
hasDescription: boolean;
branchOptions: ReturnType<typeof useBranchOptions>;
branchesLoading: boolean;
agentProfileOptions: ReturnType<typeof useAgentProfileOptions>;
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<typeof useKeyboardShortcutHandler>;
onBranchChange: (v: string) => void;
onAgentProfileChange: (v: string) => void;
onExecutorProfileChange: (v: string) => void;
onWorkflowChange: (v: string) => void;
hasRepositorySelection: boolean;
};
function DialogFormBody({
open,
isSessionMode,
isCreateMode,
isTaskStarted,
isPassthroughProfile,
initialDescription,
hasDescription,
branchOptions,
branchesLoading,
agentProfileOptions,
executorProfileOptions,
agentProfiles,
agentProfilesLoading,
executorsLoading,
isCreatingSession,
workflows,
snapshots,
effectiveWorkflowId,
fs,
handleKeyDown,
onBranchChange,
onAgentProfileChange,
onExecutorProfileChange,
onWorkflowChange,
hasRepositorySelection,
}: DialogFormBodyProps) {
return (
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<TaskFormInputs
key={`${open}-${initialDescription}`}
isSessionMode={isSessionMode}
autoFocus={isTaskStarted ? false : true}
initialDescription={initialDescription}
onDescriptionChange={fs.setHasDescription}
onKeyDown={handleKeyDown}
descriptionValueRef={fs.descriptionInputRef}
disabled={isTaskStarted || isPassthroughProfile}
placeholder={
isPassthroughProfile ? "Sending a prompt is not supported in passthrough mode" : undefined
}
/>
{isPassthroughProfile && hasDescription && (
<p className="text-xs text-amber-500">
Prompt will be ignored — passthrough sessions don&apos;t support sending a prompt on
start.
</p>
)}
{!isSessionMode && (
<CreateEditSelectors
isTaskStarted={isTaskStarted}
hasRepositorySelection={hasRepositorySelection}
branchOptions={branchOptions}
branch={fs.branch}
onBranchChange={onBranchChange}
branchesLoading={branchesLoading}
localBranchesLoading={fs.localBranchesLoading}
agentProfiles={agentProfiles}
agentProfilesLoading={agentProfilesLoading}
agentProfileOptions={agentProfileOptions}
agentProfileId={fs.agentProfileId}
onAgentProfileChange={onAgentProfileChange}
isCreatingSession={isCreatingSession}
executorProfileOptions={executorProfileOptions}
executorProfileId={fs.executorProfileId}
onExecutorProfileChange={onExecutorProfileChange}
executorsLoading={executorsLoading}
BranchSelectorComponent={BranchSelector}
AgentSelectorComponent={AgentSelector}
ExecutorProfileSelectorComponent={ExecutorProfileSelector}
/>
)}
<WorkflowSection
isCreateMode={isCreateMode}
isTaskStarted={isTaskStarted}
workflows={workflows as Parameters<typeof WorkflowSection>[0]["workflows"]}
snapshots={snapshots as Parameters<typeof WorkflowSection>[0]["snapshots"]}
effectiveWorkflowId={effectiveWorkflowId}
onWorkflowChange={onWorkflowChange}
/>
{isSessionMode && (
<SessionSelectors
agentProfileOptions={agentProfileOptions}
agentProfileId={fs.agentProfileId}
onAgentProfileChange={onAgentProfileChange}
agentProfilesLoading={agentProfilesLoading}
isCreatingSession={isCreatingSession}
executorProfileOptions={executorProfileOptions}
executorProfileId={fs.executorProfileId}
onExecutorProfileChange={onExecutorProfileChange}
executorsLoading={executorsLoading}
AgentSelectorComponent={AgentSelector}
ExecutorProfileSelectorComponent={ExecutorProfileSelector}
/>
)}
</div>
);
}
function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
const { open, onOpenChange, mode = "create", workspaceId, workflowId, defaultStepId } = props;
const { editingTask, onSuccess, onCreateSession, initialValues, taskId = null } = 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,
branches,
branchesLoading,
computed,
} = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs);
useTaskCreateDialogEffects(fs, {
open,
workspaceId,
workflowId,
repositories,
repositoriesLoading,
branches,
agentProfiles,
executors,
workspaceDefaults: computed.workspaceDefaults,
toast,
});
const handlers = useDialogHandlers(fs, repositories);
const submitHandlers = useTaskSubmitHandlers({
isSessionMode,
isEditMode,
isPassthroughProfile: computed.isPassthroughProfile,
taskName: fs.taskName,
workspaceId,
workflowId,
effectiveWorkflowId: computed.effectiveWorkflowId,
effectiveDefaultStepId: computed.effectiveDefaultStepId,
repositoryId: fs.repositoryId,
selectedLocalRepo: fs.selectedLocalRepo,
useGitHubUrl: fs.useGitHubUrl,
githubUrl: fs.githubUrl,
githubPrHeadBranch: fs.githubPrHeadBranch,
branch: fs.branch,
agentProfileId: fs.agentProfileId,
executorId: fs.executorId,
executorProfileId: fs.executorProfileId,
editingTask,
onSuccess,
onCreateSession,
onOpenChange,
taskId,
descriptionInputRef: fs.descriptionInputRef,
setIsCreatingSession: fs.setIsCreatingSession,
setIsCreatingTask: fs.setIsCreatingTask,
setHasTitle: fs.setHasTitle,
setHasDescription: fs.setHasDescription,
setTaskName: fs.setTaskName,
setRepositoryId: fs.setRepositoryId,
setBranch: fs.setBranch,
setAgentProfileId: fs.setAgentProfileId,
setExecutorId: fs.setExecutorId,
setSelectedWorkflowId: fs.setSelectedWorkflowId,
setFetchedSteps: fs.setFetchedSteps,
});
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
submitHandlers.handleSubmit(event as unknown as FormEvent);
});
return {
fs,
isSessionMode,
isEditMode,
isCreateMode,
isTaskStarted,
sessionRepoName,
workflows,
agentProfiles,
snapshots,
repositoriesLoading,
branchesLoading,
computed,
handlers,
submitHandlers,
handleKeyDown,
};
}
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 } = setup;
const { repositoriesLoading, branchesLoading, computed, handlers, handleKeyDown } = setup;
const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers;
const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-testid="create-task-dialog"
className="w-full h-full max-w-full max-h-full rounded-none sm:w-[900px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col"
>
<DialogHeader>
<DialogHeaderContent
isCreateMode={isCreateMode}
isEditMode={isEditMode}
isTaskStarted={isTaskStarted}
sessionRepoName={sessionRepoName}
initialTitle={initialValues?.title}
taskName={fs.taskName}
repositoryId={fs.repositoryId}
discoveredRepoPath={fs.discoveredRepoPath}
workspaceId={workspaceId}
repositoriesLoading={repositoriesLoading}
discoverReposLoading={fs.discoverReposLoading}
headerRepositoryOptions={computed.headerRepositoryOptions}
onRepositoryChange={handlers.handleRepositoryChange}
onTaskNameChange={handlers.handleTaskNameChange}
useGitHubUrl={fs.useGitHubUrl}
githubUrl={fs.githubUrl}
githubUrlError={fs.githubUrlError}
onToggleGitHubUrl={handlers.handleToggleGitHubUrl}
onGitHubUrlChange={handlers.handleGitHubUrlChange}
/>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 overflow-hidden">
<DialogFormBody
open={open}
isSessionMode={isSessionMode}
isCreateMode={isCreateMode}
isTaskStarted={isTaskStarted}
isPassthroughProfile={computed.isPassthroughProfile}
initialDescription={initialValues?.description ?? ""}
hasDescription={fs.hasDescription}
branchOptions={computed.branchOptions}
branchesLoading={branchesLoading || (fs.useGitHubUrl && fs.githubBranchesLoading)}
agentProfileOptions={computed.agentProfileOptions}
executorProfileOptions={computed.executorProfileOptions}
agentProfiles={agentProfiles}
agentProfilesLoading={computed.agentProfilesLoading}
executorsLoading={computed.executorsLoading}
isCreatingSession={fs.isCreatingSession}
workflows={workflows}
snapshots={snapshots}
effectiveWorkflowId={computed.effectiveWorkflowId ?? null}
fs={fs}
handleKeyDown={handleKeyDown}
onBranchChange={handlers.handleBranchChange}
onAgentProfileChange={handlers.handleAgentProfileChange}
onExecutorProfileChange={handlers.handleExecutorProfileChange}
onWorkflowChange={handlers.handleWorkflowChange}
hasRepositorySelection={computed.hasRepositorySelection}
/>
<DialogFooter className="border-t border-border pt-3 flex-col gap-3 sm:flex-row sm:gap-2">
<TaskCreateDialogFooter
isSessionMode={isSessionMode}
isCreateMode={isCreateMode}
isEditMode={isEditMode}
isTaskStarted={isTaskStarted}
isPassthroughProfile={computed.isPassthroughProfile}
isCreatingSession={fs.isCreatingSession}
isCreatingTask={fs.isCreatingTask}
hasTitle={fs.hasTitle}
hasDescription={fs.hasDescription}
hasRepositorySelection={computed.hasRepositorySelection}
branch={fs.branch}
agentProfileId={fs.agentProfileId}
workspaceId={workspaceId}
effectiveWorkflowId={computed.effectiveWorkflowId ?? null}
executorHint={computed.executorHint}
onCancel={handleCancel}
onUpdateWithoutAgent={handleUpdateWithoutAgent}
onCreateWithoutAgent={handleCreateWithoutAgent}
onCreateWithPlanMode={handleCreateWithPlanMode}
/>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}