@loupekit/sdk 0.11.1-next.77 → 0.12.0-next.78

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.
@@ -378,6 +378,8 @@ var Loupe = (() => {
378
378
  }
379
379
  .tabs .tab:hover { color: var(--ink); background: var(--bg-2); }
380
380
  .tabs .tab.on { color: var(--accent); border-bottom-color: var(--accent); }
381
+ .tabs .tab.off { opacity: .45; cursor: not-allowed; }
382
+ .tabs .tab.off:hover { color: inherit; background: transparent; }
381
383
 
382
384
  /* Only the active page shows. Driven by an "on" class rather than a .tab-<id>
383
385
  selector, so host-registered tab ids need no CSS of their own. */
@@ -436,11 +438,6 @@ var Loupe = (() => {
436
438
  font-size: 10.5px; font-family: ui-monospace, Menlo, monospace;
437
439
  max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
438
440
  }
439
- .listhead .reposel {
440
- margin-left: 6px; font-size: 11px; padding: 3px 6px; border: 1px solid var(--line);
441
- border-radius: 7px; background: var(--bg-2); color: var(--ink);
442
- text-transform: none; letter-spacing: 0; max-width: 130px;
443
- }
444
441
 
445
442
  /* recording marker + video in the list */
446
443
  .item .rectag { font-size: 10px; font-weight: 700; color: var(--pin); background: var(--bg-3); border-radius: 999px; padding: 1px 7px; white-space: nowrap; }
@@ -554,6 +551,9 @@ var Loupe = (() => {
554
551
  .activity-view { padding: 10px 12px 16px; }
555
552
  .mon-status { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 600; color: var(--ink); }
556
553
  .mon-spacer { flex: 1; }
554
+ .mon-live { font-size: 10px; font-weight: 600; color: var(--muted); }
555
+ .mon-live.live { color: #2f9e6a; }
556
+ .mon-live.offline { color: #d9534f; }
557
557
  .mon-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
558
558
  .mon-status.st-working .mon-dot { background: var(--accent); animation: loupe-pulse 1.4s ease-in-out infinite; }
559
559
  .mon-status.st-error .mon-dot { background: var(--pin); }
@@ -617,8 +617,7 @@ var Loupe = (() => {
617
617
  color: var(--ink); font-size: 11.5px; font-family: ui-monospace, Menlo, monospace; cursor: pointer;
618
618
  }
619
619
  .proj-chip:hover { border-color: var(--accent); }
620
- .proj-chip .proj-repo { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
621
- .proj-chip.unset .proj-repo { color: var(--muted); font-style: italic; }
620
+ .proj-chip .proj-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
622
621
  .proj-caret { color: var(--muted); font-family: inherit; }
623
622
  .proj-pop {
624
623
  position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 25;
@@ -631,19 +630,17 @@ var Loupe = (() => {
631
630
  .pp-x:hover { background: var(--bg-3); color: var(--ink); }
632
631
  .pp-cur { margin-bottom: 8px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
633
632
  .pp-cur b { color: var(--ink); font-family: ui-monospace, Menlo, monospace; }
634
- .pp-search {
635
- width: 100%; margin-bottom: 6px; padding: 6px 8px; border: 1px solid var(--line);
636
- border-radius: 8px; background: var(--bg); color: var(--ink); font-size: 12px;
637
- }
638
- .pp-search:focus { outline: none; border-color: var(--accent); }
639
633
  .pp-list { display: flex; flex-direction: column; gap: 2px; max-height: 30vh; overflow-y: auto; }
640
- .pp-item {
641
- padding: 6px 8px; border: 1px solid transparent; border-radius: 7px; background: transparent;
642
- color: var(--ink); font-size: 11.5px; font-family: ui-monospace, Menlo, monospace;
643
- text-align: left; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
634
+ .pp-proj {
635
+ display: flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid transparent;
636
+ border-radius: 7px; font-size: 11.5px; color: var(--ink);
637
+ }
638
+ .pp-proj.on { border-color: var(--accent); }
639
+ .pp-proj-n { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
640
+ .pp-badge {
641
+ flex: none; padding: 1px 6px; border-radius: 999px; background: var(--bg-3);
642
+ color: var(--muted); font-size: 10px; white-space: nowrap;
644
643
  }
645
- .pp-item:hover { background: var(--bg-3); }
646
- .pp-item.on { border-color: var(--accent); color: var(--accent); }
647
644
  .pp-empty { padding: 8px 6px; font-size: 11px; line-height: 1.5; color: var(--muted); }
648
645
  .pp-empty code { font-family: ui-monospace, Menlo, monospace; color: var(--ink); }
649
646
  .pp-clear {
@@ -677,6 +674,15 @@ var Loupe = (() => {
677
674
  /* The host package's version, shown only when it differs from this bundle's (a stale publish). */
678
675
  .ver-stale { color: var(--pin); font-family: ui-monospace, Menlo, monospace; font-weight: 600; cursor: help; }
679
676
 
677
+ /* ------------------------------------------------ tickets between projects */
678
+ .srcchip, .fwdchip {
679
+ padding: 1px 6px; border-radius: 999px; white-space: nowrap; font-size: 10px;
680
+ border: 1px solid var(--line); background: var(--bg); color: var(--muted);
681
+ max-width: 140px; overflow: hidden; text-overflow: ellipsis;
682
+ }
683
+ .fwdchip { border-color: var(--accent); color: var(--accent); }
684
+ .fwdchip.bad { border-color: #d9534f; color: #d9534f; }
685
+
680
686
  /* ------------------------------------------------ lifecycle chips + review flow */
681
687
  .lifechip {
682
688
  padding: 1px 6px; border-radius: 999px; white-space: nowrap;
@@ -3796,6 +3802,14 @@ var Loupe = (() => {
3796
3802
  }
3797
3803
  }
3798
3804
  }
3805
+ /** Offline: there is no organization to read. */
3806
+ async getOrg() {
3807
+ return null;
3808
+ }
3809
+ /** Offline: no server feed; the panel shows only what this browser reports. */
3810
+ async listActivity(_projectKey, _since) {
3811
+ return null;
3812
+ }
3799
3813
  };
3800
3814
 
3801
3815
  // src/http-adapter.ts
@@ -3931,6 +3945,28 @@ var Loupe = (() => {
3931
3945
  }));
3932
3946
  if (!res.ok) throw new Error(`update failed: ${res.status}`);
3933
3947
  }
3948
+ /** The organization, read through the host app. A 404 means the backend has no such endpoint. */
3949
+ async getOrg() {
3950
+ const res = await fetch(`${this.base}/v1/org`, this.opts({ headers: this.headers() }));
3951
+ if (res.status === 404) return null;
3952
+ if (!res.ok) throw new Error(`getOrg failed: ${res.status}`);
3953
+ const body = await res.json();
3954
+ if (!body || typeof body !== "object" || !body.project || !Array.isArray(body.projects)) {
3955
+ throw new Error("getOrg: the answer was not an organization");
3956
+ }
3957
+ return body;
3958
+ }
3959
+ /** The server's activity feed. A 404 means the backend keeps none. */
3960
+ async listActivity(projectKey, since) {
3961
+ const q = new URLSearchParams({ projectKey });
3962
+ if (since) q.set("since", since);
3963
+ const res = await fetch(`${this.base}/v1/activity?${q}`, this.opts({ headers: this.headers() }));
3964
+ if (res.status === 404) return null;
3965
+ if (!res.ok) throw new Error(`listActivity failed: ${res.status}`);
3966
+ const body = await res.json();
3967
+ if (!Array.isArray(body)) throw new Error("listActivity: the answer was not a list");
3968
+ return body;
3969
+ }
3934
3970
  async remove(id) {
3935
3971
  const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3936
3972
  method: "DELETE",
@@ -3941,7 +3977,7 @@ var Loupe = (() => {
3941
3977
  };
3942
3978
 
3943
3979
  // src/app.ts
3944
- var SDK_VERSION = true ? "0.11.1" : "dev";
3980
+ var SDK_VERSION = true ? "0.12.0" : "dev";
3945
3981
  var FAB_SIZE = 46;
3946
3982
  var FAB_DRAG_THRESHOLD = 6;
3947
3983
  var LAUNCHER_SHORTCUT = "Alt+Shift+L";
@@ -3966,7 +4002,7 @@ var Loupe = (() => {
3966
4002
  sel: ".hscope",
3967
4003
  tab: "home",
3968
4004
  title: "This page, or the whole project",
3969
- body: "Switch to All to see every page's feedback as a day-grouped timeline, with a repo filter."
4005
+ body: "Switch to All to see every page's feedback as a day-grouped timeline."
3970
4006
  },
3971
4007
  {
3972
4008
  sel: ".tools",
@@ -3978,7 +4014,7 @@ var Loupe = (() => {
3978
4014
  sel: '.tabs [data-tab="activity"]',
3979
4015
  tab: "activity",
3980
4016
  title: "Watch the work happen",
3981
- body: "Anything an agent bridge or your app reports lands here \u2014 with tool chips to filter it, and Loupe's own operations alongside."
4017
+ body: "Comments, status changes and forwarded tickets land here as they happen, with anything a bridge or your app reports. Tool chips filter it."
3982
4018
  },
3983
4019
  {
3984
4020
  sel: '.dctl [data-role="settings"]',
@@ -4095,8 +4131,6 @@ var Loupe = (() => {
4095
4131
  this.allLoaded = false;
4096
4132
  /** A clicked stat tile, which narrows the list. */
4097
4133
  this.statFilter = "";
4098
- /** Repo filter, offered once the scope is "all". */
4099
- this.repoFilter = "";
4100
4134
  /** Accent preset id (see ACCENTS), applied as inline --accent / --accent-soft. */
4101
4135
  this.accent = "indigo";
4102
4136
  /** Collapsed to the one-line minimize bar. */
@@ -4120,8 +4154,16 @@ var Loupe = (() => {
4120
4154
  this.activityFollow = true;
4121
4155
  /** The summary card's key/value rows are behind this toggle. */
4122
4156
  this.summaryOpen = false;
4123
- /** Per-browser project settings: a repo override and the environment URLs. */
4157
+ /** Per-browser project settings: the environment URLs and the Local AI endpoint. */
4124
4158
  this.project = { environments: [] };
4159
+ /** The organization, read once from the backend. Null offline or before it loads. */
4160
+ this.org = null;
4161
+ /** Whether the server keeps an activity feed, and whether the last read reached it. */
4162
+ this.activityRemote = "unsupported";
4163
+ /** Newest server event seen, so a poll asks only for what is new. */
4164
+ this.activitySince = "";
4165
+ /** The backend answered that it keeps no feed, so the panel stops asking. */
4166
+ this.activityNone = false;
4125
4167
  /** Iteration history per thread — generating a change is a stack, not a one-shot. */
4126
4168
  this.iterations = /* @__PURE__ */ new Map();
4127
4169
  /** The thread whose generate pane is open, if any. */
@@ -4134,13 +4176,9 @@ var Loupe = (() => {
4134
4176
  this.consent = emptyConsent();
4135
4177
  /** The live SpeechRecognition instance while dictating, if any. */
4136
4178
  this.voice = null;
4137
- this.projSearch = "";
4138
4179
  this.projOpen = false;
4139
- this.projResults = [];
4140
4180
  this.projError = "";
4141
4181
  this.envDraft = "";
4142
- /** Guards against a slow repo search overwriting a newer one. */
4143
- this.repoSeq = 0;
4144
4182
  /** Float-mode window geometry; (x<=0 && y<=0) → placed on first layout. */
4145
4183
  this.floatRect = { x: 0, y: 0, w: 380, h: 540 };
4146
4184
  this.floatDrag = null;
@@ -4485,6 +4523,10 @@ var Loupe = (() => {
4485
4523
  get url() {
4486
4524
  return location.pathname + location.search;
4487
4525
  }
4526
+ /** Chat is experimental: the tab stays dimmed and inert unless the host opts in. */
4527
+ get chatOn() {
4528
+ return this.cfg.chat === true;
4529
+ }
4488
4530
  async start() {
4489
4531
  this.loadState();
4490
4532
  this.loadProject();
@@ -4501,8 +4543,11 @@ var Loupe = (() => {
4501
4543
  this.renderHome();
4502
4544
  this.startPresence();
4503
4545
  this.startLiveThreads();
4504
- this.startCompanionStream();
4546
+ if (this.chatOn) this.startCompanionStream();
4505
4547
  void this.loadNotifications();
4548
+ void this.loadOrg();
4549
+ void this.loadActivity();
4550
+ this.syncActivityPoll();
4506
4551
  void this.loadAllComments();
4507
4552
  this.observe();
4508
4553
  this.watchNavigation();
@@ -4673,8 +4718,13 @@ var Loupe = (() => {
4673
4718
  this.tabList = [...BUILTIN_TABS, ...(this.cfg.tabs ?? []).map((t) => ({ id: t.id, label: t.label }))];
4674
4719
  const tabs = el("div", "tabs");
4675
4720
  for (const t of this.tabList) {
4676
- const b = el("button", "tab", t.label);
4721
+ const off = t.id === "chat" && !this.chatOn;
4722
+ const b = el("button", off ? "tab off" : "tab", t.label);
4677
4723
  b.dataset.tab = t.id;
4724
+ if (off) {
4725
+ b.setAttribute("aria-disabled", "true");
4726
+ b.title = "Chat \u2014 coming soon";
4727
+ }
4678
4728
  b.onclick = () => this.setTab(t.id);
4679
4729
  tabs.appendChild(b);
4680
4730
  }
@@ -4714,15 +4764,6 @@ var Loupe = (() => {
4714
4764
  this.renderList();
4715
4765
  };
4716
4766
  listHead.appendChild(search);
4717
- this.repoSel = el("select", "reposel");
4718
- this.repoSel.title = "Filter by repository";
4719
- this.repoSel.setAttribute("aria-label", "Filter by repository");
4720
- this.repoSel.onchange = () => {
4721
- this.repoFilter = this.repoSel.value;
4722
- this.renderList();
4723
- };
4724
- this.repoSel.style.display = "none";
4725
- listHead.appendChild(this.repoSel);
4726
4767
  this.listEl = el("div", "list");
4727
4768
  const homeView = el("div", "view home-view");
4728
4769
  this.homeEl = homeView;
@@ -4808,6 +4849,7 @@ var Loupe = (() => {
4808
4849
  }
4809
4850
  /** The Chat tab label carries the unread count, so it is visible from any page. */
4810
4851
  updateChatBadge() {
4852
+ if (!this.chatOn) return;
4811
4853
  const tab = this.tabList.findIndex((t) => t.id === "chat");
4812
4854
  const buttons = this.shadow?.querySelectorAll(".tabs .tab");
4813
4855
  const el2 = buttons?.[tab];
@@ -5131,7 +5173,7 @@ var Loupe = (() => {
5131
5173
  }
5132
5174
  }
5133
5175
  buildActivityPanel() {
5134
- 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>`;
5176
+ 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-live" id="loupe-mon-live"></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>`;
5135
5177
  this.feedEl = this.activityEl.querySelector("#loupe-mon-feed");
5136
5178
  this.activityHint = this.activityEl.querySelector("#loupe-ahint");
5137
5179
  this.activityEl.querySelector('[data-role="mon-clear"]').onclick = () => this.clearActivity();
@@ -5169,6 +5211,52 @@ var Loupe = (() => {
5169
5211
  this.activityStatus = "idle";
5170
5212
  this.renderActivity();
5171
5213
  }
5214
+ /**
5215
+ * Read the server's feed and merge it in by id. A backend without a feed answers
5216
+ * null once, and the panel stops asking.
5217
+ */
5218
+ async loadActivity() {
5219
+ if (this.activityNone) return;
5220
+ let events;
5221
+ try {
5222
+ const read = this.store.listActivity?.bind(this.store);
5223
+ events = read ? await read(this.cfg.projectKey, this.activitySince || void 0) : null;
5224
+ } catch {
5225
+ if (this.activityRemote === "live") {
5226
+ this.activityRemote = "offline";
5227
+ this.renderActivity();
5228
+ }
5229
+ return;
5230
+ }
5231
+ if (events === null) {
5232
+ this.activityRemote = "unsupported";
5233
+ this.activityNone = true;
5234
+ this.syncActivityPoll();
5235
+ this.renderActivity();
5236
+ return;
5237
+ }
5238
+ this.activityRemote = "live";
5239
+ const known = new Set(this.activityEvents.map((e) => e.id));
5240
+ const fresh = events.filter((e) => e && typeof e.id === "string" && !known.has(e.id));
5241
+ if (fresh.length) {
5242
+ this.activityEvents = [...this.activityEvents, ...fresh].sort((a, b) => a.at.localeCompare(b.at)).slice(-500);
5243
+ const newest = fresh.reduce((m, e) => e.at > m ? e.at : m, this.activitySince);
5244
+ this.activitySince = newest;
5245
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1e3).toISOString();
5246
+ if (fresh.some((e) => e.level === "error" && e.at >= tenMinutesAgo)) this.activityStatus = "error";
5247
+ }
5248
+ this.renderActivity();
5249
+ }
5250
+ /** Poll the server's feed every 15s, but only while the Activity view is on screen. */
5251
+ syncActivityPoll() {
5252
+ const want = this.tab === "activity" && !this.activityNone && !!this.store.listActivity;
5253
+ if (want && !this.activityPoll) {
5254
+ this.activityPoll = setInterval(() => void this.loadActivity(), 15e3);
5255
+ } else if (!want && this.activityPoll) {
5256
+ clearInterval(this.activityPoll);
5257
+ this.activityPoll = void 0;
5258
+ }
5259
+ }
5172
5260
  /** The summary, status row, micro-stats and chips — derived, never stored. */
5173
5261
  renderActivityChrome() {
5174
5262
  if (!this.activityEl) return;
@@ -5177,6 +5265,11 @@ var Loupe = (() => {
5177
5265
  const s = summarizeActivity(this.activityEvents, this.activityStatus);
5178
5266
  status.querySelector(".mon-status-label").textContent = ACTIVITY_STATUS_LABELS[s.status];
5179
5267
  status.className = `mon-status st-${s.status}`;
5268
+ const live = status.querySelector("#loupe-mon-live");
5269
+ if (live) {
5270
+ live.textContent = this.activityRemote === "live" ? "Live" : this.activityRemote === "offline" ? "Offline" : "";
5271
+ live.className = `mon-live ${this.activityRemote}`;
5272
+ }
5180
5273
  const summary = this.activityEl.querySelector("#loupe-mon-summary");
5181
5274
  if (!s.events) {
5182
5275
  summary.innerHTML = "";
@@ -5230,7 +5323,7 @@ var Loupe = (() => {
5230
5323
  if (!this.feedEl) return;
5231
5324
  const events = this.activityFilter ? this.activityEvents.filter((e) => e.kind === this.activityFilter) : this.activityEvents;
5232
5325
  if (!events.length) {
5233
- this.feedEl.innerHTML = `<div class="mon-empty">` + (this.activityEvents.length ? `Nothing from <b>${escapeHtml(this.activityFilter)}</b> yet.` : `<b>Monitor unavailable.</b> Nothing is feeding this view yet.<br>A bridge or your app can push events with <code>Loupe.trackActivity({ kind, label })</code>, and Loupe reports its own operations here as you work.`) + `</div>`;
5326
+ this.feedEl.innerHTML = `<div class="mon-empty">` + (this.activityEvents.length ? `Nothing from <b>${escapeHtml(this.activityFilter)}</b> yet.` : this.activityRemote === "live" ? `No activity yet. Comments, status changes and forwarded tickets appear here.` : `<b>Monitor unavailable.</b> Nothing is feeding this view yet.<br>A bridge or your app can push events with <code>Loupe.trackActivity({ kind, label })</code>, and Loupe reports its own operations here as you work.`) + `</div>`;
5234
5327
  return;
5235
5328
  }
5236
5329
  this.feedEl.innerHTML = events.map((e) => {
@@ -5246,7 +5339,7 @@ var Loupe = (() => {
5246
5339
  */
5247
5340
  buildHomePanel() {
5248
5341
  const title = this.cfg.label ?? "Loupe";
5249
- this.homeEl.innerHTML = `<div class="hint-slot" id="loupe-hhint"></div><div class="hstat" id="loupe-hstats"></div><div class="hscope"><button class="hscope-b" data-scope="page">This page <b class="hscope-n"></b></button><button class="hscope-b" data-scope="all">All <b class="hscope-n"></b></button><button class="hrefresh" title="Refresh" aria-label="Refresh">\u27F3</button></div><button class="hpin" data-role="home-pin">\u271B Pin feedback on this page</button><div class="projbar"><span class="proj-label">Project</span><button class="proj-chip" data-role="proj-open" aria-haspopup="dialog" aria-expanded="false"><span class="proj-repo"></span><span class="proj-caret">\u25BE</span></button><div class="proj-pop" id="loupe-proj" role="dialog" aria-label="Project settings"></div></div><div class="hnotif" id="loupe-hnotif"></div><div class="hlabel">Recent</div><div class="hfeed" id="loupe-hfeed"></div><div class="hfoot">${escapeHtml(title)} \xB7 <span class="hver">v${escapeHtml(SDK_VERSION)}</span>${this.packageVersionNote()}</div>`;
5342
+ this.homeEl.innerHTML = `<div class="hint-slot" id="loupe-hhint"></div><div class="hstat" id="loupe-hstats"></div><div class="hscope"><button class="hscope-b" data-scope="page">This page <b class="hscope-n"></b></button><button class="hscope-b" data-scope="all">All <b class="hscope-n"></b></button><button class="hrefresh" title="Refresh" aria-label="Refresh">\u27F3</button></div><button class="hpin" data-role="home-pin">\u271B Pin feedback on this page</button><div class="projbar"><span class="proj-label">Project</span><button class="proj-chip" data-role="proj-open" aria-haspopup="dialog" aria-expanded="false"><span class="proj-name"></span><span class="proj-caret">\u25BE</span></button><div class="proj-pop" id="loupe-proj" role="dialog" aria-label="Project settings"></div></div><div class="hnotif" id="loupe-hnotif"></div><div class="hlabel">Recent</div><div class="hfeed" id="loupe-hfeed"></div><div class="hfoot">${escapeHtml(title)} \xB7 <span class="hver">v${escapeHtml(SDK_VERSION)}</span>${this.packageVersionNote()}</div>`;
5250
5343
  this.homeEl.querySelector('[data-role="proj-open"]').onclick = (e) => {
5251
5344
  e.stopPropagation();
5252
5345
  this.setProjectOpen(!this.projOpen);
@@ -5398,65 +5491,36 @@ var Loupe = (() => {
5398
5491
  };
5399
5492
  }
5400
5493
  // ---- project manager (#60) ------------------------------------------------
5401
- /** The repo new comments are filed against: the panel's choice, else the config. */
5402
- effectiveRepo() {
5403
- return this.project.repo || this.cfg.repo || void 0;
5404
- }
5405
5494
  setProjectOpen(open) {
5406
5495
  this.projOpen = open;
5407
5496
  this.projError = "";
5408
- if (open) {
5409
- this.projSearch = "";
5410
- void this.refreshRepos();
5411
- }
5412
5497
  this.renderProject();
5413
5498
  }
5414
- /** Repositories to offer — a fixed list, or the host's search function. */
5415
- async repoChoices() {
5416
- const source = this.cfg.repos;
5417
- if (!source) return [];
5418
- if (Array.isArray(source)) {
5419
- const q = this.projSearch.trim().toLowerCase();
5420
- const all = q ? source.filter((r) => r.toLowerCase().includes(q)) : source;
5421
- return all.slice(0, 50);
5422
- }
5499
+ /** Read the organization once. A backend without the endpoint, or offline mode, leaves it null. */
5500
+ async loadOrg() {
5423
5501
  try {
5424
- const out = await source(this.projSearch);
5425
- return (Array.isArray(out) ? out : []).slice(0, 50);
5426
- } catch (e) {
5427
- this.projError = e instanceof Error ? e.message : "Could not load repositories.";
5428
- return [];
5502
+ this.org = await this.store.getOrg?.() ?? null;
5503
+ } catch {
5504
+ this.org = null;
5429
5505
  }
5506
+ this.renderProject();
5430
5507
  }
5431
- /**
5432
- * Re-run the repo search. The sequence guard matters: typing fires overlapping
5433
- * requests, and without it a slow early reply would overwrite a newer one.
5434
- */
5435
- async refreshRepos() {
5436
- this.projError = "";
5437
- const seq = ++this.repoSeq;
5438
- const out = await this.repoChoices();
5439
- if (seq !== this.repoSeq) return;
5440
- this.projResults = out;
5441
- this.renderRepoList();
5442
- this.renderProjectError();
5508
+ /** "Project · Organization", or the project key when there is no organization to show. */
5509
+ projectLabel() {
5510
+ const org = this.org;
5511
+ const name = org?.project.name || this.cfg.projectKey;
5512
+ return org?.organization ? `${name} \xB7 ${org.organization.name}` : name;
5443
5513
  }
5444
- /** Only the list is rewritten on search, so the input keeps focus while typing. */
5445
- renderRepoList() {
5446
- const list = this.homeEl?.querySelector("#loupe-pp-repos");
5447
- if (!list) return;
5448
- const repo = this.effectiveRepo();
5449
- list.innerHTML = this.projResults.length ? this.projResults.map((r) => `<button class="pp-item${r === repo ? " on" : ""}" data-repo="${escapeAttr(r)}" title="${escapeAttr(r)}">${escapeHtml(r)}</button>`).join("") : `<div class="pp-empty">No repositories match.</div>`;
5450
- list.querySelectorAll("[data-repo]").forEach((b) => {
5451
- b.onclick = () => {
5452
- this.project.repo = b.dataset.repo;
5453
- this.saveProject();
5454
- this.renderProject();
5455
- this.renderPins();
5456
- this.renderList();
5457
- this.addActivity({ kind: "repo.link", label: `Linked this page to ${b.dataset.repo}` });
5458
- };
5459
- });
5514
+ /** The Organization section of the project popover. Read-only: Hub owns this. */
5515
+ orgSectionHtml() {
5516
+ const org = this.org;
5517
+ const head = `<div class="pp-head"><span>Organization</span><button class="pp-x" data-role="pp-close" aria-label="Close">\u2715</button></div>`;
5518
+ if (!org?.organization) {
5519
+ return head + `<div class="pp-cur">` + (org?.error ? "Could not reach Loupe Hub. Your organization's projects will show here when it is back." : "This project is not connected to an organization. Connect it to Loupe Hub to see the organization's projects and where its tickets go.") + `</div>`;
5520
+ }
5521
+ const dest = org.project.destination;
5522
+ const others = org.projects;
5523
+ return head + `<div class="pp-cur"><b>${escapeHtml(org.project.name || this.cfg.projectKey)}</b> in <b>${escapeHtml(org.organization.name)}</b>.</div><div class="pp-cur pp-dest">${dest ? `Tickets go to <b>${escapeHtml(dest.name)}</b>.` : "Tickets stay in this project."}</div><div class="pp-list pp-org">` + (others.length ? others.map((p) => `<div class="pp-proj${p.isDestination ? " on" : ""}"><span class="pp-proj-n">${escapeHtml(p.name)}</span>` + (p.receives ? `<span class="pp-badge">receives tickets</span>` : "") + `</div>`).join("") : `<div class="pp-empty">No other projects in this organization yet.</div>`) + `</div>`;
5460
5524
  }
5461
5525
  renderProjectError() {
5462
5526
  const box = this.homeEl?.querySelector("#loupe-pp-err");
@@ -5469,9 +5533,8 @@ var Loupe = (() => {
5469
5533
  const chip = this.homeEl.querySelector(".proj-chip");
5470
5534
  const pop = this.homeEl.querySelector("#loupe-proj");
5471
5535
  if (!chip || !pop) return;
5472
- const repo = this.effectiveRepo();
5473
- chip.querySelector(".proj-repo").textContent = repo ?? "no repo linked";
5474
- chip.classList.toggle("unset", !repo);
5536
+ chip.querySelector(".proj-name").textContent = this.projectLabel();
5537
+ chip.classList.toggle("unset", !this.org?.organization);
5475
5538
  chip.setAttribute("aria-expanded", String(this.projOpen));
5476
5539
  if (!this.projOpen) {
5477
5540
  pop.classList.remove("open");
@@ -5480,25 +5543,9 @@ var Loupe = (() => {
5480
5543
  }
5481
5544
  pop.classList.add("open");
5482
5545
  const envs = this.project.environments;
5483
- pop.innerHTML = `<div class="pp-head"><span>Repository</span><button class="pp-x" data-role="pp-close" aria-label="Close">\u2715</button></div><div class="pp-cur">${repo ? `New comments are filed against <b>${escapeHtml(repo)}</b>.` : "This page is not linked to a repository yet."}</div>` + (this.cfg.repos ? `<input class="pp-search" type="search" placeholder="Search repositories\u2026" value="${escapeAttr(this.projSearch)}" aria-label="Search repositories"><div class="pp-list" id="loupe-pp-repos"></div>` : `<div class="pp-empty">No repository list to search. Pass <code>repos</code> to <code>init()</code> \u2014 a string array, or a function the panel calls with the search text.</div>`) + (repo ? `<button class="pp-clear" data-role="pp-clear">Unlink this page</button>` : "") + `<div class="pp-sep"></div><div class="pp-head"><span>Environments</span></div><div class="pp-list">` + (envs.length ? envs.map((u, i) => `<div class="pp-env"><span class="pp-env-u" title="${escapeAttr(u)}">${escapeHtml(u)}</span><button class="pp-x" data-env-rm="${i}" aria-label="Remove environment">\u2715</button></div>`).join("") : `<div class="pp-empty">No environment URLs yet.</div>`) + `</div><div class="pp-add"><input class="pp-env-url" type="url" placeholder="https://staging.example.com" value="${escapeAttr(this.envDraft)}" aria-label="Environment URL"><button class="pp-add-b" data-role="env-add">Add</button></div><div class="pp-sep"></div><div class="pp-head"><span>Local AI</span></div><div class="pp-empty">Any OpenAI-compatible server \u2014 used by the Generate pane.</div><div class="pp-add"><input class="pp-ai-url" type="url" placeholder="http://localhost:11434" value="${escapeAttr(this.project.localAi?.url ?? "")}" aria-label="Local AI endpoint"></div><div class="pp-add"><input class="pp-ai-model" type="text" placeholder="llama3.2" value="${escapeAttr(this.project.localAi?.model ?? "")}" aria-label="Model name"><button class="pp-add-b" data-role="ai-save">Save</button></div><div class="pp-add"><button class="pp-clear" data-role="ai-test">Test connection</button></div><div class="pp-ai-out" id="loupe-pp-ai"></div><div class="pp-err" id="loupe-pp-err"></div>`;
5484
- this.renderRepoList();
5546
+ pop.innerHTML = this.orgSectionHtml() + `<div class="pp-sep"></div><div class="pp-head"><span>Environments</span></div><div class="pp-list">` + (envs.length ? envs.map((u, i) => `<div class="pp-env"><span class="pp-env-u" title="${escapeAttr(u)}">${escapeHtml(u)}</span><button class="pp-x" data-env-rm="${i}" aria-label="Remove environment">\u2715</button></div>`).join("") : `<div class="pp-empty">No environment URLs yet.</div>`) + `</div><div class="pp-add"><input class="pp-env-url" type="url" placeholder="https://staging.example.com" value="${escapeAttr(this.envDraft)}" aria-label="Environment URL"><button class="pp-add-b" data-role="env-add">Add</button></div><div class="pp-sep"></div><div class="pp-head"><span>Local AI</span></div><div class="pp-empty">Any OpenAI-compatible server \u2014 used by the Generate pane.</div><div class="pp-add"><input class="pp-ai-url" type="url" placeholder="http://localhost:11434" value="${escapeAttr(this.project.localAi?.url ?? "")}" aria-label="Local AI endpoint"></div><div class="pp-add"><input class="pp-ai-model" type="text" placeholder="llama3.2" value="${escapeAttr(this.project.localAi?.model ?? "")}" aria-label="Model name"><button class="pp-add-b" data-role="ai-save">Save</button></div><div class="pp-add"><button class="pp-clear" data-role="ai-test">Test connection</button></div><div class="pp-ai-out" id="loupe-pp-ai"></div><div class="pp-err" id="loupe-pp-err"></div>`;
5485
5547
  this.renderProjectError();
5486
5548
  pop.querySelector('[data-role="pp-close"]').onclick = () => this.setProjectOpen(false);
5487
- pop.querySelector('[data-role="pp-clear"]')?.addEventListener("click", () => {
5488
- this.project.repo = void 0;
5489
- this.saveProject();
5490
- this.renderProject();
5491
- this.renderPins();
5492
- this.renderList();
5493
- });
5494
- const search = pop.querySelector(".pp-search");
5495
- if (search) {
5496
- search.addEventListener("click", (e) => e.stopPropagation());
5497
- search.oninput = () => {
5498
- this.projSearch = search.value;
5499
- void this.refreshRepos();
5500
- };
5501
- }
5502
5549
  pop.querySelectorAll("[data-env-rm]").forEach((b) => {
5503
5550
  b.onclick = () => {
5504
5551
  const i = Number(b.dataset.envRm);
@@ -5562,10 +5609,9 @@ var Loupe = (() => {
5562
5609
  try {
5563
5610
  const raw = localStorage.getItem(`loupe:project:${this.cfg.projectKey}`);
5564
5611
  const p = raw ? JSON.parse(raw) : null;
5565
- const repo = typeof p?.repo === "string" && p.repo ? p.repo : void 0;
5566
5612
  const stored = Array.isArray(p?.environments) ? p.environments.filter((u) => typeof u === "string") : null;
5567
5613
  const localAi = p?.localAi && typeof p.localAi.url === "string" && typeof p.localAi.model === "string" ? { url: p.localAi.url, model: p.localAi.model } : void 0;
5568
- this.project = { repo, environments: stored ?? fromConfig, localAi };
5614
+ this.project = { environments: stored ?? fromConfig, localAi };
5569
5615
  } catch {
5570
5616
  this.project = { environments: fromConfig };
5571
5617
  }
@@ -6164,10 +6210,6 @@ var Loupe = (() => {
6164
6210
  status: "queue",
6165
6211
  priority,
6166
6212
  changeType,
6167
- // Branch-aware threads: the host declares these once in `init()`, and the
6168
- // panel's project manager can override the repo per browser.
6169
- repo: this.effectiveRepo(),
6170
- branch: this.cfg.branch,
6171
6213
  kind: target.kind,
6172
6214
  anchor,
6173
6215
  context,
@@ -6198,8 +6240,7 @@ var Loupe = (() => {
6198
6240
  this.addActivity({
6199
6241
  kind: "comment.create",
6200
6242
  label: `Created \u201C${comment.title || comment.body.split("\n")[0] || "comment"}\u201D`,
6201
- detail: [normalizePriority(comment.priority), comment.kind ?? "element", comment.repo].filter(Boolean).join(" \xB7 "),
6202
- files: comment.repo ? [`${comment.repo}/${shortPath(comment.url)}`] : []
6243
+ detail: [normalizePriority(comment.priority), comment.kind ?? "element", shortPath(comment.url)].join(" \xB7 ")
6203
6244
  });
6204
6245
  }
6205
6246
  /** Upload the reporter's picked files. A file that fails is skipped, not fatal. */
@@ -6224,6 +6265,7 @@ var Loupe = (() => {
6224
6265
  }
6225
6266
  }
6226
6267
  this.comments.forEach((c, i) => {
6268
+ if (c.source) return;
6227
6269
  let pin = this.pins.get(c.id);
6228
6270
  if (!pin) {
6229
6271
  pin = el("button", "pin");
@@ -6305,6 +6347,7 @@ var Loupe = (() => {
6305
6347
  /** Switch the sidebar page (Home ↔ Comments ↔ Connect Claude). */
6306
6348
  setTab(tab) {
6307
6349
  if (this.tab === tab) return;
6350
+ if (tab === "chat" && !this.chatOn) return;
6308
6351
  if (tab !== "comments") this.setMode("off");
6309
6352
  this.tab = tab;
6310
6353
  this.hintFor = null;
@@ -6314,7 +6357,11 @@ var Loupe = (() => {
6314
6357
  if (this.scope === "all" && !this.allComments.length) void this.loadAllComments();
6315
6358
  }
6316
6359
  if (tab === "comments") this.renderList();
6317
- if (tab === "activity") this.renderActivity();
6360
+ if (tab === "activity") {
6361
+ this.renderActivity();
6362
+ void this.loadActivity();
6363
+ }
6364
+ this.syncActivityPoll();
6318
6365
  this.applyDockLayout();
6319
6366
  }
6320
6367
  toggleTheme() {
@@ -6501,11 +6548,10 @@ var Loupe = (() => {
6501
6548
  if (DOCK_MODES.includes(p?.mode)) this.dockMode = p.mode;
6502
6549
  if (typeof p?.open === "boolean") this.open = p.open;
6503
6550
  if (p?.theme === "light" || p?.theme === "dark") this.theme = p.theme;
6504
- const known = [...BUILTIN_TABS.map((t) => t.id), ...(this.cfg.tabs ?? []).map((t) => t.id)];
6551
+ const known = [...BUILTIN_TABS.map((t) => t.id).filter((id) => id !== "chat" || this.chatOn), ...(this.cfg.tabs ?? []).map((t) => t.id)];
6505
6552
  if (typeof p?.tab === "string" && known.includes(p.tab)) this.tab = p.tab;
6506
6553
  if (p?.scope === "page" || p?.scope === "all") this.scope = p.scope;
6507
6554
  if (typeof p?.statFilter === "string") this.statFilter = p.statFilter;
6508
- if (typeof p?.repoFilter === "string") this.repoFilter = p.repoFilter;
6509
6555
  if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
6510
6556
  if (typeof p?.markersHidden === "boolean") this.markersHidden = p.markersHidden;
6511
6557
  if (typeof p?.launcherHidden === "boolean") this.launcherHidden = p.launcherHidden;
@@ -6532,7 +6578,6 @@ var Loupe = (() => {
6532
6578
  fab: this.fabPos,
6533
6579
  scope: this.scope,
6534
6580
  statFilter: this.statFilter,
6535
- repoFilter: this.repoFilter,
6536
6581
  accent: this.accent,
6537
6582
  minimized: this.minimized,
6538
6583
  hoverHints: this.hoverHints,
@@ -6615,11 +6660,9 @@ var Loupe = (() => {
6615
6660
  if (this.statFilter === "needs_you" && !needsYou({ status: c.status }).needs) return false;
6616
6661
  if (this.statFilter === "resolved" && stage !== "resolved") return false;
6617
6662
  if (this.statFilter === "stale" && (stage === "resolved" || !(Date.parse(c.createdAt) < weekAgo))) return false;
6618
- if (this.repoFilter && c.repo !== this.repoFilter) return false;
6619
6663
  return !q || `${c.title ?? ""} ${c.body} ${c.author?.name ?? ""}`.toLowerCase().includes(q);
6620
6664
  });
6621
6665
  if (this.scope === "all") items = [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
6622
- this.renderRepoFilter();
6623
6666
  this.renderReviewBar();
6624
6667
  if (!items.length) {
6625
6668
  this.listEl.appendChild(el(
@@ -6641,15 +6684,6 @@ var Loupe = (() => {
6641
6684
  });
6642
6685
  this.updateCount(items.length);
6643
6686
  }
6644
- /** Repo filter — only offered in the project scope, where it means something. */
6645
- renderRepoFilter() {
6646
- if (!this.repoSel) return;
6647
- const repos = [...new Set(this.visibleComments.map((c) => c.repo).filter((r) => !!r))].sort();
6648
- this.repoSel.style.display = this.scope === "all" && repos.length > 1 ? "" : "none";
6649
- const current2 = this.repoFilter;
6650
- this.repoSel.innerHTML = `<option value="">All repos</option>` + repos.map((r) => `<option value="${escapeHtml(r)}">${escapeHtml(r)}</option>`).join("");
6651
- this.repoSel.value = current2;
6652
- }
6653
6687
  itemView(c, i) {
6654
6688
  const detached = this.pins.get(c.id)?.classList.contains("detached") && !isResolved(c);
6655
6689
  const open = this.expanded.has(c.id);
@@ -6664,6 +6698,19 @@ var Loupe = (() => {
6664
6698
  const icon = vw < 768 ? "\u{1F4F1}" : vw < 1024 ? "\u25A6" : "\u{1F5A5}";
6665
6699
  top.appendChild(el("span", "device", `${icon} ${kind}`));
6666
6700
  }
6701
+ if (c.source) {
6702
+ const from = el("span", "srcchip", `from ${c.source.projectName || c.source.projectId}`);
6703
+ from.title = c.source.reporter ? `Sent by ${c.source.reporter.name || c.source.reporter.email}` : "Sent from another project";
6704
+ top.appendChild(from);
6705
+ }
6706
+ const fwd = c.forwarded;
6707
+ if (fwd && fwd.status !== "none") {
6708
+ const where = fwd.destinationName || (fwd.status === "ok" ? "webhook" : "Hub");
6709
+ const ok = fwd.status === "ok";
6710
+ const chip = el("span", ok ? "fwdchip" : "fwdchip bad", ok ? `\u2192 ${where}` : `\u2192 ${where} failed`);
6711
+ chip.title = ok ? `Sent to ${where}` : `Could not send to ${where}${fwd.error ? `: ${fwd.error}` : ""}`;
6712
+ top.appendChild(chip);
6713
+ }
6667
6714
  if (isResolved(c)) top.appendChild(el("span", "badge done", "resolved"));
6668
6715
  else if (detached) {
6669
6716
  const b = el("span", "badge detached", "moved");
@@ -7839,6 +7886,8 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7839
7886
  this.companionSource?.close();
7840
7887
  this.companionSource = void 0;
7841
7888
  if (this.companionPoll) clearInterval(this.companionPoll);
7889
+ if (this.activityPoll) clearInterval(this.activityPoll);
7890
+ this.activityPoll = void 0;
7842
7891
  this.stopVoice();
7843
7892
  this.stopPresence();
7844
7893
  this.stopRecording?.();