wisp-agents 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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>`);
@@ -262,6 +319,7 @@ function renderHead(n, s) {
262
319
  : s.state === 'review'
263
320
  ? `<span class="pill st-${s.status}"><i></i>${STATUS_LABEL[s.status] || 'Done'}</span>`
264
321
  : '';
322
+ if (runsOf(s).length > 1) return renderRunHead(n, s, { open, live, pill });
265
323
  n.card.className = `item c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
266
324
  n.head.innerHTML = `
267
325
  <span class="q-dot ${s.state === 'running' ? 'live' : ''} ${s.state === 'waiting' ? 'waiting' : ''}"></span>
@@ -272,7 +330,8 @@ function renderHead(n, s) {
272
330
  <span class="it-side">
273
331
  ${pill}
274
332
  ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
275
- ${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)}
276
335
  <span class="q-proj">${esc(s.project)}</span>
277
336
  <span class="q-time">${ago(s.updatedAt)}</span>
278
337
  ${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
@@ -280,13 +339,74 @@ function renderHead(n, s) {
280
339
  </span>`;
281
340
  }
282
341
 
342
+ // One run inside a compare task: the harness and model stand in for the title the task shows.
343
+ function renderRunHead(n, s, { open, live, pill }) {
344
+ const a = S.agents.find((x) => x.id === s.agent);
345
+ const model = (s.model && a?.models?.find((m) => m.id === s.model)?.label) || s.model || 'Default model';
346
+ n.card.className = `item run c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
347
+ n.head.innerHTML = `
348
+ <span class="run-logo">${harnessLogo(s.agent)}</span>
349
+ <span class="it-main">
350
+ <span class="it-title"><b>${esc(a?.name || agentName(s.agent))}</b><span class="run-model">${esc(model)}${s.effort ? ` · ${esc(effortLabel(s.effort))}` : ''}</span></span>
351
+ ${live ? `<span class="q-live ${s.state}">${esc(s.live)}</span>` : open ? '' : `<span class="it-sub">${esc(s.preview || '')}</span>`}
352
+ </span>
353
+ <span class="it-side">
354
+ ${live && s.state === 'running' ? `<span class="pill st-running"><i></i>Working</span>` : pill}
355
+ ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
356
+ ${s.pr ? prPill(s.pr) : ''}
357
+ <span class="q-time">${ago(s.updatedAt)}</span>
358
+ ${icon('chevron', 'chev')}
359
+ </span>`;
360
+ }
361
+
362
+ // The card for a compare task: the shared prompt on top, one row per run (each an ordinary,
363
+ // expandable item), and the side-by-side view one click away.
364
+ function taskItem(t) {
365
+ let g = S.tasks.get(t.key);
366
+ if (!g) {
367
+ const card = el(`<section class="task-item"><header class="task-head"></header><div class="task-runs"></div></section>`);
368
+ g = { card, head: $('.task-head', card), runs: $('.task-runs', card) };
369
+ S.tasks.set(t.key, g);
370
+ g.head.addEventListener('click', (e) => {
371
+ const a = e.target.closest('[data-a]')?.dataset.a;
372
+ const ids = (taskOf(S.sessions.get(g.lead) || {}).runs || []).map((r) => r.id);
373
+ if (a === 'compare') return compareView(g.lead);
374
+ if (a === 'quick-done') return taskDone(ids.filter((id) => S.sessions.get(id)?.state === 'review'));
375
+ });
376
+ }
377
+ g.lead = t.id;
378
+ const finished = t.runs.filter((r) => r.state === 'review' || r.state === 'done').length;
379
+ const status = t.state === 'waiting' ? 'A run is asking you' : t.state === 'running' ? `${finished} of ${t.runs.length} finished` : t.state === 'done' ? 'Done' : 'All finished · pick one to keep';
380
+ g.card.className = `task-item c-${t.color} st-${t.state} ${t.unread ? 'unread' : ''}`;
381
+ g.head.innerHTML = `
382
+ <span class="q-dot ${t.state === 'running' ? 'live' : ''} ${t.state === 'waiting' ? 'waiting' : ''}"></span>
383
+ <span class="it-main">
384
+ <span class="it-title">${esc(t.title)}</span>
385
+ <span class="it-sub">${esc(status)}</span>
386
+ </span>
387
+ <span class="it-side">
388
+ <button class="ghost xs" data-a="compare" title="Side by side: time, cost, changes, tests and each presentation"><b class="vs-mark">vs</b><span>Compare</span></button>
389
+ <span class="q-proj">${esc(t.project)}</span>
390
+ <span class="q-time">${ago(t.updatedAt)}</span>
391
+ ${t.state === 'review' && !t.archived ? `<button class="it-quick" data-a="quick-done" title="Mark every run done">${icon('done')}</button>` : ''}
392
+ </span>`;
393
+ t.runs.forEach((r, i) => {
394
+ const { card } = item(r);
395
+ if (g.runs.children[i] !== card) g.runs.insertBefore(card, g.runs.children[i] || null);
396
+ });
397
+ while (g.runs.children.length > t.runs.length) g.runs.lastElementChild.remove();
398
+ return g;
399
+ }
400
+
283
401
  function setFocus(id, { scroll = true } = {}) {
284
402
  if (S.focus === id) return;
285
403
  S.nodes.get(S.focus)?.card.classList.remove('focused');
286
404
  S.focus = id;
287
405
  const n = S.nodes.get(id);
288
406
  n?.card.classList.add('focused');
289
- 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' });
290
410
  }
291
411
 
292
412
  function toggle(id) {
@@ -482,10 +602,17 @@ function renderLauncher() {
482
602
  <div class="l-bar">
483
603
  <button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
484
604
  <button class="mode-btn" id="mode" aria-haspopup="menu"></button>
485
- <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>
486
611
  <span class="grow"></span>
612
+ <button class="tog-btn" id="wtBtn"></button>
487
613
  <button class="send" id="launch" title="Launch (Enter · Shift+Enter for newline)">${icon('enter')}</button>
488
- </div>`;
614
+ </div>
615
+ <div class="l-recipes"></div>`;
489
616
  renderLaunchChips();
490
617
 
491
618
  const ta = $('#prompt', box);
@@ -494,7 +621,7 @@ function renderLauncher() {
494
621
  acceptFiles(box, ta, tray);
495
622
  autosize(ta);
496
623
  ta.value = S.drafts.get('home') || '';
497
- ta.addEventListener('input', () => S.drafts.set('home', ta.value));
624
+ ta.addEventListener('input', () => (S.drafts.set('home', ta.value), paintSaveRecipe()));
498
625
  // While focus is elsewhere, the placeholder advertises the shortcut that brings you back.
499
626
  const hint = () => (ta.placeholder = document.activeElement === ta
500
627
  ? 'What should I go do? Paste screenshots, drop logs or videos…'
@@ -502,6 +629,12 @@ function renderLauncher() {
502
629
  ta.addEventListener('focus', hint);
503
630
  ta.addEventListener('blur', hint);
504
631
  hint();
632
+ // Docked and folded to one line, a click anywhere on it opens it up for typing.
633
+ box.addEventListener('mousedown', (e) => {
634
+ if (!box.matches('.docked:not(:focus-within)')) return;
635
+ e.preventDefault();
636
+ ta.focus();
637
+ });
505
638
  $('#attach', box).onclick = tray.pick;
506
639
 
507
640
  // Mode is set-and-forget, so it's one small button: just an icon on the default (Edits),
@@ -522,21 +655,82 @@ function renderLauncher() {
522
655
  };
523
656
  paintMode();
524
657
  modeBtn.onclick = () => modeMenu(modeBtn, curMode, setMode);
658
+ box.mode = () => curMode;
525
659
  let curModel = savedModel(agent?.id);
526
660
  let curEffort = savedEffort(agent?.id, curModel);
527
661
  const runnerBtn = $('#runner', box);
528
662
  const paintRunner = () => {
529
- const m = (agent?.models || []).find((x) => x.id === curModel);
530
- runnerBtn.innerHTML = `<span class="rb-logo">${harnessLogo(agent?.id)}</span><span class="rb-h">${esc(agent?.name || 'No agent')}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${curEffort ? `<span class="rb-e">${esc(effortLabel(curEffort))}</span>` : ''}${icon('chevron', 'chev')}`;
663
+ paintRunnerBtn(runnerBtn, agent?.id, curModel, curEffort);
531
664
  runnerBtn.title = `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
532
665
  };
533
666
  paintRunner();
667
+
668
+ // Worktree: each launch gets a fresh branch from the remote's default branch (git repos only).
669
+ const wtBtn = $('#wtBtn', box);
670
+ const paintWt = () => {
671
+ const on = S.config.defaults.worktree !== 'off';
672
+ wtBtn.classList.toggle('on', on);
673
+ wtBtn.innerHTML = `${icon('branch')}<span>${on ? 'Worktree' : 'In place'}</span>`;
674
+ wtBtn.title = on
675
+ ? 'Worktree: runs on its own branch from origin/main in a separate checkout, so parallel agents never collide (git repos; not in Plan mode). Click to work in the project folder instead.'
676
+ : 'In place: edits the project folder directly. Click to give each task its own worktree and branch.';
677
+ };
678
+ paintWt();
679
+ wtBtn.onclick = () => {
680
+ const worktree = S.config.defaults.worktree === 'off' ? 'on' : 'off';
681
+ S.config.defaults.worktree = worktree;
682
+ paintWt();
683
+ api('/config', { method: 'PATCH', body: { defaults: { worktree } } }).catch(() => {});
684
+ };
685
+
686
+ // Subagents: remembered like the other picks, so a team stays set until you clear it.
687
+ let team = (S.config.defaults.subagents || []).filter((m) => S.agents.some((a) => a.id === m.agent));
688
+ let teamPreset = S.config.defaults.teamPreset || '';
689
+ const teamBtn = $('#teamBtn', box);
690
+ const paintTeam = () => paintTeamBtn(teamBtn, team, teamPreset, agent?.id);
691
+ paintTeam();
692
+ teamBtn.onclick = () =>
693
+ teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset }, (members, preset) => {
694
+ if (JSON.stringify([members, preset]) === JSON.stringify([team, teamPreset])) return;
695
+ team = members;
696
+ teamPreset = preset;
697
+ Object.assign(S.config.defaults, { subagents: team, teamPreset });
698
+ paintTeam();
699
+ api('/config', { method: 'PATCH', body: { defaults: { subagents: team, teamPreset } } }).catch(() => {});
700
+ });
701
+
702
+ // Compare: a second harness gets the same task; picks here don't touch the launcher defaults.
703
+ let compare = null;
704
+ const vsBtn = $('#compareBtn', box);
705
+ const runner2 = $('#runner2', box);
706
+ const paintCompare = () => {
707
+ vsBtn.parentElement.classList.toggle('cmp', !!compare);
708
+ vsBtn.setAttribute('aria-pressed', String(!!compare));
709
+ vsBtn.title = compare ? 'Stop comparing' : 'Compare: run the same task on a second harness, side by side';
710
+ runner2.hidden = !compare;
711
+ if (compare) paintRunnerBtn(runner2, compare.agent, compare.model, compare.effort);
712
+ };
713
+ paintCompare();
714
+ vsBtn.onclick = () => {
715
+ if (compare) compare = null;
716
+ else {
717
+ const other = S.agents.find((a) => a.available && a.id !== agent?.id) || agent;
718
+ compare = { agent: other?.id, model: savedModel(other?.id), effort: savedEffort(other?.id, savedModel(other?.id)) };
719
+ }
720
+ paintCompare();
721
+ };
722
+ runner2.onclick = () =>
723
+ runnerPicker(runner2, { ...compare, savedModel, savedEffort }, (id, model, effort) => {
724
+ compare = { agent: id, model, effort };
725
+ paintCompare();
726
+ });
534
727
  runnerBtn.onclick = () =>
535
728
  runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
536
729
  agent = S.agents.find((a) => a.id === id);
537
730
  curModel = model;
538
731
  curEffort = effort;
539
732
  paintRunner();
733
+ paintTeam();
540
734
  const d = S.config.defaults;
541
735
  const models = { ...d.models, [id]: model };
542
736
  // A model without levels leaves the harness's saved level alone, so switching back restores it.
@@ -552,10 +746,15 @@ function renderLauncher() {
552
746
  if (!project) return openProjects({ focusAdd: true });
553
747
  const btn = $('#launch', box);
554
748
  btn.disabled = true;
749
+ btn.classList.add('busy'); // a fetch and fresh worktrees can take a second or two
555
750
  try {
556
751
  const attachments = await tray.ids();
557
- const s = await api('/sessions', { method: 'POST', body: { prompt, cwd: project.path, agent: agent?.id || 'claude', mode: curMode, model: curModel, effort: curEffort, attachments } });
558
- 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();
559
758
  ta.value = '';
560
759
  S.drafts.delete('home');
561
760
  tray.clear();
@@ -565,12 +764,14 @@ function renderLauncher() {
565
764
  save('wisp.filter', S.filter);
566
765
  }
567
766
  renderFeed();
568
- 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);
569
769
  askNotify();
570
770
  } catch (err) {
571
771
  toast(esc(err.message), { tone: 'bad' });
572
772
  } finally {
573
773
  btn.disabled = false;
774
+ btn.classList.remove('busy');
574
775
  ta.focus();
575
776
  }
