@agentforge-io/chat-sdk 2.0.24 → 2.0.25

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/dist/react.d.ts CHANGED
@@ -127,6 +127,39 @@ export interface ChatWidgetProps {
127
127
  /** Input placeholder. Defaults to "Type a message…" — override when
128
128
  * the persona speaks a different language. */
129
129
  inputPlaceholder?: string;
130
+ /**
131
+ * One-click conversation starters rendered as chips ABOVE the input
132
+ * (and only while the conversation is empty — they fade away after
133
+ * the first turn). Click pre-fills the input with the chip text so
134
+ * the visitor can review / edit before sending. Use this for
135
+ * persona-specific openings ("Schedule a call", "Get a quote") on
136
+ * the same surface that owns the chat. */
137
+ shortcuts?: string[];
138
+ /**
139
+ * Called when the visitor clicks a shortcut chip. When omitted the
140
+ * default behaviour is to populate the input with the chip text.
141
+ * Provide this hook to customise — e.g. send immediately, log the
142
+ * click, or open an external link instead. */
143
+ onShortcutClick?: (text: string, index: number) => void;
144
+ /**
145
+ * Imperative handle. The widget fills this ref on mount with a
146
+ * small command object the host can call to drive the session
147
+ * without owning it. Today exposes `sendNow(text)` — sends the
148
+ * given text as a user turn, bypassing the textarea/draft state.
149
+ * Use this for chips, suggested replies, programmatic kicks, etc.
150
+ *
151
+ * `null` while the session is still booting. Becomes available
152
+ * once `agent_loaded` has fired internally.
153
+ */
154
+ handleRef?: React.MutableRefObject<ChatWidgetHandle | null>;
155
+ }
156
+ /** Imperative surface exposed via ChatWidgetProps.handleRef. Narrow
157
+ * on purpose — hosts get a verb, not the whole session. */
158
+ export interface ChatWidgetHandle {
159
+ /** Send `text` immediately as a user turn. Skips the textarea
160
+ * entirely. No-op if the session isn't ready yet (status ===
161
+ * 'idle' / 'loading'). */
162
+ sendNow(text: string): void;
130
163
  }
131
164
  /**
132
165
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
package/dist/react.js CHANGED
@@ -133,6 +133,47 @@ function renderMarkdown(src) {
133
133
  out.push(`<blockquote>${renderInline(qbuf.join(' '))}</blockquote>`);
134
134
  continue;
135
135
  }
136
+ // GFM-style table: a row of `| col | col |`, then a separator row
137
+ // `|---|---|` (any number of dashes / optional :), then body rows.
138
+ // Detection looks two lines ahead so a stray "| pipe |" sentence
139
+ // in prose doesn't get promoted to a table.
140
+ if (/^\s*\|.*\|\s*$/.test(line) &&
141
+ i + 1 < lines.length &&
142
+ /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[i + 1])) {
143
+ const splitRow = (raw) => {
144
+ // Trim outer pipes then split, preserving empty cells. GFM
145
+ // tolerates escaped pipes (\|) inside cells — we don't, the
146
+ // server-side markdown the agent emits never includes them.
147
+ const trimmed = raw.trim().replace(/^\|/, '').replace(/\|$/, '');
148
+ return trimmed.split('|').map((c) => c.trim());
149
+ };
150
+ const header = splitRow(line);
151
+ i += 2; // skip header + separator
152
+ const bodyRows = [];
153
+ while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
154
+ bodyRows.push(splitRow(lines[i]));
155
+ i++;
156
+ }
157
+ // We stamp each <td> with a `data-label` attribute so the
158
+ // mobile stylesheet can show a label before the cell value
159
+ // when the table collapses to a stacked layout. Cheap on the
160
+ // desktop side (the CSS just hides the label) and gives mobile
161
+ // a real card-per-row read instead of a horizontal scroll.
162
+ const thead = '<thead><tr>' +
163
+ header.map((c) => `<th>${renderInline(c)}</th>`).join('') +
164
+ '</tr></thead>';
165
+ const tbody = '<tbody>' +
166
+ bodyRows
167
+ .map((r) => '<tr>' +
168
+ r
169
+ .map((c, idx) => `<td data-label="${escapeHtml(header[idx] ?? '')}">${renderInline(c)}</td>`)
170
+ .join('') +
171
+ '</tr>')
172
+ .join('') +
173
+ '</tbody>';
174
+ out.push(`<table>${thead}${tbody}</table>`);
175
+ continue;
176
+ }
136
177
  if (/^\s*$/.test(line)) {
137
178
  i++;
138
179
  continue;
@@ -145,7 +186,12 @@ function renderMarkdown(src) {
145
186
  !/^\s*[-*]\s+/.test(lines[i]) &&
146
187
  !/^\s*\d+\.\s+/.test(lines[i]) &&
147
188
  !/^\s*>\s?/.test(lines[i]) &&
148
- !/^\s*(---+|\*\*\*+|___+)\s*$/.test(lines[i])) {
189
+ !/^\s*(---+|\*\*\*+|___+)\s*$/.test(lines[i]) &&
190
+ // Stop on a likely table start (header + separator next line) so
191
+ // the paragraph collector doesn't swallow the table rows.
192
+ !(/^\s*\|.*\|\s*$/.test(lines[i]) &&
193
+ i + 1 < lines.length &&
194
+ /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[i + 1]))) {
149
195
  pbuf.push(lines[i]);
150
196
  i++;
151
197
  }
@@ -181,7 +227,7 @@ function fallbackCopy(ctx) {
181
227
  * SDK event. Consumers don't need to read `session.getState()` themselves.
182
228
  */
