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.
- package/CHANGELOG.md +12 -1
- package/README.md +8 -4
- package/bin/wisp.js +4 -3
- package/package.json +1 -1
- package/server/adapters/claude.js +5 -4
- package/server/adapters/codex.js +5 -2
- package/server/client.js +3 -2
- package/server/mcp-wisp.js +2 -1
- package/server/openapi.js +1 -0
- package/server/routes/sessions.js +15 -8
- package/server/routes/ship.js +19 -24
- package/server/runner.js +25 -5
- package/server/services/auto.js +10 -2
- package/server/services/launch.js +35 -3
- package/server/services/ship.js +97 -10
- package/skills/wisp/SKILL.md +2 -1
- 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 +12 -0
- package/web/js/core/state.js +4 -2
- package/web/js/features/actions.js +1 -1
- package/web/js/features/feed.js +9 -9
- package/web/js/features/launcher.js +62 -18
- package/web/js/features/places.js +10 -0
- package/web/js/features/project-list.js +20 -5
- package/web/js/features/projects.js +4 -3
- package/web/js/features/ship.js +66 -29
- package/web/js/features/turns.js +2 -1
package/server/services/ship.js
CHANGED
|
@@ -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
|
|
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
|
|
39
|
-
const
|
|
40
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/skills/wisp/SKILL.md
CHANGED
|
@@ -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), `--
|
|
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 */
|
package/web/css/launcher.css
CHANGED
|
@@ -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 */
|
package/web/css/palette.css
CHANGED
|
@@ -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
|
|
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));
|
|
@@ -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) {
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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>`}
|
|
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
|
|
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 ? '' :
|
|
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
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
|