Merge pull request #7578 from deepseek-harness/worktree/sync-dsh-0.2.1-rc.5-to-master

release(dsh): sync 0.2.1-rc.5 back to master
This commit is contained in:
imccyu
2026-10-29 23:41:20 +08:00
committed by GitHub
19 changed files with 182 additions and 52 deletions
+18 -6
View File
@@ -10,12 +10,13 @@ import {
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, writeComposerDraft } from './support.ts'
const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}`
const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail']
const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail', '\n\nExpanded update']
const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url))
/** Computed mask of the animated whale: the ui-chat package asset URL, whose `@` is percent-encoded. */
const WHALE_MASK = /^url\("([^"]*\/plugins\/@deepseek-ai\/dsh-client-ui-chat\/assets\/running-whale%402x-[0-9a-f]{12}\.png)"\)$/
class PausedReasoningAdapter extends LlmAdapter {
constructor(private readonly prefix: string) { super() }
override async listModels(provider: string) { return [{ provider, id: 'paused', name: `${provider}/paused` }] }
readonly stages = DELTAS.map(text => ({
text,
@@ -29,13 +30,14 @@ class PausedReasoningAdapter extends LlmAdapter {
override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
yield { type: 'block-start', index: 0, blockType: 'reasoning' }
if (this.prefix !== '') yield { type: 'reasoning-delta', index: 0, text: this.prefix }
for (const stage of this.stages) {
yield { type: 'reasoning-delta', index: 0, text: stage.text }
stage.arrived.resolve(undefined)
await stage.proceed.promise
options.signal?.throwIfAborted()
}
yield { type: 'block-end', index: 0, block: { type: 'reasoning', text: DELTAS.join('') } }
yield { type: 'block-end', index: 0, block: { type: 'reasoning', text: this.prefix + DELTAS.join('') } }
yield { type: 'block-start', index: 1, blockType: 'text' }
yield { type: 'text-delta', index: 1, text: 'Done' }
yield { type: 'block-end', index: 1, block: { type: 'text', text: 'Done' } }
@@ -43,9 +45,9 @@ class PausedReasoningAdapter extends LlmAdapter {
}
}
it('shows completed paragraph first lines across blank lines with a right-edge fade', async () => {
it.each(['plain', 'signature'])('shows completed paragraph first lines with a right-edge fade (%s)', async (format) => {
const scaffold = await launchWebScaffold()
const adapter = new PausedReasoningAdapter()
const adapter = new PausedReasoningAdapter(format === 'signature' ? '<signature>metadata.payload</signature>\n\n' : '')
try {
scaffold.ctx.effect(() => scaffold.ctx.llm.registerAdapter(['reasoning-preview-test'], adapter))
await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'reasoning-preview-test', model: 'paused' })
@@ -61,8 +63,10 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
await writeComposerDraft(page, input, 'Show a streamed reasoning preview.')
await input.press('Enter')
const [first, second, third] = adapter.stages
if (first === undefined || second === undefined || third === undefined) throw new Error('preview stages are incomplete')
const [first, second, third, fourth] = adapter.stages
if (first === undefined || second === undefined || third === undefined || fourth === undefined) {
throw new Error('preview stages are incomplete')
}
await first.arrived.promise
const reasoning = page.locator('[data-variant="think"][data-state="running"]')
await expandOwningTurnProcess(page, reasoning)
@@ -120,6 +124,7 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
await page.emulateMedia({ reducedMotion: null, forcedColors: null })
}
expect(await reasoning.getAttribute('data-preview')).toBeNull()
expect(await reasoning.textContent()).not.toContain('metadata.payload')
first.proceed.resolve(undefined)
await second.arrived.promise
@@ -138,7 +143,14 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
await compareOrRefreshGolden(UI_EXPECTED,
await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), webSnapshotMode())
await reasoning.getByRole('button').click()
await reasoning.getByText('More detail', { exact: false }).waitFor()
expect(await reasoning.textContent()).not.toContain('metadata.payload')
third.proceed.resolve(undefined)
await fourth.arrived.promise
await reasoning.getByText('Expanded update', { exact: true }).waitFor()
expect(await reasoning.textContent()).not.toContain('metadata.payload')
fourth.proceed.resolve(undefined)
await settled
await page.getByText('Done', { exact: true }).waitFor()
await whale.waitFor({ state: 'detached' })
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.4/reasoning-body-props/guide.md
/:
en: ec5a417eaedb6661
zh: b5adde6bf6c32fd0
/reasoning-body-slot-text-fields:
en: ea09fc75b8c4467e
zh: 64d548cbdb42792a
/reasoning-body-slot-text-fields/change:
en: ff40c45f886d47f5
zh: 18d7bb5456b048e6
/reasoning-body-slot-text-fields/migration:
en: 49cd6281b4aef73a
zh: 6bdf66c3f82bfa8b
@@ -0,0 +1,20 @@
---
kind: upgrade-guide
description: "Reasoning Body Slot text carries display-ready content, with original output in rawText."
---
# Reasoning Body Slot text fields
English | [中文](guide.zh.md)
## Change
Contributions to `conversation.chat.reasoning.body` receive the model output in the new `rawText` field. The existing `text` field supplies Chat's prepared display text instead of the original output. `running` is unchanged. This affects custom reasoning renderers and translation wrappers.
The `conversation.chat.reasoning.content` Factory still accepts `text`, `running`, and optional `labels`. It renders the caller-selected Markdown. The [Chat README](../../../../packages/client/ui-chat/README.md#grouped-rendering) documents the Slot.
## Migration
1. Keep reading `text` for ordinary rendering, translation requests, and untranslated views. Update extensions that need the full model output to read `rawText`.
2. Pass the selected `text`, or its translation, to the Content Factory without repeating Chat's display processing.
3. Rebuild the extension and verify streamed and settled rendering, translation, and the untranslated view with each supported model format.
@@ -0,0 +1,20 @@
---
kind: upgrade-guide
description: "推理 Body Slot 的 text 提供显示文本,rawText 保留原始输出。"
---
# 推理 Body Slot 的文本字段
[English](guide.md) | 中文
## 变更
`conversation.chat.reasoning.body` 扩展通过新增的 `rawText` 字段接收模型输出。现有的 `text` 字段提供 Chat 处理后的显示文本,替代原始输出。`running` 保持不变。这会影响自定义推理渲染器和翻译包装组件。
`conversation.chat.reasoning.content` Factory 仍接收 `text`、`running` 和可选的 `labels`。它渲染调用方选定的 Markdown。[Chat README](../../../../packages/client/ui-chat/README.zh.md#grouped-rendering) 介绍了该 Slot。
## 迁移
1. 在普通渲染、翻译请求和未翻译视图中继续读取 `text`。需要完整模型输出的扩展改为读取 `rawText`。
2. 向 Content Factory 传入选定的 `text` 或其译文,不要重复执行 Chat 的显示处理。
3. 重新构建扩展,使用每种支持的模型格式验证流式与完成后的渲染、翻译和未翻译视图。
+2 -2
View File
@@ -33,8 +33,8 @@
en: b0d806604bc17006
zh: 18dabe5dc2d0d419
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
en: 3afad0b4c6bf9ce0
zh: d8179ba4ee9fcd61
en: b1630d93a52f483e
zh: 59cb81ca50ecffc4
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
en: dd4e69828096c3a0
zh: 2a9bc370f44efcd7
+1 -1
View File
@@ -127,7 +127,7 @@ Scroll-edge fades initialize when `ResizeObserver` reports the open group's layo
Each group owns local `useDisclosure` state that survives mode changes while its component stays mounted.
The session-scoped `conversation.chat.reasoning.body` single Slot offers the original reasoning text and streaming state only when its body is expanded. Its official entry registers at priority 100 and calls the `conversation.chat.reasoning.content` Factory. A replacement uses a lower priority number and may call that same Factory with original or derived text. The root-scoped Factory requires no Session and fixes compact typography. Its optional `labels` accepts complete `MarkdownLabels` unchanged; omission uses Chat's localized defaults. The host supplies no inline fallback or plugin action state. Display extensions must preserve access to the original text and own their controls inside their Body wrapper. Extensions do not replace the reasoning preview or its disclosure policy. The [reasoning display rules](src/client/conversation-nodes/README.md#display-modes) define which blocks produce rows.
The session-scoped `conversation.chat.reasoning.body` single Slot offers the original `rawText`, display-ready `text`, and streaming state only when its body is expanded. Its official entry registers at priority 100 and calls the `conversation.chat.reasoning.content` Factory. A replacement uses a lower priority number and may call that same Factory with original or derived text. The root-scoped Factory requires no Session and fixes compact typography. Its optional `labels` accepts complete `MarkdownLabels` unchanged; omission uses Chat's localized defaults. The host supplies no inline fallback or plugin action state. Display extensions must preserve access to the untranslated text and own their controls inside their Body wrapper. Extensions do not replace the reasoning preview or its disclosure policy. The [reasoning display rules](src/client/conversation-nodes/README.md#display-modes) define which blocks produce rows.
The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. Source callbacks retain their receiver and stable identity. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state.
+1 -1
View File
@@ -127,7 +127,7 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的
每个组拥有本地 `useDisclosure` 状态,组件保持挂载时,模式切换保留该状态。
Session 作用域的 `conversation.chat.reasoning.body` single Slot 仅在思考正文展开时提供原始思考文本和流式状态。官方条目以优先级 100 注册,并调用 `conversation.chat.reasoning.content` Factory。替换项使用更小的优先级数值,可向同一个 Factory 传入原文或派生显示文本。root 作用域的 Factory 不要求 Session,并固定使用紧凑排版。其可选 `labels` 原样接受完整的 `MarkdownLabels`,省略时使用 Chat 的默认本地化文案。宿主不提供内联 fallback 或插件操作状态。显示扩展必须保留查看原文的能力,并在自己的 Body 包装内维护控件。扩展不替换思考预览或展开策略。哪些块生成思考行由[思考展示规则](src/client/conversation-nodes/README.zh.md#display-modes)定义。
Session 作用域的 `conversation.chat.reasoning.body` single Slot 仅在思考正文展开时提供原始文本 `rawText`、显示文本 `text` 和流式状态。官方条目以优先级 100 注册,并调用 `conversation.chat.reasoning.content` Factory。替换项使用更小的优先级数值,可向同一个 Factory 传入原文或派生显示文本。root 作用域的 Factory 不要求 Session,并固定使用紧凑排版。其可选 `labels` 原样接受完整的 `MarkdownLabels`,省略时使用 Chat 的默认本地化文案。宿主不提供内联 fallback 或插件操作状态。显示扩展必须保留查看未翻译文本的能力,并在自己的 Body 包装内维护控件。扩展不替换思考预览或展开策略。哪些块生成思考行由[思考展示规则](src/client/conversation-nodes/README.zh.md#display-modes)定义。
Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。来源回调保留接收对象及稳定引用。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。
@@ -18,7 +18,7 @@ export const ReasoningContent = memo(function ReasoningContent({ text, running,
/**
* Render the default Body through the same Factory available to third-party wrappers.
* @param props - original reasoning and the framework's Factory renderer.
* @param props - display-ready reasoning and the framework's Factory renderer.
* @returns an occurrence of the official reasoning Content Factory.
*/
export function DefaultReasoningBody({ text, running, renderFactorySlot }: ReasoningBodyProps) {
@@ -3,6 +3,7 @@ import { memo, useMemo } from 'react'
import { DisclosureRow, IconThinkOutlineRegular, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChatViewSlotProps, UseDisclosure, UsePresentation } from '../contract/slots.ts'
import { reasoningText } from '../reasoning-text.ts'
import a11yCss from './accessibility.module.css'
import css from './ReasoningRow.module.css'
@@ -36,7 +37,7 @@ function latestCompletedParagraphFirstLine(text: string): string {
* collapsed summary omits double-asterisk markers; expanded content renders
* the complete Markdown with secondary typography. A streaming preview advances
* when a paragraph's first line completes. Mode changes toggle CSS display without unmounting
* collapsed summaries.
* collapsed summaries. The Body Slot receives both original and display-ready text.
* @param props.text - complete or streaming reasoning text.
* @param props.running - whether this block is the streaming tail.
* @param props.usePresentation - live display-policy selector for this reasoning row.
@@ -46,7 +47,7 @@ function latestCompletedParagraphFirstLine(text: string): string {
* @returns the reasoning disclosure.
*/
export const ReasoningRow = memo(function ReasoningRow({
text, running, textInteractionSource = false, usePresentation, useDisclosure, renderSlot, t,
text: rawText, running, textInteractionSource = false, usePresentation, useDisclosure, renderSlot, t,
}: {
text: string
running: boolean
@@ -56,6 +57,10 @@ export const ReasoningRow = memo(function ReasoningRow({
t: ChatViewSlotProps['t']
} & Pick<PropsRenderSlots<'conversation.chat.reasoning.body'>, 'renderSlot'>) {
const { expanded, toggle } = useDisclosure()
const { text, hidden } = useMemo(() => {
const text = reasoningText(rawText)
return { text, hidden: text !== rawText && text.trim() === '' }
}, [rawText])
const summaryText = running ? latestCompletedParagraphFirstLine(text) : firstLine(text)
const summary = useMemo(() => summaryText.replaceAll('**', ''), [summaryText])
const preview = usePresentation(policy => !expanded && summary !== ''
@@ -72,9 +77,11 @@ export const ReasoningRow = memo(function ReasoningRow({
), [running, summary])
const content = useMemo(() => expanded ? (
<div className={css.thinkBody} data-text-interaction-source={textInteractionSource && !running ? '' : undefined}>
{renderSlot('conversation.chat.reasoning.body', { text, running })}
{renderSlot('conversation.chat.reasoning.body', { rawText, text, running })}
</div>
) : undefined, [expanded, renderSlot, running, text, textInteractionSource])
) : undefined, [expanded, renderSlot, running, rawText, text, textInteractionSource])
if (hidden) return null
return (
<div
@@ -79,16 +79,22 @@ export interface QuotaNoticeState {
seq: number
}
/** Original model-provided reasoning offered to the expanded Body Slot. */
/** Original and display-ready reasoning offered to the expanded Body Slot. */
export interface ReasoningBodyOwnerProps {
/** Complete or streaming reasoning text. */
/** Complete or streaming model output. */
readonly rawText: string
/** Reasoning text prepared for display. */
readonly text: string
/** Whether the reasoning tail is still streaming. */
readonly running: boolean
}
/** Session-independent original or derived display input to the Content Factory. */
export interface ReasoningContentInput extends ReasoningBodyOwnerProps {
export interface ReasoningContentInput {
/** Caller-selected Markdown. */
readonly text: string
/** Whether the supplied text is still streaming. */
readonly running: boolean
/** Localized Markdown controls, used unchanged when supplied; omission uses Chat's defaults. */
readonly labels?: MarkdownLabels
}
@@ -468,9 +474,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
/**
* Expanded reasoning body with the unchanged model text. The official entry
* Expanded reasoning body with raw and display-ready text. The official entry
* has priority 100 and calls the reasoning Content Factory; smaller priority
* values replace it. Contributions must preserve access to the original text.
* values replace it. Contributions must preserve access to untranslated display text.
*/
'conversation.chat.reasoning.body': { kind: 'single'; scope: 'session'; owner: ReasoningBodyOwnerProps }
/**
@@ -1,4 +1,5 @@
/** Tool-category and live-detail interpretation owned by Chat grouping. */
import { reasoningText } from '../reasoning-text.ts'
import type { ToolArgs } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ProcessActivity, ProcessActivitySummary } from '../contract/process-groups.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
@@ -99,7 +100,7 @@ function liveReasoningDetail(nodes: readonly ChatNode[]): string {
for (let blockIndex = node.data.blocks.length - 1; blockIndex >= 0; blockIndex--) {
const block = node.data.blocks[blockIndex]
if (block?.kind !== 'reasoning') continue
const paragraphs = block.text.split(/\r?\n[\t ]*\r?\n/)
const paragraphs = reasoningText(block.text).split(/\r?\n[\t ]*\r?\n/)
for (let paragraphIndex = paragraphs.length - 1; paragraphIndex >= 0; paragraphIndex--) {
const detail = normalizeLiveToolDetail(paragraphs[paragraphIndex]?.replaceAll('**', ''))
if (detail !== '') return detail
@@ -0,0 +1,25 @@
/** Display text preparation for model reasoning. */
/**
* Prepare complete or streaming reasoning for display.
* @param text - original complete or streaming reasoning.
* @returns display text, or empty text when no displayable content is available.
*/
export function reasoningText(text: string): string {
if (text[0] !== '<') return text
/* v8 ignore start -- Custom rendering behavior is outside unit coverage. */
const opening = '<signature>'
if (opening.startsWith(text)) return ''
if (!text.startsWith(opening)) return text
const closing = '</signature>'
const end = text.indexOf(closing, opening.length)
if (end === -1) return ''
let start = end + closing.length
while (start < text.length) {
if (text[start] === '\n') start++
else if (text[start] === '\r' && text[start + 1] === '\n') start += 2
else break
}
return text.slice(start)
/* v8 ignore stop */
}
@@ -5,7 +5,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
import type { PropsRenderFactories } from '@deepseek-ai/dsh-client-ui-slots'
import type { ReasoningContentInput } from '../src/client/contract/slots.ts'
import type { AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { ReasoningContent } from '../src/client/chat/ReasoningContent.tsx'
import { DefaultReasoningBody, ReasoningContent } from '../src/client/chat/ReasoningContent.tsx'
import { zh } from '../src/client/locale.ts'
const t = makeTranslate(zh, commonZh)
@@ -23,7 +23,9 @@ function content(input: ReasoningContentInput) {
/** Render the production child component without emulating a registry or its dispatch. */
export const renderReasoningSlot: AssistantMarkdownProps['renderSlot'] = (key: string, input: object) => {
if (key !== 'conversation.chat.reasoning.body') throw new Error(`Unexpected reasoning Slot: ${key}`)
return content(input as ReasoningContentInput)
return createElement(DefaultReasoningBody, {
...input, renderFactorySlot: renderReasoningFactory,
} as ComponentProps<typeof DefaultReasoningBody>)
}
/** Supply the official component at the Factory call seam of a standalone component unit. */
@@ -67,7 +67,9 @@ describe('reasoning Content Factory', () => {
it('restores the registered default after a wrapping plugin unloads and removes the Factory with its owner', async () => {
const b = await createReasoningRenderFixture('en')
const view = b.render(b.renderSlot('conversation.chat.reasoning.body', { text: 'Original thought', running: false }))
const view = b.render(b.renderSlot('conversation.chat.reasoning.body', {
rawText: 'Original model thought', text: 'Original thought', running: false,
}))
expect(view.getByText('Original thought')).toBeTruthy()
expect(b.runtime.slots.entries('conversation.chat.reasoning.body')[0]?.options.priority).toBe(100)
const plugin = await b.runtime.mount({ inject: ['slots'], apply: (ctx) => {
@@ -39,7 +39,7 @@ describe('AssistantMarkdown reasoning blocks', () => {
expect(view.getAllByRole('button')).toHaveLength(1)
expect(view.container.querySelectorAll('[data-variant="think"]')).toHaveLength(1)
fireEvent.click(view.getByRole('button'))
expect(calls).toHaveBeenCalledWith('conversation.chat.reasoning.body', { text, running: false })
expect(calls).toHaveBeenCalledWith('conversation.chat.reasoning.body', { rawText: text, text, running: false })
expect(view.getByText('Visible reasoning').tagName).toBe('STRONG')
expect(blocks.map(block => block.text)).toEqual(texts)
})
@@ -134,7 +134,7 @@ describe('AssistantMarkdown reasoning blocks', () => {
})
describe('ReasoningRow', () => {
it('offers only text and streaming state to the standard Body Slot after expansion', () => {
it('offers original and display text after expansion', () => {
const calls = vi.fn()
const extension: AssistantMarkdownProps['renderSlot'] = (key, owner, options) => {
calls(key, owner)
@@ -144,7 +144,7 @@ describe('ReasoningRow', () => {
usePresentation={useDetailedPresentation} renderSlot={extension} t={t} />)
expect(calls).not.toHaveBeenCalled()
fireEvent.click(view.getByRole('button'))
expect(calls).toHaveBeenCalledWith('conversation.chat.reasoning.body', { text: 'Original thought', running: false })
expect(calls).toHaveBeenCalledWith('conversation.chat.reasoning.body', { rawText: 'Original thought', text: 'Original thought', running: false })
expect(view.getAllByText('Original thought')).toHaveLength(1)
expect(view.getAllByRole('button')).toHaveLength(1)
})
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { reasoningText } from '../src/client/reasoning-text.ts'
describe('reasoning display text', () => {
it.each([
'',
'Ordinary reasoning',
'普通思考正文',
'\n Original text \n',
'<note>Original text</note>',
])('preserves ordinary reasoning %j', (text) => {
expect(reasoningText(text)).toBe(text)
})
})
@@ -24,7 +24,7 @@ export type TranslationBodyProps = PropsRuntime<'conversation.chat.reasoning.bod
/**
* Render translated reasoning, retaining the complete original until a failed request is retried.
* @param props - original reasoning, translation preferences, and cancellation-aware callback.
* @param props - display-ready reasoning, translation preferences, and cancellation-aware callback.
* @returns compact Markdown and translation status with a retry action on failure.
*/
export function TranslationBody(props: TranslationBodyProps) {
@@ -13,18 +13,18 @@ import type { TranslateText } from '../src/client/translation.ts'
afterEach(cleanup)
function fixture() {
function fixture(text = 'Original paragraph') {
const preferences = createSnapshotStore<CotTranslationPreferences>({ provider: 'google', targetLanguage: 'auto' })
const locale = createSnapshotStore<LocaleSnapshot>({ active: 'zh', locales: [], revision: 0 })
const translationLimit = createSnapshotStore(4000)
const translate = vi.fn<TranslateText>(async request => `${request.provider}:${request.targetLanguage}:${request.text}`)
const props = { text: 'Original paragraph', running: false, translate, renderFactorySlot, t: makeTranslate(en),
const props = { rawText: 'Raw model reasoning', text, running: false, translate, renderFactorySlot, t: makeTranslate(en),
usePreferences: bindSnapshotSelector(preferences), useTranslationLocale: bindSnapshotSelector(locale),
useTranslationLimit: bindSnapshotSelector(translationLimit) } as TranslationBodyProps
return { props, preferences, locale, translationLimit, translate }
}
it('honors an explicit Google selection and the UI language, and switches to the original without another request', async () => {
it('translates display text and switches to its original without another request', async () => {
const b = fixture(), view = render(<TranslationBody {...b.props} />)
await view.findByText('google:zh:Original paragraph')
expect(b.translate).toHaveBeenCalledWith({ text: 'Original paragraph', provider: 'google', targetLanguage: 'zh' }, expect.any(AbortSignal))
@@ -32,6 +32,7 @@ it('honors an explicit Google selection and the UI language, and switches to the
fireEvent.click(view.getByRole('button', { name: 'View original' }))
expect(view.container.querySelector('[data-markdown-variant]')).toBe(markdown)
expect(view.getByText('Original paragraph')).toBeTruthy()
expect(view.container.textContent).not.toContain(b.props.rawText)
fireEvent.click(view.getByRole('button', { name: 'View translation' }))
expect(view.getByText('google:zh:Original paragraph')).toBeTruthy()
expect(b.translate).toHaveBeenCalledTimes(1)
@@ -62,6 +63,7 @@ it('shows a generic failure with original text and retries only on the reader ac
const view = render(<TranslationBody {...b.props} />)
await view.findByText(en.failed)
expect(view.getByText('Original paragraph')).toBeTruthy()
expect(view.container.textContent).not.toContain(b.props.rawText)
expect(view.queryByRole('button', { name: 'View original' })).toBeNull()
expect(view.getByRole('button', { name: 'View translation' }).hasAttribute('disabled')).toBe(true)
expect(view.queryByText('secret request text')).toBeNull()
@@ -72,9 +74,9 @@ it('shows a generic failure with original text and retries only on the reader ac
})
it('keeps the full original visible after a later paragraph fails and reuses completed translations on retry', async () => {
const b = fixture()
const b = fixture('First\n\nSecond')
b.translate.mockResolvedValueOnce('第一段').mockRejectedValueOnce(new Error('provider unavailable'))
const view = render(<TranslationBody {...b.props} text={'First\n\nSecond'} />)
const view = render(<TranslationBody {...b.props} />)
await view.findByText(en.failed)
expect(view.getByText('First')).toBeTruthy()
expect(view.getByText('Second')).toBeTruthy()
@@ -93,12 +95,15 @@ it('keeps the full original visible after a later paragraph fails and reuses com
})
it('keeps an unfinished streaming tail original and cancels the disclosure on unmount', async () => {
const b = fixture(), pending = Promise.withResolvers<string>()
const b = fixture('Unfinished'), pending = Promise.withResolvers<string>()
b.translate.mockImplementationOnce(() => pending.promise)
const view = render(<TranslationBody {...b.props} text="Unfinished" running />)
const view = render(<TranslationBody {...b.props} running />)
expect(b.translate).not.toHaveBeenCalled()
view.rerender(<TranslationBody {...b.props} text={'First\n\nTail'} running />)
view.rerender(<TranslationBody {...b.props} rawText="Raw model reasoning" text={'First\n\nTail'} running />)
expect(view.getByRole('status', { name: en.translating })).toBeTruthy()
expect(b.translate.mock.calls[0]?.[0].text).toBe('First')
expect(view.getByText('Tail', { exact: false })).toBeTruthy()
expect(view.container.textContent).not.toContain(b.props.rawText)
const signal = b.translate.mock.calls[0]![1]
view.unmount()
expect(signal.aborted).toBe(true)
@@ -107,9 +112,9 @@ it('keeps an unfinished streaming tail original and cancels the disclosure on un
})
it('cancels the previous request and rechunks when the request limit changes', async () => {
const b = fixture(), first = Promise.withResolvers<string>()
const b = fixture('abcdefghij'), first = Promise.withResolvers<string>()
b.translate.mockImplementationOnce(() => first.promise)
const view = render(<TranslationBody {...b.props} text="abcdefghij" />)
const view = render(<TranslationBody {...b.props} />)
const firstSignal = b.translate.mock.calls[0]![1]
act(() => { b.translationLimit.set(4) })
expect(firstSignal.aborted).toBe(true)
@@ -273,7 +273,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:487',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:493',
},
{
key: 'conversation.chat.commandview',
@@ -325,7 +325,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:469',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:475',
},
{
key: 'conversation.chat.flow',
@@ -372,7 +372,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.flow\', () => ctx.slots.register(\n { name: \'conversation.chat.flow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:425',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:431',
},
{
key: 'conversation.chat.message.images',
@@ -419,7 +419,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.message.images\', () => ctx.slots.register(\n { name: \'conversation.chat.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:461',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:467',
},
{
key: 'conversation.chat.node',
@@ -491,17 +491,17 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:437',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:443',
},
{
key: 'conversation.chat.reasoning.body',
kind: 'single',
scope: 'session',
summary: 'Expanded reasoning body with the unchanged model text.',
doc: 'Expanded reasoning body with the unchanged model text. The official entry\nhas priority 100 and calls the reasoning Content Factory; smaller priority\nvalues replace it. Contributions must preserve access to the original text.',
summary: 'Expanded reasoning body with raw and display-ready text.',
doc: 'Expanded reasoning body with raw and display-ready text. The official entry\nhas priority 100 and calls the reasoning Content Factory; smaller priority\nvalues replace it. Contributions must preserve access to untranslated display text.',
registerOptions: [],
ownerProps: [
'/** Original model-provided reasoning offered to the expanded Body Slot. */\nexport interface ReasoningBodyOwnerProps {\n /** Complete or streaming reasoning text. */\n readonly text: string\n /** Whether the reasoning tail is still streaming. */\n readonly running: boolean\n}',
'/** Original and display-ready reasoning offered to the expanded Body Slot. */\nexport interface ReasoningBodyOwnerProps {\n /** Complete or streaming model output. */\n readonly rawText: string\n /** Reasoning text prepared for display. */\n readonly text: string\n /** Whether the reasoning tail is still streaming. */\n readonly running: boolean\n}',
],
ownerPropsReferences: [],
standardProps: [
@@ -535,7 +535,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.reasoning.body\', () => ctx.slots.register(\n { name: \'conversation.chat.reasoning.body\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:475',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:481',
},
{
key: 'conversation.chat.text-interaction',
@@ -587,7 +587,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.text-interaction\', () => ctx.slots.register(\n { name: \'conversation.chat.text-interaction\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:422',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:428',
},
{
key: 'conversation.chat.turnTail',
@@ -654,7 +654,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:481',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:487',
},
{
key: 'conversation.chat.user.before',
@@ -704,7 +704,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.user.before\', () => ctx.slots.register(\n { name: \'conversation.chat.user.before\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:451',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:457',
},
{
key: 'conversation.chat.user.images',
@@ -751,7 +751,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.user.images\', () => ctx.slots.register(\n { name: \'conversation.chat.user.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:463',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:469',
},
{
key: 'conversation.composer',
@@ -3778,7 +3778,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.quota-notice\', () => ctx.slots.register(\n { name: \'shell.quota-notice\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:495',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:501',
},
{
key: 'sidebar',