@nanocollective/roster 0.1.0-alpha.25 → 0.1.0-alpha.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.25",
3
+ "version": "0.1.0-alpha.26",
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": {
@@ -276,3 +276,12 @@ body[data-lightbox] {overflow:hidden}
276
276
 
277
277
  /* And the affordance on the document side: an image you can open should say so. */
278
278
  .md img,img.zoomable{cursor:zoom-in}
279
+
280
+ /* ---------- a dialog for a whole task (sheet() in dialog.js) ---------- */
281
+ dialog.sheet{border:1px solid var(--border-strong);border-radius:var(--r-lg);background:var(--bg);
282
+ color:var(--ink);padding:0;width:min(860px,calc(100vw - 32px));max-height:calc(100vh - 48px);
283
+ box-shadow:var(--shadow-lg)}
284
+ dialog.sheet[open]{animation:dlgin .18s var(--ease)}
285
+ dialog.sheet::backdrop{background:rgba(0,0,0,.35);backdrop-filter:blur(8px)}
286
+ .sheetbody{padding:22px 24px 26px;overflow:auto;max-height:calc(100vh - 50px)}
287
+ .sheettitle{margin:0 0 14px;font:600 18px var(--sans);letter-spacing:-.02em}
@@ -158,3 +158,38 @@ export function askYes({ title, hint, confirm = "Continue" }) {
158
158
  cancel.focus();
159
159
  });
160
160
  }
161
+
162
+ /**
163
+ * A larger dialog for a whole task: hiring someone, or finishing their setup. Native <dialog>
164
+ * again, appended to <body> rather than to the screen, so a background refresh repainting the
165
+ * screen leaves it open with whatever was typed into it.
166
+ *
167
+ * @returns {{set: (node: Node) => void, close: () => void, isOpen: () => boolean} | null}
168
+ * null where there is no dialog element (the test shim), and the caller draws inline instead.
169
+ */
170
+ export function sheet({ node, onClose }) {
171
+ const box = document.createElement("dialog");
172
+ if (typeof box.showModal !== "function") return null;
173
+ box.className = "sheet";
174
+ const body = el("div", { className: "sheetbody" });
175
+ body.append(node);
176
+ box.append(body);
177
+ let open = true;
178
+ const close = () => {
179
+ if (!open) return;
180
+ open = false;
181
+ box.close();
182
+ box.remove();
183
+ onClose?.();
184
+ };
185
+ box.addEventListener("cancel", (e) => {
186
+ e.preventDefault();
187
+ close();
188
+ });
189
+ box.addEventListener("click", (e) => {
190
+ if (onBackdrop(e, box.getBoundingClientRect())) close();
191
+ });
192
+ document.body.append(box);
193
+ box.showModal();
194
+ return { set: (n) => body.replaceChildren(n), close, isOpen: () => open };
195
+ }
@@ -6,15 +6,15 @@
6
6
  */
7
7
 
8
8
  import { getStaffProgress, post } from "../api.js";
9
- import { askYes } from "../dialog.js";
9
+ import { askYes, sheet } from "../dialog.js";
10
10
  import { ago, el, esc } from "../dom.js";
11
11
  import { refreshAll } from "../refresh.js";
12
12
  import { S } from "../state.js";
13
- import { appPanel } from "./app.js";
14
- import { credentialPanel } from "./credential.js";
15
13
  import { hireFlow, line, rolePicker } from "./hire.js";
16
- import { paste } from "./paste.js";
17
- import { runOnce } from "./runonce.js";
14
+
15
+ /* The open dialog, if any. It lives on <body>, so it outlives a repaint of this screen, and a
16
+ background refresh leaves whatever was typed into it alone. */
17
+ let current = null;
18
18
 
