Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
75c9a5c742 refactor(create-issue): unify agent/manual modes under one Dialog shell
Recasts Quick/Advanced as Agent/Manual and lets users flip between modes
in-place from a footer switch button instead of a separate Advanced
shortcut. The two old modal types now route through one CreateIssueDialog
shell that owns the single <Dialog> and <DialogContent> — only the inner
panel body swaps on mode change, so the Portal/Backdrop/Popup stay
mounted and the switch is instant (no close→open animation flash).

Mode preference is persisted globally in localStorage via a small
useCreateModeStore, so the `c` shortcut always opens whichever mode the
user last used (or switched to). Carry payload (description / agent /
prompt) hands off through the shell's local state plus the existing
issue-draft store, so nothing the user typed is lost across switches.

Also drops the Shift+C → manual branch — `c` is now mode-agnostic and
the in-modal switch covers the same intent without users having to
remember a second shortcut.

Visible labels: "Quick create" → "Create with agent",
"New issue" → "Create manually".
2026-04-29 14:54:25 +08:00
7 changed files with 371 additions and 143 deletions

View File

@@ -0,0 +1,36 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { defaultStorage } from "../../platform/storage";
/**
* Last create-issue mode the user landed on. Drives the global `c` shortcut
* and the in-modal mode switch — pressing `c` opens whichever modal the user
* used last, and the switch button in either modal updates this so the
* preference sticks.
*
* Workspace-agnostic on purpose: the user's mental preference for "how do I
* file an issue" doesn't change per workspace, so this lives in plain
* localStorage rather than the workspace-aware StateStorage that scopes
* per-workspace stores like quick-create-store / draft-store.
*/
export type CreateMode = "agent" | "manual";
interface CreateModeState {
lastMode: CreateMode;
setLastMode: (mode: CreateMode) => void;
}
export const useCreateModeStore = create<CreateModeState>()(
persist(
(set) => ({
lastMode: "agent",
setLastMode: (mode) => set({ lastMode: mode }),
}),
{
name: "multica_create_mode",
storage: createJSONStorage(() => defaultStorage),
},
),
);

View File

