wisp-agents 0.17.0 → 0.18.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/CHANGELOG.md +22 -1
- package/README.md +5 -4
- package/bin/wisp.js +4 -2
- package/package.json +2 -2
- package/server/git.js +114 -2
- package/server/machines.js +2 -2
- package/server/openapi.js +18 -1
- package/server/routes/meta.js +1 -1
- package/server/routes/settings.js +51 -4
- package/server/routes/ship.js +24 -2
- package/server/runner.js +6 -2
- package/server/services/auto.js +12 -1
- package/server/services/launch.js +11 -7
- package/server/services/ship.js +83 -4
- package/server/store.js +15 -0
- package/web/css/launcher.css +17 -0
- package/web/css/layout.css +10 -2
- package/web/css/modals.css +8 -0
- package/web/css/palette.css +22 -0
- package/web/css/ship.css +40 -0
- package/web/index.html +15 -5
- package/web/js/app.js +14 -3
- package/web/js/core/state.js +2 -1
- package/web/js/features/clones.js +79 -0
- package/web/js/features/feed.js +3 -3
- package/web/js/features/launcher.js +139 -19
- package/web/js/features/live.js +3 -3
- package/web/js/features/project-list.js +99 -0
- package/web/js/features/projects.js +218 -34
- package/web/js/features/settings.js +16 -2
- package/web/js/features/setup.js +3 -3
- package/web/js/features/ship.js +100 -24
|
@@ -8,11 +8,17 @@ import { launch, S, save, saveLaunch } from '../core/state.js';
|
|
|
8
8
|
import { present } from '../ui/overlay.js';
|
|
9
9
|
import { popover } from '../ui/popover.js';
|
|
10
10
|
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
11
|
+
import { clones, isRepo, repoLabel, repoName, startClone } from './clones.js';
|
|
11
12
|
import { feedToTop, renderFeed } from './feed.js';
|
|
12
13
|
import { renderLaunchChips } from './launcher.js';
|
|
13
14
|
import { loadConfig } from './live.js';
|
|
14
15
|
import { connectMachine, openMachines } from './machines.js';
|
|
15
|
-
import { MACHINE_STATE, machineById, placeOf, projectById, projectTitle } from './places.js';
|
|
16
|
+
import { MACHINE_STATE, machineById, placeOf, projectById, projectTitle, recentFirst } from './places.js';
|
|
17
|
+
import { projectList } from './project-list.js';
|
|
18
|
+
|
|
19
|
+
// Project filter chips in the top bar before the rest fold into "+N".
|
|
20
|
+
const TOP_CHIPS = 6;
|
|
21
|
+
let fitBar; // refits the chips when the bar's width changes
|
|
16
22
|
|
|
17
23
|
export function renderProjects() {
|
|
18
24
|
const all = [...S.sessions.values()];
|
|
@@ -20,26 +26,89 @@ export function renderProjects() {
|
|
|
20
26
|
const l = pid === 'all' ? all : all.filter((s) => s.projectId === pid);
|
|
21
27
|
return { run: l.filter((s) => s.state === 'running').length, need: l.filter((s) => s.state === 'waiting' || (s.state === 'review' && s.unread)).length };
|
|
22
28
|
};
|
|
23
|
-
const
|
|
29
|
+
const badges = (id) => {
|
|
24
30
|
const st = stats(id);
|
|
25
|
-
return `<
|
|
26
|
-
|
|
31
|
+
return `${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}`;
|
|
32
|
+
};
|
|
33
|
+
const chip = (id, name, color) => `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectTitle(projectById(id)))}">
|
|
34
|
+
${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${projectById(id)?.machine ? icon('server', 'p-where') : ''}${badges(id)}
|
|
27
35
|
</button>`;
|
|
36
|
+
const list = S.config.projects.slice().sort(recentFirst);
|
|
37
|
+
// Tens of projects: the filtered one and those that need you first, then the most recent, up to
|
|
38
|
+
// TOP_CHIPS, kept in recent order so chips don't shuffle; the rest sit behind "+N".
|
|
39
|
+
const keep = new Set([
|
|
40
|
+
...list.filter((p) => p.id === S.filter),
|
|
41
|
+
...list.filter((p) => stats(p.id).need),
|
|
42
|
+
...list,
|
|
43
|
+
].slice(0, TOP_CHIPS));
|
|
44
|
+
const shown = list.filter((p) => keep.has(p));
|
|
45
|
+
$('#projects').innerHTML = `<div class="p-scroll">${chip('all', 'All', 'slate')}${shown.map((p) => chip(p.id, p.name, p.color)).join('')}</div><button class="p-chip more" id="moreProjects" title="All ${list.length} projects" aria-haspopup="dialog"><i class="p-dot" hidden></i><span class="p-more-n"></span><span class="p-need" hidden></span>${icon('chevron', 'chev')}</button><button class="p-chip add" id="manageProjects" title="Manage projects">${icon('plus')}</button>`;
|
|
46
|
+
// Keep chips whole: drop the least important ones (recent last, the filtered one never) until
|
|
47
|
+
// the rest fit, and count what's left out on "+N" with what needs you in there.
|
|
48
|
+
const fit = () => {
|
|
49
|
+
const box = $('#projects .p-scroll');
|
|
50
|
+
if (!box) return;
|
|
51
|
+
const chips = $$('.p-chip[data-id]:not([data-id="all"])', box);
|
|
52
|
+
chips.forEach((c) => (c.hidden = false));
|
|
53
|
+
const drop = [...chips].sort((a, b) => (a.dataset.id === S.filter) - (b.dataset.id === S.filter) || stats(a.dataset.id).need - stats(b.dataset.id).need || shown.findIndex((p) => p.id === b.dataset.id) - shown.findIndex((p) => p.id === a.dataset.id));
|
|
54
|
+
const more = $('#moreProjects');
|
|
55
|
+
box.hidden = false;
|
|
56
|
+
$('#manageProjects').hidden = false;
|
|
57
|
+
$('.p-dot', more).hidden = true;
|
|
58
|
+
more.classList.remove('solo', ...[...more.classList].filter((c) => c.startsWith('c-')));
|
|
59
|
+
const paint = () => {
|
|
60
|
+
const out = list.filter((p) => !chips.some((c) => !c.hidden && c.dataset.id === p.id));
|
|
61
|
+
more.hidden = !out.length;
|
|
62
|
+
$('.p-more-n', more).textContent = `+${out.length}`;
|
|
63
|
+
const need = out.reduce((n, p) => n + stats(p.id).need, 0);
|
|
64
|
+
$('.p-need', more).hidden = !need;
|
|
65
|
+
$('.p-need', more).textContent = need;
|
|
66
|
+
};
|
|
67
|
+
paint();
|
|
68
|
+
for (const c of drop) {
|
|
69
|
+
if (box.scrollWidth <= box.clientWidth) break;
|
|
70
|
+
c.hidden = true;
|
|
71
|
+
paint();
|
|
72
|
+
}
|
|
73
|
+
// Too narrow for even "All" (a phone): one chip naming the filter opens the whole list, which
|
|
74
|
+
// also has Manage projects, so + steps aside for it.
|
|
75
|
+
if (box.scrollWidth > box.clientWidth) {
|
|
76
|
+
const p = projectById(S.filter);
|
|
77
|
+
box.hidden = true;
|
|
78
|
+
$('#manageProjects').hidden = true;
|
|
79
|
+
more.hidden = false;
|
|
80
|
+
more.classList.add('solo', ...(p ? [`c-${p.color}`] : []));
|
|
81
|
+
$('.p-dot', more).hidden = !p;
|
|
82
|
+
$('.p-more-n', more).textContent = p ? p.name : 'All';
|
|
83
|
+
const need = stats(p ? p.id : 'all').need;
|
|
84
|
+
$('.p-need', more).hidden = !need;
|
|
85
|
+
$('.p-need', more).textContent = need;
|
|
86
|
+
}
|
|
28
87
|
};
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
88
|
+
fit();
|
|
89
|
+
fitBar ??= new ResizeObserver(() => fitBar.fit?.());
|
|
90
|
+
fitBar.fit = fit;
|
|
91
|
+
fitBar.observe($('#projects'));
|
|
92
|
+
const setFilter = (id) => {
|
|
93
|
+
S.filter = id;
|
|
94
|
+
save('wisp.filter', S.filter);
|
|
95
|
+
renderFeed(); // repaints these chips too
|
|
96
|
+
feedToTop();
|
|
97
|
+
renderLaunchChips();
|
|
98
|
+
};
|
|
99
|
+
$$('.p-chip[data-id]').forEach((b) => b.addEventListener('click', () => setFilter(S.filter === b.dataset.id ? 'all' : b.dataset.id)));
|
|
100
|
+
$('#moreProjects').onclick = (e) =>
|
|
101
|
+
projectList(e.currentTarget, {
|
|
102
|
+
projects: list,
|
|
103
|
+
current: S.filter,
|
|
104
|
+
label: 'Filter by project',
|
|
105
|
+
meta: (p) => badges(p.id),
|
|
106
|
+
extras: [
|
|
107
|
+
...(S.filter === 'all' ? [] : [{ id: 'all', label: 'All projects', icon: 'x' }]),
|
|
108
|
+
{ id: '+manage', label: 'Manage projects…', icon: 'settings' },
|
|
109
|
+
],
|
|
110
|
+
onPick: (id) => (id === '+manage' ? openProjects() : setFilter(id)),
|
|
111
|
+
});
|
|
43
112
|
$$('.p-chip[data-id]:not([data-id="all"])').forEach((b) =>
|
|
44
113
|
b.addEventListener('contextmenu', (e) => {
|
|
45
114
|
e.preventDefault();
|
|
@@ -94,7 +163,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
94
163
|
<div class="add-row">
|
|
95
164
|
<div class="field lg mono path-input">
|
|
96
165
|
${window.wispDesktop ? `<button class="browse" title="Browse…">${icon('folder')}</button>` : icon('folder')}
|
|
97
|
-
<input id="ppath" placeholder="~/code/my-app" spellcheck="false" autocomplete="off">
|
|
166
|
+
<input id="ppath" placeholder="~/code/my-app or a GitHub URL" spellcheck="false" autocomplete="off">
|
|
98
167
|
<div class="suggest" hidden></div>
|
|
99
168
|
</div>
|
|
100
169
|
${window.wispDesktop ? `<button class="ghost" id="pbrowse">${icon('folder')}Browse…</button>` : ''}
|
|
@@ -102,15 +171,17 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
102
171
|
</div>
|
|
103
172
|
<p class="hint"></p>
|
|
104
173
|
<div class="plist" id="plist"></div>
|
|
174
|
+
<div class="discover" id="ghrepos"></div>
|
|
105
175
|
<div class="discover" id="discover"><h4>Found on this machine</h4><p class="muted">Looking for git repos…</p></div>
|
|
106
176
|
</div>`);
|
|
107
|
-
present(node, host);
|
|
177
|
+
const close = present(node, host);
|
|
108
178
|
const input = $('#ppath', node);
|
|
109
179
|
const sug = $('.suggest', node);
|
|
110
180
|
let where = machine && machineById(machine) ? machine : null;
|
|
111
181
|
const here = () => machineById(where);
|
|
112
182
|
const home = () => (where ? here()?.remote?.home : S.config.home) || '';
|
|
113
183
|
const tilde = (p) => (home() && p.startsWith(home()) ? `~${p.slice(home().length)}` : p);
|
|
184
|
+
let editingCloneDir = false;
|
|
114
185
|
|
|
115
186
|
const paintWhere = () => {
|
|
116
187
|
const tabs = $('.where', node);
|
|
@@ -118,12 +189,59 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
118
189
|
.map((m) => `<button data-w="${m.id}" class="${where === m.id ? 'on' : ''}" title="${esc(m.target)} · ${esc(MACHINE_STATE[m.state] || m.state)}">${icon('server')}${esc(m.name)}<i class="dot ${m.state === 'online' ? 'ok' : m.state === 'error' ? 'bad' : ''}"></i></button>`)
|
|
119
190
|
.join('')}<i></i>${host ? '' : `<button data-w="+" title="Run agents on another computer over SSH">${icon('plus')}Machine</button>`}`;
|
|
120
191
|
const m = here();
|
|
121
|
-
input.placeholder = m ? `~/code/my-app on ${m.name}` : '~/code/my-app';
|
|
192
|
+
input.placeholder = m ? `~/code/my-app or a GitHub URL, on ${m.name}` : '~/code/my-app or a GitHub URL';
|
|
122
193
|
$$('#pbrowse, .browse', node).forEach((b) => (b.hidden = !!m));
|
|
123
|
-
|
|
124
|
-
? `Folders on <b>${esc(m.name)}</b>. Agents run there, in its own worktrees; you follow along here.`
|
|
125
|
-
: 'Tip: run <code>wisp add</code> inside any folder, or launch from a terminal with <code>wisp "task"</code> — the folder is added automatically.';
|
|
194
|
+
paintHint();
|
|
126
195
|
};
|
|
196
|
+
// The hint under the input follows what's typed: a repo URL says where the clone goes.
|
|
197
|
+
const paintHint = () => {
|
|
198
|
+
const m = here();
|
|
199
|
+
const hint = $('.hint', node);
|
|
200
|
+
const v = input.value.trim();
|
|
201
|
+
$('#padd', node).textContent = isRepo(v) ? 'Clone' : 'Add';
|
|
202
|
+
if (editingCloneDir) return;
|
|
203
|
+
if (isRepo(v))
|
|
204
|
+
hint.innerHTML = m
|
|
205
|
+
? `Clones <b>${esc(repoLabel(v))}</b> on <b>${esc(m.name)}</b>. You can write the task while it clones.`
|
|
206
|
+
: `Clones <b>${esc(repoLabel(v))}</b> into <button class="link" data-a="clonedir" title="Change where repos are cloned">${esc(tilde(S.config.cloneDir || '~/code'))}/${esc(repoName(v))}</button>. You can write the task while it clones.`;
|
|
207
|
+
else
|
|
208
|
+
hint.innerHTML = m
|
|
209
|
+
? `Folders on <b>${esc(m.name)}</b>, or a repo URL to clone there. Agents run there, in its own worktrees; you follow along here.`
|
|
210
|
+
: 'Paste a GitHub URL and wisp clones it for you. Or run <code>wisp add</code> inside any folder: it is added automatically.';
|
|
211
|
+
};
|
|
212
|
+
// Change the clone folder in place: the hint turns into a small path field.
|
|
213
|
+
$('.hint', node).addEventListener('click', (e) => {
|
|
214
|
+
if (!e.target.closest('[data-a="clonedir"]')) return;
|
|
215
|
+
editingCloneDir = true;
|
|
216
|
+
const hint = $('.hint', node);
|
|
217
|
+
hint.innerHTML = `Clone repos into <input class="clonedir mono" spellcheck="false" value="${esc(tilde(S.config.cloneDir || '~/code'))}"> <button class="link" data-a="savedir">Save</button>`;
|
|
218
|
+
const f = $('.clonedir', hint);
|
|
219
|
+
const done = async (keep) => {
|
|
220
|
+
if (!editingCloneDir) return;
|
|
221
|
+
editingCloneDir = false;
|
|
222
|
+
if (keep && f.value.trim() !== tilde(S.config.cloneDir || '')) {
|
|
223
|
+
S.config.cloneDir = f.value.trim(); // optimistic; the server answers with the resolved folder
|
|
224
|
+
await tryApi(async () => {
|
|
225
|
+
await api('/config', { method: 'PATCH', body: { defaults: { cloneDir: f.value.trim() } } });
|
|
226
|
+
await loadConfig();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
paintHint();
|
|
230
|
+
input.focus();
|
|
231
|
+
};
|
|
232
|
+
f.onkeydown = (ev) => {
|
|
233
|
+
if (ev.key === 'Enter' || ev.key === 'Escape') {
|
|
234
|
+
ev.preventDefault();
|
|
235
|
+
ev.stopPropagation(); // Escape cancels the edit, not the dialog
|
|
236
|
+
done(ev.key === 'Enter');
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
$('[data-a="savedir"]', hint).onclick = () => {
|
|
240
|
+
done(true);
|
|
241
|
+
};
|
|
242
|
+
f.focus();
|
|
243
|
+
f.select();
|
|
244
|
+
});
|
|
127
245
|
$('.where', node).addEventListener('click', (e) => {
|
|
128
246
|
const b = e.target.closest('[data-w]');
|
|
129
247
|
if (!b) return;
|
|
@@ -135,28 +253,42 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
135
253
|
sug.hidden = true;
|
|
136
254
|
paintWhere();
|
|
137
255
|
list();
|
|
256
|
+
github();
|
|
138
257
|
discover();
|
|
139
258
|
input.focus();
|
|
140
259
|
});
|
|
141
260
|
|
|
261
|
+
// A new project becomes the launcher's pick for its place.
|
|
262
|
+
const picked = async (proj) => {
|
|
263
|
+
await loadConfig();
|
|
264
|
+
launch.picks[placeOf(proj)] = proj.id;
|
|
265
|
+
if (!launch.where.includes(placeOf(proj))) launch.where = [placeOf(proj)];
|
|
266
|
+
if (placeOf(proj) === launch.where[0]) launch.project = proj.id;
|
|
267
|
+
saveLaunch();
|
|
268
|
+
renderLaunchChips();
|
|
269
|
+
};
|
|
142
270
|
const add = async (p) => {
|
|
271
|
+
if (isRepo(p)) return clone(p);
|
|
143
272
|
try {
|
|
144
273
|
const proj = await api('/projects', { method: 'POST', body: { path: p, ...(where && { machine: where }) } });
|
|
145
|
-
await
|
|
146
|
-
launch.picks[placeOf(proj)] = proj.id;
|
|
147
|
-
if (!launch.where.includes(placeOf(proj))) launch.where = [placeOf(proj)];
|
|
148
|
-
if (placeOf(proj) === launch.where[0]) launch.project = proj.id;
|
|
149
|
-
saveLaunch();
|
|
150
|
-
renderLaunchChips();
|
|
274
|
+
await picked(proj);
|
|
151
275
|
toast(`Added <b>${esc(proj.name)}</b>${where ? ` on ${esc(here()?.name || '')}` : ''}`);
|
|
152
276
|
input.value = '';
|
|
153
277
|
sug.hidden = true;
|
|
278
|
+
paintHint();
|
|
154
279
|
list();
|
|
155
280
|
discover();
|
|
156
281
|
} catch (err) {
|
|
157
282
|
toastError(err);
|
|
158
283
|
}
|
|
159
284
|
};
|
|
285
|
+
// Clone a repo: the launcher picks it right away (cloning in the background) and the dialog
|
|
286
|
+
// closes, so the next thing is writing the task. A launch into it waits for the clone.
|
|
287
|
+
const clone = (url) => {
|
|
288
|
+
startClone(url, where);
|
|
289
|
+
close();
|
|
290
|
+
setTimeout(() => $('#prompt')?.focus(), 60);
|
|
291
|
+
};
|
|
160
292
|
$('#padd', node).onclick = () => {
|
|
161
293
|
if (input.value.trim()) add(input.value.trim());
|
|
162
294
|
};
|
|
@@ -169,10 +301,11 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
169
301
|
let sel = -1;
|
|
170
302
|
let t;
|
|
171
303
|
input.addEventListener('input', () => {
|
|
304
|
+
paintHint();
|
|
172
305
|
clearTimeout(t);
|
|
173
306
|
t = setTimeout(async () => {
|
|
174
307
|
const q = input.value;
|
|
175
|
-
if (!q) return (sug.hidden = true);
|
|
308
|
+
if (!q || isRepo(q)) return (sug.hidden = true);
|
|
176
309
|
const dirs = await api(`${where ? `/machines/${where}/fs` : '/fs'}?q=${encodeURIComponent(q)}`).catch(() => []);
|
|
177
310
|
sel = -1;
|
|
178
311
|
sug.hidden = !dirs.length;
|
|
@@ -202,8 +335,16 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
202
335
|
const list = () => {
|
|
203
336
|
const box = $('#plist', node);
|
|
204
337
|
const projects = S.config.projects.filter((p) => (p.machine || null) === where);
|
|
205
|
-
|
|
206
|
-
|
|
338
|
+
const pending = clones
|
|
339
|
+
.filter((j) => j.where === where)
|
|
340
|
+
.map(
|
|
341
|
+
(j) => `<div class="prow cloning"><span class="swatch"><span class="orb live"></span></span>
|
|
342
|
+
<div class="prow-main"><b class="pname">${esc(repoName(j.url))}</b><span class="ppath">Cloning ${esc(repoLabel(j.url))}…</span></div></div>`,
|
|
343
|
+
)
|
|
344
|
+
.join('');
|
|
345
|
+
box.innerHTML = projects.length || pending
|
|
346
|
+
? pending +
|
|
347
|
+
projects
|
|
207
348
|
.map((p) => {
|
|
208
349
|
const n = [...S.sessions.values()].filter((s) => s.projectId === p.id).length;
|
|
209
350
|
return `<div class="prow c-${p.color}" data-id="${p.id}">
|
|
@@ -215,7 +356,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
215
356
|
})
|
|
216
357
|
.join('')
|
|
217
358
|
: `<p class="muted">No projects ${where ? `on ${esc(here()?.name || '')} ` : ''}yet. Add a folder above, or pick one below.</p>`;
|
|
218
|
-
$$('.prow', box).forEach((row) => {
|
|
359
|
+
$$('.prow[data-id]', box).forEach((row) => {
|
|
219
360
|
const id = row.dataset.id;
|
|
220
361
|
const p = projectById(id);
|
|
221
362
|
$('.pname', row).onchange = (e) => {
|
|
@@ -243,6 +384,40 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
243
384
|
});
|
|
244
385
|
};
|
|
245
386
|
|
|
387
|
+
// The user's own GitHub repos (through gh), one click to clone. Only on this computer, and
|
|
388
|
+
// hidden when gh isn't installed or signed in.
|
|
389
|
+
let ghRepos = null;
|
|
390
|
+
let ghAll = false;
|
|
391
|
+
const github = async () => {
|
|
392
|
+
const box = $('#ghrepos', node);
|
|
393
|
+
if (where) return (box.innerHTML = '');
|
|
394
|
+
box.onclick = (e) => {
|
|
395
|
+
const b = e.target.closest('[data-repo]');
|
|
396
|
+
if (b) clone(b.dataset.repo);
|
|
397
|
+
if (e.target.closest('[data-a="ghall"]')) {
|
|
398
|
+
ghAll = !ghAll;
|
|
399
|
+
github();
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
if (!ghRepos) {
|
|
403
|
+
box.innerHTML = `<h4>${icon('github')}Your GitHub repos</h4><p class="muted">Asking gh…</p>`;
|
|
404
|
+
ghRepos = await api('/projects/github').catch(() => []);
|
|
405
|
+
if (where) return (box.innerHTML = '');
|
|
406
|
+
}
|
|
407
|
+
const busy = new Set(clones.map((j) => j.label.toLowerCase()));
|
|
408
|
+
const repos = ghRepos.filter((r) => !busy.has(r.repo.toLowerCase()));
|
|
409
|
+
const more = repos.length > 6 ? `<button class="link xs" data-a="ghall">${ghAll ? 'Show fewer' : `Show all ${repos.length}`}</button>` : '';
|
|
410
|
+
box.innerHTML = repos.length
|
|
411
|
+
? `<h4>${icon('github')}Your GitHub repos <span class="muted">· click to clone</span>${more}</h4><div class="found">${repos
|
|
412
|
+
.slice(0, ghAll ? undefined : 6)
|
|
413
|
+
.map(
|
|
414
|
+
(r) =>
|
|
415
|
+
`<button class="found-item" data-repo="${esc(r.url)}" title="${esc(r.description || r.repo)}">${icon('plus')}<b>${esc(r.repo.split('/')[1])}</b><span>${esc(r.repo.split('/')[0])}${r.private ? ' · private' : ''}${r.fork ? ' · fork' : ''}</span></button>`,
|
|
416
|
+
)
|
|
417
|
+
.join('')}</div>`
|
|
418
|
+
: '';
|
|
419
|
+
};
|
|
420
|
+
|
|
246
421
|
const discover = async () => {
|
|
247
422
|
const box = $('#discover', node);
|
|
248
423
|
const m = here();
|
|
@@ -265,6 +440,14 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
265
440
|
.join('')}</div>`
|
|
266
441
|
: '';
|
|
267
442
|
};
|
|
443
|
+
bus.on(
|
|
444
|
+
'clones',
|
|
445
|
+
() => {
|
|
446
|
+
list();
|
|
447
|
+
github();
|
|
448
|
+
},
|
|
449
|
+
{ node },
|
|
450
|
+
);
|
|
268
451
|
let wasOnline = here()?.state === 'online';
|
|
269
452
|
bus.on(
|
|
270
453
|
'machines',
|
|
@@ -279,6 +462,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
279
462
|
|
|
280
463
|
paintWhere();
|
|
281
464
|
list();
|
|
465
|
+
github();
|
|
282
466
|
discover();
|
|
283
467
|
if (focusAdd) setTimeout(() => input.focus(), 60);
|
|
284
468
|
}
|
|
@@ -6,7 +6,7 @@ import { api } from '../core/api.js';
|
|
|
6
6
|
import * as bus from '../core/bus.js';
|
|
7
7
|
import { $, $$, el, icon } from '../core/dom.js';
|
|
8
8
|
import { money, pct } from '../core/format.js';
|
|
9
|
-
import { S } from '../core/state.js';
|
|
9
|
+
import { S, setAgents } from '../core/state.js';
|
|
10
10
|
import { overlay } from '../ui/overlay.js';
|
|
11
11
|
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
12
12
|
import { openRouter } from './auto.js';
|
|
@@ -78,6 +78,7 @@ export function openSettings(opts) {
|
|
|
78
78
|
</div>
|
|
79
79
|
<div class="sx-group">
|
|
80
80
|
${row('Worktree per task', 'Each task gets its own branch from origin/main, in git repos', '<button class="switch" id="wtDefault" role="switch"></button>')}
|
|
81
|
+
${row('Fix PR conflicts', 'When a shipped PR conflicts with its base, the task merges the base in, resolves it and pushes', '<button class="switch" id="fixConflicts" role="switch"></button>')}
|
|
81
82
|
</div>
|
|
82
83
|
</section>
|
|
83
84
|
<section class="sx-pane" data-pane="notifications">
|
|
@@ -170,7 +171,7 @@ export function openSettings(opts) {
|
|
|
170
171
|
btn.classList.add('busy');
|
|
171
172
|
const before = new Map(S.agents.map((a) => [a.id, a]));
|
|
172
173
|
try {
|
|
173
|
-
|
|
174
|
+
setAgents(await api('/agents/recheck', { method: 'POST', body: {} }));
|
|
174
175
|
if (!node.isConnected) return;
|
|
175
176
|
paintAgents();
|
|
176
177
|
$('#launcher')?.placesChanged?.();
|
|
@@ -300,6 +301,19 @@ export function openSettings(opts) {
|
|
|
300
301
|
});
|
|
301
302
|
if (!saved) return;
|
|
302
303
|
paintWt();
|
|
304
|
+
const fc = $('#fixConflicts', node);
|
|
305
|
+
const paintFc = () => fc.setAttribute('aria-checked', S.config.defaults.fixConflicts !== 'off');
|
|
306
|
+
fc.onclick = async () => {
|
|
307
|
+
const was = S.config.defaults.fixConflicts;
|
|
308
|
+
S.config.defaults.fixConflicts = was === 'off' ? 'on' : 'off';
|
|
309
|
+
paintFc();
|
|
310
|
+
const saved = await tryApi(() => api('/config', { method: 'PATCH', body: { defaults: { fixConflicts: S.config.defaults.fixConflicts } } }));
|
|
311
|
+
if (!saved) {
|
|
312
|
+
S.config.defaults.fixConflicts = was;
|
|
313
|
+
paintFc();
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
paintFc();
|
|
303
317
|
renderLauncher();
|
|
304
318
|
};
|
|
305
319
|
paintWt();
|
package/web/js/features/setup.js
CHANGED
|
@@ -6,7 +6,7 @@ import { esc } from '../md.js';
|
|
|
6
6
|
import { api } from '../core/api.js';
|
|
7
7
|
import * as bus from '../core/bus.js';
|
|
8
8
|
import { $, icon } from '../core/dom.js';
|
|
9
|
-
import { launch, S } from '../core/state.js';
|
|
9
|
+
import { launch, S, setAgents } from '../core/state.js';
|
|
10
10
|
import { toast, toastError } from '../ui/toast.js';
|
|
11
11
|
import { harnessLogo } from './runners.js';
|
|
12
12
|
|
|
@@ -25,7 +25,7 @@ export async function installHarness(id) {
|
|
|
25
25
|
failed = '';
|
|
26
26
|
bus.emit('install');
|
|
27
27
|
try {
|
|
28
|
-
|
|
28
|
+
setAgents(await api('/agents/install', { method: 'POST', body: { id } }));
|
|
29
29
|
$('#launcher')?.placesChanged?.();
|
|
30
30
|
toast(`<b>${esc(S.agents.find((a) => a.id === id)?.name || id)}</b> is ready: free models, no account needed. Type a task and press Enter.`, { timeout: 7000 });
|
|
31
31
|
$('#prompt')?.focus();
|
|
@@ -68,7 +68,7 @@ export function renderSetup() {
|
|
|
68
68
|
btn.disabled = true;
|
|
69
69
|
btn.classList.add('busy');
|
|
70
70
|
try {
|
|
71
|
-
|
|
71
|
+
setAgents(await api('/agents/recheck', { method: 'POST', body: {} }));
|
|
72
72
|
$('#launcher')?.placesChanged?.();
|
|
73
73
|
bus.emit('agents');
|
|
74
74
|
if (needsSetup()) toast('Still no harness found. Install opencode here, or Claude Code, Codex, Cursor… yourself.');
|
package/web/js/features/ship.js
CHANGED
|
@@ -7,26 +7,42 @@ import { S } from '../core/state.js';
|
|
|
7
7
|
import { overlay } from '../ui/overlay.js';
|
|
8
8
|
import { toast, toastError } from '../ui/toast.js';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// fix: the session's conflictFix ({ step, why }) while wisp resolves the PR's merge conflicts itself.
|
|
11
|
+
const fixing = (fix) => fix?.step === 'fixing' || fix?.step === 'pushing';
|
|
12
|
+
const prTone = (pr, fix) =>
|
|
13
|
+
pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.conflict ? (fixing(fix) ? 'pending' : 'fail') : pr.checks?.fail ? 'fail' : pr.checks?.pending ? 'pending' : pr.review === 'CHANGES_REQUESTED' ? 'fail' : 'pass';
|
|
12
14
|
const REVIEW = { APPROVED: 'Approved', CHANGES_REQUESTED: 'Changes requested', REVIEW_REQUIRED: 'Review required' };
|
|
13
|
-
function prLine(pr) {
|
|
15
|
+
function prLine(pr, fix) {
|
|
16
|
+
if (pr.state === 'OPEN' && (pr.conflict || fixing(fix))) {
|
|
17
|
+
const what = fix?.step === 'pushing' ? 'Pushing the conflict fix' : fixing(fix) ? 'Fixing merge conflicts' : fix?.step === 'failed' ? `Conflicts not fixed: ${fix.why}` : 'Merge conflicts';
|
|
18
|
+
return `Open · ${what}`;
|
|
19
|
+
}
|
|
14
20
|
const c = pr.checks || {};
|
|
15
21
|
const checks = c.fail ? `${c.fail} check${c.fail > 1 ? 's' : ''} failing` : c.pending ? `${c.pending} check${c.pending > 1 ? 's' : ''} running` : c.pass ? `${c.pass} check${c.pass > 1 ? 's' : ''} passed` : 'No checks';
|
|
16
22
|
const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
|
|
17
23
|
return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
// A ship in flight, per session: { steps, step, update, route, error, values }. The dialog closes
|
|
27
|
+
// on confirm and the card follows along here (the server sends the live stage as `shipping`);
|
|
28
|
+
// a failure stays on the card with Retry, which reopens the dialog with what was typed.
|
|
29
|
+
const ships = new Map();
|
|
30
|
+
const STEP_LABEL = { commit: 'Commit', push: 'Push', pr: 'Open PR' };
|
|
31
|
+
const STEP_DOING = { commit: 'Committing', push: 'Pushing', pr: 'Opening the PR' };
|
|
32
|
+
const shipping = (s) => !!(s?.shipping || (ships.has(s?.id) && !ships.get(s.id).error));
|
|
33
|
+
|
|
20
34
|
// Ship it only makes sense on a finished worktree task with something to ship: uncommitted
|
|
21
35
|
// changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
|
|
22
36
|
export function shippable(s) {
|
|
23
|
-
if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED') return false;
|
|
37
|
+
if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED' || shipping(s)) return false;
|
|
24
38
|
const t = s.toShip;
|
|
25
39
|
return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
|
|
26
40
|
}
|
|
27
41
|
export const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
|
|
28
42
|
|
|
29
|
-
export const
|
|
43
|
+
export const shipPill = (s) => (s.shipping ? `<span class="pill ship-pill" title="Shipping: ${esc(STEP_DOING[s.shipping.step] || 'Starting')}…"><i></i>Shipping</span>` : '');
|
|
44
|
+
|
|
45
|
+
export const prPill = (pr, fix) => `<span class="pill pr pr-${prTone(pr, fix)}" title="PR #${pr.number}: ${esc(prLine(pr, fix))}"><i></i>#${pr.number}</span>`;
|
|
30
46
|
|
|
31
47
|
// The PR row under a card's bar; reads the live summary, so polled check results show up.
|
|
32
48
|
export function renderPrStrip(id) {
|
|
@@ -39,25 +55,65 @@ export function renderPrStrip(id) {
|
|
|
39
55
|
b.hidden = !shippable(s);
|
|
40
56
|
$('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
|
|
41
57
|
});
|
|
58
|
+
// Started here, or elsewhere (another tab, the API): the server's stage moves the track along.
|
|
59
|
+
const run = ships.get(id) || (s.shipping && { steps: s.shipping.update ? ['push', 'pr'] : ['commit', 'push', 'pr'], step: 'push' });
|
|
60
|
+
if (run && STEP_LABEL[s.shipping?.step] && !run.error) run.step = s.shipping.step;
|
|
61
|
+
if (run) return renderShipRun(node, id, run);
|
|
62
|
+
const landed = node.classList.contains('ship-run') && !node.classList.contains('failed');
|
|
42
63
|
node.hidden = !pr;
|
|
43
64
|
if (!pr) return;
|
|
44
|
-
const tone = prTone(pr);
|
|
45
|
-
node.className = `pr-strip pr-${tone}`;
|
|
65
|
+
const tone = prTone(pr, s.conflictFix);
|
|
66
|
+
node.className = `pr-strip pr-${tone}${landed ? ' landed' : ''}`;
|
|
46
67
|
node.innerHTML = `
|
|
47
68
|
<span class="pr-dot"></span>
|
|
48
69
|
<a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
|
|
49
|
-
<span class="pr-line">${esc(prLine(pr))}</span>
|
|
70
|
+
<span class="pr-line">${esc(prLine(pr, s.conflictFix))}</span>
|
|
50
71
|
<span class="grow"></span>
|
|
72
|
+
${pr.state === 'OPEN' && pr.conflict && s.conflictFix?.step === 'failed' ? `<button class="ghost xs" data-pr="fix" title="Ask the agent to merge the base in and resolve the conflicts again">${icon('replay')}<span>Fix conflicts</span></button>` : ''}
|
|
51
73
|
${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
|
|
52
74
|
<a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>
|
|
53
|
-
${pr.state === 'OPEN' ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge, then fast-forward your local ${esc(s.branch ? 'base branch' : 'checkout')}">${icon('check')}<span>Merge</span></button>` : ''}`;
|
|
75
|
+
${pr.state === 'OPEN' && !pr.conflict ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge, then fast-forward your local ${esc(s.branch ? 'base branch' : 'checkout')}">${icon('check')}<span>Merge</span></button>` : ''}`;
|
|
54
76
|
node.onclick = (e) => {
|
|
55
77
|
const a = e.target.closest('[data-pr]')?.dataset.pr;
|
|
56
78
|
if (a === 'refresh') api(`/sessions/${id}/pr`, { method: 'POST', body: {} }).catch(toastError);
|
|
57
79
|
if (a === 'merge') mergePr(id);
|
|
80
|
+
if (a === 'fix') api(`/sessions/${id}/fix-conflicts`, { method: 'POST', body: {} }).catch(toastError);
|
|
58
81
|
};
|
|
59
82
|
}
|
|
60
83
|
|
|
84
|
+
// The in-flight row: the stages as a track that fills in as the server reports them. Built once
|
|
85
|
+
// and updated in place, so its entry animation and shimmer don't restart with every summary.
|
|
86
|
+
function renderShipRun(node, id, run) {
|
|
87
|
+
const at = Math.max(run.steps.indexOf(run.step), 0);
|
|
88
|
+
if (!node.classList.contains('ship-run') || node.dataset.steps !== run.steps.join()) {
|
|
89
|
+
node.dataset.steps = run.steps.join();
|
|
90
|
+
node.innerHTML = `
|
|
91
|
+
<span class="ship-icon">${icon('send')}</span>
|
|
92
|
+
<span class="ship-what"></span>
|
|
93
|
+
<ol class="ship-track">${run.steps.map((k) => `<li data-step="${k}"><i>${icon('check')}</i><span>${STEP_LABEL[k]}</span></li>`).join('')}</ol>
|
|
94
|
+
<span class="grow"></span>
|
|
95
|
+
<span class="ship-acts"></span>
|
|
96
|
+
<span class="ship-bar"></span>`;
|
|
97
|
+
node.onclick = (e) => {
|
|
98
|
+
const a = e.target.closest('[data-ship]')?.dataset.ship;
|
|
99
|
+
if (a === 'retry') shipDialog(id);
|
|
100
|
+
if (a === 'dismiss') {
|
|
101
|
+
ships.delete(id);
|
|
102
|
+
renderPrStrip(id);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const failed = !!run.error;
|
|
107
|
+
node.hidden = false;
|
|
108
|
+
node.className = `pr-strip ship-run${failed ? ' failed pr-fail' : ''}`;
|
|
109
|
+
node.title = failed ? run.error : '';
|
|
110
|
+
$$('.ship-track li', node).forEach((li, i) => (li.className = i < at ? 'done' : i === at ? (failed ? 'bad' : 'now') : ''));
|
|
111
|
+
const what = failed ? `Ship failed at ${STEP_LABEL[run.steps[at]] || 'start'}: <span class="ship-err">${esc(run.error)}</span>` : `${STEP_DOING[run.steps[at]]}<span class="dots"></span>`;
|
|
112
|
+
if ($('.ship-what', node).innerHTML !== what) $('.ship-what', node).innerHTML = what;
|
|
113
|
+
const acts = failed ? `<button class="ghost xs" data-ship="retry">${icon('replay')}<span>Retry</span></button><button class="ghost xs" data-ship="dismiss" title="Dismiss">${icon('x')}</button>` : '';
|
|
114
|
+
if ($('.ship-acts', node).innerHTML !== acts) $('.ship-acts', node).innerHTML = acts;
|
|
115
|
+
}
|
|
116
|
+
|
|
61
117
|
export async function shipDialog(id) {
|
|
62
118
|
const s = S.sessions.get(id);
|
|
63
119
|
if (!s) return;
|
|
@@ -69,6 +125,7 @@ export async function shipDialog(id) {
|
|
|
69
125
|
return toastError(err);
|
|
70
126
|
}
|
|
71
127
|
const open = info.pr?.state === 'OPEN';
|
|
128
|
+
const typed = ships.get(id)?.values; // a failed ship's message, back for Retry
|
|
72
129
|
const nothing = !info.dirty && !info.ahead;
|
|
73
130
|
const node = el(`
|
|
74
131
|
<div class="modal ship-modal">
|
|
@@ -85,23 +142,42 @@ export async function shipDialog(id) {
|
|
|
85
142
|
$('.close', node).onclick = close;
|
|
86
143
|
const title = $('[name="title"]', node);
|
|
87
144
|
const body = $('[name="body"]', node);
|
|
88
|
-
if (title) title.value = info.title;
|
|
89
|
-
if (body) body.value = info.body;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
close();
|
|
97
|
-
toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
|
|
98
|
-
} catch (err) {
|
|
99
|
-
btn.disabled = false;
|
|
100
|
-
btn.lastChild.textContent = open ? 'Commit & push' : 'Commit, push & open PR';
|
|
101
|
-
toastError(err, { timeout: 10_000 });
|
|
145
|
+
if (title) title.value = typed?.title || info.title;
|
|
146
|
+
if (body) body.value = typed?.body ?? info.body;
|
|
147
|
+
if (typed?.draft && $('[name="draft"]', node)) $('[name="draft"]', node).checked = true;
|
|
148
|
+
// Confirm closes the dialog at once; the card's PR row shows the stages, then the PR itself.
|
|
149
|
+
const go = () => {
|
|
150
|
+
if (title && !title.value.trim()) {
|
|
151
|
+
title.focus();
|
|
152
|
+
return toast('A commit message is required.', { tone: 'bad' });
|
|
102
153
|
}
|
|
154
|
+
const values = { title: title?.value || info.title, body: body?.value ?? info.body, draft: !!$('[name="draft"]', node)?.checked };
|
|
155
|
+
close();
|
|
156
|
+
ships.set(id, { steps: [...(info.dirty ? ['commit'] : []), 'push', 'pr'], step: info.dirty ? 'commit' : 'push', update: open, values });
|
|
157
|
+
renderPrStrip(id);
|
|
158
|
+
const strip = S.nodes.get(id)?.body?.querySelector('.pr-strip');
|
|
159
|
+
if (strip && !strip.hidden) strip.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
160
|
+
api(`/sessions/${id}/ship`, { method: 'POST', body: values }).then(
|
|
161
|
+
(pr) => {
|
|
162
|
+
const s = S.sessions.get(id);
|
|
163
|
+
// Everything just went up: no Push to #n until the server's recheck finds something new.
|
|
164
|
+
if (s) S.sessions.set(id, { ...s, pr: { ...s.pr, ...pr }, shipping: null, toShip: s.toShip && { ...s.toShip, dirty: 0, unpushed: 0 } });
|
|
165
|
+
ships.delete(id);
|
|
166
|
+
renderPrStrip(id);
|
|
167
|
+
toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
|
|
168
|
+
},
|
|
169
|
+
(err) => {
|
|
170
|
+
if (ships.has(id)) ships.get(id).error = err.message;
|
|
171
|
+
const s = S.sessions.get(id);
|
|
172
|
+
if (s) S.sessions.set(id, { ...s, shipping: null });
|
|
173
|
+
renderPrStrip(id);
|
|
174
|
+
toastError(err, { timeout: 10_000 });
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
$('[data-a="go"]', node).onclick = () => {
|
|
179
|
+
go();
|
|
103
180
|
};
|
|
104
|
-
$('[data-a="go"]', node).onclick = go;
|
|
105
181
|
requestAnimationFrame(() => (title || $('[data-a="go"]', node)).focus());
|
|
106
182
|
}
|
|
107
183
|
|