tree.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. import { describe, expect, it } from 'vitest'
  2. import type {
  3. SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
  4. } from '@deepseek-ai/dsh-client-runtime/client'
  5. import {
  6. deriveFlat, deriveGroups, deriveSearchResults, projectLabel, relativeTime,
  7. UNGROUPED_KEY, UNGROUPED_LABEL,
  8. } from '../src/client/tree.ts'
  9. import { createWorkspaceViewStore } from '../src/client/stores.ts'
  10. const sid = (id: string) => id as SessionId
  11. const wid = (id: string) => id as WorkspaceId
  12. const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
  13. id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
  14. })
  15. const list = (...items: SessionSummary[]): SessionListState => ({
  16. ids: items.map(item => item.id),
  17. byId: Object.fromEntries(items.map(item => [item.id, item])),
  18. current: undefined,
  19. phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
  20. })
  21. const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
  22. workspaceId: wid(id), path: `/projects/${id}`, title,
  23. sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
  24. })
  25. const view = (expandedProjects: readonly string[] = []) => ({
  26. expandedProjects,
  27. })
  28. const noArchive: readonly SessionId[] = []
  29. const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
  30. describe('deriveGroups', () => {
  31. it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
  32. const sessions = list(summary('newer', 20), summary('older', 10))
  33. const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
  34. const groups = deriveGroups(sessions, workspaces, noArchive, view(['first']))
  35. expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
  36. expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
  37. })
  38. it('projects pending-interaction state into grouped and flat rows', () => {
  39. const awaiting = { ...summary('awaiting', 10), pendingInteraction: 'plan-review' as const, running: true }
  40. const sessions = list(awaiting)
  41. const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], noArchive, view(['project']))
  42. expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
  43. expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
  44. })
  45. it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
  46. const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
  47. const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY]))
  48. expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
  49. expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
  50. })
  51. it('shows only the current blank session in its Workspace count and tree', () => {
  52. const currentBlank = { ...summary('current-blank', 5), blank: true }
  53. const staleBlank = { ...summary('stale-blank', 4), blank: true }
  54. const real = summary('shown', 3)
  55. const sessions = {
  56. ...list(real, currentBlank, staleBlank),
  57. current: currentBlank.id,
  58. }
  59. const groups = deriveGroups(
  60. sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], noArchive, view(['first']),
  61. )
  62. expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
  63. const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
  64. // The stored placeholder title stays canonical; the renderer swaps in
  65. // the localized New Session label via the blank flag.
  66. expect(blankNode.title).toBe('New Session')
  67. expect(blankNode.blank).toBe(true)
  68. expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false)
  69. expect(groups[0]!.sessionCount).toBe(2)
  70. // A non-current blank stray never surfaces an Ungrouped bucket either.
  71. const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], noArchive, view())
  72. expect(strayGroups.map(group => group.key)).toEqual(['first'])
  73. })
  74. it('projects the completion reminder into session and search rows (absent = false)', () => {
  75. const done = { ...summary('done', 3), completed: true }
  76. const plain = summary('plain', 2)
  77. const sessions = list(done, plain)
  78. const groups = deriveGroups(
  79. sessions, [workspace('first', ['done', 'plain'])], noArchive, view(['first']),
  80. )
  81. const doneNode = groups[0]!.sessions.find(session => session.id === done.id)!
  82. const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)!
  83. expect(doneNode.completed).toBe(true)
  84. expect(plainNode.completed).toBe(false)
  85. expect(deriveFlat(sessions, noArchive).find(node => node.id === done.id)!.completed).toBe(true)
  86. const search = deriveSearchResults(sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, { items: [], hasMore: false }, 10)
  87. expect(search.items[0]?.completed).toBe(true)
  88. })
  89. it('hides subagent-origin sessions without hiding ordinary forks', () => {
  90. const parent = summary('parent', 1)
  91. const subagent = {
  92. ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const, running: true,
  93. }
  94. const grandchild = {
  95. ...summary('grandchild', 4), parentId: subagent.id, origin: 'subagent' as const, running: true,
  96. }
  97. const fork = { ...summary('fork', 2), parentId: subagent.id }
  98. const forkChild = {
  99. ...summary('fork-child', 5), parentId: fork.id, origin: 'subagent' as const, running: true,
  100. }
  101. const sessions = { ...list(parent, fork, subagent, grandchild, forkChild), current: subagent.id }
  102. const groups = deriveGroups(
  103. sessions,
  104. [workspace('first', ['parent', 'fork', 'subagent', 'grandchild', 'fork-child'])],
  105. noArchive,
  106. view(['first']),
  107. )
  108. expect(groups[0]!.sessions.map(node => node.id)).toEqual([parent.id, fork.id])
  109. expect(groups[0]!.sessionCount).toBe(2)
  110. expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 })
  111. expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 })
  112. expect(deriveFlat(sessions, noArchive).map(node => [node.id, node.runningSubagentCount])).toEqual([
  113. [fork.id, 1], [parent.id, 2],
  114. ])
  115. expect(deriveSearchResults(
  116. sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive,
  117. { items: [], hasMore: false }, 10,
  118. ).items[0]).toMatchObject({ id: parent.id, runningSubagentCount: 2 })
  119. })
  120. it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
  121. const parent = summary('parent', 1)
  122. const oldChild = { ...summary('old-child', 10), parentId: parent.id }
  123. const newChild = { ...summary('new-child', 20), parentId: parent.id }
  124. const tieB = { ...summary('tie-b', 20), parentId: parent.id }
  125. const tieA = { ...summary('tie-a', 20), parentId: parent.id }
  126. const self = { ...summary('self', 2), parentId: sid('self') }
  127. const orphan = { ...summary('orphan', 3), parentId: sid('missing') }
  128. const cycleA = { ...summary('cycle-a', 4), parentId: sid('cycle-b') }
  129. const cycleB = { ...summary('cycle-b', 5), parentId: sid('cycle-a') }
  130. const groups = deriveGroups(
  131. list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
  132. [],
  133. noArchive,
  134. { expandedProjects: [UNGROUPED_KEY] },
  135. )
  136. expect(groups).toHaveLength(1)
  137. expect(groups[0]!.sessions.map(node => node.id)).toEqual([
  138. newChild.id, tieA.id, tieB.id, oldChild.id,
  139. cycleB.id, cycleA.id, orphan.id, self.id, parent.id,
  140. ])
  141. // Equal timestamps use ids as a deterministic tiebreak in either input order.
  142. expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, view([UNGROUPED_KEY]))[0]!
  143. .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
  144. })
  145. it('tolerates Workspace membership arriving before its Session summary', () => {
  146. const partial: SessionListState = {
  147. ...list(),
  148. ids: [sid('present')],
  149. byId: { [sid('present')]: summary('present', 1) },
  150. }
  151. const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], noArchive, view(['project']))
  152. expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
  153. })
  154. it('hides archived sessions from workspace groups and Ungrouped', () => {
  155. const kept = summary('kept', 1, '/projects/first')
  156. const gone = summary('gone', 2, '/projects/first')
  157. const looseGone = summary('loose-gone', 3, '/other')
  158. const sessions = list(kept, gone, looseGone)
  159. const groups = deriveGroups(
  160. sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), view(['first', UNGROUPED_KEY]),
  161. )
  162. // The archived member drops from its group AND the archived stray never
  163. // surfaces an Ungrouped bucket; counts follow the visible rows.
  164. expect(groups.map(group => group.key)).toEqual(['first'])
  165. expect(groups[0]!.sessions.map(node => node.id)).toEqual([kept.id])
  166. expect(groups[0]!.sessionCount).toBe(1)
  167. })
  168. it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
  169. const owned = summary('owned', 1)
  170. const loose = summary('loose', 2)
  171. const ws = workspace('project', ['owned'])
  172. const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], noArchive, view())
  173. expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
  174. const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], noArchive, view())
  175. expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
  176. })
  177. })
  178. describe('deriveFlat', () => {
  179. it('flattens every session — fork children included — newest-first with id tiebreak', () => {
  180. const parent = summary('parent', 10)
  181. const child = { ...summary('child', 30), parentId: parent.id }
  182. const tieB = summary('tie-b', 20)
  183. const tieA = summary('tie-a', 20)
  184. const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive)
  185. expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
  186. })
  187. it('hides subagent-origin rows but keeps ordinary forks', () => {
  188. const parent = summary('parent', 1)
  189. const fork = { ...summary('fork', 2), parentId: parent.id }
  190. const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const }
  191. const rows = deriveFlat(
  192. { ...list(parent, fork, subagent), current: subagent.id },
  193. noArchive,
  194. )
  195. expect(rows.map(row => row.id)).toEqual([fork.id, parent.id])
  196. })
  197. it('tolerates ids whose summary has not landed yet', () => {
  198. const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
  199. expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')])
  200. })
  201. it('shows only the current blank session and excludes blanks from search', () => {
  202. const currentBlank = { ...summary('current-blank', 9), blank: true }
  203. const staleBlank = { ...summary('stale-blank', 8), blank: true }
  204. const sessions = {
  205. ...list(summary('real', 1), currentBlank, staleBlank),
  206. current: currentBlank.id,
  207. }
  208. const rows = deriveFlat(sessions, noArchive)
  209. expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
  210. expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
  211. expect(rows.map(row => row.blank)).toEqual([true, false])
  212. })
  213. it('hides archived sessions in flat mode', () => {
  214. const kept = summary('kept', 1)
  215. const gone = summary('gone', 2)
  216. expect(deriveFlat(list(kept, gone), archived('gone')).map(row => row.id)).toEqual([kept.id])
  217. })
  218. })
  219. describe('deriveSearchResults archive filtering', () => {
  220. it('archived sessions never match — not by title and not via a backend content hit', () => {
  221. const hit = summary('hit', 2)
  222. hit.displayTitle = 'Needle row'
  223. const gone = summary('gone', 1)
  224. gone.displayTitle = 'Needle archived'
  225. const result = deriveSearchResults(
  226. list(hit, gone),
  227. [],
  228. 'needle',
  229. archived('gone'),
  230. { items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false },
  231. 10,
  232. )
  233. expect(result.items.map(item => item.id)).toEqual([hit.id])
  234. })
  235. })
  236. describe('deriveSearchResults', () => {
  237. it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
  238. const titleHit = summary('title-hit', 30, '/projects/a')
  239. titleHit.displayTitle = 'Needle title'
  240. titleHit.pendingInteraction = 'plan-review'
  241. const workspaceHit = summary('workspace-hit', 20, '/projects/b')
  242. workspaceHit.displayTitle = 'Ordinary title'
  243. const contentHit = summary('content-hit', 10, '/projects/c')
  244. const sessions = list(titleHit, workspaceHit, contentHit)
  245. const result = deriveSearchResults(
  246. sessions,
  247. [
  248. workspace('a', ['title-hit'], 'Alpha'),
  249. workspace('b', ['workspace-hit'], 'Needle Workspace'),
  250. workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'),
  251. ],
  252. ' NEEDLE ',
  253. noArchive,
  254. {
  255. items: [
  256. { sessionId: contentHit.id, snippet: 'body needle excerpt' },
  257. { sessionId: contentHit.id, snippet: 'ignored duplicate excerpt' },
  258. { sessionId: titleHit.id, snippet: 'title session body excerpt' },
  259. { sessionId: sid('unknown'), snippet: 'not in session.list' },
  260. ],
  261. hasMore: false,
  262. },
  263. 10,
  264. )
  265. expect(result).toEqual({
  266. items: [
  267. {
  268. id: titleHit.id,
  269. title: 'Needle title',
  270. workspace: 'Alpha',
  271. running: false,
  272. runningSubagentCount: 0,
  273. pendingInteraction: 'plan-review',
  274. completed: false,
  275. snippet: 'title session body excerpt',
  276. },
  277. {
  278. id: workspaceHit.id,
  279. title: 'Ordinary title',
  280. workspace: 'Needle Workspace',
  281. running: false,
  282. runningSubagentCount: 0,
  283. completed: false,
  284. },
  285. {
  286. id: contentHit.id,
  287. title: 'content-hit',
  288. workspace: 'c',
  289. running: false,
  290. runningSubagentCount: 0,
  291. completed: false,
  292. snippet: 'body needle excerpt',
  293. },
  294. ],
  295. hasMore: false,
  296. })
  297. })
  298. it('excludes blank sessions from search regardless of query or content hits', () => {
  299. const currentBlank = { ...summary('opaque-current', 5), blank: true }
  300. const staleBlank = { ...summary('new session stale', 4), blank: true }
  301. const sessions = {
  302. ...list(currentBlank, staleBlank),
  303. current: currentBlank.id,
  304. }
  305. // Blank placeholders never match — not their localized-display title, not
  306. // their id, and not even a backend content hit naming them.
  307. const result = deriveSearchResults(
  308. sessions,
  309. [workspace('first', ['opaque-current', 'new session stale'])],
  310. 'new session',
  311. noArchive,
  312. {
  313. items: [
  314. { sessionId: staleBlank.id, snippet: 'stale body' },
  315. { sessionId: currentBlank.id, snippet: 'current body' },
  316. ],
  317. hasMore: false,
  318. },
  319. 10,
  320. )
  321. expect(result.items).toEqual([])
  322. })
  323. it('uses the supplied cap and preserves either local overflow or backend hasMore', () => {
  324. const rows = Array.from({ length: 5 }, (_, index) => {
  325. const item = summary(`s-${String(index).padStart(2, '0')}`, index)
  326. item.displayTitle = `Needle ${String(index)}`
  327. return item
  328. })
  329. const overflow = deriveSearchResults(
  330. list(...rows),
  331. [],
  332. 'needle',
  333. noArchive,
  334. { items: [], hasMore: false },
  335. 3,
  336. )
  337. expect(overflow.items).toHaveLength(3)
  338. expect(overflow.hasMore).toBe(true)
  339. const backendMore = deriveSearchResults(
  340. list(summary('body', 1)),
  341. [],
  342. 'needle',
  343. noArchive,
  344. { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
  345. 3,
  346. )
  347. expect(backendMore.items).toHaveLength(1)
  348. expect(backendMore.hasMore).toBe(true)
  349. expect(deriveSearchResults(list(), [], ' ', noArchive, { items: [], hasMore: true }, 3))
  350. .toEqual({ items: [], hasMore: false })
  351. })
  352. })
  353. describe('createWorkspaceViewStore', () => {
  354. it('defaults to workspace grouping; setGroupBy is the sole mutation', () => {
  355. const store = createWorkspaceViewStore().create()
  356. expect(store.getSnapshot().groupBy).toBe('workspace')
  357. store.actions.setGroupBy('flat')
  358. expect(store.getSnapshot().groupBy).toBe('flat')
  359. })
  360. })
  361. describe('projectLabel', () => {
  362. it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => {
  363. expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
  364. expect(projectLabel('')).toBe(UNGROUPED_LABEL)
  365. expect(projectLabel('/projects/demo/')).toBe('demo')
  366. expect(projectLabel('C:\\projects\\demo\\')).toBe('demo')
  367. expect(projectLabel('/')).toBe('/')
  368. })
  369. })
  370. describe('relativeTime', () => {
  371. it('buckets current, minute, hour, day, month, and year distances', () => {
  372. const now = 400 * 24 * 60 * 60 * 1_000
  373. expect(relativeTime(now, now)).toEqual({ unit: 'now', n: 0 })
  374. expect(relativeTime(now - 5 * 60_000, now)).toEqual({ unit: 'minutes', n: 5 })
  375. expect(relativeTime(now - 3 * 3_600_000, now)).toEqual({ unit: 'hours', n: 3 })
  376. expect(relativeTime(now - 2 * 86_400_000, now)).toEqual({ unit: 'days', n: 2 })
  377. expect(relativeTime(now - 60 * 86_400_000, now)).toEqual({ unit: 'months', n: 2 })
  378. expect(relativeTime(0, now)).toEqual({ unit: 'years', n: 1 })
  379. })
  380. })