Files
kandev/apps/web/components/task-create-dialog-setup.ts
T
2026-09-19 21:26:36 +01:00

630 lines
20 KiB
TypeScript

"use client";
import { FormEvent, useCallback, useState } from "react";
import type { JiraTicket } from "@/lib/types/jira";
import type { LinearIssue } from "@/lib/types/linear";
import type { Repository } from "@/lib/types/http";
import type { RepositoryBranchesState } from "@/lib/state/slices/workspace/types";
import { branchOptionValue } from "@/components/branch-picker-options";
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 { usePromptResultDelivery } from "@/hooks/use-prompt-result-delivery";
import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit";
import { useToast } from "@/components/toast-provider";
import { useRepositorySets } from "@/hooks/domains/workspace/use-repository-sets";
import { useApplyRepositorySet } from "@/components/task-create-dialog-repository-sets-apply";
import { selectedRepositoryIdsForSet } from "@/components/task-create-dialog-repository-sets";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import {
useDialogFormState,
useTaskCreateDialogEffects,
useDialogHandlers,
useLockedFieldSync,
useSessionRepoName,
useTaskCreateDialogData,
computeIsTaskStarted,
type DialogFormState,
} from "@/components/task-create-dialog-state";
import type { TaskCreateDialogProps } from "@/components/task-create-dialog";
import { useResolvedTaskCreateWorkflowContext } from "@/components/task-create-dialog-workflow-context";
import { truncateRemoteTaskTitle } from "@/lib/task-title";
import { t } from "@/lib/i18n";
import { listRepositoryBranchPolicies } from "@/lib/api";
import { useTaskEditDialogDependencies } from "@/hooks/domains/task/use-task-edit-dialog-dependencies";
import {
buildWorkflowAgentOverrideValidation,
type WorkflowAgentOverrideValidation,
} from "@/components/task-create-dialog-workflow-agent-override-validation";
// Catalog key: module scope, so it is resolved at the call site.
const PROMPT_INSERTED_MESSAGE_KEY = "task:enhancedPromptInserted";
function useEnhanceForDialog(
fs: DialogFormState,
taskId: string | null | undefined,
open: boolean,
) {
const isConfigured = useIsUtilityConfigured();
const { toast } = useToast();
const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({
sessionId: null,
taskTitle: fs.taskName,
});
const applyDescription = useCallback(
(value: string) => {
const input = fs.descriptionInputRef.current;
if (!input) return false;
input.setValue(value);
const applied = input.getValue() === value;
if (applied) fs.setHasDescription(value.trim().length > 0);
return applied;
},
[fs],
);
const promptDelivery = usePromptResultDelivery({
scopeKey: `task-create:${open}:${fs.openCycle}:${taskId ?? ""}`,
getCurrent: () => fs.descriptionInputRef.current?.getValue() ?? null,
apply: applyDescription,
});
const onEnhance = useCallback(() => {
const current = fs.descriptionInputRef.current?.getValue() ?? "";
if (!current.trim()) return;
const generation = promptDelivery.captureScope();
void enhancePrompt(current, (result) => {
const inserted = promptDelivery.deliver(current, result, generation);
if (inserted) toast({ description: t(PROMPT_INSERTED_MESSAGE_KEY), variant: "success" });
return inserted;
});
}, [enhancePrompt, fs.descriptionInputRef, promptDelivery, toast]);
return {
onEnhance,
isLoading: isEnhancingPrompt,
isConfigured,
pendingResult: promptDelivery.pendingResult,
onApplyPending: promptDelivery.applyPending,
onCopyPending: promptDelivery.copyPending,
};
}
function useJiraImportHandler(fs: DialogFormState, handleTaskNameChange: (value: string) => void) {
return useCallback(
(ticket: JiraTicket) => {
handleTaskNameChange(truncateRemoteTaskTitle(`[${ticket.key}] ${ticket.summary}`));
const description = ticket.description?.trim()
? `${ticket.description}\n\n---\nJira: ${ticket.url}`
: `Jira: ${ticket.url}`;
fs.descriptionInputRef.current?.setValue(description);
fs.setHasDescription(true);
},
[fs, handleTaskNameChange],
);
}
function useLinearImportHandler(
fs: DialogFormState,
handleTaskNameChange: (value: string) => void,
) {
return useCallback(
(issue: LinearIssue) => {
handleTaskNameChange(truncateRemoteTaskTitle(`[${issue.identifier}] ${issue.title}`));
const description = issue.description?.trim()
? `${issue.description}\n\n---\nLinear: ${issue.url}`
: `Linear: ${issue.url}`;
fs.descriptionInputRef.current?.setValue(description);
fs.setHasDescription(true);
},
[fs, handleTaskNameChange],
);
}
function useEditDialogDependencies(
open: boolean,
isEditMode: boolean,
workspaceId: string | null | undefined,
taskId: string | null | undefined,
) {
return useTaskEditDialogDependencies({
open: open && isEditMode,
workspaceId,
taskId,
});
}
type SubmitWiringArgs = {
props: TaskCreateDialogProps;
fs: ReturnType<typeof useDialogFormState>;
computed: ReturnType<typeof useTaskCreateDialogData>["computed"];
workspaceRepositories: ReturnType<typeof useTaskCreateDialogData>["repositories"];
repositoryLocalPath: string;
isSessionMode: boolean;
isEditMode: boolean;
autoTitle: boolean;
editDependencies: ReturnType<typeof useTaskEditDialogDependencies>;
refreshBranchPolicies: () => Promise<void>;
preserveQueuedLastUsedOnClose: () => void;
workflowAgentOverridesBlockedReason?: string;
};
function useSubmitHandlersWiring({
props,
fs,
computed,
workspaceRepositories,
repositoryLocalPath,
isSessionMode,
isEditMode,
autoTitle,
editDependencies,
refreshBranchPolicies,
preserveQueuedLastUsedOnClose,
workflowAgentOverridesBlockedReason,
}: SubmitWiringArgs) {
const {
workspaceId,
workflowId,
editingTask,
onSuccess,
onCreateSession,
onOpenChange,
createTask,
} = props;
const { parentTaskId } = props;
const taskId = props.taskId ?? null;
return useTaskSubmitHandlers({
isSessionMode,
isEditMode,
autoTitle,
autopilot: fs.autopilot,
isPassthroughProfile: computed.isPassthroughProfile,
taskName: fs.taskName,
workspaceId,
workflowId,
effectiveWorkflowId: computed.effectiveWorkflowId,
repositories: fs.repositories,
repositoriesDirty: fs.repositoriesDirty,
discoveredRepositories: fs.discoveredRepositories,
workspaceRepositories,
useRemote: fs.useRemote,
remoteRepos: fs.remoteRepos,
prInfoByUrl: fs.prInfoByUrl,
agentProfileId: computed.effectiveAgentProfileId,
executorId: fs.executorId,
executorProfileId: fs.executorProfileId,
seededExecutorProfileId: fs.seededExecutorProfileId,
editingTask,
onSuccess,
onCreateSession,
onOpenChange,
createTask,
refreshBranchPolicies,
preserveTaskCreateLastUsedOnClose: preserveQueuedLastUsedOnClose,
taskId,
parentTaskId,
descriptionInputRef: fs.descriptionInputRef,
setIsCreatingSession: fs.setIsCreatingSession,
setIsCreatingTask: fs.setIsCreatingTask,
setHasTitle: fs.setHasTitle,
setHasDescription: fs.setHasDescription,
setTaskName: fs.setTaskName,
setRepositories: fs.setRepositories,
setRemoteRepos: fs.setRemoteRepos,
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,
priority: fs.priority,
workflowAgentOverrides: fs.workflowAgentOverrides,
workflowAgentOverridesBlockedReason,
blockedBy: fs.blockedBy,
editDependencies,
});
}
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 resolveDialogMode(
mode: TaskCreateDialogProps["mode"],
editingTask: TaskCreateDialogProps["editingTask"],
) {
const isSessionMode = mode === "session";
const isEditMode = mode === "edit";
return {
isSessionMode,
isEditMode,
isTaskStarted: computeIsTaskStarted(isEditMode, editingTask),
};
}
function canUseFreshBranch(fs: DialogFormState, isLocalExecutor: boolean): boolean {
return !fs.useRemote && isLocalExecutor && fs.repositories.length === 1;
}
export function hasUnavailableSavedBase(
rows: DialogFormState["repositories"],
repositoryBranches: RepositoryBranchesState,
): boolean {
return rows.some((row) => {
if (!row.repositoryId || !row.baseBranch) return false;
if (!repositoryBranches.loadedByRepositoryId[row.repositoryId]) return false;
const branches = repositoryBranches.itemsByRepositoryId[row.repositoryId] ?? [];
return !branches.some((branch) => branchOptionValue(branch) === row.baseBranch);
});
}
function hasPendingSavedBaseValidation(
rows: DialogFormState["repositories"],
repositoryBranches: RepositoryBranchesState,
): boolean {
return rows.some(
(row) =>
Boolean(row.repositoryId && row.baseBranch) &&
!repositoryBranches.loadedByRepositoryId[row.repositoryId as string],
);
}
function savedBaseSubmitBlockedReason(
hasPendingValidation: boolean,
hasUnavailableBase: boolean,
): string | null {
if (hasPendingValidation) return t("task:loadingBranches2");
if (hasUnavailableBase) return t("task:repositorySetBaseUnavailable");
return null;
}
function savedBaseSubmitBlockedReasonForRows(
rows: DialogFormState["repositories"],
repositoryBranches: RepositoryBranchesState,
): string | null {
return savedBaseSubmitBlockedReason(
hasPendingSavedBaseValidation(rows, repositoryBranches),
hasUnavailableSavedBase(rows, repositoryBranches),
);
}
function useRefreshBranchPolicies(fs: DialogFormState) {
const storeApi = useAppStoreApi();
const setRepositoryBranchPolicies = useAppStore((state) => state.setRepositoryBranchPolicies);
const setRepositoryBranchPoliciesLoading = useAppStore(
(state) => state.setRepositoryBranchPoliciesLoading,
);
return useCallback(async () => {
const repositoryIds = [
...new Set(
fs.repositories
.map((row) => row.repositoryId)
.filter((repositoryId): repositoryId is string => Boolean(repositoryId)),
),
];
await Promise.all(
repositoryIds.map(async (repositoryId) => {
const requestRevision =
storeApi.getState().repositoryBranchPolicies.revisionByRepositoryId[repositoryId] ?? 0;
setRepositoryBranchPoliciesLoading(repositoryId, true);
try {
const response = await listRepositoryBranchPolicies(repositoryId, { cache: "no-store" });
setRepositoryBranchPolicies(
repositoryId,
response.repository_branch_policies,
requestRevision,
);
} catch {
// Keep the original task error visible when recovery cannot refresh.
} finally {
setRepositoryBranchPoliciesLoading(repositoryId, false);
}
}),
);
}, [fs.repositories, setRepositoryBranchPolicies, setRepositoryBranchPoliciesLoading, storeApi]);
}
function useDialogSetupData(
props: TaskCreateDialogProps,
fs: ReturnType<typeof useDialogFormState>,
) {
const { open, workspaceId, workflowId, defaultStepId, initialValues } = props;
const { toast } = useToast();
const upsertWorkspaceRepository = useAppStore((state) => state.upsertRepository);
const repositoryBranches = useAppStore((state) => state.repositoryBranches);
const data = useTaskCreateDialogData({
open,
workspaceId,
workflowId,
defaultStepId,
fs,
lockedWorkflow: props.lockedFields?.workflow === true,
agentProfileRecentUseContext:
props.mode === "session" || props.mode === "edit" ? "task_session" : "task_create",
});
const {
workflows,
agentProfiles,
executors,
repositories,
repositoriesLoading,
taskCreateLastUsed,
userSettingsLoaded,
computed,
} = data;
useTaskCreateDialogEffects(fs, {
open,
workspaceId,
workflowId,
effectiveWorkflowId: computed.effectiveWorkflowId,
repositories,
repositoriesLoading,
agentProfiles,
compatibleAgentProfiles: computed.compatibleAgentProfiles,
authLoaded: computed.authLoaded,
executors,
workspaceDefaults: computed.workspaceDefaults,
toast,
workflows,
isLocalExecutor: computed.isLocalExecutor,
lastUsedRepositoryId: taskCreateLastUsed.repositoryId,
userSettingsLoaded,
lastUsedAgentProfileId: taskCreateLastUsed.agentProfileId,
lastUsedExecutorProfileId: taskCreateLastUsed.executorProfileId,
lastUsedBranch: taskCreateLastUsed.branch,
preserveBranch: initialValues?.checkoutBranch || initialValues?.branch,
editingTaskExecutorProfileId: props.editingTask?.primaryExecutorProfileId,
});
useLockedFieldSync(open, workflowId, initialValues, fs, props.lockedFields?.workflow === true);
const handlers = useDialogHandlers(fs, repositories, {
workspaceId,
executors,
upsertWorkspaceRepository,
});
const savedBaseSubmitBlockedReasonValue = savedBaseSubmitBlockedReasonForRows(
fs.repositories,
repositoryBranches,
);
const refreshBranchPolicies = useRefreshBranchPolicies(fs);
return {
...data,
handlers,
refreshBranchPolicies,
repositoryLocalPath: resolveSingleRowLocalPath(fs, repositories),
savedBaseSubmitBlockedReason: savedBaseSubmitBlockedReasonValue,
};
}
function resolveWorkflowAgentOverrideValidation(
mode: TaskCreateDialogProps["mode"],
workspaceId: string | null | undefined,
fs: ReturnType<typeof useDialogFormState>,
data: ReturnType<typeof useDialogSetupData>,
): WorkflowAgentOverrideValidation {
return buildWorkflowAgentOverrideValidation({
effectiveWorkflowId: data.computed.effectiveWorkflowId,
snapshots: data.snapshots,
workspaceSnapshotRead: data.workspaceSnapshotRead,
workspaceId,
profiles: data.agentProfiles,
replacementOptions: data.computed.agentProfileOptions,
overrides: fs.workflowAgentOverrides,
isCreateMode: mode === "create",
});
}
export function useTaskCreateDialogSetup(
props: TaskCreateDialogProps,
options: { preserveQueuedLastUsedOnClose?: () => void } = {},
) {
const resolvedProps = useResolvedTaskCreateWorkflowContext(props);
const {
open,
mode = "create",
workspaceId,
workflowId,
editingTask,
initialValues,
} = resolvedProps;
const { isSessionMode, isEditMode, isTaskStarted } = resolveDialogMode(mode, editingTask);
const agentGeneratedTaskTitles = useAppStore(
(state) => state.userSettings.agentGeneratedTaskTitles,
);
const autoTitle = mode === "create" && agentGeneratedTaskTitles;
const fs = useDialogFormState(
open,
workspaceId,
workflowId,
initialValues,
resolvedProps.lockedFields?.workflow === true,
);
const editDependencies = useEditDialogDependencies(
open,
isEditMode,
workspaceId,
editingTask?.id ?? null,
);
const sessionRepoName = useSessionRepoName(isSessionMode);
const data = useDialogSetupData(resolvedProps, fs);
const { computed, repositoryLocalPath, refreshBranchPolicies, savedBaseSubmitBlockedReason } =
data;
const workflowAgentOverrideValidation = resolveWorkflowAgentOverrideValidation(
mode,
workspaceId,
fs,
data,
);
const workflowAgentOverridesBlockedReason =
mode === "create" ? workflowAgentOverrideValidation.blockedReason : undefined;
const submitHandlers = useSubmitHandlersWiring({
props: resolvedProps,
fs,
computed,
workspaceRepositories: data.repositories,
repositoryLocalPath,
isSessionMode,
isEditMode,
autoTitle,
editDependencies,
refreshBranchPolicies,
preserveQueuedLastUsedOnClose: options.preserveQueuedLastUsedOnClose ?? (() => undefined),
workflowAgentOverridesBlockedReason,
});
const { guardedHandleSubmit, handleKeyDown } = useDialogSubmitShortcut(
submitHandlers.handleSubmit,
resolvedProps.submitBlockedReason ??
savedBaseSubmitBlockedReason ??
workflowAgentOverridesBlockedReason,
!isTaskStarted && computed.noCompatibleAgent,
);
const enhance = useEnhanceForDialog(fs, resolvedProps.taskId, resolvedProps.open);
const handleJiraImport = useJiraImportHandler(fs, data.handlers.handleTaskNameChange);
const handleLinearImport = useLinearImportHandler(fs, data.handlers.handleTaskNameChange);
const freshBranchAvailable = canUseFreshBranch(fs, computed.isLocalExecutor);
const repositorySets = useDialogRepositorySets(
resolvedProps,
fs,
data.repositories,
computed,
data.userSettingsLoaded,
);
return {
...data,
fs,
isSessionMode,
isEditMode,
isCreateMode: mode === "create",
autoTitle,
isTaskStarted,
sessionRepoName,
computed,
submitHandlers,
handleKeyDown,
freshBranchAvailable,
repositorySets,
guardedHandleSubmit,
enhance,
handleJiraImport,
handleLinearImport,
editDependencies,
savedBaseSubmitBlockedReason,
workflowAgentOverrideValidation,
};
}
function useDialogRepositorySets(
resolvedProps: TaskCreateDialogProps,
fs: DialogFormState,
repositories: Repository[],
computed: ReturnType<typeof useTaskCreateDialogData>["computed"],
userSettingsLoaded: boolean,
) {
return useRepositorySetsForDialog({
workspaceId: resolvedProps.workspaceId ?? null,
open: resolvedProps.open,
rows: fs.repositories,
repositories,
setRepositories: fs.setRepositories,
setRepositoriesDirty: fs.setRepositoriesDirty,
userSettingsLoaded,
isLocalExecutor: computed.isLocalExecutor,
freshBranchEnabled: fs.freshBranchEnabled,
});
}
type RepositorySetsForDialogArgs = {
workspaceId: string | null;
open: boolean;
rows: DialogFormState["repositories"];
repositories: Repository[];
setRepositories: DialogFormState["setRepositories"];
setRepositoriesDirty: DialogFormState["setRepositoriesDirty"];
userSettingsLoaded: boolean;
isLocalExecutor: boolean;
freshBranchEnabled: boolean;
};
/**
* Assembles the repository-set props the picker needs: the workspace's sets, why
* applying one is unavailable, and the apply handler.
*
* Gated on `userSettingsLoaded` because the repository auto-select effect writes
* rows again once user settings arrive; offering the control before then lets a
* user apply a set that autopick immediately overwrites.
*/
function useRepositorySetsForDialog({
workspaceId,
open,
rows,
repositories,
setRepositories,
setRepositoriesDirty,
userSettingsLoaded,
isLocalExecutor,
freshBranchEnabled,
}: RepositorySetsForDialogArgs) {
const { sets } = useRepositorySets(workspaceId, open);
const onApply = useApplyRepositorySet({
rows,
repositories,
setRepositories,
setRepositoriesDirty,
});
const [saveOpen, setSaveOpen] = useState(false);
// Offer "Save as set" only when there is a workspace-repository selection worth
// saving, so the action is never a dead end.
const canSave = Boolean(workspaceId) && selectedRepositoryIdsForSet(rows).length > 0;
if (!userSettingsLoaded) return undefined;
return {
sets,
onApply,
save:
canSave && workspaceId
? {
workspaceId,
rows,
repositories,
isLocalExecutor,
freshBranchEnabled,
open: saveOpen,
setOpen: setSaveOpen,
}
: null,
};
}
function useGuardedSubmit(
handleSubmit: (e: FormEvent) => void,
blockedReason: string | null | undefined,
compatibilityBlocked: boolean,
) {
const blocked = Boolean(blockedReason) || compatibilityBlocked;
return useCallback(
(e: FormEvent) => {
if (blocked) e.preventDefault();
else handleSubmit(e);
},
[blocked, handleSubmit],
);
}
function useDialogSubmitShortcut(
handleSubmit: ReturnType<typeof useSubmitHandlersWiring>["handleSubmit"],
blockedReason: string | null | undefined,
compatibilityBlocked: boolean,
) {
const guardedHandleSubmit = useGuardedSubmit(handleSubmit, blockedReason, compatibilityBlocked);
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
guardedHandleSubmit(event as unknown as FormEvent);
});
return { guardedHandleSubmit, handleKeyDown };
}