"use client"; import { useState, useRef } from "react"; import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react"; /** * GitHub mark — lucide-react v1 dropped brand icons, so we inline the * Octicon-style mark here (24×24 viewBox, currentColor fill so it inherits * the parent's text color). Stays in this file because there's only one * caller; promote to packages/ui if a second use crops up. */ function GithubIcon({ className }: { className?: string }) { return ( ); } import { useQuery } from "@tanstack/react-query"; import { useCreateProject } from "@multica/core/projects/mutations"; import { useProjectDraftStore } from "@multica/core/projects"; import { PROJECT_STATUS_CONFIG, PROJECT_STATUS_ORDER, PROJECT_PRIORITY_ORDER, } from "@multica/core/projects/config"; import { useWorkspaceId } from "@multica/core/hooks"; import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; import { useActorName } from "@multica/core/workspace/hooks"; import type { ProjectStatus, ProjectPriority } from "@multica/core/types"; import { cn } from "@multica/ui/lib/utils"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@multica/ui/components/ui/dropdown-menu"; import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover"; import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; import { Button } from "@multica/ui/components/ui/button"; import { EmojiPicker } from "@multica/ui/components/common/emoji-picker"; import { ContentEditor, type ContentEditorRef, TitleEditor } from "../editor"; import { PriorityIcon } from "../issues/components/priority-icon"; import { ActorAvatar } from "../common/actor-avatar"; import { useNavigation } from "../navigation"; import { useT } from "../i18n"; import { matchesPinyin } from "../editor/extensions/pinyin-match"; import { useProjectStatusLabels, useProjectPriorityLabels, } from "../projects/components/labels"; function PillButton({ children, className, ...props }: React.ButtonHTMLAttributes) { return ( ); } function RepoUrlText({ url, className, }: { url: string; className?: string; }) { return ( {url} } /> {url} ); } export function CreateProjectModal({ onClose }: { onClose: () => void }) { const { t } = useT("modals"); const router = useNavigation(); const workspace = useCurrentWorkspace(); const workspaceName = workspace?.name; const wsPaths = useWorkspacePaths(); const wsId = useWorkspaceId(); const { data: members = [] } = useQuery(memberListOptions(wsId)); const { data: agents = [] } = useQuery(agentListOptions(wsId)); const { getActorName } = useActorName(); const projectStatusLabels = useProjectStatusLabels(); const projectPriorityLabels = useProjectPriorityLabels(); const draft = useProjectDraftStore((s) => s.draft); const setDraft = useProjectDraftStore((s) => s.setDraft); const clearDraft = useProjectDraftStore((s) => s.clearDraft); const [title, setTitle] = useState(draft.title); const descEditorRef = useRef(null); const [status, setStatus] = useState(draft.status); const [priority, setPriority] = useState(draft.priority); const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(draft.leadType); const [leadId, setLeadId] = useState(draft.leadId); const [icon, setIcon] = useState(draft.icon); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [isExpanded, setIsExpanded] = useState(false); // Repos selected to attach as github_repo resources after the project is // created. Stored as URLs (not full ProjectResource rows) — they're not // persisted until handleSubmit fires the createProjectResource calls. const [selectedRepos, setSelectedRepos] = useState([]); const [repoPopoverOpen, setRepoPopoverOpen] = useState(false); const [customRepoUrl, setCustomRepoUrl] = useState(""); const workspaceRepos = workspace?.repos ?? []; // Sync field changes to draft store const updateTitle = (v: string) => { setTitle(v); setDraft({ title: v }); }; const updateStatus = (v: ProjectStatus) => { setStatus(v); setDraft({ status: v }); }; const updatePriority = (v: ProjectPriority) => { setPriority(v); setDraft({ priority: v }); }; const updateLead = (type?: "member" | "agent", id?: string) => { setLeadType(type); setLeadId(id); setDraft({ leadType: type, leadId: id }); }; const updateIcon = (v: string | undefined) => { setIcon(v); setDraft({ icon: v }); }; const [leadOpen, setLeadOpen] = useState(false); const [leadFilter, setLeadFilter] = useState(""); const leadQuery = leadFilter.toLowerCase(); const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery) || matchesPinyin(m.name, leadQuery)); const filteredAgents = agents.filter( (a) => !a.archived_at && (a.name.toLowerCase().includes(leadQuery) || matchesPinyin(a.name, leadQuery)), ); const leadLabel = leadType && leadId ? getActorName(leadType, leadId) : t(($) => $.create_project.lead); const createProject = useCreateProject(); const handleSubmit = async () => { if (!title.trim() || submitting) return; setSubmitting(true); try { const project = await createProject.mutateAsync({ title: title.trim(), description: descEditorRef.current?.getMarkdown()?.trim() || undefined, icon, status, priority, lead_type: leadType, lead_id: leadId, // Server attaches these in the same transaction as the project. resources: selectedRepos.length > 0 ? selectedRepos.map((url) => ({ resource_type: "github_repo" as const, resource_ref: { url }, })) : undefined, }); clearDraft(); onClose(); toast.success(t(($) => $.create_project.toast_created)); router.push(wsPaths.projectDetail(project.id)); } catch (err) { toast.error( err instanceof Error && err.message ? err.message : t(($) => $.create_project.toast_failed), ); } finally { setSubmitting(false); } }; const toggleRepo = (url: string) => { setSelectedRepos((prev) => prev.includes(url) ? prev.filter((u) => u !== url) : [...prev, url], ); }; const addCustomRepo = () => { const url = customRepoUrl.trim(); if (!url) return; setSelectedRepos((prev) => (prev.includes(url) ? prev : [...prev, url])); setCustomRepoUrl(""); }; return ( { if (!v) onClose(); }}> {t(($) => $.create_project.title)}
{workspaceName} {t(($) => $.create_project.title_breadcrumb)}
setIsExpanded(!isExpanded)} className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer" > {isExpanded ? : } } /> {isExpanded ? t(($) => $.common.collapse_tooltip) : t(($) => $.common.expand_tooltip)} } /> {t(($) => $.common.close)}
$.create_project.icon_tooltip)} > {icon || "📁"} } /> { updateIcon(emoji); setIconPickerOpen(false); }} /> $.create_project.title_placeholder)} className="text-lg font-semibold" onChange={(v) => updateTitle(v)} onSubmit={handleSubmit} />
$.create_project.description_placeholder)} onUpdate={(md) => setDraft({ description: md })} debounceMs={500} />
{/* Footer: properties (left, wrap) + Create button (right). Single row so the modal stays compact — Linear-style. Repos lives here alongside the property pills for now. Once we support more resource types (Linear / Notion / Figma / Slack), pull them out into a dedicated Resources strip above this footer — a single Repos pill on its own row looked too sparse. */}
{projectStatusLabels[status]} } /> {PROJECT_STATUS_ORDER.map((s) => ( updateStatus(s)}> {projectStatusLabels[s]} ))} {projectPriorityLabels[priority]} } /> {PROJECT_PRIORITY_ORDER.map((pr) => ( updatePriority(pr)}> {projectPriorityLabels[pr]} ))} { setLeadOpen(v); if (!v) setLeadFilter(""); }} > {leadType && leadId ? ( <> {leadLabel} ) : ( {t(($) => $.create_project.lead)} )} } />
setLeadFilter(e.target.value)} placeholder={t(($) => $.create_project.lead_placeholder)} className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" />
{filteredMembers.length > 0 && ( <>
{t(($) => $.create_project.members_group)}
{filteredMembers.map((m) => ( ))} )} {filteredAgents.length > 0 && ( <>
{t(($) => $.create_project.agents_group)}
{filteredAgents.map((a) => ( ))} )} {filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && (
{t(($) => $.create_project.no_results)}
)}
{selectedRepos.length === 0 ? t(($) => $.create_project.repos_pill) : t(($) => $.create_project.repos_pill_count, { count: selectedRepos.length })} } />
{t(($) => $.create_project.repos_heading)}
{workspaceRepos.length > 0 ? (
{workspaceRepos.map((repo) => { const checked = selectedRepos.includes(repo.url); return ( ); })}
) : (

{t(($) => $.create_project.repos_empty)}

)}
{ e.preventDefault(); addCustomRepo(); }} className="flex items-center gap-1.5 pt-1 border-t" > setCustomRepoUrl(e.target.value)} placeholder={t(($) => $.create_project.repos_url_placeholder)} className="flex-1 bg-transparent text-xs px-2 py-1 outline-none placeholder:text-muted-foreground" />
{selectedRepos.length > 0 && (
{t(($) => $.create_project.repos_selected)}
{selectedRepos.map((url) => (
))}
)}
); }