@agentforge-io/chat-sdk 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -205,24 +205,44 @@ function ChatDrawer(props) {
205
205
  // **bottom-sheet (snap<1)**: anchor the surface to the bottom of the
206
206
  // visual viewport. `bottom = visualViewport.offsetTop` is the legacy
207
207
  // path that handles the rubber-band scroll case on iOS Safari.
208
+ // Motion choreography (NLP-aware):
209
+ // - Origin: the drawer "grows" from the bottom of the page rather
210
+ // than appearing in place. Subconsciously this maps to "the chat
211
+ // emerges because YOU asked for it" — agency stays with the user.
212
+ // - Curve: cubic-bezier(.22, 1, .36, 1) is the ease-out-expo curve.
213
+ // It starts fast, then settles with the same deceleration profile
214
+ // a hand has when reaching out and stopping. Bodies recognize it
215
+ // as "natural" without consciously parsing why.
216
+ // - Duration: 360ms. Below 300ms feels mechanical/jumpy. Above 450ms
217
+ // feels sluggish on mobile. 360ms is the breath-pause sweet spot
218
+ // for "the world updated and I noticed it".
219
+ // - Coupled fade+scale: the surface opens at 98% scale and fades in
220
+ // while sliding. The 2% scale delta is barely perceivable
221
+ // consciously but the brain reads it as "expanding into presence"
222
+ // instead of "popping in".
223
+ const transitionExpr = isDragging
224
+ ? 'none'
225
+ : 'transform 360ms cubic-bezier(.22, 1, .36, 1), opacity 240ms cubic-bezier(.22, 1, .36, 1)';
226
+ const openTransform = `translate3d(0, ${dragOffset}px, 0) scale(${open ? 1 : 0.98})`;
227
+ const closedTransformFullscreen = `translate3d(0, 24px, 0) scale(0.98)`;
228
+ const closedTransformSheet = `translate3d(0, ${drawerHeight}px, 0) scale(1)`;
208
229
  const surfaceMotionStyle = fullscreen
209
230
  ? {
210
231
  top: `${vv.offsetTop}px`,
211
232
  height: `${vv.h}px`,
212
- transform: `translate3d(0, ${translateY}, 0)`,
213
- transition: isDragging
214
- ? 'none'
215
- : 'transform 240ms cubic-bezier(.32,.72,0,1)',
216
- willChange: 'transform',
233
+ transform: open ? openTransform : closedTransformFullscreen,
234
+ opacity: open ? 1 : 0,
235
+ transition: transitionExpr,
236
+ transformOrigin: 'bottom center',
237
+ willChange: 'transform, opacity',
217
238
  }
218
239
  : {
219
240
  height: `${drawerHeight}px`,
220
- transform: `translate3d(0, ${translateY}, 0)`,
241
+ transform: open ? openTransform : closedTransformSheet,
221
242
  bottom: `${vv.offsetTop}px`,
222
- transition: isDragging
223
- ? 'none'
224
- : 'transform 240ms cubic-bezier(.32,.72,0,1)',
225
- willChange: 'transform',
243
+ transition: transitionExpr,
244
+ transformOrigin: 'bottom center',
245
+ willChange: 'transform, opacity',
226
246
  };
227
247
  return (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsxs)("div", { className: "af-drawer-root", "data-state": open ? 'open' : 'closed', style: {
228
248
  position: 'fixed',
package/dist/react.js CHANGED
@@ -517,51 +517,143 @@ function ChatWidget(props) {
517
517
  };
518
518
  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 ? (
519
519
  // eslint-disable-next-line @next/next/no-img-element
520
- (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) => {
521
- // Show the avatar only on the FIRST bubble in a run of
522
- // assistant messages BY THE SAME speaker. A delegation
523
- // boundary (orchestrator member, or member → next
524
- // member) counts as a new run so each speaker gets their
525
- // avatar surfaced. User/system bubbles never get an avatar.
526
- const prev = messages[i - 1];
527
- const isAssistant = m.role === 'assistant';
528
- const sameAssistantRun = prev?.role === 'assistant' &&
529
- (prev?.actingAgentId ?? null) === (m.actingAgentId ?? null);
530
- const showAvatar = bare && isAssistant && !sameAssistantRun;
531
- // Per-bubble identity resolution. When the chunk carried
532
- // an `actingAgentId` (orchestrator delegated to a member),
533
- // we render the member's avatar/name instead of the
534
- // primary agent's. Falling back to the primary identity
535
- // keeps non-team sessions and orchestrator-self turns
536
- // looking the same as before.
537
- const member = m.actingAgentId
538
- ? membersById.get(m.actingAgentId)
539
- : undefined;
540
- const bubbleAvatarTheme = member
541
- ? { ...theme, avatarUrl: member.avatarUrl }
542
- : theme;
543
- const bubbleAvatarName = member?.name ?? personaName ?? agent?.name;
544
- // Seed for the deterministic-hue fallback avatar. Prefer
545
- // the acting agent id (so each member in a team gets its
546
- // own stable color) and fall back to the primary agent's
547
- // slug for solo chats / orchestrator-self turns. The
548
- // `agent` summary doesn't carry an id — slug is stable
549
- // and unique, which is all hueFromSeed needs.
550
- const bubbleAgentId = m.actingAgentId ?? agent?.slug;
551
- return ((0, jsx_runtime_1.jsx)(MessageBubble, { message: m, session: session, readOnly: readOnlyApprovals, onDecision: onApprovalDecision, bare: bare, showAvatar: showAvatar, avatarTheme: bubbleAvatarTheme, avatarName: bubbleAvatarName, avatarAgentId: bubbleAgentId, speakerLabel: member?.name, onContinue: () => {
552
- // After a successful Approve, kick the next turn so the
553
- // gate's fast-path consumes the approval and the tool
554
- // actually runs. `silent: true` keeps the literal
555
- // "continue" prompt out of the visible transcript —
556
- // the visitor just sees the assistant's next reply
557
- // appearing under the "Approved" pill.
558
- if (!session)
559
- return;
560
- setTimeout(() => {
561
- void session.send('continue', { silent: true });
562
- }, 250);
563
- } }, m.id));
564
- }) }), 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, agentId: agent?.slug, 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: () => {
520
+ (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: (() => {
521
+ // Pre-walk the transcript to identify "delegation chains":
522
+ // a sequence of consecutive assistant bubbles where each has
523
+ // a different `actingAgentId` AND every intermediate bubble
524
+ // is empty (the orchestrator/member delegated immediately
525
+ // and never produced text of its own). The visible bubble
526
+ // is the LAST one — the speaker that actually answered.
527
+ //
528
+ // For each chain we render a single MessageBubble for the
529
+ // last entry, with the avatar slot replaced by a HORIZONTAL
530
+ // STACK of every participant in delegation order: the
531
+ // active speaker first (largest), the upstream delegators
532
+ // smaller and overlapped to the right.
533
+ //
534
+ // chainHeadFor[i] holds the head index of the chain that
535
+ // contains message i. visibleIdxs is the list of indices
536
+ // we actually render.
537
+ const chainHeadFor = new Array(messages.length);
538
+ const visibleIdxs = [];
539
+ for (let i = 0; i < messages.length; i++) {
540
+ const m = messages[i];
541
+ if (m.role !== 'assistant') {
542
+ chainHeadFor[i] = i;
543
+ visibleIdxs.push(i);
544
+ continue;
545
+ }
546
+ const prev = messages[i - 1];
547
+ const prevSameSpeaker = prev?.role === 'assistant' &&
548
+ (prev?.actingAgentId ?? null) === (m.actingAgentId ?? null);
549
+ // Walk back through the chain: pick the head of the
550
+ // previous bubble's chain if that bubble was an empty
551
+ // assistant with a DIFFERENT actingAgentId (delegation
552
+ // boundary). Same speaker -> head stays the same.
553
+ if (prev?.role === 'assistant' &&
554
+ (prev.actingAgentId ?? null) !== (m.actingAgentId ?? null) &&
555
+ !prev.content) {
556
+ chainHeadFor[i] = chainHeadFor[i - 1];
557
+ }
558
+ else if (prevSameSpeaker) {
559
+ chainHeadFor[i] = chainHeadFor[i - 1];
560
+ }
561
+ else {
562
+ chainHeadFor[i] = i;
563
+ }
564
+ visibleIdxs.push(i);
565
+ }
566
+ // Drop the empty intermediaries: every assistant bubble
567
+ // that has no content AND whose chain ends at a later
568
+ // bubble is hidden. The chain head will collect their
569
+ // identities and surface them as an avatar group.
570
+ const lastInChain = new Map(); // head -> last idx
571
+ for (const i of visibleIdxs) {
572
+ if (messages[i].role === 'assistant') {
573
+ lastInChain.set(chainHeadFor[i], i);
574
+ }
575
+ }
576
+ const finalIdxs = visibleIdxs.filter((i) => {
577
+ const m = messages[i];
578
+ if (m.role !== 'assistant')
579
+ return true;
580
+ const head = chainHeadFor[i];
581
+ const last = lastInChain.get(head);
582
+ // Hide intermediates: assistant bubbles that are part of
583
+ // a chain whose LAST element is a later bubble AND that
584
+ // are themselves empty. The last bubble in the chain
585
+ // always renders; non-empty intermediates also render so
586
+ // we never lose visible text.
587
+ if (last !== undefined && i !== last && !m.content)
588
+ return false;
589
+ return true;
590
+ });
591
+ return finalIdxs.map((i) => {
592
+ const m = messages[i];
593
+ const prev = messages[i - 1];
594
+ const isAssistant = m.role === 'assistant';
595
+ const sameAssistantRun = prev?.role === 'assistant' &&
596
+ (prev?.actingAgentId ?? null) === (m.actingAgentId ?? null);
597
+ const showAvatar = bare && isAssistant && !sameAssistantRun;
598
+ // Per-bubble identity resolution.
599
+ const member = m.actingAgentId
600
+ ? membersById.get(m.actingAgentId)
601
+ : undefined;
602
+ const bubbleAvatarTheme = member
603
+ ? { ...theme, avatarUrl: member.avatarUrl }
604
+ : theme;
605
+ const bubbleAvatarName = member?.name ?? personaName ?? agent?.name;
606
+ const bubbleAgentId = m.actingAgentId ?? agent?.slug;
607
+ // Build the delegation-chain participants list when this
608
+ // bubble is the LAST in a chain (= the speaker that
609
+ // answered). Order: active speaker first, then the prior
610
+ // delegators in REVERSE chronological order so the most
611
+ // recent delegator sits closest to the active avatar.
612
+ let chainParticipants;
613
+ if (isAssistant && showAvatar) {
614
+ const head = chainHeadFor[i];
615
+ const last = lastInChain.get(head);
616
+ if (last === i && head !== i) {
617
+ // Collect all distinct speakers in [head…i] in
618
+ // chronological order, then surface them with the
619
+ // active one (the last) at the front.
620
+ const seen = new Set();
621
+ const ordered = [];
622
+ for (let j = head; j <= i; j++) {
623
+ const mj = messages[j];
624
+ if (mj.role !== 'assistant')
625
+ continue;
626
+ const key = mj.actingAgentId ?? null;
627
+ if (seen.has(key))
628
+ continue;
629
+ seen.add(key);
630
+ const mem = mj.actingAgentId
631
+ ? membersById.get(mj.actingAgentId)
632
+ : undefined;
633
+ ordered.push({
634
+ agentId: mj.actingAgentId ?? agent?.slug,
635
+ name: mem?.name ?? personaName ?? agent?.name,
636
+ theme: mem
637
+ ? { ...theme, avatarUrl: mem.avatarUrl }
638
+ : theme,
639
+ });
640
+ }
641
+ if (ordered.length > 1) {
642
+ // Move the LAST (active speaker) to the front.
643
+ const active = ordered.pop();
644
+ chainParticipants = [active, ...ordered.reverse()];
645
+ }
646
+ }
647
+ }
648
+ return ((0, jsx_runtime_1.jsx)(MessageBubble, { message: m, session: session, readOnly: readOnlyApprovals, onDecision: onApprovalDecision, bare: bare, showAvatar: showAvatar, avatarTheme: bubbleAvatarTheme, avatarName: bubbleAvatarName, avatarAgentId: bubbleAgentId, speakerLabel: member?.name, chainParticipants: chainParticipants, onContinue: () => {
649
+ if (!session)
650
+ return;
651
+ setTimeout(() => {
652
+ void session.send('continue', { silent: true });
653
+ }, 250);
654
+ } }, m.id));
655
+ });
656
+ })() }), 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, agentId: agent?.slug, 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: () => {
565
657
  if (onShortcutClick)
566
658
  onShortcutClick(text, i);
567
659
  else
@@ -592,16 +684,16 @@ function ChatWidget(props) {
592
684
  status === 'idle' ||
593
685
  status === 'loading' ? ((0, jsx_runtime_1.jsx)(SpinnerIcon, {})) : ((0, jsx_runtime_1.jsx)(SendIcon, {})) })] }), !bare && (0, jsx_runtime_1.jsx)("div", { className: "af-footer", children: "Powered by AgentForge" })] })] }));
