@epicbagel/saas-kit 1.4.0 → 1.5.0

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.
package/README.md CHANGED
@@ -160,6 +160,41 @@ public in 1.1.0, and these were the decisions about its props then:
160
160
  scroller neither anchors it nor tracks it. It has no time marker, files or
161
161
  rich `content`, and the turns either side of it keep their time grouping.
162
162
 
163
+ ### A turn's actions (1.5.0)
164
+
165
+ - **Under a reply, a row of actions.** `messageActions(message)` returns the
166
+ actions under a turn, after prompt-kit's "Message with Actions": small
167
+ icon-only ghost buttons with tooltips, drawn in a `MessageActions` row at
168
+ the message's side. `ChatCopyAction` is the ready one: it copies the turn's
169
+ `text`, its Markdown source, and shows a check and "Copied" for a moment.
170
+ Add your own after it with `MessageAction` (an icon child, a `tooltip`, and
171
+ `label` when the tooltip isn't text).
172
+
173
+ ```tsx
174
+ <Chat
175
+ messages={messages}
176
+ onSend={send}
177
+ messageActions={(message) => (
178
+ <>
179
+ <ChatCopyAction message={message} onCopy={(ok) => ok && track("copy")} />
180
+ <MessageAction tooltip="Good reply" onClick={() => rate(message.id, "up")}>
181
+ <ThumbsUp />
182
+ </MessageAction>
183
+ </>
184
+ )}
185
+ />
186
+ ```
187
+
188
+ - **Which turns.** Assistant turns, by default. `ChatMessage.actions: false`
189
+ drops them from a turn that isn't a reply (a progress line), `true` gives
190
+ them to a user turn. The running line, a running turn, a turn with
191
+ `status: "sending"` (the user's own turn shown optimistically, until its
192
+ stored copy replaces it) and the footer never have them.
193
+ - **Always there.** The row shows on every screen, a phone included, rather
194
+ than on hover, so nothing moves when a pointer reaches it, and the copied
195
+ state swaps one icon for another of the same size. A button is 2rem
196
+ square, 2.75rem on a touch screen, where the tooltip can't show.
197
+
163
198
  ## File preview (1.3.0)
164
199
 
165
200
  `primitives/file-preview` shows a file in a dialog without leaving the page:
@@ -1,10 +1,20 @@
1
1
  "use client";
2
+ import {
3
+ Tooltip,
4
+ TooltipContent,
5
+ TooltipProvider,
6
+ TooltipTrigger
7
+ } from "./chunk-ZRLUUZHK.js";
8
+ import {
9
+ Button
10
+ } from "./chunk-HQRWRRZJ.js";
2
11
  import {
3
12
  cn
4
13
  } from "./chunk-G6SRSWXB.js";
5
14
 
6
15
  // src/primitives/message.tsx
7
- import { jsx } from "react/jsx-runtime";
16
+ import * as React from "react";
17
+ import { jsx, jsxs } from "react/jsx-runtime";
8
18
  function MessageGroup({ className, ...props }) {
9
19
  return /* @__PURE__ */ jsx(
10
20
  "div",
@@ -85,6 +95,53 @@ function MessageFooter({ className, ...props }) {
85
95
  }
86
96
  );
87
97
  }
98
+ function MessageActions({ className, ...props }) {
99
+ return /* @__PURE__ */ jsx(
100
+ "div",
101
+ {
102
+ "data-slot": "message-actions",
103
+ className: cn(
104
+ "cn-message-actions flex max-w-full min-w-0 items-center gap-0.5 text-muted-foreground empty:hidden group-data-[align=end]/message:justify-end",
105
+ className
106
+ ),
107
+ ...props
108
+ }
109
+ );
110
+ }
111
+ var MessageAction = React.forwardRef(
112
+ function MessageAction2({
113
+ tooltip,
114
+ label,
115
+ side = "bottom",
116
+ variant = "quiet",
117
+ type = "button",
118
+ className,
119
+ children,
120
+ ...props
121
+ }, ref) {
122
+ const name = label ?? (typeof tooltip === "string" ? tooltip : void 0);
123
+ return /* @__PURE__ */ jsx(TooltipProvider, { children: /* @__PURE__ */ jsxs(Tooltip, { children: [
124
+ /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
125
+ Button,
126
+ {
127
+ ref,
128
+ type,
129
+ variant,
130
+ size: "icon-compact",
131
+ "aria-label": name,
132
+ "data-slot": "message-action",
133
+ className: cn(
134
+ "cn-message-action size-8 shrink-0 rounded-full pointer-coarse:size-11",
135
+ className
136
+ ),
137
+ ...props,
138
+ children
139
+ }
140
+ ) }),
141
+ /* @__PURE__ */ jsx(TooltipContent, { side, children: tooltip })
142
+ ] }) });
143
+ }
144
+ );
88
145
 
89
146
  export {
90
147
  MessageGroup,
@@ -92,5 +149,7 @@ export {
92
149
  MessageAvatar,
93
150
  MessageContent,
94
151
  MessageHeader,
95
- MessageFooter
152
+ MessageFooter,
153
+ MessageActions,
154
+ MessageAction
96
155
  };
@@ -1,9 +1,19 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true});"use client";
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } }"use client";
2
+
3
+
4
+
5
+
6
+
7
+ var _chunkU2NES7VRcjs = require('./chunk-U2NES7VR.cjs');
8
+
9
+
10
+ var _chunkL4DH72UFcjs = require('./chunk-L4DH72UF.cjs');
2
11
 
3
12
 
4
13
  var _chunkKWLSBTB2cjs = require('./chunk-KWLSBTB2.cjs');
5
14
 
6
15
  // src/primitives/message.tsx
16
+ var _react = require('react'); var React = _interopRequireWildcard(_react);
7
17
  var _jsxruntime = require('react/jsx-runtime');
8
18
  function MessageGroup({ className, ...props }) {
9
19
  return /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
@@ -85,6 +95,55 @@ function MessageFooter({ className, ...props }) {
85
95
  }
86
96
  );
87
97
  }
98
+ function MessageActions({ className, ...props }) {
99
+ return /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
100
+ "div",
101
+ {
102
+ "data-slot": "message-actions",
103
+ className: _chunkKWLSBTB2cjs.cn.call(void 0,
104
+ "cn-message-actions flex max-w-full min-w-0 items-center gap-0.5 text-muted-foreground empty:hidden group-data-[align=end]/message:justify-end",
105
+ className
106
+ ),
107
+ ...props
108
+ }
109
+ );
110
+ }
111
+ var MessageAction = React.forwardRef(
112
+ function MessageAction2({
113
+ tooltip,
114
+ label,
115
+ side = "bottom",
116
+ variant = "quiet",
117
+ type = "button",
118
+ className,
119
+ children,
120
+ ...props
121
+ }, ref) {
122
+ const name = _nullishCoalesce(label, () => ( (typeof tooltip === "string" ? tooltip : void 0)));
123
+ return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkU2NES7VRcjs.TooltipProvider, { children: /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkU2NES7VRcjs.Tooltip, { children: [
124
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkU2NES7VRcjs.TooltipTrigger, { asChild: true, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
125
+ _chunkL4DH72UFcjs.Button,
126
+ {
127
+ ref,
128
+ type,
129
+ variant,
130
+ size: "icon-compact",
131
+ "aria-label": name,
132
+ "data-slot": "message-action",
133
+ className: _chunkKWLSBTB2cjs.cn.call(void 0,
134
+ "cn-message-action size-8 shrink-0 rounded-full pointer-coarse:size-11",
135
+ className
136
+ ),
137
+ ...props,
138
+ children
139
+ }
140
+ ) }),
141
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkU2NES7VRcjs.TooltipContent, { side, children: tooltip })
142
+ ] }) });
143
+ }
144
+ );
145
+
146
+
88
147
 
89
148
 
90
149
 
@@ -93,4 +152,4 @@ function MessageFooter({ className, ...props }) {
93
152
 
94
153
 
95
154
 
96
- exports.MessageGroup = MessageGroup; exports.Message = Message; exports.MessageAvatar = MessageAvatar; exports.MessageContent = MessageContent; exports.MessageHeader = MessageHeader; exports.MessageFooter = MessageFooter;
155
+ exports.MessageGroup = MessageGroup; exports.Message = Message; exports.MessageAvatar = MessageAvatar; exports.MessageContent = MessageContent; exports.MessageHeader = MessageHeader; exports.MessageFooter = MessageFooter; exports.MessageActions = MessageActions; exports.MessageAction = MessageAction;
@@ -23,7 +23,9 @@ var _chunkXABXUP6Lcjs = require('../chunk-XABXUP6L.cjs');
23
23
 
24
24
 
25
25
 
26
- var _chunk2AU7ZRGXcjs = require('../chunk-2AU7ZRGX.cjs');
26
+
27
+
28
+ var _chunkLAN3K2JIcjs = require('../chunk-LAN3K2JI.cjs');
27
29
 
28
30
 
29
31
 
@@ -75,6 +77,9 @@ require('../chunk-PQUSJEXL.cjs');
75
77
  var _chunkL4DH72UFcjs = require('../chunk-L4DH72UF.cjs');
76
78
 
77
79
 
80
+ var _chunk4LXN6GLZcjs = require('../chunk-4LXN6GLZ.cjs');
81
+
82
+
78
83
  var _chunkR35FT53Gcjs = require('../chunk-R35FT53G.cjs');
79
84
 
80
85
 
@@ -104,8 +109,14 @@ var _react = require('react');
104
109
 
105
110
 
106
111
 
112
+
113
+
107
114
  var _lucidereact = require('lucide-react');
108
115
  var _jsxruntime = require('react/jsx-runtime');
116
+ function offersActions(message) {
117
+ if (message.status) return false;
118
+ return _nullishCoalesce(message.actions, () => ( message.role === "assistant"));
119
+ }
109
120
  function useArrivingUserTurnId(messages) {
110
121
  const seen = _react.useRef.call(void 0, null);
111
122
  seen.current ??= new Set(messages.map((m) => m.id));
@@ -138,7 +149,7 @@ function ChatRunningLine({
138
149
  align = "start",
139
150
  workspace
140
151
  }) {
141
- return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk2AU7ZRGXcjs.Message, { align, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk2AU7ZRGXcjs.MessageContent, { children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk2BTDBZYNcjs.Trace, { className: workspace ? void 0 : "px-3.5 py-2", children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk2BTDBZYNcjs.TraceLine, { status: "running", children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk2BTDBZYNcjs.TraceTitle, { children: label }) }) }) }) });
152
+ return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLAN3K2JIcjs.Message, { align, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLAN3K2JIcjs.MessageContent, { children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk2BTDBZYNcjs.Trace, { className: workspace ? void 0 : "px-3.5 py-2", children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk2BTDBZYNcjs.TraceLine, { status: "running", children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk2BTDBZYNcjs.TraceTitle, { children: label }) }) }) }) });
142
153
  }
