wisp-agents 0.7.0 → 0.9.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 +57 -1
- package/README.md +99 -8
- package/bin/wisp.js +53 -6
- package/package.json +2 -2
- package/server/adapters/claude.js +7 -1
- package/server/adapters/codex.js +1 -0
- package/server/adapters/common.js +1 -1
- package/server/adapters/grok.js +251 -0
- package/server/adapters/index.js +4 -2
- package/server/adapters/opencode.js +7 -1
- package/server/checkpoint.js +348 -0
- package/server/client.js +26 -2
- package/server/git.js +199 -0
- package/server/index.js +559 -20
- package/server/machines.js +672 -0
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +9 -3
- package/server/openapi.js +164 -5
- package/server/protocol.js +11 -1
- package/server/recipes.js +64 -0
- package/server/remote.js +283 -0
- package/server/render.js +18 -0
- package/server/runner.js +154 -13
- package/server/store.js +20 -7
- package/server/subagents.js +86 -0
- package/server/triggers.js +268 -0
- package/server/usage.js +79 -0
- package/server/welcome.js +9 -1
- package/skills/wisp/SKILL.md +5 -4
- package/web/css/app.css +702 -34
- package/web/index.html +15 -3
- package/web/js/app.js +2298 -134
- package/web/js/blocks.js +396 -22
- package/web/logos/grok.svg +1 -0
package/web/js/app.js
CHANGED
|
@@ -12,9 +12,11 @@ const S = {
|
|
|
12
12
|
full: new Map(), // id -> full session, for expanded items
|
|
13
13
|
open: new Set(load('wisp.open', [])), // expanded item ids
|
|
14
14
|
nodes: new Map(), // id -> { card, head, body, turnNodes, tray, showAll, unfolded, latestTurn }
|
|
15
|
+
tasks: new Map(), // compare task key -> { card, head, runs, lead }
|
|
15
16
|
focus: null, // id of the keyboard-focused item
|
|
16
17
|
config: { defaults: {}, projects: [] },
|
|
17
18
|
agents: [],
|
|
19
|
+
machines: [], // other computers over SSH: GET /api/machines, kept fresh by the 'machines' event
|
|
18
20
|
filter: load('wisp.filter', 'all'),
|
|
19
21
|
showDone: load('wisp.showDone', false),
|
|
20
22
|
showArchived: false,
|
|
@@ -24,6 +26,9 @@ const S = {
|
|
|
24
26
|
const $ = (s, root = document) => root.querySelector(s);
|
|
25
27
|
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
|
|
26
28
|
const stage = $('#stage');
|
|
29
|
+
// How much of the feed the sticky top bar and docked launcher cover: the stage's scroll-padding-top,
|
|
30
|
+
// which scrollIntoView already honours, so hand-placed cards line up with scrolled-to ones.
|
|
31
|
+
const topInset = () => parseFloat(getComputedStyle(stage).scrollPaddingTop) || 0;
|
|
27
32
|
|
|
28
33
|
function load(k, d) {
|
|
29
34
|
try {
|
|
@@ -68,6 +73,7 @@ const HARNESS_LOGO = {
|
|
|
68
73
|
opencode: { file: 'opencode', mono: true },
|
|
69
74
|
cursor: { file: 'cursor', mono: true },
|
|
70
75
|
antigravity: { file: 'antigravity-color' },
|
|
76
|
+
grok: { file: 'grok', mono: true },
|
|
71
77
|
};
|
|
72
78
|
function harnessLogo(agent) {
|
|
73
79
|
const l = HARNESS_LOGO[agent];
|
|
@@ -121,11 +127,23 @@ function overlay(node, { onClose } = {}) {
|
|
|
121
127
|
setTimeout(() => o.firstChild === node && ((o.hidden = true), (o.innerHTML = '')), 200);
|
|
122
128
|
onClose?.();
|
|
123
129
|
};
|
|
124
|
-
|
|
130
|
+
// A block body: an on* handler that returns false cancels the click, which would stop every
|
|
131
|
+
// submit button inside the dialog from submitting its form.
|
|
132
|
+
o.onclick = (e) => {
|
|
133
|
+
if (e.target === o) close();
|
|
134
|
+
};
|
|
125
135
|
o.close = close;
|
|
126
136
|
return close;
|
|
127
137
|
}
|
|
128
138
|
const closeOverlay = () => !$('#overlay').hidden && $('#overlay').close?.();
|
|
139
|
+
// A view opens as its own dialog, or inside another one (Settings) that hands it a host.
|
|
140
|
+
// Returns what closes it for good: the whole overlay either way.
|
|
141
|
+
function present(node, host) {
|
|
142
|
+
if (host) return host(node);
|
|
143
|
+
const close = overlay(node);
|
|
144
|
+
$('.close', node).onclick = close;
|
|
145
|
+
return close;
|
|
146
|
+
}
|
|
129
147
|
|
|
130
148
|
// --- feed ----------------------------------------------------------------------
|
|
131
149
|
|
|
@@ -133,8 +151,39 @@ function visible(list) {
|
|
|
133
151
|
return S.filter === 'all' ? list : list.filter((s) => s.projectId === S.filter);
|
|
134
152
|
}
|
|
135
153
|
|
|
154
|
+
// Compare runs (one launch on several harnesses, sharing a groupId) are one task in the feed
|
|
155
|
+
// while two or more of them are on the same side of archived: a card with a row per run.
|
|
156
|
+
// Keeping one archives the rest, and what's left is an ordinary card again.
|
|
157
|
+
function runsOf(s) {
|
|
158
|
+
if (!s.groupId) return [s];
|
|
159
|
+
const runs = [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived === !s.archived);
|
|
160
|
+
return runs.length > 1 ? runs.sort((a, b) => a.createdAt - b.createdAt) : [s];
|
|
161
|
+
}
|
|
162
|
+
// The task as a whole: asking if any run asks, working while any works, done once all are.
|
|
163
|
+
function taskOf(s) {
|
|
164
|
+
const runs = runsOf(s);
|
|
165
|
+
if (runs.length < 2) return s;
|
|
166
|
+
const state = runs.some((r) => r.state === 'waiting') ? 'waiting' : runs.some((r) => r.state === 'running') ? 'running' : runs.every((r) => r.state === 'done') ? 'done' : 'review';
|
|
167
|
+
return {
|
|
168
|
+
...runs[0],
|
|
169
|
+
key: `g:${s.groupId}${s.archived ? ':a' : ''}`,
|
|
170
|
+
runs,
|
|
171
|
+
state,
|
|
172
|
+
unread: runs.some((r) => r.unread),
|
|
173
|
+
createdAt: Math.min(...runs.map((r) => r.createdAt)),
|
|
174
|
+
updatedAt: Math.max(...runs.map((r) => r.updatedAt)),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
136
178
|
function groups() {
|
|
137
|
-
const every =
|
|
179
|
+
const every = [];
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
for (const s of visible([...S.sessions.values()])) {
|
|
182
|
+
const t = taskOf(s);
|
|
183
|
+
if (t.runs && seen.has(t.key)) continue;
|
|
184
|
+
if (t.runs) seen.add(t.key);
|
|
185
|
+
every.push(t);
|
|
186
|
+
}
|
|
138
187
|
const all = every.filter((s) => !s.archived);
|
|
139
188
|
const needs = all
|
|
140
189
|
.filter((s) => s.state === 'review' || s.state === 'waiting')
|
|
@@ -154,7 +203,7 @@ function renderFeed() {
|
|
|
154
203
|
$('h4 .n', sec).textContent = list.length || '';
|
|
155
204
|
const want = show ? list : [];
|
|
156
205
|
want.forEach((s, i) => {
|
|
157
|
-
const { card } = item(s);
|
|
206
|
+
const { card } = s.runs ? taskItem(s) : item(s);
|
|
158
207
|
// Only touch the DOM when the order actually changed: moving a node
|
|
159
208
|
// would blur a composer the user is typing in.
|
|
160
209
|
if (box.children[i] !== card) box.insertBefore(card, box.children[i] || null);
|
|
@@ -169,8 +218,11 @@ function renderFeed() {
|
|
|
169
218
|
$('#secArchived').classList.toggle('collapsed', !S.showArchived);
|
|
170
219
|
|
|
171
220
|
// Drop nodes for sessions that are gone or filtered out of the feed entirely.
|
|
172
|
-
const
|
|
221
|
+
const tasks = [...needs, ...working, ...done, ...archived];
|
|
222
|
+
const shown = new Set(tasks.flatMap((s) => (s.runs || [s]).map((r) => r.id)));
|
|
173
223
|
for (const [id, n] of S.nodes) if (!shown.has(id)) (n.card.remove(), S.nodes.delete(id), S.full.delete(id));
|
|
224
|
+
const keys = new Set(tasks.map((t) => t.key));
|
|
225
|
+
for (const [key, g] of S.tasks) if (!keys.has(key)) (g.card.remove(), S.tasks.delete(key));
|
|
174
226
|
|
|
175
227
|
const empty = !needs.length && !working.length && !done.length && !archived.length;
|
|
176
228
|
$('#feedEmpty').hidden = !empty;
|
|
@@ -183,6 +235,13 @@ function renderFeed() {
|
|
|
183
235
|
renderProjects();
|
|
184
236
|
}
|
|
185
237
|
|
|
238
|
+
// A new project filter swaps the whole list; staying at the old offset would drop the user
|
|
239
|
+
// somewhere arbitrary in it, so bring the top of the feed back into view.
|
|
240
|
+
function feedToTop() {
|
|
241
|
+
const top = $('#feed').getBoundingClientRect().top;
|
|
242
|
+
if (top < topInset()) scrollByPx(top - topInset());
|
|
243
|
+
}
|
|
244
|
+
|
|
186
245
|
function renderFeedHead(needs, working) {
|
|
187
246
|
const parts = [];
|
|
188
247
|
if (working.length) parts.push(`<span class="hs working"><i></i>${working.length} working</span>`);
|
|
@@ -262,6 +321,7 @@ function renderHead(n, s) {
|
|
|
262
321
|
: s.state === 'review'
|
|
263
322
|
? `<span class="pill st-${s.status}"><i></i>${STATUS_LABEL[s.status] || 'Done'}</span>`
|
|
264
323
|
: '';
|
|
324
|
+
if (runsOf(s).length > 1) return renderRunHead(n, s, { open, live, pill });
|
|
265
325
|
n.card.className = `item c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
266
326
|
n.head.innerHTML = `
|
|
267
327
|
<span class="q-dot ${s.state === 'running' ? 'live' : ''} ${s.state === 'waiting' ? 'waiting' : ''}"></span>
|
|
@@ -272,7 +332,9 @@ function renderHead(n, s) {
|
|
|
272
332
|
<span class="it-side">
|
|
273
333
|
${pill}
|
|
274
334
|
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
275
|
-
${
|
|
335
|
+
${s.pr ? prPill(s.pr) : ''}
|
|
336
|
+
${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort)}
|
|
337
|
+
${s.machine ? machinePill(s.machine) : ''}
|
|
276
338
|
<span class="q-proj">${esc(s.project)}</span>
|
|
277
339
|
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
278
340
|
${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
|
|
@@ -280,13 +342,76 @@ function renderHead(n, s) {
|
|
|
280
342
|
</span>`;
|
|
281
343
|
}
|
|
282
344
|
|
|
345
|
+
// One run inside a compare task: the harness and model stand in for the title the task shows.
|
|
346
|
+
function renderRunHead(n, s, { open, live, pill }) {
|
|
347
|
+
const a = S.agents.find((x) => x.id === s.agent);
|
|
348
|
+
const model = (s.model && a?.models?.find((m) => m.id === s.model)?.label) || s.model || 'Default model';
|
|
349
|
+
n.card.className = `item run c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
350
|
+
n.head.innerHTML = `
|
|
351
|
+
<span class="run-logo">${harnessLogo(s.agent)}</span>
|
|
352
|
+
<span class="it-main">
|
|
353
|
+
<span class="it-title"><b>${esc(a?.name || agentName(s.agent))}</b><span class="run-model">${esc(model)}${s.effort ? ` · ${esc(effortLabel(s.effort))}` : ''}</span></span>
|
|
354
|
+
${live ? `<span class="q-live ${s.state}">${esc(s.live)}</span>` : open ? '' : `<span class="it-sub">${esc(s.preview || '')}</span>`}
|
|
355
|
+
</span>
|
|
356
|
+
<span class="it-side">
|
|
357
|
+
${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>` : ''}
|
|
358
|
+
${live && s.state === 'running' ? `<span class="pill st-running"><i></i>Working</span>` : pill}
|
|
359
|
+
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
360
|
+
${s.pr ? prPill(s.pr) : ''}
|
|
361
|
+
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
362
|
+
${icon('chevron', 'chev')}
|
|
363
|
+
</span>`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// The card for a compare task: the shared prompt on top, one row per run (each an ordinary,
|
|
367
|
+
// expandable item), and the side-by-side view one click away.
|
|
368
|
+
function taskItem(t) {
|
|
369
|
+
let g = S.tasks.get(t.key);
|
|
370
|
+
if (!g) {
|
|
371
|
+
const card = el(`<section class="task-item"><header class="task-head"></header><div class="task-runs"></div></section>`);
|
|
372
|
+
g = { card, head: $('.task-head', card), runs: $('.task-runs', card) };
|
|
373
|
+
S.tasks.set(t.key, g);
|
|
374
|
+
g.head.addEventListener('click', (e) => {
|
|
375
|
+
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
376
|
+
const ids = (taskOf(S.sessions.get(g.lead) || {}).runs || []).map((r) => r.id);
|
|
377
|
+
if (a === 'compare') return compareView(g.lead);
|
|
378
|
+
if (a === 'quick-done') return taskDone(ids.filter((id) => S.sessions.get(id)?.state === 'review'));
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
g.lead = t.id;
|
|
382
|
+
const finished = t.runs.filter((r) => r.state === 'review' || r.state === 'done').length;
|
|
383
|
+
const places = new Set(t.runs.map((r) => r.machine?.id || '')).size;
|
|
384
|
+
const status = t.state === 'waiting' ? 'A run is asking you' : t.state === 'running' ? `${finished} of ${t.runs.length} finished${places > 1 ? ` · on ${places} machines` : ''}` : t.state === 'done' ? 'Done' : places > 1 ? `All finished on ${places} machines` : 'All finished · pick one to keep';
|
|
385
|
+
g.card.className = `task-item c-${t.color} st-${t.state} ${t.unread ? 'unread' : ''}`;
|
|
386
|
+
g.head.innerHTML = `
|
|
387
|
+
<span class="q-dot ${t.state === 'running' ? 'live' : ''} ${t.state === 'waiting' ? 'waiting' : ''}"></span>
|
|
388
|
+
<span class="it-main">
|
|
389
|
+
<span class="it-title">${esc(t.title)}</span>
|
|
390
|
+
<span class="it-sub">${esc(status)}</span>
|
|
391
|
+
</span>
|
|
392
|
+
<span class="it-side">
|
|
393
|
+
<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>
|
|
394
|
+
${places > 1 ? '' : `<span class="q-proj">${esc(t.project)}</span>`}
|
|
395
|
+
<span class="q-time">${ago(t.updatedAt)}</span>
|
|
396
|
+
${t.state === 'review' && !t.archived ? `<button class="it-quick" data-a="quick-done" title="Mark every run done">${icon('done')}</button>` : ''}
|
|
397
|
+
</span>`;
|
|
398
|
+
t.runs.forEach((r, i) => {
|
|
399
|
+
const { card } = item(r);
|
|
400
|
+
if (g.runs.children[i] !== card) g.runs.insertBefore(card, g.runs.children[i] || null);
|
|
401
|
+
});
|
|
402
|
+
while (g.runs.children.length > t.runs.length) g.runs.lastElementChild.remove();
|
|
403
|
+
return g;
|
|
404
|
+
}
|
|
405
|
+
|
|
283
406
|
function setFocus(id, { scroll = true } = {}) {
|
|
284
407
|
if (S.focus === id) return;
|
|
285
408
|
S.nodes.get(S.focus)?.card.classList.remove('focused');
|
|
286
409
|
S.focus = id;
|
|
287
410
|
const n = S.nodes.get(id);
|
|
288
411
|
n?.card.classList.add('focused');
|
|
289
|
-
|
|
412
|
+
// A card taller than the screen would land with its header out of view ('nearest' aligns
|
|
413
|
+
// its far edge when moving up), so bring its top in instead.
|
|
414
|
+
if (scroll && n) n.card.scrollIntoView({ block: n.card.offsetHeight > stage.clientHeight - topInset() ? 'start' : 'nearest', behavior: 'smooth' });
|
|
290
415
|
}
|
|
291
416
|
|
|
292
417
|
function toggle(id) {
|
|
@@ -454,7 +579,28 @@ function autosize(ta, max = 340) {
|
|
|
454
579
|
|
|
455
580
|
// --- launcher ----------------------------------------------------------------------
|
|
456
581
|
|
|
457
|
-
let launchProject = load('wisp.launchProject', null);
|
|
582
|
+
let launchProject = load('wisp.launchProject', null); // the project picked in the first place below
|
|
583
|
+
// Where the launcher sends a prompt: '' = this computer, else machine ids. Several run in parallel,
|
|
584
|
+
// each in the project picked for it (launchPicks[place]), and show up as one task.
|
|
585
|
+
let launchWhere = load('wisp.launchWhere', ['']);
|
|
586
|
+
let launchPicks = load('wisp.launchPicks', {});
|
|
587
|
+
const placeOf = (p) => p?.machine || '';
|
|
588
|
+
const placeName = (k) => (k ? machineById(k)?.name || 'machine' : 'This computer');
|
|
589
|
+
const recentFirst = (a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt);
|
|
590
|
+
const projectsAt = (k) => S.config.projects.filter((p) => placeOf(p) === k).sort(recentFirst);
|
|
591
|
+
const saveWhere = () => (save('wisp.launchWhere', launchWhere), save('wisp.launchPicks', launchPicks), save('wisp.launchProject', launchProject));
|
|
592
|
+
function syncWhere() {
|
|
593
|
+
launchWhere = launchWhere.filter((k, i) => (!k || machineById(k)) && launchWhere.indexOf(k) === i);
|
|
594
|
+
if (!launchWhere.length) launchWhere = [''];
|
|
595
|
+
// Filtering the feed to a project points the launcher at it (its machine joins if it wasn't picked).
|
|
596
|
+
const f = S.filter !== 'all' && projectById(S.filter);
|
|
597
|
+
if (f) {
|
|
598
|
+
if (!launchWhere.includes(placeOf(f))) launchWhere = [placeOf(f)];
|
|
599
|
+
launchPicks[placeOf(f)] = f.id;
|
|
600
|
+
} else if (projectById(launchProject) && !Object.values(launchPicks).includes(launchProject)) launchPicks[placeOf(projectById(launchProject))] ??= launchProject;
|
|
601
|
+
for (const k of launchWhere) if (!projectsAt(k).some((p) => p.id === launchPicks[k])) launchPicks[k] = projectsAt(k)[0]?.id || null;
|
|
602
|
+
launchProject = launchPicks[launchWhere[0]] || null;
|
|
603
|
+
}
|
|
458
604
|
|
|
459
605
|
// The model is remembered per harness (ids aren't portable between them).
|
|
460
606
|
function savedModel(id) {
|
|
@@ -482,10 +628,18 @@ function renderLauncher() {
|
|
|
482
628
|
<div class="l-bar">
|
|
483
629
|
<button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
|
|
484
630
|
<button class="mode-btn" id="mode" aria-haspopup="menu"></button>
|
|
485
|
-
<
|
|
631
|
+
<div class="runners">
|
|
632
|
+
<button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
|
|
633
|
+
<button class="vs-btn" id="compareBtn" aria-pressed="false"><span>vs</span>${icon('x')}</button>
|
|
634
|
+
<button class="runner-btn" id="runner2" title="Second harness to compare with" aria-haspopup="dialog" hidden></button>
|
|
635
|
+
</div>
|
|
636
|
+
<button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
|
|
637
|
+
<button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
|
|
486
638
|
<span class="grow"></span>
|
|
639
|
+
<button class="tog-btn" id="wtBtn"></button>
|
|
487
640
|
<button class="send" id="launch" title="Launch (Enter · Shift+Enter for newline)">${icon('enter')}</button>
|
|
488
|
-
</div
|
|
641
|
+
</div>
|
|
642
|
+
<div class="l-recipes"></div>`;
|
|
489
643
|
renderLaunchChips();
|
|
490
644
|
|
|
491
645
|
const ta = $('#prompt', box);
|
|
@@ -494,7 +648,7 @@ function renderLauncher() {
|
|
|
494
648
|
acceptFiles(box, ta, tray);
|
|
495
649
|
autosize(ta);
|
|
496
650
|
ta.value = S.drafts.get('home') || '';
|
|
497
|
-
ta.addEventListener('input', () => S.drafts.set('home', ta.value));
|
|
651
|
+
ta.addEventListener('input', () => (S.drafts.set('home', ta.value), paintSaveRecipe()));
|
|
498
652
|
// While focus is elsewhere, the placeholder advertises the shortcut that brings you back.
|
|
499
653
|
const hint = () => (ta.placeholder = document.activeElement === ta
|
|
500
654
|
? 'What should I go do? Paste screenshots, drop logs or videos…'
|
|
@@ -502,7 +656,15 @@ function renderLauncher() {
|
|
|
502
656
|
ta.addEventListener('focus', hint);
|
|
503
657
|
ta.addEventListener('blur', hint);
|
|
504
658
|
hint();
|
|
659
|
+
// Docked and folded to one line, a click anywhere on it opens it up for typing.
|
|
660
|
+
box.addEventListener('mousedown', (e) => {
|
|
661
|
+
if (!box.matches('.docked:not(:focus-within)')) return;
|
|
662
|
+
e.preventDefault();
|
|
663
|
+
ta.focus();
|
|
664
|
+
});
|
|
505
665
|
$('#attach', box).onclick = tray.pick;
|
|
666
|
+
$('#whereBtn', box).onclick = (e) => whereMenu(e.currentTarget);
|
|
667
|
+
paintWhereBtn();
|
|
506
668
|
|
|
507
669
|
// Mode is set-and-forget, so it's one small button: just an icon on the default (Edits),
|
|
508
670
|
// spelled out when it's Plan or Full auto so you can't miss it.
|
|
@@ -522,21 +684,82 @@ function renderLauncher() {
|
|
|
522
684
|
};
|
|
523
685
|
paintMode();
|
|
524
686
|
modeBtn.onclick = () => modeMenu(modeBtn, curMode, setMode);
|
|
687
|
+
box.mode = () => curMode;
|
|
525
688
|
let curModel = savedModel(agent?.id);
|
|
526
689
|
let curEffort = savedEffort(agent?.id, curModel);
|
|
527
690
|
const runnerBtn = $('#runner', box);
|
|
528
691
|
const paintRunner = () => {
|
|
529
|
-
|
|
530
|
-
runnerBtn.innerHTML = `<span class="rb-logo">${harnessLogo(agent?.id)}</span><span class="rb-h">${esc(agent?.name || 'No agent')}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${curEffort ? `<span class="rb-e">${esc(effortLabel(curEffort))}</span>` : ''}${icon('chevron', 'chev')}`;
|
|
692
|
+
paintRunnerBtn(runnerBtn, agent?.id, curModel, curEffort);
|
|
531
693
|
runnerBtn.title = `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
|
|
532
694
|
};
|
|
533
695
|
paintRunner();
|
|
696
|
+
|
|
697
|
+
// Worktree: each launch gets a fresh branch from the remote's default branch (git repos only).
|
|
698
|
+
const wtBtn = $('#wtBtn', box);
|
|
699
|
+
const paintWt = () => {
|
|
700
|
+
const on = S.config.defaults.worktree !== 'off';
|
|
701
|
+
wtBtn.classList.toggle('on', on);
|
|
702
|
+
wtBtn.innerHTML = `${icon('branch')}<span>${on ? 'Worktree' : 'In place'}</span>`;
|
|
703
|
+
wtBtn.title = on
|
|
704
|
+
? 'Worktree: runs on its own branch from origin/main in a separate checkout, so parallel agents never collide (git repos; not in Plan mode). Click to work in the project folder instead.'
|
|
705
|
+
: 'In place: edits the project folder directly. Click to give each task its own worktree and branch.';
|
|
706
|
+
};
|
|
707
|
+
paintWt();
|
|
708
|
+
wtBtn.onclick = () => {
|
|
709
|
+
const worktree = S.config.defaults.worktree === 'off' ? 'on' : 'off';
|
|
710
|
+
S.config.defaults.worktree = worktree;
|
|
711
|
+
paintWt();
|
|
712
|
+
api('/config', { method: 'PATCH', body: { defaults: { worktree } } }).catch(() => {});
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
// Subagents: remembered like the other picks, so a team stays set until you clear it.
|
|
716
|
+
let team = (S.config.defaults.subagents || []).filter((m) => S.agents.some((a) => a.id === m.agent));
|
|
717
|
+
let teamPreset = S.config.defaults.teamPreset || '';
|
|
718
|
+
const teamBtn = $('#teamBtn', box);
|
|
719
|
+
const paintTeam = () => paintTeamBtn(teamBtn, team, teamPreset, agent?.id);
|
|
720
|
+
paintTeam();
|
|
721
|
+
teamBtn.onclick = () =>
|
|
722
|
+
teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset }, (members, preset) => {
|
|
723
|
+
if (JSON.stringify([members, preset]) === JSON.stringify([team, teamPreset])) return;
|
|
724
|
+
team = members;
|
|
725
|
+
teamPreset = preset;
|
|
726
|
+
Object.assign(S.config.defaults, { subagents: team, teamPreset });
|
|
727
|
+
paintTeam();
|
|
728
|
+
api('/config', { method: 'PATCH', body: { defaults: { subagents: team, teamPreset } } }).catch(() => {});
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// Compare: a second harness gets the same task; picks here don't touch the launcher defaults.
|
|
732
|
+
let compare = null;
|
|
733
|
+
const vsBtn = $('#compareBtn', box);
|
|
734
|
+
const runner2 = $('#runner2', box);
|
|
735
|
+
const paintCompare = () => {
|
|
736
|
+
vsBtn.parentElement.classList.toggle('cmp', !!compare);
|
|
737
|
+
vsBtn.setAttribute('aria-pressed', String(!!compare));
|
|
738
|
+
vsBtn.title = compare ? 'Stop comparing' : 'Compare: run the same task on a second harness, side by side';
|
|
739
|
+
runner2.hidden = !compare;
|
|
740
|
+
if (compare) paintRunnerBtn(runner2, compare.agent, compare.model, compare.effort);
|
|
741
|
+
};
|
|
742
|
+
paintCompare();
|
|
743
|
+
vsBtn.onclick = () => {
|
|
744
|
+
if (compare) compare = null;
|
|
745
|
+
else {
|
|
746
|
+
const other = S.agents.find((a) => a.available && a.id !== agent?.id) || agent;
|
|
747
|
+
compare = { agent: other?.id, model: savedModel(other?.id), effort: savedEffort(other?.id, savedModel(other?.id)) };
|
|
748
|
+
}
|
|
749
|
+
paintCompare();
|
|
750
|
+
};
|
|
751
|
+
runner2.onclick = () =>
|
|
752
|
+
runnerPicker(runner2, { ...compare, savedModel, savedEffort }, (id, model, effort) => {
|
|
753
|
+
compare = { agent: id, model, effort };
|
|
754
|
+
paintCompare();
|
|
755
|
+
});
|
|
534
756
|
runnerBtn.onclick = () =>
|
|
535
757
|
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
|
|
536
758
|
agent = S.agents.find((a) => a.id === id);
|
|
537
759
|
curModel = model;
|
|
538
760
|
curEffort = effort;
|
|
539
761
|
paintRunner();
|
|
762
|
+
paintTeam();
|
|
540
763
|
const d = S.config.defaults;
|
|
541
764
|
const models = { ...d.models, [id]: model };
|
|
542
765
|
// A model without levels leaves the harness's saved level alone, so switching back restores it.
|
|
@@ -548,29 +771,57 @@ function renderLauncher() {
|
|
|
548
771
|
const launch = async () => {
|
|
549
772
|
const prompt = ta.value.trim();
|
|
550
773
|
if (!prompt) return ta.focus();
|
|
551
|
-
|
|
552
|
-
|
|
774
|
+
// One run per place picked under "Run on", each in the project picked for it.
|
|
775
|
+
const targets = launchWhere.map((k) => ({ k, m: k ? machineById(k) : null, project: projectById(launchPicks[k]) }));
|
|
776
|
+
const unset = targets.find((t) => !t.project);
|
|
777
|
+
if (unset) return openProjects({ focusAdd: true, machine: unset.k || null });
|
|
778
|
+
const off = targets.find((t) => t.m && t.m.state !== 'online');
|
|
779
|
+
if (off) return offlineToast(off.k);
|
|
780
|
+
const wanted = [agent?.id, ...(compare ? [compare.agent] : [])];
|
|
781
|
+
for (const t of targets) {
|
|
782
|
+
const missing = t.m?.remote?.agents && wanted.find((id) => !t.m.remote.agents.some((x) => x.id === id && x.available));
|
|
783
|
+
if (missing) return toast(`${esc(agentName(missing))} isn’t installed on <b>${esc(t.m.name)}</b>`, { tone: 'bad' });
|
|
784
|
+
}
|
|
553
785
|
const btn = $('#launch', box);
|
|
554
786
|
btn.disabled = true;
|
|
787
|
+
btn.classList.add('busy'); // a fetch and fresh worktrees can take a second or two
|
|
555
788
|
try {
|
|
556
789
|
const attachments = await tray.ids();
|
|
557
|
-
|
|
558
|
-
|
|
790
|
+
// Several places: one shared groupId, so the runs land as one task with a row each.
|
|
791
|
+
const groupId = targets.length > 1 ? [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(16).padStart(2, '0')).join('') : undefined;
|
|
792
|
+
const results = await Promise.allSettled(
|
|
793
|
+
targets.map((t) =>
|
|
794
|
+
api('/sessions', {
|
|
795
|
+
method: 'POST',
|
|
796
|
+
body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: agent?.id || 'claude', mode: curMode, model: curModel, effort: curEffort, attachments, worktree: S.config.defaults.worktree === 'off' && !compare ? false : undefined, compare: compare ? [compare] : undefined, subagents: team.length && canLead(agent?.id) ? team : undefined },
|
|
797
|
+
}),
|
|
798
|
+
),
|
|
799
|
+
);
|
|
800
|
+
results.forEach((r, i) => {
|
|
801
|
+
if (r.status === 'rejected') toast(`${esc(placeName(targets[i].k))}: ${esc(r.reason.message)}`, { tone: 'bad' });
|
|
802
|
+
});
|
|
803
|
+
const started = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
|
|
804
|
+
if (!started.length) return;
|
|
805
|
+
for (const x of started.flatMap((y) => y.group || [y])) S.sessions.set(x.id, x);
|
|
806
|
+
const s = started[0];
|
|
807
|
+
if (compare) (compare = null), paintCompare();
|
|
559
808
|
ta.value = '';
|
|
560
809
|
S.drafts.delete('home');
|
|
561
810
|
tray.clear();
|
|
562
811
|
ta.dispatchEvent(new Event('input'));
|
|
563
|
-
if (S.filter !== 'all' && S.filter !== s.projectId) {
|
|
812
|
+
if (S.filter !== 'all' && (started.length > 1 || S.filter !== s.projectId)) {
|
|
564
813
|
S.filter = 'all';
|
|
565
814
|
save('wisp.filter', S.filter);
|
|
566
815
|
}
|
|
567
816
|
renderFeed();
|
|
568
|
-
|
|
817
|
+
const card = S.nodes.get(s.id)?.card;
|
|
818
|
+
showLaunched(btn, card?.closest('.task-item') || card);
|
|
569
819
|
askNotify();
|
|
570
820
|
} catch (err) {
|
|
571
821
|
toast(esc(err.message), { tone: 'bad' });
|
|
572
822
|
} finally {
|
|
573
823
|
btn.disabled = false;
|
|
824
|
+
btn.classList.remove('busy');
|
|
574
825
|
ta.focus();
|
|
575
826
|
}
|
|
576
827
|
};
|
|
@@ -587,6 +838,403 @@ function renderLauncher() {
|
|
|
587
838
|
});
|
|
588
839
|
}
|
|
589
840
|
|
|
841
|
+
// The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
|
|
842
|
+
function paintRunnerBtn(btn, agentId, model, effort) {
|
|
843
|
+
const a = S.agents.find((x) => x.id === agentId);
|
|
844
|
+
const m = (a?.models || []).find((x) => x.id === model);
|
|
845
|
+
btn.innerHTML = agentId
|
|
846
|
+
? `<span class="rb-logo">${harnessLogo(agentId)}</span><span class="rb-h">${esc(a?.name || agentName(agentId))}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${effort ? `<span class="rb-e">${esc(effortLabel(effort))}</span>` : ''}${icon('chevron', 'chev')}`
|
|
847
|
+
: `<span class="rb-logo">${icon('spark')}</span><span class="rb-h">Launcher default</span>${icon('chevron', 'chev')}`;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// --- subagents -------------------------------------------------------------------
|
|
851
|
+
// A team the lead can delegate to: any harness, model and thinking level per member, plus the
|
|
852
|
+
// user's "use it for" note. Picked in the launcher, saved as presets, shown on cards and turns.
|
|
853
|
+
|
|
854
|
+
const MAX_TEAM = 6; // rows
|
|
855
|
+
const MAX_COPIES = 5; // parallel copies of one row
|
|
856
|
+
const MAX_INSTANCES = 12; // copies across the team
|
|
857
|
+
const copiesOf = (m) => Math.max(1, m.count || 1);
|
|
858
|
+
const teamSize = (team) => team.reduce((n, m) => n + copiesOf(m), 0);
|
|
859
|
+
const canLead = (id) => S.agents.find((a) => a.id === id)?.delegates !== false;
|
|
860
|
+
const memberLabel = (m) => {
|
|
861
|
+
const a = S.agents.find((x) => x.id === m.agent);
|
|
862
|
+
const model = (m.model && a?.models?.find((x) => x.id === m.model)?.label) || m.model || 'Default model';
|
|
863
|
+
return `${a?.name || agentName(m.agent)} · ${model}${m.effort ? ` · ${effortLabel(m.effort)}` : ''}`;
|
|
864
|
+
};
|
|
865
|
+
const slugName = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
|
|
866
|
+
// Same rule as the server: blank names become the harness id, repeats get -2, -3…
|
|
867
|
+
function uniqueNames(members) {
|
|
868
|
+
const taken = new Set();
|
|
869
|
+
return members.map((m) => {
|
|
870
|
+
const base = slugName(m.name) || m.agent;
|
|
871
|
+
let name = base;
|
|
872
|
+
for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
|
|
873
|
+
taken.add(name);
|
|
874
|
+
return { ...m, name };
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// The lead's logo with its subagents' tucked behind it; they fan out on hover. A member that
|
|
879
|
+
// is working pulses; one the lead never used is dimmed.
|
|
880
|
+
function teamStack(lead, team) {
|
|
881
|
+
const shown = team.slice(0, 4);
|
|
882
|
+
const tip = `${agentName(lead)} with ${team.map((m) => `${m.name}${m.busy ? ' (working)' : m.used === false ? ' (unused)' : ''}`).join(', ')}`;
|
|
883
|
+
return `<span class="team-stack" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">
|
|
884
|
+
<span class="ts-l">${harnessLogo(lead)}</span>
|
|
885
|
+
${shown.map((m, i) => `<span class="ts-m${m.used === false ? ' idle' : ''}${m.busy ? ' busy' : ''}" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}
|
|
886
|
+
${team.length > shown.length ? `<span class="ts-m ts-more" style="--i:${shown.length}">+${team.length - shown.length}</span>` : ''}
|
|
887
|
+
</span>`;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Launcher button: a stack of the members' logos and the preset's name, or just "Subagents".
|
|
891
|
+
function paintTeamBtn(btn, team, presetId, lead) {
|
|
892
|
+
const ok = canLead(lead);
|
|
893
|
+
const flat = team.flatMap((m) => Array(copiesOf(m)).fill(m));
|
|
894
|
+
const n = flat.length;
|
|
895
|
+
const p = (S.config.teams || []).find((x) => x.id === presetId);
|
|
896
|
+
btn.classList.toggle('on', team.length > 0 && ok);
|
|
897
|
+
btn.classList.toggle('off', team.length > 0 && !ok);
|
|
898
|
+
btn.innerHTML = team.length
|
|
899
|
+
? `<span class="tb-stack">${flat.slice(0, 5).map((m, i) => `<span class="ts-m" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}${n > 5 ? `<span class="ts-m ts-more" style="--i:5">+${n - 5}</span>` : ''}</span><span class="tb-label">${esc(p?.name || `${n} subagent${n > 1 ? 's' : ''}`)}</span>`
|
|
900
|
+
: `${icon('agent')}<span class="tb-label">Subagents</span>`;
|
|
901
|
+
btn.title = !team.length
|
|
902
|
+
? 'Subagents: give the lead a team on other harnesses to delegate to'
|
|
903
|
+
: ok
|
|
904
|
+
? `Subagents the lead can delegate to:\n${team.map((m) => `• ${m.name}${copiesOf(m) > 1 ? ` ×${copiesOf(m)}` : ''}: ${memberLabel(m)}${m.use ? ` — ${m.use}` : ''}`).join('\n')}`
|
|
905
|
+
: `${agentName(lead)} can't delegate (wisp can't give it tools), so this launch runs without subagents`;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function teamEditor(anchor, { lead, team, presetId }, onChange) {
|
|
909
|
+
const open = $('.team-pop');
|
|
910
|
+
if (open) return open.close();
|
|
911
|
+
let members = team.map((m) => ({ ...m }));
|
|
912
|
+
let preset = presetId;
|
|
913
|
+
const r = anchor.getBoundingClientRect();
|
|
914
|
+
const width = Math.min(540, innerWidth - 24);
|
|
915
|
+
const left = Math.max(12, Math.min(r.left, innerWidth - 12 - width));
|
|
916
|
+
const below = r.bottom + 440 < innerHeight;
|
|
917
|
+
const node = el(`
|
|
918
|
+
<div class="team-pop" role="dialog" aria-label="Subagents" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
|
|
919
|
+
<header class="tp-head">
|
|
920
|
+
<div class="tp-title"><b>Subagents</b><small>The lead hands tasks to these, each on its own harness. Tell it what each one is for.</small></div>
|
|
921
|
+
<button class="ghost xs tp-presets" aria-haspopup="menu">${icon('agent')}<span>Presets</span>${icon('chevron', 'chev')}</button>
|
|
922
|
+
</header>
|
|
923
|
+
<div class="tp-warn" hidden></div>
|
|
924
|
+
<div class="tp-list"></div>
|
|
925
|
+
<footer class="tp-foot"></footer>
|
|
926
|
+
</div>`);
|
|
927
|
+
const list = $('.tp-list', node);
|
|
928
|
+
const foot = $('.tp-foot', node);
|
|
929
|
+
const commit = () => onChange(uniqueNames(members), preset);
|
|
930
|
+
let typing;
|
|
931
|
+
const commitSoon = () => (clearTimeout(typing), (typing = setTimeout(commit, 300)));
|
|
932
|
+
|
|
933
|
+
const paintWarn = () => {
|
|
934
|
+
const w = $('.tp-warn', node);
|
|
935
|
+
w.hidden = canLead(lead);
|
|
936
|
+
w.innerHTML = `${icon('warn')}<span>${esc(agentName(lead))} can't delegate: wisp can't give it tools. Pick Claude Code, Codex or opencode as the lead to use these.</span>`;
|
|
937
|
+
};
|
|
938
|
+
const paintFoot = () => {
|
|
939
|
+
foot.innerHTML = `
|
|
940
|
+
<button class="ghost sm tp-add" ${members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES ? 'disabled' : ''}>${icon('plus')}Add subagent</button>
|
|
941
|
+
<span class="tp-count" title="Agents in all, counting copies (max ${MAX_INSTANCES})">${teamSize(members)} agent${teamSize(members) === 1 ? '' : 's'}</span>
|
|
942
|
+
<span class="grow"></span>
|
|
943
|
+
${members.length ? `<button class="link xs tp-clear">Clear</button><button class="ghost sm tp-save">${icon('check')}Save as preset</button>` : ''}`;
|
|
944
|
+
};
|
|
945
|
+
const paintList = () => {
|
|
946
|
+
const named = uniqueNames(members);
|
|
947
|
+
list.innerHTML = members.length
|
|
948
|
+
? members
|
|
949
|
+
.map(
|
|
950
|
+
(m, i) => `<div class="tp-row" data-i="${i}">
|
|
951
|
+
<div class="tp-top">
|
|
952
|
+
<button class="runner-btn tp-runner" title="Harness, model and thinking level" aria-haspopup="dialog"></button>
|
|
953
|
+
<span class="tp-copies${copiesOf(m) > 1 ? ' on' : ''}" title="Copies: identical agents that take tasks in parallel">
|
|
954
|
+
<button class="tp-dec" aria-label="One copy fewer" ${copiesOf(m) <= 1 ? 'disabled' : ''}>${icon('minus')}</button>
|
|
955
|
+
<b>×${copiesOf(m)}</b>
|
|
956
|
+
<button class="tp-inc" aria-label="One copy more" ${copiesOf(m) >= MAX_COPIES || teamSize(members) >= MAX_INSTANCES ? 'disabled' : ''}>${icon('plus')}</button>
|
|
957
|
+
</span>
|
|
958
|
+
<label class="field sm mono tp-name" title="What the lead calls it"><span>@</span><input value="${esc(m.name || '')}" placeholder="${esc(named[i].name)}" maxlength="24" spellcheck="false" autocomplete="off"></label>
|
|
959
|
+
<button class="icon-btn tp-del" title="Remove">${icon('x')}</button>
|
|
960
|
+
</div>
|
|
961
|
+
<label class="field sm tp-use"><span>Use for</span><input value="${esc(m.use || '')}" placeholder="e.g. image and UI work · research · writing tests" maxlength="300" spellcheck="false" autocomplete="off"></label>
|
|
962
|
+
</div>`,
|
|
963
|
+
)
|
|
964
|
+
.join('')
|
|
965
|
+
: `<div class="tp-empty">${icon('agent')}<p>No subagents yet. Add one per harness you want the lead to use, like Codex for tests or opencode for research.</p></div>`;
|
|
966
|
+
$$('.tp-row', list).forEach((row, i) => paintRunnerBtn($('.tp-runner', row), members[i].agent, members[i].model, members[i].effort));
|
|
967
|
+
paintFoot();
|
|
968
|
+
};
|
|
969
|
+
const add = () => {
|
|
970
|
+
if (members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES) return;
|
|
971
|
+
const used = new Set([lead, ...members.map((m) => m.agent)]);
|
|
972
|
+
const a = S.agents.find((x) => x.available && !used.has(x.id)) || S.agents.find((x) => x.available && x.id !== lead) || S.agents.find((x) => x.available);
|
|
973
|
+
if (!a) return toast('No harness is installed.', { tone: 'bad' });
|
|
974
|
+
const model = savedModel(a.id);
|
|
975
|
+
members.push({ name: '', agent: a.id, model, effort: savedEffort(a.id, model), use: '', count: 1 });
|
|
976
|
+
paintList();
|
|
977
|
+
commit();
|
|
978
|
+
$(`.tp-row[data-i="${members.length - 1}"] .tp-use input`, list)?.focus();
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
// Save: name it; an existing preset's name updates that preset.
|
|
982
|
+
const askName = () => {
|
|
983
|
+
const cur = (S.config.teams || []).find((x) => x.id === preset);
|
|
984
|
+
foot.innerHTML = `<form class="tp-saveform"><input class="input sm" placeholder="Preset name, e.g. Review crew" value="${esc(cur?.name || '')}" maxlength="60"><button class="primary sm">Save</button><button type="button" class="link xs tp-cancel">Cancel</button></form>`;
|
|
985
|
+
const input = $('input', foot);
|
|
986
|
+
input.focus();
|
|
987
|
+
input.select();
|
|
988
|
+
$('.tp-cancel', foot).onclick = paintFoot;
|
|
989
|
+
$('form', foot).onsubmit = async (e) => {
|
|
990
|
+
e.preventDefault();
|
|
991
|
+
const name = input.value.trim();
|
|
992
|
+
if (!name) return input.focus();
|
|
993
|
+
const same = (S.config.teams || []).find((x) => x.name.toLowerCase() === name.toLowerCase());
|
|
994
|
+
try {
|
|
995
|
+
const p = await api(same ? `/teams/${same.id}` : '/teams', { method: same ? 'PATCH' : 'POST', body: { name, members: uniqueNames(members) } });
|
|
996
|
+
S.config.teams = [...(S.config.teams || []).filter((x) => x.id !== p.id), p];
|
|
997
|
+
preset = p.id;
|
|
998
|
+
commit();
|
|
999
|
+
paintFoot();
|
|
1000
|
+
toast(`${same ? 'Updated' : 'Saved'} preset <b>${esc(p.name)}</b>`);
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
const presetMenu = (btn) => {
|
|
1008
|
+
const prev = $('.menu.tp-menu');
|
|
1009
|
+
if (prev) return prev.close();
|
|
1010
|
+
const teams = S.config.teams || [];
|
|
1011
|
+
const b = btn.getBoundingClientRect();
|
|
1012
|
+
const menu = el(`
|
|
1013
|
+
<div class="menu tp-menu" role="menu" style="top:${b.bottom + 4}px;right:${innerWidth - b.right}px">
|
|
1014
|
+
${teams.length
|
|
1015
|
+
? teams
|
|
1016
|
+
.map(
|
|
1017
|
+
(t) => `<button role="menuitem" data-id="${esc(t.id)}" class="${t.id === preset ? 'on' : ''}">
|
|
1018
|
+
<span class="tb-stack">${t.members.slice(0, 4).map((m, i) => `<span class="ts-m" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}</span>
|
|
1019
|
+
<span class="mm-text"><b>${esc(t.name)}</b><small>${esc(t.members.map((m) => m.name).join(', '))}</small></span>
|
|
1020
|
+
<span class="tp-mdel" data-del="${esc(t.id)}" title="Delete preset">${icon('trash')}</span>
|
|
1021
|
+
</button>`,
|
|
1022
|
+
)
|
|
1023
|
+
.join('')
|
|
1024
|
+
: `<div class="tp-mempty">No presets yet. Set up a team, then <b>Save as preset</b>.</div>`}
|
|
1025
|
+
</div>`);
|
|
1026
|
+
const close = () => (menu.remove(), document.removeEventListener('mousedown', outsideMenu, true));
|
|
1027
|
+
const outsideMenu = (ev) => !menu.contains(ev.target) && !btn.contains(ev.target) && close();
|
|
1028
|
+
menu.close = close;
|
|
1029
|
+
setTimeout(() => document.addEventListener('mousedown', outsideMenu, true));
|
|
1030
|
+
menu.onclick = async (ev) => {
|
|
1031
|
+
const del = ev.target.closest('[data-del]');
|
|
1032
|
+
if (del) {
|
|
1033
|
+
ev.stopPropagation();
|
|
1034
|
+
const t = teams.find((x) => x.id === del.dataset.del);
|
|
1035
|
+
await api(`/teams/${t.id}`, { method: 'DELETE' }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
1036
|
+
S.config.teams = (S.config.teams || []).filter((x) => x.id !== t.id);
|
|
1037
|
+
if (preset === t.id) (preset = ''), commit();
|
|
1038
|
+
close();
|
|
1039
|
+
return toast(`Deleted preset <b>${esc(t.name)}</b>`);
|
|
1040
|
+
}
|
|
1041
|
+
const item = ev.target.closest('button[data-id]');
|
|
1042
|
+
if (!item) return;
|
|
1043
|
+
const t = teams.find((x) => x.id === item.dataset.id);
|
|
1044
|
+
members = t.members.map((m) => ({ ...m }));
|
|
1045
|
+
preset = t.id;
|
|
1046
|
+
close();
|
|
1047
|
+
paintList();
|
|
1048
|
+
commit();
|
|
1049
|
+
};
|
|
1050
|
+
document.body.append(menu);
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
list.addEventListener('click', (e) => {
|
|
1054
|
+
const row = e.target.closest('.tp-row');
|
|
1055
|
+
if (!row) return;
|
|
1056
|
+
const i = Number(row.dataset.i);
|
|
1057
|
+
if (e.target.closest('.tp-del')) {
|
|
1058
|
+
members.splice(i, 1);
|
|
1059
|
+
paintList();
|
|
1060
|
+
return commit();
|
|
1061
|
+
}
|
|
1062
|
+
const step = e.target.closest('.tp-inc') ? 1 : e.target.closest('.tp-dec') ? -1 : 0;
|
|
1063
|
+
if (step) {
|
|
1064
|
+
const m = members[i];
|
|
1065
|
+
const next = copiesOf(m) + step;
|
|
1066
|
+
if (next < 1 || next > MAX_COPIES || (step > 0 && teamSize(members) >= MAX_INSTANCES)) return;
|
|
1067
|
+
m.count = next;
|
|
1068
|
+
paintList();
|
|
1069
|
+
$(`.tp-row[data-i="${i}"] ${step > 0 ? '.tp-inc' : '.tp-dec'}`, list)?.focus();
|
|
1070
|
+
return commit();
|
|
1071
|
+
}
|
|
1072
|
+
const rb = e.target.closest('.tp-runner');
|
|
1073
|
+
if (rb) {
|
|
1074
|
+
const m = members[i];
|
|
1075
|
+
runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort }, (agent, model, effort) => {
|
|
1076
|
+
Object.assign(m, { agent, model, effort });
|
|
1077
|
+
paintList();
|
|
1078
|
+
commit();
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
list.addEventListener('input', (e) => {
|
|
1083
|
+
const row = e.target.closest('.tp-row');
|
|
1084
|
+
if (!row) return;
|
|
1085
|
+
const m = members[Number(row.dataset.i)];
|
|
1086
|
+
if (e.target.closest('.tp-name')) m.name = e.target.value;
|
|
1087
|
+
else m.use = e.target.value;
|
|
1088
|
+
commitSoon();
|
|
1089
|
+
});
|
|
1090
|
+
foot.addEventListener('click', (e) => {
|
|
1091
|
+
if (e.target.closest('.tp-add')) add();
|
|
1092
|
+
if (e.target.closest('.tp-save')) askName();
|
|
1093
|
+
if (e.target.closest('.tp-clear')) {
|
|
1094
|
+
members = [];
|
|
1095
|
+
preset = '';
|
|
1096
|
+
paintList();
|
|
1097
|
+
commit();
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1100
|
+
$('.tp-presets', node).onclick = (e) => presetMenu(e.currentTarget);
|
|
1101
|
+
|
|
1102
|
+
const close = () => {
|
|
1103
|
+
clearTimeout(typing);
|
|
1104
|
+
commit();
|
|
1105
|
+
node.remove();
|
|
1106
|
+
$('.menu.tp-menu')?.close();
|
|
1107
|
+
anchor.classList.remove('open');
|
|
1108
|
+
document.removeEventListener('mousedown', outside);
|
|
1109
|
+
};
|
|
1110
|
+
node.close = close;
|
|
1111
|
+
// The harness picker and preset menu float outside the popover; clicks there keep it open.
|
|
1112
|
+
const outside = (e) => !node.contains(e.target) && !anchor.contains(e.target) && !e.target.closest('.picker, .tp-menu') && close();
|
|
1113
|
+
node.addEventListener('keydown', (e) => e.key === 'Escape' && (e.stopPropagation(), close(), anchor.focus()));
|
|
1114
|
+
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
1115
|
+
paintWarn();
|
|
1116
|
+
paintList();
|
|
1117
|
+
document.body.append(node);
|
|
1118
|
+
anchor.classList.add('open');
|
|
1119
|
+
if (!members.length) $('.tp-add', node)?.focus();
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// One delegation with the child session and turn it ran as (from the lead's childSessions).
|
|
1123
|
+
function delegationsOf(s, t) {
|
|
1124
|
+
return (t.delegations || []).map((d) => {
|
|
1125
|
+
const child = s.childSessions?.[d.sessionId];
|
|
1126
|
+
return { d, child, ct: child?.turns.find((x) => x.id === d.turnId) };
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
const subKey = (s, t) => delegationsOf(s, t).map(({ ct }) => ct?.status || '?').join(',');
|
|
1130
|
+
const subStatus = (ct) => {
|
|
1131
|
+
if (!ct || ct.status === 'running') return { cls: 'waiting', label: 'Working' };
|
|
1132
|
+
if (ct.status === 'stopped') return { cls: 'partial', label: 'Stopped' };
|
|
1133
|
+
if (ct.status === 'error') return { cls: 'blocked', label: 'Failed' };
|
|
1134
|
+
const st = ct.presentation?.blocks?.find((b) => b.type === 'hero')?.status || 'done';
|
|
1135
|
+
return { cls: st, label: STATUS_LABEL[st] || 'Done' };
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
// While the lead works: one live line per subagent it's waiting on.
|
|
1139
|
+
function teamLiveHtml(s, t) {
|
|
1140
|
+
const list = delegationsOf(s, t);
|
|
1141
|
+
if (!list.length) return '';
|
|
1142
|
+
return `<div class="team-live">${list
|
|
1143
|
+
.map(({ d, ct }) => {
|
|
1144
|
+
const running = !ct || ct.status === 'running';
|
|
1145
|
+
const hero = ct?.presentation?.blocks?.find((b) => b.type === 'hero');
|
|
1146
|
+
const last = ct?.activity?.findLast((a) => a.kind !== 'text')?.label;
|
|
1147
|
+
const st = subStatus(ct);
|
|
1148
|
+
return `<div class="tl-row ${running ? 'live' : `st-${st.cls}`}" data-sub="${esc(d.sessionId)}">
|
|
1149
|
+
<span class="tl-logo">${harnessLogo(d.agent)}</span>
|
|
1150
|
+
<b class="tl-name">@${esc(d.name)}</b>
|
|
1151
|
+
<span class="tl-live">${esc(running ? last || 'Starting up' : hero?.title || st.label)}</span>
|
|
1152
|
+
${running ? `<span class="elapsed" data-start="${ct?.startedAt || d.startedAt}">${fmtDuration(Date.now() - (ct?.startedAt || d.startedAt))}</span>` : `<span class="pill st-${st.cls}"><i></i>${st.label}</span>`}
|
|
1153
|
+
</div>`;
|
|
1154
|
+
})
|
|
1155
|
+
.join('')}</div>`;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// After the run: the team's reports, one card each, opening into the subagent's own presentation.
|
|
1159
|
+
const subOpen = new Set(); // delegation ids the user unfolded
|
|
1160
|
+
function renderSubs(s, t) {
|
|
1161
|
+
const list = delegationsOf(s, t);
|
|
1162
|
+
if (!list.length) return null;
|
|
1163
|
+
const names = [...new Set(list.map(({ d }) => `@${d.name}`))];
|
|
1164
|
+
const node = el(`
|
|
1165
|
+
<section class="subs">
|
|
1166
|
+
<div class="subs-head">
|
|
1167
|
+
${teamStack(s.agent, list.map(({ d, ct }) => ({ name: d.name, agent: d.agent, busy: ct?.status === 'running' })))}
|
|
1168
|
+
<b>${list.length} delegated task${list.length > 1 ? 's' : ''}</b>
|
|
1169
|
+
<span class="subs-to">${esc(names.join(', '))}</span>
|
|
1170
|
+
</div>
|
|
1171
|
+
<div class="subs-list"></div>
|
|
1172
|
+
</section>`);
|
|
1173
|
+
const box = $('.subs-list', node);
|
|
1174
|
+
for (const { d, child, ct } of list) {
|
|
1175
|
+
const st = subStatus(ct);
|
|
1176
|
+
const hero = ct?.presentation?.blocks?.find((b) => b.type === 'hero');
|
|
1177
|
+
const files = ct?.changes?.length || 0;
|
|
1178
|
+
const card = el(`
|
|
1179
|
+
<article class="sub st-${st.cls} ${subOpen.has(d.id) ? 'open' : ''}">
|
|
1180
|
+
<button class="sub-head" aria-expanded="${subOpen.has(d.id)}">
|
|
1181
|
+
<span class="sub-logo">${harnessLogo(d.agent)}</span>
|
|
1182
|
+
<span class="sub-main">
|
|
1183
|
+
<span class="sub-top"><b>@${esc(d.name)}</b><span class="sub-model">${esc(memberLabel(d))}</span></span>
|
|
1184
|
+
<span class="sub-title">${esc(hero?.title || (ct?.status === 'running' ? ct.activity?.findLast((a) => a.kind !== 'text')?.label || 'Working' : d.prompt))}</span>
|
|
1185
|
+
</span>
|
|
1186
|
+
<span class="sub-side">
|
|
1187
|
+
${files ? `<span class="sub-files">${files} file${files > 1 ? 's' : ''}</span>` : ''}
|
|
1188
|
+
${ct?.endedAt ? `<span class="sub-dur">${fmtDuration(ct.endedAt - ct.startedAt)}</span>` : ''}
|
|
1189
|
+
<span class="pill st-${st.cls}"><i></i>${st.label}</span>
|
|
1190
|
+
${icon('chevron', 'chev')}
|
|
1191
|
+
</span>
|
|
1192
|
+
</button>
|
|
1193
|
+
<div class="sub-body" ${subOpen.has(d.id) ? '' : 'hidden'}></div>
|
|
1194
|
+
</article>`);
|
|
1195
|
+
const body = $('.sub-body', card);
|
|
1196
|
+
const fill = () => {
|
|
1197
|
+
body.innerHTML = `<div class="sub-task"><span>Task from ${esc(agentName(s.agent))}</span><p>${esc(d.prompt)}</p></div>`;
|
|
1198
|
+
if (ct?.presentation && child) {
|
|
1199
|
+
// Its "next" and question blocks draft into the lead's composer: the lead stays in charge.
|
|
1200
|
+
const ctx = { ...ctxFor(child, ct, false), reply: (text) => draftInto(s.id, `Ask @${d.name} to: ${text}`), draft: (text) => draftInto(s.id, `Ask @${d.name} to: ${text}`) };
|
|
1201
|
+
body.append(renderPresentation(ct.presentation, ctx));
|
|
1202
|
+
} else if (ct?.status === 'running') body.append(el(`<p class="muted sub-wait">Still working…</p>`));
|
|
1203
|
+
};
|
|
1204
|
+
if (subOpen.has(d.id)) fill();
|
|
1205
|
+
$('.sub-head', card).onclick = () => {
|
|
1206
|
+
const opening = body.hidden;
|
|
1207
|
+
opening ? subOpen.add(d.id) : subOpen.delete(d.id);
|
|
1208
|
+
card.classList.toggle('open', opening);
|
|
1209
|
+
$('.sub-head', card).setAttribute('aria-expanded', String(opening));
|
|
1210
|
+
if (opening) fill();
|
|
1211
|
+
body.hidden = !opening;
|
|
1212
|
+
};
|
|
1213
|
+
box.append(card);
|
|
1214
|
+
}
|
|
1215
|
+
return node;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function draftInto(id, text) {
|
|
1219
|
+
const ta = $('.reply', S.nodes.get(id)?.card);
|
|
1220
|
+
if (!ta) return;
|
|
1221
|
+
ta.value = text;
|
|
1222
|
+
ta.dispatchEvent(new Event('input'));
|
|
1223
|
+
ta.focus();
|
|
1224
|
+
ta.setSelectionRange(text.length, text.length);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// A subagent's summary: its lead's card shows it, so update the live line or re-fetch the lead.
|
|
1228
|
+
const childState = new Map();
|
|
1229
|
+
function onChildSummary(c) {
|
|
1230
|
+
const row = $(`.tl-row[data-sub="${CSS.escape(c.id)}"] .tl-live`);
|
|
1231
|
+
if (row && c.state === 'running' && c.live) row.textContent = c.live;
|
|
1232
|
+
if (childState.get(c.id) !== c.state) {
|
|
1233
|
+
childState.set(c.id, c.state);
|
|
1234
|
+
scheduleRefetch(c.parentId);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
590
1238
|
function modeMenu(anchor, current, pick) {
|
|
591
1239
|
const open = $('.menu.mode');
|
|
592
1240
|
if (open) return open.close();
|
|
@@ -628,21 +1276,172 @@ function modeMenu(anchor, current, pick) {
|
|
|
628
1276
|
function renderLaunchChips() {
|
|
629
1277
|
const box = $('#launcher .l-projects');
|
|
630
1278
|
if (!box) return;
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
|
|
1279
|
+
syncWhere();
|
|
1280
|
+
const multi = launchWhere.length > 1;
|
|
1281
|
+
// One group per place: its name (unless it's only this computer), its projects, a way to add one.
|
|
1282
|
+
const group = (k) => {
|
|
1283
|
+
const m = k && machineById(k);
|
|
1284
|
+
const list = projectsAt(k);
|
|
1285
|
+
const off = m && m.state !== 'online';
|
|
1286
|
+
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>` : '';
|
|
1287
|
+
const chips = list.map((p) => `<button class="lp-chip c-${p.color} ${p.id === launchPicks[k] ? 'on' : ''}" data-id="${p.id}" data-k="${k}" title="${esc(projectTitle(p))}"><i class="p-dot"></i>${esc(p.name)}</button>`).join('');
|
|
1288
|
+
const add = list.length
|
|
1289
|
+
? `<button class="lp-chip add" data-add="${k}" title="Another folder${k ? ` on ${esc(placeName(k))}` : ''}">${icon('folder')}${multi ? '' : 'Other folder…'}</button>`
|
|
1290
|
+
: `<button class="lp-chip add" data-add="${k}">${icon('folder')}${k ? `Pick a project on ${esc(placeName(k))}…` : 'Choose a project folder…'}</button>`;
|
|
1291
|
+
return `<span class="lp-group">${label}${chips}${add}</span>`;
|
|
1292
|
+
};
|
|
1293
|
+
box.innerHTML = `<span class="l-in">in</span>${launchWhere.map(group).join('')}`;
|
|
1294
|
+
box.onclick = (e) => {
|
|
1295
|
+
const chip = e.target.closest('.lp-chip[data-id]');
|
|
1296
|
+
if (chip) {
|
|
1297
|
+
launchPicks[chip.dataset.k] = chip.dataset.id;
|
|
1298
|
+
if (chip.dataset.k === launchWhere[0]) launchProject = chip.dataset.id;
|
|
1299
|
+
saveWhere();
|
|
1300
|
+
$$(`.lp-chip[data-k="${chip.dataset.k}"]`, box).forEach((x) => x.classList.toggle('on', x === chip));
|
|
1301
|
+
renderLaunchRecipes();
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
const add = e.target.closest('[data-add]');
|
|
1305
|
+
if (add) openProjects({ focusAdd: true, machine: add.dataset.add || null });
|
|
1306
|
+
const conn = e.target.closest('[data-connect]');
|
|
1307
|
+
if (conn) connectMachine(conn.dataset.connect);
|
|
1308
|
+
};
|
|
1309
|
+
renderLaunchRecipes();
|
|
1310
|
+
paintWhereBtn();
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// "Run on": this computer and/or machines. The button says where; its menu picks one or several.
|
|
1314
|
+
function paintWhereBtn() {
|
|
1315
|
+
const b = $('#whereBtn');
|
|
1316
|
+
if (!b) return;
|
|
1317
|
+
const n = launchWhere.length;
|
|
1318
|
+
const remote = n > 1 || !!launchWhere[0];
|
|
1319
|
+
b.classList.toggle('on', remote);
|
|
1320
|
+
b.classList.toggle('bare', !S.machines.length && !remote);
|
|
1321
|
+
b.innerHTML = `${icon(remote ? 'server' : 'laptop')}<span>${esc(n > 1 ? `${n} places` : placeName(launchWhere[0]))}</span>${icon('chevron', 'chev')}`;
|
|
1322
|
+
b.title = n > 1 ? `Runs in parallel on ${launchWhere.map(placeName).join(', ')}` : S.machines.length ? `Runs on ${placeName(launchWhere[0])}. Pick several to run in parallel.` : 'Runs on this computer. Add a machine to run on other computers too.';
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function whereMenu(anchor) {
|
|
1326
|
+
if ($('.menu.where-menu')) return $('.menu.where-menu').close();
|
|
1327
|
+
const box = el(`<div class="menu where-menu" role="menu"></div>`);
|
|
1328
|
+
const paint = () => {
|
|
1329
|
+
const row = (k, m) => {
|
|
1330
|
+
const on = launchWhere.includes(k);
|
|
1331
|
+
const st = m ? m.state : 'online';
|
|
1332
|
+
const ready = m ? (m.remote?.agents || []).filter((a) => a.available) : [];
|
|
1333
|
+
return `<button class="wm-row ${on ? 'on' : ''}" data-k="${k}" role="menuitemcheckbox" aria-checked="${on}">
|
|
1334
|
+
<span class="wm-check">${icon('check')}</span>
|
|
1335
|
+
<span class="wm-icon">${icon(k ? 'server' : 'laptop')}${m ? `<i class="dot ${st === 'online' ? 'ok' : st === 'error' ? 'bad' : ''}"></i>` : ''}</span>
|
|
1336
|
+
<span class="wm-name"><b>${esc(placeName(k))}</b><small>${m ? esc(st === 'online' ? m.target : `${m.target} · ${MACHINE_STATE[st]}`) : `${projectsAt('').length} project${projectsAt('').length === 1 ? '' : 's'}`}</small></span>
|
|
1337
|
+
${m && st === 'online' && ready.length ? `<span class="wm-agents">${ready.map((a) => `<span class="model-logo">${harnessLogo(a.id)}</span>`).join('')}</span>` : ''}
|
|
1338
|
+
<span class="wm-only" data-only="${k}" title="Only here">only</span>
|
|
1339
|
+
</button>`;
|
|
1340
|
+
};
|
|
1341
|
+
box.innerHTML = `<div class="wm-h">Run on <small>pick several to run in parallel</small></div>
|
|
1342
|
+
${row('', null)}${S.machines.map((m) => row(m.id, m)).join('')}
|
|
1343
|
+
<div class="wm-sep"></div>
|
|
1344
|
+
<button data-a="add">${icon('plus')}${S.machines.length ? 'Add a machine…' : 'Add a machine to run on another computer…'}</button>
|
|
1345
|
+
${S.machines.length ? `<button data-a="manage">${icon('settings')}Manage machines</button>` : ''}`;
|
|
1346
|
+
};
|
|
1347
|
+
const set = (list) => {
|
|
1348
|
+
launchWhere = list.length ? list : [''];
|
|
1349
|
+
// A machine picked while disconnected starts connecting; you asked to run there.
|
|
1350
|
+
for (const k of launchWhere) if (k && machineById(k)?.state === 'off') connectMachine(k);
|
|
1351
|
+
renderLaunchChips();
|
|
1352
|
+
paint();
|
|
1353
|
+
saveWhere();
|
|
1354
|
+
};
|
|
1355
|
+
box.onclick = (e) => {
|
|
1356
|
+
const only = e.target.closest('[data-only]');
|
|
1357
|
+
if (only) return set([only.dataset.only]);
|
|
1358
|
+
const r = e.target.closest('.wm-row');
|
|
1359
|
+
if (r) {
|
|
1360
|
+
const k = r.dataset.k;
|
|
1361
|
+
return set(launchWhere.includes(k) ? launchWhere.filter((x) => x !== k) : [...launchWhere, k]);
|
|
1362
|
+
}
|
|
1363
|
+
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
1364
|
+
if (!a) return;
|
|
1365
|
+
shut();
|
|
1366
|
+
openMachines({ focusAdd: a === 'add' });
|
|
1367
|
+
};
|
|
1368
|
+
const shut = () => {
|
|
1369
|
+
box.remove();
|
|
1370
|
+
anchor.setAttribute('aria-expanded', 'false');
|
|
1371
|
+
document.removeEventListener('mousedown', outside);
|
|
1372
|
+
document.removeEventListener('keydown', esc_, true);
|
|
1373
|
+
};
|
|
1374
|
+
const outside = (ev) => {
|
|
1375
|
+
if (!box.contains(ev.target) && !anchor.contains(ev.target)) shut();
|
|
1376
|
+
};
|
|
1377
|
+
const esc_ = (ev) => {
|
|
1378
|
+
if (ev.key === 'Escape') (ev.stopPropagation(), shut());
|
|
1379
|
+
};
|
|
1380
|
+
box.close = shut;
|
|
1381
|
+
box.refresh = paint;
|
|
1382
|
+
paint();
|
|
1383
|
+
document.body.append(box);
|
|
1384
|
+
anchor.setAttribute('aria-expanded', 'true');
|
|
1385
|
+
const r = anchor.getBoundingClientRect();
|
|
1386
|
+
const h = box.offsetHeight;
|
|
1387
|
+
box.style.left = `${Math.min(r.left, innerWidth - box.offsetWidth - 8)}px`;
|
|
1388
|
+
box.style.top = `${r.bottom + 6 + h > innerHeight - 8 ? Math.max(8, r.top - h - 6) : r.bottom + 6}px`;
|
|
1389
|
+
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
1390
|
+
document.addEventListener('keydown', esc_, true);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Recipes for the picked project (and the ones tied to none), one click from the launcher.
|
|
1394
|
+
// With text in the box, "Save as recipe" turns it into one.
|
|
1395
|
+
function renderLaunchRecipes() {
|
|
1396
|
+
const row = $('#launcher .l-recipes');
|
|
1397
|
+
if (!row) return;
|
|
1398
|
+
const path = projectById(launchProject)?.path;
|
|
1399
|
+
const all = S.config.recipes || [];
|
|
1400
|
+
const list = all.filter((r) => !r.cwd || r.cwd === path).sort((a, b) => (b.lastRunAt || 0) - (a.lastRunAt || 0));
|
|
1401
|
+
const hidden = all.length - list.length;
|
|
1402
|
+
row.innerHTML = `
|
|
1403
|
+
<span class="l-in">${icon('spark')}Recipes</span>
|
|
1404
|
+
${list.map((r) => `<button class="lp-chip rc" data-recipe="${esc(r.id)}" title="${esc(r.prompt.replace(/\s+/g, ' ').slice(0, 200))}">${esc(r.name)}${recipeParams(r.prompt).length ? '<i class="rc-f">…</i>' : ''}</button>`).join('')}
|
|
1405
|
+
${list.length ? '' : `<span class="muted xs lr-empty">${hidden ? `None for this project · ${hidden} elsewhere` : 'Prompts you send again and again, one click away'}</span>`}
|
|
1406
|
+
<button class="lp-chip add" id="lrSave" hidden>${icon('plus')}Save as recipe</button>
|
|
1407
|
+
<button class="lp-chip add" id="lrNew">${icon('plus')}New</button>`;
|
|
1408
|
+
row.onclick = (e) => {
|
|
1409
|
+
const b = e.target.closest('button');
|
|
1410
|
+
if (!b) return;
|
|
1411
|
+
if (b.dataset.recipe) {
|
|
1412
|
+
const r = all.find((x) => x.id === b.dataset.recipe);
|
|
1413
|
+
return r && runRecipe({ ...r, params: recipeParams(r.prompt) });
|
|
1414
|
+
}
|
|
1415
|
+
const draft = { name: '', prompt: '', cwd: path || '', mode: $('#launcher').mode?.() };
|
|
1416
|
+
if (b.id === 'lrSave') openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft: { ...draft, prompt: $('#prompt').value.trim() } } });
|
|
1417
|
+
if (b.id === 'lrNew') openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft } });
|
|
1418
|
+
};
|
|
1419
|
+
paintSaveRecipe();
|
|
1420
|
+
}
|
|
1421
|
+
function paintSaveRecipe() {
|
|
1422
|
+
const has = !!$('#prompt')?.value.trim();
|
|
1423
|
+
const save = $('#lrSave');
|
|
1424
|
+
if (!save) return;
|
|
1425
|
+
save.hidden = !has;
|
|
1426
|
+
$('#lrNew').hidden = has;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// Top bar: always one click to recipes and triggers. The badge counts triggers that are on;
|
|
1430
|
+
// the tooltip says what fires next.
|
|
1431
|
+
async function paintAutoBtn() {
|
|
1432
|
+
const btn = $('#autoBtn');
|
|
1433
|
+
const on = (S.config.triggers || []).filter((t) => t.enabled);
|
|
1434
|
+
const n = $('.ab-n', btn);
|
|
1435
|
+
n.hidden = !on.length;
|
|
1436
|
+
n.textContent = on.length;
|
|
1437
|
+
btn.classList.toggle('live', !!on.length);
|
|
1438
|
+
const r = S.config.recipes?.length || 0;
|
|
1439
|
+
let title = `Automations: ${r} recipe${r === 1 ? '' : 's'} · ${on.length} trigger${on.length === 1 ? '' : 's'} on`;
|
|
1440
|
+
if (on.length) {
|
|
1441
|
+
const next = (await api('/triggers').catch(() => [])).filter((t) => t.enabled && t.nextAt).sort((a, b) => a.nextAt - b.nextAt)[0];
|
|
1442
|
+
if (next) title += `\nNext: ${next.name} · ${whenLabel(next.nextAt)}`;
|
|
1443
|
+
}
|
|
1444
|
+
btn.title = title;
|
|
646
1445
|
}
|
|
647
1446
|
|
|
648
1447
|
// A little wisp flies from the launch button to the new item in the feed.
|
|
@@ -668,6 +1467,18 @@ function launchedFx(from, to) {
|
|
|
668
1467
|
).onfinish = () => w.remove();
|
|
669
1468
|
}
|
|
670
1469
|
|
|
1470
|
+
// A new card lands in Working, below everything in "Needs you", so it's often off-screen. Scroll
|
|
1471
|
+
// it into view (just enough) and flash it; the flying wisp only plays when the card is already
|
|
1472
|
+
// on screen, since the launcher it starts from may scroll away or fold into the dock.
|
|
1473
|
+
function showLaunched(btn, card) {
|
|
1474
|
+
if (!card) return;
|
|
1475
|
+
const r = card.getBoundingClientRect();
|
|
1476
|
+
if (r.top >= topInset() && r.bottom <= innerHeight) return launchedFx(btn, card);
|
|
1477
|
+
card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
1478
|
+
card.classList.add('arrived');
|
|
1479
|
+
setTimeout(() => card.classList.remove('arrived'), 1600);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
671
1482
|
// --- project filter (top bar) ---------------------------------------------------
|
|
672
1483
|
|
|
673
1484
|
function renderProjects() {
|
|
@@ -678,8 +1489,8 @@ function renderProjects() {
|
|
|
678
1489
|
};
|
|
679
1490
|
const chip = (id, name, color) => {
|
|
680
1491
|
const st = stats(id);
|
|
681
|
-
return `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectById(id)
|
|
682
|
-
${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}
|
|
1492
|
+
return `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectTitle(projectById(id)))}">
|
|
1493
|
+
${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${projectById(id)?.machine ? icon('server', 'p-where') : ''}${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}
|
|
683
1494
|
</button>`;
|
|
684
1495
|
};
|
|
685
1496
|
$('#projects').innerHTML = `${chip('all', 'All', 'slate')}${S.config.projects
|
|
@@ -692,6 +1503,7 @@ function renderProjects() {
|
|
|
692
1503
|
S.filter = S.filter === b.dataset.id ? 'all' : b.dataset.id;
|
|
693
1504
|
save('wisp.filter', S.filter);
|
|
694
1505
|
renderFeed();
|
|
1506
|
+
feedToTop();
|
|
695
1507
|
renderLaunchChips();
|
|
696
1508
|
}),
|
|
697
1509
|
);
|
|
@@ -761,6 +1573,8 @@ function ctxFor(s, turn, isLatest) {
|
|
|
761
1573
|
openFile: (p, line) => openInEditor(s, p, line),
|
|
762
1574
|
fileUrl: (p) => `/api/sessions/${s.id}/file?path=${encodeURIComponent(p)}`,
|
|
763
1575
|
zoom: (src) => overlay(el(`<img class="lightbox" src="${esc(src)}">`)),
|
|
1576
|
+
revertFile: (file, restore) => revertFile(s.id, turn.id, file, restore),
|
|
1577
|
+
exec: (command, key) => execAction(s.id, turn.id, command, key),
|
|
764
1578
|
};
|
|
765
1579
|
}
|
|
766
1580
|
|
|
@@ -781,11 +1595,12 @@ function renderBody(id, { fresh }) {
|
|
|
781
1595
|
<div class="it-meta"></div>
|
|
782
1596
|
<div class="it-actions">
|
|
783
1597
|
<button class="ghost xs" data-a="replay" title="Replay presentation (R)">${icon('replay')}</button>
|
|
784
|
-
<button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}
|
|
785
|
-
<button class="ghost xs" data-a="
|
|
1598
|
+
<button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}</button>
|
|
1599
|
+
<button class="ghost xs" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
|
|
786
1600
|
<button class="ghost xs" data-a="more" title="More">${icon('dots')}</button>
|
|
787
1601
|
</div>
|
|
788
1602
|
</div>
|
|
1603
|
+
<div class="pr-strip" hidden></div>
|
|
789
1604
|
<button class="link xs earlier" hidden></button>
|
|
790
1605
|
<div class="turns"></div>
|
|
791
1606
|
<div class="it-dock"></div>
|
|
@@ -796,7 +1611,7 @@ function renderBody(id, { fresh }) {
|
|
|
796
1611
|
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
797
1612
|
if (a === 'replay') replay(id);
|
|
798
1613
|
if (a === 'takeover') takeOver(id);
|
|
799
|
-
if (a === '
|
|
1614
|
+
if (a === 'ship') shipDialog(id);
|
|
800
1615
|
if (a === 'more') moreMenu(id, e.target.closest('button'));
|
|
801
1616
|
});
|
|
802
1617
|
$('.earlier', body).onclick = () => ((n.showAll = true), renderBody(id, { fresh: false }));
|
|
@@ -805,17 +1620,25 @@ function renderBody(id, { fresh }) {
|
|
|
805
1620
|
|
|
806
1621
|
const body = n.body;
|
|
807
1622
|
const proj = projectById(summary.projectId);
|
|
1623
|
+
// The head already shows the harness, model and project, the task head has Compare, and Done
|
|
1624
|
+
// sits by the composer. This bar only says how the run was set up: a mode worth noticing
|
|
1625
|
+
// (Plan or Full auto, as on the launcher), the branch, and what launched it.
|
|
1626
|
+
const mode = MODES.find((m) => m.id === s.mode);
|
|
1627
|
+
const where = (proj?.path || s.cwd).replace(S.config.home || '', '~');
|
|
1628
|
+
$('.it-meta', body).title = [agentName(s.agent), s.model, s.effort && `${effortLabel(s.effort)} thinking`, mode?.label, where].filter(Boolean).join(' · ');
|
|
808
1629
|
$('.it-meta', body).innerHTML = `
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
${s.
|
|
812
|
-
${s.
|
|
813
|
-
|
|
814
|
-
//
|
|
1630
|
+
${mode && mode.id !== 'edits' ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
|
|
1631
|
+
${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
|
|
1632
|
+
${s.subagents?.length ? `<span class="m team-m" title="${esc(s.subagents.map((m) => `@${m.name}${copiesOf(m) > 1 ? ` ×${copiesOf(m)}` : ''}: ${memberLabel(m)}${m.use ? ` — ${m.use}` : ''}`).join('\n'))}">${icon('agent')}${teamSize(s.subagents)} subagent${teamSize(s.subagents) > 1 ? 's' : ''}</span>` : ''}
|
|
1633
|
+
${s.origin ? `<span class="m" title="Launched by a ${esc(s.origin.kind)}">${icon(s.origin.kind === 'trigger' ? 'replay' : 'spark')}${esc(s.origin.name)}</span>` : ''}`;
|
|
1634
|
+
const live = s.state === 'running' || s.state === 'waiting';
|
|
1635
|
+
// Ship sits in the top bar and again next to the composer, so it's there after reading the result.
|
|
1636
|
+
$$('[data-a="ship"]', body).forEach((b) => (b.hidden = !s.worktree || live || s.agent === 'wisp' || s.pr?.state === 'MERGED'));
|
|
1637
|
+
renderPrStrip(id);
|
|
815
1638
|
$$('[data-a="done"]', body).forEach((b) => {
|
|
816
1639
|
b.classList.toggle('on', s.state === 'done');
|
|
817
1640
|
b.hidden = s.state === 'running' || s.state === 'waiting';
|
|
818
|
-
$('span', b).textContent = s.state === 'done' ? 'Reopen' :
|
|
1641
|
+
$('span', b).textContent = s.state === 'done' ? 'Reopen' : 'Mark done';
|
|
819
1642
|
});
|
|
820
1643
|
$('[data-a="takeover"]', body).hidden = !s.agentSessionId;
|
|
821
1644
|
|
|
@@ -834,7 +1657,8 @@ function renderBody(id, { fresh }) {
|
|
|
834
1657
|
turns.forEach((t) => {
|
|
835
1658
|
const isLatest = t.id === latestId;
|
|
836
1659
|
const folded = !isLatest && !n.unfolded.has(t.id);
|
|
837
|
-
const
|
|
1660
|
+
const revKey = `${t.rolledBackAt || 0}:${(t.changes || []).map((c) => (c.reverted ? 1 : 0)).join('')}`;
|
|
1661
|
+
const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}`;
|
|
838
1662
|
const prev = n.turnNodes.get(t.id);
|
|
839
1663
|
let node = prev?.node;
|
|
840
1664
|
if (!prev || prev.key !== key) {
|
|
@@ -851,12 +1675,54 @@ function renderBody(id, { fresh }) {
|
|
|
851
1675
|
for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
|
|
852
1676
|
|
|
853
1677
|
updateDock(id);
|
|
854
|
-
if (firstNew &&
|
|
1678
|
+
if (firstNew && watching(id)) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// Whether the user is reading a card, not just catching its edge at the top or bottom of the
|
|
1682
|
+
// screen: it crosses the middle of the viewport, or it's focused and a fair share of it shows.
|
|
1683
|
+
// Anything that moves the page on a card's behalf asks this first.
|
|
1684
|
+
function watching(id) {
|
|
1685
|
+
const card = S.nodes.get(id)?.card;
|
|
1686
|
+
if (!card?.isConnected) return false;
|
|
1687
|
+
const r = card.getBoundingClientRect();
|
|
1688
|
+
const mid = innerHeight / 2;
|
|
1689
|
+
if (r.top <= mid && r.bottom >= mid) return true;
|
|
1690
|
+
const shown = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0);
|
|
1691
|
+
return S.focus === id && shown >= Math.min(r.height, innerHeight) / 3;
|
|
855
1692
|
}
|
|
856
1693
|
|
|
857
|
-
|
|
858
|
-
const
|
|
859
|
-
return
|
|
1694
|
+
const sectionOf = (s) => {
|
|
1695
|
+
const t = taskOf(s);
|
|
1696
|
+
return t.archived ? 'archived' : t.state === 'running' ? 'working' : t.state === 'done' ? 'done' : 'needs';
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
// Run a DOM change without moving `card` on screen. The browser's own scroll anchoring misses
|
|
1700
|
+
// cards that are moved between lists, so feed re-renders do this explicitly.
|
|
1701
|
+
function keepInPlace(card, change) {
|
|
1702
|
+
const top = card?.isConnected ? card.getBoundingClientRect().top : null;
|
|
1703
|
+
change();
|
|
1704
|
+
if (top !== null && card.isConnected) scrollByPx(card.getBoundingClientRect().top - top);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Any write to scrollTop cancels a smooth scroll in flight (j/k, Open, a launch), so only
|
|
1708
|
+
// write when there's an actual shift to undo.
|
|
1709
|
+
function scrollByPx(dy) {
|
|
1710
|
+
if (Math.abs(dy) >= 1) stage.scrollTop += dy;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// The card to hold still while the feed changes around it: the first one showing under the top
|
|
1714
|
+
// bar and docked launcher, skipping `id` (the card that's changing). None at the very top of the page, where new
|
|
1715
|
+
// cards should push the list down, or when `id` itself is the top card on screen: then the next
|
|
1716
|
+
// one should slide up into its place.
|
|
1717
|
+
function feedAnchor(id) {
|
|
1718
|
+
if (stage.scrollTop < 1) return null;
|
|
1719
|
+
const moving = id && S.nodes.get(id)?.card;
|
|
1720
|
+
const inset = topInset();
|
|
1721
|
+
for (const card of $$('#feed .item')) {
|
|
1722
|
+
const r = card.getBoundingClientRect();
|
|
1723
|
+
if (r.bottom > inset) return card === moving ? null : card;
|
|
1724
|
+
}
|
|
1725
|
+
return null;
|
|
860
1726
|
}
|
|
861
1727
|
|
|
862
1728
|
function renderFoldedTurn(t, unfold) {
|
|
@@ -866,6 +1732,7 @@ function renderFoldedTurn(t, unfold) {
|
|
|
866
1732
|
<button class="t-fold" title="Show this exchange">
|
|
867
1733
|
${icon('chevron', 'chev')}
|
|
868
1734
|
<span class="t-prompt">${esc(t.prompt.replace(/\s+/g, ' '))}</span>
|
|
1735
|
+
${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
|
|
869
1736
|
${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
|
|
870
1737
|
<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
|
|
871
1738
|
</button>
|
|
@@ -876,14 +1743,21 @@ function renderFoldedTurn(t, unfold) {
|
|
|
876
1743
|
|
|
877
1744
|
// `fold` is set for older turns the user unfolded, so they can tuck them away again.
|
|
878
1745
|
function renderTurn(s, t, isLatest, fold) {
|
|
879
|
-
const node = el(`<article class="turn" data-turn="${t.id}"></article>`);
|
|
1746
|
+
const node = el(`<article class="turn ${t.rolledBackAt ? 'is-rolled-back' : ''}" data-turn="${t.id}"></article>`);
|
|
880
1747
|
const long = t.prompt.length > 360 || t.prompt.split('\n').length > 5;
|
|
1748
|
+
const cp = t.checkpoint;
|
|
1749
|
+
const cpLabel = cp?.head ? `${cp.branch ? `${cp.branch}@` : ''}${cp.head.slice(0, 7)}${cp.dirty ? '*' : ''}` : '';
|
|
881
1750
|
const ask = el(`
|
|
882
1751
|
<div class="ask">
|
|
883
1752
|
<div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
|
|
884
1753
|
${long ? '<button class="link xs more-btn">Show all</button>' : ''}
|
|
885
1754
|
${t.attachments?.length ? `<div class="ask-atts">${t.attachments.map((a) => (a.mime?.startsWith('image/') ? `<img src="/api/uploads/${a.id}" alt="${esc(a.name)}" title="${esc(a.name)}">` : `<a class="att-pill" href="/api/uploads/${a.id}" target="_blank">${icon('file')}${esc(a.name)}</a>`)).join('')}</div>` : ''}
|
|
886
|
-
<div class="ask-foot"
|
|
1755
|
+
<div class="ask-foot">
|
|
1756
|
+
${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}
|
|
1757
|
+
<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
|
|
1758
|
+
${cpLabel ? `<span class="cp-sha" title="Git checkpoint before this turn (${esc(cp.head)}${cp.dirty ? ' + uncommitted changes' : ''})">${icon('branch')}${esc(cpLabel)}</span>` : ''}
|
|
1759
|
+
${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
|
|
1760
|
+
</div>
|
|
887
1761
|
${fold ? `<button class="ghost xs t-unfold" title="Collapse this exchange">${icon('chevron', 'chev')}</button>` : ''}
|
|
888
1762
|
</div>`);
|
|
889
1763
|
if (fold) ask.querySelector('.t-unfold').onclick = fold;
|
|
@@ -898,6 +1772,8 @@ function renderTurn(s, t, isLatest, fold) {
|
|
|
898
1772
|
node.append(renderWorking(s, t));
|
|
899
1773
|
} else {
|
|
900
1774
|
node.append(renderPresentation(t.presentation, ctxFor(s, t, isLatest)));
|
|
1775
|
+
const subs = renderSubs(s, t);
|
|
1776
|
+
if (subs) node.append(subs);
|
|
901
1777
|
node.append(renderTrail(s, t));
|
|
902
1778
|
}
|
|
903
1779
|
return node;
|
|
@@ -915,6 +1791,7 @@ function renderWorking(s, t) {
|
|
|
915
1791
|
<span class="w-count">${t.activity.filter((a) => a.kind !== 'text').length} actions</span>
|
|
916
1792
|
</div>
|
|
917
1793
|
<div class="ask-slot"></div>
|
|
1794
|
+
${teamLiveHtml(s, t)}
|
|
918
1795
|
<ul class="ticker"></ul>
|
|
919
1796
|
<div class="w-actions">
|
|
920
1797
|
<button class="link xs peek">Show all activity</button>
|
|
@@ -955,7 +1832,7 @@ function renderAskCard(s) {
|
|
|
955
1832
|
<p class="q-text">${esc(a.question)}</p>
|
|
956
1833
|
${a.context ? `<div class="prose q-context">${md(a.context)}</div>` : ''}
|
|
957
1834
|
${a.options?.length ? `<div class="q-opts">${a.options.map((o, i) => `<button class="opt" data-i="${i}"><span class="k">${i + 1}</span>${esc(o)}</button>`).join('')}</div>` : ''}
|
|
958
|
-
<form class="q-own"><input placeholder="${a.options?.length ? 'Or answer in your own words…' : 'Your answer…'}"><button class="primary sm">Answer</button></form>
|
|
1835
|
+
<form class="q-own"><input class="input" placeholder="${a.options?.length ? 'Or answer in your own words…' : 'Your answer…'}"><button class="primary sm">Answer</button></form>
|
|
959
1836
|
</div>`);
|
|
960
1837
|
const answer = async (text) => {
|
|
961
1838
|
if (!text.trim()) return;
|
|
@@ -985,6 +1862,8 @@ function renderTrail(s, t) {
|
|
|
985
1862
|
return { add: acc.add + n.add, del: acc.del + n.del };
|
|
986
1863
|
}, { add: 0, del: 0 });
|
|
987
1864
|
const st = t.stats || {};
|
|
1865
|
+
const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (t.changes?.length || t.checkpoint?.commit);
|
|
1866
|
+
const rolledBack = !!t.rolledBackAt;
|
|
988
1867
|
const node = el(`
|
|
989
1868
|
<div class="trail">
|
|
990
1869
|
<div class="trail-bar">
|
|
@@ -992,6 +1871,7 @@ function renderTrail(s, t) {
|
|
|
992
1871
|
${t.endedAt ? `<span class="tb">${fmtDuration(t.endedAt - t.startedAt)}</span>` : ''}
|
|
993
1872
|
${st.costUsd ? `<span class="tb">$${st.costUsd.toFixed(2)}</span>` : ''}
|
|
994
1873
|
<span class="grow"></span>
|
|
1874
|
+
${canRollback ? `<button class="link xs cp-rollback ${rolledBack ? 'rolled-back' : ''}" data-a="rollback" title="${rolledBack ? 'Re-apply changes from this turn' : `Restore working tree to before this turn${t.checkpoint?.head ? ` (${t.checkpoint.head.slice(0, 7)})` : ''}`}">${icon(rolledBack ? 'rerun' : 'replay')}${rolledBack ? 'Re-apply turn' : 'Restore to before this turn'}</button>` : ''}
|
|
995
1875
|
${t.changes?.length ? `<button class="link xs" data-tab="changes">${t.changes.length} file${t.changes.length > 1 ? 's' : ''} <span class="plus">+${totals.add}</span> <span class="minus">−${totals.del}</span></button>` : ''}
|
|
996
1876
|
${acts.length ? `<button class="link xs" data-tab="activity">Activity</button>` : ''}
|
|
997
1877
|
${t.presentation?.notes ? `<button class="link xs" data-tab="notes">Agent notes</button>` : ''}
|
|
@@ -1001,6 +1881,9 @@ function renderTrail(s, t) {
|
|
|
1001
1881
|
const body = $('.trail-body', node);
|
|
1002
1882
|
let open = null;
|
|
1003
1883
|
node.addEventListener('click', (e) => {
|
|
1884
|
+
if (e.target.closest('[data-a="rollback"]')) {
|
|
1885
|
+
return rollbackTurn(s.id, t.id, rolledBack);
|
|
1886
|
+
}
|
|
1004
1887
|
const b = e.target.closest('[data-tab]');
|
|
1005
1888
|
if (!b) return;
|
|
1006
1889
|
const tab = b.dataset.tab;
|
|
@@ -1055,6 +1938,7 @@ function renderDock(id) {
|
|
|
1055
1938
|
<div class="c-bar">
|
|
1056
1939
|
<button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
|
|
1057
1940
|
<span class="c-hint"></span>
|
|
1941
|
+
<button class="ghost sm c-ship" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
|
|
1058
1942
|
<button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
|
|
1059
1943
|
<button class="ghost xs c-queue" hidden>${icon('queue')}<span>Queue</span></button>
|
|
1060
1944
|
<button class="send" title="Send (Enter · Shift+Enter for newline)">${icon('enter')}</button>
|
|
@@ -1068,6 +1952,7 @@ function renderDock(id) {
|
|
|
1068
1952
|
acceptFiles(comp, ta, n.tray);
|
|
1069
1953
|
$('.c-attach', comp).onclick = n.tray.pick;
|
|
1070
1954
|
$('.c-done', comp).onclick = () => toggleDone(id);
|
|
1955
|
+
$('.c-ship', comp).onclick = () => shipDialog(id);
|
|
1071
1956
|
$('.c-queue', comp).onclick = toggleQueue;
|
|
1072
1957
|
ta.value = S.drafts.get(id) || '';
|
|
1073
1958
|
ta.addEventListener('input', () => S.drafts.set(id, ta.value));
|
|
@@ -1201,15 +2086,66 @@ async function toggleDone(id) {
|
|
|
1201
2086
|
if (!s) return;
|
|
1202
2087
|
if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
|
|
1203
2088
|
const next = s.state === 'done' ? 'review' : 'done';
|
|
2089
|
+
const leave = next === 'done' && handOff(id); // measured before the live update moves the card
|
|
1204
2090
|
await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
|
|
1205
|
-
if (
|
|
1206
|
-
|
|
1207
|
-
collapse(id);
|
|
1208
|
-
if (nextUp) setFocus(nextUp.id);
|
|
2091
|
+
if (leave) {
|
|
2092
|
+
leave(() => S.sessions.set(id, { ...S.sessions.get(id), state: 'done' }));
|
|
1209
2093
|
toast(`Marked done`, { action: { label: 'Undo', run: () => api(`/sessions/${id}`, { method: 'PATCH', body: { state: 'review' } }) } });
|
|
1210
2094
|
}
|
|
1211
2095
|
}
|
|
1212
2096
|
|
|
2097
|
+
// Every finished run of a compare task at once, with one toast to undo them all.
|
|
2098
|
+
async function taskDone(ids) {
|
|
2099
|
+
if (!ids.length) return;
|
|
2100
|
+
const card = S.nodes.get(ids[0])?.card.closest('.task-item');
|
|
2101
|
+
const next = card?.nextElementSibling?.querySelector('.item')?.dataset.id || card?.nextElementSibling?.dataset.id;
|
|
2102
|
+
const set = (state) => Promise.all(ids.map((id) => api(`/sessions/${id}`, { method: 'PATCH', body: { state } })));
|
|
2103
|
+
await set('done').catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
2104
|
+
for (const id of ids) S.sessions.set(id, { ...S.sessions.get(id), state: 'done' });
|
|
2105
|
+
renderFeed();
|
|
2106
|
+
if (next && S.nodes.has(next)) setFocus(next);
|
|
2107
|
+
toast(`Marked ${ids.length} runs done`, { action: { label: 'Undo', run: () => set('review') } });
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
async function revertFile(id, turnId, file, restore = false) {
|
|
2111
|
+
try {
|
|
2112
|
+
await api(`/sessions/${id}/revert`, { method: 'POST', body: { turnId, file, restore } });
|
|
2113
|
+
toast(restore ? `Re-applied ${esc(file)}` : `Reverted ${esc(file)}`, {
|
|
2114
|
+
action: { label: 'Undo', run: () => revertFile(id, turnId, file, !restore) },
|
|
2115
|
+
});
|
|
2116
|
+
} catch (err) {
|
|
2117
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
async function rollbackTurn(id, turnId, restore = false) {
|
|
2122
|
+
const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
|
|
2123
|
+
const turn = s?.turns?.find((t) => t.id === turnId) || s?.turns?.at(-1);
|
|
2124
|
+
if (!turn) return;
|
|
2125
|
+
try {
|
|
2126
|
+
const r = await api(`/sessions/${id}/rollback`, { method: 'POST', body: { turnId: turn.id, restore } });
|
|
2127
|
+
toast(
|
|
2128
|
+
restore
|
|
2129
|
+
? `Re-applied turn changes`
|
|
2130
|
+
: `Restored working tree to before this turn${r.head ? ` (${esc(r.head.slice(0, 7))})` : ''}`,
|
|
2131
|
+
{ action: { label: 'Undo', run: () => rollbackTurn(id, turn.id, !restore) } },
|
|
2132
|
+
);
|
|
2133
|
+
} catch (err) {
|
|
2134
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
async function execAction(id, turnId, command, key) {
|
|
2139
|
+
try {
|
|
2140
|
+
const r = await api(`/sessions/${id}/exec`, { method: 'POST', body: { command, turnId } });
|
|
2141
|
+
if (key) S.actionRuns.set(key, r);
|
|
2142
|
+
return r;
|
|
2143
|
+
} catch (err) {
|
|
2144
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2145
|
+
return null;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
1213
2149
|
async function takeOver(id) {
|
|
1214
2150
|
const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
|
|
1215
2151
|
if (!s?.agentSessionId) return;
|
|
@@ -1274,7 +2210,7 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
1274
2210
|
.join('')}
|
|
1275
2211
|
</div>
|
|
1276
2212
|
<div class="pk-main">
|
|
1277
|
-
<label class="pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
|
|
2213
|
+
<label class="field pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
|
|
1278
2214
|
<div class="pk-list" role="listbox" aria-label="Model"></div>
|
|
1279
2215
|
<div class="pk-effort" role="radiogroup" aria-label="Thinking level"></div>
|
|
1280
2216
|
</div>
|
|
@@ -1458,12 +2394,16 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
1458
2394
|
function moreMenu(id, anchor) {
|
|
1459
2395
|
const s = S.full.get(id);
|
|
1460
2396
|
if (!s) return;
|
|
2397
|
+
const lastTurn = s.turns?.at(-1);
|
|
2398
|
+
const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (lastTurn?.changes?.length || lastTurn?.checkpoint?.commit);
|
|
2399
|
+
const rolledBack = !!lastTurn?.rolledBackAt;
|
|
1461
2400
|
const r = anchor.getBoundingClientRect();
|
|
1462
2401
|
const menu = el(`
|
|
1463
2402
|
<div class="menu" style="top:${r.bottom + 6}px;right:${innerWidth - r.right}px">
|
|
1464
2403
|
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
1465
2404
|
${s.agentSessionId ? `<button data-a="copy">${icon('copy')}Copy resume command</button>` : ''}
|
|
1466
2405
|
<button data-a="folder">${icon('folder')}Open project in editor</button>
|
|
2406
|
+
${canRollback ? `<button data-a="rollback">${icon(rolledBack ? 'rerun' : 'replay')}${rolledBack ? 'Re-apply latest turn' : 'Restore to before latest turn'}</button>` : ''}
|
|
1467
2407
|
${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : s.state !== 'running' && s.state !== 'waiting' ? `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>` : ''}
|
|
1468
2408
|
<button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
|
|
1469
2409
|
</div>`);
|
|
@@ -1480,6 +2420,7 @@ function moreMenu(id, anchor) {
|
|
|
1480
2420
|
toast('Copied resume command');
|
|
1481
2421
|
}
|
|
1482
2422
|
if (a === 'folder') openInEditor(s, '.');
|
|
2423
|
+
if (a === 'rollback') rollbackTurn(id, lastTurn?.id, rolledBack);
|
|
1483
2424
|
if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
|
|
1484
2425
|
if (a === 'delete') deleteSession(id);
|
|
1485
2426
|
};
|
|
@@ -1497,6 +2438,10 @@ function taskMenu(id, e) {
|
|
|
1497
2438
|
${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
|
|
1498
2439
|
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
1499
2440
|
<button data-a="rerun">${icon('rerun')}Rerun…${keyHint('⇧R')}</button>
|
|
2441
|
+
${s.branch && !busy && s.pr?.state !== 'MERGED' ? `<button data-a="ship">${icon('send')}${s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…'}${keyHint('S')}</button>` : ''}
|
|
2442
|
+
${s.pr?.state === 'OPEN' ? `<button data-a="merge">${icon('check')}Merge PR #${s.pr.number}…</button>` : ''}
|
|
2443
|
+
${s.groupId ? `<button data-a="compare"><b class="vs-mark">vs</b>Compare runs</button>` : ''}
|
|
2444
|
+
<button data-a="recipe">${icon('spark')}Save as recipe…</button>
|
|
1500
2445
|
${busy || s.archived ? '' : `<button data-a="done">${icon('done')}${s.state === 'done' ? 'Reopen' : 'Mark done'}${keyHint('E')}</button>`}
|
|
1501
2446
|
${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : busy ? '' : `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>`}
|
|
1502
2447
|
<button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
|
|
@@ -1512,6 +2457,10 @@ function taskMenu(id, e) {
|
|
|
1512
2457
|
if (a === 'collapse') collapse(id);
|
|
1513
2458
|
if (a === 'rename') rename(id);
|
|
1514
2459
|
if (a === 'rerun') rerun(id);
|
|
2460
|
+
if (a === 'ship') shipDialog(id);
|
|
2461
|
+
if (a === 'merge') mergePr(id);
|
|
2462
|
+
if (a === 'compare') compareView(id);
|
|
2463
|
+
if (a === 'recipe') saveAsRecipe(id);
|
|
1515
2464
|
if (a === 'done') toggleDone(id);
|
|
1516
2465
|
if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
|
|
1517
2466
|
if (a === 'delete') deleteSession(id);
|
|
@@ -1528,7 +2477,9 @@ const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
|
|
|
1528
2477
|
|
|
1529
2478
|
async function deleteSession(id) {
|
|
1530
2479
|
if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
|
|
1531
|
-
|
|
2480
|
+
const leave = handOff(id);
|
|
2481
|
+
const ok = await api(`/sessions/${id}`, { method: 'DELETE' }).then(() => true, (err) => toast(esc(err.message), { tone: 'bad' }));
|
|
2482
|
+
if (ok) leave(() => S.sessions.delete(id));
|
|
1532
2483
|
}
|
|
1533
2484
|
|
|
1534
2485
|
// Launch the task again as a new session in the same folder: same prompt and attachments,
|
|
@@ -1573,11 +2524,7 @@ async function rerun(id) {
|
|
|
1573
2524
|
modeBtn.title = m.hint;
|
|
1574
2525
|
};
|
|
1575
2526
|
const runnerBtn = $('[data-a="runner"]', node);
|
|
1576
|
-
const paintRunner = () =>
|
|
1577
|
-
const a = S.agents.find((x) => x.id === agent);
|
|
1578
|
-
const m = (a?.models || []).find((x) => x.id === model);
|
|
1579
|
-
runnerBtn.innerHTML = `<span class="rb-logo">${harnessLogo(agent)}</span><span class="rb-h">${esc(a?.name || agentName(agent))}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${effort ? `<span class="rb-e">${esc(effortLabel(effort))}</span>` : ''}${icon('chevron', 'chev')}`;
|
|
1580
|
-
};
|
|
2527
|
+
const paintRunner = () => paintRunnerBtn(runnerBtn, agent, model, effort);
|
|
1581
2528
|
paintMode();
|
|
1582
2529
|
paintRunner();
|
|
1583
2530
|
modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
|
|
@@ -1622,13 +2569,14 @@ async function rerun(id) {
|
|
|
1622
2569
|
}
|
|
1623
2570
|
|
|
1624
2571
|
async function setArchived(id, archived) {
|
|
2572
|
+
const leave = archived && handOff(id);
|
|
1625
2573
|
try {
|
|
1626
2574
|
await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
|
|
1627
2575
|
} catch (err) {
|
|
1628
2576
|
return toast(esc(err.message), { tone: 'bad' });
|
|
1629
2577
|
}
|
|
1630
|
-
if (
|
|
1631
|
-
|
|
2578
|
+
if (leave) {
|
|
2579
|
+
leave(() => S.sessions.set(id, { ...S.sessions.get(id), archived: true }));
|
|
1632
2580
|
toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
|
|
1633
2581
|
}
|
|
1634
2582
|
}
|
|
@@ -1650,9 +2598,20 @@ const EDITORS = {
|
|
|
1650
2598
|
idea: (f, l) => `idea://open?file=${encodeURIComponent(f)}${l ? `&line=${l}` : ''}`,
|
|
1651
2599
|
};
|
|
1652
2600
|
|
|
2601
|
+
// VS Code and its forks open a file on a machine through their Remote - SSH extension.
|
|
2602
|
+
const REMOTE_EDITORS = new Set(['vscode', 'cursor', 'windsurf']);
|
|
2603
|
+
|
|
1653
2604
|
function openInEditor(s, p, line) {
|
|
1654
2605
|
const editor = S.config.defaults.editor || 'vscode';
|
|
1655
2606
|
const abs = p.startsWith('/') ? p : `${s.cwd}/${p}`.replace(/\/\.$/, '');
|
|
2607
|
+
if (s.machine) {
|
|
2608
|
+
if (!REMOTE_EDITORS.has(editor)) {
|
|
2609
|
+
copy(abs);
|
|
2610
|
+
return toast(`Copied path ${esc(abs)} (on ${esc(s.machine.name)})`);
|
|
2611
|
+
}
|
|
2612
|
+
location.href = `${editor}://vscode-remote/ssh-remote+${encodeURIComponent(s.machine.target)}${abs}${line ? `:${line}` : ''}`;
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
1656
2615
|
if (editor === 'none' || !EDITORS[editor]) {
|
|
1657
2616
|
copy(abs);
|
|
1658
2617
|
return toast(`Copied path ${esc(abs)}`);
|
|
@@ -1684,12 +2643,14 @@ function reveal(id) {
|
|
|
1684
2643
|
|
|
1685
2644
|
const COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
|
|
1686
2645
|
|
|
1687
|
-
|
|
2646
|
+
// where: a machine id to add folders on that computer (server/machines.js), else this one.
|
|
2647
|
+
async function openProjects({ focusAdd = false, machine = null, host } = {}) {
|
|
1688
2648
|
const node = el(`
|
|
1689
2649
|
<div class="modal projects-modal">
|
|
1690
2650
|
<header><h2>Projects</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
2651
|
+
<div class="mem-tabs where"></div>
|
|
1691
2652
|
<div class="add-row">
|
|
1692
|
-
<div class="path-input">
|
|
2653
|
+
<div class="field lg mono path-input">
|
|
1693
2654
|
${window.wispDesktop ? `<button class="browse" title="Browse…">${icon('folder')}</button>` : icon('folder')}
|
|
1694
2655
|
<input id="ppath" placeholder="~/code/my-app" spellcheck="false" autocomplete="off">
|
|
1695
2656
|
<div class="suggest" hidden></div>
|
|
@@ -1697,23 +2658,55 @@ async function openProjects({ focusAdd = false } = {}) {
|
|
|
1697
2658
|
${window.wispDesktop ? `<button class="ghost" id="pbrowse">${icon('folder')}Browse…</button>` : ''}
|
|
1698
2659
|
<button class="primary" id="padd">Add</button>
|
|
1699
2660
|
</div>
|
|
1700
|
-
<p class="hint"
|
|
2661
|
+
<p class="hint"></p>
|
|
1701
2662
|
<div class="plist" id="plist"></div>
|
|
1702
2663
|
<div class="discover" id="discover"><h4>Found on this machine</h4><p class="muted">Looking for git repos…</p></div>
|
|
1703
2664
|
</div>`);
|
|
1704
|
-
|
|
1705
|
-
$('.close', node).onclick = close;
|
|
2665
|
+
present(node, host);
|
|
1706
2666
|
const input = $('#ppath', node);
|
|
1707
2667
|
const sug = $('.suggest', node);
|
|
2668
|
+
let where = machine && machineById(machine) ? machine : null;
|
|
2669
|
+
const here = () => machineById(where);
|
|
2670
|
+
const home = () => (where ? here()?.remote?.home : S.config.home) || '';
|
|
2671
|
+
const tilde = (p) => (home() && p.startsWith(home()) ? `~${p.slice(home().length)}` : p);
|
|
2672
|
+
|
|
2673
|
+
const paintWhere = () => {
|
|
2674
|
+
const tabs = $('.where', node);
|
|
2675
|
+
tabs.innerHTML = `<button data-w="" class="${where ? '' : 'on'}">${icon('folder')}This computer</button>${S.machines
|
|
2676
|
+
.map((m) => `<button data-w="${m.id}" class="${where === m.id ? 'on' : ''}" title="${esc(m.target)} · ${esc(MACHINE_STATE[m.state] || m.state)}">${icon('server')}${esc(m.name)}<i class="dot ${m.state === 'online' ? 'ok' : m.state === 'error' ? 'bad' : ''}"></i></button>`)
|
|
2677
|
+
.join('')}<i></i>${host ? '' : `<button data-w="+" title="Run agents on another computer over SSH">${icon('plus')}Machine</button>`}`;
|
|
2678
|
+
const m = here();
|
|
2679
|
+
input.placeholder = m ? `~/code/my-app on ${m.name}` : '~/code/my-app';
|
|
2680
|
+
$$('#pbrowse, .browse', node).forEach((b) => (b.hidden = !!m));
|
|
2681
|
+
$('.hint', node).innerHTML = m
|
|
2682
|
+
? `Folders on <b>${esc(m.name)}</b>. Agents run there, in its own worktrees; you follow along here.`
|
|
2683
|
+
: 'Tip: run <code>wisp add</code> inside any folder, or launch from a terminal with <code>wisp "task"</code> — the folder is added automatically.';
|
|
2684
|
+
};
|
|
2685
|
+
$('.where', node).addEventListener('click', (e) => {
|
|
2686
|
+
const b = e.target.closest('[data-w]');
|
|
2687
|
+
if (!b) return;
|
|
2688
|
+
if (b.dataset.w === '+') {
|
|
2689
|
+
openMachines({ focusAdd: true });
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
where = b.dataset.w || null;
|
|
2693
|
+
sug.hidden = true;
|
|
2694
|
+
paintWhere();
|
|
2695
|
+
list();
|
|
2696
|
+
discover();
|
|
2697
|
+
input.focus();
|
|
2698
|
+
});
|
|
1708
2699
|
|
|
1709
2700
|
const add = async (p) => {
|
|
1710
2701
|
try {
|
|
1711
|
-
const proj = await api('/projects', { method: 'POST', body: { path: p } });
|
|
2702
|
+
const proj = await api('/projects', { method: 'POST', body: { path: p, ...(where && { machine: where }) } });
|
|
1712
2703
|
await loadConfig();
|
|
1713
|
-
|
|
1714
|
-
|
|
2704
|
+
launchPicks[placeOf(proj)] = proj.id;
|
|
2705
|
+
if (!launchWhere.includes(placeOf(proj))) launchWhere = [placeOf(proj)];
|
|
2706
|
+
if (placeOf(proj) === launchWhere[0]) launchProject = proj.id;
|
|
2707
|
+
saveWhere();
|
|
1715
2708
|
renderLaunchChips();
|
|
1716
|
-
toast(`Added <b>${esc(proj.name)}</b
|
|
2709
|
+
toast(`Added <b>${esc(proj.name)}</b>${where ? ` on ${esc(here()?.name || '')}` : ''}`);
|
|
1717
2710
|
input.value = '';
|
|
1718
2711
|
sug.hidden = true;
|
|
1719
2712
|
list();
|
|
@@ -1722,7 +2715,9 @@ async function openProjects({ focusAdd = false } = {}) {
|
|
|
1722
2715
|
toast(esc(err.message), { tone: 'bad' });
|
|
1723
2716
|
}
|
|
1724
2717
|
};
|
|
1725
|
-
$('#padd', node).onclick = () =>
|
|
2718
|
+
$('#padd', node).onclick = () => {
|
|
2719
|
+
if (input.value.trim()) add(input.value.trim());
|
|
2720
|
+
};
|
|
1726
2721
|
const browse = async () => {
|
|
1727
2722
|
const dir = await window.wispDesktop.pickFolder();
|
|
1728
2723
|
if (dir) add(dir);
|
|
@@ -1736,10 +2731,10 @@ async function openProjects({ focusAdd = false } = {}) {
|
|
|
1736
2731
|
t = setTimeout(async () => {
|
|
1737
2732
|
const q = input.value;
|
|
1738
2733
|
if (!q) return (sug.hidden = true);
|
|
1739
|
-
const dirs = await api(`/fs?q=${encodeURIComponent(q)}`).catch(() => []);
|
|
2734
|
+
const dirs = await api(`${where ? `/machines/${where}/fs` : '/fs'}?q=${encodeURIComponent(q)}`).catch(() => []);
|
|
1740
2735
|
sel = -1;
|
|
1741
2736
|
sug.hidden = !dirs.length;
|
|
1742
|
-
sug.innerHTML = dirs.map((d) => `<button data-p="${esc(d)}">${esc(d
|
|
2737
|
+
sug.innerHTML = dirs.map((d) => `<button data-p="${esc(d)}">${esc(tilde(d))}</button>`).join('');
|
|
1743
2738
|
}, 120);
|
|
1744
2739
|
});
|
|
1745
2740
|
input.addEventListener('keydown', (e) => {
|
|
@@ -1750,7 +2745,7 @@ async function openProjects({ focusAdd = false } = {}) {
|
|
|
1750
2745
|
opts.forEach((o, i) => o.classList.toggle('on', i === sel));
|
|
1751
2746
|
} else if (e.key === 'Tab' && opts.length) {
|
|
1752
2747
|
e.preventDefault();
|
|
1753
|
-
input.value = (opts[Math.max(0, sel)].dataset.p + '/')
|
|
2748
|
+
input.value = tilde(opts[Math.max(0, sel)].dataset.p + '/');
|
|
1754
2749
|
input.dispatchEvent(new Event('input'));
|
|
1755
2750
|
} else if (e.key === 'Enter') {
|
|
1756
2751
|
e.preventDefault();
|
|
@@ -1764,20 +2759,20 @@ async function openProjects({ focusAdd = false } = {}) {
|
|
|
1764
2759
|
|
|
1765
2760
|
const list = () => {
|
|
1766
2761
|
const box = $('#plist', node);
|
|
1767
|
-
const projects = S.config.projects;
|
|
2762
|
+
const projects = S.config.projects.filter((p) => (p.machine || null) === where);
|
|
1768
2763
|
box.innerHTML = projects.length
|
|
1769
2764
|
? projects
|
|
1770
2765
|
.map((p) => {
|
|
1771
2766
|
const n = [...S.sessions.values()].filter((s) => s.projectId === p.id).length;
|
|
1772
2767
|
return `<div class="prow c-${p.color}" data-id="${p.id}">
|
|
1773
2768
|
<button class="swatch" title="Change colour"><i class="p-dot"></i></button>
|
|
1774
|
-
<div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(p.path
|
|
2769
|
+
<div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(tilde(p.path))}</span></div>
|
|
1775
2770
|
<span class="pcount">${n} session${n === 1 ? '' : 's'}</span>
|
|
1776
2771
|
<button class="icon-btn premove" title="Remove from wisp (files untouched)">${icon('trash')}</button>
|
|
1777
2772
|
</div>`;
|
|
1778
2773
|
})
|
|
1779
2774
|
.join('')
|
|
1780
|
-
:
|
|
2775
|
+
: `<p class="muted">No projects ${where ? `on ${esc(here()?.name || '')} ` : ''}yet. Add a folder above, or pick one below.</p>`;
|
|
1781
2776
|
$$('.prow', box).forEach((row) => {
|
|
1782
2777
|
const id = row.dataset.id;
|
|
1783
2778
|
const p = projectById(id);
|
|
@@ -1797,50 +2792,1111 @@ async function openProjects({ focusAdd = false } = {}) {
|
|
|
1797
2792
|
|
|
1798
2793
|
const discover = async () => {
|
|
1799
2794
|
const box = $('#discover', node);
|
|
1800
|
-
const
|
|
1801
|
-
|
|
1802
|
-
? `<h4>Found on this machine <span class="muted">· git repos</span></h4><div class="found">${found
|
|
1803
|
-
.map((f) => `<button class="found-item" data-p="${esc(f.path)}">${icon('plus')}<b>${esc(f.name)}</b><span>${esc(f.path.replace(S.config.home, '~').replace(/\/[^/]+$/, ''))}</span></button>`)
|
|
1804
|
-
.join('')}</div>`
|
|
1805
|
-
: '';
|
|
2795
|
+
const m = here();
|
|
2796
|
+
const at = where;
|
|
1806
2797
|
box.onclick = (e) => {
|
|
1807
2798
|
const b = e.target.closest('.found-item');
|
|
1808
2799
|
if (b) add(b.dataset.p);
|
|
2800
|
+
if (e.target.closest('[data-a="connect"]')) connectMachine(at);
|
|
1809
2801
|
};
|
|
2802
|
+
if (m && m.state !== 'online') {
|
|
2803
|
+
box.innerHTML = `<p class="muted">${esc(m.name)} is ${m.state === 'connecting' || m.state === 'reconnecting' ? 'connecting…' : `offline. <button class="link" data-a="connect">Connect</button>`}</p>`;
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
box.innerHTML = `<h4>Found on ${m ? esc(m.name) : 'this machine'}</h4><p class="muted">Looking for git repos…</p>`;
|
|
2807
|
+
const found = await api(m ? `/machines/${m.id}/discover` : '/projects/discover').catch(() => []);
|
|
2808
|
+
if (where !== at) return; // switched tabs meanwhile
|
|
2809
|
+
box.innerHTML = found.length
|
|
2810
|
+
? `<h4>Found on ${m ? esc(m.name) : 'this machine'} <span class="muted">· git repos</span></h4><div class="found">${found
|
|
2811
|
+
.map((f) => `<button class="found-item" data-p="${esc(f.path)}">${icon('plus')}<b>${esc(f.name)}</b><span>${esc(tilde(f.path).replace(/\/[^/]+$/, ''))}</span></button>`)
|
|
2812
|
+
.join('')}</div>`
|
|
2813
|
+
: '';
|
|
2814
|
+
};
|
|
2815
|
+
let wasOnline = here()?.state === 'online';
|
|
2816
|
+
node.refresh = () => {
|
|
2817
|
+
paintWhere();
|
|
2818
|
+
const on = here()?.state === 'online';
|
|
2819
|
+
if (on !== wasOnline) discover();
|
|
2820
|
+
wasOnline = on;
|
|
1810
2821
|
};
|
|
1811
2822
|
|
|
2823
|
+
paintWhere();
|
|
1812
2824
|
list();
|
|
1813
2825
|
discover();
|
|
1814
2826
|
if (focusAdd) setTimeout(() => input.focus(), 60);
|
|
1815
2827
|
}
|
|
1816
2828
|
|
|
2829
|
+
// --- ship it: worktree → commit → PR → checks → merge ---------------------------------------
|
|
2830
|
+
|
|
2831
|
+
const prTone = (pr) =>
|
|
2832
|
+
pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.checks?.fail ? 'fail' : pr.checks?.pending ? 'pending' : pr.review === 'CHANGES_REQUESTED' ? 'fail' : 'pass';
|
|
2833
|
+
const REVIEW = { APPROVED: 'Approved', CHANGES_REQUESTED: 'Changes requested', REVIEW_REQUIRED: 'Review required' };
|
|
2834
|
+
function prLine(pr) {
|
|
2835
|
+
const c = pr.checks || {};
|
|
2836
|
+
const checks = c.fail ? `${c.fail} check${c.fail > 1 ? 's' : ''} failing` : c.pending ? `${c.pending} check${c.pending > 1 ? 's' : ''} running` : c.pass ? `${c.pass} check${c.pass > 1 ? 's' : ''} passed` : 'No checks';
|
|
2837
|
+
const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
|
|
2838
|
+
return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
|
|
2839
|
+
}
|
|
2840
|
+
const prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
|
|
2841
|
+
|
|
2842
|
+
// The PR row under a card's bar; reads the live summary, so polled check results show up.
|
|
2843
|
+
function renderPrStrip(id) {
|
|
2844
|
+
const n = S.nodes.get(id);
|
|
2845
|
+
const node = n?.body && $('.pr-strip', n.body);
|
|
2846
|
+
const s = S.sessions.get(id);
|
|
2847
|
+
if (!node || !s) return;
|
|
2848
|
+
const pr = s.pr;
|
|
2849
|
+
$$('[data-a="ship"] span', n.body).forEach((b) => (b.textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it'));
|
|
2850
|
+
node.hidden = !pr;
|
|
2851
|
+
if (!pr) return;
|
|
2852
|
+
const tone = prTone(pr);
|
|
2853
|
+
node.className = `pr-strip pr-${tone}`;
|
|
2854
|
+
node.innerHTML = `
|
|
2855
|
+
<span class="pr-dot"></span>
|
|
2856
|
+
<a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
|
|
2857
|
+
<span class="pr-line">${esc(prLine(pr))}</span>
|
|
2858
|
+
<span class="grow"></span>
|
|
2859
|
+
${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
|
|
2860
|
+
<a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>
|
|
2861
|
+
${pr.state === 'OPEN' ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge, then fast-forward your local ${esc(s.branch ? 'base branch' : 'checkout')}">${icon('check')}<span>Merge</span></button>` : ''}`;
|
|
2862
|
+
node.onclick = (e) => {
|
|
2863
|
+
const a = e.target.closest('[data-pr]')?.dataset.pr;
|
|
2864
|
+
if (a === 'refresh') api(`/sessions/${id}/pr`, { method: 'POST', body: {} }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
2865
|
+
if (a === 'merge') mergePr(id);
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
async function shipDialog(id) {
|
|
2870
|
+
const s = S.sessions.get(id);
|
|
2871
|
+
if (!s) return;
|
|
2872
|
+
if (s.state === 'running' || s.state === 'waiting') return toast('Still working: let it finish first.');
|
|
2873
|
+
let info;
|
|
2874
|
+
try {
|
|
2875
|
+
info = await api(`/sessions/${id}/ship`);
|
|
2876
|
+
} catch (err) {
|
|
2877
|
+
return toast(esc(err.message), { tone: 'bad' });
|
|
2878
|
+
}
|
|
2879
|
+
const open = info.pr?.state === 'OPEN';
|
|
2880
|
+
const nothing = !info.dirty && !info.ahead;
|
|
2881
|
+
const node = el(`
|
|
2882
|
+
<div class="modal ship-modal">
|
|
2883
|
+
<header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
2884
|
+
<p class="ship-route">${icon('branch')}<code>${esc(info.branch)}</code>${icon('arrow')}<code>${esc(info.remote || 'no remote')}/${esc(info.base)}</code>
|
|
2885
|
+
<span class="muted">${info.dirty ? `${info.dirty} uncommitted change${info.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${info.ahead} commit${info.ahead === 1 ? '' : 's'} on the branch</span></p>
|
|
2886
|
+
${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input class="input" name="title" spellcheck="false" maxlength="100"></label>` : ''}
|
|
2887
|
+
${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea class="textarea mono" name="body" rows="9" spellcheck="false"></textarea></label>
|
|
2888
|
+
<label class="check ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
|
|
2889
|
+
${!info.remote ? '<p class="hint bad">This repo has no <code>origin</code> remote, so there is nowhere to push.</p>' : nothing ? '<p class="hint">Nothing to ship yet: the branch has no changes.</p>' : ''}
|
|
2890
|
+
<div class="ship-foot"><span class="muted xs">Runs <code>git commit</code>, <code>git push</code>${open ? '' : ' and <code>gh pr create</code>'} in the worktree.</span><button class="primary sm" data-a="go" ${nothing || !info.remote ? 'disabled' : ''}>${icon('send')}${open ? 'Commit & push' : 'Commit, push & open PR'}</button></div>
|
|
2891
|
+
</div>`);
|
|
2892
|
+
const close = overlay(node);
|
|
2893
|
+
$('.close', node).onclick = close;
|
|
2894
|
+
const title = $('[name="title"]', node);
|
|
2895
|
+
const body = $('[name="body"]', node);
|
|
2896
|
+
if (title) title.value = info.title;
|
|
2897
|
+
if (body) body.value = info.body;
|
|
2898
|
+
const go = async () => {
|
|
2899
|
+
const btn = $('[data-a="go"]', node);
|
|
2900
|
+
btn.disabled = true;
|
|
2901
|
+
btn.lastChild.textContent = 'Shipping…';
|
|
2902
|
+
try {
|
|
2903
|
+
const pr = await api(`/sessions/${id}/ship`, { method: 'POST', body: { title: title?.value || info.title, body: body?.value ?? info.body, draft: !!$('[name="draft"]', node)?.checked } });
|
|
2904
|
+
close();
|
|
2905
|
+
toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
|
|
2906
|
+
} catch (err) {
|
|
2907
|
+
btn.disabled = false;
|
|
2908
|
+
btn.lastChild.textContent = open ? 'Commit & push' : 'Commit, push & open PR';
|
|
2909
|
+
toast(esc(err.message), { tone: 'bad', timeout: 10_000 });
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
$('[data-a="go"]', node).onclick = go;
|
|
2913
|
+
requestAnimationFrame(() => (title || $('[data-a="go"]', node)).focus());
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
async function mergePr(id) {
|
|
2917
|
+
const s = S.sessions.get(id);
|
|
2918
|
+
const pr = s?.pr;
|
|
2919
|
+
if (!pr || pr.state !== 'OPEN') return;
|
|
2920
|
+
const c = pr.checks || {};
|
|
2921
|
+
const warn = c.fail ? `\n\n⚠ ${c.fail} check${c.fail > 1 ? 's are' : ' is'} failing.` : c.pending ? `\n\n⚠ ${c.pending} check${c.pending > 1 ? 's are' : ' is'} still running.` : '';
|
|
2922
|
+
if (!confirm(`Squash-merge PR #${pr.number}${pr.draft ? ' (marks it ready first)' : ''}, then fast-forward your local base branch and remove the worktree?${warn}`)) return;
|
|
2923
|
+
toast(`Merging PR #${pr.number}…`);
|
|
2924
|
+
try {
|
|
2925
|
+
const r = await api(`/sessions/${id}/merge`, { method: 'POST', body: {} });
|
|
2926
|
+
toast(`${icon('check')} Merged <b>PR #${r.pr.number}</b>. ${esc(r.sync.text)}`, { tone: r.sync.ok ? '' : 'bad', timeout: 10_000 });
|
|
2927
|
+
} catch (err) {
|
|
2928
|
+
toast(esc(err.message), { tone: 'bad', timeout: 12_000 });
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
// --- compare: one task, several harnesses, side by side -------------------------------------------
|
|
2933
|
+
|
|
2934
|
+
function turnMetrics(t) {
|
|
2935
|
+
const lines = (t?.changes || []).reduce((acc, c) => {
|
|
2936
|
+
const n = countChanges(c.edits);
|
|
2937
|
+
return { add: acc.add + n.add, del: acc.del + n.del };
|
|
2938
|
+
}, { add: 0, del: 0 });
|
|
2939
|
+
const tests = (t?.presentation?.blocks || []).filter((b) => b?.type === 'tests');
|
|
2940
|
+
return {
|
|
2941
|
+
time: t?.endedAt ? t.endedAt - t.startedAt : null,
|
|
2942
|
+
cost: t?.stats?.costUsd,
|
|
2943
|
+
files: t?.changes?.length || 0,
|
|
2944
|
+
...lines,
|
|
2945
|
+
tests: tests.length ? { passed: tests.reduce((a, b) => a + (Number(b.passed) || 0), 0), failed: tests.reduce((a, b) => a + (Number(b.failed) || 0), 0) } : null,
|
|
2946
|
+
asks: (t?.activity || []).filter((a) => a.kind === 'ask').length,
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
async function compareView(id) {
|
|
2951
|
+
const s0 = S.sessions.get(id);
|
|
2952
|
+
if (!s0?.groupId) return;
|
|
2953
|
+
const group = [...S.sessions.values()].filter((x) => x.groupId === s0.groupId).sort((a, b) => a.createdAt - b.createdAt);
|
|
2954
|
+
const fulls = (await Promise.all(group.map((g) => api(`/sessions/${g.id}`).catch(() => null)))).filter(Boolean);
|
|
2955
|
+
const node = el(`
|
|
2956
|
+
<div class="modal compare-modal">
|
|
2957
|
+
<header><h2>Compare</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
2958
|
+
<p class="cmp-prompt">${esc(fulls[0]?.turns[0]?.prompt || '')}</p>
|
|
2959
|
+
<div class="cmp-grid" style="--n:${fulls.length}"></div>
|
|
2960
|
+
</div>`);
|
|
2961
|
+
const close = overlay(node);
|
|
2962
|
+
$('.close', node).onclick = close;
|
|
2963
|
+
const grid = $('.cmp-grid', node);
|
|
2964
|
+
for (const f of fulls) {
|
|
2965
|
+
const sum = S.sessions.get(f.id) || {};
|
|
2966
|
+
const t = f.turns.at(-1);
|
|
2967
|
+
const mx = turnMetrics(t);
|
|
2968
|
+
const live = f.state === 'running' || f.state === 'waiting';
|
|
2969
|
+
const col = el(`
|
|
2970
|
+
<section class="cmp-col" data-id="${f.id}">
|
|
2971
|
+
<div class="cmp-head">
|
|
2972
|
+
<span class="rb-logo">${harnessLogo(f.agent)}</span>
|
|
2973
|
+
<span class="cmp-who"><b>${esc(agentName(f.agent))}</b><small>${esc(f.model || 'Default model')}${f.effort ? ` · ${esc(effortLabel(f.effort))}` : ''}</small></span>
|
|
2974
|
+
${live ? `<span class="pill st-waiting"><i></i>${f.state === 'waiting' ? 'Asking' : 'Working'}</span>` : `<span class="pill st-${sum.status || 'info'}"><i></i>${STATUS_LABEL[sum.status] || 'Done'}</span>`}
|
|
2975
|
+
</div>
|
|
2976
|
+
<dl class="cmp-stats">
|
|
2977
|
+
<div><dt>Time</dt><dd>${mx.time !== null ? fmtDuration(mx.time) : '—'}</dd></div>
|
|
2978
|
+
<div><dt>Cost</dt><dd>${typeof mx.cost === 'number' ? `$${mx.cost.toFixed(2)}` : '—'}</dd></div>
|
|
2979
|
+
<div><dt>Files</dt><dd>${mx.files} <span class="plus">+${mx.add}</span> <span class="minus">−${mx.del}</span></dd></div>
|
|
2980
|
+
<div><dt>Tests</dt><dd>${mx.tests ? `<span class="${mx.tests.failed ? 'minus' : 'plus'}">${mx.tests.passed} ✓ ${mx.tests.failed ? `${mx.tests.failed} ✗` : ''}</span>` : '—'}</dd></div>
|
|
2981
|
+
<div><dt>Asked</dt><dd>${mx.asks}</dd></div>
|
|
2982
|
+
</dl>
|
|
2983
|
+
<div class="cmp-body"></div>
|
|
2984
|
+
<div class="cmp-foot">
|
|
2985
|
+
<button class="ghost xs" data-a="open">${icon('chevron')}Open card</button>
|
|
2986
|
+
<span class="grow"></span>
|
|
2987
|
+
<button class="primary sm" data-a="keep" ${live ? 'disabled title="Still working"' : ''}>${icon('check')}Keep this</button>
|
|
2988
|
+
</div>
|
|
2989
|
+
</section>`);
|
|
2990
|
+
$('.cmp-body', col).append(live ? el(`<p class="muted xs">${esc(sum.live || 'Working…')}</p>`) : renderPresentation(t?.presentation, ctxFor(f, t, false)));
|
|
2991
|
+
grid.append(col);
|
|
2992
|
+
}
|
|
2993
|
+
grid.onclick = async (e) => {
|
|
2994
|
+
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
2995
|
+
const keep = e.target.closest('.cmp-col')?.dataset.id;
|
|
2996
|
+
if (!a || !keep) return;
|
|
2997
|
+
if (a === 'open') return close(), reveal(keep);
|
|
2998
|
+
const others = group.filter((g) => g.id !== keep);
|
|
2999
|
+
if (!confirm(`Keep ${agentName(S.sessions.get(keep)?.agent)}'s result?\n\nThe other ${others.length === 1 ? 'run is' : `${others.length} runs are`} archived, and ${others.length === 1 ? 'its worktree is' : 'their worktrees are'} deleted with their changes.`)) return;
|
|
3000
|
+
for (const o of others) {
|
|
3001
|
+
if (o.state === 'running' || o.state === 'waiting') {
|
|
3002
|
+
await api(`/sessions/${o.id}/stop`, { method: 'POST', body: {} }).catch(() => {});
|
|
3003
|
+
await until(() => S.sessions.get(o.id)?.state !== 'running', 5000);
|
|
3004
|
+
}
|
|
3005
|
+
await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
3006
|
+
}
|
|
3007
|
+
close();
|
|
3008
|
+
reveal(keep);
|
|
3009
|
+
toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
|
|
3010
|
+
};
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
// Poll a condition for up to `ms` (used after asking the server to stop something).
|
|
3014
|
+
async function until(fn, ms) {
|
|
3015
|
+
const end = Date.now() + ms;
|
|
3016
|
+
while (Date.now() < end && !fn()) await new Promise((r) => setTimeout(r, 150));
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
// --- automations: recipes (saved prompts) and triggers (schedules, GitHub watchers) -----------
|
|
3020
|
+
|
|
3021
|
+
const TRIGGER_KINDS = [
|
|
3022
|
+
{ kind: 'schedule', label: 'Schedule', icon: 'clock', hint: 'On a cron schedule, local time' },
|
|
3023
|
+
{ kind: 'ci', label: 'CI failed', icon: 'warn', hint: 'A GitHub Actions run fails (polled with gh)' },
|
|
3024
|
+
{ kind: 'issues', label: 'Issue labelled', icon: 'ask', hint: 'An open issue gets a label (polled with gh)' },
|
|
3025
|
+
];
|
|
3026
|
+
const CRON_PRESETS = [
|
|
3027
|
+
['Weekdays 9:00', '0 9 * * 1-5'],
|
|
3028
|
+
['Every hour', '0 * * * *'],
|
|
3029
|
+
['Daily 18:00', '0 18 * * *'],
|
|
3030
|
+
['Mondays 8:00', '0 8 * * 1'],
|
|
3031
|
+
];
|
|
3032
|
+
const recipeParams = (prompt) => [...new Set([...String(prompt || '').matchAll(/\{([a-zA-Z][\w-]{0,30})\}/g)].map((m) => m[1]))];
|
|
3033
|
+
const whenLabel = (t) => {
|
|
3034
|
+
if (!t) return '';
|
|
3035
|
+
const d = new Date(t);
|
|
3036
|
+
const today = new Date().toDateString() === d.toDateString();
|
|
3037
|
+
return d.toLocaleString([], today ? { hour: '2-digit', minute: '2-digit' } : { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
3038
|
+
};
|
|
3039
|
+
const projectOptions = (selected, { any } = {}) =>
|
|
3040
|
+
(any ? `<option value="">${esc(any)}</option>` : '') +
|
|
3041
|
+
S.config.projects.map((p) => `<option value="${esc(p.path)}" ${p.path === selected ? 'selected' : ''}>${esc(p.name)}</option>`).join('');
|
|
3042
|
+
const modeOptions = (selected, { inherit } = {}) =>
|
|
3043
|
+
(inherit ? `<option value="">${esc(inherit)}</option>` : '') + MODES.map((m) => `<option value="${m.id}" ${m.id === selected ? 'selected' : ''}>${m.label}</option>`).join('');
|
|
3044
|
+
|
|
3045
|
+
function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
|
|
3046
|
+
const node = el(`<div class="modal auto-modal">
|
|
3047
|
+
<header><h2>Automations</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
3048
|
+
<p class="mem-lede">Recipes are prompts you send again and again; <code>{name}</code> in one becomes a field to fill in. Triggers start sessions on their own.</p>
|
|
3049
|
+
<div class="mem-tabs" role="tablist"></div>
|
|
3050
|
+
<div class="auto-body"></div>
|
|
3051
|
+
</div>`);
|
|
3052
|
+
const close = present(node, host);
|
|
3053
|
+
let recipes = [];
|
|
3054
|
+
let triggers = [];
|
|
3055
|
+
let editing = edit; // { type: 'recipe' | 'trigger', draft }
|
|
3056
|
+
|
|
3057
|
+
const render = async () => {
|
|
3058
|
+
[recipes, triggers] = await Promise.all([api('/recipes').catch(() => recipes), api('/triggers').catch(() => triggers)]);
|
|
3059
|
+
if (!node.isConnected) return;
|
|
3060
|
+
$('.mem-tabs', node).innerHTML = [
|
|
3061
|
+
['recipes', 'Recipes', recipes.length],
|
|
3062
|
+
['triggers', 'Triggers', triggers.length],
|
|
3063
|
+
].map(([k, l, n]) => `<button role="tab" data-tab="${k}" class="${tab === k ? 'on' : ''}">${l}<span>${n}</span></button>`).join('');
|
|
3064
|
+
const body = $('.auto-body', node);
|
|
3065
|
+
body.innerHTML = '';
|
|
3066
|
+
if (editing?.type === 'recipe') body.append(recipeForm(editing.draft));
|
|
3067
|
+
else if (editing?.type === 'trigger') body.append(triggerForm(editing.draft));
|
|
3068
|
+
else body.innerHTML = tab === 'recipes' ? recipesView() : triggersView();
|
|
3069
|
+
};
|
|
3070
|
+
|
|
3071
|
+
const recipesView = () =>
|
|
3072
|
+
`<div class="au-list">${
|
|
3073
|
+
recipes
|
|
3074
|
+
.map((r) => {
|
|
3075
|
+
const proj = S.config.projects.find((p) => p.path === r.cwd);
|
|
3076
|
+
const tags = [r.params.length ? r.params.map((p) => `{${p}}`).join(' ') : '', proj ? proj.name : 'Any project', r.agent ? agentName(r.agent) : '', r.runs ? `${r.runs} run${r.runs > 1 ? 's' : ''}` : ''].filter(Boolean);
|
|
3077
|
+
return `<div class="au-item" data-id="${r.id}">
|
|
3078
|
+
<span class="mem-ico">${icon('spark')}</span>
|
|
3079
|
+
<div class="au-main"><b>${esc(r.name)}</b><p>${esc(r.prompt.replace(/\s+/g, ' ').slice(0, 140))}</p><span class="au-tags">${tags.map((t) => `<i>${esc(t)}</i>`).join('')}</span></div>
|
|
3080
|
+
<span class="au-acts"><button class="primary sm" data-act="run">Run</button><button class="icon-btn" data-act="schedule" title="Run it on a schedule">${icon('clock')}</button><button class="icon-btn" data-act="edit" title="Edit">${icon('pen')}</button><button class="icon-btn" data-act="delete" title="Delete">${icon('trash')}</button></span>
|
|
3081
|
+
</div>`;
|
|
3082
|
+
})
|
|
3083
|
+
.join('') || '<p class="mem-empty">No recipes yet. Save a task you repeat, like a release checklist, a PR from a commit, or a dependency bump.</p>'
|
|
3084
|
+
}</div><button class="mem-add" data-act="new">${icon('plus')}New recipe</button>
|
|
3085
|
+
<p class="hint">Also from the terminal: <code>wisp recipes</code>, <code>wisp run <name> [values…]</code>.</p>`;
|
|
3086
|
+
|
|
3087
|
+
const describe = (t) => {
|
|
3088
|
+
const proj = S.config.projects.find((p) => p.path === t.cwd)?.name || t.cwd.split(/[\\/]/).pop();
|
|
3089
|
+
if (t.kind === 'schedule') return `<code>${esc(t.cron)}</code> in ${esc(proj)}${t.nextAt ? ` · next ${esc(whenLabel(t.nextAt))}` : ''}`;
|
|
3090
|
+
const what = t.kind === 'ci' ? `failed CI${t.branch ? ` on <code>${esc(t.branch)}</code>` : ''}` : `issues labelled <code>${esc(t.label)}</code>`;
|
|
3091
|
+
return `Watches ${what} in ${esc(proj)} every ${t.every} min${t.seeded ? '' : ' · first check records what is already there'}`;
|
|
3092
|
+
};
|
|
3093
|
+
const triggersView = () =>
|
|
3094
|
+
`<div class="au-list">${
|
|
3095
|
+
triggers
|
|
3096
|
+
.map((t) => {
|
|
3097
|
+
const k = TRIGGER_KINDS.find((x) => x.kind === t.kind);
|
|
3098
|
+
const last = t.lastSessionId && S.sessions.has(t.lastSessionId) ? `<a href="#" data-session="${esc(t.lastSessionId)}">last run ${ago(t.lastRunAt)} ago</a>` : t.lastRunAt ? `last run ${ago(t.lastRunAt)} ago` : 'not run yet';
|
|
3099
|
+
return `<div class="au-item ${t.enabled ? '' : 'off'}" data-id="${t.id}">
|
|
3100
|
+
<span class="mem-ico">${icon(k.icon)}</span>
|
|
3101
|
+
<div class="au-main"><b>${esc(t.name)}</b><p>${describe(t)}</p><span class="au-tags"><i>${esc(MODES.find((m) => m.id === t.mode)?.label || t.mode)}</i>${t.agent ? `<i>${esc(agentName(t.agent))}</i>` : ''}<i>${last}</i></span>${t.lastError ? `<p class="au-err">${icon('warn')}${esc(t.lastError)}</p>` : ''}</div>
|
|
3102
|
+
<span class="au-acts"><button class="ghost xs" data-act="fire" title="${t.kind === 'schedule' ? 'Run it now' : 'Check GitHub now'}">${icon('replay')}${t.kind === 'schedule' ? 'Run now' : 'Check now'}</button><button class="switch" data-act="toggle" role="switch" aria-checked="${t.enabled}" title="On / off"></button><button class="icon-btn" data-act="edit" title="Edit">${icon('pen')}</button><button class="icon-btn" data-act="delete" title="Delete">${icon('trash')}</button></span>
|
|
3103
|
+
</div>`;
|
|
3104
|
+
})
|
|
3105
|
+
.join('') || '<p class="mem-empty">No triggers yet. Run a recipe every weekday morning, or send an agent after every red CI run.</p>'
|
|
3106
|
+
}</div><button class="mem-add" data-act="new">${icon('plus')}New trigger</button>
|
|
3107
|
+
<p class="hint">Triggers run while wisp is open. GitHub watchers use your <code>gh</code> login and poll, so nothing has to reach this machine.</p>`;
|
|
3108
|
+
|
|
3109
|
+
// Harness · model button for a form, with "Launcher default" when no agent is set.
|
|
3110
|
+
const runnerField = (draft) => {
|
|
3111
|
+
const wrap = el(`<div class="au-runner"><button type="button" class="runner-btn" aria-haspopup="dialog"></button><button type="button" class="link xs" ${draft.agent ? '' : 'hidden'}>Use launcher default</button></div>`);
|
|
3112
|
+
const [btn, reset] = wrap.children;
|
|
3113
|
+
const paint = () => (paintRunnerBtn(btn, draft.agent, draft.model, draft.effort), (reset.hidden = !draft.agent));
|
|
3114
|
+
btn.onclick = () =>
|
|
3115
|
+
runnerPicker(btn, { agent: draft.agent || S.config.defaults.agent, model: draft.model, effort: draft.effort, savedModel, savedEffort }, (a, m, e) => {
|
|
3116
|
+
Object.assign(draft, { agent: a, model: m, effort: e });
|
|
3117
|
+
paint();
|
|
3118
|
+
});
|
|
3119
|
+
reset.onclick = () => (Object.assign(draft, { agent: '', model: '', effort: '' }), paint());
|
|
3120
|
+
paint();
|
|
3121
|
+
return wrap;
|
|
3122
|
+
};
|
|
3123
|
+
|
|
3124
|
+
const recipeForm = (d) => {
|
|
3125
|
+
const f = el(`<form class="au-form">
|
|
3126
|
+
<label><span>Name</span><input class="input" name="name" maxlength="60" placeholder="Release" spellcheck="false"></label>
|
|
3127
|
+
<label class="stack"><span>Prompt <small class="muted">· <code>{version}</code> becomes a field</small></span><textarea class="textarea mono" name="prompt" rows="7" spellcheck="false" placeholder="Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}…"></textarea></label>
|
|
3128
|
+
<p class="au-params muted xs"></p>
|
|
3129
|
+
<label><span>Project</span><select name="cwd">${projectOptions(d.cwd, { any: 'Any project (picked when run)' })}</select></label>
|
|
3130
|
+
<label><span>Mode</span><select name="mode">${modeOptions(d.mode, { inherit: 'Launcher default' })}</select></label>
|
|
3131
|
+
<div class="au-row"><span>Harness</span></div>
|
|
3132
|
+
<div class="au-foot"><button type="button" class="ghost sm" data-act="cancel">Cancel</button><button class="primary sm">${d.id ? 'Save' : 'Create recipe'}</button></div>
|
|
3133
|
+
</form>`);
|
|
3134
|
+
$('[name="name"]', f).value = d.name || '';
|
|
3135
|
+
$('[name="prompt"]', f).value = d.prompt || '';
|
|
3136
|
+
$('.au-row', f).append(runnerField(d));
|
|
3137
|
+
const params = () => {
|
|
3138
|
+
const p = recipeParams($('[name="prompt"]', f).value);
|
|
3139
|
+
$('.au-params', f).innerHTML = p.length ? `Fields when run: ${p.map((x) => `<code>{${esc(x)}}</code>`).join(' ')}` : 'No fields: it runs as written.';
|
|
3140
|
+
};
|
|
3141
|
+
$('[name="prompt"]', f).oninput = params;
|
|
3142
|
+
params();
|
|
3143
|
+
f.onsubmit = async (e) => {
|
|
3144
|
+
e.preventDefault();
|
|
3145
|
+
const body = { name: $('[name="name"]', f).value, prompt: $('[name="prompt"]', f).value, cwd: $('[name="cwd"]', f).value, mode: $('[name="mode"]', f).value, agent: d.agent || '', model: d.model || '', effort: d.effort || '' };
|
|
3146
|
+
try {
|
|
3147
|
+
await api(d.id ? `/recipes/${d.id}` : '/recipes', { method: d.id ? 'PATCH' : 'POST', body });
|
|
3148
|
+
editing = null;
|
|
3149
|
+
tab = 'recipes';
|
|
3150
|
+
render();
|
|
3151
|
+
} catch (err) {
|
|
3152
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3153
|
+
}
|
|
3154
|
+
};
|
|
3155
|
+
requestAnimationFrame(() => $(d.name ? '[name="prompt"]' : '[name="name"]', f)?.focus());
|
|
3156
|
+
return f;
|
|
3157
|
+
};
|
|
3158
|
+
|
|
3159
|
+
const triggerForm = (d) => {
|
|
3160
|
+
d.kind ||= 'schedule';
|
|
3161
|
+
const f = el(`<form class="au-form">
|
|
3162
|
+
<div class="au-kinds">${TRIGGER_KINDS.map((k) => `<button type="button" data-kind="${k.kind}" class="${k.kind === d.kind ? 'on' : ''}">${icon(k.icon)}<b>${k.label}</b><small>${k.hint}</small></button>`).join('')}</div>
|
|
3163
|
+
<label><span>Name</span><input class="input" name="name" maxlength="60" spellcheck="false"></label>
|
|
3164
|
+
<label><span>Project</span><select name="cwd">${projectOptions(d.cwd || projectById(launchProject)?.path)}</select></label>
|
|
3165
|
+
<div class="k-schedule"><label><span>When</span><input class="input mono" name="cron" spellcheck="false" placeholder="0 9 * * 1-5"></label>
|
|
3166
|
+
<p class="au-presets">${CRON_PRESETS.map(([l, c]) => `<button type="button" class="lp-chip" data-cron="${c}">${l}</button>`).join('')}<span class="muted xs">minute hour day month weekday</span></p></div>
|
|
3167
|
+
<div class="k-ci"><label><span>Branch</span><input class="input mono" name="branch" spellcheck="false" placeholder="Any branch (e.g. main)"></label></div>
|
|
3168
|
+
<div class="k-issues"><label><span>Label</span><input class="input" name="label" spellcheck="false" placeholder="wisp"></label></div>
|
|
3169
|
+
<div class="k-gh"><label><span>Check every</span><select name="every">${[2, 5, 15, 60].map((m) => `<option value="${m}" ${Number(d.every || 5) === m ? 'selected' : ''}>${m} min</option>`).join('')}</select></label></div>
|
|
3170
|
+
<label><span>Run</span><select name="recipeId"><option value="">This prompt</option>${recipes.map((r) => `<option value="${r.id}" ${r.id === d.recipeId ? 'selected' : ''}>Recipe: ${esc(r.name)}</option>`).join('')}</select></label>
|
|
3171
|
+
<label class="stack k-prompt"><span>Prompt <small class="muted k-vars"></small></span><textarea class="textarea mono" name="prompt" rows="6" spellcheck="false"></textarea></label>
|
|
3172
|
+
<label><span>Mode</span><select name="mode">${modeOptions(d.mode || (d.kind === 'issues' ? 'plan' : 'edits'))}</select></label>
|
|
3173
|
+
<div class="au-row"><span>Harness</span></div>
|
|
3174
|
+
<p class="au-warn" hidden>${icon('warn')}<span>Issue text comes from GitHub: anyone who can open and label issues can steer this agent. Plan mode only reads; Full auto runs shell commands without asking.</span></p>
|
|
3175
|
+
<div class="au-foot"><button type="button" class="ghost sm" data-act="cancel">Cancel</button><button class="primary sm">${d.id ? 'Save' : 'Create trigger'}</button></div>
|
|
3176
|
+
</form>`);
|
|
3177
|
+
for (const k of ['name', 'cron', 'branch', 'label']) $(`[name="${k}"]`, f).value = d[k] || '';
|
|
3178
|
+
$('[name="prompt"]', f).value = d.prompt || '';
|
|
3179
|
+
$('.au-row', f).append(runnerField(d));
|
|
3180
|
+
const sync = () => {
|
|
3181
|
+
for (const k of ['schedule', 'ci', 'issues']) $(`.k-${k}`, f).hidden = d.kind !== k;
|
|
3182
|
+
$('.k-gh', f).hidden = d.kind === 'schedule';
|
|
3183
|
+
$('.k-prompt', f).hidden = !!$('[name="recipeId"]', f).value;
|
|
3184
|
+
$('.k-vars', f).innerHTML = d.kind === 'ci' ? '· <code>{name} {branch} {url} {id} {sha}</code> · empty = built-in CI prompt' : d.kind === 'issues' ? '· <code>{number} {title} {body} {url} {author}</code> · empty = built-in issue prompt' : '';
|
|
3185
|
+
$('.au-warn', f).hidden = !(d.kind === 'issues' && $('[name="mode"]', f).value !== 'plan');
|
|
3186
|
+
$$('.au-kinds button', f).forEach((b) => b.classList.toggle('on', b.dataset.kind === d.kind));
|
|
3187
|
+
};
|
|
3188
|
+
f.addEventListener('click', (e) => {
|
|
3189
|
+
const k = e.target.closest('[data-kind]')?.dataset.kind;
|
|
3190
|
+
if (k) {
|
|
3191
|
+
if (k === 'issues' && !d.id) $('[name="mode"]', f).value = 'plan';
|
|
3192
|
+
d.kind = k;
|
|
3193
|
+
sync();
|
|
3194
|
+
}
|
|
3195
|
+
const c = e.target.closest('[data-cron]')?.dataset.cron;
|
|
3196
|
+
if (c) $('[name="cron"]', f).value = c;
|
|
3197
|
+
});
|
|
3198
|
+
f.addEventListener('change', sync);
|
|
3199
|
+
sync();
|
|
3200
|
+
f.onsubmit = async (e) => {
|
|
3201
|
+
e.preventDefault();
|
|
3202
|
+
const v = (k) => $(`[name="${k}"]`, f).value;
|
|
3203
|
+
const body = { kind: d.kind, name: v('name'), cwd: v('cwd'), cron: v('cron'), branch: v('branch'), label: v('label') || 'wisp', every: Number(v('every')), recipeId: v('recipeId'), prompt: v('recipeId') ? '' : v('prompt'), mode: v('mode'), agent: d.agent || '', model: d.model || '', effort: d.effort || '' };
|
|
3204
|
+
try {
|
|
3205
|
+
await api(d.id ? `/triggers/${d.id}` : '/triggers', { method: d.id ? 'PATCH' : 'POST', body });
|
|
3206
|
+
editing = null;
|
|
3207
|
+
tab = 'triggers';
|
|
3208
|
+
render();
|
|
3209
|
+
} catch (err) {
|
|
3210
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3211
|
+
}
|
|
3212
|
+
};
|
|
3213
|
+
return f;
|
|
3214
|
+
};
|
|
3215
|
+
|
|
3216
|
+
node.addEventListener('click', async (e) => {
|
|
3217
|
+
const t = e.target.closest('[data-tab],[data-act],[data-session]');
|
|
3218
|
+
if (!t || t.closest('.au-form') && !t.dataset.act) return;
|
|
3219
|
+
if (t.dataset.tab) return e.preventDefault(), (tab = t.dataset.tab), (editing = null), render();
|
|
3220
|
+
if (t.dataset.session) return e.preventDefault(), close(), reveal(t.dataset.session);
|
|
3221
|
+
const act = t.dataset.act;
|
|
3222
|
+
if (act === 'cancel') return (editing = null), render();
|
|
3223
|
+
const itemId = t.closest('.au-item')?.dataset.id;
|
|
3224
|
+
if (tab === 'recipes') {
|
|
3225
|
+
const r = recipes.find((x) => x.id === itemId);
|
|
3226
|
+
if (act === 'new') return (editing = { type: 'recipe', draft: { cwd: projectById(launchProject)?.path || '' } }), render();
|
|
3227
|
+
if (act === 'edit') return (editing = { type: 'recipe', draft: { ...r } }), render();
|
|
3228
|
+
if (act === 'run') return close(), runRecipe(r);
|
|
3229
|
+
if (act === 'schedule') return (tab = 'triggers'), (editing = { type: 'trigger', draft: { kind: 'schedule', name: r.name, recipeId: r.id, cwd: r.cwd || projectById(launchProject)?.path, mode: r.mode || undefined } }), render();
|
|
3230
|
+
if (act === 'delete' && confirm(`Delete the recipe “${r.name}”?`)) return api(`/recipes/${r.id}`, { method: 'DELETE' }).then(render);
|
|
3231
|
+
} else {
|
|
3232
|
+
const tr = triggers.find((x) => x.id === itemId);
|
|
3233
|
+
if (act === 'new') return (editing = { type: 'trigger', draft: {} }), render();
|
|
3234
|
+
if (act === 'edit') return (editing = { type: 'trigger', draft: { ...tr } }), render();
|
|
3235
|
+
if (act === 'toggle') return api(`/triggers/${tr.id}`, { method: 'PATCH', body: { enabled: !tr.enabled } }).then(render).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
3236
|
+
if (act === 'delete' && confirm(`Delete the trigger “${tr.name}”?`)) return api(`/triggers/${tr.id}`, { method: 'DELETE' }).then(render);
|
|
3237
|
+
if (act === 'fire') {
|
|
3238
|
+
t.disabled = true;
|
|
3239
|
+
try {
|
|
3240
|
+
const r = await api(`/triggers/${tr.id}/run`, { method: 'POST', body: {} });
|
|
3241
|
+
for (const x of r.started) S.sessions.set(x.id, x);
|
|
3242
|
+
renderFeed();
|
|
3243
|
+
toast(r.started.length ? `Started ${r.started.length} session${r.started.length > 1 ? 's' : ''}` : 'Nothing new to act on.');
|
|
3244
|
+
} catch (err) {
|
|
3245
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3246
|
+
}
|
|
3247
|
+
render();
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
3250
|
+
});
|
|
3251
|
+
node.refresh = () => !editing && render();
|
|
3252
|
+
render();
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
// Run a recipe: show it, ask for its fields (and a project, if it isn't tied to one), launch on Run.
|
|
3256
|
+
function runRecipe(r) {
|
|
3257
|
+
if (!r) return;
|
|
3258
|
+
const needsProject = !r.cwd;
|
|
3259
|
+
const node = el(`
|
|
3260
|
+
<form class="modal run-modal">
|
|
3261
|
+
<header><h2>${esc(r.name)}</h2><button type="button" class="icon-btn close">${icon('x')}</button></header>
|
|
3262
|
+
<p class="cmp-prompt">${esc(r.prompt)}</p>
|
|
3263
|
+
${r.params.map((p) => `<label class="ship-field"><span>${esc(p)}</span><input class="input" name="p:${esc(p)}" spellcheck="false" required></label>`).join('')}
|
|
3264
|
+
${needsProject ? `<label class="ship-field"><span>Project</span><select name="cwd">${projectOptions(projectById(launchProject)?.path)}</select></label>` : ''}
|
|
3265
|
+
<div class="ship-foot"><span class="muted xs">${r.agent ? `${esc(agentName(r.agent))}${r.model ? ` · ${esc(r.model)}` : ''}` : 'Launcher harness'} · ${esc(MODES.find((m) => m.id === r.mode)?.label || 'launcher mode')}</span><button class="primary sm">${icon('enter')}Run</button></div>
|
|
3266
|
+
</form>`);
|
|
3267
|
+
const close = overlay(node);
|
|
3268
|
+
$('.close', node).onclick = close;
|
|
3269
|
+
node.onsubmit = async (e) => {
|
|
3270
|
+
e.preventDefault();
|
|
3271
|
+
const params = Object.fromEntries($$('input[name^="p:"]', node).map((i) => [i.name.slice(2), i.value]));
|
|
3272
|
+
const d = S.config.defaults;
|
|
3273
|
+
const agent = r.agent || d.agent;
|
|
3274
|
+
try {
|
|
3275
|
+
const s = await api(`/recipes/${r.id}/run`, {
|
|
3276
|
+
method: 'POST',
|
|
3277
|
+
body: { params, cwd: needsProject ? $('[name="cwd"]', node).value : undefined, agent, model: r.agent ? undefined : savedModel(agent), effort: r.agent ? undefined : savedEffort(agent, savedModel(agent)), mode: r.mode || d.mode },
|
|
3278
|
+
});
|
|
3279
|
+
S.sessions.set(s.id, s);
|
|
3280
|
+
close();
|
|
3281
|
+
renderFeed();
|
|
3282
|
+
setFocus(s.id);
|
|
3283
|
+
toast(`Running “${esc(r.name)}”`);
|
|
3284
|
+
} catch (err) {
|
|
3285
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3286
|
+
}
|
|
3287
|
+
};
|
|
3288
|
+
// Always wait for Run, even with nothing to fill in, so a click on a recipe never launches it outright.
|
|
3289
|
+
requestAnimationFrame(() => ($('input, select', node) || $('button.primary', node))?.focus());
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
// A task worth repeating becomes a recipe, starting from its first prompt.
|
|
3293
|
+
async function saveAsRecipe(id) {
|
|
3294
|
+
const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
|
|
3295
|
+
if (!s?.turns?.[0]) return;
|
|
3296
|
+
openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft: { name: s.title, prompt: s.turns[0].prompt, cwd: '', mode: s.mode, agent: s.agent, model: s.model, effort: s.effort } } });
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
// --- usage: spend and outcomes --------------------------------------------------------------------
|
|
3300
|
+
|
|
3301
|
+
const money = (v) => (v >= 100 ? `$${Math.round(v)}` : `$${(v || 0).toFixed(2)}`);
|
|
3302
|
+
const pct = (v) => (v === null || v === undefined ? '—' : `${Math.round(v * 100)}%`);
|
|
3303
|
+
|
|
3304
|
+
async function openUsage({ days = 30, host } = {}) {
|
|
3305
|
+
const node = el(`<div class="modal usage-modal">
|
|
3306
|
+
<header><h2>Usage</h2><div class="mem-tabs us-range">${[7, 30, 90].map((d) => `<button data-days="${d}">${d} days</button>`).join('')}</div><button class="icon-btn close">${icon('x')}</button></header>
|
|
3307
|
+
<div class="us-body"><div class="it-loading"><span class="orb tiny"></span>Adding it up…</div></div>
|
|
3308
|
+
</div>`);
|
|
3309
|
+
const close = present(node, host);
|
|
3310
|
+
const render = async () => {
|
|
3311
|
+
$$('.us-range button', node).forEach((b) => b.classList.toggle('on', Number(b.dataset.days) === days));
|
|
3312
|
+
const u = await api(`/usage?days=${days}`).catch((err) => (toast(esc(err.message), { tone: 'bad' }), null));
|
|
3313
|
+
if (!u || !node.isConnected) return;
|
|
3314
|
+
const t = u.total;
|
|
3315
|
+
const unpriced = t.turns - t.priced;
|
|
3316
|
+
// Cost when any harness reports it, else turns: one measure per chart, never two axes.
|
|
3317
|
+
const byCost = t.priced > 0;
|
|
3318
|
+
const val = (d) => (byCost ? d.cost : d.turns);
|
|
3319
|
+
const max = Math.max(...u.daily.map(val), 0) || 1;
|
|
3320
|
+
const fmtDay = (k) => new Date(`${k}T12:00:00`).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
3321
|
+
const table = (rows, label) =>
|
|
3322
|
+
rows.length
|
|
3323
|
+
? `<table class="us-table"><thead><tr><th></th><th>Spend</th><th>Turns</th><th>Worked</th><th>Asked</th><th>Avg time</th></tr></thead><tbody>${rows
|
|
3324
|
+
.map((r) => `<tr><td>${label(r)}</td><td>${r.priced ? money(r.cost) : '—'}</td><td>${r.turns}</td><td>${pct(r.successRate)}</td><td>${r.questions}</td><td>${fmtDuration(r.avgMs)}</td></tr>`)
|
|
3325
|
+
.join('')}</tbody></table>`
|
|
3326
|
+
: '<p class="mem-empty">Nothing in this range.</p>';
|
|
3327
|
+
$('.us-body', node).innerHTML = `
|
|
3328
|
+
<div class="us-tiles">
|
|
3329
|
+
<div><span>Spend</span><b>${byCost ? money(t.cost) : '—'}</b><small>${unpriced ? `${unpriced} turn${unpriced > 1 ? 's' : ''} without a price` : 'all turns priced'}</small></div>
|
|
3330
|
+
<div><span>Turns</span><b>${t.turns}</b><small>in ${t.sessions} session${t.sessions === 1 ? '' : 's'}</small></div>
|
|
3331
|
+
<div><span>Worked</span><b>${pct(t.successRate)}</b><small>finished, not partial or blocked</small></div>
|
|
3332
|
+
<div><span>Questions</span><b>${t.questions}</b><small>times an agent needed you</small></div>
|
|
3333
|
+
</div>
|
|
3334
|
+
<h4>${byCost ? 'Spend per day' : 'Turns per day'}</h4>
|
|
3335
|
+
<div class="us-chart" role="img" aria-label="${byCost ? 'Spend' : 'Turns'} per day">${u.daily
|
|
3336
|
+
.map((d) => `<div class="us-bar" data-tip="${esc(fmtDay(d.date))}: ${byCost ? money(d.cost) : ''}${byCost ? ' · ' : ''}${d.turns} turn${d.turns === 1 ? '' : 's'}"><i style="height:${val(d) ? Math.max(2, (val(d) / max) * 100) : 0}%"></i></div>`)
|
|
3337
|
+
.join('')}</div>
|
|
3338
|
+
<div class="us-axis"><span>${esc(fmtDay(u.daily[0].date))}</span><span>${byCost ? `max ${money(max)}/day` : `max ${max}/day`}</span><span>today</span></div>
|
|
3339
|
+
<h4>By project</h4>${table(u.projects, (r) => `<span class="us-proj c-${esc(r.color)}"><i class="p-dot"></i>${esc(r.name)}</span>`)}
|
|
3340
|
+
<h4>By harness · model</h4>${table(u.runners, (r) => `<span class="us-runner"><span class="model-logo">${harnessLogo(r.agent)}</span>${esc(agentName(r.agent))} · ${esc(r.model || 'default')}</span>`)}
|
|
3341
|
+
${unpriced ? '<p class="hint">Codex, Cursor and Antigravity report tokens but not cost, so their turns count in turns and outcomes but not in spend.</p>' : ''}`;
|
|
3342
|
+
};
|
|
3343
|
+
// One floating tooltip for the bars (they are too thin to label).
|
|
3344
|
+
const tip = el('<div class="us-tip" hidden></div>');
|
|
3345
|
+
node.append(tip);
|
|
3346
|
+
node.addEventListener('mousemove', (e) => {
|
|
3347
|
+
const b = e.target.closest('.us-bar');
|
|
3348
|
+
tip.hidden = !b;
|
|
3349
|
+
if (!b) return;
|
|
3350
|
+
tip.textContent = b.dataset.tip;
|
|
3351
|
+
const r = b.getBoundingClientRect();
|
|
3352
|
+
const m = node.getBoundingClientRect();
|
|
3353
|
+
tip.style.left = `${Math.min(r.left - m.left + r.width / 2, m.width - 90)}px`;
|
|
3354
|
+
tip.style.top = `${r.top - m.top + node.scrollTop - 6}px`;
|
|
3355
|
+
});
|
|
3356
|
+
node.addEventListener('mouseleave', () => (tip.hidden = true));
|
|
3357
|
+
$('.us-range', node).onclick = (e) => {
|
|
3358
|
+
const d = Number(e.target.closest('[data-days]')?.dataset.days);
|
|
3359
|
+
if (d) (days = d), render();
|
|
3360
|
+
};
|
|
3361
|
+
render();
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
function usageSummary(u) {
|
|
3365
|
+
if (!u) return '';
|
|
3366
|
+
return u.total.turns ? `${u.total.priced ? `${money(u.total.cost)} · ` : ''}${u.total.turns} turns · ${pct(u.total.successRate)} worked, last 7 days` : 'Nothing in the last 7 days';
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
// --- phone: ntfy / Telegram -----------------------------------------------------------------------
|
|
3370
|
+
|
|
3371
|
+
function phoneSummary(r) {
|
|
3372
|
+
if (!r) return '';
|
|
3373
|
+
const on = [r.ntfy.enabled && r.ntfy.topic && 'ntfy', r.telegram.enabled && r.telegram.linked && 'Telegram'].filter(Boolean);
|
|
3374
|
+
return on.length ? `${on.join(' + ')} · ${r.events.map((e) => (e === 'waiting' ? 'questions' : 'finished')).join(' & ')}` : 'Off';
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3377
|
+
async function openPhone({ host } = {}) {
|
|
3378
|
+
const node = el(`<div class="modal phone-modal">
|
|
3379
|
+
<header><h2>Phone</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
3380
|
+
<p class="mem-lede">Get questions and finished tasks on your phone, and answer from there. wisp polls both services, so nothing needs to reach this machine. They receive titles, summaries and questions (with their context), never code or diffs.</p>
|
|
3381
|
+
<div class="ph-body"></div>
|
|
3382
|
+
</div>`);
|
|
3383
|
+
const close = present(node, host);
|
|
3384
|
+
let r;
|
|
3385
|
+
const put = async (patch) => {
|
|
3386
|
+
try {
|
|
3387
|
+
r = await api('/remote', { method: 'PUT', body: patch });
|
|
3388
|
+
paint();
|
|
3389
|
+
} catch (err) {
|
|
3390
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3391
|
+
}
|
|
3392
|
+
};
|
|
3393
|
+
const status = (st) => (!st ? '' : st.ok ? `<span class="ph-ok">${icon('check')}Working</span>` : `<span class="ph-bad">${icon('warn')}${esc(st.error || 'Failed')}</span>`);
|
|
3394
|
+
const paint = () => {
|
|
3395
|
+
const tg = r.telegram;
|
|
3396
|
+
$('.ph-body', node).innerHTML = `
|
|
3397
|
+
<div class="settings-modal ph-sec">
|
|
3398
|
+
<div class="setting"><span>Send me</span>
|
|
3399
|
+
<label class="check"><input type="checkbox" data-ev="waiting" ${r.events.includes('waiting') ? 'checked' : ''}> Questions</label>
|
|
3400
|
+
<label class="check"><input type="checkbox" data-ev="finished" ${r.events.includes('finished') ? 'checked' : ''}> Finished tasks</label></div>
|
|
3401
|
+
</div>
|
|
3402
|
+
<section class="ph-sec">
|
|
3403
|
+
<h4>ntfy <span class="muted xs">· tap a button on the notification to answer</span>${status(r.status.ntfy)}<button class="switch" data-sw="ntfy" role="switch" aria-checked="${r.ntfy.enabled}"></button></h4>
|
|
3404
|
+
<div class="settings-modal">
|
|
3405
|
+
<label><span>Topic</span><input class="input mono" data-ntfy="topic" value="${esc(r.ntfy.topic)}" placeholder="wisp-…" spellcheck="false"></label>
|
|
3406
|
+
<label><span>Server</span><input class="input mono" data-ntfy="server" value="${esc(r.ntfy.server)}" spellcheck="false"></label>
|
|
3407
|
+
<label><span>Access token</span><input class="input mono" data-ntfy="token" value="${esc(r.ntfy.token)}" placeholder="Only for protected topics" spellcheck="false" type="password"></label>
|
|
3408
|
+
</div>
|
|
3409
|
+
<p class="hint">Install the ntfy app and subscribe to the topic. Anyone who knows the topic name can read it, so keep it long and random; answers are signed, so nobody else can send them.</p>
|
|
3410
|
+
</section>
|
|
3411
|
+
<section class="ph-sec">
|
|
3412
|
+
<h4>Telegram <span class="muted xs">· buttons, or reply to a message in your own words</span>${status(r.status.telegram)}<button class="switch" data-sw="telegram" role="switch" aria-checked="${tg.enabled}"></button></h4>
|
|
3413
|
+
<div class="settings-modal">
|
|
3414
|
+
<label><span>Bot token</span><input class="input mono" data-tg="token" value="${esc(tg.token)}" placeholder="123456:ABC… from @BotFather" spellcheck="false" type="password"></label>
|
|
3415
|
+
</div>
|
|
3416
|
+
<p class="hint">${
|
|
3417
|
+
tg.linked
|
|
3418
|
+
? `${icon('check')} Linked to <b>${esc(tg.chatName || 'your chat')}</b>. <button class="link xs" data-act="unlink">Unlink</button>`
|
|
3419
|
+
: tg.token && tg.enabled
|
|
3420
|
+
? `Now send <code>/link ${esc(tg.linkCode)}</code> to your bot. Only the chat that sends this code gets linked.`
|
|
3421
|
+
: 'Create a bot with @BotFather, paste its token here and switch Telegram on.'
|
|
3422
|
+
}</p>
|
|
3423
|
+
</section>
|
|
3424
|
+
<div class="ship-foot"><span class="grow"></span><button class="ghost sm" data-act="test">${icon('send')}Send a test</button></div>`;
|
|
3425
|
+
};
|
|
3426
|
+
node.addEventListener('change', (e) => {
|
|
3427
|
+
const t = e.target;
|
|
3428
|
+
if (t.dataset.ev) put({ events: $$('[data-ev]', node).filter((x) => x.checked).map((x) => x.dataset.ev) });
|
|
3429
|
+
if (t.dataset.ntfy) put({ ntfy: { ...r.ntfy, [t.dataset.ntfy]: t.value.trim() } });
|
|
3430
|
+
if (t.dataset.tg) put({ telegram: { enabled: r.telegram.enabled, token: t.value.trim() } });
|
|
3431
|
+
});
|
|
3432
|
+
node.addEventListener('click', async (e) => {
|
|
3433
|
+
const sw = e.target.closest('[data-sw]')?.dataset.sw;
|
|
3434
|
+
if (sw === 'ntfy') {
|
|
3435
|
+
// A fresh random topic the first time, so it isn't guessable.
|
|
3436
|
+
const topic = r.ntfy.topic || `wisp-${crypto.getRandomValues(new Uint32Array(2)).join('').slice(0, 14)}`;
|
|
3437
|
+
return put({ ntfy: { ...r.ntfy, enabled: !r.ntfy.enabled, topic } });
|
|
3438
|
+
}
|
|
3439
|
+
if (sw === 'telegram') return put({ telegram: { enabled: !r.telegram.enabled, token: r.telegram.token } });
|
|
3440
|
+
const act = e.target.closest('[data-act]')?.dataset.act;
|
|
3441
|
+
if (act === 'unlink') return put({ telegram: { enabled: r.telegram.enabled, token: r.telegram.token, unlink: true } });
|
|
3442
|
+
if (act === 'test') {
|
|
3443
|
+
try {
|
|
3444
|
+
r = await api('/remote/test', { method: 'POST', body: {} });
|
|
3445
|
+
paint();
|
|
3446
|
+
toast('Test sent');
|
|
3447
|
+
} catch (err) {
|
|
3448
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
});
|
|
3452
|
+
node.refresh = async () => {
|
|
3453
|
+
if (node.contains(document.activeElement) && document.activeElement.matches('input')) return; // don't clobber typing
|
|
3454
|
+
r = await api('/remote').catch(() => r);
|
|
3455
|
+
if (r && node.isConnected) paint();
|
|
3456
|
+
};
|
|
3457
|
+
r = await api('/remote').catch((err) => (toast(esc(err.message), { tone: 'bad' }), null));
|
|
3458
|
+
if (r) paint();
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
// --- machines: other computers, over SSH ---------------------------------------------------------
|
|
3462
|
+
|
|
3463
|
+
// A machine runs its own wisp; this one signs in over SSH, tunnels to it and shows its sessions
|
|
3464
|
+
// as <id>@<machine>. Projects can live on a machine; launching in one runs the agent there.
|
|
3465
|
+
const machineById = (id) => S.machines.find((m) => m.id === id);
|
|
3466
|
+
const machineOnline = (id) => machineById(id)?.state === 'online';
|
|
3467
|
+
const MACHINE_STATE = { online: 'Online', connecting: 'Connecting…', reconnecting: 'Reconnecting…', error: 'Couldn’t connect', off: 'Not connected' };
|
|
3468
|
+
const projectTitle = (p) => {
|
|
3469
|
+
if (!p) return '';
|
|
3470
|
+
const m = p.machine && machineById(p.machine);
|
|
3471
|
+
return m ? `${p.path} on ${m.name}${m.state === 'online' ? '' : ' (not connected)'}` : p.path;
|
|
3472
|
+
};
|
|
3473
|
+
|
|
3474
|
+
function machinePill(mt) {
|
|
3475
|
+
const on = machineOnline(mt.id);
|
|
3476
|
+
return `<span class="q-machine ${on ? '' : 'off'}" title="Runs on ${esc(mt.name)} (${esc(mt.target)})${on ? '' : ' · not connected'}">${icon('server')}<span>${esc(mt.name)}</span></span>`;
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
function machinesSummary() {
|
|
3480
|
+
const n = S.machines.length;
|
|
3481
|
+
if (!n) return 'None yet · run agents on another computer over SSH';
|
|
3482
|
+
const on = S.machines.filter((m) => m.state === 'online').length;
|
|
3483
|
+
return `${n} machine${n === 1 ? '' : 's'} · ${on ? `${on} online` : 'none online'}`;
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
const loadMachines = () => api('/machines').then(setMachines).catch(() => {});
|
|
3487
|
+
function setMachines(list) {
|
|
3488
|
+
const was = new Map(S.machines.map((m) => [m.id, m.state === 'online']));
|
|
3489
|
+
S.machines = list;
|
|
3490
|
+
paintMachinesBtn();
|
|
3491
|
+
renderLaunchChips();
|
|
3492
|
+
renderProjects();
|
|
3493
|
+
$('.machines-modal')?.refresh();
|
|
3494
|
+
$('.projects-modal')?.refresh?.();
|
|
3495
|
+
$('.menu.where-menu')?.refresh();
|
|
3496
|
+
$('.settings-modal .machines-hint') && ($('.settings-modal .machines-hint').textContent = machinesSummary());
|
|
3497
|
+
// Coming online lists its sessions; going offline greys their cards.
|
|
3498
|
+
if (list.some((m) => (was.get(m.id) || false) !== (m.state === 'online'))) loadSessions();
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
function paintMachinesBtn() {
|
|
3502
|
+
const b = $('#machinesBtn');
|
|
3503
|
+
if (!b) return;
|
|
3504
|
+
const on = S.machines.filter((m) => m.state === 'online').length;
|
|
3505
|
+
const trouble = S.machines.some((m) => m.state === 'error' || m.state === 'reconnecting');
|
|
3506
|
+
b.classList.toggle('live', on > 0);
|
|
3507
|
+
b.classList.toggle('trouble', trouble);
|
|
3508
|
+
const n = $('.ab-n', b);
|
|
3509
|
+
n.hidden = !on;
|
|
3510
|
+
n.textContent = on;
|
|
3511
|
+
b.title = S.machines.length ? `Machines: ${machinesSummary()}` : 'Machines: run agents on another computer over SSH';
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
async function connectMachine(id) {
|
|
3515
|
+
try {
|
|
3516
|
+
await api(`/machines/${id}/connect`, { method: 'POST', body: {} });
|
|
3517
|
+
} catch (err) {
|
|
3518
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
function offlineToast(id) {
|
|
3523
|
+
const m = machineById(id);
|
|
3524
|
+
if (m?.state === 'connecting' || m?.state === 'reconnecting') return toast(`Still connecting to <b>${esc(m.name)}</b>…`);
|
|
3525
|
+
toast(`<b>${esc(m?.name || 'That machine')}</b> isn’t connected`, { tone: 'bad', action: { label: 'Connect', run: () => connectMachine(id) } });
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
const STEP_ICON = { done: 'check', fail: 'x', warn: 'warn', run: '', pending: '' };
|
|
3529
|
+
|
|
3530
|
+
function openMachines({ host, focusAdd = false } = {}) {
|
|
3531
|
+
const node = el(`<div class="modal machines-modal">
|
|
3532
|
+
<header><h2>Machines</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
3533
|
+
<div class="mc-body">
|
|
3534
|
+
<p class="mem-lede">Run agents on your other computers: a dev box, a GPU server, a cloud VM. wisp signs in over SSH, starts itself there and tunnels back, so worktrees, diffs and Ship it work as usual, and agents keep going when this computer sleeps.</p>
|
|
3535
|
+
<div class="mc-list"></div>
|
|
3536
|
+
<section class="mc-add"></section>
|
|
3537
|
+
</div>
|
|
3538
|
+
</div>`);
|
|
3539
|
+
const close = present(node, host);
|
|
3540
|
+
const body = $('.mc-body', node);
|
|
3541
|
+
let editing = null; // machine id whose form is open, '' = a new one, null = closed
|
|
3542
|
+
let hosts = [];
|
|
3543
|
+
api('/machines/ssh-hosts')
|
|
3544
|
+
.then((h) => {
|
|
3545
|
+
hosts = h;
|
|
3546
|
+
const dl = $('#sshHosts', node);
|
|
3547
|
+
if (dl) dl.innerHTML = hosts.map((x) => `<option value="${esc(x.host)}">${esc([x.user, x.hostname].filter(Boolean).join('@'))}</option>`).join('');
|
|
3548
|
+
})
|
|
3549
|
+
.catch(() => {});
|
|
3550
|
+
|
|
3551
|
+
// Projects on a machine open here, with a way back.
|
|
3552
|
+
const sub = (view) => {
|
|
3553
|
+
const head = $(':scope > header', node);
|
|
3554
|
+
body.hidden = true;
|
|
3555
|
+
head.hidden = true;
|
|
3556
|
+
view.classList.add('embedded');
|
|
3557
|
+
const back = el(`<button class="ghost sm sx-back">${icon('back')}Back</button>`);
|
|
3558
|
+
back.onclick = () => {
|
|
3559
|
+
view.remove();
|
|
3560
|
+
body.hidden = false;
|
|
3561
|
+
head.hidden = false;
|
|
3562
|
+
paint();
|
|
3563
|
+
};
|
|
3564
|
+
$('header', view).prepend(back);
|
|
3565
|
+
$('.close', view).onclick = close;
|
|
3566
|
+
node.append(view);
|
|
3567
|
+
return close;
|
|
3568
|
+
};
|
|
3569
|
+
|
|
3570
|
+
const steps = (m) => `<ol class="mc-steps">${m.steps
|
|
3571
|
+
.map((st) => `<li class="s-${st.status}"><span class="mc-si">${STEP_ICON[st.status] ? icon(STEP_ICON[st.status]) : ''}</span><b>${esc(st.label)}</b>${st.detail ? `<small>${esc(st.detail)}</small>` : ''}</li>`)
|
|
3572
|
+
.join('')}</ol>`;
|
|
3573
|
+
const trouble = (m) => `<div class="mc-hint ${m.state === 'reconnecting' ? 'soft' : ''}">
|
|
3574
|
+
<p><b>${esc(m.state === 'reconnecting' ? `${m.error || 'Connection lost.'} Trying again${m.attempt > 1 ? ` (attempt ${m.attempt})` : ''}…` : m.error)}</b></p>
|
|
3575
|
+
${m.hint?.text ? `<p>${esc(m.hint.text)}</p>` : ''}
|
|
3576
|
+
${m.hint?.command ? `<div class="mc-cmd"><code>${esc(m.hint.command)}</code><button class="ghost xs" data-a="copy" data-cmd="${esc(m.hint.command)}" title="Copy">${icon('copy')}</button></div>` : ''}
|
|
3577
|
+
${m.stale ? `<button class="ghost sm" data-a="restart">${icon('rerun')}Restart wisp there</button>` : ''}
|
|
3578
|
+
${m.log.length ? `<details class="mc-log"><summary>Log</summary><pre>${esc(m.log.join('\n'))}</pre></details>` : ''}
|
|
3579
|
+
</div>`;
|
|
3580
|
+
const projects = (m) => {
|
|
3581
|
+
const list = S.config.projects.filter((p) => p.machine === m.id);
|
|
3582
|
+
return `<div class="mc-projects">${list.map((p) => `<span class="lp-chip c-${p.color}" title="${esc(p.path)}"><i class="p-dot"></i>${esc(p.name)}</span>`).join('')}
|
|
3583
|
+
<button class="lp-chip add" data-a="projects">${icon(list.length ? 'plus' : 'folder')}${list.length ? 'Add' : 'Pick projects on it…'}</button></div>`;
|
|
3584
|
+
};
|
|
3585
|
+
const card = (m) => {
|
|
3586
|
+
const st = m.state;
|
|
3587
|
+
const busy = st === 'connecting' || st === 'reconnecting';
|
|
3588
|
+
const ready = (m.remote?.agents || []).filter((a) => a.available);
|
|
3589
|
+
return `<div class="mc st-${st}" data-id="${m.id}">
|
|
3590
|
+
<div class="mc-head">
|
|
3591
|
+
<span class="mc-icon">${icon('server')}<i class="mc-dot"></i></span>
|
|
3592
|
+
<div class="mc-title"><b>${esc(m.name)}</b><span>${esc(m.target)}${m.port !== 4477 ? ` · port ${m.port}` : ''}</span></div>
|
|
3593
|
+
<span class="mc-state">${MACHINE_STATE[st] || st}</span>
|
|
3594
|
+
${st === 'online' || busy ? `<button class="ghost sm" data-a="disconnect">${busy ? 'Cancel' : 'Disconnect'}</button>` : `<button class="primary sm" data-a="connect">${st === 'error' ? 'Try again' : 'Connect'}</button>`}
|
|
3595
|
+
<button class="icon-btn" data-a="menu" title="More">${icon('dots')}</button>
|
|
3596
|
+
</div>
|
|
3597
|
+
${
|
|
3598
|
+
st === 'online'
|
|
3599
|
+
? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${ready.length ? `<span class="mc-agents">${ready.map((a) => `<span class="model-logo" title="${esc(a.name)} ${esc(a.version || '')}">${harnessLogo(a.id)}</span>`).join('')}</span>` : '<span class="bad">no harnesses found</span>'}${m.working ? `<span>${m.working} working</span>` : ''}</div>`
|
|
3600
|
+
: ''
|
|
3601
|
+
}
|
|
3602
|
+
${busy || st === 'error' ? steps(m) : ''}
|
|
3603
|
+
${(st === 'error' || st === 'reconnecting') && m.error ? trouble(m) : ''}
|
|
3604
|
+
${st === 'online' || m.projects ? projects(m) : ''}
|
|
3605
|
+
${editing === m.id ? `<div class="mc-edit">${form(m)}</div>` : ''}
|
|
3606
|
+
</div>`;
|
|
3607
|
+
};
|
|
3608
|
+
const form = (m) => `<form class="mc-form" data-id="${m?.id || ''}">
|
|
3609
|
+
<div class="mc-row">
|
|
3610
|
+
<label class="mc-field grow"><span>SSH host</span><input class="input mono" name="target" list="sshHosts" placeholder="me@devbox, 10.0.0.5, or a Host from ~/.ssh/config" spellcheck="false" autocomplete="off" autocapitalize="off" value="${esc(m?.target || '')}" required></label>
|
|
3611
|
+
<label class="mc-field"><span>Name</span><input class="input" name="name" placeholder="${esc(m?.name || 'devbox')}" value="${esc(m?.name || '')}" spellcheck="false"></label>
|
|
3612
|
+
</div>
|
|
3613
|
+
<details class="mc-adv" ${m && (m.port !== 4477 || m.command) ? 'open' : ''}><summary>Advanced</summary>
|
|
3614
|
+
<div class="mc-row">
|
|
3615
|
+
<label class="mc-field"><span>wisp’s port there</span><input class="input mono" name="port" type="number" min="1" max="65535" value="${m?.port || 4477}"></label>
|
|
3616
|
+
<label class="mc-field grow"><span>Start command</span><input class="input mono" name="command" placeholder="${esc(S.machines[0]?.defaultCommand || 'npx -y wisp-agents@<this version>')}" value="${esc(m?.command || '')}" spellcheck="false"></label>
|
|
3617
|
+
</div>
|
|
3618
|
+
<label class="check mc-check"><input type="checkbox" name="autoConnect" ${m?.autoConnect === false ? '' : 'checked'}> Connect when wisp starts</label>
|
|
3619
|
+
</details>
|
|
3620
|
+
<div class="mc-foot">
|
|
3621
|
+
<p class="hint">${m ? 'Changing the host, port or command reconnects.' : 'Uses your SSH keys, agent and ~/.ssh/config; wisp never asks for a password. The machine needs Node.js 20+ and the harness CLIs, signed in.'}</p>
|
|
3622
|
+
${editing !== null && (m || S.machines.length) ? '<button type="button" class="ghost sm" data-a="cancel">Cancel</button>' : ''}
|
|
3623
|
+
<button class="primary sm" type="submit">${m ? 'Save' : `${icon('server')}Connect`}</button>
|
|
3624
|
+
</div>
|
|
3625
|
+
</form>`;
|
|
3626
|
+
|
|
3627
|
+
const paint = () => {
|
|
3628
|
+
if (!node.isConnected && !host) return;
|
|
3629
|
+
$('.mc-list', node).innerHTML = S.machines.map(card).join('');
|
|
3630
|
+
const add = $('.mc-add', node);
|
|
3631
|
+
// The form for a new machine: open when there are none yet, else behind a button.
|
|
3632
|
+
if (!S.machines.length || editing === '') {
|
|
3633
|
+
if (!$('form[data-id=""]', add)) add.innerHTML = `<h4>${S.machines.length ? 'Add another machine' : 'Add your first machine'}</h4>${form(null)}<datalist id="sshHosts">${hosts.map((x) => `<option value="${esc(x.host)}">${esc([x.user, x.hostname].filter(Boolean).join('@'))}</option>`).join('')}</datalist>`;
|
|
3634
|
+
} else add.innerHTML = `<button class="lp-chip add mc-new" data-a="new">${icon('plus')}Add a machine</button><datalist id="sshHosts">${hosts.map((x) => `<option value="${esc(x.host)}"></option>`).join('')}</datalist>`;
|
|
3635
|
+
};
|
|
3636
|
+
|
|
3637
|
+
const menu = (m, anchor) => {
|
|
3638
|
+
$('.menu.machine')?.close();
|
|
3639
|
+
const r = anchor.getBoundingClientRect();
|
|
3640
|
+
const box = el(`<div class="menu machine" style="top:${r.bottom + 4}px;left:${Math.max(8, r.right - 230)}px">
|
|
3641
|
+
<button data-m="edit">${icon('pen')}Edit…</button>
|
|
3642
|
+
<button data-m="projects">${icon('folder')}Projects on it…</button>
|
|
3643
|
+
${m.state === 'online' ? `<button data-m="restart">${icon('rerun')}Restart wisp there</button><button data-m="stop" class="danger">${icon('stop')}Stop wisp there</button>` : ''}
|
|
3644
|
+
<button data-m="remove" class="danger">${icon('trash')}Remove machine</button>
|
|
3645
|
+
</div>`);
|
|
3646
|
+
const shut = () => (box.remove(), document.removeEventListener('mousedown', outside));
|
|
3647
|
+
const outside = (ev) => {
|
|
3648
|
+
if (!box.contains(ev.target)) shut();
|
|
3649
|
+
};
|
|
3650
|
+
box.close = shut;
|
|
3651
|
+
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
3652
|
+
box.onclick = (ev) => {
|
|
3653
|
+
const a = ev.target.closest('button')?.dataset.m;
|
|
3654
|
+
shut();
|
|
3655
|
+
if (a) act(a, m);
|
|
3656
|
+
};
|
|
3657
|
+
document.body.append(box);
|
|
3658
|
+
};
|
|
3659
|
+
|
|
3660
|
+
const act = async (a, m) => {
|
|
3661
|
+
try {
|
|
3662
|
+
if (a === 'connect') await api(`/machines/${m.id}/connect`, { method: 'POST', body: {} });
|
|
3663
|
+
if (a === 'disconnect') await api(`/machines/${m.id}/disconnect`, { method: 'POST', body: {} });
|
|
3664
|
+
if (a === 'projects') openProjects({ machine: m.id, host: sub, focusAdd: true });
|
|
3665
|
+
if (a === 'edit') {
|
|
3666
|
+
editing = m.id;
|
|
3667
|
+
paint();
|
|
3668
|
+
$(`.mc[data-id="${m.id}"] input[name="target"]`, node)?.focus();
|
|
3669
|
+
}
|
|
3670
|
+
if (a === 'restart') {
|
|
3671
|
+
if (m.working && !confirm(`${m.working} agent${m.working === 1 ? ' is' : 's are'} working on ${m.name}. Restarting wisp there stops ${m.working === 1 ? 'it' : 'them'}. Restart?`)) return;
|
|
3672
|
+
await api(`/machines/${m.id}/stop`, { method: 'POST', body: { restart: true } });
|
|
3673
|
+
}
|
|
3674
|
+
if (a === 'stop') {
|
|
3675
|
+
if (!confirm(`Stop wisp on ${m.name}? ${m.working ? `${m.working} working agent${m.working === 1 ? '' : 's'} stop too. ` : ''}Its sessions stay there for next time.`)) return;
|
|
3676
|
+
await api(`/machines/${m.id}/stop`, { method: 'POST', body: {} });
|
|
3677
|
+
toast(`Stopped wisp on <b>${esc(m.name)}</b>`);
|
|
3678
|
+
}
|
|
3679
|
+
if (a === 'remove') {
|
|
3680
|
+
if (!confirm(`Remove ${m.name} from wisp? Its projects leave the launcher. Nothing on the machine is touched, and wisp keeps running there until you stop it.`)) return;
|
|
3681
|
+
await api(`/machines/${m.id}`, { method: 'DELETE' });
|
|
3682
|
+
await loadConfig();
|
|
3683
|
+
await loadMachines();
|
|
3684
|
+
}
|
|
3685
|
+
} catch (err) {
|
|
3686
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3687
|
+
}
|
|
3688
|
+
};
|
|
3689
|
+
|
|
3690
|
+
node.addEventListener('click', async (e) => {
|
|
3691
|
+
const b = e.target.closest('[data-a]');
|
|
3692
|
+
if (!b || !body.contains(b)) return;
|
|
3693
|
+
const a = b.dataset.a;
|
|
3694
|
+
const m = machineById(b.closest('.mc')?.dataset.id);
|
|
3695
|
+
if (a === 'copy') {
|
|
3696
|
+
copy(b.dataset.cmd);
|
|
3697
|
+
toast('Copied. Run it in a terminal, then connect again.');
|
|
3698
|
+
return;
|
|
3699
|
+
}
|
|
3700
|
+
if (a === 'new') {
|
|
3701
|
+
editing = '';
|
|
3702
|
+
paint();
|
|
3703
|
+
$('.mc-add input[name="target"]', node)?.focus();
|
|
3704
|
+
return;
|
|
3705
|
+
}
|
|
3706
|
+
if (a === 'cancel') {
|
|
3707
|
+
editing = null;
|
|
3708
|
+
paint();
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
if (a === 'menu' && m) {
|
|
3712
|
+
menu(m, b);
|
|
3713
|
+
return;
|
|
3714
|
+
}
|
|
3715
|
+
if (m) act(a, m);
|
|
3716
|
+
});
|
|
3717
|
+
// The name follows the host until you type one.
|
|
3718
|
+
node.addEventListener('input', (e) => {
|
|
3719
|
+
if (e.target.name !== 'target') return;
|
|
3720
|
+
const name = $('input[name="name"]', e.target.form);
|
|
3721
|
+
const h = hosts.find((x) => x.host === e.target.value.trim());
|
|
3722
|
+
name.placeholder = (h?.host || e.target.value.trim().replace(/^.*@/, '')).slice(0, 40) || 'devbox';
|
|
3723
|
+
});
|
|
3724
|
+
node.addEventListener('submit', async (e) => {
|
|
3725
|
+
e.preventDefault();
|
|
3726
|
+
const f = e.target;
|
|
3727
|
+
const id = f.dataset.id;
|
|
3728
|
+
const v = Object.fromEntries(new FormData(f));
|
|
3729
|
+
const payload = { target: v.target.trim(), name: v.name.trim(), port: Number(v.port) || 4477, command: v.command.trim(), autoConnect: !!v.autoConnect };
|
|
3730
|
+
const btn = $('button[type="submit"]', f);
|
|
3731
|
+
btn.disabled = true;
|
|
3732
|
+
try {
|
|
3733
|
+
if (id) await api(`/machines/${id}`, { method: 'PATCH', body: payload });
|
|
3734
|
+
else await api('/machines', { method: 'POST', body: payload });
|
|
3735
|
+
editing = null;
|
|
3736
|
+
await loadMachines();
|
|
3737
|
+
paint();
|
|
3738
|
+
} catch (err) {
|
|
3739
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3740
|
+
} finally {
|
|
3741
|
+
btn.disabled = false;
|
|
3742
|
+
}
|
|
3743
|
+
});
|
|
3744
|
+
node.refresh = () => {
|
|
3745
|
+
// Don't clobber a half-typed form: repaint the cards only.
|
|
3746
|
+
const typing = node.contains(document.activeElement) && document.activeElement.matches('input, select');
|
|
3747
|
+
if (typing && editing !== null && editing !== '') return;
|
|
3748
|
+
if (typing) $('.mc-list', node).innerHTML = S.machines.map(card).join('');
|
|
3749
|
+
else paint();
|
|
3750
|
+
};
|
|
3751
|
+
paint();
|
|
3752
|
+
if (focusAdd && S.machines.length) {
|
|
3753
|
+
editing = '';
|
|
3754
|
+
paint();
|
|
3755
|
+
}
|
|
3756
|
+
if (focusAdd || !S.machines.length) setTimeout(() => $('.mc-add input[name="target"]', node)?.focus(), 60);
|
|
3757
|
+
}
|
|
3758
|
+
|
|
1817
3759
|
// --- settings -----------------------------------------------------------------------------
|
|
1818
3760
|
|
|
1819
|
-
|
|
3761
|
+
// A sidebar of sections and one pane at a time, like every other app's settings. Big lists
|
|
3762
|
+
// (memory, automations, usage history) keep their own views; a pane only summarises and links.
|
|
3763
|
+
const SETTINGS_PANES = [
|
|
3764
|
+
{ id: 'general', label: 'General', icon: 'settings', lede: 'Where files open, where you take over, and how tasks start.' },
|
|
3765
|
+
{ id: 'notifications', label: 'Notifications', icon: 'bolt', lede: 'Hear about questions and finished tasks, here or on your phone.' },
|
|
3766
|
+
{ id: 'agents', label: 'Agents', icon: 'agent', lede: 'The harnesses wisp found on this machine. Pick one next to the model in the launcher.' },
|
|
3767
|
+
{ id: 'machines', label: 'Machines', icon: 'server', lede: 'Other computers that run agents for you, over SSH.' },
|
|
3768
|
+
{ id: 'memory', label: 'Memory', icon: 'spark', lede: "Learned from your sessions and added to every agent's prompt. What you ask always wins." },
|
|
3769
|
+
{ id: 'automations', label: 'Automations', icon: 'play', lede: 'Saved prompts, and the triggers that launch them for you.' },
|
|
3770
|
+
{ id: 'usage', label: 'Usage', icon: 'stats', lede: 'Spend and outcomes over the last 7 days.' },
|
|
3771
|
+
{ id: 'privacy', label: 'Privacy', icon: 'info', lede: 'What wisp sends about itself. Never prompts, paths or code.' },
|
|
3772
|
+
];
|
|
3773
|
+
let settingsPane = 'general';
|
|
3774
|
+
|
|
3775
|
+
function openSettings(opts) {
|
|
3776
|
+
if (SETTINGS_PANES.some((p) => p.id === opts?.pane)) settingsPane = opts.pane;
|
|
1820
3777
|
const d = S.config.defaults;
|
|
3778
|
+
// A row: name and a line of explanation on the left, the control on the right.
|
|
3779
|
+
const row = (name, desc, control, cls = '') => `<div class="sx-row ${cls}"><div class="sx-text"><b>${name}</b>${desc ? `<small>${desc}</small>` : ''}</div><div class="sx-ctl">${control}</div></div>`;
|
|
3780
|
+
const missing = S.agents.filter((a) => !a.available).length;
|
|
1821
3781
|
const node = el(`
|
|
1822
|
-
<div class="modal settings-modal">
|
|
1823
|
-
<
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
<
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
3782
|
+
<div class="modal settings-modal sx">
|
|
3783
|
+
<nav class="sx-nav" role="tablist" aria-orientation="vertical">
|
|
3784
|
+
<h2>Settings</h2>
|
|
3785
|
+
${SETTINGS_PANES.map((p) => `<button role="tab" data-pane="${p.id}">${icon(p.icon)}<span>${p.label}</span>${p.id === 'agents' && missing ? `<i class="dot bad" title="${missing} not found"></i>` : ''}</button>`).join('')}
|
|
3786
|
+
</nav>
|
|
3787
|
+
<div class="sx-main">
|
|
3788
|
+
<header><div><h3 class="sx-title"></h3><p class="sx-lede"></p></div><button class="icon-btn close">${icon('x')}</button></header>
|
|
3789
|
+
<section class="sx-pane" data-pane="general">
|
|
3790
|
+
<div class="sx-group">
|
|
3791
|
+
${row('Open files in', 'File links in cards and diffs', `<select name="editor">${[['vscode', 'VS Code'], ['cursor', 'Cursor'], ['windsurf', 'Windsurf'], ['zed', 'Zed'], ['idea', 'JetBrains'], ['none', 'Copy path']]
|
|
3792
|
+
.map(([v, l]) => `<option value="${v}" ${d.editor === v || (!d.editor && v === 'vscode') ? 'selected' : ''}>${l}</option>`)
|
|
3793
|
+
.join('')}</select>`)}
|
|
3794
|
+
${row('Take over in', 'The terminal a session opens in when you take it over', `<select name="terminal">${(S.config.terminals || []).map((t) => `<option value="${t.id}" ${(d.terminal || 'auto') === t.id ? 'selected' : ''}>${t.label}</option>`).join('')}</select>`)}
|
|
3795
|
+
<div class="sx-row custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><div class="sx-text"><b>Terminal command</b><small><code>{cwd}</code> and <code>{cmd}</code> are filled in</small></div>
|
|
3796
|
+
<div class="sx-ctl"><input class="input mono" name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></div></div>
|
|
3797
|
+
</div>
|
|
3798
|
+
<div class="sx-group">
|
|
3799
|
+
${row('Worktree per task', 'Each task gets its own branch from origin/main, in git repos', '<button class="switch" id="wtDefault" role="switch"></button>')}
|
|
3800
|
+
</div>
|
|
3801
|
+
</section>
|
|
3802
|
+
<section class="sx-pane" data-pane="notifications">
|
|
3803
|
+
<div class="sx-group">
|
|
3804
|
+
${row('Desktop notifications', '<span class="notif-hint"></span>', '<button class="switch" id="notif" role="switch"></button>')}
|
|
3805
|
+
</div>
|
|
3806
|
+
<div class="sx-group">
|
|
3807
|
+
${row('Phone', '<span class="phone-hint">ntfy or Telegram: answer questions from your phone</span>', '<button class="ghost sm" id="openPhone">Set up</button>')}
|
|
3808
|
+
</div>
|
|
3809
|
+
</section>
|
|
3810
|
+
<section class="sx-pane" data-pane="agents">
|
|
3811
|
+
<div class="sx-group">${S.agents
|
|
3812
|
+
.map((a) => row(`<span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}`, esc(a.available ? a.version : a.error || 'not found'), `<span class="sx-state ${a.available ? 'ok' : 'bad'}"><i class="dot ${a.available ? 'ok' : 'bad'}"></i>${a.available ? 'Ready' : 'Not found'}</span>`))
|
|
3813
|
+
.join('')}</div>
|
|
3814
|
+
</section>
|
|
3815
|
+
<section class="sx-pane" data-pane="machines">
|
|
3816
|
+
<div class="sx-group">
|
|
3817
|
+
${row('Machines', '<span class="machines-hint"></span>', '<button class="ghost sm" id="openMachines">Manage</button>')}
|
|
3818
|
+
</div>
|
|
3819
|
+
<p class="hint">Also in the top bar, and in Projects: add folders on a machine, then launch there like anywhere else.</p>
|
|
3820
|
+
</section>
|
|
3821
|
+
<section class="sx-pane" data-pane="memory">
|
|
3822
|
+
<div class="sx-group">
|
|
3823
|
+
${row('Learn from sessions', 'A short retro after each session keeps preferences and lessons', '<button class="switch" id="learn" role="switch"></button>')}
|
|
3824
|
+
${row('Remembered', '<span class="learn-hint"></span>', '<button class="ghost sm" id="openMemory">Open memory</button>')}
|
|
3825
|
+
</div>
|
|
3826
|
+
</section>
|
|
3827
|
+
<section class="sx-pane" data-pane="automations">
|
|
3828
|
+
<div class="sx-group">
|
|
3829
|
+
${row('Recipes', '<span class="recipe-hint"></span>', '<button class="ghost sm" data-auto="recipes">Manage</button>')}
|
|
3830
|
+
${row('Triggers', '<span class="trigger-hint"></span>', '<button class="ghost sm" data-auto="triggers">Manage</button>')}
|
|
3831
|
+
</div>
|
|
3832
|
+
</section>
|
|
3833
|
+
<section class="sx-pane" data-pane="usage">
|
|
3834
|
+
<div class="us-tiles sx-usage"><div class="it-loading"><span class="orb tiny"></span>Adding it up…</div></div>
|
|
3835
|
+
<div class="sx-group">${row('Charts and breakdowns', 'Per day, per project and per harness · model', '<button class="ghost sm" id="openUsage">Open usage</button>')}</div>
|
|
3836
|
+
</section>
|
|
3837
|
+
<section class="sx-pane" data-pane="privacy">
|
|
3838
|
+
<div class="sx-group">
|
|
3839
|
+
${row('Anonymous usage stats', '<span class="tele-hint"></span>', '<button class="switch" id="tele" role="switch"></button>')}
|
|
3840
|
+
</div>
|
|
3841
|
+
<p class="hint">Agent, model, status, timings and scrubbed errors only.</p>
|
|
3842
|
+
</section>
|
|
3843
|
+
</div>
|
|
1838
3844
|
</div>`);
|
|
1839
3845
|
const close = overlay(node);
|
|
1840
3846
|
$('.close', node).onclick = close;
|
|
3847
|
+
const pick = (id, focus = false) => {
|
|
3848
|
+
settingsPane = id;
|
|
3849
|
+
const p = SETTINGS_PANES.find((x) => x.id === id);
|
|
3850
|
+
$$('.sx-nav [data-pane]', node).forEach((b) => {
|
|
3851
|
+
const on = b.dataset.pane === id;
|
|
3852
|
+
b.setAttribute('aria-selected', on);
|
|
3853
|
+
b.tabIndex = on ? 0 : -1;
|
|
3854
|
+
if (on && focus) b.focus();
|
|
3855
|
+
});
|
|
3856
|
+
$$('.sx-pane', node).forEach((s) => (s.hidden = s.dataset.pane !== id));
|
|
3857
|
+
$('.sx-title', node).textContent = p.label;
|
|
3858
|
+
$('.sx-lede', node).textContent = p.lede;
|
|
3859
|
+
$('.sx-main', node).scrollTop = 0;
|
|
3860
|
+
};
|
|
3861
|
+
// Memory, Automations, Usage and Phone open in the main area, next to the sidebar, with a
|
|
3862
|
+
// way back to the section they came from. Leaving one goes back; only the X closes Settings.
|
|
3863
|
+
const main = $('.sx-main', node);
|
|
3864
|
+
const leave = () => {
|
|
3865
|
+
const v = $('.sx-main > .embedded', node);
|
|
3866
|
+
if (!v) return;
|
|
3867
|
+
v.remove();
|
|
3868
|
+
$('.sx-main > header', node).hidden = false;
|
|
3869
|
+
paintHints();
|
|
3870
|
+
};
|
|
3871
|
+
const show = (id, focus) => (leave(), pick(id, focus));
|
|
3872
|
+
const host = (pane) => (view) => {
|
|
3873
|
+
show(pane);
|
|
3874
|
+
view.classList.add('embedded');
|
|
3875
|
+
const back = el(`<button class="ghost sm sx-back">${icon('back')}Back</button>`);
|
|
3876
|
+
back.onclick = () => show(pane);
|
|
3877
|
+
$('header', view).prepend(back);
|
|
3878
|
+
$('.close', view).onclick = close;
|
|
3879
|
+
$('.sx-main > header', node).hidden = true;
|
|
3880
|
+
$$('.sx-pane', node).forEach((s) => (s.hidden = true));
|
|
3881
|
+
main.append(view);
|
|
3882
|
+
main.scrollTop = 0;
|
|
3883
|
+
return close;
|
|
3884
|
+
};
|
|
3885
|
+
$('.sx-nav', node).onclick = (e) => {
|
|
3886
|
+
const b = e.target.closest('[data-pane]');
|
|
3887
|
+
if (b) show(b.dataset.pane);
|
|
3888
|
+
};
|
|
3889
|
+
$('.sx-nav', node).onkeydown = (e) => {
|
|
3890
|
+
const step = { ArrowDown: 1, ArrowUp: -1 }[e.key];
|
|
3891
|
+
if (!step) return;
|
|
3892
|
+
e.preventDefault();
|
|
3893
|
+
const i = SETTINGS_PANES.findIndex((p) => p.id === settingsPane);
|
|
3894
|
+
show(SETTINGS_PANES[(i + step + SETTINGS_PANES.length) % SETTINGS_PANES.length].id, true);
|
|
3895
|
+
};
|
|
3896
|
+
show(settingsPane);
|
|
1841
3897
|
node.addEventListener('change', async (e) => {
|
|
1842
3898
|
const { name, value } = e.target;
|
|
1843
|
-
if (!name) return;
|
|
3899
|
+
if (!name || e.target.closest('.embedded')) return; // a nested view's own form
|
|
1844
3900
|
if (name === 'terminal') $('.custom-term', node).hidden = value !== 'custom';
|
|
1845
3901
|
await api('/config', { method: 'PATCH', body: { defaults: { [name]: value } } });
|
|
1846
3902
|
await loadConfig();
|
|
@@ -1888,9 +3944,44 @@ function openSettings() {
|
|
|
1888
3944
|
await setLearning(S.config.defaults.learn === 'off');
|
|
1889
3945
|
paintLearn();
|
|
1890
3946
|
};
|
|
1891
|
-
$('#openMemory', node).onclick = () => openMemory();
|
|
1892
3947
|
node.paintLearn = paintLearn;
|
|
1893
|
-
|
|
3948
|
+
// One line each; their lists and charts live in their own views.
|
|
3949
|
+
const wt = $('#wtDefault', node);
|
|
3950
|
+
const paintWt = () => wt.setAttribute('aria-checked', S.config.defaults.worktree !== 'off');
|
|
3951
|
+
wt.onclick = async () => {
|
|
3952
|
+
await api('/config', { method: 'PATCH', body: { defaults: { worktree: S.config.defaults.worktree === 'off' ? 'on' : 'off' } } });
|
|
3953
|
+
await loadConfig();
|
|
3954
|
+
paintWt();
|
|
3955
|
+
renderLauncher();
|
|
3956
|
+
};
|
|
3957
|
+
paintWt();
|
|
3958
|
+
// The one-line summaries; painted again on the way back from a view that may have changed them.
|
|
3959
|
+
const paintHints = () => {
|
|
3960
|
+
const r = S.config.recipes?.length || 0;
|
|
3961
|
+
const all = S.config.triggers || [];
|
|
3962
|
+
const t = all.filter((x) => x.enabled).length;
|
|
3963
|
+
$('.recipe-hint', node).textContent = r ? `${r} saved prompt${r === 1 ? '' : 's'}` : 'None yet · save a prompt you run often';
|
|
3964
|
+
$('.trigger-hint', node).textContent = all.length ? `${t} of ${all.length} on` : 'None yet · run a recipe on a schedule, CI failure or new issue';
|
|
3965
|
+
api('/usage?days=7')
|
|
3966
|
+
.then((u) => {
|
|
3967
|
+
const x = u.total;
|
|
3968
|
+
$('.sx-usage', node).innerHTML = `
|
|
3969
|
+
<div><span>Spend</span><b>${x.priced ? money(x.cost) : '—'}</b></div>
|
|
3970
|
+
<div><span>Turns</span><b>${x.turns}</b></div>
|
|
3971
|
+
<div><span>Worked</span><b>${pct(x.successRate)}</b></div>
|
|
3972
|
+
<div><span>Questions</span><b>${x.questions}</b></div>`;
|
|
3973
|
+
})
|
|
3974
|
+
.catch(() => ($('.sx-usage', node).innerHTML = ''));
|
|
3975
|
+
$('.machines-hint', node).textContent = machinesSummary();
|
|
3976
|
+
api('/remote').then((x) => ($('.phone-hint', node).textContent = phoneSummary(x) === 'Off' ? 'ntfy or Telegram: answer questions from your phone' : phoneSummary(x))).catch(() => {});
|
|
3977
|
+
paintLearn();
|
|
3978
|
+
};
|
|
3979
|
+
paintHints();
|
|
3980
|
+
$('#openMemory', node).onclick = () => openMemory({ host: host('memory') });
|
|
3981
|
+
$$('[data-auto]', node).forEach((b) => (b.onclick = () => openAutomations({ tab: b.dataset.auto, host: host('automations') })));
|
|
3982
|
+
$('#openUsage', node).onclick = () => openUsage({ days: 7, host: host('usage') });
|
|
3983
|
+
$('#openPhone', node).onclick = () => openPhone({ host: host('notifications') });
|
|
3984
|
+
$('#openMachines', node).onclick = () => openMachines({ host: host('machines') });
|
|
1894
3985
|
}
|
|
1895
3986
|
|
|
1896
3987
|
// --- memory: what the background retros learned ---------------------------------------------
|
|
@@ -1917,15 +4008,14 @@ function memorySummary(data) {
|
|
|
1917
4008
|
return `${n ? `${n} remembered` : 'Nothing yet'}${last ? ` · last retro ${ago(last.at)}` : ''}`;
|
|
1918
4009
|
}
|
|
1919
4010
|
|
|
1920
|
-
function openMemory({ tab = 'global' } = {}) {
|
|
4011
|
+
function openMemory({ tab = 'global', host } = {}) {
|
|
1921
4012
|
const node = el(`<div class="modal memory-modal">
|
|
1922
4013
|
<header><h2>Memory</h2><span class="mem-live"></span><button class="switch" id="memOn" role="switch" title="Learn from sessions"></button><button class="icon-btn close">${icon('x')}</button></header>
|
|
1923
4014
|
<p class="mem-lede">Learned from your sessions and added to every agent's prompt. What you ask always wins.</p>
|
|
1924
4015
|
<div class="mem-tabs" role="tablist"></div>
|
|
1925
4016
|
<div class="mem-body"></div>
|
|
1926
4017
|
</div>`);
|
|
1927
|
-
const close =
|
|
1928
|
-
$('.close', node).onclick = close;
|
|
4018
|
+
const close = present(node, host);
|
|
1929
4019
|
let data;
|
|
1930
4020
|
let editing = null; // { scope, kind, index } (index -1 = adding)
|
|
1931
4021
|
|
|
@@ -2039,7 +4129,7 @@ function telemetryNotice() {
|
|
|
2039
4129
|
if (!S.config.telemetry?.enabled || S.config.defaults.telemetryNotice === 'seen') return;
|
|
2040
4130
|
S.config.defaults.telemetryNotice = 'seen';
|
|
2041
4131
|
api('/config', { method: 'PATCH', body: { defaults: { telemetryNotice: 'seen' } } }).catch(() => {});
|
|
2042
|
-
toast('wisp sends anonymous usage stats and error reports. No prompts, paths or code.', { timeout: 10_000, action: { label: 'Settings', run: openSettings } });
|
|
4132
|
+
toast('wisp sends anonymous usage stats and error reports. No prompts, paths or code.', { timeout: 10_000, action: { label: 'Settings', run: () => openSettings({ pane: 'privacy' }) } });
|
|
2043
4133
|
}
|
|
2044
4134
|
|
|
2045
4135
|
function shortcuts() {
|
|
@@ -2056,10 +4146,12 @@ function shortcuts() {
|
|
|
2056
4146
|
['T', 'Take over in terminal'],
|
|
2057
4147
|
['R', 'Replay presentation'],
|
|
2058
4148
|
['⇧R', 'Rerun with another harness, model or thinking level'],
|
|
4149
|
+
['S', 'Ship it: commit, push and open a PR (worktree tasks)'],
|
|
2059
4150
|
['1–9', 'Pick an answer when asked'],
|
|
2060
4151
|
['↵', 'Launch / send'],
|
|
2061
4152
|
['⇧↵', 'New line'],
|
|
2062
4153
|
['C', 'Collapse all'],
|
|
4154
|
+
['U', 'Back to top'],
|
|
2063
4155
|
['Esc', 'Close / collapse'],
|
|
2064
4156
|
];
|
|
2065
4157
|
const node = el(`<div class="modal keys-modal"><header><h2>Shortcuts</h2></header>${rows.map(([k, l]) => `<div class="krow"><kbd>${k}</kbd><span>${l}</span></div>`).join('')}</div>`);
|
|
@@ -2079,7 +4171,8 @@ const modKey = (e, k) =>
|
|
|
2079
4171
|
const STATE_LABEL = { running: 'Working', waiting: 'Asking', review: 'Review', done: 'Done' };
|
|
2080
4172
|
|
|
2081
4173
|
function focusLauncher() {
|
|
2082
|
-
|
|
4174
|
+
// Docked under the top bar it opens where it is, so you keep your place in the feed.
|
|
4175
|
+
if (!$('#launcher').classList.contains('docked')) stage.scrollTo({ top: 0, behavior: 'smooth' });
|
|
2083
4176
|
$('#prompt')?.focus({ preventScroll: true });
|
|
2084
4177
|
}
|
|
2085
4178
|
|
|
@@ -2095,14 +4188,23 @@ function paletteItems() {
|
|
|
2095
4188
|
const cur = S.focus && S.sessions.get(S.focus);
|
|
2096
4189
|
if (cur) {
|
|
2097
4190
|
const id = cur.id;
|
|
4191
|
+
const full = S.full.get(id);
|
|
4192
|
+
const lastTurn = full?.turns?.at(-1);
|
|
2098
4193
|
const live = cur.state === 'running' || cur.state === 'waiting';
|
|
4194
|
+
const canRollback = cur.agent !== 'wisp' && !live && (lastTurn?.changes?.length || lastTurn?.checkpoint?.commit || cur.filesChanged);
|
|
4195
|
+
const rolledBack = !!lastTurn?.rolledBackAt;
|
|
2099
4196
|
const g = 'This wisp';
|
|
2100
4197
|
const sub = cur.title;
|
|
2101
4198
|
items.push({ group: g, label: 'Follow up', sub, icon: 'send', kbd: '/', run: () => withCard(id, () => $('.reply', S.nodes.get(id)?.card)?.focus(), { open: true }) });
|
|
2102
4199
|
if (!live) items.push({ group: g, label: cur.state === 'done' ? 'Move back to review' : 'Mark done', sub, icon: 'done', kbd: 'E', run: () => toggleDone(id) });
|
|
4200
|
+
if (canRollback) items.push({ group: g, label: rolledBack ? 'Re-apply latest turn' : 'Restore to before latest turn', sub, icon: rolledBack ? 'rerun' : 'replay', keywords: 'rollback revert undo checkpoint restore', run: () => rollbackTurn(id, lastTurn?.id, rolledBack) });
|
|
2103
4201
|
if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
|
|
2104
4202
|
items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
|
|
2105
4203
|
items.push({ group: g, label: 'Rerun…', sub, icon: 'rerun', kbd: '⇧R', keywords: 'retry again restart model harness', run: () => rerun(id) });
|
|
4204
|
+
if (cur.branch && !live && cur.pr?.state !== 'MERGED') items.push({ group: g, label: cur.pr?.state === 'OPEN' ? `Push to PR #${cur.pr.number}` : 'Ship it…', sub, icon: 'send', kbd: 'S', keywords: 'commit push pull request pr github', run: () => shipDialog(id) });
|
|
4205
|
+
if (cur.pr?.state === 'OPEN') items.push({ group: g, label: `Merge PR #${cur.pr.number}…`, sub, icon: 'check', keywords: 'squash github', run: () => mergePr(id) });
|
|
4206
|
+
if (cur.groupId) items.push({ group: g, label: 'Compare runs', sub, icon: 'agent', keywords: 'vs side by side harness', run: () => compareView(id) });
|
|
4207
|
+
items.push({ group: g, label: 'Save as recipe…', sub, icon: 'spark', keywords: 'template reuse automation', run: () => saveAsRecipe(id) });
|
|
2106
4208
|
items.push({ group: g, label: 'Replay presentation', sub, icon: 'replay', kbd: 'R', run: () => withCard(id, () => replay(id), { open: true }) });
|
|
2107
4209
|
items.push({ group: g, label: 'Rename', sub, icon: 'pen', kbd: 'M', run: () => withCard(id, () => rename(id)) });
|
|
2108
4210
|
items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
|
|
@@ -2134,15 +4236,24 @@ function paletteItems() {
|
|
|
2134
4236
|
{ group: a, label: 'Add project…', icon: 'folder', keywords: 'folder repo directory', run: () => openProjects({ focusAdd: true }) },
|
|
2135
4237
|
{ group: a, label: 'Manage projects', icon: 'folder', keywords: 'rename color remove', run: () => openProjects() },
|
|
2136
4238
|
{ group: a, label: 'Memory', icon: 'spark', keywords: 'learned preferences lessons retro', run: () => openMemory() },
|
|
4239
|
+
{ group: a, label: 'Recipes', icon: 'spark', keywords: 'automations saved prompts templates', run: () => openAutomations({ tab: 'recipes' }) },
|
|
4240
|
+
{ group: a, label: 'Triggers', icon: 'replay', keywords: 'automations schedule cron ci issues github', run: () => openAutomations({ tab: 'triggers' }) },
|
|
4241
|
+
{ group: a, label: 'Usage', icon: 'stats', keywords: 'spend cost tokens outcomes success', run: () => openUsage() },
|
|
4242
|
+
{ group: a, label: 'Phone', icon: 'send', keywords: 'ntfy telegram notifications mobile remote', run: () => openPhone() },
|
|
4243
|
+
{ group: a, label: 'Machines', icon: 'server', keywords: 'remote ssh server computer devbox cloud vm', run: () => openMachines() },
|
|
4244
|
+
{ group: a, label: 'Add a machine…', icon: 'server', keywords: 'remote ssh server computer connect', run: () => openMachines({ focusAdd: true }) },
|
|
2137
4245
|
{ group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
|
|
2138
4246
|
{ group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
|
|
2139
4247
|
);
|
|
2140
4248
|
|
|
4249
|
+
for (const r of S.config.recipes || []) items.push({ group: 'Recipes', label: `Run: ${r.name}`, sub: r.prompt.replace(/\s+/g, ' ').slice(0, 80), icon: 'spark', keywords: `recipe ${r.prompt}`, run: () => runRecipe({ ...r, params: recipeParams(r.prompt) }) });
|
|
4250
|
+
|
|
2141
4251
|
const filter = (id) => () => {
|
|
2142
4252
|
S.filter = id;
|
|
2143
4253
|
save('wisp.filter', id);
|
|
2144
4254
|
renderProjects();
|
|
2145
4255
|
renderFeed();
|
|
4256
|
+
feedToTop();
|
|
2146
4257
|
renderLaunchChips();
|
|
2147
4258
|
};
|
|
2148
4259
|
items.push({ group: 'Projects', label: 'All projects', sub: S.filter === 'all' ? 'Current filter' : '', dot: 'slate', keywords: 'filter clear', run: filter('all') });
|
|
@@ -2173,10 +4284,16 @@ function askNotify() {
|
|
|
2173
4284
|
}
|
|
2174
4285
|
|
|
2175
4286
|
function notifyFinished(s) {
|
|
2176
|
-
const
|
|
2177
|
-
const viewing = S.open.has(s.id) && n && isInView(n.card) && document.hasFocus();
|
|
4287
|
+
const viewing = S.open.has(s.id) && watching(s.id) && document.hasFocus();
|
|
2178
4288
|
if (viewing) return;
|
|
2179
|
-
|
|
4289
|
+
let verb = s.state === 'waiting' ? 'has a question' : 'is ready';
|
|
4290
|
+
// A task run in several places (or compared) says so once, when its last run finishes.
|
|
4291
|
+
const runs = s.groupId ? [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived) : [];
|
|
4292
|
+
if (runs.length > 1 && s.state !== 'waiting') {
|
|
4293
|
+
if (runs.some((x) => x.id !== s.id && x.state === 'running')) return;
|
|
4294
|
+
const places = new Set(runs.map((x) => x.machine?.id || '')).size;
|
|
4295
|
+
verb = places > 1 ? `is ready on ${places} machines` : `is ready (${runs.length} runs)`;
|
|
4296
|
+
}
|
|
2180
4297
|
toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
|
|
2181
4298
|
if (notifyOn() && !document.hasFocus()) {
|
|
2182
4299
|
const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/favicon.svg', tag: s.id });
|
|
@@ -2190,20 +4307,24 @@ function connect() {
|
|
|
2190
4307
|
const es = new EventSource('/api/events');
|
|
2191
4308
|
es.addEventListener('summary', (e) => {
|
|
2192
4309
|
const s = JSON.parse(e.data);
|
|
4310
|
+
if (s.parentId) return onChildSummary(s); // a subagent: lives inside its lead's card
|
|
2193
4311
|
const prev = S.sessions.get(s.id);
|
|
4312
|
+
const was = prev && sectionOf(prev); // before the update: a compare task's section depends on all its runs
|
|
2194
4313
|
S.sessions.set(s.id, s);
|
|
2195
|
-
// A
|
|
2196
|
-
//
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
4314
|
+
// A section change moves the card (a follow-up sends it from "Needs you" to "Working").
|
|
4315
|
+
// If the user is watching it, the view follows it there; otherwise whatever they're
|
|
4316
|
+
// reading stays put. Marking done or archiving is the exception: the card leaves the
|
|
4317
|
+
// list and focus goes to the next one instead.
|
|
4318
|
+
const moved = prev && was !== sectionOf(s);
|
|
4319
|
+
const follow = moved && s.state !== 'done' && !s.archived && watching(s.id);
|
|
4320
|
+
keepInPlace(follow ? S.nodes.get(s.id).card : feedAnchor(s.id), () => {
|
|
4321
|
+
// A card left open while it worked shouldn't land in "Needs you" expanded —
|
|
4322
|
+
// fold it unless the user is actually watching it.
|
|
4323
|
+
if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(watching(s.id) && document.hasFocus())) collapse(s.id);
|
|
4324
|
+
renderFeed();
|
|
4325
|
+
});
|
|
2206
4326
|
if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
|
|
4327
|
+
if (JSON.stringify(prev?.pr) !== JSON.stringify(s.pr)) renderPrStrip(s.id);
|
|
2207
4328
|
});
|
|
2208
4329
|
es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
|
|
2209
4330
|
es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
|
|
@@ -2214,15 +4335,17 @@ function connect() {
|
|
|
2214
4335
|
S.full.delete(id);
|
|
2215
4336
|
S.open.delete(id);
|
|
2216
4337
|
saveOpen();
|
|
2217
|
-
|
|
4338
|
+
keepInPlace(feedAnchor(id), renderFeed);
|
|
2218
4339
|
});
|
|
2219
|
-
es.addEventListener('config', () => loadConfig());
|
|
4340
|
+
es.addEventListener('config', () => (loadConfig(), $('.auto-modal')?.refresh()));
|
|
4341
|
+
es.addEventListener('remote', () => $('.phone-modal')?.refresh());
|
|
4342
|
+
es.addEventListener('machines', (e) => setMachines(JSON.parse(e.data)));
|
|
2220
4343
|
es.addEventListener('update', (e) => renderUpdate(JSON.parse(e.data)));
|
|
2221
4344
|
es.addEventListener('preferences', () => {
|
|
2222
4345
|
$('.memory-modal')?.refresh();
|
|
2223
4346
|
$('.settings-modal')?.paintLearn?.();
|
|
2224
4347
|
});
|
|
2225
|
-
es.addEventListener('hello', () => (loadSessions(), loadUpdate())); // also after sleep/reconnect
|
|
4348
|
+
es.addEventListener('hello', () => (loadSessions(), loadUpdate(), loadMachines())); // also after sleep/reconnect
|
|
2226
4349
|
}
|
|
2227
4350
|
|
|
2228
4351
|
async function loadSessions() {
|
|
@@ -2230,7 +4353,7 @@ async function loadSessions() {
|
|
|
2230
4353
|
S.sessions = new Map(list.map((s) => [s.id, s]));
|
|
2231
4354
|
for (const id of S.open) if (!S.sessions.has(id)) S.open.delete(id);
|
|
2232
4355
|
saveOpen();
|
|
2233
|
-
|
|
4356
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
2234
4357
|
for (const id of S.open) scheduleRefetch(id);
|
|
2235
4358
|
}
|
|
2236
4359
|
|
|
@@ -2238,6 +4361,7 @@ async function loadConfig() {
|
|
|
2238
4361
|
S.config = await api('/config');
|
|
2239
4362
|
renderProjects();
|
|
2240
4363
|
renderLaunchChips();
|
|
4364
|
+
paintAutoBtn();
|
|
2241
4365
|
}
|
|
2242
4366
|
|
|
2243
4367
|
// --- updates ----------------------------------------------------------------------------
|
|
@@ -2285,6 +4409,27 @@ async function installUpdate() {
|
|
|
2285
4409
|
// --- keyboard ---------------------------------------------------------------------------
|
|
2286
4410
|
|
|
2287
4411
|
const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
|
|
4412
|
+
// The user takes a card out of its list (done, archive, delete). The next card — the one below,
|
|
4413
|
+
// or the one above at the end of the list — gets focus and lands where the old one sat, so
|
|
4414
|
+
// triage continues on the same spot. Call before the request, while the card is still laid
|
|
4415
|
+
// out; the returned function applies the change locally and places the next card.
|
|
4416
|
+
function handOff(id) {
|
|
4417
|
+
const card = S.nodes.get(id)?.card;
|
|
4418
|
+
if (!card?.isConnected) return (apply) => (apply(), renderFeed());
|
|
4419
|
+
const nextUp = (card.nextElementSibling || card.previousElementSibling)?.dataset.id || feedIds().find((x) => x !== id);
|
|
4420
|
+
const r = card.getBoundingClientRect();
|
|
4421
|
+
const inset = topInset();
|
|
4422
|
+
const slot = r.bottom > inset && r.top < innerHeight ? Math.max(inset, r.top) : null;
|
|
4423
|
+
return (apply) => {
|
|
4424
|
+
if (S.open.has(id)) collapse(id);
|
|
4425
|
+
apply(); // don't wait for the live update to drop the card
|
|
4426
|
+
renderFeed();
|
|
4427
|
+
if (!nextUp || !S.nodes.has(nextUp)) return;
|
|
4428
|
+
setFocus(nextUp, { scroll: slot === null });
|
|
4429
|
+
const next = S.nodes.get(nextUp).card;
|
|
4430
|
+
if (slot !== null && next.isConnected) scrollByPx(next.getBoundingClientRect().top - slot);
|
|
4431
|
+
};
|
|
4432
|
+
}
|
|
2288
4433
|
|
|
2289
4434
|
document.addEventListener('keydown', (e) => {
|
|
2290
4435
|
// ⌘K / Ctrl+K works from anywhere, even mid-sentence in the launcher.
|
|
@@ -2300,7 +4445,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
2300
4445
|
}
|
|
2301
4446
|
const typing = e.target.closest('input, textarea, select, [contenteditable="true"]');
|
|
2302
4447
|
if (e.key === 'Escape') {
|
|
2303
|
-
if (!$('#overlay').hidden) return closeOverlay();
|
|
4448
|
+
if (!$('#overlay').hidden) return $('#overlay .sx-back') ? $('#overlay .sx-back').click() : closeOverlay();
|
|
2304
4449
|
if (typing) return e.target.blur();
|
|
2305
4450
|
if (S.focus && S.open.has(S.focus)) {
|
|
2306
4451
|
collapse(S.focus);
|
|
@@ -2329,6 +4474,10 @@ document.addEventListener('keydown', (e) => {
|
|
|
2329
4474
|
if (s && (s.archived || (s.state !== 'running' && s.state !== 'waiting'))) setArchived(id, !s.archived);
|
|
2330
4475
|
}
|
|
2331
4476
|
else if (k === 'c' && S.open.size) collapseAll();
|
|
4477
|
+
else if (k === 'u' || k === 'home') {
|
|
4478
|
+
e.preventDefault();
|
|
4479
|
+
backToTop();
|
|
4480
|
+
}
|
|
2332
4481
|
else if (k === 'm' && id) {
|
|
2333
4482
|
e.preventDefault();
|
|
2334
4483
|
rename(id);
|
|
@@ -2337,6 +4486,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
2337
4486
|
deleteSession(id);
|
|
2338
4487
|
} else if (k === 't' && id) takeOver(id);
|
|
2339
4488
|
else if (k === 'r' && e.shiftKey && id) rerun(id);
|
|
4489
|
+
else if (k === 's' && id && S.sessions.get(id)?.branch) shipDialog(id);
|
|
2340
4490
|
else if (k === 'r' && id) replay(id);
|
|
2341
4491
|
else if (k === '/' && id) {
|
|
2342
4492
|
e.preventDefault();
|
|
@@ -2353,6 +4503,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
2353
4503
|
|
|
2354
4504
|
$('#settingsBtn').onclick = openSettings;
|
|
2355
4505
|
$('#searchBtn').onclick = openPalette;
|
|
4506
|
+
$('#machinesBtn').onclick = () => openMachines();
|
|
4507
|
+
$('#autoBtn').onclick = () => openAutomations({ tab: (S.config.triggers || []).length && !(S.config.recipes || []).length ? 'triggers' : 'recipes' });
|
|
2356
4508
|
$('#updateBtn').onclick = installUpdate;
|
|
2357
4509
|
$('#searchBtn').title = `Search (${combo('K')})`;
|
|
2358
4510
|
$('#searchBtn .mod-k').textContent = combo('K');
|
|
@@ -2380,11 +4532,23 @@ setInterval(() => {
|
|
|
2380
4532
|
|
|
2381
4533
|
// Sticky top bar gets a hairline once the page scrolls; "back to top" shows after a screen's worth.
|
|
2382
4534
|
const toTop = $('#toTop');
|
|
4535
|
+
// Once the launcher scrolls under the top bar it docks there, folded to one line. The slot keeps
|
|
4536
|
+
// its full height in the feed so docking never shifts what you're reading.
|
|
4537
|
+
const launchSlot = $('#launchSlot');
|
|
4538
|
+
const dockLauncher = () => {
|
|
4539
|
+
const box = $('#launcher');
|
|
4540
|
+
const gone = launchSlot.getBoundingClientRect().bottom < $('#topbar').offsetHeight;
|
|
4541
|
+
if (gone === box.classList.contains('docked')) return;
|
|
4542
|
+
launchSlot.style.height = gone ? `${box.offsetHeight}px` : '';
|
|
4543
|
+
box.classList.toggle('docked', gone);
|
|
4544
|
+
};
|
|
2383
4545
|
stage.addEventListener('scroll', () => {
|
|
2384
4546
|
$('#topbar').classList.toggle('scrolled', stage.scrollTop > 4);
|
|
4547
|
+
dockLauncher();
|
|
2385
4548
|
toTop.hidden = stage.scrollTop < stage.clientHeight * 0.75;
|
|
2386
4549
|
}, { passive: true });
|
|
2387
|
-
|
|
4550
|
+
const backToTop = () => stage.scrollTo({ top: 0, behavior: 'smooth' });
|
|
4551
|
+
toTop.onclick = backToTop;
|
|
2388
4552
|
|
|
2389
4553
|
if (/Electron/.test(navigator.userAgent)) document.body.classList.add('electron', /Mac/.test(navigator.platform) ? 'mac' : 'other');
|
|
2390
4554
|
|
|
@@ -2405,7 +4569,7 @@ window.addEventListener('focus', () => {
|
|
|
2405
4569
|
});
|
|
2406
4570
|
|
|
2407
4571
|
(async () => {
|
|
2408
|
-
[S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions()]);
|
|
4572
|
+
[S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions(), loadMachines()]);
|
|
2409
4573
|
renderLauncher();
|
|
2410
4574
|
connect();
|
|
2411
4575
|
route();
|