"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { ArrowUp, Bot, Calendar, ChevronLeft, ChevronRight, Circle, Link2, MessageSquare, MoreHorizontal, PanelRight, Pencil, Trash2, UserMinus, Users, X, } from "lucide-react"; import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, } from "@/components/ui/dropdown-menu"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; import { Input } from "@/components/ui/input"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; import { Markdown } from "@/components/markdown"; import { Tooltip, TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"; import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; import { ActorAvatar } from "@/components/common/actor-avatar"; import type { Issue, Comment, IssueSubscriber, UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; import { StatusIcon, PriorityIcon, DueDatePicker } from "@/features/issues/components"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; import { useWSEvent } from "@/features/realtime"; import { useIssueStore } from "@/features/issues"; import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload, SubscriberAddedPayload, SubscriberRemovedPayload, ActivityCreatedPayload } from "@/shared/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", }); } function formatActivity(entry: TimelineEntry): string { const details = (entry.details ?? {}) as Record; switch (entry.action) { case "created": return "created this issue"; case "status_changed": return `changed status from ${details.from ?? "?"} to ${details.to ?? "?"}`; case "assignee_changed": return "changed assignee"; case "description_updated": return "updated the description"; case "task_completed": return "completed the task"; case "task_failed": return "task failed"; default: return entry.action ?? ""; } } function commentToTimelineEntry(c: Comment): TimelineEntry { return { type: "comment", id: c.id, actor_type: c.author_type, actor_id: c.author_id, content: c.content, parent_id: c.parent_id, created_at: c.created_at, updated_at: c.updated_at, comment_type: c.type, }; } // --------------------------------------------------------------------------- // Property row // --------------------------------------------------------------------------- function PropRow({ label, children, }: { label: string; children: React.ReactNode; }) { return (
{label}
{children}
); } // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- interface IssueDetailProps { issueId: string; onDelete?: () => void; } // --------------------------------------------------------------------------- // IssueDetail // --------------------------------------------------------------------------- export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { const id = issueId; const router = useRouter(); const user = useAuthStore((s) => s.user); const workspace = useWorkspaceStore((s) => s.workspace); const members = useWorkspaceStore((s) => s.members); const agents = useWorkspaceStore((s) => s.agents); // Issue navigation const allIssues = useIssueStore((s) => s.issues); const currentIndex = allIssues.findIndex((i) => i.id === id); const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null; const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null; const { getActorName, getActorInitials } = useActorName(); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "multica_issue_detail_layout", }); const sidebarRef = usePanelRef(); const [sidebarOpen, setSidebarOpen] = useState(true); const [issue, setIssue] = useState(null); const [timeline, setTimeline] = useState([]); const [subscribers, setSubscribers] = useState([]); const [loading, setLoading] = useState(true); const [commentEmpty, setCommentEmpty] = useState(true); const commentEditorRef = useRef(null); const replyEditorRef = useRef(null); 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 [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [propertiesOpen, setPropertiesOpen] = useState(true); const [detailsOpen, setDetailsOpen] = useState(true); const [filter, setFilter] = useState<"all" | "comments" | "activity">("all"); const [replyingTo, setReplyingTo] = useState(null); const [replyEmpty, setReplyEmpty] = useState(true); // 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); setTimeline([]); setSubscribers([]); setLoading(true); Promise.all([api.getIssue(id), api.listTimeline(id), api.listIssueSubscribers(id)]) .then(([iss, entries, subs]) => { setIssue(iss); setTimeline(entries); setSubscribers(subs); }) .catch(console.error) .finally(() => setLoading(false)); }, [id]); const handleSubmitComment = async () => { const content = commentEditorRef.current?.getMarkdown()?.trim(); if (!content || submitting || !user) return; const tempId = "temp-" + Date.now(); const tempEntry: TimelineEntry = { type: "comment", id: tempId, actor_type: "member", actor_id: user.id, content, parent_id: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), comment_type: "comment", }; setTimeline((prev) => [...prev, tempEntry]); commentEditorRef.current?.clearContent(); setCommentEmpty(true); setSubmitting(true); try { const comment = await api.createComment(id, content); setTimeline((prev) => prev.map((e) => (e.id === tempId ? commentToTimelineEntry(comment) : e))); } catch { setTimeline((prev) => prev.filter((e) => e.id !== tempId)); toast.error("Failed to send comment"); } finally { setSubmitting(false); } }; const handleSubmitReply = async (parentId: string) => { const md = replyEditorRef.current?.getMarkdown()?.trim(); if (!md || !user) return; try { const comment = await api.createComment(id, md, "comment", parentId); setTimeline((prev) => [...prev, commentToTimelineEntry(comment)]); replyEditorRef.current?.clearContent(); setReplyingTo(null); setReplyEmpty(true); } catch { toast.error("Failed to send reply"); } }; 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 = (entry: TimelineEntry) => { setEditingCommentId(entry.id); setEditContent(entry.content ?? ""); }; const handleSaveEditComment = async () => { if (!editingCommentId || !editContent.trim()) return; try { const updated = await api.updateComment(editingCommentId, editContent.trim()); setTimeline((prev) => prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e))); setEditingCommentId(null); } catch { toast.error("Failed to update comment"); } }; const handleDeleteComment = async (commentId: string) => { try { await api.deleteComment(commentId); setTimeline((prev) => prev.filter((e) => e.id !== commentId)); } catch { toast.error("Failed to delete comment"); } }; // Subscriber state const isSubscribed = subscribers.some( (s) => s.user_type === "member" && s.user_id === user?.id ); const toggleSubscriber = async (userId: string, userType: "member" | "agent", currentlySubscribed: boolean) => { if (!issue) return; try { if (currentlySubscribed) { await api.unsubscribeFromIssue(id, userId, userType); setSubscribers((prev) => prev.filter((s) => !(s.user_id === userId && s.user_type === userType))); } else { await api.subscribeToIssue(id, userId, userType); setSubscribers((prev) => { // Deduplicate: WS event may have already added this subscriber if (prev.some((s) => s.user_id === userId && s.user_type === userType)) return prev; return [...prev, { issue_id: id, user_type: userType, user_id: userId, reason: "manual" as const, created_at: new Date().toISOString() }]; }); } } catch { toast.error("Failed to update subscriber"); } }; const handleToggleSubscribe = () => { if (user) toggleSubscriber(user.id, "member", isSubscribed); }; // 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; setTimeline((prev) => { if (prev.some((e) => e.id === comment.id)) return prev; return [...prev, commentToTimelineEntry(comment)]; }); }, [id, user?.id]), ); useWSEvent( "comment:updated", useCallback((payload: unknown) => { const { comment } = payload as CommentUpdatedPayload; if (comment.issue_id === id) { setTimeline((prev) => prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e))); } }, [id]), ); useWSEvent( "comment:deleted", useCallback((payload: unknown) => { const { comment_id, issue_id } = payload as CommentDeletedPayload; if (issue_id === id) { setTimeline((prev) => prev.filter((e) => e.id !== comment_id)); } }, [id]), ); useWSEvent( "activity:created", useCallback((payload: unknown) => { const p = payload as ActivityCreatedPayload; if (p.issue_id !== id) return; const entry = p.entry; if (!entry || !entry.id) return; setTimeline((prev) => { if (prev.some((e) => e.id === entry.id)) return prev; return [...prev, entry]; }); }, [id]), ); // Real-time subscriber updates useWSEvent( "subscriber:added", useCallback((payload: unknown) => { const p = payload as SubscriberAddedPayload; if (p.issue_id !== id) return; setSubscribers((prev) => { if (prev.some((s) => s.user_id === p.user_id && s.user_type === p.user_type)) return prev; return [...prev, { issue_id: p.issue_id, user_type: p.user_type as "member" | "agent", user_id: p.user_id, reason: p.reason as IssueSubscriber["reason"], created_at: new Date().toISOString(), }]; }); }, [id]), ); useWSEvent( "subscriber:removed", useCallback((payload: unknown) => { const p = payload as SubscriberRemovedPayload; if (p.issue_id !== id) return; setSubscribers((prev) => prev.filter((s) => !(s.user_id === p.user_id && s.user_type === p.user_type))); }, [id]), ); if (loading) { return (
Loading...
); } if (!issue) { return (
Issue not found
); } return ( {/* LEFT: Content area */}
{/* Header bar */}
{workspace && ( <> {workspace.name} )} {issue.id.slice(0, 8)} {issue.title}
{/* Issue navigation */} {allIssues.length > 1 && (
prevIssue && router.push(`/issues/${prevIssue.id}`)} > } /> Previous issue {currentIndex >= 0 ? currentIndex + 1 : "?"} / {allIssues.length} nextIssue && router.push(`/issues/${nextIssue.id}`)} > } /> Next issue
)} } /> {/* Status */} Status {ALL_STATUSES.map((s) => ( handleUpdateField({ status: s })} > {STATUS_CONFIG[s].label} {issue.status === s && } ))} {/* Priority */} Priority {PRIORITY_ORDER.map((p) => ( handleUpdateField({ priority: p })} > {PRIORITY_CONFIG[p].label} {issue.priority === p && } ))} {/* Assignee */} Assignee handleUpdateField({ assignee_type: null, assignee_id: null })} > Unassigned {!issue.assignee_type && } {members.map((m) => ( handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })} >
{getActorInitials("member", m.user_id)}
{m.name} {issue.assignee_type === "member" && issue.assignee_id === m.user_id && }
))} {agents.map((a) => ( handleUpdateField({ assignee_type: "agent", assignee_id: a.id })} >
{a.name} {issue.assignee_type === "agent" && issue.assignee_id === a.id && }
))}
{/* Due date */} Due date handleUpdateField({ due_date: new Date().toISOString() })}> Today { const d = new Date(); d.setDate(d.getDate() + 1); handleUpdateField({ due_date: d.toISOString() }); }}> Tomorrow { const d = new Date(); d.setDate(d.getDate() + 7); handleUpdateField({ due_date: d.toISOString() }); }}> Next week {issue.due_date && ( <> handleUpdateField({ due_date: null })}> Clear date )} {/* Copy link */} { navigator.clipboard.writeText(window.location.href); toast.success("Link copied"); }}> Copy link {/* Delete */} setDeleteDialogOpen(true)} > Delete issue
{ const panel = sidebarRef.current; if (!panel) return; if (panel.isCollapsed()) panel.expand(); else panel.collapse(); }} > } /> Toggle sidebar
{/* Delete confirmation dialog (controlled by state) */} Delete issue This will permanently delete this issue and all its comments. This action cannot be undone. Cancel {deleting ? "Deleting..." : "Delete"}
{/* Content — scrollable */}
{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-2xl font-bold leading-snug tracking-tight" /> ) : (

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

)} handleUpdateField({ description: md || undefined })} debounceMs={1500} className="mt-5" />
{/* Activity / Comments */}