143
154
  var ChatThread = _react.forwardRef.call(void 0,
144
155
  function ChatThread2({
@@ -153,6 +164,7 @@ var ChatThread = _react.forwardRef.call(void 0,
153
164
  markdownComponents,
154
165
  onOpenAttachment,
155
166
  attachmentAction = "open",
167
+ messageActions,
156
168
  className
157
169
  }, ref) {
158
170
  const markers = timelineMarkers(messages, /* @__PURE__ */ new Date());
@@ -186,6 +198,7 @@ var ChatThread = _react.forwardRef.call(void 0,
186
198
  }
187
199
  const isFullWidth = m.width === "full";
188
200
  const marker = markers[index];
201
+ const actions = messageActions && offersActions(m) ? messageActions(m) : null;
189
202
  return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0,
190
203
  _chunkXABXUP6Lcjs.MessageScrollerItem,
191
204
  {
@@ -194,7 +207,7 @@ var ChatThread = _react.forwardRef.call(void 0,
194
207
  className: _chunkKWLSBTB2cjs.cn.call(void 0, "flex flex-col", workspace ? "gap-5" : "gap-3"),
195
208
  children: [
196
209
  marker ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, TimelineMarker, { label: marker }) : null,
197
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk2AU7ZRGXcjs.Message, { align: isUser ? "end" : "start", children: /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunk2AU7ZRGXcjs.MessageContent, { children: [
210
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLAN3K2JIcjs.Message, { align: isUser ? "end" : "start", children: /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkLAN3K2JIcjs.MessageContent, { children: [
198
211
  (m.content || m.text) && /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
199
212
  _chunkV4YWTRLDcjs.Bubble,
200
213
  {
@@ -222,7 +235,21 @@ var ChatThread = _react.forwardRef.call(void 0,
222
235
  uploadingLabel,
223
236
  className: "px-0 pt-0"
224
237
  }
225
- )
238
+ ),
239
+ actions != null && actions !== false ? (
240
+ // Unboxed workspace turns pull the row out by the
241
+ // button's inset, so the first icon lines up with
242
+ // the text above it.
243
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
244
+ _chunkLAN3K2JIcjs.MessageActions,
245
+ {
246
+ className: _chunkKWLSBTB2cjs.cn.call(void 0,
247
+ workspace && (isUser ? "-me-2 pointer-coarse:-me-3.5" : "-ms-2 pointer-coarse:-ms-3.5")
248
+ ),
249
+ children: actions
250
+ }
251
+ )
252
+ ) : null
226
253
  ] }) })
227
254
  ]
228
255
  },
@@ -233,7 +260,7 @@ var ChatThread = _react.forwardRef.call(void 0,
233
260
  footer && // Always painted: content-visibility:auto would contain the
234
261
  // footer's paint and layout, clipping a ring or shadow at its
235
262
  // edge and re-parenting anything fixed inside it.
236
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkXABXUP6Lcjs.MessageScrollerItem, { className: "[content-visibility:visible]", children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk2AU7ZRGXcjs.Message, { align: "start", children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk2AU7ZRGXcjs.MessageContent, { children: footer }) }) })
263
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkXABXUP6Lcjs.MessageScrollerItem, { className: "[content-visibility:visible]", children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLAN3K2JIcjs.Message, { align: "start", children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkLAN3K2JIcjs.MessageContent, { children: footer }) }) })
237
264
  ]
238
265
  }
239
266
  ) }),
@@ -246,6 +273,29 @@ var ChatThread = _react.forwardRef.call(void 0,
246
273
  ) });
247
274
  }
248
275
  );
