@djangocfg/ui-tools 2.1.416 → 2.1.418

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 (66) hide show
  1. package/dist/audio-player/index.cjs +2098 -0
  2. package/dist/audio-player/index.cjs.map +1 -0
  3. package/dist/audio-player/index.css +65 -0
  4. package/dist/audio-player/index.css.map +1 -0
  5. package/dist/audio-player/index.d.cts +166 -0
  6. package/dist/audio-player/index.d.ts +166 -0
  7. package/dist/audio-player/index.mjs +2075 -0
  8. package/dist/audio-player/index.mjs.map +1 -0
  9. package/dist/composer-registry/index.cjs +45 -0
  10. package/dist/composer-registry/index.cjs.map +1 -0
  11. package/dist/composer-registry/index.d.cts +73 -0
  12. package/dist/composer-registry/index.d.ts +73 -0
  13. package/dist/composer-registry/index.mjs +39 -0
  14. package/dist/composer-registry/index.mjs.map +1 -0
  15. package/dist/tree/index.cjs +82 -63
  16. package/dist/tree/index.cjs.map +1 -1
  17. package/dist/tree/index.d.cts +15 -1
  18. package/dist/tree/index.d.ts +15 -1
  19. package/dist/tree/index.mjs +83 -64
  20. package/dist/tree/index.mjs.map +1 -1
  21. package/package.json +38 -17
  22. package/src/tools/chat/composer/Composer.tsx +8 -8
  23. package/src/tools/chat/context/ChatProvider.tsx +13 -78
  24. package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
  25. package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
  26. package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
  27. package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
  28. package/src/tools/data/Tree/TreeRoot.tsx +33 -109
  29. package/src/tools/data/Tree/context/TreeContext.tsx +22 -3
  30. package/src/tools/data/Tree/context/menu/index.ts +1 -0
  31. package/src/tools/data/Tree/context/menu/render.tsx +75 -0
  32. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +16 -2
  33. package/src/tools/data/Tree/index.tsx +1 -0
  34. package/src/tools/data/Tree/types/index.ts +1 -1
  35. package/src/tools/data/Tree/types/root-props.ts +16 -0
  36. package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
  37. package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
  38. package/src/tools/forms/CodeEditor/components/Editor.tsx +19 -0
  39. package/src/tools/forms/CodeEditor/types/index.ts +7 -0
  40. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +40 -0
  41. package/src/tools/forms/MarkdownEditor/styles.css +174 -21
  42. package/src/tools/forms/NotionEditor/CustomKeymap.ts +48 -0
  43. package/src/tools/forms/NotionEditor/LinkDialog.tsx +133 -0
  44. package/src/tools/forms/NotionEditor/NotionEditor.tsx +304 -0
  45. package/src/tools/forms/NotionEditor/SlashExtension.ts +32 -0
  46. package/src/tools/forms/NotionEditor/SlashList.tsx +136 -0
  47. package/src/tools/forms/NotionEditor/TaskItemView.tsx +41 -0
  48. package/src/tools/forms/NotionEditor/createSlashSuggestion.ts +121 -0
  49. package/src/tools/forms/NotionEditor/extensions.ts +105 -0
  50. package/src/tools/forms/NotionEditor/index.ts +1 -0
  51. package/src/tools/forms/NotionEditor/lazy.tsx +44 -0
  52. package/src/tools/forms/NotionEditor/slashItems.ts +159 -0
  53. package/src/tools/forms/NotionEditor/styles.css +478 -0
  54. package/src/tools/forms/NotionEditor/types.ts +28 -0
  55. package/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
  56. package/src/tools/integration/ComposerRegistry/index.ts +105 -0
  57. package/src/tools/media/AudioPlayer/Player.tsx +2 -0
  58. package/src/tools/media/AudioPlayer/PlayerShell.tsx +29 -22
  59. package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
  60. package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
  61. package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
  62. package/src/tools/media/AudioPlayer/types.ts +8 -0
  63. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +8 -0
  64. package/src/tools/media/ImageViewer/types.ts +4 -0
  65. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +20 -1
  66. package/src/tools/media/VideoPlayer/types.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.416",
3
+ "version": "2.1.418",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -66,10 +66,15 @@
66
66
  "import": "./dist/file-icon/index.mjs",
67
67
  "require": "./dist/file-icon/index.cjs"
68
68
  },
