@djangocfg/ui-tools 2.1.443 → 2.1.444

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.443",
3
+ "version": "2.1.444",
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.443",
338
- "@djangocfg/ui-core": "^2.1.443",
337
+ "@djangocfg/i18n": "^2.1.444",
338
+ "@djangocfg/ui-core": "^2.1.444",
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.7.0"
419
419
  },
420
420
  "devDependencies": {
421
- "@djangocfg/i18n": "^2.1.443",
422
- "@djangocfg/typescript-config": "^2.1.443",
423
- "@djangocfg/ui-core": "^2.1.443",
421
+ "@djangocfg/i18n": "^2.1.444",
422
+ "@djangocfg/typescript-config": "^2.1.444",
423
+ "@djangocfg/ui-core": "^2.1.444",
424
424
  "@types/lodash-es": "^4.17.12",
425
425
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
426
426
  "@types/node": "^25.2.3",
@@ -1,10 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { forwardRef, useCallback, 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
+ import { attachComposer, getActiveComposer } from '@djangocfg/ui-tools/composer-registry';
8
8
 
9
9
  import { useChatContextOptional } from '../context';
10
10
  import type { UseChatComposerReturn } from '../hooks/useChatComposer';
@@ -181,6 +181,15 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
181
181
  const ctx = useChatContextOptional();
182
182
  const isStreaming = isStreamingProp ?? ctx?.isStreaming ?? false;
183
183
  const onCancel = onCancelProp ?? ctx?.cancelStream;
184
+ // Two distinct gates, deliberately split:
185
+ // • isInputDisabled — can the user TYPE / focus / edit the buffer? Only the
186
+ // explicit `disabled` prop closes this. Streaming does NOT, so the user can
187
+ // compose the next message while the agent is still answering.
188
+ // • isDisabled — is SUBMIT blocked (Enter no-ops, Send is a Stop)? Closed by
189
+ // `disabled` OR streaming, so a streamed turn can't be double-sent. The
190
+ // send-while-streaming guard lives in `gatedComposer` (Enter is swallowed).
191
+ // Attach / drag-drop follow isDisabled (no file ops mid-stream).
192
+ const isInputDisabled = disabled ?? false;
184
193
  const isDisabled = disabled ?? isStreaming;
185
194
  const sz = SIZE_CLASSES[size];
186
195
  // `appearance` is orthogonal to `size` — `full` layers extra
@@ -245,23 +254,31 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
245
254
  // surface (built-in Send, custom SendSlot, TipTap `ComposerRichTextarea`,
246
255
  // plain `<Textarea>`) shares the same gate without each having to
247
256
  // know about slash state.
257
+ // Submit is gated when the slash menu blocks it OR while the agent is
258
+ // streaming. Streaming keeps the textarea EDITABLE (isInputDisabled is false)
259
+ // so the user can pre-compose the next message, but Enter/Send must not fire a
260
+ // second turn — the gate swallows the submit and the Enter key, exactly like
261
+ // the slash gate. (The chosen behavior: "type ahead, Enter doesn't send until
262
+ // the answer lands"; `useAutoFocusOnStreamEnd` returns focus afterward.)
248
263
  const slashAllowsSubmit = slashEnabled ? slash.canSubmit : true;
264
+ const submitGated = !slashAllowsSubmit || isStreaming;
249
265
  const gatedComposer = useMemo<UseChatComposerReturn>(() => {
250
- if (slashAllowsSubmit) return composer;
266
+ if (!submitGated) return composer;
251
267
  const baseOnKeyDown = composer.textareaProps.onKeyDown;
252
268
  return {
253
269
  ...composer,
254
270
  canSubmit: false,
255
271
  submit: async () => {
256
- // Slash gate refuses — swallow programmatic submit attempts.
272
+ // Gate refuses (slash incomplete or streaming) — swallow programmatic
273
+ // submit attempts so a streamed turn can't be double-sent.
257
274
  },
258
275
  textareaProps: {
259
276
  ...composer.textareaProps,
260
277
  onKeyDown: (e) => {
261
- // Block Enter (without Shift) so the textarea's own submit
262
- // binding cannot fire while the slash gate is closed. Cmd /
263
- // Ctrl + Enter is treated the same. Other keys (history,
264
- // arrows, etc.) pass through to the original handler.
278
+ // Block Enter (without Shift) so the textarea's own submit binding
279
+ // cannot fire while the gate is closed. Cmd / Ctrl + Enter is treated
280
+ // the same. Other keys (typing, history, arrows) pass through — the
281
+ // buffer stays editable so the user can compose ahead while streaming.
265
282
  if (e.key === 'Enter' && !e.shiftKey) {
266
283
  e.preventDefault();
267
284
  return;
@@ -270,7 +287,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
270
287
  },
271
288
  },
272
289
  };
273
- }, [composer, slashAllowsSubmit]);
290
+ }, [composer, submitGated]);
274
291
  // Capture-phase handler — runs before the textarea / TipTap own
275
292
  // submit binding, so Enter / Tab pick a verb instead of sending.
276
293
  const handleSlashKeyDownCapture = !slashEnabled
@@ -287,7 +304,9 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
287
304
  // depend on which textarea variant is mounted.
288
305
  const surfaceRef = useRef<HTMLDivElement>(null);
289
306
  const handleSurfaceMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
290
- if (isDisabled) return;
307
+ // Use isInputDisabled — clicking the surface should focus the editor even
308
+ // while streaming (the buffer is editable; only submit is gated).
309
+ if (isInputDisabled) return;
291
310
  const target = e.target as HTMLElement;
292
311
  // A real interactive element (or the editor itself) handles its own
293
312
  // focus — only act on clicks that land on the bare surface padding.
@@ -357,6 +376,20 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
357
376
  if (dropped.length > 0) attachHandle.attachFiles(dropped);
358
377
  };
