Преглед изворни кода

refactor(web): route Markdown file navigation through delegate provider

Dudu-0223 пре 5 дана
родитељ
комит
4c546124b9

+ 2 - 2
.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.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 .agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md
-2026-09-15-markdown-file-preview-links.md: 0114e8cf3a7024273ff5973f0fb09a28fc3244cb
-2026-09-15-markdown-file-preview-links.zh.md: f872abb18c26716f4a169958bb7215ef4ac16685
+2026-09-15-markdown-file-preview-links.md: d9d535afc5b005ae453bd7623e05c411e4d6932b
+2026-09-15-markdown-file-preview-links.zh.md: 63c18e284b785ed5686721a0b04cf73c78c0ceb3

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

@@ -10,7 +10,7 @@ Assistant explanations link to existing source files that the turn does not modi
 
 ## 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.
+Settled Assistant Markdown passes explicit local link destinations to the Chat file opener through `MarkdownDelegateProvider`. The provider owns both file and HTTP(S) navigation callbacks; nested link components read its current callbacks without adding navigation props to intermediate renderers. 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.
 
@@ -24,8 +24,10 @@ The Web file-reference prompt asks for a link on every existing-file mention out
 
 **Add a preview service.** Chat already supplies the required opener and line parameter.
 
+**Thread navigation callbacks through renderer props.** Every new navigation capability would enlarge unrelated intermediate interfaces. Reading the scoped delegate at link components also lets cached Markdown observe handler changes.
+
 **Filename-first or display-only prompt variants.** The three-variant development comparison favored A for occurrence-level link coverage and the user preferred its output. B produced more answers with no missing links, so these observations do not establish a universal winner. Visible `#L` suffixes were rejected in favor of the familiar colon notation; retaining anchor syntax in destinations preserves existing navigation.
 
 ## Consequences
 
-Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. 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, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar.
+Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. 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, shared file and HTTP(S) routing, nested scopes, and callback replacement or removal; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar.

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

@@ -10,7 +10,7 @@ Assistant 讲解会链接到本轮未修改或交付的现有源码文件。若
 
 ## Decision
 
-落定后的 Assistant Markdown 将显式本地链接目标传给 Chat 文件打开器。渲染器识别绝对路径和工作区相对路径,对百分号转义解码一次,并将 `#L24` 或 `#L24-L30` 分离为起始行导航请求。文件控件保留原始标签并显示文件类别图标,绝不让浏览器导航到原始路径。
+落定后的 Assistant Markdown 通过 `MarkdownDelegateProvider` 将显式本地链接目标传给 Chat 文件打开器。Provider 同时拥有文件及 HTTP(S) 导航回调;内部链接组件读取其当前回调,中间渲染器无需增加导航 props。渲染器识别绝对路径和工作区相对路径,对百分号转义解码一次,并将 `#L24` 或 `#L24-L30` 分离为起始行导航请求。文件控件保留原始标签并显示文件类别图标,绝不让浏览器导航到原始路径。
 
 现有[侧栏导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)负责 Session 寻址、标签复用及预览选择。Host 文件服务保留访问检查和文件缺失错误。行内代码的产出文件匹配保持独立。外部 URL 保留协议允许列表;查询串、仅有片段的目标、不支持的片段、错误转义及无效行号范围均不可点击。
 
@@ -24,8 +24,10 @@ Web 文件引用提示词要求链接命令、配置表达式和代码块以外
 
 **增加预览服务。** Chat 已提供所需打开器和行号参数。
 
+**通过渲染器 props 逐层传递导航回调。** 每种新增导航能力都会扩大无关的中间接口。链接组件读取作用域内的 delegate,也让缓存的 Markdown 能响应回调变化。
+
 **文件名优先或仅规范显示的提示词。** 三版开发对照中 A 的逐次引用链接覆盖率较高,用户也更偏好其输出。B 完全没有漏链的回答更多,因此这些观察不能证明某版普遍更优。用户选择熟悉的冒号格式,放弃显示 `#L` 后缀;目标保留锚点语法以维持现有导航。
 
 ## Consequences
 
-源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析和回调连接;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导。
+源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析、共享的文件与 HTTP(S) 路由、嵌套作用域及回调替换或移除;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导。

+ 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: 5ccbb29d98c5693dd0d9da8a4b6b5a03522c4c57
-README.zh.md: 4013f2894067cc506dcc923eeb5cf82dcf81b855
+README.md: 0d4e3376ffc34247490cc72a268120a9fe3180fa
+README.zh.md: 2494e932e1d143f6d167ad6044fa34d8501b367c

