From 8d2cd0cf72090cc58fe8cfc9258ebbdf58ac9f1e Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Mon, 21 Sep 2026 21:56:10 +0800 Subject: [PATCH 1/4] feat(web): unify code block styling and icon controls --- apps/web/tests/document-preview.e2e.ts | 7 +- .../clickable-links-gallery/ui.expected.md | 6 +- .../navigation-panes/code-card.expected.md | 4 + apps/web/tests/navigation-panes.e2e.ts | 23 +++++ packages/client/locale/src/locales/en.ts | 3 + packages/client/locale/src/locales/zh.ts | 3 + .../src/client/PresetGuideDialog.tsx | 9 +- .../ui-chat/src/client/markdown-labels.ts | 2 +- .../client/ui-plan/src/client/PlanPreview.tsx | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 2 + .../ui-primitives/src/CodeCard.module.css | 81 ++++++++++++++++ .../client/ui-primitives/src/CodeToolbar.tsx | 62 ++++++++++++ .../ui-primitives/src/DiffBlock.module.css | 67 ++++--------- .../client/ui-primitives/src/DiffBlock.tsx | 15 ++- .../ui-primitives/src/ReadBlock.module.css | 92 +++--------------- .../client/ui-primitives/src/ReadBlock.tsx | 29 +++--- .../src/markdown/CodeBlock.module.css | 19 ++++ .../ui-primitives/src/markdown/CodeBlock.tsx | 20 +++- .../src/markdown/MarkdownText.tsx | 5 +- .../ui-primitives/src/markdown/render.tsx | 3 + .../tests/code-card-controls.client.spec.tsx | 96 +++++++++++++++++++ .../ui-primitives/tests/labels.client.ts | 2 + .../tests/read-block.client.spec.tsx | 7 +- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 2 +- .../ui-sidebar-documentpreview/README.zh.md | 2 +- .../src/client/code/CodeBody.module.css | 1 + .../src/client/code/CodeBody.tsx | 2 + .../src/client/markdown/MarkdownBody.tsx | 7 +- .../tests/code-body.client.spec.tsx | 11 ++- packages/client/ui-theme/src/styles/shiki.css | 7 ++ .../tool/components/ToolDetails.module.css | 8 +- .../client/tool/components/ToolDetails.tsx | 3 +- .../client/tool/components/ToolRow.module.css | 6 -- .../src/client/tool/components/ToolRow.tsx | 5 +- .../client/tool/models/primitive-labels.ts | 14 ++- .../ui-tool/tests/diff-card.client.spec.tsx | 2 +- .../src/client/QuestionComposer.tsx | 2 +- .../ui-cordis/src/client/CordisDefineRow.tsx | 1 + 41 files changed, 449 insertions(+), 193 deletions(-) create mode 100644 apps/web/tests/expected/navigation-panes/code-card.expected.md create mode 100644 packages/client/ui-primitives/src/CodeCard.module.css create mode 100644 packages/client/ui-primitives/src/CodeToolbar.tsx create mode 100644 packages/client/ui-primitives/tests/code-card-controls.client.spec.tsx 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}`} > Date: Mon, 21 Sep 2026 22:40:55 +0800 Subject: [PATCH 2/4] fix(web): cover code-card overflow and refresh browser expectations --- apps/web/tests/code-card-layout.e2e.ts | 160 ++++++++++++++++++ .../clickable-links-gallery/ui.expected.md | 2 +- .../expected/cordis-history/ui.expected.md | 1 + .../navigation-panes/code-card.expected.md | 2 +- apps/web/tests/navigation-panes.e2e.ts | 6 +- .../mid-stream.expected.md | 1 + .../tests/streaming-fence-highlight.e2e.ts | 2 +- apps/web/tsconfig.json | 1 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../client/ui-primitives/src/CodeToolbar.tsx | 2 +- .../ui-primitives/src/DiffBlock.module.css | 6 + .../ui-primitives/src/ReadBlock.module.css | 4 - .../client/ui-primitives/src/ReadBlock.tsx | 8 +- packages/client/ui-primitives/src/index.ts | 1 + .../src/markdown/CodeBlock.module.css | 4 - .../ui-primitives/src/markdown/render.tsx | 3 +- .../tests/code-card-controls.client.spec.tsx | 5 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../ui-theme/src/styles/design-platform.css | 12 ++ packages/client/ui-theme/src/styles/shiki.css | 7 - .../client/tool/models/primitive-labels.ts | 4 +- snapshots/web/diff-bounded/ui.expected.md | 2 + snapshots/web/diff-context/ui.expected.md | 2 + .../ui-expanded.expected.md | 14 +- .../goal-multi-turn-actions/ui.expected.md | 10 +- snapshots/web/tool-details/ui.expected.md | 14 +- tsconfig.host.json | 1 + 31 files changed, 248 insertions(+), 42 deletions(-) create mode 100644 apps/web/tests/code-card-layout.e2e.ts diff --git a/apps/web/tests/code-card-layout.e2e.ts b/apps/web/tests/code-card-layout.e2e.ts new file mode 100644 index 0000000000..172d2d2ec9 --- /dev/null +++ b/apps/web/tests/code-card-layout.e2e.ts @@ -0,0 +1,160 @@ +/** Browser geometry for read offsets and horizontally scrolled diff fills. */ +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' +import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts' + +const LONG_LINE = 'source_columns_'.repeat(80) +const OFFSETS = [999, 99_999] + +/** Seed durable tool results without invoking a model or allocating a large file. */ +function layoutFixture(): string { + const calls: { name: string; args: Record; result: string; meta: JsonValue }[] = OFFSETS.map((offset) => { + const path = `offset-${offset}.txt` + const lines = [{ number: offset, text: 'short' }, { number: offset + 1, text: LONG_LINE }] + return { + name: 'read', args: { file_path: path, offset }, + result: `${path}\nfile\n\n${lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n`, + meta: { path, offset, lines, totalLines: offset + 2 }, + } + }) + const oldText = `old_${LONG_LINE}\nshort old` + const newText = `new_${LONG_LINE}\nshort new` + calls.push({ + name: 'edit', args: { file_path: 'long.txt', old_string: oldText, new_string: newText }, + result: 'Edited long.txt', meta: { diffs: [{ path: 'long.txt', oldText, newText }] }, + }) + const session = Session.create(SessionId('code-card-layout-source')) + session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Inspect the source windows and edit the long lines.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('step/start', { turn: 1, step: 1 }) + const namedCalls = calls.map((call, index) => ({ ...call, id: ToolCallId(`layout-${index}`), arguments: JSON.stringify(call.args) })) + session.append('assistant/message', { + stream: [], turn: 1, step: 1, + message: createAssistantMessage({ + content: namedCalls.map(call => ({ type: 'tool-call', id: call.id, name: call.name, arguments: call.arguments })), + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + for (const call of namedCalls) { + const source = session.append('tool/call', { turn: 1, step: 1, callId: call.id, name: call.name, arguments: call.arguments }) + session.append('tool/result', { + turn: 1, step: 1, meta: call.meta, + message: createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: call.result }], isError: false }), + }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] }) + } + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) + session.append('assistant/message', { + stream: [], turn: 1, step: 2, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'CODE_LAYOUT_DONE' }], source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return [ + JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0 }), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', + ].join('\n') +} + +describe('web e2e: code-card layout', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, layoutFixture(), 'code-card-layout') + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByText('CODE_LAYOUT_DONE', { exact: true }).waitFor() + await expandTurnProcesses(page) + for (const row of await page.locator('[data-variant="read"], [data-variant="edit"]').all()) { + await row.locator('[data-expandable]').click() + } + await page.locator('[data-read]').nth(1).waitFor() + await page.locator('[data-diff]').waitFor() + }) + + afterAll(async () => { + try { await browser?.close() } finally { await scaffold?.close() } + }) + + it.each(OFFSETS)('keeps offset %i line numbers clear of aligned source in both wrap modes', async (offset) => { + onTestFailed(() => saveFailureShot(page, `code-card-offset-${offset}`)) + const card = page.locator('[data-read]').filter({ hasText: `offset-${offset}.txt` }) + const wrap = card.getByRole('button', { name: 'Wrap lines', exact: true }) + const heights: number[] = [] + for (const wrapped of [false, true]) { + if (wrapped) await wrap.click() + expect(await wrap.getAttribute('aria-pressed')).toBe(String(wrapped)) + const rows = await card.locator('[class*="gutter"]').evaluateAll(gutters => gutters.map((gutter) => { + const content = gutter.nextElementSibling! + const range = document.createRange() + range.selectNodeContents(gutter) + const glyphs = range.getBoundingClientRect() + const slot = gutter.getBoundingClientRect() + const source = content.getBoundingClientRect() + return { + fits: glyphs.left >= slot.left - 1 && glyphs.right <= slot.right + 1, + gap: source.left - glyphs.right, left: source.left, height: source.height, + } + })) + expect(rows).toHaveLength(2) + expect(rows.every(row => row.fits && row.gap >= 11)).toBe(true) + expect(rows[0]!.left).toBe(rows[1]!.left) + heights.push(rows[1]!.height) + } + expect(heights[1]!).toBeGreaterThan(heights[0]!) + await wrap.click() + }) + + it.each([false, true])('fills long diff rows through horizontal scrolling with dark=%s', async (dark) => { + onTestFailed(() => saveFailureShot(page, `code-card-diff-${dark}`)) + await page.evaluate(value => document.body.toggleAttribute('data-ds-dark-theme', value), dark) + const card = page.locator('[data-diff]') + const body = card.locator(':scope > div').nth(1) + const wrap = card.getByRole('button', { name: 'Wrap lines', exact: true }) + for (const wrapped of [false, true]) { + if (wrapped) await wrap.click() + const result = await body.evaluate((element) => { + element.scrollLeft = element.scrollWidth + const viewport = element.getBoundingClientRect() + const rows = [...element.children].filter(row => ['+ ', '- '].includes(getComputedStyle(row, '::before').content.replaceAll('"', ''))) + return { + overflow: element.scrollWidth - element.clientWidth, + fills: rows.map(row => ({ + width: row.getBoundingClientRect().width, right: row.getBoundingClientRect().right, + fill: getComputedStyle(row).backgroundColor, marker: getComputedStyle(row).boxShadow, + })), + scrollWidth: element.scrollWidth, right: viewport.right, + } + }) + expect(result.fills).toHaveLength(4) + if (wrapped) expect(result.overflow).toBeLessThanOrEqual(1) + else expect(result.overflow).toBeGreaterThan(100) + for (const row of result.fills) { + expect(row.width).toBeGreaterThanOrEqual(result.scrollWidth - 1) + expect(row.right).toBeGreaterThanOrEqual(result.right - 1) + expect(row.fill).toMatch(dark ? /0\.12\)$/ : /0\.08\)$/) + expect(row.marker).toContain('inset') + } + } + await wrap.click() + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) 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 feaeaa2468..dea186cddd 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -137,7 +137,7 @@ - paragraph: - img "Token preview" - text: css -- button "Do not wrap lines" [pressed] +- button "Wrap lines" [pressed] - button "Copy" - code: "--inline-code: #F5F5F5;" - paragraph: LINK_GALLERY_DONE diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index 1b37b0484f..d492597420 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -22,6 +22,7 @@ - tab "Host" [selected] - tabpanel "Host": - text: javascript + - button "Wrap lines" [pressed] - button "Copy" - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" - text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings diff --git a/apps/web/tests/expected/navigation-panes/code-card.expected.md b/apps/web/tests/expected/navigation-panes/code-card.expected.md index 3e48942224..20b377502a 100644 --- a/apps/web/tests/expected/navigation-panes/code-card.expected.md +++ b/apps/web/tests/expected/navigation-panes/code-card.expected.md @@ -1,4 +1,4 @@ - text: Code block -- button "Do not wrap lines" [pressed] +- button "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 f43b020a60..2aaaaf5d0a 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -27,7 +27,8 @@ 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 CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url)) +const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl` @@ -231,7 +232,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { 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 }) + const unwrap = block.getByRole('button', { name: 'Wrap lines', exact: true }) await unwrap.hover() await page.getByRole('tooltip', { name: 'Do not wrap lines', exact: true }).waitFor() await unwrap.click() @@ -544,6 +545,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { }, 60_000) it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { + await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md']) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md', 'terminal-card.expected.md', 'timing.expected.md', diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index 19c2becc76..87f67da186 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -10,6 +10,7 @@ - button "Copy" - button "Context injection runtime-context" - text: ts +- button "Wrap lines" [pressed] - button "Copy" - code: "const first: number = 1 const second = \"two\" let tail" - status: Deep diving... diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts index 6d65cdca0d..48017c982d 100644 --- a/apps/web/tests/streaming-fence-highlight.e2e.ts +++ b/apps/web/tests/streaming-fence-highlight.e2e.ts @@ -85,7 +85,7 @@ async function fenceTree(block: ReturnType): Promise const pre = element.querySelector('pre.shiki') if (pre === null) throw new Error('streaming fence did not render through the shiki arm') return { - language: element.querySelector('[class*="infostring"]')?.textContent ?? '', + language: element.querySelector('[data-code-block-banner] [class*="language"]')?.textContent ?? '', pre: { className: pre.className, style: pre.style.cssText, diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9af71ce894..e7cc7396eb 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,6 +24,7 @@ "exclude": [ "tests/default-product-isolation.e2e.ts", "tests/diff-context.e2e.ts", + "tests/code-card-layout.e2e.ts", "tests/scaffold.ts", "tests/voice-input.e2e.ts", "tests/voice-setup.e2e.ts", diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index c14c318535..6f7cab38b8 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: 069aea78dd08175a9ebb1e590bb03e97dc08daab -README.zh.md: d235e19a9f9c829d4d5d6587fdd147fda9a2884e +README.md: 09eab3840b7cfea7f84d12688787f6bd0344efc0 +README.zh.md: c5e46728f9ae8dfe33dc3e67812602ecc6d58af0 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 069aea78dd..09eab3840b 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -87,7 +87,7 @@ 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. +`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. Read gutters fit the largest returned line number, and diff fills cover the full scrollable row. The wrap toggle keeps a fixed accessible name and exposes its state through `aria-pressed`; its tooltip describes the next action. 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. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index d235e19a9f..c5e46728f9 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -87,7 +87,7 @@ kind: "package-library" `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 -`CodeBlock.toolbarLabels` 启用共享代码卡片的标题栏和间距。`ReadBlock` 与 `DiffBlock` 使用相同的标题栏:secondary 色的语言名称、复制与换行图标,以及悬停或键盘聚焦时的提示。语言缺失或不受支持时显示调用方提供的本地化代码块标题;混合语言的 Diff 也使用该标题。代码卡片共用字体和内容内边距。换行设置仅属于当前挂载的卡片,不改变复制的文本;代码围栏默认换行,读取和差异卡片则在启用换行前保留源码列位置。紧凑 Markdown 不使用此工具栏。源码预览传入 `toolbarLabels` 后,可用 `wrap` 跟随所属视图的换行设置,仅显示语言和复制图标;所属视图的 CSS 保留源码布局。 +`CodeBlock.toolbarLabels` 启用共享代码卡片的标题栏和间距。`ReadBlock` 与 `DiffBlock` 使用相同的标题栏:secondary 色的语言名称、复制与换行图标,以及悬停或键盘聚焦时的提示。语言缺失或不受支持时显示调用方提供的本地化代码块标题;混合语言的 Diff 也使用该标题。代码卡片共用字体和内容内边距。读取卡片的行号栏容纳返回窗口中的最大行号,Diff 底色覆盖完整可滚动行。换行按钮保留固定的无障碍名称,以 `aria-pressed` 表达状态;提示文本说明下一步操作。换行设置仅属于当前挂载的卡片,不改变复制的文本;代码围栏默认换行,读取和差异卡片则在启用换行前保留源码列位置。紧凑 Markdown 不使用此工具栏。源码预览传入 `toolbarLabels` 后,可用 `wrap` 跟随所属视图的换行设置,仅显示语言和复制图标;所属视图的 CSS 保留源码布局。 `DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,摘要和底部统计都不计入共享上下文。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。 diff --git a/packages/client/ui-primitives/src/CodeToolbar.tsx b/packages/client/ui-primitives/src/CodeToolbar.tsx index 755753e894..897d4cd1f7 100644 --- a/packages/client/ui-primitives/src/CodeToolbar.tsx +++ b/packages/client/ui-primitives/src/CodeToolbar.tsx @@ -47,7 +47,7 @@ export function CodeToolbar({ lang, title, status, labels, copyLabel, copiedLabe
{status !== undefined && {status}} {onWrap !== undefined && - } diff --git a/packages/client/ui-primitives/src/DiffBlock.module.css b/packages/client/ui-primitives/src/DiffBlock.module.css index 649237832c..8e78a0b708 100644 --- a/packages/client/ui-primitives/src/DiffBlock.module.css +++ b/packages/client/ui-primitives/src/DiffBlock.module.css @@ -3,6 +3,8 @@ } .body { + display: grid; + grid-template-columns: minmax(max-content, 1fr); padding: 6px 0 16px; font: var(--dsw-font-markdown-code-block); overflow-x: auto; @@ -15,6 +17,10 @@ white-space: pre; } +.block[data-code-wrap='true'] .body { + grid-template-columns: minmax(0, 1fr); +} + .block[data-code-wrap='true'] .line { white-space: pre-wrap; overflow-wrap: anywhere; diff --git a/packages/client/ui-primitives/src/ReadBlock.module.css b/packages/client/ui-primitives/src/ReadBlock.module.css index fbff018f50..601bb29f48 100644 --- a/packages/client/ui-primitives/src/ReadBlock.module.css +++ b/packages/client/ui-primitives/src/ReadBlock.module.css @@ -1,7 +1,3 @@ -.block { - --dsl-read-gutter: 3ch; -} - .line { display: flex; min-height: 1lh; diff --git a/packages/client/ui-primitives/src/ReadBlock.tsx b/packages/client/ui-primitives/src/ReadBlock.tsx index e1493e3316..0eaf96374f 100644 --- a/packages/client/ui-primitives/src/ReadBlock.tsx +++ b/packages/client/ui-primitives/src/ReadBlock.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { useCallback, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react' import clsx from 'clsx' import { FoldToggle } from './FoldToggle.tsx' import { writeClipboard } from './clipboard.ts' @@ -119,14 +119,18 @@ export function ReadBlock({
)) + const gutterDigits = lines.reduce((digits, line) => Math.max(digits, String(line.number).length), 3) + const gutterStyle = { '--dsl-read-gutter': `${gutterDigits}ch` } as CSSProperties + const paired = lines.map((line, index): readonly [ReadBlockLine, readonly HighlightSpan[] | undefined] => [line, highlighted?.[index]]) return ( -
+
0 ? onCopy : undefined} onWrap={() => { setWrapped(value => !value) }} />
diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 12518ed198..b8ec370298 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -2,6 +2,7 @@ * Cordis-free React primitives styled only through `--dsw-*` tokens. */ +export type { CodeToolbarLabels } from './CodeToolbar.tsx' export { StateDot } from './StateDot.tsx' export type { StateDotState } from './StateDot.tsx' export { DisclosureRow } from './DisclosureRow.tsx' diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index 3c2cf7eebe..49bcfe33a7 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -104,10 +104,6 @@ color: var(--dsw-alias-label-primary); } -.card { - --dsl-code-block-content-font: var(--dsw-font-markdown-code-block); -} - .card :where(pre) { padding: 6px 18px 16px; word-break: normal; diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index cdf1b62ec9..a7bb8a2365 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' @@ -37,7 +38,7 @@ export interface MarkdownCodeLabels { /** 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 + toolbarLabels?: CodeToolbarLabels | undefined } /** Localized chrome for a Markdown document. */ 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 index 467aa5e833..e4b152650f 100644 --- a/packages/client/ui-primitives/tests/code-card-controls.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-card-controls.client.spec.tsx @@ -37,10 +37,11 @@ describe('code-card controls', () => { : kind === 'read' ? : ) - const button = screen.getByRole('button', { name: kind === 'code' ? 'Do not wrap lines' : 'Wrap lines' }) + 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)) @@ -52,7 +53,7 @@ describe('code-card controls', () => { fireEvent.mouseEnter(copy) expect(screen.getByRole('tooltip').textContent).toBe('Copy') fireEvent.mouseLeave(copy) - const wrap = screen.getByRole('button', { name: 'Do not wrap lines' }) + const wrap = screen.getByRole('button', { name: 'Wrap lines' }) fireEvent.mouseEnter(wrap) expect(screen.getByRole('tooltip').textContent).toBe('Do not wrap lines') fireEvent.click(wrap) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index e48e09fdac..819e09548b 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: 9526078829a882f18b08e5cc7ac8a9bb5ac7dfe4 -README.zh.md: e487d85191d1afdb4d1d013459f94c0742d93b1d +README.md: f1fb30221bb176648cc99f1b2a39984ff3b84871 +README.zh.md: c1e44eb5b9307dbb49e0868b0c68af3410b38928 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 9526078829..f1fb30221b 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -51,7 +51,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou ### Stylesheets -`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. +`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries; `shiki.css` owns syntax colors. `corner-shape.css` smooths every rounded corner: inside `@supports (corner-shape: superellipse(1.5))` it defines `--dsw-corner-shape` and applies it to all elements and their `::before`/`::after` through the universal selector, so engines without `corner-shape` keep circular corners. Full-round shapes — `border-radius: 50%` circles and pill radii — pair `corner-shape: round` with their radius in the owning component sheet because a superellipse deforms them; the corner-shape stylesheet spec enforces that pairing across every package stylesheet. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index e487d85191..c1e44eb5b9 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" ### 样式表 -`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。 +`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶;`shiki.css` 负责语法颜色。 `corner-shape.css` 平滑所有圆角:在 `@supports (corner-shape: superellipse(1.5))` 内定义 `--dsw-corner-shape`,并通过通配选择器应用到所有元素及其 `::before`/`::after`,因此不支持 `corner-shape` 的引擎保持普通圆弧。正圆形状——`border-radius: 50%` 的圆与胶囊半径——因超级椭圆会使其变形,须在所属组件样式表中把 `corner-shape: round` 与半径声明配对;corner-shape 样式表 spec 跨全部包样式表强制这一配对。 diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 4cc18f3be5..929b24be6c 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -34,6 +34,8 @@ body { --dsw-static-green-100: rgb(230, 250, 237); --dsw-static-green-400: rgb(78, 209, 126); --dsw-static-green-500: rgb(34, 197, 94); + --dsw-static-green-500-a08: rgb(34 197 94 / 8%); + --dsw-static-green-500-a12: rgb(34 197 94 / 12%); --dsw-static-green-900: rgb(35, 60, 44); --dsw-static-neutral-00: rgb(255, 255, 255); --dsw-static-neutral-1000: rgb(0, 0, 0); @@ -72,9 +74,11 @@ body { --dsw-static-neutral-bluish-950: rgb(21, 21, 23); --dsw-static-red-100: rgb(254, 226, 226); --dsw-static-red-400: rgb(242, 90, 90); + --dsw-static-red-400-a12: rgb(242 90 90 / 12%); --dsw-static-red-500: rgb(239, 68, 68); --dsw-static-red-50: rgb(254, 242, 242); --dsw-static-red-600: rgb(236, 19, 19); + --dsw-static-red-600-a08: rgb(236 19 19 / 8%); --dsw-static-red-900: rgb(87, 12, 12); } @@ -111,6 +115,8 @@ body[data-ds-dark-theme] { --dsw-static-green-100: rgb(230, 250, 237); --dsw-static-green-400: rgb(78, 209, 126); --dsw-static-green-500: rgb(34, 197, 94); + --dsw-static-green-500-a08: rgb(34 197 94 / 8%); + --dsw-static-green-500-a12: rgb(34 197 94 / 12%); --dsw-static-green-900: rgb(35, 60, 44); --dsw-static-neutral-00: rgb(255, 255, 255); --dsw-static-neutral-1000: rgb(0, 0, 0); @@ -149,9 +155,11 @@ body[data-ds-dark-theme] { --dsw-static-neutral-bluish-950: rgb(21, 21, 23); --dsw-static-red-100: rgb(254, 226, 226); --dsw-static-red-400: rgb(242, 90, 90); + --dsw-static-red-400-a12: rgb(242 90 90 / 12%); --dsw-static-red-500: rgb(239, 68, 68); --dsw-static-red-50: rgb(254, 242, 242); --dsw-static-red-600: rgb(236, 19, 19); + --dsw-static-red-600-a08: rgb(236 19 19 / 8%); --dsw-static-red-900: rgb(87, 12, 12); } @@ -226,6 +234,8 @@ body { --dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-300); --dsw-alias-state-business-primary: var(--dsw-static-deepseek-500); --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-100); + --dsw-alias-code-diff-added: var(--dsw-static-green-500-a08); + --dsw-alias-code-diff-deleted: var(--dsw-static-red-600-a08); --dsw-alias-state-error-primary: var(--dsw-static-red-600); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); --dsw-alias-state-idle-primary: var(--dsw-static-neutral-300); @@ -324,6 +334,8 @@ body[data-ds-dark-theme] { --dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550); --dsw-alias-state-business-primary: var(--dsw-static-deepseek-400); --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800); + --dsw-alias-code-diff-added: var(--dsw-static-green-500-a12); + --dsw-alias-code-diff-deleted: var(--dsw-static-red-400-a12); --dsw-alias-state-error-primary: var(--dsw-static-red-400); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); --dsw-alias-state-idle-primary: var(--dsw-static-neutral-600); diff --git a/packages/client/ui-theme/src/styles/shiki.css b/packages/client/ui-theme/src/styles/shiki.css index f969d1b264..c7a3c5d272 100644 --- a/packages/client/ui-theme/src/styles/shiki.css +++ b/packages/client/ui-theme/src/styles/shiki.css @@ -4,11 +4,6 @@ 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); @@ -24,8 +19,6 @@ body { } 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/models/primitive-labels.ts b/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts index 156a1f3c21..af1e13679b 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,7 +1,7 @@ /** Localized copy adapters for Cordis-free UI primitives used by Tool cards. */ import type { - CodeBlockProps, + CodeToolbarLabels, DiffBlockLabels, MarkdownLabels, ReadBlockLabels, @@ -17,7 +17,7 @@ type T = TranslateNS<'conversation'> * @param t - Conversation locale seat. * @returns Language fallback and wrapping actions. */ -export function codeToolbarLabels(t: T): NonNullable { +export function codeToolbarLabels(t: T): CodeToolbarLabels { return { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } } diff --git a/snapshots/web/diff-bounded/ui.expected.md b/snapshots/web/diff-bounded/ui.expected.md index 056f1e2156..1883ed4a1c 100644 --- a/snapshots/web/diff-bounded/ui.expected.md +++ b/snapshots/web/diff-bounded/ui.expected.md @@ -2,6 +2,8 @@ - text: Edit - button "large.txt" - text: +130 -130 +- text: Code block +- button "Wrap lines" - button "Copy" - text: large.txt - shared heading - old setting 0 - old setting 1 - old setting 2 - button "Expand 252 more diff lines": … 252 more lines diff --git a/snapshots/web/diff-context/ui.expected.md b/snapshots/web/diff-context/ui.expected.md index 628877899b..44e1810526 100644 --- a/snapshots/web/diff-context/ui.expected.md +++ b/snapshots/web/diff-context/ui.expected.md @@ -2,6 +2,8 @@ - text: Edit - button "config.txt" - text: +1 -1 +- text: Code block +- button "Wrap lines" - button "Copy" - text: config.txt - mode=DEBUG + mode=RELEASE level=info └ +1 -1 · 1 file - button "Inspect" diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index 7c9f533f8c..1492d81803 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -31,8 +31,12 @@ - text: — 随机选中的包: - code: packages/context/session-reference - text: 📦 +- text: Code block +- button "Wrap lines" [pressed] - button "Copy" - code: session-reference ├── README.md ├── package.json ├── src │ ├── index.ts │ └── uri.ts └── tests └── session-reference.spec.ts +- text: Code block +- button "Wrap lines" [pressed] - button "Copy" - code: "session-reference/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ ├── index.ts # 入口,导出公共 API │ └── uri.ts # 会话引用 URI 相关实现 └── tests/ └── session-reference.spec.ts # 单元测试" - paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。 @@ -54,8 +58,12 @@ - text: — 随机选中的包: - code: packages/llm/token-meter - text: 📦 +- text: Code block +- button "Wrap lines" [pressed] - button "Copy" - code: token-meter ├── README.md ├── package.json ├── src │ └── index.ts └── tests └── token-meter.spec.ts +- text: Code block +- button "Wrap lines" [pressed] - button "Copy" - code: "token-meter/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ └── index.ts # 入口,导出公共 API(token 计量逻辑) └── tests/ └── token-meter.spec.ts # 单元测试" - paragraph: @@ -76,14 +84,16 @@ - strong: Turn 1 - text: — 随机选中 - code: packages/context/session-reference - - text: ,输出了文件结构: + - text: ,输出了文件结构: Code block + - button "Wrap lines" [pressed] - button "Copy" - code: session-reference/ ├── README.md ├── package.json ├── src/ │ ├── index.ts │ └── uri.ts └── tests/ └── session-reference.spec.ts - listitem: - strong: Turn 2 - text: — 随机选中 - code: packages/llm/token-meter - - text: ,输出了文件结构: + - text: ,输出了文件结构: Code block + - button "Wrap lines" [pressed] - button "Copy" - code: token-meter/ ├── README.md ├── package.json ├── src/ │ └── index.ts └── tests/ └── token-meter.spec.ts - paragraph: diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index 2d7d6a5d24..0251c8d0e7 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -17,8 +17,12 @@ - text: — 随机选中的包: - code: packages/context/session-reference - text: 📦 +- text: Code block +- button "Wrap lines" [pressed] - button "Copy" - code: session-reference ├── README.md ├── package.json ├── src │ ├── index.ts │ └── uri.ts └── tests └── session-reference.spec.ts +- text: Code block +- button "Wrap lines" [pressed] - button "Copy" - code: "session-reference/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ ├── index.ts # 入口,导出公共 API │ └── uri.ts # 会话引用 URI 相关实现 └── tests/ └── session-reference.spec.ts # 单元测试" - paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。 @@ -36,14 +40,16 @@ - strong: Turn 1 - text: — 随机选中 - code: packages/context/session-reference - - text: ,输出了文件结构: + - text: ,输出了文件结构: Code block + - button "Wrap lines" [pressed] - button "Copy" - code: session-reference/ ├── README.md ├── package.json ├── src/ │ ├── index.ts │ └── uri.ts └── tests/ └── session-reference.spec.ts - listitem: - strong: Turn 2 - text: — 随机选中 - code: packages/llm/token-meter - - text: ,输出了文件结构: + - text: ,输出了文件结构: Code block + - button "Wrap lines" [pressed] - button "Copy" - code: token-meter/ ├── README.md ├── package.json ├── src/ │ └── index.ts └── tests/ └── token-meter.spec.ts - paragraph: diff --git a/snapshots/web/tool-details/ui.expected.md b/snapshots/web/tool-details/ui.expected.md index bd5a22851a..4a7cc83df9 100644 --- a/snapshots/web/tool-details/ui.expected.md +++ b/snapshots/web/tool-details/ui.expected.md @@ -129,6 +129,7 @@ - list: - listitem: - text: json + - button "Wrap lines" [pressed] - button "Copy" - code: "{ \"type\": \"object\", \"properties\": { \"service\": { \"type\": \"string\" } } }" - group: @@ -136,6 +137,7 @@ - list: - listitem: - text: json + - button "Wrap lines" [pressed] - button "Copy" - code: "{ \"type\": \"object\" }" - listitem: @@ -154,6 +156,7 @@ - list: - listitem: - text: json + - button "Wrap lines" [pressed] - button "Copy" - code: "{ \"type\": \"object\", \"properties\": { \"root\": { \"type\": \"string\" } } }" - group: @@ -161,6 +164,7 @@ - list: - listitem: - text: json + - button "Wrap lines" [pressed] - button "Copy" - code: "{ \"type\": \"object\" }" - button "Inspect" @@ -224,6 +228,7 @@ - list: - listitem: - text: javascript + - button "Wrap lines" [pressed] - button "Copy" - code: "return { reviewed: 12, needsDetails: [\"list_agents\", \"job_list\", \"terminal_list\"] };" - listitem: @@ -440,7 +445,8 @@ - text: Recorded result - list: - listitem: - - text: job-demo-build Running + - text: job-demo-build Running Code block + - button "Wrap lines" [pressed] - button "Copy" - code: Building client assets… ✓ 184 modules transformed. dist/index.js 92.4 kB - button "Inspect" @@ -458,6 +464,8 @@ - text: preview Started terminal-demo-1 - term: Type - definition: shell + - text: Code block + - button "Wrap lines" [pressed] - button "Copy" - code: /workspace/demo % - button "Inspect" @@ -465,7 +473,8 @@ - text: Recorded result - list: - listitem: - - text: terminal-demo-1 Lines 16–18 of 18 + - text: terminal-demo-1 Lines 16–18 of 18 Code block + - button "Wrap lines" [pressed] - button "Copy" - code: "VITE v7.0.0 ready in {{duration}} Local: http://localhost:5173/ Watching for file changes…" - button "Inspect" @@ -545,6 +554,7 @@ - listitem: - button "src/tool-details.ts:24:1" - text: typescript + - button "Wrap lines" [pressed] - button "Copy" - code: "function renderToolDetails(model: ToolDetailsModel): ReactNode" - paragraph: Render compact fields and list rows from a recorded tool result. diff --git a/tsconfig.host.json b/tsconfig.host.json index 140e9f5894..f359eaba9a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -12,6 +12,7 @@ "apps/web/product-isolation.ts", "apps/web/tests/default-product-isolation.e2e.ts", "apps/web/tests/diff-context.e2e.ts", + "apps/web/tests/code-card-layout.e2e.ts", "apps/web/tests/scaffold.ts", "apps/web/tests/voice-input.e2e.ts", "apps/web/tests/voice-setup.e2e.ts", From 62f816f3a57ce44cec5cdbe1177940c8d4986150 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Tue, 22 Sep 2026 15:44:57 +0800 Subject: [PATCH 3/4] feat(web): refine code card spacing and thinking blocks --- apps/web/tests/diff-context.e2e.ts | 3 +- .../clickable-links-gallery/ui.expected.md | 2 +- .../expected/thinking-markdown/ui.expected.md | 1 + apps/web/tests/thinking-markdown.e2e.ts | 12 ++++++-- .../ui-conversation/src/client/locales.ts | 4 --- .../client/ui-primitives/README.i18n.yaml | 4 +-- packages/client/ui-primitives/README.md | 4 +-- packages/client/ui-primitives/README.zh.md | 4 +-- .../ui-primitives/src/CodeCard.module.css | 8 +++-- .../ui-primitives/src/DiffBlock.module.css | 12 ++------ .../client/ui-primitives/src/DiffBlock.tsx | 23 +++++--------- .../src/markdown/CodeBlock.module.css | 2 +- .../ui-primitives/src/markdown/CodeBlock.tsx | 2 +- .../src/markdown/MarkdownText.module.css | 18 ----------- .../src/markdown/MarkdownText.tsx | 5 +--- .../ui-primitives/src/markdown/render.tsx | 2 +- .../tests/code-card-controls.client.spec.tsx | 10 ++++--- .../tests/diff-block.client.spec.tsx | 30 +++---------------- .../ui-primitives/tests/labels.client.ts | 1 - .../README.i18n.yaml | 4 +-- .../ui-sidebar-documentpreview/README.md | 2 +- .../ui-sidebar-documentpreview/README.zh.md | 2 +- packages/client/ui-tool/README.i18n.yaml | 4 +-- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../src/client/tool/components/ToolRow.tsx | 5 ++-- .../client/tool/models/primitive-labels.ts | 1 - .../ui-tool/tests/diff-card.client.spec.tsx | 4 +-- .../ui-tool/tests/tool-row.client.spec.tsx | 4 +-- snapshots/web/diff-bounded/ui.expected.md | 2 +- snapshots/web/diff-context/ui.expected.md | 2 +- 31 files changed, 64 insertions(+), 117 deletions(-) diff --git a/apps/web/tests/diff-context.e2e.ts b/apps/web/tests/diff-context.e2e.ts index cf893ac7ec..5f94739c5e 100644 --- a/apps/web/tests/diff-context.e2e.ts +++ b/apps/web/tests/diff-context.e2e.ts @@ -55,7 +55,8 @@ describe.skipIf(MODE === 'record').each(CASES)('web e2e: $name', (scenario) => { const card = edit.locator('[data-diff]') await card.waitFor() expect(await card.getByText(scenario.shared, { exact: true }).count()).toBe(1) - expect(await card.textContent()).toContain(`${scenario.totals} · 1 file`) + expect(await edit.getByText(scenario.totals, { exact: true }).count()).toBe(1) + expect(await card.textContent()).not.toContain(scenario.totals) const snapshotDir = `${ROOT}/web/${scenario.name}` await compareOrRefreshGolden(`${snapshotDir}/ui.expected.md`, await captureStableAria(page, '[data-variant="edit"]', scaffold.workspaceCwd), MODE) 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 4b05baf666..ab3230e5bd 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -37,7 +37,7 @@ - text: css - button "Wrap lines" - button "Copy" -- text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5; └ +1 -1 · 1 file" +- text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5;" - button "Inspect" - button "Tool call str_replace_editor · create" [expanded] - text: "IN { \"command\": \"create\", \"path\": \"docs/press.md\", \"file_text\": \"# Press kit\\n\" } OUT Created docs/press.md" diff --git a/apps/web/tests/expected/thinking-markdown/ui.expected.md b/apps/web/tests/expected/thinking-markdown/ui.expected.md index ba8e88404d..5f243d6976 100644 --- a/apps/web/tests/expected/thinking-markdown/ui.expected.md +++ b/apps/web/tests/expected/thinking-markdown/ui.expected.md @@ -214,6 +214,7 @@ - text: . - math: a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + 1 1 + 1 x - text: typescript +- button "Wrap lines" [pressed] - button "Copy" - code: const value = "reasoning code" - paragraph: {{longToken}} diff --git a/apps/web/tests/thinking-markdown.e2e.ts b/apps/web/tests/thinking-markdown.e2e.ts index 94c9cb8a09..ea2a6efb76 100644 --- a/apps/web/tests/thinking-markdown.e2e.ts +++ b/apps/web/tests/thinking-markdown.e2e.ts @@ -170,6 +170,8 @@ describe('web e2e: secondary Thinking Markdown', () => { await page.evaluate(async () => { await document.fonts.ready }) const styles = await markdown.evaluate((root, secondary) => { const elements = [...root.querySelectorAll('h1,h2,h3,h4,h5,h6,p,li,a,strong,em,pre,pre code,pre span,th,td,.katex')] + const prose = elements.filter(element => element.closest('pre') === null) + const code = elements.filter(element => element.closest('pre') !== null) const scrollers = [...root.querySelectorAll('[class*="tableScroll"],.katex-display,p:has(.katex),ul:has(.katex)')] const shortFormulas = [...root.querySelectorAll('p')].filter(element => element.textContent?.startsWith('Short ')) // Native KaTeX baselines vary with host fonts; compact adds one pixel for descender ink. @@ -190,9 +192,13 @@ describe('web e2e: secondary Thinking Markdown', () => { const banner = root.querySelector('[data-code-block-banner]')?.parentElement const loose = [...root.querySelectorAll('li p')].find(element => element.textContent === 'Loose middle paragraph.') return { - secondarySize: elements.every(element => getComputedStyle(element).fontSize === secondary.fontSize), - secondaryLine: elements.filter(element => !element.classList.contains('katex')) + secondarySize: prose.every(element => getComputedStyle(element).fontSize === secondary.fontSize), + secondaryLine: prose.filter(element => !element.classList.contains('katex')) .every(element => getComputedStyle(element).lineHeight === secondary.lineHeight), + codeTypography: code.length > 0 && code.every((element) => { + const style = getComputedStyle(element) + return style.fontSize === '11px' && style.lineHeight === '19px' + }), tertiaryColor: elements.every(element => getComputedStyle(element).color === secondary.color), tertiaryMarkers: [...root.querySelectorAll('li')] .every(element => getComputedStyle(element, '::marker').color === secondary.color), @@ -216,7 +222,7 @@ describe('web e2e: secondary Thinking Markdown', () => { } }, summaryStyle) expect(styles, `Thinking typography at ${String(width)}px`).toEqual({ - secondarySize: true, secondaryLine: true, tertiaryColor: true, tertiaryMarkers: true, + secondarySize: true, secondaryLine: true, codeTypography: true, tertiaryColor: true, tertiaryMarkers: true, contained: true, bannerStatic: true, looseSpacing: true, wideContentBounded: true, displayMathScrolls: true, shortMathNaturalHeight: true, shortMathNoScrollbar: true, longAtomScrolls: true, mathListMarkerSpace: true, diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 8ddad82052..aab4735316 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -307,8 +307,6 @@ export const zh = { 'tool.autoReviewRejected': 'Auto review 已拒绝', 'tool.autoReviewNotExecuted': '工具未执行。原因:{reason}', 'tool.autoReviewReasonFallback': 'Auto review 未授权此次操作', - 'diff.files.one': '{count} 个文件', - 'diff.files.other': '{count} 个文件', 'diff.collapseAria': '收起差异', 'diff.expandAria': '展开其余 {count} 行差异', 'diff.expandRest': '… 其余 {count} 行', @@ -661,8 +659,6 @@ export const en = { 'tool.autoReviewRejected': 'Rejected by Auto review', 'tool.autoReviewNotExecuted': 'Tool was not executed. Reason: {reason}', 'tool.autoReviewReasonFallback': 'Auto review did not authorize this action', - 'diff.files.one': '{count} file', - 'diff.files.other': '{count} files', 'diff.collapseAria': 'Collapse diff', 'diff.expandAria': 'Expand {count} more diff lines', 'diff.expandRest': '… {count} more lines', diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b3c517de78..fec5a872d2 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: bc59c7896998e7ae2466d33deef2e732a4387d1f -README.zh.md: cfd0bb0444701874b6ddc3c80011d8d2110a1f6b +README.md: 51947fcbdec941a31ed0baa221fe36603dfc3702 +README.zh.md: 6ccb781026d66f56263dfa520fb5466e7bd118de diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index bc59c78969..51947fcbde 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -87,9 +87,9 @@ 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. Read gutters fit the largest returned line number, and diff fills cover the full scrollable row. The wrap toggle keeps a fixed accessible name and exposes its state through `aria-pressed`; its tooltip describes the next action. 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. +`CodeBlock.toolbarLabels` enables the shared code-card header and spacing. `ReadBlock` and `DiffBlock` use the same header: tertiary-colored language and filename text, secondary-colored 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; their headers and bodies use the same background in both themes. Read gutters fit the largest returned line number, and diff fills cover the full scrollable row. The wrap toggle keeps a fixed accessible name and exposes its state through `aria-pressed`; its tooltip describes the next action. 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 shares this toolbar, spacing, and code typography while retaining monochrome text. 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. +`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 tool-summary totals. The card ends after the diff body. 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 cfd0bb0444..6ccb781026 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -87,9 +87,9 @@ kind: "package-library" `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 -`CodeBlock.toolbarLabels` 启用共享代码卡片的标题栏和间距。`ReadBlock` 与 `DiffBlock` 使用相同的标题栏:secondary 色的语言名称、复制与换行图标,以及悬停或键盘聚焦时的提示。语言缺失或不受支持时显示调用方提供的本地化代码块标题;混合语言的 Diff 也使用该标题。代码卡片共用字体和内容内边距。读取卡片的行号栏容纳返回窗口中的最大行号,Diff 底色覆盖完整可滚动行。换行按钮保留固定的无障碍名称,以 `aria-pressed` 表达状态;提示文本说明下一步操作。换行设置仅属于当前挂载的卡片,不改变复制的文本;代码围栏默认换行,读取和差异卡片则在启用换行前保留源码列位置。紧凑 Markdown 不使用此工具栏。源码预览传入 `toolbarLabels` 后,可用 `wrap` 跟随所属视图的换行设置,仅显示语言和复制图标;所属视图的 CSS 保留源码布局。 +`CodeBlock.toolbarLabels` 启用共享代码卡片的标题栏和间距。`ReadBlock` 与 `DiffBlock` 使用相同的标题栏:tertiary 色的语言名称与文件名、secondary 色的复制与换行图标,以及悬停或键盘聚焦时的提示。语言缺失或不受支持时显示调用方提供的本地化代码块标题;混合语言的 Diff 也使用该标题。代码卡片共用字体和内容内边距;在两种主题下,标题栏和正文都使用相同背景。读取卡片的行号栏容纳返回窗口中的最大行号,Diff 底色覆盖完整可滚动行。换行按钮保留固定的无障碍名称,以 `aria-pressed` 表达状态;提示文本说明下一步操作。换行设置仅属于当前挂载的卡片,不改变复制的文本;代码围栏默认换行,读取和差异卡片则在启用换行前保留源码列位置。紧凑 Markdown 共用此工具栏、间距和代码字体排版,同时保留单色文本。源码预览传入 `toolbarLabels` 后,可用 `wrap` 跟随所属视图的换行设置,仅显示语言和复制图标;所属视图的 CSS 保留源码布局。 -`DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,摘要和底部统计都不计入共享上下文。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。 +`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 index fa3a6e579a..b27ce9ee8f 100644 --- a/packages/client/ui-primitives/src/CodeCard.module.css +++ b/packages/client/ui-primitives/src/CodeCard.module.css @@ -12,8 +12,8 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 10px 18px 4px; - background: var(--dsw-alias-markdown-code-block-banner); + padding: 10px 18px 8px 22px; + background: var(--dsl-code-block-background, var(--dsw-alias-markdown-code-block)); border-top-left-radius: inherit; border-top-right-radius: inherit; color: var(--dsw-alias-label-secondary); @@ -28,11 +28,13 @@ } .language { + color: var(--dsw-alias-label-tertiary); flex: none; font-family: var(--ds-font-family-code); } .title { + color: var(--dsw-alias-label-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -75,7 +77,7 @@ } .body { - padding: 6px 18px 16px; + padding: 6px 22px 20px; font: var(--dsw-font-markdown-code-block); overflow-x: auto; } diff --git a/packages/client/ui-primitives/src/DiffBlock.module.css b/packages/client/ui-primitives/src/DiffBlock.module.css index 8e78a0b708..3b8c641d8b 100644 --- a/packages/client/ui-primitives/src/DiffBlock.module.css +++ b/packages/client/ui-primitives/src/DiffBlock.module.css @@ -5,7 +5,7 @@ .body { display: grid; grid-template-columns: minmax(max-content, 1fr); - padding: 6px 0 16px; + padding: 6px 0 20px; font: var(--dsw-font-markdown-code-block); overflow-x: auto; } @@ -13,7 +13,7 @@ .line { box-sizing: border-box; min-height: 1lh; - padding: 0 18px; + padding: 0 22px; white-space: pre; } @@ -66,7 +66,7 @@ .expand { display: block; width: 100%; - padding: 0 18px; + padding: 0 22px; border: none; background: transparent; color: var(--dsw-alias-label-tertiary); @@ -78,9 +78,3 @@ .expand:hover { color: var(--dsw-alias-label-secondary); } - -.footer { - 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 d9acc67fff..87ef5131ae 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -43,7 +43,6 @@ export interface DiffBlockLabels extends CodeToolbarLabels { expandAria: (hidden: number) => string collapse: string expand: (hidden: number) => string - files: (count: number) => string } /** A single rendered body line and its role, so the height cap slices a flat list. */ @@ -85,7 +84,7 @@ function localHunks(diff: DiffHunk) { * comparisons exceeding the edit limit count both complete fragments as replaced. * Text follows {@link contentLines}'s terminator rule. * @param diffs - the hunks to count. - * @returns the +/- totals for summaries and the card footer. + * @returns the +/- totals for tool summaries. */ export function diffTotals(diffs: DiffHunk[]): { added: number; removed: number } { let added = 0 @@ -102,18 +101,16 @@ export function diffTotals(diffs: DiffHunk[]): { added: number; removed: number } /** - * Flatten local patches into rows and count only added and removed lines. + * Flatten local patches into rows. * A path header opens each new file. A `⋯` gap separates consecutive same-file - * fragments and distant patches within a fragment. File counts use distinct paths. + * fragments and distant patches within a fragment. * @param diffs - the hunks to render. - * @returns the body rows, the +/- totals, and the distinct-file count. + * @returns the body rows. */ -function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } { +function buildRows(diffs: DiffHunk[]): DiffRow[] { const rows: DiffRow[] = [] - const paths = new Set() let prevPath: string | undefined for (const diff of diffs) { - paths.add(diff.path) if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path }) else rows.push({ kind: 'gap', text: '⋯' }) prevPath = diff.path @@ -125,12 +122,7 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed } } } - return { - rows, - added: rows.filter(row => row.kind === 'add').length, - removed: rows.filter(row => row.kind === 'del').length, - files: paths.size, - } + return rows } /** @@ -174,7 +166,7 @@ function copyText(rows: DiffRow[]): string { * @returns the diff block element. */ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) { - const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs]) + const rows = useMemo(() => buildRows(diffs), [diffs]) const [expanded, setExpanded] = useState(false) const [copied, setCopied] = useState(false) const [wrapped, setWrapped] = useState(false) @@ -224,7 +216,6 @@ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, cl
{row.text}
))}
-
└ +{added} -{removed} · {labels.files(files)}
) } diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index 49bcfe33a7..fa76358887 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -105,7 +105,7 @@ } .card :where(pre) { - padding: 6px 18px 16px; + padding: 6px 22px 20px; word-break: normal; overflow-wrap: anywhere; } diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 8eb85821b9..51d175393d 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -36,7 +36,7 @@ export interface CodeBlockProps { copyLabel: string /** Copy-button label during the post-copy confirmation window. */ copiedLabel: string - /** Enable the shared card toolbar and spacing; omit for owner-styled source or compact views. */ + /** Enable the shared card toolbar and spacing; omit for custom toolbar layouts. */ toolbarLabels?: CodeToolbarLabels | undefined /** With toolbarLabels, use the owner's wrapping preference and omit the toolbar's local wrap action. */ wrap?: boolean | undefined 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/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 25b9a00172..ab2c0b380a 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: providedLabels, fileMentions, pathImages, variant = 'body', + text, streaming = false, labels, fileMentions, pathImages, variant = 'body', }: { text: string streaming?: boolean @@ -181,9 +181,6 @@ 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 a7bb8a2365..84a9c2857a 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -37,7 +37,7 @@ 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. */ + /** Shared card controls; omitted for custom toolbar layouts. */ toolbarLabels?: CodeToolbarLabels | undefined } 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 index e4b152650f..62d448f499 100644 --- a/packages/client/ui-primitives/tests/code-card-controls.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-card-controls.client.spec.tsx @@ -78,11 +78,13 @@ describe('code-card controls', () => { } }) - it('preserves compact Markdown and owner-styled source controls', () => { + it('shows compact Markdown icon actions and preserves custom 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') + 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') }) 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 80b8cb7305..4d5ae803ad 100644 --- a/packages/client/ui-primitives/tests/labels.client.ts +++ b/packages/client/ui-primitives/tests/labels.client.ts @@ -18,7 +18,6 @@ export const diffBlockLabels: DiffBlockLabels = { copy: '复制', copied: '复制成功', collapseAria: '收起差异', expandAria: hidden => `展开其余 ${hidden} 行差异`, collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`, - files: count => `${count} ${count === 1 ? 'file' : 'files'}`, } export const readBlockLabels: ReadBlockLabels = { diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index c2720b1ea6..b175a9411b 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: d818feaa2cc6bff2f8400ef122e8496df6e45cfc -README.zh.md: e881f4a844708eee17945ec5ac15e17e85baa6fd +README.md: 30d3a10470682a649ea382f8d2bb07ff104f388f +README.zh.md: cabfb262e8e20ecdf7a281ca5187aaa6c050bcce diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index d818feaa2c..30d3a10470 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 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. +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 diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index e881f4a844..cabfb262e8 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 不包含可选取的文字。代码预览使用 secondary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。 +首次读取、追加页及 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 预览 diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 45be7c0b8f..b5dd281e0e 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: e26a277028d41420c4796b4678a2d6897d592872 -README.zh.md: 63d34c640571b0b3e363f6968221d15eea70ad49 +README.md: 622dbd89c93f8a21bece72f2267613a4fa81b4d6 +README.zh.md: c3fec14cb7a5d2e2448743204b59dcac73380849 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index e26a277028..622dbd89c9 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -68,7 +68,7 @@ Tool owner props forward Chat's stable `useDisclosure` Hook through root and nes Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. -Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The collapsed row and expanded footer share the primitive's exact or coarse-replacement counts. +Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer. An Auto denial takes precedence over keyed specialized views. Its generic row preserves the call identity, omits raw arguments, and normalizes the stored reason only for display: trim surrounding whitespace and collapse line separators to spaces, with localized fallback for an empty result. Session and SDK error details keep the original reason. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 63d34c6405..c3fec14cb7 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -68,7 +68,7 @@ Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调 每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 -Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。折叠工具行与展开卡片底部采用原语一致的精确或粗粒度替换统计。 +Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。 Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用身份、省略原始参数,并且只在显示时归一化存储的理由:去除首尾空白,把行分隔符折叠为空格,结果为空时使用本地化通用理由。Session 与 SDK 错误详情保留原始理由。 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 308624227d..228b6e9a94 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -171,9 +171,8 @@ export const ToolRow = memo(function ToolRow({ // amber while retaining the business icon and hidden state announcement. const failureLine = state === 'error' ? errorSummary ?? normalSummary : null const summaryText = failureLine ?? normalSummary - // A diff row's collapsed line carries the card's +/- totals (the same - // numbers the expanded footer prints) so the change size reads without - // expanding; an explicit summarySuffix (none today on diff rows) wins. + // The tool row keeps the diff's +/- totals visible while its body is collapsed. + // An explicit summarySuffix overrides the diff totals. const diffStat = useMemo(() => { if (diffBody === null) return null const { added, removed } = diffTotals(diffBody.card.diffs) 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 af1e13679b..4aa7477f4c 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 @@ -47,7 +47,6 @@ export function diffBlockLabels(t: T): DiffBlockLabels { expandAria: count => t('diff.expandAria', { count }), collapse: t('collapse'), expand: count => t('diff.expandRest', { count }), - files: count => t(count === 1 ? 'diff.files.one' : 'diff.files.other', { count }), } } 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 cbb4d46d0f..7be88bb9d6 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -246,9 +246,9 @@ describe('FileMutationRow diff card', () => { }), 'write')} />) // The collapsed row already carries the card's +/- totals beside the path. expect(view.getByText('+1 -0')).toBeTruthy() - // The footer counts live inside the collapsed diff card. toggleRow(view) - expect(view.getByText('└ +1 -0 · 1 个文件')).toBeTruthy() + expect(view.getAllByText('+1 -0')).toHaveLength(1) + expect(view.getByText('hello fixture')).toBeTruthy() }) it('reflects the run state on its leading slot', () => { diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 050f8cc42b..723f2eb359 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -301,7 +301,7 @@ describe('ToolRow', () => { expect(view.getByText('List files')).toBeTruthy() }) - it('excludes shared context from collapsed edit totals and the expanded card', () => { + it('shows totals once and shared context once when an edit expands', () => { const view = render( { expect(view.getByText('+1 -1')).toBeTruthy() expect(view.container.querySelector('[data-diff]')).toBeNull() fireEvent.click(view.getByRole('button')) - expect(view.getByText(/└ \+1 -1/)).toBeTruthy() + expect(view.getAllByText('+1 -1')).toHaveLength(1) expect(view.getAllByText('start')).toHaveLength(1) expect(view.getAllByText('end')).toHaveLength(1) expect(view.getByText('old', { exact: true })).toBeTruthy() diff --git a/snapshots/web/diff-bounded/ui.expected.md b/snapshots/web/diff-bounded/ui.expected.md index 1883ed4a1c..09cf4e6d4d 100644 --- a/snapshots/web/diff-bounded/ui.expected.md +++ b/snapshots/web/diff-bounded/ui.expected.md @@ -7,5 +7,5 @@ - button "Copy" - text: large.txt - shared heading - old setting 0 - old setting 1 - old setting 2 - button "Expand 252 more diff lines": … 252 more lines -- text: + new setting 125 + new setting 126 + new setting 127 + new setting 128 └ +130 -130 · 1 file +- text: + new setting 125 + new setting 126 + new setting 127 + new setting 128 - button "Inspect" diff --git a/snapshots/web/diff-context/ui.expected.md b/snapshots/web/diff-context/ui.expected.md index 44e1810526..72bb5f0d61 100644 --- a/snapshots/web/diff-context/ui.expected.md +++ b/snapshots/web/diff-context/ui.expected.md @@ -5,5 +5,5 @@ - text: Code block - button "Wrap lines" - button "Copy" -- text: config.txt - mode=DEBUG + mode=RELEASE level=info └ +1 -1 · 1 file +- text: config.txt - mode=DEBUG + mode=RELEASE level=info - button "Inspect" From 780e07b8eda61042c3c59a0b4eadca714d62dbfc Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Tue, 22 Sep 2026 16:28:46 +0800 Subject: [PATCH 4/4] test(web): align built boot assertions with diff layout --- apps/web/tests/built-boot.expected.e2e.ts | 25 +++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index 46115eff44..7ab01184fc 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -121,24 +121,27 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn within(contextPanel).getByText('Tool definitions') within(contextPanel).getByText('Messages') - // The write/edit turns render a real diff card through the assembled graph - // (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the - // fixture's raw text. The card is collapsed by default, so expand each edit/ - // write row first. The write turn's `hello fixture\n` proves the terminator - // rule end to end: a trailing newline terminates its line, so the footer reads - // `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS - // ::before, so it is absent from textContent — assert on the line body and the - // footer. - const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')] + // The built ToolRow and DiffBlock share the write fixture's trailing-newline + // semantics: `hello fixture\n` contributes one addition. Counts belong to the + // tool row; the diff's line markers are CSS pseudo-elements, absent from textContent. + const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')] expect(mutationRows.length).toBeGreaterThan(0) + const writeRow = mutationRows.find(row => row.getAttribute('data-variant') === 'write' + && row.textContent?.includes('new-demo.txt')) + if (writeRow === undefined) throw new Error('fixture write row missing') + expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1) + expect(writeRow.querySelector('[data-diff]')).toBeNull() for (const row of mutationRows) { const toggle = row.querySelector('[data-expandable]') if (toggle !== null) act(() => { fireEvent.click(toggle) }) } const diffCards = [...document.querySelectorAll('[data-diff]')] expect(diffCards.length).toBeGreaterThan(0) - const footers = diffCards.map(card => card.textContent ?? '') - expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true) + const writeDiff = writeRow.querySelector('[data-diff]') + if (writeDiff === null) throw new Error('expanded fixture write diff missing') + within(writeDiff).getByText('hello fixture', { exact: true }) + expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1) + expect(writeDiff.textContent).not.toContain('+1 -0') // The web render intent reaches the assembled boot graph: the fixture's // web_search / web_fetch turns render their keyed WebRow cards, proving the