mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 10:00:38 +09:00
Add assigneeAdapterOverrides JSONB column to issues, allowing per-issue model, thinking effort, and workspace overrides when assigning to agents. Heartbeat service merges overrides into adapter config at runtime. New Issue dialog exposes these options for Claude and Codex adapters. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
771 lines
28 KiB
TypeScript
771 lines
28 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 { assetsApi } from "../api/assets";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
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 } from "./MarkdownEditor";
|
|
import { AgentIcon } from "./AgentIconPicker";
|
|
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
|
|
|
const DRAFT_KEY = "paperclip:issue-draft";
|
|
const DEBOUNCE_MS = 800;
|
|
|
|
interface IssueDraft {
|
|
title: string;
|
|
description: string;
|
|
status: string;
|
|
priority: string;
|
|
assigneeId: string;
|
|
projectId: string;
|
|
assigneeModelOverride: string;
|
|
assigneeThinkingEffort: string;
|
|
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;
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 { 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 [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
|
|
const [expanded, setExpanded] = useState(false);
|
|
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Popover states
|
|
const [statusOpen, setStatusOpen] = useState(false);
|
|
const [priorityOpen, setPriorityOpen] = useState(false);
|
|
const [moreOpen, setMoreOpen] = 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(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId && newIssueOpen,
|
|
});
|
|
|
|
const { data: projects } = useQuery({
|
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId && newIssueOpen,
|
|
});
|
|
|
|
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
|
|
const supportsAssigneeOverrides = Boolean(
|
|
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
|
|
);
|
|
|
|
const { data: assigneeAdapterModels } = useQuery({
|
|
queryKey: ["adapter-models", assigneeAdapterType],
|
|
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
|
|
enabled: !!selectedCompanyId && newIssueOpen && supportsAssigneeOverrides,
|
|
});
|
|
|
|
const createIssue = useMutation({
|
|
mutationFn: (data: Record<string, unknown>) =>
|
|
issuesApi.create(selectedCompanyId!, data),
|
|
onSuccess: (issue) => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
|
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 (!selectedCompanyId) throw new Error("No company selected");
|
|
return assetsApi.uploadImage(selectedCompanyId, 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,
|
|
assigneeUseProjectWorkspace,
|
|
});
|
|
}, [
|
|
title,
|
|
description,
|
|
status,
|
|
priority,
|
|
assigneeId,
|
|
projectId,
|
|
assigneeModelOverride,
|
|
assigneeThinkingEffort,
|
|
assigneeUseProjectWorkspace,
|
|
newIssueOpen,
|
|
scheduleSave,
|
|
]);
|
|
|
|
// Restore draft or apply defaults when dialog opens
|
|
useEffect(() => {
|
|
if (!newIssueOpen) return;
|
|
|
|
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 ?? "");
|
|
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
|
|
} else {
|
|
setStatus(newIssueDefaults.status ?? "todo");
|
|
setPriority(newIssueDefaults.priority ?? "");
|
|
setProjectId(newIssueDefaults.projectId ?? "");
|
|
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
|
|
setAssigneeModelOverride("");
|
|
setAssigneeThinkingEffort("");
|
|
setAssigneeUseProjectWorkspace(true);
|
|
}
|
|
}, [newIssueOpen, newIssueDefaults]);
|
|
|
|
useEffect(() => {
|
|
if (!supportsAssigneeOverrides) {
|
|
setAssigneeOptionsOpen(false);
|
|
setAssigneeModelOverride("");
|
|
setAssigneeThinkingEffort("");
|
|
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("");
|
|
setAssigneeUseProjectWorkspace(true);
|
|
setExpanded(false);
|
|
}
|
|
|
|
function discardDraft() {
|
|
clearDraft();
|
|
reset();
|
|
closeNewIssue();
|
|
}
|
|
|
|
function handleSubmit() {
|
|
if (!selectedCompanyId || !title.trim()) return;
|
|
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
|
|
adapterType: assigneeAdapterType,
|
|
modelOverride: assigneeModelOverride,
|
|
thinkingEffortOverride: assigneeThinkingEffort,
|
|
useProjectWorkspace: assigneeUseProjectWorkspace,
|
|
});
|
|
createIssue.mutate({
|
|
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 = ``;
|
|
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 = (projects ?? []).find((p) => p.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[]>(
|
|
() =>
|
|
(projects ?? []).map((project) => ({
|
|
id: project.id,
|
|
label: project.name,
|
|
searchText: project.description ?? "",
|
|
})),
|
|
[projects],
|
|
);
|
|
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">
|
|
{selectedCompany && (
|
|
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
|
|
{selectedCompany.name.slice(0, 3).toUpperCase()}
|
|
</span>
|
|
)}
|
|
<span className="text-muted-foreground/60">›</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">×</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 = (projects ?? []).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>
|
|
<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}
|
|
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>
|
|
);
|
|
}
|