"use client"; import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { ChevronRight, Link2, Pencil, Send, Trash2, X, } from "lucide-react"; import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverTrigger, PopoverContent, } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip"; import { ActorAvatar } from "@/components/common/actor-avatar"; import type { Issue, Comment, UpdateIssueRequest } from "@multica/types"; import { StatusPicker, PriorityPicker, AssigneePicker } from "@/features/issues/components"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useActorName } from "@/features/workspace"; import { useWSEvent } from "@/features/realtime"; import { useIssueStore } from "@/features/issues"; import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const minutes = Math.floor(diff / 60000); if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; } function shortDate(date: string | null): string { if (!date) return "—"; return new Date(date).toLocaleDateString("en-US", { month: "short", day: "numeric", }); } // --------------------------------------------------------------------------- // Property row // --------------------------------------------------------------------------- function PropRow({ label, children, }: { label: string; children: React.ReactNode; }) { return (
{label}
{children}
); } // --------------------------------------------------------------------------- // Due Date Picker // --------------------------------------------------------------------------- function DueDatePicker({ dueDate, onUpdate, }: { dueDate: string | null; onUpdate: (updates: Partial) => void; }) { const [open, setOpen] = useState(false); const date = dueDate ? new Date(dueDate) : undefined; const isOverdue = date ? date < new Date() : false; return ( {date ? ( {date.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ) : ( None )} { onUpdate({ due_date: d ? d.toISOString() : null }); setOpen(false); }} /> {date && (
)}
); } // --------------------------------------------------------------------------- // Acceptance Criteria Editor // --------------------------------------------------------------------------- function AcceptanceCriteriaEditor({ criteria, onUpdate, }: { criteria: string[]; onUpdate: (updates: Partial) => void; }) { const [newItem, setNewItem] = useState(""); const addItem = () => { if (!newItem.trim()) return; onUpdate({ acceptance_criteria: [...criteria, newItem.trim()] }); setNewItem(""); }; const removeItem = (index: number) => { onUpdate({ acceptance_criteria: criteria.filter((_, i) => i !== index) }); }; const [adding, setAdding] = useState(false); return (

Acceptance Criteria

{criteria.length > 0 && (
{criteria.map((item, i) => (
{item}
))}
)} {(criteria.length > 0 || adding) ? (
{ e.preventDefault(); addItem(); }} className="flex items-center gap-2" > setNewItem(e.target.value)} onBlur={() => { if (!newItem.trim()) setAdding(false); }} placeholder="Add criteria..." aria-label="Add acceptance criteria" className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground" />
) : ( )}
); } // --------------------------------------------------------------------------- // Context Refs Editor // --------------------------------------------------------------------------- function ContextRefsEditor({ refs, onUpdate, }: { refs: string[]; onUpdate: (updates: Partial) => void; }) { const [newRef, setNewRef] = useState(""); const addRef = () => { if (!newRef.trim()) return; onUpdate({ context_refs: [...refs, newRef.trim()] }); setNewRef(""); }; const removeRef = (index: number) => { onUpdate({ context_refs: refs.filter((_, i) => i !== index) }); }; const [adding, setAdding] = useState(false); const isUrl = (s: string) => s.startsWith("http://") || s.startsWith("https://"); return (

Context References

