@nanocollective/roster 0.1.0-alpha.35 → 0.1.0-alpha.37

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/cli.js CHANGED
@@ -5816,6 +5816,32 @@ function short4(e) {
5816
5816
  return line.length > 200 ? line.slice(0, 199) + "\u2026" : line;
5817
5817
  }
5818
5818
 
5819
+ // src/lib/notifications.ts
5820
+ async function unreadFor(owner, pages = 5) {
5821
+ const out = /* @__PURE__ */ new Map();
5822
+ for (let page2 = 1; page2 <= pages; page2++) {
5823
+ const res = await ghJson(["api", `notifications?per_page=100&page=${page2}`]);
5824
+ if (!res.ok || !res.data?.length) break;
5825
+ for (const n of res.data) {
5826
+ if (!n.unread || n.repository?.owner?.login?.toLowerCase() !== owner.toLowerCase()) continue;
5827
+ const number = /\/(?:issues|pulls)\/(\d+)$/.exec(n.subject?.url ?? "")?.[1];
5828
+ if (!number || !n.repository?.full_name) continue;
5829
+ out.set(`${n.repository.full_name}#${number}`, {
5830
+ thread: n.id,
5831
+ reason: n.reason,
5832
+ updatedAt: n.updated_at
5833
+ });
5834
+ }
5835
+ if (res.data.length < 100) break;
5836
+ }
5837
+ return out;
5838
+ }
5839
+ async function markRead(thread) {
5840
+ if (!/^\d+$/.test(thread)) return false;
5841
+ const res = await api(`notifications/threads/${thread}`, ["-X", "PATCH"]);
5842
+ return res.ok;
5843
+ }
5844
+
5819
5845
  // src/lib/paste.ts
5820
5846
  var BEGIN = (path) => `<<<ROSTER FILE ${path}>>>`;
5821
5847
  var END = "<<<ROSTER END>>>";
@@ -7349,6 +7375,24 @@ async function portalCommand(argv) {
7349
7375
  }).catch((err) => json(res, { labels: [], error: String(err?.message ?? err) }));
7350
7376
  return;
7351
7377
  }
