"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 (
);
}
// ---------------------------------------------------------------------------
// 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 && (
{
onUpdate({ due_date: null });
setOpen(false);
}}
className="text-muted-foreground hover:text-foreground"
>
Clear 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}
removeItem(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
))}
)}
{(criteria.length > 0 || adding) ? (
) : (
setAdding(true)}
>
+ Add acceptance criteria
)}
);
}
// ---------------------------------------------------------------------------
// 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}
)}
removeRef(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
))}
)}
{(refs.length > 0 || adding) ? (
) : (
setAdding(true)}
>
+ Add context reference
)}
);
}
// ---------------------------------------------------------------------------
// 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 ? (
{/* RIGHT: Properties sidebar */}
Properties
{getActorName(issue.creator_type, issue.creator_id)}
{shortDate(issue.created_at)}
{shortDate(issue.updated_at)}
);
}