Implement threaded task comments with nested replies

This commit is contained in:
OpenClaw Bot 2026-02-20 12:36:12 -06:00
parent 6361ba1bb3
commit 5ba0edd856
4 changed files with 275 additions and 71 deletions

View File

@ -587,7 +587,7 @@ export default function Home() {
OpenClaw Task Hub
</h1>
<p className="text-xs md:text-sm text-slate-400 mt-1">
Track ideas, tasks, bugs, and plans with threaded notes
Track ideas, tasks, bugs, and plans with threaded comments
</p>
</div>
<div className="flex items-center gap-3">
@ -1155,13 +1155,13 @@ export default function Home() {
<div className="border-t border-slate-800 pt-6">
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Notes & Comments ({editedTask.comments?.length || 0})
Comments ({editedTask.comments?.length || 0})
</h4>
{/* Comment List */}
<div className="space-y-4 mb-4">
{!editedTask.comments || editedTask.comments.length === 0 ? (
<p className="text-slate-500 text-sm">No notes yet. Add the first one!</p>
<p className="text-slate-500 text-sm">No comments yet. Add the first one.</p>
) : (
editedTask.comments.map((comment) => (
<div
@ -1208,7 +1208,7 @@ export default function Home() {
<Textarea
value={newComment}
onChange={(e) => 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) => {

View File

@ -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<TaskType, string> = {
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<TaskComment>
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<Record<string, string>>({})
const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({})
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<string, number>()
@ -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 (
<div key={comment.id} className="space-y-2" style={{ marginLeft: depth * 20 }}>
<div className={`p-3 rounded-lg border ${comment.author === "assistant" ? "bg-blue-900/20 border-blue-900/40" : "bg-slate-800/60 border-slate-800"}`}>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2">
<span className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-medium ${comment.author === "assistant" ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-200"}`}>
{comment.author === "assistant" ? "AI" : "You"}
</span>
<span className="text-sm text-slate-300 font-medium">{comment.author === "assistant" ? "Assistant" : "You"}</span>
<span className="text-xs text-slate-500">{new Date(comment.createdAt).toLocaleString()}</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setOpenReplyEditors((prev) => ({ ...prev, [comment.id]: !prev[comment.id] }))}
className="text-xs text-blue-300 hover:text-blue-200"
>
Reply
</button>
<button
type="button"
onClick={() => handleDeleteComment(comment.id)}
className="text-slate-600 hover:text-red-400"
title="Delete comment"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
<p className="text-sm text-slate-200 whitespace-pre-wrap">{comment.text}</p>
</div>
{isReplying && (
<div className="flex gap-2" style={{ marginLeft: 20 }}>
<Textarea
value={replyDraft}
onChange={(event) => setReplyDrafts((prev) => ({ ...prev, [comment.id]: event.target.value }))}
placeholder="Write a reply..."
className="flex-1 bg-slate-800 border-slate-700 text-white placeholder-slate-500"
rows={2}
onKeyDown={(event) => {
if (event.key === "Enter" && event.metaKey) {
handleAddReply(comment.id)
}
}}
/>
<div className="flex flex-col gap-2 justify-end">
<Button onClick={() => handleAddReply(comment.id)} size="sm">Reply</Button>
<Button onClick={() => setOpenReplyEditors((prev) => ({ ...prev, [comment.id]: false }))} variant="ghost" size="sm">Cancel</Button>
</div>
</div>
)}
{replies.length > 0 && <div className="space-y-2">{renderThread(replies, depth + 1)}</div>}
</div>
)
})
if (!taskId) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 p-6">
@ -451,55 +599,14 @@ export default function TaskDetailPage() {
<div className="border-t border-slate-800 pt-6">
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Notes & Comments ({editedTask.comments?.length || 0})
Comments ({commentCount})
</h4>
<div className="space-y-4 mb-4">
{!editedTask.comments || editedTask.comments.length === 0 ? (
<p className="text-slate-500 text-sm">No notes yet. Add the first one!</p>
<div className="space-y-3 mb-4">
{commentCount === 0 ? (
<p className="text-slate-500 text-sm">No comments yet. Add the first one.</p>
) : (
editedTask.comments.map((comment) => (
<div
key={comment.id}
className={`flex gap-3 p-3 rounded-lg ${
comment.author === "assistant" ? "bg-blue-900/20" : "bg-slate-800/50"
}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
comment.author === "assistant"
? "bg-blue-600 text-white"
: "bg-slate-700 text-slate-300"
}`}
>
{comment.author === "assistant" ? "AI" : "You"}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-slate-300">
{comment.author === "assistant" ? "Assistant" : "You"}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">
{new Date(comment.createdAt).toLocaleString()}
</span>
<button
onClick={() =>
setEditedTask({
...editedTask,
comments: editedTask.comments.filter((entry) => entry.id !== comment.id),
})
}
className="text-slate-600 hover:text-red-400"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
<p className="text-slate-300 text-sm whitespace-pre-wrap">{comment.text}</p>
</div>
</div>
))
renderThread(getComments(editedTask.comments))
)}
</div>
@ -507,7 +614,7 @@ export default function TaskDetailPage() {
<Textarea
value={newComment}
onChange={(event) => setNewComment(event.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={(event) => {

View File

@ -11,6 +11,14 @@ export interface TaskAttachment {
uploadedAt: string;
}
export interface TaskComment {
id: string;
text: string;
createdAt: string;
author: "user" | "assistant";
replies?: TaskComment[];
}
export interface Task {
id: string;
title: string;
@ -23,7 +31,7 @@ export interface Task {
createdAt: string;
updatedAt: string;
dueDate?: string;
comments: { id: string; text: string; createdAt: string; author: "user" | "assistant" }[];
comments: TaskComment[];
tags: string[];
attachments: TaskAttachment[];
}
@ -105,6 +113,26 @@ function normalizeAttachments(attachments: unknown): TaskAttachment[] {
.filter((attachment): attachment is TaskAttachment => attachment !== null);
}
function normalizeComments(comments: unknown): TaskComment[] {
if (!Array.isArray(comments)) return [];
return comments
.map((entry) => {
if (!entry || typeof entry !== "object") return null;
const value = entry as Partial<TaskComment>;
if (typeof value.id !== "string" || typeof value.text !== "string") return null;
return {
id: value.id,
text: value.text,
createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(),
author: value.author === "assistant" ? "assistant" : "user",
replies: normalizeComments(value.replies),
};
})
.filter((comment): comment is TaskComment => comment !== null);
}
function normalizeTask(task: Partial<Task>): Task {
return {
id: String(task.id ?? Date.now()),
@ -118,7 +146,7 @@ function normalizeTask(task: Partial<Task>): Task {
createdAt: task.createdAt || new Date().toISOString(),
updatedAt: task.updatedAt || new Date().toISOString(),
dueDate: task.dueDate || undefined,
comments: Array.isArray(task.comments) ? task.comments : [],
comments: normalizeComments(task.comments),
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [],
attachments: normalizeAttachments(task.attachments),
};
@ -343,7 +371,7 @@ export function getData(): DataStore {
createdAt: task.createdAt,
updatedAt: task.updatedAt,
dueDate: task.dueDate ?? undefined,
comments: safeParseArray(task.comments, []),
comments: normalizeComments(safeParseArray(task.comments, [])),
tags: safeParseArray(task.tags, []),
attachments: normalizeAttachments(safeParseArray(task.attachments, [])),
})),

View File

@ -22,6 +22,7 @@ export interface Comment {
text: string
createdAt: string
author: 'user' | 'assistant'
replies?: Comment[]
}
export interface TaskAttachment {
@ -385,6 +386,65 @@ const defaultTasks: Task[] = [
}
]
const normalizeComments = (value: unknown): Comment[] => {
if (!Array.isArray(value)) return []
return value
.map((entry) => {
if (!entry || typeof entry !== 'object') return null
const candidate = entry as Partial<Comment>
if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string') return null
const author = candidate.author === 'assistant' ? 'assistant' : 'user'
return {
id: candidate.id,
text: candidate.text,
createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(),
author,
replies: normalizeComments(candidate.replies),
}
})
.filter((comment): comment is Comment => comment !== null)
}
const normalizeAttachments = (value: unknown): TaskAttachment[] => {
if (!Array.isArray(value)) return []
return value
.map((entry) => {
if (!entry || typeof entry !== 'object') return null
const candidate = entry as Partial<TaskAttachment>
if (typeof candidate.id !== 'string' || typeof candidate.name !== 'string' || typeof candidate.dataUrl !== 'string') {
return null
}
return {
id: candidate.id,
name: candidate.name,
type: typeof candidate.type === 'string' ? candidate.type : 'application/octet-stream',
size: typeof candidate.size === 'number' ? candidate.size : 0,
dataUrl: candidate.dataUrl,
uploadedAt: typeof candidate.uploadedAt === 'string' ? candidate.uploadedAt : new Date().toISOString(),
}
})
.filter((attachment): attachment is TaskAttachment => attachment !== null)
}
const normalizeTask = (task: Task): Task => ({
...task,
comments: normalizeComments(task.comments),
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0) : [],
attachments: normalizeAttachments(task.attachments),
})
const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] =>
comments
.filter((comment) => comment.id !== targetId)
.map((comment) => ({
...comment,
replies: removeCommentFromThread(normalizeComments(comment.replies), targetId),
}))
// Helper to sync to server
async function syncToServer(projects: Project[], tasks: Task[], sprints: Sprint[]) {
console.log('>>> syncToServer: saving', tasks.length, 'tasks,', projects.length, 'projects,', sprints.length, 'sprints')
@ -440,7 +500,7 @@ export const useTaskStore = create<TaskStore>()(
// ALWAYS use server data if API returns successfully
set({
projects: data.projects || [],
tasks: data.tasks || [],
tasks: (data.tasks || []).map((task: Task) => normalizeTask(task)),
sprints: data.sprints || [],
lastSynced: Date.now(),
})
@ -511,8 +571,8 @@ export const useTaskStore = create<TaskStore>()(
id: Date.now().toString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [],
attachments: task.attachments || [],
comments: normalizeComments([]),
attachments: normalizeAttachments(task.attachments),
}
set((state) => {
const newTasks = [...state.tasks, newTask]
@ -525,7 +585,15 @@ export const useTaskStore = create<TaskStore>()(
console.log('updateTask called:', id, updates)
set((state) => {
const newTasks = state.tasks.map((t) =>
t.id === id ? { ...t, ...updates, updatedAt: new Date().toISOString() } : t
t.id === id
? normalizeTask({
...t,
...updates,
comments: updates.comments !== undefined ? normalizeComments(updates.comments) : t.comments,
attachments: updates.attachments !== undefined ? normalizeAttachments(updates.attachments) : t.attachments,
updatedAt: new Date().toISOString(),
} as Task)
: t
)
const updatedTask = newTasks.find(t => t.id === id)
console.log('updateTask: updated task:', updatedTask)
@ -598,11 +666,12 @@ export const useTaskStore = create<TaskStore>()(
text,
createdAt: new Date().toISOString(),
author,
replies: [],
}
set((state) => {
const newTasks = state.tasks.map((t) =>
t.id === taskId
? { ...t, comments: [...t.comments, newComment], updatedAt: new Date().toISOString() }
? { ...t, comments: [...normalizeComments(t.comments), newComment], updatedAt: new Date().toISOString() }
: t
)
syncToServer(state.projects, newTasks, state.sprints)
@ -614,7 +683,7 @@ export const useTaskStore = create<TaskStore>()(
set((state) => {
const newTasks = state.tasks.map((t) =>
t.id === taskId
? { ...t, comments: t.comments.filter((c) => c.id !== commentId) }
? { ...t, comments: removeCommentFromThread(normalizeComments(t.comments), commentId), updatedAt: new Date().toISOString() }
: t
)
syncToServer(state.projects, newTasks, state.sprints)