@agentforge-io/chat-sdk 2.4.0-dev.0 → 2.4.0-dev.10

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.
@@ -1,40 +1,60 @@
1
1
  /**
2
- * `<ChatDrawer>` — standard mobile chat shell.
2
+ * `<ChatDrawer>` — standard bottom-sheet wrapper around `<ChatWidget>`.
3
3
  *
4
- * Mobile-first fullscreen modal that wraps `<ChatWidget>` and gets the
5
- * three pieces of mobile UX every embed needs right:
4
+ * The host gives us:
5
+ * - `open` / `onOpenChange`: controlled visibility (host owns the route /
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.
6
9
  *
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.
10
+ * What the drawer adds on top:
11
+ * - Mounts via React portal (`document.body`) so it overlays the page
12
+ * regardless of the host's stacking context. Lazy-mounted: the portal
13
+ * target is computed at first open so SSR stays clean.
14
+ * - Visually pinned to the bottom of the *visual viewport* (not the
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.
12
30
  *
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.
31
+ * What the drawer does NOT do (deliberately):
32
+ * - It doesn't sync with the URL. The host decides whether `?view=chat`,
33
+ * `/chat`, or any other route shape opens it.
34
+ * - It doesn't manage the chat session lifecycle. That's `<ChatWidget>`'s
35
+ * job. We just provide presentation.
36
+ * - It doesn't render a "fake composer" to trigger opening. The host
37
+ * decides what trigger UX makes sense (button, input pill, FAB, etc.)
38
+ * and calls `onOpenChange(true)`.
31
39
  */
32
40
  import { type CSSProperties, type ReactNode } from 'react';
33
41
  import { type ChatWidgetProps } from './react';
34
42
  /**
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.
43
+ * Drawer accepts the chat surface in two shapes:
44
+ *
45
+ * - `widgetProps`: pass the ChatWidget configuration and the drawer
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.
38
58
  */
39
59
  type ChatSurface = {
40
60
  widgetProps: ChatWidgetProps;
@@ -44,24 +64,39 @@ type ChatSurface = {
44
64
  widgetProps?: never;
45
65
  };
46
66
  export type ChatDrawerProps = ChatSurface & {
47
- /** Controlled visibility. */
67
+ /** Whether the drawer is visible. Controlled. */
48
68
  open: boolean;
49
- /** Fired when the drawer wants to close (back button, ESC). The
50
- * host is responsible for setting `open=false`. */
69
+ /** Fired when the drawer wants to close (drag-to-dismiss, tap on the
70
+ * backdrop, close button). Host is responsible for setting `open=false`. */
51
71
  onOpenChange: (open: boolean) => void;
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. */
72
+ /** Snap fraction of the visible viewport, 0–1. Defaults to 0.98 (98% =
73
+ * the standard "near-fullscreen drawer that still hints at the page
74
+ * below" pattern). Lower numbers leave more of the page visible. */
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. */
55
80
  header?: ReactNode;
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). */
81
+ /** Backdrop click closes the drawer. Default true set false if the
82
+ * host wants a "modal" feel where the only escape is the close button
83
+ * or the drag-down gesture. */
84
+ closeOnBackdropClick?: boolean;
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. */
60
88
  drawerClassName?: string;
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. */
89
+ /** Inline style applied to the drawer surface — typically the host's
90
+ * bundle of `--af-bg`, `--af-fg`, `--af-bubble-*`, etc. CSS vars.
91
+ * Because the drawer renders into a portal (`document.body`), any
92
+ * vars declared on the host's page wrapper DON'T cascade in. The
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. */
65
100
  surfaceStyle?: CSSProperties & Record<string, string>;
66
101
  };
67
102
  export declare function ChatDrawer(props: ChatDrawerProps): JSX.Element | null;
@@ -3,59 +3,143 @@ 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 mobile chat shell.
6
+ * `<ChatDrawer>` — standard bottom-sheet wrapper around `<ChatWidget>`.
7
7
  *
