queue-dock.client.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. // @vitest-environment jsdom
  2. /**
  3. * QueueDock rendering and operations: authoritative rows, inline editing,
  4. * collapse state, removal, strict steering, failure notices, and live retirement.
  5. */
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
  8. import { useSyncExternalStore } from 'react'
  9. import type {
  10. QueuedMessage, SessionListState, SessionSnapshot,
  11. } from '@deepseek-ai/dsh-api-session-controller/client'
  12. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  13. import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
  14. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  15. import {
  16. bindSnapshotSelector, conversationSnapshot, makeTranslate,
  17. } from '@deepseek-ai/dsh-client-test-runtime'
  18. import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
  19. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  20. import type { QueueItemId } from '../src/client/contract/queue.ts'
  21. import type { InputState } from '../src/client/contract/input.ts'
  22. import { zh } from '../src/client/locales.ts'
  23. import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx'
  24. afterEach(cleanup)
  25. const SID = 's1' as SessionId
  26. const iid = (id: string): QueueItemId => id as QueueItemId
  27. function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
  28. return {
  29. id: iid(id), messageId: `message-${id}` as never, placement: 'queued',
  30. content: text === null ? [{ type: 'image', data: 'x' } as never] : [{ type: 'text', text }],
  31. preview, text,
  32. }
  33. }
  34. function snapshotWith(queue: QueuedMessage[]): SessionSnapshot {
  35. return {
  36. sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null,
  37. hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null,
  38. lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false,
  39. }
  40. }
  41. /** Minimal live source backing the useSession stub. */
  42. function liveSession(initial: SessionSnapshot) {
  43. let snapshot = initial
  44. const listeners = new Set<() => void>()
  45. const useSession: SnapshotSelectorHook<SessionSnapshot> = selector =>
  46. useSyncExternalStore(
  47. (listener) => {
  48. listeners.add(listener)
  49. return () => listeners.delete(listener)
  50. },
  51. () => selector(snapshot),
  52. )
  53. return {
  54. useSession,
  55. push(next: SessionSnapshot): void {
  56. snapshot = next
  57. for (const listener of [...listeners]) listener()
  58. },
  59. }
  60. }
  61. const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
  62. const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
  63. function kitFor(snapshot: SessionSnapshot, injected: Partial<QueueDockInjected> = {}) {
  64. return {
  65. sessionId: SID,
  66. t,
  67. useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
  68. useSessionPendingInteraction: bindSnapshotSelector(
  69. createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
  70. ),
  71. useWorkspaces: (() => { throw new Error('unused') }) as never,
  72. useProjection: (() => undefined) as never,
  73. useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())),
  74. useChat: (() => { throw new Error('unused') }) as QueueDockProps['useChat'],
  75. useTrajectory: (() => { throw new Error('unused') }) as QueueDockProps['useTrajectory'],
  76. useInput: (() => { throw new Error('unused') }) as never,
  77. inputActions: { setDraft: () => {}, submit: () => {} } as never,
  78. session: snapshot,
  79. input: INPUT_STATE,
  80. updateQueue: vi.fn(() => Promise.resolve()),
  81. notify: vi.fn(),
  82. ...injected,
  83. }
  84. }
  85. describe('QueueDock', () => {
  86. it('renders null while the queue is empty', () => {
  87. const snap = snapshotWith([])
  88. const source = liveSession(snap)
  89. const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
  90. expect(container.innerHTML).toBe('')
  91. })
  92. it('leaves pending steering to the conversation flow', () => {
  93. const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const }
  94. const snap = snapshotWith([steering])
  95. const source = liveSession(snap)
  96. const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
  97. expect(container.innerHTML).toBe('')
  98. })
  99. it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
  100. const single = snapshotWith([row('i-1', 'one')])
  101. const source = liveSession(single)
  102. const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
  103. expect(view.queryByRole('button', { name: '1 条排队消息' })).toBeNull()
  104. expect(view.getByText('one')).toBeTruthy()
  105. act(() => { source.push(snapshotWith([row('i-1', 'one'), row('i-2', 'two')])) })
  106. const header = view.getByRole('button', { name: '2 条排队消息' })
  107. expect(header.getAttribute('aria-expanded')).toBe('false')
  108. expect(document.getElementById(header.getAttribute('aria-controls')!)).toBeTruthy()
  109. expect(view.queryByText('one')).toBeNull()
  110. expect(view.queryByText('two')).toBeNull()
  111. fireEvent.click(header)
  112. expect(header.getAttribute('aria-expanded')).toBe('true')
  113. expect(view.getByText('one')).toBeTruthy()
  114. expect(view.getByText('two')).toBeTruthy()
  115. fireEvent.click(header)
  116. expect(header.getAttribute('aria-expanded')).toBe('false')
  117. expect(view.queryByText('one')).toBeNull()
  118. })
  119. it('keeps an active single-row editor visible when another item arrives', () => {
  120. const single = snapshotWith([row('i-edit', 'before')])
  121. const source = liveSession(single)
  122. const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
  123. fireEvent.click(view.getByLabelText('编辑排队消息'))
  124. fireEvent.change(view.getByLabelText('编辑排队消息'), { target: { value: 'draft' } })
  125. act(() => {
  126. source.push(snapshotWith([row('i-edit', 'before'), row('i-2', 'second')]))
  127. })
  128. const header = view.getByRole('button', { name: '2 条排队消息' })
  129. expect(header).toHaveProperty('disabled', true)
  130. expect(header.getAttribute('aria-expanded')).toBe('true')
  131. expect(view.getByRole('textbox', { name: '编辑排队消息' })).toHaveProperty('value', 'draft')
  132. expect(view.getByText('second')).toBeTruthy()
  133. fireEvent.click(view.getByLabelText('取消编辑'))
  134. expect(header).toHaveProperty('disabled', false)
  135. expect(header.getAttribute('aria-expanded')).toBe('false')
  136. expect(view.queryByText('second')).toBeNull()
  137. })
  138. it('keeps an in-flight row action visible when another item arrives', async () => {
  139. const single = snapshotWith([row('i-remove', 'remove me')])
  140. const source = liveSession(single)
  141. let finishUpdate: (() => void) | undefined
  142. const updateQueue = vi.fn(() => new Promise<void>((resolve) => { finishUpdate = resolve }))
  143. const view = render(
  144. <QueueDock {...kitFor(single, { updateQueue })} useSession={source.useSession} />,
  145. )
  146. fireEvent.click(view.getByLabelText('删除排队消息'))
  147. act(() => {
  148. source.push(snapshotWith([row('i-remove', 'remove me'), row('i-2', 'second')]))
  149. })
  150. const header = view.getByRole('button', { name: '2 条排队消息' })
  151. expect(header).toHaveProperty('disabled', true)
  152. expect(header.getAttribute('aria-expanded')).toBe('true')
  153. expect(view.getByText('remove me')).toBeTruthy()
  154. expect(view.getByText('second')).toBeTruthy()
  155. expect(updateQueue).toHaveBeenCalledOnce()
  156. await act(async () => {
  157. finishUpdate?.()
  158. await Promise.resolve()
  159. })
  160. await waitFor(() => {
  161. expect(header).toHaveProperty('disabled', false)
  162. expect(header.getAttribute('aria-expanded')).toBe('false')
  163. })
  164. })
  165. it('defaults a new multi-row queue to collapsed after the prior queue empties', () => {
  166. const first = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
  167. const source = liveSession(first)
  168. const view = render(<QueueDock {...kitFor(first)} useSession={source.useSession} />)
  169. fireEvent.click(view.getByRole('button', { name: '2 条排队消息' }))
  170. expect(view.getByText('one')).toBeTruthy()
  171. act(() => { source.push(snapshotWith([])) })
  172. expect(view.container.innerHTML).toBe('')
  173. act(() => {
  174. source.push(snapshotWith([row('i-3', 'three'), row('i-4', 'four')]))
  175. })
  176. const header = view.getByRole('button', { name: '2 条排队消息' })
  177. expect(header.getAttribute('aria-expanded')).toBe('false')
  178. expect(view.queryByText('three')).toBeNull()
  179. })
  180. it('renders active actions and disables editing for mixed-content rows', () => {
  181. const snap = snapshotWith([
  182. row('i-1', '第一条排队消息'),
  183. row('i-2', null, 'image [image]'),
  184. ])
  185. const source = liveSession(snap)
  186. const { container, getByRole } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
  187. fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
  188. expect([...container.querySelectorAll('li')].map(item => item.textContent))
  189. .toEqual(['第一条排队消息', 'image [image]'])
  190. expect(container.querySelectorAll('button')).toHaveLength(7)
  191. expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
  192. expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
  193. expect(container.querySelectorAll('[aria-label="插话发送"]')).toHaveLength(2)
  194. expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false)
  195. expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true)
  196. expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title'))
  197. .toBe('包含非文本内容,暂不支持编辑')
  198. })
  199. it('edits text inline with save and cancel controls, then saves with the same item identity', async () => {
  200. const snap = snapshotWith([row('i-edit', 'before')])
  201. const source = liveSession(snap)
  202. const updateQueue = vi.fn(() => Promise.resolve())
  203. const { getByLabelText, queryByLabelText } = render(
  204. <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
  205. )
  206. fireEvent.click(getByLabelText('编辑排队消息'))
  207. const editor = getByLabelText('编辑排队消息') as HTMLInputElement
  208. expect(getByLabelText('保存排队消息')).toBeTruthy()
  209. expect(getByLabelText('取消编辑')).toBeTruthy()
  210. expect(queryByLabelText('删除排队消息')).toBeNull()
  211. fireEvent.change(editor, { target: { value: 'after' } })
  212. fireEvent.keyDown(editor, { key: 'Enter' })
  213. await waitFor(() => {
  214. expect(updateQueue).toHaveBeenCalledWith(iid('i-edit'), {
  215. kind: 'edit',
  216. content: [{ type: 'text', text: 'after' }],
  217. })
  218. })
  219. })
  220. it('cancels an edit by button or Escape without mutating the queue', () => {
  221. const snap = snapshotWith([row('i-edit', 'before')])
  222. const source = liveSession(snap)
  223. const updateQueue = vi.fn(() => Promise.resolve())
  224. const { getByLabelText, getByText } = render(
  225. <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
  226. )
  227. fireEvent.click(getByLabelText('编辑排队消息'))
  228. fireEvent.change(getByLabelText('编辑排队消息'), { target: { value: 'abandoned' } })
  229. fireEvent.click(getByLabelText('取消编辑'))
  230. expect(getByText('before')).toBeTruthy()
  231. fireEvent.click(getByLabelText('编辑排队消息'))
  232. fireEvent.keyDown(getByLabelText('编辑排队消息'), { key: 'Escape' })
  233. expect(getByText('before')).toBeTruthy()
  234. expect(updateQueue).not.toHaveBeenCalled()
  235. })
  236. it('keeps editing during IME composition and disables a blank save', () => {
  237. const snap = snapshotWith([row('i-edit', 'before')])
  238. const source = liveSession(snap)
  239. const updateQueue = vi.fn(() => Promise.resolve())
  240. const { getByLabelText } = render(
  241. <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
  242. )
  243. fireEvent.click(getByLabelText('编辑排队消息'))
  244. const editor = getByLabelText('编辑排队消息')
  245. fireEvent.change(editor, { target: { value: ' ' } })
  246. expect(getByLabelText('保存排队消息')).toHaveProperty('disabled', true)
  247. fireEvent.change(editor, { target: { value: '输入中' } })
  248. fireEvent.keyDown(editor, { key: 'Enter', isComposing: true })
  249. expect(updateQueue).not.toHaveBeenCalled()
  250. expect(getByLabelText('编辑排队消息')).toBeTruthy()
  251. })
  252. it('removes the addressed row', async () => {
  253. const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
  254. const source = liveSession(snap)
  255. const updateQueue = vi.fn(() => Promise.resolve())
  256. const { getAllByLabelText, getByRole } = render(
  257. <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
  258. )
  259. fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
  260. fireEvent.click(getAllByLabelText('删除排队消息')[0]!)
  261. await waitFor(() => {
  262. expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })
  263. })
  264. })
  265. it('strictly steers complete row content only while the agent is running', async () => {
  266. const running = snapshotWith([row('i-steer', null, 'image [image]')])
  267. const source = liveSession(running)
  268. const updateQueue = vi.fn(() => Promise.resolve())
  269. const rendered = render(
  270. <QueueDock {...kitFor(running, { updateQueue })} useSession={source.useSession} />,
  271. )
  272. const button = rendered.getByLabelText('插话发送')
  273. expect(button).toHaveProperty('disabled', false)
  274. fireEvent.click(button)
  275. await waitFor(() => {
  276. expect(updateQueue).toHaveBeenCalledWith(iid('i-steer'), { kind: 'steer' })
  277. })
  278. act(() => { source.push({ ...running, running: false }) })
  279. expect(rendered.getByLabelText('插话发送')).toHaveProperty('disabled', true)
  280. expect(rendered.getByLabelText('插话发送').getAttribute('title')).toBe('仅运行中可插话发送')
  281. })
  282. it('renders a session-backed subagent Queue without unsupported actions', () => {
  283. const snap = {
  284. ...snapshotWith([row('i-subagent', 'pending child follow-up')]),
  285. subagent: {
  286. address: {
  287. parentSessionId: 'parent' as SessionId,
  288. childSessionId: SID,
  289. mode: 'continuable' as const,
  290. },
  291. parentAvailable: true,
  292. },
  293. }
  294. const source = liveSession(snap)
  295. const view = render(
  296. <QueueDock {...kitFor(snap)} useSession={source.useSession} />,
  297. )
  298. expect(view.getByText('pending child follow-up')).toBeTruthy()
  299. expect(view.queryByLabelText('编辑排队消息')).toBeNull()
  300. expect(view.queryByLabelText('删除排队消息')).toBeNull()
  301. expect(view.queryByLabelText('插话发送')).toBeNull()
  302. })
  303. it('keeps the row and reports a genuine steer failure', async () => {
  304. const snap = snapshotWith([row('i-steer-race', 'pending steer')])
  305. const source = liveSession(snap)
  306. const notify = vi.fn()
  307. const updateQueue = vi.fn(() => Promise.reject(new Error('transport failed')))
  308. const { getByLabelText, getByText } = render(
  309. <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
  310. )
  311. fireEvent.click(getByLabelText('插话发送'))
  312. await waitFor(() => {
  313. expect(notify).toHaveBeenCalledWith(
  314. 'error',
  315. '插话发送失败,请重试。',
  316. )
  317. })
  318. expect(getByText('pending steer')).toBeTruthy()
  319. })
  320. it('keeps the row and surfaces a notice when an operation loses the claim race', async () => {
  321. const snap = snapshotWith([row('i-race', 'pending')])
  322. const source = liveSession(snap)
  323. const notify = vi.fn()
  324. const updateQueue = vi.fn(() => Promise.reject(new Error('not found')))
  325. const { getByLabelText, getByText } = render(
  326. <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
  327. )
  328. fireEvent.click(getByLabelText('删除排队消息'))
  329. await waitFor(() => {
  330. expect(notify).toHaveBeenCalledWith('error', '删除失败:这条消息可能已经开始发送。')
  331. })
  332. expect(getByText('pending')).toBeTruthy()
  333. })
  334. it('follows authoritative retirement back to null', () => {
  335. const snap = snapshotWith([row('i-1', '在场')])
  336. const source = liveSession(snap)
  337. const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
  338. expect(container.textContent).toContain('在场')
  339. act(() => { source.push(snapshotWith([])) })
  340. expect(container.innerHTML).toBe('')
  341. })
  342. it('registers as the terminal composer-context entry', () => {
  343. expect(queueDockEntry.name).toBe('conversation-queue-dock')
  344. expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
  345. const register = vi.fn(() => () => undefined)
  346. const inject = vi.fn((_name: string, callback: () => () => void) => callback())
  347. queueDockEntry.apply({ slots: { inject, register } } as never)
  348. expect(inject).toHaveBeenCalledWith('conversation.input.dock', expect.any(Function))
  349. expect(register).toHaveBeenCalledWith(
  350. expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
  351. QueueDock,
  352. )
  353. })
  354. })