@agentforge-io/chat-sdk 2.4.0-dev.12 → 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.
@@ -1,60 +1,40 @@
1
1
  /**
2
- * `<ChatDrawer>` — standard bottom-sheet wrapper around `<ChatWidget>`.
2
+ * `<ChatDrawer>` — standard mobile chat shell.
3
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.
4
+ * Mobile-first fullscreen modal that wraps `<ChatWidget>` and gets the
5
+ * three pieces of mobile UX every embed needs right:
9
6
  *
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.
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
- * 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)`.
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
- * 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.
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
- /** Whether the drawer is visible. Controlled. */
47
+ /** Controlled visibility. */
68
48
  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`. */
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
- /** 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. */
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
- /** 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. */
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
- /** 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. */
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;
@@ -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 bottom-sheet wrapper around `<ChatWidget>`.
6
+ * `<ChatDrawer>` — standard mobile chat shell.
7
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.
8
+ * Mobile-first fullscreen modal that wraps `<ChatWidget>` and gets the
9
+ * three pieces of mobile UX every embed needs right:
13
10
  *
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.
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
- * 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)`.
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, 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.
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
- // 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)
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
- 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);
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,28 +268,20 @@ function ChatWidget(props) {
268
268
  (0, react_1.useEffect)(() => {
269
269
  onConversationStartRef.current = onConversationStart;
270
270
  }, [onConversationStart]);
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);
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
- const el = inputRef.current;
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
286
  // ── Session lifecycle. Recreate when token / apiBaseUrl changes. ──────
295
287
  (0, react_1.useEffect)(() => {
@@ -378,14 +370,25 @@ function ChatWidget(props) {
378
370
  const text = draft.trim();
379
371
  if (!text)
380
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.
381
377
  hasInteractedRef.current = true;
382
378
  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).
388
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
+ }
389
392
  }, [session, draft]);
390
393
  const onKeyDown = (0, react_1.useCallback)((e) => {
391
394
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -442,20 +445,6 @@ function ChatWidget(props) {
442
445
  void session.send(trimmed);
443
446
  },
444
447
  insertText,
445
- focus: () => {
446
- const el = inputRef.current;
447
- if (!el || el.disabled)
448
- return;
449
- el.focus({ preventScroll: true });
450
- },
451
- warmup: () => {
452
- // session.start() is idempotent — joins the in-flight
453
- // promise if one exists, resolves immediately if start
454
- // already completed.
455
- if (!session)
456
- return;
457
- void session.start();
458
- },
459
448
  };
460
449
  return () => {
461
450
  // Drop the handle on unmount so a stale ref can't fire send
@@ -546,31 +535,20 @@ function ChatWidget(props) {
546
535
  onShortcutClick(text, i);
547
536
  else
548
537
  setDraft(text);
549
- }, children: text }, `${i}-${text}`))) })), (0, jsx_runtime_1.jsxs)("div", { className: "af-input-row", "data-loading": status === 'loading' || status === 'idle' ? '' : undefined, 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: status === 'idle' || status === 'loading'
550
- ? 'Preparing chat…'
551
- : 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,
552
539
  // The textarea stays editable while the agent is
553
540
  // streaming so the visitor can compose their next
554
541
  // message without waiting. Only block when the
555
542
  // conversation has actually ended OR before the
556
543
  // session is ready at all.
557
544
  disabled: status === 'ended' || status === 'loading' || status === 'idle' }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-send",
558
- // `onPointerDown preventDefault` is the single cross-
559
- // device guarantee that the button never steals focus
560
- // from the textarea. PointerEvents cover touch, mouse,
561
- // AND pen `onMouseDown` alone misses mobile touch.
562
- // Without this, the focus shifts to the button just
563
- // before `handleSend` runs, the button then disables
564
- // (sendDisabled flips true on status change), focus
565
- // jumps to <body>, and the on-screen keyboard collapses.
566
- onPointerDown: (e) => e.preventDefault(), onClick: handleSend, disabled: sendDisabled, "aria-label": status === 'sending' || status === 'streaming'
567
- ? 'Sending message'
568
- : status === 'idle' || status === 'loading'
569
- ? 'Preparing chat'
570
- : 'Send message', children: status === 'sending' ||
571
- status === 'streaming' ||
572
- status === 'idle' ||
573
- 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" })] })] }));
574
552
  }
575
553
  function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, avatarAgentId, speakerLabel, }) {
576
554
  const kind = message.metadata?.kind;
@@ -743,11 +721,6 @@ function CloseIcon() {
743
721
  function SendIcon() {
744
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" })] }));
745
723
  }
746
- function SpinnerIcon() {
747
- // Inline SVG spinner — no external dep. Stroke-dasharray
748
- // arc rotates via the CSS keyframes injected by WIDGET_CSS.
749
- 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" })] }));
750
- }
751
724
  // Stylesheet kept verbatim from the standalone widget.js so the React
752
725
  // component is visually indistinguishable from the script-injected one.
753
726
  const WIDGET_CSS = `
@@ -832,8 +805,6 @@ const WIDGET_CSS = `
832
805
  to { opacity: 1; transform: translateY(0); }
833
806
  }
834
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; }
835
- .af-input-row[data-loading] .af-input { cursor: progress; opacity: 0.7; }
836
- .af-input-row[data-loading] .af-input::placeholder { font-style: italic; }
837
808
  /* Composer left slot — hosts use this for affordance buttons that
838
809
  scope the next turn (member picker, tools menu, attachments).
839
810
  align-items: center keeps a single-line chip vertically centered
@@ -893,8 +864,6 @@ const WIDGET_CSS = `
893
864
  .af-send:active:not(:disabled) { transform: translateY(0); }
894
865
  .af-send:disabled { opacity: 0.5; cursor: not-allowed; }
895
866
  .af-send svg { width: 16px; height: 16px; }
896
- .af-send .af-spinner { animation: af-spin 720ms linear infinite; }
897
- @keyframes af-spin { to { transform: rotate(360deg); } }
898
867
  .af-error { padding: 10px 16px; font-size: 12px; color: #b91c1c; background: #fef2f2; border-top: 1px solid #fee2e2; }
899
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); }
900
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 startPromise;
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
- // 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;
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
- // 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() {
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.startPromise = null;
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
- // 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);
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.12",
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
- "typescript": "^5.0.0"
42
+ "react-dom": "^18.3.1",
43
+ "typescript": "^5.0.0",
44
+ "vaul": "^1.1.2"
39
45
  }
40
46
  }