@exegia/corpora-ui 0.19.0 → 0.21.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 (162) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/auth-state-atom.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  3. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  4. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  5. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  6. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  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/__tests__/use-ai-sidebar.test.d.ts +1 -0
  9. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
  10. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
  11. package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
  12. package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
  13. package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
  14. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +191 -29
  15. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
  16. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +27 -31
  17. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +6 -1
  18. package/dist-lib/components/blocks/profile/index.d.ts +7 -0
  19. package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
  20. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
  21. package/dist-lib/components/blocks/profile/type.d.ts +80 -0
  22. package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
  23. package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
  24. package/dist-lib/components/blocks/scaffold/constants.d.ts +5 -0
  25. package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
  26. package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
  27. package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
  28. package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
  29. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
  30. package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
  31. package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
  32. package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
  33. package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
  34. package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
  35. package/dist-lib/components/blocks/shell/index.d.ts +4 -0
  36. package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
  37. package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
  38. package/dist-lib/components/blocks/shell/type.d.ts +115 -3
  39. package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
  40. package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
  41. package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
  42. package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
  43. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  44. package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
  45. package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
  46. package/dist-lib/components/composed/tree/constants.d.ts +35 -0
  47. package/dist-lib/components/composed/tree/index.d.ts +6 -0
  48. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  49. package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
  50. package/dist-lib/components/composed/tree/tree-node.d.ts +12 -0
  51. package/dist-lib/components/composed/tree/tree.d.ts +19 -0
  52. package/dist-lib/components/composed/tree/type.d.ts +290 -0
  53. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -0
  54. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  55. package/dist-lib/components/composed/tree/use-tree.d.ts +24 -0
  56. package/dist-lib/components/composed/tree/utils.d.ts +28 -0
  57. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  58. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  59. package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
  60. package/dist-lib/components/user-avatar/component.d.ts +3 -0
  61. package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
  62. package/dist-lib/components/user-avatar/index.d.ts +18 -0
  63. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  64. package/dist-lib/components/user-avatar/type.d.ts +71 -0
  65. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  66. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
  67. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
  68. package/dist-lib/components/user-avatar/utils.d.ts +2 -0
  69. package/dist-lib/index.d.ts +6 -2
  70. package/dist-lib/index.js +3576 -1366
  71. package/dist-lib/index.js.map +1 -1
  72. package/dist-lib/state/exegia-provider.d.ts +51 -0
  73. package/dist-lib/state/index.d.ts +4 -0
  74. package/dist-lib/state/store.d.ts +17 -0
  75. package/package.json +16 -12
  76. package/src/components/beste/piece/browser-frame.tsx +9 -6
  77. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  78. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  79. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  80. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  81. package/src/components/blocks/auth/auth-state.ts +52 -0
  82. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  83. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  84. package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
  85. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  86. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +112 -76
  87. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  88. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  89. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  90. package/src/components/blocks/nav/sidebar/type.ts +228 -29
  91. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  92. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +455 -225
  93. package/src/components/blocks/nav/sidebar/utils.ts +29 -1
  94. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  95. package/src/components/blocks/profile/index.ts +28 -0
  96. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  97. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  98. package/src/components/blocks/profile/type.ts +95 -0
  99. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  100. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  101. package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
  102. package/src/components/blocks/scaffold/constants.ts +7 -0
  103. package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
  104. package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
  105. package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
  106. package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
  107. package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
  108. package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
  109. package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
  110. package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
  111. package/src/components/blocks/scaffold/type.ts +28 -0
  112. package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
  113. package/src/components/blocks/scaffold/utils.ts +19 -0
  114. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  115. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  116. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  117. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  118. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  119. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  120. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  121. package/src/components/blocks/shell/index.ts +18 -0
  122. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  123. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  124. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  125. package/src/components/blocks/shell/type.ts +130 -3
  126. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  127. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  128. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  129. package/src/components/blocks/shell/utils.ts +44 -4
  130. package/src/components/composed/tree/CLAUDE.md +132 -0
  131. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  132. package/src/components/composed/tree/__tests__/tree.test.tsx +525 -0
  133. package/src/components/composed/tree/__tests__/use-tree.test.tsx +333 -0
  134. package/src/components/composed/tree/constants.ts +60 -0
  135. package/src/components/composed/tree/index.ts +51 -0
  136. package/src/components/composed/tree/tree-atom.ts +590 -0
  137. package/src/components/composed/tree/tree-context.ts +13 -0
  138. package/src/components/composed/tree/tree-node.tsx +490 -0
  139. package/src/components/composed/tree/tree.tsx +286 -0
  140. package/src/components/composed/tree/type.ts +322 -0
  141. package/src/components/composed/tree/use-tree-dnd.ts +179 -0
  142. package/src/components/composed/tree/use-tree-state.ts +105 -0
  143. package/src/components/composed/tree/use-tree.ts +307 -0
  144. package/src/components/composed/tree/utils.ts +172 -0
  145. package/src/components/composed/user-avatar.tsx +11 -96
  146. package/src/components/docs/component-preview.tsx +11 -11
  147. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
  148. package/src/components/user-avatar/audio-wave.tsx +22 -0
  149. package/src/components/user-avatar/component.tsx +152 -0
  150. package/src/components/user-avatar/fallback.tsx +26 -0
  151. package/src/components/user-avatar/index.ts +34 -0
  152. package/src/components/user-avatar/presence-badge.tsx +56 -0
  153. package/src/components/user-avatar/type.ts +85 -0
  154. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  155. package/src/components/user-avatar/use-user-avatar.ts +144 -0
  156. package/src/components/user-avatar/user-avatar-atom.ts +198 -0
  157. package/src/components/user-avatar/utils.ts +8 -0
  158. package/src/index.css +211 -153
  159. package/src/index.ts +35 -2
  160. package/src/state/exegia-provider.tsx +79 -0
  161. package/src/state/index.ts +4 -0
  162. package/src/state/store.ts +19 -0
