Przeglądaj źródła

feat(web): open Markdown file links in sidebar previews

Dudu-0223 1 miesiąc temu
rodzic
commit
1ec62afbdb

+ 6 - 0
.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md
+2026-09-15-markdown-file-preview-links.md: 54174e250507c8cd51432099bf75ec4c576aded7
+2026-09-15-markdown-file-preview-links.zh.md: f29af6385891ad23801154e8e9a83a14fb4b032e

+ 25 - 0
.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md

@@ -0,0 +1,25 @@
+# Agent Note: Markdown file preview links
+
+Status: implemented
+
+English | [中文](2026-09-15-markdown-file-preview-links.zh.md)
+
+## Problem
+
+Assistant explanations link to existing source files that the turn does not modify or deliver. Restricting clickable references to produced-file mentions prevents readers from opening those sources beside the answer.
+
+## Decision
+
+Settled Assistant Markdown passes explicit local link destinations to the Chat file opener. The renderer recognizes absolute and workspace-relative paths, decodes percent escapes once, and separates `#L24` or `#L24-L30` into a first-line navigation request. A file control preserves the authored label and shows a file-category icon. It never navigates the browser to the authored path.
+
+The existing [Sidebar navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) owns Session addressing, tab reuse, and preview selection. The Host file service retains access checks and missing-file errors. Inline-code produced-file matching remains independent. External URLs retain their protocol allowlist; queries, fragment-only destinations, unsupported fragments, malformed escapes, and invalid line ranges remain inert.
+
+## Alternatives considered
+
+- Allow relative browser anchors: those navigate the application URL instead of requesting Session file content.
+- Require a produced-file entry: this excludes ordinary read-only explanations.
+- Add a new preview service: Chat already supplies the required opener and line parameter.
+
+## Consequences
+
+Source references need no new Session event or model prompt. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing and callback wiring; the keyless `markdown-file-links` Web snapshot covers file content, line navigation, and tab reuse through the shipped composition.

+ 25 - 0
.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Markdown 文件预览链接
+
+Status: implemented
+
+[English](2026-09-15-markdown-file-preview-links.md) | 中文
+
+## Problem
+
+Assistant 讲解会链接到本轮未修改或交付的现有源码文件。若仅允许产出文件提及成为可点击引用,读者就无法在回答旁打开这些源码。
+
+## Decision
+
+落定后的 Assistant Markdown 将显式本地链接目标传给 Chat 文件打开器。渲染器识别绝对路径和工作区相对路径,对百分号转义解码一次,并将 `#L24` 或 `#L24-L30` 分离为起始行导航请求。文件控件保留原始标签并显示文件类别图标,绝不让浏览器导航到原始路径。
+
+现有[侧栏导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)负责 Session 寻址、标签复用及预览选择。Host 文件服务保留访问检查和文件缺失错误。行内代码的产出文件匹配保持独立。外部 URL 保留协议允许列表;查询串、仅有片段的目标、不支持的片段、错误转义及无效行号范围均不可点击。
+
+## Alternatives considered
+
+- 允许相对浏览器链接:这会跳转应用 URL,而非请求 Session 文件内容。
+- 要求产出文件条目:这会排除普通只读讲解。
+- 增加预览服务:Chat 已提供所需打开器和行号参数。
+
+## Consequences
+
+源码引用无需新增 Session 事件或模型提示词。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析和回调连接;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、行号导航和标签复用。

+ 75 - 0
apps/web/tests/markdown-file-links.e2e.ts