19
19
  /**
20
20
  * @param {{open?: string, result?: object}} [o] `open` keeps one staff member's steps on
@@ -27,24 +27,52 @@ export function viewStaff(m, o = {}) {
27
27
 
28
28
  const pane = el("div", { className: "hirepane" });
29
29
 
30
- const pick = () => {
31
- pane.replaceChildren(
32
- ...(empty ? [] : [el("h3", { textContent: "Pick a role" })]),
33
- rolePicker({ onPick: (role) => open(role) }),
34
- );
35
- if (!empty) {
36
- const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
37
- cancel.onclick = () => pane.replaceChildren();
38
- pane.append(el("div", { className: "row" }, [cancel]));
30
+ /* Tasks open in a dialog. Where there is no dialog element (the test shim) they draw into
31
+ the pane instead, which is how this screen always drew them. */
32
+ function present(node) {
33
+ if (current?.isOpen()) {
34
+ current.set(node);
35
+ return;
39
36
  }
40
- };
37
+ current = sheet({
38
+ node,
39
+ onClose: () => {
40
+ current = null;
41
+ S.staffOpen = null;
42
+ },
43
+ });
44
+ if (!current) pane.replaceChildren(node);
45
+ }
46
+ function dismiss() {
47
+ if (current) current.close();
48
+ else pane.replaceChildren();
49
+ S.staffOpen = null;
50
+ if (empty) pick();
51
+ }
52
+
53
+ // With nobody hired the roles are the page. After that they are a dialog behind Hire someone.
54
+ function pick() {
55
+ if (empty) {
56
+ pane.replaceChildren(rolePicker({ onPick: (role) => open(role) }));
57
+ return;
58
+ }
59
+ const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
60
+ cancel.onclick = dismiss;
61
+ present(
62
+ el("div", {}, [
63
+ el("h2", { className: "sheettitle", textContent: "Pick a role" }),
64
+ rolePicker({ onPick: (role) => open(role) }),
65
+ el("div", { className: "row", style: "margin-top:14px" }, [cancel]),
66
+ ]),
67
+ );
68
+ }
41
69
 
42
70
  /* One staff member's steps, hired or not yet. A hire repaints the whole screen, so the new
43
71
  card is in the list and the steps reopen on the same person. */
