@agentforge-io/chat-sdk 2.0.25 → 2.1.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.
@@ -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
@@ -141,6 +141,16 @@ export interface ChatWidgetProps {
141
141
  * Provide this hook to customise — e.g. send immediately, log the
142
142
  * click, or open an external link instead. */
143
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[];
144
154
  /**
145
155
  * Imperative handle. The widget fills this ref on mount with a
146
156
  * small command object the host can call to drive the session
package/dist/react.js CHANGED
@@ -227,7 +227,17 @@ function fallbackCopy(ctx) {
227
227
  * SDK event. Consumers don't need to read `session.getState()` themselves.
228
228
  */
229
229
  function ChatWidget(props) {
230
- const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalDecision, readOnlyApprovals = false, variant = 'card', greeting, personaName, shortcuts, onShortcutClick, 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]);
231
241
  const bare = variant === 'bare';
232
242
  const [session, setSession] = (0, react_1.useState)(null);
233
243
  const [status, setStatus] = (0, react_1.useState)('idle');
@@ -238,7 +248,40 @@ function ChatWidget(props) {
238
248
  const [open, setOpen] = (0, react_1.useState)(inline);
239
249
  const [draft, setDraft] = (0, react_1.useState)('');
240
250
  const messagesRef = (0, react_1.useRef)(null);
251
+ const inputRef = (0, react_1.useRef)(null);
241
252
  const styleId = (0, react_1.useId)();
253
+ // Track whether the user has already engaged with the widget. We
254
+ // start at false (just landed on the page) and flip to true the
255
+ // moment `handleSend` fires. The auto-focus effect below uses this
256
+ // to decide whether the impending `status = 'ready'` is the first
257
+ // landing (skip focus on touch — opening the keyboard unprompted
258
+ // is hostile) or a return from a send-streaming cycle (re-focus
259
+ // ALWAYS — the user is mid-conversation, losing the cursor
260
+ // breaks the typing-rhythm).
261
+ const hasInteractedRef = (0, react_1.useRef)(false);
262
+ // Auto-focus the composer once the session is ready.
263
+ //
264
+ // Landing (hasInteractedRef.current === false):
265
+ // • pointer:fine → focus (desktop user expects ready-to-type)
266
+ // • pointer:coarse → skip (iOS/Android opening the keyboard
267
+ // before the visitor reads anything
268
+ // is jarring)
269
+ //
270
+ // Returning from a send (hasInteractedRef.current === true):
271
+ // • always focus, including touch — the user just hit Send,
272
+ // they're in conversation mode, their next thought is the
273
+ // next message, the keyboard should stay alive.
274
+ (0, react_1.useEffect)(() => {
275
+ if (status !== 'ready')
276
+ return;
277
+ if (typeof window === 'undefined')
278
+ return;
279
+ if (!hasInteractedRef.current &&
280
+ !window.matchMedia('(pointer: fine)').matches) {
281
+ return;
282
+ }
283
+ inputRef.current?.focus({ preventScroll: true });
284
+ }, [status]);
242
285
  // ── Session lifecycle. Recreate when token / apiBaseUrl changes. ──────
243
286
  (0, react_1.useEffect)(() => {
244
287
  let cancelled = false;
@@ -282,11 +325,26 @@ function ChatWidget(props) {
282
325
  };
283
326
  }, [token, apiBaseUrl, browserSessionId, resumeConversationId, stream]);
284
327
  // Auto-scroll on new tokens.
328
+ //
329
+ // We defer the scroll into a requestAnimationFrame so the DOM has
330
+ // actually grown by the time we read `scrollHeight`. Without that
331
+ // tick, a stream of small text_deltas can leave the scroll lagging
332
+ // 1–2 chunks behind because the effect runs synchronously after
333
+ // the React commit but BEFORE the browser paints the new rows.
334
+ // Result: the user sees the latest sentence half-cut at the bottom.
335
+ //
336
+ // `behavior: 'smooth'` doesn't help much during a fast stream (each
337
+ // rAF queues a new smooth-scroll that cancels the previous one) but
338
+ // it does make the FINAL settle look fluid — and that's the part
339
+ // the user notices when the stream stops.
285
340
  (0, react_1.useEffect)(() => {
286
341
  const el = messagesRef.current;
287
342
  if (!el)
288
343
  return;
289
- el.scrollTop = el.scrollHeight;
344
+ const raf = requestAnimationFrame(() => {
345
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
346
+ });
347
+ return () => cancelAnimationFrame(raf);
290
348
  }, [messages]);
291
349
  // Inject the widget stylesheet exactly once per page. We key the <style>
292
350
  // tag by a fixed id so multiple widget mounts share it.
@@ -307,6 +365,11 @@ function ChatWidget(props) {
307
365
  const text = draft.trim();
308
366
  if (!text)
309
367
  return;
368
+ // Mark engagement BEFORE the status flip so the auto-focus
369
+ // effect (which watches `status`) sees the flag when it
370
+ // re-runs after `ready` returns. Subsequent renders will
371
+ // refocus the textarea on every send completion.
372
+ hasInteractedRef.current = true;
310
373
  setDraft('');
311
374
  void session.send(text);
312
375
  }, [session, draft]);
