@oyehello/chat 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @oyehello/chat
2
2
 
3
- Framework-agnostic embeddable chat for oyehello agents — a `<oyehello-chat>` web component with a working default UI, plus a headless `createChatClient()` for custom UIs. Zero framework dependencies.
3
+ Framework-agnostic embeddable chat for oyehello agents — a `<oyehello-chat>` web component that renders a **floating chat launcher** (bottom-right; click to open a chat window), plus a headless `createChatClient()` for custom UIs. Zero framework dependencies.
4
+
5
+ The widget connects lazily (a session opens the first time the visitor opens the panel), renders assistant replies as **markdown** (sanitized), auto-scrolls unless the visitor has scrolled up — showing a "new messages" pill instead — and supports Shift+Enter for newlines. Theme the accent with the `--oc-accent` CSS variable. Just drop the tag anywhere on the page; it positions itself.
4
6
 
5
7
  ## Install
6
8
  ```bash
@@ -13,13 +15,13 @@ npm install @oyehello/chat
13
15
  <oyehello-chat
14
16
  api-key="ohk_pub_…"
15
17
  agent-id="agt_…"
16
- base-url="https://call.hotreloads.ai"
18
+ base-url="https://api.oyehello.ai"
17
19
  ></oyehello-chat>
18
20
  ```
19
21
  Or via a plain script tag (no bundler):
20
22
  ```html
21
23
  <script src="https://unpkg.com/@oyehello/chat"></script>
22
- <oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://call.hotreloads.ai"></oyehello-chat>
24
+ <oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://api.oyehello.ai"></oyehello-chat>
23
25
  ```
24
26
  Theme the accent with the `--oc-accent` CSS variable.
25
27
 
