fix(ui): persist task creation draft when modal closes (#455)

This commit is contained in:
João Salavisa
2026-03-23 21:16:41 +00:00
committed by GitHub
parent 7397916562
commit b1c848ed80
5 changed files with 315 additions and 91 deletions
+247 -86
View File
@@ -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<React.SetStateAction<number>>;
prevOpenRef: React.RefObject<boolean>;
};
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<boolean>).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<LocalRepository[]>([]);
const [discoveredRepoPath, setDiscoveredRepoPath] = useState("");
const [selectedLocalRepo, setSelectedLocalRepo] = useState<LocalRepository | null>(null);
const [localBranches, setLocalBranches] = useState<Branch[]>([]);
const [localBranchesLoading, setLocalBranchesLoading] = useState(false);
const [discoverReposLoading, setDiscoverReposLoading] = useState(false);
const [discoverReposLoaded, setDiscoverReposLoaded] = useState(false);
const [selectedWorkflowId, setSelectedWorkflowId] = useState(workflowId);
const [fetchedSteps, setFetchedSteps] = useState<StepType[] | null>(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<LocalRepository[]>([]);
const [discoveredRepoPath, setDiscoveredRepoPath] = useState("");
const [selectedLocalRepo, setSelectedLocalRepo] = useState<LocalRepository | null>(null);
const [localBranches, setLocalBranches] = useState<Branch[]>([]);
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,
@@ -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,
@@ -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;
};
+3 -5
View File
@@ -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 (
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<TaskFormInputs
key={`${open}-${initialDescription}`}
key={fs.openCycle}
isSessionMode={isSessionMode}
autoFocus={isTaskStarted ? false : true}
initialDescription={initialDescription}
@@ -429,6 +427,7 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
setExecutorId: fs.setExecutorId,
setSelectedWorkflowId: fs.setSelectedWorkflowId,
setFetchedSteps: fs.setFetchedSteps,
clearDraft: fs.clearDraft,
});
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
submitHandlers.handleSubmit(event as unknown as FormEvent);
@@ -491,12 +490,11 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
</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 ?? ""}
initialDescription={fs.currentDefaults.description}
hasDescription={fs.hasDescription}
branchOptions={computed.branchOptions}
branchesLoading={branchesLoading || (fs.useGitHubUrl && fs.githubBranchesLoading)}
+48
View File
@@ -610,3 +610,51 @@ export function cleanupTaskStorage(taskId: string, sessionIds: string[]): void {
removeSessionStorage(`kandev.comments.${sessionId}`);
}
}
// --- Task creation draft persistence (sessionStorage, per workspace) ---
const TASK_CREATE_DRAFT_KEY = "kandev.taskCreateDraft";
/**
* Draft data for task creation dialog.
* Only persists user-entered content fields (title, description).
* Other fields (repo, branch, agent) are handled by "last used" localStorage.
*/
export type TaskCreateDraft = {
title: string;
description: string;
};
/**
* Get the saved task creation draft for a workspace.
* @param workspaceId - The workspace ID
* @returns The draft data, or null if no draft exists
*/
export function getTaskCreateDraft(workspaceId: string): TaskCreateDraft | null {
if (!workspaceId) return null;
return getSessionStorage<TaskCreateDraft | null>(`${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}`);
}