8
- * Mobile-first fullscreen modal that wraps `<ChatWidget>` and gets the
9
- * three pieces of mobile UX every embed needs right:
8
+ * The host gives us:
9
+ * - `open` / `onOpenChange`: controlled visibility (host owns the route /
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.
10
13
  *
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.
14
+ * What the drawer adds on top:
15
+ * - Mounts via React portal (`document.body`) so it overlays the page
16
+ * regardless of the host's stacking context. Lazy-mounted: the portal
17
+ * target is computed at first open so SSR stays clean.
18
+ * - Visually pinned to the bottom of the *visual viewport* (not the
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.
16
34
  *
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.
35
+ * What the drawer does NOT do (deliberately):
36
+ * - It doesn't sync with the URL. The host decides whether `?view=chat`,
37
+ * `/chat`, or any other route shape opens it.
38
+ * - It doesn't manage the chat session lifecycle. That's `<ChatWidget>`'s
39
+ * job. We just provide presentation.
40
+ * - It doesn't render a "fake composer" to trigger opening. The host
41
+ * decides what trigger UX makes sense (button, input pill, FAB, etc.)
42
+ * and calls `onOpenChange(true)`.
35
43
  */
36
44
  const react_1 = require("react");
45
+ const react_dom_1 = require("react-dom");
37
46
  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
- }
53
47
  function ChatDrawer(props) {
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.
48
+ const { open, onOpenChange, snap = 1, header, closeOnBackdropClick = true, drawerClassName, surfaceStyle: surfaceStyleProp, widgetProps, chatSlot, } = props;
49
+ // `fullscreen` mode: snap=1 (the default). The drawer occupies the
50
+ // entire visible viewport. We disable drag-to-dismiss, the backdrop,
51
+ // the rounded top corners, and the drag handle in this mode — they
52
+ // exist purely for the bottom-sheet form factor (snap<1) where the
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.
59
143
  (0, react_1.useEffect)(() => {
60
144
  if (typeof document === 'undefined')
61
145
  return;
@@ -67,90 +151,127 @@ function ChatDrawer(props) {
67
151
  document.body.style.overflow = prev;
68
152
  };
69
153
  }, [open]);
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.');
78
- }
154
+ // Escape closes the drawer. Keyboard-friendly even when focus is in
155
+ // the textarea preventDefault on the input itself swallows Escape
156
+ // before it bubbles, so we use capture phase.
157
+ (0, react_1.useEffect)(() => {
158
+ if (!open)
159
+ return;
160
+ if (typeof window === 'undefined')
161
+ return;
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)
79
181
  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 ?? (0, jsx_runtime_1.jsx)(DefaultCloseButton, { onClose: () => onOpenChange(false) }), 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" }) }) }) }));
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',
217
+ }
218
+ : {
219
+ height: `${drawerHeight}px`,
220
+ transform: `translate3d(0, ${translateY}, 0)`,
221
+ bottom: `${vv.offsetTop}px`,
222
+ transition: isDragging
223
+ ? 'none'
224
+ : 'transform 240ms cubic-bezier(.32,.72,0,1)',
225
+ willChange: 'transform',
226
+ };
227
+ return (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsxs)("div", { className: "af-drawer-root", "data-state": open ? 'open' : 'closed', style: {
228
+ position: 'fixed',
229
+ inset: 0,
230
+ zIndex: 2147483600,
231
+ pointerEvents: open ? 'auto' : 'none',
232
+ }, children: [!fullscreen && ((0, jsx_runtime_1.jsx)("div", { className: "af-drawer-backdrop", onClick: closeOnBackdropClick ? () => onOpenChange(false) : undefined, style: {
233
+ position: 'absolute',
234
+ inset: 0,
235
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
236
+ opacity: open ? 1 : 0,
237
+ transition: 'opacity 200ms ease-out',
238
+ }, "aria-hidden": true })), (0, jsx_runtime_1.jsxs)("div", { ref: surfaceRef, id: panelId, role: "dialog", "aria-modal": "true", className: `af-drawer-surface ${drawerClassName ?? ''}`, style: {
239
+ position: 'absolute',
240
+ left: 0,
241
+ right: 0,
242
+ width: '100%',
243
+ // Host supplies the CSS vars via `surfaceStyle`; the
244
+ // backgroundColor resolves from `--af-bg` (which the host
245
+ // declares in that same prop). When `surfaceStyle` is
246
+ // omitted we fall back to white — passable for an unthemed
247
+ // demo, wrong for a themed embed; passing surfaceStyle is
248
+ // the correct path.
249
+ backgroundColor: 'var(--af-bg, #ffffff)',
250
+ color: 'var(--af-fg, inherit)',
251
+ // Bottom-sheet visuals only — fullscreen mode is flat.
252
+ borderTopLeftRadius: fullscreen ? 0 : '16px',
253
+ borderTopRightRadius: fullscreen ? 0 : '16px',
254
+ boxShadow: fullscreen ? 'none' : '0 -8px 24px rgba(15, 23, 42, 0.25)',
255
+ display: 'flex',
256
+ flexDirection: 'column',
257
+ overflow: 'hidden',
258
+ // Host-supplied theme vars come FIRST so the motion
259
+ // properties below override any conflicting `transform` /
260
+ // `height` declarations a careless host might pass.
261
+ ...surfaceStyleProp,
262
+ ...surfaceMotionStyle,
263
+ }, children: [!fullscreen && ((0, jsx_runtime_1.jsx)("div", { onPointerDown: onHandlePointerDown, onPointerMove: onHandlePointerMove, onPointerUp: onHandlePointerUp, onPointerCancel: onHandlePointerUp, style: {
264
+ padding: '10px 0',
265
+ cursor: 'grab',
266
+ touchAction: 'none',
267
+ display: 'flex',
268
+ justifyContent: 'center',
269
+ flexShrink: 0,
270
+ }, "aria-label": "Drag to close", children: (0, jsx_runtime_1.jsx)("span", { style: {
271
+ width: '40px',
272
+ height: '4px',
273
+ borderRadius: '999px',
274
+ backgroundColor: 'var(--af-muted, rgba(100, 116, 139, 0.45))',
275
+ display: 'block',
276
+ } }) })), header, (0, jsx_runtime_1.jsx)("div", { style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }, children: chatSlot ?? (widgetProps ? ((0, jsx_runtime_1.jsx)(react_2.ChatWidget, { ...widgetProps, inline: true, variant: widgetProps.variant ?? 'bare' })) : null) })] })] }), portalEl);
156
277
  }
