wisp-agents 0.20.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.
@@ -5,9 +5,11 @@
5
5
  // result is pushed to the PR when it's done.
6
6
 
7
7
  import fs from 'node:fs';
8
- import { worktreeStatus, prView, ship, baseMerged, conflictsWithBase } from '../git.js';
8
+ import path from 'node:path';
9
+ import { worktreeStatus, prView, ship, merge, baseMerged, conflictsWithBase } from '../git.js';
9
10
  import { blocksToMarkdown } from '../render.js';
10
11
  import { summarize } from '../runner.js';
12
+ import { bad } from '../util.js';
11
13
 
12
14
  const PR_POLL_MS = 60_000;
13
15
  const PR_FAST_POLL_MS = 10_000; // while GitHub is still working something out: checks running, mergeability unknown, a push just landed
@@ -29,15 +31,28 @@ export const conflictPrompt = (s) => {
29
31
  ].join('\n');
30
32
  };
31
33
 
34
+ // The repos a task ships: its own worktree, then those of its other folders (also). Each is
35
+ // { name, wt, at }, where `at` holds that repo's PR (the session for its own, else the also entry).
36
+ export function reposOf(s, store) {
37
+ const name = (dir) => store?.projectFor?.(dir)?.name || path.basename(dir || '');
38
+ return [s.worktree && { name: name(s.cwd), wt: s.worktree, at: s }, ...(s.also || []).map((a) => a.worktree && { name: name(a.cwd), wt: a.worktree, at: a })].filter(Boolean);
39
+ }
40
+ // Something new for this repo's PR: uncommitted changes, or commits its open PR (or a new one) lacks.
41
+ const pending = (st, pr) => !!(st.dirty || (pr?.state === 'OPEN' ? st.unpushed : st.ahead));
42
+ const hasTrees = (s) => !!(s?.worktree || s?.also?.some((a) => a.worktree));
43
+
32
44
  export function createShip(app) {
33
45
  const { store, runner } = app;
34
46
 
35
47
  // Ship it shows only when the worktree has something to ship: uncommitted changes, or commits
36
- // not yet pushed. Rechecked whenever wisp might have changed the worktree.
48
+ // not yet pushed. Rechecked whenever wisp might have changed the worktree. A task in several
49
+ // repos adds theirs up, and `repos` counts the ones with something to ship.
37
50
  const check = async (s) => {
38
- if (!s?.worktree || s.parentId) return;
39
- const { dirty, ahead, unpushed } = await worktreeStatus(s.worktree);
40
- const next = { dirty, ahead, unpushed };
51
+ if (!hasTrees(s) || s.parentId) return;
52
+ const repos = reposOf(s, store);
53
+ const all = await Promise.all(repos.map((r) => worktreeStatus(r.wt)));
54
+ const sum = (k) => all.reduce((n, st) => n + st[k], 0);
55
+ const next = { dirty: sum('dirty'), ahead: sum('ahead'), unpushed: sum('unpushed'), ...(repos.length > 1 && { repos: all.filter((st, i) => pending(st, repos[i].at.pr)).length }) };
41
56
  if (JSON.stringify(next) === JSON.stringify(s.toShip)) return;
42
57
  s.toShip = next;
43
58
  app.sessionChanged(s);
@@ -47,7 +62,7 @@ export function createShip(app) {
47
62
  const checks = new Map(); // id → true while checking, 'again' if another check is due
48
63
  const checkedAt = new Map();
49
64
  const recheck = (s, { lazy = false } = {}) => {
50
- if (!s?.worktree || s.parentId) return;
65
+ if (!hasTrees(s) || s.parentId) return;
51
66
  if (lazy && (runner.live.has(s.id) || checks.has(s.id) || Date.now() - (checkedAt.get(s.id) || 0) < SHIP_RECHECK_MS)) return;
52
67
  if (checks.has(s.id)) return void checks.set(s.id, 'again');
53
68
  checks.set(s.id, true);
@@ -61,8 +76,25 @@ export function createShip(app) {
61
76
  });
62
77
  };
63
78
 
79
+ // The PRs of the task's other repos: their state, checks and reviews, for the card.
80
+ const refreshAlso = async (s) => {
81
+ const open = (s.also || []).filter((a) => a.pr && a.worktree && a.pr.state === 'OPEN');
82
+ if (!open.length) return false;
83
+ let news = false;
84
+ await Promise.all(
85
+ open.map(async (a) => {
86
+ const prev = a.pr;
87
+ a.pr = await prView(fs.existsSync(a.worktree.path) ? a.worktree.path : a.worktree.root, prev.number).catch(() => ({ ...prev, checkedAt: Date.now() }));
88
+ if (a.pr.state !== prev.state || a.pr.review !== prev.review || (!a.pr.checks.pending && prev.checks?.pending)) news = true;
89
+ }),
90
+ );
91
+ if (news) s.unread = true;
92
+ app.sessionChanged(s);
93
+ return true;
94
+ };
64
95
  const refreshPr = async (s) => {
65
- if (!s.pr || !s.worktree) return s.pr;
96
+ await refreshAlso(s);
97
+ if (!s.pr || !s.worktree) return s.pr || s.also?.find((a) => a.pr)?.pr;
66
98
  const prev = s.pr;
67
99
  const dir = fs.existsSync(s.worktree.path) ? s.worktree.path : s.worktree.root;
68
100
  const pr = await prView(dir, prev.number);
@@ -135,6 +167,59 @@ export function createShip(app) {
135
167
  recheck(s);
136
168
  };
137
169
 
170
+ // Ship it: commit, push and open (or update) the PR, in each of the task's repos with something
171
+ // new. One repo ships as before, even with nothing new (git says why). Several: one PR each,
172
+ // each noting the others; a failure names its repo and leaves the PRs already opened in place.
173
+ const shipAll = async (s, { title, body = '', draft = false }) => {
174
+ const repos = reposOf(s, store);
175
+ let todo = repos;
176
+ if (repos.length > 1) {
177
+ const st = await Promise.all(repos.map((r) => worktreeStatus(r.wt)));
178
+ todo = repos.filter((r, i) => pending(st[i], r.at.pr));
179
+ if (!todo.length) throw bad('Nothing to ship: none of the task’s repos have new changes.');
180
+ }
181
+ const note = repos.length > 1 ? `\n\nPart of one task across ${repos.map((r) => `\`${r.name}\``).join(', ')}: wisp opens a pull request in each.` : '';
182
+ s.shipping = { step: 'start', update: todo.every((r) => r.at.pr?.state === 'OPEN'), startedAt: Date.now(), ...(todo.length > 1 && { of: todo.length }) };
183
+ app.sessionChanged(s);
184
+ try {
185
+ for (const [i, r] of todo.entries()) {
186
+ const onStep = (step) => ((s.shipping = { ...s.shipping, step, ...(todo.length > 1 && { repo: r.name, at: i + 1 }) }), app.sessionChanged(s));
187
+ try {
188
+ r.at.pr = await ship(r.wt, { title, body: `${body}${note}`.trim(), draft, pr: r.at.pr, onStep });
189
+ } catch (err) {
190
+ throw repos.length > 1 ? Object.assign(new Error(`${r.name}: ${err.message}`), { status: err.status }) : err;
191
+ }
192
+ }
193
+ s.pushedAt = Date.now(); // the PR is polled fast for a while, so its checks show up as they start
194
+ } finally {
195
+ delete s.shipping;
196
+ app.sessionChanged(s, { now: true });
197
+ }
198
+ recheck(s);
199
+ return { ...(s.pr || todo[0].at.pr), ...(repos.length > 1 && { prs: prsOf(s) }) };
200
+ };
201
+ // Every PR the task opened, its own first: { repo, number, url, state }.
202
+ const prsOf = (s) => reposOf(s, store).filter((r) => r.at.pr).map((r) => ({ repo: r.name, number: r.at.pr.number, url: r.at.pr.url, state: r.at.pr.state }));
203
+
204
+ // Merge: squash-merge each open PR of the task, its own first. Stops at the first that fails.
205
+ const mergeAll = async (s) => {
206
+ const open = reposOf(s, store).filter((r) => r.at.pr?.state === 'OPEN');
207
+ if (!open.length) throw bad('No pull request to merge.');
208
+ const synced = [];
209
+ for (const r of open) {
210
+ try {
211
+ const { pr, sync } = await merge(r.wt, r.at.pr.number);
212
+ r.at.pr = pr;
213
+ synced.push(sync);
214
+ } catch (err) {
215
+ app.sessionChanged(s, { now: true });
216
+ throw open.length > 1 ? Object.assign(new Error(`${r.name}: ${err.message}`), { status: err.status }) : err;
217
+ }
218
+ }
219
+ const sync = { ok: synced.every((x) => x.ok), text: synced.map((x) => x.text).join(' ') };
220
+ return { pr: s.pr || open[0].at.pr, sync, ...(reposOf(s, store).length > 1 && { prs: prsOf(s) }) };
221
+ };
222
+
138
223
  // Default commit message and PR text, from the session's latest presentation.
139
224
  const draft = (s) => {
140
225
  const turn = s.turns.findLast((t) => t.status === 'done') || s.turns.at(-1);
@@ -158,7 +243,7 @@ export function createShip(app) {
158
243
  delete s.shipping; // a ship cut short by a restart isn't running anymore
159
244
  if (s.conflictFix?.step === 'pushing') Object.assign(s.conflictFix, { step: 'failed', why: 'wisp restarted while pushing the fix.' });
160
245
  }
161
- for (const s of store.all()) if (s.worktree && !s.archived && s.pr?.state !== 'MERGED') recheck(s);
246
+ for (const s of store.all()) if (hasTrees(s) && !s.archived && s.pr?.state !== 'MERGED') recheck(s);
162
247
 
163
248
  // Open PRs are checked every minute, and every few seconds while their state is still settling,
164
249
  // so a check finishing or a pushed fix clearing the conflict shows up quickly.
@@ -171,7 +256,9 @@ export function createShip(app) {
171
256
  if (polling) return;
172
257
  polling = true;
173
258
  try {
174
- const due = store.all().filter((s) => s.pr?.state === 'OPEN' && Date.now() - (s.pr.checkedAt || 0) >= (settling(s) ? PR_FAST_POLL_MS : PR_POLL_MS));
259
+ // A task's other repos' PRs ride along with its own (or poll on their own when it has none open).
260
+ const lead = (s) => (s.pr?.state === 'OPEN' ? s.pr : s.also?.find((a) => a.pr?.state === 'OPEN')?.pr);
261
+ const due = store.all().filter((s) => lead(s) && Date.now() - (lead(s).checkedAt || 0) >= (s.pr?.state === 'OPEN' && settling(s) ? PR_FAST_POLL_MS : PR_POLL_MS));
175
262
  await Promise.all(due.map((s) => refreshPr(s).catch(() => {})));
176
263
  } finally {
177
264
  polling = false;
@@ -179,5 +266,5 @@ export function createShip(app) {
179
266
  }, PR_TICK_MS);
180
267
  timer.unref();
181
268
 
182
- return { recheck, refreshPr, fixConflicts, draft, stop: () => clearInterval(timer) };
269
+ return { recheck, refreshPr, fixConflicts, draft, shipAll, mergeAll, prsOf, stop: () => clearInterval(timer) };
183
270
  }
@@ -23,7 +23,8 @@ wisp "add pagination to /api/users" --cwd ~/code/api --wait # block until do
23
23
  ```
24
24
 
25
25
  Options: `--agent claude|codex|opencode|cursor|antigravity|grok`, `--mode plan|edits|auto`, `--model ID`,
26
- `--effort low|medium|high`, `--cwd DIR` (default: current folder), `--wait`, `--json`.
26
+ `--effort low|medium|high`, `--cwd DIR` (default: current folder), `--also DIR` (another folder the
27
+ same agent works in, repeatable: e.g. the API and its web app), `--wait`, `--json`.
27
28
  `--json` prints the session summary; its `id` is what every other command takes.
28
29
 
29
30
  Write the prompt as a self-contained brief: the agent cannot see your conversation. Include the
package/web/css/feed.css CHANGED
@@ -78,6 +78,9 @@
78
78
  font-size: 11px; font-weight: 500; padding: 0 7px; border-radius: 5px; line-height: 19px; white-space: nowrap; max-width: 120px; overflow: hidden; text-overflow: ellipsis;
79
79
  color: color-mix(in srgb, var(--pc) 65%, var(--ink)); background: color-mix(in srgb, var(--pc) 10%, transparent);
80
80
  }
81
+ /* a task in several folders: the others after its own, each in its colour */
82
+ .q-proj.multi { max-width: 180px; }
83
+ .q-also { margin-left: 4px; color: color-mix(in srgb, var(--pc) 65%, var(--ink)); }
81
84
  .q-time { font-size: 11.5px; color: var(--faint); min-width: 26px; text-align: right; font-variant-numeric: tabular-nums; }
82
85
  .q-queued { font-size: 11px; color: var(--accent-ink); font-weight: 600; }
83
86
  /* one-click done on collapsed review rows; revealed on hover/focus */
@@ -41,6 +41,18 @@
41
41
  .lp-chip.more:hover, .lp-chip.more.open { color: var(--ink); border-color: var(--line-2); }
42
42
  .lp-chip.more .i { width: 12px; height: 12px; }
43
43
  .l-projects .lp-chip[hidden] { display: none; }
44
+ /* one task, several folders: a small badge on a chip's corner adds it (+) or leaves it out (×) */
45
+ .l-projects .lp-chip { position: relative; }
46
+ .lp-also {
47
+ position: absolute; top: -6px; right: -5px; display: grid; place-items: center; width: 16px; height: 16px; border-radius: 50%;
48
+ background: var(--surface); border: 1px solid var(--line-2); color: var(--muted); box-shadow: var(--shadow-1);
49
+ opacity: 0; transform: scale(0.6); transition: opacity 0.12s, transform 0.12s, color 0.12s, background 0.12s; pointer-events: none;
50
+ }
51
+ .lp-also .i { width: 10px; height: 10px; }
52
+ .lp-chip:is(:hover, :focus-visible) .lp-also { opacity: 1; transform: none; pointer-events: auto; }
53
+ .lp-also:hover { color: #fff; background: var(--accent); border-color: var(--accent); }
54
+ .lp-also.x:hover { background: var(--bad); border-color: var(--bad); }
55
+ @media (hover: none) { .lp-also:not(.x) { opacity: 1; transform: none; pointer-events: auto; } }
44
56
  /* narrow, with "+N" (whose list has No folder too): it goes icon-only unless picked */
45
57
  @container launcher (max-width: 520px) { .l-projects:has(.more:not([hidden])) .lp-chip.none:not(.on) span { display: none; } }
46
58
  /* recipes: one-click prompts under the launch bar */
@@ -73,6 +73,12 @@
73
73
  .pm-row .p-run { flex: none; }
74
74
  .pm-row .pm-check { flex: none; width: 14px; height: 14px; color: var(--accent); visibility: hidden; }
75
75
  .pm-row.on .pm-check { visibility: visible; }
76
+ /* the launcher's list: + adds a project to the task, ✓ shows it's in */
77
+ .pm-also { flex: none; display: grid; place-items: center; width: 20px; height: 20px; margin: -2px -4px -2px 0; border-radius: 6px; color: var(--faint); }
78
+ .pm-also .i { width: 13px; height: 13px; }
79
+ .pm-also:hover { color: var(--ink); background: var(--surface-2); }
80
+ .pm-row.in .pm-also { color: var(--accent); }
81
+ .pm-row.in .pm-name { font-weight: 500; color: var(--ink); }
76
82
  .pm-x .i { width: 14px; height: 14px; color: var(--muted); }
77
83
  .pm-empty { padding: 18px 10px; text-align: center; font-size: 13px; color: var(--muted); }
78
84
  .menu .danger:hover { color: var(--bad); background: var(--del-bg); }
package/web/css/ship.css CHANGED
@@ -42,6 +42,7 @@
42
42
  .pill.st-running { --tone: var(--accent); }
43
43
  .it-meta .m.branch, .it-meta .m:has(> .i) { display: inline-flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
44
44
  .it-meta .m.branch { font: 11px var(--mono); color: var(--ink-2); }
45
+ .it-meta .m.also { display: inline-flex; align-items: center; gap: 5px; color: var(--ink-2); }
45
46
  .it-meta .m .i { width: 12px; height: 12px; flex: none; color: var(--faint); }
46
47
  .it-meta .m.m-plan, .it-meta .m.m-plan .i { color: var(--accent-ink); }
47
48
  .it-meta .m.m-auto, .it-meta .m.m-auto .i { color: var(--warn); }
@@ -60,6 +61,14 @@
60
61
  .pr-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--tone); flex: none; }
61
62
  .pr-strip.pr-pending .pr-dot { animation: pulse 1.4s ease-in-out infinite; }
62
63
  .pr-num { font-weight: 600; color: var(--ink); white-space: nowrap; }
64
+ /* a task in several repos: their PRs after the first, each with its own tone */
65
+ .pr-also { --tone: var(--good); display: inline-flex; align-items: center; gap: 5px; flex: none; padding: 1px 7px; border-radius: 6px; font-weight: 500; color: var(--ink); white-space: nowrap; background: color-mix(in srgb, var(--tone) 10%, transparent); }
66
+ .pr-also i { width: 6px; height: 6px; border-radius: 50%; background: var(--tone); }
67
+ .pr-also:hover { background: color-mix(in srgb, var(--tone) 18%, transparent); }
68
+ .pr-also.pr-pending { --tone: var(--warn); }
69
+ .pr-also.pr-fail { --tone: var(--bad); }
70
+ .pr-also.pr-merged { --tone: #8250df; }
71
+ .pr-also.pr-closed { --tone: var(--faint); }
63
72
  .pr-line { color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
64
73
  .pr-strip .ghost .i { width: 13px; height: 13px; }
65
74
  .pr-strip .pr-merge { color: var(--good); font-weight: 500; }
@@ -74,12 +83,15 @@
74
83
  .ship-route code { font: 12px var(--mono); padding: 1px 6px; border-radius: 6px; background: var(--bg-2); }
75
84
  .ship-route .i { width: 13px; height: 13px; color: var(--faint); }
76
85
  .ship-route .muted { margin-left: auto; }
86
+ .ship-route .ship-repo { font-weight: 600; color: var(--ink); }
87
+ .ship-route + .ship-route { margin-top: -6px; }
77
88
  .ship-field { display: flex; flex-direction: column; gap: 6px; margin: 12px 0 0; }
78
89
  .ship-field > span { font-size: 12.5px; font-weight: 500; color: var(--ink-2); }
79
90
  .ship-field select { width: 100%; }
80
91
  .ship-check { display: flex; margin-top: 10px; }
81
92
  .ship-foot { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
82
93
  .ship-foot > .muted { margin-right: auto; }
94
+ .ship-foot > .primary { flex: none; white-space: nowrap; }
83
95
  .ship-foot code { font-size: 11px; }
84
96
  .hint.bad { color: var(--bad); }
85
97
 
@@ -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));
@@ -279,7 +279,7 @@ export async function rerun(id) {
279
279
  if (!prompt) return ta.focus();
280
280
  close();
281
281
  try {
282
- 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) };
283
283
  const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort } }, () => api('/sessions', { method: 'POST', body }));
