queue-dock.client.spec.tsx 29 KB

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