@djangocfg/ui-tools 2.1.442 → 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.
|
|
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.
|
|
338
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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.
|
|
422
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
423
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
package/src/tools/chat/public.ts
CHANGED
|
@@ -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,
|