ai-agentmanager 1.1.0 → 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/CHANGELOG.md +25 -0
- package/README.md +131 -3
- package/docs/plugins.md +4 -1
- package/package.json +1 -1
- package/public/app.js +259 -15
- package/public/index.html +198 -0
- package/public/land.js +149 -0
- package/public/playbooks.js +132 -0
- package/public/review.js +368 -0
- package/public/setup.js +67 -0
- package/public/spend.js +82 -0
- package/public/style.css +116 -1
- package/public/triggers.js +85 -0
- package/src/assistant.js +279 -0
- package/src/budgets.js +166 -0
- package/src/cleanup.js +1 -0
- package/src/cli.js +389 -1
- package/src/client.js +2 -1
- package/src/engines/claude.js +6 -4
- package/src/git.js +4 -4
- package/src/landing.js +212 -0
- package/src/manager.js +168 -78
- package/src/memory.js +106 -0
- package/src/notify.js +3 -1
- package/src/playbooks.js +178 -0
- package/src/review.js +209 -0
- package/src/server.js +108 -5
- package/src/stats.js +107 -0
- package/src/tracker.js +61 -8
- package/src/triggers.js +258 -0
package/public/review.js
ADDED
|
@@ -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
|
+
}
|
package/public/setup.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Getting started: shown where an agent's details go while none is selected, until every step
|
|
2
|
+
// is done or it is hidden. Steps open the dialog that does them.
|
|
3
|
+
|
|
4
|
+
const STEPS = [
|
|
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
|
+
['alerts', 'Get Slack alerts', 'Hear when an agent needs you, is stuck or finished.', '#alerts-btn', 'Alerts'],
|
|
7
|
+
['assistant', 'Let work come to you', 'The assistant, or triggers (a GitHub label, a webhook), start agents for new work.', '#assistant-btn', 'Assistant'],
|
|
8
|
+
['remote', 'Use it from your phone', 'Answer agents from anywhere on your network.', '#remote-btn', 'Remote access'],
|
|
9
|
+
['budget', 'Set a budget', 'An alert at 80% of an agent budget, and a daily limit.', '#spend-btn', 'Results'],
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export function initSetup({ api, esc, $ }) {
|
|
13
|
+
const el = $('#placeholder');
|
|
14
|
+
const plain = el.innerHTML;
|
|
15
|
+
let data = null;
|
|
16
|
+
const hidden = () => {
|
|
17
|
+
try {
|
|
18
|
+
return localStorage.getItem('agentmgr.setupHidden') === '1';
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function render() {
|
|
25
|
+
if (!data || hidden()) {
|
|
26
|
+
el.innerHTML = plain;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const done = STEPS.filter(([k]) => data.steps[k]).length;
|
|
30
|
+
if (done === STEPS.length && !data.checks.length) {
|
|
31
|
+
el.innerHTML = plain;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
el.innerHTML = `<div class="setup">
|
|
35
|
+
<div class="setup-head"><h3>Getting started</h3><span class="hint">${done} of ${STEPS.length} done</span><span class="spacer"></span><button class="link" data-setup-hide>Hide</button></div>
|
|
36
|
+
${data.checks.length ? `<div class="setup-checks">${data.checks.map((c) => `<div class="setup-check ${c.status}"><b>${esc(c.name)}</b>: ${esc(c.detail)}${c.fix ? `<div class="hint">${esc(c.fix)}</div>` : ''}</div>`).join('')}</div>` : ''}
|
|
37
|
+
<ol class="setup-steps">${STEPS.map(([k, title, why, btn, label]) => `<li class="${data.steps[k] ? 'done' : ''}">
|
|
38
|
+
<span class="setup-mark">${data.steps[k] ? '✓' : ''}</span>
|
|
39
|
+
<div><b>${title}</b><div class="hint">${why}</div></div>
|
|
40
|
+
${data.steps[k] ? '' : `<button class="small" data-setup-open="${btn}">${label}</button>`}</li>`).join('')}</ol>
|
|
41
|
+
<p class="hint">${plain}</p>
|
|
42
|
+
</div>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
el.addEventListener('click', (e) => {
|
|
46
|
+
const open = e.target.closest('[data-setup-open]');
|
|
47
|
+
if (open) return document.querySelector(open.dataset.setupOpen)?.click();
|
|
48
|
+
if (e.target.closest('[data-setup-hide]')) {
|
|
49
|
+
try {
|
|
50
|
+
localStorage.setItem('agentmgr.setupHidden', '1');
|
|
51
|
+
} catch {}
|
|
52
|
+
render();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
async function load() {
|
|
57
|
+
try {
|
|
58
|
+
data = await api('GET', '/setup');
|
|
59
|
+
} catch {
|
|
60
|
+
data = null;
|
|
61
|
+
}
|
|
62
|
+
render();
|
|
63
|
+
}
|
|
64
|
+
load();
|
|
65
|
+
window.addEventListener('focus', () => !hidden() && load());
|
|
66
|
+
return { load };
|
|
67
|
+
}
|
package/public/spend.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Spend dialog: today's cost, the last two weeks, the costliest agents, and the limits.
|
|
2
|
+
|
|
3
|
+
export function initSpend({ api, esc, toast, $ }) {
|
|
4
|
+
const dlg = $('#spend-dialog');
|
|
5
|
+
const f = $('#spend-form');
|
|
6
|
+
const money = (n) => `$${Number(n ?? 0).toFixed(2)}`;
|
|
7
|
+
|
|
8
|
+
function fill(b) {
|
|
9
|
+
$('#spend-today').textContent = b.today.total ? money(b.today.total) : '';
|
|
10
|
+
if (!dlg.open) return;
|
|
11
|
+
f.perAgentUsd.value = b.perAgentUsd ?? '';
|
|
12
|
+
f.perDayUsd.value = b.perDayUsd ?? '';
|
|
13
|
+
const max = Math.max(...b.days.map((d) => d.total), 0.01);
|
|
14
|
+
$('#spend-summary').innerHTML = `
|
|
15
|
+
<p class="spend-big">${money(b.today.total)} <span class="hint">today${b.perDayUsd ? ` of ${money(b.perDayUsd)}` : ''}</span></p>
|
|
16
|
+
${b.days.length > 1 ? `<div class="spend-bars" title="The last two weeks">${[...b.days].reverse().map((d) => `<span style="height:${Math.max(2, Math.round((d.total / max) * 40))}px" title="${esc(d.day)}: ${money(d.total)}"></span>`).join('')}</div>` : ''}`;
|
|
17
|
+
$('#spend-agents').innerHTML = b.agents.length
|
|
18
|
+
? `<table class="spend-table"><tr><th>Agent</th><th>Spent</th><th>Today</th><th>Budget</th></tr>${b.agents.map((a) => `<tr class="${a.hit ? 'over' : ''}">
|
|
19
|
+
<td><a href="#${a.id}">${esc(a.name)}</a></td><td>${money(a.cost)}</td><td>${a.today ? money(a.today) : ''}</td>
|
|
20
|
+
<td>${a.limit ? `${money(a.limit)}${a.own ? '' : ' <span class="hint">default</span>'}${a.hit ? ' <span class="tag fail">reached</span>' : ''}` : '—'}</td></tr>`).join('')}</table>`
|
|
21
|
+
: '<p class="hint">No costs recorded yet.</p>';
|
|
22
|
+
$('#spend-note').textContent = b.note;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const hours = (h) => (h == null ? '—' : h < 1 ? `${Math.round(h * 60)} min` : h < 48 ? `${h.toFixed(1)} h` : `${(h / 24).toFixed(1)} days`);
|
|
26
|
+
const pct = (r) => (r == null ? '—' : `${Math.round(r * 100)}%`);
|
|
27
|
+
|
|
28
|
+
function table(title, rows) {
|
|
29
|
+
if (!rows.length) return '';
|
|
30
|
+
return `<details class="stats-table" ${title === 'By engine and model' ? 'open' : ''}><summary>${title}</summary><table class="spend-table">
|
|
31
|
+
<tr><th></th><th>Agents</th><th>Merged</th><th>Merge rate</th><th>To PR</th><th>Cost / merged</th></tr>
|
|
32
|
+
${rows.map((r) => `<tr><td>${esc(r.key)}</td><td>${r.agents}</td><td>${r.merged}</td><td>${pct(r.mergeRate)}</td><td>${hours(r.medianHoursToPr)}</td><td>${r.costPerMerged == null ? '—' : money(r.costPerMerged)}</td></tr>`).join('')}
|
|
33
|
+
</table></details>`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fillStats(st) {
|
|
37
|
+
const t = st.total;
|
|
38
|
+
const kpi = (label, value, hint = '') => `<div class="kpi"><div class="kpi-v">${value}</div><div class="kpi-l">${label}</div>${hint ? `<div class="hint">${hint}</div>` : ''}</div>`;
|
|
39
|
+
$('#stats-body').innerHTML = t.agents
|
|
40
|
+
? `<div class="kpis">
|
|
41
|
+
${kpi('agents', t.agents, `${t.withPr} opened a PR`)}
|
|
42
|
+
${kpi('PRs merged', t.merged, `${t.open} open, ${t.closed} closed`)}
|
|
43
|
+
${kpi('merge rate', pct(t.mergeRate), 'of PRs merged or closed')}
|
|
44
|
+
${kpi('to first PR', hours(t.medianHoursToPr), 'median')}
|
|
45
|
+
${kpi('to merge', hours(t.medianHoursToMerge), 'median')}
|
|
46
|
+
${kpi('per merged PR', t.costPerMerged == null ? '—' : money(t.costPerMerged), `${money(t.cost)} in all`)}
|
|
47
|
+
</div>
|
|
48
|
+
${table('By engine and model', st.byEngine)}${table('By where they came from', st.bySource)}${table('By project', st.byProject)}`
|
|
49
|
+
: '<p class="hint">No agents in this period.</p>';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function refresh() {
|
|
53
|
+
try {
|
|
54
|
+
fill(await api('GET', '/budgets'));
|
|
55
|
+
if (dlg.open) fillStats(await api('GET', `/stats?days=${$('#stats-days').value}`));
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
$('#stats-days').onchange = () => refresh();
|
|
59
|
+
|
|
60
|
+
$('#spend-btn').onclick = async () => {
|
|
61
|
+
$('#spend-error').hidden = true;
|
|
62
|
+
dlg.showModal();
|
|
63
|
+
await refresh();
|
|
64
|
+
};
|
|
65
|
+
$('#spend-close').onclick = () => dlg.close();
|
|
66
|
+
f.addEventListener('submit', async (e) => {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
try {
|
|
69
|
+
fill(await api('PUT', '/budgets', { perAgentUsd: f.perAgentUsd.value || null, perDayUsd: f.perDayUsd.value || null }));
|
|
70
|
+
toast('Limits saved');
|
|
71
|
+
} catch (err) {
|
|
72
|
+
$('#spend-error').hidden = false;
|
|
73
|
+
$('#spend-error').textContent = err.message;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
dlg.addEventListener('click', (e) => {
|
|
77
|
+
if (e.target.closest('a[href^="#"]')) dlg.close();
|
|
78
|
+
});
|
|
79
|
+
refresh();
|
|
80
|
+
setInterval(refresh, 120_000);
|
|
81
|
+
return { refresh };
|
|
82
|
+
}
|
package/public/style.css
CHANGED
|
@@ -157,7 +157,7 @@ body.term-max .layout { grid-template-columns: 1fr; }
|
|
|
157
157
|
.shortcut { flex: none; font: 600 11px/16px var(--mono); padding: 0 6px; border-radius: 4px; background: var(--accent); color: var(--accent-text); cursor: pointer; }
|
|
158
158
|
.bl-head .shortcut { cursor: default; }
|
|
159
159
|
.project-head .caret { width: 12px; color: var(--muted); flex: none; }
|
|
160
|
-
.project-head .pname { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
160
|
+
.project-head .pname { font-size: 16px; font-weight: 700; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
161
161
|
.project-head .pcounts { display: flex; gap: 6px; align-items: center; color: var(--muted); font-weight: 500; font-size: 12px; }
|
|
162
162
|
.project-head .prun { color: var(--green); }
|
|
163
163
|
.project-head .pcount { white-space: nowrap; }
|
|
@@ -384,6 +384,17 @@ body.remote .local-only { display: none; }
|
|
|
384
384
|
.alerts-body .check { display: flex; gap: 8px; align-items: center; font-size: 13px; }
|
|
385
385
|
.alerts-body .check input[type=checkbox] { width: auto; }
|
|
386
386
|
.alerts-body .num { width: 64px; padding: 2px 6px; }
|
|
387
|
+
.assistant-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-left: 4px; vertical-align: middle; background: var(--muted); }
|
|
388
|
+
.assistant-dot.on { background: var(--green); }
|
|
389
|
+
.assistant-dot.busy { background: var(--yellow); }
|
|
390
|
+
.assistant-status { font-size: 13px; }
|
|
391
|
+
.assistant-status p { margin: 4px 0; }
|
|
392
|
+
.assistant-started { margin: 4px 0; padding-left: 18px; font-size: 13px; }
|
|
393
|
+
.assistant-activity { font-size: 13px; max-height: 240px; overflow: auto; }
|
|
394
|
+
.assistant-activity h4 { margin: 8px 0 4px; }
|
|
395
|
+
.assistant-activity .md h1, .assistant-activity .md h2, .assistant-activity .md h3 { font-size: 13px; margin: 10px 0 2px; color: var(--muted); }
|
|
396
|
+
.assistant-activity .md p, .assistant-activity .md ul { margin: 2px 0; }
|
|
397
|
+
.tag.assistant { background: var(--panel-2); }
|
|
387
398
|
.cleanup-plan { font-size: 13px; max-height: 260px; overflow: auto; }
|
|
388
399
|
.cleanup-plan h4 { margin: 8px 0 4px; font-size: 13px; }
|
|
389
400
|
.cleanup-plan ul { margin: 0; padding-left: 18px; }
|
|
@@ -614,3 +625,107 @@ pre.diff .meta { color: var(--muted); font-weight: 600; }
|
|
|
614
625
|
.team-quest { border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; }
|
|
615
626
|
.team-quest summary { cursor: pointer; }
|
|
616
627
|
.team-quest .team-timeline { margin-top: 8px; }
|
|
628
|
+
|
|
629
|
+
/* Review changes */
|
|
630
|
+
.review-dialog { width: min(1400px, calc(100vw - 24px)); height: calc(100vh - 32px); max-height: none; }
|
|
631
|
+
.review-body { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; padding: 8px 18px 14px; }
|
|
632
|
+
.rv-summary { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; font-size: 13px; }
|
|
633
|
+
.rv-warn { margin: 0; color: var(--yellow); }
|
|
634
|
+
.rv-add { color: var(--green); font-family: var(--mono); font-size: 12px; }
|
|
635
|
+
.rv-del { color: var(--red); font-family: var(--mono); font-size: 12px; }
|
|
636
|
+
.rv-main { flex: 1; min-height: 0; display: grid; grid-template-columns: 240px 1fr; gap: 10px; }
|
|
637
|
+
.rv-files { overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; padding: 4px; font-size: 12px; }
|
|
638
|
+
.rv-files a { display: grid; grid-template-columns: 1fr auto; padding: 4px 6px; border-radius: 4px; color: var(--text); text-decoration: none; }
|
|
639
|
+
.rv-files a:hover { background: var(--panel-2); }
|
|
640
|
+
.rv-fname { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
641
|
+
.rv-fdir { grid-column: 1; color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
642
|
+
.rv-fstat { grid-column: 2; grid-row: 1 / span 2; align-self: center; white-space: nowrap; }
|
|
643
|
+
.rv-commits { margin: 8px 4px 2px; color: var(--muted); }
|
|
644
|
+
.rv-commits div { padding: 2px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
645
|
+
.rv-diffs { overflow: auto; display: flex; flex-direction: column; gap: 10px; }
|
|
646
|
+
.rv-file { border: 1px solid var(--border); border-radius: 6px; }
|
|
647
|
+
.rv-file-head { position: sticky; top: 0; z-index: 1; display: flex; gap: 6px; align-items: center; padding: 6px 10px; background: var(--panel-2); border-radius: 6px 6px 0 0; cursor: pointer; font-size: 13px; }
|
|
648
|
+
.rv-caret { color: var(--muted); width: 10px; }
|
|
649
|
+
.tag.rv-added { color: var(--green); }
|
|
650
|
+
.tag.rv-deleted { color: var(--red); }
|
|
651
|
+
.rv-pad { padding: 8px 10px; margin: 0; }
|
|
652
|
+
.rv-table { width: 100%; border-collapse: collapse; font: 12px/1.5 var(--mono); }
|
|
653
|
+
.rv-table td { padding: 0 6px; vertical-align: top; }
|
|
654
|
+
.rv-no { width: 1%; min-width: 38px; text-align: right; color: var(--muted); user-select: none; cursor: pointer; position: relative; white-space: nowrap; }
|
|
655
|
+
.rv-code { white-space: pre-wrap; word-break: break-word; }
|
|
656
|
+
.rv-sign { display: inline-block; width: 1.2em; color: var(--muted); user-select: none; }
|
|
657
|
+
tr.rv-add td.rv-code, tr.rv-add td.rv-no { background: color-mix(in srgb, var(--green) 12%, transparent); }
|
|
658
|
+
tr.rv-del td.rv-code, tr.rv-del td.rv-no { background: color-mix(in srgb, var(--red) 12%, transparent); }
|
|
659
|
+
tr.rv-hunk td { color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, transparent); padding: 2px 8px; }
|
|
660
|
+
.rv-plus { display: none; position: absolute; left: 2px; top: 1px; padding: 0 5px; font-size: 11px; line-height: 16px; border-radius: 3px; background: var(--accent); color: var(--accent-text); border: 0; cursor: pointer; }
|
|
661
|
+
tr[data-rv-line]:hover .rv-plus { display: inline-block; }
|
|
662
|
+
@media (hover: none) { .rv-plus { display: inline-block; opacity: .6; } }
|
|
663
|
+
tr.rv-note td { padding: 6px 10px; background: var(--panel); font-family: inherit; }
|
|
664
|
+
.rv-comment { display: flex; gap: 8px; align-items: flex-start; border-left: 3px solid var(--accent); padding: 4px 8px; background: var(--panel-2); border-radius: 4px; font: 13px/1.45 system-ui, sans-serif; }
|
|
665
|
+
.rv-comment-text { white-space: pre-wrap; }
|
|
666
|
+
.rv-form textarea { width: 100%; font: 13px/1.45 system-ui, sans-serif; }
|
|
667
|
+
.rv-form-actions, .rv-row { display: flex; gap: 6px; align-items: center; margin-top: 4px; }
|
|
668
|
+
.rv-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; border-top: 1px solid var(--border); padding-top: 8px; }
|
|
669
|
+
.rv-send textarea { width: 100%; }
|
|
670
|
+
.rv-general { display: flex; flex-direction: column; gap: 4px; margin-bottom: 4px; }
|
|
671
|
+
.rv-land input:not([type]) { flex: 1; min-width: 0; }
|
|
672
|
+
.d-actions .count { font-size: 11px; padding: 0 5px; border-radius: 8px; background: var(--accent); color: var(--accent-text); }
|
|
673
|
+
@media (max-width: 760px) {
|
|
674
|
+
.rv-main { grid-template-columns: 1fr; }
|
|
675
|
+
.rv-files { max-height: 120px; }
|
|
676
|
+
.rv-actions { grid-template-columns: 1fr; }
|
|
677
|
+
}
|
|
678
|
+
.rv-land .check { display: inline-flex; gap: 4px; align-items: center; white-space: nowrap; margin: 0; }
|
|
679
|
+
|
|
680
|
+
/* Land tab */
|
|
681
|
+
.land-row { cursor: default; }
|
|
682
|
+
.land-row .name { white-space: normal; color: var(--text); text-decoration: none; font-weight: 600; }
|
|
683
|
+
.land-row .name:hover { text-decoration: underline; }
|
|
684
|
+
.land-chips { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0; }
|
|
685
|
+
.tag.fail { color: var(--red); }
|
|
686
|
+
.badge.ready { background: var(--green); color: #fff; border-color: var(--green); }
|
|
687
|
+
.land-empty { padding: 10px 14px; }
|
|
688
|
+
.land-settings { padding: 8px 14px 14px; font-size: 13px; }
|
|
689
|
+
.land-settings summary { cursor: pointer; color: var(--muted); margin-bottom: 6px; }
|
|
690
|
+
.land-settings label { display: flex; gap: 6px; align-items: center; margin: 4px 0; }
|
|
691
|
+
.land-settings input[type="number"] { width: 56px; }
|
|
692
|
+
.land-settings select { width: auto; }
|
|
693
|
+
.links li:has(.pr-chips) { flex-wrap: wrap; }
|
|
694
|
+
.pr-chips { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 4px; padding-left: 2px; }
|
|
695
|
+
.land-settings input[type="checkbox"] { width: auto; flex: none; margin: 0; }
|
|
696
|
+
input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
|
|
697
|
+
#triggers-newtoken { width: auto; justify-self: start; align-self: flex-start; }
|
|
698
|
+
.trig-recent { margin: 4px 0 0; padding-left: 18px; }
|
|
699
|
+
.spend-today:empty { display: none; }
|
|
700
|
+
.spend-today { font-size: 11px; color: var(--muted); margin-left: 2px; }
|
|
701
|
+
.spend-big { font-size: 22px; font-weight: 700; margin: 0 0 6px; }
|
|
702
|
+
.spend-bars { display: flex; align-items: flex-end; gap: 3px; height: 42px; margin-bottom: 8px; }
|
|
703
|
+
.spend-bars span { width: 12px; background: var(--accent); border-radius: 2px 2px 0 0; opacity: .8; }
|
|
704
|
+
.spend-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
705
|
+
.spend-table th, .spend-table td { text-align: left; padding: 3px 6px; border-bottom: 1px solid var(--border); }
|
|
706
|
+
.spend-table tr.over td { color: var(--red); }
|
|
707
|
+
.inline-select { width: auto; font-size: 13px; font-weight: 400; margin-left: 6px; padding: 2px 4px; }
|
|
708
|
+
.kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 8px; }
|
|
709
|
+
.kpi { border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; }
|
|
710
|
+
.kpi-v { font-size: 20px; font-weight: 700; }
|
|
711
|
+
.kpi-l { font-size: 12px; color: var(--muted); }
|
|
712
|
+
.stats-table { margin: 6px 0; }
|
|
713
|
+
.stats-table summary { cursor: pointer; font-size: 13px; color: var(--muted); }
|
|
714
|
+
.spend-h { margin: 14px 0 4px; font-size: 14px; }
|
|
715
|
+
#spend-dialog { width: min(720px, calc(100vw - 32px)); }
|
|
716
|
+
@media (max-width: 560px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
|
|
717
|
+
.pb-table td { vertical-align: top; }
|
|
718
|
+
.pb-intent { max-width: 360px; white-space: normal; }
|
|
719
|
+
.pb-actions { white-space: nowrap; text-align: right; }
|
|
720
|
+
.pb-form { border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-top: 10px; }
|
|
721
|
+
.memory-text { width: 100%; font: 12px/1.5 var(--mono); }
|
|
722
|
+
#memory-dialog { width: min(760px, calc(100vw - 32px)); }
|
|
723
|
+
.setup { max-width: 640px; margin: 24px auto; text-align: left; color: var(--text); }
|
|
724
|
+
.setup-head { display: flex; gap: 10px; align-items: baseline; }
|
|
725
|
+
.setup-head h3 { margin: 0; font-size: 16px; }
|
|
726
|
+
.setup-steps { list-style: none; padding: 0; margin: 10px 0; display: flex; flex-direction: column; gap: 6px; }
|
|
727
|
+
.setup-steps li { display: grid; grid-template-columns: 22px 1fr auto; gap: 8px; align-items: center; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); }
|
|
728
|
+
.setup-steps li.done { opacity: .6; }
|
|
729
|
+
.setup-mark { color: var(--green); font-weight: 700; }
|
|
730
|
+
.setup-check { padding: 6px 10px; border-radius: 6px; margin: 6px 0; border-left: 3px solid var(--yellow); background: var(--panel-2); font-size: 13px; }
|
|
731
|
+
.setup-check.fail { border-left-color: var(--red); }
|