+ 1 - 1
packages/client/ui-chat/README.md

@@ -30,7 +30,7 @@ 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.
+Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. 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.
 
 HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also 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.
 

+ 1 - 1
packages/client/ui-chat/README.zh.md

@@ -30,7 +30,7 @@ kind: "package-reference"
 <a id="reference-previews"></a>
 ## 引用预览
 
-Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。
+Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。
 
 Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
 

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

@@ -38,8 +38,6 @@ 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']
 }
@@ -47,7 +45,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, openFile, t,
+  reasoningHidden = false, revealProcess, mentions, t,
 }: AssistantMarkdownProps) {
   // Stable per locale revision (t identity changes on switch): a fresh object
   // per render would rebuild MarkdownText's component table every chunk.
@@ -81,7 +79,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
             labels={labels}
             fileMentions={mentions}
             pathImages={pathImages}
-            openFile={openFile}
           />,
         )
         break

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

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

+ 1 - 1
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -782,7 +782,7 @@ export function ChatView({
               </button>
             </div>
           )}
-          <MarkdownDelegateProvider openExternalLink={openExternalLink}>
+          <MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile}>
             <ChatNodeList
               order={order}
               useChatNode={useChatNode}

+ 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: a3966f87bde8ac30d4c71bc41020c09de3ce9455
-README.zh.md: 739aa4b6163b83de7b435e689b6556c25b729144
+README.md: 8a2fbc1669dad0eea0dc8da95b5d48365b3b708d
+README.zh.md: 72a829b39b23bc9ed5cef0311aae2a24bb92fc5f

+ 1 - 1
packages/client/ui-primitives/README.md

