diff --git a/app/[owner]/[repo]/[postNumber]/comment-content.tsx b/app/[owner]/[repo]/[postNumber]/comment-content.tsx index 10865cc..3f17e2e 100644 --- a/app/[owner]/[repo]/[postNumber]/comment-content.tsx +++ b/app/[owner]/[repo]/[postNumber]/comment-content.tsx @@ -2,14 +2,28 @@ import { Collapsible } from "@base-ui/react/collapsible" import type { ToolUIPart } from "ai" -import { type ComponentProps, useEffect, useState } from "react" +import { + type ComponentProps, + createContext, + useContext, + useEffect, + useState, +} from "react" +import slugify from "slugify" import { Streamdown } from "streamdown" import type { AgentUIMessage } from "@/agent/types" import { ERROR_CODES } from "@/lib/errors" import { usePostMetadata } from "./post-metadata-context" +import { useToc } from "./toc-context" const LEADING_SLASH_REGEX = /^\// +const CommentNumberContext = createContext(null) + +function useCommentNumber() { + return useContext(CommentNumberContext) +} + function Heading({ level, children, @@ -17,14 +31,46 @@ function Heading({ }: ComponentProps<"h1"> & { level: 1 | 2 | 3 | 4 | 5 | 6 }) { const Tag = `h${level}` as const const prefix = "#".repeat(level) + const commentNumber = useCommentNumber() + const { registerHeading, unregisterHeading } = useToc() + + const text = + typeof children === "string" + ? children + : Array.isArray(children) + ? children.filter((c) => typeof c === "string").join("") + : "" + + const headingId = commentNumber + ? `${commentNumber}-${slugify(text, { lower: true, strict: true })}` + : undefined + + useEffect(() => { + if (headingId && text) { + registerHeading({ id: headingId, text, level }) + return () => unregisterHeading(headingId) + } + }, [headingId, text, level, registerHeading, unregisterHeading]) + return ( - - {prefix} - + {headingId ? ( + + {prefix} + + ) : ( + + {prefix} + + )} {children} ) @@ -456,6 +502,7 @@ function ToolGroup({ type CommentContentProps = { content: AgentUIMessage[] + commentNumber?: string isStreaming?: boolean isRetrying?: boolean onRetry?: () => void @@ -547,6 +594,7 @@ function groupParts(content: AgentUIMessage[]): GroupedPart[] { export function CommentContent({ content, + commentNumber, isStreaming = false, isRetrying = false, onRetry, @@ -554,7 +602,8 @@ export function CommentContent({ const grouped = groupParts(content) return ( -
+ +
{grouped.map((item, groupIdx) => { switch (item.type) { case "text": @@ -609,6 +658,7 @@ export function CommentContent({ return null } })} -
+
+ ) } diff --git a/app/[owner]/[repo]/[postNumber]/comment-thread-client.tsx b/app/[owner]/[repo]/[postNumber]/comment-thread-client.tsx deleted file mode 100644 index ff202b2..0000000 --- a/app/[owner]/[repo]/[postNumber]/comment-thread-client.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client" - -import type { InferSelectModel } from "drizzle-orm" -import { useEffect, useMemo, useState } from "react" -import { authClient } from "@/lib/auth-client" -import type { - comments as commentsSchema, - mentions as mentionsSchema, - reactions as reactionsSchema, -} from "@/lib/db/schema" -import { CommentThread } from "./comment-thread" -import { usePostMetadata } from "./post-metadata-context" - -type Comment = InferSelectModel -type Mention = InferSelectModel -type Reaction = InferSelectModel - -type AuthorInfo = { - name: string - username: string - image: string - isLlm: boolean -} - -type AskingOption = { - id: string - name: string - image?: string | null - isDefault?: boolean -} - -export function CommentThreadClient({ - owner, - repo, - comments, - mentions, - authorsById, - reactions, - rootCommentId, - commentNumbers, - askingOptions, -}: { - owner: string - repo: string - comments: Comment[] - mentions: Mention[] - authorsById: Record - reactions: Reaction[] - rootCommentId: string | null - commentNumbers: Map - askingOptions: AskingOption[] -}) { - const [replyingToId, setReplyingToId] = useState(null) - const isSignedIn = !!authClient.useSession().data?.session - const { selectedRef, gitContext } = usePostMetadata() - const currentSha = gitContext?.sha ?? null - - // Filter comments based on selected ref (or current HEAD if none selected) - const filteredComments = useMemo(() => { - const targetRef = selectedRef ?? currentSha - return comments.filter((c) => { - const isLlm = c.authorId.startsWith("llm_") - if (!isLlm) { - // Human comments are always shown - return true - } - // Streaming LLM comments are always shown (gitRef not set yet) - if (c.streamId) { - return true - } - // Completed LLM comments: show if gitRef matches the target ref - return c.gitRef === targetRef - }) - }, [comments, selectedRef, currentSha]) - - useEffect(() => { - const hash = window.location.hash.slice(1) - if (hash) { - const el = document.getElementById(hash) - if (el) { - el.scrollIntoView({ behavior: "instant", block: "start" }) - } - } - }, []) - - return ( - { - if (isSignedIn) { - setReplyingToId(null) - } - }} - onReply={(commentId) => { - if (isSignedIn) { - setReplyingToId(commentId) - } - }} - owner={owner} - reactions={reactions} - replyingToId={replyingToId} - repo={repo} - rootCommentId={rootCommentId} - /> - ) -} diff --git a/app/[owner]/[repo]/[postNumber]/comment-thread.tsx b/app/[owner]/[repo]/[postNumber]/comment-thread.tsx index 4bcb7f3..b2ff5cd 100644 --- a/app/[owner]/[repo]/[postNumber]/comment-thread.tsx +++ b/app/[owner]/[repo]/[postNumber]/comment-thread.tsx @@ -132,7 +132,10 @@ function CommentItem({ comment.streamStatus === "streaming" ? ( ) : ( - + ) const body = ( diff --git a/app/[owner]/[repo]/[postNumber]/comments-toc.tsx b/app/[owner]/[repo]/[postNumber]/comments-toc.tsx new file mode 100644 index 0000000..558d3f5 --- /dev/null +++ b/app/[owner]/[repo]/[postNumber]/comments-toc.tsx @@ -0,0 +1,95 @@ +"use client" + +import { Suspense, useState } from "react" +import { RelativeTime } from "@/components/relative-time" +import { UserAvatar } from "@/components/user-avatar" +import type { AuthorInfo } from "./comment-thread" +import { useToc } from "./toc-context" + +type CommentTocItem = { + id: string + commentNumber: string + author: AuthorInfo + createdAt: number + isRoot: boolean +} + +type CommentsTocProps = { + items: CommentTocItem[] +} + +export function CommentsToc({ items }: CommentsTocProps) { + const [isHovered, setIsHovered] = useState(false) + const { activeCommentId } = useToc() + + if (items.length === 0) return null + + function scrollToComment(commentNumber: string) { + const el = document.getElementById(commentNumber) + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }) + } + } + + return ( + + ) +} diff --git a/app/[owner]/[repo]/[postNumber]/headings-toc.tsx b/app/[owner]/[repo]/[postNumber]/headings-toc.tsx new file mode 100644 index 0000000..58189db --- /dev/null +++ b/app/[owner]/[repo]/[postNumber]/headings-toc.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useToc } from "./toc-context" + +export function HeadingsToc() { + const { headings, activeHeadingId, activeCommentId } = useToc() + + const commentHeadings = headings.filter((h) => + h.id.startsWith(`${activeCommentId}-`) + ) + + if (commentHeadings.length === 0) return null + + function scrollToHeading(id: string) { + const el = document.getElementById(id) + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }) + } + } + + return ( + + ) +} diff --git a/app/[owner]/[repo]/[postNumber]/page.tsx b/app/[owner]/[repo]/[postNumber]/page.tsx index df08646..87966f6 100644 --- a/app/[owner]/[repo]/[postNumber]/page.tsx +++ b/app/[owner]/[repo]/[postNumber]/page.tsx @@ -18,10 +18,10 @@ import { } from "@/lib/db/schema" import { getSiteOrigin } from "@/lib/utils" import { computeCommentNumbers } from "@/lib/utils/comment-numbers" -import { CommentThreadClient } from "./comment-thread-client" import { PostComposer } from "./post-composer" import { PostHeader } from "./post-header" import { PostMetadataProvider } from "./post-metadata-context" +import { PostWithToc } from "./post-with-toc" const githubCompareSchema = z.object({ ahead_by: z.number(), @@ -342,7 +342,7 @@ export default async function PostPage({
- +type Mention = InferSelectModel +type Reaction = InferSelectModel + +type AskingOption = { + id: string + name: string + image?: string | null + isDefault?: boolean +} + +type PostWithTocProps = { + owner: string + repo: string + comments: Comment[] + mentions: Mention[] + authorsById: Record + reactions: Reaction[] + rootCommentId: string | null + commentNumbers: Map + askingOptions: AskingOption[] +} + +function PostWithTocInner({ + owner, + repo, + comments, + mentions, + authorsById, + reactions, + rootCommentId, + commentNumbers, + askingOptions, +}: PostWithTocProps) { + const [replyingToId, setReplyingToId] = useState(null) + const isSignedIn = !!authClient.useSession().data?.session + const { selectedRef, gitContext } = usePostMetadata() + const { activeCommentId } = useToc() + const currentSha = gitContext?.sha ?? null + + const filteredComments = useMemo(() => { + const targetRef = selectedRef ?? currentSha + return comments.filter((c) => { + const isLlm = c.authorId.startsWith("llm_") + if (!isLlm) return true + if (c.streamId) return true + return c.gitRef === targetRef + }) + }, [comments, selectedRef, currentSha]) + + const topLevelComments = useMemo( + () => filteredComments.filter((c) => c.threadCommentId === null), + [filteredComments] + ) + + const commentIds = useMemo( + () => + topLevelComments.map((c) => commentNumbers.get(c.id) ?? "").filter(Boolean), + [topLevelComments, commentNumbers] + ) + + useActiveCommentObserver(commentIds) + useActiveHeadingObserver(activeCommentId) + + const tocItems = useMemo( + () => + topLevelComments + .filter((c) => authorsById[c.authorId]) + .map((c) => ({ + id: c.id, + commentNumber: commentNumbers.get(c.id) ?? "?", + author: authorsById[c.authorId], + createdAt: c.createdAt, + isRoot: c.id === rootCommentId, + })), + [topLevelComments, commentNumbers, authorsById, rootCommentId] + ) + + useEffect(() => { + const hash = window.location.hash.slice(1) + if (hash) { + const el = document.getElementById(hash) + if (el) { + el.scrollIntoView({ behavior: "instant", block: "start" }) + } + } + }, []) + + return ( +
+
+
+
+ +
+
+
+ +
+
+
+ +
+ { + if (isSignedIn) setReplyingToId(null) + }} + onReply={(commentId) => { + if (isSignedIn) setReplyingToId(commentId) + }} + owner={owner} + reactions={reactions} + replyingToId={replyingToId} + repo={repo} + rootCommentId={rootCommentId} + /> +
+
+ ) +} + +export function PostWithToc(props: PostWithTocProps) { + return ( + + + + ) +} diff --git a/app/[owner]/[repo]/[postNumber]/toc-context.tsx b/app/[owner]/[repo]/[postNumber]/toc-context.tsx new file mode 100644 index 0000000..398de5d --- /dev/null +++ b/app/[owner]/[repo]/[postNumber]/toc-context.tsx @@ -0,0 +1,165 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react" + +type Heading = { + id: string + text: string + level: number +} + +type TocContextValue = { + activeCommentId: string | null + setActiveCommentId: (id: string | null) => void + activeHeadingId: string | null + setActiveHeadingId: (id: string | null) => void + headings: Heading[] + registerHeading: (heading: Heading) => void + unregisterHeading: (id: string) => void + clearHeadings: () => void +} + +const TocContext = createContext(null) + +export function TocProvider({ children }: { children: React.ReactNode }) { + const [activeCommentId, setActiveCommentId] = useState(null) + const [activeHeadingId, setActiveHeadingId] = useState(null) + const [headings, setHeadings] = useState([]) + + const registerHeading = useCallback((heading: Heading) => { + setHeadings((prev) => { + if (prev.some((h) => h.id === heading.id)) { + return prev + } + return [...prev, heading] + }) + }, []) + + const unregisterHeading = useCallback((id: string) => { + setHeadings((prev) => prev.filter((h) => h.id !== id)) + }, []) + + const clearHeadings = useCallback(() => { + setHeadings([]) + }, []) + + const value = useMemo( + () => ({ + activeCommentId, + setActiveCommentId, + activeHeadingId, + setActiveHeadingId, + headings, + registerHeading, + unregisterHeading, + clearHeadings, + }), + [ + activeCommentId, + activeHeadingId, + headings, + registerHeading, + unregisterHeading, + clearHeadings, + ] + ) + + return {children} +} + +const noopToc: TocContextValue = { + activeCommentId: null, + setActiveCommentId: () => {}, + activeHeadingId: null, + setActiveHeadingId: () => {}, + headings: [], + registerHeading: () => {}, + unregisterHeading: () => {}, + clearHeadings: () => {}, +} + +export function useToc() { + const context = useContext(TocContext) + return context ?? noopToc +} + +export function useActiveCommentObserver(commentIds: string[]) { + const { setActiveCommentId } = useToc() + + useEffect(() => { + if (commentIds.length === 0) return + + const observer = new IntersectionObserver( + (entries) => { + const visibleEntries = entries.filter((entry) => entry.isIntersecting) + if (visibleEntries.length > 0) { + const topEntry = visibleEntries.reduce((prev, curr) => + prev.boundingClientRect.top < curr.boundingClientRect.top + ? prev + : curr + ) + setActiveCommentId(topEntry.target.id) + } + }, + { + rootMargin: "-10% 0px -80% 0px", + threshold: 0, + } + ) + + for (const id of commentIds) { + const el = document.getElementById(id) + if (el) { + observer.observe(el) + } + } + + return () => observer.disconnect() + }, [commentIds, setActiveCommentId]) +} + +export function useActiveHeadingObserver(commentNumber: string | null) { + const { setActiveHeadingId } = useToc() + + useEffect(() => { + if (!commentNumber) { + setActiveHeadingId(null) + return + } + + const headingElements = document.querySelectorAll( + `[data-heading-comment="${commentNumber}"]` + ) + + if (headingElements.length === 0) return + + const observer = new IntersectionObserver( + (entries) => { + const visibleEntries = entries.filter((entry) => entry.isIntersecting) + if (visibleEntries.length > 0) { + const topEntry = visibleEntries.reduce((prev, curr) => + prev.boundingClientRect.top < curr.boundingClientRect.top + ? prev + : curr + ) + setActiveHeadingId(topEntry.target.id) + } + }, + { + rootMargin: "-10% 0px -80% 0px", + threshold: 0, + } + ) + + headingElements.forEach((el) => observer.observe(el)) + + return () => observer.disconnect() + }, [commentNumber, setActiveHeadingId]) +}