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 { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; 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", "opencode_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" }, ], opencode_local: [ { value: "", label: "Default" }, { value: "minimal", label: "Minimal" }, { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, { value: "max", label: "Max" }, ], } as const; function buildAssigneeAdapterOverrides(input: { adapterType: string | null | undefined; modelOverride: string; thinkingEffortOverride: string; chrome: boolean; useProjectWorkspace: boolean; }): Record | null { const adapterType = input.adapterType ?? null; if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) { return null; } const adapterConfig: Record = {}; if (input.modelOverride) adapterConfig.model = input.modelOverride; if (input.thinkingEffortOverride) { if (adapterType === "codex_local") { adapterConfig.modelReasoningEffort = input.thinkingEffortOverride; } else if (adapterType === "opencode_local") { adapterConfig.variant = input.thinkingEffortOverride; } else if (adapterType === "claude_local") { adapterConfig.effort = input.thinkingEffortOverride; } } if (adapterType === "claude_local" && input.chrome) { adapterConfig.chrome = true; } const overrides: Record = {}; 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(null); const draftTimer = useRef | 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(null); const attachInputRef = useRef(null); const assigneeSelectorRef = useRef(null); const projectSelectorRef = useRef(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(() => { 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) => 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 : assigneeAdapterType === "opencode_local" ? ISSUE_THINKING_EFFORT_OPTIONS.opencode_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) { 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" : assigneeAdapterType === "opencode_local" ? "OpenCode options" : "Agent options"; const thinkingEffortOptions = assigneeAdapterType === "codex_local" ? ISSUE_THINKING_EFFORT_OPTIONS.codex_local : assigneeAdapterType === "opencode_local" ? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local : ISSUE_THINKING_EFFORT_OPTIONS.claude_local; const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]); const assigneeOptions = useMemo( () => sortAgentsByRecency( (agents ?? []).filter((agent) => agent.status !== "terminated"), recentAssigneeIds, ).map((agent) => ({ id: agent.id, label: agent.name, searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, })), [agents, recentAssigneeIds], ); const projectOptions = useMemo( () => orderedProjects.map((project) => ({ id: project.id, label: project.name, searchText: project.description ?? "", })), [orderedProjects], ); const modelOverrideOptions = useMemo( () => (assigneeAdapterModels ?? []).map((model) => ({ id: model.id, label: model.label, searchText: model.id, })), [assigneeAdapterModels], ); return ( { if (!open) closeNewIssue(); }} > {/* Header bar */}
{companies.filter((c) => c.status !== "archived").map((c) => ( ))} New issue
{/* Title */}