Bläddra i källkod

fix(web): handle pending and linked skill previews

Yichen Jiang 1 vecka sedan
förälder
incheckning
24e63e4555
30 ändrade filer med 213 tillägg och 74 borttagningar
  1. 2 2
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.i18n.yaml
  2. 1 1
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.md
  3. 1 1
      .agents/notes/implemented/feature/2026-09-10-composer-reference-previews.zh.md
  4. 10 3
      apps/web/tests/skill-invocation-policy.e2e.ts
  5. 1 1
      apps/web/tests/skill-user-invoke.e2e.ts
  6. 3 0
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  7. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  8. 1 1
      packages/client/ui-conversation/README.md
  9. 1 1
      packages/client/ui-conversation/README.zh.md
  10. 4 1
      packages/client/ui-conversation/tests/reference-activation.client.spec.ts
  11. 2 2
      packages/client/ui-input-trigger/README.i18n.yaml
  12. 1 1
      packages/client/ui-input-trigger/README.md
  13. 1 1
      packages/client/ui-input-trigger/README.zh.md
  14. 1 1
      packages/client/ui-input-trigger/src/client/controller.ts
  15. 1 1
      packages/client/ui-input-trigger/src/types.ts
  16. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  17. 1 1
      packages/client/ui-primitives/README.md
  18. 1 1
      packages/client/ui-primitives/README.zh.md
  19. 1 1
      packages/client/ui-primitives/src/user-text.tsx
  20. 4 2
      packages/client/ui-primitives/tests/user-text.client.spec.tsx
  21. 2 2
      packages/client/ui-skill/README.i18n.yaml
  22. 1 1
      packages/client/ui-skill/README.md
  23. 1 1
      packages/client/ui-skill/README.zh.md
  24. 23 9
      packages/client/ui-skill/src/client/index.ts
  25. 89 18
      packages/client/ui-skill/tests/browser-plugin.client.spec.ts
  26. 2 2
      packages/skill/skill-filesystem/README.i18n.yaml
  27. 2 0
      packages/skill/skill-filesystem/README.md
  28. 2 0
      packages/skill/skill-filesystem/README.zh.md
  29. 18 10
      packages/skill/skill-filesystem/src/index.ts
  30. 32 5
      packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts

+ 2 - 2
.agents/notes/implemented/feature/2026-09-10-composer-reference-previews.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-10-composer-reference-previews.md
-2026-09-10-composer-reference-previews.md: b89a438a386e811ad549a602cae366c9db1a7fc1
-2026-09-10-composer-reference-previews.zh.md: 6a4540c75d7f95f014fe5a1c3a357317bb3a7e32
+2026-09-10-composer-reference-previews.md: e9425851c1dc4ac0c8dc588d3b234c173eeb9262
+2026-09-10-composer-reference-previews.zh.md: c47718468bf1e38469140c12c1096b7bc5da0f40

+ 1 - 1
.agents/notes/implemented/feature/2026-09-10-composer-reference-previews.md

@@ -26,6 +26,6 @@ Sent message bubbles retain their logged skill-invocation evidence for decoratio
 
 ## Consequences
 
-Preview paths are transient discovery data, never added to Session messages. The skill plugin invalidates them with its existing per-Session catalog. Sidebar resource readers retain responsibility for current contents, missing-file errors, and access policy. The [workspace source-file decision](2026-09-08-present-workspace-source-files.md) remains the owner of delivered-file behavior; composer previews do not supersede it.
+Preview paths are transient discovery data, never added to Session messages. The skill plugin invalidates them with its existing per-Session catalog. An uncached click awaits the shared catalog fetch and retains its Session address; invalidation and disposal cancel pending previews. Filesystem providers publish resolved instruction paths while retaining discovered reload locators and resource bases. Sidebar resource readers retain responsibility for current contents, missing-file errors, and access policy. The [workspace source-file decision](2026-09-08-present-workspace-source-files.md) remains the owner of delivered-file behavior; composer previews do not supersede it.
 
 Focused tests cover source routing, disposal, invalidation, quoted paths, selection, and unchanged draft text. The real Web composition exercises both previews, equal hover backgrounds, and deletion after opening; owner-local expected output records the skill document and a sent message. A replayed skill-invocation turn verifies both sent references after reloading history.

+ 1 - 1
.agents/notes/implemented/feature/2026-09-10-composer-reference-previews.zh.md

@@ -26,6 +26,6 @@ Status: implemented
 
 ## Consequences
 
