Files
paperclip/ui/src/components/NewIssueDialog.tsx
Dotta 09a8ecbded Sort assignee picker: recent selections first, then alphabetical
Tracks most recently selected assignees in localStorage and sorts both
the issue properties and new issue dialog assignee lists accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:19:56 -06:00

937 lines
35 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 { 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<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 === "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<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
: 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<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"
: 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<InlineEntityOption[]>(
() =>
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<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={(id) => { if (id) trackRecentAssignee(id); setAssigneeId(id); }}
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>
);
}