Compare commits

...

1 Commits

Author SHA1 Message Date
J
3dc83e8140 feat(views): expose add-label entry in create-issue dialog
Adjust the manual create-issue dialog toolbar so the add-label entry is
surfaced directly, and move the lower-frequency due date into the ⋯ menu:

- Add a Labels picker in the slot Due date used to occupy. LabelPicker
  gains a draft mode (no issueId): selection is held via selectedIds /
  onSelectedIdsChange and attached to the issue right after it's created
  (the create endpoint takes no labels), mirroring the sub-issue linking
  pattern already in this dialog.
- Collapse Due date into the ⋯ overflow menu with the same reveal rule as
  Start date (inline only when it has a value or was just opened). Give
  DueDatePicker the controlled open/onOpenChange props StartDatePicker
  already had.
- Persist chosen labels in the issue draft store (labelIds) like every
  other draft field.
- Add useAttachLabelToIssue (variables-based attach) so labels can be
  attached to a just-created issue.
- i18n: add create_issue.set_due_date and toast_link_labels_failed across
  en / zh-Hans / ja / ko.

MUL-3971

Co-authored-by: multica-agent <github@multica.ai>
2026-07-02 15:51:25 +08:00
12 changed files with 240 additions and 30 deletions

View File

@@ -34,6 +34,7 @@ const RESET_STATE = {
assigneeId: undefined,
startDate: null,
dueDate: null,
labelIds: [],
attachments: [],
},
lastAssigneeType: undefined,

View File

@@ -13,6 +13,10 @@ interface IssueDraft {
assigneeId?: string;
startDate: string | null;
dueDate: string | null;
/** Label IDs chosen in the create dialog. Attached to the issue right
* after it is created (the create endpoint takes no labels), so they are
* kept as a plain id list rather than full Label objects. */
labelIds: string[];
attachments: Attachment[];
}
@@ -25,6 +29,7 @@ const EMPTY_DRAFT: IssueDraft = {
assigneeId: undefined,
startDate: null,
dueDate: null,
labelIds: [],
attachments: [],
};

View File

@@ -4,5 +4,6 @@ export {
useUpdateLabel,
useDeleteLabel,
useAttachLabel,
useAttachLabelToIssue,
useDetachLabel,
} from "./mutations";

View File

