@djangocfg/ui-tools 2.1.335 → 2.1.337

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 (194) hide show
  1. package/README.md +68 -2
  2. package/dist/ChatRoot-PNNGQCYF.css +7 -0
  3. package/dist/ChatRoot-PNNGQCYF.css.map +1 -0
  4. package/dist/ChatRoot-XV2QXMV4.mjs +5 -0
  5. package/dist/ChatRoot-XV2QXMV4.mjs.map +1 -0
  6. package/dist/ChatRoot-YX4RLHQX.cjs +14 -0
  7. package/dist/ChatRoot-YX4RLHQX.cjs.map +1 -0
  8. package/dist/{CronScheduler.client-3O3VU4CI.mjs → CronScheduler.client-DLMXCPAJ.mjs} +4 -4
  9. package/dist/{CronScheduler.client-3O3VU4CI.mjs.map → CronScheduler.client-DLMXCPAJ.mjs.map} +1 -1
  10. package/dist/{CronScheduler.client-A4GO6YBY.cjs → CronScheduler.client-WEJF4PWQ.cjs} +14 -14
  11. package/dist/{CronScheduler.client-A4GO6YBY.cjs.map → CronScheduler.client-WEJF4PWQ.cjs.map} +1 -1
  12. package/dist/{DocsLayout-XLDB6CJ2.cjs → DocsLayout-N5ZJZPBY.cjs} +200 -199
  13. package/dist/DocsLayout-N5ZJZPBY.cjs.map +1 -0
  14. package/dist/{DocsLayout-CTJINVBM.mjs → DocsLayout-VFPPNKSQ.mjs} +7 -6
  15. package/dist/DocsLayout-VFPPNKSQ.mjs.map +1 -0
  16. package/dist/JsonSchemaForm-DD7CLRIG.cjs +13 -0
  17. package/dist/{JsonSchemaForm-6WMS4CIY.cjs.map → JsonSchemaForm-DD7CLRIG.cjs.map} +1 -1
  18. package/dist/JsonSchemaForm-XKUIVELK.mjs +4 -0
  19. package/dist/{JsonSchemaForm-KX4JT3M4.mjs.map → JsonSchemaForm-XKUIVELK.mjs.map} +1 -1
  20. package/dist/JsonTree-55625VVH.mjs +5 -0
  21. package/dist/{JsonTree-F27RMYSI.cjs.map → JsonTree-55625VVH.mjs.map} +1 -1
  22. package/dist/JsonTree-DCM5QGWF.cjs +11 -0
  23. package/dist/{JsonTree-QTJYSHCV.mjs.map → JsonTree-DCM5QGWF.cjs.map} +1 -1
  24. package/dist/{LottiePlayer.client-6WVWDO75.cjs → LottiePlayer.client-2S7ISJ2S.cjs} +6 -6
  25. package/dist/{LottiePlayer.client-6WVWDO75.cjs.map → LottiePlayer.client-2S7ISJ2S.cjs.map} +1 -1
  26. package/dist/{LottiePlayer.client-B4I6WNZM.mjs → LottiePlayer.client-5LDSSJWS.mjs} +4 -4
  27. package/dist/{LottiePlayer.client-B4I6WNZM.mjs.map → LottiePlayer.client-5LDSSJWS.mjs.map} +1 -1
  28. package/dist/{MapContainer-RYG4HPH4.cjs → MapContainer-76YL2JXL.cjs} +8 -8
  29. package/dist/{MapContainer-RYG4HPH4.cjs.map → MapContainer-76YL2JXL.cjs.map} +1 -1
  30. package/dist/{MapContainer-GXQLP5WY.mjs → MapContainer-7HXBI3OH.mjs} +3 -3
  31. package/dist/{MapContainer-GXQLP5WY.mjs.map → MapContainer-7HXBI3OH.mjs.map} +1 -1
  32. package/dist/{Mermaid.client-SXRRI2YW.mjs → Mermaid.client-NL4SVR7F.mjs} +4 -4
  33. package/dist/{Mermaid.client-SXRRI2YW.mjs.map → Mermaid.client-NL4SVR7F.mjs.map} +1 -1
  34. package/dist/{Mermaid.client-W76R5AKJ.cjs → Mermaid.client-NNTI6DFX.cjs} +26 -26
  35. package/dist/{Mermaid.client-W76R5AKJ.cjs.map → Mermaid.client-NNTI6DFX.cjs.map} +1 -1
  36. package/dist/Player-BRV7XTWR.mjs +4 -0
  37. package/dist/{Player-M3GC3VPE.mjs.map → Player-BRV7XTWR.mjs.map} +1 -1
  38. package/dist/Player-PM7F7DD7.cjs +13 -0
  39. package/dist/{Player-ZL2X5LGG.cjs.map → Player-PM7F7DD7.cjs.map} +1 -1
  40. package/dist/{PrettyCode.client-RPDIE5CH.cjs → PrettyCode.client-KOHDVPPN.cjs} +13 -13
  41. package/dist/{PrettyCode.client-RPDIE5CH.cjs.map → PrettyCode.client-KOHDVPPN.cjs.map} +1 -1
  42. package/dist/{PrettyCode.client-SPMTQEG4.mjs → PrettyCode.client-ZGYGKE7G.mjs} +4 -4
  43. package/dist/{PrettyCode.client-SPMTQEG4.mjs.map → PrettyCode.client-ZGYGKE7G.mjs.map} +1 -1
  44. package/dist/TreeRoot-N72OYKXU.cjs +19 -0
  45. package/dist/{TreeRoot-A3J65L6F.mjs.map → TreeRoot-N72OYKXU.cjs.map} +1 -1
  46. package/dist/TreeRoot-VGAIXCUA.mjs +4 -0
  47. package/dist/{TreeRoot-DSK5JILT.cjs.map → TreeRoot-VGAIXCUA.mjs.map} +1 -1
  48. package/dist/chunk-2ZLKZ5VR.mjs +631 -0
  49. package/dist/chunk-2ZLKZ5VR.mjs.map +1 -0
  50. package/dist/{chunk-LFWQ36LJ.mjs → chunk-5G5YBFS6.mjs} +4 -4
  51. package/dist/{chunk-LFWQ36LJ.mjs.map → chunk-5G5YBFS6.mjs.map} +1 -1
  52. package/dist/{chunk-IHAY6FO6.cjs → chunk-5I5QNGUG.cjs} +17 -17
  53. package/dist/{chunk-IHAY6FO6.cjs.map → chunk-5I5QNGUG.cjs.map} +1 -1
  54. package/dist/{chunk-F2CMIIOH.cjs → chunk-76NNDZH6.cjs} +42 -42
  55. package/dist/{chunk-F2CMIIOH.cjs.map → chunk-76NNDZH6.cjs.map} +1 -1
  56. package/dist/chunk-B5AWZOHJ.cjs +649 -0
  57. package/dist/chunk-B5AWZOHJ.cjs.map +1 -0
  58. package/dist/{chunk-KR6B3LVY.mjs → chunk-B6IR5KSC.mjs} +3 -3
  59. package/dist/{chunk-KR6B3LVY.mjs.map → chunk-B6IR5KSC.mjs.map} +1 -1
  60. package/dist/{chunk-5LBDYFWH.mjs → chunk-C6GXVH5J.mjs} +3 -3
  61. package/dist/{chunk-5LBDYFWH.mjs.map → chunk-C6GXVH5J.mjs.map} +1 -1
  62. package/dist/{chunk-NRKD4F5X.cjs → chunk-FEN5S772.cjs} +36 -36
  63. package/dist/{chunk-NRKD4F5X.cjs.map → chunk-FEN5S772.cjs.map} +1 -1
  64. package/dist/{chunk-2SMCH62O.cjs → chunk-FP2RLYQZ.cjs} +11 -11
  65. package/dist/{chunk-2SMCH62O.cjs.map → chunk-FP2RLYQZ.cjs.map} +1 -1
  66. package/dist/{chunk-MOME6KYD.mjs → chunk-G5IEC7SR.mjs} +3 -3
  67. package/dist/{chunk-MOME6KYD.mjs.map → chunk-G5IEC7SR.mjs.map} +1 -1
  68. package/dist/{chunk-SE5IERVH.mjs → chunk-GYIO7W7M.mjs} +3 -3
  69. package/dist/{chunk-SE5IERVH.mjs.map → chunk-GYIO7W7M.mjs.map} +1 -1
  70. package/dist/{chunk-3Z3A7FHA.cjs → chunk-IEEAENLX.cjs} +48 -48
  71. package/dist/{chunk-3Z3A7FHA.cjs.map → chunk-IEEAENLX.cjs.map} +1 -1
  72. package/dist/{chunk-DFTVB66S.cjs → chunk-KNDLV4PI.cjs} +85 -85
  73. package/dist/{chunk-DFTVB66S.cjs.map → chunk-KNDLV4PI.cjs.map} +1 -1
  74. package/dist/{chunk-SSUOENAZ.mjs → chunk-KNEQRUBA.mjs} +3 -3
  75. package/dist/{chunk-SSUOENAZ.mjs.map → chunk-KNEQRUBA.mjs.map} +1 -1
  76. package/dist/{chunk-CGILA3WO.mjs → chunk-N2XQF2OL.mjs} +5 -3
  77. package/dist/{chunk-CGILA3WO.mjs.map → chunk-N2XQF2OL.mjs.map} +1 -1
  78. package/dist/{chunk-EUADAUBQ.mjs → chunk-N4MZYNR4.mjs} +4 -4
  79. package/dist/{chunk-EUADAUBQ.mjs.map → chunk-N4MZYNR4.mjs.map} +1 -1
  80. package/dist/{chunk-GGKGH5PM.mjs → chunk-OBRSGM64.mjs} +4 -4
  81. package/dist/{chunk-GGKGH5PM.mjs.map → chunk-OBRSGM64.mjs.map} +1 -1
  82. package/dist/{chunk-6JTB2X72.mjs → chunk-ODO4GMW7.mjs} +3 -3
  83. package/dist/{chunk-6JTB2X72.mjs.map → chunk-ODO4GMW7.mjs.map} +1 -1
  84. package/dist/{chunk-WGEGR3DF.cjs → chunk-OLISEQHS.cjs} +5 -2
  85. package/dist/{chunk-WGEGR3DF.cjs.map → chunk-OLISEQHS.cjs.map} +1 -1
  86. package/dist/{chunk-PZKAH7WQ.mjs → chunk-PVAX67JG.mjs} +3 -3
  87. package/dist/{chunk-PZKAH7WQ.mjs.map → chunk-PVAX67JG.mjs.map} +1 -1
  88. package/dist/{chunk-PRPG2T2E.cjs → chunk-QJ6GTUCO.cjs} +6 -6
  89. package/dist/{chunk-PRPG2T2E.cjs.map → chunk-QJ6GTUCO.cjs.map} +1 -1
  90. package/dist/chunk-QW4RBGHN.cjs +961 -0
  91. package/dist/chunk-QW4RBGHN.cjs.map +1 -0
  92. package/dist/{chunk-33AMWFBZ.cjs → chunk-SGP7V2UW.cjs} +15 -15
  93. package/dist/{chunk-33AMWFBZ.cjs.map → chunk-SGP7V2UW.cjs.map} +1 -1
  94. package/dist/{chunk-FX2QFYWF.mjs → chunk-VWQ5WOIL.mjs} +3 -3
  95. package/dist/{chunk-FX2QFYWF.mjs.map → chunk-VWQ5WOIL.mjs.map} +1 -1
  96. package/dist/{chunk-ZLQHUZDU.cjs → chunk-YDPDTOSP.cjs} +139 -139
  97. package/dist/{chunk-ZLQHUZDU.cjs.map → chunk-YDPDTOSP.cjs.map} +1 -1
  98. package/dist/{chunk-77HQWEQ6.cjs → chunk-YW5IVWHQ.cjs} +33 -33
  99. package/dist/{chunk-77HQWEQ6.cjs.map → chunk-YW5IVWHQ.cjs.map} +1 -1
  100. package/dist/chunk-YWSQDBNU.mjs +2339 -0
  101. package/dist/chunk-YWSQDBNU.mjs.map +1 -0
  102. package/dist/{chunk-YXBOAGIM.cjs → chunk-YXZ6GU7H.cjs} +7 -7
  103. package/dist/{chunk-YXBOAGIM.cjs.map → chunk-YXZ6GU7H.cjs.map} +1 -1
  104. package/dist/{chunk-62Y65TGK.mjs → chunk-ZUFTH5IR.mjs} +8 -631
  105. package/dist/chunk-ZUFTH5IR.mjs.map +1 -0
  106. package/dist/chunk-ZWPBBAR2.cjs +2379 -0
  107. package/dist/chunk-ZWPBBAR2.cjs.map +1 -0
  108. package/dist/components-EHOGXATG.cjs +22 -0
  109. package/dist/{components-5UXYNAKR.cjs.map → components-EHOGXATG.cjs.map} +1 -1
  110. package/dist/components-MQ6DR7TX.cjs +26 -0
  111. package/dist/{components-CFXOEVPN.mjs.map → components-MQ6DR7TX.cjs.map} +1 -1
  112. package/dist/components-XRX7QGLB.mjs +5 -0
  113. package/dist/{components-WYEZL5TE.cjs.map → components-XRX7QGLB.mjs.map} +1 -1
  114. package/dist/components-YATKRWLH.mjs +5 -0
  115. package/dist/{components-ZAGG2PBO.mjs.map → components-YATKRWLH.mjs.map} +1 -1
  116. package/dist/file-icon/index.cjs +6 -6
  117. package/dist/file-icon/index.mjs +1 -1
  118. package/dist/index.cjs +739 -215
  119. package/dist/index.cjs.map +1 -1
  120. package/dist/index.d.cts +1025 -39
  121. package/dist/index.d.ts +1025 -39
  122. package/dist/index.mjs +387 -31
  123. package/dist/index.mjs.map +1 -1
  124. package/dist/tree/index.cjs +38 -38
  125. package/dist/tree/index.d.cts +2 -2
  126. package/dist/tree/index.d.ts +2 -2
  127. package/dist/tree/index.mjs +3 -3
  128. package/package.json +6 -6
  129. package/src/index.ts +5 -0
  130. package/src/stories/index.ts +3 -1
  131. package/src/tools/Chat/Chat.story.tsx +1006 -0
  132. package/src/tools/Chat/README.md +528 -0
  133. package/src/tools/Chat/components/Attachments.tsx +192 -0
  134. package/src/tools/Chat/components/ChatRoot.tsx +208 -0
  135. package/src/tools/Chat/components/Composer.tsx +134 -0
  136. package/src/tools/Chat/components/EmptyState.tsx +47 -0
  137. package/src/tools/Chat/components/ErrorBanner.tsx +47 -0
  138. package/src/tools/Chat/components/JumpToLatest.tsx +30 -0
  139. package/src/tools/Chat/components/MessageActions.tsx +72 -0
  140. package/src/tools/Chat/components/MessageBubble.tsx +228 -0
  141. package/src/tools/Chat/components/MessageList.tsx +82 -0
  142. package/src/tools/Chat/components/Sources.tsx +55 -0
  143. package/src/tools/Chat/components/StreamingIndicator.tsx +29 -0
  144. package/src/tools/Chat/components/ToolCalls.tsx +172 -0
  145. package/src/tools/Chat/components/index.ts +24 -0
  146. package/src/tools/Chat/config.ts +55 -0
  147. package/src/tools/Chat/context/ChatProvider.tsx +126 -0
  148. package/src/tools/Chat/context/index.ts +9 -0
  149. package/src/tools/Chat/core/audio/audioBus.ts +172 -0
  150. package/src/tools/Chat/core/audio/index.ts +8 -0
  151. package/src/tools/Chat/core/audio/preferences.ts +68 -0
  152. package/src/tools/Chat/core/audio/types.ts +49 -0
  153. package/src/tools/Chat/core/ids.ts +16 -0
  154. package/src/tools/Chat/core/index.ts +5 -0
  155. package/src/tools/Chat/core/logger.ts +73 -0
  156. package/src/tools/Chat/core/markdown.ts +56 -0
  157. package/src/tools/Chat/core/payload-dispatch.ts +54 -0
  158. package/src/tools/Chat/core/persona.ts +35 -0
  159. package/src/tools/Chat/core/reducer.ts +335 -0
  160. package/src/tools/Chat/core/transport/http.ts +167 -0
  161. package/src/tools/Chat/core/transport/index.ts +13 -0
  162. package/src/tools/Chat/core/transport/mock.ts +134 -0
  163. package/src/tools/Chat/core/transport/sse.ts +116 -0
  164. package/src/tools/Chat/core/transport/types.ts +24 -0
  165. package/src/tools/Chat/hooks/index.ts +26 -0
  166. package/src/tools/Chat/hooks/useChat.ts +555 -0
  167. package/src/tools/Chat/hooks/useChatAudio.ts +191 -0
  168. package/src/tools/Chat/hooks/useChatComposer.ts +227 -0
  169. package/src/tools/Chat/hooks/useChatHistory.ts +59 -0
  170. package/src/tools/Chat/hooks/useChatLayout.ts +111 -0
  171. package/src/tools/Chat/hooks/useChatLightbox.ts +34 -0
  172. package/src/tools/Chat/hooks/useChatScroll.ts +132 -0
  173. package/src/tools/Chat/index.ts +161 -0
  174. package/src/tools/Chat/lazy.tsx +14 -0
  175. package/src/tools/Chat/types.ts +237 -0
  176. package/src/tools/Chat/utils/collectImageAttachments.ts +13 -0
  177. package/src/tools/Map/README.md +384 -0
  178. package/dist/DocsLayout-CTJINVBM.mjs.map +0 -1
  179. package/dist/DocsLayout-XLDB6CJ2.cjs.map +0 -1
  180. package/dist/JsonSchemaForm-6WMS4CIY.cjs +0 -13
  181. package/dist/JsonSchemaForm-KX4JT3M4.mjs +0 -4
  182. package/dist/JsonTree-F27RMYSI.cjs +0 -11
  183. package/dist/JsonTree-QTJYSHCV.mjs +0 -5
  184. package/dist/Player-M3GC3VPE.mjs +0 -4
  185. package/dist/Player-ZL2X5LGG.cjs +0 -13
  186. package/dist/TreeRoot-A3J65L6F.mjs +0 -4
  187. package/dist/TreeRoot-DSK5JILT.cjs +0 -19
  188. package/dist/chunk-62Y65TGK.mjs.map +0 -1
  189. package/dist/chunk-TKSFZHCG.cjs +0 -1597
  190. package/dist/chunk-TKSFZHCG.cjs.map +0 -1
  191. package/dist/components-5UXYNAKR.cjs +0 -22
  192. package/dist/components-CFXOEVPN.mjs +0 -5
  193. package/dist/components-WYEZL5TE.cjs +0 -26
  194. package/dist/components-ZAGG2PBO.mjs +0 -5
@@ -0,0 +1,192 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode } from 'react';
4
+ import { File as FileIcon, X } from 'lucide-react';
5
+
6
+ import { cn } from '@djangocfg/ui-core/lib';
7
+
8
+ import type { ChatAttachment } from '../types';
9
+
10
+ export interface AttachmentRendererArgs {
11
+ attachment: ChatAttachment;
12
+ /** True when shown inside the composer's staging tray (denser layout). */
13
+ isInComposer: boolean;
14
+ onClick?: () => void;
15
+ onRemove?: () => void;
16
+ }
17
+
18
+ export type AttachmentRenderer = (args: AttachmentRendererArgs) => ReactNode;
19
+
20
+ export interface AttachmentRendererMap {
21
+ image?: AttachmentRenderer;
22
+ audio?: AttachmentRenderer;
23
+ video?: AttachmentRenderer;
24
+ file?: AttachmentRenderer;
25
+ /** Fallback renderer when no per-type entry matched. */
26
+ default?: AttachmentRenderer;
27
+ }
28
+
29
+ interface CommonProps {
30
+ attachments: ChatAttachment[];
31
+ maxVisible?: number;
32
+ onClick?: (a: ChatAttachment) => void;
33
+ onRemove?: (a: ChatAttachment) => void;
34
+ isInComposer?: boolean;
35
+ className?: string;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // AttachmentsGrid — flex-wrap, ideal for thumbnails / file chips.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export interface AttachmentsGridProps extends CommonProps {
43
+ layout?: 'wrap' | 'grid';
44
+ }
45
+
46
+ export function AttachmentsGrid({
47
+ attachments,
48
+ maxVisible,
49
+ onClick,
50
+ onRemove,
51
+ isInComposer = false,
52
+ layout = 'wrap',
53
+ className,
54
+ }: AttachmentsGridProps) {
55
+ if (!attachments?.length) return null;
56
+ const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments;
57
+ return (
58
+ <div
59
+ className={cn(
60
+ layout === 'grid' ? 'grid grid-cols-3 gap-2' : 'flex flex-wrap gap-2',
61
+ className,
62
+ )}
63
+ >
64
+ {visible.map((a) => (
65
+ <AttachmentTile
66
+ key={a.id}
67
+ attachment={a}
68
+ isInComposer={isInComposer}
69
+ onClick={onClick ? () => onClick(a) : undefined}
70
+ onRemove={onRemove ? () => onRemove(a) : undefined}
71
+ />
72
+ ))}
73
+ </div>
74
+ );
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // AttachmentsList — vertical stack, designed for rich custom renderers
79
+ // (LazyAudioPlayer, video players, document previews).
80
+ // ---------------------------------------------------------------------------
81
+
82
+ export interface AttachmentsListProps extends CommonProps {
83
+ /** Per-type renderer overrides. Falls back to the default tile. */
84
+ renderers?: AttachmentRendererMap;
85
+ }
86
+
87
+ export function AttachmentsList({
88
+ attachments,
89
+ maxVisible,
90
+ onClick,
91
+ onRemove,
92
+ renderers,
93
+ isInComposer = false,
94
+ className,
95
+ }: AttachmentsListProps) {
96
+ if (!attachments?.length) return null;
97
+ const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments;
98
+ return (
99
+ <div className={cn('flex w-full flex-col gap-2', className)}>
100
+ {visible.map((a) => {
101
+ const renderer = renderers?.[a.type] ?? renderers?.default;
102
+ const args: AttachmentRendererArgs = {
103
+ attachment: a,
104
+ isInComposer,
105
+ onClick: onClick ? () => onClick(a) : undefined,
106
+ onRemove: onRemove ? () => onRemove(a) : undefined,
107
+ };
108
+ if (renderer) {
109
+ return (
110
+ <div key={a.id} className="relative w-full min-w-0">
111
+ {renderer(args)}
112
+ {args.onRemove ? <RemoveBtn onRemove={args.onRemove} /> : null}
113
+ </div>
114
+ );
115
+ }
116
+ return <AttachmentTile key={a.id} {...args} />;
117
+ })}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Attachments — backwards-compatible facade. Picks the right component based
124
+ // on whether `renderers` are supplied. Existing call-sites keep working.
125
+ // ---------------------------------------------------------------------------
126
+
127
+ export interface AttachmentsProps extends CommonProps {
128
+ layout?: 'grid' | 'row';
129
+ renderers?: AttachmentRendererMap;
130
+ }
131
+
132
+ export function Attachments(props: AttachmentsProps) {
133
+ const { renderers, layout, ...rest } = props;
134
+ if (renderers) {
135
+ return <AttachmentsList {...rest} renderers={renderers} />;
136
+ }
137
+ return <AttachmentsGrid {...rest} layout={layout === 'grid' ? 'grid' : 'wrap'} />;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Tile + remove btn (default renderers).
142
+ // ---------------------------------------------------------------------------
143
+
144
+ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArgs) {
145
+ const isImage = attachment.type === 'image';
146
+ const isUploading = attachment.status === 'uploading';
147
+
148
+ const inner = isImage ? (
149
+ <img
150
+ src={attachment.thumbnailUrl ?? attachment.url}
151
+ alt={attachment.name ?? 'attachment'}
152
+ className="h-16 w-16 rounded-md object-cover"
153
+ loading="lazy"
154
+ />
155
+ ) : (
156
+ <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">
157
+ <FileIcon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
158
+ <span className="truncate">{attachment.name ?? 'file'}</span>
159
+ </div>
160
+ );
161
+
162
+ return (
163
+ <div className="relative">
164
+ {onClick ? (
165
+ <button type="button" onClick={onClick} className="block">
166
+ {inner}
167
+ </button>
168
+ ) : (
169
+ inner
170
+ )}
171
+ {isUploading ? (
172
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-background/70 text-[10px] font-medium">
173
+ {attachment.progress != null ? `${Math.round(attachment.progress * 100)}%` : '…'}
174
+ </div>
175
+ ) : null}
176
+ {onRemove ? <RemoveBtn onRemove={onRemove} /> : null}
177
+ </div>
178
+ );
179
+ }
180
+
181
+ function RemoveBtn({ onRemove }: { onRemove: () => void }) {
182
+ return (
183
+ <button
184
+ type="button"
185
+ aria-label="Remove attachment"
186
+ onClick={onRemove}
187
+ className="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 hover:bg-destructive hover:text-destructive-foreground"
188
+ >
189
+ <X aria-hidden className="size-2.5" />
190
+ </button>
191
+ );
192
+ }
@@ -0,0 +1,208 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode, useRef } from 'react';
4
+
5
+ import { cn } from '@djangocfg/ui-core/lib';
6
+
7
+ import type { ChatAttachment, ChatConfig, ChatMessage, ChatTransport } from '../types';
8
+ import type { ChatAudioConfig } from '../core/audio/types';
9
+ import { ChatProvider, useChatContext, type ChatContextValue } from '../context';
10
+ import { useChatComposer, type UseChatComposerReturn } from '../hooks/useChatComposer';
11
+ import { useChatScroll } from '../hooks/useChatScroll';
12
+ import { useChatHistory } from '../hooks/useChatHistory';
13
+ import { Composer } from './Composer';
14
+ import { EmptyState } from './EmptyState';
15
+ import { ErrorBanner } from './ErrorBanner';
16
+ import { JumpToLatest } from './JumpToLatest';
17
+ import { MessageBubble } from './MessageBubble';
18
+ import { MessageList } from './MessageList';
19
+ import type { AttachmentRendererMap } from './Attachments';
20
+ import type { ToolCallsProps } from './ToolCalls';
21
+
22
+ export interface ChatRootProps {
23
+ // ---- core wiring -------------------------------------------------------
24
+ transport: ChatTransport;
25
+ config?: ChatConfig;
26
+ initialSessionId?: string;
27
+ autoCreateSession?: boolean;
28
+ streaming?: boolean;
29
+ /** Audio-trigger configuration. Off by default (no `sounds` map). */
30
+ audio?: ChatAudioConfig;
31
+ /**
32
+ * Verbose dev-mode logging via `consola` (namespace `chat:*`).
33
+ * Defaults to `isDev` from `@djangocfg/ui-core/lib`. Pass `false` to silence
34
+ * even in development, or `true` to force on in production for debugging.
35
+ */
36
+ debug?: boolean;
37
+ className?: string;
38
+
39
+ // ---- named ReactNode slots --------------------------------------------
40
+ /** Sticky banner above the message list (e.g. quota warning). */
41
+ banner?: ReactNode;
42
+ /** Header row below the banner — title / actions / session switcher. */
43
+ header?: ReactNode;
44
+ /** Footer slot below the composer (disclaimers, model picker). */
45
+ footer?: ReactNode;
46
+ /** Replaces the default `<EmptyState>` rendered when the conversation is empty. */
47
+ empty?: ReactNode;
48
+ /** Slot left of the textarea inside `<Composer>`. */
49
+ composerToolbarStart?: ReactNode;
50
+ /** Slot right of the textarea inside `<Composer>`. */
51
+ composerToolbarEnd?: ReactNode;
52
+ /** Replaces the default attachment tray inside `<Composer>`. */
53
+ composerAttachmentTray?: ReactNode;
54
+ /** Replaces the default `<JumpToLatest>` floating pill. */
55
+ jumpToLatest?: ReactNode;
56
+
57
+ // ---- render-prop slots (need access to data) --------------------------
58
+ /** Replace `<MessageBubble>` per message. */
59
+ renderMessage?: (m: ChatMessage, i: number) => ReactNode;
60
+ /** Render the header lazily — receives the chat context. */
61
+ renderHeader?: (ctx: ChatContextValue) => ReactNode;
62
+ /** Render the empty-state lazily — receives a `setValue` to seed the composer. */
63
+ renderEmpty?: (api: { setValue: (v: string) => void; focus: () => void }) => ReactNode;
64
+ /** Forwarded into `<MessageBubble toolCallsProps>` so hosts can swap payload renderers. */
65
+ toolCallsProps?: Omit<ToolCallsProps, 'calls'>;
66
+ /** Per-type attachment renderers — `{ image, audio, video, file, default }`. */
67
+ attachmentRenderers?: AttachmentRendererMap;
68
+ /** Called when an attachment tile is clicked (e.g. open lightbox). */
69
+ onAttachmentOpen?: (attachment: ChatAttachment) => void;
70
+
71
+ // ---- composer customization -------------------------------------------
72
+ /** Show the paperclip "attach" button in the composer. */
73
+ showAttachmentButton?: boolean;
74
+ /** Called when the user clicks the attach button (host opens its file picker). */
75
+ onPickFiles?: () => void;
76
+ }
77
+
78
+ export function ChatRoot(props: ChatRootProps) {
79
+ const { transport, config, initialSessionId, autoCreateSession, streaming, audio, debug, className, ...slots } = props;
80
+ return (
81
+ <ChatProvider
82
+ transport={transport}
83
+ config={config}
84
+ initialSessionId={initialSessionId}
85
+ autoCreateSession={autoCreateSession}
86
+ streaming={streaming}
87
+ audio={audio}
88
+ debug={debug}
89
+ >
90
+ <ChatRootShell className={className} slots={slots} />
91
+ </ChatProvider>
92
+ );
93
+ }
94
+
95
+ interface ChatRootShellProps {
96
+ className?: string;
97
+ slots: Omit<ChatRootProps, 'transport' | 'config' | 'initialSessionId' | 'autoCreateSession' | 'streaming' | 'audio' | 'debug' | 'className'>;
98
+ }
99
+
100
+ function ChatRootShell({ className, slots }: ChatRootShellProps) {
101
+ const chat = useChatContext();
102
+ const composer = useChatComposer({
103
+ onSubmit: (content, attachments) => chat.sendMessage(content, attachments),
104
+ disabled: chat.isStreaming,
105
+ });
106
+ const containerRef = useRef<HTMLDivElement | null>(null);
107
+ const bottomRef = useRef<HTMLDivElement | null>(null);
108
+ const topRef = useRef<HTMLDivElement | null>(null);
109
+
110
+ const scroll = useChatScroll({
111
+ containerRef,
112
+ bottomRef,
113
+ isStreaming: chat.isStreaming,
114
+ messagesCount: chat.messages.length,
115
+ });
116
+
117
+ useChatHistory({
118
+ containerRef,
119
+ topSentinelRef: topRef,
120
+ hasMore: chat.hasMore,
121
+ isLoadingMore: chat.isLoadingMore,
122
+ loadMore: chat.loadMore,
123
+ });
124
+
125
+ const greeting = chat.config.greeting ?? 'How can I help?';
126
+ const description = chat.config.description;
127
+ const suggestions = chat.config.suggestions;
128
+
129
+ const headerNode = slots.renderHeader ? slots.renderHeader(chat) : slots.header;
130
+
131
+ const emptyNode = slots.empty
132
+ ?? (slots.renderEmpty
133
+ ? slots.renderEmpty({ setValue: composer.setValue, focus: composer.focus })
134
+ : (
135
+ <EmptyState
136
+ greeting={greeting}
137
+ description={description}
138
+ suggestions={suggestions}
139
+ onPickSuggestion={(prompt) => {
140
+ composer.setValue(prompt);
141
+ composer.focus();
142
+ }}
143
+ />
144
+ ));
145
+
146
+ const renderItem = slots.renderMessage
147
+ ?? ((m: ChatMessage) => (
148
+ <MessageBubble
149
+ key={m.id}
150
+ message={m}
151
+ toolCallsProps={slots.toolCallsProps}
152
+ attachmentRenderers={slots.attachmentRenderers}
153
+ onAttachmentOpen={slots.onAttachmentOpen}
154
+ onCopy={() => copy(m.content)}
155
+ onRegenerate={() => void chat.regenerate(m.id)}
156
+ onDelete={() => chat.deleteMessage(m.id)}
157
+ />
158
+ ));
159
+
160
+ return (
161
+ <div className={cn('relative flex h-full min-h-0 flex-col overflow-hidden', className)}>
162
+ {slots.banner ?? null}
163
+ {headerNode ?? null}
164
+ <div className="relative flex min-h-0 flex-1 flex-col">
165
+ <ErrorBanner
166
+ error={chat.error}
167
+ onDismiss={chat.error ? () => chat.clearMessages() : undefined}
168
+ onRetry={chat.error ? () => void chat.regenerate() : undefined}
169
+ />
170
+ <MessageList
171
+ ref={containerRef}
172
+ topSentinelRef={topRef}
173
+ bottomRef={bottomRef}
174
+ renderItem={renderItem}
175
+ renderEmpty={() => <>{emptyNode}</>}
176
+ />
177
+ <div className="pointer-events-none absolute inset-x-0 bottom-2 flex justify-center">
178
+ {slots.jumpToLatest ?? (
179
+ <JumpToLatest
180
+ visible={!scroll.isAtBottom}
181
+ unreadCount={scroll.unreadCount}
182
+ onClick={() => scroll.scrollToBottom(true)}
183
+ />
184
+ )}
185
+ </div>
186
+ </div>
187
+ <Composer
188
+ composer={composer}
189
+ placeholder={chat.config.placeholder}
190
+ showAttachmentButton={slots.showAttachmentButton}
191
+ onPickFiles={slots.onPickFiles}
192
+ toolbarStart={slots.composerToolbarStart}
193
+ toolbarEnd={slots.composerToolbarEnd}
194
+ attachmentTray={slots.composerAttachmentTray}
195
+ />
196
+ {slots.footer ?? null}
197
+ </div>
198
+ );
199
+ }
200
+
201
+ function copy(text: string) {
202
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
203
+ void navigator.clipboard.writeText(text);
204
+ }
205
+ }
206
+
207
+ // re-export for convenience: composer hook return is a common slot dependency
208
+ export type { UseChatComposerReturn };
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode, forwardRef } from 'react';
4
+ import { Paperclip, Send, Square } from 'lucide-react';
5
+
6
+ import { Button, Textarea } from '@djangocfg/ui-core/components';
7
+ import { cn } from '@djangocfg/ui-core/lib';
8
+
9
+ import { useChatContextOptional } from '../context';
10
+ import type { UseChatComposerReturn } from '../hooks/useChatComposer';
11
+ import { Attachments } from './Attachments';
12
+
13
+ export interface ComposerProps {
14
+ composer: UseChatComposerReturn;
15
+ placeholder?: string;
16
+ disabled?: boolean;
17
+ showAttachmentButton?: boolean;
18
+ onPickFiles?: () => void;
19
+ toolbarStart?: ReactNode;
20
+ toolbarEnd?: ReactNode;
21
+ attachmentTray?: ReactNode;
22
+ className?: string;
23
+ textareaClassName?: string;
24
+ /** Show "Stop" button instead of "Send" while streaming. */
25
+ isStreaming?: boolean;
26
+ onCancel?: () => void;
27
+ }
28
+
29
+ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Composer(
30
+ {
31
+ composer,
32
+ placeholder = 'Type a message...',
33
+ disabled,
34
+ showAttachmentButton = false,
35
+ onPickFiles,
36
+ toolbarStart,
37
+ toolbarEnd,
38
+ attachmentTray,
39
+ className,
40
+ textareaClassName,
41
+ isStreaming: isStreamingProp,
42
+ onCancel: onCancelProp,
43
+ },
44
+ ref,
45
+ ) {
46
+ const ctx = useChatContextOptional();
47
+ const isStreaming = isStreamingProp ?? ctx?.isStreaming ?? false;
48
+ const onCancel = onCancelProp ?? ctx?.cancelStream;
49
+ const isDisabled = disabled ?? isStreaming;
50
+
51
+ return (
52
+ <div
53
+ ref={ref}
54
+ className={cn(
55
+ 'border-t border-border bg-background/95 px-2.5 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]',
56
+ className,
57
+ )}
58
+ >
59
+ {composer.attachments.length > 0 ? (
60
+ <div className="mb-1.5">
61
+ {attachmentTray ?? (
62
+ <Attachments
63
+ attachments={composer.attachments}
64
+ onRemove={(a) => composer.removeAttachment(a.id)}
65
+ />
66
+ )}
67
+ </div>
68
+ ) : null}
69
+
70
+ {/* `[&>*]:h-9` enforces a consistent 36px slot height so toolbar
71
+ * buttons line up with the textarea baseline (`min-h-[36px]`).
72
+ * Toolbar slots that want to opt out can pass an explicit class
73
+ * like `!h-auto`. */}
74
+ <div className="flex items-end gap-1.5 [&>:not(textarea)]:shrink-0 [&>:not(textarea)]:h-9">
75
+ {showAttachmentButton ? (
76
+ <Button
77
+ type="button"
78
+ variant="ghost"
79
+ size="icon"
80
+ onClick={onPickFiles}
81
+ aria-label="Attach files"
82
+ disabled={isDisabled}
83
+ className="h-9 w-9"
84
+ >
85
+ <Paperclip aria-hidden className="size-4" />
86
+ </Button>
87
+ ) : null}
88
+
89
+ {toolbarStart}
90
+
91
+ <Textarea
92
+ {...composer.textareaProps}
93
+ rows={1}
94
+ placeholder={placeholder}
95
+ aria-label={placeholder}
96
+ aria-multiline="true"
97
+ disabled={isDisabled}
98
+ className={cn(
99
+ 'min-h-9 max-h-60 flex-1 resize-none rounded-2xl px-3.5 py-2 text-base sm:text-sm',
100
+ textareaClassName,
101
+ )}
102
+ />
103
+
104
+ {toolbarEnd}
105
+
106
+ {isStreaming ? (
107
+ <Button
108
+ type="button"
109
+ variant="secondary"
110
+ size="icon"
111
+ onClick={onCancel}
112
+ aria-label="Stop"
113
+ aria-keyshortcuts="Escape"
114
+ className="h-9 w-9"
115
+ >
116
+ <Square aria-hidden className="size-3.5" />
117
+ </Button>
118
+ ) : (
119
+ <Button
120
+ type="button"
121
+ size="icon"
122
+ onClick={() => void composer.submit()}
123
+ disabled={!composer.canSubmit}
124
+ aria-label="Send"
125
+ aria-keyshortcuts="Enter"
126
+ className="h-9 w-9"
127
+ >
128
+ <Send aria-hidden className="size-4" />
129
+ </Button>
130
+ )}
131
+ </div>
132
+ </div>
133
+ );
134
+ });
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import { Sparkles } from 'lucide-react';
4
+
5
+ import { cn } from '@djangocfg/ui-core/lib';
6
+
7
+ export interface EmptyStateProps {
8
+ greeting?: string;
9
+ description?: string;
10
+ suggestions?: Array<{ label: string; prompt: string }>;
11
+ onPickSuggestion?: (prompt: string) => void;
12
+ className?: string;
13
+ }
14
+
15
+ export function EmptyState({
16
+ greeting,
17
+ description,
18
+ suggestions,
19
+ onPickSuggestion,
20
+ className,
21
+ }: EmptyStateProps) {
22
+ return (
23
+ <div className={cn('flex flex-col items-center gap-3 px-4 py-12 text-center', className)}>
24
+ <div className="grid size-10 place-items-center rounded-full bg-muted">
25
+ <Sparkles aria-hidden className="size-5 text-muted-foreground" />
26
+ </div>
27
+ {greeting ? <h2 className="text-base font-semibold">{greeting}</h2> : null}
28
+ {description ? (
29
+ <p className="max-w-md text-sm text-muted-foreground">{description}</p>
30
+ ) : null}
31
+ {suggestions?.length ? (
32
+ <div className="mt-2 grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
33
+ {suggestions.map((s) => (
34
+ <button
35
+ key={s.prompt}
36
+ type="button"
37
+ onClick={() => onPickSuggestion?.(s.prompt)}
38
+ className="rounded-lg border border-border bg-background/60 px-3 py-2 text-left text-xs hover:bg-accent"
39
+ >
40
+ {s.label}
41
+ </button>
42
+ ))}
43
+ </div>
44
+ ) : null}
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import { AlertCircle, RefreshCw, X } from 'lucide-react';
4
+
5
+ import { cn } from '@djangocfg/ui-core/lib';
6
+
7
+ export interface ErrorBannerProps {
8
+ error: string | null;
9
+ onDismiss?: () => void;
10
+ onRetry?: () => void;
11
+ className?: string;
12
+ }
13
+
14
+ export function ErrorBanner({ error, onDismiss, onRetry, className }: ErrorBannerProps) {
15
+ if (!error) return null;
16
+ return (
17
+ <div
18
+ role="alert"
19
+ className={cn(
20
+ 'mx-2.5 my-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive',
21
+ className,
22
+ )}
23
+ >
24
+ <AlertCircle aria-hidden className="mt-0.5 size-3.5 shrink-0" />
25
+ <p className="min-w-0 flex-1 break-words">{error}</p>
26
+ {onRetry ? (
27
+ <button
28
+ type="button"
29
+ onClick={onRetry}
30
+ className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-destructive/15"
31
+ >
32
+ <RefreshCw aria-hidden className="size-3" /> Retry
33
+ </button>
34
+ ) : null}
35
+ {onDismiss ? (
36
+ <button
37
+ type="button"
38
+ aria-label="Dismiss"
39
+ onClick={onDismiss}
40
+ className="rounded p-0.5 hover:bg-destructive/15"
41
+ >
42
+ <X aria-hidden className="size-3" />
43
+ </button>
44
+ ) : null}
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { ArrowDown } from 'lucide-react';
4
+
5
+ import { cn } from '@djangocfg/ui-core/lib';
6
+
7
+ export interface JumpToLatestProps {
8
+ visible?: boolean;
9
+ unreadCount?: number;
10
+ onClick?: () => void;
11
+ className?: string;
12
+ }
13
+
14
+ export function JumpToLatest({ visible, unreadCount = 0, onClick, className }: JumpToLatestProps) {
15
+ if (!visible) return null;
16
+ return (
17
+ <button
18
+ type="button"
19
+ onClick={onClick}
20
+ aria-live="polite"
21
+ className={cn(
22
+ 'pointer-events-auto inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-3 py-1 text-xs shadow-md hover:bg-accent',
23
+ className,
24
+ )}
25
+ >
26
+ <ArrowDown aria-hidden className="size-3.5" />
27
+ {unreadCount > 0 ? `${unreadCount} new` : 'Jump to latest'}
28
+ </button>
29
+ );
30
+ }