ai-agentmanager 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0 (2026-10-31)
4
+
5
+ - **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.
6
+ - **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.
7
+
3
8
  ## 1.6.0 (2026-10-31)
4
9
 
5
10
  - **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
@@ -84,13 +84,13 @@ Runs on macOS and Linux; on Windows, use WSL2. Requires Node 20+, `git`, and the
84
84
 
85
85
  ```sh
86
86
  npm install -g ai-agentmanager # the latest release
87
- npm install -g ai-agentmanager@1.6.0 # or a specific one
87
+ npm install -g ai-agentmanager@1.7.0 # or a specific one
88
88
  ```
89
89
 
90
90
  Or from the repository, at a release tag:
91
91
 
92
92
  ```sh
93
- git clone --branch v1.6.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
93
+ git clone --branch v1.7.0 --depth 1 https://github.com/jdmunta/ai-agentmanager-core.git
94
94
  cd ai-agentmanager-core
95
95
  npm install
96
96
  npm link # optional: puts `agentmgr` on your PATH
@@ -110,7 +110,7 @@ npm link
110
110
  | Installed with | Update |
111
111
  | --- | --- |
112
112
  | npm | `npm install -g ai-agentmanager@latest` |
113
- | A clone at a release tag | `git fetch --tags && git checkout v1.6.0 && npm install` (the tag you want) |
113
+ | A clone at a release tag | `git fetch --tags && git checkout v1.7.0 && npm install` (the tag you want) |
114
114
  | A clone of `main` | `git pull && npm install` |
115
115
 
116
116
  **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 +384,7 @@ Each card has at most **three points of about 12 words each**, so they're easy t
384
384
 
385
385
  - **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
386
  - **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.
387
+ - **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
388
  - **Commit all** commits everything in the worktree, new files included, with your git name and email.
388
389
  - **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
390
 
@@ -909,6 +910,7 @@ Usage comes from the session transcripts and is refreshed every few seconds whil
909
910
  ## Studio: code and terminals
910
911
 
911
912
  - **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.
913
+ - **◫ 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
914
  - **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
915
  - Monaco loads from the jsDelivr CDN the first time you open **Code**. Offline, a plain text editor takes its place.
914
916
  - **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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-agentmanager",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Run background coding agents (Claude Code, Codex) per intent, and keep track of them from a web UI, your phone or the CLI",
5
5
  "keywords": [
6
6
  "claude",
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
- const codeUi = initCode({ api, esc, toast, $, agents, isOpen: () => term.kind === 'code' });
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');
@@ -1899,6 +1901,7 @@ const term = {
1899
1901
  const a = this.agentId && agents.get(this.agentId);
1900
1902
  // The agent's own session tab is named after its engine (Codex, Muse Code, …).
1901
1903
  $('.term-tab[data-kind="claude"]').textContent = ENGINE_LABEL[a?.engine] ?? 'Claude';
1904
+ sidePane?.follow(this.agentId);
1902
1905
  const nums = new Set(a?.runtime.shells ?? []);
1903
1906
  if (isShell(this.kind)) nums.add(shellNum(this.kind));
1904
1907
  $('#shell-tabs').innerHTML = [...nums]
@@ -1930,6 +1933,7 @@ const term = {
1930
1933
  $('#term-wrap').hidden = false;
1931
1934
  $('#detail').classList.add('with-term');
1932
1935
  $('#term-wrap').classList.remove('browsing');
1936
+ sidePane?.mainShowsCode();
1933
1937
  $('#term-wrap').classList.add('coding');
1934
1938
  this.ensure();
1935
1939
  this.disconnect();
@@ -2095,6 +2099,20 @@ $('#term-fit').onclick = () => term.refit();
2095
2099
 
2096
2100
  // ------------------------------------------------------------------ resizing, expanding, full screen
2097
2101
 
2102
+ sidePane = initSide({
2103
+ api,
2104
+ esc,
2105
+ toast,
2106
+ $,
2107
+ agents,
2108
+ codeUi,
2109
+ docHtml,
2110
+ renderDiagrams,
2111
+ mainKind: () => term.kind,
2112
+ setMain: (kind) => term.agentId && term.open(term.agentId, kind),
2113
+ refit: () => requestAnimationFrame(() => term.refit()),
2114
+ });
2115
+
2098
2116
  const layoutPref = {
2099
2117
  get(key) {
2100
2118
  try {
@@ -2114,6 +2132,8 @@ function applyLayout() {
2114
2132
  const side = layoutPref.get('sideWidth');
2115
2133
  const brief = layoutPref.get('briefHeight');
2116
2134
  document.documentElement.style.setProperty('--side-w', side ? `${side}px` : '340px');
2135
+ const pane = layoutPref.get('paneWidth');
2136
+ document.documentElement.style.setProperty('--pane-w', pane ? `${pane}%` : '45%');
2117
2137
  if (brief) document.documentElement.style.setProperty('--brief-h', `${brief}px`);
2118
2138
  else document.documentElement.style.removeProperty('--brief-h');
2119
2139
  $('#detail').classList.toggle('brief-sized', Boolean(brief));
@@ -2156,6 +2176,14 @@ dragHandle($('#split-side'), 'cols', (ev) => {
2156
2176
  return w;
2157
2177
  }, 'sideWidth');
2158
2178
 
2179
+ // The split view's divider: the right pane's share of the panel, in percent.
2180
+ dragHandle($('#split-pane'), 'cols', (ev) => {
2181
+ const r = $('#term-split').getBoundingClientRect();
2182
+ const pct = Math.min(Math.max(((r.right - ev.clientX) / r.width) * 100, 20), 75);
2183
+ layoutPref.set('paneWidth', Math.round(pct));
2184
+ return pct;
2185
+ }, 'paneWidth');
2186
+
2159
2187
  dragHandle($('#split-term'), 'rows', (ev) => {
2160
2188
  const top = $('#detail').getBoundingClientRect().top;
2161
2189
  const h = Math.min(Math.max(ev.clientY - top, 60), $('#detail').clientHeight - 160);
@@ -2863,7 +2891,14 @@ $('#term-file').onchange = (e) => {
2863
2891
  function upsert(a) {
2864
2892
  agents.set(a.id, a);
2865
2893
  render();
2894
+ agentChanged(a);
2895
+ }
2896
+
2897
+ /** Views that show one agent's details outside the main render: keep them current. */
2898
+ function agentChanged(a) {
2866
2899
  if (a.id === term.agentId) term.renderTabs();
2900
+ reviewUi.agentChanged(a);
2901
+ sidePane?.agentChanged(a);
2867
2902
  }
2868
2903
 
2869
2904
  $('#counts').addEventListener('click', (e) => {
@@ -3498,7 +3533,7 @@ function handleEvent(msg) {
3498
3533
  } else if (msg.t === 'agent') {
3499
3534
  agents.set(msg.agent.id, msg.agent);
3500
3535
  watchDocs(msg.agent);
3501
- if (msg.agent.id === term.agentId) term.renderTabs();
3536
+ agentChanged(msg.agent);
3502
3537
  } else if (msg.t === 'removed') {
3503
3538
  agents.delete(msg.id);
3504
3539
  if (selectedId === msg.id) select(null);
package/public/index.html CHANGED
@@ -96,41 +96,59 @@
96
96
  <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
97
  <input type="file" id="term-file" multiple hidden>
98
98
  <button id="term-fit" class="ghost small" title="Refit the terminal to the panel">Fit</button>
99
+ <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
100
  <button id="term-max" class="ghost small desk-only" title="Expand to fill the window (Ctrl+Shift+E)">⤢ Expand</button>
100
101
  <button id="term-full" class="ghost small desk-only" title="Full screen (Esc to leave)">⛶ Full screen</button>
101
102
  <button id="term-close" class="ghost small" title="Hide the terminal (the agent keeps running)">Hide terminal</button>
102
103
  </div>
103
104
  <div id="signin-bar" class="signin-bar" hidden></div>
104
- <div id="terminal" class="terminal"></div>
105
- <div id="code" class="code"></div>
106
- <div id="browser" class="browser">
107
- <form id="browser-bar" class="browser-bar" autocomplete="off">
108
- <button type="button" id="browser-back" class="ghost small live-only" title="Back">◀</button>
109
- <button type="button" id="browser-fwd" class="ghost small live-only" title="Forward">▶</button>
110
- <button type="button" id="browser-reload" class="ghost small" title="Reload the page">⟳</button>
111
- <input id="browser-url" placeholder="localhost:5173, a port, or any address" spellcheck="false" autocapitalize="off" autocorrect="off" enterkeyhint="go">
112
- <button class="small">Go</button>
113
- <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>
114
- <button type="button" id="browser-keys" class="ghost small live-only touch-only" title="Show the keyboard to type into the page">⌨</button>
115
- <button type="button" id="browser-mode" class="ghost small" hidden></button>
116
- <a id="browser-pop" class="ghost small button" target="_blank" rel="noopener" title="Open in a new browser tab on this device" hidden>↗</a>
117
- </form>
118
- <div id="browser-hint" class="browser-hint" hidden>Blank? Many sites refuse to show inside another page. Open it with ↗ instead.</div>
119
- <div id="browser-cert" class="browser-start" hidden>
120
- <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>
121
- <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>
122
- <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>
105
+ <div class="term-split" id="term-split">
106
+ <div class="term-main" id="term-main">
107
+ <div id="terminal" class="terminal"></div>
108
+ <div id="code" class="code"></div>
109
+ <div id="browser" class="browser">
110
+ <form id="browser-bar" class="browser-bar" autocomplete="off">
111
+ <button type="button" id="browser-back" class="ghost small live-only" title="Back">◀</button>
112
+ <button type="button" id="browser-fwd" class="ghost small live-only" title="Forward">▶</button>
113
+ <button type="button" id="browser-reload" class="ghost small" title="Reload the page">⟳</button>
114
+ <input id="browser-url" placeholder="localhost:5173, a port, or any address" spellcheck="false" autocapitalize="off" autocorrect="off" enterkeyhint="go">
115
+ <button class="small">Go</button>
116
+ <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>
117
+ <button type="button" id="browser-keys" class="ghost small live-only touch-only" title="Show the keyboard to type into the page">⌨</button>
118
+ <button type="button" id="browser-mode" class="ghost small" hidden></button>
119
+ <a id="browser-pop" class="ghost small button" target="_blank" rel="noopener" title="Open in a new browser tab on this device" hidden>↗</a>
120
+ </form>
121
+ <div id="browser-hint" class="browser-hint" hidden>Blank? Many sites refuse to show inside another page. Open it with ↗ instead.</div>
122
+ <div id="browser-cert" class="browser-start" hidden>
123
+ <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>
124
+ <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>
125
+ <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>
126
+ </div>
127
+ <div id="browser-start" class="browser-start" hidden>
128
+ <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>
129
+ <div id="browser-suggest"></div>
130
+ </div>
131
+ <div id="browser-frames" class="browser-frames"></div>
132
+ <div id="browser-live" class="browser-live" hidden>
133
+ <img id="browser-live-img" alt="" draggable="false">
134
+ <textarea id="browser-live-keys" class="browser-live-keys" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Type into the page"></textarea>
135
+ <div id="browser-live-msg" class="browser-live-msg" hidden></div>
136
+ </div>
123
137
  </div>
124
- <div id="browser-start" class="browser-start" hidden>
125
- <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>
126
- <div id="browser-suggest"></div>
127
- </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
138
  </div>
139
+ <div class="splitter split-pane" id="split-pane" role="separator" aria-orientation="vertical" title="Drag to resize; double-click to reset" hidden></div>
140
+ <aside class="side-pane" id="side-pane" hidden>
141
+ <div class="side-bar">
142
+ <div class="term-tabs" role="tablist">
143
+ <button class="term-tab" data-side="code" role="tab" title="The agent's files">Code</button>
144
+ <button class="term-tab" data-side="docs" role="tab" title="Diagrams and the other documents">Docs</button>
145
+ <button class="term-tab" data-side="changes" role="tab" title="Its changes, explained one by one">Changes</button>
146
+ </div>
147
+ <span class="spacer"></span>
148
+ <button type="button" class="ghost small" id="side-close" title="Close the split">✕</button>
149
+ </div>
150
+ <div class="side-body" id="side-body"></div>
151
+ </aside>
134
152
  </div>
135
153
  <div id="term-drop" class="term-drop" hidden>Drop files to attach</div>
136
154
  <div class="keybar" id="keybar" aria-label="Terminal keys">
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
- return { open, get openId() { return dlg.open ? st.id : null; } };
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
@@ -837,3 +837,39 @@ input.inline { display: inline-block; width: 80px; padding: 1px 4px; }
837
837
  .attach-chip.fail { border-color: var(--red); }
838
838
  .attach-chip .link { padding: 0 4px; }
839
839
  form.dropping { outline: 2px dashed var(--accent, #6b8cff); outline-offset: 4px; }
840
+
841
+ /* Walkthrough: an explanation card above each hunk; the current one is marked. */
842
+ .wt-card { display: flex; flex-direction: column; gap: 3px; padding: 8px 10px; font: 13px/1.45 var(--sans, system-ui); white-space: normal; color: var(--text); }
843
+ .wt-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
844
+ .wt-n { font: 11px var(--mono, ui-monospace); color: var(--muted); }
845
+ .wt-k { display: inline-block; min-width: 44px; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
846
+ .rv-explain td { background: var(--panel); border-top: 1px solid var(--border); }
847
+ .rv-explain.current td { box-shadow: inset 3px 0 0 var(--accent, #6b8cff); background: var(--panel-2); }
848
+ .rv-walk { display: inline-flex; align-items: center; gap: 4px; }
849
+ .rv-walk-note { margin: 4px 12px 8px; }
850
+
851
+ /* Split view: the terminal (or a browser tab, or Code) on the left, a pane on the right. */
852
+ .term-split { flex: 1; display: flex; min-height: 0; min-width: 0; }
853
+ .term-main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
854
+ .split-pane { flex: none; width: 0; }
855
+ .split-pane::after { top: 0; bottom: 0; left: -4px; width: 8px; cursor: col-resize; }
856
+ .split-pane[hidden], .side-pane[hidden] { display: none !important; }
857
+ .side-pane { flex: none; width: var(--pane-w, 45%); display: flex; flex-direction: column; min-height: 0; border-left: 1px solid #30363d; background: var(--bg); color: var(--text); }
858
+ .side-bar { display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: #161a22; color: #c9d1d9; font-size: 12.5px; }
859
+ .side-bar button { color: #c9d1d9; border-color: #30363d; }
860
+ .side-body { flex: 1; display: flex; flex-direction: column; min-height: 0; }
861
+ .side-pane .code { display: flex; flex: 1; }
862
+ .side-tools { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); }
863
+ .side-tools select { width: auto; }
864
+ .side-scroll { flex: 1; overflow: auto; padding: 10px 12px; }
865
+ .side-pad { padding: 10px 12px; }
866
+ .side-scroll .diagrams-view .diagram, .side-scroll .diagram { display: flex; justify-content: center; overflow-x: auto; }
867
+ .side-scroll .diagram svg { max-width: 100%; height: auto; }
868
+ .sd-step { margin-bottom: 14px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
869
+ .sd-file { padding: 4px 10px; background: var(--panel-2); font-size: 12px; }
870
+ .sd-hunk { font: 12px/1.45 var(--mono, ui-monospace, monospace); white-space: pre; overflow-x: auto; border-top: 1px solid var(--border); }
871
+ .sd-hunk > div { padding: 0 10px; }
872
+ .sd-head { color: var(--muted); background: var(--panel); }
873
+ .sd-add { background: rgba(46, 160, 67, .15); }
874
+ .sd-del { background: rgba(248, 81, 73, .15); }
875
+ #term-split-btn.on { background: #262c38; }
@@ -0,0 +1,36 @@
1
+ // An agent's changes explained one by one (the "walkthrough" document): shared by the review
2
+ // screen, which shows each explanation above its hunk, and the Changes pane of the split view.
3
+
4
+ const RISK = { high: 'fail', medium: '', low: 'open' };
5
+
6
+ /** The walkthrough of an agent and whether it still matches the changes (diffHash from /changes). */
7
+ export function walkthroughOf(agent, diffHash) {
8
+ const w = agent?.docs?.walkthrough;
9
+ return {
10
+ doc: w?.data ? w : null,
11
+ pending: Boolean(w?.pending),
12
+ error: w?.error ?? null,
13
+ stale: Boolean(w?.data && diffHash && w.diffHash !== diffHash),
14
+ };
15
+ }
16
+
17
+ /** Index of the explanation for a hunk (file + header), or -1. */
18
+ export const stepOf = (doc, file, head) => doc?.data.changes.findIndex((c) => c.file === file && c.head === head) ?? -1;
19
+
20
+ /** One explanation: title, risk, what / why / check. */
21
+ export function cardHtml(c, i, total, esc) {
22
+ if (c.unexplained) return `<div class="wt-card"><div class="wt-head"><span class="wt-n">${i + 1}/${total}</span><span class="hint">Not explained (the model skipped it)</span></div></div>`;
23
+ return `<div class="wt-card">
24
+ <div class="wt-head"><span class="wt-n">${i + 1}/${total}</span><b>${esc(c.title)}</b><span class="tag ${RISK[c.risk] ?? ''}">${esc(c.risk)} risk</span></div>
25
+ ${c.what ? `<div><span class="wt-k">What</span> ${esc(c.what)}</div>` : ''}
26
+ ${c.why ? `<div><span class="wt-k">Why</span> ${esc(c.why)}</div>` : ''}
27
+ ${c.check ? `<div><span class="wt-k">Check</span> ${esc(c.check)}</div>` : ''}
28
+ </div>`;
29
+ }
30
+
31
+ /** The button that writes (or rewrites) the walkthrough, with its state. */
32
+ export function explainButton(w, attrs = 'data-wt-explain') {
33
+ const label = w.pending ? 'Explaining…' : w.doc ? (w.stale ? 'Explain again (changed)' : 'Explain again') : '✨ Explain changes';
34
+ return `<button type="button" class="small ${w.doc && !w.stale ? 'ghost' : 'primary'}" ${attrs} ${w.pending ? 'disabled' : ''}
35
+ title="Explain each change one by one: what, why, the risk and what to check, in the order to read them (a separate one-off call; the agent isn't interrupted)">${label}</button>`;
36
+ }