"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 { 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, squadListOptions } from "@multica/core/workspace/queries"; import { projectListOptions } from "@multica/core/projects/queries"; import { useQuickCreateStore, type QuickCreateActorType, } 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 { contentReferencesAttachment, type Agent, type Attachment, type Squad } 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 { PropertyPicker, PickerItem, PickerSection, PickerEmpty, } from "../issues/components/pickers/property-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"; import { matchesPinyin } from "../editor/extensions/pinyin-match"; type ActorSelection = | { type: "agent"; id: string } | { type: "squad"; id: string }; // 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)); const { data: squads = [] } = useQuery(squadListOptions(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. Squads inherit // their leader agent's reachability: the backend always routes a squad // pick to the leader, so hiding squads whose leader isn't visible keeps // the picker honest with what the server would actually accept. const visibleAgents = useMemo( () => agents.filter( (a) => !a.archived_at && canAssignAgent(a, userId, memberRole), ), [agents, userId, memberRole], ); const visibleAgentIds = useMemo( () => new Set(visibleAgents.map((a) => a.id)), [visibleAgents], ); const visibleSquads = useMemo( () => squads.filter( (s) => !s.archived_at && visibleAgentIds.has(s.leader_id), ), [squads, visibleAgentIds], ); const lastActorType = useQuickCreateStore((s) => s.lastActorType); const lastActorId = useQuickCreateStore((s) => s.lastActorId); const setLastActor = useQuickCreateStore((s) => s.setLastActor); 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); // Resolve a candidate actor against the currently-visible agents / squads. // Returns null when the candidate doesn't exist in this workspace right // now (deleted, archived, permission revoked, etc.) so callers can fall // through to the next seed in the chain. const resolveActor = useCallback( ( type: QuickCreateActorType | "agent" | "squad" | null | undefined, id: string | null | undefined, ): ActorSelection | null => { if (!type || !id) return null; if (type === "squad" && visibleSquads.some((s) => s.id === id)) { return { type: "squad", id }; } if (type === "agent" && visibleAgentIds.has(id)) { return { type: "agent", id }; } return null; }, [visibleSquads, visibleAgentIds], ); const seedActor = useCallback((): ActorSelection | null => { // Caller-provided seed wins (e.g. shell pre-seeds with `agent_id` / // `squad_id`), then persisted preference, then first visible agent. const dataAgent = data?.agent_id as string | undefined; const dataSquad = data?.squad_id as string | undefined; return ( resolveActor("agent", dataAgent) || resolveActor("squad", dataSquad) || resolveActor(lastActorType, lastActorId) || (visibleAgents[0] ? ({ type: "agent", id: visibleAgents[0].id } as const) : null) ); }, [resolveActor, data?.agent_id, data?.squad_id, lastActorType, lastActorId, visibleAgents]); const [actor, setActor] = useState(() => seedActor()); // Re-seed once visible lists resolve (queries may be empty on first render). useEffect(() => { if (actor && resolveActor(actor.type, actor.id)) return; setActor(seedActor()); }, [actor, resolveActor, seedActor]); const selectedAgent = useMemo(() => { if (!actor) return undefined; if (actor.type === "agent") return visibleAgents.find((a) => a.id === actor.id); const squad = visibleSquads.find((s) => s.id === actor.id); if (!squad) return undefined; return visibleAgents.find((a) => a.id === squad.leader_id); }, [actor, visibleAgents, visibleSquads]); const selectedSquad = useMemo(() => { if (actor?.type !== "squad") return undefined; return visibleSquads.find((s) => s.id === actor.id); }, [actor, visibleSquads]); // 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; }); // Parent-issue context — seeded by `openCreateSubIssue` when the modal is // opened from the "Add sub issue" entry on an existing issue. We carry it // through (not as an editable form field) so a manual→agent flip preserves // the sub-issue intent; the agent panel never exposes this as a picker. // Identifier is best-effort display context only — the UUID is the // authoritative reference the backend/agent uses for `--parent `. const parentIssueId = (data?.parent_issue_id as string | undefined) ?? undefined; const parentIssueIdentifier = (data?.parent_issue_identifier as string | undefined) ?? undefined; // 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); const [pendingAttachments, setPendingAttachments] = useState([]); // 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(async (file: File) => { const result = await uploadWithToast(file); if (result) { setPendingAttachments((prev) => prev.some((a) => a.id === result.id) ? prev : [...prev, result], ); } return result; }, [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 || !actor || submitting || versionBlocked || uploading) return; const activeAttachmentIds = pendingAttachments .filter((a) => contentReferencesAttachment(md, a)) .map((a) => a.id); setSubmitting(true); setError(null); try { await api.quickCreateIssue({ ...(actor.type === "agent" ? { agent_id: actor.id } : { squad_id: actor.id }), prompt: md, project_id: projectId ?? undefined, parent_issue_id: parentIssueId, ...(activeAttachmentIds.length > 0 ? { attachment_ids: activeAttachmentIds } : {}), }); setLastActor(actor.type, actor.id); 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(); setPendingAttachments([]); 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( e instanceof Error && e.message ? e.message : 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 actor (agent or squad) 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, ...(actor ? { assigneeType: actor.type, assigneeId: actor.id } : {}), }); setLastMode("manual"); // Hand the picked project and the parent-issue context to the manual // panel through the same `data` channel that already carries agent_id / // parent_issue_id. The manual panel reads these on mount; this preserves // the user's selection (and the sub-issue intent seeded by // openCreateSubIssue) across the mode flip without piping a third store // through. const carry: Record = {}; if (projectId) carry.project_id = projectId; if (parentIssueId) carry.parent_issue_id = parentIssueId; if (parentIssueIdentifier) carry.parent_issue_identifier = parentIssueIdentifier; onSwitchMode?.(Object.keys(carry).length > 0 ? carry : 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. */}
{/* Actor picker — agents and squads in one searchable list. Squads route to their leader agent on the backend; the leader runs the quick-create flow with the squad's Operating Protocol layered on top, so a squad pick is "ask this squad to file the issue". */}
{ setActor(next); setError(null); }} t={t} />
{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} attachments={pendingAttachments} 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". When the modal was opened from "Add sub issue" on an existing issue, a read-only chip on the same row tells the user that the new issue will be filed as a sub-issue of that parent — the agent handles the wiring silently, but surfacing the relationship avoids "where did this end up?" surprise. We deliberately keep it non-editable: changing the parent is a `Set parent` action on the parent itself, not a knob in the quick-create flow. */}
setProjectId(u.project_id ?? null)} triggerRender={} align="start" /> {parentIssueId && ( $.create_issue.agent.sub_issue_of, { identifier: parentIssueIdentifier ?? "", })} > {t(($) => $.create_issue.agent.sub_issue_of, { identifier: parentIssueIdentifier ?? "", })} )}
{/* Footer */}
editorRef.current?.uploadFile(file)} /> {keepOpen && sentCount > 0 && ( {t(($) => $.create_issue.agent.sent_count, { count: sentCount })} )}
); } // ActorPicker — the "Created by" trigger + searchable popover listing // agents and squads. Lives in this file (not under issues/components/pickers) // because it composes the generic PropertyPicker with a quick-create-shaped // trigger styled to match the modal header row — promoting it would invite // reuse pressure on a UI that's deliberately tuned for this one surface. function ActorPicker({ actor, visibleAgents, visibleSquads, selectedAgent, selectedSquad, onPick, t, }: { actor: ActorSelection | null; visibleAgents: Agent[]; visibleSquads: Squad[]; selectedAgent: Agent | undefined; selectedSquad: Squad | undefined; onPick: (next: ActorSelection) => void; t: ReturnType>["t"]; }) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); const query = filter.trim().toLowerCase(); const filteredAgents = useMemo( () => visibleAgents.filter((a) => a.name.toLowerCase().includes(query) || matchesPinyin(a.name, query)), [visibleAgents, query], ); const filteredSquads = useMemo( () => visibleSquads.filter((s) => s.name.toLowerCase().includes(query) || matchesPinyin(s.name, query)), [visibleSquads, query], ); const displayLabel = selectedSquad?.name ?? selectedAgent?.name; const displayActor: ActorSelection | null = selectedSquad ? { type: "squad", id: selectedSquad.id } : selectedAgent ? { type: "agent", id: selectedAgent.id } : null; return ( { setOpen(v); if (!v) setFilter(""); }} width="w-64" align="start" searchable searchPlaceholder={t(($) => $.create_issue.agent.search_placeholder)} onSearchChange={setFilter} trigger={ {t(($) => $.create_issue.agent.created_by)} {displayActor && displayLabel ? ( {displayLabel} ) : ( {t(($) => $.create_issue.agent.pick_an_agent)} )} } > {filteredAgents.length === 0 && filteredSquads.length === 0 ? ( query ? ( ) : (
{t(($) => $.create_issue.agent.no_agents)}
) ) : ( <> {filteredAgents.length > 0 && ( $.create_issue.agent.agents_group)}> {filteredAgents.map((a) => ( { onPick({ type: "agent", id: a.id }); setOpen(false); }} > {a.name} ))} )} {filteredSquads.length > 0 && ( $.create_issue.agent.squads_group)}> {filteredSquads.map((s) => ( { onPick({ type: "squad", id: s.id }); setOpen(false); }} > {s.name} ))} )} )}
); }