Files
paperclip/ui/src/components/NewIssueDialog.tsx
T
Forgotten adca44849a feat(ui): active agents panel, sidebar context, and page enhancements
Add live ActiveAgentsPanel with real-time transcript feed, SidebarContext
for responsive sidebar state, agent config form with reasoning effort,
improved inbox with failed run alerts, enriched issue detail with project
picker, and various component refinements across pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:32:32 -06:00

473 lines
17 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
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,
CircleDot,
Minus,
ArrowUp,
ArrowDown,
AlertTriangle,
User,
Hexagon,
Tag,
Calendar,
} from "lucide-react";
import { cn } from "../lib/utils";
import type { Project, Agent } from "@paperclip/shared";
const DRAFT_KEY = "paperclip:issue-draft";
const DEBOUNCE_MS = 800;
interface IssueDraft {
title: string;
description: string;
status: string;
priority: string;
assigneeId: string;
projectId: string;
}
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: "text-muted-foreground" },
{ value: "todo", label: "Todo", color: "text-blue-400" },
{ value: "in_progress", label: "In Progress", color: "text-yellow-400" },
{ value: "in_review", label: "In Review", color: "text-violet-400" },
{ value: "done", label: "Done", color: "text-green-400" },
];
const priorities = [
{ value: "critical", label: "Critical", icon: AlertTriangle, color: "text-red-400" },
{ value: "high", label: "High", icon: ArrowUp, color: "text-orange-400" },
{ value: "medium", label: "Medium", icon: Minus, color: "text-yellow-400" },
{ value: "low", label: "Low", icon: ArrowDown, color: "text-blue-400" },
];
export function NewIssueDialog() {
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
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 [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 [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [projectOpen, setProjectOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
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 createIssue = useMutation({
mutationFn: (data: Record<string, unknown>) =>
issuesApi.create(selectedCompanyId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
if (draftTimer.current) clearTimeout(draftTimer.current);
clearDraft();
reset();
closeNewIssue();
},
});
// 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 });
}, [title, description, status, priority, assigneeId, projectId, 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);
} else {
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(newIssueDefaults.projectId ?? "");
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
}
}, [newIssueOpen, newIssueDefaults]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (draftTimer.current) clearTimeout(draftTimer.current);
};
}, []);
function reset() {
setTitle("");
setDescription("");
setStatus("todo");
setPriority("");
setAssigneeId("");
setProjectId("");
setExpanded(false);
}
function discardDraft() {
clearDraft();
reset();
closeNewIssue();
}
function handleSubmit() {
if (!selectedCompanyId || !title.trim()) return;
createIssue.mutate({
title: title.trim(),
description: description.trim() || undefined,
status,
priority: priority || "medium",
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
...(projectId ? { projectId } : {}),
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
}
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);
return (
<Dialog
open={newIssueOpen}
onOpenChange={(open) => {
if (!open) closeNewIssue();
}}
>
<DialogContent
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col",
expanded
? "sm:max-w-2xl h-[calc(100vh-6rem)] max-h-[calc(100vh-6rem)]"
: "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">&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">
<input
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder="Issue title"
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
/>
</div>
{/* Description */}
<div className={cn("px-4 pb-2", expanded ? "flex-1 min-h-0" : "")}>
<textarea
className={cn(
"w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40 resize-none",
expanded ? "h-full" : "min-h-[60px]"
)}
placeholder="Add description..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</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>
{/* Assignee chip */}
<Popover open={assigneeOpen} onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}>
<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">
<User className="h-3 w-3 text-muted-foreground" />
{currentAssignee ? currentAssignee.name : "Assignee"}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-1" align="start">
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search agents..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!assigneeId && "bg-accent"
)}
onClick={() => { setAssigneeId(""); setAssigneeOpen(false); }}
>
No assignee
</button>
{(agents ?? [])
.filter((a) => a.status !== "terminated")
.filter((a) => {
if (!assigneeSearch.trim()) return true;
const q = assigneeSearch.toLowerCase();
return a.name.toLowerCase().includes(q);
})
.map((a) => (
<button
key={a.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
a.id === assigneeId && "bg-accent"
)}
onClick={() => { setAssigneeId(a.id); setAssigneeOpen(false); }}
>
{a.name}
</button>
))}
</PopoverContent>
</Popover>
{/* Project chip */}
<Popover open={projectOpen} onOpenChange={setProjectOpen}>
<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">
<Hexagon className="h-3 w-3 text-muted-foreground" />
{currentProject ? currentProject.name : "Project"}
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="start">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!projectId && "bg-accent"
)}
onClick={() => { setProjectId(""); setProjectOpen(false); }}
>
No project
</button>
{(projects ?? []).map((p) => (
<button
key={p.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
p.id === projectId && "bg-accent"
)}
onClick={() => { setProjectId(p.id); setProjectOpen(false); }}
>
{p.name}
</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>
{/* 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>
);
}