Codex Desktop: support deep links to Markdown headings from chat and rendered docs

Open 💬 2 comments Opened May 24, 2026 by kingces95

Bug / Feature Request

Codex Desktop renders local Markdown file links from chat well enough to open the target file, but heading anchors do not appear to work.

For example, a chat link to:

C:/Users/chris/GitHub/service-gpt-copy-paste/gpt/models/container-part.model.md#preconditions

opens the Markdown file, but does not navigate to the ## Preconditions heading. The same problem appears inside rendered Markdown docs themselves: links from a table of contents to headings in the same document do not jump to the target section.

Expected Behavior

Markdown links with heading fragments should navigate to the heading target:

  • From Codex chat into a local Markdown file heading.
  • From one local Markdown doc to another local Markdown doc heading.
  • From a local Markdown doc to a heading in the same rendered doc.

Actual Behavior

The file opens, but the #heading-fragment is ignored.

Why This Matters

We are using gpt/models/*.md files as durable design models and commit-note references. Codex chat can already provide useful clickable file links, but without heading support, model docs become harder to navigate as they grow.

This would make model-heavy workflows much better: Codex could draft a note in chat with deep links to exact model sections, then publish those same links into the docs.

Example Workflow

A commit note includes links to sections such as:

  • Container Part Model
  • Preconditions
  • STL Name Basis
  • STL Argument Order
  • JS Overload Names
  • Member Index
  • Lexeme Index

Each item should link directly to its section in gpt/models/container-part.model.md.

Environment

  • Codex Desktop app
  • Windows
  • Local workspace Markdown files
  • Links generated from Codex chat and from rendered Markdown docs

View original on GitHub ↗

2 Comments

jshaofa-ui · 3 months ago

Fix: Deep Links to Markdown Headings

Issue: openai/codex #24383
Severity: Medium — broken navigation in rendered Markdown
Labels: bug, app, enhancement
Status: OPEN (0 comments)

---

1. Root Cause Analysis

1.1 Symptom

Codex Desktop renders local Markdown file links from chat, but heading anchors do not work. For example:

  • Link: C:/Users/chris/GitHub/project/gpt/models/container-part.model.md#preconditions
  • Expected: Opens file and scrolls to ## Preconditions heading
  • Actual: Opens file but does not navigate to the heading

The same problem appears inside rendered Markdown docs: links from a table of contents to headings within the same file do not scroll to the target.

1.2 Architecture Context

Codex Desktop uses an Electron-based renderer for Markdown files. The rendering pipeline:

  1. Link parsing — Extracts file path and anchor from Markdown links
  2. File opening — Opens the target file in the renderer
  3. Anchor navigation — Scrolls to the heading with matching ID

The anchor navigation step is either missing or broken.

1.3 Root Cause

The Markdown renderer likely:

  1. Parses the file path correctly and opens the file
  2. Does not parse or use the #anchor fragment
  3. OR parses the anchor but the heading IDs don't match the expected format

Common heading ID formats:

  • GitHub: #preconditions (lowercase, spaces → hyphens)
  • Markdown-it: #preconditions (similar)
  • Custom: may use different ID generation

---

2. Step-by-Step Fix Approach

2.1 Parse Anchor Fragment from Link

// packages/desktop/src/markdown/link-handler.ts

interface ParsedLink {
  filePath: string;
  anchor?: string;  // e.g., "preconditions"
}

export function parseMarkdownLink(href: string): ParsedLink {
  // Handle: C:/path/to/file.md#heading
  // Handle: ./relative/path.md#heading
  // Handle: file.md#heading
  const hashIndex = href.lastIndexOf('#');
  if (hashIndex !== -1) {
    return {
      filePath: href.substring(0, hashIndex),
      anchor: href.substring(hashIndex + 1),
    };
  }
  return { filePath: href };
}

2.2 Navigate to Anchor After File Load

// packages/desktop/src/markdown/renderer.ts

export async function openMarkdownFile(
  filePath: string,
  anchor?: string
): Promise<void> {
  // Load the file content
  const content = await readFile(filePath, 'utf-8');

  // Render Markdown to HTML
  const html = renderMarkdown(content);

  // Display in renderer
  await showRenderer(html);

  // NEW: Navigate to anchor if provided
  if (anchor) {
    await scrollToAnchor(anchor);
  }
}

async function scrollToAnchor(anchor: string): Promise<void> {
  // Generate the expected heading ID format
  const headingId = generateHeadingId(anchor);

  // Try to find and scroll to the element
  const element = document.getElementById(headingId);
  if (element) {
    element.scrollIntoView({ behavior: 'smooth', block: 'start' });
    // Highlight the heading briefly
    element.classList.add('heading-highlight');
    setTimeout(() => element.classList.remove('heading-highlight'), 2000);
  } else {
    // Try alternative ID formats
    const altIds = generateAlternativeHeadingIds(anchor);
    for (const altId of altIds) {
      const altElement = document.getElementById(altId);
      if (altElement) {
        altElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
        return;
      }
    }
    console.warn(`Heading not found: ${anchor}`);
  }
}

2.3 Generate Consistent Heading IDs

// packages/desktop/src/markdown/heading-ids.ts

/**
 * Generate heading IDs matching GitHub's format:
 * - Lowercase
 * - Spaces → hyphens
 * - Remove punctuation (except hyphens)
 * - Append -1, -2, etc. for duplicates
 */
