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/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
- o.onclick = (e) => e.target === o && close();
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 = visible([...S.sessions.values()]);
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 shown = new Set([...needs, ...working, ...done, ...archived].map((s) => s.id));
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
- const collapseAll = () => [...S.open].forEach((id) => collapse(id));
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
- ${modelBadge(s.agent, s.model, s.effort)}
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
- if (scroll) n?.card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
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
- <button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
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
- // The model is remembered per harness (ids aren't portable between them).
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
- const m = (agent?.models || []).find((x) => x.id === curModel);
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', { method: 'POST', body: { prompt, cwd: project.path, agent: agent?.id || 'claude', mode: curMode, model: curModel, effort: curEffort, attachments } });
535
- S.sessions.set(s.id, s);
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
- launchedFx(btn, S.nodes.get(s.id)?.card);
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')}<span>Take over</span></button>
762
- <button class="ghost xs" data-a="done" title="Mark done (E)">${icon('done')}<span>Done</span></button>
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 === 'done') toggleDone(id);
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
- <span class="m">${esc(agentName(s.agent))}</span>
787
- <span class="m">${esc(MODES.find((m) => m.id === s.mode)?.label || s.mode)}</span>
788
- ${s.model ? `<span class="m">${esc(s.model)}</span>` : ''}
789
- ${s.effort ? `<span class="m">${esc(effortLabel(s.effort))} thinking</span>` : ''}
790
- <span class="m path" title="${esc(s.cwd)}">${esc((proj?.path || s.cwd).replace(S.config.home || '', '~'))}</span>`;
791
- // Done lives both in the top bar and next to the composer, so it's reachable after reading.
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' : b.closest('.c-bar') ? 'Mark done' : 'Done';
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 key = `${t.status}|${isLatest}|${folded}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}`;
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 && isInView(n.card)) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
1536
+ if (firstNew && watching(id)) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
832
1537
  }
833
1538
 
834
- function isInView(node) {
835
- const r = node.getBoundingClientRect();
836
- return r.bottom > 0 && r.top < innerHeight;
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">${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time></div>
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 (next === 'done') {
1183
- const nextUp = groups().needs.find((x) => x.id !== id);
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
- await api(`/sessions/${id}`, { method: 'DELETE' }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
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 (archived) {
1516
- if (S.open.has(id)) collapse(id);
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 &lt;name&gt; [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
- function openSettings() {
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
- <header><h2>Settings</h2><button class="icon-btn close">${icon('x')}</button></header>
1709
- <label><span>Open files in</span>
1710
- <select name="editor">${[['vscode', 'VS Code'], ['cursor', 'Cursor'], ['windsurf', 'Windsurf'], ['zed', 'Zed'], ['idea', 'JetBrains'], ['none', 'Copy path']]
1711
- .map(([v, l]) => `<option value="${v}" ${d.editor === v || (!d.editor && v === 'vscode') ? 'selected' : ''}>${l}</option>`)
1712
- .join('')}</select></label>
1713
- <label><span>Take over in</span>
1714
- <select name="terminal">${(S.config.terminals || []).map((t) => `<option value="${t.id}" ${(d.terminal || 'auto') === t.id ? 'selected' : ''}>${t.label}</option>`).join('')}</select></label>
1715
- <label class="custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><span>Terminal command</span>
1716
- <input name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></label>
1717
- <div class="setting"><span>Desktop notifications</span><span class="notif-hint muted xs"></span><button class="switch" id="notif" role="switch"></button></div>
1718
- <div class="setting"><span>Anonymous usage stats</span><span class="tele-hint muted xs"></span><button class="switch" id="tele" role="switch"></button></div>
1719
- <div class="agents">${S.agents
1720
- .map((a) => `<div class="agent-row"><span class="dot ${a.available ? 'ok' : 'bad'}"></span><b>${esc(a.name)}</b><span class="muted">${esc(a.available ? a.version : a.error || 'not found')}</span></div>`)
1721
- .join('')}<p class="muted xs">Pick the agent next to the model in the launcher. Others plug in via <code>server/adapters</code>.</p></div>
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
- stage.scrollTo({ top: 0, behavior: 'smooth' });
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: 'Settings', icon: 'settings', keywords: 'preferences editor terminal notifications', run: openSettings },
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 n = S.nodes.get(s.id);
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 card left open while it worked shouldn't land in "Needs you" expanded —
1923
- // fold it unless the user is actually watching it.
1924
- const n = S.nodes.get(s.id);
1925
- if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(n && isInView(n.card) && document.hasFocus())) collapse(s.id);
1926
- renderFeed();
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
- renderFeed();
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('config', () => loadConfig());
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
- renderFeed();
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)) collapse(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
- toTop.onclick = () => stage.scrollTo({ top: 0, behavior: 'smooth' });
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