183
229
  function ChatWidget(props) {
184
- const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalDecision, readOnlyApprovals = false, variant = 'card', greeting, personaName, inputPlaceholder, } = props;
230
+ const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalDecision, readOnlyApprovals = false, variant = 'card', greeting, personaName, shortcuts, onShortcutClick, inputPlaceholder, } = props;
185
231
  const bare = variant === 'bare';
186
232
  const [session, setSession] = (0, react_1.useState)(null);
187
233
  const [status, setStatus] = (0, react_1.useState)('idle');
@@ -303,27 +349,45 @@ function ChatWidget(props) {
303
349
  };
304
350
  return ((0, jsx_runtime_1.jsxs)("div", { className: rootClass, style: rootStyle, "data-style-id": styleId, children: [!inline && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-toggle", "aria-label": open ? 'Close chat' : 'Open chat', "aria-expanded": open, onClick: () => setOpen((v) => !v), children: open ? (0, jsx_runtime_1.jsx)(CloseIcon, {}) : (0, jsx_runtime_1.jsx)(ChatIcon, {}) })), (0, jsx_runtime_1.jsxs)("div", { className: `af-panel ${open ? 'af-open' : ''}`, children: [!bare && ((0, jsx_runtime_1.jsxs)("div", { className: "af-header", children: [theme?.avatarUrl ? (
305
351
  // eslint-disable-next-line @next/next/no-img-element
306
- (0, jsx_runtime_1.jsx)("img", { className: "af-header-avatar", src: theme.avatarUrl, alt: "" })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "af-header-info", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-header-title", children: personaName ?? theme?.title ?? agent?.name ?? 'Chat' }), (0, jsx_runtime_1.jsx)("div", { className: "af-header-subtitle", children: status === 'loading' ? 'Loading…' : agent?.description ?? '' })] }), !inline && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-close", onClick: () => setOpen(false), "aria-label": "Close chat", children: (0, jsx_runtime_1.jsx)(CloseIcon, {}) }))] })), (0, jsx_runtime_1.jsxs)("div", { className: "af-messages", ref: messagesRef, children: [bare && greeting && messages.length === 0 && status !== 'loading' && ((0, jsx_runtime_1.jsx)("div", { className: "af-msg af-msg-assistant af-msg-greeting", dangerouslySetInnerHTML: { __html: renderMarkdown(greeting) } })), messages.map((m) => ((0, jsx_runtime_1.jsx)(MessageBubble, { message: m, session: session, readOnly: readOnlyApprovals, onDecision: onApprovalDecision, onContinue: () => {
307
- // After a successful Approve, kick the next turn so the
308
- // gate's fast-path consumes the approval and the tool
309
- // actually runs. `silent: true` keeps the literal
310
- // "continue" prompt out of the visible transcript —
311
- // the visitor just sees the assistant's next reply
312
- // appearing under the "Approved" pill.
313
- if (!session)
314
- return;
315
- setTimeout(() => {
316
- void session.send('continue', { silent: true });
317
- }, 250);
318
- } }, m.id)))] }), lastError && status === 'error' && ((0, jsx_runtime_1.jsx)("div", { className: "af-error", children: lastError })), (0, jsx_runtime_1.jsxs)("div", { className: "af-input-row", children: [(0, jsx_runtime_1.jsx)("textarea", { className: "af-input", value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: onKeyDown, placeholder: inputPlaceholder ?? 'Type a message…', rows: 1, disabled: status === 'ended' || status === 'loading' || status === 'idle' }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-send", onClick: handleSend, disabled: sendDisabled, "aria-label": "Send message", children: (0, jsx_runtime_1.jsx)(SendIcon, {}) })] }), !bare && (0, jsx_runtime_1.jsx)("div", { className: "af-footer", children: "Powered by AgentForge" })] })] }));
319
- }
320
- function MessageBubble({ message, session, readOnly, onDecision, onContinue, }) {
352
+ (0, jsx_runtime_1.jsx)("img", { className: "af-header-avatar", src: theme.avatarUrl, alt: "" })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "af-header-info", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-header-title", children: personaName ?? theme?.title ?? agent?.name ?? 'Chat' }), (0, jsx_runtime_1.jsx)("div", { className: "af-header-subtitle", children: status === 'loading' ? 'Loading…' : agent?.description ?? '' })] }), !inline && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-close", onClick: () => setOpen(false), "aria-label": "Close chat", children: (0, jsx_runtime_1.jsx)(CloseIcon, {}) }))] })), (0, jsx_runtime_1.jsxs)("div", { className: "af-messages", ref: messagesRef, children: [bare && greeting && messages.length === 0 && status !== 'loading' && ((0, jsx_runtime_1.jsxs)("div", { className: "af-msg-row af-msg-row-assistant", children: [(0, jsx_runtime_1.jsx)(AssistantAvatar, { theme: theme, name: personaName ?? agent?.name, show: true }), (0, jsx_runtime_1.jsx)("div", { className: "af-msg af-msg-assistant af-msg-greeting", dangerouslySetInnerHTML: { __html: renderMarkdown(greeting) } })] })), messages.map((m, i) => {
353
+ // Show the avatar only on the FIRST bubble in a run of
354
+ // assistant messages. Consecutive assistant turns get a
355
+ // transparent placeholder so the column stays aligned.
356
+ // User/system bubbles never get an avatar.
357
+ const prev = messages[i - 1];
358
+ const isAssistant = m.role === 'assistant';
359
+ const prevWasAssistant = prev?.role === 'assistant';
360
+ const showAvatar = bare && isAssistant && !prevWasAssistant;
361
+ return ((0, jsx_runtime_1.jsx)(MessageBubble, { message: m, session: session, readOnly: readOnlyApprovals, onDecision: onApprovalDecision, bare: bare, showAvatar: showAvatar, avatarTheme: theme, avatarName: personaName ?? agent?.name, onContinue: () => {
362
+ // After a successful Approve, kick the next turn so the
363
+ // gate's fast-path consumes the approval and the tool
364
+ // actually runs. `silent: true` keeps the literal
365
+ // "continue" prompt out of the visible transcript —
366
+ // the visitor just sees the assistant's next reply
367
+ // appearing under the "Approved" pill.
368
+ if (!session)
369
+ return;
370
+ setTimeout(() => {
371
+ void session.send('continue', { silent: true });
372
+ }, 250);
373
+ } }, m.id));
374
+ })] }), lastError && status === 'error' && ((0, jsx_runtime_1.jsx)("div", { className: "af-error", children: lastError })), shortcuts && shortcuts.length > 0 && messages.length === 0 && ((0, jsx_runtime_1.jsx)("div", { className: "af-shortcut-row", children: shortcuts.map((text, i) => ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-shortcut", onClick: () => {
375
+ if (onShortcutClick)
376
+ onShortcutClick(text, i);
377
+ else
378
+ setDraft(text);
379
+ }, children: text }, `${i}-${text}`))) })), (0, jsx_runtime_1.jsxs)("div", { className: "af-input-row", children: [(0, jsx_runtime_1.jsx)("textarea", { className: "af-input", value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: onKeyDown, placeholder: inputPlaceholder ?? 'Type a message…', rows: 1, disabled: status === 'ended' || status === 'loading' || status === 'idle' }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-send", onClick: handleSend, disabled: sendDisabled, "aria-label": "Send message", children: (0, jsx_runtime_1.jsx)(SendIcon, {}) })] }), !bare && (0, jsx_runtime_1.jsx)("div", { className: "af-footer", children: "Powered by AgentForge" })] })] }));
380
+ }
381
+ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, }) {
321
382
  const kind = message.metadata?.kind;
322
383
  if (kind === 'awaiting_approval') {
323
- return ((0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }));
384
+ // Approval / blocked bubbles also count as "assistant-side" so we
385
+ // wrap them in the same row geometry — keeps the conversation
386
+ // aligned even when a tool dispatch interrupts the regular flow.
387
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }));
324
388
  }
