@djangocfg/ui-tools 2.1.431 → 2.1.432
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 +28 -14
- package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
- package/src/common/CodePanelHeader/index.ts +1 -0
- package/src/common/blocks/BlockError.tsx +46 -0
- package/src/common/blocks/index.ts +34 -0
- package/src/common/blocks/schemas.ts +143 -0
- package/src/common/blocks/types.ts +76 -0
- package/src/common/chips/README.md +67 -0
- package/src/common/chips/UrlChip.tsx +105 -0
- package/src/common/chips/index.ts +20 -0
- package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
- package/src/common/link-preview/index.ts +12 -0
- package/src/common/link-preview/types.ts +44 -0
- package/src/common/tiptap/index.ts +10 -0
- package/src/common/tiptap/useNodeAttrs.ts +128 -0
- package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
- package/src/tools/chat/README.md +88 -2
- package/src/tools/chat/composer/Composer.tsx +45 -12
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
- package/src/tools/chat/constants.ts +9 -2
- package/src/tools/chat/core/extractFirstUrl.ts +45 -0
- package/src/tools/chat/core/reducer.ts +32 -8
- package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
- package/src/tools/chat/core/transport/mock.ts +4 -1
- package/src/tools/chat/hooks/useChat.ts +20 -20
- package/src/tools/chat/lazy.tsx +10 -0
- package/src/tools/chat/messages/Attachments.tsx +267 -42
- package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
- package/src/tools/chat/messages/MessageBubble.tsx +175 -31
- package/src/tools/chat/messages/MessageList.tsx +107 -21
- package/src/tools/chat/messages/ToolCalls.tsx +28 -13
- package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
- package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
- package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
- package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
- package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
- package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
- package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
- package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
- package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
- package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
- package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
- package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
- package/src/tools/chat/messages/index.ts +13 -0
- package/src/tools/chat/public.ts +5 -1
- package/src/tools/chat/shell/ChatRoot.tsx +29 -0
- package/src/tools/chat/styles/bubbleTokens.ts +18 -0
- package/src/tools/chat/styles/index.ts +1 -0
- package/src/tools/chat/types/attachment.ts +26 -2
- package/src/tools/chat/types/block.ts +56 -9
- package/src/tools/chat/types/blockSchemas.ts +197 -0
- package/src/tools/chat/types/config.ts +33 -0
- package/src/tools/chat/types/events.ts +14 -7
- package/src/tools/chat/types/index.ts +4 -2
- package/src/tools/chat/types/message.ts +7 -0
- package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
- package/src/tools/dev/Map/README.md +30 -345
- package/src/tools/dev/Map/cards/index.ts +6 -0
- package/src/tools/dev/Map/cards/types.ts +67 -0
- package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
- package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
- package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
- package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
- package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
- package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
- package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
- package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
- package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
- package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
- package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
- package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
- package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
- package/src/tools/dev/Map/components/MapControls.tsx +27 -0
- package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
- package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
- package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
- package/src/tools/dev/Map/components/index.ts +35 -1
- package/src/tools/dev/Map/geocode/index.ts +2 -0
- package/src/tools/dev/Map/geocode/photon.ts +265 -0
- package/src/tools/dev/Map/hooks/index.ts +37 -0
- package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
- package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
- package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
- package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
- package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
- package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
- package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
- package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
- package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
- package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
- package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
- package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
- package/src/tools/dev/Map/index.ts +59 -2
- package/src/tools/dev/Map/layers/index.ts +1 -0
- package/src/tools/dev/Map/layers/symbol.ts +93 -0
- package/src/tools/dev/Map/lazy.tsx +120 -0
- package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
- package/src/tools/dev/Map/styles/index.ts +62 -0
- package/src/tools/dev/Map/styles/terrain.ts +29 -0
- package/src/tools/dev/Map/types.ts +31 -3
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
- package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
- package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
- package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
- package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
- package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
- package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
- package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
- package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
- package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
- package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
- package/src/tools/forms/MarkdownEditor/README.md +84 -0
- package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
- package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
- package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
- package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
- package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
- package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
- package/src/tools/forms/MarkdownEditor/index.ts +19 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
- package/src/tools/forms/MarkdownEditor/styles.css +134 -2
- package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
- package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
- package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
- package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
- package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
- package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
- package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
- package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
- package/src/tools/forms/NotionEditor/README.md +109 -188
- package/src/tools/forms/NotionEditor/extensions.ts +15 -0
- package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
- package/src/tools/forms/NotionEditor/styles.css +25 -0
- package/src/tools/forms/NotionEditor/types.ts +9 -0
- package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
- package/src/tools/input/SpeechRecognition/types.ts +10 -0
- package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
- package/src/tools/media/ImageViewer/README.md +9 -0
- package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
- package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
- package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
- package/src/tools/media/ImageViewer/components/index.ts +2 -0
- package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
- package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
- package/src/tools/media/ImageViewer/types.ts +10 -0
- package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
- package/src/tools/media/ImageViewer/utils/index.ts +4 -0
- package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
- package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
- package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
- package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
- package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
- package/src/tools/visual/charts/SmoothLine/README.md +97 -0
- package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
- package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
- package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
- package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
- package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
- package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
- package/src/tools/visual/indicators/Fps/types.ts +1 -1
- package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
- package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { memo, type ReactNode } from 'react';
|
|
3
|
+
import { memo, useMemo, type ReactNode } from 'react';
|
|
4
4
|
|
|
5
5
|
import { Avatar, AvatarFallback, AvatarImage } from '@djangocfg/ui-core/components';
|
|
6
6
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
7
|
|
|
8
|
-
import { MarkdownMessage } from '../../dev/code/MarkdownMessage';
|
|
8
|
+
import { MarkdownMessage, urlChipRule, type LinkRule } from '../../dev/code/MarkdownMessage';
|
|
9
9
|
import type {
|
|
10
10
|
ChatAssistantContext,
|
|
11
11
|
ChatAttachment,
|
|
@@ -26,7 +26,15 @@ import {
|
|
|
26
26
|
type AttachmentRendererMap,
|
|
27
27
|
} from './Attachments';
|
|
28
28
|
import { MessageActions } from './MessageActions';
|
|
29
|
+
import {
|
|
30
|
+
BubbleContextMenu,
|
|
31
|
+
buildDefaultBubbleMenuItems,
|
|
32
|
+
useResolvedBubbleMenuItems,
|
|
33
|
+
type BubbleMenuItem,
|
|
34
|
+
} from './BubbleContextMenu';
|
|
29
35
|
import { MessageBlocks } from './blocks';
|
|
36
|
+
import { LinkPreviewCard } from '../../../common/link-preview';
|
|
37
|
+
import { extractFirstUrl } from '../core/extractFirstUrl';
|
|
30
38
|
import type { BlockRegistry } from './blocks';
|
|
31
39
|
import type { ToolCallsProps } from './ToolCalls';
|
|
32
40
|
import type { ComposerAppearance } from '../composer/types';
|
|
@@ -43,7 +51,32 @@ export interface MessageBubbleProps {
|
|
|
43
51
|
user?: ChatUserContext;
|
|
44
52
|
assistant?: ChatAssistantContext;
|
|
45
53
|
showTimestamp?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Render the legacy under-bubble action button row (copy / regenerate /
|
|
56
|
+
* edit / delete). Default `false` now — those actions live in the
|
|
57
|
+
* right-click / long-press context menu (see `bubbleMenuItems`). Set
|
|
58
|
+
* `true` to bring the hover button row back alongside the menu.
|
|
59
|
+
*/
|
|
46
60
|
showActions?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Disable the right-click / long-press context menu entirely (e.g. a
|
|
63
|
+
* read-only transcript). Default `false` — the menu is on.
|
|
64
|
+
*/
|
|
65
|
+
disableContextMenu?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Host-injected EXTRA context-menu items, merged after the built-in
|
|
68
|
+
* Copy / Regenerate / Delete defaults (a divider is inserted between).
|
|
69
|
+
* Each carries its own typed `onSelect(message)` callback — e.g.
|
|
70
|
+
* "Reveal in Finder", "Copy as markdown", "Inspect". Ignored when
|
|
71
|
+
* `getBubbleMenuItems` is set (that gives the host full control).
|
|
72
|
+
*/
|
|
73
|
+
bubbleMenuItems?: BubbleMenuItem[];
|
|
74
|
+
/**
|
|
75
|
+
* Full control over the bubble's context menu. Receives the message and
|
|
76
|
+
* the built-in default items so the host can keep / reorder / drop /
|
|
77
|
+
* extend them and return the final ordered list. Beats `bubbleMenuItems`.
|
|
78
|
+
*/
|
|
79
|
+
getBubbleMenuItems?: (message: ChatMessage, defaults: BubbleMenuItem[]) => BubbleMenuItem[];
|
|
47
80
|
isCompact?: boolean;
|
|
48
81
|
/** Spaciousness of the bubble — `full` scales padding, text, avatar up
|
|
49
82
|
* for full-page chat. `compact` (default) keeps the current geometry. */
|
|
@@ -114,14 +147,18 @@ const APPEARANCE_CLASSES: Record<ComposerAppearance, {
|
|
|
114
147
|
row: string;
|
|
115
148
|
avatar: string;
|
|
116
149
|
avatarFallback: string;
|
|
150
|
+
/** Max-width cap on the whole message column (text + blocks + media),
|
|
151
|
+
* so a single message never spans the full row — ChatGPT/Claude style. */
|
|
152
|
+
content: string;
|
|
117
153
|
bubble: string;
|
|
118
154
|
meta: string;
|
|
119
155
|
}> = {
|
|
120
|
-
compact: { row: '', avatar: '', avatarFallback: '', bubble: '', meta: '' },
|
|
156
|
+
compact: { row: '', avatar: '', avatarFallback: '', content: 'max-w-[34rem]', bubble: '', meta: '' },
|
|
121
157
|
full: {
|
|
122
158
|
row: 'gap-4',
|
|
123
159
|
avatar: 'size-10',
|
|
124
160
|
avatarFallback: 'text-sm',
|
|
161
|
+
content: 'max-w-[44rem]',
|
|
125
162
|
// Roomy ChatGPT-style bubble: larger text with generous line-height
|
|
126
163
|
// and padding so a full-page chat reads spacious, not just bigger.
|
|
127
164
|
bubble: 'rounded-3xl px-5 py-3.5 text-base leading-relaxed',
|
|
@@ -138,7 +175,10 @@ const MessageBubbleInner = ({
|
|
|
138
175
|
user,
|
|
139
176
|
assistant,
|
|
140
177
|
showTimestamp = false,
|
|
141
|
-
showActions =
|
|
178
|
+
showActions = false,
|
|
179
|
+
disableContextMenu = false,
|
|
180
|
+
bubbleMenuItems,
|
|
181
|
+
getBubbleMenuItems,
|
|
142
182
|
isCompact = false,
|
|
143
183
|
appearance = 'compact',
|
|
144
184
|
className,
|
|
@@ -171,6 +211,29 @@ const MessageBubbleInner = ({
|
|
|
171
211
|
// Prop beats context; `<MessageBlocks>` falls back to the built-in
|
|
172
212
|
// registry when both are absent.
|
|
173
213
|
const resolvedBlockRegistry = blockRegistry ?? ctx?.blockRegistry;
|
|
214
|
+
|
|
215
|
+
// Auto link-preview (Telegram-style): when the host opts in and provides a
|
|
216
|
+
// resolver, surface ONE preview card under the bubble for the first URL in
|
|
217
|
+
// the text. Suppressed while streaming (the text isn't final), for error
|
|
218
|
+
// turns, and when the message already carries an explicit `link` block (so
|
|
219
|
+
// we never double up).
|
|
220
|
+
const linkCfg = ctx?.config.linkPreview;
|
|
221
|
+
const autoPreviewUrl = useMemo(() => {
|
|
222
|
+
if (!linkCfg?.autoDetect || !linkCfg.resolve) return null;
|
|
223
|
+
if (isStreaming || isErr) return null;
|
|
224
|
+
if (message.blocks?.some((b) => b.kind === 'link')) return null;
|
|
225
|
+
return extractFirstUrl(message.content);
|
|
226
|
+
}, [linkCfg?.autoDetect, linkCfg?.resolve, isStreaming, isErr, message.blocks, message.content]);
|
|
227
|
+
|
|
228
|
+
// URL chips (plan22): when the host opts in via `config.linkChips`, render
|
|
229
|
+
// bare web URLs in the bubble as compact favicon chips (matching the
|
|
230
|
+
// composer). The chip rule only claims bare `http(s)` autolinks; labeled
|
|
231
|
+
// links keep their text. Appended last so any host rules would win.
|
|
232
|
+
const linkRules = useMemo<readonly LinkRule[] | undefined>(
|
|
233
|
+
() => (ctx?.config.linkChips ? [urlChipRule] : undefined),
|
|
234
|
+
[ctx?.config.linkChips],
|
|
235
|
+
);
|
|
236
|
+
|
|
174
237
|
const persona = resolvePersona(
|
|
175
238
|
message,
|
|
176
239
|
user ?? ctx?.config.user,
|
|
@@ -182,6 +245,33 @@ const MessageBubbleInner = ({
|
|
|
182
245
|
// full-page chat reads like ChatGPT/Gemini. `compact` = no changes.
|
|
183
246
|
const ap = APPEARANCE_CLASSES[appearance];
|
|
184
247
|
|
|
248
|
+
// Right-click / long-press menu. The built-in Copy / Regenerate /
|
|
249
|
+
// Delete items wire to the same handlers the old button row used;
|
|
250
|
+
// hosts merge extras via `bubbleMenuItems` or take full control via
|
|
251
|
+
// `getBubbleMenuItems`. Suppressed while streaming (no actions yet) and
|
|
252
|
+
// when the host opts out. Memoised so the menu doesn't rebuild every
|
|
253
|
+
// token delta.
|
|
254
|
+
const defaultMenuItems = useMemo(
|
|
255
|
+
() =>
|
|
256
|
+
buildDefaultBubbleMenuItems({
|
|
257
|
+
role: message.role,
|
|
258
|
+
onCopy,
|
|
259
|
+
onRegenerate,
|
|
260
|
+
// Edit — user bubbles only (gated inside the builder). Just fires
|
|
261
|
+
// the host trigger; the host loads the text into the composer.
|
|
262
|
+
onEdit,
|
|
263
|
+
onDelete,
|
|
264
|
+
}),
|
|
265
|
+
[message.role, onCopy, onRegenerate, onEdit, onDelete],
|
|
266
|
+
);
|
|
267
|
+
const menuItems = useResolvedBubbleMenuItems(
|
|
268
|
+
message,
|
|
269
|
+
defaultMenuItems,
|
|
270
|
+
bubbleMenuItems,
|
|
271
|
+
getBubbleMenuItems,
|
|
272
|
+
);
|
|
273
|
+
const menuEnabled = !disableContextMenu && !isStreaming && menuItems.length > 0;
|
|
274
|
+
|
|
185
275
|
return (
|
|
186
276
|
<div
|
|
187
277
|
role="article"
|
|
@@ -189,7 +279,17 @@ const MessageBubbleInner = ({
|
|
|
189
279
|
aria-busy={isStreaming || undefined}
|
|
190
280
|
data-role={message.role}
|
|
191
281
|
className={cn(
|
|
192
|
-
|
|
282
|
+
// Selection containment (plan138 follow-up). The whole row —
|
|
283
|
+
// and EVERY wrapper down to the bubble — is `select-none`; only
|
|
284
|
+
// the innermost prose element (`<MarkdownMessage>`) re-enables
|
|
285
|
+
// `select-text`. Putting `select-text` on the bubble DIV (the
|
|
286
|
+
// old approach) made the bubble's own padding selectable, so a
|
|
287
|
+
// drag could ride out of one bubble's text and grab the next
|
|
288
|
+
// bubble's top-padding strip — the two bubbles were one
|
|
289
|
+
// contiguous selectable region. With the text island as the
|
|
290
|
+
// ONLY selectable node, the range can't bridge the
|
|
291
|
+
// non-selectable padding/gap between two messages.
|
|
292
|
+
'group/msg flex select-none gap-2.5 px-2.5 py-2',
|
|
193
293
|
ap.row,
|
|
194
294
|
isUser ? 'flex-row-reverse' : 'flex-row',
|
|
195
295
|
className,
|
|
@@ -209,7 +309,13 @@ const MessageBubbleInner = ({
|
|
|
209
309
|
</Avatar>
|
|
210
310
|
) : null}
|
|
211
311
|
|
|
212
|
-
<div
|
|
312
|
+
<div
|
|
313
|
+
className={cn(
|
|
314
|
+
'min-w-0 flex-1',
|
|
315
|
+
ap.content,
|
|
316
|
+
isUser && 'flex flex-col items-end',
|
|
317
|
+
)}
|
|
318
|
+
>
|
|
213
319
|
{beforeContent}
|
|
214
320
|
{message.attachments?.length
|
|
215
321
|
? attachmentsRenderer
|
|
@@ -234,32 +340,53 @@ const MessageBubbleInner = ({
|
|
|
234
340
|
)
|
|
235
341
|
: null}
|
|
236
342
|
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
)}
|
|
343
|
+
<BubbleContextMenu
|
|
344
|
+
message={message}
|
|
345
|
+
items={menuEnabled ? menuItems : []}
|
|
346
|
+
// `inline-block` so the trigger hugs the bubble (right-click +
|
|
347
|
+
// long-press land on the bubble, not the full-width column).
|
|
348
|
+
className={cn('inline-block max-w-full', isUser && 'self-end')}
|
|
243
349
|
>
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
350
|
+
<div
|
|
351
|
+
className={cn(
|
|
352
|
+
// Bubble surface stays `select-none` (inherited from the
|
|
353
|
+
// row) — its padding is NOT a selectable region, so a drag
|
|
354
|
+
// can't extend from this bubble's text into the next
|
|
355
|
+
// bubble's padding. `<MarkdownMessage>` inside re-enables
|
|
356
|
+
// `select-text` on the prose itself: the text is the only
|
|
357
|
+
// selection island, bounded to this one message.
|
|
358
|
+
'inline-block max-w-full rounded-2xl px-3.5 py-2 text-sm',
|
|
359
|
+
ap.bubble,
|
|
360
|
+
bubbleSurface,
|
|
361
|
+
)}
|
|
362
|
+
>
|
|
363
|
+
{isStreaming && message.toolActivity ? (
|
|
364
|
+
<div className="mb-1.5">
|
|
365
|
+
{streamingIndicator
|
|
366
|
+
? streamingIndicator(message)
|
|
367
|
+
: <StreamingIndicator label={message.toolActivity} />}
|
|
368
|
+
</div>
|
|
369
|
+
) : null}
|
|
251
370
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
371
|
+
{message.content || !isStreaming ? (
|
|
372
|
+
<MarkdownMessage
|
|
373
|
+
content={message.content || (isErr ? '*Failed to generate a response.*' : '')}
|
|
374
|
+
isUser={isUser}
|
|
375
|
+
size={appearance === 'full' ? 'base' : isCompact ? 'xs' : 'sm'}
|
|
376
|
+
// Render markdown LIVE during streaming (formatted as
|
|
377
|
+
// tokens arrive) instead of forcing the raw plain-text
|
|
378
|
+
// branch. `streaming` enables block-split + per-block
|
|
379
|
+
// memoization (O(n), not O(n²)) and boundary-buffers the
|
|
380
|
+
// in-flight trailing block so half-written syntax doesn't
|
|
381
|
+
// flash. The same sanitized pipeline runs in both phases.
|
|
382
|
+
streaming={isStreaming}
|
|
383
|
+
linkRules={linkRules}
|
|
384
|
+
/>
|
|
385
|
+
) : (
|
|
386
|
+
streamingIndicator ? streamingIndicator(message) : <StreamingIndicator />
|
|
387
|
+
)}
|
|
388
|
+
</div>
|
|
389
|
+
</BubbleContextMenu>
|
|
263
390
|
|
|
264
391
|
{message.blocks?.length ? (
|
|
265
392
|
<MessageBlocks
|
|
@@ -270,6 +397,16 @@ const MessageBubbleInner = ({
|
|
|
270
397
|
/>
|
|
271
398
|
) : null}
|
|
272
399
|
|
|
400
|
+
{autoPreviewUrl && linkCfg?.resolve ? (
|
|
401
|
+
<div className={cn('mt-1.5 w-full', isUser && 'flex justify-end')}>
|
|
402
|
+
<LinkPreviewCard
|
|
403
|
+
url={autoPreviewUrl}
|
|
404
|
+
resolver={linkCfg.resolve}
|
|
405
|
+
appearance={appearance}
|
|
406
|
+
/>
|
|
407
|
+
</div>
|
|
408
|
+
) : null}
|
|
409
|
+
|
|
273
410
|
{message.toolCalls?.length
|
|
274
411
|
? toolCallsRenderer
|
|
275
412
|
? toolCallsRenderer(message.toolCalls)
|
|
@@ -322,7 +459,14 @@ export const MessageBubble = memo(MessageBubbleInner, (prev, next) => {
|
|
|
322
459
|
a.toolCalls === b.toolCalls &&
|
|
323
460
|
a.sources === b.sources &&
|
|
324
461
|
a.attachments === b.attachments &&
|
|
325
|
-
a.blocks === b.blocks
|
|
462
|
+
a.blocks === b.blocks &&
|
|
463
|
+
// Context-menu config — handlers/extras are expected to be stable
|
|
464
|
+
// (useCallback / module const), so a reference change means the host
|
|
465
|
+
// wants a different menu and we must re-render.
|
|
466
|
+
prev.bubbleMenuItems === next.bubbleMenuItems &&
|
|
467
|
+
prev.getBubbleMenuItems === next.getBubbleMenuItems &&
|
|
468
|
+
prev.disableContextMenu === next.disableContextMenu &&
|
|
469
|
+
prev.showActions === next.showActions
|
|
326
470
|
);
|
|
327
471
|
});
|
|
328
472
|
MessageBubble.displayName = 'MessageBubble';
|
|
@@ -76,6 +76,19 @@ export interface MessageListHandle {
|
|
|
76
76
|
scrollToIndex: (index: number, smooth?: boolean) => void;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Breathing room under the last bubble: a spacer rendered as the
|
|
80
|
+
// Virtuoso `Footer` keeps the final message from sitting flush against
|
|
81
|
+
// the composer, and the same value is fed as a scroll `offset` so every
|
|
82
|
+
// "land at bottom" path settles with this gap visible instead of pinning
|
|
83
|
+
// the last bubble to the very edge. Tasteful — one comfortable notch.
|
|
84
|
+
const BOTTOM_GAP_PX = 24;
|
|
85
|
+
|
|
86
|
+
// Stable Footer spacer — reserves `BOTTOM_GAP_PX` of scrollable space
|
|
87
|
+
// below the last bubble. Rendered inside the virtualized scroller so it
|
|
88
|
+
// counts toward content height (followOutput scrolls past it) without
|
|
89
|
+
// shifting the viewport when items mount/unmount.
|
|
90
|
+
const FooterSpacer = () => <div aria-hidden style={{ height: BOTTOM_GAP_PX }} />;
|
|
91
|
+
|
|
79
92
|
export const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList(
|
|
80
93
|
{
|
|
81
94
|
messages: messagesProp,
|
|
@@ -107,14 +120,36 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
107
120
|
const scrollerRef = useRef<HTMLElement | Window | null>(null);
|
|
108
121
|
const [isScrollable, setIsScrollable] = useState(false);
|
|
109
122
|
const lastReportedAtBottomRef = useRef<boolean | null>(null);
|
|
123
|
+
// Mirror of the latest "is the user following" state, readable from
|
|
124
|
+
// the last-item ResizeObserver without re-subscribing it on every
|
|
125
|
+
// sticky-state flip. This is the ONLY gate for sticky-bottom: we
|
|
126
|
+
// follow because the user is at/near bottom, never because of message
|
|
127
|
+
// length. The moment they scroll up, this flips false and we stop
|
|
128
|
+
// re-pinning (the <JumpToLatest> pill takes over).
|
|
129
|
+
const isFollowingRef = useRef(true);
|
|
110
130
|
const reportAtBottom = useCallback(
|
|
111
131
|
(value: boolean) => {
|
|
132
|
+
isFollowingRef.current = value;
|
|
112
133
|
if (lastReportedAtBottomRef.current === value) return;
|
|
113
134
|
lastReportedAtBottomRef.current = value;
|
|
114
135
|
onAtBottomChange?.(value);
|
|
115
136
|
},
|
|
116
137
|
[onAtBottomChange],
|
|
117
138
|
);
|
|
139
|
+
// Pin the viewport to the absolute bottom. Used both by the last-item
|
|
140
|
+
// ResizeObserver (to correct virtuoso's `followOutput:'auto'`, which
|
|
141
|
+
// settles a few px short on the final streamed chunk) and any other
|
|
142
|
+
// "land exactly at end" path.
|
|
143
|
+
const pinToBottom = useCallback((smooth = false) => {
|
|
144
|
+
virtuosoRef.current?.scrollToIndex({
|
|
145
|
+
index: 'LAST',
|
|
146
|
+
align: 'end',
|
|
147
|
+
// Scroll past the last bubble by the footer gap so it lands with
|
|
148
|
+
// breathing room above the composer, not flush against the edge.
|
|
149
|
+
offset: BOTTOM_GAP_PX,
|
|
150
|
+
behavior: smooth ? 'smooth' : 'auto',
|
|
151
|
+
});
|
|
152
|
+
}, []);
|
|
118
153
|
// Track whether we've already landed on the bottom for the initial
|
|
119
154
|
// history. Virtuoso's `initialTopMostItemIndex` only fires on first
|
|
120
155
|
// mount and uses the `messages` length at that moment. Chats almost
|
|
@@ -128,11 +163,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
128
163
|
ref,
|
|
129
164
|
() => ({
|
|
130
165
|
scrollToBottom: (smooth = false) => {
|
|
131
|
-
|
|
132
|
-
index: 'LAST',
|
|
133
|
-
behavior: smooth ? 'smooth' : 'auto',
|
|
134
|
-
align: 'end',
|
|
135
|
-
});
|
|
166
|
+
pinToBottom(smooth);
|
|
136
167
|
},
|
|
137
168
|
scrollToIndex: (index, smooth = false) => {
|
|
138
169
|
virtuosoRef.current?.scrollToIndex({
|
|
@@ -141,7 +172,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
141
172
|
});
|
|
142
173
|
},
|
|
143
174
|
}),
|
|
144
|
-
[],
|
|
175
|
+
[pinToBottom],
|
|
145
176
|
);
|
|
146
177
|
|
|
147
178
|
const defaultRenderItem = useCallback(
|
|
@@ -172,6 +203,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
172
203
|
virtuosoRef.current?.scrollToIndex({
|
|
173
204
|
index: 'LAST',
|
|
174
205
|
align: 'end',
|
|
206
|
+
offset: BOTTOM_GAP_PX,
|
|
175
207
|
behavior: 'auto',
|
|
176
208
|
});
|
|
177
209
|
});
|
|
@@ -194,6 +226,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
194
226
|
virtuosoRef.current?.scrollToIndex({
|
|
195
227
|
index: 'LAST',
|
|
196
228
|
align: 'end',
|
|
229
|
+
offset: BOTTOM_GAP_PX,
|
|
197
230
|
behavior: 'smooth',
|
|
198
231
|
});
|
|
199
232
|
});
|
|
@@ -204,6 +237,43 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
204
237
|
};
|
|
205
238
|
}, [scrollAnchorId]);
|
|
206
239
|
|
|
240
|
+
// Sticky-bottom corrector. Virtuoso's `followOutput:'auto'` keeps the
|
|
241
|
+
// list near the bottom while content grows token-by-token, but on the
|
|
242
|
+
// FINAL streamed chunk (and any late reflow — images loading, code
|
|
243
|
+
// blocks measuring) it can settle a few px short: no further scroll
|
|
244
|
+
// event arrives to nudge it the last stretch, so a short reply lands
|
|
245
|
+
// slightly under-scrolled. We observe ONLY the last bubble's size and,
|
|
246
|
+
// while the user is following (`isFollowingRef`), re-pin to the
|
|
247
|
+
// absolute bottom on every size change. Cheap: one observer, last item
|
|
248
|
+
// only, no work once the user scrolls up.
|
|
249
|
+
const lastItemElRef = useRef<HTMLElement | null>(null);
|
|
250
|
+
const lastItemRoRef = useRef<ResizeObserver | null>(null);
|
|
251
|
+
const observeLastItem = useCallback(
|
|
252
|
+
(el: HTMLElement | null) => {
|
|
253
|
+
if (el === lastItemElRef.current) return;
|
|
254
|
+
lastItemRoRef.current?.disconnect();
|
|
255
|
+
lastItemElRef.current = el;
|
|
256
|
+
if (!el) return;
|
|
257
|
+
const ro = new ResizeObserver(() => {
|
|
258
|
+
// Only stick when the user hasn't scrolled away. This is the
|
|
259
|
+
// universal ChatGPT rule — follow = user at bottom, regardless
|
|
260
|
+
// of how long the message is.
|
|
261
|
+
if (isFollowingRef.current) pinToBottom(false);
|
|
262
|
+
});
|
|
263
|
+
ro.observe(el);
|
|
264
|
+
lastItemRoRef.current = ro;
|
|
265
|
+
},
|
|
266
|
+
[pinToBottom],
|
|
267
|
+
);
|
|
268
|
+
useEffect(
|
|
269
|
+
() => () => {
|
|
270
|
+
lastItemRoRef.current?.disconnect();
|
|
271
|
+
lastItemRoRef.current = null;
|
|
272
|
+
lastItemElRef.current = null;
|
|
273
|
+
},
|
|
274
|
+
[],
|
|
275
|
+
);
|
|
276
|
+
|
|
207
277
|
// Watch the scroll container: when content fits (scrollHeight ≤ clientHeight)
|
|
208
278
|
// there's nothing to scroll, the user is by definition "at bottom", and the
|
|
209
279
|
// Jump-to-latest pill must stay hidden regardless of what virtuoso reports.
|
|
@@ -289,6 +359,9 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
289
359
|
{messages.map((m, i) => (
|
|
290
360
|
<div key={m.id ?? i}>{itemRenderer(m, i)}</div>
|
|
291
361
|
))}
|
|
362
|
+
{/* Mirror the virtualized Footer spacer so the last bubble keeps
|
|
363
|
+
its breathing room above the composer in the non-virtual path. */}
|
|
364
|
+
<FooterSpacer />
|
|
292
365
|
</div>
|
|
293
366
|
);
|
|
294
367
|
}
|
|
@@ -302,7 +375,18 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
302
375
|
className={cn('flex-1', className)}
|
|
303
376
|
data={messages}
|
|
304
377
|
computeItemKey={computeItemKey}
|
|
305
|
-
itemContent={(index, m) =>
|
|
378
|
+
itemContent={(index, m) => {
|
|
379
|
+
if (!m) return null;
|
|
380
|
+
const node = itemRenderer(m, index);
|
|
381
|
+
// Wrap only the last bubble in a size-observed shell so we can
|
|
382
|
+
// re-pin to the absolute bottom as the streaming reply grows
|
|
383
|
+
// (corrects virtuoso's slight final-chunk under-scroll). The
|
|
384
|
+
// wrapper is `display:contents` so it adds no layout box.
|
|
385
|
+
if (index === messages.length - 1) {
|
|
386
|
+
return <div ref={observeLastItem}>{node}</div>;
|
|
387
|
+
}
|
|
388
|
+
return node;
|
|
389
|
+
}}
|
|
306
390
|
// No `defaultItemHeight` — Virtuoso uses its first-item probe
|
|
307
391
|
// pass. We previously fed it `120` which was wildly low for
|
|
308
392
|
// chat bubbles (markdown + code blocks reach 500px+) and forced
|
|
@@ -345,21 +429,23 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
345
429
|
// internally without a null-guard, so passing `undefined` here
|
|
346
430
|
// crashes with `d[l]` on any render where the empty-defaults
|
|
347
431
|
// path runs (regression: ui-tools 2.1.369 first ship).
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
<div className="flex justify-center py-2">
|
|
353
|
-
<Spinner className="size-4 text-muted-foreground" />
|
|
354
|
-
</div>
|
|
355
|
-
),
|
|
356
|
-
}
|
|
357
|
-
: EMPTY_COMPONENTS
|
|
358
|
-
}
|
|
432
|
+
// Footer always carries the bottom-gap spacer so the last bubble
|
|
433
|
+
// never sits flush against the composer; Header only appears while
|
|
434
|
+
// older history is loading.
|
|
435
|
+
components={isLoadingMore ? COMPONENTS_WITH_HEADER : FOOTER_ONLY_COMPONENTS}
|
|
359
436
|
/>
|
|
360
437
|
);
|
|
361
438
|
});
|
|
362
439
|
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
440
|
+
// Header spinner for the loading-older-history state.
|
|
441
|
+
const LoadingHeader = () => (
|
|
442
|
+
<div className="flex justify-center py-2">
|
|
443
|
+
<Spinner className="size-4 text-muted-foreground" />
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
// Stable component maps — virtuoso indexes `components[name]` without a
|
|
448
|
+
// null-guard, so we always pass a concrete object. Both carry the
|
|
449
|
+
// FooterSpacer; the loading variant adds the history spinner Header.
|
|
450
|
+
const FOOTER_ONLY_COMPONENTS = { Footer: FooterSpacer } as const;
|
|
451
|
+
const COMPONENTS_WITH_HEADER = { Header: LoadingHeader, Footer: FooterSpacer } as const;
|
|
@@ -125,14 +125,23 @@ const ToolCallItem = memo(function ToolCallItem({
|
|
|
125
125
|
};
|
|
126
126
|
|
|
127
127
|
const Icon = open ? ChevronDown : ChevronRight;
|
|
128
|
-
|
|
128
|
+
// Status maps to a small dot color (success/error/cancelled/running). The
|
|
129
|
+
// word itself is shown only for non-success states to keep the row quiet
|
|
130
|
+
// — a green dot already reads as "ok".
|
|
131
|
+
const statusDot =
|
|
129
132
|
call.status === 'success'
|
|
130
|
-
? '
|
|
133
|
+
? 'bg-success'
|
|
131
134
|
: call.status === 'error'
|
|
132
|
-
? '
|
|
135
|
+
? 'bg-destructive'
|
|
133
136
|
: call.status === 'cancelled'
|
|
134
|
-
? '
|
|
135
|
-
: '
|
|
137
|
+
? 'bg-muted-foreground'
|
|
138
|
+
: 'bg-warning';
|
|
139
|
+
const statusText =
|
|
140
|
+
call.status === 'error'
|
|
141
|
+
? 'text-destructive'
|
|
142
|
+
: call.status === 'cancelled'
|
|
143
|
+
? 'text-muted-foreground'
|
|
144
|
+
: 'text-muted-foreground';
|
|
136
145
|
|
|
137
146
|
const renderValue = (value: unknown, kind: ToolPayloadKind): ReactNode => {
|
|
138
147
|
if (kind === 'input' && renderInput) return renderInput(value, call);
|
|
@@ -143,25 +152,31 @@ const ToolCallItem = memo(function ToolCallItem({
|
|
|
143
152
|
return <DefaultPayload value={value} kind={kind} />;
|
|
144
153
|
};
|
|
145
154
|
|
|
155
|
+
// A non-success terminal status still shows its word (errors must read);
|
|
156
|
+
// success/running stay quiet (dot only).
|
|
157
|
+
const showStatusWord = call.status === 'error' || call.status === 'cancelled';
|
|
158
|
+
|
|
146
159
|
return (
|
|
147
|
-
<div className="overflow-hidden rounded-
|
|
160
|
+
<div className="overflow-hidden rounded-xl border border-border/60 bg-muted/20">
|
|
148
161
|
<button
|
|
149
162
|
type="button"
|
|
150
163
|
onClick={handleToggle}
|
|
151
164
|
aria-expanded={open}
|
|
152
|
-
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-muted/
|
|
165
|
+
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-muted/40"
|
|
153
166
|
>
|
|
154
|
-
<Icon aria-hidden className="size-3 shrink-0 text-muted-foreground" />
|
|
155
167
|
{isRunning ? (
|
|
156
|
-
<Loader2 aria-hidden className="size-3 shrink-0 animate-spin text-
|
|
168
|
+
<Loader2 aria-hidden className="size-3 shrink-0 animate-spin text-warning" />
|
|
157
169
|
) : (
|
|
158
|
-
<span className={cn('size-
|
|
170
|
+
<span className={cn('size-1.5 shrink-0 rounded-full', statusDot)} />
|
|
159
171
|
)}
|
|
160
|
-
<span className="font-mono text-foreground">{call.name}</span>
|
|
161
|
-
|
|
172
|
+
<span className="truncate font-mono text-foreground/90">{call.name}</span>
|
|
173
|
+
{showStatusWord ? (
|
|
174
|
+
<span className={cn('shrink-0', statusText)}>{call.status}</span>
|
|
175
|
+
) : null}
|
|
176
|
+
<Icon aria-hidden className="ml-auto size-3 shrink-0 opacity-60" />
|
|
162
177
|
</button>
|
|
163
178
|
{open ? (
|
|
164
|
-
<div className="space-y-1 border-t border-border px-2 py-1.5 text-[11px]">
|
|
179
|
+
<div className="space-y-1 border-t border-border/60 px-2.5 py-1.5 text-[11px]">
|
|
165
180
|
{call.input != null ? renderValue(call.input, 'input') : null}
|
|
166
181
|
{call.streamingText != null
|
|
167
182
|
? renderValue(call.streamingText, 'streaming')
|