From 1a729fa9fd609c5a9b6ac2c72f5db1d3f8c46868 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Jan 2026 14:47:55 +0000 Subject: [PATCH 1/2] feat: add table of contents for comments and headings Adds a Notion-style TOC on the left sidebar showing all comments as minimal lines that expand on hover to show avatar, name, and relative time. The active comment is highlighted with a larger line. Adds a headings TOC on the right sidebar showing the headings within the currently active comment, with proper indent based on heading level. Headings now have IDs that incorporate the comment number, e.g., "Getting Started" in comment 4 becomes "#4-getting-started". Both TOCs are desktop-only (lg breakpoint) and use IntersectionObserver to track which comment/heading is currently in view. --- .../[repo]/[postNumber]/comment-content.tsx | 47 ++++- .../[postNumber]/comment-thread-client.tsx | 110 ------------ .../[repo]/[postNumber]/comment-thread.tsx | 5 +- .../[repo]/[postNumber]/comments-toc.tsx | 93 ++++++++++ .../[repo]/[postNumber]/headings-toc.tsx | 50 ++++++ app/[owner]/[repo]/[postNumber]/page.tsx | 4 +- .../[repo]/[postNumber]/post-with-toc.tsx | 150 ++++++++++++++++ .../[repo]/[postNumber]/toc-context.tsx | 165 ++++++++++++++++++ 8 files changed, 508 insertions(+), 116 deletions(-) delete mode 100644 app/[owner]/[repo]/[postNumber]/comment-thread-client.tsx create mode 100644 app/[owner]/[repo]/[postNumber]/comments-toc.tsx create mode 100644 app/[owner]/[repo]/[postNumber]/headings-toc.tsx create mode 100644 app/[owner]/[repo]/[postNumber]/post-with-toc.tsx create mode 100644 app/[owner]/[repo]/[postNumber]/toc-context.tsx diff --git a/app/[owner]/[repo]/[postNumber]/comment-content.tsx b/app/[owner]/[repo]/[postNumber]/comment-content.tsx index 10865cc..063da5b 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,9 +31,32 @@ 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 ( @@ -456,6 +493,7 @@ function ToolGroup({ type CommentContentProps = { content: AgentUIMessage[] + commentNumber?: string isStreaming?: boolean isRetrying?: boolean onRetry?: () => void @@ -547,6 +585,7 @@ function groupParts(content: AgentUIMessage[]): GroupedPart[] { export function CommentContent({ content, + commentNumber, isStreaming = false, isRetrying = false, onRetry, @@ -554,7 +593,8 @@ export function CommentContent({ const grouped = groupParts(content) return ( -
+ +
{grouped.map((item, groupIdx) => { switch (item.type) { case "text": @@ -609,6 +649,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..15cdb74 --- /dev/null +++ b/app/[owner]/[repo]/[postNumber]/comments-toc.tsx @@ -0,0 +1,93 @@ +"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 ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + +
+ ) +} diff --git a/app/[owner]/[repo]/[postNumber]/headings-toc.tsx b/app/[owner]/[repo]/[postNumber]/headings-toc.tsx new file mode 100644 index 0000000..7f5d0e8 --- /dev/null +++ b/app/[owner]/[repo]/[postNumber]/headings-toc.tsx @@ -0,0 +1,50 @@ +"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]) +} From 0a57c4dcdab65107d2a9ee8f4363a8f793786461 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Jan 2026 14:56:06 +0000 Subject: [PATCH 2/2] fix: improve TOC positioning and styling - Position TOCs closer to content with better vertical centering for left TOC - Fix avatar squishing by wrapping in shrink-0 container - Restructure left TOC expanded view with 2-line layout (name + time) - Add scroll-mt-12 to headings so sticky header doesn't block them - Make heading prefixes (#, ##, etc.) clickable anchor links - Remove redundant hidden/lg:block classes from TOC nav elements --- .../[repo]/[postNumber]/comment-content.tsx | 17 +++- .../[repo]/[postNumber]/comments-toc.tsx | 86 ++++++++++--------- .../[repo]/[postNumber]/headings-toc.tsx | 50 ++++++----- .../[repo]/[postNumber]/post-with-toc.tsx | 18 ++-- 4 files changed, 92 insertions(+), 79 deletions(-) diff --git a/app/[owner]/[repo]/[postNumber]/comment-content.tsx b/app/[owner]/[repo]/[postNumber]/comment-content.tsx index 063da5b..3f17e2e 100644 --- a/app/[owner]/[repo]/[postNumber]/comment-content.tsx +++ b/app/[owner]/[repo]/[postNumber]/comment-content.tsx @@ -54,14 +54,23 @@ function Heading({ return ( - - {prefix} - + {headingId ? ( + + {prefix} + + ) : ( + + {prefix} + + )} {children} ) diff --git a/app/[owner]/[repo]/[postNumber]/comments-toc.tsx b/app/[owner]/[repo]/[postNumber]/comments-toc.tsx index 15cdb74..558d3f5 100644 --- a/app/[owner]/[repo]/[postNumber]/comments-toc.tsx +++ b/app/[owner]/[repo]/[postNumber]/comments-toc.tsx @@ -32,62 +32,64 @@ export function CommentsToc({ items }: CommentsTocProps) { } return ( -
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > -
) - })} - -
+ } + + return ( + + ) + })} + ) } diff --git a/app/[owner]/[repo]/[postNumber]/headings-toc.tsx b/app/[owner]/[repo]/[postNumber]/headings-toc.tsx index 7f5d0e8..58189db 100644 --- a/app/[owner]/[repo]/[postNumber]/headings-toc.tsx +++ b/app/[owner]/[repo]/[postNumber]/headings-toc.tsx @@ -19,32 +19,30 @@ export function HeadingsToc() { } return ( -
-
+ return ( + + ) + })} + ) } diff --git a/app/[owner]/[repo]/[postNumber]/post-with-toc.tsx b/app/[owner]/[repo]/[postNumber]/post-with-toc.tsx index bf349dd..edf8e67 100644 --- a/app/[owner]/[repo]/[postNumber]/post-with-toc.tsx +++ b/app/[owner]/[repo]/[postNumber]/post-with-toc.tsx @@ -108,9 +108,17 @@ function PostWithTocInner({ }, []) return ( -
-
- +
+
+
+
+ +
+
+
+ +
+
@@ -133,10 +141,6 @@ function PostWithTocInner({ rootCommentId={rootCommentId} />
- -
- -
) }