Activity

{subscribers.length > 0 ? ( {subscribers.slice(0, 4).map((sub) => ( {getActorInitials(sub.user_type, sub.user_id)} ))} {subscribers.length > 4 && ( +{subscribers.length - 4} )} ) : ( )} No results found {members.length > 0 && ( {members.filter((m, i, arr) => arr.findIndex((x) => x.user_id === m.user_id) === i).map((m) => { const sub = subscribers.find((s) => s.user_type === "member" && s.user_id === m.user_id); const isSubbed = !!sub; return ( toggleSubscriber(m.user_id, "member", isSubbed)} className="flex items-center gap-2.5" > {m.name} ); })} )} {agents.length > 0 && ( {agents.map((a) => { const sub = subscribers.find((s) => s.user_type === "agent" && s.user_id === a.id); const isSubbed = !!sub; return ( toggleSubscriber(a.id, "agent", isSubbed)} className="flex items-center gap-2.5" > {a.name} ); })} )}
{/* Timeline entries */}
{(() => { // Separate top-level entries from replies const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id); const repliesByParent = new Map(); for (const e of timeline) { if (e.type === "comment" && e.parent_id) { const list = repliesByParent.get(e.parent_id) ?? []; list.push(e); repliesByParent.set(e.parent_id, list); } } // Apply filter const filtered = topLevel.filter((e) => { if (filter === "all") return true; if (filter === "comments") return e.type === "comment"; if (filter === "activity") return e.type === "activity"; return true; }); return filtered.map((entry) => { if (entry.type === "activity") { return (
{getActorName(entry.actor_type, entry.actor_id)} {formatActivity(entry)} {timeAgo(entry.created_at)} } /> {new Date(entry.created_at).toLocaleString()}
); } // Comment entry const replies = repliesByParent.get(entry.id) ?? []; const isOwn = entry.actor_type === "member" && entry.actor_id === user?.id; return (
{getActorName(entry.actor_type, entry.actor_id)} {timeAgo(entry.created_at)} } /> {new Date(entry.created_at).toLocaleString()}
setReplyingTo(replyingTo === entry.id ? null : entry.id)} className="text-muted-foreground hover:text-foreground" > } /> Reply {isOwn && ( <> startEditComment(entry)} className="text-muted-foreground hover:text-foreground" > } /> Edit handleDeleteComment(entry.id)} className="text-muted-foreground hover:text-destructive" > } /> Delete )}
{editingCommentId === entry.id ? (
{ e.preventDefault(); handleSaveEditComment(); }} className="mt-2 pl-9.5"> setEditContent(e.target.value)} aria-label="Edit comment" className="w-full text-sm bg-transparent border-b outline-none" onKeyDown={(e) => { if (e.key === "Escape") setEditingCommentId(null); }} />
) : (
{entry.content ?? ""}
)} {/* Replies */} {replies.length > 0 && (
{replies.map((reply) => { const isReplyOwn = reply.actor_type === "member" && reply.actor_id === user?.id; return (
{getActorName(reply.actor_type, reply.actor_id)} {timeAgo(reply.created_at)} {isReplyOwn && (
startEditComment(reply)} className="text-muted-foreground hover:text-foreground" > } /> Edit handleDeleteComment(reply.id)} className="text-muted-foreground hover:text-destructive" > } /> Delete
)}
{editingCommentId === reply.id ? (
{ e.preventDefault(); handleSaveEditComment(); }} className="mt-1 pl-7.5"> setEditContent(e.target.value)} aria-label="Edit comment" className="w-full text-sm bg-transparent border-b outline-none" onKeyDown={(e) => { if (e.key === "Escape") setEditingCommentId(null); }} />
) : (
{reply.content ?? ""}
)}
); })}
)} {/* Reply input */} {replyingTo === entry.id && (
setReplyEmpty(!md.trim())} onSubmit={() => handleSubmitReply(entry.id)} debounceMs={100} />
)}
); }); })()}
{/* Comment input */}
setCommentEmpty(!md.trim())} onSubmit={handleSubmitComment} debounceMs={100} />
} /> Send
setSidebarOpen(size.inPixels > 0)} > {/* RIGHT: Properties sidebar */}
{/* Properties section */}
{propertiesOpen &&
{/* Status */} {STATUS_CONFIG[issue.status].label} handleUpdateField({ status: v as IssueStatus })}> {ALL_STATUSES.map((s) => ( {STATUS_CONFIG[s].label} ))} {/* Priority */} {PRIORITY_CONFIG[issue.priority].label} handleUpdateField({ priority: v as IssuePriority })}> {PRIORITY_ORDER.map((p) => ( {PRIORITY_CONFIG[p].label} ))} {/* Assignee */} {issue.assignee_type && issue.assignee_id ? ( <> {getActorName(issue.assignee_type, issue.assignee_id)} ) : ( Unassigned )} handleUpdateField({ assignee_type: null, assignee_id: null })}> Unassigned {members.length > 0 && ( <> Members {members.map((m) => ( handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}>
{getActorInitials("member", m.user_id)}
{m.name}
))}
)} {agents.length > 0 && ( <> Agents {agents.map((a) => ( handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}>
{a.name}
))}
)}
{/* Due date */}
}
{/* Details section */}
{detailsOpen &&
{getActorName(issue.creator_type, issue.creator_id)} {shortDate(issue.created_at)} {shortDate(issue.updated_at)}
}
); }