@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
@@ -1,10 +1,21 @@
1
1
  'use client';
2
2
 
3
- import { type ReactNode } from 'react';
4
- import { File as FileIcon, X } from 'lucide-react';
3
+ import {
4
+ createContext,
5
+ type ReactNode,
6
+ useCallback,
7
+ useContext,
8
+ useMemo,
9
+ useState,
10
+ } from 'react';
11
+ import { File as FileGlyph, FileText, X } from 'lucide-react';
5
12
 
13
+ import { Dialog, DialogContent, DialogTitle } from '@djangocfg/ui-core/components';
6
14
  import { cn } from '@djangocfg/ui-core/lib';
7
15
 
16
+ import { formatFileSize } from '../../forms/Uploader';
17
+ import { LazyImageViewer } from '../../media/ImageViewer/lazy';
18
+ import { PastedTextDialog } from '../composer/PastedTextDialog';
8
19
  import { useChatDestructiveStyles } from '../styles';
9
20
  import type { ChatAttachment } from '../types';
10
21
 
@@ -19,6 +30,7 @@ export interface AttachmentRendererArgs {
19
30
  export type AttachmentRenderer = (args: AttachmentRendererArgs) => ReactNode;
20
31
 
21
32
  export interface AttachmentRendererMap {
33
+ text?: AttachmentRenderer;
22
34
  image?: AttachmentRenderer;
23
35
  audio?: AttachmentRenderer;
24
36
  video?: AttachmentRenderer;
@@ -36,6 +48,118 @@ interface CommonProps {
36
48
  className?: string;
37
49
  }
38
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // Default image lightbox — image tiles open the shared `ImageViewer` in a
53
+ // dialog by default. All image attachments of the SAME attachments group feed
54
+ // the viewer's `images` array, so the lightbox has prev/next across the
55
+ // message's images and opens at the clicked index.
56
+ //
57
+ // Composition with a host `onClick`: the lightbox owns IMAGES, the host
58
+ // `onClick` owns NON-IMAGE files. A host `onClick` does NOT suppress the image
59
+ // lightbox — clicking an image opens the gallery, clicking a file fires the
60
+ // host handler (e.g. reveal-in-file-manager). See `useImageTileClick`.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ interface ImageLightboxContextValue {
64
+ /** Opens the default lightbox at the given attachment id. */
65
+ openImage: (attachmentId: string) => void;
66
+ }
67
+
68
+ const ImageLightboxContext = createContext<ImageLightboxContextValue | null>(null);
69
+
70
+ /**
71
+ * Provides a single shared lightbox dialog for a group of attachments and
72
+ * renders it once. Image tiles call `openImage(id)` to open at their index.
73
+ * Active only when `enabled` (i.e. the host did NOT wire `onClick`).
74
+ */
75
+ function ImageLightboxProvider({
76
+ attachments,
77
+ enabled,
78
+ children,
79
+ }: {
80
+ attachments: ChatAttachment[];
81
+ enabled: boolean;
82
+ children: ReactNode;
83
+ }) {
84
+ const [open, setOpen] = useState(false);
85
+ const [index, setIndex] = useState(0);
86
+
87
+ // Only image attachments seed the viewer — non-image files are skipped, so
88
+ // the gallery index maps onto the image subset (not the full attachment list).
89
+ const images = useMemo(
90
+ () => attachments.filter((a) => a.type === 'image'),
91
+ [attachments],
92
+ );
93
+
94
+ const openImage = useCallback(
95
+ (attachmentId: string) => {
96
+ const i = images.findIndex((a) => a.id === attachmentId);
97
+ if (i < 0) return;
98
+ setIndex(i);
99
+ setOpen(true);
100
+ },
101
+ [images],
102
+ );
103
+
104
+ const ctx = useMemo<ImageLightboxContextValue>(() => ({ openImage }), [openImage]);
105
+
106
+ if (!enabled || images.length === 0) return <>{children}</>;
107
+
108
+ return (
109
+ <ImageLightboxContext.Provider value={ctx}>
110
+ {children}
111
+ <Dialog open={open} onOpenChange={setOpen}>
112
+ {/* Borderless, full-bleed lightbox: the viewer paints its own dark
113
+ backdrop + idle-fading chrome, so the dialog provides only the
114
+ blurred overlay. No card chrome, no built-in close (the viewer's
115
+ macOS-grade close button + Esc own dismissal). */}
116
+ <DialogContent
117
+ fullscreen
118
+ closeButton={false}
119
+ className="border-0 bg-transparent p-0 shadow-none"
120
+ >
121
+ <DialogTitle className="sr-only">
122
+ Image {index + 1} of {images.length}
123
+ </DialogTitle>
124
+ {open && (
125
+ <LazyImageViewer
126
+ images={images.map((a) => ({
127
+ file: { name: a.name ?? 'image', path: a.url },
128
+ src: a.url,
129
+ }))}
130
+ initialIndex={index}
131
+ lightbox
132
+ autoFocus
133
+ onRequestClose={() => setOpen(false)}
134
+ />
135
+ )}
136
+ </DialogContent>
137
+ </Dialog>
138
+ </ImageLightboxContext.Provider>
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Resolves the click handler for an attachment tile, splitting ownership by
144
+ * type:
145
+ * - IMAGE → the default lightbox wins when its provider is mounted (so the
146
+ * gallery opens with prev/next across the message's images), regardless of
147
+ * a host `onClick`. Falls back to the host `onClick` only if no provider.
148
+ * - NON-IMAGE → the host `onClick` (e.g. reveal-in-file-manager).
149
+ * This lets a host wire `onClick` for files without suppressing the image
150
+ * lightbox (the two no longer fight over the same group-level flag).
151
+ */
152
+ function useImageTileClick(
153
+ attachment: ChatAttachment,
154
+ hostOnClick?: () => void,
155
+ ): (() => void) | undefined {
156
+ const lightbox = useContext(ImageLightboxContext);
157
+ if (attachment.type === 'image' && lightbox) {
158
+ return () => lightbox.openImage(attachment.id);
159
+ }
160
+ return hostOnClick;
161
+ }
162
+
39
163
  // ---------------------------------------------------------------------------
40
164
  // AttachmentsGrid — flex-wrap, ideal for thumbnails / file chips.
41
165
  // ---------------------------------------------------------------------------
@@ -56,22 +180,27 @@ export function AttachmentsGrid({
56
180
  if (!attachments?.length) return null;
57
181
  const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments;
58
182
  return (
59
- <div
60
- className={cn(
61
- layout === 'grid' ? 'grid grid-cols-3 gap-2' : 'flex flex-wrap gap-2',
62
- className,
63
- )}
64
- >
65
- {visible.map((a) => (
66
- <AttachmentTile
67
- key={a.id}
68
- attachment={a}
69
- isInComposer={isInComposer}
70
- onClick={onClick ? () => onClick(a) : undefined}
71
- onRemove={onRemove ? () => onRemove(a) : undefined}
72
- />
73
- ))}
74
- </div>
183
+ // Lightbox owns images even when the host wired `onClick` (that handler is
184
+ // for non-image files). `enabled` only gates the no-images case inside the
185
+ // provider, so passing `true` is safe see `useImageTileClick`.
186
+ <ImageLightboxProvider attachments={attachments} enabled>
187
+ <div
188
+ className={cn(
189
+ layout === 'grid' ? 'grid grid-cols-3 gap-2' : 'flex flex-wrap gap-2',
190
+ className,
191
+ )}
192
+ >
193
+ {visible.map((a) => (
194
+ <AttachmentTile
195
+ key={a.id}
196
+ attachment={a}
197
+ isInComposer={isInComposer}
198
+ onClick={onClick ? () => onClick(a) : undefined}
199
+ onRemove={onRemove ? () => onRemove(a) : undefined}
200
+ />
201
+ ))}
202
+ </div>
203
+ </ImageLightboxProvider>
75
204
  );
76
205
  }
77
206
 
@@ -97,26 +226,30 @@ export function AttachmentsList({
97
226
  if (!attachments?.length) return null;
98
227
  const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments;
99
228
  return (
100
- <div className={cn('flex w-full flex-col gap-2', className)}>
101
- {visible.map((a) => {
102
- const renderer = renderers?.[a.type] ?? renderers?.default;
103
- const args: AttachmentRendererArgs = {
104
- attachment: a,
105
- isInComposer,
106
- onClick: onClick ? () => onClick(a) : undefined,
107
- onRemove: onRemove ? () => onRemove(a) : undefined,
108
- };
109
- if (renderer) {
110
- return (
111
- <div key={a.id} className="relative w-full min-w-0">
112
- {renderer(args)}
113
- {args.onRemove ? <RemoveBtn onRemove={args.onRemove} /> : null}
114
- </div>
115
- );
116
- }
117
- return <AttachmentTile key={a.id} {...args} />;
118
- })}
119
- </div>
229
+ // Lightbox owns images even when the host wired `onClick` (that handler is
230
+ // for non-image files). See `useImageTileClick` + AttachmentsGrid note.
231
+ <ImageLightboxProvider attachments={attachments} enabled>
232
+ <div className={cn('flex w-full flex-col gap-2', className)}>
233
+ {visible.map((a) => {
234
+ const renderer = renderers?.[a.type] ?? renderers?.default;
235
+ const args: AttachmentRendererArgs = {
236
+ attachment: a,
237
+ isInComposer,
238
+ onClick: onClick ? () => onClick(a) : undefined,
239
+ onRemove: onRemove ? () => onRemove(a) : undefined,
240
+ };
241
+ if (renderer) {
242
+ return (
243
+ <div key={a.id} className="relative w-full min-w-0">
244
+ {renderer(args)}
245
+ {args.onRemove ? <RemoveBtn onRemove={args.onRemove} /> : null}
246
+ </div>
247
+ );
248
+ }
249
+ return <AttachmentTile key={a.id} {...args} />;
250
+ })}
251
+ </div>
252
+ </ImageLightboxProvider>
120
253
  );
121
254
  }
122
255
 
@@ -143,6 +276,28 @@ export function Attachments(props: AttachmentsProps) {
143
276
  // ---------------------------------------------------------------------------
144
277
 
145
278
  function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArgs) {
279
+ // Host `onClick` wins; otherwise image tiles fall back to the default
280
+ // lightbox (when an `ImageLightboxProvider` is mounted). Called before any
281
+ // early return to keep hook order stable. Non-image files stay interactive
282
+ // only when the host wired `onClick`.
283
+ const effectiveOnClick = useImageTileClick(attachment, onClick);
284
+
285
+ // "Pasted text" chunks are self-contained — they open their own preview
286
+ // dialog on click rather than going through the generic `onClick`.
287
+ if (attachment.type === 'text') {
288
+ return <TextChunkTile attachment={attachment} onRemove={onRemove} />;
289
+ }
290
+
291
+ // Rich-preview bag present → render the compact localpreview card
292
+ // (thumbnail + title + label/value fields). Absent → fall through to the
293
+ // existing name+thumbnail chip. Per-type `renderers` overrides still win
294
+ // upstream (AttachmentsList resolves them before reaching this default).
295
+ if (attachment.preview) {
296
+ return (
297
+ <AttachmentPreviewCard attachment={attachment} onClick={onClick} onRemove={onRemove} />
298
+ );
299
+ }
300
+
146
301
  const isImage = attachment.type === 'image';
147
302
  const isUploading = attachment.status === 'uploading';
148
303
 
@@ -150,27 +305,48 @@ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArg
150
305
  <img
151
306
  src={attachment.thumbnailUrl ?? attachment.url}
152
307
  alt={attachment.name ?? 'attachment'}
153
- className="h-16 w-16 rounded-md object-cover"
308
+ // `--radius-control` (~10px) — the design-system token for value
309
+ // chips / controls (see ui-core base.css). Keeps every chip rounded
310
+ // consistently, a touch softer than the old `rounded-md`.
311
+ className="h-16 w-16 rounded-[var(--radius-control)] object-cover"
154
312
  loading="lazy"
155
313
  />
156
314
  ) : (
157
- <div className="flex max-w-44 items-center gap-2 rounded-md border border-border bg-background/60 px-2 py-1.5 text-xs">
158
- <FileIcon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
159
- <span className="truncate">{attachment.name ?? 'file'}</span>
315
+ // Two-line file chip: name on top, formatted size on the meta line
316
+ // (Telegram/Slack convention — "report.pdf" / "1.2 KB"). The size row
317
+ // is omitted when the byte count is unknown so the chip stays single-line.
318
+ <div className="flex max-w-44 items-center gap-2 rounded-[var(--radius-control)] border border-border bg-background/60 px-2 py-1.5 text-xs">
319
+ <FileGlyph aria-hidden className="size-4 shrink-0 text-muted-foreground" />
320
+ <span className="flex min-w-0 flex-col items-start text-left">
321
+ <span className="w-full truncate">{attachment.name ?? 'file'}</span>
322
+ {attachment.sizeBytes != null && attachment.sizeBytes > 0 ? (
323
+ <span className="w-full truncate text-[11px] text-muted-foreground">
324
+ {formatFileSize(attachment.sizeBytes)}
325
+ </span>
326
+ ) : null}
327
+ </span>
160
328
  </div>
161
329
  );
162
330
 
163
331
  return (
164
332
  <div className="relative">
165
- {onClick ? (
166
- <button type="button" onClick={onClick} className="block">
333
+ {effectiveOnClick ? (
334
+ // Interactive when the host wired `onClick` OR (for images) the
335
+ // default lightbox is active — the chip reads as clickable (cursor +
336
+ // hover lift + focus ring) and is a real keyboard-focusable button.
337
+ <button
338
+ type="button"
339
+ onClick={effectiveOnClick}
340
+ aria-label={isImage ? `Open image: ${attachment.name ?? 'image'}` : undefined}
341
+ className="block cursor-pointer rounded-[var(--radius-control)] transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
342
+ >
167
343
  {inner}
168
344
  </button>
169
345
  ) : (
170
346
  inner
171
347
  )}
172
348
  {isUploading ? (
173
- <div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-background/70 text-[10px] font-medium">
349
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[var(--radius-control)] bg-background/70 text-[10px] font-medium">
174
350
  {attachment.progress != null ? `${Math.round(attachment.progress * 100)}%` : '…'}
175
351
  </div>
176
352
  ) : null}
@@ -179,6 +355,119 @@ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArg
179
355
  );
180
356
  }
181
357
 
358
+ /**
359
+ * Compact rich-preview card driven by `attachment.preview` (the serializable
360
+ * `AttachmentPreview` bag). Renders a thumbnail (cover / first page), a title,
361
+ * and an ordered label/value field list (duration, dimensions, pages, EXIF…).
362
+ * Kind-agnostic: the same layout serves audio/image/pdf/doc — the host fills
363
+ * `preview.fields` per media kind. Clickable only when the host wired `onClick`.
364
+ */
365
+ function AttachmentPreviewCard({
366
+ attachment,
367
+ onClick,
368
+ onRemove,
369
+ }: {
370
+ attachment: ChatAttachment;
371
+ onClick?: () => void;
372
+ onRemove?: () => void;
373
+ }) {
374
+ const preview = attachment.preview!;
375
+ const title = preview.title ?? attachment.name ?? 'attachment';
376
+ const fields = preview.fields ?? [];
377
+
378
+ const card = (
379
+ <div className="flex max-w-72 items-center gap-3 rounded-[var(--radius-control)] border border-border bg-background/60 px-2.5 py-2 text-left">
380
+ {preview.thumbnail ? (
381
+ <img
382
+ src={preview.thumbnail}
383
+ alt={title}
384
+ className="h-12 w-12 shrink-0 rounded-md object-cover"
385
+ loading="lazy"
386
+ />
387
+ ) : (
388
+ <span className="grid size-12 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
389
+ <FileGlyph aria-hidden className="size-5" />
390
+ </span>
391
+ )}
392
+ <span className="flex min-w-0 flex-col gap-0.5">
393
+ <span className="truncate text-xs font-medium">{title}</span>
394
+ {fields.slice(0, 3).map((f, i) => (
395
+ <span
396
+ key={`${f.label}-${i}`}
397
+ className="truncate text-[11px] text-muted-foreground"
398
+ >
399
+ <span className="font-medium">{f.label}:</span> {f.value}
400
+ </span>
401
+ ))}
402
+ </span>
403
+ </div>
404
+ );
405
+
406
+ return (
407
+ <div className="relative">
408
+ {onClick ? (
409
+ <button
410
+ type="button"
411
+ onClick={onClick}
412
+ className="block cursor-pointer rounded-[var(--radius-control)] transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
413
+ >
414
+ {card}
415
+ </button>
416
+ ) : (
417
+ card
418
+ )}
419
+ {onRemove ? <RemoveBtn onRemove={onRemove} /> : null}
420
+ </div>
421
+ );
422
+ }
423
+
424
+ /**
425
+ * Tile for a `type:'text'` chunk — a two-line "Pasted text" chip in the
426
+ * same surface style as file chips (title + "Pasted text" sublabel).
427
+ * Clicking it opens a read-only `PastedTextDialog` preview of the full
428
+ * payload. Mirrors ChatGPT's pasted-text card + click-to-expand.
429
+ */
430
+ function TextChunkTile({
431
+ attachment,
432
+ onRemove,
433
+ }: {
434
+ attachment: ChatAttachment;
435
+ onRemove?: () => void;
436
+ }) {
437
+ const [open, setOpen] = useState(false);
438
+ const title = attachment.name ?? 'Pasted text';
439
+ return (
440
+ <div className="relative">
441
+ <button
442
+ type="button"
443
+ onClick={() => setOpen(true)}
444
+ aria-label={`Preview ${title}`}
445
+ className="block cursor-pointer rounded-[var(--radius-control)] text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
446
+ >
447
+ <div className="flex max-w-56 items-center gap-2 rounded-[var(--radius-control)] border border-border bg-background/60 px-2 py-1.5 transition-colors hover:bg-background">
448
+ {/* Inner glyph plate keeps the tighter `rounded-md` — a nested
449
+ surface should round a notch less than its container
450
+ (concentric radii) so the corners read cleanly. */}
451
+ <span className="grid size-7 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
452
+ <FileText aria-hidden className="size-4" />
453
+ </span>
454
+ <span className="flex min-w-0 flex-col">
455
+ <span className="truncate text-xs font-medium">{title}</span>
456
+ <span className="truncate text-[11px] text-muted-foreground">Pasted text</span>
457
+ </span>
458
+ </div>
459
+ </button>
460
+ {onRemove ? <RemoveBtn onRemove={onRemove} /> : null}
461
+ <PastedTextDialog
462
+ open={open}
463
+ onOpenChange={setOpen}
464
+ text={attachment.text ?? ''}
465
+ title={title}
466
+ />
467
+ </div>
468
+ );
469
+ }
470
+
182
471
  function RemoveBtn({ onRemove }: { onRemove: () => void }) {
183
472
  const styles = useChatDestructiveStyles();
184
473
  return (
@@ -187,7 +476,7 @@ function RemoveBtn({ onRemove }: { onRemove: () => void }) {
187
476
  aria-label="Remove attachment"
188
477
  onClick={onRemove}
189
478
  className={cn(
190
- 'absolute -right-1.5 -top-1.5 grid h-4 w-4 place-items-center rounded-full border border-border bg-background text-muted-foreground',
479
+ 'absolute -right-1.5 -top-1.5 grid h-4 w-4 cursor-pointer place-items-center rounded-full border border-border bg-background text-muted-foreground',
191
480
  styles.hoverStrong,
192
481
  )}
193
482
  >
@@ -0,0 +1,203 @@
1
+ 'use client';
2
+
3
+ import { Fragment, memo, useMemo, type ReactNode } from 'react';
4
+ import { Copy, Pencil, RefreshCw, Trash } from 'lucide-react';
5
+
6
+ import {
7
+ ContextMenu,
8
+ ContextMenuContent,
9
+ ContextMenuItem,
10
+ ContextMenuSeparator,
11
+ ContextMenuTrigger,
12
+ } from '@djangocfg/ui-core/components';
13
+
14
+ import type { ChatMessage, ChatRole } from '../types';
15
+
16
+ /**
17
+ * A single entry in a message bubble's right-click / long-press context
18
+ * menu. Typed + serializable-ish (icon is a ReactNode, the rest is data).
19
+ *
20
+ * Hosts inject extra items — "Reveal in Finder", "Copy as markdown",
21
+ * "Inspect" — by passing `bubbleMenuItems` / `getBubbleMenuItems` on
22
+ * `<MessageBubble>` (and the matching `<ChatRoot messages.bubbleMenu*>`).
23
+ * The built-in Copy / Regenerate / Delete actions are produced as default
24
+ * items by `buildDefaultBubbleMenuItems` so a host can keep, reorder, or
25
+ * fully replace them.
26
+ */
27
+ export interface BubbleMenuItem {
28
+ /** Stable id — used as the React key. */
29
+ id: string;
30
+ label: string;
31
+ icon?: ReactNode;
32
+ /** Fires on click / Enter. Receives the message the menu opened on. */
33
+ onSelect: (message: ChatMessage) => void;
34
+ /** Red destructive styling (e.g. Delete). */
35
+ danger?: boolean;
36
+ /** Render a divider BEFORE this item. */
37
+ separator?: boolean;
38
+ disabled?: boolean;
39
+ }
40
+
41
+ /**
42
+ * Handlers the default Copy / Regenerate / Delete items wire to. Mirrors
43
+ * the existing `<MessageBubble>` props so a host can keep passing the same
44
+ * callbacks it always did and get a context menu instead of a button row.
45
+ *
46
+ * Role-gated to match the old button row: Regenerate shows only on
47
+ * assistant turns, Edit only on user turns, and items whose handler is
48
+ * absent are dropped.
49
+ */
50
+ export interface DefaultBubbleMenuHandlers {
51
+ role: ChatRole;
52
+ onCopy?: () => void;
53
+ onRegenerate?: () => void;
54
+ /**
55
+ * Fires the host's composer-edit trigger for a USER message — gated to
56
+ * `role === 'user'` (mirrors the assistant-only Regenerate gate). This
57
+ * does NOT edit in the bubble: the item just hands the message to the
58
+ * host, which loads its text into the real composer and re-runs the turn
59
+ * via `chat.editMessage` on submit. Absent → no Edit item.
60
+ */
61
+ onEdit?: () => void;
62
+ onDelete?: () => void;
63
+ }
64
+
65
+ /**
66
+ * Build the built-in Copy / Regenerate / Delete items as `BubbleMenuItem`s.
67
+ * Hosts compose these with their own extras (see `composeBubbleMenuItems`)
68
+ * — keep them, drop one, reorder, or ignore them entirely and pass a fully
69
+ * custom list. An item is omitted when its handler is undefined; Regenerate
70
+ * is additionally gated to assistant turns.
71
+ */
72
+ export function buildDefaultBubbleMenuItems({
73
+ role,
74
+ onCopy,
75
+ onRegenerate,
76
+ onEdit,
77
+ onDelete,
78
+ }: DefaultBubbleMenuHandlers): BubbleMenuItem[] {
79
+ const items: BubbleMenuItem[] = [];
80
+ if (onCopy) {
81
+ items.push({
82
+ id: 'copy',
83
+ label: 'Copy',
84
+ icon: <Copy aria-hidden className="size-4" />,
85
+ onSelect: () => onCopy(),
86
+ });
87
+ }
88
+ if (onRegenerate && role === 'assistant') {
89
+ items.push({
90
+ id: 'regenerate',
91
+ label: 'Regenerate',
92
+ icon: <RefreshCw aria-hidden className="size-4" />,
93
+ onSelect: () => onRegenerate(),
94
+ });
95
+ }
96
+ // Edit — user turns only (mirrors the assistant-only Regenerate gate).
97
+ // Fires the host trigger; the actual edit happens in the composer.
98
+ if (onEdit && role === 'user') {
99
+ items.push({
100
+ id: 'edit',
101
+ label: 'Edit',
102
+ icon: <Pencil aria-hidden className="size-4" />,
103
+ onSelect: () => onEdit(),
104
+ });
105
+ }
106
+ if (onDelete) {
107
+ items.push({
108
+ id: 'delete',
109
+ label: 'Delete',
110
+ icon: <Trash aria-hidden className="size-4" />,
111
+ onSelect: () => onDelete(),
112
+ danger: true,
113
+ // Divide built-in destructive action from the rest only when it
114
+ // isn't the very first item (e.g. a copy-only user bubble).
115
+ separator: items.length > 0,
116
+ });
117
+ }
118
+ return items;
119
+ }
120
+
121
+ /**
122
+ * Merge the built-in default items with host-provided extras. Defaults
123
+ * come first, then a divider, then host items — unless the host's first
124
+ * extra already declares its own `separator`. Either side may be empty.
125
+ */
126
+ export function composeBubbleMenuItems(
127
+ defaults: BubbleMenuItem[],
128
+ hostItems?: BubbleMenuItem[],
129
+ ): BubbleMenuItem[] {
130
+ if (!hostItems?.length) return defaults;
131
+ if (!defaults.length) return hostItems;
132
+ const [first, ...restHost] = hostItems;
133
+ const bridged: BubbleMenuItem = first.separator ? first : { ...first, separator: true };
134
+ return [...defaults, bridged, ...restHost];
135
+ }
136
+
137
+ export interface BubbleContextMenuProps {
138
+ message: ChatMessage;
139
+ items: BubbleMenuItem[];
140
+ children: ReactNode;
141
+ /** Wrapper className (defaults to `contents` — adds no layout box). */
142
+ className?: string;
143
+ }
144
+
145
+ /**
146
+ * BubbleContextMenu — wraps a bubble in a Radix context menu (right-click
147
+ * + touch long-press, Esc / arrow-key accessible, all handled by the
148
+ * primitive). Renders `items` as menu rows.
149
+ *
150
+ * No items → renders `children` bare (no trigger overhead). The wrapper is
151
+ * `display:contents` by default so it never perturbs the bubble's layout.
152
+ */
153
+ function BubbleContextMenuInner({
154
+ message,
155
+ items,
156
+ children,
157
+ className = 'contents',
158
+ }: BubbleContextMenuProps) {
159
+ if (items.length === 0) return <>{children}</>;
160
+ return (
161
+ <ContextMenu>
162
+ <ContextMenuTrigger className={className}>{children}</ContextMenuTrigger>
163
+ <ContextMenuContent className="min-w-40">
164
+ {items.map((item) => (
165
+ <Fragment key={item.id}>
166
+ {item.separator ? <ContextMenuSeparator /> : null}
167
+ <ContextMenuItem
168
+ variant={item.danger ? 'destructive' : 'default'}
169
+ disabled={item.disabled}
170
+ onSelect={() => item.onSelect(message)}
171
+ >
172
+ {item.icon}
173
+ {item.label}
174
+ </ContextMenuItem>
175
+ </Fragment>
176
+ ))}
177
+ </ContextMenuContent>
178
+ </ContextMenu>
179
+ );
180
+ }
181
+
182
+ export const BubbleContextMenu = memo(BubbleContextMenuInner);
183
+ BubbleContextMenu.displayName = 'BubbleContextMenu';
184
+
185
+ /**
186
+ * Resolve the effective menu items for a bubble — combines a static list
187
+ * and/or a per-message builder with the built-in defaults. Used by
188
+ * `<MessageBubble>` so callers can pass any of:
189
+ * - nothing → defaults only
190
+ * - `bubbleMenuItems` (static host extras) → defaults + extras
191
+ * - `getBubbleMenuItems(message, defaults)` → host owns the full list
192
+ */
193
+ export function useResolvedBubbleMenuItems(
194
+ message: ChatMessage,
195
+ defaults: BubbleMenuItem[],
196
+ bubbleMenuItems?: BubbleMenuItem[],
197
+ getBubbleMenuItems?: (message: ChatMessage, defaults: BubbleMenuItem[]) => BubbleMenuItem[],
198
+ ): BubbleMenuItem[] {
199
+ return useMemo(() => {
200
+ if (getBubbleMenuItems) return getBubbleMenuItems(message, defaults);
201
+ return composeBubbleMenuItems(defaults, bubbleMenuItems);
202
+ }, [message, defaults, bubbleMenuItems, getBubbleMenuItems]);
203
+ }