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
package/web/index.html
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
<symbol id="i-pen" viewBox="0 0 24 24"><path d="M4 20l1-4L16 5l3 3L8 19l-4 1z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
|
48
48
|
<symbol id="i-spark" viewBox="0 0 24 24"><path d="M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6L12 3zM18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2z" fill="currentColor"/></symbol>
|
|
49
49
|
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="6.5" y="6.5" width="11" height="11" rx="2" fill="currentColor"/></symbol>
|
|
50
|
+
<symbol id="i-merge" viewBox="0 0 24 24"><circle cx="6" cy="5" r="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="6" cy="19" r="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="18" cy="15" r="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M6 7.2v9.6M6 7.2c0 4.4 4.4 7.8 9.8 7.8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
|
50
51
|
<symbol id="i-send" viewBox="0 0 24 24"><path d="M12 19V5m-6 6l6-6 6 6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
|
51
52
|
<symbol id="i-enter" viewBox="0 0 24 24"><path d="M19 5v7a3 3 0 01-3 3H5m4-4l-4 4 4 4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
|
52
53
|
<symbol id="i-queue" viewBox="0 0 24 24"><path d="M4 6h12M4 12h12M4 18h7M17 15v6m-3-3h6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
package/web/js/blocks.js
CHANGED
|
@@ -512,7 +512,7 @@ R.tests = (b, ctx) => {
|
|
|
512
512
|
</svg>
|
|
513
513
|
<div class="ring-label">${icon(ok ? 'check' : 'x')}</div>
|
|
514
514
|
<div class="tests-text">
|
|
515
|
-
<div class="verdict">${ok ? 'All green' : `${failed} failing`}</div>
|
|
515
|
+
<div class="verdict">${ok ? 'All green' : `${failed} failing`}${b.by === 'wisp' ? `<span class="tests-by" title="wisp ran the project's tests itself after the agent finished${b.note ? `: ${esc(b.note)}` : ''}">Run by wisp</span>` : ''}</div>
|
|
516
516
|
<div class="counts"><span class="pass"><b class="v" data-v="${passed}"></b> passed</span>${failed ? `<span class="fail"><b class="v" data-v="${failed}"></b> failed</span>` : ''}${skipped ? `<span class="skip"><b class="v" data-v="${skipped}"></b> skipped</span>` : ''}${b.durationMs ? `<span class="dur">${fmtDuration(b.durationMs)}</span>` : ''}</div>
|
|
517
517
|
${cmd ? `<div class="tests-cmd"><code class="cmdline">$ ${esc(cmd)}</code>${ctx.exec ? `<button class="ghost xs run-tests" title="Re-run ${esc(cmd)}">${icon('rerun')}<span>Re-run</span></button>` : ''}</div>` : ''}
|
|
518
518
|
</div>
|
package/web/js/core/state.js
CHANGED
|
@@ -35,10 +35,12 @@ export const saveOpen = () => save('wisp.open', [...S.open]);
|
|
|
35
35
|
export const setAgents = (list) => ((S.agents = list), save('wisp.agents', list));
|
|
36
36
|
|
|
37
37
|
// Where the launcher sends a prompt: '' = this computer, else machine ids. Several run in parallel,
|
|
38
|
-
// each in the project picked for it (launch.picks[place]), and show up as one task.
|
|
38
|
+
// each in the project picked for it (launch.picks[place]), and show up as one task. A run can
|
|
39
|
+
// work in more of that place's projects too (launch.also[place]): one agent, several folders.
|
|
39
40
|
export const launch = {
|
|
40
41
|
project: load('wisp.launchProject', null), // the project picked in the first place
|
|
41
42
|
where: load('wisp.launchWhere', ['']),
|
|
42
43
|
picks: load('wisp.launchPicks', {}),
|
|
44
|
+
also: load('wisp.launchAlso', {}),
|
|
43
45
|
};
|
|
44
|
-
export const saveLaunch = () => (save('wisp.launchWhere', launch.where), save('wisp.launchPicks', launch.picks), save('wisp.launchProject', launch.project));
|
|
46
|
+
export const saveLaunch = () => (save('wisp.launchWhere', launch.where), save('wisp.launchPicks', launch.picks), save('wisp.launchAlso', launch.also), save('wisp.launchProject', launch.project));
|
|
@@ -8,7 +8,7 @@ import { $, el, icon } from '../core/dom.js';
|
|
|
8
8
|
import { S } from '../core/state.js';
|
|
9
9
|
import { keyHint } from '../ui/kbd.js';
|
|
10
10
|
import { overlay } from '../ui/overlay.js';
|
|
11
|
-
import { popover } from '../ui/popover.js';
|
|
11
|
+
import { clampToViewport, popover } from '../ui/popover.js';
|
|
12
12
|
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
13
13
|
import { autosize } from './attachments.js';
|
|
14
14
|
import { saveAsRecipe } from './automations.js';
|
|
@@ -208,9 +208,7 @@ export function taskMenu(id, e) {
|
|
|
208
208
|
if (a === 'delete') deleteSession(id);
|
|
209
209
|
};
|
|
210
210
|
const close = popover(menu);
|
|
211
|
-
|
|
212
|
-
if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
|
|
213
|
-
if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
|
|
211
|
+
clampToViewport(menu, e.clientX, e.clientY);
|
|
214
212
|
}
|
|
215
213
|
|
|
216
214
|
export async function deleteSession(id) {
|
|
@@ -281,7 +279,7 @@ export async function rerun(id) {
|
|
|
281
279
|
if (!prompt) return ta.focus();
|
|
282
280
|
close();
|
|
283
281
|
try {
|
|
284
|
-
const body = { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) };
|
|
282
|
+
const body = { prompt, cwd: s.cwd, also: s.also?.map((a) => a.cwd), agent, mode, model, effort, attachments: files.map((f) => f.id) };
|
|
285
283
|
const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort } }, () => api('/sessions', { method: 'POST', body }));
|
|
286
284
|
setFocus(next.id);
|
|
287
285
|
} catch (err) {
|
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);
|
|
@@ -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
|
@@ -9,16 +9,16 @@ import { S, save, saveOpen } from '../core/state.js';
|
|
|
9
9
|
import { taskDone, taskMenu, toggleDone } from './actions.js';
|
|
10
10
|
import { compareView } from './compare.js';
|
|
11
11
|
import { renderLaunchChips } from './launcher.js';
|
|
12
|
-
import { machinePill } from './places.js';
|
|
12
|
+
import { inProject, machinePill, projectLabel } 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, shipPill } from './ship.js';
|
|
16
|
+
import { prOf, prPill, shipPill } from './ship.js';
|
|
17
17
|
import { teamStack } from './team.js';
|
|
18
18
|
import { renderBody } from './turns.js';
|
|
19
19
|
|
|
20
20
|
function visible(list) {
|
|
21
|
-
return S.filter === 'all' ? list : list.filter((s) => s
|
|
21
|
+
return S.filter === 'all' ? list : list.filter((s) => inProject(s, S.filter));
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
// Compare runs (one launch on several harnesses, sharing a groupId) are one task in the feed
|
|
@@ -208,16 +208,39 @@ 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) : prOf(s) ? prPill(prOf(s), 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
|
+
${projectLabel(s)}
|
|
215
216
|
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
216
217
|
${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
|
|
217
218
|
${icon('chevron', 'chev')}
|
|
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);
|
|
@@ -230,10 +253,11 @@ function renderRunHead(n, s, { open, live, pill }) {
|
|
|
230
253
|
${live ? `<span class="q-live ${s.state}">${esc(s.live)}</span>` : open ? '' : `<span class="it-sub">${esc(s.preview || '')}</span>`}
|
|
231
254
|
</span>
|
|
232
255
|
<span class="it-side">
|
|
233
|
-
${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>`}
|
|
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>`}${projectLabel(s)}` : ''}
|
|
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) : prOf(s) ? prPill(prOf(s), 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>
|
|
@@ -267,7 +297,7 @@ function taskItem(t) {
|
|
|
267
297
|
</span>
|
|
268
298
|
<span class="it-side">
|
|
269
299
|
<button class="ghost xs" data-a="compare" title="Side by side: time, cost, changes, tests and each presentation"><b class="vs-mark">vs</b><span>Compare</span></button>
|
|
270
|
-
${places > 1 ? '' :
|
|
300
|
+
${places > 1 ? '' : projectLabel(t)}
|
|
271
301
|
<span class="q-time">${ago(t.updatedAt)}</span>
|
|
272
302
|
${t.state === 'review' && !t.archived ? `<button class="it-quick" data-a="quick-done" title="Mark every run done">${icon('done')}</button>` : ''}
|
|
273
303
|
</span>`;
|
|
@@ -364,7 +394,7 @@ export function reveal(id) {
|
|
|
364
394
|
S.showDone = true;
|
|
365
395
|
save('wisp.showDone', true);
|
|
366
396
|
}
|
|
367
|
-
if (S.filter !== 'all' && S.filter
|
|
397
|
+
if (S.filter !== 'all' && !inProject(s, S.filter)) {
|
|
368
398
|
S.filter = 'all';
|
|
369
399
|
save('wisp.filter', S.filter);
|
|
370
400
|
renderLaunchChips();
|
|
@@ -31,6 +31,11 @@ import { canLead, paintTeamBtn, teamEditor } from './team.js';
|
|
|
31
31
|
const NO_FOLDER = 'none';
|
|
32
32
|
const GENERAL = { id: null, name: 'General', path: '', color: 'slate', scratch: true };
|
|
33
33
|
const projectOf = (k) => (launch.picks[k] === NO_FOLDER ? GENERAL : projectById(launch.picks[k]));
|
|
34
|
+
// The place's other folders for the task (launch.also), and whether a project is in it at all.
|
|
35
|
+
const alsoAt = (k) => launch.also[k] || [];
|
|
36
|
+
const chosen = (k, id) => id === launch.picks[k] || alsoAt(k).includes(id);
|
|
37
|
+
const ownPick = (k) => projectsAt(k).some((p) => p.id === launch.picks[k]);
|
|
38
|
+
const MOD = combo('').replace(/\+$/, ''); // ⌘ or Ctrl, for "⌘-click"
|
|
34
39
|
// Project chips in the "in" row, at most, before the rest fold into "+N".
|
|
35
40
|
const CHIPS = 5;
|
|
36
41
|
|
|
@@ -41,13 +46,23 @@ function syncWhere() {
|
|
|
41
46
|
launch.where = launch.where.filter((k, i) => (!k || machineById(k)) && launch.where.indexOf(k) === i);
|
|
42
47
|
if (!launch.where.length) launch.where = [''];
|
|
43
48
|
// Filtering the feed to a project points the launcher at it (its machine joins if it wasn't picked).
|
|
49
|
+
// One that's already in the task (as its first folder or another) leaves the selection as it is.
|
|
44
50
|
const f = S.filter !== 'all' && S.filter !== filterSeen && projectById(S.filter);
|
|
45
51
|
filterSeen = S.filter;
|
|
46
52
|
if (f) {
|
|
47
53
|
if (!launch.where.includes(placeOf(f))) launch.where = [placeOf(f)];
|
|
48
|
-
|
|
54
|
+
if (!chosen(placeOf(f), f.id)) {
|
|
55
|
+
launch.picks[placeOf(f)] = f.id;
|
|
56
|
+
delete launch.also[placeOf(f)];
|
|
57
|
+
}
|
|
49
58
|
} else if (projectById(launch.project) && !Object.values(launch.picks).includes(launch.project)) launch.picks[placeOf(projectById(launch.project))] ??= launch.project;
|
|
50
59
|
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;
|
|
60
|
+
// Other folders only go with a project (not No folder or a clone), and only that place's projects.
|
|
61
|
+
for (const k of Object.keys(launch.also)) {
|
|
62
|
+
const ids = ownPick(k) ? launch.also[k].filter((id, i, a) => id !== launch.picks[k] && a.indexOf(id) === i && projectsAt(k).some((p) => p.id === id)) : [];
|
|
63
|
+
if (ids.length) launch.also[k] = ids;
|
|
64
|
+
else delete launch.also[k];
|
|
65
|
+
}
|
|
51
66
|
launch.project = launch.picks[launch.where[0]] || null;
|
|
52
67
|
}
|
|
53
68
|
|
|
@@ -250,7 +265,7 @@ export function renderLauncher() {
|
|
|
250
265
|
return feedbackCommand(prompt, { restore: () => !ta.value && ((ta.value = prompt), ta.dispatchEvent(new Event('input'))) });
|
|
251
266
|
}
|
|
252
267
|
// One run per place picked under "Run on", each in the project picked for it.
|
|
253
|
-
const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project: projectOf(k), clone: cloneById(launch.picks[k]) }));
|
|
268
|
+
const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project: projectOf(k), clone: cloneById(launch.picks[k]), also: ownPick(k) ? alsoAt(k).map(projectById).filter(Boolean) : [] }));
|
|
254
269
|
const unset = targets.find((t) => !t.project && !t.clone);
|
|
255
270
|
if (unset) return openProjects({ focusAdd: true, machine: unset.k || null });
|
|
256
271
|
const off = targets.find((t) => t.m && t.m.state !== 'online');
|
|
@@ -276,7 +291,7 @@ export function renderLauncher() {
|
|
|
276
291
|
ta.value = '';
|
|
277
292
|
S.drafts.delete('home');
|
|
278
293
|
ta.dispatchEvent(new Event('input'));
|
|
279
|
-
if (S.filter !== 'all' && targets.some((t) => t.project?.scratch || t.project?.id !== S.filter)) {
|
|
294
|
+
if (S.filter !== 'all' && targets.some((t) => t.project?.scratch || (t.project?.id !== S.filter && !t.also.some((p) => p.id === S.filter)))) {
|
|
280
295
|
S.filter = 'all';
|
|
281
296
|
save('wisp.filter', S.filter);
|
|
282
297
|
}
|
|
@@ -298,7 +313,7 @@ export function renderLauncher() {
|
|
|
298
313
|
targets.map((t) =>
|
|
299
314
|
api('/sessions', {
|
|
300
315
|
method: 'POST',
|
|
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 },
|
|
316
|
+
body: { prompt, cwd: t.project.scratch ? undefined : t.project.path, also: t.also.length ? t.also.map((p) => p.path) : undefined, 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 },
|
|
302
317
|
}),
|
|
303
318
|
),
|
|
304
319
|
);
|
|
@@ -374,16 +389,22 @@ export function renderLaunchChips() {
|
|
|
374
389
|
.filter((c) => (c.where || '') === k)
|
|
375
390
|
.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
391
|
.join('');
|
|
377
|
-
// Tens of projects: the most recent few as chips (the
|
|
392
|
+
// Tens of projects: the most recent few as chips (the picks always among them), the rest one
|
|
378
393
|
// search away behind "+N". fitChips() then folds more into it if the row would wrap.
|
|
379
394
|
const max = multi ? 3 : CHIPS;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
395
|
+
const picked = list.filter((p) => chosen(k, p.id));
|
|
396
|
+
const rest = list.filter((p) => !chosen(k, p.id)).slice(0, Math.max(0, max - picked.length));
|
|
397
|
+
const shown = list.filter((p) => picked.includes(p) || rest.includes(p));
|
|
398
|
+
// A task can span several of the place's projects: ⌘-click a chip, or its + badge, to add it;
|
|
399
|
+
// the × on a picked one leaves it out again.
|
|
400
|
+
const own = ownPick(k);
|
|
401
|
+
const several = own && alsoAt(k).length > 0;
|
|
402
|
+
const badge = (p) =>
|
|
403
|
+
chosen(k, p.id)
|
|
404
|
+
? several ? `<span class="lp-also x" data-also="${p.id}" title="Leave ${esc(p.name)} out of this task">${icon('x')}</span>` : ''
|
|
405
|
+
: own ? `<span class="lp-also" data-also="${p.id}" title="Also work in ${esc(p.name)} (${MOD}-click)">${icon('plus')}</span>` : '';
|
|
406
|
+
const tip = (p) => `${projectTitle(p)}${several && p.id === launch.picks[k] ? '\nThe agent starts here' : ''}${own && !chosen(k, p.id) ? `\n${MOD}-click to work in it too` : ''}`;
|
|
407
|
+
const chips = cloning + shown.map((p) => `<button class="lp-chip c-${p.color} ${chosen(k, p.id) ? 'on' : ''}" data-id="${p.id}" data-k="${k}" title="${esc(tip(p))}"><i class="p-dot"></i>${esc(p.name)}${badge(p)}</button>`).join('');
|
|
387
408
|
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
409
|
const add = list.length || cloning
|
|
389
410
|
? `<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>`
|
|
@@ -400,9 +421,21 @@ export function renderLaunchChips() {
|
|
|
400
421
|
});
|
|
401
422
|
fitRow.observe(box);
|
|
402
423
|
paintScratch();
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
424
|
+
// A plain pick makes it the task's only folder; `add` (⌘-click, the + badge) adds it to the
|
|
425
|
+
// folders or takes it out. Leaving out the first folder hands its place to the next one.
|
|
426
|
+
const pick = (k, id, { add = false } = {}) => {
|
|
427
|
+
const also = alsoAt(k);
|
|
428
|
+
if (add && ownPick(k) && projectById(id)) {
|
|
429
|
+
if (id !== launch.picks[k]) launch.also[k] = also.includes(id) ? also.filter((x) => x !== id) : [...also, id];
|
|
430
|
+
else if (also.length) {
|
|
431
|
+
launch.picks[k] = also[0];
|
|
432
|
+
launch.also[k] = also.slice(1);
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
launch.picks[k] = id;
|
|
436
|
+
delete launch.also[k];
|
|
437
|
+
}
|
|
438
|
+
if (k === launch.where[0]) launch.project = launch.picks[k];
|
|
406
439
|
saveLaunch();
|
|
407
440
|
paintScratch();
|
|
408
441
|
renderLaunchRecipes();
|
|
@@ -410,8 +443,9 @@ export function renderLaunchChips() {
|
|
|
410
443
|
box.onclick = (e) => {
|
|
411
444
|
const chip = e.target.closest('.lp-chip[data-id]');
|
|
412
445
|
if (chip) {
|
|
413
|
-
|
|
414
|
-
|
|
446
|
+
const k = chip.dataset.k;
|
|
447
|
+
pick(k, chip.dataset.id, { add: !!e.target.closest('[data-also]') || e.metaKey || e.ctrlKey || e.shiftKey });
|
|
448
|
+
renderLaunchChips(); // the + and × badges follow the picks
|
|
415
449
|
return;
|
|
416
450
|
}
|
|
417
451
|
const more = e.target.closest('[data-more]');
|
|
@@ -420,6 +454,14 @@ export function renderLaunchChips() {
|
|
|
420
454
|
return projectList(more, {
|
|
421
455
|
projects: projectsAt(k),
|
|
422
456
|
current: launch.picks[k],
|
|
457
|
+
// Each row's + adds it to the task (or takes it out) and leaves the list open.
|
|
458
|
+
also: ownPick(k) && {
|
|
459
|
+
has: (id) => chosen(k, id),
|
|
460
|
+
toggle: (id) => {
|
|
461
|
+
pick(k, id, { add: true });
|
|
462
|
+
renderLaunchChips();
|
|
463
|
+
},
|
|
464
|
+
},
|
|
423
465
|
label: `Projects on ${placeName(k)}`,
|
|
424
466
|
extras: [
|
|
425
467
|
{ id: NO_FOLDER, label: 'No folder', icon: 'spark' },
|
|
@@ -460,6 +502,7 @@ function fitChips(box) {
|
|
|
460
502
|
if (launch.where.length > 1) continue;
|
|
461
503
|
const wraps = () => {
|
|
462
504
|
const kids = [...g.children].filter((x) => !x.hidden && x.offsetParent);
|
|
505
|
+
if (!kids.length) return false; // not laid out (a launcher painted over, or folded into the dock)
|
|
463
506
|
const lin = $('.l-in', box); // "in" left alone on a line counts too
|
|
464
507
|
return kids.some((x) => x.offsetTop >= kids[0].offsetTop + kids[0].offsetHeight) || (lin && kids[0].offsetTop >= lin.offsetTop + lin.offsetHeight);
|
|
465
508
|
};
|
|
@@ -605,7 +648,7 @@ function launchedFx(from, to) {
|
|
|
605
648
|
// A launch the server hasn't answered yet: a placeholder summary that shows in Working as a
|
|
606
649
|
// "Starting up" card. It can't be opened; the real card takes its place.
|
|
607
650
|
let pendingSeq = 0;
|
|
608
|
-
function addPending(prompt, { project: p, m }, { agent, model, effort }, live = 'Starting up') {
|
|
651
|
+
function addPending(prompt, { project: p, m, also = [] }, { agent, model, effort }, live = 'Starting up') {
|
|
609
652
|
const id = `pending-${++pendingSeq}`;
|
|
610
653
|
const line = plain(prompt).split('\n')[0].replace(/\s+/g, ' ');
|
|
611
654
|
const now = Date.now();
|
|
@@ -621,6 +664,7 @@ function addPending(prompt, { project: p, m }, { agent, model, effort }, live =
|
|
|
621
664
|
project: p.name,
|
|
622
665
|
projectId: p.id,
|
|
623
666
|
color: p.color || 'slate',
|
|
667
|
+
...(also.length && { also: also.map((x) => ({ cwd: x.path, project: x.name, projectId: x.id, color: x.color, branch: '', pr: null })) }),
|
|
624
668
|
state: 'running',
|
|
625
669
|
live,
|
|
626
670
|
createdAt: now,
|
package/web/js/features/live.js
CHANGED
|
@@ -7,7 +7,7 @@ import { $ } from '../core/dom.js';
|
|
|
7
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,12 +34,7 @@ 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
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
|
});
|
|
@@ -8,6 +8,16 @@ import { S } from '../core/state.js';
|
|
|
8
8
|
export const projectById = (id) => S.config.projects.find((p) => p.id === id);
|
|
9
9
|
|
|
10
10
|
export const placeOf = (p) => p?.machine || '';
|
|
11
|
+
// A task's projects: its own, then those of the other folders it works in (also).
|
|
12
|
+
export const inProject = (s, id) => s.projectId === id || !!s.also?.some((a) => a.projectId === id);
|
|
13
|
+
|
|
14
|
+
// The project on a card: "api + web", or "api +2" past that, each one in the tooltip.
|
|
15
|
+
export function projectLabel(s) {
|
|
16
|
+
const also = s.also || [];
|
|
17
|
+
if (!also.length) return `<span class="q-proj">${esc(s.project)}</span>`;
|
|
18
|
+
const names = [s.project, ...also.map((a) => a.project)];
|
|
19
|
+
return `<span class="q-proj multi" title="${esc(`One task in ${names.length} folders: ${names.join(', ')}`)}">${esc(s.project)}<span class="q-also ${also.length === 1 ? `c-${also[0].color}` : ''}">${also.length === 1 ? `+ ${esc(also[0].project)}` : `+${also.length}`}</span></span>`;
|
|
20
|
+
}
|
|
11
21
|
export const placeName = (k) => (k ? machineById(k)?.name || 'machine' : 'This computer');
|
|
12
22
|
export const recentFirst = (a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt);
|
|
13
23
|
export const projectsAt = (k) => S.config.projects.filter((p) => placeOf(p) === k).sort(recentFirst);
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// meta(p) extra html after a row's name (status badges)
|
|
7
7
|
// extras [{ id, label, icon }] fixed rows under the list (No folder, Other folder…)
|
|
8
8
|
// onPick (id) => …, a project's id or an extra's
|
|
9
|
+
// also { has(id), toggle(id) }: each row gets a + that adds the project to the task (✓ once
|
|
10
|
+
// it's in) without closing the list; ⌘/Ctrl/Shift+Enter does the same from the keyboard
|
|
9
11
|
|
|
10
12
|
import { esc } from '../md.js';
|
|
11
13
|
import { $, el, icon } from '../core/dom.js';
|
|
@@ -13,7 +15,7 @@ import { fuzzy, highlight } from '../fuzzy.js';
|
|
|
13
15
|
import { popover } from '../ui/popover.js';
|
|
14
16
|
import { projectTitle } from './places.js';
|
|
15
17
|
|
|
16
|
-
export function projectList(anchor, { projects, current, meta = () => '', extras = [], onPick, label = 'Projects' }) {
|
|
18
|
+
export function projectList(anchor, { projects, current, meta = () => '', extras = [], onPick, also = null, label = 'Projects' }) {
|
|
17
19
|
if ($('.menu.pm')) return $('.menu.pm').close();
|
|
18
20
|
const node = el(`
|
|
19
21
|
<div class="menu pm" role="dialog" aria-label="${esc(label)}">
|
|
@@ -43,8 +45,10 @@ export function projectList(anchor, { projects, current, meta = () => '', extras
|
|
|
43
45
|
list.innerHTML = hits.length
|
|
44
46
|
? hits
|
|
45
47
|
.map(
|
|
46
|
-
({ p, m }) => `<button class="pm-row c-${p.color} ${p.id === current ? 'on' : ''}" data-id="${esc(p.id)}" role="option" aria-selected="${p.id === current}" title="${esc(projectTitle(p))}">
|
|
47
|
-
<i class="p-dot"></i><span class="pm-name">${highlight(p.name, m?.idx, esc)}</span>${p.machine ? icon('server', 'pm-where') : ''}${meta(p)}<small class="pm-path">${esc(p.path.replace(/^\/(Users|home)\/[^/]+/, '~'))}</small>${
|
|
48
|
+
({ p, m }) => `<button class="pm-row c-${p.color} ${p.id === current ? 'on' : ''} ${also?.has(p.id) ? 'in' : ''}" data-id="${esc(p.id)}" role="option" aria-selected="${p.id === current}" title="${esc(projectTitle(p))}">
|
|
49
|
+
<i class="p-dot"></i><span class="pm-name">${highlight(p.name, m?.idx, esc)}</span>${p.machine ? icon('server', 'pm-where') : ''}${meta(p)}<small class="pm-path">${esc(p.path.replace(/^\/(Users|home)\/[^/]+/, '~'))}</small>${
|
|
50
|
+
also ? `<span class="pm-also" data-also title="${also.has(p.id) ? `Leave ${esc(p.name)} out of this task` : `Also work in ${esc(p.name)}`}">${icon(also.has(p.id) ? 'check' : 'plus')}</span>` : icon('check', 'pm-check')
|
|
51
|
+
}
|
|
48
52
|
</button>`,
|
|
49
53
|
)
|
|
50
54
|
.join('')
|
|
@@ -57,9 +61,19 @@ export function projectList(anchor, { projects, current, meta = () => '', extras
|
|
|
57
61
|
close();
|
|
58
62
|
onPick(id);
|
|
59
63
|
};
|
|
64
|
+
// Adding to the task keeps the list open, the row where it was and the search as typed.
|
|
65
|
+
const toggle = (r) => {
|
|
66
|
+
also.toggle(r.dataset.id);
|
|
67
|
+
const at = act;
|
|
68
|
+
paint();
|
|
69
|
+
mark(at, false);
|
|
70
|
+
input.focus({ preventScroll: true });
|
|
71
|
+
};
|
|
60
72
|
node.onclick = (e) => {
|
|
61
73
|
const r = e.target.closest('.pm-row');
|
|
62
|
-
if (r)
|
|
74
|
+
if (!r) return;
|
|
75
|
+
if (also && !r.classList.contains('pm-x') && (e.target.closest('[data-also]') || e.metaKey || e.ctrlKey || e.shiftKey)) return toggle(r);
|
|
76
|
+
pick(r.dataset.id);
|
|
63
77
|
};
|
|
64
78
|
node.onmousemove = (e) => {
|
|
65
79
|
const r = e.target.closest('.pm-row');
|
|
@@ -73,7 +87,8 @@ export function projectList(anchor, { projects, current, meta = () => '', extras
|
|
|
73
87
|
} else if (e.key === 'Enter' && !e.isComposing) {
|
|
74
88
|
e.preventDefault();
|
|
75
89
|
const r = rows()[act];
|
|
76
|
-
if (r)
|
|
90
|
+
if (r && also && !r.classList.contains('pm-x') && (e.metaKey || e.ctrlKey || e.shiftKey)) toggle(r);
|
|
91
|
+
else if (r) pick(r.dataset.id);
|
|
77
92
|
} else if (e.key === 'Tab') close();
|
|
78
93
|
};
|
|
79
94
|
|
|
@@ -6,14 +6,14 @@ import * as bus from '../core/bus.js';
|
|
|
6
6
|
import { $, $$, el, icon } from '../core/dom.js';
|
|
7
7
|
import { launch, S, save, saveLaunch } from '../core/state.js';
|
|
8
8
|
import { present } from '../ui/overlay.js';
|
|
9
|
-
import { popover } from '../ui/popover.js';
|
|
9
|
+
import { clampToViewport, popover } from '../ui/popover.js';
|
|
10
10
|
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
11
11
|
import { clones, isRepo, repoLabel, repoName, startClone } from './clones.js';
|
|
12
12
|
import { feedToTop, renderFeed } from './feed.js';
|
|
13
13
|
import { renderLaunchChips } from './launcher.js';
|
|
14
14
|
import { loadConfig } from './live.js';
|
|
15
15
|
import { connectMachine, openMachines } from './machines.js';
|
|
16
|
-
import { MACHINE_STATE, machineById, placeOf, projectById, projectTitle, recentFirst } from './places.js';
|
|
16
|
+
import { inProject, MACHINE_STATE, machineById, placeOf, projectById, projectTitle, recentFirst } from './places.js';
|
|
17
17
|
import { projectList } from './project-list.js';
|
|
18
18
|
|
|
19
19
|
// Project filter chips in the top bar before the rest fold into "+N".
|
|
@@ -23,7 +23,7 @@ let fitBar; // refits the chips when the bar's width changes
|
|
|
23
23
|
export function renderProjects() {
|
|
24
24
|
const all = [...S.sessions.values()];
|
|
25
25
|
const stats = (pid) => {
|
|
26
|
-
const l = pid === 'all' ? all : all.filter((s) => s
|
|
26
|
+
const l = pid === 'all' ? all : all.filter((s) => inProject(s, pid));
|
|
27
27
|
return { run: l.filter((s) => s.state === 'running').length, need: l.filter((s) => s.state === 'waiting' || (s.state === 'review' && s.unread)).length };
|
|
28
28
|
};
|
|
29
29
|
const badges = (id) => {
|
|
@@ -146,10 +146,7 @@ function projectMenu(id, e) {
|
|
|
146
146
|
}
|
|
147
147
|
};
|
|
148
148
|
const close = popover(menu);
|
|
149
|
-
|
|
150
|
-
const r = menu.getBoundingClientRect();
|
|
151
|
-
if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
|
|
152
|
-
if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
|
|
149
|
+
clampToViewport(menu, e.clientX, e.clientY);
|
|
153
150
|
}
|
|
154
151
|
|
|
155
152
|
const COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
|
|
@@ -262,6 +259,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
262
259
|
const picked = async (proj) => {
|
|
263
260
|
await loadConfig();
|
|
264
261
|
launch.picks[placeOf(proj)] = proj.id;
|
|
262
|
+
delete launch.also[placeOf(proj)];
|
|
265
263
|
if (!launch.where.includes(placeOf(proj))) launch.where = [placeOf(proj)];
|
|
266
264
|
if (placeOf(proj) === launch.where[0]) launch.project = proj.id;
|
|
267
265
|
saveLaunch();
|
|
@@ -346,7 +344,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
346
344
|
? pending +
|
|
347
345
|
projects
|
|
348
346
|
.map((p) => {
|
|
349
|
-
const n = [...S.sessions.values()].filter((s) => s
|
|
347
|
+
const n = [...S.sessions.values()].filter((s) => inProject(s, p.id)).length;
|
|
350
348
|
return `<div class="prow c-${p.color}" data-id="${p.id}">
|
|
351
349
|
<button class="swatch" title="Change colour"><i class="p-dot"></i></button>
|
|
352
350
|
<div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(tilde(p.path))}</span></div>
|