feat: improve issue timeline UI (#225)

This commit is contained in:
Maxwell
2026-02-27 20:52:14 -08:00
committed by GitHub
parent 33f401039d
commit 3adb31e416
8 changed files with 243 additions and 64 deletions
@@ -70,15 +70,15 @@ export default async function IssueDetailPage({
const issueNumber = parseInt(numStr, 10);
const hdrs = await headers();
const [issue, rawComments, repoData, crossRefs, timelineEvents, currentUser, session] =
const [issue, rawComments, repoData, crossRefs, currentUser, session, timelineEvents] =
await Promise.all([
getIssue(owner, repo, issueNumber),
getIssueComments(owner, repo, issueNumber),
getRepo(owner, repo),
getCrossReferences(owner, repo, issueNumber),
getIssueTimelineEvents(owner, repo, issueNumber),
getAuthenticatedUser(),
auth.api.getSession({ headers: hdrs }),
getIssueTimelineEvents(owner, repo, issueNumber),
]);
const comments = rawComments as IssueComment[];
@@ -235,10 +235,6 @@ export default async function IssueDetailPage({
currentUserLogin={currentUserLogin}
viewerHasWriteAccess={viewerHasWriteAccess}
timelineEvents={timelineEvents}
issueStateReason={
(issue as { state_reason?: string | null })
.state_reason
}
/>
}
commentForm={
@@ -7,6 +7,7 @@ import { Pencil, X, Loader2, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { TimeAgo } from "@/components/ui/time-ago";
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
import { MarkdownMentionTooltips } from "@/components/shared/markdown-mention-tooltips";
import { CollapsibleBody } from "@/components/issue/collapsible-body";
import { ReactionDisplay, type Reactions } from "@/components/shared/reaction-display";
import { UserTooltip } from "@/components/shared/user-tooltip";
@@ -46,10 +47,12 @@ export function EditableIssueDescription({
const renderedBody = entry.bodyHtml ? (
<MarkdownCopyHandler>
<div
className="ghmd"
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
/>
<MarkdownMentionTooltips>
<div
className="ghmd"
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
/>
</MarkdownMentionTooltips>
</MarkdownCopyHandler>
) : null;
@@ -1,5 +1,6 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchIssueComments } from "@/app/(app)/repos/[owner]/[repo]/issues/issue-actions";
import {
@@ -34,6 +35,70 @@ function toEntries(comments: IssueComment[]): IssueCommentEntry[] {
}));
}
type TimelineSegment =
| { type: "entries"; entries: IssueTimelineEntry[] }
| { type: "events"; events: IssueTimelineEvent[] };
function interleaveTimelineAndComments(
descriptionEntry: IssueDescriptionEntry,
comments: IssueComment[],
timelineEvents: IssueTimelineEvent[] = [],
): TimelineSegment[] {
const commentEntries = toEntries(comments);
const allItems: Array<
| { kind: "entry"; item: IssueTimelineEntry; date: Date }
| { kind: "event"; item: IssueTimelineEvent; date: Date }
> = [
{
kind: "entry",
item: descriptionEntry,
date: new Date(descriptionEntry.created_at),
},
...commentEntries.map((entry) => ({
kind: "entry" as const,
item: entry,
date: new Date(entry.created_at),
})),
...timelineEvents.map((event) => ({
kind: "event" as const,
item: event,
date: new Date(event.created_at),
})),
];
allItems.sort((a, b) => a.date.getTime() - b.date.getTime());
const segments: TimelineSegment[] = [];
let currentEntries: IssueTimelineEntry[] = [];
let currentEvents: IssueTimelineEvent[] = [];
for (const item of allItems) {
if (item.kind === "entry") {
if (currentEvents.length > 0) {
segments.push({ type: "events", events: currentEvents });
currentEvents = [];
}
currentEntries.push(item.item);
} else {
if (currentEntries.length > 0) {
segments.push({ type: "entries", entries: currentEntries });
currentEntries = [];
}
currentEvents.push(item.item);
}
}
if (currentEntries.length > 0) {
segments.push({ type: "entries", entries: currentEntries });
}
if (currentEvents.length > 0) {
segments.push({ type: "events", events: currentEvents });
}
return segments;
}
export function IssueCommentsClient({
owner,
repo,
@@ -45,7 +110,6 @@ export function IssueCommentsClient({
currentUserLogin,
viewerHasWriteAccess,
timelineEvents,
issueStateReason,
}: {
owner: string;
repo: string;
@@ -57,7 +121,6 @@ export function IssueCommentsClient({
currentUserLogin?: string;
viewerHasWriteAccess?: boolean;
timelineEvents?: IssueTimelineEvent[];
issueStateReason?: string | null;
}) {
const { data: comments = initialComments } = useQuery({
queryKey: ["issue-comments", owner, repo, issueNumber],
@@ -68,28 +131,38 @@ export function IssueCommentsClient({
gcTime: 10 * 60 * 1000,
});
const entries: IssueTimelineEntry[] = [descriptionEntry, ...toEntries(comments)];
const segments = useMemo(
() => interleaveTimelineAndComments(descriptionEntry, comments, timelineEvents),
[descriptionEntry, comments, timelineEvents],
);
return (
<>
<IssueConversation
entries={entries}
owner={owner}
repo={repo}
issueNumber={issueNumber}
canEdit={canEdit}
issueTitle={issueTitle}
currentUserLogin={currentUserLogin}
viewerHasWriteAccess={viewerHasWriteAccess}
/>
{timelineEvents && timelineEvents.length > 0 && (
<IssueTimelineEvents
events={timelineEvents}
owner={owner}
repo={repo}
issueStateReason={issueStateReason}
/>
)}
{segments.map((segment, idx) => {
const isLastSegment = idx === segments.length - 1;
return segment.type === "entries" ? (
<IssueConversation
key={`entries-${idx}`}
entries={segment.entries}
owner={owner}
repo={repo}
issueNumber={issueNumber}
canEdit={canEdit}
issueTitle={issueTitle}
currentUserLogin={currentUserLogin}
viewerHasWriteAccess={viewerHasWriteAccess}
hasMoreAfter={!isLastSegment}
/>
) : (
<IssueTimelineEvents
key={`events-${idx}`}
events={segment.events}
owner={owner}
repo={repo}
isLastSegment={isLastSegment}
/>
);
})}
</>
);
}
@@ -3,6 +3,7 @@ import Image from "next/image";
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
import { EditableIssueDescription } from "@/components/issue/editable-issue-description";
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
import { MarkdownMentionTooltips } from "@/components/shared/markdown-mention-tooltips";
import { cn } from "@/lib/utils";
import { TimeAgo } from "@/components/ui/time-ago";
import { BotActivityGroup } from "@/components/pr/bot-activity-group";
@@ -114,6 +115,7 @@ export function IssueConversation({
issueTitle,
currentUserLogin,
viewerHasWriteAccess,
hasMoreAfter = false,
}: {
entries: IssueTimelineEntry[];
owner: string;
@@ -123,13 +125,15 @@ export function IssueConversation({
issueTitle?: string;
currentUserLogin?: string;
viewerHasWriteAccess?: boolean;
hasMoreAfter?: boolean;
}) {
const grouped = groupEntries(entries);
const showLine = grouped.length > 1 || hasMoreAfter;
return (
<div className="relative">
{/* Timeline connector line */}
{grouped.length > 1 && (
{showLine && (
<div className="absolute left-[19px] top-10 bottom-4 w-px bg-border/50" />
)}
@@ -314,10 +318,12 @@ function ThreadEntry({
const renderedBody = entry.bodyHtml ? (
<MarkdownCopyHandler>
<ReactiveCodeBlocks>
<div
className="ghmd"
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
/>
<MarkdownMentionTooltips>
<div
className="ghmd"
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
/>
</MarkdownMentionTooltips>
</ReactiveCodeBlocks>
</MarkdownCopyHandler>
) : null;
@@ -565,10 +571,12 @@ function ThreadComment({
const renderedBody = entry.bodyHtml ? (
<MarkdownCopyHandler>
<ReactiveCodeBlocks>
<div
className="ghmd ghmd-sm"
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
/>
<MarkdownMentionTooltips>
<div
className="ghmd ghmd-sm"
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
/>
</MarkdownMentionTooltips>
</ReactiveCodeBlocks>
</MarkdownCopyHandler>
) : null;
@@ -3,7 +3,6 @@ import Image from "next/image";
import { CircleDot, CheckCircle2, GitPullRequest, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
import { TimeAgo } from "@/components/ui/time-ago";
import { CopyLinkButton } from "@/components/shared/copy-link-button";
import { PinButton } from "@/components/shared/pin-button";
import type { CrossReference } from "@/lib/github";
import { UserTooltip } from "@/components/shared/user-tooltip";
@@ -90,12 +89,6 @@ export function IssueHeader({
<span className="text-[11px] text-muted-foreground/50 font-mono">
{commentsCount} comment{commentsCount !== 1 ? "s" : ""}
</span>
<CopyLinkButton
owner={owner}
repo={repo}
number={number}
type="issues"
/>
<PinButton
owner={owner}
repo={repo}
@@ -10,14 +10,14 @@ interface IssueTimelineEventsProps {
events: IssueTimelineEvent[];
owner: string;
repo: string;
issueStateReason?: string | null;
isLastSegment?: boolean;
}
export function IssueTimelineEvents({
events,
owner,
repo,
issueStateReason,
isLastSegment = false,
}: IssueTimelineEventsProps) {
if (events.length === 0) return null;
@@ -26,17 +26,26 @@ export function IssueTimelineEvents({
.map((e) => e.source!);
return (
<div className="space-y-1 py-3 ml-16">
{events.map((event) => (
<TimelineEventItem
key={event.id}
event={event}
owner={owner}
repo={repo}
linkedPRs={crossRefPRs}
issueStateReason={issueStateReason}
/>
))}
<div className="relative py-1 -mt-2 mb-2">
{/* Timeline connector line - extends beyond bounds to connect with adjacent segments */}
<div
className={cn(
"absolute left-[19px] -top-5 w-px bg-border/50",
isLastSegment ? "bottom-0" : "-bottom-4",
)}
/>
<div className="space-y-1 pl-[52px] py-2">
{events.map((event) => (
<TimelineEventItem
key={event.id}
event={event}
owner={owner}
repo={repo}
linkedPRs={crossRefPRs}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { UserTooltip } from "@/components/shared/user-tooltip";
interface MentionData {
element: HTMLAnchorElement;
username: string;
wrapper: HTMLSpanElement;
}
function MentionTooltipPortal({ mention }: { mention: MentionData }) {
const [children, setChildren] = useState<React.ReactNode>(null);
useEffect(() => {
setChildren(
<span dangerouslySetInnerHTML={{ __html: mention.element.innerHTML }} />,
);
}, [mention.element]);
if (!children) return null;
return createPortal(
<UserTooltip username={mention.username} side="top">
<a
href={mention.element.href}
className={mention.element.className}
onClick={(e) => {
e.stopPropagation();
}}
>
{children}
</a>
</UserTooltip>,
mention.wrapper,
);
}
export function MarkdownMentionTooltips({ children }: { children: React.ReactNode }) {
const containerRef = useRef<HTMLDivElement>(null);
const [mentions, setMentions] = useState<MentionData[]>([]);
const mentionsRef = useRef<MentionData[]>([]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const mentionLinks =
container.querySelectorAll<HTMLAnchorElement>("a.ghmd-mention");
if (mentionLinks.length === 0) return;
const newMentions: MentionData[] = [];
for (const link of mentionLinks) {
const href = link.getAttribute("href") || "";
const usernameMatch = href.match(/\/users\/([^/]+)/);
if (!usernameMatch) continue;
const username = usernameMatch[1];
const wrapper = document.createElement("span");
wrapper.style.display = "inline";
link.parentNode?.insertBefore(wrapper, link);
link.style.display = "none";
newMentions.push({
element: link,
username,
wrapper,
});
}
mentionsRef.current = newMentions;
setMentions(newMentions);
return () => {
for (const mention of mentionsRef.current) {
mention.element.style.display = "";
mention.wrapper.remove();
}
mentionsRef.current = [];
};
}, []);
return (
<div ref={containerRef}>
{children}
{mentions.map((mention, idx) => (
<MentionTooltipPortal key={idx} mention={mention} />
))}
</div>
);
}
@@ -60,6 +60,7 @@ import { highlightCode } from "@/lib/shiki";
import { toInternalUrl } from "@/lib/github-utils";
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
import { MarkdownMentionTooltips } from "@/components/shared/markdown-mention-tooltips";
interface RepoContext {
owner: string;
@@ -500,10 +501,12 @@ export async function MarkdownRenderer({
return (
<MarkdownCopyHandler>
<ReactiveCodeBlocks>
<div
className={`ghmd ${className || ""}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
<MarkdownMentionTooltips>
<div
className={`ghmd ${className || ""}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
</MarkdownMentionTooltips>
</ReactiveCodeBlocks>
</MarkdownCopyHandler>
);