7378
+ if (url.pathname === "/api/notifications/read" && req.method === "POST") {
7379
+ if (!writeAllowed(req)) {
7380
+ refuseWrite(res);
7381
+ return;
7382
+ }
7383
+ body(req).then(async (raw) => {
7384
+ const payload = JSON.parse(raw || "{}");
7385
+ const ok = await markRead(String(payload.thread ?? ""));
7386
+ if (ok && cache && payload.ref) {
7387
+ const data = JSON.parse(cache.body);
7388
+ for (const i of data.items ?? [])
7389
+ if (`${i.repo}#${i.number}` === payload.ref) i.unread = null;
7390
+ cache = { at: cache.at, body: JSON.stringify(data) };
7391
+ }
7392
+ json(res, { ok });
7393
+ }).catch((err) => json(res, { ok: false, error: String(err?.message ?? err) }));
7394
+ return;
7395
+ }
7352
7396
  if (url.pathname === "/api/inbox") {
7353
7397
  const fresh = url.searchParams.get("refresh") === "1";
7354
7398
  if (!fresh && cache && Date.now() - cache.at < TTL) {
@@ -7359,9 +7403,15 @@ async function portalCommand(argv) {
7359
7403
  res.end(cache.body);
7360
7404
  return;
7361
7405
  }
7362
- fetchInbox(knownRepos()).then((data) => {
7406
+ const owner = readOrg(w.opsDir, parseYaml).org;
7407
+ Promise.all([fetchInbox(knownRepos()), unreadFor(owner).catch(() => /* @__PURE__ */ new Map())]).then(([data, unread]) => {
7363
7408
  const body2 = JSON.stringify({
7364
7409
  ...data,
7410
+ // Marked from GitHub's notifications, so unread here is unread there.
7411
+ items: data.items.map((i) => ({
7412
+ ...i,
7413
+ unread: unread.get(`${i.repo}#${i.number}`) ?? null
7414
+ })),
7365
7415
  repos: knownRepos(),
7366
7416
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
7367
7417
  });
package/docs/portal.md CHANGED
@@ -83,13 +83,13 @@ goes away; the repo picker lives on under [Org](#org).
83
83
  Everything open across the org, from one GraphQL call per repo. Bodies and full timelines come
84
84
  down with the list, so opening a thread is a render rather than a request.
85
85
 
86
- - **Filter by staff member.** An item belongs to somebody if it is in their brain repo, their
87
- own App wrote it, a peer addressed it to them with a `from-<handle>` label, or it is assigned
88
- to them. The shared public identity cannot name one staff member, so an item it wrote counts
89
- for anyone who works in that repo. Items authored by humans belong to nobody, which is
90
- correct.
91
- - **Scope** to everything, what is assigned to you, decisions, or open PRs.
92
- - **Open, recently closed, or both.** Open by default: an inbox is what is waiting on
86
+ - **The links under Inbox in the sidebar filter it.** All; Unread; each staff member, which
87
+ lists the issues on their own tracker whoever filed them; and Issues, the product repos.
88
+ - **Unread comes from your GitHub notifications.** A thread with activity you have not read has
89
+ a bar on the left and a bold title, and Unread lists only those, with a count. Opening one
90
+ marks it read on GitHub too, and reading it on GitHub clears it here. GitHub only notifies you
91
+ about repos you watch and threads you are part of.
92
+ - **Open or closed.** Open by default: an inbox is what is waiting on
93
93
  somebody, and months of finished work mixed into that answers a different question. Closed
94
94
  work reaches back 45 days, up to 30 issues and 30 pull requests per repository, and carries
95
95
  a shorter timeline than open work because it is there to be read rather than triaged. A
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.35",
3
+ "version": "0.1.0-alpha.37",
4
4
  "description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -190,3 +190,7 @@ textarea.dropping{border-color:var(--accent);background:color-mix(in srgb,var(--
190
190
  .tevmore[aria-expanded="true"] .caret{transform:none}
191
191
  .tevquiet{border-left:1px solid var(--line);margin-left:9px;padding-left:14px}
192
192
  .tevquiet .tev{padding:5px 0;font-size:12px}
193
+
194
+ /* Unread, from GitHub's notifications: a bar on the left and a heavier title. Cleared on open. */
195
+ .irow.unread{box-shadow:inset 3px 0 0 var(--blue);background:color-mix(in srgb,var(--blue) 6%,transparent)}
196
+ .irow.unread .ititle{font-weight:700;color:var(--ink)}
@@ -169,3 +169,5 @@ main{padding:36px 44px 96px;max-width:1220px;overflow-x:hidden}
169
169
  .nav.inboxlink{display:flex;align-items:center;gap:8px}
170
170
  .nav.inboxlink .ic,.staffrow .ic{--ic:14px;color:var(--ink-faint);flex:none}
171
171
  .nav.inboxlink[aria-current="true"] .ic{color:var(--accent)}
172
+ /* The unread count matches the unread rows. Empty, it stays grey like every other badge. */
173
+ #unreadcount:not(:empty):not(.wait){background:var(--blue);color:#fff}
@@ -325,6 +325,8 @@ function paintInboxLinks() {
325
325
  sub.replaceChildren();
326
326
  // Inbox itself is everything. Under it: each staff member, then the product repos' issues.
327
327
  const links = [
328
+ ["", "All", "inbox"],
329
+ ["@unread", "Unread", "dot"],
328
330
  ...S.data.staff.map((s) => [s.handle, s.name, staffIcon(s)]),
329
331
  ["@issues", "Issues", "issue-open"],
330
332
  ];
@@ -332,12 +334,12 @@ function paintInboxLinks() {
332
334
  const b = el("button", { className: "nav subnav inboxlink" }, [
333
335
  icon(glyph, "ic"),
334
336
  el("span", { className: "t", textContent: label }),
337
+ ...(who === "@unread" ? [el("span", { className: "n", id: "unreadcount" })] : []),
335
338
  ]);
336
339
  b.dataset.who = who;
337
340
  b.onclick = () => {
338
341
  S.view = "inbox";
339
- // A second click on the one you are on goes back to everything.
340
- S.inboxStaff = S.inboxStaff === who ? "" : who;
342
+ S.inboxStaff = who;
341
343
  S.inboxFilter = "";
342
344
  S.inboxOpen = null;
343
345
  render();
@@ -10,7 +10,7 @@
10
10
  import { getInbox, getOrg, getSync } from "./api.js";
11
11
  import { $, ago, el, esc } from "./dom.js";
12
12
  import { render } from "./router.js";
13
- import { openCount, openPrCount, S } from "./state.js";
13
+ import { openCount, openPrCount, S, unreadCount } from "./state.js";
14
14
 
15
15
  /**
16
16
  * The inbox, fetched at most once at a time.
@@ -55,6 +55,7 @@ export function stampCounts() {
55
55
  for (const [sel, n] of [
56
56
  ["#inboxcount", openCount],
57
57
  ["#prcount", openPrCount],
58
+ ["#unreadcount", unreadCount],
58
59
  ]) {
59
60
  const slot = document.querySelector(sel);
60
61
  if (!slot) continue;
@@ -86,6 +86,10 @@ export const S = {
86
86
  export const openCount = () =>
87
87
  (S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind !== "pr").length;
88
88
 
89
+ /** Issues with activity you have not read, from GitHub's notifications. */
90
+ export const unreadCount = () =>
91
+ (S.inbox?.items ?? []).filter((i) => i.unread && i.state === "OPEN" && i.kind !== "pr").length;
92
+
89
93
  /** The same, for the screen that is only pull requests. */
90
94
  export const openPrCount = () =>
91
95
  (S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind === "pr").length;
@@ -235,6 +235,7 @@ function inboxScreen(m, opts) {
235
235
  // "Issues" is the product repos: every repo a staff member works in.
236
236
  const products = new Set(S.data.staff.flatMap((s) => s.worksIn ?? []));
237
237
  const issuesOnly = staffPick === "@issues";
238
+ const unreadOnly = staffPick === "@unread";
238
239
  const whoseStaff = staffPick && !issuesOnly ? S.data.staff.find((s) => s.handle === staffPick) : null;
239
240
  /* The two screens partition the org rather than overlap on it. Pending work is every pull
240
241
  request; the inbox is everything else. An inbox that also listed the PRs said the same
@@ -242,7 +243,9 @@ function inboxScreen(m, opts) {
242
243
  const mine = S.inbox.items
243
244
  /* A staff member's link is their tracker: what is in their repo, whoever filed it. Matching
244
245
  on who filed or was named put an issue the CMO filed for the CTO under both of them. */
245
- .filter((i) => (issuesOnly ? products.has(i.repo) : !whoseStaff || i.repo === whoseStaff.brain))
246
+ .filter((i) =>
247
+ unreadOnly ? Boolean(i.unread) : issuesOnly ? products.has(i.repo) : !whoseStaff || i.repo === whoseStaff.brain,
248
+ )
246
249
  .filter((i) => (i.kind === "pr") === !!opts.prs);
247
250
  const isOpen = (i) => i.state === "OPEN";
248
251
  const scoped = mine.filter(
@@ -291,9 +294,15 @@ function inboxScreen(m, opts) {
291
294
  list.append(el("div", { className: "tdir err", textContent: e }));
292
295
  }
293
296
 
294
- // Newest first, flat. Grouping by repo buried a fresh comment under whichever project
295
- // happened to sort first; the project moves onto the row instead.
296
- items = items.slice().sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
297
+ // Unread first, then newest first, flat. Grouping by repo buried a fresh comment under
298
+ // whichever project happened to sort first; the project moves onto the row instead.
299
+ items = items
300
+ .slice()
301
+ .sort(
302
+ (a, b) =>
303
+ Number(Boolean(b.unread)) - Number(Boolean(a.unread)) ||
304
+ String(b.updatedAt).localeCompare(String(a.updatedAt)),
305
+ );
297
306
  if (!items.length) {
298
307
  list.append(el("p", { className: "empty", textContent: "Nothing matches." }));
299
308
  return;
@@ -301,7 +310,7 @@ function inboxScreen(m, opts) {
301
310
 
302
311
  for (const i of items) {
303
312
  const shutState = i.state === "MERGED" ? "merged" : i.state === "OPEN" ? "" : "closed";
304
- const b = el("button", { className: "irow" + (shutState ? " shut" : "") });
313
+ const b = el("button", { className: "irow" + (shutState ? " shut" : "") + (i.unread ? " unread" : "") });
305
314
  const yours = onAHuman(i);
306
315
  const chips = i.labels
307
316
  .slice(0, 3)
@@ -360,6 +369,14 @@ function inboxScreen(m, opts) {
360
369
  }
361
370
  const row = list.querySelector('[data-ref="' + item.repo + "#" + item.number + '"]');
362
371
  if (row) markCurrent(list, row);
372
+ // Opening it is reading it: cleared here at once, and on GitHub in the background.
373
+ if (item.unread) {
374
+ const thread = item.unread.thread;
375
+ item.unread = null;
376
+ row?.classList.remove("unread");
377
+ stampCounts();
378
+ post({ thread, ref: item.repo + "#" + item.number }, "/api/notifications/read").catch(() => {});
379
+ }
363
380
 
364
381
  const head = el("div", { className: "thead" });
365
382
  head.innerHTML =