@djangocfg/ui-tools 2.1.374 → 2.1.376

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.374",
3
+ "version": "2.1.376",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -156,8 +156,8 @@
156
156
  "check": "tsc --noEmit"
157
157
  },
158
158
  "peerDependencies": {
159
- "@djangocfg/i18n": "^2.1.374",
160
- "@djangocfg/ui-core": "^2.1.374",
159
+ "@djangocfg/i18n": "^2.1.376",
160
+ "@djangocfg/ui-core": "^2.1.376",
161
161
  "consola": "^3.4.2",
162
162
  "lodash-es": "^4.18.1",
163
163
  "lucide-react": "^0.545.0",
@@ -211,10 +211,10 @@
211
211
  "material-file-icons": "^2.4.0"
212
212
  },
213
213
  "devDependencies": {
214
- "@djangocfg/i18n": "^2.1.374",
214
+ "@djangocfg/i18n": "^2.1.376",
215
215
  "@djangocfg/playground": "workspace:*",
216
- "@djangocfg/typescript-config": "^2.1.374",
217
- "@djangocfg/ui-core": "^2.1.374",
216
+ "@djangocfg/typescript-config": "^2.1.376",
217
+ "@djangocfg/ui-core": "^2.1.376",
218
218
  "@types/lodash-es": "^4.17.12",
219
219
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
220
220
  "@types/node": "^24.7.2",
@@ -28,6 +28,7 @@ import { StreamingIndicator } from './components/StreamingIndicator';
28
28
  import { ChatProvider, useChatContext } from './context';
29
29
  import { useChatComposer } from './hooks/useChatComposer';
30
30
  import { useChatLightbox } from './hooks/useChatLightbox';
31
+ import { useAutoFocusOnStreamEnd } from './hooks/useAutoFocusOnStreamEnd';
31
32
  import { createMockTransport } from './core/transport/mock';
32
33
  import { dispatchToolPayload, isLatLng, isPlainObject } from './core/payload-dispatch';
33
34
  import { collectImageAttachments } from './utils/collectImageAttachments';
@@ -1281,3 +1282,75 @@ export const WailsLikeVirtualization = () => {
1281
1282
  </Frame>
1282
1283
  );
1283
1284
  };
1285
+
1286
+ // ---------------------------------------------------------------------------
1287
+ // AutoFocusOnStreamEnd — refocus composer the moment a reply lands
1288
+ // ---------------------------------------------------------------------------
1289
+ //
1290
+ // Standard chat UX: user types → sends → reads → starts typing again.
1291
+ // `useAutoFocusOnStreamEnd` watches `isStreaming` and fires `.focus()`
1292
+ // on the true → false edge. Pass any object with `.focus()` —
1293
+ // `useChatComposer`'s textareaRef, a forwarded handle, or a raw
1294
+ // HTMLTextAreaElement.
1295
+
1296
+ export const AutoFocusOnStreamEnd = () => {
1297
+ const [enabled] = useBoolean('autofocus', { defaultValue: true, label: 'Auto-focus composer when reply lands' });
1298
+
1299
+ const transport = useMemo(
1300
+ () =>
1301
+ createMockTransport({
1302
+ replies: [
1303
+ 'Reply landed — composer should be focused now. Start typing without reaching for the mouse.',
1304
+ 'Toggle the knob off to see the difference: focus stays wherever it was.',
1305
+ 'The hook only fires on the streaming → idle transition, so reading mid-stream is undisturbed.',
1306
+ ],
1307
+ latencyMs: 25,
1308
+ }),
1309
+ [],
1310
+ );
1311
+
1312
+ return (
1313
+ <Frame>
1314
+ <ChatProvider
1315
+ transport={transport}
1316
+ config={{
1317
+ greeting: 'Auto-focus demo',
1318
+ description:
1319
+ 'Send a message, wait for the reply to finish streaming, then start typing — the composer is already focused.',
1320
+ placeholder: 'Send anything…',
1321
+ }}
1322
+ >
1323
+ <AutoFocusWiring enabled={enabled} />
1324
+ <AutoFocusShell />
1325
+ </ChatProvider>
1326
+ </Frame>
1327
+ );
1328
+ };
1329
+
1330
+ /** Story-internal wiring component — sits inside ChatProvider. The
1331
+ * built-in `<Composer>` registers itself with the context on mount,
1332
+ * so the hook needs no args at all: it reads `isStreaming` and the
1333
+ * composer handle from context automatically. */
1334
+ function AutoFocusWiring({ enabled }: { enabled: boolean }) {
1335
+ useAutoFocusOnStreamEnd({ enabled });
1336
+ return null;
1337
+ }
1338
+
1339
+ /** Minimal shell to render the message list + composer. ChatRoot
1340
+ * doesn't take children so we drop a slim layout here. */
1341
+ function AutoFocusShell() {
1342
+ const ctx = useChatContext();
1343
+ const composer = useChatComposer({ onSubmit: (text) => ctx.sendMessage(text) });
1344
+ return (
1345
+ <div className="flex h-full flex-col">
1346
+ <MessageList
1347
+ messages={ctx.messages}
1348
+ renderItem={(m) => (
1349
+ <MessageBubble key={m.id} message={m} />
1350
+ )}
1351
+ className="flex-1"
1352
+ />
1353
+ <Composer composer={composer} placeholder={ctx.config.placeholder} />
1354
+ </div>
1355
+ );
1356
+ }
@@ -290,6 +290,58 @@ ctx.audio.isUnlocked; // boolean
290
290
 
