"use client"; import { useState, useRef, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigation } from "../navigation"; import { AlertTriangle, ArrowDown, ArrowLeftRight, ArrowUp, CalendarClock, Check, ChevronRight, Maximize2, Minimize2, MoreHorizontal, X as XIcon, } from "lucide-react"; import { cn } from "@multica/ui/lib/utils"; import { toast } from "sonner"; import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, Attachment } from "@multica/core/types"; import { contentReferencesAttachment } from "@multica/core/types"; import { DialogContent, DialogTitle, } from "@multica/ui/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@multica/ui/components/ui/dropdown-menu"; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@multica/ui/components/ui/tooltip"; import { Button } from "@multica/ui/components/ui/button"; import { Switch } from "@multica/ui/components/ui/switch"; import { ContentEditor, type ContentEditorRef, TitleEditor, useFileDropZone, FileDropOverlay } from "../editor"; import { StatusIcon, StatusPicker, PriorityPicker, AssigneePicker, StartDatePicker, DueDatePicker } from "../issues/components"; import { BacklogAgentHintContent } from "../issues/components/backlog-agent-hint-dialog"; import { ProjectPicker } from "../projects/components/project-picker"; import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; import { useWorkspaceId } from "@multica/core/hooks"; import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store"; import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store"; import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store"; import { issueDetailOptions } from "@multica/core/issues/queries"; import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations"; import { useFileUpload } from "@multica/core/hooks/use-file-upload"; import { api, ApiError, DuplicateIssueErrorBodySchema, type DuplicateIssueErrorBody, parseWithFallback, } from "@multica/core/api"; import { FileUploadButton } from "@multica/ui/components/common/file-upload-button"; import { PillButton } from "../common/pill-button"; import { IssuePickerModal } from "./issue-picker-modal"; import { useT } from "../i18n"; function toDraftAttachment(attachment: Attachment): Attachment { return { ...attachment, // `download_url` is minted for the current API response and may be a // short-lived signed URL. Drafts survive across dialog closes and app // restarts, so persist only durable fields and let render/download paths // re-resolve through id/markdown_url when needed. download_url: "", }; } // --------------------------------------------------------------------------- // ManualCreatePanel — manual-mode body of the create-issue dialog. Renders // DialogContent + everything inside; the surrounding `` is owned by // CreateIssueDialog so mode switching swaps only the inner panel without // remounting the Dialog Root (no overlay flash). `onSwitchMode` flips the // shell's local mode state. // --------------------------------------------------------------------------- export function ManualCreatePanel({ onClose, onSwitchMode, data, isExpanded, setIsExpanded, backlogHintIssueId, setBacklogHintIssueId, }: { onClose: () => void; /** Called with the carry payload to seed the agent panel after switch. */ onSwitchMode?: (carry?: Record | null) => void; data?: Record | null; /** Lifted to the shell so DialogContent's mode-aware className can react * without the body itself having to live inside DialogContent (which would * re-mount the Portal on mode swap and replay the open animation). */ isExpanded: boolean; setIsExpanded: (v: boolean) => void; backlogHintIssueId: string | null; setBacklogHintIssueId: (id: string | null) => void; }) { const { t } = useT("modals"); const router = useNavigation(); const p = useWorkspacePaths(); const workspaceName = useCurrentWorkspace()?.name; const draft = useIssueDraftStore((s) => s.draft); const setDraft = useIssueDraftStore((s) => s.setDraft); const clearDraft = useIssueDraftStore((s) => s.clearDraft); const setLastAssignee = useIssueDraftStore((s) => s.setLastAssignee); const setLastMode = useCreateModeStore((s) => s.setLastMode); const keepOpen = useQuickCreateStore((s) => s.keepOpen); const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen); const [title, setTitle] = useState(draft.title); const [formResetKey, setFormResetKey] = useState(0); const descEditorRef = useRef(null); const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({ onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)), }); 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(() => { if (data && "assignee_type" in data) { return (data.assignee_type as IssueAssigneeType | null) ?? undefined; } return draft.assigneeType; }); const [assigneeId, setAssigneeId] = useState(() => { if (data && "assignee_id" in data) { return (data.assignee_id as string | null) ?? undefined; } return draft.assigneeId; }); const [startDate, setStartDate] = useState(draft.startDate); const [dueDate, setDueDate] = useState(draft.dueDate); const [projectId, setProjectId] = useState( (data?.project_id as string) || undefined, ); const [parentIssueId, setParentIssueId] = useState( (data?.parent_issue_id as string) || undefined, ); const [parentPickerOpen, setParentPickerOpen] = useState(false); // Start date is a low-frequency field — by default it lives in the // overflow ⋯ menu. Clicking the menu item flips this open, which both // mounts the inline pill (the popover's anchor) AND opens the calendar. // When the popover closes without a value set, the pill unmounts again. const [startDatePickerOpen, setStartDatePickerOpen] = useState(false); // Children live as full Issue objects — the picker always returns the whole // object, and we never need to hydrate from an ID the way we do for parent. const [childIssues, setChildIssues] = useState([]); const [childPickerOpen, setChildPickerOpen] = useState(false); // Fetch parent issue details for the chip (status/identifier/title). // List cache usually has it already, so this resolves synchronously. const wsId = useWorkspaceId(); const { data: parentIssue } = useQuery({ ...issueDetailOptions(wsId, parentIssueId ?? ""), enabled: !!parentIssueId, }); const draftAttachments = draft.attachments ?? []; // Prune draft attachments whose markdown reference was deleted in an // earlier editing session. Runs once on mount: at that point the persisted // description IS the draft body (no editor edits have happened yet), so // dropping unreferenced records is safe. Don't prune on description updates // — an onUpdate flush can race a just-finished upload whose markdown link // hasn't been inserted yet, and pruning there would drop a live attachment. useEffect(() => { const { draft: current } = useIssueDraftStore.getState(); const attachments = current.attachments ?? []; const kept = attachments.filter((a) => contentReferencesAttachment(current.description, a), ); if (kept.length !== attachments.length) setDraft({ attachments: kept }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const { uploadWithToast } = useFileUpload(api); const handleUpload = async (file: File) => { const result = await uploadWithToast(file); if (result) { const currentAttachments = useIssueDraftStore.getState().draft.attachments ?? []; const attachments = currentAttachments.some((a) => a.id === result.id) ? currentAttachments : [...currentAttachments, toDraftAttachment(result)]; setDraft({ attachments }); } return result; }; // 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 updateStartDate = (v: string | null) => { setStartDate(v); setDraft({ startDate: v }); }; const updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); }; const createIssueMutation = useCreateIssue(); const updateIssueMutation = useUpdateIssue(); const resetForNextIssue = () => { setTitle(""); setStatus("todo"); setPriority("none"); setStartDate(null); setDueDate(null); setProjectId(undefined); setParentIssueId(undefined); setChildIssues([]); setDraft({ title: "", description: "", status: "todo", priority: "none", assigneeType, assigneeId, startDate: null, dueDate: null, attachments: [], }); descEditorRef.current?.clearContent(); setFormResetKey((key) => key + 1); }; const handleSubmit = async () => { if (!title.trim() || submitting) return; setSubmitting(true); try { const description = descEditorRef.current?.getMarkdown()?.trim() || undefined; const activeAttachmentIds = draftAttachments .filter((a) => contentReferencesAttachment(description ?? "", a)) .map((a) => a.id); const issue = await createIssueMutation.mutateAsync({ title: title.trim(), description, status, priority, assignee_type: assigneeType, assignee_id: assigneeId, start_date: startDate || undefined, due_date: dueDate || undefined, attachment_ids: activeAttachmentIds.length > 0 ? activeAttachmentIds : undefined, parent_issue_id: parentIssueId, project_id: projectId, }); // Link queued children to the new parent. Deferred to after create // because the new issue's ID doesn't exist yet. Partial failures don't // roll back the new issue — it's already committed. if (childIssues.length > 0) { const results = await Promise.allSettled( childIssues.map((child) => updateIssueMutation.mutateAsync({ id: child.id, parent_issue_id: issue.id, }), ), ); // Aggregate fan-out: N independent requests can fail for N different // reasons. The user-facing toast stays count-based (any single // err.message would mislead), but log each rejection so developers // still have signal in dev-tools / Sentry. for (const result of results) { if (result.status === "rejected") { console.error("[create-issue] sub-issue link failed", result.reason); } } const failed = results.filter((r) => r.status === "rejected").length; if (failed > 0) { toast.error( failed === childIssues.length ? t(($) => $.create_issue.toast_link_subissues_all_failed) : t(($) => $.create_issue.toast_link_subissues_partial, { failed, total: childIssues.length, }), ); } } setLastAssignee(assigneeType, assigneeId); setLastMode("manual"); clearDraft(); const shouldShowBacklogHint = status === "backlog" && assigneeType === "agent" && assigneeId && localStorage.getItem("multica:backlog-agent-hint-dismissed") !== "true"; if (shouldShowBacklogHint) { setBacklogHintIssueId(issue.id); } else if (keepOpen) { resetForNextIssue(); } else { onClose(); } if (!shouldShowBacklogHint) { toast.custom((toastId) => (
{t(($) => $.create_issue.toast_created)}
{issue.identifier} – {issue.title}
), { duration: 5000 }); } } catch (err) { // Duplicate-issue is the only structured 409 the create endpoint // returns. We schema-guard the body (ApiError.body is `unknown`) so a // future server-side rename / drop of `code` / `issue` degrades to the // normal error toast instead of throwing inside the toast renderer. if (err instanceof ApiError && err.status === 409) { const dup = parseWithFallback( err.body, DuplicateIssueErrorBodySchema, null, { endpoint: "POST /api/workspaces/:wsId/issues (active_duplicate_issue)" }, ); if (dup) { toast.custom( (toastId) => (
{t(($) => $.create_issue.toast_duplicate_title)}
{dup.issue.identifier} – {dup.issue.title}
), { duration: 5000 }, ); return; } } toast.error( err instanceof Error && err.message ? err.message : t(($) => $.create_issue.toast_failed), ); } finally { setSubmitting(false); } }; // Switch to agent mode. Hand the typed text up to the shell as the carry // payload; the shell stores it as the next panel's `data` so the agent // panel reads `data.prompt` on mount. Concatenate title + description so // nothing the user typed is lost — the agent derives a fresh title from // the combined text. Persist the mode flip so the next `c` lands in agent. // Also forward the picked project so the agent panel pins the new issue // to it; without this the agent panel would fall back to its persisted // `lastProjectId`, silently routing the issue to the wrong project. // Forward squad picks alongside agent picks so the agent panel honors // the actor the user already chose — otherwise a squad selection silently // falls back to the persisted actor / first visible agent on flip. // parent_issue_id rides through the same carry channel: the modal opener // (openCreateSubIssue) seeded it on the manual panel, and the agent panel // needs it so the new issue is still created as a sub-issue when the user // flips from "Add sub issue" → "Create with agent". const switchToAgent = () => { const desc = descEditorRef.current?.getMarkdown()?.trim() ?? ""; const prompt = [title.trim(), desc].filter(Boolean).join("\n\n"); // Title + description have been packed into the agent prompt — clear them // from the shared draft so a later agent→manual switch doesn't surface // stale manual state on top of the prompt-as-description, which would // duplicate content on every round-trip. setDraft({ title: "", description: "" }); setLastMode("agent"); // Prefer the hydrated identifier from `parentIssue`, but fall back to the // identifier the modal opener seeded on `data`. Without the fallback, a // flip that happens before the issue detail query resolves drops the // identifier and the agent chip renders as "Sub-issue of " with an empty // tail. The UUID alone still wires the sub-issue relationship correctly; // this only affects the display affordance. const carryParentIdentifier = parentIssue?.identifier ?? (data?.parent_issue_identifier as string | undefined); onSwitchMode?.({ prompt, ...(assigneeId && assigneeType === "agent" ? { agent_id: assigneeId } : assigneeId && assigneeType === "squad" ? { squad_id: assigneeId } : {}), ...(projectId ? { project_id: projectId } : {}), ...(parentIssueId ? { parent_issue_id: parentIssueId } : {}), ...(carryParentIdentifier ? { parent_issue_identifier: carryParentIdentifier } : {}), }); }; return ( <> {backlogHintIssueId ? ( { setBacklogHintIssueId(null); onClose(); }} onDismissPermanently={() => { localStorage.setItem("multica:backlog-agent-hint-dismissed", "true"); }} onMoveToTodo={() => { updateIssueMutation.mutate( { id: backlogHintIssueId, status: "todo" }, { onError: (err) => toast.error( err instanceof Error && err.message ? err.message : t(($) => $.backlog_hint.toast_status_failed), ), }, ); setBacklogHintIssueId(null); onClose(); }} /> ) : ( <> {t(($) => $.create_issue.sr_manual)} {/* Header */}
{workspaceName} {t(($) => $.create_issue.manual_breadcrumb)}
setIsExpanded(!isExpanded)} className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer" > {isExpanded ? : } } /> {isExpanded ? t(($) => $.common.collapse_tooltip) : t(($) => $.common.expand_tooltip)} } /> {t(($) => $.common.close)}
{/* Title */}
$.create_issue.title_placeholder)} className="text-lg font-semibold" onChange={(v) => updateTitle(v)} onSubmit={handleSubmit} />
{/* Description — takes remaining space */}
$.create_issue.description_placeholder)} onUpdate={(md) => setDraft({ description: md })} onUploadFile={handleUpload} debounceMs={500} attachments={draftAttachments} /> {descDragOver && }
{/* Property toolbar */}
{/* Status */} { if (u.status) updateStatus(u.status); }} triggerRender={} align="start" /> {/* Priority */} { if (u.priority) updatePriority(u.priority); }} triggerRender={} align="start" /> {/* Assignee */} updateAssignee( u.assignee_type ?? undefined, u.assignee_id ?? undefined, )} triggerRender={} align="start" /> {/* Due date */} updateDueDate(u.due_date ?? null)} triggerRender={} align="start" /> {/* Project */} setProjectId(u.project_id ?? undefined)} triggerRender={} align="start" /> {/* Start date — collapsed into the ⋯ menu by default since it's a low-frequency field. Renders inline only when the field has a value OR the user just opened it from the overflow menu (the picker's calendar popover needs the inline pill as its anchor). */} {(startDate || startDatePickerOpen) && ( updateStartDate(u.start_date ?? null)} triggerRender={} align="start" open={startDatePickerOpen} onOpenChange={setStartDatePickerOpen} /> )} {/* Parent chip — appears when parent is set. Placed before the ⋯ so it wraps to a new line with ⋯ if space is tight, but ⋯ always stays last in DOM order. */} {parentIssueId && parentIssue && (
)} {/* Child chips — one per queued sub-issue. Links are deferred until create resolves (see handleSubmit). */} {childIssues.map((c) => (
{t(($) => $.create_issue.subissue_chip, { identifier: c.identifier })}
))} {/* Overflow — always the last child so DOM order keeps it at the end of the wrap flow, no matter how many chips are present. */} $.create_issue.more_options_aria)}>
} /> {!startDate && ( setStartDatePickerOpen(true)}> {t(($) => $.create_issue.set_start_date)} )} {parentIssueId && parentIssue ? ( setParentPickerOpen(true)}> {t(($) => $.create_issue.parent_with_id, { identifier: parentIssue.identifier })} ) : ( setParentPickerOpen(true)}> {t(($) => $.create_issue.set_parent)} )} setChildPickerOpen(true)}> {t(($) => $.create_issue.add_subissue)} {parentIssueId && parentIssue && ( <> setParentIssueId(undefined)} > {t(($) => $.create_issue.remove_parent)} )}
{/* Parent / child pickers — rendered inline so they stack over this modal instead of replacing it via useModalStore. */} $.create_issue.set_parent_picker.title)} description={t(($) => $.create_issue.set_parent_picker.description)} excludeIds={[ ...childIssues.map((c) => c.id), ...(parentIssueId ? [parentIssueId] : []), ]} onSelect={(selected) => { setParentIssueId(selected.id); }} /> $.create_issue.add_subissue_picker.title)} description={t(($) => $.create_issue.add_subissue_picker.description)} excludeIds={[ ...childIssues.map((c) => c.id), ...(parentIssueId ? [parentIssueId] : []), ]} onSelect={(selected) => { setChildIssues((prev) => prev.some((x) => x.id === selected.id) ? prev : [...prev, selected], ); }} /> {/* Footer */}
descEditorRef.current?.uploadFile(file)} />
{!title.trim() ? ( } /> {t(($) => $.create_issue.title_required)} ) : ( )}
)} ); } /** className for DialogContent in manual mode — depends on isExpanded and the * backlog-hint sub-state. Exported so the shell (which now owns the * DialogContent) can apply the same visual treatment without duplicating it. */ export function manualDialogContentClass( isExpanded: boolean, backlogHintIssueId: string | null, ) { return cn( "p-0 gap-0 flex flex-col overflow-hidden", "!top-1/2 !left-1/2 !-translate-x-1/2", backlogHintIssueId ? "!max-w-[480px] !w-[calc(100vw-2rem)] !h-auto !-translate-y-1/2 !transition-none !duration-0" : "!transition-all !duration-300 !ease-out", !backlogHintIssueId && isExpanded ? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2" : !backlogHintIssueId ? "!max-w-2xl !w-full !h-96 !-translate-y-1/2" : "", ); } // Thin Dialog-wrapping export — registry mounts the panel directly under the // shell's shared Dialog, but a few legacy callers (and the test suite) still // import this module's modal version. Equivalent runtime behavior to the // pre-refactor component when used standalone. import { Dialog as DialogRoot } from "@multica/ui/components/ui/dialog"; export function CreateIssueModal(props: { onClose: () => void; data?: Record | null; }) { const [isExpanded, setIsExpanded] = useState(false); const [backlogHintIssueId, setBacklogHintIssueId] = useState(null); return ( { if (!v) props.onClose(); }}> ); }