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