@@ -6,95 +6,241 @@ 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 {
14
- AISidebarProps,
15
- DropTarget,
58
+ AISidebarConfig,
59
+ AISidebarController,
60
+ AISidebarHandlers,
61
+ AISidebarSeed,
16
62
  FlatResource,
17
- SidebarResource,
18
- SidebarResourceMove,
19
- } from "./type.ts"
63
+ UseAISidebarOptions,
64
+ } from "./type.ts";
20
65
  import {
21
66
  canContain,
22
67
  containsResource,
23
68
  findResource,
24
69
  flattenResources,
25
- moveResource,
26
- renameResource,
27
- } from "./utils.ts"
28
-
29
- export type UseAISidebarOptions = Pick<
30
- AISidebarProps,
31
- | "items"
32
- | "defaultItems"
33
- | "onItemsChange"
34
- | "onMove"
35
- | "onMoveError"
36
- | "onRename"
37
- | "activeId"
38
- | "defaultActiveId"
39
- | "onActiveChange"
40
- | "defaultExpandedIds"
41
- >
42
-
43
- export function useAISidebar({
44
- items,
45
- defaultItems = [],
46
- onItemsChange,
47
- onMove,
48
- onMoveError,
49
- onRename,
50
- activeId,
51
- defaultActiveId = null,
52
- onActiveChange,
53
- defaultExpandedIds = [],
54
- }: UseAISidebarOptions) {
55
- const [internalItems, setInternalItems] = useState(items ?? defaultItems);
56
- const [internalActiveId, setInternalActiveId] = useState(defaultActiveId);
57
- const [expandedIds, setExpandedIds] = useState(
58
- () => new Set(defaultExpandedIds),
70
+ } from "./utils.ts";
71
+
72
+ export type { UseAISidebarOptions };
73
+
74
+ /**
75
+ * The AI sidebar's headless controller — every behaviour the block
76
+ * performs, callable from outside it: select, expand/collapse (one row,
77
+ * every row, or just the ancestors of one), move focus, rename, open a
78
+ * row's action menu, reorder.
79
+ *
80
+ * `items`, `activeId` and `expandedIds` are each controlled when passed
81
+ * and hook-owned via their `default*` twin otherwise. Reorder and rename
82
+ * are applied to the data optimistically and rolled back if
83
+ * `onMove`/`onRename` rejects, so a consumer never reduces the tree
84
+ * itself — `moveResource` and `renameResource` are exported for the ones
85
+ * who keep their data elsewhere.
86
+ *
87
+ * ```tsx
88
+ * const sidebar = useAISidebar({ defaultItems: resources })
89
+ * <AISidebar controller={sidebar} />
90
+ * <Button onClick={sidebar.collapseAll}>Collapse all</Button>
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.
97
+ */
98
+ export function useAISidebar(
99
+ options: UseAISidebarOptions
100
+ ): AISidebarController {
101
+ const {
102
+ items,
103
+ defaultItems = [],
104
+ onItemsChange,
105
+ onMove,
106
+ onMoveError,
107
+ onRename,
108
+ activeId,
109
+ defaultActiveId = null,
110
+ onActiveChange,
111
+ onExpandedChange,
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;
125
+
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]
59
131
  );
60
- const [focusedId, setFocusedId] = useState<string | null>(
61
- 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
+ ]
62
150
  );
