mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 01:16:17 +02:00
* feat(quick-create): add project picker that remembers last pick Quick-create users targeting one project repeatedly had to restate "in project X" in every prompt. The modal now exposes a project picker beside the agent picker, persists the selection per-workspace, and pins the agent's `multica issue create` invocation to that project so the prompt text doesn't have to. The picked project also flows to the daemon as ProjectID/ProjectTitle and its github_repo resources override the workspace repo fallback — same treatment issue-bound tasks already get. Co-authored-by: multica-agent <github@multica.ai> * fix(quick-create): move project picker into property pill row Reviewer feedback: the picker felt out of place wedged next to the agent header. Move it into a property toolbar row above the footer, reusing the shared `ProjectPicker` + `PillButton` so its placement and styling line up exactly with the manual create panel. This also drops the bespoke dropdown / aria / label strings that were only needed while the picker rendered inline beside "Created by". Co-authored-by: multica-agent <github@multica.ai> * fix(quick-create): clear stale persisted project + carry across mode switch Two review-blocking bugs in PR #2321: 1. The stale-id sweep in AgentCreatePanel only fired when projects.length > 0 and only cleared local state, leaving lastProjectId pointing at a deleted project. The next open re-seeded the dead UUID and submit hit the server's `project not found` rejection. Gate on the query's `isSuccess` so we can tell "loading" apart from "loaded as empty", and clear both local state and the persisted preference when the selection isn't in the resolved list. 2. ManualCreatePanel's switchToAgent dropped the picked project from the carry payload, so flipping manual → agent silently fell back to the agent panel's own lastProjectId — potentially routing the issue to a different project than the one shown in manual mode. Forward project_id alongside prompt / agent_id, and add a regression test. Co-authored-by: multica-agent <github@multica.ai> * test(quick-create): pass new isExpanded props in stale-project tests Main got an expand button on AgentCreatePanel via #2320 while this branch was open, adding `isExpanded` / `setIsExpanded` to the panel's required props. The two new stale-project tests still passed `{ onClose }` only, which CI's typecheck (run on the main+branch merge) caught while my local run did not. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
627 lines
26 KiB
TypeScript
627 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useNavigation } from "../navigation";
|
||
import {
|
||
ArrowDown,
|
||
ArrowLeftRight,
|
||
ArrowUp,
|
||
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 } 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 } 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, 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 } 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";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ManualCreatePanel — manual-mode body of the create-issue dialog. Renders
|
||
// DialogContent + everything inside; the surrounding `<Dialog>` 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<string, unknown> | null) => void;
|
||
data?: Record<string, unknown> | 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<ContentEditorRef>(null);
|
||
const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({
|
||
onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)),
|
||
});
|
||
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>(draft.assigneeType);
|
||
const [assigneeId, setAssigneeId] = useState<string | undefined>(draft.assigneeId);
|
||
const [dueDate, setDueDate] = useState<string | null>(draft.dueDate);
|
||
const [projectId, setProjectId] = useState<string | undefined>(
|
||
(data?.project_id as string) || undefined,
|
||
);
|
||
const [parentIssueId, setParentIssueId] = useState<string | undefined>(
|
||
(data?.parent_issue_id as string) || undefined,
|
||
);
|
||
const [parentPickerOpen, setParentPickerOpen] = 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<Issue[]>([]);
|
||
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,
|
||
});
|
||
|
||
// File upload — collect attachment IDs so we can link them after issue creation.
|
||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||
const { uploadWithToast } = useFileUpload(api);
|
||
const handleUpload = async (file: File) => {
|
||
const result = await uploadWithToast(file);
|
||
if (result) {
|
||
setAttachmentIds((prev) => [...prev, result.id]);
|
||
}
|
||
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 updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); };
|
||
|
||
const createIssueMutation = useCreateIssue();
|
||
const updateIssueMutation = useUpdateIssue();
|
||
const resetForNextIssue = () => {
|
||
setTitle("");
|
||
setStatus("todo");
|
||
setPriority("none");
|
||
setDueDate(null);
|
||
setProjectId(undefined);
|
||
setParentIssueId(undefined);
|
||
setChildIssues([]);
|
||
setAttachmentIds([]);
|
||
setDraft({
|
||
title: "",
|
||
description: "",
|
||
status: "todo",
|
||
priority: "none",
|
||
assigneeType,
|
||
assigneeId,
|
||
dueDate: null,
|
||
});
|
||
descEditorRef.current?.clearContent();
|
||
setFormResetKey((key) => key + 1);
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!title.trim() || submitting) return;
|
||
setSubmitting(true);
|
||
try {
|
||
const issue = await createIssueMutation.mutateAsync({
|
||
title: title.trim(),
|
||
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
|
||
status,
|
||
priority,
|
||
assignee_type: assigneeType,
|
||
assignee_id: assigneeId,
|
||
due_date: dueDate || undefined,
|
||
attachment_ids: attachmentIds.length > 0 ? attachmentIds : 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,
|
||
}),
|
||
),
|
||
);
|
||
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) => (
|
||
<div className="bg-popover text-popover-foreground border rounded-lg shadow-lg p-4 w-[360px]">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<div className="flex items-center justify-center size-5 rounded-full bg-emerald-500/15 text-emerald-500">
|
||
<Check className="size-3" />
|
||
</div>
|
||
<span className="text-sm font-medium">{t(($) => $.create_issue.toast_created)}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
|
||
<StatusIcon status={issue.status} className="size-3.5 shrink-0" />
|
||
<span className="truncate">{issue.identifier} – {issue.title}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="ml-7 mt-2 text-sm text-primary hover:underline cursor-pointer"
|
||
onClick={() => {
|
||
router.push(p.issueDetail(issue.id));
|
||
toast.dismiss(toastId);
|
||
}}
|
||
>
|
||
{t(($) => $.create_issue.view_issue)}
|
||
</button>
|
||
</div>
|
||
), { duration: 5000 });
|
||
}
|
||
} catch {
|
||
toast.error(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.
|
||
const switchToAgent = () => {
|
||
const desc = descEditorRef.current?.getMarkdown()?.trim() ?? "";
|
||
const prompt = [title.trim(), desc].filter(Boolean).join("\n\n");
|
||
setLastMode("agent");
|
||
onSwitchMode?.({
|
||
prompt,
|
||
...(assigneeType === "agent" && assigneeId
|
||
? { agent_id: assigneeId }
|
||
: {}),
|
||
...(projectId ? { project_id: projectId } : {}),
|
||
});
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{backlogHintIssueId ? (
|
||
<BacklogAgentHintContent
|
||
onKeepInBacklog={() => {
|
||
setBacklogHintIssueId(null);
|
||
onClose();
|
||
}}
|
||
onDismissPermanently={() => {
|
||
localStorage.setItem("multica:backlog-agent-hint-dismissed", "true");
|
||
}}
|
||
onMoveToTodo={() => {
|
||
updateIssueMutation.mutate(
|
||
{ id: backlogHintIssueId, status: "todo" },
|
||
{ onError: () => toast.error(t(($) => $.backlog_hint.toast_status_failed)) },
|
||
);
|
||
setBacklogHintIssueId(null);
|
||
onClose();
|
||
}}
|
||
/>
|
||
) : (
|
||
<>
|
||
<DialogTitle className="sr-only">{t(($) => $.create_issue.sr_manual)}</DialogTitle>
|
||
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||
<div className="flex items-center gap-1.5 text-xs">
|
||
<span className="text-muted-foreground">{workspaceName}</span>
|
||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||
<span className="font-medium">{t(($) => $.create_issue.manual_breadcrumb)}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
render={
|
||
<button
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||
>
|
||
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||
</button>
|
||
}
|
||
/>
|
||
<TooltipContent side="bottom">
|
||
{isExpanded
|
||
? t(($) => $.common.collapse_tooltip)
|
||
: t(($) => $.common.expand_tooltip)}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
render={
|
||
<button
|
||
onClick={onClose}
|
||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||
>
|
||
<XIcon className="size-4" />
|
||
</button>
|
||
}
|
||
/>
|
||
<TooltipContent side="bottom">{t(($) => $.common.close)}</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Title */}
|
||
<div className="px-5 pb-2 shrink-0">
|
||
<TitleEditor
|
||
key={formResetKey}
|
||
autoFocus
|
||
defaultValue={draft.title}
|
||
placeholder={t(($) => $.create_issue.title_placeholder)}
|
||
className="text-lg font-semibold"
|
||
onChange={(v) => updateTitle(v)}
|
||
onSubmit={handleSubmit}
|
||
/>
|
||
</div>
|
||
|
||
{/* Description — takes remaining space */}
|
||
<div {...descDropZoneProps} className="relative flex-1 min-h-0 overflow-y-auto px-5">
|
||
<ContentEditor
|
||
ref={descEditorRef}
|
||
defaultValue={draft.description}
|
||
placeholder={t(($) => $.create_issue.description_placeholder)}
|
||
onUpdate={(md) => setDraft({ description: md })}
|
||
onUploadFile={handleUpload}
|
||
debounceMs={500}
|
||
/>
|
||
{descDragOver && <FileDropOverlay />}
|
||
</div>
|
||
|
||
{/* Property toolbar */}
|
||
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
|
||
{/* Status */}
|
||
<StatusPicker
|
||
status={status}
|
||
onUpdate={(u) => { if (u.status) updateStatus(u.status); }}
|
||
triggerRender={<PillButton />}
|
||
align="start"
|
||
/>
|
||
|
||
{/* Priority */}
|
||
<PriorityPicker
|
||
priority={priority}
|
||
onUpdate={(u) => { if (u.priority) updatePriority(u.priority); }}
|
||
triggerRender={<PillButton />}
|
||
align="start"
|
||
/>
|
||
|
||
{/* Assignee */}
|
||
<AssigneePicker
|
||
assigneeType={assigneeType ?? null}
|
||
assigneeId={assigneeId ?? null}
|
||
onUpdate={(u) => updateAssignee(
|
||
u.assignee_type ?? undefined,
|
||
u.assignee_id ?? undefined,
|
||
)}
|
||
triggerRender={<PillButton />}
|
||
align="start"
|
||
/>
|
||
|
||
{/* Due date */}
|
||
<DueDatePicker
|
||
dueDate={dueDate}
|
||
onUpdate={(u) => updateDueDate(u.due_date ?? null)}
|
||
triggerRender={<PillButton />}
|
||
align="start"
|
||
/>
|
||
|
||
{/* Project */}
|
||
<ProjectPicker
|
||
projectId={projectId ?? null}
|
||
onUpdate={(u) => setProjectId(u.project_id ?? undefined)}
|
||
triggerRender={<PillButton />}
|
||
align="start"
|
||
/>
|
||
|
||
{/* 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 && (
|
||
<div className="inline-flex items-center rounded-full border text-xs transition-colors hover:bg-accent/60">
|
||
<button
|
||
type="button"
|
||
onClick={() => setParentPickerOpen(true)}
|
||
className="flex items-center gap-1.5 py-1 pl-2.5 cursor-pointer"
|
||
>
|
||
<ArrowUp className="size-3 text-muted-foreground" />
|
||
<span>
|
||
{t(($) => $.create_issue.subissue_of, { identifier: parentIssue.identifier })}
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setParentIssueId(undefined)}
|
||
className="p-1 pr-2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||
aria-label={t(($) => $.create_issue.remove_parent_aria)}
|
||
>
|
||
<XIcon className="size-3" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Child chips — one per queued sub-issue. Links are deferred
|
||
until create resolves (see handleSubmit). */}
|
||
{childIssues.map((c) => (
|
||
<div
|
||
key={c.id}
|
||
className="inline-flex items-center rounded-full border text-xs transition-colors hover:bg-accent/60"
|
||
>
|
||
<div className="flex items-center gap-1.5 py-1 pl-2.5">
|
||
<ArrowDown className="size-3 text-muted-foreground" />
|
||
<span>{t(($) => $.create_issue.subissue_chip, { identifier: c.identifier })}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setChildIssues((prev) => prev.filter((x) => x.id !== c.id))
|
||
}
|
||
className="p-1 pr-2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||
aria-label={t(($) => $.create_issue.remove_subissue_aria, { identifier: c.identifier })}
|
||
>
|
||
<XIcon className="size-3" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
|
||
{/* Overflow — always the last child so DOM order keeps it at the
|
||
end of the wrap flow, no matter how many chips are present. */}
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
render={
|
||
<PillButton aria-label={t(($) => $.create_issue.more_options_aria)}>
|
||
<MoreHorizontal className="size-3.5" />
|
||
</PillButton>
|
||
}
|
||
/>
|
||
<DropdownMenuContent align="start" className="w-auto">
|
||
{parentIssueId && parentIssue ? (
|
||
<DropdownMenuItem onClick={() => setParentPickerOpen(true)}>
|
||
<ArrowUp className="h-3.5 w-3.5" />
|
||
{t(($) => $.create_issue.parent_with_id, { identifier: parentIssue.identifier })}
|
||
</DropdownMenuItem>
|
||
) : (
|
||
<DropdownMenuItem onClick={() => setParentPickerOpen(true)}>
|
||
<ArrowUp className="h-3.5 w-3.5" />
|
||
{t(($) => $.create_issue.set_parent)}
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuItem onClick={() => setChildPickerOpen(true)}>
|
||
<ArrowDown className="h-3.5 w-3.5" />
|
||
{t(($) => $.create_issue.add_subissue)}
|
||
</DropdownMenuItem>
|
||
{parentIssueId && parentIssue && (
|
||
<>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
onClick={() => setParentIssueId(undefined)}
|
||
>
|
||
<XIcon className="h-3.5 w-3.5" />
|
||
{t(($) => $.create_issue.remove_parent)}
|
||
</DropdownMenuItem>
|
||
</>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
|
||
{/* Parent / child pickers — rendered inline so they stack over this
|
||
modal instead of replacing it via useModalStore. */}
|
||
<IssuePickerModal
|
||
open={parentPickerOpen}
|
||
onOpenChange={setParentPickerOpen}
|
||
title={t(($) => $.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);
|
||
}}
|
||
/>
|
||
<IssuePickerModal
|
||
open={childPickerOpen}
|
||
onOpenChange={setChildPickerOpen}
|
||
title={t(($) => $.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 */}
|
||
<div className="flex flex-col gap-2 border-t px-4 py-3 shrink-0 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="flex min-h-7 items-center gap-2">
|
||
<FileUploadButton
|
||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={switchToAgent}
|
||
title={t(($) => $.create_issue.switch_to_agent_tooltip)}
|
||
className="border-beam group flex shrink-0 items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground bg-brand/5 hover:bg-brand/10 hover:text-foreground transition-colors cursor-pointer"
|
||
>
|
||
<ArrowLeftRight className="size-3.5 text-brand/80 transition-transform duration-300 group-hover:rotate-180" />
|
||
{t(($) => $.create_issue.switch_to_agent)}
|
||
</button>
|
||
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||
<Switch
|
||
size="sm"
|
||
checked={keepOpen}
|
||
onCheckedChange={setKeepOpen}
|
||
/>
|
||
{t(($) => $.create_issue.create_another)}
|
||
</label>
|
||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||
{submitting ? t(($) => $.create_issue.submitting) : t(($) => $.create_issue.submit)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** 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<string, unknown> | null;
|
||
}) {
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const [backlogHintIssueId, setBacklogHintIssueId] = useState<string | null>(null);
|
||
return (
|
||
<DialogRoot open onOpenChange={(v) => { if (!v) props.onClose(); }}>
|
||
<DialogContent
|
||
finalFocus={false}
|
||
showCloseButton={false}
|
||
className={manualDialogContentClass(isExpanded, backlogHintIssueId)}
|
||
>
|
||
<ManualCreatePanel
|
||
{...props}
|
||
isExpanded={isExpanded}
|
||
setIsExpanded={setIsExpanded}
|
||
backlogHintIssueId={backlogHintIssueId}
|
||
setBacklogHintIssueId={setBacklogHintIssueId}
|
||
/>
|
||
</DialogContent>
|
||
</DialogRoot>
|
||
);
|
||
}
|