Files
paperclip/ui/src/components/NewIssueDialog.tsx
Dotta 18c9eb7b1e Filter out archived companies from new issue company selector
Companies with status "archived" are now hidden from the company
dropdown in the NewIssueDialog, matching the expected behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:20:14 -06:00

918 lines
34 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { useToast } from "../context/ToastContext";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { assetsApi } from "../api/assets";
import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder";
import {
Dialog,
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Maximize2,
Minimize2,
MoreHorizontal,
ChevronRight,
ChevronDown,
CircleDot,
Minus,
ArrowUp,
ArrowDown,
AlertTriangle,
Tag,
Calendar,
Paperclip,
} from "lucide-react";
import { cn } from "../lib/utils";
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { AgentIcon } from "./AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
const DRAFT_KEY = "paperclip:issue-draft";
const DEBOUNCE_MS = 800;
/** Return black or white hex based on background luminance (WCAG perceptual weights). */
function getContrastTextColor(hexColor: string): string {
const hex = hexColor.replace("#", "");
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? "#000000" : "#ffffff";
}
interface IssueDraft {
title: string;
description: string;
status: string;
priority: string;
assigneeId: string;
projectId: string;
assigneeModelOverride: string;
assigneeThinkingEffort: string;
assigneeChrome: boolean;
assigneeUseProjectWorkspace: boolean;
}
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local"]);
const ISSUE_THINKING_EFFORT_OPTIONS = {
claude_local: [
{ value: "", label: "Default" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
codex_local: [
{ value: "", label: "Default" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
} as const;
function buildAssigneeAdapterOverrides(input: {
adapterType: string | null | undefined;
modelOverride: string;
thinkingEffortOverride: string;
chrome: boolean;
useProjectWorkspace: boolean;
}): Record<string, unknown> | null {
const adapterType = input.adapterType ?? null;
if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) {
return null;
}
const adapterConfig: Record<string, unknown> = {};
if (input.modelOverride) adapterConfig.model = input.modelOverride;
if (input.thinkingEffortOverride) {
if (adapterType === "codex_local") {
adapterConfig.modelReasoningEffort = input.thinkingEffortOverride;
} else if (adapterType === "claude_local") {
adapterConfig.effort = input.thinkingEffortOverride;
}
}
if (adapterType === "claude_local" && input.chrome) {
adapterConfig.chrome = true;
}
const overrides: Record<string, unknown> = {};
if (Object.keys(adapterConfig).length > 0) {
overrides.adapterConfig = adapterConfig;
}
if (!input.useProjectWorkspace) {
overrides.useProjectWorkspace = false;
}
return Object.keys(overrides).length > 0 ? overrides : null;
}
function loadDraft(): IssueDraft | null {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return null;
return JSON.parse(raw) as IssueDraft;
} catch {
return null;
}
}
function saveDraft(draft: IssueDraft) {
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
}
function clearDraft() {
localStorage.removeItem(DRAFT_KEY);
}
const statuses = [
{ value: "backlog", label: "Backlog", color: issueStatusText.backlog ?? issueStatusTextDefault },
{ value: "todo", label: "Todo", color: issueStatusText.todo ?? issueStatusTextDefault },
{ value: "in_progress", label: "In Progress", color: issueStatusText.in_progress ?? issueStatusTextDefault },
{ value: "in_review", label: "In Review", color: issueStatusText.in_review ?? issueStatusTextDefault },
{ value: "done", label: "Done", color: issueStatusText.done ?? issueStatusTextDefault },
];
const priorities = [
{ value: "critical", label: "Critical", icon: AlertTriangle, color: priorityColor.critical ?? priorityColorDefault },
{ value: "high", label: "High", icon: ArrowUp, color: priorityColor.high ?? priorityColorDefault },
{ value: "medium", label: "Medium", icon: Minus, color: priorityColor.medium ?? priorityColorDefault },
{ value: "low", label: "Low", icon: ArrowDown, color: priorityColor.low ?? priorityColorDefault },
];
export function NewIssueDialog() {
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
const { companies, selectedCompanyId, selectedCompany } = useCompany();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("todo");
const [priority, setPriority] = useState("");
const [assigneeId, setAssigneeId] = useState("");
const [projectId, setProjectId] = useState("");
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
const [assigneeChrome, setAssigneeChrome] = useState(false);
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
const [expanded, setExpanded] = useState(false);
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
// Popover states
const [statusOpen, setStatusOpen] = useState(false);
const [priorityOpen, setPriorityOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const [companyOpen, setCompanyOpen] = useState(false);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(effectiveCompanyId!),
queryFn: () => agentsApi.list(effectiveCompanyId!),
enabled: !!effectiveCompanyId && newIssueOpen,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(effectiveCompanyId!),
queryFn: () => projectsApi.list(effectiveCompanyId!),
enabled: !!effectiveCompanyId && newIssueOpen,
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const { orderedProjects } = useProjectOrder({
projects: projects ?? [],
companyId: effectiveCompanyId,
userId: currentUserId,
});
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
const supportsAssigneeOverrides = Boolean(
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
);
const mentionOptions = useMemo<MentionOption[]>(() => {
const options: MentionOption[] = [];
const activeAgents = [...(agents ?? [])]
.filter((agent) => agent.status !== "terminated")
.sort((a, b) => a.name.localeCompare(b.name));
for (const agent of activeAgents) {
options.push({
id: `agent:${agent.id}`,
name: agent.name,
kind: "agent",
});
}
for (const project of orderedProjects) {
options.push({
id: `project:${project.id}`,
name: project.name,
kind: "project",
projectId: project.id,
projectColor: project.color,
});
}
return options;
}, [agents, orderedProjects]);
const { data: assigneeAdapterModels } = useQuery({
queryKey: ["adapter-models", assigneeAdapterType],
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
enabled: !!effectiveCompanyId && newIssueOpen && supportsAssigneeOverrides,
});
const createIssue = useMutation({
mutationFn: ({ companyId, ...data }: { companyId: string } & Record<string, unknown>) =>
issuesApi.create(companyId, data),
onSuccess: (issue) => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) });
if (draftTimer.current) clearTimeout(draftTimer.current);
clearDraft();
reset();
closeNewIssue();
pushToast({
dedupeKey: `activity:issue.created:${issue.id}`,
title: `${issue.identifier ?? "Issue"} created`,
body: issue.title,
tone: "success",
action: { label: `View ${issue.identifier ?? "issue"}`, href: `/issues/${issue.identifier ?? issue.id}` },
});
},
});
const uploadDescriptionImage = useMutation({
mutationFn: async (file: File) => {
if (!effectiveCompanyId) throw new Error("No company selected");
return assetsApi.uploadImage(effectiveCompanyId, file, "issues/drafts");
},
});
// Debounced draft saving
const scheduleSave = useCallback(
(draft: IssueDraft) => {
if (draftTimer.current) clearTimeout(draftTimer.current);
draftTimer.current = setTimeout(() => {
if (draft.title.trim()) saveDraft(draft);
}, DEBOUNCE_MS);
},
[],
);
// Save draft on meaningful changes
useEffect(() => {
if (!newIssueOpen) return;
scheduleSave({
title,
description,
status,
priority,
assigneeId,
projectId,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
assigneeUseProjectWorkspace,
});
}, [
title,
description,
status,
priority,
assigneeId,
projectId,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
assigneeUseProjectWorkspace,
newIssueOpen,
scheduleSave,
]);
// Restore draft or apply defaults when dialog opens
useEffect(() => {
if (!newIssueOpen) return;
setDialogCompanyId(selectedCompanyId);
const draft = loadDraft();
if (draft && draft.title.trim()) {
setTitle(draft.title);
setDescription(draft.description);
setStatus(draft.status || "todo");
setPriority(draft.priority);
setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId);
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
setAssigneeChrome(draft.assigneeChrome ?? false);
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
} else {
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(newIssueDefaults.projectId ?? "");
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
}
}, [newIssueOpen, newIssueDefaults]);
useEffect(() => {
if (!supportsAssigneeOverrides) {
setAssigneeOptionsOpen(false);
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
return;
}
const validThinkingValues =
assigneeAdapterType === "codex_local"
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) {
setAssigneeThinkingEffort("");
}
}, [supportsAssigneeOverrides, assigneeAdapterType, assigneeThinkingEffort]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (draftTimer.current) clearTimeout(draftTimer.current);
};
}, []);
function reset() {
setTitle("");
setDescription("");
setStatus("todo");
setPriority("");
setAssigneeId("");
setProjectId("");
setAssigneeOptionsOpen(false);
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
setExpanded(false);
setDialogCompanyId(null);
setCompanyOpen(false);
}
function handleCompanyChange(companyId: string) {
if (companyId === effectiveCompanyId) return;
setDialogCompanyId(companyId);
setAssigneeId("");
setProjectId("");
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
}
function discardDraft() {
clearDraft();
reset();
closeNewIssue();
}
function handleSubmit() {
if (!effectiveCompanyId || !title.trim()) return;
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
adapterType: assigneeAdapterType,
modelOverride: assigneeModelOverride,
thinkingEffortOverride: assigneeThinkingEffort,
chrome: assigneeChrome,
useProjectWorkspace: assigneeUseProjectWorkspace,
});
createIssue.mutate({
companyId: effectiveCompanyId,
title: title.trim(),
description: description.trim() || undefined,
status,
priority: priority || "medium",
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
...(projectId ? { projectId } : {}),
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
}
async function handleAttachImage(evt: ChangeEvent<HTMLInputElement>) {
const file = evt.target.files?.[0];
if (!file) return;
try {
const asset = await uploadDescriptionImage.mutateAsync(file);
const name = file.name || "image";
setDescription((prev) => {
const suffix = `![${name}](${asset.contentPath})`;
return prev ? `${prev}\n\n${suffix}` : suffix;
});
} finally {
if (attachInputRef.current) attachInputRef.current.value = "";
}
}
const hasDraft = title.trim().length > 0 || description.trim().length > 0;
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
const currentPriority = priorities.find((p) => p.value === priority);
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
const currentProject = orderedProjects.find((project) => project.id === projectId);
const assigneeOptionsTitle =
assigneeAdapterType === "claude_local"
? "Claude options"
: assigneeAdapterType === "codex_local"
? "Codex options"
: "Agent options";
const thinkingEffortOptions =
assigneeAdapterType === "codex_local"
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
(agents ?? [])
.filter((agent) => agent.status !== "terminated")
.map((agent) => ({
id: agent.id,
label: agent.name,
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})),
[agents],
);
const projectOptions = useMemo<InlineEntityOption[]>(
() =>
orderedProjects.map((project) => ({
id: project.id,
label: project.name,
searchText: project.description ?? "",
})),
[orderedProjects],
);
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
() =>
(assigneeAdapterModels ?? []).map((model) => ({
id: model.id,
label: model.label,
searchText: model.id,
})),
[assigneeAdapterModels],
);
return (
<Dialog
open={newIssueOpen}
onOpenChange={(open) => {
if (!open) closeNewIssue();
}}
>
<DialogContent
showCloseButton={false}
aria-describedby={undefined}
className={cn(
"p-0 gap-0 flex flex-col max-h-[calc(100dvh-2rem)]",
expanded
? "sm:max-w-2xl h-[calc(100dvh-2rem)]"
: "sm:max-w-lg"
)}
onKeyDown={handleKeyDown}
>
{/* Header bar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
!dialogCompany?.brandColor && "bg-muted",
)}
style={
dialogCompany?.brandColor
? {
backgroundColor: dialogCompany.brandColor,
color: getContrastTextColor(dialogCompany.brandColor),
}
: undefined
}
>
{(dialogCompany?.name ?? "").slice(0, 3).toUpperCase()}
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start">
{companies.filter((c) => c.status !== "archived").map((c) => (
<button
key={c.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
c.id === effectiveCompanyId && "bg-accent",
)}
onClick={() => {
handleCompanyChange(c.id);
setCompanyOpen(false);
}}
>
<span
className={cn(
"px-1 py-0.5 rounded text-[10px] font-semibold leading-none",
!c.brandColor && "bg-muted",
)}
style={
c.brandColor
? {
backgroundColor: c.brandColor,
color: getContrastTextColor(c.brandColor),
}
: undefined
}
>
{c.name.slice(0, 3).toUpperCase()}
</span>
<span className="truncate">{c.name}</span>
</button>
))}
</PopoverContent>
</Popover>
<span className="text-muted-foreground/60">&rsaquo;</span>
<span>New issue</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => closeNewIssue()}
>
<span className="text-lg leading-none">&times;</span>
</Button>
</div>
</div>
{/* Title */}
<div className="px-4 pt-4 pb-2 shrink-0">
<textarea
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
placeholder="Issue title"
rows={1}
value={title}
onChange={(e) => {
setTitle(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
descriptionEditorRef.current?.focus();
}
if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
assigneeSelectorRef.current?.focus();
}
}}
autoFocus
/>
</div>
<div className="px-4 pb-2 shrink-0">
<div className="overflow-x-auto">
<div className="inline-flex min-w-max items-center gap-2 text-sm text-muted-foreground">
<span>For</span>
<InlineEntitySelector
ref={assigneeSelectorRef}
value={assigneeId}
options={assigneeOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={setAssigneeId}
onConfirm={() => {
projectSelectorRef.current?.focus();
}}
renderTriggerValue={(option) =>
option && currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">Assignee</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = (agents ?? []).find((agent) => agent.id === option.id);
return (
<>
<AgentIcon icon={assignee?.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span>
</>
);
}}
/>
<span>in</span>
<InlineEntitySelector
ref={projectSelectorRef}
value={projectId}
options={projectOptions}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={setProjectId}
onConfirm={() => {
descriptionEditorRef.current?.focus();
}}
renderTriggerValue={(option) =>
option && currentProject ? (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: currentProject.color ?? "#6366f1" }}
/>
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">Project</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const project = orderedProjects.find((item) => item.id === option.id);
return (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "#6366f1" }}
/>
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div>
</div>
</div>
{supportsAssigneeOverrides && (
<div className="px-4 pb-2 shrink-0">
<button
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setAssigneeOptionsOpen((open) => !open)}
>
{assigneeOptionsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{assigneeOptionsTitle}
</button>
{assigneeOptionsOpen && (
<div className="mt-2 rounded-md border border-border p-3 bg-muted/20 space-y-3">
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">Model</div>
<InlineEntitySelector
value={assigneeModelOverride}
options={modelOverrideOptions}
placeholder="Default model"
noneLabel="Default model"
searchPlaceholder="Search models..."
emptyMessage="No models found."
onChange={setAssigneeModelOverride}
/>
</div>
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">Thinking effort</div>
<div className="flex items-center gap-1.5 flex-wrap">
{thinkingEffortOptions.map((option) => (
<button
key={option.value || "default"}
className={cn(
"px-2 py-1 rounded-md text-xs border border-border hover:bg-accent/50 transition-colors",
assigneeThinkingEffort === option.value && "bg-accent"
)}
onClick={() => setAssigneeThinkingEffort(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
{assigneeAdapterType === "claude_local" && (
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
assigneeChrome ? "bg-green-600" : "bg-muted"
)}
onClick={() => setAssigneeChrome((value) => !value)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
assigneeChrome ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
)}
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
<div className="text-xs text-muted-foreground">Use project workspace</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
assigneeUseProjectWorkspace ? "bg-green-600" : "bg-muted"
)}
onClick={() => setAssigneeUseProjectWorkspace((value) => !value)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
assigneeUseProjectWorkspace ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
</div>
)}
</div>
)}
{/* Description */}
<div className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}>
<MarkdownEditor
ref={descriptionEditorRef}
value={description}
onChange={setDescription}
placeholder="Add description..."
bordered={false}
mentions={mentionOptions}
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")}
imageUploadHandler={async (file) => {
const asset = await uploadDescriptionImage.mutateAsync(file);
return asset.contentPath;
}}
/>
</div>
{/* Property chips bar */}
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap shrink-0">
{/* Status chip */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<CircleDot className={cn("h-3 w-3", currentStatus.color)} />
{currentStatus.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
{statuses.map((s) => (
<button
key={s.value}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
s.value === status && "bg-accent"
)}
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
>
<CircleDot className={cn("h-3 w-3", s.color)} />
{s.label}
</button>
))}
</PopoverContent>
</Popover>
{/* Priority chip */}
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
{currentPriority ? (
<>
<currentPriority.icon className={cn("h-3 w-3", currentPriority.color)} />
{currentPriority.label}
</>
) : (
<>
<Minus className="h-3 w-3 text-muted-foreground" />
Priority
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
{priorities.map((p) => (
<button
key={p.value}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
p.value === priority && "bg-accent"
)}
onClick={() => { setPriority(p.value); setPriorityOpen(false); }}
>
<p.icon className={cn("h-3 w-3", p.color)} />
{p.label}
</button>
))}
</PopoverContent>
</Popover>
{/* Labels chip (placeholder) */}
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
<Tag className="h-3 w-3" />
Labels
</button>
{/* Attach image chip */}
<input
ref={attachInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleAttachImage}
/>
<button
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground"
onClick={() => attachInputRef.current?.click()}
disabled={uploadDescriptionImage.isPending}
>
<Paperclip className="h-3 w-3" />
{uploadDescriptionImage.isPending ? "Uploading..." : "Image"}
</button>
{/* More (dates) */}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center justify-center rounded-md border border-border p-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
<MoreHorizontal className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="start">
<button className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground">
<Calendar className="h-3 w-3" />
Start date
</button>
<button className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground">
<Calendar className="h-3 w-3" />
Due date
</button>
</PopoverContent>
</Popover>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border shrink-0">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={discardDraft}
disabled={!hasDraft && !loadDraft()}
>
Discard Draft
</Button>
<Button
size="sm"
disabled={!title.trim() || createIssue.isPending}
onClick={handleSubmit}
>
{createIssue.isPending ? "Creating..." : "Create Issue"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}