{refs.length > 0 && (
{refs.map((ref, i) => (
{isUrl(ref) ? ( {ref} ) : ( {ref} )}
))}
)} {(refs.length > 0 || adding) ? (
{ e.preventDefault(); addRef(); }} className="flex items-center gap-2" > setNewRef(e.target.value)} onBlur={() => { if (!newRef.trim()) setAdding(false); }} placeholder="Add reference URL..." aria-label="Add context reference URL" className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground" />
) : ( )}
); } // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- interface IssueDetailProps { issueId: string; showBreadcrumb?: boolean; onDelete?: () => void; } // --------------------------------------------------------------------------- // IssueDetail // --------------------------------------------------------------------------- export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailProps) { const id = issueId; const router = useRouter(); const user = useAuthStore((s) => s.user); const { getActorName } = useActorName(); const [issue, setIssue] = useState(null); const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); const [commentText, setCommentText] = useState(""); const [submitting, setSubmitting] = useState(false); const [deleting, setDeleting] = useState(false); const [editingCommentId, setEditingCommentId] = useState(null); const [editContent, setEditContent] = useState(""); const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const [editingDesc, setEditingDesc] = useState(false); const [descDraft, setDescDraft] = useState(""); // Watch the global issue store for real-time updates from other users/agents const storeIssue = useIssueStore((s) => s.issues.find((i) => i.id === id)); useEffect(() => { if (storeIssue) { setIssue(storeIssue); } }, [storeIssue]); useEffect(() => { setIssue(null); setComments([]); setLoading(true); Promise.all([api.getIssue(id), api.listComments(id)]) .then(([iss, cmts]) => { setIssue(iss); setComments(cmts); }) .catch(console.error) .finally(() => setLoading(false)); }, [id]); const handleSubmitComment = async (e: React.FormEvent) => { e.preventDefault(); if (!commentText.trim() || submitting || !user) return; const content = commentText.trim(); const tempId = "temp-" + Date.now(); const tempComment: Comment = { id: tempId, issue_id: id, author_type: "member", author_id: user.id, content, type: "comment", created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; setComments((prev) => [...prev, tempComment]); setCommentText(""); setSubmitting(true); try { const comment = await api.createComment(id, content); setComments((prev) => prev.map((c) => (c.id === tempId ? comment : c))); } catch { setComments((prev) => prev.filter((c) => c.id !== tempId)); toast.error("Failed to send comment"); } finally { setSubmitting(false); } }; const handleUpdateField = useCallback( (updates: Partial) => { if (!issue) return; const prev = issue; setIssue((curr) => (curr ? ({ ...curr, ...updates } as Issue) : curr)); api.updateIssue(id, updates).catch(() => { setIssue(prev); toast.error("Failed to update issue"); }); }, [issue, id], ); const handleDelete = async () => { setDeleting(true); try { await api.deleteIssue(issue!.id); toast.success("Issue deleted"); if (onDelete) onDelete(); else router.push("/issues"); } catch { toast.error("Failed to delete issue"); setDeleting(false); } }; const startEditComment = (c: Comment) => { setEditingCommentId(c.id); setEditContent(c.content); }; const handleSaveEditComment = async () => { if (!editingCommentId || !editContent.trim()) return; try { const updated = await api.updateComment(editingCommentId, editContent.trim()); setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); setEditingCommentId(null); } catch { toast.error("Failed to update comment"); } }; const handleDeleteComment = async (commentId: string) => { try { await api.deleteComment(commentId); setComments((prev) => prev.filter((c) => c.id !== commentId)); } catch { toast.error("Failed to delete comment"); } }; // Real-time comment updates useWSEvent( "comment:created", useCallback((payload: unknown) => { const { comment } = payload as CommentCreatedPayload; if (comment.issue_id !== id) return; // Skip own comments — already added locally via API response if (comment.author_type === "member" && comment.author_id === user?.id) return; setComments((prev) => { if (prev.some((c) => c.id === comment.id)) return prev; return [...prev, comment]; }); }, [id, user?.id]), ); useWSEvent( "comment:updated", useCallback((payload: unknown) => { const { comment } = payload as CommentUpdatedPayload; if (comment.issue_id === id) { setComments((prev) => prev.map((c) => (c.id === comment.id ? comment : c))); } }, [id]), ); useWSEvent( "comment:deleted", useCallback((payload: unknown) => { const { comment_id, issue_id } = payload as CommentDeletedPayload; if (issue_id === id) { setComments((prev) => prev.filter((c) => c.id !== comment_id)); } }, [id]), ); if (loading) { return (
Loading...
); } if (!issue) { return (
Issue not found
); } return (
{/* LEFT: Content area */}
{/* Header bar */} {showBreadcrumb !== false && (
Issues {issue.id.slice(0, 8)}
} > Delete issue This will permanently delete this issue and all its comments. This action cannot be undone. Cancel {deleting ? "Deleting..." : "Delete"}
)} {/* Content */}
{issue.id.slice(0, 8)}
{editingTitle ? ( setTitleDraft(e.target.value)} onBlur={() => { if (titleDraft.trim()) handleUpdateField({ title: titleDraft.trim() }); setEditingTitle(false); }} onKeyDown={(e) => { if (e.key === "Enter") { if (titleDraft.trim()) handleUpdateField({ title: titleDraft.trim() }); setEditingTitle(false); } else if (e.key === "Escape") { setEditingTitle(false); } }} className="text-xl font-semibold leading-snug tracking-tight" /> ) : (

{ setTitleDraft(issue.title); setEditingTitle(true); }} > {issue.title}

)} {editingDesc ? (