@loupekit/sdk 0.13.0 → 0.14.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.
@@ -427,11 +427,35 @@ var Loupe = (() => {
427
427
  .hfeed-p { font-weight: 700; }
428
428
  .hfeed-p-critical { color: var(--pin); }
429
429
  .hfeed-p-high { color: #e0a92c; }
430
+ .hfeed-a { display: flex; gap: 4px; margin-top: 3px; }
431
+ .hfeed-act {
432
+ font-size: 11px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 6px;
433
+ background: var(--bg-3); color: var(--ink); cursor: pointer;
434
+ }
435
+ .hfeed-act:hover { border-color: var(--accent); }
436
+ .hfeed-act.danger:hover, .hfeed-act.danger[data-armed] { border-color: var(--pin); color: var(--pin); }
430
437
  .hempty { padding: 14px; text-align: center; color: var(--muted); font-size: 12px; }
431
438
  .hfoot { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); text-align: center; }
432
439
 
433
440
  /* Timeline grouping (project scope) + the repo filter. */
434
- .daylabel { margin: 8px 2px 0; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
441
+ .daylabel {
442
+ display: flex; align-items: center; gap: 6px; width: 100%; margin: 8px 0 0; padding: 4px 2px;
443
+ border: 0; background: none; cursor: pointer; text-align: left;
444
+ font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700;
445
+ }
446
+ .daylabel::before { content: "\u25BE"; font-size: 10px; width: 10px; }
447
+ .daylabel.shut::before { content: "\u25B8"; }
448
+ .daylabel::after {
449
+ content: attr(data-n); padding: 0 6px; border-radius: 999px; background: var(--bg-3);
450
+ font-size: 10px; letter-spacing: 0;
451
+ }
452
+ .daylabel:hover { color: var(--ink); }
453
+ .listfilters { display: flex; gap: 6px; margin: 6px 0 2px; }
454
+ .listfilters select {
455
+ flex: 1; min-width: 0; font-size: 12px; padding: 4px 6px; border: 1px solid var(--line); border-radius: 7px;
456
+ background: var(--bg-2); color: var(--ink); outline: none; cursor: pointer;
457
+ }
458
+ .listfilters select:focus { border-color: var(--accent); }
435
459
  /* The page a comment belongs to, shown in the project scope (Settings \u2192 Page paths). */
436
460
  .pathtag {
437
461
  padding: 1px 6px; border-radius: 999px; background: var(--bg-3); color: var(--muted);
@@ -3983,7 +4007,7 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
3983
4007
  };
3984
4008
 
3985
4009
  // src/app.ts
3986
- var SDK_VERSION = true ? "0.13.0" : "dev";
4010
+ var SDK_VERSION = true ? "0.14.0" : "dev";
3987
4011
  var FAB_SIZE = 46;
3988
4012
  var FAB_DRAG_THRESHOLD = 6;
3989
4013
  var LAUNCHER_SHORTCUT = "Alt+Shift+L";
@@ -4047,6 +4071,7 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
4047
4071
  var DOCK_MODES = ["left", "right", "bottom", "float"];
4048
4072
  var RECORD_MAX_MS = 2e4;
4049
4073
  var MAX_FILES = 10;
4074
+ var SYNC_POLL_MS = 1e4;
4050
4075
  var MAX_IMAGE_BYTES = 10 * 1024 * 1024;
4051
4076
  var MAX_VIDEO_BYTES = 25 * 1024 * 1024;
4052
4077
  var uid2 = () => crypto.randomUUID ? crypto.randomUUID() : "c_" + Math.abs(hash(String(performance.now()))).toString(36);
@@ -4137,6 +4162,11 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
4137
4162
  this.allLoaded = false;
4138
4163
  /** A clicked stat tile, which narrows the list. */
4139
4164
  this.statFilter = "";
4165
+ /** One board stage to show, or "" for every stage. */
4166
+ this.statusFilter = "";
4167
+ this.sortOrder = "";
4168
+ /** Day groups the reader folded shut. Session only: "Today" means another day tomorrow. */
4169
+ this.collapsedDays = /* @__PURE__ */ new Set();
4140
4170
  /** Accent preset id (see ACCENTS), applied as inline --accent / --accent-soft. */
4141
4171
  this.accent = "indigo";
4142
4172
  /** Collapsed to the one-line minimize bar. */
@@ -4170,6 +4200,7 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
4170
4200
  this.activitySince = "";
4171
4201
  /** The backend answered that it keeps no feed, so the panel stops asking. */
4172
4202
  this.activityNone = false;
4203
+ this.syncBusy = false;
4173
4204
  /** Iteration history per thread — generating a change is a stack, not a one-shot. */
4174
4205
  this.iterations = /* @__PURE__ */ new Map();
4175
4206
  /** The thread whose generate pane is open, if any. */
@@ -4190,6 +4221,9 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
4190
4221
  this.floatDrag = null;
4191
4222
  this.floatResize = null;
4192
4223
  this.raf = 0;
4224
+ this.onSyncVisible = () => {
4225
+ if (document.visibilityState === "visible") void this.syncTick();
4226
+ };
4193
4227
  /** Tab id → its hint slot (custom tabs only; the built-ins have named fields). */
4194
4228
  this.customHintSlots = /* @__PURE__ */ new Map();
4195
4229
  /** The tab strip, in order. */
@@ -4554,6 +4588,7 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
4554
4588
  void this.loadOrg();
4555
4589
  void this.loadActivity();
4556
4590
  this.syncActivityPoll();
4591
+ this.startSyncPoll();
4557
4592
  void this.loadAllComments();
4558
4593
  this.observe();
4559
4594
  this.watchNavigation();
@@ -4595,6 +4630,61 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
4595
4630
  } catch {
4596
4631
  }
4597
4632
  }
4633
+ /**
4634
+ * Keep the panel current without a reload.
4635
+ *
4636
+ * A status moved in another app (CRM approving a ticket), a reply relayed through Hub,
4637
+ * or a teammate's new pin otherwise appears only after the page reloads. The bridge's
4638
+ * SSE covers threads when one is configured; this covers everything else, for every
4639
+ * host. It pauses while the tab is hidden and catches up the moment it is shown again.
4640
+ */
4641
+ startSyncPoll() {
4642
+ if (this.syncPoll) return;
4643
+ this.syncPoll = setInterval(() => void this.syncTick(), SYNC_POLL_MS);
4644
+ document.addEventListener("visibilitychange", this.onSyncVisible);
4645
+ }
4646
+ async syncTick() {
4647
+ if (this.syncBusy || document.visibilityState === "hidden") return;
4648
+ const active = this.shadow?.activeElement;
4649
+ if (active && (active.tagName === "TEXTAREA" || active.tagName === "INPUT")) return;
4650
+ this.syncBusy = true;
4651
+ try {
4652
+ const url = this.url;
4653
+ const threads = [...this.messages.keys()];
4654
+ const [page, all, ...replies] = await Promise.all([
4655
+ this.store.list(this.cfg.projectKey, url).catch(() => null),
4656
+ this.allLoaded || this.scope === "all" ? this.store.listAll(this.cfg.projectKey).catch(() => null) : null,
4657
+ ...threads.map((id) => this.store.listMessages(id).catch(() => null))
4658
+ ]);
4659
+ if (url !== this.url) return;
4660
+ let changed = false;
4661
+ if (Array.isArray(page) && JSON.stringify(page) !== JSON.stringify(this.comments)) {
4662
+ this.comments = page;
4663
+ changed = true;
4664
+ }
4665
+ if (Array.isArray(all) && JSON.stringify(all) !== JSON.stringify(this.allComments)) {
4666
+ this.allComments = all;
4667
+ changed = true;
4668
+ }
4669
+ threads.forEach((id, i) => {
4670
+ const list = replies[i];
4671
+ if (!Array.isArray(list)) return;
4672
+ const local = (this.messages.get(id) ?? []).filter((m) => this.msgPending.has(m.id) || this.msgFailed.has(m.id));
4673
+ const next = [...list, ...local];
4674
+ if (JSON.stringify(next) === JSON.stringify(this.messages.get(id))) return;
4675
+ this.messages.set(id, next);
4676
+ changed = true;
4677
+ });
4678
+ if (changed) {
4679
+ this.renderPins();
4680
+ this.renderList();
4681
+ this.renderHome();
4682
+ }
4683
+ } finally {
4684
+ this.syncBusy = false;
4685
+ }
4686
+ void this.loadNotifications();
4687
+ }
4598
4688
  // ---- DOM construction -----------------------------------------------------
4599
4689
  buildDom() {
4600
4690
  this.root = document.createElement("div");
@@ -4770,6 +4860,27 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
4770
4860
  this.renderList();
4771
4861
  };
4772
4862
  listHead.appendChild(search);
4863
+ const filters = el("div", "listfilters");
4864
+ this.statusSel = el("select", "lf-status");
4865
+ this.statusSel.setAttribute("aria-label", "Filter by status");
4866
+ this.statusSel.append(
4867
+ optionEl("All statuses", "", !this.statusFilter),
4868
+ ...Object.keys(STAGE_LABELS).map((k) => optionEl(STAGE_LABELS[k], k, this.statusFilter === k))
4869
+ );
4870
+ this.statusSel.onchange = () => {
4871
+ this.statusFilter = this.statusSel.value;
4872
+ this.saveState();
4873
+ this.renderList();
4874
+ };
4875
+ this.sortSel = el("select", "lf-sort");
4876
+ this.sortSel.setAttribute("aria-label", "Order");
4877
+ this.sortSel.append(optionEl("Newest first", "newest", false), optionEl("Oldest first", "oldest", false), optionEl("Page order", "page", false));
4878
+ this.sortSel.onchange = () => {
4879
+ this.sortOrder = this.sortSel.value;
4880
+ this.saveState();
4881
+ this.renderList();
4882
+ };
4883
+ filters.append(this.statusSel, this.sortSel);
4773
4884
  this.listEl = el("div", "list");
4774
4885
  const homeView = el("div", "view home-view");
4775
4886
  this.homeEl = homeView;
@@ -4777,7 +4888,7 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
4777
4888
  this.commentsHint = el("div", "hint-slot");
4778
4889
  this.reviewBar = el("div", "reviewbar");
4779
4890
  this.reviewBar.style.display = "none";
4780
- commentsView.append(this.commentsHint, tools, listHead, this.reviewBar, this.listEl, this.buildIntegrations());
4891
+ commentsView.append(this.commentsHint, tools, listHead, filters, this.reviewBar, this.listEl, this.buildIntegrations());
4781
4892
  const activityView = el("div", "view activity-view");
4782
4893
  this.activityEl = activityView;
4783
4894
  const chatView = el("div", "view chat-view");
@@ -5439,17 +5550,49 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
5439
5550
  feed.innerHTML = recent.length ? recent.map((c) => {
5440
5551
  const stage = STAGE_LABELS[normalizeStatus(c.status)];
5441
5552
  const prio = normalizePriority(c.priority);
5442
- return `<button class="hfeed-i" data-id="${escapeAttr(c.id)}"><span class="hfeed-t">${escapeHtml(c.title || (c.body.split("\n")[0] ?? "").slice(0, 60))}</span><span class="hfeed-m">${escapeHtml(c.author?.name ?? "")} \xB7 <span title="${escapeAttr(this.fmtWhen(c.createdAt))}">${fmtAgo(c.createdAt)}</span> \xB7 <span class="hfeed-s hfeed-s-${normalizeStatus(c.status)}">${stage}</span><span class="hfeed-p hfeed-p-${prio}">${PRIORITY_LABELS[prio]}</span></span></button>`;
5553
+ return `<div class="hfeed-i" role="button" tabindex="0" data-id="${escapeAttr(c.id)}"><span class="hfeed-t">${escapeHtml(c.title || (c.body.split("\n")[0] ?? "").slice(0, 60))}</span><span class="hfeed-m">${escapeHtml(c.author?.name ?? "")} \xB7 <span title="${escapeAttr(this.fmtWhen(c.createdAt))}">${fmtAgo(c.createdAt)}</span> \xB7 <span class="hfeed-s hfeed-s-${normalizeStatus(c.status)}">${stage}</span><span class="hfeed-p hfeed-p-${prio}">${PRIORITY_LABELS[prio]}</span></span><span class="hfeed-a"><button class="hfeed-act" data-act="resolve">${isResolved(c) ? "Reopen" : "Resolve"}</button><button class="hfeed-act" data-act="open">Open</button><button class="hfeed-act danger" data-act="delete">Delete</button></span></div>`;
5443
5554
  }).join("") : `<div class="hempty">Nothing here yet.</div>`;
5444
5555
  feed.querySelectorAll(".hfeed-i").forEach((b) => {
5445
- b.onclick = () => {
5446
- const id = b.dataset.id;
5556
+ const id = b.dataset.id;
5557
+ const open = () => {
5447
5558
  this.statFilter = "";
5559
+ this.statusFilter = "";
5448
5560
  this.setTab("comments");
5449
5561
  this.expanded.add(id);
5562
+ const c = recent.find((x) => x.id === id);
5563
+ if (c) void this.loadMessages(c);
5450
5564
  this.renderList();
5451
5565
  this.flash(id);
5452
5566
  };
5567
+ b.onclick = open;
5568
+ b.onkeydown = (e) => {
5569
+ if (e.key === "Enter" || e.key === " ") {
5570
+ e.preventDefault();
5571
+ open();
5572
+ }
5573
+ };
5574
+ b.querySelectorAll(".hfeed-act").forEach((btn) => {
5575
+ btn.onclick = (e) => {
5576
+ e.stopPropagation();
5577
+ const c = recent.find((x) => x.id === id);
5578
+ if (!c) return;
5579
+ if (btn.dataset.act === "open") open();
5580
+ else if (btn.dataset.act === "resolve") void this.toggleResolved(c);
5581
+ else if (btn.dataset.act === "delete") {
5582
+ if (btn.dataset.armed) void this.deleteComment(c);
5583
+ else {
5584
+ btn.dataset.armed = "1";
5585
+ btn.textContent = "Confirm delete";
5586
+ setTimeout(() => {
5587
+ if (btn.isConnected) {
5588
+ delete btn.dataset.armed;
5589
+ btn.textContent = "Delete";
5590
+ }
5591
+ }, 3e3);
5592
+ }
5593
+ }
5594
+ };
5595
+ });
5453
5596
  });
5454
5597
  }
5455
5598
  async loadNotifications() {
@@ -6558,6 +6701,8 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
6558
6701
  if (typeof p?.tab === "string" && known.includes(p.tab)) this.tab = p.tab;
6559
6702
  if (p?.scope === "page" || p?.scope === "all") this.scope = p.scope;
6560
6703
  if (typeof p?.statFilter === "string") this.statFilter = p.statFilter;
6704
+ if (typeof p?.statusFilter === "string" && p.statusFilter in STAGE_LABELS) this.statusFilter = p.statusFilter;
6705
+ if (["newest", "oldest", "page"].includes(p?.sortOrder)) this.sortOrder = p.sortOrder;
6561
6706
  if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
6562
6707
  if (typeof p?.markersHidden === "boolean") this.markersHidden = p.markersHidden;
6563
6708
  if (typeof p?.launcherHidden === "boolean") this.launcherHidden = p.launcherHidden;
@@ -6584,6 +6729,8 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
6584
6729
  fab: this.fabPos,
6585
6730
  scope: this.scope,
6586
6731
  statFilter: this.statFilter,
6732
+ statusFilter: this.statusFilter,
6733
+ sortOrder: this.sortOrder,
6587
6734
  accent: this.accent,
6588
6735
  minimized: this.minimized,
6589
6736
  hoverHints: this.hoverHints,
@@ -6666,25 +6813,43 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
6666
6813
  if (this.statFilter === "needs_you" && !needsYou({ status: c.status }).needs) return false;
6667
6814
  if (this.statFilter === "resolved" && stage !== "resolved") return false;
6668
6815
  if (this.statFilter === "stale" && (stage === "resolved" || !(Date.parse(c.createdAt) < weekAgo))) return false;
6816
+ if (this.statusFilter && stage !== this.statusFilter) return false;
6669
6817
  return !q || `${c.title ?? ""} ${c.body} ${c.author?.name ?? ""}`.toLowerCase().includes(q);
6670
6818
  });