@@ -28,7 +30,7 @@ The custom element works in React too (React 19 natively; React 18 fine for thes
28
30
  ```tsx
29
31
  import '@oyehello/chat';
30
32
  export default function Chat() {
31
- return <oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://call.hotreloads.ai" />;
33
+ return <oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://api.oyehello.ai" />;
32
34
  }
33
35
  ```
34
36
  TypeScript/TSX users add this ambient declaration once (e.g. `oyehello-chat.d.ts`) so the tag typechecks:
@@ -47,7 +49,7 @@ export {};
47
49
  ## Headless (build your own UI)
48
50
  ```js
49
51
  import { createChatClient } from '@oyehello/chat';
50
- const chat = createChatClient({ apiKey: 'ohk_pub_…', agentId: 'agt_…', baseUrl: 'https://call.hotreloads.ai' });
52
+ const chat = createChatClient({ apiKey: 'ohk_pub_…', agentId: 'agt_…', baseUrl: 'https://api.oyehello.ai' });
51
53
  chat.on((e) => { if (e.type === 'messages') render(e.messages); });
52
54
  await chat.start();
53
55
  await chat.sendMessage('Hello');
package/dist/index.cjs CHANGED
@@ -1,5 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ var marked = require('marked');
4
+ var DOMPurify = require('dompurify');
5
+
6
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
7
+
8
+ var DOMPurify__default = /*#__PURE__*/_interopDefault(DOMPurify);
9
+
3
10
  // src/errors.ts
4
11
  var ChatApiError = class extends Error {
5
12
  constructor(message, status, code, meta) {
@@ -105,7 +112,7 @@ async function uploadToPresigned(uploadUrl, file, fetchImpl) {
105
112
  var seq = 0;
106
113
  var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
107
114
  function createChatClient(options) {
108
- const fetchImpl = options.fetch ?? globalThis.fetch;
115
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
109
116
  const ES = options.eventSource ?? globalThis.EventSource;
110
117
  const maxAttachments = options.maxAttachments ?? 4;
111
118
  const maxAttachmentBytes = options.maxAttachmentBytes ?? 1e7;
@@ -281,7 +288,10 @@ function createChatClient(options) {
281
288
  return getAttachmentUrl({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, storageRef, fetchImpl });
282
289
  },
283
290
  async start() {
284
- if (state !== "idle") return;
291
+ if (state === "opening" || state === "ready") return;
292
+ closed = false;
293
+ clearReconnect();
294
+ reconnectAttempts = 0;
285
295
  setState("opening");
286
296
  try {
287
297
  await open();
@@ -343,6 +353,174 @@ function createChatClient(options) {
343
353
  }
344
354
  };
345
355
  }
356
+ marked.marked.setOptions({ gfm: true, breaks: true });
357
+ var hookInstalled = false;
358
+ function installLinkHook() {
359
+ if (hookInstalled) return;
360
+ DOMPurify__default.default.addHook("afterSanitizeAttributes", (node) => {
361
+ if (node.nodeName === "A") {
362
+ node.setAttribute("target", "_blank");
363
+ node.setAttribute("rel", "noopener noreferrer nofollow");
364
+ }
365
+ });
366
+ hookInstalled = true;
367
+ }
368
+ var ALLOWED_TAGS = [
369
+ "p",
370
+ "br",
371
+ "strong",
372
+ "em",
373
+ "b",
374
+ "i",
375
+ "del",
376
+ "s",
377
+ "code",
378
+ "pre",
379
+ "blockquote",
380
+ "ul",
381
+ "ol",
382
+ "li",
383
+ "a",
384
+ "h1",
385
+ "h2",
386
+ "h3",
387
+ "h4",
388
+ "h5",
389
+ "h6",
390
+ "hr",
391
+ "table",
392
+ "thead",
393
+ "tbody",
394
+ "tr",
395
+ "th",
396
+ "td",
397
+ "span"
398
+ ];
399
+ var ALLOWED_ATTR = ["href", "target", "rel", "class"];
400
+ function renderMarkdown(text) {
401
+ installLinkHook();
402
+ const raw = marked.marked.parse(text, { async: false });
403
+ return DOMPurify__default.default.sanitize(raw, {
404
+ ALLOWED_TAGS,
405
+ ALLOWED_ATTR,
406
+ ALLOW_DATA_ATTR: false,
407
+ ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i
408
+ });
409
+ }
410
+
411
+ // src/styles.ts
412
+ var STYLES = `
413
+ :host {
414
+ --oc-accent: #2563eb;
415
+ --oc-fg: #111827;
416
+ --oc-muted: #6b7280;
417
+ --oc-surface: #ffffff;
418
+ --oc-surface-2: #f3f4f6;
419
+ --oc-border: #e5e7eb;
420
+ --oc-radius: 16px;
421
+ position: fixed; right: 20px; bottom: 20px; z-index: 2147483000;
422
+ font-family: system-ui, -apple-system, sans-serif;
423
+ }
424
+ * { box-sizing: border-box; }
425
+
426
+ /* Launcher */
427
+ .launcher {
428
+ width: 60px; height: 60px; border-radius: 50%; border: none; cursor: pointer;
429
+ background: var(--oc-accent); color: #fff; box-shadow: 0 6px 24px rgba(0,0,0,.25);
430
+ display: grid; place-items: center; transition: transform .15s ease, opacity .15s ease;
431
+ }
432
+ .launcher:hover { transform: scale(1.06); }
433
+ .launcher svg { width: 28px; height: 28px; }
434
+ .launcher .badge {
435
+ position: absolute; top: -2px; right: -2px; min-width: 20px; height: 20px; padding: 0 5px;
436
+ border-radius: 10px; background: #ef4444; color: #fff; font-size: 11px; font-weight: 700;
437
+ display: grid; place-items: center; border: 2px solid var(--oc-surface);
438
+ }
439
+
440
+ /* Panel */
441
+ .panel {
442
+ position: absolute; right: 0; bottom: 74px; width: 384px; height: min(600px, calc(100vh - 120px));
443
+ background: var(--oc-surface); color: var(--oc-fg); border: 1px solid var(--oc-border);
444
+ border-radius: var(--oc-radius); box-shadow: 0 12px 48px rgba(0,0,0,.22);
445
+ display: flex; flex-direction: column; overflow: hidden;
446
+ transform-origin: bottom right; transition: transform .16s ease, opacity .16s ease;
447
+ }
448
+ .panel[hidden] { display: none; }
449
+ .panel.enter { opacity: 0; transform: translateY(8px) scale(.98); }
450
+
451
+ .hd { display: flex; align-items: center; gap: 8px; padding: 14px 16px; border-bottom: 1px solid var(--oc-border); }
452
+ .hd .title { font-weight: 700; font-size: 15px; flex: 1; }
453
+ .hd .close { background: none; border: none; cursor: pointer; color: var(--oc-muted); padding: 4px; border-radius: 6px; display: grid; place-items: center; }
454
+ .hd .close:hover { background: var(--oc-surface-2); }
455
+ .hd .close svg { width: 18px; height: 18px; }
456
+
457
+ .status { padding: 6px 16px; font-size: 12px; color: var(--oc-muted); background: #f9fafb; border-bottom: 1px solid var(--oc-border); }
458
+ .err { padding: 8px 16px; font-size: 12px; color: #b91c1c; background: #fef2f2; border-bottom: 1px solid #fecaca; display: flex; align-items: center; gap: 8px; }
459
+ .err .retry { padding: 3px 10px; font-size: 11px; background: #b91c1c; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
460
+
461
+ /* Message list */
462
+ .body { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; scroll-behavior: smooth; }
463
+ .b { max-width: 85%; padding: 9px 13px; border-radius: 14px; font-size: 14px; line-height: 1.5; word-break: break-word; }
464
+ .b.user { align-self: flex-end; background: var(--oc-accent); color: #fff; border-bottom-right-radius: 4px; }
465
+ .b.assistant { align-self: flex-start; background: var(--oc-surface-2); color: var(--oc-fg); border-bottom-left-radius: 4px; }
466
+ .b.human { align-self: flex-start; background: #dbeafe; color: var(--oc-fg); border-bottom-left-radius: 4px; }
467
+ .b.typing { font-style: italic; color: var(--oc-muted); }
468
+ .human-label { font-size: 11px; font-weight: 600; opacity: .7; margin-bottom: 2px; }
469
+
470
+ /* Markdown inside a bubble */
471
+ .md > :first-child { margin-top: 0; }
472
+ .md > :last-child { margin-bottom: 0; }
473
+ .md p { margin: 0 0 8px; }
474
+ .md ul, .md ol { margin: 0 0 8px; padding-left: 20px; }
475
+ .md li { margin: 2px 0; }
476
+ .md a { color: inherit; text-decoration: underline; }
477
+ .b.assistant .md a, .b.human .md a { color: var(--oc-accent); }
478
+ .md code { font-family: ui-monospace, monospace; font-size: 12.5px; background: rgba(0,0,0,.07); padding: 1px 5px; border-radius: 5px; }
479
+ .md pre { background: #0f172a; color: #e2e8f0; padding: 10px 12px; border-radius: 8px; overflow-x: auto; margin: 0 0 8px; }
480
+ .md pre code { background: none; padding: 0; color: inherit; }
481
+ .md blockquote { margin: 0 0 8px; padding-left: 10px; border-left: 3px solid var(--oc-border); color: var(--oc-muted); }
482
+ .md h1, .md h2, .md h3, .md h4 { margin: 8px 0 6px; font-size: 15px; }
483
+ .md table { border-collapse: collapse; font-size: 13px; margin: 0 0 8px; display: block; overflow-x: auto; }
484
+ .md th, .md td { border: 1px solid var(--oc-border); padding: 4px 8px; }
485
+ .md hr { border: none; border-top: 1px solid var(--oc-border); margin: 8px 0; }
486
+
487
+ .src-row { align-self: flex-start; display: flex; flex-wrap: wrap; gap: 6px; max-width: 85%; }
488
+ .src-chip { font-size: 11px; background: rgba(0,0,0,.06); color: #374151; border-radius: 6px; padding: 2px 8px; cursor: default; }
489
+ .att-thumb { display: block; max-width: 180px; max-height: 180px; border-radius: 8px; margin-bottom: 6px; }
490
+ .att-chip { display: inline-block; font-size: 12px; background: rgba(0,0,0,.08); border-radius: 6px; padding: 2px 8px; margin: 0 0 6px; }
491
+ .b.user .att-chip { background: rgba(255,255,255,.25); }
492
+
493
+ /* New-messages pill */
494
+ .pill {
495
+ position: absolute; left: 50%; transform: translateX(-50%); bottom: 78px;
496
+ background: var(--oc-accent); color: #fff; border: none; cursor: pointer;
497
+ font-size: 12px; font-weight: 600; padding: 6px 12px; border-radius: 999px;
498
+ box-shadow: 0 4px 14px rgba(0,0,0,.25); display: flex; align-items: center; gap: 6px;
499
+ }
500
+ .pill[hidden] { display: none; }
501
+ .pill svg { width: 13px; height: 13px; }
502
+
503
+ /* Composer */
504
+ form { display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--oc-border); align-items: flex-end; }
505
+ textarea {
506
+ flex: 1; resize: none; max-height: 120px; padding: 9px 11px; border-radius: 10px;
507
+ border: 1px solid #d1d5db; font: inherit; font-size: 14px; line-height: 1.4; outline: none;
508
+ }
509
+ textarea:focus { border-color: var(--oc-accent); }
510
+ .send { padding: 0 14px; height: 38px; border-radius: 10px; border: none; background: var(--oc-accent); color: #fff; font-weight: 600; cursor: pointer; flex: 0 0 auto; }
511
+ .send:disabled, textarea:disabled { opacity: .55; cursor: not-allowed; }
512
+
513
+ @media (max-width: 480px) {
514
+ :host { right: 0; bottom: 0; }
515
+ .panel { position: fixed; inset: 0; width: 100vw; height: 100dvh; border-radius: 0; bottom: 0; }
516
+ .launcher { position: fixed; right: 16px; bottom: 16px; }
517
+ .pill { bottom: 82px; }
518
+ }
519
+ @media (prefers-reduced-motion: reduce) {
520
+ .launcher, .panel { transition: none; }
521
+ .body { scroll-behavior: auto; }
522
+ }
523
+ `;
346
524
 
347
525
  // src/element.ts
348
526
  var HANDOFF_STATUS_TEXT = {
@@ -352,6 +530,11 @@ var HANDOFF_STATUS_TEXT = {
352
530
  };
353
531
  var ATTACHMENT_ACCEPT = "image/*,application/pdf,text/plain,text/csv";
354
532
  var AGENT_TYPING_TEXT = "The agent is typing\u2026";
533
+ var PIN_THRESHOLD = 48;
534
+ var ICON_CHAT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>';
535
+ var ICON_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
536
+ var ICON_DOWN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
537
+ var ICON_CLIP = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
355
538
  var OyehelloChatElement = class extends HTMLElement {
356
539
  static observedAttributes = ["api-key", "agent-id", "base-url", "title"];
357
540
  client = null;
@@ -360,21 +543,37 @@ var OyehelloChatElement = class extends HTMLElement {
360
543
  state = "idle";
361
544
  agentName = null;
362
545
  handoffState = "ai";
363
- // Transient "agent is typing…" affordance (Phase 7 followups) - never a message, only rendered
364
- // while a handoff is active (human/awaiting_human).
365
546
  agentTyping = false;
366
- // registerTools() may be called before connectedCallback creates the client (e.g. right after
367
- // document.createElement, before the element is attached). Buffer until the client exists.
547
+ lastError = null;
548
+ // registerTools() may run before connectedCallback creates the client — buffer until it exists.
368
549
  pendingTools = {};
369
- // Files picked in the file input, staged until the form submits alongside the text.
370
550
  pendingFiles = [];
371
- // Best-effort local thumbnail cache for images THIS widget instance just sent, keyed by filename -
372
- // an instant blob src, no round-trip needed.
373
551
  imagePreviewCache = /* @__PURE__ */ new Map();
374
- // Read-back URLs resolved lazily (via client.getAttachmentUrl) for images with no local blob (a
375
- // reconnect, or an `attachment` SSE echo), keyed by storageRef. Cached across re-renders so the
376
- // same image isn't re-fetched on every render() call.
377
552
  resolvedUrlCache = /* @__PURE__ */ new Map();
553
+ // Rendered-markdown cache for FINALIZED assistant/human messages (keyed by message id) so the whole
554
+ // history isn't re-parsed on every streamed token — only the live streaming message is re-rendered.
555
+ mdCache = /* @__PURE__ */ new Map();
556
+ // Widget UI state.
557
+ open = false;
558
+ started = false;
559
+ // lazy: the session opens on first panel open, not on page load
560
+ pinned = true;
561
+ // view is at/near the bottom
562
+ unseen = 0;
563
+ // messages that arrived while scrolled-up or while closed
564
+ renderedCount = 0;
565
+ // message count at the last renderMessages()
566
+ // Shadow-DOM refs (assigned once in renderShell).
567
+ panelEl;
568
+ bodyEl;
569
+ textareaEl;
570
+ sendEl;
571
+ fileEl;
572
+ pillEl;
573
+ badgeEl;
574
+ statusEl;
575
+ errEl;
576
+ titleEl;
378
577
  connectedCallback() {
379
578
  if (!this.shadowRoot) this.attachShadow({ mode: "open" });
380
579
  const apiKey = this.getAttribute("api-key");
@@ -389,16 +588,16 @@ var OyehelloChatElement = class extends HTMLElement {
389
588
  this.client.registerTools(this.pendingTools);
390
589
  this.pendingTools = {};
391
590
  }
392
- this.off = this.client.on((e) => {
393
- if (e.type === "messages") this.messages = e.messages;
394
- else if (e.type === "state") this.state = e.state;
395
- else if (e.type === "agent") this.agentName = e.agent.name;
396
- else if (e.type === "handoff") this.handoffState = e.state;
397
- else if (e.type === "agentTyping") this.agentTyping = e.active;
398
- this.render();
399
- });
400
- this.render();
401
- void this.client.start();
591
+ this.off = this.client.on((e) => this.onClientEvent(e));
592
+ this.renderShell();
593
+ this.updateTitle();
594
+ this.updateStatus();
595
+ let wasOpen = false;
596
+ try {
597
+ wasOpen = sessionStorage.getItem(this.openKey()) === "1";
598
+ } catch {
599
+ }
600
+ if (wasOpen) this.openPanel();
402
601
  }
403
602
  disconnectedCallback() {
404
603
  this.off?.();
@@ -412,18 +611,229 @@ var OyehelloChatElement = class extends HTMLElement {
412
611
  if (this.client) this.client.registerTools(handlers);
413
612
  else Object.assign(this.pendingTools, handlers);
414
613
  }
415
- send(text, files) {
614
+ openKey() {
615
+ return `oyehello-chat:open:${this.getAttribute("agent-id") ?? ""}`;
616
+ }
617
+ // ── Shell (built once) ─────────────────────────────────────────────────────────────────────────
618
+ renderShell() {
619
+ const root = this.shadowRoot;
620
+ root.innerHTML = `
621
+ <style>${STYLES}</style>
622
+ <button class="launcher" part="launcher" aria-label="Open chat" aria-haspopup="dialog">
623
+ ${ICON_CHAT}<span class="badge" hidden></span>
624
+ </button>
625
+ <div class="panel" part="panel" role="dialog" aria-label="Chat" hidden>
626
+ <div class="hd"><span class="title">Chat</span><button class="close" aria-label="Close chat">${ICON_CLOSE}</button></div>
627
+ <div class="status" hidden></div>
628
+ <div class="err" hidden></div>
629
+ <div class="body"></div>
630
+ <button class="pill" type="button" hidden>${ICON_DOWN}<span class="pill-text">New messages</span></button>
631
+ <form>
632
+ <button type="button" class="attach" aria-label="Attach a file">${ICON_CLIP}</button>
633
+ <input type="file" accept="${ATTACHMENT_ACCEPT}" multiple hidden />
634
+ <textarea aria-label="message" rows="1" placeholder="Type a message\u2026"></textarea>
635
+ <button type="submit" class="send">Send</button>
636
+ </form>
637
+ </div>`;
638
+ this.panelEl = root.querySelector(".panel");
639
+ this.bodyEl = root.querySelector(".body");
640
+ this.textareaEl = root.querySelector("textarea");
641
+ this.sendEl = root.querySelector(".send");
642
+ this.fileEl = root.querySelector('input[type="file"]');
643
+ this.pillEl = root.querySelector(".pill");
644
+ this.badgeEl = root.querySelector(".badge");
645
+ this.statusEl = root.querySelector(".status");
646
+ this.errEl = root.querySelector(".err");
647
+ this.titleEl = root.querySelector(".title");
648
+ root.querySelector(".launcher").addEventListener("click", () => this.togglePanel());
649
+ root.querySelector(".close").addEventListener("click", () => this.closePanel());
650
+ root.querySelector(".attach").addEventListener("click", () => this.fileEl.click());
651
+ this.fileEl.addEventListener("change", () => {
652
+ this.pendingFiles = this.fileEl.files ? Array.from(this.fileEl.files) : [];
653
+ });
654
+ this.pillEl.addEventListener("click", () => this.scrollToBottom());
655
+ this.bodyEl.addEventListener("scroll", () => this.onBodyScroll());
656
+ root.querySelector("form").addEventListener("submit", (e) => {
657
+ e.preventDefault();
658
+ this.doSend();
659
+ });
660
+ this.textareaEl.addEventListener("input", () => this.autoGrow());
661
+ this.textareaEl.addEventListener("keydown", (e) => {
662
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
663
+ e.preventDefault();
664
+ this.doSend();
665
+ }
666
+ });
667
+ this.panelEl.addEventListener("keydown", (e) => {
668
+ if (e.key === "Escape") this.closePanel();
669
+ });
670
+ }
671
+ renderError(msg) {
672
+ if (!this.shadowRoot) this.attachShadow({ mode: "open" });
673
+ console.error(msg);
674
+ this.shadowRoot.innerHTML = `<div style="position:fixed;right:16px;bottom:16px;color:#b91c1c;font:12px system-ui;background:#fef2f2;border:1px solid #fecaca;padding:8px 12px;border-radius:8px">${escapeHtml(msg)}</div>`;
675
+ }
676
+ // ── Open / close ───────────────────────────────────────────────────────────────────────────────
677
+ togglePanel() {
678
+ this.open ? this.closePanel() : this.openPanel();
679
+ }
680
+ openPanel() {
681
+ this.open = true;
682
+ try {
683
+ sessionStorage.setItem(this.openKey(), "1");
684
+ } catch {
685
+ }
686
+ this.panelEl.hidden = false;
687
+ this.panelEl.classList.add("enter");
688
+ requestAnimationFrame(() => this.panelEl.classList.remove("enter"));
689
+ if (!this.started && this.client) {
690
+ this.started = true;
691
+ void this.client.start();
692
+ }
693
+ this.renderMessages();
694
+ this.pinned = true;
695
+ this.unseen = 0;
696
+ this.updateBadge();
697
+ this.scrollToBottom();
698
+ if (!this.textareaEl.disabled) this.textareaEl.focus();
699
+ }
700
+ closePanel() {
701
+ this.open = false;
702
+ try {
703
+ sessionStorage.setItem(this.openKey(), "0");
704
+ } catch {
705
+ }
706
+ this.panelEl.hidden = true;
707
+ }
708
+ // ── Client events ─────────────────────────────────────────────────────────────────────────────
709
+ onClientEvent(e) {
710
+ if (e.type === "messages") this.messages = e.messages;
711
+ else if (e.type === "state") {
712
+ this.state = e.state;
713
+ if (e.state === "ready" || e.state === "opening") this.lastError = null;
714
+ } else if (e.type === "agent") {
715
+ this.agentName = e.agent.name;
716
+ this.updateTitle();
717
+ } else if (e.type === "handoff") this.handoffState = e.state;
718
+ else if (e.type === "agentTyping") this.agentTyping = e.active;
719
+ else if (e.type === "error") this.lastError = e.error.message;
720
+ this.updateStatus();
721
+ this.renderMessages();
722
+ }
723
+ updateTitle() {
724
+ if (this.titleEl) this.titleEl.textContent = this.getAttribute("title") ?? this.agentName ?? "Chat";
725
+ }
726
+ updateStatus() {
727
+ if (!this.textareaEl) return;
728
+ const disabled = this.state !== "ready";
729
+ this.textareaEl.disabled = disabled;
730
+ this.sendEl.disabled = disabled;
731
+ const st = this.state === "opening" ? "Connecting\u2026" : HANDOFF_STATUS_TEXT[this.handoffState];
732
+ this.statusEl.hidden = !st;
733
+ this.statusEl.textContent = st ?? "";
734
+ if (this.state === "error" && this.lastError) {
735
+ this.errEl.hidden = false;
736
+ this.errEl.innerHTML = `<span>Couldn't start chat: ${escapeHtml(this.lastError)}</span><button class="retry" type="button">Retry</button>`;
737
+ this.errEl.querySelector(".retry").addEventListener("click", () => {
738
+ void this.client?.start();
739
+ });
740
+ } else {
741
+ this.errEl.hidden = true;
742
+ this.errEl.innerHTML = "";
743
+ }
744
+ }
745
+ // ── Message list + scroll ─────────────────────────────────────────────────────────────────────
746
+ renderMessages() {
747
+ if (!this.bodyEl) return;
748
+ const body = this.bodyEl;
749
+ const atBottom = this.open && this.pinned;
750
+ const prevTop = body.scrollTop;
751
+ const added = this.messages.length - this.renderedCount;
752
+ const showTyping = this.agentTyping && (this.handoffState === "human" || this.handoffState === "awaiting_human");
753
+ let html = this.messages.map((m) => this.bubbleHtml(m)).join("");
754
+ if (showTyping) html += `<div class="b assistant typing">${escapeHtml(AGENT_TYPING_TEXT)}</div>`;
755
+ body.innerHTML = html;
756
+ this.hydrateAttachmentImages();
757
+ this.renderedCount = this.messages.length;
758
+ if (atBottom) this.scrollToBottom();
759
+ else {
760
+ if (this.open) body.scrollTop = prevTop;
761
+ if (added > 0) this.unseen += added;
762
+ }
763
+ this.updatePill();
764
+ this.updateBadge();
765
+ }
766
+ onBodyScroll() {
767
+ const b = this.bodyEl;
768
+ const nearBottom = b.scrollHeight - b.scrollTop - b.clientHeight < PIN_THRESHOLD;
769
+ this.pinned = nearBottom;
770
+ if (nearBottom) {
771
+ this.unseen = 0;
772
+ this.updateBadge();
773
+ }
774
+ this.updatePill();
775
+ }
776
+ scrollToBottom() {
777
+ if (!this.bodyEl) return;
778
+ this.bodyEl.scrollTop = this.bodyEl.scrollHeight;
779
+ this.pinned = true;
780
+ this.unseen = 0;
781
+ this.updatePill();
782
+ this.updateBadge();
783
+ }
784
+ updatePill() {
785
+ if (!this.pillEl) return;
786
+ this.pillEl.hidden = !(this.open && !this.pinned && this.unseen > 0);
787
+ const t = this.pillEl.querySelector(".pill-text");
788
+ if (t) t.textContent = this.unseen === 1 ? "1 new message" : `${this.unseen} new messages`;
789
+ }
790
+ updateBadge() {
791
+ if (!this.badgeEl) return;
792
+ const show = !this.open && this.unseen > 0;
793
+ this.badgeEl.hidden = !show;
794
+ this.badgeEl.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
795
+ }
796
+ // ── Composer ──────────────────────────────────────────────────────────────────────────────────
797
+ autoGrow() {
798
+ const ta = this.textareaEl;
799
+ ta.style.height = "auto";
800
+ ta.style.height = `${Math.min(ta.scrollHeight, 120)}px`;
801
+ }
802
+ doSend() {
803
+ const text = this.textareaEl.value;
416
804
  if (!text.trim()) return;
805
+ const files = this.pendingFiles;
806
+ this.pendingFiles = [];
807
+ this.fileEl.value = "";
417
808
  for (const f of files) {
418
809
  if (f.type.startsWith("image/") && typeof URL.createObjectURL === "function" && !this.imagePreviewCache.has(f.name)) {
419
810
  this.imagePreviewCache.set(f.name, URL.createObjectURL(f));
420
811
  }
421
812
  }
813
+ this.pinned = true;
422
814
  void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
815
+ this.textareaEl.value = "";
816
+ this.textareaEl.style.height = "auto";
423
817
  }
424
- renderError(msg) {
425
- if (!this.shadowRoot) this.attachShadow({ mode: "open" });
426
- this.shadowRoot.innerHTML = `<div style="color:#b91c1c;font:13px system-ui;padding:12px">${escapeHtml(msg)}</div>`;
818
+ // ── Bubble rendering ──────────────────────────────────────────────────────────────────────────
819
+ bubbleHtml(m) {
820
+ const atts = (m.attachments ?? []).map((a) => this.renderAttachment(a)).join("");
821
+ const label = m.role === "human" ? '<div class="human-label">Support</div>' : "";
822
+ let content;
823
+ if (m.role === "assistant") {
824
+ let rendered = m.streaming ? void 0 : this.mdCache.get(m.id);
825
+ if (rendered === void 0) {
826
+ rendered = renderMarkdown(m.text || "");
827
+ if (!m.streaming) this.mdCache.set(m.id, rendered);
828
+ }
829
+ content = `<div class="md">${rendered}</div>`;
830
+ } else {
831
+ content = escapeHtml(m.text);
832
+ }
833
+ const streamingDots = m.streaming ? '<span aria-hidden="true">\u2026</span>' : "";
834
+ const bubble = `<div class="b ${m.role}">${label}${atts}${content}${streamingDots}</div>`;
835
+ const sources = m.sources && m.sources.length > 0 ? `<div class="src-row">${m.sources.map((s) => this.renderSource(s)).join("")}</div>` : "";
836
+ return bubble + sources;
427
837
  }
428
838
  renderAttachment(a) {
429
839
  if (a.contentType.startsWith("image/")) {
@@ -436,9 +846,6 @@ var OyehelloChatElement = class extends HTMLElement {
436
846
  renderSource(s) {
437
847
  return `<div class="src-chip" title="${escapeHtml(s.snippet)}">${escapeHtml(s.title)}</div>`;
438
848
  }
439
- // After render() attaches the markup, resolve a read-back URL (via the client) for every image
440
- // placeholder that has no src yet - a local blob or a previously-resolved URL already set one, so
441
- // those are left untouched (no redundant network call).
442
849
  hydrateAttachmentImages() {
443
850
  if (!this.client) return;
444
851
  const imgs = this.shadowRoot.querySelectorAll("img.att-thumb[data-oc-ref]");
@@ -453,81 +860,6 @@ var OyehelloChatElement = class extends HTMLElement {
453
860
  });
454
861
  });
455
862
  }
456
- render() {
457
- const title = this.getAttribute("title") ?? this.agentName ?? "Chat";
458
- const bubbles = this.messages.map((m) => {
459
- const atts = (m.attachments ?? []).map((a) => this.renderAttachment(a)).join("");
460
- const label = m.role === "human" ? '<div class="human-label">Support</div>' : "";
461
- const bubble = `<div class="b ${m.role}">${label}${atts}${m.text ? escapeHtml(m.text) : ""}${m.streaming ? "\u2026" : ""}</div>`;
462
- const sources = m.sources && m.sources.length > 0 ? `<div class="src-row">${m.sources.map((s) => this.renderSource(s)).join("")}</div>` : "";
463
- return bubble + sources;
464
- }).join("");
465
- const statusText = HANDOFF_STATUS_TEXT[this.handoffState];
466
- const status = statusText ? `<div class="status">${escapeHtml(statusText)}</div>` : "";
467
- const showTyping = this.agentTyping && (this.handoffState === "human" || this.handoffState === "awaiting_human");
468
- const typingIndicator = showTyping ? `<div class="b assistant typing">${escapeHtml(AGENT_TYPING_TEXT)}</div>` : "";
469
- const disabled = this.state !== "ready" ? "disabled" : "";
470
- const prevInput = this.shadowRoot.querySelector('input[aria-label="message"]');
471
- const prevValue = prevInput?.value ?? "";
472
- const prevSelectionStart = prevInput?.selectionStart ?? null;
473
- const prevSelectionEnd = prevInput?.selectionEnd ?? null;
474
- const hadFocus = !!prevInput && this.shadowRoot.activeElement === prevInput;
475
- this.shadowRoot.innerHTML = `
476
- <style>
477
- :host{display:block;height:100%;min-height:320px;font-family:system-ui,sans-serif;--oc-accent:#2563eb}
478
- .wrap{display:flex;flex-direction:column;height:100%;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden}
479
- .hd{padding:10px 14px;border-bottom:1px solid #e5e7eb;font-weight:600}
480
- .body{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
481
- .b{max-width:80%;padding:8px 12px;border-radius:12px;white-space:pre-wrap;word-break:break-word}
482
- .b.user{align-self:flex-end;background:var(--oc-accent);color:#fff}
483
- .b.assistant{align-self:flex-start;background:#f3f4f6;color:#111}
484
- .b.human{align-self:flex-start;background:#dbeafe;color:#111}
485
- .b.typing{font-style:italic;color:#6b7280;opacity:.85}
486
- .human-label{font-size:11px;font-weight:600;opacity:.7;margin-bottom:2px}
487
- .status{padding:6px 14px;font-size:12px;color:#6b7280;background:#f9fafb;border-bottom:1px solid #e5e7eb}
488
- .att-thumb{display:block;max-width:160px;max-height:160px;border-radius:8px;margin-bottom:6px}
489
- .att-chip{display:inline-block;font-size:12px;background:rgba(0,0,0,.08);border-radius:6px;padding:2px 8px;margin:0 0 6px}
490
- .b.user .att-chip{background:rgba(255,255,255,.25)}
491
- .src-row{align-self:flex-start;display:flex;flex-wrap:wrap;gap:6px;max-width:80%}
492
- .src-chip{display:inline-block;font-size:11px;background:rgba(0,0,0,.06);color:#374151;border-radius:6px;padding:2px 8px;cursor:default}
493
- form{display:flex;gap:8px;padding:10px;border-top:1px solid #e5e7eb;align-items:center}
494
- input[aria-label="message"]{flex:1;padding:8px 10px;border-radius:8px;border:1px solid #d1d5db;font:inherit}
495
- input[type="file"]{max-width:120px;font-size:12px}
496
- button{padding:8px 14px;border-radius:8px;border:none;background:var(--oc-accent);color:#fff;font-weight:600;cursor:pointer}
497
- button:disabled,input:disabled{opacity:.6;cursor:not-allowed}
498
- </style>
499
- <div class="wrap">
500
- <div class="hd">${escapeHtml(title)}</div>
501
- ${status}
502
- <div class="body">${bubbles}${typingIndicator}</div>
503
- <form>
504
- <input aria-label="message" placeholder="Type a message\u2026" ${disabled}/>
505
- <input type="file" accept="${ATTACHMENT_ACCEPT}" multiple ${disabled}/>
506
- <button type="submit" ${disabled}>Send</button>
507
- </form>
508
- </div>`;
509
- const form = this.shadowRoot.querySelector("form");
510
- const input = this.shadowRoot.querySelector('input[aria-label="message"]');
511
- const fileInput = this.shadowRoot.querySelector('input[type="file"]');
512
- fileInput.addEventListener("change", () => {
513
- this.pendingFiles = fileInput.files ? Array.from(fileInput.files) : [];
514
- });
515
- form.addEventListener("submit", (e) => {
516
- e.preventDefault();
517
- const files = this.pendingFiles;
518
- this.pendingFiles = [];
519
- this.send(input.value, files);
520
- input.value = "";
521
- });
522
- if (prevValue) {
523
- input.value = prevValue;
524
- if (prevSelectionStart !== null && prevSelectionEnd !== null) input.setSelectionRange(prevSelectionStart, prevSelectionEnd);
525
- }
526
- if (hadFocus) input.focus();
527
- const body = this.shadowRoot.querySelector(".body");
528
- body.scrollTop = body.scrollHeight;
529
- this.hydrateAttachmentImages();
530
- }
531
863
  };
532
864
  function escapeHtml(s) {
533
865
  return s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);