diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
index ab2c0b380a..25b9a00172 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
@@ -172,7 +172,7 @@ class StreamingRenderer {
* absolute HTTP(S) images render directly.
*/
export const MarkdownText = memo(function MarkdownText({
- text, streaming = false, labels, fileMentions, pathImages, variant = 'body',
+ text, streaming = false, labels: providedLabels, fileMentions, pathImages, variant = 'body',
}: {
text: string
streaming?: boolean
@@ -181,6 +181,9 @@ export const MarkdownText = memo(function MarkdownText({
pathImages?: MarkdownPathImages | undefined
variant?: 'body' | 'compact'
}) {
+ const labels = useMemo(() => variant === 'compact'
+ ? { ...providedLabels, code: { ...providedLabels.code, toolbarLabels: undefined } }
+ : providedLabels, [providedLabels, variant])
const streamRef = useRef(null)
const streamLabelsRef = useRef(labels)
const children = useMemo(() => {
diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx
index c378dacd2a..cdf1b62ec9 100644
--- a/packages/client/ui-primitives/src/markdown/render.tsx
+++ b/packages/client/ui-primitives/src/markdown/render.tsx
@@ -36,6 +36,8 @@ export interface MarkdownCodeLabels {
copyLabel: string
/** Copy-button label during the post-copy confirmation window. */
copiedLabel: string
+ /** Shared card controls; omitted in compact and owner-styled source views. */
+ toolbarLabels?: import('../CodeToolbar.tsx').CodeToolbarLabels | undefined
}
/** Localized chrome for a Markdown document. */
@@ -399,6 +401,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..467aa5e833
--- /dev/null
+++ b/packages/client/ui-primitives/tests/code-card-controls.client.spec.tsx
@@ -0,0 +1,96 @@
+// @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: kind === 'code' ? 'Do not wrap lines' : 'Wrap lines' })
+ const wasWrapped = button.getAttribute('aria-pressed') === 'true'
+ fireEvent.click(button)
+ expect(button.getAttribute('aria-pressed')).toBe(String(!wasWrapped))
+ 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: 'Do not 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('preserves compact Markdown and owner-styled source controls', () => {
+ const view = render()
+ expect(screen.queryByRole('button', { name: 'Wrap lines' })).toBeNull()
+ expect(screen.queryByRole('button', { name: 'Do not wrap lines' })).toBeNull()
+ expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('Copy')
+ 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/labels.client.ts b/packages/client/ui-primitives/tests/labels.client.ts
index 7849d8e9c7..80b8cb7305 100644
--- a/packages/client/ui-primitives/tests/labels.client.ts
+++ b/packages/client/ui-primitives/tests/labels.client.ts
@@ -14,6 +14,7 @@ export const markdownLabels: MarkdownLabels = {
}
export const diffBlockLabels: DiffBlockLabels = {
+ codeLabel: '代码块', wrapLabel: '自动换行', unwrapLabel: '取消自动换行',
copy: '复制', copied: '复制成功', collapseAria: '收起差异',
expandAria: hidden => `展开其余 ${hidden} 行差异`,
collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`,
@@ -21,6 +22,7 @@ export const diffBlockLabels: DiffBlockLabels = {
}
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-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml
index d46092d14f..89c03993c4 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: 510e43fb723039b918672a58ec2bd26cf3b6023a
-README.zh.md: 5bd9fba7c7f60f7f477afb82b0e88a5dd8b5ac30
+README.md: f5d664db612f67d71ce509e44981a4678845b50d
+README.zh.md: 0361adcc79d13f06ba14cb3f6c6072e3f78dcf18
diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md
index 510e43fb72..f5d664db61 100644
--- a/packages/client/ui-sidebar-documentpreview/README.md
+++ b/packages/client/ui-sidebar-documentpreview/README.md
@@ -61,7 +61,7 @@ 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 secondary-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
diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md
index 5bd9fba7c7..0361adcc79 100644
--- a/packages/client/ui-sidebar-documentpreview/README.zh.md
+++ b/packages/client/ui-sidebar-documentpreview/README.zh.md
@@ -61,7 +61,7 @@ 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 不包含可选取的文字。代码预览使用 secondary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
## Excel 预览
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') }}
/>