wisp-agents 0.17.0 → 0.20.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 +72 -1
- package/README.md +20 -7
- package/bin/wisp.js +4 -2
- package/package.json +2 -2
- package/server/adapters/antigravity.js +2 -1
- package/server/adapters/claude.js +2 -1
- package/server/adapters/codex.js +2 -1
- package/server/adapters/commands.js +808 -0
- package/server/adapters/cursor.js +2 -1
- package/server/adapters/grok.js +2 -1
- package/server/adapters/opencode.js +2 -1
- package/server/check.js +205 -0
- package/server/git.js +114 -2
- package/server/index.js +2 -1
- package/server/judge.js +78 -0
- package/server/machines.js +2 -2
- package/server/openapi.js +19 -2
- package/server/pipeline.js +217 -0
- package/server/quick.js +76 -0
- package/server/router.js +201 -28
- package/server/routes/auto.js +8 -1
- package/server/routes/meta.js +1 -1
- package/server/routes/settings.js +53 -4
- package/server/routes/ship.js +29 -2
- package/server/runner.js +65 -15
- package/server/scout.js +71 -0
- package/server/services/auto.js +115 -15
- package/server/services/launch.js +22 -13
- package/server/services/ship.js +107 -6
- package/server/store.js +15 -0
- package/web/css/auto.css +22 -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 +44 -0
- package/web/index.html +16 -5
- package/web/js/app.js +14 -3
- package/web/js/blocks.js +1 -1
- package/web/js/core/state.js +2 -1
- package/web/js/features/actions.js +2 -4
- package/web/js/features/auto.js +38 -7
- package/web/js/features/clones.js +79 -0
- package/web/js/features/composer.js +2 -4
- package/web/js/features/feed.js +34 -4
- package/web/js/features/launcher.js +139 -19
- package/web/js/features/live.js +5 -10
- package/web/js/features/project-list.js +99 -0
- package/web/js/features/projects.js +220 -39
- package/web/js/features/runners.js +7 -6
- package/web/js/features/settings.js +47 -7
- package/web/js/features/setup.js +3 -3
- package/web/js/features/ship.js +128 -25
- package/web/js/features/turns.js +4 -3
- package/web/js/ui/popover.js +10 -0
package/web/js/features/auto.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
// Auto: what the router (server/router.js) learned about which harness and
|
|
1
|
+
// Auto: what the router (server/router.js) learned about which harness, model and thinking level work for you.
|
|
2
2
|
|
|
3
3
|
import { esc } from '../md.js';
|
|
4
4
|
import { api } from '../core/api.js';
|
|
5
5
|
import * as bus from '../core/bus.js';
|
|
6
|
-
import { $, el, icon } from '../core/dom.js';
|
|
6
|
+
import { $, $$, el, icon } from '../core/dom.js';
|
|
7
7
|
import { S } from '../core/state.js';
|
|
8
8
|
import { present } from '../ui/overlay.js';
|
|
9
|
-
import { toastError } from '../ui/toast.js';
|
|
9
|
+
import { toast, toastError } from '../ui/toast.js';
|
|
10
10
|
import { AUTO_GOALS, harnessLogo } from './runners.js';
|
|
11
11
|
|
|
12
|
-
const ARM_STATE = { ready: ['Ready', 'ok'], new: ['Trying out', 'warn'], untried: ['No data yet', ''], gone: ['Not installed', 'bad'] };
|
|
12
|
+
const ARM_STATE = { ready: ['Ready', 'ok'], new: ['Trying out', 'warn'], untried: ['No data yet', ''], cooling: ['At its limit', 'warn'], gone: ['Not installed', 'bad'] };
|
|
13
|
+
const TIER = { 1: 'small', 2: 'everyday', 3: 'frontier' };
|
|
14
|
+
const untilLabel = (t) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
13
15
|
export async function openRouter({ host } = {}) {
|
|
14
16
|
const node = el(`<div class="modal router-modal">
|
|
15
17
|
<header><h2>Auto</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
@@ -23,15 +25,22 @@ export async function openRouter({ host } = {}) {
|
|
|
23
25
|
if (!r || !node.isConnected) return;
|
|
24
26
|
const arms = r.arms.filter((a) => a.state !== 'gone' || a.n);
|
|
25
27
|
$('.ro-body', node).innerHTML = `
|
|
26
|
-
<p class="hint">For each task Auto estimates every harness · model’s chance of doing it well, from wisp-bench (${esc(r.priors.version)}) and then more and more from your own tasks: merged PRs, 👍 / 👎, rollbacks, follow-ups and how the run ended. It picks the best value for your goal (<b>${esc(AUTO_GOALS.find((g) => g.id === r.goal)?.label || '')}</b>),
|
|
27
|
-
<h4>Harness · model</h4>
|
|
28
|
+
<p class="hint">For each task Auto estimates every harness · model · thinking level’s chance of doing it well, from wisp-bench (${esc(r.priors.version)}) and then more and more from your own tasks: merged PRs, 👍 / 👎, rollbacks, follow-ups and how the run ended. It picks the best value for your goal (<b>${esc(AUTO_GOALS.find((g) => g.id === r.goal)?.label || '')}</b>), which also sets how hard it thinks: Cheap low, Balanced default or medium, Best high. Best runs frontier models only (Opus, not Sonnet or Haiku). Now and then it tries an alternative so the estimates stay honest.${r.coldStart && r.coldStart.have < r.coldStart.needed ? ` Until it has ${r.coldStart.needed} of your own outcomes (${r.coldStart.have} so far), Balanced uses everyday and frontier models only.` : ''}</p>
|
|
29
|
+
<h4>Harness · model · thinking</h4>
|
|
28
30
|
<table class="us-table ro-table"><thead><tr><th></th><th>Works</th><th>Your tasks</th><th>~Cost</th><th>~Time</th><th></th></tr></thead><tbody>${arms
|
|
29
31
|
.map((a) => {
|
|
30
32
|
const [state, tone] = ARM_STATE[a.state] || ARM_STATE.ready;
|
|
31
33
|
const types = Object.entries(a.byType).map(([t, x]) => `${t} ${pctOf(x.chance)} (${x.n})`).join(' · ');
|
|
32
|
-
return `<tr><td><span class="us-runner"><span class="model-logo">${harnessLogo(a.agent)}</span>${esc(a.label)}</span>${types ? `<small class="ro-types">${esc(types)}</small>` : ''}</td>${a.state === 'untried' ? '<td>—</td><td>—</td><td>—</td><td>—</td>' : `<td><b>${pctOf(a.chance)}</b></td><td>${a.n || '—'}</td><td>${a.usd ? `$${a.usd.toFixed(2)}` : '$0'}</td><td>${a.minutes}m</td>`}<td><span class="sx-state ${tone}"><i class="dot ${tone}"></i>${state}</span></td></tr>`;
|
|
34
|
+
return `<tr><td><span class="us-runner"><span class="model-logo">${harnessLogo(a.agent)}</span>${esc(a.label)}<i class="ro-tier t${a.tier}" title="${TIER[a.tier]} tier">${TIER[a.tier]}</i></span>${types ? `<small class="ro-types">${esc(types)}</small>` : ''}</td>${a.state === 'untried' ? '<td>—</td><td>—</td><td>—</td><td>—</td>' : `<td><b>${pctOf(a.chance)}</b></td><td>${a.n || '—'}</td><td>${a.usd ? `$${a.usd.toFixed(2)}` : '$0'}</td><td>${a.minutes}m</td>`}<td><span class="sx-state ${tone}" ${a.coolingUntil ? `title="${esc(`${a.coolingWhy || 'At its limit'} · back around ${untilLabel(a.coolingUntil)}`)}"` : ''}><i class="dot ${tone}"></i>${state}${a.coolingUntil ? ` until ${untilLabel(a.coolingUntil)}` : ''}</span></td></tr>`;
|
|
33
35
|
})
|
|
34
36
|
.join('')}</tbody></table>
|
|
37
|
+
<h4>Tests per project</h4>
|
|
38
|
+
<p class="hint">What wisp runs to check an Auto task. Leave it empty to use what wisp found, or type <code>off</code> to skip the check in that project.</p>
|
|
39
|
+
${r.projects?.length
|
|
40
|
+
? `<div class="ro-tests">${r.projects
|
|
41
|
+
.map((p) => `<label class="ro-test"><span class="us-proj c-${esc(p.color || 'slate')}"><i class="p-dot"></i>${esc(p.name)}</span><input class="input mono" data-id="${esc(p.id)}" value="${esc(p.testCommand)}" placeholder="${esc(p.detected?.command || (p.detected?.skip ? `none: ${p.detected.skip}` : 'none found'))}" spellcheck="false"></label>`)
|
|
42
|
+
.join('')}</div>`
|
|
43
|
+
: '<p class="mem-empty">Add a project to set its test command.</p>'}
|
|
35
44
|
<h4>Recent picks</h4>
|
|
36
45
|
${r.recent.length
|
|
37
46
|
? `<ul class="ro-recent">${r.recent
|
|
@@ -42,6 +51,28 @@ export async function openRouter({ host } = {}) {
|
|
|
42
51
|
.join('')}</ul>`
|
|
43
52
|
: '<p class="mem-empty">Nothing yet. Launch with Auto and its picks show up here.</p>'}
|
|
44
53
|
<div class="ro-foot"><button class="ghost sm danger" id="roReset">Forget what Auto learned</button><span class="hint">The wisp-bench starting point stays.</span></div>`;
|
|
54
|
+
for (const input of $$('.ro-test input', node)) {
|
|
55
|
+
let saved = input.value;
|
|
56
|
+
const save = async () => {
|
|
57
|
+
const v = input.value.trim();
|
|
58
|
+
if (v === saved) return;
|
|
59
|
+
const prev = saved;
|
|
60
|
+
saved = v;
|
|
61
|
+
try {
|
|
62
|
+
await api(`/projects/${input.dataset.id}`, { method: 'PATCH', body: { testCommand: v } });
|
|
63
|
+
toast(v ? `Auto will check with <code>${esc(v)}</code>` : 'Back to the test command wisp finds');
|
|
64
|
+
} catch (err) {
|
|
65
|
+
saved = input.value = prev;
|
|
66
|
+
toastError(err);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
input.onblur = () => {
|
|
70
|
+
save();
|
|
71
|
+
};
|
|
72
|
+
input.onkeydown = (e) => {
|
|
73
|
+
if (e.key === 'Enter') input.blur();
|
|
74
|
+
};
|
|
75
|
+
}
|
|
45
76
|
$('#roReset', node).onclick = async () => {
|
|
46
77
|
if (!confirm('Forget what Auto learned from your tasks?')) return;
|
|
47
78
|
await api('/router/reset', { method: 'POST', body: {} }).catch(toastError);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Repos being cloned. Clone in the Projects dialog hands the repo off here and the dialog closes:
|
|
2
|
+
// the launcher picks it at once (a spinner chip) so you can write the task while it clones, and a
|
|
3
|
+
// launch into it waits for the clone. Success swaps the chip for the real project.
|
|
4
|
+
|
|
5
|
+
import { esc } from '../md.js';
|
|
6
|
+
import { api } from '../core/api.js';
|
|
7
|
+
import * as bus from '../core/bus.js';
|
|
8
|
+
import { launch, S, save, saveLaunch } from '../core/state.js';
|
|
9
|
+
import { toast } from '../ui/toast.js';
|
|
10
|
+
import { renderFeed } from './feed.js';
|
|
11
|
+
import { renderLaunchChips } from './launcher.js';
|
|
12
|
+
import { loadConfig } from './live.js';
|
|
13
|
+
import { machineById, projectById } from './places.js';
|
|
14
|
+
import { renderProjects } from './projects.js';
|
|
15
|
+
|
|
16
|
+
// A pasted repo URL, which the server clones (server/git.js parseRepo has the full rules), vs a path.
|
|
17
|
+
export const isRepo = (s) => /^((https?|ssh|git):\/\/|[\w.-]+@[\w.-]+:|(www\.)?github\.com\/)/i.test(String(s).trim());
|
|
18
|
+
export const repoLabel = (s) =>
|
|
19
|
+
String(s)
|
|
20
|
+
.trim()
|
|
21
|
+
.replace(/^((https?|ssh):\/\/)?([\w.-]+@)?(www\.)?github\.com[/:]([^/]+\/[^/#?]+).*$/i, '$5')
|
|
22
|
+
.replace(/\.git$|\/+$/i, '');
|
|
23
|
+
export const repoName = (s) => repoLabel(s).split(/[/:]/).pop();
|
|
24
|
+
|
|
25
|
+
export const clones = []; // { id, url, where, name, label, promise }
|
|
26
|
+
export const cloneById = (id) => clones.find((c) => c.id === id);
|
|
27
|
+
let seq = 0;
|
|
28
|
+
|
|
29
|
+
const tilde = (p) => (S.config.home && p.startsWith(S.config.home) ? `~${p.slice(S.config.home.length)}` : p);
|
|
30
|
+
|
|
31
|
+
// Start cloning `url` on `where` (a machine id, or null for this computer) and point the launcher
|
|
32
|
+
// at it. job.promise resolves with the project, or rejects (already toasted) and the launcher
|
|
33
|
+
// goes back to what it had picked.
|
|
34
|
+
export function startClone(url, where = null) {
|
|
35
|
+
const k = where || '';
|
|
36
|
+
const prev = launch.picks[k];
|
|
37
|
+
const job = { id: `clone-${++seq}`, url, where: where || null, name: repoName(url), label: repoLabel(url) };
|
|
38
|
+
clones.push(job);
|
|
39
|
+
// A feed filtered to another project would pull the launcher back to that project.
|
|
40
|
+
if (S.filter !== 'all') {
|
|
41
|
+
S.filter = 'all';
|
|
42
|
+
save('wisp.filter', S.filter);
|
|
43
|
+
renderFeed();
|
|
44
|
+
renderProjects();
|
|
45
|
+
}
|
|
46
|
+
launch.picks[k] = job.id;
|
|
47
|
+
if (!launch.where.includes(k)) launch.where = [k];
|
|
48
|
+
if (k === launch.where[0]) launch.project = job.id;
|
|
49
|
+
const repick = (id) => {
|
|
50
|
+
if (launch.picks[k] !== job.id) return; // picked something else meanwhile
|
|
51
|
+
launch.picks[k] = id;
|
|
52
|
+
if (k === launch.where[0]) launch.project = id;
|
|
53
|
+
saveLaunch();
|
|
54
|
+
};
|
|
55
|
+
const on = where ? ` on ${esc(machineById(where)?.name || 'the machine')}` : '';
|
|
56
|
+
job.promise = api('/projects', { method: 'POST', body: { repo: url, ...(where && { machine: where }) } })
|
|
57
|
+
.then(
|
|
58
|
+
async (proj) => {
|
|
59
|
+
await loadConfig();
|
|
60
|
+
repick(proj.id);
|
|
61
|
+
toast(proj.cloned?.existed ? `<b>${esc(proj.name)}</b> was already cloned at ${esc(tilde(proj.path))}: added it` : `Cloned <b>${esc(proj.name)}</b>${on}${where ? '' : ` into ${esc(tilde(proj.path))}`}`);
|
|
62
|
+
return proj;
|
|
63
|
+
},
|
|
64
|
+
(err) => {
|
|
65
|
+
repick(projectById(prev) ? prev : null);
|
|
66
|
+
toast(esc(err.message), { tone: 'bad', timeout: 8000 });
|
|
67
|
+
throw Object.assign(err, { shown: true });
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
.finally(() => {
|
|
71
|
+
clones.splice(clones.indexOf(job), 1);
|
|
72
|
+
renderLaunchChips();
|
|
73
|
+
bus.emit('clones');
|
|
74
|
+
});
|
|
75
|
+
job.promise.catch(() => {}); // the launcher or dialog may not be waiting on it
|
|
76
|
+
renderLaunchChips();
|
|
77
|
+
bus.emit('clones');
|
|
78
|
+
return job;
|
|
79
|
+
}
|
|
@@ -5,7 +5,7 @@ import { mentionHtml } from '../mention-text.js';
|
|
|
5
5
|
import { api } from '../core/api.js';
|
|
6
6
|
import { $, el, icon } from '../core/dom.js';
|
|
7
7
|
import { S } from '../core/state.js';
|
|
8
|
-
import { popover } from '../ui/popover.js';
|
|
8
|
+
import { clampToViewport, popover } from '../ui/popover.js';
|
|
9
9
|
import { toast, toastError } from '../ui/toast.js';
|
|
10
10
|
import { toggleDone } from './actions.js';
|
|
11
11
|
import { acceptFiles, attachTray, autosize } from './attachments.js';
|
|
@@ -226,9 +226,7 @@ function queuedMenu(e, cancel) {
|
|
|
226
226
|
if (a === 'cancel') cancel();
|
|
227
227
|
};
|
|
228
228
|
const close = popover(menu);
|
|
229
|
-
|
|
230
|
-
if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
|
|
231
|
-
if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
|
|
229
|
+
clampToViewport(menu, e.clientX, e.clientY);
|
|
232
230
|
}
|
|
233
231
|
|
|
234
232
|
// Follow-ups sent while an agent works interrupt it, unless the user flipped this on.
|
package/web/js/features/feed.js
CHANGED
|
@@ -13,7 +13,7 @@ import { machinePill } from './places.js';
|
|
|
13
13
|
import { renderProjects } from './projects.js';
|
|
14
14
|
import { agentName, effortLabel, harnessLogo, modelBadge } from './runners.js';
|
|
15
15
|
import { scrollByPx, stage, topInset } from './scroll.js';
|
|
16
|
-
import { prPill } from './ship.js';
|
|
16
|
+
import { prPill, shipPill } from './ship.js';
|
|
17
17
|
import { teamStack } from './team.js';
|
|
18
18
|
import { renderBody } from './turns.js';
|
|
19
19
|
|
|
@@ -208,7 +208,8 @@ function renderHead(n, s) {
|
|
|
208
208
|
<span class="it-side">
|
|
209
209
|
${pill}
|
|
210
210
|
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
211
|
-
${
|
|
211
|
+
${autoPill(s)}
|
|
212
|
+
${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
|
|
212
213
|
${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort, s.route?.switched ? null : s.route)}
|
|
213
214
|
${s.machine ? machinePill(s.machine) : ''}
|
|
214
215
|
<span class="q-proj">${esc(s.project)}</span>
|
|
@@ -218,6 +219,28 @@ function renderHead(n, s) {
|
|
|
218
219
|
</span>`;
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
// What Auto found after the run: its own test check, the best-of-2 pick, a hand-over.
|
|
223
|
+
function autoPill(s) {
|
|
224
|
+
const a = s.auto;
|
|
225
|
+
if (!a || s.pending) return '';
|
|
226
|
+
const out = [];
|
|
227
|
+
if (a.pick?.winner) out.push(`<span class="pill auto-pick" title="${esc(`wisp's pick of the two runs: ${a.pick.why}`)}">${icon('spark')}Pick</span>`);
|
|
228
|
+
if (a.escalatedTo) {
|
|
229
|
+
const to = S.sessions.get(a.escalatedTo);
|
|
230
|
+
const toName = to ? `${agentName(to.agent)}${to.model ? ` · ${to.model}` : ''}` : 'another pick';
|
|
231
|
+
const why = a.reason === 'limit' ? `${agentName(s.agent)} was at its usage limit, so wisp moved the task to ${toName}` : a.reason === 'error' ? `The run failed, so wisp handed the task to ${toName}` : `Tests still failed, so wisp handed the task to ${toName}`;
|
|
232
|
+
out.push(`<span class="pill auto-handed" title="${esc(why)}">${a.reason === 'limit' ? 'Moved on' : 'Handed on'}</span>`);
|
|
233
|
+
}
|
|
234
|
+
const c = a.check;
|
|
235
|
+
if (c && !a.busy && c.verdict !== 'skip') {
|
|
236
|
+
const n = c.newFailures || c.failed;
|
|
237
|
+
const tip = c.verdict === 'pass' ? `wisp ran ${c.command}: no new failures${c.note ? ` (${c.note})` : ''}` : c.verdict === 'fail' ? `wisp ran ${c.command}: ${n} test${n === 1 ? '' : 's'} that passed before now fail${a.repairs ? `, after ${a.repairs} repair round${a.repairs > 1 ? 's' : ''}` : ''}` : `wisp ran ${c.command}: ${c.note || "couldn't tell"}`;
|
|
238
|
+
const label = c.verdict === 'pass' ? 'Tests' : c.verdict === 'fail' ? `${n} failing` : 'Tests ?';
|
|
239
|
+
out.push(`<span class="pill auto-ck ck-${c.verdict}" title="${esc(tip)}">${icon(c.verdict === 'pass' ? 'check' : c.verdict === 'fail' ? 'x' : 'info')}${label}</span>`);
|
|
240
|
+
}
|
|
241
|
+
return out.join('');
|
|
242
|
+
}
|
|
243
|
+
|
|
221
244
|
// One run inside a compare task: the harness and model stand in for the title the task shows.
|
|
222
245
|
function renderRunHead(n, s, { open, live, pill }) {
|
|
223
246
|
const a = S.agents.find((x) => x.id === s.agent);
|
|
@@ -233,7 +256,8 @@ function renderRunHead(n, s, { open, live, pill }) {
|
|
|
233
256
|
${runsOf(s).some((r) => (r.machine?.id || '') !== (s.machine?.id || '')) ? `${s.machine ? machinePill(s.machine) : `<span class="q-machine" title="Runs on this computer">${icon('laptop')}<span>This computer</span></span>`}<span class="q-proj">${esc(s.project)}</span>` : ''}
|
|
234
257
|
${live && s.state === 'running' ? `<span class="pill st-running"><i></i>Working</span>` : pill}
|
|
235
258
|
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
236
|
-
${
|
|
259
|
+
${autoPill(s)}
|
|
260
|
+
${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
|
|
237
261
|
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
238
262
|
${icon('chevron', 'chev')}
|
|
239
263
|
</span>`;
|
|
@@ -257,7 +281,13 @@ function taskItem(t) {
|
|
|
257
281
|
g.lead = t.id;
|
|
258
282
|
const finished = t.runs.filter((r) => r.state === 'review' || r.state === 'done').length;
|
|
259
283
|
const places = new Set(t.runs.map((r) => r.machine?.id || '')).size;
|
|
260
|
-
|
|
284
|
+
// Auto's groups say what Auto did: which run it picked, or who it handed the task to.
|
|
285
|
+
const picked = t.runs.find((r) => r.auto?.pick?.winner);
|
|
286
|
+
const handed = t.runs.find((r) => r.auto?.escalatedFrom);
|
|
287
|
+
const runName = (r) => `${agentName(r.agent)}${r.model ? ` · ${r.model}` : ''}`;
|
|
288
|
+
const how = handed?.auto?.reason === 'limit' ? 'The first harness was at its usage limit, so wisp moved it to' : handed?.auto?.reason === 'error' ? 'The first run failed, so wisp handed it to' : 'Tests failed, so wisp handed it to';
|
|
289
|
+
const autoLine = picked ? `wisp's pick: ${picked.auto.pick.why}` : handed ? `${how} ${runName(handed)}${handed.auto?.check?.verdict === 'pass' ? ' · tests pass now' : handed.auto?.check?.verdict === 'fail' ? ' · still failing' : ''}` : '';
|
|
290
|
+
const status = t.state === 'waiting' ? 'A run is asking you' : t.state === 'running' ? `${finished} of ${t.runs.length} finished${places > 1 ? ` · on ${places} machines` : ''}` : t.state === 'done' ? 'Done' : autoLine || (places > 1 ? `All finished on ${places} machines` : 'All finished · pick one to keep');
|
|
261
291
|
g.card.className = `task-item c-${t.color} st-${t.state} ${t.unread ? 'unread' : ''}`;
|
|
262
292
|
g.head.innerHTML = `
|
|
263
293
|
<span class="q-dot ${t.state === 'running' ? 'live' : ''} ${t.state === 'waiting' ? 'waiting' : ''}"></span>
|
|
@@ -12,12 +12,14 @@ import { popover } from '../ui/popover.js';
|
|
|
12
12
|
import { toast, toastError } from '../ui/toast.js';
|
|
13
13
|
import { acceptFiles, attachTray, autosize } from './attachments.js';
|
|
14
14
|
import { openAutomations, recipeParams, runRecipe } from './automations.js';
|
|
15
|
+
import { cloneById, clones } from './clones.js';
|
|
15
16
|
import { renderFeed } from './feed.js';
|
|
16
17
|
import { feedbackCommand, isFeedback } from './feedback.js';
|
|
17
18
|
import { connectMachine, offlineToast, openMachines } from './machines.js';
|
|
18
19
|
import { mentionBox } from './mentions.js';
|
|
19
20
|
import { askNotify } from './notify.js';
|
|
20
21
|
import { MACHINE_STATE, machineById, placeName, placeOf, projectById, projectsAt, projectTitle } from './places.js';
|
|
22
|
+
import { projectList } from './project-list.js';
|
|
21
23
|
import { openProjects } from './projects.js';
|
|
22
24
|
import { runnerPicker } from './runner-picker.js';
|
|
23
25
|
import { needsSetup, nudgeSetup, renderSetup } from './setup.js';
|
|
@@ -25,16 +27,27 @@ import { agentName, agentsAt, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt
|
|
|
25
27
|
import { feedAnchor, keepInPlace, stage, topInset } from './scroll.js';
|
|
26
28
|
import { canLead, paintTeamBtn, teamEditor } from './team.js';
|
|
27
29
|
|
|
30
|
+
// "No folder": a general ask with no project. The server gives it an empty scratch directory.
|
|
31
|
+
const NO_FOLDER = 'none';
|
|
32
|
+
const GENERAL = { id: null, name: 'General', path: '', color: 'slate', scratch: true };
|
|
33
|
+
const projectOf = (k) => (launch.picks[k] === NO_FOLDER ? GENERAL : projectById(launch.picks[k]));
|
|
34
|
+
// Project chips in the "in" row, at most, before the rest fold into "+N".
|
|
35
|
+
const CHIPS = 5;
|
|
36
|
+
|
|
37
|
+
// Only a change of feed filter moves the launcher, so a "No folder" pick survives re-renders.
|
|
38
|
+
let filterSeen = null;
|
|
39
|
+
|
|
28
40
|
function syncWhere() {
|
|
29
41
|
launch.where = launch.where.filter((k, i) => (!k || machineById(k)) && launch.where.indexOf(k) === i);
|
|
30
42
|
if (!launch.where.length) launch.where = [''];
|
|
31
43
|
// Filtering the feed to a project points the launcher at it (its machine joins if it wasn't picked).
|
|
32
|
-
const f = S.filter !== 'all' && projectById(S.filter);
|
|
44
|
+
const f = S.filter !== 'all' && S.filter !== filterSeen && projectById(S.filter);
|
|
45
|
+
filterSeen = S.filter;
|
|
33
46
|
if (f) {
|
|
34
47
|
if (!launch.where.includes(placeOf(f))) launch.where = [placeOf(f)];
|
|
35
48
|
launch.picks[placeOf(f)] = f.id;
|
|
36
49
|
} else if (projectById(launch.project) && !Object.values(launch.picks).includes(launch.project)) launch.picks[placeOf(projectById(launch.project))] ??= launch.project;
|
|
37
|
-
for (const k of launch.where) if (!projectsAt(k).some((p) => p.id === launch.picks[k])) launch.picks[k] = projectsAt(k)[0]?.id || null;
|
|
50
|
+
for (const k of launch.where) if (launch.picks[k] !== NO_FOLDER && !cloneById(launch.picks[k]) && !projectsAt(k).some((p) => p.id === launch.picks[k])) launch.picks[k] = projectsAt(k)[0]?.id || null;
|
|
38
51
|
launch.project = launch.picks[launch.where[0]] || null;
|
|
39
52
|
}
|
|
40
53
|
|
|
@@ -44,6 +57,12 @@ export function renderLauncher() {
|
|
|
44
57
|
const here = harnessesAt(launch.where, { auto: true });
|
|
45
58
|
const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'auto'));
|
|
46
59
|
let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
|
|
60
|
+
// Rendered over what's there (index.html's first paint, or fresh settings or harnesses): what
|
|
61
|
+
// was typed, and the caret if you're typing, carry over.
|
|
62
|
+
const old = $('#prompt', box);
|
|
63
|
+
const typing = old && document.activeElement === old && [old.selectionStart, old.selectionEnd];
|
|
64
|
+
if (old) S.drafts.set('home', old.value);
|
|
65
|
+
box.classList.remove('skel');
|
|
47
66
|
box.innerHTML = `
|
|
48
67
|
<div class="l-projects"></div>
|
|
49
68
|
<div class="l-setup" hidden></div>
|
|
@@ -79,6 +98,7 @@ export function renderLauncher() {
|
|
|
79
98
|
ta.addEventListener('focus', hint);
|
|
80
99
|
ta.addEventListener('blur', hint);
|
|
81
100
|
hint();
|
|
101
|
+
if (typing) (ta.focus(), ta.setSelectionRange(...typing));
|
|
82
102
|
// Docked and folded to one line, a click anywhere on it opens it up for typing. A property, not
|
|
83
103
|
// a listener: the launcher re-renders in place, and each render would add another.
|
|
84
104
|
box.onmousedown = (e) => {
|
|
@@ -230,8 +250,8 @@ export function renderLauncher() {
|
|
|
230
250
|
return feedbackCommand(prompt, { restore: () => !ta.value && ((ta.value = prompt), ta.dispatchEvent(new Event('input'))) });
|
|
231
251
|
}
|
|
232
252
|
// One run per place picked under "Run on", each in the project picked for it.
|
|
233
|
-
const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project:
|
|
234
|
-
const unset = targets.find((t) => !t.project);
|
|
253
|
+
const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project: projectOf(k), clone: cloneById(launch.picks[k]) }));
|
|
254
|
+
const unset = targets.find((t) => !t.project && !t.clone);
|
|
235
255
|
if (unset) return openProjects({ focusAdd: true, machine: unset.k || null });
|
|
236
256
|
const off = targets.find((t) => t.m && t.m.state !== 'online');
|
|
237
257
|
if (off) return offlineToast(off.k);
|
|
@@ -250,12 +270,13 @@ export function renderLauncher() {
|
|
|
250
270
|
const files = tray.take();
|
|
251
271
|
const cmp = compare;
|
|
252
272
|
const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
|
|
253
|
-
|
|
273
|
+
// A repo still cloning gets a stand-in project; its card says so until the clone lands.
|
|
274
|
+
const pending = targets.map((t) => addPending(prompt, t.project ? t : { ...t, project: { id: t.clone.id, name: t.clone.name, path: '', color: 'slate' } }, runner, t.clone && `Cloning ${t.clone.label}`));
|
|
254
275
|
if (cmp.length) (compare = []), paintCompare();
|
|
255
276
|
ta.value = '';
|
|
256
277
|
S.drafts.delete('home');
|
|
257
278
|
ta.dispatchEvent(new Event('input'));
|
|
258
|
-
if (S.filter !== 'all' && targets.some((t) => t.project.id !== S.filter)) {
|
|
279
|
+
if (S.filter !== 'all' && targets.some((t) => t.project?.scratch || t.project?.id !== S.filter)) {
|
|
259
280
|
S.filter = 'all';
|
|
260
281
|
save('wisp.filter', S.filter);
|
|
261
282
|
}
|
|
@@ -264,13 +285,20 @@ export function renderLauncher() {
|
|
|
264
285
|
showLaunched(btn, ghost);
|
|
265
286
|
ta.focus();
|
|
266
287
|
try {
|
|
288
|
+
for (const [i, t] of targets.entries()) {
|
|
289
|
+
if (!t.clone) continue;
|
|
290
|
+
const p = (t.project = await t.clone.promise);
|
|
291
|
+
// Now a real project, so the live session can replace the card (dropPending).
|
|
292
|
+
const card = S.sessions.get(pending[i]);
|
|
293
|
+
if (card) Object.assign(card, { projectId: p.id, project: p.name, cwd: p.path, color: p.color, live: 'Starting up' }), renderFeed();
|
|
294
|
+
}
|
|
267
295
|
// Several places: one shared groupId, so the runs land as one task with a row each.
|
|
268
296
|
const groupId = targets.length > 1 ? [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(16).padStart(2, '0')).join('') : undefined;
|
|
269
297
|
const results = await Promise.allSettled(
|
|
270
298
|
targets.map((t) =>
|
|
271
299
|
api('/sessions', {
|
|
272
300
|
method: 'POST',
|
|
273
|
-
body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
|
|
301
|
+
body: { prompt, cwd: t.project.scratch ? undefined : t.project.path, scratch: t.project.scratch || undefined, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
|
|
274
302
|
}),
|
|
275
303
|
),
|
|
276
304
|
);
|
|
@@ -291,7 +319,10 @@ export function renderLauncher() {
|
|
|
291
319
|
} catch (err) {
|
|
292
320
|
for (const id of pending) S.sessions.delete(id);
|
|
293
321
|
renderFeed();
|
|
294
|
-
toastError(err);
|
|
322
|
+
if (!err.shown) return toastError(err);
|
|
323
|
+
// The clone failed (its toast says why): the task goes back in the box.
|
|
324
|
+
if (!ta.value) ((ta.value = prompt), ta.dispatchEvent(new Event('input')));
|
|
325
|
+
tray.restore(files);
|
|
295
326
|
}
|
|
296
327
|
};
|
|
297
328
|
$('#launch', box).onclick = go;
|
|
@@ -321,6 +352,12 @@ export function renderLauncher() {
|
|
|
321
352
|
});
|
|
322
353
|
}
|
|
323
354
|
|
|
355
|
+
// No folder everywhere picked: no git repo, so no worktree either; the toggle would only mislead.
|
|
356
|
+
function paintScratch() {
|
|
357
|
+
const wt = $('#launcher #wtBtn');
|
|
358
|
+
if (wt) wt.hidden = launch.where.every((k) => launch.picks[k] === NO_FOLDER);
|
|
359
|
+
}
|
|
360
|
+
|
|
324
361
|
export function renderLaunchChips() {
|
|
325
362
|
const box = $('#launcher .l-projects');
|
|
326
363
|
if (!box) return;
|
|
@@ -332,23 +369,70 @@ export function renderLaunchChips() {
|
|
|
332
369
|
const list = projectsAt(k);
|
|
333
370
|
const off = m && m.state !== 'online';
|
|
334
371
|
const label = k || multi ? `<span class="lp-gl ${off ? 'off' : ''}" title="${esc(k ? `${m?.target} · ${MACHINE_STATE[m?.state] || ''}` : 'This computer')}">${icon(k ? 'server' : 'laptop')}${esc(placeName(k))}${off ? `<button class="link xs" data-connect="${k}">${m.state === 'connecting' || m.state === 'reconnecting' ? 'connecting…' : 'Connect'}</button>` : ''}</span>` : '';
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
372
|
+
// A repo still cloning: picked, with a spinner, until the real project replaces it.
|
|
373
|
+
const cloning = clones
|
|
374
|
+
.filter((c) => (c.where || '') === k)
|
|
375
|
+
.map((c) => `<button class="lp-chip cloning ${c.id === launch.picks[k] ? 'on' : ''}" data-id="${c.id}" data-k="${k}" title="Cloning ${esc(c.label)}…"><span class="orb live"></span>${esc(c.name)}<span class="lp-note">cloning…</span></button>`)
|
|
376
|
+
.join('');
|
|
377
|
+
// Tens of projects: the most recent few as chips (the pick always among them), the rest one
|
|
378
|
+
// search away behind "+N". fitChips() then folds more into it if the row would wrap.
|
|
379
|
+
const max = multi ? 3 : CHIPS;
|
|
380
|
+
let shown = list;
|
|
381
|
+
if (list.length > max) {
|
|
382
|
+
const top = list.slice(0, max);
|
|
383
|
+
const picked = list.find((p) => p.id === launch.picks[k]);
|
|
384
|
+
shown = !picked || top.includes(picked) ? top : [...top.slice(0, -1), picked].sort((a, b) => list.indexOf(a) - list.indexOf(b));
|
|
385
|
+
}
|
|
386
|
+
const chips = cloning + shown.map((p) => `<button class="lp-chip c-${p.color} ${p.id === launch.picks[k] ? 'on' : ''}" data-id="${p.id}" data-k="${k}" title="${esc(projectTitle(p))}"><i class="p-dot"></i>${esc(p.name)}</button>`).join('');
|
|
387
|
+
const more = list.length > 1 ? `<button class="lp-chip more" data-more="${k}" data-total="${list.length}" title="All ${list.length} projects${k ? ` on ${esc(placeName(k))}` : ''}" aria-haspopup="dialog"><span class="lp-more-n"></span>${icon('search')}</button>` : '';
|
|
388
|
+
const add = list.length || cloning
|
|
389
|
+
? `<button class="lp-chip add" data-add="${k}" title="Another folder${k ? ` on ${esc(placeName(k))}` : ''}">${icon('folder')}${multi || list.length > max ? '' : 'Other folder…'}</button>`
|
|
390
|
+
: `<button class="lp-chip add" data-add="${k}">${icon('folder')}${k ? `Pick a project on ${esc(placeName(k))}…` : 'Choose a folder…'}</button>`;
|
|
391
|
+
const none = `<button class="lp-chip none ${launch.picks[k] === NO_FOLDER ? 'on' : ''}" data-id="${NO_FOLDER}" data-k="${k}" title="No folder: a general ask, with no project and no worktree. The agent gets an empty scratch directory.">${icon('spark')}<span>No folder</span></button>`;
|
|
392
|
+
return `<span class="lp-group">${label}${chips}${more}${none}${add}</span>`;
|
|
340
393
|
};
|
|
341
394
|
box.innerHTML = `<span class="l-in">in</span>${launch.where.map(group).join('')}`;
|
|
395
|
+
fitChips(box);
|
|
396
|
+
fitRow ??= new ResizeObserver(([e]) => {
|
|
397
|
+
if (e.contentRect.width === fitRow.w) return; // its own folding only changes the height
|
|
398
|
+
fitRow.w = e.contentRect.width;
|
|
399
|
+
fitChips(e.target);
|
|
400
|
+
});
|
|
401
|
+
fitRow.observe(box);
|
|
402
|
+
paintScratch();
|
|
403
|
+
const pick = (k, id) => {
|
|
404
|
+
launch.picks[k] = id;
|
|
405
|
+
if (k === launch.where[0]) launch.project = id;
|
|
406
|
+
saveLaunch();
|
|
407
|
+
paintScratch();
|
|
408
|
+
renderLaunchRecipes();
|
|
409
|
+
};
|
|
342
410
|
box.onclick = (e) => {
|
|
343
411
|
const chip = e.target.closest('.lp-chip[data-id]');
|
|
344
412
|
if (chip) {
|
|
345
|
-
|
|
346
|
-
if (chip.dataset.k === launch.where[0]) launch.project = chip.dataset.id;
|
|
347
|
-
saveLaunch();
|
|
413
|
+
pick(chip.dataset.k, chip.dataset.id);
|
|
348
414
|
$$(`.lp-chip[data-k="${chip.dataset.k}"]`, box).forEach((x) => x.classList.toggle('on', x === chip));
|
|
349
|
-
renderLaunchRecipes();
|
|
350
415
|
return;
|
|
351
416
|
}
|
|
417
|
+
const more = e.target.closest('[data-more]');
|
|
418
|
+
if (more) {
|
|
419
|
+
const k = more.dataset.more;
|
|
420
|
+
return projectList(more, {
|
|
421
|
+
projects: projectsAt(k),
|
|
422
|
+
current: launch.picks[k],
|
|
423
|
+
label: `Projects on ${placeName(k)}`,
|
|
424
|
+
extras: [
|
|
425
|
+
{ id: NO_FOLDER, label: 'No folder', icon: 'spark' },
|
|
426
|
+
{ id: '+add', label: k ? `Another folder on ${placeName(k)}…` : 'Other folder…', icon: 'folder' },
|
|
427
|
+
],
|
|
428
|
+
onPick: (id) => {
|
|
429
|
+
if (id === '+add') return openProjects({ focusAdd: true, machine: k || null });
|
|
430
|
+
pick(k, id);
|
|
431
|
+
renderLaunchChips(); // the pick joins the chips
|
|
432
|
+
$('#prompt')?.focus();
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
}
|
|
352
436
|
const add = e.target.closest('[data-add]');
|
|
353
437
|
if (add) openProjects({ focusAdd: true, machine: add.dataset.add || null });
|
|
354
438
|
const conn = e.target.closest('[data-connect]');
|
|
@@ -358,6 +442,41 @@ export function renderLaunchChips() {
|
|
|
358
442
|
paintWhereBtn();
|
|
359
443
|
}
|
|
360
444
|
|
|
445
|
+
// One row, however many projects: on a single place, the least recent chips (never the pick, and
|
|
446
|
+
// at least one) fold into "+N" until the row stops wrapping. "+N" counts what isn't a chip.
|
|
447
|
+
let fitRow;
|
|
448
|
+
function fitChips(box) {
|
|
449
|
+
for (const g of $$('.lp-group', box)) {
|
|
450
|
+
const chips = $$('.lp-chip[data-id]:not(.none):not(.cloning)', g);
|
|
451
|
+
const more = $('.lp-chip.more', g);
|
|
452
|
+
chips.forEach((c) => (c.hidden = false));
|
|
453
|
+
const paint = () => {
|
|
454
|
+
const n = Number(more?.dataset.total) - chips.filter((c) => !c.hidden).length;
|
|
455
|
+
if (!more) return;
|
|
456
|
+
more.hidden = !n;
|
|
457
|
+
$('.lp-more-n', more).textContent = `+${n}`;
|
|
458
|
+
};
|
|
459
|
+
paint();
|
|
460
|
+
if (launch.where.length > 1) continue;
|
|
461
|
+
const wraps = () => {
|
|
462
|
+
const kids = [...g.children].filter((x) => !x.hidden && x.offsetParent);
|
|
463
|
+
const lin = $('.l-in', box); // "in" left alone on a line counts too
|
|
464
|
+
return kids.some((x) => x.offsetTop >= kids[0].offsetTop + kids[0].offsetHeight) || (lin && kids[0].offsetTop >= lin.offsetTop + lin.offsetHeight);
|
|
465
|
+
};
|
|
466
|
+
for (const c of chips.filter((x) => !x.classList.contains('on')).reverse()) {
|
|
467
|
+
if (!wraps() || chips.filter((x) => !x.hidden).length <= 1) break;
|
|
468
|
+
c.hidden = true;
|
|
469
|
+
paint();
|
|
470
|
+
}
|
|
471
|
+
// "+1" is no shorter than the one chip it would stand for: keep the chip, let the row wrap.
|
|
472
|
+
const folded = chips.filter((c) => c.hidden);
|
|
473
|
+
if (folded.length && Number(more?.dataset.total) - chips.length + folded.length === 1) {
|
|
474
|
+
folded.forEach((c) => (c.hidden = false));
|
|
475
|
+
paint();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
361
480
|
// "Run on": this computer and/or machines. The button says where; its menu picks one or several.
|
|
362
481
|
function paintWhereBtn() {
|
|
363
482
|
const b = $('#whereBtn');
|
|
@@ -486,7 +605,7 @@ function launchedFx(from, to) {
|
|
|
486
605
|
// A launch the server hasn't answered yet: a placeholder summary that shows in Working as a
|
|
487
606
|
// "Starting up" card. It can't be opened; the real card takes its place.
|
|
488
607
|
let pendingSeq = 0;
|
|
489
|
-
function addPending(prompt, { project: p, m }, { agent, model, effort }) {
|
|
608
|
+
function addPending(prompt, { project: p, m }, { agent, model, effort }, live = 'Starting up') {
|
|
490
609
|
const id = `pending-${++pendingSeq}`;
|
|
491
610
|
const line = plain(prompt).split('\n')[0].replace(/\s+/g, ' ');
|
|
492
611
|
const now = Date.now();
|
|
@@ -498,11 +617,12 @@ function addPending(prompt, { project: p, m }, { agent, model, effort }) {
|
|
|
498
617
|
model,
|
|
499
618
|
effort,
|
|
500
619
|
cwd: p.path,
|
|
620
|
+
scratch: p.scratch,
|
|
501
621
|
project: p.name,
|
|
502
622
|
projectId: p.id,
|
|
503
623
|
color: p.color || 'slate',
|
|
504
624
|
state: 'running',
|
|
505
|
-
live
|
|
625
|
+
live,
|
|
506
626
|
createdAt: now,
|
|
507
627
|
updatedAt: now,
|
|
508
628
|
machine: m ? { id: m.id, name: m.name, target: m.target } : null,
|
package/web/js/features/live.js
CHANGED
|
@@ -4,10 +4,10 @@ import { esc } from '../md.js';
|
|
|
4
4
|
import { api } from '../core/api.js';
|
|
5
5
|
import * as bus from '../core/bus.js';
|
|
6
6
|
import { $ } from '../core/dom.js';
|
|
7
|
-
import { S, saveOpen } from '../core/state.js';
|
|
7
|
+
import { S, saveOpen, setAgents } from '../core/state.js';
|
|
8
8
|
import { toast } from '../ui/toast.js';
|
|
9
9
|
import { paintAutoBtn } from './automations.js';
|
|
10
|
-
import {
|
|
10
|
+
import { renderFeed, scheduleRefetch, sectionOf } from './feed.js';
|
|
11
11
|
import { dropPending, renderLaunchChips } from './launcher.js';
|
|
12
12
|
import { loadMachines, setMachines } from './machines.js';
|
|
13
13
|
import { notifyFinished } from './notify.js';
|
|
@@ -34,14 +34,9 @@ export function connect() {
|
|
|
34
34
|
// list and focus goes to the next one instead.
|
|
35
35
|
const moved = prev && was !== sectionOf(s);
|
|
36
36
|
const follow = moved && s.state !== 'done' && !s.archived && watching(s.id);
|
|
37
|
-
keepInPlace(follow ? S.nodes.get(s.id).card : feedAnchor(s.id),
|
|
38
|
-
// A card left open while it worked shouldn't land in "Needs you" expanded —
|
|
39
|
-
// fold it unless the user is actually watching it.
|
|
40
|
-
if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(watching(s.id) && document.hasFocus())) collapse(s.id);
|
|
41
|
-
renderFeed();
|
|
42
|
-
});
|
|
37
|
+
keepInPlace(follow ? S.nodes.get(s.id).card : feedAnchor(s.id), renderFeed);
|
|
43
38
|
if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
|
|
44
|
-
if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state]) !== JSON.stringify([s.pr, s.toShip, s.state])) renderPrStrip(s.id);
|
|
39
|
+
if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state, prev?.shipping]) !== JSON.stringify([s.pr, s.toShip, s.state, s.shipping])) renderPrStrip(s.id);
|
|
45
40
|
});
|
|
46
41
|
es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
|
|
47
42
|
es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
|
|
@@ -66,7 +61,7 @@ export function connect() {
|
|
|
66
61
|
});
|
|
67
62
|
// A harness was installed from wisp (maybe in another tab): fetch the new list.
|
|
68
63
|
es.addEventListener('agents', async () => {
|
|
69
|
-
|
|
64
|
+
setAgents(await api('/agents').catch(() => S.agents));
|
|
70
65
|
$('#launcher')?.placesChanged?.();
|
|
71
66
|
bus.emit('agents');
|
|
72
67
|
});
|