wisp-agents 0.18.0 → 0.21.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 +62 -1
- package/README.md +23 -7
- package/bin/wisp.js +4 -3
- package/package.json +2 -2
- package/server/adapters/antigravity.js +2 -1
- package/server/adapters/claude.js +7 -5
- package/server/adapters/codex.js +7 -3
- 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/client.js +3 -2
- package/server/index.js +2 -1
- package/server/judge.js +78 -0
- package/server/mcp-wisp.js +2 -1
- package/server/openapi.js +4 -3
- 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/sessions.js +15 -8
- package/server/routes/settings.js +2 -0
- package/server/routes/ship.js +27 -27
- package/server/runner.js +83 -17
- package/server/scout.js +71 -0
- package/server/services/auto.js +111 -14
- package/server/services/launch.js +45 -8
- package/server/services/ship.js +132 -23
- package/skills/wisp/SKILL.md +2 -1
- package/web/css/auto.css +22 -0
- package/web/css/feed.css +3 -0
- package/web/css/launcher.css +12 -0
- package/web/css/palette.css +6 -0
- package/web/css/ship.css +17 -1
- package/web/index.html +1 -0
- package/web/js/blocks.js +1 -1
- package/web/js/core/state.js +4 -2
- package/web/js/features/actions.js +3 -5
- package/web/js/features/auto.js +38 -7
- package/web/js/features/composer.js +2 -4
- package/web/js/features/feed.js +40 -10
- package/web/js/features/launcher.js +62 -18
- package/web/js/features/live.js +2 -7
- package/web/js/features/places.js +10 -0
- package/web/js/features/project-list.js +20 -5
- package/web/js/features/projects.js +6 -8
- package/web/js/features/runners.js +7 -6
- package/web/js/features/settings.js +34 -8
- package/web/js/features/ship.js +95 -31
- package/web/js/features/turns.js +6 -4
- package/web/js/ui/popover.js +10 -0
|
@@ -17,14 +17,15 @@ export const DEFAULT_MODE = MODES[2];
|
|
|
17
17
|
|
|
18
18
|
export const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'auto' ? 'Auto' : id === 'wisp' ? 'wisp' : id);
|
|
19
19
|
|
|
20
|
-
// Auto: not a CLI but wisp's router (server/router.js), which picks harness
|
|
21
|
-
// Its "models" are the goal it optimises for
|
|
20
|
+
// Auto: not a CLI but wisp's router (server/router.js), which picks harness, model and thinking
|
|
21
|
+
// level per task. Its "models" are the goal it optimises for, which also sets the level band, so
|
|
22
|
+
// there are no thinking levels to pick here.
|
|
22
23
|
export const AUTO_GOALS = [
|
|
23
|
-
{ id: 'balanced', label: 'Balanced', hint: 'Best result per dollar' },
|
|
24
|
-
{ id: 'cheap', label: 'Cheap', hint: 'Lowest cost that still gets it done' },
|
|
25
|
-
{ id: 'best', label: 'Best', hint: 'Best result; cost only breaks ties' },
|
|
24
|
+
{ id: 'balanced', label: 'Balanced', hint: 'Best result per dollar · default or medium thinking' },
|
|
25
|
+
{ id: 'cheap', label: 'Cheap', hint: 'Lowest cost that still gets it done · low thinking' },
|
|
26
|
+
{ id: 'best', label: 'Best', hint: 'Best result; cost only breaks ties · high thinking' },
|
|
26
27
|
];
|
|
27
|
-
export const AUTO = { id: 'auto', name: 'Auto', available: true, delegates: true, models: AUTO_GOALS, efforts: [], hint: 'wisp picks the harness and
|
|
28
|
+
export const AUTO = { id: 'auto', name: 'Auto', available: true, delegates: true, models: AUTO_GOALS, efforts: [], hint: 'wisp picks the harness, model and thinking level from what worked before' };
|
|
28
29
|
|
|
29
30
|
// Harness lists per place: '' is this computer, else a machine (what it reported when it came online).
|
|
30
31
|
export const agentsAt = (k) => (k ? (machineById(k)?.state === 'online' && machineById(k).remote?.agents) || null : S.agents);
|
|
@@ -78,7 +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('
|
|
81
|
+
${row('Auto-fix PR conflicts', 'Without waiting for a click on Fix 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>')}
|
|
82
82
|
</div>
|
|
83
83
|
</section>
|
|
84
84
|
<section class="sx-pane" data-pane="notifications">
|
|
@@ -101,6 +101,13 @@ export function openSettings(opts) {
|
|
|
101
101
|
${row('Try new models', 'A new model or harness gets a few tasks that aren’t marked important before Auto relies on it', '<button class="switch" id="roExplore" role="switch"></button>')}
|
|
102
102
|
${row('Harnesses it may use', 'Leave all off to let Auto use every one you have', '<span class="ro-allowed"></span>')}
|
|
103
103
|
</div>
|
|
104
|
+
<div class="sx-group">
|
|
105
|
+
${row('Check with the tests', 'When the agent says it’s done, wisp runs the project’s tests itself. Only failures that are new count', '<button class="switch" id="roCheck" role="switch"></button>')}
|
|
106
|
+
${row('Repair rounds', 'Failing tests go back to the same agent this many times', `<span class="pk-seg ro-repairs">${[0, 1, 2].map((n) => `<button data-n="${n}">${n}</button>`).join('')}</span>`)}
|
|
107
|
+
${row('Hand over when it still fails', 'The task runs again on a stronger pick, next to the first run', '<button class="switch" id="roEscalate" role="switch"></button>')}
|
|
108
|
+
${row('Best of two', 'Off by default. With the Best goal, two picks work side by side and wisp marks the better one', '<button class="switch" id="roBest2" role="switch"></button>')}
|
|
109
|
+
${row('Scout first', 'A cheap model looks around the repo and tells the agent where to start', `<span class="pk-seg ro-scout">${[['off', 'Off'], ['big', 'Bigger tasks'], ['all', 'Every task']].map(([v, l]) => `<button data-v="${v}">${l}</button>`).join('')}</span>`)}
|
|
110
|
+
</div>
|
|
104
111
|
<div class="sx-group">
|
|
105
112
|
${row('What it learned', '<span class="ro-hint"></span>', '<button class="ghost sm" id="openRouter">Open</button>')}
|
|
106
113
|
</div>
|
|
@@ -302,10 +309,10 @@ export function openSettings(opts) {
|
|
|
302
309
|
if (!saved) return;
|
|
303
310
|
paintWt();
|
|
304
311
|
const fc = $('#fixConflicts', node);
|
|
305
|
-
const paintFc = () => fc.setAttribute('aria-checked', S.config.defaults.fixConflicts
|
|
312
|
+
const paintFc = () => fc.setAttribute('aria-checked', S.config.defaults.fixConflicts === 'on');
|
|
306
313
|
fc.onclick = async () => {
|
|
307
314
|
const was = S.config.defaults.fixConflicts;
|
|
308
|
-
S.config.defaults.fixConflicts = was === '
|
|
315
|
+
S.config.defaults.fixConflicts = was === 'on' ? 'off' : 'on';
|
|
309
316
|
paintFc();
|
|
310
317
|
const saved = await tryApi(() => api('/config', { method: 'PATCH', body: { defaults: { fixConflicts: S.config.defaults.fixConflicts } } }));
|
|
311
318
|
if (!saved) {
|
|
@@ -323,13 +330,18 @@ export function openSettings(opts) {
|
|
|
323
330
|
if (!r || !node.isConnected) return;
|
|
324
331
|
$$('.ro-goal button', node).forEach((b) => b.classList.toggle('on', b.dataset.goal === r.goal));
|
|
325
332
|
$('#roExplore', node).setAttribute('aria-checked', r.settings.explore);
|
|
333
|
+
$('#roCheck', node).setAttribute('aria-checked', r.settings.check);
|
|
334
|
+
$('#roEscalate', node).setAttribute('aria-checked', r.settings.escalate);
|
|
335
|
+
$('#roBest2', node).setAttribute('aria-checked', r.settings.best2);
|
|
336
|
+
$$('.ro-repairs button', node).forEach((b) => b.classList.toggle('on', Number(b.dataset.n) === r.settings.repairs));
|
|
337
|
+
$$('.ro-scout button', node).forEach((b) => b.classList.toggle('on', b.dataset.v === r.settings.scout));
|
|
326
338
|
const allowed = r.settings.allowed || [];
|
|
327
339
|
$('.ro-allowed', node).innerHTML = S.agents
|
|
328
340
|
.filter((a) => a.available)
|
|
329
341
|
.map((a) => `<button class="chip-tog${allowed.includes(a.id) ? ' on' : ''}" data-id="${esc(a.id)}" aria-pressed="${allowed.includes(a.id)}" title="${esc(a.name)}"><span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}</button>`)
|
|
330
342
|
.join('');
|
|
331
343
|
$('.ro-hint', node).textContent = r.learned
|
|
332
|
-
? `${r.learned} task${r.learned === 1 ? '' : 's'} learned from · ${r.arms.filter((a) => a.state !== 'gone').length} harness · model
|
|
344
|
+
? `${r.learned} task${r.learned === 1 ? '' : 's'} learned from · ${r.arms.filter((a) => a.state !== 'gone').length} harness · model · level options`
|
|
333
345
|
: `Nothing of yours yet · starting from wisp-bench (${r.priors.version})`;
|
|
334
346
|
};
|
|
335
347
|
const patchAuto = async (body) => {
|
|
@@ -349,10 +361,24 @@ export function openSettings(opts) {
|
|
|
349
361
|
patchAuto({ goal: b.dataset.goal });
|
|
350
362
|
}
|
|
351
363
|
};
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
364
|
+
for (const [id, key] of [['#roExplore', 'explore'], ['#roCheck', 'check'], ['#roEscalate', 'escalate'], ['#roBest2', 'best2']]) {
|
|
365
|
+
$(id, node).onclick = (e) => {
|
|
366
|
+
const on = e.currentTarget.getAttribute('aria-checked') !== 'true';
|
|
367
|
+
e.currentTarget.setAttribute('aria-checked', on);
|
|
368
|
+
patchAuto({ [key]: on });
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
$('.ro-repairs', node).onclick = (e) => {
|
|
372
|
+
const b = e.target.closest('[data-n]');
|
|
373
|
+
if (!b) return;
|
|
374
|
+
$$('.ro-repairs button', node).forEach((x) => x.classList.toggle('on', x === b));
|
|
375
|
+
patchAuto({ repairs: Number(b.dataset.n) });
|
|
376
|
+
};
|
|
377
|
+
$('.ro-scout', node).onclick = (e) => {
|
|
378
|
+
const b = e.target.closest('[data-v]');
|
|
379
|
+
if (!b) return;
|
|
380
|
+
$$('.ro-scout button', node).forEach((x) => x.classList.toggle('on', x === b));
|
|
381
|
+
patchAuto({ scout: b.dataset.v });
|
|
356
382
|
};
|
|
357
383
|
$('.ro-allowed', node).onclick = (e) => {
|
|
358
384
|
const b = e.target.closest('[data-id]');
|
package/web/js/features/ship.js
CHANGED
|
@@ -31,16 +31,25 @@ const STEP_LABEL = { commit: 'Commit', push: 'Push', pr: 'Open PR' };
|
|
|
31
31
|
const STEP_DOING = { commit: 'Committing', push: 'Pushing', pr: 'Opening the PR' };
|
|
32
32
|
const shipping = (s) => !!(s?.shipping || (ships.has(s?.id) && !ships.get(s.id).error));
|
|
33
33
|
|
|
34
|
+
// A task in several repos (also) has a PR per repo: its own first, then the others', each { repo, pr }.
|
|
35
|
+
export const prsOf = (s) => [...(s?.pr ? [{ repo: s.project, pr: s.pr }] : []), ...(s?.also || []).filter((a) => a.pr).map((a) => ({ repo: a.project, pr: a.pr }))];
|
|
36
|
+
export const prOf = (s) => prsOf(s)[0]?.pr || null; // the one the card's pill shows
|
|
37
|
+
const branches = (s) => [s?.branch, ...(s?.also || []).map((a) => a.branch)].filter(Boolean);
|
|
38
|
+
const severalRepos = (s) => branches(s).length > 1;
|
|
39
|
+
|
|
34
40
|
// Ship it only makes sense on a finished worktree task with something to ship: uncommitted
|
|
35
41
|
// changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
|
|
42
|
+
// Several repos: the server counts the ones with something new (toShip.repos).
|
|
36
43
|
export function shippable(s) {
|
|
37
|
-
if (!s
|
|
44
|
+
if (!branches(s).length || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || shipping(s)) return false;
|
|
38
45
|
const t = s.toShip;
|
|
46
|
+
if (t?.repos !== undefined) return t.repos > 0;
|
|
47
|
+
if (s.pr?.state === 'MERGED') return false;
|
|
39
48
|
return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
|
|
40
49
|
}
|
|
41
|
-
export const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
|
|
50
|
+
export const shipLabel = (s) => (severalRepos(s) ? (prsOf(s).some((x) => x.pr.state === 'OPEN') ? 'Push to the PRs…' : 'Ship it…') : s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
|
|
42
51
|
|
|
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>` : '');
|
|
52
|
+
export const shipPill = (s) => (s.shipping ? `<span class="pill ship-pill" title="Shipping: ${esc(STEP_DOING[s.shipping.step] || 'Starting')}${s.shipping.repo ? ` ${esc(s.shipping.repo)}` : ''}…"><i></i>Shipping</span>` : '');
|
|
44
53
|
|
|
45
54
|
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>`;
|
|
46
55
|
|
|
@@ -50,37 +59,76 @@ export function renderPrStrip(id) {
|
|
|
50
59
|
const node = n?.body && $('.pr-strip', n.body);
|
|
51
60
|
const s = S.sessions.get(id);
|
|
52
61
|
if (!node || !s) return;
|
|
53
|
-
const
|
|
62
|
+
const prs = prsOf(s);
|
|
63
|
+
const pr = prs[0]?.pr;
|
|
64
|
+
const several = prs.length > 1;
|
|
54
65
|
$$('[data-a="ship"]', n.body).forEach((b) => {
|
|
55
66
|
b.hidden = !shippable(s);
|
|
56
|
-
$('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
|
|
67
|
+
$('span', b).textContent = severalRepos(s) ? (prs.some((x) => x.pr.state === 'OPEN') ? 'Push to PRs' : prs.length ? 'Ship again' : 'Ship it') : pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
|
|
57
68
|
});
|
|
58
69
|
// Started here, or elsewhere (another tab, the API): the server's stage moves the track along.
|
|
59
70
|
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
|
|
71
|
+
if (run && STEP_LABEL[s.shipping?.step] && !run.error) Object.assign(run, { step: s.shipping.step, repo: s.shipping.repo, at: s.shipping.at, of: s.shipping.of });
|
|
61
72
|
if (run) return renderShipRun(node, id, run);
|
|
62
73
|
const landed = node.classList.contains('ship-run') && !node.classList.contains('failed');
|
|
63
74
|
node.hidden = !pr;
|
|
64
75
|
if (!pr) return;
|
|
65
|
-
|
|
76
|
+
// Several repos: the first PR leads the row and the others follow as small links, each in its
|
|
77
|
+
// own tone; the row takes the worst of them, and Merge merges them all.
|
|
78
|
+
const fix = s.pr ? s.conflictFix : null; // wisp fixes conflicts on the task's own PR only
|
|
79
|
+
const TONES = ['fail', 'pending', 'pass', 'merged', 'closed'];
|
|
80
|
+
const tone = prs.map((x, i) => prTone(x.pr, i ? null : fix)).sort((a, b) => TONES.indexOf(a) - TONES.indexOf(b))[0];
|
|
81
|
+
const open = prs.filter((x) => x.pr.state === 'OPEN');
|
|
82
|
+
const conflict = open.some((x) => x.pr.conflict);
|
|
66
83
|
node.className = `pr-strip pr-${tone}${landed ? ' landed' : ''}`;
|
|
67
84
|
node.innerHTML = `
|
|
68
85
|
<span class="pr-dot"></span>
|
|
69
|
-
<a href="${esc(pr.url)}" target="_blank" class="pr-num"
|
|
70
|
-
|
|
86
|
+
<a href="${esc(pr.url)}" target="_blank" class="pr-num">${several ? `${esc(prs[0].repo)} #${pr.number}` : `PR #${pr.number}`}</a>
|
|
87
|
+
${prs
|
|
88
|
+
.slice(1)
|
|
89
|
+
.map((x) => `<a href="${esc(x.pr.url)}" target="_blank" class="pr-also pr-${prTone(x.pr)}" title="${esc(`${x.repo}: PR #${x.pr.number} · ${prLine(x.pr)}`)}"><i></i>${esc(x.repo)} #${x.pr.number}</a>`)
|
|
90
|
+
.join('')}
|
|
91
|
+
<span class="pr-line">${esc(prLine(pr, fix))}</span>
|
|
71
92
|
<span class="grow"></span>
|
|
72
|
-
${pr.state === 'OPEN' && pr.conflict && s.
|
|
73
|
-
${
|
|
74
|
-
|
|
75
|
-
${
|
|
93
|
+
${pr.state === 'OPEN' && pr.conflict && s.pr && !fixing(fix) && s.state !== 'running' ? `<button class="ghost xs pr-fix" data-pr="fix" title="Send the task a follow-up to merge the base in and resolve the conflicts; wisp pushes the fix to the PR">${icon('merge')}<span>Fix conflicts</span></button>` : ''}
|
|
94
|
+
${open.length ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
|
|
95
|
+
${several ? '' : `<a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>`}
|
|
96
|
+
${open.length && !conflict ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge${open.length > 1 ? ` all ${open.length} PRs` : ''}, then fast-forward your local base branch${open.length > 1 ? 'es' : ''}">${icon('check')}<span>Merge${open.length > 1 ? ' all' : ''}</span></button>` : ''}`;
|
|
76
97
|
node.onclick = (e) => {
|
|
77
98
|
const a = e.target.closest('[data-pr]')?.dataset.pr;
|
|
78
|
-
if (a === 'refresh')
|
|
99
|
+
if (a === 'refresh') refreshPr(id, e.target.closest('[data-pr]'));
|
|
79
100
|
if (a === 'merge') mergePr(id);
|
|
80
|
-
if (a === 'fix')
|
|
101
|
+
if (a === 'fix') fixPr(id);
|
|
81
102
|
};
|
|
82
103
|
}
|
|
83
104
|
|
|
105
|
+
// Check now: the button spins at once and stops when the answer (pushed as a summary) is in.
|
|
106
|
+
function refreshPr(id, btn) {
|
|
107
|
+
btn.classList.add('busy');
|
|
108
|
+
btn.disabled = true;
|
|
109
|
+
api(`/sessions/${id}/pr`, { method: 'POST', body: {} })
|
|
110
|
+
.catch(toastError)
|
|
111
|
+
.finally(() => {
|
|
112
|
+
btn.classList.remove('busy');
|
|
113
|
+
btn.disabled = false;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fix conflicts: the row flips to "Fixing merge conflicts" at once; a refusal puts it back.
|
|
118
|
+
function fixPr(id) {
|
|
119
|
+
const s = S.sessions.get(id);
|
|
120
|
+
if (!s) return;
|
|
121
|
+
const was = s.conflictFix;
|
|
122
|
+
s.conflictFix = { ...was, step: 'fixing', why: '' };
|
|
123
|
+
renderPrStrip(id);
|
|
124
|
+
api(`/sessions/${id}/fix-conflicts`, { method: 'POST', body: {} }).catch((err) => {
|
|
125
|
+
const now = S.sessions.get(id);
|
|
126
|
+
if (now?.conflictFix?.step === 'fixing') now.conflictFix = was;
|
|
127
|
+
renderPrStrip(id);
|
|
128
|
+
toastError(err);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
84
132
|
// The in-flight row: the stages as a track that fills in as the server reports them. Built once
|
|
85
133
|
// and updated in place, so its entry animation and shimmer don't restart with every summary.
|
|
86
134
|
function renderShipRun(node, id, run) {
|
|
@@ -108,7 +156,8 @@ function renderShipRun(node, id, run) {
|
|
|
108
156
|
node.className = `pr-strip ship-run${failed ? ' failed pr-fail' : ''}`;
|
|
109
157
|
node.title = failed ? run.error : '';
|
|
110
158
|
$$('.ship-track li', node).forEach((li, i) => (li.className = i < at ? 'done' : i === at ? (failed ? 'bad' : 'now') : ''));
|
|
111
|
-
const
|
|
159
|
+
const repo = !failed && run.repo ? ` ${esc(run.repo)}${run.of > 1 ? ` <span class="muted">(${run.at} of ${run.of})</span>` : ''}` : '';
|
|
160
|
+
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]]}${repo}<span class="dots"></span>`;
|
|
112
161
|
if ($('.ship-what', node).innerHTML !== what) $('.ship-what', node).innerHTML = what;
|
|
113
162
|
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
163
|
if ($('.ship-acts', node).innerHTML !== acts) $('.ship-acts', node).innerHTML = acts;
|
|
@@ -124,19 +173,23 @@ export async function shipDialog(id) {
|
|
|
124
173
|
} catch (err) {
|
|
125
174
|
return toastError(err);
|
|
126
175
|
}
|
|
127
|
-
|
|
176
|
+
// Several repos: a route line each, and the PRs open or update together.
|
|
177
|
+
const repos = info.repos || [info];
|
|
178
|
+
const open = repos.every((r) => r.pr?.state === 'OPEN');
|
|
128
179
|
const typed = ships.get(id)?.values; // a failed ship's message, back for Retry
|
|
129
180
|
const nothing = !info.dirty && !info.ahead;
|
|
181
|
+
const noRemote = repos.every((r) => !r.remote);
|
|
182
|
+
const route = (r) => `<p class="ship-route">${icon('branch')}${info.repos ? `<b class="ship-repo">${esc(r.repo)}</b>` : ''}<code>${esc(r.branch)}</code>${icon('arrow')}<code>${esc(r.remote || 'no remote')}/${esc(r.base)}</code>
|
|
183
|
+
<span class="muted">${r.dirty ? `${r.dirty} uncommitted change${r.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${r.ahead} commit${r.ahead === 1 ? '' : 's'} on the branch${info.repos && r.pr ? ` · PR #${r.pr.number}` : ''}</span></p>`;
|
|
130
184
|
const node = el(`
|
|
131
185
|
<div class="modal ship-modal">
|
|
132
|
-
<header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
133
|
-
|
|
134
|
-
<span class="muted">${info.dirty ? `${info.dirty} uncommitted change${info.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${info.ahead} commit${info.ahead === 1 ? '' : 's'} on the branch</span></p>
|
|
186
|
+
<header><h2>${open ? (info.repos ? `Push to ${repos.length} PRs` : `Push to PR #${info.pr.number}`) : info.repos ? `Ship it to ${repos.length} repos` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
187
|
+
${repos.map(route).join('')}
|
|
135
188
|
${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input class="input" name="title" spellcheck="false" maxlength="100"></label>` : ''}
|
|
136
189
|
${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea class="textarea mono" name="body" rows="9" spellcheck="false"></textarea></label>
|
|
137
190
|
<label class="check ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
|
|
138
|
-
${
|
|
139
|
-
<div class="ship-foot"><span class="muted xs">Runs <code>git commit</code>, <code>git push</code>${open ? '' : ' and <code>gh pr create</code>'} in the worktree.</span><button class="primary sm" data-a="go" ${nothing ||
|
|
191
|
+
${noRemote ? `<p class="hint bad">${info.repos ? 'These repos have' : 'This repo has'} no <code>origin</code> remote, so there is nowhere to push.</p>` : nothing ? '<p class="hint">Nothing to ship yet: the branch has no changes.</p>' : ''}
|
|
192
|
+
<div class="ship-foot"><span class="muted xs">Runs <code>git commit</code>, <code>git push</code>${open ? '' : ' and <code>gh pr create</code>'} in ${info.repos ? 'each worktree with changes, one PR per repo' : 'the worktree'}.</span><button class="primary sm" data-a="go" ${nothing || noRemote ? 'disabled' : ''}>${icon('send')}${open ? 'Commit & push' : info.repos ? 'Commit, push & open PRs' : 'Commit, push & open PR'}</button></div>
|
|
140
193
|
</div>`);
|
|
141
194
|
const close = overlay(node);
|
|
142
195
|
$('.close', node).onclick = close;
|
|
@@ -161,10 +214,12 @@ export async function shipDialog(id) {
|
|
|
161
214
|
(pr) => {
|
|
162
215
|
const s = S.sessions.get(id);
|
|
163
216
|
// Everything just went up: no Push to #n until the server's recheck finds something new.
|
|
164
|
-
|
|
217
|
+
// Several repos: their PRs come with the server's next summary.
|
|
218
|
+
if (s) S.sessions.set(id, { ...s, ...(!pr.prs && { pr: { ...s.pr, ...pr } }), shipping: null, toShip: s.toShip && { ...s.toShip, dirty: 0, unpushed: 0, ...(s.toShip.repos !== undefined && { repos: 0 }) } });
|
|
165
219
|
ships.delete(id);
|
|
166
220
|
renderPrStrip(id);
|
|
167
|
-
|
|
221
|
+
const what = pr.prs ? pr.prs.map((x) => `<b>${esc(x.repo)} #${x.number}</b>`).join(', ') : `<b>PR #${pr.number}</b>`;
|
|
222
|
+
toast(`${icon('branch')} ${open ? 'Pushed to' : pr.prs ? 'PRs:' : 'Opened'} ${what}`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
|
|
168
223
|
},
|
|
169
224
|
(err) => {
|
|
170
225
|
if (ships.has(id)) ships.get(id).error = err.message;
|
|
@@ -183,15 +238,24 @@ export async function shipDialog(id) {
|
|
|
183
238
|
|
|
184
239
|
export async function mergePr(id) {
|
|
185
240
|
const s = S.sessions.get(id);
|
|
186
|
-
const
|
|
187
|
-
if (!
|
|
188
|
-
const
|
|
189
|
-
const warn =
|
|
190
|
-
|
|
191
|
-
|
|
241
|
+
const open = prsOf(s).filter((x) => x.pr.state === 'OPEN');
|
|
242
|
+
if (!open.length) return;
|
|
243
|
+
const several = open.length > 1;
|
|
244
|
+
const warn = open
|
|
245
|
+
.map(({ repo, pr }) => {
|
|
246
|
+
const c = pr.checks || {};
|
|
247
|
+
const where = several ? ` in ${repo}` : '';
|
|
248
|
+
return c.fail ? `⚠ ${c.fail} check${c.fail > 1 ? 's are' : ' is'} failing${where}.` : c.pending ? `⚠ ${c.pending} check${c.pending > 1 ? 's are' : ' is'} still running${where}.` : '';
|
|
249
|
+
})
|
|
250
|
+
.filter(Boolean)
|
|
251
|
+
.join('\n');
|
|
252
|
+
const what = several ? `these ${open.length} PRs (${open.map((x) => `${x.repo} #${x.pr.number}`).join(', ')})` : `PR #${open[0].pr.number}${open[0].pr.draft ? ' (marks it ready first)' : ''}`;
|
|
253
|
+
if (!confirm(`Squash-merge ${what}, then fast-forward your local base branch${several ? 'es' : ''} and remove the worktree${several ? 's' : ''}?${warn ? `\n\n${warn}` : ''}`)) return;
|
|
254
|
+
toast(several ? `Merging ${open.length} PRs…` : `Merging PR #${open[0].pr.number}…`);
|
|
192
255
|
try {
|
|
193
256
|
const r = await api(`/sessions/${id}/merge`, { method: 'POST', body: {} });
|
|
194
|
-
|
|
257
|
+
const merged = r.prs ? r.prs.filter((x) => x.state === 'MERGED').map((x) => `${esc(x.repo)} #${x.number}`).join(', ') : `PR #${r.pr.number}`;
|
|
258
|
+
toast(`${icon('check')} Merged <b>${merged}</b>. ${esc(r.sync.text)}`, { tone: r.sync.ok ? '' : 'bad', timeout: 10_000 });
|
|
195
259
|
} catch (err) {
|
|
196
260
|
toastError(err, { timeout: 12_000 });
|
|
197
261
|
}
|
package/web/js/features/turns.js
CHANGED
|
@@ -19,7 +19,7 @@ import { scrollByPx, watching } from './scroll.js';
|
|
|
19
19
|
import { renderPrStrip, shipDialog } from './ship.js';
|
|
20
20
|
import { copiesOf, memberLabel, renderSubs, subKey, teamLiveHtml, teamSize } from './team.js';
|
|
21
21
|
|
|
22
|
-
const KIND_ICON = { read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask', background: 'clock' };
|
|
22
|
+
const KIND_ICON = { check: 'check', read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask', background: 'clock' };
|
|
23
23
|
|
|
24
24
|
export function ctxFor(s, turn, isLatest) {
|
|
25
25
|
return {
|
|
@@ -97,13 +97,14 @@ export function renderBody(id, { fresh }) {
|
|
|
97
97
|
const proj = projectById(summary.projectId);
|
|
98
98
|
// The head already shows the harness, model and project, and the task head has Compare. This bar
|
|
99
99
|
// says how the run was set up: a mode worth noticing (Plan or Edits, as on the launcher), the
|
|
100
|
-
// branch, and what launched it.
|
|
100
|
+
// branch, the other folders it works in, and what launched it.
|
|
101
101
|
const mode = MODES.find((m) => m.id === s.mode);
|
|
102
102
|
const where = (proj?.path || s.cwd).replace(S.config.home || '', '~');
|
|
103
103
|
$('.it-meta', body).title = [agentName(s.agent), s.model, s.effort && `${effortLabel(s.effort)} thinking`, mode?.label, where].filter(Boolean).join(' · ');
|
|
104
104
|
$('.it-meta', body).innerHTML = `
|
|
105
105
|
${mode && mode !== DEFAULT_MODE ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
|
|
106
106
|
${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
|
|
107
|
+
${(s.also || []).map((a, i) => `<span class="m also c-${summary.also?.[i]?.color || 'slate'}" title="${esc(`Also works in ${a.cwd}${a.worktree ? `\nWorktree: ${a.worktree.path}\nBranch ${a.worktree.branch}, from ${a.worktree.baseRef}` : ''}`)}"><i class="p-dot"></i>${esc(summary.also?.[i]?.project || a.cwd.split(/[\\/]/).pop())}</span>`).join('')}
|
|
107
108
|
${s.subagents?.length ? `<span class="m team-m" title="${esc(s.subagents.map((m) => `@${m.name}${copiesOf(m) > 1 ? ` ×${copiesOf(m)}` : ''}: ${memberLabel(m)}${m.use ? ` — ${m.use}` : ''}`).join('\n'))}">${icon('agent')}${teamSize(s.subagents)} subagent${teamSize(s.subagents) > 1 ? 's' : ''}</span>` : ''}
|
|
108
109
|
${s.origin ? `<span class="m" title="${s.origin.kind === 'telegram' ? 'Started from Telegram' : `Launched by a ${esc(s.origin.kind)}`}">${icon({ trigger: 'replay', telegram: 'send' }[s.origin.kind] || 'spark')}${esc(s.origin.name)}</span>` : ''}`;
|
|
109
110
|
const live = s.state === 'running' || s.state === 'waiting';
|
|
@@ -169,7 +170,7 @@ function renderFoldedTurn(t, unfold) {
|
|
|
169
170
|
<article class="turn folded" data-turn="${t.id}">
|
|
170
171
|
<button class="t-fold" title="Show this exchange">
|
|
171
172
|
${icon('chevron', 'chev')}
|
|
172
|
-
<span class="t-prompt">${mentionHtml(esc(t.prompt.replace(/\s+/g, ' ')), { link: false })}</span>
|
|
173
|
+
<span class="t-prompt">${t.by === 'wisp' ? '<b class="t-by">wisp</b> ' : ''}${mentionHtml(esc(t.prompt.replace(/\s+/g, ' ')), { link: false })}</span>
|
|
173
174
|
${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
|
|
174
175
|
${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
|
|
175
176
|
<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
|
|
@@ -194,7 +195,8 @@ function renderTurn(s, t, isLatest, fold) {
|
|
|
194
195
|
const cp = t.checkpoint;
|
|
195
196
|
const cpLabel = cp?.head ? `${cp.branch ? `${cp.branch}@` : ''}${cp.head.slice(0, 7)}${cp.dirty ? '*' : ''}` : '';
|
|
196
197
|
const ask = el(`
|
|
197
|
-
<div class="ask">
|
|
198
|
+
<div class="ask${t.by === 'wisp' ? ' by-wisp' : ''}">
|
|
199
|
+
${t.by === 'wisp' ? `<div class="ask-by">${icon('spark')}Sent by wisp after its test run</div>` : ''}
|
|
198
200
|
<div class="ask-text ${long ? 'clamped' : ''}">${mentionHtml(esc(t.prompt))}</div>
|
|
199
201
|
${long ? '<button class="link xs more-btn">Show all</button>' : ''}
|
|
200
202
|
${t.attachments?.length ? `<div class="ask-atts">${t.attachments.map((a) => (a.mime?.startsWith('image/') ? `<img src="/api/uploads/${a.id}" alt="${esc(a.name)}" title="${esc(a.name)}">` : `<a class="att-pill" href="/api/uploads/${a.id}" target="_blank">${icon('file')}${esc(a.name)}</a>`)).join('')}</div>` : ''}
|
package/web/js/ui/popover.js
CHANGED
|
@@ -50,3 +50,13 @@ export function popover(node, { anchor, keep, capture = false, onClose } = {}) {
|
|
|
50
50
|
document.body.append(node);
|
|
51
51
|
return close;
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
// Keep a right-click menu at the pointer but on-screen near the window edges. Reads offset
|
|
55
|
+
// sizes: the menu's pop-in animation starts scaled down, so its bounding box is too small
|
|
56
|
+
// on the first frame and the last items would end up below the fold.
|
|
57
|
+
export function clampToViewport(node, x, y) {
|
|
58
|
+
const w = node.offsetWidth;
|
|
59
|
+
const h = node.offsetHeight;
|
|
60
|
+
node.style.left = `${Math.max(8, Math.min(x, innerWidth - w - 8))}px`;
|
|
61
|
+
node.style.top = `${Math.max(8, Math.min(y, innerHeight - h - 8))}px`;
|
|
62
|
+
}
|