wisp-agents 0.12.0 → 0.13.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
@@ -21,6 +21,7 @@ const S = {
21
21
  showDone: load('wisp.showDone', false),
22
22
  showArchived: false,
23
23
  drafts: new Map(),
24
+ replyModes: new Map(), // session id → the mode its next follow-up switches to
24
25
  };
25
26
 
26
27
  const $ = (s, root = document) => root.querySelector(s);
@@ -667,8 +668,7 @@ function renderLauncher() {
667
668
  <button class="mode-btn" id="mode" aria-haspopup="menu"></button>
668
669
  <div class="runners">
669
670
  <button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
670
- <button class="vs-btn" id="compareBtn" aria-pressed="false"><span>vs</span>${icon('x')}</button>
671
- <button class="runner-btn" id="runner2" title="Second harness to compare with" aria-haspopup="dialog" hidden></button>
671
+ <button class="vs-btn vs-add" id="compareBtn"><span>vs</span>${icon('plus')}</button>
672
672
  </div>
673
673
  <button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
674
674
  <span class="grow"></span>
@@ -726,9 +726,8 @@ function renderLauncher() {
726
726
  const runnerBtn = $('#runner', box);
727
727
  const paintRunner = () => {
728
728
  paintRunnerBtn(runnerBtn, agent?.id, curModel, curEffort);
729
- runnerBtn.title = `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
729
+ runnerBtn.title = compare.length ? runnerTip(agent?.id, curModel, curEffort) : `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
730
730
  };
731
- paintRunner();
732
731
 
733
732
  // Worktree: each launch gets a fresh branch from the remote's default branch (git repos only).
734
733
  const wtBtn = $('#wtBtn', box);
@@ -764,31 +763,61 @@ function renderLauncher() {
764
763
  api('/config', { method: 'PATCH', body: { defaults: { subagents: team, teamPreset } } }).catch(() => {});
765
764
  });
766
765
 
767
- // Compare: a second harness gets the same task; picks here don't touch the launcher defaults.
768
- let compare = null;
766
+ // Compare: more harnesses get the same task (up to MAX_RUNNERS in all); picks here don't touch
767
+ // the launcher defaults. Each extra sits behind a "vs" that removes it; the button at the end
768
+ // adds the next one. Past two, the pill folds the names away and keeps the logos.
769
+ let compare = [];
769
770
  const vsBtn = $('#compareBtn', box);
770
- const runner2 = $('#runner2', box);
771
+ const runners = vsBtn.parentElement;
771
772
  const paintCompare = () => {
772
- vsBtn.parentElement.classList.toggle('cmp', !!compare);
773
- vsBtn.setAttribute('aria-pressed', String(!!compare));
774
- vsBtn.title = compare ? 'Stop comparing' : 'Compare: run the same task on a second harness, side by side';
775
- runner2.hidden = !compare;
776
- if (compare) paintRunnerBtn(runner2, compare.agent, compare.model, compare.effort);
773
+ const n = compare.length;
774
+ runners.classList.toggle('cmp', n > 0);
775
+ runners.classList.toggle('many', n > 1);
776
+ for (const x of $$('.cmp-x', runners)) x.remove();
777
+ compare.forEach((c, i) => {
778
+ const name = agentName(c.agent);
779
+ const del = el(`<button class="vs-btn vs-del cmp-x" data-i="${i}" title="Remove ${esc(name)} from the comparison" aria-label="Remove ${esc(name)}"><span>vs</span>${icon('x')}</button>`);
780
+ const btn = el(`<button class="runner-btn cmp-x" data-i="${i}" aria-haspopup="dialog"></button>`);
781
+ paintRunnerBtn(btn, c.agent, c.model, c.effort);
782
+ btn.title = runnerTip(c.agent, c.model, c.effort);
783
+ vsBtn.before(del, btn);
784
+ });
785
+ vsBtn.hidden = n >= MAX_RUNNERS - 1;
786
+ vsBtn.title = n ? `Add another harness (up to ${MAX_RUNNERS} side by side)` : 'Compare: run the same task on another harness, side by side';
787
+ paintRunner(); // its tooltip spells everything out once the names fold away
777
788
  };
778
- paintCompare();
779
789
  vsBtn.onclick = () => {
780
- if (compare) compare = null;
781
- else {
782
- const other = harnessesAt(launchWhere).find((a) => a.available && a.id !== agent?.id) || agent;
783
- compare = { agent: other?.id, model: savedModel(other?.id), effort: savedEffort(other?.id, savedModel(other?.id)) };
784
- }
790
+ // The next harness nobody in the pill uses yet; once they're all in, another copy of the lead.
791
+ const used = new Set([agent?.id, ...compare.map((c) => c.agent)]);
792
+ const here = harnessesAt(launchWhere).filter((a) => a.available);
793
+ const next = here.find((a) => !used.has(a.id)) || agent;
794
+ const model = savedModel(next?.id);
795
+ compare = [...compare, { agent: next?.id, model, effort: savedEffort(next?.id, model) }];
785
796
  paintCompare();
797
+ // Its "vs" lands right under the pointer: it can't remove anything until the pointer has left.
798
+ const fresh = $$('.vs-del', runners).at(-1);
799
+ fresh?.classList.add('fresh');
800
+ fresh?.addEventListener('pointerleave', () => fresh.classList.remove('fresh'), { once: true });
801
+ // Straight into picking it when it's a repeat, since that's rarely the one you want.
802
+ if (used.has(next?.id)) $$('.runner-btn.cmp-x', runners).at(-1)?.click();
786
803
  };
787
- runner2.onclick = () =>
788
- runnerPicker(runner2, { ...compare, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
789
- compare = { agent: id, model, effort };
804
+ runners.addEventListener('click', (e) => {
805
+ const x = e.target.closest('.cmp-x');
806
+ if (!x) return;
807
+ const i = Number(x.dataset.i);
808
+ if (x.classList.contains('vs-del')) {
809
+ if (x.classList.contains('fresh')) return;
810
+ compare = compare.filter((_, k) => k !== i);
811
+ paintCompare();
812
+ (compare.length ? $$('.vs-del', runners)[Math.min(i, compare.length - 1)] : vsBtn).focus();
813
+ return;
814
+ }
815
+ runnerPicker(x, { ...compare[i], savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
816
+ compare = compare.map((c, k) => (k === i ? { agent: id, model, effort } : c));
790
817
  paintCompare();
791
818
  });
819
+ });
820
+ paintCompare();
792
821
  runnerBtn.onclick = () =>
793
822
  runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
794
823
  agent = harnessesAt(launchWhere).find((a) => a.id === id);
@@ -807,13 +836,18 @@ function renderLauncher() {
807
836
  const launch = async () => {
808
837
  const prompt = ta.value.trim();
809
838
  if (!prompt) return ta.focus();
839
+ if (isFeedback(prompt)) {
840
+ ta.value = '';
841
+ ta.dispatchEvent(new Event('input'));
842
+ return feedbackCommand(prompt, { restore: () => !ta.value && ((ta.value = prompt), ta.dispatchEvent(new Event('input'))) });
843
+ }
810
844
  // One run per place picked under "Run on", each in the project picked for it.
811
845
  const targets = launchWhere.map((k) => ({ k, m: k ? machineById(k) : null, project: projectById(launchPicks[k]) }));
812
846
  const unset = targets.find((t) => !t.project);
813
847
  if (unset) return openProjects({ focusAdd: true, machine: unset.k || null });
814
848
  const off = targets.find((t) => t.m && t.m.state !== 'online');
815
849
  if (off) return offlineToast(off.k);
816
- const wanted = [agent?.id, ...(compare ? [compare.agent] : [])];
850
+ const wanted = [agent?.id, ...compare.map((c) => c.agent)];
817
851
  for (const t of targets) {
818
852
  const list = agentsAt(t.k);
819
853
  const missing = list && wanted.find((id) => !list.some((x) => x.id === id && x.available));
@@ -828,7 +862,7 @@ function renderLauncher() {
828
862
  const cmp = compare;
829
863
  const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
830
864
  const pending = targets.map((t) => addPending(prompt, t, runner));
831
- if (compare) (compare = null), paintCompare();
865
+ if (cmp.length) (compare = []), paintCompare();
832
866
  ta.value = '';
833
867
  S.drafts.delete('home');
834
868
  ta.dispatchEvent(new Event('input'));
@@ -847,7 +881,7 @@ function renderLauncher() {
847
881
  targets.map((t) =>
848
882
  api('/sessions', {
849
883
  method: 'POST',
850
- body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp ? false : undefined, compare: cmp ? [cmp] : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
884
+ body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
851
885
  }),
852
886
  ),
853
887
  );
@@ -861,7 +895,7 @@ function renderLauncher() {
861
895
  if (!started.length) {
862
896
  if (!ta.value) ((ta.value = prompt), ta.dispatchEvent(new Event('input')));
863
897
  tray.restore(files);
864
- if (cmp && !compare) (compare = cmp), paintCompare();
898
+ if (cmp.length && !compare.length) (compare = cmp), paintCompare();
865
899
  return;
866
900
  }
867
901
  askNotify();
@@ -897,6 +931,15 @@ function renderLauncher() {
897
931
  });
898
932
  }
899
933
 
934
+ // One launch runs on at most this many harnesses side by side (the server's MAX_COMPARE).
935
+ const MAX_RUNNERS = 4;
936
+ // A runner button's tooltip, for when the pill is too full to show names.
937
+ function runnerTip(agentId, model, effort) {
938
+ const a = agentById(agentId);
939
+ const m = (a?.models || []).find((x) => x.id === model);
940
+ return [a?.name || agentName(agentId), m?.label || model || 'Default model', effort && effortLabel(effort)].filter(Boolean).join(' · ');
941
+ }
942
+
900
943
  // The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
901
944
  function paintRunnerBtn(btn, agentId, model, effort) {
902
945
  const a = agentById(agentId);
@@ -1313,7 +1356,7 @@ function modeMenu(anchor, current, pick) {
1313
1356
  if (!b) return;
1314
1357
  close();
1315
1358
  pick(b.dataset.mode);
1316
- $('#prompt')?.focus();
1359
+ if (anchor.id === 'mode') $('#prompt')?.focus();
1317
1360
  };
1318
1361
  menu.addEventListener('keydown', (ev) => {
1319
1362
  const items = $$('button', menu);
@@ -1750,7 +1793,7 @@ function renderBody(id, { fresh }) {
1750
1793
  ${mode && mode.id !== 'edits' ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
1751
1794
  ${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
1752
1795
  ${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>` : ''}
1753
- ${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>` : ''}`;
1796
+ ${s.origin ? `<span class="m" title="${s.origin.kind === 'telegram' ? 'Started from Telegram' : `Launched by a ${esc(s.origin.kind)}`}">${icon({ trigger: 'replay', telegram: 'send' }[s.origin.kind] || 'spark')}${esc(s.origin.name)}</span>` : ''}`;
1754
1797
  const live = s.state === 'running' || s.state === 'waiting';
1755
1798
  // Ship sits in the top bar and again next to the composer, so it's there after reading the result.
1756
1799
  renderPrStrip(id);
@@ -1770,7 +1813,14 @@ function renderBody(id, { fresh }) {
1770
1813
  earlier.textContent = `Show ${hiddenCount} earlier turn${hiddenCount === 1 ? '' : 's'}`;
1771
1814
  // A new turn re-folds everything older, so the latest exchange is what's open by default.
1772
1815
  const latestId = s.turns.at(-1)?.id;
1773
- if (n.latestTurn !== latestId) (n.unfolded.clear(), (n.latestTurn = latestId));
1816
+ const newTurn = n.latestTurn !== latestId;
1817
+ if (newTurn) (n.unfolded.clear(), (n.latestTurn = latestId));
1818
+ // Folding the last answer when a follow-up starts can take a screen or more out of the card
1819
+ // above the composer. Hold the composer still, so the new exchange lands where the user was
1820
+ // typing instead of the whole card sliding off the top of the screen.
1821
+ const watched = watching(id);
1822
+ const dock = watched && newTurn && !fresh ? $('.it-dock', body) : null;
1823
+ const dockTop = dock?.getBoundingClientRect().top;
1774
1824
  const keep = new Set();
1775
1825
  let firstNew = null;
1776
1826
  turns.forEach((t) => {
@@ -1794,7 +1844,8 @@ function renderBody(id, { fresh }) {
1794
1844
  for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
1795
1845
 
1796
1846
  updateDock(id);
1797
- if (firstNew && watching(id)) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
1847
+ if (dock?.isConnected) scrollByPx(dock.getBoundingClientRect().top - dockTop);
1848
+ if (firstNew && watched) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
1798
1849
  }
1799
1850
 
1800
1851
  // Whether the user is reading a card, not just catching its edge at the top or bottom of the
@@ -1860,6 +1911,14 @@ function renderFoldedTurn(t, unfold) {
1860
1911
  return node;
1861
1912
  }
1862
1913
 
1914
+ // A follow-up that switched the mode says so, next to the model it ran on.
1915
+ function modeSwitch(s, t) {
1916
+ const i = s.turns.indexOf(t);
1917
+ const prev = s.turns.slice(0, i).findLast((x) => x.mode)?.mode;
1918
+ const m = t.mode && prev && t.mode !== prev && MODES.find((x) => x.id === t.mode);
1919
+ return m ? `<span class="t-mode m-${m.id}" title="Switched from ${esc(MODES.find((x) => x.id === prev)?.label || prev)} to ${m.label} for this follow-up">${icon(m.icon)}${m.label}</span>` : '';
1920
+ }
1921
+
1863
1922
  // `fold` is set for older turns the user unfolded, so they can tuck them away again.
1864
1923
  function renderTurn(s, t, isLatest, fold) {
1865
1924
  const node = el(`<article class="turn ${t.rolledBackAt ? 'is-rolled-back' : ''}" data-turn="${t.id}"></article>`);
@@ -1873,6 +1932,7 @@ function renderTurn(s, t, isLatest, fold) {
1873
1932
  ${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>` : ''}
1874
1933
  <div class="ask-foot">
1875
1934
  ${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}
1935
+ ${modeSwitch(s, t)}
1876
1936
  <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
1877
1937
  ${cpLabel ? `<span class="cp-sha" title="Git checkpoint before this turn (${esc(cp.head)}${cp.dirty ? ' + uncommitted changes' : ''})">${icon('branch')}${esc(cpLabel)}</span>` : ''}
1878
1938
  ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
@@ -2001,6 +2061,7 @@ function renderTrail(s, t) {
2001
2061
  const st = t.stats || {};
2002
2062
  const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (t.changes?.length || t.checkpoint?.commit);
2003
2063
  const rolledBack = !!t.rolledBackAt;
2064
+ const rated = t.feedback?.rating;
2004
2065
  const node = el(`
2005
2066
  <div class="trail">
2006
2067
  <div class="trail-bar">
@@ -2012,6 +2073,7 @@ function renderTrail(s, t) {
2012
2073
  ${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>` : ''}
2013
2074
  ${acts.length ? `<button class="link xs" data-tab="activity">Activity</button>` : ''}
2014
2075
  ${t.presentation?.notes ? `<button class="link xs" data-tab="notes">Agent notes</button>` : ''}
2076
+ ${feedbackOn() ? `<span class="rate ${rated ? 'rated' : ''}">${['up', 'down'].map((r) => `<button class="rate-btn ${rated === r ? 'on' : ''}" data-rate="${r}" title="${r === 'up' ? 'Good result' : 'Bad result'}: tell the wisp team" aria-pressed="${rated === r}">${icon(`thumb-${r}`)}</button>`).join('')}</span>` : ''}
2015
2077
  </div>
2016
2078
  <div class="trail-body" hidden></div>
2017
2079
  </div>`);
@@ -2021,6 +2083,20 @@ function renderTrail(s, t) {
2021
2083
  if (e.target.closest('[data-a="rollback"]')) {
2022
2084
  return rollbackTurn(s.id, t.id, rolledBack);
2023
2085
  }
2086
+ const rb = e.target.closest('[data-rate]');
2087
+ if (rb) {
2088
+ const next = t.feedback?.rating === rb.dataset.rate ? null : rb.dataset.rate;
2089
+ rateTurn(s, t, node, next);
2090
+ if (next !== 'down' && open === 'rate') ((open = null), (body.hidden = true));
2091
+ if (next === 'down') {
2092
+ open = 'rate';
2093
+ $$('[data-tab]', node).forEach((x) => x.classList.remove('on'));
2094
+ body.hidden = false;
2095
+ body.innerHTML = '';
2096
+ body.append(whyForm(s, t, () => ((open = null), (body.hidden = true))));
2097
+ }
2098
+ return;
2099
+ }
2024
2100
  const b = e.target.closest('[data-tab]');
2025
2101
  if (!b) return;
2026
2102
  const tab = b.dataset.tab;
@@ -2074,6 +2150,7 @@ function renderDock(id) {
2074
2150
  <textarea class="reply" rows="1" spellcheck="false"></textarea>
2075
2151
  <div class="c-bar">
2076
2152
  <button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
2153
+ <button class="mode-btn c-mode" aria-haspopup="menu"></button>
2077
2154
  <span class="c-hint"></span>
2078
2155
  <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>
2079
2156
  <button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
@@ -2091,6 +2168,8 @@ function renderDock(id) {
2091
2168
  $('.c-done', comp).onclick = () => toggleDone(id);
2092
2169
  $('.c-ship', comp).onclick = () => shipDialog(id);
2093
2170
  $('.c-queue', comp).onclick = toggleQueue;
2171
+ const modeBtn = $('.c-mode', comp);
2172
+ modeBtn.onclick = () => modeMenu(modeBtn, replyMode(id), (m) => (setReplyMode(id, m), ta.focus()));
2094
2173
  ta.value = S.drafts.get(id) || '';
2095
2174
  ta.addEventListener('input', () => S.drafts.set(id, ta.value));
2096
2175
  ta.addEventListener('focus', () => setFocus(id, { scroll: false }));
@@ -2099,10 +2178,37 @@ function renderDock(id) {
2099
2178
  e.preventDefault();
2100
2179
  sendReply(id, ta.value);
2101
2180
  }
2181
+ if (e.key === 'Tab' && e.shiftKey) {
2182
+ e.preventDefault();
2183
+ setReplyMode(id, MODES[(MODES.findIndex((m) => m.id === replyMode(id)) + 1) % MODES.length].id);
2184
+ }
2102
2185
  });
2103
2186
  $('.send', comp).onclick = () => sendReply(id, ta.value);
2104
2187
  }
2105
2188
 
2189
+ // The mode the next follow-up runs in: the session's own until the user picks another here.
2190
+ // The pick goes out with that follow-up and stays from then on.
2191
+ const replyMode = (id) => S.replyModes.get(id) || S.full.get(id)?.mode || 'edits';
2192
+
2193
+ function setReplyMode(id, mode) {
2194
+ if (mode === S.full.get(id)?.mode) S.replyModes.delete(id);
2195
+ else S.replyModes.set(id, mode);
2196
+ updateDock(id);
2197
+ }
2198
+
2199
+ function paintReplyMode(id, btn) {
2200
+ const s = S.full.get(id);
2201
+ const m = MODES.find((x) => x.id === replyMode(id)) || MODES[1];
2202
+ const from = s && S.replyModes.has(id) && MODES.find((x) => x.id === s.mode);
2203
+ btn.className = `mode-btn c-mode m-${m.id}${from ? ' changed' : ''}`;
2204
+ btn.innerHTML = `${icon(m.icon)}${m.id === 'edits' && !from ? '' : `<span>${m.label}</span>`}`;
2205
+ btn.title = from
2206
+ ? `${m.label}: ${m.hint}\nYour next follow-up switches this task from ${from.label} to ${m.label}. Shift+Tab to switch`
2207
+ : `${m.label}: ${m.hint}\nPick another mode for your next follow-up. Shift+Tab to switch`;
2208
+ // Subagents run in their lead's mode; the tour has nothing to run.
2209
+ btn.hidden = !s || s.agent === 'wisp' || !!s.parentId;
2210
+ }
2211
+
2106
2212
  function updateDock(id) {
2107
2213
  const s = S.full.get(id);
2108
2214
  const n = S.nodes.get(id);
@@ -2123,6 +2229,7 @@ function updateDock(id) {
2123
2229
  ? 'Follow up to reopen…'
2124
2230
  : `Follow up with ${name}…`;
2125
2231
  $('.c-hint', n.card).innerHTML = s.queue?.length ? `${s.queue.length} queued` : '';
2232
+ paintReplyMode(id, $('.c-mode', n.card));
2126
2233
  renderQueued(s, $('.c-queued', n.card), name);
2127
2234
  const q = $('.c-queue', n.card);
2128
2235
  q.hidden = tour || s.state !== 'running';
@@ -2199,20 +2306,28 @@ async function sendReply(id, text) {
2199
2306
  const n = S.nodes.get(id);
2200
2307
  text = (text || '').trim();
2201
2308
  if (!s || !text) return;
2309
+ const ta = n && $('.reply', n.card);
2310
+ if (isFeedback(text)) {
2311
+ if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
2312
+ S.drafts.delete(id);
2313
+ return feedbackCommand(text, { sessionId: id, restore: () => ta?.isConnected && !ta.value && ((ta.value = text), ta.dispatchEvent(new Event('input'))) });
2314
+ }
2202
2315
  if (n?.tray?.busy) toast('Waiting for uploads…');
2203
2316
  const attachments = n?.tray ? await n.tray.ids() : [];
2204
- const answering = !!s.ask && !attachments.length;
2317
+ const mode = S.replyModes.get(id);
2318
+ // An answer goes to the run that asked; a new mode needs a new run, so that's a follow-up.
2319
+ const answering = !!s.ask && !attachments.length && !mode;
2205
2320
  // Optimistic: the composer empties and the card goes to Working before the server answers
2206
2321
  // (it may first have to bring back an archived worktree, or reach another machine).
2207
- const ta = n && $('.reply', n.card);
2208
2322
  if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
2209
2323
  S.drafts.delete(id);
2324
+ S.replyModes.delete(id);
2210
2325
  const files = n?.tray?.take();
2211
- const undo = startOptimistic(id, text, files, { answering });
2326
+ const undo = startOptimistic(id, text, files, { answering, mode });
2212
2327
  try {
2213
2328
  const r = answering
2214
2329
  ? await api(`/sessions/${id}/answer`, { method: 'POST', body: { text } })
2215
- : await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing() } });
2330
+ : await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing(), mode } });
2216
2331
  if (undo) scheduleRefetch(id); // the summary that follows says "running" too, so it won't refetch by itself
2217
2332
  if (r.queued)
2218
2333
  toast(r.ahead ? `Queued behind ${r.ahead} earlier follow-up${r.ahead > 1 ? 's' : ''} — they go out first, in order.` : 'Queued — it goes out as soon as the agent finishes.');
@@ -2220,6 +2335,7 @@ async function sendReply(id, text) {
2220
2335
  undo?.();
2221
2336
  // Give the message back, unless the user has already started typing another one.
2222
2337
  if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
2338
+ if (mode && !S.replyModes.has(id)) setReplyMode(id, mode);
2223
2339
  n?.tray?.restore(files);
2224
2340
  toast(esc(err.message), { tone: 'bad' });
2225
2341
  }
@@ -2228,7 +2344,7 @@ async function sendReply(id, text) {
2228
2344
  // Show a follow-up as started right away: the card moves to Working with the prompt as a live
2229
2345
  // turn. Only when it will start now, not when it queues or interrupts a run already working.
2230
2346
  // Returns an undo for a failed send, or null when nothing changed.
2231
- function startOptimistic(id, text, files, { answering }) {
2347
+ function startOptimistic(id, text, files, { answering, mode }) {
2232
2348
  const full = S.full.get(id);
2233
2349
  const sum = S.sessions.get(id);
2234
2350
  if (!full || !sum) return null;
@@ -2236,8 +2352,9 @@ function startOptimistic(id, text, files, { answering }) {
2236
2352
  if (live && !answering) return null;
2237
2353
  const now = Date.now();
2238
2354
  const turn = { id: `pending-${now}`, prompt: text, status: 'running', startedAt: now, activity: [], attachments: (files || []).filter((f) => f.id).map((f) => ({ id: f.id, name: f.name, mime: f.mime })) };
2239
- const nextFull = { ...full, state: 'running', ask: null, turns: answering ? full.turns : [...full.turns, turn] };
2240
- const nextSum = { ...sum, state: 'running', live: answering ? 'Picking up your answer' : 'Starting up', updatedAt: now };
2355
+ if (mode) turn.mode = mode;
2356
+ const nextFull = { ...full, state: 'running', ask: null, turns: answering ? full.turns : [...full.turns, turn], ...(mode && { mode }) };
2357
+ const nextSum = { ...sum, state: 'running', live: answering ? 'Picking up your answer' : 'Starting up', updatedAt: now, ...(mode && { mode }) };
2241
2358
  const apply = (f, sm) => {
2242
2359
  S.full.set(id, f);
2243
2360
  S.sessions.set(id, sm);
@@ -3610,6 +3727,8 @@ async function openPhone({ host } = {}) {
3610
3727
  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>`);
3611
3728
  const paint = () => {
3612
3729
  const tg = r.telegram;
3730
+ const projects = S.config.projects || [];
3731
+ const lastUsed = [...projects].sort(recentFirst)[0];
3613
3732
  $('.ph-body', node).innerHTML = `
3614
3733
  <div class="settings-modal ph-sec">
3615
3734
  <div class="setting"><span>Send me</span>
@@ -3637,6 +3756,22 @@ async function openPhone({ host } = {}) {
3637
3756
  ? `Now send <code>/link ${esc(tg.linkCode)}</code> to your bot. Only the chat that sends this code gets linked.`
3638
3757
  : 'Create a bot with @BotFather, paste its token here and switch Telegram on.'
3639
3758
  }</p>
3759
+ ${
3760
+ tg.linked
3761
+ ? `<div class="ph-start">
3762
+ <div class="ph-row"><span class="grow">Start tasks from Telegram</span><button class="switch" data-sw="tgStart" role="switch" aria-checked="${tg.allowStart}"></button></div>
3763
+ ${
3764
+ tg.allowStart
3765
+ ? `<div class="settings-modal"><label><span>Default project</span><select class="input" data-tg="project">
3766
+ <option value="">Last used${lastUsed ? ` (${esc(lastUsed.name)})` : ''}</option>
3767
+ ${projects.map((p) => `<option value="${esc(p.id)}" ${p.id === tg.project ? 'selected' : ''}>${esc(p.name)}${p.machine ? ` · ${esc(placeName(p.machine))}` : ''}</option>`).join('')}
3768
+ </select></label></div>
3769
+ <p class="hint">Send <code>/new fix the flaky login test</code> to start a task in the default project, or <code>/new @${esc(projects[0]?.name || 'project')} …</code> to pick one; <code>/projects</code> lists them. Tasks use the launcher's harness, model and mode. Only your linked chat can start them.</p>`
3770
+ : '<p class="hint">Off: the bot only answers and follows up on tasks you started here.</p>'
3771
+ }
3772
+ </div>`
3773
+ : ''
3774
+ }
3640
3775
  </section>
3641
3776
  <div class="ship-foot"><span class="grow"></span><button class="ghost sm" data-act="test">${icon('send')}Send a test</button></div>`;
3642
3777
  };
@@ -3644,7 +3779,8 @@ async function openPhone({ host } = {}) {
3644
3779
  const t = e.target;
3645
3780
  if (t.dataset.ev) put({ events: $$('[data-ev]', node).filter((x) => x.checked).map((x) => x.dataset.ev) });
3646
3781
  if (t.dataset.ntfy) put({ ntfy: { ...r.ntfy, [t.dataset.ntfy]: t.value.trim() } });
3647
- if (t.dataset.tg) put({ telegram: { enabled: r.telegram.enabled, token: t.value.trim() } });
3782
+ if (t.dataset.tg === 'token') put({ telegram: { enabled: r.telegram.enabled, token: t.value.trim() } });
3783
+ if (t.dataset.tg === 'project') put({ telegram: { project: t.value } });
3648
3784
  });
3649
3785
  node.addEventListener('click', async (e) => {
3650
3786
  const sw = e.target.closest('[data-sw]')?.dataset.sw;
@@ -3654,6 +3790,7 @@ async function openPhone({ host } = {}) {
3654
3790
  return put({ ntfy: { ...r.ntfy, enabled: !r.ntfy.enabled, topic } });
3655
3791
  }
3656
3792
  if (sw === 'telegram') return put({ telegram: { enabled: !r.telegram.enabled, token: r.telegram.token } });
3793
+ if (sw === 'tgStart') return put({ telegram: { allowStart: !r.telegram.allowStart } });
3657
3794
  const act = e.target.closest('[data-act]')?.dataset.act;
3658
3795
  if (act === 'unlink') return put({ telegram: { enabled: r.telegram.enabled, token: r.telegram.token, unlink: true } });
3659
3796
  if (act === 'test') {
@@ -4450,6 +4587,7 @@ function shortcuts() {
4450
4587
  ['1–9', 'Pick an answer when asked'],
4451
4588
  ['↵', 'Launch / send'],
4452
4589
  ['⇧↵', 'New line'],
4590
+ ['/feedback', 'Send feedback to the wisp team (in any prompt box)'],
4453
4591
  ['C', 'Collapse all'],
4454
4592
  ['U', 'Back to top'],
4455
4593
  ['Esc', 'Close / collapse'],
@@ -4458,6 +4596,129 @@ function shortcuts() {
4458
4596
  overlay(node);
4459
4597
  }
4460
4598
 
4599
+ // --- feedback ---------------------------------------------------------------------------
4600
+ // Three ways in, none of them taking room: thumbs at the end of a turn's trail, "/feedback …" in
4601
+ // any prompt box, and "Send feedback" in ⌘K. It all goes to the wisp team (PostHog), with the
4602
+ // run's harness, model and status but never the prompt or the result.
4603
+
4604
+ const feedbackOn = () => S.config?.feedback !== false;
4605
+ const FEEDBACK_CMD = /^\/feedback(?:\s|$)/i;
4606
+ const isFeedback = (text) => FEEDBACK_CMD.test(text);
4607
+ const THANKS = 'Thanks! Sent to the wisp team.';
4608
+
4609
+ // The box is already empty; `restore` puts the text back if the server refuses it.
4610
+ function feedbackCommand(text, { sessionId, restore } = {}) {
4611
+ if (!feedbackOn()) return (restore?.(), toast('Feedback is turned off on this install.', { tone: 'bad' }));
4612
+ const note = text.replace(FEEDBACK_CMD, '').trim();
4613
+ if (!note) return openFeedback({ sessionId });
4614
+ postFeedback({ text: note, sessionId, source: 'command' }, { onFail: restore });
4615
+ }
4616
+
4617
+ // Optimistic: thanks at once. Offline, the server keeps it and sends it later.
4618
+ function postFeedback(body, { onFail, quiet = false } = {}) {
4619
+ if (!quiet) toast(THANKS);
4620
+ return api('/feedback', { method: 'POST', body })
4621
+ .then((r) => {
4622
+ if (!r.sent && r.available !== false) toast('Saved: it goes out once wisp is back online.');
4623
+ })
4624
+ .catch((err) => {
4625
+ onFail?.();
4626
+ toast(esc(err.message), { tone: 'bad' });
4627
+ });
4628
+ }
4629
+
4630
+ function paintRate(node, rating) {
4631
+ $('.rate', node)?.classList.toggle('rated', !!rating);
4632
+ $$('[data-rate]', node).forEach((b) => {
4633
+ b.classList.toggle('on', b.dataset.rate === rating);
4634
+ b.setAttribute('aria-pressed', b.dataset.rate === rating);
4635
+ });
4636
+ }
4637
+
4638
+ function rateTurn(s, t, node, rating) {
4639
+ const prev = t.feedback;
4640
+ t.feedback = rating ? { rating, at: Date.now() } : undefined;
4641
+ paintRate(node, rating);
4642
+ if (rating === 'up') {
4643
+ const tip = $('.rate', node);
4644
+ tip.classList.remove('thanked');
4645
+ void tip.offsetWidth; // restart the animation
4646
+ tip.classList.add('thanked');
4647
+ }
4648
+ postFeedback({ rating, sessionId: s.id, turnId: t.id, source: 'turn' }, {
4649
+ quiet: true,
4650
+ onFail: () => ((t.feedback = prev), paintRate(node, prev?.rating)),
4651
+ });
4652
+ }
4653
+
4654
+ // After 👎: one optional line on what went wrong. The rating is already sent.
4655
+ function whyForm(s, t, close) {
4656
+ const f = el(`
4657
+ <form class="why">
4658
+ <textarea class="textarea" rows="2" placeholder="What went wrong? Optional, goes to the wisp team" maxlength="4000"></textarea>
4659
+ <div class="why-foot"><span class="muted xs">Sent with the harness and model, not your prompt or code.</span><button type="button" class="link xs" data-a="skip">Skip</button><button class="primary xs">Send</button></div>
4660
+ </form>`);
4661
+ const ta = $('textarea', f);
4662
+ f.onsubmit = (e) => {
4663
+ e.preventDefault();
4664
+ const text = ta.value.trim();
4665
+ if (!text) return close();
4666
+ close();
4667
+ postFeedback({ rating: 'down', text, sessionId: s.id, turnId: t.id, source: 'turn' }, { onFail: () => openFeedback({ sessionId: s.id, text, rating: 'down' }) });
4668
+ };
4669
+ ta.onkeydown = (e) => {
4670
+ if (e.key === 'Enter' && !e.shiftKey) (e.preventDefault(), f.requestSubmit());
4671
+ if (e.key === 'Escape') (e.stopPropagation(), close());
4672
+ };
4673
+ $('[data-a="skip"]', f).onclick = () => {
4674
+ close();
4675
+ toast(THANKS);
4676
+ };
4677
+ requestAnimationFrame(() => ta.focus());
4678
+ return f;
4679
+ }
4680
+
4681
+ function openFeedback({ sessionId, text = '', rating = null } = {}) {
4682
+ if (!feedbackOn()) return toast('Feedback is turned off on this install.', { tone: 'bad' });
4683
+ const node = el(`
4684
+ <form class="modal fb-modal">
4685
+ <header><h2>Send feedback</h2><button type="button" class="icon-btn close">${icon('x')}</button></header>
4686
+ <div class="fb-rate"><span>How is wisp working for you?</span>
4687
+ ${['up', 'down'].map((r) => `<button type="button" class="rate-btn" data-rate="${r}" aria-pressed="false" title="${r === 'up' ? 'Good' : 'Not good'}">${icon(`thumb-${r}`)}</button>`).join('')}
4688
+ </div>
4689
+ <textarea class="textarea" name="text" rows="5" maxlength="4000" placeholder="What works, what breaks, what you wish it did…"></textarea>
4690
+ <div class="ship-foot"><span class="muted xs">Goes to the wisp team with your wisp version and OS. Not your code or prompts. Tip: type <code>/feedback …</code> in any prompt box.</span><button class="primary sm" data-a="send">${icon('send')}Send</button></div>
4691
+ </form>`);
4692
+ const close = overlay(node);
4693
+ $('.close', node).onclick = close;
4694
+ const ta = $('textarea', node);
4695
+ const send = $('[data-a="send"]', node);
4696
+ ta.value = text;
4697
+ const paint = () => {
4698
+ paintRate(node, rating);
4699
+ send.disabled = !rating && !ta.value.trim();
4700
+ };
4701
+ $$('[data-rate]', node).forEach((b) => {
4702
+ b.onclick = () => {
4703
+ rating = rating === b.dataset.rate ? null : b.dataset.rate;
4704
+ paint();
4705
+ };
4706
+ });
4707
+ ta.oninput = paint;
4708
+ ta.onkeydown = (e) => {
4709
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) (e.preventDefault(), node.requestSubmit());
4710
+ };
4711
+ node.onsubmit = (e) => {
4712
+ e.preventDefault();
4713
+ const note = ta.value.trim();
4714
+ if (!rating && !note) return;
4715
+ close();
4716
+ postFeedback({ rating: rating || undefined, text: note, sessionId, source: 'dialog' }, { onFail: () => openFeedback({ sessionId, text: note, rating }) });
4717
+ };
4718
+ paint();
4719
+ requestAnimationFrame(() => ta.focus());
4720
+ }
4721
+
4461
4722
  // --- command palette ------------------------------------------------------------------
4462
4723
 
4463
4724
  const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
@@ -4545,6 +4806,7 @@ function paletteItems() {
4545
4806
  { group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
4546
4807
  { group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
4547
4808
  );
4809
+ if (feedbackOn()) items.push({ group: a, label: 'Send feedback…', icon: 'thumb-up', keywords: 'feedback bug report idea suggestion thumbs contact team', run: () => openFeedback() });
4548
4810
 
4549
4811
  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) }) });
4550
4812