rows.client.spec.tsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
  4. import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
  5. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  6. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  7. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  8. import type { RowDragProps } from '../src/client/rows/Rows.tsx'
  9. import { ProjectRowItem, SearchResultItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
  10. import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts'
  11. import { zh } from '../src/client/locales.ts'
  12. afterEach(cleanup)
  13. const t = makeTranslate(zh, commonZh) as never
  14. const sid = (id: string) => id as SessionId
  15. const wid = (id: string) => id as WorkspaceId
  16. /** Half detection reads the row rect; jsdom rects are all-zero by default. */
  17. function stubRect(row: HTMLElement): void {
  18. row.getBoundingClientRect = () => ({
  19. top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
  20. x: 0, y: 100, toJSON: () => ({}),
  21. })
  22. }
  23. function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
  24. return {
  25. start: vi.fn(), active: false, marker: null,
  26. hover: vi.fn(), drop: vi.fn(), end: vi.fn(),
  27. ...overrides,
  28. }
  29. }
  30. /** Install the async browser clipboard and restore its prior host shape. */
  31. function installClipboard(writeText: (text: string) => Promise<void>): () => void {
  32. const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
  33. Object.defineProperty(navigator, 'clipboard', {
  34. configurable: true,
  35. value: { writeText },
  36. })
  37. return () => {
  38. if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard')
  39. else Object.defineProperty(navigator, 'clipboard', prior)
  40. }
  41. }
  42. const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn() }
  43. /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
  44. function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
  45. const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
  46. Object.defineProperty(event, 'clientY', { value: clientY })
  47. Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } })
  48. fireEvent(row, event)
  49. }
  50. describe('workspace browser rows', () => {
  51. it('omits only an empty leading status slot in the hierarchy-free flat list', () => {
  52. const idle: SessionNode = {
  53. id: sid('flat'), title: 'Flat Session', blank: false, running: false,
  54. runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
  55. }
  56. const view = render(<SessionNodeItem node={idle} currentId={undefined} now={0} onOpen={vi.fn()}
  57. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} flat t={t} />)
  58. const title = screen.getByText('Flat Session')
  59. expect(title.previousElementSibling).toBeNull()
  60. view.rerender(<SessionNodeItem node={{ ...idle, running: true }} currentId={undefined} now={0}
  61. onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} flat t={t} />)
  62. expect(screen.getByText('Flat Session').previousElementSibling?.querySelector('[data-state="ongoing"]')).toBeTruthy()
  63. })
  64. it('renders a selected content-search row and opens only its session', () => {
  65. const onOpen = vi.fn()
  66. const result: SearchResultNode = {
  67. id: sid('result'),
  68. title: 'Result title',
  69. workspace: 'Workspace context',
  70. running: true,
  71. runningSubagentCount: 0,
  72. completed: false,
  73. hasActiveSchedule: false,
  74. snippet: 'matching message excerpt',
  75. }
  76. render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} t={t} />)
  77. const row = screen.getByRole('treeitem')
  78. expect(row.getAttribute('aria-selected')).toBe('true')
  79. expect(screen.getByText('Workspace context')).toBeTruthy()
  80. expect(screen.getByText('matching message excerpt')).toBeTruthy()
  81. expect(row.querySelector('[data-state="ongoing"]')).toBeTruthy()
  82. expect(screen.getByText('进行中')).toBeTruthy()
  83. expect(row.hasAttribute('draggable')).toBe(false)
  84. fireEvent.click(row)
  85. expect(onOpen).toHaveBeenCalledWith(result.id)
  86. })
  87. it('keeps the active-Schedule marker after a search title and inside the row action', () => {
  88. const onOpen = vi.fn()
  89. const result: SearchResultNode = {
  90. id: sid('scheduled-result'), title: 'Scheduled result', workspace: 'Project',
  91. running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: true,
  92. }
  93. render(<SearchResultItem result={result} currentId={undefined} onOpen={onOpen} t={t} />)
  94. const row = screen.getByRole('treeitem')
  95. const title = screen.getByText('Scheduled result')
  96. const indicator = screen.getByRole('img', { name: '有活动定时任务' })
  97. expect(title.nextElementSibling).toBe(indicator)
  98. expect(indicator.getAttribute('title')).toBe('有活动定时任务')
  99. expect(indicator.getAttribute('tabindex')).toBeNull()
  100. expect(row.querySelectorAll('button')).toHaveLength(0)
  101. fireEvent.click(indicator)
  102. expect(onOpen).toHaveBeenCalledWith(result.id)
  103. })
  104. it.each([
  105. ['approval', '等待审批'],
  106. ['plan-review', '计划待审'],
  107. ['question', '等待回答'],
  108. ] as const)('shows %s ahead of running in search results', (pendingInteraction, label) => {
  109. const result: SearchResultNode = {
  110. id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project',
  111. pendingInteraction, running: true, runningSubagentCount: 0, completed: false,
  112. hasActiveSchedule: false,
  113. }
  114. render(<SearchResultItem result={result} currentId={undefined} onOpen={vi.fn()} t={t} />)
  115. const row = screen.getByRole('treeitem')
  116. expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
  117. expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
  118. expect(screen.getByText(label)).toBeTruthy()
  119. })
  120. it('renders an active Workspace and keeps its create action separate from toggling', () => {
  121. const onToggle = vi.fn()
  122. const onCreate = vi.fn()
  123. const group: GroupNode = {
  124. key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
  125. sessionCount: 1, expanded: true, containsCurrent: true, sessions: [],
  126. }
  127. render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} t={t} />)
  128. expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
  129. fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' }))
  130. expect(onCreate).toHaveBeenCalledOnce()
  131. expect(onToggle).not.toHaveBeenCalled()
  132. fireEvent.click(screen.getByText('Project'))
  133. expect(onToggle).toHaveBeenCalledOnce()
  134. })
  135. it('renders and opens a selected running Session row', () => {
  136. const node: SessionNode = {
  137. id: sid('session'), title: 'Session', blank: false, running: true,
  138. runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
  139. }
  140. const onOpen = vi.fn()
  141. render(
  142. <SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
  143. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
  144. )
  145. const row = screen.getByRole('treeitem')
  146. expect(row.getAttribute('aria-selected')).toBe('true')
  147. expect(row.hasAttribute('aria-expanded')).toBe(false)
  148. expect(screen.queryByRole('button', { name: /展开|收起/ })).toBeNull()
  149. fireEvent.click(row)
  150. expect(onOpen).toHaveBeenCalledWith(node.id)
  151. })
  152. it('keeps the active-Schedule marker between the title and time in grouped and flat rows', () => {
  153. const onOpen = vi.fn()
  154. const node: SessionNode = {
  155. id: sid('scheduled-session'), title: 'Scheduled Session', blank: false, running: false,
  156. runningSubagentCount: 0, completed: false, hasActiveSchedule: true, updatedAt: 0,
  157. }
  158. const view = render(
  159. <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
  160. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
  161. )
  162. const assertIndicator = (): HTMLElement => {
  163. const title = screen.getByText('Scheduled Session')
  164. const time = screen.getByText('刚刚')
  165. const indicator = screen.getByRole('img', { name: '有活动定时任务' })
  166. expect(title.nextElementSibling).toBe(indicator)
  167. expect(indicator.nextElementSibling).toBe(time)
  168. expect(indicator.getAttribute('title')).toBe('有活动定时任务')
  169. expect(indicator.getAttribute('tabindex')).toBeNull()
  170. return indicator
  171. }
  172. fireEvent.click(assertIndicator())
  173. expect(onOpen).toHaveBeenCalledWith(node.id)
  174. view.rerender(
  175. <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
  176. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} flat t={t} />,
  177. )
  178. assertIndicator()
  179. })
  180. it('shows the green done dot only on a finished, unviewed session (live activity wins the slot)', () => {
  181. const renderRow = (over: Partial<SessionNode>) => render(
  182. <SessionNodeItem
  183. node={{
  184. id: sid('s1'), title: 'One', blank: false, running: false,
  185. runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, ...over,
  186. }}
  187. currentId={undefined} now={0} onOpen={vi.fn()}
  188. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t}
  189. />,
  190. )
  191. const stateDot = (view: ReturnType<typeof renderRow>) =>
  192. view.container.querySelector('[data-state]')
  193. // No completion reminder, not running: no state dot at all.
  194. const plain = renderRow({})
  195. expect(stateDot(plain)).toBeNull()
  196. plain.unmount()
  197. // Completed while unviewed: the green done dot.
  198. const done = renderRow({ completed: true })
  199. expect(done.container.querySelector('[data-state="done"]')).not.toBeNull()
  200. done.unmount()
  201. // Running wins the slot: the animated ongoing dot, no done dot.
  202. const running = renderRow({ completed: true, running: true })
  203. expect(running.container.querySelector('[data-state="ongoing"]')).not.toBeNull()
  204. expect(running.container.querySelector('[data-state="done"]')).toBeNull()
  205. running.unmount()
  206. // Descendant activity also wins until the last running descendant stops.
  207. const delegated = renderRow({ completed: true, runningSubagentCount: 1 })
  208. expect(delegated.container.querySelector('[data-state="ongoing"]')).not.toBeNull()
  209. expect(delegated.container.querySelector('[data-state="done"]')).toBeNull()
  210. })
  211. it('shows descendant activity without describing an idle parent as running', () => {
  212. vi.useFakeTimers()
  213. try {
  214. const node: SessionNode = {
  215. id: sid('owner'), title: 'Delegating', blank: false, running: false,
  216. runningSubagentCount: 2, completed: false, hasActiveSchedule: false, updatedAt: 0,
  217. }
  218. render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  219. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  220. const row = screen.getByRole('treeitem')
  221. expect(row.querySelector('[data-state="ongoing"]')).not.toBeNull()
  222. expect(screen.getByText('2 个子代理运行中')).toBeTruthy()
  223. expect(screen.queryByText('进行中')).toBeNull()
  224. fireEvent.pointerEnter(row.parentElement as HTMLElement)
  225. act(() => { vi.advanceTimersByTime(500) })
  226. expect(screen.getAllByText('2 个子代理运行中')).toHaveLength(2)
  227. } finally {
  228. vi.useRealTimers()
  229. }
  230. })
  231. it('keeps descendant activity secondary while the parent is running', () => {
  232. vi.useFakeTimers()
  233. try {
  234. const node: SessionNode = {
  235. id: sid('owner'), title: 'Delegating', blank: false, running: true,
  236. runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0,
  237. }
  238. render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  239. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  240. const row = screen.getByRole('treeitem')
  241. expect(row.querySelectorAll('[data-state="ongoing"]')).toHaveLength(1)
  242. expect(screen.getByText('进行中')).toBeTruthy()
  243. expect(screen.getByText('1 个子代理运行中')).toBeTruthy()
  244. fireEvent.pointerEnter(row.parentElement as HTMLElement)
  245. act(() => { vi.advanceTimersByTime(500) })
  246. expect(screen.getAllByText('进行中')).toHaveLength(2)
  247. expect(screen.getAllByText('1 个子代理运行中')).toHaveLength(2)
  248. } finally {
  249. vi.useRealTimers()
  250. }
  251. })
  252. it('keeps child activity as a secondary status while user attention is primary', () => {
  253. const node: SessionNode = {
  254. id: sid('owner'), title: 'Needs input', blank: false, pendingInteraction: 'question',
  255. running: false, runningSubagentCount: 1, completed: false, hasActiveSchedule: false, updatedAt: 0,
  256. }
  257. render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  258. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  259. const row = screen.getByRole('treeitem')
  260. expect(row.querySelector('[data-state="warning"]')).not.toBeNull()
  261. expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
  262. expect(screen.getByText('等待回答')).toBeTruthy()
  263. expect(screen.getByText('1 个子代理运行中')).toBeTruthy()
  264. })
  265. it('shows the green done dot on a finished search result row', () => {
  266. render(<SearchResultItem
  267. result={{
  268. id: sid('result'), title: 'Done', workspace: 'Workspace', running: false,
  269. runningSubagentCount: 0, completed: true, hasActiveSchedule: false,
  270. }}
  271. currentId={undefined} onOpen={vi.fn()} t={t}
  272. />)
  273. expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull()
  274. })
  275. it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
  276. const onRename = vi.fn()
  277. const onDelete = vi.fn()
  278. const onToggle = vi.fn()
  279. const group: GroupNode = {
  280. key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
  281. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  282. }
  283. render(<ProjectRowItem
  284. group={group} onToggle={onToggle} onCreate={vi.fn()}
  285. actions={{ rename: onRename, delete: onDelete }} t={t}
  286. />)
  287. fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
  288. // Opening the menu neither toggles the group nor renames yet.
  289. expect(onToggle).not.toHaveBeenCalled()
  290. expect(screen.getByRole('menuitem', { name: '删除工作区' }).className).toMatch(/danger/)
  291. fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
  292. expect(onRename).toHaveBeenCalledOnce()
  293. expect(screen.queryByRole('menu')).toBeNull()
  294. fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
  295. fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
  296. expect(screen.queryByRole('menu')).toBeNull()
  297. expect(onRename).toHaveBeenCalledOnce()
  298. expect(onDelete).toHaveBeenCalledOnce()
  299. // Escape closes without selecting (Menu onClose path).
  300. fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
  301. fireEvent.keyDown(document, { key: 'Escape' })
  302. expect(screen.queryByRole('menu')).toBeNull()
  303. })
  304. it('workspace hover card shows its details and copies the full directory path', async () => {
  305. vi.useFakeTimers()
  306. const writeText = vi.fn(async () => {})
  307. const restoreClipboard = installClipboard(writeText)
  308. try {
  309. const group: GroupNode = {
  310. key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
  311. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  312. }
  313. render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
  314. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  315. act(() => { vi.advanceTimersByTime(500) })
  316. // Card body: full title + cwd + absolute creation time.
  317. expect(screen.getAllByText('Project')).toHaveLength(2)
  318. expect(screen.getByText('/projects/project')).toBeTruthy()
  319. expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy()
  320. await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /projects/project' })) })
  321. expect(writeText).toHaveBeenCalledWith('/projects/project')
  322. expect(screen.getByRole('status').textContent).toBe('已复制')
  323. } finally {
  324. restoreClipboard()
  325. vi.useRealTimers()
  326. }
  327. })
  328. it('workspace hover card shows a POSIX home descendant as ~ and still copies the full path', async () => {
  329. vi.useFakeTimers()
  330. const writeText = vi.fn(async () => {})
  331. const restoreClipboard = installClipboard(writeText)
  332. try {
  333. const group: GroupNode = {
  334. key: 'project', workspaceId: wid('project'), cwd: '/home/u/Documents/project', createdAt: 0, label: 'Project',
  335. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  336. }
  337. render(<ProjectRowItem group={group} home="/home/u" onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
  338. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  339. act(() => { vi.advanceTimersByTime(500) })
  340. expect(screen.getByText('~/Documents/project')).toBeTruthy()
  341. expect(screen.queryByText('/home/u/Documents/project')).toBeNull()
  342. await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /home/u/Documents/project' })) })
  343. expect(writeText).toHaveBeenCalledWith('/home/u/Documents/project')
  344. } finally {
  345. restoreClipboard()
  346. vi.useRealTimers()
  347. }
  348. })
  349. it('workspace hover card without a directory omits the path and copy action', async () => {
  350. vi.useFakeTimers()
  351. try {
  352. const group: GroupNode = {
  353. key: 'project', workspaceId: wid('project'), cwd: undefined, createdAt: 0, label: 'Project',
  354. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  355. }
  356. render(<ProjectRowItem group={group} home="/home/u" onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
  357. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  358. act(() => { vi.advanceTimersByTime(500) })
  359. expect(screen.getAllByText('Project')).toHaveLength(2)
  360. expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy()
  361. expect(screen.queryByRole('button', { name: /^复制:/ })).toBeNull()
  362. } finally {
  363. vi.useRealTimers()
  364. }
  365. })
  366. it('workspace hover card leaves a Windows path verbatim', async () => {
  367. vi.useFakeTimers()
  368. try {
  369. const group: GroupNode = {
  370. key: 'project', workspaceId: wid('project'), cwd: 'C:\\Users\\u\\project', createdAt: 0, label: 'Project',
  371. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  372. }
  373. render(<ProjectRowItem group={group} home="C:\\Users\\u" onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
  374. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  375. act(() => { vi.advanceTimersByTime(500) })
  376. expect(screen.getByText('C:\\Users\\u\\project')).toBeTruthy()
  377. } finally {
  378. vi.useRealTimers()
  379. }
  380. })
  381. it('ungrouped bucket renders no workspace menu', () => {
  382. const group: GroupNode = {
  383. key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped',
  384. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  385. }
  386. render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
  387. expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull()
  388. })
  389. it('blank New Session rows carry no menu, no time label, and no hover-card time', () => {
  390. vi.useFakeTimers()
  391. try {
  392. const node: SessionNode = {
  393. id: sid('s-blank'), title: 'ignored', blank: true, running: false,
  394. runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
  395. }
  396. render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
  397. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  398. // The placeholder has no content yet: no row verbs, no "now" stamp.
  399. expect(screen.queryByRole('button', { name: /会话.*的操作/ })).toBeNull()
  400. expect(screen.queryByText('刚刚')).toBeNull()
  401. // The hover card keeps title + status but drops the timestamp line.
  402. const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
  403. fireEvent.pointerEnter(wrapper)
  404. act(() => { vi.advanceTimersByTime(500) })
  405. expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2)
  406. expect(screen.getByText('空闲')).toBeTruthy()
  407. expect(screen.queryByText('刚刚')).toBeNull()
  408. expect(screen.getByText('空闲').closest('[role="button"]')).toBeNull()
  409. } finally {
  410. vi.useRealTimers()
  411. }
  412. })
  413. it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => {
  414. const onOpen = vi.fn()
  415. const onRename = vi.fn()
  416. const onFork = vi.fn()
  417. const onArchive = vi.fn()
  418. const node: SessionNode = {
  419. id: sid('s1'), title: 'One', blank: false, running: false,
  420. runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
  421. }
  422. render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
  423. onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
  424. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
  425. expect(onOpen).not.toHaveBeenCalled()
  426. // Archive is not destructive (log and accounting slot remain): no danger styling.
  427. expect(screen.getByRole('menuitem', { name: '归档会话' }).className).not.toMatch(/danger/)
  428. // Rename dispatches with the current display title (dialog prefill).
  429. fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
  430. expect(screen.queryByRole('menu')).toBeNull()
  431. expect(onRename).toHaveBeenCalledWith(node.id, 'One')
  432. expect(onOpen).not.toHaveBeenCalled()
  433. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
  434. fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' }))
  435. expect(onFork).toHaveBeenCalledWith(node.id)
  436. // Archive dispatches without opening the session.
  437. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
  438. fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
  439. expect(onArchive).toHaveBeenCalledWith(node.id)
  440. expect(onRename).toHaveBeenCalledOnce()
  441. expect(onOpen).not.toHaveBeenCalled()
  442. // Escape closes without selecting (Menu onClose path).
  443. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
  444. fireEvent.keyDown(document, { key: 'Escape' })
  445. expect(screen.queryByRole('menu')).toBeNull()
  446. })
  447. it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
  448. vi.useFakeTimers()
  449. try {
  450. const node: SessionNode = {
  451. id: sid('s1'), title: 'Hovered', blank: false, running: true,
  452. runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
  453. }
  454. render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
  455. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  456. const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
  457. fireEvent.pointerEnter(wrapper)
  458. act(() => { vi.advanceTimersByTime(500) })
  459. // Card body: full title + relative time + running status.
  460. expect(screen.getAllByText('Hovered')).toHaveLength(2)
  461. expect(screen.getByText('1分钟前')).toBeTruthy()
  462. expect(screen.getAllByText('进行中')).toHaveLength(2)
  463. fireEvent.pointerLeave(wrapper)
  464. // Menu open (disabled=true) suppresses the card for the same hover.
  465. fireEvent.click(screen.getByRole('button', { name: '会话“Hovered”的操作' }))
  466. fireEvent.pointerEnter(wrapper)
  467. act(() => { vi.advanceTimersByTime(1000) })
  468. expect(screen.queryByText('1分钟前')).toBeNull()
  469. } finally {
  470. vi.useRealTimers()
  471. }
  472. })
  473. it.each([
  474. ['approval', '等待审批'],
  475. ['plan-review', '计划待审'],
  476. ['question', '等待回答'],
  477. ] as const)('shows %s as warning ahead of the running state', (pendingInteraction, label) => {
  478. vi.useFakeTimers()
  479. try {
  480. const node: SessionNode = {
  481. id: sid(pendingInteraction), title: 'Needs input', blank: false,
  482. pendingInteraction, running: true, runningSubagentCount: 0, completed: false,
  483. hasActiveSchedule: false, updatedAt: 0,
  484. }
  485. const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  486. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  487. const row = screen.getByRole('treeitem')
  488. expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
  489. expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
  490. expect(screen.getByText(label)).toBeTruthy()
  491. view.rerender(<SessionNodeItem node={{ ...node, running: false }} currentId={undefined} now={0}
  492. onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  493. expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy()
  494. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  495. act(() => { vi.advanceTimersByTime(500) })
  496. expect(screen.getAllByText(label)).toHaveLength(2)
  497. expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
  498. } finally {
  499. vi.useRealTimers()
  500. }
  501. })
  502. it('idle hover card shows the Idle status line', () => {
  503. vi.useFakeTimers()
  504. try {
  505. const node: SessionNode = {
  506. id: sid('s1'), title: 'Quiet', blank: false, running: false,
  507. runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
  508. }
  509. render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  510. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  511. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  512. act(() => { vi.advanceTimersByTime(500) })
  513. expect(screen.getByText('空闲')).toBeTruthy()
  514. expect(screen.getAllByText('刚刚')).toHaveLength(2)
  515. } finally {
  516. vi.useRealTimers()
  517. }
  518. })
  519. it('completed hover card shows the Completed status line', () => {
  520. vi.useFakeTimers()
  521. try {
  522. const node: SessionNode = {
  523. id: sid('s1'), title: 'Done', blank: false, running: false,
  524. runningSubagentCount: 0, completed: true, hasActiveSchedule: false, updatedAt: 0,
  525. }
  526. render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  527. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  528. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  529. act(() => { vi.advanceTimersByTime(500) })
  530. // Row's visually-hidden reminder label plus the hover card's status line.
  531. expect(screen.getAllByText('已完成')).toHaveLength(2)
  532. } finally {
  533. vi.useRealTimers()
  534. }
  535. })
  536. it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
  537. const node: SessionNode = {
  538. id: sid('s1'), title: 'Drag me', blank: false, running: false,
  539. runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
  540. }
  541. const inactive = dragProps()
  542. const { rerender } = render(
  543. <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  544. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={inactive} t={t} />,
  545. )
  546. const row = screen.getByRole('treeitem')
  547. stubRect(row)
  548. expect(row.getAttribute('draggable')).toBe('true')
  549. fireEvent.dragStart(row, { dataTransfer })
  550. expect(inactive.start).toHaveBeenCalledOnce()
  551. // Inactive drag: hover and drop are rejected.
  552. fireEvent.dragOver(row, { dataTransfer })
  553. fireEvent.drop(row, { dataTransfer })
  554. expect(inactive.hover).not.toHaveBeenCalled()
  555. expect(inactive.drop).not.toHaveBeenCalled()
  556. fireEvent.dragEnd(row)
  557. expect(inactive.end).toHaveBeenCalledOnce()
  558. const active = dragProps({ active: true, marker: 'before' })
  559. rerender(
  560. <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  561. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={active} t={t} />,
  562. )
  563. stubRect(screen.getByRole('treeitem'))
  564. // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
  565. fireDrag(screen.getByRole('treeitem'), 'dragOver', 105)
  566. expect(active.hover).toHaveBeenCalledWith('before')
  567. fireDrag(screen.getByRole('treeitem'), 'dragOver', 130)
  568. expect(active.hover).toHaveBeenCalledWith('after')
  569. fireDrag(screen.getByRole('treeitem'), 'drop', 130)
  570. expect(active.drop).toHaveBeenCalledWith('after')
  571. const after = dragProps({ active: true, marker: 'after' })
  572. rerender(
  573. <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  574. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={after} t={t} />,
  575. )
  576. expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
  577. })
  578. })