"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowLeftRight, Check, ChevronRight, Maximize2, Minimize2, X as XIcon } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { DialogTitle } from "@multica/ui/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@multica/ui/components/ui/dropdown-menu"; import { Button } from "@multica/ui/components/ui/button"; import { Switch } from "@multica/ui/components/ui/switch"; import { api, ApiError } from "@multica/core/api"; import { useWorkspaceId } from "@multica/core/hooks"; import { useCurrentWorkspace } from "@multica/core/paths"; import { agentListOptions } from "@multica/core/workspace/queries"; import { projectListOptions } from "@multica/core/projects/queries"; import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store"; import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store"; import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store"; import { runtimeListOptions, checkQuickCreateCliVersion, readRuntimeCliVersion, MIN_QUICK_CREATE_CLI_VERSION, } from "@multica/core/runtimes"; import { useFileUpload } from "@multica/core/hooks/use-file-upload"; import { formatShortcut, modKey, enterKey } from "@multica/core/platform"; import type { Agent } from "@multica/core/types"; import { ActorAvatar } from "../common/actor-avatar"; import { PillButton } from "../common/pill-button"; 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 { FileUploadButton } from "@multica/ui/components/common/file-upload-button"; import { useT } from "../i18n"; // AgentCreatePanel — agent-mode body of the create-issue dialog. Renders // only the inner content; the surrounding `` AND `` // (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 with an optional // carry payload (currently `project_id`). The shared draft store carries the // description + agent across the agent→manual flip; project_id rides through // the same carry channel manual→agent uses, so the manual panel reads it // from `data?.project_id` without a parallel store. export function AgentCreatePanel({ onClose, onSwitchMode, data, isExpanded, setIsExpanded, }: { onClose: () => void; onSwitchMode?: (carry?: Record | null) => void; data?: Record | null; /** Lifted to the shell so DialogContent's mode-aware className can react — * same pattern as ManualCreatePanel. Shared across modes so the user's * expand preference persists when switching between agent and manual. */ isExpanded: boolean; setIsExpanded: (v: boolean) => void; }) { const { t } = useT("modals"); const workspaceName = useCurrentWorkspace()?.name; const wsId = useWorkspaceId(); const userId = useAuthStore((s) => s.user?.id); const { data: members = [] } = useQuery(memberListOptions(wsId)); const { data: agents = [] } = useQuery(agentListOptions(wsId)); // Pull `isSuccess` so the stale-id sweep below can distinguish "still // loading" from "loaded as empty". Reading length alone treats both as // empty and incorrectly clears a valid persisted preference on every open. const { data: projects = [], isSuccess: projectsLoaded } = useQuery( projectListOptions(wsId), ); const memberRole = useMemo( () => members.find((m) => m.user_id === userId)?.role, [members, userId], ); // Visible = not archived AND assignable by this user. const visibleAgents = useMemo( () => agents.filter( (a) => !a.archived_at && canAssignAgent(a, userId, memberRole), ), [agents, userId, memberRole], ); const lastAgentId = useQuickCreateStore((s) => s.lastAgentId); const setLastAgentId = useQuickCreateStore((s) => s.setLastAgentId); const lastProjectId = useQuickCreateStore((s) => s.lastProjectId); const setLastProjectId = useQuickCreateStore((s) => s.setLastProjectId); const promptDraft = useQuickCreateStore((s) => s.prompt); const setPrompt = useQuickCreateStore((s) => s.setPrompt); const clearPrompt = useQuickCreateStore((s) => s.clearPrompt); const keepOpen = useQuickCreateStore((s) => s.keepOpen); const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen); const setLastMode = useCreateModeStore((s) => s.setLastMode); const [agentId, setAgentId] = useState(() => { const seed = (data?.agent_id as string) || lastAgentId || undefined; if (seed && visibleAgents.some((a) => a.id === seed)) return seed; return visibleAgents[0]?.id; }); // Re-seed once visible list resolves (queries may be empty on first render). useEffect(() => { if (agentId && visibleAgents.some((a) => a.id === agentId)) return; const seed = (data?.agent_id as string) || lastAgentId || undefined; if (seed && visibleAgents.some((a) => a.id === seed)) { setAgentId(seed); return; } const first = visibleAgents[0]; if (first) setAgentId(first.id); }, [visibleAgents, agentId, data?.agent_id, lastAgentId]); const selectedAgent = useMemo( () => visibleAgents.find((a) => a.id === agentId), [visibleAgents, agentId], ); // Project selection — defaults to the last project the user picked in this // workspace. `data?.project_id` lets the modal opener seed a one-shot // override (e.g. a future "+ Issue" button on a project page); it does NOT // replace the persisted default. const [projectId, setProjectId] = useState(() => { const seed = (data?.project_id as string | undefined) ?? lastProjectId; return seed ?? null; }); // Stale-id sweep. Once the project list query has actually resolved // (`isSuccess` — distinct from "data is the empty default during loading"), // a `projectId` that isn't in the list means the project was deleted in // another session. Clear BOTH local state and the persisted preference; // dropping only local state would leave the deleted UUID in `lastProjectId`, // and the next open would re-seed it and submit the same dead value. useEffect(() => { if (!projectsLoaded || projectId === null) return; if (projects.some((p) => p.id === projectId)) return; setProjectId(null); if (lastProjectId === projectId) setLastProjectId(null); }, [projectsLoaded, projects, projectId, lastProjectId, setLastProjectId]); // Daemon CLI version gate. The agent-create flow needs the runtime's // bundled multica CLI to be ≥ MIN_QUICK_CREATE_CLI_VERSION; older // daemons handle attachments and partial-failure retries incorrectly // (see PR #1851 / MUL-1496). Pre-check on the picker so the user gets // immediate feedback instead of waiting for the inbox failure; the // server re-validates as the trust boundary. Dev-built daemons // (git-describe shape) are exempted inside checkQuickCreateCliVersion // — frontend and server share the same signal there, so they agree by // construction across web/desktop/staging without comparing env flags. const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId)); const selectedRuntime = useMemo( () => selectedAgent?.runtime_id ? runtimes.find((r) => r.id === selectedAgent.runtime_id) : undefined, [runtimes, selectedAgent?.runtime_id], ); const versionCheck = useMemo( () => checkQuickCreateCliVersion(readRuntimeCliVersion(selectedRuntime?.metadata)), [selectedRuntime?.metadata], ); const versionBlocked = versionCheck.state !== "ok"; const initialPrompt = (data?.prompt as string) || promptDraft; // 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(null); const [hasContent, setHasContent] = useState(initialPrompt.trim().length > 0); const [submitting, setSubmitting] = useState(false); const [justSent, setJustSent] = useState(false); const [sentCount, setSentCount] = useState(0); const [error, setError] = useState(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, uploading } = useFileUpload(api); const handleUploadFile = useCallback( (file: File) => uploadWithToast(file), [uploadWithToast], ); const { isDragOver, dropZoneProps } = useFileDropZone({ onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)), }); useEffect(() => { // 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 () => { const md = editorRef.current?.getMarkdown()?.trim() ?? ""; if (!md || !agentId || submitting || versionBlocked || uploading) return; setSubmitting(true); setError(null); try { await api.quickCreateIssue({ agent_id: agentId, prompt: md, project_id: projectId ?? undefined, }); setLastAgentId(agentId); setLastProjectId(projectId); clearPrompt(); setLastMode("agent"); toast.success(t(($) => $.create_issue.agent.toast_sent), { duration: 4000, }); if (keepOpen) { // Stay open for continuous creation — clear the editor so the // user can immediately type the next prompt. editorRef.current?.clearContent(); setHasContent(false); setSentCount((c) => c + 1); setJustSent(true); setTimeout(() => setJustSent(false), 1500); requestAnimationFrame(() => editorRef.current?.focus()); } else { onClose(); } } catch (e) { // Server returns 422 with { code, ... } for the structured rejection // paths the modal cares about. Surface the reason in-modal so the // user can switch to a live agent / upgrade their daemon without // leaving the flow. if (e instanceof ApiError && e.body && typeof e.body === "object") { const body = e.body as { code?: string; reason?: string; current_version?: string; min_version?: string; }; if (body.code === "agent_unavailable") { setError(body.reason || t(($) => $.create_issue.agent.error_agent_unavailable_fallback)); setSubmitting(false); return; } if (body.code === "daemon_version_unsupported") { // Race fallback: the picker pre-check should normally catch this, // but a runtime can silently re-register with an older CLI between // pre-check and submit. Same wording as the inline notice for // consistency. const cur = body.current_version || "unknown"; setError( t(($) => $.create_issue.agent.error_daemon_version, { current: cur, min: body.min_version || MIN_QUICK_CREATE_CLI_VERSION, }), ); setSubmitting(false); return; } } setError(t(($) => $.create_issue.agent.error_unknown)); } finally { setSubmitting(false); } }; // 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: md, ...(agentId ? { assigneeType: "agent" as const, assigneeId: agentId } : {}), }); setLastMode("manual"); // Hand the picked project to the manual panel through the same `data` // channel that already carries agent_id / parent_issue_id. The manual // panel reads `data.project_id` on mount; this preserves the user's // selection across the mode flip without piping a third store through. onSwitchMode?.(projectId ? { project_id: projectId } : null); }; return ( <> {t(($) => $.create_issue.sr_agent)} {/* Header */}
{workspaceName} {t(($) => $.create_issue.agent_breadcrumb)}
{/* 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. Same workaround applies to expand. */}
{/* Agent picker */}
$.create_issue.agent.select_agent_aria)} className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded-sm px-1.5 py-1 -ml-1.5 hover:bg-accent/60" > {t(($) => $.create_issue.agent.created_by)} {selectedAgent ? ( {selectedAgent.name} ) : ( {t(($) => $.create_issue.agent.pick_an_agent)} )} } /> {visibleAgents.length === 0 ? (
{t(($) => $.create_issue.agent.no_agents)}
) : ( visibleAgents.map((a: Agent) => ( { setAgentId(a.id); setError(null); }} className="flex items-center gap-2" > {a.name} {agentId === a.id && ( )} )) )}
{selectedAgent && versionBlocked && (
{versionCheck.state === "missing" ? t(($) => $.create_issue.agent.version_missing, { min: versionCheck.min }) : t(($) => $.create_issue.agent.version_below, { current: versionCheck.current, min: versionCheck.min, })}
)} {/* 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. */} {/* `flex-1 min-h-0 overflow-y-auto` so the editor area absorbs the remaining vertical space inside the (now max-bounded) DialogContent and scrolls internally. Without it, pasting an image expanded the editor unbounded and pushed the modal past the viewport. */}
$.create_issue.agent.prompt_placeholder)} onUpdate={(md) => { setHasContent(md.trim().length > 0); setPrompt(md); }} onUploadFile={handleUploadFile} onSubmit={submit} debounceMs={150} /> {isDragOver && }
{error && (
{error}
)} {/* Property toolbar — mirrors the manual panel's pill row so the project pill sits in the same place across both modes. Agent mode owns only the project (status / priority / assignee / due-date are inferred from the prompt), so it's a single pill. The pick is persisted per-workspace via useQuickCreateStore.lastProjectId so users targeting one project skip retyping "in project X". */}
setProjectId(u.project_id ?? null)} triggerRender={} align="start" />
{/* Footer */}
editorRef.current?.uploadFile(file)} /> {keepOpen && sentCount > 0 && ( {t(($) => $.create_issue.agent.sent_count, { count: sentCount })} )}
); }