import { Circle, CircleCheck, CircleDashed, CircleDot, CircleX, MessageSquare, Tag, type LucideIcon, User, } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { useIsManagedAgent } from "@/features/agent-memory/hooks"; import { ForumComposer } from "@/features/forum/ui/ForumComposer"; import { type ProjectIssue, type Repository as Project, useCreateProjectIssueCommentMutation, useProjectIssuesQuery, } from "@/features/projects/hooks"; import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; import { entityDiscussionQuery } from "@/features/projects/lib/discussionChannels"; import { selectionItemFromTask } from "@/features/projects/lib/projectSelection"; import { issueShareLink } from "@/features/projects/lib/projectShareLinks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import { projectTaskCategoryLabel, projectTaskUserLabels, } from "@/features/projects/projectTaskCategories"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ChannelMember } from "@/shared/api/types"; import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { IssueAssigneeFacepile, IssueAssigneesRow } from "./IssueAssigneesRow"; import { DiscussedInChannels } from "./DiscussionChannels"; import { ProjectIssueCommentTimeline } from "./ProjectIssueCommentTimeline"; import { ProjectOriginReference } from "./ProjectOriginReference"; import { ProjectDetailMetaList, ProjectDetailMetaPills, ProjectDetailMetaRow, } from "./ProjectDetailMeta"; import { ProjectDetailSection } from "./ProjectDetailSection"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; import { ProjectRichContent } from "./ProjectRichContent"; import { ShareLinkButton } from "./ShareLinkButton"; import { PROJECT_DETAIL_READING_COLUMN_CLASS } from "./projectPanelStyles"; import { ProjectStatusProgressIcon, type ProjectStatusProgressState, } from "./ProjectStatusProgressIcon"; import { ProjectWorkItemGroup } from "./ProjectWorkItemGroup"; import { ProjectWorkItemRow } from "./ProjectWorkItemRow"; export function issueStatusClassName(status: ProjectIssue["status"]) { if (status === "Triage" || status === "In Progress") return "text-amber-500"; if (status === "Backlog") return "text-muted-foreground"; if (status === "In Review") return "text-green-500"; if (status === "Done") return "text-purple-400"; if (status === "Closed") return "text-destructive"; return "text-muted-foreground"; } function issueStatusVisual(status: ProjectIssue["status"]): { className: string; icon: LucideIcon; progress: ProjectStatusProgressState; } { if (status === "Done") { return { className: "text-purple-400", icon: CircleCheck, progress: "completed", }; } if (status === "Closed") { return { className: "text-destructive", icon: CircleX, progress: "canceled", }; } if (status === "Backlog") { return { className: issueStatusClassName(status), icon: Circle, progress: "queued", }; } if (status === "Triage") { return { className: issueStatusClassName(status), icon: CircleDashed, progress: "queued", }; } return { className: issueStatusClassName(status), icon: CircleDot, progress: status === "In Review" ? "review" : "started", }; } const ISSUE_STATUS_ORDER: readonly ProjectIssue["status"][] = [ "In Review", "In Progress", "Triage", "Backlog", "Done", "Closed", ]; function issueMembers( project: Project, issue: ProjectIssue, profiles?: UserProfileLookup, ): ChannelMember[] { return [ ...new Set([ project.owner, issue.author, ...project.contributors, ...issue.recipients, ]), ].map((pubkey) => { const profile = profiles?.[normalizePubkey(pubkey)]; return { pubkey, role: "member" as const, isAgent: profile?.isAgent === true, joinedAt: new Date(0).toISOString(), displayName: profile?.displayName?.trim() || profile?.nip05Handle?.trim() || null, }; }); } function issueSelectionItem(project: Project, issue: ProjectIssue) { return selectionItemFromTask({ author: issue.author, channelId: issue.channelId ?? project.channelId, id: issue.id, shareLink: issueShareLink(issue), title: issue.title, }); } function IssueRow({ issue, onOpen, profiles, project, rangeItems, }: { issue: ProjectIssue; onOpen: () => void; profiles?: UserProfileLookup; project: Project; rangeItems: ReturnType[]; }) { const authorProfile = profiles?.[normalizePubkey(issue.author)]; const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author }); const status = issueStatusVisual(issue.status); return ( } testId="project-issue-row" title={issue.title} trailing={ <> {projectTaskCategoryLabel(issue.category)} {issue.assignees.length > 0 ? ( ) : ( )} {relativeTime(issue.createdAt)} } /> ); } /** Full issue conversation and comment composer. */ export function ProjectIssueDetail({ issue, profiles, project, }: { issue: ProjectIssue; profiles?: UserProfileLookup; project: Project; }) { const commentMutation = useCreateProjectIssueCommentMutation(project); const members = React.useMemo( () => issueMembers(project, issue, profiles), [issue, profiles, project], ); const handleCommentSubmit = React.useCallback( async ( content: string, mentionPubkeys: string[], mediaTags?: string[][], ) => { try { await commentMutation.mutateAsync({ content, issue, mediaTags, mentionPubkeys, }); toast.success("Comment posted."); } catch (error) { toast.error( error instanceof Error ? error.message : "Failed to post comment.", ); throw error; } }, [commentMutation, issue], ); const identityQuery = useIdentityQuery(); const status = issueStatusVisual(issue.status); const labels = projectTaskUserLabels(issue.labels); const viewerPubkey = identityQuery.data?.pubkey; const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null; const isAuthor = viewer === normalizePubkey(issue.author); const isOwner = viewer === normalizePubkey(project.owner); const isManagedAgentOwner = useIsManagedAgent(project.owner) === true; const canAssignOthers = Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner); return (

{issue.title}{" "} #{issue.id.slice(0, 8)}

Task created {relativeTime(issue.createdAt)}

{issue.status} {projectTaskCategoryLabel(issue.category)} {issue.assignees.length > 0 || viewer ? ( ) : null} {labels.length > 0 ? ( ) : null} {issue.content ? ( ) : null}
); } export function ProjectIssuesPanel({ onSelectedIssueIdChange, profiles, project, selectedIssueId, }: { onSelectedIssueIdChange: (id: string | null) => void; profiles?: UserProfileLookup; project: Project; selectedIssueId: string | null; }) { const issuesQuery = useProjectIssuesQuery(project); const issues = issuesQuery.data ?? []; const selectedIssue = issues.find((issue) => issue.id === selectedIssueId) ?? null; if (issuesQuery.isLoading) { return ; } if (issues.length === 0) { return (

{issuesQuery.error ? "Could not load tasks for this repository." : "No tasks yet."}

); } if (selectedIssue) { return ( ); } const groups = ISSUE_STATUS_ORDER.map((status) => ({ items: issues.filter((issue) => issue.status === status), status, })).filter((group) => group.items.length > 0); const rangeItems = issues.map((issue) => issueSelectionItem(project, issue)); return (
{groups.map(({ items, status }) => { const visual = issueStatusVisual(status); return ( } items={items.map((issue) => issueSelectionItem(project, issue))} key={status} label={status} > {items.map((issue) => ( onSelectedIssueIdChange(issue.id)} profiles={profiles} project={project} rangeItems={rangeItems} /> ))} ); })}
); }