@clivly/chat-widget 0.17.0 → 0.18.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.
@@ -154,6 +154,43 @@ function writeVisitorToken(storage, widgetId, email, token) {
154
154
  storage.removeItem?.(legacyVisitorTokenKey(widgetId));
155
155
  } catch {}
156
156
  }
157
+ // src/widget-presentation-cache.ts
158
+ function presentationKey(widgetId) {
159
+ return `clivly-chat-widget:${widgetId}:presentation`;
160
+ }
161
+ function isPresentation(value) {
162
+ if (typeof value !== "object" || value === null) return false;
163
+ const candidate = value;
164
+ return typeof candidate.name === "string" && typeof candidate.config === "object" && candidate.config !== null && Array.isArray(candidate.topics);
165
+ }
166
+ function readPresentation(storage, widgetId) {
167
+ if (!storage) return null;
168
+ try {
169
+ const raw = storage.getItem(presentationKey(widgetId));
170
+ if (!raw) return null;
171
+ const parsed = JSON.parse(raw);
172
+ return isPresentation(parsed) ? parsed : null;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+ function presentationOf(session) {
178
+ return {
179
+ config: {
180
+ brandColor: session.widget.config.brandColor ?? null,
181
+ greeting: session.widget.config.greeting ?? null
182
+ },
183
+ name: session.widget.name,
184
+ supportState: session.support?.state ?? null,
185
+ topics: session.support?.topics ?? []
186
+ };
187
+ }
188
+ function writePresentation(storage, widgetId, presentation) {
189
+ if (!storage) return;
190
+ try {
191
+ storage.setItem(presentationKey(widgetId), JSON.stringify(presentation));
192
+ } catch {}
193
+ }
157
194
  // src/chat-widget.tsx
158
195
  const DELIVERY_LABELS = {
159
196
  failed: "Failed to send",
@@ -308,9 +345,9 @@ const WIDGET_STYLES = `
308
345
  border: 1px solid var(--clivly-chat-border);
309
346
  border-radius: 1rem;
310
347
  box-shadow: 0 24px 80px var(--clivly-chat-shadow);
311
- display: grid;
312
- grid-template-rows: auto 1fr auto;
313
- height: min(44rem, 75vh);
348
+ display: flex;
349
+ flex-direction: column;
350
+ height: min(40rem, 75vh);
314
351
  overflow: hidden;
315
352
  position: absolute;
316
353
  right: 0;
@@ -327,20 +364,56 @@ const WIDGET_STYLES = `
327
364
  @media (width < 48rem) {
328
365
  .clivly-chat-widget__panel {
329
366
  bottom: 4rem;
330
- height: min(44rem, calc(100dvh - 6.5rem));
367
+ height: min(40rem, calc(100dvh - 6.5rem));
368
+ }
369
+ }
370
+ /* On a phone a floating card leaves a strip of page above it and puts the
371
+ launcher over its own composer, so the panel becomes a full-screen sheet
372
+ instead — the close button in its header takes over from the launcher,
373
+ which is hidden while it is open. */
374
+ @media (width < 30rem) {
375
+ .clivly-chat-widget__panel {
376
+ border: 0;
377
+ border-radius: 0;
378
+ bottom: 0;
379
+ height: 100dvh;
380
+ left: 0;
381
+ padding-bottom: env(safe-area-inset-bottom);
382
+ padding-top: env(safe-area-inset-top);
383
+ position: fixed;
384
+ right: 0;
385
+ top: 0;
386
+ width: 100vw;
387
+ }
388
+ .clivly-chat-widget[data-clivly-open] .clivly-chat-widget__launcher {
389
+ display: none;
331
390
  }
332
391
  }
333
392
  .clivly-chat-widget__header {
393
+ align-items: center;
334
394
  border-bottom: 1px solid var(--clivly-chat-border);
335
395
  display: flex;
396
+ flex: 0 0 auto;
336
397
  justify-content: space-between;
337
- gap: 1rem;
338
- padding: 1rem;
398
+ gap: 0.75rem;
399
+ padding: 0.75rem 0.75rem 0.75rem 1rem;
400
+ }
401
+ .clivly-chat-widget__heading {
402
+ min-width: 0;
339
403
  }
340
404
  .clivly-chat-widget__title {
341
405
  font-size: 1rem;
342
406
  font-weight: 600;
343
407
  margin: 0;
408
+ overflow: hidden;
409
+ text-overflow: ellipsis;
410
+ white-space: nowrap;
411
+ }
412
+ .clivly-chat-widget__header .clivly-chat-widget__subtitle {
413
+ margin-top: 0.125rem;
414
+ overflow: hidden;
415
+ text-overflow: ellipsis;
416
+ white-space: nowrap;
344
417
  }
345
418
  .clivly-chat-widget__subtitle {
346
419
  color: var(--clivly-chat-muted);
@@ -348,19 +421,50 @@ const WIDGET_STYLES = `
348
421
  margin: 0.25rem 0 0;
349
422
  }
350
423
  .clivly-chat-widget__close {
424
+ align-items: center;
351
425
  background: transparent;
352
426
  border: 0;
427
+ border-radius: 999px;
353
428
  color: var(--clivly-chat-muted);
354
429
  cursor: pointer;
430
+ display: inline-flex;
431
+ flex: 0 0 auto;
432
+ height: 2.25rem;
433
+ justify-content: center;
355
434
  padding: 0;
435
+ width: 2.25rem;
436
+ }
437
+ .clivly-chat-widget__close:hover,
438
+ .clivly-chat-widget__close:focus-visible {
439
+ background: var(--clivly-chat-operator);
440
+ color: var(--clivly-chat-text);
441
+ }
442
+ .clivly-chat-widget__close:focus-visible {
443
+ outline: 2px solid var(--clivly-chat-accent);
444
+ outline-offset: 2px;
445
+ }
446
+ .clivly-chat-widget__close svg {
447
+ height: 1.25rem;
448
+ width: 1.25rem;
356
449
  }
357
450
  .clivly-chat-widget__body {
358
451
  display: flex;
452
+ flex: 1 1 auto;
359
453
  flex-direction: column;
360
454
  gap: 0.75rem;
455
+ min-height: 0;
361
456
  overflow-y: auto;
457
+ overscroll-behavior: contain;
362
458
  padding: 1rem;
363
459
  }
460
+ .clivly-chat-widget__visually-hidden {
461
+ clip-path: inset(50%);
462
+ height: 1px;
463
+ overflow: hidden;
464
+ position: absolute;
465
+ white-space: nowrap;
466
+ width: 1px;
467
+ }
364
468
  .clivly-chat-widget__message {
365
469
  border-radius: 1rem;
366
470
  max-width: 85%;
@@ -438,6 +542,11 @@ const WIDGET_STYLES = `
438
542
  .clivly-chat-widget__powered a:focus-visible {
439
543
  text-decoration: underline;
440
544
  }
545
+ .clivly-chat-widget__powered,
546
+ .clivly-chat-widget__composer,
547
+ .clivly-chat-widget__form {
548
+ flex: 0 0 auto;
549
+ }
441
550
  .clivly-chat-widget__composer,
442
551
  .clivly-chat-widget__form {
443
552
  border-top: 1px solid var(--clivly-chat-border);
@@ -445,6 +554,62 @@ const WIDGET_STYLES = `
445
554
  gap: 0.75rem;
446
555
  padding: 1rem;
447
556
  }
557
+ .clivly-chat-widget__composer {
558
+ gap: 0.375rem;
559
+ padding: 0.75rem;
560
+ }
561
+ /* With topics docked above it, the composer has no border of its own: the
562
+ strip and the field read as one input area. */
563
+ .clivly-chat-widget__topics + .clivly-chat-widget__composer {
564
+ border-top: 0;
565
+ }
566
+ .clivly-chat-widget__composer-row {
567
+ align-items: flex-end;
568
+ display: flex;
569
+ gap: 0.5rem;
570
+ }
571
+ .clivly-chat-widget__composer-field {
572
+ flex: 1 1 auto;
573
+ min-width: 0;
574
+ }
575
+ .clivly-chat-widget__composer .clivly-chat-widget__textarea {
576
+ line-height: 1.4;
577
+ max-height: 8rem;
578
+ min-height: 2.75rem;
579
+ overflow-y: auto;
580
+ padding: 0.625rem 0.75rem;
581
+ resize: none;
582
+ }
583
+ .clivly-chat-widget__send {
584
+ align-items: center;
585
+ border-radius: 999px;
586
+ display: inline-flex;
587
+ height: 2.75rem;
588
+ justify-content: center;
589
+ padding: 0;
590
+ width: 2.75rem;
591
+ }
592
+ .clivly-chat-widget__send svg {
593
+ height: 1.25rem;
594
+ width: 1.25rem;
595
+ }
596
+ .clivly-chat-widget__composer .clivly-chat-widget__actions {
597
+ padding: 0 0.25rem;
598
+ }
599
+ .clivly-chat-widget__composer .clivly-chat-widget__subtitle {
600
+ font-size: 0.75rem;
601
+ margin: 0;
602
+ }
603
+ /* Once the conversation is ready the status has nothing to say to a sighted
604
+ visitor; it stays in the accessibility tree so the change is announced. */
605
+ .clivly-chat-widget__actions[data-ready] {
606
+ clip-path: inset(50%);
607
+ height: 1px;
608
+ overflow: hidden;
609
+ position: absolute;
610
+ white-space: nowrap;
611
+ width: 1px;
612
+ }
448
613
  .clivly-chat-widget__field {
449
614
  display: grid;
450
615
  gap: 0.35rem;
@@ -525,13 +690,42 @@ const WIDGET_STYLES = `
525
690
  font-size: 0.6875rem;
526
691
  padding: 0 0.4rem;
527
692
  }
693
+ /* Docked above the composer. Wide screens wrap up to about two rows and
694
+ scroll beyond that; narrow or touch screens get one sideways-scrolling row
695
+ whose right edge fades to show there is more. */
528
696
  .clivly-chat-widget__topics {
697
+ border-top: 1px solid var(--clivly-chat-border);
529
698
  display: flex;
699
+ flex: 0 0 auto;
530
700
  flex-wrap: wrap;
531
- gap: 0.5rem;
532
- margin: 0;
533
- padding: 0;
701
+ gap: 0.375rem;
534
702
  list-style: none;
703
+ margin: 0;
704
+ max-height: 5.25rem;
705
+ overflow-y: auto;
706
+ padding: 0.625rem 0.75rem 0;
707
+ scrollbar-width: thin;
708
+ }
709
+ .clivly-chat-widget__topics > li {
710
+ flex: 0 0 auto;
711
+ scroll-snap-align: start;
712
+ }
713
+ @media (width < 48rem), (any-pointer: coarse) {
714
+ .clivly-chat-widget__topics {
715
+ flex-wrap: nowrap;
716
+ -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent);
717
+ mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent);
718
+ max-height: none;
719
+ overflow-x: auto;
720
+ overflow-y: hidden;
721
+ padding-right: 1.5rem;
722
+ scroll-padding-left: 0.75rem;
723
+ scroll-snap-type: x proximity;
724
+ scrollbar-width: none;
725
+ }
726
+ .clivly-chat-widget__topics::-webkit-scrollbar {
727
+ display: none;
728
+ }
535
729
  }
536
730
  .clivly-chat-widget__topic {
537
731
  background: var(--clivly-chat-panel);
@@ -539,9 +733,11 @@ const WIDGET_STYLES = `
539
733
  border-radius: 999px;
540
734
  color: var(--clivly-chat-text);
541
735
  cursor: pointer;
542
- font-size: 0.875rem;
543
- min-height: 2.25rem;
544
- padding: 0.4rem 0.85rem;
736
+ font-size: 0.8125rem;
737
+ line-height: 1.2;
738
+ min-height: 2rem;
739
+ padding: 0.375rem 0.75rem;
740
+ white-space: nowrap;
545
741
  }
546
742
  .clivly-chat-widget__topic:hover,
547
743
  .clivly-chat-widget__topic:focus-visible {
@@ -617,15 +813,44 @@ function markMessageFailed(messages, clientMessageId) {
617
813
  function sortMessages(messages) {
618
814
  return [...messages].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
619
815
  }
620
- function useThemeStyle(session, style) {
816
+ const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
817
+ /** Relative luminance above which white text stops being readable. */
818
+ const LIGHT_ACCENT_LUMINANCE = .45;
819
+ /**
820
+ * Text colour for a button filled with `hex`: white on dark brands, near-black
821
+ * on light ones, so a yellow or pastel brand colour does not leave the
822
+ * launcher's icon and the Send label white-on-white. Null for anything that is
823
+ * not a plain hex colour, which keeps the stylesheet's own contrast colour.
824
+ */
825
+ function accentContrastFor(hex) {
826
+ if (!HEX_COLOR.test(hex)) return null;
827
+ const digits = hex.length === 4 ? [...hex.slice(1)].map((digit) => digit + digit).join("") : hex.slice(1);
828
+ const [red = 0, green = 0, blue = 0] = [
829
+ 0,
830
+ 2,
831
+ 4
832
+ ].map((offset) => {
833
+ const value = Number.parseInt(digits.slice(offset, offset + 2), 16) / 255;
834
+ return value <= .03928 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4;
835
+ });
836
+ return .2126 * red + .7152 * green + .0722 * blue > LIGHT_ACCENT_LUMINANCE ? "#111827" : "#ffffff";
837
+ }
838
+ /** The session's brand colour, or the remembered one until a session lands. */
839
+ function brandColorOf(session, cached) {
840
+ if (session) return session.widget.config.brandColor ?? null;
841
+ return cached?.config.brandColor ?? null;
842
+ }
843
+ function useThemeStyle(brandColor, style) {
621
844
  return (0, react.useMemo)(() => {
622
- const brandColor = session?.widget.config.brandColor ?? null;
623
- if (!brandColor) return style;
845
+ const hostSetsAccent = Boolean(style && "--clivly-chat-accent" in style);
846
+ if (!brandColor || hostSetsAccent) return style;
847
+ const contrast = accentContrastFor(brandColor);
624
848
  return {
625
849
  ["--clivly-chat-accent"]: brandColor,
850
+ ...contrast ? { ["--clivly-chat-accent-contrast"]: contrast } : {},
626
851
  ...style
627
852
  };
628
- }, [session?.widget.config.brandColor, style]);
853
+ }, [brandColor, style]);
629
854
  }
630
855
  /**
631
856
  * Track the theme the widget should paint.
@@ -699,12 +924,21 @@ function automationBadge(message) {
699
924
  * root's `data-clivly-launcher` (see WIDGET_STYLES); the label is always its
700
925
  * accessible name, and `aria-controls` names the panel only while it exists.
701
926
  */
702
- function Launcher({ label, onToggle, open, panelId }) {
927
+ function Launcher({ label, onIntent, onToggle, open, panelId }) {
703
928
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
704
929
  "aria-controls": open ? panelId : void 0,
705
930
  "aria-expanded": open,
706
931
  className: "clivly-chat-widget__launcher",
707
932
  onClick: onToggle,
933
+ onFocus: () => {
934
+ onIntent("likely");
935
+ },
936
+ onPointerDown: () => {
937
+ onIntent("certain");
938
+ },
939
+ onPointerEnter: (event) => {
940
+ if (event.pointerType === "mouse") onIntent("likely");
941
+ },
708
942
  type: "button",
709
943
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LauncherIcon, { open }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
710
944
  className: "clivly-chat-widget__launcher-label",
@@ -849,9 +1083,14 @@ function composerStatusText(hasTransport, isReady, reconnecting) {
849
1083
  return reconnecting ? "Reconnecting…" : "Connecting…";
850
1084
  }
851
1085
  /**
852
- * Guided options under the greeting. Native buttons in a labelled group, so
853
- * they are reachable with Tab and operable with Enter/Space; free text stays
854
- * available beside them the whole time.
1086
+ * Guided options, docked above the composer rather than inside the transcript.
1087
+ * In the transcript they sat below the greeting and the scroll-to-newest
1088
+ * pinned them there, so on a phone six wrapped chips pushed the greeting and
1089
+ * the answer above the fold. Docked, they are one compact strip (scrolling
1090
+ * sideways on narrow screens) and the conversation keeps the space.
1091
+ *
1092
+ * Native buttons in a labelled group, so they are reachable with Tab and
1093
+ * operable with Enter/Space; free text stays available beside them.
855
1094
  */
856
1095
  function SupportTopics({ disabled, onSelect, topics }) {
857
1096
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
@@ -868,9 +1107,64 @@ function SupportTopics({ disabled, onSelect, topics }) {
868
1107
  }) }, topic.id))
869
1108
  });
870
1109
  }
871
- /** Topics are offered only while automation is answering this conversation. */
872
- function visibleTopics(session, state) {
873
- return state === "automated" ? session?.support?.topics ?? [] : [];
1110
+ /**
1111
+ * Topics are offered only while automation is answering this conversation.
1112
+ * Before the session lands, the remembered presentation stands in so the
1113
+ * strip is there on the first frame; it shows only if this browser last saw
1114
+ * them offered, so a visitor already talking to a person does not see them
1115
+ * flash up and vanish.
1116
+ */
1117
+ function visibleTopics(session, state, cached) {
1118
+ if (session) return state === "automated" ? session.support?.topics ?? [] : [];
1119
+ return cached?.supportState === "automated" ? cached.topics : [];
1120
+ }
1121
+ /**
1122
+ * What the open panel shows, derived from the session or — before it lands —
1123
+ * from the host's identity and the remembered presentation.
1124
+ *
1125
+ * A host-identified visitor never sees the form: while their session loads the
1126
+ * panel already shows the conversation shell — title, topics and a composer
1127
+ * they can type into — so opening the widget is not a blank wait. A failed
1128
+ * bootstrap sets `error` and falls back to the form, the recoverable state.
1129
+ */
1130
+ function panelView({ cachedPresentation, defaultVisitor, error, session, supportState }) {
1131
+ const awaitingHostSession = !session && isCompleteVisitor(defaultVisitor) && !error;
1132
+ const showComposer = Boolean(session) || awaitingHostSession;
1133
+ const greeting = session?.widget.config.greeting ?? cachedPresentation?.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
1134
+ return {
1135
+ awaitingHostSession,
1136
+ greeting,
1137
+ panelTitle: session?.widget.name ?? cachedPresentation?.name ?? "Start a conversation",
1138
+ sessionSummary: awaitingHostSession && defaultVisitor ? `${defaultVisitor.name?.trim()} • ${defaultVisitor.email?.trim()}` : sessionSummaryText(session, greeting),
1139
+ showComposer,
1140
+ topics: showComposer ? visibleTopics(session, supportState, cachedPresentation) : []
1141
+ };
1142
+ }
1143
+ /**
1144
+ * Who to start a session for ahead of the click, or null to wait for it. Only
1145
+ * a host-identified visitor; and on a mere hover or focus only one with a
1146
+ * stored thread to resume.
1147
+ */
1148
+ function prefetchIdentity(intent, { defaultVisitor, settled }, storedToken) {
1149
+ if (settled || !isCompleteVisitor(defaultVisitor)) return null;
1150
+ const identity = {
1151
+ email: defaultVisitor.email.trim(),
1152
+ name: defaultVisitor.name.trim()
1153
+ };
1154
+ if (intent === "likely" && !storedToken(identity.email)) return null;
1155
+ return identity;
1156
+ }
1157
+ function startSessionQuietly(startSession, identity) {
1158
+ if (!identity) return;
1159
+ startSession(identity, "host").catch(() => {});
1160
+ }
1161
+ /** The composer grows with its text up to this height, then scrolls. */
1162
+ const COMPOSER_MAX_HEIGHT_PX = 128;
1163
+ /** Size a textarea to its content, between its CSS min-height and the cap. */
1164
+ function fitTextarea(textarea) {
1165
+ if (!textarea) return;
1166
+ textarea.style.height = "auto";
1167
+ textarea.style.height = `${Math.min(textarea.scrollHeight, COMPOSER_MAX_HEIGHT_PX)}px`;
874
1168
  }
875
1169
  function composerPlaceholder(hasTopics) {
876
1170
  return hasTopics ? "Or type your question" : "Write your message";
@@ -1039,12 +1333,20 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1039
1333
  const [session, setSession] = (0, react.useState)(null);
1040
1334
  const [supportState, setSupportState] = (0, react.useState)(null);
1041
1335
  const [assistantThinking, setAssistantThinking] = (0, react.useState)(false);
1336
+ const [cachedPresentation, setCachedPresentation] = (0, react.useState)(null);
1337
+ (0, react.useEffect)(() => {
1338
+ setCachedPresentation(readPresentation(storage, widgetId));
1339
+ }, [storage, widgetId]);
1042
1340
  const [visitor, setVisitor] = (0, react.useState)({
1043
1341
  email: defaultVisitor?.email ?? "",
1044
1342
  name: defaultVisitor?.name ?? ""
1045
1343
  });
1046
1344
  const transportRef = (0, react.useRef)(null);
1047
1345
  const listRef = (0, react.useRef)(null);
1346
+ const composerRef = (0, react.useRef)(null);
1347
+ (0, react.useEffect)(() => {
1348
+ fitTextarea(composerRef.current);
1349
+ }, [messageDraft]);
1048
1350
  const identityRef = (0, react.useRef)(null);
1049
1351
  const sessionSourceRef = (0, react.useRef)(null);
1050
1352
  const generationRef = (0, react.useRef)(0);
@@ -1056,7 +1358,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1056
1358
  console.warn("[clivly] <ChatWidget> was given no createTransport, so messages cannot be sent. Pass createTransport={createClivlyTransport()} — see https://www.npmjs.com/package/@clivly/chat-widget#five-minute-quick-start");
1057
1359
  }, []);
1058
1360
  const showWaitingNotice = useWaitingNotice(messages);
1059
- const themeStyle = useThemeStyle(session, style);
1361
+ const themeStyle = useThemeStyle(brandColorOf(session, cachedPresentation), style);
1060
1362
  const resolvedTheme = useResolvedTheme(theme);
1061
1363
  (0, react.useEffect)(() => () => {
1062
1364
  transportRef.current?.close();
@@ -1085,6 +1387,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1085
1387
  });
1086
1388
  if (generation !== generationRef.current) return;
1087
1389
  writeVisitorToken(storage, widgetId, identity.email, next.visitorToken);
1390
+ writePresentation(storage, widgetId, presentationOf(next));
1088
1391
  setSupportState(next.support?.state ?? null);
1089
1392
  setSession(next);
1090
1393
  },
@@ -1122,6 +1425,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1122
1425
  });
1123
1426
  if (isStale()) return;
1124
1427
  writeVisitorToken(storage, widgetId, identity.email, nextSession.visitorToken);
1428
+ writePresentation(storage, widgetId, presentationOf(nextSession));
1125
1429
  identityRef.current = identity;
1126
1430
  sessionSourceRef.current = source;
1127
1431
  setSupportState(nextSession.support?.state ?? null);
@@ -1175,6 +1479,13 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1175
1479
  session,
1176
1480
  startSession
1177
1481
  ]);
1482
+ const prefetchSession = (intent) => {
1483
+ const identity = prefetchIdentity(intent, {
1484
+ defaultVisitor,
1485
+ settled: isOpen || Boolean(session) || isConnecting || Boolean(error)
1486
+ }, (email) => readVisitorToken(storage, widgetId, email));
1487
+ startSessionQuietly(startSession, identity);
1488
+ };
1178
1489
  /**
1179
1490
  * Show the visitor's message at once and hand it to the transport. A topic
1180
1491
  * click goes as `topic.select` when the transport supports it, so the
@@ -1213,13 +1524,17 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1213
1524
  sendVisitorMessage(body);
1214
1525
  };
1215
1526
  const canSend = Boolean(createTransport) && isReady;
1216
- const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
1217
- const panelTitle = session?.widget.name ?? "Start a conversation";
1218
- const sessionSummary = sessionSummaryText(session, greeting);
1219
- const topics = visibleTopics(session, supportState);
1527
+ const { awaitingHostSession, greeting, panelTitle, sessionSummary, showComposer, topics } = panelView({
1528
+ cachedPresentation,
1529
+ defaultVisitor,
1530
+ error,
1531
+ session,
1532
+ supportState
1533
+ });
1220
1534
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1221
1535
  className: `clivly-chat-widget ${className ?? ""}`.trim(),
1222
1536
  "data-clivly-launcher": launcher,
1537
+ "data-clivly-open": isOpen || void 0,
1223
1538
  "data-clivly-placement": placement,
1224
1539
  "data-clivly-theme": resolvedTheme,
1225
1540
  style: themeStyle,
@@ -1227,6 +1542,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1227
1542
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDGET_STYLES }),
1228
1543
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Launcher, {
1229
1544
  label: launcherLabel,
1545
+ onIntent: prefetchSession,
1230
1546
  onToggle: () => {
1231
1547
  setIsOpen((open) => !open);
1232
1548
  },
@@ -1240,23 +1556,36 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1240
1556
  children: [
1241
1557
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
1242
1558
  className: "clivly-chat-widget__header",
1243
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1244
- className: "clivly-chat-widget__title",
1245
- children: panelTitle
1246
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1247
- className: "clivly-chat-widget__subtitle",
1248
- children: sessionSummary
1249
- })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1559
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1560
+ className: "clivly-chat-widget__heading",
1561
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1562
+ className: "clivly-chat-widget__title",
1563
+ children: panelTitle
1564
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1565
+ className: "clivly-chat-widget__subtitle",
1566
+ children: sessionSummary
1567
+ })]
1568
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1250
1569
  "aria-label": "Close chat",
1251
1570
  className: "clivly-chat-widget__close",
1252
1571
  onClick: () => {
1253
1572
  setIsOpen(false);
1254
1573
  },
1255
1574
  type: "button",
1256
- children: "Close"
1575
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1576
+ "aria-hidden": "true",
1577
+ fill: "none",
1578
+ focusable: "false",
1579
+ stroke: "currentColor",
1580
+ strokeLinecap: "round",
1581
+ strokeWidth: 2,
1582
+ viewBox: "0 0 24 24",
1583
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18M6 6l12 12" })
1584
+ })
1257
1585
  })]
1258
1586
  }),
1259
1587
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1588
+ "aria-busy": awaitingHostSession,
1260
1589
  "aria-label": "Conversation",
1261
1590
  className: "clivly-chat-widget__body",
1262
1591
  ref: listRef,
@@ -1271,13 +1600,6 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1271
1600
  hasSession: Boolean(session),
1272
1601
  messages
1273
1602
  }),
1274
- topics.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SupportTopics, {
1275
- disabled: !canSend,
1276
- onSelect: (topic) => {
1277
- sendVisitorMessage(topic.label, topic);
1278
- },
1279
- topics
1280
- }) : null,
1281
1603
  session ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusNotice, {
1282
1604
  assistantThinking,
1283
1605
  email: session.contact?.email,
@@ -1285,39 +1607,63 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1285
1607
  }) : null
1286
1608
  ]
1287
1609
  }),
1288
- session ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
1610
+ topics.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SupportTopics, {
1611
+ disabled: !canSend,
1612
+ onSelect: (topic) => {
1613
+ sendVisitorMessage(topic.label, topic);
1614
+ },
1615
+ topics
1616
+ }) : null,
1617
+ showComposer ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
1289
1618
  className: "clivly-chat-widget__composer",
1290
1619
  onSubmit: submitMessage,
1291
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1292
- className: "clivly-chat-widget__field",
1293
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1294
- className: "clivly-chat-widget__label",
1295
- children: "Message"
1296
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
1297
- className: "clivly-chat-widget__textarea",
1298
- onChange: (event) => {
1299
- setMessageDraft(event.target.value);
1300
- },
1301
- onKeyDown: (event) => {
1302
- if (!isSendKey(event)) return;
1303
- event.preventDefault();
1304
- if (messageDraft.trim() && canSend) submitMessage();
1305
- },
1306
- placeholder: composerPlaceholder(topics.length > 0),
1307
- value: messageDraft
1620
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1621
+ className: "clivly-chat-widget__composer-row",
1622
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1623
+ className: "clivly-chat-widget__field clivly-chat-widget__composer-field",
1624
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1625
+ className: "clivly-chat-widget__label clivly-chat-widget__visually-hidden",
1626
+ children: "Message"
1627
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
1628
+ className: "clivly-chat-widget__textarea",
1629
+ onChange: (event) => {
1630
+ setMessageDraft(event.target.value);
1631
+ },
1632
+ onKeyDown: (event) => {
1633
+ if (!isSendKey(event)) return;
1634
+ event.preventDefault();
1635
+ if (messageDraft.trim() && canSend) submitMessage();
1636
+ },
1637
+ placeholder: composerPlaceholder(topics.length > 0),
1638
+ ref: composerRef,
1639
+ rows: 1,
1640
+ value: messageDraft
1641
+ })]
1642
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1643
+ "aria-label": "Send",
1644
+ className: "clivly-chat-widget__button clivly-chat-widget__send",
1645
+ disabled: !(messageDraft.trim() && canSend),
1646
+ type: "submit",
1647
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1648
+ "aria-hidden": "true",
1649
+ fill: "none",
1650
+ focusable: "false",
1651
+ stroke: "currentColor",
1652
+ strokeLinecap: "round",
1653
+ strokeLinejoin: "round",
1654
+ strokeWidth: 2,
1655
+ viewBox: "0 0 24 24",
1656
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 19V5M5 12l7-7 7 7" })
1657
+ })
1308
1658
  })]
1309
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1659
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1310
1660
  className: "clivly-chat-widget__actions",
1311
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1661
+ "data-ready": canSend || void 0,
1662
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1312
1663
  "aria-live": "polite",
1313
1664
  className: "clivly-chat-widget__subtitle",
1314
1665
  children: composerStatusText(Boolean(createTransport), isReady, reconnecting)
1315
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1316
- className: "clivly-chat-widget__button",
1317
- disabled: !(messageDraft.trim() && canSend),
1318
- type: "submit",
1319
- children: "Send"
1320
- })]
1666
+ })
1321
1667
  })]
1322
1668
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
1323
1669
  className: "clivly-chat-widget__form",