291
291
  Or directly: `useChatAudio(config)` — returns the same surface, no provider required.
292
292
 
293
+ ## Composer focus & auto-focus on stream end
294
+
295
+ The chat context exposes a small composer registry so other parts of
296
+ the tree can drive the composer imperatively without prop-drilling a
297
+ ref. The built-in `<Composer>` registers itself on mount; custom
298
+ composers opt in with a one-line hook.
299
+
300
+ ```tsx
301
+ import { useChatContext, useRegisterComposer } from '@djangocfg/ui-tools';
302
+
303
+ // Inside any custom composer:
304
+ function MyComposer() {
305
+ const focus = useCallback(() => myEditorRef.current?.commands.focus(), []);
306
+ useRegisterComposer(focus);
307
+ // ...
308
+ }
309
+
310
+ // Then anywhere inside <ChatProvider>:
311
+ const ctx = useChatContext();
312
+ ctx.composer?.focus(); // null until any composer has mounted
313
+ ```
314
+
315
+ ### `useAutoFocusOnStreamEnd` — refocus when the reply lands
316
+
317
+ Standard chat UX: user types → sends → reads the reply → starts
318
+ typing again without reaching for the mouse. The hook listens for the
319
+ streaming → idle transition and calls `.focus()` exactly once. Reading
320
+ mid-stream is undisturbed.
321
+
322
+ Zero-config (default: focuses the registered composer):
323
+
324
+ ```tsx
325
+ import { useAutoFocusOnStreamEnd } from '@djangocfg/ui-tools';
326
+
327
+ function MyChatShell() {
328
+ useAutoFocusOnStreamEnd(); // reads isStreaming + composer from context
329
+ return <ChatProvider>{/* … */}</ChatProvider>;
330
+ }
331
+ ```
332
+
333
+ Options:
334
+
335
+ | option | default | when to set |
336
+ |---------------|------------------------|-----------------------------------------------------------------------------|
337
+ | `isStreaming` | `ctx.isStreaming` | Driving stream state from your own store (e.g. an external event bus). |
338
+ | `targetRef` | registered composer | Focus something other than the composer — an "approve" button, etc. |
339
+ | `enabled` | `true` | User preference toggle (off without unmounting the hook). |
340
+ | `delayMs` | `0` (next rAF) | 50–150ms helps when the composer re-mounts after the final chunk. |
341
+
342
+ The hook only fires on the `true → false` edge — flipping `enabled`
343
+ mid-stream won't steal focus.
344
+
293
345
  ## Attachment renderers (registry)
294
346
 
295
347
  `<Attachments>` and `<ChatRoot>` accept a per-type renderer map. Default tile is used when no renderer matches. Plug in heavy viewers (`LazyAudioPlayer`, `LazyImageViewer`, `LazyMap`) host-side without forcing `ui-tools/Chat` to depend on them.
@@ -464,7 +516,9 @@ createHttpTransport, createMockTransport, parseSSE, TransportError
464
516
 
465
517
  // Hooks
466
518
  useChat, useChatComposer, useChatScroll, useChatHistory, useChatLayout,
467
- useChatAudio, useChatLightbox
519
+ useChatAudio, useChatLightbox,
520
+ useAutoFocusOnStreamEnd, useRegisterComposer,
521
+ type UseAutoFocusOnStreamEndOptions, type Focusable
468
522
 
