@loupekit/sdk 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -341,6 +341,8 @@ var STYLES = (
341
341
  }
342
342
  .tabs .tab:hover { color: var(--ink); background: var(--bg-2); }
343
343
  .tabs .tab.on { color: var(--accent); border-bottom-color: var(--accent); }
344
+ .tabs .tab.off { opacity: .45; cursor: not-allowed; }
345
+ .tabs .tab.off:hover { color: inherit; background: transparent; }
344
346
 
345
347
  /* Only the active page shows. Driven by an "on" class rather than a .tab-<id>
346
348
  selector, so host-registered tab ids need no CSS of their own. */
@@ -399,11 +401,6 @@ var STYLES = (
399
401
  font-size: 10.5px; font-family: ui-monospace, Menlo, monospace;
400
402
  max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
401
403
  }
402
- .listhead .reposel {
403
- margin-left: 6px; font-size: 11px; padding: 3px 6px; border: 1px solid var(--line);
404
- border-radius: 7px; background: var(--bg-2); color: var(--ink);
405
- text-transform: none; letter-spacing: 0; max-width: 130px;
406
- }
407
404
 
408
405
  /* recording marker + video in the list */
409
406
  .item .rectag { font-size: 10px; font-weight: 700; color: var(--pin); background: var(--bg-3); border-radius: 999px; padding: 1px 7px; white-space: nowrap; }
