@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.
@@ -807,6 +807,64 @@ var Loupe = (() => {
807
807
  object-fit: cover; cursor: pointer; background: var(--bg-2);
808
808
  }
809
809
 
810
+ /* ------------------------------------------------------------- companion chat */
811
+ /* Same inset as the Activity view \u2014 without it the composer and its Send button sit
812
+ flush against the panel's edge, which reads as a clipped button. */
813
+ .chat-view { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; padding: 10px 12px 16px; }
814
+ .chat-tray {
815
+ display: flex; flex-direction: column; gap: 4px; padding: 8px;
816
+ border: 1px solid var(--line); border-radius: 10px; background: var(--bg-2);
817
+ }
818
+ .tray-head { display: flex; align-items: center; gap: 6px; }
819
+ .tray-title { font-size: 11px; color: var(--muted); }
820
+ .tray-spacer, .chat-spacer { flex: 1; }
821
+ .tray-chip {
822
+ display: flex; align-items: center; gap: 6px; padding: 4px 6px;
823
+ border: 1px solid var(--line); border-radius: 8px; background: var(--bg); font-size: 11px;
824
+ }
825
+ .tray-chip.off { opacity: .5; }
826
+ .tray-thumb { width: 24px; height: 24px; border-radius: 4px; object-fit: cover; flex: none; }
827
+ .tray-kind { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; flex: none; }
828
+ .tray-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; }
829
+ .tray-nudge, .tray-x {
830
+ border: 0; background: transparent; color: var(--muted); cursor: pointer;
831
+ font-size: 11px; padding: 1px 4px; border-radius: 4px; line-height: 1.2;
832
+ }
833
+ .tray-nudge:disabled { opacity: .3; cursor: default; }
834
+ .tray-nudge:not(:disabled):hover, .tray-x:hover { color: var(--accent); background: var(--accent-soft); }
835
+ .chat-log { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 2px; }
836
+ .chat-empty { color: var(--muted); font-size: 12px; padding: 12px 4px; line-height: 1.5; }
837
+ .chat-msg { padding: 8px 10px; border-radius: 10px; background: var(--bg-2); border: 1px solid var(--line); }
838
+ .chat-msg.mine { background: var(--accent-soft); border-color: var(--accent); }
839
+ .chat-head { display: flex; align-items: baseline; gap: 6px; margin-bottom: 3px; }
840
+ .chat-who { font-size: 11px; }
841
+ .chat-when { font-size: 10px; color: var(--muted); }
842
+ .chat-ctx {
843
+ font-size: 10px; color: var(--accent); border: 1px solid var(--accent);
844
+ border-radius: 999px; padding: 0 5px; margin-left: auto;
845
+ }
846
+ .chat-body { font-size: 12px; white-space: pre-wrap; word-break: break-word; }
847
+ .chat-compose { border-top: 1px solid var(--line); padding-top: 8px; }
848
+ .chat-in {
849
+ width: 100%; resize: vertical; min-height: 44px; padding: 7px 9px;
850
+ border: 1px solid var(--line); border-radius: 9px; background: var(--bg);
851
+ color: var(--ink); font: inherit; font-size: 12.5px;
852
+ }
853
+ .chat-in:focus { outline: none; border-color: var(--accent); }
854
+ .chat-foot { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
855
+ .chat-add, .chat-mic, .chat-send {
856
+ padding: 4px 9px; border: 1px solid var(--line); border-radius: 7px;
857
+ background: var(--bg); color: var(--ink); font-size: 11px; cursor: pointer;
858
+ }
859
+ .chat-add:hover, .chat-mic:hover { border-color: var(--accent); color: var(--accent); }
860
+ .chat-mic.on { border-color: var(--err, #f2555a); color: #f2555a; background: color-mix(in srgb, #f2555a 14%, transparent); }
861
+ .chat-send { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
862
+ .chat-send:hover { opacity: .9; }
863
+ .chat-rec { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: #f2555a; }
864
+ .chat-rec-dot { width: 7px; height: 7px; border-radius: 50%; background: #f2555a; animation: chatpulse 1.2s ease-in-out infinite; }
865
+ @keyframes chatpulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
866
+ .chat-err { font-size: 11px; color: #f2555a; }
867
+
810
868
  /* reactions */
811
869
  .rxns { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 6px; position: relative; }
812
870
  .rxn {
@@ -3389,6 +3447,87 @@ var Loupe = (() => {
3389
3447
  return (words[0].slice(0, 1) + words[words.length - 1].slice(0, 1)).toUpperCase();
3390
3448
  }
3391
3449
 
3450
+ // ../shared/dist/companion-tray.js
3451
+ function emptyTray() {
3452
+ return { items: [] };
3453
+ }
3454
+ function addToTray(state, item) {
3455
+ const full = { include: true, ...item };
3456
+ const at = state.items.findIndex((i) => i.id === full.id);
3457
+ if (at < 0)
3458
+ return { items: [...state.items, full] };
3459
+ const items = [...state.items];
3460
+ items[at] = { ...items[at], ...full, include: items[at].include };
3461
+ return { items };
3462
+ }
3463
+ function removeFromTray(state, id) {
3464
+ return { items: state.items.filter((i) => i.id !== id) };
3465
+ }
3466
+ function toggleInclude(state, id) {
3467
+ return { items: state.items.map((i) => i.id === id ? { ...i, include: !i.include } : i) };
3468
+ }
3469
+ function moveInTray(state, id, to) {
3470
+ const from = state.items.findIndex((i) => i.id === id);
3471
+ if (from < 0)
3472
+ return state;
3473
+ const target = Math.max(0, Math.min(state.items.length - 1, to));
3474
+ if (target === from)
3475
+ return state;
3476
+ const items = [...state.items];
3477
+ const [moved] = items.splice(from, 1);
3478
+ items.splice(target, 0, moved);
3479
+ return { items };
3480
+ }
3481
+ function nudgeInTray(state, id, delta) {
3482
+ const at = state.items.findIndex((i) => i.id === id);
3483
+ if (at < 0)
3484
+ return state;
3485
+ return moveInTray(state, id, at + delta);
3486
+ }
3487
+ function includedItems(state) {
3488
+ return state.items.filter((i) => i.include);
3489
+ }
3490
+ function trayCount(state) {
3491
+ return { total: state.items.length, included: includedItems(state).length };
3492
+ }
3493
+ function clearTray() {
3494
+ return { items: [] };
3495
+ }
3496
+ function trayPayload(state) {
3497
+ return includedItems(state).map((i) => ({
3498
+ kind: i.kind,
3499
+ id: i.ref ?? i.id,
3500
+ url: i.url,
3501
+ label: i.label
3502
+ }));
3503
+ }
3504
+ function traySummary(state) {
3505
+ const { total, included } = trayCount(state);
3506
+ if (!total)
3507
+ return "No context gathered";
3508
+ if (included === total)
3509
+ return `${total} context${total === 1 ? "" : "s"}`;
3510
+ return `${included} of ${total} included`;
3511
+ }
3512
+ function voiceSupport(env) {
3513
+ if (!env.isSecureContext)
3514
+ return "insecure";
3515
+ return env.hasCtor ? "supported" : "unsupported";
3516
+ }
3517
+ function voiceMessage(support) {
3518
+ if (support === "supported")
3519
+ return null;
3520
+ if (support === "insecure")
3521
+ return "Dictation needs a secure page (https, or localhost).";
3522
+ return "This browser cannot dictate \u2014 the Web Speech API is not available.";
3523
+ }
3524
+ function elapsedLabel(ms) {
3525
+ const total = Math.max(0, Math.floor(ms / 1e3));
3526
+ const mm = Math.floor(total / 60);
3527
+ const ss = total % 60;
3528
+ return `${mm}:${String(ss).padStart(2, "0")}`;
3529
+ }
3530
+
3392
3531
  // ../shared/dist/index.js
3393
3532
  var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
3394
3533
  var STAGE_LABELS = {
@@ -3742,7 +3881,7 @@ var Loupe = (() => {
3742
3881
  };
3743
3882
 
3744
3883
  // src/app.ts
3745
- var SDK_VERSION = true ? "0.10.26" : "dev";
3884
+ var SDK_VERSION = true ? "0.10.27" : "dev";
3746
3885
  var ACCENTS = [
3747
3886
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
3748
3887
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -3786,6 +3925,10 @@ var Loupe = (() => {
3786
3925
  }
3787
3926
  ];
3788
3927
  var HINTS = {
3928
+ chat: {
3929
+ title: "Talk to the agent",
3930
+ 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."
3931
+ },
3789
3932
  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." },
3790
3933
  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." },
3791
3934
  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." }
@@ -3793,7 +3936,8 @@ var Loupe = (() => {
3793
3936
  var BUILTIN_TABS = [
3794
3937
  { id: "home", label: "Home" },
3795
3938
  { id: "comments", label: "Comments" },
3796
- { id: "activity", label: "Activity" }
3939
+ { id: "activity", label: "Activity" },
3940
+ { id: "chat", label: "Chat" }
3797
3941
  ];
3798
3942
  var DOCK_MODES = ["left", "right", "bottom", "float"];
3799
3943
  var RECORD_MAX_MS = 2e4;
@@ -3931,6 +4075,17 @@ var Loupe = (() => {
3931
4075
  * rather than a `.tab-<id>` selector, so host-registered ids need no CSS.
3932
4076
  */
3933
4077
  this.viewEls = /* @__PURE__ */ new Map();
4078
+ /** The gather tray: elements and screenshots collected into one message. */
4079
+ this.chatTray = emptyTray();
4080
+ /** Sent messages and received replies, in one ordered transcript. */
4081
+ this.chatLog = [];
4082
+ this.chatUnread = 0;
4083
+ this.chatSending = false;
4084
+ this.chatError = "";
4085
+ this.voiceOn = false;
4086
+ this.voiceBase = "";
4087
+ this.voiceStart = 0;
4088
+ this.lastReplyId = "";
3934
4089
  /** In-app notifications: mentions of you, newest first. */
3935
4090
  this.notifications = [];
3936
4091
  // ---- inspector ------------------------------------------------------------
@@ -4185,6 +4340,7 @@ var Loupe = (() => {
4185
4340
  this.renderHome();
4186
4341
  this.startPresence();
4187
4342
  this.startLiveThreads();
4343
+ this.startCompanionStream();
4188
4344
  void this.loadNotifications();
4189
4345
  if (this.scope === "all") void this.loadAllComments();
4190
4346
  this.observe();
@@ -4402,6 +4558,8 @@ var Loupe = (() => {
4402
4558
  commentsView.append(this.commentsHint, tools, listHead, this.reviewBar, this.listEl, this.buildIntegrations());
4403
4559
  const activityView = el("div", "view activity-view");
4404
4560
  this.activityEl = activityView;
4561
+ const chatView = el("div", "view chat-view");
4562
+ this.chatEl = chatView;
4405
4563
  const customViews = (this.cfg.tabs ?? []).map((t) => {
4406
4564
  const view = el("div", "view custom-view");
4407
4565
  const slot = el("div", "hint-slot");
@@ -4433,20 +4591,370 @@ var Loupe = (() => {
4433
4591
  resize.addEventListener("pointerdown", this.onResizeDown);
4434
4592
  this.consentEl = el("div", "consent");
4435
4593
  this.consentEl.style.display = "none";
4436
- dock.append(head, this.minBar, this.consentEl, tabs, homeView, commentsView, activityView, ...customViews, resize);
4437
- for (const [id, view] of [["home", homeView], ["comments", commentsView], ["activity", activityView]]) {
4594
+ dock.append(head, this.minBar, this.consentEl, tabs, homeView, commentsView, activityView, chatView, ...customViews, resize);
4595
+ for (const [id, view] of [["home", homeView], ["comments", commentsView], ["activity", activityView], ["chat", chatView]]) {
4438
4596
  this.viewEls.set(id, view);
4439
4597
  }
4440
4598
  customViews.forEach((v, i) => this.viewEls.set((this.cfg.tabs ?? [])[i].id, v));
4441
4599
  this.buildHomePanel();
4442
4600
  this.buildActivityPanel();
4601
+ this.buildChatPanel();
4443
4602
  return dock;
4444
4603
  }
4604
+ buildChatPanel() {
4605
+ 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>`;
4606
+ const input = this.chatEl.querySelector("#loupe-chatin");
4607
+ input.addEventListener("click", (e) => e.stopPropagation());
4608
+ input.oninput = () => {
4609
+ this.chatError = "";
4610
+ this.renderChatError();
4611
+ };
4612
+ input.onkeydown = (e) => {
4613
+ if (e.key === "Enter" && !e.shiftKey) {
4614
+ e.preventDefault();
4615
+ void this.sendCompanion();
4616
+ }
4617
+ };
4618
+ this.chatEl.querySelector("#loupe-chatadd").onclick = (e) => {
4619
+ e.stopPropagation();
4620
+ void this.addSelectionToTray();
4621
+ };
4622
+ this.chatEl.querySelector("#loupe-chatsend").onclick = (e) => {
4623
+ e.stopPropagation();
4624
+ void this.sendCompanion();
4625
+ };
4626
+ this.chatEl.querySelector("#loupe-chatmic").onclick = (e) => {
4627
+ e.stopPropagation();
4628
+ this.toggleVoice();
4629
+ };
4630
+ this.renderChatTray();
4631
+ this.renderChatLog();
4632
+ this.updateChatBadge();
4633
+ }
4634
+ /** The Chat tab label carries the unread count, so it is visible from any page. */
4635
+ updateChatBadge() {
4636
+ const tab = this.tabList.findIndex((t) => t.id === "chat");
4637
+ const buttons = this.shadow?.querySelectorAll(".tabs .tab");
4638
+ const el2 = buttons?.[tab];
4639
+ if (!el2) return;
4640
+ el2.textContent = this.chatUnread ? `Chat \xB7 ${this.chatUnread > 9 ? "9+" : this.chatUnread}` : "Chat";
4641
+ }
4642
+ renderChatError() {
4643
+ const box = this.chatEl?.querySelector("#loupe-chaterr");
4644
+ if (box) box.textContent = this.chatError;
4645
+ }
4646
+ renderChatTray() {
4647
+ const box = this.chatEl?.querySelector("#loupe-tray");
4648
+ if (!box) return;
4649
+ box.textContent = "";
4650
+ if (!this.chatTray.items.length) {
4651
+ box.style.display = "none";
4652
+ return;
4653
+ }
4654
+ box.style.display = "";
4655
+ const head = el("div", "tray-head");
4656
+ head.append(
4657
+ el("span", "tray-title", traySummary(this.chatTray)),
4658
+ el("span", "tray-spacer")
4659
+ );
4660
+ const clear = el("button", "tray-x", "Clear");
4661
+ clear.onclick = (e) => {
4662
+ e.stopPropagation();
4663
+ this.chatTray = clearTray();
4664
+ this.renderChatTray();
4665
+ };
4666
+ head.appendChild(clear);
4667
+ box.appendChild(head);
4668
+ this.chatTray.items.forEach((item, i) => {
4669
+ const chip = el("div", "tray-chip" + (item.include ? "" : " off"));
4670
+ const check = el("input");
4671
+ check.type = "checkbox";
4672
+ check.checked = item.include;
4673
+ check.title = "Include in the message";
4674
+ check.onchange = (e) => {
4675
+ e.stopPropagation();
4676
+ this.chatTray = toggleInclude(this.chatTray, item.id);
4677
+ this.renderChatTray();
4678
+ };
4679
+ const label = el("span", "tray-label", item.label);
4680
+ label.title = item.label + (item.url ? ` \u2014 ${item.url}` : "");
4681
+ const up = el("button", "tray-nudge", "\u2191");
4682
+ up.disabled = i === 0;
4683
+ up.onclick = (e) => {
4684
+ e.stopPropagation();
4685
+ this.chatTray = nudgeInTray(this.chatTray, item.id, -1);
4686
+ this.renderChatTray();
4687
+ };
4688
+ const down = el("button", "tray-nudge", "\u2193");
4689
+ down.disabled = i === this.chatTray.items.length - 1;
4690
+ down.onclick = (e) => {
4691
+ e.stopPropagation();
4692
+ this.chatTray = nudgeInTray(this.chatTray, item.id, 1);
4693
+ this.renderChatTray();
4694
+ };
4695
+ const x = el("button", "tray-x", "\u2715");
4696
+ x.title = "Remove";
4697
+ x.onclick = (e) => {
4698
+ e.stopPropagation();
4699
+ this.chatTray = removeFromTray(this.chatTray, item.id);
4700
+ this.renderChatTray();
4701
+ };
4702
+ chip.append(check, item.thumb ? (() => {
4703
+ const img = el("img", "tray-thumb");
4704
+ img.src = item.thumb;
4705
+ img.alt = "";
4706
+ return img;
4707
+ })() : el("span", "tray-kind", item.kind), label, up, down, x);
4708
+ box.appendChild(chip);
4709
+ });
4710
+ }
4711
+ renderChatLog() {
4712
+ const box = this.chatEl?.querySelector("#loupe-chatlog");
4713
+ if (!box) return;
4714
+ box.textContent = "";
4715
+ if (!this.chatLog.length) {
4716
+ box.appendChild(el(
4717
+ "div",
4718
+ "chat-empty",
4719
+ 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."
4720
+ ));
4721
+ return;
4722
+ }
4723
+ for (const m of this.chatLog) {
4724
+ const row = el("div", "chat-msg" + (m.mine ? " mine" : ""));
4725
+ const head = el("div", "chat-head");
4726
+ head.append(
4727
+ el("b", "chat-who", m.mine ? "You" : m.author ?? "Agent"),
4728
+ el("span", "chat-when", fmtAgo(m.at))
4729
+ );
4730
+ if (m.count) head.appendChild(el("span", "chat-ctx", `${m.count} context${m.count === 1 ? "" : "s"}`));
4731
+ const body = el("div", "chat-body");
4732
+ for (const seg of mentionSegments(m.body, parseMentions(m.body))) {
4733
+ if (seg.mention) body.appendChild(el("span", "mention", seg.text));
4734
+ else body.appendChild(document.createTextNode(seg.text));
4735
+ }
4736
+ row.append(head, body);
4737
+ box.appendChild(row);
4738
+ }
4739
+ box.scrollTop = box.scrollHeight;
4740
+ }
4741
+ /** Pull the current selection off the bridge and into the tray. */
4742
+ async addSelectionToTray() {
4743
+ const base = this.cfg.bridge?.replace(/\/$/, "");
4744
+ if (!base) {
4745
+ this.chatError = "No bridge configured.";
4746
+ this.renderChatError();
4747
+ return;
4748
+ }
4749
+ try {
4750
+ const res = await fetch(`${base}/selection/latest`);
4751
+ if (!res.ok) throw new Error(String(res.status));
4752
+ const { selection } = await res.json();
4753
+ if (!selection) {
4754
+ this.chatError = "Nothing selected yet.";
4755
+ this.renderChatError();
4756
+ return;
4757
+ }
4758
+ this.chatTray = addToTray(this.chatTray, {
4759
+ id: selection.correlationId ?? selection.selector ?? String(selection.at),
4760
+ kind: "element",
4761
+ // The selector is what identifies it; the text makes it recognisable.
4762
+ label: `${selection.selector ?? selection.tag ?? "element"}${selection.text ? ` \u2014 \u201C${String(selection.text).slice(0, 24)}\u201D` : ""}`,
4763
+ url: selection.url,
4764
+ ref: selection.correlationId ?? selection.selector
4765
+ });
4766
+ this.chatError = "";
4767
+ this.renderChatError();
4768
+ this.renderChatTray();
4769
+ } catch (e) {
4770
+ this.chatError = e instanceof Error ? `Could not read the selection: ${e.message}` : "Could not read the selection.";
4771
+ this.renderChatError();
4772
+ }
4773
+ }
4774
+ async sendCompanion() {
4775
+ const input = this.chatEl.querySelector("#loupe-chatin");
4776
+ const body = input.value.trim();
4777
+ const contexts = trayPayload(this.chatTray);
4778
+ if (!body && !contexts.length) return;
4779
+ if (this.chatSending) return;
4780
+ const base = this.cfg.bridge?.replace(/\/$/, "");
4781
+ if (!base) {
4782
+ this.chatError = "No bridge configured \u2014 nothing to deliver to.";
4783
+ this.renderChatError();
4784
+ return;
4785
+ }
4786
+ this.chatSending = true;
4787
+ this.chatError = "";
4788
+ this.renderChatError();
4789
+ const optimistic = {
4790
+ id: `pending-${Date.now().toString(36)}`,
4791
+ at: (/* @__PURE__ */ new Date()).toISOString(),
4792
+ mine: true,
4793
+ body: body || "(context only)",
4794
+ count: contexts.length
4795
+ };
4796
+ this.chatLog.push(optimistic);
4797
+ this.renderChatLog();
4798
+ try {
4799
+ const res = await fetch(`${base}/companion`, {
4800
+ method: "POST",
4801
+ headers: { "content-type": "application/json" },
4802
+ body: JSON.stringify({
4803
+ body: body || "(see the gathered context)",
4804
+ author: { id: this.cfg.user.id, name: this.cfg.user.name },
4805
+ contexts,
4806
+ attachments: includedItems(this.chatTray).filter((i) => i.kind === "screenshot" && i.thumb).map((i) => ({ url: i.thumb, kind: "image", name: i.label })),
4807
+ voice: this.voiceBase.length > 0
4808
+ })
4809
+ });
4810
+ if (!res.ok) throw new Error(`the bridge answered ${res.status}`);
4811
+ input.value = "";
4812
+ this.chatTray = clearTray();
4813
+ this.modelVoiceBase("");
4814
+ this.renderChatTray();
4815
+ this.addActivity({ kind: "message.create", label: `Sent a brief to the agent${contexts.length ? ` (${contexts.length} contexts)` : ""}` });
4816
+ } catch (e) {
4817
+ this.chatError = e instanceof Error ? e.message : "Could not send.";
4818
+ this.renderChatError();
4819
+ this.addActivity({ kind: "error", level: "error", label: "Companion message failed to send" });
4820
+ } finally {
4821
+ this.chatSending = false;
4822
+ this.renderChatLog();
4823
+ }
4824
+ }
4825
+ modelVoiceBase(v) {
4826
+ this.voiceBase = v;
4827
+ }
4445
4828
  /**
4446
- * The Activity view: a status row, a collapsible summary, the tool chips and the
4447
- * live feed. Everything is re-derived from `activityEvents` on render, so the
4448
- * summary and the micro-stats can never disagree with the feed below them.
4829
+ * Dictation.
4830
+ *
4831
+ * Two different reasons the control can be unavailable, and they need different
4832
+ * words: an unsupported browser is a fact, while a non-secure page is something the
4833
+ * person can fix. Saying "your browser cannot do this" when the real problem is
4834
+ * `http://` sends them looking in the wrong place.
4449
4835
  */
4836
+ toggleVoice() {
4837
+ const support = voiceSupport({
4838
+ hasCtor: typeof globalThis.SpeechRecognition !== "undefined" || typeof globalThis.webkitSpeechRecognition !== "undefined",
4839
+ isSecureContext: typeof isSecureContext === "boolean" ? isSecureContext : true
4840
+ });
4841
+ if (support !== "supported") {
4842
+ this.chatError = voiceMessage(support) ?? "Dictation is unavailable.";
4843
+ this.renderChatError();
4844
+ return;
4845
+ }
4846
+ if (this.voiceOn) return this.stopChatVoice();
4847
+ const Ctor = globalThis.SpeechRecognition ?? globalThis.webkitSpeechRecognition;
4848
+ const input = this.chatEl.querySelector("#loupe-chatin");
4849
+ try {
4850
+ this.stopChatVoice();
4851
+ const rec = new Ctor();
4852
+ rec.continuous = true;
4853
+ rec.interimResults = true;
4854
+ rec.lang = document.documentElement.lang || "en-US";
4855
+ this.voiceBase = input.value ? `${input.value.trim()} ` : "";
4856
+ rec.onresult = (ev) => {
4857
+ let text = "";
4858
+ for (let i = ev.resultIndex; i < ev.results.length; i++) text += ev.results[i][0].transcript;
4859
+ input.value = `${this.voiceBase}${text}`;
4860
+ };
4861
+ rec.onerror = (ev) => {
4862
+ this.chatError = ev?.error === "not-allowed" ? "Microphone permission was refused." : "Dictation stopped.";
4863
+ this.renderChatError();
4864
+ this.stopChatVoice();
4865
+ };
4866
+ rec.onend = () => {
4867
+ if (this.voiceOn) this.stopChatVoice();
4868
+ };
4869
+ rec.start();
4870
+ this.voice = rec;
4871
+ this.voiceTarget = input;
4872
+ this.voiceOn = true;
4873
+ this.voiceStart = Date.now();
4874
+ const pill = this.chatEl.querySelector("#loupe-chatrec");
4875
+ pill.style.display = "";
4876
+ this.voiceTimer = setInterval(() => {
4877
+ const t = this.chatEl.querySelector("#loupe-chatrectime");
4878
+ if (t) t.textContent = elapsedLabel(Date.now() - this.voiceStart);
4879
+ }, 500);
4880
+ this.chatEl.querySelector("#loupe-chatmic").classList.add("on");
4881
+ this.renderChatTray();
4882
+ } catch (e) {
4883
+ this.chatError = e instanceof Error ? e.message : "Could not start dictation.";
4884
+ this.renderChatError();
4885
+ }
4886
+ }
4887
+ stopChatVoice() {
4888
+ this.voiceOn = false;
4889
+ if (this.voiceTimer) clearInterval(this.voiceTimer);
4890
+ this.voiceTimer = void 0;
4891
+ this.stopVoice();
4892
+ const pill = this.chatEl?.querySelector("#loupe-chatrec");
4893
+ if (pill) pill.style.display = "none";
4894
+ this.chatEl?.querySelector("#loupe-chatmic")?.classList.remove("on");
4895
+ }
4896
+ /**
4897
+ * Follow the bridge for replies.
4898
+ *
4899
+ * With SSE where it works and a poll where it does not, because a reply the person
4900
+ * never sees is the same as no reply — and the panel is the only place it appears.
4901
+ */
4902
+ startCompanionStream() {
4903
+ const base = this.cfg.bridge?.replace(/\/$/, "");
4904
+ if (!base || this.companionSource) return;
4905
+ if (typeof EventSource === "undefined") return;
4906
+ try {
4907
+ const source = new EventSource(`${base}/events`);
4908
+ source.addEventListener("companion", (ev) => {
4909
+ let parsed;
4910
+ try {
4911
+ parsed = JSON.parse(ev.data);
4912
+ } catch {
4913
+ return;
4914
+ }
4915
+ if (parsed?.eventType === "reply") this.receiveReply(parsed.data);
4916
+ });
4917
+ source.onerror = () => {
4918
+ };
4919
+ this.companionSource = source;
4920
+ this.companionPoll = setInterval(() => void this.pollReplies(), 5e3);
4921
+ } catch {
4922
+ this.companionPoll = setInterval(() => void this.pollReplies(), 4e3);
4923
+ }
4924
+ }
4925
+ async pollReplies() {
4926
+ const base = this.cfg.bridge?.replace(/\/$/, "");
4927
+ if (!base) return;
4928
+ try {
4929
+ const res = await fetch(`${base}/companion?since=${encodeURIComponent(this.lastReplyId)}`);
4930
+ if (!res.ok) return;
4931
+ const { replies } = await res.json();
4932
+ for (const r of replies ?? []) this.receiveReply(r);
4933
+ } catch {
4934
+ }
4935
+ }
4936
+ receiveReply(reply) {
4937
+ if (!reply?.id || !reply.body) return;
4938
+ if (this.chatLog.some((m) => m.id === reply.id)) return;
4939
+ this.lastReplyId = reply.id;
4940
+ this.chatLog.push({ id: reply.id, at: reply.at ?? (/* @__PURE__ */ new Date()).toISOString(), mine: false, body: reply.body });
4941
+ if (this.tab !== "chat" || this.minimized) {
4942
+ this.chatUnread++;
4943
+ this.updateChatBadge();
4944
+ this.desktopNotify("Loupe \u2014 the agent replied", reply.body);
4945
+ }
4946
+ this.addActivity({ kind: "agent.reply", label: "The agent replied" });
4947
+ this.renderChatLog();
4948
+ }
4949
+ /** A desktop notification, when the browser will show one. Never a prompt for it. */
4950
+ desktopNotify(title, body) {
4951
+ try {
4952
+ const N = globalThis.Notification;
4953
+ if (!N || N.permission !== "granted") return;
4954
+ new N(title, { body: String(body).slice(0, 200), tag: "loupe-companion" });
4955
+ } catch {
4956
+ }
4957
+ }
4450
4958
  buildActivityPanel() {
4451
4959
  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>`;
4452
4960
  this.feedEl = this.activityEl.querySelector("#loupe-mon-feed");
@@ -6787,17 +7295,17 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6787
7295
  if (typeof EventSource === "undefined") return;
6788
7296
  try {
6789
7297
  const source = new EventSource(`${bridge.replace(/\/$/, "")}/thread-updates`);
6790
- source.onmessage = (ev) => {
7298
+ source.addEventListener("thread", (ev) => {
6791
7299
  let parsed;
6792
7300
  try {
6793
7301
  parsed = JSON.parse(ev.data);
6794
7302
  } catch {
6795
7303
  return;
6796
7304
  }
6797
- if (!parsed || parsed.type !== "thread" || typeof parsed.threadId !== "string") return;
7305
+ if (!parsed || typeof parsed.threadId !== "string") return;
6798
7306
  if (!this.messages.has(parsed.threadId)) return;
6799
7307
  void this.refreshMessages(parsed.threadId);
6800
- };
7308
+ });
6801
7309
  source.onerror = () => {
6802
7310
  };
6803
7311
  this.liveSource = source;
@@ -7020,6 +7528,10 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7020
7528
  destroy() {
7021
7529
  this.liveSource?.close();
7022
7530
  this.liveSource = void 0;
7531
+ this.companionSource?.close();
7532
+ this.companionSource = void 0;
7533
+ if (this.companionPoll) clearInterval(this.companionPoll);
7534
+ this.stopVoice();
7023
7535
  this.stopPresence();
7024
7536
  this.stopRecording?.();
7025
7537
  this.setMode("off");