@exegia/corpora-ui 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  2. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  3. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  4. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  5. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  6. package/dist-lib/components/blocks/layout.d.ts +4 -1
  7. package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
  8. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
  9. package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
  10. package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
  11. package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
  12. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +109 -29
  13. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
  14. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +6 -1
  15. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +1 -1
  16. package/dist-lib/components/blocks/profile/index.d.ts +7 -0
  17. package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
  18. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
  19. package/dist-lib/components/blocks/profile/type.d.ts +80 -0
  20. package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
  21. package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
  22. package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
  23. package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
  24. package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
  25. package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
  26. package/dist-lib/components/blocks/shell/index.d.ts +4 -12
  27. package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
  28. package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
  29. package/dist-lib/components/blocks/shell/type.d.ts +115 -3
  30. package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
  31. package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
  32. package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
  33. package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
  34. package/dist-lib/components/composed/__tests__/logo.test.d.ts +1 -0
  35. package/dist-lib/components/composed/logo.d.ts +34 -0
  36. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  37. package/dist-lib/components/composed/tree/constants.d.ts +4 -4
  38. package/dist-lib/components/composed/tree/index.d.ts +3 -1
  39. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  40. package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
  41. package/dist-lib/components/composed/tree/type.d.ts +116 -18
  42. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
  43. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  44. package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
  45. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  46. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  47. package/dist-lib/components/user-avatar/component.d.ts +1 -8
  48. package/dist-lib/components/user-avatar/index.d.ts +5 -0
  49. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  50. package/dist-lib/components/user-avatar/type.d.ts +53 -0
  51. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  52. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +37 -0
  53. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +64 -0
  54. package/dist-lib/components/user-avatar/utils.d.ts +22 -0
  55. package/dist-lib/index.d.ts +6 -3
  56. package/dist-lib/index.js +3732 -2670
  57. package/dist-lib/index.js.map +1 -1
  58. package/dist-lib/state/exegia-provider.d.ts +51 -0
  59. package/dist-lib/state/index.d.ts +4 -0
  60. package/dist-lib/state/store.d.ts +17 -0
  61. package/package.json +15 -12
  62. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  63. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  64. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  65. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  66. package/src/components/blocks/auth/auth-state.ts +52 -0
  67. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  68. package/src/components/blocks/layout.ts +30 -2
  69. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  70. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  71. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
  72. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  73. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  74. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  75. package/src/components/blocks/nav/sidebar/type.ts +125 -30
  76. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  77. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
  78. package/src/components/blocks/nav/sidebar/utils.ts +1 -1
  79. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  80. package/src/components/blocks/profile/index.ts +28 -0
  81. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  82. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  83. package/src/components/blocks/profile/type.ts +95 -0
  84. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  85. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  86. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  87. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  88. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  89. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  90. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  91. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  92. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  93. package/src/components/blocks/shell/index.ts +18 -13
  94. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  95. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  96. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  97. package/src/components/blocks/shell/type.ts +130 -3
  98. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  99. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  100. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  101. package/src/components/blocks/shell/utils.ts +44 -4
  102. package/src/components/composed/__tests__/logo.test.tsx +60 -0
  103. package/src/components/composed/logo.tsx +159 -0
  104. package/src/components/composed/tree/CLAUDE.md +132 -0
  105. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  106. package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
  107. package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
  108. package/src/components/composed/tree/constants.ts +4 -4
  109. package/src/components/composed/tree/index.ts +38 -0
  110. package/src/components/composed/tree/tree-atom.ts +590 -0
  111. package/src/components/composed/tree/tree-node.tsx +117 -78
  112. package/src/components/composed/tree/tree.tsx +24 -16
  113. package/src/components/composed/tree/type.ts +125 -15
  114. package/src/components/composed/tree/use-tree-dnd.ts +82 -51
  115. package/src/components/composed/tree/use-tree-state.ts +105 -0
  116. package/src/components/composed/tree/use-tree.ts +185 -184
  117. package/src/components/composed/user-avatar.tsx +11 -99
  118. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +286 -0
  119. package/src/components/user-avatar/component.tsx +151 -22
  120. package/src/components/user-avatar/fallback.tsx +3 -1
  121. package/src/components/user-avatar/index.ts +19 -0
  122. package/src/components/user-avatar/presence-badge.tsx +56 -0
  123. package/src/components/user-avatar/type.ts +58 -0
  124. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  125. package/src/components/user-avatar/use-user-avatar.ts +180 -0
  126. package/src/components/user-avatar/user-avatar-atom.ts +218 -0
  127. package/src/components/user-avatar/utils.ts +94 -0
  128. package/src/index.ts +35 -3
  129. package/src/state/exegia-provider.tsx +79 -0
  130. package/src/state/index.ts +4 -0
  131. package/src/state/store.ts +19 -0
  132. package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
  133. package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
  134. package/dist-lib/components/blocks/nav/types.d.ts +0 -60
  135. package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
  136. package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
  137. package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
  138. package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
  139. package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
  140. package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
  141. package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
  142. package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
  143. package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
  144. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
  145. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
  146. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
  147. package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
  148. package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
  149. package/src/components/blocks/nav/sidebar-block.tsx +0 -142
  150. package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
  151. package/src/components/blocks/nav/types.ts +0 -65
  152. package/src/components/blocks/nav/utils.ts +0 -4
  153. package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
  154. package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
  155. package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
  156. package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
  157. package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
  158. package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
  159. package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
  160. package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
  161. package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
  162. package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
  163. package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
  164. package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
  165. /package/dist-lib/components/blocks/{nav/__tests__/sidebar-block.test.d.ts → auth/__tests__/auth-state-atom.test.d.ts} +0 -0
