@agentforge-io/chat-sdk 2.4.0-dev.9 → 2.4.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.
@@ -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.d.ts CHANGED
@@ -198,6 +198,26 @@ export interface ChatWidgetHandle {
198
198
  * and the cursor lands at the end of the inserted text.
199
199
  */
200
200
  insertText(text: string): void;
201
+ /**
202
+ * Move keyboard focus to the composer textarea. Use when the
203
+ * host UI surfaces the widget late (e.g. opening a drawer) and
204
+ * wants the on-screen keyboard up immediately. Safe no-op when
205
+ * the textarea hasn't mounted yet or is disabled.
206
+ */
207
+ focus(): void;
208
+ /**
209
+ * Warm up the underlying session — fires the initial
210
+ * `GET /agent` (and optionally a `resumeConversation` lookup) so
211
+ * the first `send()` doesn't pay the round-trip. Idempotent:
212
+ * subsequent calls join the same in-flight promise.
213
+ *
214
+ * Hosts call this when the visitor signals INTENT to chat (e.g.
215
+ * tapping a fake-composer pill that opens a drawer) so the
216
+ * session boot overlaps with the visitor finding the keyboard
217
+ * and tapping out their first message. Safe no-op if the session
218
+ * is already past `loading`.
219
+ */
220
+ warmup(): void;
201
221
  }