44
72
  function open(role, result) {
45
73
  // Remembered, so a refresh after a trip to GitHub (creating the App) reopens it.
46
74
  S.staffOpen = role;
47
- pane.replaceChildren(
75
+ present(
48
76
  hireFlow({
49
77
  role,
50
78
  result,
@@ -52,14 +80,9 @@ export function viewStaff(m, o = {}) {
52
80
  m.replaceChildren();
53
81
  viewStaff(m, { open: handle, result: r });
54
82
  },
55
- onClose: () => {
56
- S.staffOpen = null;
57
- if (empty) pick();
58
- else pane.replaceChildren();
59
- },
83
+ onClose: dismiss,
60
84
  }),
61
85
  );
62
- pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
63
86
  }
64
87
 
65
88
  if (!empty) {
@@ -75,9 +98,10 @@ export function viewStaff(m, o = {}) {
75
98
  const opened = o.open && S.data.staff.find((s) => s.handle === o.open);
76
99
  const kept = !opened && S.staffOpen;
77
100
  const keptStaff = kept && S.data.staff.find((s) => s.handle === kept.handle);
101
+ if (empty) pick();
78
102
  if (opened) open(roleOf(opened), o.result);
79
- else if (kept) open(keptStaff ? roleOf(keptStaff) : kept);
80
- else if (empty) pick();
103
+ // An open dialog is still open; only the inline fallback has to be drawn again.
104
+ else if (kept && !current?.isOpen()) open(keptStaff ? roleOf(keptStaff) : kept);
81
105
 
82
106
  /* --------------------------- one staff member --------------------------- */
83
107
 
@@ -93,73 +117,32 @@ export function viewStaff(m, o = {}) {
93
117
 
94
118
  const status = el("span", { className: "meta" });
95
119
  const go = el("button", { className: "ghbtn", textContent: "Retire" });
96
- go.onclick = () => retireForm(pane, s, status);
97
-
98
- /* The charter is the one file `hire` deliberately does not write, and until now the only
99
- route to it was a CLI command printing a brief. This is the same brief, with every file
100
- it refers to already inside it and somewhere to put the answer. */
101
- const write = el("button", { className: "ghbtn", textContent: "Write the charter" });
102
- write.onclick = () => {
103
- pane.replaceChildren(
104
- el("div", { className: "card" }, [
105
- paste({
106
- kind: "charter",
107
- staff: s.handle,
108
- title: "Write " + s.name + "'s charter with your own AI",
109
- onSaved: () => refreshAll(false),
110
- }),
111
- ]),
112
- );
113
- pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
114
- };
115
-
116
- /* The step `hire` has always had to hand back: there is no API that creates a GitHub App,
117
- so it is a browser hand-off either way. It may as well be this browser. */
118
- const app = el("button", { className: "ghbtn", textContent: "GitHub App" });
119
- app.onclick = () => {
120
- pane.replaceChildren(
121
- el("div", { className: "card" }, [
122
- el("h3", { textContent: s.name + "'s identity" }),
123
- appPanel({ staff: s.handle, name: s.name, scope: "private" }),
124
- appPanel({ staff: s.handle, name: s.name, scope: "public" }),
125
- ]),
126
- );
127
- pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
120
+ go.onclick = () => {
121
+ const host = el("div");
122
+ present(host);
123
+ retireForm(host, s, status);
128
124
  };
129
125
 
130
- /* The credential is once for the org, but the moment somebody looks for it is while setting
131
- up one staff member, so it is reachable from each card. */
132
- const cred = el("button", { className: "ghbtn", textContent: "Agent credential" });
133
- cred.onclick = () => {
134
- pane.replaceChildren(el("div", { className: "card" }, [credentialPanel()]));
135
- pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
136
- };
137
-
138
- const run = el("button", { className: "ghbtn", textContent: "Run once now" });
139
- run.onclick = () => {
140
- pane.replaceChildren(
141
- el("div", { className: "card" }, [
142
- runOnce({ staff: s.handle, name: s.name, onDone: (ok) => ok && refreshAll(true) }),
143
- ]),
144
- );
145
- pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
146
- };
147
-
148
- /* Whether setup is finished, as far as can be told cheaply: the charter from disk at once,
149
- and the App's secrets and a first run from GitHub when it answers. */
150
- const finish = el("button", { className: "ghbtn primary", textContent: "Finish setting up", hidden: true });
126
+ /* Every setup step (the App, the charter, the credential, a run) is in the one list, so the
127
+ card has one way in. It says Finish setting up until setup looks finished: the charter
128
+ from disk at once, and the App's secrets and a first run from GitHub when it answers. */
129
+ const finish = el("button", { className: "ghbtn", textContent: "Set up" });
151
130
  finish.onclick = () => open(roleOf(s));
152
- if (s.rig?.charterStub) finish.hidden = false;
131
+ const unfinished = () => {
132
+ finish.textContent = "Finish setting up";
133
+ finish.className = "ghbtn primary";
134
+ };
135
+ if (s.rig?.charterStub) unfinished();
153
136
  else {
154
137
  getStaffProgress(s.handle)
155
138
  .then((p) => {
156
- if (p.app === false || p.ran === false) finish.hidden = false;
139
+ if (p.app === false || p.ran === false) unfinished();
157
140
  })
158
141
  .catch(() => {});
159
142
  }
160
143
 
161
144
  d.append(
162
- el("div", { className: "row", style: "margin-top:10px" }, [finish, write, app, cred, run, go, status]),
145
+ el("div", { className: "row", style: "margin-top:10px" }, [finish, go, status]),
163
146
  );
164
147
  return d;
165
148
  }
@@ -206,7 +189,7 @@ export function viewStaff(m, o = {}) {
206
189
  const status = el("span", { className: "meta" });
207
190
  const go = el("button", { className: "ghbtn", textContent: "Retire " + plan.handle });
208
191
  const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
209
- cancel.onclick = () => host.replaceChildren();
192
+ cancel.onclick = dismiss;
210
193
  go.onclick = () =>
211
194
  apply(
212
195
  { action: "retire", handle: plan.handle },