576
777
  };
@@ -587,6 +788,403 @@ function renderLauncher() {
587
788
  });
588
789
  }
589
790
 
791
+ // The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
792
+ function paintRunnerBtn(btn, agentId, model, effort) {
793
+ const a = S.agents.find((x) => x.id === agentId);
794
+ const m = (a?.models || []).find((x) => x.id === model);
795
+ btn.innerHTML = agentId
796
+ ? `<span class="rb-logo">${harnessLogo(agentId)}</span><span class="rb-h">${esc(a?.name || agentName(agentId))}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${effort ? `<span class="rb-e">${esc(effortLabel(effort))}</span>` : ''}${icon('chevron', 'chev')}`
797
+ : `<span class="rb-logo">${icon('spark')}</span><span class="rb-h">Launcher default</span>${icon('chevron', 'chev')}`;
798
+ }
799
+
800
+ // --- subagents -------------------------------------------------------------------
801
+ // A team the lead can delegate to: any harness, model and thinking level per member, plus the
802
+ // user's "use it for" note. Picked in the launcher, saved as presets, shown on cards and turns.
803
+
804
+ const MAX_TEAM = 6; // rows
805
+ const MAX_COPIES = 5; // parallel copies of one row
806
+ const MAX_INSTANCES = 12; // copies across the team
807
+ const copiesOf = (m) => Math.max(1, m.count || 1);
808
+ const teamSize = (team) => team.reduce((n, m) => n + copiesOf(m), 0);
809
+ const canLead = (id) => S.agents.find((a) => a.id === id)?.delegates !== false;
810
+ const memberLabel = (m) => {
811
+ const a = S.agents.find((x) => x.id === m.agent);
812
+ const model = (m.model && a?.models?.find((x) => x.id === m.model)?.label) || m.model || 'Default model';
813
+ return `${a?.name || agentName(m.agent)} · ${model}${m.effort ? ` · ${effortLabel(m.effort)}` : ''}`;
814
+ };
815
+ const slugName = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
816
+ // Same rule as the server: blank names become the harness id, repeats get -2, -3…
817
+ function uniqueNames(members) {
818
+ const taken = new Set();
819
+ return members.map((m) => {
820
+ const base = slugName(m.name) || m.agent;
821
+ let name = base;
822
+ for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
823
+ taken.add(name);
824
+ return { ...m, name };
825
+ });
826
+ }
827
+
828
+ // The lead's logo with its subagents' tucked behind it; they fan out on hover. A member that
829
+ // is working pulses; one the lead never used is dimmed.
830
+ function teamStack(lead, team) {
831
+ const shown = team.slice(0, 4);
832
+ const tip = `${agentName(lead)} with ${team.map((m) => `${m.name}${m.busy ? ' (working)' : m.used === false ? ' (unused)' : ''}`).join(', ')}`;
833
+ return `<span class="team-stack" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">
834
+ <span class="ts-l">${harnessLogo(lead)}</span>
835
+ ${shown.map((m, i) => `<span class="ts-m${m.used === false ? ' idle' : ''}${m.busy ? ' busy' : ''}" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}
836
+ ${team.length > shown.length ? `<span class="ts-m ts-more" style="--i:${shown.length}">+${team.length - shown.length}</span>` : ''}
837
+ </span>`;
838
+ }
839
+
840
+ // Launcher button: a stack of the members' logos and the preset's name, or just "Subagents".
841
+ function paintTeamBtn(btn, team, presetId, lead) {
842
+ const ok = canLead(lead);
843
+ const flat = team.flatMap((m) => Array(copiesOf(m)).fill(m));
844
+ const n = flat.length;
845
+ const p = (S.config.teams || []).find((x) => x.id === presetId);
846
+ btn.classList.toggle('on', team.length > 0 && ok);
847
+ btn.classList.toggle('off', team.length > 0 && !ok);
848
+ btn.innerHTML = team.length
849
+ ? `<span class="tb-stack">${flat.slice(0, 5).map((m, i) => `<span class="ts-m" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}${n > 5 ? `<span class="ts-m ts-more" style="--i:5">+${n - 5}</span>` : ''}</span><span class="tb-label">${esc(p?.name || `${n} subagent${n > 1 ? 's' : ''}`)}</span>`
850
+ : `${icon('agent')}<span class="tb-label">Subagents</span>`;
851
+ btn.title = !team.length
852
+ ? 'Subagents: give the lead a team on other harnesses to delegate to'
853
+ : ok
854
+ ? `Subagents the lead can delegate to:\n${team.map((m) => `• ${m.name}${copiesOf(m) > 1 ? ` ×${copiesOf(m)}` : ''}: ${memberLabel(m)}${m.use ? ` — ${m.use}` : ''}`).join('\n')}`
855
+ : `${agentName(lead)} can't delegate (wisp can't give it tools), so this launch runs without subagents`;
856
+ }
857
+
858
+ function teamEditor(anchor, { lead, team, presetId }, onChange) {
859
+ const open = $('.team-pop');
860
+ if (open) return open.close();
861
+ let members = team.map((m) => ({ ...m }));
862
+ let preset = presetId;
863
+ const r = anchor.getBoundingClientRect();
864
+ const width = Math.min(540, innerWidth - 24);
865
+ const left = Math.max(12, Math.min(r.left, innerWidth - 12 - width));
866
+ const below = r.bottom + 440 < innerHeight;
867
+ const node = el(`
868
+ <div class="team-pop" role="dialog" aria-label="Subagents" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
869
+ <header class="tp-head">
870
+ <div class="tp-title"><b>Subagents</b><small>The lead hands tasks to these, each on its own harness. Tell it what each one is for.</small></div>
871
+ <button class="ghost xs tp-presets" aria-haspopup="menu">${icon('agent')}<span>Presets</span>${icon('chevron', 'chev')}</button>
872
+ </header>
873
+ <div class="tp-warn" hidden></div>
874
+ <div class="tp-list"></div>
875
+ <footer class="tp-foot"></footer>
876
+ </div>`);
877
+ const list = $('.tp-list', node);
878
+ const foot = $('.tp-foot', node);
879
+ const commit = () => onChange(uniqueNames(members), preset);
880
+ let typing;
881
+ const commitSoon = () => (clearTimeout(typing), (typing = setTimeout(commit, 300)));
882
+
883
+ const paintWarn = () => {
884
+ const w = $('.tp-warn', node);
885
+ w.hidden = canLead(lead);
886
+ w.innerHTML = `${icon('warn')}<span>${esc(agentName(lead))} can't delegate: wisp can't give it tools. Pick Claude Code, Codex or opencode as the lead to use these.</span>`;
887
+ };
888
+ const paintFoot = () => {
889
+ foot.innerHTML = `
890
+ <button class="ghost sm tp-add" ${members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES ? 'disabled' : ''}>${icon('plus')}Add subagent</button>
891
+ <span class="tp-count" title="Agents in all, counting copies (max ${MAX_INSTANCES})">${teamSize(members)} agent${teamSize(members) === 1 ? '' : 's'}</span>
892
+ <span class="grow"></span>
893
+ ${members.length ? `<button class="link xs tp-clear">Clear</button><button class="ghost sm tp-save">${icon('check')}Save as preset</button>` : ''}`;
894
+ };
895
+ const paintList = () => {
896
+ const named = uniqueNames(members);
897
+ list.innerHTML = members.length
898
+ ? members
899
+ .map(
900
+ (m, i) => `<div class="tp-row" data-i="${i}">
901
+ <div class="tp-top">
902
+ <button class="runner-btn tp-runner" title="Harness, model and thinking level" aria-haspopup="dialog"></button>
903
+ <span class="tp-copies${copiesOf(m) > 1 ? ' on' : ''}" title="Copies: identical agents that take tasks in parallel">
904
+ <button class="tp-dec" aria-label="One copy fewer" ${copiesOf(m) <= 1 ? 'disabled' : ''}>${icon('minus')}</button>
905
+ <b>×${copiesOf(m)}</b>
906
+ <button class="tp-inc" aria-label="One copy more" ${copiesOf(m) >= MAX_COPIES || teamSize(members) >= MAX_INSTANCES ? 'disabled' : ''}>${icon('plus')}</button>
907
+ </span>
908
+ <label class="tp-name" title="What the lead calls it"><span>@</span><input value="${esc(m.name || '')}" placeholder="${esc(named[i].name)}" maxlength="24" spellcheck="false" autocomplete="off"></label>
909
+ <button class="icon-btn tp-del" title="Remove">${icon('x')}</button>
910
+ </div>
911
+ <label class="tp-use"><span>Use for</span><input value="${esc(m.use || '')}" placeholder="e.g. image and UI work · research · writing tests" maxlength="300" spellcheck="false" autocomplete="off"></label>
912
+ </div>`,
913
+ )
914
+ .join('')
915
+ : `<div class="tp-empty">${icon('agent')}<p>No subagents yet. Add one per harness you want the lead to use, like Codex for tests or opencode for research.</p></div>`;
916
+ $$('.tp-row', list).forEach((row, i) => paintRunnerBtn($('.tp-runner', row), members[i].agent, members[i].model, members[i].effort));
917
+ paintFoot();
918
+ };
919
+ const add = () => {
920
+ if (members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES) return;
921
+ const used = new Set([lead, ...members.map((m) => m.agent)]);
922
+ const a = S.agents.find((x) => x.available && !used.has(x.id)) || S.agents.find((x) => x.available && x.id !== lead) || S.agents.find((x) => x.available);
923
+ if (!a) return toast('No harness is installed.', { tone: 'bad' });
924
+ const model = savedModel(a.id);
925
+ members.push({ name: '', agent: a.id, model, effort: savedEffort(a.id, model), use: '', count: 1 });
926
+ paintList();
927
+ commit();
928
+ $(`.tp-row[data-i="${members.length - 1}"] .tp-use input`, list)?.focus();
929
+ };
930
+
931
+ // Save: name it; an existing preset's name updates that preset.
932
+ const askName = () => {
933
+ const cur = (S.config.teams || []).find((x) => x.id === preset);
934
+ foot.innerHTML = `<form class="tp-saveform"><input placeholder="Preset name, e.g. Review crew" value="${esc(cur?.name || '')}" maxlength="60"><button class="primary sm">Save</button><button type="button" class="link xs tp-cancel">Cancel</button></form>`;
935
+ const input = $('input', foot);
936
+ input.focus();
937
+ input.select();
938
+ $('.tp-cancel', foot).onclick = paintFoot;
939
+ $('form', foot).onsubmit = async (e) => {
940
+ e.preventDefault();
941
+ const name = input.value.trim();
942
+ if (!name) return input.focus();
943
+ const same = (S.config.teams || []).find((x) => x.name.toLowerCase() === name.toLowerCase());
944
+ try {
945
+ const p = await api(same ? `/teams/${same.id}` : '/teams', { method: same ? 'PATCH' : 'POST', body: { name, members: uniqueNames(members) } });
946
+ S.config.teams = [...(S.config.teams || []).filter((x) => x.id !== p.id), p];
947
+ preset = p.id;
948
+ commit();
949
+ paintFoot();
950
+ toast(`${same ? 'Updated' : 'Saved'} preset <b>${esc(p.name)}</b>`);
951
+ } catch (err) {
952
+ toast(esc(err.message), { tone: 'bad' });
953
+ }
954
+ };
955
+ };
956
+
957
+ const presetMenu = (btn) => {
958
+ const prev = $('.menu.tp-menu');
959
+ if (prev) return prev.close();
960
+ const teams = S.config.teams || [];
961
+ const b = btn.getBoundingClientRect();
962
+ const menu = el(`
963
+ <div class="menu tp-menu" role="menu" style="top:${b.bottom + 4}px;right:${innerWidth - b.right}px">
964
+ ${teams.length
965
+ ? teams
966
+ .map(
967
+ (t) => `<button role="menuitem" data-id="${esc(t.id)}" class="${t.id === preset ? 'on' : ''}">
968
+ <span class="tb-stack">${t.members.slice(0, 4).map((m, i) => `<span class="ts-m" style="--i:${i}">${harnessLogo(m.agent)}</span>`).join('')}</span>
969
+ <span class="mm-text"><b>${esc(t.name)}</b><small>${esc(t.members.map((m) => m.name).join(', '))}</small></span>
970
+ <span class="tp-mdel" data-del="${esc(t.id)}" title="Delete preset">${icon('trash')}</span>
971
+ </button>`,
972
+ )
973
+ .join('')
974
+ : `<div class="tp-mempty">No presets yet. Set up a team, then <b>Save as preset</b>.</div>`}
975
+ </div>`);
976
+ const close = () => (menu.remove(), document.removeEventListener('mousedown', outsideMenu, true));
977
+ const outsideMenu = (ev) => !menu.contains(ev.target) && !btn.contains(ev.target) && close();
978
+ menu.close = close;
979
+ setTimeout(() => document.addEventListener('mousedown', outsideMenu, true));
980
+ menu.onclick = async (ev) => {
981
+ const del = ev.target.closest('[data-del]');
982
+ if (del) {
983
+ ev.stopPropagation();
984
+ const t = teams.find((x) => x.id === del.dataset.del);
985
+ await api(`/teams/${t.id}`, { method: 'DELETE' }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
986
+ S.config.teams = (S.config.teams || []).filter((x) => x.id !== t.id);
987
+ if (preset === t.id) (preset = ''), commit();
988
+ close();
989
+ return toast(`Deleted preset <b>${esc(t.name)}</b>`);
990
+ }
991
+ const item = ev.target.closest('button[data-id]');
992
+ if (!item) return;
993
+ const t = teams.find((x) => x.id === item.dataset.id);
994
+ members = t.members.map((m) => ({ ...m }));
995
+ preset = t.id;
996
+ close();
997
+ paintList();
998
+ commit();
999
+ };
1000
+ document.body.append(menu);
1001
+ };
1002
+
1003
+ list.addEventListener('click', (e) => {
1004
+ const row = e.target.closest('.tp-row');
1005
+ if (!row) return;
1006
+ const i = Number(row.dataset.i);
1007
+ if (e.target.closest('.tp-del')) {
1008
+ members.splice(i, 1);
1009
+ paintList();
1010
+ return commit();
1011
+ }
1012
+ const step = e.target.closest('.tp-inc') ? 1 : e.target.closest('.tp-dec') ? -1 : 0;
1013
+ if (step) {
1014
+ const m = members[i];
1015
+ const next = copiesOf(m) + step;
1016
+ if (next < 1 || next > MAX_COPIES || (step > 0 && teamSize(members) >= MAX_INSTANCES)) return;
1017
+ m.count = next;
1018
+ paintList();
1019
+ $(`.tp-row[data-i="${i}"] ${step > 0 ? '.tp-inc' : '.tp-dec'}`, list)?.focus();
1020
+ return commit();
1021
+ }
1022
+ const rb = e.target.closest('.tp-runner');
1023
+ if (rb) {
1024
+ const m = members[i];
1025
+ runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort }, (agent, model, effort) => {
1026
+ Object.assign(m, { agent, model, effort });
1027
+ paintList();
1028
+ commit();
1029
+ });
1030
+ }
1031
+ });
1032
+ list.addEventListener('input', (e) => {
1033
+ const row = e.target.closest('.tp-row');
1034
+ if (!row) return;
1035
+ const m = members[Number(row.dataset.i)];
1036
+ if (e.target.closest('.tp-name')) m.name = e.target.value;
1037
+ else m.use = e.target.value;
1038
+ commitSoon();
1039
+ });
1040
+ foot.addEventListener('click', (e) => {
1041
+ if (e.target.closest('.tp-add')) add();
1042
+ if (e.target.closest('.tp-save')) askName();
1043
+ if (e.target.closest('.tp-clear')) {
1044
+ members = [];
1045
+ preset = '';
1046
+ paintList();
1047
+ commit();
1048
+ }
1049
+ });
1050
+ $('.tp-presets', node).onclick = (e) => presetMenu(e.currentTarget);
1051
+
1052
+ const close = () => {
1053
+ clearTimeout(typing);
1054
+ commit();
1055
+ node.remove();
1056
+ $('.menu.tp-menu')?.close();
1057
+ anchor.classList.remove('open');
1058
+ document.removeEventListener('mousedown', outside);
1059
+ };
1060
+ node.close = close;
1061
+ // The harness picker and preset menu float outside the popover; clicks there keep it open.
1062
+ const outside = (e) => !node.contains(e.target) && !anchor.contains(e.target) && !e.target.closest('.picker, .tp-menu') && close();
1063
+ node.addEventListener('keydown', (e) => e.key === 'Escape' && (e.stopPropagation(), close(), anchor.focus()));
1064
+ setTimeout(() => document.addEventListener('mousedown', outside));
1065
+ paintWarn();
1066
+ paintList();
1067
+ document.body.append(node);
1068
+ anchor.classList.add('open');
1069
+ if (!members.length) $('.tp-add', node)?.focus();
1070
+ }
1071
+
1072
+ // One delegation with the child session and turn it ran as (from the lead's childSessions).
1073
+ function delegationsOf(s, t) {
1074
+ return (t.delegations || []).map((d) => {
1075
+ const child = s.childSessions?.[d.sessionId];
1076
+ return { d, child, ct: child?.turns.find((x) => x.id === d.turnId) };
1077
+ });
1078
+ }
1079
+ const subKey = (s, t) => delegationsOf(s, t).map(({ ct }) => ct?.status || '?').join(',');
1080
+ const subStatus = (ct) => {
1081
+ if (!ct || ct.status === 'running') return { cls: 'waiting', label: 'Working' };
1082
+ if (ct.status === 'stopped') return { cls: 'partial', label: 'Stopped' };
1083
+ if (ct.status === 'error') return { cls: 'blocked', label: 'Failed' };
1084
+ const st = ct.presentation?.blocks?.find((b) => b.type === 'hero')?.status || 'done';
1085
+ return { cls: st, label: STATUS_LABEL[st] || 'Done' };
1086
+ };
1087
+
1088
+ // While the lead works: one live line per subagent it's waiting on.
1089
+ function teamLiveHtml(s, t) {
1090
+ const list = delegationsOf(s, t);
1091
+ if (!list.length) return '';
1092
+ return `<div class="team-live">${list
1093
+ .map(({ d, ct }) => {
1094
+ const running = !ct || ct.status === 'running';
1095
+ const hero = ct?.presentation?.blocks?.find((b) => b.type === 'hero');
1096
+ const last = ct?.activity?.findLast((a) => a.kind !== 'text')?.label;
1097
+ const st = subStatus(ct);
1098
+ return `<div class="tl-row ${running ? 'live' : `st-${st.cls}`}" data-sub="${esc(d.sessionId)}">
1099
+ <span class="tl-logo">${harnessLogo(d.agent)}</span>
1100
+ <b class="tl-name">@${esc(d.name)}</b>
1101
+ <span class="tl-live">${esc(running ? last || 'Starting up' : hero?.title || st.label)}</span>
1102
+ ${running ? `<span class="elapsed" data-start="${ct?.startedAt || d.startedAt}">${fmtDuration(Date.now() - (ct?.startedAt || d.startedAt))}</span>` : `<span class="pill st-${st.cls}"><i></i>${st.label}</span>`}
1103
+ </div>`;
1104
+ })
1105
+ .join('')}</div>`;
1106
+ }
1107
+
1108
+ // After the run: the team's reports, one card each, opening into the subagent's own presentation.
1109
+ const subOpen = new Set(); // delegation ids the user unfolded
1110
+ function renderSubs(s, t) {
1111
+ const list = delegationsOf(s, t);
1112
+ if (!list.length) return null;
1113
+ const names = [...new Set(list.map(({ d }) => `@${d.name}`))];
1114
+ const node = el(`
1115
+ <section class="subs">
1116
+ <div class="subs-head">
1117
+ ${teamStack(s.agent, list.map(({ d, ct }) => ({ name: d.name, agent: d.agent, busy: ct?.status === 'running' })))}
1118
+ <b>${list.length} delegated task${list.length > 1 ? 's' : ''}</b>
1119
+ <span class="subs-to">${esc(names.join(', '))}</span>
1120
+ </div>
1121
+ <div class="subs-list"></div>
1122
+ </section>`);
1123
+ const box = $('.subs-list', node);
1124
+ for (const { d, child, ct } of list) {
1125
+ const st = subStatus(ct);
1126
+ const hero = ct?.presentation?.blocks?.find((b) => b.type === 'hero');
1127
+ const files = ct?.changes?.length || 0;
1128
+ const card = el(`
1129
+ <article class="sub st-${st.cls} ${subOpen.has(d.id) ? 'open' : ''}">
1130
+ <button class="sub-head" aria-expanded="${subOpen.has(d.id)}">
1131
+ <span class="sub-logo">${harnessLogo(d.agent)}</span>
1132
+ <span class="sub-main">
1133
+ <span class="sub-top"><b>@${esc(d.name)}</b><span class="sub-model">${esc(memberLabel(d))}</span></span>
1134
+ <span class="sub-title">${esc(hero?.title || (ct?.status === 'running' ? ct.activity?.findLast((a) => a.kind !== 'text')?.label || 'Working' : d.prompt))}</span>
1135
+ </span>
1136
+ <span class="sub-side">
1137
+ ${files ? `<span class="sub-files">${files} file${files > 1 ? 's' : ''}</span>` : ''}
1138
+ ${ct?.endedAt ? `<span class="sub-dur">${fmtDuration(ct.endedAt - ct.startedAt)}</span>` : ''}
1139
+ <span class="pill st-${st.cls}"><i></i>${st.label}</span>
1140
+ ${icon('chevron', 'chev')}
1141
+ </span>
1142
+ </button>
1143
+ <div class="sub-body" ${subOpen.has(d.id) ? '' : 'hidden'}></div>
1144
+ </article>`);
1145
+ const body = $('.sub-body', card);
1146
+ const fill = () => {
1147
+ body.innerHTML = `<div class="sub-task"><span>Task from ${esc(agentName(s.agent))}</span><p>${esc(d.prompt)}</p></div>`;
1148
+ if (ct?.presentation && child) {
1149
+ // Its "next" and question blocks draft into the lead's composer: the lead stays in charge.
1150
+ const ctx = { ...ctxFor(child, ct, false), reply: (text) => draftInto(s.id, `Ask @${d.name} to: ${text}`), draft: (text) => draftInto(s.id, `Ask @${d.name} to: ${text}`) };
1151
+ body.append(renderPresentation(ct.presentation, ctx));
1152
+ } else if (ct?.status === 'running') body.append(el(`<p class="muted sub-wait">Still working…</p>`));
1153
+ };
1154
+ if (subOpen.has(d.id)) fill();
1155
+ $('.sub-head', card).onclick = () => {
1156
+ const opening = body.hidden;
1157
+ opening ? subOpen.add(d.id) : subOpen.delete(d.id);
1158
+ card.classList.toggle('open', opening);
1159
+ $('.sub-head', card).setAttribute('aria-expanded', String(opening));
1160
+ if (opening) fill();
1161
+ body.hidden = !opening;
1162
+ };
1163
+ box.append(card);
1164
+ }
1165
+ return node;
1166
+ }
1167
+
1168
+ function draftInto(id, text) {
1169
+ const ta = $('.reply', S.nodes.get(id)?.card);
1170
+ if (!ta) return;
1171
+ ta.value = text;
1172
+ ta.dispatchEvent(new Event('input'));
1173
+ ta.focus();
1174
+ ta.setSelectionRange(text.length, text.length);
1175
+ }
1176
+
1177
+ // A subagent's summary: its lead's card shows it, so update the live line or re-fetch the lead.
1178
+ const childState = new Map();
1179
+ function onChildSummary(c) {
1180
+ const row = $(`.tl-row[data-sub="${CSS.escape(c.id)}"] .tl-live`);
1181
+ if (row && c.state === 'running' && c.live) row.textContent = c.live;
1182
+ if (childState.get(c.id) !== c.state) {
1183
+ childState.set(c.id, c.state);
1184
+ scheduleRefetch(c.parentId);
1185
+ }
1186
+ }
1187
+
590
1188
  function modeMenu(anchor, current, pick) {
591
1189
  const open = $('.menu.mode');
592
1190
  if (open) return open.close();
@@ -640,9 +1238,68 @@ function renderLaunchChips() {
640
1238
  launchProject = b.dataset.id;
641
1239
  save('wisp.launchProject', launchProject);
642
1240
  $$('.lp-chip', box).forEach((x) => x.classList.toggle('on', x === b));
1241
+ renderLaunchRecipes();
643
1242
  }),