594
686
  }
595
- function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, avatarAgentId, speakerLabel, }) {
687
+ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, avatarAgentId, speakerLabel, chainParticipants, }) {
596
688
  const kind = message.metadata?.kind;
597
689
  if (kind === 'awaiting_approval') {
598
690
  // Approval / blocked bubbles also count as "assistant-side" so we
599
691
  // wrap them in the same row geometry — keeps the conversation
600
692
  // aligned even when a tool dispatch interrupts the regular flow.
601
- return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }), speakerLabel);
693
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }), speakerLabel, chainParticipants);
602
694
  }
603
695
  if (kind === 'tool_blocked') {
604
- return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }), speakerLabel);
696
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }), speakerLabel, chainParticipants);
605
697
  }
606
698
  const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
607
699
  ? message.content
@@ -610,13 +702,13 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bar
610
702
  : ''}`;
611
703
  // Typing state (no content yet): render the three-dot indicator, no markdown.
612
704
  if (message.role === 'assistant' && message.isStreaming && !message.content) {
613
- return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (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);
705
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (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, chainParticipants);
614
706
  }
615
707
  if (message.role === 'assistant') {
616
708
  return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (0, jsx_runtime_1.jsx)("div", { className: cls,
617
709
  // Output is sanitized by escapeHtml + a fixed tag whitelist in
618
710
  // renderMarkdown — safe to inject as HTML.
619
- dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }), speakerLabel);
711
+ dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }), speakerLabel, chainParticipants);
620
712
  }
621
713
  // User & system messages stay as plain text — they're typed verbatim.
622
714
  // No avatar column on the user side; they align right.
@@ -631,10 +723,15 @@ function wrapAssistantRow(bare, showAvatar, theme, name, agentId, child,
631
723
  /** When set + this is the first bubble of a speaker run (showAvatar
632
724
  * is true), render the speaker's display name just above the
633
725
  * bubble. Used by Team chats so members are visually attributed. */
634
- speakerLabel) {
726
+ speakerLabel,
727
+ /** When set, replace the single avatar with a horizontal stack of
728
+ * every speaker in the delegation chain that produced this bubble.
729
+ * Index 0 is the active speaker (largest), the rest are upstream
730
+ * delegators in reverse-chronological order. */
731
+ chainParticipants) {
635
732
  if (!bare)
636
733
  return child;
637
- 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, agentId: agentId, 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] })] }));
734
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "af-msg-row af-msg-row-assistant", children: [chainParticipants && chainParticipants.length > 1 ? ((0, jsx_runtime_1.jsx)(DelegationAvatarStack, { participants: chainParticipants })) : ((0, jsx_runtime_1.jsx)(AssistantAvatar, { theme: theme, name: name, agentId: agentId, 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] })] }));
638
735
  }
639
736
  /**
640
737
  * Small circular avatar for the assistant-side column. Prefers the
@@ -660,6 +757,36 @@ function AssistantAvatar({ theme, name, agentId, show, }) {
660
757
  const seed = agentId || name || 'agent';
661
758
  return ((0, jsx_runtime_1.jsx)("div", { className: "af-msg-avatar af-msg-avatar-fallback", "aria-hidden": true, style: { backgroundColor: hueFromSeed(seed) }, children: (0, jsx_runtime_1.jsx)("span", { children: initial }) }));
662
759
  }
760
+ /**
761
+ * Horizontal stack of avatars representing the delegation chain that
762
+ * produced the current bubble. Index 0 is the active speaker — drawn
763
+ * at the regular avatar size on the LEFT (the speaker that actually
764
+ * answered, attention sits on them). The remaining participants are
765
+ * upstream delegators in reverse-chronological order: each one
766
+ * slightly smaller and overlapped to the right of the active avatar.
767
+ *
768
+ * Visually: [active] [delegator-1] [delegator-2] [...]
769
+ * Mental model: "this answer came from <active>, delegated by <…>".
770
+ */
771
+ function DelegationAvatarStack({ participants, }) {
772
+ return ((0, jsx_runtime_1.jsx)("div", { className: "af-msg-avatar af-msg-avatar-stack", role: "img", "aria-label": `Delegation chain: ${participants.map((p) => p.name).filter(Boolean).join(' → ')}`, children: participants.map((p, idx) => {
773
+ const isActive = idx === 0;
774
+ const initial = (p.name ?? 'A').trim().charAt(0).toUpperCase() || 'A';
775
+ const seed = p.agentId || p.name || `agent-${idx}`;
776
+ const url = p.theme?.avatarUrl;
777
+ const className = 'af-msg-avatar-stack-item' +
778
+ (isActive ? ' af-msg-avatar-stack-active' : '');
779
+ // Inline z-index so the leftmost (active) lands on top of the
780
+ // overlapping siblings to its right.
781
+ const z = participants.length - idx;
782
+ return url ? (
783
+ // eslint-disable-next-line @next/next/no-img-element
784
+ (0, jsx_runtime_1.jsx)("img", { className: className + ' af-msg-avatar-stack-img', src: url, alt: "", "aria-hidden": true, style: { zIndex: z } }, `${idx}-${seed}`)) : ((0, jsx_runtime_1.jsx)("div", { className: className + ' af-msg-avatar-stack-fallback', "aria-hidden": true, style: {
785
+ backgroundColor: hueFromSeed(seed),
786
+ zIndex: z,
787
+ }, children: (0, jsx_runtime_1.jsx)("span", { children: initial }) }, `${idx}-${seed}`));
788
+ }) }));
789
+ }
663
790
  /**
664
791
  * Deterministic per-agent hue. Hash the seed into the HSL hue space
665
792
  * so an agent's avatar color stays stable across renders and looks
@@ -1229,6 +1356,60 @@ const WIDGET_CSS = `
1229
1356
  * inline color and made every agent avatar look the same. */