284
284
  setFocus(next.id);
285
285
  } catch (err) {
@@ -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
@@ -209,10 +209,10 @@ function renderHead(n, s) {
209
209
  ${pill}
210
210
  ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
211
211
  ${autoPill(s)}
212
- ${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
212
+ ${s.shipping ? shipPill(s) : prOf(s) ? prPill(prOf(s), s.conflictFix) : ''}
213
213
  ${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort, s.route?.switched ? null : s.route)}
214
214
  ${s.machine ? machinePill(s.machine) : ''}
215
- <span class="q-proj">${esc(s.project)}</span>
215
+ ${projectLabel(s)}
216
216
  <span class="q-time">${ago(s.updatedAt)}</span>
217
217
  ${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
218
218
  ${icon('chevron', 'chev')}
@@ -253,11 +253,11 @@ function renderRunHead(n, s, { open, live, pill }) {
253
253
  ${live ? `<span class="q-live ${s.state}">${esc(s.live)}</span>` : open ? '' : `<span class="it-sub">${esc(s.preview || '')}</span>`}
254
254
  </span>
255
255
  <span class="it-side">
256
- ${runsOf(s).some((r) => (r.machine?.id || '') !== (s.machine?.id || '')) ? `${s.machine ? machinePill(s.machine) : `<span class="q-machine" title="Runs on this computer">${icon('laptop')}<span>This computer</span></span>`}<span class="q-proj">${esc(s.project)}</span>` : ''}
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)}` : ''}
257
257
  ${live && s.state === 'running' ? `<span class="pill st-running"><i></i>Working</span>` : pill}
258
258
  ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
259
259
  ${autoPill(s)}
260
- ${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
260
+ ${s.shipping ? shipPill(s) : prOf(s) ? prPill(prOf(s), s.conflictFix) : ''}
261
261
  <span class="q-time">${ago(s.updatedAt)}</span>
262
262
  ${icon('chevron', 'chev')}
263
263
  </span>`;
@@ -297,7 +297,7 @@ function taskItem(t) {
297
297
  </span>
298
298
  <span class="it-side">
299
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>
300
- ${places > 1 ? '' : `<span class="q-proj">${esc(t.project)}</span>`}
300
+ ${places > 1 ? '' : projectLabel(t)}
301
301
  <span class="q-time">${ago(t.updatedAt)}</span>
302
302
  ${t.state === 'review' && !t.archived ? `<button class="it-quick" data-a="quick-done" title="Mark every run done">${icon('done')}</button>` : ''}
303
303
  </span>`;
@@ -394,7 +394,7 @@ export function reveal(id) {
394
394
  S.showDone = true;
395
395
  save('wisp.showDone', true);
396
396
  }
397
- if (S.filter !== 'all' && S.filter !== s.projectId) {
397
+ if (S.filter !== 'all' && !inProject(s, S.filter)) {
398
398
  S.filter = 'all';
399
399
  save('wisp.filter', S.filter);
400
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,
@@ -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