644
1243
  );
645
1244
  $('#lpAdd', box).onclick = () => openProjects({ focusAdd: true });
1245
+ renderLaunchRecipes();
1246
+ }
1247
+
1248
+ // Recipes for the picked project (and the ones tied to none), one click from the launcher.
1249
+ // With text in the box, "Save as recipe" turns it into one.
1250
+ function renderLaunchRecipes() {
1251
+ const row = $('#launcher .l-recipes');
1252
+ if (!row) return;
1253
+ const path = projectById(launchProject)?.path;
1254
+ const all = S.config.recipes || [];
1255
+ const list = all.filter((r) => !r.cwd || r.cwd === path).sort((a, b) => (b.lastRunAt || 0) - (a.lastRunAt || 0));
1256
+ const hidden = all.length - list.length;
1257
+ row.innerHTML = `
1258
+ <span class="l-in">${icon('spark')}Recipes</span>
1259
+ ${list.map((r) => `<button class="lp-chip rc" data-recipe="${esc(r.id)}" title="${esc(r.prompt.replace(/\s+/g, ' ').slice(0, 200))}">${esc(r.name)}${recipeParams(r.prompt).length ? '<i class="rc-f">…</i>' : ''}</button>`).join('')}
1260
+ ${list.length ? '' : `<span class="muted xs lr-empty">${hidden ? `None for this project · ${hidden} elsewhere` : 'Prompts you send again and again, one click away'}</span>`}
1261
+ <button class="lp-chip add" id="lrSave" hidden>${icon('plus')}Save as recipe</button>
1262
+ <button class="lp-chip add" id="lrNew">${icon('plus')}New</button>
1263
+ <span class="grow"></span>
1264
+ <button class="link xs" id="lrAll">Manage</button>`;
1265
+ row.onclick = (e) => {
1266
+ const b = e.target.closest('button');
1267
+ if (!b) return;
1268
+ if (b.dataset.recipe) {
1269
+ const r = all.find((x) => x.id === b.dataset.recipe);
1270
+ return r && runRecipe({ ...r, params: recipeParams(r.prompt) });
1271
+ }
1272
+ const draft = { name: '', prompt: '', cwd: path || '', mode: $('#launcher').mode?.() };
1273
+ if (b.id === 'lrSave') openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft: { ...draft, prompt: $('#prompt').value.trim() } } });
1274
+ if (b.id === 'lrNew') openAutomations({ tab: 'recipes', edit: { type: 'recipe', draft } });
1275
+ if (b.id === 'lrAll') openAutomations({ tab: 'recipes' });
1276
+ };
1277
+ paintSaveRecipe();
1278
+ }
1279
+ function paintSaveRecipe() {
1280
+ const has = !!$('#prompt')?.value.trim();
1281
+ const save = $('#lrSave');
1282
+ if (!save) return;
1283
+ save.hidden = !has;
1284
+ $('#lrNew').hidden = has;
1285
+ }
1286
+
1287
+ // Top bar: always one click to recipes and triggers. The badge counts triggers that are on;
1288
+ // the tooltip says what fires next.
1289
+ async function paintAutoBtn() {
1290
+ const btn = $('#autoBtn');
1291
+ const on = (S.config.triggers || []).filter((t) => t.enabled);
1292
+ const n = $('.ab-n', btn);
1293
+ n.hidden = !on.length;
1294
+ n.textContent = on.length;
1295
+ btn.classList.toggle('live', !!on.length);
1296
+ const r = S.config.recipes?.length || 0;
1297
+ let title = `Automations: ${r} recipe${r === 1 ? '' : 's'} · ${on.length} trigger${on.length === 1 ? '' : 's'} on`;
1298
+ if (on.length) {
1299
+ const next = (await api('/triggers').catch(() => [])).filter((t) => t.enabled && t.nextAt).sort((a, b) => a.nextAt - b.nextAt)[0];
1300
+ if (next) title += `\nNext: ${next.name} · ${whenLabel(next.nextAt)}`;
1301
+ }
1302
+ btn.title = title;
646
1303
  }