325
389
  if (kind === 'tool_blocked') {
326
- return (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message });
390
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }));
327
391
  }
328
392
  const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
329
393
  ? message.content
@@ -332,17 +396,46 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, })
332
396
  : ''}`;
333
397
  // Typing state (no content yet): render the three-dot indicator, no markdown.
334
398
  if (message.role === 'assistant' && message.isStreaming && !message.content) {
335
- return ((0, jsx_runtime_1.jsx)("div", { className: cls, children: (0, jsx_runtime_1.jsxs)("span", { className: "af-typing-dots", "aria-label": "Assistant is typing", children: [(0, jsx_runtime_1.jsx)("span", {}), " ", (0, jsx_runtime_1.jsx)("span", {}), " ", (0, jsx_runtime_1.jsx)("span", {})] }) }));
399
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)("div", { className: cls, children: (0, jsx_runtime_1.jsxs)("span", { className: "af-typing-dots", "aria-label": "Assistant is typing", children: [(0, jsx_runtime_1.jsx)("span", {}), " ", (0, jsx_runtime_1.jsx)("span", {}), " ", (0, jsx_runtime_1.jsx)("span", {})] }) }));
336
400
  }
337
401
  if (message.role === 'assistant') {
338
- return ((0, jsx_runtime_1.jsx)("div", { className: cls,
402
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)("div", { className: cls,
339
403
  // Output is sanitized by escapeHtml + a fixed tag whitelist in
340
404
  // renderMarkdown — safe to inject as HTML.
341
405
  dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }));
342
406
  }
343
407
  // User & system messages stay as plain text — they're typed verbatim.
408
+ // No avatar column on the user side; they align right.
344
409
  return (0, jsx_runtime_1.jsx)("div", { className: cls, children: message.content });
345
410
  }
411
+ /**
412
+ * Wrap an assistant-side bubble in the row geometry that reserves an
413
+ * avatar column. Card variant skips the wrapper entirely so its
414
+ * historical layout is unchanged.
415
+ */
416
+ function wrapAssistantRow(bare, showAvatar, theme, name, child) {
417
+ if (!bare)
418
+ return child;
419
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "af-msg-row af-msg-row-assistant", children: [(0, jsx_runtime_1.jsx)(AssistantAvatar, { theme: theme, name: name, show: showAvatar }), child] }));
420
+ }
421
+ /**
422
+ * Small circular avatar for the assistant-side column. Prefers the
423
+ * agent's avatarUrl when set; otherwise renders a gradient circle
424
+ * with the first letter of `name`. The slot reserves space even when
425
+ * `show` is false so consecutive bubbles stay column-aligned.
426
+ */
427
+ function AssistantAvatar({ theme, name, show, }) {
428
+ const initial = (name ?? 'A').trim().charAt(0).toUpperCase() || 'A';
429
+ if (!show) {
430
+ return (0, jsx_runtime_1.jsx)("div", { className: "af-msg-avatar af-msg-avatar-spacer", "aria-hidden": true });
431
+ }
432
+ if (theme?.avatarUrl) {
433
+ return (
434
+ // eslint-disable-next-line @next/next/no-img-element -- vanilla widget; consumer can override host
435
+ (0, jsx_runtime_1.jsx)("img", { className: "af-msg-avatar af-msg-avatar-img", src: theme.avatarUrl, alt: "", "aria-hidden": true }));
436
+ }
437
+ return ((0, jsx_runtime_1.jsx)("div", { className: "af-msg-avatar af-msg-avatar-fallback", "aria-hidden": true, children: (0, jsx_runtime_1.jsx)("span", { children: initial }) }));
438
+ }
346
439
  /**
347
440
  * Awaiting-approval bubble. Renders the tool name + a countdown, and
348
441
  * either the Approve/Deny buttons (when the host wired the handler)
@@ -517,6 +610,52 @@ const WIDGET_CSS = `
517
610
  to { opacity: 1; transform: translateY(0); }
