@agentforge-io/chat-sdk 2.1.1 → 2.2.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.
@@ -160,6 +160,17 @@ export type ChatEvent = {
160
160
  type: 'error';
161
161
  message: string;
162
162
  code?: string;
163
+ }
164
+ /**
165
+ * Emitted exactly once per session, the moment the server hands us
166
+ * back the new conversation id after the visitor sent the first
167
+ * message. View layers use this to write the id into URL query
168
+ * (`?c=<id>`), localStorage, etc. so a refresh / drawer-reopen
169
+ * resumes the same conversation instead of starting fresh.
170
+ */
171
+ | {
172
+ type: 'conversation_started';
173
+ conversationId: string;
163
174
  } | {
164
175
  type: 'destroyed';
165
176
  };
package/dist/react.d.ts CHANGED
@@ -77,6 +77,14 @@ export interface ChatWidgetProps {
77
77
  browserSessionId?: string;
78
78
  /** Existing conversation id to resume. */
79
79
  resumeConversationId?: string;
80
+ /**
81
+ * Fired exactly once per session, when the server hands back the
82
+ * new conversation id after the visitor sends their first message.
83
+ * Hosts use this to write the id to URL query, localStorage, etc.
84
+ * so a refresh or drawer-reopen resumes the same conversation.
85
+ * Not called when resuming an existing conversation (the host
86
+ * already has the id in that case). */
87
+ onConversationStart?: (conversationId: string) => void;
80
88
  /** Disable SSE streaming and use plain POSTs. */
81
89
  stream?: boolean;
82
90
  /** Extra class on the root container. */
@@ -151,6 +159,17 @@ export interface ChatWidgetProps {
151
159
  * behaviour).
152
160
  */
153
161
  members?: ChatTeamMember[];
162
+ /**
163
+ * Slot rendered inside the composer row, BEFORE the textarea. Hosts
164
+ * use this for affordances that scope or augment the next turn —
165
+ * e.g. a Team chat's member-picker chip (the "@<agent>" affordance),
166
+ * a tools menu à la Gemini, an attachment button.
167
+ *
168
+ * Kept as a generic React slot rather than a typed prop list so the
169
+ * SDK doesn't have to learn every product surface that wants to put
170
+ * something there.
171
+ */
172
+ composerLeftSlot?: React.ReactNode;
154
173
  /**
155
174
  * Imperative handle. The widget fills this ref on mount with a
156
175
  * small command object the host can call to drive the session
@@ -170,6 +189,15 @@ export interface ChatWidgetHandle {
170
189
  * entirely. No-op if the session isn't ready yet (status ===
171
190
  * 'idle' / 'loading'). */
172
191
  sendNow(text: string): void;
192
+ /**
193
+ * Insert `text` into the composer's draft at the current cursor
194
+ * position (replacing any selection). Used by mention pickers,
195
+ * suggested-fragment chips, or slash-menu commands — the user
196
+ * sees the text appear in the input as if they had typed it and
197
+ * can keep editing before sending. Focus stays on the textarea
198
+ * and the cursor lands at the end of the inserted text.
199
+ */
200
+ insertText(text: string): void;
173
201
  }
