@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.js CHANGED
@@ -5276,6 +5276,8 @@ async function getRecaptchaV3Token(siteKey, action = EXPLORE_RECAPTCHA_ACTION) {
5276
5276
 
5277
5277
  // src/hooks/use-explore-chat.ts
5278
5278
  var DEFAULT_STORAGE_KEY = "boff.explore.v1";
5279
+ var HISTORY_SUFFIX = ".history";
5280
+ var MAX_ARCHIVED_CONVERSATIONS = 15;
5279
5281
  var DEFAULT_POLL_INTERVAL_MS = 1500;
5280
5282
  var DEFAULT_POLL_TIMEOUT_MS = 9e4;
5281
5283
  var POLL_REQUEST_TIMEOUT_MS = 1e4;
@@ -5414,10 +5416,58 @@ function classifyExploreError(code, serverMessage) {
5414
5416
  retryable: true
5415
5417
  };
5416
5418
  }
5419
+ function historyKey(key) {
5420
+ return `${key}${HISTORY_SUFFIX}`;
5421
+ }
5422
+ function readArchive(key) {
5423
+ if (typeof window === "undefined") return [];
5424
+ try {
5425
+ const raw = window.localStorage.getItem(historyKey(key));
5426
+ if (!raw) return [];
5427
+ const parsed = JSON.parse(raw);
5428
+ if (!Array.isArray(parsed)) return [];
5429
+ return parsed.filter(
5430
+ (entry) => typeof entry === "object" && entry !== null && typeof entry.token === "string" && Array.isArray(entry.messages)
5431
+ );
5432
+ } catch {
5433
+ return [];
5434
+ }
5435
+ }
5436
+ function writeArchive(key, entries) {
5437
+ if (typeof window === "undefined") return;
5438
+ try {
5439
+ if (entries.length === 0) {
5440
+ window.localStorage.removeItem(historyKey(key));
5441
+ return;
5442
+ }
5443
+ window.localStorage.setItem(historyKey(key), JSON.stringify(entries));
5444
+ } catch {
5445
+ }
5446
+ }
5447
+ function upsertArchive(key, thread) {
5448
+ if (!thread.token) return readArchive(key);
5449
+ const real = thread.messages.filter(
5450
+ (m) => m.content.trim() !== "" || m.role === "USER"
5451
+ );
5452
+ if (real.length === 0) return readArchive(key);
5453
+ const firstUser = real.find((m) => m.role === "USER");
5454
+ const entry = {
5455
+ token: thread.token,
5456
+ title: (firstUser?.content ?? "Conversation").trim().slice(0, 80),
5457
+ updatedAt: real[real.length - 1]?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
5458
+ messageCount: real.length,
5459
+ focusProduct: thread.focusProduct,
5460
+ messages: real.slice(-20)
5461
+ };
5462
+ const rest = readArchive(key).filter((e) => e.token !== entry.token);
5463
+ const next = [entry, ...rest].slice(0, MAX_ARCHIVED_CONVERSATIONS);
5464
+ writeArchive(key, next);
5465
+ return next;
5466
+ }
5417
5467
  function readPersisted(key) {
5418
5468
  if (typeof window === "undefined") return null;
5419
5469
  try {
5420
- const raw = window.sessionStorage.getItem(key);
5470
+ const raw = window.localStorage.getItem(key);
5421
5471
  if (!raw) return null;
5422
5472
  const parsed = JSON.parse(raw);
5423
5473
  if (!parsed || typeof parsed !== "object") return null;
@@ -5462,6 +5512,8 @@ function useExploreChat(options = {}) {
5462
5512
  const [remainingToday, setRemainingToday] = React.useState(null);
5463
5513
  const [focusProduct, setFocusProductState] = React.useState(null);
5464
5514
  const [turnCount, setTurnCount] = React.useState(0);
5515
+ const messagesRef = React.useRef([]);
5516
+ const [archive, setArchive] = React.useState([]);
5465
5517
  const [hydrated, setHydrated] = React.useState(false);
5466
5518
  const mountedRef = React.useRef(true);
5467
5519
  const pollGenerationRef = React.useRef(0);
@@ -5499,6 +5551,7 @@ function useExploreChat(options = {}) {
5499
5551
  setHydrated(true);
5500
5552
  return;
5501
5553
  }
5554
+ setArchive(readArchive(storageKey));
5502
5555
  const stored = readPersisted(storageKey);
5503
5556
  if (stored) {
5504
5557
  conversationTokenRef.current = stored.conversationToken;
@@ -5527,13 +5580,28 @@ function useExploreChat(options = {}) {
5527
5580
  focusProduct
5528
5581
  };
5529
5582
  if (!payload.conversationToken && payload.messages.length === 0 && !payload.focusProduct) {
5530
- window.sessionStorage.removeItem(storageKey);
5583
+ window.localStorage.removeItem(storageKey);
5531
5584
  return;
5532
5585
  }
5533
- window.sessionStorage.setItem(storageKey, JSON.stringify(payload));
5586
+ window.localStorage.setItem(storageKey, JSON.stringify(payload));
5534
5587
  } catch {
5535
5588
  }
5536
5589
  }, [persist, hydrated, storageKey, messages, focusProduct]);
5590
+ React.useEffect(() => {
5591
+ messagesRef.current = messages;
5592
+ if (!persist || !hydrated) return;
5593
+ const settled = messages.some(
5594
+ (m) => m.role === "ASSISTANT" && (m.status === "COMPLETED" || m.status === "FAILED")
5595
+ );
5596
+ if (!settled) return;
5597
+ setArchive(
5598
+ upsertArchive(storageKey, {
5599
+ token: conversationTokenRef.current,
5600
+ messages,
5601
+ focusProduct: focusProductRef.current
5602
+ })
5603
+ );
5604
+ }, [messages, persist, hydrated, storageKey]);
5537
5605
  React.useEffect(() => {
5538
5606
  let cancelled = false;
5539
5607
  const load = async () => {
@@ -5819,6 +5887,13 @@ function useExploreChat(options = {}) {
5819
5887
  void send(text);
5820
5888
  }, [cancelPolling, send]);
5821
5889
  const reset = React.useCallback(() => {
5890
+ setArchive(
5891
+ upsertArchive(storageKey, {
5892
+ token: conversationTokenRef.current,
5893
+ messages: messagesRef.current,
5894
+ focusProduct: focusProductRef.current
5895
+ })
5896
+ );
5822
5897
  cancelPolling();
5823
5898
  inFlightRef.current = false;
5824
5899
  conversationTokenRef.current = null;
@@ -5831,11 +5906,48 @@ function useExploreChat(options = {}) {
5831
5906
  setPhase(catalogRef.current.enabled ? "ready" : "disabled");
5832
5907
  if (typeof window !== "undefined") {
5833
5908
  try {
5834
- window.sessionStorage.removeItem(storageKey);
5909
+ window.localStorage.removeItem(storageKey);
5835
5910
  } catch {
5836
5911
  }
5837
5912
  }
5838
5913
  }, [cancelPolling, storageKey]);
5914
+ const openConversation = React.useCallback(
5915
+ (token) => {
5916
+ const entry = readArchive(storageKey).find((e) => e.token === token);
5917
+ if (!entry) return;
5918
+ upsertArchive(storageKey, {
5919
+ token: conversationTokenRef.current,
5920
+ messages: messagesRef.current,
5921
+ focusProduct: focusProductRef.current
5922
+ });
5923
+ cancelPolling();
5924
+ inFlightRef.current = false;
5925
+ conversationTokenRef.current = entry.token;
5926
+ turnCountRef.current = entry.messages.filter(
5927
+ (m) => m.role === "USER"
5928
+ ).length;
5929
+ setTurnCount(turnCountRef.current);
5930
+ setMessages(entry.messages);
5931
+ focusProductRef.current = entry.focusProduct;
5932
+ setFocusProductState(entry.focusProduct);
5933
+ setError(null);
5934
+ setPhase(catalogRef.current.enabled ? "ready" : "disabled");
5935
+ setArchive(readArchive(storageKey));
5936
+ },
5937
+ [cancelPolling, storageKey]
5938
+ );
5939
+ const deleteConversation = React.useCallback(
5940
+ (token) => {
5941
+ const next = readArchive(storageKey).filter((e) => e.token !== token);
5942
+ writeArchive(storageKey, next);
5943
+ setArchive(next);
5944
+ },
5945
+ [storageKey]
5946
+ );
5947
+ const clearHistory = React.useCallback(() => {
5948
+ writeArchive(storageKey, []);
5949
+ setArchive([]);
5950
+ }, [storageKey]);
5839
5951
  const setFocusProduct = React.useCallback((slug) => {
5840
5952
  focusProductRef.current = slug;
5841
5953
  setFocusProductState(slug);
@@ -5864,7 +5976,11 @@ function useExploreChat(options = {}) {
5864
5976
  stop,
5865
5977
  retry,
5866
5978
  reset,
5867
- canSend
5979
+ canSend,
5980
+ history: archive,
5981
+ openConversation,
5982
+ deleteConversation,
5983
+ clearHistory
5868
5984
  };
5869
5985
  }
5870
5986
  var optimisticCounter = 0;
@@ -5872,6 +5988,182 @@ function makeOptimisticId() {
5872
5988
  optimisticCounter += 1;
5873
5989
  return `boff-explore-local-${Date.now()}-${optimisticCounter}`;
5874
5990
  }
5991
+ function getRecognitionCtor() {
5992
+ if (typeof window === "undefined") return null;
5993
+ const w = window;
5994
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
5995
+ }
5996
+ var MIC_DENIED_MESSAGE = "Microphone access was blocked. Allow it in your browser's site settings to dictate.";
5997
+ async function ensureMicrophoneAccess() {
5998
+ const media = typeof navigator === "undefined" ? void 0 : navigator.mediaDevices;
5999
+ if (!media?.getUserMedia) return { ok: true };
6000
+ try {
6001
+ const status = await navigator.permissions?.query({
6002
+ name: "microphone"
6003
+ });
6004
+ if (status?.state === "granted") return { ok: true };
6005
+ if (status?.state === "denied")
6006
+ return { ok: false, message: MIC_DENIED_MESSAGE };
6007
+ } catch {
6008
+ }
6009
+ try {
6010
+ const stream = await media.getUserMedia({ audio: true });
6011
+ for (const track of stream.getTracks()) track.stop();
6012
+ return { ok: true };
6013
+ } catch (error) {
6014
+ const name = typeof error === "object" && error !== null && "name" in error ? String(error.name) : "";
6015
+ if (name === "NotAllowedError" || name === "SecurityError") {
6016
+ return { ok: false, message: MIC_DENIED_MESSAGE };
6017
+ }
6018
+ if (name === "NotFoundError" || name === "DevicesNotFoundError") {
6019
+ return { ok: false, message: "No microphone was found." };
6020
+ }
6021
+ return {
6022
+ ok: false,
6023
+ message: "Dictation could not start. You can type instead."
6024
+ };
6025
+ }
6026
+ }
6027
+ function describeError(code) {
6028
+ switch (code) {
6029
+ case "not-allowed":
6030
+ case "service-not-allowed":
6031
+ return MIC_DENIED_MESSAGE;
6032
+ case "no-speech":
6033
+ return "I didn't catch anything \u2014 try again a little closer to the mic.";
6034
+ case "audio-capture":
6035
+ return "No microphone was found.";
6036
+ case "network":
6037
+ return "Speech recognition needs a network connection.";
6038
+ case "aborted":
6039
+ return "";
6040
+ default:
6041
+ return "Dictation stopped unexpectedly. You can type instead.";
6042
+ }
6043
+ }
6044
+ function useSpeechInput({
6045
+ onFinalTranscript,
6046
+ lang,
6047
+ onError
6048
+ }) {
6049
+ const [supported] = React.useState(() => getRecognitionCtor() !== null);
6050
+ const [listening, setListening] = React.useState(false);
6051
+ const [interim, setInterim] = React.useState("");
6052
+ const [error, setError] = React.useState(null);
6053
+ const recognitionRef = React.useRef(null);
6054
+ const startTokenRef = React.useRef(0);
6055
+ const beginRecognitionRef = React.useRef(null);
6056
+ const beginRecognition = React.useCallback(
6057
+ (Ctor, token) => {
6058
+ beginRecognitionRef.current?.(Ctor, token);
6059
+ },
6060
+ []
6061
+ );
6062
+ const finalRef = React.useRef(onFinalTranscript);
6063
+ const errorRef = React.useRef(onError);
6064
+ finalRef.current = onFinalTranscript;
6065
+ errorRef.current = onError;
6066
+ const stop = React.useCallback(() => {
6067
+ startTokenRef.current += 1;
6068
+ setListening(false);
6069
+ setInterim("");
6070
+ const recognition = recognitionRef.current;
6071
+ if (!recognition) return;
6072
+ try {
6073
+ recognition.stop();
6074
+ } catch {
6075
+ }
6076
+ setListening(false);
6077
+ setInterim("");
6078
+ }, []);
6079
+ const start = React.useCallback(() => {
6080
+ const Ctor = getRecognitionCtor();
6081
+ if (!Ctor) return;
6082
+ if (recognitionRef.current) stop();
6083
+ setError(null);
6084
+ setListening(true);
6085
+ startTokenRef.current += 1;
6086
+ const token = startTokenRef.current;
6087
+ void ensureMicrophoneAccess().then((access) => {
6088
+ if (token !== startTokenRef.current) return;
6089
+ if (!access.ok) {
6090
+ setListening(false);
6091
+ setError(access.message);
6092
+ errorRef.current?.(access.message);
6093
+ return;
6094
+ }
6095
+ beginRecognition(Ctor, token);
6096
+ });
6097
+ }, [beginRecognition, stop]);
6098
+ const beginRecognitionImpl = React.useCallback(
6099
+ (Ctor, token) => {
6100
+ const recognition = new Ctor();
6101
+ recognition.lang = lang ?? (typeof navigator === "undefined" ? "en-US" : navigator.language || "en-US");
6102
+ recognition.continuous = true;
6103
+ recognition.interimResults = true;
6104
+ recognition.maxAlternatives = 1;
6105
+ recognition.onstart = () => {
6106
+ if (token !== startTokenRef.current) return;
6107
+ setError(null);
6108
+ setListening(true);
6109
+ };
6110
+ recognition.onresult = (event) => {
6111
+ let settled = "";
6112
+ let pending = "";
6113
+ for (let i = event.resultIndex; i < event.results.length; i += 1) {
6114
+ const result = event.results[i];
6115
+ if (!result) continue;
6116
+ const text = result[0]?.transcript ?? "";
6117
+ if (result.isFinal) settled += text;
6118
+ else pending += text;
6119
+ }
6120
+ setInterim(pending);
6121
+ if (settled.trim() !== "") finalRef.current(settled);
6122
+ };
6123
+ recognition.onerror = (event) => {
6124
+ const message = describeError(event.error);
6125
+ setListening(false);
6126
+ setInterim("");
6127
+ if (message !== "") {
6128
+ setError(message);
6129
+ errorRef.current?.(message);
6130
+ }
6131
+ };
6132
+ recognition.onend = () => {
6133
+ setListening(false);
6134
+ setInterim("");
6135
+ };
6136
+ recognitionRef.current = recognition;
6137
+ try {
6138
+ recognition.start();
6139
+ } catch {
6140
+ setListening(false);
6141
+ }
6142
+ },
6143
+ [lang]
6144
+ );
6145
+ beginRecognitionRef.current = beginRecognitionImpl;
6146
+ const toggle = React.useCallback(() => {
6147
+ if (listening) stop();
6148
+ else start();
6149
+ }, [listening, start, stop]);
6150
+ React.useEffect(
6151
+ () => () => {
6152
+ const recognition = recognitionRef.current;
6153
+ if (!recognition) return;
6154
+ recognition.onresult = null;
6155
+ recognition.onerror = null;
6156
+ recognition.onend = null;
6157
+ recognition.onstart = null;
6158
+ try {
6159
+ recognition.abort();
6160
+ } catch {
6161
+ }
6162
+ },
6163
+ []
6164
+ );
6165
+ return { supported, listening, interim, error, start, stop, toggle };
6166
+ }
5875
6167
  var EXPLORE_CSS = `
5876
6168
  @keyframes boff-explore-pulse {
5877
6169
  0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
@@ -5966,10 +6258,33 @@ function initialOf(value, fallback) {
5966
6258
  const source = (value || fallback).trim();
5967
6259
  return source ? source.slice(0, 1).toUpperCase() : "?";
5968
6260
  }
6261
+ function logoUrlOf(url) {
6262
+ try {
6263
+ const { origin, protocol } = new URL(url);
6264
+ if (protocol !== "https:" && protocol !== "http:") return null;
6265
+ return `${origin}/favicon.svg`;
6266
+ } catch {
6267
+ return null;
6268
+ }
6269
+ }
5969
6270
  function ReferenceCard({ reference }) {
5970
6271
  const [imageFailed, setImageFailed] = React.useState(false);
6272
+ const [logoFailed, setLogoFailed] = React.useState(false);
5971
6273
  const host = hostnameOf(reference.url);
6274
+ const logoUrl = logoUrlOf(reference.url);
5972
6275
  const showImage = Boolean(reference.imageUrl) && !imageFailed;
6276
+ const showLogo = Boolean(logoUrl) && !logoFailed;
6277
+ const letterPlate = /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.jsx("span", { className: "text-sm font-semibold text-primary/70 sm:text-2xl", children: initialOf(reference.product, reference.title || host) }) });
6278
+ const logoImg = showLogo ? /* @__PURE__ */ jsxRuntime.jsx(
6279
+ "img",
6280
+ {
6281
+ src: logoUrl ?? "",
6282
+ alt: "",
6283
+ loading: "lazy",
6284
+ onError: () => setLogoFailed(true),
6285
+ className: "h-full w-full object-contain p-1.5 sm:p-0"
6286
+ }
6287
+ ) : letterPlate;
5973
6288
  return /* @__PURE__ */ jsxRuntime.jsxs(
5974
6289
  "a",
5975
6290
  {
@@ -5977,9 +6292,10 @@ function ReferenceCard({ reference }) {
5977
6292
  href: reference.url,
5978
6293
  target: "_blank",
5979
6294
  rel: "noopener noreferrer",
5980
- 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",
6295
+ 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",
5981
6296
  children: [
5982
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "relative aspect-[16/9] w-full overflow-hidden bg-muted", children: showImage ? /* @__PURE__ */ jsxRuntime.jsx(
6297
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "relative h-11 w-11 shrink-0 overflow-hidden rounded-lg bg-muted sm:hidden", children: logoImg }),
6298
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "relative hidden aspect-[16/9] w-full overflow-hidden bg-muted sm:block", children: showImage ? /* @__PURE__ */ jsxRuntime.jsx(
5983
6299
  "img",
5984
6300
  {
5985
6301
  src: reference.imageUrl ?? "",
@@ -5988,17 +6304,22 @@ function ReferenceCard({ reference }) {
5988
6304
  onError: () => setImageFailed(true),
5989
6305
  className: "h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
5990
6306
  }
5991
- ) : (
5992
- /* Graceful fallback: a token-derived gradient plate, so a missing or
5993
- broken preview still reads as a deliberate card, not a hole. */
5994
- /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.jsx("span", { className: "text-2xl font-semibold text-primary/70", children: initialOf(reference.product, reference.title || host) }) })
5995
- ) }),
5996
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-1 flex-col gap-1.5 p-3", children: [
6307
+ ) : showLogo ? /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.jsx(
6308
+ "img",
6309
+ {
6310
+ src: logoUrl ?? "",
6311
+ alt: "",
6312
+ loading: "lazy",
6313
+ onError: () => setLogoFailed(true),
6314
+ className: "max-h-full max-w-full object-contain"
6315
+ }
6316
+ ) }) : letterPlate }),
6317
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5 sm:gap-1.5 sm:p-3", children: [
5997
6318
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "line-clamp-2 text-sm font-semibold leading-snug text-card-foreground", children: reference.title || host || reference.url }),
5998
- reference.description ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "line-clamp-2 text-xs leading-relaxed text-muted-foreground", children: reference.description }) : null,
5999
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mt-auto flex items-center gap-2 pt-2", children: [
6000
- reference.product ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground", children: reference.product }) : null,
6001
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "ml-auto inline-flex min-w-0 items-center gap-1 text-xs text-muted-foreground", children: [
6319
+ reference.description ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "line-clamp-2 text-xs leading-relaxed text-muted-foreground max-sm:hidden", children: reference.description }) : null,
6320
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 sm:mt-auto sm:pt-2", children: [
6321
+ reference.product ? /* @__PURE__ */ jsxRuntime.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,
6322
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "inline-flex min-w-0 items-center gap-1 text-xs text-muted-foreground sm:ml-auto", children: [
6002
6323
  /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ExternalLink, { className: "h-3 w-3 shrink-0" }),
6003
6324
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: host })
6004
6325
  ] })
@@ -6084,7 +6405,8 @@ function UserBubble({ message }) {
6084
6405
  function AssistantBubble({
6085
6406
  message,
6086
6407
  activity,
6087
- onNavigate
6408
+ onNavigate,
6409
+ anchorRef
6088
6410
  }) {
6089
6411
  const streaming = message.status === "PENDING" || message.status === "RUNNING";
6090
6412
  const body = message.content || message.partialContent || "";
@@ -6092,9 +6414,10 @@ function AssistantBubble({
6092
6414
  return /* @__PURE__ */ jsxRuntime.jsxs(
6093
6415
  "div",
6094
6416
  {
6417
+ ref: anchorRef,
6095
6418
  "data-boff-explore": "assistant",
6096
6419
  "data-status": message.status,
6097
- className: "flex gap-3",
6420
+ className: "flex scroll-mt-4 gap-3",
6098
6421
  children: [
6099
6422
  /* @__PURE__ */ jsxRuntime.jsx(
6100
6423
  "span",
@@ -6299,11 +6622,32 @@ function ExplorePage({
6299
6622
  stop,
6300
6623
  retry,
6301
6624
  reset,
6302
- canSend
6625
+ canSend,
6626
+ history,
6627
+ openConversation,
6628
+ deleteConversation,
6629
+ clearHistory
6303
6630
  } = useExploreChat({ productSlug, pageUrl, onSend, examplePrompts });
6304
6631
  const [draft, setDraft] = React.useState("");
6305
6632
  const textareaRef = React.useRef(null);
6633
+ const [historyOpen, setHistoryOpen] = React.useState(false);
6634
+ const speechStopRef = React.useRef(() => void 0);
6635
+ const stopDictation = React.useCallback(() => {
6636
+ speechStopRef.current();
6637
+ }, []);
6638
+ const speech = useSpeechInput({
6639
+ onFinalTranscript: (text) => {
6640
+ setDraft((current) => {
6641
+ const joined = current.trim() === "" ? text.trimStart() : `${current.trimEnd()} ${text.trim()}`;
6642
+ return joined;
6643
+ });
6644
+ textareaRef.current?.focus();
6645
+ }
6646
+ });
6647
+ speechStopRef.current = speech.stop;
6306
6648
  const scrollRef = React.useRef(null);
6649
+ const latestAssistantRef = React.useRef(null);
6650
+ const alignedForRef = React.useRef(null);
6307
6651
  const stickToBottomRef = React.useRef(true);
6308
6652
  const composingRef = React.useRef(false);
6309
6653
  const busy = phase === "sending" || phase === "streaming";
@@ -6329,20 +6673,41 @@ function ExplorePage({
6329
6673
  if (!el) return;
6330
6674
  stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
6331
6675
  }, []);
6676
+ const latestAssistantId = React.useMemo(() => {
6677
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
6678
+ const message = messages[i];
6679
+ if (message && message.role === "ASSISTANT") return message.id;
6680
+ }
6681
+ return null;
6682
+ }, [messages]);
6332
6683
  React.useEffect(() => {
6684
+ const el = scrollRef.current;
6685
+ const anchor = latestAssistantRef.current;
6686
+ if (!el || !anchor || !latestAssistantId) return;
6687
+ if (alignedForRef.current === latestAssistantId) return;
6333
6688
  if (!stickToBottomRef.current) return;
6689
+ if (anchor.offsetHeight === 0) return;
6690
+ alignedForRef.current = latestAssistantId;
6691
+ const delta = anchor.getBoundingClientRect().top - el.getBoundingClientRect().top;
6692
+ el.scrollTop = Math.max(0, el.scrollTop + delta - 12);
6693
+ }, [latestAssistantId, messages]);
6694
+ React.useEffect(() => {
6695
+ if (!stickToBottomRef.current) return;
6696
+ if (latestAssistantId && alignedForRef.current === latestAssistantId)
6697
+ return;
6334
6698
  const el = scrollRef.current;
6335
6699
  if (!el) return;
6336
6700
  el.scrollTop = el.scrollHeight;
6337
- }, [messages, activity]);
6701
+ }, [activity, latestAssistantId, messages]);
6338
6702
  const submitDraft = React.useCallback(() => {
6339
6703
  const text = draft.trim();
6340
6704
  if (!text || overLimit || busy || !canSend) return;
6705
+ stopDictation();
6341
6706
  setDraft("");
6342
6707
  stickToBottomRef.current = true;
6343
6708
  void send(text);
6344
6709
  textareaRef.current?.focus();
6345
- }, [busy, canSend, draft, overLimit, send]);
6710
+ }, [busy, canSend, draft, overLimit, send, stopDictation]);
6346
6711
  const sendPrompt = React.useCallback(
6347
6712
  (prompt) => {
6348
6713
  if (!canSend || busy) return;
@@ -6415,8 +6780,96 @@ function ExplorePage({
6415
6780
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "hidden sm:inline", children: "New chat" })
6416
6781
  ]
6417
6782
  }
6418
- )
6783
+ ),
6784
+ history.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs(
6785
+ Button,
6786
+ {
6787
+ "data-boff-explore": "history-toggle",
6788
+ type: "button",
6789
+ size: "sm",
6790
+ variant: "ghost",
6791
+ className: "shrink-0 text-muted-foreground",
6792
+ "aria-expanded": historyOpen,
6793
+ onClick: () => {
6794
+ setHistoryOpen((open) => !open);
6795
+ },
6796
+ children: [
6797
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.History, { className: "h-3.5 w-3.5" }),
6798
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "hidden sm:inline", children: [
6799
+ "History (",
6800
+ history.length,
6801
+ ")"
6802
+ ] })
6803
+ ]
6804
+ }
6805
+ ) : null
6419
6806
  ] }) : null,
6807
+ historyOpen && history.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(
6808
+ "div",
6809
+ {
6810
+ "data-boff-explore": "history-panel",
6811
+ className: "border-b border-border bg-muted/30 px-4 py-3",
6812
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mx-auto w-full max-w-3xl", children: [
6813
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-2 flex items-center justify-between gap-2", children: [
6814
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-muted-foreground", children: "Your recent conversations" }),
6815
+ /* @__PURE__ */ jsxRuntime.jsxs(
6816
+ Button,
6817
+ {
6818
+ "data-boff-explore": "history-clear",
6819
+ type: "button",
6820
+ size: "sm",
6821
+ variant: "ghost",
6822
+ className: "h-7 shrink-0 text-xs text-muted-foreground hover:text-destructive",
6823
+ onClick: () => {
6824
+ clearHistory();
6825
+ setHistoryOpen(false);
6826
+ },
6827
+ children: [
6828
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Trash2, { className: "h-3.5 w-3.5" }),
6829
+ "Clear history"
6830
+ ]
6831
+ }
6832
+ )
6833
+ ] }),
6834
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "space-y-1", children: history.map((entry) => /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "flex items-center gap-1", children: [
6835
+ /* @__PURE__ */ jsxRuntime.jsxs(
6836
+ "button",
6837
+ {
6838
+ "data-boff-explore": "history-item",
6839
+ type: "button",
6840
+ 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",
6841
+ onClick: () => {
6842
+ openConversation(entry.token);
6843
+ setHistoryOpen(false);
6844
+ },
6845
+ children: [
6846
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: entry.title }),
6847
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "ml-2 text-xs text-muted-foreground", children: [
6848
+ entry.messageCount,
6849
+ " message",
6850
+ entry.messageCount === 1 ? "" : "s"
6851
+ ] })
6852
+ ]
6853
+ }
6854
+ ),
6855
+ /* @__PURE__ */ jsxRuntime.jsx(
6856
+ Button,
6857
+ {
6858
+ type: "button",
6859
+ size: "icon",
6860
+ variant: "ghost",
6861
+ className: "h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive",
6862
+ "aria-label": `Delete conversation: ${entry.title}`,
6863
+ onClick: () => {
6864
+ deleteConversation(entry.token);
6865
+ },
6866
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Trash2, { className: "h-3.5 w-3.5" })
6867
+ }
6868
+ )
6869
+ ] }, entry.token)) })
6870
+ ] })
6871
+ }
6872
+ ) : null,
6420
6873
  /* @__PURE__ */ jsxRuntime.jsx(
6421
6874
  "div",
6422
6875
  {
@@ -6445,7 +6898,8 @@ function ExplorePage({
6445
6898
  {
6446
6899
  message,
6447
6900
  activity,
6448
- onNavigate
6901
+ onNavigate,
6902
+ anchorRef: message.id === latestAssistantId ? latestAssistantRef : void 0
6449
6903
  },
6450
6904
  message.id
6451
6905
  )
@@ -6491,6 +6945,23 @@ function ExplorePage({
6491
6945
  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"
6492
6946
  }
6493
6947
  ),
6948
+ speech.supported ? /* @__PURE__ */ jsxRuntime.jsx(
6949
+ Button,
6950
+ {
6951
+ "data-boff-explore": "mic",
6952
+ "data-listening": speech.listening ? "true" : "false",
6953
+ type: "button",
6954
+ size: "icon",
6955
+ variant: speech.listening ? "default" : "ghost",
6956
+ disabled: composerDisabled,
6957
+ "aria-label": speech.listening ? "Stop dictating" : "Dictate your question",
6958
+ "aria-pressed": speech.listening,
6959
+ title: speech.listening ? "Stop dictating" : "Dictate your question",
6960
+ onClick: speech.toggle,
6961
+ className: cn(speech.listening && "animate-pulse"),
6962
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, { className: "h-4 w-4" })
6963
+ }
6964
+ ) : null,
6494
6965
  busy ? /* @__PURE__ */ jsxRuntime.jsx(
6495
6966
  Button,
6496
6967
  {
@@ -6516,6 +6987,29 @@ function ExplorePage({
6516
6987
  ]
6517
6988
  }
6518
6989
  ),
6990
+ speech.listening || speech.interim !== "" ? /* @__PURE__ */ jsxRuntime.jsxs(
6991
+ "p",
6992
+ {
6993
+ "data-boff-explore": "dictation",
6994
+ className: "mt-2 flex items-center gap-2 text-xs text-muted-foreground",
6995
+ "aria-live": "polite",
6996
+ children: [
6997
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "relative flex h-2 w-2 shrink-0", children: [
6998
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" }),
6999
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-primary" })
7000
+ ] }),
7001
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "italic", children: speech.interim !== "" ? speech.interim : "Listening\u2026" })
7002
+ ]
7003
+ }
7004
+ ) : null,
7005
+ speech.error !== null ? /* @__PURE__ */ jsxRuntime.jsx(
7006
+ "p",
7007
+ {
7008
+ "data-boff-explore": "dictation-error",
7009
+ className: "mt-2 text-xs text-destructive",
7010
+ children: speech.error
7011
+ }
7012
+ ) : null,
6519
7013
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground", children: [
6520
7014
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "hidden sm:inline", children: "Enter to send \xB7 Shift + Enter for a new line" }),
6521
7015
  remainingToday !== null ? /* @__PURE__ */ jsxRuntime.jsxs("span", { "data-boff-explore": "remaining", children: [
@@ -6539,39 +7033,48 @@ function ExplorePage({
6539
7033
  }
6540
7034
  )
6541
7035
  ] }),
6542
- /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "mt-2 text-xs leading-relaxed text-muted-foreground", children: [
6543
- "Answers are generated from our public documentation and can be imperfect \u2014 check anything important against the linked sources.",
6544
- captchaOn ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
6545
- " ",
6546
- "This site is protected by reCAPTCHA; the Google",
6547
- " ",
6548
- /* @__PURE__ */ jsxRuntime.jsx(
6549
- "a",
6550
- {
6551
- href: "https://policies.google.com/privacy",
6552
- target: "_blank",
6553
- rel: "noopener noreferrer",
6554
- className: "underline underline-offset-2 hover:text-foreground",
6555
- children: "Privacy Policy"
6556
- }
6557
- ),
6558
- " ",
6559
- "and",
6560
- " ",
6561
- /* @__PURE__ */ jsxRuntime.jsx(
6562
- "a",
6563
- {
6564
- href: "https://policies.google.com/terms",
6565
- target: "_blank",
6566
- rel: "noopener noreferrer",
6567
- className: "underline underline-offset-2 hover:text-foreground",
6568
- children: "Terms of Service"
6569
- }
6570
- ),
6571
- " ",
6572
- "apply."
6573
- ] }) : null
6574
- ] })
7036
+ /* @__PURE__ */ jsxRuntime.jsxs(
7037
+ "p",
7038
+ {
7039
+ "data-boff-explore": "disclaimer",
7040
+ className: "mt-2 text-xs leading-relaxed text-muted-foreground",
7041
+ children: [
7042
+ "Answers are generated from our public documentation and can be imperfect \u2014 check anything important against the linked sources.",
7043
+ " ",
7044
+ "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.",
7045
+ captchaOn ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
7046
+ " ",
7047
+ "This site is protected by reCAPTCHA; the Google",
7048
+ " ",
7049
+ /* @__PURE__ */ jsxRuntime.jsx(
7050
+ "a",
7051
+ {
7052
+ href: "https://policies.google.com/privacy",
7053
+ target: "_blank",
7054
+ rel: "noopener noreferrer",
7055
+ className: "underline underline-offset-2 hover:text-foreground",
7056
+ children: "Privacy Policy"
7057
+ }
7058
+ ),
7059
+ " ",
7060
+ "and",
7061
+ " ",
7062
+ /* @__PURE__ */ jsxRuntime.jsx(
7063
+ "a",
7064
+ {
7065
+ href: "https://policies.google.com/terms",
7066
+ target: "_blank",
7067
+ rel: "noopener noreferrer",
7068
+ className: "underline underline-offset-2 hover:text-foreground",
7069
+ children: "Terms of Service"
7070
+ }
7071
+ ),
7072
+ " ",
7073
+ "apply."
7074
+ ] }) : null
7075
+ ]
7076
+ }
7077
+ )
6575
7078
  ]
6576
7079
  }
6577
7080
  ) })
@@ -6621,11 +7124,12 @@ function ExploreCta({
6621
7124
  onNavigate(href);
6622
7125
  };
6623
7126
  const classes = {
6624
- // `shrink-0` matters: this sits in a host header's flex row, and without it the
6625
- // surrounding nav gets squeezed and its links wrap onto two lines. The label is hidden
6626
- // below 2xl for the same reason most product headers are already close to full at
6627
- // 1440px, and a 90px pill there pushes them over.
6628
- 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",
7127
+ // A quiet icon control, deliberately NOT a second gradient pill: a header should carry one
7128
+ // primary CTA ("Get started"), and a competing coloured button next to it reads as clutter.
7129
+ // This matches the theme toggle's visual weight, so the row scans as [utilities] [CTA].
7130
+ // The prominent, labelled entry point to /explore is the floating bubble, which is visible
7131
+ // on every page without scrolling. `shrink-0` keeps it from squeezing the nav.
7132
+ 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",
6629
7133
  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",
6630
7134
  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"
6631
7135
  };
@@ -6653,7 +7157,7 @@ function ExploreCta({
6653
7157
  {
6654
7158
  className: cn(
6655
7159
  "shrink-0",
6656
- variant === "floating" ? "h-4 w-4" : "h-3.5 w-3.5"
7160
+ variant === "floating" ? "h-4 w-4" : variant === "header" ? "h-5 w-5" : "h-3.5 w-3.5"
6657
7161
  ),
6658
7162
  "aria-hidden": "true"
6659
7163
  }
@@ -6663,7 +7167,8 @@ function ExploreCta({
6663
7167
  {
6664
7168
  className: cn(
6665
7169
  variant === "floating" && "hidden sm:inline",
6666
- variant === "header" && "hidden 2xl:inline"
7170
+ // Never labelled in the header see the class comment above.
7171
+ variant === "header" && "hidden"
6667
7172
  ),
6668
7173
  children: label
6669
7174
  }