@smooai/chat-widget 0.9.0 → 0.10.1

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.
@@ -11442,396 +11442,703 @@ var SmoothAgentChat = (function(exports) {
11442
11442
  AsYouType.prototype = Object.create(AsYouType$1.prototype, {});
11443
11443
  AsYouType.prototype.constructor = AsYouType;
11444
11444
  //#endregion
11445
- //#region src/config.ts
11446
- /** Resolve a partial config against the built-in defaults. */
11447
- function resolveConfig(config) {
11448
- const theme = config.theme ?? {};
11449
- const primary = theme.primary ?? "#00a6a6";
11450
- const primaryText = theme.primaryText ?? "#f8fafc";
11451
- const assistantBubble = theme.chatBubbleInbound ?? theme.assistantBubble ?? "#06134b";
11452
- const assistantBubbleText = theme.chatBubbleInboundText ?? theme.assistantBubbleText ?? "#f8fafc";
11453
- const userBubble = theme.chatBubbleOutbound ?? theme.userBubble ?? primary;
11454
- const userBubbleText = theme.chatBubbleOutboundText ?? theme.userBubbleText ?? primaryText;
11455
- return {
11456
- endpoint: config.endpoint,
11457
- mode: config.mode ?? "popover",
11458
- agentId: config.agentId,
11459
- agentName: config.agentName ?? "Assistant",
11460
- userName: config.userName,
11461
- userEmail: config.userEmail,
11462
- userPhone: config.userPhone,
11463
- authContext: config.authContext,
11464
- placeholder: config.placeholder ?? "Type a message…",
11465
- greeting: config.greeting ?? "Hi! How can I help you today?",
11466
- connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
11467
- startOpen: config.startOpen ?? false,
11468
- examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
11469
- requireName: config.requireName ?? false,
11470
- requireEmail: config.requireEmail ?? false,
11471
- requirePhone: config.requirePhone ?? false,
11472
- collectPhone: config.collectPhone ?? true,
11473
- collectConsent: config.collectConsent ?? true,
11474
- allowChatRestore: config.allowChatRestore ?? true,
11475
- allowAnonymous: config.allowAnonymous ?? false,
11476
- theme: {
11477
- text: theme.text ?? "#f8fafc",
11478
- background: theme.background ?? "#040d30",
11479
- primary,
11480
- primaryText,
11481
- secondary: theme.secondary ?? "#ff6b6c",
11482
- assistantBubble,
11483
- assistantBubbleText,
11484
- userBubble,
11485
- userBubbleText,
11486
- border: theme.border ?? "rgba(255, 255, 255, 0.1)"
11445
+ //#region src/markdown.ts
11446
+ /**
11447
+ * A tiny, safe-by-default Markdown → HTML renderer for the chat widget.
11448
+ *
11449
+ * ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
11450
+ *
11451
+ * The widget renders **untrusted** text in two places: the assistant's reply
11452
+ * (LLM output, which can echo attacker-supplied content) and citation snippets
11453
+ * (raw scraped page chunks). Today both are written via `textContent`, so
11454
+ * `**bold**`, numbered lists, and `[links](url)` show up literally. We want
11455
+ * them rendered — without re-opening the XSS hole that `textContent` was
11456
+ * guarding against.
11457
+ *
11458
+ * markdown-it with `html:false` is safe-by-default but ships ~30 kB min into
11459
+ * what is an embeddable **global** bundle, where every kilobyte is on the host
11460
+ * page's critical path. snarkdown is ~1 kB but emits raw HTML, so it would
11461
+ * require bolting on a separate sanitizer. Instead, this renderer is
11462
+ * **safe-by-construction**:
11463
+ *
11464
+ * 1. It is a *tokenizer*, not an HTML passthrough. It only ever emits a
11465
+ * fixed allowlist of tags (`p`, `br`, `strong`, `em`, `ul`/`ol`/`li`,
11466
+ * `code`/`pre`, `a`, `blockquote`). There is no code path that copies a
11467
+ * tag out of the input — a literal `<script>` in the input is treated as
11468
+ * plain text.
11469
+ * 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
11470
+ * reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
11471
+ * 3. **Images are dropped entirely** — `![alt](src)` renders as its alt text,
11472
+ * no `<img>` is ever produced (a scraped tracking pixel must not load).
11473
+ * 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
11474
+ * URLs become anchors (with `target="_blank"` + a hardened `rel`);
11475
+ * `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
11476
+ * 5. **Headings** (`#`..`######`) are *downgraded* to bold lines — a full
11477
+ * `<h1>` is far too large inside a chat bubble or citation card.
11478
+ *
11479
+ * The output is a string of HTML that is only ever assigned to `innerHTML` of
11480
+ * an element the caller controls; because of (1)–(4) it can only contain the
11481
+ * allowlisted, attribute-sanitized tags.
11482
+ *
11483
+ * Supported subset (deliberately small):
11484
+ * - Paragraphs (blank-line separated) and hard/soft line breaks
11485
+ * - `**bold**` / `__bold__`, `*italic*` / `_italic_`
11486
+ * - `` `inline code` `` and fenced ``` ```code blocks``` ```
11487
+ * - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
11488
+ * - `> ` blockquotes
11489
+ * - `[text](http(s)://url)` links (images dropped to alt text)
11490
+ * - `#`..`######` headings → bold line
11491
+ */
11492
+ /** Escape the five HTML-significant characters so a text run can never be markup. */
11493
+ function escapeHtml(value) {
11494
+ return value.replace(/[&<>"']/g, (c) => {
11495
+ switch (c) {
11496
+ case "&": return "&amp;";
11497
+ case "<": return "&lt;";
11498
+ case ">": return "&gt;";
11499
+ case "\"": return "&quot;";
11500
+ default: return "&#39;";
11487
11501
  }
11488
- };
11502
+ });
11489
11503
  }
11490
11504
  /**
11491
- * Whether the pre-chat identity form should gate the conversation: at least one
11492
- * field is required and anonymous chat is not allowed.
11505
+ * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
11506
+ *
11507
+ * SECURITY: link targets here originate from untrusted content (LLM output /
11508
+ * scraped citation chunks). Allowing an arbitrary string as an `href` permits
11509
+ * `javascript:`/`data:`/`vbscript:` URLs that execute on click — a stored-XSS
11510
+ * vector. Only absolute http(s) links are rendered as anchors; anything else
11511
+ * falls back to plain text upstream.
11493
11512
  */
11494
- function needsUserInfo(resolved) {
11495
- return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
11496
- }
11497
- //#endregion
11498
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
11499
- function _typeof(o) {
11500
- "@babel/helpers - typeof";
11501
- return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
11502
- return typeof o;
11503
- } : function(o) {
11504
- return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
11505
- }, _typeof(o);
11506
- }
11507
- //#endregion
11508
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
11509
- function toPrimitive(t, r) {
11510
- if ("object" != _typeof(t) || !t) return t;
11511
- var e = t[Symbol.toPrimitive];
11512
- if (void 0 !== e) {
11513
- var i = e.call(t, r || "default");
11514
- if ("object" != _typeof(i)) return i;
11515
- throw new TypeError("@@toPrimitive must return a primitive value.");
11513
+ function safeHttpUrl(url) {
11514
+ if (!url) return null;
11515
+ try {
11516
+ const parsed = new URL(url);
11517
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
11518
+ } catch {
11519
+ return null;
11516
11520
  }
11517
- return ("string" === r ? String : Number)(t);
11518
- }
11519
- //#endregion
11520
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
11521
- function toPropertyKey(t) {
11522
- var i = toPrimitive(t, "string");
11523
- return "symbol" == _typeof(i) ? i : i + "";
11524
- }
11525
- //#endregion
11526
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
11527
- function _defineProperty(e, r, t) {
11528
- return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
11529
- value: t,
11530
- enumerable: !0,
11531
- configurable: !0,
11532
- writable: !0
11533
- }) : e[r] = t, e;
11534
11521
  }
11535
- //#endregion
11536
- //#region node_modules/.pnpm/@smooai+smooth-operator@1.8.0/node_modules/@smooai/smooth-operator/dist/transport.js
11537
11522
  /**
11538
- * Transport abstraction for the client.
11523
+ * Render the inline span grammar of a single line/segment to safe HTML.
11539
11524
  *
11540
- * The client is deliberately decoupled from any concrete WebSocket implementation
11541
- * so it can be unit-tested with a mock and run on Node, the browser, or a custom
11542
- * socket. A transport is anything that can send a string frame and surface
11543
- * incoming string frames + lifecycle events.
11544
- */
11545
- const WS_CONNECTING = 0;
11546
- const WS_OPEN = 1;
11547
- const WS_CLOSING = 2;
11548
- /** Default connect timeout (ms) for the WebSocket transport. */
11549
- const DEFAULT_CONNECT_TIMEOUT = 3e4;
11550
- /**
11551
- * Default transport backed by a `WebSocket`-like object. By default it uses the
11552
- * global `WebSocket`; pass a `factory` to inject one (e.g. the `ws` package on
11553
- * Node, or a mock in tests).
11525
+ * Order matters: code spans are extracted first (their contents are *not*
11526
+ * further parsed), then images are stripped to alt text, then links, then
11527
+ * emphasis. Every literal text run is escaped on the way out.
11554
11528
  */
11555
- var WebSocketTransport = class {
11556
- constructor(url, factory, connectTimeout = DEFAULT_CONNECT_TIMEOUT) {
11557
- _defineProperty(this, "socket", null);
11558
- _defineProperty(this, "url", void 0);
11559
- _defineProperty(this, "factory", void 0);
11560
- _defineProperty(this, "connectTimeout", void 0);
11561
- _defineProperty(this, "messageHandlers", /* @__PURE__ */ new Set());
11562
- _defineProperty(this, "closeHandlers", /* @__PURE__ */ new Set());
11563
- _defineProperty(this, "errorHandlers", /* @__PURE__ */ new Set());
11564
- this.url = url;
11565
- this.connectTimeout = connectTimeout;
11566
- if (factory) this.factory = factory;
11567
- else {
11568
- const G = globalThis;
11569
- if (!G.WebSocket) throw new Error("No global WebSocket available; pass a WebSocketFactory to WebSocketTransport.");
11570
- const Ctor = G.WebSocket;
11571
- this.factory = (u) => new Ctor(u);
11529
+ function renderInline(input) {
11530
+ let out = "";
11531
+ let i = 0;
11532
+ const n = input.length;
11533
+ let buf = "";
11534
+ const flush = () => {
11535
+ if (buf) {
11536
+ out += escapeHtml(buf);
11537
+ buf = "";
11572
11538
  }
11573
- }
11574
- get state() {
11575
- if (!this.socket) return "closed";
11576
- switch (this.socket.readyState) {
11577
- case WS_CONNECTING: return "connecting";
11578
- case WS_OPEN: return "open";
11579
- case WS_CLOSING: return "closing";
11580
- default: return "closed";
11539
+ };
11540
+ while (i < n) {
11541
+ const ch = input[i];
11542
+ if (ch === "`") {
11543
+ const end = input.indexOf("`", i + 1);
11544
+ if (end > i) {
11545
+ flush();
11546
+ out += `<code>${escapeHtml(input.slice(i + 1, end))}</code>`;
11547
+ i = end + 1;
11548
+ continue;
11549
+ }
11581
11550
  }
11582
- }
11583
- connect() {
11584
- if (this.socket && this.socket.readyState === WS_OPEN) return Promise.resolve();
11585
- if (this.socket && this.socket.readyState !== WS_OPEN) {
11586
- const stale = this.socket;
11587
- this.socket = null;
11588
- try {
11589
- stale.close();
11590
- } catch {}
11551
+ if (ch === "!" && input[i + 1] === "[") {
11552
+ const m = imageAt(input, i);
11553
+ if (m) {
11554
+ flush();
11555
+ out += renderInline(m.alt);
11556
+ i = m.end;
11557
+ continue;
11558
+ }
11591
11559
  }
11592
- return new Promise((resolve, reject) => {
11593
- const socket = this.factory(this.url);
11594
- this.socket = socket;
11595
- let settled = false;
11596
- const timer = this.connectTimeout > 0 ? setTimeout(() => {
11597
- if (settled) return;
11598
- settled = true;
11599
- if (this.socket === socket) this.socket = null;
11600
- try {
11601
- socket.close();
11602
- } catch {}
11603
- reject(/* @__PURE__ */ new Error(`WebSocket connect to ${this.url} timed out after ${this.connectTimeout}ms`));
11604
- }, this.connectTimeout) : void 0;
11605
- socket.addEventListener("open", () => {
11606
- if (this.socket !== socket) return;
11607
- if (settled) return;
11608
- settled = true;
11609
- if (timer) clearTimeout(timer);
11610
- resolve();
11611
- });
11612
- socket.addEventListener("error", (ev) => {
11613
- if (this.socket !== socket) return;
11614
- for (const h of this.errorHandlers) h(ev);
11615
- if (!settled && this.state !== "open") {
11616
- settled = true;
11617
- if (timer) clearTimeout(timer);
11618
- if (this.socket === socket) this.socket = null;
11619
- try {
11620
- socket.close();
11621
- } catch {}
11622
- reject(ev instanceof Error ? ev : /* @__PURE__ */ new Error("WebSocket connection error"));
11623
- }
11624
- });
11625
- socket.addEventListener("close", (ev) => {
11626
- if (this.socket !== socket) return;
11627
- if (timer) clearTimeout(timer);
11628
- for (const h of this.closeHandlers) h({
11629
- code: ev.code,
11630
- reason: ev.reason
11631
- });
11632
- });
11633
- socket.addEventListener("message", (ev) => {
11634
- if (this.socket !== socket) return;
11635
- const data = typeof ev.data === "string" ? ev.data : String(ev.data);
11636
- for (const h of this.messageHandlers) h(data);
11637
- });
11638
- });
11639
- }
11640
- send(data) {
11641
- if (!this.socket || this.socket.readyState !== WS_OPEN) throw new Error(`Cannot send: transport is "${this.state}"`);
11642
- this.socket.send(data);
11643
- }
11644
- close(code, reason) {
11645
- this.socket?.close(code, reason);
11646
- }
11647
- onMessage(handler) {
11648
- this.messageHandlers.add(handler);
11649
- return () => this.messageHandlers.delete(handler);
11650
- }
11651
- onClose(handler) {
11652
- this.closeHandlers.add(handler);
11653
- return () => this.closeHandlers.delete(handler);
11560
+ if (ch === "[") {
11561
+ const m = linkAt(input, i);
11562
+ if (m) {
11563
+ flush();
11564
+ const safe = safeHttpUrl(m.href);
11565
+ const inner = renderInline(m.text);
11566
+ if (safe) out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer nofollow">${inner}</a>`;
11567
+ else out += inner;
11568
+ i = m.end;
11569
+ continue;
11570
+ }
11571
+ }
11572
+ if (ch === "*" && input[i + 1] === "*" || ch === "_" && input[i + 1] === "_") {
11573
+ const marker = ch + ch;
11574
+ const end = input.indexOf(marker, i + 2);
11575
+ if (end > i + 1) {
11576
+ flush();
11577
+ out += `<strong>${renderInline(input.slice(i + 2, end))}</strong>`;
11578
+ i = end + 2;
11579
+ continue;
11580
+ }
11581
+ }
11582
+ if (ch === "*" || ch === "_") {
11583
+ const end = input.indexOf(ch, i + 1);
11584
+ if (end > i + 1 && input[i + 1] !== ch) {
11585
+ flush();
11586
+ out += `<em>${renderInline(input.slice(i + 1, end))}</em>`;
11587
+ i = end + 1;
11588
+ continue;
11589
+ }
11590
+ }
11591
+ buf += ch;
11592
+ i++;
11654
11593
  }
11655
- onError(handler) {
11656
- this.errorHandlers.add(handler);
11657
- return () => this.errorHandlers.delete(handler);
11594
+ flush();
11595
+ return out;
11596
+ }
11597
+ /** Parse a `[text](href)` link starting at `start` (`input[start] === '['`). */
11598
+ function linkAt(input, start) {
11599
+ const close = matchBracket(input, start);
11600
+ if (close < 0 || input[close + 1] !== "(") return null;
11601
+ const paren = input.indexOf(")", close + 2);
11602
+ if (paren < 0) return null;
11603
+ return {
11604
+ text: input.slice(start + 1, close),
11605
+ href: input.slice(close + 2, paren).trim().split(/\s+/)[0] ?? "",
11606
+ end: paren + 1
11607
+ };
11608
+ }
11609
+ /** Parse a `![alt](src)` image starting at `start` (`input[start] === '!'`). */
11610
+ function imageAt(input, start) {
11611
+ const link = linkAt(input, start + 1);
11612
+ if (!link) return null;
11613
+ return {
11614
+ alt: link.text,
11615
+ end: link.end
11616
+ };
11617
+ }
11618
+ /** Find the matching `]` for a `[` at `open`, honoring one level of nesting. */
11619
+ function matchBracket(input, open) {
11620
+ let depth = 0;
11621
+ for (let i = open; i < input.length; i++) {
11622
+ const c = input[i];
11623
+ if (c === "[") depth++;
11624
+ else if (c === "]") {
11625
+ depth--;
11626
+ if (depth === 0) return i;
11627
+ }
11658
11628
  }
11659
- };
11660
- //#endregion
11661
- //#region node_modules/.pnpm/@smooai+smooth-operator@1.8.0/node_modules/@smooai/smooth-operator/dist/types.js
11662
- /** Every server→client `type` discriminator value. */
11663
- const EVENT_TYPES = [
11664
- "immediate_response",
11665
- "eventual_response",
11666
- "stream_chunk",
11667
- "stream_token",
11668
- "keepalive",
11669
- "write_confirmation_required",
11670
- "otp_verification_required",
11671
- "otp_sent",
11672
- "otp_verified",
11673
- "otp_invalid",
11674
- "error",
11675
- "pong"
11676
- ];
11677
- /** True if `frame` looks like any server event (has a known `type` discriminator). */
11678
- function isServerEvent(frame) {
11679
- return typeof frame === "object" && frame !== null && "type" in frame && typeof frame.type === "string" && EVENT_TYPES.includes(frame.type);
11629
+ return -1;
11680
11630
  }
11681
- //#endregion
11682
- //#region node_modules/.pnpm/@smooai+smooth-operator@1.8.0/node_modules/@smooai/smooth-operator/dist/client.js
11631
+ const UL_RE = /^\s*[-*+]\s+(.*)$/;
11632
+ const OL_RE = /^\s*\d+[.)]\s+(.*)$/;
11633
+ const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*)$/;
11634
+ const QUOTE_RE = /^\s*>\s?(.*)$/;
11635
+ const FENCE_RE = /^\s*(`{3,}|~{3,})\s*(.*)$/;
11683
11636
  /**
11684
- * SmoothAgentClient a minimal, idiomatic, transport-agnostic client for the
11685
- * smooth-operator WebSocket protocol.
11637
+ * Render a full Markdown string to safe HTML.
11686
11638
  *
11687
- * Design goals
11688
- * ------------
11689
- * - **Transport-agnostic.** The client never touches a real socket directly; it
11690
- * talks to an injectable {@link Transport}. The default ({@link WebSocketTransport})
11691
- * uses the global `WebSocket`, but tests inject a mock and Node can inject `ws`.
11692
- * - **Request/response correlation by `requestId`.** Every action gets a generated
11693
- * `requestId`; the client routes incoming events back to the originating call.
11694
- * - **Streaming as an async iterator.** `sendMessage` returns a {@link MessageTurn}
11695
- * that is both awaitable (resolves with the terminal `eventual_response`) and
11696
- * async-iterable (yields each `stream_token` / `stream_chunk` / HITL event in
11697
- * order). This models the `stream_token`/`stream_chunk` → `eventual_response`
11698
- * flow without forcing a callback style on the caller.
11699
- * - **No live server required.** Correctness is fully unit-testable with a mock
11700
- * transport (see `test/client.test.ts`).
11639
+ * @returns a string containing only the allowlisted tags described in the
11640
+ * module doc. Safe to assign to `innerHTML` of a caller-owned element.
11701
11641
  */
11702
- let _Symbol$asyncIterator;
11703
- /** A timeout that yields no terminal event. */
11704
- var RequestTimeoutError = class extends Error {
11705
- constructor(requestId, ms) {
11706
- super(`Request ${requestId} timed out after ${ms}ms`);
11707
- this.name = "RequestTimeoutError";
11642
+ function renderMarkdown(src) {
11643
+ const lines = src.replace(/\r\n?/g, "\n").split("\n");
11644
+ const out = [];
11645
+ let i = 0;
11646
+ while (i < lines.length) {
11647
+ const line = lines[i];
11648
+ const fence = FENCE_RE.exec(line);
11649
+ if (fence) {
11650
+ const marker = fence[1];
11651
+ const body = [];
11652
+ i++;
11653
+ while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
11654
+ body.push(lines[i]);
11655
+ i++;
11656
+ }
11657
+ if (i < lines.length) i++;
11658
+ out.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
11659
+ continue;
11660
+ }
11661
+ if (line.trim() === "") {
11662
+ i++;
11663
+ continue;
11664
+ }
11665
+ const heading = HEADING_RE.exec(line);
11666
+ if (heading) {
11667
+ out.push(`<p><strong>${renderInline(heading[2])}</strong></p>`);
11668
+ i++;
11669
+ continue;
11670
+ }
11671
+ if (UL_RE.test(line) || OL_RE.test(line)) {
11672
+ const ordered = OL_RE.test(line) && !UL_RE.test(line);
11673
+ const re = ordered ? OL_RE : UL_RE;
11674
+ const items = [];
11675
+ while (i < lines.length) {
11676
+ const m = re.exec(lines[i]);
11677
+ if (!m) break;
11678
+ items.push(`<li>${renderInline(m[1])}</li>`);
11679
+ i++;
11680
+ }
11681
+ out.push(`<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`);
11682
+ continue;
11683
+ }
11684
+ if (QUOTE_RE.test(line)) {
11685
+ const quoted = [];
11686
+ while (i < lines.length) {
11687
+ const m = QUOTE_RE.exec(lines[i]);
11688
+ if (!m) break;
11689
+ quoted.push(m[1]);
11690
+ i++;
11691
+ }
11692
+ out.push(`<blockquote>${renderInline(quoted.join("\n")).replace(/\n/g, "<br>")}</blockquote>`);
11693
+ continue;
11694
+ }
11695
+ const para = [];
11696
+ while (i < lines.length) {
11697
+ const l = lines[i];
11698
+ if (l.trim() === "" || FENCE_RE.test(l) || HEADING_RE.test(l) || UL_RE.test(l) || OL_RE.test(l) || QUOTE_RE.test(l)) break;
11699
+ para.push(l);
11700
+ i++;
11701
+ }
11702
+ out.push(`<p>${renderInline(para.join("\n")).replace(/\n/g, "<br>")}</p>`);
11708
11703
  }
11709
- };
11704
+ return out.join("");
11705
+ }
11706
+ const SNIPPET_MAX = 260;
11710
11707
  /**
11711
- * A streaming turn that received no terminal `eventual_response` / `error` within the
11712
- * configured {@link SmoothAgentClientOptions.turnTimeout}. The turn rejects with this
11713
- * and its async iteration throws it, so a stuck server can never hang the caller.
11708
+ * Clean a raw scraped citation snippet into a short, readable excerpt.
11709
+ *
11710
+ * Scraped chunks frequently begin with page boilerplate a logo image wrapped
11711
+ * in a link, standalone nav, repeated whitespace — e.g.
11712
+ * `[![Logo](…)](…) # Our Work We build…`. The source itself is already linked
11713
+ * from the citation card, so the snippet only needs to be a clean teaser.
11714
+ *
11715
+ * Steps:
11716
+ * 1. Strip a leading image / logo-link (`[![…](…)](…)` or `![…](…)`).
11717
+ * 2. Drop a leading standalone heading marker (`#`/`##`).
11718
+ * 3. Collapse all runs of whitespace to single spaces.
11719
+ * 4. Truncate to ~{@link SNIPPET_MAX} chars at a word boundary, adding `…`.
11720
+ *
11721
+ * The result is still rendered through {@link renderMarkdown} downstream, so any
11722
+ * remaining inline markup (bold/links) stays safe.
11714
11723
  */
11715
- var TurnTimeoutError = class extends Error {
11716
- constructor(requestId, ms) {
11717
- super(`Turn ${requestId} timed out after ${ms}ms without a terminal response`);
11718
- _defineProperty(this, "requestId", void 0);
11719
- this.name = "TurnTimeoutError";
11720
- this.requestId = requestId;
11724
+ function cleanCitationSnippet(raw) {
11725
+ let s = raw ?? "";
11726
+ let changed = true;
11727
+ while (changed) {
11728
+ changed = false;
11729
+ const before = s;
11730
+ s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, "");
11731
+ s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, "");
11732
+ s = s.replace(/^\s*#{1,6}\s+/, "");
11733
+ if (s !== before) changed = true;
11721
11734
  }
11722
- };
11723
- /** A protocol-level error event surfaced as a throwable. */
11724
- var ProtocolError = class extends Error {
11725
- constructor(code, message, requestId) {
11726
- super(message);
11727
- _defineProperty(this, "code", void 0);
11728
- _defineProperty(this, "requestId", void 0);
11729
- this.name = "ProtocolError";
11730
- this.code = code;
11731
- this.requestId = requestId;
11735
+ s = s.replace(/\s+/g, " ").trim();
11736
+ if (s.length > SNIPPET_MAX) {
11737
+ const cut = s.slice(0, SNIPPET_MAX);
11738
+ const lastSpace = cut.lastIndexOf(" ");
11739
+ s = (lastSpace > SNIPPET_MAX * .6 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
11732
11740
  }
11733
- };
11734
- _Symbol$asyncIterator = Symbol.asyncIterator;
11741
+ return s;
11742
+ }
11743
+ //#endregion
11744
+ //#region src/config.ts
11735
11745
  /**
11736
- * A streaming message turn. Await it for the terminal {@link EventualResponse},
11737
- * or async-iterate it to receive every intermediate event in arrival order.
11746
+ * Public configuration surface for the chat widget.
11738
11747
  *
11739
- * ```ts
11740
- * const turn = client.sendMessage({ sessionId, message: 'hi' });
11741
- * for await (const ev of turn) {
11742
- * if (ev.type === 'stream_token') process.stdout.write(ev.token ?? '');
11743
- * }
11744
- * const final = await turn; // EventualResponse
11745
- * ```
11748
+ * A host page configures the widget either declaratively (HTML attributes on the
11749
+ * `<smooth-agent-chat>` element) or programmatically (passing this object to
11750
+ * {@link mountChatWidget} / `element.configure(...)`).
11746
11751
  */
11747
- var MessageTurn = class {
11748
- constructor(requestId, onClose, turnTimeout = 0) {
11749
- _defineProperty(
11750
- this,
11751
- /** The requestId this turn is correlated on. */
11752
- "requestId",
11753
- void 0
11754
- );
11755
- _defineProperty(this, "queue", []);
11756
- _defineProperty(this, "waiter", null);
11757
- _defineProperty(this, "done", false);
11758
- _defineProperty(this, "finalEvent", null);
11759
- _defineProperty(this, "error", null);
11760
- _defineProperty(this, "settled", void 0);
11761
- _defineProperty(this, "settle", void 0);
11762
- _defineProperty(this, "fail", void 0);
11763
- _defineProperty(this, "onClose", void 0);
11764
- _defineProperty(this, "timeoutTimer", void 0);
11765
- this.requestId = requestId;
11766
- this.onClose = onClose;
11767
- this.settled = new Promise((resolve, reject) => {
11768
- this.settle = resolve;
11769
- this.fail = reject;
11770
- });
11771
- this.settled.catch(() => {});
11772
- if (turnTimeout > 0) this.timeoutTimer = setTimeout(() => {
11773
- this.finish(null, new TurnTimeoutError(this.requestId, turnTimeout));
11774
- }, turnTimeout);
11775
- }
11776
- /** Feed an event into the turn (called by the client's dispatcher). */
11777
- push(event) {
11778
- if (this.done) return;
11779
- if (event.type === "error") {
11780
- const code = event.data?.error?.code ?? "INTERNAL_ERROR";
11781
- const message = event.data?.error?.message ?? "Unknown protocol error";
11782
- this.deliver(event);
11783
- this.finish(null, new ProtocolError(code, message, this.requestId));
11784
- return;
11752
+ /** Resolve a partial config against the built-in defaults. */
11753
+ function resolveConfig(config) {
11754
+ const theme = config.theme ?? {};
11755
+ const primary = theme.primary ?? "#00a6a6";
11756
+ const primaryText = theme.primaryText ?? "#f8fafc";
11757
+ const assistantBubble = theme.chatBubbleInbound ?? theme.assistantBubble ?? "#06134b";
11758
+ const assistantBubbleText = theme.chatBubbleInboundText ?? theme.assistantBubbleText ?? "#f8fafc";
11759
+ const userBubble = theme.chatBubbleOutbound ?? theme.userBubble ?? primary;
11760
+ const userBubbleText = theme.chatBubbleOutboundText ?? theme.userBubbleText ?? primaryText;
11761
+ return {
11762
+ endpoint: config.endpoint,
11763
+ mode: config.mode ?? "popover",
11764
+ agentId: config.agentId,
11765
+ agentName: config.agentName ?? "Assistant",
11766
+ logoUrl: safeHttpUrl(config.logoUrl) ?? void 0,
11767
+ userName: config.userName,
11768
+ userEmail: config.userEmail,
11769
+ userPhone: config.userPhone,
11770
+ authContext: config.authContext,
11771
+ placeholder: config.placeholder ?? "Type a message…",
11772
+ greeting: config.greeting ?? "Hi! How can I help you today?",
11773
+ connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
11774
+ startOpen: config.startOpen ?? false,
11775
+ examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
11776
+ requireName: config.requireName ?? false,
11777
+ requireEmail: config.requireEmail ?? false,
11778
+ requirePhone: config.requirePhone ?? false,
11779
+ collectPhone: config.collectPhone ?? true,
11780
+ collectConsent: config.collectConsent ?? true,
11781
+ allowChatRestore: config.allowChatRestore ?? true,
11782
+ allowAnonymous: config.allowAnonymous ?? false,
11783
+ theme: {
11784
+ text: theme.text ?? "#f8fafc",
11785
+ background: theme.background ?? "#040d30",
11786
+ primary,
11787
+ primaryText,
11788
+ secondary: theme.secondary ?? "#ff6b6c",
11789
+ assistantBubble,
11790
+ assistantBubbleText,
11791
+ userBubble,
11792
+ userBubbleText,
11793
+ border: theme.border ?? "rgba(255, 255, 255, 0.1)"
11785
11794
  }
11786
- this.deliver(event);
11787
- if (event.type === "eventual_response") this.finish(event, null);
11788
- }
11789
- /** Force-close the turn (e.g. on disconnect) with an error. */
11790
- abort(err) {
11791
- if (this.done) return;
11792
- this.finish(null, err);
11793
- }
11794
- deliver(event) {
11795
- if (this.waiter) {
11796
- const w = this.waiter;
11797
- this.waiter = null;
11798
- w.resolve({
11799
- value: event,
11800
- done: false
11801
- });
11802
- } else this.queue.push(event);
11795
+ };
11796
+ }
11797
+ /**
11798
+ * Whether the pre-chat identity form should gate the conversation: at least one
11799
+ * field is required and anonymous chat is not allowed.
11800
+ */
11801
+ function needsUserInfo(resolved) {
11802
+ return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
11803
+ }
11804
+ //#endregion
11805
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
11806
+ function _typeof(o) {
11807
+ "@babel/helpers - typeof";
11808
+ return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
11809
+ return typeof o;
11810
+ } : function(o) {
11811
+ return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
11812
+ }, _typeof(o);
11813
+ }
11814
+ //#endregion
11815
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
11816
+ function toPrimitive(t, r) {
11817
+ if ("object" != _typeof(t) || !t) return t;
11818
+ var e = t[Symbol.toPrimitive];
11819
+ if (void 0 !== e) {
11820
+ var i = e.call(t, r || "default");
11821
+ if ("object" != _typeof(i)) return i;
11822
+ throw new TypeError("@@toPrimitive must return a primitive value.");
11803
11823
  }
11804
- finish(final, err) {
11805
- if (this.done) return;
11806
- this.done = true;
11807
- this.finalEvent = final;
11808
- this.error = err;
11809
- if (this.timeoutTimer) {
11810
- clearTimeout(this.timeoutTimer);
11811
- this.timeoutTimer = void 0;
11824
+ return ("string" === r ? String : Number)(t);
11825
+ }
11826
+ //#endregion
11827
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
11828
+ function toPropertyKey(t) {
11829
+ var i = toPrimitive(t, "string");
11830
+ return "symbol" == _typeof(i) ? i : i + "";
11831
+ }
11832
+ //#endregion
11833
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
11834
+ function _defineProperty(e, r, t) {
11835
+ return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
11836
+ value: t,
11837
+ enumerable: !0,
11838
+ configurable: !0,
11839
+ writable: !0
11840
+ }) : e[r] = t, e;
11841
+ }
11842
+ //#endregion
11843
+ //#region node_modules/.pnpm/@smooai+smooth-operator@1.8.0/node_modules/@smooai/smooth-operator/dist/transport.js
11844
+ /**
11845
+ * Transport abstraction for the client.
11846
+ *
11847
+ * The client is deliberately decoupled from any concrete WebSocket implementation
11848
+ * so it can be unit-tested with a mock and run on Node, the browser, or a custom
11849
+ * socket. A transport is anything that can send a string frame and surface
11850
+ * incoming string frames + lifecycle events.
11851
+ */
11852
+ const WS_CONNECTING = 0;
11853
+ const WS_OPEN = 1;
11854
+ const WS_CLOSING = 2;
11855
+ /** Default connect timeout (ms) for the WebSocket transport. */
11856
+ const DEFAULT_CONNECT_TIMEOUT = 3e4;
11857
+ /**
11858
+ * Default transport backed by a `WebSocket`-like object. By default it uses the
11859
+ * global `WebSocket`; pass a `factory` to inject one (e.g. the `ws` package on
11860
+ * Node, or a mock in tests).
11861
+ */
11862
+ var WebSocketTransport = class {
11863
+ constructor(url, factory, connectTimeout = DEFAULT_CONNECT_TIMEOUT) {
11864
+ _defineProperty(this, "socket", null);
11865
+ _defineProperty(this, "url", void 0);
11866
+ _defineProperty(this, "factory", void 0);
11867
+ _defineProperty(this, "connectTimeout", void 0);
11868
+ _defineProperty(this, "messageHandlers", /* @__PURE__ */ new Set());
11869
+ _defineProperty(this, "closeHandlers", /* @__PURE__ */ new Set());
11870
+ _defineProperty(this, "errorHandlers", /* @__PURE__ */ new Set());
11871
+ this.url = url;
11872
+ this.connectTimeout = connectTimeout;
11873
+ if (factory) this.factory = factory;
11874
+ else {
11875
+ const G = globalThis;
11876
+ if (!G.WebSocket) throw new Error("No global WebSocket available; pass a WebSocketFactory to WebSocketTransport.");
11877
+ const Ctor = G.WebSocket;
11878
+ this.factory = (u) => new Ctor(u);
11812
11879
  }
11813
- this.onClose();
11814
- if (err) this.fail(err);
11815
- else if (final) this.settle(final);
11816
- if (this.waiter) {
11817
- const w = this.waiter;
11818
- this.waiter = null;
11819
- if (err) w.reject(err);
11820
- else w.resolve({
11821
- value: void 0,
11822
- done: true
11823
- });
11880
+ }
11881
+ get state() {
11882
+ if (!this.socket) return "closed";
11883
+ switch (this.socket.readyState) {
11884
+ case WS_CONNECTING: return "connecting";
11885
+ case WS_OPEN: return "open";
11886
+ case WS_CLOSING: return "closing";
11887
+ default: return "closed";
11824
11888
  }
11825
11889
  }
11826
- [_Symbol$asyncIterator]() {
11827
- return { next: () => {
11828
- if (this.queue.length > 0) return Promise.resolve({
11829
- value: this.queue.shift(),
11830
- done: false
11831
- });
11832
- if (this.done) {
11833
- if (this.error) return Promise.reject(this.error);
11834
- return Promise.resolve({
11890
+ connect() {
11891
+ if (this.socket && this.socket.readyState === WS_OPEN) return Promise.resolve();
11892
+ if (this.socket && this.socket.readyState !== WS_OPEN) {
11893
+ const stale = this.socket;
11894
+ this.socket = null;
11895
+ try {
11896
+ stale.close();
11897
+ } catch {}
11898
+ }
11899
+ return new Promise((resolve, reject) => {
11900
+ const socket = this.factory(this.url);
11901
+ this.socket = socket;
11902
+ let settled = false;
11903
+ const timer = this.connectTimeout > 0 ? setTimeout(() => {
11904
+ if (settled) return;
11905
+ settled = true;
11906
+ if (this.socket === socket) this.socket = null;
11907
+ try {
11908
+ socket.close();
11909
+ } catch {}
11910
+ reject(/* @__PURE__ */ new Error(`WebSocket connect to ${this.url} timed out after ${this.connectTimeout}ms`));
11911
+ }, this.connectTimeout) : void 0;
11912
+ socket.addEventListener("open", () => {
11913
+ if (this.socket !== socket) return;
11914
+ if (settled) return;
11915
+ settled = true;
11916
+ if (timer) clearTimeout(timer);
11917
+ resolve();
11918
+ });
11919
+ socket.addEventListener("error", (ev) => {
11920
+ if (this.socket !== socket) return;
11921
+ for (const h of this.errorHandlers) h(ev);
11922
+ if (!settled && this.state !== "open") {
11923
+ settled = true;
11924
+ if (timer) clearTimeout(timer);
11925
+ if (this.socket === socket) this.socket = null;
11926
+ try {
11927
+ socket.close();
11928
+ } catch {}
11929
+ reject(ev instanceof Error ? ev : /* @__PURE__ */ new Error("WebSocket connection error"));
11930
+ }
11931
+ });
11932
+ socket.addEventListener("close", (ev) => {
11933
+ if (this.socket !== socket) return;
11934
+ if (timer) clearTimeout(timer);
11935
+ for (const h of this.closeHandlers) h({
11936
+ code: ev.code,
11937
+ reason: ev.reason
11938
+ });
11939
+ });
11940
+ socket.addEventListener("message", (ev) => {
11941
+ if (this.socket !== socket) return;
11942
+ const data = typeof ev.data === "string" ? ev.data : String(ev.data);
11943
+ for (const h of this.messageHandlers) h(data);
11944
+ });
11945
+ });
11946
+ }
11947
+ send(data) {
11948
+ if (!this.socket || this.socket.readyState !== WS_OPEN) throw new Error(`Cannot send: transport is "${this.state}"`);
11949
+ this.socket.send(data);
11950
+ }
11951
+ close(code, reason) {
11952
+ this.socket?.close(code, reason);
11953
+ }
11954
+ onMessage(handler) {
11955
+ this.messageHandlers.add(handler);
11956
+ return () => this.messageHandlers.delete(handler);
11957
+ }
11958
+ onClose(handler) {
11959
+ this.closeHandlers.add(handler);
11960
+ return () => this.closeHandlers.delete(handler);
11961
+ }
11962
+ onError(handler) {
11963
+ this.errorHandlers.add(handler);
11964
+ return () => this.errorHandlers.delete(handler);
11965
+ }
11966
+ };
11967
+ //#endregion
11968
+ //#region node_modules/.pnpm/@smooai+smooth-operator@1.8.0/node_modules/@smooai/smooth-operator/dist/types.js
11969
+ /** Every server→client `type` discriminator value. */
11970
+ const EVENT_TYPES = [
11971
+ "immediate_response",
11972
+ "eventual_response",
11973
+ "stream_chunk",
11974
+ "stream_token",
11975
+ "keepalive",
11976
+ "write_confirmation_required",
11977
+ "otp_verification_required",
11978
+ "otp_sent",
11979
+ "otp_verified",
11980
+ "otp_invalid",
11981
+ "error",
11982
+ "pong"
11983
+ ];
11984
+ /** True if `frame` looks like any server event (has a known `type` discriminator). */
11985
+ function isServerEvent(frame) {
11986
+ return typeof frame === "object" && frame !== null && "type" in frame && typeof frame.type === "string" && EVENT_TYPES.includes(frame.type);
11987
+ }
11988
+ //#endregion
11989
+ //#region node_modules/.pnpm/@smooai+smooth-operator@1.8.0/node_modules/@smooai/smooth-operator/dist/client.js
11990
+ /**
11991
+ * SmoothAgentClient — a minimal, idiomatic, transport-agnostic client for the
11992
+ * smooth-operator WebSocket protocol.
11993
+ *
11994
+ * Design goals
11995
+ * ------------
11996
+ * - **Transport-agnostic.** The client never touches a real socket directly; it
11997
+ * talks to an injectable {@link Transport}. The default ({@link WebSocketTransport})
11998
+ * uses the global `WebSocket`, but tests inject a mock and Node can inject `ws`.
11999
+ * - **Request/response correlation by `requestId`.** Every action gets a generated
12000
+ * `requestId`; the client routes incoming events back to the originating call.
12001
+ * - **Streaming as an async iterator.** `sendMessage` returns a {@link MessageTurn}
12002
+ * that is both awaitable (resolves with the terminal `eventual_response`) and
12003
+ * async-iterable (yields each `stream_token` / `stream_chunk` / HITL event in
12004
+ * order). This models the `stream_token`/`stream_chunk` → `eventual_response`
12005
+ * flow without forcing a callback style on the caller.
12006
+ * - **No live server required.** Correctness is fully unit-testable with a mock
12007
+ * transport (see `test/client.test.ts`).
12008
+ */
12009
+ let _Symbol$asyncIterator;
12010
+ /** A timeout that yields no terminal event. */
12011
+ var RequestTimeoutError = class extends Error {
12012
+ constructor(requestId, ms) {
12013
+ super(`Request ${requestId} timed out after ${ms}ms`);
12014
+ this.name = "RequestTimeoutError";
12015
+ }
12016
+ };
12017
+ /**
12018
+ * A streaming turn that received no terminal `eventual_response` / `error` within the
12019
+ * configured {@link SmoothAgentClientOptions.turnTimeout}. The turn rejects with this
12020
+ * and its async iteration throws it, so a stuck server can never hang the caller.
12021
+ */
12022
+ var TurnTimeoutError = class extends Error {
12023
+ constructor(requestId, ms) {
12024
+ super(`Turn ${requestId} timed out after ${ms}ms without a terminal response`);
12025
+ _defineProperty(this, "requestId", void 0);
12026
+ this.name = "TurnTimeoutError";
12027
+ this.requestId = requestId;
12028
+ }
12029
+ };
12030
+ /** A protocol-level error event surfaced as a throwable. */
12031
+ var ProtocolError = class extends Error {
12032
+ constructor(code, message, requestId) {
12033
+ super(message);
12034
+ _defineProperty(this, "code", void 0);
12035
+ _defineProperty(this, "requestId", void 0);
12036
+ this.name = "ProtocolError";
12037
+ this.code = code;
12038
+ this.requestId = requestId;
12039
+ }
12040
+ };
12041
+ _Symbol$asyncIterator = Symbol.asyncIterator;
12042
+ /**
12043
+ * A streaming message turn. Await it for the terminal {@link EventualResponse},
12044
+ * or async-iterate it to receive every intermediate event in arrival order.
12045
+ *
12046
+ * ```ts
12047
+ * const turn = client.sendMessage({ sessionId, message: 'hi' });
12048
+ * for await (const ev of turn) {
12049
+ * if (ev.type === 'stream_token') process.stdout.write(ev.token ?? '');
12050
+ * }
12051
+ * const final = await turn; // EventualResponse
12052
+ * ```
12053
+ */
12054
+ var MessageTurn = class {
12055
+ constructor(requestId, onClose, turnTimeout = 0) {
12056
+ _defineProperty(
12057
+ this,
12058
+ /** The requestId this turn is correlated on. */
12059
+ "requestId",
12060
+ void 0
12061
+ );
12062
+ _defineProperty(this, "queue", []);
12063
+ _defineProperty(this, "waiter", null);
12064
+ _defineProperty(this, "done", false);
12065
+ _defineProperty(this, "finalEvent", null);
12066
+ _defineProperty(this, "error", null);
12067
+ _defineProperty(this, "settled", void 0);
12068
+ _defineProperty(this, "settle", void 0);
12069
+ _defineProperty(this, "fail", void 0);
12070
+ _defineProperty(this, "onClose", void 0);
12071
+ _defineProperty(this, "timeoutTimer", void 0);
12072
+ this.requestId = requestId;
12073
+ this.onClose = onClose;
12074
+ this.settled = new Promise((resolve, reject) => {
12075
+ this.settle = resolve;
12076
+ this.fail = reject;
12077
+ });
12078
+ this.settled.catch(() => {});
12079
+ if (turnTimeout > 0) this.timeoutTimer = setTimeout(() => {
12080
+ this.finish(null, new TurnTimeoutError(this.requestId, turnTimeout));
12081
+ }, turnTimeout);
12082
+ }
12083
+ /** Feed an event into the turn (called by the client's dispatcher). */
12084
+ push(event) {
12085
+ if (this.done) return;
12086
+ if (event.type === "error") {
12087
+ const code = event.data?.error?.code ?? "INTERNAL_ERROR";
12088
+ const message = event.data?.error?.message ?? "Unknown protocol error";
12089
+ this.deliver(event);
12090
+ this.finish(null, new ProtocolError(code, message, this.requestId));
12091
+ return;
12092
+ }
12093
+ this.deliver(event);
12094
+ if (event.type === "eventual_response") this.finish(event, null);
12095
+ }
12096
+ /** Force-close the turn (e.g. on disconnect) with an error. */
12097
+ abort(err) {
12098
+ if (this.done) return;
12099
+ this.finish(null, err);
12100
+ }
12101
+ deliver(event) {
12102
+ if (this.waiter) {
12103
+ const w = this.waiter;
12104
+ this.waiter = null;
12105
+ w.resolve({
12106
+ value: event,
12107
+ done: false
12108
+ });
12109
+ } else this.queue.push(event);
12110
+ }
12111
+ finish(final, err) {
12112
+ if (this.done) return;
12113
+ this.done = true;
12114
+ this.finalEvent = final;
12115
+ this.error = err;
12116
+ if (this.timeoutTimer) {
12117
+ clearTimeout(this.timeoutTimer);
12118
+ this.timeoutTimer = void 0;
12119
+ }
12120
+ this.onClose();
12121
+ if (err) this.fail(err);
12122
+ else if (final) this.settle(final);
12123
+ if (this.waiter) {
12124
+ const w = this.waiter;
12125
+ this.waiter = null;
12126
+ if (err) w.reject(err);
12127
+ else w.resolve({
12128
+ value: void 0,
12129
+ done: true
12130
+ });
12131
+ }
12132
+ }
12133
+ [_Symbol$asyncIterator]() {
12134
+ return { next: () => {
12135
+ if (this.queue.length > 0) return Promise.resolve({
12136
+ value: this.queue.shift(),
12137
+ done: false
12138
+ });
12139
+ if (this.done) {
12140
+ if (this.error) return Promise.reject(this.error);
12141
+ return Promise.resolve({
11835
12142
  value: void 0,
11836
12143
  done: true
11837
12144
  });
@@ -12986,492 +13293,187 @@ var SmoothAgentChat = (function(exports) {
12986
13293
  });
12987
13294
  break;
12988
13295
  case "write_confirmation_required":
12989
- this.setInterrupt({
12990
- kind: "confirm",
12991
- toolId: str(inner.toolId),
12992
- actionDescription: str(inner.actionDescription)
12993
- });
12994
- break;
12995
- default: break;
12996
- }
12997
- }
12998
- /**
12999
- * Begin the cross-device restore: POST `/internal/identity/request-otp` for
13000
- * `email` over `channel`. The view collects the email via an explicit affordance.
13001
- */
13002
- async requestIdentityOtp(email, channel = "email") {
13003
- const trimmed = email.trim();
13004
- if (!trimmed) return;
13005
- this.setIdentityRestore({
13006
- phase: "requesting",
13007
- email: trimmed,
13008
- channel
13009
- });
13010
- if (!this.sessionId) try {
13011
- await this.connect();
13012
- } catch {}
13013
- if (!this.sessionId) {
13014
- this.setIdentityRestore({
13015
- phase: "error",
13016
- message: "No active session to verify against."
13017
- });
13018
- return;
13019
- }
13020
- try {
13021
- const json = await this.postInternal("/internal/identity/request-otp", {
13022
- sessionId: this.sessionId,
13023
- email: trimmed,
13024
- channel
13025
- });
13026
- const masked = typeof json.maskedDestination === "string" ? json.maskedDestination : void 0;
13027
- this.setIdentityRestore({
13028
- phase: "awaiting_code",
13029
- email: trimmed,
13030
- channel,
13031
- maskedDestination: masked
13032
- });
13033
- } catch (err) {
13034
- this.setIdentityRestore({
13035
- phase: "error",
13036
- message: err instanceof Error ? err.message : "Could not send a verification code."
13037
- });
13038
- }
13039
- }
13040
- /** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */
13041
- async verifyIdentityOtp(code) {
13042
- const state = this.identityRestore;
13043
- const trimmed = code.trim();
13044
- if (!trimmed || state.phase !== "awaiting_code") return;
13045
- const { email, channel } = state;
13046
- if (!this.sessionId) {
13047
- this.setIdentityRestore({
13048
- phase: "error",
13049
- message: "No active session to verify against."
13050
- });
13051
- return;
13052
- }
13053
- this.setIdentityRestore({
13054
- phase: "verifying",
13055
- email,
13056
- channel
13057
- });
13058
- try {
13059
- const json = await this.postInternal("/internal/identity/verify-otp", {
13060
- sessionId: this.sessionId,
13061
- email,
13062
- code: trimmed
13063
- });
13064
- if (json.event === "otp_verified") {
13065
- this.store.getState().setVerifiedEmail(email, this.sessionId);
13066
- await this.resolveIdentity(email);
13067
- } else if (json.event === "otp_invalid") {
13068
- const remaining = typeof json.attemptsRemaining === "number" ? json.attemptsRemaining : void 0;
13069
- this.setIdentityRestore({
13070
- phase: "awaiting_code",
13071
- email,
13072
- channel,
13073
- error: "That code was incorrect.",
13074
- attemptsRemaining: remaining
13075
- });
13076
- } else this.setIdentityRestore({
13077
- phase: "error",
13078
- message: "Verification failed."
13079
- });
13080
- } catch (err) {
13081
- this.setIdentityRestore({
13082
- phase: "error",
13083
- message: err instanceof Error ? err.message : "Verification failed."
13084
- });
13085
- }
13086
- }
13087
- /** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */
13088
- async resolveIdentity(email) {
13089
- if (!this.sessionId) return;
13090
- this.setIdentityRestore({
13091
- phase: "resolving",
13092
- email
13093
- });
13094
- try {
13095
- const json = await this.postInternal("/internal/identity/resolve", {
13096
- sessionId: this.sessionId,
13097
- email
13098
- });
13099
- if (json.resolved !== true) {
13100
- this.setIdentityRestore({
13101
- phase: "resolved",
13102
- email,
13103
- conversations: []
13104
- });
13105
- return;
13106
- }
13107
- const raw = json.conversations;
13108
- const conversations = Array.isArray(raw) ? raw.map((c) => {
13109
- if (!c || typeof c !== "object") return null;
13110
- const o = c;
13111
- const conversationId = typeof o.conversationId === "string" ? o.conversationId : "";
13112
- const sessionId = typeof o.sessionId === "string" ? o.sessionId : "";
13113
- if (!sessionId) return null;
13114
- return {
13115
- conversationId,
13116
- sessionId,
13117
- lastActivityAt: typeof o.lastActivityAt === "string" ? o.lastActivityAt : void 0,
13118
- preview: typeof o.preview === "string" ? o.preview : void 0
13119
- };
13120
- }).filter((c) => c !== null) : [];
13121
- this.setIdentityRestore({
13122
- phase: "resolved",
13123
- email,
13124
- conversations
13125
- });
13126
- } catch (err) {
13127
- this.setIdentityRestore({
13128
- phase: "error",
13129
- message: err instanceof Error ? err.message : "Could not load your chats."
13130
- });
13131
- }
13132
- }
13133
- /**
13134
- * Replay a chosen restorable conversation: point the live session at its
13135
- * sessionId, hydrate its transcript (get_session + get_messages), and persist
13136
- * the new pointer so the next `sendMessage` continues it.
13137
- */
13138
- async restoreConversation(sessionId) {
13139
- if (!this.client) await this.ensureClient();
13140
- const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
13141
- if (await this.tryResume(sessionId)) {
13142
- this.store.getState().setSessionId(sessionId);
13143
- if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
13144
- this.setIdentityRestore({ phase: "idle" });
13145
- this.setStatus("ready");
13146
- } else this.setIdentityRestore({
13147
- phase: "error",
13148
- message: "That conversation is no longer available."
13149
- });
13150
- }
13151
- /** Dismiss the cross-device restore panel. */
13152
- cancelIdentityRestore() {
13153
- this.setIdentityRestore({ phase: "idle" });
13154
- }
13155
- /** Tear down the underlying client. */
13156
- disconnect() {
13157
- this.client?.disconnect("widget closed");
13158
- this.client = null;
13159
- this.sessionId = null;
13160
- this.activeRequestId = null;
13161
- this.resumeAttempted = false;
13162
- this.setInterrupt(null);
13163
- this.setStatus("closed");
13164
- }
13165
- };
13166
- //#endregion
13167
- //#region src/logo.ts
13168
- /**
13169
- * The Smooth logo, inlined as an SVG string so the full-page header can render
13170
- * it without a separate network fetch (the IIFE bundle is self-contained).
13171
- *
13172
- * GENERATED from `assets/smooth-logo.svg` — do not edit by hand. Regenerate with:
13173
- * node -e ... (see the commit that added this file)
13174
- */
13175
- const SMOOTH_LOGO_SVG = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg id=\"Layer_1\" data-name=\"Layer 1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 550 135\">\n <defs>\n <style>\n .cls-1 {\n fill: url(#linear-gradient-3);\n }\n\n .cls-2 {\n fill: url(#linear-gradient-2);\n }\n\n .cls-3 {\n fill: url(#linear-gradient);\n fill-rule: evenodd;\n }\n </style>\n <linearGradient id=\"linear-gradient\" x1=\"115.59\" y1=\"112.81\" x2=\"25.08\" y2=\"22.3\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".3\" stop-color=\"#f49f0a\"/>\n <stop offset=\".79\" stop-color=\"#fb7a4d\"/>\n <stop offset=\"1\" stop-color=\"#ff6b6c\"/>\n </linearGradient>\n <linearGradient id=\"linear-gradient-2\" x1=\"360.91\" y1=\"152.01\" x2=\"202.32\" y2=\"-6.59\" xlink:href=\"#linear-gradient\"/>\n <linearGradient id=\"linear-gradient-3\" x1=\"443.91\" y1=\"30.15\" x2=\"531.36\" y2=\"117.59\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".43\" stop-color=\"#00a6a6\"/>\n <stop offset=\"1\" stop-color=\"#1238dd\"/>\n </linearGradient>\n </defs>\n <path class=\"cls-3\" d=\"M48.28,14.96c-12.39,5.21-22.54,14.64-28.65,26.61-6.12,11.97-7.8,25.72-4.77,38.81,3.04,13.09,10.6,24.69,21.36,32.75,10.76,8.06,24.02,12.05,37.44,11.28,13.42-.77,26.13-6.26,35.9-15.5,9.76-9.24,15.95-21.63,17.46-34.99,1.51-13.36-1.74-26.82-9.19-38.01-1.07-1.61-.64-3.78.97-4.85,1.61-1.07,3.78-.64,4.85.97,8.36,12.56,12.02,27.68,10.32,42.67-1.7,15-8.64,28.91-19.61,39.28-10.96,10.37-25.24,16.54-40.31,17.4-15.07.87-29.96-3.62-42.04-12.66-12.08-9.05-20.58-22.07-23.99-36.77-3.41-14.7-1.51-30.14,5.35-43.58,6.87-13.44,18.26-24.02,32.17-29.87,13.91-5.85,29.44-6.6,43.85-2.11,1.85.57,2.88,2.54,2.3,4.38-.57,1.85-2.54,2.88-4.38,2.3-12.83-4-26.67-3.33-39.06,1.88ZM111.39,19.75c0,2.07-1.68,3.75-3.75,3.75s-3.75-1.68-3.75-3.75,1.68-3.75,3.75-3.75,3.75,1.68,3.75,3.75ZM64.64,59.93c0,1.91,2.39,2.56,7.69,3.88,3.89.97,6.6,2.18,8.15,3.63,1.53,1.45,2.29,3.53,2.29,6.25,0,3.57-1.03,6.26-3.11,8.08-2.07,1.82-5.09,2.73-9.09,2.73h-9.6c-1.97,0-3.57-1.6-3.59-3.57-.01-1.99,1.6-3.61,3.59-3.61h9.41c3.15-.12,4.79-.95,4.91-2.47,0-1.3-1.03-2.21-3.07-2.73-6.91-1.72-11.11-3.44-12.6-5.15-1.48-1.71-2.23-3.77-2.23-6.19,0-6.59,3.2-9.85,9.59-9.8h10.77c1.99,0,3.6,1.61,3.6,3.59s-1.61,3.59-3.6,3.59h-9.69c-1.83,0-3.43.06-3.43,1.77Z\"/>\n <path class=\"cls-2\" d=\"M205.52,48.44h-8.86c-.44-3.75-2.23-6.65-5.38-8.72-3.16-2.07-7.03-3.1-11.6-3.1h0c-3.35,0-6.27.54-8.78,1.62-2.49,1.09-4.44,2.59-5.84,4.48-1.39,1.89-2.08,4.05-2.08,6.46h0c0,2.01.49,3.75,1.46,5.2.97,1.44,2.22,2.63,3.74,3.58,1.53.95,3.13,1.72,4.8,2.32,1.68.6,3.22,1.09,4.62,1.46h0l7.68,2.06c1.97.52,4.17,1.23,6.6,2.14,2.43.92,4.75,2.16,6.98,3.72,2.23,1.56,4.07,3.56,5.52,6,1.45,2.44,2.18,5.43,2.18,8.98h0c0,4.08-1.07,7.77-3.2,11.08-2.12,3.29-5.22,5.91-9.3,7.86-4.08,1.95-9.02,2.92-14.82,2.92h0c-5.43,0-10.11-.87-14.06-2.62-3.95-1.75-7.05-4.19-9.3-7.32-2.25-3.12-3.53-6.75-3.84-10.88h9.46c.25,2.85,1.22,5.21,2.9,7.06,1.69,1.87,3.83,3.25,6.42,4.14,2.6.89,5.41,1.34,8.42,1.34h0c3.49,0,6.63-.57,9.4-1.72,2.79-1.13,4.99-2.73,6.62-4.8,1.63-2.05,2.44-4.46,2.44-7.22h0c0-2.51-.7-4.55-2.1-6.12-1.41-1.57-3.26-2.85-5.54-3.84-2.29-.99-4.77-1.85-7.44-2.58h0l-9.3-2.66c-5.91-1.71-10.59-4.13-14.04-7.28-3.44-3.16-5.16-7.29-5.16-12.38h0c0-4.23,1.15-7.93,3.46-11.1,2.29-3.16,5.39-5.62,9.3-7.38,3.91-1.76,8.27-2.64,13.08-2.64h0c4.88,0,9.21.87,13,2.6,3.8,1.73,6.81,4.11,9.04,7.12,2.23,3,3.4,6.41,3.52,10.22h0ZM229.16,105.18h-8.72v-56.74h8.42v8.86h.74c1.19-3.03,3.1-5.38,5.74-7.06,2.63-1.69,5.79-2.54,9.48-2.54h0c3.75,0,6.87.85,9.36,2.54,2.51,1.68,4.46,4.03,5.86,7.06h.58c1.45-2.92,3.63-5.25,6.54-7,2.91-1.73,6.39-2.6,10.46-2.6h0c5.07,0,9.21,1.58,12.44,4.74,3.23,3.17,4.84,8.09,4.84,14.76h0v37.98h-8.72v-37.98c0-4.19-1.14-7.18-3.42-8.98-2.29-1.79-4.99-2.68-8.1-2.68h0c-3.99,0-7.07,1.2-9.26,3.6-2.2,2.4-3.3,5.43-3.3,9.1h0v36.94h-8.86v-38.86c0-3.23-1.05-5.83-3.14-7.82-2.09-1.97-4.79-2.96-8.08-2.96h0c-2.27,0-4.38.6-6.34,1.8-1.96,1.21-3.53,2.88-4.72,5-1.2,2.13-1.8,4.59-1.8,7.38h0v35.46ZM333.9,106.36h0c-5.12,0-9.61-1.22-13.46-3.66-3.85-2.44-6.86-5.85-9.02-10.24-2.15-4.37-3.22-9.49-3.22-15.36h0c0-5.91,1.07-11.07,3.22-15.48,2.16-4.4,5.17-7.82,9.02-10.26,3.85-2.44,8.34-3.66,13.46-3.66h0c5.12,0,9.61,1.22,13.46,3.66,3.85,2.44,6.86,5.86,9.02,10.26,2.15,4.41,3.22,9.57,3.22,15.48h0c0,5.87-1.07,10.99-3.22,15.36-2.16,4.39-5.17,7.8-9.02,10.24-3.85,2.44-8.34,3.66-13.46,3.66ZM333.9,98.52h0c3.89,0,7.09-.99,9.6-2.98,2.52-2,4.38-4.63,5.58-7.88,1.21-3.25,1.82-6.77,1.82-10.56h0c0-3.79-.61-7.32-1.82-10.6-1.2-3.27-3.06-5.91-5.58-7.94-2.51-2.01-5.71-3.02-9.6-3.02h0c-3.89,0-7.09,1.01-9.6,3.02-2.51,2.03-4.37,4.67-5.58,7.94-1.2,3.28-1.8,6.81-1.8,10.6h0c0,3.79.6,7.31,1.8,10.56,1.21,3.25,3.07,5.88,5.58,7.88,2.51,1.99,5.71,2.98,9.6,2.98ZM395.94,106.36h0c-5.12,0-9.61-1.22-13.46-3.66-3.85-2.44-6.85-5.85-9-10.24-2.16-4.37-3.24-9.49-3.24-15.36h0c0-5.91,1.08-11.07,3.24-15.48,2.15-4.4,5.15-7.82,9-10.26,3.85-2.44,8.34-3.66,13.46-3.66h0c5.12,0,9.61,1.22,13.46,3.66,3.85,2.44,6.86,5.86,9.02,10.26,2.16,4.41,3.24,9.57,3.24,15.48h0c0,5.87-1.08,10.99-3.24,15.36-2.16,4.39-5.17,7.8-9.02,10.24-3.85,2.44-8.34,3.66-13.46,3.66ZM395.94,98.52h0c3.89,0,7.09-.99,9.6-2.98,2.52-2,4.38-4.63,5.58-7.88,1.21-3.25,1.82-6.77,1.82-10.56h0c0-3.79-.61-7.32-1.82-10.6-1.2-3.27-3.06-5.91-5.58-7.94-2.51-2.01-5.71-3.02-9.6-3.02h0c-3.88,0-7.08,1.01-9.6,3.02-2.51,2.03-4.37,4.67-5.58,7.94-1.2,3.28-1.8,6.81-1.8,10.6h0c0,3.79.6,7.31,1.8,10.56,1.21,3.25,3.07,5.88,5.58,7.88,2.52,1.99,5.72,2.98,9.6,2.98Z\"/>\n <path class=\"cls-1\" d=\"M467.88,48.02v13.28h-35.79v-13.28h35.79ZM439.68,34.38h17.89v53.42c0,1.5.36,2.62,1.08,3.36.72.74,1.88,1.1,3.49,1.1.62,0,1.48-.07,2.59-.21,1.11-.14,1.91-.27,2.38-.41l2.31,13.02c-2.02.58-3.97.97-5.84,1.18-1.88.21-3.66.31-5.33.31-6.08,0-10.7-1.43-13.84-4.28-3.15-2.85-4.72-7.01-4.72-12.48v-55.01ZM506.59,72.63v32.71h-17.89V28.95h17.53v33.53h-1.13c1.4-4.55,3.6-8.21,6.59-11,2.99-2.79,7.01-4.18,12.07-4.18,4,0,7.48.89,10.46,2.67,2.97,1.78,5.28,4.29,6.92,7.54,1.64,3.25,2.46,7.02,2.46,11.33v36.5h-17.89v-33.02c0-3.21-.82-5.73-2.46-7.56-1.64-1.83-3.93-2.74-6.87-2.74-1.92,0-3.62.42-5.1,1.26-1.49.84-2.64,2.04-3.46,3.61-.82,1.57-1.23,3.49-1.23,5.74Z\"/>\n</svg>";
13176
- //#endregion
13177
- //#region src/markdown.ts
13178
- /**
13179
- * A tiny, safe-by-default Markdown → HTML renderer for the chat widget.
13180
- *
13181
- * ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
13182
- *
13183
- * The widget renders **untrusted** text in two places: the assistant's reply
13184
- * (LLM output, which can echo attacker-supplied content) and citation snippets
13185
- * (raw scraped page chunks). Today both are written via `textContent`, so
13186
- * `**bold**`, numbered lists, and `[links](url)` show up literally. We want
13187
- * them rendered — without re-opening the XSS hole that `textContent` was
13188
- * guarding against.
13189
- *
13190
- * markdown-it with `html:false` is safe-by-default but ships ~30 kB min into
13191
- * what is an embeddable **global** bundle, where every kilobyte is on the host
13192
- * page's critical path. snarkdown is ~1 kB but emits raw HTML, so it would
13193
- * require bolting on a separate sanitizer. Instead, this renderer is
13194
- * **safe-by-construction**:
13195
- *
13196
- * 1. It is a *tokenizer*, not an HTML passthrough. It only ever emits a
13197
- * fixed allowlist of tags (`p`, `br`, `strong`, `em`, `ul`/`ol`/`li`,
13198
- * `code`/`pre`, `a`, `blockquote`). There is no code path that copies a
13199
- * tag out of the input — a literal `<script>` in the input is treated as
13200
- * plain text.
13201
- * 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
13202
- * reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
13203
- * 3. **Images are dropped entirely** — `![alt](src)` renders as its alt text,
13204
- * no `<img>` is ever produced (a scraped tracking pixel must not load).
13205
- * 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
13206
- * URLs become anchors (with `target="_blank"` + a hardened `rel`);
13207
- * `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
13208
- * 5. **Headings** (`#`..`######`) are *downgraded* to bold lines — a full
13209
- * `<h1>` is far too large inside a chat bubble or citation card.
13210
- *
13211
- * The output is a string of HTML that is only ever assigned to `innerHTML` of
13212
- * an element the caller controls; because of (1)–(4) it can only contain the
13213
- * allowlisted, attribute-sanitized tags.
13214
- *
13215
- * Supported subset (deliberately small):
13216
- * - Paragraphs (blank-line separated) and hard/soft line breaks
13217
- * - `**bold**` / `__bold__`, `*italic*` / `_italic_`
13218
- * - `` `inline code` `` and fenced ``` ```code blocks``` ```
13219
- * - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
13220
- * - `> ` blockquotes
13221
- * - `[text](http(s)://url)` links (images dropped to alt text)
13222
- * - `#`..`######` headings → bold line
13223
- */
13224
- /** Escape the five HTML-significant characters so a text run can never be markup. */
13225
- function escapeHtml(value) {
13226
- return value.replace(/[&<>"']/g, (c) => {
13227
- switch (c) {
13228
- case "&": return "&amp;";
13229
- case "<": return "&lt;";
13230
- case ">": return "&gt;";
13231
- case "\"": return "&quot;";
13232
- default: return "&#39;";
13233
- }
13234
- });
13235
- }
13236
- /**
13237
- * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
13238
- *
13239
- * SECURITY: link targets here originate from untrusted content (LLM output /
13240
- * scraped citation chunks). Allowing an arbitrary string as an `href` permits
13241
- * `javascript:`/`data:`/`vbscript:` URLs that execute on click — a stored-XSS
13242
- * vector. Only absolute http(s) links are rendered as anchors; anything else
13243
- * falls back to plain text upstream.
13244
- */
13245
- function safeHttpUrl(url) {
13246
- if (!url) return null;
13247
- try {
13248
- const parsed = new URL(url);
13249
- return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
13250
- } catch {
13251
- return null;
13252
- }
13253
- }
13254
- /**
13255
- * Render the inline span grammar of a single line/segment to safe HTML.
13256
- *
13257
- * Order matters: code spans are extracted first (their contents are *not*
13258
- * further parsed), then images are stripped to alt text, then links, then
13259
- * emphasis. Every literal text run is escaped on the way out.
13260
- */
13261
- function renderInline(input) {
13262
- let out = "";
13263
- let i = 0;
13264
- const n = input.length;
13265
- let buf = "";
13266
- const flush = () => {
13267
- if (buf) {
13268
- out += escapeHtml(buf);
13269
- buf = "";
13270
- }
13271
- };
13272
- while (i < n) {
13273
- const ch = input[i];
13274
- if (ch === "`") {
13275
- const end = input.indexOf("`", i + 1);
13276
- if (end > i) {
13277
- flush();
13278
- out += `<code>${escapeHtml(input.slice(i + 1, end))}</code>`;
13279
- i = end + 1;
13280
- continue;
13281
- }
13282
- }
13283
- if (ch === "!" && input[i + 1] === "[") {
13284
- const m = imageAt(input, i);
13285
- if (m) {
13286
- flush();
13287
- out += renderInline(m.alt);
13288
- i = m.end;
13289
- continue;
13290
- }
13291
- }
13292
- if (ch === "[") {
13293
- const m = linkAt(input, i);
13294
- if (m) {
13295
- flush();
13296
- const safe = safeHttpUrl(m.href);
13297
- const inner = renderInline(m.text);
13298
- if (safe) out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer nofollow">${inner}</a>`;
13299
- else out += inner;
13300
- i = m.end;
13301
- continue;
13302
- }
13303
- }
13304
- if (ch === "*" && input[i + 1] === "*" || ch === "_" && input[i + 1] === "_") {
13305
- const marker = ch + ch;
13306
- const end = input.indexOf(marker, i + 2);
13307
- if (end > i + 1) {
13308
- flush();
13309
- out += `<strong>${renderInline(input.slice(i + 2, end))}</strong>`;
13310
- i = end + 2;
13311
- continue;
13312
- }
13313
- }
13314
- if (ch === "*" || ch === "_") {
13315
- const end = input.indexOf(ch, i + 1);
13316
- if (end > i + 1 && input[i + 1] !== ch) {
13317
- flush();
13318
- out += `<em>${renderInline(input.slice(i + 1, end))}</em>`;
13319
- i = end + 1;
13320
- continue;
13321
- }
13322
- }
13323
- buf += ch;
13324
- i++;
13325
- }
13326
- flush();
13327
- return out;
13328
- }
13329
- /** Parse a `[text](href)` link starting at `start` (`input[start] === '['`). */
13330
- function linkAt(input, start) {
13331
- const close = matchBracket(input, start);
13332
- if (close < 0 || input[close + 1] !== "(") return null;
13333
- const paren = input.indexOf(")", close + 2);
13334
- if (paren < 0) return null;
13335
- return {
13336
- text: input.slice(start + 1, close),
13337
- href: input.slice(close + 2, paren).trim().split(/\s+/)[0] ?? "",
13338
- end: paren + 1
13339
- };
13340
- }
13341
- /** Parse a `![alt](src)` image starting at `start` (`input[start] === '!'`). */
13342
- function imageAt(input, start) {
13343
- const link = linkAt(input, start + 1);
13344
- if (!link) return null;
13345
- return {
13346
- alt: link.text,
13347
- end: link.end
13348
- };
13349
- }
13350
- /** Find the matching `]` for a `[` at `open`, honoring one level of nesting. */
13351
- function matchBracket(input, open) {
13352
- let depth = 0;
13353
- for (let i = open; i < input.length; i++) {
13354
- const c = input[i];
13355
- if (c === "[") depth++;
13356
- else if (c === "]") {
13357
- depth--;
13358
- if (depth === 0) return i;
13359
- }
13360
- }
13361
- return -1;
13362
- }
13363
- const UL_RE = /^\s*[-*+]\s+(.*)$/;
13364
- const OL_RE = /^\s*\d+[.)]\s+(.*)$/;
13365
- const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*)$/;
13366
- const QUOTE_RE = /^\s*>\s?(.*)$/;
13367
- const FENCE_RE = /^\s*(`{3,}|~{3,})\s*(.*)$/;
13368
- /**
13369
- * Render a full Markdown string to safe HTML.
13370
- *
13371
- * @returns a string containing only the allowlisted tags described in the
13372
- * module doc. Safe to assign to `innerHTML` of a caller-owned element.
13373
- */
13374
- function renderMarkdown(src) {
13375
- const lines = src.replace(/\r\n?/g, "\n").split("\n");
13376
- const out = [];
13377
- let i = 0;
13378
- while (i < lines.length) {
13379
- const line = lines[i];
13380
- const fence = FENCE_RE.exec(line);
13381
- if (fence) {
13382
- const marker = fence[1];
13383
- const body = [];
13384
- i++;
13385
- while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
13386
- body.push(lines[i]);
13387
- i++;
13388
- }
13389
- if (i < lines.length) i++;
13390
- out.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
13391
- continue;
13296
+ this.setInterrupt({
13297
+ kind: "confirm",
13298
+ toolId: str(inner.toolId),
13299
+ actionDescription: str(inner.actionDescription)
13300
+ });
13301
+ break;
13302
+ default: break;
13392
13303
  }
13393
- if (line.trim() === "") {
13394
- i++;
13395
- continue;
13304
+ }
13305
+ /**
13306
+ * Begin the cross-device restore: POST `/internal/identity/request-otp` for
13307
+ * `email` over `channel`. The view collects the email via an explicit affordance.
13308
+ */
13309
+ async requestIdentityOtp(email, channel = "email") {
13310
+ const trimmed = email.trim();
13311
+ if (!trimmed) return;
13312
+ this.setIdentityRestore({
13313
+ phase: "requesting",
13314
+ email: trimmed,
13315
+ channel
13316
+ });
13317
+ if (!this.sessionId) try {
13318
+ await this.connect();
13319
+ } catch {}
13320
+ if (!this.sessionId) {
13321
+ this.setIdentityRestore({
13322
+ phase: "error",
13323
+ message: "No active session to verify against."
13324
+ });
13325
+ return;
13396
13326
  }
13397
- const heading = HEADING_RE.exec(line);
13398
- if (heading) {
13399
- out.push(`<p><strong>${renderInline(heading[2])}</strong></p>`);
13400
- i++;
13401
- continue;
13327
+ try {
13328
+ const json = await this.postInternal("/internal/identity/request-otp", {
13329
+ sessionId: this.sessionId,
13330
+ email: trimmed,
13331
+ channel
13332
+ });
13333
+ const masked = typeof json.maskedDestination === "string" ? json.maskedDestination : void 0;
13334
+ this.setIdentityRestore({
13335
+ phase: "awaiting_code",
13336
+ email: trimmed,
13337
+ channel,
13338
+ maskedDestination: masked
13339
+ });
13340
+ } catch (err) {
13341
+ this.setIdentityRestore({
13342
+ phase: "error",
13343
+ message: err instanceof Error ? err.message : "Could not send a verification code."
13344
+ });
13402
13345
  }
13403
- if (UL_RE.test(line) || OL_RE.test(line)) {
13404
- const ordered = OL_RE.test(line) && !UL_RE.test(line);
13405
- const re = ordered ? OL_RE : UL_RE;
13406
- const items = [];
13407
- while (i < lines.length) {
13408
- const m = re.exec(lines[i]);
13409
- if (!m) break;
13410
- items.push(`<li>${renderInline(m[1])}</li>`);
13411
- i++;
13412
- }
13413
- out.push(`<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`);
13414
- continue;
13346
+ }
13347
+ /** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */
13348
+ async verifyIdentityOtp(code) {
13349
+ const state = this.identityRestore;
13350
+ const trimmed = code.trim();
13351
+ if (!trimmed || state.phase !== "awaiting_code") return;
13352
+ const { email, channel } = state;
13353
+ if (!this.sessionId) {
13354
+ this.setIdentityRestore({
13355
+ phase: "error",
13356
+ message: "No active session to verify against."
13357
+ });
13358
+ return;
13415
13359
  }
13416
- if (QUOTE_RE.test(line)) {
13417
- const quoted = [];
13418
- while (i < lines.length) {
13419
- const m = QUOTE_RE.exec(lines[i]);
13420
- if (!m) break;
13421
- quoted.push(m[1]);
13422
- i++;
13423
- }
13424
- out.push(`<blockquote>${renderInline(quoted.join("\n")).replace(/\n/g, "<br>")}</blockquote>`);
13425
- continue;
13360
+ this.setIdentityRestore({
13361
+ phase: "verifying",
13362
+ email,
13363
+ channel
13364
+ });
13365
+ try {
13366
+ const json = await this.postInternal("/internal/identity/verify-otp", {
13367
+ sessionId: this.sessionId,
13368
+ email,
13369
+ code: trimmed
13370
+ });
13371
+ if (json.event === "otp_verified") {
13372
+ this.store.getState().setVerifiedEmail(email, this.sessionId);
13373
+ await this.resolveIdentity(email);
13374
+ } else if (json.event === "otp_invalid") {
13375
+ const remaining = typeof json.attemptsRemaining === "number" ? json.attemptsRemaining : void 0;
13376
+ this.setIdentityRestore({
13377
+ phase: "awaiting_code",
13378
+ email,
13379
+ channel,
13380
+ error: "That code was incorrect.",
13381
+ attemptsRemaining: remaining
13382
+ });
13383
+ } else this.setIdentityRestore({
13384
+ phase: "error",
13385
+ message: "Verification failed."
13386
+ });
13387
+ } catch (err) {
13388
+ this.setIdentityRestore({
13389
+ phase: "error",
13390
+ message: err instanceof Error ? err.message : "Verification failed."
13391
+ });
13426
13392
  }
13427
- const para = [];
13428
- while (i < lines.length) {
13429
- const l = lines[i];
13430
- if (l.trim() === "" || FENCE_RE.test(l) || HEADING_RE.test(l) || UL_RE.test(l) || OL_RE.test(l) || QUOTE_RE.test(l)) break;
13431
- para.push(l);
13432
- i++;
13393
+ }
13394
+ /** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */
13395
+ async resolveIdentity(email) {
13396
+ if (!this.sessionId) return;
13397
+ this.setIdentityRestore({
13398
+ phase: "resolving",
13399
+ email
13400
+ });
13401
+ try {
13402
+ const json = await this.postInternal("/internal/identity/resolve", {
13403
+ sessionId: this.sessionId,
13404
+ email
13405
+ });
13406
+ if (json.resolved !== true) {
13407
+ this.setIdentityRestore({
13408
+ phase: "resolved",
13409
+ email,
13410
+ conversations: []
13411
+ });
13412
+ return;
13413
+ }
13414
+ const raw = json.conversations;
13415
+ const conversations = Array.isArray(raw) ? raw.map((c) => {
13416
+ if (!c || typeof c !== "object") return null;
13417
+ const o = c;
13418
+ const conversationId = typeof o.conversationId === "string" ? o.conversationId : "";
13419
+ const sessionId = typeof o.sessionId === "string" ? o.sessionId : "";
13420
+ if (!sessionId) return null;
13421
+ return {
13422
+ conversationId,
13423
+ sessionId,
13424
+ lastActivityAt: typeof o.lastActivityAt === "string" ? o.lastActivityAt : void 0,
13425
+ preview: typeof o.preview === "string" ? o.preview : void 0
13426
+ };
13427
+ }).filter((c) => c !== null) : [];
13428
+ this.setIdentityRestore({
13429
+ phase: "resolved",
13430
+ email,
13431
+ conversations
13432
+ });
13433
+ } catch (err) {
13434
+ this.setIdentityRestore({
13435
+ phase: "error",
13436
+ message: err instanceof Error ? err.message : "Could not load your chats."
13437
+ });
13433
13438
  }
13434
- out.push(`<p>${renderInline(para.join("\n")).replace(/\n/g, "<br>")}</p>`);
13435
13439
  }
13436
- return out.join("");
13437
- }
13438
- const SNIPPET_MAX = 260;
13439
- /**
13440
- * Clean a raw scraped citation snippet into a short, readable excerpt.
13441
- *
13442
- * Scraped chunks frequently begin with page boilerplate — a logo image wrapped
13443
- * in a link, standalone nav, repeated whitespace — e.g.
13444
- * `[![Logo](…)]() # Our Work We build…`. The source itself is already linked
13445
- * from the citation card, so the snippet only needs to be a clean teaser.
13446
- *
13447
- * Steps:
13448
- * 1. Strip a leading image / logo-link (`[![…](…)](…)` or `![…](…)`).
13449
- * 2. Drop a leading standalone heading marker (`#`/`##`).
13450
- * 3. Collapse all runs of whitespace to single spaces.
13451
- * 4. Truncate to ~{@link SNIPPET_MAX} chars at a word boundary, adding `…`.
13452
- *
13453
- * The result is still rendered through {@link renderMarkdown} downstream, so any
13454
- * remaining inline markup (bold/links) stays safe.
13455
- */
13456
- function cleanCitationSnippet(raw) {
13457
- let s = raw ?? "";
13458
- let changed = true;
13459
- while (changed) {
13460
- changed = false;
13461
- const before = s;
13462
- s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, "");
13463
- s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, "");
13464
- s = s.replace(/^\s*#{1,6}\s+/, "");
13465
- if (s !== before) changed = true;
13440
+ /**
13441
+ * Replay a chosen restorable conversation: point the live session at its
13442
+ * sessionId, hydrate its transcript (get_session + get_messages), and persist
13443
+ * the new pointer so the next `sendMessage` continues it.
13444
+ */
13445
+ async restoreConversation(sessionId) {
13446
+ if (!this.client) await this.ensureClient();
13447
+ const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
13448
+ if (await this.tryResume(sessionId)) {
13449
+ this.store.getState().setSessionId(sessionId);
13450
+ if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
13451
+ this.setIdentityRestore({ phase: "idle" });
13452
+ this.setStatus("ready");
13453
+ } else this.setIdentityRestore({
13454
+ phase: "error",
13455
+ message: "That conversation is no longer available."
13456
+ });
13466
13457
  }
13467
- s = s.replace(/\s+/g, " ").trim();
13468
- if (s.length > SNIPPET_MAX) {
13469
- const cut = s.slice(0, SNIPPET_MAX);
13470
- const lastSpace = cut.lastIndexOf(" ");
13471
- s = (lastSpace > SNIPPET_MAX * .6 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
13458
+ /** Dismiss the cross-device restore panel. */
13459
+ cancelIdentityRestore() {
13460
+ this.setIdentityRestore({ phase: "idle" });
13472
13461
  }
13473
- return s;
13474
- }
13462
+ /** Tear down the underlying client. */
13463
+ disconnect() {
13464
+ this.client?.disconnect("widget closed");
13465
+ this.client = null;
13466
+ this.sessionId = null;
13467
+ this.activeRequestId = null;
13468
+ this.resumeAttempted = false;
13469
+ this.setInterrupt(null);
13470
+ this.setStatus("closed");
13471
+ }
13472
+ };
13473
+ //#endregion
13474
+ //#region src/logo.ts
13475
+ /** The square Smooth icon (the stylized `th` glyph) — used as the default full-page header avatar. */
13476
+ const SMOOTH_ICON_SVG = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg id=\"Layer_1\" data-name=\"Layer 1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 150 150\">\n <defs>\n <style>\n .cls-1 {\n fill: url(#linear-gradient);\n }\n </style>\n <linearGradient id=\"linear-gradient\" x1=\"31.06\" y1=\"37.6\" x2=\"118.5\" y2=\"125.04\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".43\" stop-color=\"#00a6a6\"/>\n <stop offset=\"1\" stop-color=\"#1238dd\"/>\n </linearGradient>\n </defs>\n <path class=\"cls-1\" d=\"M55.03,55.47v13.28H19.24v-13.28h35.79ZM26.83,41.83h17.89v53.42c0,1.5.36,2.62,1.08,3.36.72.74,1.88,1.1,3.49,1.1.62,0,1.48-.07,2.59-.21,1.11-.14,1.91-.27,2.38-.41l2.31,13.02c-2.02.58-3.97.97-5.84,1.18-1.88.21-3.66.31-5.33.31-6.08,0-10.7-1.43-13.84-4.28-3.15-2.85-4.72-7.01-4.72-12.48v-55.01ZM93.74,80.08v32.71h-17.89V36.39h17.53v33.53h-1.13c1.4-4.55,3.6-8.21,6.59-11,2.99-2.79,7.01-4.18,12.07-4.18,4,0,7.48.89,10.46,2.67,2.97,1.78,5.28,4.29,6.92,7.54,1.64,3.25,2.46,7.02,2.46,11.33v36.5h-17.89v-33.02c0-3.21-.82-5.73-2.46-7.56-1.64-1.83-3.93-2.74-6.87-2.74-1.92,0-3.62.42-5.1,1.26-1.49.84-2.64,2.04-3.46,3.61-.82,1.57-1.23,3.49-1.23,5.74Z\"/>\n</svg>";
13475
13477
  //#endregion
13476
13478
  //#region src/styles.ts
13477
13479
  /**
@@ -13516,11 +13518,11 @@ var SmoothAgentChat = (function(exports) {
13516
13518
  --sac-ease: cubic-bezier(.16, 1, .3, 1);
13517
13519
 
13518
13520
  ${mode === "fullpage" ? `/* Full-page: fill the host's box (sized by its container, else the viewport). */
13519
- display: block;
13521
+ display: flex;
13522
+ flex-direction: column;
13520
13523
  position: relative;
13521
13524
  width: 100%;
13522
- height: 100%;
13523
- min-height: 100vh;` : `/* Popover: float in the bottom-right corner. */
13525
+ height: 100%;` : `/* Popover: float in the bottom-right corner. */
13524
13526
  position: fixed;
13525
13527
  bottom: 24px;
13526
13528
  right: 24px;
@@ -13528,7 +13530,20 @@ var SmoothAgentChat = (function(exports) {
13528
13530
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
13529
13531
  -webkit-font-smoothing: antialiased;
13530
13532
  }
13531
-
13533
+ ${mode === "fullpage" ? `
13534
+ /* Viewport fallback — the element sets this attribute only when the host's
13535
+ container gives it no resolved height (e.g. mounted straight into an
13536
+ auto-height <body>). A sized container always wins, so an embed inside a
13537
+ fixed-height box never overflows it (composer stays visible). */
13538
+ :host([data-viewport-fallback]) { min-height: 100dvh; }
13539
+ /* The render wrapper passes the host's box down to the panel via flex. */
13540
+ .wrap {
13541
+ flex: 1;
13542
+ display: flex;
13543
+ flex-direction: column;
13544
+ min-height: 0;
13545
+ }
13546
+ ` : ""}
13532
13547
  * { box-sizing: border-box; }
13533
13548
 
13534
13549
  /* ───────────────────────────── Launcher ───────────────────────────── */
@@ -13610,11 +13625,13 @@ var SmoothAgentChat = (function(exports) {
13610
13625
  pointer-events: none;
13611
13626
  background: radial-gradient(120% 100% at 50% 0%, color-mix(in srgb, var(--sac-primary) 22%, transparent), transparent 70%);
13612
13627
  }
13613
- /* Full-page: the panel becomes the whole surface. */
13628
+ /* Full-page: the panel becomes the whole surface — it follows the host's box
13629
+ (via the .wrap flex chain), never a hardcoded viewport unit. */
13614
13630
  .panel.fullpage {
13615
13631
  width: 100%;
13616
- height: 100%;
13617
- min-height: 100vh;
13632
+ flex: 1;
13633
+ height: auto;
13634
+ min-height: 0;
13618
13635
  max-width: none;
13619
13636
  max-height: none;
13620
13637
  border: none;
@@ -13646,8 +13663,9 @@ var SmoothAgentChat = (function(exports) {
13646
13663
  0 1px 0 rgba(255, 255, 255, .25) inset;
13647
13664
  }
13648
13665
  .avatar svg { width: 22px; height: 22px; }
13649
- .avatar .logo-wrap { display: flex; }
13666
+ .avatar .logo-wrap { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
13650
13667
  .avatar .logo { height: 22px; width: auto; display: block; }
13668
+ .avatar .logo-img { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; display: block; border-radius: 9px; }
13651
13669
  .meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
13652
13670
  .title { font-weight: 650; font-size: 15.5px; letter-spacing: -.01em; line-height: 1.1; }
13653
13671
  .status {
@@ -13696,6 +13714,7 @@ var SmoothAgentChat = (function(exports) {
13696
13714
  .panel.fullpage .header { padding: 18px 22px; }
13697
13715
  .panel.fullpage .avatar { width: 44px; height: 44px; }
13698
13716
  .panel.fullpage .avatar .logo { height: 26px; }
13717
+ .panel.fullpage .avatar svg { width: 28px; height: 28px; }
13699
13718
 
13700
13719
  /* ────────────────────────────── Messages ──────────────────────────── */
13701
13720
  .messages {
@@ -14217,6 +14236,7 @@ var SmoothAgentChat = (function(exports) {
14217
14236
  "endpoint",
14218
14237
  "agent-id",
14219
14238
  "agent-name",
14239
+ "logo-url",
14220
14240
  "placeholder",
14221
14241
  "greeting",
14222
14242
  "start-open",
@@ -14328,6 +14348,7 @@ var SmoothAgentChat = (function(exports) {
14328
14348
  mode: this.overrides.mode ?? (modeAttr === "fullpage" ? "fullpage" : modeAttr === "popover" ? "popover" : void 0) ?? "popover",
14329
14349
  agentId,
14330
14350
  agentName: this.overrides.agentName ?? this.getAttribute("agent-name") ?? void 0,
14351
+ logoUrl: this.overrides.logoUrl ?? this.getAttribute("logo-url") ?? void 0,
14331
14352
  userName: this.overrides.userName,
14332
14353
  userEmail: this.overrides.userEmail,
14333
14354
  userPhone: this.overrides.userPhone,
@@ -14382,8 +14403,9 @@ var SmoothAgentChat = (function(exports) {
14382
14403
  const style = document.createElement("style");
14383
14404
  style.textContent = buildStyles(resolved.theme, resolved.mode);
14384
14405
  const monogram = escapeHtml((resolved.agentName.trim().charAt(0) || "A").toUpperCase());
14406
+ const headerLogo = resolved.logoUrl ? `<img src="${escapeHtml(resolved.logoUrl)}" alt="" class="logo-img" />` : SMOOTH_ICON_SVG;
14385
14407
  const header = fullpage ? `<div class="header">
14386
- <div class="avatar"><span class="logo-wrap">${SMOOTH_LOGO_SVG}</span></div>
14408
+ <div class="avatar"><span class="logo-wrap">${headerLogo}</span></div>
14387
14409
  <div class="meta">
14388
14410
  <span class="title">${escapeHtml(resolved.agentName)}</span>
14389
14411
  <span class="status"><span class="dot off"></span><span class="status-text"></span></span>
@@ -14434,6 +14456,7 @@ var SmoothAgentChat = (function(exports) {
14434
14456
  <div class="footer">powered by <b>smooth&#8209;operator</b>${restoreLink}</div>
14435
14457
  </div>`;
14436
14458
  const container = document.createElement("div");
14459
+ container.className = "wrap";
14437
14460
  container.innerHTML = `
14438
14461
  ${fullpage ? "" : `<button class="launcher" part="launcher" aria-label="Open chat">${ICON.spark}</button>`}
14439
14462
  <div class="panel${fullpage ? " fullpage" : " hidden"}" part="panel" role="${fullpage ? "region" : "dialog"}" aria-label="${escapeHtml(resolved.agentName)} chat">
@@ -14477,6 +14500,7 @@ var SmoothAgentChat = (function(exports) {
14477
14500
  await this.controller?.connect().catch(() => {});
14478
14501
  })();
14479
14502
  });
14503
+ if (fullpage) this.syncViewportFallback();
14480
14504
  if (fullpage && !gating) this.controller?.connect().catch(() => {});
14481
14505
  this.syncOpenState();
14482
14506
  if (!gating) this.renderMessages();
@@ -14966,6 +14990,24 @@ var SmoothAgentChat = (function(exports) {
14966
14990
  this.streamBubbleEl.textContent = this.streamTarget;
14967
14991
  }
14968
14992
  }
14993
+ /**
14994
+ * Full-page sizing probe: decide whether the host's container gives it a
14995
+ * real box. With the `.wrap` flex chain hidden, `height: 100%` of a SIZED
14996
+ * container still resolves (clientHeight > 0), while an auto-height parent
14997
+ * (e.g. mounted straight into `<body>`) collapses to ~0. Only the latter
14998
+ * gets `data-viewport-fallback`, whose CSS applies `min-height: 100dvh` —
14999
+ * so an embed inside a fixed-height box never overflows it (the composer
15000
+ * stays visible), and a bare full-page route still fills the viewport.
15001
+ */
15002
+ syncViewportFallback() {
15003
+ const wrap = this.shadowRoot?.querySelector(".wrap");
15004
+ if (!wrap) return;
15005
+ const prev = wrap.style.display;
15006
+ wrap.style.display = "none";
15007
+ const heightless = this.clientHeight < 8;
15008
+ wrap.style.display = prev;
15009
+ this.toggleAttribute("data-viewport-fallback", heightless);
15010
+ }
14969
15011
  /** Cancel any in-flight reveal loop and clear its state (called on full rebuild). */
14970
15012
  resetReveal() {
14971
15013
  if (this.rafId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafId);