@loupekit/sdk 0.13.0 → 0.14.0-next.80

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
@@ -390,11 +390,35 @@ var STYLES = (
390
390
  .hfeed-p { font-weight: 700; }
391
391
  .hfeed-p-critical { color: var(--pin); }
392
392
  .hfeed-p-high { color: #e0a92c; }
393
+ .hfeed-a { display: flex; gap: 4px; margin-top: 3px; }
394
+ .hfeed-act {
395
+ font-size: 11px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 6px;
396
+ background: var(--bg-3); color: var(--ink); cursor: pointer;
397
+ }
398
+ .hfeed-act:hover { border-color: var(--accent); }
399
+ .hfeed-act.danger:hover, .hfeed-act.danger[data-armed] { border-color: var(--pin); color: var(--pin); }
393
400
  .hempty { padding: 14px; text-align: center; color: var(--muted); font-size: 12px; }
394
401
  .hfoot { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); text-align: center; }
395
402
 
396
403
  /* Timeline grouping (project scope) + the repo filter. */
397
- .daylabel { margin: 8px 2px 0; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
404
+ .daylabel {
405
+ display: flex; align-items: center; gap: 6px; width: 100%; margin: 8px 0 0; padding: 4px 2px;
406
+ border: 0; background: none; cursor: pointer; text-align: left;
407
+ font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700;
408
+ }
409
+ .daylabel::before { content: "\u25BE"; font-size: 10px; width: 10px; }
410
+ .daylabel.shut::before { content: "\u25B8"; }
411
+ .daylabel::after {
412
+ content: attr(data-n); padding: 0 6px; border-radius: 999px; background: var(--bg-3);
413
+ font-size: 10px; letter-spacing: 0;
414
+ }
415
+ .daylabel:hover { color: var(--ink); }
416
+ .listfilters { display: flex; gap: 6px; margin: 6px 0 2px; }
417
+ .listfilters select {
418
+ flex: 1; min-width: 0; font-size: 12px; padding: 4px 6px; border: 1px solid var(--line); border-radius: 7px;
419
+ background: var(--bg-2); color: var(--ink); outline: none; cursor: pointer;
420
+ }
421
+ .listfilters select:focus { border-color: var(--accent); }
398
422
  /* The page a comment belongs to, shown in the project scope (Settings \u2192 Page paths). */
399
423
  .pathtag {
400
424
  padding: 1px 6px; border-radius: 999px; background: var(--bg-3); color: var(--muted);
@@ -3946,7 +3970,7 @@ var HttpAdapter = class {
3946
3970
  };
3947
3971
 
3948
3972
  // src/app.ts
3949
- var SDK_VERSION = true ? "0.13.0" : "dev";
3973
+ var SDK_VERSION = true ? "0.14.0" : "dev";
3950
3974
  var FAB_SIZE = 46;
3951
3975
  var FAB_DRAG_THRESHOLD = 6;
3952
3976
  var LAUNCHER_SHORTCUT = "Alt+Shift+L";
@@ -4010,6 +4034,7 @@ var BUILTIN_TABS = [
4010
4034
  var DOCK_MODES = ["left", "right", "bottom", "float"];
4011
4035
  var RECORD_MAX_MS = 2e4;
4012
4036
  var MAX_FILES = 10;
4037
+ var SYNC_POLL_MS = 1e4;
4013
4038
  var MAX_IMAGE_BYTES = 10 * 1024 * 1024;
4014
4039
  var MAX_VIDEO_BYTES = 25 * 1024 * 1024;
4015
4040
  var uid2 = () => crypto.randomUUID ? crypto.randomUUID() : "c_" + Math.abs(hash(String(performance.now()))).toString(36);
@@ -4100,6 +4125,11 @@ var LoupeApp = class {
4100
4125
  this.allLoaded = false;
4101
4126
  /** A clicked stat tile, which narrows the list. */
4102
4127
  this.statFilter = "";
4128
+ /** One board stage to show, or "" for every stage. */
4129
+ this.statusFilter = "";
4130
+ this.sortOrder = "";
4131
+ /** Day groups the reader folded shut. Session only: "Today" means another day tomorrow. */
4132
+ this.collapsedDays = /* @__PURE__ */ new Set();
4103
4133
  /** Accent preset id (see ACCENTS), applied as inline --accent / --accent-soft. */
4104
4134
  this.accent = "indigo";
4105
4135
  /** Collapsed to the one-line minimize bar. */
@@ -4133,6 +4163,7 @@ var LoupeApp = class {
4133
4163
  this.activitySince = "";
4134
4164
  /** The backend answered that it keeps no feed, so the panel stops asking. */
4135
4165
  this.activityNone = false;
4166
+ this.syncBusy = false;
4136
4167
  /** Iteration history per thread — generating a change is a stack, not a one-shot. */
4137
4168
  this.iterations = /* @__PURE__ */ new Map();
4138
4169
  /** The thread whose generate pane is open, if any. */
@@ -4153,6 +4184,9 @@ var LoupeApp = class {
4153
4184
  this.floatDrag = null;
4154
4185
  this.floatResize = null;
4155
4186
  this.raf = 0;
4187
+ this.onSyncVisible = () => {
4188
+ if (document.visibilityState === "visible") void this.syncTick();
4189
+ };
4156
4190
  /** Tab id → its hint slot (custom tabs only; the built-ins have named fields). */
4157
4191
  this.customHintSlots = /* @__PURE__ */ new Map();
4158
4192
  /** The tab strip, in order. */
@@ -4517,6 +4551,7 @@ var LoupeApp = class {
4517
4551
  void this.loadOrg();
4518
4552
  void this.loadActivity();
4519
4553
  this.syncActivityPoll();
4554
+ this.startSyncPoll();
4520
4555
  void this.loadAllComments();
4521
4556
  this.observe();
4522
4557
  this.watchNavigation();
@@ -4558,6 +4593,61 @@ var LoupeApp = class {
4558
4593
  } catch {
4559
4594
  }
4560
4595
  }
4596
+ /**
4597
+ * Keep the panel current without a reload.
4598
+ *
4599
+ * A status moved in another app (CRM approving a ticket), a reply relayed through Hub,
4600
+ * or a teammate's new pin otherwise appears only after the page reloads. The bridge's
4601
+ * SSE covers threads when one is configured; this covers everything else, for every
4602
+ * host. It pauses while the tab is hidden and catches up the moment it is shown again.
4603
+ */
4604
+ startSyncPoll() {
4605
+ if (this.syncPoll) return;
4606
+ this.syncPoll = setInterval(() => void this.syncTick(), SYNC_POLL_MS);
4607
+ document.addEventListener("visibilitychange", this.onSyncVisible);
4608
+ }
4609
+ async syncTick() {
4610
+ if (this.syncBusy || document.visibilityState === "hidden") return;
4611
+ const active = this.shadow?.activeElement;
4612
+ if (active && (active.tagName === "TEXTAREA" || active.tagName === "INPUT")) return;
4613
+ this.syncBusy = true;
4614
+ try {
4615
+ const url = this.url;
4616
+ const threads = [...this.messages.keys()];
4617
+ const [page, all, ...replies] = await Promise.all([
4618
+ this.store.list(this.cfg.projectKey, url).catch(() => null),
4619
+ this.allLoaded || this.scope === "all" ? this.store.listAll(this.cfg.projectKey).catch(() => null) : null,
4620
+ ...threads.map((id) => this.store.listMessages(id).catch(() => null))
4621
+ ]);
4622
+ if (url !== this.url) return;
4623
+ let changed = false;
4624
+ if (Array.isArray(page) && JSON.stringify(page) !== JSON.stringify(this.comments)) {
4625
+ this.comments = page;
4626
+ changed = true;
4627
+ }
4628
+ if (Array.isArray(all) && JSON.stringify(all) !== JSON.stringify(this.allComments)) {
4629
+ this.allComments = all;
4630
+ changed = true;
4631
+ }
4632
+ threads.forEach((id, i) => {
4633
+ const list = replies[i];
4634
+ if (!Array.isArray(list)) return;
4635
+ const local = (this.messages.get(id) ?? []).filter((m) => this.msgPending.has(m.id) || this.msgFailed.has(m.id));
4636
+ const next = [...list, ...local];
4637
+ if (JSON.stringify(next) === JSON.stringify(this.messages.get(id))) return;
4638
+ this.messages.set(id, next);
4639
+ changed = true;
4640
+ });
4641
+ if (changed) {
4642
+ this.renderPins();
4643
+ this.renderList();
4644
+ this.renderHome();
4645
+ }
4646
+ } finally {
4647
+ this.syncBusy = false;
4648
+ }
4649
+ void this.loadNotifications();
4650
+ }
4561
4651
  // ---- DOM construction -----------------------------------------------------
4562
4652
  buildDom() {
4563
4653
  this.root = document.createElement("div");
@@ -4733,6 +4823,27 @@ var LoupeApp = class {
4733
4823
  this.renderList();
4734
4824
  };
4735
4825
  listHead.appendChild(search);
4826
+ const filters = el("div", "listfilters");
4827
+ this.statusSel = el("select", "lf-status");
4828
+ this.statusSel.setAttribute("aria-label", "Filter by status");
4829
+ this.statusSel.append(
4830
+ optionEl("All statuses", "", !this.statusFilter),
4831
+ ...Object.keys(STAGE_LABELS).map((k) => optionEl(STAGE_LABELS[k], k, this.statusFilter === k))
4832
+ );
4833
+ this.statusSel.onchange = () => {
4834
+ this.statusFilter = this.statusSel.value;
4835
+ this.saveState();
4836
+ this.renderList();
4837
+ };
4838
+ this.sortSel = el("select", "lf-sort");
4839
+ this.sortSel.setAttribute("aria-label", "Order");
4840
+ this.sortSel.append(optionEl("Newest first", "newest", false), optionEl("Oldest first", "oldest", false), optionEl("Page order", "page", false));
4841
+ this.sortSel.onchange = () => {
4842
+ this.sortOrder = this.sortSel.value;
4843
+ this.saveState();
4844
+ this.renderList();
4845
+ };
4846
+ filters.append(this.statusSel, this.sortSel);
4736
4847
  this.listEl = el("div", "list");
4737
4848
  const homeView = el("div", "view home-view");
4738
4849
  this.homeEl = homeView;
@@ -4740,7 +4851,7 @@ var LoupeApp = class {
4740
4851
  this.commentsHint = el("div", "hint-slot");
4741
4852
  this.reviewBar = el("div", "reviewbar");
4742
4853
  this.reviewBar.style.display = "none";
4743
- commentsView.append(this.commentsHint, tools, listHead, this.reviewBar, this.listEl, this.buildIntegrations());
4854
+ commentsView.append(this.commentsHint, tools, listHead, filters, this.reviewBar, this.listEl, this.buildIntegrations());
4744
4855
  const activityView = el("div", "view activity-view");
4745
4856
  this.activityEl = activityView;
4746
4857
  const chatView = el("div", "view chat-view");
@@ -5402,17 +5513,49 @@ var LoupeApp = class {
5402
5513
  feed.innerHTML = recent.length ? recent.map((c) => {
5403
5514
  const stage = STAGE_LABELS[normalizeStatus(c.status)];
5404
5515
  const prio = normalizePriority(c.priority);
5405
- 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>`;
5516
+ 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>`;
5406
5517
  }).join("") : `<div class="hempty">Nothing here yet.</div>`;
5407
5518
  feed.querySelectorAll(".hfeed-i").forEach((b) => {
5408
- b.onclick = () => {
5409
- const id = b.dataset.id;
5519
+ const id = b.dataset.id;
5520
+ const open = () => {
5410
5521
  this.statFilter = "";
5522
+ this.statusFilter = "";
5411
5523
  this.setTab("comments");
5412
5524
  this.expanded.add(id);
5525
+ const c = recent.find((x) => x.id === id);
5526
+ if (c) void this.loadMessages(c);
5413
5527
  this.renderList();
5414
5528
  this.flash(id);
5415
5529
  };
5530
+ b.onclick = open;
5531
+ b.onkeydown = (e) => {
5532
+ if (e.key === "Enter" || e.key === " ") {
5533
+ e.preventDefault();
5534
+ open();
5535
+ }
5536
+ };
5537
+ b.querySelectorAll(".hfeed-act").forEach((btn) => {
5538
+ btn.onclick = (e) => {
5539
+ e.stopPropagation();
5540
+ const c = recent.find((x) => x.id === id);
5541
+ if (!c) return;
5542
+ if (btn.dataset.act === "open") open();
5543
+ else if (btn.dataset.act === "resolve") void this.toggleResolved(c);
5544
+ else if (btn.dataset.act === "delete") {
5545
+ if (btn.dataset.armed) void this.deleteComment(c);
5546
+ else {
5547
+ btn.dataset.armed = "1";
5548
+ btn.textContent = "Confirm delete";
5549
+ setTimeout(() => {
5550
+ if (btn.isConnected) {
5551
+ delete btn.dataset.armed;
5552
+ btn.textContent = "Delete";
5553
+ }
5554
+ }, 3e3);
5555
+ }
5556
+ }
5557
+ };
5558
+ });
5416
5559
  });
5417
5560
  }
5418
5561
  async loadNotifications() {
@@ -6521,6 +6664,8 @@ var LoupeApp = class {
6521
6664
  if (typeof p?.tab === "string" && known.includes(p.tab)) this.tab = p.tab;
6522
6665
  if (p?.scope === "page" || p?.scope === "all") this.scope = p.scope;
6523
6666
  if (typeof p?.statFilter === "string") this.statFilter = p.statFilter;
6667
+ if (typeof p?.statusFilter === "string" && p.statusFilter in STAGE_LABELS) this.statusFilter = p.statusFilter;
6668
+ if (["newest", "oldest", "page"].includes(p?.sortOrder)) this.sortOrder = p.sortOrder;
6524
6669
  if (p?.float && typeof p.float.w === "number") this.floatRect = { ...this.floatRect, ...p.float };
6525
6670
  if (typeof p?.markersHidden === "boolean") this.markersHidden = p.markersHidden;
6526
6671
  if (typeof p?.launcherHidden === "boolean") this.launcherHidden = p.launcherHidden;
@@ -6547,6 +6692,8 @@ var LoupeApp = class {
6547
6692
  fab: this.fabPos,
6548
6693
  scope: this.scope,
6549
6694
  statFilter: this.statFilter,
6695
+ statusFilter: this.statusFilter,
6696
+ sortOrder: this.sortOrder,
6550
6697
  accent: this.accent,
6551
6698
  minimized: this.minimized,
6552
6699
  hoverHints: this.hoverHints,
@@ -6629,25 +6776,43 @@ var LoupeApp = class {
6629
6776
  if (this.statFilter === "needs_you" && !needsYou({ status: c.status }).needs) return false;
6630
6777
  if (this.statFilter === "resolved" && stage !== "resolved") return false;
6631
6778
  if (this.statFilter === "stale" && (stage === "resolved" || !(Date.parse(c.createdAt) < weekAgo))) return false;
6779
+ if (this.statusFilter && stage !== this.statusFilter) return false;
6632
6780
  return !q || `${c.title ?? ""} ${c.body} ${c.author?.name ?? ""}`.toLowerCase().includes(q);
6633
6781
  });
6634
- if (this.scope === "all") items = [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
6782
+ const order = this.sortOrder || (this.scope === "all" ? "newest" : "page");
6783
+ if (this.sortSel) this.sortSel.value = order;
6784
+ if (this.statusSel) this.statusSel.value = this.statusFilter;
6785
+ if (order === "newest") items = [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
6786
+ if (order === "oldest") items = [...items].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
6787
+ const grouped = order !== "page";
6788
+ const perDay = /* @__PURE__ */ new Map();
6789
+ if (grouped) for (const c of items) perDay.set(dayLabel(c.createdAt), (perDay.get(dayLabel(c.createdAt)) ?? 0) + 1);
6635
6790
  this.renderReviewBar();
6636
6791
  if (!items.length) {
6637
6792
  this.listEl.appendChild(el(
6638
6793
  "div",
6639
6794
  "empty",
6640
- 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."
6795
+ 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."
6641
6796
  ));
6642
6797
  }
6643
6798
  let lastDay = "";
6644
6799
  items.forEach((c, i) => {
6645
- if (this.scope === "all") {
6800
+ if (grouped) {
6646
6801
  const day = dayLabel(c.createdAt);
6802
+ const shut = this.collapsedDays.has(day);
6647
6803
  if (day !== lastDay) {
6648
- this.listEl.appendChild(el("div", "daylabel", day));
6804
+ const head = el("button", "daylabel" + (shut ? " shut" : ""), day);
6805
+ head.dataset.n = String(perDay.get(day) ?? 0);
6806
+ head.setAttribute("aria-expanded", String(!shut));
6807
+ head.onclick = () => {
6808
+ if (shut) this.collapsedDays.delete(day);
6809
+ else this.collapsedDays.add(day);
6810
+ this.renderList();
6811
+ };
6812
+ this.listEl.appendChild(head);
6649
6813
  lastDay = day;
6650
6814
  }
6815
+ if (shut) return;
6651
6816
  }
6652
6817
  this.listEl.appendChild(this.itemView(c, i));
6653
6818
  });
@@ -6794,31 +6959,14 @@ var LoupeApp = class {
6794
6959
  }
6795
6960
  const actions = el("div", "actions");
6796
6961
  const doneBtn = el("button", "", isResolved(c) ? "Reopen" : "Resolve");
6797
- doneBtn.onclick = async (e) => {
6962
+ doneBtn.onclick = (e) => {
6798
6963
  e.stopPropagation();
6799
- const status = isResolved(c) ? "queue" : "resolved";
6800
- c.status = status;
6801
- await this.store.update(c.id, { status });
6802
- this.renderPins();
6803
- this.renderList();
6804
- this.addActivity({
6805
- kind: status === "resolved" ? "comment.resolve" : "comment.reopen",
6806
- label: `${status === "resolved" ? "Resolved" : "Reopened"} \u201C${c.title || c.body.split("\n")[0] || "comment"}\u201D`
6807
- });
6964
+ void this.toggleResolved(c);
6808
6965
  };
6809
6966
  const del = el("button", "", "Delete");
6810
- del.onclick = async (e) => {
6967
+ del.onclick = (e) => {
6811
6968
  e.stopPropagation();
6812
- await this.store.remove(c.id);
6813
- this.comments = this.comments.filter((x) => x.id !== c.id);
6814
- this.resolved.delete(c.id);
6815
- this.renderPins();
6816
- this.renderList();
6817
- this.addActivity({
6818
- kind: "comment.delete",
6819
- label: `Deleted \u201C${c.title || c.body.split("\n")[0] || "comment"}\u201D`,
6820
- level: "warn"
6821
- });
6969
+ void this.deleteComment(c);
6822
6970
  };
6823
6971
  actions.append(doneBtn, del);
6824
6972
  detail.appendChild(actions);
@@ -6835,6 +6983,37 @@ var LoupeApp = class {
6835
6983
  };
6836
6984
  return item;
6837
6985
  }
6986
+ /**
6987
+ * Resolve or reopen a comment. Shared by the card and the Home feed. The page list
6988
+ * and the project list are separate reads, so the change is applied to both.
6989
+ */
6990
+ async toggleResolved(c) {
6991
+ const status = isResolved(c) ? "queue" : "resolved";
6992
+ await this.store.update(c.id, { status });
6993
+ for (const x of [c, ...this.comments, ...this.allComments]) if (x.id === c.id) x.status = status;
6994
+ this.renderPins();
6995
+ this.renderList();
6996
+ this.renderHome();
6997
+ this.addActivity({
6998
+ kind: status === "resolved" ? "comment.resolve" : "comment.reopen",
6999
+ label: `${status === "resolved" ? "Resolved" : "Reopened"} \u201C${c.title || c.body.split("\n")[0] || "comment"}\u201D`
7000
+ });
7001
+ }
7002
+ /** Delete a comment, from the card or the Home feed. */
7003
+ async deleteComment(c) {
7004
+ await this.store.remove(c.id);
7005
+ this.comments = this.comments.filter((x) => x.id !== c.id);
7006
+ this.allComments = this.allComments.filter((x) => x.id !== c.id);
7007
+ this.resolved.delete(c.id);
7008
+ this.renderPins();
7009
+ this.renderList();
7010
+ this.renderHome();
7011
+ this.addActivity({
7012
+ kind: "comment.delete",
7013
+ label: `Deleted \u201C${c.title || c.body.split("\n")[0] || "comment"}\u201D`,
7014
+ level: "warn"
7015
+ });
7016
+ }
6838
7017
  /**
6839
7018
  * The review banner. This is the one place the panel asks a human to decide
6840
7019
  * something, so it sits above everything else in the detail.
@@ -7874,6 +8053,9 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
7874
8053
  if (this.companionPoll) clearInterval(this.companionPoll);
7875
8054
  if (this.activityPoll) clearInterval(this.activityPoll);
7876
8055
  this.activityPoll = void 0;
8056
+ if (this.syncPoll) clearInterval(this.syncPoll);
8057
+ this.syncPoll = void 0;
8058
+ document.removeEventListener("visibilitychange", this.onSyncVisible);
7877
8059
  this.stopVoice();
7878
8060
  this.stopPresence();
7879
8061
  this.stopRecording?.();