69
+ "./composer-registry": {
70
+ "types": "./dist/composer-registry/index.d.ts",
71
+ "import": "./dist/composer-registry/index.mjs",
72
+ "require": "./dist/composer-registry/index.cjs"
73
+ },
69
74
  "./audio-player": {
70
- "types": "./src/tools/media/AudioPlayer/lazy.tsx",
71
- "import": "./src/tools/media/AudioPlayer/lazy.tsx",
72
- "require": "./src/tools/media/AudioPlayer/lazy.tsx"
75
+ "types": "./dist/audio-player/index.d.ts",
76
+ "import": "./dist/audio-player/index.mjs",
77
+ "require": "./dist/audio-player/index.cjs"
73
78
  },
74
79
  "./chat": {
75
80
  "types": "./src/tools/chat/lazy.tsx",
@@ -111,6 +116,11 @@
111
116
  "import": "./src/tools/forms/MarkdownEditor/lazy.tsx",
112
117
  "require": "./src/tools/forms/MarkdownEditor/lazy.tsx"
113
118
  },
119
+ "./notion-editor": {
120
+ "types": "./src/tools/forms/NotionEditor/lazy.tsx",
121
+ "import": "./src/tools/forms/NotionEditor/lazy.tsx",
122
+ "require": "./src/tools/forms/NotionEditor/lazy.tsx"
123
+ },
114
124
  "./markdown-message": {
115
125
  "types": "./src/tools/dev/MarkdownMessage/index.ts",
116
126
  "import": "./src/tools/dev/MarkdownMessage/index.ts",
@@ -254,8 +264,8 @@
254
264
  "test:watch": "vitest"
255
265
  },
256
266
  "peerDependencies": {
257
- "@djangocfg/i18n": "^2.1.416",
258
- "@djangocfg/ui-core": "^2.1.416",
267
+ "@djangocfg/i18n": "^2.1.418",
268
+ "@djangocfg/ui-core": "^2.1.418",
259
269
  "consola": "^3.4.2",
260
270
  "lodash-es": "^4.18.1",
261
271
  "lucide-react": "^0.545.0",
@@ -274,14 +284,25 @@
274
284
  "@rjsf/utils": "^6.1.2",
275
285
  "@rjsf/validator-ajv8": "^6.1.2",
276
286
  "@rpldy/uploady": "^1.8.5",
277
- "@tiptap/core": "^3.20.1",
278
- "@tiptap/extension-mention": "^3.20.1",
279
- "@tiptap/extension-placeholder": "^3.20.1",
280
- "@tiptap/markdown": "^3.20.1",
281
- "@tiptap/pm": "^3.20.1",
282
- "@tiptap/react": "^3.20.1",
283
- "@tiptap/starter-kit": "^3.20.1",
284
- "@tiptap/suggestion": "^3.20.1",
287
+ "@tiptap/core": "^3.23.0",
288
+ "@tiptap/extension-bubble-menu": "^3.23.0",
289
+ "@tiptap/extension-code-block-lowlight": "^3.23.0",
290
+ "@tiptap/extension-highlight": "^3.23.0",
291
+ "@tiptap/extension-mention": "^3.23.0",
292
+ "@tiptap/extension-placeholder": "^3.23.0",
293
+ "@tiptap/extension-table": "^3.23.0",
294
+ "@tiptap/extension-table-cell": "^3.23.0",
295
+ "@tiptap/extension-table-header": "^3.23.0",
296
+ "@tiptap/extension-table-row": "^3.23.0",
297
+ "@tiptap/extension-task-item": "^3.23.0",
298
+ "@tiptap/extension-task-list": "^3.23.0",
299
+ "@tiptap/markdown": "^3.23.0",
300
+ "@tiptap/pm": "^3.23.0",
301
+ "@tiptap/react": "^3.23.0",
302
+ "@tiptap/starter-kit": "^3.23.0",
303
+ "@tiptap/suggestion": "^3.23.0",
304
+ "lowlight": "^3.3.0",
305
+ "tiptap-extension-global-drag-handle": "^0.1.18",
285
306
  "@wavesurfer/react": "^1.0.12",
286
307
  "@radix-ui/react-slot": "^1.2.4",
287
308
  "@radix-ui/react-direction": "^1.1.1",
@@ -319,9 +340,9 @@
319
340
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
320
341
  },
