mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 18:09:14 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/copi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67e4789692 |
@@ -5,11 +5,16 @@ import { Paperclip } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
interface FileUploadButtonProps {
|
||||
/** Called with the selected File — caller handles upload. */
|
||||
/** Called once per selected file — caller handles upload. The native
|
||||
* picker now allows multi-select by default, so this fires N times for
|
||||
* N files in a single open. Callers don't need to change anything to
|
||||
* opt in. Set `multiple={false}` to restore single-file behavior. */
|
||||
onSelect: (file: File) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
size?: "sm" | "default";
|
||||
/** Allow multi-select in the native picker. Default true. */
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
function FileUploadButton({
|
||||
@@ -17,14 +22,16 @@ function FileUploadButton({
|
||||
disabled,
|
||||
className,
|
||||
size = "default",
|
||||
multiple = true,
|
||||
}: FileUploadButtonProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
const list = Array.from(files);
|
||||
e.target.value = "";
|
||||
onSelect(file);
|
||||
for (const file of list) onSelect(file);
|
||||
};
|
||||
|
||||
const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
|
||||
@@ -49,6 +56,7 @@ function FileUploadButton({
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { FileText, Loader2, Download } from "lucide-react";
|
||||
import { FileText, Loader2, Download, Trash2 } from "lucide-react";
|
||||
import { useT } from "../../i18n";
|
||||
import { useAttachmentDownloadResolver } from "../attachment-download-context";
|
||||
|
||||
@@ -30,12 +30,13 @@ import { useAttachmentDownloadResolver } from "../attachment-download-context";
|
||||
// React NodeView
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FileCardView({ node }: NodeViewProps) {
|
||||
function FileCardView({ node, editor, deleteNode }: NodeViewProps) {
|
||||
const { t } = useT("editor");
|
||||
const href = (node.attrs.href as string) || "";
|
||||
const filename = (node.attrs.filename as string) || "";
|
||||
const uploading = node.attrs.uploading as boolean;
|
||||
const { openByUrl } = useAttachmentDownloadResolver();
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const openFile = () => {
|
||||
openByUrl(href);
|
||||
@@ -69,6 +70,21 @@ function FileCardView({ node }: NodeViewProps) {
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isEditable && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.file_card.delete)}
|
||||
aria-label={t(($) => $.file_card.delete)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
deleteNode();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
"copy_code": "Copy code"
|
||||
},
|
||||
"file_card": {
|
||||
"uploading": "Uploading {{filename}}"
|
||||
"uploading": "Uploading {{filename}}",
|
||||
"delete": "Remove attachment"
|
||||
},
|
||||
"title_editor": {
|
||||
"title_aria_label": "Title"
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
"copy_code": "复制代码"
|
||||
},
|
||||
"file_card": {
|
||||
"uploading": "正在上传 {{filename}}"
|
||||
"uploading": "正在上传 {{filename}}",
|
||||
"delete": "移除附件"
|
||||
},
|
||||
"title_editor": {
|
||||
"title_aria_label": "标题"
|
||||
|
||||
@@ -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 {
|
||||
@@ -49,6 +49,34 @@ import { PillButton } from "../common/pill-button";
|
||||
import { IssuePickerModal } from "./issue-picker-modal";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Strip embedded attachment markdown nodes (file-card + images) from a
|
||||
* draft body. Used when the user closes the create-issue modal without
|
||||
* submitting: the typed text is preserved as a draft, but uploaded
|
||||
* attachments do NOT carry over to the next "create issue" session.
|
||||
* This addresses the historical bug where attachment X uploaded into
|
||||
* Issue A's abandoned draft would still appear when opening Issue B
|
||||
* with no way to clear it. */
|
||||
export function stripAttachmentMarkdown(md: string): string {
|
||||
if (!md) return md;
|
||||
return md
|
||||
.split("\n")
|
||||
.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
// !file[name](url) — custom file-card syntax.
|
||||
if (/^!file\[[^\]]*\]\(https?:\/\/[^)]+\)\s*$/.test(trimmed)) return false;
|
||||
//  — image syntax. Lines that are nothing but an image.
|
||||
if (/^!\[[^\]]*\]\(https?:\/\/[^)]+\)\s*$/.test(trimmed)) return false;
|
||||
return true;
|
||||
})
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ManualCreatePanel — manual-mode body of the create-issue dialog. Renders
|
||||
// DialogContent + everything inside; the surrounding `<Dialog>` is owned by
|
||||
@@ -145,6 +173,23 @@ export function ManualCreatePanel({
|
||||
|
||||
const createIssueMutation = useCreateIssue();
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
|
||||
// Tracks whether the panel was unmounted via a successful submit. On a
|
||||
// plain close (cancel / overlay click) we strip uploaded attachments out
|
||||
// of the persisted draft so the next "create issue" session starts with
|
||||
// a clean attachment area while preserving any typed title / body text.
|
||||
const submittedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (submittedRef.current) return;
|
||||
const current = useIssueDraftStore.getState().draft.description ?? "";
|
||||
const cleaned = stripAttachmentMarkdown(current);
|
||||
if (cleaned !== current) {
|
||||
useIssueDraftStore.getState().setDraft({ description: cleaned });
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetForNextIssue = () => {
|
||||
setTitle("");
|
||||
setStatus("todo");
|
||||
@@ -165,6 +210,9 @@ export function ManualCreatePanel({
|
||||
});
|
||||
descEditorRef.current?.clearContent();
|
||||
setFormResetKey((key) => key + 1);
|
||||
// Re-arm the unmount cleanup: if the user uploads in this fresh
|
||||
// round and then closes without submitting, strip again.
|
||||
submittedRef.current = false;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -211,6 +259,7 @@ export function ManualCreatePanel({
|
||||
|
||||
setLastAssignee(assigneeType, assigneeId);
|
||||
setLastMode("manual");
|
||||
submittedRef.current = true;
|
||||
clearDraft();
|
||||
const shouldShowBacklogHint =
|
||||
status === "backlog" && assigneeType === "agent" && assigneeId &&
|
||||
|
||||
@@ -78,8 +78,11 @@ vi.mock("@multica/core/projects/queries", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/stores/quick-create-store", () => ({
|
||||
useQuickCreateStore: (selector?: (state: typeof mockQuickCreateStore) => unknown) =>
|
||||
(selector ? selector(mockQuickCreateStore) : mockQuickCreateStore),
|
||||
useQuickCreateStore: Object.assign(
|
||||
(selector?: (state: typeof mockQuickCreateStore) => unknown) =>
|
||||
(selector ? selector(mockQuickCreateStore) : mockQuickCreateStore),
|
||||
{ getState: () => mockQuickCreateStore },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/stores/create-mode-store", () => ({
|
||||
|
||||
@@ -36,13 +36,9 @@ import { ProjectPicker } from "../projects/components/project-picker";
|
||||
import { canAssignAgent } from "../issues/components/pickers/assignee-picker";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import {
|
||||
ContentEditor,
|
||||
type ContentEditorRef,
|
||||
useFileDropZone,
|
||||
FileDropOverlay,
|
||||
} from "../editor";
|
||||
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { stripAttachmentMarkdown } from "./create-issue";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
// AgentCreatePanel — agent-mode body of the create-issue dialog. Renders
|
||||
@@ -210,6 +206,21 @@ export function AgentCreatePanel({
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
// On unmount without a successful submit, strip any uploaded attachments
|
||||
// from the persisted prompt draft so the next "create issue" session
|
||||
// starts with a clean attachment area while preserving any typed text.
|
||||
const submittedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (submittedRef.current) return;
|
||||
const current = useQuickCreateStore.getState().prompt ?? "";
|
||||
const cleaned = stripAttachmentMarkdown(current);
|
||||
if (cleaned !== current) {
|
||||
useQuickCreateStore.getState().setPrompt(cleaned);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
const md = editorRef.current?.getMarkdown()?.trim() ?? "";
|
||||
if (!md || !agentId || submitting || versionBlocked || uploading) return;
|
||||
@@ -223,6 +234,7 @@ export function AgentCreatePanel({
|
||||
});
|
||||
setLastAgentId(agentId);
|
||||
setLastProjectId(projectId);
|
||||
submittedRef.current = true;
|
||||
clearPrompt();
|
||||
setLastMode("agent");
|
||||
toast.success(t(($) => $.create_issue.agent.toast_sent), {
|
||||
@@ -237,6 +249,8 @@ export function AgentCreatePanel({
|
||||
setJustSent(true);
|
||||
setTimeout(() => setJustSent(false), 1500);
|
||||
requestAnimationFrame(() => editorRef.current?.focus());
|
||||
// Re-arm the unmount cleanup for the next round.
|
||||
submittedRef.current = false;
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user