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.
@@ -0,0 +1,512 @@
1
+ // Sidebar tabs (Session / Map / Trace) for `mma web`.
2
+ //
3
+ // Owns: active-tab state (localStorage), the live per-session Trace buffer with
4
+ // master-detail expansion, and the Map tab rendering. Leaf module: imports only
5
+ // dom/i18n/format/store/api — the frontend DAG stays acyclic
6
+ // (app → sidebar → {dom,i18n,format,store,api}).
7
+ // Design: docs/specs/2026-09-30-webui-sidebar-tabs-design.md.
8
+
9
+ import { el, icon } from "./dom.js";
10
+ import { api } from "./api.js";
11
+ import { S } from "./store.js";
12
+ import { t } from "./i18n.js";
13
+ import { labelOf, categoryOf, argText, formatMs, fmtTokens } from "./format.js";
14
+
15
+ const TAB_KEY = "mma-sidebar-tab";
16
+ const TABS = ["session", "map", "trace"];
17
+ const ICON_NAME = { session: "list-checks", map: "map", trace: "activity" };
18
+ const TRACE_CAP = 500;
19
+ const MAX_OUTPUT_CHARS = 20000;
20
+
21
+ let sbEl = null, nameEl = null, nameTextEl = null, mapEl = null, listEl = null, detailEl = null;
22
+ let detailGlyph = null, detailTitle = null, detailBody = null, detailClose = null;
23
+ let active = "session";
24
+ let trace = []; // { id, kind, ts, name, args, output, success, durationMs, phase, text, steps, currentIndex }
25
+ let selected = null; // trace id
26
+ let turn = 0;
27
+ let seq = 0;
28
+ const nodeById = new Map(); // entry id → row node
29
+ const callIndex = new Map(); // toolCallId → entry
30
+ const headerByTurn = new Map(); // turn → header node
31
+ let textEntry = null; // current text row (updated in place)
32
+
33
+ function paneEl(id) { return document.getElementById("pane-" + id); }
34
+
35
+ function mqMobile() {
36
+ return typeof window !== "undefined" && window.matchMedia
37
+ ? window.matchMedia("(max-width: 760px)").matches : false;
38
+ }
39
+
40
+ function readStored() {
41
+ try { return localStorage.getItem(TAB_KEY); } catch { return null; }
42
+ }
43
+ function storeTab(id) {
44
+ try { localStorage.setItem(TAB_KEY, id); } catch { /* private mode */ }
45
+ }
46
+
47
+ export function setSidebarTab(id, persist = true) {
48
+ if (!TABS.includes(id)) id = "session";
49
+ if (persist) storeTab(id);
50
+ const effective = id === "trace" && mqMobile() ? "session" : id;
51
+ active = effective;
52
+ TABS.forEach((name) => {
53
+ const pane = paneEl(name);
54
+ if (pane) pane.hidden = name !== effective;
55
+ const tab = sbEl && sbEl.querySelector('.sb-tab[data-tab="' + name + '"]');
56
+ if (tab) {
57
+ const on = name === effective;
58
+ tab.classList.toggle("on", on);
59
+ tab.setAttribute("aria-selected", on ? "true" : "false");
60
+ }
61
+ });
62
+ if (nameTextEl) nameTextEl.textContent = t("web.sidebar.tab_" + effective);
63
+ const nic = nameEl && nameEl.querySelector(".sb-ico");
64
+ if (nic) { nic.textContent = ""; nic.appendChild(icon(ICON_NAME[effective])); }
65
+ }
66
+
67
+ function collapseDetail() {
68
+ selected = null;
69
+ if (sbEl) sbEl.classList.remove("sb-expanded");
70
+ if (detailEl) detailEl.hidden = true;
71
+ if (listEl) listEl.querySelectorAll(".trc.sel").forEach((n) => n.classList.remove("sel"));
72
+ }
73
+
74
+ function phaseLabel(phase) {
75
+ if (phase === "composing") return t("web.rail.composing");
76
+ if (phase === "tool") return t("web.rail.working");
77
+ if (phase === "thinking") return t("web.action.thinking");
78
+ return String(phase || "");
79
+ }
80
+
81
+ function catClass(kind, name) {
82
+ if (kind === "tool" || kind === "tool_fail") return categoryOf(name);
83
+ if (kind === "phase") return "phase";
84
+ return "meta";
85
+ }
86
+
87
+ function rowLabel(entry) {
88
+ switch (entry.kind) {
89
+ case "phase": return phaseLabel(entry.phase);
90
+ case "plan": return t("web.sidebar.trace_plan");
91
+ case "question": return t("web.sidebar.trace_question");
92
+ case "text": return t("web.sidebar.trace_text", { count: entry.chars || 0 });
93
+ default: return labelOf(entry.name);
94
+ }
95
+ }
96
+
97
+ function rowArgs(entry) {
98
+ if (entry.kind === "text" || entry.kind === "phase") return "";
99
+ if (entry.kind === "plan") {
100
+ const idx = typeof entry.currentIndex === "number" ? entry.currentIndex : 0;
101
+ const total = Array.isArray(entry.steps) ? entry.steps.length : 0;
102
+ if (!total) return "";
103
+ return t("web.rail.step_of", { cur: Math.min(idx + 1, total), total });
104
+ }
105
+ if (entry.kind === "question") return entry.text || "";
106
+ return argText(entry.args);
107
+ }
108
+
109
+ function isWarn(entry) {
110
+ return entry.kind === "tool_fail" || entry.kind === "error";
111
+ }
112
+
113
+ function renderRow(entry) {
114
+ const cls = ["trc", catClass(entry.kind, entry.name)];
115
+ if (isWarn(entry)) cls.push("warn");
116
+ if (selected === entry.id) cls.push("sel");
117
+ const row = el("div", cls.join(" "));
118
+ row.dataset.id = String(entry.id);
119
+ row.appendChild(el("span", "g", isWarn(entry) ? "⚠" : entry.kind === "question" ? "?" : "◇"));
120
+ row.appendChild(el("span", "lab", rowLabel(entry)));
121
+ const args = rowArgs(entry);
122
+ if (args) row.appendChild(el("span", "args", args));
123
+ if (entry.durationMs) row.appendChild(el("span", "t", formatMs(entry.durationMs)));
124
+ row.addEventListener("click", () => {
125
+ if (selected === entry.id) { collapseDetail(); return; }
126
+ if (listEl) listEl.querySelectorAll(".trc.sel").forEach((n) => n.classList.remove("sel"));
127
+ selected = entry.id;
128
+ row.classList.add("sel");
129
+ renderDetail(entry);
130
+ if (sbEl) sbEl.classList.add("sb-expanded");
131
+ });
132
+ return row;
133
+ }
134
+
135
+ function renderList() {
136
+ if (!listEl) return;
137
+ listEl.textContent = "";
138
+ listEl.dataset.lastTurn = "-1";
139
+ nodeById.clear();
140
+ headerByTurn.clear();
141
+ if (selected && !trace.some((e) => e.id === selected)) selected = null;
142
+ if (trace.length === 0) {
143
+ listEl.appendChild(el("div", "trace-turn", t("web.sidebar.trace_empty")));
144
+ return;
145
+ }
146
+ let shownTurn = -1;
147
+ for (const entry of trace) {
148
+ if (entry.turn !== shownTurn) {
149
+ shownTurn = entry.turn;
150
+ const h = el("div", "trace-turn", t("web.sidebar.trace_turn", { n: entry.turn }));
151
+ headerByTurn.set(entry.turn, h);
152
+ listEl.appendChild(h);
153
+ }
154
+ const row = renderRow(entry);
155
+ nodeById.set(entry.id, row);
156
+ listEl.appendChild(row);
157
+ }
158
+ listEl.dataset.lastTurn = String(shownTurn);
159
+ listEl.scrollTop = listEl.scrollHeight;
160
+ }
161
+
162
+ function append(entry) {
163
+ entry.id = ++seq;
164
+ entry.turn = turn;
165
+ entry.ts = Date.now();
166
+ trace.push(entry);
167
+ while (trace.length > TRACE_CAP) {
168
+ const old = trace.shift();
169
+ const node = nodeById.get(old.id);
170
+ if (node) { node.remove(); nodeById.delete(old.id); }
171
+ if (old.toolCallId != null) callIndex.delete(old.toolCallId);
172
+ if (!trace.some((e) => e.turn === old.turn)) {
173
+ const h = headerByTurn.get(old.turn);
174
+ if (h) { h.remove(); headerByTurn.delete(old.turn); }
175
+ }
176
+ }
177
+ return entry;
178
+ }
179
+
180
+ function pushRow(entry) {
181
+ if (!listEl) return;
182
+ const last = listEl.dataset.lastTurn ? Number(listEl.dataset.lastTurn) : -1;
183
+ if (entry.turn !== last) {
184
+ const h = el("div", "trace-turn", t("web.sidebar.trace_turn", { n: entry.turn }));
185
+ headerByTurn.set(entry.turn, h);
186
+ listEl.appendChild(h);
187
+ listEl.dataset.lastTurn = String(entry.turn);
188
+ }
189
+ const row = renderRow(entry);
190
+ nodeById.set(entry.id, row);
191
+ listEl.appendChild(row);
192
+ listEl.scrollTop = listEl.scrollHeight;
193
+ }
194
+
195
+ function replaceRow(entry) {
196
+ const old = nodeById.get(entry.id);
197
+ const next = renderRow(entry);
198
+ nodeById.set(entry.id, next);
199
+ if (old && old.parentNode) old.replaceWith(next);
200
+ else if (listEl) listEl.appendChild(next);
201
+ if (listEl) listEl.scrollTop = listEl.scrollHeight;
202
+ }
203
+
204
+ function updateTextRow(entry) {
205
+ const row = nodeById.get(entry.id);
206
+ if (!row) return;
207
+ row.textContent = "";
208
+ row.appendChild(el("span", "g", "✦"));
209
+ row.appendChild(el("span", "lab", rowLabel(entry)));
210
+ if (listEl) listEl.scrollTop = listEl.scrollHeight;
211
+ }
212
+
213
+ // ---- public API ----
214
+
215
+ export function sidebarSessionChanged(sid) {
216
+ const prev = sbEl && sbEl.dataset.sid;
217
+ if (prev === (sid || "")) return;
218
+ if (sbEl) sbEl.dataset.sid = sid || "";
219
+ trace = [];
220
+ callIndex.clear();
221
+ textEntry = null;
222
+ turn = 0;
223
+ seq = 0;
224
+ collapseDetail();
225
+ renderList();
226
+ }
227
+
228
+ export function sidebarTurnEnd() {
229
+ turn += 1;
230
+ textEntry = null;
231
+ }
232
+
233
+ export function sidebarTraceEvent(kind, data) {
234
+ if (!data) return;
235
+ switch (kind) {
236
+ case "tool_start": {
237
+ const entry = append({ kind: "tool", name: data.name, args: data.args || {}, toolCallId: data.toolCallId });
238
+ if (data.toolCallId != null) callIndex.set(data.toolCallId, entry);
239
+ pushRow(entry);
240
+ break;
241
+ }
242
+ case "tool_end": {
243
+ const entry = data.toolCallId != null ? callIndex.get(data.toolCallId) : undefined;
244
+ if (data.toolCallId != null) callIndex.delete(data.toolCallId);
245
+ if (entry) {
246
+ entry.kind = data.success === false ? "tool_fail" : "tool";
247
+ entry.success = data.success !== false;
248
+ entry.durationMs = data.durationMs;
249
+ entry.output = data.output || "";
250
+ replaceRow(entry);
251
+ } else {
252
+ const e2 = append({
253
+ kind: data.success === false ? "tool_fail" : "tool",
254
+ name: data.name, args: data.args || {}, toolCallId: data.toolCallId,
255
+ success: data.success !== false, durationMs: data.durationMs, output: data.output || "",
256
+ });
257
+ pushRow(e2);
258
+ }
259
+ break;
260
+ }
261
+ case "phase":
262
+ if (data.phase !== "done") pushRow(append({ kind: "phase", phase: data.phase }));
263
+ break;
264
+ case "plan":
265
+ pushRow(append({
266
+ kind: "plan",
267
+ steps: Array.isArray(data.steps) ? data.steps : [],
268
+ currentIndex: typeof data.currentIndex === "number" ? data.currentIndex : 0,
269
+ }));
270
+ break;
271
+ case "question":
272
+ pushRow(append({ kind: "question", text: data.question || data.text || "" }));
273
+ break;
274
+ case "text": {
275
+ if (!textEntry) { textEntry = append({ kind: "text", text: "", chars: 0 }); pushRow(textEntry); }
276
+ const chunk = data.chunk || "";
277
+ textEntry.chars += chunk.length;
278
+ textEntry.text += chunk;
279
+ if (textEntry.text.length > 4000) textEntry.text = textEntry.text.slice(-4000);
280
+ updateTextRow(textEntry);
281
+ break;
282
+ }
283
+ case "error":
284
+ pushRow(append({ kind: "error", message: data.message || data.error || "" }));
285
+ break;
286
+ default:
287
+ break;
288
+ }
289
+ }
290
+
291
+ function detailGroup(title, bodyNode) {
292
+ const g = el("div");
293
+ g.appendChild(el("h5", "", title));
294
+ g.appendChild(bodyNode);
295
+ return g;
296
+ }
297
+ function kv(label, value) {
298
+ const row = el("div", "kv");
299
+ row.appendChild(el("span", "dim", label));
300
+ const b = el("b");
301
+ b.textContent = value;
302
+ row.appendChild(b);
303
+ return row;
304
+ }
305
+ function codeBlock(text) {
306
+ const c = el("div", "code");
307
+ c.textContent = text;
308
+ return c;
309
+ }
310
+ function capOutput(text) {
311
+ if (typeof text !== "string") return "";
312
+ return text.length <= MAX_OUTPUT_CHARS ? text : text.slice(0, MAX_OUTPUT_CHARS) + "\n… [truncated from " + text.length + " chars]";
313
+ }
314
+
315
+ function renderDetail(entry) {
316
+ if (!detailEl) return;
317
+ detailEl.hidden = false;
318
+ const warn = isWarn(entry);
319
+ detailGlyph.textContent = warn ? "⚠" : entry.kind === "question" ? "?" : "◇";
320
+ detailGlyph.style.color = warn ? "var(--yellow)" : "var(--accent)";
321
+ detailTitle.textContent = rowLabel(entry);
322
+ detailBody.textContent = "";
323
+
324
+ const meta = el("div");
325
+ meta.appendChild(el("h5", "", t("web.sidebar.detail_metadata")));
326
+ const d = entry.ts ? new Date(entry.ts) : null;
327
+ if (entry.kind === "tool" || entry.kind === "tool_fail") {
328
+ meta.appendChild(kv(t("web.sidebar.detail_status"), entry.success === false ? t("web.sidebar.detail_fail") : t("web.sidebar.detail_ok")));
329
+ if (entry.durationMs) meta.appendChild(kv(t("web.sidebar.detail_duration"), formatMs(entry.durationMs)));
330
+ }
331
+ if (d) meta.appendChild(kv(t("web.sidebar.detail_time"), d.toLocaleTimeString()));
332
+ if (entry.toolCallId) meta.appendChild(kv(t("web.sidebar.detail_tool_call"), String(entry.toolCallId)));
333
+ if (sbEl && sbEl.dataset.sid) meta.appendChild(kv(t("web.sidebar.detail_session"), sbEl.dataset.sid));
334
+ detailBody.appendChild(meta);
335
+
336
+ if (entry.kind === "tool" || entry.kind === "tool_fail") {
337
+ detailBody.appendChild(detailGroup(t("web.sidebar.detail_arguments"), codeBlock(JSON.stringify(entry.args || {}, null, 2))));
338
+ detailBody.appendChild(detailGroup(t("web.sidebar.detail_output"), codeBlock(capOutput(entry.output))));
339
+ } else if (entry.kind === "text") {
340
+ detailBody.appendChild(detailGroup(t("web.sidebar.detail_text"), codeBlock(entry.text || "")));
341
+ } else if (entry.kind === "plan") {
342
+ const list = el("div");
343
+ (entry.steps || []).forEach((s) => {
344
+ const mark = s.status === "done" ? "✓" : s.status === "failed" ? "✗" : s.status === "in_progress" ? "▸" : "○";
345
+ const cls = s.status === "failed" ? "fail" : s.status === "in_progress" ? "cur" : s.status === "done" ? "done" : "";
346
+ list.appendChild(el("div", ("plan-step " + cls).trim(), mark + " " + s.text));
347
+ });
348
+ detailBody.appendChild(detailGroup(t("web.sidebar.detail_plan"), list));
349
+ } else if (entry.kind === "question") {
350
+ detailBody.appendChild(detailGroup(t("web.sidebar.trace_question"), codeBlock(entry.text || "")));
351
+ } else if (entry.kind === "error") {
352
+ detailBody.appendChild(detailGroup(t("web.sidebar.detail_error"), codeBlock(entry.message || "")));
353
+ }
354
+ }
355
+
356
+ // ---- Map tab ----
357
+
358
+ function fmtUpdated(updatedAt) {
359
+ if (typeof updatedAt !== "number") return t("web.sidebar.map_never");
360
+ const d = new Date(updatedAt);
361
+ const now = new Date();
362
+ const pad = (n) => String(n).padStart(2, "0");
363
+ const time = pad(d.getHours()) + ":" + pad(d.getMinutes());
364
+ if (d.toDateString() === now.toDateString()) return t("web.sidebar.map_today", { time });
365
+ return pad(d.getDate()) + "." + pad(d.getMonth() + 1) + " " + time;
366
+ }
367
+
368
+ function mapKv(label, value, cls) {
369
+ const row = el("div", ("kv" + (cls ? " " + cls : "")).trim());
370
+ row.appendChild(el("span", "dim", label));
371
+ const b = el("b");
372
+ b.textContent = value;
373
+ row.appendChild(b);
374
+ return row;
375
+ }
376
+
377
+ let reindexing = false; // клиентский спиннер до свежего снапшота
378
+ let postResolved = false; // POST вернулся; следующий снапшот снимет спиннер
379
+ let reindexError = false; // последний POST не удался
380
+
381
+ export function sidebarMapState(host) {
382
+ if (!mapEl) return;
383
+ const map = (host && host.projectMap) || { indexed: false, files: 0, symbols: 0, tokens: 0, languages: [] };
384
+ const serverIndexing = !!(map && map.indexing);
385
+ if (postResolved && !serverIndexing) { reindexing = false; postResolved = false; }
386
+ const indexing = serverIndexing || reindexing;
387
+ const num = (v) => (map.indexed ? String(v || 0) : "—");
388
+ mapEl.textContent = "";
389
+ const status = indexing ? t("web.sidebar.map_indexing")
390
+ : !map.indexed ? t("web.sidebar.map_never")
391
+ : (map.watcher ? t("web.sidebar.map_ready") : t("web.sidebar.map_stale"));
392
+ mapEl.appendChild(mapKv(t("web.sidebar.map_status"), status));
393
+ mapEl.appendChild(mapKv(t("web.sidebar.map_updated"), fmtUpdated(map.updatedAt)));
394
+ mapEl.appendChild(mapKv(t("web.sidebar.map_watcher"), map.watcher ? t("web.sidebar.map_on") : t("web.sidebar.map_off")));
395
+ mapEl.appendChild(mapKv(t("web.sidebar.map_files"), num(map.files)));
396
+ mapEl.appendChild(mapKv(t("web.sidebar.map_symbols"), num(map.symbols)));
397
+ mapEl.appendChild(mapKv(t("web.sidebar.map_tokens"), map.indexed ? fmtTokens(map.tokens || 0) : "—"));
398
+ if (host && host.workspace) mapEl.appendChild(mapKv(t("web.sidebar.map_root"), host.workspace));
399
+
400
+ if (Array.isArray(map.languages) && map.languages.length > 0) {
401
+ const head = el("div", "kv");
402
+ head.appendChild(el("span", "dim", t("web.sidebar.map_languages")));
403
+ mapEl.appendChild(head);
404
+ map.languages.forEach((l) => mapEl.appendChild(mapKv(l.language, l.files + " · " + l.symbols)));
405
+ }
406
+ if (reindexError) mapEl.appendChild(mapKv(t("web.sidebar.map_reindex_failed"), "—", "wn"));
407
+
408
+ const btn = el("button", "map-reindex", indexing ? t("web.sidebar.map_reindexing") : t("web.sidebar.map_reindex"));
409
+ btn.type = "button";
410
+ btn.disabled = indexing || !map.indexed;
411
+ btn.addEventListener("click", async () => {
412
+ reindexing = true;
413
+ postResolved = false;
414
+ reindexError = false;
415
+ sidebarMapState(host);
416
+ sidebarMapIndicator(host);
417
+ const r = await api("/api/index", { method: "POST" });
418
+ if (!r.ok) {
419
+ reindexing = false;
420
+ postResolved = false;
421
+ reindexError = true;
422
+ sidebarMapState(host);
423
+ return;
424
+ }
425
+ // Свежий снапшот (поллинг / `state`-broadcast по завершении) снимет спиннер.
426
+ postResolved = true;
427
+ });
428
+ mapEl.appendChild(btn);
429
+ }
430
+
431
+ // ---- indicators ----
432
+
433
+ export function sidebarBusy(on) {
434
+ const tab = sbEl && sbEl.querySelector('.sb-tab[data-tab="trace"]');
435
+ if (tab) tab.dataset.indicator = on ? "live" : "";
436
+ }
437
+ export function sidebarMapIndicator(host) {
438
+ const tab = sbEl && sbEl.querySelector('.sb-tab[data-tab="map"]');
439
+ if (!tab) return;
440
+ const map = (host && host.projectMap) || null;
441
+ const busy = (map && map.indexing) || reindexing;
442
+ const next = busy ? "busy" : map && map.indexed && !map.watcher ? "stale" : "";
443
+ tab.dataset.indicator = next;
444
+ }
445
+
446
+ // ---- init / i18n ----
447
+
448
+ export function applySidebarI18n() {
449
+ if (!sbEl) return;
450
+ TABS.forEach((id) => {
451
+ const tab = sbEl.querySelector('.sb-tab[data-tab="' + id + '"]');
452
+ if (tab) { const label = t("web.sidebar.tab_" + id); tab.title = label; tab.setAttribute("aria-label", label); }
453
+ });
454
+ setSidebarTab(active, false);
455
+ renderList();
456
+ if (selected) {
457
+ const entry = trace.find((x) => x.id === selected);
458
+ if (entry) renderDetail(entry);
459
+ }
460
+ sidebarMapState(S.state.host);
461
+ }
462
+
463
+ export function initSidebar() {
464
+ if (sbEl) return;
465
+ sbEl = document.querySelector(".sb");
466
+ if (!sbEl) return;
467
+ nameEl = document.getElementById("sbName");
468
+ nameTextEl = document.getElementById("sbNameText");
469
+ mapEl = document.getElementById("mapBody");
470
+ listEl = document.getElementById("traceList");
471
+ detailEl = document.getElementById("traceDetail");
472
+ detailGlyph = document.getElementById("detailGlyph");
473
+ detailTitle = document.getElementById("detailTitle");
474
+ detailBody = document.getElementById("detailBody");
475
+ detailClose = document.getElementById("detailClose");
476
+ if (detailClose) detailClose.addEventListener("click", collapseDetail);
477
+
478
+ sbEl.querySelectorAll(".sb-tab .sb-ico").forEach((span) => {
479
+ const id = span.closest(".sb-tab").dataset.tab;
480
+ span.textContent = "";
481
+ span.appendChild(icon(ICON_NAME[id]));
482
+ });
483
+
484
+ sbEl.querySelectorAll(".sb-tab").forEach((tab) => {
485
+ tab.addEventListener("click", () => setSidebarTab(tab.dataset.tab));
486
+ tab.addEventListener("keydown", (e) => {
487
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
488
+ e.preventDefault();
489
+ const i = TABS.indexOf(tab.dataset.tab);
490
+ const next = TABS[(i + (e.key === "ArrowRight" ? 1 : TABS.length - 1)) % TABS.length];
491
+ setSidebarTab(next);
492
+ const focus = sbEl.querySelector('.sb-tab[data-tab="' + next + '"]');
493
+ if (focus) focus.focus();
494
+ });
495
+ });
496
+
497
+ document.addEventListener("keydown", (e) => {
498
+ if (!e.altKey || !["1", "2", "3"].includes(e.key)) return;
499
+ const tag = document.activeElement ? String(document.activeElement.tagName) : "";
500
+ if (tag === "INPUT" || tag === "TEXTAREA") return;
501
+ e.preventDefault();
502
+ setSidebarTab(TABS[Number(e.key) - 1]);
503
+ });
504
+
505
+ document.addEventListener("keydown", (e) => {
506
+ if (e.key === "Escape" && selected) collapseDetail();
507
+ });
508
+
509
+ const stored = readStored();
510
+ setSidebarTab(stored && TABS.includes(stored) ? stored : "session");
511
+ renderList();
512
+ }
@@ -13,6 +13,7 @@ import {
13
13
  makeFeed, showEmptyFeed, activeFeed, liveFeed, appendTo,
14
14
  clearEmpty, showBusy, clearFeed, finishMd, resetTurnText,
15
15
  finishThink, feedHasContent, loadHistory, syncUrl, renderPlan,
16
+ scrollToBottom,
16
17
  } from "./feed.js";
