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.
Files changed (52) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/README.md +23 -7
  3. package/bin/wisp.js +4 -3
  4. package/package.json +2 -2
  5. package/server/adapters/antigravity.js +2 -1
  6. package/server/adapters/claude.js +7 -5
  7. package/server/adapters/codex.js +7 -3
  8. package/server/adapters/commands.js +808 -0
  9. package/server/adapters/cursor.js +2 -1
  10. package/server/adapters/grok.js +2 -1
  11. package/server/adapters/opencode.js +2 -1
  12. package/server/check.js +205 -0
  13. package/server/client.js +3 -2
  14. package/server/index.js +2 -1
  15. package/server/judge.js +78 -0
  16. package/server/mcp-wisp.js +2 -1
  17. package/server/openapi.js +4 -3
  18. package/server/pipeline.js +217 -0
  19. package/server/quick.js +76 -0
  20. package/server/router.js +201 -28
  21. package/server/routes/auto.js +8 -1
  22. package/server/routes/sessions.js +15 -8
  23. package/server/routes/settings.js +2 -0
  24. package/server/routes/ship.js +27 -27
  25. package/server/runner.js +83 -17
  26. package/server/scout.js +71 -0
  27. package/server/services/auto.js +111 -14
  28. package/server/services/launch.js +45 -8
  29. package/server/services/ship.js +132 -23
  30. package/skills/wisp/SKILL.md +2 -1
  31. package/web/css/auto.css +22 -0
  32. package/web/css/feed.css +3 -0
  33. package/web/css/launcher.css +12 -0
  34. package/web/css/palette.css +6 -0
  35. package/web/css/ship.css +17 -1
  36. package/web/index.html +1 -0
  37. package/web/js/blocks.js +1 -1
  38. package/web/js/core/state.js +4 -2
  39. package/web/js/features/actions.js +3 -5
  40. package/web/js/features/auto.js +38 -7
  41. package/web/js/features/composer.js +2 -4
  42. package/web/js/features/feed.js +40 -10
  43. package/web/js/features/launcher.js +62 -18
  44. package/web/js/features/live.js +2 -7
  45. package/web/js/features/places.js +10 -0
  46. package/web/js/features/project-list.js +20 -5
  47. package/web/js/features/projects.js +6 -8
  48. package/web/js/features/runners.js +7 -6
  49. package/web/js/features/settings.js +34 -8
  50. package/web/js/features/ship.js +95 -31
  51. package/web/js/features/turns.js +6 -4
  52. 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>
@@ -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
- const r = menu.getBoundingClientRect();
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) {
@@ -1,15 +1,17 @@
1
- // Auto: what the router (server/router.js) learned about which harness and model work for you.
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>), now and then trying an alternative so the estimates stay honest.</p>
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
- const r = menu.getBoundingClientRect();
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.
@@ -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.projectId === S.filter);
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
- ${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
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
- <span class="q-proj">${esc(s.project)}</span>
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>`}<span class="q-proj">${esc(s.project)}</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
- ${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
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
- 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' : places > 1 ? `All finished on ${places} machines` : 'All finished · pick one to keep';
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 ? '' : `<span class="q-proj">${esc(t.project)}</span>`}
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 !== s.projectId) {
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
- launch.picks[placeOf(f)] = f.id;
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 pick always among them), the rest one
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
- 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('');
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
- const pick = (k, id) => {
404
- launch.picks[k] = id;
405
- if (k === launch.where[0]) launch.project = id;
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
- pick(chip.dataset.k, chip.dataset.id);
414
- $$(`.lp-chip[data-k="${chip.dataset.k}"]`, box).forEach((x) => x.classList.toggle('on', x === chip));
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,
@@ -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 { collapse, renderFeed, scheduleRefetch, sectionOf } from './feed.js';
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>${icon('check', 'pm-check')}
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) pick(r.dataset.id);
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) pick(r.dataset.id);
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.projectId === pid);
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
- // Keep the menu on-screen when right-clicking near the window edge.
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.projectId === p.id).length;
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>