Implement threaded task comments with nested replies
This commit is contained in:
parent
6361ba1bb3
commit
5ba0edd856
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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, [])),
|
||||
})),
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user