@darqlabs/curator-sdk-react 0.3.4 → 0.4.1

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.
@@ -20,8 +20,9 @@ const CuratorAdapter_js_1 = require("./CuratorAdapter.js");
20
20
  const brand_js_1 = require("./brand.js");
21
21
  const Markdown_js_1 = require("./Markdown.js");
22
22
  const attachmentEnvelope_js_1 = require("./attachmentEnvelope.js");
23
+ const CuratorAttachmentAdapter_js_1 = require("./CuratorAttachmentAdapter.js");
23
24
  function CuratorChat(props) {
24
- const { curator, agent, project, environment, system, conversationId, title = "Chat", placeholder = "Type a message…", welcomeMessage, theme = "light", className, style, onClose, loadingIndicator, onMessage, onState, onPlanProgress, onError, enableRetry = false, enableCopy = false, showTimestamps = true, queueWhileRunning = true, } = props;
25
+ const { curator, agent, project, environment, system, conversationId, title = "Chat", placeholder = "Type a message…", welcomeMessage, theme = "light", className, style, onClose, loadingIndicator, onMessage, onState, onPlanProgress, onError, enableRetry = false, enableCopy = false, showTimestamps = true, enableAttachments = false, hideHeader = false, prompts, tokens, acceptedFileTypes, maxFileSize, queueWhileRunning = true, } = props;
25
26
  const contextClient = (0, CuratorProvider_js_1.useCuratorOrNull)();
26
27
  const client = curator ?? contextClient;
27
28
  if (!client) {
@@ -34,8 +35,17 @@ function CuratorChat(props) {
34
35
  const a = client.agent(agent, { project, environment, system });
35
36
  return conversationId ? a.conversation(conversationId) : a.chat();
36
37
  }, [client, agent, project, environment, system, conversationId]);
37
- const adapter = (0, react_1.useMemo)(() => (0, CuratorAdapter_js_1.createCuratorAdapter)(chat, { onMessage, onState, onPlanProgress, onError }), [chat, onMessage, onState, onPlanProgress, onError]);
38
- const runtime = (0, react_2.useLocalRuntime)(adapter);
38
+ // Rebuilt with the chat handle: the upload map is per-conversation, and a
39
+ // handle swap means the pending references belong to a transcript that is no
40
+ // longer on screen.
41
+ const attachmentAdapter = (0, react_1.useMemo)(() => enableAttachments
42
+ ? new CuratorAttachmentAdapter_js_1.CuratorAttachmentAdapter(client, {
43
+ accept: acceptedFileTypes,
44
+ maxFileSize,
45
+ })
46
+ : undefined, [client, chat, enableAttachments, acceptedFileTypes, maxFileSize]);
47
+ const adapter = (0, react_1.useMemo)(() => (0, CuratorAdapter_js_1.createCuratorAdapter)(chat, { onMessage, onState, onPlanProgress, onError }, attachmentAdapter), [chat, onMessage, onState, onPlanProgress, onError, attachmentAdapter]);
48
+ const runtime = (0, react_2.useLocalRuntime)(adapter, attachmentAdapter ? { adapters: { attachments: attachmentAdapter } } : undefined);
39
49
  // Branding is a server-resolved entitlement (org subscription tier), not a
40
50
  // client prop — the widget runs on the customer's own page, so a local
41
51
  // boolean would be trivially flippable. We fetch the deployment config on
@@ -62,12 +72,21 @@ function CuratorChat(props) {
62
72
  cancelled = true;
63
73
  };
64
74
  }, [client, agent, project, environment, system]);
75
+ // Order is the contract: non-colour defaults, then the theme's palette, then
76
+ // the consumer's tokens, then the raw `style` prop — each able to override
77
+ // everything before it, with `style` still winning as it always has.
65
78
  const palette = theme === "dark" ? DARK_VARS : LIGHT_VARS;
66
- const wrapperStyle = { ...palette, ...containerStyle, ...style };
67
- return ((0, jsx_runtime_1.jsxs)("div", { className: className, style: wrapperStyle, "data-curator-chat": true, "data-theme": theme, children: [(0, jsx_runtime_1.jsx)(Header, { title: title, onClose: onClose }), (0, jsx_runtime_1.jsx)(LoadingIndicatorContext.Provider, { value: loadingIndicator ?? (0, jsx_runtime_1.jsx)(TypingDots, {}), children: (0, jsx_runtime_1.jsx)(RetryEnabledContext.Provider, { value: enableRetry, children: (0, jsx_runtime_1.jsx)(CopyEnabledContext.Provider, { value: enableCopy, children: (0, jsx_runtime_1.jsx)(TimestampsEnabledContext.Provider, { value: showTimestamps, children: (0, jsx_runtime_1.jsx)(react_2.AssistantRuntimeProvider, { runtime: runtime, children: (0, jsx_runtime_1.jsxs)(react_2.ThreadPrimitive.Root, { style: threadRootStyle, children: [(0, jsx_runtime_1.jsxs)(react_2.ThreadPrimitive.Viewport, { autoScroll: true, style: viewportStyle, children: [(0, jsx_runtime_1.jsx)(react_2.ThreadPrimitive.Empty, { children: welcomeMessage ? (0, jsx_runtime_1.jsx)(Welcome, { text: welcomeMessage }) : null }), (0, jsx_runtime_1.jsx)(react_2.ThreadPrimitive.Messages, { components: {
79
+ const wrapperStyle = {
80
+ ...BASE_TOKENS,
81
+ ...palette,
82
+ ...tokensToVars(tokens),
83
+ ...containerStyle,
84
+ ...style,
85
+ };
86
+ return ((0, jsx_runtime_1.jsxs)("div", { className: className, style: wrapperStyle, "data-curator-chat": true, "data-theme": theme, children: [!hideHeader && (0, jsx_runtime_1.jsx)(Header, { title: title, onClose: onClose }), (0, jsx_runtime_1.jsx)(LoadingIndicatorContext.Provider, { value: loadingIndicator ?? (0, jsx_runtime_1.jsx)(TypingDots, {}), children: (0, jsx_runtime_1.jsx)(RetryEnabledContext.Provider, { value: enableRetry, children: (0, jsx_runtime_1.jsx)(CopyEnabledContext.Provider, { value: enableCopy, children: (0, jsx_runtime_1.jsx)(TimestampsEnabledContext.Provider, { value: showTimestamps, children: (0, jsx_runtime_1.jsx)(react_2.AssistantRuntimeProvider, { runtime: runtime, children: (0, jsx_runtime_1.jsxs)(react_2.ThreadPrimitive.Root, { "data-curator-part": "thread", style: threadRootStyle, children: [(0, jsx_runtime_1.jsxs)(react_2.ThreadPrimitive.Viewport, { autoScroll: true, "data-curator-part": "viewport", style: viewportStyle, children: [(0, jsx_runtime_1.jsxs)(react_2.ThreadPrimitive.Empty, { children: [welcomeMessage ? (0, jsx_runtime_1.jsx)(Welcome, { text: welcomeMessage }) : null, prompts && prompts.length > 0 && (0, jsx_runtime_1.jsx)(Prompts, { prompts: prompts })] }), (0, jsx_runtime_1.jsx)(react_2.ThreadPrimitive.Messages, { components: {
68
87
  UserMessage,
69
88
  AssistantMessage,
70
- } })] }), (0, jsx_runtime_1.jsx)(Composer, { placeholder: placeholder, allowQueue: queueWhileRunning })] }) }) }) }) }) }), hidePoweredBy === false && (0, jsx_runtime_1.jsx)(brand_js_1.PoweredByCurator, {})] }));
89
+ } })] }), (0, jsx_runtime_1.jsx)(Composer, { placeholder: placeholder, allowQueue: queueWhileRunning, allowAttachments: !!attachmentAdapter })] }) }) }) }) }) }), hidePoweredBy === false && (0, jsx_runtime_1.jsx)(brand_js_1.PoweredByCurator, {})] }));
71
90
  }