6671
- if (this.scope === "all") items = [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
6819
+ const order = this.sortOrder || (this.scope === "all" ? "newest" : "page");
6820
+ if (this.sortSel) this.sortSel.value = order;
6821
+ if (this.statusSel) this.statusSel.value = this.statusFilter;
6822
+ if (order === "newest") items = [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
6823
+ if (order === "oldest") items = [...items].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
6824
+ const grouped = order !== "page";
6825
+ const perDay = /* @__PURE__ */ new Map();
6826
+ if (grouped) for (const c of items) perDay.set(dayLabel(c.createdAt), (perDay.get(dayLabel(c.createdAt)) ?? 0) + 1);
6672
6827
  this.renderReviewBar();
6673
6828
  if (!items.length) {
6674
6829
  this.listEl.appendChild(el(
6675
6830
  "div",
6676
6831
  "empty",
6677
- this.statFilter ? "Nothing in this bucket." : q ? "No comments match your search." : this.scope === "all" ? "No feedback in this project yet." : "No comments yet. Use Inspect to pick an element, or Note to drop a comment anywhere on the page."
6832
+ this.statFilter ? "Nothing in this bucket." : this.statusFilter ? `Nothing in ${STAGE_LABELS[this.statusFilter]}.` : q ? "No comments match your search." : this.scope === "all" ? "No feedback in this project yet." : "No comments yet. Use Inspect to pick an element, or Note to drop a comment anywhere on the page."
6678
6833
  ));
6679
6834
  }
6680
6835
  let lastDay = "";
6681
6836
  items.forEach((c, i) => {
6682
- if (this.scope === "all") {
6837
+ if (grouped) {
6683
6838
  const day = dayLabel(c.createdAt);
6839
+ const shut = this.collapsedDays.has(day);
6684
6840
  if (day !== lastDay) {
6685
- this.listEl.appendChild(el("div", "daylabel", day));
6841
+ const head = el("button", "daylabel" + (shut ? " shut" : ""), day);
6842
+ head.dataset.n = String(perDay.get(day) ?? 0);
6843
+ head.setAttribute("aria-expanded", String(!shut));
6844
+ head.onclick = () => {
6845
+ if (shut) this.collapsedDays.delete(day);
6846
+ else this.collapsedDays.add(day);
6847
+ this.renderList();
6848
+ };
6849
+ this.listEl.appendChild(head);
6686
6850
  lastDay = day;
6687
6851
  }
6852
+ if (shut) return;
6688
6853
  }
6689
6854
  this.listEl.appendChild(this.itemView(c, i));
6690
6855
  });
@@ -6831,31 +6996,14 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
6831
6996
  }
6832
6997
  const actions = el("div", "actions");
6833
6998
  const doneBtn = el("button", "", isResolved(c) ? "Reopen" : "Resolve");
6834
- doneBtn.onclick = async (e) => {
6999
+ doneBtn.onclick = (e) => {
6835
7000
  e.stopPropagation();
6836
- const status = isResolved(c) ? "queue" : "resolved";
6837
- c.status = status;
6838
- await this.store.update(c.id, { status });
6839
- this.renderPins();
6840
- this.renderList();
6841
- this.addActivity({
6842
- kind: status === "resolved" ? "comment.resolve" : "comment.reopen",
6843
- label: `${status === "resolved" ? "Resolved" : "Reopened"} \u201C${c.title || c.body.split("\n")[0] || "comment"}\u201D`
6844
- });
7001
+ void this.toggleResolved(c);
6845
7002
  };
6846
7003
  const del = el("button", "", "Delete");
6847
- del.onclick = async (e) => {
7004
+ del.onclick = (e) => {
6848
7005
  e.stopPropagation();
6849
- await this.store.remove(c.id);
6850
- this.comments = this.comments.filter((x) => x.id !== c.id);
6851
- this.resolved.delete(c.id);
6852
- this.renderPins();
6853
- this.renderList();
6854
- this.addActivity({
6855
- kind: "comment.delete",
6856
- label: `Deleted \u201C${c.title || c.body.split("\n")[0] || "comment"}\u201D`,
6857
- level: "warn"
6858
- });
7006
+ void this.deleteComment(c);
6859
7007
  };
6860
7008
  actions.append(doneBtn, del);
6861
7009
  detail.appendChild(actions);
@@ -6872,6 +7020,37 @@ a.fwdchip { text-decoration: none; cursor: pointer; }
6872
7020
  };
6873
7021
  return item;
6874
7022
  }
7023
+ /**
7024
+ * Resolve or reopen a comment. Shared by the card and the Home feed. The page list
7025
+ * and the project list are separate reads, so the change is applied to both.
7026
+ */
7027
+ async toggleResolved(c) {
7028
+ const status = isResolved(c) ? "queue" : "resolved";
7029
+ await this.store.update(c.id, { status });
7030
+ for (const x of [c, ...this.comments, ...this.allComments]) if (x.id === c.id) x.status = status;
7031
+ this.renderPins();
7032
+ this.renderList();
7033
+ this.renderHome();
7034
+ this.addActivity({
7035
+ kind: status === "resolved" ? "comment.resolve" : "comment.reopen",
7036
+ label: `${status === "resolved" ? "Resolved" : "Reopened"} \u201C${c.title || c.body.split("\n")[0] || "comment"}\u201D`
7037
+ });
7038
+ }
7039
+ /** Delete a comment, from the card or the Home feed. */
7040
+ async deleteComment(c) {
7041
+ await this.store.remove(c.id);
7042
+ this.comments = this.comments.filter((x) => x.id !== c.id);
7043
+ this.allComments = this.allComments.filter((x) => x.id !== c.id);
7044
+ this.resolved.delete(c.id);
7045
+ this.renderPins();
7046
+ this.renderList();
7047
+ this.renderHome();
7048
+ this.addActivity({
7049
+ kind: "comment.delete",
7050
+ label: `Deleted \u201C${c.title || c.body.split("\n")[0] || "comment"}\u201D`,
7051
+ level: "warn"
7052
+ });
7053
+ }
6875
7054
  /**
6876
7055
  * The review banner. This is the one place the panel asks a human to decide
6877
7056
  * something, so it sits above everything else in the detail.
@@ -7911,6 +8090,9 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7911
8090
  if (this.companionPoll) clearInterval(this.companionPoll);
7912
8091
  if (this.activityPoll) clearInterval(this.activityPoll);
7913
8092
  this.activityPoll = void 0;
8093
+ if (this.syncPoll) clearInterval(this.syncPoll);
8094
+ this.syncPoll = void 0;
8095
+ document.removeEventListener("visibilitychange", this.onSyncVisible);
7914
8096
  this.stopVoice();
7915
8097
  this.stopPresence();
7916
8098
  this.stopRecording?.();