diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml
index 45af01e0ff..182ff386d9 100644
--- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml
+++ b/.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
diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md
index 0114e8cf3a..d9d535afc5 100644
--- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md
+++ b/.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.
diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md
index f872abb18c..63c18e284b 100644
--- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md
+++ b/.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 中的指导。
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 81c74cefb6..7b0bcb7ce2 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/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
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index 5ccbb29d98..0d4e3376ff 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -30,7 +30,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
## 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.
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index 4013f28940..2494e932e1 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -30,7 +30,7 @@ kind: "package-reference"
## 引用预览
-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 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx
index 44d2f1f558..3212801e00 100644
--- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx
+++ b/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
diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx
index abdbc89665..399897a9b5 100644
--- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx
+++ b/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}
/>
)
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index 3ad01f83b3..4c407c8e8a 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -782,7 +782,7 @@ export function ChatView({
)}
-
+ void
-const MarkdownDelegateContext = createContext(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({})
/** 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 (
-
+
{children}
)
}
-/** 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)
}
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
index 67e0ac8980..2977db6042 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
+++ b/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(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