174
202
  /**
175
203
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
package/dist/react.js CHANGED
@@ -227,7 +227,7 @@ 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, members, } = props;
230
+ const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, onConversationStart, stream, className, style, onApprovalDecision, readOnlyApprovals = false, variant = 'card', greeting, personaName, shortcuts, onShortcutClick, inputPlaceholder, members, composerLeftSlot, handleRef, } = props;
231
231
  // Build a lookup so MessageBubble can resolve actingAgentId → identity
232
232
  // in O(1) per render without re-walking the members array. Stable
233
233
  // identity per `members` prop change.
@@ -259,6 +259,14 @@ function ChatWidget(props) {
259
259
  // ALWAYS — the user is mid-conversation, losing the cursor
260
260
  // breaks the typing-rhythm).
261
261
  const hasInteractedRef = (0, react_1.useRef)(false);
262
+ // Hold the latest `onConversationStart` in a ref so the session
263
+ // effect doesn't recreate the ChatSession every time the parent
264
+ // passes a new function identity (typical with inline arrow props).
265
+ // Recreating the session would wipe the transcript on every render.
266
+ const onConversationStartRef = (0, react_1.useRef)(onConversationStart);
267
+ (0, react_1.useEffect)(() => {
268
+ onConversationStartRef.current = onConversationStart;
269
+ }, [onConversationStart]);
262
270
  // Auto-focus the composer once the session is ready.
263
271
  //
264
272
  // Landing (hasInteractedRef.current === false):
@@ -316,6 +324,10 @@ function ChatWidget(props) {
316
324
  setLastError(evt.message);
317
325
  return;
318
326
  }
327
+ if (evt.type === 'conversation_started') {
328
+ onConversationStartRef.current?.(evt.conversationId);
329
+ return;
330
+ }
319
331
  });
320
332
  void s.start();
321
333
  return () => {
@@ -379,6 +391,63 @@ function ChatWidget(props) {
379
391
  handleSend();
380
392
  }
381
393
  }, [handleSend]);
394
+ // Insert `text` at the current cursor position of the composer
395
+ // textarea. Replaces any active selection, advances the cursor to
396
+ // the end of the inserted slice, and keeps focus on the input so
397
+ // the user can keep typing without a click. Falls back to "append
398
+ // at the end" when the textarea isn't mounted yet (e.g. the host
399
+ // calls insertText before the session has booted).
400
+ const insertText = (0, react_1.useCallback)((text) => {
401
+ if (!text)
402
+ return;
403
+ const el = inputRef.current;
404
+ if (!el) {
405
+ setDraft((prev) => prev + text);
406
+ return;
407
+ }
408
+ const start = el.selectionStart ?? el.value.length;
409
+ const end = el.selectionEnd ?? el.value.length;
410
+ const before = el.value.slice(0, start);
411
+ const after = el.value.slice(end);
412
+ const next = before + text + after;
413
+ setDraft(next);
414
+ // Imperatively place the cursor after the commit. React's
415
+ // state update is async, so we wait one tick — by then the
416
+ // controlled value has flushed into the DOM and we can move
417
+ // the selection without it being clobbered.
418
+ queueMicrotask(() => {
419
+ const cursor = before.length + text.length;
420
+ el.focus({ preventScroll: true });
421
+ el.setSelectionRange(cursor, cursor);
422
+ });
423
+ }, []);
424
+ // Wire the imperative handle. The host's ref slot is filled in on
425
+ // every render so a late-binding consumer still gets the live
426
+ // closure (sendNow reads the latest session/state via the closure
427
+ // it captures here).
428
+ (0, react_1.useEffect)(() => {
429
+ if (!handleRef)
430
+ return;
431
+ handleRef.current = {
432
+ sendNow: (text) => {
433
+ if (!session)
434
+ return;
435
+ const trimmed = text.trim();
436
+ if (!trimmed)
437
+ return;
438
+ hasInteractedRef.current = true;
439
+ setDraft('');
440
+ void session.send(trimmed);
441
+ },
442
+ insertText,
443
+ };
444
+ return () => {
445
+ // Drop the handle on unmount so a stale ref can't fire send
446
+ // after the component is gone.
447
+ if (handleRef.current)
448
+ handleRef.current = null;
449
+ };
450
+ }, [handleRef, session, insertText]);
382
451
  const sendDisabled = !session ||
383
452
  status === 'idle' ||
384
453
  status === 'loading' ||
@@ -436,7 +505,14 @@ function ChatWidget(props) {
436
505
  ? { ...theme, avatarUrl: member.avatarUrl }
437
506
  : theme;
438
507
  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: () => {
508
+ // Seed for the deterministic-hue fallback avatar. Prefer
509
+ // the acting agent id (so each member in a team gets its
510
+ // own stable color) and fall back to the primary agent's
511
+ // slug for solo chats / orchestrator-self turns. The
512
+ // `agent` summary doesn't carry an id — slug is stable
513
+ // and unique, which is all hueFromSeed needs.
514
+ const bubbleAgentId = m.actingAgentId ?? agent?.slug;
515
+ 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: () => {
440
516
  // After a successful Approve, kick the next turn so the
441
517
  // gate's fast-path consumes the approval and the tool
442
518
  // actually runs. `silent: true` keeps the literal
@@ -449,23 +525,23 @@ function ChatWidget(props) {
449
525
  void session.send('continue', { silent: true });
450
526
  }, 250);
451
527
  } }, 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: () => {
528
+ }) }), 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: () => {
453
529
  if (onShortcutClick)
454
530
  onShortcutClick(text, i);
455
531
  else
456
532
  setDraft(text);
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" })] })] }));
533
+ }, children: text }, `${i}-${text}`))) })), (0, jsx_runtime_1.jsxs)("div", { className: "af-input-row", children: [composerLeftSlot && ((0, jsx_runtime_1.jsx)("div", { className: "af-input-left", children: composerLeftSlot })), (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" })] })] }));
458
534
  }
459
- function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, speakerLabel, }) {
535
+ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, avatarAgentId, speakerLabel, }) {
460
536
  const kind = message.metadata?.kind;
461
537
  if (kind === 'awaiting_approval') {
462
538
  // Approval / blocked bubbles also count as "assistant-side" so we
463
539
  // wrap them in the same row geometry — keeps the conversation
464
540
  // aligned even when a tool dispatch interrupts the regular flow.
465
- return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }), speakerLabel);
541
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }), speakerLabel);
466
542
  }
467
543
  if (kind === 'tool_blocked') {
468
- return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }), speakerLabel);
544
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }), speakerLabel);
469
545
  }
470
546
  const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
471
547
  ? message.content
@@ -474,10 +550,10 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bar
474
550
  : ''}`;
475
551
  // Typing state (no content yet): render the three-dot indicator, no markdown.
476
552
  if (message.role === 'assistant' && message.isStreaming && !message.content) {
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);
553
+ 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);
478
554
  }
479
555
  if (message.role === 'assistant') {
480
- return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)("div", { className: cls,
556
+ return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (0, jsx_runtime_1.jsx)("div", { className: cls,
481
557
  // Output is sanitized by escapeHtml + a fixed tag whitelist in
482
558
  // renderMarkdown — safe to inject as HTML.
483
559
  dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }), speakerLabel);
@@ -491,22 +567,27 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bar
491
567
  * avatar column. Card variant skips the wrapper entirely so its
492
568
  * historical layout is unchanged.
493
569
  */
