Merge pull request #4857 from deepseek-harness/ihsiang/unify-code-block-ui

feat(web): 统一代码块样式与复制、换行控件
This commit is contained in:
ihsiang authored and GitHub committed 2026-09-22 17:06:07 +08:00
commit e420c44d60
66 files changed
+721 -309

No files matched your search

+14 -11
View File
@@ -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<HTMLElement>('[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<HTMLElement>('[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
+160
View File
@@ -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<string, JsonValue>; 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>${path}</path>\n<type>file</type>\n<content>\n${lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n</content>`,
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<typeof watchConsole>
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([])
})
})
+2 -1
View File
@@ -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)
+6 -1
View File
@@ -851,7 +851,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', '',
@@ -34,8 +34,10 @@
- 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"
- 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"
@@ -45,7 +47,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
@@ -136,6 +139,7 @@
- paragraph:
- img "Token preview"
- text: css
- button "Wrap lines" [pressed]
- button "Copy"
- code: "--inline-code: #F5F5F5;"
- paragraph: LINK_GALLERY_DONE
@@ -23,6 +23,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
@@ -0,0 +1,4 @@
- text: Code block
- button "Wrap lines" [pressed]
- button "Copy"
- code: echo WATERFALL
@@ -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}}
+25
View File
@@ -27,6 +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_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`
@@ -218,6 +220,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: '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)
@@ -521,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',
@@ -11,6 +11,7 @@
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- text: ts
- button "Wrap lines" [pressed]
- button "Copy"
- code: "const first: number = 1 const second = \"two\" let tail"
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -85,7 +85,7 @@ async function fenceTree(block: ReturnType<Page['locator']>): Promise<FenceTree>
const pre = element.querySelector<HTMLPreElement>('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,
+9 -3
View File
@@ -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<HTMLElement>('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<HTMLElement>('[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,
+1
View File
@@ -26,6 +26,7 @@
"tests/server-restart.e2e.ts",
"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",
+3
View File
@@ -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',
+3
View File
@@ -5,6 +5,9 @@ export const zh = {
'close': '关闭',
'copy': '复制',
'copied': '复制成功',
'codeBlock.title': '代码块',
'codeBlock.wrap': '自动换行',
'codeBlock.unwrap': '取消自动换行',
'copy.failed': '复制失败',
'copy.value': '复制值',
'copy.json': '复制 JSON',
@@ -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 }: {
<MarkdownText
text={t(guide.explanation)}
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'),
}}
/>
@@ -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'),
}
}
@@ -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',
@@ -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 (
@@ -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: 7e8a6e2e4ae22d3f9e4d6413c783f4d4da9fed6e
README.zh.md: bdbd2faef9ec95a842540c861b934d47c6575e1e
README.md: 43476f2aa531decbe1eff1b6664530f7873723b4
README.zh.md: 44f5c6e123bd7d6e861fec9554beb2fccf827927
+3 -1
View File
@@ -87,7 +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.
`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.
`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 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.
+3 -1
View File
@@ -87,7 +87,9 @@ kind: "package-library"
`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
`DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,摘要和底部统计都不计入共享上下文。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。
`CodeBlock.toolbarLabels` 启用共享代码卡片的标题栏和间距。`ReadBlock` 与 `DiffBlock` 使用相同的标题栏:tertiary 色的语言名称与文件名、secondary 色的复制与换行图标,以及悬停或键盘聚焦时的提示。语言缺失或不受支持时显示调用方提供的本地化代码块标题;混合语言的 Diff 也使用该标题。代码卡片共用字体和内容内边距;在两种主题下,标题栏和正文都使用相同背景。读取卡片的行号栏容纳返回窗口中的最大行号,Diff 底色覆盖完整可滚动行。换行按钮保留固定的无障碍名称,以 `aria-pressed` 表达状态;提示文本说明下一步操作。换行设置仅属于当前挂载的卡片,不改变复制的文本;代码围栏默认换行,读取和差异卡片则在启用换行前保留源码列位置。紧凑 Markdown 共用此工具栏、间距和代码字体排版,同时保留单色文本。源码预览传入 `toolbarLabels` 后,可用 `wrap` 跟随所属视图的换行设置,仅显示语言和复制图标;所属视图的 CSS 保留源码布局。
`DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,工具摘要的统计不计入共享上下文。卡片在差异正文后结束。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。
`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
@@ -0,0 +1,83 @@
.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 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);
font: 11px/18px var(--dsw-font-family);
}
.heading {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
}
.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;
}
.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 22px 20px;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
}
@@ -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 (
<div className={css.header} data-code-block-banner>
<div className={css.heading}>
<span className={css.language}>{supportsHighlighting(lang) ? lang : labels.codeLabel}</span>
{title !== undefined && <span className={css.title} title={title}>{title}</span>}
</div>
<div className={css.actions}>
{status !== undefined && <span className={css.status}>{status}</span>}
{onWrap !== undefined && <Tooltip label={wrapLabel} side="top" portal>
<button type="button" className={css.action} aria-label={labels.wrapLabel} aria-pressed={wrapped} onClick={onWrap}>
{wrapped ? <IconNowrapFillRegular size={14} /> : <IconWrapFillRegular size={14} />}
</button>
</Tooltip>}
{onCopy !== undefined && <Tooltip label={clipboardLabel} side="top" portal>
<button type="button" className={css.action} aria-label={clipboardLabel} onClick={onCopy}>
{copied ? <IconCheckOutlineRegular size={14} /> : <IconCopyOutlineRegular size={14} />}
</button>
</Tooltip>}
</div>
</div>
)
}
@@ -1,79 +1,58 @@
.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;
display: grid;
grid-template-columns: minmax(max-content, 1fr);
padding: 6px 0 20px;
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 22px;
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'] .body {
grid-template-columns: minmax(0, 1fr);
}
.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 +66,9 @@
.expand {
display: block;
width: 100%;
padding: 0;
padding: 0 22px;
border: none;
background-color: transparent;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
@@ -99,11 +78,3 @@
.expand:hover {
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;
font: var(--dsw-font-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
}
+17 -21
View File
@@ -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,14 +36,13 @@ export interface DiffBlockProps {
}
/** Localized chrome for {@link DiffBlock}. */
export interface DiffBlockLabels {
export interface DiffBlockLabels extends CodeToolbarLabels {
copy: string
copied: string
collapseAria: string
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. */
@@ -82,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
@@ -99,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<string>()
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
@@ -122,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
}
/**
@@ -171,9 +166,12 @@ 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)
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 +196,9 @@ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, cl
const tail = capped ? rows.slice(rows.length - tailLines) : []
return (
<div className={clsx(css.block, className)} data-diff="">
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? labels.copied : labels.copy}
</button>
<div className={clsx(cardCss.card, css.block, className)} data-diff="" data-code-wrap={wrapped}>
<CodeToolbar lang={language} labels={labels} copyLabel={labels.copy} copiedLabel={labels.copied}
copied={copied} wrapped={wrapped} onCopy={onCopy} onWrap={() => { setWrapped(value => !value) }} />
<div className={css.body}>
{head.map((row, index) => (
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
@@ -219,7 +216,6 @@ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, cl
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
))}
</div>
<div className={css.footer}>└ +{added} -{removed} · {labels.files(files)}</div>
</div>
)
}
@@ -1,105 +1,37 @@
.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);
}
.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;
+16 -19
View File
@@ -1,7 +1,9 @@
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'
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
@@ -116,27 +119,21 @@ export function ReadBlock({
</div>
))
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 (
<div ref={rootRef} className={clsx(css.block, className)} data-read="">
<div className={css.banner}>
<div className={css.label}>{label ?? ''}</div>
<div className={css.action}>
{windowed && (
<span className={css.count}>{labels.window(lines.length, totalLines)}</span>
)}
<span className={css.lang}>{lang ?? ''}</span>
{/* Empty files omit Copy to avoid replacing the clipboard with an empty string. */}
{lines.length > 0 && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? labels.copied : labels.copy}
</button>
)}
</div>
</div>
<div className={css.body}>
<div ref={rootRef} className={clsx(cardCss.card, css.block, className)} data-read="" data-code-wrap={wrapped} style={gutterStyle}>
<CodeToolbar
lang={lang} title={label} status={windowed ? labels.window(lines.length, totalLines) : undefined}
labels={labels} copyLabel={labels.copy} copiedLabel={labels.copied} copied={copied} wrapped={wrapped}
// Empty files must not replace the clipboard with empty text.
onCopy={lines.length > 0 ? onCopy : undefined} onWrap={() => { setWrapped(value => !value) }}
/>
<div className={cardCss.body}>
{rows(capped ? paired.slice(0, headLines) : paired)}
{hidden > 0 && (
<FoldToggle
@@ -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'
@@ -104,6 +104,21 @@
color: var(--dsw-alias-label-primary);
}
.card :where(pre) {
padding: 6px 22px 20px;
word-break: normal;
overflow-wrap: anywhere;
}
.card[data-code-wrap='false'] {
--dsl-code-block-line-white-space: pre;
}
.card[data-code-wrap='false'] :where(pre) {
white-space: pre;
overflow-wrap: normal;
}
.numbered :where(pre) code {
display: block;
counter-reset: source-line;
@@ -2,6 +2,7 @@ import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore
import type { CSSProperties, ReactNode, Ref } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { CodeToolbar, type CodeToolbarLabels } from '../CodeToolbar.tsx'
import {
StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
} from './highlight.ts'
@@ -35,6 +36,10 @@ 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 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
}
/**
@@ -64,7 +69,7 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
}
export function CodeBlock({
code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel,
code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, toolbarLabels, wrap,
}: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
@@ -149,6 +154,8 @@ export function CodeBlock({
[streaming, highlighting, streamedBody, trimmed, lang, loaded],
)
const [copied, setCopied] = useState(false)
const [localWrapped, setWrapped] = useState(true)
const wrapped = wrap ?? localWrapped
const onCopy = useCallback(() => {
if (copied) return
@@ -178,21 +185,26 @@ export function CodeBlock({
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, className)}
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, toolbarLabels !== undefined && css.card, className)}
data-line-numbers={lineNumbers || undefined}
data-code-wrap={toolbarLabels === undefined ? undefined : wrapped}
style={sourceLines === undefined ? undefined : {
'--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
} as CSSProperties}>
{/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */}
{showHeader && <div className={css.bannerWrap}>
<div className={css.banner} data-code-block-banner>
{toolbarLabels !== undefined ? <CodeToolbar
lang={lang} labels={toolbarLabels} copyLabel={copyLabel} copiedLabel={copiedLabel}
copied={copied} wrapped={wrapped} onCopy={onCopy}
onWrap={wrap === undefined ? () => { setWrapped(value => !value) } : undefined}
/> : <div className={css.banner} data-code-block-banner>
<div className={css.infostring}>{lang ?? ''}</div>
<div className={css.action}>
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? copiedLabel : copyLabel}
</button>
</div>
</div>
</div>}
</div>}
<div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
</div>
@@ -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) {
@@ -22,6 +22,7 @@ import clsx from 'clsx'
import type * as Md from 'mdast'
import type {} from 'mdast-util-math'
import { normalizeUri } from 'micromark-util-sanitize-uri'
import type { CodeToolbarLabels } from '../CodeToolbar.tsx'
import { CodeBlock } from './CodeBlock.tsx'
import { parseFileLink } from './file-link.ts'
import { renderTexToReact } from './katex.tsx'
@@ -36,6 +37,8 @@ export interface MarkdownCodeLabels {
copyLabel: string
/** Copy-button label during the post-copy confirmation window. */
copiedLabel: string
/** Shared card controls; omitted for custom toolbar layouts. */
toolbarLabels?: CodeToolbarLabels | undefined
}
/** Localized chrome for a Markdown document. */
@@ -399,6 +402,7 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
streaming={context.streaming}
copyLabel={context.labels.code.copyLabel}
copiedLabel={context.labels.code.copiedLabel}
toolbarLabels={context.labels.code.toolbarLabels}
/>
)
}
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
import { ReadBlock } from '../src/ReadBlock.tsx'
import { DiffBlock } from '../src/DiffBlock.tsx'
import { diffBlockLabels, readBlockLabels } from './labels.client.ts'
const toolbarLabels = { codeLabel: 'Code block', wrapLabel: 'Wrap lines', unwrapLabel: 'Do not wrap lines' }
const labels = { copyLabel: 'Copy', copiedLabel: 'Copied', toolbarLabels }
afterEach(() => {
cleanup()
vi.clearAllTimers()
vi.useRealTimers()
})
describe('code-card controls', () => {
it.each([undefined, 'unregistered'])('labels the %s language with the localized code title', (lang) => {
render(<CodeBlock code="plain source" lang={lang} {...labels} />)
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(<CodeBlock code="const x = 1" lang="typescript" {...labels} />)
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'
? <CodeBlock code={code} lang="ts" {...labels} />
: kind === 'read'
? <ReadBlock lines={[{ number: 24, text: code }]} totalLines={30} lang="ts" labels={{ ...readBlockLabels, ...toolbarLabels }} />
: <DiffBlock diffs={[{ path: 'example.ts', oldText: 'old', newText: code }]} labels={{ ...diffBlockLabels, ...toolbarLabels }} />)
const button = screen.getByRole('button', { name: 'Wrap lines' })
const wasWrapped = button.getAttribute('aria-pressed') === 'true'
fireEvent.click(button)
expect(button.getAttribute('aria-pressed')).toBe(String(!wasWrapped))
expect(button.getAttribute('aria-label')).toBe('Wrap lines')
expect(view.container.textContent).toContain(code)
fireEvent.click(button)
expect(button.getAttribute('aria-pressed')).toBe(String(wasWrapped))
})
it('shows hover tooltips for copying and wrapping and updates the wrapping action', () => {
render(<CodeBlock code="source" {...labels} />)
const copy = screen.getByRole('button', { name: 'Copy' })
fireEvent.mouseEnter(copy)
expect(screen.getByRole('tooltip').textContent).toBe('Copy')
fireEvent.mouseLeave(copy)
const wrap = screen.getByRole('button', { name: 'Wrap lines' })
fireEvent.mouseEnter(wrap)
expect(screen.getByRole('tooltip').textContent).toBe('Do not wrap lines')
fireEvent.click(wrap)
expect(screen.queryByRole('tooltip')).toBeNull()
fireEvent.mouseEnter(wrap)
expect(screen.getByRole('tooltip').textContent).toBe('Wrap lines')
})
it('copies source through the icon and announces success', async () => {
vi.useFakeTimers()
const original = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
const writeText = vi.fn<(text: string) => Promise<void>>().mockResolvedValue()
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
try {
render(<CodeBlock code={'const x = 1\n'} {...labels} />)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Copy' })) })
expect(writeText).toHaveBeenCalledWith('const x = 1')
expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy()
} finally {
if (original === undefined) Reflect.deleteProperty(navigator, 'clipboard')
else Object.defineProperty(navigator, 'clipboard', original)
}
})
it('shows compact Markdown icon actions and preserves custom source controls', () => {
const view = render(<MarkdownText text={'```ts\nconst x = 1\n```'} variant="compact" labels={{ code: labels, footnotes: 'Footnotes' }} />)
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(<CodeBlock code="source" copyLabel="Copy" copiedLabel="Copied" />)
expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('Copy')
})
it('uses the fallback title for diffs containing different languages', () => {
render(<DiffBlock diffs={[
{ path: 'example.ts', oldText: '', newText: 'export {}' },
{ path: 'example.py', oldText: '', newText: 'print(1)' },
]} labels={{ ...diffBlockLabels, ...toolbarLabels }} />)
expect(screen.getByText('Code block')).toBeTruthy()
})
})
@@ -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(<DiffBlock diffs={[{ path: 'n.txt', oldText: null, newText: 'hello\n' }]} />)
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(<DiffBlock diffs={[{ path: 'gone.ts', oldText: 'a\nb', newText: '' }]} />)
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(<DiffBlock diffs={diffs} maxLines={1000} />)
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(<DiffBlock diffs={diffs} />)
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(<DiffBlock diffs={diffs} />)
const { container } = render(<DiffBlock diffs={diffs} />)
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(<DiffBlock diffs={diffs} />)
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(<DiffBlock diffs={diffs} />)
expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy()
expect(container.querySelectorAll('[class*="_add_"]')).toHaveLength(added)
expect(container.querySelectorAll('[class*="_del_"]')).toHaveLength(removed)
})
})
@@ -14,13 +14,14 @@ export const markdownLabels: MarkdownLabels = {
}
export const diffBlockLabels: DiffBlockLabels = {
codeLabel: '代码块', wrapLabel: '自动换行', unwrapLabel: '取消自动换行',
copy: '复制', copied: '复制成功', collapseAria: '收起差异',
expandAria: hidden => `展开其余 ${hidden} 行差异`,
collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`,
files: count => `${count} ${count === 1 ? 'file' : 'files'}`,
}
export const readBlockLabels: ReadBlockLabels = {
codeLabel: '代码块', wrapLabel: '自动换行', unwrapLabel: '取消自动换行',
window: (shown, total) => `显示 ${shown} / ${total} 行`,
copy: '复制', copied: '复制成功', collapseAria: '收起内容',
expandAria: hidden => `展开其余 ${hidden} 行`,
@@ -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(<ReadBlock lines={lines(1)} totalLines={1} />)
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(<ReadBlock lines={lines(1)} totalLines={1} />)
expect(screen.getByText('代码块')).toBeTruthy()
})
})
@@ -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: c238b30dd87a72d4a2a0ed4f29498f72c32c61b8
README.zh.md: 559f6e337c3aadd29d3551f64c59307073461808
README.md: 5507bd89f07bfd6225d4ecc1bc4500ceb2e30fe8
README.zh.md: eec96c28654ae01aca6b89862d503af6c12296e8
@@ -61,7 +61,7 @@ PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` s
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls.
Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a tertiary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
<a id="excel-preview"></a>
## Excel preview
@@ -61,7 +61,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。
首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览使用 tertiary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
<a id="excel-preview"></a>
## Excel 预览
@@ -43,6 +43,7 @@
.renderer .code pre {
box-sizing: border-box;
padding: 16px;
min-width: 100%;
overflow: visible;
white-space: pre;
@@ -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') }}
/>
</div>
)
@@ -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<MarkdownLabels>(() => ({
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 (
<div className={css.document} data-document-markdown>
@@ -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(<CodeBody {...props(content)} />)
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(<CodeBody {...props(content, { wrap: true })} />)
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')
+2 -2
View File
@@ -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: 3961c16f862ed83d8b64b1a899f24f254e99ee63
README.zh.md: 22fc05f7a82fe549cb8e77a2772b62bfd8b15baa
README.md: f8db4c3c353aa9b6854c05288316babd198475d7
README.zh.md: 135878213908ea11093d267c361cf2f80b4d768a
+1 -1
View File
@@ -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.
`brand-font.css` exports the local Montserrat Light, Regular and Medium faces (normal style, weights 300, 400 and 500), with `montserrat-light.woff2`, `montserrat-regular.woff2`, `montserrat-medium.woff2` and its SIL Open Font License in `lib/styles/`. Desktop bundles the same stylesheet, font and license for offline welcome brand text; ordinary UI keeps its system font stack.
+1 -1
View File
@@ -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` 负责语法颜色。
`brand-font.css` 导出本地 Montserrat Light、Regular 和 Medium 字体(正体、字重 300、400 和 500),`lib/styles/` 同时提供 `montserrat-light.woff2`、`montserrat-regular.woff2`、`montserrat-medium.woff2` 及其 SIL Open Font License。Desktop 将同一份样式表、字体和许可证打包,用于欢迎页品牌文字的离线显示;普通界面保留系统字体栈。
@@ -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);
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 错误详情保留原始理由。
@@ -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;
@@ -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 && <div className={css.prose}><MarkdownText text={item.markdown} labels={markdownLabels(t)} /></div>}
{item.code !== undefined && (
<CodeBlock
toolbarLabels={codeToolbarLabels(t)}
className={css.code} code={item.code.text} lang={item.code.language}
copyLabel={t('copy')} copiedLabel={t('copied')}
/>
@@ -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. */
@@ -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 {
@@ -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)
@@ -289,7 +288,8 @@ export const ToolRow = memo(function ToolRow({
<>
{variant === 'code' && bodyText !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')}
toolbarLabels={codeToolbarLabels(t)} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
@@ -1,6 +1,7 @@
/** Localized copy adapters for Cordis-free UI primitives used by Tool cards. */
import type {
CodeToolbarLabels,
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): CodeToolbarLabels {
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,13 +40,13 @@ 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'),
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 }),
}
}
@@ -47,6 +57,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'),
@@ -225,7 +225,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', () => {
@@ -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', () => {
@@ -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(<ToolRow {...rowProps} variant="edit" title="Edit" summary="settings.ts" diff={{
card: { diffs: [{
path: 'settings.ts',
@@ -312,7 +312,7 @@ describe('ToolRow', () => {
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()
@@ -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<QuestionDraftProgress>(() => ({
@@ -134,6 +134,7 @@ export function CordisDefineRow({
aria-labelledby={`${sourcePanelId}-${activeSource}`}
>
<CodeBlock
toolbarLabels={{ codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') }}
code={activeCode}
lang="javascript"
copyLabel={t('body.copy')}
+3 -1
View File
@@ -2,8 +2,10 @@
- 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
- 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"
+3 -1
View File
@@ -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
- text: config.txt - mode=DEBUG + mode=RELEASE level=info
- button "Inspect"
@@ -34,8 +34,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。
@@ -61,8 +65,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:
@@ -84,14 +92,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:
@@ -21,8 +21,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。
@@ -44,14 +48,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:
+12 -2
View File
@@ -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.
+1
View File
@@ -14,6 +14,7 @@
"apps/web/tests/server-restart.e2e.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",