Files
multica/packages/views/modals/create-project.tsx
Jiayuan Zhang 7803a5b9ea feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
tokens.css defined colours, radii and font families but not a single --text-*
step, so font sizes had no baseline to align to and grew wherever they were
needed: 51 distinct sizes across web + desktop, 370 written as arbitrary
values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px).
text-xs and text-sm carried nearly all UI text while the range between them —
11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not
come from having more sizes; past a handful, each extra size makes the
hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the ~1,900-call-site rename moves nothing on screen.
The visible changes are confined to former arbitrary values snapping to a step:
8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30
(text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small
buttons and toggles. Half-pixel sizes are gone.

This supersedes #6108, which was reverted by #6116 because the sidebar group
labels rendered at the inherited 16px. The cause was not the scale but cn():
`text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a
table listing only the default sizes, so it filed every role step under
text-colour and dropped whichever of `text-caption` /
`text-sidebar-foreground/70` came first. Registering the steps as a font-size
class group restores the real conflict groups — size beats size, colour beats
colour, the two coexist — and a test pins the list against the scale, since
the failure is silent in source.

Hand-written CSS is covered too. The transcript kept a 12.5px body long after
every Tailwind call site was on the scale, so the "no half-pixel sizes" claim
was true of the classes and false of the product; the editor's prose, code and
mermaid ramps had the same blind spot, and seven of their eight values already
equalled a step exactly. All now reference var(--text-*). The guard test reads
raw `font-size:` declarations as well as class names, exempting only the 16px
iOS input-zoom workaround in base.css and the landing pages' marketing ramp.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing display type
(rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative
emoji / serif-hero sizes.

Verified on a running local stack: pinned sidebar rows and group labels
measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit
of every rendered font size across the product surfaces finds nothing off the
scale; the only exceptions are avatar initials and emoji, which
actor-avatar.tsx sizes proportionally to the avatar diameter by design.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 13:42:33 +08:00

851 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 { githubShortLabel } from "../common/github-url";
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 className={cn("truncate flex-1 text-left", className)}>
{githubShortLabel(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-caption">
<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-display-sm 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-title 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-caption 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-body 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-body 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-caption 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-body 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-caption 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-body 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-body 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-caption 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-caption 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-caption 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-caption 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-caption 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-caption 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-caption 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-caption px-2 py-1 outline-none placeholder:text-muted-foreground"
/>
<Button
type="submit"
size="sm"
variant="ghost"
className="h-6 px-2 text-caption"
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-micro 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-caption"
>
<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-caption 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-micro text-muted-foreground">
{t(($) => $.create_project.local_on_device, {
device: daemonStatus.deviceName ?? t(($) => $.create_project.local_this_machine),
})}
</p>
) : (
<p className="text-micro 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-caption">
<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-micro 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-caption"
onClick={handlePickLocalDirectory}
disabled={localPicking || !daemonStatus.running}
>
{t(($) => $.create_project.local_change)}
</Button>
</div>
) : (
<Button
type="button"
size="sm"
variant="outline"
className="w-full text-caption"
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-micro text-destructive">{localPickError}</p>
)}
<p className="text-micro 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>
);
}