@lovek4d/kickflip 0.1.0 → 0.4.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 +239 -104
- package/README.md +54 -4
- package/SKILL.md +63 -42
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +103 -40
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/auto-review.mjs +2 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +371 -82
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +8 -3
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -0
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +66 -28
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +354 -77
- package/board/public/app-core.js +40 -8
- package/board/public/app-header.js +33 -8
- package/board/public/app-lanes.js +238 -34
- package/board/public/app-needs.js +2 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +20 -21
- package/board/public/app.html +6 -2
- package/board/public/app.js +114 -33
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +161 -118
- package/board/public/hub-kit.js +27 -7
- package/board/public/hub.css +26 -33
- package/board/public/linear-key.js +143 -0
- package/board/public/log.html +48 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +14 -10
- package/board/public/rules-editor.js +4 -5
- package/board/public/shell.js +21 -7
- package/board/public/sidebar.css +51 -11
- package/board/public/sidebar.js +124 -19
- package/board/public/style.css +244 -56
- package/board/public/toasts.js +5 -3
- package/board/public/ui-cards.js +236 -66
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +23 -14
- package/board/public/ui-hub.js +34 -16
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +21 -0
- package/board/public/ui-page-header.js +13 -8
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +35 -11
- package/board/public/ui-sidebar.js +83 -21
- package/board/public/ui-snapshot.js +30 -13
- package/board/public/ui-threads.js +38 -23
- package/board/public/ui.js +96 -16
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +67 -44
- package/board/public/view-hub.js +105 -13
- package/board/public/view-notes.js +8 -5
- package/board/public/view-questions.js +123 -52
- package/board/public/view-settings.js +235 -62
- package/board/review.mjs +1 -0
- package/board/server.mjs +18 -7
- package/board/store.mjs +15 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -13
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/duration.mjs +1 -0
- package/hub/github.mjs +3 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -0
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -2
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/shell.mjs +2 -1
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +12 -12
- package/hub/sw.js +2 -2
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +8 -3
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +9 -6
- package/shared/fake-gh.mjs +105 -0
- package/shared/repos.mjs +1 -1
- package/shared/service.mjs +16 -16
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -1
- package/starter/public/claude.js +3 -2
- package/bin/web-ts.mjs +0 -2
- package/board/public/view-polling.js +0 -87
package/board/public/dialogs.js
CHANGED
|
@@ -1,63 +1,133 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Load before any page script that asks (docs/adr/
|
|
1
|
+
// Every confirmation and notice is the browser's own window.confirm or window.alert; no page imitates a native dialog.
|
|
2
|
+
// Load before any page script that asks (docs/adr/2026-11-03-0259-confirmations-are-the-browsers-own.md).
|
|
3
3
|
(function () {
|
|
4
|
-
// linear: Hub's /_hub/api/linear answer,
|
|
5
|
-
//
|
|
6
|
-
|
|
4
|
+
// linear: Hub's /_hub/api/linear answer, else loadLinear() fetches it once Linear is the source. Linear is picked only
|
|
5
|
+
// while Hub's key works; tapping it without one opens Hub's key modal over the page, or with keyState the key field
|
|
6
|
+
// inline (linear-key.js,
|
|
7
|
+
// docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A New board's "New project…" reads as
|
|
8
|
+
// new_project, which the hub creates on Start; Board Settings creates it with createProject. change(value, valid,
|
|
9
|
+
// {loaded, typing}) follows each change: loaded when only Linear's lists arrived, typing while a text field is being typed in.
|
|
10
|
+
// GitHub issues needs repo(), the board's GitHub owner/name: without one, as on a board whose repo is another git
|
|
11
|
+
// remote, it can't be picked and says why (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
|
|
12
|
+
// With keyState(), Hub's /_hub/api/settings `linear`, as Board Settings passes it, the source row reads its live state
|
|
13
|
+
// as a status word, and Linear tapped without a working key opens Hub's key field in keySlot, an element outside any
|
|
14
|
+
// form, since the key field is a form of its own; keyBusy(on) follows each save of the key
|
|
15
|
+
// (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
|
|
16
|
+
function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState, keySlot, keyBusy = () => {} } = {}) {
|
|
7
17
|
const { esc } = BoardUI;
|
|
8
18
|
const draft = { prefix: 'INT', ...value };
|
|
9
|
-
let creating = false, createError = '', destroyed = false;
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null, inline = null;
|
|
20
|
+
const works = () => Boolean(linear?.set && !linear.refused);
|
|
21
|
+
const keyWorks = () => Boolean(key?.set && !key.refused);
|
|
22
|
+
const keyWord = () => (keyWorks() ? 'linear key saved' : key.refused ? 'Linear refused the key' : 'no Linear key');
|
|
23
|
+
const statusWord = () => (draft.source === 'linear' && keyWorks() ? 'linear · connected' : `${draft.source} · ${keyWord()}`);
|
|
24
|
+
function status() {
|
|
25
|
+
if (!key) return '';
|
|
26
|
+
const on = draft.source === 'linear' && keyWorks();
|
|
27
|
+
return `<div class="tk-status${on ? ' on' : ''}" data-tk-status role="status"><span class="dot" aria-hidden="true"></span><b>${esc(statusWord())}</b></div>`;
|
|
28
|
+
}
|
|
29
|
+
// Hub's key field in keySlot. Once a key works, Linear is picked.
|
|
30
|
+
function openInline() {
|
|
31
|
+
if (!inline) {
|
|
32
|
+
keySlot.innerHTML = '<p class="tk-ask">Linear needs a key. Paste one here.</p><div class="hub-view"><main></main></div>';
|
|
33
|
+
inline = LinearKey.mount(keySlot.querySelector('main'), { linear: key, busy: keyBusy,
|
|
34
|
+
saved: (out) => { key = out.linear; render(); },
|
|
35
|
+
worked: async () => {
|
|
36
|
+
closeInline();
|
|
37
|
+
draft.source = 'linear';
|
|
38
|
+
await load();
|
|
39
|
+
if (destroyed) return;
|
|
40
|
+
pickDefaults();
|
|
41
|
+
render(); root.querySelector('[data-tk-source="linear"]').focus(); change(read(), valid());
|
|
42
|
+
} });
|
|
43
|
+
keySlot.hidden = false;
|
|
44
|
+
}
|
|
45
|
+
keySlot.querySelector('#linearKey')?.focus({ preventScroll: true });
|
|
46
|
+
}
|
|
47
|
+
function closeInline() {
|
|
48
|
+
if (!inline) return;
|
|
49
|
+
inline.destroy();
|
|
50
|
+
inline = null;
|
|
51
|
+
keySlot.hidden = true;
|
|
52
|
+
keySlot.innerHTML = '';
|
|
53
|
+
}
|
|
54
|
+
async function load() {
|
|
14
55
|
linear = { loading: true };
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
};
|
|
20
|
-
const workspace = () => linear.url_key ?? linear.workspace ?? draft.workspace;
|
|
56
|
+
render();
|
|
57
|
+
linear = await loadLinear().catch((e) => ({ set: true, error: e.message || 'Is the host awake?' }));
|
|
58
|
+
}
|
|
59
|
+
const workspace = () => linear?.url_key ?? linear?.workspace ?? draft.workspace;
|
|
21
60
|
const newName = () => (draft.newName ?? title()).trim();
|
|
22
|
-
const
|
|
23
|
-
const typedProject = () => (draft.project && draft.project !== '__new' ? draft.project : isNew ? title() : '').trim();
|
|
61
|
+
const ready = () => works() && !linear.error;
|
|
24
62
|
const read = () => draft.source === 'internal' ? { source: 'internal', prefix: draft.prefix || 'INT' }
|
|
25
|
-
: draft.source === 'github' ? { source: 'github' }
|
|
26
|
-
:
|
|
63
|
+
: draft.source === 'github' ? { source: 'github', ...(draft.account ? { account: draft.account } : {}) }
|
|
64
|
+
: !ready() ? { source: 'linear', workspace: draft.workspace, team: draft.team, project: draft.project }
|
|
27
65
|
: draft.project === '__new' && isNew ? { source: 'linear', workspace: workspace(), team: draft.team, new_project: newName() }
|
|
28
66
|
: { source: 'linear', workspace: workspace(), team: draft.team, project: draft.project };
|
|
29
67
|
const valid = () => draft.source === 'internal' ? /^[A-Z]{2,6}$/.test(draft.prefix || 'INT')
|
|
30
68
|
: draft.source === 'github' ? Boolean(repo())
|
|
31
|
-
:
|
|
32
|
-
: Boolean(linear.set && !linear.error && draft.team && draft.project && (draft.project !== '__new' || (isNew && newName())));
|
|
69
|
+
: Boolean(ready() && draft.team && draft.project && (draft.project !== '__new' || (isNew && newName())));
|
|
33
70
|
const pickDefaults = () => {
|
|
34
|
-
if (draft.source !== 'linear' || !
|
|
71
|
+
if (draft.source !== 'linear' || !ready()) return;
|
|
35
72
|
if (!linear.teams?.some((t) => t.key === draft.team)) draft.team = linear.teams?.[0]?.key;
|
|
36
|
-
draft.project
|
|
73
|
+
if (draft.project === '__new' || linear.projects?.some((p) => p.id === draft.project && p.team === draft.team)) return;
|
|
74
|
+
draft.project = isNew ? '__new' : linear.projects?.find((p) => p.team === draft.team)?.id;
|
|
37
75
|
};
|
|
76
|
+
// A board already on Linear whose Hub key is missing or refused keeps its source and shows why.
|
|
77
|
+
function needsKey() {
|
|
78
|
+
const why = linear.refused ? 'Linear refused Hub’s key' : 'Hub has no Linear key';
|
|
79
|
+
return `<p class="muted small" role="status">${why}, so this board can’t list Linear’s teams or create issues there.</p><p><button type="button" data-tk-key>Add Hub’s Linear key</button></p>`;
|
|
80
|
+
}
|
|
38
81
|
function linearDetail() {
|
|
39
|
-
if (linear.loading) return '<p class="muted small" role="status">Loading Linear…</p>';
|
|
40
|
-
if (!
|
|
82
|
+
if (!linear || linear.loading) return '<p class="muted small" role="status">Loading Linear…</p>';
|
|
83
|
+
if (!works()) return needsKey();
|
|
41
84
|
if (linear.error) return `<p class="muted small" role="alert">Couldn’t list Linear’s teams: ${esc(linear.error)}</p>`;
|
|
42
85
|
const project = linear.projects?.find((p) => p.id === draft.project);
|
|
43
86
|
const team = esc(draft.team || '');
|
|
44
87
|
const note = draft.project === '__new'
|
|
45
88
|
? (isNew ? `<p class="muted small">Start creates the project in the ${team} team on Linear.</p>` : `<p class="muted small">Creates the project in the ${team} team on Linear.</p>`)
|
|
46
89
|
: `<p class="muted small">A new card gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key. Its badge opens it in Linear.</p>`;
|
|
47
|
-
return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ?
|
|
90
|
+
return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ? BoardUI.BUSY : ''}>Create</button>`}</div>` : ''}${createError ? `<p class="muted small" role="alert">${esc(createError)}</p>` : ''}${note}`;
|
|
48
91
|
}
|
|
49
92
|
function render() {
|
|
50
|
-
const
|
|
93
|
+
const off = !repo();
|
|
94
|
+
const segment = `<div class="tk-seg" role="group" aria-label="Ticket source">${[['linear', 'Linear'], ['github', 'GitHub issues'], ['internal', 'Internal']].map(([source, name]) => `<button type="button" data-tk-source="${source}" aria-pressed="${draft.source === source}"${source === 'github' && off ? ' aria-disabled="true"' : ''}>${name}</button>`).join('')}</div>`;
|
|
95
|
+
const why = `<p class="muted small" role="status">GitHub issues need a repo on GitHub, and ${isNew ? 'the repo chosen above' : 'this board’s repo'} isn’t on GitHub.</p>`;
|
|
51
96
|
let detail;
|
|
52
|
-
if (draft.source === '
|
|
53
|
-
else if (draft.source === '
|
|
97
|
+
if (draft.source === 'github' && off) detail = why;
|
|
98
|
+
else if (draft.source === 'internal') detail = `<label class="tk-field"><span>Prefix</span><input data-tk="prefix" value="${esc(draft.prefix)}" placeholder="INT" maxlength="6" pattern="[A-Z]{2,6}" title="2–6 uppercase letters" autocapitalize="characters" autocomplete="off" spellcheck="false"></label><p class="muted small" data-tk-example>Cards number ${esc(draft.prefix || 'INT')}-1, ${esc(draft.prefix || 'INT')}-2… on this board. The badge opens the card.</p>`;
|
|
99
|
+
else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A new card opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''}; its badge reads #41.</span></span></div>`;
|
|
54
100
|
else detail = linearDetail();
|
|
55
|
-
root.innerHTML = `${segment}<div class="tk-detail">${detail}</div>`;
|
|
56
|
-
if (draft.source === 'linear' &&
|
|
101
|
+
root.innerHTML = `${status()}${segment}<div class="tk-detail">${noGithub && off && draft.source !== 'github' ? why : ''}${detail}</div>`;
|
|
102
|
+
if (draft.source === 'linear' && ready()) {
|
|
57
103
|
root.querySelector('[data-tk="team"]').value = draft.team || '';
|
|
58
104
|
root.querySelector('[data-tk="project"]').value = draft.project || '';
|
|
59
105
|
}
|
|
60
106
|
}
|
|
107
|
+
// Linear is checked again on each tap until Hub's key works, so a key added elsewhere counts.
|
|
108
|
+
async function pickLinear() {
|
|
109
|
+
if (asking || linear?.loading) return;
|
|
110
|
+
const before = draft.source;
|
|
111
|
+
draft.source = 'linear';
|
|
112
|
+
if (!ready()) await load();
|
|
113
|
+
if (destroyed) return;
|
|
114
|
+
if (!works()) {
|
|
115
|
+
draft.source = before;
|
|
116
|
+
render();
|
|
117
|
+
await keyed;
|
|
118
|
+
if (destroyed) return;
|
|
119
|
+
if (key) { openInline(); return; }
|
|
120
|
+
asking = true;
|
|
121
|
+
const added = await LinearKey.open();
|
|
122
|
+
asking = false;
|
|
123
|
+
if (destroyed || !added) return;
|
|
124
|
+
draft.source = 'linear';
|
|
125
|
+
await load();
|
|
126
|
+
if (destroyed) return;
|
|
127
|
+
}
|
|
128
|
+
pickDefaults();
|
|
129
|
+
render(); root.querySelector('[data-tk-source="linear"]').focus(); change(read(), valid());
|
|
130
|
+
}
|
|
61
131
|
const click = async (e) => {
|
|
62
132
|
if (e.target.closest('[data-tk-create]')) {
|
|
63
133
|
const name = newName();
|
|
@@ -73,82 +143,38 @@
|
|
|
73
143
|
render(); change(read(), valid());
|
|
74
144
|
return;
|
|
75
145
|
}
|
|
146
|
+
if (e.target.closest('[data-tk-key]')) { pickLinear(); return; }
|
|
76
147
|
const button = e.target.closest('[data-tk-source]');
|
|
77
148
|
if (!button) return;
|
|
149
|
+
noGithub = button.dataset.tkSource === 'github' && !repo();
|
|
150
|
+
if (noGithub) { render(); root.querySelector('[data-tk-source="github"]').focus(); return; }
|
|
151
|
+
if (button.dataset.tkSource === 'linear') { pickLinear(); return; }
|
|
152
|
+
closeInline();
|
|
78
153
|
draft.source = button.dataset.tkSource;
|
|
79
|
-
fetchLinear();
|
|
80
|
-
pickDefaults();
|
|
81
154
|
render(); root.querySelector(`[data-tk-source="${draft.source}"]`).focus(); change(read(), valid());
|
|
82
155
|
};
|
|
83
156
|
const input = (e) => {
|
|
84
157
|
const field = e.target.dataset.tk;
|
|
85
|
-
if (!field) return;
|
|
86
|
-
if (field.endsWith('Text')) {
|
|
87
|
-
const name = field.slice(0, -4);
|
|
88
|
-
draft[name] = name === 'team' ? e.target.value.toUpperCase().trim() : e.target.value.trim();
|
|
89
|
-
if (name === 'team') e.target.value = draft.team;
|
|
90
|
-
change(read(), valid());
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
158
|
+
if (!field || (e.type === 'input' && e.target.tagName === 'SELECT')) return;
|
|
93
159
|
draft[field] = field === 'prefix' ? e.target.value.toUpperCase() : e.target.value;
|
|
94
160
|
if (field === 'prefix') e.target.value = draft.prefix;
|
|
95
161
|
if (field === 'team') draft.project = isNew ? '__new' : linear.projects?.find((p) => p.team === draft.team)?.id;
|
|
96
162
|
if (field === 'team' || field === 'project') { createError = ''; render(); }
|
|
97
163
|
if (field === 'prefix') root.querySelector('[data-tk-example]').textContent = `Cards number ${draft.prefix || 'INT'}-1, ${draft.prefix || 'INT'}-2… on this board. The badge opens the card.`;
|
|
98
|
-
change(read(), valid());
|
|
164
|
+
change(read(), valid(), { typing: e.type === 'input' });
|
|
99
165
|
};
|
|
100
166
|
root.addEventListener('click', click);
|
|
101
167
|
root.addEventListener('input', input);
|
|
102
168
|
root.addEventListener('change', input);
|
|
103
|
-
fetchLinear();
|
|
104
169
|
render();
|
|
170
|
+
if (draft.source === 'linear' && !linear) load().then(() => { if (destroyed) return; pickDefaults(); render(); change(read(), valid(), { loaded: true }); });
|
|
171
|
+
// Without an answer the row has no status word, and Linear without a key opens Hub's key modal as on New board.
|
|
172
|
+
const keyed = keyState ? keyState().then((state) => { if (destroyed) return; key = state; render(); }, () => {}) : null;
|
|
105
173
|
return { read, valid, render, nameChanged() {
|
|
106
174
|
const field = root.querySelector('[data-tk="newName"]');
|
|
107
175
|
if (field && draft.newName === undefined) field.value = title();
|
|
108
|
-
const typedField = root.querySelector('[data-tk="projectText"]');
|
|
109
|
-
if (typedField && !draft.project) typedField.value = title();
|
|
110
176
|
},
|
|
111
|
-
destroy() { destroyed = true; root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
|
|
112
|
-
}
|
|
113
|
-
let close = null;
|
|
114
|
-
function sheet(message, { ok, cancel = 'Cancel', signal }) {
|
|
115
|
-
close?.(false);
|
|
116
|
-
return new Promise((resolve) => {
|
|
117
|
-
const previous = document.activeElement;
|
|
118
|
-
const el = document.createElement('dialog');
|
|
119
|
-
el.setAttribute('class', 'confirm-sheet');
|
|
120
|
-
el.setAttribute('role', 'alertdialog');
|
|
121
|
-
el.setAttribute('aria-modal', 'true');
|
|
122
|
-
el.setAttribute('aria-labelledby', 'confirmSheetText');
|
|
123
|
-
el.innerHTML = '<div class="box"><p id="confirmSheetText"></p><button type="button" data-answer="ok"></button></div>'
|
|
124
|
-
+ '<div class="box"><button type="button" class="cancel" data-answer="cancel"></button></div>';
|
|
125
|
-
el.querySelector('p').textContent = message;
|
|
126
|
-
el.querySelector('[data-answer="ok"]').textContent = ok;
|
|
127
|
-
el.querySelector('[data-answer="cancel"]').textContent = cancel;
|
|
128
|
-
let settled = false;
|
|
129
|
-
const abort = () => done(false);
|
|
130
|
-
function done(yes) {
|
|
131
|
-
if (settled) return;
|
|
132
|
-
settled = true;
|
|
133
|
-
close = null;
|
|
134
|
-
signal?.removeEventListener('abort', abort);
|
|
135
|
-
el.close();
|
|
136
|
-
el.remove();
|
|
137
|
-
if (previous?.isConnected) previous.focus({ preventScroll: true });
|
|
138
|
-
resolve(yes);
|
|
139
|
-
}
|
|
140
|
-
el.addEventListener('click', (e) => {
|
|
141
|
-
const b = e.target.closest('[data-answer]');
|
|
142
|
-
if (b || e.target === el) done(b?.dataset.answer === 'ok');
|
|
143
|
-
});
|
|
144
|
-
el.addEventListener('cancel', (e) => { e.preventDefault(); done(false); });
|
|
145
|
-
el.addEventListener('close', () => done(false));
|
|
146
|
-
signal?.addEventListener('abort', abort, { once: true });
|
|
147
|
-
document.body.appendChild(el);
|
|
148
|
-
el.showModal();
|
|
149
|
-
el.querySelector('.cancel').focus();
|
|
150
|
-
close = done;
|
|
151
|
-
});
|
|
177
|
+
destroy() { destroyed = true; closeInline(); root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
|
|
152
178
|
}
|
|
153
179
|
function fitSheet(sheet) {
|
|
154
180
|
const vv = window.visualViewport;
|
|
@@ -194,9 +220,9 @@
|
|
|
194
220
|
el.setAttribute('class', 'sheet');
|
|
195
221
|
el.setAttribute('aria-labelledby', 'newBoardTitle');
|
|
196
222
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
197
|
-
const draft = { name: '', purpose: '', github: '', branch: '', tickets: { source: 'internal', prefix: 'INT' } };
|
|
223
|
+
const draft = { name: '', purpose: '', github: '', remote: '', branch: '', tickets: { source: 'internal', prefix: 'INT' } };
|
|
198
224
|
let tickets;
|
|
199
|
-
let repos = [], pasted, selected, edited = false, sending = false, closed = false, generation = 0, lookupTimer, linear;
|
|
225
|
+
let repos = [], signedIn = false, pasted, selected, edited = false, sending = false, closed = false, generation = 0, lookupTimer, linear;
|
|
200
226
|
const controller = new AbortController();
|
|
201
227
|
el.innerHTML = `<form class="nb-form"><div class="sheet-head"><h2 id="newBoardTitle" tabindex="-1" autofocus>${current ? 'Change repo' : 'New board'}</h2><button type="button" data-cancel-new aria-label="Close sheet">✕</button></div><div data-repo-content></div><p class="nb-error" role="alert" hidden></p><div class="nb-actions sheet-actions"><button type="button" data-cancel-new>Cancel</button><button type="submit" class="primary" hidden disabled>${current ? 'Use repo' : 'Start'}</button></div></form>`;
|
|
202
228
|
const content = el.querySelector('[data-repo-content]');
|
|
@@ -211,10 +237,19 @@
|
|
|
211
237
|
return d;
|
|
212
238
|
};
|
|
213
239
|
const fail = (e) => { if (!closed) { error.textContent = e.message; error.hidden = false; } };
|
|
214
|
-
|
|
215
|
-
const
|
|
240
|
+
// A repo already on the host joins from its folder instead (docs/adr/2026-11-03-0248-kickflip-join-adds-the-current-repository-as-a-board.md).
|
|
241
|
+
const joinHint = current ? '' : '<p class="nb-help" data-join-hint>A repo already on the host running Kickflip? Run <code>kickflip join</code> in its folder.</p>';
|
|
242
|
+
function sync() { start.disabled = sending || !(draft.github || draft.remote) || !draft.branch || (!current && (!draft.name.trim() || !tickets?.valid())); }
|
|
243
|
+
// A repo row's id is its GitHub owner/name, or the git link of a repo on another host, which the row names by
|
|
244
|
+
// host/path and labels with its host where a GitHub row says Private or Public
|
|
245
|
+
// (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
|
|
246
|
+
const idOf = (r) => r.github || r.remote;
|
|
247
|
+
const nameOf = (r) => r.github || r.name;
|
|
248
|
+
const access = (r) => (r.remote ? r.host : r.private ? 'Private' : 'Public');
|
|
249
|
+
const row = (r, highlighted = false) => `<button type="button" class="nb-repo${highlighted ? ' nb-repo-highlight' : ''}" data-repo="${esc(idOf(r))}"><strong>${esc(nameOf(r))}</strong>${r.listed === false ? '<span class="nb-repo-tag">Not in your list</span>' : ''}<span class="nb-help">${esc(access(r))} · <time datetime="${esc(r.pushedAt)}">${r.pushedAt ? `Pushed ${esc(new Date(r.pushedAt).toLocaleString())}` : r.listed === false ? 'Last push unknown' : 'No pushes yet'}</time></span><span class="nb-help">${esc(r.description)}</span><span class="nb-local">${r.cloned ? `✓ Cloned${r.shared.length ? ` · shared with ${esc(r.shared.map((b) => b.title).join(', '))}` : ' · already cloned'}` : 'Not cloned yet'}</span></button>`;
|
|
216
250
|
const results = (html) => { el.querySelector('[data-repo-results]').innerHTML = html; };
|
|
217
251
|
function rows(query = '') {
|
|
252
|
+
if (!signedIn) { results(''); return; }
|
|
218
253
|
const list = repos.filter((r) => `${r.github} ${r.description}`.toLowerCase().includes(query.trim().toLowerCase()));
|
|
219
254
|
results(`<p class="nb-help" role="status">${list.length} of ${repos.length} repos · Newest push first</p><div class="nb-repo-list">${list.map((r) => row(r)).join('')}</div>${list.length ? '' : '<p class="nb-help">No matching repos. Try an owner or repo name.</p>'}`);
|
|
220
255
|
}
|
|
@@ -225,8 +260,20 @@
|
|
|
225
260
|
const q = value.trim();
|
|
226
261
|
const host = /^(?:[a-z][a-z\d+.-]*:\/\/(?:[^@/\s]+@)?|[^@/\s]+@)([^/:?#\s]+)/i.exec(q);
|
|
227
262
|
const reason = (name, message) => results(`<div class="nb-box nb-box-no" role="alert"><span><strong>${esc(name)}</strong><span class="nb-why">${esc(message)}</span></span></div>`);
|
|
263
|
+
const checking = (name) => results(`<p class="nb-help" role="status">Checking the hub can reach it…</p><div class="nb-box"><span class="nb-spin" aria-hidden="true"></span><span><strong>${esc(name)}</strong><span class="nb-help">Checking…</span></span></div>`);
|
|
264
|
+
const check = (query, name) => async () => {
|
|
265
|
+
try {
|
|
266
|
+
const repo = await request(`/_hub/repo?${query}`);
|
|
267
|
+
if (closed || generation !== version) return;
|
|
268
|
+
pasted = repo;
|
|
269
|
+
results(`<p class="nb-help" role="status">From your link</p><div class="nb-repo-list">${row(repo)}</div>`);
|
|
270
|
+
} catch (e) { if (!closed && generation === version) reason(name, e.message); }
|
|
271
|
+
};
|
|
272
|
+
if (/^(?:file:|[/~]|\.\.?\/)/i.test(q)) { reason(q, 'Paste the repo’s remote link. Local folders don’t work here.'); return; }
|
|
228
273
|
if (host && !/^(?:www\.)?github\.com$/i.test(host[1])) {
|
|
229
|
-
|
|
274
|
+
const name = q.replace(/^[a-z][a-z\d+.-]*:\/\//i, '').replace(/^[^@/\s]+@/, '').replace(/:(?!\d+\/)/, '/').replace(/:\d+\//, '/').replace(/\.git\/?$/i, '');
|
|
275
|
+
checking(name);
|
|
276
|
+
check(`remote=${encodeURIComponent(q)}`, name)();
|
|
230
277
|
return;
|
|
231
278
|
}
|
|
232
279
|
const link = /^(?:https?:\/\/(?:www\.)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s?#]+\/[^/\s?#]+)(?:\/[^\s]*)?(?:[?#][^\s]*)?$/i.exec(q);
|
|
@@ -237,17 +284,9 @@
|
|
|
237
284
|
const github = link ? link[1].replace(/\.git$/i, '') : q;
|
|
238
285
|
const listed = repos.find((r) => r.github.toLowerCase() === github.toLowerCase());
|
|
239
286
|
if (listed) { results(`<p class="nb-help" role="status">Already in your list</p><div class="nb-repo-list">${row(listed, true)}</div>`); return; }
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const repo = await request(`/_hub/repo?github=${encodeURIComponent(github)}`);
|
|
244
|
-
if (closed || generation !== version) return;
|
|
245
|
-
pasted = repo;
|
|
246
|
-
results(`<p class="nb-help" role="status">From your link</p><div class="nb-repo-list">${row(repo)}</div>`);
|
|
247
|
-
} catch (e) { if (!closed && generation === version) reason(github, e.message); }
|
|
248
|
-
};
|
|
249
|
-
if (bare) lookupTimer = setTimeout(check, 500);
|
|
250
|
-
else check();
|
|
287
|
+
checking(github);
|
|
288
|
+
if (bare) lookupTimer = setTimeout(check(`github=${encodeURIComponent(github)}`, github), 500);
|
|
289
|
+
else check(`github=${encodeURIComponent(github)}`, github)();
|
|
251
290
|
}
|
|
252
291
|
async function picker(fresh = false) {
|
|
253
292
|
tickets?.destroy();
|
|
@@ -261,42 +300,45 @@
|
|
|
261
300
|
try {
|
|
262
301
|
const d = await request(`/_hub/repos${fresh ? '?fresh=1' : ''}`);
|
|
263
302
|
if (closed || generation !== version) return;
|
|
303
|
+
signedIn = d.signedIn;
|
|
264
304
|
if (!d.signedIn) {
|
|
265
|
-
|
|
305
|
+
repos = [];
|
|
306
|
+
content.innerHTML = `<h3>GitHub isn’t connected</h3><p>gh isn’t signed in on the host running Kickflip. Sign in there to see your repos.</p><code>gh auth login</code><p><button type="button" data-check-auth>Check again</button></p><label class="nb-field"><span>Or paste a git link</span><input name="search" type="search" placeholder="https://host/path or user@host:path" autocomplete="off" autocapitalize="off" spellcheck="false"></label>${joinHint}<div data-repo-results></div>`;
|
|
266
307
|
return;
|
|
267
308
|
}
|
|
268
309
|
repos = d.repos;
|
|
269
|
-
content.innerHTML =
|
|
310
|
+
content.innerHTML = `<p class="nb-next">1 · Choose a repo</p><p>Choose the repo this board will work on: one of your GitHub repos, or any git repo by its link.</p><label class="nb-field"><span>Search repos or paste a git link</span><input name="search" type="search" placeholder="Search or paste owner/name or a git link…" autocomplete="off" autocapitalize="off" spellcheck="false"></label>${joinHint}<div data-repo-results></div>`;
|
|
270
311
|
rows();
|
|
271
312
|
} catch (e) { fail(e); content.innerHTML = '<button type="button" data-check-auth>Try again</button>'; }
|
|
272
313
|
}
|
|
273
|
-
async function pick(
|
|
274
|
-
selected = repos.find((r) => r.github ===
|
|
314
|
+
async function pick(id) {
|
|
315
|
+
selected = repos.find((r) => r.github === id) || (pasted && idOf(pasted) === id ? pasted : null);
|
|
275
316
|
if (!selected) return;
|
|
276
317
|
const version = ++generation;
|
|
277
318
|
clearTimeout(lookupTimer);
|
|
278
|
-
draft.github = github;
|
|
319
|
+
draft.github = selected.github || '';
|
|
320
|
+
draft.remote = selected.remote || '';
|
|
279
321
|
draft.branch = '';
|
|
280
|
-
if (!edited) draft.name =
|
|
322
|
+
if (!edited) draft.name = nameOf(selected).split('/').at(-1);
|
|
281
323
|
error.hidden = true;
|
|
282
324
|
const shared = selected.shared.map((b) => b.title).join(', ');
|
|
283
|
-
content.innerHTML = `<button type="button" data-back-repos>‹ Back</button><p class="nb-next">${current ? 'Repo details' : '2 · Board details'}</p><div class="nb-selected"><strong>${esc(
|
|
325
|
+
content.innerHTML = `<button type="button" data-back-repos>‹ Back</button><p class="nb-next">${current ? 'Repo details' : '2 · Board details'}</p><div class="nb-selected"><strong>${esc(nameOf(selected))}</strong><span class="nb-help">${esc(access(selected))}</span><p>${selected.cloned ? shared ? `Shared with ${esc(shared)}` : 'Already cloned' : 'Clones to'}<code class="nb-clone">${esc(selected.clone)}</code></p></div><label class="nb-field"><span>Default branch for this board</span><select name="branch" disabled><option value="">Loading branches…</option></select><span class="nb-help" data-branch-help></span></label><p class="nb-next">Each board’s implementers work in separate worktrees off this clone. Sharing the clone doesn’t share their working files.</p>${current ? '<p class="nb-next">New implementers and GitHub links will use the selected repo.</p>' : `<label class="nb-field"><span>Name</span><input name="name" type="text" value="${esc(draft.name)}" required maxlength="60" autocomplete="off"></label><label class="nb-field"><span>What it’s for</span><input name="purpose" type="text" value="${esc(draft.purpose)}" maxlength="240"><span class="nb-help">One line to get the board started. Optional.</span></label><p class="nb-next">The hub ${selected.cloned ? 'reuses the clone' : 'clones the repo'} and starts your board. You can keep using your other boards.</p>`}`;
|
|
284
326
|
if (!current) {
|
|
285
327
|
content.querySelectorAll('.nb-next')[content.querySelectorAll('.nb-next').length - 1].insertAdjacentHTML('beforebegin', '<div class="nb-field tk-section" role="group" aria-labelledby="nbTicketsLabel"><span id="nbTicketsLabel">Tickets</span><span class="nb-help">Where this board’s cards get their tickets. You can change it in Board settings.</span><div data-board-tickets></div></div>');
|
|
286
|
-
tickets = ticketsControl(content.querySelector('[data-board-tickets]'), { value: draft.tickets,
|
|
328
|
+
tickets = ticketsControl(content.querySelector('[data-board-tickets]'), { value: draft.tickets, linear, loadLinear: async () => (linear = await request('/_hub/api/linear')),
|
|
287
329
|
repo: () => draft.github, title: () => draft.name.trim(), isNew: true, change(value) { draft.tickets = value; sync(); } });
|
|
288
330
|
}
|
|
289
331
|
start.hidden = false;
|
|
290
332
|
sync();
|
|
291
333
|
try {
|
|
292
|
-
const d = await request(`/_hub/branches?github=${encodeURIComponent(
|
|
334
|
+
const d = await request(`/_hub/branches?${draft.remote ? 'remote' : 'github'}=${encodeURIComponent(id)}`);
|
|
293
335
|
if (closed || generation !== version) return;
|
|
294
|
-
draft.branch = current?.github ===
|
|
336
|
+
draft.branch = (current?.github || current?.remote) === id && d.branches.includes(current.branch) ? current.branch : d.defaultBranch;
|
|
295
337
|
if (!d.branches.includes(draft.branch)) draft.branch = d.branches[0] || '';
|
|
296
338
|
field('branch').innerHTML = d.branches.map((b) => `<option value="${esc(b)}"${b === draft.branch ? ' selected' : ''}>${esc(b)}${b === d.defaultBranch ? ' (repo default)' : ''}</option>`).join('');
|
|
297
339
|
field('branch').value = draft.branch;
|
|
298
340
|
field('branch').disabled = false;
|
|
299
|
-
el.querySelector('[data-branch-help]').textContent = draft.branch ? `New
|
|
341
|
+
el.querySelector('[data-branch-help]').textContent = draft.branch ? `New implementers branch from ${draft.branch}.` : 'This repo has no branches yet.';
|
|
300
342
|
sync();
|
|
301
343
|
} catch (e) { fail(e); }
|
|
302
344
|
}
|
|
@@ -317,7 +359,7 @@
|
|
|
317
359
|
form.addEventListener('change', (e) => {
|
|
318
360
|
if (e.target.getAttribute('name') === 'branch') {
|
|
319
361
|
draft.branch = e.target.value;
|
|
320
|
-
el.querySelector('[data-branch-help]').textContent = `New
|
|
362
|
+
el.querySelector('[data-branch-help]').textContent = `New implementers branch from ${draft.branch}.`;
|
|
321
363
|
sync();
|
|
322
364
|
}
|
|
323
365
|
});
|
|
@@ -328,7 +370,9 @@
|
|
|
328
370
|
sync();
|
|
329
371
|
error.hidden = true;
|
|
330
372
|
try {
|
|
331
|
-
|
|
373
|
+
const repo = draft.remote ? { remote: draft.remote } : { github: draft.github };
|
|
374
|
+
// Start, or Use repo, waits (ui.js waitOn) until the hub answers; a new board's own start then shows in the sidebar.
|
|
375
|
+
await BoardUI.waitOn(start, save(current ? { ...repo, branch: draft.branch } : { name: draft.name.trim(), purpose: draft.purpose.trim(), ...repo, branch: draft.branch, tickets: tickets.read() }, selected));
|
|
332
376
|
sending = false;
|
|
333
377
|
el.close();
|
|
334
378
|
} catch (e) { sending = false; fail(e); sync(); }
|
|
@@ -353,8 +397,7 @@
|
|
|
353
397
|
picker();
|
|
354
398
|
return () => { sending = false; el.close(); };
|
|
355
399
|
}
|
|
356
|
-
const confirm = (message,
|
|
357
|
-
: labels?.ok ? sheet(message, labels) : Promise.resolve(window.confirm(message)));
|
|
400
|
+
const confirm = (message, { signal } = {}) => Promise.resolve(!signal?.aborted && window.confirm(message));
|
|
358
401
|
const notice = (message) => { window.alert(message); return Promise.resolve(); };
|
|
359
402
|
globalThis.BoardDialogs = { confirm, notice, fitSheet, repoSheet, ticketsControl };
|
|
360
403
|
})();
|
package/board/public/hub-kit.js
CHANGED
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
// the hub's answer, fail(message) a refusal. A checkbox marked data-submit saves on change, and a time input marked
|
|
53
53
|
// data-autosave once its picker closes, so turning an iOS wheel doesn't save each value it passes. accepts(action, form)
|
|
54
54
|
// limits it to some of those forms, since the Hub page and its hub settings each wire their own over one root.
|
|
55
|
+
// While a post is out its control waits (ui.js waitOn): the tapped button, or else the form's toggle or field, with
|
|
56
|
+
// the form's other buttons, the setting's other choices, disabled.
|
|
55
57
|
// → {busy()}, true while a post is out
|
|
56
58
|
function forms(B, { answer, fail, accepts = () => true }) {
|
|
57
59
|
let sending = false;
|
|
@@ -65,9 +67,10 @@
|
|
|
65
67
|
const body = new URLSearchParams(new FormData(form));
|
|
66
68
|
if (e.submitter?.name) body.append(e.submitter.name, e.submitter.value);
|
|
67
69
|
sending = true;
|
|
70
|
+
const post = fetch(action, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body })
|
|
71
|
+
.then(async (r) => [r, await r.json().catch(() => null)]);
|
|
68
72
|
try {
|
|
69
|
-
const r = await
|
|
70
|
-
const out = await r.json().catch(() => null);
|
|
73
|
+
const [r, out] = await BoardUI.waitOn(e.submitter || form.querySelector('input:not([type="hidden"]), select'), post, { group: form.querySelectorAll('button') });
|
|
71
74
|
if (!B.alive()) return;
|
|
72
75
|
if (r.ok && out) answer(out, action); else fail(out?.error || FAILED);
|
|
73
76
|
} catch {
|
|
@@ -88,7 +91,8 @@
|
|
|
88
91
|
return { busy: () => sending };
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
// This device's push subscription, as HubUI.deviceRow shows it: Turn on subscribes,
|
|
94
|
+
// This device's push subscription, as HubUI.deviceRow shows it: Turn on subscribes, Test sends a test push, and each
|
|
95
|
+
// showing of the row tells the hub of a subscription this device no longer has.
|
|
92
96
|
// iOS allows web push only in a Home Screen app, and asks for permission only from a tap, so requestPermission runs
|
|
93
97
|
// first in the tap. paint() redraws the row, never before device() returns; changed() follows a subscription this
|
|
94
98
|
// device just made.
|
|
@@ -103,15 +107,31 @@
|
|
|
103
107
|
const keyBytes = (k) => Uint8Array.from(atob(k.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
|
104
108
|
const post = (p, body) => fetch(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }).then((r) => r.json());
|
|
105
109
|
const show = (next, isOn = false) => { if (!B.alive()) return; state = next; on = isOn; paint(); };
|
|
110
|
+
// The endpoint this device last gave the hub, kept in this browser, so that once the subscription goes (the
|
|
111
|
+
// browser dropped or replaced it, or notifications were blocked) the page tells the hub to forget it
|
|
112
|
+
// (docs/adr/2026-11-03-0501-the-hub-reads-healthy-degraded-or-a-site-down.md).
|
|
113
|
+
const KEPT = 'kickflip.push.endpoint';
|
|
114
|
+
const kept = () => { try { return localStorage.getItem(KEPT); } catch { return null; } };
|
|
115
|
+
const keep = (endpoint) => { try { if (endpoint) localStorage.setItem(KEPT, endpoint); else localStorage.removeItem(KEPT); } catch { /* storage blocked here */ } };
|
|
116
|
+
const settle = (endpoint = null) => {
|
|
117
|
+
const old = kept();
|
|
118
|
+
if (old && old !== endpoint) post('/_hub/push/unsubscribe', { endpoint: old }).catch(() => {});
|
|
119
|
+
keep(endpoint);
|
|
120
|
+
};
|
|
106
121
|
|
|
107
122
|
async function render() {
|
|
108
123
|
if (!('serviceWorker' in navigator) || !('PushManager' in globalThis)) {
|
|
109
124
|
if (ios && !standalone) show({ title: 'Add to Home Screen', sub: 'Tap Share, then Add to Home Screen, and open Claude from your Home Screen to turn on notifications.' });
|
|
110
125
|
return;
|
|
111
126
|
}
|
|
112
|
-
if (Notification.permission === 'denied') {
|
|
127
|
+
if (Notification.permission === 'denied') {
|
|
128
|
+
settle();
|
|
129
|
+
show({ title: 'Notifications are blocked', sub: `Allow them in ${ios ? 'Settings → Notifications → Claude.' : "this browser's site settings."}` });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
113
132
|
const reg = await navigator.serviceWorker.getRegistration('/');
|
|
114
133
|
const sub = await reg?.pushManager.getSubscription();
|
|
134
|
+
settle(sub?.toJSON().endpoint);
|
|
115
135
|
if (sub) {
|
|
116
136
|
post('/_hub/push/subscribe', sub.toJSON()).catch(() => {});
|
|
117
137
|
show({ title: 'This device', action: { label: 'Test', act: 'test', quiet: true } }, true);
|
|
@@ -121,7 +141,7 @@
|
|
|
121
141
|
}
|
|
122
142
|
async function enable() {
|
|
123
143
|
busy = true;
|
|
124
|
-
show({ ...state, action: { ...state.action,
|
|
144
|
+
show({ ...state, action: { ...state.action, busy: true } }, on);
|
|
125
145
|
try {
|
|
126
146
|
if (await Notification.requestPermission() !== 'granted') { await render(); return; }
|
|
127
147
|
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
|
@@ -135,9 +155,9 @@
|
|
|
135
155
|
finally { busy = false; }
|
|
136
156
|
}
|
|
137
157
|
async function test() {
|
|
138
|
-
show({ ...state, action: { ...state.action,
|
|
158
|
+
show({ ...state, action: { ...state.action, busy: true } }, true);
|
|
139
159
|
const r = await post('/_hub/push/test').catch(() => null);
|
|
140
|
-
show({ ...state, action: { ...state.action, label: r?.sent ? 'Sent' : 'Could not send' } }, true);
|
|
160
|
+
show({ ...state, action: { ...state.action, busy: false, label: r?.sent ? 'Sent' : 'Could not send' } }, true);
|
|
141
161
|
}
|
|
142
162
|
B.on(B.root, 'click', (e) => {
|
|
143
163
|
const act = e.target.closest?.('[data-device]')?.dataset.device;
|