micro-models-agent 2.12.0 → 2.13.1

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
@@ -4,6 +4,35 @@ All notable changes to Micro Models Agent (MMA) will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [2.13.1] - 2026-09-30
10
+
11
+ ### Fixed
12
+
13
+ - **In-loop critic is now selective instead of always-on** (`src/core/agent/critic-gate.ts`, `loop-state.ts`, `src/core/agent.ts`): evidence is scoped to the current run (`LoopState.criticHistoryMark` + `gatherTurnToolEvents(cm, mark)`), so tool calls from previous questions no longer testify against the current task (observed: weather lookups from an earlier turn caused 4 false `critical` verdicts on a chat-only "show a React example" answer). Chat-only turns (no tool events in the run) now `pass` without an LLM call and without spending `maxCalls`; the dead mid-turn `mutationsSinceCritic` trigger branch is removed.
14
+ - **Advice and repeat verdicts no longer retry** (`src/core/agent/critic-gate.ts`, `src/i18n/en.json`, `ru.json`): only a fresh `critical` inside `maxRepairCycles` repairs + retries; `concern`, over-budget `critical` and near-identical repeats inject `<system-summary>` (`critic.advice`, new `critic.repeat` — "change approach or finish honestly") and `pass`. Previously every non-ok verdict retried identically until `maxCalls`, looping the same answer 4 times.
15
+ - **Critic sees chat-delivered answers** (`src/modules/critic/types.ts`, `evidence.ts`, `client.ts`, `src/core/agent.ts`): `EvidencePack.answerExcerpt` (≤2000 chars, secrets redacted) carries the final text for mutation-free turns, and the critic prompt states that a chat answer without files is not itself an issue. Previously the critic judged chat tasks blind (tools only) and demanded file artifacts the user never asked for.
16
+ - **Honest critic evidence bits** (`src/modules/critic/evidence.ts`, `provider.ts`): successful bash no longer gets a synthesized `exitCode: 0` (`undefined` renders as `?`; `1` stays for known failures); setting `critic.provider` without `critic.model` now warns instead of silently doing nothing.
17
+ - **Method calls no longer flagged as missing files** (`src/modules/hallucination/js-identifiers.ts`): `extractFileLikeTokens` drops tokens glued to `(` (`prev.map(`, `todos.map(` — `map` is a sourcemap extension), fixing false "mentioned files not found" warnings on code answers. Applies to all consumers (factual check, execution auditor, verifier).
18
+ - **Rejected drafts no longer duplicate in the feed** (`src/core/agent/artifact-resolve.ts`, `hallucination-gate.ts`, `critic-gate.ts`, `audit-gate.ts`, `src/core/types.ts`, `src/modules/webui/forward.ts`, `assets/js/feed.js`, `app.js`): a gate-rejected draft was already streamed before the verdict, so the re-answer stacked as a visible double (observed: artifact-claim false positive on bare `game.ts` vs `src/game.ts`). Gates now emit `onMeta(…, "retract")` (new `MetaKind`, user line `ui.draft_retracted` en+ru); the web forwarder sends a dedicated SSE `retract` event and `feed.js onRetract` removes the rejected block (also from turn copy buffers) with a one-line note, the terminal prints the same line. Rejections are logged as `claim_retry` session entries (`SessionLogger.logClaimRetry`, new `SessionLogEntry.kind`), closing the `llm_usage`-without-`assistant` gap; critic verify exceptions now log a fail-open diagnostic instead of a silent lone `critic_check`.
19
+ - **Artifact ground-check resolves bare basenames** (`src/core/agent/artifact-resolve.ts`): `resolveOneArtifact` goes through the auditor's `findExistingFile` (exact path, then basename/suffix subtree walk in the `baseDir` jail) instead of a strict `resolve`, so `game.ts` matches `src/game.ts` and no longer triggers a false claim-retry.
20
+
21
+ ## [2.13.0] - 2026-09-30
22
+
23
+ ### Added
24
+
25
+ - **Web UI**: sidebar tabs — Session / Map / Trace. Square icon tabs with the active
26
+ tab name shown below them (`assets/js/sidebar.js`, `index.html`, `styles.css`).
27
+ - **Map tab**: project-map index state — status, last-updated timestamp, watcher,
28
+ files/symbols/tokens, root, per-language breakdown, and a Re-index button
29
+ (`POST /api/index`). `IndexerModule.mapInfo()` + `ProjectMapInfo` host contract.
30
+ - **Trace tab**: live per-session log of tool calls, phases, plan updates, errors,
31
+ questions and the answer text stream; clicking a row opens a detail panel (full
32
+ args, output, metadata) and doubles the sidebar width. Desktop only (hidden ≤760px).
33
+ - **i18n**: `web.sidebar.tab_*`, `web.sidebar.map_*`, `web.sidebar.trace_*`,
34
+ `web.sidebar.detail_*` (en + ru).
35
+
7
36
  ## [2.12.0] - 2026-09-29
