diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 07e7397d12..fd0ebd02ad 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -797,7 +797,12 @@ else process.exit(1); })).toEqual({ scrollTop, scrollportBelowBanner: true, firstLineAbove: true }) await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) await page.evaluate(() => navigator.clipboard.writeText('')) - await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click() + const copyCode = codeBlock.getByRole('button', { name: 'Copy', exact: true }) + expect(await codeBlock.getByRole('button').count()).toBe(1) + expect(await copyCode.textContent()).toBe('') + await copyCode.hover() + await page.getByRole('tooltip', { name: 'Copy', exact: true }).waitFor() + await copyCode.click() await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(codeLines.join('\n')) sections.push([ '## Code paging', '', diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index 43b462deac..feaeaa2468 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -32,6 +32,8 @@ - text: Edit - button "src/tokens.css" - text: +1 -1 +- text: css +- button "Wrap lines" - button "Copy" - text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5; └ +1 -1 · 1 file" - button "Inspect" @@ -43,7 +45,8 @@ - button "Read docs/guide.md" [expanded]: - text: Read - button "docs/guide.md" -- text: docs/guide.md +- text: Code block docs/guide.md +- button "Wrap lines" - button "Copy" - text: "# Link style guide guide line 2 guide line 3 guide line 4" - button "Expand 4 more lines": … 4 more lines @@ -134,6 +137,7 @@ - paragraph: - img "Token preview" - text: css +- button "Do not wrap lines" [pressed] - button "Copy" - code: "--inline-code: #F5F5F5;" - paragraph: LINK_GALLERY_DONE diff --git a/apps/web/tests/expected/navigation-panes/code-card.expected.md b/apps/web/tests/expected/navigation-panes/code-card.expected.md new file mode 100644 index 0000000000..3e48942224 --- /dev/null +++ b/apps/web/tests/expected/navigation-panes/code-card.expected.md @@ -0,0 +1,4 @@ +- text: Code block +- button "Do not wrap lines" [pressed] +- button "Copy" +- code: echo WATERFALL diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 4ab75b3ba0..f43b020a60 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -27,6 +27,7 @@ const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md') const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md') const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') +const CODE_CARD_EXPECTED = fileURLToPath(new URL('./expected/navigation-panes/code-card.expected.md', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl` @@ -218,6 +219,28 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1) }, 90_000) + it.skipIf(MODE === 'record')('renders recorded Markdown with icon tooltips and switchable wrapping', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-code-card')) + await ensureSeedOpen(page) + const selector = '.md-code-block:has-text("echo WATERFALL")' + const block = page.locator(selector) + await block.waitFor({ timeout: 15_000 }) + await compareOrRefreshGolden(CODE_CARD_EXPECTED, await captureStableAria(page, selector, scaffold.workspaceCwd), MODE) + const code = block.locator('pre') + const source = await code.textContent() + const copy = block.getByRole('button', { name: 'Copy', exact: true }) + await copy.hover() + await page.getByRole('tooltip', { name: 'Copy', exact: true }).waitFor() + const unwrap = block.getByRole('button', { name: 'Do not wrap lines', exact: true }) + await unwrap.hover() + await page.getByRole('tooltip', { name: 'Do not wrap lines', exact: true }).waitFor() + await unwrap.click() + await expect.poll(() => code.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre') + await block.getByRole('button', { name: 'Wrap lines', exact: true }).click() + await expect.poll(() => code.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre-wrap') + expect(await code.textContent()).toBe(source) + }, 60_000) + it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) await ensureSeedOpen(page) diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index d16b4913ae..d21b7d67f7 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -7,6 +7,9 @@ export const en = { 'close': 'Close', 'copy': 'Copy', 'copied': 'Copied', + 'codeBlock.title': 'Code block', + 'codeBlock.wrap': 'Wrap lines', + 'codeBlock.unwrap': 'Do not wrap lines', 'copy.failed': 'Copy failed', 'copy.value': 'Copy value', 'copy.json': 'Copy JSON', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index 2e932cef41..d7b215babd 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -5,6 +5,9 @@ export const zh = { 'close': '关闭', 'copy': '复制', 'copied': '复制成功', + 'codeBlock.title': '代码块', + 'codeBlock.wrap': '自动换行', + 'codeBlock.unwrap': '取消自动换行', 'copy.failed': '复制失败', 'copy.value': '复制值', 'copy.json': '复制 JSON', diff --git a/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx b/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx index 6ecb534f02..8ca5270d52 100644 --- a/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx +++ b/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx @@ -1,6 +1,7 @@ /** Read-only help stays local to Settings and never changes the selected preset. */ import { useId, useLayoutEffect, useRef, useState } from 'react' import type { KeyboardEvent, ReactNode } from 'react' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { Button, IconCloseOutlineRegular, IconListPenOutlineRegular, MarkdownText, Modal, SegmentedTabs, Tag } from '@deepseek-ai/dsh-client-ui-primitives' import type { AgentPresetSettingsKey } from './locales.ts' import css from './PresetGuideDialog.module.css' @@ -35,10 +36,10 @@ export function presetGuide(id: string, trust: string): PresetGuide | undefined /** Curated usage dictionaries contain only level-three example sections. */ function GuideUsage({ text, t }: { text: string - t: (key: AgentPresetSettingsKey) => string + t: TranslateNS<'settings.agentPreset'> }): ReactNode { const labels = { - code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied') }, + code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } }, footnotes: t('guideFootnotes'), } return text.split(/(?=^### )/m).map((section) => { @@ -67,7 +68,7 @@ function GuideUsage({ text, t }: { export function PresetGuideDialog({ guide, initialPage, t, onClose }: { guide: PresetGuide initialPage: PresetGuidePage - t: (key: AgentPresetSettingsKey) => string + t: TranslateNS<'settings.agentPreset'> onClose: () => void }): ReactNode { const [page, setPage] = useState(initialPage) @@ -144,7 +145,7 @@ export function PresetGuideDialog({ guide, initialPage, t, onClose }: { diff --git a/packages/client/ui-chat/src/client/markdown-labels.ts b/packages/client/ui-chat/src/client/markdown-labels.ts index 6c30b99396..8818ea8f14 100644 --- a/packages/client/ui-chat/src/client/markdown-labels.ts +++ b/packages/client/ui-chat/src/client/markdown-labels.ts @@ -10,7 +10,7 @@ import type { ChatViewSlotProps } from './contract/slots.ts' */ export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels { return { - code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } }, footnotes: t('markdown.footnotes'), } } diff --git a/packages/client/ui-plan/src/client/PlanPreview.tsx b/packages/client/ui-plan/src/client/PlanPreview.tsx index b1e1689fe8..fdee35e7b9 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.tsx +++ b/packages/client/ui-plan/src/client/PlanPreview.tsx @@ -22,7 +22,7 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) { const params = tab.tab.navigation.params const plan = temporary ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value const labels = useMemo(() => ({ - code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } }, footnotes: t('markdown.footnotes'), }), [t]) if (plan === undefined) return ( diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 38355c398f..c14c318535 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: 7dafaf759ac329a4162b7138e58ed13ca5afd651 -README.zh.md: db38df0a298f567287e9cc99e623287465213c74 +README.md: 069aea78dd08175a9ebb1e590bb03e97dc08daab +README.zh.md: d235e19a9f9c829d4d5d6587fdd147fda9a2884e diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7dafaf759a..069aea78dd 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -87,6 +87,8 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. +`CodeBlock.toolbarLabels` enables the shared code-card header and spacing. `ReadBlock` and `DiffBlock` use the same header: secondary-colored language text, copy and wrap icons, and hover or keyboard-focus tooltips. Missing or unsupported languages use the owner’s localized code-block title; mixed-language diffs use that title too. Code cards share typography and content insets. Wrapping is local to each mounted card and never changes copied text; code fences start wrapped, while reads and diffs preserve source columns until wrapping is enabled. Compact Markdown omits this toolbar. With `toolbarLabels`, source previews can pass `wrap` to follow their owner’s preference and show only the language and copy icon; owner CSS retains the source layout. + `DiffBlock` compares the old and new content by line. It shows actual additions and deletions with up to three neutral context lines on each side, separates distant changes with `⋯`, and excludes shared context from both summary and footer totals. Search stops beyond 256 line additions/deletions per fragment; those fragments display and count the complete old and new contents as a coarse replacement, including shared lines. Copy includes the full displayed diff with its prefixes. A final newline is treated as a terminator; differences only in the presence of a final newline are not displayed. `JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index db38df0a29..d235e19a9f 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -87,6 +87,8 @@ kind: "package-library" `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 +`CodeBlock.toolbarLabels` 启用共享代码卡片的标题栏和间距。`ReadBlock` 与 `DiffBlock` 使用相同的标题栏:secondary 色的语言名称、复制与换行图标,以及悬停或键盘聚焦时的提示。语言缺失或不受支持时显示调用方提供的本地化代码块标题;混合语言的 Diff 也使用该标题。代码卡片共用字体和内容内边距。换行设置仅属于当前挂载的卡片,不改变复制的文本;代码围栏默认换行,读取和差异卡片则在启用换行前保留源码列位置。紧凑 Markdown 不使用此工具栏。源码预览传入 `toolbarLabels` 后,可用 `wrap` 跟随所属视图的换行设置,仅显示语言和复制图标;所属视图的 CSS 保留源码布局。 + `DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,摘要和底部统计都不计入共享上下文。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。 `JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。 diff --git a/packages/client/ui-primitives/src/CodeCard.module.css b/packages/client/ui-primitives/src/CodeCard.module.css new file mode 100644 index 0000000000..fa3a6e579a --- /dev/null +++ b/packages/client/ui-primitives/src/CodeCard.module.css @@ -0,0 +1,81 @@ +.card { + position: relative; + margin: 16px 0; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-markdown-code-block); + border-radius: 12px; + font: var(--dsw-font-markdown-code-block); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 18px 4px; + background: var(--dsw-alias-markdown-code-block-banner); + border-top-left-radius: inherit; + border-top-right-radius: inherit; + color: var(--dsw-alias-label-secondary); + font: 11px/18px var(--dsw-font-family); +} + +.heading { + display: flex; + align-items: baseline; + gap: 12px; + min-width: 0; +} + +.language { + flex: none; + font-family: var(--ds-font-family-code); +} + +.title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.actions { + display: flex; + align-items: center; + gap: 4px; + flex: none; +} + +.status { + margin-right: 8px; + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} + +.action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 6px; + color: var(--dsw-alias-label-secondary); + background: transparent; + cursor: pointer; +} + +.action:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.action:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.body { + padding: 6px 18px 16px; + font: var(--dsw-font-markdown-code-block); + overflow-x: auto; +} diff --git a/packages/client/ui-primitives/src/CodeToolbar.tsx b/packages/client/ui-primitives/src/CodeToolbar.tsx new file mode 100644 index 0000000000..755753e894 --- /dev/null +++ b/packages/client/ui-primitives/src/CodeToolbar.tsx @@ -0,0 +1,62 @@ +/** Shared language, wrapping, and clipboard controls for code cards. */ +import { + IconCheckOutlineRegular, IconCopyOutlineRegular, IconNowrapFillRegular, IconWrapFillRegular, +} from './icons/index.tsx' +import { Tooltip } from './Tooltip.tsx' +import { supportsHighlighting } from './markdown/highlight.ts' +import css from './CodeCard.module.css' + +/** Localized language fallback and wrapping actions supplied by the card owner. */ +export interface CodeToolbarLabels { + /** Title for an absent or unsupported language. */ + codeLabel: string + /** Action that enables wrapping. */ + wrapLabel: string + /** Action that preserves source columns with horizontal scrolling. */ + unwrapLabel: string +} + +/** Display state and callbacks for a code card's toolbar. */ +interface CodeToolbarProps { + lang?: string | undefined + title?: string | undefined + status?: string | undefined + labels: CodeToolbarLabels + copyLabel: string + copiedLabel: string + copied: boolean + wrapped: boolean + onCopy?: (() => void) | undefined + onWrap?: (() => void) | undefined +} + +/** + * Render a language label and keyboard-accessible icon actions with tooltips. + * @param props - Localized labels, current state, and card-owned actions. + * @returns The shared code-card header. + */ +export function CodeToolbar({ lang, title, status, labels, copyLabel, copiedLabel, copied, wrapped, onCopy, onWrap }: CodeToolbarProps) { + const wrapLabel = wrapped ? labels.unwrapLabel : labels.wrapLabel + const clipboardLabel = copied ? copiedLabel : copyLabel + return ( +
+
+ {supportsHighlighting(lang) ? lang : labels.codeLabel} + {title !== undefined && {title}} +
+
+ {status !== undefined && {status}} + {onWrap !== undefined && + + } + {onCopy !== undefined && + + } +
+
+ ) +} diff --git a/packages/client/ui-primitives/src/DiffBlock.module.css b/packages/client/ui-primitives/src/DiffBlock.module.css index dd1d621aeb..649237832c 100644 --- a/packages/client/ui-primitives/src/DiffBlock.module.css +++ b/packages/client/ui-primitives/src/DiffBlock.module.css @@ -1,79 +1,52 @@ .block { - --dsl-diff-radius: 12px; - --dsl-diff-line-height: 22px; - - position: relative; - margin: 16px 0; - color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-markdown-code-block); - border-radius: var(--dsl-diff-radius); -} - -/* The copy control floats in the top-right corner over the body, so the card - has no empty banner row above its first diff line (the TUI diff card has no - banner either — only the footer). The block is position: relative, so this - anchors to the card. */ -.copyButton { - position: absolute; - top: 8px; - right: 12px; - z-index: 1; - background-color: transparent; - border: none; - padding: 0; - margin: 0; - color: var(--dsw-alias-label-secondary); - cursor: pointer; - font: var(--dsw-font-xs-13); + overflow: hidden; } .body { - padding: 12px 14px; + padding: 6px 0 16px; font: var(--dsw-font-markdown-code-block); overflow-x: auto; - overflow-y: hidden; } -/* No wrapping, no word-break: a diff is read by its indentation. */ .line { - min-height: var(--dsl-diff-line-height); + box-sizing: border-box; + min-height: 1lh; + padding: 0 18px; white-space: pre; } -/* A file header: the path in the primary tone, set apart by weight. The copy - button floats over this first row's top-right corner, so reserve space at the - line's end for it — a long path scrolls under the button otherwise, and the - button's hit area would eat clicks on the path's tail. */ +.block[data-code-wrap='true'] .line { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + .path { - color: var(--dsw-alias-label-primary); - font-weight: 600; - padding-right: 56px; + color: var(--dsw-alias-label-secondary); + font-weight: 500; } -/* A same-file second hunk's separator (a scattered edit), in the dim tone. */ .gap { color: var(--dsw-alias-label-tertiary); } -/* The diff's own meaning-carrying colors: removed on the error token, added on - the success token. A `- `/`+ ` prefix is drawn here so a copied line and the - shown line agree, and so the sign reads without relying on color alone. */ .del::before { content: '- '; - color: var(--dsw-alias-state-error-primary); } .del { color: var(--dsw-alias-state-error-primary); + background: var(--dsw-alias-code-diff-deleted); + box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary); } .add::before { content: '+ '; - color: var(--dsw-alias-state-success-primary); } .add { color: var(--dsw-alias-state-success-primary); + background: var(--dsw-alias-code-diff-added); + box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary); } .context { @@ -87,9 +60,9 @@ .expand { display: block; width: 100%; - padding: 0; + padding: 0 18px; border: none; - background-color: transparent; + background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; @@ -100,10 +73,8 @@ color: var(--dsw-alias-label-secondary); } -/* The change summary, dim under the body: `└ +A -R · N file(s)`, the same - footer the TUI transcript's diff card draws. */ .footer { - padding: 0 14px 12px; + padding: 0 18px 16px; font: var(--dsw-font-markdown-code-block); color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index 339c4a4af1..d9acc67fff 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -3,6 +3,9 @@ import clsx from 'clsx' import { structuredPatch } from 'diff' import { FoldToggle } from './FoldToggle.tsx' import { writeClipboard } from './clipboard.ts' +import { CodeToolbar, type CodeToolbarLabels } from './CodeToolbar.tsx' +import { languageForPath } from './code-highlighting.ts' +import cardCss from './CodeCard.module.css' import css from './DiffBlock.module.css' /** Output lines shown before the height cap collapses the middle. */ @@ -33,7 +36,7 @@ export interface DiffBlockProps { } /** Localized chrome for {@link DiffBlock}. */ -export interface DiffBlockLabels { +export interface DiffBlockLabels extends CodeToolbarLabels { copy: string copied: string collapseAria: string @@ -174,6 +177,9 @@ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, cl const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs]) const [expanded, setExpanded] = useState(false) const [copied, setCopied] = useState(false) + const [wrapped, setWrapped] = useState(false) + const firstLanguage = diffs[0] === undefined ? undefined : languageForPath(diffs[0].path) + const language = diffs.every(diff => languageForPath(diff.path) === firstLanguage) ? firstLanguage : undefined const onCopy = useCallback(() => { if (copied) return @@ -198,10 +204,9 @@ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, cl const tail = capped ? rows.slice(rows.length - tailLines) : [] return ( -
- +
+ { setWrapped(value => !value) }} />
{head.map((row, index) => (
{row.text}
diff --git a/packages/client/ui-primitives/src/ReadBlock.module.css b/packages/client/ui-primitives/src/ReadBlock.module.css index 2c4cf0370d..fbff018f50 100644 --- a/packages/client/ui-primitives/src/ReadBlock.module.css +++ b/packages/client/ui-primitives/src/ReadBlock.module.css @@ -1,105 +1,41 @@ .block { - --dsl-read-radius: 12px; - --dsl-read-line-height: 22px; - /* Fixed-width gutter column for the line numbers, so the content edge stays - put down the whole window regardless of how wide the numbers grow. */ - --dsl-read-gutter: 48px; - - position: relative; - margin: 16px 0; - color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-markdown-code-block); - border-radius: var(--dsl-read-radius); + --dsl-read-gutter: 3ch; } -.banner { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - padding: 9px 14px; - background: var(--dsw-alias-markdown-code-block-banner); - border-top-left-radius: var(--dsl-read-radius); - border-top-right-radius: var(--dsl-read-radius); -} - -.label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--dsw-alias-label-primary); - font-family: var(--ds-font-family-code); - font-size: 12px; - line-height: 18px; -} - -.action { - display: flex; - align-items: center; - flex-shrink: 0; - gap: 12px; -} - -.count { - color: var(--dsw-alias-label-tertiary); - font: var(--dsw-font-xs-13); -} - -.lang { - color: var(--dsw-alias-label-tertiary); - font-family: var(--ds-font-family-code); - font-size: 12px; - line-height: 18px; -} - -.copyButton { - background-color: transparent; - border: none; - padding: 0; - margin: 0; - color: var(--dsw-alias-label-secondary); - cursor: pointer; - font: var(--dsw-font-xs-13); -} - -.body { - padding: 12px 0; - font: var(--dsw-font-markdown-code-block); - overflow-x: auto; - overflow-y: hidden; -} - -/* One row per file line: a fixed gutter column, then the content. No wrapping — - a source line's leading whitespace is meaningful and scrolls sideways. */ .line { display: flex; - min-height: var(--dsl-read-line-height); - line-height: var(--dsl-read-line-height); + min-height: 1lh; white-space: pre; } .gutter { flex: none; width: var(--dsl-read-gutter); - padding-right: 14px; + margin-right: 12px; text-align: right; color: var(--dsw-alias-label-tertiary); - /* The gutter is chrome, not content: keep it out of a text selection so a - copy of the visible rows carries the source, not the line numbers. */ user-select: none; } .content { + min-width: 0; color: var(--dsw-alias-label-primary); } +.block[data-code-wrap='true'] .line { + white-space: pre-wrap; +} + +.block[data-code-wrap='true'] .content { + overflow-wrap: anywhere; +} + .expand { display: block; width: 100%; - padding: 0 0 0 var(--dsl-read-gutter); + padding: 0 0 0 calc(var(--dsl-read-gutter) + 12px); border: none; - background-color: transparent; + background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; diff --git a/packages/client/ui-primitives/src/ReadBlock.tsx b/packages/client/ui-primitives/src/ReadBlock.tsx index 6c1b2eaec9..e1493e3316 100644 --- a/packages/client/ui-primitives/src/ReadBlock.tsx +++ b/packages/client/ui-primitives/src/ReadBlock.tsx @@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 're import clsx from 'clsx' import { FoldToggle } from './FoldToggle.tsx' import { writeClipboard } from './clipboard.ts' +import { CodeToolbar, type CodeToolbarLabels } from './CodeToolbar.tsx' +import cardCss from './CodeCard.module.css' import { grammarLoadCount, highlightLines, @@ -44,7 +46,7 @@ export interface ReadBlockProps { } /** Localized chrome for {@link ReadBlock}. */ -export interface ReadBlockLabels { +export interface ReadBlockLabels extends CodeToolbarLabels { window: (shown: number, total: number) => string copy: string copied: string @@ -88,6 +90,7 @@ export function ReadBlock({ ) const [expanded, setExpanded] = useState(false) const [copied, setCopied] = useState(false) + const [wrapped, setWrapped] = useState(false) const onCopy = useCallback(() => { if (copied) return @@ -120,23 +123,13 @@ export function ReadBlock({ [line, highlighted?.[index]]) return ( -
-
-
{label ?? ''}
-
- {windowed && ( - {labels.window(lines.length, totalLines)} - )} - {lang ?? ''} - {/* Empty files omit Copy to avoid replacing the clipboard with an empty string. */} - {lines.length > 0 && ( - - )} -
-
-
+
+ 0 ? onCopy : undefined} onWrap={() => { setWrapped(value => !value) }} + /> +
{rows(capped ? paired.slice(0, headLines) : paired)} {hidden > 0 && ( { if (copied) return @@ -178,21 +185,26 @@ export function CodeBlock({ ) return ( -
{/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */} {showHeader &&
-
+ {toolbarLabels !== undefined ? { setWrapped(value => !value) } : undefined} + /> :
{lang ?? ''}
-
+
}
}
{body}
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') }} />
) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx index f6164af6f3..27c672c60e 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx @@ -24,9 +24,12 @@ export function MarkdownBody({ content, resourceAddress, useResource, t }: Markd const copyLabel = t('code.copy') const copiedLabel = t('code.copied') const footnotes = t('footnotes') + const codeLabel = t('codeBlock.title') + const wrapLabel = t('codeBlock.wrap') + const unwrapLabel = t('codeBlock.unwrap') const labels = useMemo(() => ({ - code: { copyLabel, copiedLabel }, footnotes, - }), [copyLabel, copiedLabel, footnotes]) + code: { copyLabel, copiedLabel, toolbarLabels: { codeLabel, wrapLabel, unwrapLabel } }, footnotes, + }), [copyLabel, copiedLabel, footnotes, codeLabel, wrapLabel, unwrapLabel]) if (content.kind !== 'text') return null return (
diff --git a/packages/client/ui-sidebar-documentpreview/tests/code-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/code-body.client.spec.tsx index c2b3f0daae..ce8e6b548f 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/code-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/code-body.client.spec.tsx @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { cleanup, render, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { CodeBody } from '../src/client/code/CodeBody.tsx' @@ -84,7 +84,11 @@ describe('CodeBody', () => { expect(view.getByText(language, { exact: true })).toBeTruthy() expect(element(view.container, '.shiki').textContent).toBe('const answer = 42') expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull() - expect(view.getByRole('button', { name: 'Copy' })).toBeTruthy() + const copy = view.getByRole('button', { name: 'Copy' }) + expect(copy.textContent).toBe('') + expect(view.getAllByRole('button')).toHaveLength(1) + fireEvent.mouseEnter(copy) + expect(view.getByRole('tooltip').textContent).toBe('Copy') }) it('carries multiline grammar state across pages and preserves completed lines through settlement', () => { @@ -155,6 +159,7 @@ describe('CodeBody', () => { const view = render() const pre = element(view.container, '.shiki') const block = element(view.container, '.md-code-block') + expect(block.getAttribute('data-code-wrap')).toBe('false') const scrollport = element(view.container, '[data-code-block-content]') expect(getComputedStyle(block).marginTop).toBe('0px') expect(getComputedStyle(block).position).toBe('static') @@ -165,6 +170,8 @@ describe('CodeBody', () => { expect(getComputedStyle(pre).overflow).toBe('visible') expect(getComputedStyle(pre).wordBreak).toBe('normal') view.rerender() + expect(block.getAttribute('data-code-wrap')).toBe('true') + expect(view.getAllByRole('button')).toHaveLength(1) expect(element(view.container, '.shiki')).toBe(pre) expect(element(view.container, '[data-code-block-content]')).toBe(scrollport) expect(getComputedStyle(pre).whiteSpace).toBe('pre-wrap') diff --git a/packages/client/ui-theme/src/styles/shiki.css b/packages/client/ui-theme/src/styles/shiki.css index c7a3c5d272..f969d1b264 100644 --- a/packages/client/ui-theme/src/styles/shiki.css +++ b/packages/client/ui-theme/src/styles/shiki.css @@ -4,6 +4,11 @@ cascade as every other token sheet. Background/foreground deliberately alias the markdown code-block tokens so highlighted and plain blocks agree. */ +body { + --dsw-alias-code-diff-added: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); + --dsw-alias-code-diff-deleted: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); +} + :root { --shiki-foreground: var(--dsw-alias-label-primary); --shiki-background: var(--dsw-alias-markdown-code-block); @@ -19,6 +24,8 @@ } body[data-ds-dark-theme] { + --dsw-alias-code-diff-added: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); + --dsw-alias-code-diff-deleted: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); --shiki-token-constant: #4dabf7; --shiki-token-string: #69db7c; --shiki-token-comment: #adb5bd; diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css index 824bd18fde..c813c772cc 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css @@ -232,15 +232,13 @@ overflow: auto; } +.root .item:has(> .code:only-child) { padding: 0; } +.root .item > .code:only-child { margin: 0; } + .root .group .item:has(> .code:only-child) { padding: 4px 0 0 20px; } -.root .group .item > .code:only-child { margin: 0; } - -.root .code [data-code-block-banner] { padding: 4px 10px; } -.root .code pre { padding: 6px 10px 8px; } - .path { flex: 1; min-width: 0; diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx index 801be3c7a0..618d998ce3 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx @@ -4,7 +4,7 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { OpenFileOptions } from '@deepseek-ai/dsh-client-ui-chat/client' -import { markdownLabels } from '../models/primitive-labels.ts' +import { codeToolbarLabels, markdownLabels } from '../models/primitive-labels.ts' import css from './ToolDetails.module.css' /** One recorded entity, receipt, or collapsible group of related values. */ @@ -89,6 +89,7 @@ function DetailItem({ item, t, onOpenFile }: { item: ToolDetailItem } & DetailCo {item.markdown !== undefined &&
} {item.code !== undefined && ( diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 1478b06961..5e54eeb7e5 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -324,12 +324,6 @@ color: var(--dsw-alias-label-tertiary); } -/* In-row code renders at the smaller code size (12/18) via each primitive's - rebindable content-font seam; standalone markdown code blocks keep 13/22. */ -.codeBody { - --dsl-code-block-content-font: var(--dsw-font-markdown-code-block-small); -} - /* The terminal card scrolls its OUTPUT inside its own surface (same l1 hairline as the IN/OUT card): the banner stays pinned and the scrollbar never rides over it. 224px = the 260px card cap minus the ~36px banner. */ diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 62b91189fc..76fc93d13d 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -16,7 +16,7 @@ import { localizeTerminalCardModel, terminalBlockLabels, type TerminalCardModel, } from '../models/terminal-card-model.ts' import { - diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, + codeToolbarLabels, diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, } from '../models/primitive-labels.ts' import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts' import { @@ -289,7 +289,8 @@ export const ToolRow = memo(function ToolRow({ <> {variant === 'code' && bodyText !== null && (
- +
)} {(cardBody !== null || outputText !== null) && ( diff --git a/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts b/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts index 4b48cf4f8e..156a1f3c21 100644 --- a/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts +++ b/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts @@ -1,6 +1,7 @@ /** Localized copy adapters for Cordis-free UI primitives used by Tool cards. */ import type { + CodeBlockProps, DiffBlockLabels, MarkdownLabels, ReadBlockLabels, @@ -11,6 +12,15 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' type T = TranslateNS<'conversation'> +/** + * Localize the shared code-card toolbar. + * @param t - Conversation locale seat. + * @returns Language fallback and wrapping actions. + */ +export function codeToolbarLabels(t: T): NonNullable { + return { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } +} + /** * Build localized Markdown chrome labels. * @param t - Conversation locale seat. @@ -18,7 +28,7 @@ type T = TranslateNS<'conversation'> */ export function markdownLabels(t: T): MarkdownLabels { return { - code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: codeToolbarLabels(t) }, footnotes: t('markdown.footnotes'), } } @@ -30,6 +40,7 @@ export function markdownLabels(t: T): MarkdownLabels { */ export function diffBlockLabels(t: T): DiffBlockLabels { return { + ...codeToolbarLabels(t), copy: t('copy'), copied: t('copied'), collapseAria: t('diff.collapseAria'), @@ -47,6 +58,7 @@ export function diffBlockLabels(t: T): DiffBlockLabels { */ export function readBlockLabels(t: T): ReadBlockLabels { return { + ...codeToolbarLabels(t), window: (shown, total) => t('read.window', { shown, total }), copy: t('copy'), copied: t('copied'), diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index d7e37d6962..57233ea381 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -224,7 +224,7 @@ describe('FileMutationRow diff card', () => { toggleRow(view) expect(view.container.querySelector('[data-diff]')).not.toBeNull() expect(view.getByText('hello fixture')).toBeTruthy() - expect(view.getByText('复制')).toBeTruthy() + expect(view.getByRole('button', { name: '复制' })).toBeTruthy() }) it('the summary is a path link that opens the tool path through the host', () => { diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 4b99d20991..4068deb520 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -131,7 +131,7 @@ type QuestionFlowProps = function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) { const questions = pending.questions const markdownLabels = useMemo(() => ({ - code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } }, footnotes: t('markdown.footnotes'), }), [t]) const initialProgress = useMemo(() => ({ diff --git a/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx b/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx index 0c0806a1ef..b36b62b599 100644 --- a/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx @@ -134,6 +134,7 @@ export function CordisDefineRow({ aria-labelledby={`${sourcePanelId}-${activeSource}`} >