"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import { AppLink } from "../../navigation"; import { useNavigation } from "../../navigation"; import { ArrowDown, ArrowUp, Calendar, ChevronDown, ChevronLeft, ChevronRight, Link2, MoreHorizontal, PanelRight, Pin, PinOff, Plus, Trash2, UserMinus, Users, } from "lucide-react"; import { PageHeader } from "../../layout/page-header"; import { toast } from "sonner"; import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@multica/ui/components/ui/alert-dialog"; import { Button } from "@multica/ui/components/ui/button"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, } from "@multica/ui/components/ui/dropdown-menu"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@multica/ui/components/ui/resizable"; import { Sheet, SheetContent } from "@multica/ui/components/ui/sheet"; import { useIsMobile } from "@multica/ui/hooks/use-mobile"; import { ContentEditor, type ContentEditorRef, TitleEditor, useFileDropZone, FileDropOverlay } from "../../editor"; import { FileUploadButton } from "@multica/ui/components/common/file-upload-button"; import { Tooltip, TooltipTrigger, TooltipContent, } from "@multica/ui/components/ui/tooltip"; import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover"; import { Checkbox } from "@multica/ui/components/ui/checkbox"; import { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@multica/ui/components/ui/command"; import { AvatarGroup, AvatarGroupCount } from "@multica/ui/components/ui/avatar"; import { ActorAvatar } from "../../common/actor-avatar"; import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry, Issue } from "@multica/core/types"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@multica/core/issues/config"; import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, DueDatePicker, AssigneePicker, canAssignAgent } from "."; import { ProjectPicker } from "../../projects/components/project-picker"; import { CommentCard } from "./comment-card"; import { CommentInput } from "./comment-input"; import { AgentLiveCard, TaskRunHistory } from "./agent-live-card"; import { BacklogAgentHintDialog } from "./backlog-agent-hint-dialog"; import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@multica/core/auth"; import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; import { useActorName } from "@multica/core/workspace/hooks"; import { useWorkspaceId } from "@multica/core/hooks"; import { issueListOptions, issueDetailOptions, childIssuesOptions, issueUsageOptions } from "@multica/core/issues/queries"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; import { useUpdateIssue, useDeleteIssue } from "@multica/core/issues/mutations"; import { useRecentIssuesStore } from "@multica/core/issues/stores"; import { useIssueTimeline } from "../hooks/use-issue-timeline"; import { useIssueReactions } from "../hooks/use-issue-reactions"; import { useIssueSubscribers } from "../hooks/use-issue-subscribers"; import { ReactionBar } from "@multica/ui/components/common/reaction-bar"; import { useFileUpload } from "@multica/core/hooks/use-file-upload"; import { api } from "@multica/core/api"; import { useModalStore } from "@multica/core/modals"; import { timeAgo } from "@multica/core/utils"; import { cn } from "@multica/ui/lib/utils"; import { pinListOptions } from "@multica/core/pins"; import { useCreatePin, useDeletePin } from "@multica/core/pins"; import { ProgressRing } from "./progress-ring"; 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 ?? ""; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function formatTokenCount(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return String(n); } // --------------------------------------------------------------------------- // Property row // --------------------------------------------------------------------------- function PropRow({ label, children, }: { label: string; children: React.ReactNode; }) { return (
{label}
{children}
); } // --------------------------------------------------------------------------- // Issue Picker Dialog // --------------------------------------------------------------------------- function IssuePickerDialog({ open, onOpenChange, title, description, excludeIds, onSelect, }: { open: boolean; onOpenChange: (open: boolean) => void; title: string; description: string; excludeIds: string[]; onSelect: (issue: Issue) => void; }) { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [isLoading, setIsLoading] = useState(false); const debounceRef = useRef>(undefined); const abortRef = useRef(undefined); // Reset state when dialog opens/closes useEffect(() => { if (!open) { setQuery(""); setResults([]); setIsLoading(false); } }, [open]); const search = useCallback( (q: string) => { if (debounceRef.current) clearTimeout(debounceRef.current); if (abortRef.current) abortRef.current.abort(); if (!q.trim()) { setResults([]); setIsLoading(false); return; } setIsLoading(true); debounceRef.current = setTimeout(async () => { const controller = new AbortController(); abortRef.current = controller; try { const res = await api.searchIssues({ q: q.trim(), limit: 20, include_closed: true, signal: controller.signal, }); if (!controller.signal.aborted) { setResults( res.issues.filter((i) => !excludeIds.includes(i.id)), ); setIsLoading(false); } } catch { if (!controller.signal.aborted) { setIsLoading(false); } } }, 300); }, [excludeIds], ); return ( { setQuery(v); search(v); }} /> {isLoading && (
Searching...
)} {!isLoading && query.trim() && results.length === 0 && ( No issues found. )} {!isLoading && !query.trim() && (
Type to search issues
)} {results.length > 0 && ( {results.map((issue) => ( { onSelect(issue); onOpenChange(false); }} > {issue.identifier} {issue.title} ))} )}
); } // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- interface IssueDetailProps { issueId: string; onDelete?: () => void; defaultSidebarOpen?: boolean; layoutId?: string; /** When set, the issue detail will auto-scroll to this comment and briefly highlight it. */ highlightCommentId?: string; } // --------------------------------------------------------------------------- // IssueDetail // --------------------------------------------------------------------------- export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout", highlightCommentId }: IssueDetailProps) { const id = issueId; const router = useNavigation(); const user = useAuthStore((s) => s.user); const userId = useAuthStore((s) => s.user?.id); const workspace = useCurrentWorkspace(); const paths = useWorkspacePaths(); // Issue navigation — read from TQ list cache const wsId = useWorkspaceId(); const { data: members = [] } = useQuery(memberListOptions(wsId)); const { data: agents = [] } = useQuery(agentListOptions(wsId)); const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role; const { data: allIssues = [] } = useQuery(issueListOptions(wsId)); const { getActorName } = useActorName(); const { uploadWithToast } = useFileUpload(api); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: layoutId, }); const sidebarRef = usePanelRef(); const isMobile = useIsMobile(); const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen); useEffect(() => { if (isMobile) { setSidebarOpen(false); sidebarRef.current?.collapse(); } }, [isMobile]); const [deleting, setDeleting] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [backlogHintOpen, setBacklogHintOpen] = useState(false); const [propertiesOpen, setPropertiesOpen] = useState(true); const [detailsOpen, setDetailsOpen] = useState(true); const [parentIssueOpen, setParentIssueOpen] = useState(true); const [tokenUsageOpen, setTokenUsageOpen] = useState(true); const scrollContainerRef = useRef(null); const [highlightedId, setHighlightedId] = useState(null); const didHighlightRef = useRef(null); const [parentPickerOpen, setParentPickerOpen] = useState(false); const [childPickerOpen, setChildPickerOpen] = useState(false); // Issue data from TQ — uses detail query, seeded from list cache if available. // Only seed when description is present; list API omits it, and ContentEditor // reads defaultValue on mount only — seeding null description shows an empty editor. const { data: issue = null, isLoading: issueLoading } = useQuery({ ...issueDetailOptions(wsId, id), initialData: () => { const cached = allIssues.find((i) => i.id === id); return cached?.description != null ? cached : undefined; }, }); // Record recent visit const recordVisit = useRecentIssuesStore((s) => s.recordVisit); useEffect(() => { if (issue) { recordVisit(issue.id); } }, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps // Custom hooks — encapsulate timeline, reactions, subscribers const { timeline, 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); // Token usage const { data: usage } = useQuery(issueUsageOptions(id)); // Pinned state const { data: pinnedItems = [] } = useQuery({ ...pinListOptions(wsId, userId ?? ""), enabled: !!userId, }); const isPinned = pinnedItems.some((p) => p.item_type === "issue" && p.item_id === id); const createPin = useCreatePin(); const deletePin = useDeletePin(); // Sub-issue queries const parentIssueId = issue?.parent_issue_id; const { data: parentIssue = null } = useQuery({ ...issueDetailOptions(wsId, parentIssueId ?? ""), enabled: !!parentIssueId, initialData: () => allIssues.find((i) => i.id === parentIssueId), }); const { data: childIssues = [] } = useQuery({ ...childIssuesOptions(wsId, id), enabled: !!issue, }); // Parent's children — used to render the "x/y" progress next to the // "Sub-issue of …" breadcrumb under the title. const { data: parentChildIssues = [] } = useQuery({ ...childIssuesOptions(wsId, parentIssueId ?? ""), enabled: !!parentIssueId, }); const [subIssuesCollapsed, setSubIssuesCollapsed] = useState(false); const loading = issueLoading; // Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId) useEffect(() => { if (!highlightCommentId || timeline.length === 0) return; if (didHighlightRef.current === highlightCommentId) return; const el = document.getElementById(`comment-${highlightCommentId}`); if (el) { didHighlightRef.current = highlightCommentId; requestAnimationFrame(() => { el.scrollIntoView({ behavior: "smooth", block: "center" }); setHighlightedId(highlightCommentId); const timer = setTimeout(() => setHighlightedId(null), 2000); return () => clearTimeout(timer); }); } }, [highlightCommentId, timeline.length]); // Issue field updates via TQ mutation (optimistic update + rollback in mutation hook) const updateIssueMutation = useUpdateIssue(); const handleUpdateField = useCallback( (updates: Partial) => { if (!issue) return; updateIssueMutation.mutate( { id, ...updates }, { onError: () => toast.error("Failed to update issue") }, ); // Hint: assigning an agent to a backlog issue won't trigger execution // until the issue is moved to an active status. if ( updates.assignee_type === "agent" && updates.assignee_id && issue.status === "backlog" && localStorage.getItem("multica:backlog-agent-hint-dismissed") !== "true" ) { setBacklogHintOpen(true); } }, [issue, id, updateIssueMutation], ); const descEditorRef = useRef(null); const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({ onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)), }); // Description uploads don't pass issueId — the URL lives in the markdown. // This avoids stale attachment records when users delete images from the editor. const handleDescriptionUpload = useCallback( (file: File) => uploadWithToast(file), [uploadWithToast], ); const deleteIssueMutation = useDeleteIssue(); const handleDelete = async () => { setDeleting(true); try { await deleteIssueMutation.mutateAsync(issue!.id); toast.success("Issue deleted"); if (onDelete) onDelete(); else router.push(paths.issues()); } catch { toast.error("Failed to delete issue"); setDeleting(false); } }; if (loading) { return (
{Array.from({ length: 4 }).map((_, i) => (
))} {Array.from({ length: 3 }).map((_, i) => (
))}
); } if (!issue) { return (

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

{!onDelete && ( )}
); } const sidebarContent = (
{/* Properties */}
{propertiesOpen &&
}
{/* Parent issue */} {parentIssue && (
{parentIssueOpen &&
{parentIssue.identifier} {parentIssue.title}
}
)} {/* Details */}
{detailsOpen &&
{getActorName(issue.creator_type, issue.creator_id)} {shortDate(issue.created_at)} {shortDate(issue.updated_at)}
}
{/* Token usage */} {usage && usage.task_count > 0 && (
{tokenUsageOpen &&
{formatTokenCount(usage.total_input_tokens)} {formatTokenCount(usage.total_output_tokens)} {(usage.total_cache_read_tokens > 0 || usage.total_cache_write_tokens > 0) && ( {formatTokenCount(usage.total_cache_read_tokens)} read / {formatTokenCount(usage.total_cache_write_tokens)} write )} {usage.task_count}
}
)}
); return (
{workspace && ( <> {workspace.name} )} {parentIssue && ( <> {parentIssue.identifier} )} {issue.identifier}
{ if (isPinned) { deletePin.mutate({ itemType: "issue", itemId: issue.id }); } else { createPin.mutate({ item_type: "issue", item_id: issue.id }); } }} > {isPinned ? : } } /> {isPinned ? "Unpin from sidebar" : "Pin to sidebar"} } /> {/* 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) => !a.archived_at && 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 )} {/* Create sub-issue */} { useModalStore.getState().open("create-issue", { parent_issue_id: issue.id, parent_issue_identifier: issue.identifier, }); }}> Create sub-issue {/* Add as sub-issue of another issue */} setParentPickerOpen(true)}> Set parent issue... {/* Add another issue as sub-issue */} setChildPickerOpen(true)}> Add sub-issue... {/* Pin / Unpin */} { if (isPinned) { deletePin.mutate({ itemType: "issue", itemId: issue.id }); } else { createPin.mutate({ item_type: "issue", item_id: issue.id }); } }}> {isPinned ? : } {isPinned ? "Unpin from sidebar" : "Pin to sidebar"} {/* Copy link */} { const url = router.getShareableUrl ? router.getShareableUrl(router.pathname) : window.location.href; navigator.clipboard.writeText(url); toast.success("Link copied"); }}> Copy link {/* Delete */} setDeleteDialogOpen(true)} > Delete issue { if (isMobile) { setSidebarOpen(!sidebarOpen); } else { 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"} { localStorage.setItem("multica:backlog-agent-hint-dismissed", "true"); }} onMoveToTodo={() => { updateIssueMutation.mutate( { id, status: "todo" }, { onError: () => toast.error("Failed to update status") }, ); setBacklogHintOpen(false); }} /> {/* Set parent issue picker */} c.id)]} onSelect={(selected) => { handleUpdateField({ parent_issue_id: selected.id }); toast.success(`Set ${selected.identifier} as parent issue`); }} /> {/* Add sub-issue picker */} c.id)]} onSelect={(selected) => { updateIssueMutation.mutate( { id: selected.id, parent_issue_id: id }, { onError: () => toast.error("Failed to add sub-issue") }, ); toast.success(`Added ${selected.identifier} as sub-issue`); }} /> {/* Content — scrollable */}
{ const trimmed = value.trim(); if (trimmed && trimmed !== issue.title) handleUpdateField({ title: trimmed }); }} /> {parentIssue && ( Sub-issue of {parentIssue.identifier} {parentIssue.title} {parentChildIssues.length > 0 && (() => { const done = parentChildIssues.filter((c) => c.status === "done").length; return ( {done}/{parentChildIssues.length} ); })()} )}
handleUpdateField({ description: md })} onUploadFile={handleDescriptionUpload} debounceMs={1500} />
descEditorRef.current?.uploadFile(file)} />
{descDragOver && }
{/* Sub-issues — Linear-style */} {childIssues.length === 0 && (
)} {childIssues.length > 0 && (() => { const doneCount = childIssues.filter((c) => c.status === "done").length; return (
{/* Header */}
{doneCount}/{childIssues.length}
useModalStore.getState().open("create-issue", { parent_issue_id: issue.id, parent_issue_identifier: issue.identifier, }) } aria-label="Add sub-issue" > } /> Add sub-issue
{/* List */} {!subIssuesCollapsed && (
{childIssues.map((child) => { const isDone = child.status === "done" || child.status === "cancelled"; return ( {child.identifier} {child.title} {child.assignee_type && child.assignee_id ? ( ) : ( )} ); })}
)}
); })()}
{/* 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.filter((a) => !a.archived_at).length > 0 && ( {agents.filter((a) => !a.archived_at).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 — sticky inside the Activity section so it stays pinned while scrolling through TaskRunHistory + comments. */} {/* 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 */}
{!isMobile && } {!isMobile && ( setSidebarOpen(size.inPixels > 0)} >
{sidebarContent}
)} {isMobile && ( {sidebarContent} )} ); }