@nanocollective/roster 0.1.0-alpha.1 → 0.1.0-alpha.11
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 +65 -84
- package/dist/cli.js +4687 -2495
- package/docs/README.md +19 -11
- package/docs/agents.md +328 -13
- package/docs/architecture.md +13 -5
- package/docs/charters/cmo.md +69 -0
- package/docs/charters/cto.md +71 -0
- package/docs/charters/support.md +60 -0
- package/docs/commands.md +90 -11
- package/docs/concepts.md +64 -12
- package/docs/cost.md +39 -3
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +21 -6
- package/docs/export.md +2 -1
- package/docs/extending.md +13 -4
- package/docs/getting-started.md +118 -80
- package/docs/images/brain.jpg +0 -0
- package/docs/images/org.jpg +0 -0
- package/docs/images/prompt.jpg +0 -0
- package/docs/images/setup-org.jpg +0 -0
- package/docs/images/setup-plan.jpg +0 -0
- package/docs/images/staff.jpg +0 -0
- package/docs/manual-steps.md +94 -101
- package/docs/memory.md +29 -8
- package/docs/org-yaml.md +74 -11
- package/docs/portal.md +261 -47
- package/docs/prompts.md +71 -11
- package/docs/security.md +43 -7
- package/docs/session-workflow.md +51 -21
- package/docs/staff-yaml.md +16 -7
- package/docs/troubleshooting.md +23 -20
- package/docs/upgrading.md +6 -0
- package/docs/writing-a-charter.md +33 -17
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +7 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +16 -4
- package/templates/brain/CHARTER.md +3 -3
- package/templates/brain/README.md +1 -0
- package/templates/brain/log/decisions.md +3 -0
- package/templates/brain/staff.yaml +0 -1
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/ops/.github/workflows/session.yaml +117 -40
- package/templates/ops/agents.mjs +127 -8
- package/templates/ops/compose.mjs +77 -7
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +21 -7
- package/templates/ops/org/voice.md +9 -0
- package/templates/ops/prompts/_identity.md +8 -1
- package/templates/ops/prompts/_inflight.md +14 -0
- package/templates/ops/prompts/_paths.md +2 -1
- package/templates/ops/prompts/daily.md +16 -7
- package/templates/ops/prompts/mention.md +18 -2
- package/templates/ops/run-record.mjs +144 -0
- package/templates/portal/css/base.css +238 -64
- package/templates/portal/css/brain.css +30 -20
- package/templates/portal/css/diff.css +15 -10
- package/templates/portal/css/graph.css +12 -7
- package/templates/portal/css/health.css +32 -11
- package/templates/portal/css/inbox.css +117 -14
- package/templates/portal/css/layout.css +92 -41
- package/templates/portal/css/markdown.css +38 -15
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +52 -39
- package/templates/portal/index.html +24 -3
- package/templates/portal/js/api.js +65 -4
- package/templates/portal/js/app.js +112 -12
- package/templates/portal/js/dialog.js +94 -4
- package/templates/portal/js/dom.js +25 -0
- package/templates/portal/js/icons.js +8 -1
- package/templates/portal/js/lightbox.js +273 -0
- package/templates/portal/js/md.js +23 -6
- package/templates/portal/js/mention.js +264 -0
- package/templates/portal/js/refresh.js +136 -6
- package/templates/portal/js/state.js +55 -8
- package/templates/portal/js/views/app.js +23 -5
- package/templates/portal/js/views/checklist.js +20 -7
- package/templates/portal/js/views/credential.js +93 -0
- package/templates/portal/js/views/docs.js +94 -4
- package/templates/portal/js/views/files.js +58 -14
- package/templates/portal/js/views/graph.js +1 -1
- package/templates/portal/js/views/health.js +178 -37
- package/templates/portal/js/views/inbox.js +938 -98
- package/templates/portal/js/views/memory.js +16 -1
- package/templates/portal/js/views/org.js +161 -61
- package/templates/portal/js/views/paste.js +29 -0
- package/templates/portal/js/views/prompt.js +57 -66
- package/templates/portal/js/views/repos.js +12 -7
- package/templates/portal/js/views/runonce.js +94 -0
- package/templates/portal/js/views/runs.js +165 -0
- package/templates/portal/js/views/setup.js +302 -56
- package/templates/portal/js/views/staff.js +143 -22
- package/templates/portal/js/yaml.js +134 -0
- package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
- package/templates/ops/prompts/pr-mention.md +0 -57
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>roster</title>
|
|
7
7
|
<!-- Inline, so a local page with no network still has a tab icon and the console stays clean. -->
|
|
8
|
-
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2334c759'/%3E%3C/svg%3E">
|
|
9
9
|
<!-- Stylesheets and modules are served from /assets by `roster portal`. No build step:
|
|
10
10
|
editing one of these files and reloading is the whole edit loop. -->
|
|
11
11
|
<link rel="stylesheet" href="/assets/css/base.css">
|
|
@@ -16,20 +16,34 @@
|
|
|
16
16
|
<link rel="stylesheet" href="/assets/css/graph.css">
|
|
17
17
|
<link rel="stylesheet" href="/assets/css/diff.css">
|
|
18
18
|
<link rel="stylesheet" href="/assets/css/health.css">
|
|
19
|
+
<link rel="stylesheet" href="/assets/css/runs.css">
|
|
19
20
|
<link rel="stylesheet" href="/assets/css/setup.css">
|
|
20
21
|
</head>
|
|
21
22
|
<body>
|
|
22
23
|
<div class="app">
|
|
23
24
|
<aside class="side">
|
|
24
|
-
<div class="brand">roster<small id="orgname">loading…</small></div>
|
|
25
|
+
<div class="brand"><span class="mark"></span>roster<small id="orgname">loading…</small></div>
|
|
25
26
|
<button class="refreshall" id="refreshall" title="Reload everything from disk (r)">
|
|
26
27
|
<span class="sync" data-icon="refresh"></span><span id="loaded">data now</span>
|
|
27
28
|
</button>
|
|
28
29
|
|
|
30
|
+
<!-- Only while something is left to set up; app.js decides. -->
|
|
31
|
+
<button class="nav top" id="setupnav" data-view="setup" hidden>
|
|
32
|
+
<span class="ic" data-icon="task-done"></span> Getting started
|
|
33
|
+
</button>
|
|
29
34
|
<button class="nav top" id="inboxnav" data-view="inbox">
|
|
30
35
|
<span class="ic" data-icon="inbox"></span> Inbox
|
|
31
36
|
<span class="n" id="inboxcount"></span>
|
|
32
37
|
</button>
|
|
38
|
+
<!-- "Pending work", not "Pull requests": what is behind it is finished work waiting on you,
|
|
39
|
+
and the word for how it is delivered is not the word for what it is. -->
|
|
40
|
+
<button class="nav" id="prsnav" data-view="prs" title="Finished work waiting on you to merge">
|
|
41
|
+
<span class="ic" data-icon="merged"></span> Pending work
|
|
42
|
+
<span class="n" id="prcount"></span>
|
|
43
|
+
</button>
|
|
44
|
+
<button class="nav" id="runsnav" data-view="runs" title="What ran, how it ended, and what it cost">
|
|
45
|
+
<span class="ic" data-icon="play"></span> Runs
|
|
46
|
+
</button>
|
|
33
47
|
<button class="nav" id="orgnav" data-view="org">
|
|
34
48
|
<span class="ic" data-icon="file"></span> Org
|
|
35
49
|
</button>
|
|
@@ -40,7 +54,14 @@
|
|
|
40
54
|
<span class="ic" data-icon="docs"></span> Docs
|
|
41
55
|
</button>
|
|
42
56
|
|
|
43
|
-
|
|
57
|
+
<!-- The heading is the fold. Collapsed, it carries the count, because a hidden list still
|
|
58
|
+
has to say how much it is hiding. -->
|
|
59
|
+
<!-- Named apart from the Staff screen above it: two controls both called "Staff" is how a
|
|
60
|
+
click meant for the screen landed on the fold, for people and for automation alike. -->
|
|
61
|
+
<button class="sect sectfold" id="stafffold" aria-expanded="false" aria-controls="stafflist"
|
|
62
|
+
aria-label="Staff list" title="Show or hide each staff member's own screens">
|
|
63
|
+
<span class="caret" data-icon="chevron"></span>Staff<span class="n" id="staffcount" hidden></span>
|
|
64
|
+
</button>
|
|
44
65
|
<div id="stafflist"></div>
|
|
45
66
|
|
|
46
67
|
<div class="sidefoot">
|
|
@@ -7,6 +7,21 @@ export const getDocs = () => json("/api/docs");
|
|
|
7
7
|
export const getInbox = (force) => json("/api/inbox" + (force ? "?refresh=1" : ""));
|
|
8
8
|
export const getSync = () => json("/api/sync").catch(() => null);
|
|
9
9
|
|
|
10
|
+
/** Every staff member's recent runs and 30-day spend. Online only; offline it says so. */
|
|
11
|
+
export const getRuns = (force) => json("/api/runs" + (force ? "?refresh=1" : ""));
|
|
12
|
+
|
|
13
|
+
/** The repos org.yaml lists. Off disk, so the new-issue form does not wait on the inbox. */
|
|
14
|
+
export const getRepos = () => json("/api/repos");
|
|
15
|
+
|
|
16
|
+
/** What one repo's labels actually are, so they can be offered rather than typed. */
|
|
17
|
+
export const getLabels = (repo) => json("/api/labels?repo=" + encodeURIComponent(repo));
|
|
18
|
+
|
|
19
|
+
/** Full-text across the docs. Titles are not enough: what you want is usually a paragraph. */
|
|
20
|
+
export const searchDocs = (q) => json("/api/docsearch?q=" + encodeURIComponent(q));
|
|
21
|
+
|
|
22
|
+
/** Every file in the org layer that the portal may write, off disk rather than hardcoded. */
|
|
23
|
+
export const getOrgLayer = () => json("/api/orglayer");
|
|
24
|
+
|
|
10
25
|
export const getDoc = (page) =>
|
|
11
26
|
fetch("/api/doc?page=" + encodeURIComponent(page), { cache: "no-store" }).then((r) => r.text());
|
|
12
27
|
|
|
@@ -18,6 +33,10 @@ export const getThread = (repo, number, kind) =>
|
|
|
18
33
|
"/api/thread?repo=" + encodeURIComponent(repo) + "&number=" + number + "&kind=" + kind,
|
|
19
34
|
);
|
|
20
35
|
|
|
36
|
+
/** A pull request's commits and its diff. Asked for on the Files or Commits tab, not before. */
|
|
37
|
+
export const getPr = (repo, number) =>
|
|
38
|
+
json("/api/pr?repo=" + encodeURIComponent(repo) + "&number=" + number);
|
|
39
|
+
|
|
21
40
|
export const diffUrl = (dir, sha, path) =>
|
|
22
41
|
"/api/diff?dir=" + encodeURIComponent(dir) + "&sha=" + encodeURIComponent(sha) +
|
|
23
42
|
(path ? "&path=" + encodeURIComponent(path) : "");
|
|
@@ -31,10 +50,32 @@ export async function post(payload, url = "/api/act") {
|
|
|
31
50
|
body: JSON.stringify(payload),
|
|
32
51
|
});
|
|
33
52
|
const data = await res.json().catch(() => ({}));
|
|
34
|
-
if (!res.ok || data.error)
|
|
53
|
+
if (!res.ok || data.error) {
|
|
54
|
+
// The body travels with the error: a refusal often carries the next step, like a link.
|
|
55
|
+
const err = new Error(data.error || "failed with " + res.status);
|
|
56
|
+
err.data = data;
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
35
59
|
return data;
|
|
36
60
|
}
|
|
37
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Put a file in the repo an issue lives in, and get back the path and the link.
|
|
64
|
+
*
|
|
65
|
+
* Read here rather than posted as a form because everything else on this server speaks JSON,
|
|
66
|
+
* and a file small enough to belong in a repo is small enough to base64.
|
|
67
|
+
*/
|
|
68
|
+
export async function upload(repo, file) {
|
|
69
|
+
const data = await new Promise((ok, no) => {
|
|
70
|
+
const r = new FileReader();
|
|
71
|
+
r.onerror = () => no(new Error("could not read " + file.name));
|
|
72
|
+
// A data URL is "data:<type>;base64,<payload>"; only the payload travels.
|
|
73
|
+
r.onload = () => ok(String(r.result).split(",")[1] ?? "");
|
|
74
|
+
r.readAsDataURL(file);
|
|
75
|
+
});
|
|
76
|
+
return post({ repo, name: file.name, data }, "/api/upload");
|
|
77
|
+
}
|
|
78
|
+
|
|
38
79
|
/* ------------------------------- setup and authoring ------------------------------- */
|
|
39
80
|
|
|
40
81
|
export const getSetup = () => json("/api/setup/status");
|
|
@@ -45,8 +86,12 @@ export const planTenant = (params) =>
|
|
|
45
86
|
export const createTenant = (params) => post(params, "/api/setup/apply");
|
|
46
87
|
|
|
47
88
|
/** The copyable prompt, with every file it refers to carried inside it. */
|
|
48
|
-
export const getBrief = (kind, staff) =>
|
|
49
|
-
json(
|
|
89
|
+
export const getBrief = (kind, staff, example) =>
|
|
90
|
+
json(
|
|
91
|
+
"/api/brief?kind=" + encodeURIComponent(kind) +
|
|
92
|
+
(staff ? "&staff=" + encodeURIComponent(staff) : "") +
|
|
93
|
+
(example ? "&example=" + encodeURIComponent(example) : ""),
|
|
94
|
+
);
|
|
50
95
|
|
|
51
96
|
/** Parse what came back from the model. Reads only: saving is a second, deliberate step. */
|
|
52
97
|
export const parsePaste = (kind, staff, answer) =>
|
|
@@ -65,10 +110,26 @@ export const getDoctor = (offline) => json("/api/doctor" + (offline ? "?offline=
|
|
|
65
110
|
export const getFix = (offline) => json("/api/fix" + (offline ? "?offline=1" : ""));
|
|
66
111
|
|
|
67
112
|
export const listRepos = (org) => json("/api/setup/repos?org=" + encodeURIComponent(org));
|
|
68
|
-
|
|
113
|
+
/** Commits org.yaml and pushes, as every other write here does. */
|
|
114
|
+
export const addRepo = (name, role, visibility) =>
|
|
115
|
+
post({ name, role, visibility }, "/api/setup/add-repo");
|
|
69
116
|
export const startApp = (staff, scope) => post({ staff, scope }, "/api/setup/app");
|
|
70
117
|
export const appResult = (state) => json("/api/setup/app-result?state=" + encodeURIComponent(state));
|
|
71
118
|
|
|
72
119
|
/** Does this org already run roster. "Create" and "join" are different answers. */
|
|
73
120
|
export const checkOrg = (org) => json("/api/setup/check-org?org=" + encodeURIComponent(org));
|
|
74
121
|
export const joinOrg = (org) => post({ org }, "/api/setup/join");
|
|
122
|
+
|
|
123
|
+
/** The ops repo's Actions access: read it, or ask the server to set it. */
|
|
124
|
+
export const getAccess = () => json("/api/setup/access");
|
|
125
|
+
export const setAccess = () => post({}, "/api/setup/access");
|
|
126
|
+
|
|
127
|
+
/** Where the agent credential goes, and storing it. The value only ever travels in a POST. */
|
|
128
|
+
export const getCredential = () => json("/api/setup/credential");
|
|
129
|
+
export const storeCredential = (value, repoSecrets) =>
|
|
130
|
+
post({ value, repoSecrets }, "/api/setup/credential");
|
|
131
|
+
|
|
132
|
+
/** One daily run, now, and where it has got to. */
|
|
133
|
+
export const startRun = (staff) => post({ staff }, "/api/run/start");
|
|
134
|
+
export const runStatus = (staff, id) =>
|
|
135
|
+
json("/api/run/state?staff=" + encodeURIComponent(staff) + "&id=" + encodeURIComponent(id));
|
|
@@ -3,23 +3,35 @@
|
|
|
3
3
|
import { getOrg } from "./api.js";
|
|
4
4
|
import { $, el, store } from "./dom.js";
|
|
5
5
|
import { icon, iconHTML } from "./icons.js";
|
|
6
|
+
import { installLightbox } from "./lightbox.js";
|
|
6
7
|
import { setPeople } from "./md.js";
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
countInBackground,
|
|
10
|
+
refreshAll,
|
|
11
|
+
refreshQuietly,
|
|
12
|
+
stampCounts,
|
|
13
|
+
stampLoaded,
|
|
14
|
+
syncNotice,
|
|
15
|
+
} from "./refresh.js";
|
|
8
16
|
import { onRender } from "./router.js";
|
|
9
|
-
import { ORG_WIDE, S, VIEWS, applyHash,
|
|
17
|
+
import { ORG_WIDE, S, VIEWS, applyHash, readHash, writeHash } from "./state.js";
|
|
10
18
|
import { viewBrain } from "./views/brain.js";
|
|
11
19
|
import { viewChanged } from "./views/changed.js";
|
|
12
20
|
import { viewDocs } from "./views/docs.js";
|
|
13
21
|
import { viewGraph } from "./views/graph.js";
|
|
14
22
|
import { viewHealth } from "./views/health.js";
|
|
15
|
-
import { viewInbox } from "./views/inbox.js";
|
|
23
|
+
import { viewInbox, viewPrs } from "./views/inbox.js";
|
|
16
24
|
import { viewOrg } from "./views/org.js";
|
|
17
25
|
import { viewPrompt } from "./views/prompt.js";
|
|
18
|
-
import {
|
|
26
|
+
import { viewRuns } from "./views/runs.js";
|
|
27
|
+
import { paintSetupNav, viewGettingStarted, viewSetup } from "./views/setup.js";
|
|
19
28
|
import { viewStaff } from "./views/staff.js";
|
|
20
29
|
|
|
21
30
|
const SCREEN = {
|
|
31
|
+
setup: viewGettingStarted,
|
|
22
32
|
inbox: viewInbox,
|
|
33
|
+
prs: viewPrs,
|
|
34
|
+
runs: viewRuns,
|
|
23
35
|
org: viewOrg,
|
|
24
36
|
staff: viewStaff,
|
|
25
37
|
docs: viewDocs,
|
|
@@ -41,9 +53,42 @@ const SCREEN = {
|
|
|
41
53
|
*/
|
|
42
54
|
const NEEDS = ["org", "name", "opsName", "staff"];
|
|
43
55
|
|
|
56
|
+
/** The org-wide screens in the sidebar, by button. */
|
|
57
|
+
const NAV = [
|
|
58
|
+
["#setupnav", "setup"],
|
|
59
|
+
["#inboxnav", "inbox"],
|
|
60
|
+
["#prsnav", "prs"],
|
|
61
|
+
["#runsnav", "runs"],
|
|
62
|
+
["#staffnav", "staff"],
|
|
63
|
+
["#orgnav", "org"],
|
|
64
|
+
["#docsnav", "docs"],
|
|
65
|
+
];
|
|
66
|
+
|
|
44
67
|
export async function boot() {
|
|
45
68
|
initTheme();
|
|
46
69
|
|
|
70
|
+
/* Bound before the first request rather than after it. The sidebar is markup, so it is on
|
|
71
|
+
screen and clickable while /api/org is still building the export, and a click in that
|
|
72
|
+
window used to do nothing at all: the first click on Staff after a load was simply lost.
|
|
73
|
+
Now it is remembered and honoured once there is something to render. */
|
|
74
|
+
let early = null;
|
|
75
|
+
for (const [id, view] of NAV) {
|
|
76
|
+
const b = $(id);
|
|
77
|
+
if (!b) continue;
|
|
78
|
+
b.onclick = () => {
|
|
79
|
+
if (!S.data) {
|
|
80
|
+
early = view;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
S.view = view;
|
|
84
|
+
render();
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* Before the first render, and once: it is delegated, so it covers every image any screen
|
|
89
|
+
draws from here on, including the setup screen. */
|
|
90
|
+
installLightbox();
|
|
91
|
+
|
|
47
92
|
/* Before there is a tenant the server answers every data route with `mode: setup`. That is
|
|
48
93
|
not an error state: it is the first thing a new user ever sees, and the whole page becomes
|
|
49
94
|
the thing that fixes it. */
|
|
@@ -51,6 +96,12 @@ export async function boot() {
|
|
|
51
96
|
if (first && first.mode === "setup") {
|
|
52
97
|
document.body.dataset.setup = "1";
|
|
53
98
|
$("#orgname").textContent = "no org yet";
|
|
99
|
+
/* Every one of these reads a tenant, so during setup they answer 409 and the sidebar is a
|
|
100
|
+
row of four dead ends beside an empty Staff heading. There is one thing to do on this
|
|
101
|
+
screen; the navigation comes back with the org. */
|
|
102
|
+
for (const el of document.querySelectorAll("#inboxnav, #prsnav, #runsnav, #orgnav, #staffnav, #docsnav, #refreshall, .sect, #stafflist")) {
|
|
103
|
+
el.hidden = true;
|
|
104
|
+
}
|
|
54
105
|
for (const slot of document.querySelectorAll("[data-icon]")) {
|
|
55
106
|
slot.innerHTML = iconHTML(slot.dataset.icon);
|
|
56
107
|
}
|
|
@@ -70,18 +121,23 @@ export async function boot() {
|
|
|
70
121
|
slot.innerHTML = iconHTML(slot.dataset.icon);
|
|
71
122
|
}
|
|
72
123
|
onRender(render);
|
|
124
|
+
const linked = readHash() !== null;
|
|
73
125
|
applyHash();
|
|
126
|
+
/* An org nobody has been hired into has an empty Inbox, which says nothing about what to do
|
|
127
|
+
next. It opens on what is left instead, unless a link or a click asked for somewhere else. */
|
|
128
|
+
if (early) S.view = early;
|
|
129
|
+
else if (!linked && (S.data.unfinished ?? []).includes("hire")) S.view = "setup";
|
|
130
|
+
initStaffFold();
|
|
74
131
|
paintSidebar();
|
|
75
132
|
|
|
76
|
-
$("#inboxnav").onclick = () => { S.view = "inbox"; render(); };
|
|
77
|
-
$("#staffnav").onclick = () => { S.view = "staff"; render(); };
|
|
78
|
-
$("#orgnav").onclick = () => { S.view = "org"; render(); };
|
|
79
|
-
$("#docsnav").onclick = () => { S.view = "docs"; render(); };
|
|
80
133
|
$("#refreshall").onclick = () => refreshAll(false);
|
|
81
134
|
|
|
82
|
-
|
|
135
|
+
/* Coming back to the tab after a run should show the run — and coming back to it thirty
|
|
136
|
+
seconds after leaving it should show nothing at all. Clicking into the window used to
|
|
137
|
+
re-read every repo on GitHub and repaint the screen under you; now it looks first and
|
|
138
|
+
only repaints when something actually moved. */
|
|
83
139
|
addEventListener("focus", () => {
|
|
84
|
-
if (S.loadedAt && Date.now() - S.loadedAt >
|
|
140
|
+
if (S.loadedAt && Date.now() - S.loadedAt > 60000) refreshQuietly();
|
|
85
141
|
});
|
|
86
142
|
addEventListener("keydown", (e) => {
|
|
87
143
|
if (e.key === "r" && !e.metaKey && !e.ctrlKey &&
|
|
@@ -92,6 +148,11 @@ export async function boot() {
|
|
|
92
148
|
});
|
|
93
149
|
setInterval(stampLoaded, 30000);
|
|
94
150
|
|
|
151
|
+
/* What is waiting on you is the first thing this page should be able to tell you, and it used
|
|
152
|
+
to be the one thing it would not say until you clicked Inbox. One request, shared with
|
|
153
|
+
whichever screen renders next. */
|
|
154
|
+
countInBackground();
|
|
155
|
+
|
|
95
156
|
// Only the state changes here. This used to re-run half of boot on every navigation,
|
|
96
157
|
// which stacked up a focus listener, a keydown listener and an interval each time.
|
|
97
158
|
addEventListener("hashchange", () => {
|
|
@@ -153,6 +214,42 @@ function learnPeople() {
|
|
|
153
214
|
*/
|
|
154
215
|
let paintedRoster = null;
|
|
155
216
|
|
|
217
|
+
/**
|
|
218
|
+
* The Staff heading is a fold, and folded it carries the count.
|
|
219
|
+
*
|
|
220
|
+
* It starts folded, so the count is not decoration: a bare "STAFF" heading over nothing is how
|
|
221
|
+
* a loaded org would look if the export came back empty, and those two states have to be
|
|
222
|
+
* tellable apart at a glance. The count sits on the heading rather than beside it so there is
|
|
223
|
+
* one thing to click.
|
|
224
|
+
*/
|
|
225
|
+
let staffOpen = true;
|
|
226
|
+
|
|
227
|
+
function paintStaffFold() {
|
|
228
|
+
const b = $("#stafffold");
|
|
229
|
+
if (b) b.setAttribute("aria-expanded", String(staffOpen));
|
|
230
|
+
const list = $("#stafflist");
|
|
231
|
+
if (list) list.hidden = !staffOpen;
|
|
232
|
+
const n = $("#staffcount");
|
|
233
|
+
if (n) {
|
|
234
|
+
n.textContent = "(" + (S.data?.staff?.length ?? 0) + ")";
|
|
235
|
+
n.hidden = staffOpen;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function initStaffFold() {
|
|
240
|
+
// Folded until you say otherwise: the count on the heading is enough to know the roster is
|
|
241
|
+
// there, and the four org-wide screens above it are where a session starts.
|
|
242
|
+
staffOpen = store("roster.staffopen") === "1";
|
|
243
|
+
paintStaffFold();
|
|
244
|
+
const b = $("#stafffold");
|
|
245
|
+
if (!b) return;
|
|
246
|
+
b.onclick = () => {
|
|
247
|
+
staffOpen = !staffOpen;
|
|
248
|
+
store("roster.staffopen", staffOpen ? "1" : "0");
|
|
249
|
+
paintStaffFold();
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
156
253
|
function paintSidebar() {
|
|
157
254
|
const host = $("#stafflist");
|
|
158
255
|
host.replaceChildren();
|
|
@@ -201,6 +298,9 @@ function paintSidebar() {
|
|
|
201
298
|
|
|
202
299
|
host.append(head, views);
|
|
203
300
|
}
|
|
301
|
+
|
|
302
|
+
// A refresh can hire or retire someone, and the folded heading is where that number shows.
|
|
303
|
+
paintStaffFold();
|
|
204
304
|
}
|
|
205
305
|
|
|
206
306
|
/** Which rows are lit, and which staff member is unfolded. */
|
|
@@ -229,9 +329,9 @@ function render() {
|
|
|
229
329
|
// A refresh can add or remove a staff member, and the sidebar is built once.
|
|
230
330
|
const roster = S.data.staff.map((s) => s.handle).join(" ");
|
|
231
331
|
if (roster !== paintedRoster) paintSidebar();
|
|
332
|
+
paintSetupNav();
|
|
232
333
|
markSidebar();
|
|
233
|
-
|
|
234
|
-
if (ic) ic.textContent = S.inbox ? String(openCount()) : "";
|
|
334
|
+
stampCounts();
|
|
235
335
|
writeHash(true);
|
|
236
336
|
stampLoaded();
|
|
237
337
|
|
|
@@ -7,11 +7,47 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { el, grow } from "./dom.js";
|
|
10
|
+
import { attachMentions } from "./mention.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
13
|
+
* Whether a click was on the backdrop, which is not the same question as "is the target the
|
|
14
|
+
* dialog".
|
|
15
|
+
*
|
|
16
|
+
* Picking a name from the `@` list used to close the whole dialog and throw away what you had
|
|
17
|
+
* typed. The list hides itself on mousedown so the caret survives the pick, so by the time the
|
|
18
|
+
* `click` lands the row is gone and the event retargets to the nearest thing still under the
|
|
19
|
+
* pointer, which is the dialog. A target test alone reads that as a backdrop click.
|
|
20
|
+
*
|
|
21
|
+
* So the pointer has to actually be outside the dialog's own box. `detail` of 0 is a click
|
|
22
|
+
* synthesised by the keyboard, which reports 0,0 and would otherwise look like the top corner
|
|
23
|
+
* of the page.
|
|
24
|
+
*/
|
|
25
|
+
export function onBackdrop(e, rect) {
|
|
26
|
+
if (!rect || e.currentTarget !== e.target || !e.detail) return false;
|
|
27
|
+
return (
|
|
28
|
+
e.clientX < rect.left ||
|
|
29
|
+
e.clientX > rect.right ||
|
|
30
|
+
e.clientY < rect.top ||
|
|
31
|
+
e.clientY > rect.bottom
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param decorate given the textarea, returns a node to sit under it. This is how the reply
|
|
37
|
+
* box carries its attachments: the control has to write into the box it is beside.
|
|
38
|
+
* @param allowEmpty saying nothing is an answer for some of these. Closing an issue without
|
|
39
|
+
* a parting comment is the normal case, not a cancelled dialog.
|
|
12
40
|
* @returns the text, or null if they cancelled.
|
|
13
41
|
*/
|
|
14
|
-
export function askText({
|
|
42
|
+
export function askText({
|
|
43
|
+
title,
|
|
44
|
+
hint,
|
|
45
|
+
value = "",
|
|
46
|
+
placeholder = "",
|
|
47
|
+
confirm = "Continue",
|
|
48
|
+
decorate,
|
|
49
|
+
allowEmpty = false,
|
|
50
|
+
}) {
|
|
15
51
|
const box = document.createElement("dialog");
|
|
16
52
|
// The shim in the tests has no dialog element. Falling back keeps a view renderable there
|
|
17
53
|
// rather than throwing halfway through a paint.
|
|
@@ -25,6 +61,12 @@ export function askText({ title, hint, value = "", placeholder = "", confirm = "
|
|
|
25
61
|
|
|
26
62
|
const ta = el("textarea", { value, placeholder, rows: 5 });
|
|
27
63
|
box.append(ta);
|
|
64
|
+
/* Before the ⌘⏎ handler below, so that when the `@` list is open its own Enter wins. Every
|
|
65
|
+
dialog this function opens is prose that may need to reach somebody, and a mention is how
|
|
66
|
+
it reaches them. */
|
|
67
|
+
attachMentions(ta);
|
|
68
|
+
const under = decorate?.(ta);
|
|
69
|
+
if (under) box.append(under);
|
|
28
70
|
|
|
29
71
|
const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
|
|
30
72
|
const go = el("button", { className: "ghbtn primary", textContent: confirm });
|
|
@@ -42,12 +84,13 @@ export function askText({ title, hint, value = "", placeholder = "", confirm = "
|
|
|
42
84
|
answer = text;
|
|
43
85
|
box.close();
|
|
44
86
|
};
|
|
87
|
+
const said = () => ta.value.trim() || (allowEmpty ? "" : null);
|
|
45
88
|
cancel.onclick = () => done(null);
|
|
46
|
-
go.onclick = () => done(
|
|
89
|
+
go.onclick = () => done(said());
|
|
47
90
|
ta.addEventListener("keydown", (e) => {
|
|
48
91
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
49
92
|
e.preventDefault();
|
|
50
|
-
done(
|
|
93
|
+
done(said());
|
|
51
94
|
}
|
|
52
95
|
});
|
|
53
96
|
// Escape and the backdrop both close it, and both mean no.
|
|
@@ -56,7 +99,7 @@ export function askText({ title, hint, value = "", placeholder = "", confirm = "
|
|
|
56
99
|
resolve(answer);
|
|
57
100
|
});
|
|
58
101
|
box.addEventListener("click", (e) => {
|
|
59
|
-
if (e
|
|
102
|
+
if (onBackdrop(e, box.getBoundingClientRect?.())) done(null);
|
|
60
103
|
});
|
|
61
104
|
|
|
62
105
|
document.body.append(box);
|
|
@@ -68,3 +111,50 @@ export function askText({ title, hint, value = "", placeholder = "", confirm = "
|
|
|
68
111
|
ta.setSelectionRange?.(ta.value.length, ta.value.length);
|
|
69
112
|
});
|
|
70
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Asking before something that cannot be taken back: a merge, a push, a new repo, a paid run.
|
|
117
|
+
*
|
|
118
|
+
* Native `confirm()` looked like the browser rather than this page, and it blocks the whole
|
|
119
|
+
* tab, which froze browser automation mid-run. This is the same `<dialog>` as askText, so the
|
|
120
|
+
* backdrop, Escape and the focus trap come free. The wording is the caller's: `title` is the
|
|
121
|
+
* question, `hint` says what will happen.
|
|
122
|
+
*
|
|
123
|
+
* @returns true only for the confirm button. Escape, Cancel and the backdrop are all no.
|
|
124
|
+
*/
|
|
125
|
+
export function askYes({ title, hint, confirm = "Continue" }) {
|
|
126
|
+
const box = document.createElement("dialog");
|
|
127
|
+
// The test shim has no dialog element; see askText.
|
|
128
|
+
if (typeof box.showModal !== "function") {
|
|
129
|
+
return Promise.resolve(Boolean(globalThis.confirm?.(hint ? title + "\n\n" + hint : title)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
box.className = "ask";
|
|
133
|
+
box.append(el("h3", { textContent: title }));
|
|
134
|
+
if (hint) box.append(el("p", { className: "askhint", textContent: hint }));
|
|
135
|
+
|
|
136
|
+
const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
|
|
137
|
+
const go = el("button", { className: "ghbtn primary", textContent: confirm });
|
|
138
|
+
box.append(el("div", { className: "row askrow" }, [cancel, go]));
|
|
139
|
+
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
let answer = false;
|
|
142
|
+
const done = (yes) => {
|
|
143
|
+
answer = yes;
|
|
144
|
+
box.close();
|
|
145
|
+
};
|
|
146
|
+
cancel.onclick = () => done(false);
|
|
147
|
+
go.onclick = () => done(true);
|
|
148
|
+
box.addEventListener("close", () => {
|
|
149
|
+
box.remove();
|
|
150
|
+
resolve(answer);
|
|
151
|
+
});
|
|
152
|
+
box.addEventListener("click", (e) => {
|
|
153
|
+
if (onBackdrop(e, box.getBoundingClientRect?.())) done(false);
|
|
154
|
+
});
|
|
155
|
+
document.body.append(box);
|
|
156
|
+
box.showModal();
|
|
157
|
+
// Cancel has the focus, so an Enter pressed out of habit does not merge anything.
|
|
158
|
+
cancel.focus();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -35,6 +35,31 @@ export function markCurrent(list, btn) {
|
|
|
35
35
|
btn.setAttribute("aria-current", "true");
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Placeholder shapes, for the seconds a screen spends asking GitHub.
|
|
40
|
+
*
|
|
41
|
+
* The inbox reads every repo in the org, which is several seconds on a cold cache, and what
|
|
42
|
+
* stood there meanwhile was the word "Asking GitHub…" in an otherwise empty box beside an
|
|
43
|
+
* empty box. Nothing about that says the shape of what is coming, so the page looked broken
|
|
44
|
+
* rather than busy.
|
|
45
|
+
*
|
|
46
|
+
* `kind` names the shape: "row" for a list item, "line" for a paragraph, "head" for a title.
|
|
47
|
+
* They are decorative, so they are hidden from assistive technology and the live region
|
|
48
|
+
* elsewhere on the screen does the announcing.
|
|
49
|
+
*/
|
|
50
|
+
export function skeleton(kind, n = 1) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (let i = 0; i < n; i++) {
|
|
53
|
+
const box = el("div", { className: "sk sk-" + kind });
|
|
54
|
+
box.setAttribute("aria-hidden", "true");
|
|
55
|
+
if (kind === "row") {
|
|
56
|
+
box.append(el("div", { className: "skbar t" }), el("div", { className: "skbar m" }));
|
|
57
|
+
}
|
|
58
|
+
out.push(box);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
38
63
|
export function store(k, v) {
|
|
39
64
|
try {
|
|
40
65
|
return v === undefined ? localStorage.getItem(k) : localStorage.setItem(k, v);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Icons.
|
|
2
2
|
*
|
|
3
|
-
*
|
|
3
|
+
* Lucide glyphs (lucide.dev, ISC), vendored as path data rather than pulled from
|
|
4
4
|
* a CDN or a font. The portal is local-first and offline, so an icon that needs the network
|
|
5
5
|
* is an icon that is sometimes a blank square.
|
|
6
6
|
*
|
|
@@ -53,8 +53,15 @@ const PATHS = {
|
|
|
53
53
|
"<rect width=\"20\" height=\"14\" x=\"2\" y=\"3\" rx=\"2\" /> <line x1=\"8\" x2=\"16\" y1=\"21\" y2=\"21\" /> <line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"21\" />",
|
|
54
54
|
"moon":
|
|
55
55
|
"<path d=\"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401\" />",
|
|
56
|
+
"paperclip":
|
|
57
|
+
"<path d=\"m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48\" />",
|
|
56
58
|
"person":
|
|
57
59
|
"<circle cx=\"12\" cy=\"8\" r=\"5\" /> <path d=\"M20 21a8 8 0 0 0-16 0\" />",
|
|
60
|
+
"play":
|
|
61
|
+
"<path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\" />",
|
|
62
|
+
// The pair for the lightbox's zoom controls. `dash` above is the other half.
|
|
63
|
+
"plus":
|
|
64
|
+
"<path d=\"M5 12h14\" /> <path d=\"M12 5v14\" />",
|
|
58
65
|
"refresh":
|
|
59
66
|
"<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\" /> <path d=\"M21 3v5h-5\" /> <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\" /> <path d=\"M8 16H3v5\" />",
|
|
60
67
|
"rename":
|