276
+ function ChatCopyAction({
277
+ message,
278
+ label = "Copy",
279
+ copiedLabel = "Copied",
280
+ onCopy,
281
+ resetAfter = 2e3
282
+ }) {
283
+ const { copied, copy } = _chunk4LXN6GLZcjs.useCopyToClipboard.call(void 0, { resetAfter });
284
+ const text = message.text;
285
+ if (!text) return null;
286
+ return /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
287
+ _chunkLAN3K2JIcjs.MessageAction,
288
+ {
289
+ tooltip: copied ? copiedLabel : label,
290
+ "data-copied": copied || void 0,
291
+ onClick: async () => {
292
+ const ok = await copy(text);
293
+ _optionalChain([onCopy, 'optionalCall', _ => _(ok)]);
294
+ },
295
+ children: copied ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Check, {}) : /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Copy, {})
296
+ }
297
+ );
298
+ }
249
299
  function formatFileSize(size) {
250
300
  if (size === void 0) return "File";
251
301
  if (size < 1024) return `${size} B`;
@@ -285,7 +335,7 @@ function ChatAttachmentList({
285
335
  {
286
336
  "aria-label": `${verb} ${attachment.name}`,
287
337
  title: `${verb} ${attachment.name}`,
288
- onClick: () => _optionalChain([onOpen, 'optionalCall', _ => _(attachment)]),
338
+ onClick: () => _optionalChain([onOpen, 'optionalCall', _2 => _2(attachment)]),
289
339
  className: "rounded-xl focus-visible:ring-2 focus-visible:ring-ring/50"
290
340
  }
291
341
  ),
@@ -430,7 +480,7 @@ function ChatInput({
430
480
  "data-slot": "chat-input-actions",
431
481
  onClick: (event) => {
432
482
  if (!event.currentTarget.contains(event.target)) return;
433
- if (!event.target.closest("button")) _optionalChain([ref, 'access', _2 => _2.current, 'optionalAccess', _3 => _3.focus, 'call', _4 => _4()]);
483
+ if (!event.target.closest("button")) _optionalChain([ref, 'access', _3 => _3.current, 'optionalAccess', _4 => _4.focus, 'call', _5 => _5()]);
434
484
  },
435
485
  className: _chunkKWLSBTB2cjs.cn.call(void 0,
436
486
  "gap-2",
@@ -448,7 +498,7 @@ function ChatInput({
448
498
  children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Plus, { className: _chunkKWLSBTB2cjs.cn.call(void 0, spacious && "h-5 w-5") })
449
499
  }
450
500
  ) }),
451
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkZAITUDYDcjs.DropdownMenuContent, { align: "start", side: "top", className: "min-w-52", children: /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkZAITUDYDcjs.DropdownMenuItem, { onSelect: () => _optionalChain([fileInputRef, 'access', _5 => _5.current, 'optionalAccess', _6 => _6.click, 'call', _7 => _7()]), children: [
501
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkZAITUDYDcjs.DropdownMenuContent, { align: "start", side: "top", className: "min-w-52", children: /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkZAITUDYDcjs.DropdownMenuItem, { onSelect: () => _optionalChain([fileInputRef, 'access', _6 => _6.current, 'optionalAccess', _7 => _7.click, 'call', _8 => _8()]), children: [
452
502
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Paperclip, { className: "h-4 w-4" }),
453
503
  addFilesLabel
454
504
  ] }) })
@@ -655,6 +705,7 @@ var Chat = _react.forwardRef.call(void 0, function Chat2({
655
705
  markdownComponents,
656
706
  onOpenAttachment,
657
707
  attachmentAction,
708
+ messageActions,
658
709
  className
659
710
  }, ref) {
660
711
  const isMobile = _chunkR35FT53Gcjs.useIsMobile.call(void 0, );
@@ -679,14 +730,14 @@ var Chat = _react.forwardRef.call(void 0, function Chat2({
679
730
  else if (typeof globalThis.reportError === "function") globalThis.reportError(cause);
680
731
  else console.error(cause);
681
732
  }
682
- window.setTimeout(() => _optionalChain([inputRef, 'access', _8 => _8.current, 'optionalAccess', _9 => _9.focus, 'call', _10 => _10()]), 0);
733
+ window.setTimeout(() => _optionalChain([inputRef, 'access', _9 => _9.current, 'optionalAccess', _10 => _10.focus, 'call', _11 => _11()]), 0);
683
734
  },
684
735
  [attachments, mode, onSend, onSendError, pending]
685
736
  );
686
737
  _react.useImperativeHandle.call(void 0,
687
738
  ref,
688
739
  () => ({
689
- focusInput: () => _optionalChain([inputRef, 'access', _11 => _11.current, 'optionalAccess', _12 => _12.focus, 'call', _13 => _13()]),
740
+ focusInput: () => _optionalChain([inputRef, 'access', _12 => _12.current, 'optionalAccess', _13 => _13.focus, 'call', _14 => _14()]),
690
741
  clearInput: () => setInput("")
691
742
  }),
692
743
  []
@@ -694,7 +745,7 @@ var Chat = _react.forwardRef.call(void 0, function Chat2({
694
745
  const dropFiles = _react.useCallback.call(void 0,
695
746
  (files) => {
696
747
  if (!conversationStarted && mode === "voice") setUserMode("text");
697
- return _optionalChain([onFilesSelected, 'optionalCall', _14 => _14(files)]);
748
+ return _optionalChain([onFilesSelected, 'optionalCall', _15 => _15(files)]);
698
749
  },
699
750
  [conversationStarted, mode, onFilesSelected]
700
751
  );
@@ -851,6 +902,7 @@ var Chat = _react.forwardRef.call(void 0, function Chat2({
851
902
  markdownComponents,
852
903
  onOpenAttachment,
853
904
  attachmentAction,
905
+ messageActions,
854
906
  className: _chunkKWLSBTB2cjs.cn.call(void 0,
855
907
  "mb-3",
856
908
  application && "min-h-0 flex-1 px-4 py-4 md:px-0 md:py-0",
@@ -905,4 +957,5 @@ var Chat = _react.forwardRef.call(void 0, function Chat2({
905
957
 
906
958
 
907
959
 
908
- exports.Chat = Chat; exports.ChatInput = ChatInput; exports.ChatThread = ChatThread;
960
+
961
+ exports.Chat = Chat; exports.ChatCopyAction = ChatCopyAction; exports.ChatInput = ChatInput; exports.ChatThread = ChatThread;
@@ -43,8 +43,20 @@ type ChatMessage = {
43
43
  * with the reply when that lands. The line is not a message: it has no time
44
44
  * marker, files or rich `content`, and the scroller neither anchors nor
45
45
  * tracks it.
46
+ *
47
+ * `"sending"`: a turn still on its way to being stored, such as the user's
48
+ * own turn shown optimistically. It is drawn as any turn, but offers no
49
+ * actions until its stored copy replaces it.
46
50
  */
47
- status?: "running";
51
+ status?: "running" | "sending";
52
+ /**
53
+ * Whether the thread offers this turn the actions `messageActions` returns.
54
+ * By default an assistant turn has them and a user turn doesn't; `false`
55
+ * drops them from an assistant turn that isn't a reply (a progress line),
56
+ * and `true` gives them to a user turn. A running or sending turn never has
57
+ * them, whatever this says.
58
+ */
59
+ actions?: boolean;
48
60
  };
49
61
  interface ChatThreadProps {
50
62
  messages: ChatMessage[];
@@ -80,6 +92,15 @@ interface ChatThreadProps {
80
92
  onOpenAttachment?: (attachment: ChatAttachment) => void;
81
93
  /** What opening a message's file does, for its label and icon. Defaults to `"open"`. */
82
94
  attachmentAction?: ChatAttachmentAction;
95
+ /**
96
+ * The actions under a turn, in a `MessageActions` row: `ChatCopyAction`,
97
+ * then any `MessageAction`s of your own (a thumbs up or down). Called for
98
+ * each turn that offers them (`ChatMessage.actions`: assistant turns, by
99
+ * default), never for the running line, a running or sending turn or the
100
+ * footer. The row always shows, on a phone too, so nothing moves when a
101
+ * pointer reaches it; return `null` for none.
102
+ */
103
+ messageActions?: (message: ChatMessage) => ReactNode;
83
104
  className?: string;
84
105
  }
85
106
  /**
@@ -97,6 +118,24 @@ interface ChatThreadProps {
97
118
  * appears. A turn with `status: "running"` draws the same line in its place.
98
119
  */
99
120
  declare const ChatThread: React$1.ForwardRefExoticComponent<ChatThreadProps & React$1.RefAttributes<HTMLDivElement>>;
121
+ interface ChatCopyActionProps {
122
+ /** The turn whose `text` (its Markdown source) is copied. */
123
+ message: Pick<ChatMessage, "text">;
124
+ /** The tooltip and accessible name. Defaults to "Copy". */
125
+ label?: string;
126
+ /** Both, for a moment after a copy. Defaults to "Copied". */
127
+ copiedLabel?: string;
128
+ /** Called after each copy, with whether the clipboard took the text. */
129
+ onCopy?: (success: boolean) => void;
130
+ /** How long the copied state shows, in ms. Defaults to 2000. */
131
+ resetAfter?: number;
132
+ }
133
+ /**
134
+ * The ready Copy action for `messageActions`: copies the turn's `text`, its
135
+ * Markdown source, and shows a check and "Copied" for a moment. A turn with
136
+ * no `text` (rich `content` only) has nothing to copy, so it draws nothing.
137
+ */
138
+ declare function ChatCopyAction({ message, label, copiedLabel, onCopy, resetAfter, }: ChatCopyActionProps): react_jsx_runtime.JSX.Element | null;
100
139
  interface ChatInputProps {
101
140
  value: string;
102
141
  onChange: (next: string) => void;
@@ -330,6 +369,11 @@ interface ChatProps {
330
369
  onOpenAttachment?: (attachment: ChatAttachment) => void;
331
370
  /** What opening a message's file does, for its label and icon. Defaults to `"open"`. */
332
371
  attachmentAction?: ChatAttachmentAction;
372
+ /**
373
+ * The actions under each turn that offers them (`ChatMessage.actions`:
374
+ * assistant turns, by default), such as `ChatCopyAction`. See `ChatThread`.
375
+ */
376
+ messageActions?: (message: ChatMessage) => ReactNode;
333
377
  className?: string;
334
378
  }
335
379
  interface ChatHandle {
@@ -361,4 +405,4 @@ interface ChatHandle {
361
405
  */
362
406
  declare const Chat: React$1.ForwardRefExoticComponent<ChatProps & React$1.RefAttributes<ChatHandle>>;
363
407
 
364
- export { Chat, type ChatAttachment, type ChatAttachmentAction, type ChatHandle, ChatInput, type ChatInputProps, type ChatMessage, type ChatProps, type ChatRole, ChatThread, type ChatThreadProps };
408
+ export { Chat, type ChatAttachment, type ChatAttachmentAction, ChatCopyAction, type ChatCopyActionProps, type ChatHandle, ChatInput, type ChatInputProps, type ChatMessage, type ChatProps, type ChatRole, ChatThread, type ChatThreadProps };
@@ -43,8 +43,20 @@ type ChatMessage = {
43
43
  * with the reply when that lands. The line is not a message: it has no time
44
44
  * marker, files or rich `content`, and the scroller neither anchors nor
45
45
  * tracks it.
46
+ *
47
+ * `"sending"`: a turn still on its way to being stored, such as the user's
48
+ * own turn shown optimistically. It is drawn as any turn, but offers no
49
+ * actions until its stored copy replaces it.
46
50
  */
47
- status?: "running";
51
+ status?: "running" | "sending";
52
+ /**
53
+ * Whether the thread offers this turn the actions `messageActions` returns.
54
+ * By default an assistant turn has them and a user turn doesn't; `false`
55
+ * drops them from an assistant turn that isn't a reply (a progress line),
56
+ * and `true` gives them to a user turn. A running or sending turn never has
57
+ * them, whatever this says.
58
+ */
59
+ actions?: boolean;
48
60
  };
49
61
  interface ChatThreadProps {
50
62
  messages: ChatMessage[];
@@ -80,6 +92,15 @@ interface ChatThreadProps {
80
92
  onOpenAttachment?: (attachment: ChatAttachment) => void;
81
93
  /** What opening a message's file does, for its label and icon. Defaults to `"open"`. */
82
94
  attachmentAction?: ChatAttachmentAction;
95
+ /**
96
+ * The actions under a turn, in a `MessageActions` row: `ChatCopyAction`,
97
+ * then any `MessageAction`s of your own (a thumbs up or down). Called for
98
+ * each turn that offers them (`ChatMessage.actions`: assistant turns, by
99
+ * default), never for the running line, a running or sending turn or the
100
+ * footer. The row always shows, on a phone too, so nothing moves when a
101
+ * pointer reaches it; return `null` for none.
102
+ */
103
+ messageActions?: (message: ChatMessage) => ReactNode;
83
104
  className?: string;
84
105
  }
85
106
  /**
@@ -97,6 +118,24 @@ interface ChatThreadProps {
97
118
  * appears. A turn with `status: "running"` draws the same line in its place.
98
119
  */
99
120
  declare const ChatThread: React$1.ForwardRefExoticComponent<ChatThreadProps & React$1.RefAttributes<HTMLDivElement>>;
121
+ interface ChatCopyActionProps {
122
+ /** The turn whose `text` (its Markdown source) is copied. */
123
+ message: Pick<ChatMessage, "text">;
124
+ /** The tooltip and accessible name. Defaults to "Copy". */
125
+ label?: string;
126
+ /** Both, for a moment after a copy. Defaults to "Copied". */
127
+ copiedLabel?: string;
128
+ /** Called after each copy, with whether the clipboard took the text. */
129
+ onCopy?: (success: boolean) => void;
130
+ /** How long the copied state shows, in ms. Defaults to 2000. */
131
+ resetAfter?: number;
132
+ }
133
+ /**
134
+ * The ready Copy action for `messageActions`: copies the turn's `text`, its
135
+ * Markdown source, and shows a check and "Copied" for a moment. A turn with
136
+ * no `text` (rich `content` only) has nothing to copy, so it draws nothing.
137
+ */
138
+ declare function ChatCopyAction({ message, label, copiedLabel, onCopy, resetAfter, }: ChatCopyActionProps): react_jsx_runtime.JSX.Element | null;
100
139
  interface ChatInputProps {
101
140
  value: string;
102
141
  onChange: (next: string) => void;
@@ -330,6 +369,11 @@ interface ChatProps {
330
369
  onOpenAttachment?: (attachment: ChatAttachment) => void;
331
370
  /** What opening a message's file does, for its label and icon. Defaults to `"open"`. */
332
371
  attachmentAction?: ChatAttachmentAction;
372
+ /**
373
+ * The actions under each turn that offers them (`ChatMessage.actions`:
374
+ * assistant turns, by default), such as `ChatCopyAction`. See `ChatThread`.
375
+ */
376
+ messageActions?: (message: ChatMessage) => ReactNode;
333
377
  className?: string;
334
378
  }
335
379
  interface ChatHandle {
@@ -361,4 +405,4 @@ interface ChatHandle {
361
405
  */
362
406
  declare const Chat: React$1.ForwardRefExoticComponent<ChatProps & React$1.RefAttributes<ChatHandle>>;
363
407
 
364
- export { Chat, type ChatAttachment, type ChatAttachmentAction, type ChatHandle, ChatInput, type ChatInputProps, type ChatMessage, type ChatProps, type ChatRole, ChatThread, type ChatThreadProps };
408
+ export { Chat, type ChatAttachment, type ChatAttachmentAction, ChatCopyAction, type ChatCopyActionProps, type ChatHandle, ChatInput, type ChatInputProps, type ChatMessage, type ChatProps, type ChatRole, ChatThread, type ChatThreadProps };
@@ -22,8 +22,10 @@ import {
22
22
  } from "../chunk-GFG2R5BZ.js";
23
23
  import {
24
24
  Message,
25
+ MessageAction,
26
+ MessageActions,
25
27
  MessageContent
26
- } from "../chunk-JZOBIPFC.js";
28
+ } from "../chunk-FGISSYXG.js";
27
29
  import {
28
30
  Marker,
29
31
  MarkerContent
@@ -73,6 +75,9 @@ import "../chunk-QMA3WVB7.js";
73
75
  import {
74
76
  Button
75
77
  } from "../chunk-HQRWRRZJ.js";
78
+ import {
79
+ useCopyToClipboard
80
+ } from "../chunk-ZWZAAGQF.js";
76
81
  import {
77
82
  useIsMobile
78
83
  } from "../chunk-PIU5SSUV.js";
@@ -98,6 +103,8 @@ import {
98
103
  ArrowRight,
99
104
  ArrowUp,
100
105
  ArrowUpRight,
106
+ Check,
107
+ Copy,
101
108
  Download,
102
109
  FileText,
103
110
  LockKeyhole,
@@ -106,6 +113,10 @@ import {
106
113
  X
107
114
  } from "lucide-react";
108
115
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
116
+ function offersActions(message) {
117
+ if (message.status) return false;
118
+ return message.actions ?? message.role === "assistant";
119
+ }
109
120
  function useArrivingUserTurnId(messages) {
110
121
  const seen = useRef(null);
111
122
  seen.current ??= new Set(messages.map((m) => m.id));
@@ -153,6 +164,7 @@ var ChatThread = forwardRef(
153
164
  markdownComponents,
154
165
  onOpenAttachment,
155
166
  attachmentAction = "open",
167
+ messageActions,
156
168
  className
157
169
  }, ref) {
158
170
  const markers = timelineMarkers(messages, /* @__PURE__ */ new Date());
@@ -186,6 +198,7 @@ var ChatThread = forwardRef(
186
198
  }
187
199
  const isFullWidth = m.width === "full";
188
200
  const marker = markers[index];
201
+ const actions = messageActions && offersActions(m) ? messageActions(m) : null;
189
202
  return /* @__PURE__ */ jsxs(
190
203
  MessageScrollerItem,
191
204
  {
@@ -222,7 +235,21 @@ var ChatThread = forwardRef(
222
235
  uploadingLabel,
223
236
  className: "px-0 pt-0"
224
237
  }
225
- )
238
+ ),
239
+ actions != null && actions !== false ? (
240
+ // Unboxed workspace turns pull the row out by the
241
+ // button's inset, so the first icon lines up with
242
+ // the text above it.
243
+ /* @__PURE__ */ jsx(
244
+ MessageActions,
245
+ {
246
+ className: cn(
247
+ workspace && (isUser ? "-me-2 pointer-coarse:-me-3.5" : "-ms-2 pointer-coarse:-ms-3.5")
248
+ ),
249
+ children: actions
250
+ }
251
+ )
252
+ ) : null
226
253
  ] }) })
227
254
  ]
228
255
  },
@@ -246,6 +273,29 @@ var ChatThread = forwardRef(
246
273
  ) });
247
274
  }
248
275
  );
276
+ function ChatCopyAction({
277
+ message,
278
+ label = "Copy",
279
+ copiedLabel = "Copied",
280
+ onCopy,
281
+ resetAfter = 2e3
282
+ }) {
283
+ const { copied, copy } = useCopyToClipboard({ resetAfter });
284
+ const text = message.text;
285
+ if (!text) return null;
286
+ return /* @__PURE__ */ jsx(
287
+ MessageAction,
288
+ {
289
+ tooltip: copied ? copiedLabel : label,
290
+ "data-copied": copied || void 0,
291
+ onClick: async () => {
292
+ const ok = await copy(text);
293
+ onCopy?.(ok);
294
+ },
295
+ children: copied ? /* @__PURE__ */ jsx(Check, {}) : /* @__PURE__ */ jsx(Copy, {})
296
+ }
297
+ );
298
+ }
249
299
  function formatFileSize(size) {
250
300
  if (size === void 0) return "File";
251
301
  if (size < 1024) return `${size} B`;
@@ -655,6 +705,7 @@ var Chat = forwardRef(function Chat2({
655
705
  markdownComponents,
656
706
  onOpenAttachment,
657
707
  attachmentAction,
708
+ messageActions,
658
709
  className
659
710
  }, ref) {
660
711
  const isMobile = useIsMobile();
@@ -851,6 +902,7 @@ var Chat = forwardRef(function Chat2({
851
902
  markdownComponents,
852
903
  onOpenAttachment,
853
904
  attachmentAction,
905
+ messageActions,
854
906
  className: cn(
855
907
  "mb-3",
856
908
  application && "min-h-0 flex-1 px-4 py-4 md:px-0 md:py-0",
@@ -903,6 +955,7 @@ var Chat = forwardRef(function Chat2({
903
955
  });
904
956
  export {
905
957
  Chat,
958
+ ChatCopyAction,
906
959
  ChatInput,
907
960
  ChatThread
908
961
  };
@@ -6,7 +6,11 @@
6
6
 
7
7
 
8
8
 
9
- var _chunk2AU7ZRGXcjs = require('../chunk-2AU7ZRGX.cjs');
9
+
10
+
11
+ var _chunkLAN3K2JIcjs = require('../chunk-LAN3K2JI.cjs');
12
+ require('../chunk-U2NES7VR.cjs');
13
+ require('../chunk-L4DH72UF.cjs');
10
14
  require('../chunk-KWLSBTB2.cjs');
11
15
 
12
16
 
@@ -15,4 +19,6 @@ require('../chunk-KWLSBTB2.cjs');
15
19
 
16
20
 
17
21
 
18
- exports.Message = _chunk2AU7ZRGXcjs.Message; exports.MessageAvatar = _chunk2AU7ZRGXcjs.MessageAvatar; exports.MessageContent = _chunk2AU7ZRGXcjs.MessageContent; exports.MessageFooter = _chunk2AU7ZRGXcjs.MessageFooter; exports.MessageGroup = _chunk2AU7ZRGXcjs.MessageGroup; exports.MessageHeader = _chunk2AU7ZRGXcjs.MessageHeader;
22
+
23
+
24
+ exports.Message = _chunkLAN3K2JIcjs.Message; exports.MessageAction = _chunkLAN3K2JIcjs.MessageAction; exports.MessageActions = _chunkLAN3K2JIcjs.MessageActions; exports.MessageAvatar = _chunkLAN3K2JIcjs.MessageAvatar; exports.MessageContent = _chunkLAN3K2JIcjs.MessageContent; exports.MessageFooter = _chunkLAN3K2JIcjs.MessageFooter; exports.MessageGroup = _chunkLAN3K2JIcjs.MessageGroup; exports.MessageHeader = _chunkLAN3K2JIcjs.MessageHeader;
@@ -1,5 +1,10 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as React from 'react';
3
+ import { ButtonProps } from './button.cjs';
4
+ import { TooltipContent } from './tooltip.cjs';
5
+ import 'class-variance-authority/types';
6
+ import 'class-variance-authority';
7
+ import '@radix-ui/react-tooltip';
3
8
 
4
9
  declare function MessageGroup({ className, ...props }: React.ComponentProps<"div">): react_jsx_runtime.JSX.Element;
5
10
  declare function Message({ className, align, ...props }: React.ComponentProps<"div"> & {
@@ -9,5 +14,34 @@ declare function MessageAvatar({ className, ...props }: React.ComponentProps<"di
9
14
  declare function MessageContent({ className, ...props }: React.ComponentProps<"div">): react_jsx_runtime.JSX.Element;
10
15
  declare function MessageHeader({ className, ...props }: React.ComponentProps<"div">): react_jsx_runtime.JSX.Element;
11
16
  declare function MessageFooter({ className, ...props }: React.ComponentProps<"div">): react_jsx_runtime.JSX.Element;
17
+ /**
18
+ * A row of `MessageAction`s under a message: copy, then any others (a thumbs
19
+ * up or down). It sits at the message's side, the start of a turn from the
20
+ * other party and the end of one aligned to the end, and collapses when it
21
+ * holds nothing.
22
+ */
23
+ declare function MessageActions({ className, ...props }: React.ComponentProps<"div">): react_jsx_runtime.JSX.Element;
24
+ type MessageActionProps = Omit<ButtonProps, "size" | "aria-label"> & {
25
+ /** What the action does: its tooltip, and its accessible name when it is text. */
26
+ tooltip: React.ReactNode;
27
+ /** The accessible name, for a `tooltip` that isn't plain text. */
28
+ label?: string;
29
+ /** Which side of the button the tooltip opens on. Defaults to `"bottom"`. */
30
+ side?: React.ComponentPropsWithoutRef<typeof TooltipContent>["side"];
31
+ };
32
+ /**
33
+ * One action in a `MessageActions` row: an icon-only ghost button (pass the
34
+ * icon as its child) with a tooltip and an accessible name. It is 2rem
35
+ * square, and 2.75rem on a touch screen, where the tooltip can't show on
36
+ * hover, so a finger has the kit's usual target.
37
+ */
38
+ declare const MessageAction: React.ForwardRefExoticComponent<Omit<ButtonProps, "aria-label" | "size"> & {
39
+ /** What the action does: its tooltip, and its accessible name when it is text. */
40
+ tooltip: React.ReactNode;
41
+ /** The accessible name, for a `tooltip` that isn't plain text. */
42
+ label?: string;
43
+ /** Which side of the button the tooltip opens on. Defaults to `"bottom"`. */
44
+ side?: React.ComponentPropsWithoutRef<typeof TooltipContent>["side"];
45
+ } & React.RefAttributes<HTMLButtonElement>>;
12
46
 
13
- export { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader };
47
+ export { Message, MessageAction, type MessageActionProps, MessageActions, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader };
@@ -1,5 +1,10 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as React from 'react';
3
+ import { ButtonProps } from './button.js';
4
+ import { TooltipContent } from './tooltip.js';
5
+ import 'class-variance-authority/types';
6
+ import 'class-variance-authority';
7
+ import '@radix-ui/react-tooltip';
3
8
 
4
9
  declare function MessageGroup({ className, ...props }: React.ComponentProps<"div">): react_jsx_runtime.JSX.Element;
5
10
  declare function Message({ className, align, ...props }: React.ComponentProps<"div"> & {
@@ -9,5 +14,34 @@ declare function MessageAvatar({ className, ...props }: React.ComponentProps<"di
9
14
  declare function MessageContent({ className, ...props }: React.ComponentProps<"div">): react_jsx_runtime.JSX.Element;
10
15
  declare function MessageHeader({ className, ...props }: React.ComponentProps<"div">): react_jsx_runtime.JSX.Element;
11
16
  declare function MessageFooter({ className, ...props }: React.ComponentProps<"div">): react_jsx_runtime.JSX.Element;
17
+ /**
18
+ * A row of `MessageAction`s under a message: copy, then any others (a thumbs
19
+ * up or down). It sits at the message's side, the start of a turn from the
20
+ * other party and the end of one aligned to the end, and collapses when it
21
+ * holds nothing.
22
+ */
23
+ declare function MessageActions({ className, ...props }: React.ComponentProps<"div">): react_jsx_runtime.JSX.Element;
24
+ type MessageActionProps = Omit<ButtonProps, "size" | "aria-label"> & {
25
+ /** What the action does: its tooltip, and its accessible name when it is text. */
26
+ tooltip: React.ReactNode;
27
+ /** The accessible name, for a `tooltip` that isn't plain text. */
28
+ label?: string;
29
+ /** Which side of the button the tooltip opens on. Defaults to `"bottom"`. */
30
+ side?: React.ComponentPropsWithoutRef<typeof TooltipContent>["side"];
31
+ };
32
+ /**
33
+ * One action in a `MessageActions` row: an icon-only ghost button (pass the
34
+ * icon as its child) with a tooltip and an accessible name. It is 2rem
35
+ * square, and 2.75rem on a touch screen, where the tooltip can't show on
36
+ * hover, so a finger has the kit's usual target.
37
+ */
38
+ declare const MessageAction: React.ForwardRefExoticComponent<Omit<ButtonProps, "aria-label" | "size"> & {
39
+ /** What the action does: its tooltip, and its accessible name when it is text. */
40
+ tooltip: React.ReactNode;
41
+ /** The accessible name, for a `tooltip` that isn't plain text. */
42
+ label?: string;
43
+ /** Which side of the button the tooltip opens on. Defaults to `"bottom"`. */
44
+ side?: React.ComponentPropsWithoutRef<typeof TooltipContent>["side"];
45
+ } & React.RefAttributes<HTMLButtonElement>>;
12
46
 
13
- export { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader };
47
+ export { Message, MessageAction, type MessageActionProps, MessageActions, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader };
@@ -1,15 +1,21 @@
1
1
  "use client";
2
2
  import {
3
3
  Message,
4
+ MessageAction,
5
+ MessageActions,
4
6
  MessageAvatar,
5
7
  MessageContent,
6
8
  MessageFooter,
7
9
  MessageGroup,
8
10
  MessageHeader
9
- } from "../chunk-JZOBIPFC.js";
11
+ } from "../chunk-FGISSYXG.js";
12
+ import "../chunk-ZRLUUZHK.js";
13
+ import "../chunk-HQRWRRZJ.js";
10
14
  import "../chunk-G6SRSWXB.js";
11
15
  export {
12
16
  Message,
17
+ MessageAction,
18
+ MessageActions,
13
19
  MessageAvatar,
14
20
  MessageContent,
15
21
  MessageFooter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epicbagel/saas-kit",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Accessible React primitives, layout components and design tokens for SaaS apps, built on Radix UI and Tailwind CSS v4.",
5
5
  "license": "MIT",
6
6
  "keywords": [