@sunboard/react 1.0.0 → 2.0.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/dist/index.mjs CHANGED
@@ -3,7 +3,6 @@
3
3
  import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
5
  import { FloatingArrow, FloatingPortal, arrow, autoUpdate, flip, offset, shift, useFloating } from "@floating-ui/react";
6
- import { flushSync } from "react-dom";
7
6
  //#region src/debug.ts
8
7
  const noLog = () => {};
9
8
  function consoleLogger() {
@@ -194,7 +193,9 @@ const defaultTheme = createTheme("Default", {
194
193
  const defaultComponentSettings = {
195
194
  launcher: {
196
195
  showBranding: true,
197
- supportUrl: null
196
+ supportUrl: null,
197
+ talkToPerson: false,
198
+ replyTime: "We reply within a few hours."
198
199
  },
199
200
  tour: {
200
201
  showStepCount: true,
@@ -220,7 +221,6 @@ const enLabels = {
220
221
  cancel: "Cancel",
221
222
  send: "Send",
222
223
  latest: "Latest",
223
- sourcesCount: (count) => `${count} ${count === 1 ? "source" : "sources"}`,
224
224
  greeting: (name) => name ? `Hey ${name},` : "Hey there,",
225
225
  guideClosing: "I put together a guide for you that walks you through it.",
226
226
  tourFinishedAsk: "Tour finished, how was your experience?",
@@ -228,7 +228,7 @@ const enLabels = {
228
228
  rateTour: "Rate this tour",
229
229
  stars: (count) => `${count} ${count === 1 ? "star" : "stars"}`,
230
230
  ratingThanks: "Thanks for the feedback.",
231
- conversation: "Conversation",
231
+ thread: "Conversation",
232
232
  writingStatus: "Writing an answer",
233
233
  stages: {
234
234
  searching: (product) => `Searching the ${product} help docs`,
@@ -236,7 +236,6 @@ const enLabels = {
236
236
  checking: "Checking it against the docs",
237
237
  slow: "Taking a little longer than usual"
238
238
  },
239
- sources: "Sources",
240
239
  commonQuestions: "Common questions",
241
240
  showMeHow: "Show me how",
242
241
  newTab: "(opens in a new tab)",
@@ -249,11 +248,39 @@ const enLabels = {
249
248
  lostBody: "Check your connection and try again.",
250
249
  tryAgain: "Try again",
251
250
  notCovered: (product) => `The ${product} help docs don't cover this yet.`,
252
- coveredBy: (article, heading) => heading ? `The section "${heading}" of the article "${article}" covers this.` : `The article "${article}" covers this.`,
253
- guideFound: "There is a guide for this.",
254
251
  closeAria: "Close help",
255
252
  poweredBy: "Powered by"
256
253
  },
254
+ support: {
255
+ talkToPerson: "Talk to a person",
256
+ title: (company) => `${company} support`,
257
+ back: "Back",
258
+ talking: (company) => `Talking to ${company} support`,
259
+ greeting: (name) => `Hey ${name ?? "there"}, what can we help you with?`,
260
+ from: (company) => `${company} support`,
261
+ boxLabel: (company) => `Write to ${company} support`,
262
+ placeholder: (company) => `Write to ${company} support…`,
263
+ teamMessage: (who, company, time) => [
264
+ who,
265
+ `${company} support`,
266
+ time
267
+ ].filter(Boolean).join(" · "),
268
+ asked: (question) => `Asked the assistant: ${question}`,
269
+ closed: (company) => `${company} support closed this conversation`,
270
+ reopen: "Write again to reopen it.",
271
+ conversationWith: (company) => `Conversation with ${company} support`,
272
+ replied: (who) => `${who} replied`,
273
+ waiting: "Waiting for a reply",
274
+ newReply: "New reply",
275
+ openAriaNewReply: "Open help, new reply",
276
+ past: "Past conversations",
277
+ closedWhen: (when, who) => who ? `Closed ${when} · ${who}` : `Closed ${when}`,
278
+ ago: (days) => days < 1 ? "today" : days < 2 ? "yesterday" : days < 14 ? `${days} days ago` : days < 60 ? `${Math.round(days / 7)} weeks ago` : `${Math.round(days / 30)} months ago`,
279
+ tooMany: "Too many messages this hour",
280
+ writeAgainIn: (minutes) => `You can write again in about ${minutes} ${minutes === 1 ? "minute" : "minutes"}.`,
281
+ lost: "Could not send your message",
282
+ lostBody: "Check your connection and try again."
283
+ },
257
284
  tour: {
258
285
  back: "Back",
259
286
  next: "Next",
@@ -283,6 +310,10 @@ function withLabels(overrides) {
283
310
  ...enLabels.launcher,
284
311
  ...overrides.launcher
285
312
  },
313
+ support: {
314
+ ...enLabels.support,
315
+ ...overrides.support
316
+ },
286
317
  tour: {
287
318
  ...enLabels.tour,
288
319
  ...overrides.tour
@@ -382,6 +413,8 @@ const TOUR_RATINGS = [
382
413
  4,
383
414
  5
384
415
  ];
416
+ /** One home for the cap, so the composer and the route agree on it. */
417
+ const CONVERSATION_MESSAGE_MAX_LENGTH = 4e3;
385
418
  //#endregion
386
419
  //#region src/active-tour.ts
387
420
  const STORAGE_KEY = "sunboard:active-tour";
@@ -418,7 +451,7 @@ function stepInRange(guides, key, stepIndex) {
418
451
  const PREFIX = "sunboard:bootstrap:";
419
452
  /** Past this, content the server has since changed is likelier than not. */
420
453
  const MAX_AGE_MS = 6048e5;
421
- const VERSION = 4;
454
+ const VERSION = 5;
422
455
  function readCachedBootstrap(key) {
423
456
  if (typeof window === "undefined") return null;
424
457
  try {
@@ -1355,15 +1388,221 @@ function useThemeMode(setting) {
1355
1388
  return setting === "auto" ? detected : setting;
1356
1389
  }
1357
1390
  //#endregion
1391
+ //#region src/memory-support.ts
1392
+ let counter = 0;
1393
+ const nextId = (kind) => `memory-${kind}-${++counter}`;
1394
+ var MemorySupport = class {
1395
+ script;
1396
+ starts = [];
1397
+ sent = [];
1398
+ reads = [];
1399
+ polls = 0;
1400
+ views = 0;
1401
+ conversations;
1402
+ constructor(script) {
1403
+ this.script = script;
1404
+ this.conversations = (script.conversations ?? []).map(stored);
1405
+ }
1406
+ conversationPoll() {
1407
+ this.polls += 1;
1408
+ const open = this.open();
1409
+ return Promise.resolve({
1410
+ open: open ? summary(open) : null,
1411
+ hasPast: this.conversations.some((conversation) => conversation.view.closed)
1412
+ });
1413
+ }
1414
+ conversation(id, after) {
1415
+ this.views += 1;
1416
+ const found = this.find(id);
1417
+ if (!found) return Promise.resolve(null);
1418
+ const view = structuredClone(found.view);
1419
+ const at = after === void 0 ? -1 : view.messages.findIndex((message) => message.id === after);
1420
+ return Promise.resolve({
1421
+ ...view,
1422
+ messages: view.messages.slice(at + 1)
1423
+ });
1424
+ }
1425
+ startConversation(start) {
1426
+ this.starts.push(start);
1427
+ if (this.script.refuse) return Promise.resolve({ refused: this.script.refuse });
1428
+ let conversation = this.open();
1429
+ if (!conversation) {
1430
+ conversation = stored({ messages: [] });
1431
+ this.conversations.unshift(conversation);
1432
+ }
1433
+ return Promise.resolve(this.write(conversation, start.text));
1434
+ }
1435
+ sendMessage(id, text) {
1436
+ this.sent.push({
1437
+ id,
1438
+ text
1439
+ });
1440
+ if (this.script.refuse) return Promise.resolve({ refused: this.script.refuse });
1441
+ const conversation = this.find(id);
1442
+ if (!conversation) return Promise.resolve({ refused: "lost" });
1443
+ return Promise.resolve(this.write(conversation, text));
1444
+ }
1445
+ markRead(id) {
1446
+ this.reads.push(id);
1447
+ const conversation = this.find(id);
1448
+ if (conversation) conversation.unread = false;
1449
+ return Promise.resolve();
1450
+ }
1451
+ pastConversations() {
1452
+ const closed = this.conversations.filter((conversation) => conversation.view.closed).sort((a, b) => (b.view.closedAt ?? "").localeCompare(a.view.closedAt ?? ""));
1453
+ return Promise.resolve(closed.map((conversation) => ({
1454
+ id: conversation.view.id,
1455
+ title: conversation.view.messages.find((message) => message.by === "END_USER")?.text ?? "",
1456
+ closedAt: conversation.view.closedAt,
1457
+ closedBy: conversation.closedBy
1458
+ })));
1459
+ }
1460
+ /** The team writes into the open conversation. */
1461
+ teamReplies(text, who) {
1462
+ const conversation = this.open();
1463
+ if (!conversation) return;
1464
+ conversation.view.messages.push(message("TEAM", text, who));
1465
+ conversation.view.status = "PENDING";
1466
+ conversation.unread = true;
1467
+ }
1468
+ /** The team closes the open conversation. */
1469
+ teamCloses(who) {
1470
+ const conversation = this.open();
1471
+ if (!conversation) return;
1472
+ conversation.view.status = "CLOSED";
1473
+ conversation.view.closed = true;
1474
+ conversation.view.closedAt = (/* @__PURE__ */ new Date()).toISOString();
1475
+ conversation.closedBy = who;
1476
+ }
1477
+ write(conversation, text) {
1478
+ const sent = message("END_USER", text);
1479
+ const first = conversation.view.messages.length === 0;
1480
+ conversation.view.messages.push(sent);
1481
+ conversation.view.status = "OPEN";
1482
+ conversation.view.closed = false;
1483
+ conversation.view.closedAt = null;
1484
+ conversation.closedBy = null;
1485
+ const { reply } = this.script;
1486
+ if (first && reply) setTimeout(() => this.teamReplies(reply.text, reply.who), reply.afterMs);
1487
+ return {
1488
+ summary: {
1489
+ ...summary(conversation),
1490
+ unread: false
1491
+ },
1492
+ message: sent
1493
+ };
1494
+ }
1495
+ open() {
1496
+ return this.conversations.find((conversation) => !conversation.view.closed);
1497
+ }
1498
+ find(id) {
1499
+ return this.conversations.find((conversation) => conversation.view.id === id);
1500
+ }
1501
+ };
1502
+ function message(by, text, who) {
1503
+ return {
1504
+ id: nextId("message"),
1505
+ by,
1506
+ who: who ?? null,
1507
+ text,
1508
+ sentAt: (/* @__PURE__ */ new Date()).toISOString()
1509
+ };
1510
+ }
1511
+ function folded(questions) {
1512
+ return (questions ?? []).map((question) => ({
1513
+ ...question,
1514
+ id: nextId("question"),
1515
+ outcome: "ANSWERED"
1516
+ }));
1517
+ }
1518
+ function stored(script) {
1519
+ const messages = script.messages.map((scripted) => ({
1520
+ id: nextId("message"),
1521
+ by: scripted.by,
1522
+ who: scripted.who ?? null,
1523
+ text: scripted.text,
1524
+ sentAt: scripted.sentAt
1525
+ }));
1526
+ const closed = script.closedAt !== void 0;
1527
+ return {
1528
+ view: {
1529
+ id: nextId("conversation"),
1530
+ status: closed ? "CLOSED" : messages.at(-1)?.by === "TEAM" ? "PENDING" : "OPEN",
1531
+ startedAt: messages[0]?.sentAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1532
+ closedAt: script.closedAt ?? null,
1533
+ closed,
1534
+ messages,
1535
+ before: folded(script.before),
1536
+ during: folded(script.during)
1537
+ },
1538
+ unread: script.unread ?? false,
1539
+ closedBy: script.closedBy ?? null
1540
+ };
1541
+ }
1542
+ function summary({ view, unread }) {
1543
+ const last = view.messages.at(-1);
1544
+ return {
1545
+ id: view.id,
1546
+ status: view.status,
1547
+ lastMessageAt: last?.sentAt ?? view.startedAt,
1548
+ lastMessageBy: last?.by ?? "END_USER",
1549
+ lastMessageText: last?.text ?? "",
1550
+ lastMessageWho: last?.by === "TEAM" ? last.who ?? null : null,
1551
+ unread
1552
+ };
1553
+ }
1554
+ //#endregion
1555
+ //#region src/user-token.ts
1556
+ const NOT_A_TOKEN = "Sunboard: userToken is not a user token. Make it on your server with your project's identity secret.";
1557
+ function decodeSegment(segment) {
1558
+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
1559
+ const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
1560
+ return JSON.parse(new TextDecoder().decode(bytes));
1561
+ }
1562
+ /**
1563
+ * Reads the token's claims without checking its signature, which only the runtime can do. Throws
1564
+ * on anything that is not a token with a subject.
1565
+ */
1566
+ function readUserToken(token) {
1567
+ const [, body, signature, extra] = token.split(".");
1568
+ if (body === void 0 || signature === void 0 || extra !== void 0) throw new Error(NOT_A_TOKEN);
1569
+ let payload;
1570
+ try {
1571
+ payload = decodeSegment(body);
1572
+ } catch {
1573
+ throw new Error(NOT_A_TOKEN);
1574
+ }
1575
+ if (typeof payload !== "object" || payload === null) throw new Error(NOT_A_TOKEN);
1576
+ const sub = "sub" in payload ? payload.sub : void 0;
1577
+ const name = "name" in payload ? payload.name : void 0;
1578
+ if (typeof sub !== "string" || sub === "") throw new Error(NOT_A_TOKEN);
1579
+ return typeof name === "string" ? {
1580
+ sub,
1581
+ name
1582
+ } : { sub };
1583
+ }
1584
+ //#endregion
1358
1585
  //#region src/transport.ts
1359
1586
  /** Long enough for a slow model, short enough that nobody stares at nothing. */
1360
1587
  const ASK_TIMEOUT_MS = 3e4;
1361
- let memoryConversations = 0;
1588
+ let memoryThreads = 0;
1362
1589
  const STAGES$1 = [
1363
1590
  "searching",
1364
1591
  "writing",
1365
1592
  "checking"
1366
1593
  ];
1594
+ /** Sunboard switched off: what a connection whose token cannot be read gets, so the host's page stays up. */
1595
+ function offTransport() {
1596
+ const nothing = () => Promise.resolve();
1597
+ return {
1598
+ bootstrap: () => Promise.resolve({ off: true }),
1599
+ mutate: nothing,
1600
+ reportAnchorMiss: nothing,
1601
+ reportAnchorFound: nothing,
1602
+ stepCheck: nothing,
1603
+ recordEvents: nothing
1604
+ };
1605
+ }
1367
1606
  var MemoryTransport = class {
1368
1607
  data;
1369
1608
  misses = [];
@@ -1377,10 +1616,12 @@ var MemoryTransport = class {
1377
1616
  user;
1378
1617
  ask;
1379
1618
  reportPage;
1619
+ support;
1380
1620
  constructor(data, options) {
1381
1621
  this.data = data;
1382
1622
  this.cacheKey = options?.cacheKey;
1383
1623
  this.user = options?.user;
1624
+ if (options?.support) this.support = new MemorySupport(options.support);
1384
1625
  if (options?.census) this.reportPage = (report) => {
1385
1626
  this.pages.push(report);
1386
1627
  return Promise.resolve();
@@ -1388,7 +1629,7 @@ var MemoryTransport = class {
1388
1629
  const script = options?.ask;
1389
1630
  if (!script) return;
1390
1631
  const gapMs = options?.gapMs ?? 0;
1391
- const conversationId = `memory-conversation-${++memoryConversations}`;
1632
+ const threadId = `memory-thread-${++memoryThreads}`;
1392
1633
  this.ask = async (asked, signal, onStage) => {
1393
1634
  this.questions.push(asked);
1394
1635
  const scripted = script(asked);
@@ -1409,8 +1650,8 @@ var MemoryTransport = class {
1409
1650
  kind: "answer",
1410
1651
  reply: {
1411
1652
  ...scripted.reply,
1412
- conversationId: scripted.reply.conversationId ?? conversationId,
1413
- firstTurn: scripted.reply.firstTurn ?? asked.conversationId === void 0
1653
+ threadId: scripted.reply.threadId ?? threadId,
1654
+ firstTurn: scripted.reply.firstTurn ?? asked.threadId === void 0
1414
1655
  }
1415
1656
  };
1416
1657
  };
@@ -1521,89 +1762,114 @@ var HttpTransport = class {
1521
1762
  previewKey;
1522
1763
  cacheKey;
1523
1764
  user;
1765
+ support;
1524
1766
  constructor(config) {
1525
1767
  this.config = config;
1768
+ const { sub, name } = readUserToken(config.userToken);
1526
1769
  this.baseUrl = config.baseUrl ?? "https://api.sunboardhq.com";
1527
- this.user = config.user;
1770
+ this.user = name === void 0 ? {} : { firstName: name };
1528
1771
  this.previewKey = config.previewKey ?? storedPreviewKey();
1529
- this.cacheKey = this.previewKey ? void 0 : `${config.publishableKey}:${config.userId}`;
1772
+ this.cacheKey = this.previewKey ? void 0 : `${config.publishableKey}:${sub}`;
1773
+ this.support = {
1774
+ conversationPoll: () => this.getJson("/runtime/conversation"),
1775
+ conversation: async (id, after) => {
1776
+ const response = await this.send(`/runtime/conversation/${encodeURIComponent(id)}?${this.identity(after ? { after } : {})}`);
1777
+ if (response.status === 404) return null;
1778
+ return await ok(response, "/runtime/conversation/:id").json();
1779
+ },
1780
+ startConversation: ({ threadId, path, text }) => this.write("/runtime/conversation", {
1781
+ threadId,
1782
+ path,
1783
+ text
1784
+ }),
1785
+ sendMessage: (id, text) => this.write(`/runtime/conversation/${encodeURIComponent(id)}/messages`, { text }),
1786
+ markRead: async (id) => {
1787
+ await this.post(`/runtime/conversation/${encodeURIComponent(id)}/read`, {});
1788
+ },
1789
+ pastConversations: () => this.getJson("/runtime/conversations")
1790
+ };
1530
1791
  }
1531
- headers() {
1792
+ identity(extra = {}) {
1793
+ return new URLSearchParams({
1794
+ userToken: this.config.userToken,
1795
+ ...extra
1796
+ }).toString();
1797
+ }
1798
+ signed(body) {
1532
1799
  return {
1533
- "content-type": "application/json",
1534
- "x-sunboard-key": this.config.publishableKey,
1535
- ...this.previewKey ? { "x-sunboard-preview-key": this.previewKey } : {}
1800
+ ...body,
1801
+ userToken: this.config.userToken
1536
1802
  };
1537
1803
  }
1538
- async request(path, init) {
1539
- const response = await fetch(`${this.baseUrl}${path}`, {
1804
+ send(path, init) {
1805
+ return fetch(`${this.baseUrl}${path}`, {
1540
1806
  ...init,
1541
1807
  headers: {
1542
1808
  ...this.headers(),
1543
1809
  ...init?.headers
1544
1810
  }
1545
1811
  });
1546
- if (!response.ok) throw new Error(`sunboard runtime request failed: ${response.status} for ${path}`);
1547
- return response;
1548
1812
  }
1549
- async bootstrap() {
1550
- return await (await this.request(`/runtime/bootstrap?user=${encodeURIComponent(this.config.userId)}`)).json();
1813
+ async getJson(path) {
1814
+ return await (await this.request(`${path}?${this.identity()}`)).json();
1551
1815
  }
1552
- async mutate(action) {
1553
- await this.request("/runtime/state", {
1816
+ post(path, body, init) {
1817
+ return this.request(path, {
1818
+ ...init,
1554
1819
  method: "POST",
1555
- body: JSON.stringify({
1556
- user: this.config.userId,
1557
- ...action
1558
- })
1820
+ body: JSON.stringify(this.signed(body))
1559
1821
  });
1560
1822
  }
1561
- async reportAnchorMiss(report) {
1562
- await this.request("/runtime/anchor-miss", {
1823
+ async write(path, body) {
1824
+ const response = await this.send(path, {
1563
1825
  method: "POST",
1564
- body: JSON.stringify({
1565
- user: this.config.userId,
1566
- ...report
1567
- })
1826
+ body: JSON.stringify(this.signed(body))
1568
1827
  });
1828
+ if (response.status === 429) return {
1829
+ refused: "too-many",
1830
+ retryAt: refusalIn(await response.text())?.retryAt
1831
+ };
1832
+ if (response.status === 409) return { refused: "another-open" };
1833
+ if (response.status === 404) return { refused: "lost" };
1834
+ return await ok(response, path).json();
1835
+ }
1836
+ headers() {
1837
+ return {
1838
+ "content-type": "application/json",
1839
+ "x-sunboard-key": this.config.publishableKey,
1840
+ ...this.previewKey ? { "x-sunboard-preview-key": this.previewKey } : {}
1841
+ };
1842
+ }
1843
+ async request(path, init) {
1844
+ return ok(await this.send(path, init), path);
1845
+ }
1846
+ async bootstrap() {
1847
+ return await (await this.request(`/runtime/bootstrap?${this.identity()}`)).json();
1848
+ }
1849
+ async mutate(action) {
1850
+ await this.post("/runtime/state", action);
1851
+ }
1852
+ async reportAnchorMiss(report) {
1853
+ await this.post("/runtime/anchor-miss", report);
1569
1854
  }
1570
1855
  async reportAnchorFound(report) {
1571
- await this.request("/runtime/anchor-found", {
1572
- method: "POST",
1573
- body: JSON.stringify(report)
1574
- });
1856
+ await this.post("/runtime/anchor-found", report);
1575
1857
  }
1576
1858
  async stepCheck(report) {
1577
- await this.request("/runtime/step-check", {
1578
- method: "POST",
1579
- body: JSON.stringify(report)
1580
- });
1859
+ await this.post("/runtime/step-check", report);
1581
1860
  }
1582
1861
  async recordEvents(events) {
1583
1862
  if (events.length === 0) return;
1584
- await this.request("/runtime/events", {
1585
- method: "POST",
1586
- body: JSON.stringify({
1587
- user: this.config.userId,
1588
- events
1589
- }),
1590
- keepalive: true
1591
- });
1863
+ await this.post("/runtime/events", { events }, { keepalive: true });
1592
1864
  }
1593
1865
  async reportPage(report) {
1594
- await this.request("/runtime/page", {
1595
- method: "POST",
1596
- body: JSON.stringify(report)
1597
- });
1866
+ await this.post("/runtime/page", report);
1598
1867
  }
1599
1868
  async ask(asked, signal, onStage) {
1600
1869
  const response = await fetch(`${this.baseUrl}/runtime/ask`, {
1601
1870
  method: "POST",
1602
1871
  headers: this.headers(),
1603
- body: JSON.stringify({
1604
- user: this.config.userId,
1605
- ...asked
1606
- }),
1872
+ body: JSON.stringify(this.signed(asked)),
1607
1873
  signal: AbortSignal.any([signal, AbortSignal.timeout(ASK_TIMEOUT_MS)])
1608
1874
  });
1609
1875
  if (response.status === 400 || response.status === 429) {
@@ -1620,6 +1886,10 @@ var HttpTransport = class {
1620
1886
  };
1621
1887
  }
1622
1888
  };
1889
+ function ok(response, path) {
1890
+ if (!response.ok) throw new Error(`sunboard runtime request failed: ${response.status} for ${path}`);
1891
+ return response;
1892
+ }
1623
1893
  /**
1624
1894
  * The sentence a 400 or a 429 carries, and the time the cap lifts. Read as
1625
1895
  * text and parsed loosely: a proxy in front of the API answers those codes
@@ -1654,39 +1924,20 @@ function stringOrUndefined(value) {
1654
1924
  function optionalString(value) {
1655
1925
  return value === void 0 || typeof value === "string";
1656
1926
  }
1657
- function parseSource(value) {
1658
- if (!isRecord$1(value)) return void 0;
1659
- const { id, title, url, heading } = value;
1660
- if (typeof id !== "string" || typeof title !== "string") return void 0;
1661
- if (!optionalString(url) || !optionalString(heading)) return void 0;
1662
- return {
1663
- id,
1664
- title,
1665
- ...url === void 0 ? {} : { url },
1666
- ...heading === void 0 ? {} : { heading }
1667
- };
1668
- }
1669
1927
  /** A reply as the server sends it, or as the thread kept it in session storage. */
1670
1928
  function parseReply(value) {
1671
1929
  if (!isRecord$1(value)) return void 0;
1672
- const { id, conversationId, firstTurn, outcome, answer, sources, guideKey, supportUrl } = value;
1673
- if (typeof id !== "string" || typeof conversationId !== "string") return void 0;
1930
+ const { id, threadId, firstTurn, outcome, answer, guideKey, supportUrl } = value;
1931
+ if (typeof id !== "string" || typeof threadId !== "string") return void 0;
1674
1932
  if (typeof firstTurn !== "boolean") return void 0;
1675
1933
  if (!isAskOutcome(outcome) || typeof answer !== "string") return void 0;
1676
- if (!Array.isArray(sources) || !optionalString(guideKey) || !optionalString(supportUrl)) return;
1677
- const parsed = [];
1678
- for (const source of sources) {
1679
- const article = parseSource(source);
1680
- if (!article) return void 0;
1681
- parsed.push(article);
1682
- }
1934
+ if (!optionalString(guideKey) || !optionalString(supportUrl)) return void 0;
1683
1935
  return {
1684
1936
  id,
1685
- conversationId,
1937
+ threadId,
1686
1938
  firstTurn,
1687
1939
  outcome,
1688
1940
  answer,
1689
- sources: parsed,
1690
1941
  ...guideKey === void 0 ? {} : { guideKey },
1691
1942
  ...supportUrl === void 0 ? {} : { supportUrl }
1692
1943
  };
@@ -1750,8 +2001,13 @@ function SunboardProvider(props) {
1750
2001
  const connectionKey = connection ? JSON.stringify(connection) : null;
1751
2002
  const resolvedTransport = useMemo(() => {
1752
2003
  if (transport) return transport;
1753
- if (connectionKey) return new HttpTransport(JSON.parse(connectionKey));
1754
- return null;
2004
+ if (!connectionKey) return null;
2005
+ try {
2006
+ return new HttpTransport(JSON.parse(connectionKey));
2007
+ } catch (error) {
2008
+ console.error(error instanceof Error ? error.message : String(error));
2009
+ return offTransport();
2010
+ }
1755
2011
  }, [transport, connectionKey]);
1756
2012
  if (!resolvedTransport) throw new Error("SunboardProvider needs either a connection or a transport");
1757
2013
  const [answer, setAnswer] = useState(null);
@@ -1985,6 +2241,7 @@ function SunboardProvider(props) {
1985
2241
  if (!transport.ask) throw new Error("this transport cannot ask questions");
1986
2242
  return transport.ask(asked, signal, onStage);
1987
2243
  },
2244
+ support: off ? void 0 : resolvedTransport.support,
1988
2245
  track,
1989
2246
  navigate
1990
2247
  };
@@ -2123,7 +2380,7 @@ function threadReducer(thread, action) {
2123
2380
  } : response);
2124
2381
  return next === thread ? thread : {
2125
2382
  ...next,
2126
- conversationId: action.reply.conversationId
2383
+ threadId: action.reply.threadId
2127
2384
  };
2128
2385
  }
2129
2386
  case "failed": return replaceResponse(thread, action.questionId, (response) => response.kind === "waiting" ? {
@@ -2303,14 +2560,14 @@ function parseThread(value) {
2303
2560
  if (!message) return null;
2304
2561
  messages.push(message);
2305
2562
  }
2306
- const conversationId = typeof value.conversationId === "string" ? value.conversationId : void 0;
2563
+ const threadId = typeof value.threadId === "string" ? value.threadId : void 0;
2307
2564
  const handoff = parseHandoff(value.handoff);
2308
- return conversationId === void 0 ? {
2565
+ return threadId === void 0 ? {
2309
2566
  messages,
2310
2567
  handoff
2311
2568
  } : {
2312
2569
  messages,
2313
- conversationId,
2570
+ threadId,
2314
2571
  handoff
2315
2572
  };
2316
2573
  }
@@ -2399,6 +2656,162 @@ function parseFailure(value) {
2399
2656
  return null;
2400
2657
  }
2401
2658
  //#endregion
2659
+ //#region src/support.ts
2660
+ const emptySupport = {
2661
+ off: false,
2662
+ open: null,
2663
+ hasPast: false,
2664
+ view: "assistant",
2665
+ conversation: null,
2666
+ readOnly: false,
2667
+ past: null,
2668
+ sending: false,
2669
+ failure: null
2670
+ };
2671
+ function supportReducer(state, action) {
2672
+ switch (action.type) {
2673
+ case "off": return {
2674
+ ...emptySupport,
2675
+ off: true
2676
+ };
2677
+ case "polled": return {
2678
+ ...state,
2679
+ open: action.poll.open,
2680
+ hasPast: action.poll.hasPast
2681
+ };
2682
+ case "talk": {
2683
+ const keep = state.conversation !== null && state.conversation.id === state.open?.id;
2684
+ return {
2685
+ ...state,
2686
+ view: "conversation",
2687
+ readOnly: false,
2688
+ failure: null,
2689
+ conversation: keep ? state.conversation : null
2690
+ };
2691
+ }
2692
+ case "loaded": {
2693
+ const loaded = action.conversation;
2694
+ if (!canWrite(state)) return state;
2695
+ if (state.conversation !== null && state.conversation.id !== loaded.id) return state;
2696
+ const conversation = merged(state.conversation, loaded);
2697
+ return {
2698
+ ...state,
2699
+ conversation,
2700
+ ...state.open?.id === loaded.id ? summarized(state, conversation) : {}
2701
+ };
2702
+ }
2703
+ case "sending": return {
2704
+ ...state,
2705
+ sending: true,
2706
+ failure: null
2707
+ };
2708
+ case "sent": {
2709
+ const { summary, message } = action.sent;
2710
+ const current = state.conversation?.id === summary.id ? state.conversation : null;
2711
+ const conversation = current ? {
2712
+ ...current,
2713
+ status: summary.status,
2714
+ closed: false,
2715
+ closedAt: null,
2716
+ messages: withMessages(current.messages, [message])
2717
+ } : {
2718
+ id: summary.id,
2719
+ status: summary.status,
2720
+ startedAt: message.sentAt,
2721
+ closedAt: null,
2722
+ closed: false,
2723
+ messages: [message],
2724
+ before: [],
2725
+ during: []
2726
+ };
2727
+ return {
2728
+ ...state,
2729
+ sending: false,
2730
+ open: summary,
2731
+ conversation
2732
+ };
2733
+ }
2734
+ case "refused": return {
2735
+ ...state,
2736
+ sending: false,
2737
+ failure: action.refused
2738
+ };
2739
+ case "read": return state.open?.id === action.id && state.open.unread ? {
2740
+ ...state,
2741
+ open: {
2742
+ ...state.open,
2743
+ unread: false
2744
+ }
2745
+ } : state;
2746
+ case "showPast": return {
2747
+ ...state,
2748
+ view: "past",
2749
+ readOnly: false,
2750
+ failure: null
2751
+ };
2752
+ case "pastLoaded": return {
2753
+ ...state,
2754
+ past: action.past,
2755
+ hasPast: state.hasPast || action.past.length > 0
2756
+ };
2757
+ case "openPast": return {
2758
+ ...state,
2759
+ view: "conversation",
2760
+ readOnly: true,
2761
+ conversation: action.conversation,
2762
+ failure: null
2763
+ };
2764
+ case "back": return {
2765
+ ...state,
2766
+ view: state.view === "conversation" && state.readOnly ? "past" : "assistant",
2767
+ readOnly: false,
2768
+ failure: null
2769
+ };
2770
+ }
2771
+ }
2772
+ /** What the bar and the start screen row say, brought up to the messages on screen. */
2773
+ function summarized({ open, hasPast }, conversation) {
2774
+ if (conversation.closed) return {
2775
+ open: null,
2776
+ hasPast: true
2777
+ };
2778
+ const last = conversation.messages.at(-1);
2779
+ if (!open || !last) return {
2780
+ open,
2781
+ hasPast
2782
+ };
2783
+ return {
2784
+ open: {
2785
+ ...open,
2786
+ status: conversation.status,
2787
+ lastMessageAt: last.sentAt,
2788
+ lastMessageBy: last.by,
2789
+ lastMessageText: last.text
2790
+ },
2791
+ hasPast
2792
+ };
2793
+ }
2794
+ /** Talk to a person is offered: the project's switch is on and the runtime has not said off. */
2795
+ function offersTalk(switchOn, hasTransport, support) {
2796
+ return switchOn && hasTransport && !support.off;
2797
+ }
2798
+ /** A full view replaces what is held; a view fetched with `after` adds its messages. */
2799
+ function merged(current, next) {
2800
+ if (current === null || current.id !== next.id) return next;
2801
+ return {
2802
+ ...next,
2803
+ messages: withMessages(current.messages, next.messages)
2804
+ };
2805
+ }
2806
+ function withMessages(current, next) {
2807
+ const seen = new Set(current.map((message) => message.id));
2808
+ return [...current, ...next.filter((message) => !seen.has(message.id))];
2809
+ }
2810
+ /** The conversation on screen can take a message: not one opened from the past list. */
2811
+ function canWrite(support) {
2812
+ return support.view === "conversation" && !support.readOnly;
2813
+ }
2814
+ //#endregion
2402
2815
  //#region src/hooks.ts
2403
2816
  function useGuidesApi() {
2404
2817
  const api = useContext(GuidesContext);
@@ -2518,7 +2931,7 @@ function useLauncher({ remember = true } = {}) {
2518
2931
  const asked = {
2519
2932
  question,
2520
2933
  pathname,
2521
- conversationId: threadRef.current.conversationId
2934
+ threadId: threadRef.current.threadId
2522
2935
  };
2523
2936
  run(transportAsk, asked, questionId, controller.signal, dispatch);
2524
2937
  };
@@ -2528,6 +2941,7 @@ function useLauncher({ remember = true } = {}) {
2528
2941
  ready: api.ready,
2529
2942
  tourRunning: api.activeTour !== null,
2530
2943
  messages: thread.messages,
2944
+ threadId: thread.threadId,
2531
2945
  waiting: isWaiting(thread),
2532
2946
  rateLimitedUntil: cappedUntil,
2533
2947
  handedOff: thread.handoff !== null,
@@ -2600,6 +3014,157 @@ function useLauncher({ remember = true } = {}) {
2600
3014
  }
2601
3015
  };
2602
3016
  }
3017
+ /** How often the end user's open conversation is checked, and the conversation on screen. */
3018
+ const SUPPORT_POLL_MS = 3e4;
3019
+ const CONVERSATION_POLL_MS = 5e3;
3020
+ function isOff(value) {
3021
+ return typeof value === "object" && value !== null && "off" in value;
3022
+ }
3023
+ function useTabVisible() {
3024
+ const [visible, setVisible] = useState(() => typeof document === "undefined" || document.visibilityState === "visible");
3025
+ useEffect(() => {
3026
+ const onChange = () => setVisible(document.visibilityState === "visible");
3027
+ document.addEventListener("visibilitychange", onChange);
3028
+ return () => document.removeEventListener("visibilitychange", onChange);
3029
+ }, []);
3030
+ return visible;
3031
+ }
3032
+ /**
3033
+ * Talk to a person. Polls the runtime only while it can matter: once on load,
3034
+ * then every 30 seconds while the end user has an open conversation, and the
3035
+ * conversation itself every 5 seconds while it is on screen. Nothing polls
3036
+ * while the tab is hidden.
3037
+ */
3038
+ function useSupport({ onScreen, threadId }) {
3039
+ const api = useGuidesApi();
3040
+ const switchOn = useComponentSettings().launcher.talkToPerson;
3041
+ const transport = api.support;
3042
+ const [state, dispatch] = useReducer(supportReducer, emptySupport);
3043
+ const stateRef = useRef(state);
3044
+ stateRef.current = state;
3045
+ const visible = useTabVisible();
3046
+ const available = offersTalk(switchOn, transport !== void 0, state);
3047
+ const live = available ? transport : void 0;
3048
+ const hasOpen = state.open !== null;
3049
+ const poll = useCallback(() => {
3050
+ if (!live) return;
3051
+ live.conversationPoll().then((answer) => dispatch(isOff(answer) ? { type: "off" } : {
3052
+ type: "polled",
3053
+ poll: answer
3054
+ }), () => void 0);
3055
+ }, [live]);
3056
+ useEffect(() => {
3057
+ poll();
3058
+ }, [poll]);
3059
+ const wasVisible = useRef(visible);
3060
+ useEffect(() => {
3061
+ const returned = visible && !wasVisible.current;
3062
+ wasVisible.current = visible;
3063
+ if (!hasOpen || !visible) return;
3064
+ if (returned) poll();
3065
+ const timer = setInterval(poll, SUPPORT_POLL_MS);
3066
+ return () => clearInterval(timer);
3067
+ }, [
3068
+ poll,
3069
+ hasOpen,
3070
+ visible
3071
+ ]);
3072
+ const watchedId = onScreen && visible && canWrite(state) ? state.conversation?.id ?? state.open?.id : void 0;
3073
+ useEffect(() => {
3074
+ if (!live || watchedId === void 0) return;
3075
+ const fetchView = () => {
3076
+ const held = stateRef.current.conversation;
3077
+ const after = held?.id === watchedId ? held.messages.at(-1)?.id : void 0;
3078
+ live.conversation(watchedId, after).then((view) => {
3079
+ if (view === null) return;
3080
+ if (isOff(view)) {
3081
+ dispatch({ type: "off" });
3082
+ return;
3083
+ }
3084
+ dispatch({
3085
+ type: "loaded",
3086
+ conversation: view
3087
+ });
3088
+ if (after !== void 0 && view.messages.some((message) => message.by === "TEAM")) live.markRead(watchedId).catch(() => void 0);
3089
+ }, () => void 0);
3090
+ };
3091
+ fetchView();
3092
+ const timer = setInterval(fetchView, CONVERSATION_POLL_MS);
3093
+ return () => clearInterval(timer);
3094
+ }, [live, watchedId]);
3095
+ const unreadOnScreen = state.open?.unread === true && state.open.id === watchedId;
3096
+ useEffect(() => {
3097
+ if (!live || !unreadOnScreen || watchedId === void 0) return;
3098
+ dispatch({
3099
+ type: "read",
3100
+ id: watchedId
3101
+ });
3102
+ live.markRead(watchedId).catch(() => void 0);
3103
+ }, [
3104
+ live,
3105
+ unreadOnScreen,
3106
+ watchedId
3107
+ ]);
3108
+ return {
3109
+ available,
3110
+ state,
3111
+ talk: () => dispatch({ type: "talk" }),
3112
+ send: async (typed) => {
3113
+ const text = typed.trim();
3114
+ const current = stateRef.current;
3115
+ if (!live || text === "" || current.sending || !canWrite(current)) return false;
3116
+ dispatch({ type: "sending" });
3117
+ stateRef.current = {
3118
+ ...current,
3119
+ sending: true
3120
+ };
3121
+ let answer;
3122
+ try {
3123
+ answer = current.conversation ? await live.sendMessage(current.conversation.id, text) : await live.startConversation({
3124
+ threadId: threadId ?? null,
3125
+ path: window.location.pathname,
3126
+ text
3127
+ });
3128
+ } catch {
3129
+ answer = { refused: "lost" };
3130
+ }
3131
+ if (isOff(answer)) {
3132
+ dispatch({ type: "off" });
3133
+ return false;
3134
+ }
3135
+ if ("refused" in answer) {
3136
+ const refused = answer.refused === "another-open" ? { refused: "lost" } : answer;
3137
+ dispatch({
3138
+ type: "refused",
3139
+ refused
3140
+ });
3141
+ return false;
3142
+ }
3143
+ dispatch({
3144
+ type: "sent",
3145
+ sent: answer
3146
+ });
3147
+ return true;
3148
+ },
3149
+ showPast: () => {
3150
+ dispatch({ type: "showPast" });
3151
+ live?.pastConversations().then((past) => dispatch(isOff(past) ? { type: "off" } : {
3152
+ type: "pastLoaded",
3153
+ past
3154
+ }), () => void 0);
3155
+ },
3156
+ openPast: (id) => {
3157
+ live?.conversation(id).then((view) => {
3158
+ if (view === null) return;
3159
+ dispatch(isOff(view) ? { type: "off" } : {
3160
+ type: "openPast",
3161
+ conversation: view
3162
+ });
3163
+ }, () => void 0);
3164
+ },
3165
+ back: () => dispatch({ type: "back" })
3166
+ };
3167
+ }
2603
3168
  /** One guide's content plus launchTour; call with no key for launch-only. */
2604
3169
  function useGuide(key) {
2605
3170
  const api = useGuidesApi();
@@ -2720,12 +3285,6 @@ function PlayIcon() {
2720
3285
  children: /* @__PURE__ */ jsx("path", { d: "M7 4.5v15l12-7.5z" })
2721
3286
  });
2722
3287
  }
2723
- function DocIcon() {
2724
- return /* @__PURE__ */ jsxs(Glyph, {
2725
- width: 1.8,
2726
- children: [/* @__PURE__ */ jsx("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }), /* @__PURE__ */ jsx("path", { d: "M14 3v5h5M9 13h6M9 17h4" })]
2727
- });
2728
- }
2729
3288
  function ExternalIcon() {
2730
3289
  return /* @__PURE__ */ jsx(Glyph, {
2731
3290
  width: 1.8,
@@ -2763,12 +3322,6 @@ function RestartIcon() {
2763
3322
  function SendIcon() {
2764
3323
  return /* @__PURE__ */ jsx(Glyph, { children: /* @__PURE__ */ jsx("path", { d: "M12 19V5M5 12l7-7 7 7" }) });
2765
3324
  }
2766
- function ChevronDownIcon() {
2767
- return /* @__PURE__ */ jsx(Glyph, {
2768
- width: 1.8,
2769
- children: /* @__PURE__ */ jsx("path", { d: "m6 9 6 6 6-6" })
2770
- });
2771
- }
2772
3325
  function CheckIcon() {
2773
3326
  return /* @__PURE__ */ jsx(Glyph, {
2774
3327
  width: 1.8,
@@ -2800,6 +3353,28 @@ function StarIcon() {
2800
3353
  children: /* @__PURE__ */ jsx("path", { d: "M11.53 2.3a.5.5 0 0 1 .94 0l2.31 4.68a2 2 0 0 0 1.5 1.1l5.17.75a.5.5 0 0 1 .28.86l-3.74 3.64a2 2 0 0 0-.58 1.77l.88 5.14a.5.5 0 0 1-.73.53l-4.62-2.43a2 2 0 0 0-1.86 0l-4.62 2.43a.5.5 0 0 1-.73-.53l.88-5.14a2 2 0 0 0-.57-1.77L2.3 9.69a.5.5 0 0 1 .28-.86l5.16-.75a2 2 0 0 0 1.52-1.1z" })
2801
3354
  });
2802
3355
  }
3356
+ function ChevronLeftIcon() {
3357
+ return /* @__PURE__ */ jsx(Glyph, {
3358
+ width: 1.8,
3359
+ children: /* @__PURE__ */ jsx("path", { d: "m15 18-6-6 6-6" })
3360
+ });
3361
+ }
3362
+ function HeadsetIcon() {
3363
+ return /* @__PURE__ */ jsxs(Glyph, {
3364
+ width: 1.8,
3365
+ children: [/* @__PURE__ */ jsx("path", { d: "M3 11h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5Zm0 0a9 9 0 1 1 18 0m0 0v5a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3Z" }), /* @__PURE__ */ jsx("path", { d: "M21 16v2a4 4 0 0 1-4 4h-5" })]
3366
+ });
3367
+ }
3368
+ function HistoryIcon() {
3369
+ return /* @__PURE__ */ jsxs(Glyph, {
3370
+ width: 1.8,
3371
+ children: [
3372
+ /* @__PURE__ */ jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
3373
+ /* @__PURE__ */ jsx("path", { d: "M3 3v5h5" }),
3374
+ /* @__PURE__ */ jsx("path", { d: "M12 7v5l4 2" })
3375
+ ]
3376
+ });
3377
+ }
2803
3378
  //#endregion
2804
3379
  //#region src/overlay/CardMedia.tsx
2805
3380
  const VIDEO_URL = /\.(mp4|webm|m4v|mov|ogg)([?#]|$)/i;
@@ -3887,10 +4462,387 @@ function SunboardLogo({ width = 75 }) {
3887
4462
  }) })]
3888
4463
  });
3889
4464
  }
4465
+ const FOCUSABLE = "a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled])";
4466
+ /**
4467
+ * On a phone the panel is a bottom sheet, and focusing the box raises the
4468
+ * soft keyboard over what the visitor came to read. The panel is fixed, so
4469
+ * focus never needs to scroll, and in an iframe it would scroll the parent.
4470
+ */
4471
+ function useOpeningFocus(inline, focusBox, boxRef, panelRef) {
4472
+ useEffect(() => {
4473
+ if (inline) return;
4474
+ const phone = window.matchMedia?.(`(max-width: 640px)`).matches ?? false;
4475
+ const box = boxRef.current;
4476
+ if ((focusBox || !phone) && box && !box.disabled) box.focus({ preventScroll: true });
4477
+ else panelRef.current?.focus({ preventScroll: true });
4478
+ }, [inline]);
4479
+ }
4480
+ /**
4481
+ * Bound to the document rather than the panel: disabling the box the visitor
4482
+ * was typing in hands focus back to the page, and a listener on the panel
4483
+ * would never hear the Tab that follows.
4484
+ */
4485
+ function useTabTrap(inline, panelRef) {
4486
+ useEffect(() => {
4487
+ if (inline) return;
4488
+ const onKeyDown = (event) => {
4489
+ const panel = panelRef.current;
4490
+ if (event.key !== "Tab" || event.defaultPrevented || !panel) return;
4491
+ const stops = [...panel.querySelectorAll(FOCUSABLE)];
4492
+ if (stops.length === 0) return;
4493
+ const first = stops[0];
4494
+ const last = stops[stops.length - 1];
4495
+ const inside = panel.contains(document.activeElement);
4496
+ const leaving = document.activeElement === (event.shiftKey ? first : last);
4497
+ if (inside && !leaving) return;
4498
+ event.preventDefault();
4499
+ (event.shiftKey ? last : first).focus();
4500
+ };
4501
+ document.addEventListener("keydown", onKeyDown);
4502
+ return () => document.removeEventListener("keydown", onKeyDown);
4503
+ }, [inline, panelRef]);
4504
+ }
4505
+ function clockTime(iso) {
4506
+ const at = new Date(iso);
4507
+ if (Number.isNaN(at.getTime())) return iso;
4508
+ return at.toLocaleTimeString(void 0, {
4509
+ hour: "2-digit",
4510
+ minute: "2-digit"
4511
+ });
4512
+ }
4513
+ /** Whole calendar days from `iso` to `now`, in the visitor's own time zone. */
4514
+ function daysAgo(iso, now) {
4515
+ const at = new Date(iso);
4516
+ if (Number.isNaN(at.getTime())) return 0;
4517
+ const midnight = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
4518
+ return Math.max(0, Math.round((midnight(now) - midnight(at)) / 864e5));
4519
+ }
4520
+ function minutesUntil(iso) {
4521
+ const at = new Date(iso).getTime();
4522
+ if (Number.isNaN(at)) return 1;
4523
+ return Math.max(1, Math.round((at - Date.now()) / 6e4));
4524
+ }
4525
+ //#endregion
4526
+ //#region src/components/Conversation.tsx
4527
+ /** "Sean replied" or "Waiting for a reply", for the bar and the start screen row. */
4528
+ function supportStatus(open, company, labels) {
4529
+ if (open.lastMessageBy !== "TEAM") return labels.waiting;
4530
+ return labels.replied(open.lastMessageWho ?? labels.from(company));
4531
+ }
4532
+ function when(iso, labels, now) {
4533
+ const days = daysAgo(iso, now);
4534
+ return days === 0 ? clockTime(iso) : labels.ago(days);
4535
+ }
4536
+ function PanelHead({ title, onBack, close }) {
4537
+ const labels = useLabels();
4538
+ return /* @__PURE__ */ jsxs("header", {
4539
+ className: "sb-head sb-head-back",
4540
+ children: [
4541
+ /* @__PURE__ */ jsx("button", {
4542
+ type: "button",
4543
+ className: "sb-panel-close",
4544
+ "aria-label": labels.support.back,
4545
+ onClick: onBack,
4546
+ children: /* @__PURE__ */ jsx(ChevronLeftIcon, {})
4547
+ }),
4548
+ /* @__PURE__ */ jsx("h2", {
4549
+ className: "sb-title",
4550
+ children: title
4551
+ }),
4552
+ /* @__PURE__ */ jsx("button", {
4553
+ type: "button",
4554
+ className: "sb-panel-close",
4555
+ "aria-label": labels.launcher.closeAria,
4556
+ onClick: close,
4557
+ children: /* @__PURE__ */ jsx(XIcon, {})
4558
+ })
4559
+ ]
4560
+ });
4561
+ }
4562
+ function Branding() {
4563
+ const L = useLabels().launcher;
4564
+ if (!useComponentSettings().launcher.showBranding) return null;
4565
+ return /* @__PURE__ */ jsxs("a", {
4566
+ className: "sb-brand",
4567
+ href: "https://sunboardhq.com",
4568
+ target: "_blank",
4569
+ rel: "noreferrer",
4570
+ children: [L.poweredBy, /* @__PURE__ */ jsx(SunboardLogo, { width: 62 })]
4571
+ });
4572
+ }
4573
+ /** The messages and the questions asked of the assistant meanwhile, in the order they happened. */
4574
+ function items(conversation) {
4575
+ return [...conversation.messages.map((message) => ({
4576
+ kind: "message",
4577
+ at: message.sentAt,
4578
+ message
4579
+ })), ...conversation.during.map((question) => ({
4580
+ kind: "fold",
4581
+ at: question.askedAt,
4582
+ question
4583
+ }))].sort((a, b) => a.at.localeCompare(b.at));
4584
+ }
4585
+ /** The assistant's turns the conversation follows on from, dimmed. */
4586
+ function AssistantTurns({ messages }) {
4587
+ const replies = new Map(messages.flatMap((message) => message.kind === "reply" ? [[message.questionId, message.reply.answer]] : []));
4588
+ return /* @__PURE__ */ jsx("div", {
4589
+ className: "sb-dim",
4590
+ children: messages.map((message) => {
4591
+ if (message.kind !== "question") return null;
4592
+ const answer = replies.get(message.id);
4593
+ return /* @__PURE__ */ jsxs("div", {
4594
+ className: "sb-exchange",
4595
+ children: [/* @__PURE__ */ jsx("div", {
4596
+ className: "sb-asked",
4597
+ children: /* @__PURE__ */ jsx("p", { children: message.text })
4598
+ }), answer && /* @__PURE__ */ jsx("div", {
4599
+ className: "sb-reply-text",
4600
+ children: /* @__PURE__ */ jsx(AnswerText, { text: answer })
4601
+ })]
4602
+ }, message.id);
4603
+ })
4604
+ });
4605
+ }
4606
+ function Fold({ question, labels }) {
4607
+ const [open, setOpen] = useState(false);
4608
+ return /* @__PURE__ */ jsxs("button", {
4609
+ type: "button",
4610
+ className: `sb-fold${open ? " sb-fold-open" : ""}`,
4611
+ "aria-expanded": open,
4612
+ onClick: () => setOpen((now) => !now),
4613
+ children: [/* @__PURE__ */ jsx(ChevronRightIcon, {}), /* @__PURE__ */ jsx("span", {
4614
+ className: "sb-fold-text",
4615
+ children: labels.asked(question.text)
4616
+ })]
4617
+ });
4618
+ }
4619
+ function Line({ children }) {
4620
+ return /* @__PURE__ */ jsxs("div", {
4621
+ className: "sb-line",
4622
+ role: "note",
4623
+ children: [
4624
+ /* @__PURE__ */ jsx("span", { className: "sb-line-rule" }),
4625
+ /* @__PURE__ */ jsx("span", {
4626
+ className: "sb-line-text",
4627
+ children
4628
+ }),
4629
+ /* @__PURE__ */ jsx("span", { className: "sb-line-rule" })
4630
+ ]
4631
+ });
4632
+ }
4633
+ function TeamMessage({ who, text }) {
4634
+ return /* @__PURE__ */ jsxs("div", {
4635
+ className: "sb-team",
4636
+ children: [/* @__PURE__ */ jsx("p", {
4637
+ className: "sb-team-who",
4638
+ children: who
4639
+ }), /* @__PURE__ */ jsx("p", {
4640
+ className: "sb-team-text",
4641
+ children: text
4642
+ })]
4643
+ });
4644
+ }
4645
+ function SendFailure({ failure, labels }) {
4646
+ const capped = failure.refused === "too-many";
4647
+ return /* @__PURE__ */ jsxs("div", {
4648
+ className: "sb-note sb-arrive",
4649
+ role: capped ? "status" : "alert",
4650
+ children: [/* @__PURE__ */ jsx("span", {
4651
+ className: "sb-note-mark",
4652
+ children: capped ? /* @__PURE__ */ jsx(ClockIcon, {}) : /* @__PURE__ */ jsx(NoSignalIcon, {})
4653
+ }), /* @__PURE__ */ jsx("div", {
4654
+ className: "sb-note-text",
4655
+ children: /* @__PURE__ */ jsxs("div", {
4656
+ className: "sb-note-words",
4657
+ children: [/* @__PURE__ */ jsx("p", {
4658
+ className: "sb-note-title",
4659
+ children: capped ? labels.tooMany : labels.lost
4660
+ }), /* @__PURE__ */ jsx("p", {
4661
+ className: "sb-note-body",
4662
+ children: capped && failure.retryAt ? labels.writeAgainIn(minutesUntil(failure.retryAt)) : labels.lostBody
4663
+ })]
4664
+ })
4665
+ })]
4666
+ });
4667
+ }
4668
+ function ConversationPanel({ launcher, support, close, focusBox }) {
4669
+ const api = useGuidesApi();
4670
+ const { support: S, launcher: L } = useLabels();
4671
+ const replyTime = useComponentSettings().launcher.replyTime.trim();
4672
+ const company = api.productName;
4673
+ const { conversation, readOnly, sending, failure } = support.state;
4674
+ const panelRef = useRef(null);
4675
+ const boxRef = useRef(null);
4676
+ const [draft, setDraft] = useState("");
4677
+ const now = /* @__PURE__ */ new Date();
4678
+ useOpeningFocus(false, focusBox, boxRef, panelRef);
4679
+ useTabTrap(false, panelRef);
4680
+ useLayoutEffect(() => {
4681
+ const box = boxRef.current;
4682
+ if (!box) return;
4683
+ box.style.height = "";
4684
+ box.style.height = `${String(box.scrollHeight)}px`;
4685
+ box.style.overflowY = box.scrollHeight > box.clientHeight + 1 ? "auto" : "hidden";
4686
+ }, [draft]);
4687
+ const send = async (event) => {
4688
+ event?.preventDefault();
4689
+ if (await support.send(draft)) setDraft("");
4690
+ };
4691
+ const onBoxKeyDown = (event) => {
4692
+ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
4693
+ event.preventDefault();
4694
+ send();
4695
+ };
4696
+ const questions = launcher.messages.filter((message) => message.kind === "question" || message.kind === "reply");
4697
+ const showTurns = !readOnly && questions.length > 0 && (conversation === null || conversation.before.length > 0);
4698
+ const firstFromEndUser = conversation?.messages.find((message) => message.by === "END_USER")?.id;
4699
+ return /* @__PURE__ */ jsxs("section", {
4700
+ className: "sb-panel",
4701
+ ref: panelRef,
4702
+ role: "dialog",
4703
+ "aria-label": S.title(company),
4704
+ tabIndex: -1,
4705
+ children: [
4706
+ /* @__PURE__ */ jsx(PanelHead, {
4707
+ title: S.title(company),
4708
+ onBack: support.back,
4709
+ close
4710
+ }),
4711
+ /* @__PURE__ */ jsx("div", {
4712
+ className: "sb-thread",
4713
+ role: "log",
4714
+ "aria-label": S.conversationWith(company),
4715
+ "aria-live": "polite",
4716
+ children: /* @__PURE__ */ jsx("div", {
4717
+ className: "sb-thread-scroll sb-anchored",
4718
+ children: /* @__PURE__ */ jsxs("div", {
4719
+ className: "sb-thread-list sb-conversation",
4720
+ children: [
4721
+ showTurns && /* @__PURE__ */ jsx(AssistantTurns, { messages: questions }),
4722
+ !showTurns && conversation?.before.map((question) => /* @__PURE__ */ jsx(Fold, {
4723
+ question,
4724
+ labels: S
4725
+ }, question.id)),
4726
+ /* @__PURE__ */ jsx(Line, { children: S.talking(company) }),
4727
+ /* @__PURE__ */ jsx(TeamMessage, {
4728
+ who: S.from(company),
4729
+ text: S.greeting(api.firstName)
4730
+ }),
4731
+ conversation && items(conversation).map((item) => item.kind === "fold" ? /* @__PURE__ */ jsx(Fold, {
4732
+ question: item.question,
4733
+ labels: S
4734
+ }, item.question.id) : item.message.by === "TEAM" ? /* @__PURE__ */ jsx(TeamMessage, {
4735
+ who: S.teamMessage(item.message.who, company, when(item.message.sentAt, S, now)),
4736
+ text: item.message.text
4737
+ }, item.message.id) : /* @__PURE__ */ jsxs("div", {
4738
+ className: "sb-asked sb-sent",
4739
+ children: [/* @__PURE__ */ jsx("p", { children: item.message.text }), item.message.id === firstFromEndUser && replyTime !== "" && /* @__PURE__ */ jsx("span", {
4740
+ className: "sb-reply-time",
4741
+ children: replyTime
4742
+ })]
4743
+ }, item.message.id)),
4744
+ conversation?.closed && /* @__PURE__ */ jsxs("div", {
4745
+ className: "sb-closed",
4746
+ children: [/* @__PURE__ */ jsx(Line, { children: S.closed(company) }), !readOnly && /* @__PURE__ */ jsx("p", {
4747
+ className: "sb-reopen",
4748
+ children: S.reopen
4749
+ })]
4750
+ }),
4751
+ failure && /* @__PURE__ */ jsx(SendFailure, {
4752
+ failure,
4753
+ labels: S
4754
+ })
4755
+ ]
4756
+ })
4757
+ })
4758
+ }),
4759
+ readOnly ? /* @__PURE__ */ jsx("div", {
4760
+ className: "sb-foot",
4761
+ children: /* @__PURE__ */ jsx(Branding, {})
4762
+ }) : /* @__PURE__ */ jsxs("form", {
4763
+ className: "sb-composer",
4764
+ onSubmit: (event) => void send(event),
4765
+ children: [/* @__PURE__ */ jsxs("label", {
4766
+ className: "sb-field",
4767
+ children: [
4768
+ /* @__PURE__ */ jsx("span", {
4769
+ className: "sb-sr",
4770
+ children: S.boxLabel(company)
4771
+ }),
4772
+ /* @__PURE__ */ jsx("textarea", {
4773
+ className: "sb-box",
4774
+ ref: boxRef,
4775
+ rows: 1,
4776
+ value: draft,
4777
+ onChange: (event) => setDraft(event.target.value),
4778
+ onKeyDown: onBoxKeyDown,
4779
+ placeholder: S.placeholder(company),
4780
+ maxLength: CONVERSATION_MESSAGE_MAX_LENGTH,
4781
+ autoComplete: "off"
4782
+ }),
4783
+ /* @__PURE__ */ jsx("button", {
4784
+ type: "submit",
4785
+ className: "sb-send",
4786
+ "aria-label": L.send,
4787
+ disabled: sending || draft.trim() === "",
4788
+ children: /* @__PURE__ */ jsx(SendIcon, {})
4789
+ })
4790
+ ]
4791
+ }), /* @__PURE__ */ jsx(Branding, {})]
4792
+ })
4793
+ ]
4794
+ });
4795
+ }
4796
+ function PastPanel({ support, close }) {
4797
+ const S = useLabels().support;
4798
+ const panelRef = useRef(null);
4799
+ const now = /* @__PURE__ */ new Date();
4800
+ useTabTrap(false, panelRef);
4801
+ useLayoutEffect(() => {
4802
+ panelRef.current?.focus({ preventScroll: true });
4803
+ }, []);
4804
+ return /* @__PURE__ */ jsxs("section", {
4805
+ className: "sb-panel",
4806
+ ref: panelRef,
4807
+ role: "dialog",
4808
+ "aria-label": S.past,
4809
+ tabIndex: -1,
4810
+ children: [
4811
+ /* @__PURE__ */ jsx(PanelHead, {
4812
+ title: S.past,
4813
+ onBack: support.back,
4814
+ close
4815
+ }),
4816
+ /* @__PURE__ */ jsx("div", {
4817
+ className: "sb-thread",
4818
+ children: /* @__PURE__ */ jsx("div", {
4819
+ className: "sb-thread-scroll",
4820
+ children: /* @__PURE__ */ jsx("div", {
4821
+ className: "sb-past-list",
4822
+ children: (support.state.past ?? []).map((past) => /* @__PURE__ */ jsxs("button", {
4823
+ type: "button",
4824
+ className: "sb-past",
4825
+ onClick: () => support.openPast(past.id),
4826
+ children: [/* @__PURE__ */ jsx("span", {
4827
+ className: "sb-past-title",
4828
+ children: past.title
4829
+ }), /* @__PURE__ */ jsx("span", {
4830
+ className: "sb-past-meta",
4831
+ children: S.closedWhen(S.ago(daysAgo(past.closedAt ?? now.toISOString(), now)), past.closedBy)
4832
+ })]
4833
+ }, past.id))
4834
+ })
4835
+ })
4836
+ }),
4837
+ /* @__PURE__ */ jsx("div", {
4838
+ className: "sb-foot",
4839
+ children: /* @__PURE__ */ jsx(Branding, {})
4840
+ })
4841
+ ]
4842
+ });
4843
+ }
3890
4844
  //#endregion
3891
4845
  //#region src/components/Launcher.tsx
3892
- /** The width below which the panel is a bottom sheet; see styles.css. */
3893
- const PHONE_WIDTH = 640;
3894
4846
  /** How long the stage labels stay before the wait owns up to being slow. */
3895
4847
  const SLOW_MS = 6e3;
3896
4848
  const STAGE_AT = {
@@ -3898,14 +4850,19 @@ const STAGE_AT = {
3898
4850
  writing: 1,
3899
4851
  checking: 2
3900
4852
  };
3901
- const FOCUSABLE = "a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled])";
3902
4853
  function Launcher({ inline } = {}) {
3903
4854
  const launcher = useLauncher({ remember: !inline });
3904
4855
  const { ready, onThisPage, elsewhere, tourRunning, opened, handedOff } = launcher;
3905
4856
  const [open, setOpen] = useState(false);
4857
+ const support = useSupport({
4858
+ onScreen: open !== false,
4859
+ threadId: launcher.threadId
4860
+ });
3906
4861
  const { off } = useGuidesApi();
3907
4862
  const canAsk = launcher.ask !== void 0;
3908
4863
  const L = useLabels().launcher;
4864
+ const S = useLabels().support;
4865
+ const unread = support.available && support.state.open?.unread === true;
3909
4866
  const pillRef = useRef(null);
3910
4867
  const wasOpen = useRef(false);
3911
4868
  const wasHandedOff = useRef(handedOff);
@@ -3932,31 +4889,49 @@ function Launcher({ inline } = {}) {
3932
4889
  if (inline) return /* @__PURE__ */ jsx(Scope, { children: /* @__PURE__ */ jsx(QuestionPanel, {
3933
4890
  inline: true,
3934
4891
  launcher,
4892
+ support,
3935
4893
  close: () => setOpen(false),
3936
4894
  focusBox: false
3937
4895
  }) });
3938
4896
  const floating = (surface) => /* @__PURE__ */ jsx(FloatingPortal, { children: /* @__PURE__ */ jsx(Scope, { children: surface }) });
3939
4897
  if (!ready) return null;
3940
4898
  if (tourRunning) return floating(/* @__PURE__ */ jsx(TourPill, {}));
3941
- if (!canAsk && onThisPage.length + elsewhere.length === 0) return null;
3942
- if (open) return floating(/* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
3943
- className: "sb-panel-scrim",
3944
- onClick: () => setOpen(false)
3945
- }), /* @__PURE__ */ jsx(QuestionPanel, {
3946
- launcher,
3947
- close: () => setOpen(false),
3948
- focusBox: open === "back"
3949
- })] }));
4899
+ if (!canAsk && onThisPage.length + elsewhere.length === 0 && !support.state.open) return null;
4900
+ if (open) {
4901
+ const close = () => setOpen(false);
4902
+ const view = support.available ? support.state.view : "assistant";
4903
+ return floating(/* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
4904
+ className: "sb-panel-scrim",
4905
+ onClick: close
4906
+ }), view === "conversation" ? /* @__PURE__ */ jsx(ConversationPanel, {
4907
+ launcher,
4908
+ support,
4909
+ close,
4910
+ focusBox: open === "back"
4911
+ }) : view === "past" ? /* @__PURE__ */ jsx(PastPanel, {
4912
+ support,
4913
+ close
4914
+ }) : /* @__PURE__ */ jsx(QuestionPanel, {
4915
+ launcher,
4916
+ support,
4917
+ close,
4918
+ focusBox: open === "back"
4919
+ })] }));
4920
+ }
3950
4921
  return floating(/* @__PURE__ */ jsxs("button", {
3951
4922
  type: "button",
3952
4923
  className: "sb-launcher",
3953
4924
  ref: pillRef,
3954
- "aria-label": L.openAria,
4925
+ "aria-label": unread ? S.openAriaNewReply : L.openAria,
3955
4926
  onClick: () => {
3956
4927
  setOpen("opened");
3957
4928
  opened();
3958
4929
  },
3959
- children: [/* @__PURE__ */ jsx(HelpIcon, {}), L.open]
4930
+ children: [
4931
+ /* @__PURE__ */ jsx(HelpIcon, {}),
4932
+ L.open,
4933
+ unread && /* @__PURE__ */ jsx("span", { className: "sb-dot sb-launcher-dot" })
4934
+ ]
3960
4935
  }));
3961
4936
  }
3962
4937
  /**
@@ -3964,10 +4939,14 @@ function Launcher({ inline } = {}) {
3964
4939
  * tour taking its place, loses nothing; a question still out lands while the
3965
4940
  * panel is away and is there when it comes back.
3966
4941
  */
3967
- function QuestionPanel({ inline, launcher, close, focusBox }) {
4942
+ function QuestionPanel({ inline, launcher, support, close, focusBox }) {
3968
4943
  const { onThisPage, elsewhere, messages, waiting, rateLimitedUntil, ask } = launcher;
3969
4944
  const api = useGuidesApi();
3970
4945
  const L = useLabels().launcher;
4946
+ const S = useLabels().support;
4947
+ const company = api.productName;
4948
+ const talk = support.available && !inline ? support.talk : void 0;
4949
+ const openConversation = support.available ? support.state.open : null;
3971
4950
  const showBranding = useComponentSettings().launcher.showBranding;
3972
4951
  const panelRef = useRef(null);
3973
4952
  const scrollRef = useRef(null);
@@ -4022,16 +5001,6 @@ function QuestionPanel({ inline, launcher, close, focusBox }) {
4022
5001
  followRef.current = false;
4023
5002
  measure();
4024
5003
  }, [messages]);
4025
- const keepInPlace = (change) => {
4026
- const box = scrollRef.current;
4027
- const before = box ? {
4028
- height: box.scrollHeight,
4029
- top: box.scrollTop
4030
- } : null;
4031
- flushSync(change);
4032
- if (box && before && anchored) box.scrollTop = before.top - (box.scrollHeight - before.height);
4033
- measure();
4034
- };
4035
5004
  useEffect(() => {
4036
5005
  const seen = seenRef.current;
4037
5006
  let finished = null;
@@ -4040,7 +5009,7 @@ function QuestionPanel({ inline, launcher, close, focusBox }) {
4040
5009
  if (message.kind === "waiting") continue;
4041
5010
  seen.add(message.id);
4042
5011
  if (message.kind === "reply") {
4043
- const { greeting, closing } = replyWords(message, listed, api.firstName, L);
5012
+ const { greeting, closing } = replyWords(message, api.firstName, L);
4044
5013
  finished = [
4045
5014
  greeting,
4046
5015
  plainAnswer(message.reply.answer),
@@ -4050,31 +5019,8 @@ function QuestionPanel({ inline, launcher, close, focusBox }) {
4050
5019
  }
4051
5020
  if (finished !== null) setAnnouncement(finished);
4052
5021
  }, [messages]);
4053
- useEffect(() => {
4054
- if (inline) return;
4055
- const phone = window.matchMedia?.(`(max-width: ${PHONE_WIDTH}px)`).matches ?? false;
4056
- const box = boxRef.current;
4057
- if ((focusBox || !phone) && box && !box.disabled) box.focus({ preventScroll: true });
4058
- else panelRef.current?.focus({ preventScroll: true });
4059
- }, [inline]);
4060
- useEffect(() => {
4061
- if (inline) return;
4062
- const onKeyDown = (event) => {
4063
- const panel = panelRef.current;
4064
- if (event.key !== "Tab" || event.defaultPrevented || !panel) return;
4065
- const stops = [...panel.querySelectorAll(FOCUSABLE)];
4066
- if (stops.length === 0) return;
4067
- const first = stops[0];
4068
- const last = stops[stops.length - 1];
4069
- const inside = panel.contains(document.activeElement);
4070
- const leaving = document.activeElement === (event.shiftKey ? first : last);
4071
- if (inside && !leaving) return;
4072
- event.preventDefault();
4073
- (event.shiftKey ? last : first).focus();
4074
- };
4075
- document.addEventListener("keydown", onKeyDown);
4076
- return () => document.removeEventListener("keydown", onKeyDown);
4077
- }, [inline]);
5022
+ useOpeningFocus(inline, focusBox, boxRef, panelRef);
5023
+ useTabTrap(inline, panelRef);
4078
5024
  useEffect(() => {
4079
5025
  if (!confirming) return;
4080
5026
  cancelRef.current?.focus();
@@ -4151,6 +5097,13 @@ function QuestionPanel({ inline, launcher, close, focusBox }) {
4151
5097
  onClick: () => setConfirming((now) => !now),
4152
5098
  children: [/* @__PURE__ */ jsx(RestartIcon, {}), L.startOver]
4153
5099
  }),
5100
+ !inline && support.available && support.state.hasPast && /* @__PURE__ */ jsx("button", {
5101
+ type: "button",
5102
+ className: "sb-panel-close",
5103
+ "aria-label": S.past,
5104
+ onClick: support.showPast,
5105
+ children: /* @__PURE__ */ jsx(HistoryIcon, {})
5106
+ }),
4154
5107
  !inline && /* @__PURE__ */ jsx("button", {
4155
5108
  type: "button",
4156
5109
  className: "sb-panel-close",
@@ -4193,10 +5146,30 @@ function QuestionPanel({ inline, launcher, close, focusBox }) {
4193
5146
  })
4194
5147
  ]
4195
5148
  }),
5149
+ openConversation && messages.length > 0 && /* @__PURE__ */ jsxs("button", {
5150
+ type: "button",
5151
+ className: "sb-convbar",
5152
+ onClick: talk,
5153
+ children: [
5154
+ openConversation.unread && /* @__PURE__ */ jsx("span", { className: "sb-dot" }),
5155
+ openConversation.unread && /* @__PURE__ */ jsx("span", {
5156
+ className: "sb-sr",
5157
+ children: S.newReply
5158
+ }),
5159
+ /* @__PURE__ */ jsxs("span", {
5160
+ className: "sb-convbar-text",
5161
+ children: [S.conversationWith(company), /* @__PURE__ */ jsxs("span", {
5162
+ className: "sb-convbar-status",
5163
+ children: [" · ", supportStatus(openConversation, company, S)]
5164
+ })]
5165
+ }),
5166
+ /* @__PURE__ */ jsx(ChevronRightIcon, {})
5167
+ ]
5168
+ }),
4196
5169
  /* @__PURE__ */ jsxs("div", {
4197
5170
  className: "sb-thread",
4198
5171
  role: "log",
4199
- "aria-label": L.conversation,
5172
+ "aria-label": L.thread,
4200
5173
  "aria-live": "off",
4201
5174
  children: [/* @__PURE__ */ jsx("div", {
4202
5175
  className: `sb-thread-scroll${anchored ? " sb-anchored" : ""}`,
@@ -4207,7 +5180,30 @@ function QuestionPanel({ inline, launcher, close, focusBox }) {
4207
5180
  children: messages.length === 0 ? /* @__PURE__ */ jsx(Suggestions, {
4208
5181
  labels: L,
4209
5182
  guides: listed,
4210
- onRow: inline ? void 0 : (guide) => start(guide.key)
5183
+ onRow: inline ? void 0 : (guide) => start(guide.key),
5184
+ conversation: openConversation && /* @__PURE__ */ jsxs("button", {
5185
+ type: "button",
5186
+ className: "sb-conv",
5187
+ onClick: talk,
5188
+ children: [
5189
+ openConversation.unread && /* @__PURE__ */ jsx("span", { className: "sb-dot" }),
5190
+ openConversation.unread && /* @__PURE__ */ jsx("span", {
5191
+ className: "sb-sr",
5192
+ children: S.newReply
5193
+ }),
5194
+ /* @__PURE__ */ jsxs("span", {
5195
+ className: "sb-conv-text",
5196
+ children: [/* @__PURE__ */ jsx("span", {
5197
+ className: "sb-conv-title",
5198
+ children: supportStatus(openConversation, company, S)
5199
+ }), /* @__PURE__ */ jsx("span", {
5200
+ className: "sb-conv-line",
5201
+ children: openConversation.lastMessageText
5202
+ })]
5203
+ }),
5204
+ /* @__PURE__ */ jsx(ChevronRightIcon, {})
5205
+ ]
5206
+ })
4211
5207
  }) : threadItems(messages).map((item) => item.kind === "line" ? /* @__PURE__ */ jsx(TourLine, {
4212
5208
  line: item.line,
4213
5209
  labels: L,
@@ -4230,10 +5226,13 @@ function QuestionPanel({ inline, launcher, close, focusBox }) {
4230
5226
  latest: item.question.id === latestQuestion,
4231
5227
  fresh: !seenRef.current.has(item.response.id),
4232
5228
  guide: guideFor(item.response, listed),
4233
- words: replyWords(item.response, listed, api.firstName, L),
5229
+ words: replyWords(item.response, api.firstName, L),
4234
5230
  labels: L,
4235
5231
  onShowMe: inline ? void 0 : start,
4236
- keepInPlace
5232
+ talk: support.available ? {
5233
+ labels: S,
5234
+ onTalk: talk
5235
+ } : void 0
4237
5236
  }, item.response.id),
4238
5237
  item.response?.kind === "error" && /* @__PURE__ */ jsx(FailureNote, {
4239
5238
  message: item.response,
@@ -4311,30 +5310,33 @@ function guideFor(message, listed) {
4311
5310
  return key === void 0 ? void 0 : listed.find((guide) => guide.key === key);
4312
5311
  }
4313
5312
  /**
4314
- * The assistant's own lines around a written answer: the greeting on the
4315
- * first reply of a conversation, and the closing under one that offers a
4316
- * guide. A reply with nothing to offer gets neither.
5313
+ * The assistant's own lines around a reply: the greeting on the first reply
5314
+ * of a conversation that offers a guide or an answer, and the closing under
5315
+ * any reply that offers a guide.
4317
5316
  */
4318
- function replyWords(message, listed, name, labels) {
4319
- const { reply } = message;
4320
- if (reply.outcome === "NONE") return {};
5317
+ function replyWords({ reply }, name, labels) {
5318
+ const offers = reply.outcome === "GUIDE" || reply.outcome === "ANSWERED";
4321
5319
  return {
4322
- greeting: reply.firstTurn ? labels.greeting(name) : void 0,
4323
- closing: guideFor(message, listed) ? labels.guideClosing : void 0
5320
+ greeting: reply.firstTurn && offers ? labels.greeting(name) : void 0,
5321
+ closing: reply.guideKey !== void 0 ? labels.guideClosing : void 0
4324
5322
  };
4325
5323
  }
4326
5324
  const SUGGESTION_LIMIT = 4;
4327
- function Suggestions({ labels, guides, onRow }) {
5325
+ function Suggestions({ labels, guides, onRow, conversation }) {
4328
5326
  return /* @__PURE__ */ jsxs("div", {
4329
5327
  className: "sb-start",
4330
- children: [/* @__PURE__ */ jsx("h3", {
4331
- className: "sb-start-title",
4332
- children: labels.emptyTitle
4333
- }), guides.length > 0 && /* @__PURE__ */ jsx(Group, {
4334
- label: labels.commonQuestions,
4335
- guides: guides.slice(0, SUGGESTION_LIMIT),
4336
- onRow
4337
- })]
5328
+ children: [
5329
+ conversation,
5330
+ /* @__PURE__ */ jsx("h3", {
5331
+ className: "sb-start-title",
5332
+ children: labels.emptyTitle
5333
+ }),
5334
+ guides.length > 0 && /* @__PURE__ */ jsx(Group, {
5335
+ label: labels.commonQuestions,
5336
+ guides: guides.slice(0, SUGGESTION_LIMIT),
5337
+ onRow
5338
+ })
5339
+ ]
4338
5340
  });
4339
5341
  }
4340
5342
  function Group({ label, guides, onRow }) {
@@ -4414,8 +5416,9 @@ function Waiting({ stage, since, labels, productName }) {
4414
5416
  ]
4415
5417
  });
4416
5418
  }
4417
- function ReplyArticle({ message, latest, fresh, guide, words, labels, onShowMe, keepInPlace }) {
5419
+ function ReplyArticle({ message, latest, fresh, guide, words, labels, onShowMe, talk }) {
4418
5420
  const { reply } = message;
5421
+ const notCovered = reply.outcome === "NONE" || reply.outcome === "UNSUPPORTED";
4419
5422
  const primary = latest && !message.toured;
4420
5423
  const [arrived] = useState(fresh);
4421
5424
  return /* @__PURE__ */ jsxs("article", {
@@ -4428,7 +5431,7 @@ function ReplyArticle({ message, latest, fresh, guide, words, labels, onShowMe,
4428
5431
  className: "sb-reply-greeting",
4429
5432
  children: words.greeting
4430
5433
  }),
4431
- /* @__PURE__ */ jsx(AnswerText, { text: reply.answer }),
5434
+ reply.answer !== "" && /* @__PURE__ */ jsx(AnswerText, { text: reply.answer }),
4432
5435
  words.closing && /* @__PURE__ */ jsx("p", {
4433
5436
  className: "sb-reply-closing",
4434
5437
  children: words.closing
@@ -4444,7 +5447,21 @@ function ReplyArticle({ message, latest, fresh, guide, words, labels, onShowMe,
4444
5447
  children: [/* @__PURE__ */ jsx(PlayIcon, {}), labels.showMeHow]
4445
5448
  })
4446
5449
  }),
4447
- reply.outcome === "NONE" && reply.supportUrl && /* @__PURE__ */ jsxs("div", {
5450
+ talk && (notCovered ? /* @__PURE__ */ jsx("div", {
5451
+ className: "sb-reply-action",
5452
+ children: /* @__PURE__ */ jsxs("button", {
5453
+ type: "button",
5454
+ className: "sb-secondary sb-talk-button",
5455
+ onClick: talk.onTalk,
5456
+ children: [/* @__PURE__ */ jsx(HeadsetIcon, {}), talk.labels.talkToPerson]
5457
+ })
5458
+ }) : /* @__PURE__ */ jsxs("button", {
5459
+ type: "button",
5460
+ className: "sb-talk",
5461
+ onClick: talk.onTalk,
5462
+ children: [/* @__PURE__ */ jsx(HeadsetIcon, {}), talk.labels.talkToPerson]
5463
+ })),
5464
+ !talk && reply.supportUrl && /* @__PURE__ */ jsxs("div", {
4448
5465
  className: "sb-support",
4449
5466
  children: [/* @__PURE__ */ jsx("span", { children: labels.supportOffer }), /* @__PURE__ */ jsxs("a", {
4450
5467
  className: "sb-support-link",
@@ -4460,82 +5477,10 @@ function ReplyArticle({ message, latest, fresh, guide, words, labels, onShowMe,
4460
5477
  })
4461
5478
  ]
4462
5479
  })]
4463
- }),
4464
- reply.sources.length > 0 && (latest ? /* @__PURE__ */ jsx(Sources, {
4465
- sources: reply.sources,
4466
- labels
4467
- }) : /* @__PURE__ */ jsx(FoldedSources, {
4468
- sources: reply.sources,
4469
- labels,
4470
- keepInPlace
4471
- }))
5480
+ })
4472
5481
  ]
4473
5482
  });
4474
5483
  }
4475
- function Sources({ sources, labels }) {
4476
- return /* @__PURE__ */ jsx("div", {
4477
- className: "sb-sources",
4478
- role: "group",
4479
- "aria-label": labels.sources,
4480
- children: /* @__PURE__ */ jsx(SourceRows, {
4481
- sources,
4482
- labels
4483
- })
4484
- });
4485
- }
4486
- function FoldedSources({ sources, labels, keepInPlace }) {
4487
- const [open, setOpen] = useState(false);
4488
- return /* @__PURE__ */ jsxs("div", {
4489
- className: "sb-sources",
4490
- role: "group",
4491
- "aria-label": labels.sources,
4492
- children: [/* @__PURE__ */ jsxs("button", {
4493
- type: "button",
4494
- className: "sb-sources-toggle",
4495
- "aria-expanded": open,
4496
- onClick: () => keepInPlace(() => setOpen((now) => !now)),
4497
- children: [
4498
- /* @__PURE__ */ jsx(DocIcon, {}),
4499
- /* @__PURE__ */ jsx("span", { children: labels.sourcesCount(sources.length) }),
4500
- /* @__PURE__ */ jsx(ChevronDownIcon, {})
4501
- ]
4502
- }), open && /* @__PURE__ */ jsx(SourceRows, {
4503
- sources,
4504
- labels
4505
- })]
4506
- });
4507
- }
4508
- function SourceRows({ sources, labels }) {
4509
- return /* @__PURE__ */ jsx(Fragment, { children: sources.map((source) => {
4510
- const name = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(DocIcon, {}), /* @__PURE__ */ jsxs("span", {
4511
- className: "sb-doc-text",
4512
- children: [source.heading !== void 0 && /* @__PURE__ */ jsx("span", {
4513
- className: "sb-doc-trail",
4514
- children: `${source.title} › `
4515
- }), /* @__PURE__ */ jsx("span", {
4516
- className: "sb-doc-title",
4517
- children: source.heading ?? source.title
4518
- })]
4519
- })] });
4520
- return source.url === void 0 ? /* @__PURE__ */ jsx("div", {
4521
- className: "sb-doc",
4522
- children: name
4523
- }, source.id) : /* @__PURE__ */ jsxs("a", {
4524
- className: "sb-doc",
4525
- href: source.url,
4526
- target: "_blank",
4527
- rel: "noreferrer",
4528
- children: [
4529
- name,
4530
- /* @__PURE__ */ jsx(ExternalIcon, {}),
4531
- /* @__PURE__ */ jsx("span", {
4532
- className: "sb-sr",
4533
- children: labels.newTab
4534
- })
4535
- ]
4536
- }, source.id);
4537
- }) });
4538
- }
4539
5484
  function FailureNote({ message, fresh, labels, onRetry }) {
4540
5485
  const { failure } = message;
4541
5486
  const lost = failure.kind === "lost";
@@ -4641,19 +5586,6 @@ function Stars({ labels, onPick }) {
4641
5586
  }, rating))
4642
5587
  });
4643
5588
  }
4644
- function clockTime(iso) {
4645
- const at = new Date(iso);
4646
- if (Number.isNaN(at.getTime())) return iso;
4647
- return at.toLocaleTimeString(void 0, {
4648
- hour: "2-digit",
4649
- minute: "2-digit"
4650
- });
4651
- }
4652
- function minutesUntil(iso) {
4653
- const at = new Date(iso).getTime();
4654
- if (Number.isNaN(at)) return 1;
4655
- return Math.max(1, Math.round((at - Date.now()) / 6e4));
4656
- }
4657
5589
  //#endregion
4658
5590
  //#region src/components/SunboardOverlay.tsx
4659
5591
  function SunboardOverlay({ launcher = true } = {}) {
@@ -4665,4 +5597,4 @@ function SunboardOverlay({ launcher = true } = {}) {
4665
5597
  ] });
4666
5598
  }
4667
5599
  //#endregion
4668
- export { DEFAULT_SUNBOARD_API_URL, HttpTransport, LAUNCH_PARAM, Launcher, MemoryTransport, PREVIEW_EXPIRY_STORAGE, PREVIEW_KEY_STORAGE, PREVIEW_PARAM, SunboardOverlay, SunboardProvider, cooldownActive, detectThemeMode, enLabels, hasSeen, isBootstrapOff, markSeen, pickPushGuide, recordDismissal, resolveAnchor, themeToCssVars, useAnchorElement, useComponentSettings, useCurrentPathname, useGuide, useGuidesApi, useLabels, useLauncher, useTheme, useThemeMode, useTrack };
5600
+ export { DEFAULT_SUNBOARD_API_URL, HttpTransport, LAUNCH_PARAM, Launcher, MemoryTransport, PREVIEW_EXPIRY_STORAGE, PREVIEW_KEY_STORAGE, PREVIEW_PARAM, SunboardOverlay, SunboardProvider, cooldownActive, detectThemeMode, enLabels, hasSeen, isBootstrapOff, markSeen, pickPushGuide, recordDismissal, resolveAnchor, themeToCssVars, useAnchorElement, useComponentSettings, useCurrentPathname, useGuide, useGuidesApi, useLabels, useLauncher, useSupport, useTheme, useThemeMode, useTrack };