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