From bf379b2e76a7f291c352f31e0cabe61cf2fa97b4 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:57:29 +0800 Subject: [PATCH] feat(issues): persist create-issue draft with sidebar indicator - Add zustand draft store with localStorage persistence - Restore draft fields when reopening create-issue modal - Clear draft only on successful submission - Show brand-colored dot on sidebar new-issue button when draft exists Co-Authored-By: Claude Opus 4.6 (1M context) --- .../(dashboard)/_components/app-sidebar.tsx | 10 +++- .../web/features/issues/stores/draft-store.ts | 46 +++++++++++++++++ apps/web/features/modals/create-issue.tsx | 50 ++++++++++++------- 3 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 apps/web/features/issues/stores/draft-store.ts diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index a8d3485e..7447b2d5 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -16,6 +16,7 @@ import { SquarePen, } from "lucide-react"; import { WorkspaceAvatar } from "@/features/workspace"; +import { useIssueDraftStore } from "@/features/issues/stores/draft-store"; import { Sidebar, SidebarContent, @@ -55,6 +56,12 @@ const workspaceNav = [ { href: "/settings", label: "Settings", icon: Settings }, ]; +function DraftDot() { + const hasDraft = useIssueDraftStore((s) => !!(s.draft.title || s.draft.description)); + if (!hasDraft) return null; + return ; +} + export function AppSidebar() { const pathname = usePathname(); const router = useRouter(); @@ -148,10 +155,11 @@ export function AppSidebar() { useModalStore.getState().open("create-issue")} > + New issue diff --git a/apps/web/features/issues/stores/draft-store.ts b/apps/web/features/issues/stores/draft-store.ts new file mode 100644 index 00000000..6fe00b1d --- /dev/null +++ b/apps/web/features/issues/stores/draft-store.ts @@ -0,0 +1,46 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { IssueStatus, IssuePriority, IssueAssigneeType } from "@/shared/types"; + +interface IssueDraft { + title: string; + description: string; + status: IssueStatus; + priority: IssuePriority; + assigneeType?: IssueAssigneeType; + assigneeId?: string; + dueDate: string | null; +} + +const EMPTY_DRAFT: IssueDraft = { + title: "", + description: "", + status: "todo", + priority: "none", + assigneeType: undefined, + assigneeId: undefined, + dueDate: null, +}; + +interface IssueDraftStore { + draft: IssueDraft; + setDraft: (patch: Partial) => void; + clearDraft: () => void; + hasDraft: () => boolean; +} + +export const useIssueDraftStore = create()( + persist( + (set, get) => ({ + draft: { ...EMPTY_DRAFT }, + setDraft: (patch) => + set((s) => ({ draft: { ...s.draft, ...patch } })), + clearDraft: () => set({ draft: { ...EMPTY_DRAFT } }), + hasDraft: () => { + const { draft } = get(); + return !!(draft.title || draft.description); + }, + }), + { name: "multica_issue_draft" }, + ), +); diff --git a/apps/web/features/modals/create-issue.tsx b/apps/web/features/modals/create-issue.tsx index 18d4c921..9ce70cb6 100644 --- a/apps/web/features/modals/create-issue.tsx +++ b/apps/web/features/modals/create-issue.tsx @@ -30,6 +30,7 @@ import { StatusIcon, PriorityIcon } from "@/features/issues/components"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; import { useIssueStore } from "@/features/issues"; +import { useIssueDraftStore } from "@/features/issues/stores/draft-store"; import { api } from "@/shared/api"; // --------------------------------------------------------------------------- @@ -66,14 +67,18 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? const agents = useWorkspaceStore((s) => s.agents); const { getActorName, getActorInitials } = useActorName(); - const [title, setTitle] = useState(""); + const draft = useIssueDraftStore((s) => s.draft); + const setDraft = useIssueDraftStore((s) => s.setDraft); + const clearDraft = useIssueDraftStore((s) => s.clearDraft); + + const [title, setTitle] = useState(draft.title); const descEditorRef = useRef(null); - const [status, setStatus] = useState((data?.status as IssueStatus) || "todo"); - const [priority, setPriority] = useState("none"); + const [status, setStatus] = useState((data?.status as IssueStatus) || draft.status); + const [priority, setPriority] = useState(draft.priority); const [submitting, setSubmitting] = useState(false); - const [assigneeType, setAssigneeType] = useState(); - const [assigneeId, setAssigneeId] = useState(); - const [dueDate, setDueDate] = useState(null); + const [assigneeType, setAssigneeType] = useState(draft.assigneeType); + const [assigneeId, setAssigneeId] = useState(draft.assigneeId); + const [dueDate, setDueDate] = useState(draft.dueDate); const [isExpanded, setIsExpanded] = useState(false); // Assignee popover @@ -94,6 +99,16 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? const dueDateObj = dueDate ? new Date(dueDate) : undefined; + // Sync field changes to draft store + const updateTitle = (v: string) => { setTitle(v); setDraft({ title: v }); }; + const updateStatus = (v: IssueStatus) => { setStatus(v); setDraft({ status: v }); }; + const updatePriority = (v: IssuePriority) => { setPriority(v); setDraft({ priority: v }); }; + const updateAssignee = (type?: IssueAssigneeType, id?: string) => { + setAssigneeType(type); setAssigneeId(id); + setDraft({ assigneeType: type, assigneeId: id }); + }; + const updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); }; + const handleSubmit = async () => { if (!title.trim() || submitting) return; setSubmitting(true); @@ -108,6 +123,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? due_date: dueDate || undefined, }); useIssueStore.getState().addIssue(issue); + clearDraft(); onClose(); } catch { toast.error("Failed to create issue"); @@ -174,7 +190,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? autoFocus type="text" value={title} - onChange={(e) => setTitle(e.target.value)} + onChange={(e) => updateTitle(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -190,7 +206,10 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
setDraft({ description: md })} + debounceMs={500} />
@@ -208,7 +227,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? /> {ALL_STATUSES.map((s) => ( - setStatus(s)}> + updateStatus(s)}> {STATUS_CONFIG[s].label} @@ -228,7 +247,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? /> {PRIORITY_ORDER.map((p) => ( - setPriority(p)}> + updatePriority(p)}> {PRIORITY_CONFIG[p].label} @@ -274,8 +293,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?