@agentforge-io/chat-sdk 2.4.0-dev.3 → 2.4.0-dev.4

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.
package/dist/react.js CHANGED
@@ -268,20 +268,28 @@ function ChatWidget(props) {
268
268
  (0, react_1.useEffect)(() => {
269
269
  onConversationStartRef.current = onConversationStart;
270
270
  }, [onConversationStart]);
271
- // Auto-focus the composer once the session is ready. We always
272
- // focus even on touch devices because:
273
- // On mobile the chat is opened from a drawer behind a tap.
274
- // That tap is a user gesture that authorises opening the
275
- // keyboard, so it's NOT jarring to focus the textarea.
276
- // On desktop the user expects ready-to-type.
277
- // After a send, the visitor is in conversation rhythm and
278
- // the keyboard MUST stay alive.
271
+ // Auto-focus the composer ONCE, the first time the session
272
+ // reaches `ready` AND the textarea is enabled. After that,
273
+ // focus is preserved by the composer's `onPointerDown
274
+ // preventDefault` (the send button never steals it) and by NOT
275
+ // firing focus() on every status change. That used to cause a
276
+ // visible "blink": status flips `ready → sending → streaming →
277
+ // ready` on every send, and a status-watching focus effect
278
+ // would refocus AFTER the keyboard had already started
279
+ // collapsing, producing the jitter.
280
+ const focusedOnceRef = (0, react_1.useRef)(false);
279
281
  (0, react_1.useEffect)(() => {
282
+ if (focusedOnceRef.current)
283
+ return;
280
284
  if (status !== 'ready')
281
285
  return;
282
286
  if (typeof window === 'undefined')
283
287
  return;
284
- inputRef.current?.focus({ preventScroll: true });
288
+ const el = inputRef.current;
289
+ if (!el || el.disabled)
290
+ return;
291
+ el.focus({ preventScroll: true });
292
+ focusedOnceRef.current = true;
285
293
  }, [status]);
286
294
  // ── Session lifecycle. Recreate when token / apiBaseUrl changes. ──────
287
295
  (0, react_1.useEffect)(() => {
@@ -370,25 +378,14 @@ function ChatWidget(props) {
370
378
  const text = draft.trim();
371
379
  if (!text)
372
380
  return;
373
- // Mark engagement BEFORE the status flip so the auto-focus
374
- // effect (which watches `status`) sees the flag when it
375
- // re-runs after `ready` returns. Subsequent renders will
376
- // refocus the textarea on every send completion.
377
381
  hasInteractedRef.current = true;
378
382
  setDraft('');
383
+ // session.send awaits start() internally, so it's safe to fire
384
+ // even before the initial agent/theme fetch resolves. The send
385
+ // button's `onPointerDown preventDefault` keeps the textarea
386
+ // focused through tap/click, so we don't manually re-focus
387
+ // here (manual focus during the status flip caused a blink).
379
388
  void session.send(text);
380
- // Mobile UX: when the send button disables (sendDisabled flips
381
- // true the moment status goes to 'sending'), focus would jump to
382
- // <body> and the on-screen keyboard would collapse. The send
383
- // button uses `onMouseDown preventDefault` to keep the textarea
384
- // focused through the click, but we still re-anchor focus here
385
- // as a one-shot synchronous fallback (e.g. Enter-key submit on a
386
- // hardware keyboard). NO rAF retry — that caused a visible focus
387
- // blink on mobile (focus left, came back a frame later).
388
- const el = inputRef.current;
389
- if (el && document.activeElement !== el) {
390
- el.focus({ preventScroll: true });
391
- }
392
389
  }, [session, draft]);
393
390
  const onKeyDown = (0, react_1.useCallback)((e) => {
394
391
  if (e.key === 'Enter' && !e.shiftKey) {
package/dist/session.d.ts CHANGED
@@ -23,12 +23,13 @@ export declare class ChatSession {
23
23
  private readonly resumeId?;
24
24
  private listeners;
25
25
  private state;
26
- private started;
26
+ private startPromise;
27
27
  constructor(opts: ChatSessionOptions);
28
28
  /** Returns an unsubscribe function. Listeners are called synchronously. */
29
29
  onEvent(listener: Listener): () => void;
30
30
  getState(): ChatSessionState;
31
31
  start(): Promise<void>;
32
+ private runStart;
32
33
  /**
33
34
  * Mark the active conversation as ended on the server and lock the
34
35
  * session locally. After this, sends throw — the consumer should drop
package/dist/session.js CHANGED
@@ -25,7 +25,13 @@ class ChatSession {
25
25
  status: 'idle',
26
26
  messages: [],
27
27
  };
28
- this.started = false;
28
+ // Tracks the in-flight (or completed) `start()` invocation.
29
+ // Used so concurrent callers — typically a React effect that
30
+ // fires `void s.start()` AND a user that taps Send before
31
+ // start resolved — both await the SAME promise instead of
32
+ // either firing two starts or skipping the wait entirely.
33
+ // Resolves once status reaches `ready`/`ended` (or throws).
34
+ this.startPromise = null;
29
35
  if (!opts.token)
30
36
  throw new Error('ChatSession: token is required');
31
37
  const apiBaseUrl = opts.apiBaseUrl ?? defaultApiBase();
@@ -49,9 +55,14 @@ class ChatSession {
49
55
  }
50
56
  // ─── Lifecycle ──────────────────────────────────────────────────────────
51
57
  async start() {
52
- if (this.started)
53
- return;
54
- this.started = true;
58
+ // Idempotent: if another caller already kicked off start(),
59
+ // join their promise. Critical for the React + send race.
60
+ if (this.startPromise)
61
+ return this.startPromise;
62
+ this.startPromise = this.runStart();
63
+ return this.startPromise;
64
+ }
65
+ async runStart() {
55
66
  this.setStatus('loading');
56
67
  try {
57
68
  const { agent, theme } = await this.transport.getAgent();
@@ -141,7 +152,7 @@ class ChatSession {
141
152
  this.emit({ type: 'destroyed' });
142
153
  // Drop everything — a destroyed session shouldn't be reused.
143
154
  this.state = { status: 'idle', messages: [] };
144
- this.started = false;
155
+ this.startPromise = null;
145
156
  }
146
157
  // ─── User actions ───────────────────────────────────────────────────────
147
158
  /**
@@ -164,8 +175,11 @@ class ChatSession {
164
175
  if (this.state.status === 'ended') {
165
176
  throw new Error('Conversation has ended. Start a fresh chat to continue.');
166
177
  }
167
- if (!this.started)
168
- await this.start();
178
+ // Always await start — start() is idempotent and resolves
179
+ // immediately if it already completed. This closes the race
180
+ // where the user taps Send while the initial agent/theme
181
+ // fetch is still in flight and conversationId is unset.
182
+ await this.start();
169
183
  if (!opts?.silent) {
170
184
  this.appendMessage({
171
185
  id: makeMessageId('u'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.4.0-dev.3",
3
+ "version": "2.4.0-dev.4",
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",