@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
@@ -1,19 +1,47 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
4
5
 
5
- import { playCue } from "@/lib/sound"
6
- import type { TreeController, TreeNode, UseTreeOptions } from "./type"
7
- import { useTreeDnd } from "./use-tree-dnd"
8
6
  import {
9
- ancestorIdsOf,
10
- expandableIdsOf,
11
- findNode,
12
- hasThreeLevels,
13
- initialExpandedIds,
14
- moveNode,
15
- renameNode,
16
- } from "./utils"
7
+ cancelTreeRenameAtom,
8
+ collapseAllTreeNodesAtom,
9
+ collapseTreeNodeAtom,
10
+ expandAllTreeNodesAtom,
11
+ expandTreeNodeAtom,
12
+ mountTreeAtom,
13
+ moveTreeNodeAtom,
14
+ projectTreeActiveIdAtom,
15
+ projectTreeCollapsedAtom,
16
+ projectTreeItemsAtom,
17
+ removeTreeInstance,
18
+ renameTreeNodeAtom,
19
+ resetTreeAtom,
20
+ revealTreeAncestorsAtom,
21
+ revealTreeNodeAtom,
22
+ selectTreeNodeAtom,
23
+ setTreeCollapsedAtom,
24
+ setTreeHandlersAtom,
25
+ startTreeRenameAtom,
26
+ toggleTreeCollapsedAtom,
27
+ toggleTreeNodeAtom,
28
+ treeActiveIdAtom,
29
+ treeCanMoveAtom,
30
+ treeCanRenameAtom,
31
+ treeCollapsedAtom,
32
+ treeExpandedIdsAtom,
33
+ treeOwnedItemsAtom,
34
+ treeRenamingIdAtom,
35
+ } from "./tree-atom"
36
+ import type {
37
+ TreeConfig,
38
+ TreeController,
39
+ TreeHandlers,
40
+ TreeSeed,
41
+ UseTreeOptions,
42
+ } from "./type"
43
+ import { useTreeDnd } from "./use-tree-dnd"
44
+ import { findNode, hasThreeLevels } from "./utils"
17
45
 
18
46
  /**
19
47
  * The tree's headless controller — every behaviour the component exposes,
@@ -32,6 +60,10 @@ import {
32
60
  * <Tree tree={tree} />
33
61
  * <Button onClick={tree.collapseAll}>Collapse all</Button>
34
62
  * ```
63
+ *
64
+ * State lives in Jotai atoms keyed by `treeId`, so anything under
65
+ * `ExegiaProvider` can drive this tree without the controller — name it and
66
+ * reach for `useTreeState(treeId)` / `useTreeActions(treeId)` elsewhere.
35
67
  */
