mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 12:05:06 +02:00
- Add explicit type="button" to 61 <button> elements missing the attribute - Replace useContext() with React 19 use() across 16 context consumers - Replace [...arr].sort() with arr.toSorted() in 12 web/desktop files (mobile excluded — Hermes lacks toSorted support) - Fix rules-of-hooks violation: useSidebar try/catch → useSidebarSafe null check - Fix nested component definition: useMemo wrapping HeaderRight → useCallback - Fix missing ARIA: add aria-expanded + aria-controls to combobox in create-squad React Doctor score: 23 → 30. No behavioral changes, no business logic modified. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
804 lines
33 KiB
TypeScript
804 lines
33 KiB
TypeScript
"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 (
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
fill="currentColor"
|
||
aria-hidden="true"
|
||
className={className}
|
||
>
|
||
<path d="M12 .5C5.73.5.66 5.57.66 11.84c0 5.01 3.25 9.26 7.76 10.76.57.1.78-.25.78-.55 0-.27-.01-1.17-.02-2.13-3.16.69-3.83-1.34-3.83-1.34-.52-1.31-1.27-1.66-1.27-1.66-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.02 1.75 2.68 1.24 3.34.95.1-.74.4-1.24.72-1.53-2.52-.29-5.18-1.26-5.18-5.62 0-1.24.45-2.26 1.18-3.06-.12-.29-.51-1.45.11-3.02 0 0 .96-.31 3.15 1.17a10.93 10.93 0 0 1 5.74 0c2.19-1.48 3.15-1.17 3.15-1.17.62 1.57.23 2.73.11 3.02.74.8 1.18 1.82 1.18 3.06 0 4.37-2.67 5.32-5.21 5.61.41.35.78 1.04.78 2.1 0 1.52-.01 2.74-.01 3.11 0 .3.21.66.79.55 4.51-1.5 7.76-5.75 7.76-10.76C23.34 5.57 18.27.5 12 .5Z" />
|
||
</svg>
|
||
);
|
||
}
|
||
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<HTMLButtonElement>) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
|
||
"hover:bg-accent/60 transition-colors cursor-pointer",
|
||
className,
|
||
)}
|
||
{...props}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function RepoUrlText({
|
||
url,
|
||
className,
|
||
}: {
|
||
url: string;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
render={
|
||
<span
|
||
title={url}
|
||
className={cn("truncate flex-1 text-left", className)}
|
||
>
|
||
{url}
|
||
</span>
|
||
}
|
||
/>
|
||
<TooltipContent side="top" align="start" className="max-w-sm break-all">
|
||
{url}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
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<ContentEditorRef>(null);
|
||
const [status, setStatus] = useState<ProjectStatus>(draft.status);
|
||
const [priority, setPriority] = useState<ProjectPriority>(draft.priority);
|
||
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(draft.leadType);
|
||
const [leadId, setLeadId] = useState<string | undefined>(draft.leadId);
|
||
const [icon, setIcon] = useState<string | undefined>(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<string[]>([]);
|
||
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<string | null>(null);
|
||
const [selectedLocalLabel, setSelectedLocalLabel] = useState<string | null>(null);
|
||
const [localPickError, setLocalPickError] = useState<string | null>(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<string, unknown> }>
|
||
| 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 (
|
||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||
<DialogContent
|
||
showCloseButton={false}
|
||
className={cn(
|
||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||
"!top-1/2 !left-1/2 !-translate-x-1/2",
|
||
"!transition-all !duration-300 !ease-out",
|
||
isExpanded
|
||
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
|
||
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
|
||
)}
|
||
>
|
||
<DialogTitle className="sr-only">{t(($) => $.create_project.title)}</DialogTitle>
|
||
|
||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||
<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">{t(($) => $.create_project.title_breadcrumb)}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
render={
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||
>
|
||
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||
</button>
|
||
}
|
||
/>
|
||
<TooltipContent side="bottom">
|
||
{isExpanded
|
||
? t(($) => $.common.collapse_tooltip)
|
||
: t(($) => $.common.expand_tooltip)}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
render={
|
||
<button
|
||
type="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">{t(($) => $.common.close)}</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-5 pb-2 shrink-0">
|
||
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
|
||
<PopoverTrigger
|
||
render={
|
||
<button
|
||
type="button"
|
||
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
|
||
title={t(($) => $.create_project.icon_tooltip)}
|
||
>
|
||
{icon || "📁"}
|
||
</button>
|
||
}
|
||
/>
|
||
<PopoverContent align="start" className="w-auto p-0">
|
||
<EmojiPicker
|
||
onSelect={(emoji) => {
|
||
updateIcon(emoji);
|
||
setIconPickerOpen(false);
|
||
}}
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
<TitleEditor
|
||
autoFocus
|
||
defaultValue={draft.title}
|
||
placeholder={t(($) => $.create_project.title_placeholder)}
|
||
className="text-lg font-semibold"
|
||
onChange={(v) => updateTitle(v)}
|
||
onSubmit={handleSubmit}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
||
<ContentEditor
|
||
ref={descEditorRef}
|
||
defaultValue={draft.description}
|
||
placeholder={t(($) => $.create_project.description_placeholder)}
|
||
onUpdate={(md) => setDraft({ description: md })}
|
||
debounceMs={500}
|
||
/>
|
||
</div>
|
||
|
||
{/* 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. */}
|
||
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t shrink-0">
|
||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
render={
|
||
<PillButton>
|
||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
|
||
<span>{projectStatusLabels[status]}</span>
|
||
</PillButton>
|
||
}
|
||
/>
|
||
<DropdownMenuContent align="start" className="w-44">
|
||
{PROJECT_STATUS_ORDER.map((s) => (
|
||
<DropdownMenuItem key={s} onClick={() => updateStatus(s)}>
|
||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
|
||
<span>{projectStatusLabels[s]}</span>
|
||
</DropdownMenuItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
render={
|
||
<PillButton>
|
||
<PriorityIcon priority={priority} />
|
||
<span>{projectPriorityLabels[priority]}</span>
|
||
</PillButton>
|
||
}
|
||
/>
|
||
<DropdownMenuContent align="start" className="w-44">
|
||
{PROJECT_PRIORITY_ORDER.map((pr) => (
|
||
<DropdownMenuItem key={pr} onClick={() => updatePriority(pr)}>
|
||
<PriorityIcon priority={pr} />
|
||
<span>{projectPriorityLabels[pr]}</span>
|
||
</DropdownMenuItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
|
||
<Popover
|
||
open={leadOpen}
|
||
onOpenChange={(v) => {
|
||
setLeadOpen(v);
|
||
if (!v) setLeadFilter("");
|
||
}}
|
||
>
|
||
<PopoverTrigger
|
||
render={
|
||
<PillButton>
|
||
{leadType && leadId ? (
|
||
<>
|
||
<ActorAvatar actorType={leadType} actorId={leadId} size={16} showStatusDot />
|
||
<span>{leadLabel}</span>
|
||
</>
|
||
) : (
|
||
<span className="text-muted-foreground">{t(($) => $.create_project.lead)}</span>
|
||
)}
|
||
</PillButton>
|
||
}
|
||
/>
|
||
<PopoverContent align="start" className="w-52 p-0">
|
||
<div className="px-2 py-1.5 border-b">
|
||
<input
|
||
type="text"
|
||
value={leadFilter}
|
||
onChange={(e) => setLeadFilter(e.target.value)}
|
||
placeholder={t(($) => $.create_project.lead_placeholder)}
|
||
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
||
/>
|
||
</div>
|
||
<div className="p-1 max-h-60 overflow-y-auto">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
updateLead(undefined, undefined);
|
||
setLeadOpen(false);
|
||
}}
|
||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||
>
|
||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||
<span className="text-muted-foreground">{t(($) => $.create_project.no_lead)}</span>
|
||
</button>
|
||
{filteredMembers.length > 0 && (
|
||
<>
|
||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||
{t(($) => $.create_project.members_group)}
|
||
</div>
|
||
{filteredMembers.map((m) => (
|
||
<button
|
||
type="button"
|
||
key={m.user_id}
|
||
onClick={() => {
|
||
updateLead("member", m.user_id);
|
||
setLeadOpen(false);
|
||
}}
|
||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||
>
|
||
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
||
<span>{m.name}</span>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
{filteredAgents.length > 0 && (
|
||
<>
|
||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||
{t(($) => $.create_project.agents_group)}
|
||
</div>
|
||
{filteredAgents.map((a) => (
|
||
<button
|
||
type="button"
|
||
key={a.id}
|
||
onClick={() => {
|
||
updateLead("agent", a.id);
|
||
setLeadOpen(false);
|
||
}}
|
||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||
>
|
||
<ActorAvatar actorType="agent" actorId={a.id} size={16} showStatusDot />
|
||
<span>{a.name}</span>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
{filteredMembers.length === 0 &&
|
||
filteredAgents.length === 0 &&
|
||
leadFilter && (
|
||
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
|
||
{t(($) => $.create_project.no_results)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
|
||
<Popover
|
||
open={repoPopoverOpen}
|
||
onOpenChange={(v) => {
|
||
setRepoPopoverOpen(v);
|
||
if (!v) setRepoSearch("");
|
||
}}
|
||
>
|
||
<PopoverTrigger
|
||
render={
|
||
<PillButton>
|
||
{sourceMode === "local" ? (
|
||
<>
|
||
<FolderOpen className="size-3" />
|
||
<span className="max-w-[12rem] truncate">
|
||
{selectedLocalPath
|
||
? selectedLocalLabel ?? selectedLocalPath
|
||
: t(($) => $.create_project.source_pill_local)}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<GithubIcon className="size-3" />
|
||
<span>
|
||
{selectedRepos.length === 0
|
||
? t(($) => $.create_project.repos_pill)
|
||
: t(($) => $.create_project.repos_pill_count, { count: selectedRepos.length })}
|
||
</span>
|
||
</>
|
||
)}
|
||
</PillButton>
|
||
}
|
||
/>
|
||
<PopoverContent side="top" align="start" className="w-72 p-2 space-y-2">
|
||
{/* 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 && (
|
||
<div className="grid grid-cols-2 gap-1 rounded-md bg-muted/60 p-0.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSourceModeChange("repos")}
|
||
className={cn(
|
||
"rounded px-2 py-1 text-xs transition-colors",
|
||
sourceMode === "repos"
|
||
? "bg-background shadow-sm font-medium"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
{t(($) => $.create_project.source_tab_repos)}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSourceModeChange("local")}
|
||
className={cn(
|
||
"rounded px-2 py-1 text-xs transition-colors",
|
||
sourceMode === "local"
|
||
? "bg-background shadow-sm font-medium"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
{t(($) => $.create_project.source_tab_local)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{sourceMode === "repos" ? (
|
||
<>
|
||
<div className="text-xs font-medium text-muted-foreground">
|
||
{t(($) => $.create_project.repos_heading)}
|
||
</div>
|
||
{workspaceRepos.length > 0 ? (
|
||
<>
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||
<input
|
||
type="text"
|
||
value={repoSearch}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||
{filteredWorkspaceRepos.length === 0 && repoQuery && (
|
||
<p className="py-2 text-center text-xs text-muted-foreground">
|
||
{t(($) => $.create_project.repos_search_empty)}
|
||
</p>
|
||
)}
|
||
{filteredWorkspaceRepos.map((repo) => {
|
||
const checked = selectedRepos.includes(repo.url);
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={repo.url}
|
||
onClick={() => toggleRepo(repo.url)}
|
||
className={cn(
|
||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-accent transition-colors",
|
||
checked && "bg-accent",
|
||
)}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
readOnly
|
||
className="size-3.5"
|
||
/>
|
||
<GithubIcon className="size-3.5" />
|
||
<RepoUrlText url={repo.url} />
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(($) => $.create_project.repos_empty)}
|
||
</p>
|
||
)}
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
addCustomRepo();
|
||
}}
|
||
className="flex items-center gap-1.5 pt-1 border-t"
|
||
>
|
||
<input
|
||
type="text"
|
||
value={customRepoUrl}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<Button
|
||
type="submit"
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-6 px-2 text-xs"
|
||
disabled={!customRepoUrl.trim()}
|
||
>
|
||
{t(($) => $.create_project.repos_add)}
|
||
</Button>
|
||
</form>
|
||
{selectedRepos.length > 0 && (
|
||
<div className="space-y-1 pt-1 border-t">
|
||
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
||
{t(($) => $.create_project.repos_selected)}
|
||
</div>
|
||
{selectedRepos.map((url) => (
|
||
<div
|
||
key={url}
|
||
className="flex items-center gap-2 text-xs"
|
||
>
|
||
<GithubIcon className="size-3 text-muted-foreground" />
|
||
<RepoUrlText url={url} />
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleRepo(url)}
|
||
className="text-muted-foreground hover:text-foreground"
|
||
>
|
||
<XIcon className="size-3" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="text-xs font-medium text-muted-foreground">
|
||
{t(($) => $.create_project.local_heading)}
|
||
</div>
|
||
{/* 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 ? (
|
||
<p className="text-[11px] text-muted-foreground">
|
||
{t(($) => $.create_project.local_on_device, {
|
||
device: daemonStatus.deviceName ?? t(($) => $.create_project.local_this_machine),
|
||
})}
|
||
</p>
|
||
) : (
|
||
<p className="text-[11px] text-amber-600 dark:text-amber-400">
|
||
{t(($) => $.create_project.local_daemon_offline)}
|
||
</p>
|
||
)}
|
||
|
||
{selectedLocalPath ? (
|
||
<div className="rounded-md border px-2 py-2 space-y-1">
|
||
<div className="flex items-start gap-2 text-xs">
|
||
<FolderOpen className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||
<div className="min-w-0 flex-1">
|
||
{selectedLocalLabel && (
|
||
<div className="font-medium truncate">{selectedLocalLabel}</div>
|
||
)}
|
||
<div className="font-mono text-[10px] text-muted-foreground break-all">
|
||
{selectedLocalPath}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={clearLocalDirectory}
|
||
className="text-muted-foreground hover:text-foreground"
|
||
aria-label={t(($) => $.create_project.local_clear)}
|
||
>
|
||
<XIcon className="size-3" />
|
||
</button>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-6 w-full text-xs"
|
||
onClick={handlePickLocalDirectory}
|
||
disabled={localPicking || !daemonStatus.running}
|
||
>
|
||
{t(($) => $.create_project.local_change)}
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="w-full text-xs"
|
||
onClick={handlePickLocalDirectory}
|
||
disabled={localPicking || !daemonStatus.running}
|
||
>
|
||
<FolderOpen className="size-3" />
|
||
{localPicking
|
||
? t(($) => $.create_project.local_picking)
|
||
: t(($) => $.create_project.local_pick)}
|
||
</Button>
|
||
)}
|
||
|
||
{localPickError && (
|
||
<p className="text-[11px] text-destructive">{localPickError}</p>
|
||
)}
|
||
|
||
<p className="text-[10px] text-muted-foreground leading-snug">
|
||
{t(($) => $.create_project.local_hint)}
|
||
</p>
|
||
</>
|
||
)}
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
|
||
<Button
|
||
size="sm"
|
||
onClick={handleSubmit}
|
||
disabled={!title.trim() || submitting}
|
||
className="shrink-0"
|
||
>
|
||
{submitting ? t(($) => $.create_project.submitting) : t(($) => $.create_project.submit)}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|