package/dist/react.d.ts CHANGED
@@ -198,6 +198,13 @@ 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;
201
208
  }
202
209
  /**
203
210
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
package/dist/react.js CHANGED
@@ -268,28 +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.
272
- //
273
- // Landing (hasInteractedRef.current === false):
274
- // pointer:fine → focus (desktop user expects ready-to-type)
275
- // pointer:coarse skip (iOS/Android opening the keyboard
276
- // before the visitor reads anything
277
- // is jarring)
278
- //
279
- // Returning from a send (hasInteractedRef.current === true):
280
- // • always focus, including touch — the user just hit Send,
281
- // they're in conversation mode, their next thought is the
282
- // next message, the keyboard should 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);
283
281
  (0, react_1.useEffect)(() => {
282
+ if (focusedOnceRef.current)
283
+ return;
284
284
  if (status !== 'ready')
285
285
  return;
286
286
  if (typeof window === 'undefined')
287
287
  return;
288
- if (!hasInteractedRef.current &&
289
- !window.matchMedia('(pointer: fine)').matches) {
288
+ const el = inputRef.current;
289
+ if (!el || el.disabled)
290
290
  return;
291
- }
292
- inputRef.current?.focus({ preventScroll: true });
291
+ el.focus({ preventScroll: true });
292
+ focusedOnceRef.current = true;
293
293
  }, [status]);
294
294
  // ── Session lifecycle. Recreate when token / apiBaseUrl changes. ──────
295
295
  (0, react_1.useEffect)(() => {
@@ -378,12 +378,13 @@ function ChatWidget(props) {
378
378
  const text = draft.trim();
379
379
  if (!text)
380
380
  return;
381
- // Mark engagement BEFORE the status flip so the auto-focus
382
- // effect (which watches `status`) sees the flag when it
383
- // re-runs after `ready` returns. Subsequent renders will
384
- // refocus the textarea on every send completion.
385
381
  hasInteractedRef.current = true;
386
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).
387
388
  void session.send(text);
388
389
  }, [session, draft]);
389
390
  const onKeyDown = (0, react_1.useCallback)((e) => {
@@ -441,6 +442,12 @@ function ChatWidget(props) {
441
442
  void session.send(trimmed);
442
443
  },
443
444
  insertText,
445
+ focus: () => {
446
+ const el = inputRef.current;
447
+ if (!el || el.disabled)
448
+ return;
449
+ el.focus({ preventScroll: true });
450
+ },
444
451
  };
445
452
  return () => {
446
453
  // Drop the handle on unmount so a stale ref can't fire send
@@ -531,7 +538,22 @@ function ChatWidget(props) {
531
538
  onShortcutClick(text, i);
532
539
  else
533
540
  setDraft(text);
534
- }, 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" })] })] }));
541
+ }, 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,
542
+ // The textarea stays editable while the agent is
543
+ // streaming so the visitor can compose their next
544
+ // message without waiting. Only block when the
545
+ // conversation has actually ended OR before the
546
+ // session is ready at all.
547
+ disabled: status === 'ended' || status === 'loading' || status === 'idle' }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-send",
548
+ // `onPointerDown preventDefault` is the single cross-
549
+ // device guarantee that the button never steals focus
550
+ // from the textarea. PointerEvents cover touch, mouse,
551
+ // AND pen — `onMouseDown` alone misses mobile touch.
552
+ // Without this, the focus shifts to the button just
553
+ // before `handleSend` runs, the button then disables
554
+ // (sendDisabled flips true on status change), focus
555
+ // jumps to <body>, and the on-screen keyboard collapses.
556
+ 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" })] })] }));
535
557
  }
536
558
  function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, avatarAgentId, speakerLabel, }) {
537
559
  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 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
@@ -68,6 +69,7 @@ export declare class ChatSession {
68
69
  private removeMessage;
69
70
  private setStatus;
70
71
  private emitStateChange;
72
+ private debug;
71
73
  private handleError;
72
74
  private emit;
73
75
  }
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
  /**
@@ -161,11 +172,16 @@ class ChatSession {
161
172
  const trimmed = text.trim();
162
173
  if (!trimmed)
163
174
  return '';
175
+ this.debug('send() called', { text: trimmed, status: this.state.status });
164
176
  if (this.state.status === 'ended') {
165
177
  throw new Error('Conversation has ended. Start a fresh chat to continue.');
166
178
  }
167
- if (!this.started)
168
- await this.start();
179
+ // Always await start — start() is idempotent and resolves
180
+ // immediately if it already completed. This closes the race
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);
169
185
  if (!opts?.silent) {
170
186
  this.appendMessage({
171
187
  id: makeMessageId('u'),
@@ -189,6 +205,7 @@ class ChatSession {
189
205
  }
190
206
  // ─── Internals ──────────────────────────────────────────────────────────
191
207
  async runStream(text, assistant) {
208
+ this.debug('runStream start', { hasConvId: !!this.state.conversationId });
192
209
  this.setStatus('sending');
193
210
  // The "active" message is the one we're currently appending
194
211
  // text_deltas into. For non-team sessions it's always the initial
@@ -207,8 +224,11 @@ class ChatSession {
207
224
  const generator = this.state.conversationId
208
225
  ? this.transport.streamSendMessage(this.state.conversationId, text, this.browserSessionId)
209
226
  : this.transport.streamCreateConversation(text, this.browserSessionId);
227
+ this.debug('runStream got generator, awaiting events');
210
228
  let sawAnyChunk = false;
229
+ let chunkCount = 0;
211
230
  for await (const evt of generator) {
231
+ this.debug('SSE evt', evt.kind, chunkCount++);
212
232
  if (evt.kind === 'conversation') {
213
233
  this.state.conversationId = evt.id;
214
234
  this.emit({ type: 'conversation_started', conversationId: evt.id });
@@ -384,6 +404,7 @@ class ChatSession {
384
404
  cleanup(assistant);
385
405
  if (active !== assistant)
386
406
  cleanup(active);
407
+ this.debug('runStream THREW', err instanceof Error ? err.message : String(err));
387
408
  this.handleError(err);
388
409
  return assistant.content;
389
410
  }
@@ -444,6 +465,50 @@ class ChatSession {
444
465
  emitStateChange() {
445
466
  this.emit({ type: 'state', state: this.getState() });
446
467
  }
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
+ }
447
512
  handleError(err) {
448
513
  const message = err instanceof Error ? err.message : String(err);
449
514
  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.0",
3
+ "version": "2.4.0-dev.10",
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,23 +24,17 @@
24
24
  "clean": "rm -rf dist *.tgz"
25
25
  },
26
26
  "peerDependencies": {
27
- "react": ">=18.0.0",
28
- "vaul": ">=1.0.0"
27
+ "react": ">=18.0.0"
29
28
  },
30
29
  "peerDependenciesMeta": {
31
30
  "react": {
32
31
  "optional": true
33
- },
34
- "vaul": {
35
- "optional": true
36
32
  }
37
33
  },
38
34
  "devDependencies": {
39
35
  "@types/node": "^20.0.0",
40
36
  "@types/react": "^18.3.28",
41
37
  "react": "^18.3.1",
42
- "react-dom": "^18.3.1",
43
- "typescript": "^5.0.0",
44
- "vaul": "^1.1.2"
38
+ "typescript": "^5.0.0"
45
39
  }
46
40
  }