@nanocollective/roster 0.1.0-alpha.45 → 0.1.0-alpha.47

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/docs/portal.md CHANGED
@@ -95,13 +95,18 @@ Where the portal opens. An Ask box at the top, then these, in order.
95
95
  - a pull request has **Merge**, unless its checks fail, are still running, or it conflicts.
96
96
 
97
97
  Answers go out as you, with the staff member's `@handle`, so they act on them straight away.
98
- When nothing is waiting, it says so.
98
+ Once you have had the last word, the card leaves Needs you for Your requests, since it is
99
+ waiting on them now. If they reply, it comes back. When nothing is waiting, it says so.
100
+ - **Unread.** Anything on a staff member's own tracker, or elsewhere, with activity you have not
101
+ seen: a peer's ask, a status issue, their own work. Unread comes from your GitHub
102
+ notifications, as on Trackers. Cards and rows anywhere on Home carry the same blue mark, and
103
+ opening one marks it read here and on GitHub.
99
104
  - **Working now.** Each staff member: what they are running and what started it (the daily run,
100
105
  a follow-on, answering an issue, a peer's ask), for how long, with a link to the log. When
101
106
  they are idle, how their last run ended. Peer and follow-on runs today are counted against
102
107
  `max_runs_per_day`. This asks GitHub every few seconds while a run is going and every half
103
108
  minute otherwise, and only while Home is on screen.
104
- - **Your requests.** What you asked for: waiting, being worked on (a run for it is going), or
109
+ - **Your requests.** What you asked for, and asks of theirs you have answered: waiting, being worked on (a run for it is going), or
105
110
  answered (a staff member had the last word).
106
111
  - **Closed today.** What the staff closed today, with the line they closed it with, and
107
112
  **Reopen** beside each. Staff close finished issues themselves, so this is where you check.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.45",
3
+ "version": "0.1.0-alpha.47",
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": {
@@ -283,6 +283,10 @@ dialog.sheet{border:1px solid var(--border-strong);border-radius:var(--r-lg);bac
283
283
  color:var(--ink);padding:0;width:min(860px,calc(100vw - 32px));max-height:calc(100vh - 48px);
284
284
  box-shadow:var(--shadow-lg)}
285
285
  dialog.sheet[open]{animation:dlgin .18s var(--ease)}
286
+ /* Pinned to the window. The graph's own `.sheet` panel is position:absolute, and that rule
287
+ reached this dialog too, so one opened on a scrolled page sat at the top of the document,
288
+ half off the screen. */
289
+ dialog.sheet{position:fixed}
286
290
  dialog.sheet::backdrop{background:rgba(0,0,0,.35);backdrop-filter:blur(8px)}
287
291
  .sheetbody{padding:22px 24px 26px;overflow:auto;max-height:calc(100vh - 50px)}
288
292
 
@@ -295,6 +299,12 @@ dialog.sheet.side .sheetbody{max-height:100vh;height:100%;box-sizing:border-box;
295
299
  @media (prefers-reduced-motion:reduce){dialog.sheet.side[open]{animation:none}}
296
300
  .sidebar-x{position:sticky;top:0;display:flex;justify-content:flex-end;margin:-6px -6px 4px;z-index:1}
297
301
  dialog.sheet.side .viewer{border:0;box-shadow:none;padding:0;min-height:0;background:none}
302
+ /* The title and its actions stay in view while a long thread scrolls under them. The close
303
+ button floats over the header's right edge, so the header leaves room for it. */
304
+ dialog.sheet.side .thead{position:sticky;top:-18px;z-index:1;background:var(--bg);
305
+ padding-top:18px;margin-top:-18px;padding-right:44px}
306
+ dialog.sheet.side .sidebar-x{position:sticky;top:0;height:0;margin:0;z-index:2;overflow:visible}
307
+ dialog.sheet.side .sidebar-x .iconbtn{margin-top:-4px}
298
308
  .sheettitle{margin:0 0 14px;font:600 18px var(--sans);letter-spacing:-.02em}
299
309
  .sheetloading{display:grid;place-items:center;min-height:260px}
300
310
  .spinner{width:22px;height:22px;border-radius:50%;border:2px solid var(--line);border-top-color:var(--accent);
@@ -1,6 +1,9 @@
1
1
  /* Home: what needs you, who is working, your requests, closed today. */
2
2
 
3
3
  .home{max-width:820px}
4
+ /* Secondary text on Home is read, not scanned for numbers: the readable face and colour, not
5
+ the faint monospace the rest of the portal uses for metadata. */
6
+ .home .meta{font:12.5px/1.5 var(--sans);color:var(--ink-dim)}
4
7
  .home h2{font:600 13px var(--sans);color:var(--ink-dim);text-transform:none;letter-spacing:0;
5
8
  margin:26px 0 10px;display:flex;align-items:center;gap:8px}
6
9
  .home h2 .pill{font-size:10.5px}
@@ -59,13 +62,26 @@
59
62
 
60
63
  .homerow{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;padding:8px 2px;
61
64
  border-bottom:1px solid var(--line);font-size:13px}
62
- .homerow .homeopen{font-weight:500;font-size:13px}
65
+ .homerow .homeopen{font-weight:500;font-size:13.5px}
66
+ .homelist{display:grid;grid-template-columns:auto 1fr;align-items:start;gap:4px 12px}
67
+ .homelist > .chip{margin-top:2px}
68
+ .homelist .meta{margin-top:3px}
63
69
  .homeclick{cursor:pointer;border-radius:8px;padding-left:8px;padding-right:8px;margin:0 -8px}
64
70
  .homeclick:hover{background:var(--hover)}
65
71
  .homeclick:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
66
72
  .homeclosed{display:grid;grid-template-columns:1fr auto;align-items:center;gap:4px 14px}
67
- .homeclosed .meta{margin-top:3px;line-height:1.5;font-family:var(--sans);font-size:12px}
73
+ .homeclosed .meta{margin-top:3px}
68
74
  .homerest{margin-top:26px}
75
+ .homecard.gone,.homerow.gone{opacity:0;transform:translateX(12px);
76
+ transition:opacity .2s var(--ease),transform .2s var(--ease)}
77
+
78
+ /* Activity you have not seen, as Trackers marks it: a blue edge and a bold title. Opening the
79
+ thread marks it read, here and on GitHub. */
80
+ .homecard.unread{box-shadow:inset 3px 0 0 var(--blue)}
81
+ .homerow.unread{box-shadow:inset 3px 0 0 var(--blue);background:color-mix(in srgb,var(--blue) 6%,transparent)}
82
+ .unread .homeopen{font-weight:700}
83
+ .unread .homeopen::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;
84
+ background:var(--blue);margin:0 7px 1px 0;vertical-align:middle}
69
85
 
70
86
  .homereports{display:flex;flex-direction:column;gap:14px;padding:14px 16px}
71
87
  .homereport{display:grid;grid-template-columns:190px 1fr;gap:12px;font-size:13px;line-height:1.5}
@@ -7,6 +7,10 @@
7
7
  * staff a staff member's own work: their status issue, a peer's ask, their own issues
8
8
  * elsewhere draft pull requests, and product or ops issues nobody has put on a person
9
9
  *
10
+ * `unread` is not a place: it lists what in `staff` and `elsewhere` has activity you have not
11
+ * seen (from your GitHub notifications), since those two are otherwise off Home. Items in the
12
+ * other places carry their own unread mark.
13
+ *
10
14
  * Closed items only matter when a staff member closed them today: that is "Closed today", with
11
15
  * Reopen beside each, because staff now close things without asking. */
12
16
 
@@ -45,7 +49,15 @@ export function sortHome(items, staff, humans, live = [], now = new Date()) {
45
49
  for (const r of l.running ?? []) if (s && r.issue) working.add(`${s.brain}#${r.issue}`);
46
50
  }
47
51
 
48
- const out = { reports: [], needs: [], requests: [], staff: [], elsewhere: [], closedToday: [] };
52
+ const out = {
53
+ reports: [],
54
+ needs: [],
55
+ requests: [],
56
+ staff: [],
57
+ elsewhere: [],
58
+ unread: [],
59
+ closedToday: [],
60
+ };
49
61
  const today = dayOf(now);
50
62
 
51
63
  for (const item of items) {
@@ -92,6 +104,9 @@ export function sortHome(items, staff, humans, live = [], now = new Date()) {
92
104
  out.needs.sort(oldest);
93
105
  out.requests.sort((a, b) => b.item.updatedAt.localeCompare(a.item.updatedAt));
94
106
  out.closedToday.sort((a, b) => b.item.closedAt.localeCompare(a.item.closedAt));
107
+ out.unread = [...out.staff, ...out.elsewhere]
108
+ .filter((e) => e.item.unread)
109
+ .sort((a, b) => b.item.updatedAt.localeCompare(a.item.updatedAt));
95
110
  return out;
96
111
  }
97
112
 
@@ -106,17 +121,22 @@ export function placeOf(item, owner, people, markers) {
106
121
  (item.assignees ?? []).some((a) => people.has(lower(a))) ||
107
122
  labels.some((l) => markers.has(l));
108
123
  const byPerson = people.has(lower(item.author));
124
+ /* You had the last word on an ask: approved it, answered it, said go with the default. It is
125
+ waiting on them now, so it is one of your requests until they reply or close it. If they
126
+ reply, the last word is theirs and it is back on you. */
127
+ const youAnswered = Boolean(item.lastComment) && people.has(lower(item.lastComment.author));
109
128
 
110
129
  if (!owner) {
111
- return onPerson && !byPerson
112
- ? { place: "needs", kind: kindOf(labels) }
113
- : { place: "elsewhere", why: item.role === "product" ? "contributor" : "other" };
130
+ if (!onPerson || byPerson) {
131
+ return { place: "elsewhere", why: item.role === "product" ? "contributor" : "other" };
132
+ }
133
+ return youAnswered ? { place: "requests" } : { place: "needs", kind: kindOf(labels) };
114
134
  }
115
135
  if (item.number === owner.statusIssue || labels.includes("keep-open")) {
116
136
  return { place: "staff", why: "status" };
117
137
  }
118
138
  if (byPerson) return { place: "requests" };
119
- if (onPerson) return { place: "needs", kind: kindOf(labels) };
139
+ if (onPerson) return youAnswered ? { place: "requests" } : { place: "needs", kind: kindOf(labels) };
120
140
  return {
121
141
  place: "staff",
122
142
  why: labels.some((l) => l.startsWith("from-")) ? "peer" : "own",
@@ -73,6 +73,11 @@ export function viewHome(m) {
73
73
  if (h.needs.length) for (const e of h.needs) page.append(needCard(e));
74
74
  else page.append(el("p", { className: "homeempty", textContent: "Nothing needs you." }));
75
75
 
76
+ if (h.unread.length) {
77
+ page.append(section("Unread", h.unread.length));
78
+ for (const e of h.unread) page.append(unreadRow(e));
79
+ }
80
+
76
81
  page.append(section("Working now"));
77
82
  page.append(working(staff));
78
83
 
@@ -147,7 +152,11 @@ function needCard(e) {
147
152
  const { item, kind } = e;
148
153
  /* The whole card opens the thread; the one quick action on it does not. Answering in words
149
154
  happens in the thread, where what was asked is in front of you. */
150
- const card = el("div", { className: "card homecard", tabIndex: 0, role: "button" });
155
+ const card = el("div", {
156
+ className: "card homecard" + (item.unread ? " unread" : ""),
157
+ tabIndex: 0,
158
+ role: "button",
159
+ });
151
160
  card.onclick = (ev) => {
152
161
  if (!ev.target.closest("button, a, textarea")) openThread(item);
153
162
  };
@@ -237,6 +246,12 @@ async function act(note, actions, payload, ok) {
237
246
  try {
238
247
  await post(payload);
239
248
  note.textContent = ok;
249
+ // Done is done: the card goes now, rather than when GitHub next answers.
250
+ const card = actions.closest?.(".homecard, .homerow");
251
+ if (card) {
252
+ card.classList.add("gone");
253
+ setTimeout(() => card.remove(), 220);
254
+ }
240
255
  refreshSoon();
241
256
  } catch (err) {
242
257
  note.textContent = String(err.message || err);
@@ -329,6 +344,16 @@ function outcome(r) {
329
344
  return r.conclusion === "success" ? "finished" : r.conclusion === "failure" ? "failed" : (r.conclusion ?? "ended");
330
345
  }
331
346
 
347
+ /**
348
+ * A timer that never keeps a process alive. In the browser this is setTimeout; under the
349
+ * tests' Node shim the polling loop would otherwise hold a test file open forever.
350
+ */
351
+ function later(fn, ms) {
352
+ const t = setTimeout(fn, ms);
353
+ t?.unref?.();
354
+ return t;
355
+ }
356
+
332
357
  function poll() {
333
358
  clearTimeout(timer);
334
359
  getLive()
@@ -342,7 +367,7 @@ function poll() {
342
367
  .finally(() => {
343
368
  if (S.view !== "home") return;
344
369
  const busy = (S.live?.staff ?? []).some((l) => l.running?.length);
345
- timer = setTimeout(poll, busy ? FAST : SLOW);
370
+ timer = later(poll, busy ? FAST : SLOW);
346
371
  });
347
372
  }
348
373
 
@@ -350,17 +375,46 @@ function poll() {
350
375
 
351
376
  const STATUS = { waiting: "Waiting", working: "Being worked on", answered: "Answered" };
352
377
 
378
+ const WHY = {
379
+ status: "Status",
380
+ peer: "Peer ask",
381
+ own: "Their work",
382
+ draft: "Draft",
383
+ contributor: "Issue",
384
+ other: "Issue",
385
+ };
386
+
387
+ /** Something on a staff member's tracker, or elsewhere, with activity you have not seen. */
388
+ function unreadRow(e) {
389
+ return clickable(e.item, "homerow homelist", [
390
+ el("span", { className: "chip", textContent: WHY[e.why] ?? "Issue" }),
391
+ el("div", {}, [
392
+ openLink(e.item),
393
+ el("div", { className: "meta", textContent: whereOf(e) + " · " + ago(e.item.updatedAt) }),
394
+ ]),
395
+ ]);
396
+ }
397
+
353
398
  function requestRow(e) {
354
- return clickable(e.item, "homerow", [
355
- el("span", { className: "chip " + (e.status === "answered" ? "cool" : e.status === "working" ? "warm" : ""), textContent: STATUS[e.status] }),
356
- openLink(e.item),
357
- el("span", { className: "meta", textContent: whereOf(e) + " · " + ago(e.item.updatedAt) }),
399
+ return clickable(e.item, "homerow homelist", [
400
+ el("span", {
401
+ className: "chip " + (e.status === "answered" ? "cool" : e.status === "working" ? "warm" : ""),
402
+ textContent: STATUS[e.status],
403
+ }),
404
+ el("div", {}, [
405
+ openLink(e.item),
406
+ el("div", { className: "meta", textContent: whereOf(e) + " · " + ago(e.item.updatedAt) }),
407
+ ]),
358
408
  ]);
359
409
  }
360
410
 
361
411
  /** A row that opens its thread from anywhere on it, except a button inside it. */
362
412
  function clickable(item, className, kids) {
363
- const row = el("div", { className: className + " homeclick", tabIndex: 0, role: "button" }, kids);
413
+ const row = el(
414
+ "div",
415
+ { className: className + " homeclick" + (item.unread ? " unread" : ""), tabIndex: 0, role: "button" },
416
+ kids,
417
+ );
364
418
  row.onclick = (ev) => {
365
419
  if (!ev.target.closest("button, a")) openThread(item);
366
420
  };
@@ -460,5 +514,5 @@ function refreshSoon() {
460
514
  ensureInbox(true)
461
515
  .then(() => S.view === "home" && !document.querySelector("dialog[open]") && repaintInPlace())
462
516
  .catch(() => {});
463
- setTimeout(poll, 3_000);
517
+ later(poll, 3_000);
464
518
  }