494
- function wrapAssistantRow(bare, showAvatar, theme, name, child,
570
+ function wrapAssistantRow(bare, showAvatar, theme, name, agentId, child,
495
571
  /** When set + this is the first bubble of a speaker run (showAvatar
496
572
  * is true), render the speaker's display name just above the
497
573
  * bubble. Used by Team chats so members are visually attributed. */
498
574
  speakerLabel) {
499
575
  if (!bare)
500
576
  return 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] })] }));
577
+ 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] })] }));
502
578
  }
503
579
  /**
504
580
  * Small circular avatar for the assistant-side column. Prefers the
505
- * agent's avatarUrl when set; otherwise renders a gradient circle
506
- * with the first letter of `name`. The slot reserves space even when
507
- * `show` is false so consecutive bubbles stay column-aligned.
581
+ * agent's avatarUrl when set; otherwise renders a SOLID circle with
582
+ * the first letter of `name` over a hue derived from `agentId`. The
583
+ * hash HSL mapping means every agent in a team gets a stable,
584
+ * distinguishable color across renders without us needing a palette
585
+ * table or per-agent config.
586
+ *
587
+ * The slot reserves space even when `show` is false so consecutive
588
+ * bubbles stay column-aligned.
508
589
  */
509
- function AssistantAvatar({ theme, name, show, }) {
590
+ function AssistantAvatar({ theme, name, agentId, show, }) {
510
591
  const initial = (name ?? 'A').trim().charAt(0).toUpperCase() || 'A';
511
592
  if (!show) {
512
593
  return (0, jsx_runtime_1.jsx)("div", { className: "af-msg-avatar af-msg-avatar-spacer", "aria-hidden": true });
@@ -516,7 +597,21 @@ function AssistantAvatar({ theme, name, show, }) {
516
597
  // eslint-disable-next-line @next/next/no-img-element -- vanilla widget; consumer can override host
517
598
  (0, jsx_runtime_1.jsx)("img", { className: "af-msg-avatar af-msg-avatar-img", src: theme.avatarUrl, alt: "", "aria-hidden": true }));
518
599
  }
519
- 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 }) }));
600
+ const seed = agentId || name || 'agent';
601
+ 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 }) }));
602
+ }
603
+ /**
604
+ * Deterministic per-agent hue. Hash the seed into the HSL hue space
605
+ * so an agent's avatar color stays stable across renders and looks
606
+ * varied across a roster. Saturation/lightness are tuned for both
607
+ * light + dark chat surfaces: mid-saturation + mid-lightness reads
608
+ * legibly with white text on either background.
609
+ */
610
+ function hueFromSeed(seed) {
611
+ let h = 0;
612
+ for (let i = 0; i < seed.length; i++)
613
+ h = (h * 31 + seed.charCodeAt(i)) >>> 0;
614
+ return `hsl(${h % 360} 60% 52%)`;
520
615
  }
