@burdenoff/website-sdk 2026.828.4 → 2026.828.6

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
@@ -1,7 +1,7 @@
1
1
  import * as React from 'react';
2
2
  import { createContext, useMemo, useContext, useState, useCallback, useLayoutEffect, useRef, useEffect } from 'react';
3
3
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
- import { Instagram, Youtube, Facebook, Github, Linkedin, Twitter, Mail, Phone, MapPin, Clock, Send, CheckCircle, Upload, Globe, ChevronDown, Search, Rocket, ArrowRight, Download, Sparkles, RotateCcw, Square, ArrowUp, History, BookOpenText, Layers, Link2, TriangleAlert, ChevronLeft, ChevronRight, FileText, ExternalLink } from 'lucide-react';
4
+ import { Instagram, Youtube, Facebook, Github, Linkedin, Twitter, Mail, Phone, MapPin, Clock, Send, CheckCircle, Upload, Globe, ChevronDown, Search, Rocket, ArrowRight, Download, Sparkles, RotateCcw, History, Trash2, Mic, Square, ArrowUp, BookOpenText, Layers, Link2, TriangleAlert, ChevronLeft, ChevronRight, FileText, ExternalLink } from 'lucide-react';
5
5
  import ReCAPTCHA4 from 'react-google-recaptcha';
6
6
  import { toast } from 'sonner';
7
7
  import { Helmet } from 'react-helmet-async';
@@ -5246,6 +5246,8 @@ async function getRecaptchaV3Token(siteKey, action = EXPLORE_RECAPTCHA_ACTION) {
5246
5246
 
5247
5247
  // src/hooks/use-explore-chat.ts
5248
5248
  var DEFAULT_STORAGE_KEY = "boff.explore.v1";
5249
+ var HISTORY_SUFFIX = ".history";
5250
+ var MAX_ARCHIVED_CONVERSATIONS = 15;
5249
5251
  var DEFAULT_POLL_INTERVAL_MS = 1500;
5250
5252
  var DEFAULT_POLL_TIMEOUT_MS = 9e4;
5251
5253
  var POLL_REQUEST_TIMEOUT_MS = 1e4;
@@ -5384,10 +5386,58 @@ function classifyExploreError(code, serverMessage) {
5384
5386
  retryable: true
5385
5387
  };
5386
5388
  }
