@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
|
@@ -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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|