321
342
  "devDependencies": {
322
- "@djangocfg/i18n": "^2.1.416",
323
- "@djangocfg/typescript-config": "^2.1.416",
324
- "@djangocfg/ui-core": "^2.1.416",
343
+ "@djangocfg/i18n": "^2.1.418",
344
+ "@djangocfg/typescript-config": "^2.1.418",
345
+ "@djangocfg/ui-core": "^2.1.418",
325
346
  "@types/lodash-es": "^4.17.12",
326
347
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
327
348
  "@types/node": "^25.2.3",
@@ -4,6 +4,7 @@ import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
4
4
 
5
5
  import { Textarea } from '@djangocfg/ui-core/components';
6
6
  import { cn } from '@djangocfg/ui-core/lib';
7
+ import { attachComposer } from '@djangocfg/ui-tools/composer-registry';
7
8
 
8
9
  import { useChatContextOptional } from '../context';
9
10
  import type { UseChatComposerReturn } from '../hooks/useChatComposer';
@@ -174,10 +175,10 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
174
175
  // host overrides it explicitly — see §3.1 of the redesign doc.
175
176
  const layout: ComposerLayout = layoutProp ?? (size === 'sm' ? 'inline' : 'stacked');
176
177
 
177
- // Register the composer's handle with the chat context so other parts
178
- // of the tree (useAutoFocusOnStreamEnd, VoiceComposerSlot) can drive it
179
- // imperatively. No-op when used outside a ChatProvider.
180
- const register = ctx?.registerComposer;
178
+ // Publish the composer's imperative handle to the cross-tool
179
+ // composer registry (`@djangocfg/ui-tools/composer-registry`).
180
+ // Consumers (useAutoFocusOnStreamEnd, VoiceComposerSlot) read it
181
+ // from there — works the same inside or outside a ChatProvider.
181
182
  const composerFocus = composer.focus;
182
183
  const composerSetValue = composer.setValue;
183
184
  const textareaRef = composer.textareaRef;
@@ -189,8 +190,8 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
189
190
  // ours so we don't overwrite it (child effects run before parent).
190
191
  const hasCustomTextarea = !!slots?.Textarea;
191
192
  useEffect(() => {
192
- if (!register || hasCustomTextarea) return;
193
- register({
193
+ if (hasCustomTextarea) return;
194
+ return attachComposer({
194
195
  focus: composerFocus,
195
196
  moveCursorToEnd: () => {
196
197
  const el = textareaRef.current;
@@ -201,8 +202,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
201
202
  getValue: () => getValueRef.current(),
202
203
  setValue: composerSetValue,
203
204
  });
204
- return () => register(null);
205
- }, [register, hasCustomTextarea, composerFocus, composerSetValue, textareaRef]);
205
+ }, [hasCustomTextarea, composerFocus, composerSetValue, textareaRef]);
206
206
 
207
207
  const inlineStart = composerSlots?.inlineStart;
208
208
  const inlineEnd = composerSlots?.inlineEnd;
@@ -8,9 +8,10 @@ import {
8
8
  useEffect,
9
9
  useMemo,
10
10
  useRef,
11
- useState,
12
11
  } from 'react';
13
12
 
13
+ import { getActiveComposer } from '@djangocfg/ui-tools/composer-registry';
14
+
14
15
  import type { ChatConfig, ChatLabels, ChatTransport } from '../types';
15
16
  import { DEFAULT_LABELS } from '../types';
16
17
  import type { BlockRegistry } from '../messages/blocks';
@@ -34,18 +35,12 @@ import type { ChatAudioConfig, UseChatAudioReturn } from '../core/audio/types';
34
35
  * Consumed by `VoiceComposerSlot` for the focus / move-caret behaviour
35
36
  * during live dictation.
36
37
  */
37
- export interface ComposerHandle {
38
- focus: () => void;
39
- /** Move the caret to the very end of the input. */
40
- moveCursorToEnd?: () => void;
41
- /** Read the current draft text. Needed by voice dictation to anchor
42
- * partial transcripts onto the user's already-typed prefix. */
43
- getValue?: () => string;
44
- /** Replace the current draft text. Voice dictation uses this to push
45
- * interim + final transcripts into the composer without owning a
46
- * controlled binding. */
47
- setValue?: (value: string) => void;
48
- }
38
+ // `ComposerHandle` lives in `@djangocfg/ui-tools/composer-registry` —
39
+ // the cross-tool registry shared by chat (producer) and
40
+ // speech-recognition (consumer). Re-export here so existing call sites
41
+ // `import { ComposerHandle } from '@djangocfg/ui-tools/chat'` keep
42
+ // working unchanged.
43
+ export type { ComposerHandle } from '@djangocfg/ui-tools/composer-registry';
49
44
 
50
45
  export interface ChatContextValue extends UseChatReturn {
51
46
  layout: UseChatLayoutReturn;
@@ -56,13 +51,6 @@ export interface ChatContextValue extends UseChatReturn {
56
51
  * Components like ``AudioToggle`` use this to auto-hide when there
57
52
  * is nothing to mute. */
58
53
  hasAudio: boolean;
59
- /** Composer registry. The built-in `<Composer>` calls
60
- * `registerComposer({ focus })` on mount; custom composers (e.g.
61
- * cmdop's MarkdownEditor wrapper) do the same via the
62
- * `useRegisterComposer` helper. Read it via `composer?.focus()` —
63
- * null until any composer has mounted. Plan64 follow-up. */
64
- composer: ComposerHandle | null;
65
- registerComposer: (handle: ComposerHandle | null) => void;
66
54
  /** Registry of `kind` → renderer for `message.blocks`. `null` when the
67
55
  * host wired none — `<MessageBubble>` then uses `BUILTIN_BLOCK_REGISTRY`. */
68
56
  blockRegistry: BlockRegistry | null;
@@ -70,48 +58,6 @@ export interface ChatContextValue extends UseChatReturn {
70
58
 
71
59
  const Ctx = createContext<ChatContextValue | null>(null);
72
60
 
73
- // ─── Dual-bundle guard ────────────────────────────────────────────────────
74
- //
75
- // `@djangocfg/ui-tools` ships its root export through `dist/` (compiled) but
76
- // most subpaths resolve to raw `src/`. If a consumer mixes them — e.g.
77
- // `ChatRoot` from the root barrel and `VoiceComposerSlot` from
78
- // `@djangocfg/ui-tools/speech-recognition` — `createContext(...)` runs twice
79
- // and produces two distinct context instances. The Composer registers its
80
- // handle on one; `useChatContextOptional()` in the voice slot reads from the
81
- // other, sees `null`, and silently drops every transcript.
82
- //
83
- // We tag the global with our module identity. When more than one tag is
84
- // observed the consumer is told exactly what to do.
85
- type GuardSlot = { stamps: Set<symbol>; warned: boolean };
86
- const GLOBAL_KEY = '__djangocfg_chat_ctx_stamps__';
87
- const stamp = Symbol('djangocfg.chat.ctx');
88
- function markCtxLoad(): void {
89
- if (typeof globalThis === 'undefined') return;
90
- const g = globalThis as unknown as Record<string, GuardSlot | undefined>;
91
- let slot = g[GLOBAL_KEY];
92
- if (!slot) {
93
- slot = { stamps: new Set(), warned: false };
94
- g[GLOBAL_KEY] = slot;
95
- }
96
- slot.stamps.add(stamp);
97
- if (slot.stamps.size > 1 && !slot.warned && process.env.NODE_ENV !== 'production') {
98
- slot.warned = true;
99
- // eslint-disable-next-line no-console
100
- console.warn(
101
- '[@djangocfg/ui-tools/chat] Two ChatProvider context instances detected — ' +
102
- 'this means `@djangocfg/ui-tools` was loaded twice (one bundle via the root ' +
103
- '`.` export → `dist/`, another via a `./chat` / `./speech-recognition` subpath → `src/`). ' +
104
- 'Symptom: `useChatContextOptional()` returns `null` for descendants of `<ChatProvider>`, ' +
105
- 'so VoiceComposerSlot drops transcripts, useChatReset silently no-ops, etc. ' +
106
- '\n\nFix: import every Chat surface from the SAME subpath. Recommended:\n' +
107
- " import { ChatRoot, ChatLauncher, useChatContextOptional, … } from '@djangocfg/ui-tools/chat';\n" +
108
- " import { VoiceComposerSlot } from '@djangocfg/ui-tools/speech-recognition';\n" +
109
- '\n(See packages/ui-tools/src/tools/Chat/README.md → "Anti-patterns".)',
110
- );
111
- }
112
- }
113
- markCtxLoad();
114
-
115
61
  export interface ChatProviderProps {
116
62
  transport: ChatTransport;
117
63
  config?: ChatConfig;
@@ -240,26 +186,17 @@ export function ChatProvider({
240
186
  );
241
187
  }, [audio]);
242
188
 
243
- // Composer registry — kept in state (not a ref) so consumers
244
- // observing `ctx.composer` re-render when a composer mounts /
245
- // unmounts. The setter is the API surface; pass it to the composer
246
- // on mount and call with `null` on unmount.
247
- const [composer, setComposer] = useState<ComposerHandle | null>(null);
248
- const registerComposer = useCallback((handle: ComposerHandle | null) => {
249
- setComposer(handle);
250
- }, []);
251
-
252
189
  // Re-focus the composer on the streaming → idle edge. Lives here (not
253
190
  // in `ChatRoot`) so it works for *every* usage pattern — `ChatRoot`,
254
191
  // a hand-rolled `ChatProvider` + `Composer` layout, or headless — as
255
- // long as a composer registered its handle.
256
- const composerRef = useRef<ComposerHandle | null>(composer);
257
- composerRef.current = composer;
192
+ // long as a composer registered its handle. The active handle lives
193
+ // in `@djangocfg/ui-tools/composer-registry` a single cross-tool
194
+ // registry shared with `<VoiceComposerSlot>`.
258
195
  useStreamEndFocus({
259
196
  isStreaming: chat.isStreaming,
260
197
  enabled: autoFocusOnStreamEnd,
261
198
  delayMs: 0,
262
- resolveTarget: () => composerRef.current,
199
+ resolveTarget: () => getActiveComposer(),
263
200
  });
264
201
 
265
202
  const value = useMemo<ChatContextValue>(
@@ -270,11 +207,9 @@ export function ChatProvider({
270
207
  labels,
271
208
  audio: audioApi,
272
209
  hasAudio,
273
- composer,
274
- registerComposer,
275
210
  blockRegistry: blockRegistry ?? null,
276
211
  }),
277
- [chat, layout, config, labels, audioApi, hasAudio, composer, registerComposer, blockRegistry],
212
+ [chat, layout, config, labels, audioApi, hasAudio, blockRegistry],
278
213
  );
279
214
 
280
215
  return (
@@ -1,8 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { type RefObject, useEffect, useRef } from 'react';
3
+ import { type RefObject, useEffect } from 'react';
4
4
 
5
- import { useChatContextOptional, type ComposerHandle } from '../context';
5
+ import {
6
+ attachComposer,
7
+ getActiveComposer,
8
+ type ComposerHandle,
9
+ } from '@djangocfg/ui-tools/composer-registry';
10
+ import { useChatContextOptional } from '../context';
6
11
  import { useStreamEndFocus, type Focusable } from './useStreamEndFocus';
7
12
 
8
13
  export type { Focusable } from './useStreamEndFocus';
@@ -63,18 +68,14 @@ export function useAutoFocusOnStreamEnd(
63
68
  // Prefer the prop (caller knows best), fall back to context.
64
69
  const isStreaming = isStreamingProp ?? ctx?.isStreaming ?? false;
65
70
 
66
- // Keep latest ctx-composer in a ref so target resolution always sees
67
- // the freshest registered handle.
68
- const composerHandleRef = useRef<ComposerHandle | null>(null);
69
- composerHandleRef.current = ctx?.composer ?? null;
70
-
71
71
  useStreamEndFocus({
72
72
  isStreaming,
73
73
  enabled,
74
74
  delayMs,
75
- // Resolve in priority order: explicit ref > registered composer.
75
+ // Resolve in priority order: explicit ref > active composer
76
+ // (from the cross-tool registry).
76
77
  resolveTarget: () =>
77
- (targetRef?.current as Focusable | null) ?? composerHandleRef.current,
78
+ (targetRef?.current as Focusable | null) ?? getActiveComposer(),
78
79
  });
79
80
  }
80
81
 
@@ -94,8 +95,6 @@ export function useAutoFocusOnStreamEnd(
94
95
  * No-op when called outside a `<ChatProvider>`.
95
96
  */
96
97
  export function useRegisterComposer(handle: ComposerHandle): void {
97
- const ctx = useChatContextOptional();
98
- const register = ctx?.registerComposer;
99
98
  const focus = handle.focus;
100
99
  const moveCursorToEnd = handle.moveCursorToEnd;
101
100
  // Forward `getValue/setValue` too — voice dictation reads/writes the
@@ -104,8 +103,6 @@ export function useRegisterComposer(handle: ComposerHandle): void {
104
103
  const getValue = handle.getValue;
105
104
  const setValue = handle.setValue;
106
105
  useEffect(() => {
107
- if (!register) return;
108
- register({ focus, moveCursorToEnd, getValue, setValue });
109
- return () => register(null);
110
- }, [register, focus, moveCursorToEnd, getValue, setValue]);
106
+ return attachComposer({ focus, moveCursorToEnd, getValue, setValue });
107
+ }, [focus, moveCursorToEnd, getValue, setValue]);
111
108
  }
@@ -3,7 +3,9 @@
3
3
  import { useCallback, useRef } from 'react';
4
4
  import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
5
5
 
6
- import { useChatContextOptional, type ComposerHandle } from '../context';
6
+ import { getActiveComposer } from '@djangocfg/ui-tools/composer-registry';
7
+
8
+ import { useChatContextOptional } from '../context';
7
9
  import type { Focusable } from './useAutoFocusOnStreamEnd';
8
10
 
9
11
  export interface UseFocusOnEmptyClickOptions {
@@ -51,9 +53,6 @@ export function useFocusOnEmptyClick(
51
53
  const { targetRef, enabled = true, skipWhileStreaming = true } = options;
52
54
  const ctx = useChatContextOptional();
53
55
 
54
- const composerHandleRef = useRef<ComposerHandle | null>(null);
55
- composerHandleRef.current = ctx?.composer ?? null;
56
-
57
56
  const isStreamingRef = useRef(false);
58
57
  isStreamingRef.current = ctx?.isStreaming ?? false;
59
58
 
@@ -83,7 +82,7 @@ export function useFocusOnEmptyClick(
83
82
  explicit.focus?.();
84
83
  return;
85
84
  }
86
- composerHandleRef.current?.focus?.();
85
+ getActiveComposer()?.focus?.();
87
86
  },
88
87
  [enabled, skipWhileStreaming, targetRef],
89
88
  );
@@ -7,7 +7,6 @@ import {
7
7
  Button,
8
8
  Tooltip,
9
9
  TooltipContent,
10
- TooltipProvider,
11
10
  TooltipTrigger,
12
11
  } from '@djangocfg/ui-core/components';
13
12
  import { cn } from '@djangocfg/ui-core/lib';
@@ -66,24 +65,20 @@ export function ChatHeader({
66
65
  {actions}
67
66
  {closeSlot ??
68
67
  (showClose && onClose && (
69
- <TooltipProvider delayDuration={300}>
70
- <Tooltip>
71
- <TooltipTrigger asChild>
72
- <Button
73
- variant="ghost"
74
- size="sm"
75
- onClick={onClose}
76
- aria-label={closeLabel}
77
- className="-mr-1 h-7 w-7 p-0"
78
- >
79
- <X className="h-4 w-4" />
80
- </Button>
81
- </TooltipTrigger>
82
- {/* Tooltip portals to <body> in ui-core's anchored-overlay
83
- tier, already above the dock — no override needed. */}
84
- <TooltipContent side="bottom">{closeLabel}</TooltipContent>
85
- </Tooltip>
86
- </TooltipProvider>
68
+ <Tooltip>
69
+ <TooltipTrigger asChild>
70
+ <Button
71
+ variant="ghost"
72
+ size="sm"
73
+ onClick={onClose}
74
+ aria-label={closeLabel}
75
+ className="-mr-1 h-7 w-7 p-0"
76
+ >
77
+ <X className="h-4 w-4" />
78
+ </Button>
79
+ </TooltipTrigger>
80
+ <TooltipContent side="bottom">{closeLabel}</TooltipContent>
81
+ </Tooltip>
87
82
  ))}
88
83
  </div>
89
84
  </header>
@@ -6,7 +6,6 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react';
6
6
  import {
7
7
  Tooltip,
8
8
  TooltipContent,
9
- TooltipProvider,
10
9
  TooltipTrigger,
11
10
  } from '@djangocfg/ui-core/components';
12
11
  import { cn } from '@djangocfg/ui-core/lib';
@@ -102,19 +101,16 @@ export const ChatHeaderActionButton = forwardRef<HTMLButtonElement, ChatHeaderAc
102
101
  // Opted out — keep the native `title` only.
103
102
  if (tooltip === false) return button;
104
103
 
105
- // Floating tooltip on hover *and* keyboard focus. Self-contained
106
- // `<TooltipProvider>` so the button works standalone without the
107
- // host wiring an ambient provider; Radix dedupes nested providers.
104
+ // Floating tooltip on hover *and* keyboard focus.
105
+ // Host app mounts <UiProviders> (which includes TooltipProvider).
108
106
  const tooltipLabel = tooltip ?? ariaLabel;
109
107
  return (
110
- <TooltipProvider delayDuration={300}>
111
- <Tooltip>
112
- <TooltipTrigger asChild>{button}</TooltipTrigger>
113
- {/* Tooltip portals to <body> in ui-core's anchored-overlay tier,
114
- already above the dock — no z-index override needed. */}
115
- <TooltipContent side="bottom">{tooltipLabel}</TooltipContent>
116
- </Tooltip>
117
- </TooltipProvider>
108
+ <Tooltip>
109
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
110
+ {/* Tooltip portals to <body> in ui-core's anchored-overlay tier,
111
+ already above the dock no z-index override needed. */}
112
+ <TooltipContent side="bottom">{tooltipLabel}</TooltipContent>
113
+ </Tooltip>
118
114
  );
119
115
  },
120
116
  );
@@ -1,17 +1,9 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useMemo, useRef } from 'react';
3
+ import { useCallback, useEffect, useRef } from 'react';
4
4
  import { cn } from '@djangocfg/ui-core/lib';
5
- import {
6
- ContextMenu,
7
- ContextMenuContent,
8
- ContextMenuItem,
9
- ContextMenuSeparator,
10
- ContextMenuShortcut,
11
- ContextMenuTrigger,
12
- } from '@djangocfg/ui-core/components';
13
5
 
14
- import { TreeContext, TreeProvider, useTreeContext } from './context/TreeContext';
6
+ import { TreeProvider, useTreeContext } from './context/TreeContext';
15
7
  import { TreeDndProvider } from './TreeDndProvider';
16
8
  import { TreeContent, treeRowDomId } from './components/TreeContent';
17
9
  import { TreeEmptyArea } from './components/TreeEmptyArea';
@@ -22,70 +14,7 @@ import {
22
14
  useTreeTypeAhead,
23
15
  useTreeFinderHotkeys,
24
16
  } from './hooks';
25
- import type {
26
- TreeContextMenuItem,
27
- TreeContextMenuSlot,
28
- TreeRootProps,
29
- TreeRowRenderProps,
30
- } from './types';
31
-
32
- /**
33
- * Render an array of declarative menu items as a themed `<ContextMenu>`
34
- * wrapped around the supplied trigger element. Pure presentational layer
35
- * — the caller resolves and merges items.
36
- */
37
- function renderItemsAsContextMenu<T>(
38
- rowProps: TreeRowRenderProps<T>,
39
- items: TreeContextMenuItem<T>[],
40
- trigger: React.ReactNode,
41
- ): React.ReactNode {
42
- return (
43
- <ContextMenu>
44
- <ContextMenuTrigger asChild>{trigger}</ContextMenuTrigger>
45
- <ContextMenuContent>
46
- {items.map((item, idx) => {
47
- if (item === 'separator') {
48
- return <ContextMenuSeparator key={`sep-${idx}`} />;
49
- }
50
- const Icon = item.icon;
51
- return (
52
- <ContextMenuItem
53
- key={item.id}
54
- disabled={item.disabled}
55
- variant={item.destructive ? 'destructive' : undefined}
56
- onSelect={() => item.onSelect(rowProps)}
57
- >
58
- {Icon ? <Icon /> : null}
59
- {item.label}
60
- {item.shortcut ? (
61
- <ContextMenuShortcut>{item.shortcut}</ContextMenuShortcut>
62
- ) : null}
63
- </ContextMenuItem>
64
- );
65
- })}
66
- </ContextMenuContent>
67
- </ContextMenu>
68
- );
69
- }
70
-
71
- /**
72
- * Drop trailing / leading / duplicate separators so a merged menu never
73
- * shows a separator next to a section header or another separator.
74
- */
75
- function tidyMenuItems<T>(items: TreeContextMenuItem<T>[]): TreeContextMenuItem<T>[] {
76
- const out: TreeContextMenuItem<T>[] = [];
77
- for (const it of items) {
78
- if (it === 'separator') {
79
- if (out.length === 0) continue;
80
- if (out[out.length - 1] === 'separator') continue;
81
- out.push(it);
82
- } else {
83
- out.push(it);
84
- }
85
- }
86
- while (out.length > 0 && out[out.length - 1] === 'separator') out.pop();
87
- return out;
88
- }
17
+ import type { TreeActionsHandle, TreeRootProps } from './types';
89
18
 
90
19
  /**
91
20
  * High-level entry point. Wraps Provider + (optional) search bar + content.
@@ -126,6 +55,7 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
126
55
  persistSelection = false,
127
56
  adapter,
128
57
  defaultMenuItems,
58
+ actionsRef,
129
59
  className,
130
60
  style,
131
61
  } = props;
@@ -171,6 +101,7 @@ function TreeRoot<T>(props: TreeRootProps<T>) {
171
101
  enableTypeAhead={enableTypeAhead}
172
102
  enableFinderHotkeys={enableFinderHotkeys}
173
103
  renderRow={renderRow}
104
+ actionsRef={actionsRef}
174
105
  />
175
106
  </TreeProvider>
176
107
  );
@@ -183,6 +114,7 @@ interface TreeRootShellProps<T> {
183
114
  enableTypeAhead: boolean;
184
115
  enableFinderHotkeys: boolean;
185
116
  renderRow?: TreeRootProps<T>['renderRow'];
117
+ actionsRef?: React.MutableRefObject<TreeActionsHandle | null>;
186
118
  }
187
119
 
188
120
  function TreeRootShell<T>({
@@ -192,10 +124,33 @@ function TreeRootShell<T>({
192
124
  enableTypeAhead,
193
125
  enableFinderHotkeys,
194
126
  renderRow,
127
+ actionsRef,
195
128
  }: TreeRootShellProps<T>) {
196
129
  const containerRef = useRef<HTMLDivElement>(null);
197
130
  const ctx = useTreeContext<T>();
198
131
 
132
+ // Publish the action handle to the outer ref so host code can call
133
+ // refresh / refreshAll after a mutation that originated outside Tree.
134
+ // Effect runs after mount; the ref stays populated until unmount.
135
+ useEffect(() => {
136
+ if (!actionsRef) return;
137
+ actionsRef.current = {
138
+ refresh: ctx.refresh,
139
+ refreshAll: ctx.refreshAll,
140
+ expandAll: ctx.expandAll,
141
+ collapseAll: ctx.collapseAll,
142
+ };
143
+ return () => {
144
+ if (actionsRef.current) actionsRef.current = null;
145
+ };
146
+ }, [
147
+ actionsRef,
148
+ ctx.refresh,
149
+ ctx.refreshAll,
150
+ ctx.expandAll,
151
+ ctx.collapseAll,
152
+ ]);
153
+
199
154
  // Keyboard navigation (↑↓ ←→ Home/End Enter Esc Cmd+A, Shift-extend) —
200
155
  // scoped via callback ref.
201
156
  const isMulti = ctx.selectionMode === 'multiple';
@@ -288,27 +243,9 @@ function TreeRootShell<T>({
288
243
  enabled: enableTypeAhead,
289
244
  });
290
245
 
291
- // Build the final renderContextMenu slot from the merged declarative
292
- // resolver. `renderContextMenu` (escape-hatch slot) wins if both are
293
- // provided. We replace `ctx.renderContextMenu` on the child context
294
- // here so TreeRow keeps reading a single source of truth.
295
- const finalRenderContextMenu = useMemo<TreeContextMenuSlot<T> | undefined>(() => {
296
- if (ctx.renderContextMenu) return ctx.renderContextMenu;
297
- const resolve = ctx.resolvedContextMenuActions;
298
- if (!resolve) return undefined;
299
- return (rowProps, trigger) => {
300
- const items = resolve(rowProps);
301
- const cleaned = items ? tidyMenuItems(items) : null;
302
- if (!cleaned || cleaned.length === 0) return trigger;
303
- return renderItemsAsContextMenu(rowProps, cleaned, trigger);
304
- };
305
- }, [ctx.renderContextMenu, ctx.resolvedContextMenuActions]);
306
-
307
- const childCtx = useMemo(
308
- () => ({ ...ctx, renderContextMenu: finalRenderContextMenu }),
309
- [ctx, finalRenderContextMenu],
310
- );
311
-
246
+ // Tree body. `ctx.renderContextMenu` is already the final slot —
247
+ // TreeProvider does the declarative→slot merge centrally, so Shell
248
+ // doesn't re-derive it (and no nested provider override is needed).
312
249
  const treeBody = (
313
250
  <div
314
251
  ref={setContainerRef}
@@ -336,23 +273,10 @@ function TreeRootShell<T>({
336
273
  </div>
337
274
  );
338
275
 
339
- // If we computed a different renderContextMenu slot than the one in
340
- // the parent provider, override it via a thin nested provider so
341
- // <TreeRow> reads the merged slot. Otherwise skip the wrapper to keep
342
- // the render cheap.
343
- const body =
344
- finalRenderContextMenu === ctx.renderContextMenu ? (
345
- treeBody
346
- ) : (
347
- <TreeContext.Provider value={childCtx as never}>
348
- {treeBody}
349
- </TreeContext.Provider>
350
- );
351
-
352
276
  // Wrap in @dnd-kit context only when DnD is active — `TreeDndProvider`
353
277
  // short-circuits to a fragment otherwise, so we don't pay the
354
278
  // sensor-registration cost.
355
- return <TreeDndProvider>{body}</TreeDndProvider>;
279
+ return <TreeDndProvider>{treeBody}</TreeDndProvider>;
356
280
  }
357
281
 
358
282
  export default TreeRoot;