518
611
  }
519
612
  .af-input-row { padding: 12px; border-top: 1px solid var(--af-border); background: var(--af-bg); display: flex; gap: 8px; align-items: flex-end; }
613
+ .af-shortcut-row {
614
+ display: flex;
615
+ gap: 6px;
616
+ padding: 10px 12px 6px 12px;
617
+ overflow-x: auto;
618
+ scrollbar-width: none;
619
+ background: var(--af-bg);
620
+ animation: af-msg-in 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
621
+ }
622
+ .af-shortcut-row::-webkit-scrollbar { display: none; }
623
+ /* When the shortcut row sits directly above the composer, drop the
624
+ composer's top padding + border so the two read as a single
625
+ stacked block. Without this the 12px input-row padding + 1px
626
+ border-top create a visible gutter between the chips and the
627
+ textarea. */
628
+ .af-shortcut-row + .af-input-row {
629
+ padding-top: 4px;
630
+ border-top: 0;
631
+ }
632
+ .af-shortcut {
633
+ flex-shrink: 0;
634
+ display: inline-flex;
635
+ align-items: center;
636
+ gap: 6px;
637
+ padding: 6px 12px;
638
+ font-size: 12.5px;
639
+ font-weight: 500;
640
+ font-family: inherit;
641
+ line-height: 1.2;
642
+ color: var(--af-fg);
643
+ background: var(--af-input-bg, rgba(148, 163, 184, 0.10));
644
+ border: 1px solid var(--af-input-border, rgba(148, 163, 184, 0.22));
645
+ border-radius: 999px;
646
+ cursor: pointer;
647
+ transition: background-color .15s ease, border-color .15s ease, transform .15s ease;
648
+ white-space: nowrap;
649
+ max-width: 280px;
650
+ overflow: hidden;
651
+ text-overflow: ellipsis;
652
+ }
653
+ .af-shortcut:hover {
654
+ background: var(--af-primary-soft, rgba(99, 102, 241, 0.10));
655
+ border-color: var(--af-primary, #6366f1);
656
+ transform: translateY(-1px);
657
+ }
658
+ .af-shortcut:active { transform: translateY(0); }
520
659
  .af-input { flex: 1; border: 1px solid var(--af-border); border-radius: 10px; padding: 10px 12px; font-size: 14px; font-family: inherit; outline: none; resize: none; min-height: 40px; max-height: 120px; color: var(--af-fg); background: var(--af-bg); transition: border-color .15s ease, box-shadow .15s ease; }
521
660
  .af-input:focus { border-color: var(--af-primary); box-shadow: 0 0 0 3px var(--af-primary-soft); }
522
661
  .af-input:disabled { background: var(--af-bubble-bg); cursor: not-allowed; }
@@ -567,6 +706,23 @@ const WIDGET_CSS = `
567
706
  * --af-gap spacing between messages (default: 12px)
568
707
  */
569
708
  .af-widget-root.af-variant-bare { font-family: inherit; color: inherit; }
709
+ /* Make the bare-inline root fill its flex parent so the panel +
710
+ * its sticky input dock to the parent's bottom edge instead of
711
+ * collapsing to content height. Required for hosts that nest the
712
+ * widget inside a constrained drawer / column.
713
+ *
714
+ * width:100% is mandatory: the base af-widget-root ships
715
+ * position:fixed which leaves the root with intrinsic-content
716
+ * width by default. af-inline only resets position, not the width
717
+ * — so without this the bare-inline widget collapses to the width
718
+ * of its tallest child (usually the input pill). */
719
+ .af-widget-root.af-variant-bare.af-inline {
720
+ display: flex;
721
+ flex-direction: column;
722
+ width: 100%;
723
+ height: 100%;
724
+ min-height: 0;
725
+ }
570
726
  .af-widget-root.af-variant-bare.af-inline .af-panel,
571
727
  .af-widget-root.af-variant-bare .af-panel {
572
728
  position: relative;
@@ -579,11 +735,20 @@ const WIDGET_CSS = `
579
735
  overflow: visible;
580
736
  display: flex;
581
737
  flex-direction: column;
738
+ flex: 1;
739
+ min-height: 0;
582
740
  }