72
91
  // Context for threading the loading indicator into the message-component
73
92
  // callbacks without remounting them on every render. Default is rendered
@@ -82,14 +101,44 @@ const CopyEnabledContext = (0, react_1.createContext)(false);
82
101
  const TimestampsEnabledContext = (0, react_1.createContext)(true);
83
102
  // ── header ──────────────────────────────────────────────────────
84
103
  function Header({ title, onClose }) {
85
- return ((0, jsx_runtime_1.jsxs)("header", { style: headerStyle, children: [(0, jsx_runtime_1.jsx)("h2", { style: titleStyle, children: title }), onClose && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onClose, "aria-label": "Close chat", style: headerActionStyle, children: (0, jsx_runtime_1.jsx)(HeaderCloseGlyph, {}) }))] }));
104
+ return ((0, jsx_runtime_1.jsxs)("header", { "data-curator-part": "header", style: headerStyle, children: [(0, jsx_runtime_1.jsx)("h2", { "data-curator-part": "title", style: titleStyle, children: title }), onClose && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onClose, "aria-label": "Close chat", "data-curator-part": "header-action", style: headerActionStyle, children: (0, jsx_runtime_1.jsx)(HeaderCloseGlyph, {}) }))] }));
86
105
  }
87
106
  // ── message primitives (custom UI on top of assistant-ui state) ─
88
107
  function Welcome({ text }) {
89
- return (0, jsx_runtime_1.jsx)("div", { style: welcomeStyle, children: text });
108
+ return (0, jsx_runtime_1.jsx)("div", { "data-curator-part": "welcome", style: welcomeStyle, children: text });
109
+ }
110
+ /**
111
+ * Starter prompts for an empty thread.
112
+ *
113
+ * Built on `ThreadPrimitive.Suggestion` rather than a plain button so the send
114
+ * goes through the same path as the composer's — including the case where one
115
+ * is clicked while a run is already in flight, which the primitive queues
116
+ * instead of dropping.
117
+ */
118
+ function Prompts({ prompts }) {
119
+ return ((0, jsx_runtime_1.jsx)("div", { "data-curator-part": "prompts", style: promptsStyle, children: prompts.map((p, i) => {
120
+ const label = typeof p === "string" ? p : p.label;
121
+ const prompt = typeof p === "string" ? p : (p.prompt ?? p.label);
122
+ return ((0, jsx_runtime_1.jsx)(react_2.ThreadPrimitive.Suggestion, { prompt: prompt, send: true, "data-curator-part": "prompt", style: promptStyle,
123
+ // The message differs from the button when the object form is used,
124
+ // so the full text has to be reachable without clicking.
125
+ title: prompt !== label ? prompt : undefined, children: label }, `${label}-${i}`));
126
+ }) }));
90
127
  }