@@ -349,45 +412,60 @@ function ChatWidget(props) {
349
412
  };
350
413
  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 ? (
351
414
  // eslint-disable-next-line @next/next/no-img-element
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: () => {
415
+ (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) => {
416
+ // Show the avatar only on the FIRST bubble in a run of
417
+ // assistant messages BY THE SAME speaker. A delegation
418
+ // boundary (orchestrator member, or member → next
419
+ // member) counts as a new run so each speaker gets their
420
+ // avatar surfaced. User/system bubbles never get an avatar.
421
+ const prev = messages[i - 1];
422
+ const isAssistant = m.role === 'assistant';
423
+ const sameAssistantRun = prev?.role === 'assistant' &&
424
+ (prev?.actingAgentId ?? null) === (m.actingAgentId ?? null);
425
+ const showAvatar = bare && isAssistant && !sameAssistantRun;
426
+ // Per-bubble identity resolution. When the chunk carried
427
+ // an `actingAgentId` (orchestrator delegated to a member),
428
+ // we render the member's avatar/name instead of the
429
+ // primary agent's. Falling back to the primary identity
430
+ // keeps non-team sessions and orchestrator-self turns
431
+ // looking the same as before.
432
+ const member = m.actingAgentId
433
+ ? membersById.get(m.actingAgentId)
434
+ : undefined;
435
+ const bubbleAvatarTheme = member
436
+ ? { ...theme, avatarUrl: member.avatarUrl }
437
+ : theme;
438
+ const bubbleAvatarName = member?.name ?? personaName ?? agent?.name;
439
+ 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: () => {
440
+ // After a successful Approve, kick the next turn so the
441
+ // gate's fast-path consumes the approval and the tool
442
+ // actually runs. `silent: true` keeps the literal
443
+ // "continue" prompt out of the visible transcript —
444
+ // the visitor just sees the assistant's next reply
445
+ // appearing under the "Approved" pill.
446
+ if (!session)
447
+ return;
448
+ setTimeout(() => {
449
+ void session.send('continue', { silent: true });
450
+ }, 250);
451
+ } }, m.id));
452
+ }) }), 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: () => {
375
453
  if (onShortcutClick)
376
454
  onShortcutClick(text, i);
377
455
  else
378
456
  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" })] })] }));
457
+ }, 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" })] })] }));
380
458
  }
381
- function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, }) {
459
+ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, speakerLabel, }) {
382
460
  const kind = message.metadata?.kind;
383
461
  if (kind === 'awaiting_approval') {
384
462
  // Approval / blocked bubbles also count as "assistant-side" so we
385
463
  // wrap them in the same row geometry — keeps the conversation
386
464
  // 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 }));
465
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }), speakerLabel);
388
466
  }
389
467
  if (kind === 'tool_blocked') {
390
- return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }));
468
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }), speakerLabel);
391
469
  }
392
470
  const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
393
471
  ? message.content
@@ -396,13 +474,13 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bar
396
474
  : ''}`;
397
475
  // Typing state (no content yet): render the three-dot indicator, no markdown.
398
476
  if (message.role === 'assistant' && message.isStreaming && !message.content) {
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", {})] }) }));
477
+ 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);
400
478
  }
401
479
  if (message.role === 'assistant') {
402
480
  return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)("div", { className: cls,
403
481
  // Output is sanitized by escapeHtml + a fixed tag whitelist in
404
482
  // renderMarkdown — safe to inject as HTML.
405
- dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }));
483
+ dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }), speakerLabel);
406
484
  }
407
485
  // User & system messages stay as plain text — they're typed verbatim.
408
486
  // No avatar column on the user side; they align right.
@@ -413,10 +491,14 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bar
413
491
  * avatar column. Card variant skips the wrapper entirely so its
414
492
  * historical layout is unchanged.
415
493
  */
416
- function wrapAssistantRow(bare, showAvatar, theme, name, child) {
494
+ function wrapAssistantRow(bare, showAvatar, theme, name, child,
495
+ /** When set + this is the first bubble of a speaker run (showAvatar
496
+ * is true), render the speaker's display name just above the
497
+ * bubble. Used by Team chats so members are visually attributed. */
498
+ speakerLabel) {
417
499
  if (!bare)
418
500
  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] }));
501
+ 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] })] }));
420
502
  }
421
503
  /**
422
504
  * Small circular avatar for the assistant-side column. Prefers the
@@ -722,13 +804,14 @@ const WIDGET_CSS = `
722
804
  width: 100%;
