@djangocfg/ui-tools 2.1.431 → 2.1.433

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
@@ -18,6 +18,63 @@ import { looksLikePlainProse } from './plainText';
18
18
  import { createMarkdownComponents } from './components';
19
19
  import { CollapseToggle } from './CollapseToggle';
20
20
  import { applyPreprocess, buildLinkRulesComponent, collectProtocols } from './linkRules';
21
+ import { renderableTail, splitStreamingBlocks } from './streamingBlocks';
22
+
23
+ /**
24
+ * Shared remark plugin chain. Hoisted to a module constant so its
25
+ * reference is stable across every block render in streaming mode —
26
+ * otherwise a fresh array each render would defeat ReactMarkdown's own
27
+ * internal memoization and force every block to re-process.
28
+ *
29
+ * Order is load-bearing:
30
+ * 1. `remark-gfm` — tables, strikethrough, autolinks, task lists.
31
+ * 2. `remark-breaks` — chat convention: single `\n` → `<br>`.
32
+ * 3. `remark-smartypants` — typographic substitutions.
33
+ * 4. `remark-emoji` — `:smile:` → 😄.
34
+ */
35
+ const REMARK_PLUGINS = [remarkGfm, remarkBreaks, remarkSmartypants, remarkEmoji] as const;
36
+
37
+ /** Props for a single rendered markdown block. Kept small so the memo
38
+ * comparator is cheap and precise. */
39
+ interface MarkdownBlockProps {
40
+ source: string;
41
+ components: Partial<Components>;
42
+ schema: ReturnType<typeof buildSchema>;
43
+ urlTransform: ReturnType<typeof buildUrlTransform> | undefined;
44
+ }
45
+
46
+ /**
47
+ * One memoized markdown block. In streaming mode the parent block-splits
48
+ * the document and renders each completed block through this; the memo
49
+ * comparator skips re-parsing any block whose `source` (and render config)
50
+ * is unchanged — so as tokens append to the tail, blocks 0..N-1 stay put.
51
+ */
52
+ const MarkdownBlock = memo(
53
+ function MarkdownBlockRaw({ source, components, schema, urlTransform }: MarkdownBlockProps) {
54
+ return (
55
+ <ReactMarkdown
56
+ remarkPlugins={REMARK_PLUGINS as unknown as React.ComponentProps<typeof ReactMarkdown>['remarkPlugins']}
57
+ // rehype-raw parses inline HTML; rehype-sanitize (extended schema)
58
+ // runs after to keep XSS guards; rehype-external-links tags <a>.
59
+ // SAME chain as the non-streaming path → identical safety posture.
60
+ rehypePlugins={[
61
+ rehypeRaw,
62
+ [rehypeSanitize, schema],
63
+ [rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
64
+ ]}
65
+ components={components}
66
+ urlTransform={urlTransform}
67
+ >
68
+ {source}
69
+ </ReactMarkdown>
70
+ );
71
+ },
72
+ (a, b) =>
73
+ a.source === b.source &&
74
+ a.components === b.components &&
75
+ a.schema === b.schema &&
76
+ a.urlTransform === b.urlTransform,
77
+ );
21
78
 
22
79
  /** Body text size class for the plain-text / prose wrapper. */
23
80
  const SIZE_TO_TEXT: Record<'xs' | 'sm' | 'base', string> = {
@@ -86,6 +143,7 @@ function MarkdownMessageRaw({
86
143
  showLessLabel = 'Show less',
87
144
  defaultExpanded = false,
88
145
  onCollapseChange,
146
+ streaming = false,
89
147
  codeTheme = 'dark',
90
148
  }: MarkdownMessageProps) {
91
149
  // Pre-process content through any rules that requested it. Done
@@ -167,6 +225,19 @@ function MarkdownMessageRaw({
167
225
  ? plainText
168
226
  : !customComponentsBeyondLinks && looksLikePlainProse(displayContent);
169
227
 
228
+ // Streaming mode is only meaningful on the markdown path and when not
229
+ // collapsing (collapse is a finished-message concern). `plainText` and
230
+ // `collapsible` both win over it.
231
+ const isStreamingMarkdown = streaming && !isPlainText && !collapsible;
232
+
233
+ // Block-split the in-flight document. Recomputed only when content
234
+ // changes; cheap (single string pass). Stable blocks feed memoized
235
+ // <MarkdownBlock>s so blocks 0..N-1 don't re-parse per token.
236
+ const split = React.useMemo(
237
+ () => (isStreamingMarkdown ? splitStreamingBlocks(displayContent) : null),
238
+ [isStreamingMarkdown, displayContent],
239
+ );
240
+
170
241
  if (isPlainText) {
171
242
  // <div> + whitespace-pre-wrap: respects newlines AND collapses
172
243
  // double spaces, which is what users mean when they hit Enter
@@ -200,70 +271,91 @@ function MarkdownMessageRaw({
200
271
  );
201
272
  }
202
273
 
274
+ // Prose wrapper class — shared by the static and streaming branches so
275
+ // typography is byte-identical whether rendered in one pass or block by
276
+ // block.
277
+ const proseWrapperClass = `
278
+ prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass} font-normal antialiased
279
+ ${isUser ? 'prose-invert' : 'dark:prose-invert'}
280
+ [&>*]:leading-relaxed
281
+ [&>*:first-child]:mt-0 [&>*:last-child]:mb-0
282
+ [&_p]:my-2
283
+ [&_ul]:my-2 [&_ol]:my-2 [&_ul]:pl-5 [&_ol]:pl-5
284
+ [&_li]:my-1 [&_li>p]:my-0
285
+ [&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold
286
+ [&_h2]:mt-3.5 [&_h2]:mb-1.5 [&_h2]:text-[15px] [&_h2]:font-semibold
287
+ [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-medium
288
+ [&_h4]:mt-3 [&_h4]:mb-1 [&_h4]:text-sm [&_h4]:font-medium
289
+ `;
290
+ const proseWrapperStyle = {
291
+ // Inherit colors from parent — fixes issues with external CSS
292
+ // variables overriding prose tokens.
293
+ '--tw-prose-body': 'inherit',
294
+ '--tw-prose-headings': 'inherit',
295
+ '--tw-prose-bold': 'inherit',
296
+ '--tw-prose-links': 'inherit',
297
+ color: 'inherit',
298
+ } as React.CSSProperties;
299
+
300
+ // ── Streaming branch ──────────────────────────────────────────────────
301
+ // Render completed blocks as memoized markdown; hold the in-flight tail
302
+ // as raw text (boundary buffering) — or as an in-progress code block if
303
+ // it's inside an unterminated fence. Same sanitize/components chain, so
304
+ // identical XSS posture; just invoked per block instead of once.
305
+ if (isStreamingMarkdown && split) {
306
+ const tail = renderableTail(split.tail, split.tailInOpenFence);
307
+ return (
308
+ <div className={`select-text ${className}`}>
309
+ <div className={proseWrapperClass} style={proseWrapperStyle}>
310
+ {split.blocks.map((source, i) => (
311
+ <MarkdownBlock
312
+ // Index keys: streaming only appends/edits the tail, so
313
+ // earlier blocks never reorder — index keys avoid unmount
314
+ // churn while keeping the memo identity stable.
315
+ key={i}
316
+ source={source}
317
+ components={components}
318
+ schema={schema}
319
+ urlTransform={urlTransform}
320
+ />
321
+ ))}
322
+ {tail.source &&
323
+ (tail.asCode ? (
324
+ // Unterminated fence → render as an in-progress code block so
325
+ // it doesn't swallow the rest of the message. The close fence
326
+ // is virtual (appended to a copy); stored content is untouched.
327
+ <MarkdownBlock
328
+ key="tail-code"
329
+ source={tail.source}
330
+ components={components}
331
+ schema={schema}
332
+ urlTransform={urlTransform}
333
+ />
334
+ ) : (
335
+ // Boundary buffering: the trailing incomplete line/block stays
336
+ // raw `whitespace-pre-wrap` until a newline promotes it — no
337
+ // flash of half-written `**bold` / `[link](`.
338
+ <div className="whitespace-pre-wrap break-words leading-relaxed">
339
+ {tail.source}
340
+ </div>
341
+ ))}
342
+ </div>
343
+ </div>
344
+ );
345
+ }
346
+
347
+ // ── Static branch (finished message / non-streaming) ──────────────────
203
348
  return (
204
349
  // `select-text` keeps prose + code blocks selectable under native-host
205
350
  // `body { user-select: none }` (Wails/Electron); no-op on web.
206
351
  <div className={`select-text ${className}`}>
207
- <div
208
- className={`
209
- prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass} font-normal antialiased
210
- ${isUser ? 'prose-invert' : 'dark:prose-invert'}
211
- [&>*]:leading-relaxed
212
- [&>*:first-child]:mt-0 [&>*:last-child]:mb-0
213
- [&_p]:my-2
214
- [&_ul]:my-2 [&_ol]:my-2 [&_ul]:pl-5 [&_ol]:pl-5
215
- [&_li]:my-1 [&_li>p]:my-0
216
- [&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold
217
- [&_h2]:mt-3.5 [&_h2]:mb-1.5 [&_h2]:text-[15px] [&_h2]:font-semibold
218
- [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-medium
219
- [&_h4]:mt-3 [&_h4]:mb-1 [&_h4]:text-sm [&_h4]:font-medium
220
- `}
221
- style={{
222
- // Inherit colors from parent — fixes issues with external
223
- // CSS variables overriding prose tokens.
224
- '--tw-prose-body': 'inherit',
225
- '--tw-prose-headings': 'inherit',
226
- '--tw-prose-bold': 'inherit',
227
- '--tw-prose-links': 'inherit',
228
- color: 'inherit',
229
- } as React.CSSProperties}
230
- >
231
- <ReactMarkdown
232
- // Remark plugin order is load-bearing:
233
- // 1. `remark-gfm` — tables, strikethrough, autolinks, task lists.
234
- // 2. `remark-breaks` — chat convention: single `\n` → `<br>`
235
- // (ChatGPT / Slack / Discord / Linear). CommonMark's
236
- // default would collapse those into one paragraph and
237
- // LLM punchlines / poems / dialogue would render as a
238
- // run-on line. Goes BEFORE smartypants so quotes that
239
- // land at line breaks still get the curly treatment.
240
- // 3. `remark-smartypants` — typographic substitutions:
241
- // "..." → …, -- → —, "x" → "x". Cheap "humanized"
242
- // polish à la Medium / Substack.
243
- // 4. `remark-emoji` — `:smile:` → 😄 (GitHub / Linear
244
- // shortcode style). Leaves Unicode emoji untouched.
245
- remarkPlugins={[remarkGfm, remarkBreaks, remarkSmartypants, remarkEmoji]}
246
- // rehype-raw parses inline HTML in the source; rehype-sanitize
247
- // (with our extended schema) runs after to keep XSS guards
248
- // (no scripts, no on* handlers, no javascript: urls).
249
- // rehype-external-links runs LAST so it tags every <a> that
250
- // sanitize let through — externals get target=_blank +
251
- // rel=noopener noreferrer in one pass, instead of every
252
- // custom `a` renderer reimplementing the rule.
253
- rehypePlugins={[
254
- rehypeRaw,
255
- [rehypeSanitize, schema],
256
- [rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
257
- ]}
352
+ <div className={proseWrapperClass} style={proseWrapperStyle}>
353
+ <MarkdownBlock
354
+ source={displayContent}
258
355
  components={components}
259
- // urlTransform runs in remark-rehype before sanitize. Without
260
- // overriding it, react-markdown's default strips `href` for
261
- // unknown schemes — making our extended sanitize whitelist
262
- // moot. Only set when the caller opted into extra protocols.
356
+ schema={schema}
263
357
  urlTransform={urlTransform}
264
- >
265
- {displayContent}
266
- </ReactMarkdown>
358
+ />
267
359
  </div>
268
360
  {collapsible && shouldCollapse && (
269
361
  <CollapseToggle
@@ -21,6 +21,7 @@ import { MarkdownMessage } from '@djangocfg/ui-tools/markdown-message';
21
21
  | Syntax-highlighted code fences with copy button | `<PrettyCode>` |
22
22
  | Mermaid diagrams (` ```mermaid ` fence) with click-to-fullscreen | `<Mermaid>` |
23
23
  | Custom URL schemes via declarative `linkRules` | `./linkRules.ts` |
24
+ | Opt-in URL chips (bare URLs → favicon + middle-ellipsis chip) | `urlChipRule` · `./builtinRules.tsx` |
24
25
  | Plain-text fast path — short messages skip the markdown pipeline | `./plainText.ts` |
25
26
  | Optional collapsible "Read more" for long replies | `useCollapsibleContent` |
26
27
  | Hover-revealed copy action under the bubble (separate `<ActionRow>` export) | `<CopyButton>` (ui-core) |
@@ -84,6 +85,42 @@ The plain `a` renderer keeps its palette and typography; `linkRules` only fires
84
85
  when both the `protocols` and the `match` predicate accept the href. See
85
86
  `linkRules.ts` for the sanitize-extension logic.
86
87
 
88
+ ### Built-in `urlChipRule` (URL chips)
89
+
90
+ `urlChipRule` (exported from `./builtinRules`) is a shipped, **opt-in** `LinkRule`
91
+ that renders a URL as a compact `<UrlChip>` — favicon + domain + middle-ellipsis
92
+ path (`github.com/…/README.md`), the same chip the chat composer shows while you
93
+ type a URL. It is not applied automatically; a host appends it to its own
94
+ `linkRules`:
95
+
96
+ ```tsx
97
+ import { MarkdownMessage, urlChipRule } from '@djangocfg/ui-tools/markdown-message';
98
+
99
+ <MarkdownMessage content={text} linkRules={[urlChipRule]} />
100
+ ```
101
+
102
+ In the Chat tool this is wired by `ChatConfig.linkChips` (off by default); when
103
+ on, `MessageBubble` passes `linkRules={[urlChipRule]}`.
104
+
105
+ Behaviour:
106
+
107
+ - **Bare URLs only.** It chips a link whose rendered label *is* the href (a
108
+ plain autolink, trailing-slash difference tolerated). A `[labeled](url)` link
109
+ keeps the author's text and renders as a normal styled link — the rule falls
110
+ back to an `<a>` using the chat `ANCHOR` tokens so it stays colored +
111
+ underlined, not stripped to plain text.
112
+ - **Scope is `http(s)` only** (`match: /^https?:\/\//i`). `mailto:` / custom
113
+ schemes (`cmdop://`, …) are left to other rules, so it needs no `protocols`
114
+ whitelist.
115
+ - **`www.` host promotion.** Its `preprocess` hook rewrites bare `www.host…`
116
+ tokens to `https://www.host…` before parsing — GFM only autolinks tokens with
117
+ a scheme, so without this a bare `www.` host would reach neither the rule nor
118
+ the default anchor and render as plain text. The promotion is boundary-guarded
119
+ so it never double-prefixes an already-schemed URL.
120
+
121
+ `UrlChip` itself is the surface-agnostic chip in `src/common/chips` — see
122
+ [`common/chips/README.md`](../../../../common/chips/README.md).
123
+
87
124
  ## Copy action (`<ChatMessageRow>` + `<ActionRow>`)
88
125
 
89
126
  Two pieces, two responsibilities:
@@ -160,6 +197,7 @@ so it's already future-proof for richer rows like
160
197
  | `components.tsx` | The `Components` map — typography, lists, table, code, blockquote, hr |
161
198
  | `CodeBlock.tsx` | Code-fence renderer (`PrettyCode`) + plain `<pre>` fallback |
162
199
  | `linkRules.ts` | Declarative custom-URL primitive — schema & helpers |
200
+ | `builtinRules.tsx` | Shipped opt-in rules — `urlChipRule` (bare URL → `<UrlChip>`) |
163
201
  | `plainText.ts` | `looksLikePlainProse` heuristic + `extractTextFromChildren` utility |
164
202
  | `sanitize.ts` | Extended rehype-sanitize schema + `urlTransform` builder |
165
203
  | `CollapseToggle.tsx` | "Read more / Show less" affordance for long replies |
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Built-in `LinkRule`s shipped with `MarkdownMessage`. These are opt-in —
5
+ * a host enables them (e.g. via `ChatConfig.linkChips`) by appending them
6
+ * to its own `linkRules` array; they are NOT applied automatically.
7
+ *
8
+ * ── `urlChipRule` ──
9
+ *
10
+ * Renders a BARE web URL (a plain `https://…` autolink, where the markdown
11
+ * link text === the href) as a compact `<UrlChip>` (favicon + domain +
12
+ * middle-ellipsis path) — the same chip the composer shows while you type a
13
+ * URL. This makes a URL read identically across the composer and the
14
+ * rendered bubble (plan22's reuse goal).
15
+ *
16
+ * Deliberately CHIPS ONLY bare URLs:
17
+ * - `https://github.com/wailsapp/wails` → chip (children === href)
18
+ * - `[the repo](https://github.com/...)` → NOT chipped — the author chose
19
+ * custom link text, so we honour it and render a normal styled link
20
+ * (fall through to the built-in anchor).
21
+ *
22
+ * Scope is `http(s)` only — `mailto:` / custom schemes (`cmdop://`, …) are
23
+ * left to other rules. Because it only matches `http(s)`, it needs no extra
24
+ * `protocols` whitelist.
25
+ */
26
+
27
+ import type React from 'react';
28
+
29
+ import { UrlChip } from '../../../../common/chips/UrlChip';
30
+ import { ANCHOR } from '../../../chat/styles/bubbleTokens';
31
+ import { extractTextFromChildren } from './plainText';
32
+ import type { LinkRule } from './types';
33
+
34
+ /** True when the link's rendered label is just the href itself (a bare
35
+ * autolinked URL), i.e. the author did NOT supply custom link text. We
36
+ * compare the flattened text label to the href, tolerating a trailing
37
+ * slash difference that markdown autolinkers sometimes introduce. */
38
+ function isBareUrlLink(href: string, children: React.ReactNode): boolean {
39
+ const label = extractTextFromChildren(children).trim();
40
+ if (!label) return true; // no visible text → treat as bare
41
+ const strip = (s: string) => s.replace(/\/+$/, '');
42
+ return strip(label) === strip(href);
43
+ }
44
+
45
+ /**
46
+ * Promote bare `www.host…` tokens to `https://www.host…` BEFORE markdown
47
+ * parsing. remark-gfm only autolinks tokens with a scheme, so a bare `www.`
48
+ * host would otherwise reach neither this rule nor the default anchor — it
49
+ * renders as unstyled plain text (the reported bug). Adding the scheme lets
50
+ * gfm autolink it so the chip rule can claim it, matching the composer
51
+ * (whose autolink whitelist also chips `www.` hosts).
52
+ *
53
+ * Guards against double-prefixing: only matches a `www.` that is preceded by
54
+ * a whitespace / line-start / `(` boundary AND not already part of a
55
+ * `://www.` or `](…www.` URL.
56
+ */
57
+ function promoteWwwHosts(source: string): string {
58
+ return source.replace(
59
+ /(^|[\s(])(www\.[^\s)<>]+)/gim,
60
+ (_m, lead: string, host: string) => `${lead}https://${host}`,
61
+ );
62
+ }
63
+
64
+ export const urlChipRule: LinkRule = {
65
+ name: 'url-chip',
66
+ // Promote bare `www.` hosts so gfm autolinks them (then we chip them).
67
+ preprocess: promoteWwwHosts,
68
+ // Only real web URLs. Leave mailto:/custom schemes to other rules.
69
+ match: (href) => /^https?:\/\//i.test(href),
70
+ render: ({ href, children, isUser }) => {
71
+ // Honour author-chosen link text: a `[label](url)` link with custom
72
+ // text is NOT chipped — render a normal styled link so the chosen words
73
+ // stay the link. Use the SAME anchor styling as the built-in renderer
74
+ // (`ANCHOR`) so it reads as a link (colored + underlined), not plain
75
+ // text — otherwise claiming the href here strips the default styling.
76
+ if (!isBareUrlLink(href, children)) {
77
+ return (
78
+ <a
79
+ href={href}
80
+ target="_blank"
81
+ rel="noopener noreferrer"
82
+ className={isUser ? ANCHOR.user : ANCHOR.assistant}
83
+ >
84
+ {children}
85
+ </a>
86
+ );
87
+ }
88
+ return <UrlChip href={href} />;
89
+ },
90
+ };
91
+
92
+ export default urlChipRule;
@@ -7,6 +7,8 @@
7
7
  * - `LinkRule` — the declarative link-handling primitive.
8
8
  * - `extractTextFromChildren` — utility for callers that build their
9
9
  * own custom `a` renderers and need a string label.
10
+ * - `urlChipRule` — opt-in built-in `LinkRule` that renders a bare
11
+ * web URL as a compact `<UrlChip>` (favicon + middle-ellipsis).
10
12
  * - `ActionRow` — dumb action row (copy button), takes `visible`
11
13
  * from its owner.
12
14
  * - `ChatMessageRow` — opinionated bubble + action-row wrapper
@@ -15,5 +17,6 @@
15
17
  export { MarkdownMessage, default } from './MarkdownMessage';
16
18
  export type { MarkdownMessageProps, LinkRule } from './types';
17
19
  export { extractTextFromChildren } from './plainText';
20
+ export { urlChipRule } from './builtinRules';
18
21
  export { ActionRow } from './ActionRow';
19
22
  export { ChatMessageRow } from './ChatMessageRow';
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Streaming-markdown block splitter + boundary buffering.
3
+ *
4
+ * Purpose: render markdown LIVE during streaming without (a) re-parsing
5
+ * the whole document on every token (O(n²)) and (b) flashing raw syntax
6
+ * for half-written constructs. Approach (per plan105 §3.1 + §3.3):
7
+ *
8
+ * 1. Block-split: split accumulating markdown into top-level blocks at
9
+ * blank-line boundaries, RESPECTING fenced code blocks (a blank line
10
+ * inside ``` does NOT end the block). Each completed block has stable
11
+ * `raw` source, so the consumer can memoize blocks 0..N-1 and only
12
+ * re-parse the tail → ~O(n).
13
+ * 2. Boundary buffering: the trailing block — the one still receiving
14
+ * tokens — is held back as RAW plain text until it reaches a safe
15
+ * boundary (a trailing newline). This kills the "flash of incomplete
16
+ * `**bold` / `[link](`" without any fragile syntax repair.
17
+ * 3. Fence awareness: if the stream ends INSIDE an unclosed ``` fence,
18
+ * the tail is rendered as an in-progress code block (we virtually
19
+ * close the fence for that render only) instead of as broken markdown
20
+ * bleeding into the rest of the message.
21
+ *
22
+ * No `marked` / lexer dependency — a blank-line splitter that tracks fence
23
+ * state is sufficient for the safe-80% path and adds zero deps. The actual
24
+ * per-block rendering still goes through the full ReactMarkdown pipeline.
25
+ *
26
+ * This module is pure (no React) so it is trivially testable.
27
+ */
28
+
29
+ /** A fenced-code-block opener: ``` or ~~~, optionally indented up to 3
30
+ * spaces, with an optional info string. Per CommonMark the closing fence
31
+ * must use the same char and be at least as long; we keep it simple and
32
+ * match the char + length, which covers real LLM output. */
33
+ const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
34
+
35
+ interface FenceState {
36
+ open: boolean;
37
+ /** The exact fence marker that opened the current block (e.g. "```"). */
38
+ marker: string;
39
+ }
40
+
41
+ /** Does `line` open/close a fence given the current state? Returns the
42
+ * next fence state. A line that closes uses the same char, >= length, and
43
+ * no trailing info string. */
44
+ function stepFence(line: string, state: FenceState): FenceState {
45
+ const m = FENCE_RE.exec(line);
46
+ if (!m) return state;
47
+ const marker = m[2];
48
+ const info = m[3].trim();
49
+ if (!state.open) {
50
+ // Any fence line opens a block (info string allowed on open).
51
+ return { open: true, marker };
52
+ }
53
+ // Inside a fence: a bare fence of the same char and >= length closes it.
54
+ const sameChar = marker[0] === state.marker[0];
55
+ const longEnough = marker.length >= state.marker.length;
56
+ if (sameChar && longEnough && info === '') {
57
+ return { open: false, marker: '' };
58
+ }
59
+ return state;
60
+ }
61
+
62
+ export interface SplitResult {
63
+ /** Completed, stable top-level blocks (safe to memoize + parse). */
64
+ blocks: string[];
65
+ /** The trailing in-flight block, or '' if the content ended on a
66
+ * clean boundary. Held back from `blocks` so it can be rendered raw
67
+ * (boundary buffering) until it completes. */
68
+ tail: string;
69
+ /** True when `tail` is the body of an unterminated fenced code block —
70
+ * the consumer should render it as an in-progress code block rather
71
+ * than raw prose. */
72
+ tailInOpenFence: boolean;
73
+ }
74
+
75
+ /**
76
+ * Split `content` into stable blocks + a trailing in-flight block.
77
+ *
78
+ * Boundary rule: a block is "complete" once a blank line follows it AND
79
+ * we are not inside an open fence. The text after the last such boundary
80
+ * is the `tail`. If the whole content ends with a newline and no fence is
81
+ * open, the tail is empty (everything promoted to stable blocks).
82
+ *
83
+ * @param content raw accumulated markdown (never mutated)
84
+ */
85
+ export function splitStreamingBlocks(content: string): SplitResult {
86
+ if (content.length === 0) {
87
+ return { blocks: [], tail: '', tailInOpenFence: false };
88
+ }
89
+
90
+ const lines = content.split('\n');
91
+ const blocks: string[] = [];
92
+ let current: string[] = [];
93
+ let fence: FenceState = { open: false, marker: '' };
94
+
95
+ // Track fence state at the START of the current accumulating block so we
96
+ // know, at the end, whether the tail is inside an open fence.
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const line = lines[i];
99
+ const wasOpen = fence.open;
100
+ fence = stepFence(line, fence);
101
+
102
+ const isBlank = line.trim() === '';
103
+ // A blank line outside any fence is a block boundary. We only cut when
104
+ // we have accumulated something — leading/again-blank lines collapse.
105
+ if (isBlank && !fence.open && !wasOpen) {
106
+ if (current.length > 0) {
107
+ blocks.push(current.join('\n'));
108
+ current = [];
109
+ }
110
+ // Drop the blank separator itself.
111
+ continue;
112
+ }
113
+ current.push(line);
114
+ }
115
+
116
+ // Whatever remains is the in-flight tail. If the original content ended
117
+ // with a trailing newline AND we're on a clean boundary (no open fence),
118
+ // the tail is genuinely complete prose — but we still keep it as `tail`
119
+ // unless it was already closed by a blank line above. To maximize stable
120
+ // blocks, promote the tail to a completed block when it ends on a clean
121
+ // newline boundary and no fence is open.
122
+ let tail = current.join('\n');
123
+ let tailInOpenFence = fence.open;
124
+
125
+ if (!tailInOpenFence && tail.length > 0 && content.endsWith('\n')) {
126
+ // Content ended on a newline and the tail isn't inside a fence — treat
127
+ // it as a finished block so it parses (and memoizes) immediately.
128
+ blocks.push(tail);
129
+ tail = '';
130
+ }
131
+
132
+ return { blocks, tail, tailInOpenFence };
133
+ }
134
+
135
+ /**
136
+ * Prepare the in-flight tail for rendering.
137
+ *
138
+ * - If the tail is inside an open fence, virtually close it so it renders
139
+ * as an in-progress code block (not raw text bleeding downward). The
140
+ * close is appended to a COPY — stored content is never mutated.
141
+ * - Otherwise return the tail untouched; the consumer renders it as plain
142
+ * `whitespace-pre-wrap` text (boundary buffering — no syntax repair).
143
+ *
144
+ * NOTE: we deliberately do NOT auto-close dangling `**` / `[` / inline
145
+ * backticks here (plan105 §3.2, deferred / flag-gated). The only fence we
146
+ * close is the block code fence, because an unclosed block fence would
147
+ * otherwise swallow the rest of the message and the close is unambiguous.
148
+ */
149
+ export function renderableTail(
150
+ tail: string,
151
+ tailInOpenFence: boolean,
152
+ ): { source: string; asCode: boolean } {
153
+ if (!tailInOpenFence) return { source: tail, asCode: false };
154
+ // Re-derive the opening fence marker so we close with a matching one.
155
+ const firstLine = tail.split('\n', 1)[0] ?? '';
156
+ const m = FENCE_RE.exec(firstLine);
157
+ const marker = m ? m[2] : '```';
158
+ // Ensure the close sits on its own line.
159
+ const needsNewline = !tail.endsWith('\n');
160
+ return {
161
+ source: `${tail}${needsNewline ? '\n' : ''}${marker}`,
162
+ asCode: true,
163
+ };
164
+ }
@@ -131,6 +131,26 @@ export interface MarkdownMessageProps {
131
131
  defaultExpanded?: boolean;
132
132
  /** Callback when collapsed state changes */
133
133
  onCollapseChange?: (isCollapsed: boolean) => void;
134
+ /**
135
+ * Live-streaming mode. When `true`, the content is treated as a
136
+ * partial markdown document still receiving tokens:
137
+ *
138
+ * - Completed top-level blocks render as memoized markdown (blocks
139
+ * 0..N-1 don't re-parse as new tokens land → ~O(n), not O(n²)).
140
+ * - The trailing in-flight block is held back as raw `whitespace-pre-wrap`
141
+ * text until it reaches a safe boundary (a newline), so half-written
142
+ * `**bold` / `[link](` don't flash formatted-then-broken.
143
+ * - An unterminated fenced code block renders as an in-progress code
144
+ * block instead of swallowing the rest of the message.
145
+ *
146
+ * Ignored when `plainText` is `true` (plain wins outright) or when
147
+ * `collapsible` is set (collapsing is a post-stream concern). Has no
148
+ * effect on the finished message — pass `false` (the default) once the
149
+ * stream completes to get a single fully-parsed render.
150
+ *
151
+ * @default false
152
+ */
153
+ streaming?: boolean;
134
154
  /**
135
155
  * Code-fence surface palette. @default 'dark' — syntax highlighting
136
156
  * ships its own contrast model and reads washed out on a light