diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml
index 5ab066c127..c27ef04676 100644
--- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.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/architecture/2026-09-11-node-office-kit.md
-2026-09-11-node-office-kit.md: d77d6c5392532f39a9993af60faad6474bc8f36c
-2026-09-11-node-office-kit.zh.md: a0cbd22e4f06bd4f2878adf27852f9d2378b8b33
+2026-09-11-node-office-kit.md: 6cab947d7cb8d4c2c416a83f0b765813c7f98ea9
+2026-09-11-node-office-kit.zh.md: 8ed1a81922249cf0b9c1a74b06e5ce9d44696e0a
diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
index d77d6c5392..6cab947d7c 100644
--- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
+++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
@@ -22,7 +22,7 @@ The kit owns default serif, sans-serif, and monospace preference groups, includi
DSH exports raster images at configurable resolution, defaulting to 192 DPI for the shared PDF canvas's 96 CSS DPI at device-pixel ratio 2. Text and vectors remain scalable; explicit bookmark export preserves the engine default when JSON filter options replace it. Node WASM downscales images with LibreOffice's CPU filter. Native conversion uses its separate platform engine.
-The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and appear in a dismissible notice above the Office scrollport; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values.
+The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and are available from the document toolbar’s warning icon; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values.
Ordinary file reads and Office responses share `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory.
diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md
index a0cbd22e4f..8ed1a81922 100644
--- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md
@@ -22,7 +22,7 @@ kit 维护 serif、sans-serif 和 monospace 的默认优先组,其中包含中
DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF 画布在设备像素比 2 时的 96 CSS DPI。文本与矢量仍可缩放;JSON 过滤选项替代隐式选项时,显式书签导出保留引擎默认行为。Node WASM 使用 LibreOffice 的 CPU 过滤器降采样图片。原生转换使用独立的平台引擎。
-[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,在 Office 滚动区上方显示可关闭的提示;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。
+[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,可通过文档工具栏的警告图标查看;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。
普通文件读取与 Office 响应共享 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。
diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts
index bee048c828..7e2d43331c 100644
--- a/apps/web/tests/document-preview.e2e.ts
+++ b/apps/web/tests/document-preview.e2e.ts
@@ -752,44 +752,61 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
await preview.getByRole('button', { name: 'Read the file again', exact: true }).click()
await canvas.waitFor({ state: 'visible' })
expect(convert).toHaveBeenCalledTimes(1)
- const notice = preview.locator('[data-office-font-notice]')
- const more = notice.getByRole('button', { name: 'Show more', exact: true })
- await more.waitFor({ state: 'visible' })
+ const warning = preview.locator('[data-office-font-warning]').getByRole('button')
+ await warning.waitFor({ state: 'visible' })
+ expect(await warning.getAttribute('aria-expanded')).toBe('false')
+ expect(await page.getByRole('dialog', { name: 'Missing fonts', exact: true }).count()).toBe(0)
+ const warningBox = (await warning.boundingBox())!
+ const reload = preview.getByRole('button', { name: 'Read the file again', exact: true })
+ const reloadBox = (await reload.boundingBox())!
+ expect(warningBox.x + warningBox.width).toBeLessThanOrEqual(reloadBox.x)
+ expect(Math.abs(warningBox.y + warningBox.height / 2 - reloadBox.y - reloadBox.height / 2)).toBeLessThan(1)
+ expect([warningBox.width, warningBox.height]).toEqual([reloadBox.width, reloadBox.height])
+ expect(await warning.evaluate(node => getComputedStyle(node).borderRadius))
+ .toBe(await reload.evaluate(node => getComputedStyle(node).borderRadius))
+ expect(await warning.locator('svg').evaluate(node => node.getBoundingClientRect().width))
+ .toBe(await reload.locator('svg').evaluate(node => node.getBoundingClientRect().width))
+ const warningColor = await warning.evaluate(node => getComputedStyle(node).color)
+ await warning.hover()
+ expect(await warning.evaluate(node => getComputedStyle(node).color)).toBe(warningColor)
+ await page.getByRole('tooltip', { name: /Missing fonts:/ }).waitFor({ state: 'visible' })
const before = await canvas.evaluate(node => node.getBoundingClientRect().top)
- await more.click()
+ await successShot(page, 'office-font-warning')
+ await warning.click()
const details = page.getByRole('dialog', { name: 'Missing fonts', exact: true })
await details.getByText('DSH Missing Preview Font', { exact: true }).waitFor({ state: 'visible' })
+ expect(await page.getByRole('tooltip', { name: /Missing fonts:/ }).count()).toBe(0)
await successShot(page, 'office-font-details')
await page.keyboard.press('Escape')
await expect.poll(() => details.count()).toBe(0)
- expect(await more.evaluate(node => node === document.activeElement)).toBe(true)
- await more.click()
+ expect(await warning.evaluate(node => node === document.activeElement)).toBe(true)
+ await warning.click()
await page.getByRole('button', { name: 'Close font details', exact: true }).click()
- expect(await more.isVisible()).toBe(true)
- await notice.getByRole('button', { name: 'Dismiss font notice', exact: true }).click()
- await expect.poll(() => notice.evaluate(node => node.getBoundingClientRect().height)).toBe(0)
+ expect(await warning.isVisible()).toBe(true)
const after = await canvas.evaluate(node => node.getBoundingClientRect().top)
- expect(before - after).toBeGreaterThan(40)
+ expect(after).toBe(before)
const topInset = await preview.evaluate((node) => {
const body = node.querySelector('[data-textpreview-body]')!.getBoundingClientRect()
const canvas = node.querySelector('canvas')!.getBoundingClientRect()
return canvas.top - body.top
})
expect(topInset).toBe(0)
- await successShot(page, 'office-font-dismissed')
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/office-font-notice.md', import.meta.url)), [
- '# Office font notice', '',
+ '# Office font warning', '',
+ '- Warning precedes reload in the same toolbar: true',
+ '- Warning and reload share button geometry and icon size: true',
+ '- Details open only on request: true',
'- Requested absent family is listed: true',
- '- Escape restores focus to Show more: true',
- '- Closing details preserves the notice: true',
- '- Dismissing the notice collapses its occupied height: 0',
- `- Document top inset after dismissal: ${topInset}px`,
+ '- Escape restores focus to the warning: true',
+ '- Closing details preserves the warning and document position: true',
+ `- Document top inset: ${topInset}px`,
].join('\n'), MODE)
await successShot(page, 'office-docx')
for (const extension of ['doc', 'xls', 'xlsx', 'ppt', 'pptx']) {
await openPreviewFile(column, filesTab, preview, `chinese.${extension}`)
await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 })
await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档')
+ if (['doc', 'xls', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0)
await successShot(page, `office-${extension}`)
}
expect(convert).toHaveBeenCalledTimes(6)
diff --git a/apps/web/tests/expected/office-font-notice.md b/apps/web/tests/expected/office-font-notice.md
index 4de2908510..21b132dd58 100644
--- a/apps/web/tests/expected/office-font-notice.md
+++ b/apps/web/tests/expected/office-font-notice.md
@@ -1,7 +1,9 @@
-# Office font notice
+# Office font warning
+- Warning precedes reload in the same toolbar: true
+- Warning and reload share button geometry and icon size: true
+- Details open only on request: true
- Requested absent family is listed: true
-- Escape restores focus to Show more: true
-- Closing details preserves the notice: true
-- Dismissing the notice collapses its occupied height: 0
-- Document top inset after dismissal: 0px
+- Escape restores focus to the warning: true
+- Closing details preserves the warning and document position: true
+- Document top inset: 0px
diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml
index 8b23e7fe7c..2d2a10c137 100644
--- a/docs/subsystems/sidebar-right.i18n.yaml
+++ b/docs/subsystems/sidebar-right.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 docs/subsystems/sidebar-right.md
-sidebar-right.md: a6478e10f19bc11f2a8bfa89d6213a85ffd76ffc
-sidebar-right.zh.md: af23b93945ce714cf4f0485752d8e07f155055c2
+sidebar-right.md: 663139313edb3b2341874c05030faa8c26998944
+sidebar-right.zh.md: bdab35cb88bb62c33e447b67592858c0c92f7a4c
diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md
index a6478e10f1..663139313e 100644
--- a/docs/subsystems/sidebar-right.md
+++ b/docs/subsystems/sidebar-right.md
@@ -115,7 +115,7 @@ The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/docum
Preview records its loaded version and the version observed when a read starts. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)).
-A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, reload }` instead of file bytes. The body loads through its own injected callbacks, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its own store and bounded cache retain converted bytes, and its body owns font notices above a nested PDF view. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle.
+A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, reload }` instead of file bytes. The body loads through its own injected callbacks, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its own store and bounded cache retain converted bytes, while its body renders a nested PDF view. `OfficeFontAction` renders the missing-font warning before reload through `sidebar.right.tab.document.action`. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle.
## Resource model
diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md
index af23b93945..bdab35cb88 100644
--- a/docs/subsystems/sidebar-right.zh.md
+++ b/docs/subsystems/sidebar-right.zh.md
@@ -115,7 +115,7 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed
Preview 记录已载入版本和读取开始时的观察版本。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。
-自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, reload }`,而不是文件字节。正文通过自己的注入回调加载,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);自己的 store 和有界缓存保留转换字节,正文在嵌套 PDF 视图上方管理字体提示。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。
+自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, reload }`,而不是文件字节。正文通过自己的注入回调加载,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);自己的 store 和有界缓存保留转换字节,正文渲染嵌套 PDF 视图,`OfficeFontAction` 通过 `sidebar.right.tab.document.action` 在刷新按钮前显示缺失字体警告。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。
## 资源模型
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 6a866733a0..f12df6c122 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/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: 165df8a18eac4a64befbbb43b0270fc57e3952d6
-README.zh.md: da3186dde43e21d01938c597c13aa422c75ce865
+README.md: 62f3346050ac106b9324f19666560347d5b37928
+README.zh.md: d30726da1881affb589709d9bf667c78875cf16d
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 165df8a18e..62f3346050 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -69,7 +69,7 @@ Writing your own component in your own package is fine when the need is genuinel
### Controls and icons
-The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
+The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
### Rendering agent output
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index da3186dde4..d30726da18 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -69,7 +69,7 @@ kind: "package-library"
### 控件与图标
-上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
+上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
### 渲染 agent 输出
diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx
index 0bd4f50211..cb6ccb92b2 100644
--- a/packages/client/ui-primitives/src/icons/index.tsx
+++ b/packages/client/ui-primitives/src/icons/index.tsx
@@ -586,6 +586,23 @@ export const IconWarningOutlineMedium = (props: IconProps) => (
)
+const IconWarningTriangleOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
+
+)
+
+/** Regular rounded warning triangle with an exclamation mark. */
+export const IconWarningTriangleOutlineRegular = (props: IconProps) => (
+
+)
+
+/** Medium rounded warning triangle with an exclamation mark. */
+export const IconWarningTriangleOutlineMedium = (props: IconProps) => (
+
+)
+
const IconUserOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (