@djangocfg/ui-tools 2.1.431 → 2.1.433

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/package.json +28 -14
  2. package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
  3. package/src/common/CodePanelHeader/index.ts +1 -0
  4. package/src/common/blocks/BlockError.tsx +46 -0
  5. package/src/common/blocks/index.ts +34 -0
  6. package/src/common/blocks/schemas.ts +143 -0
  7. package/src/common/blocks/types.ts +76 -0
  8. package/src/common/chips/README.md +67 -0
  9. package/src/common/chips/UrlChip.tsx +105 -0
  10. package/src/common/chips/index.ts +20 -0
  11. package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
  12. package/src/common/link-preview/index.ts +12 -0
  13. package/src/common/link-preview/types.ts +44 -0
  14. package/src/common/tiptap/index.ts +10 -0
  15. package/src/common/tiptap/useNodeAttrs.ts +128 -0
  16. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
  17. package/src/tools/chat/README.md +88 -2
  18. package/src/tools/chat/composer/Composer.tsx +45 -12
  19. package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
  20. package/src/tools/chat/constants.ts +9 -2
  21. package/src/tools/chat/core/extractFirstUrl.ts +45 -0
  22. package/src/tools/chat/core/reducer.ts +32 -8
  23. package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
  24. package/src/tools/chat/core/transport/mock.ts +4 -1
  25. package/src/tools/chat/hooks/useChat.ts +20 -20
  26. package/src/tools/chat/lazy.tsx +10 -0
  27. package/src/tools/chat/messages/Attachments.tsx +267 -42
  28. package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
  29. package/src/tools/chat/messages/MessageBubble.tsx +175 -31
  30. package/src/tools/chat/messages/MessageList.tsx +107 -21
  31. package/src/tools/chat/messages/ToolCalls.tsx +28 -13
  32. package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
  33. package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
  34. package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
  35. package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
  36. package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
  37. package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
  38. package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
  39. package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
  40. package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
  41. package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
  42. package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
  43. package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
  44. package/src/tools/chat/messages/index.ts +13 -0
  45. package/src/tools/chat/public.ts +5 -1
  46. package/src/tools/chat/shell/ChatRoot.tsx +29 -0
  47. package/src/tools/chat/styles/bubbleTokens.ts +18 -0
  48. package/src/tools/chat/styles/index.ts +1 -0
  49. package/src/tools/chat/types/attachment.ts +26 -2
  50. package/src/tools/chat/types/block.ts +56 -9
  51. package/src/tools/chat/types/blockSchemas.ts +197 -0
  52. package/src/tools/chat/types/config.ts +33 -0
  53. package/src/tools/chat/types/events.ts +14 -7
  54. package/src/tools/chat/types/index.ts +4 -2
  55. package/src/tools/chat/types/message.ts +7 -0
  56. package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
  57. package/src/tools/dev/Map/README.md +30 -345
  58. package/src/tools/dev/Map/cards/index.ts +6 -0
  59. package/src/tools/dev/Map/cards/types.ts +67 -0
  60. package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
  61. package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
  62. package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
  63. package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
  64. package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
  65. package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
  66. package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
  67. package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
  68. package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
  69. package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
  70. package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
  71. package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
  72. package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
  73. package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
  74. package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
  75. package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
  76. package/src/tools/dev/Map/components/MapControls.tsx +27 -0
  77. package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
  78. package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
  79. package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
  80. package/src/tools/dev/Map/components/index.ts +35 -1
  81. package/src/tools/dev/Map/geocode/index.ts +2 -0
  82. package/src/tools/dev/Map/geocode/photon.ts +265 -0
  83. package/src/tools/dev/Map/hooks/index.ts +37 -0
  84. package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
  85. package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
  86. package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
  87. package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
  88. package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
  89. package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
  90. package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
  91. package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
  92. package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
  93. package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
  94. package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
  95. package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
  96. package/src/tools/dev/Map/index.ts +59 -2
  97. package/src/tools/dev/Map/layers/index.ts +1 -0
  98. package/src/tools/dev/Map/layers/symbol.ts +93 -0
  99. package/src/tools/dev/Map/lazy.tsx +120 -0
  100. package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
  101. package/src/tools/dev/Map/styles/index.ts +62 -0
  102. package/src/tools/dev/Map/styles/terrain.ts +29 -0
  103. package/src/tools/dev/Map/types.ts +31 -3
  104. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
  105. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
  106. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
  107. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
  108. package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
  109. package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
  110. package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
  111. package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
  112. package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
  113. package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
  114. package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
  115. package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
  116. package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
  117. package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
  118. package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
  119. package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
  120. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
  121. package/src/tools/forms/MarkdownEditor/README.md +84 -0
  122. package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
  123. package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
  124. package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
  125. package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
  126. package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
  127. package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
  128. package/src/tools/forms/MarkdownEditor/index.ts +19 -0
  129. package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
  130. package/src/tools/forms/MarkdownEditor/styles.css +134 -2
  131. package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
  132. package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
  133. package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
  134. package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
  135. package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
  136. package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
  137. package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
  138. package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
  139. package/src/tools/forms/NotionEditor/README.md +109 -188
  140. package/src/tools/forms/NotionEditor/extensions.ts +15 -0
  141. package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
  142. package/src/tools/forms/NotionEditor/styles.css +25 -0
  143. package/src/tools/forms/NotionEditor/types.ts +9 -0
  144. package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
  145. package/src/tools/input/SpeechRecognition/types.ts +10 -0
  146. package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
  147. package/src/tools/media/ImageViewer/README.md +9 -0
  148. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
  149. package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
  150. package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
  151. package/src/tools/media/ImageViewer/components/index.ts +2 -0
  152. package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
  153. package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
  154. package/src/tools/media/ImageViewer/types.ts +10 -0
  155. package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
  156. package/src/tools/media/ImageViewer/utils/index.ts +4 -0
  157. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
  158. package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
  159. package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
  160. package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
  161. package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
  162. package/src/tools/visual/charts/SmoothLine/README.md +97 -0
  163. package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
  164. package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
  165. package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
  166. package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
  167. package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
  168. package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
  169. package/src/tools/visual/indicators/Fps/types.ts +1 -1
  170. package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
  171. package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
