Merge remote-tracking branch 'origin/master' into worktree/web-model-request-retry

# Conflicts:
#	apps/cli/README.i18n.yaml
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/tests/chat-view.spec.tsx
This commit is contained in:
Yichen Jiang
2026-07-31 10:19:14 +08:00
933 changed files with 28910 additions and 4079 deletions
@@ -8,18 +8,24 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import {
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
} from '../src/client/chat/message-chrome.ts'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
import { MessageItem, type MessageItemProps } from '../src/client/chat/MessageItem.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(() => {
cleanup()
vi.useRealTimers()
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
describe('MessageItem arms', () => {
it('user bubbles expose clock / copy / branch / edit; copy writes the text', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
@@ -31,7 +37,7 @@ describe('MessageItem arms', () => {
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'user', seq: 1, time,
content: [{ type: 'text', text: 'hello bubble' }] as never,
source: null,
@@ -57,7 +63,7 @@ describe('MessageItem arms', () => {
value: exec,
})
render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'fallback body' }] as never,
source: null,
@@ -80,7 +86,7 @@ describe('MessageItem arms', () => {
},
})
render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'quiet' }] as never,
source: null,
@@ -98,7 +104,7 @@ describe('MessageItem arms', () => {
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
const view = render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'steering', seq: 2, turn: 1, source: null,
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
} as never}
@@ -112,7 +118,7 @@ describe('MessageItem arms', () => {
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
const ctxView = render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
@@ -138,7 +144,7 @@ describe('MessageItem arms', () => {
it('context preserves the bounded JSON truncation contract', () => {
const view = render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
@@ -153,7 +159,7 @@ describe('MessageItem arms', () => {
it('unknown nodes retain the generic JSON row', () => {
const unknownView = render(
<MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
)
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
})
@@ -163,6 +169,7 @@ describe('MessageItem arms', () => {
vi.setSystemTime(10_000)
const view = render(
<MessageItem
t={t}
retryActive
node={{
kind: 'model-retry',
@@ -196,6 +203,7 @@ describe('MessageItem arms', () => {
view.rerender(
<MessageItem
t={t}
retryActive
node={{
kind: 'model-retry',
@@ -221,7 +229,7 @@ describe('MessageItem arms', () => {
expect(details?.open).toBe(true)
view.rerender(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'model-retry',
seq: 6,
time: 12_100,
@@ -242,7 +250,7 @@ describe('MessageItem arms', () => {
expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 4s')
view.rerender(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'model-retry',
seq: 7,
time: 12_100,
@@ -261,7 +269,7 @@ describe('MessageItem arms', () => {
expect(view.getByRole('status').textContent).toBe('已重试模型请求(3/∞) · 4s')
view.rerender(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'model-retry',
seq: 8,
time: 12_100,
@@ -299,11 +307,11 @@ describe('MessageItem arms', () => {
delayMs: 5_000,
failure: { code: 'TRANSPORT', message: '连接被重置' },
} as const
const view = render(<MessageItem node={node} />)
const view = render(<MessageItem t={t} node={node} />)
expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s')
act(() => { vi.advanceTimersByTime(4_200) })
view.rerender(<MessageItem node={node} retryActive />)
view.rerender(<MessageItem t={t} node={node} retryActive />)
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
})
})
@@ -312,15 +320,15 @@ describe('formatMessageClock', () => {
const now = new Date(2026, 6, 29, 10, 0).getTime()
it('keeps HH:mm on the same calendar day', () => {
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), now)).toBe('14:24')
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), t, now)).toBe('14:24')
})
it('prefixes month and day across days in the same year', () => {
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), now)).toBe('1月1日 14:24')
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), t, now)).toBe('1月1日 14:24')
})
it('prefixes year, month, and day across years', () => {
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), now)).toBe('2025年12月31日 09:05')
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), t, now)).toBe('2025年12月31日 09:05')
})
it('arms the next local midnight from an in-day instant', () => {
@@ -343,7 +351,7 @@ describe('useCalendarDay boundary refresh', () => {
vi.setSystemTime(dayStart)
const time = new Date(2026, 6, 29, 14, 24).getTime()
render(
<MessageItem node={{
<MessageItem t={t} node={{
kind: 'user', seq: 1, time,
content: [{ type: 'text', text: 'night bubble' }] as never,
source: null,
@@ -361,7 +369,7 @@ describe('useCalendarDay boundary refresh', () => {
describe('small branch tails', () => {
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
const view = render(
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
<AssistantMarkdown t={t} blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
)
expect(view.getByText('one-liner')).toBeTruthy()
})
@@ -376,6 +384,7 @@ describe('small branch tails', () => {
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const settled = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
streaming={false}
time={time}
@@ -390,6 +399,7 @@ describe('small branch tails', () => {
const thinkOnly = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
streaming={false}
time={time}
@@ -400,7 +410,7 @@ describe('small branch tails', () => {
thinkOnly.unmount()
const streaming = render(
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
)
expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
expect(streaming.queryByText('14:24')).toBeNull()