@agentforge-io/chat-sdk 2.2.0 → 2.3.0

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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `<ChatDrawer>` — standard bottom-sheet wrapper around `<ChatWidget>`.
3
+ *
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.
9
+ *
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.
30
+ *
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)`.
39
+ */
40
+ import { type ReactNode } from 'react';
41
+ import { type ChatWidgetProps } from './react';
42
+ /**
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.
58
+ */
59
+ type ChatSurface = {
60
+ widgetProps: ChatWidgetProps;
61
+ chatSlot?: never;
62
+ } | {
63
+ chatSlot: ReactNode;
64
+ widgetProps?: never;
65
+ };
66
+ export type ChatDrawerProps = ChatSurface & {
67
+ /** Whether the drawer is visible. Controlled. */
68
+ open: boolean;
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`. */
71
+ onOpenChange: (open: boolean) => void;
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. */
80
+ header?: ReactNode;
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. */
88
+ drawerClassName?: string;
89
+ };
90
+ export declare function ChatDrawer(props: ChatDrawerProps): JSX.Element | null;
91
+ export {};
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChatDrawer = ChatDrawer;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ /**
6
+ * `<ChatDrawer>` — standard bottom-sheet wrapper around `<ChatWidget>`.
7
+ *
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.
13
+ *
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.
34
+ *
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)`.
43
+ */
44
+ const react_1 = require("react");
45
+ const react_dom_1 = require("react-dom");
46
+ const react_2 = require("./react");
47
+ function ChatDrawer(props) {
48
+ const { open, onOpenChange, snap = 0.98, header, closeOnBackdropClick = true, drawerClassName, widgetProps, chatSlot, } = props;
49
+ // We mount once and keep alive across close→reopen so the chat session
50
+ // doesn't get destroyed. After the FIRST open the panel stays in the
51
+ // DOM forever (toggled by `display:none`) — `hasOpened` gates the
52
+ // initial mount so SSR doesn't render an empty portal.
53
+ const [hasOpened, setHasOpened] = (0, react_1.useState)(open);
54
+ (0, react_1.useEffect)(() => {
55
+ if (open)
56
+ setHasOpened(true);
57
+ }, [open]);
58
+ // Visible viewport tracking. iOS Safari + Android Chrome shrink
59
+ // `visualViewport.height` when the on-screen keyboard pops up; the
60
+ // layout viewport stays the same. We pin the drawer's height + bottom
61
+ // to the visual viewport so the composer is always above the keyboard.
62
+ const [vv, setVv] = (0, react_1.useState)(() => ({
63
+ h: typeof window === 'undefined' ? 0 : window.innerHeight,
64
+ offsetTop: 0,
65
+ }));
66
+ (0, react_1.useEffect)(() => {
67
+ if (typeof window === 'undefined')
68
+ return;
69
+ const view = window.visualViewport;
70
+ if (!view)
71
+ return;
72
+ const update = () => setVv({ h: view.height, offsetTop: view.offsetTop });
73
+ update();
74
+ view.addEventListener('resize', update);
75
+ view.addEventListener('scroll', update);
76
+ return () => {
77
+ view.removeEventListener('resize', update);
78
+ view.removeEventListener('scroll', update);
79
+ };
80
+ }, []);
81
+ // Snap-point height: % of the visible viewport. Recomputed when vv
82
+ // changes so a keyboard popup keeps the drawer aligned.
83
+ const drawerHeight = Math.max(0, Math.round(vv.h * Math.min(Math.max(snap, 0.1), 1)));
84
+ // Drag-to-dismiss. Vanilla touch handlers — no library. We track
85
+ // pointerdown on the handle, follow movement on pointermove, and
86
+ // decide on pointerup whether the drag crossed the "dismiss" threshold
87
+ // (30% of the panel height).
88
+ const surfaceRef = (0, react_1.useRef)(null);
89
+ const dragStateRef = (0, react_1.useRef)(null);
90
+ const [dragOffset, setDragOffset] = (0, react_1.useState)(0);
91
+ const onHandlePointerDown = (0, react_1.useCallback)((e) => {
92
+ if (e.button !== 0 && e.pointerType !== 'touch')
93
+ return;
94
+ e.currentTarget.setPointerCapture?.(e.pointerId);
95
+ dragStateRef.current = {
96
+ startY: e.clientY,
97
+ startTime: Date.now(),
98
+ dragging: true,
99
+ };
100
+ }, []);
101
+ const onHandlePointerMove = (0, react_1.useCallback)((e) => {
102
+ const s = dragStateRef.current;
103
+ if (!s?.dragging)
104
+ return;
105
+ const delta = Math.max(0, e.clientY - s.startY);
106
+ setDragOffset(delta);
107
+ }, []);
108
+ const onHandlePointerUp = (0, react_1.useCallback)((e) => {
109
+ const s = dragStateRef.current;
110
+ if (!s?.dragging)
111
+ return;
112
+ dragStateRef.current = null;
113
+ try {
114
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
115
+ }
116
+ catch {
117
+ // Pointer was already released by some other code path; nothing
118
+ // to do here. Releasing a non-captured pointer throws — swallow.
119
+ }
120
+ const delta = Math.max(0, e.clientY - s.startY);
121
+ const dismissThreshold = drawerHeight * 0.3;
122
+ const elapsed = Date.now() - s.startTime;
123
+ // Dismiss on EITHER a long-drag (past the threshold) or a quick
124
+ // flick (small distance but high velocity). Velocity unit is
125
+ // px/ms; >0.5 is roughly the threshold iOS sheets use.
126
+ const velocity = elapsed > 0 ? delta / elapsed : 0;
127
+ if (delta > dismissThreshold || velocity > 0.5) {
128
+ onOpenChange(false);
129
+ }
130
+ setDragOffset(0);
131
+ }, [drawerHeight, onOpenChange]);
132
+ // Lock body scroll while the drawer is open so the page underneath
133
+ // doesn't move when the visitor scrolls inside the chat. Restored on
134
+ // close. Necessary on iOS Safari where the rubber-band scroll bleeds
135
+ // through to the body even with overflow:hidden on a child.
136
+ (0, react_1.useEffect)(() => {
137
+ if (typeof document === 'undefined')
138
+ return;
139
+ if (!open)
140
+ return;
141
+ const prev = document.body.style.overflow;
142
+ document.body.style.overflow = 'hidden';
143
+ return () => {
144
+ document.body.style.overflow = prev;
145
+ };
146
+ }, [open]);
147
+ // Escape closes the drawer. Keyboard-friendly even when focus is in
148
+ // the textarea — preventDefault on the input itself swallows Escape
149
+ // before it bubbles, so we use capture phase.
150
+ (0, react_1.useEffect)(() => {
151
+ if (!open)
152
+ return;
153
+ if (typeof window === 'undefined')
154
+ return;
155
+ const onKey = (e) => {
156
+ if (e.key === 'Escape')
157
+ onOpenChange(false);
158
+ };
159
+ window.addEventListener('keydown', onKey, true);
160
+ return () => window.removeEventListener('keydown', onKey, true);
161
+ }, [open, onOpenChange]);
162
+ // Lazy portal target. We render to `document.body` so the drawer
163
+ // overlays everything regardless of the consumer's stacking context.
164
+ const [portalEl, setPortalEl] = (0, react_1.useState)(null);
165
+ (0, react_1.useLayoutEffect)(() => {
166
+ if (typeof document === 'undefined')
167
+ return;
168
+ setPortalEl(document.body);
169
+ }, []);
170
+ // Stable id so the drag handle's `aria-controls` can point at the
171
+ // panel. Required for screen readers to announce the relationship.
172
+ const panelId = (0, react_1.useId)();
173
+ if (!portalEl)
174
+ return null;
175
+ if (!hasOpened)
176
+ return null;
177
+ // The translate3d ensures the drawer animates from below on first
178
+ // open (initial transform = 100%, becomes 0% after the open prop
179
+ // flips). When dragging we add the manual drag offset on top.
180
+ const translateY = open ? `${dragOffset}px` : `${drawerHeight}px`;
181
+ // Transition disabled WHILE dragging so the surface follows the
182
+ // finger 1:1. Re-enabled at the end of the drag (delta back to 0
183
+ // smoothly when below threshold).
184
+ const isDragging = dragStateRef.current?.dragging === true;
185
+ const surfaceStyle = {
186
+ height: `${drawerHeight}px`,
187
+ transform: `translate3d(0, ${translateY}, 0)`,
188
+ bottom: `${vv.offsetTop}px`,
189
+ transition: isDragging ? 'none' : 'transform 240ms cubic-bezier(.32,.72,0,1)',
190
+ willChange: 'transform',
191
+ };
192
+ return (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsxs)("div", { className: "af-drawer-root", "data-state": open ? 'open' : 'closed', style: {
193
+ position: 'fixed',
194
+ inset: 0,
195
+ zIndex: 2147483600,
196
+ pointerEvents: open ? 'auto' : 'none',
197
+ }, children: [(0, jsx_runtime_1.jsx)("div", { className: "af-drawer-backdrop", onClick: closeOnBackdropClick ? () => onOpenChange(false) : undefined, style: {
198
+ position: 'absolute',
199
+ inset: 0,
200
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
201
+ opacity: open ? 1 : 0,
202
+ transition: 'opacity 200ms ease-out',
203
+ }, "aria-hidden": true }), (0, jsx_runtime_1.jsxs)("div", { ref: surfaceRef, id: panelId, role: "dialog", "aria-modal": "true", className: `af-drawer-surface ${drawerClassName ?? ''}`, style: {
204
+ position: 'absolute',
205
+ left: 0,
206
+ right: 0,
207
+ width: '100%',
208
+ backgroundColor: 'var(--af-bg, #ffffff)',
209
+ borderTopLeftRadius: '16px',
210
+ borderTopRightRadius: '16px',
211
+ boxShadow: '0 -8px 24px rgba(15, 23, 42, 0.15)',
212
+ display: 'flex',
213
+ flexDirection: 'column',
214
+ overflow: 'hidden',
215
+ ...surfaceStyle,
216
+ }, children: [(0, jsx_runtime_1.jsx)("div", { onPointerDown: onHandlePointerDown, onPointerMove: onHandlePointerMove, onPointerUp: onHandlePointerUp, onPointerCancel: onHandlePointerUp, style: {
217
+ padding: '10px 0',
218
+ cursor: 'grab',
219
+ touchAction: 'none',
220
+ display: 'flex',
221
+ justifyContent: 'center',
222
+ flexShrink: 0,
223
+ }, "aria-label": "Drag to close", children: (0, jsx_runtime_1.jsx)("span", { style: {
224
+ width: '40px',
225
+ height: '4px',
226
+ borderRadius: '999px',
227
+ backgroundColor: 'var(--af-muted, rgba(100, 116, 139, 0.45))',
228
+ display: 'block',
229
+ } }) }), 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);
230
+ }
package/dist/react.d.ts CHANGED
@@ -204,3 +204,5 @@ export interface ChatWidgetHandle {
204
204
  * SDK event. Consumers don't need to read `session.getState()` themselves.
205
205
  */
206
206
  export declare function ChatWidget(props: ChatWidgetProps): JSX.Element;
207
+ export { ChatDrawer } from './ChatDrawer';
208
+ export type { ChatDrawerProps } from './ChatDrawer';
package/dist/react.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChatDrawer = void 0;
3
4
  exports.ChatWidget = ChatWidget;
4
5
  const jsx_runtime_1 = require("react/jsx-runtime");
5
6
  /**
@@ -1269,3 +1270,9 @@ const WIDGET_CSS = `
1269
1270
  }
1270
1271
  }
1271
1272
  `;
1273
+ // ─── Re-exports ───────────────────────────────────────────────────────
1274
+ // Bottom-sheet wrapper around the widget. Hosts that need the full
1275
+ // drawer UX (98% snap, visualViewport-aware composer, drag-to-dismiss)
1276
+ // import this from `@agentforge-io/chat-sdk/react` next to ChatWidget.
1277
+ var ChatDrawer_1 = require("./ChatDrawer");
1278
+ Object.defineProperty(exports, "ChatDrawer", { enumerable: true, get: function () { return ChatDrawer_1.ChatDrawer; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
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",