@djangocfg/ui-tools 2.1.430 → 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 (188) hide show
  1. package/dist/tree/index.cjs +43 -8
  2. package/dist/tree/index.cjs.map +1 -1
  3. package/dist/tree/index.d.cts +31 -1
  4. package/dist/tree/index.d.ts +31 -1
  5. package/dist/tree/index.mjs +43 -8
  6. package/dist/tree/index.mjs.map +1 -1
  7. package/package.json +28 -14
  8. package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
  9. package/src/common/CodePanelHeader/index.ts +1 -0
  10. package/src/common/blocks/BlockError.tsx +46 -0
  11. package/src/common/blocks/index.ts +34 -0
  12. package/src/common/blocks/schemas.ts +143 -0
  13. package/src/common/blocks/types.ts +76 -0
  14. package/src/common/chips/README.md +67 -0
  15. package/src/common/chips/UrlChip.tsx +105 -0
  16. package/src/common/chips/index.ts +20 -0
  17. package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
  18. package/src/common/link-preview/index.ts +12 -0
  19. package/src/common/link-preview/types.ts +44 -0
  20. package/src/common/tiptap/index.ts +10 -0
  21. package/src/common/tiptap/useNodeAttrs.ts +128 -0
  22. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
  23. package/src/tools/chat/README.md +109 -2
  24. package/src/tools/chat/composer/Composer.tsx +45 -12
  25. package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
  26. package/src/tools/chat/composer/PastedTextDialog.tsx +88 -0
  27. package/src/tools/chat/composer/index.ts +9 -0
  28. package/src/tools/chat/composer/textToAttachment.ts +69 -0
  29. package/src/tools/chat/composer/types.ts +13 -0
  30. package/src/tools/chat/composer/useComposerAttach.tsx +47 -0
  31. package/src/tools/chat/constants.ts +9 -2
  32. package/src/tools/chat/core/extractFirstUrl.ts +45 -0
  33. package/src/tools/chat/core/reducer.ts +32 -8
  34. package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
  35. package/src/tools/chat/core/transport/mock.ts +4 -1
  36. package/src/tools/chat/hooks/useChat.ts +20 -20
  37. package/src/tools/chat/lazy.tsx +10 -0
  38. package/src/tools/chat/messages/Attachments.tsx +335 -46
  39. package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
  40. package/src/tools/chat/messages/MessageBubble.tsx +175 -31
  41. package/src/tools/chat/messages/MessageList.tsx +107 -21
  42. package/src/tools/chat/messages/ToolCalls.tsx +28 -13
  43. package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
  44. package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
  45. package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
  46. package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
  47. package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
  48. package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
  49. package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
  50. package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
  51. package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
  52. package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
  53. package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
  54. package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
  55. package/src/tools/chat/messages/index.ts +13 -0
  56. package/src/tools/chat/public.ts +5 -1
  57. package/src/tools/chat/shell/ChatRoot.tsx +29 -0
  58. package/src/tools/chat/styles/bubbleTokens.ts +18 -0
  59. package/src/tools/chat/styles/index.ts +1 -0
  60. package/src/tools/chat/types/attachment.ts +38 -3
  61. package/src/tools/chat/types/block.ts +56 -9
  62. package/src/tools/chat/types/blockSchemas.ts +197 -0
  63. package/src/tools/chat/types/config.ts +33 -0
  64. package/src/tools/chat/types/events.ts +14 -7
  65. package/src/tools/chat/types/index.ts +4 -2
  66. package/src/tools/chat/types/message.ts +7 -0
  67. package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
  68. package/src/tools/data/Tree/README.md +30 -0
  69. package/src/tools/data/Tree/components/TreeRow.tsx +11 -2
  70. package/src/tools/data/Tree/context/menu/render.tsx +56 -27
  71. package/src/tools/data/Tree/data/appearance.ts +65 -3
  72. package/src/tools/data/Tree/data/index.ts +2 -0
  73. package/src/tools/data/Tree/index.tsx +2 -0
  74. package/src/tools/dev/Map/README.md +30 -345
  75. package/src/tools/dev/Map/cards/index.ts +6 -0
  76. package/src/tools/dev/Map/cards/types.ts +67 -0
  77. package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
  78. package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
  79. package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
  80. package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
  81. package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
  82. package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
  83. package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
  84. package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
  85. package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
  86. package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
  87. package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
  88. package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
  89. package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
  90. package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
  91. package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
  92. package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
  93. package/src/tools/dev/Map/components/MapControls.tsx +27 -0
  94. package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
  95. package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
  96. package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
  97. package/src/tools/dev/Map/components/index.ts +35 -1
  98. package/src/tools/dev/Map/geocode/index.ts +2 -0
  99. package/src/tools/dev/Map/geocode/photon.ts +265 -0
  100. package/src/tools/dev/Map/hooks/index.ts +37 -0
  101. package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
  102. package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
  103. package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
  104. package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
  105. package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
  106. package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
  107. package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
  108. package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
  109. package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
  110. package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
  111. package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
  112. package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
  113. package/src/tools/dev/Map/index.ts +59 -2
  114. package/src/tools/dev/Map/layers/index.ts +1 -0
  115. package/src/tools/dev/Map/layers/symbol.ts +93 -0
  116. package/src/tools/dev/Map/lazy.tsx +120 -0
  117. package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
  118. package/src/tools/dev/Map/styles/index.ts +62 -0
  119. package/src/tools/dev/Map/styles/terrain.ts +29 -0
  120. package/src/tools/dev/Map/types.ts +31 -3
  121. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
  122. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
  123. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
  124. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
  125. package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
  126. package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
  127. package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
  128. package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
  129. package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
  130. package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
  131. package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
  132. package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
  133. package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
  134. package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
  135. package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
  136. package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
  137. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
  138. package/src/tools/forms/MarkdownEditor/README.md +84 -0
  139. package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
  140. package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
  141. package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
  142. package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
  143. package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
  144. package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
  145. package/src/tools/forms/MarkdownEditor/index.ts +19 -0
  146. package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
  147. package/src/tools/forms/MarkdownEditor/styles.css +134 -2
  148. package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
  149. package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
  150. package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
  151. package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
  152. package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
  153. package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
  154. package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
  155. package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
  156. package/src/tools/forms/NotionEditor/README.md +109 -188
  157. package/src/tools/forms/NotionEditor/extensions.ts +15 -0
  158. package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
  159. package/src/tools/forms/NotionEditor/styles.css +25 -0
  160. package/src/tools/forms/NotionEditor/types.ts +9 -0
  161. package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
  162. package/src/tools/input/SpeechRecognition/types.ts +10 -0
  163. package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
  164. package/src/tools/media/ImageViewer/README.md +9 -0
  165. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
  166. package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
  167. package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
  168. package/src/tools/media/ImageViewer/components/index.ts +2 -0
  169. package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
  170. package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
  171. package/src/tools/media/ImageViewer/types.ts +10 -0
  172. package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
  173. package/src/tools/media/ImageViewer/utils/index.ts +4 -0
  174. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
  175. package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
  176. package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
  177. package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
  178. package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
  179. package/src/tools/visual/charts/SmoothLine/README.md +97 -0
  180. package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
  181. package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
  182. package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
  183. package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
  184. package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
  185. package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
  186. package/src/tools/visual/indicators/Fps/types.ts +1 -1
  187. package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
  188. 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 {
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+ import { FileText } from 'lucide-react';
5
+
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from '@djangocfg/ui-core/components';
12
+ import { cn } from '@djangocfg/ui-core/lib';
13
+
14
+ import { MarkdownMessage } from '../../dev/code/MarkdownMessage';
15
+
16
+ export interface PastedTextDialogProps {
17
+ /** Whether the dialog is open. */
18
+ open: boolean;
19
+ /** Open-state change handler (Esc / overlay click / close button). */
20
+ onOpenChange: (open: boolean) => void;
21
+ /** The raw pasted text to preview. */
22
+ text: string;
23
+ /** Short title shown in the header (the chip's derived title). */
24
+ title?: string;
25
+ }
26
+
27
+ /**
28
+ * Read-only preview for a "Pasted text" attachment chunk.
29
+ *
30
+ * Renders the raw paste through `MarkdownMessage` — the same chat-tuned
31
+ * renderer used for message bubbles, so fenced code blocks, tables and
32
+ * GFM all come out formatted, with the plain-text fast path kicking in
33
+ * for prose-only pastes. View-only by design: there is no edit affordance.
34
+ *
35
+ * Built on ui-core's radix `Dialog` primitive with local open-state — it
36
+ * does not touch the global `dialog-service` (that service is shaped for
37
+ * imperative string-message alerts/confirms, not arbitrary content). The
38
+ * caller owns `open` so the chip can drive it from a click.
39
+ */
40
+ export function PastedTextDialog({
41
+ open,
42
+ onOpenChange,
43
+ text,
44
+ title,
45
+ }: PastedTextDialogProps) {
46
+ // A rough size hint for the header (chars / KB) — cheap, derived.
47
+ const meta = useMemo(() => {
48
+ const chars = text.length;
49
+ const kb = new Blob([text]).size / 1024;
50
+ return kb >= 1
51
+ ? `${chars.toLocaleString()} chars · ${kb.toFixed(1)} KB`
52
+ : `${chars.toLocaleString()} chars`;
53
+ }, [text]);
54
+
55
+ return (
56
+ <Dialog open={open} onOpenChange={onOpenChange}>
57
+ <DialogContent
58
+ className={cn(
59
+ 'flex max-h-[80dvh] w-full max-w-2xl flex-col gap-3 p-0',
60
+ )}
61
+ // This is a read-only preview — there is no primary action to
62
+ // focus. radix would otherwise autofocus the first focusable node,
63
+ // which is the (visually hover-hidden) "Copy" button inside the
64
+ // markdown code block — surfacing a stray focus ring on open.
65
+ // Prevent the default and keep focus on the panel itself; Esc /
66
+ // overlay click still dismiss, and Tab reaches controls normally.
67
+ onOpenAutoFocus={(e) => e.preventDefault()}
68
+ >
69
+ <DialogHeader className="shrink-0 space-y-1 border-b border-border px-5 pb-3 pt-5 text-left">
70
+ <DialogTitle className="flex items-center gap-2 text-base">
71
+ <span className="grid size-7 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
72
+ <FileText aria-hidden className="size-4" />
73
+ </span>
74
+ <span className="min-w-0 truncate">{title ?? 'Pasted text'}</span>
75
+ </DialogTitle>
76
+ <p className="pl-9 text-xs text-muted-foreground">{meta}</p>
77
+ </DialogHeader>
78
+
79
+ {/* Scrollable body — `MarkdownMessage` formats the paste. */}
80
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">
81
+ <MarkdownMessage content={text} size="sm" />
82
+ </div>
83
+ </DialogContent>
84
+ </Dialog>
85
+ );
86
+ }
87
+
88
+ export default PastedTextDialog;
@@ -40,6 +40,15 @@ export {
40
40
  } from './useComposerAttach';
41
41
  export { useComposerAttachContext } from './AttachContext';
42
42
  export { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment';
43
+ export {
44
+ textToAttachment,
45
+ deriveTextTitle,
46
+ DEFAULT_PASTE_TEXT_THRESHOLD,
47
+ } from './textToAttachment';
48
+ export {
49
+ PastedTextDialog,
50
+ type PastedTextDialogProps,
51
+ } from './PastedTextDialog';
43
52
  export {
44
53
  parseSlashState,
45
54
  filterCommands,
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `string → ChatAttachment` — the producer for "Pasted text" chunks.
3
+ *
4
+ * When a long paste is converted into an attachment chip instead of
5
+ * being dumped into the textarea (ChatGPT/Claude behaviour), the raw
6
+ * text is minted into a `type:'text'` attachment. Its payload lives in
7
+ * `text` (exact, newline-preserving); `url` is empty since there is no
8
+ * blob backing it.
9
+ *
10
+ * Mirrors `fileToAttachment` so the picker, drag-drop, file-paste and
11
+ * text-paste paths all funnel into the same `ChatAttachment` shape.
12
+ */
13
+
14
+ import { createId } from '../core/ids';
15
+ import type { ChatAttachment } from '../types';
16
+
17
+ /** Default character threshold above which a paste becomes a chunk. */
18
+ export const DEFAULT_PASTE_TEXT_THRESHOLD = 2000;
19
+
20
+ /** Max length of the auto-derived title (first non-empty line). */
21
+ const TITLE_MAX = 32;
22
+
23
+ /**
24
+ * Derive a short, human-readable title from the pasted text — the first
25
+ * non-empty line, collapsed and ellipsised (e.g. "Valuing the Bali Vec…").
26
+ * Falls back to a generic label for whitespace-only / empty input.
27
+ */
28
+ export function deriveTextTitle(text: string): string {
29
+ const firstLine = text
30
+ .split(/\r?\n/)
31
+ .map((l) => l.trim())
32
+ .find((l) => l.length > 0);
33
+ if (!firstLine) return 'Pasted text';
34
+ // Strip leading markdown markers (heading `#`, blockquote `>`, list
35
+ // bullets `-`/`*`/`+`) so a markdown paste reads as plain text on the
36
+ // chip — the title is a label, not source. The payload is untouched.
37
+ const stripped = firstLine
38
+ .replace(/^#{1,6}\s+/, '')
39
+ .replace(/^>\s+/, '')
40
+ .replace(/^[-*+]\s+/, '')
41
+ .trim();
42
+ // Collapse internal runs of whitespace so a title never wraps oddly.
43
+ const collapsed = (stripped || firstLine).replace(/\s+/g, ' ');
44
+ return collapsed.length > TITLE_MAX
45
+ ? `${collapsed.slice(0, TITLE_MAX).trimEnd()}…`
46
+ : collapsed;
47
+ }
48
+
49
+ /**
50
+ * Convert a pasted string into a `type:'text'` `ChatAttachment`.
51
+ *
52
+ * - `name` — short title derived from the first line (shown on the chip).
53
+ * - `text` — the full, exact payload (sent to `onSubmit` alongside the
54
+ * message content).
55
+ * - `url` — empty: text chunks have no blob backing.
56
+ * - `sizeBytes` — UTF-8 byte length, for an optional "N chars / KB" hint.
57
+ */
58
+ export function textToAttachment(text: string): ChatAttachment {
59
+ return {
60
+ id: createId('att'),
61
+ type: 'text',
62
+ url: '',
63
+ name: deriveTextTitle(text),
64
+ mimeType: 'text/plain',
65
+ sizeBytes: new Blob([text]).size,
66
+ status: 'ready',
67
+ text,
68
+ };
69
+ }
@@ -198,6 +198,19 @@ export interface ComposerAttachConfig {
198
198
  pasteEnabled?: boolean;
199
199
  /** Called when a file is rejected (size / type / count). */
200
200
  onReject?: (file: File, reason: 'size' | 'type' | 'count') => void;
201
+ /**
202
+ * Convert a long plain-text paste (⌘V / Ctrl+V) into a "Pasted text"
203
+ * attachment chip instead of dumping it into the textarea — the
204
+ * ChatGPT/Claude behaviour. Default `true`. Short pastes (below
205
+ * {@link pasteTextThreshold}) always go straight into the textarea.
206
+ */
207
+ pasteTextAsChunk?: boolean;
208
+ /**
209
+ * Character threshold above which a plain-text paste becomes a chunk.
210
+ * Default `2000` (matches Claude / LibreChat). Ignored when
211
+ * `pasteTextAsChunk` is `false`.
212
+ */
213
+ pasteTextThreshold?: number;
201
214
  }
202
215
 
203
216
  /** Imperative handle the attach pipeline exposes to the composer + menu. */
@@ -5,6 +5,10 @@ import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react';
5
5
  import { buildAcceptString, useClipboardPaste } from '../../forms/Uploader';
6
6
  import type { UseChatComposerReturn } from '../hooks/useChatComposer';
7
7
  import { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment';
8
+ import {
9
+ DEFAULT_PASTE_TEXT_THRESHOLD,
10
+ textToAttachment,
11
+ } from './textToAttachment';
8
12
  import type {
9
13
  ComposerAcceptType,
10
14
  ComposerAttachConfig,
@@ -59,6 +63,8 @@ export function useComposerAttach({
59
63
  maxSizeBytes,
60
64
  multiple = true,
61
65
  pasteEnabled = true,
66
+ pasteTextAsChunk = true,
67
+ pasteTextThreshold = DEFAULT_PASTE_TEXT_THRESHOLD,
62
68
  uploadFn,
63
69
  onReject,
64
70
  } = config;
@@ -187,6 +193,47 @@ export function useComposerAttach({
187
193
  pasteScopeRef,
188
194
  );
189
195
 
196
+ // Long-text paste → "Pasted text" chunk. A separate listener from the
197
+ // file path above: `useClipboardPaste` deliberately ignores pastes
198
+ // that carry text into a text field (so it doesn't hijack typing), so
199
+ // it never sees this case. We intercept here, before the browser
200
+ // inserts the text into the textarea, and mint a `type:'text'`
201
+ // attachment instead — the ChatGPT/Claude behaviour.
202
+ const pasteTextEnabled = pasteEnabled && pasteTextAsChunk && !disabled;
203
+ useEffect(() => {
204
+ if (!pasteTextEnabled) return undefined;
205
+ const target: HTMLElement | Document = pasteScopeRef?.current ?? document;
206
+
207
+ const onPaste = (e: Event) => {
208
+ const ev = e as ClipboardEvent;
209
+ const cd = ev.clipboardData;
210
+ if (!cd) return;
211
+ // Only chunk pastes aimed at the composer's editable surface —
212
+ // a paste into some unrelated input inside the scope is left alone.
213
+ const node = ev.target;
214
+ const el =
215
+ node instanceof HTMLElement ? node : null;
216
+ const isEditable =
217
+ el != null &&
218
+ (el instanceof HTMLTextAreaElement ||
219
+ el instanceof HTMLInputElement ||
220
+ el.isContentEditable);
221
+ if (!isEditable) return;
222
+ // A file/blob paste is the image path's job — don't steal it.
223
+ if (Array.from(cd.items).some((i) => i.kind === 'file')) return;
224
+ const text = cd.getData('text/plain');
225
+ if (text.length < pasteTextThreshold) return;
226
+ // Respect the file-count cap (text chunks share the attachment list).
227
+ const c = composerRef.current;
228
+ if (maxFiles != null && c.attachments.length >= maxFiles) return;
229
+ e.preventDefault();
230
+ c.addAttachment(textToAttachment(text));
231
+ };
232
+
233
+ target.addEventListener('paste', onPaste);
234
+ return () => target.removeEventListener('paste', onPaste);
235
+ }, [pasteTextEnabled, pasteTextThreshold, maxFiles, pasteScopeRef]);
236
+
190
237
  // On unmount, free any object-URLs still held by staged attachments
191
238
  // (drafts abandoned without sending). `composerRef` gives the latest
192
239
  // list without re-running the effect on every attach.
@@ -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,