export function generateHeadingId(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, '')  // Remove punctuation
    .replace(/\s+/g, '-')       // Spaces to hyphens
    .replace(/-+/g, '-')        // Collapse multiple hyphens
    .replace(/^-|-$/g, '');     // Trim leading/trailing hyphens
}

/**
 * Generate alternative heading ID formats for compatibility.
 */
export function generateAlternativeHeadingIds(anchor: string): string[] {
  return [
    anchor,                       // Exact match
    anchor.toLowerCase(),         // Lowercase
    anchor.replace(/\s+/g, '-'),  // Spaces to hyphens
    `user-content-${anchor}`,     // GitHub user-content prefix
  ];
}

2.4 Handle Internal Links in Rendered Markdown

// packages/desktop/src/markdown/internal-links.ts

export function processInternalLinks(html: string): string {
  // Find all anchor links within the rendered HTML
  return html.replace(
    /<a href="#([^"]+)">/g,
    (match, anchor) => {
      // Replace with onclick handler for in-page navigation
      return `<a href="#${anchor}" onclick="handleInternalLink('${anchor}'); return false;">`;
    }
  );
}

// Inject into renderer
function handleInternalLink(anchor: string): void {
  const headingId = generateHeadingId(anchor);
  const element = document.getElementById(headingId);
  if (element) {
    element.scrollIntoView({ behavior: 'smooth', block: 'start' });
    // Update URL hash without page jump
    history.replaceState(null, '', `#${anchor}`);
  }
}

2.5 Add CSS for Heading Highlight

/* packages/desktop/src/styles/headings.css */

.heading-highlight {
  animation: heading-flash 2s ease-out;
}

@keyframes heading-flash {
  0% { background-color: rgba(255, 255, 0, 0.3); }
  100% { background-color: transparent; }
}

---

3. Testing Strategy

3.1 Unit Tests

describe('Heading ID generation', () => {
  it('should generate lowercase hyphenated IDs', () => {
    expect(generateHeadingId('Preconditions')).toBe('preconditions');
    expect(generateHeadingId('Getting Started Guide')).toBe('getting-started-guide');
    expect(generateHeadingId('API v2.0 Reference')).toBe('api-v20-reference');
  });

  it('should handle special characters', () => {
    expect(generateHeadingId("What's New?")).toBe('whats-new');
    expect(generateHeadingId('C++ & Rust')).toBe('c--rust');
  });
});

describe('Link parsing', () => {
  it('should parse file path and anchor', () => {
    const result = parseMarkdownLink('C:/path/to/file.md#preconditions');
    expect(result.filePath).toBe('C:/path/to/file.md');
    expect(result.anchor).toBe('preconditions');
  });

  it('should handle links without anchors', () => {
    const result = parseMarkdownLink('./relative/path.md');
    expect(result.filePath).toBe('./relative/path.md');
    expect(result.anchor).toBeUndefined();
  });
});

3.2 Integration Tests

  1. Create a Markdown file with multiple headings
  2. Open the file via Codex Desktop chat link with #heading anchor
  3. Verify the file opens and scrolls to the correct heading
  4. Click internal TOC links and verify navigation

3.3 Regression Tests

  • Verify links without anchors still work
  • Verify links to non-existent headings show a warning (not crash)
  • Verify Windows paths with # in directory names are handled correctly

---

4. Impact Assessment

4.1 User Impact

  • Severity: Medium — broken navigation for Markdown links
  • Scope: All users who link to Markdown headings
  • Benefit: Proper navigation within and between Markdown documents

4.2 Code Risk

  • Risk: Low — adding anchor navigation is additive
  • Backward Compatibility: Full — links without anchors work as before
  • Performance: Negligible — single DOM lookup per link

4.3 Security

  • No security implications — purely a navigation fix

4.4 Files Modified

  • packages/desktop/src/markdown/link-handler.ts (new)
  • packages/desktop/src/markdown/heading-ids.ts (new)
  • packages/desktop/src/markdown/internal-links.ts (new)
  • packages/desktop/src/markdown/renderer.ts — integrate anchor navigation
  • packages/desktop/src/styles/headings.css (new)
  • packages/desktop/src/__tests__/heading-navigation.test.ts (new)

---

5. Alternative Approaches Considered

  1. Use browser's native anchor navigation — Rejected: Electron renderer may not support it
  2. Use Markdown-it's anchor plugin — Rejected: adds dependency, may conflict with existing rendering
  3. Custom anchor navigation — Selected — full control, matches Codex's rendering pipeline

---

Solution prepared for openai/codex #24383

yui-stingray · 3 months ago

I checked current main for the Rust-side Markdown/local-file-link rendering path. One relevant normalization point is codex-rs/tui/src/markdown_render.rs: render_local_link_target() calls parse_local_link_target(), which separates supported line/column targets such as #L.., #L..C.., ranges, and :line[:col] suffixes from local file links. The surrounding tests in codex-rs/tui/src/markdown_render_tests.rs cover those line/column cases.

For a file:// heading fragment like file:///.../container-part.model.md#preconditions, that path does not currently preserve preconditions as a rendered location suffix. normalize_hash_location_suffix_fragment() only accepts line-style fragments, so non-line fragments are not treated as location targets in this renderer.

A narrow regression test would be to add a local Markdown link case with a non-line file:// hash fragment, then decide whether the rendered/local-link contract should preserve that fragment as a heading target rather than dropping it. That would not cover the whole Desktop document viewer by itself, but it looks like a useful Rust-side boundary to pin down before wiring same-document and cross-document heading navigation.