queue-dock.client.spec.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  1. // @vitest-environment jsdom
  2. /**
  3. * QueueDock rendering and operations: authoritative rows, inline editing,
  4. * collapse state, removal, QueueDock Steer, failure notices, and live retirement.
  5. */
  6. import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
  7. import { afterEach, describe, expect, it, vi } from 'vitest'
  8. import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
  9. import { useSyncExternalStore } from 'react'
  10. import type {
  11. QueuedMessage, SessionListState, SessionSnapshot,
  12. } from '@deepseek-ai/dsh-api-session-controller/client'
  13. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  14. import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
  15. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  16. import {
  17. bindSnapshotSelector, conversationSnapshot, makeTranslate,
  18. } from '@deepseek-ai/dsh-client-test-runtime'
  19. import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
  20. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  21. import type { QueueItemId } from '../src/client/contract/queue.ts'
  22. import type { InputState } from '../src/client/contract/input.ts'
  23. import { zh } from '../src/client/locales.ts'
  24. import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx'
  25. // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
  26. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
  27. afterEach(cleanup)
  28. const SID = 's1' as SessionId
  29. const iid = (id: string): QueueItemId => id as QueueItemId
  30. function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
  31. return {
  32. id: iid(id), messageId: `message-${id}` as never, placement: 'queued',
  33. content: text === null ? [{ type: 'image', data: 'x' } as never] : [{ type: 'text', text }],
  34. preview, text,
  35. }
  36. }
  37. function snapshotWith(queue: QueuedMessage[]): SessionSnapshot {
  38. return {
  39. sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null,
  40. hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null,
  41. pendingSubmissions: [],
  42. lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false,
  43. }
  44. }
  45. /** Minimal live source backing the useSession stub. */
  46. function liveSession(initial: SessionSnapshot) {
  47. let snapshot = initial
  48. const listeners = new Set<() => void>()
  49. const useSession: SnapshotSelectorHook<SessionSnapshot> = selector =>
  50. useSyncExternalStore(
  51. (listener) => {
  52. listeners.add(listener)
  53. return () => listeners.delete(listener)
  54. },
  55. () => selector(snapshot),
  56. )
  57. return {
  58. useSession,
  59. push(next: SessionSnapshot): void {
  60. snapshot = next
  61. for (const listener of [...listeners]) listener()
  62. },
  63. }
  64. }
  65. const INPUT_STATE: InputState = { draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
  66. const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
  67. const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
  68. function kitFor(snapshot: SessionSnapshot, injected: Partial<QueueDockInjected> = {}) {
  69. return {
  70. sessionId: SID,
  71. t,
  72. usePanelInfo,
  73. useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
  74. useResource,
  75. useSessionPendingInteraction: bindSnapshotSelector(
  76. createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
  77. ),
  78. useWorkspaces: (() => { throw new Error('unused') }) as never,
  79. useProjection: (() => undefined) as never,
  80. useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())),
  81. useChat: (() => { throw new Error('unused') }) as QueueDockProps['useChat'],
  82. useTrajectory: (() => { throw new Error('unused') }) as QueueDockProps['useTrajectory'],
  83. useInput: (() => { throw new Error('unused') }) as never,
  84. inputActions: { setDraft: () => {}, submit: () => {} } as never,
  85. session: snapshot,
  86. input: INPUT_STATE,
  87. updateQueue: vi.fn(() => Promise.resolve()),
  88. notify: vi.fn(),
  89. loadImage: vi.fn(() => Promise.resolve('blob:unused')),
  90. ...injected,
  91. }
  92. }
  93. /** One queued row carrying a durable image reference (plus optional leading text). */
  94. function imageRow(id: string, refId: string, text = ''): QueuedMessage {
  95. return {
  96. id: iid(id), messageId: `message-${id}` as never, placement: 'queued',
  97. content: [
  98. ...text === '' ? [] : [{ type: 'text' as const, text }],
  99. {
  100. type: 'image',
  101. attachment: { attachmentId: refId, mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
  102. } as never,
  103. ],
  104. preview: text, text: null,
  105. }
  106. }
  107. describe('QueueDock', () => {
  108. it('renders null while the queue is empty', () => {
  109. const snap = snapshotWith([])
  110. const source = liveSession(snap)
  111. const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
  112. expect(container.innerHTML).toBe('')
  113. })
  114. it('renders a queued local echo in the dock and hands off by rpcId', () => {
  115. const pending = {
  116. ...snapshotWith([]),
  117. pendingSubmissions: [{
  118. requestId: 'req-local-queue' as never,
  119. placement: 'queued' as const,
  120. time: 1,
  121. text: '等待上传',
  122. attachments: [
  123. {
  124. type: 'image' as const,
  125. value: { previewUrl: 'blob:queue-preview', name: 'queue.png' },
  126. },
  127. {
  128. type: 'file' as const,
  129. value: {
  130. attachmentId: 'file-local' as never,
  131. name: 'notes.txt',
  132. bytes: 2447 * 1024 * 1024,
  133. },
  134. },
  135. ],
  136. }],
  137. }
  138. const source = liveSession(pending)
  139. const props = kitFor(pending)
  140. const view = render(<QueueDock {...props} useSession={source.useSession} />)
  141. expect(view.getByText('等待上传').closest('[data-submission-echo]')).not.toBeNull()
  142. expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:queue-preview')
  143. expect(view.getByLabelText('排队文件 notes.txt').textContent).toContain('2.4GB')
  144. expect(view.getByRole('status').textContent).toBe('发送中…')
  145. for (const name of ['编辑排队消息', '删除排队消息', '插话发送']) {
  146. const button = view.getByRole('button', { name }) as HTMLButtonElement
  147. expect(button.disabled).toBe(true)
  148. fireEvent.click(button)
  149. }
  150. expect(props.updateQueue).not.toHaveBeenCalled()
  151. expect(view.queryByRole('textbox')).toBeNull()
  152. act(() => {
  153. source.push({
  154. ...pending,
  155. queue: [{ ...row('accepted', '等待上传'), rpcId: 'req-local-queue' as never }],
  156. })
  157. })
  158. expect(view.getAllByText('等待上传')).toHaveLength(1)
  159. expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
  160. expect(view.queryByRole('status')).toBeNull()
  161. for (const name of ['编辑排队消息', '删除排队消息', '插话发送']) {
  162. expect((view.getByRole('button', { name }) as HTMLButtonElement).disabled).toBe(false)
  163. }
  164. fireEvent.click(view.getByRole('button', { name: '编辑排队消息' }))
  165. expect((view.getByRole('textbox') as HTMLInputElement).value).toBe('等待上传')
  166. })
  167. it('loads the durable thumbnail after replacing a local image echo', async () => {
  168. const pending: SessionSnapshot = {
  169. ...snapshotWith([]),
  170. pendingSubmissions: [{
  171. requestId: 'req-image' as never, placement: 'queued', time: 1,
  172. text: 'queued image',
  173. attachments: [{
  174. type: 'image', value: { previewUrl: 'blob:local-preview', name: 'queue.png' },
  175. }],
  176. }],
  177. }
  178. const image = Promise.withResolvers<string>()
  179. const loadImage = vi.fn(() => image.promise)
  180. const source = liveSession(pending)
  181. const view = render(<QueueDock {...kitFor(pending, { loadImage })} useSession={source.useSession} />)
  182. expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:local-preview')
  183. expect(loadImage).not.toHaveBeenCalled()
  184. act(() => {
  185. source.push({
  186. ...pending,
  187. queue: [{ ...imageRow('accepted-image', 'durable-image', 'queued image'), rpcId: 'req-image' as never }],
  188. })
  189. })
  190. expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
  191. expect(view.getByText('queued image')).toBeTruthy()
  192. expect(view.getByRole('button', { name: '删除排队消息' })).toHaveProperty('disabled', false)
  193. expect(view.queryByRole('img', { name: '排队消息图片' })).toBeNull()
  194. expect(loadImage).toHaveBeenCalledOnce()
  195. await act(async () => { image.resolve('blob:durable-image'); await image.promise })
  196. const thumbnail = view.getByRole('img', { name: '排队消息图片' })
  197. expect(thumbnail.getAttribute('src')).toBe('blob:durable-image')
  198. expect(thumbnail.closest('li')?.hasAttribute('data-submission-echo')).toBe(false)
  199. })
  200. it('keeps sending status visible while a queue containing local submissions is collapsed', () => {
  201. const pending: SessionSnapshot = {
  202. ...snapshotWith([row('accepted', '已排队')]),
  203. pendingSubmissions: [{
  204. requestId: 'req-waiting' as never, placement: 'queued', time: 1,
  205. text: '等待发送', attachments: [],
  206. }],
  207. }
  208. const source = liveSession(pending)
  209. const view = render(<QueueDock {...kitFor(pending)} useSession={source.useSession} />)
  210. expect(view.getByRole('status').textContent).toBe('发送中…')
  211. const header = view.getByRole('button', { name: /2 条排队消息\s*发送中…/ })
  212. expect(header.getAttribute('aria-expanded')).toBe('false')
  213. fireEvent.click(header)
  214. expect(view.getAllByRole('status')).toHaveLength(1)
  215. expect(view.getByRole('status').closest('[data-submission-echo]')).not.toBeNull()
  216. act(() => { source.push(snapshotWith([row('accepted', '已排队')])) })
  217. expect(view.queryByRole('status')).toBeNull()
  218. })
  219. it('leaves pending steering to the conversation flow', () => {
  220. const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const }
  221. const snap = snapshotWith([steering])
  222. const source = liveSession(snap)
  223. const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
  224. expect(container.innerHTML).toBe('')
  225. })
  226. it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
  227. const single = snapshotWith([row('i-1', 'one')])
  228. const source = liveSession(single)
  229. const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
  230. expect(view.queryByRole('button', { name: '1 条排队消息' })).toBeNull()
  231. expect(view.getByText('one')).toBeTruthy()
  232. act(() => { source.push(snapshotWith([row('i-1', 'one'), row('i-2', 'two')])) })
  233. const header = view.getByRole('button', { name: '2 条排队消息' })
  234. expect(header.getAttribute('aria-expanded')).toBe('false')
  235. expect(document.getElementById(header.getAttribute('aria-controls')!)).toBeTruthy()
  236. expect(view.queryByText('one')).toBeNull()
  237. expect(view.queryByText('two')).toBeNull()
  238. fireEvent.click(header)
  239. expect(header.getAttribute('aria-expanded')).toBe('true')
  240. expect(view.getByText('one')).toBeTruthy()
  241. expect(view.getByText('two')).toBeTruthy()
  242. fireEvent.click(header)
  243. expect(header.getAttribute('aria-expanded')).toBe('false')
  244. expect(view.queryByText('one')).toBeNull()
  245. })
  246. it('keeps an active single-row editor visible when another item arrives', () => {
  247. const single = snapshotWith([row('i-edit', 'before')])
  248. const source = liveSession(single)
  249. const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
  250. fireEvent.click(view.getByLabelText('编辑排队消息'))
  251. fireEvent.change(view.getByLabelText('编辑排队消息'), { target: { value: 'draft' } })
  252. act(() => {
  253. source.push(snapshotWith([row('i-edit', 'before'), row('i-2', 'second')]))
  254. })
  255. const header = view.getByRole('button', { name: '2 条排队消息' })
  256. expect(header).toHaveProperty('disabled', true)
  257. expect(header.getAttribute('aria-expanded')).toBe('true')
  258. expect(view.getByRole('textbox', { name: '编辑排队消息' })).toHaveProperty('value', 'draft')
  259. expect(view.getByText('second')).toBeTruthy()
  260. fireEvent.click(view.getByLabelText('取消编辑'))
  261. expect(header).toHaveProperty('disabled', false)
  262. expect(header.getAttribute('aria-expanded')).toBe('false')
  263. expect(view.queryByText('second')).toBeNull()
  264. })
  265. it('keeps an in-flight row action visible when another item arrives', async () => {
  266. const single = snapshotWith([row('i-remove', 'remove me')])
  267. const source = liveSession(single)
  268. let finishUpdate: (() => void) | undefined
  269. const updateQueue = vi.fn(() => new Promise<void>((resolve) => { finishUpdate = resolve }))
  270. const view = render(
  271. <QueueDock {...kitFor(single, { updateQueue })} useSession={source.useSession} />,
  272. )
  273. fireEvent.click(view.getByLabelText('删除排队消息'))
  274. act(() => {
  275. source.push(snapshotWith([row('i-remove', 'remove me'), row('i-2', 'second')]))
  276. })
  277. const header = view.getByRole('button', { name: '2 条排队消息' })
  278. expect(header).toHaveProperty('disabled', true)
  279. expect(header.getAttribute('aria-expanded')).toBe('true')
  280. expect(view.getByText('remove me')).toBeTruthy()
  281. expect(view.getByText('second')).toBeTruthy()
  282. expect(updateQueue).toHaveBeenCalledOnce()
  283. await act(async () => {
  284. finishUpdate?.()
  285. await Promise.resolve()
  286. })
  287. await waitFor(() => {
  288. expect(header).toHaveProperty('disabled', false)
  289. expect(header.getAttribute('aria-expanded')).toBe('false')
  290. })
  291. })
  292. it('defaults a new multi-row queue to collapsed after the prior queue empties', () => {
  293. const first = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
  294. const source = liveSession(first)
  295. const view = render(<QueueDock {...kitFor(first)} useSession={source.useSession} />)
  296. fireEvent.click(view.getByRole('button', { name: '2 条排队消息' }))
  297. expect(view.getByText('one')).toBeTruthy()
  298. act(() => { source.push(snapshotWith([])) })
  299. expect(view.container.innerHTML).toBe('')
  300. act(() => {
  301. source.push(snapshotWith([row('i-3', 'three'), row('i-4', 'four')]))
  302. })
  303. const header = view.getByRole('button', { name: '2 条排队消息' })
  304. expect(header.getAttribute('aria-expanded')).toBe('false')
  305. expect(view.queryByText('three')).toBeNull()
  306. })
  307. it('renders active actions and disables editing for mixed-content rows', () => {
  308. const snap = snapshotWith([
  309. row('i-1', '第一条排队消息'),
  310. row('i-2', null, 'image [image]'),
  311. ])
  312. const source = liveSession(snap)
  313. const { container, getByRole } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
  314. fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
  315. expect([...container.querySelectorAll('li')].map(item => item.textContent))
  316. .toEqual(['第一条排队消息', 'image [image]'])
  317. expect(container.querySelectorAll('button')).toHaveLength(7)
  318. expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
  319. expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
  320. expect(container.querySelectorAll('[aria-label="插话发送"]')).toHaveLength(2)
  321. expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false)
  322. expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true)
  323. expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title'))
  324. .toBe('包含非文本内容,暂不支持编辑')
  325. })
  326. it('renders queued image thumbnails from durable references beside the text preview', async () => {
  327. const loadImage = vi.fn(() => Promise.resolve('blob:thumb-1'))
  328. const snap = snapshotWith([imageRow('i-img', 'att-9', '带图消息')])
  329. const source = liveSession(snap)
  330. const { container } = render(
  331. <QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} />,
  332. )
  333. await waitFor(() => {
  334. expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:thumb-1')
  335. })
  336. expect(loadImage).toHaveBeenCalledWith(expect.objectContaining({ attachmentId: 'att-9' }))
  337. expect(container.querySelector('img')?.getAttribute('alt')).toBe('排队消息图片')
  338. expect(container.querySelector('li')?.textContent).toBe('带图消息')
  339. })
  340. it('renders durable files and images in their original queue order', async () => {
  341. const loadImage = vi.fn(() => Promise.resolve('blob:mixed'))
  342. const mixed: QueuedMessage = {
  343. id: iid('i-mixed'), messageId: 'message-i-mixed' as never, placement: 'queued',
  344. content: [
  345. {
  346. type: 'file',
  347. attachment: { attachmentId: 'file-durable' as never, name: 'report.csv', bytes: 427 },
  348. },
  349. {
  350. type: 'image',
  351. attachment: {
  352. attachmentId: 'image-durable' as never,
  353. mediaType: 'image/png', bytes: 1, width: 1, height: 1,
  354. },
  355. },
  356. ],
  357. preview: '', text: null,
  358. }
  359. const snap = snapshotWith([mixed])
  360. const source = liveSession(snap)
  361. const view = render(<QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} />)
  362. await waitFor(() => { expect(view.container.querySelector('img')).not.toBeNull() })
  363. const group = view.getByLabelText('排队文件 report.csv').parentElement
  364. expect(group?.children).toHaveLength(2)
  365. expect(group?.children[0]?.getAttribute('aria-label')).toBe('排队文件 report.csv')
  366. expect(group?.children[1]?.tagName).toBe('IMG')
  367. })
  368. it('keeps the empty thumbnail placeholder when the image read fails', async () => {
  369. const loadImage = vi.fn(() => Promise.reject(new Error('read denied')))
  370. const snap = snapshotWith([imageRow('i-broken', 'att-x')])
  371. const source = liveSession(snap)
  372. const { container } = render(
  373. <QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} />,
  374. )
  375. await act(async () => { await Promise.resolve() })
  376. expect(loadImage).toHaveBeenCalled()
  377. expect(container.querySelector('img')).toBeNull()
  378. })
  379. it('ignores a thumbnail resolution landing after unmount', async () => {
  380. let resolveUrl: ((url: string) => void) | undefined
  381. const loadImage = vi.fn(() => new Promise<string>((resolve) => { resolveUrl = resolve }))
  382. const snap = snapshotWith([imageRow('i-late', 'att-late')])
  383. const source = liveSession(snap)
  384. const { unmount } = render(
  385. <QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} />,
  386. )
  387. unmount()
  388. await act(async () => {
  389. resolveUrl?.('blob:late')
  390. await Promise.resolve()
  391. })
  392. expect(loadImage).toHaveBeenCalledTimes(1)
  393. })
  394. it('edits text inline with save and cancel controls, then saves with the same item identity', async () => {
  395. const snap = snapshotWith([row('i-edit', 'before')])
  396. const source = liveSession(snap)
  397. const updateQueue = vi.fn(() => Promise.resolve())
  398. const { getByLabelText, queryByLabelText } = render(
  399. <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
  400. )
  401. fireEvent.click(getByLabelText('编辑排队消息'))
  402. const editor = getByLabelText('编辑排队消息') as HTMLInputElement
  403. expect(getByLabelText('保存排队消息')).toBeTruthy()
  404. expect(getByLabelText('取消编辑')).toBeTruthy()
  405. expect(queryByLabelText('删除排队消息')).toBeNull()
  406. fireEvent.change(editor, { target: { value: 'after' } })
  407. fireEvent.keyDown(editor, { key: 'Enter' })
  408. await waitFor(() => {
  409. expect(updateQueue).toHaveBeenCalledWith(iid('i-edit'), {
  410. kind: 'edit',
  411. content: [{ type: 'text', text: 'after' }],
  412. })
  413. })
  414. })
  415. it('cancels an edit by button or Escape without mutating the queue', () => {
  416. const snap = snapshotWith([row('i-edit', 'before')])
  417. const source = liveSession(snap)
  418. const updateQueue = vi.fn(() => Promise.resolve())
  419. const { getByLabelText, getByText } = render(
  420. <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
  421. )
  422. fireEvent.click(getByLabelText('编辑排队消息'))
  423. fireEvent.change(getByLabelText('编辑排队消息'), { target: { value: 'abandoned' } })
  424. fireEvent.click(getByLabelText('取消编辑'))
  425. expect(getByText('before')).toBeTruthy()
  426. fireEvent.click(getByLabelText('编辑排队消息'))
  427. fireEvent.keyDown(getByLabelText('编辑排队消息'), { key: 'Escape' })
  428. expect(getByText('before')).toBeTruthy()
  429. expect(updateQueue).not.toHaveBeenCalled()
  430. })
  431. it('keeps editing during IME composition and disables a blank save', () => {
  432. const snap = snapshotWith([row('i-edit', 'before')])
  433. const source = liveSession(snap)
  434. const updateQueue = vi.fn(() => Promise.resolve())
  435. const { getByLabelText } = render(
  436. <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
  437. )
  438. fireEvent.click(getByLabelText('编辑排队消息'))
  439. const editor = getByLabelText('编辑排队消息')
  440. fireEvent.change(editor, { target: { value: ' ' } })
  441. expect(getByLabelText('保存排队消息')).toHaveProperty('disabled', true)
  442. fireEvent.change(editor, { target: { value: '输入中' } })
  443. fireEvent.keyDown(editor, { key: 'Enter', isComposing: true })
  444. expect(updateQueue).not.toHaveBeenCalled()
  445. expect(getByLabelText('编辑排队消息')).toBeTruthy()
  446. })
  447. it('removes the addressed row', async () => {
  448. const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
  449. const source = liveSession(snap)
  450. const updateQueue = vi.fn(() => Promise.resolve())
  451. const { getAllByLabelText, getByRole } = render(
  452. <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
  453. )
  454. fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
  455. fireEvent.click(getAllByLabelText('删除排队消息')[0]!)
  456. await waitFor(() => {
  457. expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })
  458. })
  459. })
  460. it('strictly steers complete row content only while the agent is running', async () => {
  461. const running = snapshotWith([row('i-steer', null, 'image [image]')])
  462. const source = liveSession(running)
  463. const updateQueue = vi.fn(() => Promise.resolve())
  464. const rendered = render(
  465. <QueueDock {...kitFor(running, { updateQueue })} useSession={source.useSession} />,
  466. )
  467. const button = rendered.getByLabelText('插话发送')
  468. expect(button).toHaveProperty('disabled', false)
  469. fireEvent.click(button)
  470. await waitFor(() => {
  471. expect(updateQueue).toHaveBeenCalledWith(iid('i-steer'), { kind: 'steer' })
  472. })
  473. act(() => { source.push({ ...running, running: false }) })
  474. expect(rendered.getByLabelText('插话发送')).toHaveProperty('disabled', true)
  475. expect(rendered.getByLabelText('插话发送').getAttribute('title')).toBe('仅运行中可插话发送')
  476. })
  477. it('renders ordinary queue actions for a continuable child', () => {
  478. const snap = {
  479. ...snapshotWith([row('i-subagent', 'pending child follow-up')]),
  480. subagent: {
  481. address: {
  482. parentSessionId: 'parent' as SessionId,
  483. childSessionId: SID,
  484. mode: 'continuable' as const,
  485. },
  486. parentAvailable: false,
  487. },
  488. }
  489. const source = liveSession(snap)
  490. const view = render(
  491. <QueueDock {...kitFor(snap)} useSession={source.useSession} />,
  492. )
  493. expect(view.getByText('pending child follow-up')).toBeTruthy()
  494. expect(view.getByLabelText('编辑排队消息')).toBeTruthy()
  495. expect(view.getByLabelText('删除排队消息')).toBeTruthy()
  496. expect(view.getByLabelText('插话发送')).toBeTruthy()
  497. })
  498. it('keeps a one-shot child Queue read-only', () => {
  499. const snap = {
  500. ...snapshotWith([row('i-subagent', 'pending child follow-up')]),
  501. subagent: {
  502. address: {
  503. parentSessionId: 'parent' as SessionId,
  504. childSessionId: SID,
  505. mode: 'one-shot' as const,
  506. },
  507. parentAvailable: true,
  508. },
  509. }
  510. const source = liveSession(snap)
  511. const view = render(
  512. <QueueDock {...kitFor(snap)} useSession={source.useSession} />,
  513. )
  514. expect(view.getByText('pending child follow-up')).toBeTruthy()
  515. expect(view.queryByLabelText('编辑排队消息')).toBeNull()
  516. expect(view.queryByLabelText('删除排队消息')).toBeNull()
  517. expect(view.queryByLabelText('插话发送')).toBeNull()
  518. })
  519. it('keeps the row and reports a genuine steer failure', async () => {
  520. const snap = snapshotWith([row('i-steer-race', 'pending steer')])
  521. const source = liveSession(snap)
  522. const notify = vi.fn()
  523. const updateQueue = vi.fn(() => Promise.reject(new Error('transport failed')))
  524. const { getByLabelText, getByText } = render(
  525. <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
  526. )
  527. fireEvent.click(getByLabelText('插话发送'))
  528. await waitFor(() => {
  529. expect(notify).toHaveBeenCalledWith(
  530. 'error',
  531. '插话发送失败,请重试。',
  532. )
  533. })
  534. expect(getByText('pending steer')).toBeTruthy()
  535. })
  536. it('keeps the row and surfaces a notice when an operation loses the claim race', async () => {
  537. const snap = snapshotWith([row('i-race', 'pending')])
  538. const source = liveSession(snap)
  539. const notify = vi.fn()
  540. const updateQueue = vi.fn(() => Promise.reject(new Error('not found')))
  541. const { getByLabelText, getByText } = render(
  542. <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
  543. )
  544. fireEvent.click(getByLabelText('删除排队消息'))
  545. await waitFor(() => {
  546. expect(notify).toHaveBeenCalledWith('error', '删除失败:这条消息可能已经开始发送。')
  547. })
  548. expect(getByText('pending')).toBeTruthy()
  549. })
  550. it('follows authoritative retirement back to null', () => {
  551. const snap = snapshotWith([row('i-1', '在场')])
  552. const source = liveSession(snap)
  553. const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
  554. expect(container.textContent).toContain('在场')
  555. act(() => { source.push(snapshotWith([])) })
  556. expect(container.innerHTML).toBe('')
  557. })
  558. it('registers as the terminal composer-context entry', () => {
  559. expect(queueDockEntry.name).toBe('conversation-queue-dock')
  560. expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions', 'uiConversation'])
  561. const register = vi.fn(() => () => undefined)
  562. const inject = vi.fn((_name: string, callback: () => () => void) => callback())
  563. queueDockEntry.apply({ slots: { inject, register } } as never)
  564. expect(inject).toHaveBeenCalledWith('conversation.input.dock', expect.any(Function))
  565. expect(register).toHaveBeenCalledWith(
  566. expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
  567. QueueDock,
  568. )
  569. })
  570. })