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",