647
1304
 
648
1305
  // A little wisp flies from the launch button to the new item in the feed.
@@ -668,6 +1325,18 @@ function launchedFx(from, to) {
668
1325
  ).onfinish = () => w.remove();
669
1326
  }
670
1327
 
1328
+ // A new card lands in Working, below everything in "Needs you", so it's often off-screen. Scroll
1329
+ // it into view (just enough) and flash it; the flying wisp only plays when the card is already
1330
+ // on screen, since the launcher it starts from may scroll away or fold into the dock.
1331
+ function showLaunched(btn, card) {
1332
+ if (!card) return;
1333
+ const r = card.getBoundingClientRect();
1334
+ if (r.top >= topInset() && r.bottom <= innerHeight) return launchedFx(btn, card);
1335
+ card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1336
+ card.classList.add('arrived');
1337
+ setTimeout(() => card.classList.remove('arrived'), 1600);
1338
+ }
1339
+
671
1340
  // --- project filter (top bar) ---------------------------------------------------
672
1341
 
673
1342
  function renderProjects() {
@@ -692,6 +1361,7 @@ function renderProjects() {
692
1361
  S.filter = S.filter === b.dataset.id ? 'all' : b.dataset.id;
693
1362
  save('wisp.filter', S.filter);
694
1363
  renderFeed();
1364
+ feedToTop();
695
1365
  renderLaunchChips();
696
1366
  }),
697
1367
  );
@@ -761,6 +1431,8 @@ function ctxFor(s, turn, isLatest) {
761
1431
  openFile: (p, line) => openInEditor(s, p, line),
762
1432
  fileUrl: (p) => `/api/sessions/${s.id}/file?path=${encodeURIComponent(p)}`,
763
1433
  zoom: (src) => overlay(el(`<img class="lightbox" src="${esc(src)}">`)),
1434
+ revertFile: (file, restore) => revertFile(s.id, turn.id, file, restore),
1435
+ exec: (command, key) => execAction(s.id, turn.id, command, key),
764
1436
  };
765
1437
  }
766
1438
 
@@ -781,11 +1453,12 @@ function renderBody(id, { fresh }) {
781
1453
  <div class="it-meta"></div>
782
1454
  <div class="it-actions">
783
1455
  <button class="ghost xs" data-a="replay" title="Replay presentation (R)">${icon('replay')}</button>
784
- <button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}<span>Take over</span></button>
785
- <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>
786
1458
  <button class="ghost xs" data-a="more" title="More">${icon('dots')}</button>
787
1459
  </div>
788
1460
  </div>
1461
+ <div class="pr-strip" hidden></div>
789
1462
  <button class="link xs earlier" hidden></button>
790
1463
  <div class="turns"></div>
791
1464
  <div class="it-dock"></div>
@@ -796,7 +1469,7 @@ function renderBody(id, { fresh }) {
796
1469
  const a = e.target.closest('[data-a]')?.dataset.a;
797
1470
  if (a === 'replay') replay(id);
798
1471
  if (a === 'takeover') takeOver(id);
799
- if (a === 'done') toggleDone(id);
1472
+ if (a === 'ship') shipDialog(id);
800
1473
  if (a === 'more') moreMenu(id, e.target.closest('button'));
801
1474
  });
802
1475
  $('.earlier', body).onclick = () => ((n.showAll = true), renderBody(id, { fresh: false }));
