wisp-agents 0.16.0 → 0.18.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 +38 -1
- package/README.md +6 -5
- package/bin/wisp.js +4 -2
- package/package.json +2 -2
- package/server/adapters/antigravity.js +5 -1
- package/server/adapters/claude.js +48 -5
- package/server/adapters/codex.js +3 -0
- package/server/adapters/cursor.js +2 -0
- package/server/adapters/grok.js +15 -0
- package/server/adapters/index.js +9 -1
- package/server/adapters/opencode.js +31 -6
- package/server/client.js +1 -1
- package/server/git.js +114 -2
- package/server/handoff.js +2 -1
- package/server/machines.js +2 -2
- package/server/mcp-ask.js +113 -8
- package/server/mentions.js +39 -0
- package/server/openapi.js +21 -3
- package/server/protocol.js +8 -1
- package/server/render.js +3 -1
- package/server/routes/internal.js +121 -1
- package/server/routes/meta.js +1 -1
- package/server/routes/sessions.js +12 -0
- package/server/routes/settings.js +51 -4
- package/server/routes/ship.js +24 -2
- package/server/runner.js +142 -22
- package/server/services/auto.js +12 -1
- package/server/services/launch.js +12 -8
- package/server/services/phone.js +1 -1
- package/server/services/ship.js +83 -4
- package/server/store.js +23 -2
- package/server/triggers.js +1 -1
- package/server/usage.js +94 -0
- package/server/welcome.js +2 -2
- package/web/css/composer.css +22 -0
- package/web/css/feed.css +27 -0
- package/web/css/launcher.css +17 -0
- package/web/css/layout.css +10 -2
- package/web/css/modals.css +8 -0
- package/web/css/palette.css +22 -0
- package/web/css/ship.css +42 -2
- package/web/index.html +16 -5
- package/web/js/app.js +14 -3
- package/web/js/blocks.js +46 -0
- package/web/js/core/state.js +2 -1
- package/web/js/features/actions.js +9 -4
- package/web/js/features/automations.js +1 -1
- package/web/js/features/clones.js +79 -0
- package/web/js/features/compare.js +2 -1
- package/web/js/features/composer.js +11 -7
- package/web/js/features/feed.js +3 -3
- package/web/js/features/launcher.js +150 -28
- package/web/js/features/live.js +3 -3
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +1 -1
- package/web/js/features/project-list.js +99 -0
- package/web/js/features/projects.js +218 -34
- package/web/js/features/runners.js +3 -1
- package/web/js/features/settings.js +16 -2
- package/web/js/features/setup.js +3 -3
- package/web/js/features/ship.js +100 -24
- package/web/js/features/turns.js +44 -13
- package/web/js/mention-text.js +59 -0
|
@@ -6,7 +6,7 @@ import { api } from '../core/api.js';
|
|
|
6
6
|
import * as bus from '../core/bus.js';
|
|
7
7
|
import { $, $$, el, icon } from '../core/dom.js';
|
|
8
8
|
import { money, pct } from '../core/format.js';
|
|
9
|
-
import { S } from '../core/state.js';
|
|
9
|
+
import { S, setAgents } from '../core/state.js';
|
|
10
10
|
import { overlay } from '../ui/overlay.js';
|
|
11
11
|
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
12
12
|
import { openRouter } from './auto.js';
|
|
@@ -78,6 +78,7 @@ export function openSettings(opts) {
|
|
|
78
78
|
</div>
|
|
79
79
|
<div class="sx-group">
|
|
80
80
|
${row('Worktree per task', 'Each task gets its own branch from origin/main, in git repos', '<button class="switch" id="wtDefault" role="switch"></button>')}
|
|
81
|
+
${row('Fix PR conflicts', 'When a shipped PR conflicts with its base, the task merges the base in, resolves it and pushes', '<button class="switch" id="fixConflicts" role="switch"></button>')}
|
|
81
82
|
</div>
|
|
82
83
|
</section>
|
|
83
84
|
<section class="sx-pane" data-pane="notifications">
|
|
@@ -170,7 +171,7 @@ export function openSettings(opts) {
|
|
|
170
171
|
btn.classList.add('busy');
|
|
171
172
|
const before = new Map(S.agents.map((a) => [a.id, a]));
|
|
172
173
|
try {
|
|
173
|
-
|
|
174
|
+
setAgents(await api('/agents/recheck', { method: 'POST', body: {} }));
|
|
174
175
|
if (!node.isConnected) return;
|
|
175
176
|
paintAgents();
|
|
176
177
|
$('#launcher')?.placesChanged?.();
|
|
@@ -300,6 +301,19 @@ export function openSettings(opts) {
|
|
|
300
301
|
});
|
|
301
302
|
if (!saved) return;
|
|
302
303
|
paintWt();
|
|
304
|
+
const fc = $('#fixConflicts', node);
|
|
305
|
+
const paintFc = () => fc.setAttribute('aria-checked', S.config.defaults.fixConflicts !== 'off');
|
|
306
|
+
fc.onclick = async () => {
|
|
307
|
+
const was = S.config.defaults.fixConflicts;
|
|
308
|
+
S.config.defaults.fixConflicts = was === 'off' ? 'on' : 'off';
|
|
309
|
+
paintFc();
|
|
310
|
+
const saved = await tryApi(() => api('/config', { method: 'PATCH', body: { defaults: { fixConflicts: S.config.defaults.fixConflicts } } }));
|
|
311
|
+
if (!saved) {
|
|
312
|
+
S.config.defaults.fixConflicts = was;
|
|
313
|
+
paintFc();
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
paintFc();
|
|
303
317
|
renderLauncher();
|
|
304
318
|
};
|
|
305
319
|
paintWt();
|
package/web/js/features/setup.js
CHANGED
|
@@ -6,7 +6,7 @@ import { esc } from '../md.js';
|
|
|
6
6
|
import { api } from '../core/api.js';
|
|
7
7
|
import * as bus from '../core/bus.js';
|
|
8
8
|
import { $, icon } from '../core/dom.js';
|
|
9
|
-
import { launch, S } from '../core/state.js';
|
|
9
|
+
import { launch, S, setAgents } from '../core/state.js';
|
|
10
10
|
import { toast, toastError } from '../ui/toast.js';
|
|
11
11
|
import { harnessLogo } from './runners.js';
|
|
12
12
|
|
|
@@ -25,7 +25,7 @@ export async function installHarness(id) {
|
|
|
25
25
|
failed = '';
|
|
26
26
|
bus.emit('install');
|
|
27
27
|
try {
|
|
28
|
-
|
|
28
|
+
setAgents(await api('/agents/install', { method: 'POST', body: { id } }));
|
|
29
29
|
$('#launcher')?.placesChanged?.();
|
|
30
30
|
toast(`<b>${esc(S.agents.find((a) => a.id === id)?.name || id)}</b> is ready: free models, no account needed. Type a task and press Enter.`, { timeout: 7000 });
|
|
31
31
|
$('#prompt')?.focus();
|
|
@@ -68,7 +68,7 @@ export function renderSetup() {
|
|
|
68
68
|
btn.disabled = true;
|
|
69
69
|
btn.classList.add('busy');
|
|
70
70
|
try {
|
|
71
|
-
|
|
71
|
+
setAgents(await api('/agents/recheck', { method: 'POST', body: {} }));
|
|
72
72
|
$('#launcher')?.placesChanged?.();
|
|
73
73
|
bus.emit('agents');
|
|
74
74
|
if (needsSetup()) toast('Still no harness found. Install opencode here, or Claude Code, Codex, Cursor… yourself.');
|
package/web/js/features/ship.js
CHANGED
|
@@ -7,26 +7,42 @@ import { S } from '../core/state.js';
|
|
|
7
7
|
import { overlay } from '../ui/overlay.js';
|
|
8
8
|
import { toast, toastError } from '../ui/toast.js';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// fix: the session's conflictFix ({ step, why }) while wisp resolves the PR's merge conflicts itself.
|
|
11
|
+
const fixing = (fix) => fix?.step === 'fixing' || fix?.step === 'pushing';
|
|
12
|
+
const prTone = (pr, fix) =>
|
|
13
|
+
pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.conflict ? (fixing(fix) ? 'pending' : 'fail') : pr.checks?.fail ? 'fail' : pr.checks?.pending ? 'pending' : pr.review === 'CHANGES_REQUESTED' ? 'fail' : 'pass';
|
|
12
14
|
const REVIEW = { APPROVED: 'Approved', CHANGES_REQUESTED: 'Changes requested', REVIEW_REQUIRED: 'Review required' };
|
|
13
|
-
function prLine(pr) {
|
|
15
|
+
function prLine(pr, fix) {
|
|
16
|
+
if (pr.state === 'OPEN' && (pr.conflict || fixing(fix))) {
|
|
17
|
+
const what = fix?.step === 'pushing' ? 'Pushing the conflict fix' : fixing(fix) ? 'Fixing merge conflicts' : fix?.step === 'failed' ? `Conflicts not fixed: ${fix.why}` : 'Merge conflicts';
|
|
18
|
+
return `Open · ${what}`;
|
|
19
|
+
}
|
|
14
20
|
const c = pr.checks || {};
|
|
15
21
|
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';
|
|
16
22
|
const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
|
|
17
23
|
return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
// A ship in flight, per session: { steps, step, update, route, error, values }. The dialog closes
|
|
27
|
+
// on confirm and the card follows along here (the server sends the live stage as `shipping`);
|
|
28
|
+
// a failure stays on the card with Retry, which reopens the dialog with what was typed.
|
|
29
|
+
const ships = new Map();
|
|
30
|
+
const STEP_LABEL = { commit: 'Commit', push: 'Push', pr: 'Open PR' };
|
|
31
|
+
const STEP_DOING = { commit: 'Committing', push: 'Pushing', pr: 'Opening the PR' };
|
|
32
|
+
const shipping = (s) => !!(s?.shipping || (ships.has(s?.id) && !ships.get(s.id).error));
|
|
33
|
+
|
|
20
34
|
// Ship it only makes sense on a finished worktree task with something to ship: uncommitted
|
|
21
35
|
// changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
|
|
22
36
|
export function shippable(s) {
|
|
23
|
-
if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED') return false;
|
|
37
|
+
if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED' || shipping(s)) return false;
|
|
24
38
|
const t = s.toShip;
|
|
25
39
|
return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
|
|
26
40
|
}
|
|
27
41
|
export const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
|
|
28
42
|
|
|
29
|
-
export const
|
|
43
|
+
export const shipPill = (s) => (s.shipping ? `<span class="pill ship-pill" title="Shipping: ${esc(STEP_DOING[s.shipping.step] || 'Starting')}…"><i></i>Shipping</span>` : '');
|
|
44
|
+
|
|
45
|
+
export const prPill = (pr, fix) => `<span class="pill pr pr-${prTone(pr, fix)}" title="PR #${pr.number}: ${esc(prLine(pr, fix))}"><i></i>#${pr.number}</span>`;
|
|
30
46
|
|
|
31
47
|
// The PR row under a card's bar; reads the live summary, so polled check results show up.
|
|
32
48
|
export function renderPrStrip(id) {
|
|
@@ -39,25 +55,65 @@ export function renderPrStrip(id) {
|
|
|
39
55
|
b.hidden = !shippable(s);
|
|
40
56
|
$('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
|
|
41
57
|
});
|
|
58
|
+
// Started here, or elsewhere (another tab, the API): the server's stage moves the track along.
|
|
59
|
+
const run = ships.get(id) || (s.shipping && { steps: s.shipping.update ? ['push', 'pr'] : ['commit', 'push', 'pr'], step: 'push' });
|
|
60
|
+
if (run && STEP_LABEL[s.shipping?.step] && !run.error) run.step = s.shipping.step;
|
|
61
|
+
if (run) return renderShipRun(node, id, run);
|
|
62
|
+
const landed = node.classList.contains('ship-run') && !node.classList.contains('failed');
|
|
42
63
|
node.hidden = !pr;
|
|
43
64
|
if (!pr) return;
|
|
44
|
-
const tone = prTone(pr);
|
|
45
|
-
node.className = `pr-strip pr-${tone}`;
|
|
65
|
+
const tone = prTone(pr, s.conflictFix);
|
|
66
|
+
node.className = `pr-strip pr-${tone}${landed ? ' landed' : ''}`;
|
|
46
67
|
node.innerHTML = `
|
|
47
68
|
<span class="pr-dot"></span>
|
|
48
69
|
<a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
|
|
49
|
-
<span class="pr-line">${esc(prLine(pr))}</span>
|
|
70
|
+
<span class="pr-line">${esc(prLine(pr, s.conflictFix))}</span>
|
|
50
71
|
<span class="grow"></span>
|
|
72
|
+
${pr.state === 'OPEN' && pr.conflict && s.conflictFix?.step === 'failed' ? `<button class="ghost xs" data-pr="fix" title="Ask the agent to merge the base in and resolve the conflicts again">${icon('replay')}<span>Fix conflicts</span></button>` : ''}
|
|
51
73
|
${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
|
|
52
74
|
<a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>
|
|
53
|
-
${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>` : ''}`;
|
|
75
|
+
${pr.state === 'OPEN' && !pr.conflict ? `<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>` : ''}`;
|
|
54
76
|
node.onclick = (e) => {
|
|
55
77
|
const a = e.target.closest('[data-pr]')?.dataset.pr;
|
|
56
78
|
if (a === 'refresh') api(`/sessions/${id}/pr`, { method: 'POST', body: {} }).catch(toastError);
|
|
57
79
|
if (a === 'merge') mergePr(id);
|
|
80
|
+
if (a === 'fix') api(`/sessions/${id}/fix-conflicts`, { method: 'POST', body: {} }).catch(toastError);
|
|
58
81
|
};
|
|
59
82
|
}
|
|
60
83
|
|
|
84
|
+
// The in-flight row: the stages as a track that fills in as the server reports them. Built once
|
|
85
|
+
// and updated in place, so its entry animation and shimmer don't restart with every summary.
|
|
86
|
+
function renderShipRun(node, id, run) {
|
|
87
|
+
const at = Math.max(run.steps.indexOf(run.step), 0);
|
|
88
|
+
if (!node.classList.contains('ship-run') || node.dataset.steps !== run.steps.join()) {
|
|
89
|
+
node.dataset.steps = run.steps.join();
|
|
90
|
+
node.innerHTML = `
|
|
91
|
+
<span class="ship-icon">${icon('send')}</span>
|
|
92
|
+
<span class="ship-what"></span>
|
|
93
|
+
<ol class="ship-track">${run.steps.map((k) => `<li data-step="${k}"><i>${icon('check')}</i><span>${STEP_LABEL[k]}</span></li>`).join('')}</ol>
|
|
94
|
+
<span class="grow"></span>
|
|
95
|
+
<span class="ship-acts"></span>
|
|
96
|
+
<span class="ship-bar"></span>`;
|
|
97
|
+
node.onclick = (e) => {
|
|
98
|
+
const a = e.target.closest('[data-ship]')?.dataset.ship;
|
|
99
|
+
if (a === 'retry') shipDialog(id);
|
|
100
|
+
if (a === 'dismiss') {
|
|
101
|
+
ships.delete(id);
|
|
102
|
+
renderPrStrip(id);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const failed = !!run.error;
|
|
107
|
+
node.hidden = false;
|
|
108
|
+
node.className = `pr-strip ship-run${failed ? ' failed pr-fail' : ''}`;
|
|
109
|
+
node.title = failed ? run.error : '';
|
|
110
|
+
$$('.ship-track li', node).forEach((li, i) => (li.className = i < at ? 'done' : i === at ? (failed ? 'bad' : 'now') : ''));
|
|
111
|
+
const what = failed ? `Ship failed at ${STEP_LABEL[run.steps[at]] || 'start'}: <span class="ship-err">${esc(run.error)}</span>` : `${STEP_DOING[run.steps[at]]}<span class="dots"></span>`;
|
|
112
|
+
if ($('.ship-what', node).innerHTML !== what) $('.ship-what', node).innerHTML = what;
|
|
113
|
+
const acts = failed ? `<button class="ghost xs" data-ship="retry">${icon('replay')}<span>Retry</span></button><button class="ghost xs" data-ship="dismiss" title="Dismiss">${icon('x')}</button>` : '';
|
|
114
|
+
if ($('.ship-acts', node).innerHTML !== acts) $('.ship-acts', node).innerHTML = acts;
|
|
115
|
+
}
|
|
116
|
+
|
|
61
117
|
export async function shipDialog(id) {
|
|
62
118
|
const s = S.sessions.get(id);
|
|
63
119
|
if (!s) return;
|
|
@@ -69,6 +125,7 @@ export async function shipDialog(id) {
|
|
|
69
125
|
return toastError(err);
|
|
70
126
|
}
|
|
71
127
|
const open = info.pr?.state === 'OPEN';
|
|
128
|
+
const typed = ships.get(id)?.values; // a failed ship's message, back for Retry
|
|
72
129
|
const nothing = !info.dirty && !info.ahead;
|
|
73
130
|
const node = el(`
|
|
74
131
|
<div class="modal ship-modal">
|
|
@@ -85,23 +142,42 @@ export async function shipDialog(id) {
|
|
|
85
142
|
$('.close', node).onclick = close;
|
|
86
143
|
const title = $('[name="title"]', node);
|
|
87
144
|
const body = $('[name="body"]', node);
|
|
88
|
-
if (title) title.value = info.title;
|
|
89
|
-
if (body) body.value = info.body;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
close();
|
|
97
|
-
toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
|
|
98
|
-
} catch (err) {
|
|
99
|
-
btn.disabled = false;
|
|
100
|
-
btn.lastChild.textContent = open ? 'Commit & push' : 'Commit, push & open PR';
|
|
101
|
-
toastError(err, { timeout: 10_000 });
|
|
145
|
+
if (title) title.value = typed?.title || info.title;
|
|
146
|
+
if (body) body.value = typed?.body ?? info.body;
|
|
147
|
+
if (typed?.draft && $('[name="draft"]', node)) $('[name="draft"]', node).checked = true;
|
|
148
|
+
// Confirm closes the dialog at once; the card's PR row shows the stages, then the PR itself.
|
|
149
|
+
const go = () => {
|
|
150
|
+
if (title && !title.value.trim()) {
|
|
151
|
+
title.focus();
|
|
152
|
+
return toast('A commit message is required.', { tone: 'bad' });
|
|
102
153
|
}
|
|
154
|
+
const values = { title: title?.value || info.title, body: body?.value ?? info.body, draft: !!$('[name="draft"]', node)?.checked };
|
|
155
|
+
close();
|
|
156
|
+
ships.set(id, { steps: [...(info.dirty ? ['commit'] : []), 'push', 'pr'], step: info.dirty ? 'commit' : 'push', update: open, values });
|
|
157
|
+
renderPrStrip(id);
|
|
158
|
+
const strip = S.nodes.get(id)?.body?.querySelector('.pr-strip');
|
|
159
|
+
if (strip && !strip.hidden) strip.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
160
|
+
api(`/sessions/${id}/ship`, { method: 'POST', body: values }).then(
|
|
161
|
+
(pr) => {
|
|
162
|
+
const s = S.sessions.get(id);
|
|
163
|
+
// Everything just went up: no Push to #n until the server's recheck finds something new.
|
|
164
|
+
if (s) S.sessions.set(id, { ...s, pr: { ...s.pr, ...pr }, shipping: null, toShip: s.toShip && { ...s.toShip, dirty: 0, unpushed: 0 } });
|
|
165
|
+
ships.delete(id);
|
|
166
|
+
renderPrStrip(id);
|
|
167
|
+
toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
|
|
168
|
+
},
|
|
169
|
+
(err) => {
|
|
170
|
+
if (ships.has(id)) ships.get(id).error = err.message;
|
|
171
|
+
const s = S.sessions.get(id);
|
|
172
|
+
if (s) S.sessions.set(id, { ...s, shipping: null });
|
|
173
|
+
renderPrStrip(id);
|
|
174
|
+
toastError(err, { timeout: 10_000 });
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
$('[data-a="go"]', node).onclick = () => {
|
|
179
|
+
go();
|
|
103
180
|
};
|
|
104
|
-
$('[data-a="go"]', node).onclick = go;
|
|
105
181
|
requestAnimationFrame(() => (title || $('[data-a="go"]', node)).focus());
|
|
106
182
|
}
|
|
107
183
|
|
package/web/js/features/turns.js
CHANGED
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
// and older ones folded.
|
|
3
3
|
|
|
4
4
|
import { countChanges, fmtDuration, renderPresentation } from '../blocks.js';
|
|
5
|
-
import { esc, md } from '../md.js';
|
|
5
|
+
import { esc, inlineMd, md } from '../md.js';
|
|
6
|
+
import { mentionHtml } from '../mention-text.js';
|
|
6
7
|
import { api } from '../core/api.js';
|
|
7
8
|
import { $, $$, el, icon } from '../core/dom.js';
|
|
8
9
|
import { S } from '../core/state.js';
|
|
9
10
|
import { overlay } from '../ui/overlay.js';
|
|
10
11
|
import { toastError } from '../ui/toast.js';
|
|
11
|
-
import { execAction, moreMenu, openInEditor, revertFile, rollbackTurn, takeOver } from './actions.js';
|
|
12
|
+
import { execAction, moreMenu, openInEditor, revertFile, rollbackTurn, takeOver, toggleDone } from './actions.js';
|
|
12
13
|
import { draftInto, renderDock, sendReply, startOptimistic, updateDock } from './composer.js';
|
|
13
14
|
import { scheduleRefetch } from './feed.js';
|
|
14
15
|
import { feedbackOn, rateTurn, whyForm } from './feedback.js';
|
|
15
16
|
import { projectById } from './places.js';
|
|
16
|
-
import { agentName, effortLabel, modelBadge, MODES } from './runners.js';
|
|
17
|
+
import { agentName, DEFAULT_MODE, effortLabel, modelBadge, MODES } from './runners.js';
|
|
17
18
|
import { scrollByPx, watching } from './scroll.js';
|
|
18
19
|
import { renderPrStrip, shipDialog } from './ship.js';
|
|
19
20
|
import { copiesOf, memberLabel, renderSubs, subKey, teamLiveHtml, teamSize } from './team.js';
|
|
@@ -32,9 +33,24 @@ export function ctxFor(s, turn, isLatest) {
|
|
|
32
33
|
zoom: (src) => overlay(el(`<img class="lightbox" src="${esc(src)}">`)),
|
|
33
34
|
revertFile: (file, restore) => revertFile(s.id, turn.id, file, restore),
|
|
34
35
|
exec: (command, key) => execAction(s.id, turn.id, command, key),
|
|
36
|
+
approve: (allow) => approveBlocked(s, turn, allow),
|
|
35
37
|
};
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
// The "Blocked by permissions" card's buttons. Allow starts the retry, so the card goes to
|
|
41
|
+
// Working at once; a refusal rolls that back and shows why.
|
|
42
|
+
async function approveBlocked(s, turn, allow) {
|
|
43
|
+
const undo = allow ? startOptimistic(s.id, 'Allow & retry what was blocked', null, {}) : null;
|
|
44
|
+
try {
|
|
45
|
+
await api(`/sessions/${s.id}/approve`, { method: 'POST', body: { turnId: turn.id, allow } });
|
|
46
|
+
if (undo) scheduleRefetch(s.id);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
undo?.();
|
|
49
|
+
toastError(err);
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
// Older turns render as one-line folded rows; past this many they hide behind "Show earlier".
|
|
39
55
|
const VISIBLE_TURNS = 8;
|
|
40
56
|
|
|
@@ -54,12 +70,13 @@ export function renderBody(id, { fresh }) {
|
|
|
54
70
|
<button class="ghost xs" data-a="replay" title="Replay presentation (R)">${icon('replay')}</button>
|
|
55
71
|
<button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}</button>
|
|
56
72
|
<button class="ghost xs" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
|
|
73
|
+
<button class="ghost xs it-done" data-a="done" title="Mark done (E)" hidden>${icon('done')}<span>Mark done</span></button>
|
|
57
74
|
<button class="ghost xs" data-a="more" title="More">${icon('dots')}</button>
|
|
58
75
|
</div>
|
|
59
76
|
</div>
|
|
60
|
-
<div class="pr-strip" hidden></div>
|
|
61
77
|
<button class="link xs earlier" hidden></button>
|
|
62
78
|
<div class="turns"></div>
|
|
79
|
+
<div class="pr-strip" hidden></div>
|
|
63
80
|
<div class="it-dock"></div>
|
|
64
81
|
</div>`);
|
|
65
82
|
n.body ? n.body.replaceWith(body) : n.card.append(body);
|
|
@@ -69,6 +86,7 @@ export function renderBody(id, { fresh }) {
|
|
|
69
86
|
if (a === 'replay') replay(id);
|
|
70
87
|
if (a === 'takeover') takeOver(id);
|
|
71
88
|
if (a === 'ship') shipDialog(id);
|
|
89
|
+
if (a === 'done') toggleDone(id);
|
|
72
90
|
if (a === 'more') moreMenu(id, e.target.closest('button'));
|
|
73
91
|
});
|
|
74
92
|
$('.earlier', body).onclick = () => ((n.showAll = true), renderBody(id, { fresh: false }));
|
|
@@ -77,19 +95,20 @@ export function renderBody(id, { fresh }) {
|
|
|
77
95
|
|
|
78
96
|
const body = n.body;
|
|
79
97
|
const proj = projectById(summary.projectId);
|
|
80
|
-
// The head already shows the harness, model and project, the task head has Compare
|
|
81
|
-
//
|
|
82
|
-
//
|
|
98
|
+
// The head already shows the harness, model and project, and the task head has Compare. This bar
|
|
99
|
+
// says how the run was set up: a mode worth noticing (Plan or Edits, as on the launcher), the
|
|
100
|
+
// branch, and what launched it.
|
|
83
101
|
const mode = MODES.find((m) => m.id === s.mode);
|
|
84
102
|
const where = (proj?.path || s.cwd).replace(S.config.home || '', '~');
|
|
85
103
|
$('.it-meta', body).title = [agentName(s.agent), s.model, s.effort && `${effortLabel(s.effort)} thinking`, mode?.label, where].filter(Boolean).join(' · ');
|
|
86
104
|
$('.it-meta', body).innerHTML = `
|
|
87
|
-
${mode && mode
|
|
105
|
+
${mode && mode !== DEFAULT_MODE ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
|
|
88
106
|
${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
|
|
89
107
|
${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>` : ''}
|
|
90
108
|
${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>` : ''}`;
|
|
91
109
|
const live = s.state === 'running' || s.state === 'waiting';
|
|
92
|
-
// Ship
|
|
110
|
+
// Ship and Done sit in the top bar and again next to the composer, so they're there on opening the
|
|
111
|
+
// card and after reading the result. The PR row sits right above the composer for the same reason.
|
|
93
112
|
renderPrStrip(id);
|
|
94
113
|
$$('[data-a="done"]', body).forEach((b) => {
|
|
95
114
|
b.classList.toggle('on', s.state === 'done');
|
|
@@ -121,7 +140,7 @@ export function renderBody(id, { fresh }) {
|
|
|
121
140
|
const isLatest = t.id === latestId;
|
|
122
141
|
const folded = !isLatest && !n.unfolded.has(t.id);
|
|
123
142
|
const revKey = `${t.rolledBackAt || 0}:${(t.changes || []).map((c) => (c.reverted ? 1 : 0)).join('')}`;
|
|
124
|
-
const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}`;
|
|
143
|
+
const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.approval || ''}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}`;
|
|
125
144
|
const prev = n.turnNodes.get(t.id);
|
|
126
145
|
let node = prev?.node;
|
|
127
146
|
if (!prev || prev.key !== key) {
|
|
@@ -150,7 +169,7 @@ function renderFoldedTurn(t, unfold) {
|
|
|
150
169
|
<article class="turn folded" data-turn="${t.id}">
|
|
151
170
|
<button class="t-fold" title="Show this exchange">
|
|
152
171
|
${icon('chevron', 'chev')}
|
|
153
|
-
<span class="t-prompt">${esc(t.prompt.replace(/\s+/g, ' '))}</span>
|
|
172
|
+
<span class="t-prompt">${mentionHtml(esc(t.prompt.replace(/\s+/g, ' ')), { link: false })}</span>
|
|
154
173
|
${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
|
|
155
174
|
${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
|
|
156
175
|
<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
|
|
@@ -176,7 +195,7 @@ function renderTurn(s, t, isLatest, fold) {
|
|
|
176
195
|
const cpLabel = cp?.head ? `${cp.branch ? `${cp.branch}@` : ''}${cp.head.slice(0, 7)}${cp.dirty ? '*' : ''}` : '';
|
|
177
196
|
const ask = el(`
|
|
178
197
|
<div class="ask">
|
|
179
|
-
<div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
|
|
198
|
+
<div class="ask-text ${long ? 'clamped' : ''}">${mentionHtml(esc(t.prompt))}</div>
|
|
180
199
|
${long ? '<button class="link xs more-btn">Show all</button>' : ''}
|
|
181
200
|
${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>` : ''}
|
|
182
201
|
<div class="ask-foot">
|
|
@@ -264,7 +283,19 @@ function actItem(a) {
|
|
|
264
283
|
|
|
265
284
|
function renderAskCard(s) {
|
|
266
285
|
const a = s.ask;
|
|
267
|
-
const
|
|
286
|
+
const p = a.permission;
|
|
287
|
+
// A live approval: the agent is paused on a tool call its permissions don't allow by themselves.
|
|
288
|
+
const node = p
|
|
289
|
+
? el(`
|
|
290
|
+
<div class="ask-card perm">
|
|
291
|
+
<div class="q-head">${icon('lock')}<span>Needs your approval</span></div>
|
|
292
|
+
<p class="q-text">${inlineMd(a.question)}</p>
|
|
293
|
+
${p.detail && !a.question.includes(p.detail) ? `<pre class="perm-detail">${esc(p.detail)}</pre>` : ''}
|
|
294
|
+
${a.context ? `<p class="q-context">${esc(a.context)}</p>` : ''}
|
|
295
|
+
<div class="q-opts">${a.options.map((o, i) => `<button class="opt ${i === 0 ? 'go' : i === a.options.length - 1 ? 'no' : ''}" data-i="${i}"><span class="k">${i + 1}</span>${esc(o)}</button>`).join('')}</div>
|
|
296
|
+
<form class="q-own"><input class="input" placeholder="Or say what to do instead (denies this call)…"><button class="ghost sm">Send</button></form>
|
|
297
|
+
</div>`)
|
|
298
|
+
: el(`
|
|
268
299
|
<div class="ask-card">
|
|
269
300
|
<div class="q-head">${icon('ask')}<span>Needs your call</span></div>
|
|
270
301
|
<p class="q-text">${esc(a.question)}</p>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Task mentions in prompts, without the DOM. A prompt stores one as `@[Title](wisp:<id>)`; the
|
|
2
|
+
// prompt box shows `@Title` and keeps the ids aside (mentions = [{ id, title }]). The server reads
|
|
3
|
+
// the same form (server/mentions.js).
|
|
4
|
+
|
|
5
|
+
export const MENTION = /@\[([^\]\n]{1,200})\]\(wisp:([a-z0-9]{6,40})\)/g;
|
|
6
|
+
|
|
7
|
+
// Titles go inside [...], so no ] or newlines.
|
|
8
|
+
export const cleanTitle = (t) => String(t || 'Untitled task').replace(/[\]\n\r]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) || 'Untitled task';
|
|
9
|
+
|
|
10
|
+
// Stored form → what the box shows, plus the mentions in it.
|
|
11
|
+
export function decode(text = '') {
|
|
12
|
+
const mentions = [];
|
|
13
|
+
const shown = String(text).replace(MENTION, (_, title, id) => {
|
|
14
|
+
if (!mentions.some((m) => m.id === id)) mentions.push({ id, title });
|
|
15
|
+
return `@${title}`;
|
|
16
|
+
});
|
|
17
|
+
return { text: shown, mentions };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Where each mention sits in the shown text: every `@Title` of a known mention, longest titles
|
|
21
|
+
// first so one title that starts another doesn't steal it. Sorted, non-overlapping.
|
|
22
|
+
export function spans(text = '', mentions = []) {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const m of [...mentions].sort((a, b) => b.title.length - a.title.length)) {
|
|
25
|
+
const needle = `@${m.title}`;
|
|
26
|
+
for (let i = text.indexOf(needle); i !== -1; i = text.indexOf(needle, i + needle.length)) {
|
|
27
|
+
const end = i + needle.length;
|
|
28
|
+
if (!out.some((s) => i < s.end && end > s.start)) out.push({ start: i, end, id: m.id, title: m.title });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out.sort((a, b) => a.start - b.start);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Shown text → stored form. A mention the user edited into something else is just text again.
|
|
35
|
+
export function encode(text = '', mentions = []) {
|
|
36
|
+
let out = '';
|
|
37
|
+
let at = 0;
|
|
38
|
+
for (const s of spans(text, mentions)) {
|
|
39
|
+
out += text.slice(at, s.start) + `@[${s.title}](wisp:${s.id})`;
|
|
40
|
+
at = s.end;
|
|
41
|
+
}
|
|
42
|
+
return out + text.slice(at);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// For people: titles, no ids (card titles while a launch is pending).
|
|
46
|
+
export const plain = (text = '') => String(text).replace(MENTION, '@$1');
|
|
47
|
+
|
|
48
|
+
// Already-escaped HTML of a stored prompt → mentions as chips. link: chips open the task.
|
|
49
|
+
export const mentionHtml = (html, { link = true } = {}) =>
|
|
50
|
+
html.replace(MENTION, (_, title, id) => (link ? `<a class="mn" href="#/s/${id}" title="Open this task">@${title}</a>` : `<span class="mn">@${title}</span>`));
|
|
51
|
+
|
|
52
|
+
// Pasted text that is only task references (ids, `wisp:<id>`, `#/s/<id>` links), each a task
|
|
53
|
+
// `known(id)` finds → those ids; anything else → null (pasted as ordinary text).
|
|
54
|
+
export function pastedTasks(text, known) {
|
|
55
|
+
const parts = String(text || '').trim().split(/\s+/).filter(Boolean);
|
|
56
|
+
if (!parts.length || parts.length > 10) return null;
|
|
57
|
+
const ids = parts.map((p) => (p.match(/^(?:wisp:)?([a-f0-9]{12})$/) || p.match(/#\/s\/([a-z0-9]{6,40})\/?$/))?.[1]);
|
|
58
|
+
return ids.every((id) => id && known(id)) ? [...new Set(ids)] : null;
|
|
59
|
+
}
|