91
128
  function UserMessage() {
92
- return ((0, jsx_runtime_1.jsxs)(react_2.MessagePrimitive.Root, { style: rowEndStyle, children: [(0, jsx_runtime_1.jsx)("div", { style: { ...bubbleBaseStyle, ...userBubbleStyle }, children: (0, jsx_runtime_1.jsx)(react_2.MessagePrimitive.Parts, { components: { Text: UserTextPart } }) }), (0, jsx_runtime_1.jsx)(Timestamp, { align: "end" })] }));
129
+ // Attachments as the message carries them structurally. The envelope path in
130
+ // `UserTextPart` is the fallback for transcripts written before the server
131
+ // stored them as a field; both render the same chip.
132
+ const attachments = (0, react_2.useAuiState)((s) => s.message?.attachments) ?? [];
133
+ const hasText = (0, react_2.useAuiState)((s) => s.message?.content?.some((p) => p.type === "text" && p.text.length > 0) ?? false);
134
+ return ((0, jsx_runtime_1.jsxs)(react_2.MessagePrimitive.Root, { "data-curator-part": "message message-user", style: rowEndStyle, children: [(0, jsx_runtime_1.jsxs)("div", { "data-curator-part": "bubble bubble-user", style: { ...bubbleBaseStyle, ...userBubbleStyle }, children: [attachments.map((a, i) => ((0, jsx_runtime_1.jsx)(SentAttachmentChip, { name: a.name,
135
+ // A file sent with no caption is the common case; the chip is then
136
+ // the whole bubble and must not carry a top margin.
137
+ first: i === 0 && !hasText }, a.id))), (0, jsx_runtime_1.jsx)(react_2.MessagePrimitive.Parts, { components: { Text: UserTextPart } })] }), (0, jsx_runtime_1.jsx)(Timestamp, { align: "end" })] }));
138
+ }
139
+ /** One attachment on a sent message. Shared by the structural and envelope paths. */
140
+ function SentAttachmentChip({ name, size, mimeType, first, }) {
141
+ return ((0, jsx_runtime_1.jsxs)("span", { "data-curator-part": "attachment-chip", style: { ...attachmentChipStyle, marginTop: first ? 0 : 6 }, title: mimeType ? `${name} (${mimeType})` : name, children: [(0, jsx_runtime_1.jsx)(PaperclipGlyph, {}), (0, jsx_runtime_1.jsx)("span", { "data-curator-part": "attachment-name", style: attachmentNameStyle, children: name }), size !== undefined && size > 0 && ((0, jsx_runtime_1.jsx)("span", { "data-curator-part": "attachment-meta", style: attachmentMetaStyle, children: (0, attachmentEnvelope_js_1.formatAttachmentSize)(size) }))] }));
93
142
  }
94
143
  /**
95
144
  * Local-time stamp under a message.
@@ -107,7 +156,7 @@ function Timestamp({ align }) {
107
156
  const date = createdAt instanceof Date ? createdAt : new Date(createdAt);
108
157
  if (Number.isNaN(date.getTime()))
109
158
  return null;
110
- return ((0, jsx_runtime_1.jsx)("time", { dateTime: date.toISOString(), title: date.toLocaleString(), style: {
159
+ return ((0, jsx_runtime_1.jsx)("time", { "data-curator-part": "timestamp", dateTime: date.toISOString(), title: date.toLocaleString(), style: {
111
160
  ...timestampStyle,
112
161
  alignSelf: align === "end" ? "flex-end" : "flex-start",
113
162
  }, children: date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }) }));
@@ -118,10 +167,7 @@ const UserTextPart = ({ text }) => {
118
167
  // in the message, but showing it to the person who sent the message is a leak
119
168
  // and it is what renders as a wall of JSON. See attachmentEnvelope.ts.
120
169
  const { text: visible, attachments } = (0, attachmentEnvelope_js_1.splitAttachmentEnvelope)(text);
121
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [visible, attachments.map((a, i) => ((0, jsx_runtime_1.jsxs)("span", { style: { ...attachmentChipStyle, marginTop: visible || i > 0 ? 6 : 0 },
122
- // The name can be long; the tooltip gives the full value without
123
- // widening the bubble.
124
- title: `${a.filename} (${a.mimeType})`, children: [(0, jsx_runtime_1.jsx)(PaperclipGlyph, {}), (0, jsx_runtime_1.jsx)("span", { style: attachmentNameStyle, children: a.filename }), a.size > 0 && ((0, jsx_runtime_1.jsx)("span", { style: attachmentMetaStyle, children: (0, attachmentEnvelope_js_1.formatAttachmentSize)(a.size) }))] }, `${a.filename}-${i}`)))] }));
170
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [visible, attachments.map((a, i) => ((0, jsx_runtime_1.jsx)(SentAttachmentChip, { name: a.filename, size: a.size, mimeType: a.mimeType, first: !visible && i === 0 }, `${a.filename}-${i}`)))] }));
125
171
  };
126
172
  /** Small paperclip, sized to the chip's line-height. */