63
- const [draggingId, setDraggingId] = useState<string | null>(null);
64
- const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
65
- const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
66
- const [renamingId, setRenamingId] = useState<string | null>(null);
67
- const [hoveredId, setHoveredId] = useState<string | null>(null);
68
- const hoverLayoutId = useId();
69
- const [announcement, setAnnouncement] = useState("");
70
- const rowRefs = useRef(new Map<string, HTMLDivElement>());
71
- const movePendingRef = useRef(false);
72
- const renderedItems = internalItems;
73
- const selectedId = activeId ?? internalActiveId;
74
151
 
75
- const [prevItems, setPrevItems] = useState(items);
76
- if (items !== prevItems) {
77
- setPrevItems(items);
78
- if (items) setInternalItems(items);
79
- }
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));
213
+ const expandedIds = useMemo(
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));
80
226
 
81
227
  const flat = useMemo(
82
228
  () => flattenResources(renderedItems, expandedIds),
83
- [expandedIds, renderedItems],
229
+ [expandedIds, renderedItems]
84
230
  );
85
231
 
86
- const isFocusedItemStillVisible =
87
- focusedId !== null && flat.some((row) => row.item.id === focusedId);
88
- if (!isFocusedItemStillVisible) {
89
- const firstVisibleItemId = flat[0]?.item.id ?? null;
90
- if (focusedId !== firstVisibleItemId) setFocusedId(firstVisibleItemId);
91
- }
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]);
92
238
 
