wisp-agents 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -1
- package/README.md +76 -1
- package/bin/wisp.js +52 -5
- package/package.json +2 -2
- package/server/adapters/claude.js +4 -0
- package/server/adapters/codex.js +1 -0
- package/server/adapters/common.js +1 -1
- package/server/adapters/index.js +2 -1
- package/server/adapters/opencode.js +4 -0
- package/server/checkpoint.js +348 -0
- package/server/client.js +26 -2
- package/server/git.js +199 -0
- package/server/index.js +409 -21
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +7 -1
- package/server/openapi.js +147 -3
- package/server/protocol.js +11 -1
- package/server/recipes.js +64 -0
- package/server/remote.js +279 -0
- package/server/render.js +18 -0
- package/server/runner.js +152 -13
- package/server/store.js +9 -1
- package/server/subagents.js +85 -0
- package/server/triggers.js +268 -0
- package/server/usage.js +79 -0
- package/server/welcome.js +9 -1
- package/skills/wisp/SKILL.md +3 -2
- package/web/css/app.css +557 -16
- package/web/index.html +10 -3
- package/web/js/app.js +1727 -86
- package/web/js/blocks.js +396 -22
package/web/js/app.js
CHANGED
|
@@ -12,6 +12,7 @@ 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: [],
|
|
@@ -24,6 +25,9 @@ const S = {
|
|
|
24
25
|
const $ = (s, root = document) => root.querySelector(s);
|
|
25
26
|
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
|
|
26
27
|
const stage = $('#stage');
|
|
28
|
+
// How much of the feed the sticky top bar and docked launcher cover: the stage's scroll-padding-top,
|
|
29
|
+
// which scrollIntoView already honours, so hand-placed cards line up with scrolled-to ones.
|
|
30
|
+
const topInset = () => parseFloat(getComputedStyle(stage).scrollPaddingTop) || 0;
|
|
27
31
|
|
|
28
32
|
function load(k, d) {
|
|
29
33
|
try {
|
|
@@ -121,11 +125,23 @@ function overlay(node, { onClose } = {}) {
|
|
|
121
125
|
setTimeout(() => o.firstChild === node && ((o.hidden = true), (o.innerHTML = '')), 200);
|
|
122
126
|
onClose?.();
|
|
123
127
|
};
|
|
124
|
-
|
|
128
|
+
// A block body: an on* handler that returns false cancels the click, which would stop every
|
|
129
|
+
// submit button inside the dialog from submitting its form.
|
|
130
|
+
o.onclick = (e) => {
|
|
131
|
+
if (e.target === o) close();
|
|
132
|
+
};
|
|
125
133
|
o.close = close;
|
|
126
134
|
return close;
|
|
127
135
|
}
|
|
128
136
|
const closeOverlay = () => !$('#overlay').hidden && $('#overlay').close?.();
|
|
137
|
+
// A view opens as its own dialog, or inside another one (Settings) that hands it a host.
|
|
138
|
+
// Returns what closes it for good: the whole overlay either way.
|
|
139
|
+
function present(node, host) {
|
|
140
|
+
if (host) return host(node);
|
|
141
|
+
const close = overlay(node);
|
|
142
|
+
$('.close', node).onclick = close;
|
|
143
|
+
return close;
|
|
144
|
+
}
|
|
129
145
|
|
|
130
146
|
// --- feed ----------------------------------------------------------------------
|
|
131
147
|
|
|
@@ -133,8 +149,39 @@ function visible(list) {
|
|
|
133
149
|
return S.filter === 'all' ? list : list.filter((s) => s.projectId === S.filter);
|
|
134
150
|
}
|
|
135
151
|
|
|
152
|
+
// Compare runs (one launch on several harnesses, sharing a groupId) are one task in the feed
|
|
153
|
+
// while two or more of them are on the same side of archived: a card with a row per run.
|
|
154
|
+
// Keeping one archives the rest, and what's left is an ordinary card again.
|
|
155
|
+
function runsOf(s) {
|
|
156
|
+
if (!s.groupId) return [s];
|
|
157
|
+
const runs = [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived === !s.archived);
|
|
158
|
+
return runs.length > 1 ? runs.sort((a, b) => a.createdAt - b.createdAt) : [s];
|
|
159
|
+
}
|
|
160
|
+
// The task as a whole: asking if any run asks, working while any works, done once all are.
|
|
161
|
+
function taskOf(s) {
|
|
162
|
+
const runs = runsOf(s);
|
|
163
|
+
if (runs.length < 2) return s;
|
|
164
|
+
const state = runs.some((r) => r.state === 'waiting') ? 'waiting' : runs.some((r) => r.state === 'running') ? 'running' : runs.every((r) => r.state === 'done') ? 'done' : 'review';
|
|
165
|
+
return {
|
|
166
|
+
...runs[0],
|
|
167
|
+
key: `g:${s.groupId}${s.archived ? ':a' : ''}`,
|
|
168
|
+
runs,
|
|
169
|
+
state,
|
|
170
|
+
unread: runs.some((r) => r.unread),
|
|
171
|
+
createdAt: Math.min(...runs.map((r) => r.createdAt)),
|
|
172
|
+
updatedAt: Math.max(...runs.map((r) => r.updatedAt)),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
136
176
|
function groups() {
|
|
137
|
-
const every =
|
|
177
|
+
const every = [];
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
for (const s of visible([...S.sessions.values()])) {
|
|
180
|
+
const t = taskOf(s);
|
|
181
|
+
if (t.runs && seen.has(t.key)) continue;
|
|
182
|
+
if (t.runs) seen.add(t.key);
|
|
183
|
+
every.push(t);
|
|
184
|
+
}
|
|
138
185
|
const all = every.filter((s) => !s.archived);
|
|
139
186
|
const needs = all
|
|
140
187
|
.filter((s) => s.state === 'review' || s.state === 'waiting')
|
|
@@ -154,7 +201,7 @@ function renderFeed() {
|
|
|
154
201
|
$('h4 .n', sec).textContent = list.length || '';
|
|
155
202
|
const want = show ? list : [];
|
|
156
203
|
want.forEach((s, i) => {
|
|
157
|
-
const { card } = item(s);
|
|
204
|
+
const { card } = s.runs ? taskItem(s) : item(s);
|
|
158
205
|
// Only touch the DOM when the order actually changed: moving a node
|
|
159
206
|
// would blur a composer the user is typing in.
|
|
160
207
|
if (box.children[i] !== card) box.insertBefore(card, box.children[i] || null);
|
|
@@ -169,8 +216,11 @@ function renderFeed() {
|
|
|
169
216
|
$('#secArchived').classList.toggle('collapsed', !S.showArchived);
|
|
170
217
|
|
|
171
218
|
// Drop nodes for sessions that are gone or filtered out of the feed entirely.
|
|
172
|
-
const
|
|
219
|
+
const tasks = [...needs, ...working, ...done, ...archived];
|
|
220
|
+
const shown = new Set(tasks.flatMap((s) => (s.runs || [s]).map((r) => r.id)));
|
|
173
221
|
for (const [id, n] of S.nodes) if (!shown.has(id)) (n.card.remove(), S.nodes.delete(id), S.full.delete(id));
|
|
222
|
+
const keys = new Set(tasks.map((t) => t.key));
|
|
223
|
+
for (const [key, g] of S.tasks) if (!keys.has(key)) (g.card.remove(), S.tasks.delete(key));
|
|
174
224
|
|
|
175
225
|
const empty = !needs.length && !working.length && !done.length && !archived.length;
|
|
176
226
|
$('#feedEmpty').hidden = !empty;
|
|
@@ -183,6 +233,13 @@ function renderFeed() {
|
|
|
183
233
|
renderProjects();
|
|
184
234
|
}
|
|
185
235
|
|
|
236
|
+
// A new project filter swaps the whole list; staying at the old offset would drop the user
|
|
237
|
+
// somewhere arbitrary in it, so bring the top of the feed back into view.
|
|
238
|
+
function feedToTop() {
|
|
239
|
+
const top = $('#feed').getBoundingClientRect().top;
|
|
240
|
+
if (top < topInset()) scrollByPx(top - topInset());
|
|
241
|
+
}
|
|
242
|
+
|
|
186
243
|
function renderFeedHead(needs, working) {
|
|
187
244
|
const parts = [];
|
|
188
245
|
if (working.length) parts.push(`<span class="hs working"><i></i>${working.length} working</span>`);
|
|
@@ -262,6 +319,7 @@ function renderHead(n, s) {
|
|
|
262
319
|
: s.state === 'review'
|
|
263
320
|
? `<span class="pill st-${s.status}"><i></i>${STATUS_LABEL[s.status] || 'Done'}</span>`
|
|
264
321
|
: '';
|
|
322
|
+
if (runsOf(s).length > 1) return renderRunHead(n, s, { open, live, pill });
|
|
265
323
|
n.card.className = `item c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
266
324
|
n.head.innerHTML = `
|
|
267
325
|
<span class="q-dot ${s.state === 'running' ? 'live' : ''} ${s.state === 'waiting' ? 'waiting' : ''}"></span>
|
|
@@ -272,7 +330,8 @@ function renderHead(n, s) {
|
|
|
272
330
|
<span class="it-side">
|
|
273
331
|
${pill}
|
|
274
332
|
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
275
|
-
${
|
|
333
|
+
${s.pr ? prPill(s.pr) : ''}
|
|
334
|
+
${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort)}
|
|
276
335
|
<span class="q-proj">${esc(s.project)}</span>
|
|
277
336
|
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
278
337
|
${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
|
|
@@ -280,13 +339,74 @@ function renderHead(n, s) {
|
|
|
280
339
|
</span>`;
|
|
281
340
|
}
|
|
282
341
|
|
|
342
|
+
// One run inside a compare task: the harness and model stand in for the title the task shows.
|
|
343
|
+
function renderRunHead(n, s, { open, live, pill }) {
|
|
344
|
+
const a = S.agents.find((x) => x.id === s.agent);
|
|
345
|
+
const model = (s.model && a?.models?.find((m) => m.id === s.model)?.label) || s.model || 'Default model';
|
|
346
|
+
n.card.className = `item run c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
347
|
+
n.head.innerHTML = `
|
|
348
|
+
<span class="run-logo">${harnessLogo(s.agent)}</span>
|
|
349
|
+
<span class="it-main">
|
|
350
|
+
<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>
|
|
351
|
+
${live ? `<span class="q-live ${s.state}">${esc(s.live)}</span>` : open ? '' : `<span class="it-sub">${esc(s.preview || '')}</span>`}
|
|
352
|
+
</span>
|
|
353
|
+
<span class="it-side">
|
|
354
|
+
${live && s.state === 'running' ? `<span class="pill st-running"><i></i>Working</span>` : pill}
|
|
355
|
+
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
356
|
+
${s.pr ? prPill(s.pr) : ''}
|
|
357
|
+
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
358
|
+
${icon('chevron', 'chev')}
|
|
359
|
+
</span>`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// The card for a compare task: the shared prompt on top, one row per run (each an ordinary,
|
|
363
|
+
// expandable item), and the side-by-side view one click away.
|
|
364
|
+
function taskItem(t) {
|
|
365
|
+
let g = S.tasks.get(t.key);
|
|
366
|
+
if (!g) {
|
|
367
|
+
const card = el(`<section class="task-item"><header class="task-head"></header><div class="task-runs"></div></section>`);
|
|
368
|
+
g = { card, head: $('.task-head', card), runs: $('.task-runs', card) };
|
|
369
|
+
S.tasks.set(t.key, g);
|
|
370
|
+
g.head.addEventListener('click', (e) => {
|
|
371
|
+
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
372
|
+
const ids = (taskOf(S.sessions.get(g.lead) || {}).runs || []).map((r) => r.id);
|
|
373
|
+
if (a === 'compare') return compareView(g.lead);
|
|
374
|
+
if (a === 'quick-done') return taskDone(ids.filter((id) => S.sessions.get(id)?.state === 'review'));
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
g.lead = t.id;
|
|
378
|
+
const finished = t.runs.filter((r) => r.state === 'review' || r.state === 'done').length;
|
|
379
|
+
const status = t.state === 'waiting' ? 'A run is asking you' : t.state === 'running' ? `${finished} of ${t.runs.length} finished` : t.state === 'done' ? 'Done' : 'All finished · pick one to keep';
|
|
380
|
+
g.card.className = `task-item c-${t.color} st-${t.state} ${t.unread ? 'unread' : ''}`;
|
|
381
|
+
g.head.innerHTML = `
|
|
382
|
+
<span class="q-dot ${t.state === 'running' ? 'live' : ''} ${t.state === 'waiting' ? 'waiting' : ''}"></span>
|
|
383
|
+
<span class="it-main">
|
|
384
|
+
<span class="it-title">${esc(t.title)}</span>
|
|
385
|
+
<span class="it-sub">${esc(status)}</span>
|
|
386
|
+
</span>
|
|
387
|
+
<span class="it-side">
|
|
388
|
+
<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>
|
|
389
|
+
<span class="q-proj">${esc(t.project)}</span>
|
|
390
|
+
<span class="q-time">${ago(t.updatedAt)}</span>
|
|
391
|
+
${t.state === 'review' && !t.archived ? `<button class="it-quick" data-a="quick-done" title="Mark every run done">${icon('done')}</button>` : ''}
|
|
392
|
+
</span>`;
|
|
393
|
+
t.runs.forEach((r, i) => {
|
|
394
|
+
const { card } = item(r);
|
|
395
|
+
if (g.runs.children[i] !== card) g.runs.insertBefore(card, g.runs.children[i] || null);
|
|
396
|
+
});
|
|
397
|
+
while (g.runs.children.length > t.runs.length) g.runs.lastElementChild.remove();
|
|
398
|
+
return g;
|
|
399
|
+
}
|
|
400
|
+
|
|
283
401
|
function setFocus(id, { scroll = true } = {}) {
|
|
284
402
|
if (S.focus === id) return;
|
|
285
403
|
S.nodes.get(S.focus)?.card.classList.remove('focused');
|
|
286
404
|
S.focus = id;
|
|
287
405
|
const n = S.nodes.get(id);
|
|
288
406
|
n?.card.classList.add('focused');
|
|
289
|
-
|
|
407
|
+
// A card taller than the screen would land with its header out of view ('nearest' aligns
|
|
408
|
+
// its far edge when moving up), so bring its top in instead.
|
|
409
|
+
if (scroll && n) n.card.scrollIntoView({ block: n.card.offsetHeight > stage.clientHeight - topInset() ? 'start' : 'nearest', behavior: 'smooth' });
|
|
290
410
|
}
|
|
291
411
|
|
|
292
412
|
function toggle(id) {
|
|
@@ -482,10 +602,17 @@ function renderLauncher() {
|
|
|
482
602
|
<div class="l-bar">
|
|
483
603
|
<button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
|
|
484
604
|
<button class="mode-btn" id="mode" aria-haspopup="menu"></button>
|
|
485
|
-
<
|
|
605
|
+
<div class="runners">
|
|
606
|
+
<button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
|
|
607
|
+
<button class="vs-btn" id="compareBtn" aria-pressed="false"><span>vs</span>${icon('x')}</button>
|
|
608
|
+
<button class="runner-btn" id="runner2" title="Second harness to compare with" aria-haspopup="dialog" hidden></button>
|
|
609
|
+
</div>
|
|
610
|
+
<button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
|
|
486
611
|
<span class="grow"></span>
|
|
612
|
+
<button class="tog-btn" id="wtBtn"></button>
|
|
487
613
|
<button class="send" id="launch" title="Launch (Enter · Shift+Enter for newline)">${icon('enter')}</button>
|
|
488
|
-
</div
|
|
614
|
+
</div>
|
|
615
|
+
<div class="l-recipes"></div>`;
|
|
489
616
|
renderLaunchChips();
|
|
490
617
|
|
|
491
618
|
const ta = $('#prompt', box);
|
|
@@ -494,7 +621,7 @@ function renderLauncher() {
|
|
|
494
621
|
acceptFiles(box, ta, tray);
|
|
495
622
|
autosize(ta);
|
|
496
623
|
ta.value = S.drafts.get('home') || '';
|
|
497
|
-
ta.addEventListener('input', () => S.drafts.set('home', ta.value));
|
|
624
|
+
ta.addEventListener('input', () => (S.drafts.set('home', ta.value), paintSaveRecipe()));
|
|
498
625
|
// While focus is elsewhere, the placeholder advertises the shortcut that brings you back.
|
|
499
626
|
const hint = () => (ta.placeholder = document.activeElement === ta
|
|
500
627
|
? 'What should I go do? Paste screenshots, drop logs or videos…'
|
|
@@ -502,6 +629,12 @@ function renderLauncher() {
|
|
|
502
629
|
ta.addEventListener('focus', hint);
|
|
503
630
|
ta.addEventListener('blur', hint);
|
|
504
631
|
hint();
|
|
632
|
+
// Docked and folded to one line, a click anywhere on it opens it up for typing.
|
|
633
|
+
box.addEventListener('mousedown', (e) => {
|
|
634
|
+
if (!box.matches('.docked:not(:focus-within)')) return;
|
|
635
|
+
e.preventDefault();
|
|
636
|
+
ta.focus();
|
|
637
|
+
});
|
|
505
638
|
$('#attach', box).onclick = tray.pick;
|
|
506
639
|
|
|
507
640
|
// Mode is set-and-forget, so it's one small button: just an icon on the default (Edits),
|
|
@@ -522,21 +655,82 @@ function renderLauncher() {
|
|
|
522
655
|
};
|
|
523
656
|
paintMode();
|
|
524
657
|
modeBtn.onclick = () => modeMenu(modeBtn, curMode, setMode);
|
|
658
|
+
box.mode = () => curMode;
|
|
525
659
|
let curModel = savedModel(agent?.id);
|
|
526
660
|
let curEffort = savedEffort(agent?.id, curModel);
|
|
527
661
|
const runnerBtn = $('#runner', box);
|
|
528
662
|
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')}`;
|
|
663
|
+
paintRunnerBtn(runnerBtn, agent?.id, curModel, curEffort);
|
|
531
664
|
runnerBtn.title = `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
|
|
532
665
|
};
|
|
533
666
|
paintRunner();
|
|
667
|
+
|
|
668
|
+
// Worktree: each launch gets a fresh branch from the remote's default branch (git repos only).
|
|
669
|
+
const wtBtn = $('#wtBtn', box);
|
|
670
|
+
const paintWt = () => {
|
|
671
|
+
const on = S.config.defaults.worktree !== 'off';
|
|
672
|
+
wtBtn.classList.toggle('on', on);
|
|
673
|
+
wtBtn.innerHTML = `${icon('branch')}<span>${on ? 'Worktree' : 'In place'}</span>`;
|
|
674
|
+
wtBtn.title = on
|
|
675
|
+
? '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.'
|
|
676
|
+
: 'In place: edits the project folder directly. Click to give each task its own worktree and branch.';
|
|
677
|
+
};
|
|
678
|
+
paintWt();
|
|
679
|
+
wtBtn.onclick = () => {
|
|
680
|
+
const worktree = S.config.defaults.worktree === 'off' ? 'on' : 'off';
|
|
681
|
+
S.config.defaults.worktree = worktree;
|
|
682
|
+
paintWt();
|
|
683
|
+
api('/config', { method: 'PATCH', body: { defaults: { worktree } } }).catch(() => {});
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
// Subagents: remembered like the other picks, so a team stays set until you clear it.
|
|
687
|
+
let team = (S.config.defaults.subagents || []).filter((m) => S.agents.some((a) => a.id === m.agent));
|
|
688
|
+
let teamPreset = S.config.defaults.teamPreset || '';
|
|
689
|
+
const teamBtn = $('#teamBtn', box);
|
|
690
|
+
const paintTeam = () => paintTeamBtn(teamBtn, team, teamPreset, agent?.id);
|
|
691
|
+
paintTeam();
|
|
692
|
+
teamBtn.onclick = () =>
|
|
693
|
+
teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset }, (members, preset) => {
|
|
694
|
+
if (JSON.stringify([members, preset]) === JSON.stringify([team, teamPreset])) return;
|
|
695
|
+
team = members;
|
|
696
|
+
teamPreset = preset;
|
|
697
|
+
Object.assign(S.config.defaults, { subagents: team, teamPreset });
|
|
698
|
+
paintTeam();
|
|
699
|
+
api('/config', { method: 'PATCH', body: { defaults: { subagents: team, teamPreset } } }).catch(() => {});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// Compare: a second harness gets the same task; picks here don't touch the launcher defaults.
|
|
703
|
+
let compare = null;
|
|
704
|
+
const vsBtn = $('#compareBtn', box);
|
|
705
|
+
const runner2 = $('#runner2', box);
|
|
706
|
+
const paintCompare = () => {
|
|
707
|
+
vsBtn.parentElement.classList.toggle('cmp', !!compare);
|
|
708
|
+
vsBtn.setAttribute('aria-pressed', String(!!compare));
|
|
709
|
+
vsBtn.title = compare ? 'Stop comparing' : 'Compare: run the same task on a second harness, side by side';
|
|
710
|
+
runner2.hidden = !compare;
|
|
711
|
+
if (compare) paintRunnerBtn(runner2, compare.agent, compare.model, compare.effort);
|
|
712
|
+
};
|
|
713
|
+
paintCompare();
|
|
714
|
+
vsBtn.onclick = () => {
|
|
715
|
+
if (compare) compare = null;
|
|
716
|
+
else {
|
|
717
|
+
const other = S.agents.find((a) => a.available && a.id !== agent?.id) || agent;
|
|
718
|
+
compare = { agent: other?.id, model: savedModel(other?.id), effort: savedEffort(other?.id, savedModel(other?.id)) };
|
|
719
|
+
}
|
|
720
|
+
paintCompare();
|
|
721
|
+
};
|
|
722
|
+
runner2.onclick = () =>
|
|
723
|
+
runnerPicker(runner2, { ...compare, savedModel, savedEffort }, (id, model, effort) => {
|
|
724
|
+
compare = { agent: id, model, effort };
|
|
725
|
+
paintCompare();
|
|
726
|
+
});
|
|
534
727
|
runnerBtn.onclick = () =>
|
|
535
728
|
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
|
|
536
729
|
agent = S.agents.find((a) => a.id === id);
|
|
537
730
|
curModel = model;
|
|
538
731
|
curEffort = effort;
|
|
539
732
|
paintRunner();
|
|
733
|
+
paintTeam();
|
|
540
734
|
const d = S.config.defaults;
|
|
541
735
|
const models = { ...d.models, [id]: model };
|
|
542
736
|
// A model without levels leaves the harness's saved level alone, so switching back restores it.
|
|
@@ -552,10 +746,15 @@ function renderLauncher() {
|
|
|
552
746
|
if (!project) return openProjects({ focusAdd: true });
|
|
553
747
|
const btn = $('#launch', box);
|
|
554
748
|
btn.disabled = true;
|
|
749
|
+
btn.classList.add('busy'); // a fetch and fresh worktrees can take a second or two
|
|
555
750
|
try {
|
|
556
751
|
const attachments = await tray.ids();
|
|
557
|
-
const s = await api('/sessions', {
|
|
558
|
-
|
|
752
|
+
const s = await api('/sessions', {
|
|
753
|
+
method: 'POST',
|
|
754
|
+
body: { prompt, cwd: project.path, 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 },
|
|
755
|
+
});
|
|
756
|
+
for (const x of s.group || [s]) S.sessions.set(x.id, x);
|
|
757
|
+
if (compare) (compare = null), paintCompare();
|
|
559
758
|
ta.value = '';
|
|
560
759
|
S.drafts.delete('home');
|
|
561
760
|
tray.clear();
|
|
@@ -565,12 +764,14 @@ function renderLauncher() {
|
|
|
565
764
|
save('wisp.filter', S.filter);
|
|
566
765
|
}
|
|
567
766
|
renderFeed();
|
|
568
|
-
|
|
767
|
+
const card = S.nodes.get(s.id)?.card;
|
|
768
|
+
showLaunched(btn, card?.closest('.task-item') || card);
|
|
569
769
|
askNotify();
|
|
570
770
|
} catch (err) {
|
|
571
771
|
toast(esc(err.message), { tone: 'bad' });
|
|
572
772
|
} finally {
|
|
573
773
|
btn.disabled = false;
|
|
774
|
+
btn.classList.remove('busy');
|
|
574
775
|
ta.focus();
|
|
575
776
|
}
|
|
576
777
|
};
|
|
@@ -587,6 +788,403 @@ function renderLauncher() {
|
|
|
587
788
|
});
|
|
588
789
|
}
|
|
589
790
|
|
|
791
|
+
// The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
|
|
792
|
+
function paintRunnerBtn(btn, agentId, model, effort) {
|
|
793
|
+
const a = S.agents.find((x) => x.id === agentId);
|
|
794
|
+
const m = (a?.models || []).find((x) => x.id === model);
|
|
795
|
+
btn.innerHTML = agentId
|
|
796
|
+
? `<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')}`
|
|
797
|
+
: `<span class="rb-logo">${icon('spark')}</span><span class="rb-h">Launcher default</span>${icon('chevron', 'chev')}`;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// --- subagents -------------------------------------------------------------------
|
|
801
|
+
// A team the lead can delegate to: any harness, model and thinking level per member, plus the
|
|
802
|
+
// user's "use it for" note. Picked in the launcher, saved as presets, shown on cards and turns.
|
|
803
|
+
|
|
804
|
+
const MAX_TEAM = 6; // rows
|
|
805
|
+
const MAX_COPIES = 5; // parallel copies of one row
|
|
806
|
+
const MAX_INSTANCES = 12; // copies across the team
|
|
807
|
+
const copiesOf = (m) => Math.max(1, m.count || 1);
|
|
808
|
+
const teamSize = (team) => team.reduce((n, m) => n + copiesOf(m), 0);
|
|
809
|
+
const canLead = (id) => S.agents.find((a) => a.id === id)?.delegates !== false;
|
|
810
|
+
const memberLabel = (m) => {
|
|
811
|
+
const a = S.agents.find((x) => x.id === m.agent);
|
|
812
|
+
const model = (m.model && a?.models?.find((x) => x.id === m.model)?.label) || m.model || 'Default model';
|
|
813
|
+
return `${a?.name || agentName(m.agent)} · ${model}${m.effort ? ` · ${effortLabel(m.effort)}` : ''}`;
|
|
814
|
+
};
|
|
815
|
+
const slugName = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
|
|
816
|
+
// Same rule as the server: blank names become the harness id, repeats get -2, -3…
|
|
817
|
+
function uniqueNames(members) {
|
|
818
|
+
const taken = new Set();
|
|
819
|
+
return members.map((m) => {
|
|
820
|
+
const base = slugName(m.name) || m.agent;
|
|
821
|
+
let name = base;
|
|
822
|
+
for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
|
|
823
|
+
taken.add(name);
|
|
824
|
+
return { ...m, name };
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// The lead's logo with its subagents' tucked behind it; they fan out on hover. A member that
|
|
829
|
+
// is working pulses; one the lead never used is dimmed.
|
|
830
|
+
function teamStack(lead, team) {
|
|
831
|
+
const shown = team.slice(0, 4);
|
|
832
|
+
const tip = `${agentName(lead)} with ${team.map((m) => `${m.name}${m.busy ? ' (working)' : m.used === false ? ' (unused)' : ''}`).join(', ')}`;
|
|
833
|
+
return `<span class="team-stack" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">
|
|
834
|
+
<span class="ts-l">${harnessLogo(lead)}</span>
|
|
835
|
+
${shown.map((m, i) => `<span class="ts-m${m.used === false ? ' idle' : ''}${m.busy ? ' busy' : ''}" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}
|
|
836
|
+
${team.length > shown.length ? `<span class="ts-m ts-more" style="--i:${shown.length}">+${team.length - shown.length}</span>` : ''}
|
|
837
|
+
</span>`;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Launcher button: a stack of the members' logos and the preset's name, or just "Subagents".
|
|
841
|
+
function paintTeamBtn(btn, team, presetId, lead) {
|
|
842
|
+
const ok = canLead(lead);
|
|
843
|
+
const flat = team.flatMap((m) => Array(copiesOf(m)).fill(m));
|
|
844
|
+
const n = flat.length;
|
|
845
|
+
const p = (S.config.teams || []).find((x) => x.id === presetId);
|
|
846
|
+
btn.classList.toggle('on', team.length > 0 && ok);
|
|
847
|
+
btn.classList.toggle('off', team.length > 0 && !ok);
|
|
848
|
+
btn.innerHTML = team.length
|
|
849
|
+
? `<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>`
|
|
850
|
+
: `${icon('agent')}<span class="tb-label">Subagents</span>`;
|
|
851
|
+
btn.title = !team.length
|
|
852
|
+
? 'Subagents: give the lead a team on other harnesses to delegate to'
|
|
853
|
+
: ok
|
|
854
|
+
? `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')}`
|
|
855
|
+
: `${agentName(lead)} can't delegate (wisp can't give it tools), so this launch runs without subagents`;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function teamEditor(anchor, { lead, team, presetId }, onChange) {
|
|
859
|
+
const open = $('.team-pop');
|
|
860
|
+
if (open) return open.close();
|
|
861
|
+
let members = team.map((m) => ({ ...m }));
|
|
862
|
+
let preset = presetId;
|
|
863
|
+
const r = anchor.getBoundingClientRect();
|
|
864
|
+
const width = Math.min(540, innerWidth - 24);
|
|
865
|
+
const left = Math.max(12, Math.min(r.left, innerWidth - 12 - width));
|
|
866
|
+
const below = r.bottom + 440 < innerHeight;
|
|
867
|
+
const node = el(`
|
|
868
|
+
<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`}">
|
|
869
|
+
<header class="tp-head">
|
|
870
|
+
<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>
|
|
871
|
+
<button class="ghost xs tp-presets" aria-haspopup="menu">${icon('agent')}<span>Presets</span>${icon('chevron', 'chev')}</button>
|
|
872
|
+
</header>
|
|
873
|
+
<div class="tp-warn" hidden></div>
|
|
874
|
+
<div class="tp-list"></div>
|
|
875
|
+
<footer class="tp-foot"></footer>
|
|
876
|
+
</div>`);
|
|
877
|
+
const list = $('.tp-list', node);
|
|
878
|
+
const foot = $('.tp-foot', node);
|
|
879
|
+
const commit = () => onChange(uniqueNames(members), preset);
|
|
880
|
+
let typing;
|
|
881
|
+
const commitSoon = () => (clearTimeout(typing), (typing = setTimeout(commit, 300)));
|
|
882
|
+
|
|
883
|
+
const paintWarn = () => {
|
|
884
|
+
const w = $('.tp-warn', node);
|
|
885
|
+
w.hidden = canLead(lead);
|
|
886
|
+
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>`;
|
|
887
|
+
};
|
|
888
|
+
const paintFoot = () => {
|
|
889
|
+
foot.innerHTML = `
|
|
890
|
+
<button class="ghost sm tp-add" ${members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES ? 'disabled' : ''}>${icon('plus')}Add subagent</button>
|
|
891
|
+
<span class="tp-count" title="Agents in all, counting copies (max ${MAX_INSTANCES})">${teamSize(members)} agent${teamSize(members) === 1 ? '' : 's'}</span>
|
|
892
|
+
<span class="grow"></span>
|
|
893
|
+
${members.length ? `<button class="link xs tp-clear">Clear</button><button class="ghost sm tp-save">${icon('check')}Save as preset</button>` : ''}`;
|
|
894
|
+
};
|
|
895
|
+
const paintList = () => {
|
|
896
|
+
const named = uniqueNames(members);
|
|
897
|
+
list.innerHTML = members.length
|
|
898
|
+
? members
|
|
899
|
+
.map(
|
|
900
|
+
(m, i) => `<div class="tp-row" data-i="${i}">
|
|
901
|
+
<div class="tp-top">
|
|
902
|
+
<button class="runner-btn tp-runner" title="Harness, model and thinking level" aria-haspopup="dialog"></button>
|
|
903
|
+
<span class="tp-copies${copiesOf(m) > 1 ? ' on' : ''}" title="Copies: identical agents that take tasks in parallel">
|
|
904
|
+
<button class="tp-dec" aria-label="One copy fewer" ${copiesOf(m) <= 1 ? 'disabled' : ''}>${icon('minus')}</button>
|
|
905
|
+
<b>×${copiesOf(m)}</b>
|
|
906
|
+
<button class="tp-inc" aria-label="One copy more" ${copiesOf(m) >= MAX_COPIES || teamSize(members) >= MAX_INSTANCES ? 'disabled' : ''}>${icon('plus')}</button>
|
|
907
|
+
</span>
|
|
908
|
+
<label class="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>
|
|
909
|
+
<button class="icon-btn tp-del" title="Remove">${icon('x')}</button>
|
|
910
|
+
</div>
|
|
911
|
+
<label class="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>
|
|
912
|
+
</div>`,
|
|
913
|
+
)
|
|
914
|
+
.join('')
|
|
915
|
+
: `<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>`;
|
|
916
|
+
$$('.tp-row', list).forEach((row, i) => paintRunnerBtn($('.tp-runner', row), members[i].agent, members[i].model, members[i].effort));
|
|
917
|
+
paintFoot();
|
|
918
|
+
};
|
|
919
|
+
const add = () => {
|
|
920
|
+
if (members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES) return;
|
|
921
|
+
const used = new Set([lead, ...members.map((m) => m.agent)]);
|
|
922
|
+
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);
|
|
923
|
+
if (!a) return toast('No harness is installed.', { tone: 'bad' });
|
|
924
|
+
const model = savedModel(a.id);
|
|
925
|
+
members.push({ name: '', agent: a.id, model, effort: savedEffort(a.id, model), use: '', count: 1 });
|
|
926
|
+
paintList();
|
|
927
|
+
commit();
|
|
928
|
+
$(`.tp-row[data-i="${members.length - 1}"] .tp-use input`, list)?.focus();
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
// Save: name it; an existing preset's name updates that preset.
|
|
932
|
+
const askName = () => {
|
|
933
|
+
const cur = (S.config.teams || []).find((x) => x.id === preset);
|
|
934
|
+
foot.innerHTML = `<form class="tp-saveform"><input 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>`;
|
|
935
|
+
const input = $('input', foot);
|
|
936
|
+
input.focus();
|
|
937
|
+
input.select();
|
|
938
|
+
$('.tp-cancel', foot).onclick = paintFoot;
|
|
939
|
+
$('form', foot).onsubmit = async (e) => {
|
|
940
|
+
e.preventDefault();
|
|
941
|
+
const name = input.value.trim();
|
|
942
|
+
if (!name) return input.focus();
|
|
943
|
+
const same = (S.config.teams || []).find((x) => x.name.toLowerCase() === name.toLowerCase());
|
|
944
|
+
try {
|
|
945
|
+
const p = await api(same ? `/teams/${same.id}` : '/teams', { method: same ? 'PATCH' : 'POST', body: { name, members: uniqueNames(members) } });
|
|
946
|
+
S.config.teams = [...(S.config.teams || []).filter((x) => x.id !== p.id), p];
|
|
947
|
+
preset = p.id;
|
|
948
|
+
commit();
|
|
949
|
+
paintFoot();
|
|
950
|
+
toast(`${same ? 'Updated' : 'Saved'} preset <b>${esc(p.name)}</b>`);
|
|
951
|
+
} catch (err) {
|
|
952
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
const presetMenu = (btn) => {
|
|
958
|
+
const prev = $('.menu.tp-menu');
|
|
959
|
+
if (prev) return prev.close();
|
|
960
|
+
const teams = S.config.teams || [];
|
|
961
|
+
const b = btn.getBoundingClientRect();
|
|
962
|
+
const menu = el(`
|
|
963
|
+
<div class="menu tp-menu" role="menu" style="top:${b.bottom + 4}px;right:${innerWidth - b.right}px">
|
|
964
|
+
${teams.length
|
|
965
|
+
? teams
|
|
966
|
+
.map(
|
|
967
|
+
(t) => `<button role="menuitem" data-id="${esc(t.id)}" class="${t.id === preset ? 'on' : ''}">
|
|
968
|
+
<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>
|
|
969
|
+
<span class="mm-text"><b>${esc(t.name)}</b><small>${esc(t.members.map((m) => m.name).join(', '))}</small></span>
|
|
970
|
+
<span class="tp-mdel" data-del="${esc(t.id)}" title="Delete preset">${icon('trash')}</span>
|
|
971
|
+
</button>`,
|
|
972
|
+
)
|
|
973
|
+
.join('')
|
|
974
|
+
: `<div class="tp-mempty">No presets yet. Set up a team, then <b>Save as preset</b>.</div>`}
|
|
975
|
+
</div>`);
|
|
976
|
+
const close = () => (menu.remove(), document.removeEventListener('mousedown', outsideMenu, true));
|
|
977
|
+
const outsideMenu = (ev) => !menu.contains(ev.target) && !btn.contains(ev.target) && close();
|
|
978
|
+
menu.close = close;
|
|
979
|
+
setTimeout(() => document.addEventListener('mousedown', outsideMenu, true));
|
|
980
|
+
menu.onclick = async (ev) => {
|
|
981
|
+
const del = ev.target.closest('[data-del]');
|
|
982
|
+
if (del) {
|
|
983
|
+
ev.stopPropagation();
|
|
984
|
+
const t = teams.find((x) => x.id === del.dataset.del);
|
|
985
|
+
await api(`/teams/${t.id}`, { method: 'DELETE' }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
986
|
+
S.config.teams = (S.config.teams || []).filter((x) => x.id !== t.id);
|
|
987
|
+
if (preset === t.id) (preset = ''), commit();
|
|
988
|
+
close();
|
|
989
|
+
return toast(`Deleted preset <b>${esc(t.name)}</b>`);
|
|
990
|
+
}
|
|
991
|
+
const item = ev.target.closest('button[data-id]');
|
|
992
|
+
if (!item) return;
|
|
993
|
+
const t = teams.find((x) => x.id === item.dataset.id);
|
|
994
|
+
members = t.members.map((m) => ({ ...m }));
|
|
995
|
+
preset = t.id;
|
|
996
|
+
close();
|
|
997
|
+
paintList();
|
|
998
|
+
commit();
|
|
999
|
+
};
|
|
1000
|
+
document.body.append(menu);
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
list.addEventListener('click', (e) => {
|
|
1004
|
+
const row = e.target.closest('.tp-row');
|
|
1005
|
+
if (!row) return;
|
|
1006
|
+
const i = Number(row.dataset.i);
|
|
1007
|
+
if (e.target.closest('.tp-del')) {
|
|
1008
|
+
members.splice(i, 1);
|
|
1009
|
+
paintList();
|
|
1010
|
+
return commit();
|
|
1011
|
+
}
|
|
1012
|
+
const step = e.target.closest('.tp-inc') ? 1 : e.target.closest('.tp-dec') ? -1 : 0;
|
|
1013
|
+
if (step) {
|
|
1014
|
+
const m = members[i];
|
|
1015
|
+
const next = copiesOf(m) + step;
|
|
1016
|
+
if (next < 1 || next > MAX_COPIES || (step > 0 && teamSize(members) >= MAX_INSTANCES)) return;
|
|
1017
|
+
m.count = next;
|
|
1018
|
+
paintList();
|
|
1019
|
+
$(`.tp-row[data-i="${i}"] ${step > 0 ? '.tp-inc' : '.tp-dec'}`, list)?.focus();
|
|
1020
|
+
return commit();
|
|
1021
|
+
}
|
|
1022
|
+
const rb = e.target.closest('.tp-runner');
|
|
1023
|
+
if (rb) {
|
|
1024
|
+
const m = members[i];
|
|
1025
|
+
runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort }, (agent, model, effort) => {
|
|
1026
|
+
Object.assign(m, { agent, model, effort });
|
|
1027
|
+
paintList();
|
|
1028
|
+
commit();
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
list.addEventListener('input', (e) => {
|
|
1033
|
+
const row = e.target.closest('.tp-row');
|
|
1034
|
+
if (!row) return;
|
|
1035
|
+
const m = members[Number(row.dataset.i)];
|
|
1036
|
+
if (e.target.closest('.tp-name')) m.name = e.target.value;
|
|
1037
|
+
else m.use = e.target.value;
|
|
1038
|
+
commitSoon();
|
|
1039
|
+
});
|
|
1040
|
+
foot.addEventListener('click', (e) => {
|
|
1041
|
+
if (e.target.closest('.tp-add')) add();
|
|
1042
|
+
if (e.target.closest('.tp-save')) askName();
|
|
1043
|
+
if (e.target.closest('.tp-clear')) {
|
|
1044
|
+
members = [];
|
|
1045
|
+
preset = '';
|
|
1046
|
+
paintList();
|
|
1047
|
+
commit();
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
$('.tp-presets', node).onclick = (e) => presetMenu(e.currentTarget);
|
|
1051
|
+
|
|
1052
|
+
const close = () => {
|
|
1053
|
+
clearTimeout(typing);
|
|
1054
|
+
commit();
|
|
1055
|
+
node.remove();
|
|
1056
|
+
$('.menu.tp-menu')?.close();
|
|
1057
|
+
anchor.classList.remove('open');
|
|
1058
|
+
document.removeEventListener('mousedown', outside);
|
|
1059
|
+
};
|
|
1060
|
+
node.close = close;
|
|
1061
|
+
// The harness picker and preset menu float outside the popover; clicks there keep it open.
|
|
1062
|
+
const outside = (e) => !node.contains(e.target) && !anchor.contains(e.target) && !e.target.closest('.picker, .tp-menu') && close();
|
|
1063
|
+
node.addEventListener('keydown', (e) => e.key === 'Escape' && (e.stopPropagation(), close(), anchor.focus()));
|
|
1064
|
+
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
1065
|
+
paintWarn();
|
|
1066
|
+
paintList();
|
|
1067
|
+
document.body.append(node);
|
|
1068
|
+
anchor.classList.add('open');
|
|
1069
|
+
if (!members.length) $('.tp-add', node)?.focus();
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// One delegation with the child session and turn it ran as (from the lead's childSessions).
|
|
1073
|
+
function delegationsOf(s, t) {
|
|
1074
|
+
return (t.delegations || []).map((d) => {
|
|
1075
|
+
const child = s.childSessions?.[d.sessionId];
|
|
1076
|
+
return { d, child, ct: child?.turns.find((x) => x.id === d.turnId) };
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
const subKey = (s, t) => delegationsOf(s, t).map(({ ct }) => ct?.status || '?').join(',');
|
|
1080
|
+
const subStatus = (ct) => {
|
|
1081
|
+
if (!ct || ct.status === 'running') return { cls: 'waiting', label: 'Working' };
|
|
1082
|
+
if (ct.status === 'stopped') return { cls: 'partial', label: 'Stopped' };
|
|
1083
|
+
if (ct.status === 'error') return { cls: 'blocked', label: 'Failed' };
|
|
1084
|
+
const st = ct.presentation?.blocks?.find((b) => b.type === 'hero')?.status || 'done';
|
|
1085
|
+
return { cls: st, label: STATUS_LABEL[st] || 'Done' };
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
// While the lead works: one live line per subagent it's waiting on.
|
|
1089
|
+
function teamLiveHtml(s, t) {
|
|
1090
|
+
const list = delegationsOf(s, t);
|
|
1091
|
+
if (!list.length) return '';
|
|
1092
|
+
return `<div class="team-live">${list
|
|
1093
|
+
.map(({ d, ct }) => {
|
|
1094
|
+
const running = !ct || ct.status === 'running';
|
|
1095
|
+
const hero = ct?.presentation?.blocks?.find((b) => b.type === 'hero');
|
|
1096
|
+
const last = ct?.activity?.findLast((a) => a.kind !== 'text')?.label;
|
|
1097
|
+
const st = subStatus(ct);
|
|
1098
|
+
return `<div class="tl-row ${running ? 'live' : `st-${st.cls}`}" data-sub="${esc(d.sessionId)}">
|
|
1099
|
+
<span class="tl-logo">${harnessLogo(d.agent)}</span>
|
|
1100
|
+
<b class="tl-name">@${esc(d.name)}</b>
|
|
1101
|
+
<span class="tl-live">${esc(running ? last || 'Starting up' : hero?.title || st.label)}</span>
|
|
1102
|
+
${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>`}
|
|
1103
|
+
</div>`;
|
|
1104
|
+
})
|
|
1105
|
+
.join('')}</div>`;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// After the run: the team's reports, one card each, opening into the subagent's own presentation.
|
|
1109
|
+
const subOpen = new Set(); // delegation ids the user unfolded
|
|
1110
|
+
function renderSubs(s, t) {
|
|
1111
|
+
const list = delegationsOf(s, t);
|
|
1112
|
+
if (!list.length) return null;
|
|
1113
|
+
const names = [...new Set(list.map(({ d }) => `@${d.name}`))];
|
|
1114
|
+
const node = el(`
|
|
1115
|
+
<section class="subs">
|
|
1116
|
+
<div class="subs-head">
|
|
1117
|
+
${teamStack(s.agent, list.map(({ d, ct }) => ({ name: d.name, agent: d.agent, busy: ct?.status === 'running' })))}
|
|
1118
|
+
<b>${list.length} delegated task${list.length > 1 ? 's' : ''}</b>
|
|
1119
|
+
<span class="subs-to">${esc(names.join(', '))}</span>
|
|
1120
|
+
</div>
|
|
1121
|
+
<div class="subs-list"></div>
|
|
1122
|
+
</section>`);
|
|
1123
|
+
const box = $('.subs-list', node);
|
|
1124
|
+
for (const { d, child, ct } of list) {
|
|
1125
|
+
const st = subStatus(ct);
|
|
1126
|
+
const hero = ct?.presentation?.blocks?.find((b) => b.type === 'hero');
|
|
1127
|
+
const files = ct?.changes?.length || 0;
|
|
1128
|
+
const card = el(`
|
|
1129
|
+
<article class="sub st-${st.cls} ${subOpen.has(d.id) ? 'open' : ''}">
|
|
1130
|
+
<button class="sub-head" aria-expanded="${subOpen.has(d.id)}">
|
|
1131
|
+
<span class="sub-logo">${harnessLogo(d.agent)}</span>
|
|
1132
|
+
<span class="sub-main">
|
|
1133
|
+
<span class="sub-top"><b>@${esc(d.name)}</b><span class="sub-model">${esc(memberLabel(d))}</span></span>
|
|
1134
|
+
<span class="sub-title">${esc(hero?.title || (ct?.status === 'running' ? ct.activity?.findLast((a) => a.kind !== 'text')?.label || 'Working' : d.prompt))}</span>
|
|
1135
|
+
</span>
|
|
1136
|
+
<span class="sub-side">
|
|
1137
|
+
${files ? `<span class="sub-files">${files} file${files > 1 ? 's' : ''}</span>` : ''}
|
|
1138
|
+
${ct?.endedAt ? `<span class="sub-dur">${fmtDuration(ct.endedAt - ct.startedAt)}</span>` : ''}
|
|
1139
|
+
<span class="pill st-${st.cls}"><i></i>${st.label}</span>
|
|
1140
|
+
${icon('chevron', 'chev')}
|
|
1141
|
+
</span>
|
|
1142
|
+
</button>
|
|
1143
|
+
<div class="sub-body" ${subOpen.has(d.id) ? '' : 'hidden'}></div>
|
|
1144
|
+
</article>`);
|
|
1145
|
+
const body = $('.sub-body', card);
|
|
1146
|
+
const fill = () => {
|
|
1147
|
+
body.innerHTML = `<div class="sub-task"><span>Task from ${esc(agentName(s.agent))}</span><p>${esc(d.prompt)}</p></div>`;
|
|
1148
|
+
if (ct?.presentation && child) {
|
|
1149
|
+
// Its "next" and question blocks draft into the lead's composer: the lead stays in charge.
|
|
1150
|
+
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}`) };
|
|
1151
|
+
body.append(renderPresentation(ct.presentation, ctx));
|
|
1152
|
+
} else if (ct?.status === 'running') body.append(el(`<p class="muted sub-wait">Still working…</p>`));
|
|
1153
|
+
};
|
|
1154
|
+
if (subOpen.has(d.id)) fill();
|
|
1155
|
+
$('.sub-head', card).onclick = () => {
|
|
1156
|
+
const opening = body.hidden;
|
|
1157
|
+
opening ? subOpen.add(d.id) : subOpen.delete(d.id);
|
|
1158
|
+
card.classList.toggle('open', opening);
|
|
1159
|
+
$('.sub-head', card).setAttribute('aria-expanded', String(opening));
|
|
1160
|
+
if (opening) fill();
|
|
1161
|
+
body.hidden = !opening;
|
|
1162
|
+
};
|
|
1163
|
+
box.append(card);
|
|
1164
|
+
}
|
|
1165
|
+
return node;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function draftInto(id, text) {
|
|
1169
|
+
const ta = $('.reply', S.nodes.get(id)?.card);
|
|
1170
|
+
if (!ta) return;
|
|
1171
|
+
ta.value = text;
|
|
1172
|
+
ta.dispatchEvent(new Event('input'));
|
|
1173
|
+
ta.focus();
|
|
1174
|
+
ta.setSelectionRange(text.length, text.length);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// A subagent's summary: its lead's card shows it, so update the live line or re-fetch the lead.
|
|
1178
|
+
const childState = new Map();
|
|
1179
|
+
function onChildSummary(c) {
|
|
1180
|
+
const row = $(`.tl-row[data-sub="${CSS.escape(c.id)}"] .tl-live`);
|
|
1181
|
+
if (row && c.state === 'running' && c.live) row.textContent = c.live;
|
|
1182
|
+
if (childState.get(c.id) !== c.state) {
|
|
1183
|
+
childState.set(c.id, c.state);
|
|
1184
|
+
scheduleRefetch(c.parentId);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
590
1188
|
function modeMenu(anchor, current, pick) {
|
|
591
1189
|
const open = $('.menu.mode');
|
|
592
1190
|
if (open) return open.close();
|
|
@@ -640,9 +1238,68 @@ function renderLaunchChips() {
|
|
|
640
1238
|
launchProject = b.dataset.id;
|
|
641
1239
|
save('wisp.launchProject', launchProject);
|
|
642
1240
|
$$('.lp-chip', box).forEach((x) => x.classList.toggle('on', x === b));
|
|
1241
|
+
renderLaunchRecipes();
|
|
643
1242
|
}),
|
|
644
1243
|
);
|
|
645
1244
|
$('#lpAdd', box).onclick = () => openProjects({ focusAdd: true });
|
|
1245
|
+
renderLaunchRecipes();
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Recipes for the picked project (and the ones tied to none), one click from the launcher.
|
|
1249
|
+
// With text in the box, "Save as recipe" turns it into one.
|
|
1250
|
+
function renderLaunchRecipes() {
|
|
1251
|
+
const row = $('#launcher .l-recipes');
|
|
1252
|
+
if (!row) return;
|
|
1253
|
+
const path = projectById(launchProject)?.path;
|
|
1254
|
+
const all = S.config.recipes || [];
|
|
1255
|
+
const list = all.filter((r) => !r.cwd || r.cwd === path).sort((a, b) => (b.lastRunAt || 0) - (a.lastRunAt || 0));
|
|
1256
|
+
const hidden = all.length - list.length;
|
|
1257
|
+
row.innerHTML = `
|
|
1258
|
+
<span class="l-in">${icon('spark')}Recipes</span>
|
|
1259
|
+
${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('')}
|
|
1260
|
+
${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>`}
|
|
1261
|
+
<button class="lp-chip add" id="lrSave" hidden>${icon('plus')}Save as recipe</button>
|
|
1262
|
+
<button class="lp-chip add" id="lrNew">${icon('plus')}New</button>
|
|
1263
|
+
<span class="grow"></span>
|
|
1264
|
+
<button class="link xs" id="lrAll">Manage</button>`;
|
|
1265
|
+
row.onclick = (e) => {
|
|
1266
|
+
const b = e.target.closest('button');
|
|
1267
|
+
if (!b) return;
|
|
1268
|
+
if (b.dataset.recipe) {
|
|
1269
|
+
const r = all.find((x) => x.id === b.dataset.recipe);
|
|
1270
|
+
return r && runRecipe({ ...r, params: recipeParams(r.prompt) });
|
|
1271
|
+
}
|
|
1272
|
+
const draft = { name: '', prompt: '', cwd: path || '', mode: $('#launcher').mode?.() };
|
|
1273
|
+
if (b.id === 'lrSave') openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft: { ...draft, prompt: $('#prompt').value.trim() } } });
|
|
1274
|
+
if (b.id === 'lrNew') openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft } });
|
|
1275
|
+
if (b.id === 'lrAll') openAutomations({ tab: 'recipes' });
|
|
1276
|
+
};
|
|
1277
|
+
paintSaveRecipe();
|
|
1278
|
+
}
|
|
1279
|
+
function paintSaveRecipe() {
|
|
1280
|
+
const has = !!$('#prompt')?.value.trim();
|
|
1281
|
+
const save = $('#lrSave');
|
|
1282
|
+
if (!save) return;
|
|
1283
|
+
save.hidden = !has;
|
|
1284
|
+
$('#lrNew').hidden = has;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Top bar: always one click to recipes and triggers. The badge counts triggers that are on;
|
|
1288
|
+
// the tooltip says what fires next.
|
|
1289
|
+
async function paintAutoBtn() {
|
|
1290
|
+
const btn = $('#autoBtn');
|
|
1291
|
+
const on = (S.config.triggers || []).filter((t) => t.enabled);
|
|
1292
|
+
const n = $('.ab-n', btn);
|
|
1293
|
+
n.hidden = !on.length;
|
|
1294
|
+
n.textContent = on.length;
|
|
1295
|
+
btn.classList.toggle('live', !!on.length);
|
|
1296
|
+
const r = S.config.recipes?.length || 0;
|
|
1297
|
+
let title = `Automations: ${r} recipe${r === 1 ? '' : 's'} · ${on.length} trigger${on.length === 1 ? '' : 's'} on`;
|
|
1298
|
+
if (on.length) {
|
|
1299
|
+
const next = (await api('/triggers').catch(() => [])).filter((t) => t.enabled && t.nextAt).sort((a, b) => a.nextAt - b.nextAt)[0];
|
|
1300
|
+
if (next) title += `\nNext: ${next.name} · ${whenLabel(next.nextAt)}`;
|
|
1301
|
+
}
|
|
1302
|
+
btn.title = title;
|
|
646
1303
|
}
|
|
647
1304
|
|
|
648
1305
|
// A little wisp flies from the launch button to the new item in the feed.
|
|
@@ -668,6 +1325,18 @@ function launchedFx(from, to) {
|
|
|
668
1325
|
).onfinish = () => w.remove();
|
|
669
1326
|
}
|
|
670
1327
|
|
|
1328
|
+
// A new card lands in Working, below everything in "Needs you", so it's often off-screen. Scroll
|
|
1329
|
+
// it into view (just enough) and flash it; the flying wisp only plays when the card is already
|
|
1330
|
+
// on screen, since the launcher it starts from may scroll away or fold into the dock.
|
|
1331
|
+
function showLaunched(btn, card) {
|
|
1332
|
+
if (!card) return;
|
|
1333
|
+
const r = card.getBoundingClientRect();
|
|
1334
|
+
if (r.top >= topInset() && r.bottom <= innerHeight) return launchedFx(btn, card);
|
|
1335
|
+
card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
1336
|
+
card.classList.add('arrived');
|
|
1337
|
+
setTimeout(() => card.classList.remove('arrived'), 1600);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
671
1340
|
// --- project filter (top bar) ---------------------------------------------------
|
|
672
1341
|
|
|
673
1342
|
function renderProjects() {
|
|
@@ -692,6 +1361,7 @@ function renderProjects() {
|
|
|
692
1361
|
S.filter = S.filter === b.dataset.id ? 'all' : b.dataset.id;
|
|
693
1362
|
save('wisp.filter', S.filter);
|
|
694
1363
|
renderFeed();
|
|
1364
|
+
feedToTop();
|
|
695
1365
|
renderLaunchChips();
|
|
696
1366
|
}),
|
|
697
1367
|
);
|
|
@@ -761,6 +1431,8 @@ function ctxFor(s, turn, isLatest) {
|
|
|
761
1431
|
openFile: (p, line) => openInEditor(s, p, line),
|
|
762
1432
|
fileUrl: (p) => `/api/sessions/${s.id}/file?path=${encodeURIComponent(p)}`,
|
|
763
1433
|
zoom: (src) => overlay(el(`<img class="lightbox" src="${esc(src)}">`)),
|
|
1434
|
+
revertFile: (file, restore) => revertFile(s.id, turn.id, file, restore),
|
|
1435
|
+
exec: (command, key) => execAction(s.id, turn.id, command, key),
|
|
764
1436
|
};
|
|
765
1437
|
}
|
|
766
1438
|
|
|
@@ -781,11 +1453,12 @@ function renderBody(id, { fresh }) {
|
|
|
781
1453
|
<div class="it-meta"></div>
|
|
782
1454
|
<div class="it-actions">
|
|
783
1455
|
<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="
|
|
1456
|
+
<button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}</button>
|
|
1457
|
+
<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
1458
|
<button class="ghost xs" data-a="more" title="More">${icon('dots')}</button>
|
|
787
1459
|
</div>
|
|
788
1460
|
</div>
|
|
1461
|
+
<div class="pr-strip" hidden></div>
|
|
789
1462
|
<button class="link xs earlier" hidden></button>
|
|
790
1463
|
<div class="turns"></div>
|
|
791
1464
|
<div class="it-dock"></div>
|
|
@@ -796,7 +1469,7 @@ function renderBody(id, { fresh }) {
|
|
|
796
1469
|
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
797
1470
|
if (a === 'replay') replay(id);
|
|
798
1471
|
if (a === 'takeover') takeOver(id);
|
|
799
|
-
if (a === '
|
|
1472
|
+
if (a === 'ship') shipDialog(id);
|
|
800
1473
|
if (a === 'more') moreMenu(id, e.target.closest('button'));
|
|
801
1474
|
});
|
|
802
1475
|
$('.earlier', body).onclick = () => ((n.showAll = true), renderBody(id, { fresh: false }));
|
|
@@ -805,17 +1478,25 @@ function renderBody(id, { fresh }) {
|
|
|
805
1478
|
|
|
806
1479
|
const body = n.body;
|
|
807
1480
|
const proj = projectById(summary.projectId);
|
|
1481
|
+
// The head already shows the harness, model and project, the task head has Compare, and Done
|
|
1482
|
+
// sits by the composer. This bar only says how the run was set up: a mode worth noticing
|
|
1483
|
+
// (Plan or Full auto, as on the launcher), the branch, and what launched it.
|
|
1484
|
+
const mode = MODES.find((m) => m.id === s.mode);
|
|
1485
|
+
const where = (proj?.path || s.cwd).replace(S.config.home || '', '~');
|
|
1486
|
+
$('.it-meta', body).title = [agentName(s.agent), s.model, s.effort && `${effortLabel(s.effort)} thinking`, mode?.label, where].filter(Boolean).join(' · ');
|
|
808
1487
|
$('.it-meta', body).innerHTML = `
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
${s.
|
|
812
|
-
${s.
|
|
813
|
-
|
|
814
|
-
//
|
|
1488
|
+
${mode && mode.id !== 'edits' ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
|
|
1489
|
+
${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
|
|
1490
|
+
${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>` : ''}
|
|
1491
|
+
${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>` : ''}`;
|
|
1492
|
+
const live = s.state === 'running' || s.state === 'waiting';
|
|
1493
|
+
// Ship sits in the top bar and again next to the composer, so it's there after reading the result.
|
|
1494
|
+
$$('[data-a="ship"]', body).forEach((b) => (b.hidden = !s.worktree || live || s.agent === 'wisp' || s.pr?.state === 'MERGED'));
|
|
1495
|
+
renderPrStrip(id);
|
|
815
1496
|
$$('[data-a="done"]', body).forEach((b) => {
|
|
816
1497
|
b.classList.toggle('on', s.state === 'done');
|
|
817
1498
|
b.hidden = s.state === 'running' || s.state === 'waiting';
|
|
818
|
-
$('span', b).textContent = s.state === 'done' ? 'Reopen' :
|
|
1499
|
+
$('span', b).textContent = s.state === 'done' ? 'Reopen' : 'Mark done';
|
|
819
1500
|
});
|
|
820
1501
|
$('[data-a="takeover"]', body).hidden = !s.agentSessionId;
|
|
821
1502
|
|
|
@@ -834,7 +1515,8 @@ function renderBody(id, { fresh }) {
|
|
|
834
1515
|
turns.forEach((t) => {
|
|
835
1516
|
const isLatest = t.id === latestId;
|
|
836
1517
|
const folded = !isLatest && !n.unfolded.has(t.id);
|
|
837
|
-
const
|
|
1518
|
+
const revKey = `${t.rolledBackAt || 0}:${(t.changes || []).map((c) => (c.reverted ? 1 : 0)).join('')}`;
|
|
1519
|
+
const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}`;
|
|
838
1520
|
const prev = n.turnNodes.get(t.id);
|
|
839
1521
|
let node = prev?.node;
|
|
840
1522
|
if (!prev || prev.key !== key) {
|
|
@@ -851,12 +1533,54 @@ function renderBody(id, { fresh }) {
|
|
|
851
1533
|
for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
|
|
852
1534
|
|
|
853
1535
|
updateDock(id);
|
|
854
|
-
if (firstNew &&
|
|
1536
|
+
if (firstNew && watching(id)) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
|
855
1537
|
}
|
|
856
1538
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1539
|
+
// Whether the user is reading a card, not just catching its edge at the top or bottom of the
|
|
1540
|
+
// screen: it crosses the middle of the viewport, or it's focused and a fair share of it shows.
|
|
1541
|
+
// Anything that moves the page on a card's behalf asks this first.
|
|
1542
|
+
function watching(id) {
|
|
1543
|
+
const card = S.nodes.get(id)?.card;
|
|
1544
|
+
if (!card?.isConnected) return false;
|
|
1545
|
+
const r = card.getBoundingClientRect();
|
|
1546
|
+
const mid = innerHeight / 2;
|
|
1547
|
+
if (r.top <= mid && r.bottom >= mid) return true;
|
|
1548
|
+
const shown = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0);
|
|
1549
|
+
return S.focus === id && shown >= Math.min(r.height, innerHeight) / 3;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const sectionOf = (s) => {
|
|
1553
|
+
const t = taskOf(s);
|
|
1554
|
+
return t.archived ? 'archived' : t.state === 'running' ? 'working' : t.state === 'done' ? 'done' : 'needs';
|
|
1555
|
+
};
|
|
1556
|
+
|
|
1557
|
+
// Run a DOM change without moving `card` on screen. The browser's own scroll anchoring misses
|
|
1558
|
+
// cards that are moved between lists, so feed re-renders do this explicitly.
|
|
1559
|
+
function keepInPlace(card, change) {
|
|
1560
|
+
const top = card?.isConnected ? card.getBoundingClientRect().top : null;
|
|
1561
|
+
change();
|
|
1562
|
+
if (top !== null && card.isConnected) scrollByPx(card.getBoundingClientRect().top - top);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// Any write to scrollTop cancels a smooth scroll in flight (j/k, Open, a launch), so only
|
|
1566
|
+
// write when there's an actual shift to undo.
|
|
1567
|
+
function scrollByPx(dy) {
|
|
1568
|
+
if (Math.abs(dy) >= 1) stage.scrollTop += dy;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// The card to hold still while the feed changes around it: the first one showing under the top
|
|
1572
|
+
// bar and docked launcher, skipping `id` (the card that's changing). None at the very top of the page, where new
|
|
1573
|
+
// cards should push the list down, or when `id` itself is the top card on screen: then the next
|
|
1574
|
+
// one should slide up into its place.
|
|
1575
|
+
function feedAnchor(id) {
|
|
1576
|
+
if (stage.scrollTop < 1) return null;
|
|
1577
|
+
const moving = id && S.nodes.get(id)?.card;
|
|
1578
|
+
const inset = topInset();
|
|
1579
|
+
for (const card of $$('#feed .item')) {
|
|
1580
|
+
const r = card.getBoundingClientRect();
|
|
1581
|
+
if (r.bottom > inset) return card === moving ? null : card;
|
|
1582
|
+
}
|
|
1583
|
+
return null;
|
|
860
1584
|
}
|
|
861
1585
|
|
|
862
1586
|
function renderFoldedTurn(t, unfold) {
|
|
@@ -866,6 +1590,7 @@ function renderFoldedTurn(t, unfold) {
|
|
|
866
1590
|
<button class="t-fold" title="Show this exchange">
|
|
867
1591
|
${icon('chevron', 'chev')}
|
|
868
1592
|
<span class="t-prompt">${esc(t.prompt.replace(/\s+/g, ' '))}</span>
|
|
1593
|
+
${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
|
|
869
1594
|
${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
|
|
870
1595
|
<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
|
|
871
1596
|
</button>
|
|
@@ -876,14 +1601,21 @@ function renderFoldedTurn(t, unfold) {
|
|
|
876
1601
|
|
|
877
1602
|
// `fold` is set for older turns the user unfolded, so they can tuck them away again.
|
|
878
1603
|
function renderTurn(s, t, isLatest, fold) {
|
|
879
|
-
const node = el(`<article class="turn" data-turn="${t.id}"></article>`);
|
|
1604
|
+
const node = el(`<article class="turn ${t.rolledBackAt ? 'is-rolled-back' : ''}" data-turn="${t.id}"></article>`);
|
|
880
1605
|
const long = t.prompt.length > 360 || t.prompt.split('\n').length > 5;
|
|
1606
|
+
const cp = t.checkpoint;
|
|
1607
|
+
const cpLabel = cp?.head ? `${cp.branch ? `${cp.branch}@` : ''}${cp.head.slice(0, 7)}${cp.dirty ? '*' : ''}` : '';
|
|
881
1608
|
const ask = el(`
|
|
882
1609
|
<div class="ask">
|
|
883
1610
|
<div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
|
|
884
1611
|
${long ? '<button class="link xs more-btn">Show all</button>' : ''}
|
|
885
1612
|
${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"
|
|
1613
|
+
<div class="ask-foot">
|
|
1614
|
+
${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}
|
|
1615
|
+
<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
|
|
1616
|
+
${cpLabel ? `<span class="cp-sha" title="Git checkpoint before this turn (${esc(cp.head)}${cp.dirty ? ' + uncommitted changes' : ''})">${icon('branch')}${esc(cpLabel)}</span>` : ''}
|
|
1617
|
+
${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
|
|
1618
|
+
</div>
|
|
887
1619
|
${fold ? `<button class="ghost xs t-unfold" title="Collapse this exchange">${icon('chevron', 'chev')}</button>` : ''}
|
|
888
1620
|
</div>`);
|
|
889
1621
|
if (fold) ask.querySelector('.t-unfold').onclick = fold;
|
|
@@ -898,6 +1630,8 @@ function renderTurn(s, t, isLatest, fold) {
|
|
|
898
1630
|
node.append(renderWorking(s, t));
|
|
899
1631
|
} else {
|
|
900
1632
|
node.append(renderPresentation(t.presentation, ctxFor(s, t, isLatest)));
|
|
1633
|
+
const subs = renderSubs(s, t);
|
|
1634
|
+
if (subs) node.append(subs);
|
|
901
1635
|
node.append(renderTrail(s, t));
|
|
902
1636
|
}
|
|
903
1637
|
return node;
|
|
@@ -915,6 +1649,7 @@ function renderWorking(s, t) {
|
|
|
915
1649
|
<span class="w-count">${t.activity.filter((a) => a.kind !== 'text').length} actions</span>
|
|
916
1650
|
</div>
|
|
917
1651
|
<div class="ask-slot"></div>
|
|
1652
|
+
${teamLiveHtml(s, t)}
|
|
918
1653
|
<ul class="ticker"></ul>
|
|
919
1654
|
<div class="w-actions">
|
|
920
1655
|
<button class="link xs peek">Show all activity</button>
|
|
@@ -985,6 +1720,8 @@ function renderTrail(s, t) {
|
|
|
985
1720
|
return { add: acc.add + n.add, del: acc.del + n.del };
|
|
986
1721
|
}, { add: 0, del: 0 });
|
|
987
1722
|
const st = t.stats || {};
|
|
1723
|
+
const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (t.changes?.length || t.checkpoint?.commit);
|
|
1724
|
+
const rolledBack = !!t.rolledBackAt;
|
|
988
1725
|
const node = el(`
|
|
989
1726
|
<div class="trail">
|
|
990
1727
|
<div class="trail-bar">
|
|
@@ -992,6 +1729,7 @@ function renderTrail(s, t) {
|
|
|
992
1729
|
${t.endedAt ? `<span class="tb">${fmtDuration(t.endedAt - t.startedAt)}</span>` : ''}
|
|
993
1730
|
${st.costUsd ? `<span class="tb">$${st.costUsd.toFixed(2)}</span>` : ''}
|
|
994
1731
|
<span class="grow"></span>
|
|
1732
|
+
${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
1733
|
${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
1734
|
${acts.length ? `<button class="link xs" data-tab="activity">Activity</button>` : ''}
|
|
997
1735
|
${t.presentation?.notes ? `<button class="link xs" data-tab="notes">Agent notes</button>` : ''}
|
|
@@ -1001,6 +1739,9 @@ function renderTrail(s, t) {
|
|
|
1001
1739
|
const body = $('.trail-body', node);
|
|
1002
1740
|
let open = null;
|
|
1003
1741
|
node.addEventListener('click', (e) => {
|
|
1742
|
+
if (e.target.closest('[data-a="rollback"]')) {
|
|
1743
|
+
return rollbackTurn(s.id, t.id, rolledBack);
|
|
1744
|
+
}
|
|
1004
1745
|
const b = e.target.closest('[data-tab]');
|
|
1005
1746
|
if (!b) return;
|
|
1006
1747
|
const tab = b.dataset.tab;
|
|
@@ -1055,6 +1796,7 @@ function renderDock(id) {
|
|
|
1055
1796
|
<div class="c-bar">
|
|
1056
1797
|
<button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
|
|
1057
1798
|
<span class="c-hint"></span>
|
|
1799
|
+
<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
1800
|
<button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
|
|
1059
1801
|
<button class="ghost xs c-queue" hidden>${icon('queue')}<span>Queue</span></button>
|
|
1060
1802
|
<button class="send" title="Send (Enter · Shift+Enter for newline)">${icon('enter')}</button>
|
|
@@ -1068,6 +1810,7 @@ function renderDock(id) {
|
|
|
1068
1810
|
acceptFiles(comp, ta, n.tray);
|
|
1069
1811
|
$('.c-attach', comp).onclick = n.tray.pick;
|
|
1070
1812
|
$('.c-done', comp).onclick = () => toggleDone(id);
|
|
1813
|
+
$('.c-ship', comp).onclick = () => shipDialog(id);
|
|
1071
1814
|
$('.c-queue', comp).onclick = toggleQueue;
|
|
1072
1815
|
ta.value = S.drafts.get(id) || '';
|
|
1073
1816
|
ta.addEventListener('input', () => S.drafts.set(id, ta.value));
|
|
@@ -1201,15 +1944,66 @@ async function toggleDone(id) {
|
|
|
1201
1944
|
if (!s) return;
|
|
1202
1945
|
if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
|
|
1203
1946
|
const next = s.state === 'done' ? 'review' : 'done';
|
|
1947
|
+
const leave = next === 'done' && handOff(id); // measured before the live update moves the card
|
|
1204
1948
|
await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
|
|
1205
|
-
if (
|
|
1206
|
-
|
|
1207
|
-
collapse(id);
|
|
1208
|
-
if (nextUp) setFocus(nextUp.id);
|
|
1949
|
+
if (leave) {
|
|
1950
|
+
leave(() => S.sessions.set(id, { ...S.sessions.get(id), state: 'done' }));
|
|
1209
1951
|
toast(`Marked done`, { action: { label: 'Undo', run: () => api(`/sessions/${id}`, { method: 'PATCH', body: { state: 'review' } }) } });
|
|
1210
1952
|
}
|
|
1211
1953
|
}
|
|
1212
1954
|
|
|
1955
|
+
// Every finished run of a compare task at once, with one toast to undo them all.
|
|
1956
|
+
async function taskDone(ids) {
|
|
1957
|
+
if (!ids.length) return;
|
|
1958
|
+
const card = S.nodes.get(ids[0])?.card.closest('.task-item');
|
|
1959
|
+
const next = card?.nextElementSibling?.querySelector('.item')?.dataset.id || card?.nextElementSibling?.dataset.id;
|
|
1960
|
+
const set = (state) => Promise.all(ids.map((id) => api(`/sessions/${id}`, { method: 'PATCH', body: { state } })));
|
|
1961
|
+
await set('done').catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
1962
|
+
for (const id of ids) S.sessions.set(id, { ...S.sessions.get(id), state: 'done' });
|
|
1963
|
+
renderFeed();
|
|
1964
|
+
if (next && S.nodes.has(next)) setFocus(next);
|
|
1965
|
+
toast(`Marked ${ids.length} runs done`, { action: { label: 'Undo', run: () => set('review') } });
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
async function revertFile(id, turnId, file, restore = false) {
|
|
1969
|
+
try {
|
|
1970
|
+
await api(`/sessions/${id}/revert`, { method: 'POST', body: { turnId, file, restore } });
|
|
1971
|
+
toast(restore ? `Re-applied ${esc(file)}` : `Reverted ${esc(file)}`, {
|
|
1972
|
+
action: { label: 'Undo', run: () => revertFile(id, turnId, file, !restore) },
|
|
1973
|
+
});
|
|
1974
|
+
} catch (err) {
|
|
1975
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
async function rollbackTurn(id, turnId, restore = false) {
|
|
1980
|
+
const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
|
|
1981
|
+
const turn = s?.turns?.find((t) => t.id === turnId) || s?.turns?.at(-1);
|
|
1982
|
+
if (!turn) return;
|
|
1983
|
+
try {
|
|
1984
|
+
const r = await api(`/sessions/${id}/rollback`, { method: 'POST', body: { turnId: turn.id, restore } });
|
|
1985
|
+
toast(
|
|
1986
|
+
restore
|
|
1987
|
+
? `Re-applied turn changes`
|
|
1988
|
+
: `Restored working tree to before this turn${r.head ? ` (${esc(r.head.slice(0, 7))})` : ''}`,
|
|
1989
|
+
{ action: { label: 'Undo', run: () => rollbackTurn(id, turn.id, !restore) } },
|
|
1990
|
+
);
|
|
1991
|
+
} catch (err) {
|
|
1992
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
async function execAction(id, turnId, command, key) {
|
|
1997
|
+
try {
|
|
1998
|
+
const r = await api(`/sessions/${id}/exec`, { method: 'POST', body: { command, turnId } });
|
|
1999
|
+
if (key) S.actionRuns.set(key, r);
|
|
2000
|
+
return r;
|
|
2001
|
+
} catch (err) {
|
|
2002
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2003
|
+
return null;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
1213
2007
|
async function takeOver(id) {
|
|
1214
2008
|
const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
|
|
1215
2009
|
if (!s?.agentSessionId) return;
|
|
@@ -1458,12 +2252,16 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
1458
2252
|
function moreMenu(id, anchor) {
|
|
1459
2253
|
const s = S.full.get(id);
|
|
1460
2254
|
if (!s) return;
|
|
2255
|
+
const lastTurn = s.turns?.at(-1);
|
|
2256
|
+
const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (lastTurn?.changes?.length || lastTurn?.checkpoint?.commit);
|
|
2257
|
+
const rolledBack = !!lastTurn?.rolledBackAt;
|
|
1461
2258
|
const r = anchor.getBoundingClientRect();
|
|
1462
2259
|
const menu = el(`
|
|
1463
2260
|
<div class="menu" style="top:${r.bottom + 6}px;right:${innerWidth - r.right}px">
|
|
1464
2261
|
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
1465
2262
|
${s.agentSessionId ? `<button data-a="copy">${icon('copy')}Copy resume command</button>` : ''}
|
|
1466
2263
|
<button data-a="folder">${icon('folder')}Open project in editor</button>
|
|
2264
|
+
${canRollback ? `<button data-a="rollback">${icon(rolledBack ? 'rerun' : 'replay')}${rolledBack ? 'Re-apply latest turn' : 'Restore to before latest turn'}</button>` : ''}
|
|
1467
2265
|
${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
2266
|
<button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
|
|
1469
2267
|
</div>`);
|
|
@@ -1480,6 +2278,7 @@ function moreMenu(id, anchor) {
|
|
|
1480
2278
|
toast('Copied resume command');
|
|
1481
2279
|
}
|
|
1482
2280
|
if (a === 'folder') openInEditor(s, '.');
|
|
2281
|
+
if (a === 'rollback') rollbackTurn(id, lastTurn?.id, rolledBack);
|
|
1483
2282
|
if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
|
|
1484
2283
|
if (a === 'delete') deleteSession(id);
|
|
1485
2284
|
};
|
|
@@ -1497,6 +2296,10 @@ function taskMenu(id, e) {
|
|
|
1497
2296
|
${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
2297
|
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
1499
2298
|
<button data-a="rerun">${icon('rerun')}Rerun…${keyHint('⇧R')}</button>
|
|
2299
|
+
${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>` : ''}
|
|
2300
|
+
${s.pr?.state === 'OPEN' ? `<button data-a="merge">${icon('check')}Merge PR #${s.pr.number}…</button>` : ''}
|
|
2301
|
+
${s.groupId ? `<button data-a="compare"><b class="vs-mark">vs</b>Compare runs</button>` : ''}
|
|
2302
|
+
<button data-a="recipe">${icon('spark')}Save as recipe…</button>
|
|
1500
2303
|
${busy || s.archived ? '' : `<button data-a="done">${icon('done')}${s.state === 'done' ? 'Reopen' : 'Mark done'}${keyHint('E')}</button>`}
|
|
1501
2304
|
${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : busy ? '' : `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>`}
|
|
1502
2305
|
<button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
|
|
@@ -1512,6 +2315,10 @@ function taskMenu(id, e) {
|
|
|
1512
2315
|
if (a === 'collapse') collapse(id);
|
|
1513
2316
|
if (a === 'rename') rename(id);
|
|
1514
2317
|
if (a === 'rerun') rerun(id);
|
|
2318
|
+
if (a === 'ship') shipDialog(id);
|
|
2319
|
+
if (a === 'merge') mergePr(id);
|
|
2320
|
+
if (a === 'compare') compareView(id);
|
|
2321
|
+
if (a === 'recipe') saveAsRecipe(id);
|
|
1515
2322
|
if (a === 'done') toggleDone(id);
|
|
1516
2323
|
if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
|
|
1517
2324
|
if (a === 'delete') deleteSession(id);
|
|
@@ -1528,7 +2335,9 @@ const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
|
|
|
1528
2335
|
|
|
1529
2336
|
async function deleteSession(id) {
|
|
1530
2337
|
if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
|
|
1531
|
-
|
|
2338
|
+
const leave = handOff(id);
|
|
2339
|
+
const ok = await api(`/sessions/${id}`, { method: 'DELETE' }).then(() => true, (err) => toast(esc(err.message), { tone: 'bad' }));
|
|
2340
|
+
if (ok) leave(() => S.sessions.delete(id));
|
|
1532
2341
|
}
|
|
1533
2342
|
|
|
1534
2343
|
// Launch the task again as a new session in the same folder: same prompt and attachments,
|
|
@@ -1573,11 +2382,7 @@ async function rerun(id) {
|
|
|
1573
2382
|
modeBtn.title = m.hint;
|
|
1574
2383
|
};
|
|
1575
2384
|
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
|
-
};
|
|
2385
|
+
const paintRunner = () => paintRunnerBtn(runnerBtn, agent, model, effort);
|
|
1581
2386
|
paintMode();
|
|
1582
2387
|
paintRunner();
|
|
1583
2388
|
modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
|
|
@@ -1622,13 +2427,14 @@ async function rerun(id) {
|
|
|
1622
2427
|
}
|
|
1623
2428
|
|
|
1624
2429
|
async function setArchived(id, archived) {
|
|
2430
|
+
const leave = archived && handOff(id);
|
|
1625
2431
|
try {
|
|
1626
2432
|
await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
|
|
1627
2433
|
} catch (err) {
|
|
1628
2434
|
return toast(esc(err.message), { tone: 'bad' });
|
|
1629
2435
|
}
|
|
1630
|
-
if (
|
|
1631
|
-
|
|
2436
|
+
if (leave) {
|
|
2437
|
+
leave(() => S.sessions.set(id, { ...S.sessions.get(id), archived: true }));
|
|
1632
2438
|
toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
|
|
1633
2439
|
}
|
|
1634
2440
|
}
|
|
@@ -1814,33 +2620,773 @@ async function openProjects({ focusAdd = false } = {}) {
|
|
|
1814
2620
|
if (focusAdd) setTimeout(() => input.focus(), 60);
|
|
1815
2621
|
}
|
|
1816
2622
|
|
|
2623
|
+
// --- ship it: worktree → commit → PR → checks → merge ---------------------------------------
|
|
2624
|
+
|
|
2625
|
+
const prTone = (pr) =>
|
|
2626
|
+
pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.checks?.fail ? 'fail' : pr.checks?.pending ? 'pending' : pr.review === 'CHANGES_REQUESTED' ? 'fail' : 'pass';
|
|
2627
|
+
const REVIEW = { APPROVED: 'Approved', CHANGES_REQUESTED: 'Changes requested', REVIEW_REQUIRED: 'Review required' };
|
|
2628
|
+
function prLine(pr) {
|
|
2629
|
+
const c = pr.checks || {};
|
|
2630
|
+
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';
|
|
2631
|
+
const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
|
|
2632
|
+
return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
|
|
2633
|
+
}
|
|
2634
|
+
const prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
|
|
2635
|
+
|
|
2636
|
+
// The PR row under a card's bar; reads the live summary, so polled check results show up.
|
|
2637
|
+
function renderPrStrip(id) {
|
|
2638
|
+
const n = S.nodes.get(id);
|
|
2639
|
+
const node = n?.body && $('.pr-strip', n.body);
|
|
2640
|
+
const s = S.sessions.get(id);
|
|
2641
|
+
if (!node || !s) return;
|
|
2642
|
+
const pr = s.pr;
|
|
2643
|
+
$$('[data-a="ship"] span', n.body).forEach((b) => (b.textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it'));
|
|
2644
|
+
node.hidden = !pr;
|
|
2645
|
+
if (!pr) return;
|
|
2646
|
+
const tone = prTone(pr);
|
|
2647
|
+
node.className = `pr-strip pr-${tone}`;
|
|
2648
|
+
node.innerHTML = `
|
|
2649
|
+
<span class="pr-dot"></span>
|
|
2650
|
+
<a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
|
|
2651
|
+
<span class="pr-line">${esc(prLine(pr))}</span>
|
|
2652
|
+
<span class="grow"></span>
|
|
2653
|
+
${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
|
|
2654
|
+
<a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>
|
|
2655
|
+
${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>` : ''}`;
|
|
2656
|
+
node.onclick = (e) => {
|
|
2657
|
+
const a = e.target.closest('[data-pr]')?.dataset.pr;
|
|
2658
|
+
if (a === 'refresh') api(`/sessions/${id}/pr`, { method: 'POST', body: {} }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
2659
|
+
if (a === 'merge') mergePr(id);
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
async function shipDialog(id) {
|
|
2664
|
+
const s = S.sessions.get(id);
|
|
2665
|
+
if (!s) return;
|
|
2666
|
+
if (s.state === 'running' || s.state === 'waiting') return toast('Still working: let it finish first.');
|
|
2667
|
+
let info;
|
|
2668
|
+
try {
|
|
2669
|
+
info = await api(`/sessions/${id}/ship`);
|
|
2670
|
+
} catch (err) {
|
|
2671
|
+
return toast(esc(err.message), { tone: 'bad' });
|
|
2672
|
+
}
|
|
2673
|
+
const open = info.pr?.state === 'OPEN';
|
|
2674
|
+
const nothing = !info.dirty && !info.ahead;
|
|
2675
|
+
const node = el(`
|
|
2676
|
+
<div class="modal ship-modal">
|
|
2677
|
+
<header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
2678
|
+
<p class="ship-route">${icon('branch')}<code>${esc(info.branch)}</code>${icon('arrow')}<code>${esc(info.remote || 'no remote')}/${esc(info.base)}</code>
|
|
2679
|
+
<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>
|
|
2680
|
+
${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input name="title" spellcheck="false" maxlength="100"></label>` : ''}
|
|
2681
|
+
${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea name="body" rows="9" spellcheck="false"></textarea></label>
|
|
2682
|
+
<label class="ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
|
|
2683
|
+
${!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>' : ''}
|
|
2684
|
+
<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>
|
|
2685
|
+
</div>`);
|
|
2686
|
+
const close = overlay(node);
|
|
2687
|
+
$('.close', node).onclick = close;
|
|
2688
|
+
const title = $('[name="title"]', node);
|
|
2689
|
+
const body = $('[name="body"]', node);
|
|
2690
|
+
if (title) title.value = info.title;
|
|
2691
|
+
if (body) body.value = info.body;
|
|
2692
|
+
const go = async () => {
|
|
2693
|
+
const btn = $('[data-a="go"]', node);
|
|
2694
|
+
btn.disabled = true;
|
|
2695
|
+
btn.lastChild.textContent = 'Shipping…';
|
|
2696
|
+
try {
|
|
2697
|
+
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 } });
|
|
2698
|
+
close();
|
|
2699
|
+
toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
|
|
2700
|
+
} catch (err) {
|
|
2701
|
+
btn.disabled = false;
|
|
2702
|
+
btn.lastChild.textContent = open ? 'Commit & push' : 'Commit, push & open PR';
|
|
2703
|
+
toast(esc(err.message), { tone: 'bad', timeout: 10_000 });
|
|
2704
|
+
}
|
|
2705
|
+
};
|
|
2706
|
+
$('[data-a="go"]', node).onclick = go;
|
|
2707
|
+
requestAnimationFrame(() => (title || $('[data-a="go"]', node)).focus());
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
async function mergePr(id) {
|
|
2711
|
+
const s = S.sessions.get(id);
|
|
2712
|
+
const pr = s?.pr;
|
|
2713
|
+
if (!pr || pr.state !== 'OPEN') return;
|
|
2714
|
+
const c = pr.checks || {};
|
|
2715
|
+
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.` : '';
|
|
2716
|
+
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;
|
|
2717
|
+
toast(`Merging PR #${pr.number}…`);
|
|
2718
|
+
try {
|
|
2719
|
+
const r = await api(`/sessions/${id}/merge`, { method: 'POST', body: {} });
|
|
2720
|
+
toast(`${icon('check')} Merged <b>PR #${r.pr.number}</b>. ${esc(r.sync.text)}`, { tone: r.sync.ok ? '' : 'bad', timeout: 10_000 });
|
|
2721
|
+
} catch (err) {
|
|
2722
|
+
toast(esc(err.message), { tone: 'bad', timeout: 12_000 });
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
// --- compare: one task, several harnesses, side by side -------------------------------------------
|
|
2727
|
+
|
|
2728
|
+
function turnMetrics(t) {
|
|
2729
|
+
const lines = (t?.changes || []).reduce((acc, c) => {
|
|
2730
|
+
const n = countChanges(c.edits);
|
|
2731
|
+
return { add: acc.add + n.add, del: acc.del + n.del };
|
|
2732
|
+
}, { add: 0, del: 0 });
|
|
2733
|
+
const tests = (t?.presentation?.blocks || []).filter((b) => b?.type === 'tests');
|
|
2734
|
+
return {
|
|
2735
|
+
time: t?.endedAt ? t.endedAt - t.startedAt : null,
|
|
2736
|
+
cost: t?.stats?.costUsd,
|
|
2737
|
+
files: t?.changes?.length || 0,
|
|
2738
|
+
...lines,
|
|
2739
|
+
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,
|
|
2740
|
+
asks: (t?.activity || []).filter((a) => a.kind === 'ask').length,
|
|
2741
|
+
};
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
async function compareView(id) {
|
|
2745
|
+
const s0 = S.sessions.get(id);
|
|
2746
|
+
if (!s0?.groupId) return;
|
|
2747
|
+
const group = [...S.sessions.values()].filter((x) => x.groupId === s0.groupId).sort((a, b) => a.createdAt - b.createdAt);
|
|
2748
|
+
const fulls = (await Promise.all(group.map((g) => api(`/sessions/${g.id}`).catch(() => null)))).filter(Boolean);
|
|
2749
|
+
const node = el(`
|
|
2750
|
+
<div class="modal compare-modal">
|
|
2751
|
+
<header><h2>Compare</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
2752
|
+
<p class="cmp-prompt">${esc(fulls[0]?.turns[0]?.prompt || '')}</p>
|
|
2753
|
+
<div class="cmp-grid" style="--n:${fulls.length}"></div>
|
|
2754
|
+
</div>`);
|
|
2755
|
+
const close = overlay(node);
|
|
2756
|
+
$('.close', node).onclick = close;
|
|
2757
|
+
const grid = $('.cmp-grid', node);
|
|
2758
|
+
for (const f of fulls) {
|
|
2759
|
+
const sum = S.sessions.get(f.id) || {};
|
|
2760
|
+
const t = f.turns.at(-1);
|
|
2761
|
+
const mx = turnMetrics(t);
|
|
2762
|
+
const live = f.state === 'running' || f.state === 'waiting';
|
|
2763
|
+
const col = el(`
|
|
2764
|
+
<section class="cmp-col" data-id="${f.id}">
|
|
2765
|
+
<div class="cmp-head">
|
|
2766
|
+
<span class="rb-logo">${harnessLogo(f.agent)}</span>
|
|
2767
|
+
<span class="cmp-who"><b>${esc(agentName(f.agent))}</b><small>${esc(f.model || 'Default model')}${f.effort ? ` · ${esc(effortLabel(f.effort))}` : ''}</small></span>
|
|
2768
|
+
${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>`}
|
|
2769
|
+
</div>
|
|
2770
|
+
<dl class="cmp-stats">
|
|
2771
|
+
<div><dt>Time</dt><dd>${mx.time !== null ? fmtDuration(mx.time) : '—'}</dd></div>
|
|
2772
|
+
<div><dt>Cost</dt><dd>${typeof mx.cost === 'number' ? `$${mx.cost.toFixed(2)}` : '—'}</dd></div>
|
|
2773
|
+
<div><dt>Files</dt><dd>${mx.files} <span class="plus">+${mx.add}</span> <span class="minus">−${mx.del}</span></dd></div>
|
|
2774
|
+
<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>
|
|
2775
|
+
<div><dt>Asked</dt><dd>${mx.asks}</dd></div>
|
|
2776
|
+
</dl>
|
|
2777
|
+
<div class="cmp-body"></div>
|
|
2778
|
+
<div class="cmp-foot">
|
|
2779
|
+
<button class="ghost xs" data-a="open">${icon('chevron')}Open card</button>
|
|
2780
|
+
<span class="grow"></span>
|
|
2781
|
+
<button class="primary sm" data-a="keep" ${live ? 'disabled title="Still working"' : ''}>${icon('check')}Keep this</button>
|
|
2782
|
+
</div>
|
|
2783
|
+
</section>`);
|
|
2784
|
+
$('.cmp-body', col).append(live ? el(`<p class="muted xs">${esc(sum.live || 'Working…')}</p>`) : renderPresentation(t?.presentation, ctxFor(f, t, false)));
|
|
2785
|
+
grid.append(col);
|
|
2786
|
+
}
|
|
2787
|
+
grid.onclick = async (e) => {
|
|
2788
|
+
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
2789
|
+
const keep = e.target.closest('.cmp-col')?.dataset.id;
|
|
2790
|
+
if (!a || !keep) return;
|
|
2791
|
+
if (a === 'open') return close(), reveal(keep);
|
|
2792
|
+
const others = group.filter((g) => g.id !== keep);
|
|
2793
|
+
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;
|
|
2794
|
+
for (const o of others) {
|
|
2795
|
+
if (o.state === 'running' || o.state === 'waiting') {
|
|
2796
|
+
await api(`/sessions/${o.id}/stop`, { method: 'POST', body: {} }).catch(() => {});
|
|
2797
|
+
await until(() => S.sessions.get(o.id)?.state !== 'running', 5000);
|
|
2798
|
+
}
|
|
2799
|
+
await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
2800
|
+
}
|
|
2801
|
+
close();
|
|
2802
|
+
reveal(keep);
|
|
2803
|
+
toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
// Poll a condition for up to `ms` (used after asking the server to stop something).
|
|
2808
|
+
async function until(fn, ms) {
|
|
2809
|
+
const end = Date.now() + ms;
|
|
2810
|
+
while (Date.now() < end && !fn()) await new Promise((r) => setTimeout(r, 150));
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
// --- automations: recipes (saved prompts) and triggers (schedules, GitHub watchers) -----------
|
|
2814
|
+
|
|
2815
|
+
const TRIGGER_KINDS = [
|
|
2816
|
+
{ kind: 'schedule', label: 'Schedule', icon: 'clock', hint: 'On a cron schedule, local time' },
|
|
2817
|
+
{ kind: 'ci', label: 'CI failed', icon: 'warn', hint: 'A GitHub Actions run fails (polled with gh)' },
|
|
2818
|
+
{ kind: 'issues', label: 'Issue labelled', icon: 'ask', hint: 'An open issue gets a label (polled with gh)' },
|
|
2819
|
+
];
|
|
2820
|
+
const CRON_PRESETS = [
|
|
2821
|
+
['Weekdays 9:00', '0 9 * * 1-5'],
|
|
2822
|
+
['Every hour', '0 * * * *'],
|
|
2823
|
+
['Daily 18:00', '0 18 * * *'],
|
|
2824
|
+
['Mondays 8:00', '0 8 * * 1'],
|
|
2825
|
+
];
|
|
2826
|
+
const recipeParams = (prompt) => [...new Set([...String(prompt || '').matchAll(/\{([a-zA-Z][\w-]{0,30})\}/g)].map((m) => m[1]))];
|
|
2827
|
+
const whenLabel = (t) => {
|
|
2828
|
+
if (!t) return '';
|
|
2829
|
+
const d = new Date(t);
|
|
2830
|
+
const today = new Date().toDateString() === d.toDateString();
|
|
2831
|
+
return d.toLocaleString([], today ? { hour: '2-digit', minute: '2-digit' } : { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
2832
|
+
};
|
|
2833
|
+
const projectOptions = (selected, { any } = {}) =>
|
|
2834
|
+
(any ? `<option value="">${esc(any)}</option>` : '') +
|
|
2835
|
+
S.config.projects.map((p) => `<option value="${esc(p.path)}" ${p.path === selected ? 'selected' : ''}>${esc(p.name)}</option>`).join('');
|
|
2836
|
+
const modeOptions = (selected, { inherit } = {}) =>
|
|
2837
|
+
(inherit ? `<option value="">${esc(inherit)}</option>` : '') + MODES.map((m) => `<option value="${m.id}" ${m.id === selected ? 'selected' : ''}>${m.label}</option>`).join('');
|
|
2838
|
+
|
|
2839
|
+
function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
|
|
2840
|
+
const node = el(`<div class="modal auto-modal">
|
|
2841
|
+
<header><h2>Automations</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
2842
|
+
<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>
|
|
2843
|
+
<div class="mem-tabs" role="tablist"></div>
|
|
2844
|
+
<div class="auto-body"></div>
|
|
2845
|
+
</div>`);
|
|
2846
|
+
const close = present(node, host);
|
|
2847
|
+
let recipes = [];
|
|
2848
|
+
let triggers = [];
|
|
2849
|
+
let editing = edit; // { type: 'recipe' | 'trigger', draft }
|
|
2850
|
+
|
|
2851
|
+
const render = async () => {
|
|
2852
|
+
[recipes, triggers] = await Promise.all([api('/recipes').catch(() => recipes), api('/triggers').catch(() => triggers)]);
|
|
2853
|
+
if (!node.isConnected) return;
|
|
2854
|
+
$('.mem-tabs', node).innerHTML = [
|
|
2855
|
+
['recipes', 'Recipes', recipes.length],
|
|
2856
|
+
['triggers', 'Triggers', triggers.length],
|
|
2857
|
+
].map(([k, l, n]) => `<button role="tab" data-tab="${k}" class="${tab === k ? 'on' : ''}">${l}<span>${n}</span></button>`).join('');
|
|
2858
|
+
const body = $('.auto-body', node);
|
|
2859
|
+
body.innerHTML = '';
|
|
2860
|
+
if (editing?.type === 'recipe') body.append(recipeForm(editing.draft));
|
|
2861
|
+
else if (editing?.type === 'trigger') body.append(triggerForm(editing.draft));
|
|
2862
|
+
else body.innerHTML = tab === 'recipes' ? recipesView() : triggersView();
|
|
2863
|
+
};
|
|
2864
|
+
|
|
2865
|
+
const recipesView = () =>
|
|
2866
|
+
`<div class="au-list">${
|
|
2867
|
+
recipes
|
|
2868
|
+
.map((r) => {
|
|
2869
|
+
const proj = S.config.projects.find((p) => p.path === r.cwd);
|
|
2870
|
+
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);
|
|
2871
|
+
return `<div class="au-item" data-id="${r.id}">
|
|
2872
|
+
<span class="mem-ico">${icon('spark')}</span>
|
|
2873
|
+
<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>
|
|
2874
|
+
<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>
|
|
2875
|
+
</div>`;
|
|
2876
|
+
})
|
|
2877
|
+
.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>'
|
|
2878
|
+
}</div><button class="mem-add" data-act="new">${icon('plus')}New recipe</button>
|
|
2879
|
+
<p class="hint">Also from the terminal: <code>wisp recipes</code>, <code>wisp run <name> [values…]</code>.</p>`;
|
|
2880
|
+
|
|
2881
|
+
const describe = (t) => {
|
|
2882
|
+
const proj = S.config.projects.find((p) => p.path === t.cwd)?.name || t.cwd.split(/[\\/]/).pop();
|
|
2883
|
+
if (t.kind === 'schedule') return `<code>${esc(t.cron)}</code> in ${esc(proj)}${t.nextAt ? ` · next ${esc(whenLabel(t.nextAt))}` : ''}`;
|
|
2884
|
+
const what = t.kind === 'ci' ? `failed CI${t.branch ? ` on <code>${esc(t.branch)}</code>` : ''}` : `issues labelled <code>${esc(t.label)}</code>`;
|
|
2885
|
+
return `Watches ${what} in ${esc(proj)} every ${t.every} min${t.seeded ? '' : ' · first check records what is already there'}`;
|
|
2886
|
+
};
|
|
2887
|
+
const triggersView = () =>
|
|
2888
|
+
`<div class="au-list">${
|
|
2889
|
+
triggers
|
|
2890
|
+
.map((t) => {
|
|
2891
|
+
const k = TRIGGER_KINDS.find((x) => x.kind === t.kind);
|
|
2892
|
+
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';
|
|
2893
|
+
return `<div class="au-item ${t.enabled ? '' : 'off'}" data-id="${t.id}">
|
|
2894
|
+
<span class="mem-ico">${icon(k.icon)}</span>
|
|
2895
|
+
<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>
|
|
2896
|
+
<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>
|
|
2897
|
+
</div>`;
|
|
2898
|
+
})
|
|
2899
|
+
.join('') || '<p class="mem-empty">No triggers yet. Run a recipe every weekday morning, or send an agent after every red CI run.</p>'
|
|
2900
|
+
}</div><button class="mem-add" data-act="new">${icon('plus')}New trigger</button>
|
|
2901
|
+
<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>`;
|
|
2902
|
+
|
|
2903
|
+
// Harness · model button for a form, with "Launcher default" when no agent is set.
|
|
2904
|
+
const runnerField = (draft) => {
|
|
2905
|
+
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>`);
|
|
2906
|
+
const [btn, reset] = wrap.children;
|
|
2907
|
+
const paint = () => (paintRunnerBtn(btn, draft.agent, draft.model, draft.effort), (reset.hidden = !draft.agent));
|
|
2908
|
+
btn.onclick = () =>
|
|
2909
|
+
runnerPicker(btn, { agent: draft.agent || S.config.defaults.agent, model: draft.model, effort: draft.effort, savedModel, savedEffort }, (a, m, e) => {
|
|
2910
|
+
Object.assign(draft, { agent: a, model: m, effort: e });
|
|
2911
|
+
paint();
|
|
2912
|
+
});
|
|
2913
|
+
reset.onclick = () => (Object.assign(draft, { agent: '', model: '', effort: '' }), paint());
|
|
2914
|
+
paint();
|
|
2915
|
+
return wrap;
|
|
2916
|
+
};
|
|
2917
|
+
|
|
2918
|
+
const recipeForm = (d) => {
|
|
2919
|
+
const f = el(`<form class="au-form">
|
|
2920
|
+
<label><span>Name</span><input name="name" maxlength="60" placeholder="Release" spellcheck="false"></label>
|
|
2921
|
+
<label class="stack"><span>Prompt <small class="muted">· <code>{version}</code> becomes a field</small></span><textarea name="prompt" rows="7" spellcheck="false" placeholder="Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}…"></textarea></label>
|
|
2922
|
+
<p class="au-params muted xs"></p>
|
|
2923
|
+
<label><span>Project</span><select name="cwd">${projectOptions(d.cwd, { any: 'Any project (picked when run)' })}</select></label>
|
|
2924
|
+
<label><span>Mode</span><select name="mode">${modeOptions(d.mode, { inherit: 'Launcher default' })}</select></label>
|
|
2925
|
+
<div class="au-row"><span>Harness</span></div>
|
|
2926
|
+
<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>
|
|
2927
|
+
</form>`);
|
|
2928
|
+
$('[name="name"]', f).value = d.name || '';
|
|
2929
|
+
$('[name="prompt"]', f).value = d.prompt || '';
|
|
2930
|
+
$('.au-row', f).append(runnerField(d));
|
|
2931
|
+
const params = () => {
|
|
2932
|
+
const p = recipeParams($('[name="prompt"]', f).value);
|
|
2933
|
+
$('.au-params', f).innerHTML = p.length ? `Fields when run: ${p.map((x) => `<code>{${esc(x)}}</code>`).join(' ')}` : 'No fields: it runs as written.';
|
|
2934
|
+
};
|
|
2935
|
+
$('[name="prompt"]', f).oninput = params;
|
|
2936
|
+
params();
|
|
2937
|
+
f.onsubmit = async (e) => {
|
|
2938
|
+
e.preventDefault();
|
|
2939
|
+
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 || '' };
|
|
2940
|
+
try {
|
|
2941
|
+
await api(d.id ? `/recipes/${d.id}` : '/recipes', { method: d.id ? 'PATCH' : 'POST', body });
|
|
2942
|
+
editing = null;
|
|
2943
|
+
tab = 'recipes';
|
|
2944
|
+
render();
|
|
2945
|
+
} catch (err) {
|
|
2946
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2947
|
+
}
|
|
2948
|
+
};
|
|
2949
|
+
requestAnimationFrame(() => $(d.name ? '[name="prompt"]' : '[name="name"]', f)?.focus());
|
|
2950
|
+
return f;
|
|
2951
|
+
};
|
|
2952
|
+
|
|
2953
|
+
const triggerForm = (d) => {
|
|
2954
|
+
d.kind ||= 'schedule';
|
|
2955
|
+
const f = el(`<form class="au-form">
|
|
2956
|
+
<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>
|
|
2957
|
+
<label><span>Name</span><input name="name" maxlength="60" spellcheck="false"></label>
|
|
2958
|
+
<label><span>Project</span><select name="cwd">${projectOptions(d.cwd || projectById(launchProject)?.path)}</select></label>
|
|
2959
|
+
<div class="k-schedule"><label><span>When</span><input name="cron" spellcheck="false" placeholder="0 9 * * 1-5"></label>
|
|
2960
|
+
<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>
|
|
2961
|
+
<div class="k-ci"><label><span>Branch</span><input name="branch" spellcheck="false" placeholder="Any branch (e.g. main)"></label></div>
|
|
2962
|
+
<div class="k-issues"><label><span>Label</span><input name="label" spellcheck="false" placeholder="wisp"></label></div>
|
|
2963
|
+
<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>
|
|
2964
|
+
<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>
|
|
2965
|
+
<label class="stack k-prompt"><span>Prompt <small class="muted k-vars"></small></span><textarea name="prompt" rows="6" spellcheck="false"></textarea></label>
|
|
2966
|
+
<label><span>Mode</span><select name="mode">${modeOptions(d.mode || (d.kind === 'issues' ? 'plan' : 'edits'))}</select></label>
|
|
2967
|
+
<div class="au-row"><span>Harness</span></div>
|
|
2968
|
+
<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>
|
|
2969
|
+
<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>
|
|
2970
|
+
</form>`);
|
|
2971
|
+
for (const k of ['name', 'cron', 'branch', 'label']) $(`[name="${k}"]`, f).value = d[k] || '';
|
|
2972
|
+
$('[name="prompt"]', f).value = d.prompt || '';
|
|
2973
|
+
$('.au-row', f).append(runnerField(d));
|
|
2974
|
+
const sync = () => {
|
|
2975
|
+
for (const k of ['schedule', 'ci', 'issues']) $(`.k-${k}`, f).hidden = d.kind !== k;
|
|
2976
|
+
$('.k-gh', f).hidden = d.kind === 'schedule';
|
|
2977
|
+
$('.k-prompt', f).hidden = !!$('[name="recipeId"]', f).value;
|
|
2978
|
+
$('.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' : '';
|
|
2979
|
+
$('.au-warn', f).hidden = !(d.kind === 'issues' && $('[name="mode"]', f).value !== 'plan');
|
|
2980
|
+
$$('.au-kinds button', f).forEach((b) => b.classList.toggle('on', b.dataset.kind === d.kind));
|
|
2981
|
+
};
|
|
2982
|
+
f.addEventListener('click', (e) => {
|
|
2983
|
+
const k = e.target.closest('[data-kind]')?.dataset.kind;
|
|
2984
|
+
if (k) {
|
|
2985
|
+
if (k === 'issues' && !d.id) $('[name="mode"]', f).value = 'plan';
|
|
2986
|
+
d.kind = k;
|
|
2987
|
+
sync();
|
|
2988
|
+
}
|
|
2989
|
+
const c = e.target.closest('[data-cron]')?.dataset.cron;
|
|
2990
|
+
if (c) $('[name="cron"]', f).value = c;
|
|
2991
|
+
});
|
|
2992
|
+
f.addEventListener('change', sync);
|
|
2993
|
+
sync();
|
|
2994
|
+
f.onsubmit = async (e) => {
|
|
2995
|
+
e.preventDefault();
|
|
2996
|
+
const v = (k) => $(`[name="${k}"]`, f).value;
|
|
2997
|
+
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 || '' };
|
|
2998
|
+
try {
|
|
2999
|
+
await api(d.id ? `/triggers/${d.id}` : '/triggers', { method: d.id ? 'PATCH' : 'POST', body });
|
|
3000
|
+
editing = null;
|
|
3001
|
+
tab = 'triggers';
|
|
3002
|
+
render();
|
|
3003
|
+
} catch (err) {
|
|
3004
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3005
|
+
}
|
|
3006
|
+
};
|
|
3007
|
+
return f;
|
|
3008
|
+
};
|
|
3009
|
+
|
|
3010
|
+
node.addEventListener('click', async (e) => {
|
|
3011
|
+
const t = e.target.closest('[data-tab],[data-act],[data-session]');
|
|
3012
|
+
if (!t || t.closest('.au-form') && !t.dataset.act) return;
|
|
3013
|
+
if (t.dataset.tab) return e.preventDefault(), (tab = t.dataset.tab), (editing = null), render();
|
|
3014
|
+
if (t.dataset.session) return e.preventDefault(), close(), reveal(t.dataset.session);
|
|
3015
|
+
const act = t.dataset.act;
|
|
3016
|
+
if (act === 'cancel') return (editing = null), render();
|
|
3017
|
+
const itemId = t.closest('.au-item')?.dataset.id;
|
|
3018
|
+
if (tab === 'recipes') {
|
|
3019
|
+
const r = recipes.find((x) => x.id === itemId);
|
|
3020
|
+
if (act === 'new') return (editing = { type: 'recipe', draft: { cwd: projectById(launchProject)?.path || '' } }), render();
|
|
3021
|
+
if (act === 'edit') return (editing = { type: 'recipe', draft: { ...r } }), render();
|
|
3022
|
+
if (act === 'run') return close(), runRecipe(r);
|
|
3023
|
+
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();
|
|
3024
|
+
if (act === 'delete' && confirm(`Delete the recipe “${r.name}”?`)) return api(`/recipes/${r.id}`, { method: 'DELETE' }).then(render);
|
|
3025
|
+
} else {
|
|
3026
|
+
const tr = triggers.find((x) => x.id === itemId);
|
|
3027
|
+
if (act === 'new') return (editing = { type: 'trigger', draft: {} }), render();
|
|
3028
|
+
if (act === 'edit') return (editing = { type: 'trigger', draft: { ...tr } }), render();
|
|
3029
|
+
if (act === 'toggle') return api(`/triggers/${tr.id}`, { method: 'PATCH', body: { enabled: !tr.enabled } }).then(render).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
3030
|
+
if (act === 'delete' && confirm(`Delete the trigger “${tr.name}”?`)) return api(`/triggers/${tr.id}`, { method: 'DELETE' }).then(render);
|
|
3031
|
+
if (act === 'fire') {
|
|
3032
|
+
t.disabled = true;
|
|
3033
|
+
try {
|
|
3034
|
+
const r = await api(`/triggers/${tr.id}/run`, { method: 'POST', body: {} });
|
|
3035
|
+
for (const x of r.started) S.sessions.set(x.id, x);
|
|
3036
|
+
renderFeed();
|
|
3037
|
+
toast(r.started.length ? `Started ${r.started.length} session${r.started.length > 1 ? 's' : ''}` : 'Nothing new to act on.');
|
|
3038
|
+
} catch (err) {
|
|
3039
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3040
|
+
}
|
|
3041
|
+
render();
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
});
|
|
3045
|
+
node.refresh = () => !editing && render();
|
|
3046
|
+
render();
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
// Run a recipe: show it, ask for its fields (and a project, if it isn't tied to one), launch on Run.
|
|
3050
|
+
function runRecipe(r) {
|
|
3051
|
+
if (!r) return;
|
|
3052
|
+
const needsProject = !r.cwd;
|
|
3053
|
+
const node = el(`
|
|
3054
|
+
<form class="modal run-modal">
|
|
3055
|
+
<header><h2>${esc(r.name)}</h2><button type="button" class="icon-btn close">${icon('x')}</button></header>
|
|
3056
|
+
<p class="cmp-prompt">${esc(r.prompt)}</p>
|
|
3057
|
+
${r.params.map((p) => `<label class="ship-field"><span>${esc(p)}</span><input name="p:${esc(p)}" spellcheck="false" required></label>`).join('')}
|
|
3058
|
+
${needsProject ? `<label class="ship-field"><span>Project</span><select name="cwd">${projectOptions(projectById(launchProject)?.path)}</select></label>` : ''}
|
|
3059
|
+
<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>
|
|
3060
|
+
</form>`);
|
|
3061
|
+
const close = overlay(node);
|
|
3062
|
+
$('.close', node).onclick = close;
|
|
3063
|
+
node.onsubmit = async (e) => {
|
|
3064
|
+
e.preventDefault();
|
|
3065
|
+
const params = Object.fromEntries($$('input[name^="p:"]', node).map((i) => [i.name.slice(2), i.value]));
|
|
3066
|
+
const d = S.config.defaults;
|
|
3067
|
+
const agent = r.agent || d.agent;
|
|
3068
|
+
try {
|
|
3069
|
+
const s = await api(`/recipes/${r.id}/run`, {
|
|
3070
|
+
method: 'POST',
|
|
3071
|
+
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 },
|
|
3072
|
+
});
|
|
3073
|
+
S.sessions.set(s.id, s);
|
|
3074
|
+
close();
|
|
3075
|
+
renderFeed();
|
|
3076
|
+
setFocus(s.id);
|
|
3077
|
+
toast(`Running “${esc(r.name)}”`);
|
|
3078
|
+
} catch (err) {
|
|
3079
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3080
|
+
}
|
|
3081
|
+
};
|
|
3082
|
+
// Always wait for Run, even with nothing to fill in, so a click on a recipe never launches it outright.
|
|
3083
|
+
requestAnimationFrame(() => ($('input, select', node) || $('button.primary', node))?.focus());
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
// A task worth repeating becomes a recipe, starting from its first prompt.
|
|
3087
|
+
async function saveAsRecipe(id) {
|
|
3088
|
+
const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
|
|
3089
|
+
if (!s?.turns?.[0]) return;
|
|
3090
|
+
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 } } });
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
// --- usage: spend and outcomes --------------------------------------------------------------------
|
|
3094
|
+
|
|
3095
|
+
const money = (v) => (v >= 100 ? `$${Math.round(v)}` : `$${(v || 0).toFixed(2)}`);
|
|
3096
|
+
const pct = (v) => (v === null || v === undefined ? '—' : `${Math.round(v * 100)}%`);
|
|
3097
|
+
|
|
3098
|
+
async function openUsage({ days = 30, host } = {}) {
|
|
3099
|
+
const node = el(`<div class="modal usage-modal">
|
|
3100
|
+
<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>
|
|
3101
|
+
<div class="us-body"><div class="it-loading"><span class="orb tiny"></span>Adding it up…</div></div>
|
|
3102
|
+
</div>`);
|
|
3103
|
+
const close = present(node, host);
|
|
3104
|
+
const render = async () => {
|
|
3105
|
+
$$('.us-range button', node).forEach((b) => b.classList.toggle('on', Number(b.dataset.days) === days));
|
|
3106
|
+
const u = await api(`/usage?days=${days}`).catch((err) => (toast(esc(err.message), { tone: 'bad' }), null));
|
|
3107
|
+
if (!u || !node.isConnected) return;
|
|
3108
|
+
const t = u.total;
|
|
3109
|
+
const unpriced = t.turns - t.priced;
|
|
3110
|
+
// Cost when any harness reports it, else turns: one measure per chart, never two axes.
|
|
3111
|
+
const byCost = t.priced > 0;
|
|
3112
|
+
const val = (d) => (byCost ? d.cost : d.turns);
|
|
3113
|
+
const max = Math.max(...u.daily.map(val), 0) || 1;
|
|
3114
|
+
const fmtDay = (k) => new Date(`${k}T12:00:00`).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
3115
|
+
const table = (rows, label) =>
|
|
3116
|
+
rows.length
|
|
3117
|
+
? `<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
|
|
3118
|
+
.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>`)
|
|
3119
|
+
.join('')}</tbody></table>`
|
|
3120
|
+
: '<p class="mem-empty">Nothing in this range.</p>';
|
|
3121
|
+
$('.us-body', node).innerHTML = `
|
|
3122
|
+
<div class="us-tiles">
|
|
3123
|
+
<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>
|
|
3124
|
+
<div><span>Turns</span><b>${t.turns}</b><small>in ${t.sessions} session${t.sessions === 1 ? '' : 's'}</small></div>
|
|
3125
|
+
<div><span>Worked</span><b>${pct(t.successRate)}</b><small>finished, not partial or blocked</small></div>
|
|
3126
|
+
<div><span>Questions</span><b>${t.questions}</b><small>times an agent needed you</small></div>
|
|
3127
|
+
</div>
|
|
3128
|
+
<h4>${byCost ? 'Spend per day' : 'Turns per day'}</h4>
|
|
3129
|
+
<div class="us-chart" role="img" aria-label="${byCost ? 'Spend' : 'Turns'} per day">${u.daily
|
|
3130
|
+
.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>`)
|
|
3131
|
+
.join('')}</div>
|
|
3132
|
+
<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>
|
|
3133
|
+
<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>`)}
|
|
3134
|
+
<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>`)}
|
|
3135
|
+
${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>' : ''}`;
|
|
3136
|
+
};
|
|
3137
|
+
// One floating tooltip for the bars (they are too thin to label).
|
|
3138
|
+
const tip = el('<div class="us-tip" hidden></div>');
|
|
3139
|
+
node.append(tip);
|
|
3140
|
+
node.addEventListener('mousemove', (e) => {
|
|
3141
|
+
const b = e.target.closest('.us-bar');
|
|
3142
|
+
tip.hidden = !b;
|
|
3143
|
+
if (!b) return;
|
|
3144
|
+
tip.textContent = b.dataset.tip;
|
|
3145
|
+
const r = b.getBoundingClientRect();
|
|
3146
|
+
const m = node.getBoundingClientRect();
|
|
3147
|
+
tip.style.left = `${Math.min(r.left - m.left + r.width / 2, m.width - 90)}px`;
|
|
3148
|
+
tip.style.top = `${r.top - m.top + node.scrollTop - 6}px`;
|
|
3149
|
+
});
|
|
3150
|
+
node.addEventListener('mouseleave', () => (tip.hidden = true));
|
|
3151
|
+
$('.us-range', node).onclick = (e) => {
|
|
3152
|
+
const d = Number(e.target.closest('[data-days]')?.dataset.days);
|
|
3153
|
+
if (d) (days = d), render();
|
|
3154
|
+
};
|
|
3155
|
+
render();
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
function usageSummary(u) {
|
|
3159
|
+
if (!u) return '';
|
|
3160
|
+
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';
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
// --- phone: ntfy / Telegram -----------------------------------------------------------------------
|
|
3164
|
+
|
|
3165
|
+
function phoneSummary(r) {
|
|
3166
|
+
if (!r) return '';
|
|
3167
|
+
const on = [r.ntfy.enabled && r.ntfy.topic && 'ntfy', r.telegram.enabled && r.telegram.linked && 'Telegram'].filter(Boolean);
|
|
3168
|
+
return on.length ? `${on.join(' + ')} · ${r.events.map((e) => (e === 'waiting' ? 'questions' : 'finished')).join(' & ')}` : 'Off';
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
async function openPhone({ host } = {}) {
|
|
3172
|
+
const node = el(`<div class="modal phone-modal">
|
|
3173
|
+
<header><h2>Phone</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
3174
|
+
<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>
|
|
3175
|
+
<div class="ph-body"></div>
|
|
3176
|
+
</div>`);
|
|
3177
|
+
const close = present(node, host);
|
|
3178
|
+
let r;
|
|
3179
|
+
const put = async (patch) => {
|
|
3180
|
+
try {
|
|
3181
|
+
r = await api('/remote', { method: 'PUT', body: patch });
|
|
3182
|
+
paint();
|
|
3183
|
+
} catch (err) {
|
|
3184
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3185
|
+
}
|
|
3186
|
+
};
|
|
3187
|
+
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>`);
|
|
3188
|
+
const paint = () => {
|
|
3189
|
+
const tg = r.telegram;
|
|
3190
|
+
$('.ph-body', node).innerHTML = `
|
|
3191
|
+
<div class="settings-modal ph-sec">
|
|
3192
|
+
<div class="setting"><span>Send me</span>
|
|
3193
|
+
<label class="ph-check"><input type="checkbox" data-ev="waiting" ${r.events.includes('waiting') ? 'checked' : ''}> Questions</label>
|
|
3194
|
+
<label class="ph-check"><input type="checkbox" data-ev="finished" ${r.events.includes('finished') ? 'checked' : ''}> Finished tasks</label></div>
|
|
3195
|
+
</div>
|
|
3196
|
+
<section class="ph-sec">
|
|
3197
|
+
<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>
|
|
3198
|
+
<div class="settings-modal">
|
|
3199
|
+
<label><span>Topic</span><input data-ntfy="topic" value="${esc(r.ntfy.topic)}" placeholder="wisp-…" spellcheck="false"></label>
|
|
3200
|
+
<label><span>Server</span><input data-ntfy="server" value="${esc(r.ntfy.server)}" spellcheck="false"></label>
|
|
3201
|
+
<label><span>Access token</span><input data-ntfy="token" value="${esc(r.ntfy.token)}" placeholder="Only for protected topics" spellcheck="false" type="password"></label>
|
|
3202
|
+
</div>
|
|
3203
|
+
<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>
|
|
3204
|
+
</section>
|
|
3205
|
+
<section class="ph-sec">
|
|
3206
|
+
<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>
|
|
3207
|
+
<div class="settings-modal">
|
|
3208
|
+
<label><span>Bot token</span><input data-tg="token" value="${esc(tg.token)}" placeholder="123456:ABC… from @BotFather" spellcheck="false" type="password"></label>
|
|
3209
|
+
</div>
|
|
3210
|
+
<p class="hint">${
|
|
3211
|
+
tg.linked
|
|
3212
|
+
? `${icon('check')} Linked to <b>${esc(tg.chatName || 'your chat')}</b>. <button class="link xs" data-act="unlink">Unlink</button>`
|
|
3213
|
+
: tg.token && tg.enabled
|
|
3214
|
+
? `Now send <code>/link ${esc(tg.linkCode)}</code> to your bot. Only the chat that sends this code gets linked.`
|
|
3215
|
+
: 'Create a bot with @BotFather, paste its token here and switch Telegram on.'
|
|
3216
|
+
}</p>
|
|
3217
|
+
</section>
|
|
3218
|
+
<div class="ship-foot"><span class="grow"></span><button class="ghost sm" data-act="test">${icon('send')}Send a test</button></div>`;
|
|
3219
|
+
};
|
|
3220
|
+
node.addEventListener('change', (e) => {
|
|
3221
|
+
const t = e.target;
|
|
3222
|
+
if (t.dataset.ev) put({ events: $$('[data-ev]', node).filter((x) => x.checked).map((x) => x.dataset.ev) });
|
|
3223
|
+
if (t.dataset.ntfy) put({ ntfy: { ...r.ntfy, [t.dataset.ntfy]: t.value.trim() } });
|
|
3224
|
+
if (t.dataset.tg) put({ telegram: { enabled: r.telegram.enabled, token: t.value.trim() } });
|
|
3225
|
+
});
|
|
3226
|
+
node.addEventListener('click', async (e) => {
|
|
3227
|
+
const sw = e.target.closest('[data-sw]')?.dataset.sw;
|
|
3228
|
+
if (sw === 'ntfy') {
|
|
3229
|
+
// A fresh random topic the first time, so it isn't guessable.
|
|
3230
|
+
const topic = r.ntfy.topic || `wisp-${crypto.getRandomValues(new Uint32Array(2)).join('').slice(0, 14)}`;
|
|
3231
|
+
return put({ ntfy: { ...r.ntfy, enabled: !r.ntfy.enabled, topic } });
|
|
3232
|
+
}
|
|
3233
|
+
if (sw === 'telegram') return put({ telegram: { enabled: !r.telegram.enabled, token: r.telegram.token } });
|
|
3234
|
+
const act = e.target.closest('[data-act]')?.dataset.act;
|
|
3235
|
+
if (act === 'unlink') return put({ telegram: { enabled: r.telegram.enabled, token: r.telegram.token, unlink: true } });
|
|
3236
|
+
if (act === 'test') {
|
|
3237
|
+
try {
|
|
3238
|
+
r = await api('/remote/test', { method: 'POST', body: {} });
|
|
3239
|
+
paint();
|
|
3240
|
+
toast('Test sent');
|
|
3241
|
+
} catch (err) {
|
|
3242
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
});
|
|
3246
|
+
node.refresh = async () => {
|
|
3247
|
+
if (node.contains(document.activeElement) && document.activeElement.matches('input')) return; // don't clobber typing
|
|
3248
|
+
r = await api('/remote').catch(() => r);
|
|
3249
|
+
if (r && node.isConnected) paint();
|
|
3250
|
+
};
|
|
3251
|
+
r = await api('/remote').catch((err) => (toast(esc(err.message), { tone: 'bad' }), null));
|
|
3252
|
+
if (r) paint();
|
|
3253
|
+
}
|
|
3254
|
+
|
|
1817
3255
|
// --- settings -----------------------------------------------------------------------------
|
|
1818
3256
|
|
|
1819
|
-
|
|
3257
|
+
// A sidebar of sections and one pane at a time, like every other app's settings. Big lists
|
|
3258
|
+
// (memory, automations, usage history) keep their own views; a pane only summarises and links.
|
|
3259
|
+
const SETTINGS_PANES = [
|
|
3260
|
+
{ id: 'general', label: 'General', icon: 'settings', lede: 'Where files open, where you take over, and how tasks start.' },
|
|
3261
|
+
{ id: 'notifications', label: 'Notifications', icon: 'bolt', lede: 'Hear about questions and finished tasks, here or on your phone.' },
|
|
3262
|
+
{ id: 'agents', label: 'Agents', icon: 'agent', lede: 'The harnesses wisp found on this machine. Pick one next to the model in the launcher.' },
|
|
3263
|
+
{ id: 'memory', label: 'Memory', icon: 'spark', lede: "Learned from your sessions and added to every agent's prompt. What you ask always wins." },
|
|
3264
|
+
{ id: 'automations', label: 'Automations', icon: 'play', lede: 'Saved prompts, and the triggers that launch them for you.' },
|
|
3265
|
+
{ id: 'usage', label: 'Usage', icon: 'stats', lede: 'Spend and outcomes over the last 7 days.' },
|
|
3266
|
+
{ id: 'privacy', label: 'Privacy', icon: 'info', lede: 'What wisp sends about itself. Never prompts, paths or code.' },
|
|
3267
|
+
];
|
|
3268
|
+
let settingsPane = 'general';
|
|
3269
|
+
|
|
3270
|
+
function openSettings(opts) {
|
|
3271
|
+
if (SETTINGS_PANES.some((p) => p.id === opts?.pane)) settingsPane = opts.pane;
|
|
1820
3272
|
const d = S.config.defaults;
|
|
3273
|
+
// A row: name and a line of explanation on the left, the control on the right.
|
|
3274
|
+
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>`;
|
|
3275
|
+
const missing = S.agents.filter((a) => !a.available).length;
|
|
1821
3276
|
const node = el(`
|
|
1822
|
-
<div class="modal settings-modal">
|
|
1823
|
-
<
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
<
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
3277
|
+
<div class="modal settings-modal sx">
|
|
3278
|
+
<nav class="sx-nav" role="tablist" aria-orientation="vertical">
|
|
3279
|
+
<h2>Settings</h2>
|
|
3280
|
+
${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('')}
|
|
3281
|
+
</nav>
|
|
3282
|
+
<div class="sx-main">
|
|
3283
|
+
<header><div><h3 class="sx-title"></h3><p class="sx-lede"></p></div><button class="icon-btn close">${icon('x')}</button></header>
|
|
3284
|
+
<section class="sx-pane" data-pane="general">
|
|
3285
|
+
<div class="sx-group">
|
|
3286
|
+
${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']]
|
|
3287
|
+
.map(([v, l]) => `<option value="${v}" ${d.editor === v || (!d.editor && v === 'vscode') ? 'selected' : ''}>${l}</option>`)
|
|
3288
|
+
.join('')}</select>`)}
|
|
3289
|
+
${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>`)}
|
|
3290
|
+
<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>
|
|
3291
|
+
<div class="sx-ctl"><input name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></div></div>
|
|
3292
|
+
</div>
|
|
3293
|
+
<div class="sx-group">
|
|
3294
|
+
${row('Worktree per task', 'Each task gets its own branch from origin/main, in git repos', '<button class="switch" id="wtDefault" role="switch"></button>')}
|
|
3295
|
+
</div>
|
|
3296
|
+
</section>
|
|
3297
|
+
<section class="sx-pane" data-pane="notifications">
|
|
3298
|
+
<div class="sx-group">
|
|
3299
|
+
${row('Desktop notifications', '<span class="notif-hint"></span>', '<button class="switch" id="notif" role="switch"></button>')}
|
|
3300
|
+
</div>
|
|
3301
|
+
<div class="sx-group">
|
|
3302
|
+
${row('Phone', '<span class="phone-hint">ntfy or Telegram: answer questions from your phone</span>', '<button class="ghost sm" id="openPhone">Set up</button>')}
|
|
3303
|
+
</div>
|
|
3304
|
+
</section>
|
|
3305
|
+
<section class="sx-pane" data-pane="agents">
|
|
3306
|
+
<div class="sx-group">${S.agents
|
|
3307
|
+
.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>`))
|
|
3308
|
+
.join('')}</div>
|
|
3309
|
+
<p class="hint">Others plug in via <code>server/adapters</code>.</p>
|
|
3310
|
+
</section>
|
|
3311
|
+
<section class="sx-pane" data-pane="memory">
|
|
3312
|
+
<div class="sx-group">
|
|
3313
|
+
${row('Learn from sessions', 'A short retro after each session keeps preferences and lessons', '<button class="switch" id="learn" role="switch"></button>')}
|
|
3314
|
+
${row('Remembered', '<span class="learn-hint"></span>', '<button class="ghost sm" id="openMemory">Open memory</button>')}
|
|
3315
|
+
</div>
|
|
3316
|
+
</section>
|
|
3317
|
+
<section class="sx-pane" data-pane="automations">
|
|
3318
|
+
<div class="sx-group">
|
|
3319
|
+
${row('Recipes', '<span class="recipe-hint"></span>', '<button class="ghost sm" data-auto="recipes">Manage</button>')}
|
|
3320
|
+
${row('Triggers', '<span class="trigger-hint"></span>', '<button class="ghost sm" data-auto="triggers">Manage</button>')}
|
|
3321
|
+
</div>
|
|
3322
|
+
</section>
|
|
3323
|
+
<section class="sx-pane" data-pane="usage">
|
|
3324
|
+
<div class="us-tiles sx-usage"><div class="it-loading"><span class="orb tiny"></span>Adding it up…</div></div>
|
|
3325
|
+
<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>
|
|
3326
|
+
</section>
|
|
3327
|
+
<section class="sx-pane" data-pane="privacy">
|
|
3328
|
+
<div class="sx-group">
|
|
3329
|
+
${row('Anonymous usage stats', '<span class="tele-hint"></span>', '<button class="switch" id="tele" role="switch"></button>')}
|
|
3330
|
+
</div>
|
|
3331
|
+
<p class="hint">Agent, model, status, timings and scrubbed errors only.</p>
|
|
3332
|
+
</section>
|
|
3333
|
+
</div>
|
|
1838
3334
|
</div>`);
|
|
1839
3335
|
const close = overlay(node);
|
|
1840
3336
|
$('.close', node).onclick = close;
|
|
3337
|
+
const pick = (id, focus = false) => {
|
|
3338
|
+
settingsPane = id;
|
|
3339
|
+
const p = SETTINGS_PANES.find((x) => x.id === id);
|
|
3340
|
+
$$('.sx-nav [data-pane]', node).forEach((b) => {
|
|
3341
|
+
const on = b.dataset.pane === id;
|
|
3342
|
+
b.setAttribute('aria-selected', on);
|
|
3343
|
+
b.tabIndex = on ? 0 : -1;
|
|
3344
|
+
if (on && focus) b.focus();
|
|
3345
|
+
});
|
|
3346
|
+
$$('.sx-pane', node).forEach((s) => (s.hidden = s.dataset.pane !== id));
|
|
3347
|
+
$('.sx-title', node).textContent = p.label;
|
|
3348
|
+
$('.sx-lede', node).textContent = p.lede;
|
|
3349
|
+
$('.sx-main', node).scrollTop = 0;
|
|
3350
|
+
};
|
|
3351
|
+
// Memory, Automations, Usage and Phone open in the main area, next to the sidebar, with a
|
|
3352
|
+
// way back to the section they came from. Leaving one goes back; only the X closes Settings.
|
|
3353
|
+
const main = $('.sx-main', node);
|
|
3354
|
+
const leave = () => {
|
|
3355
|
+
const v = $('.sx-main > .embedded', node);
|
|
3356
|
+
if (!v) return;
|
|
3357
|
+
v.remove();
|
|
3358
|
+
$('.sx-main > header', node).hidden = false;
|
|
3359
|
+
paintHints();
|
|
3360
|
+
};
|
|
3361
|
+
const show = (id, focus) => (leave(), pick(id, focus));
|
|
3362
|
+
const host = (pane) => (view) => {
|
|
3363
|
+
show(pane);
|
|
3364
|
+
view.classList.add('embedded');
|
|
3365
|
+
const back = el(`<button class="ghost sm sx-back">${icon('back')}Back</button>`);
|
|
3366
|
+
back.onclick = () => show(pane);
|
|
3367
|
+
$('header', view).prepend(back);
|
|
3368
|
+
$('.close', view).onclick = close;
|
|
3369
|
+
$('.sx-main > header', node).hidden = true;
|
|
3370
|
+
$$('.sx-pane', node).forEach((s) => (s.hidden = true));
|
|
3371
|
+
main.append(view);
|
|
3372
|
+
main.scrollTop = 0;
|
|
3373
|
+
return close;
|
|
3374
|
+
};
|
|
3375
|
+
$('.sx-nav', node).onclick = (e) => {
|
|
3376
|
+
const b = e.target.closest('[data-pane]');
|
|
3377
|
+
if (b) show(b.dataset.pane);
|
|
3378
|
+
};
|
|
3379
|
+
$('.sx-nav', node).onkeydown = (e) => {
|
|
3380
|
+
const step = { ArrowDown: 1, ArrowUp: -1 }[e.key];
|
|
3381
|
+
if (!step) return;
|
|
3382
|
+
e.preventDefault();
|
|
3383
|
+
const i = SETTINGS_PANES.findIndex((p) => p.id === settingsPane);
|
|
3384
|
+
show(SETTINGS_PANES[(i + step + SETTINGS_PANES.length) % SETTINGS_PANES.length].id, true);
|
|
3385
|
+
};
|
|
3386
|
+
show(settingsPane);
|
|
1841
3387
|
node.addEventListener('change', async (e) => {
|
|
1842
3388
|
const { name, value } = e.target;
|
|
1843
|
-
if (!name) return;
|
|
3389
|
+
if (!name || e.target.closest('.embedded')) return; // a nested view's own form
|
|
1844
3390
|
if (name === 'terminal') $('.custom-term', node).hidden = value !== 'custom';
|
|
1845
3391
|
await api('/config', { method: 'PATCH', body: { defaults: { [name]: value } } });
|
|
1846
3392
|
await loadConfig();
|
|
@@ -1888,9 +3434,42 @@ function openSettings() {
|
|
|
1888
3434
|
await setLearning(S.config.defaults.learn === 'off');
|
|
1889
3435
|
paintLearn();
|
|
1890
3436
|
};
|
|
1891
|
-
$('#openMemory', node).onclick = () => openMemory();
|
|
1892
3437
|
node.paintLearn = paintLearn;
|
|
1893
|
-
|
|
3438
|
+
// One line each; their lists and charts live in their own views.
|
|
3439
|
+
const wt = $('#wtDefault', node);
|
|
3440
|
+
const paintWt = () => wt.setAttribute('aria-checked', S.config.defaults.worktree !== 'off');
|
|
3441
|
+
wt.onclick = async () => {
|
|
3442
|
+
await api('/config', { method: 'PATCH', body: { defaults: { worktree: S.config.defaults.worktree === 'off' ? 'on' : 'off' } } });
|
|
3443
|
+
await loadConfig();
|
|
3444
|
+
paintWt();
|
|
3445
|
+
renderLauncher();
|
|
3446
|
+
};
|
|
3447
|
+
paintWt();
|
|
3448
|
+
// The one-line summaries; painted again on the way back from a view that may have changed them.
|
|
3449
|
+
const paintHints = () => {
|
|
3450
|
+
const r = S.config.recipes?.length || 0;
|
|
3451
|
+
const all = S.config.triggers || [];
|
|
3452
|
+
const t = all.filter((x) => x.enabled).length;
|
|
3453
|
+
$('.recipe-hint', node).textContent = r ? `${r} saved prompt${r === 1 ? '' : 's'}` : 'None yet · save a prompt you run often';
|
|
3454
|
+
$('.trigger-hint', node).textContent = all.length ? `${t} of ${all.length} on` : 'None yet · run a recipe on a schedule, CI failure or new issue';
|
|
3455
|
+
api('/usage?days=7')
|
|
3456
|
+
.then((u) => {
|
|
3457
|
+
const x = u.total;
|
|
3458
|
+
$('.sx-usage', node).innerHTML = `
|
|
3459
|
+
<div><span>Spend</span><b>${x.priced ? money(x.cost) : '—'}</b></div>
|
|
3460
|
+
<div><span>Turns</span><b>${x.turns}</b></div>
|
|
3461
|
+
<div><span>Worked</span><b>${pct(x.successRate)}</b></div>
|
|
3462
|
+
<div><span>Questions</span><b>${x.questions}</b></div>`;
|
|
3463
|
+
})
|
|
3464
|
+
.catch(() => ($('.sx-usage', node).innerHTML = ''));
|
|
3465
|
+
api('/remote').then((x) => ($('.phone-hint', node).textContent = phoneSummary(x) === 'Off' ? 'ntfy or Telegram: answer questions from your phone' : phoneSummary(x))).catch(() => {});
|
|
3466
|
+
paintLearn();
|
|
3467
|
+
};
|
|
3468
|
+
paintHints();
|
|
3469
|
+
$('#openMemory', node).onclick = () => openMemory({ host: host('memory') });
|
|
3470
|
+
$$('[data-auto]', node).forEach((b) => (b.onclick = () => openAutomations({ tab: b.dataset.auto, host: host('automations') })));
|
|
3471
|
+
$('#openUsage', node).onclick = () => openUsage({ days: 7, host: host('usage') });
|
|
3472
|
+
$('#openPhone', node).onclick = () => openPhone({ host: host('notifications') });
|
|
1894
3473
|
}
|
|
1895
3474
|
|
|
1896
3475
|
// --- memory: what the background retros learned ---------------------------------------------
|
|
@@ -1917,15 +3496,14 @@ function memorySummary(data) {
|
|
|
1917
3496
|
return `${n ? `${n} remembered` : 'Nothing yet'}${last ? ` · last retro ${ago(last.at)}` : ''}`;
|
|
1918
3497
|
}
|
|
1919
3498
|
|
|
1920
|
-
function openMemory({ tab = 'global' } = {}) {
|
|
3499
|
+
function openMemory({ tab = 'global', host } = {}) {
|
|
1921
3500
|
const node = el(`<div class="modal memory-modal">
|
|
1922
3501
|
<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
3502
|
<p class="mem-lede">Learned from your sessions and added to every agent's prompt. What you ask always wins.</p>
|
|
1924
3503
|
<div class="mem-tabs" role="tablist"></div>
|
|
1925
3504
|
<div class="mem-body"></div>
|
|
1926
3505
|
</div>`);
|
|
1927
|
-
const close =
|
|
1928
|
-
$('.close', node).onclick = close;
|
|
3506
|
+
const close = present(node, host);
|
|
1929
3507
|
let data;
|
|
1930
3508
|
let editing = null; // { scope, kind, index } (index -1 = adding)
|
|
1931
3509
|
|
|
@@ -2039,7 +3617,7 @@ function telemetryNotice() {
|
|
|
2039
3617
|
if (!S.config.telemetry?.enabled || S.config.defaults.telemetryNotice === 'seen') return;
|
|
2040
3618
|
S.config.defaults.telemetryNotice = 'seen';
|
|
2041
3619
|
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 } });
|
|
3620
|
+
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
3621
|
}
|
|
2044
3622
|
|
|
2045
3623
|
function shortcuts() {
|
|
@@ -2056,10 +3634,12 @@ function shortcuts() {
|
|
|
2056
3634
|
['T', 'Take over in terminal'],
|
|
2057
3635
|
['R', 'Replay presentation'],
|
|
2058
3636
|
['⇧R', 'Rerun with another harness, model or thinking level'],
|
|
3637
|
+
['S', 'Ship it: commit, push and open a PR (worktree tasks)'],
|
|
2059
3638
|
['1–9', 'Pick an answer when asked'],
|
|
2060
3639
|
['↵', 'Launch / send'],
|
|
2061
3640
|
['⇧↵', 'New line'],
|
|
2062
3641
|
['C', 'Collapse all'],
|
|
3642
|
+
['U', 'Back to top'],
|
|
2063
3643
|
['Esc', 'Close / collapse'],
|
|
2064
3644
|
];
|
|
2065
3645
|
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 +3659,8 @@ const modKey = (e, k) =>
|
|
|
2079
3659
|
const STATE_LABEL = { running: 'Working', waiting: 'Asking', review: 'Review', done: 'Done' };
|
|
2080
3660
|
|
|
2081
3661
|
function focusLauncher() {
|
|
2082
|
-
|
|
3662
|
+
// Docked under the top bar it opens where it is, so you keep your place in the feed.
|
|
3663
|
+
if (!$('#launcher').classList.contains('docked')) stage.scrollTo({ top: 0, behavior: 'smooth' });
|
|
2083
3664
|
$('#prompt')?.focus({ preventScroll: true });
|
|
2084
3665
|
}
|
|
2085
3666
|
|
|
@@ -2095,14 +3676,23 @@ function paletteItems() {
|
|
|
2095
3676
|
const cur = S.focus && S.sessions.get(S.focus);
|
|
2096
3677
|
if (cur) {
|
|
2097
3678
|
const id = cur.id;
|
|
3679
|
+
const full = S.full.get(id);
|
|
3680
|
+
const lastTurn = full?.turns?.at(-1);
|
|
2098
3681
|
const live = cur.state === 'running' || cur.state === 'waiting';
|
|
3682
|
+
const canRollback = cur.agent !== 'wisp' && !live && (lastTurn?.changes?.length || lastTurn?.checkpoint?.commit || cur.filesChanged);
|
|
3683
|
+
const rolledBack = !!lastTurn?.rolledBackAt;
|
|
2099
3684
|
const g = 'This wisp';
|
|
2100
3685
|
const sub = cur.title;
|
|
2101
3686
|
items.push({ group: g, label: 'Follow up', sub, icon: 'send', kbd: '/', run: () => withCard(id, () => $('.reply', S.nodes.get(id)?.card)?.focus(), { open: true }) });
|
|
2102
3687
|
if (!live) items.push({ group: g, label: cur.state === 'done' ? 'Move back to review' : 'Mark done', sub, icon: 'done', kbd: 'E', run: () => toggleDone(id) });
|
|
3688
|
+
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
3689
|
if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
|
|
2104
3690
|
items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
|
|
2105
3691
|
items.push({ group: g, label: 'Rerun…', sub, icon: 'rerun', kbd: '⇧R', keywords: 'retry again restart model harness', run: () => rerun(id) });
|
|
3692
|
+
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) });
|
|
3693
|
+
if (cur.pr?.state === 'OPEN') items.push({ group: g, label: `Merge PR #${cur.pr.number}…`, sub, icon: 'check', keywords: 'squash github', run: () => mergePr(id) });
|
|
3694
|
+
if (cur.groupId) items.push({ group: g, label: 'Compare runs', sub, icon: 'agent', keywords: 'vs side by side harness', run: () => compareView(id) });
|
|
3695
|
+
items.push({ group: g, label: 'Save as recipe…', sub, icon: 'spark', keywords: 'template reuse automation', run: () => saveAsRecipe(id) });
|
|
2106
3696
|
items.push({ group: g, label: 'Replay presentation', sub, icon: 'replay', kbd: 'R', run: () => withCard(id, () => replay(id), { open: true }) });
|
|
2107
3697
|
items.push({ group: g, label: 'Rename', sub, icon: 'pen', kbd: 'M', run: () => withCard(id, () => rename(id)) });
|
|
2108
3698
|
items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
|
|
@@ -2134,15 +3724,22 @@ function paletteItems() {
|
|
|
2134
3724
|
{ group: a, label: 'Add project…', icon: 'folder', keywords: 'folder repo directory', run: () => openProjects({ focusAdd: true }) },
|
|
2135
3725
|
{ group: a, label: 'Manage projects', icon: 'folder', keywords: 'rename color remove', run: () => openProjects() },
|
|
2136
3726
|
{ group: a, label: 'Memory', icon: 'spark', keywords: 'learned preferences lessons retro', run: () => openMemory() },
|
|
3727
|
+
{ group: a, label: 'Recipes', icon: 'spark', keywords: 'automations saved prompts templates', run: () => openAutomations({ tab: 'recipes' }) },
|
|
3728
|
+
{ group: a, label: 'Triggers', icon: 'replay', keywords: 'automations schedule cron ci issues github', run: () => openAutomations({ tab: 'triggers' }) },
|
|
3729
|
+
{ group: a, label: 'Usage', icon: 'stats', keywords: 'spend cost tokens outcomes success', run: () => openUsage() },
|
|
3730
|
+
{ group: a, label: 'Phone', icon: 'send', keywords: 'ntfy telegram notifications mobile remote', run: () => openPhone() },
|
|
2137
3731
|
{ group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
|
|
2138
3732
|
{ group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
|
|
2139
3733
|
);
|
|
2140
3734
|
|
|
3735
|
+
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) }) });
|
|
3736
|
+
|
|
2141
3737
|
const filter = (id) => () => {
|
|
2142
3738
|
S.filter = id;
|
|
2143
3739
|
save('wisp.filter', id);
|
|
2144
3740
|
renderProjects();
|
|
2145
3741
|
renderFeed();
|
|
3742
|
+
feedToTop();
|
|
2146
3743
|
renderLaunchChips();
|
|
2147
3744
|
};
|
|
2148
3745
|
items.push({ group: 'Projects', label: 'All projects', sub: S.filter === 'all' ? 'Current filter' : '', dot: 'slate', keywords: 'filter clear', run: filter('all') });
|
|
@@ -2173,8 +3770,7 @@ function askNotify() {
|
|
|
2173
3770
|
}
|
|
2174
3771
|
|
|
2175
3772
|
function notifyFinished(s) {
|
|
2176
|
-
const
|
|
2177
|
-
const viewing = S.open.has(s.id) && n && isInView(n.card) && document.hasFocus();
|
|
3773
|
+
const viewing = S.open.has(s.id) && watching(s.id) && document.hasFocus();
|
|
2178
3774
|
if (viewing) return;
|
|
2179
3775
|
const verb = s.state === 'waiting' ? 'has a question' : 'is ready';
|
|
2180
3776
|
toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
|
|
@@ -2190,20 +3786,24 @@ function connect() {
|
|
|
2190
3786
|
const es = new EventSource('/api/events');
|
|
2191
3787
|
es.addEventListener('summary', (e) => {
|
|
2192
3788
|
const s = JSON.parse(e.data);
|
|
3789
|
+
if (s.parentId) return onChildSummary(s); // a subagent: lives inside its lead's card
|
|
2193
3790
|
const prev = S.sessions.get(s.id);
|
|
3791
|
+
const was = prev && sectionOf(prev); // before the update: a compare task's section depends on all its runs
|
|
2194
3792
|
S.sessions.set(s.id, s);
|
|
2195
|
-
// A
|
|
2196
|
-
//
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
3793
|
+
// A section change moves the card (a follow-up sends it from "Needs you" to "Working").
|
|
3794
|
+
// If the user is watching it, the view follows it there; otherwise whatever they're
|
|
3795
|
+
// reading stays put. Marking done or archiving is the exception: the card leaves the
|
|
3796
|
+
// list and focus goes to the next one instead.
|
|
3797
|
+
const moved = prev && was !== sectionOf(s);
|
|
3798
|
+
const follow = moved && s.state !== 'done' && !s.archived && watching(s.id);
|
|
3799
|
+
keepInPlace(follow ? S.nodes.get(s.id).card : feedAnchor(s.id), () => {
|
|
3800
|
+
// A card left open while it worked shouldn't land in "Needs you" expanded —
|
|
3801
|
+
// fold it unless the user is actually watching it.
|
|
3802
|
+
if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(watching(s.id) && document.hasFocus())) collapse(s.id);
|
|
3803
|
+
renderFeed();
|
|
3804
|
+
});
|
|
2206
3805
|
if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
|
|
3806
|
+
if (JSON.stringify(prev?.pr) !== JSON.stringify(s.pr)) renderPrStrip(s.id);
|
|
2207
3807
|
});
|
|
2208
3808
|
es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
|
|
2209
3809
|
es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
|
|
@@ -2214,9 +3814,10 @@ function connect() {
|
|
|
2214
3814
|
S.full.delete(id);
|
|
2215
3815
|
S.open.delete(id);
|
|
2216
3816
|
saveOpen();
|
|
2217
|
-
|
|
3817
|
+
keepInPlace(feedAnchor(id), renderFeed);
|
|
2218
3818
|
});
|
|
2219
|
-
es.addEventListener('config', () => loadConfig());
|
|
3819
|
+
es.addEventListener('config', () => (loadConfig(), $('.auto-modal')?.refresh()));
|
|
3820
|
+
es.addEventListener('remote', () => $('.phone-modal')?.refresh());
|
|
2220
3821
|
es.addEventListener('update', (e) => renderUpdate(JSON.parse(e.data)));
|
|
2221
3822
|
es.addEventListener('preferences', () => {
|
|
2222
3823
|
$('.memory-modal')?.refresh();
|
|
@@ -2230,7 +3831,7 @@ async function loadSessions() {
|
|
|
2230
3831
|
S.sessions = new Map(list.map((s) => [s.id, s]));
|
|
2231
3832
|
for (const id of S.open) if (!S.sessions.has(id)) S.open.delete(id);
|
|
2232
3833
|
saveOpen();
|
|
2233
|
-
|
|
3834
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
2234
3835
|
for (const id of S.open) scheduleRefetch(id);
|
|
2235
3836
|
}
|
|
2236
3837
|
|
|
@@ -2238,6 +3839,7 @@ async function loadConfig() {
|
|
|
2238
3839
|
S.config = await api('/config');
|
|
2239
3840
|
renderProjects();
|
|
2240
3841
|
renderLaunchChips();
|
|
3842
|
+
paintAutoBtn();
|
|
2241
3843
|
}
|
|
2242
3844
|
|
|
2243
3845
|
// --- updates ----------------------------------------------------------------------------
|
|
@@ -2285,6 +3887,27 @@ async function installUpdate() {
|
|
|
2285
3887
|
// --- keyboard ---------------------------------------------------------------------------
|
|
2286
3888
|
|
|
2287
3889
|
const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
|
|
3890
|
+
// The user takes a card out of its list (done, archive, delete). The next card — the one below,
|
|
3891
|
+
// or the one above at the end of the list — gets focus and lands where the old one sat, so
|
|
3892
|
+
// triage continues on the same spot. Call before the request, while the card is still laid
|
|
3893
|
+
// out; the returned function applies the change locally and places the next card.
|
|
3894
|
+
function handOff(id) {
|
|
3895
|
+
const card = S.nodes.get(id)?.card;
|
|
3896
|
+
if (!card?.isConnected) return (apply) => (apply(), renderFeed());
|
|
3897
|
+
const nextUp = (card.nextElementSibling || card.previousElementSibling)?.dataset.id || feedIds().find((x) => x !== id);
|
|
3898
|
+
const r = card.getBoundingClientRect();
|
|
3899
|
+
const inset = topInset();
|
|
3900
|
+
const slot = r.bottom > inset && r.top < innerHeight ? Math.max(inset, r.top) : null;
|
|
3901
|
+
return (apply) => {
|
|
3902
|
+
if (S.open.has(id)) collapse(id);
|
|
3903
|
+
apply(); // don't wait for the live update to drop the card
|
|
3904
|
+
renderFeed();
|
|
3905
|
+
if (!nextUp || !S.nodes.has(nextUp)) return;
|
|
3906
|
+
setFocus(nextUp, { scroll: slot === null });
|
|
3907
|
+
const next = S.nodes.get(nextUp).card;
|
|
3908
|
+
if (slot !== null && next.isConnected) scrollByPx(next.getBoundingClientRect().top - slot);
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
2288
3911
|
|
|
2289
3912
|
document.addEventListener('keydown', (e) => {
|
|
2290
3913
|
// ⌘K / Ctrl+K works from anywhere, even mid-sentence in the launcher.
|
|
@@ -2300,7 +3923,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
2300
3923
|
}
|
|
2301
3924
|
const typing = e.target.closest('input, textarea, select, [contenteditable="true"]');
|
|
2302
3925
|
if (e.key === 'Escape') {
|
|
2303
|
-
if (!$('#overlay').hidden) return closeOverlay();
|
|
3926
|
+
if (!$('#overlay').hidden) return $('#overlay .sx-back') ? $('#overlay .sx-back').click() : closeOverlay();
|
|
2304
3927
|
if (typing) return e.target.blur();
|
|
2305
3928
|
if (S.focus && S.open.has(S.focus)) {
|
|
2306
3929
|
collapse(S.focus);
|
|
@@ -2329,6 +3952,10 @@ document.addEventListener('keydown', (e) => {
|
|
|
2329
3952
|
if (s && (s.archived || (s.state !== 'running' && s.state !== 'waiting'))) setArchived(id, !s.archived);
|
|
2330
3953
|
}
|
|
2331
3954
|
else if (k === 'c' && S.open.size) collapseAll();
|
|
3955
|
+
else if (k === 'u' || k === 'home') {
|
|
3956
|
+
e.preventDefault();
|
|
3957
|
+
backToTop();
|
|
3958
|
+
}
|
|
2332
3959
|
else if (k === 'm' && id) {
|
|
2333
3960
|
e.preventDefault();
|
|
2334
3961
|
rename(id);
|
|
@@ -2337,6 +3964,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
2337
3964
|
deleteSession(id);
|
|
2338
3965
|
} else if (k === 't' && id) takeOver(id);
|
|
2339
3966
|
else if (k === 'r' && e.shiftKey && id) rerun(id);
|
|
3967
|
+
else if (k === 's' && id && S.sessions.get(id)?.branch) shipDialog(id);
|
|
2340
3968
|
else if (k === 'r' && id) replay(id);
|
|
2341
3969
|
else if (k === '/' && id) {
|
|
2342
3970
|
e.preventDefault();
|
|
@@ -2353,6 +3981,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
2353
3981
|
|
|
2354
3982
|
$('#settingsBtn').onclick = openSettings;
|
|
2355
3983
|
$('#searchBtn').onclick = openPalette;
|
|
3984
|
+
$('#autoBtn').onclick = () => openAutomations({ tab: (S.config.triggers || []).length && !(S.config.recipes || []).length ? 'triggers' : 'recipes' });
|
|
2356
3985
|
$('#updateBtn').onclick = installUpdate;
|
|
2357
3986
|
$('#searchBtn').title = `Search (${combo('K')})`;
|
|
2358
3987
|
$('#searchBtn .mod-k').textContent = combo('K');
|
|
@@ -2380,11 +4009,23 @@ setInterval(() => {
|
|
|
2380
4009
|
|
|
2381
4010
|
// Sticky top bar gets a hairline once the page scrolls; "back to top" shows after a screen's worth.
|
|
2382
4011
|
const toTop = $('#toTop');
|
|
4012
|
+
// Once the launcher scrolls under the top bar it docks there, folded to one line. The slot keeps
|
|
4013
|
+
// its full height in the feed so docking never shifts what you're reading.
|
|
4014
|
+
const launchSlot = $('#launchSlot');
|
|
4015
|
+
const dockLauncher = () => {
|
|
4016
|
+
const box = $('#launcher');
|
|
4017
|
+
const gone = launchSlot.getBoundingClientRect().bottom < $('#topbar').offsetHeight;
|
|
4018
|
+
if (gone === box.classList.contains('docked')) return;
|
|
4019
|
+
launchSlot.style.height = gone ? `${box.offsetHeight}px` : '';
|
|
4020
|
+
box.classList.toggle('docked', gone);
|
|
4021
|
+
};
|
|
2383
4022
|
stage.addEventListener('scroll', () => {
|
|
2384
4023
|
$('#topbar').classList.toggle('scrolled', stage.scrollTop > 4);
|
|
4024
|
+
dockLauncher();
|
|
2385
4025
|
toTop.hidden = stage.scrollTop < stage.clientHeight * 0.75;
|
|
2386
4026
|
}, { passive: true });
|
|
2387
|
-
|
|
4027
|
+
const backToTop = () => stage.scrollTo({ top: 0, behavior: 'smooth' });
|
|
4028
|
+
toTop.onclick = backToTop;
|
|
2388
4029
|
|
|
2389
4030
|
if (/Electron/.test(navigator.userAgent)) document.body.classList.add('electron', /Mac/.test(navigator.platform) ? 'mac' : 'other');
|
|
2390
4031
|
|