@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.
Files changed (171) hide show
  1. package/package.json +28 -14
  2. package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
  3. package/src/common/CodePanelHeader/index.ts +1 -0
  4. package/src/common/blocks/BlockError.tsx +46 -0
  5. package/src/common/blocks/index.ts +34 -0
  6. package/src/common/blocks/schemas.ts +143 -0
  7. package/src/common/blocks/types.ts +76 -0
  8. package/src/common/chips/README.md +67 -0
  9. package/src/common/chips/UrlChip.tsx +105 -0
  10. package/src/common/chips/index.ts +20 -0
  11. package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
  12. package/src/common/link-preview/index.ts +12 -0
  13. package/src/common/link-preview/types.ts +44 -0
  14. package/src/common/tiptap/index.ts +10 -0
  15. package/src/common/tiptap/useNodeAttrs.ts +128 -0
  16. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
  17. package/src/tools/chat/README.md +88 -2
  18. package/src/tools/chat/composer/Composer.tsx +45 -12
  19. package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
  20. package/src/tools/chat/constants.ts +9 -2
  21. package/src/tools/chat/core/extractFirstUrl.ts +45 -0
  22. package/src/tools/chat/core/reducer.ts +32 -8
  23. package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
  24. package/src/tools/chat/core/transport/mock.ts +4 -1
  25. package/src/tools/chat/hooks/useChat.ts +20 -20
  26. package/src/tools/chat/lazy.tsx +10 -0
  27. package/src/tools/chat/messages/Attachments.tsx +267 -42
  28. package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
  29. package/src/tools/chat/messages/MessageBubble.tsx +175 -31
  30. package/src/tools/chat/messages/MessageList.tsx +107 -21
  31. package/src/tools/chat/messages/ToolCalls.tsx +28 -13
  32. package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
  33. package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
  34. package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
  35. package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
  36. package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
  37. package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
  38. package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
  39. package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
  40. package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
  41. package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
  42. package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
  43. package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
  44. package/src/tools/chat/messages/index.ts +13 -0
  45. package/src/tools/chat/public.ts +5 -1
  46. package/src/tools/chat/shell/ChatRoot.tsx +29 -0
  47. package/src/tools/chat/styles/bubbleTokens.ts +18 -0
  48. package/src/tools/chat/styles/index.ts +1 -0
  49. package/src/tools/chat/types/attachment.ts +26 -2
  50. package/src/tools/chat/types/block.ts +56 -9
  51. package/src/tools/chat/types/blockSchemas.ts +197 -0
  52. package/src/tools/chat/types/config.ts +33 -0
  53. package/src/tools/chat/types/events.ts +14 -7
  54. package/src/tools/chat/types/index.ts +4 -2
  55. package/src/tools/chat/types/message.ts +7 -0
  56. package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
  57. package/src/tools/dev/Map/README.md +30 -345
  58. package/src/tools/dev/Map/cards/index.ts +6 -0
  59. package/src/tools/dev/Map/cards/types.ts +67 -0
  60. package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
  61. package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
  62. package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
  63. package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
  64. package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
  65. package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
  66. package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
  67. package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
  68. package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
  69. package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
  70. package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
  71. package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
  72. package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
  73. package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
  74. package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
  75. package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
  76. package/src/tools/dev/Map/components/MapControls.tsx +27 -0
  77. package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
  78. package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
  79. package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
  80. package/src/tools/dev/Map/components/index.ts +35 -1
  81. package/src/tools/dev/Map/geocode/index.ts +2 -0
  82. package/src/tools/dev/Map/geocode/photon.ts +265 -0
  83. package/src/tools/dev/Map/hooks/index.ts +37 -0
  84. package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
  85. package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
  86. package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
  87. package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
  88. package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
  89. package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
  90. package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
  91. package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
  92. package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
  93. package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
  94. package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
  95. package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
  96. package/src/tools/dev/Map/index.ts +59 -2
  97. package/src/tools/dev/Map/layers/index.ts +1 -0
  98. package/src/tools/dev/Map/layers/symbol.ts +93 -0
  99. package/src/tools/dev/Map/lazy.tsx +120 -0
  100. package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
  101. package/src/tools/dev/Map/styles/index.ts +62 -0
  102. package/src/tools/dev/Map/styles/terrain.ts +29 -0
  103. package/src/tools/dev/Map/types.ts +31 -3
  104. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
  105. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
  106. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
  107. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
  108. package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
  109. package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
  110. package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
  111. package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
  112. package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
  113. package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
  114. package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
  115. package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
  116. package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
  117. package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
  118. package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
  119. package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
  120. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
  121. package/src/tools/forms/MarkdownEditor/README.md +84 -0
  122. package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
  123. package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
  124. package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
  125. package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
  126. package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
  127. package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
  128. package/src/tools/forms/MarkdownEditor/index.ts +19 -0
  129. package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
  130. package/src/tools/forms/MarkdownEditor/styles.css +134 -2
  131. package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
  132. package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
  133. package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
  134. package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
  135. package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
  136. package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
  137. package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
  138. package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
  139. package/src/tools/forms/NotionEditor/README.md +109 -188
  140. package/src/tools/forms/NotionEditor/extensions.ts +15 -0
  141. package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
  142. package/src/tools/forms/NotionEditor/styles.css +25 -0
  143. package/src/tools/forms/NotionEditor/types.ts +9 -0
  144. package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
  145. package/src/tools/input/SpeechRecognition/types.ts +10 -0
  146. package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
  147. package/src/tools/media/ImageViewer/README.md +9 -0
  148. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
  149. package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
  150. package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
  151. package/src/tools/media/ImageViewer/components/index.ts +2 -0
  152. package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
  153. package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
  154. package/src/tools/media/ImageViewer/types.ts +10 -0
  155. package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
  156. package/src/tools/media/ImageViewer/utils/index.ts +4 -0
  157. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
  158. package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
  159. package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
  160. package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
  161. package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
  162. package/src/tools/visual/charts/SmoothLine/README.md +97 -0
  163. package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
  164. package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
  165. package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
  166. package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
  167. package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
  168. package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
  169. package/src/tools/visual/indicators/Fps/types.ts +1 -1
  170. package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
  171. 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 = true,
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
- 'group/msg flex gap-2.5 px-2.5 py-2',
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 className={cn('min-w-0 flex-1', isUser && 'flex flex-col items-end')}>
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
- <div
238
- className={cn(
239
- 'inline-block max-w-full rounded-2xl px-3.5 py-2 text-sm',
240
- ap.bubble,
241
- bubbleSurface,
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
- {isStreaming && message.toolActivity ? (
245
- <div className="mb-1.5">
246
- {streamingIndicator
247
- ? streamingIndicator(message)
248
- : <StreamingIndicator label={message.toolActivity} />}
249
- </div>
250
- ) : null}
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
- {message.content || !isStreaming ? (
253
- <MarkdownMessage
254
- content={message.content || (isErr ? '*Failed to generate a response.*' : '')}
255
- isUser={isUser}
256
- size={appearance === 'full' ? 'base' : isCompact ? 'xs' : 'sm'}
257
- plainText={isStreaming}
258
- />
259
- ) : (
260
- streamingIndicator ? streamingIndicator(message) : <StreamingIndicator />
261
- )}
262
- </div>
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
- virtuosoRef.current?.scrollToIndex({
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) => (m ? itemRenderer(m, index) : null)}
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
- components={
349
- isLoadingMore
350
- ? {
351
- Header: () => (
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
- // Stable empty-components reference passed when `isLoadingMore` is
364
- // false so virtuoso's `components[name]` lookups never see undefined.
365
- const EMPTY_COMPONENTS = {} as const;
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
- const statusColor =
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
- ? 'text-emerald-500'
133
+ ? 'bg-success'
131
134
  : call.status === 'error'
132
- ? 'text-destructive'
135
+ ? 'bg-destructive'
133
136
  : call.status === 'cancelled'
134
- ? 'text-muted-foreground'
135
- : 'text-amber-500';
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-md border border-border bg-muted/30">
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/60"
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-amber-500" />
168
+ <Loader2 aria-hidden className="size-3 shrink-0 animate-spin text-warning" />
157
169
  ) : (
158
- <span className={cn('size-2 shrink-0 rounded-full', statusColor.replace('text-', 'bg-'))} />
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
- <span className={cn('ml-auto', statusColor)}>{call.status}</span>
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')