@usereq/widget 0.2.21 → 0.2.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usereq/widget",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,6 +1,5 @@
1
1
  import React from "react";
2
2
  import { createRoot, type Root } from "react-dom/client";
3
- import { DEFAULT_START_CONVERSATION_LABEL } from "../chat-widget/chat-widget-defaults";
4
3
  import {
5
4
  DEFAULT_WIDGET_APPEARANCE,
6
5
  DEFAULT_WIDGET_PRESET,
@@ -15,7 +14,10 @@ import {
15
14
  type WidgetSessionState,
16
15
  type WidgetVariant,
17
16
  } from "../types";
18
- import { widgetTriggerRuleKey } from "../shared/widget-config";
17
+ import {
18
+ normalizeWidgetMobileHeight,
19
+ widgetTriggerRuleKey,
20
+ } from "../shared/widget-config";
19
21
  import { WidgetRuntime, type WidgetRuntimeStatus } from "../renderer/index";
20
22
  import { buildAssistantMessageRevealSteps } from "../chat-widget/message-reveal";
21
23
  import { DEFAULT_STOP_CONVERSATION_PROMPT } from "../shared/stop-confirmation";
@@ -23,7 +25,6 @@ import {
23
25
  bootstrapWidget,
24
26
  resetWidgetSession,
25
27
  submitWidgetStopConfirmation,
26
- startWidgetConversation,
27
28
  submitWidgetMessage,
28
29
  } from "../runtime/bootstrap";
29
30
  import { getWidgetShadowCss } from "../shared/shadow-css";
@@ -67,7 +68,6 @@ export class AgentWidgetElement extends HTMLElement {
67
68
  private sendVersion = 0;
68
69
  private confirmationVersion = 0;
69
70
  private bootstrapPhase: "idle" | "booting" | "ready" | "blocked" = "idle";
70
- private autoStartTriggered = false;
71
71
  // Back-to-back send support. The composer stays enabled while the agent
72
72
  // is replying; new submissions land here and are drained sequentially by
73
73
  // `sendMessage`. Each entry carries the optimistic bubble id so failures
@@ -234,32 +234,7 @@ export class AgentWidgetElement extends HTMLElement {
234
234
  const variant = this.getResolvedVariant();
235
235
  const preset = this.getResolvedPreset();
236
236
  const behavior = this.sessionConfig?.widgetConfig.widgetBehavior;
237
- const autoStartEnabled = behavior?.autoStart === true;
238
237
  const alwaysOpen = behavior?.alwaysOpen === true;
239
- const awaitingConversation =
240
- this.status === "booting" ||
241
- (this.sessionState != null && !this.sessionState.conversationId);
242
- // When auto-start is on we never render the manual CTA — except as a
243
- // fallback if the auto-start attempt errored, so the visitor isn't
244
- // stranded on a blank panel.
245
- const showFallbackCta = autoStartEnabled && this.status === "error";
246
- const emptyAction =
247
- !autoStartEnabled && awaitingConversation
248
- ? {
249
- label: DEFAULT_START_CONVERSATION_LABEL,
250
- onClick: () => void this.handleStartConversation(),
251
- disabled: this.status !== "ready",
252
- }
253
- : showFallbackCta
254
- ? {
255
- label: DEFAULT_START_CONVERSATION_LABEL,
256
- onClick: () => {
257
- this.autoStartTriggered = false;
258
- void this.handleStartConversation();
259
- },
260
- disabled: false,
261
- }
262
- : undefined;
263
238
 
264
239
  this.root.render(
265
240
  React.createElement(WidgetRuntime, {
@@ -277,21 +252,23 @@ export class AgentWidgetElement extends HTMLElement {
277
252
  welcomeMessage: this.getWelcomeMessage(),
278
253
  spotlights: this.getSpotlights(),
279
254
  ctaLink: this.getCtaLink(),
255
+ mobileHeight: normalizeWidgetMobileHeight(
256
+ this.sessionConfig?.widgetConfig.mobileHeight,
257
+ ),
280
258
  open: this.open,
281
259
  alwaysOpen,
282
260
  status: this.status,
283
261
  messages: this.messages,
284
262
  errorMessage: this.errorMessage,
285
263
  triggerRule: this.sessionConfig?.widgetConfig.triggerRule ?? null,
286
- emptyAction,
264
+ // Lazy session: no "Start a chat" gate. The composer is live from
265
+ // the moment the panel opens; the conversation is created on the
266
+ // server the first time the user actually sends something.
267
+ emptyAction: undefined,
287
268
  onStopConversation:
288
269
  this.sessionState?.conversationId != null
289
270
  ? () => void this.handleStopConversation()
290
271
  : undefined,
291
- // Allow the user to keep typing while the agent is mid-reply —
292
- // submissions queue inside `sendMessage` and drain sequentially.
293
- // Only block when there's no conversation to send to yet.
294
- sendDisabled: !this.sessionState?.conversationId,
295
272
  onOpenChange: (nextOpen: boolean) => void this.handleOpenChange(nextOpen),
296
273
  onSendMessage: (content: string) => void this.sendMessage(content),
297
274
  onConfirmationDecision: (confirmationId: string, accepted: boolean) =>
@@ -347,6 +324,11 @@ export class AgentWidgetElement extends HTMLElement {
347
324
  this.welcomeMessage = state.session.widgetConfig.welcomeMessage;
348
325
  this.status = "ready";
349
326
  this.bootstrapPhase = "ready";
327
+ // Lazy session: when the visitor doesn't already have a running
328
+ // conversation, seed a client-side welcome bubble from the boot
329
+ // config so the panel doesn't open on a blank empty state. The
330
+ // real conversation is only created server-side once they type.
331
+ this.seedClientWelcomeIfNeeded();
350
332
  // Apply the agent's configured open-state on mount. `alwaysOpen`
351
333
  // wins (the panel is pinned regardless), and `defaultOpen` opens it
352
334
  // once if the visitor hasn't already interacted with the launcher.
@@ -355,17 +337,6 @@ export class AgentWidgetElement extends HTMLElement {
355
337
  this.open = true;
356
338
  }
357
339
  this.renderWidget();
358
- // Bootstrap has finished and status is now "ready". If `autoStart`
359
- // is on AND the panel is open (either because `defaultOpen` /
360
- // `alwaysOpen` just opened it, or because the visitor opened it
361
- // before bootstrap finished and `handleOpenChange` is awaiting
362
- // this very `ensureBootstrap` call), trigger the auto-start now.
363
- // Without this, the `defaultOpen + autoStart` combination renders
364
- // an empty panel with no CTA (autoStart suppresses the Start CTA)
365
- // and the visitor sees no welcome message ever.
366
- // `maybeAutoStartConversation` is idempotent — it guards on
367
- // `autoStartTriggered` so calling it from multiple paths is safe.
368
- this.maybeAutoStartConversation();
369
340
  } catch (error) {
370
341
  if (runVersion !== this.bootstrapVersion) {
371
342
  return;
@@ -397,14 +368,19 @@ export class AgentWidgetElement extends HTMLElement {
397
368
  */
398
369
  private async sendMessage(content: string) {
399
370
  const trimmedContent = content.trim();
400
- if (!trimmedContent || !this.sessionState?.conversationId) return;
371
+ // Lazy session: no `conversationId` check here. If the visitor is
372
+ // sending their very first message, `submitWidgetMessage` will call
373
+ // POST /conversations/public/start under the hood before posting.
374
+ if (!trimmedContent || !this.sessionState) return;
401
375
 
402
376
  const optimisticMessageId = `optimistic:${Date.now()}:${Math.random()
403
377
  .toString(36)
404
378
  .slice(2, 8)}`;
405
379
  const optimisticMessage: WidgetMessage = {
406
380
  id: optimisticMessageId,
407
- conversationId: this.sessionState.conversationId,
381
+ // Empty when no conversation exists yet — the real id lands on the
382
+ // server row we swap in from `submitWidgetMessage`'s response.
383
+ conversationId: this.sessionState.conversationId ?? "",
408
384
  role: "user",
409
385
  content: trimmedContent,
410
386
  createdAt: new Date().toISOString(),
@@ -480,6 +456,26 @@ export class AgentWidgetElement extends HTMLElement {
480
456
  if (runVersion !== this.sendVersion) return;
481
457
  this.sessionState = result.session;
482
458
 
459
+ // Lazy-start swap: when this was the first send, `submitWidgetMessage`
460
+ // created the conversation server-side and the server inserted its
461
+ // own welcome message row. Replace the placeholder client-welcome
462
+ // bubble with the real server row so ids/timestamps match the DB.
463
+ if (result.welcomeMessage) {
464
+ const clientWelcomeId = this.getClientWelcomeId();
465
+ this.messages = this.messages.map((m) =>
466
+ m.id === clientWelcomeId
467
+ ? {
468
+ id: result.welcomeMessage!.id,
469
+ conversationId: result.conversationId,
470
+ role: "assistant",
471
+ content: result.welcomeMessage!.content,
472
+ createdAt: result.welcomeMessage!.createdAt,
473
+ }
474
+ : m,
475
+ );
476
+ this.renderWidget();
477
+ }
478
+
483
479
  const revealSteps = buildAssistantMessageRevealSteps(
484
480
  result.assistantMessages.map((message) => ({
485
481
  id: message.id,
@@ -533,55 +529,34 @@ export class AgentWidgetElement extends HTMLElement {
533
529
  if (this.open && (this.status === "idle" || !this.sessionState)) {
534
530
  await this.ensureBootstrap();
535
531
  }
532
+ }
536
533
 
537
- this.maybeAutoStartConversation();
534
+ private getClientWelcomeId(): string {
535
+ return `client-welcome:${this.agentId}`;
538
536
  }
539
537
 
540
538
  /**
541
- * Auto-start opt-in: when the agent has `widgetBehavior.autoStart`
542
- * enabled, the first time the visitor opens the launcher we transparently
543
- * call /conversations/public/start so they skip the empty state. Fires
544
- * once per element lifetime; subsequent opens reuse the existing
545
- * conversation via the standard sessionStorage rehydrate.
539
+ * Lazy session seeding. When bootstrap finishes with no live conversation
540
+ * and no rehydrated transcript, drop a synthetic assistant bubble carrying
541
+ * the boot-config welcome copy into `this.messages`. Skipped when there's
542
+ * already a transcript (rehydrated session) or no welcome copy configured.
543
+ * The bubble's id is stable (`client-welcome:{agentId}`) so `processSend`
544
+ * can swap it for the real server row after the first successful send.
546
545
  */
547
- private maybeAutoStartConversation() {
548
- if (this.autoStartTriggered) return;
549
- if (!this.open) return;
550
- if (this.status !== "ready") return;
551
- if (!this.sessionConfig) return;
552
- if (!this.sessionConfig.widgetConfig.widgetBehavior?.autoStart) return;
546
+ private seedClientWelcomeIfNeeded() {
547
+ if (this.messages.length > 0) return;
553
548
  if (this.sessionState?.conversationId) return;
554
-
555
- this.autoStartTriggered = true;
556
- void this.handleStartConversation();
557
- }
558
-
559
- private async handleStartConversation() {
560
- if (!this.sessionState) {
561
- await this.ensureBootstrap();
562
- }
563
- if (!this.sessionState) {
564
- return;
565
- }
566
-
567
- this.status = "booting";
568
- this.errorMessage = null;
569
- this.renderWidget();
570
-
571
- try {
572
- const result = await startWidgetConversation({
573
- agentId: this.agentId,
574
- });
575
- this.sessionState = result.session;
576
- this.messages = result.messages;
577
- this.status = "ready";
578
- this.renderWidget();
579
- } catch (error) {
580
- this.status = "error";
581
- this.errorMessage =
582
- error instanceof Error ? error.message : "Failed to start conversation";
583
- this.renderWidget();
584
- }
549
+ const welcome = this.getWelcomeMessage();
550
+ if (!welcome) return;
551
+ this.messages = [
552
+ {
553
+ id: this.getClientWelcomeId(),
554
+ conversationId: "",
555
+ role: "assistant",
556
+ content: welcome,
557
+ createdAt: new Date().toISOString(),
558
+ },
559
+ ];
585
560
  }
586
561
 
587
562
  private async handleStopConversation() {
@@ -14,6 +14,7 @@ import type { WidgetMessage } from "../types";
14
14
  import {
15
15
  splitWidgetPlacement,
16
16
  type WidgetAppearance,
17
+ type WidgetMobileHeight,
17
18
  type WidgetPlacement,
18
19
  type WidgetPreset,
19
20
  type WidgetVariant,
@@ -49,6 +50,11 @@ export type WidgetRuntimeProps = {
49
50
  welcomeMessage: string | null;
50
51
  spotlights: string[];
51
52
  ctaLink?: string | null;
53
+ /**
54
+ * How much of the screen the open panel covers on mobile. Ignored on
55
+ * desktop, where the panel floats above the launcher regardless.
56
+ */
57
+ mobileHeight?: WidgetMobileHeight;
52
58
  open: boolean;
53
59
  /**
54
60
  * When true, the embedded panel is pinned open and the launcher is hidden.
@@ -93,6 +99,7 @@ export function WidgetRuntime({
93
99
  welcomeMessage,
94
100
  spotlights,
95
101
  ctaLink,
102
+ mobileHeight = "full",
96
103
  open,
97
104
  alwaysOpen = false,
98
105
  status,
@@ -142,11 +149,13 @@ export function WidgetRuntime({
142
149
  ctaLink,
143
150
  actionLabel: appearance.actionText || undefined,
144
151
  showStopConversation: Boolean(onStopConversation),
152
+ mobileHeight,
145
153
  }),
146
154
  [
147
155
  agentAvatarUrl,
148
156
  appearance,
149
157
  ctaLink,
158
+ mobileHeight,
150
159
  onStopConversation,
151
160
  placement,
152
161
  preset,
@@ -227,6 +227,15 @@ export async function bootstrapWidget(input: {
227
227
  return bootstrapWidgetWithDeps(input, defaultBootstrapDeps);
228
228
  }
229
229
 
230
+ /**
231
+ * Send a user message. Lazy-starts the conversation on the first send —
232
+ * `startPublicChat` is called transparently when no `conversationId` is
233
+ * stored yet, so visitors never create empty conversations by opening
234
+ * the panel and walking away. When a start happens as part of this call
235
+ * the server-inserted welcome message rides back on `welcomeMessage`
236
+ * so the caller can reconcile the client-side placeholder bubble with
237
+ * the real DB row.
238
+ */
230
239
  export async function submitWidgetMessage(input: {
231
240
  agentId: string;
232
241
  content: string;
@@ -236,37 +245,52 @@ export async function submitWidgetMessage(input: {
236
245
  messages: WidgetMessage[];
237
246
  userMessage: WidgetMessage;
238
247
  assistantMessages: WidgetMessage[];
248
+ welcomeMessage?: WidgetMessage;
239
249
  }> {
240
- const stored = await ensureWidgetSession({
250
+ let stored = await ensureWidgetSession({
241
251
  agentId: input.agentId,
242
252
  pageUrl: window.location.href,
243
253
  referrer: document.referrer || undefined,
244
254
  });
245
- if (!stored.conversationId) {
246
- throw new Error("Widget conversation not started");
255
+
256
+ let welcomeMessage: WidgetMessage | undefined;
257
+ let conversationId: string;
258
+ if (stored.conversationId) {
259
+ conversationId = stored.conversationId;
260
+ } else {
261
+ const started = await startPublicChat({
262
+ agentId: input.agentId,
263
+ sessionToken: stored.sessionToken,
264
+ });
265
+ conversationId = started.conversation.id;
266
+ stored = { ...stored, conversationId };
267
+ saveSession(input.agentId, stored);
268
+ welcomeMessage = started.welcomeMessage;
247
269
  }
248
270
 
249
271
  const response = await sendPublicChatMessage({
250
- conversationId: stored.conversationId,
272
+ conversationId,
251
273
  sessionToken: stored.sessionToken,
252
274
  content: input.content,
253
275
  });
254
276
 
255
277
  const nextSession: WidgetSessionState = {
256
278
  ...stored,
257
- conversationId: stored.conversationId,
279
+ conversationId,
258
280
  };
259
281
  saveSession(input.agentId, nextSession);
260
282
 
261
283
  return {
262
284
  session: nextSession,
263
- conversationId: stored.conversationId,
285
+ conversationId,
264
286
  messages: sortByCreatedAt([
287
+ ...(welcomeMessage ? [welcomeMessage] : []),
265
288
  response.userMessage,
266
289
  ...response.assistantMessages,
267
290
  ]),
268
291
  userMessage: response.userMessage,
269
292
  assistantMessages: response.assistantMessages,
293
+ welcomeMessage,
270
294
  };
271
295
  }
272
296
 
@@ -337,39 +361,6 @@ export async function submitWidgetStopConfirmation(input: {
337
361
  };
338
362
  }
339
363
 
340
- export async function startWidgetConversation(input: {
341
- agentId: string;
342
- }): Promise<{
343
- session: WidgetSessionState;
344
- conversationId: string;
345
- messages: WidgetMessage[];
346
- welcomeMessage: WidgetMessage;
347
- }> {
348
- const stored = await ensureWidgetSession({
349
- agentId: input.agentId,
350
- pageUrl: window.location.href,
351
- referrer: document.referrer || undefined,
352
- });
353
-
354
- const response = await startPublicChat({
355
- agentId: input.agentId,
356
- sessionToken: stored.sessionToken,
357
- });
358
-
359
- const nextSession: WidgetSessionState = {
360
- ...stored,
361
- conversationId: response.conversation.id,
362
- };
363
- saveSession(input.agentId, nextSession);
364
-
365
- return {
366
- session: nextSession,
367
- conversationId: response.conversation.id,
368
- messages: sortByCreatedAt([response.welcomeMessage]),
369
- welcomeMessage: response.welcomeMessage,
370
- };
371
- }
372
-
373
364
  export async function resetWidgetSession(agentId: string): Promise<void> {
374
365
  clearSession(agentId);
375
366
  }
@@ -18,6 +18,27 @@ export type WidgetPreset = "default" | "facebook_messenger";
18
18
 
19
19
  export const DEFAULT_WIDGET_PRESET: WidgetPreset = "default";
20
20
 
21
+ /**
22
+ * How much of the screen the open panel covers on mobile. `full` is the
23
+ * edge-to-edge sheet; the others render a bottom drawer over the host page.
24
+ */
25
+ export type WidgetMobileHeight = "half" | "three-quarter" | "full";
26
+
27
+ export const DEFAULT_WIDGET_MOBILE_HEIGHT: WidgetMobileHeight = "full";
28
+
29
+ export function normalizeWidgetMobileHeight(
30
+ value: string | null | undefined,
31
+ ): WidgetMobileHeight {
32
+ switch (value) {
33
+ case "half":
34
+ case "three-quarter":
35
+ case "full":
36
+ return value;
37
+ default:
38
+ return DEFAULT_WIDGET_MOBILE_HEIGHT;
39
+ }
40
+ }
41
+
21
42
  export type WidgetAppearance = ResolvedChatWidgetAppearance;
22
43
 
23
44
  export const DEFAULT_WIDGET_APPEARANCE: WidgetAppearance =
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  WidgetAppearance,
3
3
  WidgetBehavior,
4
+ WidgetMobileHeight,
4
5
  WidgetPlacement,
5
6
  WidgetPreset,
6
7
  WidgetVariant,
@@ -10,14 +11,17 @@ import type {
10
11
  export {
11
12
  DEFAULT_WIDGET_APPEARANCE,
12
13
  DEFAULT_WIDGET_BEHAVIOR,
14
+ DEFAULT_WIDGET_MOBILE_HEIGHT,
13
15
  DEFAULT_WIDGET_PRESET,
14
16
  type WidgetAppearance,
15
17
  type WidgetBehavior,
18
+ type WidgetMobileHeight,
16
19
  type WidgetPlacement,
17
20
  type WidgetPreset,
18
21
  type WidgetVariant,
19
22
  normalizeWidgetAppearance,
20
23
  normalizeWidgetBehavior,
24
+ normalizeWidgetMobileHeight,
21
25
  normalizeWidgetPlacement,
22
26
  normalizeWidgetPreset,
23
27
  normalizeWidgetVariant,
@@ -41,6 +45,12 @@ export type WidgetSessionConfig = {
41
45
  widgetVariant: WidgetVariant;
42
46
  widgetPreset: WidgetPreset;
43
47
  widgetPlacement: WidgetPlacement;
48
+ /**
49
+ * Mobile presentation of the open panel. Optional on the wire so an
50
+ * embed running against an older API (which doesn't send the field)
51
+ * still boots — it falls back to the edge-to-edge default.
52
+ */
53
+ mobileHeight?: WidgetMobileHeight | null;
44
54
  allowedDomains: string[];
45
55
  triggerRule: WidgetTriggerRule | null;
46
56
  };
@@ -1,6 +0,0 @@
1
- /**
2
- * Default values for the chat widget when database / agent config is not set.
3
- */
4
-
5
- /** Default label for the open empty-state "start conversation" CTA. */
6
- export const DEFAULT_START_CONVERSATION_LABEL = "Start a new conversation";