36
68
  export function useTree(options: UseTreeOptions): TreeController {
37
69
  const {
@@ -45,206 +77,172 @@ export function useTree(options: UseTreeOptions): TreeController {
45
77
  sound = true,
46
78
  } = options
47
79
 
48
- // Data: controlled through `items`, otherwise owned here.
49
- const [ownItems, setOwnItems] = React.useState<TreeNode[]>(
50
- () => options.defaultItems ?? []
51
- )
52
- const items = options.items ?? ownItems
53
- // The hook may only rewrite the data when it owns it, or when the
54
- // consumer asked for the next tree through `onItemsChange`.
55
- const managesItems =
56
- options.items === undefined || onItemsChange !== undefined
57
-
58
- const setItems = React.useCallback(
59
- (next: TreeNode[]) => {
60
- if (options.items === undefined) setOwnItems(next)
61
- onItemsChange?.(next)
62
- },
63
- [options.items, onItemsChange]
64
- )
65
-
66
- const sectioned = variant === "navigation" && hasThreeLevels(items)
80
+ // A generated key isolates unnamed trees from each other; an explicit
81
+ // `treeId` is the app's handle on this one.
82
+ const generatedId = React.useId()
83
+ const treeId = options.treeId ?? generatedId
67
84
 
68
- // Selection: controlled through `activeId`, otherwise owned here.
69
- const [ownActiveId, setOwnActiveId] = React.useState(options.defaultActiveId)
70
- const activeId = options.activeId ?? ownActiveId
85
+ const controlsItems = options.items !== undefined
86
+ const controlsActiveId = options.activeId !== undefined
87
+ const controlsCollapsed = options.collapsed !== undefined
88
+ // The hook may only rewrite the data when it owns it, or when the consumer
89
+ // asked for the next tree through `onItemsChange`.
90
+ const managesItems = !controlsItems || onItemsChange !== undefined
91
+ const hasRenameHandler = onRename !== undefined
92
+ const hasMoveHandler = onMove !== undefined
71
93
 
72
- const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => {
73
- const expanded = options.defaultExpandedIds
74
- ? new Set(options.defaultExpandedIds)
75
- : initialExpandedIds(items, activeId)
76
- // Section names default open — they are headings over their run of
77
- // items, not drawers; `defaultOpen: false` still starts one closed.
78
- if (sectioned && !options.defaultExpandedIds)
79
- for (const node of items)
80
- if (node.defaultOpen !== false) expanded.add(node.id)
81
- return expanded
82
- })
83
-
84
- const commitExpanded = React.useCallback(
85
- (next: Set<string>) => {
86
- setExpandedIds(next)
87
- onExpandedChange?.([...next])
88
- },
89
- [onExpandedChange]
94
+ // Primitives only, so this object is stable and the store write below runs
95
+ // once per real change rather than once per render.
96
+ const config = React.useMemo<TreeConfig>(
97
+ () => ({
98
+ variant,
99
+ sound,
100
+ controlsItems,
101
+ controlsActiveId,
102
+ controlsCollapsed,
103
+ managesItems,
104
+ hasRenameHandler,
105
+ hasMoveHandler,
106
+ }),
107
+ [
108
+ variant,
109
+ sound,
110
+ controlsItems,
111
+ controlsActiveId,
112
+ controlsCollapsed,
113
+ managesItems,
114
+ hasRenameHandler,
115
+ hasMoveHandler,
116
+ ]
90
117
  )
91
118
 
92
- // Navigating into a nested entry from elsewhere reveals its ancestors,
93
- // even ones the reader had collapsed. Adjusted during render so the
94
- // branch never paints closed for a frame first.
95
- const [prevActiveId, setPrevActiveId] = React.useState(activeId)
96
- if (activeId !== prevActiveId) {
97
- setPrevActiveId(activeId)
98
- if (activeId !== undefined) {
99
- const ancestors = ancestorIdsOf(items, activeId)
100
- if (ancestors.some((id) => !expandedIds.has(id)))
101
- setExpandedIds(new Set([...expandedIds, ...ancestors]))
102
- }
103
- }
104
-
105
- // Rail fold: controlled through `collapsed`, otherwise owned here. Only
106
- // `sidebar` has a rail — every other variant reads as expanded.
107
- const [ownCollapsed, setOwnCollapsed] = React.useState(
108
- options.defaultCollapsed ?? false
119
+ const handlers = React.useMemo<TreeHandlers>(
120
+ () => ({
121
+ onNavigate,
122
+ onItemsChange,
123
+ onExpandedChange,
124
+ onCollapsedChange,
125
+ onRename,
126
+ onMove,
127
+ }),
128
+ [
129
+ onNavigate,
130
+ onItemsChange,
131
+ onExpandedChange,
132
+ onCollapsedChange,
133
+ onRename,
134
+ onMove,
135
+ ]
109
136
  )
110
- const collapsed =
111
- variant === "sidebar" ? (options.collapsed ?? ownCollapsed) : false
112
137
 
113
- const [renamingId, setRenamingId] = React.useState<string | null>(null)
138
+ // Read once: the `default*` options describe the mount, not every render.
139
+ const [seed] = React.useState<TreeSeed>(() => ({
140
+ items: options.items ?? options.defaultItems ?? [],
141
+ activeId: options.activeId ?? options.defaultActiveId,
142
+ collapsed: options.collapsed ?? options.defaultCollapsed ?? false,
143
+ expandedIds: options.defaultExpandedIds
144
+ ? [...options.defaultExpandedIds]
145
+ : undefined,
146
+ }))
114
147
 
115
- const canRename =
116
- variant === "files" && (onRename !== undefined || managesItems)
117
- const canMove = variant === "files" && (onMove !== undefined || managesItems)
148
+ const mount = useSetAtom(mountTreeAtom(treeId))
149
+ const publishHandlers = useSetAtom(setTreeHandlersAtom(treeId))
150
+ const projectItems = useSetAtom(projectTreeItemsAtom(treeId))
151
+ const projectActiveId = useSetAtom(projectTreeActiveIdAtom(treeId))
152
+ const projectCollapsed = useSetAtom(projectTreeCollapsedAtom(treeId))
153
+ const revealAncestors = useSetAtom(revealTreeAncestorsAtom(treeId))
118
154
 
119
- const isExpanded = React.useCallback(
120
- (id: string) => expandedIds.has(id),
121
- [expandedIds]
122
- )
155
+ // Handlers first — they must be in the store before any action can fire.
156
+ // Rewritten every commit; no read atom depends on them, so nobody
157
+ // re-renders for it.
158
+ React.useLayoutEffect(() => {
159
+ publishHandlers(handlers)
160
+ }, [publishHandlers, handlers])
123
161
 
124
- const expand = React.useCallback(
125
- (id: string) => {
126
- if (expandedIds.has(id)) return
127
- commitExpanded(new Set([...expandedIds, id]))
128
- if (sound) playCue("toggle")
129
- },
130
- [commitExpanded, expandedIds, sound]
131
- )
162
+ // Before paint, so a seeded branch never paints closed for a frame.
163
+ React.useLayoutEffect(() => {
164
+ mount(config, seed)
165
+ }, [mount, config, seed])
132
166
 
133
- const collapse = React.useCallback(
134
- (id: string) => {
135
- if (!expandedIds.has(id)) return
136
- const next = new Set(expandedIds)
137
- next.delete(id)
138
- commitExpanded(next)
139
- if (sound) playCue("toggle")
140
- },
141
- [commitExpanded, expandedIds, sound]
142
- )
167
+ // Controlled props stay the source of truth; the store carries a projection
168
+ // so the action atoms and remote readers see current data.
169
+ React.useLayoutEffect(() => {
170
+ if (options.items !== undefined) projectItems(options.items)
171
+ }, [options.items, projectItems])
143
172
 
144
- const toggleExpanded = React.useCallback(
145
- (id: string) => {
146
- const next = new Set(expandedIds)
147
- if (next.has(id)) next.delete(id)
148
- else next.add(id)
149
- commitExpanded(next)
150
- if (sound) playCue("toggle")
151
- },
152
- [commitExpanded, expandedIds, sound]
153
- )
173
+ React.useLayoutEffect(() => {
174
+ if (options.activeId !== undefined) projectActiveId(options.activeId)
175
+ }, [options.activeId, projectActiveId])
154
176
 
155
- const expandAll = React.useCallback(() => {
156
- commitExpanded(new Set(expandableIdsOf(items, variant === "files")))
157
- }, [commitExpanded, items, variant])
177
+ React.useLayoutEffect(() => {
178
+ if (options.collapsed !== undefined) projectCollapsed(options.collapsed)
179
+ }, [options.collapsed, projectCollapsed])
158
180
 
159
- const collapseAll = React.useCallback(() => {
160
- commitExpanded(new Set())
161
- }, [commitExpanded])
181
+ // A tree the hook keyed is scrap once its component goes. An explicit
182
+ // `treeId` is the app's key and outlives the mount.
183
+ React.useEffect(() => {
184
+ if (options.treeId !== undefined) return
185
+ return () => removeTreeInstance(treeId)
186
+ }, [treeId, options.treeId])
162
187
 
163
- /** Open every ancestor of `id` without touching the rest of the tree. */
164
- const reveal = React.useCallback(
165
- (id: string) => {
166
- const ancestors = ancestorIdsOf(items, id)
167
- if (!ancestors.some((ancestor) => !expandedIds.has(ancestor))) return
168
- commitExpanded(new Set([...expandedIds, ...ancestors]))
169
- },
170
- [commitExpanded, expandedIds, items]
171
- )
188
+ // Never `treeItemsAtom` — see the note on `treeOwnedItemsAtom`.
189
+ const ownedItems = useAtomValue(treeOwnedItemsAtom(treeId))
190
+ const items = options.items ?? ownedItems
191
+ const activeId = useAtomValue(treeActiveIdAtom(treeId))
192
+ const expandedIds = useAtomValue(treeExpandedIdsAtom(treeId))
193
+ const collapsed = useAtomValue(treeCollapsedAtom(treeId))
194
+ const renamingId = useAtomValue(treeRenamingIdAtom(treeId))
195
+ const canRename = useAtomValue(treeCanRenameAtom(treeId))
196
+ const canMove = useAtomValue(treeCanMoveAtom(treeId))
172
197
 
173
- const setCollapsed = React.useCallback(
174
- (next: boolean) => {
175
- if (options.collapsed === undefined) setOwnCollapsed(next)
176
- onCollapsedChange?.(next)
177
- if (sound) playCue("toggle")
178
- },
179
- [options.collapsed, onCollapsedChange, sound]
180
- )
198
+ // Navigating into a nested entry from elsewhere reveals its ancestors, even
199
+ // ones the reader had collapsed. In a layout effect, so the branch is open
200
+ // before the frame paints.
201
+ const lastActiveId = React.useRef(activeId)
202
+ React.useLayoutEffect(() => {
203
+ if (lastActiveId.current === activeId) return
204
+ lastActiveId.current = activeId
205
+ if (activeId !== undefined) revealAncestors(activeId)
206
+ }, [activeId, revealAncestors])
181
207
 
182
- const toggleCollapsed = React.useCallback(
183
- () => setCollapsed(!collapsed),
184
- [collapsed, setCollapsed]
208
+ const sectioned = React.useMemo(
209
+ () => variant === "navigation" && hasThreeLevels(items),
210
+ [variant, items]
211
+ )
212
+ const sectionIds = React.useMemo(
213
+ () => (sectioned ? items.map((node) => node.id) : []),
214
+ [items, sectioned]
185
215
  )
186
-
187
216
  const getNode = React.useCallback(
188
217
  (id: string) => findNode(items, id),
189
218
  [items]
190
219
  )
191
-
192
- /** Selecting runs the node's own `onSelect` first, then `onNavigate` —
193
- * the same order a row press follows. Disabled nodes are inert. */
194
- const select = React.useCallback(
195
- (id: string) => {
196
- const node = findNode(items, id)
197
- if (!node || node.disabled) return
198
- if (options.activeId === undefined) setOwnActiveId(id)
199
- node.onSelect?.()
200
- onNavigate?.(node)
201
- },
202
- [items, options.activeId, onNavigate]
203
- )
204
-
205
- const startRename = React.useCallback(
206
- (id: string) => {
207
- // An id from elsewhere (a stale selection, another tree) would arm
208
- // rename mode against a row that never renders.
209
- if (!canRename || !findNode(items, id)) return
210
- setRenamingId(id)
211
- },
212
- [canRename, items]
213
- )
214
-
215
- const cancelRename = React.useCallback(() => setRenamingId(null), [])
216
-
217
- const rename = React.useCallback(
218
- (id: string, label: string) => {
219
- setRenamingId(null)
220
- const next = label.trim()
221
- if (!canRename || !next) return
222
- const node = findNode(items, id)
223
- if (!node || node.label === next) return
224
- onRename?.(id, next)
225
- if (managesItems) setItems(renameNode(items, id, next))
226
- },
227
- [canRename, items, managesItems, onRename, setItems]
228
- )
229
-
230
- const move = React.useCallback(
231
- (id: string, parentId: string | null, index: number) => {
232
- if (!canMove) return
233
- onMove?.(id, parentId, index)
234
- if (managesItems) setItems(moveNode(items, id, parentId, index))
235
- },
236
- [canMove, items, managesItems, onMove, setItems]
220
+ const isExpanded = React.useCallback(
221
+ (id: string) => expandedIds.has(id),
222
+ [expandedIds]
237
223
  )
238
224
 
239
- const dnd = useTreeDnd(items, canMove ? move : undefined)
225
+ // `useSetAtom` setters are already stable — no useCallback needed.
226
+ const expand = useSetAtom(expandTreeNodeAtom(treeId))
227
+ const collapse = useSetAtom(collapseTreeNodeAtom(treeId))
228
+ const toggleExpanded = useSetAtom(toggleTreeNodeAtom(treeId))
229
+ const expandAll = useSetAtom(expandAllTreeNodesAtom(treeId))
230
+ const collapseAll = useSetAtom(collapseAllTreeNodesAtom(treeId))
231
+ const reveal = useSetAtom(revealTreeNodeAtom(treeId))
232
+ const setCollapsed = useSetAtom(setTreeCollapsedAtom(treeId))
233
+ const toggleCollapsed = useSetAtom(toggleTreeCollapsedAtom(treeId))
234
+ const select = useSetAtom(selectTreeNodeAtom(treeId))
235
+ const startRename = useSetAtom(startTreeRenameAtom(treeId))
236
+ const cancelRename = useSetAtom(cancelTreeRenameAtom(treeId))
237
+ const rename = useSetAtom(renameTreeNodeAtom(treeId))
238
+ const move = useSetAtom(moveTreeNodeAtom(treeId))
239
+ const reset = useSetAtom(resetTreeAtom(treeId))
240
240
 
241
- const sectionIds = React.useMemo(
242
- () => (sectioned ? items.map((node) => node.id) : []),
243
- [items, sectioned]
244
- )
241
+ const dnd = useTreeDnd(treeId)
245
242
 
246
243
  return React.useMemo<TreeController>(
247
244
  () => ({
245
+ treeId,
248
246
  variant,
249
247
  items,
250
248
  sectioned,
@@ -271,9 +269,11 @@ export function useTree(options: UseTreeOptions): TreeController {
271
269
  rename,
272
270
  canMove,
273
271
  move,
272
+ reset,
274
273
  dnd,
275
274
  }),
276
275
  [
276
+ treeId,
277
277
  variant,
278
278
  items,
279
279
  sectioned,
@@ -300,6 +300,7 @@ export function useTree(options: UseTreeOptions): TreeController {
300
300
  rename,
301
301
  canMove,
302
302
  move,
303
+ reset,
303
304
  dnd,
304
305
  ]
305
306
  )
@@ -1,101 +1,13 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
-
5
- import {
6
- Avatar,
7
- AvatarFallback,
8
- AvatarImage,
9
- type AvatarPrimitive,
10
- } from "@/components/ui/avatar"
11
- import { Skeleton } from "@/components/ui/skeleton"
12
- import { cn } from "@/lib/utils"
13
-
14
- type ImageStatus = Parameters<
15
- NonNullable<AvatarPrimitive.Image.Props["onLoadingStatusChange"]>
16
- >[0]
17
-
18
- export interface UserAvatarProps extends Omit<
19
- AvatarPrimitive.Root.Props,
20
- "children"
21
- > {
22
- /** Image URL. Without one the initials show immediately — no skeleton. */
23
- src?: string
24
- /** Drives the initials, and the alt text unless `alt` overrides it. */
25
- name?: string
26
- /** Overrides the initials derived from `name`. */
27
- initials?: string
28
- /**
29
- * Alt text for the image. Pass "" when adjacent text already names the
30
- * person — the image is then decorative.
31
- */
32
- alt?: string
33
- /**
34
- * Force the skeleton, for when the identity itself is still being fetched.
35
- * Omitted, it follows the image: a passed `src` skeletons until it resolves.
36
- */
37
- loading?: boolean
38
- }
39
-
40
- /** "Jenny Hamilton" → "JH", "luna" → "L". */
41
- export function initialsFrom(name: string): string {
42
- const parts = name.trim().split(/\s+/).filter(Boolean)
43
- if (parts.length === 0) return ""
44
- const first = parts[0]?.[0] ?? ""
45
- const last = parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : ""
46
- return (first + last).toUpperCase()
47
- }
48
-
49
1
  /**
50
- * Identity avatar: an image when one is given, initials otherwise.
51
- *
52
- * A passed `src` is assumed to be remote, so the slot holds a skeleton until
53
- * the image resolves rather than flashing initials that are about to be
54
- * replaced. A failed load settles on the initials.
2
+ * Back-compat path. The avatar moved to `components/user-avatar/` (split
3
+ * into component / atoms / hooks); this module re-exports it so existing
4
+ * imports keep resolving to the one implementation. Prefer the new path.
55
5
  */
56
- export function UserAvatar({
57
- src,
58
- name = "",
59
- initials,
60
- alt,
61
- loading: loadingProp,
62
- className,
63
- ...props
64
- }: UserAvatarProps): React.ReactElement {
65
- const [status, setStatus] = React.useState<ImageStatus>("idle")
66
- const loading =
67
- loadingProp ??
68
- (src !== undefined && (status === "idle" || status === "loading"))
69
-
70
- return (
71
- <Avatar
72
- className={cn(
73
- "size-8 border-2 border-white bg-neutral-800 text-xl",
74
- className
75
- )}
76
- data-loading={loading || undefined}
77
- data-slot="user-avatar"
78
- {...props}
79
- >
80
- {/* Held back while forced-loading: a loaded image would hide the
81
- fallback the skeleton lives in. */}
82
- {src === undefined || loadingProp === true ? null : (
83
- <AvatarImage
84
- alt={alt ?? name}
85
- onLoadingStatusChange={setStatus}
86
- src={src}
87
- />
88
- )}
89
- <AvatarFallback>
90
- {loading ? (
91
- <Skeleton
92
- className="size-full rounded-full"
93
- data-slot="avatar-skeleton"
94
- />
95
- ) : (
96
- (initials ?? initialsFrom(name))
97
- )}
98
- </AvatarFallback>
99
- </Avatar>
100
- )
101
- }
6
+ export {
7
+ UserAvatar,
8
+ initialsFrom,
9
+ useUserAvatar,
10
+ useUserAvatarActions,
11
+ useUserAvatarState,
12
+ } from "@/components/user-avatar"
13
+ export type { UserAvatarProps, UserPresence } from "@/components/user-avatar"