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.
@@ -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 chip = (id, name, color) => {
29
+ const badges = (id) => {
24
30
  const st = stats(id);
25
- return `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectTitle(projectById(id)))}">
26
- ${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${projectById(id)?.machine ? icon('server', 'p-where') : ''}${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}
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
- $('#projects').innerHTML = `${chip('all', 'All', 'slate')}${S.config.projects
30
- .slice()
31
- .sort((a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt))
32
- .map((p) => chip(p.id, p.name, p.color))
33
- .join('')}<button class="p-chip add" id="manageProjects" title="Manage projects">${icon('plus')}</button>`;
34
- $$('.p-chip[data-id]').forEach((b) =>
35
- b.addEventListener('click', () => {
36
- S.filter = S.filter === b.dataset.id ? 'all' : b.dataset.id;
37
- save('wisp.filter', S.filter);
38
- renderFeed();
39
- feedToTop();
40
- renderLaunchChips();
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
- $('.hint', node).innerHTML = m
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 loadConfig();
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
- box.innerHTML = projects.length
206
- ? projects
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
- S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
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();
@@ -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
- S.agents = await api('/agents/install', { method: 'POST', body: { id } });
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
- S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
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.');
@@ -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
- const prTone = (pr) =>
11
- pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.checks?.fail ? 'fail' : pr.checks?.pending ? 'pending' : pr.review === 'CHANGES_REQUESTED' ? 'fail' : 'pass';
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 prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
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
- const go = async () => {
91
- const btn = $('[data-a="go"]', node);
92
- btn.disabled = true;
93
- btn.lastChild.textContent = 'Shipping…';
94
- try {
95
- const pr = await api(`/sessions/${id}/ship`, { method: 'POST', body: { title: title?.value || info.title, body: body?.value ?? info.body, draft: !!$('[name="draft"]', node)?.checked } });
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