From 0afff4972838e1ec1eb33c7e640a9f6e1b8ba358 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:06:18 +0800 Subject: [PATCH] feat(web): enhance issue detail page with comment editing, deletion, and real-time updates - Add inline comment editing and delete functionality - Wire up WebSocket events for comment create/update/delete - Expand issue property editing (due date, acceptance criteria, context refs) - Add real-time issue updates via WebSocket Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(dashboard)/issues/[id]/page.test.tsx | 26 + apps/web/app/(dashboard)/issues/[id]/page.tsx | 734 ++++++++++++------ 2 files changed, 526 insertions(+), 234 deletions(-) diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index e2c13db9..7344ff45 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -50,16 +50,42 @@ vi.mock("../../../../lib/auth-context", () => ({ }), })); +// Mock ws-context +vi.mock("../../../../lib/ws-context", () => ({ + useWSEvent: () => {}, +})); + +// Mock @multica/ui calendar (react-day-picker needs browser APIs) +vi.mock("@multica/ui/components/ui/calendar", () => ({ + Calendar: () => null, +})); + +// Mock tab-store +vi.mock("../../../../lib/tab-store", () => ({ + useTabStore: () => ({ + updateTabTitle: vi.fn(), + activeTabId: "tab-1", + }), +})); + // Mock api const mockGetIssue = vi.hoisted(() => vi.fn()); const mockListComments = vi.hoisted(() => vi.fn()); const mockCreateComment = vi.hoisted(() => vi.fn()); +const mockUpdateComment = vi.hoisted(() => vi.fn()); +const mockDeleteComment = vi.hoisted(() => vi.fn()); +const mockDeleteIssue = vi.hoisted(() => vi.fn()); +const mockUpdateIssue = vi.hoisted(() => vi.fn()); vi.mock("../../../../lib/api", () => ({ api: { getIssue: (...args: any[]) => mockGetIssue(...args), listComments: (...args: any[]) => mockListComments(...args), createComment: (...args: any[]) => mockCreateComment(...args), + updateComment: (...args: any[]) => mockUpdateComment(...args), + deleteComment: (...args: any[]) => mockDeleteComment(...args), + deleteIssue: (...args: any[]) => mockDeleteIssue(...args), + updateIssue: (...args: any[]) => mockUpdateIssue(...args), }, })); diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx index a10f1267..c6789ed1 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx @@ -1,19 +1,43 @@ "use client"; -import { use, useState, useEffect, useRef } from "react"; +import { use, useState, useEffect, useCallback } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { Bot, ChevronRight, + GitBranch, + Link2, + Pencil, Send, - UserCircle, + Trash2, X, } from "lucide-react"; -import type { Issue, Comment, IssueAssigneeType } from "@multica/types"; -import { STATUS_CONFIG, PRIORITY_CONFIG } from "../_data/config"; -import { StatusIcon, PriorityIcon } from "../page"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@multica/ui/components/ui/alert-dialog"; +import { Calendar } from "@multica/ui/components/ui/calendar"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@multica/ui/components/ui/popover"; +import type { Issue, Comment, UpdateIssueRequest } from "@multica/types"; +import { StatusPicker, PriorityPicker, AssigneePicker } from "../_components"; import { api } from "../../../../lib/api"; import { useAuth } from "../../../../lib/auth-context"; +import { useWSEvent } from "../../../../lib/ws-context"; +import { useTabStore } from "../../../../lib/tab-store"; +import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types"; // --------------------------------------------------------------------------- // Helpers @@ -81,19 +105,12 @@ function ActorAvatar({ function PropRow({ label, children, - onClick, }: { label: string; children: React.ReactNode; - onClick?: () => void; }) { return ( -
+
{label}
{children} @@ -103,150 +120,285 @@ function PropRow({ } // --------------------------------------------------------------------------- -// Assignee Picker +// Due Date Picker // --------------------------------------------------------------------------- -function AssigneePicker({ - issue, - onSelect, - onClose, +function DueDatePicker({ + dueDate, + onUpdate, }: { - issue: Issue; - onSelect: (type: IssueAssigneeType | null, id: string | null) => void; - onClose: () => void; + dueDate: string | null; + onUpdate: (updates: Partial) => void; }) { - const { members, agents } = useAuth(); - const [search, setSearch] = useState(""); - const ref = useRef(null); - const inputRef = useRef(null); - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - onClose(); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [onClose]); - - const q = search.toLowerCase(); - const filteredMembers = members.filter((m) => - m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q), - ); - const filteredAgents = agents.filter((a) => - a.name.toLowerCase().includes(q), - ); - - const isSelected = (type: string, id: string) => - issue.assignee_type === type && issue.assignee_id === id; + const [open, setOpen] = useState(false); + const date = dueDate ? new Date(dueDate) : undefined; + const isOverdue = date ? date < new Date() : false; return ( -
-
- setSearch(e.target.value)} - placeholder="Search..." - className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring" + + + {date ? ( + + {date.toLocaleDateString("en-US", { month: "short", day: "numeric" })} + + ) : ( + None + )} + + + { + onUpdate({ due_date: d ? d.toISOString() : null }); + setOpen(false); + }} /> -
- -
- {/* Unassign option */} - {issue.assignee_id && ( - <> + {date && ( +
-
- - )} - - {/* Members */} - {filteredMembers.length > 0 && ( - <> -
- Members -
- {filteredMembers.map((m) => ( - - ))} - - )} - - {/* Agents */} - {filteredAgents.length > 0 && ( - <> -
- Agents -
- {filteredAgents.map((a) => ( - - ))} - - )} - - {filteredMembers.length === 0 && filteredAgents.length === 0 && ( -
- No results found
)} + + + ); +} + +// --------------------------------------------------------------------------- +// 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) }); + }; + + if (criteria.length === 0 && !newItem) { + return null; + } + + return ( +
+

