@agentforge-io/chat-sdk 2.0.24 → 2.1.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.
@@ -71,12 +71,43 @@ export interface ChatMessage {
71
71
  * errors, …). View layers should branch on `metadata.kind` before
72
72
  * falling back to `content`. */
73
73
  metadata?: ChatMessageMetadata;
74
+ /** When set, this assistant bubble was produced by a delegated member
75
+ * agent (not the primary session agent). View layers resolve the
76
+ * member's avatar/name via the session's `members` roster. Absent
77
+ * on user messages and on bubbles spoken by the primary agent
78
+ * (the orchestrator itself, or any non-team agent). */
79
+ actingAgentId?: string;
74
80
  }
75
81
  export interface ChatAgentSummary {
76
82
  slug: string;
77
83
  name: string;
78
84
  description?: string;
79
85
  }
86
+ /**
87
+ * One entry in a Team session's member roster. The orchestrator delegates
88
+ * to these members; the SDK uses the roster to resolve `actingAgentId`
89
+ * → avatar/name when rendering per-bubble identity.
90
+ *
91
+ * For non-team sessions (a single agent), `members` is empty/undefined
92
+ * and the SDK falls back to the primary agent's identity on every
93
+ * assistant bubble — the legacy behaviour.
94
+ */
95
+ export interface ChatTeamMember {
96
+ /** Agent id — matches `actingAgentId` on stream chunks and on
97
+ * rehydrated `ChatMessage.actingAgentId`. */
98
+ id: string;
99
+ name: string;
100
+ /** Optional avatar URL. When absent, view layers fall back to a
101
+ * gradient-with-initial placeholder. */
102
+ avatarUrl?: string;
103
+ /** Optional short description ("Handles billing and refunds"). Used
104
+ * for tooltip / member-strip rendering on the team channel page. */
105
+ description?: string;
106
+ /** Optional per-member routing hint inherited from the team config.
107
+ * Surfaced in tooltips so visitors understand the routing policy
108
+ * without opening the admin view. */
109
+ roleHint?: string;
110
+ }
80
111
  /**
81
112
  * Visual theme returned alongside the agent. Pure data — the SDK doesn't
82
113
  * apply it; it just surfaces it so any View layer (widget renderer, React
@@ -110,6 +141,21 @@ export type ChatEvent = {
110
141
  } | {
111
142
  type: 'message_removed';
112
143
  messageId: string;
144
+ }
145
+ /**
146
+ * Emitted when a Team orchestrator delegates a turn to one of its
147
+ * members. Carries the member identity so the View layer can render
148
+ * a subtle "Routing to <member>…" hint (typically as a low-contrast
149
+ * line between the orchestrator's wrapper bubble and the member's
150
+ * answer). Pure UX signal — the actual member output arrives via
151
+ * regular `message_added` / `message_updated` events with the
152
+ * matching `actingAgentId`.
153
+ */
154
+ | {
155
+ type: 'delegation_routed';
156
+ memberId: string;
157
+ memberName?: string;
158
+ memberAvatarUrl?: string;
113
159
  } | {
114
160
  type: 'error';
115
161
  message: string;
package/dist/react.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * runtime error.
17
17
  */
18
18
  import { type CSSProperties } from 'react';
19
- import type { ChatTheme } from './entities';
19
+ import type { ChatTeamMember, ChatTheme } from './entities';
20
20
  /**
21
21
  * Optional observer fired after the SDK resolves a tool-approval bubble.
22
22
  * The SDK owns the network call (it already has `apiBaseUrl` and the
@@ -127,6 +127,49 @@ 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
+ * Team-member roster. When the chat is bound to a Team orchestrator,
146
+ * pass the team's members here so the SDK can render each delegated
147
+ * bubble with the right member's avatar / name. Each entry's `id`
148
+ * must match the `actingAgentId` the runtime emits on stream chunks
149
+ * and on rehydrated `ChatMessage`s. Empty/undefined ⇒ single-agent
150
+ * session, every bubble uses the primary agent identity (legacy
151
+ * behaviour).
152
+ */
153
+ members?: ChatTeamMember[];
154
+ /**
155
+ * Imperative handle. The widget fills this ref on mount with a
156
+ * small command object the host can call to drive the session
157
+ * without owning it. Today exposes `sendNow(text)` — sends the
158
+ * given text as a user turn, bypassing the textarea/draft state.
159
+ * Use this for chips, suggested replies, programmatic kicks, etc.
160
+ *
161
+ * `null` while the session is still booting. Becomes available
162
+ * once `agent_loaded` has fired internally.
163
+ */
164
+ handleRef?: React.MutableRefObject<ChatWidgetHandle | null>;
165
+ }
166
+ /** Imperative surface exposed via ChatWidgetProps.handleRef. Narrow
167
+ * on purpose — hosts get a verb, not the whole session. */
168
+ export interface ChatWidgetHandle {
169
+ /** Send `text` immediately as a user turn. Skips the textarea
170
+ * entirely. No-op if the session isn't ready yet (status ===
171
+ * 'idle' / 'loading'). */
172
+ sendNow(text: string): void;
130
173
  }