17
18
  import { openConfirm, setIndicator } from "./dialog.js";
18
19
  import { setBusy, setPlan as railSetPlan } from "./rail.js";
@@ -122,7 +123,7 @@ export function openTab(id, name, kind, server) {
122
123
  msgs.replaceChildren(tab.feed);
123
124
  renderTabstrip();
124
125
  renderSessionsDropdown();
125
- msgs.scrollTop = msgs.scrollHeight;
126
+ scrollToBottom();
126
127
  if (tab.kind === "session" && !server) {
127
128
  // Auto-opened live session: SSE fills the feed, no transcript fetch.
128
129
  tab.historyLoaded = true;
@@ -159,6 +160,7 @@ function closeTab(id) {
159
160
  S.activeTabId = next.id;
160
161
  S.liveTabId = next.id;
161
162
  msgs.replaceChildren(next.feed);
163
+ scrollToBottom();
162
164
  } else {
163
165
  S.activeTabId = null;
164
166
  S.liveTabId = null;
@@ -636,6 +638,7 @@ async function send() {
636
638
  finishThink();
637
639
  if (text.startsWith("/")) {
638
640
  appendTo(feed, el("div", "u", text));
641
+ scrollToBottom();
639
642
  clearInput();
640
643
  const name = text.slice(1).split(/\s+/)[0].toLowerCase();
641
644
  // `/clear` and `/new` never reach the server: both are pure browser-side
@@ -663,6 +666,7 @@ async function send() {
663
666
  feed.insertBefore(banner, feed.firstChild);
664
667
  }
665
668
  appendTo(feed, userMessageNode(text, images));
669
+ scrollToBottom();
666
670
  clearInput();
667
671
  const body = { text };
668
672
  if (images.length) body.images = images;
@@ -672,7 +676,9 @@ async function send() {
672
676
  appendTo(feed, el("div", "warnline", "⚠ " + msg));
673
677
  } else {
674
678
  if (images.length) clearAttachments();
675
- showBusy(feed);
679
+ // Delay the spinner so fast first tokens never flash it: `showBusy`
680
+ // itself skips when a stream/think block already started.
681
+ window.setTimeout(() => showBusy(feed), 180);
676
682
  }
677
683
  }
678
684