@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
@@ -6,30 +6,70 @@ import {
6
6
  useCallback,
7
7
  useEffect,
8
8
  useId,
9
+ useLayoutEffect,
9
10
  useMemo,
10
11
  useRef,
11
12
  useState,
12
13
  } from "react";
14
+ import { useAtomValue, useSetAtom, useStore } from "jotai";
15
+
16
+ import {
17
+ aiSidebarAnnouncementAtom,
18
+ aiSidebarDraggingIdAtom,
19
+ aiSidebarDropTargetAtom,
20
+ aiSidebarExpandedIdsAtom,
21
+ aiSidebarFlatAtom,
22
+ aiSidebarFocusedIdAtom,
23
+ aiSidebarHoveredIdAtom,
24
+ aiSidebarItemsAtom,
25
+ aiSidebarMenuOpenIdAtom,
26
+ aiSidebarMovePendingAtom,
27
+ aiSidebarOwnActiveIdAtom,
28
+ aiSidebarOwnedExpandedIdsAtom,
29
+ aiSidebarOwnedItemsAtom,
30
+ aiSidebarRenamingIdAtom,
31
+ cancelAISidebarRenameAtom,
32
+ clearAISidebarHoverAtom,
33
+ closeAISidebarMenuAtom,
34
+ collapseAISidebarRowAtom,
35
+ collapseAllAISidebarRowsAtom,
36
+ endAISidebarDragAtom,
37
+ expandAISidebarRowAtom,
38
+ expandAllAISidebarRowsAtom,
39
+ focusAISidebarRowAtom,
40
+ mountAISidebarAtom,
41
+ moveAISidebarRowAtom,
42
+ normalizeAISidebarFocusAtom,
43
+ openAISidebarMenuAtom,
44
+ projectAISidebarActiveIdAtom,
45
+ projectAISidebarExpandedIdsAtom,
46
+ projectAISidebarItemsAtom,
47
+ removeAISidebarInstance,
48
+ renameAISidebarRowAtom,
49
+ resetAISidebarAtom,
50
+ revealAISidebarRowAtom,
51
+ selectAISidebarRowAtom,
52
+ setAISidebarHandlersAtom,
53
+ setAISidebarHoveredAtom,
54
+ startAISidebarRenameAtom,
55
+ toggleAISidebarRowAtom,
56
+ } from "./ai-sidebar-atom.ts";
13
57
  import type {
58
+ AISidebarConfig,
14
59
  AISidebarController,
15
- DropTarget,
60
+ AISidebarHandlers,
61
+ AISidebarSeed,
16
62
  FlatResource,
17
- SidebarResource,
18
- SidebarResourceMove,
19
63
  UseAISidebarOptions,
20
- } from "./type.ts"
64
+ } from "./type.ts";
21
65
  import {
22
- ancestorIdsOf,
23
66
  canContain,
24
67
  containsResource,
25
- expandableIdsOf,
26
68
  findResource,
27
69
  flattenResources,
28
- moveResource,
29
- renameResource,
30
- } from "./utils.ts"
70
+ } from "./utils.ts";
31
71
 
32
- export type { UseAISidebarOptions }
72
+ export type { UseAISidebarOptions };
33
73
 
34
74
  /**
35
75
  * The AI sidebar's headless controller — every behaviour the block
@@ -39,7 +79,7 @@ export type { UseAISidebarOptions }
39
79
  *
40
80
  * `items`, `activeId` and `expandedIds` are each controlled when passed
41
81
  * and hook-owned via their `default*` twin otherwise. Reorder and rename
42
- * are applied to the data optimistically either way and rolled back if
82
+ * are applied to the data optimistically and rolled back if
43
83
  * `onMove`/`onRename` rejects, so a consumer never reduces the tree
44
84
  * itself — `moveResource` and `renameResource` are exported for the ones
45
85
  * who keep their data elsewhere.
@@ -49,8 +89,15 @@ export type { UseAISidebarOptions }
49
89
  * <AISidebar controller={sidebar} />
50
90
  * <Button onClick={sidebar.collapseAll}>Collapse all</Button>
51
91
  * ```
92
+ *
93
+ * State lives in Jotai atoms keyed by `sidebarId`, so anything under
94
+ * `ExegiaProvider` can drive this sidebar without the controller — name it
95
+ * and reach for `useAISidebarState(sidebarId)` /
96
+ * `useAISidebarActions(sidebarId)` elsewhere.
52
97
  */
