diff --git a/.gitignore b/.gitignore
index 539bf374..b5e156be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,6 +42,7 @@ apps/pi-extension/review-core.ts
# Claude Code session-local runtime state (lock files, scheduled-task state).
# Machine-specific; never belongs in the repo.
.claude/
+.serena/
*.ntvs*
*.njsproj
*.sln
diff --git a/packages/ui/components/BlockRenderer.tsx b/packages/ui/components/BlockRenderer.tsx
index c4bd74e7..65fc2207 100644
--- a/packages/ui/components/BlockRenderer.tsx
+++ b/packages/ui/components/BlockRenderer.tsx
@@ -7,6 +7,7 @@ import { HtmlBlock } from "./blocks/HtmlBlock";
import { Callout } from "./blocks/Callout";
import { AlertBlock } from "./blocks/AlertBlock";
import { TableBlock } from "./blocks/TableBlock";
+import { getHeadingAnchorAliases } from "../utils/anchors";
export const BlockRenderer: React.FC<{
block: Block;
@@ -18,7 +19,8 @@ export const BlockRenderer: React.FC<{
orderedIndex?: number | null;
githubRepo?: string;
headingAnchorId?: string;
-}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId }) => {
+ onNavigateAnchor?: (hash: string) => void;
+}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId, onNavigateAnchor }) => {
switch (block.type) {
case 'heading': {
const Tag = `h${block.level || 1}` as React.ElementType;
@@ -27,7 +29,25 @@ export const BlockRenderer: React.FC<{
2: 'text-xl font-semibold mb-3 mt-8 text-foreground/90',
3: 'text-base font-semibold mb-2 mt-6 text-foreground/80',
}[block.level || 1] || 'text-base font-semibold mb-2 mt-4';
- return ;
+ const anchorAliases = [
+ headingAnchorId,
+ ...getHeadingAnchorAliases(block.content),
+ ].filter(Boolean);
+ const anchorAliasTokens = anchorAliases.length > 0
+ ? [...new Set(anchorAliases)].join(' ')
+ : undefined;
+
+ return (
+
+
+
+ );
}
case 'blockquote': {
@@ -41,6 +61,7 @@ export const BlockRenderer: React.FC<{
imageBaseDir={imageBaseDir}
onImageClick={onImageClick}
githubRepo={githubRepo}
+ onNavigateAnchor={onNavigateAnchor}
/>
);
}
@@ -54,7 +75,7 @@ export const BlockRenderer: React.FC<{
>
{paragraphs.map((para, i) => (
0 ? 'mt-2' : ''}>
-
+
))}
@@ -83,7 +104,7 @@ export const BlockRenderer: React.FC<{
onToggle={isInteractive ? () => onToggleCheckbox!(block.id, !isChecked) : undefined}
/>
-
+
);
@@ -100,6 +121,7 @@ export const BlockRenderer: React.FC<{
onImageClick={onImageClick}
onOpenLinkedDoc={onOpenLinkedDoc}
githubRepo={githubRepo}
+ onNavigateAnchor={onNavigateAnchor}
/>
);
@@ -123,6 +145,7 @@ export const BlockRenderer: React.FC<{
imageBaseDir={imageBaseDir}
onImageClick={onImageClick}
githubRepo={githubRepo}
+ onNavigateAnchor={onNavigateAnchor}
/>
);
}
@@ -133,7 +156,7 @@ export const BlockRenderer: React.FC<{
className="mb-4 leading-relaxed text-foreground/90 text-[15px]"
data-block-id={block.id}
>
-
+
);
}
diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx
index 797ff3e3..d76e992e 100644
--- a/packages/ui/components/InlineMarkdown.tsx
+++ b/packages/ui/components/InlineMarkdown.tsx
@@ -85,10 +85,11 @@ function emitPlainTextWithBareUrls(
export const InlineMarkdown: React.FC<{
text: string;
onOpenLinkedDoc?: (path: string) => void;
+ onNavigateAnchor?: (hash: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
-}> = ({ text, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo }) => {
+}> = ({ text, onOpenLinkedDoc, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo }) => {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
@@ -179,8 +180,9 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
+ onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
-/>
+ />
,
);
remaining = remaining.slice(match[0].length);
@@ -199,8 +201,9 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
- githubRepo={githubRepo}
- />
+ onNavigateAnchor={onNavigateAnchor}
+ githubRepo={githubRepo}
+ />
,
);
@@ -219,8 +222,9 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
+ onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
-/>
+ />
,
);
remaining = remaining.slice(match[0].length);
@@ -238,8 +242,9 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
+ onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
-/>
+ />
,
);
remaining = remaining.slice(match[0].length);
@@ -258,8 +263,9 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
+ onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
-/>
+ />
,
);
remaining = remaining.slice(match[0].length);
@@ -500,8 +506,23 @@ export const InlineMarkdown: React.FC<{
!linkUrl.startsWith("http://") &&
!linkUrl.startsWith("https://");
const linkedDocPath = isLocalDoc ? linkUrl.replace(/#.*$/, '') : linkUrl;
+ const isInPageAnchor = safeLinkUrl.startsWith('#');
- if (isLocalDoc && onOpenLinkedDoc) {
+ if (isInPageAnchor) {
+ parts.push(
+ {
+ e.preventDefault();
+ onNavigateAnchor(safeLinkUrl);
+ } : undefined}
+ className="text-primary underline underline-offset-2 hover:text-primary/80"
+ >
+ {linkText}
+ ,
+ );
+ } else if (isLocalDoc && onOpenLinkedDoc) {
parts.push(
,
);
diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx
index 601feef4..63e7ce1f 100644
--- a/packages/ui/components/Viewer.tsx
+++ b/packages/ui/components/Viewer.tsx
@@ -45,6 +45,7 @@ import { PinpointOverlay } from './PinpointOverlay';
import { usePinpoint } from '../hooks/usePinpoint';
import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
import { useScrollViewport } from '../hooks/useScrollViewport';
+import { decodeAnchorHash } from '../utils/anchors';
interface ViewerProps {
blocks: Block[];
@@ -163,6 +164,7 @@ export const Viewer = forwardRef(({
}, ref) => {
const [copied, setCopied] = useState(false);
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
+ const [locationHash, setLocationHash] = useState(() => window.location.hash);
const globalCommentButtonRef = useRef(null);
const handleCopyPlan = async () => {
@@ -200,6 +202,7 @@ export const Viewer = forwardRef(({
} | null>(null);
const hoverTimeoutRef = useRef(null);
const stickySentinelRef = useRef(null);
+ const lastAutoScrolledHashRef = useRef(null);
const [isStuck, setIsStuck] = useState(false);
// Shared annotation infrastructure via hook
@@ -294,6 +297,61 @@ export const Viewer = forwardRef(({
return () => observer.disconnect();
}, [stickyActions, stickyScrollViewport]);
+ useEffect(() => {
+ const handleHashChange = () => {
+ lastAutoScrolledHashRef.current = null;
+ setLocationHash(window.location.hash);
+ };
+
+ window.addEventListener('hashchange', handleHashChange);
+ return () => window.removeEventListener('hashchange', handleHashChange);
+ }, []);
+
+ const scrollToAnchor = useCallback((hash: string) => {
+ const anchor = decodeAnchorHash(hash);
+ if (!anchor) return false;
+
+ const container = containerRef.current;
+ if (!container || !stickyScrollViewport) return false;
+
+ const targetById = document.getElementById(anchor);
+ const target = (targetById && container.contains(targetById))
+ ? targetById
+ : Array.from(container.querySelectorAll('[data-anchor-aliases]')).find(element =>
+ element.dataset.anchorAliases?.split(/\s+/).includes(anchor)
+ );
+
+ if (!target) return false;
+
+ const stickyActionsEl = container.querySelector('[data-sticky-actions]');
+ const stickyTop = stickyActionsEl
+ ? Number.parseFloat(window.getComputedStyle(stickyActionsEl).top || '0') || 0
+ : 0;
+ const headerOffset = stickyActionsEl
+ ? stickyActionsEl.getBoundingClientRect().height + stickyTop
+ : 0;
+ const containerRect = stickyScrollViewport.getBoundingClientRect();
+ const targetRect = target.getBoundingClientRect();
+ const relativeTop = targetRect.top - containerRect.top;
+ const offsetPosition = stickyScrollViewport.scrollTop + relativeTop - headerOffset;
+
+ stickyScrollViewport.scrollTo({
+ top: Math.max(0, offsetPosition),
+ behavior: 'smooth',
+ });
+ return true;
+ }, [stickyScrollViewport]);
+
+ useEffect(() => {
+ if (!stickyScrollViewport || !locationHash || lastAutoScrolledHashRef.current === locationHash) return;
+ const timer = window.setTimeout(() => {
+ if (scrollToAnchor(locationHash)) {
+ lastAutoScrolledHashRef.current = locationHash;
+ }
+ }, 0);
+ return () => window.clearTimeout(timer);
+ }, [blocks, locationHash, scrollToAnchor, stickyScrollViewport]);
+
// Cmd+C / Ctrl+C keyboard shortcut for copying selected text
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
@@ -563,6 +621,7 @@ export const Viewer = forwardRef(({
checkboxOverrides={checkboxOverrides}
githubRepo={repoInfo?.display}
headingAnchorId={headingSlugMap.get(block.id)}
+ onNavigateAnchor={scrollToAnchor}
/>
))}
@@ -580,6 +639,7 @@ export const Viewer = forwardRef(({
onImageClick={(src, alt) => setLightbox({ src, alt })}
onOpenLinkedDoc={onOpenLinkedDoc}
githubRepo={repoInfo?.display}
+ onNavigateAnchor={scrollToAnchor}
onHover={(element) => {
if (tableHoverTimeoutRef.current) {
clearTimeout(tableHoverTimeoutRef.current);
@@ -631,7 +691,7 @@ export const Viewer = forwardRef(({
isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id}
/>
) : (
- setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
+ setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
)
)}
@@ -728,6 +788,7 @@ export const Viewer = forwardRef(({
onImageClick={(src, alt) => setLightbox({ src, alt })}
onOpenLinkedDoc={onOpenLinkedDoc}
githubRepo={repoInfo?.display}
+ onNavigateAnchor={scrollToAnchor}
/>
)}
@@ -855,5 +916,3 @@ function groupBlocks(blocks: Block[]): RenderGroup[] {
}
-
-
diff --git a/packages/ui/components/blocks/AlertBlock.tsx b/packages/ui/components/blocks/AlertBlock.tsx
index acf1a0ce..f035c82f 100644
--- a/packages/ui/components/blocks/AlertBlock.tsx
+++ b/packages/ui/components/blocks/AlertBlock.tsx
@@ -10,6 +10,7 @@ interface AlertBlockProps {
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
+ onNavigateAnchor?: (hash: string) => void;
}
const TITLE: Record = {
@@ -37,7 +38,7 @@ const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
};
export const AlertBlock: React.FC = ({
- blockId, kind, body, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo,
+ blockId, kind, body, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo, onNavigateAnchor,
}) => {
return (
= ({
{TITLE[kind]}
- {renderProseBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, githubRepo })}
+ {renderProseBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, onNavigateAnchor, githubRepo })}
);
};
diff --git a/packages/ui/components/blocks/Callout.tsx b/packages/ui/components/blocks/Callout.tsx
index 93d5c14e..4ec712bf 100644
--- a/packages/ui/components/blocks/Callout.tsx
+++ b/packages/ui/components/blocks/Callout.tsx
@@ -12,6 +12,7 @@ interface CalloutProps {
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
+ onNavigateAnchor?: (hash: string) => void;
}
export const Callout: React.FC = ({
@@ -25,6 +26,7 @@ export const Callout: React.FC = ({
imageBaseDir,
onImageClick,
githubRepo,
+ onNavigateAnchor,
}) => {
const kindAttr =
blockType === 'alert' ? { 'data-alert-kind': kindAttribute } : { 'data-directive-kind': kindAttribute };
@@ -47,6 +49,7 @@ export const Callout: React.FC = ({
imageBaseDir,
onImageClick,
onOpenLinkedDoc,
+ onNavigateAnchor,
githubRepo,
})}
diff --git a/packages/ui/components/blocks/TableBlock.tsx b/packages/ui/components/blocks/TableBlock.tsx
index 646b5682..ea861f3a 100644
--- a/packages/ui/components/blocks/TableBlock.tsx
+++ b/packages/ui/components/blocks/TableBlock.tsx
@@ -7,6 +7,7 @@ interface TableBlockProps {
onHover?: (element: HTMLElement) => void;
onLeave?: () => void;
onOpenLinkedDoc?: (path: string) => void;
+ onNavigateAnchor?: (hash: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
@@ -80,6 +81,7 @@ export const TableBlock: React.FC = ({
onHover,
onLeave,
onOpenLinkedDoc,
+ onNavigateAnchor,
imageBaseDir,
onImageClick,
githubRepo,
@@ -109,6 +111,7 @@ export const TableBlock: React.FC = ({
onImageClick={onImageClick}
text={header}
onOpenLinkedDoc={onOpenLinkedDoc}
+ onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
@@ -125,6 +128,7 @@ export const TableBlock: React.FC = ({
onImageClick={onImageClick}
text={cell}
onOpenLinkedDoc={onOpenLinkedDoc}
+ onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
diff --git a/packages/ui/components/blocks/TablePopout.tsx b/packages/ui/components/blocks/TablePopout.tsx
index 6d84e6a6..a77e66a4 100644
--- a/packages/ui/components/blocks/TablePopout.tsx
+++ b/packages/ui/components/blocks/TablePopout.tsx
@@ -22,6 +22,7 @@ interface TablePopoutProps {
/** hooks can walk into the popout's text nodes. Null falls back to body. */
container?: HTMLElement | null;
onOpenLinkedDoc?: (path: string) => void;
+ onNavigateAnchor?: (hash: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
@@ -38,6 +39,7 @@ const TablePopoutImpl: React.FC = ({
onClose,
container,
onOpenLinkedDoc,
+ onNavigateAnchor,
imageBaseDir,
onImageClick,
githubRepo,
@@ -80,12 +82,13 @@ const TablePopoutImpl: React.FC = ({
onImageClick={onImageClick}
text={info.getValue()}
onOpenLinkedDoc={onOpenLinkedDoc}
+ onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
),
}),
);
- }, [columnIds, headers, imageBaseDir, onImageClick, onOpenLinkedDoc, githubRepo]);
+ }, [columnIds, headers, imageBaseDir, onImageClick, onOpenLinkedDoc, onNavigateAnchor, githubRepo]);
const [sorting, setSorting] = useState([]);
const [globalFilter, setGlobalFilter] = useState('');
diff --git a/packages/ui/components/blocks/proseBody.tsx b/packages/ui/components/blocks/proseBody.tsx
index 50e52b5a..37f2c081 100644
--- a/packages/ui/components/blocks/proseBody.tsx
+++ b/packages/ui/components/blocks/proseBody.tsx
@@ -16,6 +16,7 @@ export function renderProseBody(args: {
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
onOpenLinkedDoc?: (path: string) => void;
+ onNavigateAnchor?: (hash: string) => void;
githubRepo?: string;
}): React.ReactNode {
const {
@@ -25,6 +26,7 @@ export function renderProseBody(args: {
imageBaseDir,
onImageClick,
onOpenLinkedDoc,
+ onNavigateAnchor,
githubRepo,
} = args;
@@ -34,6 +36,7 @@ export function renderProseBody(args: {
onImageClick={onImageClick}
text={text}
onOpenLinkedDoc={onOpenLinkedDoc}
+ onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
);
diff --git a/packages/ui/utils/anchors.test.ts b/packages/ui/utils/anchors.test.ts
new file mode 100644
index 00000000..ce092b8e
--- /dev/null
+++ b/packages/ui/utils/anchors.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ decodeAnchorHash,
+ getHeadingAnchorAliases,
+ legacySlugifyHeadingAnchor,
+ normalizeAnchorText,
+ slugifyHeadingAnchor,
+} from './anchors';
+
+describe('anchor helpers', () => {
+ test('normalizes inline markdown before slugging', () => {
+ expect(normalizeAnchorText('Part I: **Project** `Overview`')).toBe('Part I: Project Overview');
+ expect(normalizeAnchorText('[Docs](./docs.md) and [[notes|Notes Page]]')).toBe('Docs and Notes Page');
+ });
+
+ test('decodes anchor hashes and strips callback suffixes', () => {
+ expect(decodeAnchorHash('#section--overview')).toBe('section--overview');
+ expect(decodeAnchorHash('#section--overview?cb=https://example.com&ct=token')).toBe(
+ 'section--overview',
+ );
+ });
+
+ test('keeps malformed percent-encoded hashes non-fatal', () => {
+ expect(decodeAnchorHash('#bad%E0%A4%A')).toBe('bad%E0%A4%A');
+ });
+
+ test('creates a canonical collapsed slug', () => {
+ expect(slugifyHeadingAnchor('Part I: Project Overview: Goals and Scope')).toBe(
+ 'part-i-project-overview-goals-and-scope',
+ );
+ });
+
+ test('creates a legacy slug variant that preserves repeated separators', () => {
+ expect(legacySlugifyHeadingAnchor('Part I: Project Overview: Goals and Scope')).toBe(
+ 'part-i--project-overview--goals-and-scope',
+ );
+ });
+
+ test('returns both canonical and legacy aliases without duplicates', () => {
+ expect(getHeadingAnchorAliases('Simple Heading')).toEqual(['simple-heading']);
+ expect(getHeadingAnchorAliases('Part I: Project Overview: Goals and Scope')).toEqual([
+ 'part-i-project-overview-goals-and-scope',
+ 'part-i--project-overview--goals-and-scope',
+ ]);
+ });
+});
diff --git a/packages/ui/utils/anchors.ts b/packages/ui/utils/anchors.ts
new file mode 100644
index 00000000..b916b76c
--- /dev/null
+++ b/packages/ui/utils/anchors.ts
@@ -0,0 +1,45 @@
+export function normalizeAnchorText(text: string): string {
+ return text
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1')
+ .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2')
+ .replace(/\[\[([^\]]+)\]\]/g, '$1')
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/[*_~]/g, '')
+ .trim();
+}
+
+export function decodeAnchorHash(hash: string): string | null {
+ const raw = hash.replace(/^#/, '').split('?')[0]?.trim();
+ if (!raw) return null;
+
+ try {
+ const decoded = decodeURIComponent(raw).trim();
+ return decoded || null;
+ } catch {
+ // Keep malformed fragments non-fatal; the caller can still attempt lookup.
+ return raw;
+ }
+}
+
+export function slugifyHeadingAnchor(text: string): string {
+ return normalizeAnchorText(text)
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
+
+export function legacySlugifyHeadingAnchor(text: string): string {
+ return normalizeAnchorText(text)
+ .toLowerCase()
+ .replace(/[^a-z0-9]/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
+
+export function getHeadingAnchorAliases(text: string): string[] {
+ const aliases = [slugifyHeadingAnchor(text), legacySlugifyHeadingAnchor(text)]
+ .filter(Boolean);
+ return [...new Set(aliases)];
+}