ai-agentmanager 1.6.0 → 1.8.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 +11 -0
- package/README.md +7 -4
- package/package.json +1 -1
- package/public/app.js +58 -6
- package/public/index.html +67 -31
- package/public/menu.js +40 -4
- package/public/review.js +85 -2
- package/public/side.js +191 -0
- package/public/style.css +58 -1
- package/public/update.js +3 -5
- package/public/walkthrough.js +36 -0
- package/src/jira.js +100 -0
- package/src/manager.js +32 -0
- package/src/server.js +15 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.0 (2026-10-31)
|
|
4
|
+
|
|
5
|
+
- **Update from your phone:** **Update to …** and **About → Update and restart** now show over remote access too; the install and restart run on the computer and the page reloads when it's back. It installs only the official release, from where this copy came from.
|
|
6
|
+
- **Jira summaries:** an agent created from a Jira link shows the issue's summary as its description (in the list and its header, and as the issue's title under Bugs), fetched in the background from Jira Cloud. Until then, or when it can't be fetched, the words after the link. **Settings → Jira** takes your site, email and API token for private issues (also `JIRA_SITE` / `JIRA_EMAIL` / `JIRA_API_TOKEN`); only `*.atlassian.net` and that site are contacted, and the token goes only to that site. Saving fills in agents already created.
|
|
7
|
+
- **Theme:** Auto / Light / Dark in the ☰ menu, remembered in the browser; diagrams redraw in the theme's colors.
|
|
8
|
+
|
|
9
|
+
## 1.7.0 (2026-10-31)
|
|
10
|
+
|
|
11
|
+
- **Split view:** **◫ Split** in the terminal bar: the terminal (or a shell, or a browser tab) on the left, and Code, Docs (Diagrams by default, or any other document) or Changes on the right. Resizable and remembered.
|
|
12
|
+
- **Review: changes explained one by one:** **Explain changes** in Review changes puts an explanation above each hunk (what, why, risk, what to check) and **◀ / ▶** or **[ / ]** step through them in reading order, skipping hunks that moved since. A notice and **Explain again** when the code changed after.
|
|
13
|
+
|
|
3
14
|
## 1.6.0 (2026-10-31)
|
|
4
15
|
|
|
5
16
|
- **Changes explained one by one:** `agentmgr doc <agent> walkthrough` explains each hunk of an agent's changes (what, why, risk, what to check) in the order a reviewer should read them, from the same diff the review screen shows. The review screen and a split view for it come next.
|
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ Run one background [Claude Code](https://claude.com/claude-code) agent per inten
|
|
|
7
7
|

|
|
8
8
|
|
|
9
9
|
- **Intents in, agents out.** Give a list, one per line (say, a ticket each), and each becomes its own `claude` process. Or pick **One agent for all of it** and a long description goes to a single agent as written.
|
|
10
|
+
- **Issue links.** A request that starts with a Jira link is named after the issue (`ABC-123`) and described by its summary, fetched from Jira Cloud (public issues as they are; your company's site after **Settings → Jira**: site, email and API token, kept on this computer and sent only to that site). Without access, the words after the link describe it.
|
|
10
11
|
- **Repo or folder.** Give a git URL (cloned once, then each agent gets its own worktree and branch) or a local folder.
|
|
11
12
|
- **Fast clones.** URLs are cloned as partial clones (`--filter=blob:none`): history now, file contents only for what's checked out.
|
|
12
13
|
- **Progress.** Clone and checkout progress shows in the UI and `agentmgr ls`.
|
|
@@ -84,13 +85,13 @@ Runs on macOS and Linux; on Windows, use WSL2. Requires Node 20+, `git`, and the
|
|
|
84
85
|
|
|
85
86
|
```sh
|
|
86
87
|
npm install -g ai-agentmanager # the latest release
|
|
87
|
-
npm install -g ai-agentmanager@1.
|
|
88
|
+
npm install -g ai-agentmanager@1.8.0 # or a specific one
|
|
88
89
|
```
|
|
89
90
|
|
|
90
91
|
Or from the repository, at a release tag:
|
|
91
92
|
|
|
92
93
|
```sh
|
|
93
|
-
git clone --branch v1.
|
|
94
|
+
git clone --branch v1.8.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
|
|
94
95
|
cd ai-agentmanager-core
|
|
95
96
|
npm install
|
|
96
97
|
npm link # optional: puts `agentmgr` on your PATH
|
|
@@ -105,12 +106,12 @@ npm install
|
|
|
105
106
|
npm link
|
|
106
107
|
```
|
|
107
108
|
|
|
108
|
-
**Updating.** `agentmgr update` installs the newest release and restarts onto it; running agents pause briefly and resume. In the app, **Update to …** appears in the top bar when a release is out (checked twice a day; turn it off in **☰ → Settings** or with `agentmgr update --auto-check off`), and **About → Update and restart** does the same. A clone at a release tag (or one you `npm link`ed) moves to the newest tag; a clone on a branch or with uncommitted changes is left alone. By hand, use the command for how you installed, then `agentmgr restart`:
|
|
109
|
+
**Updating.** `agentmgr update` installs the newest release and restarts onto it; running agents pause briefly and resume. In the app, **Update to …** appears in the top bar when a release is out (checked twice a day; turn it off in **☰ → Settings** or with `agentmgr update --auto-check off`), and **About → Update and restart** does the same. Both work from a signed-in phone too: the install and restart run on the computer, and the page reloads when it's back. A clone at a release tag (or one you `npm link`ed) moves to the newest tag; a clone on a branch or with uncommitted changes is left alone. By hand, use the command for how you installed, then `agentmgr restart`:
|
|
109
110
|
|
|
110
111
|
| Installed with | Update |
|
|
111
112
|
| --- | --- |
|
|
112
113
|
| npm | `npm install -g ai-agentmanager@latest` |
|
|
113
|
-
| A clone at a release tag | `git fetch --tags && git checkout v1.
|
|
114
|
+
| A clone at a release tag | `git fetch --tags && git checkout v1.8.0 && npm install` (the tag you want) |
|
|
114
115
|
| A clone of `main` | `git pull && npm install` |
|
|
115
116
|
|
|
116
117
|
**Check your setup.** `agentmgr doctor` checks Node.js, Claude Code and its sign-in, git, `gh`, the data folder, the daemon's port and Chrome, and says how to fix anything missing.
|
|
@@ -384,6 +385,7 @@ Each card has at most **three points of about 12 words each**, so they're easy t
|
|
|
384
385
|
|
|
385
386
|
- **Files and diff.** The changed files on the left with lines added and removed, and the diff file by file on the right. Click a file header to fold it; very large files start folded.
|
|
386
387
|
- **Line comments.** Click **+** next to a line (or its line number) to comment on it, and add an overall comment if you like. **Send to agent** types them into the agent as one message, each with its file, line and the code it is about, and asks it to address them. Comments not sent yet are kept in this browser.
|
|
388
|
+
- **Explain changes** explains each change one by one: a title, what it changes, why (from the request and the conversation), the risk and what to check, in the order a reviewer should read them. Each explanation sits above its hunk, and **◀ / ▶** (or **[** and **]**) step through them, opening folded files on the way. When the agent changes the code afterwards, the page says so and **Explain again** brings it up to date. From the CLI: `agentmgr doc <agent> walkthrough`.
|
|
387
389
|
- **Commit all** commits everything in the worktree, new files included, with your git name and email.
|
|
388
390
|
- **Push & open PR** pushes the branch and opens a pull request with `gh`, against the branch the agent started from (title from your text, or from the commits; **Draft** if you like). With a pull request already open, it pushes to it. The pull request is linked to the agent.
|
|
389
391
|
|
|
@@ -909,6 +911,7 @@ Usage comes from the session transcripts and is refreshed every few seconds whil
|
|
|
909
911
|
## Studio: code and terminals
|
|
910
912
|
|
|
911
913
|
- **Code** (a tab next to **Claude** in the terminal pane) opens the agent's folder: a file tree, tabs, and Monaco, the editor inside VS Code, with syntax highlighting and search within a file. **Save** (Ctrl/Cmd+S) writes into the agent's folder. If the agent changed the file since you opened it, you're asked before anything is overwritten. Files you haven't edited pick up the agent's changes as it makes them. **Search** finds text across the folder (git grep, so ignored files are skipped). **Diff** compares the file with where the agent started, side by side, and you can edit on the right. **+ / ▣ / ✎ / 🗑** create, rename and delete. Every path stays inside the agent's folder: `..`, and links that point outside, are refused.
|
|
914
|
+
- **◫ Split** (in the terminal bar) keeps the terminal, a shell or a browser tab on the left and opens a pane on the right with **Code**, **Docs** (Diagrams by default; Design, Flow, Analysis, Proposal or Explain from the menu, written there if missing) or **Changes** (the change-by-change explanations, each with its diff). Drag the divider to resize; the pane, its tab and its width are remembered in this browser. Desktop only.
|
|
912
915
|
- **VS Code / Cursor** opens the agent's folder (and the open file) in the editor installed on this computer. The buttons show when its `code` or `cursor` command is found.
|
|
913
916
|
- Monaco loads from the jsDelivr CDN the first time you open **Code**. Offline, a plain text editor takes its place.
|
|
914
917
|
- **Terminal** in the top bar opens a shell on this computer in your home folder, not tied to any agent. It works from any device you're signed in on, so you can run any command from your phone. **+ Terminal** opens more. They keep running when hidden; **×** ends one. Plugins can refuse it with the `terminal.open` policy (`kind: "machine"`).
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -5,6 +5,7 @@ import DOMPurify from '/vendor/dompurify/purify.es.mjs';
|
|
|
5
5
|
import { touchScroll } from '/touchscroll.js';
|
|
6
6
|
import { openStream, post, REMOTE } from '/transport.js';
|
|
7
7
|
import { attachBox } from '/attach.js';
|
|
8
|
+
import { initSide } from '/side.js';
|
|
8
9
|
import { initReview } from '/review.js';
|
|
9
10
|
import { initLand, prChips } from '/land.js';
|
|
10
11
|
import { initTriggers } from '/triggers.js';
|
|
@@ -111,7 +112,8 @@ initSetup({ api, esc, $ });
|
|
|
111
112
|
const updateUi = initUpdate({ api, esc, toast, $ });
|
|
112
113
|
initMenu({ api, esc, toast, $ });
|
|
113
114
|
initMachine({ api, esc, toast, $, Terminal, FitAddon });
|
|
114
|
-
|
|
115
|
+
let sidePane = null; // the split view's right pane (set up once the terminal is)
|
|
116
|
+
const codeUi = initCode({ api, esc, toast, $, agents, isOpen: () => term.kind === 'code' || Boolean(sidePane?.showing('code')) });
|
|
115
117
|
const playbooksUi = initPlaybooks({ api, esc, toast, $, select: (id) => (showTab('agents'), select(id)) });
|
|
116
118
|
$('#new-playbook').onclick = () => {
|
|
117
119
|
const f = $('#new-form');
|
|
@@ -162,6 +164,18 @@ function checksSummary(b) {
|
|
|
162
164
|
}
|
|
163
165
|
|
|
164
166
|
const diagrams = new Map(); // mermaid source -> rendered SVG (or an error)
|
|
167
|
+
/** Whether the page is dark: the menu's choice, else this device's setting. */
|
|
168
|
+
const isDark = () => {
|
|
169
|
+
const t = document.documentElement.dataset.theme;
|
|
170
|
+
return t ? t === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
|
|
171
|
+
};
|
|
172
|
+
// Diagrams are drawn in the theme's colors: redraw them when it changes.
|
|
173
|
+
window.addEventListener('agentmgr-theme', () => {
|
|
174
|
+
diagrams.clear();
|
|
175
|
+
mermaidLib?.initialize({ startOnLoad: false, securityLevel: 'strict', theme: isDark() ? 'dark' : 'default' });
|
|
176
|
+
for (const el of document.querySelectorAll('[data-mermaid][data-filled]')) delete el.dataset.filled;
|
|
177
|
+
renderDiagrams(document);
|
|
178
|
+
});
|
|
165
179
|
const expanded = new Set(); // "<agent id>:<section>" the user opened with Show more
|
|
166
180
|
|
|
167
181
|
/** Long sections are clamped; Show more toggles them. */
|
|
@@ -187,7 +201,7 @@ async function renderDiagrams(root) {
|
|
|
187
201
|
try {
|
|
188
202
|
if (!mermaidLib) {
|
|
189
203
|
mermaidLib = (await import('/vendor/mermaid/mermaid.esm.min.mjs')).default;
|
|
190
|
-
const dark =
|
|
204
|
+
const dark = isDark();
|
|
191
205
|
mermaidLib.initialize({ startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' });
|
|
192
206
|
}
|
|
193
207
|
const { svg } = await mermaidLib.render(`mmd-${diagrams.size}-${Date.now()}`, src);
|
|
@@ -636,7 +650,7 @@ function agentItem(a) {
|
|
|
636
650
|
const idle = !isActive(a);
|
|
637
651
|
return `<li data-id="${a.id}" ${a.kind === 'general' || a.kind === 'assistant' ? '' : 'draggable="true" '}class="agent pr${a.priority ?? 2} ${a.id === selectedId ? 'selected' : ''} ${idle ? 'idle' : ''}">
|
|
638
652
|
<div class="line1">${prioChip(a.priority ?? 2)}${a.results?.outcome === 'blocked' ? '<span class="outcome-mark" title="Blocked">⛔</span>' : a.results?.outcome === 'done' ? '<span class="outcome-mark" title="Done">✅</span>' : ''}<span class="name">${esc(a.name)}</span>${flags(a)}<span class="pill ${st}">${pillText(st)}</span></div>
|
|
639
|
-
<div class="intent">${a.kind === 'general' ? '<span class="tag">general</span> ' : a.kind === 'assistant' ? '<span class="tag assistant">assistant</span> ' : a.startedBy ? '<span class="tag assistant" title="Started by the assistant">🤖 by assistant</span> ' : a.trigger ? `<span class="tag assistant" title="Started from ${a.trigger.source === 'github' ? 'a labeled GitHub issue' : 'the webhook'}${a.trigger.url ? `: ${esc(a.trigger.url)}` : ''}">⚡ ${a.trigger.source === 'github' ? 'GitHub' : 'webhook'}</span> ` : a.playbook ? `<span class="tag assistant" title="Started by the playbook ${esc(a.playbook.name)}">▶ ${esc(a.playbook.name)}</span> ` : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(a
|
|
653
|
+
<div class="intent" title="${esc(describeTitle(a) ?? '')}">${a.kind === 'general' ? '<span class="tag">general</span> ' : a.kind === 'assistant' ? '<span class="tag assistant">assistant</span> ' : a.startedBy ? '<span class="tag assistant" title="Started by the assistant">🤖 by assistant</span> ' : a.trigger ? `<span class="tag assistant" title="Started from ${a.trigger.source === 'github' ? 'a labeled GitHub issue' : 'the webhook'}${a.trigger.url ? `: ${esc(a.trigger.url)}` : ''}">⚡ ${a.trigger.source === 'github' ? 'GitHub' : 'webhook'}</span> ` : a.playbook ? `<span class="tag assistant" title="Started by the playbook ${esc(a.playbook.name)}">▶ ${esc(a.playbook.name)}</span> ` : ''}${a.engine && a.engine !== 'claude' ? `<span class="tag engine">${esc(ENGINE_LABEL[a.engine] ?? a.engine)}</span> ` : ''}${esc(describe(a) || (a.kind === 'assistant' ? 'Finds new work and starts agents for it' : 'General session'))}</div>
|
|
640
654
|
${a.status === 'preparing' && a.progress ? `<div class="progress-line">${esc(progressText(a.progress))}</div>` : ''}
|
|
641
655
|
<div class="meta">
|
|
642
656
|
<span class="mono">${a.id}</span>
|
|
@@ -1592,7 +1606,7 @@ function renderDetail() {
|
|
|
1592
1606
|
${[0, 1, 2, 3].map((p) => `<option value="${p}" ${p === (a.priority ?? 2) ? 'selected' : ''}>P${p}</option>`).join('')}
|
|
1593
1607
|
</select>
|
|
1594
1608
|
<span class="d-name">${esc(a.name)}</span> <span class="pill ${st}">${pillText(st)}</span>${a.kind === 'general' ? ' <span class="tag">general</span>' : ''}</h2>
|
|
1595
|
-
<p class="intent">${esc(a.intent || (a.kind === 'general' ? 'General session: ask Claude anything in the terminal.' : a.kind === 'assistant' ? 'Your assistant: it checks for new work every few minutes and starts agents for it. Settings and activity: Assistant in the top bar.' : ''))}</p>
|
|
1609
|
+
<p class="intent">${a.synopsis?.text ? `<b>${esc(a.synopsis.text)}</b>${a.synopsis.source === 'jira' ? ` <span class="hint">· ${esc(a.synopsis.key)}</span>` : ''}<br><span class="hint">${esc(a.intent)}</span>` : esc(a.intent || (a.kind === 'general' ? 'General session: ask Claude anything in the terminal.' : a.kind === 'assistant' ? 'Your assistant: it checks for new work every few minutes and starts agents for it. Settings and activity: Assistant in the top bar.' : ''))}</p>
|
|
1596
1610
|
</div>
|
|
1597
1611
|
<div class="d-actions">
|
|
1598
1612
|
${a.status === 'error' && !a.workdir
|
|
@@ -1899,6 +1913,7 @@ const term = {
|
|
|
1899
1913
|
const a = this.agentId && agents.get(this.agentId);
|
|
1900
1914
|
// The agent's own session tab is named after its engine (Codex, Muse Code, …).
|
|
1901
1915
|
$('.term-tab[data-kind="claude"]').textContent = ENGINE_LABEL[a?.engine] ?? 'Claude';
|
|
1916
|
+
sidePane?.follow(this.agentId);
|
|
1902
1917
|
const nums = new Set(a?.runtime.shells ?? []);
|
|
1903
1918
|
if (isShell(this.kind)) nums.add(shellNum(this.kind));
|
|
1904
1919
|
$('#shell-tabs').innerHTML = [...nums]
|
|
@@ -1930,6 +1945,7 @@ const term = {
|
|
|
1930
1945
|
$('#term-wrap').hidden = false;
|
|
1931
1946
|
$('#detail').classList.add('with-term');
|
|
1932
1947
|
$('#term-wrap').classList.remove('browsing');
|
|
1948
|
+
sidePane?.mainShowsCode();
|
|
1933
1949
|
$('#term-wrap').classList.add('coding');
|
|
1934
1950
|
this.ensure();
|
|
1935
1951
|
this.disconnect();
|
|
@@ -2095,6 +2111,20 @@ $('#term-fit').onclick = () => term.refit();
|
|
|
2095
2111
|
|
|
2096
2112
|
// ------------------------------------------------------------------ resizing, expanding, full screen
|
|
2097
2113
|
|
|
2114
|
+
sidePane = initSide({
|
|
2115
|
+
api,
|
|
2116
|
+
esc,
|
|
2117
|
+
toast,
|
|
2118
|
+
$,
|
|
2119
|
+
agents,
|
|
2120
|
+
codeUi,
|
|
2121
|
+
docHtml,
|
|
2122
|
+
renderDiagrams,
|
|
2123
|
+
mainKind: () => term.kind,
|
|
2124
|
+
setMain: (kind) => term.agentId && term.open(term.agentId, kind),
|
|
2125
|
+
refit: () => requestAnimationFrame(() => term.refit()),
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2098
2128
|
const layoutPref = {
|
|
2099
2129
|
get(key) {
|
|
2100
2130
|
try {
|
|
@@ -2114,6 +2144,8 @@ function applyLayout() {
|
|
|
2114
2144
|
const side = layoutPref.get('sideWidth');
|
|
2115
2145
|
const brief = layoutPref.get('briefHeight');
|
|
2116
2146
|
document.documentElement.style.setProperty('--side-w', side ? `${side}px` : '340px');
|
|
2147
|
+
const pane = layoutPref.get('paneWidth');
|
|
2148
|
+
document.documentElement.style.setProperty('--pane-w', pane ? `${pane}%` : '45%');
|
|
2117
2149
|
if (brief) document.documentElement.style.setProperty('--brief-h', `${brief}px`);
|
|
2118
2150
|
else document.documentElement.style.removeProperty('--brief-h');
|
|
2119
2151
|
$('#detail').classList.toggle('brief-sized', Boolean(brief));
|
|
@@ -2156,6 +2188,14 @@ dragHandle($('#split-side'), 'cols', (ev) => {
|
|
|
2156
2188
|
return w;
|
|
2157
2189
|
}, 'sideWidth');
|
|
2158
2190
|
|
|
2191
|
+
// The split view's divider: the right pane's share of the panel, in percent.
|
|
2192
|
+
dragHandle($('#split-pane'), 'cols', (ev) => {
|
|
2193
|
+
const r = $('#term-split').getBoundingClientRect();
|
|
2194
|
+
const pct = Math.min(Math.max(((r.right - ev.clientX) / r.width) * 100, 20), 75);
|
|
2195
|
+
layoutPref.set('paneWidth', Math.round(pct));
|
|
2196
|
+
return pct;
|
|
2197
|
+
}, 'paneWidth');
|
|
2198
|
+
|
|
2159
2199
|
dragHandle($('#split-term'), 'rows', (ev) => {
|
|
2160
2200
|
const top = $('#detail').getBoundingClientRect().top;
|
|
2161
2201
|
const h = Math.min(Math.max(ev.clientY - top, 60), $('#detail').clientHeight - 160);
|
|
@@ -2863,7 +2903,14 @@ $('#term-file').onchange = (e) => {
|
|
|
2863
2903
|
function upsert(a) {
|
|
2864
2904
|
agents.set(a.id, a);
|
|
2865
2905
|
render();
|
|
2906
|
+
agentChanged(a);
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
/** Views that show one agent's details outside the main render: keep them current. */
|
|
2910
|
+
function agentChanged(a) {
|
|
2866
2911
|
if (a.id === term.agentId) term.renderTabs();
|
|
2912
|
+
reviewUi.agentChanged(a);
|
|
2913
|
+
sidePane?.agentChanged(a);
|
|
2867
2914
|
}
|
|
2868
2915
|
|
|
2869
2916
|
$('#counts').addEventListener('click', (e) => {
|
|
@@ -3415,7 +3462,8 @@ $('#about-check').onclick = async () => {
|
|
|
3415
3462
|
out.hidden = false;
|
|
3416
3463
|
out.textContent = 'Checking…';
|
|
3417
3464
|
try {
|
|
3418
|
-
//
|
|
3465
|
+
// The daemon checks and can install it (from this computer or a signed-in phone); an older
|
|
3466
|
+
// daemon without /update gets the manual steps.
|
|
3419
3467
|
const local = await updateUi.check().catch(() => null);
|
|
3420
3468
|
if (local) return void (out.innerHTML = local);
|
|
3421
3469
|
const r = await api('GET', '/about/latest');
|
|
@@ -3498,7 +3546,7 @@ function handleEvent(msg) {
|
|
|
3498
3546
|
} else if (msg.t === 'agent') {
|
|
3499
3547
|
agents.set(msg.agent.id, msg.agent);
|
|
3500
3548
|
watchDocs(msg.agent);
|
|
3501
|
-
|
|
3549
|
+
agentChanged(msg.agent);
|
|
3502
3550
|
} else if (msg.t === 'removed') {
|
|
3503
3551
|
agents.delete(msg.id);
|
|
3504
3552
|
if (selectedId === msg.id) select(null);
|
|
@@ -4035,6 +4083,10 @@ $('#login-form').addEventListener('submit', async (e) => {
|
|
|
4035
4083
|
});
|
|
4036
4084
|
|
|
4037
4085
|
// Engines this machine can run; the New agent form offers a choice once there's more than Claude.
|
|
4086
|
+
/** What an agent is about: the issue's summary when its request starts with an issue link, else the request. */
|
|
4087
|
+
const describe = (a) => a.synopsis?.text || a.intent;
|
|
4088
|
+
const describeTitle = (a) => (a.synopsis?.text ? `${a.synopsis.source === 'jira' ? `${a.synopsis.key} in Jira` : `${a.synopsis.key} (summary not fetched; your words after the link)`}. Request: ${a.intent}` : a.intent);
|
|
4089
|
+
|
|
4038
4090
|
const ENGINE_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravity', muse: 'Muse Code' };
|
|
4039
4091
|
async function loadEngines() {
|
|
4040
4092
|
try {
|
package/public/index.html
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
6
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
7
7
|
<title>AI Agent Manager</title>
|
|
8
|
+
<script>
|
|
9
|
+
// The theme chosen in the menu (Auto / Light / Dark), applied before anything draws.
|
|
10
|
+
try {
|
|
11
|
+
const t = localStorage.getItem('agentmgr.theme');
|
|
12
|
+
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
|
|
13
|
+
} catch {}
|
|
14
|
+
</script>
|
|
8
15
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22%3E%3Cpath d=%22M8 1l7 7-7 7-7-7z%22 fill=%22%233b5bdb%22/%3E%3C/svg%3E" />
|
|
9
16
|
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
|
|
10
17
|
<meta name="theme-color" content="#151821" />
|
|
@@ -17,7 +24,7 @@
|
|
|
17
24
|
<div class="brand">
|
|
18
25
|
<a href="#" id="home-link" class="home-link" title="Home: all agents"><span class="logo" aria-hidden="true">◆</span><h1>AI Agent Manager</h1></a>
|
|
19
26
|
<button id="about-btn" class="about-btn" type="button" title="About AI Agent Manager: version, release, links">About</button>
|
|
20
|
-
<button id="update-btn" class="update-btn
|
|
27
|
+
<button id="update-btn" class="update-btn" type="button" hidden></button>
|
|
21
28
|
<span id="conn" class="conn" title="Daemon connection">connecting…</span>
|
|
22
29
|
</div>
|
|
23
30
|
<div class="top-actions">
|
|
@@ -42,6 +49,8 @@
|
|
|
42
49
|
<button role="menuitem" id="alerts-btn" class="local-only" title="Slack alerts when an agent needs you">Alerts…</button>
|
|
43
50
|
<button role="menuitem" id="remote-btn" class="local-only" title="Open AI Agent Manager from your phone">Remote access…</button>
|
|
44
51
|
<div class="menu-sep"></div>
|
|
52
|
+
<div class="menu-theme"><span>Theme</span><span class="seg" role="radiogroup" aria-label="Theme"><button type="button" data-theme-set="auto" title="Follow this device's setting">Auto</button><button type="button" data-theme-set="light">Light</button><button type="button" data-theme-set="dark">Dark</button></span></div>
|
|
53
|
+
<div class="menu-sep"></div>
|
|
45
54
|
<button role="menuitem" id="menu-about">About and updates…</button>
|
|
46
55
|
</div>
|
|
47
56
|
</div>
|
|
@@ -96,41 +105,59 @@
|
|
|
96
105
|
<button id="term-attach" class="ghost small" title="Attach files or images (you can also drop or paste them into the terminal)">📎 Attach</button>
|
|
97
106
|
<input type="file" id="term-file" multiple hidden>
|
|
98
107
|
<button id="term-fit" class="ghost small" title="Refit the terminal to the panel">Fit</button>
|
|
108
|
+
<button id="term-split-btn" class="ghost small desk-only" title="Split: the terminal on the left, and Code, Docs (diagrams) or Changes on the right">◫ Split</button>
|
|
99
109
|
<button id="term-max" class="ghost small desk-only" title="Expand to fill the window (Ctrl+Shift+E)">⤢ Expand</button>
|
|
100
110
|
<button id="term-full" class="ghost small desk-only" title="Full screen (Esc to leave)">⛶ Full screen</button>
|
|
101
111
|
<button id="term-close" class="ghost small" title="Hide the terminal (the agent keeps running)">Hide terminal</button>
|
|
102
112
|
</div>
|
|
103
113
|
<div id="signin-bar" class="signin-bar" hidden></div>
|
|
104
|
-
<div
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
<
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
<div id="browser-
|
|
114
|
+
<div class="term-split" id="term-split">
|
|
115
|
+
<div class="term-main" id="term-main">
|
|
116
|
+
<div id="terminal" class="terminal"></div>
|
|
117
|
+
<div id="code" class="code"></div>
|
|
118
|
+
<div id="browser" class="browser">
|
|
119
|
+
<form id="browser-bar" class="browser-bar" autocomplete="off">
|
|
120
|
+
<button type="button" id="browser-back" class="ghost small live-only" title="Back">◀</button>
|
|
121
|
+
<button type="button" id="browser-fwd" class="ghost small live-only" title="Forward">▶</button>
|
|
122
|
+
<button type="button" id="browser-reload" class="ghost small" title="Reload the page">⟳</button>
|
|
123
|
+
<input id="browser-url" placeholder="localhost:5173, a port, or any address" spellcheck="false" autocapitalize="off" autocorrect="off" enterkeyhint="go">
|
|
124
|
+
<button class="small">Go</button>
|
|
125
|
+
<button type="button" id="browser-sound" class="ghost small live-only" title="Hear the page: sound from videos and audio playing on that machine">🔇</button>
|
|
126
|
+
<button type="button" id="browser-keys" class="ghost small live-only touch-only" title="Show the keyboard to type into the page">⌨</button>
|
|
127
|
+
<button type="button" id="browser-mode" class="ghost small" hidden></button>
|
|
128
|
+
<a id="browser-pop" class="ghost small button" target="_blank" rel="noopener" title="Open in a new browser tab on this device" hidden>↗</a>
|
|
129
|
+
</form>
|
|
130
|
+
<div id="browser-hint" class="browser-hint" hidden>Blank? Many sites refuse to show inside another page. Open it with ↗ instead.</div>
|
|
131
|
+
<div id="browser-cert" class="browser-start" hidden>
|
|
132
|
+
<p><b>One more step on this device.</b> Each browser tab goes through its own port, and this device hasn't accepted AI Agent Manager's certificate for this one yet.</p>
|
|
133
|
+
<p>Open it, accept the warning the same way you did for AI Agent Manager (Safari: Show Details → visit this website), then come back here. You only do this once per site; it keeps its port.</p>
|
|
134
|
+
<div><a id="browser-cert-open" class="button primary small" target="_blank" rel="noopener">Open and accept</a> <button type="button" id="browser-cert-retry" class="small">Done, load it</button></div>
|
|
135
|
+
</div>
|
|
136
|
+
<div id="browser-start" class="browser-start" hidden>
|
|
137
|
+
<p>Open a page as this machine sees it: the agent's dev server, a site on its network or VPN, or any address. It works from your phone or laptop too: AI Agent Manager relays it over its own secure connection, for signed-in devices only.</p>
|
|
138
|
+
<div id="browser-suggest"></div>
|
|
139
|
+
</div>
|
|
140
|
+
<div id="browser-frames" class="browser-frames"></div>
|
|
141
|
+
<div id="browser-live" class="browser-live" hidden>
|
|
142
|
+
<img id="browser-live-img" alt="" draggable="false">
|
|
143
|
+
<textarea id="browser-live-keys" class="browser-live-keys" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Type into the page"></textarea>
|
|
144
|
+
<div id="browser-live-msg" class="browser-live-msg" hidden></div>
|
|
145
|
+
</div>
|
|
127
146
|
</div>
|
|
128
|
-
<div id="browser-frames" class="browser-frames"></div>
|
|
129
|
-
<div id="browser-live" class="browser-live" hidden>
|
|
130
|
-
<img id="browser-live-img" alt="" draggable="false">
|
|
131
|
-
<textarea id="browser-live-keys" class="browser-live-keys" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Type into the page"></textarea>
|
|
132
|
-
<div id="browser-live-msg" class="browser-live-msg" hidden></div>
|
|
133
147
|
</div>
|
|
148
|
+
<div class="splitter split-pane" id="split-pane" role="separator" aria-orientation="vertical" title="Drag to resize; double-click to reset" hidden></div>
|
|
149
|
+
<aside class="side-pane" id="side-pane" hidden>
|
|
150
|
+
<div class="side-bar">
|
|
151
|
+
<div class="term-tabs" role="tablist">
|
|
152
|
+
<button class="term-tab" data-side="code" role="tab" title="The agent's files">Code</button>
|
|
153
|
+
<button class="term-tab" data-side="docs" role="tab" title="Diagrams and the other documents">Docs</button>
|
|
154
|
+
<button class="term-tab" data-side="changes" role="tab" title="Its changes, explained one by one">Changes</button>
|
|
155
|
+
</div>
|
|
156
|
+
<span class="spacer"></span>
|
|
157
|
+
<button type="button" class="ghost small" id="side-close" title="Close the split">✕</button>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="side-body" id="side-body"></div>
|
|
160
|
+
</aside>
|
|
134
161
|
</div>
|
|
135
162
|
<div id="term-drop" class="term-drop" hidden>Drop files to attach</div>
|
|
136
163
|
<div class="keybar" id="keybar" aria-label="Terminal keys">
|
|
@@ -673,9 +700,9 @@
|
|
|
673
700
|
<dl id="about-list" class="about-list"></dl>
|
|
674
701
|
<p id="about-update" class="hint" hidden></p>
|
|
675
702
|
<div class="dialog-actions">
|
|
676
|
-
<label class="about-auto
|
|
703
|
+
<label class="about-auto" title="Twice a day, ask for the newest release (GitHub for a git checkout, npm otherwise)"><input type="checkbox" id="about-auto" /> Check automatically</label>
|
|
677
704
|
<button type="button" id="about-check" class="ghost" title="Asks for the newest release">Check for updates</button>
|
|
678
|
-
<button type="button" id="about-install" class="primary
|
|
705
|
+
<button type="button" id="about-install" class="primary" hidden>Update and restart</button>
|
|
679
706
|
<button type="button" id="about-close" class="primary">Close</button>
|
|
680
707
|
</div>
|
|
681
708
|
</dialog>
|
|
@@ -694,6 +721,15 @@
|
|
|
694
721
|
<p class="hint">After a restart or a crash, the daemon comes back by itself and running agents resume; without it, it starts only when you run <code>agentmgr</code>. <span id="settings-service-state"></span></p>
|
|
695
722
|
</fieldset>
|
|
696
723
|
<fieldset class="local-only">
|
|
724
|
+
<legend>Jira</legend>
|
|
725
|
+
<p class="hint">For an agent created from a Jira link, its description shows the issue's summary. Public issues on Atlassian Cloud need nothing here; for your company's site, add it with your email and an <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" rel="noopener">API token</a>. The token is kept on this computer and sent only to that site.</p>
|
|
726
|
+
<div class="row three">
|
|
727
|
+
<label>Site <input name="jiraSite" placeholder="https://yourcompany.atlassian.net" autocomplete="off" /></label>
|
|
728
|
+
<label>Email <input name="jiraEmail" type="email" autocomplete="off" /></label>
|
|
729
|
+
<label>API token <input name="jiraToken" type="password" autocomplete="new-password" /></label>
|
|
730
|
+
</div>
|
|
731
|
+
</fieldset>
|
|
732
|
+
<fieldset>
|
|
697
733
|
<legend>Updates</legend>
|
|
698
734
|
<label class="check"><input type="checkbox" name="updateCheck" /> Check for a new release twice a day</label>
|
|
699
735
|
<p class="hint">Shows <b>Update to …</b> in the top bar; one click installs it and restarts, and running agents resume.</p>
|
package/public/menu.js
CHANGED
|
@@ -22,7 +22,8 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
22
22
|
});
|
|
23
23
|
// Any item closes the menu; its own handler does the rest.
|
|
24
24
|
menu.addEventListener('click', (e) => {
|
|
25
|
-
|
|
25
|
+
// The theme buttons keep it open, to compare.
|
|
26
|
+
if (e.target.closest('button') && !e.target.closest('[data-theme-set]')) open(false);
|
|
26
27
|
});
|
|
27
28
|
$('#menu-about').onclick = () => $('#about-btn').click();
|
|
28
29
|
|
|
@@ -53,6 +54,7 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
53
54
|
const dialog = $('#settings-dialog');
|
|
54
55
|
const form = $('#settings-form');
|
|
55
56
|
let service = null; // the login service as the dialog opened
|
|
57
|
+
let jira = null; // the Jira settings as the dialog opened
|
|
56
58
|
$('#settings-btn').onclick = async () => {
|
|
57
59
|
$('#settings-error').hidden = true;
|
|
58
60
|
try {
|
|
@@ -60,9 +62,14 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
60
62
|
form.maxRunning.value = c.maxRunning ?? '';
|
|
61
63
|
$('#settings-running').textContent = `Open now: ${c.running}.`;
|
|
62
64
|
service = null;
|
|
65
|
+
const u = await api('GET', '/update').catch(() => null);
|
|
66
|
+
form.updateCheck.checked = u?.check !== false;
|
|
63
67
|
if (!remote()) {
|
|
64
|
-
|
|
65
|
-
form.
|
|
68
|
+
jira = await api('GET', '/jira').catch(() => null);
|
|
69
|
+
form.jiraSite.value = jira?.site ?? '';
|
|
70
|
+
form.jiraEmail.value = jira?.email ?? '';
|
|
71
|
+
form.jiraToken.value = '';
|
|
72
|
+
form.jiraToken.placeholder = jira?.hasToken ? 'saved (type to replace)' : '';
|
|
66
73
|
service = await api('GET', '/service').catch(() => null);
|
|
67
74
|
$('#settings-service').hidden = !service?.supported;
|
|
68
75
|
form.startAtLogin.checked = Boolean(service?.installed);
|
|
@@ -84,7 +91,11 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
84
91
|
e.preventDefault();
|
|
85
92
|
try {
|
|
86
93
|
const c = await api('PUT', '/cleanup', { maxRunning: form.maxRunning.value.trim() || null });
|
|
87
|
-
|
|
94
|
+
await api('PUT', '/update', { check: form.updateCheck.checked }).catch(() => {});
|
|
95
|
+
if (!remote() && jira && (form.jiraSite.value.trim() !== (jira.site ?? '') || form.jiraEmail.value.trim() !== (jira.email ?? '') || form.jiraToken.value.trim())) {
|
|
96
|
+
await api('PUT', '/jira', { site: form.jiraSite.value.trim(), email: form.jiraEmail.value.trim(), token: form.jiraToken.value.trim() || undefined });
|
|
97
|
+
toast('Jira saved; summaries for agents from Jira links are being fetched', 4000);
|
|
98
|
+
}
|
|
88
99
|
// Only when it changed: installing touches launchd / systemd.
|
|
89
100
|
if (!remote() && service?.supported && form.startAtLogin.checked !== Boolean(service.installed)) {
|
|
90
101
|
const s = await api('PUT', '/service', { enabled: form.startAtLogin.checked });
|
|
@@ -98,6 +109,31 @@ export function initMenu({ api, esc, toast, $ }) {
|
|
|
98
109
|
}
|
|
99
110
|
});
|
|
100
111
|
|
|
112
|
+
// ------------------------------------------------------------ Theme
|
|
113
|
+
const THEME_KEY = 'agentmgr.theme';
|
|
114
|
+
const theme = () => {
|
|
115
|
+
try {
|
|
116
|
+
return localStorage.getItem(THEME_KEY) || 'auto';
|
|
117
|
+
} catch {
|
|
118
|
+
return 'auto';
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
function applyTheme(t) {
|
|
122
|
+
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
|
|
123
|
+
else delete document.documentElement.dataset.theme;
|
|
124
|
+
for (const b of menu.querySelectorAll('[data-theme-set]')) b.classList.toggle('on', b.dataset.themeSet === t);
|
|
125
|
+
window.dispatchEvent(new CustomEvent('agentmgr-theme'));
|
|
126
|
+
}
|
|
127
|
+
menu.addEventListener('click', (e) => {
|
|
128
|
+
const b = e.target.closest('[data-theme-set]');
|
|
129
|
+
if (!b) return;
|
|
130
|
+
try {
|
|
131
|
+
localStorage.setItem(THEME_KEY, b.dataset.themeSet);
|
|
132
|
+
} catch {}
|
|
133
|
+
applyTheme(b.dataset.themeSet);
|
|
134
|
+
});
|
|
135
|
+
applyTheme(theme());
|
|
136
|
+
|
|
101
137
|
loadMe();
|
|
102
138
|
return { loadMe };
|
|
103
139
|
}
|
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/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
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
16
16
|
color-scheme: light;
|
|
17
17
|
}
|
|
18
|
+
/* Dark: the system's setting unless the page is set to Light, or always when set to Dark (☰ menu). */
|
|
18
19
|
@media (prefers-color-scheme: dark) {
|
|
19
|
-
:root {
|
|
20
|
+
:root:not([data-theme="light"]) {
|
|
20
21
|
--bg: #0d0f14;
|
|
21
22
|
--panel: #151821;
|
|
22
23
|
--panel-2: #1c202b;
|
|
@@ -31,6 +32,20 @@
|
|
|
31
32
|
color-scheme: dark;
|
|
32
33
|
}
|
|
33
34
|
}
|
|
35
|
+
:root[data-theme="dark"] {
|
|
36
|
+
--bg: #0d0f14;
|
|
37
|
+
--panel: #151821;
|
|
38
|
+
--panel-2: #1c202b;
|
|
39
|
+
--border: #2a2f3c;
|
|
40
|
+
--text: #e5e8ef;
|
|
41
|
+
--muted: #8b93a7;
|
|
42
|
+
--accent: #6d8bff;
|
|
43
|
+
--accent-text: #0d0f14;
|
|
44
|
+
--green: #3ecf8e;
|
|
45
|
+
--yellow: #e6b450;
|
|
46
|
+
--red: #ff6b6b;
|
|
47
|
+
color-scheme: dark;
|
|
48
|
+
}
|
|
34
49
|
|
|
35
50
|
* { box-sizing: border-box; }
|
|
36
51
|
[hidden] { display: none !important; }
|
|
@@ -837,3 +852,45 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
|
|
|
837
852
|
.attach-chip.fail { border-color: var(--red); }
|
|
838
853
|
.attach-chip .link { padding: 0 4px; }
|
|
839
854
|
form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px; }
|
|
855
|
+
|
|
856
|
+
/* Walkthrough: an explanation card above each hunk; the current one is marked. */
|
|
857
|
+
.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); }
|
|
858
|
+
.wt-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
859
|
+
.wt-n { font: 11px var(--mono, ui-monospace); color: var(--muted); }
|
|
860
|
+
.wt-k { display: inline-block; min-width: 44px; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
|
|
861
|
+
.rv-explain td { background: var(--panel); border-top: 1px solid var(--border); }
|
|
862
|
+
.rv-explain.current td { box-shadow: inset 3px 0 0 var(--accent, #6b8cff); background: var(--panel-2); }
|
|
863
|
+
.rv-walk { display: inline-flex; align-items: center; gap: 4px; }
|
|
864
|
+
.rv-walk-note { margin: 4px 12px 8px; }
|
|
865
|
+
|
|
866
|
+
/* Split view: the terminal (or a browser tab, or Code) on the left, a pane on the right. */
|
|
867
|
+
.term-split { flex: 1; display: flex; min-height: 0; min-width: 0; }
|
|
868
|
+
.term-main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
869
|
+
.split-pane { flex: none; width: 0; }
|
|
870
|
+
.split-pane::after { top: 0; bottom: 0; left: -4px; width: 8px; cursor: col-resize; }
|
|
871
|
+
.split-pane[hidden], .side-pane[hidden] { display: none !important; }
|
|
872
|
+
.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); }
|
|
873
|
+
.side-bar { display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: #161a22; color: #c9d1d9; font-size: 12.5px; }
|
|
874
|
+
.side-bar button { color: #c9d1d9; border-color: #30363d; }
|
|
875
|
+
.side-body { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
|
876
|
+
.side-pane .code { display: flex; flex: 1; }
|
|
877
|
+
.side-tools { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); }
|
|
878
|
+
.side-tools select { width: auto; }
|
|
879
|
+
.side-scroll { flex: 1; overflow: auto; padding: 10px 12px; }
|
|
880
|
+
.side-pad { padding: 10px 12px; }
|
|
881
|
+
.side-scroll .diagrams-view .diagram, .side-scroll .diagram { display: flex; justify-content: center; overflow-x: auto; }
|
|
882
|
+
.side-scroll .diagram svg { max-width: 100%; height: auto; }
|
|
883
|
+
.sd-step { margin-bottom: 14px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
884
|
+
.sd-file { padding: 4px 10px; background: var(--panel-2); font-size: 12px; }
|
|
885
|
+
.sd-hunk { font: 12px/1.45 var(--mono, ui-monospace, monospace); white-space: pre; overflow-x: auto; border-top: 1px solid var(--border); }
|
|
886
|
+
.sd-hunk > div { padding: 0 10px; }
|
|
887
|
+
.sd-head { color: var(--muted); background: var(--panel); }
|
|
888
|
+
.sd-add { background: rgba(46, 160, 67, .15); }
|
|
889
|
+
.sd-del { background: rgba(248, 81, 73, .15); }
|
|
890
|
+
#term-split-btn.on { background: #262c38; }
|
|
891
|
+
|
|
892
|
+
/* Theme switch in the menu: Auto / Light / Dark. */
|
|
893
|
+
.menu-theme { display: flex; align-items: center; gap: 6px; padding: 6px 12px; }
|
|
894
|
+
.menu-theme .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin-left: auto; }
|
|
895
|
+
.menu-theme .seg button { border: 0; border-radius: 0; padding: 3px 9px; font-size: 12px; background: transparent; }
|
|
896
|
+
.menu-theme .seg button.on { background: var(--accent); color: var(--accent-text); }
|
package/public/update.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Updating from the app: a button in the top bar when a newer release is out, and "Update and
|
|
2
2
|
// restart" in About. The daemon installs it (git checkout of the release tag, or npm install -g)
|
|
3
|
-
// and restarts onto it; this page then waits for it to come back and reloads.
|
|
3
|
+
// and restarts onto it; this page then waits for it to come back and reloads. It works the same
|
|
4
|
+
// from a phone over remote access: the install runs on the computer.
|
|
4
5
|
|
|
5
6
|
export function initUpdate({ api, esc, toast, $ }) {
|
|
6
|
-
const remote = () => document.body.classList.contains('remote');
|
|
7
7
|
let state = null;
|
|
8
8
|
|
|
9
9
|
function show(s) {
|
|
@@ -20,7 +20,6 @@ export function initUpdate({ api, esc, toast, $ }) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
async function load() {
|
|
23
|
-
if (remote()) return;
|
|
24
23
|
try {
|
|
25
24
|
show(await api('GET', '/update'));
|
|
26
25
|
} catch {
|
|
@@ -62,9 +61,8 @@ export function initUpdate({ api, esc, toast, $ }) {
|
|
|
62
61
|
}
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
/** "Check for updates" in About: the daemon's check
|
|
64
|
+
/** "Check for updates" in About: the daemon's check, and whether it can update itself. */
|
|
66
65
|
async function check() {
|
|
67
|
-
if (remote()) return null;
|
|
68
66
|
const s = await api('POST', '/update/check');
|
|
69
67
|
show(s);
|
|
70
68
|
if (!s.newer) return `You have the newest release (${esc(s.current)}).`;
|
|
@@ -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/jira.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The summary of the Jira issue a request starts with, so an agent created from a link shows what
|
|
2
|
+
// the issue is about instead of the link. Fetched from Jira Cloud's REST API in the background;
|
|
3
|
+
// when it can't be fetched, the words after the link stand in for it.
|
|
4
|
+
//
|
|
5
|
+
// Only Atlassian Cloud sites (*.atlassian.net) and the site set here are contacted, and the email
|
|
6
|
+
// and API token go only to that site: requests can come from outside (GitHub issues, the
|
|
7
|
+
// assistant), and a link in one must not receive your token or reach internal addresses.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { paths } from './config.js';
|
|
11
|
+
import { leadingJiraKey } from './tracker.js';
|
|
12
|
+
|
|
13
|
+
const file = () => path.join(paths.home, 'jira.json');
|
|
14
|
+
|
|
15
|
+
/** Saved settings ({ site, email, token }), with JIRA_SITE / JIRA_EMAIL / JIRA_API_TOKEN as fallbacks. */
|
|
16
|
+
export function loadJira(env = process.env) {
|
|
17
|
+
let saved = {};
|
|
18
|
+
try {
|
|
19
|
+
saved = JSON.parse(fs.readFileSync(file(), 'utf8'));
|
|
20
|
+
} catch {}
|
|
21
|
+
return {
|
|
22
|
+
site: normSite(saved.site || env.JIRA_SITE || ''),
|
|
23
|
+
email: saved.email || env.JIRA_EMAIL || '',
|
|
24
|
+
token: saved.token || env.JIRA_API_TOKEN || '',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What the page shows: never the token itself. */
|
|
29
|
+
export const jiraView = (j = loadJira()) => ({ site: j.site, email: j.email, hasToken: Boolean(j.token) });
|
|
30
|
+
|
|
31
|
+
/** Save settings; an empty token keeps the saved one unless `clearToken`. */
|
|
32
|
+
export function saveJira({ site, email, token, clearToken = false } = {}) {
|
|
33
|
+
const cur = (() => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(file(), 'utf8'));
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
const next = {
|
|
41
|
+
site: site === undefined ? cur.site ?? '' : normSite(site),
|
|
42
|
+
email: email === undefined ? cur.email ?? '' : String(email).trim(),
|
|
43
|
+
token: clearToken ? '' : token ? String(token).trim() : cur.token ?? '',
|
|
44
|
+
};
|
|
45
|
+
if (next.site && !/^https:\/\/[\w.-]+$/.test(next.site)) throw Object.assign(new Error('The Jira site is an https address, like https://yourcompany.atlassian.net'), { status: 400 });
|
|
46
|
+
fs.mkdirSync(paths.home, { recursive: true });
|
|
47
|
+
fs.writeFileSync(file(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
48
|
+
return jiraView(loadJira());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normSite(s) {
|
|
52
|
+
const v = String(s ?? '').trim().replace(/\/+$/, '');
|
|
53
|
+
if (!v) return '';
|
|
54
|
+
return /^https?:\/\//.test(v) ? v : `https://${v}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The issue a request starts with: { key, site } from its leading link, or null. */
|
|
58
|
+
export function leadingIssue(text) {
|
|
59
|
+
const key = leadingJiraKey(text);
|
|
60
|
+
if (!key) return null;
|
|
61
|
+
const url = String(text).trimStart().match(/^<?(https?:\/\/\S+)/)[1];
|
|
62
|
+
try {
|
|
63
|
+
return { key, site: new URL(url).origin };
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The person's own words after the leading link ("…/browse/ABC-1 fix the timeout" → "fix the timeout"). */
|
|
70
|
+
export function wordsAfterLink(text) {
|
|
71
|
+
const rest = String(text ?? '').trimStart().replace(/^<?https?:\/\/\S+>?/, '').trim();
|
|
72
|
+
return rest.replace(/^[-:–—,\s]+/, '').split('\n')[0].trim().slice(0, 200) || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The issue's summary, or null when it can't be had (not a site we contact, no access, offline).
|
|
77
|
+
* `fetchImpl` and `allow` are for tests.
|
|
78
|
+
*/
|
|
79
|
+
export async function issueSummary({ key, site }, { jira = loadJira(), fetchImpl = fetch, timeoutMs = 6000, allow = null } = {}) {
|
|
80
|
+
const host = (() => {
|
|
81
|
+
try {
|
|
82
|
+
return new URL(site).host;
|
|
83
|
+
} catch {
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
87
|
+
const configured = jira.site && new URL(jira.site).host === host;
|
|
88
|
+
const allowed = configured || /\.atlassian\.net$/.test(host) || allow?.(site);
|
|
89
|
+
if (!allowed) return null;
|
|
90
|
+
const headers = { accept: 'application/json' };
|
|
91
|
+
if (configured && jira.email && jira.token) headers.authorization = `Basic ${Buffer.from(`${jira.email}:${jira.token}`).toString('base64')}`;
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetchImpl(`${site}/rest/api/3/issue/${encodeURIComponent(key)}?fields=summary`, { headers, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) });
|
|
94
|
+
if (!res.ok) return null;
|
|
95
|
+
const summary = (await res.json())?.fields?.summary;
|
|
96
|
+
return typeof summary === 'string' && summary.trim() ? summary.trim().slice(0, 300) : null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/manager.js
CHANGED
|
@@ -93,6 +93,7 @@ async function repoLayout(dir) {
|
|
|
93
93
|
import { spawn } from 'node:child_process';
|
|
94
94
|
import * as git from './git.js';
|
|
95
95
|
import { parseDiff } from '../public/review.js';
|
|
96
|
+
import { issueSummary, leadingIssue, wordsAfterLink } from './jira.js';
|
|
96
97
|
import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
|
|
97
98
|
import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
|
|
98
99
|
import { STUCK_DEFAULTS, detectStuck } from './stuck.js';
|
|
@@ -667,9 +668,14 @@ export class AgentManager extends EventEmitter {
|
|
|
667
668
|
};
|
|
668
669
|
for (const pr of refs.prs) agent.prs[pr.url] = { ...pr, source: 'intent' };
|
|
669
670
|
for (const bug of refs.bugs) agent.bugs[bug.key] = { ...bug, source: 'intent' };
|
|
671
|
+
// A request that starts with an issue link: describe it by the issue's summary (fetched below),
|
|
672
|
+
// and until then by the words after the link.
|
|
673
|
+
const issue = leadingIssue(intent);
|
|
674
|
+
if (issue) agent.synopsis = { text: wordsAfterLink(intent), source: 'request', key: issue.key };
|
|
670
675
|
this.store.put(agent);
|
|
671
676
|
this.registerProject(agent);
|
|
672
677
|
this.changed(agent);
|
|
678
|
+
if (issue) this.fetchSynopsis(agent.id).catch(() => {});
|
|
673
679
|
|
|
674
680
|
this.plugins.emit('agent.created', { agent: this.auditView(agent) });
|
|
675
681
|
this.prepare(agent, { start, workspace }).catch(() => {});
|
|
@@ -682,6 +688,32 @@ export class AgentManager extends EventEmitter {
|
|
|
682
688
|
return this.view(agent);
|
|
683
689
|
}
|
|
684
690
|
|
|
691
|
+
/** Fetch the summary of the issue an agent's request starts with; it becomes its description. */
|
|
692
|
+
async fetchSynopsis(ref) {
|
|
693
|
+
const agent = this.store.get(this.store.resolve(ref)?.id ?? ref);
|
|
694
|
+
const issue = agent && leadingIssue(agent.intent);
|
|
695
|
+
if (!issue) return null;
|
|
696
|
+
const summary = await issueSummary(issue, this.jiraOptions ?? {});
|
|
697
|
+
const cur = this.store.get(agent.id);
|
|
698
|
+
if (!summary || !cur) return null;
|
|
699
|
+
const bug = cur.bugs?.[issue.key];
|
|
700
|
+
this.patch(cur.id, {
|
|
701
|
+
synopsis: { text: summary, source: 'jira', key: issue.key },
|
|
702
|
+
...(bug && !bug.title ? { bugs: { ...cur.bugs, [issue.key]: { ...bug, title: summary } } } : {}),
|
|
703
|
+
});
|
|
704
|
+
return summary;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Fill in summaries still missing (after Jira settings change); returns how many it found. */
|
|
708
|
+
async refreshSynopses() {
|
|
709
|
+
let found = 0;
|
|
710
|
+
for (const a of this.store.list()) {
|
|
711
|
+
if (a.synopsis?.source === 'jira' || !leadingIssue(a.intent)) continue;
|
|
712
|
+
if (await this.fetchSynopsis(a.id).catch(() => null)) found++;
|
|
713
|
+
}
|
|
714
|
+
return found;
|
|
715
|
+
}
|
|
716
|
+
|
|
685
717
|
prepare(agent, { start, workspace }) {
|
|
686
718
|
const controller = new AbortController();
|
|
687
719
|
const { signal } = controller;
|
package/src/server.js
CHANGED
|
@@ -33,6 +33,7 @@ import { Previews, parseUrl } from './preview.js';
|
|
|
33
33
|
import { LiveBrowser } from './livebrowser.js';
|
|
34
34
|
import * as remote from './remote.js';
|
|
35
35
|
import * as service from './service.js';
|
|
36
|
+
import { jiraView, saveJira } from './jira.js';
|
|
36
37
|
import { Store } from './store.js';
|
|
37
38
|
import { about, latestRelease } from './about.js';
|
|
38
39
|
|
|
@@ -60,7 +61,9 @@ function isAllowedOrigin(origin, port) {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
const PUBLIC_REMOTE_API = new Set(['POST /api/login', 'GET /api/session']);
|
|
63
|
-
|
|
64
|
+
// Updates are not here: a signed-in device (a phone) can install the newest release too. It only
|
|
65
|
+
// ever installs an official release from where this copy came from (git tag or npm) and restarts.
|
|
66
|
+
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|service$|jira$|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
|
|
64
67
|
const HOOK_PATH = '/api/hooks/agents';
|
|
65
68
|
const NO_REPO_KINDS = new Set(['general', 'assistant']);
|
|
66
69
|
|
|
@@ -508,6 +511,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
508
511
|
api.get('/agents/:id/memory', wrap((req) => memory.read(req.params.id)));
|
|
509
512
|
api.put('/agents/:id/memory', wrap((req) => memory.write(req.params.id, req.body?.text)));
|
|
510
513
|
api.post('/agents/:id/learn', wrap((req) => memory.learn(req.params.id, req.body?.text, { by: req.headers['x-agentmgr-agent'] ? undefined : 'you' })));
|
|
514
|
+
// Jira: the site and API token used to show an issue's summary for agents created from its link.
|
|
515
|
+
api.get('/jira', wrap(() => jiraView()));
|
|
516
|
+
api.put(
|
|
517
|
+
'/jira',
|
|
518
|
+
wrap(async (req) => {
|
|
519
|
+
const view = saveJira(req.body ?? {});
|
|
520
|
+
// Agents already created from links get their summaries now (in the background).
|
|
521
|
+
manager.refreshSynopses().catch(() => {});
|
|
522
|
+
return view;
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
511
525
|
// Start at login: the login service (launchd or systemd) that brings the daemon back after a reboot.
|
|
512
526
|
const serviceSupported = ['darwin', 'linux'].includes(process.platform);
|
|
513
527
|
const serviceView = async () => ({ supported: serviceSupported, ...(serviceSupported ? await service.status().catch(() => ({ installed: false, loaded: false })) : { installed: false, loaded: false }) });
|