@exegia/corpora-ui 0.20.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 (123) 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/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 -0
  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/tree/__tests__/tree-atom.test.d.ts +1 -0
  35. package/dist-lib/components/composed/tree/constants.d.ts +4 -4
  36. package/dist-lib/components/composed/tree/index.d.ts +3 -1
  37. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  38. package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
  39. package/dist-lib/components/composed/tree/type.d.ts +116 -18
  40. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
  41. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  42. package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
  43. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  44. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  45. package/dist-lib/components/user-avatar/component.d.ts +1 -8
  46. package/dist-lib/components/user-avatar/index.d.ts +5 -0
  47. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  48. package/dist-lib/components/user-avatar/type.d.ts +45 -0
  49. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  50. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
  51. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
  52. package/dist-lib/index.d.ts +5 -2
  53. package/dist-lib/index.js +3004 -1796
  54. package/dist-lib/index.js.map +1 -1
  55. package/dist-lib/state/exegia-provider.d.ts +51 -0
  56. package/dist-lib/state/index.d.ts +4 -0
  57. package/dist-lib/state/store.d.ts +17 -0
  58. package/package.json +15 -12
  59. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  60. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  61. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  62. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  63. package/src/components/blocks/auth/auth-state.ts +52 -0
  64. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  65. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  66. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  67. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
  68. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  69. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  70. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  71. package/src/components/blocks/nav/sidebar/type.ts +125 -30
  72. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  73. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
  74. package/src/components/blocks/nav/sidebar/utils.ts +1 -1
  75. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  76. package/src/components/blocks/profile/index.ts +28 -0
  77. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  78. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  79. package/src/components/blocks/profile/type.ts +95 -0
  80. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  81. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  82. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  83. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  84. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  85. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  86. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  87. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  88. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  89. package/src/components/blocks/shell/index.ts +18 -0
  90. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  91. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  92. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  93. package/src/components/blocks/shell/type.ts +130 -3
  94. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  95. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  96. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  97. package/src/components/blocks/shell/utils.ts +44 -4
  98. package/src/components/composed/tree/CLAUDE.md +132 -0
  99. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  100. package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
  101. package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
  102. package/src/components/composed/tree/constants.ts +4 -4
  103. package/src/components/composed/tree/index.ts +38 -0
  104. package/src/components/composed/tree/tree-atom.ts +590 -0
  105. package/src/components/composed/tree/tree-node.tsx +117 -78
  106. package/src/components/composed/tree/tree.tsx +24 -16
  107. package/src/components/composed/tree/type.ts +125 -15
  108. package/src/components/composed/tree/use-tree-dnd.ts +82 -51
  109. package/src/components/composed/tree/use-tree-state.ts +105 -0
  110. package/src/components/composed/tree/use-tree.ts +185 -184
  111. package/src/components/composed/user-avatar.tsx +11 -99
  112. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
  113. package/src/components/user-avatar/component.tsx +121 -22
  114. package/src/components/user-avatar/index.ts +19 -0
  115. package/src/components/user-avatar/presence-badge.tsx +56 -0
  116. package/src/components/user-avatar/type.ts +50 -0
  117. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  118. package/src/components/user-avatar/use-user-avatar.ts +144 -0
  119. package/src/components/user-avatar/user-avatar-atom.ts +198 -0
  120. package/src/index.ts +34 -2
  121. package/src/state/exegia-provider.tsx +79 -0
  122. package/src/state/index.ts +4 -0
  123. package/src/state/store.ts +19 -0
@@ -1,15 +1,18 @@
1
- "use client";
1
+ "use client"
2
2
 
3
- import { AnimatePresence } from "motion/react";
4
- import { cn } from "@/lib/utils";
3
+ import { useMemo } from "react"
4
+ import { AnimatePresence } from "motion/react"
5
+ import { cn } from "@/lib/utils"
5
6
  import type {
6
7
  AISidebarComponentProps,
8
+ AISidebarContextValue,
7
9
  AISidebarController,
8
10
  AISidebarProps,
9
11
  AISidebarViewProps,
10
12
  FlatResource,
11
13
  } from "./type"
12
14
  import { useAISidebar } from "./use-ai-sidebar"
15
+ import { AISidebarContext } from "./sidebar-context"
13
16
  import { ResourceRow } from "./sidebar-row"
14
17
 
