import React, { useMemo } from 'react'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { useI18n } from '@/lib/i18n'; import { formatDateTimeForPreference } from '@/lib/timeFormat'; import { useUIStore } from '@/stores/useUIStore'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import type { IconName } from '@/components/icon/icons'; import type { ForgeComment, ForgeTimelineEvent, ForgeTimelineEventType, ForgeUser } from '@/lib/forge/types'; interface ForgeTimelineSectionProps { events: ForgeTimelineEvent[]; comments: ForgeComment[]; loading?: boolean; error?: string | null; /** Optional: asked when the user hits Reply on an inline-comment thread (its root comment). */ onReply?: (comment: ForgeComment) => void; /** Optional: rendered under a thread card the parent is replying to. */ renderReply?: (comment: ForgeComment) => React.ReactNode; } const EVENT_ICONS: Record = { opened: 'git-pull-request', reopened: 'git-pull-request', closed: 'git-close-pull-request', merged: 'git-merge', committed: 'git-commit', reviewed: 'eye', approved: 'checkbox-circle', 'requested-changes': 'alert', commented: 'chat-1', referenced: 'external-link', labeled: 'pushpin', unlabeled: 'pushpin', assigned: 'user', unassigned: 'user', milestoned: 'target', demilestoned: 'target', other: 'more', }; const EVENT_COLORS: Partial> = { approved: 'var(--status-success)', 'requested-changes': 'var(--status-error)', merged: 'var(--pr-merged)', closed: 'var(--pr-closed)', }; const toTimestamp = (value?: string): number => { if (!value) return 0; const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : 0; }; const CommentAvatar: React.FC<{ author?: ForgeUser | null }> = ({ author }) => { const label = author?.name ?? author?.login ?? '?'; const initial = label.charAt(0).toUpperCase(); return (
{author?.avatarUrl ? ( {label} ) : ( {initial} )}
); }; const InlineContextChip: React.FC<{ comment: ForgeComment; label: string }> = ({ comment, label }) => { if (!comment.path) return null; const text = comment.line ? `${comment.path}:${comment.line}` : comment.path; return ( {text} ); }; type TimelineItem = | { kind: 'event'; event: ForgeTimelineEvent } | { kind: 'thread'; thread: ForgeComment[] }; /** * Chronologically merged activity timeline for a pull request or issue: event * markers interleaved with comment threads. Inline review comments are grouped * by `inReplyToId` chains or (path, line) buckets; a thread renders as one * card with its comments stacked. Pure presentation. */ export const ForgeTimelineSection = React.memo(function ForgeTimelineSection({ events, comments, loading, error, onReply, renderReply }) { const { t } = useI18n(); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const formatTime = React.useCallback((value?: string): string => { if (!value) return ''; const ts = Date.parse(value); if (!Number.isFinite(ts)) return value; return formatDateTimeForPreference(ts, timeFormatPreference, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }); }, [timeFormatPreference]); const threads = useMemo(() => { const byId = new Map(comments.map((comment) => [comment.id, comment])); const repliesByParent = new Map(); for (const comment of comments) { if (comment.inReplyToId && byId.has(comment.inReplyToId)) { const list = repliesByParent.get(comment.inReplyToId) ?? []; list.push(comment); repliesByParent.set(comment.inReplyToId, list); } } const collect = (root: ForgeComment): ForgeComment[] => { const members: ForgeComment[] = []; const visit = (comment: ForgeComment): void => { members.push(comment); for (const reply of repliesByParent.get(comment.id) ?? []) { visit(reply); } }; visit(root); return members.sort((a, b) => toTimestamp(a.createdAt) - toTimestamp(b.createdAt)); }; const roots = comments.filter((comment) => !comment.inReplyToId || !byId.has(comment.inReplyToId)); const buckets = new Map(); const standalone: ForgeComment[] = []; for (const root of roots) { if (root.path) { const key = root.line ? `${root.path}:${root.line}` : `path:${root.path}`; const list = buckets.get(key) ?? []; list.push(root); buckets.set(key, list); } else { standalone.push(root); } } const seen = new Set(); const dedupe = (members: ForgeComment[]): ForgeComment[] => members.filter((member) => { if (seen.has(member.id)) return false; seen.add(member.id); return true; }); const result: ForgeComment[][] = []; for (const root of buckets.values()) { result.push(dedupe(root.flatMap(collect))); } for (const root of standalone) { result.push(dedupe(collect(root))); } return result; }, [comments]); const items = useMemo(() => { const all: TimelineItem[] = [ ...events.map((event) => ({ kind: 'event' as const, event })), ...threads.map((thread) => ({ kind: 'thread' as const, thread })), ]; all.sort((a, b) => { const aTs = a.kind === 'event' ? toTimestamp(a.event.createdAt) : toTimestamp(a.thread[0]?.createdAt); const bTs = b.kind === 'event' ? toTimestamp(b.event.createdAt) : toTimestamp(b.thread[0]?.createdAt); return aTs - bTs; }); return all; }, [events, threads]); if (loading) { return (
); } if (error) { return (
{error}
); } if (items.length === 0) { return

{t('forge.timeline.empty')}

; } return (
{items.map((item, idx) => { const isLast = idx === items.length - 1; if (item.kind === 'event') { const { event } = item; return (
{!isLast ?
: null}
{t(`forge.timeline.event.${event.type}` as never)} {event.author ? {event.author.login} : null} {event.createdAt ? {formatTime(event.createdAt)} : null}
{event.body ? (

{event.body}

) : null}
); } const { thread } = item; const root = thread[0]; return (
{!isLast ?
: null}
{thread.map((comment, commentIdx) => (
0 ? 'border-t border-border/40 pt-3' : ''} >
{comment.author?.name ?? comment.author?.login ?? 'Unknown'} {comment.createdAt ? {formatTime(comment.createdAt)} : null}
))}
{root.path && onReply ? (
) : null} {renderReply ? renderReply(root) : null}
); })}
); });