@djangocfg/ui-tools 2.1.368 → 2.1.371
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/dist/ChatRoot-DYMCNGOB.mjs +5 -0
- package/dist/{ChatRoot-6U7633X3.mjs.map → ChatRoot-DYMCNGOB.mjs.map} +1 -1
- package/dist/ChatRoot-HOQ37WRE.cjs +14 -0
- package/dist/{ChatRoot-HARTIAJ5.cjs.map → ChatRoot-HOQ37WRE.cjs.map} +1 -1
- package/dist/{chunk-OPKFKTIN.cjs → chunk-2SKR4U5S.cjs} +160 -203
- package/dist/chunk-2SKR4U5S.cjs.map +1 -0
- package/dist/{chunk-WGU5BEZX.mjs → chunk-MVAT6OPZ.mjs} +163 -204
- package/dist/chunk-MVAT6OPZ.mjs.map +1 -0
- package/dist/index.cjs +181 -55
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +113 -4
- package/dist/index.d.ts +113 -4
- package/dist/index.mjs +136 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -6
- package/src/tools/Chat/README.md +22 -1
- package/src/tools/Chat/components/ChatRoot.tsx +15 -28
- package/src/tools/Chat/components/MessageBubble.tsx +37 -9
- package/src/tools/Chat/components/MessageList.tsx +203 -27
- package/src/tools/Chat/components/index.ts +5 -1
- package/src/tools/Chat/context/ChatProvider.tsx +8 -0
- package/src/tools/Chat/hooks/useChat.ts +24 -3
- package/src/tools/Chat/hooks/useChatComposer.ts +39 -1
- package/src/tools/Chat/hooks/useChatScroll.ts +13 -0
- package/src/tools/Chat/index.ts +1 -0
- package/src/tools/Chat/types.ts +6 -0
- package/dist/ChatRoot-6U7633X3.mjs +0 -5
- package/dist/ChatRoot-HARTIAJ5.cjs +0 -14
- package/dist/chunk-OPKFKTIN.cjs.map +0 -1
- package/dist/chunk-WGU5BEZX.mjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.371",
|
|
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.
|
|
160
|
-
"@djangocfg/ui-core": "^2.1.
|
|
159
|
+
"@djangocfg/i18n": "^2.1.371",
|
|
160
|
+
"@djangocfg/ui-core": "^2.1.371",
|
|
161
161
|
"consola": "^3.4.2",
|
|
162
162
|
"lodash-es": "^4.18.1",
|
|
163
163
|
"lucide-react": "^0.545.0",
|
|
@@ -193,6 +193,7 @@
|
|
|
193
193
|
"react-lottie-player": "^2.1.0",
|
|
194
194
|
"react-map-gl": "^8.1.0",
|
|
195
195
|
"react-markdown": "10.1.0",
|
|
196
|
+
"react-virtuoso": "^4.18.7",
|
|
196
197
|
"react-zoom-pan-pinch": "^3.7.0",
|
|
197
198
|
"rehype-external-links": "^3.0.0",
|
|
198
199
|
"rehype-raw": "^7.0.0",
|
|
@@ -210,10 +211,10 @@
|
|
|
210
211
|
"material-file-icons": "^2.4.0"
|
|
211
212
|
},
|
|
212
213
|
"devDependencies": {
|
|
213
|
-
"@djangocfg/i18n": "^2.1.
|
|
214
|
+
"@djangocfg/i18n": "^2.1.371",
|
|
214
215
|
"@djangocfg/playground": "workspace:*",
|
|
215
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
216
|
-
"@djangocfg/ui-core": "^2.1.
|
|
216
|
+
"@djangocfg/typescript-config": "^2.1.371",
|
|
217
|
+
"@djangocfg/ui-core": "^2.1.371",
|
|
217
218
|
"@types/lodash-es": "^4.17.12",
|
|
218
219
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
219
220
|
"@types/node": "^24.7.2",
|
package/src/tools/Chat/README.md
CHANGED
|
@@ -513,7 +513,28 @@ LazyChat
|
|
|
513
513
|
- **Token coalescing.** `createTokenBuffer` aggregates stream chunks within ~16ms before dispatching → ≤1 render per frame.
|
|
514
514
|
- **Plain text during stream.** `MessageBubble` skips ReactMarkdown until the message finishes, then re-renders once with full markdown.
|
|
515
515
|
- **Memoized bubbles.** Memo key `(id, content, isStreaming, version, toolActivity, toolCalls, sources, attachments)` — references only.
|
|
516
|
-
- **Virtualization is
|
|
516
|
+
- **Virtualization is on by default.** As of plan64, `<MessageList>` is built on [`react-virtuoso`](https://virtuoso.dev/). Sticky-bottom + auto-follow on streaming come from `followOutput`, top-of-list pagination from `startReached`, scroll-anchor preservation on prepend from `firstItemIndex`. No host-side virtualizer needed. Set `noVirtualize` on `<MessageList>` to opt out (stories / DevTools tracing).
|
|
517
|
+
|
|
518
|
+
### Scroll API
|
|
519
|
+
|
|
520
|
+
`<MessageList>` exposes an imperative handle:
|
|
521
|
+
|
|
522
|
+
```tsx
|
|
523
|
+
const listRef = useRef<MessageListHandle>(null);
|
|
524
|
+
|
|
525
|
+
<MessageList
|
|
526
|
+
ref={listRef}
|
|
527
|
+
onAtBottomChange={setIsAtBottom} // drives "Jump to latest" pill
|
|
528
|
+
onStartReached={() => void loadMore()} // top-of-list pagination
|
|
529
|
+
/>;
|
|
530
|
+
|
|
531
|
+
listRef.current?.scrollToBottom(true); // smooth jump
|
|
532
|
+
listRef.current?.scrollToIndex(42); // jump to a specific bubble
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
The legacy `useChatScroll` hook is kept for hosts that render their
|
|
536
|
+
own (non-virtualized) scroll container, but is `@deprecated` for use
|
|
537
|
+
with `<MessageList>` — the component owns sticky-bottom internally.
|
|
517
538
|
|
|
518
539
|
## Design docs
|
|
519
540
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { type ReactNode, useRef } from 'react';
|
|
3
|
+
import { type ReactNode, useRef, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
6
|
|
|
@@ -8,14 +8,12 @@ import type { ChatAttachment, ChatConfig, ChatMessage, ChatTransport } from '../
|
|
|
8
8
|
import type { ChatAudioConfig } from '../core/audio/types';
|
|
9
9
|
import { ChatProvider, useChatContext, type ChatContextValue } from '../context';
|
|
10
10
|
import { useChatComposer, type UseChatComposerReturn } from '../hooks/useChatComposer';
|
|
11
|
-
import { useChatScroll } from '../hooks/useChatScroll';
|
|
12
|
-
import { useChatHistory } from '../hooks/useChatHistory';
|
|
13
11
|
import { Composer, type ComposerSize } from './Composer';
|
|
14
12
|
import { EmptyState } from './EmptyState';
|
|
15
13
|
import { ErrorBanner } from './ErrorBanner';
|
|
16
14
|
import { JumpToLatest } from './JumpToLatest';
|
|
17
15
|
import { MessageBubble } from './MessageBubble';
|
|
18
|
-
import { MessageList } from './MessageList';
|
|
16
|
+
import { MessageList, type MessageListHandle } from './MessageList';
|
|
19
17
|
import type { AttachmentRendererMap } from './Attachments';
|
|
20
18
|
import type { ToolCallsProps } from './ToolCalls';
|
|
21
19
|
|
|
@@ -115,24 +113,14 @@ function ChatRootShell({ className, listClassName, slots }: ChatRootShellProps)
|
|
|
115
113
|
onSubmit: (content, attachments) => chat.sendMessage(content, attachments),
|
|
116
114
|
disabled: chat.isStreaming,
|
|
117
115
|
});
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
messagesCount: chat.messages.length,
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
useChatHistory({
|
|
130
|
-
containerRef,
|
|
131
|
-
topSentinelRef: topRef,
|
|
132
|
-
hasMore: chat.hasMore,
|
|
133
|
-
isLoadingMore: chat.isLoadingMore,
|
|
134
|
-
loadMore: chat.loadMore,
|
|
135
|
-
});
|
|
116
|
+
// MessageList (virtuoso) owns the scroll viewport. We talk to it
|
|
117
|
+
// via the imperative handle (scrollToBottom on JumpToLatest click)
|
|
118
|
+
// and via the `onAtBottomChange` callback (drives the pill).
|
|
119
|
+
const listRef = useRef<MessageListHandle | null>(null);
|
|
120
|
+
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
121
|
+
const handleStartReached = chat.hasMore && !chat.isLoadingMore
|
|
122
|
+
? () => void chat.loadMore()
|
|
123
|
+
: undefined;
|
|
136
124
|
|
|
137
125
|
const greeting = chat.config.greeting ?? 'How can I help?';
|
|
138
126
|
const description = chat.config.description;
|
|
@@ -180,19 +168,18 @@ function ChatRootShell({ className, listClassName, slots }: ChatRootShellProps)
|
|
|
180
168
|
onRetry={chat.error ? () => void chat.regenerate() : undefined}
|
|
181
169
|
/>
|
|
182
170
|
<MessageList
|
|
183
|
-
ref={
|
|
184
|
-
topSentinelRef={topRef}
|
|
185
|
-
bottomRef={bottomRef}
|
|
171
|
+
ref={listRef}
|
|
186
172
|
renderItem={renderItem}
|
|
187
173
|
renderEmpty={() => <>{emptyNode}</>}
|
|
188
174
|
className={listClassName}
|
|
175
|
+
onStartReached={handleStartReached}
|
|
176
|
+
onAtBottomChange={setIsAtBottom}
|
|
189
177
|
/>
|
|
190
178
|
<div className="pointer-events-none absolute inset-x-0 bottom-2 flex justify-center">
|
|
191
179
|
{slots.jumpToLatest ?? (
|
|
192
180
|
<JumpToLatest
|
|
193
|
-
visible={!
|
|
194
|
-
|
|
195
|
-
onClick={() => scroll.scrollToBottom(true)}
|
|
181
|
+
visible={!isAtBottom}
|
|
182
|
+
onClick={() => listRef.current?.scrollToBottom(true)}
|
|
196
183
|
/>
|
|
197
184
|
)}
|
|
198
185
|
</div>
|
|
@@ -57,6 +57,27 @@ export interface MessageBubbleProps {
|
|
|
57
57
|
onRegenerate?: () => void;
|
|
58
58
|
onEdit?: () => void;
|
|
59
59
|
onDelete?: () => void;
|
|
60
|
+
/**
|
|
61
|
+
* Extra content rendered alongside the default copy/regenerate/edit/
|
|
62
|
+
* delete actions (after them, on the same row). Hosts pass app-
|
|
63
|
+
* specific affordances here — e.g. "Send to plan", "Add note",
|
|
64
|
+
* "Open in inspector" — without having to fork `<MessageActions>`.
|
|
65
|
+
* Receives the message so renderers can branch by id / role / etc.
|
|
66
|
+
* Plan64.
|
|
67
|
+
*/
|
|
68
|
+
messageActionsExtra?: (m: ChatMessage) => ReactNode;
|
|
69
|
+
/**
|
|
70
|
+
* Override the default streaming indicator (the dots / pulse + tool
|
|
71
|
+
* activity label). Receives the message so the host can read
|
|
72
|
+
* `toolActivity`, the running tool call name, etc., and render a
|
|
73
|
+
* richer affordance ("Running cmd_execute on vps-audi…"). Plan64.
|
|
74
|
+
*
|
|
75
|
+
* Renders in two slots: as the in-bubble pre-token affordance, and
|
|
76
|
+
* as the inline label above the bubble when `toolActivity` is set.
|
|
77
|
+
* The render-prop is called for both — branch on `m.content` if
|
|
78
|
+
* you need different behaviour per slot.
|
|
79
|
+
*/
|
|
80
|
+
streamingIndicator?: (m: ChatMessage) => ReactNode;
|
|
60
81
|
}
|
|
61
82
|
|
|
62
83
|
const MessageBubbleInner = ({
|
|
@@ -83,6 +104,8 @@ const MessageBubbleInner = ({
|
|
|
83
104
|
onRegenerate,
|
|
84
105
|
onEdit,
|
|
85
106
|
onDelete,
|
|
107
|
+
messageActionsExtra,
|
|
108
|
+
streamingIndicator,
|
|
86
109
|
}: MessageBubbleProps) => {
|
|
87
110
|
const isUser = isUserProp ?? message.role === 'user';
|
|
88
111
|
const isStreaming = !!message.isStreaming;
|
|
@@ -160,7 +183,9 @@ const MessageBubbleInner = ({
|
|
|
160
183
|
>
|
|
161
184
|
{isStreaming && message.toolActivity ? (
|
|
162
185
|
<div className="mb-1.5">
|
|
163
|
-
|
|
186
|
+
{streamingIndicator
|
|
187
|
+
? streamingIndicator(message)
|
|
188
|
+
: <StreamingIndicator label={message.toolActivity} />}
|
|
164
189
|
</div>
|
|
165
190
|
) : null}
|
|
166
191
|
|
|
@@ -172,7 +197,7 @@ const MessageBubbleInner = ({
|
|
|
172
197
|
plainText={isStreaming}
|
|
173
198
|
/>
|
|
174
199
|
) : (
|
|
175
|
-
<StreamingIndicator />
|
|
200
|
+
streamingIndicator ? streamingIndicator(message) : <StreamingIndicator />
|
|
176
201
|
)}
|
|
177
202
|
</div>
|
|
178
203
|
|
|
@@ -189,13 +214,16 @@ const MessageBubbleInner = ({
|
|
|
189
214
|
: null}
|
|
190
215
|
|
|
191
216
|
{showActions && !isStreaming ? (
|
|
192
|
-
<
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
217
|
+
<div className="flex items-center gap-0.5">
|
|
218
|
+
<MessageActions
|
|
219
|
+
role={message.role}
|
|
220
|
+
onCopy={onCopy}
|
|
221
|
+
onRegenerate={onRegenerate}
|
|
222
|
+
onEdit={onEdit}
|
|
223
|
+
onDelete={onDelete}
|
|
224
|
+
/>
|
|
225
|
+
{messageActionsExtra ? messageActionsExtra(message) : null}
|
|
226
|
+
</div>
|
|
199
227
|
) : null}
|
|
200
228
|
|
|
201
229
|
{showTimestamp ? (
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type ReactNode,
|
|
5
|
+
type RefObject,
|
|
6
|
+
forwardRef,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useImperativeHandle,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
|
4
14
|
|
|
5
15
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
16
|
import { Spinner } from '@djangocfg/ui-core/components';
|
|
17
|
+
import { useCopy } from '@djangocfg/ui-core/hooks';
|
|
7
18
|
|
|
8
19
|
import type { ChatMessage } from '../types';
|
|
9
20
|
import { useChatContextOptional } from '../context';
|
|
@@ -14,69 +25,234 @@ export interface MessageListProps {
|
|
|
14
25
|
renderItem?: (m: ChatMessage, i: number) => ReactNode;
|
|
15
26
|
renderEmpty?: () => ReactNode;
|
|
16
27
|
isLoadingMore?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Fires when the user scrolls within `topThresholdPx` of the top of
|
|
30
|
+
* the list — wire to your `loadMore()` here. Replaces the previous
|
|
31
|
+
* `topSentinelRef` API. The callback is gated by Virtuoso, so it
|
|
32
|
+
* won't fire repeatedly while a load is in flight (Virtuoso pauses
|
|
33
|
+
* `startReached` until `data` length grows).
|
|
34
|
+
*/
|
|
35
|
+
onStartReached?: () => void;
|
|
36
|
+
/**
|
|
37
|
+
* @deprecated Kept as a no-op for backwards compatibility — wire
|
|
38
|
+
* `onStartReached` instead. Virtuoso owns the scroll viewport now,
|
|
39
|
+
* external sentinels never see scroll events.
|
|
40
|
+
*/
|
|
17
41
|
topSentinelRef?: RefObject<HTMLDivElement | null>;
|
|
42
|
+
/**
|
|
43
|
+
* @deprecated Kept as a no-op for backwards compatibility — Virtuoso
|
|
44
|
+
* exposes `scrollToIndex` via the imperative handle instead. See
|
|
45
|
+
* `useChatScroll` for the chat-friendly wrapper.
|
|
46
|
+
*/
|
|
18
47
|
bottomRef?: RefObject<HTMLDivElement | null>;
|
|
19
48
|
className?: string;
|
|
20
49
|
itemClassName?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Skip virtualization and render through plain `.map()`. Use for
|
|
52
|
+
* stories / debugging where DevTools needs to see every node, or
|
|
53
|
+
* when `messages` is guaranteed-tiny. Default: `false` — virtualize
|
|
54
|
+
* always. Plan64.
|
|
55
|
+
*/
|
|
56
|
+
noVirtualize?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Initial item height estimate fed to Virtuoso's first-paint pass.
|
|
59
|
+
* The library re-measures every item after mount; this just tightens
|
|
60
|
+
* the initial scrollbar before measurements land. Default `120`.
|
|
61
|
+
*/
|
|
62
|
+
defaultItemHeight?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Fires when the viewport sticky state changes — `true` when the
|
|
65
|
+
* user is pinned to the bottom (streaming token deltas keep them
|
|
66
|
+
* there), `false` once they scroll up. Wire to your "Jump to
|
|
67
|
+
* latest" affordance via the inverse: render the pill when
|
|
68
|
+
* `!isAtBottom`. Plan64.
|
|
69
|
+
*/
|
|
70
|
+
onAtBottomChange?: (isAtBottom: boolean) => void;
|
|
21
71
|
}
|
|
22
72
|
|
|
23
|
-
export
|
|
73
|
+
export interface MessageListHandle {
|
|
74
|
+
scrollToBottom: (smooth?: boolean) => void;
|
|
75
|
+
scrollToIndex: (index: number, smooth?: boolean) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList(
|
|
24
79
|
{
|
|
25
80
|
messages: messagesProp,
|
|
26
81
|
renderItem,
|
|
27
82
|
renderEmpty,
|
|
28
83
|
isLoadingMore: isLoadingMoreProp,
|
|
29
|
-
|
|
30
|
-
bottomRef,
|
|
84
|
+
onStartReached,
|
|
31
85
|
className,
|
|
32
86
|
itemClassName,
|
|
87
|
+
noVirtualize = false,
|
|
88
|
+
defaultItemHeight = 120,
|
|
89
|
+
onAtBottomChange,
|
|
33
90
|
},
|
|
34
91
|
ref,
|
|
35
92
|
) {
|
|
36
93
|
const ctx = useChatContextOptional();
|
|
37
94
|
const messages = messagesProp ?? ctx?.messages ?? [];
|
|
38
95
|
const isLoadingMore = isLoadingMoreProp ?? ctx?.isLoadingMore ?? false;
|
|
96
|
+
const { copyToClipboard } = useCopy();
|
|
97
|
+
|
|
98
|
+
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
|
99
|
+
|
|
100
|
+
useImperativeHandle(
|
|
101
|
+
ref,
|
|
102
|
+
() => ({
|
|
103
|
+
scrollToBottom: (smooth = false) => {
|
|
104
|
+
virtuosoRef.current?.scrollToIndex({
|
|
105
|
+
index: 'LAST',
|
|
106
|
+
behavior: smooth ? 'smooth' : 'auto',
|
|
107
|
+
align: 'end',
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
scrollToIndex: (index, smooth = false) => {
|
|
111
|
+
virtuosoRef.current?.scrollToIndex({
|
|
112
|
+
index,
|
|
113
|
+
behavior: smooth ? 'smooth' : 'auto',
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
[],
|
|
118
|
+
);
|
|
39
119
|
|
|
40
120
|
const defaultRenderItem = useCallback(
|
|
41
121
|
(m: ChatMessage) => (
|
|
42
|
-
<div className={itemClassName}
|
|
122
|
+
<div className={itemClassName}>
|
|
43
123
|
<MessageBubble
|
|
44
124
|
message={m}
|
|
45
|
-
onCopy={() =>
|
|
125
|
+
onCopy={() => void copyToClipboard(m.content)}
|
|
46
126
|
onRegenerate={ctx ? () => void ctx.regenerate(m.id) : undefined}
|
|
47
127
|
onDelete={ctx ? () => ctx.deleteMessage(m.id) : undefined}
|
|
48
128
|
/>
|
|
49
129
|
</div>
|
|
50
130
|
),
|
|
51
|
-
[itemClassName, ctx],
|
|
131
|
+
[itemClassName, ctx, copyToClipboard],
|
|
52
132
|
);
|
|
53
133
|
|
|
54
134
|
const itemRenderer = renderItem ?? defaultRenderItem;
|
|
135
|
+
// Virtuoso may invoke `computeItemKey` for an index briefly out of
|
|
136
|
+
// sync with `data` during fast state churn (streaming chunks +
|
|
137
|
+
// Strict Mode double-mount). `m` arrives undefined in that window.
|
|
138
|
+
// Falling back to the index keeps the lookup stable instead of
|
|
139
|
+
// crashing with `undefined.id` — the next pass with the real item
|
|
140
|
+
// re-keys it correctly. See react-virtuoso issue #532 / #1045.
|
|
141
|
+
const computeItemKey = useCallback(
|
|
142
|
+
(index: number, m: ChatMessage | undefined) => m?.id ?? index,
|
|
143
|
+
[],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Wrap user-supplied onStartReached so we don't double-fire while a
|
|
147
|
+
// load is already in flight. Virtuoso pauses startReached until
|
|
148
|
+
// `data` grows, but our consumers pass `onStartReached={loadMore}`
|
|
149
|
+
// directly — `loadMore` sets `isLoadingMore=true` synchronously, so
|
|
150
|
+
// we can suppress further calls until that flag drops back to false.
|
|
151
|
+
const startReachedHandler = useMemo(() => {
|
|
152
|
+
if (!onStartReached) return undefined;
|
|
153
|
+
let inFlight = false;
|
|
154
|
+
return () => {
|
|
155
|
+
if (inFlight || isLoadingMore) return;
|
|
156
|
+
inFlight = true;
|
|
157
|
+
try {
|
|
158
|
+
onStartReached();
|
|
159
|
+
} finally {
|
|
160
|
+
// Release on the next tick — Virtuoso re-fires startReached
|
|
161
|
+
// only after data length changes, so the inFlight guard is
|
|
162
|
+
// belt+suspenders against same-frame double calls.
|
|
163
|
+
queueMicrotask(() => {
|
|
164
|
+
inFlight = false;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}, [onStartReached, isLoadingMore]);
|
|
169
|
+
|
|
170
|
+
// Empty path — render renderEmpty instead of the virtualizer to avoid
|
|
171
|
+
// a blank Virtuoso frame and the cost of mounting it for nothing.
|
|
172
|
+
if (messages.length === 0) {
|
|
173
|
+
return (
|
|
174
|
+
<div
|
|
175
|
+
role="log"
|
|
176
|
+
aria-live="polite"
|
|
177
|
+
aria-atomic="false"
|
|
178
|
+
className={cn('flex-1 overflow-y-auto', className)}
|
|
179
|
+
>
|
|
180
|
+
{renderEmpty?.() ?? null}
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (noVirtualize) {
|
|
186
|
+
return (
|
|
187
|
+
<div
|
|
188
|
+
role="log"
|
|
189
|
+
aria-live="polite"
|
|
190
|
+
aria-atomic="false"
|
|
191
|
+
className={cn('flex-1 overflow-y-auto', className)}
|
|
192
|
+
>
|
|
193
|
+
{isLoadingMore ? (
|
|
194
|
+
<div className="flex justify-center py-2">
|
|
195
|
+
<Spinner className="size-4 text-muted-foreground" />
|
|
196
|
+
</div>
|
|
197
|
+
) : null}
|
|
198
|
+
{messages.map((m, i) => (
|
|
199
|
+
<div key={m.id ?? i}>{itemRenderer(m, i)}</div>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
55
204
|
|
|
56
205
|
return (
|
|
57
|
-
<
|
|
58
|
-
ref={
|
|
206
|
+
<Virtuoso
|
|
207
|
+
ref={virtuosoRef}
|
|
59
208
|
role="log"
|
|
60
209
|
aria-live="polite"
|
|
61
210
|
aria-atomic="false"
|
|
62
|
-
className={cn('flex-1
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
{
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
211
|
+
className={cn('flex-1', className)}
|
|
212
|
+
data={messages}
|
|
213
|
+
computeItemKey={computeItemKey}
|
|
214
|
+
itemContent={(index, m) => (m ? itemRenderer(m, index) : null)}
|
|
215
|
+
defaultItemHeight={defaultItemHeight}
|
|
216
|
+
// Sticky-bottom: keep the viewport anchored to the latest message
|
|
217
|
+
// unless the user scrolled up. `'smooth'` while streaming would
|
|
218
|
+
// jank; Virtuoso defaults to `'auto'` which is what we want.
|
|
219
|
+
followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)}
|
|
220
|
+
atBottomStateChange={onAtBottomChange}
|
|
221
|
+
// Pad the list so a short conversation still hugs the bottom of
|
|
222
|
+
// the viewport (Telegram / iMessage feel) instead of stacking at
|
|
223
|
+
// the top.
|
|
224
|
+
alignToBottom
|
|
225
|
+
// Top-of-list pagination — fire when the topmost item enters the
|
|
226
|
+
// viewport. Virtuoso pauses this until `data` grows, so loadMore
|
|
227
|
+
// implementations don't need their own debounce.
|
|
228
|
+
startReached={startReachedHandler}
|
|
229
|
+
// Spinner while older history is loading. Rendering it as the
|
|
230
|
+
// Header keeps it inside the virtualized scroll, so it doesn't
|
|
231
|
+
// shift the viewport when it appears/disappears.
|
|
232
|
+
//
|
|
233
|
+
// Always pass an object — virtuoso indexes `components[name]`
|
|
234
|
+
// internally without a null-guard, so passing `undefined` here
|
|
235
|
+
// crashes with `d[l]` on any render where the empty-defaults
|
|
236
|
+
// path runs (regression: ui-tools 2.1.369 first ship).
|
|
237
|
+
components={
|
|
238
|
+
isLoadingMore
|
|
239
|
+
? {
|
|
240
|
+
Header: () => (
|
|
241
|
+
<div className="flex justify-center py-2">
|
|
242
|
+
<Spinner className="size-4 text-muted-foreground" />
|
|
243
|
+
</div>
|
|
244
|
+
),
|
|
245
|
+
}
|
|
246
|
+
: EMPTY_COMPONENTS
|
|
247
|
+
}
|
|
248
|
+
// Item height is dynamic; Virtuoso re-measures on resize. We
|
|
249
|
+
// bias the initial overscan a bit so streaming-token re-layout
|
|
250
|
+
// doesn't leave gaps before the next measurement lands.
|
|
251
|
+
increaseViewportBy={{ top: 200, bottom: 400 }}
|
|
252
|
+
/>
|
|
75
253
|
);
|
|
76
254
|
});
|
|
77
255
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
}
|
|
256
|
+
// Stable empty-components reference — passed when `isLoadingMore` is
|
|
257
|
+
// false so virtuoso's `components[name]` lookups never see undefined.
|
|
258
|
+
const EMPTY_COMPONENTS = {} as const;
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
export { ChatRoot, type ChatRootProps } from './ChatRoot';
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
MessageList,
|
|
6
|
+
type MessageListProps,
|
|
7
|
+
type MessageListHandle,
|
|
8
|
+
} from './MessageList';
|
|
5
9
|
export { MessageBubble, type MessageBubbleProps } from './MessageBubble';
|
|
6
10
|
export { MessageActions, type MessageActionsProps } from './MessageActions';
|
|
7
11
|
export { Composer, type ComposerProps } from './Composer';
|
|
@@ -40,6 +40,12 @@ export interface ChatProviderProps {
|
|
|
40
40
|
audio?: ChatAudioConfig;
|
|
41
41
|
/** Enable verbose dev logging via consola. Defaults to `isDev`. */
|
|
42
42
|
debug?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Rewrite outgoing message content before it hits the transport.
|
|
45
|
+
* History bubble keeps the original; useful for stripping rich-
|
|
46
|
+
* display chips so the LLM sees plain text. See `useChat`.
|
|
47
|
+
*/
|
|
48
|
+
onBeforeSend?: (content: string) => string | Promise<string>;
|
|
43
49
|
children?: ReactNode;
|
|
44
50
|
}
|
|
45
51
|
|
|
@@ -51,6 +57,7 @@ export function ChatProvider({
|
|
|
51
57
|
streaming,
|
|
52
58
|
audio,
|
|
53
59
|
debug,
|
|
60
|
+
onBeforeSend,
|
|
54
61
|
children,
|
|
55
62
|
}: ChatProviderProps) {
|
|
56
63
|
const audioApi = useChatAudio(audio ?? {});
|
|
@@ -80,6 +87,7 @@ export function ChatProvider({
|
|
|
80
87
|
onMessageEnd,
|
|
81
88
|
onStreamStart,
|
|
82
89
|
onError,
|
|
90
|
+
onBeforeSend,
|
|
83
91
|
});
|
|
84
92
|
const layout = useChatLayout({ defaultMode: 'embedded' });
|
|
85
93
|
|
|
@@ -37,6 +37,16 @@ export interface UseChatConfig {
|
|
|
37
37
|
metadata?: Record<string, unknown>;
|
|
38
38
|
/** Stamped on outgoing user messages as `message.sender`. */
|
|
39
39
|
userPersona?: ChatPersona;
|
|
40
|
+
/**
|
|
41
|
+
* Rewrite the outgoing message content right before it hits the
|
|
42
|
+
* transport — runs after the user bubble is added (so history shows
|
|
43
|
+
* the original) but before `transport.stream/send`. Sync or async.
|
|
44
|
+
* Return the original to opt out for that call.
|
|
45
|
+
*
|
|
46
|
+
* Use case: strip rich-display chips (e.g. mention links) so the LLM
|
|
47
|
+
* sees plain text, while the bubble keeps the chip rendering. Plan64.
|
|
48
|
+
*/
|
|
49
|
+
onBeforeSend?: (content: string) => string | Promise<string>;
|
|
40
50
|
/**
|
|
41
51
|
* Enable verbose dev-mode logging (consola, namespace `chat:*`).
|
|
42
52
|
* Defaults to `isDev` from `@djangocfg/ui-core/lib`. Pass `false` to silence
|
|
@@ -439,13 +449,24 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
439
449
|
dispatch({ type: 'MESSAGE_USER_ADD', message: userMsg });
|
|
440
450
|
config.onMessageSent?.(userMsg);
|
|
441
451
|
|
|
452
|
+
// History bubble shows the original; transport sees the rewrite.
|
|
453
|
+
// Use case: strip rich-display chips so the LLM sees plain text.
|
|
454
|
+
let outbound = content;
|
|
455
|
+
if (config.onBeforeSend) {
|
|
456
|
+
try {
|
|
457
|
+
outbound = await config.onBeforeSend(content);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
log.error.error('onBeforeSend threw — falling back to original content', err);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
442
463
|
if (streaming) {
|
|
443
|
-
await consumeStream(sessionId,
|
|
464
|
+
await consumeStream(sessionId, outbound, attachments);
|
|
444
465
|
} else {
|
|
445
|
-
await consumeBuffered(sessionId,
|
|
466
|
+
await consumeBuffered(sessionId, outbound, attachments);
|
|
446
467
|
}
|
|
447
468
|
},
|
|
448
|
-
[streaming, consumeStream, consumeBuffered, config, awaitSession],
|
|
469
|
+
[streaming, consumeStream, consumeBuffered, config, awaitSession, log],
|
|
449
470
|
);
|
|
450
471
|
|
|
451
472
|
const cancelStream = useCallback(() => {
|
|
@@ -24,6 +24,16 @@ export interface UseChatComposerOptions {
|
|
|
24
24
|
submitOn?: 'enter' | 'cmd+enter';
|
|
25
25
|
history?: { enabled?: boolean; size?: number };
|
|
26
26
|
onPasteFiles?: (files: File[]) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Persist the current draft to `sessionStorage` under this key. The
|
|
29
|
+
* draft is loaded once on mount (overrides `initialValue` if non-
|
|
30
|
+
* empty) and rewritten on every value change. Cleared on `reset()`.
|
|
31
|
+
*
|
|
32
|
+
* Pass a per-conversation id to keep separate drafts per chat. Pass
|
|
33
|
+
* `undefined` (default) to disable persistence — composer behaves
|
|
34
|
+
* exactly as before. Plan64.
|
|
35
|
+
*/
|
|
36
|
+
persistKey?: string;
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
export interface UseChatComposerReturn {
|
|
@@ -62,9 +72,37 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
|
|
|
62
72
|
submitOn = 'enter',
|
|
63
73
|
history = { enabled: true, size: LIMITS.composerHistorySize },
|
|
64
74
|
onPasteFiles,
|
|
75
|
+
persistKey,
|
|
65
76
|
} = options;
|
|
66
77
|
|
|
67
|
-
|
|
78
|
+
// Hydrate draft from sessionStorage on mount when a key is provided.
|
|
79
|
+
// We read once, lazily — switching `persistKey` mid-life (e.g.
|
|
80
|
+
// session change) requires a parent remount via React `key`, same
|
|
81
|
+
// pattern as `<MessageList>`. Avoids accidental cross-session
|
|
82
|
+
// bleed-through when the parent forgets to remount.
|
|
83
|
+
const initialFromStorage = (() => {
|
|
84
|
+
if (!persistKey || typeof window === 'undefined') return initialValue;
|
|
85
|
+
try {
|
|
86
|
+
const stored = window.sessionStorage.getItem(`chat:draft:${persistKey}`);
|
|
87
|
+
return stored && stored.length > 0 ? stored : initialValue;
|
|
88
|
+
} catch {
|
|
89
|
+
return initialValue;
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
const [value, setValueState] = useState(initialFromStorage);
|
|
93
|
+
|
|
94
|
+
// Persist on every value change. Throwaway swallow keeps quota /
|
|
95
|
+
// private-mode failures from breaking the composer.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!persistKey || typeof window === 'undefined') return;
|
|
98
|
+
try {
|
|
99
|
+
const k = `chat:draft:${persistKey}`;
|
|
100
|
+
if (value.length > 0) window.sessionStorage.setItem(k, value);
|
|
101
|
+
else window.sessionStorage.removeItem(k);
|
|
102
|
+
} catch {
|
|
103
|
+
/* noop — quota / disabled storage is non-fatal */
|
|
104
|
+
}
|
|
105
|
+
}, [value, persistKey]);
|
|
68
106
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
|
69
107
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
70
108
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|