"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 { Bot, Calendar, Check, ChevronLeft, ChevronRight, Link2, MoreHorizontal, PanelRight, 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, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, } from "@/components/ui/dropdown-menu"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; import { Input } from "@/components/ui/input"; import { RichTextEditor } from "@/components/common/rich-text-editor"; 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 { CommentCard } from "./comment-card"; import { CommentInput } from "./comment-input"; 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"; import { timeAgo } from "@/shared/utils"; function shortDate(date: string | null): string { if (!date) return "—"; return new Date(date).toLocaleDateString("en-US", { month: "short", day: "numeric", }); } function statusLabel(status: string): string { return STATUS_CONFIG[status as IssueStatus]?.label ?? status; } function priorityLabel(priority: string): string { return PRIORITY_CONFIG[priority as IssuePriority]?.label ?? priority; } function formatActivity( entry: TimelineEntry, resolveActorName?: (type: string, id: string) => string, ): string { const details = (entry.details ?? {}) as Record; switch (entry.action) { case "created": return "created this issue"; case "status_changed": return `changed status from ${statusLabel(details.from ?? "?")} to ${statusLabel(details.to ?? "?")}`; case "priority_changed": return `changed priority from ${priorityLabel(details.from ?? "?")} to ${priorityLabel(details.to ?? "?")}`; case "assignee_changed": { const isSelfAssign = details.to_type === entry.actor_type && details.to_id === entry.actor_id; if (isSelfAssign) return "self-assigned this issue"; const toName = details.to_id && details.to_type && resolveActorName ? resolveActorName(details.to_type, details.to_id) : null; if (toName) return `assigned to ${toName}`; if (details.from_id && !details.to_id) return "removed assignee"; return "changed assignee"; } case "due_date_changed": { if (!details.to) return "removed due date"; const formatted = new Date(details.to).toLocaleDateString("en-US", { month: "short", day: "numeric" }); return `set due date to ${formatted}`; } case "title_changed": return `renamed this issue from "${details.from ?? "?"}" to "${details.to ?? "?"}"`; 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 [submitting, setSubmitting] = useState(false); const [deleting, setDeleting] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const titleFocusedRef = useRef(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [propertiesOpen, setPropertiesOpen] = useState(true); const [detailsOpen, setDetailsOpen] = 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); if (!titleFocusedRef.current) { setTitleDraft(storeIssue.title); } } }, [storeIssue]); useEffect(() => { setIssue(null); setTitleDraft(""); setTimeline([]); setSubscribers([]); setLoading(true); Promise.all([api.getIssue(id), api.listTimeline(id), api.listIssueSubscribers(id)]) .then(([iss, entries, subs]) => { setIssue(iss); setTitleDraft(iss.title); setTimeline(entries); setSubscribers(subs); }) .catch(console.error) .finally(() => setLoading(false)); }, [id]); const handleSubmitComment = async (content: string) => { if (!content.trim() || 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]); 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, content: string) => { if (!content.trim() || !user) return; try { const comment = await api.createComment(id, content, "comment", parentId); setTimeline((prev) => { if (prev.some((e) => e.id === comment.id)) return prev; return [...prev, commentToTimelineEntry(comment)]; }); } catch { toast.error("Failed to send reply"); } }; const handleEditComment = async (commentId: string, content: string) => { try { const updated = await api.updateComment(commentId, content); setTimeline((prev) => prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e))); } catch { toast.error("Failed to update comment"); } }; const handleDeleteComment = async (commentId: string) => { try { await api.deleteComment(commentId); setTimeline((prev) => { const idsToRemove = new Set([commentId]); // Recursively collect all descendant IDs let added = true; while (added) { added = false; for (const e of prev) { if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) { idsToRemove.add(e.id); added = true; } } } return prev.filter((e) => !idsToRemove.has(e.id)); }); } catch { toast.error("Failed to delete comment"); } }; 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); } }; // 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) => { const idsToRemove = new Set([comment_id]); let added = true; while (added) { added = false; for (const e of prev) { if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) { idsToRemove.add(e.id); added = true; } } } return prev.filter((e) => !idsToRemove.has(e.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.identifier} {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 */}
setTitleDraft(e.target.value)} onFocus={() => { titleFocusedRef.current = true; }} onBlur={() => { titleFocusedRef.current = false; const trimmed = titleDraft.trim(); if (trimmed && trimmed !== issue.title) handleUpdateField({ title: trimmed }); else setTitleDraft(issue.title); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); } else if (e.key === "Escape") { setTitleDraft(issue.title); (e.target as HTMLInputElement).blur(); } }} className="w-full bg-transparent text-2xl font-bold leading-snug tracking-tight outline-none placeholder:text-muted-foreground" /> 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 */}
{(() => { 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); } } // Group consecutive activities together so the connector line works const groups: { type: "activities" | "comment"; entries: TimelineEntry[] }[] = []; for (const entry of topLevel) { if (entry.type === "activity") { const last = groups[groups.length - 1]; if (last?.type === "activities") { last.entries.push(entry); } else { groups.push({ type: "activities", entries: [entry] }); } } else { groups.push({ type: "comment", entries: [entry] }); } } return groups.map((group) => { if (group.type === "comment") { const entry = group.entries[0]!; return ( ); } return (
{group.entries.map((entry, idx) => { const details = (entry.details ?? {}) as Record; const isStatusChange = entry.action === "status_changed"; const isPriorityChange = entry.action === "priority_changed"; const isDueDateChange = entry.action === "due_date_changed"; const isLast = idx === group.entries.length - 1; let leadIcon: React.ReactNode; if (isStatusChange && details.to) { leadIcon = ; } else if (isPriorityChange && details.to) { leadIcon = ; } else if (isDueDateChange) { leadIcon = ; } else { leadIcon = ; } return (
{leadIcon}
{!isLast &&
}
{getActorName(entry.actor_type, entry.actor_id)} {formatActivity(entry, getActorName)} {timeAgo(entry.created_at)} } /> {new Date(entry.created_at).toLocaleString()}
); })}
); }); })()}
{/* Bottom comment input — no avatar, full width */}
setSidebarOpen(size.inPixels > 0)} > {/* RIGHT: Properties sidebar */}
{/* Properties section */}
{propertiesOpen &&
{/* Status */} {STATUS_CONFIG[issue.status].label} {ALL_STATUSES.map((s) => ( handleUpdateField({ status: s })}> {STATUS_CONFIG[s].label} {s === issue.status && } ))} {/* Priority */} {PRIORITY_CONFIG[issue.priority].label} {PRIORITY_ORDER.map((p) => ( handleUpdateField({ priority: p })}> {PRIORITY_CONFIG[p].label} {p === issue.priority && } ))} {/* 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)}
}
); }