53
- export function useAISidebar(options: UseAISidebarOptions): AISidebarController {
98
+ export function useAISidebar(
99
+ options: UseAISidebarOptions
100
+ ): AISidebarController {
54
101
  const {
55
102
  items,
56
103
  defaultItems = [],
@@ -62,60 +109,138 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
62
109
  defaultActiveId = null,
63
110
  onActiveChange,
64
111
  onExpandedChange,
65
- } = options
112
+ } = options;
113
+
114
+ // A generated key isolates unnamed sidebars from each other; an explicit
115
+ // `sidebarId` is the app's handle on this one.
116
+ const generatedId = useId();
117
+ const sidebarId = options.sidebarId ?? generatedId;
118
+ const store = useStore();
119
+ const hoverLayoutId = useId();
120
+
121
+ const controlledExpandedIds = options.expandedIds;
122
+ const controlsItems = items !== undefined;
123
+ const controlsActiveId = activeId !== undefined;
124
+ const controlsExpandedIds = controlledExpandedIds !== undefined;
66
125
 
67
- const [internalItems, setInternalItems] = useState(items ?? defaultItems);
68
- const [internalActiveId, setInternalActiveId] = useState(defaultActiveId);
69
- const [ownExpandedIds, setOwnExpandedIds] = useState(
70
- () => new Set(options.defaultExpandedIds ?? []),
126
+ // Primitives only, so this object is stable and the store write below runs
127
+ // once per real change rather than once per render.
128
+ const config = useMemo<AISidebarConfig>(
129
+ () => ({ controlsItems, controlsActiveId, controlsExpandedIds }),
130
+ [controlsActiveId, controlsExpandedIds, controlsItems]
71
131
  );
72
- const [focusedId, setFocusedId] = useState<string | null>(
73
- activeId ?? defaultActiveId,
132
+
133
+ const handlers = useMemo<AISidebarHandlers>(
134
+ () => ({
135
+ onItemsChange,
136
+ onMove,
137
+ onMoveError,
138
+ onRename,
139
+ onActiveChange,
140
+ onExpandedChange,
141
+ }),
142
+ [
143
+ onActiveChange,
144
+ onExpandedChange,
145
+ onItemsChange,
146
+ onMove,
147
+ onMoveError,
148
+ onRename,
149
+ ]
74
150
  );
75
- const [draggingId, setDraggingId] = useState<string | null>(null);
76
- const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
77
- const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
78
- const [renamingId, setRenamingId] = useState<string | null>(null);
79
- const [hoveredId, setHoveredId] = useState<string | null>(null);
80
- const hoverLayoutId = useId();
81
- const [announcement, setAnnouncement] = useState("");
82
- // `movePending` is mirrored in a ref because the guard has to read it
83
- // synchronously, before React has committed the state change.
84
- const [movePending, setMovePending] = useState(false);
85
- const rowRefs = useRef(new Map<string, HTMLDivElement>());
86
- const movePendingRef = useRef(false);
87
- const renderedItems = internalItems;
88
- const selectedId = activeId ?? internalActiveId;
89
151
 
90
- const controlledExpandedIds = options.expandedIds
152
+ // Read once: the `default*` options describe the mount, not every render.
153
+ const [seed] = useState<AISidebarSeed>(() => ({
154
+ items: items ?? defaultItems,
155
+ activeId: defaultActiveId,
156
+ focusedId: activeId ?? defaultActiveId,
157
+ expandedIds: [
158
+ ...(controlledExpandedIds ?? options.defaultExpandedIds ?? []),
159
+ ],
160
+ }));
161
+
162
+ const mount = useSetAtom(mountAISidebarAtom(sidebarId));
163
+ const publishHandlers = useSetAtom(setAISidebarHandlersAtom(sidebarId));
164
+ const projectItems = useSetAtom(projectAISidebarItemsAtom(sidebarId));
165
+ const projectActiveId = useSetAtom(projectAISidebarActiveIdAtom(sidebarId));
166
+ const projectExpandedIds = useSetAtom(
167
+ projectAISidebarExpandedIdsAtom(sidebarId)
168
+ );
169
+ const normalizeFocus = useSetAtom(normalizeAISidebarFocusAtom(sidebarId));
170
+
171
+ // Handlers first — they must be in the store before any action can fire.
172
+ // Rewritten every commit; no read atom depends on them, so nobody
173
+ // re-renders for it.
174
+ useLayoutEffect(() => {
175
+ publishHandlers(handlers);
176
+ }, [handlers, publishHandlers]);
177
+
178
+ // Before paint, so a seeded row never paints closed for a frame.
179
+ useLayoutEffect(() => {
180
+ mount(config, seed);
181
+ }, [config, mount, seed]);
182
+
183
+ // Controlled props stay the source of truth; the store carries a projection
184
+ // so the action atoms and remote readers see current data.
185
+ useLayoutEffect(() => {
186
+ if (items !== undefined) projectItems(items);
187
+ }, [items, projectItems]);
188
+
189
+ useLayoutEffect(() => {
190
+ if (activeId !== undefined) projectActiveId(activeId);
191
+ }, [activeId, projectActiveId]);
192
+
193
+ useLayoutEffect(() => {
194
+ if (controlledExpandedIds !== undefined)
195
+ projectExpandedIds(controlledExpandedIds);
196
+ }, [controlledExpandedIds, projectExpandedIds]);
197
+
198
+ // A sidebar the hook keyed is scrap once its component goes. An explicit
199
+ // `sidebarId` is the app's key and outlives the mount.
200
+ useEffect(() => {
201
+ if (options.sidebarId !== undefined) return;
202
+ return () => removeAISidebarInstance(sidebarId);
203
+ }, [options.sidebarId, sidebarId]);
204
+
205
+ // Never `aiSidebarItemsAtom` — see the note on `aiSidebarOwnedItemsAtom`.
206
+ const ownedItems = useAtomValue(aiSidebarOwnedItemsAtom(sidebarId));
207
+ const renderedItems = items ?? ownedItems;
208
+ // `activeId ?? own` rather than the projected atom: a controlled `activeId`
209
+ // of `null` has always fallen through to the hook-owned selection.
210
+ const ownActiveId = useAtomValue(aiSidebarOwnActiveIdAtom(sidebarId));
211
+ const selectedId = activeId ?? ownActiveId;
212
+ const ownExpandedIds = useAtomValue(aiSidebarOwnedExpandedIdsAtom(sidebarId));
91
213
  const expandedIds = useMemo(
92
- () => (controlledExpandedIds ? new Set(controlledExpandedIds) : ownExpandedIds),
93
- [controlledExpandedIds, ownExpandedIds],
94
- )
95
-
96
- const [prevItems, setPrevItems] = useState(items);
97
- if (items !== prevItems) {
98
- setPrevItems(items);
99
- if (items) setInternalItems(items);
100
- }
214
+ () =>
215
+ controlledExpandedIds ? new Set(controlledExpandedIds) : ownExpandedIds,
216
+ [controlledExpandedIds, ownExpandedIds]
217
+ );
218
+ const focusedId = useAtomValue(aiSidebarFocusedIdAtom(sidebarId));
219
+ const renamingId = useAtomValue(aiSidebarRenamingIdAtom(sidebarId));
220
+ const menuOpenId = useAtomValue(aiSidebarMenuOpenIdAtom(sidebarId));
221
+ const draggingId = useAtomValue(aiSidebarDraggingIdAtom(sidebarId));
222
+ const dropTarget = useAtomValue(aiSidebarDropTargetAtom(sidebarId));
223
+ const hoveredId = useAtomValue(aiSidebarHoveredIdAtom(sidebarId));
224
+ const announcement = useAtomValue(aiSidebarAnnouncementAtom(sidebarId));
225
+ const movePending = useAtomValue(aiSidebarMovePendingAtom(sidebarId));
101
226
 
102
227
  const flat = useMemo(
103
228
  () => flattenResources(renderedItems, expandedIds),
104
- [expandedIds, renderedItems],
229
+ [expandedIds, renderedItems]
105
230
  );
106
231
 
107
- const isFocusedItemStillVisible =
108
- focusedId !== null && flat.some((row) => row.item.id === focusedId);
109
- if (!isFocusedItemStillVisible) {
110
- const firstVisibleItemId = flat[0]?.item.id ?? null;
111
- if (focusedId !== firstVisibleItemId) setFocusedId(firstVisibleItemId);
112
- }
232
+ // The roving target must name a row that still renders: one that folds away
233
+ // hands focus to the first visible row instead of pointing at nothing. Last
234
+ // of the layout effects, so it reads the projections this commit wrote.
235
+ useLayoutEffect(() => {
236
+ normalizeFocus();
237
+ }, [flat, focusedId, normalizeFocus]);
113
238
 
114
239
  useEffect(() => {
115
240
  if (!menuOpenId) return;
116
241
  const frame = requestAnimationFrame(() => {
117
242
  const menus = Array.from(
118
- document.querySelectorAll<HTMLElement>("[data-sidebar-resource-menu]"),
243
+ document.querySelectorAll<HTMLElement>("[data-sidebar-resource-menu]")
119
244
  );
120
245
  menus
121
246
  .find((menu) => menu.dataset.sidebarResourceMenu === menuOpenId)
@@ -125,177 +250,81 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
125
250
  return () => cancelAnimationFrame(frame);
126
251
  }, [menuOpenId]);
127
252
 
128
- const updateItems = useCallback(
129
- (next: SidebarResource[]) => {
130
- setInternalItems(next);
131
- onItemsChange?.(next);
132
- },
133
- [onItemsChange],
134
- );
135
-
136
- const commitExpanded = useCallback(
137
- (next: Set<string>) => {
138
- if (controlledExpandedIds === undefined) setOwnExpandedIds(next);
139
- onExpandedChange?.([...next]);
140
- },
141
- [controlledExpandedIds, onExpandedChange],
142
- );
143
-
144
253
  const getItem = useCallback(
145
254
  (id: string) => findResource(renderedItems, id),
146
- [renderedItems],
255
+ [renderedItems]
147
256
  );
148
257
 
149
258
  const isExpanded = useCallback(
150
259
  (id: string) => expandedIds.has(id),
151
- [expandedIds],
260
+ [expandedIds]
152
261
  );
153
262
 
154
- const expand = useCallback(
155
- (id: string) => {
156
- if (expandedIds.has(id)) return;
157
- commitExpanded(new Set(expandedIds).add(id));
158
- },
159
- [commitExpanded, expandedIds],
160
- );
161
-
162
- const collapse = useCallback(
163
- (id: string) => {
164
- if (!expandedIds.has(id)) return;
165
- const next = new Set(expandedIds);
166
- next.delete(id);
167
- commitExpanded(next);
168
- },
169
- [commitExpanded, expandedIds],
170
- );
171
-
172
- const toggleExpanded = useCallback(
173
- (id: string) => {
174
- const next = new Set(expandedIds);
175
- if (next.has(id)) next.delete(id);
176
- else next.add(id);
177
- commitExpanded(next);
178
- },
179
- [commitExpanded, expandedIds],
180
- );
181
-
182
- const expandAll = useCallback(() => {
183
- commitExpanded(new Set(expandableIdsOf(renderedItems)));
184
- }, [commitExpanded, renderedItems]);
185
-
186
- const collapseAll = useCallback(() => {
187
- commitExpanded(new Set());
188
- }, [commitExpanded]);
189
-
190
- const reveal = useCallback(
191
- (id: string) => {
192
- const ancestors = ancestorIdsOf(renderedItems, id);
193
- if (!ancestors.some((ancestor) => !expandedIds.has(ancestor))) return;
194
- commitExpanded(new Set([...expandedIds, ...ancestors]));
195
- },
196
- [commitExpanded, expandedIds, renderedItems],
197
- );
198
-
199
- const move = useCallback(
200
- async (move: SidebarResourceMove) => {
201
- if (movePendingRef.current) {
202
- setAnnouncement("Wait for the current move to finish.");
203
- return;
204
- }
205
- const before = renderedItems;
206
- const next = moveResource(before, move);
207
- if (!next || next === before) return;
208
-
209
- movePendingRef.current = true;
210
- setMovePending(true);
211
- updateItems(next);
212
- setDropTarget(null);
213
- setDraggingId(null);
214
- const moved = findResource(before, move.itemId);
215
- const target = move.targetId ? findResource(before, move.targetId) : null;
216
- setAnnouncement(
217
- target
218
- ? `Moved ${moved?.label ?? "item"} ${move.position} ${target.label}.`
219
- : `Moved ${moved?.label ?? "item"} to the top level.`,
220
- );
263
+ // `useSetAtom` setters are already stable — no useCallback needed.
264
+ const select = useSetAtom(selectAISidebarRowAtom(sidebarId));
265
+ const expand = useSetAtom(expandAISidebarRowAtom(sidebarId));
266
+ const collapse = useSetAtom(collapseAISidebarRowAtom(sidebarId));
267
+ const toggleExpanded = useSetAtom(toggleAISidebarRowAtom(sidebarId));
268
+ const expandAll = useSetAtom(expandAllAISidebarRowsAtom(sidebarId));
269
+ const collapseAll = useSetAtom(collapseAllAISidebarRowsAtom(sidebarId));
270
+ const reveal = useSetAtom(revealAISidebarRowAtom(sidebarId));
271
+ const startRename = useSetAtom(startAISidebarRenameAtom(sidebarId));
272
+ const cancelRename = useSetAtom(cancelAISidebarRenameAtom(sidebarId));
273
+ const rename = useSetAtom(renameAISidebarRowAtom(sidebarId));
274
+ const openMenu = useSetAtom(openAISidebarMenuAtom(sidebarId));
275
+ const closeMenuInStore = useSetAtom(closeAISidebarMenuAtom(sidebarId));
276
+ const move = useSetAtom(moveAISidebarRowAtom(sidebarId));
277
+ const reset = useSetAtom(resetAISidebarAtom(sidebarId));
278
+ const setFocusedRow = useSetAtom(focusAISidebarRowAtom(sidebarId));
279
+ const onRowHover = useSetAtom(setAISidebarHoveredAtom(sidebarId));
280
+ const clearHover = useSetAtom(clearAISidebarHoverAtom(sidebarId));
281
+ const onRowDragEnd = useSetAtom(endAISidebarDragAtom(sidebarId));
282
+ const setDraggingId = useSetAtom(aiSidebarDraggingIdAtom(sidebarId));
283
+ const setDropTarget = useSetAtom(aiSidebarDropTargetAtom(sidebarId));
284
+
285
+ // The one piece of row state that cannot live in the store: these are DOM
286
+ // nodes. The block owns them, and every DOM focus move goes through here.
287
+ const rowRefs = useRef(new Map<string, HTMLDivElement>());
221
288
 
222
- try {
223
- await onMove?.(move);
224
- } catch (error) {
225
- updateItems(before);
226
- setAnnouncement(`Move failed. ${moved?.label ?? "Item"} was restored.`);
227
- onMoveError?.(error, move);
228
- } finally {
229
- movePendingRef.current = false;
230
- setMovePending(false);
231
- }
232
- },
233
- [onMove, onMoveError, renderedItems, updateItems],
234
- );
289
+ const setRowRef = useCallback((id: string, node: HTMLDivElement | null) => {
290
+ if (node) rowRefs.current.set(id, node);
291
+ else rowRefs.current.delete(id);
292
+ }, []);
235
293
 
236
- const focus = useCallback((id: string) => {
237
- setFocusedId(id);
294
+ const focusRow = useCallback((id: string) => {
238
295
  requestAnimationFrame(() => rowRefs.current.get(id)?.focus());
239
296
  }, []);
240
297
 
241
- const select = useCallback(
242
- (id: string) => {
243
- if (activeId === undefined) setInternalActiveId(id);
244
- onActiveChange?.(id);
245
- },
246
- [activeId, onActiveChange],
247
- );
248
-
249
- const startRename = useCallback(
298
+ const focus = useCallback(
250
299
  (id: string) => {
251
- // An id from elsewhere would arm rename mode against a row that
252
- // never renders.
253
- if (!findResource(renderedItems, id)) return;
254
- setRenamingId(id);
300
+ setFocusedRow(id);
301
+ focusRow(id);
255
302
  },
256
- [renderedItems],
257
- );
258
-
259
- const cancelRename = useCallback(() => setRenamingId(null), []);
260
-
261
- const rename = useCallback(
262
- (id: string, label: string) => {
263
- const trimmed = label.trim();
264
- setRenamingId(null);
265
- const item = findResource(renderedItems, id);
266
- if (!item || !trimmed || trimmed === item.label) return;
267
- const before = renderedItems;
268
- updateItems(renameResource(before, id, trimmed));
269
- void Promise.resolve(onRename?.(item, trimmed)).catch(() => {
270
- updateItems(before);
271
- setAnnouncement(`Rename failed. ${item.label} was restored.`);
272
- });
273
- },
274
- [onRename, renderedItems, updateItems],
275
- );
276
-
277
- const openMenu = useCallback(
278
- (id: string) => {
279
- if (!findResource(renderedItems, id)) return;
280
- setMenuOpenId(id);
281
- },
282
- [renderedItems],
303
+ [focusRow, setFocusedRow]
283
304
  );
284
305
 
285
306
  // Closing returns focus to the row that owned the menu — otherwise focus
286
- // falls back to the body and the keyboard user loses their place.
307
+ // falls back to the body and the keyboard user loses their place. The atom
308
+ // hands back the id it closed so only the DOM half happens here.
287
309
  const closeMenu = useCallback(() => {
288
- setMenuOpenId((current) => {
289
- if (current) focus(current);
290
- return null;
291
- });
292
- }, [focus]);
310
+ const closed = closeMenuInStore();
311
+ if (closed) focusRow(closed);
312
+ }, [closeMenuInStore, focusRow]);
313
+
314
+ // Roving nav reads the visible rows out of the store when a key lands
315
+ // rather than closing over them, so one stable handler serves every row.
316
+ const readFlat = useCallback(
317
+ () => store.get(aiSidebarFlatAtom(sidebarId)),
318
+ [sidebarId, store]
319
+ );
293
320
 
294
321
  const onRowKeyDown = useCallback(
295
322
  (event: KeyboardEvent<HTMLDivElement>, row: FlatResource) => {
296
- const index = flat.findIndex(({ item }) => item.id === row.item.id);
297
- const previous = flat[index - 1];
298
- const next = flat[index + 1];
323
+ const flatRows = readFlat();
324
+ const expanded = store.get(aiSidebarExpandedIdsAtom(sidebarId));
325
+ const index = flatRows.findIndex(({ item }) => item.id === row.item.id);
326
+ const previous = flatRows[index - 1];
327
+ const next = flatRows[index + 1];
299
328
  const moveModifier = event.altKey && event.shiftKey;
300
329
 
301
330
  if (event.key === "ArrowDown" && !moveModifier && next) {
@@ -308,14 +337,14 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
308
337
  focus(previous.item.id);
309
338
  return;
310
339
  }
311
- if (event.key === "Home" && flat[0]) {
340
+ if (event.key === "Home" && flatRows[0]) {
312
341
  event.preventDefault();
313
- focus(flat[0].item.id);
342
+ focus(flatRows[0].item.id);
314
343
  return;
315
344
  }
316
- if (event.key === "End" && flat.at(-1)) {
345
+ if (event.key === "End" && flatRows.at(-1)) {
317
346
  event.preventDefault();
318
- focus(flat.at(-1)?.item.id ?? row.item.id);
347
+ focus(flatRows.at(-1)?.item.id ?? row.item.id);
319
348
  return;
320
349
  }
321
350
 
@@ -326,7 +355,7 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
326
355
  } else if (
327
356
  moveModifier ||
328
357
  ["ArrowRight", "Enter", " ", "F2", "ContextMenu"].includes(
329
- event.key,
358
+ event.key
330
359
  ) ||
331
360
  (event.shiftKey && event.key === "F10")
332
361
  ) {
@@ -337,33 +366,54 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
337
366
 
338
367
  if (moveModifier && event.key === "ArrowUp" && previous) {
339
368
  event.preventDefault();
340
- void move({ itemId: row.item.id, targetId: previous.item.id, position: "before" });
369
+ void move({
370
+ itemId: row.item.id,
371
+ targetId: previous.item.id,
372
+ position: "before",
373
+ });
341
374
  return;
342
375
  }
343
376
  if (moveModifier && event.key === "ArrowDown" && next) {
344
377
  event.preventDefault();
345
- void move({ itemId: row.item.id, targetId: next.item.id, position: "after" });
378
+ void move({
379
+ itemId: row.item.id,
380
+ targetId: next.item.id,
381
+ position: "after",
382
+ });
346
383
  return;
347
384
  }
348
- if (moveModifier && event.key === "ArrowRight" && previous && canContain(previous.item)) {
385
+ if (
386
+ moveModifier &&
387
+ event.key === "ArrowRight" &&
388
+ previous &&
389
+ canContain(previous.item)
390
+ ) {
349
391
  event.preventDefault();
350
392
  expand(previous.item.id);
351
- void move({ itemId: row.item.id, targetId: previous.item.id, position: "inside" });
393
+ void move({
394
+ itemId: row.item.id,
395
+ targetId: previous.item.id,
396
+ position: "inside",
397
+ });
352
398
  return;
353
399
  }
354
400
  if (moveModifier && event.key === "ArrowLeft" && row.parentId) {
355
401
  event.preventDefault();
356
- void move({ itemId: row.item.id, targetId: row.parentId, position: "after" });
402
+ void move({
403
+ itemId: row.item.id,
404
+ targetId: row.parentId,
405
+ position: "after",
406
+ });
357
407
  return;
358
408
  }
359
409
 
360
410
  if (event.key === "ArrowRight" && canContain(row.item)) {
361
411
  event.preventDefault();
362
- if (!expandedIds.has(row.item.id)) expand(row.item.id);
412
+ if (!expanded.has(row.item.id)) expand(row.item.id);
363
413
  else if (next?.parentId === row.item.id) focus(next.item.id);
364
414
  } else if (event.key === "ArrowLeft") {
365
415
  event.preventDefault();
366
- if (expandedIds.has(row.item.id)) collapse(row.item.id);
416
+ if (expanded.has(row.item.id)) collapse(row.item.id);
367
417
  else if (row.parentId) focus(row.parentId);
368
418
  } else if (event.key === "Enter" || event.key === " ") {
369
419
  event.preventDefault();
@@ -372,7 +422,10 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
372
422
  } else if (event.key === "F2") {
373
423
  event.preventDefault();
374
424
  startRename(row.item.id);
375
- } else if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
425
+ } else if (
426
+ event.key === "ContextMenu" ||
427
+ (event.shiftKey && event.key === "F10")
428
+ ) {
376
429
  event.preventDefault();
377
430
  openMenu(row.item.id);
378
431
  }
@@ -380,39 +433,49 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
380
433
  [
381
434
  collapse,
382
435
  expand,
383
- expandedIds,
384
- flat,
385
436
  focus,
386
437
  move,
387
438
  openMenu,
439
+ readFlat,
388
440
  select,
441
+ sidebarId,
389
442
  startRename,
443
+ store,
390
444
  toggleExpanded,
391
- ],
445
+ ]
392
446
  );
393
447
 
448
+ // The drag handlers read drag state out of the store when they run instead
449
+ // of closing over it. That is what keeps them stable for the life of the
450
+ // sidebar, so every row can share one set without re-rendering on a drag.
394
451
  const onRootDragOver = useCallback(
395
452
  (event: DragEvent<HTMLDivElement>) => {
396
- if (!draggingId || event.target !== event.currentTarget) return;
453
+ if (
454
+ !store.get(aiSidebarDraggingIdAtom(sidebarId)) ||
455
+ event.target !== event.currentTarget
456
+ )
457
+ return;
397
458
  event.preventDefault();
398
459
  setDropTarget({ id: null, position: "after" });
399
460
  },
400
- [draggingId],
461
+ [setDropTarget, sidebarId, store]
401
462
  );
402
463
 
403
464
  const onDrop = useCallback(
404
465
  (event: DragEvent<HTMLDivElement>) => {
405
466
  event.preventDefault();
406
467
  event.stopPropagation();
407
- if (draggingId && dropTarget) {
468
+ const dragging = store.get(aiSidebarDraggingIdAtom(sidebarId));
469
+ const target = store.get(aiSidebarDropTargetAtom(sidebarId));
470
+ if (dragging && target) {
408
471
  void move({
409
- itemId: draggingId,
410
- targetId: dropTarget.id,
411
- position: dropTarget.position,
472
+ itemId: dragging,
473
+ targetId: target.id,
474
+ position: target.position,
412
475
  });
413
476
  }
414
477
  },
415
- [draggingId, dropTarget, move],
478
+ [move, sidebarId, store]
416
479
  );
417
480
 
418
481
  const onRowDragStart = useCallback(
@@ -421,18 +484,17 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
421
484
  event.dataTransfer.effectAllowed = "move";
422
485
  event.dataTransfer.setData("text/plain", id);
423
486
  },
424
- [],
487
+ [setDraggingId]
425
488
  );
426
489
 
427
- const onRowDragEnd = useCallback(() => {
428
- setDraggingId(null);
429
- setDropTarget(null);
430
- }, []);
431
-
432
490
  const onRowDragOver = useCallback(
433
491
  (event: DragEvent<HTMLDivElement>, targetRow: FlatResource) => {
434
- if (!draggingId || draggingId === targetRow.item.id) return;
435
- const source = findResource(renderedItems, draggingId);
492
+ const dragging = store.get(aiSidebarDraggingIdAtom(sidebarId));
493
+ if (!dragging || dragging === targetRow.item.id) return;
494
+ const source = findResource(
495
+ store.get(aiSidebarItemsAtom(sidebarId)),
496
+ dragging
497
+ );
436
498
  if (source && containsResource(source, targetRow.item.id)) return;
437
499
  event.preventDefault();
438
500
  event.stopPropagation();
@@ -449,22 +511,9 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
449
511
  : "after";
450
512
  setDropTarget({ id: targetRow.item.id, position });
451
513
  },
452
- [draggingId, renderedItems],
514
+ [setDropTarget, sidebarId, store]
453
515
  );
454
516
 
455
- const onRowHover = useCallback((id: string, hovered: boolean) => {
456
- setHoveredId((current) =>
457
- hovered ? id : current === id ? null : current,
458
- );
459
- }, []);
460
-
461
- const clearHover = useCallback(() => setHoveredId(null), []);
462
-
463
- const setRowRef = useCallback((id: string, node: HTMLDivElement | null) => {
464
- if (node) rowRefs.current.set(id, node);
465
- else rowRefs.current.delete(id);
466
- }, []);
467
-
468
517
  const dnd = useMemo(
469
518
  () => ({
470
519
  draggingId,
@@ -483,7 +532,7 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
483
532
  onRowDragStart,
484
533
  onRowDragEnd,
485
534
  onRowDragOver,
486
- ],
535
+ ]
487
536
  );
488
537
 
489
538
  const hover = useMemo(
@@ -493,11 +542,12 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
493
542
  onRowHover,
494
543
  clear: clearHover,
495
544
  }),
496
- [clearHover, hoverLayoutId, hoveredId, onRowHover],
545
+ [clearHover, hoverLayoutId, hoveredId, onRowHover]
497
546
  );
498
547
 
499
548
  return useMemo<AISidebarController>(
500
549
  () => ({
550
+ sidebarId,
501
551
  items: renderedItems,
502
552
  flat,
503
553
  getItem,
@@ -523,12 +573,14 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
523
573
  move,
524
574
  movePending,
525
575
  announcement,
576
+ reset,
526
577
  dnd,
527
578
  hover,
528
579
  onRowKeyDown,
529
580
  setRowRef,
530
581
  }),
531
582
  [
583
+ sidebarId,
532
584
  renderedItems,
533
585
  flat,
534
586
  getItem,
@@ -554,10 +606,11 @@ export function useAISidebar(options: UseAISidebarOptions): AISidebarController
554
606
  move,
555
607
  movePending,
556
608
  announcement,
609
+ reset,
557
610
  dnd,
558
611
  hover,
559
612
  onRowKeyDown,
560
613
  setRowRef,
561
- ],
614
+ ]
562
615
  );
563
616
  }