@djangocfg/ui-tools 2.1.469 → 2.1.470

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.469",
3
+ "version": "2.1.470",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -334,8 +334,8 @@
334
334
  "test:watch": "vitest"
335
335
  },
336
336
  "peerDependencies": {
337
- "@djangocfg/i18n": "^2.1.469",
338
- "@djangocfg/ui-core": "^2.1.469",
337
+ "@djangocfg/i18n": "^2.1.470",
338
+ "@djangocfg/ui-core": "^2.1.470",
339
339
  "consola": "^3.4.2",
340
340
  "lodash-es": "^4.18.1",
341
341
  "lucide-react": "^0.545.0",
@@ -418,9 +418,9 @@
418
418
  "@maplibre/maplibre-gl-geocoder": "^1.9.4"
419
419
  },
420
420
  "devDependencies": {
421
- "@djangocfg/i18n": "^2.1.469",
422
- "@djangocfg/typescript-config": "^2.1.469",
423
- "@djangocfg/ui-core": "^2.1.469",
421
+ "@djangocfg/i18n": "^2.1.470",
422
+ "@djangocfg/typescript-config": "^2.1.470",
423
+ "@djangocfg/ui-core": "^2.1.470",
424
424
  "@types/lodash-es": "^4.17.12",
425
425
  "@types/mapbox__mapbox-gl-draw": "^1.4.9",
426
426
  "@types/node": "^25.9.5",
@@ -90,7 +90,14 @@ ChatRoot (one-line preset) ◄── optionally wrapped by ──► ChatLau
90
90
 
91
91
  `ChatLauncher` mounts the `ChatProvider` itself — pass `transport` / `config` / `audio` / `initialSessionId` / `autoCreateSession` / `streaming` / `debug` to the launcher and use `<ChatRoot />` without props as the child. `ChatRoot` detects the ambient provider and reuses it; standalone `<ChatRoot transport={…}>` still works for non-launcher embeds. This is what makes declarative `headerSlots` (which render in the dock header) able to call `useChatContext()` and read `sessionId` / `clearMessages` / etc.
92
92
 
93
- `ChatProvider` also owns chat-wide UX behaviour that must hold regardless of which preset wraps it — notably **stream-end composer re-focus** (`autoFocusOnStreamEnd`, default `true`). Putting it in the provider means a hand-rolled `ChatProvider` + `MessageList` + `Composer` layout behaves identically to `ChatRoot` with no extra wiring.
93
+ `ChatProvider` is the single owner of automatic composer focus: one settled
94
+ focus after a chat mounts or switches session, and one after a streamed reply
95
+ ends (`autoFocusOnMount` / `autoFocusOnStreamEnd`, both default `true`). A
96
+ normal message, presence, telemetry, or prop update never takes focus. Direct
97
+ user intent remains local: clicking the empty message area or the composer
98
+ surface focuses the editor. This keeps a hand-rolled `ChatProvider` +
99
+ `MessageList` + `Composer` layout identical to `ChatRoot` without competing
100
+ focus effects.
94
101
 
95
102
  Module boundaries:
96
103
 
@@ -242,7 +242,11 @@ export function ChatProvider({
242
242
  if (prevSessionRef.current === chat.sessionId) return;
243
243
  prevSessionRef.current = chat.sessionId;
244
244
  if (!autoFocusOnMount) return;
245
- getActiveComposer()?.focus();
245
+ // Session switch is the provider's second and final automatic focus
246
+ // intent (after mount). Wait one frame for a closing dialog / editor DOM
247
+ // update, rather than competing with consumers through retry loops.
248
+ const raf = requestAnimationFrame(() => getActiveComposer()?.focus());
249
+ return () => cancelAnimationFrame(raf);
246
250
  }, [chat.sessionId, autoFocusOnMount]);
247
251
 
248
252
  const value = useMemo<ChatContextValue>(
@@ -111,35 +111,16 @@ export function useAutoFocusOnMount(
111
111
  const { enabled = true, targetRef } = options;
112
112
  useEffect(() => {
113
113
  if (!enabled) return;
114
- // Retry across a few frames, not a single rAF. On a `key`-remount
115
- // (chat-switch) two things race our focus: (1) a custom composer's
116
- // `useRegisterComposer` effect must run before `getActiveComposer()`
117
- // returns the NEW handle, and (2) the click that triggered the switch
118
- // leaves focus on the machine button — a one-frame focus() can fire
119
- // before the editor is mounted, or get immediately clobbered. We poll
120
- // a handful of frames and stop as soon as the editor actually holds
121
- // focus, so the caret reliably lands in the composer without a
122
- // guessed fixed delay.
123
- let raf = 0;
124
- let tries = 0;
125
- const MAX_TRIES = 8; // ~8 frames ≈ 130ms @60fps — enough post-remount.
126
- const tick = () => {
114
+ // One settled frame is enough: child effects register the composer before
115
+ // the provider's effect runs. Repeated focus polling was hostile to rich
116
+ // editors an unrelated render could keep reclaiming their selection.
117
+ // A chat has one focus owner (ChatProvider); mounting is one deliberate
118
+ // focus intent, not an 8-frame retry loop.
119
+ const raf = requestAnimationFrame(() => {
127
120
  const target =
128
121
  (targetRef?.current as Focusable | null) ?? getActiveComposer();
129
122
  target?.focus?.();
130
- tries += 1;
131
- // Stop once the composer owns focus, or we run out of tries. The
132
- // active-element check keeps us from stealing focus the user just
133
- // moved elsewhere (e.g. opened the slash menu) after the first frame.
134
- const editor = document.querySelector<HTMLElement>(
135
- '.ProseMirror, textarea',
136
- );
137
- const settled = !!editor && document.activeElement === editor;
138
- if (!settled && tries < MAX_TRIES) {
139
- raf = requestAnimationFrame(tick);
140
- }
141
- };
142
- raf = requestAnimationFrame(tick);
123
+ });
143
124
  return () => cancelAnimationFrame(raf);
144
125
  // Mount-only by design: the empty dep array means a host that wants
145
126
  // re-focus on chat-switch must remount the tree (the `key={id}`
@@ -510,6 +510,14 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
510
510
  if (!editor) return;
511
511
  const current = getMarkdown(editor);
512
512
  if (current !== value) {
513
+ // `setContent` replaces the ProseMirror document and therefore creates a
514
+ // fresh selection. In a controlled chat editor an unrelated parent
515
+ // render can arrive while the user is typing; without preserving this
516
+ // selection Chromium may release focus to <body> or place the caret at
517
+ // document start. External content still wins, but it must never erase
518
+ // the user's active editing position.
519
+ const hadFocus = editor.isFocused;
520
+ const previousSelection = editor.state.selection;
513
521
  isExternalUpdate.current = true;
514
522
  editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false });
515
523
  // After the new content is written, convert a leading `/verb` text
@@ -535,6 +543,18 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
535
543
  });
536
544
  }
537
545
  isExternalUpdate.current = false;
546
+
547
+ if (hadFocus) {
548
+ // SetContent / chip normalization can shorten the document. Clamp the
549
+ // old range to the new valid bounds, then restore it after focusing so
550
+ // both a collapsed caret and a text selection remain where the user
551
+ // left them. We intentionally do not focus an editor that was not
552
+ // active — a background update must not steal focus from another UI.
553
+ const maxPosition = editor.state.doc.content.size;
554
+ const from = Math.max(1, Math.min(previousSelection.from, maxPosition));
555
+ const to = Math.max(1, Math.min(previousSelection.to, maxPosition));
556
+ editor.chain().focus().setTextSelection({ from, to }).run();
557
+ }
538
558
  }
539
559
  }, [value, editor]);
540
560
 
@@ -204,6 +204,10 @@ A bare URL serialises as the bare URL; a file path as the literal path.
204
204
  pasted text, keeping the surrounding prose.
205
205
  - **Initial `value`**: paths / URLs in the controlled markdown are
206
206
  converted after `setContent` (the same way leading `/`-commands are).
207
+ - **External controlled update while editing**: the editor preserves the
208
+ current focused selection, clamped to the new document. A background parent
209
+ render must never send the caret to document start or steal focus from
210
+ another control.
207
211
 
208
212
  ### Caret / editing
209
213