Acceptance Criteria

+
+ {criteria.map((item, i) => ( +
+ + {item} + +
+ ))}
+
{ e.preventDefault(); addItem(); }} + className="flex items-center gap-2" + > + setNewItem(e.target.value)} + placeholder="Add criteria..." + className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground" + /> +
); } +// --------------------------------------------------------------------------- +// 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) }); + }; + + if (refs.length === 0 && !newRef) { + return null; + } + + const isUrl = (s: string) => s.startsWith("http://") || s.startsWith("https://"); + + return ( +
+

Context References

+
+ {refs.map((ref, i) => ( +
+ + {isUrl(ref) ? ( + + {ref} + + ) : ( + {ref} + )} + +
+ ))} +
+
{ e.preventDefault(); addRef(); }} + className="flex items-center gap-2" + > + setNewRef(e.target.value)} + placeholder="Add reference URL..." + className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground" + /> +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Repository Editor +// --------------------------------------------------------------------------- + +function RepositoryEditor({ + repository, + onUpdate, +}: { + repository: { url: string; branch?: string; path?: string } | null; + onUpdate: (updates: Partial) => void; +}) { + const [open, setOpen] = useState(false); + const [url, setUrl] = useState(""); + const [branch, setBranch] = useState(""); + const [path, setPath] = useState(""); + + const handleOpen = (v: boolean) => { + if (v) { + setUrl(repository?.url ?? ""); + setBranch(repository?.branch ?? ""); + setPath(repository?.path ?? ""); + } + setOpen(v); + }; + + const save = () => { + if (!url.trim()) { + onUpdate({ repository: null }); + } else { + onUpdate({ + repository: { + url: url.trim(), + branch: branch.trim() || undefined, + path: path.trim() || undefined, + }, + }); + } + setOpen(false); + }; + + const clear = () => { + onUpdate({ repository: null }); + setOpen(false); + }; + + return ( + + + {repository ? ( + <> + + {repository.branch ?? "main"} + + ) : ( + None + )} + + +
Repository
+
+ setUrl(e.target.value)} + placeholder="https://github.com/org/repo" + className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring" + autoFocus + /> + setBranch(e.target.value)} + placeholder="Branch" + className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring" + /> + setPath(e.target.value)} + placeholder="Path" + className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring" + /> +
+
+ {repository && ( + + )} + +
+
+
+ ); +} + // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- @@ -257,13 +409,17 @@ export default function IssueDetailPage({ params: Promise<{ id: string }>; }) { const { id } = use(params); - const { getActorName } = useAuth(); + const router = useRouter(); + const { user, getActorName } = useAuth(); + const { updateTabTitle, activeTabId, closeTabByPath } = useTabStore(); const [issue, setIssue] = useState(null); const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); const [commentText, setCommentText] = useState(""); const [submitting, setSubmitting] = useState(false); - const [showAssigneePicker, setShowAssigneePicker] = useState(false); + const [deleting, setDeleting] = useState(false); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editContent, setEditContent] = useState(""); useEffect(() => { setIssue(null); @@ -278,6 +434,13 @@ export default function IssueDetailPage({ .finally(() => setLoading(false)); }, [id]); + // Sync tab title with loaded issue title + useEffect(() => { + if (issue?.title && activeTabId) { + updateTabTitle(activeTabId, issue.title); + } + }, [issue?.title, activeTabId, updateTabTitle]); + const handleSubmitComment = async (e: React.FormEvent) => { e.preventDefault(); if (!commentText.trim() || submitting) return; @@ -293,31 +456,92 @@ export default function IssueDetailPage({ } }; - const handleAssigneeChange = async ( - type: IssueAssigneeType | null, - assigneeId: string | null, - ) => { - if (!issue) return; - setShowAssigneePicker(false); - // Optimistic update - setIssue({ - ...issue, - assignee_type: type, - assignee_id: assigneeId, - }); - try { - const updated = await api.updateIssue(id, { - assignee_type: type, - assignee_id: assigneeId, + 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"); }); - setIssue(updated); - } catch (err) { - console.error("Failed to update assignee:", err); - // Revert on error - setIssue(issue); + }, + [issue, id], + ); + + const handleDelete = async () => { + setDeleting(true); + try { + await api.deleteIssue(issue!.id); + toast.success("Issue deleted"); + closeTabByPath(`/issues/${id}`); + 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 (
@@ -334,25 +558,47 @@ export default function IssueDetailPage({ ); } - const statusCfg = STATUS_CONFIG[issue.status]; - const priorityCfg = PRIORITY_CONFIG[issue.priority]; - const isOverdue = - issue.due_date && new Date(issue.due_date) < new Date() && issue.status !== "done"; - return (
{/* LEFT: Content area */}
{/* Header bar */} -
- - Issues - - - {issue.id.slice(0, 8)} +
+
+ + 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 */} @@ -369,6 +615,19 @@ export default function IssueDetailPage({
)} + {(issue.acceptance_criteria.length > 0 || issue.context_refs.length > 0) && ( +
+ + +
+ )} +
{/* Activity / Comments */} @@ -376,26 +635,57 @@ export default function IssueDetailPage({

Activity

- {comments.map((comment) => ( -
-
- - - {getActorName(comment.author_type, comment.author_id)} - - - {timeAgo(comment.created_at)} - + {comments.map((comment) => { + const isOwn = comment.author_type === "member" && comment.author_id === user?.id; + return ( +
+
+ + + {getActorName(comment.author_type, comment.author_id)} + + + {timeAgo(comment.created_at)} + + {isOwn && ( +
+ + +
+ )} +
+ {editingCommentId === comment.id ? ( +
{ e.preventDefault(); handleSaveEditComment(); }} className="mt-2 pl-[38px]"> + setEditContent(e.target.value)} + className="w-full text-[13px] bg-transparent border-b outline-none" + onKeyDown={(e) => { if (e.key === "Escape") setEditingCommentId(null); }} + /> +
+ ) : ( +
+ {comment.content} +
+ )}
-
- {comment.content} -
-
- ))} + ); + })}
{/* Comment input */} @@ -430,51 +720,27 @@ export default function IssueDetailPage({
- - {statusCfg.label} + - - {priorityCfg.label} + -
- setShowAssigneePicker(!showAssigneePicker)} - > - {issue.assignee_type && issue.assignee_id ? ( - <> - - {getActorName(issue.assignee_type, issue.assignee_id)} - - ) : ( - Unassigned - )} - - - {showAssigneePicker && ( - setShowAssigneePicker(false)} - /> - )} -
+ + + - {issue.due_date ? ( - - {shortDate(issue.due_date)} - - ) : ( - None - )} + + + + +