|
|
@@ -20,7 +20,8 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
|
|
|
import { createChatStore } from '../src/client/stores.ts'
|
|
|
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
|
|
import { zh } from '../src/client/locales.ts'
|
|
|
-import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts'
|
|
|
+import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
|
|
|
+import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
|
|
|
|
|
|
afterEach(() => {
|
|
|
cleanup()
|
|
|
@@ -36,7 +37,7 @@ const SID = 's1' as SessionId
|
|
|
|
|
|
function snapshotBase(): ConversationSnapshot {
|
|
|
return {
|
|
|
- sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
|
|
+ sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
|
|
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
|
|
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
|
|
}
|
|
|
@@ -241,6 +242,25 @@ describe('chat-flow derivation', () => {
|
|
|
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
|
|
|
})
|
|
|
|
|
|
+ it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
|
|
|
+ expect(runningTurnStartTime(new Map([
|
|
|
+ [1, { startTime: 1_000, endTime: 5_000 }],
|
|
|
+ [2, { startTime: 6_000 }],
|
|
|
+ ]))).toBe(6_000)
|
|
|
+ expect(runningTurnStartTime(new Map([
|
|
|
+ [1, { startTime: 1_000, endTime: 5_000 }],
|
|
|
+ [2, { startTime: 6_000, endTime: 9_000 }],
|
|
|
+ ]))).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('formatRunDuration localizes units and floors partial seconds', () => {
|
|
|
+ const t = makeTranslate(zh, commonZh)
|
|
|
+ expect(formatRunDuration(0, t)).toBe('0秒')
|
|
|
+ expect(formatRunDuration(-500, t)).toBe('0秒')
|
|
|
+ expect(formatRunDuration(15_999, t)).toBe('15秒')
|
|
|
+ expect(formatRunDuration(125_000, t)).toBe('2分05秒')
|
|
|
+ })
|
|
|
+
|
|
|
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
|
|
|
const interruptedThink: AssistantMessageNode = {
|
|
|
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
|
|
@@ -504,6 +524,42 @@ describe('ChatView', () => {
|
|
|
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null])
|
|
|
})
|
|
|
|
|
|
+ it('the actions-owning assistant footer shows the turn run time', () => {
|
|
|
+ const h = makeHarness({
|
|
|
+ nodes: [
|
|
|
+ user(1, 'hi'), // time 1_000
|
|
|
+ assistant(2, 'mid-turn text'),
|
|
|
+ assistant(16, 'final answer'),
|
|
|
+ toolResult(18, 'trailing'),
|
|
|
+ ],
|
|
|
+ turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
|
|
|
+ turnEnds: new Map([[1, 20]]),
|
|
|
+ })
|
|
|
+ const view = render(<h.ChatView {...h.props} />)
|
|
|
+ // The exact turn/end includes trailing tool activity after the final text.
|
|
|
+ expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('user and assistant message containers scope the hover-revealed time chrome', () => {
|
|
|
+ const h = makeHarness({
|
|
|
+ nodes: [user(1, 'hi'), assistant(2, 'answer')],
|
|
|
+ turnTimings: new Map([[1, { startTime: 1_000, endTime: 2_000 }]]),
|
|
|
+ turnEnds: new Map([[1, 2]]),
|
|
|
+ })
|
|
|
+ const view = render(<h.ChatView {...h.props} />)
|
|
|
+ // One scope per message row; the CSS reveal keys off this attribute.
|
|
|
+ expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('the run-time label is withheld when the turn start is outside the window', () => {
|
|
|
+ const h = makeHarness({
|
|
|
+ nodes: [assistant(16, 'tail without trigger')],
|
|
|
+ turnEnds: new Map([[1, 16]]),
|
|
|
+ })
|
|
|
+ const view = render(<h.ChatView {...h.props} />)
|
|
|
+ expect(view.queryByText(/用时/)).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
it('enables fork only on the finalized assistant at the completed transcript tail', () => {
|
|
|
const h = makeHarness({
|
|
|
nodes: [user(1, 'question'), assistant(2, 'answer')],
|
|
|
@@ -664,6 +720,26 @@ describe('ChatView', () => {
|
|
|
expect(view.getByRole('status').textContent).toBe('Deep diving...')
|
|
|
})
|
|
|
|
|
|
+ it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
|
|
|
+ const startTime = Date.now() - 125_000
|
|
|
+ const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
|
|
|
+ const h = makeHarness({
|
|
|
+ nodes: [trigger], turnTimings: new Map([[1, { startTime }]]), running: true,
|
|
|
+ })
|
|
|
+ const view = render(<h.ChatView {...h.props} />)
|
|
|
+ // Freshly mounted (as after a reload) yet already past the 15s gate.
|
|
|
+ const status = view.getByRole('status')
|
|
|
+ expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
|
|
+ expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
|
|
|
+ act(() => {
|
|
|
+ h.set({ nodes: [trigger, {
|
|
|
+ kind: 'steering', messageId: 'st' as never, seq: 2, time: Date.now(), turn: 1,
|
|
|
+ content: [{ type: 'text', text: 'also' }], source: null,
|
|
|
+ }] })
|
|
|
+ })
|
|
|
+ expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
|
|
+ })
|
|
|
+
|
|
|
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
|
|
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
|
|
const calls: { key: string; entryKey?: string }[] = []
|