-预览路径是临时发现数据,不会加入 Session 消息。Skill 插件使用现有的按 Session 缓存机制使路径失效。侧栏资源读取器继续负责当前内容、文件缺失错误和访问策略。[工作区源文件决策](2026-09-08-present-workspace-source-files.zh.md)仍负责交付文件行为;输入框预览不取代该决策。
+预览路径是临时发现数据,不会加入 Session 消息。Skill 插件使用现有的按 Session 缓存机制使路径失效。缓存未就绪时,点击等待共享目录请求并保留所属 Session 地址;缓存失效和插件释放会取消待处理的预览。文件系统提供方公布解析后的指令路径,同时保留发现时的重新加载定位信息和资源根。侧栏资源读取器继续负责当前内容、文件缺失错误和访问策略。[工作区源文件决策](2026-09-08-present-workspace-source-files.zh.md)仍负责交付文件行为;输入框预览不取代该决策。
 
 针对性测试覆盖来源路由、释放、缓存失效、带引号路径、文本选择和草稿不变。真实 Web 组合验证两种预览、一致的悬停背景及打开后的删除操作;其所属的预期输出记录 skill 文档及已发送消息。回放的 skill 调用轮次验证刷新历史后两种已发送引用仍可预览。

+ 10 - 3
apps/web/tests/skill-invocation-policy.e2e.ts

@@ -3,7 +3,7 @@
 // with their marker while user-disabled quadrants stay hidden. A real
 // chromium connects a fresh workspace seeded with all four policy quadrants;
 // no model call is issued, so a stray stream fails loud on the open LLM seam.
