@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
@@ -94,21 +94,34 @@ const SIZE_CLASSES: Record<ComposerSize, {
94
94
  inlineSlot: string;
95
95
  }> = {
96
96
  sm: {
97
- surface: 'rounded-xl px-2 py-1',
97
+ // Symmetric corner inset — px == py so the trailing send button sits
98
+ // equidistant from the surface's right and bottom edges.
99
+ surface: 'rounded-xl px-2 py-2',
98
100
  textarea: 'min-h-7 max-h-44 px-1.5 py-1',
99
101
  text: 'text-sm',
100
102
  containerPadding: 'px-2 pt-1.5 pb-[max(0.375rem,env(safe-area-inset-bottom))]',
101
103
  inlineSlot: '[&>:not(textarea)]:h-7',
102
104
  },
103
105
  md: {
104
- surface: 'rounded-2xl px-2 py-1.5',
105
- textarea: 'min-h-9 max-h-60 px-2 py-1.5',
106
- text: 'text-base sm:text-sm',
106
+ // Symmetric corner inset — px == py so the trailing send button sits
107
+ // equidistant from the surface's right and bottom edges (was py-1.5,
108
+ // which left the button closer to the bottom than the right).
109
+ surface: 'rounded-2xl px-2 py-2',
110
+ // ~one-line tall (min-h-8) with tight, balanced vertical padding.
111
+ // `py-1.5` (6px) stacked under the surface's own `py-1.5` read like a
112
+ // phantom empty line under a single-line input — `py-1` keeps the
113
+ // text vertically centred without the extra bottom gap.
114
+ textarea: 'min-h-8 max-h-60 px-2 py-1',
115
+ // Comfortable 15px chat body. `text-base sm:text-sm` collapsed to
116
+ // 14px and read too small; 15px matches the rich-editor baseline.
117
+ text: 'text-[15px]',
107
118
  containerPadding: 'px-2.5 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]',
108
119
  inlineSlot: '[&>:not(textarea)]:h-9',
109
120
  },
110
121
  lg: {
111
- surface: 'rounded-2xl px-3 py-2.5',
122
+ // Symmetric corner inset — px == py so the trailing send button sits
123
+ // equidistant from the surface's right and bottom edges.
124
+ surface: 'rounded-2xl px-3 py-3',
112
125
  textarea: 'min-h-12 max-h-72 px-2.5 py-2',
113
126
  text: 'text-base',
114
127
  containerPadding: 'px-3.5 pt-3 pb-[max(0.875rem,env(safe-area-inset-bottom))]',
@@ -133,7 +146,9 @@ const APPEARANCE_CLASSES: Record<ComposerAppearance, {
133
146
  }> = {
134
147
  compact: { surface: '', textarea: '', text: '', containerPadding: '' },
135
148
  full: {
136
- surface: 'rounded-3xl px-3 py-2',
149
+ // Symmetric corner inset — px == py so the trailing send button sits
150
+ // equidistant from the surface's right and bottom edges.
151
+ surface: 'rounded-3xl px-3 py-3',
137
152
  textarea: 'min-h-10 max-h-80 px-2.5 py-2',
138
153
  text: 'text-base',
139
154
  containerPadding: 'px-4 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]',
@@ -337,6 +352,8 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
337
352
  dragDepth.current = 0;
338
353
  setIsDragging(false);
339
354
  const dropped = Array.from(e.dataTransfer.files ?? []);
355
+ // eslint-disable-next-line no-console
356
+ console.log('[composer] handleDrop files=%d names=%o', dropped.length, dropped.map((f) => f.name));
340
357
  if (dropped.length > 0) attachHandle.attachFiles(dropped);
341
358
  };
342
359
 
@@ -485,7 +502,20 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
485
502
  // ── Inline (compact single-row) layout ─────────────────────────────────
486
503
  // Drag-drop handlers shared by both layouts. No-op when attaching is
487
504
  // disabled — the handlers themselves early-return.
488
- const dragProps = {
505
+ //
506
+ // `data-wails-dropzone` is load-bearing inside the Wails desktop shell:
507
+ // its runtime gates DOM file drops on a per-element basis. On `dragover`
508
+ // it walks `elementFromPoint(...).closest('[data-wails-dropzone]')` and
509
+ // sets `dataTransfer.dropEffect = 'copy'` ONLY when the cursor is over a
510
+ // marked element — otherwise it forces `dropEffect = 'none'`, and the
511
+ // browser then REJECTS the `drop` on that element (the React `onDrop`
512
+ // never fires). Marking the composer surface makes the runtime allow the
513
+ // drop here, exactly like the debug drop-test harness. Harmless no-op in
514
+ // a plain browser (it's just an unknown data-attribute there).
515
+ const dragProps: React.HTMLAttributes<HTMLDivElement> & {
516
+ 'data-wails-dropzone': string;
517
+ } = {
518
+ 'data-wails-dropzone': '',
489
519
  onDragEnter: handleDragEnter,
490
520
  onDragOver: handleDragOver,
491
521
  onDragLeave: handleDragLeave,
@@ -615,12 +645,15 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
615
645
  ref={surfaceRef}
616
646
  onMouseDown={handleSurfaceMouseDown}
617
647
  className={cn(
618
- // Raised input panel — reads against the chat background via the
619
- // neutral `muted` surface + a hairline ring (Gemini/ChatGPT
620
- // style), not a heavy drawn border. Focus firms up the ring
621
- // without a hard accent frame.
648
+ // Raised input panel — a *gentle* elevation over the chat
649
+ // background, not a hard box. `card` is the "slightly elevated
650
+ // surface" token (one step up from `background`); `muted` is the
651
+ // chips/fills token and sat far too light, reading as a
652
+ // high-contrast block on the dark macOS theme. A softened
653
+ // hairline ring (`border/60`) keeps the edge whisper-quiet
654
+ // (Gemini/ChatGPT/Claude style). Focus firms the ring up.
622
655
  'flex flex-col rounded-2xl text-foreground',
623
- 'bg-muted ring-1 ring-border',
656
+ 'bg-card ring-1 ring-border/60',
624
657
  'focus-within:ring-ring/50',
625
658
  'transition-colors',
626
659
  // The whole surface reads as a text field — the padding around the
@@ -12,11 +12,23 @@ import {
12
12
  import { useRegisterComposer } from '../hooks/useAutoFocusOnStreamEnd';
13
13
  import type { ComposerSize, ComposerTextareaProps } from './types';
14
14
 
15
- /** Single-row baseline height per composer size (px). */
15
+ /**
16
+ * Single-row min-height FLOOR per composer size (px).
17
+ *
18
+ * This is a floor, NOT the row height — the empty `<p>`'s own line box
19
+ * defines the real single-row height. The floor must stay BELOW one line
20
+ * box (15px text × line-height) so it can never inflate the editor past
21
+ * one line: when a host overrides the editor leading (e.g. cmdop pins
22
+ * `.ProseMirror` to `leading-normal` ≈ 22.5px), a 24/28px min-height used
23
+ * to sit ABOVE the line box and the empty composer read as having a
24
+ * phantom extra line under the caret. Keeping every floor ≤ the tightest
25
+ * expected single line (≈22px) means the line box always wins and the
26
+ * single-row composer is exactly one line tall in every host.
27
+ */
16
28
  const MIN_HEIGHT: Record<ComposerSize, number> = {
17
- sm: 20,
18
- md: 24,
19
- lg: 28,
29
+ sm: 18,
30
+ md: 20,
31
+ lg: 22,
20
32
  };
21
33
 
22
34
  export interface ComposerRichTextareaProps extends ComposerTextareaProps {
@@ -44,8 +44,15 @@ export const LIMITS = {
44
44
  attachmentsMax: 10,
45
45
  /** Composer history slots. */
46
46
  composerHistorySize: 50,
47
- /** Coalesce stream tokens within this window before dispatching. */
48
- streamCoalesceMs: 16,
47
+ /**
48
+ * Coalesce stream tokens within this window before dispatching one
49
+ * aggregated chunk. 33ms ≈ 30fps — enough to feel live while halving
50
+ * the re-parse count now that markdown renders during streaming
51
+ * (block-split + per-block memoization keeps each render ~O(n), but
52
+ * fewer dispatches still means fewer tail re-parses). ai-sdk uses ~50ms
53
+ * for the same reason.
54
+ */
55
+ streamCoalesceMs: 33,
49
56
  /** Default history page size. */
50
57
  pageSize: 50,
51
58
  /** Virtualize list when >= this many messages (host-controlled threshold). */
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Extract the FIRST previewable http(s) URL from a message's plain text.
3
+ *
4
+ * Telegram/WhatsApp rule: at most one preview per message — we surface the
5
+ * first link only. Returns `null` when there's nothing to unfurl.
6
+ *
7
+ * Deliberately conservative: only absolute http/https URLs (no bare
8
+ * `example.com`, no mailto/ftp), trailing punctuation trimmed, and a length
9
+ * cap to avoid pathological inputs.
10
+ */
11
+
12
+ // http(s) URL up to the first whitespace or closing bracket/quote.
13
+ const URL_RE = /\bhttps?:\/\/[^\s<>"'`)\]}]+/i;
14
+
15
+ /** Trailing punctuation that's almost always sentence/markup, not the URL. */
16
+ const TRAILING = /[.,;:!?)\]}'"»]+$/;
17
+
18
+ export function extractFirstUrl(text: string | undefined | null): string | null {
19
+ if (!text) return null;
20
+ // Skip fenced/inline code-heavy content cheaply: still match, but the cap
21
+ // below guards huge blobs.
22
+ if (text.length > 50_000) return null;
23
+
24
+ const match = URL_RE.exec(text);
25
+ if (!match) return null;
26
+
27
+ let url = match[0].replace(TRAILING, '');
28
+
29
+ // Balance a trailing ")" that belongs to a markdown link `](url)` wrapper:
30
+ // if the URL has more ")" than "(", drop the extras.
31
+ const opens = (url.match(/\(/g) ?? []).length;
32
+ const closes = (url.match(/\)/g) ?? []).length;
33
+ if (closes > opens) {
34
+ url = url.replace(/\)+$/, (m) => m.slice(0, Math.max(0, m.length - (closes - opens))));
35
+ }
36
+
37
+ // Validate — a malformed match resolves to no preview.
38
+ try {
39
+ const parsed = new URL(url);
40
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
41
+ return parsed.toString();
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
@@ -8,13 +8,23 @@
8
8
  * - `version` bumps on edit so memo keys invalidate.
9
9
  */
10
10
 
11
- import type { ChatAttachment, ChatMessage, ChatSource, ChatToolCall } from '../types';
11
+ import type { ChatAttachment, ChatMessage, ChatToolCall } from '../types';
12
12
 
13
13
  export interface ChatState {
14
14
  sessionId: string | null;
15
15
  messages: ChatMessage[];
16
16
  /** Initial history load in flight. */
17
17
  isLoading: boolean;
18
+ /**
19
+ * True once the initial history load has settled (done OR errored). False
20
+ * from mount until then. Lets the UI distinguish "history still loading"
21
+ * from "genuinely empty conversation": render a spinner while
22
+ * `!historyLoaded`, the empty-state only once `historyLoaded` is true and
23
+ * `messages` is empty. `isLoading` alone can't carry this — it is `false`
24
+ * on the very first synchronous render (before the bootstrap effect runs),
25
+ * which would flash the empty-state for a session that does have history.
26
+ */
27
+ historyLoaded: boolean;
18
28
  /** Assistant is generating a reply. */
19
29
  isStreaming: boolean;
20
30
  /** Older history page in flight. */
@@ -28,6 +38,7 @@ export const initialState: ChatState = {
28
38
  sessionId: null,
29
39
  messages: [],
30
40
  isLoading: false,
41
+ historyLoaded: false,
31
42
  isStreaming: false,
32
43
  isLoadingMore: false,
33
44
  hasMore: true,
@@ -73,9 +84,12 @@ export type ChatAction =
73
84
  | {
74
85
  type: 'STREAM_DONE';
75
86
  id: string;
76
- tokensIn?: number;
77
- tokensOut?: number;
78
- sources?: ChatSource[];
87
+ /**
88
+ * Generic finalize merge (content/blocks/sources/tokens/attachments/
89
+ * metadata). The single slot the terminal event carries — applied as
90
+ * a `Partial<ChatMessage>` merge. No-op when absent.
91
+ */
92
+ patch?: Partial<ChatMessage>;
79
93
  }
80
94
  | { type: 'STREAM_CANCELLED'; id: string; partialText: string; label?: string }
81
95
  | { type: 'STREAM_ERROR'; id?: string; message: string }
@@ -134,6 +148,11 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
134
148
  messages: action.messages ?? state.messages,
135
149
  hasMore: action.hasMore ?? state.hasMore,
136
150
  oldestCursor: action.cursor ?? state.oldestCursor,
151
+ // When the session carries its messages (the autoCreateSession /
152
+ // newSession path commits them here), the initial load is settled.
153
+ // A bare `SESSION_SET` (resume path sets the id, then awaits
154
+ // loadHistory → HISTORY_LOAD_DONE) leaves `historyLoaded` untouched.
155
+ historyLoaded: action.messages !== undefined ? true : state.historyLoaded,
137
156
  error: null,
138
157
  };
139
158
 
@@ -144,6 +163,7 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
144
163
  return {
145
164
  ...state,
146
165
  isLoading: false,
166
+ historyLoaded: true,
147
167
  messages: action.messages,
148
168
  hasMore: action.hasMore,
149
169
  oldestCursor: action.cursor,
@@ -258,10 +278,11 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
258
278
  case 'STREAM_DONE': {
259
279
  const patched = patchMessageById(state.messages, action.id, (m) => ({
260
280
  ...m,
281
+ // Generic finalize merge (content/blocks/sources/tokens/attachments/
282
+ // metadata). No-op when `patch` is absent — `message_end` is then a
283
+ // pure terminal that only clears `isStreaming`.
284
+ ...(action.patch ?? {}),
261
285
  isStreaming: false,
262
- tokensIn: action.tokensIn ?? m.tokensIn,
263
- tokensOut: action.tokensOut ?? m.tokensOut,
264
- sources: action.sources ?? m.sources,
265
286
  }));
266
287
  // Drop an assistant message that ended up completely empty (no text, no
267
288
  // tool calls). This happens in the HITL approval flow: the first stream
@@ -377,7 +398,10 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
377
398
  };
378
399
 
379
400
  case 'ERROR_SET':
380
- return { ...state, error: action.error };
401
+ // An error settles the initial load — a failed bootstrap must not leave
402
+ // a stuck spinner; clear `isLoading` and mark the load resolved so the
403
+ // host can fall through to its error/empty surface.
404
+ return { ...state, error: action.error, isLoading: false, historyLoaded: true };
381
405
 
382
406
  case 'ATTACHMENT_PROGRESS': {
383
407
  const messages = patchMessageById(state.messages, action.messageId, (m) => {
@@ -100,7 +100,7 @@ export function* mapPydanticAIEvent(
100
100
  }
101
101
 
102
102
  case 'done':
103
- yield { type: 'message_end', tokensOut: ev.total_tokens };
103
+ yield { type: 'message_end', patch: { tokensOut: ev.total_tokens } };
104
104
  return;
105
105
 
106
106
  case 'error':
@@ -93,7 +93,10 @@ export function createMockTransport(opts: MockTransportOptions = {}): ChatTransp
93
93
  await sleep(latency);
94
94
  yield { type: 'chunk', delta: piece };
95
95
  }
96
- yield { type: 'message_end', tokensIn: content.length, tokensOut: reply.length };
96
+ yield {
97
+ type: 'message_end',
98
+ patch: { tokensIn: content.length, tokensOut: reply.length },
99
+ };
97
100
  } else {
98
101
  for (const ev of reply) {
99
102
  if (options.signal.aborted) return;
@@ -143,30 +143,34 @@ export function useChat(config: UseChatConfig): UseChatReturn {
143
143
  const t0 = performance.now();
144
144
  try {
145
145
  if (config.initialSessionId) {
146
- if (!cancelled) {
147
- dispatch({
148
- type: 'SESSION_SET',
149
- sessionId: config.initialSessionId,
150
- });
151
- }
146
+ dispatch({
147
+ type: 'SESSION_SET',
148
+ sessionId: config.initialSessionId,
149
+ });
152
150
  const page = await transport.loadHistory(config.initialSessionId, null, pageSize);
153
- if (cancelled) {
154
- log.bootstrap.debug('cancelled (post-loadHistory)');
155
- // Even though *this* effect is cancelled, the network call did
156
- // succeed return the sessionId so awaitSession() doesn't see
157
- // a phantom null when the next mount picks up.
158
- return config.initialSessionId;
159
- }
151
+ // Commit the loaded history even if THIS effect run was cancelled.
152
+ // Under React StrictMode the first mount's effect is always
153
+ // cancelled (mount unmount mount), and the second run skips the
154
+ // fetch because `sessionId` is already in the persisted reducer
155
+ // state (the early-return at the top of the effect). If we bailed
156
+ // here on `cancelled`, `HISTORY_LOAD_DONE` would never dispatch on
157
+ // either run and the resumed history would silently never reach the
158
+ // reducer — the read succeeds but the messages are dropped, so the
159
+ // chat shows the empty-state until a remount re-bootstraps it. The
160
+ // reducer state survives the StrictMode remount, so dispatching the
161
+ // load result regardless is correct (mirrors the autoCreateSession
162
+ // branch below, which already commits post-cancel for the same reason).
160
163
  dispatch({
161
164
  type: 'HISTORY_LOAD_DONE',
162
165
  messages: page.messages,
163
166
  hasMore: page.hasMore,
164
167
  cursor: page.nextCursor,
165
168
  });
166
- log.bootstrap.success('resumed', {
169
+ log.bootstrap.success(cancelled ? 'resumed (post-cancel)' : 'resumed', {
167
170
  sessionId: config.initialSessionId,
168
171
  messages: page.messages.length,
169
172
  hasMore: page.hasMore,
173
+ cancelled,
170
174
  elapsedMs: Math.round(performance.now() - t0),
171
175
  });
172
176
  return config.initialSessionId;
@@ -397,14 +401,10 @@ export function useChat(config: UseChatConfig): UseChatReturn {
397
401
  dispatch({
398
402
  type: 'STREAM_DONE',
399
403
  id: targetId,
400
- tokensIn: ev.tokensIn,
401
- tokensOut: ev.tokensOut,
402
- sources: ev.sources,
404
+ patch: ev.patch,
403
405
  });
404
406
  log.stream.debug('message_end', {
405
- tokensIn: ev.tokensIn,
406
- tokensOut: ev.tokensOut,
407
- sources: ev.sources?.length ?? 0,
407
+ patchKeys: ev.patch ? Object.keys(ev.patch) : [],
408
408
  });
409
409
  return;
410
410
  case 'message_metrics':
@@ -75,6 +75,16 @@ export * from './composer';
75
75
  export * from './shell';
76
76
  export * from './launcher';
77
77
 
78
+ // Markdown renderer + plain-text helper — also surfaced from index.ts; the
79
+ // `./chat` package entry resolves to THIS file (lazy.tsx), so a host that
80
+ // composes a custom mention-aware bubble needs them re-exported here too
81
+ // (otherwise the symbols typecheck via index.ts but fail to bundle at runtime).
82
+ export {
83
+ MarkdownMessage,
84
+ extractTextFromChildren,
85
+ type MarkdownMessageProps,
86
+ } from '../dev/code/MarkdownMessage';
87
+
78
88
  // Bridge — AI-driven `point` directives (lightweight, no heavy deps).
79
89
  export {
80
90
  HighlightOverlay,