127
173
  function PaperclipGlyph() {
@@ -131,10 +177,10 @@ function AssistantMessage() {
131
177
  const indicator = (0, react_1.useContext)(LoadingIndicatorContext);
132
178
  const retryEnabled = (0, react_1.useContext)(RetryEnabledContext);
133
179
  const copyEnabled = (0, react_1.useContext)(CopyEnabledContext);
134
- return ((0, jsx_runtime_1.jsxs)(react_2.MessagePrimitive.Root, { style: rowStartStyle, children: [(0, jsx_runtime_1.jsxs)("div", { style: { ...bubbleBaseStyle, ...assistantBubbleStyle }, children: [(0, jsx_runtime_1.jsx)(react_2.MessagePrimitive.If, { hasContent: false, children: indicator }), (0, jsx_runtime_1.jsx)(react_2.MessagePrimitive.Parts, { components: { Text: AssistantTextPart } })] }), (0, jsx_runtime_1.jsx)(Timestamp, { align: "start" }), (retryEnabled || copyEnabled) && (
180
+ return ((0, jsx_runtime_1.jsxs)(react_2.MessagePrimitive.Root, { "data-curator-part": "message message-assistant", style: rowStartStyle, children: [(0, jsx_runtime_1.jsxs)("div", { "data-curator-part": "bubble bubble-assistant", style: { ...bubbleBaseStyle, ...assistantBubbleStyle }, children: [(0, jsx_runtime_1.jsx)(react_2.MessagePrimitive.If, { hasContent: false, children: indicator }), (0, jsx_runtime_1.jsx)(react_2.MessagePrimitive.Parts, { components: { Text: AssistantTextPart } })] }), (0, jsx_runtime_1.jsx)(Timestamp, { align: "start" }), (retryEnabled || copyEnabled) && (
135
181
  // `autohide="not-last"` shows the bar only on the trailing assistant
136
182
  // message; `hideWhenRunning` keeps it away while a reply streams.
137
- (0, jsx_runtime_1.jsxs)(react_2.ActionBarPrimitive.Root, { autohide: "not-last", hideWhenRunning: true, style: actionBarStyle, children: [copyEnabled && ((0, jsx_runtime_1.jsxs)(react_2.ActionBarPrimitive.Copy, { "aria-label": "Copy", style: actionButtonStyle, children: [(0, jsx_runtime_1.jsx)(react_2.MessagePrimitive.If, { copied: true, children: (0, jsx_runtime_1.jsx)(CheckGlyph, {}) }), (0, jsx_runtime_1.jsx)(react_2.MessagePrimitive.If, { copied: false, children: (0, jsx_runtime_1.jsx)(CopyGlyph, {}) })] })), retryEnabled && ((0, jsx_runtime_1.jsx)(react_2.ActionBarPrimitive.Reload, { "aria-label": "Retry", style: actionButtonStyle, children: (0, jsx_runtime_1.jsx)(RetryGlyph, {}) }))] }))] }));
183
+ (0, jsx_runtime_1.jsxs)(react_2.ActionBarPrimitive.Root, { autohide: "not-last", hideWhenRunning: true, "data-curator-part": "action-bar", style: actionBarStyle, children: [copyEnabled && ((0, jsx_runtime_1.jsxs)(react_2.ActionBarPrimitive.Copy, { "aria-label": "Copy", "data-curator-part": "action-button", style: actionButtonStyle, children: [(0, jsx_runtime_1.jsx)(react_2.MessagePrimitive.If, { copied: true, children: (0, jsx_runtime_1.jsx)(CheckGlyph, {}) }), (0, jsx_runtime_1.jsx)(react_2.MessagePrimitive.If, { copied: false, children: (0, jsx_runtime_1.jsx)(CopyGlyph, {}) })] })), retryEnabled && ((0, jsx_runtime_1.jsx)(react_2.ActionBarPrimitive.Reload, { "aria-label": "Retry", "data-curator-part": "action-button", style: actionButtonStyle, children: (0, jsx_runtime_1.jsx)(RetryGlyph, {}) }))] }))] }));
138
184
  }
139
185
  /**
140
186
  * Waiting state for a reply that has not started streaming.
@@ -146,7 +192,7 @@ function AssistantMessage() {
146
192
  * the indicator still says "busy" without the movement.
147
193
  */
148
194
  function TypingDots() {
149
- return ((0, jsx_runtime_1.jsxs)("span", { style: typingDotsStyle, role: "status", "aria-label": "Assistant is thinking", children: [(0, jsx_runtime_1.jsx)(Dot, { delay: 0 }), (0, jsx_runtime_1.jsx)(Dot, { delay: 160 }), (0, jsx_runtime_1.jsx)(Dot, { delay: 320 })] }));
195
+ return ((0, jsx_runtime_1.jsxs)("span", { "data-curator-part": "typing-dots", style: typingDotsStyle, role: "status", "aria-label": "Assistant is thinking", children: [(0, jsx_runtime_1.jsx)(Dot, { delay: 0 }), (0, jsx_runtime_1.jsx)(Dot, { delay: 160 }), (0, jsx_runtime_1.jsx)(Dot, { delay: 320 })] }));
150
196
  }
151
197
  function Dot({ delay }) {
152
198
  return ((0, jsx_runtime_1.jsx)("span", { className: "curator-dot", style: {
@@ -161,12 +207,18 @@ function Dot({ delay }) {
161
207
  }
162
208
  const AssistantTextPart = ({ text }) => ((0, jsx_runtime_1.jsx)(Markdown_js_1.Markdown, { content: text }));
163
209
  // ── composer ────────────────────────────────────────────────────
164
- function Composer({ placeholder, allowQueue, }) {
210
+ function Composer({ placeholder, allowQueue, allowAttachments, }) {
165
211
  const aui = (0, react_2.useAui)();
166
212
  const isRunning = (0, react_2.useAuiState)((s) => s.thread.isRunning);
213
+ // Composer attachments live in assistant-ui's state, not ours — the runtime
214
+ // owns the upload lifecycle, so reading it here keeps one source of truth.
215
+ // State is read-only here; mutations go through `aui.thread.composer()`,
216
+ // which is a factory rather than an object — hence the call.
217
+ const attachments = (0, react_2.useAuiState)((s) => s.composer?.attachments) ?? [];
167
218
  const [text, setText] = (0, react_1.useState)("");
168
219
  const [queued, setQueued] = (0, react_1.useState)(null);
169
220
  const taRef = (0, react_1.useRef)(null);
221
+ const fileRef = (0, react_1.useRef)(null);
170
222
  // Guards the window between dispatching a queued message and `isRunning`
171
223
  // flipping back to true. Without it the flush effect sees "not running" on
172
224
  // the very next render and sends the same text again.
@@ -189,7 +241,9 @@ function Composer({ placeholder, allowQueue, }) {
189
241
  }, [isRunning, queued, send]);
190
242
  const submit = (0, react_1.useCallback)(() => {
191
243
  const value = text.trim();
192
- if (!value)
244
+ // An attachment with no caption is a complete message. Requiring text here
245
+ // is what made a dropped document impossible to send.
246
+ if (!value && attachments.length === 0)
193
247
  return;
194
248
  setText("");
195
249
  if (taRef.current)
@@ -202,7 +256,7 @@ function Composer({ placeholder, allowQueue, }) {
202
256
  return;
203
257
  }
204
258
  send(value);
205
- }, [text, isRunning, allowQueue, send]);
259
+ }, [text, attachments.length, isRunning, allowQueue, send]);
206
260
  const onKeyDown = (e) => {
207
261
  if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
208
262
  e.preventDefault();
@@ -218,15 +272,41 @@ function Composer({ placeholder, allowQueue, }) {
218
272
  // `disabled` is never set on the input: being able to type mid-reply is the
219
273
  // point. Send is disabled only when there is nothing to send, or when a
220
274
  // message is already held.
221
- const canSubmit = text.trim().length > 0 && !(isRunning && allowQueue && queued !== null);
222
- return ((0, jsx_runtime_1.jsxs)("div", { style: composerWrapStyle, children: [queued !== null && ((0, jsx_runtime_1.jsxs)("div", { style: queuedRowStyle, children: [(0, jsx_runtime_1.jsx)("span", { style: queuedLabelStyle, children: "Sending next" }), (0, jsx_runtime_1.jsx)("span", { style: queuedTextStyle, title: queued, children: queued }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setQueued(null), "aria-label": "Cancel queued message", style: queuedCancelStyle, children: (0, jsx_runtime_1.jsx)(HeaderCloseGlyph, {}) })] })), (0, jsx_runtime_1.jsxs)("div", { style: composerRootStyle, children: [(0, jsx_runtime_1.jsx)("textarea", { ref: (el) => {
275
+ const canSubmit = (text.trim().length > 0 || attachments.length > 0) &&
276
+ !(isRunning && allowQueue && queued !== null);
277
+ const onPickFiles = async (files) => {
278
+ if (!files)
279
+ return;
280
+ // Sequential rather than parallel: `addAttachment` mutates composer state,
281
+ // and the adapter's per-file rejection message is easier to attribute when
282
+ // they arrive in order.
283
+ for (const file of Array.from(files)) {
284
+ try {
285
+ await aui.thread.composer().addAttachment(file);
286
+ }
287
+ catch {
288
+ // The adapter reports rejection on the chip; a throw here would take
289
+ // the rest of the selection down with it.
290
+ }
291
+ }
292
+ if (fileRef.current)
293
+ fileRef.current.value = "";
294
+ };
295
+ return ((0, jsx_runtime_1.jsxs)("div", { "data-curator-part": "composer", style: composerWrapStyle, children: [queued !== null && ((0, jsx_runtime_1.jsxs)("div", { "data-curator-part": "queued-row", style: queuedRowStyle, children: [(0, jsx_runtime_1.jsx)("span", { "data-curator-part": "queued-label", style: queuedLabelStyle, children: "Sending next" }), (0, jsx_runtime_1.jsx)("span", { "data-curator-part": "queued-text", style: queuedTextStyle, title: queued, children: queued }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setQueued(null), "aria-label": "Cancel queued message", "data-curator-part": "queued-cancel", style: queuedCancelStyle, children: (0, jsx_runtime_1.jsx)(HeaderCloseGlyph, {}) })] })), attachments.length > 0 && ((0, jsx_runtime_1.jsx)("div", { "data-curator-part": "pending-attachments", style: pendingRowStyle, children: attachments.map((a) => {
296
+ const failed = a.status?.type === "incomplete";
297
+ const uploading = a.status?.type === "running";
298
+ return ((0, jsx_runtime_1.jsxs)("span", { "data-curator-part": "attachment-chip attachment-chip-pending", "data-curator-status": a.status?.type, style: {
299
+ ...attachmentChipStyle,
300
+ ...(failed ? { color: "var(--curator-error)" } : {}),
301
+ }, title: a.status?.type === "incomplete" ? a.status.message ?? a.name : a.name, children: [(0, jsx_runtime_1.jsx)(PaperclipGlyph, {}), (0, jsx_runtime_1.jsx)("span", { "data-curator-part": "attachment-name", style: attachmentNameStyle, children: a.name }), uploading && (0, jsx_runtime_1.jsx)("span", { "data-curator-part": "attachment-meta", style: attachmentMetaStyle, children: "uploading\u2026" }), failed && (0, jsx_runtime_1.jsx)("span", { "data-curator-part": "attachment-meta", style: attachmentMetaStyle, children: "failed" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => void aui.thread.composer().attachment({ id: a.id }).remove(), "aria-label": `Remove ${a.name}`, "data-curator-part": "chip-remove", style: chipRemoveStyle, children: (0, jsx_runtime_1.jsx)(HeaderCloseGlyph, {}) })] }, a.id));
302
+ }) })), (0, jsx_runtime_1.jsxs)("div", { "data-curator-part": "composer-root", style: composerRootStyle, children: [allowAttachments && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("input", { ref: fileRef, type: "file", multiple: true, onChange: (e) => void onPickFiles(e.target.files), style: { display: "none" }, "aria-hidden": "true", tabIndex: -1 }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => fileRef.current?.click(), "aria-label": "Attach a file", "data-curator-part": "attach-button", style: attachButtonStyle, children: (0, jsx_runtime_1.jsx)(PaperclipGlyph, {}) })] })), (0, jsx_runtime_1.jsx)("textarea", { ref: (el) => {
223
303
  taRef.current = el;
224
304
  if (el)
225
305
  autosize(el);
226
306
  }, value: text, onChange: (e) => {
227
307
  setText(e.target.value);
228
308
  autosize(e.target);
229
- }, onKeyDown: onKeyDown, placeholder: placeholder, rows: 1, style: textareaStyle, "aria-label": "Message" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: submit, disabled: !canSubmit, "aria-label": isRunning && allowQueue ? "Queue message" : "Send", style: sendButtonStyle, children: (0, jsx_runtime_1.jsx)(SendGlyph, {}) })] })] }));
309
+ }, onKeyDown: onKeyDown, placeholder: placeholder, rows: 1, "data-curator-part": "composer-input", style: textareaStyle, "aria-label": "Message" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: submit, disabled: !canSubmit, "aria-label": isRunning && allowQueue ? "Queue message" : "Send", "data-curator-part": "send-button", style: sendButtonStyle, children: (0, jsx_runtime_1.jsx)(SendGlyph, {}) })] })] }));
230
310
  }