-import { mkdir, writeFile } from 'node:fs/promises'
+import { mkdir, symlink, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
@@ -56,7 +56,8 @@ const SKILLS: readonly SeedSkill[] = [
 
 async function seedSkills(workspaceCwd: string): Promise<void> {
   for (const skill of SKILLS) {
-    const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', skill.name)
+    const root = join(workspaceCwd, 'workspace', '.agents', 'skills')
+    const directory = skill.name === 'policy-shared' ? join(workspaceCwd, 'linked-skills', skill.name) : join(root, skill.name)
     await mkdir(directory, { recursive: true })
     const policyLines = skill.frontmatter === '' ? [] : skill.frontmatter.trimEnd().split('\n')
     await writeFile(join(directory, 'SKILL.md'), [
@@ -69,6 +70,10 @@ async function seedSkills(workspaceCwd: string): Promise<void> {
       `# ${skill.name}`,
       '',
     ].join('\n'))
+    if (skill.name === 'policy-shared') {
+      await mkdir(root, { recursive: true })
+      await symlink(join(directory, 'SKILL.md'), join(root, `${skill.name}.md`))
+    }
   }
 }
 
@@ -174,8 +179,10 @@ describe('web e2e: skill invocation policy through the real host', () => {
     await expect.poll(() => preview.textContent()).toContain('Reference preview fixture.')
     expect(await input.textContent()).toBe(draft)
     await file.hover()
-    await skill.click()
+    await skill.dblclick()
     await expect.poll(() => preview.textContent()).toContain('policy-shared')
+    await expect.poll(() => page.evaluate(() => document.getSelection()?.toString())).not.toBe('')
+    expect(await input.textContent()).toBe(draft)
     await skill.hover()
     await expect.poll(() => skill.evaluate(el => getComputedStyle(el).backgroundColor)).toBe(skillBackground)
     expect(tripwire.pageErrors).toEqual([])

+ 1 - 1
apps/web/tests/skill-user-invoke.e2e.ts

@@ -182,7 +182,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
     expect(tripwire.warnings).toEqual([])
   })
 
-  it('keeps its snapshot inventory closed' , async () => {
+  it('keeps its snapshot inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'ui-expanded.expected.md'])
   })
 })

+ 3 - 0
packages/client/ui-chat/tests/apply-inject.client.spec.tsx

@@ -151,6 +151,9 @@ describe('Chat inject API', () => {
     injected.openSkill('review')
     expect(sessionOf).toHaveBeenCalledWith(b.runtime.sessions.scope(ROOT))
     expect(openReference).toHaveBeenCalledWith('skill', { ref: '/review' })
+    vi.spyOn(b.runtime.sessions, 'scope').mockReturnValueOnce(undefined)
+    injected.openSkill('review')
+    expect(openReference).toHaveBeenCalledTimes(1)
     await b.runtime.dispose()
   })
 

+ 2 - 2
packages/client/ui-conversation/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-conversation/README.md
-README.md: 6f666eac73b171052fea483ca2d42eacde86e971
-README.zh.md: 17952b200e8a9cb039d9f4bbcd886b97aa2b7098
+README.md: 6471419852b7fdd09539f5dbd669f1d24f06460e
+README.zh.md: c6fabbc1bb85ee5c6f2c6ca559feda59ef88aac2

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

@@ -52,7 +52,7 @@ Queued submission echoes show “Sending…” beside disabled edit, remove, and
 
 Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
 
-File chips and editable skill references share a whole-reference hover background and follow the composer's line height and text baseline. A single click delegates preview opening to the registered reference source; double-click and text-selection gestures retain editor handling. Previewing does not change the draft, its clipboard projection, or submission.
+File chips and editable skill references share a whole-reference hover background and follow the composer's line height and text baseline. The first click delegates preview opening to the registered reference source immediately, including the first click of a double-click sequence. Subsequent clicks retain native text selection; an existing noncollapsed selection suppresses pointer preview activation. Previewing does not change the draft, its clipboard projection, or submission.
 
 <a id="temporary-composer-entries"></a>
 ## Temporary composer entries

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

@@ -52,7 +52,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
 
 Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
 
-文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。单击由已注册的引用来源负责打开预览;双击和文本选择手势仍由编辑器处理。预览不改变草稿、剪贴板文本或提交内容。
+文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。
 
 <a id="temporary-composer-entries"></a>
 ## 临时 composer entry

+ 4 - 1
packages/client/ui-conversation/tests/reference-activation.client.spec.ts

@@ -48,9 +48,12 @@ describe('reference activation', () => {
     expect(open).toHaveBeenCalledTimes(2)
   })
 
-  it('leaves double clicks, text selections, invalid chips, and ordinary text to the editor', () => {
+  it('opens once in a double-click sequence and leaves selections, invalid chips, and ordinary text to the editor', () => {
     const { editor, root, chip, text, open, click } = bench()
+    click(text.getKey(), 1)
     click(text.getKey(), 2)
+    expect(open).toHaveBeenCalledTimes(1)
+    open.mockClear()
     root.dispatchEvent(new MouseEvent('click', { bubbles: true }))
     editor.update(() => { text.select(0, 3) }, { discrete: true })
     click(text.getKey())

+ 2 - 2
packages/client/ui-input-trigger/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-input-trigger/README.md
-README.md: 67146cc46705d9c8e2e019b5d261a12cb9ec8f07
-README.zh.md: f20972b6516c81341555db349e072d7d2c7eb6bf
+README.md: aa8919ed9aa0b883fef2bbd33e9bce36ffb0e535
+README.zh.md: cb552d9ade693bdd4a3a7e93d4ed87f0e217f8e6

+ 1 - 1
packages/client/ui-input-trigger/README.md

@@ -31,7 +31,7 @@ Mount this plugin alongside `ui-conversation`; the menu then appears in the inpu
 
 The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. Tab acts on the highlighted completion: a candidate declaring `drill: true` routes through `onPick` with `action: 'drill'`, while an ordinary candidate settles through `action: 'pick'`; without a highlight, Tab passes untouched so native focus traversal survives. A drillable row's trailing chevron exposes the same second verb to pointer users. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`.
 
-A source may implement `openReference(session, reference)` to open a draft reference without submitting it. Chips route by source name; editable tokens route through the current source lexicon. Returning `false`, a missing source, or a disposed controller leaves the editor gesture unchanged.
+A source may implement `openReference(session, reference)` to open a draft reference without submitting it. Acceptance may precede asynchronous catalog loading. Chips route by source name; editable tokens route through the current source lexicon. Returning `false`, a missing source, or a disposed controller leaves the editor gesture unchanged.
 
 -----
 

+ 1 - 1
packages/client/ui-input-trigger/README.zh.md

@@ -31,7 +31,7 @@ kind: "package-reference"
 
 菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。Tab 会作用于高亮补全项:声明 `drill: true` 的候选项以 `action: 'drill'` 进入 `onPick`,普通候选项则以 `action: 'pick'` 完成选定;没有高亮项时 Tab 原样放行,原生焦点遍历不受影响。可下钻行尾的 chevron 向指针用户提供同一个动词。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:流水线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。
 
-来源可以实现 `openReference(session, reference)`,打开草稿引用而不提交。标签按来源名称路由;可编辑文本按来源当前的词表路由。返回 `false`、来源缺失或控制器已释放时,保留编辑器原有的手势处理。
+来源可以实现 `openReference(session, reference)`,打开草稿引用而不提交。来源可以先接受预览请求,再异步加载目录。标签按来源名称路由;可编辑文本按来源当前的词表路由。返回 `false`、来源缺失或控制器已释放时,保留编辑器原有的手势处理。
 
 -----
 

+ 1 - 1
packages/client/ui-input-trigger/src/client/controller.ts

@@ -309,7 +309,7 @@ export class InputTriggerController {
    * Route a chip to its owner or an editable token to its current lexicon owner.
    * @param source - chip source name; undefined for editable text.
    * @param reference - source-owned id and optional chip glyph.
-   * @returns whether an owner opened the reference.
+   * @returns whether an owner accepted the preview, possibly awaiting its catalog.
    */
   openReference(source: string | undefined, reference: Pick<ReferenceInsert, 'ref' | 'appearance'>): boolean {
     if (this.disposed) return false

+ 1 - 1
packages/client/ui-input-trigger/src/types.ts

@@ -215,7 +215,7 @@ export interface InputTriggerSource {
    * Open a reference preview without changing or submitting the draft.
    * @param session - session owning the composer.
    * @param reference - source-owned id and optional chip glyph; text references retain their trigger.
-   * @returns whether this source opened the reference; false leaves the editor gesture unchanged.
+   * @returns whether this source accepted the preview, possibly awaiting its catalog; false leaves the editor gesture unchanged.
    */
   openReference?(session: ClientSessionContext, reference: Pick<ReferenceInsert, 'ref' | 'appearance'>): boolean
   /** Reference codec; required for sources producing insert outcomes. */

+ 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: 96e9f0936146bc239fad003ecee0434c2c448546
-README.zh.md: 8a7a713d9c6e4b9045e0848925fb09e4d81ebbb7
+README.md: c7536d946516e9626d1880244e879663d7565cd2
+README.zh.md: 0bba56e593662aed05394e918f570c83ad2d7ca3

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

@@ -71,7 +71,7 @@ The catalog above lists what each export is for; this section covers the behavio
 
 ### Rendering agent output
 
-`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; text selection does not open previews.
+`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.
 
 
 ### Localizing copy

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

@@ -71,7 +71,7 @@ kind: "package-library"
 
 ### 渲染 agent 输出
 
-`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 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;选择文本不会打开预览。
+`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 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 
 
 ### 本地化文案

+ 1 - 1
packages/client/ui-primitives/src/user-text.tsx

@@ -142,7 +142,7 @@ export function projectUserText(
         data-ref-chip={referenceKind ?? slashKind}
         title={label}
         onClick={(event) => {
-          if (event.detail > 1 || event.currentTarget.ownerDocument.getSelection()?.isCollapsed === false) return
+          if (event.detail > 1 || (event.detail !== 0 && event.currentTarget.ownerDocument.getSelection()?.isCollapsed === false)) return
           open()
         }}
       >

+ 4 - 2
packages/client/ui-primitives/tests/user-text.client.spec.tsx

@@ -132,11 +132,13 @@ describe('projectUserText', () => {
     range.selectNodeContents(button)
     selection.addRange(range)
     fireEvent.click(button, { detail: 1 })
-    selection.removeAllRanges()
-    fireEvent.click(button, { detail: 2 })
     expect(openFile).not.toHaveBeenCalled()
     fireEvent.click(button, { detail: 0 })
     expect(openFile).toHaveBeenCalledWith('notes.md')
+    selection.removeAllRanges()
+    openFile.mockClear()
+    fireEvent.click(button, { detail: 2 })
+    expect(openFile).not.toHaveBeenCalled()
   })
 
   it('renders undecorated text as one inline run', () => {

+ 2 - 2
packages/client/ui-skill/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-skill/README.md
-README.md: 9167b68068a09657db653675b4c1303bcf47dcfe
-README.zh.md: e3a9fd620c279f93390e9e536f6b66924de1050e
+README.md: a1e9e7047f102f2610160905727450833b540082
+README.zh.md: d7669b11fc7249c857e20f3c52f93b8bfd214281

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

@@ -35,7 +35,7 @@ Ordinary-session candidates come from the `skills/list` Remote; the host serves
 
 A collapsed row renders the skill glyph, `Skill` title, and requested skill name; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change.
 
-Hovering over `/name` highlights the entire reference. Clicking a known skill opens its provider-supplied `SKILL.md` path in the right Sidebar while keeping the token editable. Skills without a file path remain invocable but have no file preview; preset changes and connection resets invalidate cached preview paths with the catalog.
+Hovering over `/name` highlights the entire reference. Clicking a known skill opens its provider-supplied `SKILL.md` path in the right Sidebar while keeping the token editable. An uncached click shares the per-Session catalog fetch and opens when it completes, retaining the clicked Session address. Preset changes, connection resets, and plugin disposal cancel pending previews; a later click fetches the current catalog again. Skills without a file path remain invocable but have no file preview.
 
 -----
 

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

@@ -35,7 +35,7 @@ kind: "package-reference"
 
 收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断的调用使用警告状态。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。
 
-鼠标移入 `/name` 时,背景覆盖整个引用。点击已知 skill 会在右侧栏打开提供方给出的 `SKILL.md` 路径,同时保留文本的可编辑性。没有文件路径的 skill 仍可调用,但没有文件预览;切换预设或重置连接时,缓存的预览路径随目录一并失效
+鼠标移入 `/name` 时,背景覆盖整个引用。点击已知 skill 会在右侧栏打开提供方给出的 `SKILL.md` 路径,同时保留文本的可编辑性。缓存未就绪时,点击复用该 Session 的目录请求,在完成后按点击时的 Session 地址打开预览。切换预设、重置连接和插件释放会取消待处理的预览;后续点击重新获取当前目录。没有文件路径的 skill 仍可调用,但没有文件预览。
 
 -----
 

+ 23 - 9
packages/client/ui-skill/src/client/index.ts

@@ -95,13 +95,13 @@ export function apply(ctx: ClientContext): void {
     }
   }
 
-  const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
-    if (sessions.subagentAddress(sessionId) !== undefined) return Promise.resolve([])
+  const fetchCatalog = (sessionId: SessionId): CatalogFetch => {
     const existing = fetches.get(sessionId)
-    if (existing !== undefined) return existing.promise
+    if (existing !== undefined) return existing
     const abort = new AbortController()
     const promise = (async () => {
       const result = await skills.list({ sessionId }, abort.signal)
+      abort.signal.throwIfAborted()
       if (!result.ok) throw new Error(`skills/list failed: ${result.error.code}: ${result.error.message}`)
       return result.value.skills
     })()
@@ -118,7 +118,7 @@ export function apply(ctx: ClientContext): void {
         if (fetches.get(sessionId) === entry) fetches.delete(sessionId)
       },
     )
-    return promise
+    return entry
   }
 
   const invalidate = (key: SessionId): void => {
@@ -142,7 +142,8 @@ export function apply(ctx: ClientContext): void {
     name: 'skill',
     order: 2,
     async candidates(session, { query, signal }) {
-      const skills = await fetchCatalog(session.sessionId)
+      if (sessions.subagentAddress(session.sessionId) !== undefined) return []
+      const skills = await fetchCatalog(session.sessionId).promise
       // Superseded keystroke: the shared fetch stays warm, this caller yields.
       if (signal.aborted) return []
       // The same ranking as the command group of this menu: case-insensitive
@@ -158,7 +159,8 @@ export function apply(ctx: ClientContext): void {
     warm(session) {
       // Fire-and-forget scope-birth prewarm; the shared fetch reports
       // through candidates.
-      fetchCatalog(session.sessionId).catch(() => {})
+      if (sessions.subagentAddress(session.sessionId) !== undefined) return
+      fetchCatalog(session.sessionId).promise.catch(() => {})
     },
     lexicon(session) {
       return fetches.get(session.sessionId)?.settled?.map(skill => skill.name)
@@ -174,10 +176,22 @@ export function apply(ctx: ClientContext): void {
       }
     },
     openReference(session, { ref }) {
-      const path = fetches.get(session.sessionId)?.settled?.find(skill => `/${skill.name}` === ref)?.path
-      if (path === undefined) return false
+      if (sessions.subagentAddress(session.sessionId) !== undefined) return false
       const cwd = sessions.list.getSnapshot().byId[session.sessionId]?.cwd
-      ctx.sidebarRight.openResource(fileAddressFor(session.sessionId, cwd, path))
+      const open = (catalog: readonly SkillEntry[]): boolean => {
+        const path = catalog.find(skill => `/${skill.name}` === ref)?.path
+        if (path === undefined) return false
+        ctx.sidebarRight.openResource(fileAddressFor(session.sessionId, cwd, path))
+        return true
+      }
+      const settled = fetches.get(session.sessionId)?.settled
+      if (settled !== undefined) return open(settled)
+      const entry = fetchCatalog(session.sessionId)
+      void entry.promise.then((catalog) => {
+        if (!entry.abort.signal.aborted) open(catalog)
+      }).catch((error: unknown) => {
+        if (!entry.abort.signal.aborted) console.error('[ui-skill] reference preview failed:', error)
+      })
       return true
     },
     onPick({ candidate }) {

+ 89 - 18
packages/client/ui-skill/tests/browser-plugin.client.spec.ts

@@ -14,7 +14,7 @@
  * source's own contract.
  */
 import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
@@ -62,7 +62,8 @@ function providePresentation(ctx: Context): PresentationCapture {
 /** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */
 async function bench(list: ListFn, addressed?: SessionId) {
   const ctx = new Context()
-  ctx.provide('sidebarRight', { openResource: vi.fn() })
+  const openResource = vi.fn()
+  ctx.provide('sidebarRight', { openResource })
   let captured: InputTriggerSource | undefined
   ctx.provide('inputTriggers', { registerSource: (src: InputTriggerSource) => { captured = src; return () => {} } })
   ctx.provide('sessions', {
@@ -74,8 +75,9 @@ async function bench(list: ListFn, addressed?: SessionId) {
   const remote = new TestRemote(ctx, { skills: { list } })
   providePresentation(ctx)
   const fiber = ctx.plugin({ inject: [...inject], apply })
+  onTestFinished(async () => { await fiber.dispose() })
   await fiber.await()
-  return { ctx, source: captured!, remote, fiber }
+  return { ctx, source: captured!, remote, fiber, openResource }
 }
 
 const CATALOG: SkillRow[] = [
@@ -390,21 +392,90 @@ describe('user-only marking', () => {
 })
 
 describe('reference preview', () => {
-  it('opens the current session catalog path and refuses absent or invalidated paths', async () => {
-    const { ctx, source, fiber } = await bench(listOk([
-      { name: 'review', description: 'Review', path: '/skills/review/SKILL.md' },
-      { name: 'virtual', description: 'Virtual' },
-    ]))
-    const openResource = vi.spyOn(ctx.sidebarRight, 'openResource')
-    const session = { sessionId: sid('preview') }
-    expect(source.openReference?.(session, { ref: '/review' })).toBe(false)
-    await source.candidates(session, { query: '', position: 'inline', drilled: false, signal: new AbortController().signal })
-    expect(source.openReference?.(session, { ref: '/virtual' })).toBe(false)
-    expect(source.openReference?.(session, { ref: '/missing' })).toBe(false)
-    expect(source.openReference?.(session, { ref: '/review' })).toBe(true)
-    expect(openResource).toHaveBeenCalledWith('dsh-resource://file/session/preview//skills/review/SKILL.md')
+  const rows: SkillRow[] = [
+    { name: 'review', description: 'Review', path: '/skills/review/SKILL.md' },
+    { name: 'virtual', description: 'Virtual' },
+  ]
+
+  it('finishes the first click after a shared warm fetch and reloads after reconnect', async () => {
+    const gate = Promise.withResolvers<ListResult>()
+    const list = vi.fn<ListFn>().mockReturnValueOnce(gate.promise).mockImplementation(listOk(rows))
+    const { ctx, source, openResource } = await bench(list)
+    const session = proj('preview')
+    source.warm!(session)
+    expect(source.openReference!(session, { ref: '/review' })).toBe(true)
+    const candidates = source.candidates(session, req(''))
+    expect(openResource).not.toHaveBeenCalled()
+    expect(list).toHaveBeenCalledTimes(1)
+    gate.resolve({ ok: true, value: { skills: rows } })
+    await candidates
+    expect(openResource).toHaveBeenCalledExactlyOnceWith('dsh-resource://file/session/preview//skills/review/SKILL.md')
+    expect(source.openReference!(session, { ref: '/virtual' })).toBe(false)
+    expect(source.openReference!(session, { ref: '/missing' })).toBe(false)
+    expect(source.openReference!(session, { ref: '/review' })).toBe(true)
     ctx.emit('connection/reset')
-    expect(source.openReference?.(session, { ref: '/review' })).toBe(false)
-    await fiber.dispose()
+    expect(source.openReference!(session, { ref: '/review' })).toBe(true)
+    await source.candidates(session, req(''))
+    expect(list).toHaveBeenCalledTimes(2)
+    expect(openResource).toHaveBeenCalledTimes(3)
+  })
+
+  it('keeps pending clicks bound to their own Session when another Session opens', async () => {
+    const first = Promise.withResolvers<ListResult>()
+    const second = Promise.withResolvers<ListResult>()
+    const list = vi.fn<ListFn>().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
+    const { source, openResource } = await bench(list)
+    source.openReference!(proj('first'), { ref: '/review' })
+    const firstDone = source.candidates(proj('first'), req(''))
+    source.openReference!(proj('second'), { ref: '/review' })
+    const secondDone = source.candidates(proj('second'), req(''))
+    second.resolve({ ok: true, value: { skills: [{ ...rows[0]!, path: '/second/SKILL.md' }] } })
+    await secondDone
+    expect(openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/second//second/SKILL.md')
+    first.resolve({ ok: true, value: { skills: rows } })
+    await firstDone
+    expect(openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/first//skills/review/SKILL.md')
+    expect(list.mock.calls.map(([payload]) => payload)).toEqual([{ sessionId: 'first' }, { sessionId: 'second' }])
+  })
+
+  it.each(['reset', 'preset', 'dispose'] as const)('cancels a pending preview on %s even if the RPC completes late', async (reason) => {
+    const gate = Promise.withResolvers<ListResult>()
+    const { ctx, source, remote, fiber, openResource } = await bench(() => gate.promise)
+    const session = proj('preview')
+    const listener = vi.fn()
+    source.subscribeLexicon!(session, listener)
+    source.openReference!(session, { ref: '/review' })
+    const completion = expect(source.candidates(session, req(''))).rejects.toThrow()
+    if (reason === 'reset') ctx.emit('connection/reset')
+    else if (reason === 'preset') remote.emit('agent-preset/selected', [session.sessionId, 'minimal'])
+    else await fiber.dispose()
+    expect(listener).toHaveBeenCalledTimes(1)
+    gate.resolve({ ok: true, value: { skills: rows } })
+    await completion
+    expect(source.lexicon!(session)).toBeUndefined()
+    expect(listener).toHaveBeenCalledTimes(1)
+    expect(openResource).not.toHaveBeenCalled()
+  })
+
+  it('reports a failed preview fetch and allows the next click to retry', async () => {
+    const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+    onTestFinished(() => { error.mockRestore() })
+    const list = vi.fn<ListFn>().mockRejectedValueOnce(new Error('offline')).mockImplementation(listOk(rows))
+    const { source, openResource } = await bench(list)
+    const session = proj('preview')
+    source.openReference!(session, { ref: '/review' })
+    await vi.waitFor(() => { expect(error).toHaveBeenCalledWith('[ui-skill] reference preview failed:', expect.any(Error)) })
+    expect(openResource).not.toHaveBeenCalled()
+    source.openReference!(session, { ref: '/review' })
+    await source.candidates(session, req(''))
+    expect(openResource).toHaveBeenCalledOnce()
+  })
+
+  it('does not fetch or preview a skill from addressed subagent history', async () => {
+    const list = vi.fn(listOk(rows))
+    const { source, openResource } = await bench(list, sid('child'))
+    expect(source.openReference!(proj('child'), { ref: '/review' })).toBe(false)
+    expect(list).not.toHaveBeenCalled()
+    expect(openResource).not.toHaveBeenCalled()
   })
 })

+ 2 - 2
packages/skill/skill-filesystem/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/skill/skill-filesystem/README.md
-README.md: 808df61c0fa248ba02b4e2993e6cbe60ede35f7c
-README.zh.md: 61589037c061fb3ecb308022cf359ca562d0a7a6
+README.md: 55ec66938f53fc282a51f9bf140ed2c999a937b7
+README.zh.md: f5ffb1f60b3fd908077038ae9479661266cdd160

+ 2 - 0
packages/skill/skill-filesystem/README.md

@@ -37,6 +37,8 @@ A skill is either a directory bundle `<name>/SKILL.md` or a flat file `<name>.md
 
 `disable-model-invocation: true` keeps the skill out of model-facing catalogs and loaders; `user-invocable: false` keeps it out of human-facing commands, and omitted fields default to permitting their surface. The two keys accept YAML booleans plus the case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0` forms; a rejected spelling or a non-boolean value drops the whole skill with a warning rather than silently permitting a surface.
 
+Catalog entries and loaded skills expose the resolved instruction-file path, so symlinked directories and flat files can open as regular-file previews. Reload locators and resource bases retain the discovered paths, including symlinks.
+
 The catalog and the body have separate lifecycles: discovery parses frontmatter into the catalog entry, and every load re-reads the current file, so editing a skill body needs no versioning or cache invalidation.
 
 ### Roots and priority

+ 2 - 0
packages/skill/skill-filesystem/README.zh.md

@@ -37,6 +37,8 @@ skill 可以是被扫描根目录顶层的目录 bundle `<name>/SKILL.md`,也
 
 `disable-model-invocation: true` 会把 skill 从面向模型的目录和 loader 中排除;`user-invocable: false` 会把它从面向用户的命令中排除,省略的字段默认允许对应接口调用。这两个键接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0` 形式;被拒绝的拼写或非布尔值会让整个 skill 随警告一起被丢弃,而不会静默允许某个接口。
 
+目录条目和已加载 skill 提供解析后的指令文件路径,使符号链接目录和扁平文件都能作为普通文件预览。重新加载的定位信息和资源根保留发现时的路径,包括符号链接。
+
 目录与正文具有独立的生命周期:发现阶段把 frontmatter 解析进目录条目,每次加载都会重新读取当前文件,因此编辑 skill 正文无需版本化或缓存失效。
 
 ### 根目录与优先级

+ 18 - 10
packages/skill/skill-filesystem/src/index.ts

@@ -9,7 +9,7 @@
  * @module @deepseek-ai/dsh-skill-filesystem
  */
 
-import { access, lstat, readdir, readFile, stat } from 'node:fs/promises'
+import { access, lstat, readdir, readFile, realpath, stat } from 'node:fs/promises'
 import { unwatchFile, watchFile, type Stats } from 'node:fs'
 import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
 import { homedir } from 'node:os'
@@ -103,13 +103,17 @@ interface SkillRootEntry {
   path: string
 }
 
-interface ParsedSkill {
+interface SkillText {
+  path: string
+  content: string
+}
+
+interface ParsedSkill extends SkillText {
   name: string
   description: string
   whenToUse?: string
   invocation: SkillInvocationPolicy
   metadata?: Record<string, unknown>
-  content: string
 }
 
 interface LocalLocator {
@@ -215,7 +219,7 @@ export class FileSystemSkillProvider implements SkillProvider {
       source: candidate.source,
       provider: this.name,
       resourceBase: { kind: 'directory', path: locator.directory },
-      path: locator.path,
+      path: parsed.path,
       ...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
       content: parsed.content,
     }
@@ -739,7 +743,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context, provider: string): Pr
       rank: root.rank,
       locator,
       resourceBase: { kind: 'directory', path: locator.directory },
-      path: locator.path,
+      path: parsed.path,
       ...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
     })
   }
@@ -798,7 +802,7 @@ async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal,
   }
   let parsed
   try {
-    parsed = parseFrontmatter(raw)
+    parsed = parseFrontmatter(raw.content)
   } catch (error) {
     ctx.logger.warn(`skill file ${path} ignored: invalid YAML frontmatter: ${errorMessage(error)}`)
     return undefined
@@ -830,6 +834,7 @@ async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal,
     ...optionalString(parsed.data, 'whenToUse'),
     invocation,
     ...optionalMetadata(parsed.data),
+    path: raw.path,
     content: parsed.body.trim(),
   }
 }
@@ -838,14 +843,15 @@ function optionalFileSystem(ctx: Context): FileSystem | undefined {
   return ctx.get('fs')
 }
 
-async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, trustedHost = false): Promise<string | undefined> {
+async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, trustedHost = false): Promise<SkillText | undefined> {
   signal?.throwIfAborted()
   const fs = optionalFileSystem(ctx)
   if (fs !== undefined && !trustedHost) {
     return await readSkillTextFromFileSystem(ctx, fs, path, signal)
   }
   try {
-    return await readFile(path, { encoding: 'utf8', signal })
+    const resolvedPath = await realpath(path)
+    return { path: resolvedPath, content: await readFile(resolvedPath, { encoding: 'utf8', signal }) }
   } catch (error) {
     signal?.throwIfAborted()
     if (isAbsentSkillPathError(error)) return undefined
@@ -853,7 +859,9 @@ async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, t
   }
 }
 
-async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string, signal?: AbortSignal): Promise<string | undefined> {
+async function readSkillTextFromFileSystem(
+  ctx: Context, fs: FileSystem, path: string, signal?: AbortSignal,
+): Promise<SkillText | undefined> {
   // A missing or temporarily inaccessible skill file is not fatal to discovery.
   signal?.throwIfAborted()
   let target
@@ -874,7 +882,7 @@ async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: s
   }
   if (info === undefined || info.type !== 'file') return undefined
   try {
-    return await fs.readText(target, signal)
+    return { path: fs.processPath(target), content: await fs.readText(target, signal) }
   } catch (error) {
     signal?.throwIfAborted()
     if (isAbsentSkillPathError(error)) return undefined

+ 32 - 5
packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts

@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, it } from 'vitest'
-import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
+import { lstat, mkdir, readdir, readFile, realpath, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
 import { dirname, join } from 'node:path'
 import { tmpdir } from 'node:os'
 import { Context } from '@deepseek-ai/cordis'
@@ -16,7 +16,7 @@ afterEach(async () => {
 async function tempDir(name: string): Promise<string> {
   const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
   tempDirs.push(dir)
-  return dir
+  return await realpath(dir)
 }
 
 async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise<void> {
@@ -418,7 +418,7 @@ describe('FileSystemSkillProvider', () => {
     expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
   })
 
-  it('discovers symlinked skill directories and flat files', async () => {
+  it.each([false, true])('publishes regular-file paths for linked skills while retaining their resource roots (filesystem service: %s)', async (withFileSystem) => {
     const home = await tempDir('skill-symlink-home')
     const external = await tempDir('skill-symlink-external')
     await writeSkill(external, 'linked-dir', 'Linked directory')
@@ -429,9 +429,36 @@ describe('FileSystemSkillProvider', () => {
     await symlink(join(external, 'missing'), join(home, '.dsh/skills/broken-link'))
     await symlink('/dev/null', join(home, '.dsh/skills/device-link'))
 
-    const ctx = await setupLocal(home)
+    const ctx = new Context()
+    await ctx.plugin(SkillRegistry)
+    if (withFileSystem) {
+      await ctx.plugin(class extends TestFileSystem {
+        override async resolve(path: string): Promise<FsTarget> {
+          return { targetKey: await realpath(path) as never, displayPath: path }
+        }
+      })
+    }
+    const fiber = ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
+    await fiber
 
-    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat'])
+    try {
+      const catalog = await ctx.skills.list()
+      expect(catalog.map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat'])
+      for (const name of ['linked-dir', 'linked-flat']) {
+        const path = name === 'linked-dir' ? join(external, name, 'SKILL.md') : join(external, `${name}.md`)
+        expect(catalog.find(skill => skill.name === name)?.path).toBe(path)
+        const loaded = await ctx.skills.get(name)
+        expect(loaded?.path).toBe(path)
+        expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: name === 'linked-dir' ? join(home, '.dsh/skills', name) : join(home, '.dsh/skills') })
+        expect((await lstat(path)).isFile()).toBe(true)
+      }
+      await writeFile(join(external, 'replacement.md'), '---\nname: linked-flat\ndescription: Replacement\n---\n\nReplacement body.\n')
+      await rm(join(home, '.dsh/skills/linked-flat.md'))
+      await symlink(join(external, 'replacement.md'), join(home, '.dsh/skills/linked-flat.md'))
+      expect((await ctx.skills.get('linked-flat'))?.content).toBe('Replacement body.')
+    } finally {
+      await fiber.dispose()
+    }
   })
 
   it('uses the filesystem service for discovery, reads, and project-root lookup', async () => {