diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css
index db318ac6eb..e3c5b31953 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css
@@ -401,24 +401,6 @@
z-index: auto;
}
-.compact [data-code-block-banner] {
- padding: 4px 8px;
- font: inherit;
-}
-
-.compact [data-code-block-banner] > :first-child {
- font-size: inherit;
- line-height: inherit;
- color: inherit;
-}
-
-.compact pre {
- margin: 4px 0;
- padding: 8px;
- font-size: inherit;
- line-height: inherit;
-}
-
/* Shiki writes token colors inline; compact code uses the surrounding text color. */
.compact pre,
.compact pre :is(code, span) {
diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx
index c378dacd2a..84a9c2857a 100644
--- a/packages/client/ui-primitives/src/markdown/render.tsx
+++ b/packages/client/ui-primitives/src/markdown/render.tsx
@@ -22,6 +22,7 @@ import clsx from 'clsx'
import type * as Md from 'mdast'
import type {} from 'mdast-util-math'
import { normalizeUri } from 'micromark-util-sanitize-uri'
+import type { CodeToolbarLabels } from '../CodeToolbar.tsx'
import { CodeBlock } from './CodeBlock.tsx'
import { parseFileLink } from './file-link.ts'
import { renderTexToReact } from './katex.tsx'
@@ -36,6 +37,8 @@ export interface MarkdownCodeLabels {
copyLabel: string
/** Copy-button label during the post-copy confirmation window. */
copiedLabel: string
+ /** Shared card controls; omitted for custom toolbar layouts. */
+ toolbarLabels?: CodeToolbarLabels | undefined
}
/** Localized chrome for a Markdown document. */
@@ -399,6 +402,7 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
streaming={context.streaming}
copyLabel={context.labels.code.copyLabel}
copiedLabel={context.labels.code.copiedLabel}
+ toolbarLabels={context.labels.code.toolbarLabels}
/>
)
}
diff --git a/packages/client/ui-primitives/tests/code-card-controls.client.spec.tsx b/packages/client/ui-primitives/tests/code-card-controls.client.spec.tsx
new file mode 100644
index 0000000000..62d448f499
--- /dev/null
+++ b/packages/client/ui-primitives/tests/code-card-controls.client.spec.tsx
@@ -0,0 +1,99 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
+import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
+import { ReadBlock } from '../src/ReadBlock.tsx'
+import { DiffBlock } from '../src/DiffBlock.tsx'
+import { diffBlockLabels, readBlockLabels } from './labels.client.ts'
+
+const toolbarLabels = { codeLabel: 'Code block', wrapLabel: 'Wrap lines', unwrapLabel: 'Do not wrap lines' }
+const labels = { copyLabel: 'Copy', copiedLabel: 'Copied', toolbarLabels }
+
+afterEach(() => {
+ cleanup()
+ vi.clearAllTimers()
+ vi.useRealTimers()
+})
+
+describe('code-card controls', () => {
+ it.each([undefined, 'unregistered'])('labels the %s language with the localized code title', (lang) => {
+ render()
+ expect(screen.getByText('Code block')).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('')
+ })
+
+ it('keeps a known grammar name while its highlighter is not yet visible', () => {
+ render()
+ expect(screen.getByText('typescript')).toBeTruthy()
+ expect(screen.queryByText('Code block')).toBeNull()
+ })
+
+ it.each(['code', 'read', 'diff'] as const)('toggles %s wrapping without changing source text', (kind) => {
+ const code = 'const longName = "a long value whose source must stay intact"'
+ const view = render(kind === 'code'
+ ?
+ : kind === 'read'
+ ?
+ : )
+ const button = screen.getByRole('button', { name: 'Wrap lines' })
+ const wasWrapped = button.getAttribute('aria-pressed') === 'true'
+ fireEvent.click(button)
+ expect(button.getAttribute('aria-pressed')).toBe(String(!wasWrapped))
+ expect(button.getAttribute('aria-label')).toBe('Wrap lines')
+ expect(view.container.textContent).toContain(code)
+ fireEvent.click(button)
+ expect(button.getAttribute('aria-pressed')).toBe(String(wasWrapped))
+ })
+
+ it('shows hover tooltips for copying and wrapping and updates the wrapping action', () => {
+ render()
+ const copy = screen.getByRole('button', { name: 'Copy' })
+ fireEvent.mouseEnter(copy)
+ expect(screen.getByRole('tooltip').textContent).toBe('Copy')
+ fireEvent.mouseLeave(copy)
+ const wrap = screen.getByRole('button', { name: 'Wrap lines' })
+ fireEvent.mouseEnter(wrap)
+ expect(screen.getByRole('tooltip').textContent).toBe('Do not wrap lines')
+ fireEvent.click(wrap)
+ expect(screen.queryByRole('tooltip')).toBeNull()
+ fireEvent.mouseEnter(wrap)
+ expect(screen.getByRole('tooltip').textContent).toBe('Wrap lines')
+ })
+
+ it('copies source through the icon and announces success', async () => {
+ vi.useFakeTimers()
+ const original = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
+ const writeText = vi.fn<(text: string) => Promise>().mockResolvedValue()
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
+ try {
+ render()
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Copy' })) })
+ expect(writeText).toHaveBeenCalledWith('const x = 1')
+ expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy()
+ } finally {
+ if (original === undefined) Reflect.deleteProperty(navigator, 'clipboard')
+ else Object.defineProperty(navigator, 'clipboard', original)
+ }
+ })
+
+ it('shows compact Markdown icon actions and preserves custom source controls', () => {
+ const view = render()
+ const wrap = screen.getByRole('button', { name: 'Wrap lines' })
+ expect(wrap.getAttribute('aria-pressed')).toBe('true')
+ fireEvent.click(wrap)
+ expect(wrap.getAttribute('aria-pressed')).toBe('false')
+ expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('')
+ view.rerender()
+ expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('Copy')
+ })
+
+ it('uses the fallback title for diffs containing different languages', () => {
+ render()
+ expect(screen.getByText('Code block')).toBeTruthy()
+ })
+})
diff --git a/packages/client/ui-primitives/tests/diff-block.client.spec.tsx b/packages/client/ui-primitives/tests/diff-block.client.spec.tsx
index 391fbfda51..2aaa72501b 100644
--- a/packages/client/ui-primitives/tests/diff-block.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/diff-block.client.spec.tsx
@@ -75,18 +75,15 @@ describe('DiffBlock structure', () => {
})
it('treats a trailing newline as a terminator, not an extra blank line', () => {
- // A create whose newText ends in a newline is one added line, not two, and
- // the footer counts one — the phantom `+ ` empty line the naive split drew.
const { container } = render()
expect(changeRows(container)).toEqual(['hello'])
- expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
it('renders a full deletion as removed-only with no phantom added line', () => {
// newText '' is zero added lines: an empty string must contribute nothing.
const { container } = render()
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0)
- expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy()
+ expect(changeRows(container)).toEqual(['a', 'b'])
})
it('keeps a genuine interior blank line', () => {
@@ -105,7 +102,6 @@ describe('DiffBlock local changes', () => {
const total = count === 128 ? count : count + 1
render()
expect(diffTotals(diffs)).toEqual({ added: total, removed: total })
- expect(screen.getByText(`└ +${total} -${total} · 1 file`)).toBeTruthy()
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
expect(writeText).toHaveBeenCalledWith(count === 128
? ['large.txt', ' shared context', ...oldLines.slice(1).map(line => `- ${line}`), ...newLines.slice(1).map(line => `+ ${line}`)].join('\n')
@@ -132,7 +128,6 @@ describe('DiffBlock local changes', () => {
render()
expect(screen.getAllByText('start')).toHaveLength(1)
expect(screen.getAllByText('end')).toHaveLength(1)
- expect(screen.getByText('└ +1 -1 · 1 file')).toBeTruthy()
expect(diffTotals(diffs)).toEqual({ added: 1, removed: 1 })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
expect(writeText).toHaveBeenCalledWith('settings.ts\n start\n- mode = 1\n+ mode = 2\n end')
@@ -150,7 +145,6 @@ describe('DiffBlock local changes', () => {
'item 40', 'changed 40', 'item 41', 'item 42', 'item 43',
])
expect(changeRows(container)).toEqual(['item 10', 'changed 10', 'item 40', 'changed 40'])
- expect(screen.getByText('└ +2 -2 · 1 file')).toBeTruthy()
})
it.each([
@@ -163,26 +157,10 @@ describe('DiffBlock local changes', () => {
['a\n\nb', 'a\nb', 0, 1],
])('counts ordered changes in %j → %j', (oldText, newText, added, removed) => {
const diffs = [{ path: 'a.txt', oldText, newText }]
- render()
+ const { container } = render()
expect(diffTotals(diffs)).toEqual({ added, removed })
- expect(screen.getByText(`└ +${added} -${removed} · 1 file`)).toBeTruthy()
- })
-})
-
-describe('DiffBlock footer', () => {
- it('counts added and removed lines and one file', () => {
- const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }]
- render()
- expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy()
- })
-
- it('pluralizes the distinct-file count', () => {
- const diffs: DiffHunk[] = [
- { path: 'a.ts', oldText: null, newText: 'x' },
- { path: 'b.ts', oldText: null, newText: 'y' },
- ]
- render()
- expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy()
+ expect(container.querySelectorAll('[class*="_add_"]')).toHaveLength(added)
+ expect(container.querySelectorAll('[class*="_del_"]')).toHaveLength(removed)
})
})
diff --git a/packages/client/ui-primitives/tests/labels.client.ts b/packages/client/ui-primitives/tests/labels.client.ts
index 7849d8e9c7..4d5ae803ad 100644
--- a/packages/client/ui-primitives/tests/labels.client.ts
+++ b/packages/client/ui-primitives/tests/labels.client.ts
@@ -14,13 +14,14 @@ export const markdownLabels: MarkdownLabels = {
}
export const diffBlockLabels: DiffBlockLabels = {
+ codeLabel: '代码块', wrapLabel: '自动换行', unwrapLabel: '取消自动换行',
copy: '复制', copied: '复制成功', collapseAria: '收起差异',
expandAria: hidden => `展开其余 ${hidden} 行差异`,
collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`,
- files: count => `${count} ${count === 1 ? 'file' : 'files'}`,
}
export const readBlockLabels: ReadBlockLabels = {
+ codeLabel: '代码块', wrapLabel: '自动换行', unwrapLabel: '取消自动换行',
window: (shown, total) => `显示 ${shown} / ${total} 行`,
copy: '复制', copied: '复制成功', collapseAria: '收起内容',
expandAria: hidden => `展开其余 ${hidden} 行`,
diff --git a/packages/client/ui-primitives/tests/read-block.client.spec.tsx b/packages/client/ui-primitives/tests/read-block.client.spec.tsx
index 4ad939838d..1e3c7f95a7 100644
--- a/packages/client/ui-primitives/tests/read-block.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/read-block.client.spec.tsx
@@ -134,10 +134,9 @@ describe('ReadBlock banner', () => {
expect(view.queryByText(/显示/u)).toBeNull()
})
- it('draws an empty label and empty language when neither is given', () => {
- const view = render()
- expect(view.container.querySelector('[class^="_label_"]')?.textContent).toBe('')
- expect(view.container.querySelector('[class^="_lang_"]')?.textContent).toBe('')
+ it('uses the localized code title when no language is given', () => {
+ render()
+ expect(screen.getByText('代码块')).toBeTruthy()
})
})
diff --git a/packages/client/ui-sidebar-browser/README.i18n.yaml b/packages/client/ui-sidebar-browser/README.i18n.yaml
index 9cd455c8d6..0fb7063909 100644
--- a/packages/client/ui-sidebar-browser/README.i18n.yaml
+++ b/packages/client/ui-sidebar-browser/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-sidebar-browser/README.md
-README.md: 52ebeddd088a8427b1bf058442b6fab6a768de37
-README.zh.md: 93df01dbe13bfa4a5aa318a1fd3a9e9786cd3c67
+README.md: 3fc359efc0b26ecfa006f92a094e6408781c2155
+README.zh.md: b3a452b0e53695ef08aa5ceced432d2710c170e3
diff --git a/packages/client/ui-sidebar-browser/README.md b/packages/client/ui-sidebar-browser/README.md
index 52ebeddd08..3fc359efc0 100644
--- a/packages/client/ui-sidebar-browser/README.md
+++ b/packages/client/ui-sidebar-browser/README.md
@@ -25,7 +25,7 @@ Browse HTTP(S) pages, including loopback services, inside independent right-Side
## Use this package
-Browser is disabled by default in Web profiles and enabled on Desktop. Enable the shipped entry through the Web profile patch to use it. Open **Browser** from the right-Sidebar guide and enter an HTTP(S) URL. Chat HTTP(S) links open here when the [link preference](../ui-chat/README.md) selects the built-in browser. A host name without a scheme becomes HTTPS. Public and loopback targets use the same default sandbox. Each guide action or delegated message-link activation creates another Browser tab.
+Browser is disabled by default in Web profiles and enabled on Desktop. Enable the shipped entry through the Web profile patch to use it. Open **Browser** from the right-Sidebar guide and enter an HTTP(S) URL. Chat HTTP(S) links open here when the [link preference](../ui-chat/README.md) selects **In-App Sidebar**. A host name without a scheme becomes HTTPS. Public and loopback targets use the same default sandbox. Each guide action or delegated message-link activation creates another Browser tab.
### When to choose it
diff --git a/packages/client/ui-sidebar-browser/README.zh.md b/packages/client/ui-sidebar-browser/README.zh.md
index 93df01dbe1..b3a452b0e5 100644
--- a/packages/client/ui-sidebar-browser/README.zh.md
+++ b/packages/client/ui-sidebar-browser/README.zh.md
@@ -25,7 +25,7 @@ kind: "package-reference"
## 使用本包
-Browser 在 Web profile 中默认禁用,在 Desktop 中默认启用。Web 用户可通过 profile patch 启用随附条目。可以从右侧 Sidebar guide 打开 **浏览器**并输入 HTTP(S) URL。Chat 的[链接偏好](../ui-chat/README.zh.md)选择内置浏览器时,HTTP(S) 链接会在此打开。不带 scheme 的主机名会补全为 HTTPS。公共目标与 loopback 目标使用相同的默认 sandbox。每次 guide 操作或委托到此的消息链接操作都会创建一个新的 Browser tab。
+Browser 在 Web profile 中默认禁用,在 Desktop 中默认启用。Web 用户可通过 profile patch 启用随附条目。可以从右侧 Sidebar guide 打开 **浏览器**并输入 HTTP(S) URL。Chat 的[链接偏好](../ui-chat/README.zh.md)选择**应用内侧边栏**时,HTTP(S) 链接会在此打开。不带 scheme 的主机名会补全为 HTTPS。公共目标与 loopback 目标使用相同的默认 sandbox。每次 guide 操作或委托到此的消息链接操作都会创建一个新的 Browser tab。
### 何时选择
diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml
index 0978246a14..cacfb2117c 100644
--- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml
+++ b/packages/client/ui-sidebar-documentpreview/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-sidebar-documentpreview/README.md
-README.md: 224957fec45fd4d2849df677b9cada62d01d18cd
-README.zh.md: 18fe9e94617291d85fd3db7bf1c7bcc347f6ec73
+README.md: 5507bd89f07bfd6225d4ecc1bc4500ceb2e30fe8
+README.zh.md: eec96c28654ae01aca6b89862d503af6c12296e8
diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md
index 224957fec4..5507bd89f0 100644
--- a/packages/client/ui-sidebar-documentpreview/README.md
+++ b/packages/client/ui-sidebar-documentpreview/README.md
@@ -61,12 +61,12 @@ PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` s
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls.
-Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
+Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a tertiary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
## Excel preview
-Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. Spreadsheet preview does not call the Office conversion service.
+Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. The workbook viewport follows preview pane size changes without reloading its sheets or selection. Spreadsheet preview does not call the Office conversion service.
XLSX preview omits DrawingML parts from an in-memory copy before parsing and ignores worksheet drawing references. The original file and worksheet XML remain unchanged. A notice above the table lists detected charts, images, shapes, and conditional formatting that are not displayed, and recommends opening the workbook in a system application. Files without these detected features have no notice; formula warnings remain separate.
diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md
index 18fe9e9461..eec96c2865 100644
--- a/packages/client/ui-sidebar-documentpreview/README.zh.md
+++ b/packages/client/ui-sidebar-documentpreview/README.zh.md
@@ -61,12 +61,12 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态
共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。
-首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
+首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览使用 tertiary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
## Excel 预览
-直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。表格预览不调用 Office 转换服务。
+直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。工作簿视图随预览面板尺寸变化自动调整,无需重新加载工作表或选择状态。表格预览不调用 Office 转换服务。
XLSX 预览在解析前从内存副本中移除 DrawingML 部件,并忽略工作表的绘图引用。原文件和工作表 XML 保持不变。表格上方的提示列出实际检测到但未展示的图表、图片、形状和条件格式,并建议使用系统应用打开工作簿。未检测到这些内容的文件不显示此提示;公式提示保持独立。
diff --git a/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css
index 9e284f9ce9..c430564b19 100644
--- a/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css
+++ b/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css
@@ -43,6 +43,7 @@
.renderer .code pre {
box-sizing: border-box;
+ padding: 16px;
min-width: 100%;
overflow: visible;
white-space: pre;
diff --git a/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.tsx
index a643654d5b..898c0eb596 100644
--- a/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.tsx
+++ b/packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.tsx
@@ -24,9 +24,11 @@ export function CodeBody({ resourceAddress, content, wrap, scrollportRef, t }: C
code={content.text}
lang={language}
streaming={!content.eof}
+ wrap={wrap}
lineNumbers
copyLabel={t('copy')}
copiedLabel={t('copied')}
+ toolbarLabels={{ codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') }}
/>
)
diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx
index b8bbb89159..0e1b67da4b 100644
--- a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx
+++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx
@@ -1,5 +1,5 @@
/** Read-only spreadsheet surface backed by browser-parsed workbook data. */
-import { useEffect, useState, type ReactNode } from 'react'
+import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Workbook } from '@fortune-sheet/react'
import { Button, IconWarningTriangleOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import fortuneCss from '@fortune-sheet/react/dist/index.css?inline'
@@ -14,6 +14,7 @@ const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }`
/**
* Display stored spreadsheet values and formats without editing or recalculation.
+ * Keep the mounted workbook sized to its preview pane.
* @param props - Complete workbook bytes, limits, and locale.
* @returns An isolated spreadsheet surface with cancellable loading.
*/
@@ -21,6 +22,7 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
const data = content.kind === 'bytes' ? content.data : undefined
const [state, setState] = useState()
const [attempt, setAttempt] = useState(0)
+ const workbookRef = useRef(null)
useEffect(() => {
if (data === undefined) return
const controller = new AbortController()
@@ -31,6 +33,14 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
)
return () => { controller.abort() }
}, [data, format, limits, attempt])
+ useEffect(() => {
+ const element = workbookRef.current
+ if (element === null || typeof ResizeObserver === 'undefined') return
+ // FortuneSheet measures its canvas on window resize, but pane resizing does not emit that event.
+ const observer = new ResizeObserver(() => { window.dispatchEvent(new Event('resize')) })
+ observer.observe(element)
+ return () => { observer.disconnect() }
+ }, [state])
if (data === undefined) return
{t('invalid')}
if (state?.data !== data || state.format !== format) return
@@ -46,7 +56,7 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
{t('unsupportedNotice', { features: state.value.unsupportedFeatures.map(feature => t(feature)).join(t('featureSeparator')) })}