workspace-browser.spec.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. // @vitest-environment jsdom
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
  4. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  5. import type {
  6. SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView,
  7. } from '@deepseek-ai/dsh-client-runtime/client'
  8. import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
  9. import { createWorkspaceViewStore } from '../src/client/stores.ts'
  10. import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
  11. afterEach(cleanup)
  12. beforeEach(() => { localStorage.clear() })
  13. const sid = (id: string) => id as SessionId
  14. const wid = (id: string) => id as WorkspaceId
  15. const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
  16. id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides,
  17. })
  18. const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
  19. ids: items.map(item => item.id),
  20. byId: Object.fromEntries(items.map(item => [item.id, item])),
  21. current: undefined,
  22. phase: 'ready',
  23. ...overrides,
  24. })
  25. const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
  26. workspaceId: wid(id), path: `/projects/${id}`, title,
  27. sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
  28. })
  29. const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
  30. items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
  31. recentWorkspaceId: items[0]?.workspaceId,
  32. })
  33. function hook<T>(snapshot: T) {
  34. return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
  35. }
  36. /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
  37. function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
  38. const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
  39. Object.defineProperty(event, 'clientY', { value: clientY })
  40. Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', dropEffect: '' } })
  41. fireEvent(row, event)
  42. }
  43. function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
  44. const store = createWorkspaceViewStore().create()
  45. const props: WorkspaceBrowserProps = {
  46. wide: true,
  47. expandSidebar: vi.fn(),
  48. useSessions: hook(sessionState([])),
  49. useWorkspaces: hook(workspaceState([])),
  50. useStore: bindSnapshotSelector(store),
  51. actions: store.actions,
  52. startSession: vi.fn(),
  53. open: vi.fn(),
  54. renameWorkspace: vi.fn(async () => {}),
  55. deleteWorkspace: vi.fn(async () => {}),
  56. insertSessionBefore: vi.fn(async () => {}),
  57. createWorkspace: vi.fn(async () => workspace('created', [])),
  58. pickDirectory: vi.fn(async () => null),
  59. ...overrides,
  60. }
  61. const view = render(<WorkspaceBrowser {...props} />)
  62. return { view, props, store }
  63. }
  64. /** Re-render with (possibly) changed props — WorkspaceBrowser has no side channel. */
  65. function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrowserProps>) {
  66. Object.assign(b.props, overrides)
  67. b.view.rerender(<WorkspaceBrowser {...b.props} />)
  68. }
  69. describe('WorkspaceBrowser', () => {
  70. it('renders the grouped tree by default and switches to the flat list via Group by', () => {
  71. const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)])
  72. const b = mount({
  73. useSessions: hook(sessions),
  74. useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])),
  75. })
  76. expect(screen.getByText('Workspaces')).toBeTruthy()
  77. expect(screen.getByText('alpha')).toBeTruthy()
  78. // Sessions hidden while their group is folded.
  79. expect(screen.queryByText('alpha-s')).toBeNull()
  80. fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
  81. expect(screen.getByText('Group by')).toBeTruthy() // the menu heading label
  82. fireEvent.click(screen.getByRole('menuitem', { name: 'In one list' }))
  83. // Store-driven flip: title changes, rows flatten newest-first, headers gone.
  84. expect(b.store.getSnapshot().groupBy).toBe('flat')
  85. expect(screen.getByText('Sessions')).toBeTruthy()
  86. expect(screen.queryByText('alpha')).toBeNull()
  87. expect(screen.getByText('alpha-s')).toBeTruthy()
  88. expect(screen.getByText('beta-s')).toBeTruthy()
  89. // Back to workspace grouping through the same menu.
  90. fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
  91. fireEvent.click(screen.getByRole('menuitem', { name: 'WorkSpace' }))
  92. expect(b.store.getSnapshot().groupBy).toBe('workspace')
  93. expect(screen.getByText('Workspaces')).toBeTruthy()
  94. // Escape closes the menu without picking.
  95. fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
  96. fireEvent.keyDown(document, { key: 'Escape' })
  97. expect(screen.queryByRole('menu')).toBeNull()
  98. expect(b.store.getSnapshot().groupBy).toBe('workspace')
  99. })
  100. it('expands a group on click and opens a session row', () => {
  101. const open = vi.fn()
  102. mount({
  103. useSessions: hook(sessionState([summary('alpha-s', 1)])),
  104. useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
  105. open,
  106. })
  107. fireEvent.click(screen.getByText('alpha'))
  108. fireEvent.click(screen.getByText('alpha-s'))
  109. expect(open).toHaveBeenCalledWith(sid('alpha-s'))
  110. // Collapse hides the row again.
  111. fireEvent.click(screen.getByText('alpha'))
  112. expect(screen.queryByText('alpha-s')).toBeNull()
  113. })
  114. it('unfolds a session subtree through the row twist', () => {
  115. const parent = summary('parent-s', 2)
  116. const child = { ...summary('child-s', 1), parentId: parent.id }
  117. mount({
  118. useSessions: hook(sessionState([parent, child])),
  119. useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])),
  120. })
  121. fireEvent.click(screen.getByText('alpha'))
  122. expect(screen.queryByText('child-s')).toBeNull()
  123. fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
  124. expect(screen.getByText('child-s')).toBeTruthy()
  125. fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
  126. expect(screen.queryByText('child-s')).toBeNull()
  127. })
  128. it('auto-expands the selected session group and starts a session from the group +', () => {
  129. const startSession = vi.fn()
  130. mount({
  131. useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })),
  132. useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
  133. startSession,
  134. })
  135. // The current-group effect expanded the owning group without a click.
  136. expect(screen.getByText('alpha-s')).toBeTruthy()
  137. fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' }))
  138. expect(startSession).toHaveBeenCalledWith(wid('alpha'))
  139. })
  140. it('auto-expands the Ungrouped bucket for a loose current session; its header has no menu and its + is inert', () => {
  141. const startSession = vi.fn()
  142. mount({
  143. useSessions: hook(sessionState([summary('loose', 1)], { current: sid('loose') })),
  144. useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
  145. startSession,
  146. })
  147. // The loose session's group is UNGROUPED_KEY: expanded by the effect.
  148. expect(screen.getByText('loose')).toBeTruthy()
  149. expect(screen.queryByRole('button', { name: 'Workspace actions for Ungrouped' })).toBeNull()
  150. fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
  151. expect(startSession).not.toHaveBeenCalled()
  152. })
  153. it('keeps an already-expanded group when the selection moves within it', () => {
  154. const first = sessionState([summary('a', 2), summary('b', 1)], { current: sid('a') })
  155. const b = mount({
  156. useSessions: hook(first),
  157. useWorkspaces: hook(workspaceState([workspace('alpha', ['a', 'b'])])),
  158. })
  159. expect(screen.getByText('a')).toBeTruthy()
  160. // Selection hop inside the same group: the effect re-runs and leaves the
  161. // expansion list unchanged (no duplicate key, group still open).
  162. rerender(b, { useSessions: hook({ ...first, current: sid('b') }) })
  163. expect(screen.getByText('b')).toBeTruthy()
  164. fireEvent.click(screen.getByText('alpha'))
  165. expect(screen.queryByText('b')).toBeNull()
  166. })
  167. it('shows only the current blank session as New Session in grouped, flat, and search modes', () => {
  168. const currentBlank = summary('alpha-blank', 9, { blank: true })
  169. const staleBlank = summary('beta-blank', 8, { blank: true })
  170. const sessions = sessionState(
  171. [currentBlank, staleBlank],
  172. { current: currentBlank.id },
  173. )
  174. const b = mount({
  175. useSessions: hook(sessions),
  176. useWorkspaces: hook(workspaceState([
  177. workspace('alpha', ['alpha-blank']), workspace('beta', ['beta-blank']),
  178. ])),
  179. })
  180. expect(screen.getByText('New Session')).toBeTruthy()
  181. expect(screen.queryByText('alpha-blank')).toBeNull()
  182. expect(screen.queryByText('beta-blank')).toBeNull()
  183. expect(screen.getByText('1 session')).toBeTruthy()
  184. rerender(b, { useSessions: hook({ ...sessions, current: staleBlank.id }) })
  185. expect(screen.getAllByText('New Session')).toHaveLength(1)
  186. b.store.actions.setGroupBy('flat')
  187. rerender(b, {})
  188. expect(screen.getAllByText('New Session')).toHaveLength(1)
  189. fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'new session' } })
  190. expect(screen.getAllByText('New Session')).toHaveLength(1)
  191. })
  192. it('searches across groups, clears via the clear button, and shows the empty states', () => {
  193. const sessions = sessionState([
  194. summary('needle-row', 2, { displayTitle: 'Needle row' }),
  195. summary('other-row', 1, { displayTitle: 'Other row' }),
  196. ])
  197. mount({
  198. useSessions: hook(sessions),
  199. useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
  200. })
  201. const input = screen.getByPlaceholderText<HTMLInputElement>('Search name, keywords...')
  202. fireEvent.change(input, { target: { value: 'needle' } })
  203. // Search forces matches visible without expansion state.
  204. expect(screen.getByText('Needle row')).toBeTruthy()
  205. expect(screen.queryByText('Other row')).toBeNull()
  206. fireEvent.change(input, { target: { value: 'zzz' } })
  207. expect(screen.getByText('No matches')).toBeTruthy()
  208. fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
  209. expect(input.value).toBe('')
  210. // Clicking the field row focuses the input (wide mode).
  211. fireEvent.click(input.parentElement as HTMLElement)
  212. expect(document.activeElement).toBe(input)
  213. })
  214. it('shows the no-sessions empty state in both modes', () => {
  215. const b = mount()
  216. expect(screen.getByText('No sessions yet')).toBeTruthy()
  217. b.store.actions.setGroupBy('flat')
  218. rerender(b, {})
  219. expect(screen.getByText('No sessions yet')).toBeTruthy()
  220. // Flat search misses show No matches.
  221. fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } })
  222. expect(screen.getByText('No matches')).toBeTruthy()
  223. })
  224. it('rail state renders icon controls that request expansion', () => {
  225. vi.useFakeTimers()
  226. try {
  227. const expandSidebar = vi.fn()
  228. const b = mount({ wide: false, expandSidebar })
  229. // No wide chrome in rail state.
  230. expect(screen.queryByText('Workspaces')).toBeNull()
  231. expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
  232. fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
  233. expect(expandSidebar).toHaveBeenCalledTimes(1)
  234. // The wide flip mounts the input and focuses it after the slide.
  235. rerender(b, { wide: true })
  236. const input = screen.getByPlaceholderText('Search name, keywords...')
  237. act(() => { vi.advanceTimersByTime(300) })
  238. expect(document.activeElement).toBe(input)
  239. // Wide search button is decorative (tabIndex -1, no expand call).
  240. fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
  241. expect(expandSidebar).toHaveBeenCalledTimes(1)
  242. } finally {
  243. vi.useRealTimers()
  244. }
  245. })
  246. it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => {
  247. const expandSidebar = vi.fn()
  248. const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
  249. fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
  250. expect(expandSidebar).toHaveBeenCalledTimes(1)
  251. rerender(b, { wide: true })
  252. // The picker menu is open (anchored on the +); picking starts a session.
  253. fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' }))
  254. expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha'))
  255. expect(screen.queryByRole('menu')).toBeNull()
  256. // Wide toggle: open and close without expand requests.
  257. fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
  258. expect(screen.getByRole('menu')).toBeTruthy()
  259. fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
  260. expect(screen.queryByRole('menu')).toBeNull()
  261. expect(expandSidebar).toHaveBeenCalledTimes(1)
  262. // Escape closes the picker through its own onClose.
  263. fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
  264. fireEvent.keyDown(document, { key: 'Escape' })
  265. expect(screen.queryByRole('menu')).toBeNull()
  266. })
  267. it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {
  268. const insertSessionBefore = vi.fn(async () => {})
  269. const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
  270. mount({
  271. useSessions: hook(sessions),
  272. useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])),
  273. insertSessionBefore,
  274. })
  275. fireEvent.click(screen.getByText('alpha'))
  276. const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header
  277. const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
  278. three.getBoundingClientRect = () => ({
  279. top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
  280. })
  281. const dataTransfer = { effectAllowed: '', dropEffect: '' }
  282. fireEvent.dragStart(one, { dataTransfer })
  283. // Drop on the top half of "three": insert one before three.
  284. fireDrag(three, 'dragOver', 205)
  285. fireDrag(three, 'drop', 205)
  286. expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three'))
  287. // Dropping right back onto its own position is a no-op — top half
  288. // (anchor = itself) and bottom half (anchor = the next root) alike.
  289. fireEvent.dragStart(one, { dataTransfer })
  290. one.getBoundingClientRect = () => ({
  291. top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
  292. })
  293. fireDrag(one, 'dragOver', 105)
  294. fireDrag(one, 'drop', 105)
  295. expect(insertSessionBefore).toHaveBeenCalledTimes(1)
  296. fireEvent.dragStart(one, { dataTransfer })
  297. fireDrag(one, 'drop', 130)
  298. expect(insertSessionBefore).toHaveBeenCalledTimes(1)
  299. })
  300. it('still sends the reorder when the dragged row left the group mid-drag', () => {
  301. const insertSessionBefore = vi.fn(async () => {})
  302. const sessions = sessionState([summary('one', 2), summary('two', 1)])
  303. const b = mount({
  304. useSessions: hook(sessions),
  305. useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
  306. insertSessionBefore,
  307. })
  308. fireEvent.click(screen.getByText('alpha'))
  309. const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
  310. fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } })
  311. // The host dropped "one" from the workspace account while the drag is in
  312. // flight: the source index is gone but the drop still resolves its anchor.
  313. rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) })
  314. const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
  315. two.getBoundingClientRect = () => ({
  316. top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
  317. })
  318. fireDrag(two, 'drop', 155)
  319. expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
  320. })
  321. it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => {
  322. const insertSessionBefore = vi.fn(async () => {})
  323. const sessions = sessionState([summary('one', 2), summary('two', 1)])
  324. mount({
  325. useSessions: hook(sessions),
  326. useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
  327. insertSessionBefore,
  328. })
  329. fireEvent.click(screen.getByText('alpha'))
  330. const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
  331. two.getBoundingClientRect = () => ({
  332. top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
  333. })
  334. const dataTransfer = { effectAllowed: '', dropEffect: '' }
  335. fireEvent.dragStart(one, { dataTransfer })
  336. fireEvent.dragEnd(one)
  337. // The drag ended: rows no longer accept drops.
  338. fireDrag(two, 'drop', 180)
  339. expect(insertSessionBefore).not.toHaveBeenCalled()
  340. // Bottom half of the last row: append (anchor omitted).
  341. fireEvent.dragStart(one, { dataTransfer })
  342. fireDrag(two, 'dragOver', 180)
  343. fireDrag(two, 'drop', 180)
  344. expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
  345. })
  346. it('logs and keeps the order when the reorder call rejects', async () => {
  347. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
  348. try {
  349. const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') })
  350. const sessions = sessionState([summary('one', 2), summary('two', 1)])
  351. mount({
  352. useSessions: hook(sessions),
  353. useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
  354. insertSessionBefore,
  355. })
  356. fireEvent.click(screen.getByText('alpha'))
  357. const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
  358. two.getBoundingClientRect = () => ({
  359. top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
  360. })
  361. const dataTransfer = { effectAllowed: '', dropEffect: '' }
  362. fireEvent.dragStart(one, { dataTransfer })
  363. fireDrag(two, 'drop', 180)
  364. await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) })
  365. } finally {
  366. warn.mockRestore()
  367. }
  368. })
  369. it('renames a workspace through the row menu dialog', async () => {
  370. let resolveRename!: () => void
  371. const renameWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveRename = resolve }))
  372. mount({
  373. useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])),
  374. renameWorkspace,
  375. })
  376. fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
  377. fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
  378. const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
  379. expect(input.value).toBe('Alpha')
  380. // Unchanged and blank names stay blocked.
  381. expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
  382. fireEvent.change(input, { target: { value: ' ' } })
  383. expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
  384. // A duplicate of another workspace's title shows the inline conflict.
  385. fireEvent.change(input, { target: { value: ' Beta ' } })
  386. expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
  387. expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
  388. fireEvent.change(input, { target: { value: 'Gamma' } })
  389. fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
  390. expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
  391. // While renaming: input disabled, close blocked, Enter ignored.
  392. expect(input.disabled).toBe(true)
  393. fireEvent.keyDown(document, { key: 'Escape' })
  394. expect(screen.getByRole('dialog')).toBeTruthy()
  395. await act(async () => { resolveRename() })
  396. expect(screen.queryByRole('dialog')).toBeNull()
  397. })
  398. it('rename via Enter, failure surfaces the error, Cancel closes', async () => {
  399. const renameWorkspace = vi.fn(async () => { throw new Error('rename conflict') })
  400. mount({
  401. useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
  402. renameWorkspace,
  403. })
  404. fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
  405. fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
  406. const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
  407. // Enter with a blocked draft (unchanged) does nothing.
  408. fireEvent.keyDown(input, { key: 'Enter' })
  409. expect(renameWorkspace).not.toHaveBeenCalled()
  410. fireEvent.change(input, { target: { value: 'Renamed' } })
  411. fireEvent.keyDown(input, { key: 'a' })
  412. fireEvent.keyDown(input, { key: 'Enter' })
  413. expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Renamed')
  414. await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('rename conflict') })
  415. // The dialog stays for retry; typing clears the error; Cancel closes.
  416. fireEvent.change(input, { target: { value: 'Renamed2' } })
  417. expect(screen.queryByRole('alert')).toBeNull()
  418. fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
  419. expect(screen.queryByRole('dialog')).toBeNull()
  420. })
  421. it('reports non-Error rename failures as text', async () => {
  422. const renameWorkspace = vi.fn(async () => { throw 'denied' })
  423. mount({
  424. useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
  425. renameWorkspace,
  426. })
  427. fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
  428. fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
  429. fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Other' } })
  430. fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
  431. await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
  432. })
  433. it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => {
  434. let resolveDelete!: () => void
  435. const deleteWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveDelete = resolve }))
  436. const browser = mount({
  437. useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])),
  438. deleteWorkspace,
  439. })
  440. fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
  441. fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
  442. const dialog = screen.getByRole('dialog', { name: 'Delete workspace' })
  443. expect(dialog.textContent).toContain('removes “Alpha” from the workspace list')
  444. expect(dialog.textContent).toContain('folder and session logs will be kept')
  445. expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
  446. const confirm = screen.getByRole<HTMLButtonElement>('button', { name: 'Delete workspace' })
  447. fireEvent.click(confirm)
  448. fireEvent.click(confirm)
  449. expect(deleteWorkspace).toHaveBeenCalledOnce()
  450. expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
  451. expect(confirm.disabled).toBe(true)
  452. expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Cancel' }).disabled).toBe(true)
  453. expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
  454. fireEvent.keyDown(document, { key: 'Escape' })
  455. fireEvent.click(screen.getByRole('button', { name: 'Close' }))
  456. expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
  457. await act(async () => { resolveDelete() })
  458. // RPC success alone does not close: the component waits until its
  459. // useWorkspaces projection has committed the removal, preventing a stale
  460. // duplicate-name frame from leaking into the next create gesture.
  461. expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
  462. rerender(browser, { useWorkspaces: hook(workspaceState([])) })
  463. expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
  464. })
  465. it('keeps the delete dialog open on failure and allows retry or cancellation', async () => {
  466. const deleteWorkspace = vi.fn()
  467. .mockRejectedValueOnce(new Error('storage unavailable'))
  468. .mockRejectedValueOnce('denied')
  469. mount({
  470. useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
  471. deleteWorkspace,
  472. })
  473. fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
  474. fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
  475. fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
  476. await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('storage unavailable') })
  477. expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
  478. fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
  479. await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
  480. fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
  481. expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
  482. })
  483. it('Cancel, Escape, and Close dismiss deletion without calling the action', () => {
  484. const deleteWorkspace = vi.fn(async () => {})
  485. mount({
  486. useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
  487. deleteWorkspace,
  488. })
  489. const open = () => {
  490. fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
  491. fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
  492. }
  493. open()
  494. fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
  495. open()
  496. fireEvent.keyDown(document, { key: 'Escape' })
  497. open()
  498. fireEvent.click(screen.getByRole('button', { name: 'Close' }))
  499. expect(deleteWorkspace).not.toHaveBeenCalled()
  500. expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
  501. })
  502. it('search hides drag affordances (rows are not draggable during search)', () => {
  503. const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })])
  504. mount({
  505. useSessions: hook(sessions),
  506. useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
  507. })
  508. fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } })
  509. const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
  510. expect(row.getAttribute('draggable')).toBe('false')
  511. })
  512. })