521
616
  /**
522
617
  * Awaiting-approval bubble. Renders the tool name + a countdown, and
@@ -692,6 +787,11 @@ const WIDGET_CSS = `
692
787
  to { opacity: 1; transform: translateY(0); }
693
788
  }
694
789
  .af-input-row { padding: 12px; border-top: 1px solid var(--af-border); background: var(--af-bg); display: flex; gap: 8px; align-items: flex-end; }
790
+ /* Composer left slot — hosts use this for affordance buttons that
791
+ scope the next turn (member picker, tools menu, attachments).
792
+ align-items: center keeps a single-line chip vertically centered
793
+ against the auto-growing textarea. */
794
+ .af-input-left { display: flex; align-items: center; padding-bottom: 4px; flex-shrink: 0; }
695
795
  .af-shortcut-row {
696
796
  display: flex;
697
797
  gap: 6px;
@@ -815,7 +915,13 @@ const WIDGET_CSS = `
815
915
  background: transparent;
816
916
  border-radius: 0;
817
917
  box-shadow: none;
818
- overflow: visible;
918
+ /* overflow:hidden so a long transcript (markdown tables, long
919
+ lists) scrolls inside af-messages instead of pushing the
920
+ af-input-row past the bottom of the host envelope. Was 'visible'
921
+ before, which let a tall message break out of the host sized
922
+ container and shove the composer off-screen. The messages region
923
+ itself carries overflow-y:auto so internal scroll keeps working. */
924
+ overflow: hidden;
819
925
  display: flex;
820
926
  flex-direction: column;
821
927
  flex: 1;
@@ -1048,7 +1154,11 @@ const WIDGET_CSS = `
1048
1154
  font-size: 11.5px;
1049
1155
  font-weight: 600;
1050
1156
  letter-spacing: 0.01em;
1051
- background-image: linear-gradient(135deg, var(--af-primary, #8b5cf6), color-mix(in srgb, var(--af-primary, #8b5cf6) 60%, #6366f1));
1157
+ /* SOLID fill the React component sets the actual color via an
1158
+ * inline style.backgroundColor (deterministic hash of agentId).
1159
+ * No gradient here on purpose: the gradient was masking the
1160
+ * inline color and made every agent avatar look the same. */
1161
+ background-color: var(--af-primary, #8b5cf6);
1052
1162
  }
1053
1163
  @media (min-width: 768px) {
1054
1164
  .af-widget-root.af-variant-bare .af-msg-avatar { width: 30px; height: 30px; }
package/dist/session.js CHANGED
@@ -211,6 +211,7 @@ class ChatSession {
211
211
  for await (const evt of generator) {
212
212
  if (evt.kind === 'conversation') {
213
213
  this.state.conversationId = evt.id;
214
+ this.emit({ type: 'conversation_started', conversationId: evt.id });
214
215
  continue;
215
216
  }
216
217
  if (evt.kind === 'chunk') {
@@ -398,6 +399,7 @@ class ChatSession {
398
399
  else {
399
400
  const res = await this.transport.createConversation(text, this.browserSessionId);
400
401
  this.state.conversationId = res.conversationId;
402
+ this.emit({ type: 'conversation_started', conversationId: res.conversationId });
401
403
  content = res.content;
402
404
  }
403
405
  assistant.content = content;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.1.1",
3
+ "version": "2.2.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",