@@ -0,0 +1,325 @@
1
+ import { describe, expect, mock, test } from "bun:test"
2
+ import { render, screen } from "@testing-library/react"
3
+ import userEvent from "@testing-library/user-event"
4
+ import { createStore } from "jotai"
5
+
6
+ import { ExegiaProvider } from "@/state"
7
+ import { AISidebar } from "../ai-sidebar"
8
+ import {
9
+ DEFAULT_AI_SIDEBAR_CONFIG,
10
+ aiSidebarAnnouncementAtom,
11
+ aiSidebarExpandedIdsAtom,
12
+ aiSidebarItemsAtom,
13
+ aiSidebarMovePendingAtom,
14
+ expandAISidebarRowAtom,
15
+ mountAISidebarAtom,
16
+ moveAISidebarRowAtom,
17
+ removeAISidebarInstance,
18
+ renameAISidebarRowAtom,
19
+ resetAISidebarAtom,
20
+ setAISidebarHandlersAtom,
21
+ } from "../ai-sidebar-atom"
22
+ import type { AISidebarConfig, AISidebarSeed, SidebarResource } from "../type"
23
+ import { useAISidebarActions, useAISidebarState } from "../use-ai-sidebar-state"
24
+
25
+ type Store = ReturnType<typeof createStore>
26
+
27
+ const RESOURCES: SidebarResource[] = [
28
+ {
29
+ id: "corpora",
30
+ label: "Corpora",
31
+ kind: "project",
32
+ children: [
33
+ { id: "manuscripts", label: "Manuscripts", kind: "folder" },
34
+ { id: "readme", label: "Field notes", kind: "file" },
35
+ ],
36
+ },
37
+ { id: "reading-list", label: "Reading list", kind: "bookmark" },
38
+ { id: "archive", label: "Archive", kind: "folder", disabled: true },
39
+ ]
40
+
41
+ function mount(
42
+ store: Store,
43
+ id: string,
44
+ config: Partial<AISidebarConfig> = {},
45
+ seed: Partial<AISidebarSeed> = {}
46
+ ) {
47
+ store.set(
48
+ mountAISidebarAtom(id),
49
+ { ...DEFAULT_AI_SIDEBAR_CONFIG, ...config },
50
+ {
51
+ items: RESOURCES,
52
+ activeId: null,
53
+ focusedId: null,
54
+ expandedIds: [],
55
+ ...seed,
56
+ }
57
+ )
58
+ }
59
+
60
+ const ids = (items: SidebarResource[]) => items.map((item) => item.id)
61
+
62
+ describe("ai-sidebar atoms · instance isolation", () => {
63
+ test("two ids never share state", () => {
64
+ const store = createStore()
65
+ mount(store, "a")
66
+ mount(store, "b")
67
+
68
+ store.set(expandAISidebarRowAtom("a"), "corpora")
69
+ expect(store.get(aiSidebarExpandedIdsAtom("a")).has("corpora")).toBe(true)
70
+ expect(store.get(aiSidebarExpandedIdsAtom("b")).has("corpora")).toBe(false)
71
+ })
72
+
73
+ test("two stores never share state under the same id", () => {
74
+ const one = createStore()
75
+ const two = createStore()
76
+ mount(one, "shared")
77
+ mount(two, "shared")
78
+
79
+ one.set(expandAISidebarRowAtom("shared"), "corpora")
80
+ expect(one.get(aiSidebarExpandedIdsAtom("shared")).has("corpora")).toBe(
81
+ true
82
+ )
83
+ expect(two.get(aiSidebarExpandedIdsAtom("shared")).has("corpora")).toBe(
84
+ false
85
+ )
86
+ })
87
+
88
+ test("removeAISidebarInstance drops the slice back to its defaults", () => {
89
+ const store = createStore()
90
+ mount(store, "gone")
91
+ store.set(expandAISidebarRowAtom("gone"), "corpora")
92
+
93
+ removeAISidebarInstance("gone")
94
+ expect(store.get(aiSidebarExpandedIdsAtom("gone")).size).toBe(0)
95
+ expect(store.get(aiSidebarItemsAtom("gone"))).toEqual([])
96
+ })
97
+ })
98
+
99
+ describe("ai-sidebar atoms · write gates", () => {
100
+ test("hook-owned data renames itself", () => {
101
+ const store = createStore()
102
+ mount(store, "owned")
103
+
104
+ store.set(renameAISidebarRowAtom("owned"), "reading-list", " To read ")
105
+ expect(store.get(aiSidebarItemsAtom("owned"))[1]?.label).toBe("To read")
106
+ })
107
+
108
+ test("a controlled items prop is never overwritten, only reported", () => {
109
+ const store = createStore()
110
+ const onItemsChange = mock((items: SidebarResource[]) => void items)
111
+ mount(store, "controlled", { controlsItems: true })
112
+ store.set(setAISidebarHandlersAtom("controlled"), { onItemsChange })
113
+
114
+ store.set(renameAISidebarRowAtom("controlled"), "reading-list", "To read")
115
+ expect(store.get(aiSidebarItemsAtom("controlled"))[1]?.label).toBe(
116
+ "Reading list"
117
+ )
118
+ expect(onItemsChange).toHaveBeenCalledTimes(1)
119
+ expect(onItemsChange.mock.calls[0]![0][1]?.label).toBe("To read")
120
+
121
+ // A move is gated the same way: the atom keeps the prop's data, the
122
+ // consumer gets the next tree to apply themselves.
123
+ void store.set(moveAISidebarRowAtom("controlled"), {
124
+ itemId: "reading-list",
125
+ targetId: "corpora",
126
+ position: "before",
127
+ })
128
+ expect(ids(store.get(aiSidebarItemsAtom("controlled")))).toEqual([
129
+ "corpora",
130
+ "reading-list",
131
+ "archive",
132
+ ])
133
+ expect(onItemsChange).toHaveBeenCalledTimes(2)
134
+ expect(ids(onItemsChange.mock.calls[1]![0])).toEqual([
135
+ "reading-list",
136
+ "corpora",
137
+ "archive",
138
+ ])
139
+ })
140
+
141
+ test("reset replays the mount seed", () => {
142
+ const store = createStore()
143
+ mount(store, "replay", {}, { expandedIds: ["corpora"] })
144
+ store.set(expandAISidebarRowAtom("replay"), "manuscripts")
145
+ store.set(renameAISidebarRowAtom("replay"), "reading-list", "To read")
146
+
147
+ store.set(resetAISidebarAtom("replay"))
148
+ expect([...store.get(aiSidebarExpandedIdsAtom("replay"))]).toEqual([
149
+ "corpora",
150
+ ])
151
+ expect(store.get(aiSidebarItemsAtom("replay"))[1]?.label).toBe(
152
+ "Reading list"
153
+ )
154
+ expect(store.get(aiSidebarAnnouncementAtom("replay"))).toBe("")
155
+ })
156
+ })
157
+
158
+ describe("ai-sidebar atoms · optimistic move", () => {
159
+ test("a rejected onMove rolls the data back and announces it", async () => {
160
+ const store = createStore()
161
+ const onMoveError = mock(() => {})
162
+ mount(store, "rollback")
163
+ store.set(setAISidebarHandlersAtom("rollback"), {
164
+ onMove: () => Promise.reject(new Error("409")),
165
+ onMoveError,
166
+ })
167
+
168
+ const pending = store.set(moveAISidebarRowAtom("rollback"), {
169
+ itemId: "reading-list",
170
+ targetId: "corpora",
171
+ position: "before",
172
+ })
173
+ // The move applies optimistically and announces before onMove settles…
174
+ expect(ids(store.get(aiSidebarItemsAtom("rollback")))).toEqual([
175
+ "reading-list",
176
+ "corpora",
177
+ "archive",
178
+ ])
179
+ expect(store.get(aiSidebarAnnouncementAtom("rollback"))).toContain(
180
+ "Moved Reading list before Corpora"
181
+ )
182
+
183
+ await pending
184
+ // …and the rejection restores the previous order.
185
+ expect(ids(store.get(aiSidebarItemsAtom("rollback")))).toEqual([
186
+ "corpora",
187
+ "reading-list",
188
+ "archive",
189
+ ])
190
+ expect(store.get(aiSidebarAnnouncementAtom("rollback"))).toContain(
191
+ "Move failed"
192
+ )
193
+ expect(onMoveError).toHaveBeenCalled()
194
+ expect(store.get(aiSidebarMovePendingAtom("rollback"))).toBe(false)
195
+ })
196
+
197
+ test("a second move is refused while one is in flight", async () => {
198
+ const store = createStore()
199
+ let release!: () => void
200
+ mount(store, "pending")
201
+ store.set(setAISidebarHandlersAtom("pending"), {
202
+ onMove: () =>
203
+ new Promise<void>((resolve) => {
204
+ release = resolve
205
+ }),
206
+ })
207
+
208
+ const first = store.set(moveAISidebarRowAtom("pending"), {
209
+ itemId: "reading-list",
210
+ targetId: "corpora",
211
+ position: "before",
212
+ })
213
+ const second = store.set(moveAISidebarRowAtom("pending"), {
214
+ itemId: "archive",
215
+ targetId: "corpora",
216
+ position: "before",
217
+ })
218
+ // The second move never touched the data, only the live region.
219
+ expect(store.get(aiSidebarAnnouncementAtom("pending"))).toBe(
220
+ "Wait for the current move to finish."
221
+ )
222
+ expect(ids(store.get(aiSidebarItemsAtom("pending")))).toEqual([
223
+ "reading-list",
224
+ "corpora",
225
+ "archive",
226
+ ])
227
+
228
+ release()
229
+ await Promise.all([first, second])
230
+ expect(store.get(aiSidebarMovePendingAtom("pending"))).toBe(false)
231
+ })
232
+ })
233
+
234
+ describe("ExegiaProvider · driving a sidebar by id", () => {
235
+ /** A component with no relationship to the sidebar beyond its id. */
236
+ function Remote() {
237
+ const actions = useAISidebarActions("app-resources")
238
+ const { expandedIds } = useAISidebarState("app-resources")
239
+ return (
240
+ <button onClick={() => actions.expand("corpora")} type="button">
241
+ {`expand (${expandedIds.size})`}
242
+ </button>
243
+ )
244
+ }
245
+
246
+ test("a sibling component expands the sidebar through one provider", async () => {
247
+ const user = userEvent.setup()
248
+ render(
249
+ <ExegiaProvider store={createStore()}>
250
+ <AISidebar defaultItems={RESOURCES} sidebarId="app-resources" />
251
+ <Remote />
252
+ </ExegiaProvider>
253
+ )
254
+
255
+ // corpora starts collapsed, so its children start unmounted.
256
+ expect(screen.queryByRole("treeitem", { name: /Field notes/ })).toBeNull()
257
+
258
+ await user.click(screen.getByRole("button", { name: /^expand/ }))
259
+ expect(
260
+ await screen.findByRole("treeitem", { name: /Field notes/ })
261
+ ).toBeDefined()
262
+ // The remote reader saw the change too.
263
+ expect(screen.getByRole("button", { name: "expand (1)" })).toBeDefined()
264
+ })
265
+ })
266
+
267
+ describe("AISidebar · render granularity", () => {
268
+ test("expanding and selecting one row leave a sibling row untouched", async () => {
269
+ const user = userEvent.setup()
270
+ // `renderIcon` runs inside a row's render, so counting calls per id is a
271
+ // proxy for counting that row's renders.
272
+ const renders = new Map<string, number>()
273
+
274
+ function Driver() {
275
+ const actions = useAISidebarActions("granular")
276
+ return (
277
+ <>
278
+ <button onClick={() => actions.expand("corpora")} type="button">
279
+ open
280
+ </button>
281
+ <button onClick={() => actions.select("reading-list")} type="button">
282
+ pick
283
+ </button>
284
+ </>
285
+ )
286
+ }
287
+
288
+ render(
289
+ <ExegiaProvider store={createStore()}>
290
+ <AISidebar
291
+ defaultItems={RESOURCES}
292
+ renderIcon={(item) => {
293
+ renders.set(item.id, (renders.get(item.id) ?? 0) + 1)
294
+ return null
295
+ }}
296
+ sidebarId="granular"
297
+ />
298
+ <Driver />
299
+ </ExegiaProvider>
300
+ )
301
+
302
+ const beforeCorpora = renders.get("corpora") ?? 0
303
+ const beforeReadingList = renders.get("reading-list") ?? 0
304
+ const beforeArchive = renders.get("archive") ?? 0
305
+ expect(beforeCorpora).toBeGreaterThan(0)
306
+ expect(beforeReadingList).toBeGreaterThan(0)
307
+
308
+ await user.click(screen.getByRole("button", { name: "open" }))
309
+ await screen.findByRole("treeitem", { name: /Field notes/ })
310
+
311
+ // corpora's branch opened, so its row re-rendered…
312
+ expect(renders.get("corpora")).toBeGreaterThan(beforeCorpora)
313
+ // …and its siblings, whose atoms did not change, did not.
314
+ expect(renders.get("reading-list")).toBe(beforeReadingList)
315
+ expect(renders.get("archive")).toBe(beforeArchive)
316
+
317
+ const expandedCorpora = renders.get("corpora") ?? 0
318
+ await user.click(screen.getByRole("button", { name: "pick" }))
319
+
320
+ // Selection flips reading-list's own atom and nobody else's.
321
+ expect(renders.get("reading-list")).toBeGreaterThan(beforeReadingList)
322
+ expect(renders.get("corpora")).toBe(expandedCorpora)
323
+ expect(renders.get("archive")).toBe(beforeArchive)
324
+ })
325
+ })