ai-agentmanager 1.5.0 → 1.7.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 +12 -0
- package/README.md +9 -6
- package/package.json +1 -1
- package/public/app.js +76 -12
- package/public/attach.js +100 -0
- package/public/index.html +66 -34
- package/public/menu.js +11 -0
- package/public/review.js +85 -2
- package/public/setup.js +1 -0
- package/public/side.js +191 -0
- package/public/style.css +44 -0
- package/public/walkthrough.js +36 -0
- package/src/backlog.js +4 -1
- package/src/brief.js +75 -0
- package/src/cli.js +32 -6
- package/src/manager.js +110 -5
- package/src/review.js +3 -0
- package/src/server.js +33 -3
package/public/menu.js
CHANGED
|
@@ -52,15 +52,21 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
52
52
|
// ------------------------------------------------------------ Settings dialog
|
|
53
53
|
const dialog = $('#settings-dialog');
|
|
54
54
|
const form = $('#settings-form');
|
|
55
|
+
let service = null; // the login service as the dialog opened
|
|
55
56
|
$('#settings-btn').onclick = async () => {
|
|
56
57
|
$('#settings-error').hidden = true;
|
|
57
58
|
try {
|
|
58
59
|
const c = await api('GET', '/cleanup');
|
|
59
60
|
form.maxRunning.value = c.maxRunning ?? '';
|
|
60
61
|
$('#settings-running').textContent = `Open now: ${c.running}.`;
|
|
62
|
+
service = null;
|
|
61
63
|
if (!remote()) {
|
|
62
64
|
const u = await api('GET', '/update').catch(() => null);
|
|
63
65
|
form.updateCheck.checked = u?.check !== false;
|
|
66
|
+
service = await api('GET', '/service').catch(() => null);
|
|
67
|
+
$('#settings-service').hidden = !service?.supported;
|
|
68
|
+
form.startAtLogin.checked = Boolean(service?.installed);
|
|
69
|
+
$('#settings-service-state').textContent = service?.installed ? (service.loaded ? 'On: the login service is loaded.' : 'On; loads at the next login.') : 'Off now.';
|
|
64
70
|
}
|
|
65
71
|
dialog.showModal();
|
|
66
72
|
} catch (err) {
|
|
@@ -79,6 +85,11 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
79
85
|
try {
|
|
80
86
|
const c = await api('PUT', '/cleanup', { maxRunning: form.maxRunning.value.trim() || null });
|
|
81
87
|
if (!remote()) await api('PUT', '/update', { check: form.updateCheck.checked }).catch(() => {});
|
|
88
|
+
// Only when it changed: installing touches launchd / systemd.
|
|
89
|
+
if (!remote() && service?.supported && form.startAtLogin.checked !== Boolean(service.installed)) {
|
|
90
|
+
const s = await api('PUT', '/service', { enabled: form.startAtLogin.checked });
|
|
91
|
+
toast(s.installed ? `Starts at login from now on${s.note ? `. ${s.note}` : ''}` : 'No longer starts at login', 6000);
|
|
92
|
+
}
|
|
82
93
|
dialog.close();
|
|
83
94
|
toast(c.maxRunning ? `At most ${c.maxRunning} agents open; the ones idle longest close above that` : 'No limit on open agents');
|
|
84
95
|
} catch (err) {
|
package/public/review.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Review screen: an agent's changes file by file, line comments sent back to the agent as one
|
|
2
2
|
// message, and Commit / Push & open PR. Comments not sent yet are kept per agent in this browser.
|
|
3
|
+
// With a walkthrough, each hunk shows its explanation and ◀ / ▶ step through them in reading order.
|
|
4
|
+
import { cardHtml, explainButton, stepOf, walkthroughOf } from './walkthrough.js';
|
|
3
5
|
|
|
4
6
|
/** Unified diff -> [{ path, status, binary, hunks: [{ head, lines: [{ t, old, new, text }] }] }] */
|
|
5
7
|
export function parseDiff(text) {
|
|
@@ -56,7 +58,8 @@ const BIG = 600; // files with more changed lines start collapsed
|
|
|
56
58
|
export function initReview({ api, esc, toast, agents, $ }) {
|
|
57
59
|
const dlg = $('#review-dialog');
|
|
58
60
|
const body = $('#review-body');
|
|
59
|
-
const st = { id: null, data: null, files: [], comments: [], collapsed: new Set(), editing: null, loading: false };
|
|
61
|
+
const st = { id: null, data: null, files: [], comments: [], collapsed: new Set(), editing: null, loading: false, step: null };
|
|
62
|
+
const walk = () => walkthroughOf(agents.get(st.id), st.data?.diffHash);
|
|
60
63
|
|
|
61
64
|
const key = (id) => `agentmgr.review.${id}`;
|
|
62
65
|
const loadDrafts = (id) => {
|
|
@@ -80,6 +83,7 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
80
83
|
st.collapsed = new Set();
|
|
81
84
|
st.editing = null;
|
|
82
85
|
st.data = null;
|
|
86
|
+
st.step = null;
|
|
83
87
|
$('#review-title').textContent = `Review: ${agents.get(id)?.name ?? ''}`;
|
|
84
88
|
body.innerHTML = '<p class="hint">Loading changes…</p>';
|
|
85
89
|
if (!dlg.open) dlg.showModal();
|
|
@@ -129,7 +133,10 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
129
133
|
if (collapsed) return `<div class="rv-file" id="rv-${encodeURIComponent(f.path)}">${head}</div>`;
|
|
130
134
|
if (f.binary) return `<div class="rv-file" id="rv-${encodeURIComponent(f.path)}">${head}<p class="hint rv-pad">Binary file</p></div>`;
|
|
131
135
|
const rows = [];
|
|
136
|
+
const w = walk();
|
|
132
137
|
for (const h of f.hunks) {
|
|
138
|
+
const i = w.doc ? stepOf(w.doc, f.path, h.head) : -1;
|
|
139
|
+
if (i >= 0) rows.push(`<tr class="rv-explain ${st.step === i ? 'current' : ''}" id="rv-step-${i}"><td colspan="3">${cardHtml(w.doc.data.changes[i], i, w.doc.data.changes.length, esc)}</td></tr>`);
|
|
133
140
|
rows.push(`<tr class="rv-hunk"><td colspan="3">${esc(h.head)}</td></tr>`);
|
|
134
141
|
for (const l of h.lines) {
|
|
135
142
|
const side = l.t === 'del' ? 'old' : 'new';
|
|
@@ -149,6 +156,48 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
149
156
|
return `<div class="rv-file" id="rv-${encodeURIComponent(f.path)}">${head}<table class="rv-table">${rows.join('')}</table></div>`;
|
|
150
157
|
}
|
|
151
158
|
|
|
159
|
+
/** Explain changes, then ◀ n of N ▶ once there is a walkthrough. */
|
|
160
|
+
function walkNav() {
|
|
161
|
+
const w = walk();
|
|
162
|
+
const n = w.doc?.data.changes.length ?? 0;
|
|
163
|
+
const nav = n
|
|
164
|
+
? `<span class="rv-walk"><button type="button" class="small ghost" data-rv-step="-1" title="Previous change ([)">◀</button><span class="hint">${st.step == null ? `${n} changes` : `${st.step + 1} of ${n}`}</span><button type="button" class="small ghost" data-rv-step="1" title="Next change (])">▶</button></span>`
|
|
165
|
+
: '';
|
|
166
|
+
return `${nav}${explainButton(w, 'data-rv-explain')}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function walkNote() {
|
|
170
|
+
const w = walk();
|
|
171
|
+
if (w.error && !w.pending) return `<p class="error rv-pad">Couldn't explain the changes: ${esc(w.error)}</p>`;
|
|
172
|
+
if (!w.doc) return '';
|
|
173
|
+
const notes = [esc(w.doc.data.summary)];
|
|
174
|
+
if (w.stale) notes.push('<b>The changes moved since this was written</b>; explanations for hunks that changed are missing. Explain again for the latest.');
|
|
175
|
+
if (w.doc.cut) notes.push('The diff was too long to explain all of it; the rest has no explanation.');
|
|
176
|
+
return `<p class="hint rv-walk-note">${notes.join(' ')}</p>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Explanations whose hunk is still in the diff (after more edits, some may be gone). */
|
|
180
|
+
const present = (w) => (w.doc?.data.changes ?? []).map((c, i) => (st.files.some((f) => f.path === c.file && f.hunks.some((h) => h.head === c.head)) ? i : -1)).filter((i) => i >= 0);
|
|
181
|
+
|
|
182
|
+
/** Step `dir` (+1 / -1) from the current explanation: open its file, mark it and scroll to it. */
|
|
183
|
+
function step(dir) {
|
|
184
|
+
const w = walk();
|
|
185
|
+
const ok = present(w);
|
|
186
|
+
if (!ok.length) return toast('These explanations no longer match the changes; Explain again');
|
|
187
|
+
const at = st.step == null ? -1 : ok.indexOf(st.step);
|
|
188
|
+
const next = at < 0 ? (dir > 0 ? ok[0] : ok.at(-1)) : ok[(at + dir + ok.length) % ok.length];
|
|
189
|
+
gotoStep(next);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function gotoStep(i) {
|
|
193
|
+
const w = walk();
|
|
194
|
+
st.step = i;
|
|
195
|
+
st.collapsed.delete(w.doc.data.changes[st.step].file);
|
|
196
|
+
keepInputs();
|
|
197
|
+
render();
|
|
198
|
+
body.querySelector(`#rv-step-${st.step}`)?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
199
|
+
}
|
|
200
|
+
|
|
152
201
|
function render() {
|
|
153
202
|
const d = st.data;
|
|
154
203
|
if (!d) return;
|
|
@@ -168,8 +217,10 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
168
217
|
${d.unpushed ? `<span class="tag">${d.unpushed} not pushed</span>` : ''}
|
|
169
218
|
${openPr ? `<a href="${esc(openPr.url)}" target="_blank" rel="noopener">PR #${esc(openPr.number ?? '')}</a>` : ''}
|
|
170
219
|
<span class="spacer"></span>
|
|
220
|
+
${d.files.length ? walkNav() : ''}
|
|
171
221
|
<button type="button" class="small ghost" data-rv-reload>${st.loading ? 'Loading…' : 'Reload'}</button>
|
|
172
222
|
</div>
|
|
223
|
+
${walkNote()}
|
|
173
224
|
${d.busy ? '<p class="hint rv-warn">The agent is working, so the changes may still move. Reload when it is idle.</p>' : ''}
|
|
174
225
|
${d.truncated ? '<p class="hint rv-warn">The diff is large and was cut short; open a shell for the rest.</p>' : ''}
|
|
175
226
|
<div class="rv-main">
|
|
@@ -235,6 +286,20 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
235
286
|
body.addEventListener('click', async (e) => {
|
|
236
287
|
const t = e.target;
|
|
237
288
|
if (t.closest('[data-rv-reload]')) return keepInputs(), reload();
|
|
289
|
+
const stepBtn = t.closest('[data-rv-step]');
|
|
290
|
+
if (stepBtn) return step(Number(stepBtn.dataset.rvStep));
|
|
291
|
+
if (t.closest('[data-rv-explain]')) {
|
|
292
|
+
keepInputs();
|
|
293
|
+
try {
|
|
294
|
+
// In the background: the agent's record says when it's done (agentChanged re-renders).
|
|
295
|
+
agents.set(st.id, await api('POST', `/agents/${st.id}/docs/walkthrough?background=1`));
|
|
296
|
+
st.step = null;
|
|
297
|
+
render();
|
|
298
|
+
} catch (err) {
|
|
299
|
+
toast(`Error: ${err.message}`, 5000);
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
238
303
|
const go = t.closest('[data-rv-goto]');
|
|
239
304
|
if (go) {
|
|
240
305
|
e.preventDefault();
|
|
@@ -339,6 +404,13 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
339
404
|
}
|
|
340
405
|
});
|
|
341
406
|
|
|
407
|
+
// [ and ] step through the explained changes (not while typing).
|
|
408
|
+
dlg.addEventListener('keydown', (e) => {
|
|
409
|
+
if ((e.key !== '[' && e.key !== ']') || e.target.closest?.('textarea, input') || !walk().doc) return;
|
|
410
|
+
e.preventDefault();
|
|
411
|
+
step(e.key === ']' ? 1 : -1);
|
|
412
|
+
});
|
|
413
|
+
|
|
342
414
|
body.addEventListener('keydown', (e) => {
|
|
343
415
|
const form = e.target.closest?.('.rv-form');
|
|
344
416
|
if (form && e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
@@ -364,5 +436,16 @@ export function initReview({ api, esc, toast, agents, $ }) {
|
|
|
364
436
|
st.note = st.msg = st.prTitle = '';
|
|
365
437
|
});
|
|
366
438
|
|
|
367
|
-
|
|
439
|
+
/** The agent changed (its walkthrough finished, say): redraw if it's the one under review. */
|
|
440
|
+
function agentChanged(a) {
|
|
441
|
+
if (!dlg.open || a.id !== st.id || !st.data) return;
|
|
442
|
+
const w = a.docs?.walkthrough;
|
|
443
|
+
const sig = `${w?.at}|${w?.pending}|${w?.error}`;
|
|
444
|
+
if (sig === st.walkSig) return;
|
|
445
|
+
st.walkSig = sig;
|
|
446
|
+
keepInputs();
|
|
447
|
+
render();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return { open, agentChanged, get openId() { return dlg.open ? st.id : null; } };
|
|
368
451
|
}
|
package/public/setup.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const STEPS = [
|
|
5
5
|
['agent', 'Start your first agent', 'Give it a request and a repo or folder; it works in its own branch.', '#new-btn', 'New agents'],
|
|
6
|
+
['service', 'Start at login', 'So AI Agent Manager and your agents come back by themselves after a restart.', '#settings-btn', 'Settings'],
|
|
6
7
|
['alerts', 'Get Slack alerts', 'Hear when an agent needs you, is stuck or finished.', '#alerts-btn', 'Alerts'],
|
|
7
8
|
['assistant', 'Let work come to you', 'The assistant, or triggers (a GitHub label, a webhook), start agents for new work.', '#assistant-btn', 'Assistant'],
|
|
8
9
|
['remote', 'Use it from your phone', 'Answer agents from anywhere on your network.', '#remote-btn', 'Remote access'],
|
package/public/side.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// The split view: the terminal (Claude, a shell or a browser tab) stays on the left, and the right
|
|
2
|
+
// pane shows the same agent's Code, its Docs (Diagrams by default) or its Changes explained one by
|
|
3
|
+
// one. On, the tab and the document are remembered in this browser.
|
|
4
|
+
import { parseDiff } from '/review.js';
|
|
5
|
+
import { cardHtml, explainButton, walkthroughOf } from '/walkthrough.js';
|
|
6
|
+
|
|
7
|
+
const DOCS = [
|
|
8
|
+
['diagrams', 'Diagrams'],
|
|
9
|
+
['design', 'Design'],
|
|
10
|
+
['flow', 'Flow'],
|
|
11
|
+
['analysis', 'Analysis'],
|
|
12
|
+
['proposal', 'Proposal'],
|
|
13
|
+
['explain', 'Explain'],
|
|
14
|
+
];
|
|
15
|
+
const pref = {
|
|
16
|
+
get: (k, d) => {
|
|
17
|
+
try {
|
|
18
|
+
return localStorage.getItem(`agentmgr.side.${k}`) ?? d;
|
|
19
|
+
} catch {
|
|
20
|
+
return d;
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
set: (k, v) => {
|
|
24
|
+
try {
|
|
25
|
+
localStorage.setItem(`agentmgr.side.${k}`, v);
|
|
26
|
+
} catch {}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function initSide({ api, esc, toast, $, agents, codeUi, docHtml, renderDiagrams, mainKind, setMain, refit }) {
|
|
31
|
+
const pane = $('#side-pane');
|
|
32
|
+
const body = $('#side-body');
|
|
33
|
+
const code = $('#code');
|
|
34
|
+
const codeHome = $('#term-main');
|
|
35
|
+
const st = { on: pref.get('on', '0') === '1', tab: pref.get('tab', 'docs'), doc: pref.get('doc', 'diagrams'), agentId: null, changes: null, sig: '', loading: false };
|
|
36
|
+
|
|
37
|
+
const docOf = (a, kind) => (kind === 'explain' ? a?.explanation : a?.docs?.[kind]);
|
|
38
|
+
|
|
39
|
+
function show() {
|
|
40
|
+
const on = st.on && Boolean(st.agentId);
|
|
41
|
+
pane.hidden = !on;
|
|
42
|
+
$('#split-pane').hidden = !on;
|
|
43
|
+
$('#term-split-btn').classList.toggle('on', on);
|
|
44
|
+
for (const t of pane.querySelectorAll('[data-side]')) t.classList.toggle('active', t.dataset.side === st.tab);
|
|
45
|
+
// Code lives in one place at a time: here when this tab shows it, else back in the main panel.
|
|
46
|
+
const want = on && st.tab === 'code' ? body : codeHome;
|
|
47
|
+
if (code.parentElement !== want) want === body ? body.replaceChildren(code) : codeHome.insertBefore(code, $('#browser'));
|
|
48
|
+
pane.classList.toggle('with-code', on && st.tab === 'code');
|
|
49
|
+
if (!on) return refit();
|
|
50
|
+
if (st.tab === 'code') codeUi.open(st.agentId);
|
|
51
|
+
else if (st.tab === 'docs') renderDocs(true);
|
|
52
|
+
else renderChanges(true);
|
|
53
|
+
refit();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ------------------------------------------------------------ Docs
|
|
57
|
+
function renderDocs(force) {
|
|
58
|
+
const a = agents.get(st.agentId);
|
|
59
|
+
const d = docOf(a, st.doc);
|
|
60
|
+
const sig = `docs|${st.agentId}|${st.doc}|${d?.at}|${d?.pending}|${d?.error}`;
|
|
61
|
+
if (!force && sig === st.sig) return;
|
|
62
|
+
st.sig = sig;
|
|
63
|
+
const label = DOCS.find(([k]) => k === st.doc)?.[1] ?? st.doc;
|
|
64
|
+
const picker = `<select data-side-doc>${DOCS.map(([k, l]) => `<option value="${k}" ${k === st.doc ? 'selected' : ''}>${l}${docOf(a, k)?.markdown ? ' ✓' : ''}</option>`).join('')}</select>`;
|
|
65
|
+
const stale = d?.markdown && a?.brief?.updatedAt && d.briefUpdatedAt !== a.brief.updatedAt;
|
|
66
|
+
const action = d?.pending
|
|
67
|
+
? `<span class="hint">Writing…</span>`
|
|
68
|
+
: `<button type="button" class="small ${d?.markdown ? 'ghost' : 'primary'}" data-side-write>${d?.markdown ? 'Again' : st.doc === 'diagrams' ? 'Draw' : 'Write'}</button>`;
|
|
69
|
+
let content;
|
|
70
|
+
if (d?.pending && !d?.markdown) content = `<p class="hint">Writing the ${esc(label.toLowerCase())} from the agent's work so far…</p>`;
|
|
71
|
+
else if (d?.error && !d?.markdown) content = `<p class="error">Couldn't write it: ${esc(d.error)}</p>`;
|
|
72
|
+
else if (d?.markdown) content = `<div class="md ${st.doc === 'diagrams' ? 'diagrams-view' : ''}">${docHtml(d.markdown)}</div>`;
|
|
73
|
+
else content = `<p class="hint">No ${esc(label.toLowerCase())} for this agent yet.</p>`;
|
|
74
|
+
body.innerHTML = `<div class="side-tools">${picker}<span class="spacer"></span>${stale ? '<span class="hint">brief changed since</span>' : ''}${action}</div><div class="side-scroll">${content}</div>`;
|
|
75
|
+
renderDiagrams(body);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ------------------------------------------------------------ Changes
|
|
79
|
+
async function loadChanges() {
|
|
80
|
+
st.loading = true;
|
|
81
|
+
try {
|
|
82
|
+
const data = await api('GET', `/agents/${st.agentId}/changes`);
|
|
83
|
+
st.changes = { id: st.agentId, diffHash: data.diffHash, files: parseDiff(data.diff) };
|
|
84
|
+
} catch (err) {
|
|
85
|
+
st.changes = { id: st.agentId, error: err.message };
|
|
86
|
+
}
|
|
87
|
+
st.loading = false;
|
|
88
|
+
renderChanges(true);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hunkHtml(file, head) {
|
|
92
|
+
const h = st.changes?.files?.find((f) => f.path === file)?.hunks.find((x) => x.head === head);
|
|
93
|
+
if (!h) return '<p class="hint">This hunk has changed since; Explain again for the latest.</p>';
|
|
94
|
+
const lines = h.lines.slice(0, 40).map((l) => `<div class="sd-${l.t}">${l.t === 'add' ? '+' : l.t === 'del' ? '−' : ' '}${esc(l.text)}</div>`);
|
|
95
|
+
if (h.lines.length > 40) lines.push(`<div class="hint">… ${h.lines.length - 40} more lines (Review changes shows all)</div>`);
|
|
96
|
+
return `<div class="sd-hunk"><div class="sd-head">${esc(head)}</div>${lines.join('')}</div>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderChanges(force) {
|
|
100
|
+
if (!st.changes || st.changes.id !== st.agentId) {
|
|
101
|
+
body.innerHTML = '<p class="hint side-pad">Loading the changes…</p>';
|
|
102
|
+
if (!st.loading) loadChanges();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const a = agents.get(st.agentId);
|
|
106
|
+
const w = walkthroughOf(a, st.changes.diffHash);
|
|
107
|
+
const sig = `changes|${st.agentId}|${a?.docs?.walkthrough?.at}|${w.pending}|${w.error}|${st.changes.diffHash}`;
|
|
108
|
+
if (!force && sig === st.sig) return;
|
|
109
|
+
st.sig = sig;
|
|
110
|
+
const tools = `<div class="side-tools">${explainButton(w, 'data-side-explain')}<span class="spacer"></span><button type="button" class="small ghost" data-side-reload title="Load the changes again">⟳</button></div>`;
|
|
111
|
+
let content;
|
|
112
|
+
if (st.changes.error) content = `<p class="error">${esc(st.changes.error)}</p>`;
|
|
113
|
+
else if (!st.changes.files.length) content = '<p class="hint">No changes yet.</p>';
|
|
114
|
+
else if (!w.doc) content = `${w.error && !w.pending ? `<p class="error">Couldn't explain them: ${esc(w.error)}</p>` : ''}<p class="hint">${st.changes.files.length} file${st.changes.files.length === 1 ? '' : 's'} changed. <b>Explain changes</b> goes through them one by one: what, why, the risk and what to check.</p>`;
|
|
115
|
+
else {
|
|
116
|
+
const list = w.doc.data.changes;
|
|
117
|
+
content = `<p class="hint">${esc(w.doc.data.summary)}${w.stale ? ' <b>The changes moved since this was written.</b>' : ''}</p>${list
|
|
118
|
+
.map((c, i) => `<section class="sd-step"><div class="sd-file"><code>${esc(c.file)}</code></div>${cardHtml(c, i, list.length, esc)}${hunkHtml(c.file, c.head)}</section>`)
|
|
119
|
+
.join('')}`;
|
|
120
|
+
}
|
|
121
|
+
body.innerHTML = `${tools}<div class="side-scroll">${content}</div>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ------------------------------------------------------------ events
|
|
125
|
+
pane.addEventListener('click', async (e) => {
|
|
126
|
+
const tab = e.target.closest('[data-side]');
|
|
127
|
+
if (tab) return setTab(tab.dataset.side);
|
|
128
|
+
if (e.target.closest('#side-close')) return toggle(false);
|
|
129
|
+
if (e.target.closest('[data-side-reload]')) return loadChanges();
|
|
130
|
+
try {
|
|
131
|
+
if (e.target.closest('[data-side-explain]')) {
|
|
132
|
+
agents.set(st.agentId, await api('POST', `/agents/${st.agentId}/docs/walkthrough?background=1`));
|
|
133
|
+
return renderChanges(true);
|
|
134
|
+
}
|
|
135
|
+
if (e.target.closest('[data-side-write]')) {
|
|
136
|
+
const route = st.doc === 'explain' ? `/agents/${st.agentId}/explain?background=1` : `/agents/${st.agentId}/docs/${st.doc}?background=1`;
|
|
137
|
+
agents.set(st.agentId, await api('POST', route));
|
|
138
|
+
return renderDocs(true);
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
toast(`Error: ${err.message}`, 5000);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
pane.addEventListener('change', (e) => {
|
|
145
|
+
if (!e.target.matches('[data-side-doc]')) return;
|
|
146
|
+
st.doc = e.target.value;
|
|
147
|
+
pref.set('doc', st.doc);
|
|
148
|
+
renderDocs(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
function setTab(tab) {
|
|
152
|
+
st.tab = tab;
|
|
153
|
+
pref.set('tab', tab);
|
|
154
|
+
// Code can't be on both sides: the main panel goes back to the agent's session.
|
|
155
|
+
if (tab === 'code' && mainKind() === 'code') setMain('claude');
|
|
156
|
+
show();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toggle(on = !st.on) {
|
|
160
|
+
st.on = on;
|
|
161
|
+
pref.set('on', on ? '1' : '0');
|
|
162
|
+
if (on && st.tab === 'code' && mainKind() === 'code') st.tab = 'docs';
|
|
163
|
+
show();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
$('#term-split-btn').onclick = () => toggle();
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
/** The terminal now shows this agent (or none): follow it. */
|
|
170
|
+
follow(id) {
|
|
171
|
+
if (id === st.agentId) return;
|
|
172
|
+
st.agentId = id ?? null;
|
|
173
|
+
st.changes = null;
|
|
174
|
+
st.sig = '';
|
|
175
|
+
show();
|
|
176
|
+
},
|
|
177
|
+
/** The main panel is about to show Code: move this pane off it. */
|
|
178
|
+
mainShowsCode() {
|
|
179
|
+
if (st.on && st.tab === 'code') {
|
|
180
|
+
st.tab = 'docs';
|
|
181
|
+
show();
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
showing: (tab) => st.on && Boolean(st.agentId) && st.tab === tab,
|
|
185
|
+
agentChanged(a) {
|
|
186
|
+
if (!st.on || a.id !== st.agentId) return;
|
|
187
|
+
if (st.tab === 'docs') renderDocs(false);
|
|
188
|
+
else if (st.tab === 'changes') renderChanges(false);
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
package/public/style.css
CHANGED
|
@@ -829,3 +829,47 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
|
|
|
829
829
|
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
830
830
|
.hint.warn { display: block; margin-top: 4px; color: var(--yellow); }
|
|
831
831
|
.hint.warn[hidden] { display: none; }
|
|
832
|
+
|
|
833
|
+
/* Attachments in New agents and Ask: a button, then a chip per file. */
|
|
834
|
+
.attach-box { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: -4px 0 10px; }
|
|
835
|
+
.attach-chips { display: contents; }
|
|
836
|
+
.attach-chip { display: inline-flex; align-items: center; gap: 6px; padding: 2px 4px 2px 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--panel-2); font-size: 12px; max-width: 100%; }
|
|
837
|
+
.attach-chip.fail { border-color: var(--red); }
|
|
838
|
+
.attach-chip .link { padding: 0 4px; }
|
|
839
|
+
form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px; }
|
|
840
|
+
|
|
841
|
+
/* Walkthrough: an explanation card above each hunk; the current one is marked. */
|
|
842
|
+
.wt-card { display: flex; flex-direction: column; gap: 3px; padding: 8px 10px; font: 13px/1.45 var(--sans, system-ui); white-space: normal; color: var(--text); }
|
|
843
|
+
.wt-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
844
|
+
.wt-n { font: 11px var(--mono, ui-monospace); color: var(--muted); }
|
|
845
|
+
.wt-k { display: inline-block; min-width: 44px; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
|
|
846
|
+
.rv-explain td { background: var(--panel); border-top: 1px solid var(--border); }
|
|
847
|
+
.rv-explain.current td { box-shadow: inset 3px 0 0 var(--accent, #6b8cff); background: var(--panel-2); }
|
|
848
|
+
.rv-walk { display: inline-flex; align-items: center; gap: 4px; }
|
|
849
|
+
.rv-walk-note { margin: 4px 12px 8px; }
|
|
850
|
+
|
|
851
|
+
/* Split view: the terminal (or a browser tab, or Code) on the left, a pane on the right. */
|
|
852
|
+
.term-split { flex: 1; display: flex; min-height: 0; min-width: 0; }
|
|
853
|
+
.term-main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
854
|
+
.split-pane { flex: none; width: 0; }
|
|
855
|
+
.split-pane::after { top: 0; bottom: 0; left: -4px; width: 8px; cursor: col-resize; }
|
|
856
|
+
.split-pane[hidden], .side-pane[hidden] { display: none !important; }
|
|
857
|
+
.side-pane { flex: none; width: var(--pane-w, 45%); display: flex; flex-direction: column; min-height: 0; border-left: 1px solid #30363d; background: var(--bg); color: var(--text); }
|
|
858
|
+
.side-bar { display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: #161a22; color: #c9d1d9; font-size: 12.5px; }
|
|
859
|
+
.side-bar button { color: #c9d1d9; border-color: #30363d; }
|
|
860
|
+
.side-body { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
|
861
|
+
.side-pane .code { display: flex; flex: 1; }
|
|
862
|
+
.side-tools { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); }
|
|
863
|
+
.side-tools select { width: auto; }
|
|
864
|
+
.side-scroll { flex: 1; overflow: auto; padding: 10px 12px; }
|
|
865
|
+
.side-pad { padding: 10px 12px; }
|
|
866
|
+
.side-scroll .diagrams-view .diagram, .side-scroll .diagram { display: flex; justify-content: center; overflow-x: auto; }
|
|
867
|
+
.side-scroll .diagram svg { max-width: 100%; height: auto; }
|
|
868
|
+
.sd-step { margin-bottom: 14px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
869
|
+
.sd-file { padding: 4px 10px; background: var(--panel-2); font-size: 12px; }
|
|
870
|
+
.sd-hunk { font: 12px/1.45 var(--mono, ui-monospace, monospace); white-space: pre; overflow-x: auto; border-top: 1px solid var(--border); }
|
|
871
|
+
.sd-hunk > div { padding: 0 10px; }
|
|
872
|
+
.sd-head { color: var(--muted); background: var(--panel); }
|
|
873
|
+
.sd-add { background: rgba(46, 160, 67, .15); }
|
|
874
|
+
.sd-del { background: rgba(248, 81, 73, .15); }
|
|
875
|
+
#term-split-btn.on { background: #262c38; }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// An agent's changes explained one by one (the "walkthrough" document): shared by the review
|
|
2
|
+
// screen, which shows each explanation above its hunk, and the Changes pane of the split view.
|
|
3
|
+
|
|
4
|
+
const RISK = { high: 'fail', medium: '', low: 'open' };
|
|
5
|
+
|
|
6
|
+
/** The walkthrough of an agent and whether it still matches the changes (diffHash from /changes). */
|
|
7
|
+
export function walkthroughOf(agent, diffHash) {
|
|
8
|
+
const w = agent?.docs?.walkthrough;
|
|
9
|
+
return {
|
|
10
|
+
doc: w?.data ? w : null,
|
|
11
|
+
pending: Boolean(w?.pending),
|
|
12
|
+
error: w?.error ?? null,
|
|
13
|
+
stale: Boolean(w?.data && diffHash && w.diffHash !== diffHash),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Index of the explanation for a hunk (file + header), or -1. */
|
|
18
|
+
export const stepOf = (doc, file, head) => doc?.data.changes.findIndex((c) => c.file === file && c.head === head) ?? -1;
|
|
19
|
+
|
|
20
|
+
/** One explanation: title, risk, what / why / check. */
|
|
21
|
+
export function cardHtml(c, i, total, esc) {
|
|
22
|
+
if (c.unexplained) return `<div class="wt-card"><div class="wt-head"><span class="wt-n">${i + 1}/${total}</span><span class="hint">Not explained (the model skipped it)</span></div></div>`;
|
|
23
|
+
return `<div class="wt-card">
|
|
24
|
+
<div class="wt-head"><span class="wt-n">${i + 1}/${total}</span><b>${esc(c.title)}</b><span class="tag ${RISK[c.risk] ?? ''}">${esc(c.risk)} risk</span></div>
|
|
25
|
+
${c.what ? `<div><span class="wt-k">What</span> ${esc(c.what)}</div>` : ''}
|
|
26
|
+
${c.why ? `<div><span class="wt-k">Why</span> ${esc(c.why)}</div>` : ''}
|
|
27
|
+
${c.check ? `<div><span class="wt-k">Check</span> ${esc(c.check)}</div>` : ''}
|
|
28
|
+
</div>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The button that writes (or rewrites) the walkthrough, with its state. */
|
|
32
|
+
export function explainButton(w, attrs = 'data-wt-explain') {
|
|
33
|
+
const label = w.pending ? 'Explaining…' : w.doc ? (w.stale ? 'Explain again (changed)' : 'Explain again') : '✨ Explain changes';
|
|
34
|
+
return `<button type="button" class="small ${w.doc && !w.stale ? 'ghost' : 'primary'}" ${attrs} ${w.pending ? 'disabled' : ''}
|
|
35
|
+
title="Explain each change one by one: what, why, the risk and what to check, in the order to read them (a separate one-off call; the agent isn't interrupted)">${label}</button>`;
|
|
36
|
+
}
|
package/src/backlog.js
CHANGED
|
@@ -89,7 +89,7 @@ export class Backlog extends EventEmitter {
|
|
|
89
89
|
// ------------------------------------------------------------ editing
|
|
90
90
|
|
|
91
91
|
/** Queue one item per title. Options are what an agent would take (repo, dir, base, project, ...). */
|
|
92
|
-
add(titles, { repo, dir, base, project, worktree, permissionMode, model, plan, planReview, engine, priority = 2 } = {}) {
|
|
92
|
+
add(titles, { repo, dir, base, project, worktree, permissionMode, model, plan, planReview, engine, attachments, priority = 2 } = {}) {
|
|
93
93
|
const list = titles.map((t) => String(t ?? '').trim()).filter(Boolean);
|
|
94
94
|
if (!list.length) throw err(400, 'At least one item is required');
|
|
95
95
|
const pr = parsePriority(priority);
|
|
@@ -120,6 +120,8 @@ export class Backlog extends EventEmitter {
|
|
|
120
120
|
permissionMode: permissionMode || null,
|
|
121
121
|
model: model || null,
|
|
122
122
|
engine: engine || null,
|
|
123
|
+
// Staged uploads; each item's agent gets its own copies when it starts.
|
|
124
|
+
attachments: Array.isArray(attachments) && attachments.length ? attachments.slice(0, 20).map(String) : undefined,
|
|
123
125
|
plan: Boolean(plan),
|
|
124
126
|
planReview: planReview ?? null,
|
|
125
127
|
priority: pr,
|
|
@@ -219,6 +221,7 @@ export class Backlog extends EventEmitter {
|
|
|
219
221
|
permissionMode: it.permissionMode,
|
|
220
222
|
model: it.model,
|
|
221
223
|
engine: it.engine ?? undefined,
|
|
224
|
+
attachments: it.attachments,
|
|
222
225
|
plan: it.plan,
|
|
223
226
|
planReview: it.planReview ?? undefined,
|
|
224
227
|
priority: it.priority,
|
package/src/brief.js
CHANGED
|
@@ -411,6 +411,81 @@ export function diagramsOnly(markdown) {
|
|
|
411
411
|
.trim();
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
+
// ------------------------------------------------------------------ Change walkthrough
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The agent's changes explained one by one, for review: each hunk of its diff gets a title, what
|
|
418
|
+
* changed, why, a risk and what to check, in the order a reviewer should read them.
|
|
419
|
+
* `hunks`: [{ id, file, head, text }].
|
|
420
|
+
*/
|
|
421
|
+
export function walkthroughPrompt({ intent, status, digest, briefMarkdown, hunks }) {
|
|
422
|
+
return [
|
|
423
|
+
"You help a person review an AI coding agent's changes, one change at a time.",
|
|
424
|
+
'Below are its changes as numbered hunks (H1, H2, ...), with the request and its conversation for the reasons behind them.',
|
|
425
|
+
'',
|
|
426
|
+
'Reply with one JSON object only, no prose around it:',
|
|
427
|
+
'{"summary": "<two sentences: what the changes do overall>", "changes": [{"id": "H3", "title": "<under 10 words>", "what": "<what this hunk changes, concretely>", "why": "<why, from the request or conversation; \"Not stated\" if unknown>", "risk": "low|medium|high", "check": "<what the reviewer should verify here>"}]}',
|
|
428
|
+
'',
|
|
429
|
+
'Rules:',
|
|
430
|
+
'- One entry per hunk, every hunk exactly once. Order them the way a reviewer should read them: the core change first, then what depends on it, then tests, docs and small edits.',
|
|
431
|
+
'- Be specific: name the functions, values and behavior. Base it only on the hunks and the context; do not invent intent.',
|
|
432
|
+
'- risk is high for security, data loss, concurrency, money or public API changes; medium for behavior changes; low for refactors, tests, docs and formatting.',
|
|
433
|
+
'- Keep what, why and check to one or two sentences each.',
|
|
434
|
+
'',
|
|
435
|
+
...contextBlock({ intent, status, digest, repo: '', briefMarkdown }),
|
|
436
|
+
'<hunks>',
|
|
437
|
+
...hunks.map((h) => `<hunk id="${h.id}" file="${h.file}">\n${h.head}\n${h.text}\n</hunk>`),
|
|
438
|
+
'</hunks>',
|
|
439
|
+
].join('\n');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The hunks a walkthrough explains, from a parsed diff: ids, and the text sent (capped). */
|
|
443
|
+
export function walkthroughHunks(files, { maxLines = 80, maxChars = 60_000 } = {}) {
|
|
444
|
+
const out = [];
|
|
445
|
+
let used = 0;
|
|
446
|
+
for (const f of files) {
|
|
447
|
+
if (f.binary) continue;
|
|
448
|
+
for (const h of f.hunks) {
|
|
449
|
+
const lines = h.lines.map((l) => `${l.t === 'add' ? '+' : l.t === 'del' ? '-' : ' '}${l.text}`);
|
|
450
|
+
const text = lines.length > maxLines ? [...lines.slice(0, maxLines), `… (${lines.length - maxLines} more lines)`].join('\n') : lines.join('\n');
|
|
451
|
+
if (used + text.length > maxChars) return { hunks: out, cut: true };
|
|
452
|
+
used += text.length;
|
|
453
|
+
out.push({ id: `H${out.length + 1}`, file: f.path, head: h.head, text });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return { hunks: out, cut: false };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The model's reply mapped back to hunks (file + header), in its reading order; unknown ids dropped. */
|
|
460
|
+
export function walkthroughFrom(reply, hunks) {
|
|
461
|
+
const byId = new Map(hunks.map((h) => [h.id, h]));
|
|
462
|
+
const seen = new Set();
|
|
463
|
+
const risk = (r) => (['low', 'medium', 'high'].includes(r) ? r : 'medium');
|
|
464
|
+
const changes = [];
|
|
465
|
+
for (const c of Array.isArray(reply?.changes) ? reply.changes : []) {
|
|
466
|
+
const h = byId.get(String(c?.id ?? '').trim());
|
|
467
|
+
if (!h || seen.has(h.id)) continue;
|
|
468
|
+
seen.add(h.id);
|
|
469
|
+
changes.push({ file: h.file, head: h.head, title: String(c.title ?? '').slice(0, 120), what: String(c.what ?? '').slice(0, 800), why: String(c.why ?? '').slice(0, 800), risk: risk(c.risk), check: String(c.check ?? '').slice(0, 600) });
|
|
470
|
+
}
|
|
471
|
+
// Hunks the model skipped still get a place (unexplained), so stepping through covers everything.
|
|
472
|
+
for (const h of hunks) if (!seen.has(h.id)) changes.push({ file: h.file, head: h.head, title: '', what: '', why: '', risk: 'low', check: '', unexplained: true });
|
|
473
|
+
return { summary: String(reply?.summary ?? '').slice(0, 600), changes };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Markdown version, for Copy / Download and the CLI. */
|
|
477
|
+
export function walkthroughMarkdown({ summary, changes }, title = '') {
|
|
478
|
+
const out = [`# Changes explained${title ? `: ${title}` : ''}`, '', summary, ''];
|
|
479
|
+
changes.forEach((c, i) => {
|
|
480
|
+
out.push(`## ${i + 1}. ${c.title || '(not explained)'}`, '', `\`${c.file}\` · \`${c.head.replace(/^(@@[^@]*@@).*/, '$1')}\` · risk: ${c.risk}`, '');
|
|
481
|
+
if (c.what) out.push(`- **What:** ${c.what}`);
|
|
482
|
+
if (c.why) out.push(`- **Why:** ${c.why}`);
|
|
483
|
+
if (c.check) out.push(`- **Check:** ${c.check}`);
|
|
484
|
+
out.push('');
|
|
485
|
+
});
|
|
486
|
+
return out.join('\n').trim() + '\n';
|
|
487
|
+
}
|
|
488
|
+
|
|
414
489
|
// ------------------------------------------------------------------ Human vs AI
|
|
415
490
|
|
|
416
491
|
export const contextBlock = ({ intent, status, digest, repo, briefMarkdown }) => [
|