583
741
  .af-widget-root.af-variant-bare .af-messages {
584
742
  background: transparent;
585
- padding: 16px 14px 12px;
586
- gap: var(--af-gap, 12px);
743
+ /* Vertical only. Side gutters belong to the host so messages can
744
+ align with whatever surrounding column the page renders.
745
+ Bottom padding creates breathing room between the last bubble
746
+ and the composer pill — without it the assistant's reply
747
+ visually collides with the input border. */
748
+ padding: 6px 0 14px;
749
+ gap: var(--af-gap, 8px);
750
+ width: 100%;
751
+ box-sizing: border-box;
587
752
  /* Let the parent flexbox give us a min-height; we'll grow into it
588
753
  and scroll internally when the transcript gets longer. */
589
754
  min-height: 0;
@@ -613,18 +778,197 @@ const WIDGET_CSS = `
613
778
  background: rgba(148, 163, 184, 0.2);
614
779
  color: inherit;
615
780
  }
781
+ /* Tables inside an assistant bubble. We use @media on viewport
782
+ (not @container) because the bubble row uses max-width 92% and
783
+ any container-type:inline-size ancestor breaks the
784
+ percentage-against-flex-parent sizing, collapsing the bubble to
785
+ one character wide. Trade-off: the editor preview iframe (which
786
+ is 420px inside a desktop browser) wont show the stacked layout
787
+ — fine, the preview is for design checks and the layout
788
+ activates correctly on a real phone. */
789
+ .af-widget-root.af-variant-bare .af-msg-assistant table {
790
+ display: table;
791
+ width: 100%;
792
+ max-width: 100%;
793
+ margin: 6px 0;
794
+ font-size: 13px;
795
+ border-collapse: collapse;
796
+ table-layout: auto;
797
+ }
798
+ .af-widget-root.af-variant-bare .af-msg-assistant thead th {
799
+ text-align: left;
800
+ font-weight: 600;
801
+ padding: 6px 10px;
802
+ border-bottom: 1px solid rgba(148, 163, 184, 0.35);
803
+ white-space: nowrap;
804
+ }
805
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody td {
806
+ padding: 5px 10px;
807
+ border-bottom: 1px solid rgba(148, 163, 184, 0.15);
808
+ vertical-align: top;
809
+ }
810
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody tr:last-child td {
811
+ border-bottom: none;
812
+ }
813
+
814
+ /* Stacked card layout for tables on narrow viewports. Each row
815
+ becomes a small card; each cell shows its column label inline
816
+ via the data-label attribute we stamped on render. No horizontal
817
+ scroll, no truncation. Activates on real mobile (the editor's
818
+ preview iframe stays in the desktop look since it sits inside a
819
+ wide browser viewport — that's intentional). */
820
+ @media (max-width: 480px) {
821
+ .af-widget-root.af-variant-bare .af-msg-assistant table,
822
+ .af-widget-root.af-variant-bare .af-msg-assistant thead,
823
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody,
824
+ .af-widget-root.af-variant-bare .af-msg-assistant tr,
825
+ .af-widget-root.af-variant-bare .af-msg-assistant td,
826
+ .af-widget-root.af-variant-bare .af-msg-assistant th {
827
+ display: block;
828
+ width: 100%;
829
+ }
830
+ .af-widget-root.af-variant-bare .af-msg-assistant thead {
831
+ position: absolute;
832
+ left: -9999px;
833
+ }
834
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody tr {
835
+ border: 1px solid rgba(148, 163, 184, 0.18);
836
+ border-radius: 10px;
837
+ padding: 8px 10px;
838
+ margin: 6px 0;
839
+ }
840
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody td {
841
+ border-bottom: none;
842
+ padding: 3px 0;
843
+ display: flex;
844
+ gap: 8px;
845
+ align-items: baseline;
846
+ flex-wrap: wrap;
847
+ }
848
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody td::before {
849
+ content: attr(data-label);
850
+ flex-shrink: 0;
851
+ min-width: 72px;
852
+ font-weight: 600;
853
+ opacity: 0.65;
854
+ font-size: 11px;
855
+ text-transform: uppercase;
856
+ letter-spacing: 0.04em;
857
+ }
858
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody td[data-label='']::before {
859
+ display: none;
860
+ }
861
+ }
616
862
  .af-widget-root.af-variant-bare .af-msg-greeting { animation: af-msg-in 320ms cubic-bezier(0.2, 0.8, 0.2, 1); }
863
+
864
+ /* Slack-style assistant rows: avatar column on the left, bubble on
865
+ * the right. The row IS the alignment container — align-self targets
866
+ * the row, not the bubble, so user/assistant rows still push opposite
867
+ * edges. */
868
+ .af-widget-root.af-variant-bare .af-msg-row {
869
+ display: flex;
870
+ align-items: flex-end;
871
+ gap: 8px;
872
+ max-width: 92%;
873
+ }
874
+ .af-widget-root.af-variant-bare .af-msg-row-assistant {
875
+ align-self: flex-start;
876
+ }
877
+ .af-widget-root.af-variant-bare .af-msg-row .af-msg {
878
+ /* The bubble is now a flex child of the row. Reset the max-width
879
+ * the row already enforces — otherwise we'd stack two caps. */
880
+ max-width: 100%;
881
+ align-self: flex-end;
882
+ }
883
+ .af-widget-root.af-variant-bare .af-msg-avatar {
884
+ width: 26px;
885
+ height: 26px;
886
+ flex-shrink: 0;
887
+ border-radius: 50%;
888
+ overflow: hidden;
889
+ /* Stick to the bubble's bottom edge so multi-line replies still
890
+ * show the avatar next to the last line — same as iMessage. */
891
+ align-self: flex-end;
892
+ margin-bottom: 2px;
893
+ }
894
+ .af-widget-root.af-variant-bare .af-msg-avatar-spacer {
895
+ background: transparent;
896
+ }
897
+ .af-widget-root.af-variant-bare .af-msg-avatar-img {
898
+ object-fit: cover;
899
+ display: block;
900
+ }
901
+ .af-widget-root.af-variant-bare .af-msg-avatar-fallback {
902
+ display: flex;
903
+ align-items: center;
904
+ justify-content: center;
905
+ color: #fff;
906
+ font-size: 11.5px;
907
+ font-weight: 600;
908
+ letter-spacing: 0.01em;
909
+ background-image: linear-gradient(135deg, var(--af-primary, #8b5cf6), color-mix(in srgb, var(--af-primary, #8b5cf6) 60%, #6366f1));
910
+ }
911
+ @media (min-width: 768px) {
912
+ .af-widget-root.af-variant-bare .af-msg-avatar { width: 30px; height: 30px; }
913
+ .af-widget-root.af-variant-bare .af-msg-avatar-fallback { font-size: 13px; }
914
+ }
617
915
  .af-widget-root.af-variant-bare .af-input-row {
618
- position: sticky;
619
- bottom: 0;
620
- padding: 10px 12px 14px;
916
+ /* Plain flex item. af-messages carries flex:1 so it eats the
917
+ remaining space and naturally pushes the input row to the
918
+ bottom of the panel. Sticky positioning across host containers
919
+ was unreliable (different ancestors had different overflow
920
+ semantics); flex layout is deterministic.
921
+ width:100% + box-sizing:border-box so the row fills its
922
+ parent cross-axis instead of shrinking to its children
923
+ intrinsic width — without these, certain ancestor combos
924
+ (Radix portals, Sheet content) left the row offset from the
925
+ edges and pushed the send button out of view. */
926
+ flex-shrink: 0;
927
+ width: 100%;
928
+ box-sizing: border-box;
929
+ /* Vertical only — horizontal padding lives on the host wrapper so
930
+ the input pill can stretch edge-to-edge when the host wants
931
+ (e.g. a sheet drawer). Templates that want side gutters add
932
+ their own padding above the widget. */
933
+ padding: 10px 0 14px;
621
934
  border-top: none;
622
- background: transparent;
935
+ /* Soft fade so messages scroll behind the input without bleeding
936
+ through. Host can override via --af-input-row-bg (e.g. solid
937
+ color) for templates that want a hard band instead. */
938
+ background: var(
939
+ --af-input-row-bg,
940
+ linear-gradient(to top, rgba(255, 255, 255, 0.92) 60%, rgba(255, 255, 255, 0))
941
+ );
942
+ backdrop-filter: saturate(140%) blur(8px);
943
+ -webkit-backdrop-filter: saturate(140%) blur(8px);
623
944
  gap: 8px;
624
945
  }
946
+ @media (prefers-color-scheme: dark) {
947
+ .af-widget-root.af-variant-bare .af-input-row {
948
+ background: var(
949
+ --af-input-row-bg,
950
+ linear-gradient(to top, rgba(15, 23, 42, 0.85) 60%, rgba(15, 23, 42, 0))
951
+ );
952
+ }
953
+ }
954
+ /* In the bare variant the shortcut row also lives directly above
955
+ the composer. Tighten the gap so the chips read as a continuous
956
+ stack with the input. */
957
+ .af-widget-root.af-variant-bare .af-shortcut-row {
958
+ padding: 0 12px 6px;
959
+ background: transparent;
960
+ }
961
+ .af-widget-root.af-variant-bare .af-shortcut-row + .af-input-row {
962
+ padding-top: 2px;
963
+ }
625
964
  .af-widget-root.af-variant-bare .af-input {
626
965
  background: var(--af-input-bg, rgba(148, 163, 184, 0.10));
627
- color: inherit;
966
+ /* Foreground is overridable. Defaults to currentColor so the
967
+ input inherits the page text color (light text in dark mode,
968
+ dark text in light mode) instead of getting frozen at the
969
+ bg's "neutral" value, which was causing low-contrast text on
970
+ dark backgrounds when the host overrode --af-input-bg. */
971
+ color: var(--af-input-fg, currentColor);
628
972
  border: 1px solid var(--af-input-border, rgba(148, 163, 184, 0.25));
629
973
  border-radius: var(--af-input-radius, 24px);
630
974
  padding: 12px 16px;
@@ -632,13 +976,21 @@ const WIDGET_CSS = `
632
976
  min-height: 48px;
633
977
  line-height: 1.4;
634
978
  }