93
239
  useEffect(() => {
94
240
  if (!menuOpenId) return;
95
241
  const frame = requestAnimationFrame(() => {
96
242
  const menus = Array.from(
97
- document.querySelectorAll<HTMLElement>("[data-sidebar-resource-menu]"),
243
+ document.querySelectorAll<HTMLElement>("[data-sidebar-resource-menu]")
98
244
  );
99
245
  menus
100
246
  .find((menu) => menu.dataset.sidebarResourceMenu === menuOpenId)
@@ -104,107 +250,112 @@ export function useAISidebar({
104
250
  return () => cancelAnimationFrame(frame);
105
251
  }, [menuOpenId]);
106
252
 
107
- const updateItems = useCallback(
108
- (next: SidebarResource[]) => {
109
- setInternalItems(next);
110
- onItemsChange?.(next);
111
- },
112
- [onItemsChange],
253
+ const getItem = useCallback(
254
+ (id: string) => findResource(renderedItems, id),
255
+ [renderedItems]
113
256
  );
114
257
 
115
- const performMove = useCallback(
116
- async (move: SidebarResourceMove) => {
117
- if (movePendingRef.current) {
118
- setAnnouncement("Wait for the current move to finish.");
119
- return;
120
- }
121
- const before = renderedItems;
122
- const next = moveResource(before, move);
123
- if (!next || next === before) return;
124
-
125
- movePendingRef.current = true;
126
- updateItems(next);
127
- setDropTarget(null);
128
- setDraggingId(null);
129
- const moved = findResource(before, move.itemId);
130
- const target = move.targetId ? findResource(before, move.targetId) : null;
131
- setAnnouncement(
132
- target
133
- ? `Moved ${moved?.label ?? "item"} ${move.position} ${target.label}.`
134
- : `Moved ${moved?.label ?? "item"} to the top level.`,
135
- );
136
-
137
- try {
138
- await onMove?.(move);
139
- } catch (error) {
140
- updateItems(before);
141
- setAnnouncement(`Move failed. ${moved?.label ?? "Item"} was restored.`);
142
- onMoveError?.(error, move);
143
- } finally {
144
- movePendingRef.current = false;
145
- }
146
- },
147
- [onMove, onMoveError, renderedItems, updateItems],
258
+ const isExpanded = useCallback(
259
+ (id: string) => expandedIds.has(id),
260
+ [expandedIds]
148
261
  );
149
262
 
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>());
288
+
289
+ const setRowRef = useCallback((id: string, node: HTMLDivElement | null) => {
290
+ if (node) rowRefs.current.set(id, node);
291
+ else rowRefs.current.delete(id);
292
+ }, []);
293
+
150
294
  const focusRow = useCallback((id: string) => {
151
- setFocusedId(id);
152
295
  requestAnimationFrame(() => rowRefs.current.get(id)?.focus());
153
296
  }, []);
154
297
 
155
- const select = useCallback(
298
+ const focus = useCallback(
156
299
  (id: string) => {
157
- if (activeId === undefined) setInternalActiveId(id);
158
- onActiveChange?.(id);
300
+ setFocusedRow(id);
301
+ focusRow(id);
159
302
  },
160
- [activeId, onActiveChange],
303
+ [focusRow, setFocusedRow]
161
304
  );
162
305
 
163
- const toggle = useCallback((id: string) => {
164
- setExpandedIds((current) => {
165
- const next = new Set(current);
166
- if (next.has(id)) next.delete(id);
167
- else next.add(id);
168
- return next;
169
- });
170
- }, []);
306
+ // Closing returns focus to the row that owned the menu — otherwise focus
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.
309
+ const closeMenu = useCallback(() => {
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
+ );
171
320
 
172
- const handleKeyDown = useCallback(
321
+ const onRowKeyDown = useCallback(
173
322
  (event: KeyboardEvent<HTMLDivElement>, row: FlatResource) => {
174
- const index = flat.findIndex(({ item }) => item.id === row.item.id);
175
- const previous = flat[index - 1];
176
- 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];
177
328
  const moveModifier = event.altKey && event.shiftKey;
178
329
 
179
330
  if (event.key === "ArrowDown" && !moveModifier && next) {
180
331
  event.preventDefault();
181
- focusRow(next.item.id);
332
+ focus(next.item.id);
182
333
  return;
183
334
  }
184
335
  if (event.key === "ArrowUp" && !moveModifier && previous) {
185
336
  event.preventDefault();
186
- focusRow(previous.item.id);
337
+ focus(previous.item.id);
187
338
  return;
188
339
  }
189
- if (event.key === "Home" && flat[0]) {
340
+ if (event.key === "Home" && flatRows[0]) {
190
341
  event.preventDefault();
191
- focusRow(flat[0].item.id);
342
+ focus(flatRows[0].item.id);
192
343
  return;
193
344
  }
194
- if (event.key === "End" && flat.at(-1)) {
345
+ if (event.key === "End" && flatRows.at(-1)) {
195
346
  event.preventDefault();
196
- focusRow(flat.at(-1)?.item.id ?? row.item.id);
347
+ focus(flatRows.at(-1)?.item.id ?? row.item.id);
197
348
  return;
198
349
  }
199
350
 
200
351
  if (row.item.disabled) {
201
352
  if (event.key === "ArrowLeft" && row.parentId) {
202
353
  event.preventDefault();
203
- focusRow(row.parentId);
354
+ focus(row.parentId);
204
355
  } else if (
205
356
  moveModifier ||
206
357
  ["ArrowRight", "Enter", " ", "F2", "ContextMenu"].includes(
207
- event.key,
358
+ event.key
208
359
  ) ||
209
360
  (event.shiftKey && event.key === "F10")
210
361
  ) {
@@ -215,91 +366,135 @@ export function useAISidebar({
215
366
 
216
367
  if (moveModifier && event.key === "ArrowUp" && previous) {
217
368
  event.preventDefault();
218
- void performMove({ 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
+ });
219
374
  return;
220
375
  }
221
376
  if (moveModifier && event.key === "ArrowDown" && next) {
222
377
  event.preventDefault();
223
- void performMove({ 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
+ });
224
383
  return;
225
384
  }
226
- if (moveModifier && event.key === "ArrowRight" && previous && canContain(previous.item)) {
385
+ if (
386
+ moveModifier &&
387
+ event.key === "ArrowRight" &&
388
+ previous &&
389
+ canContain(previous.item)
390
+ ) {
227
391
  event.preventDefault();
228
- setExpandedIds((current) => new Set(current).add(previous.item.id));
229
- void performMove({ itemId: row.item.id, targetId: previous.item.id, position: "inside" });
392
+ expand(previous.item.id);
393
+ void move({
394
+ itemId: row.item.id,
395
+ targetId: previous.item.id,
396
+ position: "inside",
397
+ });
230
398
  return;
231
399
  }
232
400
  if (moveModifier && event.key === "ArrowLeft" && row.parentId) {
233
401
  event.preventDefault();
234
- void performMove({ 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
+ });
235
407
  return;
236
408
  }
237
409
 
238
410
  if (event.key === "ArrowRight" && canContain(row.item)) {
239
411
  event.preventDefault();
240
- if (!expandedIds.has(row.item.id)) toggle(row.item.id);
241
- else if (next?.parentId === row.item.id) focusRow(next.item.id);
412
+ if (!expanded.has(row.item.id)) expand(row.item.id);
413
+ else if (next?.parentId === row.item.id) focus(next.item.id);
242
414
  } else if (event.key === "ArrowLeft") {
243
415
  event.preventDefault();
244
- if (expandedIds.has(row.item.id)) toggle(row.item.id);
245
- else if (row.parentId) focusRow(row.parentId);
416
+ if (expanded.has(row.item.id)) collapse(row.item.id);
417
+ else if (row.parentId) focus(row.parentId);
246
418
  } else if (event.key === "Enter" || event.key === " ") {
247
419
  event.preventDefault();
248
- if (canContain(row.item)) toggle(row.item.id);
420
+ if (canContain(row.item)) toggleExpanded(row.item.id);
249
421
  else select(row.item.id);
250
422
  } else if (event.key === "F2") {
251
423
  event.preventDefault();
252
- setRenamingId(row.item.id);
253
- } else if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
424
+ startRename(row.item.id);
425
+ } else if (
426
+ event.key === "ContextMenu" ||
427
+ (event.shiftKey && event.key === "F10")
428
+ ) {
254
429
  event.preventDefault();
255
- setMenuOpenId(row.item.id);
430
+ openMenu(row.item.id);
256
431
  }
257
432
  },
258
- [expandedIds, flat, focusRow, performMove, select, toggle],
433
+ [
434
+ collapse,
435
+ expand,
436
+ focus,
437
+ move,
438
+ openMenu,
439
+ readFlat,
440
+ select,
441
+ sidebarId,
442
+ startRename,
443
+ store,
444
+ toggleExpanded,
445
+ ]
259
446
  );
260
447
 
261
- const handleRootDragOver = useCallback(
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.
451
+ const onRootDragOver = useCallback(
262
452
  (event: DragEvent<HTMLDivElement>) => {
263
- if (!draggingId || event.target !== event.currentTarget) return;
453
+ if (
454
+ !store.get(aiSidebarDraggingIdAtom(sidebarId)) ||
455
+ event.target !== event.currentTarget
456
+ )
457
+ return;
264
458
  event.preventDefault();
265
459
  setDropTarget({ id: null, position: "after" });
266
460
  },
267
- [draggingId],
461
+ [setDropTarget, sidebarId, store]
268
462
  );
269
463
 
270
- const handleDrop = useCallback(
464
+ const onDrop = useCallback(
271
465
  (event: DragEvent<HTMLDivElement>) => {
272
466
  event.preventDefault();
273
467
  event.stopPropagation();
274
- if (draggingId && dropTarget) {
275
- void performMove({
276
- itemId: draggingId,
277
- targetId: dropTarget.id,
278
- position: dropTarget.position,
468
+ const dragging = store.get(aiSidebarDraggingIdAtom(sidebarId));
469
+ const target = store.get(aiSidebarDropTargetAtom(sidebarId));
470
+ if (dragging && target) {
471
+ void move({
472
+ itemId: dragging,
473
+ targetId: target.id,
474
+ position: target.position,
279
475
  });
280
476
  }
281
477
  },
282
- [draggingId, dropTarget, performMove],
478
+ [move, sidebarId, store]
283
479
  );
284
480
 
285
- const handleRowDragStart = useCallback(
481
+ const onRowDragStart = useCallback(
286
482
  (event: DragEvent<HTMLDivElement>, id: string) => {
287
483
  setDraggingId(id);
288
484
  event.dataTransfer.effectAllowed = "move";
289
485
  event.dataTransfer.setData("text/plain", id);
290
486
  },
291
- [],
487
+ [setDraggingId]
292
488
  );
293
489
 
294
- const handleRowDragEnd = useCallback(() => {
295
- setDraggingId(null);
296
- setDropTarget(null);
297
- }, []);
298
-
299
- const handleRowDragOver = useCallback(
490
+ const onRowDragOver = useCallback(
300
491
  (event: DragEvent<HTMLDivElement>, targetRow: FlatResource) => {
301
- if (!draggingId || draggingId === targetRow.item.id) return;
302
- 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
+ );
303
498
  if (source && containsResource(source, targetRow.item.id)) return;
304
499
  event.preventDefault();
305
500
  event.stopPropagation();
@@ -316,71 +511,106 @@ export function useAISidebar({
316
511
  : "after";
317
512
  setDropTarget({ id: targetRow.item.id, position });
318
513
  },
319
- [draggingId, renderedItems],
514
+ [setDropTarget, sidebarId, store]
320
515
  );
321
516
 
322
- const commitRename = useCallback(
323
- (row: FlatResource, label: string) => {
324
- const trimmed = label.trim();
325
- setRenamingId(null);
326
- if (!trimmed || trimmed === row.item.label) return;
327
- const before = renderedItems;
328
- updateItems(renameResource(before, row.item.id, trimmed));
329
- void Promise.resolve(onRename?.(row.item, trimmed)).catch(() => {
330
- updateItems(before);
331
- setAnnouncement(`Rename failed. ${row.item.label} was restored.`);
332
- });
333
- },
334
- [onRename, renderedItems, updateItems],
517
+ const dnd = useMemo(
518
+ () => ({
519
+ draggingId,
520
+ dropTarget,
521
+ onRootDragOver,
522
+ onDrop,
523
+ onRowDragStart,
524
+ onRowDragEnd,
525
+ onRowDragOver,
526
+ }),
527
+ [
528
+ draggingId,
529
+ dropTarget,
530
+ onRootDragOver,
531
+ onDrop,
532
+ onRowDragStart,
533
+ onRowDragEnd,
534
+ onRowDragOver,
535
+ ]
335
536
  );
336
537
 
337
- const handleMenuOpenChange = useCallback(
338
- (row: FlatResource, open: boolean) => {
339
- setMenuOpenId(open ? row.item.id : null);
340
- if (!open) focusRow(row.item.id);
341
- },
342
- [focusRow],
538
+ const hover = useMemo(
539
+ () => ({
540
+ hoveredId,
541
+ layoutId: hoverLayoutId,
542
+ onRowHover,
543
+ clear: clearHover,
544
+ }),
545
+ [clearHover, hoverLayoutId, hoveredId, onRowHover]
343
546
  );
344
547
 
345
- const handleRowHover = useCallback((id: string, hovered: boolean) => {
346
- setHoveredId((current) =>
347
- hovered ? id : current === id ? null : current,
348
- );
349
- }, []);
350
-
351
- const clearHover = useCallback(() => setHoveredId(null), []);
352
-
353
- const setRowRef = useCallback((id: string, node: HTMLDivElement | null) => {
354
- if (node) rowRefs.current.set(id, node);
355
- else rowRefs.current.delete(id);
356
- }, []);
357
-
358
- return {
359
- flat,
360
- selectedId,
361
- expandedIds,
362
- focusedId,
363
- setFocusedId,
364
- draggingId,
365
- dropTarget,
366
- hoveredId,
367
- hoverLayoutId,
368
- handleRowHover,
369
- clearHover,
370
- menuOpenId,
371
- renamingId,
372
- setRenamingId,
373
- announcement,
374
- select,
375
- toggle,
376
- handleKeyDown,
377
- handleRootDragOver,
378
- handleDrop,
379
- handleRowDragStart,
380
- handleRowDragEnd,
381
- handleRowDragOver,
382
- commitRename,
383
- handleMenuOpenChange,
384
- setRowRef,
385
- };
548
+ return useMemo<AISidebarController>(
549
+ () => ({
550
+ sidebarId,
551
+ items: renderedItems,
552
+ flat,
553
+ getItem,
554
+ selectedId,
555
+ select,
556
+ expandedIds,
557
+ isExpanded,
558
+ expand,
559
+ collapse,
560
+ toggleExpanded,
561
+ expandAll,
562
+ collapseAll,
563
+ reveal,
564
+ focusedId,
565
+ focus,
566
+ renamingId,
567
+ startRename,
568
+ cancelRename,
569
+ rename,
570
+ menuOpenId,
571
+ openMenu,
572
+ closeMenu,
573
+ move,
574
+ movePending,
575
+ announcement,
576
+ reset,
577
+ dnd,
578
+ hover,
579
+ onRowKeyDown,
580
+ setRowRef,
581
+ }),
582
+ [
583
+ sidebarId,
584
+ renderedItems,
585
+ flat,
586
+ getItem,
587
+ selectedId,
588
+ select,
589
+ expandedIds,
590
+ isExpanded,
591
+ expand,
592
+ collapse,
593
+ toggleExpanded,
594
+ expandAll,
595
+ collapseAll,
596
+ reveal,
597
+ focusedId,
598
+ focus,
599
+ renamingId,
600
+ startRename,
601
+ cancelRename,
602
+ rename,
603
+ menuOpenId,
604
+ openMenu,
605
+ closeMenu,
606
+ move,
607
+ movePending,
608
+ announcement,
609
+ reset,
610
+ dnd,
611
+ hover,
612
+ onRowKeyDown,
613
+ setRowRef,
614
+ ]
615
+ );
386
616
  }