updated comments

Signed-off-by: Max <ai-agent@topdoglabs.com>
This commit is contained in:
Max 2026-02-23 16:44:17 -06:00
parent 92adbef66e
commit 8aaca14e3a
7 changed files with 1269 additions and 136 deletions

View File

@ -215,7 +215,7 @@ cmd_task_comment() {
--arg id "$comment_id" \
--arg text "$text" \
--arg createdAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{id: $id, text: $text, createdAt: $createdAt, author: "assistant"}')
'{id: $id, text: $text, createdAt: $createdAt, commentAuthorId: "assistant", replies: []}')
local updated_task
updated_task=$(echo "$task" | jq --argjson comment "$new_comment" '.comments += [$comment]')

View File

@ -231,7 +231,7 @@ function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserPro
throw new HttpError(500, "Invalid tasks.tags value in database", { taskId: row.id, value: row.tags });
}
if (includeFullData && row.comments !== undefined && !Array.isArray(row.comments)) {
if (row.comments !== undefined && !Array.isArray(row.comments)) {
throw new HttpError(500, "Invalid tasks.comments value in database", { taskId: row.id, value: row.comments });
}
@ -261,7 +261,7 @@ function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserPro
assigneeEmail: assigneeUser?.email,
assigneeAvatarUrl: assigneeUser?.avatarUrl,
dueDate: toNonEmptyString(row.due_date),
comments: includeFullData ? (row.comments as unknown[] | undefined) ?? [] : [],
comments: (row.comments as unknown[] | undefined) ?? [],
tags: (row.tags as unknown[]).filter((tag): tag is string => typeof tag === "string"),
attachments: includeFullData ? (row.attachments as unknown[] | undefined) ?? [] : [],
};

View File

@ -35,7 +35,7 @@ import {
markdownPreviewObjectUrl,
textPreviewObjectUrl,
} from "@/lib/attachments"
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore"
import { BacklogSkeleton, SearchSkeleton } from "@/components/LoadingSkeletons"
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive } from "lucide-react"
import { toast } from "sonner"
@ -422,27 +422,8 @@ export default function Home() {
})
}
const getCommentAuthor = (value: unknown): CommentAuthor => {
if (value === "assistant") {
return { id: "assistant", name: "Assistant", type: "assistant" }
}
if (value === "user") {
return { id: "legacy-user", name: "User", type: "human" }
}
if (!value || typeof value !== "object") {
return { id: "legacy-user", name: "User", type: "human" }
}
const candidate = value as Partial<CommentAuthor>
const type = candidate.type === "assistant" || candidate.id === "assistant" ? "assistant" : "human"
return {
id: typeof candidate.id === "string" && candidate.id ? candidate.id : type === "assistant" ? "assistant" : "legacy-user",
name: typeof candidate.name === "string" && candidate.name ? candidate.name : type === "assistant" ? "Assistant" : "User",
email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined,
avatarUrl: typeof candidate.avatarUrl === "string" && candidate.avatarUrl ? candidate.avatarUrl : undefined,
type,
}
}
const getCommentAuthorId = (value: unknown): string | null =>
typeof value === "string" && value.trim().length > 0 ? value.trim() : null
const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"
@ -872,7 +853,12 @@ export default function Home() {
const handleAddComment = () => {
if (newComment.trim() && selectedTaskId) {
addComment(selectedTaskId, newComment.trim())
const commentAuthorId = getCommentAuthorId(currentUser.id)
if (!commentAuthorId) {
toast.error("You must be signed in to add a comment.")
return
}
addComment(selectedTaskId, newComment.trim(), commentAuthorId)
setNewComment("")
}
}
@ -1697,10 +1683,15 @@ export default function Home() {
<p className="text-slate-500 text-sm">No comments yet. Add the first one.</p>
) : (
editedTask.comments.map((comment) => {
const author = getCommentAuthor(comment.author)
const isAssistant = author.type === "assistant"
const displayName = author.id === currentUser.id ? "You" : author.name
const resolvedAuthorAvatar = author.avatarUrl || resolveAssignee(author.id)?.avatarUrl
const authorId = comment.commentAuthorId
const isAssistant = authorId === "assistant"
const resolvedAuthor = authorId === currentUser.id ? currentUser : resolveAssignee(authorId)
const displayName = isAssistant
? "Assistant"
: authorId === currentUser.id
? "You"
: resolvedAuthor?.name || "Unknown user"
const resolvedAuthorAvatar = resolvedAuthor?.avatarUrl
return (
<div
key={comment.id}
@ -1713,7 +1704,7 @@ export default function Home() {
AI
</div>
) : (
<AvatarCircle name={displayName} avatarUrl={resolvedAuthorAvatar} seed={author.id} sizeClass="h-8 w-8" />
<AvatarCircle name={displayName} avatarUrl={resolvedAuthorAvatar} seed={authorId} sizeClass="h-8 w-8" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">

View File

@ -21,7 +21,6 @@ import { parseSprintStart } from "@/lib/utils"
import {
useTaskStore,
type Comment as TaskComment,
type CommentAuthor,
type Priority,
type Task,
type TaskAttachment,
@ -89,13 +88,15 @@ const getComments = (value: unknown): TaskComment[] => {
for (const entry of value) {
if (!entry || typeof entry !== "object") continue
const comment = entry as Partial<TaskComment>
if (typeof comment.id !== "string" || typeof comment.text !== "string") continue
if (typeof comment.id !== "string" || typeof comment.text !== "string" || typeof comment.createdAt !== "string") continue
const commentAuthorId = getCommentAuthorId(comment.commentAuthorId)
if (!commentAuthorId) continue
normalized.push({
id: comment.id,
text: comment.text,
createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(),
author: getCommentAuthor(comment.author),
createdAt: comment.createdAt,
commentAuthorId,
replies: getComments(comment.replies),
})
}
@ -103,41 +104,17 @@ const getComments = (value: unknown): TaskComment[] => {
return normalized
}
const getCommentAuthor = (value: unknown): CommentAuthor => {
if (value === "assistant") {
return { id: "assistant", name: "Assistant", type: "assistant" }
}
if (value === "user") {
return { id: "legacy-user", name: "User", type: "human" }
}
if (!value || typeof value !== "object") {
return { id: "legacy-user", name: "User", type: "human" }
}
const getCommentAuthorId = (value: unknown): string | null =>
typeof value === "string" && value.trim().length > 0 ? value.trim() : null
const candidate = value as Partial<CommentAuthor>
const type = candidate.type === "assistant" || candidate.id === "assistant" ? "assistant" : "human"
return {
id: typeof candidate.id === "string" && candidate.id ? candidate.id : type === "assistant" ? "assistant" : "legacy-user",
name: typeof candidate.name === "string" && candidate.name ? candidate.name : type === "assistant" ? "Assistant" : "User",
email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined,
avatarUrl: typeof candidate.avatarUrl === "string" && candidate.avatarUrl ? candidate.avatarUrl : undefined,
type,
}
}
const getCurrentUserCommentAuthorId = (profile: UserProfile): string | null =>
getCommentAuthorId(profile.id)
const profileToAuthor = (profile: UserProfile): CommentAuthor => ({
id: profile.id,
name: profile.name,
email: profile.email,
avatarUrl: profile.avatarUrl,
type: "human",
})
const buildComment = (text: string, author: CommentAuthor): TaskComment => ({
const buildComment = (text: string, commentAuthorId: string): TaskComment => ({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
text,
createdAt: new Date().toISOString(),
author,
commentAuthorId,
replies: [],
})
@ -400,10 +377,14 @@ export default function TaskDetailPage() {
const handleAddComment = () => {
if (!editedTask || !newComment.trim()) return
const actor = profileToAuthor(currentUser)
const commentAuthorId = getCurrentUserCommentAuthorId(currentUser)
if (!commentAuthorId) {
toast.error("You must be signed in to add a comment.")
return
}
setEditedTask({
...editedTask,
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), actor)],
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), commentAuthorId)],
})
setNewComment("")
}
@ -413,10 +394,14 @@ export default function TaskDetailPage() {
const text = replyDrafts[parentId]?.trim()
if (!text) return
const actor = profileToAuthor(currentUser)
const commentAuthorId = getCurrentUserCommentAuthorId(currentUser)
if (!commentAuthorId) {
toast.error("You must be signed in to reply.")
return
}
setEditedTask({
...editedTask,
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, actor)),
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, commentAuthorId)),
})
setReplyDrafts((prev) => ({ ...prev, [parentId]: "" }))
@ -586,10 +571,11 @@ export default function TaskDetailPage() {
const replies = getComments(comment.replies)
const isReplying = !!openReplyEditors[comment.id]
const replyDraft = replyDrafts[comment.id] || ""
const author = getCommentAuthor(comment.author)
const isAssistant = author.type === "assistant"
const displayName = isAssistant ? "Assistant" : author.id === currentUser.id ? "You" : author.name
const resolvedAuthorAvatar = author.avatarUrl || resolveAssignee(author.id)?.avatarUrl
const authorId = comment.commentAuthorId
const isAssistant = authorId === "assistant"
const resolvedAuthor = authorId === currentUser.id ? currentUser : resolveAssignee(authorId)
const displayName = isAssistant ? "Assistant" : authorId === currentUser.id ? "You" : resolvedAuthor?.name || "Unknown user"
const resolvedAuthorAvatar = resolvedAuthor?.avatarUrl
return (
<div key={comment.id} className="space-y-2" style={{ marginLeft: depth * 20 }}>
@ -601,7 +587,7 @@ export default function TaskDetailPage() {
AI
</span>
) : (
<AvatarCircle name={displayName} avatarUrl={resolvedAuthorAvatar} seed={author.id} />
<AvatarCircle name={displayName} avatarUrl={resolvedAuthorAvatar} seed={authorId} />
)}
<span className="text-sm text-slate-300 font-medium">{displayName}</span>
<span className="text-xs text-slate-500">{new Date(comment.createdAt).toLocaleString()}</span>

View File

@ -22,18 +22,10 @@ export interface Comment {
id: string
text: string
createdAt: string
author: CommentAuthor | 'user' | 'assistant'
commentAuthorId: string
replies?: Comment[]
}
export interface CommentAuthor {
id: string
name: string
email?: string
avatarUrl?: string
type: 'human' | 'assistant'
}
export interface UserProfile {
id: string
name: string
@ -121,7 +113,7 @@ interface TaskStore {
getTasksBySprint: (sprintId: string) => Task[]
// Comment actions
addComment: (taskId: string, text: string, author?: CommentAuthor | 'user' | 'assistant') => void
addComment: (taskId: string, text: string, commentAuthorId: string) => void
deleteComment: (taskId: string, commentId: string) => void
// Filters
@ -145,57 +137,14 @@ const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCur
return { id, name, email, avatarUrl }
}
const profileToCommentAuthor = (profile: UserProfile): CommentAuthor => ({
const profileToCommentAuthor = (profile: UserProfile) => ({
id: profile.id,
name: profile.name,
email: profile.email,
avatarUrl: profile.avatarUrl,
type: 'human',
})
const assistantAuthor: CommentAuthor = {
id: 'assistant',
name: 'Assistant',
type: 'assistant',
}
const normalizeCommentAuthor = (value: unknown): CommentAuthor => {
if (value === 'assistant') return assistantAuthor
if (value === 'user') {
return {
id: 'legacy-user',
name: 'User',
type: 'human',
}
}
if (!value || typeof value !== 'object') {
return {
id: 'legacy-user',
name: 'User',
type: 'human',
}
}
const candidate = value as Partial<CommentAuthor>
const type: CommentAuthor['type'] =
candidate.type === 'assistant' || candidate.id === 'assistant' ? 'assistant' : 'human'
const id = typeof candidate.id === 'string' && candidate.id.trim().length > 0
? candidate.id
: type === 'assistant'
? 'assistant'
: 'legacy-user'
const name = typeof candidate.name === 'string' && candidate.name.trim().length > 0
? candidate.name.trim()
: type === 'assistant'
? 'Assistant'
: 'User'
const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined
const avatarUrl = typeof candidate.avatarUrl === 'string' && candidate.avatarUrl.trim().length > 0 ? candidate.avatarUrl : undefined
return { id, name, email, avatarUrl, type }
}
const normalizeCommentAuthorId = (value: unknown): string | null =>
typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
const normalizeComments = (value: unknown): Comment[] => {
if (!Array.isArray(value)) return []
@ -205,13 +154,15 @@ const normalizeComments = (value: unknown): Comment[] => {
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue
const candidate = entry as Partial<Comment>
if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string') continue
if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string' || typeof candidate.createdAt !== 'string') continue
const commentAuthorId = normalizeCommentAuthorId(candidate.commentAuthorId)
if (!commentAuthorId) continue
comments.push({
id: candidate.id,
text: candidate.text,
createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(),
author: normalizeCommentAuthor(candidate.author),
createdAt: candidate.createdAt,
commentAuthorId,
replies: normalizeComments(candidate.replies),
})
}
@ -646,13 +597,19 @@ export const useTaskStore = create<TaskStore>()(
return get().tasks.filter((t) => t.sprintId === sprintId)
},
addComment: (taskId, text, author) => {
const actor = normalizeCommentAuthor(author ?? profileToCommentAuthor(get().currentUser))
addComment: (taskId, text, commentAuthorId) => {
const normalizedCommentAuthorId = normalizeCommentAuthorId(commentAuthorId)
if (!normalizedCommentAuthorId) {
const message = 'Failed to add comment: invalid comment author id'
console.error(message, { taskId, commentAuthorId })
set({ syncError: message })
return
}
const newComment: Comment = {
id: Date.now().toString(),
text,
createdAt: new Date().toISOString(),
author: actor,
commentAuthorId: normalizedCommentAuthorId,
replies: [],
}
set((state) => {

1177
supabase/tasks.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long