@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/web-component.mjs CHANGED
@@ -461,13 +461,13 @@ var DEFAULT_FEATURES = {
461
461
  };
462
462
  var DEFAULT_FORMS = { list: [], byTrigger: {} };
463
463
  var DEFAULT_TRACKING = {
464
- enabled: false,
464
+ enabled: true,
465
465
  sampleRate: 1
466
466
  };
467
467
  var DEFAULT_ENDPOINTS = {
468
- upload: "/ai/agent/upload-file",
469
- transcribe: "/ai/agent/transcribe-audio",
470
- submitForm: "/submit-form"
468
+ upload: "/pai/upload-file",
469
+ transcribe: "/pai/transcribe-audio",
470
+ submitForm: "/pai/submit-form"
471
471
  };
472
472
  var DEFAULT_LAUNCHER = {
473
473
  variant: "circle",
@@ -510,12 +510,14 @@ function resolveOptions(rawOpts) {
510
510
  const locale = i18n.locale ?? resolveDefaultLocale(i18n.defaultLocale);
511
511
  const availableLocales = i18n.availableLocales ?? [locale];
512
512
  const baseUrl = (opts.baseUrl ?? BRAND.defaultBaseUrl).replace(/\/$/, "");
513
+ const agentBaseUrl = (opts.agentBaseUrl ?? `${baseUrl}/api/agent`).replace(/\/$/, "");
513
514
  const dataBaseUrl = (opts.dataBaseUrl ?? `${baseUrl}/api/data`).replace(/\/$/, "");
514
515
  return {
515
516
  widgetId: opts.widgetId ?? "default",
516
517
  publicKey: opts.publicKey,
517
518
  aiAgentDeploymentId: opts.aiAgentDeploymentId,
518
519
  baseUrl,
520
+ agentBaseUrl,
519
521
  dataBaseUrl,
520
522
  userContext: sanitizeUserContext(opts.userContext),
521
523
  pageContext: sanitizePageContext(opts.pageContext),
@@ -598,9 +600,9 @@ function resolveTracking(overrides, dataBaseUrl) {
598
600
  const sampleRate = overrides?.sampleRate ?? DEFAULT_TRACKING.sampleRate;
599
601
  return {
600
602
  enabled: overrides?.enabled ?? DEFAULT_TRACKING.enabled,
601
- // The collector rides the data module — `${dataBaseUrl}/elements/px.gif`
602
- // (e.g. `https://help.ai/api/data/elements/px.gif`).
603
- endpoint: overrides?.endpoint ?? `${dataBaseUrl}/elements/px.gif`,
603
+ // The collector rides the data module — `${dataBaseUrl}/pai/elements-px.gif`
604
+ // (e.g. `https://help.ai/api/data/pai/elements-px.gif`).
605
+ endpoint: overrides?.endpoint ?? `${dataBaseUrl}/pai/elements-px.gif`,
604
606
  // Clamp instead of reject — a malformed rate must never turn a
605
607
  // disabled tracker on or crash resolve (no Zod on the IIFE path).
606
608
  sampleRate: Math.min(1, Math.max(0, Number.isFinite(sampleRate) ? sampleRate : 1)),
@@ -829,6 +831,7 @@ var EMBED_SCALAR_ATTRS = [
829
831
  ["public-key", "publicKey"],
830
832
  ["ai-agent-deployment-id", "aiAgentDeploymentId"],
831
833
  ["base-url", "baseUrl"],
834
+ ["agent-base-url", "agentBaseUrl"],
832
835
  ["data-base-url", "dataBaseUrl"],
833
836
  ["theme", "theme", (v) => v]
834
837
  ];
@@ -977,7 +980,7 @@ function parseLauncher(get, str2) {
977
980
  return Object.keys(base).length > 0 ? base : null;
978
981
  }
979
982
 
980
- // src/core/start-conversation-shape.ts
983
+ // src/core/handshake-shape.ts
981
984
  function isPlainObject(raw) {
982
985
  return !!raw && typeof raw === "object" && !Array.isArray(raw);
983
986
  }
@@ -1305,63 +1308,61 @@ function parseWireDate(value) {
1305
1308
  }
1306
1309
  var DEFAULT_PATHS = {
1307
1310
  /** Conversation bootstrap. `POST` → deployment config + per-visitor state. */
1308
- startConversation: "/ai/agent/start-conversation",
1311
+ handshake: "/pai/handshake",
1309
1312
  /** Send a message + stream the reply (SSE). `POST`. */
1310
- streamMessage: "/ai/agent/stream-message",
1313
+ streamMessage: "/pai/stream-message",
1311
1314
  /**
1312
1315
  * Resume an interrupted reply. `GET` (envelope rides as query params).
1313
1316
  * Stateless: the server re-streams the whole reply SSE again from the start —
1314
1317
  * there is NO `Last-Event-ID`. The client reconciles by id (`seenIds`), so
1315
1318
  * replayed events are skipped and only the continuation surfaces.
1316
1319
  */
1317
- streamResume: "/ai/agent/stream-resume",
1320
+ streamResume: "/pai/stream-resume",
1318
1321
  /** Abort the in-flight reply stream. `POST`. */
1319
- cancelStream: "/ai/agent/cancel-stream",
1322
+ cancelStream: "/pai/cancel-stream",
1320
1323
  /** List the visitor's conversations. `GET`. */
1321
- listConversations: "/ai/agent/list-conversations",
1322
- /** Load one conversation's messages. `GET ?conversationId=…`. */
1323
- listMessages: "/ai/agent/list-messages",
1324
+ listConversations: "/pai/list-conversations",
1325
+ /** Load one conversation's thread (messages + canContinue + suggestions). `GET ?conversationId=…`. */
1326
+ listMessages: "/pai/list-messages",
1324
1327
  /**
1325
1328
  * Persist a user-driven settings change. POST `{ visitorId, userPrefs }` →
1326
1329
  * server persists, returns the merged authoritative copy. Fire-and-forget on
1327
1330
  * the client — a failure leaves the local cache valid; the next
1328
- * `startConversation()` reconciles.
1331
+ * `handshake()` reconciles.
1329
1332
  */
1330
- updateSettings: "/ai/agent/update-settings",
1333
+ updateSettings: "/pai/update-settings",
1331
1334
  /**
1332
1335
  * Mark a conversation read. POST `{ visitorId, conversationId }` → the server
1333
1336
  * records `lastReadAt = now` for that (visitor, conversation) and recomputes
1334
1337
  * `unreadCount` (returned on `/list-conversations`) accordingly. Fire-and-forget
1335
1338
  * on the client; a failure just leaves the badge until the next sync.
1336
1339
  */
1337
- markRead: "/ai/agent/mark-read",
1340
+ markRead: "/pai/mark-read",
1338
1341
  // ── Data API (module-help-ai-data-api, via `dataBaseUrl`) ─────────
1339
- /** All widget content via one filtered endpoint. `GET /content?tags=…`. */
1340
- content: "/content",
1341
1342
  /**
1342
- * Resolved form definitions for the deployment. `GET /forms` →
1343
- * `{ forms: FormDef[] }`. Fetched once at boot (in parallel with
1344
- * start-conversation); form gating waits for it. A failure is treated as
1345
- * "no forms" (non-fatal).
1343
+ * The data module's one ACTIVATION read. `GET
1344
+ * /pai/bootstrap?visitorId=…&conversationId=…`
1345
+ * `{ forms, formSubmissions }`: the deployment's resolved form
1346
+ * definitions plus the visitor's recorded submissions for the envelope's
1347
+ * conversation (chronological — they rebuild the timeline markers).
1348
+ * Also re-read with an explicit `conversationId` selector on a
1349
+ * history-pane thread switch. A failure is treated as "no forms, no
1350
+ * records" (non-fatal) — the chat works regardless.
1351
+ */
1352
+ bootstrap: "/pai/bootstrap",
1353
+ /**
1354
+ * All widget content via one filtered endpoint — content stays OUT of the
1355
+ * bootstrap because its reads are query-driven (`tags` / `categories` /
1356
+ * `ids` / `search` + pagination). `GET /pai/content?tags=…`.
1346
1357
  */
1347
- forms: "/forms",
1358
+ content: "/pai/content",
1348
1359
  /**
1349
1360
  * Form submission (the event-driven forms engine). POST `{ visitorId,
1350
1361
  * conversationId, formId, trigger, values, skipped? }` → record the form's answers
1351
1362
  * immediately, keyed by the same `visitorId` + `conversationId` the chat uses.
1352
1363
  * Overridable via `endpoints.submitForm`. Fire-and-forget; 404 is ignored.
1353
1364
  */
1354
- submitForm: "/submit-form",
1355
- /**
1356
- * Visitor interaction records for a conversation. `GET
1357
- * /activity?visitorId=…&conversationId=…` →
1358
- * `{ formSubmissions: FormSubmissionRecord[] }` (chronological). Replaces
1359
- * the former `formSubmissions` echo on start-conversation / list-messages;
1360
- * deliberately generic — future visitor interaction records ride the same
1361
- * endpoint. A failure (e.g. 404 `AI_AGENT_PUBLIC_ACCESS_NOT_FOUND` when the
1362
- * deployment doesn't resolve) is treated as "no records" (non-fatal).
1363
- */
1364
- activity: "/activity"
1365
+ submitForm: "/pai/submit-form"
1365
1366
  };
1366
1367
  var CONTEXT_PARAM = "context";
1367
1368
  function buildSendMessageRequest(params) {
@@ -1462,7 +1463,7 @@ function sleep(ms, signal7) {
1462
1463
  var AgentTransport = class {
1463
1464
  constructor(opts) {
1464
1465
  __publicField(this, "opts", opts);
1465
- // Identity adopted from the start-conversation response; rides the request envelope
1466
+ // Identity adopted from the handshake response; rides the request envelope
1466
1467
  // (see `envelope()`) on every subsequent call so the backend can validate the
1467
1468
  // (visitor, conversation) pair.
1468
1469
  __publicField(this, "visitorId");
@@ -1485,9 +1486,9 @@ var AgentTransport = class {
1485
1486
  }
1486
1487
  /**
1487
1488
  * Seed the visitor + conversation identity from persistence BEFORE the first
1488
- * `startConversation` resolves. A mount-time `resumeStream()` runs in parallel
1489
- * with `startConversation`, so without this its envelope would carry no
1490
- * `conversationId` and the resume GET couldn't identify the thread. `startConversation`
1489
+ * `handshake` resolves. A mount-time `resumeStream()` runs in parallel
1490
+ * with `handshake`, so without this its envelope would carry no
1491
+ * `conversationId` and the resume GET couldn't identify the thread. `handshake`
1491
1492
  * later overwrites these with the server's authoritative ids (rebind-safe).
1492
1493
  */
1493
1494
  primeIdentity(visitorId, conversationId) {
@@ -1504,7 +1505,7 @@ var AgentTransport = class {
1504
1505
  return out;
1505
1506
  }
1506
1507
  /** Merge the envelope into a JSON body (POSTs). Explicit body fields win — e.g.
1507
- * start-conversation's client-proposed ids, or a mark-read target conversation that
1508
+ * handshake's client-proposed ids, or a mark-read target conversation that
1508
1509
  * differs from the active one. */
1509
1510
  withEnvelope(body) {
1510
1511
  return { ...this.envelope(), ...body };
@@ -1525,28 +1526,22 @@ var AgentTransport = class {
1525
1526
  return url.toString();
1526
1527
  }
1527
1528
  /** One-shot runtime bootstrap. Called once after the widget mounts. */
1528
- async startConversation(body) {
1529
- log4.debug("startConversation \u2192", {
1529
+ async handshake(body) {
1530
+ log4.debug("handshake \u2192", {
1530
1531
  visitorId: body.visitorId,
1531
1532
  conversationId: body.conversationId,
1532
1533
  locale: body.locale
1533
1534
  });
1534
- const res = await this.postJson(
1535
- DEFAULT_PATHS.startConversation,
1536
- body,
1537
- "startConversation"
1538
- );
1539
- log4.debug("startConversation \u2190", {
1535
+ const res = await this.postJson(DEFAULT_PATHS.handshake, body, "handshake");
1536
+ log4.debug("handshake \u2190", {
1540
1537
  visitorId: res.visitorId,
1541
1538
  conversationId: res.conversationId,
1542
- canContinue: res.canContinue,
1543
- hasMessages: !!res.messages?.length,
1544
1539
  hasWelcome: !!res.welcome,
1545
1540
  hasConfig: !!res.config,
1546
1541
  rebind: res.rebind
1547
1542
  });
1548
- if (!isStartConversationResponseShape(res)) {
1549
- log4.warn("startConversation response did not match expected shape; using raw response", { response: res });
1543
+ if (!isHandshakeResponseShape(res)) {
1544
+ log4.warn("handshake response did not match expected shape; using raw response", { response: res });
1550
1545
  }
1551
1546
  this.visitorId = res.visitorId ?? body.visitorId;
1552
1547
  this.conversationId = res.conversationId;
@@ -1656,38 +1651,26 @@ var AgentTransport = class {
1656
1651
  return { ...res, items };
1657
1652
  }
1658
1653
  /**
1659
- * Fetch the deployment's resolved form definitions. `GET /forms`
1660
- * (data-API). Replaces the former `config.forms` push on the handshake
1661
- * called once at boot, in parallel with `startConversation`; form gating
1662
- * (pre-chat etc.) waits for it. Callers treat a thrown StreamError as
1663
- * "no forms" (non-fatal).
1664
- */
1665
- async listForms() {
1666
- log4.debug("listForms \u2192");
1667
- const res = await this.getJson(this.dataUrl(DEFAULT_PATHS.forms), "listForms");
1668
- const forms = res.forms ?? [];
1669
- log4.debug("listForms \u2190", { count: forms.length });
1670
- return { forms };
1671
- }
1672
- /**
1673
- * Fetch the visitor's recorded activity for a conversation — currently the
1674
- * form submissions that rebuild the collapsed "form submitted / skipped"
1675
- * timeline markers on resume. `GET /activity` (data-API); the
1676
- * envelope carries `visitorId` / `conversationId` as query params.
1677
- * `conversationId` here is an optional resource selector (a thread that may
1678
- * differ from the active conversation — the history-pane switch); when
1679
- * omitted, the envelope's active conversation applies. Callers treat a
1680
- * thrown StreamError (e.g. 404 on a deployment that doesn't resolve) as
1681
- * "no activity" (non-fatal).
1654
+ * The data module's ONE activation read. `GET /pai/bootstrap` (data-API)
1655
+ * the deployment's resolved form definitions + the visitor's recorded
1656
+ * form submissions for the envelope's conversation. The handshake
1657
+ * (agent-API) is completely independent of these surfaces.
1658
+ *
1659
+ * `conversationId` is an optional resource selector (a thread that may
1660
+ * differ from the active conversation — the history-pane switch re-reads
1661
+ * that thread's submissions); when omitted, the envelope's active
1662
+ * conversation applies. Callers treat a thrown StreamError (e.g. 404 on a
1663
+ * deployment that doesn't resolve) as "no forms, no records" (non-fatal).
1682
1664
  */
1683
- async getActivity(conversationId) {
1684
- const url = new URL(this.dataUrl(DEFAULT_PATHS.activity));
1665
+ async bootstrap(conversationId) {
1666
+ const url = new URL(this.dataUrl(DEFAULT_PATHS.bootstrap));
1685
1667
  if (conversationId) url.searchParams.set("conversationId", conversationId);
1686
- log4.debug("getActivity \u2192", { conversationId: conversationId ?? this.conversationId });
1687
- const res = await this.getJson(url.toString(), "getActivity");
1668
+ log4.debug("bootstrap \u2192", { conversationId: conversationId ?? this.conversationId });
1669
+ const res = await this.getJson(url.toString(), "bootstrap");
1670
+ const forms = res.forms ?? [];
1688
1671
  const formSubmissions = res.formSubmissions ?? [];
1689
- log4.debug("getActivity \u2190", { count: formSubmissions.length });
1690
- return { formSubmissions };
1672
+ log4.debug("bootstrap \u2190", { forms: forms.length, formSubmissions: formSubmissions.length });
1673
+ return { forms, formSubmissions };
1691
1674
  }
1692
1675
  async saveUserPrefs(userPrefs) {
1693
1676
  log4.debug("saveUserPrefs \u2192", {
@@ -1700,10 +1683,10 @@ var AgentTransport = class {
1700
1683
  return res;
1701
1684
  }
1702
1685
  get uploadPath() {
1703
- return this.opts.endpoints?.upload ?? "/ai/agent/upload-file";
1686
+ return this.opts.endpoints?.upload ?? "/pai/upload-file";
1704
1687
  }
1705
1688
  get transcribePath() {
1706
- return this.opts.endpoints?.transcribe ?? "/ai/agent/transcribe-audio";
1689
+ return this.opts.endpoints?.transcribe ?? "/pai/transcribe-audio";
1707
1690
  }
1708
1691
  get submitFormPath() {
1709
1692
  return this.opts.endpoints?.submitForm ?? DEFAULT_PATHS.submitForm;
@@ -1743,9 +1726,9 @@ var AgentTransport = class {
1743
1726
  * Re-attach to an in-flight reply on a cold page load / refresh.
1744
1727
  *
1745
1728
  * UNCONDITIONAL by design — the caller runs this in parallel with
1746
- * `startConversation`, never gated on whether there are persisted messages. If
1729
+ * `handshake`, never gated on whether there are persisted messages. If
1747
1730
  * the user refreshed while the assistant was still streaming, the
1748
- * `startConversation` reply carries no messages (the turn isn't persisted until
1731
+ * `handshake` reply carries no messages (the turn isn't persisted until
1749
1732
  * it finishes) and THIS is the channel that resumes the live reply. When
1750
1733
  * nothing is in flight the server answers 204 and the handle yields nothing
1751
1734
  * (the common case — no bubble is created).
@@ -1772,7 +1755,7 @@ var AgentTransport = class {
1772
1755
  * - after a POST /stream-message drops mid-reply (`immediate=false` — back
1773
1756
  * off first, the reply may still be spinning up server-side);
1774
1757
  * - on a cold page load / refresh (`immediate=true` — hit it at once, in
1775
- * parallel with start-conversation, so a reply that was streaming when the
1758
+ * parallel with handshake, so a reply that was streaming when the
1776
1759
  * user reloaded keeps flowing).
1777
1760
  *
1778
1761
  * The endpoint ALWAYS opens a 200 SSE stream (mirrors the AI SDK
@@ -1781,7 +1764,7 @@ var AgentTransport = class {
1781
1764
  * nothing ever started) — closes an EMPTY stream. So "nothing to resume" is
1782
1765
  * signalled by a segment that ends without a terminal chunk AND surfaced no
1783
1766
  * NEW events — not by a status code. We stop gracefully there; the completed
1784
- * turn comes from the DB via start-conversation / list-messages.
1767
+ * turn comes from the DB via the handshake / list-messages.
1785
1768
  *
1786
1769
  * Returns (no throw) on a terminal chunk, an empty/no-new-events segment, or
1787
1770
  * a 204 / non-OK (a backend that signals "nothing" by status, or lacks the
@@ -1846,7 +1829,7 @@ var AgentTransport = class {
1846
1829
  }
1847
1830
  return false;
1848
1831
  }
1849
- /** Abort + fire-and-forget POST to `/ai/agent/cancel-stream`. Idempotent. */
1832
+ /** Abort + fire-and-forget POST to `/pai/cancel-stream`. Idempotent. */
1850
1833
  cancelStream(ctrl, conversationId) {
1851
1834
  if (ctrl.signal.aborted) return;
1852
1835
  log4.debug("cancel stream", { conversationId, visitorId: this.visitorId });
@@ -1887,7 +1870,7 @@ var AgentTransport = class {
1887
1870
  return url.toString();
1888
1871
  }
1889
1872
  // JSON requests are idempotent (reads, or full-snapshot writes like
1890
- // update-settings / mark-read / start-conversation), so they auto-retry transient
1873
+ // update-settings / mark-read / handshake), so they auto-retry transient
1891
1874
  // failures. The message POST is NOT in here — re-sending could duplicate the
1892
1875
  // reply, so it surfaces an error for the user to retry (see `openMessageStream`).
1893
1876
  async postJson(path, body, label) {
@@ -1956,23 +1939,22 @@ var AgentTransport = class {
1956
1939
  return await res.json();
1957
1940
  }
1958
1941
  /**
1959
- * Agent-API URL — resolves a `/ai/agent/*` path against the agent module
1960
- * base, `${baseUrl}/api/agent`. Absolute URLs pass through unchanged.
1942
+ * Agent-API URL — resolves a `/pai/*` path against the resolved
1943
+ * agent module base. Absolute URLs pass through unchanged.
1961
1944
  */
1962
1945
  url(pathOrUrl) {
1963
1946
  if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
1964
- return `${this.opts.baseUrl}/api/agent${pathOrUrl}`;
1947
+ return `${this.opts.agentBaseUrl}${pathOrUrl}`;
1965
1948
  }
1966
1949
  /**
1967
1950
  * Data-API variant of {@link url} — resolves a root-level path (content /
1968
- * forms / submit-form / activity) against `dataBaseUrl`. Absolute URLs
1969
- * (e.g. an `endpoints.submitForm` pointing straight at a CRM) pass through
1970
- * unchanged; with no `dataBaseUrl` configured the data module derives from
1971
- * the main origin: `${baseUrl}/api/data`.
1951
+ * forms / submit-form / activity) against the resolved data module base.
1952
+ * Absolute URLs (e.g. an `endpoints.submitForm` pointing straight at a
1953
+ * CRM) pass through unchanged.
1972
1954
  */
1973
1955
  dataUrl(pathOrUrl) {
1974
1956
  if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
1975
- return `${this.opts.dataBaseUrl ?? `${this.opts.baseUrl}/api/data`}${pathOrUrl}`;
1957
+ return `${this.opts.dataBaseUrl}${pathOrUrl}`;
1976
1958
  }
1977
1959
  get fetchImpl() {
1978
1960
  return this.opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
@@ -1986,10 +1968,10 @@ function extensionFor(mimeType) {
1986
1968
  if (mimeType.includes("wav")) return "wav";
1987
1969
  return "bin";
1988
1970
  }
1989
- function isStartConversationResponseShape(raw) {
1971
+ function isHandshakeResponseShape(raw) {
1990
1972
  if (!raw || typeof raw !== "object") return false;
1991
1973
  const r = raw;
1992
- return typeof r.visitorId === "string" && typeof r.conversationId === "string" && typeof r.canContinue === "boolean";
1974
+ return typeof r.visitorId === "string" && typeof r.conversationId === "string";
1993
1975
  }
1994
1976
 
1995
1977
  // src/stream/messages.ts
@@ -6504,6 +6486,14 @@ function App({ options, hostElement, bus }) {
6504
6486
  resolveInitialPanelState(options, currentViewportWidth(), persistence.loadPanelOpen(), persistence.loadPanelSize())
6505
6487
  );
6506
6488
  const [isOpen, setIsOpen] = useState13(initialPanelRef.current.panelOpen);
6489
+ const [activated, setActivated] = useState13(initialPanelRef.current.panelOpen);
6490
+ const activatedRef = useRef9(activated);
6491
+ activatedRef.current = activated;
6492
+ const pendingThreadRef = useRef9(false);
6493
+ const welcomeRef = useRef9(void 0);
6494
+ useEffect17(() => {
6495
+ if (isOpen) setActivated(true);
6496
+ }, [isOpen]);
6507
6497
  const initialPanelApplied = useRef9(false);
6508
6498
  const [launcherLeaving, setLauncherLeaving] = useState13(false);
6509
6499
  const { dismissed: calloutDismissed, dismissCallout: dismissCalloutRaw } = useLauncherCallout({
@@ -6530,7 +6520,7 @@ function App({ options, hostElement, bus }) {
6530
6520
  const [formContext, setFormContext] = useState13({});
6531
6521
  const [transport] = useState13(
6532
6522
  () => new AgentTransport({
6533
- baseUrl: options.baseUrl,
6523
+ agentBaseUrl: options.agentBaseUrl,
6534
6524
  dataBaseUrl: options.dataBaseUrl,
6535
6525
  auth: createAuth({
6536
6526
  publicKey: options.publicKey,
@@ -6612,10 +6602,11 @@ function App({ options, hostElement, bus }) {
6612
6602
  },
6613
6603
  [messagesSig]
6614
6604
  );
6605
+ const dataBootRef = useRef9(null);
6615
6606
  const fetchFormMarkers = useCallback6(
6616
6607
  async (conversationId) => {
6617
6608
  try {
6618
- const { formSubmissions: subs } = await transport.getActivity(conversationId);
6609
+ const { formSubmissions: subs } = await (conversationId ? transport.bootstrap(conversationId) : dataBootRef.current ?? transport.bootstrap());
6619
6610
  const lastIndexByForm = /* @__PURE__ */ new Map();
6620
6611
  subs.forEach((rec, i) => lastIndexByForm.set(rec.formId, i));
6621
6612
  return subs.filter((rec, i) => !rec.skipped || lastIndexByForm.get(rec.formId) === i).map((rec, i) => ({
@@ -6626,14 +6617,53 @@ function App({ options, hostElement, bus }) {
6626
6617
  values: rec.values
6627
6618
  }));
6628
6619
  } catch (err) {
6629
- log16.debug("getActivity failed \u2014 no form markers (non-fatal)", { err });
6620
+ log16.debug("bootstrap failed \u2014 no form markers (non-fatal)", { err });
6630
6621
  return [];
6631
6622
  }
6632
6623
  },
6633
6624
  [transport]
6634
6625
  );
6635
6626
  const connectGenRef = useRef9(0);
6636
- const runStartConversation = useCallback6(
6627
+ const loadThread = useCallback6(
6628
+ async (conversationId) => {
6629
+ const myGen = connectGenRef.current;
6630
+ const isStale = () => myGen !== connectGenRef.current;
6631
+ setLoadingMessages(true);
6632
+ try {
6633
+ const [chat, markers] = await Promise.all([transport.loadConversation(conversationId), fetchFormMarkers()]);
6634
+ if (isStale()) return;
6635
+ const loaded = (chat.messages ?? []).map(fromWireMessage);
6636
+ setFormMarkers(markers);
6637
+ const timelineStamps = [...loaded.map((m) => m.createdAt), ...markers.map((m) => m.createdAt)].filter(
6638
+ (n) => n !== void 0
6639
+ );
6640
+ const welcomeAnchor = (timelineStamps.length ? Math.min(...timelineStamps) : Date.now()) - 1;
6641
+ const loadedIds = new Set(loaded.map((m) => m.id));
6642
+ const welcomeRows = (welcomeRef.current?.messages ?? []).filter((w) => !loadedIds.has(w.id)).map((w) => {
6643
+ const row = makeInstantWelcomeMessage(w);
6644
+ row.createdAt = welcomeAnchor;
6645
+ return row;
6646
+ });
6647
+ const tail = resumeBubbleRef.current ? [resumeBubbleRef.current] : [];
6648
+ if (loaded.length || tail.length) {
6649
+ messagesSig.value = [...welcomeRows, ...loaded, ...tail];
6650
+ setCanSend(chat.canContinue ?? true);
6651
+ setSuggestions(chat.suggestions ?? []);
6652
+ } else {
6653
+ messagesSig.value = welcomeRows;
6654
+ }
6655
+ } catch (err) {
6656
+ if (isStale()) return;
6657
+ log16.warn("loadThread failed; resetting conversationId", { err, conversationId });
6658
+ conversationIdSig.value = void 0;
6659
+ persistence.saveConversationId(void 0);
6660
+ } finally {
6661
+ if (!isStale()) setLoadingMessages(false);
6662
+ }
6663
+ },
6664
+ [transport, fetchFormMarkers, messagesSig, conversationIdSig, persistence]
6665
+ );
6666
+ const runHandshake = useCallback6(
6637
6667
  async ({ newConversation }) => {
6638
6668
  const myGen = ++connectGenRef.current;
6639
6669
  const isStale = () => myGen !== connectGenRef.current;
@@ -6645,7 +6675,7 @@ function App({ options, hostElement, bus }) {
6645
6675
  }
6646
6676
  let res;
6647
6677
  try {
6648
- res = await transport.startConversation({
6678
+ res = await transport.handshake({
6649
6679
  visitorId,
6650
6680
  conversationId,
6651
6681
  userPrefs: persistence.loadUserPrefs(),
@@ -6696,67 +6726,22 @@ function App({ options, hostElement, bus }) {
6696
6726
  if (res.userPrefs.themeMode) setActiveThemeMode(res.userPrefs.themeMode);
6697
6727
  if (res.userPrefs.textSize) setActiveTextSize(res.userPrefs.textSize);
6698
6728
  }
6729
+ welcomeRef.current = res.welcome;
6699
6730
  const isResume = !newConversation && persistedChatId && res.conversationId === persistedChatId;
6700
6731
  if (isResume) {
6701
- setLoadingMessages(true);
6702
- try {
6703
- const chatPromise = res.messages?.length ? Promise.resolve({
6704
- conversationId: persistedChatId,
6705
- canContinue: res.canContinue ?? true,
6706
- messages: res.messages
6707
- }) : transport.loadConversation(persistedChatId);
6708
- const [chat, markers] = await Promise.all([chatPromise, fetchFormMarkers()]);
6709
- if (isStale()) return;
6710
- const loaded = (chat.messages ?? []).map(fromWireMessage);
6711
- setFormMarkers(markers);
6712
- const timelineStamps = [...loaded.map((m) => m.createdAt), ...markers.map((m) => m.createdAt)].filter(
6713
- (n) => n !== void 0
6714
- );
6715
- const welcomeAnchor = (timelineStamps.length ? Math.min(...timelineStamps) : Date.now()) - 1;
6716
- const loadedIds = new Set(loaded.map((m) => m.id));
6717
- const welcomeRows = (res.welcome?.messages ?? []).filter((w) => !loadedIds.has(w.id)).map((w) => {
6718
- const row = makeInstantWelcomeMessage(w);
6719
- row.createdAt = welcomeAnchor;
6720
- return row;
6721
- });
6722
- const tail = resumeBubbleRef.current ? [resumeBubbleRef.current] : [];
6723
- if (loaded.length || tail.length) {
6724
- messagesSig.value = [...welcomeRows, ...loaded, ...tail];
6725
- setCanSend(chat.canContinue ?? true);
6726
- setSuggestions(chat.suggestions ?? []);
6727
- } else {
6728
- messagesSig.value = welcomeRows;
6729
- }
6730
- } catch (err) {
6731
- if (isStale()) return;
6732
- log16.warn("loadConversation failed; resetting conversationId", { err, conversationId: persistedChatId });
6733
- conversationIdSig.value = void 0;
6734
- persistence.saveConversationId(void 0);
6735
- } finally {
6736
- if (!isStale()) setLoadingMessages(false);
6737
- }
6732
+ if (activatedRef.current) void loadThread(persistedChatId);
6733
+ else pendingThreadRef.current = true;
6738
6734
  } else {
6739
6735
  conversationIdSig.value = res.conversationId;
6740
6736
  persistence.saveConversationId(res.conversationId);
6741
6737
  playWelcome(res.welcome?.messages);
6742
6738
  }
6743
6739
  if (!newConversation) {
6744
- bus.emit("conversationStarted", res);
6740
+ bus.emit("handshake", res);
6745
6741
  setConversationReady(true);
6746
6742
  }
6747
6743
  },
6748
- [
6749
- transport,
6750
- visitorId,
6751
- persistence,
6752
- options,
6753
- bus,
6754
- messagesSig,
6755
- conversationIdSig,
6756
- feedback,
6757
- playWelcome,
6758
- fetchFormMarkers
6759
- ]
6744
+ [transport, visitorId, persistence, options, bus, conversationIdSig, feedback, playWelcome, loadThread]
6760
6745
  );
6761
6746
  const userSig = JSON.stringify(options.userContext ?? null);
6762
6747
  const pageSig = JSON.stringify(options.pageContext ?? null);
@@ -6809,36 +6794,46 @@ function App({ options, hostElement, bus }) {
6809
6794
  },
6810
6795
  [reducer, messagesSig, feedback, bus, options, homeNav]
6811
6796
  );
6797
+ const resumeHandleRef = useRef9(null);
6798
+ useEffect17(() => {
6799
+ const persistedConversation = conversationIdSig.value;
6800
+ if (persistedConversation) transport.primeIdentity(visitorId, persistedConversation);
6801
+ void runHandshake({ newConversation: false });
6802
+ return () => {
6803
+ connectGenRef.current++;
6804
+ resumeHandleRef.current?.cancel();
6805
+ };
6806
+ }, []);
6812
6807
  useEffect17(() => {
6813
- void runStartConversation({ newConversation: false });
6808
+ if (!activated) return;
6809
+ dataBootRef.current = transport.bootstrap();
6814
6810
  void (async () => {
6815
6811
  try {
6816
- const res = await transport.listForms();
6817
- setRemoteForms(resolveForms(res.forms));
6812
+ const res = await dataBootRef.current;
6813
+ setRemoteForms(resolveForms((res ?? { forms: [] }).forms));
6818
6814
  } catch (err) {
6819
- log16.debug("listForms failed \u2014 treating as no forms", { err });
6815
+ log16.debug("bootstrap failed \u2014 treating as no forms", { err });
6820
6816
  } finally {
6821
6817
  setFormsReady(true);
6822
6818
  }
6823
6819
  })();
6824
- const persistedConversation = conversationIdSig.value;
6825
- let resume = null;
6826
- if (persistedConversation) {
6827
- transport.primeIdentity(visitorId, persistedConversation);
6828
- resume = transport.resumeStream();
6829
- void runResume(resume);
6820
+ if (pendingThreadRef.current) {
6821
+ pendingThreadRef.current = false;
6822
+ const persisted = conversationIdSig.value;
6823
+ if (persisted) void loadThread(persisted);
6830
6824
  }
6831
- return () => {
6832
- connectGenRef.current++;
6833
- resume?.cancel();
6834
- };
6835
- }, []);
6825
+ if (conversationIdSig.value) {
6826
+ const handle = transport.resumeStream();
6827
+ resumeHandleRef.current = handle;
6828
+ void runResume(handle);
6829
+ }
6830
+ }, [activated]);
6836
6831
  const lastUserSig = useRef9(userSig);
6837
6832
  useEffect17(() => {
6838
6833
  if (!conversationReady || lastUserSig.current === userSig) return;
6839
6834
  lastUserSig.current = userSig;
6840
- void runStartConversation({ newConversation: false });
6841
- }, [userSig, conversationReady, runStartConversation]);
6835
+ void runHandshake({ newConversation: false });
6836
+ }, [userSig, conversationReady, runHandshake]);
6842
6837
  useEffect17(() => {
6843
6838
  applyMode(hostElement, computeHostMode(options.mode, panelSize, isOpen));
6844
6839
  }, [hostElement, isOpen, panelSize, options.mode]);
@@ -7065,10 +7060,10 @@ function App({ options, hostElement, bus }) {
7065
7060
  }, [transport, options.modules, unreadCountSig]);
7066
7061
  const unreadSeeded = useRef9(false);
7067
7062
  useEffect17(() => {
7068
- if (!conversationReady || unreadSeeded.current) return;
7063
+ if (!activated || !conversationReady || unreadSeeded.current) return;
7069
7064
  unreadSeeded.current = true;
7070
7065
  refreshUnread();
7071
- }, [conversationReady, refreshUnread]);
7066
+ }, [activated, conversationReady, refreshUnread]);
7072
7067
  const handleOpen = useCallback6(() => {
7073
7068
  log16.info("open", { mode: options.mode });
7074
7069
  feedback.unlock();
@@ -7090,8 +7085,8 @@ function App({ options, hostElement, bus }) {
7090
7085
  persistence.clearConversation();
7091
7086
  setCanSend(true);
7092
7087
  setFormMarkers([]);
7093
- void runStartConversation({ newConversation: true });
7094
- }, [streaming, activeCancel, messagesSig, conversationIdSig, persistence, runStartConversation, bus]);
7088
+ void runHandshake({ newConversation: true });
7089
+ }, [streaming, activeCancel, messagesSig, conversationIdSig, persistence, runHandshake, bus]);
7095
7090
  const handleNewChat = useCallback6(() => {
7096
7091
  handleClear();
7097
7092
  setView("chat");
@@ -7202,6 +7197,20 @@ function App({ options, hostElement, bus }) {
7202
7197
  },
7203
7198
  [transport, messagesSig, conversationIdSig, bus, options, persistence, refreshUnread, fetchFormMarkers]
7204
7199
  );
7200
+ const pendingOpenFormRef = useRef9(null);
7201
+ const openForm = useCallback6(
7202
+ (id) => {
7203
+ if (typeof id !== "string") return;
7204
+ if (formsReady) forms.fire("manual", id);
7205
+ else pendingOpenFormRef.current = id;
7206
+ },
7207
+ [formsReady, forms]
7208
+ );
7209
+ useEffect17(() => {
7210
+ if (!formsReady || !pendingOpenFormRef.current) return;
7211
+ forms.fire("manual", pendingOpenFormRef.current);
7212
+ pendingOpenFormRef.current = null;
7213
+ }, [formsReady, forms]);
7205
7214
  useEffect17(() => {
7206
7215
  const unsub = bindHostCommands(hostElement, {
7207
7216
  open: handleOpen,
@@ -7209,10 +7218,10 @@ function App({ options, hostElement, bus }) {
7209
7218
  expand: handleExpand,
7210
7219
  fullscreen: handleFullscreen,
7211
7220
  popout: handlePopOut,
7212
- openForm: (id) => forms.fire("manual", typeof id === "string" ? id : void 0)
7221
+ openForm
7213
7222
  });
7214
7223
  return unsub;
7215
- }, [hostElement, handleOpen, handleClose, handleExpand, handleFullscreen, handlePopOut, forms]);
7224
+ }, [hostElement, handleOpen, handleClose, handleExpand, handleFullscreen, handlePopOut, openForm]);
7216
7225
  const effectiveOptions = useMemo3(
7217
7226
  () => applyOptionOverrides(options, activeLocale, activeThemeMode, activeTextSize),
7218
7227
  [options, activeLocale, activeThemeMode, activeTextSize]
@@ -7449,7 +7458,7 @@ var ChatElement = class extends HTMLElement {
7449
7458
  /**
7450
7459
  * Raw options accumulator. Starts as whatever the page-owner set via
7451
7460
  * HTML attributes; gets server-side settings merged in on the
7452
- * `conversationStarted` event so subsequent re-renders pick up the server's
7461
+ * `handshake` event so subsequent re-renders pick up the server's
7453
7462
  * mode / theme / launcher / etc.
7454
7463
  *
7455
7464
  * Mirrors `index.ts`'s `rawOptions` so both entry points share the
@@ -7479,7 +7488,7 @@ var ChatElement = class extends HTMLElement {
7479
7488
  existingHost: this,
7480
7489
  position: hostPositionForMode(resolved.mode)
7481
7490
  });
7482
- this.bus.on("conversationStarted", (response) => {
7491
+ this.bus.on("handshake", (response) => {
7483
7492
  if (!response.config) return;
7484
7493
  this.rawOptions = mergeServerConfig(this.rawOptions, response.config);
7485
7494
  this.renderApp();
@@ -7489,7 +7498,7 @@ var ChatElement = class extends HTMLElement {
7489
7498
  }
7490
7499
  /**
7491
7500
  * Apply the current `rawOptions` to the host element + App tree.
7492
- * Called by both the initial boot and every `conversationStarted` merge.
7501
+ * Called by both the initial boot and every `handshake` merge.
7493
7502
  */
7494
7503
  renderApp() {
7495
7504
  if (!this.mountResult) return;