15
18
  /**
@@ -69,8 +72,47 @@ function AISidebarView({
69
72
  const { dnd, hover } = controller
70
73
  const { draggingId, dropTarget } = dnd
71
74
 
75
+ // Rows take the sidebar's id and the stable handlers, never the controller
76
+ // — so this value keeps its identity and a hover or a toggle re-renders
77
+ // only the rows whose own atoms changed. Deps name each function because
78
+ // `dnd` itself carries live drag state and changes identity with it.
79
+ const context = useMemo<AISidebarContextValue>(
80
+ () => ({
81
+ sidebarId: controller.sidebarId,
82
+ hoverLayoutId: hover.layoutId,
83
+ renderIcon,
84
+ renderMenu,
85
+ renderActionsTrigger,
86
+ setRowRef: controller.setRowRef,
87
+ onRowKeyDown: controller.onRowKeyDown,
88
+ onRowHover: hover.onRowHover,
89
+ closeMenu: controller.closeMenu,
90
+ dnd: {
91
+ onDrop: dnd.onDrop,
92
+ onRowDragStart: dnd.onRowDragStart,
93
+ onRowDragEnd: dnd.onRowDragEnd,
94
+ onRowDragOver: dnd.onRowDragOver,
95
+ },
96
+ }),
97
+ [
98
+ controller.sidebarId,
99
+ controller.setRowRef,
100
+ controller.onRowKeyDown,
101
+ controller.closeMenu,
102
+ hover.layoutId,
103
+ hover.onRowHover,
104
+ renderIcon,
105
+ renderMenu,
106
+ renderActionsTrigger,
107
+ dnd.onDrop,
108
+ dnd.onRowDragStart,
109
+ dnd.onRowDragEnd,
110
+ dnd.onRowDragOver,
111
+ ]
112
+ )
113
+
72
114
  return (
73
- <>
115
+ <AISidebarContext.Provider value={context}>
74
116
  <div
75
117
  role="tree"
76
118
  aria-label={ariaLabel}
@@ -86,43 +128,7 @@ function AISidebarView({
86
128
  >
87
129
  <AnimatePresence initial={false}>
88
130
  {controller.flat.map((row: FlatResource) => (
89
- <ResourceRow
90
- key={row.item.id}
91
- row={row}
92
- active={controller.selectedId === row.item.id}
93
- expanded={controller.isExpanded(row.item.id)}
94
- focused={controller.focusedId === row.item.id}
95
- draggingId={draggingId}
96
- dropTarget={dropTarget}
97
- menuOpen={controller.menuOpenId === row.item.id}
98
- renaming={controller.renamingId === row.item.id}
99
- hoverActive={hover.hoveredId !== null && !draggingId}
100
- hoverPill={
101
- hover.hoveredId === row.item.id &&
102
- !draggingId &&
103
- !row.item.disabled
104
- }
105
- hoverLayoutId={hover.layoutId}
106
- onHoverChange={(hovered) => hover.onRowHover(row.item.id, hovered)}
107
- onFocus={() => controller.focus(row.item.id)}
108
- onSelect={() => controller.select(row.item.id)}
109
- onToggle={() => controller.toggleExpanded(row.item.id)}
110
- onKeyDown={(event) => controller.onRowKeyDown(event, row)}
111
- onRenameStart={() => controller.startRename(row.item.id)}
112
- onRenameCancel={controller.cancelRename}
113
- onRenameCommit={(label) => controller.rename(row.item.id, label)}
114
- onMenuOpenChange={(open) =>
115
- open ? controller.openMenu(row.item.id) : controller.closeMenu()
116
- }
117
- onDragStart={dnd.onRowDragStart}
118
- onDragEnd={dnd.onRowDragEnd}
119
- onDragOver={dnd.onRowDragOver}
120
- onDrop={dnd.onDrop}
121
- renderIcon={renderIcon}
122
- renderMenu={renderMenu}
123
- renderActionsTrigger={renderActionsTrigger}
124
- setRef={(node) => controller.setRowRef(row.item.id, node)}
125
- />
131
+ <ResourceRow key={row.item.id} row={row} />
126
132
  ))}
127
133
  </AnimatePresence>
128
134
 
@@ -139,6 +145,6 @@ function AISidebarView({
139
145
  <span className="sr-only" aria-live="polite">
140
146
  {controller.announcement}
141
147
  </span>
142
- </>
148
+ </AISidebarContext.Provider>
143
149
  )
144
150
  }
@@ -3,7 +3,59 @@ import { MarqueeLabel } from "./marquee-label.tsx"
3
3
  import { ResourceRow } from "./sidebar-row.tsx"
4
4
  import { SidebarIcon } from "./sidebar-icon.tsx"
5
5
  export { useAISidebar, type UseAISidebarOptions } from "./use-ai-sidebar.ts"
6
+ export {
7
+ useAISidebarActions,
8
+ useAISidebarState,
9
+ } from "./use-ai-sidebar-state.ts"
6
10
  export type * from "./type.ts"
11
+ // The public atom surface. `@internal` atoms (config, handlers, mount, the
12
+ // projections, the owned-* loop guards) stay unexported on purpose —
13
+ // `export *` would make every internal a breaking-change surface for
14
+ // consumer apps.
15
+ export {
16
+ aiSidebarActiveIdAtom,
17
+ aiSidebarAnnouncementAtom,
18
+ aiSidebarDraggingIdAtom,
19
+ aiSidebarDropTargetAtom,
20
+ aiSidebarExpandedIdsAtom,
21
+ aiSidebarFlatAtom,
22
+ aiSidebarFocusedIdAtom,
23
+ aiSidebarHoverActiveAtom,
24
+ aiSidebarHoveredIdAtom,
25
+ aiSidebarItemsAtom,
26
+ aiSidebarMenuOpenIdAtom,
27
+ aiSidebarMovePendingAtom,
28
+ aiSidebarRenamingIdAtom,
29
+ aiSidebarRowDraggingAtom,
30
+ aiSidebarRowDropAtom,
31
+ aiSidebarRowExpandedAtom,
32
+ aiSidebarRowFocusedAtom,
33
+ aiSidebarRowHoveredAtom,
34
+ aiSidebarRowMenuOpenAtom,
35
+ aiSidebarRowRenamingAtom,
36
+ aiSidebarRowSelectedAtom,
37
+ aiSidebarStateAtom,
38
+ cancelAISidebarRenameAtom,
39
+ clearAISidebarHoverAtom,
40
+ closeAISidebarMenuAtom,
41
+ collapseAISidebarRowAtom,
42
+ collapseAllAISidebarRowsAtom,
43
+ endAISidebarDragAtom,
44
+ expandAISidebarRowAtom,
45
+ expandAllAISidebarRowsAtom,
46
+ focusAISidebarRowAtom,
47
+ moveAISidebarRowAtom,
48
+ openAISidebarMenuAtom,
49
+ removeAISidebarInstance,
50
+ renameAISidebarRowAtom,
51
+ resetAISidebarAtom,
52
+ revealAISidebarRowAtom,
53
+ selectAISidebarRowAtom,
54
+ setAISidebarHoveredAtom,
55
+ setAISidebarItemsAtom,
56
+ startAISidebarRenameAtom,
57
+ toggleAISidebarRowAtom,
58
+ } from "./ai-sidebar-atom.ts"
7
59
 
8
60
  export { AISidebar, MarqueeLabel, ResourceRow, SidebarIcon }
9
61
 
@@ -11,5 +63,5 @@ export const Sidebar = {
11
63
  Wrapper: AISidebar,
12
64
  MarqueeLabel,
13
65
  Row: ResourceRow,
14
- Icon: SidebarIcon
66
+ Icon: SidebarIcon,
15
67
  }
@@ -0,0 +1,15 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+
5
+ import type { AISidebarContextValue } from "./type.ts";
6
+
7
+ export const AISidebarContext = createContext<AISidebarContextValue | null>(
8
+ null
9
+ );
10
+
11
+ export function useAISidebarContext(): AISidebarContextValue {
12
+ const context = useContext(AISidebarContext);
13
+ if (!context) throw new Error("Sidebar rows must render inside <AISidebar>.");
14
+ return context;
15
+ }
@@ -1,10 +1,15 @@
1
+ "use client"
2
+
1
3
  import {
2
4
  cloneElement,
5
+ memo,
6
+ useCallback,
3
7
  useEffect,
4
8
  useRef,
5
9
  useState,
6
10
  type MouseEvent,
7
11
  } from "react"
12
+ import { useAtomValue, useSetAtom } from "jotai"
8
13
  import type { ResourceRowProps } from "./type"
9
14
  import {
10
15
  AnimatePresence,
@@ -15,6 +20,25 @@ import {
15
20
  import { ChevronRight, MoreHorizontal, Pencil } from "lucide-react"
16
21
  import { SPRING_LAYOUT } from "@/lib/ease.ts"
17
22
  import { cn } from "@/lib/utils.ts"
23
+ import {
24
+ aiSidebarHoverActiveAtom,
25
+ aiSidebarRowDraggingAtom,
26
+ aiSidebarRowDropAtom,
27
+ aiSidebarRowExpandedAtom,
28
+ aiSidebarRowFocusedAtom,
29
+ aiSidebarRowHoveredAtom,
30
+ aiSidebarRowMenuOpenAtom,
31
+ aiSidebarRowRenamingAtom,
32
+ aiSidebarRowSelectedAtom,
33
+ cancelAISidebarRenameAtom,
34
+ focusAISidebarRowAtom,
35
+ openAISidebarMenuAtom,
36
+ renameAISidebarRowAtom,
37
+ selectAISidebarRowAtom,
38
+ startAISidebarRenameAtom,
39
+ toggleAISidebarRowAtom,
40
+ } from "./ai-sidebar-atom.ts"
41
+ import { useAISidebarContext } from "./sidebar-context.ts"
18
42
  import { canContain } from "./utils"
19
43
  import { SidebarIcon } from "./sidebar-icon"
20
44
  import { MarqueeLabel } from "./marquee-label"
@@ -23,7 +47,7 @@ import {
23
47
  MorphPopoverContent,
24
48
  MorphPopoverTrigger,
25
49
  } from "@/components/motion/popover-morph.tsx"
26
- import { Button } from "@/components/ui/button";
50
+ import { Button } from "@/components/ui/button"
27
51
 
28
52
  // Same choreography as SharedLayoutBg: the wrapper only fades/blurs out when
29
53
  // the pointer leaves the whole tree; a row-to-row move exits instantly so the
@@ -41,46 +65,74 @@ const reducedPillVariants: Variants = {
41
65
  exit: (hoverActive: boolean) => (!hoverActive ? { opacity: 0 } : {}),
42
66
  }
43
67
 
44
- export function ResourceRow({
45
- row,
46
- active,
47
- expanded,
48
- focused,
49
- draggingId,
50
- dropTarget,
51
- menuOpen,
52
- renaming,
53
- onDragEnd,
54
- onDragOver,
55
- onDragStart,
56
- onDrop,
57
- onFocus,
58
- onKeyDown,
59
- onMenuOpenChange,
60
- onRenameCancel,
61
- onRenameCommit,
62
- onRenameStart,
63
- onSelect,
64
- onToggle,
65
- hoverActive = false,
66
- hoverPill = false,
67
- hoverLayoutId,
68
- onHoverChange,
69
- renderIcon,
70
- renderMenu,
71
- renderActionsTrigger,
72
- setRef,
73
- }: ResourceRowProps) {
68
+ function ResourceRowImpl({ row }: ResourceRowProps) {
69
+ const {
70
+ sidebarId,
71
+ hoverLayoutId,
72
+ renderIcon,
73
+ renderMenu,
74
+ renderActionsTrigger,
75
+ setRowRef,
76
+ onRowKeyDown,
77
+ onRowHover,
78
+ closeMenu,
79
+ dnd,
80
+ } = useAISidebarContext()
74
81
  const reduce = useReducedMotion() ?? false
82
+
83
+ // One subscription per field, per row. A row re-renders when its own
84
+ // selection, expansion, focus, rename, menu, drag or hover state changes
85
+ // and sits still while a sibling's does — reading the controller off
86
+ // context instead re-rendered every row on every change.
87
+ const active = useAtomValue(aiSidebarRowSelectedAtom(sidebarId, row.item.id))
88
+ const expanded = useAtomValue(
89
+ aiSidebarRowExpandedAtom(sidebarId, row.item.id)
90
+ )
91
+ const focused = useAtomValue(aiSidebarRowFocusedAtom(sidebarId, row.item.id))
92
+ const renaming = useAtomValue(
93
+ aiSidebarRowRenamingAtom(sidebarId, row.item.id)
94
+ )
95
+ const menuOpen = useAtomValue(
96
+ aiSidebarRowMenuOpenAtom(sidebarId, row.item.id)
97
+ )
98
+ const isDragging = useAtomValue(
99
+ aiSidebarRowDraggingAtom(sidebarId, row.item.id)
100
+ )
101
+ const dropPosition = useAtomValue(
102
+ aiSidebarRowDropAtom(sidebarId, row.item.id)
103
+ )
104
+ const hoverActive = useAtomValue(aiSidebarHoverActiveAtom(sidebarId))
105
+ const hoverPill =
106
+ useAtomValue(aiSidebarRowHoveredAtom(sidebarId, row.item.id)) &&
107
+ !row.item.disabled
108
+
109
+ const select = useSetAtom(selectAISidebarRowAtom(sidebarId))
110
+ const toggleExpanded = useSetAtom(toggleAISidebarRowAtom(sidebarId))
111
+ const setFocusedRow = useSetAtom(focusAISidebarRowAtom(sidebarId))
112
+ const startRename = useSetAtom(startAISidebarRenameAtom(sidebarId))
113
+ const cancelRename = useSetAtom(cancelAISidebarRenameAtom(sidebarId))
114
+ const rename = useSetAtom(renameAISidebarRowAtom(sidebarId))
115
+ const openMenu = useSetAtom(openAISidebarMenuAtom(sidebarId))
116
+
117
+ const onMenuOpenChange = useCallback(
118
+ (open: boolean) => {
119
+ if (open) openMenu(row.item.id)
120
+ else closeMenu()
121
+ },
122
+ [closeMenu, openMenu, row.item.id]
123
+ )
124
+
125
+ const setRef = useCallback(
126
+ (node: HTMLDivElement | null) => setRowRef(row.item.id, node),
127
+ [row.item.id, setRowRef]
128
+ )
129
+
75
130
  const [hovered, setHovered] = useState(false)
76
131
  const inputRef = useRef<HTMLInputElement>(null)
77
132
  const skipRenameBlurRef = useRef(false)
78
133
  const draggedRef = useRef(false)
79
134
  const [draft, setDraft] = useState(row.item.label)
80
135
  const acceptsChildren = canContain(row.item)
81
- const isDragging = draggingId === row.item.id
82
- const dropPosition =
83
- dropTarget?.id === row.item.id ? dropTarget.position : null
84
136
 
85
137
  useEffect(() => {
86
138
  if (!renaming) return
@@ -96,14 +148,14 @@ export function ResourceRow({
96
148
  close: () => onMenuOpenChange(false),
97
149
  rename: () => {
98
150
  onMenuOpenChange(false)
99
- onRenameStart()
151
+ startRename(row.item.id)
100
152
  },
101
153
  }) ?? (
102
154
  <button
103
155
  type="button"
104
156
  onClick={() => {
105
157
  onMenuOpenChange(false)
106
- onRenameStart()
158
+ startRename(row.item.id)
107
159
  }}
108
160
  className="flex h-8 w-full items-center gap-2 rounded-lg px-2.5 text-left text-xs text-foreground transition-colors outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
109
161
  >
@@ -127,8 +179,8 @@ export function ResourceRow({
127
179
  data-menu-open={menuOpen || undefined}
128
180
  data-drop={dropPosition ?? undefined}
129
181
  data-dragging={isDragging || undefined}
130
- onFocus={onFocus}
131
- onKeyDown={onKeyDown}
182
+ onFocus={() => setFocusedRow(row.item.id)}
183
+ onKeyDown={(event) => onRowKeyDown(event, row)}
132
184
  onClick={(event) => {
133
185
  if (
134
186
  event.defaultPrevented ||
@@ -137,34 +189,34 @@ export function ResourceRow({
137
189
  row.item.disabled
138
190
  )
139
191
  return
140
- if (acceptsChildren) onToggle()
141
- else onSelect()
192
+ if (acceptsChildren) toggleExpanded(row.item.id)
193
+ else select(row.item.id)
142
194
  }}
143
195
  onDoubleClick={(event) => {
144
196
  if (acceptsChildren || row.item.disabled) return
145
197
  event.preventDefault()
146
- onRenameStart()
198
+ startRename(row.item.id)
147
199
  }}
148
200
  onMouseEnter={() => {
149
201
  setHovered(true)
150
- onHoverChange?.(true)
202
+ onRowHover(row.item.id, true)
151
203
  }}
152
204
  onMouseLeave={() => {
153
205
  setHovered(false)
154
- onHoverChange?.(false)
206
+ onRowHover(row.item.id, false)
155
207
  }}
156
208
  onDragStartCapture={(event) => {
157
209
  draggedRef.current = true
158
- onDragStart(event, row.item.id)
210
+ dnd.onRowDragStart(event, row.item.id)
159
211
  }}
160
212
  onDragEndCapture={() => {
161
- onDragEnd()
213
+ dnd.onRowDragEnd()
162
214
  requestAnimationFrame(() => {
163
215
  draggedRef.current = false
164
216
  })
165
217
  }}
166
- onDragOver={(event) => onDragOver(event, row)}
167
- onDrop={onDrop}
218
+ onDragOver={(event) => dnd.onRowDragOver(event, row)}
219
+ onDrop={dnd.onDrop}
168
220
  className={cn(
169
221
  "group/resource relative flex min-h-9 min-w-0 cursor-pointer items-center gap-2.5 rounded-md pr-3 text-sm outline-none",
170
222
  "text-muted-foreground transition-[color,background-color,scale] duration-150 ease-smooth-out hover:text-foreground motion-reduce:transition-none",
@@ -226,17 +278,17 @@ export function ResourceRow({
226
278
  onClick={(event) => event.stopPropagation()}
227
279
  onDoubleClick={(event) => event.stopPropagation()}
228
280
  onBlur={() => {
229
- if (!skipRenameBlurRef.current) onRenameCommit(draft)
281
+ if (!skipRenameBlurRef.current) rename(row.item.id, draft)
230
282
  }}
231
283
  onKeyDown={(event) => {
232
284
  event.stopPropagation()
233
285
  if (event.key === "Enter") {
234
286
  skipRenameBlurRef.current = true
235
- onRenameCommit(draft)
287
+ rename(row.item.id, draft)
236
288
  }
237
289
  if (event.key === "Escape") {
238
290
  skipRenameBlurRef.current = true
239
- onRenameCancel()
291
+ cancelRename()
240
292
  }
241
293
  }}
242
294
  className="relative h-7 min-w-0 flex-1 text-sm text-foreground outline-none focus-visible:ring-0 focus-visible:ring-ring"
@@ -307,3 +359,20 @@ export function ResourceRow({
307
359
  </motion.div>
308
360
  )
309
361
  }
362
+
363
+ /** `flat` is rebuilt whenever expansion changes, so its `FlatResource`
364
+ * wrappers are fresh objects even for rows nothing happened to. The
365
+ * comparator looks through the wrapper at what a row actually renders from —
366
+ * its item (stable unless the data changed), depth and parent — so a sibling
367
+ * opening does not walk every row. Each row's own state reaches it through
368
+ * its atoms instead. */
369
+ function sameRow(prev: ResourceRowProps, next: ResourceRowProps): boolean {
370
+ return (
371
+ prev.row.item === next.row.item &&
372
+ prev.row.depth === next.row.depth &&
373
+ prev.row.parentId === next.row.parentId
374
+ )
375
+ }
376
+
377
+ export const ResourceRow = memo(ResourceRowImpl, sameRow)
378
+ ResourceRow.displayName = "ResourceRow"
@@ -23,10 +23,19 @@ export interface SidebarResourceMenuControls {
23
23
  rename: () => void
24
24
  }
