Merge remote-tracking branch 'origin/master' into feat/web-workspace-file-links

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/ToolRow.module.css
#	packages/host/apiproxy/src/native-path-opener.ts
This commit is contained in:
imccyu
2026-08-07 16:49:20 +08:00
604 changed files with 21472 additions and 2659 deletions

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(() => {
@@ -158,9 +158,9 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
}
/** Simulate reader input before the browser delivers the host scroll event. */
/** Simulate reader input (any device): a delivered position that deviates
* from the observed-top ledger of programmatic writes. */
function readerScroll(element: HTMLElement, top: number): void {
fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 })
element.scrollTop = top
fireEvent.scroll(element)
}
@@ -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()
})
@@ -912,7 +941,7 @@ describe('ChatView', () => {
expect(view.queryByLabelText('回到底部')).toBeNull()
})
it('keeps following when a delayed clamp scroll arrives after layout regrows', () => {
it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
@@ -920,12 +949,12 @@ describe('ChatView', () => {
scroller.scrollTop = 700
fireEvent.scroll(scroller)
// The wheel cannot move farther down. A stream-finalization shrink clamps
// the old position, then reflow grows the layout before scroll delivery.
fireEvent.wheel(scroller, { deltaY: 120 })
metrics.setLayout(1_040, 500)
// Stream finalization shrinks the column: the browser clamps the pinned
// position onto the new floor and delivers a scroll event. The clamp
// lands exactly on the ledger's floor min, so it is not reader input.
metrics.setLayout(800, 700)
fireEvent.scroll(scroller)
expect(scroller.scrollTop).toBe(740)
expect(scroller.scrollTop).toBe(500)
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(h.chatScroll.read()).toBeNull()
@@ -934,7 +963,7 @@ describe('ChatView', () => {
expect(scroller.scrollTop).toBe(900)
})
it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => {
it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
@@ -942,8 +971,10 @@ describe('ChatView', () => {
scroller.scrollTop = 700
fireEvent.scroll(scroller)
// Chromium advances compositor geometry before delivering the event:
// attribution must compare against the observed-top ledger, never a
// baseline sampled from already-moved raw geometry.
scroller.scrollTop = 500
fireEvent.wheel(scroller, { deltaY: -200 })
fireEvent.scroll(scroller)
expect(view.getByLabelText('回到底部')).toBeTruthy()
})