8
37
 
9
38
  ### Added
@@ -5,6 +5,7 @@ import { S, hooks } from "./js/store.js";
5
5
  import {
6
6
  onText, onReasoning, onMeta, onPhase, onToolStart, onToolEnd,
7
7
  onTurnEnd, onErrorEvent, onCommandOutput, onLog, onPlan, onQuestion,
8
+ onRetract,
8
9
  showEmptyFeed, renderPlan, renderUsage, renderEnvironment, renderWorkspace,
9
10
  feedHasContent, loadHistory,
10
11
  } from "./js/feed.js";
@@ -19,6 +20,11 @@ loadMemory, loadSkills, loadPlugins, applyTheme, currentTheme, applyFontSize, se
19
20
  import {
20
21
  initRail, setPlan as railPlan, railEvent, applyRailI18n,
21
22
  } from "./js/rail.js";
23
+ import {
24
+ initSidebar, sidebarSessionChanged, sidebarMapState,
25
+ sidebarTraceEvent, sidebarTurnEnd, sidebarBusy, sidebarMapIndicator,
26
+ applySidebarI18n,
27
+ } from "./js/sidebar.js";
22
28
 
23
29
  document.querySelectorAll("[data-collapse]").forEach((head) => {
24
30
  const initArrow = head.querySelector(".arrow");
@@ -77,6 +83,9 @@ function renderState(snapshot, busy) {
77
83
  renderSelectors();
78
84
  }
79
85
  renderEnvironment(S.state.host);
86
+ sidebarMapState(S.state.host);
87
+ sidebarMapIndicator(S.state.host);
88
+ sidebarSessionChanged(sessionId);
80
89
  renderWorkspace(S.state.host);
81
90
  renderSessionsDropdown();
82
91
  }
@@ -131,8 +140,9 @@ const handlers = [
131
140
  ["tool_end", onToolEnd],
132
141
  ["plan", onPlan],
133
142
  ["question", onQuestion],
134
- ["turn_end", onTurnEnd],
143
+ ["turn_end", (d) => { onTurnEnd(d); sidebarTurnEnd(); sidebarBusy(false); }],
135
144
  ["log", onLog],
145
+ ["retract", (d) => onRetract(d.reason || "", d.sessionId)],
136
146
  ];
137
147
  for (const [event, fn] of handlers) {
138
148
  es.addEventListener(event, (e) => {
@@ -149,6 +159,11 @@ for (const ev of ["tool_start", "tool_end", "text", "plan"]) {
149
159
  else railEvent(ev, d.name);
150
160
  });
151
161
  }
162
+ // Sidebar Trace: live per-session event log. Feed handlers stay untouched.
163
+ for (const ev of ["tool_start", "tool_end", "phase", "plan", "question"]) {
164
+ es.addEventListener(ev, (e) => { const d = parse(e); if (d) sidebarTraceEvent(ev, d); });
165
+ }
166
+ es.addEventListener("text", (e) => { const d = parse(e); if (d) sidebarTraceEvent("text", d); });
152
167
  es.addEventListener("command", (e) => {
153
168
  const d = parse(e);
154
169
  if (d && typeof d.text === "string") onCommandOutput(d.text, d.sessionId);
@@ -158,6 +173,8 @@ es.addEventListener("command", (e) => {
158
173
  if (d) {
159
174
  S.state.busy = !!d.busy;
160
175
  reflectBusy();
176
+ sidebarBusy(!!d.busy);
177
+ refresh();
161
178
  if (d.usage && typeof d.usage === "object") {
162
179
  S.state.host = Object.assign({}, S.state.host, d.usage);
163
180
  renderUsage(S.state.host);
@@ -167,7 +184,7 @@ es.addEventListener("command", (e) => {
167
184
  es.addEventListener("error", (e) => {
168
185
  if (!e.data) return;
169
186
  const d = parse(e);
170
- if (d) onErrorEvent(d);
187
+ if (d) { onErrorEvent(d); sidebarTraceEvent("error", d); }
171
188
  });
172
189
  }
173
190
 
@@ -239,6 +256,7 @@ function applyStaticI18n() {
239
256
  renderSelectors();
240
257
  renderSessionsDropdown();
241
258
  applyRailI18n();
259
+ applySidebarI18n();
242
260
  if (msgs.querySelector(".empty")) showEmptyFeed();
243
261
  if (connStatus && !connStatus.hidden) setConnStatus(true);
244
262
  }
@@ -265,6 +283,8 @@ async function boot() {
265
283
  renderSettings();
266
284
  await loadConfig();
267
285
  initRail(S.state.config && S.state.config.ui ? S.state.config.ui.activityRail : undefined);
286
+ initSidebar();
287
+ applySidebarI18n();
268
288
  await loadModels();
269
289
  await loadProviders();
270
290
  await loadSessions();
@@ -96,19 +96,49 @@
96
96
  </div>
97
97
 
98
98
  <div class="sb">
99
- <div class="sec plan-sec">
100
- <button class="sec-h" type="button" data-collapse aria-expanded="true"><span class="arrow">▾</span> <span id="planHeaderLabel">plan</span> <span class="n" id="planCount"></span></button>
101
- <div class="sec-body" id="plan"></div>
99
+ <div class="sb-tabs" role="tablist" aria-label="sidebar">
100
+ <button class="sb-tab on" type="button" role="tab" data-tab="session" aria-selected="true" aria-controls="pane-session" title="Session">
101
+ <span class="sb-ico" data-icon="session" aria-hidden="true"></span>
102
+ <span class="sb-dot" aria-hidden="true"></span>
103
+ </button>
104
+ <button class="sb-tab" type="button" role="tab" data-tab="map" aria-selected="false" aria-controls="pane-map" title="Map">
105
+ <span class="sb-ico" data-icon="map" aria-hidden="true"></span>
106
+ <span class="sb-dot" aria-hidden="true"></span>
107
+ </button>
108
+ <button class="sb-tab" type="button" role="tab" data-tab="trace" aria-selected="false" aria-controls="pane-trace" title="Trace">
109
+ <span class="sb-ico" data-icon="trace" aria-hidden="true"></span>
110
+ <span class="sb-dot" aria-hidden="true"></span>
111
+ </button>
102
112
  </div>
113
+ <div class="sb-name" id="sbName"><span class="sb-ico" data-icon="session" aria-hidden="true"></span><span id="sbNameText">Session</span></div>
103
114
 
104
- <div class="sec usage-sec">
105
- <button class="sec-h" type="button" data-collapse aria-expanded="true"><span class="arrow">▾</span> <span id="usageHeaderLabel">session · usage</span></button>
106
- <div class="sec-body" id="usage"></div>
115
+ <div class="sb-pane" id="pane-session" role="tabpanel" data-pane="session">
116
+ <div class="sec plan-sec">
117
+ <button class="sec-h" type="button" data-collapse aria-expanded="true"><span class="arrow">▾</span> <span id="planHeaderLabel">plan</span> <span class="n" id="planCount"></span></button>
118
+ <div class="sec-body" id="plan"></div>
119
+ </div>
120
+ <div class="sec usage-sec">
121
+ <button class="sec-h" type="button" data-collapse aria-expanded="true"><span class="arrow">▾</span> <span id="usageHeaderLabel">session · usage</span></button>
122
+ <div class="sec-body" id="usage"></div>
123
+ </div>
124
+ <div class="sec env-sec">
125
+ <button class="sec-h" type="button" data-collapse aria-expanded="true"><span class="arrow">▾</span> <span id="envHeaderLabel">environment</span></button>
126
+ <div class="sec-body" id="env"></div>
127
+ </div>
107
128
  </div>
108
129
 
109
- <div class="sec env-sec">
110
- <button class="sec-h" type="button" data-collapse aria-expanded="true"><span class="arrow">▾</span> <span id="envHeaderLabel">environment</span></button>
111
- <div class="sec-body" id="env"></div>
130
+ <div class="sb-pane" id="pane-map" role="tabpanel" data-pane="map" hidden>
131
+ <div class="sec map-sec">
132
+ <div class="sec-body" id="mapBody"></div>
133
+ </div>
134
+ </div>
135
+
136
+ <div class="sb-pane" id="pane-trace" role="tabpanel" data-pane="trace" hidden>
137
+ <div class="trace-list" id="traceList"></div>
138
+ <div class="trc-detail" id="traceDetail" hidden>
139
+ <div class="detail-h"><span class="g" id="detailGlyph">◇</span><span id="detailTitle"></span><button class="x linkbtn" type="button" id="detailClose">✕</button></div>
140
+ <div class="detail-b" id="detailBody"></div>
141
+ </div>
112
142
  </div>
113
143
  </div>
114
144
  </div>
@@ -74,6 +74,21 @@ search: [
74
74
  ["circle", { cx: 11, cy: 11, r: 8 }],
75
75
  ["path", { d: "m21 21-4.3-4.3" }],
76
76
  ],
77
+ "list-checks": [
78
+ ["path", { d: "m3 17 2 2 4-4" }],
79
+ ["path", { d: "m3 7 2 2 4-4" }],
80
+ ["path", { d: "M13 6h8" }],
81
+ ["path", { d: "M13 12h8" }],
82
+ ["path", { d: "M13 18h8" }],
83
+ ],
84
+ map: [
85
+ ["path", { d: "M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z" }],
86
+ ["path", { d: "M15 5.764v15" }],
87
+ ["path", { d: "M9 3.236v15" }],
88
+ ],
89
+ activity: [
90
+ ["path", { d: "M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" }],
91
+ ],
77
92
  "arrow-up": [["path", { d: "M12 19V5" }], ["path", { d: "m5 12 7-7 7 7" }]],
78
93
  panel: [
79
94
  ["rect", { width: 18, height: 18, x: 3, y: 3, rx: 2, ry: 2 }],
@@ -36,12 +36,16 @@ export function makeFeed() {
36
36
 
37
37
  export function showEmptyFeed() {
38
38
  msgs.replaceChildren(emptyView());
39
+ follow = true;
40
+ hideToLatest();
39
41
  syncUrl();
40
42
  }
41
43
 
42
44
  export function clearFeed(feed) {
43
45
  if (!feed) return;
44
46
  feed.replaceChildren(emptyView());
47
+ follow = true;
48
+ hideToLatest();
45
49
  const tab = S.tabs.find((x) => x.feed === feed);
46
50
  if (tab) tab.planCard = null;
47
51
  if (feed === activeFeed()) scroll();
@@ -71,10 +75,80 @@ function nearBottom() {
71
75
  return msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 140;
72
76
  }
73
77
 
74
- function scroll() {
75
- if (nearBottom()) msgs.scrollTop = msgs.scrollHeight;
78
+ // Sticky follow: while the reader is at the live edge we pin to the bottom
79
+ // on every append; the moment they scroll up we leave them alone (the
80
+ // "jump to latest" pill offers the trip back). A new user turn always
81
+ // force-scrolls — otherwise one missed pin cascades into "no autoscroll"
82
+ // for the whole turn, since every later `scroll()` would see `nearBottom()`
83
+ // as false and skip.
84
+ let follow = true;
85
+
86
+ function scroll(force) {
87
+ if (force || follow || nearBottom()) {
88
+ msgs.scrollTop = msgs.scrollHeight;
89
+ follow = true;
90
+ hideToLatest();
91
+ } else {
92
+ maybeShowToLatest();
93
+ }
94
+ }
95
+
96
+ // Force-pin to the live edge (send / history load / pill click).
97
+ export function scrollToBottom(smooth) {
98
+ follow = true;
99
+ hideToLatest();
100
+ if (smooth && typeof msgs.scrollTo === "function") {
101
+ try {
102
+ msgs.scrollTo({ top: msgs.scrollHeight, behavior: "smooth" });
103
+ return;
104
+ } catch (err) {
105
+ /* fall through to instant */
106
+ }
107
+ }
108
+ msgs.scrollTop = msgs.scrollHeight;
109
+ }
110
+
111
+ export function isFollowing() {
112
+ return follow;
113
+ }
114
+
115
+ // Floating "jump to latest" pill: appears only when the reader scrolled up
116
+ // and new content landed below. Created lazily so tests/SSR without `.chat`
117
+ // never break.
118
+ let toLatestBtn = null;
119
+ function ensureToLatest() {
120
+ if (toLatestBtn) return toLatestBtn;
121
+ const chat = document.querySelector(".chat");
122
+ if (!chat) return null;
123
+ toLatestBtn = el("button", "to-latest");
124
+ toLatestBtn.type = "button";
125
+ toLatestBtn.hidden = true;
126
+ toLatestBtn.addEventListener("click", () => scrollToBottom(true));
127
+ chat.appendChild(toLatestBtn);
128
+ return toLatestBtn;
129
+ }
130
+
131
+ function maybeShowToLatest() {
132
+ const btn = ensureToLatest();
133
+ if (!btn) return;
134
+ btn.textContent = "↓ " + t("web.scroll.latest");
135
+ btn.hidden = false;
136
+ msgs.classList.add("unfollowed");
137
+ }
138
+
139
+ function hideToLatest() {
140
+ if (toLatestBtn) toLatestBtn.hidden = true;
141
+ msgs.classList.remove("unfollowed");
76
142
  }
77
143
 
144
+ msgs.addEventListener("scroll", () => {
145
+ const nb = nearBottom();
146
+ follow = nb;
147
+ msgs.classList.toggle("scrolled", msgs.scrollTop > 24);
148
+ if (nb) hideToLatest();
149
+ else if (liveFeed() && feedHasContent(liveFeed())) maybeShowToLatest();
150
+ });
151
+
78
152
  export function appendTo(feed, node) {
79
153
  if (!feed) return node;
80
154
  feed.appendChild(node);
@@ -109,6 +183,9 @@ function clearBusy(feed) {
109
183
  export function showBusy(feed) {
110
184
  feed = feed || liveFeed();
111
185
  if (!feed) return;
186
+ // The delayed post-send call must not flash a spinner over a stream that
187
+ // already started (fast first token).
188
+ if (S.curMd || S.curThink) return;
112
189
  clearEmpty(feed);
113
190
  if (feed.querySelector(".busy")) return;
114
191
  if (S.busyTimer) {
@@ -142,7 +219,10 @@ function flushRun() {
142
219
  // replacing the per-action lines (mirrors `Renderer.flushRecon`). `normal`
143
220
  // and `verbose` keep the individual action lines and add nothing.
144
221
  if (verbosity() === "quiet" && S.runReconOnly && S.runFails === 0 && S.runCount > 1) {
145
- S.runNodes.forEach((node) => node.remove());
222
+ // Fade the folded lines out instead of snapping them away.
223
+ const folding = S.runNodes.slice();
224
+ folding.forEach((node) => node.classList.add("fold-out"));
225
+ window.setTimeout(() => folding.forEach((node) => node.remove()), 160);
146
226
  const line = el("div", "grouped");
147
227
  line.appendChild(el("span", "pipe", "│"));
148
228
  const dur = formatMs(S.runDuration);
@@ -177,6 +257,7 @@ function mdOpts() {
177
257
  // Close the active Markdown segment (if any) and clear the text S.state.
178
258
  export function finishMd() {
179
259
  if (S.curMd) {
260
+ S.curMd.root.classList.remove("streaming");
180
261
  S.curMd.end();
181
262
  S.curMd = null;
182
263
  }
@@ -195,6 +276,7 @@ export function onText(chunk, sid) {
195
276
  flushRun();
196
277
  S.curText = "";
197
278
  S.curMd = mountMarkdown(feed, mdOpts());
279
+ S.curMd.root.classList.add("streaming");
198
280
  S.turnRoots.push(S.curMd.root);
199
281
  }
200
282
  S.curText += chunk;
@@ -204,17 +286,21 @@ export function onText(chunk, sid) {
204
286
  }
205
287
 
206
288
  export function finishThink() {
207
- if (S.curThink && S.curThink.startedAt) {
208
- const dur = S.curThink.head.querySelector(".dur");
209
- if (dur) dur.textContent = "· " + ((Date.now() - S.curThink.startedAt) / 1000).toFixed(1) + "s";
289
+ if (S.curThink) {
290
+ if (S.curThink.wrap) S.curThink.wrap.classList.remove("live");
291
+ if (S.curThink.startedAt) {
292
+ const dur = S.curThink.head.querySelector(".dur");
293
+ if (dur) dur.textContent = "· " + ((Date.now() - S.curThink.startedAt) / 1000).toFixed(1) + "s";
294
+ }
210
295
  }
211
296
  S.curThink = null;
212
297
  }
213
298
 
214
299
  // Build the collapsible reasoning block. The global `.think-h` click handler
215
300
  // depends on this exact structure: wrap > [head, body], body carries `hidden`.
216
- function buildThinkBlock(withDur) {
217
- const wrap = el("div", "think");
301
+ // `live` marks an in-progress block (pulsing label); history renders static.
302
+ function buildThinkBlock(withDur, live) {
303
+ const wrap = el("div", "think" + (live === false ? "" : " live"));
218
304
  const head = el("button", "think-h");
219
305
  head.type = "button";
220
306
  head.setAttribute("aria-expanded", "false");
@@ -264,6 +350,31 @@ export function onMeta(chunk, sid, kind) {
264
350
  if (feed === activeFeed()) scroll();
265
351
  }
266
352
 
353
+ // Гейт забраковал уже отстримленный черновик (SSE `retract`): убираем его
354
+ // из ленты до переответа, иначе финал встанет дублем следом
355
+ // (ses_munvrcfz_na9ze3). Копипаст хода тоже чистим — отклонённого текста
356
+ // в «copy / copy as markdown» быть не должно. Без видимого черновика
357
+ // (пустой ответ, только тулы) — тихо, без шумовой строки.
358
+ export function onRetract(reason, sid) {
359
+ const feed = feedForEvent(sid);
360
+ if (!feed) return;
361
+ clearBusy(feed);
362
+ const draftRoot = S.curMd ? S.curMd.root : null;
363
+ const hadDraft = !!draftRoot || (S.curText != null && S.curText !== "");
364
+ const draftLen = S.curText ? S.curText.length : 0;
365
+ finishMd();
366
+ finishThink();
367
+ if (draftRoot) {
368
+ const idx = S.turnRoots.indexOf(draftRoot);
369
+ if (idx !== -1) S.turnRoots.splice(idx, 1);
370
+ draftRoot.remove();
371
+ }
372
+ if (draftLen > 0) S.turnMd = S.turnMd.slice(0, Math.max(0, S.turnMd.length - draftLen));
373
+ if (!hadDraft) return;
374
+ if (reason) appendTo(feed, el("div", "metaln", reason));
375
+ if (feed === activeFeed()) scroll();
376
+ }
377
+
267
378
  export function onPhase(phase, sid) {
268
379
  const feed = feedForEvent(sid);
269
380
  if (phase === "thinking") {
@@ -291,6 +402,7 @@ export function onToolStart(data) {
291
402
  }
292
403
  const status = el("span", "ok", "");
293
404
  line.appendChild(status);
405
+ line.classList.add("running");
294
406
  appendTo(feed, line);
295
407
  S.toolNodes.set(data.toolCallId, { status, line });
296
408
  if (categoryOf(data.name) !== "recon") S.runReconOnly = false;
@@ -419,6 +531,7 @@ export function onToolEnd(data) {
419
531
  const node = S.toolNodes.get(data.toolCallId);
420
532
  const line = node ? node.line : null;
421
533
  if (node) {
534
+ node.line.classList.remove("running");
422
535
  const dur = formatMs(data.durationMs);
423
536
  node.status.textContent = data.success ? " ✓" + (dur ? " " + dur : "") : " ✗" + (dur ? " " + dur : "");
424
537
  if (!data.success) {
@@ -456,6 +569,11 @@ export function onTurnEnd(data) {
456
569
  finishThink();
457
570
  finishMd();
458
571
  flushRun();
572
+ // Interrupted tools never get `tool_end`: drop their pulse so nothing
573
+ // looks alive after the turn is over.
574
+ S.toolNodes.forEach((n) => {
575
+ if (n && n.line && feed && n.line.parentNode === feed) n.line.classList.remove("running");
576
+ });
459
577
  const owner = S.tabs.find((x) => x.id === data.sessionId);
460
578
  if (owner) owner.planCard = null;
461
579
  if (!feed) {
@@ -562,7 +680,7 @@ function renderAssistantText(feed, text) {
562
680
  }
563
681
 
564
682
  function renderThinkBlock(feed, text) {
565
- const { wrap, body } = buildThinkBlock(false);
683
+ const { wrap, body } = buildThinkBlock(false, false);
566
684
  body.textContent = text || "";
567
685
  feed.appendChild(wrap);
568
686
  }
@@ -659,7 +777,8 @@ export async function loadHistory(tab) {
659
777
  if (feedHasContent(tab.feed)) return;
660
778
  renderHistory(tab.feed, r.data.history);
661
779
  if (!r.data.history.length) tab.feed.appendChild(el("div", "detail", t("web.history.empty")));
662
- if (tab.feed === activeFeed()) scroll();
780
+ if (tab.feed === activeFeed()) scrollToBottom();
781
+ else if (tab.feed === liveFeed()) scrollToBottom();
663
782
  }
664
783
 
665
784
  export function syncUrl() {