micro-models-agent 2.12.0 → 2.14.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
@@ -4,6 +4,41 @@ 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.14.0] - 2026-10-01
10
+
11
+ ### Changed
12
+
13
+ - **Web UI**: file actions now render as quiet terminal-style lines instead of boxed cards (`src/modules/webui/assets/js/feed.js`, `styles.css`). Each file tool shows `◇ <label> · <path-link> [copy-icon] <status>` in the tool-category color — no more Open/Download/Path pill buttons (path click opens the viewer, copy is an icon-only hover action). Consecutive file rows collapse into an expandable `│ N files` group with a chevron toggle. File-type badges are gone (format detection stays silent for highlighting only; draw.io hint is verbose-only). Dead strings `web.card.open/download/copy_path/download_title` removed, count via new `web.card.files_*` (en+ru). Tests: `tests/webui-assets.test.ts`, `tests/webui-smoke.test.ts`, new `tests/e2e/web-filecard.e2e-test.ts`.
14
+
15
+ ## [2.13.1] - 2026-09-30
16
+
17
+ ### Fixed
18
+
19
+ - **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.
20
+ - **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.
21
+ - **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.
22
+ - **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.
23
+ - **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).
24
+ - **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`.
25
+ - **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.
26
+
27
+ ## [2.13.0] - 2026-09-30
28
+
29
+ ### Added
30
+
31
+ - **Web UI**: sidebar tabs — Session / Map / Trace. Square icon tabs with the active
32
+ tab name shown below them (`assets/js/sidebar.js`, `index.html`, `styles.css`).
33
+ - **Map tab**: project-map index state — status, last-updated timestamp, watcher,
34
+ files/symbols/tokens, root, per-language breakdown, and a Re-index button
35
+ (`POST /api/index`). `IndexerModule.mapInfo()` + `ProjectMapInfo` host contract.
36
+ - **Trace tab**: live per-session log of tool calls, phases, plan updates, errors,
37
+ questions and the answer text stream; clicking a row opens a detail panel (full
38
+ args, output, metadata) and doubles the sidebar width. Desktop only (hidden ≤760px).
39
+ - **i18n**: `web.sidebar.tab_*`, `web.sidebar.map_*`, `web.sidebar.trace_*`,
40
+ `web.sidebar.detail_*` (en + ru).
41
+
7
42
  ## [2.12.0] - 2026-09-29
8
43
 
9
44
  ### 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;
76
109
  }
77
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");
142
+ }
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;
@@ -332,56 +444,91 @@ function safeUrl(url) {
332
444
  return null;
333
445
  }
334
446
 
335
- function cardFor(name, args, sid) {
447
+ function maybeGroupFilerow(feed, row) {
448
+ const prev = row.previousElementSibling;
449
+ const group = prev && prev.classList && prev.classList.contains("filegroup") ? prev : null;
450
+ const prevRow = prev && prev.classList && prev.classList.contains("filerow") ? prev : null;
451
+ if (group && group.dataset.open !== "1") {
452
+ group.querySelector(".items").appendChild(row);
453
+ if (row.dataset.fail === "1") group.dataset.fail = "1";
454
+ const n = group.querySelectorAll(".items > .filerow").length;
455
+ group.querySelector(".count").textContent = pluralCount("web.card.files", n);
456
+ group.querySelector(".ok").textContent = group.dataset.fail === "1" ? " ✗" : " ✓";
457
+ return group;
458
+ }
459
+ if (prevRow) {
460
+ const wrap = el("div", "filegroup");
461
+ const head = el("button", "head");
462
+ head.setAttribute("aria-expanded", "false");
463
+ head.appendChild(el("span", "g", "│ "));
464
+ head.appendChild(el("span", "count", pluralCount("web.card.files", 2)));
465
+ const fail = prevRow.dataset.fail === "1" || row.dataset.fail === "1";
466
+ if (fail) wrap.dataset.fail = "1";
467
+ head.appendChild(el("span", "ok", fail ? " ✗" : " ✓"));
468
+ const chev = icon("chevron-down");
469
+ head.appendChild(chev);
470
+ const items = el("div", "items hidden");
471
+ feed.replaceChild(wrap, prevRow);
472
+ items.appendChild(prevRow);
473
+ items.appendChild(row);
474
+ wrap.appendChild(head);
475
+ wrap.appendChild(items);
476
+ head.addEventListener("click", () => {
477
+ const open = wrap.dataset.open === "1";
478
+ wrap.dataset.open = open ? "" : "1";
479
+ head.setAttribute("aria-expanded", open ? "false" : "true");
480
+ items.classList.toggle("hidden", open);
481
+ });
482
+ return wrap;
483
+ }
484
+ return null;
485
+ }
486
+
487
+ function cardFor(name, args, sid, ok) {
336
488
  const feed = feedForEvent(sid);
337
489
  if (!feed) return null;
338
490
  if (FILE_TOOLS.indexOf(name) !== -1) {
339
491
  const path = args.path || args.file || args.dir;
340
492
  if (!path) return null;
341
- const card = el("div", "filecard");
342
- const top = el("div", "fc-top");
343
- top.appendChild(el("span", "g", "◇"));
344
- const nameEl = el("span", "name", path);
345
- nameEl.title = path;
346
- top.appendChild(nameEl);
347
- card.appendChild(top);
348
- const bottom = el("div", "fc-bottom");
349
- bottom.appendChild(el("span", "lab", labelOf(name)));
350
- const actions = el("span", "fc-actions");
351
- const baseName = String(path.split(/[\\/]/).pop() || path);
493
+ const row = el("div", "filerow " + categoryOf(name));
494
+ row.appendChild(el("span", "g", "◇"));
495
+ row.appendChild(el("span", "lab", labelOf(name)));
496
+ row.appendChild(el("span", "sep", " · "));
352
497
  if (OPENABLE_TOOLS.indexOf(name) !== -1) {
353
- const openLink = el("a", "fc-open", t("web.card.open"));
498
+ const openLink = el("a", "path", String(path).split(/[\\/]/).pop() || path);
354
499
  openLink.href = "/api/file?path=" + encodeURIComponent(path);
355
500
  openLink.target = "_blank";
356
501
  openLink.rel = "noopener noreferrer";
357
- openLink.title = t("web.card.open_title");
358
- actions.appendChild(openLink);
359
- const dlLink = el("a", "fc-open", t("web.card.download"));
360
- dlLink.href = "/api/file?path=" + encodeURIComponent(path) + "&download=1";
361
- dlLink.title = t("web.card.download_title");
362
- dlLink.setAttribute("download", baseName);
363
- actions.appendChild(dlLink);
502
+ openLink.title = t("web.card.open_title") + " · " + path;
503
+ row.appendChild(openLink);
504
+ const tail = String(path).split(/[\\/]/).slice(0, -1).join("/");
505
+ if (tail) row.appendChild(el("span", "tail", " · " + tail));
506
+ } else {
507
+ const s = el("span", "tail", path);
508
+ s.title = path;
509
+ row.appendChild(s);
364
510
  }
365
- const copyBtn = el("button", "fc-open", t("web.card.copy_path"));
511
+ const copyBtn = el("button", "copy-btn");
366
512
  copyBtn.type = "button";
367
513
  copyBtn.title = t("web.card.copy_title");
514
+ copyBtn.setAttribute("aria-label", t("web.card.copy_title"));
515
+ copyBtn.appendChild(icon("copy"));
368
516
  copyBtn.addEventListener("click", () => {
369
517
  copyText(path).then((ok) => {
370
- copyBtn.textContent = ok ? "✓ " + t("web.code.copied") : "⚠ " + t("web.code.copy_failed");
371
- window.setTimeout(() => {
372
- copyBtn.textContent = t("web.card.copy_path");
373
- }, 1400);
518
+ copyBtn.replaceChildren(icon(ok ? "check" : "copy"));
519
+ if (!ok) { const st = row.querySelector(".st"); if (st) st.textContent = " ⚠"; }
520
+ window.setTimeout(() => copyBtn.replaceChildren(icon("copy")), 1400);
374
521
  });
375
522
  });
376
- actions.appendChild(copyBtn);
377
- bottom.appendChild(actions);
378
- card.appendChild(bottom);
379
- if (/\.drawio(\.xml)?$/i.test(path)) {
380
- const hint = el("div", "fc-hint", t("web.card.drawio_hint"));
381
- card.appendChild(hint);
523
+ row.appendChild(copyBtn);
524
+ row.appendChild(el("span", "st", ok === false ? " ✗" : ok === true ? " ✓" : ""));
525
+ if (ok === false) row.dataset.fail = "1";
526
+ if (/\.drawio(\.xml)?$/i.test(path) && verbosity() === "verbose") {
527
+ row.appendChild(el("span", "detail", "↳ " + t("web.card.drawio_hint")));
382
528
  }
383
- appendTo(feed, card);
384
- return card;
529
+ appendTo(feed, row);
530
+ try { maybeGroupFilerow(feed, row); } catch (err) { /* fail-open: plain row stays */ }
531
+ return row;
385
532
  }
386
533
  if (LINK_TOOLS.indexOf(name) !== -1 && args.url) {
387
534
  const line = el("div", "linkline");
@@ -419,6 +566,7 @@ export function onToolEnd(data) {
419
566
  const node = S.toolNodes.get(data.toolCallId);
420
567
  const line = node ? node.line : null;
421
568
  if (node) {
569
+ node.line.classList.remove("running");
422
570
  const dur = formatMs(data.durationMs);
423
571
  node.status.textContent = data.success ? " ✓" + (dur ? " " + dur : "") : " ✗" + (dur ? " " + dur : "");
424
572
  if (!data.success) {
@@ -436,7 +584,7 @@ export function onToolEnd(data) {
436
584
  // a single `↳` cause instead of the whole model-facing body.
437
585
  if (data.success) {
438
586
  if (line) S.runNodes.push(line);
439
- const card = cardFor(data.name, args, data.sessionId);
587
+ const card = cardFor(data.name, args, data.sessionId, data.success);
440
588
  if (card) S.runNodes.push(card);
441
589
  S.runDuration += typeof data.durationMs === "number" ? data.durationMs : 0;
442
590
  } else {
@@ -445,7 +593,7 @@ export function onToolEnd(data) {
445
593
  }
446
594
  return;
447
595
  }
448
- if (data.success) cardFor(data.name, args, data.sessionId);
596
+ if (data.success) cardFor(data.name, args, data.sessionId, data.success);
449
597
  renderToolOutput(line, data.output);
450
598
  }
451
599
 
@@ -456,6 +604,11 @@ export function onTurnEnd(data) {
456
604
  finishThink();
457
605
  finishMd();
458
606
  flushRun();
607
+ // Interrupted tools never get `tool_end`: drop their pulse so nothing
608
+ // looks alive after the turn is over.
609
+ S.toolNodes.forEach((n) => {
610
+ if (n && n.line && feed && n.line.parentNode === feed) n.line.classList.remove("running");
611
+ });
459
612
  const owner = S.tabs.find((x) => x.id === data.sessionId);
460
613
  if (owner) owner.planCard = null;
461
614
  if (!feed) {
@@ -562,7 +715,7 @@ function renderAssistantText(feed, text) {
562
715
  }
563
716
 
564
717
  function renderThinkBlock(feed, text) {
565
- const { wrap, body } = buildThinkBlock(false);
718
+ const { wrap, body } = buildThinkBlock(false, false);
566
719
  body.textContent = text || "";
567
720
  feed.appendChild(wrap);
568
721
  }
@@ -659,7 +812,8 @@ export async function loadHistory(tab) {
659
812
  if (feedHasContent(tab.feed)) return;
660
813
  renderHistory(tab.feed, r.data.history);
661
814
  if (!r.data.history.length) tab.feed.appendChild(el("div", "detail", t("web.history.empty")));
662
- if (tab.feed === activeFeed()) scroll();
815
+ if (tab.feed === activeFeed()) scrollToBottom();
816
+ else if (tab.feed === liveFeed()) scrollToBottom();
663
817
  }
664
818
 
665
819
  export function syncUrl() {