@@ -0,0 +1,75 @@
+/** Cold-replayed Markdown references open Session files through the shipped Web composition. */
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/markdown-file-links', import.meta.url))
+const MODE = webSnapshotMode()
+
+describe('web e2e: Markdown file links', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    await mkdir(join(scaffold.workspaceCwd, 'src'))
+    await mkdir(join(scaffold.workspaceCwd, 'other'))
+    await writeFile(join(scaffold.workspaceCwd, 'src/example.txt'),
+      Array.from({ length: 60 }, (_, i) => `source line ${i + 1}\n`).join(''))
+    await writeFile(join(scaffold.workspaceCwd, 'other/example.txt'), 'other file\n')
+    await seedSession(scaffold, await readFile(join(SNAPSHOT_DIR, 'session.v3.jsonl'), 'utf8'), 'markdown-file-links')
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.locator('[role="treeitem"]').first().click()
+    await page.locator('[role="treeitem"]').nth(1).click()
+    await page.getByRole('button', { name: 'Source at line 24', exact: true }).waitFor()
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('previews relative and absolute links at the requested line without duplicate tabs', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-file-links'))
+    const source = page.getByRole('button', { name: 'Source at line 24', exact: true })
+    const prose = await source.locator('..').ariaSnapshot()
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'links.expected.md'), prose, MODE)
+    const beforeUrl = page.url()
+    await source.click()
+    const column = page.locator('[data-rightbar-col]')
+    await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('source line 24\n')
+    await expect.poll(() => column.locator('[data-textpreview-path]').textContent())
+      .toBe(join(scaffold.workspaceCwd, 'src/example.txt'))
+    await page.getByRole('button', { name: 'Absolute at line 30', exact: true }).click()
+    await expect.poll(() => column.locator('[data-textpreview-target="30"]').textContent()).toBe('source line 30\n')
+    expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt'])
+    expect(page.url()).toBe(beforeUrl)
+    expect(page.context().pages()).toHaveLength(1)
+    expect(await page.getByRole('link', { name: 'Website', exact: true }).getAttribute('target')).toBe('_blank')
+    await page.getByRole('button', { name: 'Other example', exact: true }).click()
+    await expect.poll(() => column.locator('[data-textpreview-path]').textContent())
+      .toBe(join(scaffold.workspaceCwd, 'other/example.txt'))
+    await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('other file\n')
+    expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt', 'example.txt'])
+    const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'), preview, MODE)
+    await page.getByRole('button', { name: 'Missing file', exact: true }).click()
+    await column.locator('[data-textpreview-failed="workspace-file/not-found"]').waitFor()
+    expect(page.url()).toBe(beforeUrl)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'links.expected.md', 'preview.expected.md'])
+  })
+})

+ 2 - 2
packages/client/ui-chat/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: c7c9fb7cb2a9a149fae9b27db78e019d821dfb95
-README.zh.md: 386d7aae91b157a7b09fcd0515bfd8d075a39638
+README.md: e9ec7876521ccdcb90e7451a733d719c3c846b60
+README.zh.md: 78d69dbcee888c258981b8524293a0b24d00885c

+ 2 - 0
packages/client/ui-chat/README.md

@@ -29,6 +29,8 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
 <a id="reference-previews"></a>
 ## Reference previews
 
+Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state.
+
 Sent file references and skills confirmed by the message’s logged invocation open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references.
 
 <a id="system-prompt-row"></a>

+ 2 - 0
packages/client/ui-chat/README.zh.md

@@ -29,6 +29,8 @@ kind: "package-reference"
 <a id="reference-previews"></a>
 ## 引用预览
 
+Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。
+
 已发送的文件引用及消息日志确认调用的 skill 可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
 
 <a id="system-prompt-row"></a>

+ 4 - 1
packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx

@@ -38,6 +38,8 @@ export interface AssistantMarkdownProps {
   revealProcess?: (() => void) | undefined
   /** Resolved prose file mentions for this Assistant's closing turn. */
   mentions?: MarkdownFileMentions | undefined
+  /** Open an authored Markdown file link in the Session's sidebar preview. */
+  openFile?: ChatNodeOwnerProps['openFile'] | undefined
   /** The owning view's locale seat, passed down as a plain prop. */
   t: ChatViewSlotProps['t']
 }
