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 id "$comment_id" \
--arg text "$text" \ --arg text "$text" \
--arg createdAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --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 local updated_task
updated_task=$(echo "$task" | jq --argjson comment "$new_comment" '.comments += [$comment]') 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 }); 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 }); 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, assigneeEmail: assigneeUser?.email,
assigneeAvatarUrl: assigneeUser?.avatarUrl, assigneeAvatarUrl: assigneeUser?.avatarUrl,
dueDate: toNonEmptyString(row.due_date), 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"), tags: (row.tags as unknown[]).filter((tag): tag is string => typeof tag === "string"),
attachments: includeFullData ? (row.attachments as unknown[] | undefined) ?? [] : [], attachments: includeFullData ? (row.attachments as unknown[] | undefined) ?? [] : [],
}; };

View File

@ -35,7 +35,7 @@ import {
markdownPreviewObjectUrl, markdownPreviewObjectUrl,
textPreviewObjectUrl, textPreviewObjectUrl,
} from "@/lib/attachments" } 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 { BacklogSkeleton, SearchSkeleton } from "@/components/LoadingSkeletons"
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive } from "lucide-react" import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
@ -422,27 +422,8 @@ export default function Home() {
}) })
} }
const getCommentAuthor = (value: unknown): CommentAuthor => { const getCommentAuthorId = (value: unknown): string | null =>
if (value === "assistant") { typeof value === "string" && value.trim().length > 0 ? value.trim() : null
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 formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B" if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"
@ -872,7 +853,12 @@ export default function Home() {
const handleAddComment = () => { const handleAddComment = () => {
if (newComment.trim() && selectedTaskId) { 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("") setNewComment("")
} }
} }
@ -1697,10 +1683,15 @@ export default function Home() {
<p className="text-slate-500 text-sm">No comments 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) => { editedTask.comments.map((comment) => {
const author = getCommentAuthor(comment.author) const authorId = comment.commentAuthorId
const isAssistant = author.type === "assistant" const isAssistant = authorId === "assistant"
const displayName = author.id === currentUser.id ? "You" : author.name const resolvedAuthor = authorId === currentUser.id ? currentUser : resolveAssignee(authorId)
const resolvedAuthorAvatar = author.avatarUrl || resolveAssignee(author.id)?.avatarUrl const displayName = isAssistant
? "Assistant"
: authorId === currentUser.id
? "You"
: resolvedAuthor?.name || "Unknown user"
const resolvedAuthorAvatar = resolvedAuthor?.avatarUrl
return ( return (
<div <div
key={comment.id} key={comment.id}
@ -1713,7 +1704,7 @@ export default function Home() {
AI AI
</div> </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-1 min-w-0">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">

View File

@ -21,7 +21,6 @@ import { parseSprintStart } from "@/lib/utils"
import { import {
useTaskStore, useTaskStore,
type Comment as TaskComment, type Comment as TaskComment,
type CommentAuthor,
type Priority, type Priority,
type Task, type Task,
type TaskAttachment, type TaskAttachment,
@ -89,13 +88,15 @@ const getComments = (value: unknown): TaskComment[] => {
for (const entry of value) { for (const entry of value) {
if (!entry || typeof entry !== "object") continue if (!entry || typeof entry !== "object") continue
const comment = entry as Partial<TaskComment> 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({ normalized.push({
id: comment.id, id: comment.id,
text: comment.text, text: comment.text,
createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(), createdAt: comment.createdAt,
author: getCommentAuthor(comment.author), commentAuthorId,
replies: getComments(comment.replies), replies: getComments(comment.replies),
}) })
} }
@ -103,41 +104,17 @@ const getComments = (value: unknown): TaskComment[] => {
return normalized return normalized
} }
const getCommentAuthor = (value: unknown): CommentAuthor => { const getCommentAuthorId = (value: unknown): string | null =>
if (value === "assistant") { typeof value === "string" && value.trim().length > 0 ? value.trim() : null
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 getCurrentUserCommentAuthorId = (profile: UserProfile): string | null =>
const type = candidate.type === "assistant" || candidate.id === "assistant" ? "assistant" : "human" getCommentAuthorId(profile.id)
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 profileToAuthor = (profile: UserProfile): CommentAuthor => ({ const buildComment = (text: string, commentAuthorId: string): TaskComment => ({
id: profile.id,
name: profile.name,
email: profile.email,
avatarUrl: profile.avatarUrl,
type: "human",
})
const buildComment = (text: string, author: CommentAuthor): TaskComment => ({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
text, text,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
author, commentAuthorId,
replies: [], replies: [],
}) })
@ -400,10 +377,14 @@ export default function TaskDetailPage() {
const handleAddComment = () => { const handleAddComment = () => {
if (!editedTask || !newComment.trim()) return 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({ setEditedTask({
...editedTask, ...editedTask,
comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), actor)], comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), commentAuthorId)],
}) })
setNewComment("") setNewComment("")
} }
@ -413,10 +394,14 @@ export default function TaskDetailPage() {
const text = replyDrafts[parentId]?.trim() const text = replyDrafts[parentId]?.trim()
if (!text) return if (!text) return
const actor = profileToAuthor(currentUser) const commentAuthorId = getCurrentUserCommentAuthorId(currentUser)
if (!commentAuthorId) {
toast.error("You must be signed in to reply.")
return
}
setEditedTask({ setEditedTask({
...editedTask, ...editedTask,
comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, actor)), comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, commentAuthorId)),
}) })
setReplyDrafts((prev) => ({ ...prev, [parentId]: "" })) setReplyDrafts((prev) => ({ ...prev, [parentId]: "" }))
@ -586,10 +571,11 @@ export default function TaskDetailPage() {
const replies = getComments(comment.replies) const replies = getComments(comment.replies)
const isReplying = !!openReplyEditors[comment.id] const isReplying = !!openReplyEditors[comment.id]
const replyDraft = replyDrafts[comment.id] || "" const replyDraft = replyDrafts[comment.id] || ""
const author = getCommentAuthor(comment.author) const authorId = comment.commentAuthorId
const isAssistant = author.type === "assistant" const isAssistant = authorId === "assistant"
const displayName = isAssistant ? "Assistant" : author.id === currentUser.id ? "You" : author.name const resolvedAuthor = authorId === currentUser.id ? currentUser : resolveAssignee(authorId)
const resolvedAuthorAvatar = author.avatarUrl || resolveAssignee(author.id)?.avatarUrl const displayName = isAssistant ? "Assistant" : authorId === currentUser.id ? "You" : resolvedAuthor?.name || "Unknown user"
const resolvedAuthorAvatar = resolvedAuthor?.avatarUrl
return ( return (
<div key={comment.id} className="space-y-2" style={{ marginLeft: depth * 20 }}> <div key={comment.id} className="space-y-2" style={{ marginLeft: depth * 20 }}>
@ -601,7 +587,7 @@ export default function TaskDetailPage() {
AI AI
</span> </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-sm text-slate-300 font-medium">{displayName}</span>
<span className="text-xs text-slate-500">{new Date(comment.createdAt).toLocaleString()}</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 id: string
text: string text: string
createdAt: string createdAt: string
author: CommentAuthor | 'user' | 'assistant' commentAuthorId: string
replies?: Comment[] replies?: Comment[]
} }
export interface CommentAuthor {
id: string
name: string
email?: string
avatarUrl?: string
type: 'human' | 'assistant'
}
export interface UserProfile { export interface UserProfile {
id: string id: string
name: string name: string
@ -121,7 +113,7 @@ interface TaskStore {
getTasksBySprint: (sprintId: string) => Task[] getTasksBySprint: (sprintId: string) => Task[]
// Comment actions // 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 deleteComment: (taskId: string, commentId: string) => void
// Filters // Filters
@ -145,57 +137,14 @@ const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCur
return { id, name, email, avatarUrl } return { id, name, email, avatarUrl }
} }
const profileToCommentAuthor = (profile: UserProfile): CommentAuthor => ({ const profileToCommentAuthor = (profile: UserProfile) => ({
id: profile.id, id: profile.id,
name: profile.name, name: profile.name,
email: profile.email,
avatarUrl: profile.avatarUrl, avatarUrl: profile.avatarUrl,
type: 'human',
}) })
const assistantAuthor: CommentAuthor = { const normalizeCommentAuthorId = (value: unknown): string | null =>
id: 'assistant', typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
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 normalizeComments = (value: unknown): Comment[] => { const normalizeComments = (value: unknown): Comment[] => {
if (!Array.isArray(value)) return [] if (!Array.isArray(value)) return []
@ -205,13 +154,15 @@ const normalizeComments = (value: unknown): Comment[] => {
for (const entry of value) { for (const entry of value) {
if (!entry || typeof entry !== 'object') continue if (!entry || typeof entry !== 'object') continue
const candidate = entry as Partial<Comment> 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({ comments.push({
id: candidate.id, id: candidate.id,
text: candidate.text, text: candidate.text,
createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(), createdAt: candidate.createdAt,
author: normalizeCommentAuthor(candidate.author), commentAuthorId,
replies: normalizeComments(candidate.replies), replies: normalizeComments(candidate.replies),
}) })
} }
@ -646,13 +597,19 @@ export const useTaskStore = create<TaskStore>()(
return get().tasks.filter((t) => t.sprintId === sprintId) return get().tasks.filter((t) => t.sprintId === sprintId)
}, },
addComment: (taskId, text, author) => { addComment: (taskId, text, commentAuthorId) => {
const actor = normalizeCommentAuthor(author ?? profileToCommentAuthor(get().currentUser)) 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 = { const newComment: Comment = {
id: Date.now().toString(), id: Date.now().toString(),
text, text,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
author: actor, commentAuthorId: normalizedCommentAuthorId,
replies: [], replies: [],
} }
set((state) => { 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