231
311
  // ── glyphs ──────────────────────────────────────────────────────
232
312
  function HeaderCloseGlyph() {
@@ -245,16 +325,126 @@ function CheckGlyph() {
245
325
  return ((0, jsx_runtime_1.jsx)("svg", { width: "13", height: "13", viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("path", { d: "m5 13 4 4L19 7", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }));
246
326
  }
247
327
  // ── design tokens ───────────────────────────────────────────────
248
- const SPACE_2 = 8;
249
- const SPACE_3 = 12;
250
- const SPACE_4 = 16;
251
- const FONT_BASE = 14;
252
- const FONT_SM = 12;
328
+ /**
329
+ * Every value below is a CSS variable reference, not a number.
330
+ *
331
+ * They used to be module constants baked into 27 inline style objects, which
332
+ * made typography, spacing and two of the three radii unreachable from outside
333
+ * the package: a widget whose font did not match the surrounding site could
334
+ * only be fixed by forking. Referencing variables here means the defaults ship
335
+ * in `BASE_TOKENS` and a consumer overrides any of them — via the `tokens`
336
+ * prop, or plain CSS on `[data-curator-chat]`.
337
+ *
338
+ * The `var(--x, fallback)` form is deliberate: the widget still renders
339
+ * correctly if the container's variables are stripped by an aggressive CSS
340
+ * reset on the host page.
341
+ */
342
+ const SPACE_2 = "var(--curator-space-2, 8px)";
343
+ const SPACE_3 = "var(--curator-space-3, 12px)";
344
+ const SPACE_4 = "var(--curator-space-4, 16px)";
345
+ const FONT_BASE = "var(--curator-font-size, 14px)";
346
+ const FONT_SM = "var(--curator-font-size-sm, 12px)";
253
347
  /** Timestamps only — small enough to recede, large enough to stay legible. */
254
- const FONT_XS = 11;
255
- const RADIUS_LG = 14;
256
- const RADIUS_MD = 10;
257
- const RADIUS_SM = 8;
348
+ const FONT_XS = "var(--curator-font-size-xs, 11px)";
349
+ const RADIUS_LG = "var(--curator-radius, 14px)";
350
+ const RADIUS_MD = "var(--curator-radius-md, 10px)";
351
+ const RADIUS_SM = "var(--curator-radius-sm, 8px)";
352
+ /**
353
+ * Defaults for everything that is not a colour. Colours live in the palettes
354
+ * below because they are the one axis that genuinely differs between light and
355
+ * dark; these do not.
356
+ */
357
+ const BASE_TOKENS = {
358
+ ["--curator-font-family"]: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
359
+ ["--curator-font-size"]: "14px",
360
+ ["--curator-font-size-sm"]: "12px",
361
+ ["--curator-font-size-xs"]: "11px",
362
+ ["--curator-line-height"]: "1.5",
363
+ ["--curator-font-weight-strong"]: "600",
364
+ ["--curator-space-1"]: "4px",
365
+ ["--curator-space-2"]: "8px",
366
+ ["--curator-space-3"]: "12px",
367
+ ["--curator-space-4"]: "16px",
368
+ ["--curator-radius-md"]: "10px",
369
+ ["--curator-radius-sm"]: "8px",
370
+ };
371
+ /**
372
+ * Consumer-facing token names, mapped to the variables they set.
373
+ *
374
+ * Complete for the axes a consumer can be expected to want: the whole type
375
+ * scale, the whole spacing scale, every colour surface, and the radii. An
376
+ * earlier version exposed only `fontSize` and `radius`, which split each scale
377
+ * down the middle — half reachable by prop, half only by stylesheet. A partial
378
+ * scale is worse than none, because it lets someone change a base size and then
379
+ * discover the secondary sizes did not follow.
380
+ */
381
+ const TOKEN_VARS = {
382
+ fontFamily: "--curator-font-family",
383
+ fontSize: "--curator-font-size",
384
+ // The rest of the type scale. Exposing `fontSize` alone let a host set a base
385
+ // size while the secondary sizes stayed pinned at 12/11px, which is a worse
386
+ // mismatch than not being able to change any of them.
387
+ fontSizeSm: "--curator-font-size-sm",
388
+ fontSizeXs: "--curator-font-size-xs",
389
+ fontWeightStrong: "--curator-font-weight-strong",
390
+ lineHeight: "--curator-line-height",
391
+ // Density. Every padding and gap in the widget derives from these four.
392
+ space1: "--curator-space-1",
393
+ space2: "--curator-space-2",
394
+ space3: "--curator-space-3",
395
+ space4: "--curator-space-4",
396
+ radius: "--curator-radius",
397
+ radiusMd: "--curator-radius-md",
398
+ radiusSm: "--curator-radius-sm",
399
+ background: "--curator-bg",
400
+ surface: "--curator-surface",
401
+ text: "--curator-text",
402
+ muted: "--curator-muted",
403
+ border: "--curator-border",
404
+ accent: "--curator-accent",
405
+ accentText: "--curator-accent-text",
406
+ userBubble: "--curator-user-bubble",
407
+ userBubbleText: "--curator-user-bubble-text",
408
+ assistantBubble: "--curator-assistant-bubble",
409
+ assistantBubbleText: "--curator-assistant-bubble-text",
410
+ headerBackground: "--curator-header-bg",
411
+ composerBackground: "--curator-composer-bg",
412
+ focusRing: "--curator-focus-ring",
413
+ error: "--curator-error",
414
+ };
415
+ /**
416
+ * Turn a `tokens` object into inline custom properties.
417
+ *
418
+ * Numbers become `px` because every numeric token here is a length, and
419
+ * `radius: 8` reads better at the call site than `radius: "8px"`. A string is
420
+ * passed through untouched so `radius: "0.5rem"` still works.
421
+ */
422
+ function tokensToVars(tokens) {
423
+ if (!tokens)
424
+ return {};
425
+ const out = {};
426
+ for (const [key, value] of Object.entries(tokens)) {
427
+ const cssVar = TOKEN_VARS[key];
428
+ if (!cssVar || value === undefined)
429
+ continue;
430
+ out[cssVar] = typeof value === "number" ? `${value}px` : value;
431
+ }
432
+ return out;
433
+ }
434
+ /**
435
+ * Accent on the USER bubble, neutral on the assistant's.
436
+ *
437
+ * The reverse of what this widget shipped with, and the reverse of what the
438
+ * variable names imply — the assistant bubble used to borrow `--curator-accent`
439
+ * directly. Two problems with that. The assistant writes most of the words, so
440
+ * painting its bubble in the accent colour turns every substantial reply into a
441
+ * slab of saturated colour and makes the machine the loudest thing on screen.
442
+ * And it inverts the convention every messaging UI has taught people to read,
443
+ * where the accent marks *your* messages.
444
+ *
445
+ * Both are now separate variables, so a consumer who preferred the old
446
+ * arrangement sets `assistantBubble` / `userBubble` back.
447
+ */
258
448
  const LIGHT_VARS = {
259
449
  ["--curator-bg"]: "#ffffff",
260
450
  ["--curator-surface"]: "#f8fafc",
@@ -263,9 +453,15 @@ const LIGHT_VARS = {
263
453
  ["--curator-border"]: "rgba(15, 23, 42, 0.08)",
264
454
  ["--curator-accent"]: "#0f172a",
265
455
  ["--curator-accent-text"]: "#ffffff",
266
- ["--curator-user-bubble"]: "#f1f5f9",
456
+ ["--curator-user-bubble"]: "#0f172a",
457
+ ["--curator-user-bubble-text"]: "#ffffff",
458
+ ["--curator-assistant-bubble"]: "#f1f5f9",
459
+ ["--curator-assistant-bubble-text"]: "#0f172a",
460
+ ["--curator-header-bg"]: "transparent",
461
+ ["--curator-composer-bg"]: "transparent",
462
+ ["--curator-focus-ring"]: "rgba(15, 23, 42, 0.16)",
267
463
  ["--curator-error"]: "#b91c1c",
268
- ["--curator-radius"]: `${RADIUS_LG}px`,
464
+ ["--curator-radius"]: "14px",
269
465
  };
270
466
  const DARK_VARS = {
271
467
  ["--curator-bg"]: "#0b1220",
@@ -275,9 +471,17 @@ const DARK_VARS = {
275
471
  ["--curator-border"]: "rgba(226, 232, 240, 0.1)",
276
472
  ["--curator-accent"]: "#e2e8f0",
277
473
  ["--curator-accent-text"]: "#0b1220",
278
- ["--curator-user-bubble"]: "#1f2937",
474
+ // Dark mode keeps the same relationship: the user's bubble is the lighter,
475
+ // more saturated one; the assistant sits on a raised neutral surface.
476
+ ["--curator-user-bubble"]: "#e2e8f0",
477
+ ["--curator-user-bubble-text"]: "#0b1220",
478
+ ["--curator-assistant-bubble"]: "#1f2937",
479
+ ["--curator-assistant-bubble-text"]: "#e2e8f0",
480
+ ["--curator-header-bg"]: "transparent",
481
+ ["--curator-composer-bg"]: "transparent",
482
+ ["--curator-focus-ring"]: "rgba(226, 232, 240, 0.2)",
279
483
  ["--curator-error"]: "#fca5a5",
280
- ["--curator-radius"]: `${RADIUS_LG}px`,
484
+ ["--curator-radius"]: "14px",
281
485
  };
282
486
  const containerStyle = {
283
487
  display: "flex",
@@ -290,9 +494,9 @@ const containerStyle = {
290
494
  border: "1px solid var(--curator-border)",
291
495
  borderRadius: "var(--curator-radius)",
292
496
  overflow: "hidden",
293
- fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
497
+ fontFamily: "var(--curator-font-family)",
294
498
  fontSize: FONT_BASE,
295
- lineHeight: 1.5,
499
+ lineHeight: "var(--curator-line-height, 1.5)",
296
500
  boxSizing: "border-box",
297
501
  };
298
502
  const headerStyle = {
@@ -345,15 +549,39 @@ const viewportStyle = {
345
549
  // clips rather than growing a horizontal scrollbar across the whole thread.
346
550
  overflowX: "hidden",
347
551
  minWidth: 0,
348
- padding: `${SPACE_4}px ${SPACE_4}px ${SPACE_3}px`,
552
+ padding: `${SPACE_4} ${SPACE_4} ${SPACE_3}`,
349
553
  display: "flex",
350
554
  flexDirection: "column",
351
- gap: SPACE_2,
555
+ gap: SPACE_3,
352
556
  };
353
557
  const welcomeStyle = {
354
558
  color: "var(--curator-muted)",
355
- fontSize: 13,
356
- padding: `${SPACE_2}px 0`,
559
+ fontSize: FONT_SM,
560
+ padding: `${SPACE_2} 0`,
561
+ };
562
+ const promptsStyle = {
563
+ display: "flex",
564
+ flexWrap: "wrap",
565
+ gap: 6,
566
+ // Sits under the welcome line rather than beside it: the welcome says what
567
+ // this is, the prompts say what to do, and that reads as a sequence.
568
+ paddingTop: 2,
569
+ };
570
+ const promptStyle = {
571
+ // Outlined, not filled. A filled chip competes with the send button for
572
+ // "primary action", and there are several of these — a row of solid accent
573
+ // blocks in an otherwise empty panel is the loudest thing on the screen.
574
+ border: "1px solid var(--curator-border)",
575
+ borderRadius: RADIUS_SM,
576
+ background: "transparent",
577
+ color: "var(--curator-text)",
578
+ fontFamily: "inherit",
579
+ fontSize: FONT_SM,
580
+ lineHeight: 1.3,
581
+ padding: `6px ${SPACE_2}`,
582
+ cursor: "pointer",
583
+ textAlign: "left",
584
+ maxWidth: "100%",
357
585
  };
358
586
  const rowStartStyle = {
359
587
  // Column so the retry action bar can stack beneath the bubble, both
@@ -365,12 +593,18 @@ const rowStartStyle = {
365
593
  minWidth: 0,
366
594
  };
367
595
  const rowEndStyle = {
596
+ // Column, mirroring the assistant row. It was a plain row, which made the
597
+ // bubble and the timestamp siblings on one line — so a user message rendered
598
+ // as "hello7:19 AM", the stamp jammed against the bubble with no gap, while
599
+ // the assistant's sat neatly underneath. The two sides should differ in
600
+ // alignment, not in structure.
368
601
  display: "flex",
602
+ flexDirection: "column",
603
+ alignItems: "flex-end",
369
604
  width: "100%",
370
605
  // Same `min-width: auto` trap as the bubble: without this the row itself
371
606
  // refuses to shrink and the cap on the bubble inside it never binds.
372
607
  minWidth: 0,
373
- justifyContent: "flex-end",
374
608
  };
375
609
  const actionBarStyle = {
376
610
  display: "flex",
@@ -404,7 +638,7 @@ const bubbleBaseStyle = {
404
638
  // and the whole thread scrolls sideways. This is the fix for that; the
405
639
  // wrapping rules below only get a chance to apply once the box can shrink.
406
640
  minWidth: 0,
407
- padding: `${SPACE_2 + 2}px ${SPACE_3}px`,
641
+ padding: `calc(${SPACE_2} + 2px) ${SPACE_3}`,
408
642
  borderRadius: RADIUS_MD,
409
643
  whiteSpace: "pre-wrap",
410
644
  // `overflow-wrap: anywhere` is the standard property and it breaks inside an
@@ -432,7 +666,7 @@ const attachmentChipStyle = {
432
666
  // Tinted from the bubble's own text colour so the chip reads as part of the
433
667
  // message in either theme, without needing its own palette token.
434
668
  background: "color-mix(in srgb, currentColor 10%, transparent)",
435
- fontSize: 12,
669
+ fontSize: FONT_SM,
436
670
  lineHeight: 1.4,
437
671
  verticalAlign: "middle",
438
672
  };
@@ -447,12 +681,12 @@ const attachmentMetaStyle = {
447
681
  };
448
682
  const userBubbleStyle = {
449
683
  background: "var(--curator-user-bubble)",
450
- color: "var(--curator-text)",
684
+ color: "var(--curator-user-bubble-text, var(--curator-text))",
451
685
  borderBottomRightRadius: 4,
452
686
  };
453
687
  const assistantBubbleStyle = {
454
- background: "var(--curator-accent)",
455
- color: "var(--curator-accent-text)",
688
+ background: "var(--curator-assistant-bubble, var(--curator-accent))",
689
+ color: "var(--curator-assistant-bubble-text, var(--curator-accent-text))",
456
690
  borderBottomLeftRadius: 4,
457
691
  };
458
692
  const typingDotsStyle = {
@@ -473,7 +707,7 @@ const queuedRowStyle = {
473
707
  display: "flex",
474
708
  alignItems: "center",
475
709
  gap: SPACE_2,
476
- padding: `${SPACE_2}px ${SPACE_3}px 0`,
710
+ padding: `${SPACE_2} ${SPACE_3} 0`,
477
711
  fontSize: FONT_SM,
478
712
  color: "var(--curator-muted)",
479
713
  minWidth: 0,
@@ -506,15 +740,55 @@ const timestampStyle = {
506
740
  fontSize: FONT_XS,
507
741
  color: "var(--curator-muted)",
508
742
  opacity: 0.7,
509
- marginTop: 2,
743
+ marginTop: 4,
510
744
  padding: "0 2px",
511
745
  userSelect: "none",
512
746
  };
747
+ /** Pending attachments sit above the input, on their own row, like the queue row. */
748
+ const pendingRowStyle = {
749
+ display: "flex",
750
+ flexWrap: "wrap",
751
+ gap: 6,
752
+ paddingLeft: SPACE_3,
753
+ paddingRight: SPACE_3,
754
+ paddingBottom: 6,
755
+ };
756
+ /** Sized to match the send button so the composer's two ends balance. */
757
+ const attachButtonStyle = {
758
+ display: "inline-flex",
759
+ alignItems: "center",
760
+ justifyContent: "center",
761
+ flexShrink: 0,
762
+ width: 32,
763
+ height: 32,
764
+ marginBottom: 2,
765
+ border: "none",
766
+ borderRadius: RADIUS_SM,
767
+ background: "transparent",
768
+ color: "var(--curator-muted)",
769
+ cursor: "pointer",
770
+ padding: 0,
771
+ };
772
+ /** The chip's own dismiss control; smaller than the header's close button. */
773
+ const chipRemoveStyle = {
774
+ display: "inline-flex",
775
+ alignItems: "center",
776
+ justifyContent: "center",
777
+ width: 14,
778
+ height: 14,
779
+ marginLeft: 2,
780
+ padding: 0,
781
+ border: "none",
782
+ background: "transparent",
783
+ color: "inherit",
784
+ cursor: "pointer",
785
+ opacity: 0.7,
786
+ };
513
787
  const composerRootStyle = {
514
788
  display: "flex",
515
789
  gap: SPACE_2,
516
790
  alignItems: "flex-end",
517
- padding: `${SPACE_3}px ${SPACE_3}px ${SPACE_2}px`,
791
+ padding: `${SPACE_3} ${SPACE_3} ${SPACE_2}`,
518
792
  flexShrink: 0,
519
793
  };
520
794
  const textareaStyle = {
@@ -522,7 +796,7 @@ const textareaStyle = {
522
796
  resize: "none",
523
797
  border: "1px solid var(--curator-border)",
524
798
  borderRadius: RADIUS_SM,
525
- padding: `${SPACE_2}px ${SPACE_3}px`,
799
+ padding: `${SPACE_2} ${SPACE_3}`,
526
800
  background: "var(--curator-bg)",
527
801
  color: "var(--curator-text)",
528
802
  fontFamily: "inherit",
@@ -575,6 +849,10 @@ if (typeof document !== "undefined") {
575
849
  transform: none;
576
850
  }
577
851
  }
852
+ [data-curator-chat] [data-curator-part~="prompt"]:hover {
853
+ background-color: var(--curator-user-bubble);
854
+ border-color: var(--curator-muted);
855
+ }
578
856
  [data-curator-chat] button:disabled { opacity: 0.4; cursor: not-allowed; }
579
857
  [data-curator-chat] textarea {
580
858
  transition: border-color 120ms ease, box-shadow 120ms ease;