@@ -72,7 +72,7 @@ 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. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. 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.
+The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `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. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. 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. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. 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, source-only rendering 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.
 

+ 1 - 1
packages/client/ui-primitives/README.zh.md

@@ -72,7 +72,7 @@ kind: "package-library"
 
 ### 渲染 agent 输出
 
-传入 `openFile` 后,本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
+最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
 
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 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 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 

+ 1 - 1
packages/client/ui-primitives/src/index.ts

@@ -71,7 +71,7 @@ export type {
 } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
 export { MarkdownDelegateProvider } from './markdown/MarkdownDelegate.tsx'
-export type { MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx'
+export type { MarkdownDelegate, MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'
 export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './markdown/MarkdownText.tsx'
 export { extractMarkdownPlainText } from './markdown/plain-text.ts'

+ 27 - 10
packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx

@@ -1,5 +1,5 @@
-/** Consumer-owned navigation for ordinary Markdown HTTP(S) link activation. */
-import { createContext, useContext } from 'react'
+/** Consumer-owned navigation for Markdown links. */
+import { createContext, useContext, useMemo } from 'react'
 import type { ReactNode } from 'react'
 
 /**
@@ -8,31 +8,48 @@ import type { ReactNode } from 'react'
  */
 export type MarkdownExternalLinkHandler = (href: string) => void
 
-const MarkdownDelegateContext = createContext<MarkdownExternalLinkHandler | undefined>(undefined)
+/** Navigation capabilities supplied by the nearest Markdown owner. */
+export interface MarkdownDelegate {
+  /** Ordinary HTTP(S) activation; absent handlers retain native anchor behavior. */
+  readonly openExternalLink?: MarkdownExternalLinkHandler | undefined
+  /**
+   * Open a decoded local destination from settled Markdown; absent handlers leave plain text.
+   * @param path - Absolute or workspace-relative file path.
+   * @param options - First line to reveal when the destination specifies a line or range.
+   */
+  readonly openFile?: ((path: string, options?: { line?: number }) => void) | undefined
+}
+
+const MarkdownDelegateContext = createContext<MarkdownDelegate>({})
 
 /** Props for one Markdown navigation scope. */
-export interface MarkdownDelegateProviderProps {
+export interface MarkdownDelegateProviderProps extends MarkdownDelegate {
   readonly children: ReactNode
-  readonly openExternalLink: MarkdownExternalLinkHandler
 }
 
 /**
- * Delegate ordinary Markdown HTTP(S) activation without threading callbacks through renderers.
- * @param props - child tree and its link handler.
+ * Scope Markdown navigation without threading callbacks through renderers.
+ * Nested providers replace the enclosing capabilities. Handler changes reach cached links.
+ * @param props - Child tree and its file and HTTP(S) link handlers.
  * @returns the scoped child tree.
  */
 export function MarkdownDelegateProvider({
   children,
   openExternalLink,
+  openFile,
 }: MarkdownDelegateProviderProps): ReactNode {
+  const delegate = useMemo(() => ({ openExternalLink, openFile }), [openExternalLink, openFile])
   return (
-    <MarkdownDelegateContext.Provider value={openExternalLink}>
+    <MarkdownDelegateContext.Provider value={delegate}>
       {children}
     </MarkdownDelegateContext.Provider>
   )
 }
 
-/** Read the nearest optional Markdown HTTP(S) navigation delegate. */
-export function useMarkdownExternalLinkDelegate(): MarkdownExternalLinkHandler | undefined {
+/**
+ * Read the nearest Markdown navigation capabilities.
+ * @returns Owner callbacks, or an empty delegate outside a provider.
+ */
+export function useMarkdownDelegate(): MarkdownDelegate {
   return useContext(MarkdownDelegateContext)
 }

+ 5 - 8
packages/client/ui-primitives/src/markdown/MarkdownText.tsx

@@ -36,7 +36,6 @@ function renderSettled(
   previews: FencePreviewCatalog | undefined,
   fileMentions: MarkdownFileMentions | undefined,
   pathImages: MarkdownPathImages | undefined,
-  openFile: MarkdownRenderContext['openFile'],
 ): ReactNode[] {
   const root = parseGfmWithMath(text)
   const targets = createReferenceTargets()
@@ -47,7 +46,6 @@ function renderSettled(
     previews,
     fileMentions,
     pathImages,
-    openFile,
     targets,
     footnoteOrder: [],
     footnoteCounts: new Map(),
@@ -178,21 +176,20 @@ class StreamingRenderer {
  * modified clicks retain native behavior. `variant="compact"` uses secondary
  * text sizing, uniform bold headings, and tight block spacing; the default
  * `body` variant uses the full document typography.
- * `openFile` enables local Markdown links in settled messages, including
- * `#L24` and `#L24-L30` destinations (ranges open at their first line).
+ * The provider's `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, openFile, variant = 'body',
+  text, streaming = false, labels, fileMentions, pathImages, variant = 'body',
 }: {
   text: string
   streaming?: boolean
   labels: MarkdownLabels
   fileMentions?: MarkdownFileMentions | undefined
   pathImages?: MarkdownPathImages | undefined
-  openFile?: MarkdownRenderContext['openFile']
   variant?: 'body' | 'compact'
 }) {
   const streamRef = useRef<StreamingRenderer | null>(null)
@@ -201,14 +198,14 @@ export const MarkdownText = memo(function MarkdownText({
   const children = useMemo(() => {
     if (!streaming) {
       streamRef.current = null
-      return renderSettled(text, labels, previews, fileMentions, pathImages, openFile)
+      return renderSettled(text, labels, previews, fileMentions, pathImages)
     }
     if (streamRef.current === null || streamLabelsRef.current !== labels) {
       streamRef.current = new StreamingRenderer(labels, previews)
       streamLabelsRef.current = labels
     }
     return streamRef.current.render(text)
-  }, [text, streaming, labels, previews, fileMentions, pathImages, openFile])
+  }, [text, streaming, labels, previews, fileMentions, pathImages])
   return <div className={clsx(css.markdown, variant === 'compact' && css.compact)}
     data-markdown-variant={variant === 'compact' ? variant : undefined}>{children}</div>
 })

+ 29 - 22
packages/client/ui-primitives/src/markdown/render.tsx

@@ -31,7 +31,7 @@ import { renderMermaid } from './mermaid.ts'
 import type { PreviewLabels } from './SourcePreview.tsx'
 import { renderTexToReact } from './katex.tsx'
 import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx'
-import { useMarkdownExternalLinkDelegate } from './MarkdownDelegate.tsx'
+import { useMarkdownDelegate } from './MarkdownDelegate.tsx'
 import type { PositionedBlock } from './incremental.ts'
 import css from './MarkdownText.module.css'
 
@@ -233,8 +233,6 @@ 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. */
@@ -403,7 +401,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
     case 'link':
       return renderAnchor(
         node.url, renderChildren(node.children, { ...context, inLink: true }), key,
-        !anchorWrapsOnlyImages(node.children), context.openFile,
+        !anchorWrapsOnlyImages(node.children), context.streaming,
       )
     case 'linkReference':
       return renderLinkReference(node, key, context)
