Files
multica/packages/views/modals/create-project.tsx
Bohan Jiang 47e3fdedd0 feat(projects): start_date / due_date UI + create-project footer aligned with create-issue (MUL-4388) (#5331)
* feat(projects): add start_date / due_date pickers to project create modal and sidebar

#5313 landed the backend start_date/due_date fields + types but deliberately
shipped no UI. Wire up the two editor surfaces users expect:

- ProjectStartDatePicker / ProjectDueDatePicker mirror the issue pickers (same
  calendar-day contract, clear idiom, shared @multica/core/issues/date helpers)
  but are typed to UpdateProjectRequest and scoped to the "projects" i18n
  namespace. One component serves both surfaces via a custom trigger.
- Create-project modal: two date pills; values flow into the create payload and
  the persisted draft (draft-store gains startDate/dueDate).
- Project sidebar (project-detail): two PropRows after Lead, wired to the update
  mutation, with clear support (send null).
- i18n: prop_start_date / prop_due_date / clear_date across en/zh-Hans/ja/ko,
  reusing the existing issue date wording.

Tests: picker display + clear behavior (real popover), and the create modal
renders both pills. typecheck + lint + i18n parity pass.

Part of #5227

Co-authored-by: multica-agent <github@multica.ai>

* refactor(projects): align create-project footer with create-issue

Restructure the create-project modal footer to match the create-issue pattern
(per design feedback): the primary action moves out of the cramped single
justify-between row into its own border-t action strip, and the property pills
sit in a dedicated wrapping toolbar above it. Low-frequency fields (start/due
date) collapse into a ⋯ overflow via progressive disclosure — a pill only
renders inline once its date is set or the user opens it from the menu — so the
default toolbar stays a clean single row (Status · Priority · Lead · Repos · ⋯).

- Use the shared PillButton (../common/pill-button) instead of the modal-local
  copy, gaining the data-popup-open styling create-issue uses.
- ProjectStartDatePicker / ProjectDueDatePicker gain controlled open props so
  the overflow menu can reveal + open them (mirrors the issue pickers).
- i18n: create_project.set_start_date / set_due_date / more_options_aria across
  en / zh-Hans / ja / ko, reusing the create-issue wording.

Test updated to assert the dates are revealed from the overflow rather than
shown inline by default. typecheck / lint / i18n parity pass.

Part of #5227

Co-authored-by: multica-agent <github@multica.ai>

* refactor(views): extract shared DateOnlyPicker base for date pills (Elon nit2)

The issue and project start/due-date pickers were near-complete copies of the
same Popover + Calendar + clear wiring, so they could drift in behaviour or
formatting. Extract that into one entity-agnostic DateOnlyPicker
(packages/views/common/date-only-picker.tsx); each of the four pills is now a
thin wrapper supplying only its field name (via onChange), icon, overdue flag,
and localized copy. -264 lines of duplication, single source of truth.

Behaviour is unchanged: the issue pickers keep their full API (trigger /
triggerRender / open / onOpenChange / align / defaultOpen — all still used by
board-card, issue-detail, create-issue) and the calendar-day contract stays in
@multica/core/issues/date. The en-US display format now lives in one place
rather than being duplicated per entity.

Full views test suite (1857 tests) + typecheck + lint pass.

Part of #5227

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 17:13:43 +08:00

853 lines
36 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 { CalendarClock, CalendarDays, ChevronRight, FolderOpen, Maximize2, Minimize2, MoreHorizontal, 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 { ProjectStartDatePicker } from "../projects/components/project-start-date-picker";
import { ProjectDueDatePicker } from "../projects/components/project-due-date-picker";
import { PillButton } from "../common/pill-button";
import {
isDesktopShell,
pickDirectory,
validateLocalDirectory,
} from "../platform/local-directory";
import { useLocalDaemonStatus } from "../platform/use-local-daemon-status";
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 [startDate, setStartDate] = useState<string>(draft.startDate ?? "");
const [dueDate, setDueDate] = useState<string>(draft.dueDate ?? "");
// Dates are collapsed into the ⋯ overflow by default (progressive
// disclosure, mirroring create-issue); these flip a pill inline + open.
const [startDatePickerOpen, setStartDatePickerOpen] = useState(false);
const [dueDatePickerOpen, setDueDatePickerOpen] = useState(false);
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 updateStartDate = (v: string) => { setStartDate(v); setDraft({ startDate: v || undefined }); };
const updateDueDate = (v: string) => { setDueDate(v); setDraft({ dueDate: v || undefined }); };
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,
start_date: startDate || undefined,
due_date: dueDate || undefined,
// 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}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t(($) => $.create_project.description_hint)}
</p>
</div>
{/* Property toolbar — mirrors the create-issue footer: a wrapping pill
row whose low-frequency fields (start/due date) collapse into a ⋯
overflow, with the primary action in a separate bar below.
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. */}
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
<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="sm" 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="sm" />
<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="sm" 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>
{/* Start date — collapsed into ⋯ unless it has a value or was just
opened from the overflow (the calendar anchors on the inline pill). */}
{(startDate || startDatePickerOpen) && (
<ProjectStartDatePicker
startDate={startDate || null}
onUpdate={(u) => updateStartDate(u.start_date ?? "")}
triggerRender={<PillButton />}
open={startDatePickerOpen}
onOpenChange={setStartDatePickerOpen}
/>
)}
{(dueDate || dueDatePickerOpen) && (
<ProjectDueDatePicker
dueDate={dueDate || null}
onUpdate={(u) => updateDueDate(u.due_date ?? "")}
triggerRender={<PillButton />}
open={dueDatePickerOpen}
onOpenChange={setDueDatePickerOpen}
/>
)}
<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>
{/* Overflow — always the last child so it stays at the end of the
wrap flow. Only rendered while a date is still collapsible; when
both are set there is nothing left to add. */}
{(!startDate || !dueDate) && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton aria-label={t(($) => $.create_project.more_options_aria)}>
<MoreHorizontal className="size-3.5" />
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-auto">
{!dueDate && (
<DropdownMenuItem onClick={() => setDueDatePickerOpen(true)}>
<CalendarDays className="h-3.5 w-3.5" />
{t(($) => $.create_project.set_due_date)}
</DropdownMenuItem>
)}
{!startDate && (
<DropdownMenuItem onClick={() => setStartDatePickerOpen(true)}>
<CalendarClock className="h-3.5 w-3.5" />
{t(($) => $.create_project.set_start_date)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Footer action bar — primary action in its own strip, matching
create-issue. */}
<div className="flex items-center justify-end border-t px-4 py-3 shrink-0">
<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>
);
}