Merge remote-tracking branch 'github/feat/web-queue-steer-all' into feat/web-queue-steer-tooltip

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
_Kerman
2026-08-07 14:05:27 +08:00
128 changed files with 4545 additions and 526 deletions

View File

@@ -36,7 +36,7 @@ afterEach(() => {
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
describe('MessageItem arms', () => {
it('user bubbles expose clock / copy / branch and no edit; copy writes the text', () => {
it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -45,24 +45,20 @@ describe('MessageItem arms', () => {
// Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const onFork = vi.fn()
render(
<MessageItem t={t} node={{
kind: 'user', seq: 1, time,
content: [{ type: 'text', text: 'hello bubble' }] as never,
source: null,
}}
onFork={onFork}
/>,
)
expect(screen.getByText('14:24')).toBeTruthy()
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
expect(screen.queryByRole('button', { name: '编辑' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('hello bubble')
fireEvent.click(screen.getByRole('button', { name: '在新对话中分支' }))
expect(onFork).toHaveBeenCalledWith(1)
})
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
@@ -87,30 +83,6 @@ describe('MessageItem arms', () => {
expect(exec).toHaveBeenCalledWith('copy')
})
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
const onFork = vi.fn()
render(
<MessageItem t={t} node={{
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'open turn' }] as never,
source: null,
}}
onFork={onFork}
forkUnavailable
/>,
)
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
expect(branch.disabled).toBe(false)
expect(branch.getAttribute('aria-disabled')).toBe('true')
const reasonId = branch.getAttribute('aria-describedby')
expect(reasonId).not.toBeNull()
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
fireEvent.click(branch)
expect(onFork).not.toHaveBeenCalled()
fireEvent.focus(branch)
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
})
it('user copy never claims success when the host rejects the write', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -212,19 +184,17 @@ describe('MessageItem arms', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => {
it('consumed steering is captioned as an interjection and keeps copy without branch', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const fork = vi.fn()
const view = render(
<MessageItem t={t} node={{
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
} as never}
onFork={fork}
/>,
)
expect(view.getByText('插话')).toBeTruthy()
@@ -232,8 +202,7 @@ describe('MessageItem arms', () => {
expect(view.getByText(/附加内容块/)).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('steer!')
fireEvent.click(view.getByRole('button', { name: '在新对话中分支' }))
expect(fork).toHaveBeenCalledWith(2)
expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
})
it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => {
@@ -1002,6 +971,31 @@ describe('small branch tails', () => {
expect(streaming.queryByText('14:24')).toBeNull()
})
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
const onFork = vi.fn()
render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'answer before a trailing tool row' }]}
streaming={false}
time={1_000}
seq={1}
onFork={onFork}
forkUnavailable
/>,
)
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
expect(branch.disabled).toBe(false)
expect(branch.getAttribute('aria-disabled')).toBe('true')
const reasonId = branch.getAttribute('aria-describedby')
expect(reasonId).not.toBeNull()
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
fireEvent.click(branch)
expect(onFork).not.toHaveBeenCalled()
fireEvent.focus(branch)
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
})
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
// Cache hit is null only when all three prompt buckets are zero (pure
// output accounting) — any billed input makes it a real 0%.

View File

@@ -20,7 +20,7 @@ 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, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, flowKeys, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
afterEach(() => {
@@ -225,12 +225,12 @@ describe('chat-flow derivation', () => {
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
})
it('assistantActionsSeqs keeps only the last content assistant per turn', () => {
it('assistantActionsSeqs keeps only the last content assistant per completed turn', () => {
const thinkOnly: AssistantMessageNode = {
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'planning' }],
}
const seqs = assistantActionsSeqs([
const nodes: ConversationNode[] = [
user(1, 'hi'),
assistant(2, 'looking', 1),
thinkOnly,
@@ -238,8 +238,11 @@ describe('chat-flow derivation', () => {
assistant(5, 'done', 1),
user(6, 'again'),
assistant(7, 'second turn', 2),
])
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
]
expect([...assistantActionsSeqs(nodes, new Map([[1, 5], [2, 7]]))].sort((a, b) => a - b)).toEqual([5, 7])
// Turn 2 is still producing steps: its latest narration owns nothing, and
// the settled turn 1 keeps its seat.
expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5])
})
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
@@ -261,7 +264,7 @@ describe('chat-flow derivation', () => {
expect(formatRunDuration(125_000, t)).toBe('2分05秒')
})
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
it('assistantBranchSeqs keeps only content-assistant tails; user/steering tails own no branch', () => {
const interruptedThink: AssistantMessageNode = {
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
@@ -276,8 +279,8 @@ describe('chat-flow derivation', () => {
user(10, 'user-only tail'),
user(13, 'steering tail'),
]
const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
expect([...seqs]).toEqual([7, 10, 13])
const seqs = assistantBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
expect([...seqs]).toEqual([7])
})
})
@@ -401,21 +404,24 @@ describe('ChatView', () => {
expect(view.getAllByText('interrupt now')).toHaveLength(1)
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
expect(view.getAllByText('插话')).toHaveLength(1)
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
// Only the durable steering bubble: the turn is still running, so its
// assistant narration owns no footer yet, and a steering bubble never
// carries a branch action.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(1)
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' })
expect(unavailable.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(unavailable)
expect(h.forkAt).not.toHaveBeenCalled()
expect(within(durableBubble).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
act(() => {
h.set({ running: false, turnEnds: new Map([[1, 3]]) })
})
// The completed turn's transcript tail is the steering bubble, not the
// narration, so the assistant's branch action stays unavailable and the
// steering bubble still offers none.
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(branchButtons).toHaveLength(2)
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
fireEvent.click(branchButtons[1]!)
expect(h.forkAt).toHaveBeenCalledWith(2)
expect(branchButtons).toHaveLength(1)
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(branchButtons[0]!)
expect(h.forkAt).not.toHaveBeenCalled()
})
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
@@ -518,11 +524,35 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 4], [2, 6]]),
})
const view = render(<h.ChatView {...h.props} />)
// Every message footer keeps branch visible; only completed assistant tails enable it.
// Branch renders only under assistant answers; user bubbles keep copy alone.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(branchButtons).toHaveLength(4)
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null])
expect(branchButtons).toHaveLength(2)
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null])
})
it('withholds assistant IconActions while the turn is still running', () => {
const h = makeHarness({
running: true,
runningCalls: [runningCall('a')],
nodes: [
user(1, 'first'),
assistant(2, 'previous answer', 1),
user(4, 'second'),
assistant(5, 'mid-turn text', 2),
],
// Boundary seqs follow the log: a turn/end is strictly after its own nodes.
turnEnds: new Map([[1, 3]]),
})
const view = render(<h.ChatView {...h.props} />)
// 2 user + the settled turn-1 tail, which keeps its seat while a later
// turn runs; turn 2's narration stays chrome-free while its tool runs, so
// the footer never appears and then moves.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3)
expect(view.getByText('mid-turn text')).toBeTruthy()
// turn/end lands: the same node becomes the settled answer and takes the seat.
act(() => { h.set({ running: false, runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) }) })
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
})
it('the actions-owning assistant footer shows the turn run time', () => {
@@ -606,11 +636,11 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 3]]),
})
const view = render(<h.ChatView {...h.props} />)
// The user bubble offers no branch; the settled answer's is live.
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
expect(buttons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
expect(buttons).toHaveLength(1)
expect(buttons[0]!.getAttribute('aria-disabled')).toBeNull()
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt.mock.calls).toEqual([[2]])
})
@@ -626,10 +656,9 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
expect(buttons.every(button => button.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(buttons).toHaveLength(1)
expect(buttons[0]!.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt).not.toHaveBeenCalled()
})