359
378
 
379
+ // Send, then return focus to the composer. Clicking the Send button
380
+ // moves focus to that button; after the message goes the caret should
381
+ // be back in the input so the user can keep typing without reaching for
382
+ // the mouse (the same UX the stream-end refocus gives on Enter-send —
383
+ // this covers the button-click and instant/non-streaming paths too).
384
+ // Resolve the live handle from the registry so it works for both the
385
+ // built-in <textarea> and the custom TipTap composer. No-op while the
386
+ // gate is closed (gatedComposer.submit swallows it).
387
+ const handleSend = useCallback(() => {
388
+ void Promise.resolve(gatedComposer.submit()).finally(() => {
389
+ requestAnimationFrame(() => getActiveComposer()?.focus?.());
390
+ });
391
+ }, [gatedComposer]);
392
+
360
393
  // Merge built-in send/stop/attach descriptors with host arrays.
361
394
  const { actionsStart, actionsEnd } = useComposerActions({
362
395
  composer: gatedComposer,
@@ -366,7 +399,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
366
399
  onPickFiles: pickFiles,
367
400
  actionsStart: composerSlots?.actionsStart,
368
401
  actionsEnd: composerSlots?.actionsEnd,
369
- onSend: () => void gatedComposer.submit(),
402
+ onSend: handleSend,
370
403
  onCancel,
371
404
  micSendSwap,
372
405
  });
@@ -390,11 +423,14 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
390
423
  slotProps?.textarea?.className,
391
424
  textareaClassName,
392
425
  );
426
+ // The textarea uses isInputDisabled (NOT isDisabled): it stays editable while
427
+ // streaming so the user can compose ahead. Submit is still blocked — the
428
+ // gatedComposer swallows Enter and the Send button shows Stop (isDisabled).
393
429
  const textareaNode = TextareaSlot ? (
394
430
  <TextareaSlot
395
431
  composer={gatedComposer}
396
432
  placeholder={placeholder}
397
- disabled={isDisabled}
433
+ disabled={isInputDisabled}
398
434
  size={size}
399
435
  className={slotProps?.textarea?.className ?? textareaClassName}
400
436
  />
@@ -402,7 +438,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
402
438
  <SlashHighlightTextarea
403
439
  composer={gatedComposer}
404
440
  placeholder={placeholder}
405
- disabled={isDisabled}
441
+ disabled={isInputDisabled}
406
442
  size={size}
407
443
  textareaClassName={sharedTextareaClass}
408
444
  />
@@ -414,7 +450,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
414
450
  aria-label={placeholder}
415
451
  aria-multiline="true"
416
452
  aria-keyshortcuts="Enter"
417
- disabled={isDisabled}
453
+ disabled={isInputDisabled}
418
454
  className={sharedTextareaClass}
419
455
  />
420
456
  );
@@ -450,7 +486,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
450
486
  streaming={isStreaming}
451
487
  disabled={!gatedComposer.canSubmit}
452
488
  size={size}
453
- onSend={() => void gatedComposer.submit()}
489
+ onSend={handleSend}
454
490
  onCancel={onCancel}
455
491
  {...slotProps?.send}
456
492
  />
@@ -21,6 +21,7 @@ import { useChat, type UseChatReturn } from '../hooks/useChat';
21
21
  import { useChatLayout, type UseChatLayoutReturn } from '../hooks/useChatLayout';
22
22
  import { useChatAudio } from '../hooks/useChatAudio';
23
23
  import { useStreamEndFocus } from '../hooks/useStreamEndFocus';
24
+ import { useAutoFocusOnMount } from '../hooks/useAutoFocusOnStreamEnd';
24
25
  import type { ChatAudioConfig, UseChatAudioReturn } from '../core/audio/types';
25
26
 
26
27
  /** Imperative handle a composer (built-in or custom) registers so
@@ -93,6 +94,15 @@ export interface ChatProviderProps {
93
94
  * long as a composer is registered. Set `false` to opt out.
94
95
  */
95
96
  autoFocusOnStreamEnd?: boolean;
