Merge pull request #185 from multica-ai/NevilleQingNY/issue-draft-persist

feat(issues): persist create-issue draft with sidebar indicator
This commit is contained in:
Naiyuan Qing 2026-03-30 15:00:09 +08:00 committed by GitHub
commit 038dac7fcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 88 additions and 18 deletions

View file

@ -16,6 +16,7 @@ import {
SquarePen, SquarePen,
} from "lucide-react"; } from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace"; import { WorkspaceAvatar } from "@/features/workspace";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@ -55,6 +56,12 @@ const workspaceNav = [
{ href: "/settings", label: "Settings", icon: Settings }, { href: "/settings", label: "Settings", icon: Settings },
]; ];
function DraftDot() {
const hasDraft = useIssueDraftStore((s) => !!(s.draft.title || s.draft.description));
if (!hasDraft) return null;
return <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />;
}
export function AppSidebar() { export function AppSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
@ -148,10 +155,11 @@ export function AppSidebar() {
</SidebarMenu> </SidebarMenu>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
className="flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent" className="relative flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
onClick={() => useModalStore.getState().open("create-issue")} onClick={() => useModalStore.getState().open("create-issue")}
> >
<SquarePen className="size-3.5" /> <SquarePen className="size-3.5" />
<DraftDot />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom">New issue</TooltipContent> <TooltipContent side="bottom">New issue</TooltipContent>
</Tooltip> </Tooltip>

View file

@ -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<IssueDraft>) => void;
clearDraft: () => void;
hasDraft: () => boolean;
}
export const useIssueDraftStore = create<IssueDraftStore>()(
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" },
),
);

View file

@ -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 { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useWorkspaceStore, useActorName } from "@/features/workspace"; import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues"; import { useIssueStore } from "@/features/issues";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
import { api } from "@/shared/api"; import { api } from "@/shared/api";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -66,14 +67,18 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
const agents = useWorkspaceStore((s) => s.agents); const agents = useWorkspaceStore((s) => s.agents);
const { getActorName, getActorInitials } = useActorName(); 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<RichTextEditorRef>(null); const descEditorRef = useRef<RichTextEditorRef>(null);
const [status, setStatus] = useState<IssueStatus>((data?.status as IssueStatus) || "todo"); const [status, setStatus] = useState<IssueStatus>((data?.status as IssueStatus) || draft.status);
const [priority, setPriority] = useState<IssuePriority>("none"); const [priority, setPriority] = useState<IssuePriority>(draft.priority);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [assigneeType, setAssigneeType] = useState<IssueAssigneeType | undefined>(); const [assigneeType, setAssigneeType] = useState<IssueAssigneeType | undefined>(draft.assigneeType);
const [assigneeId, setAssigneeId] = useState<string | undefined>(); const [assigneeId, setAssigneeId] = useState<string | undefined>(draft.assigneeId);
const [dueDate, setDueDate] = useState<string | null>(null); const [dueDate, setDueDate] = useState<string | null>(draft.dueDate);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
// Assignee popover // Assignee popover
@ -94,6 +99,16 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
const dueDateObj = dueDate ? new Date(dueDate) : undefined; 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 () => { const handleSubmit = async () => {
if (!title.trim() || submitting) return; if (!title.trim() || submitting) return;
setSubmitting(true); setSubmitting(true);
@ -108,6 +123,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
due_date: dueDate || undefined, due_date: dueDate || undefined,
}); });
useIssueStore.getState().addIssue(issue); useIssueStore.getState().addIssue(issue);
clearDraft();
onClose(); onClose();
} catch { } catch {
toast.error("Failed to create issue"); toast.error("Failed to create issue");
@ -174,7 +190,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
autoFocus autoFocus
type="text" type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => updateTitle(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@ -190,7 +206,10 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
<div className="flex-1 min-h-0 overflow-y-auto px-5"> <div className="flex-1 min-h-0 overflow-y-auto px-5">
<RichTextEditor <RichTextEditor
ref={descEditorRef} ref={descEditorRef}
defaultValue={draft.description}
placeholder="Add description..." placeholder="Add description..."
onUpdate={(md) => setDraft({ description: md })}
debounceMs={500}
/> />
</div> </div>
@ -208,7 +227,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
/> />
<DropdownMenuContent align="start" className="w-44"> <DropdownMenuContent align="start" className="w-44">
{ALL_STATUSES.map((s) => ( {ALL_STATUSES.map((s) => (
<DropdownMenuItem key={s} onClick={() => setStatus(s)}> <DropdownMenuItem key={s} onClick={() => updateStatus(s)}>
<StatusIcon status={s} className="size-3.5" /> <StatusIcon status={s} className="size-3.5" />
<span>{STATUS_CONFIG[s].label}</span> <span>{STATUS_CONFIG[s].label}</span>
</DropdownMenuItem> </DropdownMenuItem>
@ -228,7 +247,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
/> />
<DropdownMenuContent align="start" className="w-44"> <DropdownMenuContent align="start" className="w-44">
{PRIORITY_ORDER.map((p) => ( {PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => setPriority(p)}> <DropdownMenuItem key={p} onClick={() => updatePriority(p)}>
<PriorityIcon priority={p} /> <PriorityIcon priority={p} />
<span>{PRIORITY_CONFIG[p].label}</span> <span>{PRIORITY_CONFIG[p].label}</span>
</DropdownMenuItem> </DropdownMenuItem>
@ -274,8 +293,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setAssigneeType(undefined); updateAssignee(undefined, undefined);
setAssigneeId(undefined);
setAssigneeOpen(false); setAssigneeOpen(false);
}} }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
@ -293,8 +311,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
type="button" type="button"
key={m.user_id} key={m.user_id}
onClick={() => { onClick={() => {
setAssigneeType("member"); updateAssignee("member", m.user_id);
setAssigneeId(m.user_id);
setAssigneeOpen(false); setAssigneeOpen(false);
}} }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
@ -317,8 +334,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
type="button" type="button"
key={a.id} key={a.id}
onClick={() => { onClick={() => {
setAssigneeType("agent"); updateAssignee("agent", a.id);
setAssigneeId(a.id);
setAssigneeOpen(false); setAssigneeOpen(false);
}} }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
@ -358,7 +374,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
mode="single" mode="single"
selected={dueDateObj} selected={dueDateObj}
onSelect={(d: Date | undefined) => { onSelect={(d: Date | undefined) => {
setDueDate(d ? d.toISOString() : null); updateDueDate(d ? d.toISOString() : null);
setDueDateOpen(false); setDueDateOpen(false);
}} }}
/> />
@ -368,7 +384,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
variant="ghost" variant="ghost"
size="xs" size="xs"
onClick={() => { onClick={() => {
setDueDate(null); updateDueDate(null);
setDueDateOpen(false); setDueDateOpen(false);
}} }}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"