tree.client.spec.ts 26 KB

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