@@ -1,10 +1,20 @@
1
1
  'use client';
2
2
 
3
- import { type ReactNode, useState } from 'react';
3
+ import {
4
+ createContext,
5
+ type ReactNode,
6
+ useCallback,
7
+ useContext,
8
+ useMemo,
9
+ useState,
10
+ } from 'react';
4
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';
8
18
  import { PastedTextDialog } from '../composer/PastedTextDialog';
9
19
  import { useChatDestructiveStyles } from '../styles';
10
20
  import type { ChatAttachment } from '../types';
@@ -38,6 +48,118 @@ interface CommonProps {
38
48
  className?: string;
39
49
  }
40
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
+
41
163
  // ---------------------------------------------------------------------------
42
164
  // AttachmentsGrid — flex-wrap, ideal for thumbnails / file chips.
43
165
  // ---------------------------------------------------------------------------
@@ -58,22 +180,27 @@ export function AttachmentsGrid({
58
180
  if (!attachments?.length) return null;
59
181
  const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments;
60
182
  return (
61
- <div
62
- className={cn(
63
- layout === 'grid' ? 'grid grid-cols-3 gap-2' : 'flex flex-wrap gap-2',
64
- className,
65
- )}
66
- >
67
- {visible.map((a) => (
68
- <AttachmentTile
69
- key={a.id}
70
- attachment={a}
71
- isInComposer={isInComposer}
72
- onClick={onClick ? () => onClick(a) : undefined}
73
- onRemove={onRemove ? () => onRemove(a) : undefined}
74
- />
75
- ))}
76
- </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>
77
204
  );
78
205
  }
79
206
 
@@ -99,26 +226,30 @@ export function AttachmentsList({
99
226
  if (!attachments?.length) return null;
100
227
  const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments;
101
228
  return (
102
- <div className={cn('flex w-full flex-col gap-2', className)}>
103
- {visible.map((a) => {
104
- const renderer = renderers?.[a.type] ?? renderers?.default;
105
- const args: AttachmentRendererArgs = {
106
- attachment: a,
107
- isInComposer,
108
- onClick: onClick ? () => onClick(a) : undefined,
109
- onRemove: onRemove ? () => onRemove(a) : undefined,
110
- };
111
- if (renderer) {
112
- return (
113
- <div key={a.id} className="relative w-full min-w-0">
114
- {renderer(args)}
115
- {args.onRemove ? <RemoveBtn onRemove={args.onRemove} /> : null}
116
- </div>
117
- );
118
- }
119
- return <AttachmentTile key={a.id} {...args} />;
120
- })}
121
- </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>
122
253
  );
123
254
  }
124
255
 
@@ -145,12 +276,28 @@ export function Attachments(props: AttachmentsProps) {
145
276
  // ---------------------------------------------------------------------------
146
277
 
147
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
+
148
285
  // "Pasted text" chunks are self-contained — they open their own preview
149
286
  // dialog on click rather than going through the generic `onClick`.
150
287
  if (attachment.type === 'text') {
151
288
  return <TextChunkTile attachment={attachment} onRemove={onRemove} />;
152
289
  }
153
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
+
154
301
  const isImage = attachment.type === 'image';
155
302
  const isUploading = attachment.status === 'uploading';
156
303
 
@@ -165,20 +312,32 @@ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArg
165
312
  loading="lazy"
166
313
  />
167
314
  ) : (
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.
168
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">
169
319
  <FileGlyph aria-hidden className="size-4 shrink-0 text-muted-foreground" />
170
- <span className="truncate">{attachment.name ?? 'file'}</span>
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>
171
328
  </div>
172
329
  );
173
330
 
174
331
  return (
175
332
  <div className="relative">
176
- {onClick ? (
177
- // Interactive only when the host wired `onClick` only then does
178
- // the chip read as clickable (cursor + hover lift + focus ring).
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.
179
337
  <button
180
338
  type="button"
181
- onClick={onClick}
339
+ onClick={effectiveOnClick}
340
+ aria-label={isImage ? `Open image: ${attachment.name ?? 'image'}` : undefined}
182
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"
183
342
  >
184
343
  {inner}
@@ -196,6 +355,72 @@ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArg
196
355
  );
197
356
  }
198
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
+
199
424
  /**
200
425
  * Tile for a `type:'text'` chunk — a two-line "Pasted text" chip in the
201
426
  * same surface style as file chips (title + "Pasted text" sublabel).
@@ -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
+ }