@loupekit/sdk 0.10.26-next.73 → 0.10.27-next.74

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
@@ -773,6 +773,64 @@ var STYLES = (
773
773
  object-fit: cover; cursor: pointer; background: var(--bg-2);
774
774
  }
775
775
 
776
+ /* ------------------------------------------------------------- companion chat */
777
+ /* Same inset as the Activity view \u2014 without it the composer and its Send button sit
778
+ flush against the panel's edge, which reads as a clipped button. */
779
+ .chat-view { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; padding: 10px 12px 16px; }
780
+ .chat-tray {
781
+ display: flex; flex-direction: column; gap: 4px; padding: 8px;
782
+ border: 1px solid var(--line); border-radius: 10px; background: var(--bg-2);
783
+ }
784
+ .tray-head { display: flex; align-items: center; gap: 6px; }
785
+ .tray-title { font-size: 11px; color: var(--muted); }
786
+ .tray-spacer, .chat-spacer { flex: 1; }
787
+ .tray-chip {
788
+ display: flex; align-items: center; gap: 6px; padding: 4px 6px;
789
+ border: 1px solid var(--line); border-radius: 8px; background: var(--bg); font-size: 11px;
790
+ }
791
+ .tray-chip.off { opacity: .5; }
792
+ .tray-thumb { width: 24px; height: 24px; border-radius: 4px; object-fit: cover; flex: none; }
793
+ .tray-kind { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; flex: none; }
794
+ .tray-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; }
795
+ .tray-nudge, .tray-x {
796
+ border: 0; background: transparent; color: var(--muted); cursor: pointer;
797
+ font-size: 11px; padding: 1px 4px; border-radius: 4px; line-height: 1.2;
798
+ }
799
+ .tray-nudge:disabled { opacity: .3; cursor: default; }
800
+ .tray-nudge:not(:disabled):hover, .tray-x:hover { color: var(--accent); background: var(--accent-soft); }
801
+ .chat-log { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 2px; }
802
+ .chat-empty { color: var(--muted); font-size: 12px; padding: 12px 4px; line-height: 1.5; }
803
+ .chat-msg { padding: 8px 10px; border-radius: 10px; background: var(--bg-2); border: 1px solid var(--line); }
804
+ .chat-msg.mine { background: var(--accent-soft); border-color: var(--accent); }
805
+ .chat-head { display: flex; align-items: baseline; gap: 6px; margin-bottom: 3px; }
806
+ .chat-who { font-size: 11px; }
807
+ .chat-when { font-size: 10px; color: var(--muted); }
808
+ .chat-ctx {
809
+ font-size: 10px; color: var(--accent); border: 1px solid var(--accent);
810
+ border-radius: 999px; padding: 0 5px; margin-left: auto;
811
+ }
812
+ .chat-body { font-size: 12px; white-space: pre-wrap; word-break: break-word; }
813
+ .chat-compose { border-top: 1px solid var(--line); padding-top: 8px; }
814
+ .chat-in {
815
+ width: 100%; resize: vertical; min-height: 44px; padding: 7px 9px;
816
+ border: 1px solid var(--line); border-radius: 9px; background: var(--bg);
817
+ color: var(--ink); font: inherit; font-size: 12.5px;
818
+ }
819
+ .chat-in:focus { outline: none; border-color: var(--accent); }
820
+ .chat-foot { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
821
+ .chat-add, .chat-mic, .chat-send {
822
+ padding: 4px 9px; border: 1px solid var(--line); border-radius: 7px;
823
+ background: var(--bg); color: var(--ink); font-size: 11px; cursor: pointer;
824
+ }
825
+ .chat-add:hover, .chat-mic:hover { border-color: var(--accent); color: var(--accent); }
826
+ .chat-mic.on { border-color: var(--err, #f2555a); color: #f2555a; background: color-mix(in srgb, #f2555a 14%, transparent); }
827
+ .chat-send { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
828
+ .chat-send:hover { opacity: .9; }
829
+ .chat-rec { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: #f2555a; }
830
+ .chat-rec-dot { width: 7px; height: 7px; border-radius: 50%; background: #f2555a; animation: chatpulse 1.2s ease-in-out infinite; }
831
+ @keyframes chatpulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
832
+ .chat-err { font-size: 11px; color: #f2555a; }
833
+
776
834
  /* reactions */
777
835
  .rxns { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 6px; position: relative; }
778
836
  .rxn {
@@ -3355,6 +3413,87 @@ function initialsOf(name) {
3355
3413
  return (words[0].slice(0, 1) + words[words.length - 1].slice(0, 1)).toUpperCase();
3356
3414
  }
3357
3415
 
3416
+ // ../shared/dist/companion-tray.js
3417
+ function emptyTray() {
3418
+ return { items: [] };
3419
+ }
3420
+ function addToTray(state, item) {
3421
+ const full = { include: true, ...item };
3422
+ const at = state.items.findIndex((i) => i.id === full.id);
3423
+ if (at < 0)
3424
+ return { items: [...state.items, full] };
3425
+ const items = [...state.items];
3426
+ items[at] = { ...items[at], ...full, include: items[at].include };
3427
+ return { items };
3428
+ }
3429
+ function removeFromTray(state, id) {
3430
+ return { items: state.items.filter((i) => i.id !== id) };
3431
+ }
3432
+ function toggleInclude(state, id) {
3433
+ return { items: state.items.map((i) => i.id === id ? { ...i, include: !i.include } : i) };
3434
+ }
3435
+ function moveInTray(state, id, to) {
3436
+ const from = state.items.findIndex((i) => i.id === id);
3437
+ if (from < 0)
3438
+ return state;
3439
+ const target = Math.max(0, Math.min(state.items.length - 1, to));
3440
+ if (target === from)
3441
+ return state;
3442
+ const items = [...state.items];
3443
+ const [moved] = items.splice(from, 1);
3444
+ items.splice(target, 0, moved);
3445
+ return { items };
3446
+ }
3447
+ function nudgeInTray(state, id, delta) {
3448
+ const at = state.items.findIndex((i) => i.id === id);
3449
+ if (at < 0)
3450
+ return state;
3451
+ return moveInTray(state, id, at + delta);
3452
+ }
3453
+ function includedItems(state) {
3454
+ return state.items.filter((i) => i.include);
3455
+ }
3456
+ function trayCount(state) {
3457
+ return { total: state.items.length, included: includedItems(state).length };
3458
+ }
3459
+ function clearTray() {
3460
+ return { items: [] };
3461
+ }
3462
+ function trayPayload(state) {
3463
+ return includedItems(state).map((i) => ({
3464
+ kind: i.kind,
3465
+ id: i.ref ?? i.id,
3466
+ url: i.url,
3467
+ label: i.label
3468
+ }));
3469
+ }
3470
+ function traySummary(state) {
3471
+ const { total, included } = trayCount(state);
3472
+ if (!total)
3473
+ return "No context gathered";
3474
+ if (included === total)
3475
+ return `${total} context${total === 1 ? "" : "s"}`;
3476
+ return `${included} of ${total} included`;
3477
+ }
3478
+ function voiceSupport(env) {
3479
+ if (!env.isSecureContext)
3480
+ return "insecure";
3481
+ return env.hasCtor ? "supported" : "unsupported";
3482
+ }
3483
+ function voiceMessage(support) {
3484
+ if (support === "supported")
3485
+ return null;
3486
+ if (support === "insecure")
3487
+ return "Dictation needs a secure page (https, or localhost).";
3488
+ return "This browser cannot dictate \u2014 the Web Speech API is not available.";
3489
+ }
3490
+ function elapsedLabel(ms) {
3491
+ const total = Math.max(0, Math.floor(ms / 1e3));
3492
+ const mm = Math.floor(total / 60);
3493
+ const ss = total % 60;
3494
+ return `${mm}:${String(ss).padStart(2, "0")}`;
3495
+ }
3496
+
3358
3497
  // ../shared/dist/index.js
3359
3498
  var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
3360
3499
  var STAGE_LABELS = {
@@ -3708,7 +3847,7 @@ var HttpAdapter = class {
3708
3847
  };
3709
3848
 
3710
3849
  // src/app.ts
3711
- var SDK_VERSION = true ? "0.10.26" : "dev";
3850
+ var SDK_VERSION = true ? "0.10.27" : "dev";
3712
3851
  var ACCENTS = [
3713
3852
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
3714
3853
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -3752,6 +3891,10 @@ var TOUR = [
3752
3891
  }
3753
3892
  ];
3754
3893
  var HINTS = {
3894
+ chat: {
3895
+ title: "Talk to the agent",
3896
+ body: "Gather what you are looking at and send it in one go. The agent gets it on its very next step \u2014 not after it finishes."
3897
+ },
3755
3898
  home: { title: "Your triage at a glance", body: "The tiles count this page by default. Switch to All for the whole project, or click a tile to jump straight to that bucket." },
3756
3899
  comments: { title: "Pin, note or record", body: "Inspect selects an element, Note comments anywhere on the page, Region screenshots a rectangle, and Record captures video of one." },
3757
3900
  activity: { title: "Watch the work happen", body: "Every event the bridge or your app reports lands here, alongside Loupe's own operations. Click a tool chip to filter the feed." }
@@ -3759,7 +3902,8 @@ var HINTS = {
3759
3902
  var BUILTIN_TABS = [
3760
3903
  { id: "home", label: "Home" },
3761
3904
  { id: "comments", label: "Comments" },
3762
- { id: "activity", label: "Activity" }
3905
+ { id: "activity", label: "Activity" },
3906
+ { id: "chat", label: "Chat" }
3763
3907
  ];
3764
3908
  var DOCK_MODES = ["left", "right", "bottom", "float"];
3765
3909
  var RECORD_MAX_MS = 2e4;
@@ -3897,6 +4041,17 @@ var LoupeApp = class {
3897
4041
  * rather than a `.tab-<id>` selector, so host-registered ids need no CSS.
3898
4042
  */
3899
4043
  this.viewEls = /* @__PURE__ */ new Map();
4044
+ /** The gather tray: elements and screenshots collected into one message. */
4045
+ this.chatTray = emptyTray();
4046
+ /** Sent messages and received replies, in one ordered transcript. */
4047
+ this.chatLog = [];
4048
+ this.chatUnread = 0;
4049
+ this.chatSending = false;
4050
+ this.chatError = "";
4051
+ this.voiceOn = false;
4052
+ this.voiceBase = "";
4053
+ this.voiceStart = 0;
4054
+ this.lastReplyId = "";
3900
4055
  /** In-app notifications: mentions of you, newest first. */
3901
4056
  this.notifications = [];
3902
4057
  // ---- inspector ------------------------------------------------------------
@@ -4151,6 +4306,7 @@ var LoupeApp = class {
4151
4306
  this.renderHome();
4152
4307
  this.startPresence();
4153
4308
  this.startLiveThreads();
4309
+ this.startCompanionStream();
4154
4310
  void this.loadNotifications();
4155
4311
  if (this.scope === "all") void this.loadAllComments();
4156
4312
  this.observe();
@@ -4368,6 +4524,8 @@ var LoupeApp = class {
4368
4524
  commentsView.append(this.commentsHint, tools, listHead, this.reviewBar, this.listEl, this.buildIntegrations());
4369
4525
  const activityView = el("div", "view activity-view");
4370
4526
  this.activityEl = activityView;
4527
+ const chatView = el("div", "view chat-view");
4528
+ this.chatEl = chatView;
4371
4529
  const customViews = (this.cfg.tabs ?? []).map((t) => {
4372
4530
  const view = el("div", "view custom-view");
4373
4531
  const slot = el("div", "hint-slot");
@@ -4399,20 +4557,370 @@ var LoupeApp = class {
4399
4557
  resize.addEventListener("pointerdown", this.onResizeDown);
4400
4558
  this.consentEl = el("div", "consent");
4401
4559
  this.consentEl.style.display = "none";
4402
- dock.append(head, this.minBar, this.consentEl, tabs, homeView, commentsView, activityView, ...customViews, resize);
4403
- for (const [id, view] of [["home", homeView], ["comments", commentsView], ["activity", activityView]]) {
4560
+ dock.append(head, this.minBar, this.consentEl, tabs, homeView, commentsView, activityView, chatView, ...customViews, resize);
4561
+ for (const [id, view] of [["home", homeView], ["comments", commentsView], ["activity", activityView], ["chat", chatView]]) {
4404
4562
  this.viewEls.set(id, view);
4405
4563
  }
4406
4564
  customViews.forEach((v, i) => this.viewEls.set((this.cfg.tabs ?? [])[i].id, v));
4407
4565
  this.buildHomePanel();
4408
4566
  this.buildActivityPanel();
4567
+ this.buildChatPanel();
4409
4568
  return dock;
4410
4569
  }
4570
+ buildChatPanel() {
4571
+ this.chatEl.innerHTML = `<div class="hint-slot" id="loupe-chint"></div><div class="chat-tray" id="loupe-tray"></div><div class="chat-log" id="loupe-chatlog"></div><div class="chat-compose"><textarea class="chat-in" id="loupe-chatin" rows="2" placeholder="Tell the agent what you see\u2026"></textarea><div class="chat-foot"><button class="chat-add" id="loupe-chatadd" title="Add what you have selected">\uFF0B Selection</button><button class="chat-mic" id="loupe-chatmic" title="Dictate">\u{1F399}</button><span class="chat-rec" id="loupe-chatrec" style="display:none"><span class="chat-rec-dot"></span><span id="loupe-chatrectime">0:00</span></span><span class="chat-spacer"></span><span class="chat-err" id="loupe-chaterr"></span><button class="chat-send" id="loupe-chatsend">Send</button></div></div>`;
4572
+ const input = this.chatEl.querySelector("#loupe-chatin");
4573
+ input.addEventListener("click", (e) => e.stopPropagation());
4574
+ input.oninput = () => {
4575
+ this.chatError = "";
4576
+ this.renderChatError();
4577
+ };
4578
+ input.onkeydown = (e) => {
4579
+ if (e.key === "Enter" && !e.shiftKey) {
4580
+ e.preventDefault();
4581
+ void this.sendCompanion();
4582
+ }
4583
+ };
4584
+ this.chatEl.querySelector("#loupe-chatadd").onclick = (e) => {
4585
+ e.stopPropagation();
4586
+ void this.addSelectionToTray();
4587
+ };
4588
+ this.chatEl.querySelector("#loupe-chatsend").onclick = (e) => {
4589
+ e.stopPropagation();
4590
+ void this.sendCompanion();
4591
+ };
4592
+ this.chatEl.querySelector("#loupe-chatmic").onclick = (e) => {
4593
+ e.stopPropagation();
4594
+ this.toggleVoice();
4595
+ };
4596
+ this.renderChatTray();
4597
+ this.renderChatLog();
4598
+ this.updateChatBadge();
4599
+ }
4600
+ /** The Chat tab label carries the unread count, so it is visible from any page. */
4601
+ updateChatBadge() {
4602
+ const tab = this.tabList.findIndex((t) => t.id === "chat");
4603
+ const buttons = this.shadow?.querySelectorAll(".tabs .tab");
4604
+ const el2 = buttons?.[tab];
4605
+ if (!el2) return;
4606
+ el2.textContent = this.chatUnread ? `Chat \xB7 ${this.chatUnread > 9 ? "9+" : this.chatUnread}` : "Chat";
4607
+ }
4608
+ renderChatError() {
4609
+ const box = this.chatEl?.querySelector("#loupe-chaterr");
4610
+ if (box) box.textContent = this.chatError;
4611
+ }
4612
+ renderChatTray() {
4613
+ const box = this.chatEl?.querySelector("#loupe-tray");
4614
+ if (!box) return;
4615
+ box.textContent = "";
4616
+ if (!this.chatTray.items.length) {
4617
+ box.style.display = "none";
4618
+ return;
4619
+ }
4620
+ box.style.display = "";
4621
+ const head = el("div", "tray-head");
4622
+ head.append(
4623
+ el("span", "tray-title", traySummary(this.chatTray)),
4624
+ el("span", "tray-spacer")
4625
+ );
4626
+ const clear = el("button", "tray-x", "Clear");
4627
+ clear.onclick = (e) => {
4628
+ e.stopPropagation();
4629
+ this.chatTray = clearTray();
4630
+ this.renderChatTray();
4631
+ };
4632
+ head.appendChild(clear);
4633
+ box.appendChild(head);
4634
+ this.chatTray.items.forEach((item, i) => {
4635
+ const chip = el("div", "tray-chip" + (item.include ? "" : " off"));
4636
+ const check = el("input");
4637
+ check.type = "checkbox";
4638
+ check.checked = item.include;
4639
+ check.title = "Include in the message";
4640
+ check.onchange = (e) => {
4641
+ e.stopPropagation();
4642
+ this.chatTray = toggleInclude(this.chatTray, item.id);
4643
+ this.renderChatTray();
4644
+ };
4645
+ const label = el("span", "tray-label", item.label);
4646
+ label.title = item.label + (item.url ? ` \u2014 ${item.url}` : "");
4647
+ const up = el("button", "tray-nudge", "\u2191");
4648
+ up.disabled = i === 0;
4649
+ up.onclick = (e) => {
4650
+ e.stopPropagation();
4651
+ this.chatTray = nudgeInTray(this.chatTray, item.id, -1);
4652
+ this.renderChatTray();
4653
+ };
4654
+ const down = el("button", "tray-nudge", "\u2193");
4655
+ down.disabled = i === this.chatTray.items.length - 1;
4656
+ down.onclick = (e) => {
4657
+ e.stopPropagation();
4658
+ this.chatTray = nudgeInTray(this.chatTray, item.id, 1);
4659
+ this.renderChatTray();
4660
+ };
4661
+ const x = el("button", "tray-x", "\u2715");
4662
+ x.title = "Remove";
4663
+ x.onclick = (e) => {
4664
+ e.stopPropagation();
4665
+ this.chatTray = removeFromTray(this.chatTray, item.id);
4666
+ this.renderChatTray();
4667
+ };
4668
+ chip.append(check, item.thumb ? (() => {
4669
+ const img = el("img", "tray-thumb");
4670
+ img.src = item.thumb;
4671
+ img.alt = "";
4672
+ return img;
4673
+ })() : el("span", "tray-kind", item.kind), label, up, down, x);
4674
+ box.appendChild(chip);
4675
+ });
4676
+ }
4677
+ renderChatLog() {
4678
+ const box = this.chatEl?.querySelector("#loupe-chatlog");
4679
+ if (!box) return;
4680
+ box.textContent = "";
4681
+ if (!this.chatLog.length) {
4682
+ box.appendChild(el(
4683
+ "div",
4684
+ "chat-empty",
4685
+ this.cfg.bridge ? "Say what you see while the agent works \u2014 it lands on the agent\u2019s very next step." : "Connect a bridge to talk to the agent. Without one there is nothing to deliver to."
4686
+ ));
4687
+ return;
4688
+ }
4689
+ for (const m of this.chatLog) {
4690
+ const row = el("div", "chat-msg" + (m.mine ? " mine" : ""));
4691
+ const head = el("div", "chat-head");
4692
+ head.append(
4693
+ el("b", "chat-who", m.mine ? "You" : m.author ?? "Agent"),
4694
+ el("span", "chat-when", fmtAgo(m.at))
4695
+ );
4696
+ if (m.count) head.appendChild(el("span", "chat-ctx", `${m.count} context${m.count === 1 ? "" : "s"}`));
4697
+ const body = el("div", "chat-body");
4698
+ for (const seg of mentionSegments(m.body, parseMentions(m.body))) {
4699
+ if (seg.mention) body.appendChild(el("span", "mention", seg.text));
4700
+ else body.appendChild(document.createTextNode(seg.text));
4701
+ }
4702
+ row.append(head, body);
4703
+ box.appendChild(row);
4704
+ }
4705
+ box.scrollTop = box.scrollHeight;
4706
+ }
4707
+ /** Pull the current selection off the bridge and into the tray. */
4708
+ async addSelectionToTray() {
4709
+ const base = this.cfg.bridge?.replace(/\/$/, "");
4710
+ if (!base) {
4711
+ this.chatError = "No bridge configured.";
4712
+ this.renderChatError();
4713
+ return;
4714
+ }
4715
+ try {
4716
+ const res = await fetch(`${base}/selection/latest`);
4717
+ if (!res.ok) throw new Error(String(res.status));
4718
+ const { selection } = await res.json();
4719
+ if (!selection) {
4720
+ this.chatError = "Nothing selected yet.";
4721
+ this.renderChatError();
4722
+ return;
4723
+ }
4724
+ this.chatTray = addToTray(this.chatTray, {
4725
+ id: selection.correlationId ?? selection.selector ?? String(selection.at),
4726
+ kind: "element",
4727
+ // The selector is what identifies it; the text makes it recognisable.
4728
+ label: `${selection.selector ?? selection.tag ?? "element"}${selection.text ? ` \u2014 \u201C${String(selection.text).slice(0, 24)}\u201D` : ""}`,
4729
+ url: selection.url,
4730
+ ref: selection.correlationId ?? selection.selector
4731
+ });
4732
+ this.chatError = "";
4733
+ this.renderChatError();
4734
+ this.renderChatTray();
4735
+ } catch (e) {
4736
+ this.chatError = e instanceof Error ? `Could not read the selection: ${e.message}` : "Could not read the selection.";
4737
+ this.renderChatError();
4738
+ }
4739
+ }
4740
+ async sendCompanion() {
4741
+ const input = this.chatEl.querySelector("#loupe-chatin");
4742
+ const body = input.value.trim();
4743
+ const contexts = trayPayload(this.chatTray);
4744
+ if (!body && !contexts.length) return;
4745
+ if (this.chatSending) return;
4746
+ const base = this.cfg.bridge?.replace(/\/$/, "");
4747
+ if (!base) {
4748
+ this.chatError = "No bridge configured \u2014 nothing to deliver to.";
4749
+ this.renderChatError();
4750
+ return;
4751
+ }
4752
+ this.chatSending = true;
4753
+ this.chatError = "";
4754
+ this.renderChatError();
4755
+ const optimistic = {
4756
+ id: `pending-${Date.now().toString(36)}`,
4757
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4758
+ mine: true,
4759
+ body: body || "(context only)",
4760
+ count: contexts.length
4761
+ };
4762
+ this.chatLog.push(optimistic);
4763
+ this.renderChatLog();
4764
+ try {
4765
+ const res = await fetch(`${base}/companion`, {
4766
+ method: "POST",
4767
+ headers: { "content-type": "application/json" },
4768
+ body: JSON.stringify({
4769
+ body: body || "(see the gathered context)",
4770
+ author: { id: this.cfg.user.id, name: this.cfg.user.name },
4771
+ contexts,
4772
+ attachments: includedItems(this.chatTray).filter((i) => i.kind === "screenshot" && i.thumb).map((i) => ({ url: i.thumb, kind: "image", name: i.label })),
4773
+ voice: this.voiceBase.length > 0
4774
+ })
4775
+ });
4776
+ if (!res.ok) throw new Error(`the bridge answered ${res.status}`);
4777
+ input.value = "";
4778
+ this.chatTray = clearTray();
4779
+ this.modelVoiceBase("");
4780
+ this.renderChatTray();
4781
+ this.addActivity({ kind: "message.create", label: `Sent a brief to the agent${contexts.length ? ` (${contexts.length} contexts)` : ""}` });
4782
+ } catch (e) {
4783
+ this.chatError = e instanceof Error ? e.message : "Could not send.";
4784
+ this.renderChatError();
4785
+ this.addActivity({ kind: "error", level: "error", label: "Companion message failed to send" });
4786
+ } finally {
4787
+ this.chatSending = false;
4788
+ this.renderChatLog();
4789
+ }
4790
+ }
4791
+ modelVoiceBase(v) {
4792
+ this.voiceBase = v;
4793
+ }
4411
4794
  /**
4412
- * The Activity view: a status row, a collapsible summary, the tool chips and the
4413
- * live feed. Everything is re-derived from `activityEvents` on render, so the
4414
- * summary and the micro-stats can never disagree with the feed below them.
4795
+ * Dictation.
4796
+ *
4797
+ * Two different reasons the control can be unavailable, and they need different
4798
+ * words: an unsupported browser is a fact, while a non-secure page is something the
4799
+ * person can fix. Saying "your browser cannot do this" when the real problem is
4800
+ * `http://` sends them looking in the wrong place.
4415
4801
  */
4802
+ toggleVoice() {
4803
+ const support = voiceSupport({
4804
+ hasCtor: typeof globalThis.SpeechRecognition !== "undefined" || typeof globalThis.webkitSpeechRecognition !== "undefined",
4805
+ isSecureContext: typeof isSecureContext === "boolean" ? isSecureContext : true
4806
+ });
4807
+ if (support !== "supported") {
4808
+ this.chatError = voiceMessage(support) ?? "Dictation is unavailable.";
4809
+ this.renderChatError();
4810
+ return;
4811
+ }
4812
+ if (this.voiceOn) return this.stopChatVoice();
4813
+ const Ctor = globalThis.SpeechRecognition ?? globalThis.webkitSpeechRecognition;
4814
+ const input = this.chatEl.querySelector("#loupe-chatin");
4815
+ try {
4816
+ this.stopChatVoice();
4817
+ const rec = new Ctor();
4818
+ rec.continuous = true;
4819
+ rec.interimResults = true;
4820
+ rec.lang = document.documentElement.lang || "en-US";
4821
+ this.voiceBase = input.value ? `${input.value.trim()} ` : "";
4822
+ rec.onresult = (ev) => {
4823
+ let text = "";
4824
+ for (let i = ev.resultIndex; i < ev.results.length; i++) text += ev.results[i][0].transcript;
4825
+ input.value = `${this.voiceBase}${text}`;
4826
+ };
4827
+ rec.onerror = (ev) => {
4828
+ this.chatError = ev?.error === "not-allowed" ? "Microphone permission was refused." : "Dictation stopped.";
4829
+ this.renderChatError();
4830
+ this.stopChatVoice();
4831
+ };
4832
+ rec.onend = () => {
4833
+ if (this.voiceOn) this.stopChatVoice();
4834
+ };
4835
+ rec.start();
4836
+ this.voice = rec;
4837
+ this.voiceTarget = input;
4838
+ this.voiceOn = true;
4839
+ this.voiceStart = Date.now();
4840
+ const pill = this.chatEl.querySelector("#loupe-chatrec");
4841
+ pill.style.display = "";
4842
+ this.voiceTimer = setInterval(() => {
4843
+ const t = this.chatEl.querySelector("#loupe-chatrectime");
4844
+ if (t) t.textContent = elapsedLabel(Date.now() - this.voiceStart);
4845
+ }, 500);
4846
+ this.chatEl.querySelector("#loupe-chatmic").classList.add("on");
4847
+ this.renderChatTray();
4848
+ } catch (e) {
4849
+ this.chatError = e instanceof Error ? e.message : "Could not start dictation.";
4850
+ this.renderChatError();
4851
+ }
4852
+ }
4853
+ stopChatVoice() {
4854
+ this.voiceOn = false;
4855
+ if (this.voiceTimer) clearInterval(this.voiceTimer);
4856
+ this.voiceTimer = void 0;
4857
+ this.stopVoice();
4858
+ const pill = this.chatEl?.querySelector("#loupe-chatrec");
4859
+ if (pill) pill.style.display = "none";
4860
+ this.chatEl?.querySelector("#loupe-chatmic")?.classList.remove("on");
4861
+ }
4862
+ /**
4863
+ * Follow the bridge for replies.
4864
+ *
4865
+ * With SSE where it works and a poll where it does not, because a reply the person
4866
+ * never sees is the same as no reply — and the panel is the only place it appears.
4867
+ */
4868
+ startCompanionStream() {
4869
+ const base = this.cfg.bridge?.replace(/\/$/, "");
4870
+ if (!base || this.companionSource) return;
4871
+ if (typeof EventSource === "undefined") return;
4872
+ try {
4873
+ const source = new EventSource(`${base}/events`);
4874
+ source.addEventListener("companion", (ev) => {
4875
+ let parsed;
4876
+ try {
4877
+ parsed = JSON.parse(ev.data);
4878
+ } catch {
4879
+ return;
4880
+ }
4881
+ if (parsed?.eventType === "reply") this.receiveReply(parsed.data);
4882
+ });
4883
+ source.onerror = () => {
4884
+ };
4885
+ this.companionSource = source;
4886
+ this.companionPoll = setInterval(() => void this.pollReplies(), 5e3);
4887
+ } catch {
4888
+ this.companionPoll = setInterval(() => void this.pollReplies(), 4e3);
4889
+ }
4890
+ }
4891
+ async pollReplies() {
4892
+ const base = this.cfg.bridge?.replace(/\/$/, "");
4893
+ if (!base) return;
4894
+ try {
4895
+ const res = await fetch(`${base}/companion?since=${encodeURIComponent(this.lastReplyId)}`);
4896
+ if (!res.ok) return;
4897
+ const { replies } = await res.json();
4898
+ for (const r of replies ?? []) this.receiveReply(r);
4899
+ } catch {
4900
+ }
4901
+ }
4902
+ receiveReply(reply) {
4903
+ if (!reply?.id || !reply.body) return;
4904
+ if (this.chatLog.some((m) => m.id === reply.id)) return;
4905
+ this.lastReplyId = reply.id;
4906
+ this.chatLog.push({ id: reply.id, at: reply.at ?? (/* @__PURE__ */ new Date()).toISOString(), mine: false, body: reply.body });
4907
+ if (this.tab !== "chat" || this.minimized) {
4908
+ this.chatUnread++;
4909
+ this.updateChatBadge();
4910
+ this.desktopNotify("Loupe \u2014 the agent replied", reply.body);
4911
+ }
4912
+ this.addActivity({ kind: "agent.reply", label: "The agent replied" });
4913
+ this.renderChatLog();
4914
+ }
4915
+ /** A desktop notification, when the browser will show one. Never a prompt for it. */
4916
+ desktopNotify(title, body) {
4917
+ try {
4918
+ const N = globalThis.Notification;
4919
+ if (!N || N.permission !== "granted") return;
4920
+ new N(title, { body: String(body).slice(0, 200), tag: "loupe-companion" });
4921
+ } catch {
4922
+ }
4923
+ }
4416
4924
  buildActivityPanel() {
4417
4925
  this.activityEl.innerHTML = `<div class="hint-slot" id="loupe-ahint"></div><div class="mon-status" id="loupe-mon-status"><span class="mon-dot"></span><span class="mon-status-label"></span><span class="mon-spacer"></span><button class="mon-clear" data-role="mon-clear" title="Clear the feed">Clear</button></div><div class="mon-summary" id="loupe-mon-summary"></div><div class="mon-micro" id="loupe-mon-micro"></div><div class="mon-chips" id="loupe-mon-chips"></div><div class="mon-feed" id="loupe-mon-feed"></div>`;
4418
4926
  this.feedEl = this.activityEl.querySelector("#loupe-mon-feed");
@@ -6753,17 +7261,17 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6753
7261
  if (typeof EventSource === "undefined") return;
6754
7262
  try {
6755
7263
  const source = new EventSource(`${bridge.replace(/\/$/, "")}/thread-updates`);
6756
- source.onmessage = (ev) => {
7264
+ source.addEventListener("thread", (ev) => {
6757
7265
  let parsed;
6758
7266
  try {
6759
7267
  parsed = JSON.parse(ev.data);
6760
7268
  } catch {
6761
7269
  return;
6762
7270
  }
6763
- if (!parsed || parsed.type !== "thread" || typeof parsed.threadId !== "string") return;
7271
+ if (!parsed || typeof parsed.threadId !== "string") return;
6764
7272
  if (!this.messages.has(parsed.threadId)) return;
6765
7273
  void this.refreshMessages(parsed.threadId);
6766
- };
7274
+ });
6767
7275
  source.onerror = () => {
6768
7276
  };
6769
7277
  this.liveSource = source;
@@ -6986,6 +7494,10 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6986
7494
  destroy() {
6987
7495
  this.liveSource?.close();
6988
7496
  this.liveSource = void 0;
7497
+ this.companionSource?.close();
7498
+ this.companionSource = void 0;
7499
+ if (this.companionPoll) clearInterval(this.companionPoll);
7500
+ this.stopVoice();
6989
7501
  this.stopPresence();
6990
7502
  this.stopRecording?.();
6991
7503
  this.setMode("off");