@helpai/elements 0.25.0 → 0.27.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.
package/index.mjs CHANGED
@@ -476,13 +476,13 @@ var DEFAULT_FEATURES = {
476
476
  };
477
477
  var DEFAULT_FORMS = { list: [], byTrigger: {} };
478
478
  var DEFAULT_TRACKING = {
479
- enabled: false,
479
+ enabled: true,
480
480
  sampleRate: 1
481
481
  };
482
482
  var DEFAULT_ENDPOINTS = {
483
- upload: "/ai/agent/upload-file",
484
- transcribe: "/ai/agent/transcribe-audio",
485
- submitForm: "/submit-form"
483
+ upload: "/pai/upload-file",
484
+ transcribe: "/pai/transcribe-audio",
485
+ submitForm: "/pai/submit-form"
486
486
  };
487
487
  var DEFAULT_LAUNCHER = {
488
488
  variant: "circle",
@@ -525,12 +525,14 @@ function resolveOptions(rawOpts) {
525
525
  const locale = i18n.locale ?? resolveDefaultLocale(i18n.defaultLocale);
526
526
  const availableLocales = i18n.availableLocales ?? [locale];
527
527
  const baseUrl = (opts.baseUrl ?? BRAND.defaultBaseUrl).replace(/\/$/, "");
528
+ const agentBaseUrl = (opts.agentBaseUrl ?? `${baseUrl}/api/agent`).replace(/\/$/, "");
528
529
  const dataBaseUrl = (opts.dataBaseUrl ?? `${baseUrl}/api/data`).replace(/\/$/, "");
529
530
  return {
530
531
  widgetId: opts.widgetId ?? "default",
531
532
  publicKey: opts.publicKey,
532
533
  aiAgentDeploymentId: opts.aiAgentDeploymentId,
533
534
  baseUrl,
535
+ agentBaseUrl,
534
536
  dataBaseUrl,
535
537
  userContext: sanitizeUserContext(opts.userContext),
536
538
  pageContext: sanitizePageContext(opts.pageContext),
@@ -613,9 +615,9 @@ function resolveTracking(overrides, dataBaseUrl) {
613
615
  const sampleRate = overrides?.sampleRate ?? DEFAULT_TRACKING.sampleRate;
614
616
  return {
615
617
  enabled: overrides?.enabled ?? DEFAULT_TRACKING.enabled,
616
- // The collector rides the data module — `${dataBaseUrl}/elements/px.gif`
617
- // (e.g. `https://help.ai/api/data/elements/px.gif`).
618
- endpoint: overrides?.endpoint ?? `${dataBaseUrl}/elements/px.gif`,
618
+ // The collector rides the data module — `${dataBaseUrl}/pai/elements-px.gif`
619
+ // (e.g. `https://help.ai/api/data/pai/elements-px.gif`).
620
+ endpoint: overrides?.endpoint ?? `${dataBaseUrl}/pai/elements-px.gif`,
619
621
  // Clamp instead of reject — a malformed rate must never turn a
620
622
  // disabled tracker on or crash resolve (no Zod on the IIFE path).
621
623
  sampleRate: Math.min(1, Math.max(0, Number.isFinite(sampleRate) ? sampleRate : 1)),
@@ -844,6 +846,7 @@ var EMBED_SCALAR_ATTRS = [
844
846
  ["public-key", "publicKey"],
845
847
  ["ai-agent-deployment-id", "aiAgentDeploymentId"],
846
848
  ["base-url", "baseUrl"],
849
+ ["agent-base-url", "agentBaseUrl"],
847
850
  ["data-base-url", "dataBaseUrl"],
848
851
  ["theme", "theme", (v) => v]
849
852
  ];
@@ -919,7 +922,7 @@ var REACTIVE_ATTRS = [
919
922
  ...SPECIAL_REACTIVE_ATTRS
920
923
  ];
921
924
 
922
- // src/core/start-conversation-shape.ts
925
+ // src/core/handshake-shape.ts
923
926
  function isPlainObject(raw) {
924
927
  return !!raw && typeof raw === "object" && !Array.isArray(raw);
925
928
  }
@@ -1250,63 +1253,61 @@ function parseWireDate(value) {
1250
1253
  }
1251
1254
  var DEFAULT_PATHS = {
1252
1255
  /** Conversation bootstrap. `POST` → deployment config + per-visitor state. */
1253
- startConversation: "/ai/agent/start-conversation",
1256
+ handshake: "/pai/handshake",
1254
1257
  /** Send a message + stream the reply (SSE). `POST`. */
1255
- streamMessage: "/ai/agent/stream-message",
1258
+ streamMessage: "/pai/stream-message",
1256
1259
  /**
1257
1260
  * Resume an interrupted reply. `GET` (envelope rides as query params).
1258
1261
  * Stateless: the server re-streams the whole reply SSE again from the start —
1259
1262
  * there is NO `Last-Event-ID`. The client reconciles by id (`seenIds`), so
1260
1263
  * replayed events are skipped and only the continuation surfaces.
1261
1264
  */
1262
- streamResume: "/ai/agent/stream-resume",
1265
+ streamResume: "/pai/stream-resume",
1263
1266
  /** Abort the in-flight reply stream. `POST`. */
1264
- cancelStream: "/ai/agent/cancel-stream",
1267
+ cancelStream: "/pai/cancel-stream",
1265
1268
  /** List the visitor's conversations. `GET`. */
1266
- listConversations: "/ai/agent/list-conversations",
1267
- /** Load one conversation's messages. `GET ?conversationId=…`. */
1268
- listMessages: "/ai/agent/list-messages",
1269
+ listConversations: "/pai/list-conversations",
1270
+ /** Load one conversation's thread (messages + canContinue + suggestions). `GET ?conversationId=…`. */
1271
+ listMessages: "/pai/list-messages",
1269
1272
  /**
1270
1273
  * Persist a user-driven settings change. POST `{ visitorId, userPrefs }` →
1271
1274
  * server persists, returns the merged authoritative copy. Fire-and-forget on
1272
1275
  * the client — a failure leaves the local cache valid; the next
1273
- * `startConversation()` reconciles.
1276
+ * `handshake()` reconciles.
1274
1277
  */
1275
- updateSettings: "/ai/agent/update-settings",
1278
+ updateSettings: "/pai/update-settings",
1276
1279
  /**
1277
1280
  * Mark a conversation read. POST `{ visitorId, conversationId }` → the server
1278
1281
  * records `lastReadAt = now` for that (visitor, conversation) and recomputes
1279
1282
  * `unreadCount` (returned on `/list-conversations`) accordingly. Fire-and-forget
1280
1283
  * on the client; a failure just leaves the badge until the next sync.
1281
1284
  */
1282
- markRead: "/ai/agent/mark-read",
1285
+ markRead: "/pai/mark-read",
1283
1286
  // ── Data API (module-help-ai-data-api, via `dataBaseUrl`) ─────────
1284
- /** All widget content via one filtered endpoint. `GET /content?tags=…`. */
1285
- content: "/content",
1286
1287
  /**
1287
- * Resolved form definitions for the deployment. `GET /forms` →
1288
- * `{ forms: FormDef[] }`. Fetched once at boot (in parallel with
1289
- * start-conversation); form gating waits for it. A failure is treated as
1290
- * "no forms" (non-fatal).
1288
+ * The data module's one ACTIVATION read. `GET
1289
+ * /pai/bootstrap?visitorId=…&conversationId=…`
1290
+ * `{ forms, formSubmissions }`: the deployment's resolved form
1291
+ * definitions plus the visitor's recorded submissions for the envelope's
1292
+ * conversation (chronological — they rebuild the timeline markers).
1293
+ * Also re-read with an explicit `conversationId` selector on a
1294
+ * history-pane thread switch. A failure is treated as "no forms, no
1295
+ * records" (non-fatal) — the chat works regardless.
1296
+ */
1297
+ bootstrap: "/pai/bootstrap",
1298
+ /**
1299
+ * All widget content via one filtered endpoint — content stays OUT of the
1300
+ * bootstrap because its reads are query-driven (`tags` / `categories` /
1301
+ * `ids` / `search` + pagination). `GET /pai/content?tags=…`.
1291
1302
  */
1292
- forms: "/forms",
1303
+ content: "/pai/content",
1293
1304
  /**
1294
1305
  * Form submission (the event-driven forms engine). POST `{ visitorId,
1295
1306
  * conversationId, formId, trigger, values, skipped? }` → record the form's answers
1296
1307
  * immediately, keyed by the same `visitorId` + `conversationId` the chat uses.
1297
1308
  * Overridable via `endpoints.submitForm`. Fire-and-forget; 404 is ignored.
1298
1309
  */
1299
- submitForm: "/submit-form",
1300
- /**
1301
- * Visitor interaction records for a conversation. `GET
1302
- * /activity?visitorId=…&conversationId=…` →
1303
- * `{ formSubmissions: FormSubmissionRecord[] }` (chronological). Replaces
1304
- * the former `formSubmissions` echo on start-conversation / list-messages;
1305
- * deliberately generic — future visitor interaction records ride the same
1306
- * endpoint. A failure (e.g. 404 `AI_AGENT_PUBLIC_ACCESS_NOT_FOUND` when the
1307
- * deployment doesn't resolve) is treated as "no records" (non-fatal).
1308
- */
1309
- activity: "/activity"
1310
+ submitForm: "/pai/submit-form"
1310
1311
  };
1311
1312
  var CONTEXT_PARAM = "context";
1312
1313
  function buildSendMessageRequest(params) {
@@ -1407,7 +1408,7 @@ function sleep(ms, signal7) {
1407
1408
  var AgentTransport = class {
1408
1409
  constructor(opts) {
1409
1410
  __publicField(this, "opts", opts);
1410
- // Identity adopted from the start-conversation response; rides the request envelope
1411
+ // Identity adopted from the handshake response; rides the request envelope
1411
1412
  // (see `envelope()`) on every subsequent call so the backend can validate the
1412
1413
  // (visitor, conversation) pair.
1413
1414
  __publicField(this, "visitorId");
@@ -1430,9 +1431,9 @@ var AgentTransport = class {
1430
1431
  }
1431
1432
  /**
1432
1433
  * Seed the visitor + conversation identity from persistence BEFORE the first
1433
- * `startConversation` resolves. A mount-time `resumeStream()` runs in parallel
1434
- * with `startConversation`, so without this its envelope would carry no
1435
- * `conversationId` and the resume GET couldn't identify the thread. `startConversation`
1434
+ * `handshake` resolves. A mount-time `resumeStream()` runs in parallel
1435
+ * with `handshake`, so without this its envelope would carry no
1436
+ * `conversationId` and the resume GET couldn't identify the thread. `handshake`
1436
1437
  * later overwrites these with the server's authoritative ids (rebind-safe).
1437
1438
  */
1438
1439
  primeIdentity(visitorId, conversationId) {
@@ -1449,7 +1450,7 @@ var AgentTransport = class {
1449
1450
  return out;
1450
1451
  }
1451
1452
  /** Merge the envelope into a JSON body (POSTs). Explicit body fields win — e.g.
1452
- * start-conversation's client-proposed ids, or a mark-read target conversation that
1453
+ * handshake's client-proposed ids, or a mark-read target conversation that
1453
1454
  * differs from the active one. */
1454
1455
  withEnvelope(body) {
1455
1456
  return { ...this.envelope(), ...body };
@@ -1470,28 +1471,22 @@ var AgentTransport = class {
1470
1471
  return url.toString();
1471
1472
  }
1472
1473
  /** One-shot runtime bootstrap. Called once after the widget mounts. */
1473
- async startConversation(body) {
1474
- log4.debug("startConversation \u2192", {
1474
+ async handshake(body) {
1475
+ log4.debug("handshake \u2192", {
1475
1476
  visitorId: body.visitorId,
1476
1477
  conversationId: body.conversationId,
1477
1478
  locale: body.locale
1478
1479
  });
1479
- const res = await this.postJson(
1480
- DEFAULT_PATHS.startConversation,
1481
- body,
1482
- "startConversation"
1483
- );
1484
- log4.debug("startConversation \u2190", {
1480
+ const res = await this.postJson(DEFAULT_PATHS.handshake, body, "handshake");
1481
+ log4.debug("handshake \u2190", {
1485
1482
  visitorId: res.visitorId,
1486
1483
  conversationId: res.conversationId,
1487
- canContinue: res.canContinue,
1488
- hasMessages: !!res.messages?.length,
1489
1484
  hasWelcome: !!res.welcome,
1490
1485
  hasConfig: !!res.config,
1491
1486
  rebind: res.rebind
1492
1487
  });
1493
- if (!isStartConversationResponseShape(res)) {
1494
- log4.warn("startConversation response did not match expected shape; using raw response", { response: res });
1488
+ if (!isHandshakeResponseShape(res)) {
1489
+ log4.warn("handshake response did not match expected shape; using raw response", { response: res });
1495
1490
  }
1496
1491
  this.visitorId = res.visitorId ?? body.visitorId;
1497
1492
  this.conversationId = res.conversationId;
@@ -1601,38 +1596,26 @@ var AgentTransport = class {
1601
1596
  return { ...res, items };
1602
1597
  }
1603
1598
  /**
1604
- * Fetch the deployment's resolved form definitions. `GET /forms`
1605
- * (data-API). Replaces the former `config.forms` push on the handshake
1606
- * called once at boot, in parallel with `startConversation`; form gating
1607
- * (pre-chat etc.) waits for it. Callers treat a thrown StreamError as
1608
- * "no forms" (non-fatal).
1609
- */
1610
- async listForms() {
1611
- log4.debug("listForms \u2192");
1612
- const res = await this.getJson(this.dataUrl(DEFAULT_PATHS.forms), "listForms");
1613
- const forms = res.forms ?? [];
1614
- log4.debug("listForms \u2190", { count: forms.length });
1615
- return { forms };
1616
- }
1617
- /**
1618
- * Fetch the visitor's recorded activity for a conversation — currently the
1619
- * form submissions that rebuild the collapsed "form submitted / skipped"
1620
- * timeline markers on resume. `GET /activity` (data-API); the
1621
- * envelope carries `visitorId` / `conversationId` as query params.
1622
- * `conversationId` here is an optional resource selector (a thread that may
1623
- * differ from the active conversation — the history-pane switch); when
1624
- * omitted, the envelope's active conversation applies. Callers treat a
1625
- * thrown StreamError (e.g. 404 on a deployment that doesn't resolve) as
1626
- * "no activity" (non-fatal).
1599
+ * The data module's ONE activation read. `GET /pai/bootstrap` (data-API)
1600
+ * the deployment's resolved form definitions + the visitor's recorded
1601
+ * form submissions for the envelope's conversation. The handshake
1602
+ * (agent-API) is completely independent of these surfaces.
1603
+ *
1604
+ * `conversationId` is an optional resource selector (a thread that may
1605
+ * differ from the active conversation — the history-pane switch re-reads
1606
+ * that thread's submissions); when omitted, the envelope's active
1607
+ * conversation applies. Callers treat a thrown StreamError (e.g. 404 on a
1608
+ * deployment that doesn't resolve) as "no forms, no records" (non-fatal).
1627
1609
  */
1628
- async getActivity(conversationId) {
1629
- const url = new URL(this.dataUrl(DEFAULT_PATHS.activity));
1610
+ async bootstrap(conversationId) {
1611
+ const url = new URL(this.dataUrl(DEFAULT_PATHS.bootstrap));
1630
1612
  if (conversationId) url.searchParams.set("conversationId", conversationId);
1631
- log4.debug("getActivity \u2192", { conversationId: conversationId ?? this.conversationId });
1632
- const res = await this.getJson(url.toString(), "getActivity");
1613
+ log4.debug("bootstrap \u2192", { conversationId: conversationId ?? this.conversationId });
1614
+ const res = await this.getJson(url.toString(), "bootstrap");
1615
+ const forms = res.forms ?? [];
1633
1616
  const formSubmissions = res.formSubmissions ?? [];
1634
- log4.debug("getActivity \u2190", { count: formSubmissions.length });
1635
- return { formSubmissions };
1617
+ log4.debug("bootstrap \u2190", { forms: forms.length, formSubmissions: formSubmissions.length });
1618
+ return { forms, formSubmissions };
1636
1619
  }
1637
1620
  async saveUserPrefs(userPrefs) {
1638
1621
  log4.debug("saveUserPrefs \u2192", {
@@ -1645,10 +1628,10 @@ var AgentTransport = class {
1645
1628
  return res;
1646
1629
  }
1647
1630
  get uploadPath() {
1648
- return this.opts.endpoints?.upload ?? "/ai/agent/upload-file";
1631
+ return this.opts.endpoints?.upload ?? "/pai/upload-file";
1649
1632
  }
1650
1633
  get transcribePath() {
1651
- return this.opts.endpoints?.transcribe ?? "/ai/agent/transcribe-audio";
1634
+ return this.opts.endpoints?.transcribe ?? "/pai/transcribe-audio";
1652
1635
  }
1653
1636
  get submitFormPath() {
1654
1637
  return this.opts.endpoints?.submitForm ?? DEFAULT_PATHS.submitForm;
@@ -1688,9 +1671,9 @@ var AgentTransport = class {
1688
1671
  * Re-attach to an in-flight reply on a cold page load / refresh.
1689
1672
  *
1690
1673
  * UNCONDITIONAL by design — the caller runs this in parallel with
1691
- * `startConversation`, never gated on whether there are persisted messages. If
1674
+ * `handshake`, never gated on whether there are persisted messages. If
1692
1675
  * the user refreshed while the assistant was still streaming, the
1693
- * `startConversation` reply carries no messages (the turn isn't persisted until
1676
+ * `handshake` reply carries no messages (the turn isn't persisted until
1694
1677
  * it finishes) and THIS is the channel that resumes the live reply. When
1695
1678
  * nothing is in flight the server answers 204 and the handle yields nothing
1696
1679
  * (the common case — no bubble is created).
@@ -1717,7 +1700,7 @@ var AgentTransport = class {
1717
1700
  * - after a POST /stream-message drops mid-reply (`immediate=false` — back
1718
1701
  * off first, the reply may still be spinning up server-side);
1719
1702
  * - on a cold page load / refresh (`immediate=true` — hit it at once, in
1720
- * parallel with start-conversation, so a reply that was streaming when the
1703
+ * parallel with handshake, so a reply that was streaming when the
1721
1704
  * user reloaded keeps flowing).
1722
1705
  *
1723
1706
  * The endpoint ALWAYS opens a 200 SSE stream (mirrors the AI SDK
@@ -1726,7 +1709,7 @@ var AgentTransport = class {
1726
1709
  * nothing ever started) — closes an EMPTY stream. So "nothing to resume" is
1727
1710
  * signalled by a segment that ends without a terminal chunk AND surfaced no
1728
1711
  * NEW events — not by a status code. We stop gracefully there; the completed
1729
- * turn comes from the DB via start-conversation / list-messages.
1712
+ * turn comes from the DB via the handshake / list-messages.
1730
1713
  *
1731
1714
  * Returns (no throw) on a terminal chunk, an empty/no-new-events segment, or
1732
1715
  * a 204 / non-OK (a backend that signals "nothing" by status, or lacks the
@@ -1791,7 +1774,7 @@ var AgentTransport = class {
1791
1774
  }
1792
1775
  return false;
1793
1776
  }
1794
- /** Abort + fire-and-forget POST to `/ai/agent/cancel-stream`. Idempotent. */
1777
+ /** Abort + fire-and-forget POST to `/pai/cancel-stream`. Idempotent. */
1795
1778
  cancelStream(ctrl, conversationId) {
1796
1779
  if (ctrl.signal.aborted) return;
1797
1780
  log4.debug("cancel stream", { conversationId, visitorId: this.visitorId });
@@ -1832,7 +1815,7 @@ var AgentTransport = class {
1832
1815
  return url.toString();
1833
1816
  }
1834
1817
  // JSON requests are idempotent (reads, or full-snapshot writes like
1835
- // update-settings / mark-read / start-conversation), so they auto-retry transient
1818
+ // update-settings / mark-read / handshake), so they auto-retry transient
1836
1819
  // failures. The message POST is NOT in here — re-sending could duplicate the
1837
1820
  // reply, so it surfaces an error for the user to retry (see `openMessageStream`).
1838
1821
  async postJson(path, body, label) {
@@ -1901,23 +1884,22 @@ var AgentTransport = class {
1901
1884
  return await res.json();
1902
1885
  }
1903
1886
  /**
1904
- * Agent-API URL — resolves a `/ai/agent/*` path against the agent module
1905
- * base, `${baseUrl}/api/agent`. Absolute URLs pass through unchanged.
1887
+ * Agent-API URL — resolves a `/pai/*` path against the resolved
1888
+ * agent module base. Absolute URLs pass through unchanged.
1906
1889
  */
1907
1890
  url(pathOrUrl) {
1908
1891
  if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
1909
- return `${this.opts.baseUrl}/api/agent${pathOrUrl}`;
1892
+ return `${this.opts.agentBaseUrl}${pathOrUrl}`;
1910
1893
  }
1911
1894
  /**
1912
1895
  * Data-API variant of {@link url} — resolves a root-level path (content /
1913
- * forms / submit-form / activity) against `dataBaseUrl`. Absolute URLs
1914
- * (e.g. an `endpoints.submitForm` pointing straight at a CRM) pass through
1915
- * unchanged; with no `dataBaseUrl` configured the data module derives from
1916
- * the main origin: `${baseUrl}/api/data`.
1896
+ * forms / submit-form / activity) against the resolved data module base.
1897
+ * Absolute URLs (e.g. an `endpoints.submitForm` pointing straight at a
1898
+ * CRM) pass through unchanged.
1917
1899
  */
1918
1900
  dataUrl(pathOrUrl) {
1919
1901
  if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
1920
- return `${this.opts.dataBaseUrl ?? `${this.opts.baseUrl}/api/data`}${pathOrUrl}`;
1902
+ return `${this.opts.dataBaseUrl}${pathOrUrl}`;
1921
1903
  }
1922
1904
  get fetchImpl() {
1923
1905
  return this.opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
@@ -1931,10 +1913,10 @@ function extensionFor(mimeType) {
1931
1913
  if (mimeType.includes("wav")) return "wav";
1932
1914
  return "bin";
1933
1915
  }
1934
- function isStartConversationResponseShape(raw) {
1916
+ function isHandshakeResponseShape(raw) {
1935
1917
  if (!raw || typeof raw !== "object") return false;
1936
1918
  const r = raw;
1937
- return typeof r.visitorId === "string" && typeof r.conversationId === "string" && typeof r.canContinue === "boolean";
1919
+ return typeof r.visitorId === "string" && typeof r.conversationId === "string";
1938
1920
  }
1939
1921
 
1940
1922
  // src/stream/messages.ts
@@ -6449,6 +6431,14 @@ function App({ options, hostElement, bus }) {
6449
6431
  resolveInitialPanelState(options, currentViewportWidth(), persistence.loadPanelOpen(), persistence.loadPanelSize())
6450
6432
  );
6451
6433
  const [isOpen, setIsOpen] = useState13(initialPanelRef.current.panelOpen);
6434
+ const [activated, setActivated] = useState13(initialPanelRef.current.panelOpen);
6435
+ const activatedRef = useRef9(activated);
6436
+ activatedRef.current = activated;
6437
+ const pendingThreadRef = useRef9(false);
6438
+ const welcomeRef = useRef9(void 0);
6439
+ useEffect17(() => {
6440
+ if (isOpen) setActivated(true);
6441
+ }, [isOpen]);
6452
6442
  const initialPanelApplied = useRef9(false);
6453
6443
  const [launcherLeaving, setLauncherLeaving] = useState13(false);
6454
6444
  const { dismissed: calloutDismissed, dismissCallout: dismissCalloutRaw } = useLauncherCallout({
@@ -6475,7 +6465,7 @@ function App({ options, hostElement, bus }) {
6475
6465
  const [formContext, setFormContext] = useState13({});
6476
6466
  const [transport] = useState13(
6477
6467
  () => new AgentTransport({
6478
- baseUrl: options.baseUrl,
6468
+ agentBaseUrl: options.agentBaseUrl,
6479
6469
  dataBaseUrl: options.dataBaseUrl,
6480
6470
  auth: createAuth({
6481
6471
  publicKey: options.publicKey,
@@ -6557,10 +6547,11 @@ function App({ options, hostElement, bus }) {
6557
6547
  },
6558
6548
  [messagesSig]
6559
6549
  );
6550
+ const dataBootRef = useRef9(null);
6560
6551
  const fetchFormMarkers = useCallback6(
6561
6552
  async (conversationId) => {
6562
6553
  try {
6563
- const { formSubmissions: subs } = await transport.getActivity(conversationId);
6554
+ const { formSubmissions: subs } = await (conversationId ? transport.bootstrap(conversationId) : dataBootRef.current ?? transport.bootstrap());
6564
6555
  const lastIndexByForm = /* @__PURE__ */ new Map();
6565
6556
  subs.forEach((rec, i) => lastIndexByForm.set(rec.formId, i));
6566
6557
  return subs.filter((rec, i) => !rec.skipped || lastIndexByForm.get(rec.formId) === i).map((rec, i) => ({
@@ -6571,14 +6562,53 @@ function App({ options, hostElement, bus }) {
6571
6562
  values: rec.values
6572
6563
  }));
6573
6564
  } catch (err) {
6574
- log16.debug("getActivity failed \u2014 no form markers (non-fatal)", { err });
6565
+ log16.debug("bootstrap failed \u2014 no form markers (non-fatal)", { err });
6575
6566
  return [];
6576
6567
  }
6577
6568
  },
6578
6569
  [transport]
6579
6570
  );
6580
6571
  const connectGenRef = useRef9(0);
6581
- const runStartConversation = useCallback6(
6572
+ const loadThread = useCallback6(
6573
+ async (conversationId) => {
6574
+ const myGen = connectGenRef.current;
6575
+ const isStale = () => myGen !== connectGenRef.current;
6576
+ setLoadingMessages(true);
6577
+ try {
6578
+ const [chat, markers] = await Promise.all([transport.loadConversation(conversationId), fetchFormMarkers()]);
6579
+ if (isStale()) return;
6580
+ const loaded = (chat.messages ?? []).map(fromWireMessage);
6581
+ setFormMarkers(markers);
6582
+ const timelineStamps = [...loaded.map((m) => m.createdAt), ...markers.map((m) => m.createdAt)].filter(
6583
+ (n) => n !== void 0
6584
+ );
6585
+ const welcomeAnchor = (timelineStamps.length ? Math.min(...timelineStamps) : Date.now()) - 1;
6586
+ const loadedIds = new Set(loaded.map((m) => m.id));
6587
+ const welcomeRows = (welcomeRef.current?.messages ?? []).filter((w) => !loadedIds.has(w.id)).map((w) => {
6588
+ const row = makeInstantWelcomeMessage(w);
6589
+ row.createdAt = welcomeAnchor;
6590
+ return row;
6591
+ });
6592
+ const tail = resumeBubbleRef.current ? [resumeBubbleRef.current] : [];
6593
+ if (loaded.length || tail.length) {
6594
+ messagesSig.value = [...welcomeRows, ...loaded, ...tail];
6595
+ setCanSend(chat.canContinue ?? true);
6596
+ setSuggestions(chat.suggestions ?? []);
6597
+ } else {
6598
+ messagesSig.value = welcomeRows;
6599
+ }
6600
+ } catch (err) {
6601
+ if (isStale()) return;
6602
+ log16.warn("loadThread failed; resetting conversationId", { err, conversationId });
6603
+ conversationIdSig.value = void 0;
6604
+ persistence.saveConversationId(void 0);
6605
+ } finally {
6606
+ if (!isStale()) setLoadingMessages(false);
6607
+ }
6608
+ },
6609
+ [transport, fetchFormMarkers, messagesSig, conversationIdSig, persistence]
6610
+ );
6611
+ const runHandshake = useCallback6(
6582
6612
  async ({ newConversation }) => {
6583
6613
  const myGen = ++connectGenRef.current;
6584
6614
  const isStale = () => myGen !== connectGenRef.current;
@@ -6590,7 +6620,7 @@ function App({ options, hostElement, bus }) {
6590
6620
  }
6591
6621
  let res;
6592
6622
  try {
6593
- res = await transport.startConversation({
6623
+ res = await transport.handshake({
6594
6624
  visitorId,
6595
6625
  conversationId,
6596
6626
  userPrefs: persistence.loadUserPrefs(),
@@ -6641,67 +6671,22 @@ function App({ options, hostElement, bus }) {
6641
6671
  if (res.userPrefs.themeMode) setActiveThemeMode(res.userPrefs.themeMode);
6642
6672
  if (res.userPrefs.textSize) setActiveTextSize(res.userPrefs.textSize);
6643
6673
  }
6674
+ welcomeRef.current = res.welcome;
6644
6675
  const isResume = !newConversation && persistedChatId && res.conversationId === persistedChatId;
6645
6676
  if (isResume) {
6646
- setLoadingMessages(true);
6647
- try {
6648
- const chatPromise = res.messages?.length ? Promise.resolve({
6649
- conversationId: persistedChatId,
6650
- canContinue: res.canContinue ?? true,
6651
- messages: res.messages
6652
- }) : transport.loadConversation(persistedChatId);
6653
- const [chat, markers] = await Promise.all([chatPromise, fetchFormMarkers()]);
6654
- if (isStale()) return;
6655
- const loaded = (chat.messages ?? []).map(fromWireMessage);
6656
- setFormMarkers(markers);
6657
- const timelineStamps = [...loaded.map((m) => m.createdAt), ...markers.map((m) => m.createdAt)].filter(
6658
- (n) => n !== void 0
6659
- );
6660
- const welcomeAnchor = (timelineStamps.length ? Math.min(...timelineStamps) : Date.now()) - 1;
6661
- const loadedIds = new Set(loaded.map((m) => m.id));
6662
- const welcomeRows = (res.welcome?.messages ?? []).filter((w) => !loadedIds.has(w.id)).map((w) => {
6663
- const row = makeInstantWelcomeMessage(w);
6664
- row.createdAt = welcomeAnchor;
6665
- return row;
6666
- });
6667
- const tail = resumeBubbleRef.current ? [resumeBubbleRef.current] : [];
6668
- if (loaded.length || tail.length) {
6669
- messagesSig.value = [...welcomeRows, ...loaded, ...tail];
6670
- setCanSend(chat.canContinue ?? true);
6671
- setSuggestions(chat.suggestions ?? []);
6672
- } else {
6673
- messagesSig.value = welcomeRows;
6674
- }
6675
- } catch (err) {
6676
- if (isStale()) return;
6677
- log16.warn("loadConversation failed; resetting conversationId", { err, conversationId: persistedChatId });
6678
- conversationIdSig.value = void 0;
6679
- persistence.saveConversationId(void 0);
6680
- } finally {
6681
- if (!isStale()) setLoadingMessages(false);
6682
- }
6677
+ if (activatedRef.current) void loadThread(persistedChatId);
6678
+ else pendingThreadRef.current = true;
6683
6679
  } else {
6684
6680
  conversationIdSig.value = res.conversationId;
6685
6681
  persistence.saveConversationId(res.conversationId);
6686
6682
  playWelcome(res.welcome?.messages);
6687
6683
  }
6688
6684
  if (!newConversation) {
6689
- bus.emit("conversationStarted", res);
6685
+ bus.emit("handshake", res);
6690
6686
  setConversationReady(true);
6691
6687
  }
6692
6688
  },
6693
- [
6694
- transport,
6695
- visitorId,
6696
- persistence,
6697
- options,
6698
- bus,
6699
- messagesSig,
6700
- conversationIdSig,
6701
- feedback,
6702
- playWelcome,
6703
- fetchFormMarkers
6704
- ]
6689
+ [transport, visitorId, persistence, options, bus, conversationIdSig, feedback, playWelcome, loadThread]
6705
6690
  );
6706
6691
  const userSig = JSON.stringify(options.userContext ?? null);
6707
6692
  const pageSig = JSON.stringify(options.pageContext ?? null);
@@ -6754,36 +6739,46 @@ function App({ options, hostElement, bus }) {
6754
6739
  },
6755
6740
  [reducer, messagesSig, feedback, bus, options, homeNav]
6756
6741
  );
6742
+ const resumeHandleRef = useRef9(null);
6743
+ useEffect17(() => {
6744
+ const persistedConversation = conversationIdSig.value;
6745
+ if (persistedConversation) transport.primeIdentity(visitorId, persistedConversation);
6746
+ void runHandshake({ newConversation: false });
6747
+ return () => {
6748
+ connectGenRef.current++;
6749
+ resumeHandleRef.current?.cancel();
6750
+ };
6751
+ }, []);
6757
6752
  useEffect17(() => {
6758
- void runStartConversation({ newConversation: false });
6753
+ if (!activated) return;
6754
+ dataBootRef.current = transport.bootstrap();
6759
6755
  void (async () => {
6760
6756
  try {
6761
- const res = await transport.listForms();
6762
- setRemoteForms(resolveForms(res.forms));
6757
+ const res = await dataBootRef.current;
6758
+ setRemoteForms(resolveForms((res ?? { forms: [] }).forms));
6763
6759
  } catch (err) {
6764
- log16.debug("listForms failed \u2014 treating as no forms", { err });
6760
+ log16.debug("bootstrap failed \u2014 treating as no forms", { err });
6765
6761
  } finally {
6766
6762
  setFormsReady(true);
6767
6763
  }
6768
6764
  })();
6769
- const persistedConversation = conversationIdSig.value;
6770
- let resume = null;
6771
- if (persistedConversation) {
6772
- transport.primeIdentity(visitorId, persistedConversation);
6773
- resume = transport.resumeStream();
6774
- void runResume(resume);
6765
+ if (pendingThreadRef.current) {
6766
+ pendingThreadRef.current = false;
6767
+ const persisted = conversationIdSig.value;
6768
+ if (persisted) void loadThread(persisted);
6775
6769
  }
6776
- return () => {
6777
- connectGenRef.current++;
6778
- resume?.cancel();
6779
- };
6780
- }, []);
6770
+ if (conversationIdSig.value) {
6771
+ const handle = transport.resumeStream();
6772
+ resumeHandleRef.current = handle;
6773
+ void runResume(handle);
6774
+ }
6775
+ }, [activated]);
6781
6776
  const lastUserSig = useRef9(userSig);
6782
6777
  useEffect17(() => {
6783
6778
  if (!conversationReady || lastUserSig.current === userSig) return;
6784
6779
  lastUserSig.current = userSig;
6785
- void runStartConversation({ newConversation: false });
6786
- }, [userSig, conversationReady, runStartConversation]);
6780
+ void runHandshake({ newConversation: false });
6781
+ }, [userSig, conversationReady, runHandshake]);
6787
6782
  useEffect17(() => {
6788
6783
  applyMode(hostElement, computeHostMode(options.mode, panelSize, isOpen));
6789
6784
  }, [hostElement, isOpen, panelSize, options.mode]);
@@ -7010,10 +7005,10 @@ function App({ options, hostElement, bus }) {
7010
7005
  }, [transport, options.modules, unreadCountSig]);
7011
7006
  const unreadSeeded = useRef9(false);
7012
7007
  useEffect17(() => {
7013
- if (!conversationReady || unreadSeeded.current) return;
7008
+ if (!activated || !conversationReady || unreadSeeded.current) return;
7014
7009
  unreadSeeded.current = true;
7015
7010
  refreshUnread();
7016
- }, [conversationReady, refreshUnread]);
7011
+ }, [activated, conversationReady, refreshUnread]);
7017
7012
  const handleOpen = useCallback6(() => {
7018
7013
  log16.info("open", { mode: options.mode });
7019
7014
  feedback.unlock();
@@ -7035,8 +7030,8 @@ function App({ options, hostElement, bus }) {
7035
7030
  persistence.clearConversation();
7036
7031
  setCanSend(true);
7037
7032
  setFormMarkers([]);
7038
- void runStartConversation({ newConversation: true });
7039
- }, [streaming, activeCancel, messagesSig, conversationIdSig, persistence, runStartConversation, bus]);
7033
+ void runHandshake({ newConversation: true });
7034
+ }, [streaming, activeCancel, messagesSig, conversationIdSig, persistence, runHandshake, bus]);
7040
7035
  const handleNewChat = useCallback6(() => {
7041
7036
  handleClear();
7042
7037
  setView("chat");
@@ -7147,6 +7142,20 @@ function App({ options, hostElement, bus }) {
7147
7142
  },
7148
7143
  [transport, messagesSig, conversationIdSig, bus, options, persistence, refreshUnread, fetchFormMarkers]
7149
7144
  );
7145
+ const pendingOpenFormRef = useRef9(null);
7146
+ const openForm = useCallback6(
7147
+ (id) => {
7148
+ if (typeof id !== "string") return;
7149
+ if (formsReady) forms.fire("manual", id);
7150
+ else pendingOpenFormRef.current = id;
7151
+ },
7152
+ [formsReady, forms]
7153
+ );
7154
+ useEffect17(() => {
7155
+ if (!formsReady || !pendingOpenFormRef.current) return;
7156
+ forms.fire("manual", pendingOpenFormRef.current);
7157
+ pendingOpenFormRef.current = null;
7158
+ }, [formsReady, forms]);
7150
7159
  useEffect17(() => {
7151
7160
  const unsub = bindHostCommands(hostElement, {
7152
7161
  open: handleOpen,
@@ -7154,10 +7163,10 @@ function App({ options, hostElement, bus }) {
7154
7163
  expand: handleExpand,
7155
7164
  fullscreen: handleFullscreen,
7156
7165
  popout: handlePopOut,
7157
- openForm: (id) => forms.fire("manual", typeof id === "string" ? id : void 0)
7166
+ openForm
7158
7167
  });
7159
7168
  return unsub;
7160
- }, [hostElement, handleOpen, handleClose, handleExpand, handleFullscreen, handlePopOut, forms]);
7169
+ }, [hostElement, handleOpen, handleClose, handleExpand, handleFullscreen, handlePopOut, openForm]);
7161
7170
  const effectiveOptions = useMemo3(
7162
7171
  () => applyOptionOverrides(options, activeLocale, activeThemeMode, activeTextSize),
7163
7172
  [options, activeLocale, activeThemeMode, activeTextSize]
@@ -7452,7 +7461,7 @@ var TRACKED = {
7452
7461
  clear: () => void 0,
7453
7462
  suggestion: () => void 0,
7454
7463
  toggleHistory: (p33) => ({ view: p33.view }),
7455
- conversationStarted: () => void 0,
7464
+ handshake: () => void 0,
7456
7465
  // Forms + human-in-the-loop — ids and outcomes, never values.
7457
7466
  formSubmit: (p33) => ({ formId: p33.formId, skipped: p33.skipped }),
7458
7467
  toolResult: () => void 0,
@@ -7621,7 +7630,7 @@ var EVENT_NAMES = [
7621
7630
  "calloutDismiss",
7622
7631
  // Lifecycle / errors
7623
7632
  "error",
7624
- "conversationStarted",
7633
+ "handshake",
7625
7634
  "visitorRebound",
7626
7635
  "conversationRebound"
7627
7636
  ];
@@ -7678,7 +7687,7 @@ function mount(opts) {
7678
7687
  );
7679
7688
  };
7680
7689
  renderApp();
7681
- bus.on("conversationStarted", (response) => {
7690
+ bus.on("handshake", (response) => {
7682
7691
  if (!response.config) return;
7683
7692
  rawOptions = mergeServerConfig(rawOptions, response.config);
7684
7693
  currentOptions = resolveOptions(rawOptions);