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/CHANGELOG.md +22 -1
- package/README.md +14 -5
- package/bin/wisp.js +2 -2
- package/package.json +2 -2
- package/server/client.js +1 -1
- package/server/index.js +63 -8
- package/server/mcp-wisp.js +3 -3
- package/server/openapi.js +18 -2
- package/server/remote.js +103 -17
- package/server/runner.js +14 -6
- package/server/telemetry.js +58 -7
- package/web/css/app.css +53 -7
- package/web/index.html +2 -0
- package/web/js/app.js +299 -37
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"
|
|
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:
|
|
768
|
-
|
|
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
|
|
771
|
+
const runners = vsBtn.parentElement;
|
|
771
772
|
const paintCompare = () => {
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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, ...(
|
|
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 (
|
|
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 ?
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
2240
|
-
const
|
|
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
|
|