@loupekit/sdk 0.10.14-next.59 → 0.10.15-next.60

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/README.md CHANGED
@@ -153,6 +153,29 @@ trackActivity({ kind: "Bash", label: "pnpm test", detail: "exit 1", level: "erro
153
153
  Loupe's own operations feed the same stream, so the view is never empty. With nothing connected it
154
154
  says *Monitor unavailable* and explains how to wire it up.
155
155
 
156
+ ### Reviewing a change
157
+
158
+ A thread can carry the pull request its fix is riding on, which the panel turns into a lifecycle chip,
159
+ a PR link and a checks meter:
160
+
161
+ ```ts
162
+ // Via the API (or your own tooling) — the panel picks it up on the next load.
163
+ await fetch(`${apiBase}/v1/comments/${id}`, {
164
+ method: "PATCH",
165
+ headers: { "Content-Type": "application/json", "X-Loupe-Admin": secret },
166
+ body: JSON.stringify({
167
+ pr: { number: 412, url: "https://github.com/acme/web/pull/412", checksPassed: 3, checksTotal: 4 },
168
+ }),
169
+ });
170
+ ```
171
+
172
+ Threads in **In Review** lead their detail with a review banner — **Approve** and **Add comment** —
173
+ and, when a change has been proposed, a *Show original* toggle that puts your original request beside
174
+ the proposal. Approving resolves the thread and is logged in the Activity feed.
175
+
176
+ The rule that shape enforces: **only a human resolves a thread.** An agent moves work to In Review;
177
+ a person closes it from there.
178
+
156
179
  ### Offline mode (no backend)
157
180
 
158
181
  Omit `apiBase` and comments persist to `localStorage` — great for demos and local dev:
@@ -238,7 +238,12 @@ var Loupe = (() => {
238
238
 
239
239
  .item { border: 1px solid var(--line); border-radius: 10px; padding: 10px; cursor: pointer; background: var(--bg-2); }
240
240
  .item:hover { border-color: var(--accent); }
241
- .item .top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
241
+ /* The header row now carries the number, a detached badge, a lifecycle chip, a PR
242
+ chip, a checks meter and the caret \u2014 more than fits on one line in a narrow
243
+ panel, so it wraps rather than clipping the last chip. The caret keeps its place
244
+ at the end of the row. */
245
+ .item .top { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; margin-bottom: 6px; }
246
+ .item .top .caret { margin-left: auto; }
242
247
  .item .num { background: var(--pin); color: #fff; width: 20px; height: 20px; border-radius: 50%; font-size: 11px; font-weight: 700; display: grid; place-items: center; flex: none; }
243
248
  .item .num.detached { background: #9aa0af; }
244
249
  .item .num.done { background: #10935a; }
@@ -612,6 +617,74 @@ var Loupe = (() => {
612
617
  .menu-ver b { color: var(--ink); font-family: ui-monospace, Menlo, monospace; font-weight: 600; }
613
618
  .menu-mode { text-transform: uppercase; letter-spacing: .06em; font-size: 9.5px; }
614
619
 
620
+ /* ------------------------------------------------ lifecycle chips + review flow */
621
+ .lifechip {
622
+ padding: 1px 6px; border-radius: 999px; white-space: nowrap;
623
+ border: 1px solid var(--line); background: var(--bg); color: var(--muted);
624
+ font-size: 9.5px; font-weight: 700; letter-spacing: .02em; text-transform: uppercase;
625
+ }
626
+ .lifechip.st-sent { border-color: var(--accent); color: var(--accent); }
627
+ .lifechip.st-in_pr { border-color: #3f8ae0; color: #3f8ae0; }
628
+ .lifechip.st-preview { border-color: var(--accent); background: var(--accent); color: #fff; }
629
+ .lifechip.st-reviewed { border-color: #2f9e6a; color: #2f9e6a; }
630
+ .prchip {
631
+ padding: 1px 6px; border-radius: 6px; text-decoration: none; cursor: pointer;
632
+ border: 1px solid #3f8ae0; background: var(--bg); color: #3f8ae0;
633
+ font-family: ui-monospace, Menlo, monospace; font-size: 10px; font-weight: 700;
634
+ }
635
+ .prchip:hover { background: #3f8ae0; color: #fff; }
636
+ .prchip.st-merged { border-color: #8250df; color: #8250df; }
637
+ .prchip.st-merged:hover { background: #8250df; color: #fff; }
638
+ .prchip.st-closed { border-color: var(--line); color: var(--muted); }
639
+ /* checks meter \u2014 a numerator over a thin bar */
640
+ .checks { display: inline-flex; align-items: center; gap: 4px; }
641
+ .checks-n { font-family: ui-monospace, Menlo, monospace; font-size: 9.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
642
+ .checks-bar { display: block; width: 26px; height: 3px; border-radius: 2px; background: var(--line); overflow: hidden; }
643
+ .checks-bar i { display: block; height: 100%; background: #2f9e6a; }
644
+
645
+ /* the "N waiting on your review" strip above the list */
646
+ .reviewbar {
647
+ display: flex; align-items: center; gap: 7px; margin: 8px 12px 0; padding: 7px 10px;
648
+ border: 1px solid var(--accent); border-radius: 9px; background: var(--bg-2);
649
+ }
650
+ .rb-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; animation: loupe-pulse 1.6s ease-in-out infinite; }
651
+ @media (prefers-reduced-motion: reduce) { .rb-dot { animation: none; } }
652
+ .rb-t { flex: 1; font-size: 11.5px; color: var(--ink); }
653
+ .rb-t b { font-variant-numeric: tabular-nums; }
654
+ .rb-b {
655
+ padding: 3px 9px; border: 1px solid var(--accent); border-radius: 7px;
656
+ background: var(--accent); color: #fff; font-size: 11px; font-weight: 600; cursor: pointer;
657
+ }
658
+
659
+ /* the review banner inside a thread */
660
+ .revbanner {
661
+ display: flex; align-items: center; gap: 6px; margin: 8px 0; padding: 7px 8px;
662
+ border: 1px solid var(--accent); border-radius: 9px; background: var(--bg-3);
663
+ }
664
+ .rev-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; }
665
+ .rev-t { flex: 1; font-size: 11.5px; font-weight: 600; color: var(--ink); }
666
+ .rev-approve {
667
+ padding: 4px 10px; border: 1px solid var(--accent); border-radius: 7px;
668
+ background: var(--accent); color: #fff; font-size: 11px; font-weight: 600; cursor: pointer;
669
+ }
670
+ .rev-approve:disabled { opacity: .6; cursor: default; }
671
+ .rev-comment, .rev-origin {
672
+ padding: 4px 8px; border: 1px solid var(--line); border-radius: 7px;
673
+ background: var(--bg); color: var(--ink); font-size: 11px; cursor: pointer;
674
+ }
675
+ .rev-comment:hover, .rev-origin:hover { border-color: var(--accent); }
676
+
677
+ /* original request beside the proposed change */
678
+ .origin { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; margin: 8px 0; }
679
+ .or-col { min-width: 0; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); }
680
+ .or-h { margin-bottom: 4px; font-size: 9.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
681
+ .or-b { font-size: 11px; line-height: 1.45; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
682
+ .or-code {
683
+ margin: 6px 0 0; padding: 6px; max-height: 130px; overflow: auto; border-radius: 6px;
684
+ background: var(--bg-2); color: var(--muted); font-family: ui-monospace, Menlo, monospace;
685
+ font-size: 10px; white-space: pre-wrap; overflow-wrap: anywhere;
686
+ }
687
+
615
688
  /* ------------------------------------------------------- hint card (once per view) */
616
689
  .hint {
617
690
  position: relative; margin: 8px 12px 0; padding: 10px 28px 10px 11px;
@@ -2876,6 +2949,44 @@ var Loupe = (() => {
2876
2949
  };
2877
2950
  }
2878
2951
 
2952
+ // ../shared/dist/lifecycle.js
2953
+ var LIFECYCLE_LABELS = {
2954
+ sent: "Sent to agent",
2955
+ in_pr: "In PR",
2956
+ preview: "Review preview",
2957
+ reviewed: "Reviewed"
2958
+ };
2959
+ function lifecycle(c) {
2960
+ const hasProposal = !!c.proposal;
2961
+ const pr = c.pr ?? void 0;
2962
+ const withPr = (stage) => {
2963
+ const out = { stage, label: LIFECYCLE_LABELS[stage] };
2964
+ if (pr) {
2965
+ out.pr = pr;
2966
+ if (typeof pr.checksTotal === "number" && pr.checksTotal > 0) {
2967
+ const passed = Math.max(0, Math.min(pr.checksTotal, pr.checksPassed ?? 0));
2968
+ out.checks = {
2969
+ text: `${passed}/${pr.checksTotal}`,
2970
+ ratio: passed / pr.checksTotal
2971
+ };
2972
+ }
2973
+ }
2974
+ return out;
2975
+ };
2976
+ if (c.status === "resolved")
2977
+ return hasProposal || pr ? withPr("reviewed") : null;
2978
+ if (pr)
2979
+ return withPr("in_pr");
2980
+ if (c.status === "in_review")
2981
+ return { stage: "preview", label: LIFECYCLE_LABELS.preview };
2982
+ if (hasProposal)
2983
+ return { stage: "sent", label: LIFECYCLE_LABELS.sent };
2984
+ return null;
2985
+ }
2986
+ function awaitingReview(comments) {
2987
+ return comments.filter((c) => c.status === "in_review");
2988
+ }
2989
+
2879
2990
  // ../shared/dist/index.js
2880
2991
  var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
2881
2992
  var STAGE_LABELS = {
@@ -2923,7 +3034,7 @@ var Loupe = (() => {
2923
3034
  }
2924
3035
 
2925
3036
  // src/app.ts
2926
- var SDK_VERSION = true ? "0.10.14" : "dev";
3037
+ var SDK_VERSION = true ? "0.10.15" : "dev";
2927
3038
  var ACCENTS = [
2928
3039
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
2929
3040
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -3535,7 +3646,9 @@ var Loupe = (() => {
3535
3646
  this.homeEl = homeView;
3536
3647
  const commentsView = el("div", "view comments-view");
3537
3648
  this.commentsHint = el("div", "hint-slot");
3538
- commentsView.append(this.commentsHint, tools, listHead, this.listEl, this.buildIntegrations());
3649
+ this.reviewBar = el("div", "reviewbar");
3650
+ this.reviewBar.style.display = "none";
3651
+ commentsView.append(this.commentsHint, tools, listHead, this.reviewBar, this.listEl, this.buildIntegrations());
3539
3652
  const activityView = el("div", "view activity-view");
3540
3653
  this.activityEl = activityView;
3541
3654
  const customViews = (this.cfg.tabs ?? []).map((t) => {
@@ -4889,6 +5002,7 @@ var Loupe = (() => {
4889
5002
  });
4890
5003
  if (this.scope === "all") items = [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
4891
5004
  this.renderRepoFilter();
5005
+ this.renderReviewBar();
4892
5006
  if (!items.length) {
4893
5007
  this.listEl.appendChild(el(
4894
5008
  "div",
@@ -4933,7 +5047,32 @@ var Loupe = (() => {
4933
5047
  top.appendChild(el("span", "device", `${icon} ${kind}`));
4934
5048
  }
4935
5049
  if (isResolved(c)) top.appendChild(el("span", "badge done", "resolved"));
4936
- else if (detached) top.appendChild(el("span", "badge detached", "element moved/removed"));
5050
+ else if (detached) {
5051
+ const b = el("span", "badge detached", "moved");
5052
+ b.title = "element moved or removed";
5053
+ top.appendChild(b);
5054
+ }
5055
+ const lc = lifecycle(c);
5056
+ if (lc) {
5057
+ if (lc.stage !== "reviewed") top.appendChild(el("span", `lifechip st-${lc.stage}`, lc.label));
5058
+ if (lc.pr) {
5059
+ const pr = el(lc.pr.url ? "a" : "span", `prchip${lc.pr.state ? ` st-${lc.pr.state}` : ""}`, `#${lc.pr.number}`);
5060
+ pr.title = `Pull request #${lc.pr.number}${lc.pr.state ? ` \u2014 ${lc.pr.state}` : ""}`;
5061
+ if (lc.pr.url) {
5062
+ pr.href = lc.pr.url;
5063
+ pr.setAttribute("target", "_blank");
5064
+ pr.setAttribute("rel", "noreferrer");
5065
+ pr.addEventListener("click", (e) => e.stopPropagation());
5066
+ }
5067
+ top.appendChild(pr);
5068
+ }
5069
+ if (lc.checks) {
5070
+ const meter = el("span", "checks");
5071
+ meter.title = `Checks \u2014 ${lc.checks.text} passed`;
5072
+ meter.innerHTML = `<span class="checks-n">${escapeHtml(lc.checks.text)}</span><span class="checks-bar"><i style="width:${Math.round(lc.checks.ratio * 100)}%"></i></span>`;
5073
+ top.appendChild(meter);
5074
+ }
5075
+ }
4937
5076
  if (this.showPaths && this.scope === "all" && c.url) {
4938
5077
  top.appendChild(el("span", "pathtag", shortPath(c.url)));
4939
5078
  }
@@ -4942,6 +5081,8 @@ var Loupe = (() => {
4942
5081
  const summary = c.title || (c.body.split("\n")[0] ?? "").slice(0, 140) || "(no description)";
4943
5082
  item.appendChild(el("div", "summary", summary));
4944
5083
  const detail = el("div", "detail");
5084
+ if (c.status === "in_review") detail.appendChild(this.reviewBanner(c));
5085
+ if (c.proposal) detail.appendChild(this.proposalView(c));
4945
5086
  if (c.title || c.body.includes("\n")) detail.appendChild(el("div", "body", c.body));
4946
5087
  detail.appendChild(el("div", "meta", describeAnchor(c)));
4947
5088
  if (c.recording) {
@@ -5008,6 +5149,87 @@ var Loupe = (() => {
5008
5149
  };
5009
5150
  return item;
5010
5151
  }
5152
+ /**
5153
+ * The review banner. This is the one place the panel asks a human to decide
5154
+ * something, so it sits above everything else in the detail.
5155
+ *
5156
+ * Approving resolves the thread. That asymmetry is the rule the whole flow rests
5157
+ * on: an agent moves work to In Review, only a person closes it.
5158
+ */
5159
+ reviewBanner(c) {
5160
+ const label = c.title || c.body.split("\n")[0] || "this thread";
5161
+ const banner = el("div", "revbanner");
5162
+ banner.innerHTML = `<span class="rev-dot"></span><span class="rev-t">Waiting on your review</span><span class="rev-spacer"></span>`;
5163
+ const approve = el("button", "rev-approve", "Approve");
5164
+ approve.onclick = async (e) => {
5165
+ e.stopPropagation();
5166
+ approve.disabled = true;
5167
+ approve.textContent = "Approving\u2026";
5168
+ await this.store.update(c.id, { status: "resolved" });
5169
+ c.status = "resolved";
5170
+ this.renderPins();
5171
+ this.renderList();
5172
+ this.renderHome();
5173
+ this.addActivity({ kind: "review.approve", label: `Approved \u201C${label}\u201D` });
5174
+ };
5175
+ const discuss = el("button", "rev-comment", "Add comment");
5176
+ discuss.onclick = (e) => {
5177
+ e.stopPropagation();
5178
+ const target = this.resolved.get(c.id);
5179
+ if (target && target.isConnected) {
5180
+ const r = target.getBoundingClientRect();
5181
+ this.openComposer({ kind: "element", element: target }, r.left + r.width / 2, r.top + r.height);
5182
+ } else {
5183
+ this.setMode("free");
5184
+ }
5185
+ };
5186
+ banner.append(approve, discuss);
5187
+ if (c.proposal) {
5188
+ const origin = el("button", "rev-origin", "Show original");
5189
+ origin.onclick = (e) => {
5190
+ e.stopPropagation();
5191
+ const view = banner.parentElement?.querySelector(".origin");
5192
+ if (!view) return;
5193
+ const shown = view.classList.toggle("show");
5194
+ view.style.display = shown ? "" : "none";
5195
+ origin.textContent = shown ? "Hide original" : "Show original";
5196
+ };
5197
+ banner.appendChild(origin);
5198
+ }
5199
+ return banner;
5200
+ }
5201
+ /**
5202
+ * The original request beside Claude's proposed change. Hidden until the review
5203
+ * banner's toggle asks for it — a reviewer who does not care should not pay for
5204
+ * the markup.
5205
+ */
5206
+ proposalView(c) {
5207
+ const p = c.proposal;
5208
+ const wrap = el("div", "origin");
5209
+ wrap.style.display = "none";
5210
+ wrap.innerHTML = `<div class="or-col"><div class="or-h">Original request</div><div class="or-b">${escapeHtml(c.title ? `${c.title}
5211
+
5212
+ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escapeHtml(c.context.html)}</pre>` : "") + `</div><div class="or-col"><div class="or-h">Proposed change${p.author ? ` \xB7 ${escapeHtml(p.author)}` : ""}</div>` + (p.notes ? `<div class="or-b">${escapeHtml(p.notes)}</div>` : "") + (p.html ? `<pre class="or-code">${escapeHtml(p.html)}</pre>` : "") + (p.css ? `<pre class="or-code">${escapeHtml(p.css)}</pre>` : "") + `</div>`;
5213
+ return wrap;
5214
+ }
5215
+ /** The strips above the list: how many threads are waiting on a human. */
5216
+ renderReviewBar() {
5217
+ if (!this.reviewBar) return;
5218
+ const waiting = awaitingReview(this.visibleComments);
5219
+ if (!waiting.length) {
5220
+ this.reviewBar.innerHTML = "";
5221
+ this.reviewBar.style.display = "none";
5222
+ return;
5223
+ }
5224
+ const on = this.statFilter === "needs_you";
5225
+ this.reviewBar.style.display = "";
5226
+ this.reviewBar.innerHTML = `<span class="rb-dot"></span><span class="rb-t"><b>${waiting.length}</b> waiting on your review</span><button class="rb-b" data-role="rb-toggle">${on ? "Show all" : "Review"}</button>`;
5227
+ this.reviewBar.querySelector('[data-role="rb-toggle"]').onclick = () => {
5228
+ this.statFilter = on ? "" : "needs_you";
5229
+ this.renderList();
5230
+ this.renderHome();
5231
+ };
5232
+ }
5011
5233
  flash(id) {
5012
5234
  const c = this.comments.find((x) => x.id === id);
5013
5235
  const pin = this.pins.get(id);