131
174
  /**
132
175
  * 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,17 @@ 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, members, } = props;
231
+ // Build a lookup so MessageBubble can resolve actingAgentId → identity
232
+ // in O(1) per render without re-walking the members array. Stable
233
+ // identity per `members` prop change.
234
+ const membersById = (0, react_1.useMemo)(() => {
235
+ const m = new Map();
236
+ if (members)
237
+ for (const member of members)
238
+ m.set(member.id, member);
239
+ return m;
240
+ }, [members]);
185
241
  const bare = variant === 'bare';
186
242
  const [session, setSession] = (0, react_1.useState)(null);
187
243
  const [status, setStatus] = (0, react_1.useState)('idle');
@@ -192,7 +248,21 @@ function ChatWidget(props) {
192
248
  const [open, setOpen] = (0, react_1.useState)(inline);
193
249
  const [draft, setDraft] = (0, react_1.useState)('');
194
250
  const messagesRef = (0, react_1.useRef)(null);
251
+ const inputRef = (0, react_1.useRef)(null);
195
252
  const styleId = (0, react_1.useId)();
253
+ // Auto-focus the composer on desktop (pointer:fine) once the session
254
+ // is ready. Skipped on touch devices — focusing a textarea on iOS /
255
+ // Android opens the on-screen keyboard, which is hostile on a public
256
+ // profile page where the visitor probably wants to read first.
257
+ (0, react_1.useEffect)(() => {
258
+ if (status !== 'ready')
259
+ return;
260
+ if (typeof window === 'undefined')
261
+ return;
262
+ if (!window.matchMedia('(pointer: fine)').matches)
263
+ return;
264
+ inputRef.current?.focus({ preventScroll: true });
265
+ }, [status]);
196
266
  // ── Session lifecycle. Recreate when token / apiBaseUrl changes. ──────
197
267
  (0, react_1.useEffect)(() => {
198
268
  let cancelled = false;
@@ -303,7 +373,31 @@ function ChatWidget(props) {
303
373
  };
304
374
  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
375
  // 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: () => {
376
+ (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.jsx)("div", { className: "af-messages", ref: messagesRef, children: messages.map((m, i) => {
377
+ // Show the avatar only on the FIRST bubble in a run of
378
+ // assistant messages BY THE SAME speaker. A delegation
379
+ // boundary (orchestrator → member, or member → next
380
+ // member) counts as a new run so each speaker gets their
381
+ // avatar surfaced. User/system bubbles never get an avatar.
382
+ const prev = messages[i - 1];
383
+ const isAssistant = m.role === 'assistant';
384
+ const sameAssistantRun = prev?.role === 'assistant' &&
385
+ (prev?.actingAgentId ?? null) === (m.actingAgentId ?? null);
386
+ const showAvatar = bare && isAssistant && !sameAssistantRun;
387
+ // Per-bubble identity resolution. When the chunk carried
388
+ // an `actingAgentId` (orchestrator delegated to a member),
389
+ // we render the member's avatar/name instead of the
390
+ // primary agent's. Falling back to the primary identity
391
+ // keeps non-team sessions and orchestrator-self turns
392
+ // looking the same as before.
393
+ const member = m.actingAgentId
394
+ ? membersById.get(m.actingAgentId)
395
+ : undefined;
396
+ const bubbleAvatarTheme = member
397
+ ? { ...theme, avatarUrl: member.avatarUrl }
398
+ : theme;
399
+ const bubbleAvatarName = member?.name ?? personaName ?? agent?.name;
400
+ return ((0, jsx_runtime_1.jsx)(MessageBubble, { message: m, session: session, readOnly: readOnlyApprovals, onDecision: onApprovalDecision, bare: bare, showAvatar: showAvatar, avatarTheme: bubbleAvatarTheme, avatarName: bubbleAvatarName, speakerLabel: member?.name, onContinue: () => {
307
401
  // After a successful Approve, kick the next turn so the
308
402
  // gate's fast-path consumes the approval and the tool
309
403
  // actually runs. `silent: true` keeps the literal
@@ -315,15 +409,24 @@ function ChatWidget(props) {
315
409
  setTimeout(() => {
316
410
  void session.send('continue', { silent: true });
317
411
  }, 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" })] })] }));
412
+ } }, m.id));
413
+ }) }), lastError && status === 'error' && ((0, jsx_runtime_1.jsx)("div", { className: "af-error", children: lastError })), bare && greeting && messages.length === 0 && status !== 'loading' && ((0, jsx_runtime_1.jsx)("div", { className: "af-greeting-slot", children: (0, jsx_runtime_1.jsxs)("div", { className: "af-msg-row af-msg-row-assistant af-msg-row-greeting", 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) } })] }) })), 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: () => {
414
+ if (onShortcutClick)
415
+ onShortcutClick(text, i);
416
+ else
417
+ setDraft(text);
418
+ }, children: text }, `${i}-${text}`))) })), (0, jsx_runtime_1.jsxs)("div", { className: "af-input-row", children: [(0, jsx_runtime_1.jsx)("textarea", { ref: inputRef, 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
419
  }
320
- function MessageBubble({ message, session, readOnly, onDecision, onContinue, }) {
420
+ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, speakerLabel, }) {
321
421
  const kind = message.metadata?.kind;
322
422
  if (kind === 'awaiting_approval') {
323
- return ((0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }));
423
+ // Approval / blocked bubbles also count as "assistant-side" so we
424
+ // wrap them in the same row geometry — keeps the conversation
425
+ // aligned even when a tool dispatch interrupts the regular flow.
426
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }), speakerLabel);
324
427
  }
325
428
  if (kind === 'tool_blocked') {
326
- return (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message });
429
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }), speakerLabel);
327
430
  }
328
431
  const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
329
432
  ? message.content
@@ -332,17 +435,50 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, })
332
435
  : ''}`;
333
436
  // Typing state (no content yet): render the three-dot indicator, no markdown.
334
437
  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", {})] }) }));
438
+ 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", {})] }) }), speakerLabel);
336
439
  }
337
440
  if (message.role === 'assistant') {
338
- return ((0, jsx_runtime_1.jsx)("div", { className: cls,
441
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)("div", { className: cls,
339
442
  // Output is sanitized by escapeHtml + a fixed tag whitelist in
340
443
  // renderMarkdown — safe to inject as HTML.
341
- dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }));
444
+ dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }), speakerLabel);
342
445
  }
343
446
  // User & system messages stay as plain text — they're typed verbatim.
447
+ // No avatar column on the user side; they align right.
344
448
  return (0, jsx_runtime_1.jsx)("div", { className: cls, children: message.content });
345
449
  }
450
+ /**
451
+ * Wrap an assistant-side bubble in the row geometry that reserves an
452
+ * avatar column. Card variant skips the wrapper entirely so its
453
+ * historical layout is unchanged.
454
+ */
455
+ function wrapAssistantRow(bare, showAvatar, theme, name, child,
456
+ /** When set + this is the first bubble of a speaker run (showAvatar
457
+ * is true), render the speaker's display name just above the
458
+ * bubble. Used by Team chats so members are visually attributed. */
459
+ speakerLabel) {
460
+ if (!bare)
461
+ return child;
462
+ 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 }), (0, jsx_runtime_1.jsxs)("div", { className: "af-msg-col", children: [showAvatar && speakerLabel ? ((0, jsx_runtime_1.jsx)("div", { className: "af-msg-speaker", children: speakerLabel })) : null, child] })] }));
463
+ }
464
+ /**
465
+ * Small circular avatar for the assistant-side column. Prefers the
466
+ * agent's avatarUrl when set; otherwise renders a gradient circle
467
+ * with the first letter of `name`. The slot reserves space even when
468
+ * `show` is false so consecutive bubbles stay column-aligned.
469
+ */
470
+ function AssistantAvatar({ theme, name, show, }) {
471
+ const initial = (name ?? 'A').trim().charAt(0).toUpperCase() || 'A';
472
+ if (!show) {
473
+ return (0, jsx_runtime_1.jsx)("div", { className: "af-msg-avatar af-msg-avatar-spacer", "aria-hidden": true });
474
+ }
475
+ if (theme?.avatarUrl) {
476
+ return (
477
+ // eslint-disable-next-line @next/next/no-img-element -- vanilla widget; consumer can override host
478
+ (0, jsx_runtime_1.jsx)("img", { className: "af-msg-avatar af-msg-avatar-img", src: theme.avatarUrl, alt: "", "aria-hidden": true }));
479
+ }
480
+ 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 }) }));
481
+ }
346
482
  /**
347
483
  * Awaiting-approval bubble. Renders the tool name + a countdown, and
348
484
  * either the Approve/Deny buttons (when the host wired the handler)
@@ -517,6 +653,52 @@ const WIDGET_CSS = `
517
653
  to { opacity: 1; transform: translateY(0); }
518
654
  }
519
655
  .af-input-row { padding: 12px; border-top: 1px solid var(--af-border); background: var(--af-bg); display: flex; gap: 8px; align-items: flex-end; }
656
+ .af-shortcut-row {
657
+ display: flex;
658
+ gap: 6px;
659
+ padding: 10px 12px 6px 12px;
660
+ overflow-x: auto;
661
+ scrollbar-width: none;
662
+ background: var(--af-bg);
663
+ animation: af-msg-in 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
664
+ }
665
+ .af-shortcut-row::-webkit-scrollbar { display: none; }
666
+ /* When the shortcut row sits directly above the composer, drop the
667
+ composer's top padding + border so the two read as a single
668
+ stacked block. Without this the 12px input-row padding + 1px
669
+ border-top create a visible gutter between the chips and the
670
+ textarea. */
671
+ .af-shortcut-row + .af-input-row {
672
+ padding-top: 4px;
673
+ border-top: 0;
674
+ }
675
+ .af-shortcut {
676
+ flex-shrink: 0;
677
+ display: inline-flex;
678
+ align-items: center;
679
+ gap: 6px;
680
+ padding: 6px 12px;
681
+ font-size: 12.5px;
682
+ font-weight: 500;
683
+ font-family: inherit;
684
+ line-height: 1.2;
685
+ color: var(--af-fg);
686
+ background: var(--af-input-bg, rgba(148, 163, 184, 0.10));
687
+ border: 1px solid var(--af-input-border, rgba(148, 163, 184, 0.22));
688
+ border-radius: 999px;
689
+ cursor: pointer;
690
+ transition: background-color .15s ease, border-color .15s ease, transform .15s ease;
691
+ white-space: nowrap;
692
+ max-width: 280px;
693
+ overflow: hidden;
694
+ text-overflow: ellipsis;
695
+ }
696
+ .af-shortcut:hover {
697
+ background: var(--af-primary-soft, rgba(99, 102, 241, 0.10));
698
+ border-color: var(--af-primary, #6366f1);
699
+ transform: translateY(-1px);
700
+ }
701
+ .af-shortcut:active { transform: translateY(0); }
520
702
  .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
703
  .af-input:focus { border-color: var(--af-primary); box-shadow: 0 0 0 3px var(--af-primary-soft); }
522
704
  .af-input:disabled { background: var(--af-bubble-bg); cursor: not-allowed; }
@@ -567,26 +749,62 @@ const WIDGET_CSS = `
567
749
  * --af-gap spacing between messages (default: 12px)
568
750
  */
569
751
  .af-widget-root.af-variant-bare { font-family: inherit; color: inherit; }
752
+ /* Make the bare-inline root fill its flex parent so the panel +
753
+ * its sticky input dock to the parent's bottom edge instead of
754
+ * collapsing to content height. Required for hosts that nest the
755
+ * widget inside a constrained drawer / column.
756
+ *
757
+ * width:100% is mandatory: the base af-widget-root ships
758
+ * position:fixed which leaves the root with intrinsic-content
759
+ * width by default. af-inline only resets position, not the width
760
+ * — so without this the bare-inline widget collapses to the width
761
+ * of its tallest child (usually the input pill). */
762
+ .af-widget-root.af-variant-bare.af-inline {
763
+ display: flex;
764
+ flex-direction: column;
765
+ width: 100%;
766
+ height: 100%;
767
+ min-height: 0;
768
+ flex: 1 1 0%;
769
+ }
570
770
  .af-widget-root.af-variant-bare.af-inline .af-panel,
571
771
  .af-widget-root.af-variant-bare .af-panel {
572
772
  position: relative;
573
773
  width: 100%;
574
- height: 100%;
575
- max-height: none;
774
+ height: 100% !important;
775
+ max-height: none !important;
576
776
  background: transparent;
577
777
  border-radius: 0;
578
778
  box-shadow: none;
579
779
  overflow: visible;
580
780
  display: flex;
581
781
  flex-direction: column;
782
+ flex: 1;
783
+ min-height: 0;
582
784
  }
583
785
  .af-widget-root.af-variant-bare .af-messages {
584
786
  background: transparent;
585
- padding: 16px 14px 12px;
586
- gap: var(--af-gap, 12px);
787
+ /* Vertical only. Side gutters belong to the host so messages can
788
+ align with whatever surrounding column the page renders.
789
+ Bottom padding creates breathing room between the last bubble
790
+ and the composer pill — without it the assistant's reply
791
+ visually collides with the input border. */
792
+ padding: 6px 0 14px;
793
+ gap: var(--af-gap, 8px);
794
+ width: 100%;
795
+ box-sizing: border-box;
587
796
  /* Let the parent flexbox give us a min-height; we'll grow into it
588
797
  and scroll internally when the transcript gets longer. */
589
798
  min-height: 0;
799
+ /* Anchor messages to the bottom of the scroll-area when the
800
+ transcript is shorter than the available height — short
801
+ conversations sit just above the composer instead of floating
802
+ up to the top of a tall window. The 'margin-top: auto' on the
803
+ first child does the same job in pure flex without needing
804
+ justify-content. */
805
+ }
806
+ .af-widget-root.af-variant-bare .af-messages > :first-child {
807
+ margin-top: auto;
590
808
  }
591
809
  .af-widget-root.af-variant-bare .af-msg {
592
810
  font-size: 15px;
@@ -613,18 +831,247 @@ const WIDGET_CSS = `
613
831
  background: rgba(148, 163, 184, 0.2);
614
832
  color: inherit;
615
833
  }