@@ -1,4 +1,5 @@
export { useIssueSelectionStore } from "./selection-store";
export { useCreateModeStore, type CreateMode } from "./create-mode-store";
export { useIssueDraftStore } from "./draft-store";
export { useRecentIssuesStore, type RecentIssueEntry } from "./recent-issues-store";
export {

View File

@@ -38,6 +38,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@multica/ui/components/ui/collapsible";
import { StatusIcon } from "../issues/components/status-icon";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
import {
Sidebar,
SidebarContent,
@@ -395,13 +396,14 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
},
});
// Global "C" shortcut: opens the quick-create modal by default; Shift+C
// jumps straight to the legacy advanced form for users who want every
// field. Both branches honor the same focus / open-modal guards.
// Global "C" shortcut: opens whichever create mode the user landed on last
// (agent vs manual), persisted in useCreateModeStore. The mode switch lives
// inside both modal footers so users can flip without remembering which
// shortcut goes where — `c` always means "open the create flow I prefer".
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "c" && e.key !== "C") return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
const tag = (e.target as HTMLElement)?.tagName;
const isEditable =
tag === "INPUT" ||
@@ -411,10 +413,11 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
if (isEditable) return;
if (useModalStore.getState().modal) return;
e.preventDefault();
// Auto-fill project when on a project detail page (advanced form only —
// quick-create lets the agent infer project from prompt).
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
if (e.shiftKey) {
const lastMode = useCreateModeStore.getState().lastMode;
if (lastMode === "manual") {
// Auto-fill project when on a project detail page (manual form only —
// agent mode lets the agent infer project from the prompt).
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
const data = projectMatch ? { project_id: projectMatch[1] } : undefined;
useModalStore.getState().open("create-issue", data);
} else {

View File

@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { cn } from "@multica/ui/lib/utils";
import { Dialog, DialogContent } from "@multica/ui/components/ui/dialog";
import {
useCreateModeStore,
type CreateMode,
} from "@multica/core/issues/stores/create-mode-store";
import { AgentCreatePanel } from "./quick-create-issue";
import { ManualCreatePanel, manualDialogContentClass } from "./create-issue";
/**
* Shell that owns the single `<Dialog>` AND `<DialogContent>` for the
* create-issue flow. Mode switching unmounts/mounts only the inner panel
* body — the Portal, Backdrop, and Popup all stay in the DOM, so Base UI
* never replays the open animation. That's what makes the switch feel
* instant; an earlier version put `<DialogContent>` inside each panel and
* the close→open animation cycle still fired on every toggle.
*
* `initialMode` comes from the modal registry (`quick-create-issue` →
* agent, `create-issue` → manual). Subsequent switches are local state
* only and never round-trip through the modal store.
*
* Carry payload: when a panel switches mode it can hand a payload up via
* `onSwitchMode`; the shell stores it as the next panel's `data` so seeding
* works exactly like a fresh open.
*
* Manual-mode `isExpanded` / `backlogHintIssueId` are lifted up because they
* drive `DialogContent`'s className — the className lives here in the shell
* since the Popup is here, but the toggles for those states live in the
* manual panel body.
*/
export function CreateIssueDialog({
onClose,
initialMode,
data,
}: {
onClose: () => void;
initialMode: CreateMode;
data?: Record<string, unknown> | null;
}) {
const setLastMode = useCreateModeStore((s) => s.setLastMode);
const [mode, setMode] = useState<CreateMode>(initialMode);
const [panelData, setPanelData] = useState(data ?? null);
const [isExpanded, setIsExpanded] = useState(false);
const [backlogHintIssueId, setBacklogHintIssueId] = useState<string | null>(null);
const switchTo = (next: CreateMode) => (carry?: Record<string, unknown> | null) => {
setLastMode(next);
setPanelData(carry ?? null);
setMode(next);
};
const className =
mode === "agent"
? cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2",
"!max-w-xl !w-full",
// Smooth size transition when switching modes — the manual mode
// uses the same easing.
"!transition-all !duration-300 !ease-out",
)
: manualDialogContentClass(isExpanded, backlogHintIssueId);
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
finalFocus={false}
showCloseButton={false}
className={className}
>
{mode === "agent" ? (
<AgentCreatePanel
onClose={onClose}
onSwitchMode={switchTo("manual")}
data={panelData}
/>
) : (
<ManualCreatePanel
onClose={onClose}
onSwitchMode={switchTo("agent")}
data={panelData}
isExpanded={isExpanded}
setIsExpanded={setIsExpanded}
backlogHintIssueId={backlogHintIssueId}
setBacklogHintIssueId={setBacklogHintIssueId}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { useNavigation } from "../navigation";
import {
ArrowDown,
ArrowLeftRight,
ArrowUp,
Check,
ChevronRight,
@@ -17,7 +18,6 @@ import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "@multica/core/types";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
@@ -37,6 +37,7 @@ 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 { issueDetailOptions } from "@multica/core/issues/queries";
import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
@@ -46,10 +47,34 @@ import { PillButton } from "../common/pill-button";
import { IssuePickerModal } from "./issue-picker-modal";
// ---------------------------------------------------------------------------
// CreateIssueModal
// 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 CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record<string, unknown> | null }) {
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 router = useNavigation();
const p = useWorkspacePaths();
const workspaceName = useCurrentWorkspace()?.name;
@@ -58,6 +83,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
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 [title, setTitle] = useState(draft.title);
const descEditorRef = useRef<ContentEditorRef>(null);
@@ -81,9 +107,6 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
// 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);
const [isExpanded, setIsExpanded] = useState(false);
const [backlogHintIssueId, setBacklogHintIssueId] = useState<string | null>(null);
// Fetch parent issue details for the chip (status/identifier/title).
// List cache usually has it already, so this resolves synchronously.
const wsId = useWorkspaceId();
@@ -155,6 +178,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
}
setLastAssignee(assigneeType, assigneeId);
setLastMode("manual");
clearDraft();
const shouldShowBacklogHint =
status === "backlog" && assigneeType === "agent" && assigneeId &&
@@ -199,32 +223,25 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
}
};
// 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.
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 }
: {}),
});
};
return (
<Dialog
open
onOpenChange={(v) => {
if (!v) {
setBacklogHintIssueId(null);
onClose();
}
}}
>
<DialogContent
finalFocus={false}
showCloseButton={false}
className={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"
: "",
)}
>
<>
{backlogHintIssueId ? (
<BacklogAgentHintContent
onKeepInBacklog={() => {
@@ -252,7 +269,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
<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">New issue</span>
<span className="font-medium">Create manually</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
@@ -481,13 +498,74 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
<FileUploadButton
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
/>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={switchToAgent}
title="Switch to create with agent — describe in one line and let the agent file it"
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
>
<ArrowLeftRight className="size-3.5" />
Switch to agent
</button>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
</>
);
}
/** 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>
);
}

View File

@@ -1,22 +1,16 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, Sparkles, X as XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowLeftRight, ChevronRight, Sparkles, X as XIcon } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { DialogTitle } from "@multica/ui/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import { api, ApiError } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -24,22 +18,36 @@ import { useCurrentWorkspace } from "@multica/core/paths";
import { agentListOptions } from "@multica/core/workspace/queries";
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import { useModalStore } from "@multica/core/modals";
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import type { Agent } from "@multica/core/types";
import { ActorAvatar } from "../common/actor-avatar";
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";
// QuickCreateIssueModal — a streamlined create-issue UI: pick an agent, type
// one line, submit. The agent translates the line into a `multica issue
// create` call asynchronously; the modal closes immediately and the user is
// notified via inbox when the agent finishes.
export function QuickCreateIssueModal({
// AgentCreatePanel — agent-mode body of the create-issue dialog. Renders
// only the inner content; the surrounding `<Dialog>` AND `<DialogContent>`
// (Portal + Overlay + Popup) are owned by CreateIssueDialog so mode-switching
// swaps only this body. Lifting the Portal is what eliminates the close→open
// animation flash — Base UI replays Popup enter/exit when DialogContent is
// remounted, even inside a still-open Dialog Root.
//
// `onSwitchMode` is wired by the shell — the panel calls it (no payload from
// agent → manual; the shared draft store carries description + agent).
export function AgentCreatePanel({
onClose,
onSwitchMode,
data,
}: {
onClose: () => void;
onSwitchMode?: () => void;
data?: Record<string, unknown> | null;
}) {
const workspaceName = useCurrentWorkspace()?.name;
@@ -64,6 +72,7 @@ export function QuickCreateIssueModal({
const lastAgentId = useQuickCreateStore((s) => s.lastAgentId);
const setLastAgentId = useQuickCreateStore((s) => s.setLastAgentId);
const setLastMode = useCreateModeStore((s) => s.setLastMode);
const [agentId, setAgentId] = useState<string | undefined>(() => {
const seed = (data?.agent_id as string) || lastAgentId || undefined;
@@ -89,22 +98,43 @@ export function QuickCreateIssueModal({
);
const initialPrompt = (data?.prompt as string) || "";
const [prompt, setPrompt] = useState(initialPrompt);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// The editor is uncontrolled — we read the latest markdown via the ref at
// submit/switch time. `hasContent` mirrors emptiness so the Create button
// can disable correctly without a controlled-input rerender on every keystroke.
const editorRef = useRef<ContentEditorRef>(null);
const [hasContent, setHasContent] = useState(initialPrompt.trim().length > 0);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Image paste/drop support: route uploads through the same helper Advanced
// uses, so users can paste screenshots straight into the prompt and the
// agent receives them as embedded markdown image URLs in the prompt.
const { uploadWithToast } = useFileUpload(api);
const handleUploadFile = useCallback(
(file: File) => uploadWithToast(file),
[uploadWithToast],
);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
});
useEffect(() => {
textareaRef.current?.focus();
// Defer focus so it lands after the dialog's focus trap has settled —
// otherwise the trap can bounce focus back to the first focusable header
// button on the next tick.
const id = requestAnimationFrame(() => editorRef.current?.focus());
return () => cancelAnimationFrame(id);
}, []);
const submit = async () => {
if (!prompt.trim() || !agentId || submitting) return;
const md = editorRef.current?.getMarkdown()?.trim() ?? "";
if (!md || !agentId || submitting) return;
setSubmitting(true);
setError(null);
try {
await api.quickCreateIssue({ agent_id: agentId, prompt: prompt.trim() });
await api.quickCreateIssue({ agent_id: agentId, prompt: md });
setLastAgentId(agentId);
setLastMode("agent");
toast.success("Sent to agent — you'll get an inbox notification when it's done", {
duration: 4000,
});
@@ -127,39 +157,26 @@ export function QuickCreateIssueModal({
}
};
// Switch to the legacy advanced form, carrying the prompt over as the
// description so the user doesn't lose what they typed. The picked agent
// becomes the default assignee candidate (still editable). We seed the
// shared issue-draft store directly because the legacy modal reads its
// initial values from there rather than from `data`.
const switchToAdvanced = () => {
// Switch to the manual form, carrying what the user typed over as the
// description (markdown, including any pasted images) so they don't lose
// their work. The picked agent becomes the default assignee candidate
// (still editable). We seed the shared issue-draft store directly because
// the manual panel reads its initial values from there. Persist the mode
// flip so the next `c` lands in manual.
const switchToManual = () => {
const md = editorRef.current?.getMarkdown() ?? "";
useIssueDraftStore.getState().setDraft({
description: prompt,
description: md,
...(agentId
? { assigneeType: "agent" as const, assigneeId: agentId }
: {}),
});
useModalStore.getState().open("create-issue");
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submit();
}
setLastMode("manual");
onSwitchMode?.();
};
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
finalFocus={false}
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2",
"!max-w-xl !w-full",
)}
>
<>
<DialogTitle className="sr-only">Quick create issue</DialogTitle>
{/* Header */}
@@ -167,38 +184,20 @@ export function QuickCreateIssueModal({
<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">Quick create</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<button
onClick={switchToAdvanced}
className="text-xs px-2 py-1 rounded-sm opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
Advanced
</button>
}
/>
<TooltipContent side="bottom">
Open the full form with all fields
</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">Close</TooltipContent>
</Tooltip>
<span className="font-medium">Create with agent</span>
</div>
{/* Native `title` instead of Base UI Tooltip — Tooltip opens on
keyboard focus, and the dialog's focus trap briefly lands focus
on the first focusable element on mount, causing the tooltip to
auto-pop every open. */}
<button
onClick={onClose}
title="Close"
aria-label="Close"
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<XIcon className="size-4" />
</button>
</div>
{/* Agent picker */}
@@ -255,17 +254,23 @@ export function QuickCreateIssueModal({
</DropdownMenu>
</div>
{/* Prompt textarea */}
<div className="px-5 pb-3">
<textarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
{/* Prompt — same rich editor Advanced uses, so paste/drop images,
mentions, and formatting all work. The dropZone wrapper enables
drag-and-drop file uploads alongside paste. */}
<div
{...dropZoneProps}
className="relative px-5 pb-3 min-h-[140px]"
>
<ContentEditor
ref={editorRef}
defaultValue={initialPrompt}
placeholder='Describe the issue, e.g. "fix inbox loading slowness, assign to naiyuan, P1"'
rows={5}
className="w-full resize-none bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none"
onUpdate={(md) => setHasContent(md.trim().length > 0)}
onUploadFile={handleUploadFile}
onSubmit={submit}
debounceMs={150}
/>
{isDragOver && <FileDropOverlay />}
</div>
{error && (
@@ -275,15 +280,25 @@ export function QuickCreateIssueModal({
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
<span className="text-xs text-muted-foreground"> to submit</span>
<Button
size="sm"
onClick={submit}
disabled={!prompt.trim() || !agentId || submitting}
>
{submitting ? "Sending…" : "Create"}
</Button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={switchToManual}
title="Switch to manual create — fill the fields yourself"
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
>
<ArrowLeftRight className="size-3.5" />
Switch to manual
</button>
<Button
size="sm"
onClick={submit}
disabled={!hasContent || !agentId || submitting}
>
{submitting ? "Sending…" : "Create"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -2,8 +2,7 @@
import { useModalStore } from "@multica/core/modals";
import { CreateWorkspaceModal } from "./create-workspace";
import { CreateIssueModal } from "./create-issue";
import { QuickCreateIssueModal } from "./quick-create-issue";
import { CreateIssueDialog } from "./create-issue-dialog";
import { CreateProjectModal } from "./create-project";
import { FeedbackModal } from "./feedback";
import { SetParentIssueModal } from "./set-parent-issue";
@@ -19,10 +18,12 @@ export function ModalRegistry() {
switch (modal) {
case "create-workspace":
return <CreateWorkspaceModal onClose={close} />;
// Both modal types open the same shell so the in-modal mode switch is
// instant — only the inner panel swaps, the Dialog Root stays mounted.
case "create-issue":
return <CreateIssueModal onClose={close} data={data} />;
return <CreateIssueDialog onClose={close} initialMode="manual" data={data} />;
case "quick-create-issue":
return <QuickCreateIssueModal onClose={close} data={data} />;
return <CreateIssueDialog onClose={close} initialMode="agent" data={data} />;
case "create-project":
return <CreateProjectModal onClose={close} />;
case "feedback":