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,
} 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 <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />;
}
export function AppSidebar() {
const pathname = usePathname();
const router = useRouter();
@ -148,10 +155,11 @@ export function AppSidebar() {
</SidebarMenu>
<Tooltip>
<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")}
>
<SquarePen className="size-3.5" />
<DraftDot />
</TooltipTrigger>
<TooltipContent side="bottom">New issue</TooltipContent>
</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 { 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<RichTextEditorRef>(null);
const [status, setStatus] = useState<IssueStatus>((data?.status as IssueStatus) || "todo");
const [priority, setPriority] = useState<IssuePriority>("none");
const [status, setStatus] = useState<IssueStatus>((data?.status as IssueStatus) || draft.status);
const [priority, setPriority] = useState<IssuePriority>(draft.priority);
const [submitting, setSubmitting] = useState(false);
const [assigneeType, setAssigneeType] = useState<IssueAssigneeType | undefined>();
const [assigneeId, setAssigneeId] = useState<string | undefined>();
const [dueDate, setDueDate] = useState<string | null>(null);
const [assigneeType, setAssigneeType] = useState<IssueAssigneeType | undefined>(draft.assigneeType);
const [assigneeId, setAssigneeId] = useState<string | undefined>(draft.assigneeId);
const [dueDate, setDueDate] = useState<string | null>(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?
<div className="flex-1 min-h-0 overflow-y-auto px-5">
<RichTextEditor
ref={descEditorRef}
defaultValue={draft.description}
placeholder="Add description..."
onUpdate={(md) => setDraft({ description: md })}
debounceMs={500}
/>
</div>
@ -208,7 +227,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
/>
<DropdownMenuContent align="start" className="w-44">
{ALL_STATUSES.map((s) => (
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
<DropdownMenuItem key={s} onClick={() => updateStatus(s)}>
<StatusIcon status={s} className="size-3.5" />
<span>{STATUS_CONFIG[s].label}</span>
</DropdownMenuItem>
@ -228,7 +247,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
/>
<DropdownMenuContent align="start" className="w-44">
{PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => setPriority(p)}>
<DropdownMenuItem key={p} onClick={() => updatePriority(p)}>
<PriorityIcon priority={p} />
<span>{PRIORITY_CONFIG[p].label}</span>
</DropdownMenuItem>
@ -274,8 +293,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
<button
type="button"
onClick={() => {
setAssigneeType(undefined);
setAssigneeId(undefined);
updateAssignee(undefined, undefined);
setAssigneeOpen(false);
}}
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"
key={m.user_id}
onClick={() => {
setAssigneeType("member");
setAssigneeId(m.user_id);
updateAssignee("member", m.user_id);
setAssigneeOpen(false);
}}
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"
key={a.id}
onClick={() => {
setAssigneeType("agent");
setAssigneeId(a.id);
updateAssignee("agent", a.id);
setAssigneeOpen(false);
}}
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"
selected={dueDateObj}
onSelect={(d: Date | undefined) => {
setDueDate(d ? d.toISOString() : null);
updateDueDate(d ? d.toISOString() : null);
setDueDateOpen(false);
}}
/>
@ -368,7 +384,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
variant="ghost"
size="xs"
onClick={() => {
setDueDate(null);
updateDueDate(null);
setDueDateOpen(false);
}}
className="text-muted-foreground hover:text-foreground"