616
- .af-widget-root.af-variant-bare .af-msg-greeting { animation: af-msg-in 320ms cubic-bezier(0.2, 0.8, 0.2, 1); }
834
+ /* Tables inside an assistant bubble. We use @media on viewport
835
+ (not @container) because the bubble row uses max-width 92% and
836
+ any container-type:inline-size ancestor breaks the
837
+ percentage-against-flex-parent sizing, collapsing the bubble to
838
+ one character wide. Trade-off: the editor preview iframe (which
839
+ is 420px inside a desktop browser) wont show the stacked layout
840
+ — fine, the preview is for design checks and the layout
841
+ activates correctly on a real phone. */
842
+ .af-widget-root.af-variant-bare .af-msg-assistant table {
843
+ display: table;
844
+ width: 100%;
845
+ max-width: 100%;
846
+ margin: 6px 0;
847
+ font-size: 13px;
848
+ border-collapse: collapse;
849
+ table-layout: auto;
850
+ }
851
+ .af-widget-root.af-variant-bare .af-msg-assistant thead th {
852
+ text-align: left;
853
+ font-weight: 600;
854
+ padding: 6px 10px;
855
+ border-bottom: 1px solid rgba(148, 163, 184, 0.35);
856
+ white-space: nowrap;
857
+ }
858
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody td {
859
+ padding: 5px 10px;
860
+ border-bottom: 1px solid rgba(148, 163, 184, 0.15);
861
+ vertical-align: top;
862
+ }
863
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody tr:last-child td {
864
+ border-bottom: none;
865
+ }
866
+
867
+ /* Stacked card layout for tables on narrow viewports. Each row
868
+ becomes a small card; each cell shows its column label inline
869
+ via the data-label attribute we stamped on render. No horizontal
870
+ scroll, no truncation. Activates on real mobile (the editor's
871
+ preview iframe stays in the desktop look since it sits inside a
872
+ wide browser viewport — that's intentional). */
873
+ @media (max-width: 480px) {
874
+ .af-widget-root.af-variant-bare .af-msg-assistant table,
875
+ .af-widget-root.af-variant-bare .af-msg-assistant thead,
876
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody,
877
+ .af-widget-root.af-variant-bare .af-msg-assistant tr,
878
+ .af-widget-root.af-variant-bare .af-msg-assistant td,
879
+ .af-widget-root.af-variant-bare .af-msg-assistant th {
880
+ display: block;
881
+ width: 100%;
882
+ }
883
+ .af-widget-root.af-variant-bare .af-msg-assistant thead {
884
+ position: absolute;
885
+ left: -9999px;
886
+ }
887
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody tr {
888
+ border: 1px solid rgba(148, 163, 184, 0.18);
889
+ border-radius: 10px;
890
+ padding: 8px 10px;
891
+ margin: 6px 0;
892
+ }
893
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody td {
894
+ border-bottom: none;
895
+ padding: 3px 0;
896
+ display: flex;
897
+ gap: 8px;
898
+ align-items: baseline;
899
+ flex-wrap: wrap;
900
+ }
901
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody td::before {
902
+ content: attr(data-label);
903
+ flex-shrink: 0;
904
+ min-width: 72px;
905
+ font-weight: 600;
906
+ opacity: 0.65;
907
+ font-size: 11px;
908
+ text-transform: uppercase;
909
+ letter-spacing: 0.04em;
910
+ }
911
+ .af-widget-root.af-variant-bare .af-msg-assistant tbody td[data-label='']::before {
912
+ display: none;
913
+ }
914
+ }
915
+ /* Greeting slot — lives between the message scroller and the
916
+ * shortcut chips. The bubble itself animates in with a small upward
917
+ * slide + fade so the visitor's eye is pulled there instead of the
918
+ * static chip row underneath. Avatar slides in alongside it. The
919
+ * 120ms delay gives the chip row a beat to render first so the
920
+ * greeting reads as the assistant *responding* to the page loading,
921
+ * not part of the static UI. */
922
+ .af-widget-root.af-variant-bare .af-greeting-slot {
923
+ padding: 4px 0 6px;
924
+ }
925
+ .af-widget-root.af-variant-bare .af-msg-row-greeting {
926
+ opacity: 0;
927
+ animation: af-greeting-row-in 420ms cubic-bezier(0.2, 0.8, 0.2, 1) 120ms forwards;
928
+ }
929
+ .af-widget-root.af-variant-bare .af-msg-greeting {
930
+ opacity: 0;
931
+ transform: translateY(6px);
932
+ animation: af-greeting-in 480ms cubic-bezier(0.2, 0.8, 0.2, 1) 180ms forwards;
933
+ }
934
+ @keyframes af-greeting-row-in {
935
+ from { opacity: 0; transform: translateY(8px); }
936
+ to { opacity: 1; transform: translateY(0); }
937
+ }
938
+ @keyframes af-greeting-in {
939
+ from { opacity: 0; transform: translateY(8px) scale(0.98); }
940
+ to { opacity: 1; transform: translateY(0) scale(1); }
941
+ }
942
+ @media (prefers-reduced-motion: reduce) {
943
+ .af-widget-root.af-variant-bare .af-msg-row-greeting,
944
+ .af-widget-root.af-variant-bare .af-msg-greeting {
945
+ animation: none;
946
+ opacity: 1;
947
+ transform: none;
948
+ }
949
+ }
950
+
951
+ /* Slack-style assistant rows: avatar column on the left, bubble on
952
+ * the right. The row IS the alignment container — align-self targets
953
+ * the row, not the bubble, so user/assistant rows still push opposite
954
+ * edges. */
955
+ .af-widget-root.af-variant-bare .af-msg-row {
956
+ display: flex;
957
+ align-items: flex-end;
958
+ gap: 8px;
959
+ max-width: 92%;
960
+ }
961
+ .af-widget-root.af-variant-bare .af-msg-row-assistant {
962
+ align-self: flex-start;
963
+ }
964
+ .af-widget-root.af-variant-bare .af-msg-row .af-msg {
965
+ /* The bubble is now a flex child of the row. Reset the max-width
966
+ * the row already enforces — otherwise we'd stack two caps. */
967
+ max-width: 100%;
968
+ align-self: flex-end;
969
+ }
970
+ /* Column wrapper around the bubble so a Team speaker label can sit
971
+ * above the bubble without disrupting the avatar's bottom-alignment. */
972
+ .af-widget-root.af-variant-bare .af-msg-col {
973
+ display: flex;
974
+ flex-direction: column;
975
+ min-width: 0;
976
+ flex: 1;
977
+ gap: 2px;
978
+ }
979
+ .af-widget-root.af-variant-bare .af-msg-speaker {
980
+ font-size: 11px;
981
+ font-weight: 600;
982
+ color: var(--af-muted, #64748b);
983
+ padding-left: 2px;
984
+ letter-spacing: 0.01em;
985
+ }
986
+ .af-widget-root.af-variant-bare .af-msg-avatar {
987
+ width: 26px;
988
+ height: 26px;
989
+ flex-shrink: 0;
990
+ border-radius: 50%;
991
+ overflow: hidden;
992
+ /* Stick to the bubble's bottom edge so multi-line replies still
993
+ * show the avatar next to the last line — same as iMessage. */
994
+ align-self: flex-end;
995
+ margin-bottom: 2px;
996
+ }
997
+ .af-widget-root.af-variant-bare .af-msg-avatar-spacer {
998
+ background: transparent;
999
+ }
1000
+ .af-widget-root.af-variant-bare .af-msg-avatar-img {
1001
+ object-fit: cover;
1002
+ display: block;
1003
+ }
1004
+ .af-widget-root.af-variant-bare .af-msg-avatar-fallback {
1005
+ display: flex;
1006
+ align-items: center;
1007
+ justify-content: center;
1008
+ color: #fff;
1009
+ font-size: 11.5px;
1010
+ font-weight: 600;
1011
+ letter-spacing: 0.01em;
1012
+ background-image: linear-gradient(135deg, var(--af-primary, #8b5cf6), color-mix(in srgb, var(--af-primary, #8b5cf6) 60%, #6366f1));
1013
+ }
1014
+ @media (min-width: 768px) {
1015
+ .af-widget-root.af-variant-bare .af-msg-avatar { width: 30px; height: 30px; }
1016
+ .af-widget-root.af-variant-bare .af-msg-avatar-fallback { font-size: 13px; }
1017
+ }
617
1018
  .af-widget-root.af-variant-bare .af-input-row {
618
- position: sticky;
619
- bottom: 0;
620
- padding: 10px 12px 14px;
1019
+ /* Plain flex item. af-messages carries flex:1 so it eats the
1020
+ remaining space and naturally pushes the input row to the
1021
+ bottom of the panel. Sticky positioning across host containers
1022
+ was unreliable (different ancestors had different overflow
1023
+ semantics); flex layout is deterministic.
1024
+ width:100% + box-sizing:border-box so the row fills its
1025
+ parent cross-axis instead of shrinking to its children
1026
+ intrinsic width — without these, certain ancestor combos
1027
+ (Radix portals, Sheet content) left the row offset from the
1028
+ edges and pushed the send button out of view. */
1029
+ flex-shrink: 0;
1030
+ width: 100%;
1031
+ box-sizing: border-box;
1032
+ /* Vertical only — horizontal padding lives on the host wrapper so
1033
+ the input pill can stretch edge-to-edge when the host wants
1034
+ (e.g. a sheet drawer). Templates that want side gutters add
1035
+ their own padding above the widget. */
1036
+ padding: 10px 0 14px;
621
1037
  border-top: none;
622
- background: transparent;
1038
+ /* Soft fade so messages scroll behind the input without bleeding
1039
+ through. Host can override via --af-input-row-bg (e.g. solid
1040
+ color) for templates that want a hard band instead. */
1041
+ background: var(
1042
+ --af-input-row-bg,
1043
+ linear-gradient(to top, rgba(255, 255, 255, 0.92) 60%, rgba(255, 255, 255, 0))
1044
+ );
1045
+ backdrop-filter: saturate(140%) blur(8px);
1046
+ -webkit-backdrop-filter: saturate(140%) blur(8px);
623
1047
  gap: 8px;
624
1048
  }
1049
+ @media (prefers-color-scheme: dark) {
1050
+ .af-widget-root.af-variant-bare .af-input-row {
1051
+ background: var(
1052
+ --af-input-row-bg,
1053
+ linear-gradient(to top, rgba(15, 23, 42, 0.85) 60%, rgba(15, 23, 42, 0))
1054
+ );
1055
+ }
1056
+ }
1057
+ /* In the bare variant the shortcut row also lives directly above
1058
+ the composer. Tighten the gap so the chips read as a continuous
1059
+ stack with the input. */
1060
+ .af-widget-root.af-variant-bare .af-shortcut-row {
1061
+ padding: 0 12px 6px;
1062
+ background: transparent;
1063
+ }
1064
+ .af-widget-root.af-variant-bare .af-shortcut-row + .af-input-row {
1065
+ padding-top: 2px;
1066
+ }
625
1067
  .af-widget-root.af-variant-bare .af-input {
626
1068
  background: var(--af-input-bg, rgba(148, 163, 184, 0.10));
627
- color: inherit;
1069
+ /* Foreground is overridable. Defaults to currentColor so the
1070
+ input inherits the page text color (light text in dark mode,
1071
+ dark text in light mode) instead of getting frozen at the
1072
+ bg's "neutral" value, which was causing low-contrast text on
1073
+ dark backgrounds when the host overrode --af-input-bg. */
1074
+ color: var(--af-input-fg, currentColor);
628
1075
  border: 1px solid var(--af-input-border, rgba(148, 163, 184, 0.25));
629
1076
  border-radius: var(--af-input-radius, 24px);
630
1077
  padding: 12px 16px;
@@ -632,13 +1079,21 @@ const WIDGET_CSS = `
632
1079
  min-height: 48px;
633
1080
  line-height: 1.4;
634
1081
  }
1082
+ .af-widget-root.af-variant-bare .af-input::placeholder {
1083
+ color: var(--af-input-placeholder, currentColor);
1084
+ opacity: 0.5;
1085
+ }
635
1086
  .af-widget-root.af-variant-bare .af-input:focus {
636
1087
  border-color: var(--af-primary);
637
1088
  box-shadow: 0 0 0 3px var(--af-primary-soft);
638
1089
  }
639
1090
  .af-widget-root.af-variant-bare .af-input:disabled { background: rgba(148, 163, 184, 0.06); }
640
1091
  .af-widget-root.af-variant-bare .af-send {
641
- background: var(--af-primary);
1092
+ /* Host overrides via --af-send-bg; otherwise the primary color
1093
+ drives the send button. Same fallback pattern used elsewhere
1094
+ so templates can opt into different palettes per surface
1095
+ without redeclaring the primary. */
1096
+ background: var(--af-send-bg, var(--af-primary));
642
1097
  color: #fff;
643
1098
  border-radius: 50%;
644
1099
  width: 44px;
@@ -655,23 +1110,13 @@ const WIDGET_CSS = `
655
1110
  }
656
1111
  .af-widget-root.af-variant-bare .af-typing-dots span { background: currentColor; opacity: 0.5; }
657
1112
 
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. */
1113
+ /* On narrow viewports add safe-area padding so the input clears the
1114
+ * home indicator on iPhones. Base sticky + backdrop styles above
1115
+ * apply at every viewport no override needed. */
662
1116
  @media (max-width: 640px) {
663
1117
  .af-widget-root.af-variant-bare .af-input-row {
664
- position: sticky;
665
1118
  bottom: env(safe-area-inset-bottom, 0);
666
1119
  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
1120
  }
676
1121
  }
677
1122
  `;
package/dist/session.js CHANGED
@@ -190,11 +190,23 @@ class ChatSession {
190
190
  // ─── Internals ──────────────────────────────────────────────────────────
191
191
  async runStream(text, assistant) {
192
192
  this.setStatus('sending');
193
+ // The "active" message is the one we're currently appending
194
+ // text_deltas into. For non-team sessions it's always the initial
195
+ // `assistant` message we seeded before calling runStream. For team
196
+ // sessions, every `delegation_start` opens a NEW message (the
197
+ // member's bubble) and `delegation_result` closes it back to the
198
+ // orchestrator's bubble. Hoisted out of the try so the catch can
199
+ // reach it during cleanup.
200
+ let active = assistant;
201
+ let activeFull = ''; // running text of the active message only
202
+ // Aggregate full transcript across every speaker so the empty-
203
+ // response guard at the end still works when an orchestrator turn
204
+ // delegates and the member's text is the only content.
205
+ let totalFull = '';
193
206
  try {
194
207
  const generator = this.state.conversationId
195
208
  ? this.transport.streamSendMessage(this.state.conversationId, text, this.browserSessionId)
196
209
  : this.transport.streamCreateConversation(text, this.browserSessionId);
197
- let full = '';
198
210
  let sawAnyChunk = false;
199
211
  for await (const evt of generator) {
200
212
  if (evt.kind === 'conversation') {
@@ -211,6 +223,61 @@ class ChatSession {
211
223
  // legacy widget (no metadata renderer) still shows something
212
224
  // readable.
213
225
  const chunkType = evt.chunk.type;
226
+ // Team orchestrator → member handoff. Open a new assistant
227
+ // message tagged with the member's id; subsequent text_deltas
228
+ // (which the server tags with `actingAgentId` matching the
229
+ // delegation) flow into THIS bubble until delegation_result
230
+ // closes the handoff and we revert to the orchestrator.
231
+ if (chunkType === 'delegation_start') {
232
+ const c = evt.chunk;
233
+ // Close the orchestrator's wrapper (if any text accumulated).
234
+ if (activeFull) {
235
+ active.isStreaming = false;
236
+ this.updateMessage(active);
237
+ }
238
+ // Open the member's bubble.
239
+ const memberMsg = {
240
+ id: makeMessageId('a'),
241
+ role: 'assistant',
242
+ content: '',
243
+ createdAt: new Date(),
244
+ isStreaming: true,
245
+ actingAgentId: c.subAgentId,
246
+ };
247
+ this.appendMessage(memberMsg);
248
+ active = memberMsg;
249
+ activeFull = '';
250
+ // UX hint: surface the routing decision so view layers can
251
+ // render "Routing to <member>…" without parsing the stream.
252
+ this.emit({
253
+ type: 'delegation_routed',
254
+ memberId: c.subAgentId,
255
+ memberName: c.subAgentName,
256
+ memberAvatarUrl: c.subAgentAvatarUrl,
257
+ });
258
+ continue;
259
+ }
260
+ if (chunkType === 'delegation_result') {
261
+ // Close the member's bubble and re-open an orchestrator
262
+ // bubble in case the orchestrator wants to add a closing
263
+ // line ("Anything else?"). If no orchestrator text follows
264
+ // we drop the empty wrapper at end-of-stream.
265
+ if (activeFull) {
266
+ active.isStreaming = false;
267
+ this.updateMessage(active);
268
+ }
269
+ const wrapMsg = {
270
+ id: makeMessageId('a'),
271
+ role: 'assistant',
272
+ content: '',
273
+ createdAt: new Date(),
274
+ isStreaming: true,
275
+ };
276
+ this.appendMessage(wrapMsg);
277
+ active = wrapMsg;
278
+ activeFull = '';
279
+ continue;
280
+ }
214
281
  if (chunkType === 'awaiting_approval') {
215
282
  const c = evt.chunk;
216
283
  assistant.metadata = {
@@ -225,7 +292,8 @@ class ChatSession {
225
292
  // Plain-text fallback for legacy clients that don't render
226
293
  // metadata.kind. Capable widgets ignore `content` and read
227
294
  // `metadata.copy` directly.
228
- assistant.content = full || c.copy?.body || (c.connectorName ?? '');
295
+ assistant.content =
296
+ totalFull || c.copy?.body || (c.connectorName ?? '');
229
297
  this.updateMessage(assistant);
230
298
  continue;
231
299
  }
@@ -240,7 +308,10 @@ class ChatSession {
240
308
  copy: c.copy,
241
309
  };
242
310
  assistant.content =
243
- full || c.copy?.blockedBody || c.reason || (c.connectorName ?? '');
311
+ totalFull ||
312
+ c.copy?.blockedBody ||
313
+ c.reason ||
314
+ (c.connectorName ?? '');
244
315
  this.updateMessage(assistant);
245
316
  continue;
246
317
  }
@@ -249,9 +320,10 @@ class ChatSession {
249
320
  continue;
250
321
  if (this.state.status !== 'streaming')
251
322
  this.setStatus('streaming');
252
- full += delta;
253
- assistant.content = full;
254
- this.updateMessage(assistant);
323
+ activeFull += delta;
324
+ totalFull += delta;
325
+ active.content = activeFull;
326
+ this.updateMessage(active);
255
327
  continue;
256
328
  }
257
329
  if (evt.kind === 'error') {
@@ -270,26 +342,47 @@ class ChatSession {
270
342
  // chunk, the bubble carries that structured payload and is a
271
343
  // legitimate terminal state — not a missing reply. Skip the
272
344
  // "empty response" guard for those.
273
- if (!full && !assistant.metadata) {
345
+ if (!totalFull && !assistant.metadata) {
274
346
  throw new Error(sawAnyChunk
275
347
  ? 'The model returned an empty response.'
276
348
  : 'No response received from the server.');
277
349
  }
278
- assistant.isStreaming = false;
279
- this.updateMessage(assistant);
280
- this.setStatus('ready');
281
- return full;
282
- }
283
- catch (err) {
284
- // Drop the half-built assistant message — leaving an empty bubble
285
- // around is worse than no bubble at all.
286
- if (!assistant.content) {
287
- this.removeMessage(assistant.id);
350
+ // If we opened an orchestrator wrapper after the last delegation
351
+ // and the orchestrator didn't add a closing line, drop the empty
352
+ // wrapper instead of leaving a ghost bubble.
353
+ if (active !== assistant && !activeFull) {
354
+ this.removeMessage(active.id);
288
355
  }
289
356
  else {
290
- assistant.isStreaming = false;
291
- this.updateMessage(assistant);
357
+ active.isStreaming = false;
358
+ this.updateMessage(active);
292
359
  }
360
+ // The original `assistant` slot can also be empty if the first
361
+ // chunk we saw was a delegation_start (orchestrator chose to
362
+ // delegate immediately without prefacing). Drop it in that case.
363
+ if (assistant !== active && !assistant.content) {
364
+ this.removeMessage(assistant.id);
365
+ }
366
+ this.setStatus('ready');
367
+ return totalFull;
368
+ }
369
+ catch (err) {
370
+ // Drop the half-built bubble — leaving empty bubbles around is
371
+ // worse than no bubble at all. We may have multiple in flight
372
+ // (orchestrator wrapper + member) when delegating; clean each
373
+ // independently.
374
+ const cleanup = (m) => {
375
+ if (!m.content) {
376
+ this.removeMessage(m.id);
377
+ }
378
+ else {
379
+ m.isStreaming = false;
380
+ this.updateMessage(m);
381
+ }
382
+ };
383
+ cleanup(assistant);
384
+ if (active !== assistant)
385
+ cleanup(active);
293
386
  this.handleError(err);
294
387
  return assistant.content;
295
388
  }
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.1.0",
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",