tree.client.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  1. import { describe, expect, it } from 'vitest'
  2. import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
  3. import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
  4. import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-session/client'
  5. import type { ScheduleId, ScheduleRecord } from '@deepseek-ai/dsh-schedule/client'
  6. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  7. import {
  8. deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey, owningParentFolder,
  9. pinCurrentBlank, reconcileManualOrder, visibleSessionIds, workspaceLabel, UNGROUPED_KEY,
  10. } from '../src/client/tree.ts'
  11. import { createWorkspaceViewStore } from '../src/client/stores.ts'
  12. const sid = (id: string) => id as SessionId
  13. const wid = (id: string) => id as WorkspaceId
  14. const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
  15. id: sid(id), displayTitle: id, running: false, blank: false,
  16. updatedAt, ...(cwd === undefined ? {} : { cwd }),
  17. })
  18. const list = (...items: SessionSummary[]): SessionListState => ({
  19. ids: items.map(item => item.id),
  20. byId: Object.fromEntries(items.map(item => [item.id, item])),
  21. current: undefined,
  22. phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
  23. })
  24. const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
  25. workspaceId: wid(id), path: `/projects/${id}`, title,
  26. sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
  27. })
  28. const view = (expandedGroups: readonly string[] = [], ungroupedOrder?: readonly string[]) => ({
  29. expandedGroups,
  30. ...(ungroupedOrder === undefined ? {} : { ungroupedOrder }),
  31. })
  32. const noArchive: readonly SessionId[] = []
  33. const noAttention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map()
  34. const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
  35. const schedule = (id: string, scheduledAt: string): ScheduleRecord => ({
  36. id: id as ScheduleId,
  37. kind: 'at',
  38. prompt: id,
  39. scheduledAt,
  40. })
  41. describe('owningGroupKey', () => {
  42. it('returns the owning Workspace id or the Ungrouped key', () => {
  43. const workspaces = [workspace('first', ['owned'])]
  44. expect(owningGroupKey(workspaces, sid('owned'))).toBe('first')
  45. expect(owningGroupKey(workspaces, sid('loose'))).toBe(UNGROUPED_KEY)
  46. })
  47. })
  48. describe('Session ordering', () => {
  49. it('orders known members by recency with a stable identity tie-break', () => {
  50. const summaries = list(summary('tie-b', 20), summary('older', 10), summary('tie-a', 20)).byId
  51. expect(orderByRecency(
  52. [sid('unknown'), sid('tie-b'), sid('older'), sid('tie-a')],
  53. summaries,
  54. )).toEqual([sid('tie-a'), sid('tie-b'), sid('older')])
  55. })
  56. it('reconciles retained manual slots and appends newly known members by recency', () => {
  57. const summaries = list(summary('kept', 1), summary('newer', 30), summary('older', 20)).byId
  58. expect(reconcileManualOrder(
  59. [sid('saved-without-summary'), sid('kept'), sid('newer'), sid('older'), sid('new-without-summary')],
  60. ['departed', 'saved-without-summary', 'kept'],
  61. summaries,
  62. )).toEqual([
  63. sid('saved-without-summary'), sid('kept'), sid('newer'), sid('older'),
  64. ])
  65. })
  66. it('pins only the selected blank without changing the base order', () => {
  67. expect(pinCurrentBlank([sid('newer'), sid('blank'), sid('older')], sid('blank')))
  68. .toEqual([sid('blank'), sid('newer'), sid('older')])
  69. expect(pinCurrentBlank([sid('newer'), sid('older')], undefined))
  70. .toEqual([sid('newer'), sid('older')])
  71. })
  72. })
  73. describe('deriveGroups', () => {
  74. it('keeps caller-supplied Workspace and sessionIds order', () => {
  75. const sessions = list(summary('newer', 20), summary('older', 10))
  76. const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
  77. const groups = deriveGroups(sessions, workspaces, noArchive, noAttention, view(['first']))
  78. expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
  79. expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
  80. })
  81. it('projects pending-interaction state into grouped and flat rows', () => {
  82. const awaiting = { ...summary('awaiting', 10), running: true }
  83. const sessions = list(awaiting)
  84. const attention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map([[
  85. awaiting.id,
  86. { key: 'question:1', kind: 'plan-review', sessionId: awaiting.id },
  87. ]])
  88. const grouped = deriveGroups(
  89. sessions, [workspace('project', ['awaiting'])], noArchive, attention, view(['project']),
  90. )
  91. expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
  92. expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), attention)[0])
  93. .toMatchObject({ pendingInteraction: 'plan-review', running: true })
  94. })
  95. it.each(['approval', 'question'] as const)(
  96. 'projects the %s pending-interaction kind',
  97. (kind) => {
  98. const awaiting = summary(kind, 10)
  99. const attention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map([[
  100. awaiting.id,
  101. { key: `${kind}:1`, kind, sessionId: awaiting.id },
  102. ]])
  103. expect(deriveFlat(list(awaiting), [awaiting.id], attention)[0]?.pendingInteraction).toBe(kind)
  104. },
  105. )
  106. it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
  107. const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
  108. const groups = deriveGroups(
  109. sessions, [workspace('first', ['owned'])], noArchive, noAttention, view([UNGROUPED_KEY]),
  110. )
  111. expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
  112. expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
  113. })
  114. it('applies stored Ungrouped order and appends new loose Sessions by recency', () => {
  115. const sessions = list(summary('one', 3), summary('two', 2), summary('new', 4))
  116. const groups = deriveGroups(
  117. sessions,
  118. [],
  119. noArchive,
  120. noAttention,
  121. view([UNGROUPED_KEY], ['two', 'stale', 'two']),
  122. )
  123. expect(groups[0]!.sessions.map(session => session.id)).toEqual([
  124. sid('two'), sid('new'), sid('one'),
  125. ])
  126. })
  127. it('shows only the current blank session in its Workspace count and tree', () => {
  128. const currentBlank = { ...summary('current-blank', 5), blank: true }
  129. const staleBlank = { ...summary('stale-blank', 4), blank: true }
  130. const real = summary('shown', 3)
  131. const sessions = {
  132. ...list(real, currentBlank, staleBlank),
  133. current: currentBlank.id,
  134. }
  135. const groups = deriveGroups(
  136. sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])],
  137. noArchive, noAttention, view(['first']),
  138. )
  139. expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
  140. const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
  141. // The stored placeholder title stays canonical; the renderer swaps in
  142. // the localized New Session label via the blank flag.
  143. expect(blankNode.title).toBe('')
  144. expect(blankNode.blank).toBe(true)
  145. expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false)
  146. expect(groups[0]!.sessionCount).toBe(2)
  147. // A non-current blank stray never surfaces an Ungrouped bucket either.
  148. const strayGroups = deriveGroups(
  149. list({ ...summary('stray', 2), blank: true }),
  150. [workspace('first', [])], noArchive, noAttention, view(),
  151. )
  152. expect(strayGroups.map(group => group.key)).toEqual(['first'])
  153. })
  154. it('projects the completion reminder into session and search rows (absent = false)', () => {
  155. const done = { ...summary('done', 3), completed: true }
  156. const plain = summary('plain', 2)
  157. const sessions = list(done, plain)
  158. const groups = deriveGroups(
  159. sessions, [workspace('first', ['done', 'plain'])], noArchive, noAttention, view(['first']),
  160. )
  161. const doneNode = groups[0]!.sessions.find(session => session.id === done.id)!
  162. const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)!
  163. expect(doneNode.completed).toBe(true)
  164. expect(plainNode.completed).toBe(false)
  165. expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
  166. .find(node => node.id === done.id)!.completed).toBe(true)
  167. const search = deriveSearchResults(
  168. sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive,
  169. noAttention, { items: [], hasMore: false }, 10,
  170. )
  171. expect(search.items[0]?.completed).toBe(true)
  172. })
  173. it('derives one active-Schedule fact for grouped, flat, and search rows', () => {
  174. const absent = summary('absent', 4)
  175. const empty = { ...summary('empty', 3), projectionValues: { schedule: [] } }
  176. const future = {
  177. ...summary('future', 2),
  178. projectionValues: { schedule: [schedule('future', '2099-01-01T00:00:00.000Z')] },
  179. }
  180. const overdue = {
  181. ...summary('overdue', 1),
  182. projectionValues: { schedule: [schedule('overdue', '2000-01-01T00:00:00.000Z')] },
  183. }
  184. const sessions = list(absent, empty, future, overdue)
  185. const workspaces = [workspace('project', ['absent', 'empty', 'future', 'overdue'], 'Project')]
  186. const expected = [
  187. [sid('absent'), false],
  188. [sid('empty'), false],
  189. [sid('future'), true],
  190. [sid('overdue'), true],
  191. ]
  192. expect(deriveGroups(
  193. sessions, workspaces, noArchive, noAttention, view(['project']),
  194. )[0]!.sessions.map(node => [node.id, node.hasActiveSchedule])).toEqual(expected)
  195. expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
  196. .map(node => [node.id, node.hasActiveSchedule])).toEqual(expected)
  197. expect(deriveSearchResults(
  198. sessions, workspaces, 'project', noArchive, noAttention, { items: [], hasMore: false }, 10,
  199. ).items.map(node => [node.id, node.hasActiveSchedule])).toEqual(expected)
  200. })
  201. it('hides subagent-origin sessions without hiding ordinary forks', () => {
  202. const parent = summary('parent', 1)
  203. const subagent = {
  204. ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const, running: true,
  205. }
  206. const grandchild = {
  207. ...summary('grandchild', 4), parentId: subagent.id, origin: 'subagent' as const, running: true,
  208. }
  209. const fork = { ...summary('fork', 2), parentId: subagent.id }
  210. const forkChild = {
  211. ...summary('fork-child', 5), parentId: fork.id, origin: 'subagent' as const, running: true,
  212. }
  213. const sessions = { ...list(parent, fork, subagent, grandchild, forkChild), current: subagent.id }
  214. const groups = deriveGroups(
  215. sessions,
  216. [workspace('first', ['parent', 'fork', 'subagent', 'grandchild', 'fork-child'])],
  217. noArchive,
  218. noAttention,
  219. view(['first']),
  220. )
  221. expect(groups[0]!.sessions.map(node => node.id)).toEqual([parent.id, fork.id])
  222. expect(groups[0]!.sessionCount).toBe(2)
  223. expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 })
  224. expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 })
  225. expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
  226. .map(node => [node.id, node.runningSubagentCount])).toEqual([
  227. [parent.id, 2], [fork.id, 1],
  228. ])
  229. expect(deriveSearchResults(
  230. sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive,
  231. noAttention, { items: [], hasMore: false }, 10,
  232. ).items[0]).toMatchObject({ id: parent.id, runningSubagentCount: 2 })
  233. })
  234. it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
  235. const parent = summary('parent', 1)
  236. const oldChild = { ...summary('old-child', 10), parentId: parent.id }
  237. const newChild = { ...summary('new-child', 20), parentId: parent.id }
  238. const tieB = { ...summary('tie-b', 20), parentId: parent.id }
  239. const tieA = { ...summary('tie-a', 20), parentId: parent.id }
  240. const self = { ...summary('self', 2), parentId: sid('self') }
  241. const orphan = { ...summary('orphan', 3), parentId: sid('missing') }
  242. const cycleA = { ...summary('cycle-a', 4), parentId: sid('cycle-b') }
  243. const cycleB = { ...summary('cycle-b', 5), parentId: sid('cycle-a') }
  244. const groups = deriveGroups(
  245. list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
  246. [],
  247. noArchive,
  248. noAttention,
  249. { expandedGroups: [UNGROUPED_KEY] },
  250. )
  251. expect(groups).toHaveLength(1)
  252. expect(groups[0]!.sessions.map(node => node.id)).toEqual([
  253. newChild.id, tieA.id, tieB.id, oldChild.id,
  254. cycleB.id, cycleA.id, orphan.id, self.id, parent.id,
  255. ])
  256. // Equal timestamps use ids as a deterministic tiebreak in either input order.
  257. expect(deriveGroups(
  258. list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, noAttention, view([UNGROUPED_KEY]),
  259. )[0]!
  260. .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
  261. })
  262. it('tolerates Workspace membership arriving before its Session summary', () => {
  263. const partial: SessionListState = {
  264. ...list(),
  265. ids: [sid('present')],
  266. byId: { [sid('present')]: summary('present', 1) },
  267. }
  268. const groups = deriveGroups(
  269. partial, [workspace('project', ['missing', 'present'])], noArchive, noAttention, view(['project']),
  270. )
  271. expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
  272. })
  273. it('hides archived sessions from workspace groups and Ungrouped', () => {
  274. const kept = summary('kept', 1, '/projects/first')
  275. const gone = summary('gone', 2, '/projects/first')
  276. const looseGone = summary('loose-gone', 3, '/other')
  277. const sessions = list(kept, gone, looseGone)
  278. const groups = deriveGroups(
  279. sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'),
  280. noAttention, view(['first', UNGROUPED_KEY]),
  281. )
  282. // The archived member drops from its group AND the archived stray never
  283. // surfaces an Ungrouped bucket; counts follow the visible rows.
  284. expect(groups.map(group => group.key)).toEqual(['first'])
  285. expect(groups[0]!.sessions.map(node => node.id)).toEqual([kept.id])
  286. expect(groups[0]!.sessionCount).toBe(1)
  287. })
  288. it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
  289. const owned = summary('owned', 1)
  290. const loose = summary('loose', 2)
  291. const ws = workspace('project', ['owned'])
  292. const ownedGroups = deriveGroups(
  293. { ...list(owned, loose), current: owned.id }, [ws], noArchive, noAttention, view(),
  294. )
  295. expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
  296. const looseGroups = deriveGroups(
  297. { ...list(owned, loose), current: loose.id }, [ws], noArchive, noAttention, view(),
  298. )
  299. expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
  300. })
  301. })
  302. describe('deriveFlat', () => {
  303. it('renders ordinary forks in the supplied order regardless of timestamps', () => {
  304. const parent = summary('parent', 10)
  305. const child = { ...summary('child', 30), parentId: parent.id }
  306. const tieB = summary('tie-b', 20)
  307. const tieA = summary('tie-a', 20)
  308. const rows = deriveFlat(list(parent, child, tieB, tieA), [parent.id, tieA.id, child.id, tieB.id], noAttention)
  309. expect(rows.map(row => row.id)).toEqual([parent.id, tieA.id, child.id, tieB.id])
  310. })
  311. it('hides subagent-origin rows but keeps ordinary forks', () => {
  312. const parent = summary('parent', 1)
  313. const fork = { ...summary('fork', 2), parentId: parent.id }
  314. const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const }
  315. const ids = visibleSessionIds({ ...list(parent, fork, subagent), current: subagent.id }, noArchive)
  316. expect(ids).toEqual([parent.id, fork.id])
  317. })
  318. it('tolerates ids whose summary has not landed yet', () => {
  319. const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
  320. expect(visibleSessionIds(partial, noArchive)).toEqual([sid('present')])
  321. })
  322. it('shows only the current blank session and excludes blanks from search', () => {
  323. const currentBlank = { ...summary('current-blank', 9), blank: true }
  324. const staleBlank = { ...summary('stale-blank', 8), blank: true }
  325. const sessions = {
  326. ...list(currentBlank, summary('real', 1), staleBlank),
  327. current: currentBlank.id,
  328. }
  329. const rows = deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
  330. expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
  331. expect(rows.map(row => row.title)).toEqual(['', 'real'])
  332. expect(rows.map(row => row.blank)).toEqual([true, false])
  333. })
  334. it('hides archived sessions in flat mode', () => {
  335. const kept = summary('kept', 1)
  336. const gone = summary('gone', 2)
  337. expect(visibleSessionIds(list(kept, gone), archived('gone'))).toEqual([kept.id])
  338. })
  339. })
  340. describe('deriveSearchResults archive filtering', () => {
  341. it('archived sessions never match — not by title and not via a backend content hit', () => {
  342. const hit = summary('hit', 2)
  343. hit.displayTitle = 'Needle row'
  344. const gone = summary('gone', 1)
  345. gone.displayTitle = 'Needle archived'
  346. const result = deriveSearchResults(
  347. list(hit, gone),
  348. [],
  349. 'needle',
  350. archived('gone'),
  351. noAttention,
  352. { items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false },
  353. 10,
  354. )
  355. expect(result.items.map(item => item.id)).toEqual([hit.id])
  356. })
  357. })
  358. describe('deriveSearchResults', () => {
  359. it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
  360. const titleHit = summary('title-hit', 30, '/projects/a')
  361. titleHit.displayTitle = 'Needle title'
  362. const workspaceHit = summary('workspace-hit', 20, '/projects/b')
  363. workspaceHit.displayTitle = 'Ordinary title'
  364. const contentHit = summary('content-hit', 10, '/projects/c')
  365. const sessions = list(titleHit, workspaceHit, contentHit)
  366. const result = deriveSearchResults(
  367. sessions,
  368. [
  369. workspace('a', ['title-hit'], 'Alpha'),
  370. workspace('b', ['workspace-hit'], 'Needle Workspace'),
  371. workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'),
  372. ],
  373. ' NEEDLE ',
  374. noArchive,
  375. new Map([[titleHit.id, {
  376. key: 'question:1', kind: 'plan-review', sessionId: titleHit.id,
  377. }]]),
  378. {
  379. items: [
  380. { sessionId: contentHit.id, snippet: 'body needle excerpt' },
  381. { sessionId: contentHit.id, snippet: 'ignored duplicate excerpt' },
  382. { sessionId: titleHit.id, snippet: 'title session body excerpt' },
  383. { sessionId: sid('unknown'), snippet: 'not in session.list' },
  384. ],
  385. hasMore: false,
  386. },
  387. 10,
  388. )
  389. expect(result).toEqual({
  390. items: [
  391. {
  392. id: titleHit.id,
  393. title: 'Needle title',
  394. workspace: 'Alpha',
  395. running: false,
  396. runningSubagentCount: 0,
  397. pendingInteraction: 'plan-review',
  398. completed: false,
  399. hasActiveSchedule: false,
  400. snippet: 'title session body excerpt',
  401. },
  402. {
  403. id: workspaceHit.id,
  404. title: 'Ordinary title',
  405. workspace: 'Needle Workspace',
  406. running: false,
  407. runningSubagentCount: 0,
  408. completed: false,
  409. hasActiveSchedule: false,
  410. },
  411. {
  412. id: contentHit.id,
  413. title: 'content-hit',
  414. workspace: 'c',
  415. running: false,
  416. runningSubagentCount: 0,
  417. completed: false,
  418. hasActiveSchedule: false,
  419. snippet: 'body needle excerpt',
  420. },
  421. ],
  422. hasMore: false,
  423. })
  424. })
  425. it('excludes blank sessions from search regardless of query or content hits', () => {
  426. const currentBlank = { ...summary('opaque-current', 5), blank: true }
  427. const staleBlank = { ...summary('new session stale', 4), blank: true }
  428. const sessions = {
  429. ...list(currentBlank, staleBlank),
  430. current: currentBlank.id,
  431. }
  432. // Blank placeholders never match — not their localized-display title, not
  433. // their id, and not even a backend content hit naming them.
  434. const result = deriveSearchResults(
  435. sessions,
  436. [workspace('first', ['opaque-current', 'new session stale'])],
  437. 'new session',
  438. noArchive,
  439. noAttention,
  440. {
  441. items: [
  442. { sessionId: staleBlank.id, snippet: 'stale body' },
  443. { sessionId: currentBlank.id, snippet: 'current body' },
  444. ],
  445. hasMore: false,
  446. },
  447. 10,
  448. )
  449. expect(result.items).toEqual([])
  450. })
  451. it('uses the supplied cap and preserves either local overflow or backend hasMore', () => {
  452. const rows = Array.from({ length: 5 }, (_, index) => {
  453. const item = summary(`s-${String(index).padStart(2, '0')}`, index)
  454. item.displayTitle = `Needle ${String(index)}`
  455. return item
  456. })
  457. const overflow = deriveSearchResults(
  458. list(...rows),
  459. [],
  460. 'needle',
  461. noArchive,
  462. noAttention,
  463. { items: [], hasMore: false },
  464. 3,
  465. )
  466. expect(overflow.items).toHaveLength(3)
  467. expect(overflow.hasMore).toBe(true)
  468. const backendMore = deriveSearchResults(
  469. list(summary('body', 1)),
  470. [],
  471. 'needle',
  472. noArchive,
  473. noAttention,
  474. { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
  475. 3,
  476. )
  477. expect(backendMore.items).toHaveLength(1)
  478. expect(backendMore.hasMore).toBe(true)
  479. expect(deriveSearchResults(list(), [], ' ', noArchive, noAttention, { items: [], hasMore: true }, 3))
  480. .toEqual({ items: [], hasMore: false })
  481. })
  482. })
  483. describe('createWorkspaceViewStore', () => {
  484. it('stores view preferences and selects Manual when saving a drag order', () => {
  485. const store = createWorkspaceViewStore().create()
  486. expect(store.getSnapshot().groupBy).toBe('workspace')
  487. expect(store.getSnapshot().orderBy).toBe('updated')
  488. store.actions.setGroupBy('flat')
  489. store.actions.setOrderBy('updated', {})
  490. store.actions.setGroupExpanded('alpha', true)
  491. store.actions.setSessionOrder('alpha', ['one', 'two'], {})
  492. expect(store.getSnapshot().groupBy).toBe('flat')
  493. expect(store.getSnapshot()).toMatchObject({
  494. orderBy: 'manual',
  495. groupExpansion: { alpha: true },
  496. sessionOrderByAccount: { alpha: ['one', 'two'] },
  497. })
  498. })
  499. it('retains positions when reselecting Manual and snapshots the supplied order on mode switches', () => {
  500. const store = createWorkspaceViewStore().create()
  501. store.actions.setSessionOrder('alpha', ['two', 'one'], {})
  502. store.actions.setOrderBy('manual', {})
  503. expect(store.getSnapshot()).toMatchObject({ orderBy: 'manual', sessionOrderByAccount: { alpha: ['two', 'one'] } })
  504. store.actions.setOrderBy('updated', {})
  505. expect(store.getSnapshot()).toMatchObject({ orderBy: 'updated', sessionOrderByAccount: {} })
  506. store.actions.setOrderBy('manual', { alpha: ['one', 'two'] })
  507. expect(store.getSnapshot().sessionOrderByAccount).toEqual({ alpha: ['one', 'two'] })
  508. })
  509. it('snapshots every account when a recency drag selects Manual', () => {
  510. const store = createWorkspaceViewStore().create()
  511. store.actions.setSessionOrder('alpha', ['two', 'one'], {
  512. alpha: ['one', 'two'],
  513. beta: ['three'],
  514. })
  515. expect(store.getSnapshot()).toMatchObject({
  516. orderBy: 'manual',
  517. sessionOrderByAccount: { alpha: ['two', 'one'], beta: ['three'] },
  518. })
  519. })
  520. it('ignores reconciliation writes outside Manual', () => {
  521. const store = createWorkspaceViewStore().create()
  522. store.actions.syncSessionOrders({ alpha: ['one'] })
  523. expect(store.getSnapshot().sessionOrderByAccount).toEqual({})
  524. })
  525. it('removes view state outside the retained Workspace key set', () => {
  526. const store = createWorkspaceViewStore().create()
  527. store.actions.setGroupExpanded('', true)
  528. store.actions.setGroupExpanded('alpha', true)
  529. store.actions.setGroupExpanded('deleted', true)
  530. store.actions.setSessionOrder('alpha', ['alpha-session'], {})
  531. store.actions.setSessionOrder('deleted', ['deleted-session'], {})
  532. store.actions.retainAccountKeys(['', 'alpha'])
  533. const snapshot = store.getSnapshot()
  534. expect(snapshot.groupExpansion).toEqual({ '': true, alpha: true })
  535. expect(snapshot.sessionOrderByAccount).toEqual({ alpha: ['alpha-session'] })
  536. })
  537. })
  538. describe('workspaceLabel', () => {
  539. it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => {
  540. expect(workspaceLabel(undefined)).toBe('')
  541. expect(workspaceLabel('')).toBe('')
  542. expect(workspaceLabel('/projects/demo/')).toBe('demo')
  543. expect(workspaceLabel('C:\\projects\\demo\\')).toBe('demo')
  544. expect(workspaceLabel('/')).toBe('/')
  545. })
  546. })
  547. describe('parent folder membership', () => {
  548. it.each([
  549. ['/git/app', ['/git'], '/git'],
  550. ['/git', ['/git/'], undefined],
  551. ['/git-other/app', ['/git'], undefined],
  552. ['/git/team/app', ['/git', '/git/team'], '/git/team'],
  553. ['/git/team/app', ['/git/team', '/git'], '/git/team'],
  554. ['/git/app', ['/'], '/'],
  555. [undefined, ['/git'], undefined],
  556. ['/git/app', [], undefined],
  557. [String.raw`C:\git\app`, ['C:/git/'], 'C:/git/'],
  558. [String.raw`\\server\share\app`, [String.raw`\\server\share`], String.raw`\\server\share`],
  559. [String.raw`/git/a\b`, ['/git/a'], undefined],
  560. ['/Git/app', ['/git'], undefined],
  561. ])('groups %s under its nearest registered ancestor', (path, parents, expected) => {
  562. expect(owningParentFolder(path, parents)).toBe(expected)
  563. })
  564. })