workspace-browser.client.spec.tsx 70 KB

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