@agentforge-io/chat-sdk 2.4.0-dev.2 → 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 +31 -32
- package/dist/session.d.ts +2 -1
- package/dist/session.js +21 -7
- package/package.json +1 -1
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
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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
|
|
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) {
|
|
@@ -542,13 +539,15 @@ function ChatWidget(props) {
|
|
|
542
539
|
// conversation has actually ended OR before the
|
|
543
540
|
// session is ready at all.
|
|
544
541
|
disabled: status === 'ended' || status === 'loading' || status === 'idle' }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-send",
|
|
545
|
-
// `
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
|
|
542
|
+
// `onPointerDown preventDefault` is the single cross-
|
|
543
|
+
// device guarantee that the button never steals focus
|
|
544
|
+
// from the textarea. PointerEvents cover touch, mouse,
|
|
545
|
+
// AND pen — `onMouseDown` alone misses mobile touch.
|
|
546
|
+
// Without this, the focus shifts to the button just
|
|
547
|
+
// before `handleSend` runs, the button then disables
|
|
548
|
+
// (sendDisabled flips true on status change), focus
|
|
549
|
+
// 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" })] })] }));
|
|
552
551
|
}
|
|
553
552
|
function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, avatarAgentId, speakerLabel, }) {
|
|
554
553
|
const kind = message.metadata?.kind;
|
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
|
|
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
|
-
|
|
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 (
|
|
53
|
-
|
|
54
|
-
this.
|
|
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.
|
|
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
|
-
|
|
168
|
-
|
|
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
|
+
"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",
|