feat(ui-chat): restore the complete running status

This commit is contained in:
imccyu committed 2026-09-27 15:10:07 +08:00
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>
+2 -2
View File
@@ -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('-')
})
})