1230
1357
  background-color: var(--af-primary, #8b5cf6);
1231
1358
  }
1359
+ /* Delegation-chain avatar stack. Override the default 26x26 size so
1360
+ * the row can host multiple overlapping items without clipping. */
1361
+ .af-widget-root.af-variant-bare .af-msg-avatar-stack {
1362
+ width: auto;
1363
+ height: 26px;
1364
+ border-radius: 0;
1365
+ overflow: visible;
1366
+ display: flex;
1367
+ flex-direction: row;
1368
+ align-items: flex-end;
1369
+ /* Slight padding-right so the rightmost stacked item doesn't sit
1370
+ * flush against the bubble bevel. */
1371
+ padding-right: 4px;
1372
+ }
1373
+ .af-widget-root.af-variant-bare .af-msg-avatar-stack-item {
1374
+ width: 18px;
1375
+ height: 18px;
1376
+ border-radius: 50%;
1377
+ overflow: hidden;
1378
+ display: flex;
1379
+ align-items: center;
1380
+ justify-content: center;
1381
+ flex-shrink: 0;
1382
+ /* 2px ring matching the bubble background so overlapping reads
1383
+ * as distinct circles, not a continuous blob. */
1384
+ box-shadow: 0 0 0 2px var(--af-bg, #ffffff);
1385
+ /* Overlap with the prior sibling. */
1386
+ margin-left: -6px;
1387
+ }
1388
+ .af-widget-root.af-variant-bare .af-msg-avatar-stack-item:first-child {
1389
+ margin-left: 0;
1390
+ }
1391
+ .af-widget-root.af-variant-bare .af-msg-avatar-stack-active {
1392
+ /* The active speaker is rendered full-size so it visually anchors
1393
+ * the chain. Slightly larger ring keeps it clearly separated from
1394
+ * the smaller upstream delegators. */
1395
+ width: 26px;
1396
+ height: 26px;
1397
+ }
1398
+ .af-widget-root.af-variant-bare .af-msg-avatar-stack-img {
1399
+ object-fit: cover;
1400
+ display: block;
1401
+ width: 100%;
1402
+ height: 100%;
1403
+ }
1404
+ .af-widget-root.af-variant-bare .af-msg-avatar-stack-fallback {
1405
+ color: #fff;
1406
+ font-size: 9.5px;
1407
+ font-weight: 600;
1408
+ background-color: var(--af-primary, #8b5cf6);
1409
+ }
1410
+ .af-widget-root.af-variant-bare .af-msg-avatar-stack-active.af-msg-avatar-stack-fallback {
1411
+ font-size: 11.5px;
1412
+ }
1232
1413
  @media (min-width: 768px) {
1233
1414
  .af-widget-root.af-variant-bare .af-msg-avatar { width: 30px; height: 30px; }
1234
1415
  .af-widget-root.af-variant-bare .af-msg-avatar-fallback { font-size: 13px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.4.0",
3
+ "version": "2.5.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",