97
+ /**
98
+ * Focus the registered composer when the chat mounts — caret already in
99
+ * the input the instant the chat appears. With the standard
100
+ * `key={conversationId}` engine remount, this also focuses on every
101
+ * chat-switch (the tree remounts → mount focus re-fires) without any
102
+ * id-tracking effect. Default `true`. Set `false` to opt out (e.g. an
103
+ * embedded widget that shouldn't steal focus on load).
104
+ */
105
+ autoFocusOnMount?: boolean;
96
106
  children?: ReactNode;
97
107
  }
98
108
 
@@ -108,6 +118,7 @@ export function ChatProvider({
108
118
  onBeforeSend,
109
119
  getDynamicMetadata,
110
120
  autoFocusOnStreamEnd = true,
121
+ autoFocusOnMount = true,
111
122
  children,
112
123
  }: ChatProviderProps) {
113
124
  const audioApi = useChatAudio(audio ?? {});
@@ -199,6 +210,11 @@ export function ChatProvider({
199
210
  resolveTarget: () => getActiveComposer(),
200
211
  });
201
212
 
213
+ // Focus the composer on mount (and, via the host's `key={id}` engine
214
+ // remount, on every chat-switch) so the caret is ready the moment the
215
+ // chat appears. Pairs with the stream-end focus above.
216
+ useAutoFocusOnMount({ enabled: autoFocusOnMount });
217
+
202
218
  const value = useMemo<ChatContextValue>(
203
219
  () => ({
204
220
  ...chat,
@@ -21,8 +21,10 @@ export {
21
21
  } from './useChatLightbox';
22
22
  export {
23
23
  useAutoFocusOnStreamEnd,
24
+ useAutoFocusOnMount,
24
25
  useRegisterComposer,
25
26
  type UseAutoFocusOnStreamEndOptions,
27
+ type UseAutoFocusOnMountOptions,
26
28
  type Focusable,
27
29
  } from './useAutoFocusOnStreamEnd';
28
30
  export {
@@ -79,6 +79,75 @@ export function useAutoFocusOnStreamEnd(
79
79
  });
80
80
  }
81
81
 
82
+ export interface UseAutoFocusOnMountOptions {
83
+ /** Opt-out. Default true. */
84
+ enabled?: boolean;
85
+ /** Ref / handle to focus. Defaults to the registered composer handle. */
86
+ targetRef?: RefObject<Focusable | HTMLElement | null>;
87
+ }
88
+
89
+ /**
90
+ * Focus the chat composer once, when the chat tree mounts.
91
+ *
92
+ * The companion to `useStreamEndFocus`: that one fires on the streaming
93
+ * true→false edge so the user can keep typing after a reply; this one
94
+ * fires on mount so the caret is already in the composer the instant the
95
+ * chat appears — and, because a host that keys the chat engine on the
96
+ * active conversation (`key={machineId}`) remounts the whole tree on every
97
+ * switch, it doubles as "focus the input when you switch chats" with no
98
+ * id-tracking effect. Pure Google-style: open a chat, just start typing.
99
+ *
100
+ * Runs on the next animation frame so a custom composer's
101
+ * `useRegisterComposer` effect has run and the handle is live before we
102
+ * resolve it (mount focus would otherwise race an empty registry). No-op
103
+ * when nothing is registered yet.
104
+ *
105
+ * `<ChatProvider autoFocusOnMount>` runs this for free; call it directly
106
+ * only to focus something other than the composer.
107
+ */
108
+ export function useAutoFocusOnMount(
109
+ options: UseAutoFocusOnMountOptions = {},
110
+ ): void {
111
+ const { enabled = true, targetRef } = options;
112
+ useEffect(() => {
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 = () => {
127
+ const target =
128
+ (targetRef?.current as Focusable | null) ?? getActiveComposer();
129
+ 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);
143
+ return () => cancelAnimationFrame(raf);
144
+ // Mount-only by design: the empty dep array means a host that wants
145
+ // re-focus on chat-switch must remount the tree (the `key={id}`
146
+ // pattern), which is exactly how the chat engine is already keyed.
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ }, []);
149
+ }
150
+
82
151
  /**
83
152
  * Helper for custom composers (anything that's NOT the built-in
84
153
  * `<Composer>`) to register their focus() with the chat context so
@@ -114,6 +114,7 @@ export {
114
114
  useChatLayout,
115
115
  useChatAudio,
116
116
  useAutoFocusOnStreamEnd,
117
+ useAutoFocusOnMount,
117
118
  useRegisterComposer,
118
119
  useChatReset,
119
120
  useVisitorFingerprint,
@@ -134,6 +135,7 @@ export {
134
135
  type UseChatLayoutConfig,
135
136
  type UseChatLayoutReturn,
136
137
  type UseAutoFocusOnStreamEndOptions,
138
+ type UseAutoFocusOnMountOptions,
137
139
  type Focusable,
138
140
  type UseChatResetOptions,
139
141
  type UseChatResetReturn,