979
+ .af-widget-root.af-variant-bare .af-input::placeholder {
980
+ color: var(--af-input-placeholder, currentColor);
981
+ opacity: 0.5;
982
+ }
635
983
  .af-widget-root.af-variant-bare .af-input:focus {
636
984
  border-color: var(--af-primary);
637
985
  box-shadow: 0 0 0 3px var(--af-primary-soft);
638
986
  }
639
987
  .af-widget-root.af-variant-bare .af-input:disabled { background: rgba(148, 163, 184, 0.06); }
640
988
  .af-widget-root.af-variant-bare .af-send {
641
- background: var(--af-primary);
989
+ /* Host overrides via --af-send-bg; otherwise the primary color
990
+ drives the send button. Same fallback pattern used elsewhere
991
+ so templates can opt into different palettes per surface
992
+ without redeclaring the primary. */
993
+ background: var(--af-send-bg, var(--af-primary));
642
994
  color: #fff;
643
995
  border-radius: 50%;
644
996
  width: 44px;
@@ -655,23 +1007,13 @@ const WIDGET_CSS = `
655
1007
  }
656
1008
  .af-widget-root.af-variant-bare .af-typing-dots span { background: currentColor; opacity: 0.5; }
657
1009
 
658
- /* Mobile-first sticky input: on narrow viewports the input docks to
659
- * the viewport's bottom edge so the visitor never has to scroll to
660
- * find it (same affordance as iMessage / WhatsApp). On desktop the
661
- * sticky behaviour scopes to the chat container, which is enough. */
1010
+ /* On narrow viewports add safe-area padding so the input clears the
1011
+ * home indicator on iPhones. Base sticky + backdrop styles above
1012
+ * apply at every viewport no override needed. */
662
1013
  @media (max-width: 640px) {
663
1014
  .af-widget-root.af-variant-bare .af-input-row {
664
- position: sticky;
665
1015
  bottom: env(safe-area-inset-bottom, 0);
666
1016
  padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
667
- background: var(--af-input-row-bg, rgba(255, 255, 255, 0.85));
668
- backdrop-filter: saturate(140%) blur(8px);
669
- -webkit-backdrop-filter: saturate(140%) blur(8px);
670
- }
671
- }
672
- @media (max-width: 640px) and (prefers-color-scheme: dark) {
673
- .af-widget-root.af-variant-bare .af-input-row {
674
- background: var(--af-input-row-bg, rgba(15, 23, 42, 0.7));
675
1017
  }
676
1018
  }
677
1019
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.0.24",
3
+ "version": "2.0.25",
4
4
  "description": "Framework-free chat session SDK for AgentForge public chat tokens. Headless — no DOM. Drop into any frontend (React, Vue, Svelte, vanilla) and listen for events.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",