diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 1165a9d633..2158319ca3 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -19,7 +19,12 @@ import { useExecutorHint, useExecutorProfileOptions, } from "@/components/task-create-dialog-options"; -import { setLocalStorage } from "@/lib/local-storage"; +import { + setLocalStorage, + getTaskCreateDraft, + setTaskCreateDraft, + removeTaskCreateDraft, +} from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; import type { StepType, @@ -62,55 +67,154 @@ type FormResetters = { setGitHubPrHeadBranch: (v: string | null) => void; }; -function useFormResetEffects( - open: boolean, - workspaceId: string | null, - workflowId: string | null, - initialValues: TaskCreateDialogInitialValues | undefined, - resetters: FormResetters, -) { +type FormResetEffectsArgs = { + open: boolean; + workspaceId: string | null; + workflowId: string | null; + initialValues: TaskCreateDialogInitialValues | undefined; + resetters: FormResetters; + setDraftDescription: (v: string) => void; + setCurrentDefaults: (v: { name: string; description: string }) => void; + setOpenCycle: React.Dispatch>; + prevOpenRef: React.RefObject; +}; + +function useFormResetEffects({ + open, + workspaceId, + workflowId, + initialValues, + resetters, + setDraftDescription, + setCurrentDefaults, + setOpenCycle, + prevOpenRef, +}: FormResetEffectsArgs) { + // Restore draft or initialValues when dialog opens useEffect(() => { - if (!open) return; - const name = initialValues?.title || ""; - void Promise.resolve().then(() => { - resetters.setTaskName(name); - resetters.setHasTitle(name.trim().length > 0); - resetters.setHasDescription(Boolean(initialValues?.description?.trim())); - resetters.setRepositoryId(initialValues?.repositoryId ?? ""); - resetters.setBranch(initialValues?.branch ?? ""); - resetters.setAgentProfileId(""); - resetters.setExecutorId(""); - resetters.setExecutorProfileId(""); - resetters.setSelectedWorkflowId(workflowId); - resetters.setFetchedSteps(null); - }); + // Only run on rising edge (dialog opening) + const wasOpen = prevOpenRef.current; + (prevOpenRef as React.MutableRefObject).current = open; + + if (!open || wasOpen) return; + + // Increment cycle to force TaskFormInputs remount + setOpenCycle((c) => c + 1); + + const defaults = resolveFormDefaults(initialValues, workspaceId); + setCurrentDefaults(defaults); + resetTaskForm(resetters, defaults.name, defaults.description, workflowId, initialValues); + setDraftDescription(defaults.description); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - initialValues?.branch, - initialValues?.description, - initialValues?.repositoryId, - initialValues?.title, - open, - workflowId, - ]); + }, [open, workflowId, workspaceId]); + useEffect(() => { if (!open) return; - void Promise.resolve().then(() => { - resetters.setDiscoveredRepositories([]); - resetters.setDiscoveredRepoPath(""); - resetters.setSelectedLocalRepo(null); - resetters.setLocalBranches([]); - resetters.setDiscoverReposLoaded(false); - resetters.setUseGitHubUrl(false); - resetters.setGitHubUrl(""); - resetters.setGitHubBranches([]); - resetters.setGitHubUrlError(null); - resetters.setGitHubPrHeadBranch(null); - }); + resetDiscoveryState(resetters); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, workspaceId]); } +/** Checks if initialValues has any user-provided content */ +function hasUserContent(initialValues?: TaskCreateDialogInitialValues): boolean { + const title = initialValues?.title ?? ""; + const description = initialValues?.description ?? ""; + return title.trim().length > 0 || description.trim().length > 0; +} + +/** Resolves form defaults from draft (for create) or initialValues (for edit) */ +function resolveFormDefaults( + initialValues: TaskCreateDialogInitialValues | undefined, + workspaceId: string | null, +) { + // In edit mode (has content), use initialValues; in create mode, try draft + const draft = + !hasUserContent(initialValues) && workspaceId ? getTaskCreateDraft(workspaceId) : null; + const initTitle = initialValues?.title ?? ""; + const initDesc = initialValues?.description ?? ""; + return { + name: draft?.title ?? initTitle, + description: draft?.description ?? initDesc, + }; +} + +/** Resets task form fields to specified values */ +function resetTaskForm( + resetters: FormResetters, + name: string, + description: string, + workflowId: string | null, + initialValues?: TaskCreateDialogInitialValues, +) { + resetters.setTaskName(name); + resetters.setHasTitle(name.trim().length > 0); + resetters.setHasDescription(description.trim().length > 0); + resetters.setRepositoryId(initialValues?.repositoryId ?? ""); + resetters.setBranch(initialValues?.branch ?? ""); + resetters.setAgentProfileId(""); + resetters.setExecutorId(""); + resetters.setExecutorProfileId(""); + resetters.setSelectedWorkflowId(workflowId); + resetters.setFetchedSteps(null); +} + +/** Resets repository discovery state */ +function resetDiscoveryState(resetters: FormResetters) { + resetters.setDiscoveredRepositories([]); + resetters.setDiscoveredRepoPath(""); + resetters.setSelectedLocalRepo(null); + resetters.setLocalBranches([]); + resetters.setDiscoverReposLoaded(false); + resetters.setUseGitHubUrl(false); + resetters.setGitHubUrl(""); + resetters.setGitHubBranches([]); + resetters.setGitHubUrlError(null); + resetters.setGitHubPrHeadBranch(null); +} + +/** Hook to manage draft persistence for task creation dialog */ +function useDraftPersistence( + open: boolean, + workspaceId: string | null, + initialValues: TaskCreateDialogInitialValues | undefined, + taskName: string, + descriptionInputRef: React.RefObject<{ getValue: () => string } | null>, +) { + const wasOpenRef = useRef(false); + const skipDraftSaveRef = useRef(false); + + // Save draft when dialog closes (only in create mode without initialValues) + useEffect(() => { + const wasOpen = wasOpenRef.current; + wasOpenRef.current = open; + + if (!wasOpen || open || !workspaceId) return; + // Skip if clearDraft was called (successful submission) + if (skipDraftSaveRef.current) { + skipDraftSaveRef.current = false; + return; + } + const hasInitialValues = Boolean( + initialValues?.title?.trim() || initialValues?.description?.trim(), + ); + // Only save draft in create mode + if (!hasInitialValues) { + const currentDescription = descriptionInputRef.current?.getValue() ?? ""; + setTaskCreateDraft(workspaceId, { title: taskName, description: currentDescription }); + } + }, [open, workspaceId, initialValues, taskName, descriptionInputRef]); + + // Clear draft (call on successful submission before closing dialog) + const clearDraft = useCallback(() => { + if (workspaceId) { + removeTaskCreateDraft(workspaceId); + skipDraftSaveRef.current = true; + } + }, [workspaceId]); + + return { clearDraft }; +} + function useGitHubUrlState() { const [useGitHubUrl, setUseGitHubUrl] = useState(false); const [githubUrl, setGitHubUrl] = useState(""); @@ -134,55 +238,39 @@ function useGitHubUrlState() { }; } -export function useDialogFormState( - open: boolean, - workspaceId: string | null, +/** Core form state declarations */ +function useFormStateValues( workflowId: string | null, + workspaceId: string | null, + open: boolean, initialValues?: TaskCreateDialogInitialValues, ) { + // openCycle increments each time dialog opens - used in key to force TaskFormInputs remount + const [openCycle, setOpenCycle] = useState(0); + const prevOpenRef = useRef(open); + + // currentDefaults stores the loaded draft/initial values for this open cycle + const [currentDefaults, setCurrentDefaults] = useState<{ name: string; description: string }>({ + name: "", + description: "", + }); + + // These states are initialized with defaults and then managed by effects/handlers const [taskName, setTaskName] = useState(""); - const [hasTitle, setHasTitle] = useState(Boolean(initialValues?.title?.trim())); - const [hasDescription, setHasDescription] = useState(Boolean(initialValues?.description?.trim())); + const [hasTitle, setHasTitle] = useState(false); + const [hasDescription, setHasDescription] = useState(false); + const [draftDescription, setDraftDescription] = useState(""); + const descriptionInputRef = useRef<{ getValue: () => string } | null>(null); const [repositoryId, setRepositoryId] = useState(initialValues?.repositoryId ?? ""); const [branch, setBranch] = useState(initialValues?.branch ?? ""); const [agentProfileId, setAgentProfileId] = useState(""); const [executorId, setExecutorId] = useState(""); const [executorProfileId, setExecutorProfileId] = useState(""); - const [discoveredRepositories, setDiscoveredRepositories] = useState([]); - const [discoveredRepoPath, setDiscoveredRepoPath] = useState(""); - const [selectedLocalRepo, setSelectedLocalRepo] = useState(null); - const [localBranches, setLocalBranches] = useState([]); - const [localBranchesLoading, setLocalBranchesLoading] = useState(false); - const [discoverReposLoading, setDiscoverReposLoading] = useState(false); - const [discoverReposLoaded, setDiscoverReposLoaded] = useState(false); const [selectedWorkflowId, setSelectedWorkflowId] = useState(workflowId); const [fetchedSteps, setFetchedSteps] = useState(null); const [isCreatingSession, setIsCreatingSession] = useState(false); const [isCreatingTask, setIsCreatingTask] = useState(false); - const ghUrl = useGitHubUrlState(); - useFormResetEffects(open, workspaceId, workflowId, initialValues, { - setTaskName, - setHasTitle, - setHasDescription, - setRepositoryId, - setBranch, - setAgentProfileId, - setExecutorId, - setExecutorProfileId, - setSelectedWorkflowId, - setFetchedSteps, - setDiscoveredRepositories, - setDiscoveredRepoPath, - setSelectedLocalRepo, - setLocalBranches, - setDiscoverReposLoaded, - setUseGitHubUrl: ghUrl.setUseGitHubUrl, - setGitHubUrl: ghUrl.setGitHubUrl, - setGitHubBranches: ghUrl.setGitHubBranches, - setGitHubUrlError: ghUrl.setGitHubUrlError, - setGitHubPrHeadBranch: ghUrl.setGitHubPrHeadBranch, - }); return { taskName, setTaskName, @@ -190,6 +278,8 @@ export function useDialogFormState( setHasTitle, hasDescription, setHasDescription, + draftDescription, + setDraftDescription, descriptionInputRef, repositoryId, setRepositoryId, @@ -201,6 +291,32 @@ export function useDialogFormState( setExecutorId, executorProfileId, setExecutorProfileId, + selectedWorkflowId, + setSelectedWorkflowId, + fetchedSteps, + setFetchedSteps, + isCreatingSession, + setIsCreatingSession, + isCreatingTask, + setIsCreatingTask, + openCycle, + setOpenCycle, + currentDefaults, + setCurrentDefaults, + prevOpenRef, + }; +} + +/** Repository discovery state */ +function useDiscoveryState() { + const [discoveredRepositories, setDiscoveredRepositories] = useState([]); + const [discoveredRepoPath, setDiscoveredRepoPath] = useState(""); + const [selectedLocalRepo, setSelectedLocalRepo] = useState(null); + const [localBranches, setLocalBranches] = useState([]); + const [localBranchesLoading, setLocalBranchesLoading] = useState(false); + const [discoverReposLoading, setDiscoverReposLoading] = useState(false); + const [discoverReposLoaded, setDiscoverReposLoaded] = useState(false); + return { discoveredRepositories, setDiscoveredRepositories, discoveredRepoPath, @@ -215,18 +331,63 @@ export function useDialogFormState( setDiscoverReposLoading, discoverReposLoaded, setDiscoverReposLoaded, - selectedWorkflowId, - setSelectedWorkflowId, - fetchedSteps, - setFetchedSteps, - isCreatingSession, - setIsCreatingSession, - isCreatingTask, - setIsCreatingTask, - ...ghUrl, }; } +export function useDialogFormState( + open: boolean, + workspaceId: string | null, + workflowId: string | null, + initialValues?: TaskCreateDialogInitialValues, +) { + const form = useFormStateValues(workflowId, workspaceId, open, initialValues); + const discovery = useDiscoveryState(); + const ghUrl = useGitHubUrlState(); + + useFormResetEffects({ + open, + workspaceId, + workflowId, + initialValues, + setDraftDescription: form.setDraftDescription, + setCurrentDefaults: form.setCurrentDefaults, + setOpenCycle: form.setOpenCycle, + prevOpenRef: form.prevOpenRef, + resetters: { + setTaskName: form.setTaskName, + setHasTitle: form.setHasTitle, + setHasDescription: form.setHasDescription, + setRepositoryId: form.setRepositoryId, + setBranch: form.setBranch, + setAgentProfileId: form.setAgentProfileId, + setExecutorId: form.setExecutorId, + setExecutorProfileId: form.setExecutorProfileId, + setSelectedWorkflowId: form.setSelectedWorkflowId, + setFetchedSteps: form.setFetchedSteps, + setDiscoveredRepositories: discovery.setDiscoveredRepositories, + setDiscoveredRepoPath: discovery.setDiscoveredRepoPath, + setSelectedLocalRepo: discovery.setSelectedLocalRepo, + setLocalBranches: discovery.setLocalBranches, + setDiscoverReposLoaded: discovery.setDiscoverReposLoaded, + setUseGitHubUrl: ghUrl.setUseGitHubUrl, + setGitHubUrl: ghUrl.setGitHubUrl, + setGitHubBranches: ghUrl.setGitHubBranches, + setGitHubUrlError: ghUrl.setGitHubUrlError, + setGitHubPrHeadBranch: ghUrl.setGitHubPrHeadBranch, + }, + }); + + const { clearDraft } = useDraftPersistence( + open, + workspaceId, + initialValues, + form.taskName, + form.descriptionInputRef, + ); + + return { ...form, ...discovery, ...ghUrl, clearDraft }; +} + export type { DialogFormState } from "@/components/task-create-dialog-types"; export { computePassthroughProfile, diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index 264a779b27..9a332727a1 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -9,6 +9,7 @@ import { launchSession } from "@/lib/services/session-launch-service"; import { buildStartRequest } from "@/lib/services/session-launch-helpers"; import { useToast } from "@/components/toast-provider"; import { linkToSession } from "@/lib/links"; + import { activatePlanMode, buildCreateTaskPayload, @@ -64,6 +65,7 @@ export type SubmitHandlersDeps = { setExecutorId: (v: string) => void; setSelectedWorkflowId: (v: string | null) => void; setFetchedSteps: (v: null) => void; + clearDraft: () => void; }; // eslint-disable-next-line max-lines-per-function @@ -102,6 +104,7 @@ export function useTaskSubmitHandlers({ setExecutorId, setSelectedWorkflowId, setFetchedSteps, + clearDraft, }: SubmitHandlersDeps) { const router = useRouter(); const { toast } = useToast(); @@ -314,6 +317,7 @@ export function useTaskSubmitHandlers({ ); const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null; onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId }); + clearDraft(); onOpenChange(false); if (planMode && newSessionId) { activatePlanMode({ @@ -336,6 +340,7 @@ export function useTaskSubmitHandlers({ isPassthroughProfile, onSuccess, onOpenChange, + clearDraft, setActiveDocument, setPlanMode, router, @@ -361,6 +366,7 @@ export function useTaskSubmitHandlers({ ); const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null; onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId }); + clearDraft(); onOpenChange(false); if (newSessionId) { activatePlanMode({ @@ -380,6 +386,7 @@ export function useTaskSubmitHandlers({ executorProfileId, onSuccess, onOpenChange, + clearDraft, setActiveDocument, setPlanMode, router, @@ -561,6 +568,7 @@ export function useTaskSubmitHandlers({ executor_profile_id: executorProfileId || undefined, }); onSuccess?.(taskResponse, "create"); + clearDraft(); onOpenChange(false); } catch (error) { toast({ @@ -585,6 +593,7 @@ export function useTaskSubmitHandlers({ getRepositoriesPayload, onSuccess, onOpenChange, + clearDraft, toast, descriptionInputRef, setIsCreatingTask, diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index 4c14d1bf3a..076144ccce 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -87,6 +87,12 @@ export type DialogFormState = { setHasTitle: (v: boolean) => void; hasDescription: boolean; setHasDescription: (v: boolean) => void; + /** Restored draft description, used as initialDescription for TaskFormInputs */ + draftDescription: string; + /** Cycle counter incremented each time dialog opens - used in key for remount */ + openCycle: number; + /** Computed defaults for current open cycle (includes draft restoration) */ + currentDefaults: { name: string; description: string }; descriptionInputRef: import("react").RefObject<{ getValue: () => string } | null>; repositoryId: string; setRepositoryId: (v: string) => void; @@ -132,4 +138,6 @@ export type DialogFormState = { setGitHubUrlError: (v: string | null) => void; githubPrHeadBranch: string | null; setGitHubPrHeadBranch: (v: string | null) => void; + /** Clear draft on successful submission (before closing dialog) */ + clearDraft: () => void; }; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index e4684883ab..b381fc26b4 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -230,7 +230,6 @@ function DialogHeaderContent(props: DialogHeaderContentProps) { } type DialogFormBodyProps = { - open: boolean; isSessionMode: boolean; isCreateMode: boolean; isTaskStarted: boolean; @@ -262,7 +261,6 @@ type DialogFormBodyProps = { }; function DialogFormBody({ - open, isSessionMode, isCreateMode, isTaskStarted, @@ -291,7 +289,7 @@ function DialogFormBody({ return (
{ submitHandlers.handleSubmit(event as unknown as FormEvent); @@ -491,12 +490,11 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
(`${TASK_CREATE_DRAFT_KEY}.${workspaceId}`, null); +} + +/** + * Save a task creation draft for a workspace. + * @param workspaceId - The workspace ID + * @param draft - The draft data to save + */ +export function setTaskCreateDraft(workspaceId: string, draft: TaskCreateDraft): void { + if (!workspaceId) return; + // Only save if there's actual content + if (!draft.title.trim() && !draft.description.trim()) { + removeTaskCreateDraft(workspaceId); + return; + } + setSessionStorage(`${TASK_CREATE_DRAFT_KEY}.${workspaceId}`, draft); +} + +/** + * Remove the task creation draft for a workspace. + * @param workspaceId - The workspace ID + */ +export function removeTaskCreateDraft(workspaceId: string): void { + if (!workspaceId) return; + removeSessionStorage(`${TASK_CREATE_DRAFT_KEY}.${workspaceId}`); +}