@@ -517,6 +514,9 @@ var STYLES = (
517
514
  .activity-view { padding: 10px 12px 16px; }
518
515
  .mon-status { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 600; color: var(--ink); }
519
516
  .mon-spacer { flex: 1; }
517
+ .mon-live { font-size: 10px; font-weight: 600; color: var(--muted); }
518
+ .mon-live.live { color: #2f9e6a; }
519
+ .mon-live.offline { color: #d9534f; }
520
520
  .mon-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
521
521
  .mon-status.st-working .mon-dot { background: var(--accent); animation: loupe-pulse 1.4s ease-in-out infinite; }
522
522
  .mon-status.st-error .mon-dot { background: var(--pin); }
@@ -580,8 +580,7 @@ var STYLES = (
580
580
  color: var(--ink); font-size: 11.5px; font-family: ui-monospace, Menlo, monospace; cursor: pointer;
581
581
  }
582
582
  .proj-chip:hover { border-color: var(--accent); }
583
- .proj-chip .proj-repo { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
584
- .proj-chip.unset .proj-repo { color: var(--muted); font-style: italic; }
583
+ .proj-chip .proj-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
585
584
  .proj-caret { color: var(--muted); font-family: inherit; }
586
585
  .proj-pop {
587
586
  position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 25;
@@ -594,19 +593,17 @@ var STYLES = (
594
593
  .pp-x:hover { background: var(--bg-3); color: var(--ink); }
595
594
  .pp-cur { margin-bottom: 8px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
596
595
  .pp-cur b { color: var(--ink); font-family: ui-monospace, Menlo, monospace; }
597
- .pp-search {
598
- width: 100%; margin-bottom: 6px; padding: 6px 8px; border: 1px solid var(--line);
599
- border-radius: 8px; background: var(--bg); color: var(--ink); font-size: 12px;
600
- }
601
- .pp-search:focus { outline: none; border-color: var(--accent); }
602
596
  .pp-list { display: flex; flex-direction: column; gap: 2px; max-height: 30vh; overflow-y: auto; }
603
- .pp-item {
604
- padding: 6px 8px; border: 1px solid transparent; border-radius: 7px; background: transparent;
605
- color: var(--ink); font-size: 11.5px; font-family: ui-monospace, Menlo, monospace;
606
- text-align: left; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
597
+ .pp-proj {
598
+ display: flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid transparent;
599
+ border-radius: 7px; font-size: 11.5px; color: var(--ink);
600
+ }
601
+ .pp-proj.on { border-color: var(--accent); }
602
+ .pp-proj-n { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
603
+ .pp-badge {
604
+ flex: none; padding: 1px 6px; border-radius: 999px; background: var(--bg-3);
605
+ color: var(--muted); font-size: 10px; white-space: nowrap;
607
606
  }
608
- .pp-item:hover { background: var(--bg-3); }
609
- .pp-item.on { border-color: var(--accent); color: var(--accent); }
610
607
  .pp-empty { padding: 8px 6px; font-size: 11px; line-height: 1.5; color: var(--muted); }
611
608
  .pp-empty code { font-family: ui-monospace, Menlo, monospace; color: var(--ink); }
612
609
  .pp-clear {
@@ -640,6 +637,15 @@ var STYLES = (
640
637
  /* The host package's version, shown only when it differs from this bundle's (a stale publish). */
641
638
  .ver-stale { color: var(--pin); font-family: ui-monospace, Menlo, monospace; font-weight: 600; cursor: help; }
642
639
 
640
+ /* ------------------------------------------------ tickets between projects */
641
+ .srcchip, .fwdchip {
642
+ padding: 1px 6px; border-radius: 999px; white-space: nowrap; font-size: 10px;
643
+ border: 1px solid var(--line); background: var(--bg); color: var(--muted);
644
+ max-width: 140px; overflow: hidden; text-overflow: ellipsis;
645
+ }
646
+ .fwdchip { border-color: var(--accent); color: var(--accent); }
647
+ .fwdchip.bad { border-color: #d9534f; color: #d9534f; }
648
+
643
649
  /* ------------------------------------------------ lifecycle chips + review flow */
644
650
  .lifechip {
645
651
  padding: 1px 6px; border-radius: 999px; white-space: nowrap;
@@ -3759,6 +3765,14 @@ var LocalStorageAdapter = class {
3759
3765
  }
3760
3766
  }
3761
3767
  }
3768
+ /** Offline: there is no organization to read. */
3769
+ async getOrg() {
3770
+ return null;
3771
+ }
3772
+ /** Offline: no server feed; the panel shows only what this browser reports. */
3773
+ async listActivity(_projectKey, _since) {
3774
+ return null;
3775
+ }
3762
3776
  };
3763
3777
 
3764
3778
  // src/http-adapter.ts
@@ -3894,6 +3908,28 @@ var HttpAdapter = class {
3894
3908
  }));
3895
3909
  if (!res.ok) throw new Error(`update failed: ${res.status}`);
3896
3910
  }
3911
+ /** The organization, read through the host app. A 404 means the backend has no such endpoint. */
3912
+ async getOrg() {
3913
+ const res = await fetch(`${this.base}/v1/org`, this.opts({ headers: this.headers() }));
3914
+ if (res.status === 404) return null;
3915
+ if (!res.ok) throw new Error(`getOrg failed: ${res.status}`);
3916
+ const body = await res.json();
3917
+ if (!body || typeof body !== "object" || !body.project || !Array.isArray(body.projects)) {
3918
+ throw new Error("getOrg: the answer was not an organization");
3919
+ }
3920
+ return body;
3921
+ }
3922
+ /** The server's activity feed. A 404 means the backend keeps none. */
3923
+ async listActivity(projectKey, since) {
3924
+ const q = new URLSearchParams({ projectKey });
3925
+ if (since) q.set("since", since);
3926
+ const res = await fetch(`${this.base}/v1/activity?${q}`, this.opts({ headers: this.headers() }));
3927
+ if (res.status === 404) return null;
3928
+ if (!res.ok) throw new Error(`listActivity failed: ${res.status}`);
3929
+ const body = await res.json();
3930
+ if (!Array.isArray(body)) throw new Error("listActivity: the answer was not a list");
3931
+ return body;
3932
+ }
3897
3933
  async remove(id) {
3898
3934
  const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3899
3935
  method: "DELETE",
@@ -3904,7 +3940,7 @@ var HttpAdapter = class {
3904
3940
  };
3905
3941
 
3906
3942
  // src/app.ts
3907
- var SDK_VERSION = true ? "0.11.1" : "dev";
3943
+ var SDK_VERSION = true ? "0.12.0" : "dev";
3908
3944
  var FAB_SIZE = 46;
3909
3945
  var FAB_DRAG_THRESHOLD = 6;
3910
3946
  var LAUNCHER_SHORTCUT = "Alt+Shift+L";
@@ -3929,7 +3965,7 @@ var TOUR = [
3929
3965
  sel: ".hscope",
3930
3966
  tab: "home",
3931
3967
  title: "This page, or the whole project",
3932
- body: "Switch to All to see every page's feedback as a day-grouped timeline, with a repo filter."
3968
+ body: "Switch to All to see every page's feedback as a day-grouped timeline."
3933
3969
  },
3934
3970
  {
3935
3971
  sel: ".tools",
@@ -3941,7 +3977,7 @@ var TOUR = [
3941
3977
  sel: '.tabs [data-tab="activity"]',
3942
3978
  tab: "activity",
3943
3979
  title: "Watch the work happen",
3944
- body: "Anything an agent bridge or your app reports lands here \u2014 with tool chips to filter it, and Loupe's own operations alongside."
3980
+ body: "Comments, status changes and forwarded tickets land here as they happen, with anything a bridge or your app reports. Tool chips filter it."
3945
3981
  },
3946
3982
  {
3947
3983
  sel: '.dctl [data-role="settings"]',
@@ -4058,8 +4094,6 @@ var LoupeApp = class {
4058
4094
  this.allLoaded = false;
4059
4095
  /** A clicked stat tile, which narrows the list. */
4060
4096
  this.statFilter = "";
4061
- /** Repo filter, offered once the scope is "all". */
4062
- this.repoFilter = "";
4063
4097
  /** Accent preset id (see ACCENTS), applied as inline --accent / --accent-soft. */
4064
4098
  this.accent = "indigo";
4065
4099
  /** Collapsed to the one-line minimize bar. */
@@ -4083,8 +4117,16 @@ var LoupeApp = class {
4083
4117
  this.activityFollow = true;
4084
4118
  /** The summary card's key/value rows are behind this toggle. */
4085
4119
  this.summaryOpen = false;
4086
- /** Per-browser project settings: a repo override and the environment URLs. */
4120
+ /** Per-browser project settings: the environment URLs and the Local AI endpoint. */
4087
4121
  this.project = { environments: [] };
4122
+ /** The organization, read once from the backend. Null offline or before it loads. */
4123
+ this.org = null;
4124
+ /** Whether the server keeps an activity feed, and whether the last read reached it. */
4125
+ this.activityRemote = "unsupported";
4126
+ /** Newest server event seen, so a poll asks only for what is new. */
4127
+ this.activitySince = "";
4128
+ /** The backend answered that it keeps no feed, so the panel stops asking. */
4129
+ this.activityNone = false;
4088
4130
  /** Iteration history per thread — generating a change is a stack, not a one-shot. */
4089
4131
  this.iterations = /* @__PURE__ */ new Map();
4090
4132
  /** The thread whose generate pane is open, if any. */
@@ -4097,13 +4139,9 @@ var LoupeApp = class {
4097
4139
  this.consent = emptyConsent();
4098
4140
  /** The live SpeechRecognition instance while dictating, if any. */
4099
4141
  this.voice = null;
4100
- this.projSearch = "";
4101
4142
  this.projOpen = false;
4102
- this.projResults = [];
4103
4143
  this.projError = "";
4104
4144
  this.envDraft = "";
4105
- /** Guards against a slow repo search overwriting a newer one. */
4106
- this.repoSeq = 0;
4107
4145
  /** Float-mode window geometry; (x<=0 && y<=0) → placed on first layout. */
4108
4146
  this.floatRect = { x: 0, y: 0, w: 380, h: 540 };
4109
4147
  this.floatDrag = null;
@@ -4448,6 +4486,10 @@ var LoupeApp = class {
4448
4486
  get url() {
4449
4487
  return location.pathname + location.search;
4450
4488
  }
4489
+ /** Chat is experimental: the tab stays dimmed and inert unless the host opts in. */
4490
+ get chatOn() {
4491
+ return this.cfg.chat === true;
4492
+ }
4451
4493
  async start() {
4452
4494
  this.loadState();
4453
4495
  this.loadProject();
@@ -4464,8 +4506,11 @@ var LoupeApp = class {
4464
4506
  this.renderHome();
4465
4507
  this.startPresence();
4466
4508
  this.startLiveThreads();
4467
- this.startCompanionStream();
4509
+ if (this.chatOn) this.startCompanionStream();
4468
4510
  void this.loadNotifications();
4511
+ void this.loadOrg();
4512
+ void this.loadActivity();
4513
+ this.syncActivityPoll();
4469
4514
  void this.loadAllComments();
4470
4515
  this.observe();
4471
4516
  this.watchNavigation();
@@ -4636,8 +4681,13 @@ var LoupeApp = class {
4636
4681
  this.tabList = [...BUILTIN_TABS, ...(this.cfg.tabs ?? []).map((t) => ({ id: t.id, label: t.label }))];
4637
4682
  const tabs = el("div", "tabs");
4638
4683
  for (const t of this.tabList) {
4639
- const b = el("button", "tab", t.label);
4684
+ const off = t.id === "chat" && !this.chatOn;
4685
+ const b = el("button", off ? "tab off" : "tab", t.label);
4640
4686
  b.dataset.tab = t.id;
4687
+ if (off) {
4688
+ b.setAttribute("aria-disabled", "true");
4689
+ b.title = "Chat \u2014 coming soon";
4690
+ }
4641
4691
  b.onclick = () => this.setTab(t.id);
4642
4692
  tabs.appendChild(b);
4643
4693
  }
@@ -4677,15 +4727,6 @@ var LoupeApp = class {
4677
4727
  this.renderList();
4678
4728
  };
4679
4729
  listHead.appendChild(search);
4680
- this.repoSel = el("select", "reposel");
4681
- this.repoSel.title = "Filter by repository";
4682
- this.repoSel.setAttribute("aria-label", "Filter by repository");
4683
- this.repoSel.onchange = () => {
4684
- this.repoFilter = this.repoSel.value;
4685
- this.renderList();
4686
- };
4687
- this.repoSel.style.display = "none";
4688
- listHead.appendChild(this.repoSel);
4689
4730
  this.listEl = el("div", "list");
4690
4731
  const homeView = el("div", "view home-view");
4691
4732
  this.homeEl = homeView;
@@ -4771,6 +4812,7 @@ var LoupeApp = class {
4771
4812
  }
4772
4813
  /** The Chat tab label carries the unread count, so it is visible from any page. */
4773
4814
  updateChatBadge() {
4815
+ if (!this.chatOn) return;
4774
4816
  const tab = this.tabList.findIndex((t) => t.id === "chat");
4775
4817
  const buttons = this.shadow?.querySelectorAll(".tabs .tab");
4776
4818
  const el2 = buttons?.[tab];
@@ -5094,7 +5136,7 @@ var LoupeApp = class {
5094
5136
  }
5095
5137
  }
5096
5138
  buildActivityPanel() {
5097
- 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>`;
5139
+ 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>`;
5098
5140
  this.feedEl = this.activityEl.querySelector("#loupe-mon-feed");
5099
5141
  this.activityHint = this.activityEl.querySelector("#loupe-ahint");
5100
5142
  this.activityEl.querySelector('[data-role="mon-clear"]').onclick = () => this.clearActivity();
@@ -5132,6 +5174,52 @@ var LoupeApp = class {
5132
5174
  this.activityStatus = "idle";
5133
5175
  this.renderActivity();
5134
5176
  }
5177
+ /**
5178
+ * Read the server's feed and merge it in by id. A backend without a feed answers
5179
+ * null once, and the panel stops asking.
5180
+ */
5181
+ async loadActivity() {
5182
+ if (this.activityNone) return;
5183
+ let events;
5184
+ try {
5185
+ const read = this.store.listActivity?.bind(this.store);
5186
+ events = read ? await read(this.cfg.projectKey, this.activitySince || void 0) : null;
5187
+ } catch {
5188
+ if (this.activityRemote === "live") {
5189
+ this.activityRemote = "offline";
5190
+ this.renderActivity();
5191
+ }
5192
+ return;
5193
+ }
5194
+ if (events === null) {
5195
+ this.activityRemote = "unsupported";
5196
+ this.activityNone = true;
5197
+ this.syncActivityPoll();
5198
+ this.renderActivity();
5199
+ return;
5200
+ }
5201
+ this.activityRemote = "live";
5202
+ const known = new Set(this.activityEvents.map((e) => e.id));
5203
+ const fresh = events.filter((e) => e && typeof e.id === "string" && !known.has(e.id));
5204
+ if (fresh.length) {
5205
+ this.activityEvents = [...this.activityEvents, ...fresh].sort((a, b) => a.at.localeCompare(b.at)).slice(-500);
5206
+ const newest = fresh.reduce((m, e) => e.at > m ? e.at : m, this.activitySince);
5207
+ this.activitySince = newest;
5208
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1e3).toISOString();
5209
+ if (fresh.some((e) => e.level === "error" && e.at >= tenMinutesAgo)) this.activityStatus = "error";
5210
+ }
5211
+ this.renderActivity();
5212
+ }
5213
+ /** Poll the server's feed every 15s, but only while the Activity view is on screen. */
5214
+ syncActivityPoll() {
5215
+ const want = this.tab === "activity" && !this.activityNone && !!this.store.listActivity;
5216
+ if (want && !this.activityPoll) {
5217
+ this.activityPoll = setInterval(() => void this.loadActivity(), 15e3);
5218
+ } else if (!want && this.activityPoll) {
5219
+ clearInterval(this.activityPoll);
5220
+ this.activityPoll = void 0;
5221
+ }
5222
+ }
5135
5223
  /** The summary, status row, micro-stats and chips — derived, never stored. */
5136
5224
  renderActivityChrome() {
5137
5225
  if (!this.activityEl) return;
@@ -5140,6 +5228,11 @@ var LoupeApp = class {
5140
5228
  const s = summarizeActivity(this.activityEvents, this.activityStatus);
5141
5229
  status.querySelector(".mon-status-label").textContent = ACTIVITY_STATUS_LABELS[s.status];
5142
5230
  status.className = `mon-status st-${s.status}`;
5231
+ const live = status.querySelector("#loupe-mon-live");
5232
+ if (live) {
5233
+ live.textContent = this.activityRemote === "live" ? "Live" : this.activityRemote === "offline" ? "Offline" : "";
5234
+ live.className = `mon-live ${this.activityRemote}`;
5235
+ }
5143
5236
  const summary = this.activityEl.querySelector("#loupe-mon-summary");
5144
5237
  if (!s.events) {
5145
5238
  summary.innerHTML = "";
@@ -5193,7 +5286,7 @@ var LoupeApp = class {
5193
5286
  if (!this.feedEl) return;
5194
5287
  const events = this.activityFilter ? this.activityEvents.filter((e) => e.kind === this.activityFilter) : this.activityEvents;
5195
5288
  if (!events.length) {
5196
- 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>`;
5289
+ 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>`;
5197
5290
  return;
5198
5291
  }
5199
5292
  this.feedEl.innerHTML = events.map((e) => {
@@ -5209,7 +5302,7 @@ var LoupeApp = class {
5209
5302
  */
5210
5303
  buildHomePanel() {
5211
5304
  const title = this.cfg.label ?? "Loupe";
5212
- 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>`;
5305
+ 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>`;
5213
5306
  this.homeEl.querySelector('[data-role="proj-open"]').onclick = (e) => {
5214
5307
  e.stopPropagation();
5215
5308
  this.setProjectOpen(!this.projOpen);
@@ -5361,65 +5454,36 @@ var LoupeApp = class {
5361
5454
  };
5362
5455
  }
5363
5456
  // ---- project manager (#60) ------------------------------------------------
5364
- /** The repo new comments are filed against: the panel's choice, else the config. */
5365
- effectiveRepo() {
5366
- return this.project.repo || this.cfg.repo || void 0;
5367
- }
5368
5457
  setProjectOpen(open) {
5369
5458
  this.projOpen = open;
5370
5459
  this.projError = "";
5371
- if (open) {
5372
- this.projSearch = "";
5373
- void this.refreshRepos();
5374
- }
5375
5460
  this.renderProject();
5376
5461
  }
5377
- /** Repositories to offer — a fixed list, or the host's search function. */
5378
- async repoChoices() {
5379
- const source = this.cfg.repos;
5380
- if (!source) return [];
5381
- if (Array.isArray(source)) {
5382
- const q = this.projSearch.trim().toLowerCase();
5383
- const all = q ? source.filter((r) => r.toLowerCase().includes(q)) : source;
5384
- return all.slice(0, 50);
5385
- }
5462
+ /** Read the organization once. A backend without the endpoint, or offline mode, leaves it null. */
5463
+ async loadOrg() {
5386
5464
  try {
5387
- const out = await source(this.projSearch);
5388
- return (Array.isArray(out) ? out : []).slice(0, 50);
5389
- } catch (e) {
5390
- this.projError = e instanceof Error ? e.message : "Could not load repositories.";
5391
- return [];
5465
+ this.org = await this.store.getOrg?.() ?? null;
5466
+ } catch {
5467
+ this.org = null;
5392
5468
  }
5469
+ this.renderProject();
5393
5470
  }
5394
- /**
5395
- * Re-run the repo search. The sequence guard matters: typing fires overlapping
5396
- * requests, and without it a slow early reply would overwrite a newer one.
5397
- */
5398
- async refreshRepos() {
5399
- this.projError = "";
5400
- const seq = ++this.repoSeq;
5401
- const out = await this.repoChoices();
5402
- if (seq !== this.repoSeq) return;
5403
- this.projResults = out;
5404
- this.renderRepoList();
5405
- this.renderProjectError();
5471
+ /** "Project · Organization", or the project key when there is no organization to show. */
5472
+ projectLabel() {
5473
+ const org = this.org;
5474
+ const name = org?.project.name || this.cfg.projectKey;
5475
+ return org?.organization ? `${name} \xB7 ${org.organization.name}` : name;
5406
5476
  }
5407
- /** Only the list is rewritten on search, so the input keeps focus while typing. */
5408
- renderRepoList() {
5409
- const list = this.homeEl?.querySelector("#loupe-pp-repos");
5410
- if (!list) return;
5411
- const repo = this.effectiveRepo();
5412
- 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>`;
5413
- list.querySelectorAll("[data-repo]").forEach((b) => {
5414
- b.onclick = () => {
5415
- this.project.repo = b.dataset.repo;
5416
- this.saveProject();
5417
- this.renderProject();
5418
- this.renderPins();
5419
- this.renderList();
5420
- this.addActivity({ kind: "repo.link", label: `Linked this page to ${b.dataset.repo}` });
5421
- };
5422
- });
5477
+ /** The Organization section of the project popover. Read-only: Hub owns this. */
5478
+ orgSectionHtml() {
5479
+ const org = this.org;
5480
+ const head = `<div class="pp-head"><span>Organization</span><button class="pp-x" data-role="pp-close" aria-label="Close">\u2715</button></div>`;
5481
+ if (!org?.organization) {
5482
+ 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>`;
5483
+ }
5484
+ const dest = org.project.destination;
5485
+ const others = org.projects;
5486
+ 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>`;
5423
5487
  }
5424
5488
  renderProjectError() {
5425
5489
  const box = this.homeEl?.querySelector("#loupe-pp-err");
@@ -5432,9 +5496,8 @@ var LoupeApp = class {
5432
5496
  const chip = this.homeEl.querySelector(".proj-chip");
5433
5497
  const pop = this.homeEl.querySelector("#loupe-proj");
5434
5498
  if (!chip || !pop) return;
5435
- const repo = this.effectiveRepo();
5436
- chip.querySelector(".proj-repo").textContent = repo ?? "no repo linked";
5437
- chip.classList.toggle("unset", !repo);
5499
+ chip.querySelector(".proj-name").textContent = this.projectLabel();
5500
+ chip.classList.toggle("unset", !this.org?.organization);
5438
5501
  chip.setAttribute("aria-expanded", String(this.projOpen));
5439
5502
  if (!this.projOpen) {
5440
5503
  pop.classList.remove("open");
@@ -5443,25 +5506,9 @@ var LoupeApp = class {
5443
5506
  }
5444
5507
  pop.classList.add("open");
5445
5508
  const envs = this.project.environments;
5446
- 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>`;
5447
- this.renderRepoList();
5509
+ 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>`;
5448
5510
  this.renderProjectError();
5449
5511
  pop.querySelector('[data-role="pp-close"]').onclick = () => this.setProjectOpen(false);
5450
- pop.querySelector('[data-role="pp-clear"]')?.addEventListener("click", () => {
5451
- this.project.repo = void 0;
5452
- this.saveProject();
5453
- this.renderProject();
5454
- this.renderPins();
5455
- this.renderList();
5456
- });
5457
- const search = pop.querySelector(".pp-search");
5458
- if (search) {
5459
- search.addEventListener("click", (e) => e.stopPropagation());
5460
- search.oninput = () => {
5461
- this.projSearch = search.value;
5462
- void this.refreshRepos();
5463
- };
5464
- }
5465
5512
  pop.querySelectorAll("[data-env-rm]").forEach((b) => {
5466
5513
  b.onclick = () => {
5467
5514
  const i = Number(b.dataset.envRm);
@@ -5525,10 +5572,9 @@ var LoupeApp = class {
5525
5572
  try {
5526
5573
  const raw = localStorage.getItem(`loupe:project:${this.cfg.projectKey}`);
5527
5574
  const p = raw ? JSON.parse(raw) : null;
5528
- const repo = typeof p?.repo === "string" && p.repo ? p.repo : void 0;
5529
5575
  const stored = Array.isArray(p?.environments) ? p.environments.filter((u) => typeof u === "string") : null;
5530
5576
  const localAi = p?.localAi && typeof p.localAi.url === "string" && typeof p.localAi.model === "string" ? { url: p.localAi.url, model: p.localAi.model } : void 0;
5531
- this.project = { repo, environments: stored ?? fromConfig, localAi };
5577
+ this.project = { environments: stored ?? fromConfig, localAi };
5532
5578
  } catch {
5533
5579
  this.project = { environments: fromConfig };
5534
5580
  }
@@ -6127,10 +6173,6 @@ var LoupeApp = class {
6127
6173
  status: "queue",
6128
6174
  priority,
6129
6175
  changeType,
6130
- // Branch-aware threads: the host declares these once in `init()`, and the
6131
- // panel's project manager can override the repo per browser.
6132
- repo: this.effectiveRepo(),
6133
- branch: this.cfg.branch,
6134
6176
  kind: target.kind,
6135
6177
  anchor,
6136
6178
  context,
@@ -6161,8 +6203,7 @@ var LoupeApp = class {
6161
6203
  this.addActivity({
6162
6204
  kind: "comment.create",
6163
6205
  label: `Created \u201C${comment.title || comment.body.split("\n")[0] || "comment"}\u201D`,
6164
- detail: [normalizePriority(comment.priority), comment.kind ?? "element", comment.repo].filter(Boolean).join(" \xB7 "),
6165
- files: comment.repo ? [`${comment.repo}/${shortPath(comment.url)}`] : []
6206
+ detail: [normalizePriority(comment.priority), comment.kind ?? "element", shortPath(comment.url)].join(" \xB7 ")
6166
6207
  });
6167
6208
  }
6168
6209
  /** Upload the reporter's picked files. A file that fails is skipped, not fatal. */
@@ -6187,6 +6228,7 @@ var LoupeApp = class {
6187
6228
  }
6188
6229
  }
6189
6230
  this.comments.forEach((c, i) => {
6231
+ if (c.source) return;
6190
6232
  let pin = this.pins.get(c.id);
6191
6233
  if (!pin) {
6192
6234
  pin = el("button", "pin");
@@ -6268,6 +6310,7 @@ var LoupeApp = class {
6268
6310
  /** Switch the sidebar page (Home ↔ Comments ↔ Connect Claude). */
6269
6311
  setTab(tab) {
6270
6312
  if (this.tab === tab) return;
6313
+ if (tab === "chat" && !this.chatOn) return;
6271
6314
  if (tab !== "comments") this.setMode("off");
6272
6315
  this.tab = tab;
6273
6316
  this.hintFor = null;
@@ -6277,7 +6320,11 @@ var LoupeApp = class {
6277
6320
  if (this.scope === "all" && !this.allComments.length) void this.loadAllComments();
6278
6321
  }
6279
6322
  if (tab === "comments") this.renderList();
6280
- if (tab === "activity") this.renderActivity();
6323
+ if (tab === "activity") {
6324
+ this.renderActivity();
6325
+ void this.loadActivity();
6326
+ }
6327
+ this.syncActivityPoll();
6281
6328
  this.applyDockLayout();
6282
6329
  }
6283
6330
  toggleTheme() {
@@ -6464,11 +6511,10 @@ var LoupeApp = class {
6464
6511
  if (DOCK_MODES.includes(p?.mode)) this.dockMode = p.mode;
6465
6512
  if (typeof p?.open === "boolean") this.open = p.open;
6466
6513
  if (p?.theme === "light" || p?.theme === "dark") this.theme = p.theme;
6467
- const known = [...BUILTIN_TABS.map((t) => t.id), ...(this.cfg.tabs ?? []).map((t) => t.id)];
6514
+ const known = [...BUILTIN_TABS.map((t) => t.id).filter((id) => id !== "chat" || this.chatOn), ...(this.cfg.tabs ?? []).map((t) => t.id)];
6468
6515
  if (typeof p?.tab === "string" && known.includes(p.tab)) this.tab = p.tab;
6469
6516
  if (p?.scope === "page" || p?.scope === "all") this.scope = p.scope;
6470
6517
  if (typeof p?.statFilter === "string") this.statFilter = p.statFilter;
6471
- if (typeof p?.repoFilter === "string") this.repoFilter = p.repoFilter;
6472
6518
  if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
6473
6519
  if (typeof p?.markersHidden === "boolean") this.markersHidden = p.markersHidden;
6474
6520
  if (typeof p?.launcherHidden === "boolean") this.launcherHidden = p.launcherHidden;
@@ -6495,7 +6541,6 @@ var LoupeApp = class {
6495
6541
  fab: this.fabPos,
6496
6542
  scope: this.scope,
6497
6543
  statFilter: this.statFilter,
6498
- repoFilter: this.repoFilter,
6499
6544
  accent: this.accent,
6500
6545
  minimized: this.minimized,
6501
6546
  hoverHints: this.hoverHints,
@@ -6578,11 +6623,9 @@ var LoupeApp = class {
6578
6623
  if (this.statFilter === "needs_you" && !needsYou({ status: c.status }).needs) return false;
6579
6624
  if (this.statFilter === "resolved" && stage !== "resolved") return false;
6580
6625
  if (this.statFilter === "stale" && (stage === "resolved" || !(Date.parse(c.createdAt) < weekAgo))) return false;
6581
- if (this.repoFilter && c.repo !== this.repoFilter) return false;
6582
6626
  return !q || `${c.title ?? ""} ${c.body} ${c.author?.name ?? ""}`.toLowerCase().includes(q);
6583
6627
  });
6584
6628
  if (this.scope === "all") items = [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
6585
- this.renderRepoFilter();
6586
6629
  this.renderReviewBar();
6587
6630
  if (!items.length) {
6588
6631
  this.listEl.appendChild(el(
@@ -6604,15 +6647,6 @@ var LoupeApp = class {
6604
6647
  });
6605
6648
  this.updateCount(items.length);
6606
6649
  }
6607
- /** Repo filter — only offered in the project scope, where it means something. */
6608
- renderRepoFilter() {
6609
- if (!this.repoSel) return;
6610
- const repos = [...new Set(this.visibleComments.map((c) => c.repo).filter((r) => !!r))].sort();
6611
- this.repoSel.style.display = this.scope === "all" && repos.length > 1 ? "" : "none";
6612
- const current2 = this.repoFilter;
6613
- this.repoSel.innerHTML = `<option value="">All repos</option>` + repos.map((r) => `<option value="${escapeHtml(r)}">${escapeHtml(r)}</option>`).join("");
6614
- this.repoSel.value = current2;
6615
- }
6616
6650
  itemView(c, i) {
6617
6651
  const detached = this.pins.get(c.id)?.classList.contains("detached") && !isResolved(c);
6618
6652
  const open = this.expanded.has(c.id);
@@ -6627,6 +6661,19 @@ var LoupeApp = class {
6627
6661
  const icon = vw < 768 ? "\u{1F4F1}" : vw < 1024 ? "\u25A6" : "\u{1F5A5}";
6628
6662
  top.appendChild(el("span", "device", `${icon} ${kind}`));
6629
6663
  }
6664
+ if (c.source) {
6665
+ const from = el("span", "srcchip", `from ${c.source.projectName || c.source.projectId}`);
6666
+ from.title = c.source.reporter ? `Sent by ${c.source.reporter.name || c.source.reporter.email}` : "Sent from another project";
6667
+ top.appendChild(from);
6668
+ }
6669
+ const fwd = c.forwarded;
6670
+ if (fwd && fwd.status !== "none") {
6671
+ const where = fwd.destinationName || (fwd.status === "ok" ? "webhook" : "Hub");
6672
+ const ok = fwd.status === "ok";
6673
+ const chip = el("span", ok ? "fwdchip" : "fwdchip bad", ok ? `\u2192 ${where}` : `\u2192 ${where} failed`);
6674
+ chip.title = ok ? `Sent to ${where}` : `Could not send to ${where}${fwd.error ? `: ${fwd.error}` : ""}`;
6675
+ top.appendChild(chip);
6676
+ }
6630
6677
  if (isResolved(c)) top.appendChild(el("span", "badge done", "resolved"));
6631
6678
  else if (detached) {
6632
6679
  const b = el("span", "badge detached", "moved");
@@ -7802,6 +7849,8 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7802
7849
  this.companionSource?.close();
7803
7850
  this.companionSource = void 0;
7804
7851
  if (this.companionPoll) clearInterval(this.companionPoll);
7852
+ if (this.activityPoll) clearInterval(this.activityPoll);
7853
+ this.activityPoll = void 0;
7805
7854
  this.stopVoice();
7806
7855
  this.stopPresence();
7807
7856
  this.stopRecording?.();