@@ -614,7 +612,7 @@ function MarkdownAnchor({ href, glyph, children }: {
   readonly glyph: boolean
   readonly children: ReactNode[]
 }): ReactNode {
-  const openExternalLink = useMarkdownExternalLinkDelegate()
+  const { openExternalLink } = useMarkdownDelegate()
   const external = ['http:', 'https:'].includes(new URL(href).protocol)
   const open = external ? openExternalLink : undefined
   return (
@@ -633,26 +631,35 @@ function MarkdownAnchor({ href, glyph, children }: {
   )
 }
 
-/** 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={clsx(css.fileMention, css.fileLink)}
-        title={file.path}
-        onClick={() => { openFile(file.path, file.line === undefined ? undefined : { line: file.line }) }}
-      >
-        {glyph && <LinkIcon kind={classifyLinkPath(file.path)} className={css.linkIcon} />}
-        {children}
-      </button>
-    )
+/** Local destinations use the scoped file delegate after settlement. */
+function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true, streaming = false): ReactNode {
+  const file = streaming ? undefined : parseFileLink(url)
+  if (file !== undefined) {
+    return <MarkdownFileLink key={key} file={file} glyph={glyph}>{children}</MarkdownFileLink>
   }
   return renderSafeLink(normalizeUri(url), children, key, glyph)
 }
 
+function MarkdownFileLink({ file, glyph, children }: {
+  readonly file: { path: string; line?: number }
+  readonly glyph: boolean
+  readonly children: ReactNode[]
+}): ReactNode {
+  const { openFile } = useMarkdownDelegate()
+  if (openFile === undefined) return <>{children}</>
+  return (
+    <button
+      type="button"
+      className={clsx(css.fileMention, css.fileLink)}
+      title={file.path}
+      onClick={() => { openFile(file.path, file.line === undefined ? undefined : { line: file.line }) }}
+    >
+      {glyph && <LinkIcon kind={classifyLinkPath(file.path)} className={css.linkIcon} />}
+      {children}
+    </button>
+  )
+}
+
 /**
  * The complete inline-code value when it is exactly an absolute HTTP(S) URL
  * (no surrounding whitespace); anything else stays inert code.
@@ -714,7 +721,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), context.openFile)
+  return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children), context.streaming)
 }
 
 function renderImageReference(

+ 85 - 12
packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx

@@ -1,6 +1,7 @@
 // @vitest-environment jsdom
 import { cleanup, fireEvent, render } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
+import { MarkdownDelegateProvider } from '../src/index.ts'
 import { MarkdownText } from './markdown-test-components.tsx'
 
 afterEach(cleanup)
@@ -16,7 +17,11 @@ describe('Markdown file links', () => {
     ['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 view = render(
+      <MarkdownDelegateProvider openFile={openFile}>
+        <MarkdownText text={`[source](${target})`} />
+      </MarkdownDelegateProvider>,
+    )
     const link = view.getByRole('button', { name: 'source' })
     expect(link.getAttribute('title')).toBe(path)
     fireEvent.click(link)
@@ -25,23 +30,34 @@ describe('Markdown file links', () => {
   })
 
   it.each(['', '![](https://example.com/image.png)'])('names an empty label %s with the decoded path', (label) => {
-    const view = render(<MarkdownText text={`[${label}](docs/My%20Notes.md)`} openFile={vi.fn()} />)
+    const view = render(
+      <MarkdownDelegateProvider openFile={vi.fn()}>
+        <MarkdownText text={`[${label}](docs/My%20Notes.md)`} />
+      </MarkdownDelegateProvider>,
+    )
     expect(view.getByRole('button', { name: 'docs/My Notes.md' })).toBeTruthy()
   })
 
   it('preserves an image label’s alternative text as the accessible name', () => {
-    const view = render(<MarkdownText text={'[![diagram](https://example.com/image.png)](src/a.ts)'} openFile={vi.fn()} />)
+    const view = render(
+      <MarkdownDelegateProvider openFile={vi.fn()}>
+        <MarkdownText text={'[![diagram](https://example.com/image.png)](src/a.ts)'} />
+      </MarkdownDelegateProvider>,
+    )
     expect(view.getByRole('button', { name: 'diagram' })).toBeTruthy()
   })
 
   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 }}
-    />)
+    const view = render(
+      <MarkdownDelegateProvider openFile={openFile}>
+        <MarkdownText
+          text={'[see `index.ts`][source]\n\n[source]: src/index.ts#L3-L3'}
+          fileMentions={{ resolve }}
+        />
+      </MarkdownDelegateProvider>,
+    )
     fireEvent.click(view.getByRole('button', { name: 'see index.ts' }))
     expect(openFile).toHaveBeenCalledWith('src/index.ts', { line: 3 })
     expect(resolve).not.toHaveBeenCalled()
@@ -56,7 +72,11 @@ describe('Markdown file links', () => {
     '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} />)
+    const view = render(
+      <MarkdownDelegateProvider openFile={openFile}>
+        <MarkdownText text={`[source](${target})`} />
+      </MarkdownDelegateProvider>,
+    )
     expect(view.getByText('source')).toBeTruthy()
     expect(view.container.querySelector('button, a')).toBeNull()
     expect(openFile).not.toHaveBeenCalled()
@@ -73,14 +93,67 @@ describe('Markdown file links', () => {
     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} />)
+    const view = render(
+      <MarkdownDelegateProvider openFile={first}>
+        <MarkdownText text={text} streaming />
+      </MarkdownDelegateProvider>,
+    )
     expect(view.queryByRole('button')).toBeNull()
-    view.rerender(<MarkdownText text={text} openFile={first} />)
+    view.rerender(
+      <MarkdownDelegateProvider openFile={first}>
+        <MarkdownText text={text} />
+      </MarkdownDelegateProvider>,
+    )
     fireEvent.click(view.getByRole('button', { name: 'source' }))
     expect(first).toHaveBeenCalledOnce()
-    view.rerender(<MarkdownText text={text} openFile={second} />)
+    view.rerender(
+      <MarkdownDelegateProvider openFile={second}>
+        <MarkdownText text={text} />
+      </MarkdownDelegateProvider>,
+    )
     fireEvent.click(view.getByRole('button', { name: 'source' }))
     expect(second).toHaveBeenCalledOnce()
     expect(first).toHaveBeenCalledOnce()
   })
+
+  it('routes file and HTTP(S) links through the same provider', () => {
+    const openFile = vi.fn()
+    const openExternalLink = vi.fn()
+    const view = render(
+      <MarkdownDelegateProvider openFile={openFile} openExternalLink={openExternalLink}>
+        <MarkdownText text={'[source](src/a.ts#L4) [web](https://example.com)'} />
+      </MarkdownDelegateProvider>,
+    )
+    fireEvent.click(view.getByRole('button', { name: 'source' }))
+    expect(openFile).toHaveBeenCalledExactlyOnceWith('src/a.ts', { line: 4 })
+    expect(openExternalLink).not.toHaveBeenCalled()
+    fireEvent.click(view.getByRole('link', { name: 'web' }))
+    expect(openExternalLink).toHaveBeenCalledExactlyOnceWith('https://example.com')
+    expect(openFile).toHaveBeenCalledOnce()
+  })
+
+  it('updates cached links when the nearest provider gains, replaces, or removes its file handler', () => {
+    const outer = vi.fn()
+    const first = vi.fn()
+    const second = vi.fn()
+    const markdown = <MarkdownText text="[source](src/a.ts)" />
+    const scope = (openFile?: (path: string) => void) => (
+      <MarkdownDelegateProvider openFile={outer}>
+        <MarkdownDelegateProvider openFile={openFile}>{markdown}</MarkdownDelegateProvider>
+      </MarkdownDelegateProvider>
+    )
+    const view = render(scope())
+    expect(view.queryByRole('button')).toBeNull()
+    view.rerender(scope(first))
+    fireEvent.click(view.getByRole('button', { name: 'source' }))
+    expect(first).toHaveBeenCalledExactlyOnceWith('src/a.ts', undefined)
+    view.rerender(scope(second))
+    fireEvent.click(view.getByRole('button', { name: 'source' }))
+    expect(second).toHaveBeenCalledExactlyOnceWith('src/a.ts', undefined)
+    expect(first).toHaveBeenCalledOnce()
+    view.rerender(scope())
+    expect(view.queryByRole('button')).toBeNull()
+    expect(view.getByText('source')).toBeTruthy()
+    expect(outer).not.toHaveBeenCalled()
+  })
 })