workspace-browser.client.spec.tsx 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248
  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-test-runtime'
  5. import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
  6. import type {
  7. WorkspaceId, WorkspaceSnapshot, WorkspaceView,
  8. } from '@deepseek-ai/dsh-api-workspace-controller/client'
  9. import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
  10. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  11. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  12. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  13. import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
  14. import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts'
  15. import { UNGROUPED_KEY } from '../src/client/tree.ts'
  16. import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
  17. import { zh } from '../src/client/locales.ts'
  18. afterEach(cleanup)
  19. beforeEach(() => { localStorage.clear(); createWorkspaceViewStore().create().actions.setOrderBy('manual') })
  20. // The seat's key domain is workspace ∪ common; the stub mirrors the real
  21. // lookup chain (namespace, then common vocabulary, then the key).
  22. const t: WorkspaceBrowserProps['t'] = makeTranslate(zh, commonZh)
  23. const sid = (id: string) => id as SessionId
  24. const wid = (id: string) => id as WorkspaceId
  25. const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
  26. id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides,
  27. })
  28. const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
  29. ids: items.map(item => item.id),
  30. byId: Object.fromEntries(items.map(item => [item.id, item])),
  31. current: undefined,
  32. phase: 'ready',
  33. subagentsByParent: {}, jobsBySession: {},
  34. currentAddress: undefined,
  35. ...overrides,
  36. })
  37. const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
  38. workspaceId: wid(id), path: `/projects/${id}`, title,
  39. sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
  40. })
  41. const workspaceState = (
  42. items: readonly WorkspaceView[],
  43. archivedSessionIds: readonly SessionId[] = [],
  44. ): WorkspaceSnapshot => ({ items, archivedSessionIds, state: 'idle', phase: 'ready', error: null })
  45. const noPendingInteraction: SessionPendingInteractionSnapshot = new Map()
  46. function hook<T>(snapshot: T) {
  47. return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
  48. }
  49. /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
  50. function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
  51. const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
  52. Object.defineProperty(event, 'clientY', { value: clientY })
  53. Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', dropEffect: '' } })
  54. fireEvent(row, event)
  55. }
  56. function dragData(): Pick<DataTransfer, 'effectAllowed' | 'dropEffect' | 'setData'> {
  57. return { effectAllowed: 'uninitialized', dropEffect: 'none', setData: vi.fn() }
  58. }
  59. function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
  60. const store = createWorkspaceViewStore().create()
  61. const props: WorkspaceBrowserProps = {
  62. wide: true,
  63. expandSidebar: vi.fn(),
  64. useSessions: hook(sessionState([])),
  65. useSessionPendingInteraction: hook(noPendingInteraction),
  66. useWorkspaces: hook(workspaceState([])),
  67. useStore: bindSnapshotSelector(store),
  68. actions: store.actions,
  69. startSession: vi.fn(),
  70. open: vi.fn(),
  71. searchSessions: vi.fn(async () => ({ items: [], hasMore: false })),
  72. searchResultLimit: 20,
  73. renameSession: vi.fn(async () => {}),
  74. forkSession: vi.fn(),
  75. renameWorkspace: vi.fn(async () => {}),
  76. deleteWorkspace: vi.fn(async () => {}),
  77. archiveSession: vi.fn(async () => {}),
  78. insertWorkspaceBefore: vi.fn(async () => {}),
  79. insertSessionBefore: vi.fn(async () => {}),
  80. createWorkspace: vi.fn(async () => workspace('created', [])),
  81. useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
  82. useHostDescription: selector => selector(undefined),
  83. renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
  84. t,
  85. ...overrides,
  86. }
  87. const view = render(<WorkspaceBrowser {...props} />)
  88. return { view, props, store }
  89. }
  90. /** Re-render with (possibly) changed props — WorkspaceBrowser has no side channel. */
  91. function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrowserProps>) {
  92. Object.assign(b.props, overrides)
  93. b.view.rerender(<WorkspaceBrowser {...b.props} />)
  94. }
  95. describe('WorkspaceBrowser', () => {
  96. it('workspace hover card shows a POSIX home descendant as ~', () => {
  97. vi.useFakeTimers()
  98. try {
  99. mount({
  100. useWorkspaces: hook(workspaceState([{
  101. ...workspace('project', []),
  102. path: '/home/u/Documents/project',
  103. title: 'Project',
  104. }])),
  105. useHostDescription: selector => selector({
  106. version: '0', cwd: '/tmp', attachedSessions: 0, home: '/home/u', canOpenPath: false,
  107. }),
  108. })
  109. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  110. act(() => { vi.advanceTimersByTime(500) })
  111. expect(screen.getByText('~/Documents/project')).toBeTruthy()
  112. } finally {
  113. vi.useRealTimers()
  114. }
  115. })
  116. it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => {
  117. const pending = {
  118. ...workspaceState([]),
  119. phase: 'pending' as const,
  120. state: 'loading' as const,
  121. }
  122. const b = mount({ useWorkspaces: hook(pending) })
  123. act(() => {
  124. b.store.actions.setGroupExpanded('deleted', true)
  125. b.store.actions.syncSessionOrderAccount('deleted', ['session'], { session: 1 })
  126. })
  127. expect(b.store.getSnapshot().groupExpansion).toEqual({ deleted: true })
  128. rerender(b, { useWorkspaces: hook(workspaceState([])) })
  129. await waitFor(() => {
  130. expect(b.store.getSnapshot().groupExpansion).toEqual({})
  131. expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({ [UNGROUPED_KEY]: [] })
  132. expect(b.store.getSnapshot().sessionUpdatedAtByAccount).toEqual({ [UNGROUPED_KEY]: {} })
  133. })
  134. })
  135. it('renders the grouped tree by default and switches to the flat list via Group by', () => {
  136. const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)])
  137. const b = mount({
  138. useSessions: hook(sessions),
  139. useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])),
  140. })
  141. expect(screen.getByText('工作区')).toBeTruthy()
  142. expect(screen.getByText('alpha')).toBeTruthy()
  143. // Sessions hidden while their group is folded.
  144. expect(screen.queryByText('alpha-s')).toBeNull()
  145. fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
  146. expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
  147. expect(screen.getByRole('separator')).toBeTruthy()
  148. expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
  149. '按工作区', '单列表', '手动排序', '最近更新',
  150. ])
  151. expect(screen.getByRole('menuitem', { name: '按工作区' }).querySelector('svg')).toBeTruthy()
  152. expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy()
  153. fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
  154. // Store-driven flip: title changes, rows flatten newest-first, headers gone.
  155. expect(b.store.getSnapshot().groupBy).toBe('flat')
  156. expect(screen.getByText('会话')).toBeTruthy()
  157. expect(screen.queryByText('alpha')).toBeNull()
  158. expect(screen.getByText('alpha-s')).toBeTruthy()
  159. expect(screen.getByText('beta-s')).toBeTruthy()
  160. // Back to workspace grouping through the same menu.
  161. fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
  162. expect(screen.getByRole('menuitem', { name: '手动排序' }).hasAttribute('disabled')).toBe(false)
  163. fireEvent.click(screen.getByRole('menuitem', { name: '按工作区' }))
  164. expect(b.store.getSnapshot().groupBy).toBe('workspace')
  165. expect(screen.getByText('工作区')).toBeTruthy()
  166. // Escape closes the menu without picking.
  167. fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
  168. fireEvent.keyDown(document, { key: 'Escape' })
  169. expect(screen.queryByRole('menu')).toBeNull()
  170. expect(b.store.getSnapshot().groupBy).toBe('workspace')
  171. })
  172. it('persists flat-list drag order locally and applies Last updated within that account', async () => {
  173. const insertSessionBefore = vi.fn(async () => {})
  174. const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
  175. const workspaces = workspaceState([
  176. workspace('alpha', ['one']),
  177. workspace('beta', ['two']),
  178. ])
  179. const b = mount({
  180. useSessions: hook(sessions),
  181. useWorkspaces: hook(workspaces),
  182. insertSessionBefore,
  183. })
  184. fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
  185. fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
  186. await waitFor(() => {
  187. expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
  188. .toEqual(['one', 'two', 'three'])
  189. })
  190. const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
  191. const three = screen.getByText('three').closest('[role="treeitem"]') as HTMLElement
  192. three.getBoundingClientRect = () => ({
  193. top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34,
  194. x: 0, y: 150, toJSON: () => ({}),
  195. })
  196. fireEvent.dragStart(one, { dataTransfer: dragData() })
  197. fireDrag(three, 'drop', 180)
  198. expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
  199. .toEqual(['two', 'three', 'one'])
  200. expect(insertSessionBefore).not.toHaveBeenCalled()
  201. fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
  202. fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
  203. await waitFor(() => {
  204. expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
  205. .toEqual(['one', 'two', 'three'])
  206. })
  207. fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
  208. fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' }))
  209. fireEvent.dragStart(one, { dataTransfer: dragData() })
  210. fireDrag(three, 'drop', 180)
  211. b.view.unmount()
  212. const restored = mount({ useSessions: hook(sessions), useWorkspaces: hook(workspaces) })
  213. expect(restored.store.getSnapshot().groupBy).toBe('flat')
  214. expect(restored.store.getSnapshot().orderBy).toBe('manual')
  215. expect(screen.getAllByRole('treeitem').map(row => row.textContent)).toEqual([
  216. expect.stringContaining('two'),
  217. expect.stringContaining('three'),
  218. expect.stringContaining('one'),
  219. ])
  220. })
  221. it('expands a group on click and opens a session row', () => {
  222. const open = vi.fn()
  223. mount({
  224. useSessions: hook(sessionState([summary('alpha-s', 1)])),
  225. useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
  226. open,
  227. })
  228. fireEvent.click(screen.getByText('alpha'))
  229. fireEvent.click(screen.getByText('alpha-s'))
  230. expect(open).toHaveBeenCalledWith(sid('alpha-s'))
  231. // Collapse hides the row again.
  232. fireEvent.click(screen.getByText('alpha'))
  233. expect(screen.queryByText('alpha-s')).toBeNull()
  234. })
  235. it('shows five sessions by default and clears transient show-all when the Workspace collapses', () => {
  236. const items = Array.from({ length: 7 }, (_, index) => summary(`session-${index + 1}`, 7 - index))
  237. const b = mount({
  238. useSessions: hook(sessionState(items)),
  239. useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])),
  240. })
  241. fireEvent.click(screen.getByText('alpha'))
  242. for (const item of items.slice(0, 5)) expect(screen.getByText(item.displayTitle)).toBeTruthy()
  243. expect(screen.queryByText('session-6')).toBeNull()
  244. expect(screen.queryByText('session-7')).toBeNull()
  245. fireEvent.click(screen.getByRole('button', { name: '展开其余 2 个会话' }))
  246. expect(screen.getByText('session-6')).toBeTruthy()
  247. expect(screen.getByText('session-7')).toBeTruthy()
  248. expect(screen.getByRole('button', { name: '收起' })).toBeTruthy()
  249. fireEvent.click(screen.getByText('alpha'))
  250. expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: false })
  251. fireEvent.click(screen.getByText('alpha'))
  252. expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true })
  253. expect(screen.queryByText('session-6')).toBeNull()
  254. expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy()
  255. })
  256. it('shares one editable order across modes and promotes only while Last updated is active', async () => {
  257. const initial = sessionState([summary('one', 3), summary('two', 2)])
  258. const b = mount({
  259. useSessions: hook(initial),
  260. useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])),
  261. })
  262. fireEvent.click(screen.getByText('alpha'))
  263. fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
  264. fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
  265. await waitFor(() => {
  266. const rows = screen.getAllByRole('treeitem').slice(1)
  267. expect(rows[0]?.textContent).toContain('one')
  268. expect(rows[1]?.textContent).toContain('two')
  269. })
  270. const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
  271. two.getBoundingClientRect = () => ({
  272. top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
  273. })
  274. fireEvent.dragStart(one, { dataTransfer: dragData() })
  275. fireDrag(two, 'drop', 180)
  276. expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
  277. fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
  278. fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' }))
  279. expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
  280. // User activity updates the timestamp baseline in Manual mode without
  281. // changing the shared visual order.
  282. const updated = sessionState([summary('one', 4), summary('two', 2)])
  283. rerender(b, { useSessions: hook(updated) })
  284. await waitFor(() => {
  285. expect(b.store.getSnapshot().sessionUpdatedAtByAccount.alpha).toEqual({ one: 4, two: 2 })
  286. })
  287. expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
  288. expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
  289. // Entering Last updated performs one complete recency sort.
  290. fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
  291. fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
  292. await waitFor(() => {
  293. expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['one', 'two'])
  294. expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('one')
  295. })
  296. // A later user activity timestamp promotes that Session once while the
  297. // mode remains active.
  298. const promoted = sessionState([summary('one', 4), summary('two', 5)])
  299. rerender(b, { useSessions: hook(promoted) })
  300. await waitFor(() => {
  301. expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
  302. expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
  303. })
  304. b.view.unmount()
  305. const restored = mount({
  306. useSessions: hook(promoted),
  307. useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])),
  308. })
  309. expect(restored.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
  310. expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
  311. })
  312. it('archives a session from the row menu and hides archived rows in both modes', async () => {
  313. const archiveSession = vi.fn(async () => {})
  314. const b = mount({
  315. useSessions: hook(sessionState([summary('kept-s', 2), summary('gone-s', 1)])),
  316. useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])])),
  317. archiveSession,
  318. })
  319. fireEvent.click(screen.getByText('alpha'))
  320. fireEvent.click(screen.getByRole('button', { name: '会话“gone-s”的操作' }))
  321. fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
  322. expect(archiveSession).toHaveBeenCalledWith(sid('gone-s'))
  323. // The archive-set echo hides the row in grouped and flat modes.
  324. rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], [sid('gone-s')])) })
  325. expect(screen.queryByText('gone-s')).toBeNull()
  326. fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
  327. fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
  328. expect(screen.getByText('kept-s')).toBeTruthy()
  329. expect(screen.queryByText('gone-s')).toBeNull()
  330. })
  331. it('logs and keeps the tree when the archive call rejects', async () => {
  332. const rejection = new Error('archive exploded')
  333. const archiveSession = vi.fn(async () => { throw rejection })
  334. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
  335. try {
  336. mount({
  337. useSessions: hook(sessionState([summary('alpha-s', 1)])),
  338. useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
  339. archiveSession,
  340. })
  341. fireEvent.click(screen.getByText('alpha'))
  342. fireEvent.click(screen.getByRole('button', { name: '会话“alpha-s”的操作' }))
  343. fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
  344. await Promise.resolve()
  345. await Promise.resolve()
  346. expect(warn).toHaveBeenCalledWith('session archive rejected:', rejection)
  347. expect(screen.getByText('alpha-s')).toBeTruthy()
  348. } finally {
  349. warn.mockRestore()
  350. }
  351. })
  352. it('renders a fork child as a top-level row without a session twist', () => {
  353. const parent = summary('parent-s', 2)
  354. const child = { ...summary('child-s', 1), parentId: parent.id }
  355. mount({
  356. useSessions: hook(sessionState([parent, child])),
  357. useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])),
  358. })
  359. fireEvent.click(screen.getByText('alpha'))
  360. expect(screen.getByText('child-s')).toBeTruthy()
  361. expect(screen.queryByRole('button', { name: /展开|收起/ })).toBeNull()
  362. expect(screen.getByText('child-s').closest('[role="treeitem"]')?.getAttribute('draggable')).toBe('true')
  363. })
  364. it('expands the target group before starting a session from its +', () => {
  365. const startSession = vi.fn()
  366. const b = mount({
  367. useSessions: hook(sessionState([summary('alpha-s', 1)])),
  368. useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
  369. startSession,
  370. })
  371. startSession.mockImplementation(() => {
  372. expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true })
  373. })
  374. expect(screen.queryByText('alpha-s')).toBeNull()
  375. fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' }))
  376. expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true })
  377. expect(screen.getByText('alpha-s')).toBeTruthy()
  378. expect(startSession).toHaveBeenCalledWith(wid('alpha'))
  379. })
  380. it('auto-expands the Ungrouped bucket for a loose current session; its header has no menu and its + is inert', () => {
  381. const startSession = vi.fn()
  382. mount({
  383. useSessions: hook(sessionState([summary('loose', 1)], { current: sid('loose') })),
  384. useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
  385. startSession,
  386. })
  387. // The loose session's group is UNGROUPED_KEY: expanded by the effect.
  388. expect(screen.getByText('loose')).toBeTruthy()
  389. expect(screen.queryByRole('button', { name: '工作区“未分组”的操作' })).toBeNull()
  390. fireEvent.click(screen.getByRole('button', { name: '在“未分组”中新建会话' }))
  391. expect(startSession).not.toHaveBeenCalled()
  392. })
  393. it('keeps an already-expanded group when the selection moves within it', () => {
  394. const first = sessionState([summary('a', 2), summary('b', 1)], { current: sid('a') })
  395. const b = mount({
  396. useSessions: hook(first),
  397. useWorkspaces: hook(workspaceState([workspace('alpha', ['a', 'b'])])),
  398. })
  399. expect(screen.getByText('a')).toBeTruthy()
  400. // Selection hop inside the same group: the effect re-runs and leaves the
  401. // expansion list unchanged (no duplicate key, group still open).
  402. rerender(b, { useSessions: hook({ ...first, current: sid('b') }) })
  403. expect(screen.getByText('b')).toBeTruthy()
  404. fireEvent.click(screen.getByText('alpha'))
  405. expect(screen.queryByText('b')).toBeNull()
  406. })
  407. it('shows only the current blank session as the localized New Session, excluded from search', () => {
  408. const currentBlank = summary('alpha-blank', 9, { blank: true })
  409. const staleBlank = summary('beta-blank', 8, { blank: true })
  410. const sessions = sessionState(
  411. [currentBlank, staleBlank],
  412. { current: currentBlank.id },
  413. )
  414. const b = mount({
  415. useSessions: hook(sessions),
  416. useWorkspaces: hook(workspaceState([
  417. workspace('alpha', ['alpha-blank']), workspace('beta', ['beta-blank']),
  418. ])),
  419. })
  420. expect(screen.getByText('新会话')).toBeTruthy()
  421. expect(screen.queryByText('alpha-blank')).toBeNull()
  422. expect(screen.queryByText('beta-blank')).toBeNull()
  423. rerender(b, { useSessions: hook({ ...sessions, current: staleBlank.id }) })
  424. expect(screen.getAllByText('新会话')).toHaveLength(1)
  425. b.store.actions.setGroupBy('flat')
  426. rerender(b, {})
  427. expect(screen.getAllByText('新会话')).toHaveLength(1)
  428. // Search excludes blank rows entirely — neither the canonical stored
  429. // title nor the localized display label participates in matching.
  430. fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'new session' } })
  431. expect(screen.queryByText('新会话')).toBeNull()
  432. fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: '新会话' } })
  433. expect(screen.queryByText('新会话')).toBeNull()
  434. })
  435. it('promotes the blank selected by New Session in its grouped and flat orders', async () => {
  436. const items = [
  437. summary('old', 100),
  438. summary('blank', 150, { blank: true }),
  439. summary('mid', 200),
  440. ]
  441. const startSession = vi.fn()
  442. const b = mount({
  443. useSessions: hook(sessionState(items)),
  444. useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
  445. startSession,
  446. })
  447. await waitFor(() => {
  448. expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'blank', 'mid'])
  449. })
  450. startSession.mockImplementation(() => {
  451. rerender(b, { useSessions: hook(sessionState(items, { current: sid('blank') })) })
  452. })
  453. fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' }))
  454. expect(startSession).toHaveBeenCalledWith(wid('alpha'))
  455. await waitFor(() => {
  456. expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid'])
  457. expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['blank'])
  458. })
  459. b.store.actions.setGroupBy('flat')
  460. await waitFor(() => {
  461. expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['blank', 'mid', 'old'])
  462. })
  463. })
  464. it('does not repeat blank promotion after a manual drag or the first prompt', async () => {
  465. const insertSessionBefore = vi.fn(async () => {})
  466. const b = mount({
  467. useSessions: hook(sessionState([
  468. summary('old', 100),
  469. summary('blank', 150, { blank: true }),
  470. summary('mid', 200),
  471. ], { current: sid('blank') })),
  472. useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
  473. insertSessionBefore,
  474. })
  475. await waitFor(() => {
  476. expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid'])
  477. })
  478. const blank = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement
  479. const mid = screen.getByText('mid').closest('[role="treeitem"]') as HTMLElement
  480. mid.getBoundingClientRect = () => ({
  481. top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
  482. })
  483. fireEvent.dragStart(blank, { dataTransfer: dragData() })
  484. fireDrag(mid, 'drop', 180)
  485. expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank'])
  486. expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('blank'), undefined)
  487. rerender(b, {
  488. useSessions: hook(sessionState([
  489. summary('old', 100),
  490. summary('blank', 150),
  491. summary('mid', 200),
  492. ], { current: sid('blank') })),
  493. })
  494. await waitFor(() => {
  495. expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank'])
  496. })
  497. })
  498. it('shows local metadata matches immediately, then clears back to the grouped tree', async () => {
  499. vi.useFakeTimers()
  500. try {
  501. const sessions = sessionState([
  502. summary('needle-row', 2, { displayTitle: 'Needle row' }),
  503. summary('other-row', 1, { displayTitle: 'Other row' }),
  504. ])
  505. mount({
  506. useSessions: hook(sessions),
  507. useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
  508. })
  509. fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
  510. const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
  511. fireEvent.change(input, { target: { value: 'needle' } })
  512. const resultTree = screen.getByRole('tree', { name: '搜索结果' })
  513. expect(screen.getByText('Needle row')).toBeTruthy()
  514. expect(screen.queryByText('Other row')).toBeNull()
  515. const status = screen.getByRole('status')
  516. expect(status.textContent).toBe('正在搜索会话历史…')
  517. expect(resultTree.contains(status)).toBe(false)
  518. fireEvent.change(input, { target: { value: 'zzz' } })
  519. await act(async () => { await vi.advanceTimersByTimeAsync(250) })
  520. expect(screen.getByText('无匹配会话')).toBeTruthy()
  521. fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
  522. expect(input.value).toBe('')
  523. expect(screen.getByRole('tree', { name: '会话' })).toBeTruthy()
  524. // Clicking the field row focuses the input (wide mode).
  525. fireEvent.click(input.parentElement as HTMLElement)
  526. expect(document.activeElement).toBe(input)
  527. } finally {
  528. vi.useRealTimers()
  529. }
  530. })
  531. it('collapses an empty search on outside click but keeps a non-empty query expanded', () => {
  532. mount()
  533. const search = screen.getByRole('button', { name: '搜索会话' })
  534. fireEvent.click(search)
  535. expect(search.getAttribute('aria-expanded')).toBe('true')
  536. fireEvent.click(document.body)
  537. expect(search.getAttribute('aria-expanded')).toBe('false')
  538. fireEvent.click(search)
  539. const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
  540. fireEvent.change(input, { target: { value: ' ' } })
  541. fireEvent.click(document.body)
  542. expect(search.getAttribute('aria-expanded')).toBe('false')
  543. fireEvent.click(search)
  544. fireEvent.change(input, { target: { value: 'kept' } })
  545. fireEvent.click(document.body)
  546. expect(search.getAttribute('aria-expanded')).toBe('true')
  547. expect(input.value).toBe('kept')
  548. })
  549. it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => {
  550. vi.useFakeTimers()
  551. try {
  552. const open = vi.fn()
  553. const searchSessions = vi.fn(async () => ({
  554. items: [{ sessionId: sid('body-hit'), snippet: '…the waterfall token appears here…' }],
  555. hasMore: true,
  556. }))
  557. mount({
  558. useSessions: hook(sessionState([
  559. summary('body-hit', 1, { displayTitle: 'Research notes' }),
  560. ])),
  561. useWorkspaces: hook(workspaceState([
  562. workspace('research', ['body-hit'], 'Research Workspace'),
  563. ])),
  564. open,
  565. searchSessions,
  566. })
  567. const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
  568. fireEvent.change(input, { target: { value: 'waterfall token' } })
  569. expect(screen.getByText('正在搜索会话历史…')).toBeTruthy()
  570. expect(screen.queryByText('Research notes')).toBeNull()
  571. await act(async () => { await vi.advanceTimersByTimeAsync(250) })
  572. expect(searchSessions).toHaveBeenCalledWith('waterfall token', expect.any(AbortSignal))
  573. expect(screen.getByText('Research notes')).toBeTruthy()
  574. expect(screen.getByText('Research Workspace')).toBeTruthy()
  575. expect(screen.getByText('…the waterfall token appears here…')).toBeTruthy()
  576. expect(screen.getByText('仅显示前 20 条结果,请缩小搜索范围。')).toBeTruthy()
  577. fireEvent.click(screen.getByRole('treeitem'))
  578. expect(open).toHaveBeenCalledWith(sid('body-hit'))
  579. expect(input.value).toBe('waterfall token')
  580. } finally {
  581. vi.useRealTimers()
  582. }
  583. })
  584. it('bounds programmatic search input to a schema-valid request without splitting an astral character', async () => {
  585. vi.useFakeTimers()
  586. try {
  587. const searchSessions = vi.fn(async () => ({ items: [], hasMore: false }))
  588. mount({ searchSessions })
  589. const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
  590. expect(input.maxLength).toBe(500)
  591. fireEvent.change(input, { target: { value: 'y'.repeat(501) } })
  592. expect(input.value).toBe('y'.repeat(500))
  593. const expected = `prefix${'x'.repeat(493)}`
  594. fireEvent.change(input, {
  595. target: { value: `prefix\0${'x'.repeat(493)}😀tail` },
  596. })
  597. expect(input.value).toBe(expected)
  598. expect(input.value.length).toBe(499)
  599. expect(input.value).not.toContain('\0')
  600. await act(async () => { await vi.advanceTimersByTimeAsync(250) })
  601. expect(searchSessions).toHaveBeenCalledOnce()
  602. expect(searchSessions).toHaveBeenCalledWith(expected, expect.any(AbortSignal))
  603. } finally {
  604. vi.useRealTimers()
  605. }
  606. })
  607. it('keeps local matches and shows a lightweight warning when Host search fails', async () => {
  608. vi.useFakeTimers()
  609. try {
  610. const searchSessions = vi.fn(async () => { throw new Error('index unavailable') })
  611. mount({
  612. useSessions: hook(sessionState([
  613. summary('local-hit', 1, { displayTitle: 'Needle title' }),
  614. ])),
  615. useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])),
  616. searchSessions,
  617. })
  618. fireEvent.change(screen.getByPlaceholderText('搜索会话…'), {
  619. target: { value: 'needle' },
  620. })
  621. expect(screen.getByText('Needle title')).toBeTruthy()
  622. await act(async () => { await vi.advanceTimersByTimeAsync(250) })
  623. expect(screen.getByText('Needle title')).toBeTruthy()
  624. expect(screen.getByText('内容搜索暂不可用,仅显示名称匹配。')).toBeTruthy()
  625. expect(screen.queryByText('无匹配会话')).toBeNull()
  626. } finally {
  627. vi.useRealTimers()
  628. }
  629. })
  630. it('aborts a superseded request and ignores its stale result', async () => {
  631. vi.useFakeTimers()
  632. try {
  633. let resolveFirst!: (value: {
  634. items: { sessionId: SessionId; snippet: string }[]
  635. hasMore: boolean
  636. }) => void
  637. const first = new Promise<{
  638. items: { sessionId: SessionId; snippet: string }[]
  639. hasMore: boolean
  640. }>((resolve) => { resolveFirst = resolve })
  641. const searchSessions = vi.fn((query: string, _signal: AbortSignal) => query === 'first'
  642. ? first
  643. : Promise.resolve({
  644. items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }],
  645. hasMore: false,
  646. }))
  647. mount({
  648. useSessions: hook(sessionState([
  649. summary('first-hit', 2, { displayTitle: 'Old result' }),
  650. summary('second-hit', 1, { displayTitle: 'Fresh result' }),
  651. ])),
  652. searchSessions,
  653. })
  654. const input = screen.getByPlaceholderText('搜索会话…')
  655. fireEvent.change(input, { target: { value: 'first' } })
  656. await act(async () => { await vi.advanceTimersByTimeAsync(250) })
  657. const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal
  658. expect(firstSignal.aborted).toBe(false)
  659. fireEvent.change(input, { target: { value: 'second' } })
  660. expect(firstSignal.aborted).toBe(true)
  661. await act(async () => { await vi.advanceTimersByTimeAsync(250) })
  662. expect(screen.getByText('Fresh result')).toBeTruthy()
  663. await act(async () => {
  664. resolveFirst({
  665. items: [{ sessionId: sid('first-hit'), snippet: 'stale excerpt' }],
  666. hasMore: false,
  667. })
  668. await Promise.resolve()
  669. })
  670. expect(screen.queryByText('Old result')).toBeNull()
  671. expect(screen.getByText('Fresh result')).toBeTruthy()
  672. } finally {
  673. vi.useRealTimers()
  674. }
  675. })
  676. it('ignores a rejected request after it has been superseded', async () => {
  677. vi.useFakeTimers()
  678. try {
  679. let rejectFirst!: (reason: Error) => void
  680. const first = new Promise<never>((_resolve, reject) => { rejectFirst = reject })
  681. const searchSessions = vi.fn((query: string) => query === 'first'
  682. ? first
  683. : Promise.resolve({ items: [], hasMore: false }))
  684. mount({ searchSessions })
  685. const input = screen.getByPlaceholderText('搜索会话…')
  686. fireEvent.change(input, { target: { value: 'first' } })
  687. await act(async () => { await vi.advanceTimersByTimeAsync(250) })
  688. fireEvent.change(input, { target: { value: 'second' } })
  689. await act(async () => {
  690. rejectFirst(new Error('stale failure'))
  691. await Promise.resolve()
  692. })
  693. expect(screen.queryByText('内容搜索暂不可用,仅显示名称匹配。')).toBeNull()
  694. } finally {
  695. vi.useRealTimers()
  696. }
  697. })
  698. it('shows the no-sessions empty state in both modes and resolves an empty search', async () => {
  699. vi.useFakeTimers()
  700. try {
  701. const b = mount()
  702. expect(screen.getByText('暂无会话')).toBeTruthy()
  703. b.store.actions.setGroupBy('flat')
  704. rerender(b, {})
  705. expect(screen.getByText('暂无会话')).toBeTruthy()
  706. fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'x' } })
  707. expect(screen.getByText('正在搜索会话历史…')).toBeTruthy()
  708. await act(async () => { await vi.advanceTimersByTimeAsync(250) })
  709. expect(screen.getByText('无匹配会话')).toBeTruthy()
  710. } finally {
  711. vi.useRealTimers()
  712. }
  713. })
  714. it('rail state renders icon controls that request expansion', () => {
  715. vi.useFakeTimers()
  716. try {
  717. const expandSidebar = vi.fn()
  718. const b = mount({ wide: false, expandSidebar })
  719. // No wide chrome in rail state.
  720. expect(screen.queryByText('工作区')).toBeNull()
  721. expect(screen.queryByPlaceholderText('搜索会话…')).toBeNull()
  722. fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
  723. expect(expandSidebar).toHaveBeenCalledTimes(1)
  724. // The wide flip mounts the input and focuses it after the slide.
  725. rerender(b, { wide: true })
  726. const input = screen.getByPlaceholderText('搜索会话…')
  727. act(() => { vi.advanceTimersByTime(300) })
  728. expect(document.activeElement).toBe(input)
  729. // Wide search button is decorative (tabIndex -1, no expand call).
  730. fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
  731. expect(expandSidebar).toHaveBeenCalledTimes(1)
  732. } finally {
  733. vi.useRealTimers()
  734. }
  735. })
  736. it('keeps the rail-opened search expanded when the initiating click reaches document', () => {
  737. vi.useFakeTimers()
  738. try {
  739. const b = mount({ wide: false })
  740. fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
  741. rerender(b, { wide: true })
  742. // In the browser the rail click keeps bubbling to document after the
  743. // wide flip mounted the outside-click listener, with the unmounted rail
  744. // button as its target — outside searchRoot. It must not dismiss the
  745. // search it just opened.
  746. fireEvent.click(document.body)
  747. expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('true')
  748. act(() => { vi.advanceTimersByTime(300) })
  749. expect(document.activeElement).toBe(screen.getByPlaceholderText('搜索会话…'))
  750. // The gesture has settled: outside clicks dismiss the search again.
  751. fireEvent.click(document.body)
  752. expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('false')
  753. } finally {
  754. vi.useRealTimers()
  755. }
  756. })
  757. it('rail add-workspace raises the directory flow in place, with no menu and no expansion', () => {
  758. const expandSidebar = vi.fn()
  759. mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
  760. fireEvent.click(screen.getByRole('button', { name: '添加工作区' }))
  761. expect(expandSidebar).not.toHaveBeenCalled()
  762. // Adding is the header's only action, so the gesture IS that action: no
  763. // one-row popover, and existing workspaces stay in the tree below.
  764. expect(screen.queryByRole('menu')).toBeNull()
  765. expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
  766. expect(screen.getByTestId('directory-flow')).toBeTruthy()
  767. })
  768. it('hides the add button when no directory-flow occupant is composed', () => {
  769. mount({
  770. useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
  771. useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => false, subscribe: () => () => {} }),
  772. })
  773. // Nothing to add with, so the header offers no dead button.
  774. expect(screen.queryByRole('button', { name: '添加工作区' })).toBeNull()
  775. expect(screen.getByText('alpha')).toBeTruthy()
  776. })
  777. it('uses the full expanded Workspace section when resolving a Workspace drop half', () => {
  778. const insertWorkspaceBefore = vi.fn(async () => {})
  779. const sessions = sessionState(Array.from({ length: 5 }, (_, index) => summary(`beta-${index}`, index)))
  780. mount({
  781. useSessions: hook(sessions),
  782. useWorkspaces: hook(workspaceState([
  783. workspace('alpha', []),
  784. workspace('beta', sessions.ids),
  785. workspace('tail', []),
  786. ])),
  787. insertWorkspaceBefore,
  788. })
  789. fireEvent.click(screen.getByText('beta'))
  790. const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement
  791. let targetSection = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement
  792. while (targetSection.parentElement?.getAttribute('role') !== 'tree') {
  793. targetSection = targetSection.parentElement as HTMLElement
  794. }
  795. targetSection.getBoundingClientRect = () => ({
  796. top: 100, bottom: 300, left: 0, right: 200, width: 200, height: 200, x: 0, y: 100, toJSON: () => ({}),
  797. })
  798. fireEvent.dragStart(source, { dataTransfer: dragData() })
  799. // y=190 is below the header row but still in the top half of the whole
  800. // expanded section, so the target is before beta rather than after it.
  801. fireDrag(targetSection, 'drop', 190)
  802. expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta'))
  803. })
  804. it('draws the first Workspace insertion boundary on the scroll container', () => {
  805. mount({
  806. useWorkspaces: hook(workspaceState([
  807. workspace('alpha', []),
  808. workspace('beta', []),
  809. ])),
  810. })
  811. const source = screen.getByText('beta').closest('[role="treeitem"]') as HTMLElement
  812. let firstSection = screen.getByText('alpha').closest('[role="treeitem"]')?.parentElement as HTMLElement
  813. while (firstSection.parentElement?.getAttribute('role') !== 'tree') {
  814. firstSection = firstSection.parentElement as HTMLElement
  815. }
  816. firstSection.getBoundingClientRect = () => ({
  817. top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
  818. })
  819. fireEvent.dragStart(source, { dataTransfer: dragData() })
  820. fireDrag(firstSection, 'dragOver', 105)
  821. expect(firstSection.parentElement?.className).toContain('listTopDropActive')
  822. const marker = firstSection.parentElement?.previousElementSibling
  823. expect(marker?.className).toContain('listTopDropIndicator')
  824. })
  825. it('accepts a document-level drop and commits the last Workspace marker on drag end', () => {
  826. const insertWorkspaceBefore = vi.fn(async () => {})
  827. mount({
  828. useWorkspaces: hook(workspaceState([
  829. workspace('alpha', []),
  830. workspace('beta', []),
  831. workspace('tail', []),
  832. ])),
  833. insertWorkspaceBefore,
  834. })
  835. const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement
  836. let target = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement
  837. while (target.parentElement?.getAttribute('role') !== 'tree') {
  838. target = target.parentElement as HTMLElement
  839. }
  840. target.getBoundingClientRect = () => ({
  841. top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
  842. })
  843. fireEvent.dragStart(source, { dataTransfer: dragData() })
  844. fireDrag(target, 'dragOver', 105)
  845. const outsideDrop = createEvent.drop(document.body)
  846. Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() })
  847. fireEvent(document.body, outsideDrop)
  848. expect(outsideDrop.defaultPrevented).toBe(true)
  849. fireEvent.dragEnd(source)
  850. expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta'))
  851. })
  852. it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {
  853. const insertSessionBefore = vi.fn(async () => {})
  854. const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
  855. mount({
  856. useSessions: hook(sessions),
  857. useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])),
  858. insertSessionBefore,
  859. })
  860. fireEvent.click(screen.getByText('alpha'))
  861. const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header
  862. const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
  863. three.getBoundingClientRect = () => ({
  864. top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
  865. })
  866. const dataTransfer = dragData()
  867. fireEvent.dragStart(one, { dataTransfer })
  868. // Drop on the top half of "three": insert one before three.
  869. fireDrag(three, 'dragOver', 205)
  870. fireDrag(three, 'drop', 205)
  871. expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three'))
  872. // Dropping right back onto its own position is a no-op — top half
  873. // (anchor = itself) and bottom half (anchor = the next root) alike.
  874. fireEvent.dragStart(one, { dataTransfer })
  875. one.getBoundingClientRect = () => ({
  876. top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
  877. })
  878. fireDrag(one, 'dragOver', 105)
  879. fireDrag(one, 'drop', 105)
  880. expect(insertSessionBefore).toHaveBeenCalledTimes(1)
  881. fireEvent.dragStart(one, { dataTransfer })
  882. fireDrag(one, 'drop', 130)
  883. expect(insertSessionBefore).toHaveBeenCalledTimes(1)
  884. })
  885. it('persists Ungrouped drag order in both modes without writing a Host Workspace account', async () => {
  886. const insertSessionBefore = vi.fn(async () => {})
  887. const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
  888. const b = mount({
  889. useSessions: hook(sessions),
  890. useWorkspaces: hook(workspaceState([])),
  891. insertSessionBefore,
  892. })
  893. fireEvent.click(screen.getByText('未分组'))
  894. const dragAfter = (sourceTitle: string, targetTitle: string): void => {
  895. const source = screen.getByText(sourceTitle).closest('[role="treeitem"]') as HTMLElement
  896. const target = screen.getByText(targetTitle).closest('[role="treeitem"]') as HTMLElement
  897. target.getBoundingClientRect = () => ({
  898. top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
  899. })
  900. fireEvent.dragStart(source, { dataTransfer: dragData() })
  901. fireDrag(target, 'drop', 180)
  902. }
  903. dragAfter('one', 'three')
  904. expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
  905. dragAfter('two', 'one')
  906. expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['three', 'one', 'two'])
  907. expect(insertSessionBefore).not.toHaveBeenCalled()
  908. fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
  909. fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
  910. await waitFor(() => {
  911. expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['one', 'two', 'three'])
  912. })
  913. dragAfter('one', 'three')
  914. expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
  915. expect(insertSessionBefore).not.toHaveBeenCalled()
  916. b.view.unmount()
  917. const restored = mount({
  918. useSessions: hook(sessions),
  919. useWorkspaces: hook(workspaceState([])),
  920. insertSessionBefore,
  921. })
  922. expect(restored.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
  923. expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([
  924. expect.stringContaining('two'),
  925. expect.stringContaining('three'),
  926. expect.stringContaining('one'),
  927. ])
  928. })
  929. it('still sends the reorder when the dragged row left the group mid-drag', () => {
  930. const insertSessionBefore = vi.fn(async () => {})
  931. const sessions = sessionState([summary('one', 2), summary('two', 1)])
  932. const b = mount({
  933. useSessions: hook(sessions),
  934. useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
  935. insertSessionBefore,
  936. })
  937. fireEvent.click(screen.getByText('alpha'))
  938. const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
  939. fireEvent.dragStart(one, { dataTransfer: dragData() })
  940. // The host dropped "one" from the workspace account while the drag is in
  941. // flight: the source index is gone but the drop still resolves its anchor.
  942. rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) })
  943. const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
  944. two.getBoundingClientRect = () => ({
  945. top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
  946. })
  947. fireDrag(two, 'drop', 155)
  948. expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
  949. })
  950. it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => {
  951. const insertSessionBefore = vi.fn(async () => {})
  952. const sessions = sessionState([summary('one', 2), summary('two', 1)])
  953. mount({
  954. useSessions: hook(sessions),
  955. useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
  956. insertSessionBefore,
  957. })
  958. fireEvent.click(screen.getByText('alpha'))
  959. const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
  960. two.getBoundingClientRect = () => ({
  961. top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
  962. })
  963. const dataTransfer = dragData()
  964. fireEvent.dragStart(one, { dataTransfer })
  965. fireEvent.dragEnd(one)
  966. // The drag ended: rows no longer accept drops.
  967. fireDrag(two, 'drop', 180)
  968. expect(insertSessionBefore).not.toHaveBeenCalled()
  969. // Bottom half of the last row: append (anchor omitted).
  970. fireEvent.dragStart(one, { dataTransfer })
  971. fireDrag(two, 'dragOver', 180)
  972. fireDrag(two, 'drop', 180)
  973. expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
  974. })
  975. it('accepts a document-level drop and commits the last Session marker on drag end', () => {
  976. const insertSessionBefore = vi.fn(async () => {})
  977. mount({
  978. useSessions: hook(sessionState([summary('one', 2), summary('two', 1)])),
  979. useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
  980. insertSessionBefore,
  981. })
  982. fireEvent.click(screen.getByText('alpha'))
  983. const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
  984. two.getBoundingClientRect = () => ({
  985. top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
  986. })
  987. fireEvent.dragStart(one, { dataTransfer: dragData() })
  988. fireDrag(two, 'dragOver', 180)
  989. const outsideDrop = createEvent.drop(document.body)
  990. Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() })
  991. fireEvent(document.body, outsideDrop)
  992. expect(outsideDrop.defaultPrevented).toBe(true)
  993. fireEvent.dragEnd(one)
  994. expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
  995. })
  996. it('logs and keeps the order when the reorder call rejects', async () => {
  997. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
  998. try {
  999. const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') })
  1000. const sessions = sessionState([summary('one', 2), summary('two', 1)])
  1001. mount({
  1002. useSessions: hook(sessions),
  1003. useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
  1004. insertSessionBefore,
  1005. })
  1006. fireEvent.click(screen.getByText('alpha'))
  1007. const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
  1008. two.getBoundingClientRect = () => ({
  1009. top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
  1010. })
  1011. const dataTransfer = dragData()
  1012. fireEvent.dragStart(one, { dataTransfer })
  1013. fireDrag(two, 'drop', 180)
  1014. await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) })
  1015. } finally {
  1016. warn.mockRestore()
  1017. }
  1018. })
  1019. it('renames a workspace through the row menu dialog', async () => {
  1020. let resolveRename!: () => void
  1021. const renameWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveRename = resolve }))
  1022. mount({
  1023. useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])),
  1024. renameWorkspace,
  1025. })
  1026. fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
  1027. fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
  1028. const input = screen.getByLabelText<HTMLInputElement>('工作区名称')
  1029. expect(input.value).toBe('Alpha')
  1030. // Unchanged and blank names stay blocked.
  1031. expect(screen.getByRole<HTMLButtonElement>('button', { name: '重命名' }).disabled).toBe(true)
  1032. fireEvent.change(input, { target: { value: ' ' } })
  1033. expect(screen.getByRole<HTMLButtonElement>('button', { name: '重命名' }).disabled).toBe(true)
  1034. // A duplicate of another workspace's title shows the inline conflict.
  1035. fireEvent.change(input, { target: { value: ' Beta ' } })
  1036. expect(screen.getByRole('alert').textContent).toBe('已存在名为“Beta”的工作区。')
  1037. expect(screen.getByRole<HTMLButtonElement>('button', { name: '重命名' }).disabled).toBe(true)
  1038. fireEvent.change(input, { target: { value: 'Gamma' } })
  1039. fireEvent.click(screen.getByRole('button', { name: '重命名' }))
  1040. expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
  1041. // While renaming: input disabled, close blocked, Enter ignored.
  1042. expect(input.disabled).toBe(true)
  1043. fireEvent.keyDown(document, { key: 'Escape' })
  1044. expect(screen.getByRole('dialog')).toBeTruthy()
  1045. await act(async () => { resolveRename() })
  1046. expect(screen.queryByRole('dialog')).toBeNull()
  1047. })
  1048. it('rename via Enter, failure surfaces the error, Cancel closes', async () => {
  1049. const renameWorkspace = vi.fn(async () => { throw new Error('rename conflict') })
  1050. mount({
  1051. useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
  1052. renameWorkspace,
  1053. })
  1054. fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
  1055. fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
  1056. const input = screen.getByLabelText<HTMLInputElement>('工作区名称')
  1057. // Enter with a blocked draft (unchanged) does nothing.
  1058. fireEvent.keyDown(input, { key: 'Enter' })
  1059. expect(renameWorkspace).not.toHaveBeenCalled()
  1060. fireEvent.change(input, { target: { value: 'Renamed' } })
  1061. fireEvent.keyDown(input, { key: 'a' })
  1062. fireEvent.keyDown(input, { key: 'Enter' })
  1063. expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Renamed')
  1064. await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('rename conflict') })
  1065. // The dialog stays for retry; typing clears the error; Cancel closes.
  1066. fireEvent.change(input, { target: { value: 'Renamed2' } })
  1067. expect(screen.queryByRole('alert')).toBeNull()
  1068. fireEvent.click(screen.getByRole('button', { name: '取消' }))
  1069. expect(screen.queryByRole('dialog')).toBeNull()
  1070. })
  1071. it('reports non-Error rename failures as text', async () => {
  1072. const renameWorkspace = vi.fn(async () => { throw 'denied' })
  1073. mount({
  1074. useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
  1075. renameWorkspace,
  1076. })
  1077. fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
  1078. fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
  1079. fireEvent.change(screen.getByLabelText('工作区名称'), { target: { value: 'Other' } })
  1080. fireEvent.click(screen.getByRole('button', { name: '重命名' }))
  1081. await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
  1082. })
  1083. it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => {
  1084. let resolveDelete!: () => void
  1085. const deleteWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveDelete = resolve }))
  1086. const browser = mount({
  1087. useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])),
  1088. deleteWorkspace,
  1089. })
  1090. fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
  1091. fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
  1092. const dialog = screen.getByRole('dialog', { name: '删除工作区' })
  1093. expect(dialog.textContent).toContain('将把“Alpha”从工作区列表中移除')
  1094. expect(dialog.textContent).toContain('文件夹与会话记录会保留')
  1095. expect(dialog.textContent).toContain('其会话将显示在“未分组”下')
  1096. const confirm = screen.getByRole<HTMLButtonElement>('button', { name: '删除工作区' })
  1097. fireEvent.click(confirm)
  1098. fireEvent.click(confirm)
  1099. expect(deleteWorkspace).toHaveBeenCalledOnce()
  1100. expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
  1101. expect(confirm.disabled).toBe(true)
  1102. expect(screen.getByRole<HTMLButtonElement>('button', { name: '取消' }).disabled).toBe(true)
  1103. expect(screen.getByRole('status').textContent).toBe('正在删除工作区…')
  1104. fireEvent.keyDown(document, { key: 'Escape' })
  1105. fireEvent.click(screen.getByRole('button', { name: '关闭' }))
  1106. expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
  1107. await act(async () => { resolveDelete() })
  1108. // RPC success alone does not close: the component waits until its
  1109. // useWorkspaces projection has committed the removal, preventing a stale
  1110. // Workspace frame from leaking into the next gesture.
  1111. expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
  1112. rerender(browser, { useWorkspaces: hook(workspaceState([])) })
  1113. expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()
  1114. })
  1115. it('keeps the delete dialog open on failure and allows retry or cancellation', async () => {
  1116. const deleteWorkspace = vi.fn()
  1117. .mockRejectedValueOnce(new Error('storage unavailable'))
  1118. .mockRejectedValueOnce('denied')
  1119. mount({
  1120. useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
  1121. deleteWorkspace,
  1122. })
  1123. fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
  1124. fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
  1125. fireEvent.click(screen.getByRole('button', { name: '删除工作区' }))
  1126. await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('storage unavailable') })
  1127. expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
  1128. fireEvent.click(screen.getByRole('button', { name: '删除工作区' }))
  1129. await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
  1130. fireEvent.click(screen.getByRole('button', { name: '取消' }))
  1131. expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()
  1132. })
  1133. it('Cancel, Escape, and Close dismiss deletion without calling the action', () => {
  1134. const deleteWorkspace = vi.fn(async () => {})
  1135. mount({
  1136. useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
  1137. deleteWorkspace,
  1138. })
  1139. const open = () => {
  1140. fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
  1141. fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
  1142. }
  1143. open()
  1144. fireEvent.click(screen.getByRole('button', { name: '取消' }))
  1145. open()
  1146. fireEvent.keyDown(document, { key: 'Escape' })
  1147. open()
  1148. fireEvent.click(screen.getByRole('button', { name: '关闭' }))
  1149. expect(deleteWorkspace).not.toHaveBeenCalled()
  1150. expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()
  1151. })
  1152. it('search hides drag affordances (rows are not draggable during search)', () => {
  1153. const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })])
  1154. mount({
  1155. useSessions: hook(sessions),
  1156. useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
  1157. })
  1158. fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'needle' } })
  1159. const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
  1160. expect(row.hasAttribute('draggable')).toBe(false)
  1161. })
  1162. })