feat(ui-chat): restore the complete running status
This commit is contained in:
1 parent
398c2cde25
commit
cae515d504
8 files changed
+221
-61
No files matched your search
@@ -101,13 +101,55 @@
|
||||
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
}
|
||||
|
||||
/* Decorative whale below the last flow row while the Session runs. */
|
||||
.runningWhale {
|
||||
.running {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
--dsh-chat-flow-gap: 12px;
|
||||
color: var(--dsw-alias-label-deep-diving);
|
||||
font-size: calc(var(--dsh-content-font-size, 14px) - 2px);
|
||||
line-height: calc(22px + var(--dsh-content-font-delta, 0px));
|
||||
}
|
||||
|
||||
/* The last visible row before the status must contain output, not an input.
|
||||
Hidden and empty seats do not separate a user message from the status. */
|
||||
.column > :nth-last-child(2 of :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
|
||||
.flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]),
|
||||
[data-chat-group-key]
|
||||
) ~ .running > .runningDivider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.runningDivider {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 0.5px;
|
||||
margin: 8px 0 10px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-border-l1) 75%, var(--dsw-alias-border-l2));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.runningContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.runningText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runningNumber {
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.runningIcon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: calc(14px + var(--dsh-content-font-delta, 0px));
|
||||
height: calc(14px + var(--dsh-content-font-delta, 0px));
|
||||
color: var(--dsw-alias-label-deep-diving);
|
||||
}
|
||||
|
||||
.openError {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ChatGroupSeat } from './ChatGroupSeat.tsx'
|
||||
import { chatRenderKey } from './render-entry.ts'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import { TurnNavigator } from './TurnNavigator.tsx'
|
||||
import { RunningWhaleTail } from './RunningWhaleTail.tsx'
|
||||
import { RunningStatus } from './RunningStatus.tsx'
|
||||
import { mergeTurnRailItems } from './turn-rail-items.ts'
|
||||
import { useChatScroll } from './use-chat-scroll.ts'
|
||||
import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
@@ -112,6 +112,13 @@ export function ChatView({
|
||||
// both the data and its change signal: the array identity moves only when a
|
||||
// Turn enters, leaves, or changes its preview.
|
||||
const turnNavigationItems = useChat(s => s.navigation.items())
|
||||
const latestTurnAnchor = turnNavigationItems.at(-1)?.anchorKey
|
||||
const runningStartTime = useChatNode(latestTurnAnchor ?? '', (node) => {
|
||||
const location = node?.location
|
||||
return location?.kind === 'turn' || location?.kind === 'step'
|
||||
? location.turn.status === 'open' ? location.turn.start?.time : undefined
|
||||
: undefined
|
||||
})
|
||||
// Host-computed whole-log outline; the merge is view-layer only (the
|
||||
// conversation snapshot never carries projection values).
|
||||
const turnOutline = useProjection('turnOutline')
|
||||
@@ -270,7 +277,7 @@ export function ChatView({
|
||||
t={t}
|
||||
/>
|
||||
</MarkdownDelegateProvider>
|
||||
{running && <RunningWhaleTail />}
|
||||
{running && <RunningStatus startTime={runningStartTime} t={t} />}
|
||||
{/* No pending placeholders: questions (ui-user-questions) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Running Turn clock isolated from the transcript's render cycle. */
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { formatLiveRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts'
|
||||
import { RunningWhaleTail } from './RunningWhaleTail.tsx'
|
||||
import a11yCss from './accessibility.module.css'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
interface RunningStatusProps {
|
||||
readonly startTime: number | undefined
|
||||
readonly t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
/**
|
||||
* Show live elapsed time after the current Turn's content without announcing ticks.
|
||||
* @param props - Current Turn start time and localized copy.
|
||||
* @returns the blue running indicator; mount only while the Session is running.
|
||||
*/
|
||||
export const RunningStatus = memo(function RunningStatus({ startTime, t }: RunningStatusProps) {
|
||||
const [now, setNow] = useState(Date.now)
|
||||
useEffect(() => {
|
||||
if (startTime === undefined) return
|
||||
setNow(Date.now())
|
||||
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
|
||||
return () => { clearInterval(timer) }
|
||||
}, [startTime])
|
||||
const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', {
|
||||
duration: formatLiveRunDuration(Math.max(1000, now - startTime), t),
|
||||
})
|
||||
return (
|
||||
<div className={css.running} data-chat-running>
|
||||
<span className={a11yCss.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">{t('chat.deepDiving')}</span>
|
||||
<span className={css.runningDivider} aria-hidden="true" />
|
||||
<span className={css.runningContent}>
|
||||
<RunningWhaleTail />
|
||||
<span className={css.runningText}>
|
||||
{label.split(/(\d+)/).map((part, index) => (
|
||||
<TextShimmer key={index} active className={/^\d+$/.test(part) ? css.runningNumber : undefined}>{part}</TextShimmer>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -52,7 +52,7 @@ export function RunningWhaleTail() {
|
||||
return () => { preference.removeEventListener('change', update) }
|
||||
}, [])
|
||||
return (
|
||||
<span className={css.runningWhale} aria-hidden="true" data-chat-running>
|
||||
<span className={css.runningIcon} aria-hidden="true">
|
||||
<svg key={reducedMotion ? 'still' : 'animated'} width="100%" height="100%" viewBox="0 0 16 16" fill="none">
|
||||
<path d={REST_PATH} stroke="currentColor" strokeWidth={1}>
|
||||
{!reducedMotion && <animate attributeName="d" values={MOTION_PATHS} keyTimes={MOTION_TIMES}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeViewProps } from '../contract/slots.ts'
|
||||
import { turnProcessAlwaysOpen } from '../contract/turn-process.ts'
|
||||
import { formatLiveRunDuration, formatRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts'
|
||||
import { formatRunDuration } from './message-chrome.ts'
|
||||
import a11yCss from './accessibility.module.css'
|
||||
import css from './TurnProcessNodeView.module.css'
|
||||
|
||||
@@ -15,33 +15,21 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
|
||||
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
||||
? node.location.turn
|
||||
: undefined
|
||||
const [now, setNow] = useState(Date.now)
|
||||
const ticking = turn?.status === 'open' && turn.start !== undefined
|
||||
useEffect(() => {
|
||||
if (!ticking) return
|
||||
setNow(Date.now())
|
||||
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
|
||||
return () => { clearInterval(timer) }
|
||||
}, [ticking])
|
||||
if (turn?.start === undefined && turn?.status !== 'closed') return null
|
||||
if (turn?.status !== 'closed') return null
|
||||
const canCollapse = turnProcess.foldable && turnProcess.hasContent && !turnProcessAlwaysOpen(node)
|
||||
const running = turn.status === 'open'
|
||||
const reason = turn.end?.data.reason.kind
|
||||
const elapsedMs = turn.start === undefined ? undefined
|
||||
: Math.max(1000, (turn.end?.time ?? now) - turn.start.time)
|
||||
const elapsedMs = turn.start === undefined || turn.end === undefined ? undefined
|
||||
: Math.max(1000, turn.end.time - turn.start.time)
|
||||
const duration = elapsedMs === undefined ? undefined
|
||||
: running ? formatLiveRunDuration(elapsedMs, t) : formatRunDuration(elapsedMs, t)
|
||||
: formatRunDuration(elapsedMs, t)
|
||||
// Other end reasons retain elapsed time; only cancellation and failure replace it.
|
||||
const label = running
|
||||
? duration === undefined ? t('chat.deepDiving') : t('message.turnProcess.deepDivingFor', { duration })
|
||||
: reason === 'aborted' ? t('message.stopped')
|
||||
: reason === 'error' ? t('message.turnProcess.failed')
|
||||
: duration === undefined ? t('message.turnProcess.worked')
|
||||
: t('message.turnProcess.took', { duration })
|
||||
const announcement = running ? t('chat.deepDiving')
|
||||
: reason === 'aborted' ? t('message.stopped')
|
||||
: reason === 'error' ? t('message.turnProcess.failed')
|
||||
: t('message.turnProcess.worked')
|
||||
const label = reason === 'aborted' ? t('message.stopped')
|
||||
: reason === 'error' ? t('message.turnProcess.failed')
|
||||
: duration === undefined ? t('message.turnProcess.worked')
|
||||
: t('message.turnProcess.took', { duration })
|
||||
const announcement = reason === 'aborted' ? t('message.stopped')
|
||||
: reason === 'error' ? t('message.turnProcess.failed')
|
||||
: t('message.turnProcess.worked')
|
||||
return (
|
||||
<>
|
||||
<span className={a11yCss.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">{announcement}</span>
|
||||
|
||||
@@ -62,7 +62,6 @@ export const zh = {
|
||||
'message.trigger.plugin': '插件状态更新',
|
||||
'message.trigger.explanation': '这条通知触发了本轮回复。',
|
||||
'message.turnProcess.worked': '已完成工作',
|
||||
'message.turnProcess.deepDivingFor': '深度求索中,用时{duration}',
|
||||
'message.turnProcess.took': '用时 {duration}',
|
||||
'message.turnProcess.failed': '处理失败',
|
||||
'view.chat': '对话',
|
||||
@@ -88,6 +87,7 @@ export const zh = {
|
||||
'chat.loadOlder': '加载更早',
|
||||
'chat.toBottom': '回到底部',
|
||||
'chat.deepDiving': '深度求索中',
|
||||
'chat.deepDivingFor': '深度求索中,用时 {duration} ···',
|
||||
'chat.turnNavigation.label': '轮次导航',
|
||||
'chat.turnNavigation.jump': '跳转到第 {turn} 轮',
|
||||
'chat.turnNavigation.jumpLoad': '加载并跳转到第 {turn} 轮',
|
||||
@@ -255,7 +255,6 @@ export const en = {
|
||||
'message.trigger.plugin': 'Plugin status updated',
|
||||
'message.trigger.explanation': 'This notification triggered this response.',
|
||||
'message.turnProcess.worked': 'Worked',
|
||||
'message.turnProcess.deepDivingFor': 'Deep diving for {duration}',
|
||||
'message.turnProcess.took': 'Took {duration}',
|
||||
'message.turnProcess.failed': 'Failed',
|
||||
'view.chat': 'Chat',
|
||||
@@ -281,6 +280,7 @@ export const en = {
|
||||
'chat.loadOlder': 'Load earlier',
|
||||
'chat.toBottom': 'Back to bottom',
|
||||
'chat.deepDiving': 'Deep diving...',
|
||||
'chat.deepDivingFor': 'Deep diving for {duration} ···',
|
||||
'chat.turnNavigation.label': 'Turn navigation',
|
||||
'chat.turnNavigation.jump': 'Jump to turn {turn}',
|
||||
'chat.turnNavigation.jumpLoad': 'Load and jump to turn {turn}',
|
||||
|
||||
@@ -505,6 +505,10 @@ function turnProcessControl(container: HTMLElement): HTMLButtonElement | null {
|
||||
return container.querySelector<HTMLButtonElement>('[data-turn-process]')
|
||||
}
|
||||
|
||||
function runningContent(container: HTMLElement): HTMLElement | null {
|
||||
return container.querySelector<HTMLElement>('[data-chat-running] > :last-child')
|
||||
}
|
||||
|
||||
function withClock(time: number, run: () => void): () => void {
|
||||
return () => {
|
||||
vi.useFakeTimers()
|
||||
@@ -1619,9 +1623,10 @@ describe('ChatView', () => {
|
||||
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('interrupt now')
|
||||
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
expect(turnProcessControl(view.container)?.textContent).toBe('深度求索中,用时2秒')
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒 ···$/)
|
||||
expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now'))
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
& Node.DOCUMENT_POSITION_PRECEDING).not.toBe(0)
|
||||
|
||||
act(() => {
|
||||
h.setSession({ testInbox: { 'next-turn': [queued], 'next-step': [] } })
|
||||
@@ -2271,7 +2276,8 @@ describe('ChatView', () => {
|
||||
const h = makeHarness({ chat: initial }, { running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(renderedFlowKinds(view.container)).toEqual(['user', 'turn-process'])
|
||||
expect(turnProcessControl(view.container)?.textContent).toBe('深度求索中,用时4秒')
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒 ···$/)
|
||||
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
|
||||
|
||||
act(() => {
|
||||
@@ -2453,11 +2459,9 @@ describe('ChatView', () => {
|
||||
turnTimings: new Map([[1, { startTime: 0 }]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const liveToggle = view.getByRole('button', { name: '深度求索中,用时3秒' }) as HTMLButtonElement
|
||||
expect(liveToggle.disabled).toBe(true)
|
||||
expect(liveToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒 ···$/)
|
||||
const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
|
||||
fireEvent.click(liveToggle)
|
||||
expect(processRow.getAttribute('hidden')).toBeNull()
|
||||
|
||||
act(() => {
|
||||
@@ -2469,7 +2473,7 @@ describe('ChatView', () => {
|
||||
})
|
||||
})
|
||||
const toggle = turnProcessControl(view.container)!
|
||||
expect(toggle).toBe(liveToggle)
|
||||
expect(runningContent(view.container)).toBeNull()
|
||||
expect(toggle.textContent).toBe('用时 5秒')
|
||||
expect(toggle.disabled).toBe(false)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
@@ -3204,9 +3208,8 @@ describe('ChatView', () => {
|
||||
expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
|
||||
expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
|
||||
expect(view.getByRole('status').textContent).toBe('深度求索中')
|
||||
const toggle = view.getByRole('button', { name: '深度求索中,用时2秒' }) as HTMLButtonElement
|
||||
expect(toggle.disabled).toBe(true)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒 ···$/)
|
||||
}))
|
||||
|
||||
it('keeps the Tool renderer mounted when a running call settles into log order', () => {
|
||||
@@ -3265,13 +3268,13 @@ describe('ChatView', () => {
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const status = view.getByRole('status')
|
||||
const toggle = view.getByRole('button', { name: '深度求索中,用时2分5秒' }) as HTMLButtonElement
|
||||
expect(toggle).toBe(turnProcessControl(view.container))
|
||||
expect(toggle.disabled).toBe(true)
|
||||
const content = runningContent(view.container)!
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒 ···$/)
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
expect(status.textContent).toBe('深度求索中')
|
||||
expect(status.getAttribute('aria-live')).toBe('polite')
|
||||
expect(status.getAttribute('aria-atomic')).toBe('true')
|
||||
expect(toggle.closest('[aria-live]')).toBeNull()
|
||||
expect(content.closest('[aria-live]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-chat-flow-kind="assistant-step"]')).toBeNull()
|
||||
act(() => {
|
||||
h.setSession({ testInbox: { 'next-turn': [], 'next-step': [{
|
||||
@@ -3281,20 +3284,22 @@ describe('ChatView', () => {
|
||||
role: 'user', source: { kind: 'user' },
|
||||
}] } })
|
||||
})
|
||||
expect(toggle.textContent).toBe('深度求索中,用时2分5秒')
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒 ···$/)
|
||||
const beforeTick = content.textContent
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
expect(toggle.textContent).toBe('深度求索中,用时2分7秒')
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒 ···$/)
|
||||
expect(content.textContent).not.toBe(beforeTick)
|
||||
expect(view.getByRole('status')).toBe(status)
|
||||
expect(status.textContent).toBe('深度求索中')
|
||||
act(() => {
|
||||
h.setSession({ testInbox: { 'next-turn': [], 'next-step': [] } })
|
||||
h.setChat({ nodes: [trigger, { ...steering(2, 'also', 1), time: 128_000 }] })
|
||||
})
|
||||
expect(turnProcessControl(view.container)).toBe(toggle)
|
||||
expect(toggle.textContent).toBe('深度求索中,用时2分7秒')
|
||||
expect(runningContent(view.container)).toBe(content)
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒 ···$/)
|
||||
expect(view.getByText('also').closest('[data-pending-steering]')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(1_000) })
|
||||
expect(toggle.textContent).toBe('深度求索中,用时2分8秒')
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒 ···$/)
|
||||
expect(status.textContent).toBe('深度求索中')
|
||||
view.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
@@ -3308,11 +3313,14 @@ describe('ChatView', () => {
|
||||
{ running: true },
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const toggle = view.getByRole('button', { name: '深度求索中,用时59分59秒' })
|
||||
const content = runningContent(view.container)!
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒 ···$/)
|
||||
act(() => { vi.advanceTimersByTime(1_000) })
|
||||
expect(toggle.textContent).toBe('深度求索中,用时1小时00分0秒')
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+小时\d+分\d+秒 ···$/)
|
||||
const hourlyText = content.textContent
|
||||
act(() => { vi.advanceTimersByTime(303_000) })
|
||||
expect(toggle.textContent).toBe('深度求索中,用时1小时05分3秒')
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+小时\d+分\d+秒 ···$/)
|
||||
expect(content.textContent).not.toBe(hourlyText)
|
||||
expect(view.getByRole('status').textContent).toBe('深度求索中')
|
||||
view.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
@@ -4090,14 +4098,16 @@ describe('ChatView', () => {
|
||||
expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('appends the whale below the last flow row only while the Session runs', () => {
|
||||
it('appends the running status below the last flow row only while the Session runs', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'do the thing'), assistant(2, 'working')] }, { running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const column = view.container.querySelector('[data-chat-flow]')!
|
||||
const whale = column.querySelector('[data-chat-running]')
|
||||
expect(column.lastElementChild).toBe(whale)
|
||||
expect(whale?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(whale?.querySelector('animate')).not.toBeNull()
|
||||
const status = column.querySelector('[data-chat-running]')
|
||||
expect(column.lastElementChild).toBe(status)
|
||||
expect(within(status as HTMLElement).getByRole('status').textContent).toBe('深度求索中')
|
||||
expect(status?.lastElementChild?.textContent).toBe('深度求索中')
|
||||
expect(status?.querySelector('svg')?.parentElement?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(status?.querySelector('animate')).not.toBeNull()
|
||||
act(() => { h.setSession({ running: false }) })
|
||||
expect(column.querySelector('[data-chat-running]')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { RunningStatus } from '../src/client/chat/RunningStatus.tsx'
|
||||
import { zh } from '../src/client/locale.ts'
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(5_000) })
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
function statusHarness(startTime?: number) {
|
||||
const view = render(<RunningStatus startTime={startTime} t={t} />)
|
||||
return {
|
||||
...view,
|
||||
content: () => view.container.querySelector('[data-chat-running] > :last-child'),
|
||||
set: (nextStartTime?: number) => { view.rerender(<RunningStatus startTime={nextStartTime} t={t} />) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('RunningStatus', () => {
|
||||
it('waits for an open Turn start before allocating its clock', () => {
|
||||
const view = statusHarness()
|
||||
expect(view.content()?.textContent).toBe('深度求索中')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
view.set(1_000)
|
||||
expect(view.content()?.textContent).toMatch(/^深度求索中,用时 \d+秒 ···$/)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
view.set()
|
||||
expect(view.content()?.textContent).toBe('深度求索中')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the indicator mounted while a continuous run moves to the next Turn', () => {
|
||||
const view = statusHarness(1_000)
|
||||
const content = view.content()
|
||||
const status = view.getByRole('status')
|
||||
const initialText = content?.textContent
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
expect(content?.textContent).toMatch(/^深度求索中,用时 \d+秒 ···$/)
|
||||
expect(content?.textContent).not.toBe(initialText)
|
||||
view.set(7_000)
|
||||
expect(view.content()).toBe(content)
|
||||
expect(content?.textContent).toMatch(/^深度求索中,用时 \d+秒 ···$/)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
const nextTurnText = content?.textContent
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
expect(content?.textContent).toMatch(/^深度求索中,用时 \d+秒 ···$/)
|
||||
expect(content?.textContent).not.toBe(nextTurnText)
|
||||
expect(view.getByRole('status')).toBe(status)
|
||||
expect(status.textContent).toBe('深度求索中')
|
||||
view.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the duration nonnegative when the start is ahead of the local clock', () => {
|
||||
const view = statusHarness(6_000)
|
||||
expect(view.content()?.textContent).toMatch(/^深度求索中,用时 \d+秒 ···$/)
|
||||
expect(view.content()?.textContent).not.toContain('-')
|
||||
act(() => { vi.advanceTimersByTime(3_000) })
|
||||
expect(view.content()?.textContent).toMatch(/^深度求索中,用时 \d+秒 ···$/)
|
||||
expect(view.content()?.textContent).not.toContain('-')
|
||||
})
|
||||
|
||||
})
|
||||
Reference in new issue
Block a user