Files
multica/packages/views/modals/create-project.tsx
Naiyuan Qing bd1fb10afa chore: react-doctor cleanup — button types, useContext→use(), toSorted, error fixes (#3350)
- 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>
2026-05-27 14:57:07 +08:00

804 lines
33 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}