"use client"; import { useState, useRef, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigation } from "../navigation"; import { AlertTriangle, ArrowDown, ArrowLeftRight, ArrowUp, CalendarClock, CalendarDays, Check, ChevronRight, CircleUser, FolderKanban, Maximize2, Minimize2, MoreHorizontal, Settings2, Shapes, Tag, X as XIcon, } from "lucide-react"; import { cn } from "@multica/ui/lib/utils"; import { toast } from "sonner"; import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssuePropertyValue, 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, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, 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, type TitleEditorRef, useFileDropZone, FileDropOverlay, useUploadGate, useEditorUpload } from "../editor"; import { useShortcut } from "@multica/core/shortcuts"; import { ShortcutKeycaps } from "../common/shortcut-keycaps"; import { StatusIcon, StatusPicker, PriorityIcon, PriorityPicker, StagePicker, AssigneePicker, StartDatePicker, DueDatePicker, LabelPicker } from "../issues/components"; import { maxSiblingStage } from "../issues/components/pickers/stage-picker"; import { ProjectPicker } from "../projects/components/project-picker"; import { useIssueTriggerPreview } from "../issues/hooks/use-issue-trigger-preview"; import { useActorName } from "@multica/core/workspace/hooks"; 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 { useIssueCreateSettingsStore, type ManualCreateField, } from "@multica/core/issues/stores/issue-create-settings-store"; import { issueDetailOptions, childIssuesOptions } from "@multica/core/issues/queries"; import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations"; import { useAttachLabelToIssue } from "@multica/core/labels"; import { propertyListOptions, useSetIssueProperty, } from "@multica/core/properties"; import { 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 { ActorAvatar } from "../common/actor-avatar"; import { PropertyIcon } from "../common/property-icon"; import { CustomPropertyValueDisplay, CustomPropertyValueInput, } from "../issues/components/pickers/custom-property-picker"; 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. // --------------------------------------------------------------------------- // CreateRunHint is the create modal's passive pre-trigger label (MUL-3375 §4): // whether saving will start a run, driven by the unified backend predicate // (preview, isCreate) — never a frontend guess. No dialog, no blocking. // // Visually it borrows the comment header's avatar+text line, minus the // interactivity — purely a caption, never a link/hover-card. It renders its own // reveal band (a grid 0fr→1fr collapse) so it sits on a dedicated row above the // property toolbar without reflowing anything: collapsed it is 0px (the flex-1 // editor absorbs the delta), and it expands only once the predicate resolves, // animating straight to the correct copy. function CreateRunHint({ assigneeType, assigneeId, status, }: { assigneeType?: IssueAssigneeType; assigneeId?: string; status: IssueStatus; }) { const { t } = useT("modals"); const { getActorName } = useActorName(); const isAgentLike = assigneeType === "agent" || assigneeType === "squad"; const preview = useIssueTriggerPreview({ isCreate: true, assigneeType: assigneeType ?? null, assigneeId: assigneeId ?? null, status, enabled: isAgentLike && !!assigneeId, }); // Reveal only after the predicate resolves so the band animates to the final // copy instead of flashing "parked" before the run preview lands. const ready = isAgentLike && !!assigneeId && !preview.isLoading; const willStart = preview.totalCount > 0; const isSquad = assigneeType === "squad"; const triggerAgentId = preview.triggers[0]?.agent_id ?? assigneeId; // Avatar + copy mirror the flow. A squad doesn't "work" — its leader // evaluates and delegates — so the squad path keeps the squad as the subject // (avatar + name) and uses the leader-delegates copy. A single agent picks // the issue up directly; a parked issue shows whoever it was assigned to. let avatarType: string; let avatarId: string | undefined; let text: string; if (!willStart) { avatarType = assigneeType ?? "agent"; avatarId = assigneeId; text = t(($) => $.run_confirm.create_parked); } else if (isSquad) { avatarType = "squad"; avatarId = assigneeId; text = t(($) => $.run_confirm.create_will_start_squad, { name: getActorName("squad", assigneeId ?? ""), }); } else { avatarType = "agent"; avatarId = triggerAgentId; text = t(($) => $.run_confirm.create_will_start, { name: getActorName("agent", triggerAgentId ?? assigneeId ?? ""), }); } return (
{avatarId && ( )} {text}
); } export function ManualCreatePanel({ onClose, onSwitchMode, data, isExpanded, setIsExpanded, }: { 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; }) { const { t } = useT("modals"); const { t: tEditor } = useT("editor"); 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 manualFields = useIssueCreateSettingsStore((s) => s.manualCreateFields); const sendShortcut = useShortcut("send"); const [title, setTitle] = useState(draft.title); const [formResetKey, setFormResetKey] = useState(0); const titleEditorRef = useRef(null); 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( (data?.priority as IssuePriority | undefined) ?? draft.priority, ); const [submitting, setSubmitting] = useState(false); const submittingRef = useRef(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( (data?.due_date as string | undefined) ?? draft.dueDate, ); const [labelIds, setLabelIds] = useState(draft.labelIds); const [propertyValues, setPropertyValues] = useState(draft.propertyValues ?? {}); const [customPropertyPickerId, setCustomPropertyPickerId] = useState(null); const [projectId, setProjectId] = useState(() => { if (data && "project_id" in data) { return (data.project_id as string | null) ?? undefined; } return draft.projectId; }); const [parentIssueId, setParentIssueId] = useState( (data?.parent_issue_id as string) || undefined, ); // Stage only applies to a sub-issue; kept local (not in the persisted draft) // since it's a per-creation choice tied to the chosen parent. const [stage, setStage] = useState( typeof data?.stage === "number" ? (data.stage as number) : null, ); const [parentPickerOpen, setParentPickerOpen] = useState(false); // Toolbar fields hidden via Settings → Issue reuse the overflow reveal // pattern: the ⋯ menu item flips this open, which mounts the inline pill // (the popover's anchor) AND opens the picker. Closing without a value // unmounts the pill again; a field holding a non-default value always // renders regardless of the setting so nothing applied is ever invisible. const [fieldPickerOpen, setFieldPickerOpen] = useState | null>(null); // 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); // Due date follows the same overflow pattern as start date: collapsed into // the ⋯ menu by default, mounted inline (as the popover anchor) only when it // has a value or the user just opened it from the menu. const [dueDatePickerOpen, setDueDatePickerOpen] = 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: workspaceProperties = [] } = useQuery(propertyListOptions(wsId)); const { data: parentIssue } = useQuery({ ...issueDetailOptions(wsId, parentIssueId ?? ""), enabled: !!parentIssueId, }); // Sibling stages under the chosen parent, so the Stage picker can offer the // already-used max stage (and one beyond) instead of flooring at Stage 1–3. const { data: parentChildren = [] } = useQuery({ ...childIssuesOptions(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 } = useEditorUpload(); // Gate every action that fixes this draft: Create and the switch to agent // mode (which re-serializes the description into a // prompt and would carry a stripped body across). const uploadGate = useUploadGate(descEditorRef); 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 updateProject = (id?: string) => { setProjectId(id); setDraft({ projectId: id }); }; const updateStartDate = (v: string | null) => { setStartDate(v); setDraft({ startDate: v }); }; const updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); }; const updateLabelIds = (ids: string[]) => { setLabelIds(ids); setDraft({ labelIds: ids }); }; const updatePropertyValue = (propertyId: string, value: IssuePropertyValue | undefined) => { const next = { ...propertyValues }; if (value === undefined) delete next[propertyId]; else next[propertyId] = value; setPropertyValues(next); setDraft({ propertyValues: next }); }; // Inline pill reveal per toolbar field: kept by Settings → Issue, holding a // non-default value (a hidden field with a value must stay visible — the // draft or a mode-switch carry may have set it), or just opened from the ⋯ // overflow (the picker popover needs the inline pill as its anchor). const showField = { status: manualFields.includes("status") || status !== "todo" || fieldPickerOpen === "status", priority: manualFields.includes("priority") || priority !== "none" || fieldPickerOpen === "priority", assignee: manualFields.includes("assignee") || assigneeId != null || fieldPickerOpen === "assignee", labels: manualFields.includes("labels") || labelIds.length > 0 || fieldPickerOpen === "labels", project: manualFields.includes("project") || projectId != null || fieldPickerOpen === "project", due_date: manualFields.includes("due_date") || dueDate !== null || dueDatePickerOpen, start_date: manualFields.includes("start_date") || startDate !== null || startDatePickerOpen, }; // Field visibility lives in Settings → Issue; the modal closes first so the // dialog doesn't linger over the settings page. The draft store already // holds everything typed, so nothing is lost across the round-trip. const openFieldSettings = () => { onClose(); router.push(`${p.settings()}?tab=issue`); }; const createIssueMutation = useCreateIssue(); const updateIssueMutation = useUpdateIssue(); const attachLabelMutation = useAttachLabelToIssue(); const setIssuePropertyMutation = useSetIssueProperty(); const resetForNextIssue = () => { setTitle(""); setStatus("todo"); setPriority("none"); setStartDate(null); setDueDate(null); setLabelIds([]); setPropertyValues({}); setCustomPropertyPickerId(null); setProjectId(undefined); setParentIssueId(undefined); setStage(null); setChildIssues([]); setDraft({ title: "", description: "", status: "todo", priority: "none", assigneeType, assigneeId, projectId: undefined, startDate: null, dueDate: null, labelIds: [], propertyValues: {}, attachments: [], }); descEditorRef.current?.clearContent(); setFormResetKey((key) => key + 1); }; const handleSubmit = async () => { // Single-flight on a ref, not `submitting`: two shortcut presses in one // tick both read the pre-update state and would each fire a create. The // ref flips synchronously, so the second press loses the race. if (submittingRef.current) return; if (!title.trim()) { // The shortcut paths bypass the button entirely, so an empty title would // otherwise be a silent no-op. Put the caret where the fix is; the // button's tooltip says the rest. titleEditorRef.current?.focus(); return; } if (uploadGate.isBlocked()) return; submittingRef.current = true; 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, // The server attaches these in the same transaction as the create and // echoes them back as `issue.labels`, so a stale selection fails the // create instead of leaving a committed-but-unlabeled issue. A legacy // backend that predates this ignores the field — handled by the // compatibility fallback below. label_ids: labelIds.length > 0 ? labelIds : undefined, parent_issue_id: parentIssueId, // Stage is only meaningful for a sub-issue (relative to its siblings). stage: parentIssueId && stage != null ? stage : undefined, project_id: projectId, }); // Custom-property values can only be addressed once the issue has an // id. Keep the modal in its submitting state until every value settles // so closing or "Create another" cannot race the fan-out. const propertyEntries = Object.entries(propertyValues); if (propertyEntries.length > 0) { const results = await Promise.allSettled( propertyEntries.map(([propertyId, value]) => setIssuePropertyMutation.mutateAsync({ issueId: issue.id, propertyId, value, }), ), ); let failed = 0; for (const result of results) { if (result.status === "rejected") { failed += 1; console.error("[create-issue] custom property set failed", result.reason); } } if (failed > 0) { toast.error( t(($) => $.create_issue.toast_set_properties_failed, { count: failed }), ); } } // 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, }), ); } } // Backend-compatibility fallback for the rolling deploy window: the web // app auto-deploys on merge but the backend deploys manually, so a newer // web build can briefly talk to a backend that predates atomic label // creation. That backend silently ignores `label_ids` and returns an // issue with no `labels` field. Only then do we fall back to the legacy // per-label attach so the user's labels aren't silently dropped. When // `labels` is present (current backend) the atomic path already ran, so // we skip this — no double-write, no per-label fan-out. if (labelIds.length > 0 && issue.labels === undefined) { const results = await Promise.allSettled( labelIds.map((labelId) => attachLabelMutation.mutateAsync({ issueId: issue.id, labelId }), ), ); let labelsFailed = 0; for (const result of results) { if (result.status === "rejected") { labelsFailed += 1; console.error("[create-issue] label attach fallback failed", result.reason); } } if (labelsFailed > 0) { toast.error(t(($) => $.create_issue.toast_link_labels_failed)); } } setLastAssignee(assigneeType, assigneeId); setLastMode("manual"); clearDraft(); // The old post-create "agent paused in Backlog" blocking panel is gone — // a passive inline hint now warns before submit (MUL-3375). Just close or // reset and confirm the create. if (keepOpen) { resetForNextIssue(); } else { onClose(); } { 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 { submittingRef.current = false; 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 = () => { // Serializing mid-upload packs a description that has already lost the // pending image into the agent prompt, and the draft it came from is // cleared below — the file would be unrecoverable. if (uploadGate.isBlocked()) return; 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 } : {}), ...(priority !== "none" ? { priority } : {}), ...(dueDate ? { due_date: dueDate } : {}), ...(parentIssueId ? { parent_issue_id: parentIssueId } : {}), ...(carryParentIdentifier ? { parent_issue_identifier: carryParentIdentifier } : {}), }); }; // One state for the button and the keyboard paths, so a rendered affordance // can never disagree with what `handleSubmit` will actually do. const submitState: "submitting" | "uploading" | "missing_title" | "ready" = submitting ? "submitting" : uploadGate.uploading ? "uploading" : !title.trim() ? "missing_title" : "ready"; const submitBusy = submitState === "submitting" || submitState === "uploading"; // Built once and reused by both footer branches: rendering a separate Button // per branch is how the keycaps drifted out of one of them before. const createButton = ( ); return ( <> {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)} // Chord only — plain Enter still just ends title editing (#5532). onSubmitShortcut={handleSubmit} />
{/* Description — takes remaining space */}
$.create_issue.description_placeholder)} onUpdate={(md) => setDraft({ description: md })} onSubmit={handleSubmit} onUploadFile={handleUpload} onUploadingChange={uploadGate.onUploadingChange} debounceMs={500} attachments={draftAttachments} /> {descDragOver && }
{/* Pre-trigger preview — a passive caption above the toolbar; reveals when an agent assignee will pick the issue up. */} {/* Property toolbar — each field renders per the Settings → Issue selection (see showField above). */}
{/* Status */} {showField.status && ( { if (u.status) updateStatus(u.status); }} triggerRender={} align="start" open={fieldPickerOpen === "status" ? true : undefined} onOpenChange={(open) => setFieldPickerOpen(open ? "status" : null)} /> )} {/* Priority */} {showField.priority && ( { if (u.priority) updatePriority(u.priority); }} triggerRender={} align="start" open={fieldPickerOpen === "priority" ? true : undefined} onOpenChange={(open) => setFieldPickerOpen(open ? "priority" : null)} /> )} {/* Assignee */} {showField.assignee && ( updateAssignee( u.assignee_type ?? undefined, u.assignee_id ?? undefined, )} triggerRender={} align="start" open={fieldPickerOpen === "assignee" ? true : undefined} onOpenChange={(open) => setFieldPickerOpen(open ? "assignee" : null)} /> )} {/* Labels — occupies the slot that used to hold Due date so the add-label entry is exposed directly on the dialog. Draft mode: selection is local until the issue is created (handleSubmit attaches the labels afterward). */} {showField.labels && ( } align="start" open={fieldPickerOpen === "labels" ? true : undefined} onOpenChange={(open) => setFieldPickerOpen(open ? "labels" : null)} /> )} {/* Project */} {showField.project && ( updateProject(u.project_id ?? undefined)} triggerRender={} align="start" open={fieldPickerOpen === "project" ? true : undefined} onOpenChange={(open) => setFieldPickerOpen(open ? "project" : null)} /> )} {/* Stage — only relevant when creating a sub-issue under a parent */} {parentIssueId && ( setStage(u.stage ?? null)} maxStage={maxSiblingStage(parentChildren)} triggerRender={} align="start" /> )} {/* Start date — collapsed into the ⋯ menu by default since it's a low-frequency field (exposable via Settings → Issue). Renders inline when configured visible, when the field has a value, OR when the user just opened it from the overflow menu (the picker's calendar popover needs the inline pill as its anchor). */} {showField.start_date && ( updateStartDate(u.start_date ?? null)} triggerRender={} align="start" open={startDatePickerOpen} onOpenChange={setStartDatePickerOpen} /> )} {/* Due date — collapsed into the ⋯ menu by default (moved off the toolbar to make room for Labels). Same reveal rule as start date. */} {showField.due_date && ( updateDueDate(u.due_date ?? null)} triggerRender={} align="start" open={dueDatePickerOpen} onOpenChange={setDueDatePickerOpen} /> )} {/* Workspace-defined fields use the same typed editors as issue detail, but write into the persisted draft until creation. */} {workspaceProperties .filter( (property) => Object.prototype.hasOwnProperty.call(propertyValues, property.id) || customPropertyPickerId === property.id, ) .map((property) => { const value = propertyValues[property.id]; return ( updatePropertyValue(property.id, next)} open={customPropertyPickerId === property.id} onOpenChange={(open) => setCustomPropertyPickerId(open ? property.id : null) } triggerRender={} trigger={ <> {property.name} {value !== undefined && ( )} } /> ); })} {/* 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)}>
} /> {/* Re-entry points for toolbar fields hidden via Settings → Issue. Listed in toolbar order; each opens the picker inline (mounting the pill as its anchor). */} {!showField.status && ( setFieldPickerOpen("status")}> {t(($) => $.create_issue.set_status)} )} {!showField.priority && ( setFieldPickerOpen("priority")}> {t(($) => $.create_issue.set_priority)} )} {!showField.assignee && ( setFieldPickerOpen("assignee")}> {t(($) => $.create_issue.set_assignee)} )} {!showField.labels && ( setFieldPickerOpen("labels")}> {t(($) => $.create_issue.set_labels)} )} {!showField.project && ( setFieldPickerOpen("project")}> {t(($) => $.create_issue.set_project)} )} {!showField.due_date && ( setDueDatePickerOpen(true)}> {t(($) => $.create_issue.set_due_date)} )} {!showField.start_date && ( 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)} {workspaceProperties.length > 0 && ( {t(($) => $.create_issue.custom_properties)} {workspaceProperties.map((property) => ( setCustomPropertyPickerId(property.id)} > {property.name} {Object.prototype.hasOwnProperty.call( propertyValues, property.id, ) && } ))} )} {t(($) => $.create_issue.customize_fields)} {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)} />
{submitState === "missing_title" ? ( {/* No `` wrapper needed now: aria-disabled leaves the button focusable and hoverable, so it can anchor its own tooltip. */} {t(($) => $.create_issue.title_required)} ) : ( createButton )}
); } /** className for DialogContent in manual mode — depends on isExpanded. * Exported so the shell (which now owns the DialogContent) can apply the same * visual treatment without duplicating it. */ export function manualDialogContentClass(isExpanded: boolean) { return cn( "p-0 gap-0 flex flex-col overflow-hidden", "!top-1/2 !left-1/2 !-translate-x-1/2", "!transition-all !duration-300 !ease-out", isExpanded ? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2" : "!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); return ( { if (!v) props.onClose(); }}> ); }