@lovek4d/kickflip 0.5.0 → 0.6.0
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/CONTEXT.md +105 -68
- package/README.md +1 -1
- package/SKILL.md +27 -21
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +199 -79
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -4
- package/board/on-host.mjs +1 -1
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-asks.js +6 -6
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +75 -37
- package/board/public/app-core.js +26 -8
- package/board/public/app-header.js +8 -8
- package/board/public/app-lanes.js +67 -58
- package/board/public/app-needs.js +140 -10
- package/board/public/app-reader.js +1 -1
- package/board/public/app-snapshot.js +46 -72
- package/board/public/app-threads.js +78 -75
- package/board/public/app.html +9 -4
- package/board/public/app.js +114 -38
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +66 -4
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +20 -39
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +137 -7
- package/board/public/prototype-switcher.js +152 -123
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +68 -26
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -30
- package/board/public/style.css +205 -83
- package/board/public/ui-cards.js +91 -65
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +27 -18
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +47 -52
- package/board/public/ui-sidebar.js +34 -26
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +59 -30
- package/board/public/ui.js +125 -18
- package/board/public/view-hub-settings.js +22 -67
- package/board/public/view-hub.js +19 -4
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +205 -240
- package/board/public/view-settings.js +313 -76
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -20
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -15
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +25 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +5 -5
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/card-prs.mjs +1 -1
- package/shared/fake-gh.mjs +17 -4
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/service.mjs +4 -4
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -1,31 +1,54 @@
|
|
|
1
1
|
// Board Settings, a modal the app shell (shell.js) mounts over its board for /<route>/settings.html.
|
|
2
|
-
// Saving reviewers sends a `reviewers` event {add: [name], remove: [name]}; the server
|
|
3
|
-
// redraws from its answer
|
|
4
|
-
//
|
|
2
|
+
// Saving reviewers sends a `reviewers` event {add: [{name, slack_user, github}], remove: [name]}; the server writes both
|
|
3
|
+
// itself and the list redraws from its answer. Add picks a Slack member and a GitHub login from lists the server fetches
|
|
4
|
+
// (GET api/reviewer-people), or a typed login, and stays off until both are chosen. Tickets save on each change, and Done, ✕,
|
|
5
5
|
// Escape and the backdrop wait for that save to land before closing. An unsaved edit marks [data-dirty], and the shell
|
|
6
|
-
// asks before closing the modal then.
|
|
6
|
+
// asks before closing the modal then. Sharing (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md)
|
|
7
|
+
// lists the hub's GET /_hub/share/<route>, who it's shared with, each View or Full, and who was just refused, and saves
|
|
8
|
+
// each Add, level and ✕ at once through POST api/share.
|
|
7
9
|
(globalThis.AppViews ||= {}).settings = {
|
|
8
10
|
className: 'view-settings',
|
|
9
11
|
title: () => 'Board Settings',
|
|
10
12
|
markup: (view, { siteTitle = '' } = {}) => `<dialog class="settings-modal" id="boardSettingsModal" aria-labelledby="boardSettingsTitle"><div class="settings-head"><div><h2 id="boardSettingsTitle" tabindex="-1">Board settings</h2><p class="muted small">${BoardUI.esc(siteTitle)}</p></div><button type="button" class="ghost" data-settings-dismiss aria-label="Close board settings">✕</button></div>
|
|
11
13
|
<div class="set settings-body">
|
|
14
|
+
<h2>Pause board</h2>
|
|
15
|
+
<div class="hub-view"><div class="group" id="pauseBoard"><p class="muted small block">Loading…</p></div></div>
|
|
16
|
+
<p class="muted small">Running steps finish. No new builders, planners or pulls start on this board until then, as when usage reaches the pause line.</p>
|
|
17
|
+
<p class="muted small" id="pauseBoardStatus" role="status"></p>
|
|
12
18
|
<h2>Repo</h2>
|
|
13
19
|
<button type="button" class="who repo-row" id="boardRepo" aria-haspopup="dialog" disabled><span id="boardRepoValue">Loading…</span><span class="muted small" id="boardRepoChange">Change ›</span></button>
|
|
14
20
|
<p class="muted small" id="boardRepoStatus"></p>
|
|
21
|
+
<label class="who" for="buildTarget"><span><span>Build target</span>
|
|
22
|
+
<span class="muted small sub" id="buildTargetSub">The hub runs <code>.kickflip/build</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.</span></span>
|
|
23
|
+
<select id="buildTarget" disabled><option value="">Default</option></select></label>
|
|
24
|
+
<p class="muted small" id="buildTargetStatus" role="status"></p>
|
|
15
25
|
<h2>Tickets</h2>
|
|
16
26
|
<p class="muted small">Every card on this board has a ticket from here. A new card gets a new ticket unless you name one.</p>
|
|
17
27
|
<form id="ticketsForm" class="tk-section"><div id="boardTickets">Loading…</div></form>
|
|
18
|
-
<div class="tk-inline" id="ticketsKey" hidden></div>
|
|
19
28
|
<p class="muted small" id="ticketsStatus" role="status"></p>
|
|
29
|
+
<div id="landingSection" hidden>
|
|
30
|
+
<h2>Landing</h2>
|
|
31
|
+
<p class="muted small" id="landingSub"></p>
|
|
32
|
+
<div class="tk-seg landing-seg" id="landing" role="group" aria-label="Landing">
|
|
33
|
+
<button type="button" data-landing="draft" aria-pressed="false" disabled><span>Draft PR</span><span class="small sub">Opens a draft; you mark it ready.</span></button>
|
|
34
|
+
<button type="button" data-landing="ready" aria-pressed="false" disabled><span>Ready PR</span><span class="small sub">Opens ready for review once CI passes.</span></button>
|
|
35
|
+
<button type="button" data-landing="merge" aria-pressed="false" disabled><span>Merge</span><span class="small sub">No PR; lands on the base branch.</span></button>
|
|
36
|
+
</div>
|
|
37
|
+
<p class="muted small" id="landingStatus" role="status"></p>
|
|
38
|
+
</div>
|
|
20
39
|
<h2>Auto</h2>
|
|
21
40
|
<label class="who" for="autoPull"><span><span>Auto implement Todo cards</span>
|
|
22
|
-
<span class="muted small sub">Claude starts a Todo card as soon as it’s ready and nothing blocks or holds it, while an implementer slot is free.</span></span>
|
|
41
|
+
<span class="muted small sub">Claude grills each card that lands in Todo, and starts a Todo card as soon as it’s ready and nothing blocks or holds it, while an implementer slot is free.</span></span>
|
|
23
42
|
<input type="checkbox" class="toggle" id="autoPull" disabled></label>
|
|
24
43
|
<p class="muted small" id="autoPullStatus" role="status"></p>
|
|
25
44
|
<label class="who" for="maxImplementers"><span><span>Maximum parallel implementers</span>
|
|
26
|
-
<span class="muted small sub">
|
|
45
|
+
<span class="muted small sub">Each agent that builds or runs tests counts, a card’s helpers included. Grills, read-only helpers and cards waiting in Orchestrator review or on you don’t.</span></span>
|
|
27
46
|
<input type="number" class="count" id="maxImplementers" min="1" max="99" step="1" inputmode="numeric" disabled></label>
|
|
28
47
|
<p class="muted small" id="maxImplementersStatus" role="status"></p>
|
|
48
|
+
<label class="who" for="maxPlanners"><span><span>Maximum parallel planners</span>
|
|
49
|
+
<span class="muted small sub">Grills and prototypes running at once. A card that lands in Todo past it waits its turn.</span></span>
|
|
50
|
+
<input type="number" class="count" id="maxPlanners" min="1" max="99" step="1" inputmode="numeric" disabled></label>
|
|
51
|
+
<p class="muted small" id="maxPlannersStatus" role="status"></p>
|
|
29
52
|
<label class="who" for="autoBuild"><span><span>Auto serve</span>
|
|
30
53
|
<span class="muted small sub" id="autoBuildSub">${BoardUI.esc(BoardUI.tryWords('web').autoSub)}</span></span>
|
|
31
54
|
<input type="checkbox" class="toggle" id="autoBuild" disabled></label>
|
|
@@ -34,19 +57,10 @@
|
|
|
34
57
|
<span class="muted small sub">Claude reviews teammates’ PRs that touch this board’s work. May use a lot of usage.</span></span>
|
|
35
58
|
<input type="checkbox" class="toggle" id="autoReview" disabled></label>
|
|
36
59
|
<p class="muted small" id="autoReviewStatus" role="status"></p>
|
|
37
|
-
<div id="prsRow" hidden><label class="who" for="prs"><span><span>PRs</span>
|
|
38
|
-
<span class="muted small sub">Each card opens a PR. Off, no PR opens: each card lands on main and main is pushed.</span></span>
|
|
39
|
-
<input type="checkbox" class="toggle" id="prs" disabled></label>
|
|
40
|
-
<p class="muted small" id="prsStatus" role="status"></p></div>
|
|
41
60
|
<label class="who" for="autoMerge"><span><span>Auto-merge</span>
|
|
42
|
-
<span class="muted small sub"
|
|
61
|
+
<span class="muted small sub">Claude merges a PR once it is approved, green and merges cleanly, and lands a build waiting to merge into the base branch.</span></span>
|
|
43
62
|
<input type="checkbox" class="toggle" id="autoMerge" disabled></label>
|
|
44
63
|
<p class="muted small" id="autoMergeStatus" role="status"></p>
|
|
45
|
-
<div id="skipDraftRow" hidden><label class="who" for="skipDraft"><span><span>Skip the draft</span>
|
|
46
|
-
<span class="muted small sub">A builder’s PR opens as a draft and is marked ready for review once CI passes, so it goes to reviewers without waiting for you.</span></span>
|
|
47
|
-
<input type="checkbox" class="toggle" id="skipDraft" disabled></label>
|
|
48
|
-
<p class="muted small">With Auto-merge on too, a builder’s PR can merge once reviewers approve it, without you seeing it.</p>
|
|
49
|
-
<p class="muted small" id="skipDraftStatus" role="status"></p></div>
|
|
50
64
|
<label class="who" for="autoTriage"><span><span>Triage overnight</span>
|
|
51
65
|
<span class="muted small sub">Once a night, when two or more cards wait on your answers, Claude merges their overlapping questions and duplicate cards, and tidies Todo.</span></span>
|
|
52
66
|
<input type="checkbox" class="toggle" id="autoTriage" disabled></label>
|
|
@@ -55,20 +69,27 @@
|
|
|
55
69
|
<span class="muted small sub">Pushes once when the board has been idle for 15 minutes, with what closed since the last one.</span></span>
|
|
56
70
|
<input type="checkbox" class="toggle" id="idleNotification" disabled></label>
|
|
57
71
|
<p class="muted small" id="idleNotificationStatus" role="status"></p>
|
|
58
|
-
<p class="muted small">The hub runs <code>.kickflip/build</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.</p>
|
|
59
72
|
<h2>Permissions</h2>
|
|
60
73
|
<div id="permissions"><p class="muted small">Loading…</p></div>
|
|
61
74
|
<div id="reviewersSection" hidden>
|
|
62
75
|
<h2>Reviewers</h2>
|
|
63
76
|
<p class="muted small">Everyone here gets each PR link on this board once it's ready, and the weekday digest. Someone you add also gets the links of PRs already in review.</p>
|
|
64
77
|
<div id="list"><p class="muted small">Loading…</p></div>
|
|
65
|
-
<form class="add" id="addForm">
|
|
66
|
-
<input id="
|
|
67
|
-
<
|
|
78
|
+
<form class="add pick" id="addForm">
|
|
79
|
+
<div class="pick-col"><input id="slackFilter" placeholder="Slack member" autocomplete="off"><div id="slackPeople" class="pick-list"><p class="muted small">Loading…</p></div></div>
|
|
80
|
+
<div class="pick-col"><input id="githubFilter" placeholder="GitHub login" autocomplete="off"><div id="githubPeople" class="pick-list"><p class="muted small">Loading…</p></div></div>
|
|
81
|
+
<button type="submit" id="addBtn" disabled>Add</button>
|
|
68
82
|
</form>
|
|
83
|
+
<p class="muted small" id="pickStatus" role="status"></p>
|
|
69
84
|
<div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
|
|
70
|
-
<div class="replies" id="replies"></div>
|
|
71
85
|
</div>
|
|
86
|
+
<h2>Sharing</h2>
|
|
87
|
+
<p class="muted small">View, or Full: everything but Merge and Board settings. Nothing else on the host opens for them.</p>
|
|
88
|
+
<div id="shareList"><p class="muted small">Loading…</p></div>
|
|
89
|
+
<div id="shareRefused"></div>
|
|
90
|
+
<form class="add" id="shareAdd"><input id="shareLogin" placeholder="Add by login, like name@github" autocomplete="off" autocapitalize="off" spellcheck="false"><button type="submit">Add</button></form>
|
|
91
|
+
<div class="share-reach"><p class="muted small">They also need the host shared to them in Tailscale (<a href="https://login.tailscale.com/admin/machines" target="_blank" rel="noopener">Tailscale’s Machines page ${BoardUI.LINK_ARROW}</a>). Then send them this link.</p><button type="button" id="shareCopy">Copy link</button></div>
|
|
92
|
+
<p class="muted small" id="shareStatus" role="status"></p>
|
|
72
93
|
<h2>Rules</h2>
|
|
73
94
|
<p class="muted small">Standing instructions for this board's Claude session. It sees them each time it wakes, and says what changes when you save.</p>
|
|
74
95
|
<div id="rules"><p class="muted small">Loading…</p></div>
|
|
@@ -93,8 +114,31 @@
|
|
|
93
114
|
|
|
94
115
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
95
116
|
const { $, site } = B;
|
|
96
|
-
const { esc, fetchJSON, restartNotice } = BoardUI;
|
|
97
|
-
|
|
117
|
+
const { esc, fetchJSON, restartNotice, pageCopy, copyNote, downLanding } = BoardUI;
|
|
118
|
+
// While the board can't be reached, each part reads its last answer here and the whole body turns read only under
|
|
119
|
+
// the copy line, reopening live once the board answers; a part with no copy waits out a restart, then the body shows
|
|
120
|
+
// the landing (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
|
|
121
|
+
const settingsBody = $('.settings-body');
|
|
122
|
+
let readOnly = false;
|
|
123
|
+
function fromCopy(savedAt) {
|
|
124
|
+
if (readOnly) return;
|
|
125
|
+
readOnly = true;
|
|
126
|
+
settingsBody.inert = true;
|
|
127
|
+
settingsBody.insertAdjacentHTML('afterbegin', copyNote('Settings', savedAt));
|
|
128
|
+
B.every(5000, () => { fetchJSON(site.url('api/settings'), { limit: 0 }).then(() => { if (B.alive()) ctx.reload(); }, () => {}); });
|
|
129
|
+
}
|
|
130
|
+
function down(e) {
|
|
131
|
+
if (readOnly || root.classList.contains('down')) return;
|
|
132
|
+
root.classList.add('down');
|
|
133
|
+
settingsBody.insertAdjacentHTML('afterbegin', downLanding(e.down));
|
|
134
|
+
}
|
|
135
|
+
const saved = (url, r) => { if (r.ok) pageCopy.save(url, r.body); return r; };
|
|
136
|
+
async function get(url, onWait) {
|
|
137
|
+
try { return saved(url, await fetchJSON(url, { limit: 0, sleep: B.sleep })); } catch {}
|
|
138
|
+
const c = await pageCopy.read(url);
|
|
139
|
+
if (c) { fromCopy(c.savedAt); return { ok: true, status: 200, body: c.body }; }
|
|
140
|
+
try { return saved(url, await fetchJSON(url, { onWait, sleep: B.sleep })); } catch (e) { down(e); throw e; }
|
|
141
|
+
}
|
|
98
142
|
const waitIn = (sel) => () => { $(sel).innerHTML = restartNotice(); };
|
|
99
143
|
const modal = $('#boardSettingsModal');
|
|
100
144
|
const previous = document.activeElement;
|
|
@@ -186,15 +230,25 @@
|
|
|
186
230
|
const repoURL = `/_hub/board-repo/${encodeURIComponent(B.route)}`;
|
|
187
231
|
let repo = {}, closeRepo = () => {};
|
|
188
232
|
let tickets, savedTickets, savingTickets = null, ticketsTouched = false;
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
233
|
+
// Landing shows on a board on GitHub that builds from its shared clone and on the hub board, which builds from its
|
|
234
|
+
// folder, starts on Merge and opens PRs on Draft PR or Ready PR; any other local board never opens PRs. Its choice is
|
|
235
|
+
// the board's prs and skip_draft settings read together: Draft PR, prs on and skip_draft off; Ready PR, both on;
|
|
236
|
+
// Merge, prs off. Reviewers show only while cards open PRs
|
|
237
|
+
// (docs/adr/2026-11-05-0200-landing-is-one-choice-draft-pr-ready-pr-or-merge.md).
|
|
238
|
+
const LANDING = {
|
|
239
|
+
draft: { set: { prs: true, skip_draft: false }, sub: 'Each card opens a draft PR and waits for you to mark it ready for review.', saved: 'Saved. Each card opens a draft PR.' },
|
|
240
|
+
ready: { set: { prs: true, skip_draft: true }, sub: 'Each card opens a PR ready for review, without waiting for you.', saved: 'Saved. Each card opens a PR ready for review.' },
|
|
241
|
+
merge: { set: { prs: false }, sub: 'No PR opens: each card lands on the base branch and the base branch is pushed.', saved: 'Saved. Each card lands on the base branch with no PR, and the base branch is pushed.' },
|
|
242
|
+
};
|
|
243
|
+
let landing = 'draft';
|
|
244
|
+
const landingButtons = () => Object.keys(LANDING).map((value) => $(`[data-landing="${value}"]`));
|
|
245
|
+
const landingOf = (settings) => (settings?.prs === false ? 'merge' : settings?.skip_draft === true ? 'ready' : 'draft');
|
|
246
|
+
const opensPRs = () => Boolean(repo.github) && (!repo.local || Boolean(repo.hub)) && landing !== 'merge';
|
|
194
247
|
const syncPRs = () => {
|
|
195
|
-
$('#
|
|
196
|
-
$('#
|
|
197
|
-
$('#
|
|
248
|
+
$('#landingSection').hidden = !repo.github || (Boolean(repo.local) && !repo.hub);
|
|
249
|
+
$('#reviewersSection').hidden = !opensPRs();
|
|
250
|
+
$('#landingSub').textContent = LANDING[landing].sub;
|
|
251
|
+
for (const button of landingButtons()) button.setAttribute('aria-pressed', String(button === $(`[data-landing="${landing}"]`)));
|
|
198
252
|
};
|
|
199
253
|
const showRepo = () => {
|
|
200
254
|
$('#boardRepoValue').textContent = repo.github || repo.remote ? `${repo.github || repo.remote} · ${repo.branch || 'Branch not recorded'}` : 'No GitHub repo';
|
|
@@ -218,6 +272,7 @@
|
|
|
218
272
|
tickets?.render();
|
|
219
273
|
if (tickets) syncTickets();
|
|
220
274
|
showRepo();
|
|
275
|
+
loadBuildTargets();
|
|
221
276
|
$('#boardRepoStatus').textContent = 'Saved. The board’s session has been told.';
|
|
222
277
|
} });
|
|
223
278
|
});
|
|
@@ -276,11 +331,12 @@
|
|
|
276
331
|
B.on(ticketsForm, 'submit', (e) => { e.preventDefault(); saveTickets(); });
|
|
277
332
|
let buildKind = 'web';
|
|
278
333
|
let current = [];
|
|
279
|
-
|
|
334
|
+
let githubOf = {};
|
|
335
|
+
const add = new Map();
|
|
280
336
|
const remove = new Set();
|
|
281
337
|
function render() {
|
|
282
|
-
const rows = [...current.map((n) => ({ n, cls: remove.has(n) ? 'gone' : '' })), ...[...add].map((
|
|
283
|
-
$('#list').innerHTML = rows.length ? rows.map(({ n, cls }) => `<div class="who ${cls}"><span>${esc(n)}${cls === 'new' ? ' <span class="muted small">new</span>' : ''}</span>
|
|
338
|
+
const rows = [...current.map((n) => ({ n, cls: remove.has(n) ? 'gone' : '', github: githubOf[n] })), ...[...add.values()].map((a) => ({ n: a.name, cls: 'new', github: a.github }))];
|
|
339
|
+
$('#list').innerHTML = rows.length ? rows.map(({ n, cls, github }) => `<div class="who ${cls}"><span>${esc(n)}${cls === 'new' ? ' <span class="muted small">new</span>' : ''}${github ? `<span class="muted small sub">github: ${esc(github)}</span>` : ''}</span>
|
|
284
340
|
<button class="ghost" data-name="${esc(n)}" aria-label="${cls === 'gone' ? 'Keep' : 'Remove'} ${esc(n)}">${cls === 'gone' ? 'Undo' : '✕'}</button></div>`).join('') : '<p class="muted small">No reviewers yet.</p>';
|
|
285
341
|
$('#save').disabled = !add.size && !remove.size;
|
|
286
342
|
if (add.size || remove.size) $('#list').setAttribute('data-dirty', '');
|
|
@@ -292,6 +348,7 @@
|
|
|
292
348
|
buildKind = d.kind || 'web';
|
|
293
349
|
$('#autoBuildSub').textContent = BoardUI.tryWords(buildKind).autoSub;
|
|
294
350
|
current = d.reviewers || [];
|
|
351
|
+
githubOf = d.reviewerGithub || {};
|
|
295
352
|
render();
|
|
296
353
|
}).catch(() => { if (B.alive()) $('#list').innerHTML = '<p class="muted">Offline. Is the host awake?</p>'; });
|
|
297
354
|
|
|
@@ -304,39 +361,103 @@
|
|
|
304
361
|
else remove.add(n);
|
|
305
362
|
render();
|
|
306
363
|
});
|
|
364
|
+
|
|
365
|
+
// The pickers: tap a Slack member and a GitHub login (or type one), then Add stages them for Save.
|
|
366
|
+
let people = { slack: [], github: [] };
|
|
367
|
+
const picked = { slack: null, github: '' };
|
|
368
|
+
const fits = (text, filter) => text.toLowerCase().includes(filter.trim().toLowerCase());
|
|
369
|
+
function renderPick() {
|
|
370
|
+
const slackFilter = $('#slackFilter').value, githubFilter = $('#githubFilter').value.trim();
|
|
371
|
+
const taken = new Set([...current, ...add.keys()].map((n) => n.toLowerCase()));
|
|
372
|
+
const slack = (people.slack || []).filter((p) => fits(p.name, slackFilter) && !taken.has(p.name.toLowerCase()));
|
|
373
|
+
$('#slackPeople').innerHTML = people.slack ? slack.map((p) => `<button type="button" class="ghost" data-slack="${esc(p.id)}" data-name="${esc(p.name)}" aria-pressed="${picked.slack?.id === p.id}">${esc(p.name)}</button>`).join('') || '<p class="muted small">No one matches.</p>'
|
|
374
|
+
: `<p class="muted small">${esc(people.slackProblem || 'Slack members are not available.')} <a href="/_hub/settings">Hub settings</a></p>`;
|
|
375
|
+
const logins = people.github.filter((l) => fits(l, githubFilter));
|
|
376
|
+
const typed = githubFilter && !people.github.some((l) => l.toLowerCase() === githubFilter.toLowerCase()) ? [githubFilter] : [];
|
|
377
|
+
$('#githubPeople').innerHTML = [...typed, ...logins].map((l) => `<button type="button" class="ghost" data-github="${esc(l)}" aria-pressed="${picked.github === l}">${typed.includes(l) ? `Use ${esc(l)}` : esc(l)}</button>`).join('')
|
|
378
|
+
+ (people.githubProblem ? `<p class="muted small">${esc(people.githubProblem)}</p>` : '');
|
|
379
|
+
$('#addBtn').disabled = !picked.slack || !picked.github;
|
|
380
|
+
}
|
|
381
|
+
get(site.url('api/reviewer-people'), waitIn('#slackPeople')).then(({ body }) => { if (!B.alive()) return; people = { github: [], ...body }; renderPick(); })
|
|
382
|
+
.catch(() => { if (B.alive()) $('#slackPeople').innerHTML = '<p class="muted small">Offline. Is the host awake?</p>'; });
|
|
383
|
+
B.on($('#slackFilter'), 'input', renderPick);
|
|
384
|
+
B.on($('#githubFilter'), 'input', renderPick);
|
|
385
|
+
B.on($('#slackPeople'), 'click', (ev) => {
|
|
386
|
+
const b = ev.target.closest('button[data-slack]');
|
|
387
|
+
if (b) { picked.slack = { id: b.dataset.slack, name: b.dataset.name }; renderPick(); }
|
|
388
|
+
});
|
|
389
|
+
B.on($('#githubPeople'), 'click', (ev) => {
|
|
390
|
+
const b = ev.target.closest('button[data-github]');
|
|
391
|
+
if (b) { picked.github = b.dataset.github; renderPick(); }
|
|
392
|
+
});
|
|
307
393
|
B.on($('#addForm'), 'submit', (ev) => {
|
|
308
394
|
ev.preventDefault();
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
395
|
+
if (!picked.slack || !picked.github) return;
|
|
396
|
+
add.set(picked.slack.name, { name: picked.slack.name, slack_user: picked.slack.id, github: picked.github });
|
|
397
|
+
picked.slack = null; picked.github = '';
|
|
398
|
+
$('#slackFilter').value = ''; $('#githubFilter').value = '';
|
|
399
|
+
renderPick();
|
|
313
400
|
render();
|
|
314
401
|
});
|
|
315
402
|
B.on($('#save'), 'click', async () => {
|
|
316
403
|
const status = $('#saveStatus');
|
|
317
404
|
try {
|
|
318
|
-
const out = await BoardUI.waitOn($('#save'), site.send('reviewers', { add: [...add], remove: [...remove] }));
|
|
319
|
-
status.textContent =
|
|
405
|
+
const out = await BoardUI.waitOn($('#save'), site.send('reviewers', { add: [...add.values()], remove: [...remove] }));
|
|
406
|
+
status.textContent = 'Saved';
|
|
320
407
|
current = out.board.reviewers;
|
|
321
|
-
|
|
408
|
+
githubOf = out.board.reviewerGithub || {};
|
|
409
|
+
add.clear(); remove.clear(); render(); renderPick();
|
|
322
410
|
} catch (e) { status.textContent = e?.reason || 'Could not send. Is the host awake?'; }
|
|
323
411
|
});
|
|
324
|
-
B.watch((events) => {
|
|
325
|
-
$('#replies').innerHTML = events.map((e) => `<div class="reply">${e.reply ? (e.reply.html ?? '') : '<span class="muted small">Waiting for Claude…</span>'}</div>`).join('');
|
|
326
|
-
}, { kind: 'reviewers' });
|
|
327
412
|
|
|
328
413
|
// The toggle saves at once through POST api/settings, which writes board.json itself, so it takes effect with no
|
|
329
414
|
// session awake (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md).
|
|
330
415
|
const autoMerge = $('#autoMerge');
|
|
331
416
|
const autoMergeStatus = $('#autoMergeStatus');
|
|
332
|
-
|
|
417
|
+
const savedCaps = {};
|
|
418
|
+
let buildTarget = null, buildTargets = null;
|
|
419
|
+
// Pause board (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md): the hub's duration
|
|
420
|
+
// picker (hub-kit.js) with the Hold sheet's chips, Forever included, posting {pause} to api/pause at once. A Forever
|
|
421
|
+
// pause lasts until Resume.
|
|
422
|
+
const pauseBox = $('#pauseBoard');
|
|
423
|
+
const pickers = HubKit.durationPickers(B);
|
|
424
|
+
let pauseChoices = [];
|
|
425
|
+
const pausedWords = (end, now) => (end === 'forever' ? 'until you resume' : `until ${BoardUI.heldUntilWords(end, now)}`);
|
|
426
|
+
function paintPause(pausedUntil) {
|
|
427
|
+
const now = new Date();
|
|
428
|
+
const choices = pauseChoices.map((c) => ({ value: c.value, label: c.label, until: pausedWords(c.ms === null ? 'forever' : new Date(now.getTime() + c.ms), now) }));
|
|
429
|
+
pauseBox.innerHTML = HubKit.durationPicker({ id: 'pauseBoard', title: 'Pause board', icon: '', action: site.url('api/pause'), name: 'pause', button: 'Resume',
|
|
430
|
+
choices: pausedUntil ? null : choices, on: pausedUntil ? { title: `Paused ${pausedWords(pausedUntil === 'forever' ? 'forever' : new Date(pausedUntil), now)}` } : null });
|
|
431
|
+
pickers.painted();
|
|
432
|
+
}
|
|
433
|
+
B.on(pauseBox, 'submit', async (e) => {
|
|
434
|
+
e.preventDefault();
|
|
435
|
+
const form = e.target;
|
|
436
|
+
const pause = e.submitter?.value ?? form.querySelector('input[type="hidden"]')?.value;
|
|
437
|
+
const status = $('#pauseBoardStatus');
|
|
438
|
+
try {
|
|
439
|
+
const out = await BoardUI.waitOn(e.submitter || form.querySelector('input:not([type="hidden"])'), (async () => {
|
|
440
|
+
const r = await fetch(site.url('api/pause'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pause }) });
|
|
441
|
+
const body = await r.json().catch(() => ({}));
|
|
442
|
+
if (!r.ok) throw new Error(body.error);
|
|
443
|
+
return body;
|
|
444
|
+
})(), { group: pauseBox.querySelectorAll('button') });
|
|
445
|
+
if (!B.alive()) return;
|
|
446
|
+
paintPause(out.paused_until);
|
|
447
|
+
status.textContent = out.paused_until ? 'Paused. New work on this board waits.' : 'Resumed. New work starts again.';
|
|
448
|
+
ctx.sidebarChanged?.();
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (B.alive()) status.textContent = error.message || 'Could not save. Is the host awake?';
|
|
451
|
+
}
|
|
452
|
+
});
|
|
333
453
|
get(site.url('api/settings'), waitIn('#autoMergeStatus')).then(({ body: d }) => {
|
|
454
|
+
pauseChoices = d.pause_choices || [];
|
|
455
|
+
paintPause(d.paused_until);
|
|
334
456
|
if (!B.alive()) return;
|
|
335
457
|
savedTickets = d.settings?.tickets || null;
|
|
336
458
|
tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets || { source: 'internal', prefix: 'INT' }, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
|
|
337
459
|
loadLinear: async () => (await get('/_hub/api/linear')).body,
|
|
338
|
-
keyState: async () => (await get('/_hub/api/settings')).body.linear,
|
|
339
|
-
keyBusy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on),
|
|
460
|
+
keyState: async () => (await get('/_hub/api/settings')).body.linear,
|
|
340
461
|
change(_value, _valid, { loaded, typing } = {}) {
|
|
341
462
|
if (!loaded) ticketsTouched = true;
|
|
342
463
|
if (typing) syncTickets(); else saveTickets();
|
|
@@ -351,34 +472,36 @@
|
|
|
351
472
|
autoMerge.disabled = false;
|
|
352
473
|
$('#autoBuild').checked = d.settings?.auto_build === true;
|
|
353
474
|
$('#autoBuild').disabled = false;
|
|
475
|
+
buildTarget = d.settings?.build_target || '';
|
|
476
|
+
showBuildTargets();
|
|
354
477
|
$('#autoPull').checked = d.settings?.auto_pull === true;
|
|
355
478
|
$('#autoPull').disabled = false;
|
|
356
|
-
|
|
357
|
-
|
|
479
|
+
for (const [id, key] of [['maxImplementers', 'max_implementers'], ['maxPlanners', 'max_planners']]) {
|
|
480
|
+
$(`#${id}`).value = savedCaps[key] = String(d.settings?.[key]);
|
|
481
|
+
$(`#${id}`).disabled = false;
|
|
482
|
+
}
|
|
358
483
|
$('#autoTriage').checked = d.settings?.auto_triage === true;
|
|
359
484
|
$('#autoTriage').disabled = false;
|
|
360
485
|
$('#autoReview').checked = d.settings?.auto_review === true;
|
|
361
486
|
$('#autoReview').disabled = false;
|
|
362
487
|
$('#idleNotification').checked = d.settings?.idle_notification === true;
|
|
363
488
|
$('#idleNotification').disabled = false;
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
$('#prs').checked = prsSetting = d.settings?.prs !== false;
|
|
367
|
-
$('#prs').disabled = false;
|
|
489
|
+
landing = landingOf(d.settings);
|
|
490
|
+
for (const button of landingButtons()) button.disabled = false;
|
|
368
491
|
syncPRs();
|
|
369
492
|
}).catch(() => { autoMergeStatus.textContent = ticketsStatus.textContent = 'Offline. Is the host awake?'; });
|
|
370
493
|
// An Auto toggle keeps its face, disabled with a spinner beside it (ui.js waitOn), while POST api/settings saves it.
|
|
371
494
|
// → the saved settings
|
|
372
|
-
const saveToggle = (toggle, body) => BoardUI.waitOn(toggle, (async () => {
|
|
495
|
+
const saveToggle = (toggle, body, wait) => BoardUI.waitOn(toggle, (async () => {
|
|
373
496
|
const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
374
497
|
const out = await r.json().catch(() => ({}));
|
|
375
498
|
if (!r.ok) throw new Error(out.error);
|
|
376
499
|
return out.settings;
|
|
377
|
-
})());
|
|
500
|
+
})(), wait);
|
|
378
501
|
B.on(autoMerge, 'change', async () => {
|
|
379
502
|
const want = autoMerge.checked;
|
|
380
503
|
const ask = opensPRs() ? 'Turn on auto-merge? Claude merges each approved PR on this board once CI is green and it merges cleanly, without asking you.'
|
|
381
|
-
: 'Turn on auto-merge? Each verified card lands on
|
|
504
|
+
: 'Turn on auto-merge? Each verified card lands on the base branch without asking you.';
|
|
382
505
|
if (want && !(await BoardDialogs.confirm(ask))) {
|
|
383
506
|
autoMerge.checked = false;
|
|
384
507
|
return;
|
|
@@ -387,13 +510,50 @@
|
|
|
387
510
|
try {
|
|
388
511
|
autoMerge.checked = (await saveToggle(autoMerge, { auto_merge: want })).auto_merge === true;
|
|
389
512
|
autoMergeStatus.textContent = opensPRs() ? (autoMerge.checked ? 'Saved. Claude merges approved, green PRs itself.' : 'Saved. Approved, green PRs wait for your Merge.')
|
|
390
|
-
: autoMerge.checked ? 'Saved. Each verified card lands on
|
|
513
|
+
: autoMerge.checked ? 'Saved. Each verified card lands on the base branch by itself.' : 'Saved. Each verified card waits for your Merge.';
|
|
391
514
|
} catch (e) {
|
|
392
515
|
autoMerge.checked = !want;
|
|
393
516
|
autoMergeStatus.textContent = e.message || 'Could not save. Is the host awake?';
|
|
394
517
|
}
|
|
395
518
|
});
|
|
396
519
|
|
|
520
|
+
// Build target lists the repo's .kickflip/build-* scripts at main's tip and saves a pick at once, null for the
|
|
521
|
+
// default .kickflip/build (docs/adr/2026-11-05-1823-board-settings-picks-which-build-script-a-board-runs.md).
|
|
522
|
+
// A saved target the tip no longer has stays listed, marked, so the picker shows what the hub runs.
|
|
523
|
+
const buildScriptOf = (target) => (target ? `.kickflip/build-${target}` : '.kickflip/build');
|
|
524
|
+
const buildTargetSub = (target) => `The hub runs <code>${esc(buildScriptOf(target))}</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.`;
|
|
525
|
+
const showBuildTargets = () => {
|
|
526
|
+
if (buildTarget === null || buildTargets === null) return;
|
|
527
|
+
const select = $('#buildTarget');
|
|
528
|
+
const names = buildTargets.includes(buildTarget) || !buildTarget ? buildTargets : [...buildTargets, buildTarget];
|
|
529
|
+
select.innerHTML = [['', 'Default'], ...names.map((t) => [t, buildTargets.includes(t) ? t : `${t} (not on main)`])]
|
|
530
|
+
.map(([value, label]) => `<option value="${esc(value)}"${value && value === buildTarget ? ' selected' : ''}>${esc(label)}</option>`).join('');
|
|
531
|
+
select.value = buildTarget;
|
|
532
|
+
select.disabled = false;
|
|
533
|
+
$('#buildTargetSub').innerHTML = buildTargetSub(buildTarget);
|
|
534
|
+
};
|
|
535
|
+
// A new repo brings its own scripts, so a repo save lists them again.
|
|
536
|
+
const loadBuildTargets = () => get(site.url('api/build-targets')).then(({ body }) => {
|
|
537
|
+
if (!B.alive()) return;
|
|
538
|
+
buildTargets = body.targets;
|
|
539
|
+
showBuildTargets();
|
|
540
|
+
}).catch(() => { $('#buildTargetStatus').textContent = 'Could not list the repo’s build scripts. Is the host awake?'; });
|
|
541
|
+
loadBuildTargets();
|
|
542
|
+
B.on($('#buildTarget'), 'change', async () => {
|
|
543
|
+
const select = $('#buildTarget');
|
|
544
|
+
try {
|
|
545
|
+
const settings = await saveToggle(select, { build_target: select.value || null });
|
|
546
|
+
if (!B.alive()) return;
|
|
547
|
+
buildTarget = settings.build_target || '';
|
|
548
|
+
showBuildTargets();
|
|
549
|
+
$('#buildTargetStatus').textContent = `Saved. The hub runs ${buildScriptOf(buildTarget)} for each card and main.`;
|
|
550
|
+
} catch (e) {
|
|
551
|
+
if (!B.alive()) return;
|
|
552
|
+
select.value = buildTarget;
|
|
553
|
+
$('#buildTargetStatus').textContent = e.message || 'Could not save. Is the host awake?';
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
397
557
|
B.on($('#autoBuild'), 'change', async () => {
|
|
398
558
|
const toggle = $('#autoBuild');
|
|
399
559
|
const want = toggle.checked;
|
|
@@ -427,41 +587,109 @@
|
|
|
427
587
|
$(`#${id}Status`).textContent = e.message || 'Could not save. Is the host awake?';
|
|
428
588
|
}
|
|
429
589
|
});
|
|
430
|
-
autoToggle('autoPull', 'auto_pull', (on) => (on ? 'Saved. Claude starts ready
|
|
590
|
+
autoToggle('autoPull', 'auto_pull', (on) => (on ? 'Saved. Claude grills each card that lands in Todo and starts ready ones while an implementer slot is free.' : 'Saved. Todo cards wait for you.'));
|
|
431
591
|
|
|
432
|
-
// docs/adr/2026-11-04-0057-auto-implement-fills-in-progress-up-to-a-cap.md: Maximum parallel implementers
|
|
592
|
+
// docs/adr/2026-11-04-0057-auto-implement-fills-in-progress-up-to-a-cap.md: Maximum parallel implementers, and
|
|
593
|
+
// Maximum parallel planners (docs/adr/2026-11-04-2359-every-card-arriving-in-todo-is-grilled-by-the-board.md), save on
|
|
433
594
|
// change, waiting on the server like a toggle; a number outside 1 to 99 or a refusal puts the saved one back.
|
|
434
|
-
B.on($(
|
|
435
|
-
const input = $(
|
|
436
|
-
const status = $(
|
|
595
|
+
const capInput = (id, key, saidOf) => B.on($(`#${id}`), 'change', async () => {
|
|
596
|
+
const input = $(`#${id}`);
|
|
597
|
+
const status = $(`#${id}Status`);
|
|
437
598
|
const want = /^\d+$/.test(input.value) ? Number(input.value) : 0;
|
|
438
|
-
if (want < 1 || want > 99) { input.value =
|
|
599
|
+
if (want < 1 || want > 99) { input.value = savedCaps[key]; status.textContent = 'Pick a whole number from 1 to 99.'; return; }
|
|
439
600
|
try {
|
|
440
|
-
const settings = await saveToggle(input, {
|
|
601
|
+
const settings = await saveToggle(input, { [key]: want });
|
|
441
602
|
if (!B.alive()) return;
|
|
442
|
-
input.value =
|
|
443
|
-
status.textContent =
|
|
603
|
+
input.value = savedCaps[key] = String(settings[key]);
|
|
604
|
+
status.textContent = saidOf(settings[key]);
|
|
444
605
|
} catch (e) {
|
|
445
606
|
if (!B.alive()) return;
|
|
446
|
-
input.value =
|
|
607
|
+
input.value = savedCaps[key];
|
|
447
608
|
status.textContent = e.message || 'Could not save. Is the host awake?';
|
|
448
609
|
}
|
|
449
610
|
});
|
|
611
|
+
capInput('maxImplementers', 'max_implementers', (n) => `Saved. Up to ${n === 1 ? '1 agent builds' : `${n} agents build`} at once.`);
|
|
612
|
+
capInput('maxPlanners', 'max_planners', (n) => `Saved. Up to ${n === 1 ? '1 grill or prototype runs' : `${n} grills or prototypes run`} at once.`);
|
|
450
613
|
autoToggle('autoReview', 'auto_review', (on) => (on ? 'Saved. When idle, Claude reviews incoming PRs that touch this board’s work.' : 'Saved. Claude leaves incoming PRs alone.'));
|
|
451
614
|
autoToggle('idleNotification', 'idle_notification', (on) => (on ? 'Saved. One push once the board has been idle for 15 minutes.' : 'Saved. No push when the board goes idle.'));
|
|
452
615
|
autoToggle('autoTriage', 'auto_triage', (on) => (on ? 'Saved. Once a night, when cards wait on your answers, Claude triages them.' : 'Saved. Questions and Todo are left as they are.'));
|
|
453
|
-
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
616
|
+
// A Landing tap saves at once with no confirmation, its choice's settings in one POST. Merge needs Push main after a
|
|
617
|
+
// land, which the server checks; its refusal shows in the status line and the saved choice stays pressed.
|
|
618
|
+
for (const want of Object.keys(LANDING)) B.on($(`[data-landing="${want}"]`), 'click', async () => {
|
|
619
|
+
const button = $(`[data-landing="${want}"]`);
|
|
620
|
+
if (want === landing) return;
|
|
621
|
+
try {
|
|
622
|
+
const settings = await saveToggle(button, LANDING[want].set, { group: landingButtons() });
|
|
623
|
+
if (!B.alive()) return;
|
|
624
|
+
landing = landingOf(settings);
|
|
625
|
+
$('#landingStatus').textContent = LANDING[landing].saved;
|
|
626
|
+
} catch (err) {
|
|
627
|
+
if (!B.alive()) return;
|
|
628
|
+
$('#landingStatus').textContent = err.message || 'Could not save. Is the host awake?';
|
|
629
|
+
}
|
|
457
630
|
syncPRs();
|
|
458
|
-
return on ? 'Saved. Each card opens a PR.' : 'Saved. Each card lands on main with no PR, and main is pushed.';
|
|
459
631
|
});
|
|
460
632
|
|
|
461
633
|
// Each switch writes or removes one allow rule in the board folder's .claude/settings.local.json through POST
|
|
462
634
|
// api/permissions (docs/adr/2026-11-03-1921-board-settings-grant-a-boards-session-permissions.md); the hub lets only
|
|
463
635
|
// the owner reach the board (docs/adr/2026-11-04-2156-the-whole-hub-answers-only-the-owner.md). A tap waits on the server with the
|
|
464
636
|
// switch as it was and a spinner beside it, then draws the server's answer.
|
|
637
|
+
// Sharing: each row is the person's name, then their login and when; a login Tailscale sent no name with is its own.
|
|
638
|
+
// A person's row also sets them View or Full with one tap, saying what that level can do
|
|
639
|
+
// (docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md).
|
|
640
|
+
let sharing = null;
|
|
641
|
+
const CAN = { view: 'Looks only. Visit links open.', full: 'Answers, taps card buttons and adds New tasks. Merge and Board settings stay yours.' };
|
|
642
|
+
const shareRow = (title, sub, button, more = '') => `<div class="who"><span>${esc(title)}<span class="muted small sub">${sub}</span>${more}</span>${button}</div>`;
|
|
643
|
+
const shareLevel = (p) => `<span role="group" class="share-level" aria-label="${esc(p.name || p.login)}’s access">${[['view', 'View'], ['full', 'Full']].map(([level, label]) =>
|
|
644
|
+
`<button type="button" data-share-level="${level}" data-login="${esc(p.login)}" aria-pressed="${p.level === level}">${label}</button>`).join('')}</span><span class="muted small sub">${CAN[p.level]}</span>`;
|
|
645
|
+
const shareWhen = (p) => (p.now ? '<span class="share-now">viewing now</span>' : esc(p.at ? BoardUI.since(p.at) : 'not seen yet'));
|
|
646
|
+
const shareSub = (p, when) => (p.name ? `${esc(p.login)} · ${when}` : when);
|
|
647
|
+
function drawSharing() {
|
|
648
|
+
$('#shareList').innerHTML = sharing.people.length ? sharing.people.map((p) => shareRow(p.name || p.login, shareSub(p, shareWhen(p)),
|
|
649
|
+
`<button type="button" class="ghost" data-share-remove="${esc(p.login)}" aria-label="Remove ${esc(p.name || p.login)}">✕</button>`, shareLevel(p))).join('') : '<p class="muted small">Nobody yet.</p>';
|
|
650
|
+
$('#shareRefused').innerHTML = sharing.refused.length ? `<p class="muted small">Just refused. Tap to share with them:</p>${sharing.refused.map((r) => shareRow(r.name || r.login, shareSub(r, esc(BoardUI.since(r.at))),
|
|
651
|
+
`<button type="button" data-share-add="${esc(r.login)}">Add</button>`)).join('')}` : '';
|
|
652
|
+
}
|
|
653
|
+
const loadSharing = () => get(`/_hub/share/${encodeURIComponent(route)}`).then(({ ok, body }) => {
|
|
654
|
+
if (!B.alive()) return;
|
|
655
|
+
if (!ok) { $('#shareList').innerHTML = `<p class="muted small">${esc(body.error || 'Could not load sharing.')}</p>`; return; }
|
|
656
|
+
sharing = body;
|
|
657
|
+
drawSharing();
|
|
658
|
+
}).catch(() => { if (B.alive()) $('#shareList').innerHTML = '<p class="muted">Offline. Is the host awake?</p>'; });
|
|
659
|
+
async function saveShare(change) {
|
|
660
|
+
$('#shareStatus').textContent = '';
|
|
661
|
+
try {
|
|
662
|
+
const r = await fetch(site.url('api/share'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(change) });
|
|
663
|
+
const out = await r.json().catch(() => ({}));
|
|
664
|
+
if (!r.ok) throw new Error(out.error);
|
|
665
|
+
await loadSharing();
|
|
666
|
+
return true;
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if (B.alive()) $('#shareStatus').textContent = error.message || 'Could not save. Is the host awake?';
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
loadSharing();
|
|
673
|
+
B.on($('#shareList'), 'click', (e) => {
|
|
674
|
+
const b = e.target.closest?.('[data-share-remove]');
|
|
675
|
+
if (b) { saveShare({ remove: b.dataset.shareRemove }); return; }
|
|
676
|
+
const level = e.target.closest?.('[data-share-level]');
|
|
677
|
+
if (level) saveShare({ login: level.dataset.login, level: level.dataset.shareLevel });
|
|
678
|
+
});
|
|
679
|
+
B.on($('#shareRefused'), 'click', (e) => { const b = e.target.closest?.('[data-share-add]'); if (b) saveShare({ add: b.dataset.shareAdd }); });
|
|
680
|
+
B.on($('#shareAdd'), 'submit', async (e) => {
|
|
681
|
+
e.preventDefault?.();
|
|
682
|
+
const input = $('#shareLogin');
|
|
683
|
+
const login = input.value.trim();
|
|
684
|
+
if (!login) { $('#shareStatus').textContent = 'Type a login first, like name@github.'; return; }
|
|
685
|
+
if (await saveShare({ add: login }) && B.alive()) input.value = '';
|
|
686
|
+
});
|
|
687
|
+
B.on($('#shareCopy'), 'click', async () => {
|
|
688
|
+
const link = sharing?.link || new URL(`/${encodeURIComponent(route)}/`, location.href).href;
|
|
689
|
+
try { await navigator.clipboard.writeText(link); $('#shareStatus').textContent = `Copied ${link}`; }
|
|
690
|
+
catch { $('#shareStatus').textContent = link; }
|
|
691
|
+
});
|
|
692
|
+
|
|
465
693
|
const permissions = $('#permissions');
|
|
466
694
|
const REFUSED = {
|
|
467
695
|
denied: (command) => `Auto mode still refused this once: ${command}. Approve it under /permissions, Recently blocked.`,
|
|
@@ -475,6 +703,12 @@
|
|
|
475
703
|
const drawPermissions = (d) => {
|
|
476
704
|
permissions.innerHTML = d.permissions.map(permRow).join('');
|
|
477
705
|
};
|
|
706
|
+
// A switch whose saved face no answer has drawn in BoardUI's PENDING_LATE_MS: the switches load once and rest.
|
|
707
|
+
function stillWaiting() {
|
|
708
|
+
if (!B.alive()) return;
|
|
709
|
+
B.toast('Still waiting on the board');
|
|
710
|
+
get(site.url('api/permissions')).then(({ ok, body }) => { if (ok && B.alive()) drawPermissions(body); }, () => {});
|
|
711
|
+
}
|
|
478
712
|
get(site.url('api/permissions'), waitIn('#permissions')).then(({ ok, body }) => {
|
|
479
713
|
if (!B.alive()) return;
|
|
480
714
|
if (!ok) { permissions.innerHTML = `<p class="muted small">${esc(body.error || 'Could not load permissions.')}</p>`; return; }
|
|
@@ -486,14 +720,17 @@
|
|
|
486
720
|
const id = toggle.dataset.perm;
|
|
487
721
|
const on = toggle.checked;
|
|
488
722
|
toggle.checked = !on;
|
|
489
|
-
|
|
490
|
-
|
|
723
|
+
// The switch spins until the answer is drawn, so it never shows its old face in between
|
|
724
|
+
// (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
725
|
+
const saving = (async () => {
|
|
726
|
+
const r = await fetch(site.url('api/permissions'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, on }) });
|
|
491
727
|
const out = await r.json().catch(() => ({}));
|
|
492
728
|
if (!r.ok) throw new Error(out.error);
|
|
493
729
|
if (!B.alive()) return;
|
|
494
730
|
drawPermissions(out);
|
|
495
731
|
$(`#perm-${id}Status`).textContent = on ? 'Saved. The session runs it without asking.' : 'Saved. The session asks first again.';
|
|
496
|
-
}
|
|
732
|
+
})();
|
|
733
|
+
try { await BoardUI.waitOn(toggle, BoardUI.pending.within(saving, stillWaiting)); } catch (error) {
|
|
497
734
|
if (B.alive()) $(`#perm-${id}Status`).textContent = error.message || 'Could not save. Is the host awake?';
|
|
498
735
|
}
|
|
499
736
|
});
|