@@ -805,17 +1478,25 @@ function renderBody(id, { fresh }) {
805
1478
 
806
1479
  const body = n.body;
807
1480
  const proj = projectById(summary.projectId);
1481
+ // The head already shows the harness, model and project, the task head has Compare, and Done
1482
+ // sits by the composer. This bar only says how the run was set up: a mode worth noticing
1483
+ // (Plan or Full auto, as on the launcher), the branch, and what launched it.
1484
+ const mode = MODES.find((m) => m.id === s.mode);
1485
+ const where = (proj?.path || s.cwd).replace(S.config.home || '', '~');
1486
+ $('.it-meta', body).title = [agentName(s.agent), s.model, s.effort && `${effortLabel(s.effort)} thinking`, mode?.label, where].filter(Boolean).join(' · ');
808
1487
  $('.it-meta', body).innerHTML = `
809
- <span class="m">${esc(agentName(s.agent))}</span>
810
- <span class="m">${esc(MODES.find((m) => m.id === s.mode)?.label || s.mode)}</span>
811
- ${s.model ? `<span class="m">${esc(s.model)}</span>` : ''}
812
- ${s.effort ? `<span class="m">${esc(effortLabel(s.effort))} thinking</span>` : ''}
813
- <span class="m path" title="${esc(s.cwd)}">${esc((proj?.path || s.cwd).replace(S.config.home || '', '~'))}</span>`;
814
- // 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);
815
1496
  $$('[data-a="done"]', body).forEach((b) => {
816
1497
  b.classList.toggle('on', s.state === 'done');
817
1498
  b.hidden = s.state === 'running' || s.state === 'waiting';
818
- $('span', b).textContent = s.state === 'done' ? 'Reopen' : b.closest('.c-bar') ? 'Mark done' : 'Done';
1499
+ $('span', b).textContent = s.state === 'done' ? 'Reopen' : 'Mark done';
819
1500
  });
820
1501
  $('[data-a="takeover"]', body).hidden = !s.agentSessionId;
821
1502
 
@@ -834,7 +1515,8 @@ function renderBody(id, { fresh }) {
834
1515
  turns.forEach((t) => {
835
1516
  const isLatest = t.id === latestId;
836
1517
  const folded = !isLatest && !n.unfolded.has(t.id);
837
- const 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)}`;
838
1520
  const prev = n.turnNodes.get(t.id);
839
1521
  let node = prev?.node;
840
1522
  if (!prev || prev.key !== key) {
@@ -851,12 +1533,54 @@ function renderBody(id, { fresh }) {
851
1533
  for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
852
1534
 
853
1535
  updateDock(id);
854
- if (firstNew && isInView(n.card)) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
1536
+ if (firstNew && watching(id)) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
855
1537
  }
856
1538
 
857
- function isInView(node) {
858
- const r = node.getBoundingClientRect();
859
- 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;
860
1584
  }
861
1585
 
862
1586
  function renderFoldedTurn(t, unfold) {
@@ -866,6 +1590,7 @@ function renderFoldedTurn(t, unfold) {
866
1590
  <button class="t-fold" title="Show this exchange">
867
1591
  ${icon('chevron', 'chev')}
868
1592
  <span class="t-prompt">${esc(t.prompt.replace(/\s+/g, ' '))}</span>
1593
+ ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
869
1594
  ${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
870
1595
  <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
871
1596
  </button>
@@ -876,14 +1601,21 @@ function renderFoldedTurn(t, unfold) {
876
1601
 
877
1602
  // `fold` is set for older turns the user unfolded, so they can tuck them away again.
878
1603
  function renderTurn(s, t, isLatest, fold) {
879
- const node = el(`<article class="turn" data-turn="${t.id}"></article>`);
1604
+ const node = el(`<article class="turn ${t.rolledBackAt ? 'is-rolled-back' : ''}" data-turn="${t.id}"></article>`);
880
1605
  const long = t.prompt.length > 360 || t.prompt.split('\n').length > 5;
1606
+ const cp = t.checkpoint;
1607
+ const cpLabel = cp?.head ? `${cp.branch ? `${cp.branch}@` : ''}${cp.head.slice(0, 7)}${cp.dirty ? '*' : ''}` : '';
881
1608
  const ask = el(`
882
1609
  <div class="ask">
883
1610
  <div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
884
1611
  ${long ? '<button class="link xs more-btn">Show all</button>' : ''}
885
1612
  ${t.attachments?.length ? `<div class="ask-atts">${t.attachments.map((a) => (a.mime?.startsWith('image/') ? `<img src="/api/uploads/${a.id}" alt="${esc(a.name)}" title="${esc(a.name)}">` : `<a class="att-pill" href="/api/uploads/${a.id}" target="_blank">${icon('file')}${esc(a.name)}</a>`)).join('')}</div>` : ''}
886
- <div class="ask-foot">${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>
887
1619
  ${fold ? `<button class="ghost xs t-unfold" title="Collapse this exchange">${icon('chevron', 'chev')}</button>` : ''}
888
1620
  </div>`);
889
1621
  if (fold) ask.querySelector('.t-unfold').onclick = fold;
@@ -898,6 +1630,8 @@ function renderTurn(s, t, isLatest, fold) {
898
1630
  node.append(renderWorking(s, t));
899
1631
  } else {
900
1632
  node.append(renderPresentation(t.presentation, ctxFor(s, t, isLatest)));
1633
+ const subs = renderSubs(s, t);
1634
+ if (subs) node.append(subs);
901
1635
  node.append(renderTrail(s, t));
902
1636
  }
903
1637
  return node;
@@ -915,6 +1649,7 @@ function renderWorking(s, t) {
915
1649
  <span class="w-count">${t.activity.filter((a) => a.kind !== 'text').length} actions</span>
916
1650
  </div>
917
1651
  <div class="ask-slot"></div>
1652
+ ${teamLiveHtml(s, t)}
918
1653
  <ul class="ticker"></ul>
919
1654
  <div class="w-actions">
920
1655
  <button class="link xs peek">Show all activity</button>
@@ -985,6 +1720,8 @@ function renderTrail(s, t) {
985
1720
  return { add: acc.add + n.add, del: acc.del + n.del };
986
1721
  }, { add: 0, del: 0 });
987
1722
  const st = t.stats || {};
1723
+ const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (t.changes?.length || t.checkpoint?.commit);
1724
+ const rolledBack = !!t.rolledBackAt;
988
1725
  const node = el(`
989
1726
  <div class="trail">
990
1727
  <div class="trail-bar">
@@ -992,6 +1729,7 @@ function renderTrail(s, t) {
992
1729
  ${t.endedAt ? `<span class="tb">${fmtDuration(t.endedAt - t.startedAt)}</span>` : ''}
993
1730
  ${st.costUsd ? `<span class="tb">$${st.costUsd.toFixed(2)}</span>` : ''}
994
1731
  <span class="grow"></span>
1732
+ ${canRollback ? `<button class="link xs cp-rollback ${rolledBack ? 'rolled-back' : ''}" data-a="rollback" title="${rolledBack ? 'Re-apply changes from this turn' : `Restore working tree to before this turn${t.checkpoint?.head ? ` (${t.checkpoint.head.slice(0, 7)})` : ''}`}">${icon(rolledBack ? 'rerun' : 'replay')}${rolledBack ? 'Re-apply turn' : 'Restore to before this turn'}</button>` : ''}
995
1733
  ${t.changes?.length ? `<button class="link xs" data-tab="changes">${t.changes.length} file${t.changes.length > 1 ? 's' : ''} <span class="plus">+${totals.add}</span> <span class="minus">−${totals.del}</span></button>` : ''}
996
1734
  ${acts.length ? `<button class="link xs" data-tab="activity">Activity</button>` : ''}
997
1735
  ${t.presentation?.notes ? `<button class="link xs" data-tab="notes">Agent notes</button>` : ''}
@@ -1001,6 +1739,9 @@ function renderTrail(s, t) {
1001
1739
  const body = $('.trail-body', node);
1002
1740
  let open = null;
1003
1741
  node.addEventListener('click', (e) => {
1742
+ if (e.target.closest('[data-a="rollback"]')) {
1743
+ return rollbackTurn(s.id, t.id, rolledBack);
1744
+ }
1004
1745
  const b = e.target.closest('[data-tab]');
1005
1746
  if (!b) return;
1006
1747
  const tab = b.dataset.tab;
@@ -1055,6 +1796,7 @@ function renderDock(id) {
1055
1796
  <div class="c-bar">
1056
1797
  <button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
1057
1798
  <span class="c-hint"></span>
1799
+ <button class="ghost sm c-ship" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
1058
1800
  <button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
1059
1801
  <button class="ghost xs c-queue" hidden>${icon('queue')}<span>Queue</span></button>
1060
1802
  <button class="send" title="Send (Enter · Shift+Enter for newline)">${icon('enter')}</button>
@@ -1068,6 +1810,7 @@ function renderDock(id) {
1068
1810
  acceptFiles(comp, ta, n.tray);
1069
1811
  $('.c-attach', comp).onclick = n.tray.pick;
1070
1812
  $('.c-done', comp).onclick = () => toggleDone(id);
1813
+ $('.c-ship', comp).onclick = () => shipDialog(id);
1071
1814
  $('.c-queue', comp).onclick = toggleQueue;
1072
1815
  ta.value = S.drafts.get(id) || '';
1073
1816
  ta.addEventListener('input', () => S.drafts.set(id, ta.value));
@@ -1201,15 +1944,66 @@ async function toggleDone(id) {
1201
1944
  if (!s) return;
1202
1945
  if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
1203
1946
  const next = s.state === 'done' ? 'review' : 'done';
1947
+ const leave = next === 'done' && handOff(id); // measured before the live update moves the card
1204
1948
  await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
1205
- if (next === 'done') {
1206
- const nextUp = groups().needs.find((x) => x.id !== id);
1207
- collapse(id);
1208
- if (nextUp) setFocus(nextUp.id);
1949
+ if (leave) {
1950
+ leave(() => S.sessions.set(id, { ...S.sessions.get(id), state: 'done' }));
1209
1951
  toast(`Marked done`, { action: { label: 'Undo', run: () => api(`/sessions/${id}`, { method: 'PATCH', body: { state: 'review' } }) } });
1210
1952
  }
1211
1953
  }
1212
1954
 
1955
+ // Every finished run of a compare task at once, with one toast to undo them all.
1956
+ async function taskDone(ids) {
1957
+ if (!ids.length) return;
1958
+ const card = S.nodes.get(ids[0])?.card.closest('.task-item');
1959
+ const next = card?.nextElementSibling?.querySelector('.item')?.dataset.id || card?.nextElementSibling?.dataset.id;
1960
+ const set = (state) => Promise.all(ids.map((id) => api(`/sessions/${id}`, { method: 'PATCH', body: { state } })));
1961
+ await set('done').catch((err) => toast(esc(err.message), { tone: 'bad' }));
1962
+ for (const id of ids) S.sessions.set(id, { ...S.sessions.get(id), state: 'done' });
1963
+ renderFeed();
1964
+ if (next && S.nodes.has(next)) setFocus(next);
1965
+ toast(`Marked ${ids.length} runs done`, { action: { label: 'Undo', run: () => set('review') } });
1966
+ }
1967
+
1968
+ async function revertFile(id, turnId, file, restore = false) {
1969
+ try {
1970
+ await api(`/sessions/${id}/revert`, { method: 'POST', body: { turnId, file, restore } });
1971
+ toast(restore ? `Re-applied ${esc(file)}` : `Reverted ${esc(file)}`, {
1972
+ action: { label: 'Undo', run: () => revertFile(id, turnId, file, !restore) },
1973
+ });
1974
+ } catch (err) {
1975
+ toast(esc(err.message), { tone: 'bad' });
1976
+ }
1977
+ }
1978
+
1979
+ async function rollbackTurn(id, turnId, restore = false) {
1980
+ const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
1981
+ const turn = s?.turns?.find((t) => t.id === turnId) || s?.turns?.at(-1);
1982
+ if (!turn) return;
1983
+ try {
1984
+ const r = await api(`/sessions/${id}/rollback`, { method: 'POST', body: { turnId: turn.id, restore } });
1985
+ toast(
1986
+ restore
1987
+ ? `Re-applied turn changes`
1988
+ : `Restored working tree to before this turn${r.head ? ` (${esc(r.head.slice(0, 7))})` : ''}`,
1989
+ { action: { label: 'Undo', run: () => rollbackTurn(id, turn.id, !restore) } },
1990
+ );
1991
+ } catch (err) {
1992
+ toast(esc(err.message), { tone: 'bad' });
1993
+ }
1994
+ }
1995
+
1996
+ async function execAction(id, turnId, command, key) {
1997
+ try {
1998
+ const r = await api(`/sessions/${id}/exec`, { method: 'POST', body: { command, turnId } });
1999
+ if (key) S.actionRuns.set(key, r);
2000
+ return r;
2001
+ } catch (err) {
2002
+ toast(esc(err.message), { tone: 'bad' });
2003
+ return null;
2004
+ }
2005
+ }
2006
+
1213
2007
  async function takeOver(id) {
1214
2008
  const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
1215
2009
  if (!s?.agentSessionId) return;
@@ -1458,12 +2252,16 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
1458
2252
  function moreMenu(id, anchor) {
1459
2253
  const s = S.full.get(id);
1460
2254
  if (!s) return;
2255
+ const lastTurn = s.turns?.at(-1);
2256
+ const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (lastTurn?.changes?.length || lastTurn?.checkpoint?.commit);
2257
+ const rolledBack = !!lastTurn?.rolledBackAt;
1461
2258
  const r = anchor.getBoundingClientRect();
1462
2259
  const menu = el(`
1463
2260
  <div class="menu" style="top:${r.bottom + 6}px;right:${innerWidth - r.right}px">
1464
2261
  <button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
1465
2262
  ${s.agentSessionId ? `<button data-a="copy">${icon('copy')}Copy resume command</button>` : ''}
1466
2263
  <button data-a="folder">${icon('folder')}Open project in editor</button>
2264
+ ${canRollback ? `<button data-a="rollback">${icon(rolledBack ? 'rerun' : 'replay')}${rolledBack ? 'Re-apply latest turn' : 'Restore to before latest turn'}</button>` : ''}
1467
2265
  ${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : s.state !== 'running' && s.state !== 'waiting' ? `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>` : ''}
1468
2266
  <button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
1469
2267
  </div>`);
@@ -1480,6 +2278,7 @@ function moreMenu(id, anchor) {
1480
2278
  toast('Copied resume command');
1481
2279
  }
1482
2280
  if (a === 'folder') openInEditor(s, '.');
2281
+ if (a === 'rollback') rollbackTurn(id, lastTurn?.id, rolledBack);
1483
2282
  if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
1484
2283
  if (a === 'delete') deleteSession(id);
1485
2284
  };
@@ -1497,6 +2296,10 @@ function taskMenu(id, e) {
1497
2296
  ${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
1498
2297
  <button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
1499
2298
  <button data-a="rerun">${icon('rerun')}Rerun…${keyHint('⇧R')}</button>
2299
+ ${s.branch && !busy && s.pr?.state !== 'MERGED' ? `<button data-a="ship">${icon('send')}${s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…'}${keyHint('S')}</button>` : ''}
2300
+ ${s.pr?.state === 'OPEN' ? `<button data-a="merge">${icon('check')}Merge PR #${s.pr.number}…</button>` : ''}
2301
+ ${s.groupId ? `<button data-a="compare"><b class="vs-mark">vs</b>Compare runs</button>` : ''}
2302
+ <button data-a="recipe">${icon('spark')}Save as recipe…</button>
1500
2303
  ${busy || s.archived ? '' : `<button data-a="done">${icon('done')}${s.state === 'done' ? 'Reopen' : 'Mark done'}${keyHint('E')}</button>`}
1501
2304
  ${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : busy ? '' : `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>`}
1502
2305
  <button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
@@ -1512,6 +2315,10 @@ function taskMenu(id, e) {
1512
2315
  if (a === 'collapse') collapse(id);
1513
2316
  if (a === 'rename') rename(id);
1514
2317
  if (a === 'rerun') rerun(id);
2318
+ if (a === 'ship') shipDialog(id);
2319
+ if (a === 'merge') mergePr(id);
2320
+ if (a === 'compare') compareView(id);
2321
+ if (a === 'recipe') saveAsRecipe(id);
1515
2322
  if (a === 'done') toggleDone(id);
1516
2323
  if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
1517
2324
  if (a === 'delete') deleteSession(id);
@@ -1528,7 +2335,9 @@ const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
1528
2335
 
1529
2336
  async function deleteSession(id) {
1530
2337
  if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
1531
- 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));
1532
2341
  }
1533
2342
 
1534
2343
  // Launch the task again as a new session in the same folder: same prompt and attachments,
@@ -1573,11 +2382,7 @@ async function rerun(id) {
1573
2382
  modeBtn.title = m.hint;
1574
2383
  };
1575
2384
  const runnerBtn = $('[data-a="runner"]', node);
1576
- const paintRunner = () => {
1577
- const a = S.agents.find((x) => x.id === agent);
1578
- const m = (a?.models || []).find((x) => x.id === model);
1579
- runnerBtn.innerHTML = `<span class="rb-logo">${harnessLogo(agent)}</span><span class="rb-h">${esc(a?.name || agentName(agent))}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${effort ? `<span class="rb-e">${esc(effortLabel(effort))}</span>` : ''}${icon('chevron', 'chev')}`;
1580
- };
2385
+ const paintRunner = () => paintRunnerBtn(runnerBtn, agent, model, effort);
1581
2386
  paintMode();
1582
2387
  paintRunner();
1583
2388
  modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
@@ -1622,13 +2427,14 @@ async function rerun(id) {
1622
2427
  }
1623
2428
 
1624
2429
  async function setArchived(id, archived) {
2430
+ const leave = archived && handOff(id);
1625
2431
  try {
1626
2432
  await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
1627
2433
  } catch (err) {
1628
2434
  return toast(esc(err.message), { tone: 'bad' });
1629
2435
  }
1630
- if (archived) {
1631
- if (S.open.has(id)) collapse(id);
2436
+ if (leave) {
2437
+ leave(() => S.sessions.set(id, { ...S.sessions.get(id), archived: true }));
1632
2438
  toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
1633
2439
  }
1634
2440
  }
@@ -1814,33 +2620,773 @@ async function openProjects({ focusAdd = false } = {}) {
1814
2620
  if (focusAdd) setTimeout(() => input.focus(), 60);
1815
2621
  }
1816
2622
 
2623
+ // --- ship it: worktree → commit → PR → checks → merge ---------------------------------------
2624
+
2625
+ const prTone = (pr) =>
2626
+ pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.checks?.fail ? 'fail' : pr.checks?.pending ? 'pending' : pr.review === 'CHANGES_REQUESTED' ? 'fail' : 'pass';
2627
+ const REVIEW = { APPROVED: 'Approved', CHANGES_REQUESTED: 'Changes requested', REVIEW_REQUIRED: 'Review required' };
2628
+ function prLine(pr) {
2629
+ const c = pr.checks || {};
2630
+ const checks = c.fail ? `${c.fail} check${c.fail > 1 ? 's' : ''} failing` : c.pending ? `${c.pending} check${c.pending > 1 ? 's' : ''} running` : c.pass ? `${c.pass} check${c.pass > 1 ? 's' : ''} passed` : 'No checks';
2631
+ const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
2632
+ return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
2633
+ }
2634
+ const prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
2635
+
2636
+ // The PR row under a card's bar; reads the live summary, so polled check results show up.
2637
+ function renderPrStrip(id) {
2638
+ const n = S.nodes.get(id);
2639
+ const node = n?.body && $('.pr-strip', n.body);
2640
+ const s = S.sessions.get(id);
2641
+ if (!node || !s) return;
2642
+ const pr = s.pr;
2643
+ $$('[data-a="ship"] span', n.body).forEach((b) => (b.textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it'));
2644
+ node.hidden = !pr;
2645
+ if (!pr) return;
2646
+ const tone = prTone(pr);
2647
+ node.className = `pr-strip pr-${tone}`;
2648
+ node.innerHTML = `
2649
+ <span class="pr-dot"></span>
2650
+ <a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
2651
+ <span class="pr-line">${esc(prLine(pr))}</span>
2652
+ <span class="grow"></span>
2653
+ ${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
2654
+ <a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>
2655
+ ${pr.state === 'OPEN' ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge, then fast-forward your local ${esc(s.branch ? 'base branch' : 'checkout')}">${icon('check')}<span>Merge</span></button>` : ''}`;
2656
+ node.onclick = (e) => {
2657
+ const a = e.target.closest('[data-pr]')?.dataset.pr;
2658
+ if (a === 'refresh') api(`/sessions/${id}/pr`, { method: 'POST', body: {} }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
2659
+ if (a === 'merge') mergePr(id);
2660
+ };
2661
+ }
2662
+
2663
+ async function shipDialog(id) {
2664
+ const s = S.sessions.get(id);
2665
+ if (!s) return;
2666
+ if (s.state === 'running' || s.state === 'waiting') return toast('Still working: let it finish first.');
2667
+ let info;
2668
+ try {
2669
+ info = await api(`/sessions/${id}/ship`);
2670
+ } catch (err) {
2671
+ return toast(esc(err.message), { tone: 'bad' });
2672
+ }
2673
+ const open = info.pr?.state === 'OPEN';
2674
+ const nothing = !info.dirty && !info.ahead;
2675
+ const node = el(`
2676
+ <div class="modal ship-modal">
2677
+ <header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
2678
+ <p class="ship-route">${icon('branch')}<code>${esc(info.branch)}</code>${icon('arrow')}<code>${esc(info.remote || 'no remote')}/${esc(info.base)}</code>
2679
+ <span class="muted">${info.dirty ? `${info.dirty} uncommitted change${info.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${info.ahead} commit${info.ahead === 1 ? '' : 's'} on the branch</span></p>
2680
+ ${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input name="title" spellcheck="false" maxlength="100"></label>` : ''}
2681
+ ${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea name="body" rows="9" spellcheck="false"></textarea></label>
2682
+ <label class="ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
2683
+ ${!info.remote ? '<p class="hint bad">This repo has no <code>origin</code> remote, so there is nowhere to push.</p>' : nothing ? '<p class="hint">Nothing to ship yet: the branch has no changes.</p>' : ''}
2684
+ <div class="ship-foot"><span class="muted xs">Runs <code>git commit</code>, <code>git push</code>${open ? '' : ' and <code>gh pr create</code>'} in the worktree.</span><button class="primary sm" data-a="go" ${nothing || !info.remote ? 'disabled' : ''}>${icon('send')}${open ? 'Commit & push' : 'Commit, push & open PR'}</button></div>
2685
+ </div>`);
2686
+ const close = overlay(node);
2687
+ $('.close', node).onclick = close;
2688
+ const title = $('[name="title"]', node);
2689
+ const body = $('[name="body"]', node);
2690
+ if (title) title.value = info.title;
2691
+ if (body) body.value = info.body;
2692
+ const go = async () => {
2693
+ const btn = $('[data-a="go"]', node);
2694
+ btn.disabled = true;
2695
+ btn.lastChild.textContent = 'Shipping…';
2696
+ try {
2697
+ const pr = await api(`/sessions/${id}/ship`, { method: 'POST', body: { title: title?.value || info.title, body: body?.value ?? info.body, draft: !!$('[name="draft"]', node)?.checked } });
2698
+ close();
2699
+ toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
2700
+ } catch (err) {
2701
+ btn.disabled = false;
2702
+ btn.lastChild.textContent = open ? 'Commit & push' : 'Commit, push & open PR';
2703
+ toast(esc(err.message), { tone: 'bad', timeout: 10_000 });
2704
+ }
2705
+ };
2706
+ $('[data-a="go"]', node).onclick = go;
2707
+ requestAnimationFrame(() => (title || $('[data-a="go"]', node)).focus());
2708
+ }
2709
+
2710
+ async function mergePr(id) {
2711
+ const s = S.sessions.get(id);
2712
+ const pr = s?.pr;
2713
+ if (!pr || pr.state !== 'OPEN') return;
2714
+ const c = pr.checks || {};
2715
+ const warn = c.fail ? `\n\n⚠ ${c.fail} check${c.fail > 1 ? 's are' : ' is'} failing.` : c.pending ? `\n\n⚠ ${c.pending} check${c.pending > 1 ? 's are' : ' is'} still running.` : '';
2716
+ if (!confirm(`Squash-merge PR #${pr.number}${pr.draft ? ' (marks it ready first)' : ''}, then fast-forward your local base branch and remove the worktree?${warn}`)) return;
2717
+ toast(`Merging PR #${pr.number}…`);
2718
+ try {
2719
+ const r = await api(`/sessions/${id}/merge`, { method: 'POST', body: {} });
2720
+ toast(`${icon('check')} Merged <b>PR #${r.pr.number}</b>. ${esc(r.sync.text)}`, { tone: r.sync.ok ? '' : 'bad', timeout: 10_000 });
2721
+ } catch (err) {
2722
+ toast(esc(err.message), { tone: 'bad', timeout: 12_000 });
2723
+ }
2724
+ }
2725
+
2726
+ // --- compare: one task, several harnesses, side by side -------------------------------------------
2727
+
2728
+ function turnMetrics(t) {
2729
+ const lines = (t?.changes || []).reduce((acc, c) => {
2730
+ const n = countChanges(c.edits);
2731
+ return { add: acc.add + n.add, del: acc.del + n.del };
2732
+ }, { add: 0, del: 0 });
2733
+ const tests = (t?.presentation?.blocks || []).filter((b) => b?.type === 'tests');
2734
+ return {
2735
+ time: t?.endedAt ? t.endedAt - t.startedAt : null,
2736
+ cost: t?.stats?.costUsd,
2737
+ files: t?.changes?.length || 0,
2738
+ ...lines,
2739
+ tests: tests.length ? { passed: tests.reduce((a, b) => a + (Number(b.passed) || 0), 0), failed: tests.reduce((a, b) => a + (Number(b.failed) || 0), 0) } : null,
2740
+ asks: (t?.activity || []).filter((a) => a.kind === 'ask').length,
2741
+ };
2742
+ }
2743
+
2744
+ async function compareView(id) {
2745
+ const s0 = S.sessions.get(id);
2746
+ if (!s0?.groupId) return;
2747
+ const group = [...S.sessions.values()].filter((x) => x.groupId === s0.groupId).sort((a, b) => a.createdAt - b.createdAt);
2748
+ const fulls = (await Promise.all(group.map((g) => api(`/sessions/${g.id}`).catch(() => null)))).filter(Boolean);
2749
+ const node = el(`
2750
+ <div class="modal compare-modal">
2751
+ <header><h2>Compare</h2><button class="icon-btn close">${icon('x')}</button></header>
2752
+ <p class="cmp-prompt">${esc(fulls[0]?.turns[0]?.prompt || '')}</p>
2753
+ <div class="cmp-grid" style="--n:${fulls.length}"></div>
2754
+ </div>`);
2755
+ const close = overlay(node);
2756
+ $('.close', node).onclick = close;
2757
+ const grid = $('.cmp-grid', node);
2758
+ for (const f of fulls) {
2759
+ const sum = S.sessions.get(f.id) || {};
2760
+ const t = f.turns.at(-1);
2761
+ const mx = turnMetrics(t);
2762
+ const live = f.state === 'running' || f.state === 'waiting';
2763
+ const col = el(`
2764
+ <section class="cmp-col" data-id="${f.id}">
2765
+ <div class="cmp-head">
2766
+ <span class="rb-logo">${harnessLogo(f.agent)}</span>
2767
+ <span class="cmp-who"><b>${esc(agentName(f.agent))}</b><small>${esc(f.model || 'Default model')}${f.effort ? ` · ${esc(effortLabel(f.effort))}` : ''}</small></span>
2768
+ ${live ? `<span class="pill st-waiting"><i></i>${f.state === 'waiting' ? 'Asking' : 'Working'}</span>` : `<span class="pill st-${sum.status || 'info'}"><i></i>${STATUS_LABEL[sum.status] || 'Done'}</span>`}
2769
+ </div>
2770
+ <dl class="cmp-stats">
2771
+ <div><dt>Time</dt><dd>${mx.time !== null ? fmtDuration(mx.time) : '—'}</dd></div>
2772
+ <div><dt>Cost</dt><dd>${typeof mx.cost === 'number' ? `$${mx.cost.toFixed(2)}` : '—'}</dd></div>
2773
+ <div><dt>Files</dt><dd>${mx.files} <span class="plus">+${mx.add}</span> <span class="minus">−${mx.del}</span></dd></div>
2774
+ <div><dt>Tests</dt><dd>${mx.tests ? `<span class="${mx.tests.failed ? 'minus' : 'plus'}">${mx.tests.passed} ✓ ${mx.tests.failed ? `${mx.tests.failed} ✗` : ''}</span>` : '—'}</dd></div>
2775
+ <div><dt>Asked</dt><dd>${mx.asks}</dd></div>
2776
+ </dl>
2777
+ <div class="cmp-body"></div>
2778
+ <div class="cmp-foot">
2779
+ <button class="ghost xs" data-a="open">${icon('chevron')}Open card</button>
2780
+ <span class="grow"></span>
2781
+ <button class="primary sm" data-a="keep" ${live ? 'disabled title="Still working"' : ''}>${icon('check')}Keep this</button>
2782
+ </div>
2783
+ </section>`);
2784
+ $('.cmp-body', col).append(live ? el(`<p class="muted xs">${esc(sum.live || 'Working…')}</p>`) : renderPresentation(t?.presentation, ctxFor(f, t, false)));
2785
+ grid.append(col);
2786
+ }
2787
+ grid.onclick = async (e) => {
2788
+ const a = e.target.closest('[data-a]')?.dataset.a;
2789
+ const keep = e.target.closest('.cmp-col')?.dataset.id;
2790
+ if (!a || !keep) return;
2791
+ if (a === 'open') return close(), reveal(keep);
2792
+ const others = group.filter((g) => g.id !== keep);
2793
+ if (!confirm(`Keep ${agentName(S.sessions.get(keep)?.agent)}'s result?\n\nThe other ${others.length === 1 ? 'run is' : `${others.length} runs are`} archived, and ${others.length === 1 ? 'its worktree is' : 'their worktrees are'} deleted with their changes.`)) return;
2794
+ for (const o of others) {
2795
+ if (o.state === 'running' || o.state === 'waiting') {
2796
+ await api(`/sessions/${o.id}/stop`, { method: 'POST', body: {} }).catch(() => {});
2797
+ await until(() => S.sessions.get(o.id)?.state !== 'running', 5000);
2798
+ }
2799
+ await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
2800
+ }
2801
+ close();
2802
+ reveal(keep);
2803
+ toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
2804
+ };
2805
+ }
2806
+
2807
+ // Poll a condition for up to `ms` (used after asking the server to stop something).
2808
+ async function until(fn, ms) {
2809
+ const end = Date.now() + ms;
2810
+ while (Date.now() < end && !fn()) await new Promise((r) => setTimeout(r, 150));
2811
+ }
2812
+
2813
+ // --- automations: recipes (saved prompts) and triggers (schedules, GitHub watchers) -----------
2814
+
2815
+ const TRIGGER_KINDS = [
2816
+ { kind: 'schedule', label: 'Schedule', icon: 'clock', hint: 'On a cron schedule, local time' },
2817
+ { kind: 'ci', label: 'CI failed', icon: 'warn', hint: 'A GitHub Actions run fails (polled with gh)' },
2818
+ { kind: 'issues', label: 'Issue labelled', icon: 'ask', hint: 'An open issue gets a label (polled with gh)' },
2819
+ ];
2820
+ const CRON_PRESETS = [
2821
+ ['Weekdays 9:00', '0 9 * * 1-5'],
2822
+ ['Every hour', '0 * * * *'],
2823
+ ['Daily 18:00', '0 18 * * *'],
2824
+ ['Mondays 8:00', '0 8 * * 1'],
2825
+ ];
2826
+ const recipeParams = (prompt) => [...new Set([...String(prompt || '').matchAll(/\{([a-zA-Z][\w-]{0,30})\}/g)].map((m) => m[1]))];
2827
+ const whenLabel = (t) => {
2828
+ if (!t) return '';
2829
+ const d = new Date(t);
2830
+ const today = new Date().toDateString() === d.toDateString();
2831
+ return d.toLocaleString([], today ? { hour: '2-digit', minute: '2-digit' } : { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
2832
+ };
2833
+ const projectOptions = (selected, { any } = {}) =>
2834
+ (any ? `<option value="">${esc(any)}</option>` : '') +
2835
+ S.config.projects.map((p) => `<option value="${esc(p.path)}" ${p.path === selected ? 'selected' : ''}>${esc(p.name)}</option>`).join('');
2836
+ const modeOptions = (selected, { inherit } = {}) =>
2837
+ (inherit ? `<option value="">${esc(inherit)}</option>` : '') + MODES.map((m) => `<option value="${m.id}" ${m.id === selected ? 'selected' : ''}>${m.label}</option>`).join('');
2838
+
2839
+ function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
2840
+ const node = el(`<div class="modal auto-modal">
2841
+ <header><h2>Automations</h2><button class="icon-btn close">${icon('x')}</button></header>
2842
+ <p class="mem-lede">Recipes are prompts you send again and again; <code>{name}</code> in one becomes a field to fill in. Triggers start sessions on their own.</p>
2843
+ <div class="mem-tabs" role="tablist"></div>
2844
+ <div class="auto-body"></div>
2845
+ </div>`);
2846
+ const close = present(node, host);
2847
+ let recipes = [];
2848
+ let triggers = [];
2849
+ let editing = edit; // { type: 'recipe' | 'trigger', draft }
2850
+
2851
+ const render = async () => {
2852
+ [recipes, triggers] = await Promise.all([api('/recipes').catch(() => recipes), api('/triggers').catch(() => triggers)]);
2853
+ if (!node.isConnected) return;
2854
+ $('.mem-tabs', node).innerHTML = [
2855
+ ['recipes', 'Recipes', recipes.length],
2856
+ ['triggers', 'Triggers', triggers.length],
2857
+ ].map(([k, l, n]) => `<button role="tab" data-tab="${k}" class="${tab === k ? 'on' : ''}">${l}<span>${n}</span></button>`).join('');
2858
+ const body = $('.auto-body', node);
2859
+ body.innerHTML = '';
2860
+ if (editing?.type === 'recipe') body.append(recipeForm(editing.draft));
2861
+ else if (editing?.type === 'trigger') body.append(triggerForm(editing.draft));
2862
+ else body.innerHTML = tab === 'recipes' ? recipesView() : triggersView();
2863
+ };
2864
+
2865
+ const recipesView = () =>
2866
+ `<div class="au-list">${
2867
+ recipes
2868
+ .map((r) => {
2869
+ const proj = S.config.projects.find((p) => p.path === r.cwd);
2870
+ const tags = [r.params.length ? r.params.map((p) => `{${p}}`).join(' ') : '', proj ? proj.name : 'Any project', r.agent ? agentName(r.agent) : '', r.runs ? `${r.runs} run${r.runs > 1 ? 's' : ''}` : ''].filter(Boolean);
2871
+ return `<div class="au-item" data-id="${r.id}">
2872
+ <span class="mem-ico">${icon('spark')}</span>
2873
+ <div class="au-main"><b>${esc(r.name)}</b><p>${esc(r.prompt.replace(/\s+/g, ' ').slice(0, 140))}</p><span class="au-tags">${tags.map((t) => `<i>${esc(t)}</i>`).join('')}</span></div>
2874
+ <span class="au-acts"><button class="primary sm" data-act="run">Run</button><button class="icon-btn" data-act="schedule" title="Run it on a schedule">${icon('clock')}</button><button class="icon-btn" data-act="edit" title="Edit">${icon('pen')}</button><button class="icon-btn" data-act="delete" title="Delete">${icon('trash')}</button></span>
2875
+ </div>`;
2876
+ })
2877
+ .join('') || '<p class="mem-empty">No recipes yet. Save a task you repeat, like a release checklist, a PR from a commit, or a dependency bump.</p>'
2878
+ }</div><button class="mem-add" data-act="new">${icon('plus')}New recipe</button>
2879
+ <p class="hint">Also from the terminal: <code>wisp recipes</code>, <code>wisp run &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
+
1817
3255
  // --- settings -----------------------------------------------------------------------------
1818
3256
 
1819
- 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;
1820
3272
  const d = S.config.defaults;
3273
+ // A row: name and a line of explanation on the left, the control on the right.
3274
+ const row = (name, desc, control, cls = '') => `<div class="sx-row ${cls}"><div class="sx-text"><b>${name}</b>${desc ? `<small>${desc}</small>` : ''}</div><div class="sx-ctl">${control}</div></div>`;
3275
+ const missing = S.agents.filter((a) => !a.available).length;
1821
3276
  const node = el(`
1822
- <div class="modal settings-modal">
1823
- <header><h2>Settings</h2><button class="icon-btn close">${icon('x')}</button></header>
1824
- <label><span>Open files in</span>
1825
- <select name="editor">${[['vscode', 'VS Code'], ['cursor', 'Cursor'], ['windsurf', 'Windsurf'], ['zed', 'Zed'], ['idea', 'JetBrains'], ['none', 'Copy path']]
1826
- .map(([v, l]) => `<option value="${v}" ${d.editor === v || (!d.editor && v === 'vscode') ? 'selected' : ''}>${l}</option>`)
1827
- .join('')}</select></label>
1828
- <label><span>Take over in</span>
1829
- <select name="terminal">${(S.config.terminals || []).map((t) => `<option value="${t.id}" ${(d.terminal || 'auto') === t.id ? 'selected' : ''}>${t.label}</option>`).join('')}</select></label>
1830
- <label class="custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><span>Terminal command</span>
1831
- <input name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></label>
1832
- <div class="setting"><span>Desktop notifications</span><span class="notif-hint muted xs"></span><button class="switch" id="notif" role="switch"></button></div>
1833
- <div class="setting"><span>Anonymous usage stats</span><span class="tele-hint muted xs"></span><button class="switch" id="tele" role="switch"></button></div>
1834
- <div class="setting"><span>Memory</span><span class="learn-hint muted xs"></span><button class="ghost xs" id="openMemory">Open</button><button class="switch" id="learn" role="switch"></button></div>
1835
- <div class="agents">${S.agents
1836
- .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>`)
1837
- .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>
1838
3334
  </div>`);
1839
3335
  const close = overlay(node);
1840
3336
  $('.close', node).onclick = close;
3337
+ const pick = (id, focus = false) => {
3338
+ settingsPane = id;
3339
+ const p = SETTINGS_PANES.find((x) => x.id === id);
3340
+ $$('.sx-nav [data-pane]', node).forEach((b) => {
3341
+ const on = b.dataset.pane === id;
3342
+ b.setAttribute('aria-selected', on);
3343
+ b.tabIndex = on ? 0 : -1;
3344
+ if (on && focus) b.focus();
3345
+ });
3346
+ $$('.sx-pane', node).forEach((s) => (s.hidden = s.dataset.pane !== id));
3347
+ $('.sx-title', node).textContent = p.label;
3348
+ $('.sx-lede', node).textContent = p.lede;
3349
+ $('.sx-main', node).scrollTop = 0;
3350
+ };
3351
+ // Memory, Automations, Usage and Phone open in the main area, next to the sidebar, with a
3352
+ // way back to the section they came from. Leaving one goes back; only the X closes Settings.
3353
+ const main = $('.sx-main', node);
3354
+ const leave = () => {
3355
+ const v = $('.sx-main > .embedded', node);
3356
+ if (!v) return;
3357
+ v.remove();
3358
+ $('.sx-main > header', node).hidden = false;
3359
+ paintHints();
3360
+ };
3361
+ const show = (id, focus) => (leave(), pick(id, focus));
3362
+ const host = (pane) => (view) => {
3363
+ show(pane);
3364
+ view.classList.add('embedded');
3365
+ const back = el(`<button class="ghost sm sx-back">${icon('back')}Back</button>`);
3366
+ back.onclick = () => show(pane);
3367
+ $('header', view).prepend(back);
3368
+ $('.close', view).onclick = close;
3369
+ $('.sx-main > header', node).hidden = true;
3370
+ $$('.sx-pane', node).forEach((s) => (s.hidden = true));
3371
+ main.append(view);
3372
+ main.scrollTop = 0;
3373
+ return close;
3374
+ };
3375
+ $('.sx-nav', node).onclick = (e) => {
3376
+ const b = e.target.closest('[data-pane]');
3377
+ if (b) show(b.dataset.pane);
3378
+ };
3379
+ $('.sx-nav', node).onkeydown = (e) => {
3380
+ const step = { ArrowDown: 1, ArrowUp: -1 }[e.key];
3381
+ if (!step) return;
3382
+ e.preventDefault();
3383
+ const i = SETTINGS_PANES.findIndex((p) => p.id === settingsPane);
3384
+ show(SETTINGS_PANES[(i + step + SETTINGS_PANES.length) % SETTINGS_PANES.length].id, true);
3385
+ };
3386
+ show(settingsPane);
1841
3387
  node.addEventListener('change', async (e) => {
1842
3388
  const { name, value } = e.target;
1843
- if (!name) return;
3389
+ if (!name || e.target.closest('.embedded')) return; // a nested view's own form
1844
3390
  if (name === 'terminal') $('.custom-term', node).hidden = value !== 'custom';
1845
3391
  await api('/config', { method: 'PATCH', body: { defaults: { [name]: value } } });
1846
3392
  await loadConfig();
@@ -1888,9 +3434,42 @@ function openSettings() {
1888
3434
  await setLearning(S.config.defaults.learn === 'off');
1889
3435
  paintLearn();
1890
3436
  };
1891
- $('#openMemory', node).onclick = () => openMemory();
1892
3437
  node.paintLearn = paintLearn;
1893
- 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') });
1894
3473
  }
1895
3474
 
1896
3475
  // --- memory: what the background retros learned ---------------------------------------------
@@ -1917,15 +3496,14 @@ function memorySummary(data) {
1917
3496
  return `${n ? `${n} remembered` : 'Nothing yet'}${last ? ` · last retro ${ago(last.at)}` : ''}`;
1918
3497
  }
1919
3498
 
1920
- function openMemory({ tab = 'global' } = {}) {
3499
+ function openMemory({ tab = 'global', host } = {}) {
1921
3500
  const node = el(`<div class="modal memory-modal">
1922
3501
  <header><h2>Memory</h2><span class="mem-live"></span><button class="switch" id="memOn" role="switch" title="Learn from sessions"></button><button class="icon-btn close">${icon('x')}</button></header>
1923
3502
  <p class="mem-lede">Learned from your sessions and added to every agent's prompt. What you ask always wins.</p>
1924
3503
  <div class="mem-tabs" role="tablist"></div>
1925
3504
  <div class="mem-body"></div>
1926
3505
  </div>`);
1927
- const close = overlay(node);
1928
- $('.close', node).onclick = close;
3506
+ const close = present(node, host);
1929
3507
  let data;
1930
3508
  let editing = null; // { scope, kind, index } (index -1 = adding)
1931
3509
 
@@ -2039,7 +3617,7 @@ function telemetryNotice() {
2039
3617
  if (!S.config.telemetry?.enabled || S.config.defaults.telemetryNotice === 'seen') return;
2040
3618
  S.config.defaults.telemetryNotice = 'seen';
2041
3619
  api('/config', { method: 'PATCH', body: { defaults: { telemetryNotice: 'seen' } } }).catch(() => {});
2042
- toast('wisp sends anonymous usage stats and error reports. No prompts, paths or code.', { timeout: 10_000, action: { label: 'Settings', run: openSettings } });
3620
+ toast('wisp sends anonymous usage stats and error reports. No prompts, paths or code.', { timeout: 10_000, action: { label: 'Settings', run: () => openSettings({ pane: 'privacy' }) } });
2043
3621
  }
2044
3622
 
2045
3623
  function shortcuts() {
@@ -2056,10 +3634,12 @@ function shortcuts() {
2056
3634
  ['T', 'Take over in terminal'],
2057
3635
  ['R', 'Replay presentation'],
2058
3636
  ['⇧R', 'Rerun with another harness, model or thinking level'],
3637
+ ['S', 'Ship it: commit, push and open a PR (worktree tasks)'],
2059
3638
  ['1–9', 'Pick an answer when asked'],
2060
3639
  ['↵', 'Launch / send'],
2061
3640
  ['⇧↵', 'New line'],
2062
3641
  ['C', 'Collapse all'],
3642
+ ['U', 'Back to top'],
2063
3643
  ['Esc', 'Close / collapse'],
2064
3644
  ];
2065
3645
  const node = el(`<div class="modal keys-modal"><header><h2>Shortcuts</h2></header>${rows.map(([k, l]) => `<div class="krow"><kbd>${k}</kbd><span>${l}</span></div>`).join('')}</div>`);
@@ -2079,7 +3659,8 @@ const modKey = (e, k) =>
2079
3659
  const STATE_LABEL = { running: 'Working', waiting: 'Asking', review: 'Review', done: 'Done' };
2080
3660
 
2081
3661
  function focusLauncher() {
2082
- 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' });
2083
3664
  $('#prompt')?.focus({ preventScroll: true });
2084
3665
  }
2085
3666
 
@@ -2095,14 +3676,23 @@ function paletteItems() {
2095
3676
  const cur = S.focus && S.sessions.get(S.focus);
2096
3677
  if (cur) {
2097
3678
  const id = cur.id;
3679
+ const full = S.full.get(id);
3680
+ const lastTurn = full?.turns?.at(-1);
2098
3681
  const live = cur.state === 'running' || cur.state === 'waiting';
3682
+ const canRollback = cur.agent !== 'wisp' && !live && (lastTurn?.changes?.length || lastTurn?.checkpoint?.commit || cur.filesChanged);
3683
+ const rolledBack = !!lastTurn?.rolledBackAt;
2099
3684
  const g = 'This wisp';
2100
3685
  const sub = cur.title;
2101
3686
  items.push({ group: g, label: 'Follow up', sub, icon: 'send', kbd: '/', run: () => withCard(id, () => $('.reply', S.nodes.get(id)?.card)?.focus(), { open: true }) });
2102
3687
  if (!live) items.push({ group: g, label: cur.state === 'done' ? 'Move back to review' : 'Mark done', sub, icon: 'done', kbd: 'E', run: () => toggleDone(id) });
3688
+ if (canRollback) items.push({ group: g, label: rolledBack ? 'Re-apply latest turn' : 'Restore to before latest turn', sub, icon: rolledBack ? 'rerun' : 'replay', keywords: 'rollback revert undo checkpoint restore', run: () => rollbackTurn(id, lastTurn?.id, rolledBack) });
2103
3689
  if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
2104
3690
  items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
2105
3691
  items.push({ group: g, label: 'Rerun…', sub, icon: 'rerun', kbd: '⇧R', keywords: 'retry again restart model harness', run: () => rerun(id) });
3692
+ if (cur.branch && !live && cur.pr?.state !== 'MERGED') items.push({ group: g, label: cur.pr?.state === 'OPEN' ? `Push to PR #${cur.pr.number}` : 'Ship it…', sub, icon: 'send', kbd: 'S', keywords: 'commit push pull request pr github', run: () => shipDialog(id) });
3693
+ if (cur.pr?.state === 'OPEN') items.push({ group: g, label: `Merge PR #${cur.pr.number}…`, sub, icon: 'check', keywords: 'squash github', run: () => mergePr(id) });
3694
+ if (cur.groupId) items.push({ group: g, label: 'Compare runs', sub, icon: 'agent', keywords: 'vs side by side harness', run: () => compareView(id) });
3695
+ items.push({ group: g, label: 'Save as recipe…', sub, icon: 'spark', keywords: 'template reuse automation', run: () => saveAsRecipe(id) });
2106
3696
  items.push({ group: g, label: 'Replay presentation', sub, icon: 'replay', kbd: 'R', run: () => withCard(id, () => replay(id), { open: true }) });
2107
3697
  items.push({ group: g, label: 'Rename', sub, icon: 'pen', kbd: 'M', run: () => withCard(id, () => rename(id)) });
2108
3698
  items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
@@ -2134,15 +3724,22 @@ function paletteItems() {
2134
3724
  { group: a, label: 'Add project…', icon: 'folder', keywords: 'folder repo directory', run: () => openProjects({ focusAdd: true }) },
2135
3725
  { group: a, label: 'Manage projects', icon: 'folder', keywords: 'rename color remove', run: () => openProjects() },
2136
3726
  { group: a, label: 'Memory', icon: 'spark', keywords: 'learned preferences lessons retro', run: () => openMemory() },
3727
+ { group: a, label: 'Recipes', icon: 'spark', keywords: 'automations saved prompts templates', run: () => openAutomations({ tab: 'recipes' }) },
3728
+ { group: a, label: 'Triggers', icon: 'replay', keywords: 'automations schedule cron ci issues github', run: () => openAutomations({ tab: 'triggers' }) },
3729
+ { group: a, label: 'Usage', icon: 'stats', keywords: 'spend cost tokens outcomes success', run: () => openUsage() },
3730
+ { group: a, label: 'Phone', icon: 'send', keywords: 'ntfy telegram notifications mobile remote', run: () => openPhone() },
2137
3731
  { group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
2138
3732
  { group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
2139
3733
  );
2140
3734
 
3735
+ for (const r of S.config.recipes || []) items.push({ group: 'Recipes', label: `Run: ${r.name}`, sub: r.prompt.replace(/\s+/g, ' ').slice(0, 80), icon: 'spark', keywords: `recipe ${r.prompt}`, run: () => runRecipe({ ...r, params: recipeParams(r.prompt) }) });
3736
+
2141
3737
  const filter = (id) => () => {
2142
3738
  S.filter = id;
2143
3739
  save('wisp.filter', id);
2144
3740
  renderProjects();
2145
3741
  renderFeed();
3742
+ feedToTop();
2146
3743
  renderLaunchChips();
2147
3744
  };
2148
3745
  items.push({ group: 'Projects', label: 'All projects', sub: S.filter === 'all' ? 'Current filter' : '', dot: 'slate', keywords: 'filter clear', run: filter('all') });
@@ -2173,8 +3770,7 @@ function askNotify() {
2173
3770
  }
2174
3771
 
2175
3772
  function notifyFinished(s) {
2176
- const n = S.nodes.get(s.id);
2177
- const viewing = S.open.has(s.id) && n && isInView(n.card) && document.hasFocus();
3773
+ const viewing = S.open.has(s.id) && watching(s.id) && document.hasFocus();
2178
3774
  if (viewing) return;
2179
3775
  const verb = s.state === 'waiting' ? 'has a question' : 'is ready';
2180
3776
  toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
@@ -2190,20 +3786,24 @@ function connect() {
2190
3786
  const es = new EventSource('/api/events');
2191
3787
  es.addEventListener('summary', (e) => {
2192
3788
  const s = JSON.parse(e.data);
3789
+ if (s.parentId) return onChildSummary(s); // a subagent: lives inside its lead's card
2193
3790
  const prev = S.sessions.get(s.id);
3791
+ const was = prev && sectionOf(prev); // before the update: a compare task's section depends on all its runs
2194
3792
  S.sessions.set(s.id, s);
2195
- // A card left open while it worked shouldn't land in "Needs you" expanded —
2196
- // fold it unless the user is actually watching it.
2197
- const n = S.nodes.get(s.id);
2198
- if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(n && isInView(n.card) && document.hasFocus())) collapse(s.id);
2199
- // A state change moves the card to another section (a follow-up sends it from
2200
- // "Needs you" to "Working"). If the user is looking at it, follow it there
2201
- // instead of leaving them on whatever slid into its place. (Marking done
2202
- // moves focus to the next item on purpose, so that case is left alone.)
2203
- const pin = prev && prev.state !== s.state && s.state !== 'done' && n?.card.isConnected && isInView(n.card) ? n.card.getBoundingClientRect().top : null;
2204
- renderFeed();
2205
- if (pin !== null && n.card.isConnected) stage.scrollTop += n.card.getBoundingClientRect().top - pin;
3793
+ // A section change moves the card (a follow-up sends it from "Needs you" to "Working").
3794
+ // If the user is watching it, the view follows it there; otherwise whatever they're
3795
+ // reading stays put. Marking done or archiving is the exception: the card leaves the
3796
+ // list and focus goes to the next one instead.
3797
+ const moved = prev && was !== sectionOf(s);
3798
+ const follow = moved && s.state !== 'done' && !s.archived && watching(s.id);
3799
+ keepInPlace(follow ? S.nodes.get(s.id).card : feedAnchor(s.id), () => {
3800
+ // A card left open while it worked shouldn't land in "Needs you" expanded —
3801
+ // fold it unless the user is actually watching it.
3802
+ if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(watching(s.id) && document.hasFocus())) collapse(s.id);
3803
+ renderFeed();
3804
+ });
2206
3805
  if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
3806
+ if (JSON.stringify(prev?.pr) !== JSON.stringify(s.pr)) renderPrStrip(s.id);
2207
3807
  });
2208
3808
  es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
2209
3809
  es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
@@ -2214,9 +3814,10 @@ function connect() {
2214
3814
  S.full.delete(id);
2215
3815
  S.open.delete(id);
2216
3816
  saveOpen();
2217
- renderFeed();
3817
+ keepInPlace(feedAnchor(id), renderFeed);
2218
3818
  });
2219
- es.addEventListener('config', () => loadConfig());
3819
+ es.addEventListener('config', () => (loadConfig(), $('.auto-modal')?.refresh()));
3820
+ es.addEventListener('remote', () => $('.phone-modal')?.refresh());
2220
3821
  es.addEventListener('update', (e) => renderUpdate(JSON.parse(e.data)));
2221
3822
  es.addEventListener('preferences', () => {
2222
3823
  $('.memory-modal')?.refresh();
@@ -2230,7 +3831,7 @@ async function loadSessions() {
2230
3831
  S.sessions = new Map(list.map((s) => [s.id, s]));
2231
3832
  for (const id of S.open) if (!S.sessions.has(id)) S.open.delete(id);
2232
3833
  saveOpen();
2233
- renderFeed();
3834
+ keepInPlace(feedAnchor(), renderFeed);
2234
3835
  for (const id of S.open) scheduleRefetch(id);
2235
3836
  }
2236
3837
 
@@ -2238,6 +3839,7 @@ async function loadConfig() {
2238
3839
  S.config = await api('/config');
2239
3840
  renderProjects();
2240
3841
  renderLaunchChips();
3842
+ paintAutoBtn();
2241
3843
  }
2242
3844
 
2243
3845
  // --- updates ----------------------------------------------------------------------------
@@ -2285,6 +3887,27 @@ async function installUpdate() {
2285
3887
  // --- keyboard ---------------------------------------------------------------------------
2286
3888
 
2287
3889
  const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
3890
+ // The user takes a card out of its list (done, archive, delete). The next card — the one below,
3891
+ // or the one above at the end of the list — gets focus and lands where the old one sat, so
3892
+ // triage continues on the same spot. Call before the request, while the card is still laid
3893
+ // out; the returned function applies the change locally and places the next card.
3894
+ function handOff(id) {
3895
+ const card = S.nodes.get(id)?.card;
3896
+ if (!card?.isConnected) return (apply) => (apply(), renderFeed());
3897
+ const nextUp = (card.nextElementSibling || card.previousElementSibling)?.dataset.id || feedIds().find((x) => x !== id);
3898
+ const r = card.getBoundingClientRect();
3899
+ const inset = topInset();
3900
+ const slot = r.bottom > inset && r.top < innerHeight ? Math.max(inset, r.top) : null;
3901
+ return (apply) => {
3902
+ if (S.open.has(id)) collapse(id);
3903
+ apply(); // don't wait for the live update to drop the card
3904
+ renderFeed();
3905
+ if (!nextUp || !S.nodes.has(nextUp)) return;
3906
+ setFocus(nextUp, { scroll: slot === null });
3907
+ const next = S.nodes.get(nextUp).card;
3908
+ if (slot !== null && next.isConnected) scrollByPx(next.getBoundingClientRect().top - slot);
3909
+ };
3910
+ }
2288
3911
 
2289
3912
  document.addEventListener('keydown', (e) => {
2290
3913
  // ⌘K / Ctrl+K works from anywhere, even mid-sentence in the launcher.
@@ -2300,7 +3923,7 @@ document.addEventListener('keydown', (e) => {
2300
3923
  }
2301
3924
  const typing = e.target.closest('input, textarea, select, [contenteditable="true"]');
2302
3925
  if (e.key === 'Escape') {
2303
- if (!$('#overlay').hidden) return closeOverlay();
3926
+ if (!$('#overlay').hidden) return $('#overlay .sx-back') ? $('#overlay .sx-back').click() : closeOverlay();
2304
3927
  if (typing) return e.target.blur();
2305
3928
  if (S.focus && S.open.has(S.focus)) {
2306
3929
  collapse(S.focus);
@@ -2329,6 +3952,10 @@ document.addEventListener('keydown', (e) => {
2329
3952
  if (s && (s.archived || (s.state !== 'running' && s.state !== 'waiting'))) setArchived(id, !s.archived);
2330
3953
  }
2331
3954
  else if (k === 'c' && S.open.size) collapseAll();
3955
+ else if (k === 'u' || k === 'home') {
3956
+ e.preventDefault();
3957
+ backToTop();
3958
+ }
2332
3959
  else if (k === 'm' && id) {
2333
3960
  e.preventDefault();
2334
3961
  rename(id);
@@ -2337,6 +3964,7 @@ document.addEventListener('keydown', (e) => {
2337
3964
  deleteSession(id);
2338
3965
  } else if (k === 't' && id) takeOver(id);
2339
3966
  else if (k === 'r' && e.shiftKey && id) rerun(id);
3967
+ else if (k === 's' && id && S.sessions.get(id)?.branch) shipDialog(id);
2340
3968
  else if (k === 'r' && id) replay(id);
2341
3969
  else if (k === '/' && id) {
2342
3970
  e.preventDefault();
@@ -2353,6 +3981,7 @@ document.addEventListener('keydown', (e) => {
2353
3981
 
2354
3982
  $('#settingsBtn').onclick = openSettings;
2355
3983
  $('#searchBtn').onclick = openPalette;
3984
+ $('#autoBtn').onclick = () => openAutomations({ tab: (S.config.triggers || []).length && !(S.config.recipes || []).length ? 'triggers' : 'recipes' });
2356
3985
  $('#updateBtn').onclick = installUpdate;
2357
3986
  $('#searchBtn').title = `Search (${combo('K')})`;
2358
3987
  $('#searchBtn .mod-k').textContent = combo('K');
@@ -2380,11 +4009,23 @@ setInterval(() => {
2380
4009
 
2381
4010
  // Sticky top bar gets a hairline once the page scrolls; "back to top" shows after a screen's worth.
2382
4011
  const toTop = $('#toTop');
4012
+ // Once the launcher scrolls under the top bar it docks there, folded to one line. The slot keeps
4013
+ // its full height in the feed so docking never shifts what you're reading.
4014
+ const launchSlot = $('#launchSlot');
4015
+ const dockLauncher = () => {
4016
+ const box = $('#launcher');
4017
+ const gone = launchSlot.getBoundingClientRect().bottom < $('#topbar').offsetHeight;
4018
+ if (gone === box.classList.contains('docked')) return;
4019
+ launchSlot.style.height = gone ? `${box.offsetHeight}px` : '';
4020
+ box.classList.toggle('docked', gone);
4021
+ };
2383
4022
  stage.addEventListener('scroll', () => {
2384
4023
  $('#topbar').classList.toggle('scrolled', stage.scrollTop > 4);
4024
+ dockLauncher();
2385
4025
  toTop.hidden = stage.scrollTop < stage.clientHeight * 0.75;
2386
4026
  }, { passive: true });
2387
- toTop.onclick = () => stage.scrollTo({ top: 0, behavior: 'smooth' });
4027
+ const backToTop = () => stage.scrollTo({ top: 0, behavior: 'smooth' });
4028
+ toTop.onclick = backToTop;
2388
4029
 
2389
4030
  if (/Electron/.test(navigator.userAgent)) document.body.classList.add('electron', /Mac/.test(navigator.platform) ? 'mac' : 'other');
2390
4031