"use client"; import { useState, useRef } from "react"; import { ChevronRight, FolderOpen, Maximize2, Minimize2, Search, 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"; import { isDesktopShell, pickDirectory, validateLocalDirectory, } from "../platform/local-directory"; import { useLocalDaemonStatus } from "../platform/use-local-daemon-status"; 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 [repoSearch, setRepoSearch] = useState(""); const [customRepoUrl, setCustomRepoUrl] = useState(""); const workspaceRepos = workspace?.repos ?? []; const repoQuery = repoSearch.trim().toLowerCase(); const filteredWorkspaceRepos = workspaceRepos.filter((repo) => repo.url.toLowerCase().includes(repoQuery), ); // A project's source is binary: either a set of GitHub repos OR a local // working directory — never both. Mode is the source of truth for what // gets persisted on submit; switching mode does NOT clear the other // side's stash, so toggling back and forth restores the user's prior // selection. Only the mode-matching side is sent to the API. Local mode // is hidden entirely on web (no daemon to bind the path to). const desktop = isDesktopShell(); const daemonStatus = useLocalDaemonStatus(); const [sourceMode, setSourceMode] = useState<"repos" | "local">("repos"); const [selectedLocalPath, setSelectedLocalPath] = useState(null); const [selectedLocalLabel, setSelectedLocalLabel] = useState(null); const [localPickError, setLocalPickError] = useState(null); const [localPicking, setLocalPicking] = useState(false); const handleSourceModeChange = (mode: "repos" | "local") => { setSourceMode(mode); setLocalPickError(null); }; const handlePickLocalDirectory = async () => { if (localPicking) return; setLocalPickError(null); setLocalPicking(true); try { const picked = await pickDirectory(selectedLocalPath ?? undefined); if (!picked.ok || !picked.path) { if (picked.reason && picked.reason !== "cancelled") { setLocalPickError( picked.error ?? t(($) => $.create_project.local_pick_failed), ); } return; } const validation = await validateLocalDirectory(picked.path); if (!validation.ok) { setLocalPickError( validation.error ?? t(($) => $.create_project.local_invalid_dir), ); return; } setSelectedLocalPath(picked.path); setSelectedLocalLabel(picked.basename ?? null); } finally { setLocalPicking(false); } }; const clearLocalDirectory = () => { setSelectedLocalPath(null); setSelectedLocalLabel(null); setLocalPickError(null); }; // 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; // `sourceMode` decides which side's stash gets persisted — the other // side is silently dropped, so repos picked then abandoned for local // mode don't leak into the project. let resources: | Array<{ resource_type: "github_repo" | "local_directory"; resource_ref: Record }> | undefined; if (sourceMode === "repos" && selectedRepos.length > 0) { resources = selectedRepos.map((url) => ({ resource_type: "github_repo" as const, resource_ref: { url }, })); } else if ( sourceMode === "local" && selectedLocalPath && daemonStatus.daemonId ) { resources = [ { resource_type: "local_directory" as const, resource_ref: { local_path: selectedLocalPath, daemon_id: daemonStatus.daemonId, ...(selectedLocalLabel ? { label: selectedLocalLabel } : {}), }, }, ]; } 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, }); 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)}
)}
{ setRepoPopoverOpen(v); if (!v) setRepoSearch(""); }} > {sourceMode === "local" ? ( <> {selectedLocalPath ? selectedLocalLabel ?? selectedLocalPath : t(($) => $.create_project.source_pill_local)} ) : ( <> {selectedRepos.length === 0 ? t(($) => $.create_project.repos_pill) : t(($) => $.create_project.repos_pill_count, { count: selectedRepos.length })} )} } /> {/* Source mode is binary — repo OR local directory, never both. Local option is desktop-only because a local_directory resource has to be pinned to a daemon_id, which doesn't exist on the web. */} {desktop && (
)} {sourceMode === "repos" ? ( <>
{t(($) => $.create_project.repos_heading)}
{workspaceRepos.length > 0 ? ( <>
setRepoSearch(e.target.value)} aria-label={t(($) => $.create_project.repos_search_placeholder)} placeholder={t(($) => $.create_project.repos_search_placeholder)} className="h-8 w-full rounded-md border bg-transparent pl-7 pr-2 text-xs outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring" />
{filteredWorkspaceRepos.length === 0 && repoQuery && (

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

)} {filteredWorkspaceRepos.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) => (
))}
)} ) : ( <>
{t(($) => $.create_project.local_heading)}
{/* Daemon must be online — daemon_id is required to bind the resource. If it's offline, surface why and disable the picker; once it boots we re-render automatically via useLocalDaemonStatus. */} {daemonStatus.daemonId && daemonStatus.running ? (

{t(($) => $.create_project.local_on_device, { device: daemonStatus.deviceName ?? t(($) => $.create_project.local_this_machine), })}

) : (

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

)} {selectedLocalPath ? (
{selectedLocalLabel && (
{selectedLocalLabel}
)}
{selectedLocalPath}
) : ( )} {localPickError && (

{localPickError}

)}

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

)}
); }