@@ -45,7 +47,7 @@ export interface AssistantMarkdownProps {
 /** Reasoning block as the Think variant summary row (figma 39:28304). */
 export const AssistantMarkdown = memo(function AssistantMarkdown({
   blocks, streaming, interrupted, renderMessageImages,
-  reasoningHidden = false, revealProcess, mentions, t,
+  reasoningHidden = false, revealProcess, mentions, openFile, t,
 }: AssistantMarkdownProps) {
   // Stable per locale revision (t identity changes on switch): a fresh object
   // per render would rebuild MarkdownText's component table every chunk.
@@ -79,6 +81,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
             labels={labels}
             fileMentions={mentions}
             pathImages={pathImages}
+            openFile={openFile}
           />,
         )
         break

+ 1 - 0
packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx

@@ -35,6 +35,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({
       reasoningHidden={reasoningHidden}
       revealProcess={revealProcess}
       mentions={mentions}
+      openFile={openFile}
       t={t}
     />
   )

+ 10 - 0
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -499,6 +499,16 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien
 
 describe('Chat node rendering', () => {
 
+  it('opens Markdown references to unmodified files with line navigation', () => {
+    const h = makeHarness({
+      nodes: [user(1, 'explain'), assistant(2, '[source](src/index.ts#L24-L30)', 1)],
+      turnEnds: new Map([[1, 2]]),
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    fireEvent.click(view.getByRole('button', { name: 'source' }))
+    expect(h.openFile).toHaveBeenCalledWith('src/index.ts', { line: 24 })
+  })
+
   it('threads the injected file-mention vocabulary into the closing prose only', () => {
     const wrote = (seq: number, callId: string): ToolResultNode => ({
       ...toolResult(seq, callId, 'write'),

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 631b19de44660c21813bc4f87b7a59dc8dfa6ee3
-README.zh.md: b23c87f7861555625f28a9fd182aa6acb159b21c
+README.md: b6d26ad8455789b4dafdd39fc1c2947a940c21e1
+README.zh.md: 7ebd062c828ecb87b17baa08cd9862c6f45543e2

+ 2 - 0
packages/client/ui-primitives/README.md

@@ -71,6 +71,8 @@ The catalog above lists what each export is for; this section covers the behavio
 
 ### Rendering agent output
 
+Passing `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
+
 `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
 
 `DiffBlock` compares the old and new content by line. It shows actual additions and deletions with up to three neutral context lines on each side, separates distant changes with `⋯`, and excludes shared context from both summary and footer totals. Search stops beyond 256 line additions/deletions per fragment; those fragments display and count the complete old and new contents as a coarse replacement, including shared lines. Copy includes the full displayed diff with its prefixes. A final newline is treated as a terminator; differences only in the presence of a final newline are not displayed.

+ 2 - 0
packages/client/ui-primitives/README.zh.md

@@ -71,6 +71,8 @@ kind: "package-library"
 
 ### 渲染 agent 输出
 
+传入 `openFile` 后,本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
+
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 
 `DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,摘要和底部统计都不计入共享上下文。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。

+ 2 - 1
packages/client/ui-primitives/src/markdown/MarkdownText.module.css

@@ -80,7 +80,8 @@
   text-underline-offset: 3px;
 }
 
-.markdown a:focus-visible {
+.markdown a:focus-visible,
+.fileMention:focus-visible {
   box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
 }
 

+ 11 - 6
packages/client/ui-primitives/src/markdown/MarkdownText.tsx

@@ -31,6 +31,7 @@ function renderSettled(
   labels: MarkdownLabels,
   fileMentions: MarkdownFileMentions | undefined,
   pathImages: MarkdownPathImages | undefined,
+  openFile: MarkdownRenderContext['openFile'],
 ): ReactNode[] {
   const root = parseGfmWithMath(text)
   const targets = createReferenceTargets()
@@ -40,6 +41,7 @@ function renderSettled(
     labels,
     fileMentions,
     pathImages,
+    openFile,
     targets,
     footnoteOrder: [],
     footnoteCounts: new Map(),
@@ -160,29 +162,32 @@ class StreamingRenderer {
  * single streaming gate — they apply to settled renders only, because a
  * streaming message's vocabulary is not final and frozen cached elements
  * must not bake in handlers that could go stale.
- * @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
- * relative links, and unsafe protocols are disabled, while absolute HTTP(S)
- * images render directly.
+ * `openFile` enables local Markdown links in settled messages, including
+ * `#L24` and `#L24-L30` destinations (ranges open at their first line).
+ * @returns A GFM document with TeX math rendered through KaTeX; raw HTML and
+ * unsafe protocols are disabled. Local links without an opener remain text;
+ * absolute HTTP(S) images render directly.
  */
-export const MarkdownText = memo(function MarkdownText({ text, streaming = false, labels, fileMentions, pathImages }: {
+export const MarkdownText = memo(function MarkdownText({ text, streaming = false, labels, fileMentions, pathImages, openFile }: {
   text: string
   streaming?: boolean
   labels: MarkdownLabels
   fileMentions?: MarkdownFileMentions | undefined
   pathImages?: MarkdownPathImages | undefined
+  openFile?: MarkdownRenderContext['openFile']
 }) {
   const streamRef = useRef<StreamingRenderer | null>(null)
   const streamLabelsRef = useRef<MarkdownLabels>(labels)
   const children = useMemo(() => {
     if (!streaming) {
       streamRef.current = null
-      return renderSettled(text, labels, fileMentions, pathImages)
+      return renderSettled(text, labels, fileMentions, pathImages, openFile)
     }
     if (streamRef.current === null || streamLabelsRef.current !== labels) {
       streamRef.current = new StreamingRenderer(labels)
       streamLabelsRef.current = labels
     }
     return streamRef.current.render(text)
-  }, [text, streaming, labels, fileMentions, pathImages])
+  }, [text, streaming, labels, fileMentions, pathImages, openFile])
   return <div className={css.markdown}>{children}</div>
 })

+ 31 - 0
packages/client/ui-primitives/src/markdown/file-link.ts

@@ -0,0 +1,31 @@
+/** Local Markdown destinations accepted by the file-preview callback. */
+
+/**
+ * Decode a file destination and its optional GitHub-style line fragment.
+ * @param value - Parsed Markdown link destination.
+ * @returns A local path and optional first line, or undefined for URLs,
+ * fragment-only links, queries, malformed escapes, or invalid line ranges.
+ */
+export function parseFileLink(value: string): { path: string; line?: number } | undefined {
+  const hash = value.indexOf('#')
+  const destination = hash < 0 ? value : value.slice(0, hash)
+  if (destination.includes('?')) return undefined
+  let path: string
+  try {
+    path = decodeURIComponent(destination)
+  } catch (_error) {
+    // Malformed percent escapes cannot identify a file unambiguously.
+    return undefined
+  }
+  if (path.length === 0 || /[\u0000-\u001f\u007f]/.test(path)
+    || /^[\\/]{2}/.test(path)
+    || (/^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path))) return undefined
+  if (hash < 0) return { path }
+  const fragment = value.slice(hash + 1)
+  const match = /^L([1-9]\d*)(?:-L([1-9]\d*))?$/.exec(fragment)
+  if (match === null) return undefined
+  const line = Number(match[1])
+  const end = match[2] === undefined ? line : Number(match[2])
+  if (!Number.isSafeInteger(line) || !Number.isSafeInteger(end) || end < line) return undefined
+  return { path, line }
+}

+ 27 - 6
packages/client/ui-primitives/src/markdown/render.tsx

@@ -4,8 +4,8 @@
  * cache frozen blocks as React elements; the rendered DOM is pinned
  * byte-for-byte by `tests/fixtures/markdown-dom` and must not drift.
  *
- * Untrusted-output policy (unchanged from the replaced pipeline): link and
- * image destinations pass a protocol allowlist, images additionally require
+ * External link and image destinations pass a protocol allowlist; settled
+ * local file links use an explicit owner callback. Images additionally require
  * absolute HTTP(S), raw HTML renders as literal text (no HTML enters the
  * DOM), and KaTeX runs without trusted commands. Fragment-anchor URLs fail
  * the allowlist, so footnote references and back-references render as plain
@@ -23,6 +23,7 @@ import type * as Md from 'mdast'
 import type {} from 'mdast-util-math'
 import { normalizeUri } from 'micromark-util-sanitize-uri'
 import { CodeBlock } from './CodeBlock.tsx'
+import { parseFileLink } from './file-link.ts'
 import { renderTexToReact } from './katex.tsx'
 import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx'
 import type { PositionedBlock } from './incremental.ts'
@@ -174,6 +175,8 @@ export interface MarkdownFileMentions {
  * numbering accumulated in document order while references render.
  */
 export interface MarkdownRenderContext {
+  /** Opens authored local file links in the owner's preview; settled renders only. */
+  readonly openFile?: ((path: string, options?: { line?: number }) => void) | undefined
   /** Streaming arm: fences highlight incrementally as they grow; TeX (including ```math fences) stays literal until the settled pass. */
   readonly streaming: boolean
   /** Localized fence copy-button labels. */
@@ -338,7 +341,10 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
     case 'table':
       return renderTable(node, key, context)
     case 'link':
-      return renderAnchor(node.url, renderChildren(node.children, { ...context, inLink: true }), key, !anchorWrapsOnlyImages(node.children))
+      return renderAnchor(
+        node.url, renderChildren(node.children, { ...context, inLink: true }), key,
+        !anchorWrapsOnlyImages(node.children), context.openFile,
+      )
     case 'linkReference':
       return renderLinkReference(node, key, context)
     case 'image':
@@ -542,8 +548,23 @@ function renderSafeLink(href: string, children: ReactNode[], key: Key, glyph = t
   )
 }
 
-/** Anchor over a parsed markdown destination, which hast normalized before the allowlist saw it. */
-function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true): ReactNode {
+/** Local destinations use the preview callback; external destinations use the URL allowlist. */
+function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true, openFile?: MarkdownRenderContext['openFile']): ReactNode {
+  const file = openFile === undefined ? undefined : parseFileLink(url)
+  if (file !== undefined && openFile !== undefined) {
+    return (
+      <button
+        key={key}
+        type="button"
+        className={css.fileMention}
+        title={url}
+        onClick={() => { openFile(file.path, file.line === undefined ? undefined : { line: file.line }) }}
+      >
+        {glyph && <LinkIcon kind={classifyLinkPath(file.path)} className={css.linkIcon} />}
+        {children}
+      </button>
+    )
+  }
   return renderSafeLink(normalizeUri(url), children, key, glyph)
 }
 
@@ -608,7 +629,7 @@ function renderLinkReference(
     return <Fragment key={key}>{'['}{renderChildren(node.children, context)}{referenceSuffix(node)}</Fragment>
   }
   const rendered = renderChildren(node.children, { ...context, inLink: true })
-  return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children))
+  return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children), context.openFile)
 }
 
 function renderImageReference(

+ 76 - 0
packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx

@@ -0,0 +1,76 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { MarkdownText } from './markdown-test-components.tsx'
+
+afterEach(cleanup)
+
+describe('Markdown file links', () => {
+  it.each([
+    ['src/index.ts', 'src/index.ts', undefined],
+    ['/workspace/src/index.ts#L24', '/workspace/src/index.ts', { line: 24 }],
+    ['../docs/My%20Notes.md#L24-L30', '../docs/My Notes.md', { line: 24 }],
+    ['C:/work/file.ts#L2', 'C:/work/file.ts', { line: 2 }],
+    ['C:%5Cwork%5Cfile.ts', 'C:\\work\\file.ts', undefined],
+    ['docs/%E4%B8%AD%E6%96%87.md', 'docs/中文.md', undefined],
+    ['file%23name%3F.txt', 'file#name?.txt', undefined],
+  ])('opens %s through the preview callback', (target, path, options) => {
+    const openFile = vi.fn()
+    const view = render(<MarkdownText text={`[source](${target})`} openFile={openFile} />)
+    const link = view.getByRole('button', { name: 'source' })
+    expect(link.getAttribute('title')).toBe(target)
+    fireEvent.click(link)
+    expect(openFile).toHaveBeenCalledWith(path, options)
+    expect(view.container.querySelector('a')).toBeNull()
+  })
+
+  it('handles reference links and code labels without nesting file-mention buttons', () => {
+    const openFile = vi.fn()
+    const resolve = vi.fn()
+    const view = render(<MarkdownText
+      text={'[see `index.ts`][source]\n\n[source]: src/index.ts#L3-L3'}
+      openFile={openFile}
+      fileMentions={{ resolve }}
+    />)
+    fireEvent.click(view.getByRole('button', { name: 'see index.ts' }))
+    expect(openFile).toHaveBeenCalledWith('src/index.ts', { line: 3 })
+    expect(resolve).not.toHaveBeenCalled()
+    expect(view.container.querySelector('button button')).toBeNull()
+  })
+
+  it.each([
+    '//example.com/file', '%2F%2Fexample.com/file', '%5C%5Cserver%5Cfile',
+    'javascript:alert', 'data:text/plain,hi', 'file:///etc/passwd', 'vscode:open',
+    '%6Aavascript:alert', '#L2', 'file.ts?raw=1', 'file%ZZ.ts', 'file%00.ts',
+    'file.ts#heading', 'file.ts#L0', 'file.ts#L3-L2',
+    'file.ts#L9007199254740992', 'file.ts#L1-L9007199254740992',
+  ])('keeps unsupported destination %s inert', (target) => {
+    const openFile = vi.fn()
+    const view = render(<MarkdownText text={`[source](${target})`} openFile={openFile} />)
+    expect(view.getByText('source')).toBeTruthy()
+    expect(view.container.querySelector('button, a')).toBeNull()
+    expect(openFile).not.toHaveBeenCalled()
+  })
+
+  it('preserves external links and leaves local links inert without an opener', () => {
+    const view = render(<MarkdownText text={'[web](https://example.com/a#L1) [mail](mailto:a@example.com) [file](src/a.ts)'} />)
+    expect(view.getByRole('link', { name: 'web' }).getAttribute('target')).toBe('_blank')
+    expect(view.getByRole('link', { name: 'mail' }).getAttribute('href')).toBe('mailto:a@example.com')
+    expect(view.queryByRole('button')).toBeNull()
+  })
+
+  it('enables links after streaming and uses a replaced opener', () => {
+    const first = vi.fn()
+    const second = vi.fn()
+    const text = '[source](src/a.ts)\n\nmore\n\n'
+    const view = render(<MarkdownText text={text} streaming openFile={first} />)
+    expect(view.queryByRole('button')).toBeNull()
+    view.rerender(<MarkdownText text={text} openFile={first} />)
+    fireEvent.click(view.getByRole('button', { name: 'source' }))
+    expect(first).toHaveBeenCalledOnce()
+    view.rerender(<MarkdownText text={text} openFile={second} />)
+    fireEvent.click(view.getByRole('button', { name: 'source' }))
+    expect(second).toHaveBeenCalledOnce()
+    expect(first).toHaveBeenCalledOnce()
+  })
+})

+ 11 - 0
snapshots/web/markdown-file-links/links.expected.md

@@ -0,0 +1,11 @@
+- paragraph:
+  - button "Source at line 24"
+  - text: ·
+  - button "Absolute at line 30"
+  - text: ·
+  - button "Other example"
+  - text: ·
+  - button "Missing file"
+  - text: ·
+  - link "Website":
+    - /url: https://example.com/

+ 6 - 0
snapshots/web/markdown-file-links/preview.expected.md

@@ -0,0 +1,6 @@
+- text: {{cwd}}/other/example.txt
+- button "Line wrap" [pressed]:
+  - img
+- button "Read the file again":
+  - img
+- text: other file

Plik diff jest za duży
+ 7 - 0
snapshots/web/markdown-file-links/session.v3.jsonl


+ 7 - 0
snapshots/web/markdown-file-links/snapshot.yml

@@ -0,0 +1,7 @@
+version: 1
+scenario: markdown-file-links
+profile: web
+composition: web-default
+recording: authored
+header:
+  class: web-default

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików