wisp-agents 0.6.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 +52 -1
- package/README.md +90 -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 +473 -21
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +7 -1
- package/server/openapi.js +168 -4
- package/server/preferences.js +296 -0
- 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 +153 -13
- package/server/store.js +9 -1
- package/server/subagents.js +85 -0
- package/server/triggers.js +268 -0
- package/server/update.js +136 -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 +637 -14
- package/web/index.html +15 -3
- package/web/js/app.js +2060 -87
- 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>`);
|
|
@@ -197,7 +254,29 @@ function renderFeedHead(needs, working) {
|
|
|
197
254
|
// never shifts the feed, and it stays reachable deep inside a long open card.
|
|
198
255
|
const collapseAllBtn = el(`<button class="collapse-all" title="Collapse all (C) · Esc collapses the focused one"><span></span><kbd>C</kbd></button>`);
|
|
199
256
|
document.body.append(collapseAllBtn);
|
|
200
|
-
|
|
257
|
+
// Collapsing shrinks everything above the reader, which would leave them staring
|
|
258
|
+
// at unrelated cards far down the feed. Keep the card they were reading in view.
|
|
259
|
+
const collapseAll = () => {
|
|
260
|
+
const anchor = readingCard();
|
|
261
|
+
[...S.open].forEach((id) => collapse(id));
|
|
262
|
+
if (!anchor) return;
|
|
263
|
+
setFocus(anchor, { scroll: false });
|
|
264
|
+
const card = S.nodes.get(anchor)?.card;
|
|
265
|
+
if (card?.isConnected) card.scrollIntoView({ block: 'center' });
|
|
266
|
+
};
|
|
267
|
+
// The open card covering the most of the viewport; the focused one breaks ties.
|
|
268
|
+
function readingCard() {
|
|
269
|
+
let best = null;
|
|
270
|
+
let bestArea = 0;
|
|
271
|
+
for (const id of S.open) {
|
|
272
|
+
const card = S.nodes.get(id)?.card;
|
|
273
|
+
if (!card?.isConnected) continue;
|
|
274
|
+
const r = card.getBoundingClientRect();
|
|
275
|
+
const seen = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0) + (id === S.focus ? 1 : 0);
|
|
276
|
+
if (seen > bestArea) (best = id), (bestArea = seen);
|
|
277
|
+
}
|
|
278
|
+
return best || (S.open.has(S.focus) ? S.focus : null);
|
|
279
|
+
}
|
|
201
280
|
collapseAllBtn.addEventListener('click', collapseAll);
|
|
202
281
|
function placeCollapseAll() {
|
|
203
282
|
const open = [...S.open].filter((id) => S.nodes.get(id)?.card.isConnected).length;
|
|
@@ -240,6 +319,7 @@ function renderHead(n, s) {
|
|
|
240
319
|
: s.state === 'review'
|
|
241
320
|
? `<span class="pill st-${s.status}"><i></i>${STATUS_LABEL[s.status] || 'Done'}</span>`
|
|
242
321
|
: '';
|
|
322
|
+
if (runsOf(s).length > 1) return renderRunHead(n, s, { open, live, pill });
|
|
243
323
|
n.card.className = `item c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
244
324
|
n.head.innerHTML = `
|
|
245
325
|
<span class="q-dot ${s.state === 'running' ? 'live' : ''} ${s.state === 'waiting' ? 'waiting' : ''}"></span>
|
|
@@ -250,7 +330,8 @@ function renderHead(n, s) {
|
|
|
250
330
|
<span class="it-side">
|
|
251
331
|
${pill}
|
|
252
332
|
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
253
|
-
${
|
|
333
|
+
${s.pr ? prPill(s.pr) : ''}
|
|
334
|
+
${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort)}
|
|
254
335
|
<span class="q-proj">${esc(s.project)}</span>
|
|
255
336
|
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
256
337
|
${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
|
|
@@ -258,13 +339,74 @@ function renderHead(n, s) {
|
|
|
258
339
|
</span>`;
|
|
259
340
|
}
|
|
260
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
|
+
|
|
261
401
|
function setFocus(id, { scroll = true } = {}) {
|
|
262
402
|
if (S.focus === id) return;
|
|
263
403
|
S.nodes.get(S.focus)?.card.classList.remove('focused');
|
|
264
404
|
S.focus = id;
|
|
265
405
|
const n = S.nodes.get(id);
|
|
266
406
|
n?.card.classList.add('focused');
|
|
267
|
-
|
|
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' });
|
|
268
410
|
}
|
|
269
411
|
|
|
270
412
|
function toggle(id) {
|
|
@@ -434,6 +576,20 @@ function autosize(ta, max = 340) {
|
|
|
434
576
|
|
|
435
577
|
let launchProject = load('wisp.launchProject', null);
|
|
436
578
|
|
|
579
|
+
// The model is remembered per harness (ids aren't portable between them).
|
|
580
|
+
function savedModel(id) {
|
|
581
|
+
const d = S.config.defaults;
|
|
582
|
+
const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
|
|
583
|
+
const models = S.agents.find((a) => a.id === id)?.models || [];
|
|
584
|
+
return models.some((m) => m.id === want) ? want : '';
|
|
585
|
+
}
|
|
586
|
+
// Thinking level too, and only if the chosen model still accepts it.
|
|
587
|
+
function savedEffort(id, model) {
|
|
588
|
+
const d = S.config.defaults;
|
|
589
|
+
const want = d.efforts?.[id] ?? (id === d.agent ? d.effort : '');
|
|
590
|
+
return effortsFor(id, model).includes(want) ? want : '';
|
|
591
|
+
}
|
|
592
|
+
|
|
437
593
|
function renderLauncher() {
|
|
438
594
|
const box = $('#launcher');
|
|
439
595
|
const mode = S.config.defaults.mode || 'edits';
|
|
@@ -446,10 +602,17 @@ function renderLauncher() {
|
|
|
446
602
|
<div class="l-bar">
|
|
447
603
|
<button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
|
|
448
604
|
<button class="mode-btn" id="mode" aria-haspopup="menu"></button>
|
|
449
|
-
<
|
|
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>
|
|
450
611
|
<span class="grow"></span>
|
|
612
|
+
<button class="tog-btn" id="wtBtn"></button>
|
|
451
613
|
<button class="send" id="launch" title="Launch (Enter · Shift+Enter for newline)">${icon('enter')}</button>
|
|
452
|
-
</div
|
|
614
|
+
</div>
|
|
615
|
+
<div class="l-recipes"></div>`;
|
|
453
616
|
renderLaunchChips();
|
|
454
617
|
|
|
455
618
|
const ta = $('#prompt', box);
|
|
@@ -458,7 +621,7 @@ function renderLauncher() {
|
|
|
458
621
|
acceptFiles(box, ta, tray);
|
|
459
622
|
autosize(ta);
|
|
460
623
|
ta.value = S.drafts.get('home') || '';
|
|
461
|
-
ta.addEventListener('input', () => S.drafts.set('home', ta.value));
|
|
624
|
+
ta.addEventListener('input', () => (S.drafts.set('home', ta.value), paintSaveRecipe()));
|
|
462
625
|
// While focus is elsewhere, the placeholder advertises the shortcut that brings you back.
|
|
463
626
|
const hint = () => (ta.placeholder = document.activeElement === ta
|
|
464
627
|
? 'What should I go do? Paste screenshots, drop logs or videos…'
|
|
@@ -466,6 +629,12 @@ function renderLauncher() {
|
|
|
466
629
|
ta.addEventListener('focus', hint);
|
|
467
630
|
ta.addEventListener('blur', hint);
|
|
468
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
|
+
});
|
|
469
638
|
$('#attach', box).onclick = tray.pick;
|
|
470
639
|
|
|
471
640
|
// Mode is set-and-forget, so it's one small button: just an icon on the default (Edits),
|
|
@@ -486,34 +655,82 @@ function renderLauncher() {
|
|
|
486
655
|
};
|
|
487
656
|
paintMode();
|
|
488
657
|
modeBtn.onclick = () => modeMenu(modeBtn, curMode, setMode);
|
|
489
|
-
|
|
490
|
-
const savedModel = (id) => {
|
|
491
|
-
const d = S.config.defaults;
|
|
492
|
-
const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
|
|
493
|
-
const models = S.agents.find((a) => a.id === id)?.models || [];
|
|
494
|
-
return models.some((m) => m.id === want) ? want : '';
|
|
495
|
-
};
|
|
496
|
-
// Thinking level too, and only if the chosen model still accepts it.
|
|
497
|
-
const savedEffort = (id, model) => {
|
|
498
|
-
const d = S.config.defaults;
|
|
499
|
-
const want = d.efforts?.[id] ?? (id === d.agent ? d.effort : '');
|
|
500
|
-
return effortsFor(id, model).includes(want) ? want : '';
|
|
501
|
-
};
|
|
658
|
+
box.mode = () => curMode;
|
|
502
659
|
let curModel = savedModel(agent?.id);
|
|
503
660
|
let curEffort = savedEffort(agent?.id, curModel);
|
|
504
661
|
const runnerBtn = $('#runner', box);
|
|
505
662
|
const paintRunner = () => {
|
|
506
|
-
|
|
507
|
-
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);
|
|
508
664
|
runnerBtn.title = `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
|
|
509
665
|
};
|
|
510
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
|
+
});
|
|
511
727
|
runnerBtn.onclick = () =>
|
|
512
728
|
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
|
|
513
729
|
agent = S.agents.find((a) => a.id === id);
|
|
514
730
|
curModel = model;
|
|
515
731
|
curEffort = effort;
|
|
516
732
|
paintRunner();
|
|
733
|
+
paintTeam();
|
|
517
734
|
const d = S.config.defaults;
|
|
518
735
|
const models = { ...d.models, [id]: model };
|
|
519
736
|
// A model without levels leaves the harness's saved level alone, so switching back restores it.
|
|
@@ -529,10 +746,15 @@ function renderLauncher() {
|
|
|
529
746
|
if (!project) return openProjects({ focusAdd: true });
|
|
530
747
|
const btn = $('#launch', box);
|
|
531
748
|
btn.disabled = true;
|
|
749
|
+
btn.classList.add('busy'); // a fetch and fresh worktrees can take a second or two
|
|
532
750
|
try {
|
|
533
751
|
const attachments = await tray.ids();
|
|
534
|
-
const s = await api('/sessions', {
|
|
535
|
-
|
|
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();
|
|
536
758
|
ta.value = '';
|
|
537
759
|
S.drafts.delete('home');
|
|
538
760
|
tray.clear();
|
|
@@ -542,12 +764,14 @@ function renderLauncher() {
|
|
|
542
764
|
save('wisp.filter', S.filter);
|
|
543
765
|
}
|
|
544
766
|
renderFeed();
|
|
545
|
-
|
|
767
|
+
const card = S.nodes.get(s.id)?.card;
|
|
768
|
+
showLaunched(btn, card?.closest('.task-item') || card);
|
|
546
769
|
askNotify();
|
|
547
770
|
} catch (err) {
|
|
548
771
|
toast(esc(err.message), { tone: 'bad' });
|
|
549
772
|
} finally {
|
|
550
773
|
btn.disabled = false;
|
|
774
|
+
btn.classList.remove('busy');
|
|
551
775
|
ta.focus();
|
|
552
776
|
}
|
|
553
777
|
};
|
|
@@ -564,6 +788,403 @@ function renderLauncher() {
|
|
|
564
788
|
});
|
|
565
789
|
}
|
|
566
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
|
+
|
|
567
1188
|
function modeMenu(anchor, current, pick) {
|
|
568
1189
|
const open = $('.menu.mode');
|
|
569
1190
|
if (open) return open.close();
|
|
@@ -587,7 +1208,7 @@ function modeMenu(anchor, current, pick) {
|
|
|
587
1208
|
menu.addEventListener('keydown', (ev) => {
|
|
588
1209
|
const items = $$('button', menu);
|
|
589
1210
|
const i = items.indexOf(document.activeElement);
|
|
590
|
-
if (ev.key === 'Escape') (close(), anchor.focus());
|
|
1211
|
+
if (ev.key === 'Escape') (ev.stopPropagation(), close(), anchor.focus());
|
|
591
1212
|
else if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
592
1213
|
ev.preventDefault();
|
|
593
1214
|
items[(i + (ev.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length].focus();
|
|
@@ -617,9 +1238,68 @@ function renderLaunchChips() {
|
|
|
617
1238
|
launchProject = b.dataset.id;
|
|
618
1239
|
save('wisp.launchProject', launchProject);
|
|
619
1240
|
$$('.lp-chip', box).forEach((x) => x.classList.toggle('on', x === b));
|
|
1241
|
+
renderLaunchRecipes();
|
|
620
1242
|
}),
|
|
621
1243
|
);
|
|
622
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;
|
|
623
1303
|
}
|
|
624
1304
|
|
|
625
1305
|
// A little wisp flies from the launch button to the new item in the feed.
|
|
@@ -645,6 +1325,18 @@ function launchedFx(from, to) {
|
|
|
645
1325
|
).onfinish = () => w.remove();
|
|
646
1326
|
}
|
|
647
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
|
+
|
|
648
1340
|
// --- project filter (top bar) ---------------------------------------------------
|
|
649
1341
|
|
|
650
1342
|
function renderProjects() {
|
|
@@ -669,6 +1361,7 @@ function renderProjects() {
|
|
|
669
1361
|
S.filter = S.filter === b.dataset.id ? 'all' : b.dataset.id;
|
|
670
1362
|
save('wisp.filter', S.filter);
|
|
671
1363
|
renderFeed();
|
|
1364
|
+
feedToTop();
|
|
672
1365
|
renderLaunchChips();
|
|
673
1366
|
}),
|
|
674
1367
|
);
|
|
@@ -738,6 +1431,8 @@ function ctxFor(s, turn, isLatest) {
|
|
|
738
1431
|
openFile: (p, line) => openInEditor(s, p, line),
|
|
739
1432
|
fileUrl: (p) => `/api/sessions/${s.id}/file?path=${encodeURIComponent(p)}`,
|
|
740
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),
|
|
741
1436
|
};
|
|
742
1437
|
}
|
|
743
1438
|
|
|
@@ -758,11 +1453,12 @@ function renderBody(id, { fresh }) {
|
|
|
758
1453
|
<div class="it-meta"></div>
|
|
759
1454
|
<div class="it-actions">
|
|
760
1455
|
<button class="ghost xs" data-a="replay" title="Replay presentation (R)">${icon('replay')}</button>
|
|
761
|
-
<button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}
|
|
762
|
-
<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>
|
|
763
1458
|
<button class="ghost xs" data-a="more" title="More">${icon('dots')}</button>
|
|
764
1459
|
</div>
|
|
765
1460
|
</div>
|
|
1461
|
+
<div class="pr-strip" hidden></div>
|
|
766
1462
|
<button class="link xs earlier" hidden></button>
|
|
767
1463
|
<div class="turns"></div>
|
|
768
1464
|
<div class="it-dock"></div>
|
|
@@ -773,7 +1469,7 @@ function renderBody(id, { fresh }) {
|
|
|
773
1469
|
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
774
1470
|
if (a === 'replay') replay(id);
|
|
775
1471
|
if (a === 'takeover') takeOver(id);
|
|
776
|
-
if (a === '
|
|
1472
|
+
if (a === 'ship') shipDialog(id);
|
|
777
1473
|
if (a === 'more') moreMenu(id, e.target.closest('button'));
|
|
778
1474
|
});
|
|
779
1475
|
$('.earlier', body).onclick = () => ((n.showAll = true), renderBody(id, { fresh: false }));
|
|
@@ -782,17 +1478,25 @@ function renderBody(id, { fresh }) {
|
|
|
782
1478
|
|
|
783
1479
|
const body = n.body;
|
|
784
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(' · ');
|
|
785
1487
|
$('.it-meta', body).innerHTML = `
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
${s.
|
|
789
|
-
${s.
|
|
790
|
-
|
|
791
|
-
//
|
|
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);
|
|
792
1496
|
$$('[data-a="done"]', body).forEach((b) => {
|
|
793
1497
|
b.classList.toggle('on', s.state === 'done');
|
|
794
1498
|
b.hidden = s.state === 'running' || s.state === 'waiting';
|
|
795
|
-
$('span', b).textContent = s.state === 'done' ? 'Reopen' :
|
|
1499
|
+
$('span', b).textContent = s.state === 'done' ? 'Reopen' : 'Mark done';
|
|
796
1500
|
});
|
|
797
1501
|
$('[data-a="takeover"]', body).hidden = !s.agentSessionId;
|
|
798
1502
|
|
|
@@ -811,7 +1515,8 @@ function renderBody(id, { fresh }) {
|
|
|
811
1515
|
turns.forEach((t) => {
|
|
812
1516
|
const isLatest = t.id === latestId;
|
|
813
1517
|
const folded = !isLatest && !n.unfolded.has(t.id);
|
|
814
|
-
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)}`;
|
|
815
1520
|
const prev = n.turnNodes.get(t.id);
|
|
816
1521
|
let node = prev?.node;
|
|
817
1522
|
if (!prev || prev.key !== key) {
|
|
@@ -828,12 +1533,54 @@ function renderBody(id, { fresh }) {
|
|
|
828
1533
|
for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
|
|
829
1534
|
|
|
830
1535
|
updateDock(id);
|
|
831
|
-
if (firstNew &&
|
|
1536
|
+
if (firstNew && watching(id)) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
|
832
1537
|
}
|
|
833
1538
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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;
|
|
837
1584
|
}
|
|
838
1585
|
|
|
839
1586
|
function renderFoldedTurn(t, unfold) {
|
|
@@ -843,6 +1590,7 @@ function renderFoldedTurn(t, unfold) {
|
|
|
843
1590
|
<button class="t-fold" title="Show this exchange">
|
|
844
1591
|
${icon('chevron', 'chev')}
|
|
845
1592
|
<span class="t-prompt">${esc(t.prompt.replace(/\s+/g, ' '))}</span>
|
|
1593
|
+
${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
|
|
846
1594
|
${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
|
|
847
1595
|
<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
|
|
848
1596
|
</button>
|
|
@@ -853,14 +1601,21 @@ function renderFoldedTurn(t, unfold) {
|
|
|
853
1601
|
|
|
854
1602
|
// `fold` is set for older turns the user unfolded, so they can tuck them away again.
|
|
855
1603
|
function renderTurn(s, t, isLatest, fold) {
|
|
856
|
-
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>`);
|
|
857
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 ? '*' : ''}` : '';
|
|
858
1608
|
const ask = el(`
|
|
859
1609
|
<div class="ask">
|
|
860
1610
|
<div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
|
|
861
1611
|
${long ? '<button class="link xs more-btn">Show all</button>' : ''}
|
|
862
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>` : ''}
|
|
863
|
-
<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>
|
|
864
1619
|
${fold ? `<button class="ghost xs t-unfold" title="Collapse this exchange">${icon('chevron', 'chev')}</button>` : ''}
|
|
865
1620
|
</div>`);
|
|
866
1621
|
if (fold) ask.querySelector('.t-unfold').onclick = fold;
|
|
@@ -875,6 +1630,8 @@ function renderTurn(s, t, isLatest, fold) {
|
|
|
875
1630
|
node.append(renderWorking(s, t));
|
|
876
1631
|
} else {
|
|
877
1632
|
node.append(renderPresentation(t.presentation, ctxFor(s, t, isLatest)));
|
|
1633
|
+
const subs = renderSubs(s, t);
|
|
1634
|
+
if (subs) node.append(subs);
|
|
878
1635
|
node.append(renderTrail(s, t));
|
|
879
1636
|
}
|
|
880
1637
|
return node;
|
|
@@ -892,6 +1649,7 @@ function renderWorking(s, t) {
|
|
|
892
1649
|
<span class="w-count">${t.activity.filter((a) => a.kind !== 'text').length} actions</span>
|
|
893
1650
|
</div>
|
|
894
1651
|
<div class="ask-slot"></div>
|
|
1652
|
+
${teamLiveHtml(s, t)}
|
|
895
1653
|
<ul class="ticker"></ul>
|
|
896
1654
|
<div class="w-actions">
|
|
897
1655
|
<button class="link xs peek">Show all activity</button>
|
|
@@ -962,6 +1720,8 @@ function renderTrail(s, t) {
|
|
|
962
1720
|
return { add: acc.add + n.add, del: acc.del + n.del };
|
|
963
1721
|
}, { add: 0, del: 0 });
|
|
964
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;
|
|
965
1725
|
const node = el(`
|
|
966
1726
|
<div class="trail">
|
|
967
1727
|
<div class="trail-bar">
|
|
@@ -969,6 +1729,7 @@ function renderTrail(s, t) {
|
|
|
969
1729
|
${t.endedAt ? `<span class="tb">${fmtDuration(t.endedAt - t.startedAt)}</span>` : ''}
|
|
970
1730
|
${st.costUsd ? `<span class="tb">$${st.costUsd.toFixed(2)}</span>` : ''}
|
|
971
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>` : ''}
|
|
972
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>` : ''}
|
|
973
1734
|
${acts.length ? `<button class="link xs" data-tab="activity">Activity</button>` : ''}
|
|
974
1735
|
${t.presentation?.notes ? `<button class="link xs" data-tab="notes">Agent notes</button>` : ''}
|
|
@@ -978,6 +1739,9 @@ function renderTrail(s, t) {
|
|
|
978
1739
|
const body = $('.trail-body', node);
|
|
979
1740
|
let open = null;
|
|
980
1741
|
node.addEventListener('click', (e) => {
|
|
1742
|
+
if (e.target.closest('[data-a="rollback"]')) {
|
|
1743
|
+
return rollbackTurn(s.id, t.id, rolledBack);
|
|
1744
|
+
}
|
|
981
1745
|
const b = e.target.closest('[data-tab]');
|
|
982
1746
|
if (!b) return;
|
|
983
1747
|
const tab = b.dataset.tab;
|
|
@@ -1032,6 +1796,7 @@ function renderDock(id) {
|
|
|
1032
1796
|
<div class="c-bar">
|
|
1033
1797
|
<button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
|
|
1034
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>
|
|
1035
1800
|
<button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
|
|
1036
1801
|
<button class="ghost xs c-queue" hidden>${icon('queue')}<span>Queue</span></button>
|
|
1037
1802
|
<button class="send" title="Send (Enter · Shift+Enter for newline)">${icon('enter')}</button>
|
|
@@ -1045,6 +1810,7 @@ function renderDock(id) {
|
|
|
1045
1810
|
acceptFiles(comp, ta, n.tray);
|
|
1046
1811
|
$('.c-attach', comp).onclick = n.tray.pick;
|
|
1047
1812
|
$('.c-done', comp).onclick = () => toggleDone(id);
|
|
1813
|
+
$('.c-ship', comp).onclick = () => shipDialog(id);
|
|
1048
1814
|
$('.c-queue', comp).onclick = toggleQueue;
|
|
1049
1815
|
ta.value = S.drafts.get(id) || '';
|
|
1050
1816
|
ta.addEventListener('input', () => S.drafts.set(id, ta.value));
|
|
@@ -1178,15 +1944,66 @@ async function toggleDone(id) {
|
|
|
1178
1944
|
if (!s) return;
|
|
1179
1945
|
if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
|
|
1180
1946
|
const next = s.state === 'done' ? 'review' : 'done';
|
|
1947
|
+
const leave = next === 'done' && handOff(id); // measured before the live update moves the card
|
|
1181
1948
|
await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
|
|
1182
|
-
if (
|
|
1183
|
-
|
|
1184
|
-
collapse(id);
|
|
1185
|
-
if (nextUp) setFocus(nextUp.id);
|
|
1949
|
+
if (leave) {
|
|
1950
|
+
leave(() => S.sessions.set(id, { ...S.sessions.get(id), state: 'done' }));
|
|
1186
1951
|
toast(`Marked done`, { action: { label: 'Undo', run: () => api(`/sessions/${id}`, { method: 'PATCH', body: { state: 'review' } }) } });
|
|
1187
1952
|
}
|
|
1188
1953
|
}
|
|
1189
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
|
+
|
|
1190
2007
|
async function takeOver(id) {
|
|
1191
2008
|
const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
|
|
1192
2009
|
if (!s?.agentSessionId) return;
|
|
@@ -1435,12 +2252,16 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
1435
2252
|
function moreMenu(id, anchor) {
|
|
1436
2253
|
const s = S.full.get(id);
|
|
1437
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;
|
|
1438
2258
|
const r = anchor.getBoundingClientRect();
|
|
1439
2259
|
const menu = el(`
|
|
1440
2260
|
<div class="menu" style="top:${r.bottom + 6}px;right:${innerWidth - r.right}px">
|
|
1441
2261
|
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
1442
2262
|
${s.agentSessionId ? `<button data-a="copy">${icon('copy')}Copy resume command</button>` : ''}
|
|
1443
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>` : ''}
|
|
1444
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>` : ''}
|
|
1445
2266
|
<button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
|
|
1446
2267
|
</div>`);
|
|
@@ -1457,6 +2278,7 @@ function moreMenu(id, anchor) {
|
|
|
1457
2278
|
toast('Copied resume command');
|
|
1458
2279
|
}
|
|
1459
2280
|
if (a === 'folder') openInEditor(s, '.');
|
|
2281
|
+
if (a === 'rollback') rollbackTurn(id, lastTurn?.id, rolledBack);
|
|
1460
2282
|
if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
|
|
1461
2283
|
if (a === 'delete') deleteSession(id);
|
|
1462
2284
|
};
|
|
@@ -1473,6 +2295,11 @@ function taskMenu(id, e) {
|
|
|
1473
2295
|
<div class="menu task" style="top:${e.clientY}px;left:${e.clientX}px">
|
|
1474
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>`}
|
|
1475
2297
|
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
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>
|
|
1476
2303
|
${busy || s.archived ? '' : `<button data-a="done">${icon('done')}${s.state === 'done' ? 'Reopen' : 'Mark done'}${keyHint('E')}</button>`}
|
|
1477
2304
|
${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : busy ? '' : `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>`}
|
|
1478
2305
|
<button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
|
|
@@ -1487,6 +2314,11 @@ function taskMenu(id, e) {
|
|
|
1487
2314
|
if (a === 'open') expand(id);
|
|
1488
2315
|
if (a === 'collapse') collapse(id);
|
|
1489
2316
|
if (a === 'rename') rename(id);
|
|
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);
|
|
1490
2322
|
if (a === 'done') toggleDone(id);
|
|
1491
2323
|
if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
|
|
1492
2324
|
if (a === 'delete') deleteSession(id);
|
|
@@ -1503,17 +2335,106 @@ const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
|
|
|
1503
2335
|
|
|
1504
2336
|
async function deleteSession(id) {
|
|
1505
2337
|
if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
|
|
1506
|
-
|
|
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));
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
// Launch the task again as a new session in the same folder: same prompt and attachments,
|
|
2344
|
+
// optionally edited, on whichever harness, model and thinking level you pick. The original stays.
|
|
2345
|
+
async function rerun(id) {
|
|
2346
|
+
const s = S.full.get(id) || (await api(`/sessions/${id}`).catch((err) => toast(esc(err.message), { tone: 'bad' })));
|
|
2347
|
+
const first = s?.turns?.[0];
|
|
2348
|
+
if (!first?.prompt) return toast('Nothing to rerun — this session has no prompt.', { tone: 'bad' });
|
|
2349
|
+
const has = (a) => S.agents.some((x) => x.id === a && x.available);
|
|
2350
|
+
let agent = has(s.agent) ? s.agent : S.agents.find((x) => x.available)?.id || s.agent;
|
|
2351
|
+
const models = S.agents.find((x) => x.id === agent)?.models || [];
|
|
2352
|
+
let model = agent === s.agent && models.some((m) => m.id === s.model) ? s.model : savedModel(agent);
|
|
2353
|
+
let effort = agent === s.agent && effortsFor(agent, model).includes(s.effort) ? s.effort : savedEffort(agent, model);
|
|
2354
|
+
let mode = MODES.some((m) => m.id === s.mode) ? s.mode : S.config.defaults.mode || 'edits';
|
|
2355
|
+
const files = first.attachments || [];
|
|
2356
|
+
|
|
2357
|
+
const node = el(`
|
|
2358
|
+
<div class="modal rerun-modal">
|
|
2359
|
+
<header><h2>Rerun task</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
2360
|
+
<div class="rr-box">
|
|
2361
|
+
<textarea class="rr-prompt" rows="3" spellcheck="false"></textarea>
|
|
2362
|
+
${files.length ? `<div class="rr-files">${files.map((f) => `<span class="rr-file">${icon('clip')}${esc(f.name)}</span>`).join('')}</div>` : ''}
|
|
2363
|
+
<div class="l-bar">
|
|
2364
|
+
<button class="mode-btn" data-a="mode" aria-haspopup="menu"></button>
|
|
2365
|
+
<button class="runner-btn" data-a="runner" title="Harness, model and thinking level" aria-haspopup="dialog"></button>
|
|
2366
|
+
<span class="grow"></span>
|
|
2367
|
+
<button class="primary sm" data-a="go">${icon('rerun')}Rerun</button>
|
|
2368
|
+
</div>
|
|
2369
|
+
</div>
|
|
2370
|
+
<p class="hint">Starts a new session in <code>${esc(s.cwd)}</code>. “${esc(s.title)}” is kept as it is.</p>
|
|
2371
|
+
</div>`);
|
|
2372
|
+
const close = overlay(node);
|
|
2373
|
+
$('.close', node).onclick = close;
|
|
2374
|
+
const ta = $('.rr-prompt', node);
|
|
2375
|
+
ta.value = first.prompt;
|
|
2376
|
+
autosize(ta);
|
|
2377
|
+
const modeBtn = $('[data-a="mode"]', node);
|
|
2378
|
+
const paintMode = () => {
|
|
2379
|
+
const m = MODES.find((x) => x.id === mode) || MODES[1];
|
|
2380
|
+
modeBtn.className = `mode-btn m-${m.id}`;
|
|
2381
|
+
modeBtn.innerHTML = `${icon(m.icon)}<span>${m.label}</span>`;
|
|
2382
|
+
modeBtn.title = m.hint;
|
|
2383
|
+
};
|
|
2384
|
+
const runnerBtn = $('[data-a="runner"]', node);
|
|
2385
|
+
const paintRunner = () => paintRunnerBtn(runnerBtn, agent, model, effort);
|
|
2386
|
+
paintMode();
|
|
2387
|
+
paintRunner();
|
|
2388
|
+
modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
|
|
2389
|
+
// Picks here only apply to this rerun; the launcher's remembered defaults are left alone.
|
|
2390
|
+
runnerBtn.onclick = () =>
|
|
2391
|
+
runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort }, (a, m, e) => {
|
|
2392
|
+
[agent, model, effort] = [a, m, e];
|
|
2393
|
+
paintRunner();
|
|
2394
|
+
});
|
|
2395
|
+
|
|
2396
|
+
const go = async () => {
|
|
2397
|
+
const prompt = ta.value.trim();
|
|
2398
|
+
if (!prompt) return ta.focus();
|
|
2399
|
+
const btn = $('[data-a="go"]', node);
|
|
2400
|
+
btn.disabled = true;
|
|
2401
|
+
try {
|
|
2402
|
+
const next = await api('/sessions', { method: 'POST', body: { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) } });
|
|
2403
|
+
S.sessions.set(next.id, next);
|
|
2404
|
+
close();
|
|
2405
|
+
if (S.filter !== 'all' && S.filter !== next.projectId) {
|
|
2406
|
+
S.filter = 'all';
|
|
2407
|
+
save('wisp.filter', S.filter);
|
|
2408
|
+
}
|
|
2409
|
+
renderFeed();
|
|
2410
|
+
setFocus(next.id);
|
|
2411
|
+
toast(`Rerunning on ${esc(agentName(agent))}`);
|
|
2412
|
+
} catch (err) {
|
|
2413
|
+
btn.disabled = false;
|
|
2414
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2417
|
+
$('[data-a="go"]', node).onclick = go;
|
|
2418
|
+
ta.addEventListener('keydown', (e) => {
|
|
2419
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) (e.preventDefault(), go());
|
|
2420
|
+
if (e.key === 'Tab' && e.shiftKey) {
|
|
2421
|
+
e.preventDefault();
|
|
2422
|
+
mode = MODES[(MODES.findIndex((m) => m.id === mode) + 1) % MODES.length].id;
|
|
2423
|
+
paintMode();
|
|
2424
|
+
}
|
|
2425
|
+
});
|
|
2426
|
+
requestAnimationFrame(() => (ta.focus(), ta.setSelectionRange(ta.value.length, ta.value.length)));
|
|
1507
2427
|
}
|
|
1508
2428
|
|
|
1509
2429
|
async function setArchived(id, archived) {
|
|
2430
|
+
const leave = archived && handOff(id);
|
|
1510
2431
|
try {
|
|
1511
2432
|
await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
|
|
1512
2433
|
} catch (err) {
|
|
1513
2434
|
return toast(esc(err.message), { tone: 'bad' });
|
|
1514
2435
|
}
|
|
1515
|
-
if (
|
|
1516
|
-
|
|
2436
|
+
if (leave) {
|
|
2437
|
+
leave(() => S.sessions.set(id, { ...S.sessions.get(id), archived: true }));
|
|
1517
2438
|
toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
|
|
1518
2439
|
}
|
|
1519
2440
|
}
|
|
@@ -1699,32 +2620,773 @@ async function openProjects({ focusAdd = false } = {}) {
|
|
|
1699
2620
|
if (focusAdd) setTimeout(() => input.focus(), 60);
|
|
1700
2621
|
}
|
|
1701
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
|
+
|
|
1702
3255
|
// --- settings -----------------------------------------------------------------------------
|
|
1703
3256
|
|
|
1704
|
-
|
|
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;
|
|
1705
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;
|
|
1706
3276
|
const node = el(`
|
|
1707
|
-
<div class="modal settings-modal">
|
|
1708
|
-
<
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
<
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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>
|
|
1722
3334
|
</div>`);
|
|
1723
3335
|
const close = overlay(node);
|
|
1724
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);
|
|
1725
3387
|
node.addEventListener('change', async (e) => {
|
|
1726
3388
|
const { name, value } = e.target;
|
|
1727
|
-
if (!name) return;
|
|
3389
|
+
if (!name || e.target.closest('.embedded')) return; // a nested view's own form
|
|
1728
3390
|
if (name === 'terminal') $('.custom-term', node).hidden = value !== 'custom';
|
|
1729
3391
|
await api('/config', { method: 'PATCH', body: { defaults: { [name]: value } } });
|
|
1730
3392
|
await loadConfig();
|
|
@@ -1762,6 +3424,192 @@ function openSettings() {
|
|
|
1762
3424
|
paintTele();
|
|
1763
3425
|
};
|
|
1764
3426
|
paintTele();
|
|
3427
|
+
// Memory: one line here (on/off and a summary); the lists and retros live in their own view.
|
|
3428
|
+
const learn = $('#learn', node);
|
|
3429
|
+
const paintLearn = async () => {
|
|
3430
|
+
learn.setAttribute('aria-checked', S.config.defaults.learn !== 'off');
|
|
3431
|
+
$('.learn-hint', node).textContent = memorySummary(await api('/preferences').catch(() => null));
|
|
3432
|
+
};
|
|
3433
|
+
learn.onclick = async () => {
|
|
3434
|
+
await setLearning(S.config.defaults.learn === 'off');
|
|
3435
|
+
paintLearn();
|
|
3436
|
+
};
|
|
3437
|
+
node.paintLearn = paintLearn;
|
|
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') });
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
// --- memory: what the background retros learned ---------------------------------------------
|
|
3476
|
+
|
|
3477
|
+
const MEM_KINDS = [
|
|
3478
|
+
{ kind: 'preferences', label: 'Preferences', one: 'preference', icon: 'spark', hint: 'How you like agents to work', empty: 'Corrections and “always / never” asks from your sessions land here.' },
|
|
3479
|
+
{ kind: 'lessons', label: 'Lessons', one: 'lesson', icon: 'check', hint: 'A mistake an agent made, and the way that worked', empty: 'When an agent hits a wall and finds the fix, the fix lands here.' },
|
|
3480
|
+
];
|
|
3481
|
+
const MEM_CAP = 10;
|
|
3482
|
+
const memCount = (x) => x.preferences.length + x.lessons.length;
|
|
3483
|
+
const projectName = (p) => S.config.projects.find((x) => x.path === p)?.name || String(p || '').split(/[\\/]/).pop();
|
|
3484
|
+
|
|
3485
|
+
async function setLearning(on) {
|
|
3486
|
+
await api('/config', { method: 'PATCH', body: { defaults: { learn: on ? 'on' : 'off' } } });
|
|
3487
|
+
await loadConfig();
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
function memorySummary(data) {
|
|
3491
|
+
if (!data) return '';
|
|
3492
|
+
if (!data.enabled) return 'Off · nothing learned or applied';
|
|
3493
|
+
const n = memCount(data.global) + data.projects.reduce((a, p) => a + memCount(p), 0);
|
|
3494
|
+
const last = data.history[0];
|
|
3495
|
+
if (data.running) return `Retro running · ${n} remembered`;
|
|
3496
|
+
return `${n ? `${n} remembered` : 'Nothing yet'}${last ? ` · last retro ${ago(last.at)}` : ''}`;
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
function openMemory({ tab = 'global', host } = {}) {
|
|
3500
|
+
const node = el(`<div class="modal memory-modal">
|
|
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>
|
|
3502
|
+
<p class="mem-lede">Learned from your sessions and added to every agent's prompt. What you ask always wins.</p>
|
|
3503
|
+
<div class="mem-tabs" role="tablist"></div>
|
|
3504
|
+
<div class="mem-body"></div>
|
|
3505
|
+
</div>`);
|
|
3506
|
+
const close = present(node, host);
|
|
3507
|
+
let data;
|
|
3508
|
+
let editing = null; // { scope, kind, index } (index -1 = adding)
|
|
3509
|
+
|
|
3510
|
+
const render = async () => {
|
|
3511
|
+
data = (await api('/preferences').catch(() => null)) || data;
|
|
3512
|
+
if (!data || !node.isConnected) return;
|
|
3513
|
+
// Projects with something learned, plus the one picked in the launcher so you can add to it.
|
|
3514
|
+
const current = projectById(launchProject);
|
|
3515
|
+
const projects = data.projects.filter((p) => memCount(p) || p.path === current?.path);
|
|
3516
|
+
if (current && !projects.some((p) => p.path === current.path)) projects.push({ path: current.path, name: current.name, preferences: [], lessons: [] });
|
|
3517
|
+
const scopes = [{ scope: 'global', name: 'Everywhere', ...data.global }, ...projects.map((p) => ({ scope: p.path, ...p }))];
|
|
3518
|
+
if (tab !== 'retros' && !scopes.some((x) => x.scope === tab)) tab = 'global';
|
|
3519
|
+
$('#memOn', node).setAttribute('aria-checked', data.enabled);
|
|
3520
|
+
$('.mem-live', node).innerHTML = data.running ? `<span class="orb live"></span>Retro running` : data.enabled ? '' : 'Off';
|
|
3521
|
+
$('.mem-tabs', node).innerHTML =
|
|
3522
|
+
scopes.map((x) => `<button role="tab" data-tab="${esc(x.scope)}" class="${tab === x.scope ? 'on' : ''}" title="${esc(x.scope === 'global' ? 'Applies in every project' : x.scope.replace(S.config.home, '~'))}">${esc(x.name)}<span>${memCount(x)}</span></button>`).join('') +
|
|
3523
|
+
`<i></i><button role="tab" data-tab="retros" class="${tab === 'retros' ? 'on' : ''}">Retros<span>${data.history.length}</span></button>`;
|
|
3524
|
+
const body = $('.mem-body', node);
|
|
3525
|
+
body.classList.toggle('off', !data.enabled);
|
|
3526
|
+
const banner = data.enabled ? '' : `<div class="mem-off">${icon('info')}<span>Memory is off: no retros run, and nothing below reaches your agents.</span><button class="ghost xs" data-act="on">Turn on</button></div>`;
|
|
3527
|
+
body.innerHTML = banner + (tab === 'retros' ? retrosView() : scopeView(scopes.find((x) => x.scope === tab)));
|
|
3528
|
+
const input = $('.mem-edit input', body);
|
|
3529
|
+
if (input) input.focus(), input.setSelectionRange(input.value.length, input.value.length);
|
|
3530
|
+
};
|
|
3531
|
+
|
|
3532
|
+
// Which retro added an item, if it's still in the log.
|
|
3533
|
+
const origin = (scope, kind, text) => data.history.find((h) => h.added?.some((a) => a.text === text && a.kind === kind && (a.scope === 'global') === (scope === 'global')));
|
|
3534
|
+
|
|
3535
|
+
const scopeView = (x) => {
|
|
3536
|
+
const chars = [...(x.scope === 'global' ? [] : [...data.global.preferences, ...data.global.lessons]), ...x.preferences, ...x.lessons].reduce((a, t) => a + t.length + 3, 0);
|
|
3537
|
+
const budget = x.scope === 'global' ? `Added to every prompt · about ${Math.ceil(chars / 4)} tokens` : `Added to prompts in ${esc(x.name)}, with Everywhere · about ${Math.ceil(chars / 4)} tokens`;
|
|
3538
|
+
return `<p class="mem-budget muted xs">${budget}</p>` + MEM_KINDS.map((k) => {
|
|
3539
|
+
const items = x[k.kind];
|
|
3540
|
+
const row = (t, i) => {
|
|
3541
|
+
if (editing && editing.scope === x.scope && editing.kind === k.kind && editing.index === i) return editRow(t, k);
|
|
3542
|
+
const o = origin(x.scope, k.kind, t);
|
|
3543
|
+
return `<div class="mem-item" data-i="${i}"><span class="mem-ico">${icon(k.icon)}</span><div class="mem-main"><p>${esc(t)}</p>${o ? `<span class="mem-src">${o.sessionId && S.sessions.has(o.sessionId) ? `<a href="#" data-session="${esc(o.sessionId)}">${esc(o.title || 'session')}</a>` : esc(o.title || 'a session')} · ${ago(o.at)}</span>` : ''}</div>
|
|
3544
|
+
<span class="mem-acts"><button class="icon-btn" data-act="edit" title="Edit">${icon('pen')}</button><button class="icon-btn" data-act="forget" title="Forget">${icon('trash')}</button></span></div>`;
|
|
3545
|
+
};
|
|
3546
|
+
const adding = editing && editing.scope === x.scope && editing.kind === k.kind && editing.index === -1;
|
|
3547
|
+
return `<section class="mem-sec" data-scope="${esc(x.scope)}" data-kind="${k.kind}">
|
|
3548
|
+
<h4>${k.label}<span class="muted xs">${k.hint}</span><em class="${items.length >= MEM_CAP ? 'full' : ''}" title="At most ${MEM_CAP}; a retro drops the least useful when full">${items.length}/${MEM_CAP}</em></h4>
|
|
3549
|
+
${items.map(row).join('') || (adding ? '' : `<p class="mem-empty">${k.empty}</p>`)}
|
|
3550
|
+
${adding ? editRow('', k) : `<button class="mem-add" data-act="add">${icon('plus')}Add a ${k.one}</button>`}
|
|
3551
|
+
</section>`;
|
|
3552
|
+
}).join('');
|
|
3553
|
+
};
|
|
3554
|
+
const editRow = (t, k) => `<div class="mem-item mem-edit"><span class="mem-ico">${icon(k.icon)}</span><input value="${esc(t)}" maxlength="180" spellcheck="false" placeholder="One short sentence, e.g. “Run tests with node --test”"><span class="muted xs">↵ save · esc cancel</span></div>`;
|
|
3555
|
+
|
|
3556
|
+
const retrosView = () => {
|
|
3557
|
+
const live = data.running ? `<div class="tl running"><span class="tl-dot"><span class="orb live"></span></span><div class="tl-main"><div class="tl-head"><b>${esc(data.running.title || 'session')}</b><span class="muted xs">now</span></div><p class="muted xs">Reading ${data.running.turns} turn${data.running.turns === 1 ? '' : 's'} with ${esc(agentName(data.running.agent))}…</p></div></div>` : '';
|
|
3558
|
+
const where = (a) => `<span class="tl-tag">${a.scope === 'global' ? 'everywhere' : 'project'} · ${a.kind === 'lessons' ? 'lesson' : 'preference'}</span>`;
|
|
3559
|
+
const entries = data.history.map((h) => {
|
|
3560
|
+
const status = h.status || 'learned';
|
|
3561
|
+
const lines =
|
|
3562
|
+
status === 'failed' ? `<p class="tl-err">${esc(h.error || 'The retro run failed.')}</p>`
|
|
3563
|
+
: status === 'nothing' ? '<p class="muted xs">Nothing new worth remembering.</p>'
|
|
3564
|
+
: [...h.added.map((a) => `<p class="tl-add">${esc(a.text ?? a)} ${a.kind ? where(a) : ''}</p>`), ...h.removed.map((a) => `<p class="tl-del"><s>${esc(a.text ?? a)}</s> ${a.kind ? where(a) : ''}</p>`)].join('');
|
|
3565
|
+
const title = S.sessions.has(h.sessionId) ? `<a href="#" data-session="${esc(h.sessionId)}">${esc(h.title || 'session')}</a>` : esc(h.title || 'session');
|
|
3566
|
+
return `<div class="tl ${status}"><span class="tl-dot"></span><div class="tl-main"><div class="tl-head"><b>${title}</b>${h.project ? `<span class="muted xs">${esc(projectName(h.project))}</span>` : ''}<span class="muted xs">${ago(h.at)}</span></div>${lines}</div></div>`;
|
|
3567
|
+
});
|
|
3568
|
+
return live + entries.join('') || `<p class="mem-empty">No retros yet. One runs 90 s after a session finishes, or as soon as you mark it done or archive it.</p>`;
|
|
3569
|
+
};
|
|
3570
|
+
|
|
3571
|
+
const save = async (scope, kind, items) => {
|
|
3572
|
+
await api('/preferences', { method: 'PUT', body: { scope, kind, items } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
3573
|
+
editing = null;
|
|
3574
|
+
await render();
|
|
3575
|
+
};
|
|
3576
|
+
const commit = (input) => {
|
|
3577
|
+
if (!editing) return;
|
|
3578
|
+
const { scope, kind, index } = editing;
|
|
3579
|
+
editing = null; // Enter then blur must not save twice
|
|
3580
|
+
const list = ((scope === 'global' ? data.global : data.projects.find((p) => p.path === scope)) || { [kind]: [] })[kind].slice();
|
|
3581
|
+
const v = input.value.trim();
|
|
3582
|
+
if (index === -1) v ? list.push(v) : null;
|
|
3583
|
+
else v ? (list[index] = v) : list.splice(index, 1);
|
|
3584
|
+
save(scope, kind, list);
|
|
3585
|
+
};
|
|
3586
|
+
|
|
3587
|
+
node.addEventListener('click', async (e) => {
|
|
3588
|
+
const t = e.target.closest('[data-tab],[data-act],[data-session]');
|
|
3589
|
+
if (!t) return;
|
|
3590
|
+
e.preventDefault();
|
|
3591
|
+
if (t.dataset.tab) return (tab = t.dataset.tab), (editing = null), render();
|
|
3592
|
+
if (t.dataset.session) return close(), reveal(t.dataset.session);
|
|
3593
|
+
const act = t.dataset.act;
|
|
3594
|
+
if (act === 'on') return setLearning(true).then(render);
|
|
3595
|
+
const sec = t.closest('.mem-sec');
|
|
3596
|
+
const { scope, kind } = sec.dataset;
|
|
3597
|
+
const index = Number(t.closest('.mem-item')?.dataset.i ?? -1);
|
|
3598
|
+
if (act === 'add' || act === 'edit') return (editing = { scope, kind, index }), render();
|
|
3599
|
+
if (act === 'forget') {
|
|
3600
|
+
const list = (scope === 'global' ? data.global : data.projects.find((p) => p.path === scope))[kind].filter((_, i) => i !== index);
|
|
3601
|
+
return save(scope, kind, list);
|
|
3602
|
+
}
|
|
3603
|
+
});
|
|
3604
|
+
node.addEventListener('keydown', (e) => {
|
|
3605
|
+
if (!e.target.matches('.mem-edit input')) return;
|
|
3606
|
+
if (e.key === 'Enter') e.preventDefault(), commit(e.target);
|
|
3607
|
+
if (e.key === 'Escape') e.preventDefault(), e.stopPropagation(), (editing = null), render();
|
|
3608
|
+
});
|
|
3609
|
+
node.addEventListener('focusout', (e) => e.target.matches('.mem-edit input') && editing && commit(e.target));
|
|
3610
|
+
$('#memOn', node).onclick = async () => (await setLearning(!data?.enabled), render());
|
|
3611
|
+
node.refresh = () => !editing && render();
|
|
3612
|
+
render();
|
|
1765
3613
|
}
|
|
1766
3614
|
|
|
1767
3615
|
// Say once that anonymous stats are on, with a way out.
|
|
@@ -1769,7 +3617,7 @@ function telemetryNotice() {
|
|
|
1769
3617
|
if (!S.config.telemetry?.enabled || S.config.defaults.telemetryNotice === 'seen') return;
|
|
1770
3618
|
S.config.defaults.telemetryNotice = 'seen';
|
|
1771
3619
|
api('/config', { method: 'PATCH', body: { defaults: { telemetryNotice: 'seen' } } }).catch(() => {});
|
|
1772
|
-
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' }) } });
|
|
1773
3621
|
}
|
|
1774
3622
|
|
|
1775
3623
|
function shortcuts() {
|
|
@@ -1785,10 +3633,13 @@ function shortcuts() {
|
|
|
1785
3633
|
['⌫', 'Delete item'],
|
|
1786
3634
|
['T', 'Take over in terminal'],
|
|
1787
3635
|
['R', 'Replay presentation'],
|
|
3636
|
+
['⇧R', 'Rerun with another harness, model or thinking level'],
|
|
3637
|
+
['S', 'Ship it: commit, push and open a PR (worktree tasks)'],
|
|
1788
3638
|
['1–9', 'Pick an answer when asked'],
|
|
1789
3639
|
['↵', 'Launch / send'],
|
|
1790
3640
|
['⇧↵', 'New line'],
|
|
1791
3641
|
['C', 'Collapse all'],
|
|
3642
|
+
['U', 'Back to top'],
|
|
1792
3643
|
['Esc', 'Close / collapse'],
|
|
1793
3644
|
];
|
|
1794
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>`);
|
|
@@ -1808,7 +3659,8 @@ const modKey = (e, k) =>
|
|
|
1808
3659
|
const STATE_LABEL = { running: 'Working', waiting: 'Asking', review: 'Review', done: 'Done' };
|
|
1809
3660
|
|
|
1810
3661
|
function focusLauncher() {
|
|
1811
|
-
|
|
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' });
|
|
1812
3664
|
$('#prompt')?.focus({ preventScroll: true });
|
|
1813
3665
|
}
|
|
1814
3666
|
|
|
@@ -1824,13 +3676,23 @@ function paletteItems() {
|
|
|
1824
3676
|
const cur = S.focus && S.sessions.get(S.focus);
|
|
1825
3677
|
if (cur) {
|
|
1826
3678
|
const id = cur.id;
|
|
3679
|
+
const full = S.full.get(id);
|
|
3680
|
+
const lastTurn = full?.turns?.at(-1);
|
|
1827
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;
|
|
1828
3684
|
const g = 'This wisp';
|
|
1829
3685
|
const sub = cur.title;
|
|
1830
3686
|
items.push({ group: g, label: 'Follow up', sub, icon: 'send', kbd: '/', run: () => withCard(id, () => $('.reply', S.nodes.get(id)?.card)?.focus(), { open: true }) });
|
|
1831
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) });
|
|
1832
3689
|
if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
|
|
1833
3690
|
items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
|
|
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) });
|
|
1834
3696
|
items.push({ group: g, label: 'Replay presentation', sub, icon: 'replay', kbd: 'R', run: () => withCard(id, () => replay(id), { open: true }) });
|
|
1835
3697
|
items.push({ group: g, label: 'Rename', sub, icon: 'pen', kbd: 'M', run: () => withCard(id, () => rename(id)) });
|
|
1836
3698
|
items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
|
|
@@ -1861,15 +3723,23 @@ function paletteItems() {
|
|
|
1861
3723
|
items.push(
|
|
1862
3724
|
{ group: a, label: 'Add project…', icon: 'folder', keywords: 'folder repo directory', run: () => openProjects({ focusAdd: true }) },
|
|
1863
3725
|
{ group: a, label: 'Manage projects', icon: 'folder', keywords: 'rename color remove', run: () => openProjects() },
|
|
1864
|
-
{ group: a, label: '
|
|
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() },
|
|
3731
|
+
{ group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
|
|
1865
3732
|
{ group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
|
|
1866
3733
|
);
|
|
1867
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
|
+
|
|
1868
3737
|
const filter = (id) => () => {
|
|
1869
3738
|
S.filter = id;
|
|
1870
3739
|
save('wisp.filter', id);
|
|
1871
3740
|
renderProjects();
|
|
1872
3741
|
renderFeed();
|
|
3742
|
+
feedToTop();
|
|
1873
3743
|
renderLaunchChips();
|
|
1874
3744
|
};
|
|
1875
3745
|
items.push({ group: 'Projects', label: 'All projects', sub: S.filter === 'all' ? 'Current filter' : '', dot: 'slate', keywords: 'filter clear', run: filter('all') });
|
|
@@ -1900,8 +3770,7 @@ function askNotify() {
|
|
|
1900
3770
|
}
|
|
1901
3771
|
|
|
1902
3772
|
function notifyFinished(s) {
|
|
1903
|
-
const
|
|
1904
|
-
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();
|
|
1905
3774
|
if (viewing) return;
|
|
1906
3775
|
const verb = s.state === 'waiting' ? 'has a question' : 'is ready';
|
|
1907
3776
|
toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
|
|
@@ -1917,14 +3786,24 @@ function connect() {
|
|
|
1917
3786
|
const es = new EventSource('/api/events');
|
|
1918
3787
|
es.addEventListener('summary', (e) => {
|
|
1919
3788
|
const s = JSON.parse(e.data);
|
|
3789
|
+
if (s.parentId) return onChildSummary(s); // a subagent: lives inside its lead's card
|
|
1920
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
|
|
1921
3792
|
S.sessions.set(s.id, s);
|
|
1922
|
-
// A
|
|
1923
|
-
//
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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
|
+
});
|
|
1927
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);
|
|
1928
3807
|
});
|
|
1929
3808
|
es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
|
|
1930
3809
|
es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
|
|
@@ -1935,10 +3814,16 @@ function connect() {
|
|
|
1935
3814
|
S.full.delete(id);
|
|
1936
3815
|
S.open.delete(id);
|
|
1937
3816
|
saveOpen();
|
|
1938
|
-
|
|
3817
|
+
keepInPlace(feedAnchor(id), renderFeed);
|
|
3818
|
+
});
|
|
3819
|
+
es.addEventListener('config', () => (loadConfig(), $('.auto-modal')?.refresh()));
|
|
3820
|
+
es.addEventListener('remote', () => $('.phone-modal')?.refresh());
|
|
3821
|
+
es.addEventListener('update', (e) => renderUpdate(JSON.parse(e.data)));
|
|
3822
|
+
es.addEventListener('preferences', () => {
|
|
3823
|
+
$('.memory-modal')?.refresh();
|
|
3824
|
+
$('.settings-modal')?.paintLearn?.();
|
|
1939
3825
|
});
|
|
1940
|
-
es.addEventListener('
|
|
1941
|
-
es.addEventListener('hello', () => loadSessions());
|
|
3826
|
+
es.addEventListener('hello', () => (loadSessions(), loadUpdate())); // also after sleep/reconnect
|
|
1942
3827
|
}
|
|
1943
3828
|
|
|
1944
3829
|
async function loadSessions() {
|
|
@@ -1946,7 +3831,7 @@ async function loadSessions() {
|
|
|
1946
3831
|
S.sessions = new Map(list.map((s) => [s.id, s]));
|
|
1947
3832
|
for (const id of S.open) if (!S.sessions.has(id)) S.open.delete(id);
|
|
1948
3833
|
saveOpen();
|
|
1949
|
-
|
|
3834
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
1950
3835
|
for (const id of S.open) scheduleRefetch(id);
|
|
1951
3836
|
}
|
|
1952
3837
|
|
|
@@ -1954,11 +3839,75 @@ async function loadConfig() {
|
|
|
1954
3839
|
S.config = await api('/config');
|
|
1955
3840
|
renderProjects();
|
|
1956
3841
|
renderLaunchChips();
|
|
3842
|
+
paintAutoBtn();
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
// --- updates ----------------------------------------------------------------------------
|
|
3846
|
+
|
|
3847
|
+
// The server polls npm; when a newer wisp-agents is out, a pill appears next to Search.
|
|
3848
|
+
let update = null;
|
|
3849
|
+
const loadUpdate = () => api('/update').then(renderUpdate).catch(() => {});
|
|
3850
|
+
function renderUpdate(u) {
|
|
3851
|
+
update = u;
|
|
3852
|
+
const b = $('#updateBtn');
|
|
3853
|
+
const busy = u.status === 'installing' || u.status === 'restarting';
|
|
3854
|
+
b.hidden = !u.available && !busy;
|
|
3855
|
+
b.disabled = busy;
|
|
3856
|
+
b.classList.toggle('busy', busy);
|
|
3857
|
+
b.querySelector('span').textContent = u.status === 'installing' ? 'Updating…' : u.status === 'restarting' ? 'Restarting…' : `Update to ${u.latest}`;
|
|
3858
|
+
b.title = u.auto ? `wisp ${u.current} → ${u.latest}: install and restart` : `wisp ${u.latest} is out. Run \`${u.command}\` to update`;
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
async function installUpdate() {
|
|
3862
|
+
if (!update?.available) return;
|
|
3863
|
+
if (!update.auto) return toast(`wisp <b>${esc(update.latest)}</b> is out. Run <code>${esc(update.command)}</code>`, { action: { label: 'Copy', run: () => copy(update.command) }, timeout: 8000 });
|
|
3864
|
+
const post = (force) => api('/update', { method: 'POST', body: { force } });
|
|
3865
|
+
try {
|
|
3866
|
+
await post(false).catch(async (err) => {
|
|
3867
|
+
const running = [...S.sessions.values()].filter((s) => s.state === 'running' || s.state === 'waiting').length;
|
|
3868
|
+
if (err.message !== 'agents are running') throw err;
|
|
3869
|
+
if (!confirm(`Update to wisp ${update.latest} and restart? ${running || 'Some'} running agent${running === 1 ? '' : 's'} will be stopped.`)) throw null;
|
|
3870
|
+
return post(true);
|
|
3871
|
+
});
|
|
3872
|
+
} catch (err) {
|
|
3873
|
+
if (err) toast(esc(err.message), { tone: 'bad', timeout: 10_000, action: { label: 'Copy command', run: () => copy(update.command) } });
|
|
3874
|
+
return;
|
|
3875
|
+
}
|
|
3876
|
+
renderUpdate({ ...update, status: 'restarting' });
|
|
3877
|
+
// The server exits and a new one takes the port: reload once it answers with the new version.
|
|
3878
|
+
const from = update.current;
|
|
3879
|
+
const t0 = Date.now();
|
|
3880
|
+
const poll = setInterval(async () => {
|
|
3881
|
+
const h = await fetch('/api/health').then((r) => r.json()).catch(() => null);
|
|
3882
|
+
if (h?.version && h.version !== from) return clearInterval(poll), location.reload();
|
|
3883
|
+
if (Date.now() - t0 > 120_000) clearInterval(poll), toast('wisp did not come back. Start it again from your terminal.', { tone: 'bad', timeout: 15_000 });
|
|
3884
|
+
}, 1000);
|
|
1957
3885
|
}
|
|
1958
3886
|
|
|
1959
3887
|
// --- keyboard ---------------------------------------------------------------------------
|
|
1960
3888
|
|
|
1961
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
|
+
}
|
|
1962
3911
|
|
|
1963
3912
|
document.addEventListener('keydown', (e) => {
|
|
1964
3913
|
// ⌘K / Ctrl+K works from anywhere, even mid-sentence in the launcher.
|
|
@@ -1974,9 +3923,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
1974
3923
|
}
|
|
1975
3924
|
const typing = e.target.closest('input, textarea, select, [contenteditable="true"]');
|
|
1976
3925
|
if (e.key === 'Escape') {
|
|
1977
|
-
if (!$('#overlay').hidden) return closeOverlay();
|
|
3926
|
+
if (!$('#overlay').hidden) return $('#overlay .sx-back') ? $('#overlay .sx-back').click() : closeOverlay();
|
|
1978
3927
|
if (typing) return e.target.blur();
|
|
1979
|
-
if (S.focus && S.open.has(S.focus))
|
|
3928
|
+
if (S.focus && S.open.has(S.focus)) {
|
|
3929
|
+
collapse(S.focus);
|
|
3930
|
+
S.nodes.get(S.focus)?.card.scrollIntoView({ block: 'nearest' });
|
|
3931
|
+
}
|
|
1980
3932
|
return;
|
|
1981
3933
|
}
|
|
1982
3934
|
if (typing || e.metaKey || e.ctrlKey || e.altKey || !$('#overlay').hidden) return;
|
|
@@ -2000,6 +3952,10 @@ document.addEventListener('keydown', (e) => {
|
|
|
2000
3952
|
if (s && (s.archived || (s.state !== 'running' && s.state !== 'waiting'))) setArchived(id, !s.archived);
|
|
2001
3953
|
}
|
|
2002
3954
|
else if (k === 'c' && S.open.size) collapseAll();
|
|
3955
|
+
else if (k === 'u' || k === 'home') {
|
|
3956
|
+
e.preventDefault();
|
|
3957
|
+
backToTop();
|
|
3958
|
+
}
|
|
2003
3959
|
else if (k === 'm' && id) {
|
|
2004
3960
|
e.preventDefault();
|
|
2005
3961
|
rename(id);
|
|
@@ -2007,6 +3963,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
2007
3963
|
e.preventDefault();
|
|
2008
3964
|
deleteSession(id);
|
|
2009
3965
|
} else if (k === 't' && id) takeOver(id);
|
|
3966
|
+
else if (k === 'r' && e.shiftKey && id) rerun(id);
|
|
3967
|
+
else if (k === 's' && id && S.sessions.get(id)?.branch) shipDialog(id);
|
|
2010
3968
|
else if (k === 'r' && id) replay(id);
|
|
2011
3969
|
else if (k === '/' && id) {
|
|
2012
3970
|
e.preventDefault();
|
|
@@ -2023,6 +3981,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
2023
3981
|
|
|
2024
3982
|
$('#settingsBtn').onclick = openSettings;
|
|
2025
3983
|
$('#searchBtn').onclick = openPalette;
|
|
3984
|
+
$('#autoBtn').onclick = () => openAutomations({ tab: (S.config.triggers || []).length && !(S.config.recipes || []).length ? 'triggers' : 'recipes' });
|
|
3985
|
+
$('#updateBtn').onclick = installUpdate;
|
|
2026
3986
|
$('#searchBtn').title = `Search (${combo('K')})`;
|
|
2027
3987
|
$('#searchBtn .mod-k').textContent = combo('K');
|
|
2028
3988
|
$('#secDone .toggle-done').onclick = () => {
|
|
@@ -2049,11 +4009,23 @@ setInterval(() => {
|
|
|
2049
4009
|
|
|
2050
4010
|
// Sticky top bar gets a hairline once the page scrolls; "back to top" shows after a screen's worth.
|
|
2051
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
|
+
};
|
|
2052
4022
|
stage.addEventListener('scroll', () => {
|
|
2053
4023
|
$('#topbar').classList.toggle('scrolled', stage.scrollTop > 4);
|
|
4024
|
+
dockLauncher();
|
|
2054
4025
|
toTop.hidden = stage.scrollTop < stage.clientHeight * 0.75;
|
|
2055
4026
|
}, { passive: true });
|
|
2056
|
-
|
|
4027
|
+
const backToTop = () => stage.scrollTo({ top: 0, behavior: 'smooth' });
|
|
4028
|
+
toTop.onclick = backToTop;
|
|
2057
4029
|
|
|
2058
4030
|
if (/Electron/.test(navigator.userAgent)) document.body.classList.add('electron', /Mac/.test(navigator.platform) ? 'mac' : 'other');
|
|
2059
4031
|
|
|
@@ -2069,6 +4041,7 @@ function route() {
|
|
|
2069
4041
|
|
|
2070
4042
|
window.addEventListener('hashchange', route);
|
|
2071
4043
|
window.addEventListener('focus', () => {
|
|
4044
|
+
loadUpdate();
|
|
2072
4045
|
for (const id of S.open) if (S.sessions.get(id)?.unread) scheduleRefetch(id);
|
|
2073
4046
|
});
|
|
2074
4047
|
|