723
805
  height: 100%;
724
806
  min-height: 0;
807
+ flex: 1 1 0%;
725
808
  }
726
809
  .af-widget-root.af-variant-bare.af-inline .af-panel,
727
810
  .af-widget-root.af-variant-bare .af-panel {
728
811
  position: relative;
729
812
  width: 100%;
730
- height: 100%;
731
- max-height: none;
813
+ height: 100% !important;
814
+ max-height: none !important;
732
815
  background: transparent;
733
816
  border-radius: 0;
734
817
  box-shadow: none;
@@ -752,6 +835,15 @@ const WIDGET_CSS = `
752
835
  /* Let the parent flexbox give us a min-height; we'll grow into it
753
836
  and scroll internally when the transcript gets longer. */
754
837
  min-height: 0;
838
+ /* Anchor messages to the bottom of the scroll-area when the
839
+ transcript is shorter than the available height — short
840
+ conversations sit just above the composer instead of floating
841
+ up to the top of a tall window. The 'margin-top: auto' on the
842
+ first child does the same job in pure flex without needing
843
+ justify-content. */
844
+ }
845
+ .af-widget-root.af-variant-bare .af-messages > :first-child {
846
+ margin-top: auto;
755
847
  }
756
848
  .af-widget-root.af-variant-bare .af-msg {
757
849
  font-size: 15px;
@@ -859,7 +951,41 @@ const WIDGET_CSS = `
859
951
  display: none;
860
952
  }
861
953
  }
862
- .af-widget-root.af-variant-bare .af-msg-greeting { animation: af-msg-in 320ms cubic-bezier(0.2, 0.8, 0.2, 1); }
954
+ /* Greeting slot lives between the message scroller and the
955
+ * shortcut chips. The bubble itself animates in with a small upward
956
+ * slide + fade so the visitor's eye is pulled there instead of the
957
+ * static chip row underneath. Avatar slides in alongside it. The
958
+ * 120ms delay gives the chip row a beat to render first so the
959
+ * greeting reads as the assistant *responding* to the page loading,
960
+ * not part of the static UI. */
961
+ .af-widget-root.af-variant-bare .af-greeting-slot {
962
+ padding: 4px 0 6px;
963
+ }
964
+ .af-widget-root.af-variant-bare .af-msg-row-greeting {
965
+ opacity: 0;
966
+ animation: af-greeting-row-in 420ms cubic-bezier(0.2, 0.8, 0.2, 1) 120ms forwards;
967
+ }
968
+ .af-widget-root.af-variant-bare .af-msg-greeting {
969
+ opacity: 0;
970
+ transform: translateY(6px);
971
+ animation: af-greeting-in 480ms cubic-bezier(0.2, 0.8, 0.2, 1) 180ms forwards;
972
+ }
973
+ @keyframes af-greeting-row-in {
974
+ from { opacity: 0; transform: translateY(8px); }
975
+ to { opacity: 1; transform: translateY(0); }
976
+ }
977
+ @keyframes af-greeting-in {
978
+ from { opacity: 0; transform: translateY(8px) scale(0.98); }
979
+ to { opacity: 1; transform: translateY(0) scale(1); }
980
+ }
981
+ @media (prefers-reduced-motion: reduce) {
982
+ .af-widget-root.af-variant-bare .af-msg-row-greeting,
983
+ .af-widget-root.af-variant-bare .af-msg-greeting {
984
+ animation: none;
985
+ opacity: 1;
986
+ transform: none;
987
+ }
988
+ }
863
989
 
864
990
  /* Slack-style assistant rows: avatar column on the left, bubble on
865
991
  * the right. The row IS the alignment container — align-self targets
@@ -880,6 +1006,22 @@ const WIDGET_CSS = `
880
1006
  max-width: 100%;
881
1007
  align-self: flex-end;
882
1008
  }
1009
+ /* Column wrapper around the bubble so a Team speaker label can sit
1010
+ * above the bubble without disrupting the avatar's bottom-alignment. */
1011
+ .af-widget-root.af-variant-bare .af-msg-col {
1012
+ display: flex;
1013
+ flex-direction: column;
1014
+ min-width: 0;
1015
+ flex: 1;
1016
+ gap: 2px;
1017
+ }
1018
+ .af-widget-root.af-variant-bare .af-msg-speaker {
1019
+ font-size: 11px;
1020
+ font-weight: 600;
1021
+ color: var(--af-muted, #64748b);
1022
+ padding-left: 2px;
1023
+ letter-spacing: 0.01em;
1024
+ }
883
1025
  .af-widget-root.af-variant-bare .af-msg-avatar {
884
1026
  width: 26px;
885
1027
  height: 26px;
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.25",
3
+ "version": "2.1.1",
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",