From 5ba0edd85655c04bf4f158628cf295e930d1b67c Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Fri, 20 Feb 2026 12:36:12 -0600 Subject: [PATCH] Implement threaded task comments with nested replies --- src/app/page.tsx | 8 +- src/app/tasks/[taskId]/page.tsx | 223 +++++++++++++++++++++++--------- src/lib/server/taskDb.ts | 34 ++++- src/stores/useTaskStore.ts | 81 +++++++++++- 4 files changed, 275 insertions(+), 71 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 220b35e..7075700 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -587,7 +587,7 @@ export default function Home() { OpenClaw Task Hub

- Track ideas, tasks, bugs, and plans — with threaded notes + Track ideas, tasks, bugs, and plans — with threaded comments

@@ -1155,13 +1155,13 @@ export default function Home() {

- Notes & Comments ({editedTask.comments?.length || 0}) + Comments ({editedTask.comments?.length || 0})

{/* Comment List */}
{!editedTask.comments || editedTask.comments.length === 0 ? ( -

No notes yet. Add the first one!

+

No comments yet. Add the first one.

) : ( editedTask.comments.map((comment) => (
setNewComment(e.target.value)} - placeholder="Add a note or comment..." + placeholder="Add a comment..." className="flex-1 bg-slate-800 border-slate-700 text-white placeholder-slate-500" rows={2} onKeyDown={(e) => { diff --git a/src/app/tasks/[taskId]/page.tsx b/src/app/tasks/[taskId]/page.tsx index fca01ef..3bfac50 100644 --- a/src/app/tasks/[taskId]/page.tsx +++ b/src/app/tasks/[taskId]/page.tsx @@ -7,7 +7,15 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" -import { useTaskStore, type Priority, type Task, type TaskAttachment, type TaskStatus, type TaskType } from "@/stores/useTaskStore" +import { + useTaskStore, + type Comment as TaskComment, + type Priority, + type Task, + type TaskAttachment, + type TaskStatus, + type TaskType, +} from "@/stores/useTaskStore" const typeColors: Record = { idea: "bg-purple-500", @@ -53,6 +61,60 @@ const getAttachments = (taskLike: { attachments?: unknown }) => { }) } +const getComments = (value: unknown): TaskComment[] => { + if (!Array.isArray(value)) return [] + + return value + .map((entry) => { + if (!entry || typeof entry !== "object") return null + const comment = entry as Partial + if (typeof comment.id !== "string" || typeof comment.text !== "string") return null + + return { + id: comment.id, + text: comment.text, + createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(), + author: comment.author === "assistant" ? "assistant" : "user", + replies: getComments(comment.replies), + } + }) + .filter((comment): comment is TaskComment => comment !== null) +} + +const buildComment = (text: string, author: "user" | "assistant" = "user"): TaskComment => ({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + text, + createdAt: new Date().toISOString(), + author, + replies: [], +}) + +const addReplyToThread = (comments: TaskComment[], parentId: string, reply: TaskComment): TaskComment[] => + comments.map((comment) => { + if (comment.id === parentId) { + return { + ...comment, + replies: [...getComments(comment.replies), reply], + } + } + + return { + ...comment, + replies: addReplyToThread(getComments(comment.replies), parentId, reply), + } + }) + +const removeCommentFromThread = (comments: TaskComment[], targetId: string): TaskComment[] => + comments + .filter((comment) => comment.id !== targetId) + .map((comment) => ({ + ...comment, + replies: removeCommentFromThread(getComments(comment.replies), targetId), + })) + +const countThreadComments = (comments: TaskComment[]): number => + comments.reduce((total, comment) => total + 1 + countThreadComments(getComments(comment.replies)), 0) + const toLabel = (raw: string) => raw.trim().replace(/^#/, "") const addUniqueLabel = (existing: string[], raw: string) => { @@ -101,6 +163,8 @@ export default function TaskDetailPage() { const [newComment, setNewComment] = useState("") const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("") const [isSaving, setIsSaving] = useState(false) + const [replyDrafts, setReplyDrafts] = useState>({}) + const [openReplyEditors, setOpenReplyEditors] = useState>({}) useEffect(() => { syncFromServer() @@ -111,6 +175,7 @@ export default function TaskDetailPage() { setEditedTask({ ...selectedTask, tags: getTags(selectedTask), + comments: getComments(selectedTask.comments), attachments: getAttachments(selectedTask), }) setEditedTaskLabelInput("") @@ -119,6 +184,7 @@ export default function TaskDetailPage() { const editedTaskTags = editedTask ? getTags(editedTask) : [] const editedTaskAttachments = editedTask ? getAttachments(editedTask) : [] + const commentCount = editedTask ? countThreadComments(getComments(editedTask.comments)) : 0 const allLabels = useMemo(() => { const labels = new Map() @@ -166,23 +232,41 @@ export default function TaskDetailPage() { setEditedTask({ ...editedTask, - comments: [ - ...(editedTask.comments || []), - { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - text: newComment.trim(), - createdAt: new Date().toISOString(), - author: "user", - }, - ], + comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), "user")], }) setNewComment("") } + const handleAddReply = (parentId: string) => { + if (!editedTask) return + const text = replyDrafts[parentId]?.trim() + if (!text) return + + setEditedTask({ + ...editedTask, + comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, "user")), + }) + + setReplyDrafts((prev) => ({ ...prev, [parentId]: "" })) + setOpenReplyEditors((prev) => ({ ...prev, [parentId]: false })) + } + + const handleDeleteComment = (commentId: string) => { + if (!editedTask) return + + setEditedTask({ + ...editedTask, + comments: removeCommentFromThread(getComments(editedTask.comments), commentId), + }) + } + const handleSave = () => { if (!editedTask) return setIsSaving(true) - updateTask(editedTask.id, editedTask) + updateTask(editedTask.id, { + ...editedTask, + comments: getComments(editedTask.comments), + }) setIsSaving(false) } @@ -193,6 +277,70 @@ export default function TaskDetailPage() { router.push("/") } + const renderThread = (comments: TaskComment[], depth = 0): JSX.Element[] => + comments.map((comment) => { + const replies = getComments(comment.replies) + const isReplying = !!openReplyEditors[comment.id] + const replyDraft = replyDrafts[comment.id] || "" + + return ( +
+
+
+
+ + {comment.author === "assistant" ? "AI" : "You"} + + {comment.author === "assistant" ? "Assistant" : "You"} + {new Date(comment.createdAt).toLocaleString()} +
+
+ + +
+
+

{comment.text}

+
+ + {isReplying && ( +
+