diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 4ad2f0d8..a03fc852 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -184,7 +184,7 @@ function CommentCard({ collectReplies(entry.id); return ( - + {/* Parent comment */}
( -
+
+
Promise; +} + +function CommentInput({ onSubmit }: CommentInputProps) { + const editorRef = useRef(null); + const [isEmpty, setIsEmpty] = useState(true); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async () => { + const content = editorRef.current?.getMarkdown()?.trim(); + if (!content || submitting) return; + setSubmitting(true); + try { + await onSubmit(content); + editorRef.current?.clearContent(); + setIsEmpty(true); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ setIsEmpty(!md.trim())} + onSubmit={handleSubmit} + debounceMs={100} + /> +
+
+ +
+
+ ); +} + +export { CommentInput }; diff --git a/apps/web/features/issues/components/index.ts b/apps/web/features/issues/components/index.ts index 5dec79a1..c95fe21e 100644 --- a/apps/web/features/issues/components/index.ts +++ b/apps/web/features/issues/components/index.ts @@ -3,3 +3,6 @@ export { PriorityIcon } from "./priority-icon"; export { StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers"; export { IssueDetail } from "./issue-detail"; export { IssuesPage } from "./issues-page"; +export { CommentCard } from "./comment-card"; +export { CommentInput } from "./comment-input"; +export { ReplyInput } from "./reply-input"; diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index a0ba6936..f4bc29d0 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -1,20 +1,17 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { - ArrowUp, Bot, Calendar, ChevronLeft, ChevronRight, Link2, - MessageSquare, MoreHorizontal, PanelRight, - Pencil, Trash2, UserMinus, Users, @@ -48,8 +45,7 @@ import { } from "@/components/ui/dropdown-menu"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; import { Input } from "@/components/ui/input"; -import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; -import { Markdown } from "@/components/markdown"; +import { RichTextEditor } from "@/components/common/rich-text-editor"; import { Tooltip, TooltipTrigger, @@ -63,6 +59,8 @@ 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"; @@ -185,20 +183,13 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { const [timeline, setTimeline] = useState([]); const [subscribers, setSubscribers] = useState([]); const [loading, setLoading] = useState(true); - const [commentEmpty, setCommentEmpty] = useState(true); - const commentEditorRef = useRef(null); - const replyEditorRef = useRef(null); 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 [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [propertiesOpen, setPropertiesOpen] = useState(true); const [detailsOpen, setDetailsOpen] = useState(true); - const [replyingTo, setReplyingTo] = useState(null); - const [replyEmpty, setReplyEmpty] = 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)); @@ -224,9 +215,8 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { .finally(() => setLoading(false)); }, [id]); - const handleSubmitComment = async () => { - const content = commentEditorRef.current?.getMarkdown()?.trim(); - if (!content || submitting || !user) return; + const handleSubmitComment = async (content: string) => { + if (!content.trim() || submitting || !user) return; const tempId = "temp-" + Date.now(); const tempEntry: TimelineEntry = { type: "comment", @@ -240,8 +230,6 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { comment_type: "comment", }; setTimeline((prev) => [...prev, tempEntry]); - commentEditorRef.current?.clearContent(); - setCommentEmpty(true); setSubmitting(true); try { const comment = await api.createComment(id, content); @@ -254,20 +242,39 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { } }; - const handleSubmitReply = async (parentId: string) => { - const md = replyEditorRef.current?.getMarkdown()?.trim(); - if (!md || !user) return; + const handleSubmitReply = async (parentId: string, content: string) => { + if (!content.trim() || !user) return; try { - const comment = await api.createComment(id, md, "comment", parentId); - setTimeline((prev) => [...prev, commentToTimelineEntry(comment)]); - replyEditorRef.current?.clearContent(); - setReplyingTo(null); - setReplyEmpty(true); + 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) => + prev.filter((e) => e.id !== commentId && e.parent_id !== commentId) + ); + } catch { + toast.error("Failed to delete comment"); + } + }; + const handleUpdateField = useCallback( (updates: Partial) => { if (!issue) return; @@ -294,33 +301,6 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { } }; - const startEditComment = (entry: TimelineEntry) => { - setEditingCommentId(entry.id); - setEditContent(entry.content ?? ""); - }; - - const handleSaveEditComment = async () => { - if (!editingCommentId || !editContent.trim()) return; - try { - const updated = await api.updateComment(editingCommentId, editContent.trim()); - setTimeline((prev) => prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e))); - setEditingCommentId(null); - } catch { - toast.error("Failed to update comment"); - } - }; - - const handleDeleteComment = async (commentId: string) => { - try { - await api.deleteComment(commentId); - setTimeline((prev) => - prev.filter((e) => e.id !== commentId && e.parent_id !== commentId) - ); - } catch { - toast.error("Failed to delete comment"); - } - }; - // Subscriber state const isSubscribed = subscribers.some( (s) => s.user_type === "member" && s.user_id === user?.id @@ -829,7 +809,7 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
{/* Timeline entries */} -
+
{(() => { // Separate top-level entries from replies const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id); @@ -864,229 +844,25 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
); } - - // Comment entry - const replies = repliesByParent.get(entry.id) ?? []; - const isOwn = entry.actor_type === "member" && entry.actor_id === user?.id; return ( -
-
- - - {getActorName(entry.actor_type, entry.actor_id)} - - - - {timeAgo(entry.created_at)} - - } - /> - - {new Date(entry.created_at).toLocaleString()} - - -
- - setReplyingTo(replyingTo === entry.id ? null : entry.id)} - className="text-muted-foreground hover:text-foreground" - > - - - } - /> - Reply - - {isOwn && ( - <> - - startEditComment(entry)} - className="text-muted-foreground hover:text-foreground" - > - - - } - /> - Edit - - - handleDeleteComment(entry.id)} - className="text-muted-foreground hover:text-destructive" - > - - - } - /> - Delete - - - )} -
-
- {editingCommentId === entry.id ? ( -
{ e.preventDefault(); handleSaveEditComment(); }} className="mt-2 pl-9.5"> - setEditContent(e.target.value)} - aria-label="Edit comment" - className="w-full text-sm bg-transparent border-b outline-none" - onKeyDown={(e) => { if (e.key === "Escape") setEditingCommentId(null); }} - /> -
- ) : ( -
- {entry.content ?? ""} -
- )} - - {/* Replies */} - {replies.length > 0 && ( -
- {replies.map((reply) => { - const isReplyOwn = reply.actor_type === "member" && reply.actor_id === user?.id; - return ( -
-
- - - {getActorName(reply.actor_type, reply.actor_id)} - - - {timeAgo(reply.created_at)} - - {isReplyOwn && ( -
- - startEditComment(reply)} - className="text-muted-foreground hover:text-foreground" - > - - - } - /> - Edit - - - handleDeleteComment(reply.id)} - className="text-muted-foreground hover:text-destructive" - > - - - } - /> - Delete - -
- )} -
- {editingCommentId === reply.id ? ( -
{ e.preventDefault(); handleSaveEditComment(); }} className="mt-1 pl-7.5"> - setEditContent(e.target.value)} - aria-label="Edit comment" - className="w-full text-sm bg-transparent border-b outline-none" - onKeyDown={(e) => { if (e.key === "Escape") setEditingCommentId(null); }} - /> -
- ) : ( -
- {reply.content ?? ""} -
- )} -
- ); - })} -
- )} - - {/* Reply input */} - {replyingTo === entry.id && ( -
-
- setReplyEmpty(!md.trim())} - onSubmit={() => handleSubmitReply(entry.id)} - debounceMs={100} - /> -
-
- - -
-
- )} -
+ ); }); })()}
- {/* Comment input */} -
-
- setCommentEmpty(!md.trim())} - onSubmit={handleSubmitComment} - debounceMs={100} - /> -
-
- - - - - } - /> - Send - -
+ {/* Bottom comment input — no avatar, full width */} +
+