@@ -141,6 +141,30 @@ export function useAttachLabel(issueId: string) {
});
}
/**
* Attach a label to an issue identified by mutation variables rather than a
* closed-over `issueId`. `useAttachLabel` binds one issueId at hook-call time
* for the optimistic issue-detail flow; this variant defers the id to call
* time so a caller can attach labels to an issue that doesn't exist yet at
* render — e.g. labels chosen in the create-issue dialog, attached right after
* the issue is created. No optimistic patch: the create flow closes the dialog
* and relies on the settle-time invalidation to surface the new labels.
*/
export function useAttachLabelToIssue() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ issueId, labelId }: { issueId: string; labelId: string }) =>
api.attachLabel(issueId, labelId),
onSettled: (_data, _err, { issueId }) => {
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
// Issues embed a denormalized labels snapshot, so refresh the issues
// caches that hold it (list / board / detail) once the attach settles.
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
},
});
}
export function useDetachLabel(issueId: string) {
const qc = useQueryClient();
const wsId = useWorkspaceId();

View File

@@ -23,6 +23,8 @@ export function DueDatePicker({
onUpdate,
trigger: customTrigger,
triggerRender,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
align = "start",
defaultOpen = false,
}: {
@@ -30,13 +32,17 @@ export function DueDatePicker({
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
open?: boolean;
onOpenChange?: (v: boolean) => void;
align?: "start" | "center" | "end";
/** Open the popover on first mount. Used by progressive-disclosure
* sidebars so a newly-added field immediately enters edit state. */
defaultOpen?: boolean;
}) {
const { t } = useT("issues");
const [open, setOpen] = useState(defaultOpen);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const date = dateOnlyToLocalDate(dueDate);
const isOverdue = isPastDateOnly(dueDate);

View File

@@ -4,6 +4,7 @@ import { useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Tag, Plus, Settings2 } from "lucide-react";
import { toast } from "sonner";
import type { Label } from "@multica/core/types";
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
import { useWorkspaceId } from "@multica/core/hooks";
import {
@@ -23,7 +24,17 @@ import {
import { useT } from "../../../i18n";
interface LabelPickerProps {
issueId: string;
/**
* The issue whose labels are edited. Omit for **draft mode** (e.g. the
* create-issue dialog, where the issue doesn't exist yet): pass
* `selectedIds` + `onSelectedIdsChange` instead and attach the labels to the
* issue once it's created.
*/
issueId?: string;
/** Draft-mode selection. Ignored when `issueId` is set. */
selectedIds?: string[];
/** Draft-mode change handler. Ignored when `issueId` is set. */
onSelectedIdsChange?: (ids: string[]) => void;
/** Optional controlled open state (for tests / cmd+k integration). */
open?: boolean;
onOpenChange?: (open: boolean) => void;
@@ -31,6 +42,11 @@ interface LabelPickerProps {
/** Open the picker on first mount. Used by progressive-disclosure
* sidebars so a newly-added field immediately enters edit state. */
defaultOpen?: boolean;
/** Custom trigger element (e.g. a `PillButton` for the create toolbar).
* When set, the attached-label chips render inside it without their own
* × affordance — a remove <button> can't nest inside a trigger <button>,
* so removal happens by toggling the label off in the open picker. */
triggerRender?: React.ReactElement;
}
/**
@@ -52,14 +68,20 @@ function pickInlineColor(name: string): string {
}
/**
* Multi-select label picker for an issue. Shows currently-attached labels
* as inline chips above the trigger and lets the user toggle any label in
* the workspace. Attach/detach are optimistic — the UI updates before the
* server confirms.
* Multi-select label picker. Shows currently-selected labels as inline chips
* on the trigger and lets the user toggle any label in the workspace.
*
* Two modes:
* - **Attached mode** (`issueId` set): attach/detach hit the server
* optimistically — the UI updates before the server confirms.
* - **Draft mode** (`issueId` omitted): selection is held by the caller via
* `selectedIds` / `onSelectedIdsChange`; nothing is persisted until the
* caller attaches the labels itself. Used by the create-issue dialog.
*
* When the search term has no matches, offers inline creation: typing a
* new name and pressing Enter (or clicking the "Create X" row) creates the
* label with a hash-derived color and attaches it in one motion.
* label with a hash-derived color and selects it in one motion. The created
* label is a real workspace label in both modes; only the attach step differs.
*
* A "Manage labels" item at the bottom opens a dialog with the full
* workspace label management panel (rename, recolor, delete) — keeping
@@ -67,10 +89,13 @@ function pickInlineColor(name: string): string {
*/
export function LabelPicker({
issueId,
selectedIds = [],
onSelectedIdsChange,
open: controlledOpen,
onOpenChange,
align = "start",
defaultOpen = false,
triggerRender,
}: LabelPickerProps) {
const { t } = useT("issues");
const [internalOpen, setInternalOpen] = useState(defaultOpen);
@@ -86,17 +111,35 @@ export function LabelPicker({
// for an error the user didn't cause. A ref closes the window cleanly.
const creatingRef = useRef(false);
// Draft mode when no issue exists yet: hold selection in the caller instead
// of hitting the attach/detach endpoints.
const isDraft = issueId === undefined;
const wsId = useWorkspaceId();
const { data: allLabels = [] } = useQuery(labelListOptions(wsId));
const { data: attachedLabels = [] } = useQuery(issueLabelsOptions(wsId, issueId));
// `issueLabelsOptions` disables itself for an empty id, so the draft path
// never fires the by-issue read.
const { data: attachedLabels = [] } = useQuery(issueLabelsOptions(wsId, issueId ?? ""));
const attach = useAttachLabel(issueId);
const detach = useDetachLabel(issueId);
// Hooks must run unconditionally; in draft mode the empty id is never used
// because toggle/create route through onSelectedIdsChange instead.
const attach = useAttachLabel(issueId ?? "");
const detach = useDetachLabel(issueId ?? "");
const create = useCreateLabel();
const attachedIds = useMemo(
() => new Set(attachedLabels.map((l) => l.id)),
[attachedLabels],
// The selected set drives both the trigger chips and the list checkmarks.
// Draft mode resolves ids against the workspace list (dropping any id whose
// label was deleted meanwhile) and preserves the user's selection order.
const selectedLabels = useMemo<Label[]>(() => {
if (!isDraft) return attachedLabels;
return selectedIds
.map((id) => allLabels.find((l) => l.id === id))
.filter((l): l is Label => Boolean(l));
}, [isDraft, attachedLabels, selectedIds, allLabels]);
const selectedIdSet = useMemo(
() => new Set(selectedLabels.map((l) => l.id)),
[selectedLabels],
);
const query = filter.trim();
@@ -105,8 +148,22 @@ export function LabelPicker({
const exactMatch = allLabels.some((l) => l.name.toLowerCase() === queryLower);
const canCreate = query.length > 0 && !exactMatch && !create.isPending;
const removeLabel = (labelId: string) => {
if (isDraft) {
onSelectedIdsChange?.(selectedIds.filter((id) => id !== labelId));
} else {
detach.mutate(labelId);
}
};
const toggle = (labelId: string) => {
if (attachedIds.has(labelId)) {
if (isDraft) {
onSelectedIdsChange?.(
selectedIdSet.has(labelId)
? selectedIds.filter((id) => id !== labelId)
: [...selectedIds, labelId],
);
} else if (selectedIdSet.has(labelId)) {
detach.mutate(labelId);
} else {
attach.mutate(labelId);
@@ -121,7 +178,11 @@ export function LabelPicker({
{ name, color: pickInlineColor(name) },
{
onSuccess: (label) => {
attach.mutate(label.id);
if (isDraft) {
onSelectedIdsChange?.([...selectedIds, label.id]);
} else {
attach.mutate(label.id);
}
setFilter("");
},
onError: (err: unknown) => {
@@ -139,7 +200,16 @@ export function LabelPicker({
setManageOpen(true);
};
const hasLabels = attachedLabels.length > 0;
const hasLabels = selectedLabels.length > 0;
// In a custom trigger (PillButton) the trigger is itself a button, so the
// chips can't carry their own remove button. Otherwise fall back to the
// chip-wrap div used by the issue-detail sidebar.
const resolvedTriggerRender =
triggerRender ??
(hasLabels ? (
<div className="flex flex-wrap items-center gap-1 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors" />
) : undefined);
return (
<div className="flex flex-col gap-1.5">
@@ -154,19 +224,15 @@ export function LabelPicker({
searchable
searchPlaceholder={t(($) => $.pickers.label.search_placeholder)}
onSearchChange={setFilter}
triggerRender={
hasLabels ? (
<div className="flex flex-wrap items-center gap-1 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors" />
) : undefined
}
triggerRender={resolvedTriggerRender}
trigger={
hasLabels ? (
<>
{attachedLabels.map((l) => (
{selectedLabels.map((l) => (
<LabelChip
key={l.id}
label={l}
onRemove={() => detach.mutate(l.id)}
onRemove={triggerRender ? undefined : () => removeLabel(l.id)}
/>
))}
</>
@@ -191,7 +257,7 @@ export function LabelPicker({
}
>
{filtered.map((label) => {
const selected = attachedIds.has(label.id);
const selected = selectedIdSet.has(label.id);
return (
<PickerItem
key={label.id}

View File

@@ -162,6 +162,7 @@
"toast_duplicate_view": "View existing issue",
"toast_link_subissues_all_failed": "Failed to link sub-issues",
"toast_link_subissues_partial": "Failed to link {{failed}} of {{total}} sub-issues",
"toast_link_labels_failed": "Failed to attach labels",
"switch_to_agent": "Switch to Agent",
"switch_to_agent_tooltip": "Switch to create with agent — describe in one line and let the agent file it",
"switch_to_manual": "Switch to Manual",
@@ -172,6 +173,7 @@
"subissue_of": "Sub-issue of {{identifier}}",
"subissue_chip": "Sub-issue: {{identifier}}",
"parent_with_id": "Parent: {{identifier}}",
"set_due_date": "Set due date...",
"set_start_date": "Set start date...",
"set_parent": "Set parent issue...",
"add_subissue": "Add sub-issue...",

View File

@@ -160,6 +160,7 @@
"toast_duplicate_view": "既存のイシューを表示",
"toast_link_subissues_all_failed": "サブイシューをリンクできませんでした",
"toast_link_subissues_partial": "サブイシュー {{total}} 件中 {{failed}} 件をリンクできませんでした",
"toast_link_labels_failed": "ラベルを付けられませんでした",
"switch_to_agent": "エージェントに切り替え",
"switch_to_agent_tooltip": "エージェントで作成に切り替えます。一行で説明すれば、エージェントがイシューを作成します",
"switch_to_manual": "手動に切り替え",
@@ -170,6 +171,7 @@
"subissue_of": "{{identifier}} のサブイシュー",
"subissue_chip": "サブイシュー: {{identifier}}",
"parent_with_id": "親: {{identifier}}",
"set_due_date": "期限を設定...",
"set_start_date": "開始日を設定...",
"set_parent": "親イシューを設定...",
"add_subissue": "サブイシューを追加...",

View File

@@ -160,6 +160,7 @@
"toast_duplicate_view": "기존 이슈 보기",
"toast_link_subissues_all_failed": "하위 이슈를 연결하지 못했습니다",
"toast_link_subissues_partial": "하위 이슈 {{total}}개 중 {{failed}}개를 연결하지 못했습니다",
"toast_link_labels_failed": "라벨을 연결하지 못했습니다",
"switch_to_agent": "에이전트로 전환",
"switch_to_agent_tooltip": "에이전트로 만들기로 전환합니다. 한 줄로 설명하면 에이전트가 이슈를 작성합니다.",
"switch_to_manual": "직접 만들기로 전환",
@@ -170,6 +171,7 @@
"subissue_of": "{{identifier}}의 하위 이슈",
"subissue_chip": "하위 이슈: {{identifier}}",
"parent_with_id": "상위 이슈: {{identifier}}",
"set_due_date": "마감일 설정...",
"set_start_date": "시작일 설정...",
"set_parent": "상위 이슈 설정...",
"add_subissue": "하위 이슈 추가...",

View File

@@ -160,6 +160,7 @@
"toast_duplicate_view": "查看已有 issue",
"toast_link_subissues_all_failed": "关联子 issue 失败",
"toast_link_subissues_partial": "{{total}} 个子 issue 中有 {{failed}} 个关联失败",
"toast_link_labels_failed": "关联标签失败",
"switch_to_agent": "切换到智能体",
"switch_to_agent_tooltip": "切换到通过智能体创建——一句话描述,让它替你建",
"switch_to_manual": "切换到手动",
@@ -170,6 +171,7 @@
"subissue_of": "{{identifier}} 的子 issue",
"subissue_chip": "子 issue{{identifier}}",
"parent_with_id": "父:{{identifier}}",
"set_due_date": "设置截止日期...",
"set_start_date": "设置开始日期...",
"set_parent": "设置父 issue...",
"add_subissue": "添加子 issue...",

View File

@@ -40,6 +40,7 @@ const mockDraftStore = {
assigneeId: undefined as string | undefined,
startDate: null,
dueDate: null,
labelIds: [] as string[],
attachments: [] as Array<{
id: string;
workspace_id: string;
@@ -248,7 +249,16 @@ vi.mock("../issues/components", () => ({
onClick={() => onOpenChange?.(false)}
/>
),
DueDatePicker: () => <div data-testid="due-date-picker" />,
// Due date now shares the start-date overflow pattern, so surface
// open/onOpenChange to assert it too.
DueDatePicker: ({ open, onOpenChange }: { open?: boolean; onOpenChange?: (v: boolean) => void }) => (
<div
data-testid="due-date-picker"
data-open={open ? "true" : "false"}
onClick={() => onOpenChange?.(false)}
/>
),
LabelPicker: () => <div data-testid="label-picker" />,
}));
vi.mock("../projects/components/project-picker", () => ({
@@ -371,6 +381,7 @@ describe("CreateIssueModal", () => {
mockDraftStore.draft.assigneeId = undefined;
mockDraftStore.draft.startDate = null;
mockDraftStore.draft.dueDate = null;
mockDraftStore.draft.labelIds = [];
mockDraftStore.draft.attachments = [];
mockSetDraft.mockImplementation((patch: Partial<typeof mockDraftStore.draft>) => {
mockDraftStore.draft = { ...mockDraftStore.draft, ...patch };
@@ -385,6 +396,7 @@ describe("CreateIssueModal", () => {
assigneeId: mockDraftStore.lastAssigneeId,
startDate: null,
dueDate: null,
labelIds: [],
attachments: [],
};
});
@@ -501,6 +513,7 @@ describe("CreateIssueModal", () => {
assigneeId: undefined,
startDate: null,
dueDate: null,
labelIds: [],
attachments: [],
});
});
@@ -819,6 +832,35 @@ describe("CreateIssueModal", () => {
expect(screen.queryByTestId("start-date-picker")).not.toBeInTheDocument();
});
it("exposes the label picker on the toolbar and keeps due date in the overflow menu", async () => {
renderModal(<CreateIssueModal onClose={vi.fn()} />);
// Label entry is now surfaced directly on the dialog...
expect(screen.getByTestId("label-picker")).toBeInTheDocument();
// ...while due date is collapsed into the ⋯ menu (no inline pill yet).
expect(screen.queryByTestId("due-date-picker")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Set due date/i }),
).toBeInTheDocument();
});
it("hides due date behind the overflow menu and reveals it on demand", async () => {
const user = userEvent.setup();
renderModal(<CreateIssueModal onClose={vi.fn()} />);
expect(screen.queryByTestId("due-date-picker")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Set due date/i }));
const picker = await screen.findByTestId("due-date-picker");
expect(picker).toHaveAttribute("data-open", "true");
await user.click(picker);
expect(screen.queryByTestId("due-date-picker")).not.toBeInTheDocument();
});
// Title + description are packed into the agent prompt on switch; if we
// leave them in the shared draft store, the next agent→manual switch
// surfaces the stale manual draft on top of the prompt-as-description,

View File

@@ -9,6 +9,7 @@ import {
ArrowLeftRight,
ArrowUp,
CalendarClock,
CalendarDays,
Check,
ChevronRight,
Maximize2,
@@ -35,7 +36,7 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@multi
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, StagePicker, AssigneePicker, StartDatePicker, DueDatePicker } from "../issues/components";
import { StatusIcon, StatusPicker, 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";
@@ -47,6 +48,7 @@ import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-stor
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
import { issueDetailOptions, childIssuesOptions } from "@multica/core/issues/queries";
import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations";
import { useAttachLabelToIssue } from "@multica/core/labels";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import {
api,
@@ -223,6 +225,7 @@ export function ManualCreatePanel({
});
const [startDate, setStartDate] = useState<string | null>(draft.startDate);
const [dueDate, setDueDate] = useState<string | null>(draft.dueDate);
const [labelIds, setLabelIds] = useState<string[]>(draft.labelIds);
const [projectId, setProjectId] = useState<string | undefined>(
(data?.project_id as string) || undefined,
);
@@ -240,6 +243,10 @@ export function ManualCreatePanel({
// 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<Issue[]>([]);
@@ -300,15 +307,18 @@ export function ManualCreatePanel({
};
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 createIssueMutation = useCreateIssue();
const updateIssueMutation = useUpdateIssue();
const attachLabelMutation = useAttachLabelToIssue();
const resetForNextIssue = () => {
setTitle("");
setStatus("todo");
setPriority("none");
setStartDate(null);
setDueDate(null);
setLabelIds([]);
setProjectId(undefined);
setParentIssueId(undefined);
setStage(null);
@@ -322,6 +332,7 @@ export function ManualCreatePanel({
assigneeId,
startDate: null,
dueDate: null,
labelIds: [],
attachments: [],
});
descEditorRef.current?.clearContent();
@@ -386,6 +397,28 @@ export function ManualCreatePanel({
}
}
// Attach the labels chosen in the dialog. Like the sub-issue links
// above, this is deferred to after create because the new issue's ID
// doesn't exist yet, and the create endpoint takes no labels. Partial
// failures don't roll back the committed issue.
if (labelIds.length > 0) {
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 failed", result.reason);
}
}
if (labelsFailed > 0) {
toast.error(t(($) => $.create_issue.toast_link_labels_failed));
}
}
setLastAssignee(assigneeType, assigneeId);
setLastMode("manual");
clearDraft();
@@ -631,10 +664,13 @@ export function ManualCreatePanel({
align="start"
/>
{/* Due date */}
<DueDatePicker
dueDate={dueDate}
onUpdate={(u) => updateDueDate(u.due_date ?? 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). */}
<LabelPicker
selectedIds={labelIds}
onSelectedIdsChange={updateLabelIds}
triggerRender={<PillButton />}
align="start"
/>
@@ -674,6 +710,21 @@ export function ManualCreatePanel({
/>
)}
{/* Due date — collapsed into the ⋯ menu by default (moved off
the toolbar to make room for Labels). Same reveal rule as
start date: inline only when it has a value or the user just
opened it from the overflow menu. */}
{(dueDate || dueDatePickerOpen) && (
<DueDatePicker
dueDate={dueDate}
onUpdate={(u) => updateDueDate(u.due_date ?? null)}
triggerRender={<PillButton />}
align="start"
open={dueDatePickerOpen}
onOpenChange={setDueDatePickerOpen}
/>
)}
{/* 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. */}
@@ -735,6 +786,12 @@ export function ManualCreatePanel({
}
/>
<DropdownMenuContent align="start" className="w-auto">
{!dueDate && (
<DropdownMenuItem onClick={() => setDueDatePickerOpen(true)}>
<CalendarDays className="h-3.5 w-3.5" />
{t(($) => $.create_issue.set_due_date)}
</DropdownMenuItem>
)}
{!startDate && (
<DropdownMenuItem onClick={() => setStartDatePickerOpen(true)}>
<CalendarClock className="h-3.5 w-3.5" />