ai-agentmanager 1.2.1 → 1.3.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/public/land.js ADDED
@@ -0,0 +1,149 @@
1
+ // Land tab: every agent's open pull request, grouped by what it needs to get merged, with
2
+ // Review, Update, Ask to fix and Merge. Settings for sending failures back to agents at the bottom.
3
+
4
+ const SHORT = { ready: 'ready', fix: 'to fix', behind: 'behind', waiting: 'waiting', draft: 'draft' };
5
+ const GROUPS = [
6
+ ['ready', 'Ready to merge'],
7
+ ['fix', 'Needs fixes'],
8
+ ['behind', 'Behind its base'],
9
+ ['waiting', 'Waiting for checks or review'],
10
+ ['draft', 'Drafts'],
11
+ ];
12
+
13
+ /** Checks, review and merge state of an open pull request, as small tags. */
14
+ export function prChips(pr, esc) {
15
+ const c = pr.checks;
16
+ const out = [];
17
+ if (c?.state === 'failed') out.push(`<span class="tag fail" title="${esc(c.failed.join(', '))}">✗ ${c.failed.length} failing</span>`);
18
+ else if (c?.state === 'running') out.push(`<span class="tag">◔ ${c.running} running</span>`);
19
+ else if (c?.state === 'passed') out.push('<span class="tag open">✓ checks</span>');
20
+ if (pr.review === 'APPROVED') out.push('<span class="tag open">Approved</span>');
21
+ else if (pr.review === 'CHANGES_REQUESTED') out.push('<span class="tag fail">Changes requested</span>');
22
+ else if (pr.review === 'REVIEW_REQUIRED') out.push('<span class="tag">Review required</span>');
23
+ if (pr.mergeable === 'CONFLICTING' || pr.mergeState === 'DIRTY') out.push('<span class="tag fail">Conflicts</span>');
24
+ else if (pr.mergeState === 'BEHIND') out.push('<span class="tag">Behind</span>');
25
+ return out.join(' ');
26
+ }
27
+
28
+ export function initLand({ api, esc, toast, agents, $, select, openReview, isOpen }) {
29
+ const view = $('#land-view');
30
+ let settings = null;
31
+
32
+ const rows = () => {
33
+ const out = [];
34
+ for (const a of agents.values()) {
35
+ for (const pr of a.prs ?? []) {
36
+ if (pr.state !== 'OPEN' || pr.source === 'mentioned') continue;
37
+ out.push({ a, pr, land: pr.land ?? 'waiting' });
38
+ }
39
+ }
40
+ return out.sort((x, y) => (x.a.priority ?? 2) - (y.a.priority ?? 2));
41
+ };
42
+
43
+ function rowHtml({ a, pr, land }) {
44
+ const canFix = land === 'fix' || land === 'behind';
45
+ return `<li class="agent land-row" data-agent="${a.id}" data-url="${esc(pr.url)}">
46
+ <div class="line1"><span class="prio p${a.priority ?? 2}">P${a.priority ?? 2}</span>
47
+ <a class="name" href="${esc(pr.url)}" target="_blank" rel="noopener" title="${esc(pr.title ?? pr.url)}">#${esc(pr.number ?? '')} ${esc(pr.title ?? pr.url)}</a></div>
48
+ <div class="line2"><button class="link" data-land="goto">${esc(a.name)}</button>${a.runtime?.busy ? ' <span class="hint">working</span>' : ''}</div>
49
+ <div class="land-chips">${prChips(pr, esc)}</div>
50
+ <div class="bl-actions">
51
+ <button class="small" data-land="review" title="Its diff, line comments for the agent, commit and push">Review</button>
52
+ ${land === 'behind' || pr.mergeable === 'CONFLICTING' ? '<button class="small" data-land="update" title="Merge the base branch in here (when the agent is idle and its work is committed) and push; otherwise, or on conflicts, ask the agent">Update</button>' : ''}
53
+ ${canFix ? '<button class="small" data-land="fix" title="Send the failures, requested changes or conflicts to the agent">Ask to fix</button>' : ''}
54
+ ${land === 'ready' ? '<button class="small primary" data-land="merge">Merge</button>' : ''}
55
+ ${land === 'waiting' || land === 'behind' ? '<button class="small" data-land="auto" title="GitHub merges it once checks and reviews allow">Auto-merge</button>' : ''}
56
+ </div>
57
+ </li>`;
58
+ }
59
+
60
+ function render() {
61
+ const list = rows();
62
+ const need = list.filter((r) => r.land === 'ready' || r.land === 'fix').length;
63
+ $('#land-count').textContent = list.length ? String(list.length) : '';
64
+ $('#land-count').className = `badge ${list.some((r) => r.land === 'ready') ? 'ready' : ''}`;
65
+ $('#land-count').title = need ? `${need} ready or needing fixes` : '';
66
+ if (!isOpen()) return;
67
+ const s = settings;
68
+ view.innerHTML = `
69
+ <div class="counts">${GROUPS.map(([k, label]) => {
70
+ const n = list.filter((r) => r.land === k).length;
71
+ return n ? `<span class="${k === 'fix' ? 'fail' : ''}">${n} ${SHORT[k]}</span>` : '';
72
+ }).join('')}<span class="spacer"></span><button class="link" data-land="refresh" title="Ask GitHub again for every open pull request">↻ Refresh</button></div>
73
+ ${list.length
74
+ ? `<ul class="agent-list">${GROUPS.map(([k, label]) => {
75
+ const g = list.filter((r) => r.land === k);
76
+ return g.length ? `<li class="project open"><div class="project-head bl-head"><span class="pname">${label}</span><span class="pcount">${g.length}</span></div><ul>${g.map(rowHtml).join('')}</ul></li>` : '';
77
+ }).join('')}</ul>`
78
+ : '<p class="hint land-empty">No open pull requests from agents. Open one from <b>Review changes</b> on an agent, or let the agent open it.</p>'}
79
+ ${s ? `<details class="land-settings"><summary>When checks fail or changes are requested</summary>
80
+ <label class="check"><input type="checkbox" data-set="fixChecks" ${s.fixChecks ? 'checked' : ''}> Send failing checks to the agent</label>
81
+ <label class="check"><input type="checkbox" data-set="fixReviews" ${s.fixReviews ? 'checked' : ''}> Send requested changes to the agent</label>
82
+ <label>At most <input type="number" min="0" max="20" data-set="maxFixes" value="${s.maxFixes}"> times per pull request</label>
83
+ <label>Merge with <select data-set="mergeMethod">${['squash', 'merge', 'rebase'].map((m) => `<option ${m === s.mergeMethod ? 'selected' : ''}>${m}</option>`).join('')}</select></label>
84
+ <label class="check"><input type="checkbox" data-set="deleteBranch" ${s.deleteBranch ? 'checked' : ''}> Delete the branch on GitHub after merging</label>
85
+ </details>` : ''}`;
86
+ }
87
+
88
+ async function load() {
89
+ try {
90
+ settings = (await api('GET', '/landing')).settings;
91
+ } catch {}
92
+ render();
93
+ }
94
+
95
+ view.addEventListener('click', async (e) => {
96
+ const b = e.target.closest('[data-land]');
97
+ if (!b) return;
98
+ const row = b.closest('[data-agent]');
99
+ const id = row?.dataset.agent;
100
+ const url = row?.dataset.url;
101
+ const run = async (fn, ok) => {
102
+ b.disabled = true;
103
+ try {
104
+ const r = await fn();
105
+ const msg = typeof ok === 'function' ? ok(r) : ok;
106
+ if (msg) toast(msg, 4000);
107
+ } catch (err) {
108
+ toast(`Error: ${err.message}`, 6000);
109
+ } finally {
110
+ b.disabled = false;
111
+ }
112
+ };
113
+ switch (b.dataset.land) {
114
+ case 'goto':
115
+ return select(id);
116
+ case 'review':
117
+ return openReview(id);
118
+ case 'refresh':
119
+ return run(async () => {
120
+ const ids = [...new Set(rows().map((r) => r.a.id))];
121
+ await Promise.all(ids.map((x) => api('POST', `/agents/${x}/refresh`).catch(() => null)));
122
+ }, 'Asked GitHub again');
123
+ case 'update':
124
+ return run(() => api('POST', `/agents/${id}/land/update`, {}), (r) =>
125
+ r.upToDate ? `Already up to date with ${r.target}` : r.merged ? `Merged ${r.merged} commit${r.merged === 1 ? '' : 's'} from ${r.target}${r.pushed ? ' and pushed' : ''}` : `Asked the agent to update (${r.why})`);
126
+ case 'fix':
127
+ return run(() => api('POST', `/agents/${id}/land/fix`, { url }), 'Sent to the agent');
128
+ case 'merge':
129
+ if (!confirm(`Merge this pull request with ${settings?.mergeMethod ?? 'squash'}?`)) return;
130
+ return run(() => api('POST', `/agents/${id}/land/merge`, { url }), 'Merged');
131
+ case 'auto':
132
+ return run(() => api('POST', `/agents/${id}/land/merge`, { url, auto: true }), 'Auto-merge is on: GitHub merges it once it may');
133
+ }
134
+ });
135
+
136
+ view.addEventListener('change', async (e) => {
137
+ const k = e.target.dataset.set;
138
+ if (!k) return;
139
+ const v = e.target.type === 'checkbox' ? e.target.checked : e.target.type === 'number' ? Number(e.target.value) : e.target.value;
140
+ try {
141
+ settings = (await api('PUT', '/landing', { [k]: v })).settings;
142
+ toast('Saved');
143
+ } catch (err) {
144
+ toast(`Error: ${err.message}`, 5000);
145
+ }
146
+ });
147
+
148
+ return { render, load };
149
+ }
@@ -0,0 +1,132 @@
1
+ // Playbooks dialog: list, edit, run now (asking for {{placeholders}}), schedule.
2
+
3
+ const placeholders = (text) => [...new Set([...String(text ?? '').matchAll(/\{\{\s*([\w-]+)\s*\}\}/g)].map((m) => m[1]))];
4
+ const EVERY = { hour: 'every hour', day: 'every day', weekday: 'every weekday', week: 'every week' };
5
+
6
+ export function initPlaybooks({ api, esc, toast, $, select }) {
7
+ const dlg = $('#playbooks-dialog');
8
+ const f = $('#pb-form');
9
+ let list = [];
10
+
11
+ const when = (iso) => (iso ? new Date(iso).toLocaleString([], { weekday: 'short', hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }) : '');
12
+
13
+ function renderList() {
14
+ $('#pb-list').innerHTML = list.length
15
+ ? `<table class="spend-table pb-table"><tr><th>Playbook</th><th>Projects</th><th>Schedule</th><th>Last run</th><th></th></tr>${list
16
+ .map(
17
+ (p) => `<tr data-pb="${p.id}"><td><b>${esc(p.name)}</b><div class="hint pb-intent">${esc(p.intent.slice(0, 140))}</div></td>
18
+ <td>${p.projects.map((x) => `<code>${esc(x)}</code>`).join(' ')}</td>
19
+ <td>${p.schedule ? `${EVERY[p.schedule.every]}${p.schedule.every === 'hour' ? ` at :${p.schedule.at.slice(3)}` : ` at ${p.schedule.at}`}${p.schedule.day ? ` (${p.schedule.day})` : ''}${p.enabled ? `<div class="hint">next ${when(p.nextRunAt)}</div>` : ' <span class="tag">off</span>'}` : '<span class="hint">by hand</span>'}</td>
20
+ <td>${p.lastRunAt ? `${when(p.lastRunAt)}<div class="hint">${(p.runs?.at(-1)?.agents ?? []).map((a) => `<a href="#${a.id}" data-pb-goto="${a.id}">${esc(a.name)}</a>`).join(', ')}${p.runs?.at(-1)?.failed?.length ? ` <span class="error">${p.runs.at(-1).failed.length} failed</span>` : ''}</div>` : '—'}</td>
21
+ <td class="pb-actions"><button type="button" class="small primary" data-pb-run>Run now</button> <button type="button" class="small" data-pb-edit>Edit</button></td></tr>`,
22
+ )
23
+ .join('')}</table>`
24
+ : '<p class="hint">No playbooks yet. <b>+ New playbook</b>, or <b>Save as playbook</b> in New agents.</p>';
25
+ }
26
+
27
+ async function load() {
28
+ list = await api('GET', '/playbooks');
29
+ renderList();
30
+ }
31
+
32
+ function fillDefaults(values = {}) {
33
+ const keys = placeholders(f.intent.value);
34
+ $('#pb-defaults').innerHTML = keys.length
35
+ ? `<div class="row">${keys.map((k) => `<label>Default for <code>{{${esc(k)}}}</code> <span class="hint">needed for a schedule</span><input data-default="${esc(k)}" value="${esc(values[k] ?? '')}" /></label>`).join('')}</div>`
36
+ : '';
37
+ }
38
+
39
+ function edit(p = {}) {
40
+ f.hidden = false;
41
+ $('#pb-error').hidden = true;
42
+ f.id.value = p.id ?? '';
43
+ f.name.value = p.name ?? '';
44
+ f.intent.value = p.intent ?? '';
45
+ f.projects.value = (p.projects ?? []).join(' ');
46
+ f.model.value = p.model ?? '';
47
+ f.permissionMode.value = p.permissionMode ?? '';
48
+ f.priority.value = String(p.priority ?? 2);
49
+ f.every.value = p.schedule?.every ?? '';
50
+ f.at.value = p.schedule?.at ?? '09:00';
51
+ f.day.value = p.schedule?.day ?? 'mon';
52
+ f.enabled.checked = p.enabled ?? true;
53
+ $('#pb-delete').hidden = !p.id;
54
+ fillDefaults(p.defaults);
55
+ f.name.focus();
56
+ }
57
+
58
+ f.intent.addEventListener('input', () => {
59
+ const cur = Object.fromEntries([...f.querySelectorAll('[data-default]')].map((el) => [el.dataset.default, el.value]));
60
+ fillDefaults(cur);
61
+ });
62
+
63
+ f.addEventListener('submit', async (e) => {
64
+ e.preventDefault();
65
+ const body = {
66
+ name: f.name.value,
67
+ intent: f.intent.value,
68
+ projects: f.projects.value,
69
+ model: f.model.value.trim(),
70
+ permissionMode: f.permissionMode.value,
71
+ priority: Number(f.priority.value),
72
+ defaults: Object.fromEntries([...f.querySelectorAll('[data-default]')].map((el) => [el.dataset.default, el.value.trim()]).filter(([, v]) => v)),
73
+ schedule: f.every.value ? { every: f.every.value, at: f.at.value, day: f.day.value } : null,
74
+ enabled: f.enabled.checked,
75
+ };
76
+ try {
77
+ await api(f.id.value ? 'PUT' : 'POST', f.id.value ? `/playbooks/${f.id.value}` : '/playbooks', body);
78
+ f.hidden = true;
79
+ toast('Playbook saved');
80
+ await load();
81
+ } catch (err) {
82
+ $('#pb-error').hidden = false;
83
+ $('#pb-error').textContent = err.message;
84
+ }
85
+ });
86
+
87
+ $('#pb-list').addEventListener('click', async (e) => {
88
+ const go = e.target.closest('[data-pb-goto]');
89
+ if (go) {
90
+ e.preventDefault();
91
+ dlg.close();
92
+ return select(go.dataset.pbGoto);
93
+ }
94
+ const row = e.target.closest('[data-pb]');
95
+ const p = row && list.find((x) => x.id === row.dataset.pb);
96
+ if (!p) return;
97
+ if (e.target.closest('[data-pb-edit]')) return edit(p);
98
+ if (e.target.closest('[data-pb-run]')) {
99
+ const vars = {};
100
+ for (const k of p.placeholders) {
101
+ const v = prompt(`${p.name}: value for {{${k}}}`, p.defaults?.[k] ?? '');
102
+ if (v === null) return;
103
+ vars[k] = v;
104
+ }
105
+ try {
106
+ const r = await api('POST', `/playbooks/${p.id}/run`, { vars });
107
+ toast(`Started ${r.agents.length} agent${r.agents.length === 1 ? '' : 's'}${r.failed.length ? `; ${r.failed.length} failed: ${r.failed[0].reason}` : ''}`, 5000);
108
+ await load();
109
+ } catch (err) {
110
+ toast(`Error: ${err.message}`, 5000);
111
+ }
112
+ }
113
+ });
114
+
115
+ $('#pb-new').onclick = () => edit();
116
+ $('#pb-cancel').onclick = () => (f.hidden = true);
117
+ $('#pb-delete').onclick = async () => {
118
+ if (!confirm(`Delete the playbook "${f.name.value}"? Agents it started stay.`)) return;
119
+ await api('DELETE', `/playbooks/${f.id.value}`).catch((err) => toast(`Error: ${err.message}`));
120
+ f.hidden = true;
121
+ await load();
122
+ };
123
+ $('#pb-close').onclick = () => dlg.close();
124
+
125
+ async function open(prefill) {
126
+ f.hidden = true;
127
+ if (!dlg.open) dlg.showModal();
128
+ await load();
129
+ if (prefill) edit(prefill);
130
+ }
131
+ return { open };
132
+ }
@@ -0,0 +1,368 @@
1
+ // Review screen: an agent's changes file by file, line comments sent back to the agent as one
2
+ // message, and Commit / Push & open PR. Comments not sent yet are kept per agent in this browser.
3
+
4
+ /** Unified diff -> [{ path, status, binary, hunks: [{ head, lines: [{ t, old, new, text }] }] }] */
5
+ export function parseDiff(text) {
6
+ const files = [];
7
+ let f = null;
8
+ let h = null;
9
+ let o = 0;
10
+ let n = 0;
11
+ let left = 0; // lines the hunk header still promises (old side + new side)
12
+ for (const line of String(text ?? '').split('\n')) {
13
+ if (line.startsWith('diff --git ')) {
14
+ const m = / b\/(.*)$/.exec(line);
15
+ f = { path: m ? m[1] : line.slice(11), status: 'modified', binary: false, hunks: [] };
16
+ files.push(f);
17
+ h = null;
18
+ continue;
19
+ }
20
+ if (!f) continue;
21
+ if (!h) {
22
+ if (line.startsWith('new file')) f.status = 'added';
23
+ else if (line.startsWith('deleted file')) f.status = 'deleted';
24
+ else if (line.startsWith('rename to ')) {
25
+ f.status = 'renamed';
26
+ f.path = line.slice(10);
27
+ } else if (line.startsWith('Binary files')) f.binary = true;
28
+ else if (line.startsWith('+++ ') && line !== '+++ /dev/null') f.path = line.slice(6);
29
+ }
30
+ const hm = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line);
31
+ if (hm) {
32
+ o = Number(hm[1]);
33
+ n = Number(hm[3]);
34
+ left = Number(hm[2] ?? 1) + Number(hm[4] ?? 1);
35
+ h = { head: line, lines: [] };
36
+ f.hunks.push(h);
37
+ continue;
38
+ }
39
+ if (!h || left <= 0 || line.startsWith('\\')) continue;
40
+ if (line.startsWith('+')) {
41
+ h.lines.push({ t: 'add', old: null, new: n++, text: line.slice(1) });
42
+ left -= 1;
43
+ } else if (line.startsWith('-')) {
44
+ h.lines.push({ t: 'del', old: o++, new: null, text: line.slice(1) });
45
+ left -= 1;
46
+ } else if (line.startsWith(' ') || line === '') {
47
+ h.lines.push({ t: 'ctx', old: o++, new: n++, text: line.slice(1) });
48
+ left -= 2;
49
+ }
50
+ }
51
+ return files;
52
+ }
53
+
54
+ const BIG = 600; // files with more changed lines start collapsed
55
+
56
+ export function initReview({ api, esc, toast, agents, $ }) {
57
+ const dlg = $('#review-dialog');
58
+ const body = $('#review-body');
59
+ const st = { id: null, data: null, files: [], comments: [], collapsed: new Set(), editing: null, loading: false };
60
+
61
+ const key = (id) => `agentmgr.review.${id}`;
62
+ const loadDrafts = (id) => {
63
+ try {
64
+ return JSON.parse(localStorage.getItem(key(id)) || '[]');
65
+ } catch {
66
+ return [];
67
+ }
68
+ };
69
+ const saveDrafts = () => {
70
+ try {
71
+ if (st.comments.length) localStorage.setItem(key(st.id), JSON.stringify(st.comments));
72
+ else localStorage.removeItem(key(st.id));
73
+ } catch {}
74
+ };
75
+ const lineKey = (file, side, line) => `${file}\t${side}\t${line}`;
76
+
77
+ async function open(id) {
78
+ st.id = id;
79
+ st.comments = loadDrafts(id);
80
+ st.collapsed = new Set();
81
+ st.editing = null;
82
+ st.data = null;
83
+ $('#review-title').textContent = `Review: ${agents.get(id)?.name ?? ''}`;
84
+ body.innerHTML = '<p class="hint">Loading changes…</p>';
85
+ if (!dlg.open) dlg.showModal();
86
+ await reload();
87
+ }
88
+
89
+ async function reload() {
90
+ st.loading = true;
91
+ try {
92
+ st.data = await api('GET', `/agents/${st.id}/changes`);
93
+ st.files = parseDiff(st.data.diff);
94
+ for (const f of st.files) {
95
+ const size = f.hunks.reduce((s, h) => s + h.lines.length, 0);
96
+ if (size > BIG) st.collapsed.add(f.path);
97
+ }
98
+ } catch (e) {
99
+ body.innerHTML = `<p class="error">${esc(e.message)}</p>`;
100
+ st.loading = false;
101
+ return;
102
+ }
103
+ st.loading = false;
104
+ render();
105
+ }
106
+
107
+ function commentHtml(c, i) {
108
+ return `<div class="rv-comment" data-ci="${i}"><div class="rv-comment-text">${esc(c.text)}</div>
109
+ <span class="spacer"></span><button type="button" class="link" data-rv-edit="${i}">Edit</button> <button type="button" class="link" data-rv-del="${i}">Remove</button></div>`;
110
+ }
111
+
112
+ function formHtml(value = '') {
113
+ return `<div class="rv-form"><textarea rows="3" placeholder="What should the agent change here?">${esc(value)}</textarea>
114
+ <div class="rv-form-actions"><span class="hint">⌘/Ctrl+Enter to add</span><span class="spacer"></span><button type="button" class="small ghost" data-rv-cancel>Cancel</button><button type="button" class="small primary" data-rv-save>Add comment</button></div></div>`;
115
+ }
116
+
117
+ function fileHtml(f) {
118
+ const meta = st.data.files.find((x) => x.path === f.path);
119
+ const collapsed = st.collapsed.has(f.path);
120
+ const count = st.comments.filter((c) => c.file === f.path).length;
121
+ const head = `<div class="rv-file-head" data-rv-toggle="${esc(f.path)}">
122
+ <span class="rv-caret">${collapsed ? '▸' : '▾'}</span>
123
+ <code>${esc(f.path)}</code> <span class="tag rv-${f.status}">${f.status}</span>
124
+ ${meta?.untracked ? '<span class="tag">not committed</span>' : ''}
125
+ ${count ? `<span class="tag open">💬 ${count}</span>` : ''}
126
+ <span class="spacer"></span>
127
+ ${meta?.added != null ? `<span class="rv-add">+${meta.added}</span> <span class="rv-del">−${meta.removed}</span>` : ''}
128
+ </div>`;
129
+ if (collapsed) return `<div class="rv-file" id="rv-${encodeURIComponent(f.path)}">${head}</div>`;
130
+ if (f.binary) return `<div class="rv-file" id="rv-${encodeURIComponent(f.path)}">${head}<p class="hint rv-pad">Binary file</p></div>`;
131
+ const rows = [];
132
+ for (const h of f.hunks) {
133
+ rows.push(`<tr class="rv-hunk"><td colspan="3">${esc(h.head)}</td></tr>`);
134
+ for (const l of h.lines) {
135
+ const side = l.t === 'del' ? 'old' : 'new';
136
+ const line = side === 'old' ? l.old : l.new;
137
+ const k = lineKey(f.path, side, line);
138
+ rows.push(`<tr class="rv-${l.t}" data-rv-line="${esc(k)}">
139
+ <td class="rv-no">${l.old ?? ''}</td><td class="rv-no">${l.new ?? ''}<button type="button" class="rv-plus" title="Comment on this line">+</button></td>
140
+ <td class="rv-code"><span class="rv-sign">${l.t === 'add' ? '+' : l.t === 'del' ? '−' : ' '}</span>${esc(l.text)}</td></tr>`);
141
+ st.comments.forEach((c, i) => {
142
+ if (c.file === f.path && c.side === side && c.line === line) {
143
+ rows.push(`<tr class="rv-note"><td colspan="3">${st.editing?.index === i ? formHtml(c.text) : commentHtml(c, i)}</td></tr>`);
144
+ }
145
+ });
146
+ if (st.editing && st.editing.index == null && st.editing.key === k) rows.push(`<tr class="rv-note"><td colspan="3">${formHtml()}</td></tr>`);
147
+ }
148
+ }
149
+ return `<div class="rv-file" id="rv-${encodeURIComponent(f.path)}">${head}<table class="rv-table">${rows.join('')}</table></div>`;
150
+ }
151
+
152
+ function render() {
153
+ const d = st.data;
154
+ if (!d) return;
155
+ const a = agents.get(st.id);
156
+ const adds = d.files.reduce((s, f) => s + (f.added ?? 0), 0);
157
+ const dels = d.files.reduce((s, f) => s + (f.removed ?? 0), 0);
158
+ const openPr = d.prs.find((p) => p.state === 'OPEN');
159
+ const general = st.comments.filter((c) => !c.file);
160
+ const n = st.comments.length;
161
+ const scroll = body.querySelector('.rv-diffs')?.scrollTop ?? 0;
162
+ body.innerHTML = `
163
+ <div class="rv-summary">
164
+ <span><code>${esc(d.branch)}</code>${d.base ? ` ← <code>${esc(d.base.ref)}</code>` : ''}</span>
165
+ <span>${d.files.length} file${d.files.length === 1 ? '' : 's'} <span class="rv-add">+${adds}</span> <span class="rv-del">−${dels}</span></span>
166
+ <span>${d.commits.length} commit${d.commits.length === 1 ? '' : 's'}</span>
167
+ ${d.uncommitted ? `<span class="tag">${d.uncommitted} uncommitted</span>` : ''}
168
+ ${d.unpushed ? `<span class="tag">${d.unpushed} not pushed</span>` : ''}
169
+ ${openPr ? `<a href="${esc(openPr.url)}" target="_blank" rel="noopener">PR #${esc(openPr.number ?? '')}</a>` : ''}
170
+ <span class="spacer"></span>
171
+ <button type="button" class="small ghost" data-rv-reload>${st.loading ? 'Loading…' : 'Reload'}</button>
172
+ </div>
173
+ ${d.busy ? '<p class="hint rv-warn">The agent is working, so the changes may still move. Reload when it is idle.</p>' : ''}
174
+ ${d.truncated ? '<p class="hint rv-warn">The diff is large and was cut short; open a shell for the rest.</p>' : ''}
175
+ <div class="rv-main">
176
+ <nav class="rv-files">
177
+ ${d.files.length ? d.files.map((f) => `<a href="#" data-rv-goto="${esc(f.path)}" title="${esc(f.path)}"><span class="rv-fname">${esc(f.path.split('/').pop())}</span><span class="rv-fdir">${esc(f.path.split('/').slice(0, -1).join('/'))}</span>
178
+ <span class="rv-fstat">${f.added != null ? `<span class="rv-add">+${f.added}</span> <span class="rv-del">−${f.removed}</span>` : ''}</span></a>`).join('') : '<p class="hint">No changes yet.</p>'}
179
+ ${d.commits.length ? `<details class="rv-commits"><summary>Commits</summary>${d.commits.map((c) => `<div><code>${esc(c.sha)}</code> ${esc(c.subject)}</div>`).join('')}</details>` : ''}
180
+ </nav>
181
+ <div class="rv-diffs">${st.files.map(fileHtml).join('') || '<p class="hint rv-pad">Nothing changed since the branch started.</p>'}</div>
182
+ </div>
183
+ <div class="rv-actions">
184
+ <div class="rv-send">
185
+ ${general.length ? `<div class="rv-general">${general.map((c) => commentHtml(c, st.comments.indexOf(c))).join('')}</div>` : ''}
186
+ <textarea id="rv-note" rows="2" placeholder="Overall comment for the agent (optional)">${esc(st.note ?? '')}</textarea>
187
+ <div class="rv-row">
188
+ <span class="hint">${n ? `${n} comment${n === 1 ? '' : 's'} not sent` : 'Click + next to a line to comment on it'}</span>
189
+ <span class="spacer"></span>
190
+ ${n ? '<button type="button" class="small ghost" data-rv-clear>Discard comments</button>' : ''}
191
+ <button type="button" class="small primary" data-rv-send ${a && !a.workdir ? 'disabled' : ''}>Send to agent</button>
192
+ </div>
193
+ </div>
194
+ <div class="rv-land">
195
+ <div class="rv-row">
196
+ <input id="rv-msg" placeholder="Commit message" value="${esc(st.msg ?? '')}" ${d.uncommitted ? '' : 'disabled'} />
197
+ <button type="button" class="small" data-rv-commit ${d.uncommitted ? '' : 'disabled'} title="git add -A and commit, as you">Commit all</button>
198
+ </div>
199
+ <div class="rv-row">
200
+ <input id="rv-prtitle" placeholder="${openPr ? 'Pull request already open' : 'PR title (empty: from the commits)'}" value="${esc(st.prTitle ?? '')}" ${openPr ? 'disabled' : ''} />
201
+ <label class="check"><input type="checkbox" id="rv-draft" ${openPr ? 'disabled' : ''} /> Draft</label>
202
+ <button type="button" class="small primary" data-rv-pr ${!d.hasRemote || d.uncommitted || !d.commits.length ? 'disabled' : ''}
203
+ title="${!d.hasRemote ? 'No origin remote' : d.uncommitted ? 'Commit first' : !d.commits.length ? 'No commits on this branch yet' : openPr ? 'Push the branch to the open pull request' : 'Push the branch and open a pull request on GitHub'}">${openPr ? 'Push' : 'Push & open PR'}</button>
204
+ </div>
205
+ </div>
206
+ </div>`;
207
+ const diffs = body.querySelector('.rv-diffs');
208
+ if (diffs) diffs.scrollTop = scroll;
209
+ body.querySelector('.rv-form textarea')?.focus();
210
+ }
211
+
212
+ const keepInputs = () => {
213
+ st.note = $('#rv-note')?.value ?? st.note;
214
+ st.msg = $('#rv-msg')?.value ?? st.msg;
215
+ st.prTitle = $('#rv-prtitle')?.value ?? st.prTitle;
216
+ };
217
+
218
+ function saveForm(form) {
219
+ const text = form.querySelector('textarea').value.trim();
220
+ if (st.editing?.index != null) {
221
+ if (text) st.comments[st.editing.index].text = text;
222
+ else st.comments.splice(st.editing.index, 1);
223
+ } else if (text && st.editing) {
224
+ const [file, side, line] = st.editing.key.split('\t');
225
+ const row = body.querySelector(`[data-rv-line="${CSS.escape(st.editing.key)}"] .rv-code`);
226
+ const code = row ? row.textContent.slice(1) : '';
227
+ st.comments.push({ file, side, line: Number(line), code, text });
228
+ }
229
+ st.editing = null;
230
+ saveDrafts();
231
+ keepInputs();
232
+ render();
233
+ }
234
+
235
+ body.addEventListener('click', async (e) => {
236
+ const t = e.target;
237
+ if (t.closest('[data-rv-reload]')) return keepInputs(), reload();
238
+ const go = t.closest('[data-rv-goto]');
239
+ if (go) {
240
+ e.preventDefault();
241
+ st.collapsed.delete(go.dataset.rvGoto);
242
+ keepInputs();
243
+ render();
244
+ document.getElementById(`rv-${encodeURIComponent(go.dataset.rvGoto)}`)?.scrollIntoView({ block: 'start' });
245
+ return;
246
+ }
247
+ const tog = t.closest('[data-rv-toggle]');
248
+ if (tog) {
249
+ const p = tog.dataset.rvToggle;
250
+ st.collapsed.has(p) ? st.collapsed.delete(p) : st.collapsed.add(p);
251
+ keepInputs();
252
+ return render();
253
+ }
254
+ if (t.closest('.rv-plus') || (t.closest('.rv-no') && t.closest('[data-rv-line]'))) {
255
+ st.editing = { key: t.closest('[data-rv-line]').dataset.rvLine, index: null };
256
+ keepInputs();
257
+ return render();
258
+ }
259
+ if (t.closest('[data-rv-cancel]')) {
260
+ st.editing = null;
261
+ keepInputs();
262
+ return render();
263
+ }
264
+ if (t.closest('[data-rv-save]')) return saveForm(t.closest('.rv-form'));
265
+ const ed = t.closest('[data-rv-edit]');
266
+ if (ed) {
267
+ const i = Number(ed.dataset.rvEdit);
268
+ if (!st.comments[i].file) {
269
+ // A general comment: move it back into the note box.
270
+ st.note = [st.comments[i].text, $('#rv-note').value].filter(Boolean).join('\n');
271
+ st.comments.splice(i, 1);
272
+ saveDrafts();
273
+ return render();
274
+ }
275
+ st.editing = { index: i };
276
+ keepInputs();
277
+ return render();
278
+ }
279
+ const del = t.closest('[data-rv-del]');
280
+ if (del) {
281
+ st.comments.splice(Number(del.dataset.rvDel), 1);
282
+ saveDrafts();
283
+ keepInputs();
284
+ return render();
285
+ }
286
+ if (t.closest('[data-rv-clear]')) {
287
+ if (!confirm('Discard every comment not sent yet?')) return;
288
+ st.comments = [];
289
+ saveDrafts();
290
+ keepInputs();
291
+ return render();
292
+ }
293
+ if (t.closest('[data-rv-send]')) {
294
+ keepInputs();
295
+ if (!st.comments.length && !st.note?.trim()) return toast('Add a comment first');
296
+ t.disabled = true;
297
+ try {
298
+ const r = await api('POST', `/agents/${st.id}/changes/comments`, { comments: st.comments, note: st.note ?? '' });
299
+ st.comments = [];
300
+ st.note = '';
301
+ saveDrafts();
302
+ toast(r.sent ? `Sent ${r.sent} comment${r.sent === 1 ? '' : 's'} to the agent` : 'Sent to the agent');
303
+ render();
304
+ } catch (err) {
305
+ toast(`Error: ${err.message}`, 5000);
306
+ t.disabled = false;
307
+ }
308
+ return;
309
+ }
310
+ if (t.closest('[data-rv-commit]')) {
311
+ keepInputs();
312
+ if (!st.msg?.trim()) return $('#rv-msg').focus();
313
+ t.disabled = true;
314
+ try {
315
+ const r = await api('POST', `/agents/${st.id}/changes/commit`, { message: st.msg });
316
+ st.msg = '';
317
+ toast(`Committed ${r.sha}`);
318
+ await reload();
319
+ } catch (err) {
320
+ toast(`Error: ${err.message}`, 5000);
321
+ t.disabled = false;
322
+ }
323
+ return;
324
+ }
325
+ if (t.closest('[data-rv-pr]')) {
326
+ keepInputs();
327
+ t.disabled = true;
328
+ t.textContent = 'Pushing…';
329
+ try {
330
+ const r = await api('POST', `/agents/${st.id}/changes/pr`, { title: st.prTitle ?? '', draft: $('#rv-draft')?.checked });
331
+ st.prTitle = '';
332
+ toast(r.existing ? 'Pushed to the open pull request' : r.url ? 'Pull request opened' : 'Pushed', 4000, r.url ? { label: 'Open', run: () => window.open(r.url, '_blank', 'noopener') } : undefined);
333
+ await reload();
334
+ } catch (err) {
335
+ toast(`Error: ${err.message}`, 6000);
336
+ t.disabled = false;
337
+ t.textContent = 'Push & open PR';
338
+ }
339
+ }
340
+ });
341
+
342
+ body.addEventListener('keydown', (e) => {
343
+ const form = e.target.closest?.('.rv-form');
344
+ if (form && e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
345
+ e.preventDefault();
346
+ saveForm(form);
347
+ } else if (form && e.key === 'Escape') {
348
+ e.preventDefault();
349
+ e.stopPropagation();
350
+ st.editing = null;
351
+ keepInputs();
352
+ render();
353
+ }
354
+ });
355
+
356
+ // Typing in the note or the inputs must survive re-renders; keep it on every keystroke.
357
+ body.addEventListener('input', (e) => {
358
+ if (['rv-note', 'rv-msg', 'rv-prtitle'].includes(e.target.id)) keepInputs();
359
+ });
360
+
361
+ $('#review-close').onclick = () => dlg.close();
362
+ dlg.addEventListener('close', () => {
363
+ st.id = null;
364
+ st.note = st.msg = st.prTitle = '';
365
+ });
366
+
367
+ return { open, get openId() { return dlg.open ? st.id : null; } };
368
+ }