Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	apps/web/tests/snapshots/queue-actions/preserved.expected.md
#	packages/client/runtime/src/client/sessions/session.ts
This commit is contained in:
_Kerman
2026-08-04 16:56:44 +08:00
66 changed files with 917 additions and 84 deletions

View File

@@ -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,
@@ -498,6 +518,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')],
@@ -658,6 +714,30 @@ 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({ queue: [{
id: 'steering-occurrence' as never,
messageId: 'steering-message' as never,
placement: 'steering',
content: [{ type: 'text', text: 'also' }],
preview: 'also',
text: 'also',
}] })
})
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 }[] = []