5389
+ function historyKey(key) {
5390
+ return `${key}${HISTORY_SUFFIX}`;
5391
+ }
5392
+ function readArchive(key) {
5393
+ if (typeof window === "undefined") return [];
5394
+ try {
5395
+ const raw = window.localStorage.getItem(historyKey(key));
5396
+ if (!raw) return [];
5397
+ const parsed = JSON.parse(raw);
5398
+ if (!Array.isArray(parsed)) return [];
5399
+ return parsed.filter(
5400
+ (entry) => typeof entry === "object" && entry !== null && typeof entry.token === "string" && Array.isArray(entry.messages)
5401
+ );
5402
+ } catch {
5403
+ return [];
5404
+ }
5405
+ }
5406
+ function writeArchive(key, entries) {
5407
+ if (typeof window === "undefined") return;
5408
+ try {
5409
+ if (entries.length === 0) {
5410
+ window.localStorage.removeItem(historyKey(key));
5411
+ return;
5412
+ }
5413
+ window.localStorage.setItem(historyKey(key), JSON.stringify(entries));
5414
+ } catch {
5415
+ }
5416
+ }
5417
+ function upsertArchive(key, thread) {
5418
+ if (!thread.token) return readArchive(key);
5419
+ const real = thread.messages.filter(
5420
+ (m) => m.content.trim() !== "" || m.role === "USER"
5421
+ );
5422
+ if (real.length === 0) return readArchive(key);
5423
+ const firstUser = real.find((m) => m.role === "USER");
5424
+ const entry = {
5425
+ token: thread.token,
5426
+ title: (firstUser?.content ?? "Conversation").trim().slice(0, 80),
5427
+ updatedAt: real[real.length - 1]?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
5428
+ messageCount: real.length,
5429
+ focusProduct: thread.focusProduct,
5430
+ messages: real.slice(-20)
5431
+ };
5432
+ const rest = readArchive(key).filter((e) => e.token !== entry.token);
5433
+ const next = [entry, ...rest].slice(0, MAX_ARCHIVED_CONVERSATIONS);
5434
+ writeArchive(key, next);
5435
+ return next;
5436
+ }
5387
5437
  function readPersisted(key) {
5388
5438
  if (typeof window === "undefined") return null;
5389
5439
  try {
5390
- const raw = window.sessionStorage.getItem(key);
5440
+ const raw = window.localStorage.getItem(key);
5391
5441
  if (!raw) return null;
5392
5442
  const parsed = JSON.parse(raw);
5393
5443
  if (!parsed || typeof parsed !== "object") return null;
@@ -5432,6 +5482,8 @@ function useExploreChat(options = {}) {
5432
5482
  const [remainingToday, setRemainingToday] = useState(null);
5433
5483
  const [focusProduct, setFocusProductState] = useState(null);
5434
5484
  const [turnCount, setTurnCount] = useState(0);
5485
+ const messagesRef = useRef([]);
5486
+ const [archive, setArchive] = useState([]);
5435
5487
  const [hydrated, setHydrated] = useState(false);
5436
5488
  const mountedRef = useRef(true);
5437
5489
  const pollGenerationRef = useRef(0);
@@ -5469,6 +5521,7 @@ function useExploreChat(options = {}) {
5469
5521
  setHydrated(true);
5470
5522
  return;
5471
5523
  }
5524
+ setArchive(readArchive(storageKey));
5472
5525
  const stored = readPersisted(storageKey);
5473
5526
  if (stored) {
5474
5527
  conversationTokenRef.current = stored.conversationToken;
@@ -5497,13 +5550,28 @@ function useExploreChat(options = {}) {
5497
5550
  focusProduct
5498
5551
  };
5499
5552
  if (!payload.conversationToken && payload.messages.length === 0 && !payload.focusProduct) {
5500
- window.sessionStorage.removeItem(storageKey);
5553
+ window.localStorage.removeItem(storageKey);
5501
5554
  return;
5502
5555
  }
5503
- window.sessionStorage.setItem(storageKey, JSON.stringify(payload));
5556
+ window.localStorage.setItem(storageKey, JSON.stringify(payload));
5504
5557
  } catch {
5505
5558
  }
5506
5559
  }, [persist, hydrated, storageKey, messages, focusProduct]);
5560
+ useEffect(() => {
5561
+ messagesRef.current = messages;
5562
+ if (!persist || !hydrated) return;
5563
+ const settled = messages.some(
5564
+ (m) => m.role === "ASSISTANT" && (m.status === "COMPLETED" || m.status === "FAILED")
5565
+ );
5566
+ if (!settled) return;
5567
+ setArchive(
5568
+ upsertArchive(storageKey, {
5569
+ token: conversationTokenRef.current,
5570
+ messages,
5571
+ focusProduct: focusProductRef.current
5572
+ })
5573
+ );
5574
+ }, [messages, persist, hydrated, storageKey]);
5507
5575
  useEffect(() => {
5508
5576
  let cancelled = false;
5509
5577
  const load = async () => {
@@ -5789,6 +5857,13 @@ function useExploreChat(options = {}) {
5789
5857
  void send(text);
5790
5858
  }, [cancelPolling, send]);
5791
5859
  const reset = useCallback(() => {
5860
+ setArchive(
5861
+ upsertArchive(storageKey, {
5862
+ token: conversationTokenRef.current,
5863
+ messages: messagesRef.current,
5864
+ focusProduct: focusProductRef.current
5865
+ })
5866
+ );
5792
5867
  cancelPolling();
5793
5868
  inFlightRef.current = false;
5794
5869
  conversationTokenRef.current = null;
@@ -5801,11 +5876,48 @@ function useExploreChat(options = {}) {
5801
5876
  setPhase(catalogRef.current.enabled ? "ready" : "disabled");
5802
5877
  if (typeof window !== "undefined") {
5803
5878
  try {
5804
- window.sessionStorage.removeItem(storageKey);
5879
+ window.localStorage.removeItem(storageKey);
5805
5880
  } catch {
5806
5881
  }
5807
5882
  }
5808
5883
  }, [cancelPolling, storageKey]);
5884
+ const openConversation = useCallback(
5885
+ (token) => {
5886
+ const entry = readArchive(storageKey).find((e) => e.token === token);
5887
+ if (!entry) return;
5888
+ upsertArchive(storageKey, {
5889
+ token: conversationTokenRef.current,
5890
+ messages: messagesRef.current,
5891
+ focusProduct: focusProductRef.current
5892
+ });
5893
+ cancelPolling();
5894
+ inFlightRef.current = false;
5895
+ conversationTokenRef.current = entry.token;
5896
+ turnCountRef.current = entry.messages.filter(
5897
+ (m) => m.role === "USER"
5898
+ ).length;
5899
+ setTurnCount(turnCountRef.current);
5900
+ setMessages(entry.messages);
5901
+ focusProductRef.current = entry.focusProduct;
5902
+ setFocusProductState(entry.focusProduct);
5903
+ setError(null);
5904
+ setPhase(catalogRef.current.enabled ? "ready" : "disabled");
5905
+ setArchive(readArchive(storageKey));
5906
+ },
5907
+ [cancelPolling, storageKey]
5908
+ );
5909
+ const deleteConversation = useCallback(
5910
+ (token) => {
5911
+ const next = readArchive(storageKey).filter((e) => e.token !== token);
5912
+ writeArchive(storageKey, next);
5913
+ setArchive(next);
5914
+ },
5915
+ [storageKey]
5916
+ );
5917
+ const clearHistory = useCallback(() => {
5918
+ writeArchive(storageKey, []);
5919
+ setArchive([]);
5920
+ }, [storageKey]);
5809
5921
  const setFocusProduct = useCallback((slug) => {
5810
5922
  focusProductRef.current = slug;
5811
5923
  setFocusProductState(slug);
@@ -5834,7 +5946,11 @@ function useExploreChat(options = {}) {
5834
5946
  stop,
5835
5947
  retry,
5836
5948
  reset,
5837
- canSend
5949
+ canSend,
5950
+ history: archive,
5951
+ openConversation,
5952
+ deleteConversation,
5953
+ clearHistory
5838
5954
  };
5839
5955
  }
5840
5956
  var optimisticCounter = 0;
@@ -5842,6 +5958,182 @@ function makeOptimisticId() {
5842
5958
  optimisticCounter += 1;
5843
5959
  return `boff-explore-local-${Date.now()}-${optimisticCounter}`;
5844
5960
  }
5961
+ function getRecognitionCtor() {
5962
+ if (typeof window === "undefined") return null;
5963
+ const w = window;
5964
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
5965
+ }
5966
+ var MIC_DENIED_MESSAGE = "Microphone access was blocked. Allow it in your browser's site settings to dictate.";
5967
+ async function ensureMicrophoneAccess() {
5968
+ const media = typeof navigator === "undefined" ? void 0 : navigator.mediaDevices;
5969
+ if (!media?.getUserMedia) return { ok: true };
5970
+ try {
5971
+ const status = await navigator.permissions?.query({
5972
+ name: "microphone"
5973
+ });
5974
+ if (status?.state === "granted") return { ok: true };
5975
+ if (status?.state === "denied")
5976
+ return { ok: false, message: MIC_DENIED_MESSAGE };
5977
+ } catch {
5978
+ }
5979
+ try {
5980
+ const stream = await media.getUserMedia({ audio: true });
5981
+ for (const track of stream.getTracks()) track.stop();
5982
+ return { ok: true };
5983
+ } catch (error) {
5984
+ const name = typeof error === "object" && error !== null && "name" in error ? String(error.name) : "";
5985
+ if (name === "NotAllowedError" || name === "SecurityError") {
5986
+ return { ok: false, message: MIC_DENIED_MESSAGE };
5987
+ }
5988
+ if (name === "NotFoundError" || name === "DevicesNotFoundError") {
5989
+ return { ok: false, message: "No microphone was found." };
5990
+ }
5991
+ return {
5992
+ ok: false,
5993
+ message: "Dictation could not start. You can type instead."
5994
+ };
5995
+ }
5996
+ }
5997
+ function describeError(code) {
5998
+ switch (code) {
5999
+ case "not-allowed":
6000
+ case "service-not-allowed":
6001
+ return MIC_DENIED_MESSAGE;
6002
+ case "no-speech":
6003
+ return "I didn't catch anything \u2014 try again a little closer to the mic.";
6004
+ case "audio-capture":
6005
+ return "No microphone was found.";
6006
+ case "network":
6007
+ return "Speech recognition needs a network connection.";
6008
+ case "aborted":
6009
+ return "";
6010
+ default:
6011
+ return "Dictation stopped unexpectedly. You can type instead.";
6012
+ }
6013
+ }
6014
+ function useSpeechInput({
6015
+ onFinalTranscript,
6016
+ lang,
6017
+ onError
6018
+ }) {
6019
+ const [supported] = useState(() => getRecognitionCtor() !== null);
6020
+ const [listening, setListening] = useState(false);
6021
+ const [interim, setInterim] = useState("");
6022
+ const [error, setError] = useState(null);
6023
+ const recognitionRef = useRef(null);
6024
+ const startTokenRef = useRef(0);
6025
+ const beginRecognitionRef = useRef(null);
6026
+ const beginRecognition = useCallback(
6027
+ (Ctor, token) => {
6028
+ beginRecognitionRef.current?.(Ctor, token);
6029
+ },
6030
+ []
6031
+ );
6032
+ const finalRef = useRef(onFinalTranscript);
6033
+ const errorRef = useRef(onError);
6034
+ finalRef.current = onFinalTranscript;
6035
+ errorRef.current = onError;
6036
+ const stop = useCallback(() => {
6037
+ startTokenRef.current += 1;
6038
+ setListening(false);
6039
+ setInterim("");
6040
+ const recognition = recognitionRef.current;
6041
+ if (!recognition) return;
6042
+ try {
6043
+ recognition.stop();
6044
+ } catch {
6045
+ }
6046
+ setListening(false);
6047
+ setInterim("");
6048
+ }, []);
6049
+ const start = useCallback(() => {
6050
+ const Ctor = getRecognitionCtor();
6051
+ if (!Ctor) return;
6052
+ if (recognitionRef.current) stop();
6053
+ setError(null);
6054
+ setListening(true);
6055
+ startTokenRef.current += 1;
6056
+ const token = startTokenRef.current;
6057
+ void ensureMicrophoneAccess().then((access) => {
6058
+ if (token !== startTokenRef.current) return;
6059
+ if (!access.ok) {
6060
+ setListening(false);
6061
+ setError(access.message);
6062
+ errorRef.current?.(access.message);
6063
+ return;
6064
+ }
6065
+ beginRecognition(Ctor, token);
6066
+ });
6067
+ }, [beginRecognition, stop]);
6068
+ const beginRecognitionImpl = useCallback(
6069
+ (Ctor, token) => {
6070
+ const recognition = new Ctor();
6071
+ recognition.lang = lang ?? (typeof navigator === "undefined" ? "en-US" : navigator.language || "en-US");
6072
+ recognition.continuous = true;
6073
+ recognition.interimResults = true;
6074
+ recognition.maxAlternatives = 1;
6075
+ recognition.onstart = () => {
6076
+ if (token !== startTokenRef.current) return;
6077
+ setError(null);
6078
+ setListening(true);
6079
+ };
6080
+ recognition.onresult = (event) => {
6081
+ let settled = "";
6082
+ let pending = "";
6083
+ for (let i = event.resultIndex; i < event.results.length; i += 1) {
6084
+ const result = event.results[i];
6085
+ if (!result) continue;
6086
+ const text = result[0]?.transcript ?? "";
6087
+ if (result.isFinal) settled += text;
6088
+ else pending += text;
6089
+ }
6090
+ setInterim(pending);
6091
+ if (settled.trim() !== "") finalRef.current(settled);
6092
+ };
6093
+ recognition.onerror = (event) => {
6094
+ const message = describeError(event.error);
6095
+ setListening(false);
6096
+ setInterim("");
6097
+ if (message !== "") {
6098
+ setError(message);
6099
+ errorRef.current?.(message);
6100
+ }
6101
+ };
6102
+ recognition.onend = () => {
6103
+ setListening(false);
6104
+ setInterim("");
6105
+ };
6106
+ recognitionRef.current = recognition;
6107
+ try {
6108
+ recognition.start();
6109
+ } catch {
6110
+ setListening(false);
6111
+ }
6112
+ },
6113
+ [lang]
6114
+ );
6115
+ beginRecognitionRef.current = beginRecognitionImpl;
6116
+ const toggle = useCallback(() => {
6117
+ if (listening) stop();
6118
+ else start();
6119
+ }, [listening, start, stop]);
6120
+ useEffect(
6121
+ () => () => {
6122
+ const recognition = recognitionRef.current;
6123
+ if (!recognition) return;
6124
+ recognition.onresult = null;
6125
+ recognition.onerror = null;
6126
+ recognition.onend = null;
6127
+ recognition.onstart = null;
6128
+ try {
6129
+ recognition.abort();
6130
+ } catch {
6131
+ }
6132
+ },
6133
+ []
6134
+ );
6135
+ return { supported, listening, interim, error, start, stop, toggle };
6136
+ }
5845
6137
  var EXPLORE_CSS = `
5846
6138
  @keyframes boff-explore-pulse {
5847
6139
  0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
@@ -5936,10 +6228,33 @@ function initialOf(value, fallback) {
5936
6228
  const source = (value || fallback).trim();
5937
6229
  return source ? source.slice(0, 1).toUpperCase() : "?";
5938
6230
  }
6231
+ function logoUrlOf(url) {
6232
+ try {
6233
+ const { origin, protocol } = new URL(url);
6234
+ if (protocol !== "https:" && protocol !== "http:") return null;
6235
+ return `${origin}/favicon.svg`;
6236
+ } catch {
6237
+ return null;
6238
+ }
6239
+ }
5939
6240
  function ReferenceCard({ reference }) {
5940
6241
  const [imageFailed, setImageFailed] = useState(false);
6242
+ const [logoFailed, setLogoFailed] = useState(false);
5941
6243
  const host = hostnameOf(reference.url);
6244
+ const logoUrl = logoUrlOf(reference.url);
5942
6245
  const showImage = Boolean(reference.imageUrl) && !imageFailed;
6246
+ const showLogo = Boolean(logoUrl) && !logoFailed;
6247
+ const letterPlate = /* @__PURE__ */ jsx("div", { className: "flex h-full w-full items-center justify-center bg-gradient-to-br from-primary/25 via-primary/5 to-secondary", children: /* @__PURE__ */ jsx("span", { className: "text-sm font-semibold text-primary/70 sm:text-2xl", children: initialOf(reference.product, reference.title || host) }) });
6248
+ const logoImg = showLogo ? /* @__PURE__ */ jsx(
6249
+ "img",
6250
+ {
6251
+ src: logoUrl ?? "",
6252
+ alt: "",
6253
+ loading: "lazy",
6254
+ onError: () => setLogoFailed(true),
6255
+ className: "h-full w-full object-contain p-1.5 sm:p-0"
6256
+ }
6257
+ ) : letterPlate;
5943
6258
  return /* @__PURE__ */ jsxs(
5944
6259
  "a",
5945
6260
  {
@@ -5947,9 +6262,10 @@ function ReferenceCard({ reference }) {
5947
6262
  href: reference.url,
5948
6263
  target: "_blank",
5949
6264
  rel: "noopener noreferrer",
5950
- className: "group flex flex-col overflow-hidden rounded-xl border border-border bg-card transition-colors hover:border-primary/40 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
6265
+ className: "group flex flex-row items-center gap-3 overflow-hidden rounded-xl border border-border bg-card p-2 transition-colors hover:border-primary/40 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring sm:flex-col sm:items-stretch sm:gap-0 sm:p-0",
5951
6266
  children: [
5952
- /* @__PURE__ */ jsx("div", { className: "relative aspect-[16/9] w-full overflow-hidden bg-muted", children: showImage ? /* @__PURE__ */ jsx(
6267
+ /* @__PURE__ */ jsx("div", { className: "relative h-11 w-11 shrink-0 overflow-hidden rounded-lg bg-muted sm:hidden", children: logoImg }),
6268
+ /* @__PURE__ */ jsx("div", { className: "relative hidden aspect-[16/9] w-full overflow-hidden bg-muted sm:block", children: showImage ? /* @__PURE__ */ jsx(
5953
6269
  "img",
5954
6270
  {
5955
6271
  src: reference.imageUrl ?? "",
@@ -5958,17 +6274,22 @@ function ReferenceCard({ reference }) {
5958
6274
  onError: () => setImageFailed(true),
5959
6275
  className: "h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
5960
6276
  }
5961
- ) : (
5962
- /* Graceful fallback: a token-derived gradient plate, so a missing or
5963
- broken preview still reads as a deliberate card, not a hole. */
5964
- /* @__PURE__ */ jsx("div", { className: "flex h-full w-full items-center justify-center bg-gradient-to-br from-primary/25 via-primary/5 to-secondary", children: /* @__PURE__ */ jsx("span", { className: "text-2xl font-semibold text-primary/70", children: initialOf(reference.product, reference.title || host) }) })
5965
- ) }),
5966
- /* @__PURE__ */ jsxs("div", { className: "flex flex-1 flex-col gap-1.5 p-3", children: [
6277
+ ) : showLogo ? /* @__PURE__ */ jsx("div", { className: "flex h-full w-full items-center justify-center bg-gradient-to-br from-primary/15 via-transparent to-secondary/60 p-6", children: /* @__PURE__ */ jsx(
6278
+ "img",
6279
+ {
6280
+ src: logoUrl ?? "",
6281
+ alt: "",
6282
+ loading: "lazy",
6283
+ onError: () => setLogoFailed(true),
6284
+ className: "max-h-full max-w-full object-contain"
6285
+ }
6286
+ ) }) : letterPlate }),
6287
+ /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5 sm:gap-1.5 sm:p-3", children: [
5967
6288
  /* @__PURE__ */ jsx("p", { className: "line-clamp-2 text-sm font-semibold leading-snug text-card-foreground", children: reference.title || host || reference.url }),
5968
- reference.description ? /* @__PURE__ */ jsx("p", { className: "line-clamp-2 text-xs leading-relaxed text-muted-foreground", children: reference.description }) : null,
5969
- /* @__PURE__ */ jsxs("div", { className: "mt-auto flex items-center gap-2 pt-2", children: [
5970
- reference.product ? /* @__PURE__ */ jsx("span", { className: "rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground", children: reference.product }) : null,
5971
- /* @__PURE__ */ jsxs("span", { className: "ml-auto inline-flex min-w-0 items-center gap-1 text-xs text-muted-foreground", children: [
6289
+ reference.description ? /* @__PURE__ */ jsx("p", { className: "line-clamp-2 text-xs leading-relaxed text-muted-foreground max-sm:hidden", children: reference.description }) : null,
6290
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 sm:mt-auto sm:pt-2", children: [
6291
+ reference.product ? /* @__PURE__ */ jsx("span", { className: "rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground max-sm:hidden", children: reference.product }) : null,
6292
+ /* @__PURE__ */ jsxs("span", { className: "inline-flex min-w-0 items-center gap-1 text-xs text-muted-foreground sm:ml-auto", children: [
5972
6293
  /* @__PURE__ */ jsx(ExternalLink, { className: "h-3 w-3 shrink-0" }),
5973
6294
  /* @__PURE__ */ jsx("span", { className: "truncate", children: host })
5974
6295
  ] })
@@ -6054,7 +6375,8 @@ function UserBubble({ message }) {
6054
6375
  function AssistantBubble({
6055
6376
  message,
6056
6377
  activity,
6057
- onNavigate
6378
+ onNavigate,
6379
+ anchorRef
6058
6380
  }) {
6059
6381
  const streaming = message.status === "PENDING" || message.status === "RUNNING";
6060
6382
  const body = message.content || message.partialContent || "";
@@ -6062,9 +6384,10 @@ function AssistantBubble({
6062
6384
  return /* @__PURE__ */ jsxs(
6063
6385
  "div",
6064
6386
  {
6387
+ ref: anchorRef,
6065
6388
  "data-boff-explore": "assistant",
6066
6389
  "data-status": message.status,
6067
- className: "flex gap-3",
6390
+ className: "flex scroll-mt-4 gap-3",
6068
6391
  children: [
6069
6392
  /* @__PURE__ */ jsx(
6070
6393
  "span",
@@ -6269,11 +6592,32 @@ function ExplorePage({
6269
6592
  stop,
6270
6593
  retry,
6271
6594
  reset,
6272
- canSend
6595
+ canSend,
6596
+ history,
6597
+ openConversation,
6598
+ deleteConversation,
6599
+ clearHistory
6273
6600
  } = useExploreChat({ productSlug, pageUrl, onSend, examplePrompts });
6274
6601
  const [draft, setDraft] = useState("");
6275
6602
  const textareaRef = useRef(null);
6603
+ const [historyOpen, setHistoryOpen] = useState(false);
6604
+ const speechStopRef = useRef(() => void 0);
6605
+ const stopDictation = useCallback(() => {
6606
+ speechStopRef.current();
6607
+ }, []);
6608
+ const speech = useSpeechInput({
6609
+ onFinalTranscript: (text) => {
6610
+ setDraft((current) => {
6611
+ const joined = current.trim() === "" ? text.trimStart() : `${current.trimEnd()} ${text.trim()}`;
6612
+ return joined;
6613
+ });
6614
+ textareaRef.current?.focus();
6615
+ }
6616
+ });
6617
+ speechStopRef.current = speech.stop;
6276
6618
  const scrollRef = useRef(null);
6619
+ const latestAssistantRef = useRef(null);
6620
+ const alignedForRef = useRef(null);
6277
6621
  const stickToBottomRef = useRef(true);
6278
6622
  const composingRef = useRef(false);
6279
6623
  const busy = phase === "sending" || phase === "streaming";
@@ -6299,20 +6643,41 @@ function ExplorePage({
6299
6643
  if (!el) return;
6300
6644
  stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
6301
6645
  }, []);
6646
+ const latestAssistantId = useMemo(() => {
6647
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
6648
+ const message = messages[i];
6649
+ if (message && message.role === "ASSISTANT") return message.id;
6650
+ }
6651
+ return null;
6652
+ }, [messages]);
6302
6653
  useEffect(() => {
6654
+ const el = scrollRef.current;
6655
+ const anchor = latestAssistantRef.current;
6656
+ if (!el || !anchor || !latestAssistantId) return;
6657
+ if (alignedForRef.current === latestAssistantId) return;
6303
6658
  if (!stickToBottomRef.current) return;
6659
+ if (anchor.offsetHeight === 0) return;
6660
+ alignedForRef.current = latestAssistantId;
6661
+ const delta = anchor.getBoundingClientRect().top - el.getBoundingClientRect().top;
6662
+ el.scrollTop = Math.max(0, el.scrollTop + delta - 12);
6663
+ }, [latestAssistantId, messages]);
6664
+ useEffect(() => {
6665
+ if (!stickToBottomRef.current) return;
6666
+ if (latestAssistantId && alignedForRef.current === latestAssistantId)
6667
+ return;
6304
6668
  const el = scrollRef.current;
6305
6669
  if (!el) return;
6306
6670
  el.scrollTop = el.scrollHeight;
6307
- }, [messages, activity]);
6671
+ }, [activity, latestAssistantId, messages]);
6308
6672
  const submitDraft = useCallback(() => {
6309
6673
  const text = draft.trim();
6310
6674
  if (!text || overLimit || busy || !canSend) return;
6675
+ stopDictation();
6311
6676
  setDraft("");
6312
6677
  stickToBottomRef.current = true;
6313
6678
  void send(text);
6314
6679
  textareaRef.current?.focus();
6315
- }, [busy, canSend, draft, overLimit, send]);
6680
+ }, [busy, canSend, draft, overLimit, send, stopDictation]);
6316
6681
  const sendPrompt = useCallback(
6317
6682
  (prompt) => {
6318
6683
  if (!canSend || busy) return;
@@ -6385,8 +6750,96 @@ function ExplorePage({
6385
6750
  /* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: "New chat" })
6386
6751
  ]
6387
6752
  }
6388
- )
6753
+ ),
6754
+ history.length > 0 ? /* @__PURE__ */ jsxs(
6755
+ Button,
6756
+ {
6757
+ "data-boff-explore": "history-toggle",
6758
+ type: "button",
6759
+ size: "sm",
6760
+ variant: "ghost",
6761
+ className: "shrink-0 text-muted-foreground",
6762
+ "aria-expanded": historyOpen,
6763
+ onClick: () => {
6764
+ setHistoryOpen((open) => !open);
6765
+ },
6766
+ children: [
6767
+ /* @__PURE__ */ jsx(History, { className: "h-3.5 w-3.5" }),
6768
+ /* @__PURE__ */ jsxs("span", { className: "hidden sm:inline", children: [
6769
+ "History (",
6770
+ history.length,
6771
+ ")"
6772
+ ] })
6773
+ ]
6774
+ }
6775
+ ) : null
6389
6776
  ] }) : null,
6777
+ historyOpen && history.length > 0 ? /* @__PURE__ */ jsx(
6778
+ "div",
6779
+ {
6780
+ "data-boff-explore": "history-panel",
6781
+ className: "border-b border-border bg-muted/30 px-4 py-3",
6782
+ children: /* @__PURE__ */ jsxs("div", { className: "mx-auto w-full max-w-3xl", children: [
6783
+ /* @__PURE__ */ jsxs("div", { className: "mb-2 flex items-center justify-between gap-2", children: [
6784
+ /* @__PURE__ */ jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-muted-foreground", children: "Your recent conversations" }),
6785
+ /* @__PURE__ */ jsxs(
6786
+ Button,
6787
+ {
6788
+ "data-boff-explore": "history-clear",
6789
+ type: "button",
6790
+ size: "sm",
6791
+ variant: "ghost",
6792
+ className: "h-7 shrink-0 text-xs text-muted-foreground hover:text-destructive",
6793
+ onClick: () => {
6794
+ clearHistory();
6795
+ setHistoryOpen(false);
6796
+ },
6797
+ children: [
6798
+ /* @__PURE__ */ jsx(Trash2, { className: "h-3.5 w-3.5" }),
6799
+ "Clear history"
6800
+ ]
6801
+ }
6802
+ )
6803
+ ] }),
6804
+ /* @__PURE__ */ jsx("ul", { className: "space-y-1", children: history.map((entry) => /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-1", children: [
6805
+ /* @__PURE__ */ jsxs(
6806
+ "button",
6807
+ {
6808
+ "data-boff-explore": "history-item",
6809
+ type: "button",
6810
+ className: "flex-1 truncate rounded-md px-2 py-1.5 text-left text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
6811
+ onClick: () => {
6812
+ openConversation(entry.token);
6813
+ setHistoryOpen(false);
6814
+ },
6815
+ children: [
6816
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: entry.title }),
6817
+ /* @__PURE__ */ jsxs("span", { className: "ml-2 text-xs text-muted-foreground", children: [
6818
+ entry.messageCount,
6819
+ " message",
6820
+ entry.messageCount === 1 ? "" : "s"
6821
+ ] })
6822
+ ]
6823
+ }
6824
+ ),
6825
+ /* @__PURE__ */ jsx(
6826
+ Button,
6827
+ {
6828
+ type: "button",
6829
+ size: "icon",
6830
+ variant: "ghost",
6831
+ className: "h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive",
6832
+ "aria-label": `Delete conversation: ${entry.title}`,
6833
+ onClick: () => {
6834
+ deleteConversation(entry.token);
6835
+ },
6836
+ children: /* @__PURE__ */ jsx(Trash2, { className: "h-3.5 w-3.5" })
6837
+ }
6838
+ )
6839
+ ] }, entry.token)) })
6840
+ ] })
6841
+ }
6842
+ ) : null,
6390
6843
  /* @__PURE__ */ jsx(
6391
6844
  "div",
6392
6845
  {
@@ -6415,7 +6868,8 @@ function ExplorePage({
6415
6868
  {
6416
6869
  message,
6417
6870
  activity,
6418
- onNavigate
6871
+ onNavigate,
6872
+ anchorRef: message.id === latestAssistantId ? latestAssistantRef : void 0
6419
6873
  },
6420
6874
  message.id
6421
6875
  )
@@ -6461,6 +6915,23 @@ function ExplorePage({
6461
6915
  className: "max-h-[200px] min-h-[44px] flex-1 resize-none border-0 bg-transparent px-2 py-2.5 text-sm shadow-none focus-visible:ring-0 md:text-sm"
6462
6916
  }
6463
6917
  ),
6918
+ speech.supported ? /* @__PURE__ */ jsx(
6919
+ Button,
6920
+ {
6921
+ "data-boff-explore": "mic",
6922
+ "data-listening": speech.listening ? "true" : "false",
6923
+ type: "button",
6924
+ size: "icon",
6925
+ variant: speech.listening ? "default" : "ghost",
6926
+ disabled: composerDisabled,
6927
+ "aria-label": speech.listening ? "Stop dictating" : "Dictate your question",
6928
+ "aria-pressed": speech.listening,
6929
+ title: speech.listening ? "Stop dictating" : "Dictate your question",
6930
+ onClick: speech.toggle,
6931
+ className: cn(speech.listening && "animate-pulse"),
6932
+ children: /* @__PURE__ */ jsx(Mic, { className: "h-4 w-4" })
6933
+ }
6934
+ ) : null,
6464
6935
  busy ? /* @__PURE__ */ jsx(
6465
6936
  Button,
6466
6937
  {
@@ -6486,6 +6957,29 @@ function ExplorePage({
6486
6957
  ]
6487
6958
  }
6488
6959
  ),
6960
+ speech.listening || speech.interim !== "" ? /* @__PURE__ */ jsxs(
6961
+ "p",
6962
+ {
6963
+ "data-boff-explore": "dictation",
6964
+ className: "mt-2 flex items-center gap-2 text-xs text-muted-foreground",
6965
+ "aria-live": "polite",
6966
+ children: [
6967
+ /* @__PURE__ */ jsxs("span", { className: "relative flex h-2 w-2 shrink-0", children: [
6968
+ /* @__PURE__ */ jsx("span", { className: "absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" }),
6969
+ /* @__PURE__ */ jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-primary" })
6970
+ ] }),
6971
+ /* @__PURE__ */ jsx("span", { className: "italic", children: speech.interim !== "" ? speech.interim : "Listening\u2026" })
6972
+ ]
6973
+ }
6974
+ ) : null,
6975
+ speech.error !== null ? /* @__PURE__ */ jsx(
6976
+ "p",
6977
+ {
6978
+ "data-boff-explore": "dictation-error",
6979
+ className: "mt-2 text-xs text-destructive",
6980
+ children: speech.error
6981
+ }
6982
+ ) : null,
6489
6983
  /* @__PURE__ */ jsxs("div", { className: "mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground", children: [
6490
6984
  /* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: "Enter to send \xB7 Shift + Enter for a new line" }),
6491
6985
  remainingToday !== null ? /* @__PURE__ */ jsxs("span", { "data-boff-explore": "remaining", children: [
@@ -6509,39 +7003,48 @@ function ExplorePage({
6509
7003
  }
6510
7004
  )
6511
7005
  ] }),
6512
- /* @__PURE__ */ jsxs("p", { className: "mt-2 text-xs leading-relaxed text-muted-foreground", children: [
6513
- "Answers are generated from our public documentation and can be imperfect \u2014 check anything important against the linked sources.",
6514
- captchaOn ? /* @__PURE__ */ jsxs(Fragment, { children: [
6515
- " ",
6516
- "This site is protected by reCAPTCHA; the Google",
6517
- " ",
6518
- /* @__PURE__ */ jsx(
6519
- "a",
6520
- {
6521
- href: "https://policies.google.com/privacy",
6522
- target: "_blank",
6523
- rel: "noopener noreferrer",
6524
- className: "underline underline-offset-2 hover:text-foreground",
6525
- children: "Privacy Policy"
6526
- }
6527
- ),
6528
- " ",
6529
- "and",
6530
- " ",
6531
- /* @__PURE__ */ jsx(
6532
- "a",
6533
- {
6534
- href: "https://policies.google.com/terms",
6535
- target: "_blank",
6536
- rel: "noopener noreferrer",
6537
- className: "underline underline-offset-2 hover:text-foreground",
6538
- children: "Terms of Service"
6539
- }
6540
- ),
6541
- " ",
6542
- "apply."
6543
- ] }) : null
6544
- ] })
7006
+ /* @__PURE__ */ jsxs(
7007
+ "p",
7008
+ {
7009
+ "data-boff-explore": "disclaimer",
7010
+ className: "mt-2 text-xs leading-relaxed text-muted-foreground",
7011
+ children: [
7012
+ "Answers are generated from our public documentation and can be imperfect \u2014 check anything important against the linked sources.",
7013
+ " ",
7014
+ "Conversations are saved in this browser so you can come back to them, and are also stored on our servers to help us improve these answers. Please don't share personal or confidential information.",
7015
+ captchaOn ? /* @__PURE__ */ jsxs(Fragment, { children: [
7016
+ " ",
7017
+ "This site is protected by reCAPTCHA; the Google",
7018
+ " ",
7019
+ /* @__PURE__ */ jsx(
7020
+ "a",
7021
+ {
7022
+ href: "https://policies.google.com/privacy",
7023
+ target: "_blank",
7024
+ rel: "noopener noreferrer",
7025
+ className: "underline underline-offset-2 hover:text-foreground",
7026
+ children: "Privacy Policy"
7027
+ }
7028
+ ),
7029
+ " ",
7030
+ "and",
7031
+ " ",
7032
+ /* @__PURE__ */ jsx(
7033
+ "a",
7034
+ {
7035
+ href: "https://policies.google.com/terms",
7036
+ target: "_blank",
7037
+ rel: "noopener noreferrer",
7038
+ className: "underline underline-offset-2 hover:text-foreground",
7039
+ children: "Terms of Service"
7040
+ }
7041
+ ),
7042
+ " ",
7043
+ "apply."
7044
+ ] }) : null
7045
+ ]
7046
+ }
7047
+ )
6545
7048
  ]
6546
7049
  }
6547
7050
  ) })
@@ -6591,11 +7094,12 @@ function ExploreCta({
6591
7094
  onNavigate(href);
6592
7095
  };
6593
7096
  const classes = {
6594
- // `shrink-0` matters: this sits in a host header's flex row, and without it the
6595
- // surrounding nav gets squeezed and its links wrap onto two lines. The label is hidden
6596
- // below 2xl for the same reason most product headers are already close to full at
6597
- // 1440px, and a 90px pill there pushes them over.
6598
- header: "inline-flex shrink-0 items-center gap-1.5 rounded-full bg-gradient-to-r from-primary to-primary/70 px-2.5 py-1.5 text-sm font-medium text-primary-foreground shadow-sm transition-opacity hover:opacity-90 2xl:px-3.5",
7097
+ // A quiet icon control, deliberately NOT a second gradient pill: a header should carry one
7098
+ // primary CTA ("Get started"), and a competing coloured button next to it reads as clutter.
7099
+ // This matches the theme toggle's visual weight, so the row scans as [utilities] [CTA].
7100
+ // The prominent, labelled entry point to /explore is the floating bubble, which is visible
7101
+ // on every page without scrolling. `shrink-0` keeps it from squeezing the nav.
7102
+ header: "inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
6599
7103
  mobile: "inline-flex w-full items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm font-medium text-card-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
6600
7104
  floating: "fixed bottom-5 right-5 z-40 inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-primary to-primary/70 px-4 py-3 text-sm font-medium text-primary-foreground shadow-lg transition-opacity hover:opacity-90"
6601
7105
  };
@@ -6623,7 +7127,7 @@ function ExploreCta({
6623
7127
  {
6624
7128
  className: cn(
6625
7129
  "shrink-0",
6626
- variant === "floating" ? "h-4 w-4" : "h-3.5 w-3.5"
7130
+ variant === "floating" ? "h-4 w-4" : variant === "header" ? "h-5 w-5" : "h-3.5 w-3.5"
6627
7131
  ),
6628
7132
  "aria-hidden": "true"
6629
7133
  }
@@ -6633,7 +7137,8 @@ function ExploreCta({
6633
7137
  {
6634
7138
  className: cn(
6635
7139
  variant === "floating" && "hidden sm:inline",
6636
- variant === "header" && "hidden 2xl:inline"
7140
+ // Never labelled in the header see the class comment above.
7141
+ variant === "header" && "hidden"
6637
7142
  ),
6638
7143
  children: label
6639
7144
  }