"use client"; import { useState, useEffect, useCallback, useRef, memo } from "react"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { 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 { RichTextEditor } from "@/components/common/rich-text-editor"; import { FileUploadButton } from "@/components/common/file-upload-button"; import { TitleEditor } from "@/components/common/title-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 { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; import { ActorAvatar } from "@/components/common/actor-avatar"; import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components"; import { CommentCard } from "./comment-card"; import { CommentInput } from "./comment-input"; import { AgentLiveCard, TaskRunHistory } from "./agent-live-card"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; import { useIssueStore } from "@/features/issues"; import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline"; import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions"; import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers"; import { ReactionBar } from "@/components/common/reaction-bar"; import { useFileUpload } from "@/shared/hooks/use-file-upload"; 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 ?? ""; } } // --------------------------------------------------------------------------- // Property row // --------------------------------------------------------------------------- function PropRow({ label, children, }: { label: string; children: React.ReactNode; }) { return (
{label}
{children}
); } // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- interface IssueDetailProps { issueId: string; onDelete?: () => void; defaultSidebarOpen?: boolean; layoutId?: string; } // --------------------------------------------------------------------------- // IssueDetail // --------------------------------------------------------------------------- export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout" }: 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); const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role; // 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 } = useActorName(); const { uploadWithToast } = useFileUpload(); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: layoutId, }); const sidebarRef = usePanelRef(); const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen); const [deleting, setDeleting] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [propertiesOpen, setPropertiesOpen] = useState(true); const [detailsOpen, setDetailsOpen] = useState(true); // Single source of truth: read issue directly from global store const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null; const [issueLoading, setIssueLoading] = useState(!issue); // If issue isn't in the store yet, fetch and upsert it useEffect(() => { if (issue) { setIssueLoading(false); return; } setIssueLoading(true); api .getIssue(id) .then((iss) => { useIssueStore.getState().addIssue(iss); }) .catch(console.error) .finally(() => setIssueLoading(false)); }, [id, !!issue]); // Custom hooks — encapsulate timeline, reactions, subscribers const { timeline, submitting, submitComment, submitReply, editComment, deleteComment, toggleReaction: handleToggleReaction, } = useIssueTimeline(id, user?.id); const { reactions: issueReactions, toggleReaction: handleToggleIssueReaction, } = useIssueReactions(id, user?.id); const { subscribers, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber, } = useIssueSubscribers(id, user?.id); const loading = issueLoading; // Issue field updates — write directly to the global store (single source of truth) const handleUpdateField = useCallback( (updates: Partial) => { if (!issue) return; const prev = { ...issue }; useIssueStore.getState().updateIssue(id, updates); api.updateIssue(id, updates).catch(() => { useIssueStore.getState().updateIssue(id, prev); toast.error("Failed to update issue"); }); }, [issue, id], ); const descEditorRef = useRef(null); const handleDescriptionUpload = useCallback( (file: File) => uploadWithToast(file, { issueId: id }), [uploadWithToast, id], ); const handleDelete = async () => { setDeleting(true); try { await api.deleteIssue(issue!.id); useIssueStore.getState().removeIssue(issue!.id); toast.success("Issue deleted"); if (onDelete) onDelete(); else router.push("/issues"); } catch { toast.error("Failed to delete issue"); setDeleting(false); } }; if (loading) { return (
Loading...
); } if (!issue) { return (

This issue does not exist or has been deleted in this workspace.

{!onDelete && ( )}
); } 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 })} > {m.name} {issue.assignee_type === "member" && issue.assignee_id === m.user_id && } ))} {agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).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 */}
{ const trimmed = value.trim(); if (trimmed && trimmed !== issue.title) handleUpdateField({ title: trimmed }); }} /> handleUpdateField({ description: md || undefined })} onUploadFile={handleDescriptionUpload} debounceMs={1500} className="mt-5" />
descEditorRef.current?.insertFile(result.filename, result.link, isImage)} />
{/* Activity / Comments */}

Activity

{subscribers.length > 0 ? ( {subscribers.slice(0, 4).map((sub) => ( ))} {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} ); })} )}
{/* Agent live output */}
{/* Agent execution history */}
{/* 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); } } // Coalesce: same actor + same action within 2 min → keep last only const COALESCE_MS = 2 * 60 * 1000; const coalesced: TimelineEntry[] = []; for (const entry of topLevel) { if (entry.type === "activity") { const prev = coalesced[coalesced.length - 1]; if ( prev?.type === "activity" && prev.action === entry.action && prev.actor_type === entry.actor_type && prev.actor_id === entry.actor_id && Math.abs(new Date(entry.created_at).getTime() - new Date(prev.created_at).getTime()) <= COALESCE_MS ) { // Replace previous with this one (keep the later result) coalesced[coalesced.length - 1] = entry; continue; } } coalesced.push(entry); } // Group consecutive activities together so the connector line works const groups: { type: "activities" | "comment"; entries: TimelineEntry[] }[] = []; for (const entry of coalesced) { 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"; let leadIcon: React.ReactNode; if (isStatusChange && details.to) { leadIcon = ; } else if (isPriorityChange && details.to) { leadIcon = ; } else if (isDueDateChange) { leadIcon = ; } else { leadIcon = ; } return (
{leadIcon}
{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 */} {/* Due date */}
}
{/* Details section */}
{detailsOpen &&
{getActorName(issue.creator_type, issue.creator_id)} {shortDate(issue.created_at)} {shortDate(issue.updated_at)}
}
); }