202
222
  /**
203
223
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
package/dist/react.js CHANGED
@@ -291,14 +291,31 @@ function ChatWidget(props) {
291
291
  el.focus({ preventScroll: true });
292
292
  focusedOnceRef.current = true;
293
293
  }, [status]);
294
- // ── Session lifecycle. Recreate when token / apiBaseUrl changes. ──────
294
+ // ── Session lifecycle. Recreate when token / apiBaseUrl / browserSessionId
295
+ // / stream changes. NOTE: `resumeConversationId` is intentionally NOT a
296
+ // dependency.
297
+ //
298
+ // Why: hosts typically wire `resumeConversationId={conversationId}`
299
+ // and call `setConversationId(id)` in `onConversationStart`. The
300
+ // conversation_started event fires AFTER the SSE stream opens and
301
+ // the first chunks already started flowing. If we treated
302
+ // resumeConversationId as a reactive dep, this effect would
303
+ // re-run mid-stream, destroy the live ChatSession, and the
304
+ // remaining chunks would land in a corpse — the visitor sees
305
+ // an empty assistant bubble even though the backend completed
306
+ // the turn successfully.
307
+ //
308
+ // We capture the resume id at mount via a ref. Subsequent host
309
+ // updates to it are ignored — the SDK already owns the live
310
+ // conversation id internally via session.state.conversationId.
311
+ const resumeRef = (0, react_1.useRef)(resumeConversationId);
295
312
  (0, react_1.useEffect)(() => {
296
313
  let cancelled = false;
297
314
  const s = new session_1.ChatSession({
298
315
  token,
299
316
  apiBaseUrl,
300
317
  browserSessionId,
301
- resumeConversationId,
318
+ resumeConversationId: resumeRef.current,
302
319
  stream,
303
320
  });
304
321
  setSession(s);
@@ -336,7 +353,10 @@ function ChatWidget(props) {
336
353
  unsubscribe();
337
354
  s.destroy();
338
355
  };
339
- }, [token, apiBaseUrl, browserSessionId, resumeConversationId, stream]);
356
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- resumeConversationId
357
+ // is captured via resumeRef on first mount; reactive updates are
358
+ // ignored by design. See block comment above.
359
+ }, [token, apiBaseUrl, browserSessionId, stream]);
340
360
  // Auto-scroll on new tokens.
341
361
  //
342
362
  // We defer the scroll into a requestAnimationFrame so the DOM has
@@ -442,6 +462,20 @@ function ChatWidget(props) {
442
462
  void session.send(trimmed);
443
463
  },
444
464
  insertText,
465
+ focus: () => {
466
+ const el = inputRef.current;
467
+ if (!el || el.disabled)
468
+ return;
469
+ el.focus({ preventScroll: true });
470
+ },
471
+ warmup: () => {
472
+ // session.start() is idempotent — joins the in-flight
473
+ // promise if one exists, resolves immediately if start
474
+ // already completed.
475
+ if (!session)
476
+ return;
477
+ void session.start();
478
+ },
445
479
  };
446
480
  return () => {
447
481
  // Drop the handle on unmount so a stale ref can't fire send
@@ -532,7 +566,9 @@ function ChatWidget(props) {
532
566
  onShortcutClick(text, i);
533
567
  else
534
568
  setDraft(text);
535
- }, 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,
569
+ }, children: text }, `${i}-${text}`))) })), (0, jsx_runtime_1.jsxs)("div", { className: "af-input-row", "data-loading": status === 'loading' || status === 'idle' ? '' : undefined, 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: status === 'idle' || status === 'loading'
570
+ ? 'Preparing chat…'
571
+ : inputPlaceholder ?? 'Type a message…', rows: 1,
536
572
  // The textarea stays editable while the agent is
537
573
  // streaming so the visitor can compose their next
538
574
  // message without waiting. Only block when the
@@ -547,7 +583,14 @@ function ChatWidget(props) {
547
583
  // before `handleSend` runs, the button then disables
548
584
  // (sendDisabled flips true on status change), focus
549
585
  // jumps to <body>, and the on-screen keyboard collapses.
550
- onPointerDown: (e) => e.preventDefault(), 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" })] })] }));
586
+ onPointerDown: (e) => e.preventDefault(), onClick: handleSend, disabled: sendDisabled, "aria-label": status === 'sending' || status === 'streaming'
587
+ ? 'Sending message'
588
+ : status === 'idle' || status === 'loading'
589
+ ? 'Preparing chat'
590
+ : 'Send message', children: status === 'sending' ||
591
+ status === 'streaming' ||
592
+ status === 'idle' ||
593
+ 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" })] })] }));
551
594
  }
552
595
  function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, avatarAgentId, speakerLabel, }) {
553
596
  const kind = message.metadata?.kind;
@@ -720,6 +763,11 @@ function CloseIcon() {
720
763
  function SendIcon() {
721
764
  return ((0, jsx_runtime_1.jsxs)("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: [(0, jsx_runtime_1.jsx)("line", { x1: "22", y1: "2", x2: "11", y2: "13" }), (0, jsx_runtime_1.jsx)("polygon", { points: "22 2 15 22 11 13 2 9 22 2" })] }));
722
765
  }
766
+ function SpinnerIcon() {
767
+ // Inline SVG spinner — no external dep. Stroke-dasharray
768
+ // arc rotates via the CSS keyframes injected by WIDGET_CSS.
769
+ return ((0, jsx_runtime_1.jsxs)("svg", { viewBox: "0 0 24 24", fill: "none", className: "af-spinner", "aria-hidden": true, children: [(0, jsx_runtime_1.jsx)("circle", { cx: "12", cy: "12", r: "9", stroke: "currentColor", strokeOpacity: "0.25", strokeWidth: "2.5" }), (0, jsx_runtime_1.jsx)("path", { d: "M21 12a9 9 0 0 0-9-9", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }));
770
+ }
723
771
  // Stylesheet kept verbatim from the standalone widget.js so the React
724
772
  // component is visually indistinguishable from the script-injected one.
725
773
  const WIDGET_CSS = `
@@ -804,6 +852,8 @@ const WIDGET_CSS = `
804
852
  to { opacity: 1; transform: translateY(0); }
805
853
  }
806
854
  .af-input-row { padding: 12px; border-top: 1px solid var(--af-border); background: var(--af-bg); display: flex; gap: 8px; align-items: flex-end; }
855
+ .af-input-row[data-loading] .af-input { cursor: progress; opacity: 0.7; }
856
+ .af-input-row[data-loading] .af-input::placeholder { font-style: italic; }
807
857
  /* Composer left slot — hosts use this for affordance buttons that
808
858
  scope the next turn (member picker, tools menu, attachments).
809
859
  align-items: center keeps a single-line chip vertically centered
@@ -863,6 +913,8 @@ const WIDGET_CSS = `
863
913
  .af-send:active:not(:disabled) { transform: translateY(0); }
864
914
  .af-send:disabled { opacity: 0.5; cursor: not-allowed; }
865
915
  .af-send svg { width: 16px; height: 16px; }
916
+ .af-send .af-spinner { animation: af-spin 720ms linear infinite; }
917
+ @keyframes af-spin { to { transform: rotate(360deg); } }
866
918
  .af-error { padding: 10px 16px; font-size: 12px; color: #b91c1c; background: #fef2f2; border-top: 1px solid #fee2e2; }
867
919
  .af-footer { padding: 6px 12px; font-size: 10px; color: var(--af-muted); text-align: center; background: var(--af-bubble-bg); border-top: 1px solid var(--af-border); }
868
920
  /* Approval and blocked bubbles. Amber for needs-decision, red for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.4.0-dev.9",
3
+ "version": "2.4.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",