chat-view.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. // @vitest-environment jsdom
  2. // ChatView behavior: flow derivation, streaming isolation (Profiler counts),
  3. // toolview dispatch and selection handoff — driven through a scripted
  4. // ObservableSnapshot fake, no wire.
  5. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  6. import { Profiler } from 'react'
  7. import { act, cleanup, fireEvent, render } from '@testing-library/react'
  8. import type {
  9. AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
  10. } from '@deepseek-ai/dsh-client-runtime/client'
  11. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  12. import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
  13. import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
  14. import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
  15. import { createChatStore } from '../src/client/stores.ts'
  16. import { ChatView } from '../src/client/chat/ChatView.tsx'
  17. import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
  18. afterEach(cleanup)
  19. // Keyless create() persists under the bare declared key; clear between cases
  20. // so one harness's selection cannot rehydrate into the next.
  21. beforeEach(() => {
  22. localStorage.clear()
  23. })
  24. const SID = 's1' as SessionId
  25. function snapshotBase(): ConversationSnapshot {
  26. return {
  27. sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
  28. pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
  29. hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
  30. }
  31. }
  32. /** Scripted snapshot source: set() swaps the top-level object like the real Session. */
  33. function makeSource(init?: Partial<ConversationSnapshot>) {
  34. let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
  35. const subs = new Set<() => void>()
  36. return {
  37. set(next: Partial<ConversationSnapshot>) {
  38. snap = { ...snap, ...next }
  39. for (const fn of [...subs]) fn()
  40. },
  41. source: {
  42. getSnapshot: () => snap,
  43. subscribe: (fn: () => void) => {
  44. subs.add(fn)
  45. return () => subs.delete(fn)
  46. },
  47. },
  48. }
  49. }
  50. const user = (seq: number, text: string): UserMessageNode => ({
  51. kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null,
  52. })
  53. const assistant = (seq: number, text: string): AssistantMessageNode => ({
  54. kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
  55. })
  56. const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
  57. kind: 'tool-result', seq, time: seq * 1_000, callId,
  58. call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
  59. callTime: seq * 1_000 - 500,
  60. content: [], isError: false, callView: null, resultView: null,
  61. })
  62. const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
  63. callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
  64. })
  65. /** Empty sessions-list hook for the global standard-kit seat. */
  66. function emptySessions() {
  67. const store = createSnapshotStore<SessionListState>(
  68. { ids: [], byId: {}, current: undefined, phase: 'ready' })
  69. return bindSnapshotSelector(store)
  70. }
  71. function emptyWorkspaces() {
  72. const store = createSnapshotStore<WorkspaceListState>({
  73. items: [], state: 'idle', phase: 'ready', error: null,
  74. baselinesReady: true, recentWorkspaceId: undefined,
  75. })
  76. return bindSnapshotSelector(store)
  77. }
  78. function makeHarness(init?: Partial<ConversationSnapshot>) {
  79. const { set, source } = makeSource(init)
  80. const openDetails = vi.fn<(t: SelectionTarget) => void>()
  81. const loadOlder = vi.fn()
  82. // Selection rides the REAL chat store (same construction path as
  83. // production; the view reads it through the PropsStore useStore share).
  84. // renderSlot stub renders the render-site fallback (an empty keyed ledger:
  85. // every tool lands on GenericToolCard); keyed dispatch to registered rows
  86. // is the slot machinery's behavior, covered by its own specs.
  87. const chat = createChatStore().create()
  88. const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
  89. opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
  90. // SessionProvider seat arrives with the session-scope child declaration;
  91. // ChatView never invokes it (render-prop pass-through stub).
  92. const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
  93. const props: ChatViewSlotProps = {
  94. sessionId: SID,
  95. useSession: bindSnapshotSelector(source),
  96. useSessions: emptySessions(),
  97. useWorkspaces: emptyWorkspaces(),
  98. useInput: (() => { throw new Error('unused') }) as never,
  99. inputActions: { setDraft: () => {}, submit: () => {} } as never,
  100. useStore: bindSnapshotSelector(chat),
  101. actions: chat.actions,
  102. renderSlot,
  103. SessionProvider: SessionProviderStub,
  104. openDetails,
  105. loadOlder,
  106. }
  107. const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
  108. return { set, ChatView, props, openDetails, loadOlder, setSelection }
  109. }
  110. describe('chat-flow derivation', () => {
  111. it('groups consecutive tool results and keeps stable keys', () => {
  112. const nodes: ConversationNode[] = [
  113. user(1, 'hi'), assistant(2, 'let me look'), toolResult(3, 'a'), toolResult(4, 'b'),
  114. assistant(5, 'found'), toolResult(6, 'c'),
  115. ]
  116. const items = deriveChatFlow(nodes)
  117. expect(items.map((i) => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
  118. const group = items[2]!
  119. expect(group.kind === 'tool-group' && group.results.map((r) => r.callId)).toEqual(['a', 'b'])
  120. expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
  121. expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
  122. })
  123. })
  124. describe('ChatView', () => {
  125. it('a windowless tool result (call head truncated) renders with an empty tool name', () => {
  126. const h = makeHarness({
  127. nodes: [{ ...toolResult(3, 'w1'), call: null }],
  128. })
  129. const view = render(<h.ChatView {...h.props} />)
  130. // classifyTool('') → others; the summary slot falls back to the callId.
  131. expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
  132. expect(view.getByText('w1')).toBeTruthy()
  133. })
  134. it('prepend keeps the viewport anchored when the reader is NOT at the bottom (no lastKey force)', () => {
  135. // Covers the prepend early-return arm where lastItem exists but the key
  136. // path is not taken (anchor branch wins before the appended-user check).
  137. const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
  138. const view = render(<h.ChatView {...h.props} />)
  139. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  140. Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
  141. Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
  142. scroller.scrollTop = 50
  143. fireEvent.scroll(scroller)
  144. fireEvent.click(view.getByText('加载更早'))
  145. Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
  146. act(() => h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }))
  147. expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
  148. })
  149. it('renders the fixture main line: bubble, narration, grouped tool rows', () => {
  150. const h = makeHarness({
  151. nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')],
  152. })
  153. const view = render(<h.ChatView {...h.props} />)
  154. expect(view.getByText('do the thing')).toBeTruthy()
  155. expect(view.getByText('running tools')).toBeTruthy()
  156. expect(view.getAllByText('Bash')).toHaveLength(2)
  157. expect(view.getByText('run a')).toBeTruthy()
  158. })
  159. it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
  160. const markdown = '# Rendered\n\n- **one**\n- `two`'
  161. const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
  162. const view = render(<h.ChatView {...h.props} />)
  163. expect(view.container.querySelectorAll('h1')).toHaveLength(1)
  164. const literal = view.getByText((_content, element) => (
  165. element?.tagName === 'DIV' && element.childElementCount === 0 && element.textContent === markdown
  166. ))
  167. expect(literal.querySelector('h1')).toBeNull()
  168. act(() => {
  169. h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } })
  170. })
  171. expect(view.container.querySelectorAll('h1')).toHaveLength(2)
  172. expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered')
  173. act(() => {
  174. h.set({
  175. nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)],
  176. partial: null,
  177. })
  178. })
  179. expect(view.container.querySelectorAll('h1')).toHaveLength(2)
  180. expect(view.container.querySelector('[data-streaming="true"]')).toBeNull()
  181. act(() => {
  182. h.set({
  183. nodes: [
  184. user(1, markdown),
  185. assistant(2, markdown),
  186. { ...assistant(3, markdown), interrupted: true },
  187. ],
  188. })
  189. })
  190. expect(view.getByText('已停止')).toBeTruthy()
  191. expect(view.container.querySelectorAll('h1')).toHaveLength(2)
  192. })
  193. it('streaming partial frames re-render only the tail (Profiler count)', () => {
  194. const h = makeHarness({
  195. nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
  196. })
  197. let renders = 0
  198. const counting = (
  199. <Profiler id="chat" onRender={() => { renders += 1 }}>
  200. <h.ChatView {...h.props} />
  201. </Profiler>
  202. )
  203. const view = render(counting)
  204. const before = renders
  205. const beforeHtml = view.container.querySelector('[class*="toolGroup"]')!.innerHTML
  206. act(() => {
  207. h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } })
  208. })
  209. act(() => {
  210. h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } })
  211. })
  212. expect(view.getByText('streaming… more')).toBeTruthy()
  213. // Each chunk commits exactly one profiler pass (the tail), never a full-tree storm.
  214. expect(renders - before).toBe(2)
  215. expect(view.container.querySelector('[class*="toolGroup"]')!.innerHTML).toBe(beforeHtml)
  216. })
  217. it('streaming leaves neighbor tool rows and history items at zero re-renders', () => {
  218. const h = makeHarness({
  219. nodes: [user(1, 'q'), assistant(2, 'old'), toolResult(3, 'a')],
  220. })
  221. // Count renderSlot invocations: the memo boundary holds when CallRow does
  222. // not re-render, so the row's renderSlot call count freezes during chunks.
  223. let rowRenders = 0
  224. h.props.renderSlot = (((_key: string, _owner: object) => {
  225. rowRenders += 1
  226. return <div data-testid="counting-row" />
  227. }) as unknown as ChatViewSlotProps['renderSlot'])
  228. const view = render(<h.ChatView {...h.props} />)
  229. expect(view.getByTestId('counting-row')).toBeTruthy()
  230. const afterMount = rowRenders
  231. act(() => {
  232. h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } })
  233. })
  234. act(() => {
  235. h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } })
  236. })
  237. expect(rowRenders).toBe(afterMount)
  238. })
  239. it('tool row expands to the args body via the leading slot toggle', () => {
  240. const h = makeHarness({ nodes: [toolResult(3, 'a')] })
  241. const view = render(<h.ChatView {...h.props} />)
  242. expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
  243. fireEvent.click(view.container.querySelector('button[aria-expanded]')!)
  244. expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
  245. })
  246. it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => {
  247. const h = makeHarness({ nodes: [toolResult(3, 'a')] })
  248. const view = render(<h.ChatView {...h.props} />)
  249. fireEvent.click(view.getByText('run a'))
  250. expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
  251. expect(view.container.querySelector('[data-selected]')).toBeNull()
  252. act(() => h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }))
  253. expect(view.container.querySelector('[data-selected]')).not.toBeNull()
  254. })
  255. it('running calls render as a live tool group with the running state', () => {
  256. const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
  257. const view = render(<h.ChatView {...h.props} />)
  258. expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
  259. expect(view.getByText('cmd-r1')).toBeTruthy()
  260. })
  261. it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
  262. const h = makeHarness({ nodes: [toolResult(3, 'a')] })
  263. const calls: { key: string; entryKey?: string }[] = []
  264. h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
  265. calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
  266. return opts?.fallback ?? null
  267. }) as unknown as ChatViewSlotProps['renderSlot'])
  268. render(<h.ChatView {...h.props} />)
  269. // Keyed dispatch: slot name is the declared hole, entryKey the wire tool
  270. // name, and the fallback (GenericToolCard) renders on an empty ledger.
  271. // (Registered-row takeover and live unload are slot machinery behavior,
  272. // owned by the slot system's own specs.)
  273. expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
  274. })
  275. it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {
  276. const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true })
  277. const view = render(<h.ChatView {...h.props} />)
  278. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  279. // jsdom has no layout: fake the metrics the anchor math reads.
  280. Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
  281. Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
  282. // Arm the paging anchor, then deliver an older page (head seq decreases).
  283. fireEvent.click(view.getByText('加载更早'))
  284. Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
  285. act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }))
  286. expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
  287. // A new trailing user bubble (own words) force-scrolls to the bottom.
  288. act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }))
  289. expect(scroller.scrollTop).toBe(1600)
  290. })
  291. it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => {
  292. const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
  293. const view = render(<h.ChatView {...h.props} />)
  294. const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
  295. Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
  296. Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
  297. scroller.scrollTop = 100 // far from bottom
  298. fireEvent.scroll(scroller)
  299. const backButton = view.getByLabelText('回到底部')
  300. expect(backButton).toBeTruthy()
  301. // Streaming growth must NOT drag a scrolled-away reader down.
  302. act(() => h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }))
  303. expect(scroller.scrollTop).toBe(100)
  304. fireEvent.click(backButton)
  305. expect(scroller.scrollTop).toBe(1000)
  306. // At the bottom again: follow re-arms and the button unmounts.
  307. expect(view.queryByLabelText('回到底部')).toBeNull()
  308. })
  309. it('paging button loads older and shows its busy label', () => {
  310. const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
  311. const view = render(<h.ChatView {...h.props} />)
  312. fireEvent.click(view.getByText('加载更早'))
  313. expect(h.loadOlder).toHaveBeenCalledTimes(1)
  314. act(() => h.set({ loadingOlder: true }))
  315. expect(view.getByText('加载中…')).toBeTruthy()
  316. })
  317. it('shows open error and loading states', () => {
  318. const h = makeHarness({
  319. openState: 'error',
  320. openError: { code: 'internal', message: 'boom' } as never,
  321. })
  322. const view = render(<h.ChatView {...h.props} />)
  323. expect(view.getByText(/历史加载失败:boom/)).toBeTruthy()
  324. const loading = makeHarness({ openState: 'loading' })
  325. const lv = render(<loading.ChatView {...loading.props} />)
  326. expect(lv.getByText('载入历史…')).toBeTruthy()
  327. })
  328. it('pending interactions render placeholder cards', () => {
  329. const h = makeHarness({
  330. pending: [new PendingWait('approval', RpcId('r1'), SID,
  331. { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())],
  332. })
  333. const view = render(<h.ChatView {...h.props} />)
  334. expect(view.getByText(/等待审批/)).toBeTruthy()
  335. })
  336. })