MUL-3254: fix pasted image draft rendering in desktop (#4066)

* fix: keep issue draft attachment records

Co-authored-by: multica-agent <github@multica.ai>

* fix: avoid persisting signed draft attachment urls

Co-authored-by: multica-agent <github@multica.ai>

* fix: reuse resolved media url for draft previews

Co-authored-by: multica-agent <github@multica.ai>

* fix: address draft attachment review nits

- Backfill an empty caller download_url from the in-session upload on id
  collision so a just-pasted image first-paints from the signed URL
  instead of detouring through markdown_url.
- Prune draft attachments no longer referenced by the persisted
  description when the create dialog reopens.
- Backfill EMPTY_DRAFT defaults on draft-store rehydrate so drafts
  persisted before the attachments field existed get a stable shape.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-06-13 01:25:08 +08:00
committed by GitHub
parent 7db3e507d1
commit fa15041864
10 changed files with 455 additions and 36 deletions

View File

@@ -1,5 +1,28 @@
import { beforeEach, describe, expect, it } from "vitest";
// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { useIssueDraftStore } from "./draft-store";
import { setCurrentWorkspace } from "../../platform/workspace-storage";
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
// can round-trip values.
beforeAll(() => {
if (typeof globalThis.localStorage?.clear !== "function") {
const values = new Map<string, string>();
const storage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: (k) => values.get(k) ?? null,
key: (i) => Array.from(values.keys())[i] ?? null,
removeItem: (k) => { values.delete(k); },
setItem: (k, v) => { values.set(k, v); },
};
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
}
});
const RESET_STATE = {
draft: {
@@ -11,6 +34,7 @@ const RESET_STATE = {
assigneeId: undefined,
startDate: null,
dueDate: null,
attachments: [],
},
lastAssigneeType: undefined,
lastAssigneeId: undefined,
@@ -46,6 +70,36 @@ describe("issue draft store — last assignee", () => {
expect(draft.assigneeId).toBeUndefined();
});
it("clearDraft removes persisted draft attachments", () => {
const { setDraft, clearDraft } = useIssueDraftStore.getState();
setDraft({
title: "first",
attachments: [
{
id: "11111111-2222-3333-4444-555555555555",
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "alice",
filename: "shot.png",
url: "https://cdn.example.test/shot.png",
download_url: "https://cdn.example.test/shot.png",
markdown_url: "https://app.example.test/api/attachments/11111111-2222-3333-4444-555555555555/download",
content_type: "image/png",
size_bytes: 123,
created_at: "2026-06-12T00:00:00Z",
},
],
});
clearDraft();
expect(useIssueDraftStore.getState().draft.attachments).toEqual([]);
});
it("setLastAssignee(undefined) lets the user opt back out of a default", () => {
const { setLastAssignee, clearDraft } = useIssueDraftStore.getState();
@@ -59,3 +113,42 @@ describe("issue draft store — last assignee", () => {
expect(useIssueDraftStore.getState().draft.assigneeType).toBeUndefined();
});
});
describe("issue draft store — legacy rehydrate", () => {
beforeEach(() => {
localStorage.clear();
setCurrentWorkspace(null, null);
});
afterEach(() => {
setCurrentWorkspace(null, null);
});
it("backfills attachments for drafts persisted before the field existed", async () => {
localStorage.setItem(
"multica_issue_draft:acme",
JSON.stringify({
state: {
draft: {
title: "legacy",
description: "body",
status: "todo",
priority: "none",
startDate: null,
dueDate: null,
// no `attachments` — written by a build that predates the field
},
},
version: 0,
}),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
const { draft } = useIssueDraftStore.getState();
expect(draft.title).toBe("legacy");
expect(draft.attachments).toEqual([]);
});
});

View File

@@ -1,6 +1,6 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "../../types";
import type { IssueStatus, IssuePriority, IssueAssigneeType, Attachment } from "../../types";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
@@ -13,6 +13,7 @@ interface IssueDraft {
assigneeId?: string;
startDate: string | null;
dueDate: string | null;
attachments: Attachment[];
}
const EMPTY_DRAFT: IssueDraft = {
@@ -24,6 +25,7 @@ const EMPTY_DRAFT: IssueDraft = {
assigneeId: undefined,
startDate: null,
dueDate: null,
attachments: [],
};
interface IssueDraftStore {
@@ -65,6 +67,18 @@ export const useIssueDraftStore = create<IssueDraftStore>()(
{
name: "multica_issue_draft",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
// Drafts persisted by older builds predate fields added later (e.g.
// `attachments`). Backfill EMPTY_DRAFT defaults on rehydrate so every
// read site can rely on the declared IssueDraft shape instead of
// re-defending with `?? fallback`.
merge: (persistedState, currentState) => {
const persisted = (persistedState ?? {}) as Partial<IssueDraftStore>;
return {
...currentState,
...persisted,
draft: { ...EMPTY_DRAFT, ...persisted.draft },
};
},
},
),
);

View File

@@ -174,6 +174,26 @@ describe("AttachmentPreviewModal — dispatch", () => {
expect(img?.getAttribute("alt")).toBe(att.filename);
});
it("falls back to durable media URLs when a full attachment has no download_url", () => {
const att = makeAttachment({
filename: "shot.png",
content_type: "image/png",
download_url: "",
markdown_url: "https://api.example.test/api/attachments/att-1/download",
url: "https://cdn.example.test/att-1.png?Signature=old",
});
render(
<AttachmentPreviewModal
source={{ kind: "full", attachment: att }}
open
onClose={() => {}}
/>,
);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe(att.markdown_url);
expect(img?.getAttribute("src")).not.toContain("Signature=");
});
it("renders an <img> from a URL-only source for image filenames", () => {
const url = "https://cdn.example.test/orphan.png?Signature=s";
render(

View File

@@ -92,6 +92,12 @@ interface PreviewState {
attachmentId: string | null;
}
function resolvePreviewMediaUrl(attachment: Attachment): string {
const raw =
attachment.download_url || attachment.markdown_url || attachment.url;
return resolvePublicFileUrl(raw) ?? raw;
}
function normalize(source: PreviewSource): PreviewState {
// Resolve any server-relative URL (e.g. `/api/attachments/{id}/download`
// returned by the unified-endpoint metadata path when no CloudFront
@@ -102,13 +108,10 @@ function normalize(source: PreviewSource): PreviewState {
// form so `<img src>` / `<iframe src>` / `<video src>` actually point at
// the API server instead of the shell origin.
if (source.kind === "full") {
const mediaUrl =
resolvePublicFileUrl(source.attachment.download_url) ??
source.attachment.download_url;
return {
filename: source.attachment.filename,
contentType: source.attachment.content_type,
mediaUrl,
mediaUrl: resolvePreviewMediaUrl(source.attachment),
attachmentId: source.attachment.id,
};
}

View File

@@ -259,6 +259,39 @@ describe("Attachment — image dispatch", () => {
);
});
it("opens preview with the same resolved media URL when a reopened draft record has no download_url", () => {
configStore.setState({ cdnDomain: "cdn.example.test" });
const id = "11111111-2222-3333-4444-555555555555";
const markdownUrl = `https://multica-api.copilothub.ai/api/attachments/${id}/download`;
const mediaUrl = "https://cdn.example.test/uploads/ws/shot.png";
const att = makeRecord({
id,
url: mediaUrl,
markdown_url: markdownUrl,
download_url: "",
});
resolverState.attachments = [att];
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: markdownUrl,
filename: "shot.png",
forceKind: "image",
}}
/>,
);
fireEvent.click(screen.getByTitle("View"));
const imageSrcs = [...document.querySelectorAll("img")].map((img) =>
img.getAttribute("src"),
);
expect(imageSrcs).toEqual([mediaUrl, mediaUrl]);
expect(imageSrcs).not.toContain("");
});
it("forceKind=image renders as image even when filename is empty (markdown ![](url) regression)", () => {
renderWithQuery(
<Attachment

View File

@@ -312,7 +312,13 @@ export function Attachment({
const openPreview = () => {
if (state.record) {
preview.tryOpen({ kind: "full", attachment: state.record });
preview.tryOpen({
kind: "full",
attachment: {
...state.record,
download_url: state.url || state.record.download_url,
},
});
return;
}
if (state.url) {

View File

@@ -332,6 +332,44 @@ describe("ContentEditor — in-session attachment tracking (MUL-3192)", () => {
expect(providerProps.attachments?.[0]?.download_url).toContain("Signature=fresh");
});
it("backfills an empty caller download_url from the session upload on id collision", async () => {
// The create-issue draft persists attachment records with download_url
// stripped (the signed URL is response-scoped). While the upload session
// is still alive, the provider should hand back the signed URL so the
// just-pasted image first-paints from it instead of detouring through
// markdown_url.
const draftRecord = makeAttachment("draft-1", { download_url: "" });
const uploaded = makeAttachment("draft-1", {
download_url: "https://cdn.example/draft-1.png?Signature=fresh",
});
const onUploadFile = vi.fn(async () => asUploadResult(uploaded));
uploadAndInsertFileMock.mockImplementation(
async (_e: unknown, file: File, handler: (f: File) => Promise<unknown>) => {
await handler(file);
},
);
let imperativeRef: { uploadFile: (file: File) => void } | null = null;
render(
<ContentEditor
attachments={[draftRecord]}
onUploadFile={onUploadFile}
ref={(r) => {
imperativeRef = r;
}}
/>,
);
await act(async () => {
imperativeRef?.uploadFile(new File(["x"], "draft-1.png", { type: "image/png" }));
});
expect(providerProps.attachments).toHaveLength(1);
expect(providerProps.attachments?.[0]?.download_url).toContain("Signature=fresh");
// Everything except the backfilled field still comes from the caller copy.
expect(providerProps.attachments?.[0]?.filename).toBe(draftRecord.filename);
});
it("does not append a duplicate when the same upload result returns twice (paste-then-drop the same blob)", async () => {
const result = asUploadResult(makeAttachment("dedup-1"));
const onUploadFile = vi.fn(async () => result);

View File

@@ -213,20 +213,26 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
// server) take precedence — we only append session uploads the caller
// doesn't already have, so a parent re-render that includes the same record
// doesn't end up with two copies.
//
// One exception on id collision: when the caller's copy has an EMPTY
// `download_url` (the create-issue draft strips the short-lived signed URL
// before persisting), backfill it from the session upload. The session copy
// holds the this-response signed URL, so the just-pasted image first-paints
// from it instead of taking an extra redirect hop through `markdown_url`.
const providerAttachments = useMemo(() => {
if (sessionUploads.length === 0) return attachments;
const seen = new Set<string>();
const sessionById = new Map(sessionUploads.map((a) => [a.id, a]));
const merged: Attachment[] = [];
for (const a of attachments ?? []) {
if (a.id) seen.add(a.id);
merged.push(a);
}
for (const a of sessionUploads) {
if (!seen.has(a.id)) {
seen.add(a.id);
merged.push(a);
}
const session = a.id ? sessionById.get(a.id) : undefined;
if (session) sessionById.delete(a.id);
merged.push(
session && !a.download_url
? { ...a, download_url: session.download_url }
: a,
);
}
merged.push(...sessionById.values());
return merged;
}, [attachments, sessionUploads]);

View File

@@ -28,6 +28,7 @@ const mockSetKeepOpen = vi.hoisted(() => vi.fn());
const mockToastCustom = vi.hoisted(() => vi.fn());
const mockToastDismiss = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
const mockUploadWithToast = vi.hoisted(() => vi.fn());
const mockDraftStore = {
draft: {
@@ -39,6 +40,23 @@ const mockDraftStore = {
assigneeId: undefined as string | undefined,
startDate: null,
dueDate: null,
attachments: [] as Array<{
id: string;
workspace_id: string;
issue_id: string | null;
comment_id: string | null;
chat_session_id: string | null;
chat_message_id: string | null;
uploader_type: string;
uploader_id: string;
filename: string;
url: string;
download_url: string;
markdown_url: string;
content_type: string;
size_bytes: number;
created_at: string;
}>,
},
lastAssigneeType: undefined,
lastAssigneeId: undefined,
@@ -93,7 +111,7 @@ vi.mock("@multica/core/issues/mutations", () => ({
}));
vi.mock("@multica/core/hooks/use-file-upload", () => ({
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
useFileUpload: () => ({ uploadWithToast: mockUploadWithToast }),
}));
// Hoisted ApiError class so both the vi.mock factory and the tests below
@@ -137,7 +155,7 @@ vi.mock("@multica/core/api", async () => {
});
vi.mock("../editor", () => {
const ContentEditor = forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
const ContentEditor = forwardRef(({ defaultValue, onUpdate, onUploadFile, placeholder, attachments }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
@@ -146,18 +164,21 @@ vi.mock("../editor", () => {
valueRef.current = "";
setValue("");
},
uploadFile: vi.fn(),
uploadFile: (file: File) => onUploadFile?.(file),
}));
return (
<textarea
value={value}
placeholder={placeholder}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onUpdate?.(e.target.value);
}}
/>
<>
<textarea
value={value}
placeholder={placeholder}
data-attachments-count={attachments?.length ?? 0}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onUpdate?.(e.target.value);
}}
/>
</>
);
});
ContentEditor.displayName = "ContentEditor";
@@ -312,10 +333,52 @@ describe("CreateIssueModal", () => {
mockSetKeepOpen.mockImplementation((v: boolean) => {
mockQuickCreateStore.keepOpen = v;
});
mockDraftStore.draft.title = "";
mockDraftStore.draft.description = "";
mockDraftStore.draft.status = "todo";
mockDraftStore.draft.priority = "none";
// Reset the shared draft mock so per-test assignee seeding (squad / agent)
// doesn't leak into the next test in the suite.
mockDraftStore.draft.assigneeType = undefined;
mockDraftStore.draft.assigneeId = undefined;
mockDraftStore.draft.startDate = null;
mockDraftStore.draft.dueDate = null;
mockDraftStore.draft.attachments = [];
mockSetDraft.mockImplementation((patch: Partial<typeof mockDraftStore.draft>) => {
mockDraftStore.draft = { ...mockDraftStore.draft, ...patch };
});
mockClearDraft.mockImplementation(() => {
mockDraftStore.draft = {
title: "",
description: "",
status: "todo",
priority: "none",
assigneeType: mockDraftStore.lastAssigneeType,
assigneeId: mockDraftStore.lastAssigneeId,
startDate: null,
dueDate: null,
attachments: [],
};
});
mockUploadWithToast.mockResolvedValue({
id: "11111111-2222-3333-4444-555555555555",
workspace_id: "ws-test",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
filename: "shot.png",
url: "https://cdn.example.test/shot.png",
download_url: "https://cdn.example.test/shot.png?Signature=fresh",
markdown_url: "https://multica-api.copilothub.ai/api/attachments/11111111-2222-3333-4444-555555555555/download",
content_type: "image/png",
size_bytes: 123,
created_at: "2026-06-12T00:00:00Z",
link: "https://cdn.example.test/shot.png",
markdownLink: "https://multica-api.copilothub.ai/api/attachments/11111111-2222-3333-4444-555555555555/download",
});
mockCreateIssue.mockResolvedValue({
id: "issue-123",
identifier: "TES-123",
@@ -410,6 +473,111 @@ describe("CreateIssueModal", () => {
assigneeId: undefined,
startDate: null,
dueDate: null,
attachments: [],
});
});
it("persists manual-mode uploads in the issue draft", async () => {
const user = userEvent.setup();
renderModal(<CreateIssueModal onClose={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Upload file" }));
await waitFor(() => {
expect(mockSetDraft).toHaveBeenCalledWith({
attachments: [
expect.objectContaining({
id: "11111111-2222-3333-4444-555555555555",
filename: "shot.png",
download_url: "",
}),
],
});
});
const draftAttachmentsCall = mockSetDraft.mock.calls.find(
([patch]) => Array.isArray(patch.attachments),
)?.[0] as { attachments?: Array<{ download_url: string }> } | undefined;
expect(draftAttachmentsCall?.attachments?.[0]?.download_url).not.toContain(
"Signature=",
);
});
it("reuses draft attachments after reopening manual create so pasted images can render and bind", async () => {
const user = userEvent.setup();
const attachment = {
id: "11111111-2222-3333-4444-555555555555",
workspace_id: "ws-test",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
filename: "shot.png",
url: "https://cdn.example.test/shot.png",
download_url: "",
markdown_url: "https://multica-api.copilothub.ai/api/attachments/11111111-2222-3333-4444-555555555555/download",
content_type: "image/png",
size_bytes: 123,
created_at: "2026-06-12T00:00:00Z",
};
mockDraftStore.draft.title = "Image draft";
mockDraftStore.draft.description = `![shot.png](${attachment.markdown_url})`;
mockDraftStore.draft.attachments = [attachment];
renderModal(<CreateIssueModal onClose={vi.fn()} />);
expect(screen.getByPlaceholderText("Add description...")).toHaveAttribute(
"data-attachments-count",
"1",
);
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith(
expect.objectContaining({
description: `![shot.png](${attachment.markdown_url})`,
attachment_ids: ["11111111-2222-3333-4444-555555555555"],
}),
);
});
});
it("prunes draft attachments the reopened description no longer references", async () => {
const referenced = {
id: "11111111-2222-3333-4444-555555555555",
workspace_id: "ws-test",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
filename: "kept.png",
url: "https://cdn.example.test/kept.png",
download_url: "",
markdown_url: "https://multica-api.copilothub.ai/api/attachments/11111111-2222-3333-4444-555555555555/download",
content_type: "image/png",
size_bytes: 123,
created_at: "2026-06-12T00:00:00Z",
};
const deleted = {
...referenced,
id: "99999999-8888-7777-6666-555555555555",
filename: "deleted.png",
url: "https://cdn.example.test/deleted.png",
markdown_url: "https://multica-api.copilothub.ai/api/attachments/99999999-8888-7777-6666-555555555555/download",
};
mockDraftStore.draft.title = "Image draft";
mockDraftStore.draft.description = `![kept.png](${referenced.markdown_url})`;
mockDraftStore.draft.attachments = [referenced, deleted];
renderModal(<CreateIssueModal onClose={vi.fn()} />);
await waitFor(() => {
expect(mockSetDraft).toHaveBeenCalledWith({ attachments: [referenced] });
});
});

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useRef } from "react";
import { useState, useRef, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigation } from "../navigation";
import {
@@ -18,7 +18,8 @@ import {
} 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 type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, Attachment } from "@multica/core/types";
import { contentReferencesAttachment } from "@multica/core/types";
import {
DialogContent,
DialogTitle,
@@ -57,6 +58,17 @@ 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 `<Dialog>` is owned by
@@ -146,13 +158,34 @@ export function ManualCreatePanel({
enabled: !!parentIssueId,
});
// File upload — collect attachment IDs so we can link them after issue creation.
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
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) {
setAttachmentIds((prev) => [...prev, result.id]);
const currentAttachments =
useIssueDraftStore.getState().draft.attachments ?? [];
const attachments = currentAttachments.some((a) => a.id === result.id)
? currentAttachments
: [...currentAttachments, toDraftAttachment(result)];
setDraft({ attachments });
}
return result;
};
@@ -179,7 +212,6 @@ export function ManualCreatePanel({
setProjectId(undefined);
setParentIssueId(undefined);
setChildIssues([]);
setAttachmentIds([]);
setDraft({
title: "",
description: "",
@@ -189,6 +221,7 @@ export function ManualCreatePanel({
assigneeId,
startDate: null,
dueDate: null,
attachments: [],
});
descEditorRef.current?.clearContent();
setFormResetKey((key) => key + 1);
@@ -198,16 +231,20 @@ export function ManualCreatePanel({
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: descEditorRef.current?.getMarkdown()?.trim() || undefined,
description,
status,
priority,
assignee_type: assigneeType,
assignee_id: assigneeId,
start_date: startDate || undefined,
due_date: dueDate || undefined,
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
attachment_ids: activeAttachmentIds.length > 0 ? activeAttachmentIds : undefined,
parent_issue_id: parentIssueId,
project_id: projectId,
});
@@ -482,6 +519,7 @@ export function ManualCreatePanel({
onUpdate={(md) => setDraft({ description: md })}
onUploadFile={handleUpload}
debounceMs={500}
attachments={draftAttachments}
/>
{descDragOver && <FileDropOverlay />}
</div>