wisp-agents 0.16.0 → 0.18.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 +38 -1
- package/README.md +6 -5
- package/bin/wisp.js +4 -2
- package/package.json +2 -2
- package/server/adapters/antigravity.js +5 -1
- package/server/adapters/claude.js +48 -5
- package/server/adapters/codex.js +3 -0
- package/server/adapters/cursor.js +2 -0
- package/server/adapters/grok.js +15 -0
- package/server/adapters/index.js +9 -1
- package/server/adapters/opencode.js +31 -6
- package/server/client.js +1 -1
- package/server/git.js +114 -2
- package/server/handoff.js +2 -1
- package/server/machines.js +2 -2
- package/server/mcp-ask.js +113 -8
- package/server/mentions.js +39 -0
- package/server/openapi.js +21 -3
- package/server/protocol.js +8 -1
- package/server/render.js +3 -1
- package/server/routes/internal.js +121 -1
- package/server/routes/meta.js +1 -1
- package/server/routes/sessions.js +12 -0
- package/server/routes/settings.js +51 -4
- package/server/routes/ship.js +24 -2
- package/server/runner.js +142 -22
- package/server/services/auto.js +12 -1
- package/server/services/launch.js +12 -8
- package/server/services/phone.js +1 -1
- package/server/services/ship.js +83 -4
- package/server/store.js +23 -2
- package/server/triggers.js +1 -1
- package/server/usage.js +94 -0
- package/server/welcome.js +2 -2
- package/web/css/composer.css +22 -0
- package/web/css/feed.css +27 -0
- package/web/css/launcher.css +17 -0
- package/web/css/layout.css +10 -2
- package/web/css/modals.css +8 -0
- package/web/css/palette.css +22 -0
- package/web/css/ship.css +42 -2
- package/web/index.html +16 -5
- package/web/js/app.js +14 -3
- package/web/js/blocks.js +46 -0
- package/web/js/core/state.js +2 -1
- package/web/js/features/actions.js +9 -4
- package/web/js/features/automations.js +1 -1
- package/web/js/features/clones.js +79 -0
- package/web/js/features/compare.js +2 -1
- package/web/js/features/composer.js +11 -7
- package/web/js/features/feed.js +3 -3
- package/web/js/features/launcher.js +150 -28
- package/web/js/features/live.js +3 -3
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +1 -1
- package/web/js/features/project-list.js +99 -0
- package/web/js/features/projects.js +218 -34
- package/web/js/features/runners.js +3 -1
- package/web/js/features/settings.js +16 -2
- package/web/js/features/setup.js +3 -3
- package/web/js/features/ship.js +100 -24
- package/web/js/features/turns.js +44 -13
- package/web/js/mention-text.js +59 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Repos being cloned. Clone in the Projects dialog hands the repo off here and the dialog closes:
|
|
2
|
+
// the launcher picks it at once (a spinner chip) so you can write the task while it clones, and a
|
|
3
|
+
// launch into it waits for the clone. Success swaps the chip for the real project.
|
|
4
|
+
|
|
5
|
+
import { esc } from '../md.js';
|
|
6
|
+
import { api } from '../core/api.js';
|
|
7
|
+
import * as bus from '../core/bus.js';
|
|
8
|
+
import { launch, S, save, saveLaunch } from '../core/state.js';
|
|
9
|
+
import { toast } from '../ui/toast.js';
|
|
10
|
+
import { renderFeed } from './feed.js';
|
|
11
|
+
import { renderLaunchChips } from './launcher.js';
|
|
12
|
+
import { loadConfig } from './live.js';
|
|
13
|
+
import { machineById, projectById } from './places.js';
|
|
14
|
+
import { renderProjects } from './projects.js';
|
|
15
|
+
|
|
16
|
+
// A pasted repo URL, which the server clones (server/git.js parseRepo has the full rules), vs a path.
|
|
17
|
+
export const isRepo = (s) => /^((https?|ssh|git):\/\/|[\w.-]+@[\w.-]+:|(www\.)?github\.com\/)/i.test(String(s).trim());
|
|
18
|
+
export const repoLabel = (s) =>
|
|
19
|
+
String(s)
|
|
20
|
+
.trim()
|
|
21
|
+
.replace(/^((https?|ssh):\/\/)?([\w.-]+@)?(www\.)?github\.com[/:]([^/]+\/[^/#?]+).*$/i, '$5')
|
|
22
|
+
.replace(/\.git$|\/+$/i, '');
|
|
23
|
+
export const repoName = (s) => repoLabel(s).split(/[/:]/).pop();
|
|
24
|
+
|
|
25
|
+
export const clones = []; // { id, url, where, name, label, promise }
|
|
26
|
+
export const cloneById = (id) => clones.find((c) => c.id === id);
|
|
27
|
+
let seq = 0;
|
|
28
|
+
|
|
29
|
+
const tilde = (p) => (S.config.home && p.startsWith(S.config.home) ? `~${p.slice(S.config.home.length)}` : p);
|
|
30
|
+
|
|
31
|
+
// Start cloning `url` on `where` (a machine id, or null for this computer) and point the launcher
|
|
32
|
+
// at it. job.promise resolves with the project, or rejects (already toasted) and the launcher
|
|
33
|
+
// goes back to what it had picked.
|
|
34
|
+
export function startClone(url, where = null) {
|
|
35
|
+
const k = where || '';
|
|
36
|
+
const prev = launch.picks[k];
|
|
37
|
+
const job = { id: `clone-${++seq}`, url, where: where || null, name: repoName(url), label: repoLabel(url) };
|
|
38
|
+
clones.push(job);
|
|
39
|
+
// A feed filtered to another project would pull the launcher back to that project.
|
|
40
|
+
if (S.filter !== 'all') {
|
|
41
|
+
S.filter = 'all';
|
|
42
|
+
save('wisp.filter', S.filter);
|
|
43
|
+
renderFeed();
|
|
44
|
+
renderProjects();
|
|
45
|
+
}
|
|
46
|
+
launch.picks[k] = job.id;
|
|
47
|
+
if (!launch.where.includes(k)) launch.where = [k];
|
|
48
|
+
if (k === launch.where[0]) launch.project = job.id;
|
|
49
|
+
const repick = (id) => {
|
|
50
|
+
if (launch.picks[k] !== job.id) return; // picked something else meanwhile
|
|
51
|
+
launch.picks[k] = id;
|
|
52
|
+
if (k === launch.where[0]) launch.project = id;
|
|
53
|
+
saveLaunch();
|
|
54
|
+
};
|
|
55
|
+
const on = where ? ` on ${esc(machineById(where)?.name || 'the machine')}` : '';
|
|
56
|
+
job.promise = api('/projects', { method: 'POST', body: { repo: url, ...(where && { machine: where }) } })
|
|
57
|
+
.then(
|
|
58
|
+
async (proj) => {
|
|
59
|
+
await loadConfig();
|
|
60
|
+
repick(proj.id);
|
|
61
|
+
toast(proj.cloned?.existed ? `<b>${esc(proj.name)}</b> was already cloned at ${esc(tilde(proj.path))}: added it` : `Cloned <b>${esc(proj.name)}</b>${on}${where ? '' : ` into ${esc(tilde(proj.path))}`}`);
|
|
62
|
+
return proj;
|
|
63
|
+
},
|
|
64
|
+
(err) => {
|
|
65
|
+
repick(projectById(prev) ? prev : null);
|
|
66
|
+
toast(esc(err.message), { tone: 'bad', timeout: 8000 });
|
|
67
|
+
throw Object.assign(err, { shown: true });
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
.finally(() => {
|
|
71
|
+
clones.splice(clones.indexOf(job), 1);
|
|
72
|
+
renderLaunchChips();
|
|
73
|
+
bus.emit('clones');
|
|
74
|
+
});
|
|
75
|
+
job.promise.catch(() => {}); // the launcher or dialog may not be waiting on it
|
|
76
|
+
renderLaunchChips();
|
|
77
|
+
bus.emit('clones');
|
|
78
|
+
return job;
|
|
79
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { countChanges, fmtDuration, renderPresentation } from '../blocks.js';
|
|
4
4
|
import { esc } from '../md.js';
|
|
5
|
+
import { mentionHtml } from '../mention-text.js';
|
|
5
6
|
import { api } from '../core/api.js';
|
|
6
7
|
import { $, el, icon } from '../core/dom.js';
|
|
7
8
|
import { STATUS_LABEL } from '../core/format.js';
|
|
@@ -37,7 +38,7 @@ export async function compareView(id) {
|
|
|
37
38
|
const node = el(`
|
|
38
39
|
<div class="modal compare-modal">
|
|
39
40
|
<header><h2>Compare</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
40
|
-
<p class="cmp-prompt">${esc(fulls[0]?.turns[0]?.prompt || '')}</p>
|
|
41
|
+
<p class="cmp-prompt">${mentionHtml(esc(fulls[0]?.turns[0]?.prompt || ''))}</p>
|
|
41
42
|
<div class="cmp-grid" style="--n:${fulls.length}"></div>
|
|
42
43
|
</div>`);
|
|
43
44
|
const close = overlay(node);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The composer at the bottom of an expanded card: follow-ups, answers, queued follow-ups.
|
|
2
2
|
|
|
3
3
|
import { esc } from '../md.js';
|
|
4
|
+
import { mentionHtml } from '../mention-text.js';
|
|
4
5
|
import { api } from '../core/api.js';
|
|
5
6
|
import { $, el, icon } from '../core/dom.js';
|
|
6
7
|
import { S } from '../core/state.js';
|
|
@@ -10,9 +11,10 @@ import { toggleDone } from './actions.js';
|
|
|
10
11
|
import { acceptFiles, attachTray, autosize } from './attachments.js';
|
|
11
12
|
import { renderFeed, scheduleRefetch, setFocus } from './feed.js';
|
|
12
13
|
import { feedbackCommand, isFeedback } from './feedback.js';
|
|
14
|
+
import { mentionBox } from './mentions.js';
|
|
13
15
|
import { placeName } from './places.js';
|
|
14
16
|
import { runnerPicker } from './runner-picker.js';
|
|
15
|
-
import { agentById, agentName, effortLabel, effortsFor, harnessesAt, harnessLogo, modeMenu, MODES, runnerTip, savedEffort, savedModel } from './runners.js';
|
|
17
|
+
import { agentById, agentName, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt, harnessLogo, modeMenu, MODES, runnerTip, savedEffort, savedModel } from './runners.js';
|
|
16
18
|
import { feedAnchor, keepInPlace, watching } from './scroll.js';
|
|
17
19
|
import { shipDialog } from './ship.js';
|
|
18
20
|
import { renderBody } from './turns.js';
|
|
@@ -47,7 +49,7 @@ export function renderDock(id) {
|
|
|
47
49
|
dock.append(comp);
|
|
48
50
|
n.tray = attachTray();
|
|
49
51
|
comp.prepend(n.tray.node);
|
|
50
|
-
const ta = $('.reply', comp);
|
|
52
|
+
const ta = mentionBox($('.reply', comp));
|
|
51
53
|
autosize(ta, 260);
|
|
52
54
|
acceptFiles(comp, ta, n.tray);
|
|
53
55
|
$('.c-attach', comp).onclick = n.tray.pick;
|
|
@@ -78,7 +80,7 @@ export function renderDock(id) {
|
|
|
78
80
|
|
|
79
81
|
// The mode the next follow-up runs in: the session's own until the user picks another here.
|
|
80
82
|
// The pick goes out with that follow-up and stays from then on.
|
|
81
|
-
const replyMode = (id) => S.replyModes.get(id) || S.full.get(id)?.mode ||
|
|
83
|
+
const replyMode = (id) => S.replyModes.get(id) || S.full.get(id)?.mode || DEFAULT_MODE.id;
|
|
82
84
|
|
|
83
85
|
function setReplyMode(id, mode) {
|
|
84
86
|
if (mode === S.full.get(id)?.mode) S.replyModes.delete(id);
|
|
@@ -88,10 +90,10 @@ function setReplyMode(id, mode) {
|
|
|
88
90
|
|
|
89
91
|
function paintReplyMode(id, btn) {
|
|
90
92
|
const s = S.full.get(id);
|
|
91
|
-
const m = MODES.find((x) => x.id === replyMode(id)) ||
|
|
93
|
+
const m = MODES.find((x) => x.id === replyMode(id)) || DEFAULT_MODE;
|
|
92
94
|
const from = s && S.replyModes.has(id) && MODES.find((x) => x.id === s.mode);
|
|
93
95
|
btn.className = `mode-btn c-mode m-${m.id}${from ? ' changed' : ''}`;
|
|
94
|
-
btn.innerHTML = `${icon(m.icon)}${m
|
|
96
|
+
btn.innerHTML = `${icon(m.icon)}${m === DEFAULT_MODE && !from ? '' : `<span>${m.label}</span>`}`;
|
|
95
97
|
btn.title = from
|
|
96
98
|
? `${m.label}: ${m.hint}\nYour next follow-up switches this task from ${from.label} to ${m.label}. Shift+Tab to switch`
|
|
97
99
|
: `${m.label}: ${m.hint}\nPick another mode for your next follow-up. Shift+Tab to switch`;
|
|
@@ -157,7 +159,9 @@ export function updateDock(id) {
|
|
|
157
159
|
ta.placeholder = tour
|
|
158
160
|
? 'This is the tour — launch a real wisp from the box at the top'
|
|
159
161
|
: s.ask
|
|
160
|
-
?
|
|
162
|
+
? s.ask.permission
|
|
163
|
+
? `Or tell ${name} what to do instead (denies this call)…`
|
|
164
|
+
: `Answer ${name}…`
|
|
161
165
|
: s.state === 'running'
|
|
162
166
|
? queueing()
|
|
163
167
|
? `Queue a follow-up — sent when ${name} finishes`
|
|
@@ -189,7 +193,7 @@ function renderQueued(s, list, name) {
|
|
|
189
193
|
const li = el(`
|
|
190
194
|
<li class="cq">
|
|
191
195
|
<span class="cq-tag">${i === 0 ? `Next · after ${esc(name)} finishes` : `Queued #${i + 1}`}${q.run ? ` · on ${esc(runnerTip(q.run.agent, q.run.model, ''))}` : ''}</span>
|
|
192
|
-
<span class="cq-text">${esc(q.text)}</span>
|
|
196
|
+
<span class="cq-text">${mentionHtml(esc(q.text), { link: false })}</span>
|
|
193
197
|
<button class="att-x" title="Cancel this follow-up">${icon('x')}</button>
|
|
194
198
|
</li>`);
|
|
195
199
|
const cancel = () => {
|
package/web/js/features/feed.js
CHANGED
|
@@ -13,7 +13,7 @@ import { machinePill } 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 } from './ship.js';
|
|
16
|
+
import { prPill, shipPill } from './ship.js';
|
|
17
17
|
import { teamStack } from './team.js';
|
|
18
18
|
import { renderBody } from './turns.js';
|
|
19
19
|
|
|
@@ -208,7 +208,7 @@ 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.pr ? prPill(s.pr) : ''}
|
|
211
|
+
${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
|
|
212
212
|
${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort, s.route?.switched ? null : s.route)}
|
|
213
213
|
${s.machine ? machinePill(s.machine) : ''}
|
|
214
214
|
<span class="q-proj">${esc(s.project)}</span>
|
|
@@ -233,7 +233,7 @@ function renderRunHead(n, s, { open, live, pill }) {
|
|
|
233
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>` : ''}
|
|
234
234
|
${live && s.state === 'running' ? `<span class="pill st-running"><i></i>Working</span>` : pill}
|
|
235
235
|
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
236
|
-
${s.pr ? prPill(s.pr) : ''}
|
|
236
|
+
${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
|
|
237
237
|
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
238
238
|
${icon('chevron', 'chev')}
|
|
239
239
|
</span>`;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// machines, a project in each), on what, and shows a placeholder card until the server answers.
|
|
3
3
|
|
|
4
4
|
import { esc } from '../md.js';
|
|
5
|
+
import { plain } from '../mention-text.js';
|
|
5
6
|
import { api } from '../core/api.js';
|
|
6
7
|
import * as bus from '../core/bus.js';
|
|
7
8
|
import { $, $$, el, icon } from '../core/dom.js';
|
|
@@ -11,37 +12,57 @@ import { popover } from '../ui/popover.js';
|
|
|
11
12
|
import { toast, toastError } from '../ui/toast.js';
|
|
12
13
|
import { acceptFiles, attachTray, autosize } from './attachments.js';
|
|
13
14
|
import { openAutomations, recipeParams, runRecipe } from './automations.js';
|
|
15
|
+
import { cloneById, clones } from './clones.js';
|
|
14
16
|
import { renderFeed } from './feed.js';
|
|
15
17
|
import { feedbackCommand, isFeedback } from './feedback.js';
|
|
16
18
|
import { connectMachine, offlineToast, openMachines } from './machines.js';
|
|
19
|
+
import { mentionBox } from './mentions.js';
|
|
17
20
|
import { askNotify } from './notify.js';
|
|
18
21
|
import { MACHINE_STATE, machineById, placeName, placeOf, projectById, projectsAt, projectTitle } from './places.js';
|
|
22
|
+
import { projectList } from './project-list.js';
|
|
19
23
|
import { openProjects } from './projects.js';
|
|
20
24
|
import { runnerPicker } from './runner-picker.js';
|
|
21
25
|
import { needsSetup, nudgeSetup, renderSetup } from './setup.js';
|
|
22
|
-
import { agentName, agentsAt, effortLabel, effortsFor, harnessesAt, harnessLogo, MAX_RUNNERS, modeMenu, MODES, paintRunnerBtn, runnerTip, savedEffort, savedModel } from './runners.js';
|
|
26
|
+
import { agentName, agentsAt, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt, harnessLogo, MAX_RUNNERS, modeMenu, MODES, paintRunnerBtn, runnerTip, savedEffort, savedModel } from './runners.js';
|
|
23
27
|
import { feedAnchor, keepInPlace, stage, topInset } from './scroll.js';
|
|
24
28
|
import { canLead, paintTeamBtn, teamEditor } from './team.js';
|
|
25
29
|
|
|
30
|
+
// "No folder": a general ask with no project. The server gives it an empty scratch directory.
|
|
31
|
+
const NO_FOLDER = 'none';
|
|
32
|
+
const GENERAL = { id: null, name: 'General', path: '', color: 'slate', scratch: true };
|
|
33
|
+
const projectOf = (k) => (launch.picks[k] === NO_FOLDER ? GENERAL : projectById(launch.picks[k]));
|
|
34
|
+
// Project chips in the "in" row, at most, before the rest fold into "+N".
|
|
35
|
+
const CHIPS = 5;
|
|
36
|
+
|
|
37
|
+
// Only a change of feed filter moves the launcher, so a "No folder" pick survives re-renders.
|
|
38
|
+
let filterSeen = null;
|
|
39
|
+
|
|
26
40
|
function syncWhere() {
|
|
27
41
|
launch.where = launch.where.filter((k, i) => (!k || machineById(k)) && launch.where.indexOf(k) === i);
|
|
28
42
|
if (!launch.where.length) launch.where = [''];
|
|
29
43
|
// Filtering the feed to a project points the launcher at it (its machine joins if it wasn't picked).
|
|
30
|
-
const f = S.filter !== 'all' && projectById(S.filter);
|
|
44
|
+
const f = S.filter !== 'all' && S.filter !== filterSeen && projectById(S.filter);
|
|
45
|
+
filterSeen = S.filter;
|
|
31
46
|
if (f) {
|
|
32
47
|
if (!launch.where.includes(placeOf(f))) launch.where = [placeOf(f)];
|
|
33
48
|
launch.picks[placeOf(f)] = f.id;
|
|
34
49
|
} else if (projectById(launch.project) && !Object.values(launch.picks).includes(launch.project)) launch.picks[placeOf(projectById(launch.project))] ??= launch.project;
|
|
35
|
-
for (const k of launch.where) if (!projectsAt(k).some((p) => p.id === launch.picks[k])) launch.picks[k] = projectsAt(k)[0]?.id || null;
|
|
50
|
+
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;
|
|
36
51
|
launch.project = launch.picks[launch.where[0]] || null;
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
export function renderLauncher() {
|
|
40
55
|
const box = $('#launcher');
|
|
41
|
-
const mode = S.config.defaults.mode ||
|
|
56
|
+
const mode = S.config.defaults.mode || DEFAULT_MODE.id;
|
|
42
57
|
const here = harnessesAt(launch.where, { auto: true });
|
|
43
58
|
const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'auto'));
|
|
44
59
|
let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
|
|
60
|
+
// Rendered over what's there (index.html's first paint, or fresh settings or harnesses): what
|
|
61
|
+
// was typed, and the caret if you're typing, carry over.
|
|
62
|
+
const old = $('#prompt', box);
|
|
63
|
+
const typing = old && document.activeElement === old && [old.selectionStart, old.selectionEnd];
|
|
64
|
+
if (old) S.drafts.set('home', old.value);
|
|
65
|
+
box.classList.remove('skel');
|
|
45
66
|
box.innerHTML = `
|
|
46
67
|
<div class="l-projects"></div>
|
|
47
68
|
<div class="l-setup" hidden></div>
|
|
@@ -63,9 +84,9 @@ export function renderLauncher() {
|
|
|
63
84
|
renderLaunchChips();
|
|
64
85
|
renderSetup();
|
|
65
86
|
|
|
66
|
-
const ta = $('#prompt', box);
|
|
87
|
+
const ta = mentionBox($('#prompt', box));
|
|
67
88
|
const tray = attachTray();
|
|
68
|
-
ta.before(tray.node);
|
|
89
|
+
ta.parentNode.before(tray.node); // before the mention box's wrapper
|
|
69
90
|
acceptFiles(box, ta, tray);
|
|
70
91
|
autosize(ta);
|
|
71
92
|
ta.value = S.drafts.get('home') || '';
|
|
@@ -77,6 +98,7 @@ export function renderLauncher() {
|
|
|
77
98
|
ta.addEventListener('focus', hint);
|
|
78
99
|
ta.addEventListener('blur', hint);
|
|
79
100
|
hint();
|
|
101
|
+
if (typing) (ta.focus(), ta.setSelectionRange(...typing));
|
|
80
102
|
// Docked and folded to one line, a click anywhere on it opens it up for typing. A property, not
|
|
81
103
|
// a listener: the launcher re-renders in place, and each render would add another.
|
|
82
104
|
box.onmousedown = (e) => {
|
|
@@ -88,14 +110,14 @@ export function renderLauncher() {
|
|
|
88
110
|
$('#whereBtn', box).onclick = (e) => whereMenu(e.currentTarget);
|
|
89
111
|
paintWhereBtn();
|
|
90
112
|
|
|
91
|
-
// Mode is set-and-forget, so it's one small button: just an icon on the default (
|
|
92
|
-
// spelled out when it's Plan or
|
|
113
|
+
// Mode is set-and-forget, so it's one small button: just an icon on the default (Full auto),
|
|
114
|
+
// spelled out when it's Plan or Edits so you can't miss it.
|
|
93
115
|
let curMode = mode;
|
|
94
116
|
const modeBtn = $('#mode', box);
|
|
95
117
|
const paintMode = () => {
|
|
96
|
-
const m = MODES.find((x) => x.id === curMode) ||
|
|
118
|
+
const m = MODES.find((x) => x.id === curMode) || DEFAULT_MODE;
|
|
97
119
|
modeBtn.className = `mode-btn m-${m.id}`;
|
|
98
|
-
modeBtn.innerHTML = `${icon(m.icon)}${m
|
|
120
|
+
modeBtn.innerHTML = `${icon(m.icon)}${m === DEFAULT_MODE ? '' : `<span>${m.label}</span>`}`;
|
|
99
121
|
modeBtn.title = `${m.label}: ${m.hint}\nShift+Tab to switch`;
|
|
100
122
|
};
|
|
101
123
|
const setMode = (id) => {
|
|
@@ -228,8 +250,8 @@ export function renderLauncher() {
|
|
|
228
250
|
return feedbackCommand(prompt, { restore: () => !ta.value && ((ta.value = prompt), ta.dispatchEvent(new Event('input'))) });
|
|
229
251
|
}
|
|
230
252
|
// One run per place picked under "Run on", each in the project picked for it.
|
|
231
|
-
const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project:
|
|
232
|
-
const unset = targets.find((t) => !t.project);
|
|
253
|
+
const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project: projectOf(k), clone: cloneById(launch.picks[k]) }));
|
|
254
|
+
const unset = targets.find((t) => !t.project && !t.clone);
|
|
233
255
|
if (unset) return openProjects({ focusAdd: true, machine: unset.k || null });
|
|
234
256
|
const off = targets.find((t) => t.m && t.m.state !== 'online');
|
|
235
257
|
if (off) return offlineToast(off.k);
|
|
@@ -248,12 +270,13 @@ export function renderLauncher() {
|
|
|
248
270
|
const files = tray.take();
|
|
249
271
|
const cmp = compare;
|
|
250
272
|
const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
|
|
251
|
-
|
|
273
|
+
// A repo still cloning gets a stand-in project; its card says so until the clone lands.
|
|
274
|
+
const pending = targets.map((t) => addPending(prompt, t.project ? t : { ...t, project: { id: t.clone.id, name: t.clone.name, path: '', color: 'slate' } }, runner, t.clone && `Cloning ${t.clone.label}`));
|
|
252
275
|
if (cmp.length) (compare = []), paintCompare();
|
|
253
276
|
ta.value = '';
|
|
254
277
|
S.drafts.delete('home');
|
|
255
278
|
ta.dispatchEvent(new Event('input'));
|
|
256
|
-
if (S.filter !== 'all' && targets.some((t) => t.project.id !== S.filter)) {
|
|
279
|
+
if (S.filter !== 'all' && targets.some((t) => t.project?.scratch || t.project?.id !== S.filter)) {
|
|
257
280
|
S.filter = 'all';
|
|
258
281
|
save('wisp.filter', S.filter);
|
|
259
282
|
}
|
|
@@ -262,13 +285,20 @@ export function renderLauncher() {
|
|
|
262
285
|
showLaunched(btn, ghost);
|
|
263
286
|
ta.focus();
|
|
264
287
|
try {
|
|
288
|
+
for (const [i, t] of targets.entries()) {
|
|
289
|
+
if (!t.clone) continue;
|
|
290
|
+
const p = (t.project = await t.clone.promise);
|
|
291
|
+
// Now a real project, so the live session can replace the card (dropPending).
|
|
292
|
+
const card = S.sessions.get(pending[i]);
|
|
293
|
+
if (card) Object.assign(card, { projectId: p.id, project: p.name, cwd: p.path, color: p.color, live: 'Starting up' }), renderFeed();
|
|
294
|
+
}
|
|
265
295
|
// Several places: one shared groupId, so the runs land as one task with a row each.
|
|
266
296
|
const groupId = targets.length > 1 ? [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(16).padStart(2, '0')).join('') : undefined;
|
|
267
297
|
const results = await Promise.allSettled(
|
|
268
298
|
targets.map((t) =>
|
|
269
299
|
api('/sessions', {
|
|
270
300
|
method: 'POST',
|
|
271
|
-
body: { prompt, cwd: t.project.path, 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 },
|
|
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 },
|
|
272
302
|
}),
|
|
273
303
|
),
|
|
274
304
|
);
|
|
@@ -289,7 +319,10 @@ export function renderLauncher() {
|
|
|
289
319
|
} catch (err) {
|
|
290
320
|
for (const id of pending) S.sessions.delete(id);
|
|
291
321
|
renderFeed();
|
|
292
|
-
toastError(err);
|
|
322
|
+
if (!err.shown) return toastError(err);
|
|
323
|
+
// The clone failed (its toast says why): the task goes back in the box.
|
|
324
|
+
if (!ta.value) ((ta.value = prompt), ta.dispatchEvent(new Event('input')));
|
|
325
|
+
tray.restore(files);
|
|
293
326
|
}
|
|
294
327
|
};
|
|
295
328
|
$('#launch', box).onclick = go;
|
|
@@ -319,6 +352,12 @@ export function renderLauncher() {
|
|
|
319
352
|
});
|
|
320
353
|
}
|
|
321
354
|
|
|
355
|
+
// No folder everywhere picked: no git repo, so no worktree either; the toggle would only mislead.
|
|
356
|
+
function paintScratch() {
|
|
357
|
+
const wt = $('#launcher #wtBtn');
|
|
358
|
+
if (wt) wt.hidden = launch.where.every((k) => launch.picks[k] === NO_FOLDER);
|
|
359
|
+
}
|
|
360
|
+
|
|
322
361
|
export function renderLaunchChips() {
|
|
323
362
|
const box = $('#launcher .l-projects');
|
|
324
363
|
if (!box) return;
|
|
@@ -330,23 +369,70 @@ export function renderLaunchChips() {
|
|
|
330
369
|
const list = projectsAt(k);
|
|
331
370
|
const off = m && m.state !== 'online';
|
|
332
371
|
const label = k || multi ? `<span class="lp-gl ${off ? 'off' : ''}" title="${esc(k ? `${m?.target} · ${MACHINE_STATE[m?.state] || ''}` : 'This computer')}">${icon(k ? 'server' : 'laptop')}${esc(placeName(k))}${off ? `<button class="link xs" data-connect="${k}">${m.state === 'connecting' || m.state === 'reconnecting' ? 'connecting…' : 'Connect'}</button>` : ''}</span>` : '';
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
372
|
+
// A repo still cloning: picked, with a spinner, until the real project replaces it.
|
|
373
|
+
const cloning = clones
|
|
374
|
+
.filter((c) => (c.where || '') === k)
|
|
375
|
+
.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
|
+
.join('');
|
|
377
|
+
// Tens of projects: the most recent few as chips (the pick always among them), the rest one
|
|
378
|
+
// search away behind "+N". fitChips() then folds more into it if the row would wrap.
|
|
379
|
+
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('');
|
|
387
|
+
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
|
+
const add = list.length || cloning
|
|
389
|
+
? `<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>`
|
|
390
|
+
: `<button class="lp-chip add" data-add="${k}">${icon('folder')}${k ? `Pick a project on ${esc(placeName(k))}…` : 'Choose a folder…'}</button>`;
|
|
391
|
+
const none = `<button class="lp-chip none ${launch.picks[k] === NO_FOLDER ? 'on' : ''}" data-id="${NO_FOLDER}" data-k="${k}" title="No folder: a general ask, with no project and no worktree. The agent gets an empty scratch directory.">${icon('spark')}<span>No folder</span></button>`;
|
|
392
|
+
return `<span class="lp-group">${label}${chips}${more}${none}${add}</span>`;
|
|
338
393
|
};
|
|
339
394
|
box.innerHTML = `<span class="l-in">in</span>${launch.where.map(group).join('')}`;
|
|
395
|
+
fitChips(box);
|
|
396
|
+
fitRow ??= new ResizeObserver(([e]) => {
|
|
397
|
+
if (e.contentRect.width === fitRow.w) return; // its own folding only changes the height
|
|
398
|
+
fitRow.w = e.contentRect.width;
|
|
399
|
+
fitChips(e.target);
|
|
400
|
+
});
|
|
401
|
+
fitRow.observe(box);
|
|
402
|
+
paintScratch();
|
|
403
|
+
const pick = (k, id) => {
|
|
404
|
+
launch.picks[k] = id;
|
|
405
|
+
if (k === launch.where[0]) launch.project = id;
|
|
406
|
+
saveLaunch();
|
|
407
|
+
paintScratch();
|
|
408
|
+
renderLaunchRecipes();
|
|
409
|
+
};
|
|
340
410
|
box.onclick = (e) => {
|
|
341
411
|
const chip = e.target.closest('.lp-chip[data-id]');
|
|
342
412
|
if (chip) {
|
|
343
|
-
|
|
344
|
-
if (chip.dataset.k === launch.where[0]) launch.project = chip.dataset.id;
|
|
345
|
-
saveLaunch();
|
|
413
|
+
pick(chip.dataset.k, chip.dataset.id);
|
|
346
414
|
$$(`.lp-chip[data-k="${chip.dataset.k}"]`, box).forEach((x) => x.classList.toggle('on', x === chip));
|
|
347
|
-
renderLaunchRecipes();
|
|
348
415
|
return;
|
|
349
416
|
}
|
|
417
|
+
const more = e.target.closest('[data-more]');
|
|
418
|
+
if (more) {
|
|
419
|
+
const k = more.dataset.more;
|
|
420
|
+
return projectList(more, {
|
|
421
|
+
projects: projectsAt(k),
|
|
422
|
+
current: launch.picks[k],
|
|
423
|
+
label: `Projects on ${placeName(k)}`,
|
|
424
|
+
extras: [
|
|
425
|
+
{ id: NO_FOLDER, label: 'No folder', icon: 'spark' },
|
|
426
|
+
{ id: '+add', label: k ? `Another folder on ${placeName(k)}…` : 'Other folder…', icon: 'folder' },
|
|
427
|
+
],
|
|
428
|
+
onPick: (id) => {
|
|
429
|
+
if (id === '+add') return openProjects({ focusAdd: true, machine: k || null });
|
|
430
|
+
pick(k, id);
|
|
431
|
+
renderLaunchChips(); // the pick joins the chips
|
|
432
|
+
$('#prompt')?.focus();
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
}
|
|
350
436
|
const add = e.target.closest('[data-add]');
|
|
351
437
|
if (add) openProjects({ focusAdd: true, machine: add.dataset.add || null });
|
|
352
438
|
const conn = e.target.closest('[data-connect]');
|
|
@@ -356,6 +442,41 @@ export function renderLaunchChips() {
|
|
|
356
442
|
paintWhereBtn();
|
|
357
443
|
}
|
|
358
444
|
|
|
445
|
+
// One row, however many projects: on a single place, the least recent chips (never the pick, and
|
|
446
|
+
// at least one) fold into "+N" until the row stops wrapping. "+N" counts what isn't a chip.
|
|
447
|
+
let fitRow;
|
|
448
|
+
function fitChips(box) {
|
|
449
|
+
for (const g of $$('.lp-group', box)) {
|
|
450
|
+
const chips = $$('.lp-chip[data-id]:not(.none):not(.cloning)', g);
|
|
451
|
+
const more = $('.lp-chip.more', g);
|
|
452
|
+
chips.forEach((c) => (c.hidden = false));
|
|
453
|
+
const paint = () => {
|
|
454
|
+
const n = Number(more?.dataset.total) - chips.filter((c) => !c.hidden).length;
|
|
455
|
+
if (!more) return;
|
|
456
|
+
more.hidden = !n;
|
|
457
|
+
$('.lp-more-n', more).textContent = `+${n}`;
|
|
458
|
+
};
|
|
459
|
+
paint();
|
|
460
|
+
if (launch.where.length > 1) continue;
|
|
461
|
+
const wraps = () => {
|
|
462
|
+
const kids = [...g.children].filter((x) => !x.hidden && x.offsetParent);
|
|
463
|
+
const lin = $('.l-in', box); // "in" left alone on a line counts too
|
|
464
|
+
return kids.some((x) => x.offsetTop >= kids[0].offsetTop + kids[0].offsetHeight) || (lin && kids[0].offsetTop >= lin.offsetTop + lin.offsetHeight);
|
|
465
|
+
};
|
|
466
|
+
for (const c of chips.filter((x) => !x.classList.contains('on')).reverse()) {
|
|
467
|
+
if (!wraps() || chips.filter((x) => !x.hidden).length <= 1) break;
|
|
468
|
+
c.hidden = true;
|
|
469
|
+
paint();
|
|
470
|
+
}
|
|
471
|
+
// "+1" is no shorter than the one chip it would stand for: keep the chip, let the row wrap.
|
|
472
|
+
const folded = chips.filter((c) => c.hidden);
|
|
473
|
+
if (folded.length && Number(more?.dataset.total) - chips.length + folded.length === 1) {
|
|
474
|
+
folded.forEach((c) => (c.hidden = false));
|
|
475
|
+
paint();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
359
480
|
// "Run on": this computer and/or machines. The button says where; its menu picks one or several.
|
|
360
481
|
function paintWhereBtn() {
|
|
361
482
|
const b = $('#whereBtn');
|
|
@@ -484,9 +605,9 @@ function launchedFx(from, to) {
|
|
|
484
605
|
// A launch the server hasn't answered yet: a placeholder summary that shows in Working as a
|
|
485
606
|
// "Starting up" card. It can't be opened; the real card takes its place.
|
|
486
607
|
let pendingSeq = 0;
|
|
487
|
-
function addPending(prompt, { project: p, m }, { agent, model, effort }) {
|
|
608
|
+
function addPending(prompt, { project: p, m }, { agent, model, effort }, live = 'Starting up') {
|
|
488
609
|
const id = `pending-${++pendingSeq}`;
|
|
489
|
-
const line = prompt.split('\n')[0].replace(/\s+/g, ' ');
|
|
610
|
+
const line = plain(prompt).split('\n')[0].replace(/\s+/g, ' ');
|
|
490
611
|
const now = Date.now();
|
|
491
612
|
S.sessions.set(id, {
|
|
492
613
|
id,
|
|
@@ -496,11 +617,12 @@ function addPending(prompt, { project: p, m }, { agent, model, effort }) {
|
|
|
496
617
|
model,
|
|
497
618
|
effort,
|
|
498
619
|
cwd: p.path,
|
|
620
|
+
scratch: p.scratch,
|
|
499
621
|
project: p.name,
|
|
500
622
|
projectId: p.id,
|
|
501
623
|
color: p.color || 'slate',
|
|
502
624
|
state: 'running',
|
|
503
|
-
live
|
|
625
|
+
live,
|
|
504
626
|
createdAt: now,
|
|
505
627
|
updatedAt: now,
|
|
506
628
|
machine: m ? { id: m.id, name: m.name, target: m.target } : null,
|
package/web/js/features/live.js
CHANGED
|
@@ -4,7 +4,7 @@ import { esc } from '../md.js';
|
|
|
4
4
|
import { api } from '../core/api.js';
|
|
5
5
|
import * as bus from '../core/bus.js';
|
|
6
6
|
import { $ } from '../core/dom.js';
|
|
7
|
-
import { S, saveOpen } from '../core/state.js';
|
|
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
10
|
import { collapse, renderFeed, scheduleRefetch, sectionOf } from './feed.js';
|
|
@@ -41,7 +41,7 @@ export function connect() {
|
|
|
41
41
|
renderFeed();
|
|
42
42
|
});
|
|
43
43
|
if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
|
|
44
|
-
if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state]) !== JSON.stringify([s.pr, s.toShip, s.state])) renderPrStrip(s.id);
|
|
44
|
+
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
45
|
});
|
|
46
46
|
es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
|
|
47
47
|
es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
|
|
@@ -66,7 +66,7 @@ export function connect() {
|
|
|
66
66
|
});
|
|
67
67
|
// A harness was installed from wisp (maybe in another tab): fetch the new list.
|
|
68
68
|
es.addEventListener('agents', async () => {
|
|
69
|
-
|
|
69
|
+
setAgents(await api('/agents').catch(() => S.agents));
|
|
70
70
|
$('#launcher')?.placesChanged?.();
|
|
71
71
|
bus.emit('agents');
|
|
72
72
|
});
|