469
523
  // Audio
470
524
  ChatAudioConfig, ChatAudioEvent, ChatAudioSounds, UseChatAudioReturn,
@@ -479,7 +533,8 @@ type ToolPayloadMatcher, type ToolPayloadFallback
479
533
  collectImageAttachments
480
534
 
481
535
  // Context
482
- ChatProvider, useChatContext, useChatContextOptional
536
+ ChatProvider, useChatContext, useChatContextOptional,
537
+ type ComposerHandle
483
538
 
484
539
  // Components
485
540
  ChatRoot, MessageList, MessageBubble, MessageActions, Composer,
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { type ReactNode, forwardRef } from 'react';
3
+ import { type ReactNode, forwardRef, useEffect } from 'react';
4
4
  import { Paperclip, Send, Square } from 'lucide-react';
5
5
 
6
6
  import { Button, Textarea } from '@djangocfg/ui-core/components';
@@ -98,6 +98,18 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
98
98
  const isDisabled = disabled ?? isStreaming;
99
99
  const sz = SIZE_CLASSES[size];
100
100
 
101
+ // Register the composer's focus() with the chat context so other
102
+ // parts of the tree (e.g. useAutoFocusOnStreamEnd) can drive it
103
+ // imperatively without prop-drilling a ref. No-op when used outside
104
+ // a ChatProvider.
105
+ const register = ctx?.registerComposer;
106
+ const composerFocus = composer.focus;
107
+ useEffect(() => {
108
+ if (!register) return;
109
+ register({ focus: composerFocus });
110
+ return () => register(null);
111
+ }, [register, composerFocus]);
112
+
101
113
  return (
102
114
  <div
103
115
  ref={ref}
@@ -8,15 +8,24 @@ import {
8
8
  useEffect,
9
9
  useMemo,
10
10
  useRef,
11
+ useState,
11
12
  } from 'react';
12
13
 
13
14
  import type { ChatConfig, ChatLabels, ChatTransport } from '../types';
14
15
  import { DEFAULT_LABELS } from '../types';
16
+
15
17
  import { useChat, type UseChatReturn } from '../hooks/useChat';
16
18
  import { useChatLayout, type UseChatLayoutReturn } from '../hooks/useChatLayout';
17
19
  import { useChatAudio } from '../hooks/useChatAudio';
18
20
  import type { ChatAudioConfig, UseChatAudioReturn } from '../core/audio/types';
19
21
 
22
+ /** Minimal handle a composer (built-in or custom) registers so other
23
+ * parts of the chat tree can drive it imperatively — `.focus()` is
24
+ * enough today; expand the surface as new needs arise. */
25
+ export interface ComposerHandle {
26
+ focus: () => void;
27
+ }
28
+
20
29
  export interface ChatContextValue extends UseChatReturn {
21
30
  layout: UseChatLayoutReturn;
22
31
  config: ChatConfig;
@@ -26,6 +35,13 @@ export interface ChatContextValue extends UseChatReturn {
26
35
  * Components like ``AudioToggle`` use this to auto-hide when there
27
36
  * is nothing to mute. */
28
37
  hasAudio: boolean;
38
+ /** Composer registry. The built-in `<Composer>` calls
39
+ * `registerComposer({ focus })` on mount; custom composers (e.g.
40
+ * cmdop's MarkdownEditor wrapper) do the same via the
41
+ * `useRegisterComposer` helper. Read it via `composer?.focus()` —
42
+ * null until any composer has mounted. Plan64 follow-up. */
43
+ composer: ComposerHandle | null;
44
+ registerComposer: (handle: ComposerHandle | null) => void;
29
45
  }
30
46
 
31
47
  const Ctx = createContext<ChatContextValue | null>(null);
@@ -121,9 +137,18 @@ export function ChatProvider({
121
137
  );
122
138
  }, [audio]);
123
139
 
140
+ // Composer registry — kept in state (not a ref) so consumers
141
+ // observing `ctx.composer` re-render when a composer mounts /
142
+ // unmounts. The setter is the API surface; pass it to the composer
143
+ // on mount and call with `null` on unmount.
144
+ const [composer, setComposer] = useState<ComposerHandle | null>(null);
145
+ const registerComposer = useCallback((handle: ComposerHandle | null) => {
146
+ setComposer(handle);
147
+ }, []);
148
+
124
149
  const value = useMemo<ChatContextValue>(
125
- () => ({ ...chat, layout, config, labels, audio: audioApi, hasAudio }),
126
- [chat, layout, config, labels, audioApi, hasAudio],
150
+ () => ({ ...chat, layout, config, labels, audio: audioApi, hasAudio, composer, registerComposer }),
151
+ [chat, layout, config, labels, audioApi, hasAudio, composer, registerComposer],
127
152
  );
128
153
 
129
154
  return (
@@ -6,4 +6,5 @@ export {
6
6
  useChatContextOptional,
7
7
  type ChatContextValue,
8
8
  type ChatProviderProps,
9
+ type ComposerHandle,
9
10
  } from './ChatProvider';
@@ -24,3 +24,9 @@ export {
24
24
  type ChatLightboxState,
25
25
  type ChatLightboxScope,
26
26
  } from './useChatLightbox';
27
+ export {
28
+ useAutoFocusOnStreamEnd,
29
+ useRegisterComposer,
30
+ type UseAutoFocusOnStreamEndOptions,
31
+ type Focusable,
32
+ } from './useAutoFocusOnStreamEnd';
@@ -0,0 +1,128 @@
1
+ 'use client';
2
+
3
+ import { type RefObject, useEffect, useRef } from 'react';
4
+
5
+ import { useChatContextOptional, type ComposerHandle } from '../context';
6
+
7
+ /** Anything with a `.focus()` method — covers HTMLElement, the
8
+ * composer's textareaRef from `useChatComposer`, and any custom
9
+ * imperative handle exposing the same shape. */
10
+ export interface Focusable {
11
+ focus: () => void;
12
+ }
13
+
14
+ export interface UseAutoFocusOnStreamEndOptions {
15
+ /** True while an assistant reply is streaming. The hook fires the
16
+ * focus() call on the true → false transition.
17
+ *
18
+ * When omitted, the hook reads `isStreaming` from `useChatContext`.
19
+ * Pass it explicitly only if you're driving stream state from your
20
+ * own store (cmdop's Wails event bus, for example). */
21
+ isStreaming?: boolean;
22
+ /** Ref / handle to focus when the reply lands.
23
+ *
24
+ * When omitted, the hook uses the composer handle registered in
25
+ * the chat context — the built-in `<Composer>` registers itself
26
+ * automatically, custom composers can opt in via
27
+ * `useRegisterComposer`. Pass `targetRef` only when you need to
28
+ * focus something other than the composer (e.g. an "approve"
29
+ * button, a quick-reply chip). */
30
+ targetRef?: RefObject<Focusable | HTMLElement | null>;
31
+ /** Opt-out. Default true. Pass false to disable without unmounting
32
+ * the hook (e.g. user preference). */
33
+ enabled?: boolean;
34
+ /** Delay the focus call by this many ms. Default 0 = next animation
35
+ * frame, which lets the streaming bubble's final commit settle
36
+ * before focus pulls scroll. Bump to 50-150ms for layouts that
37
+ * re-mount the composer after the final chunk. */
38
+ delayMs?: number;
39
+ }
40
+
41
+ /**
42
+ * Refocus the chat composer the moment the assistant reply finishes
43
+ * streaming. Standard chat UX: the user types → sends → reads the
44
+ * reply → starts typing again without reaching for the mouse.
45
+ *
46
+ * Default (zero-config) usage — works the moment a `<Composer>` is
47
+ * mounted inside a `<ChatProvider>`:
48
+ *
49
+ * useAutoFocusOnStreamEnd();
50
+ *
51
+ * Custom composer / advanced wiring:
52
+ *
53
+ * const ref = useRef<{ focus: () => void } | null>(null);
54
+ * useAutoFocusOnStreamEnd({ targetRef: ref });
55
+ *
56
+ * Driving stream state yourself:
57
+ *
58
+ * useAutoFocusOnStreamEnd({ isStreaming: myExternalStreaming });
59
+ *
60
+ * Only the true → false transition fires focus — toggling `enabled`
61
+ * mid-stream won't steal focus while the user is reading.
62
+ */
63
+ export function useAutoFocusOnStreamEnd(
64
+ options: UseAutoFocusOnStreamEndOptions = {},
65
+ ): void {
66
+ const { isStreaming: isStreamingProp, targetRef, enabled = true, delayMs = 0 } = options;
67
+ const ctx = useChatContextOptional();
68
+ // Prefer the prop (caller knows best), fall back to context.
69
+ const isStreaming = isStreamingProp ?? ctx?.isStreaming ?? false;
70
+
71
+ // Keep latest ctx-composer in a ref so the focus effect always
72
+ // sees the freshest registered handle without re-firing when
73
+ // composers re-mount.
74
+ const composerHandleRef = useRef<ComposerHandle | null>(null);
75
+ composerHandleRef.current = ctx?.composer ?? null;
76
+
77
+ const prevStreamingRef = useRef(isStreaming);
78
+
79
+ useEffect(() => {
80
+ const wasStreaming = prevStreamingRef.current;
81
+ prevStreamingRef.current = isStreaming;
82
+
83
+ if (!enabled) return;
84
+ if (!(wasStreaming && !isStreaming)) return;
85
+
86
+ const focusNow = () => {
87
+ // Resolve target in priority order: explicit ref > registered
88
+ // composer handle. Refs may carry an HTMLElement (raw DOM) or
89
+ // any object with `.focus()`; both are handled by the same
90
+ // call site below.
91
+ const explicit = targetRef?.current as Focusable | null;
92
+ const target: Focusable | null = explicit ?? composerHandleRef.current;
93
+ target?.focus();
94
+ };
95
+
96
+ if (delayMs > 0) {
97
+ const id = window.setTimeout(focusNow, delayMs);
98
+ return () => window.clearTimeout(id);
99
+ }
100
+ const raf = requestAnimationFrame(focusNow);
101
+ return () => cancelAnimationFrame(raf);
102
+ }, [isStreaming, enabled, delayMs, targetRef]);
103
+ }
104
+
105
+ /**
106
+ * Helper for custom composers (anything that's NOT the built-in
107
+ * `<Composer>`) to register their focus() with the chat context so
108
+ * `useAutoFocusOnStreamEnd()` and other consumers work without
109
+ * prop-drilling.
110
+ *
111
+ * Usage inside your custom composer:
112
+ *
113
+ * const focus = useCallback(() => {
114
+ * myEditorRef.current?.commands.focus();
115
+ * }, []);
116
+ * useRegisterComposer(focus);
117
+ *
118
+ * No-op when called outside a `<ChatProvider>`.
119
+ */
120
+ export function useRegisterComposer(focus: () => void): void {
121
+ const ctx = useChatContextOptional();
122
+ const register = ctx?.registerComposer;
123
+ useEffect(() => {
124
+ if (!register) return;
125
+ register({ focus });
126
+ return () => register(null);
127
+ }, [register, focus]);
128
+ }
@@ -74,6 +74,8 @@ export {
74
74
  useChatHistory,
75
75
  useChatLayout,
76
76
  useChatAudio,
77
+ useAutoFocusOnStreamEnd,
78
+ useRegisterComposer,
77
79
  type UseChatConfig,
78
80
  type UseChatReturn,
79
81
  type UseChatComposerOptions,
@@ -83,6 +85,8 @@ export {
83
85
  type UseChatHistoryOptions,
84
86
  type UseChatLayoutConfig,
85
87
  type UseChatLayoutReturn,
88
+ type UseAutoFocusOnStreamEndOptions,
89
+ type Focusable,
86
90
  } from './hooks';
87
91
 
88
92
  // Audio
@@ -1,14 +0,0 @@
1
- 'use strict';
2
-
3
- var chunkTAEHNX4W_cjs = require('./chunk-TAEHNX4W.cjs');
4
- require('./chunk-XACCHZH2.cjs');
5
- require('./chunk-OLISEQHS.cjs');
6
-
7
-
8
-
9
- Object.defineProperty(exports, "ChatRoot", {
10
- enumerable: true,
11
- get: function () { return chunkTAEHNX4W_cjs.ChatRoot; }
12
- });
13
- //# sourceMappingURL=ChatRoot-3LA3DSNY.cjs.map
14
- //# sourceMappingURL=ChatRoot-3LA3DSNY.cjs.map
@@ -1,5 +0,0 @@
1
- export { ChatRoot } from './chunk-PSM3DUTC.mjs';
2
- import './chunk-NWUT327A.mjs';
3
- import './chunk-N2XQF2OL.mjs';
4
- //# sourceMappingURL=ChatRoot-RIETBE55.mjs.map
5
- //# sourceMappingURL=ChatRoot-RIETBE55.mjs.map