@agentforge-io/chat-sdk 2.4.0-dev.13 → 2.4.0-dev.2
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/ChatDrawer.d.ts +43 -78
- package/dist/ChatDrawer.js +131 -252
- package/dist/react.d.ts +0 -20
- package/dist/react.js +36 -87
- package/dist/session.d.ts +1 -3
- package/dist/session.js +7 -72
- package/package.json +9 -3
package/dist/ChatDrawer.d.ts
CHANGED
|
@@ -1,60 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `<ChatDrawer>` — standard
|
|
2
|
+
* `<ChatDrawer>` — standard mobile chat shell.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* URL sync, the SDK doesn't touch the URL).
|
|
7
|
-
* - All the `<ChatWidget>` props (token, apiBaseUrl, etc.): forwarded
|
|
8
|
-
* verbatim. The widget mounts INSIDE the drawer.
|
|
4
|
+
* Mobile-first fullscreen modal that wraps `<ChatWidget>` and gets the
|
|
5
|
+
* three pieces of mobile UX every embed needs right:
|
|
9
6
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* layout viewport). On iOS Safari and Android Chrome the on-screen
|
|
16
|
-
* keyboard shrinks `window.visualViewport.height`; we listen to
|
|
17
|
-
* `resize` / `scroll` on visualViewport and reflow the drawer's
|
|
18
|
-
* `height` + `bottom` so the composer never gets clipped by the
|
|
19
|
-
* keyboard.
|
|
20
|
-
* - Opens at a configurable snap fraction (default 0.98 = 98% of the
|
|
21
|
-
* visible viewport). The visitor sees a thin sliver of the page
|
|
22
|
-
* underneath, which keeps context and lets a tap-outside dismiss.
|
|
23
|
-
* - Drag-to-dismiss with vanilla touch events: drag the handle down
|
|
24
|
-
* past 30% of the panel height and the drawer closes. No external
|
|
25
|
-
* deps — the SDK stays portable.
|
|
26
|
-
* - Survives close+reopen: the widget inside is rendered once and kept
|
|
27
|
-
* alive (CSS `display:none` toggle when closed, NOT unmounted), so
|
|
28
|
-
* the chat session, transcript, and any in-flight tool calls stay
|
|
29
|
-
* intact when the visitor closes and reopens the drawer.
|
|
7
|
+
* 1. **Sticky header** at the top (back button + agent identity).
|
|
8
|
+
* 2. **Scrollable transcript** in the middle (the SDK panel +
|
|
9
|
+
* its own scroller).
|
|
10
|
+
* 3. **Sticky composer** at the bottom that NEVER hides under
|
|
11
|
+
* the on-screen keyboard.
|
|
30
12
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
13
|
+
* Why Vaul: implementing the keyboard-aware sticky composer with
|
|
14
|
+
* vanilla `visualViewport` listeners is doable in 50 lines but the
|
|
15
|
+
* real-world iOS Safari / Chrome Android edge cases (rubber-band
|
|
16
|
+
* scroll, address-bar transitions, autocorrect bar over the keyboard,
|
|
17
|
+
* focus loss on send button disable) are not. Vaul handles all of
|
|
18
|
+
* them (used by Linear, Vercel, etc.). It's declared as an OPTIONAL
|
|
19
|
+
* peerDependency so the SDK stays portable — if a host doesn't install
|
|
20
|
+
* Vaul, `<ChatDrawer>` falls back to a render that asks the host to
|
|
21
|
+
* install it. Most hosts already have Vaul (Radix design system).
|
|
22
|
+
*
|
|
23
|
+
* Why fullscreen (not snap points): the operator decision was that a
|
|
24
|
+
* conversation should occupy the whole device. No drag-to-dismiss,
|
|
25
|
+
* no peek of the page behind. Close via the explicit back button in
|
|
26
|
+
* the header. This is the "modal" feel — predictable, no accidental
|
|
27
|
+
* dismissals from a fast scroll.
|
|
28
|
+
*
|
|
29
|
+
* Why we still don't manage the chat session: the SDK's `ChatWidget`
|
|
30
|
+
* owns that, this component is purely the surround.
|
|
39
31
|
*/
|
|
40
32
|
import { type CSSProperties, type ReactNode } from 'react';
|
|
41
33
|
import { type ChatWidgetProps } from './react';
|
|
42
34
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* creates the widget internally. The standard / forward-looking
|
|
47
|
-
* API — most consumers should use this.
|
|
48
|
-
*
|
|
49
|
-
* - `chatSlot`: pass a pre-rendered React node (typically a
|
|
50
|
-
* `<ChatWidget>` instance the host wired up itself). Useful when
|
|
51
|
-
* the host already orchestrates the widget (multiple chat slots,
|
|
52
|
-
* custom decorators, legacy code) and just wants the drawer's
|
|
53
|
-
* positioning + drag UX on top.
|
|
54
|
-
*
|
|
55
|
-
* Exactly one of the two MUST be passed. Mutual exclusivity isn't
|
|
56
|
-
* encoded at the type level (TS unions with optional props get noisy)
|
|
57
|
-
* — the runtime asserts gracefully if neither is present.
|
|
35
|
+
* Two intake shapes — either drop in a pre-built `<ChatWidget>` as
|
|
36
|
+
* `chatSlot` (most hosts) or hand us the widget props and the drawer
|
|
37
|
+
* mounts the widget itself.
|
|
58
38
|
*/
|
|
59
39
|
type ChatSurface = {
|
|
60
40
|
widgetProps: ChatWidgetProps;
|
|
@@ -64,39 +44,24 @@ type ChatSurface = {
|
|
|
64
44
|
widgetProps?: never;
|
|
65
45
|
};
|
|
66
46
|
export type ChatDrawerProps = ChatSurface & {
|
|
67
|
-
/**
|
|
47
|
+
/** Controlled visibility. */
|
|
68
48
|
open: boolean;
|
|
69
|
-
/** Fired when the drawer wants to close (
|
|
70
|
-
*
|
|
49
|
+
/** Fired when the drawer wants to close (back button, ESC). The
|
|
50
|
+
* host is responsible for setting `open=false`. */
|
|
71
51
|
onOpenChange: (open: boolean) => void;
|
|
72
|
-
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
snap?: number;
|
|
76
|
-
/** Display in the drawer header above the chat. Templates pass the
|
|
77
|
-
* agent / team identity here. When omitted, the drawer renders a
|
|
78
|
-
* bare drag handle only — useful for hosts that want a borderless
|
|
79
|
-
* panel. */
|
|
52
|
+
/** Sticky header above the chat panel. Templates pass the agent /
|
|
53
|
+
* team identity card here. Optional — when omitted the drawer
|
|
54
|
+
* renders just the close button. */
|
|
80
55
|
header?: ReactNode;
|
|
81
|
-
/**
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
/** Extra class on the drawer's root surface (the white card). Use it to
|
|
86
|
-
* add a custom shadow / border colour. The CSS vars on `--af-*` already
|
|
87
|
-
* let you re-theme the chat widget itself; this is for the SURROUND. */
|
|
56
|
+
/** Custom close button. Defaults to a chevron-left back button at
|
|
57
|
+
* the left edge of the header. */
|
|
58
|
+
closeButton?: ReactNode;
|
|
59
|
+
/** Extra class on the drawer surface (the full-height card). */
|
|
88
60
|
drawerClassName?: string;
|
|
89
|
-
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* host re-declares them here so the chat surface inside the portal
|
|
94
|
-
* picks up the same theme.
|
|
95
|
-
*
|
|
96
|
-
* We default the SURFACE BG to `var(--af-bg, …)` so passing
|
|
97
|
-
* `--af-bg` in `surfaceStyle` themes the drawer's background.
|
|
98
|
-
* When omitted the drawer falls back to white in light mode and
|
|
99
|
-
* the system default elsewhere — passable but not theme-perfect. */
|
|
61
|
+
/** CSS variables (`--af-bg`, `--af-fg`, `--af-bubble-*`, etc.) for
|
|
62
|
+
* the drawer surface. Because the drawer renders into a portal,
|
|
63
|
+
* the host's page-wrapper vars don't cascade in — re-declare them
|
|
64
|
+
* here so the chat surface theme matches. */
|
|
100
65
|
surfaceStyle?: CSSProperties & Record<string, string>;
|
|
101
66
|
};
|
|
102
67
|
export declare function ChatDrawer(props: ChatDrawerProps): JSX.Element | null;
|
package/dist/ChatDrawer.js
CHANGED
|
@@ -3,143 +3,59 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ChatDrawer = ChatDrawer;
|
|
4
4
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
5
|
/**
|
|
6
|
-
* `<ChatDrawer>` — standard
|
|
6
|
+
* `<ChatDrawer>` — standard mobile chat shell.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* URL sync, the SDK doesn't touch the URL).
|
|
11
|
-
* - All the `<ChatWidget>` props (token, apiBaseUrl, etc.): forwarded
|
|
12
|
-
* verbatim. The widget mounts INSIDE the drawer.
|
|
8
|
+
* Mobile-first fullscreen modal that wraps `<ChatWidget>` and gets the
|
|
9
|
+
* three pieces of mobile UX every embed needs right:
|
|
13
10
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* layout viewport). On iOS Safari and Android Chrome the on-screen
|
|
20
|
-
* keyboard shrinks `window.visualViewport.height`; we listen to
|
|
21
|
-
* `resize` / `scroll` on visualViewport and reflow the drawer's
|
|
22
|
-
* `height` + `bottom` so the composer never gets clipped by the
|
|
23
|
-
* keyboard.
|
|
24
|
-
* - Opens at a configurable snap fraction (default 0.98 = 98% of the
|
|
25
|
-
* visible viewport). The visitor sees a thin sliver of the page
|
|
26
|
-
* underneath, which keeps context and lets a tap-outside dismiss.
|
|
27
|
-
* - Drag-to-dismiss with vanilla touch events: drag the handle down
|
|
28
|
-
* past 30% of the panel height and the drawer closes. No external
|
|
29
|
-
* deps — the SDK stays portable.
|
|
30
|
-
* - Survives close+reopen: the widget inside is rendered once and kept
|
|
31
|
-
* alive (CSS `display:none` toggle when closed, NOT unmounted), so
|
|
32
|
-
* the chat session, transcript, and any in-flight tool calls stay
|
|
33
|
-
* intact when the visitor closes and reopens the drawer.
|
|
11
|
+
* 1. **Sticky header** at the top (back button + agent identity).
|
|
12
|
+
* 2. **Scrollable transcript** in the middle (the SDK panel +
|
|
13
|
+
* its own scroller).
|
|
14
|
+
* 3. **Sticky composer** at the bottom that NEVER hides under
|
|
15
|
+
* the on-screen keyboard.
|
|
34
16
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
17
|
+
* Why Vaul: implementing the keyboard-aware sticky composer with
|
|
18
|
+
* vanilla `visualViewport` listeners is doable in 50 lines but the
|
|
19
|
+
* real-world iOS Safari / Chrome Android edge cases (rubber-band
|
|
20
|
+
* scroll, address-bar transitions, autocorrect bar over the keyboard,
|
|
21
|
+
* focus loss on send button disable) are not. Vaul handles all of
|
|
22
|
+
* them (used by Linear, Vercel, etc.). It's declared as an OPTIONAL
|
|
23
|
+
* peerDependency so the SDK stays portable — if a host doesn't install
|
|
24
|
+
* Vaul, `<ChatDrawer>` falls back to a render that asks the host to
|
|
25
|
+
* install it. Most hosts already have Vaul (Radix design system).
|
|
26
|
+
*
|
|
27
|
+
* Why fullscreen (not snap points): the operator decision was that a
|
|
28
|
+
* conversation should occupy the whole device. No drag-to-dismiss,
|
|
29
|
+
* no peek of the page behind. Close via the explicit back button in
|
|
30
|
+
* the header. This is the "modal" feel — predictable, no accidental
|
|
31
|
+
* dismissals from a fast scroll.
|
|
32
|
+
*
|
|
33
|
+
* Why we still don't manage the chat session: the SDK's `ChatWidget`
|
|
34
|
+
* owns that, this component is purely the surround.
|
|
43
35
|
*/
|
|
44
36
|
const react_1 = require("react");
|
|
45
|
-
const react_dom_1 = require("react-dom");
|
|
46
37
|
const react_2 = require("./react");
|
|
38
|
+
// Lazy import of Vaul. We can't `import { Drawer } from 'vaul'` at
|
|
39
|
+
// the top of the file because vaul is an OPTIONAL peerDependency
|
|
40
|
+
// and the package shouldn't crash at import-time when the host
|
|
41
|
+
// hasn't installed it. We resolve at module evaluation but inside a
|
|
42
|
+
// try/catch so the missing-vaul case is just a runtime "please
|
|
43
|
+
// install vaul" warning instead of a build break.
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
let VaulDrawer = null;
|
|
46
|
+
try {
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
48
|
+
VaulDrawer = require('vaul').Drawer;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// vaul not installed — render a fallback in the component.
|
|
52
|
+
}
|
|
47
53
|
function ChatDrawer(props) {
|
|
48
|
-
const { open, onOpenChange,
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// page peeks behind the surface. With snap=1 there's nothing behind
|
|
54
|
-
// and no affordance to drag against.
|
|
55
|
-
const fullscreen = snap >= 1;
|
|
56
|
-
// We mount once and keep alive across close→reopen so the chat session
|
|
57
|
-
// doesn't get destroyed. After the FIRST open the panel stays in the
|
|
58
|
-
// DOM forever (toggled by `display:none`) — `hasOpened` gates the
|
|
59
|
-
// initial mount so SSR doesn't render an empty portal.
|
|
60
|
-
const [hasOpened, setHasOpened] = (0, react_1.useState)(open);
|
|
61
|
-
(0, react_1.useEffect)(() => {
|
|
62
|
-
if (open)
|
|
63
|
-
setHasOpened(true);
|
|
64
|
-
}, [open]);
|
|
65
|
-
// Visible viewport tracking. iOS Safari + Android Chrome shrink
|
|
66
|
-
// `visualViewport.height` when the on-screen keyboard pops up; the
|
|
67
|
-
// layout viewport stays the same. We pin the drawer's height + bottom
|
|
68
|
-
// to the visual viewport so the composer is always above the keyboard.
|
|
69
|
-
const [vv, setVv] = (0, react_1.useState)(() => ({
|
|
70
|
-
h: typeof window === 'undefined' ? 0 : window.innerHeight,
|
|
71
|
-
offsetTop: 0,
|
|
72
|
-
}));
|
|
73
|
-
(0, react_1.useEffect)(() => {
|
|
74
|
-
if (typeof window === 'undefined')
|
|
75
|
-
return;
|
|
76
|
-
const view = window.visualViewport;
|
|
77
|
-
if (!view)
|
|
78
|
-
return;
|
|
79
|
-
const update = () => setVv({ h: view.height, offsetTop: view.offsetTop });
|
|
80
|
-
update();
|
|
81
|
-
view.addEventListener('resize', update);
|
|
82
|
-
view.addEventListener('scroll', update);
|
|
83
|
-
return () => {
|
|
84
|
-
view.removeEventListener('resize', update);
|
|
85
|
-
view.removeEventListener('scroll', update);
|
|
86
|
-
};
|
|
87
|
-
}, []);
|
|
88
|
-
// Snap-point height: % of the visible viewport. Recomputed when vv
|
|
89
|
-
// changes so a keyboard popup keeps the drawer aligned.
|
|
90
|
-
const drawerHeight = Math.max(0, Math.round(vv.h * Math.min(Math.max(snap, 0.1), 1)));
|
|
91
|
-
// Drag-to-dismiss. Vanilla touch handlers — no library. We track
|
|
92
|
-
// pointerdown on the handle, follow movement on pointermove, and
|
|
93
|
-
// decide on pointerup whether the drag crossed the "dismiss" threshold
|
|
94
|
-
// (30% of the panel height).
|
|
95
|
-
const surfaceRef = (0, react_1.useRef)(null);
|
|
96
|
-
const dragStateRef = (0, react_1.useRef)(null);
|
|
97
|
-
const [dragOffset, setDragOffset] = (0, react_1.useState)(0);
|
|
98
|
-
const onHandlePointerDown = (0, react_1.useCallback)((e) => {
|
|
99
|
-
if (e.button !== 0 && e.pointerType !== 'touch')
|
|
100
|
-
return;
|
|
101
|
-
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
102
|
-
dragStateRef.current = {
|
|
103
|
-
startY: e.clientY,
|
|
104
|
-
startTime: Date.now(),
|
|
105
|
-
dragging: true,
|
|
106
|
-
};
|
|
107
|
-
}, []);
|
|
108
|
-
const onHandlePointerMove = (0, react_1.useCallback)((e) => {
|
|
109
|
-
const s = dragStateRef.current;
|
|
110
|
-
if (!s?.dragging)
|
|
111
|
-
return;
|
|
112
|
-
const delta = Math.max(0, e.clientY - s.startY);
|
|
113
|
-
setDragOffset(delta);
|
|
114
|
-
}, []);
|
|
115
|
-
const onHandlePointerUp = (0, react_1.useCallback)((e) => {
|
|
116
|
-
const s = dragStateRef.current;
|
|
117
|
-
if (!s?.dragging)
|
|
118
|
-
return;
|
|
119
|
-
dragStateRef.current = null;
|
|
120
|
-
try {
|
|
121
|
-
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
// Pointer was already released by some other code path; nothing
|
|
125
|
-
// to do here. Releasing a non-captured pointer throws — swallow.
|
|
126
|
-
}
|
|
127
|
-
const delta = Math.max(0, e.clientY - s.startY);
|
|
128
|
-
const dismissThreshold = drawerHeight * 0.3;
|
|
129
|
-
const elapsed = Date.now() - s.startTime;
|
|
130
|
-
// Dismiss on EITHER a long-drag (past the threshold) or a quick
|
|
131
|
-
// flick (small distance but high velocity). Velocity unit is
|
|
132
|
-
// px/ms; >0.5 is roughly the threshold iOS sheets use.
|
|
133
|
-
const velocity = elapsed > 0 ? delta / elapsed : 0;
|
|
134
|
-
if (delta > dismissThreshold || velocity > 0.5) {
|
|
135
|
-
onOpenChange(false);
|
|
136
|
-
}
|
|
137
|
-
setDragOffset(0);
|
|
138
|
-
}, [drawerHeight, onOpenChange]);
|
|
139
|
-
// Lock body scroll while the drawer is open so the page underneath
|
|
140
|
-
// doesn't move when the visitor scrolls inside the chat. Restored on
|
|
141
|
-
// close. Necessary on iOS Safari where the rubber-band scroll bleeds
|
|
142
|
-
// through to the body even with overflow:hidden on a child.
|
|
54
|
+
const { open, onOpenChange, header, closeButton, drawerClassName, surfaceStyle, widgetProps, chatSlot, } = props;
|
|
55
|
+
// Body scroll lock — Vaul does this internally on open, but it
|
|
56
|
+
// also restores on unmount, which interacts badly with a portal
|
|
57
|
+
// re-mount when the drawer re-renders during a heavy parent
|
|
58
|
+
// update. Belt-and-braces.
|
|
143
59
|
(0, react_1.useEffect)(() => {
|
|
144
60
|
if (typeof document === 'undefined')
|
|
145
61
|
return;
|
|
@@ -151,127 +67,90 @@ function ChatDrawer(props) {
|
|
|
151
67
|
document.body.style.overflow = prev;
|
|
152
68
|
};
|
|
153
69
|
}, [open]);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const onKey = (e) => {
|
|
163
|
-
if (e.key === 'Escape')
|
|
164
|
-
onOpenChange(false);
|
|
165
|
-
};
|
|
166
|
-
window.addEventListener('keydown', onKey, true);
|
|
167
|
-
return () => window.removeEventListener('keydown', onKey, true);
|
|
168
|
-
}, [open, onOpenChange]);
|
|
169
|
-
// Lazy portal target. We render to `document.body` so the drawer
|
|
170
|
-
// overlays everything regardless of the consumer's stacking context.
|
|
171
|
-
const [portalEl, setPortalEl] = (0, react_1.useState)(null);
|
|
172
|
-
(0, react_1.useLayoutEffect)(() => {
|
|
173
|
-
if (typeof document === 'undefined')
|
|
174
|
-
return;
|
|
175
|
-
setPortalEl(document.body);
|
|
176
|
-
}, []);
|
|
177
|
-
// Stable id so the drag handle's `aria-controls` can point at the
|
|
178
|
-
// panel. Required for screen readers to announce the relationship.
|
|
179
|
-
const panelId = (0, react_1.useId)();
|
|
180
|
-
if (!portalEl)
|
|
181
|
-
return null;
|
|
182
|
-
if (!hasOpened)
|
|
183
|
-
return null;
|
|
184
|
-
// The translate3d ensures the drawer animates from below on first
|
|
185
|
-
// open (initial transform = 100%, becomes 0% after the open prop
|
|
186
|
-
// flips). When dragging we add the manual drag offset on top.
|
|
187
|
-
const translateY = open ? `${dragOffset}px` : `${drawerHeight}px`;
|
|
188
|
-
// Transition disabled WHILE dragging so the surface follows the
|
|
189
|
-
// finger 1:1. Re-enabled at the end of the drag (delta back to 0
|
|
190
|
-
// smoothly when below threshold).
|
|
191
|
-
const isDragging = dragStateRef.current?.dragging === true;
|
|
192
|
-
// Motion / sizing portion of the surface style, kept separate from
|
|
193
|
-
// the host-supplied theming so a re-render driven by drag offset
|
|
194
|
-
// doesn't smash the host's CSS vars.
|
|
195
|
-
//
|
|
196
|
-
// Two distinct positioning modes:
|
|
197
|
-
//
|
|
198
|
-
// **fullscreen (snap=1)**: pin the surface to the VISUAL viewport
|
|
199
|
-
// box. `top = visualViewport.offsetTop` + `height = visualViewport.h`
|
|
200
|
-
// means the drawer sits exactly on the area the user can see, with
|
|
201
|
-
// the bottom edge flush against the on-screen keyboard (when it's up)
|
|
202
|
-
// or the bottom of the screen (when it's down). No layout viewport
|
|
203
|
-
// gymnastics needed.
|
|
204
|
-
//
|
|
205
|
-
// **bottom-sheet (snap<1)**: anchor the surface to the bottom of the
|
|
206
|
-
// visual viewport. `bottom = visualViewport.offsetTop` is the legacy
|
|
207
|
-
// path that handles the rubber-band scroll case on iOS Safari.
|
|
208
|
-
const surfaceMotionStyle = fullscreen
|
|
209
|
-
? {
|
|
210
|
-
top: `${vv.offsetTop}px`,
|
|
211
|
-
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',
|
|
70
|
+
if (!VaulDrawer) {
|
|
71
|
+
// Host hasn't installed vaul. We log a one-time warning and
|
|
72
|
+
// render nothing. Most hosts that hit this never wanted the
|
|
73
|
+
// drawer in the first place (they're using ChatWidget inline).
|
|
74
|
+
if (open && typeof console !== 'undefined') {
|
|
75
|
+
console.warn('[@agentforge-io/chat-sdk] <ChatDrawer> requires `vaul` to render. ' +
|
|
76
|
+
'Install it with `npm install vaul` or `yarn add vaul`. ' +
|
|
77
|
+
'The drawer is a no-op until vaul is available.');
|
|
217
78
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const chatNode = chatSlot ?? (widgetProps ? ((0, jsx_runtime_1.jsx)(react_2.ChatWidget, { ...widgetProps, inline: true, variant: widgetProps.variant ?? 'bare' })) : null);
|
|
82
|
+
return ((0, jsx_runtime_1.jsx)(VaulDrawer.Root, { open: open, onOpenChange: onOpenChange,
|
|
83
|
+
// `direction="bottom"` is the standard bottom-sheet origin.
|
|
84
|
+
direction: "bottom",
|
|
85
|
+
// No drag-to-dismiss. The visitor closes with the explicit
|
|
86
|
+
// back button in the header (or ESC). Prevents accidental
|
|
87
|
+
// dismissals when the visitor scrolls fast in the transcript.
|
|
88
|
+
dismissible: false,
|
|
89
|
+
// No native snap points — we WANT fullscreen.
|
|
90
|
+
shouldScaleBackground: false, children: (0, jsx_runtime_1.jsxs)(VaulDrawer.Portal, { children: [(0, jsx_runtime_1.jsx)(VaulDrawer.Overlay, { className: "af-drawer-overlay", style: {
|
|
91
|
+
position: 'fixed',
|
|
92
|
+
inset: 0,
|
|
93
|
+
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
94
|
+
zIndex: 2147483600,
|
|
95
|
+
} }), (0, jsx_runtime_1.jsxs)(VaulDrawer.Content, { className: `af-drawer-surface ${drawerClassName ?? ''}`, style: {
|
|
96
|
+
// Fullscreen: 100% of the visual viewport height. Vaul
|
|
97
|
+
// tracks visualViewport internally so this height
|
|
98
|
+
// shrinks when the keyboard pops up, keeping the
|
|
99
|
+
// composer always above the keys.
|
|
100
|
+
position: 'fixed',
|
|
101
|
+
inset: 0,
|
|
102
|
+
zIndex: 2147483600,
|
|
103
|
+
display: 'flex',
|
|
104
|
+
flexDirection: 'column',
|
|
105
|
+
outline: 'none',
|
|
106
|
+
backgroundColor: 'var(--af-bg, #ffffff)',
|
|
107
|
+
color: 'var(--af-fg, inherit)',
|
|
108
|
+
// Re-apply the host's theme vars on the portalled
|
|
109
|
+
// surface so the chat widget below picks them up. The
|
|
110
|
+
// `--af-bg` declared here is what the chat panel reads
|
|
111
|
+
// for its message background; without this the drawer
|
|
112
|
+
// would strobe white over a dark themed page.
|
|
113
|
+
...surfaceStyle,
|
|
114
|
+
}, children: [(0, jsx_runtime_1.jsx)(VaulDrawer.Title, { style: {
|
|
115
|
+
position: 'absolute',
|
|
116
|
+
width: '1px',
|
|
117
|
+
height: '1px',
|
|
118
|
+
padding: 0,
|
|
119
|
+
margin: '-1px',
|
|
120
|
+
overflow: 'hidden',
|
|
121
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
122
|
+
whiteSpace: 'nowrap',
|
|
123
|
+
border: 0,
|
|
124
|
+
}, children: "Chat" }), (0, jsx_runtime_1.jsxs)("div", { style: {
|
|
125
|
+
flexShrink: 0,
|
|
126
|
+
position: 'sticky',
|
|
127
|
+
top: 0,
|
|
128
|
+
zIndex: 1,
|
|
129
|
+
backgroundColor: 'var(--af-bg, #ffffff)',
|
|
130
|
+
borderBottom: '1px solid var(--af-border, rgba(15, 23, 42, 0.08))',
|
|
131
|
+
}, children: [closeButton === undefined ? ((0, jsx_runtime_1.jsx)(DefaultCloseButton, { onClose: () => onOpenChange(false) })) : (closeButton), header] }), (0, jsx_runtime_1.jsx)("div", { style: {
|
|
132
|
+
flex: 1,
|
|
133
|
+
minHeight: 0,
|
|
134
|
+
display: 'flex',
|
|
135
|
+
flexDirection: 'column',
|
|
136
|
+
}, "data-af-drawer-body": true, children: chatNode })] })] }) }));
|
|
137
|
+
}
|
|
138
|
+
function DefaultCloseButton({ onClose }) {
|
|
139
|
+
return ((0, jsx_runtime_1.jsx)("div", { style: {
|
|
140
|
+
display: 'flex',
|
|
141
|
+
alignItems: 'center',
|
|
142
|
+
padding: '8px 12px',
|
|
143
|
+
}, children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onClose, "aria-label": "Close chat", style: {
|
|
144
|
+
appearance: 'none',
|
|
145
|
+
background: 'transparent',
|
|
146
|
+
border: 'none',
|
|
147
|
+
padding: '8px',
|
|
148
|
+
margin: 0,
|
|
149
|
+
cursor: 'pointer',
|
|
150
|
+
color: 'var(--af-fg, inherit)',
|
|
151
|
+
display: 'inline-flex',
|
|
152
|
+
alignItems: 'center',
|
|
153
|
+
justifyContent: 'center',
|
|
154
|
+
borderRadius: '8px',
|
|
155
|
+
}, children: (0, jsx_runtime_1.jsx)("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: (0, jsx_runtime_1.jsx)("polyline", { points: "15 18 9 12 15 6" }) }) }) }));
|
|
277
156
|
}
|
package/dist/react.d.ts
CHANGED
|
@@ -198,26 +198,6 @@ 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;
|
|
221
201
|
}
|
|
222
202
|
/**
|
|
223
203
|
* Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
|
package/dist/react.js
CHANGED
|
@@ -268,54 +268,29 @@ 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
|
-
//
|
|
279
|
-
// collapsing, producing the jitter.
|
|
280
|
-
const focusedOnceRef = (0, react_1.useRef)(false);
|
|
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.
|
|
281
279
|
(0, react_1.useEffect)(() => {
|
|
282
|
-
if (focusedOnceRef.current)
|
|
283
|
-
return;
|
|
284
280
|
if (status !== 'ready')
|
|
285
281
|
return;
|
|
286
282
|
if (typeof window === 'undefined')
|
|
287
283
|
return;
|
|
288
|
-
|
|
289
|
-
if (!el || el.disabled)
|
|
290
|
-
return;
|
|
291
|
-
el.focus({ preventScroll: true });
|
|
292
|
-
focusedOnceRef.current = true;
|
|
284
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
293
285
|
}, [status]);
|
|
294
|
-
// ── Session lifecycle. Recreate when token / apiBaseUrl
|
|
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);
|
|
286
|
+
// ── Session lifecycle. Recreate when token / apiBaseUrl changes. ──────
|
|
312
287
|
(0, react_1.useEffect)(() => {
|
|
313
288
|
let cancelled = false;
|
|
314
289
|
const s = new session_1.ChatSession({
|
|
315
290
|
token,
|
|
316
291
|
apiBaseUrl,
|
|
317
292
|
browserSessionId,
|
|
318
|
-
resumeConversationId
|
|
293
|
+
resumeConversationId,
|
|
319
294
|
stream,
|
|
320
295
|
});
|
|
321
296
|
setSession(s);
|
|
@@ -353,10 +328,7 @@ function ChatWidget(props) {
|
|
|
353
328
|
unsubscribe();
|
|
354
329
|
s.destroy();
|
|
355
330
|
};
|
|
356
|
-
|
|
357
|
-
// is captured via resumeRef on first mount; reactive updates are
|
|
358
|
-
// ignored by design. See block comment above.
|
|
359
|
-
}, [token, apiBaseUrl, browserSessionId, stream]);
|
|
331
|
+
}, [token, apiBaseUrl, browserSessionId, resumeConversationId, stream]);
|
|
360
332
|
// Auto-scroll on new tokens.
|
|
361
333
|
//
|
|
362
334
|
// We defer the scroll into a requestAnimationFrame so the DOM has
|
|
@@ -398,14 +370,25 @@ function ChatWidget(props) {
|
|
|
398
370
|
const text = draft.trim();
|
|
399
371
|
if (!text)
|
|
400
372
|
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.
|
|
401
377
|
hasInteractedRef.current = true;
|
|
402
378
|
setDraft('');
|
|
403
|
-
// session.send awaits start() internally, so it's safe to fire
|
|
404
|
-
// even before the initial agent/theme fetch resolves. The send
|
|
405
|
-
// button's `onPointerDown preventDefault` keeps the textarea
|
|
406
|
-
// focused through tap/click, so we don't manually re-focus
|
|
407
|
-
// here (manual focus during the status flip caused a blink).
|
|
408
379
|
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
|
+
}
|
|
409
392
|
}, [session, draft]);
|
|
410
393
|
const onKeyDown = (0, react_1.useCallback)((e) => {
|
|
411
394
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
@@ -462,20 +445,6 @@ function ChatWidget(props) {
|
|
|
462
445
|
void session.send(trimmed);
|
|
463
446
|
},
|
|
464
447
|
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
|
-
},
|
|
479
448
|
};
|
|
480
449
|
return () => {
|
|
481
450
|
// Drop the handle on unmount so a stale ref can't fire send
|
|
@@ -566,31 +535,20 @@ function ChatWidget(props) {
|
|
|
566
535
|
onShortcutClick(text, i);
|
|
567
536
|
else
|
|
568
537
|
setDraft(text);
|
|
569
|
-
}, children: text }, `${i}-${text}`))) })), (0, jsx_runtime_1.jsxs)("div", { className: "af-input-row",
|
|
570
|
-
? 'Preparing chat…'
|
|
571
|
-
: inputPlaceholder ?? 'Type a message…', rows: 1,
|
|
538
|
+
}, 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,
|
|
572
539
|
// The textarea stays editable while the agent is
|
|
573
540
|
// streaming so the visitor can compose their next
|
|
574
541
|
// message without waiting. Only block when the
|
|
575
542
|
// conversation has actually ended OR before the
|
|
576
543
|
// session is ready at all.
|
|
577
544
|
disabled: status === 'ended' || status === 'loading' || status === 'idle' }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-send",
|
|
578
|
-
// `
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
// jumps to <body>, and the on-screen keyboard collapses.
|
|
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" })] })] }));
|
|
545
|
+
// `onMouseDown preventDefault` prevents the button from
|
|
546
|
+
// stealing focus from the textarea when the visitor
|
|
547
|
+
// taps Send. Without this, the focus shifts to the
|
|
548
|
+
// button just before `handleSend` runs, the button
|
|
549
|
+
// then disables (sendDisabled flips true), focus jumps
|
|
550
|
+
// to <body>, and on mobile the keyboard collapses.
|
|
551
|
+
onMouseDown: (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" })] })] }));
|
|
594
552
|
}
|
|
595
553
|
function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, avatarAgentId, speakerLabel, }) {
|
|
596
554
|
const kind = message.metadata?.kind;
|
|
@@ -763,11 +721,6 @@ function CloseIcon() {
|
|
|
763
721
|
function SendIcon() {
|
|
764
722
|
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" })] }));
|
|
765
723
|
}
|
|
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
|
-
}
|
|
771
724
|
// Stylesheet kept verbatim from the standalone widget.js so the React
|
|
772
725
|
// component is visually indistinguishable from the script-injected one.
|
|
773
726
|
const WIDGET_CSS = `
|
|
@@ -852,8 +805,6 @@ const WIDGET_CSS = `
|
|
|
852
805
|
to { opacity: 1; transform: translateY(0); }
|
|
853
806
|
}
|
|
854
807
|
.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; }
|
|
857
808
|
/* Composer left slot — hosts use this for affordance buttons that
|
|
858
809
|
scope the next turn (member picker, tools menu, attachments).
|
|
859
810
|
align-items: center keeps a single-line chip vertically centered
|
|
@@ -913,8 +864,6 @@ const WIDGET_CSS = `
|
|
|
913
864
|
.af-send:active:not(:disabled) { transform: translateY(0); }
|
|
914
865
|
.af-send:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
915
866
|
.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); } }
|
|
918
867
|
.af-error { padding: 10px 16px; font-size: 12px; color: #b91c1c; background: #fef2f2; border-top: 1px solid #fee2e2; }
|
|
919
868
|
.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); }
|
|
920
869
|
/* Approval and blocked bubbles. Amber for needs-decision, red for
|
package/dist/session.d.ts
CHANGED
|
@@ -23,13 +23,12 @@ export declare class ChatSession {
|
|
|
23
23
|
private readonly resumeId?;
|
|
24
24
|
private listeners;
|
|
25
25
|
private state;
|
|
26
|
-
private
|
|
26
|
+
private started;
|
|
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;
|
|
33
32
|
/**
|
|
34
33
|
* Mark the active conversation as ended on the server and lock the
|
|
35
34
|
* session locally. After this, sends throw — the consumer should drop
|
|
@@ -69,7 +68,6 @@ export declare class ChatSession {
|
|
|
69
68
|
private removeMessage;
|
|
70
69
|
private setStatus;
|
|
71
70
|
private emitStateChange;
|
|
72
|
-
private debug;
|
|
73
71
|
private handleError;
|
|
74
72
|
private emit;
|
|
75
73
|
}
|
package/dist/session.js
CHANGED
|
@@ -25,13 +25,7 @@ class ChatSession {
|
|
|
25
25
|
status: 'idle',
|
|
26
26
|
messages: [],
|
|
27
27
|
};
|
|
28
|
-
|
|
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;
|
|
28
|
+
this.started = false;
|
|
35
29
|
if (!opts.token)
|
|
36
30
|
throw new Error('ChatSession: token is required');
|
|
37
31
|
const apiBaseUrl = opts.apiBaseUrl ?? defaultApiBase();
|
|
@@ -55,14 +49,9 @@ class ChatSession {
|
|
|
55
49
|
}
|
|
56
50
|
// ─── Lifecycle ──────────────────────────────────────────────────────────
|
|
57
51
|
async start() {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return this.startPromise;
|
|
62
|
-
this.startPromise = this.runStart();
|
|
63
|
-
return this.startPromise;
|
|
64
|
-
}
|
|
65
|
-
async runStart() {
|
|
52
|
+
if (this.started)
|
|
53
|
+
return;
|
|
54
|
+
this.started = true;
|
|
66
55
|
this.setStatus('loading');
|
|
67
56
|
try {
|
|
68
57
|
const { agent, theme } = await this.transport.getAgent();
|
|
@@ -152,7 +141,7 @@ class ChatSession {
|
|
|
152
141
|
this.emit({ type: 'destroyed' });
|
|
153
142
|
// Drop everything — a destroyed session shouldn't be reused.
|
|
154
143
|
this.state = { status: 'idle', messages: [] };
|
|
155
|
-
this.
|
|
144
|
+
this.started = false;
|
|
156
145
|
}
|
|
157
146
|
// ─── User actions ───────────────────────────────────────────────────────
|
|
158
147
|
/**
|
|
@@ -172,16 +161,11 @@ class ChatSession {
|
|
|
172
161
|
const trimmed = text.trim();
|
|
173
162
|
if (!trimmed)
|
|
174
163
|
return '';
|
|
175
|
-
this.debug('send() called', { text: trimmed, status: this.state.status });
|
|
176
164
|
if (this.state.status === 'ended') {
|
|
177
165
|
throw new Error('Conversation has ended. Start a fresh chat to continue.');
|
|
178
166
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// where the user taps Send while the initial agent/theme
|
|
182
|
-
// fetch is still in flight and conversationId is unset.
|
|
183
|
-
await this.start();
|
|
184
|
-
this.debug('send() start awaited, status=' + this.state.status);
|
|
167
|
+
if (!this.started)
|
|
168
|
+
await this.start();
|
|
185
169
|
if (!opts?.silent) {
|
|
186
170
|
this.appendMessage({
|
|
187
171
|
id: makeMessageId('u'),
|
|
@@ -205,7 +189,6 @@ class ChatSession {
|
|
|
205
189
|
}
|
|
206
190
|
// ─── Internals ──────────────────────────────────────────────────────────
|
|
207
191
|
async runStream(text, assistant) {
|
|
208
|
-
this.debug('runStream start', { hasConvId: !!this.state.conversationId });
|
|
209
192
|
this.setStatus('sending');
|
|
210
193
|
// The "active" message is the one we're currently appending
|
|
211
194
|
// text_deltas into. For non-team sessions it's always the initial
|
|
@@ -224,11 +207,8 @@ class ChatSession {
|
|
|
224
207
|
const generator = this.state.conversationId
|
|
225
208
|
? this.transport.streamSendMessage(this.state.conversationId, text, this.browserSessionId)
|
|
226
209
|
: this.transport.streamCreateConversation(text, this.browserSessionId);
|
|
227
|
-
this.debug('runStream got generator, awaiting events');
|
|
228
210
|
let sawAnyChunk = false;
|
|
229
|
-
let chunkCount = 0;
|
|
230
211
|
for await (const evt of generator) {
|
|
231
|
-
this.debug('SSE evt', evt.kind, chunkCount++);
|
|
232
212
|
if (evt.kind === 'conversation') {
|
|
233
213
|
this.state.conversationId = evt.id;
|
|
234
214
|
this.emit({ type: 'conversation_started', conversationId: evt.id });
|
|
@@ -404,7 +384,6 @@ class ChatSession {
|
|
|
404
384
|
cleanup(assistant);
|
|
405
385
|
if (active !== assistant)
|
|
406
386
|
cleanup(active);
|
|
407
|
-
this.debug('runStream THREW', err instanceof Error ? err.message : String(err));
|
|
408
387
|
this.handleError(err);
|
|
409
388
|
return assistant.content;
|
|
410
389
|
}
|
|
@@ -465,50 +444,6 @@ class ChatSession {
|
|
|
465
444
|
emitStateChange() {
|
|
466
445
|
this.emit({ type: 'state', state: this.getState() });
|
|
467
446
|
}
|
|
468
|
-
// Lightweight debug logger that BOTH logs to console AND appends
|
|
469
|
-
// to a visible on-screen overlay so mobile QA without remote
|
|
470
|
-
// devtools can still see what's happening. Enable by running
|
|
471
|
-
// `localStorage.setItem('af-chat-debug', '1')` in the browser
|
|
472
|
-
// console (or via the URL `?af-debug=1` once mounted) and
|
|
473
|
-
// reloading. Disabled by default so production ships clean.
|
|
474
|
-
debug(...args) {
|
|
475
|
-
try {
|
|
476
|
-
if (typeof window === 'undefined')
|
|
477
|
-
return;
|
|
478
|
-
const ls = window.localStorage;
|
|
479
|
-
let enabled = ls?.getItem('af-chat-debug') === '1';
|
|
480
|
-
if (!enabled && window.location?.search?.includes('af-debug=1')) {
|
|
481
|
-
ls?.setItem('af-chat-debug', '1');
|
|
482
|
-
enabled = true;
|
|
483
|
-
}
|
|
484
|
-
if (!enabled)
|
|
485
|
-
return;
|
|
486
|
-
const msg = args
|
|
487
|
-
.map((a) => (typeof a === 'string' ? a : JSON.stringify(a)))
|
|
488
|
-
.join(' ');
|
|
489
|
-
// eslint-disable-next-line no-console
|
|
490
|
-
console.log('[chat-sdk]', msg);
|
|
491
|
-
// Visible overlay (single shared element across all sessions).
|
|
492
|
-
let panel = document.getElementById('af-debug-panel');
|
|
493
|
-
if (!panel) {
|
|
494
|
-
panel = document.createElement('div');
|
|
495
|
-
panel.id = 'af-debug-panel';
|
|
496
|
-
panel.style.cssText =
|
|
497
|
-
'position:fixed;top:0;left:0;right:0;max-height:38vh;overflow:auto;' +
|
|
498
|
-
'background:rgba(0,0,0,0.85);color:#7CFC00;font:11px/1.3 monospace;' +
|
|
499
|
-
'padding:6px 8px;z-index:2147483647;pointer-events:auto;' +
|
|
500
|
-
'white-space:pre-wrap;word-break:break-all;';
|
|
501
|
-
document.body.appendChild(panel);
|
|
502
|
-
}
|
|
503
|
-
const line = document.createElement('div');
|
|
504
|
-
line.textContent = `${new Date().toISOString().slice(11, 23)} ${msg}`;
|
|
505
|
-
panel.appendChild(line);
|
|
506
|
-
panel.scrollTop = panel.scrollHeight;
|
|
507
|
-
}
|
|
508
|
-
catch {
|
|
509
|
-
/* localStorage can throw in private mode — ignore */
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
447
|
handleError(err) {
|
|
513
448
|
const message = err instanceof Error ? err.message : String(err);
|
|
514
449
|
const code = err && typeof err === 'object' && 'code' in err
|
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.2",
|
|
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",
|
|
@@ -24,17 +24,23 @@
|
|
|
24
24
|
"clean": "rm -rf dist *.tgz"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
|
-
"react": ">=18.0.0"
|
|
27
|
+
"react": ">=18.0.0",
|
|
28
|
+
"vaul": ">=1.0.0"
|
|
28
29
|
},
|
|
29
30
|
"peerDependenciesMeta": {
|
|
30
31
|
"react": {
|
|
31
32
|
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"vaul": {
|
|
35
|
+
"optional": true
|
|
32
36
|
}
|
|
33
37
|
},
|
|
34
38
|
"devDependencies": {
|
|
35
39
|
"@types/node": "^20.0.0",
|
|
36
40
|
"@types/react": "^18.3.28",
|
|
37
41
|
"react": "^18.3.1",
|
|
38
|
-
"
|
|
42
|
+
"react-dom": "^18.3.1",
|
|
43
|
+
"typescript": "^5.0.0",
|
|
44
|
+
"vaul": "^1.1.2"
|
|
39
45
|
}
|
|
40
46
|
}
|