25
25
 
26
+ /** Key for one sidebar's state in the store. Any stable string;
27
+ * `useAISidebar` generates one when you don't pass it. */
28
+ export type AISidebarInstanceId = string
29
+
26
30
  /** Options for `useAISidebar`. `items`, `activeId` and `expandedIds` are
27
31
  * each controlled when passed and hook-owned via their `default*` twin
28
32
  * otherwise. */
29
33
  export interface UseAISidebarOptions {
34
+ /** Key this sidebar's state under a name your app can address —
35
+ * `useAISidebarState("app-resources")` / `useAISidebarActions(...)` reach
36
+ * it from anywhere under `ExegiaProvider`. Without one the hook generates
37
+ * a key and the state is dropped when the component unmounts. */
38
+ sidebarId?: AISidebarInstanceId
30
39
  items?: SidebarResource[]
31
40
  defaultItems?: SidebarResource[]
32
41
  onItemsChange?: (items: SidebarResource[]) => void
@@ -46,6 +55,8 @@ export interface UseAISidebarOptions {
46
55
  * toolbar, a command palette, a route change. Returned by `useAISidebar`
47
56
  * and accepted by `<AISidebar controller={…} />`. */
48
57
  export interface AISidebarController {
58
+ /** The key this sidebar's state is stored under. */
59
+ sidebarId: AISidebarInstanceId
49
60
  items: SidebarResource[]
50
61
  /** Visible rows in order, each with its depth and parent id. */
51
62
  flat: FlatResource[]
@@ -89,19 +100,31 @@ export interface AISidebarController {
89
100
  /** Live-region text for the last move or rename outcome. */
90
101
  announcement: string
91
102
 
103
+ /** Back to the values the sidebar mounted with. */
104
+ reset: () => void
105
+
92
106
  /** @internal Row wiring the view threads through — drag state, the
93
107
  * hover pill and roving keyboard nav all belong to the rendering. */
94
108
  dnd: AISidebarDndState
95
109
  hover: AISidebarHoverState
96
- onRowKeyDown: (event: KeyboardEvent<HTMLDivElement>, row: FlatResource) => void
110
+ onRowKeyDown: (
111
+ event: KeyboardEvent<HTMLDivElement>,
112
+ row: FlatResource
113
+ ) => void
97
114
  setRowRef: (id: string, node: HTMLDivElement | null) => void
98
115
  }
99
116
 
100
117
  /** @internal */
101
- export interface AISidebarDndState {
118
+ export interface AISidebarDndState extends AISidebarRowDndHandlers {
102
119
  draggingId: string | null
103
120
  dropTarget: DropTarget | null
104
121
  onRootDragOver: (event: DragEvent<HTMLDivElement>) => void
122
+ }
123
+
124
+ /** @internal The drag handlers a row needs, stable for the life of the
125
+ * sidebar — they read drag state out of the store when they run instead of
126
+ * closing over it, so handing them to every row costs no re-renders. */
127
+ export interface AISidebarRowDndHandlers {
105
128
  onDrop: (event: DragEvent<HTMLDivElement>) => void
106
129
  onRowDragStart: (event: DragEvent<HTMLDivElement>, id: string) => void
107
130
  onRowDragEnd: () => void
@@ -164,36 +187,108 @@ export interface DropTarget {
164
187
  position: SidebarResourceDropPosition
165
188
  }
166
189
 
190
+ /** A row takes its place in the tree and nothing else: selection,
191
+ * expansion, focus, rename, drag and hover state all arrive through its own
192
+ * atoms, and the rendering options through the sidebar context. */
167
193
  export interface ResourceRowProps {
168
194
  row: FlatResource
169
- active: boolean
170
- expanded: boolean
171
- focused: boolean
172
- draggingId: string | null
173
- dropTarget: DropTarget | null
174
- menuOpen: boolean
175
- renaming: boolean
176
- onDragEnd: () => void
177
- onDragOver: (event: DragEvent<HTMLDivElement>, row: FlatResource) => void
178
- onDragStart: (event: DragEvent<HTMLDivElement>, id: string) => void
179
- onDrop: (event: DragEvent<HTMLDivElement>) => void
180
- onFocus: () => void
181
- onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void
182
- onMenuOpenChange: (open: boolean) => void
183
- onRenameCancel: () => void
184
- onRenameCommit: (label: string) => void
185
- onRenameStart: () => void
186
- onSelect: () => void
187
- onToggle: () => void
188
- /** Some row in the tree is hovered — mounts the pill fade wrapper. */
189
- hoverActive?: boolean
190
- /** This row owns the sliding hover pill. */
191
- hoverPill?: boolean
192
- /** Shared motion layoutId that lets the pill travel between rows. */
193
- hoverLayoutId?: string
194
- onHoverChange?: (hovered: boolean) => void
195
- renderIcon?: (item: SidebarResource) => ReactNode
195
+ }
196
+
197
+ /** @internal What a row needs from the block, threaded through context so
198
+ * the rows stay prop-light.
199
+ *
200
+ * Deliberately free of sidebar state and of the controller. Rows read state
201
+ * from per-row atoms, so this value keeps its identity for the life of the
202
+ * sidebar and hovering or expanding one row no longer re-renders every row
203
+ * through context. */
204
+ export interface AISidebarContextValue {
205
+ sidebarId: AISidebarInstanceId
206
+ /** Shared motion layoutId that lets the hover pill travel between rows. */
207
+ hoverLayoutId: string
208
+ renderIcon?: AISidebarProps["renderIcon"]
196
209
  renderMenu?: AISidebarProps["renderMenu"]
197
210
  renderActionsTrigger?: AISidebarProps["renderActionsTrigger"]
198
- setRef: (node: HTMLDivElement | null) => void
211
+ /** Registers the row's DOM node with the block, which owns the ref map —
212
+ * DOM nodes never go in the store. */
213
+ setRowRef: (id: string, node: HTMLDivElement | null) => void
214
+ onRowKeyDown: (
215
+ event: KeyboardEvent<HTMLDivElement>,
216
+ row: FlatResource
217
+ ) => void
218
+ onRowHover: (id: string, hovered: boolean) => void
219
+ /** Closing returns DOM focus to the row that owned the menu, so it goes
220
+ * through the block rather than the store. */
221
+ closeMenu: () => void
222
+ dnd: AISidebarRowDndHandlers
223
+ }
224
+
225
+ /** Everything observable about one sidebar, for consumers reading it by id. */
226
+ export interface AISidebarState {
227
+ items: SidebarResource[]
228
+ /** Visible rows in order, each with its depth and parent id. */
229
+ flat: FlatResource[]
230
+ selectedId: string | null
231
+ expandedIds: ReadonlySet<string>
232
+ focusedId: string | null
233
+ renamingId: string | null
234
+ menuOpenId: string | null
235
+ draggingId: string | null
236
+ dropTarget: DropTarget | null
237
+ hoveredId: string | null
238
+ /** A move is in flight — further moves are refused until it settles. */
239
+ movePending: boolean
240
+ /** Live-region text for the last move or rename outcome. */
241
+ announcement: string
242
+ }
243
+
244
+ /** Everything doable to one sidebar from outside its component. */
245
+ export interface AISidebarActions {
246
+ select: (id: string) => void
247
+ expand: (id: string) => void
248
+ collapse: (id: string) => void
249
+ toggleExpanded: (id: string) => void
250
+ expandAll: () => void
251
+ collapseAll: () => void
252
+ reveal: (id: string) => void
253
+ /** Moves the roving-focus target. DOM focus follows only for callers that
254
+ * hold the controller — the ref map lives in the block, not the store. */
255
+ focus: (id: string) => void
256
+ startRename: (id: string) => void
257
+ cancelRename: () => void
258
+ rename: (id: string, label: string) => void
259
+ openMenu: (id: string) => void
260
+ closeMenu: () => void
261
+ move: (move: SidebarResourceMove) => Promise<void>
262
+ setItems: (items: SidebarResource[]) => void
263
+ reset: () => void
264
+ }
265
+
266
+ /** @internal Projection of `useAISidebar`'s options — primitives only, so
267
+ * the store write runs once per real change instead of once per render. */
268
+ export interface AISidebarConfig {
269
+ controlsItems: boolean
270
+ controlsActiveId: boolean
271
+ controlsExpandedIds: boolean
272
+ }
273
+
274
+ /** @internal Latest option callbacks. Only write atoms read this, so it can
275
+ * be refreshed every commit without re-rendering anything. */
276
+ export interface AISidebarHandlers {
277
+ onItemsChange?: (items: SidebarResource[]) => void
278
+ onMove?: (move: SidebarResourceMove) => void | Promise<void>
279
+ onMoveError?: (error: unknown, move: SidebarResourceMove) => void
280
+ onRename?: (item: SidebarResource, label: string) => void | Promise<void>
281
+ onActiveChange?: (id: string) => void
282
+ onExpandedChange?: (ids: string[]) => void
283
+ }
284
+
285
+ /** @internal What an instance starts from, replayed by
286
+ * `resetAISidebarAtom`. */
287
+ export interface AISidebarSeed {
288
+ items: SidebarResource[]
289
+ /** Hook-owned selection — the `defaultActiveId`, never the controlled
290
+ * `activeId`. */
291
+ activeId: string | null
292
+ focusedId: string | null
293
+ expandedIds: string[]
199
294
  }