micro-models-agent 2.14.0 → 2.16.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.
@@ -25,6 +25,8 @@ let trace = []; // { id, kind, ts, name, args, output, success, duratio
25
25
  let selected = null; // trace id
26
26
  let turn = 0;
27
27
  let seq = 0;
28
+ let logCursor = null; // max session-log ts already pulled (supplement fetch)
29
+ let logPulling = false;
28
30
  const nodeById = new Map(); // entry id → row node
29
31
  const callIndex = new Map(); // toolCallId → entry
30
32
  const headerByTurn = new Map(); // turn → header node
@@ -48,6 +50,7 @@ export function setSidebarTab(id, persist = true) {
48
50
  if (!TABS.includes(id)) id = "session";
49
51
  if (persist) storeTab(id);
50
52
  const effective = id === "trace" && mqMobile() ? "session" : id;
53
+ if (effective !== active) collapseDetail();
51
54
  active = effective;
52
55
  TABS.forEach((name) => {
53
56
  const pane = paneEl(name);
@@ -90,6 +93,18 @@ function rowLabel(entry) {
90
93
  case "plan": return t("web.sidebar.trace_plan");
91
94
  case "question": return t("web.sidebar.trace_question");
92
95
  case "text": return t("web.sidebar.trace_text", { count: entry.chars || 0 });
96
+ case "status": return t("web.sidebar.trace_status");
97
+ case "log": return t("web.sidebar.trace_log", { level: entry.level || "" });
98
+ case "retract": return t("web.sidebar.trace_retract");
99
+ case "index": return t("web.sidebar.trace_index", { phase: entry.phase || "" });
100
+ case "critic": return t("web.sidebar.trace_critic", { status: entry.status || "" });
101
+ case "moe": return t("web.sidebar.trace_moe");
102
+ case "compaction": return t("web.sidebar.trace_compaction");
103
+ case "probe": return t("web.sidebar.trace_probe");
104
+ case "usage": return t("web.sidebar.trace_usage");
105
+ case "audit": return t("web.sidebar.trace_audit");
106
+ case "claim": return t("web.sidebar.trace_claim", { kind: entry.subkind || "" });
107
+ case "output": return t("web.sidebar.trace_output");
93
108
  default: return labelOf(entry.name);
94
109
  }
95
110
  }
@@ -103,11 +118,19 @@ function rowArgs(entry) {
103
118
  return t("web.rail.step_of", { cur: Math.min(idx + 1, total), total });
104
119
  }
105
120
  if (entry.kind === "question") return entry.text || "";
121
+ // Диагностические строки несут смысл в одной строке (статус/лог/ретракт/
122
+ // индекс/критик/MoE/компакция/проба/usage/аудит/claim/вывод).
123
+ if (entry.text && typeof entry.text === "string" && entry.kind !== "tool" && entry.kind !== "tool_fail") {
124
+ const line = entry.text.split("\n").map((l) => l.trim()).find((l) => l.length > 0) || "";
125
+ return line.slice(0, 160);
126
+ }
106
127
  return argText(entry.args);
107
128
  }
108
129
 
109
130
  function isWarn(entry) {
110
- return entry.kind === "tool_fail" || entry.kind === "error";
131
+ return entry.kind === "tool_fail" || entry.kind === "error" || entry.kind === "claim" ||
132
+ (entry.kind === "critic" && entry.status === "critical") ||
133
+ (entry.kind === "log" && (entry.level === "warn" || entry.level === "error"));
111
134
  }
112
135
 
113
136
  function renderRow(entry) {
@@ -221,12 +244,16 @@ export function sidebarSessionChanged(sid) {
221
244
  textEntry = null;
222
245
  turn = 0;
223
246
  seq = 0;
247
+ // Trace живой: старые ходы чужой сессии не докачиваем, только новые события.
248
+ logCursor = new Date().toISOString();
249
+ logPulling = false;
224
250
  collapseDetail();
225
251
  renderList();
226
252
  }
227
253
 
228
- export function sidebarTurnEnd() {
229
- turn += 1;
254
+ export function sidebarTurnEnd(turnSeq) {
255
+ // Серверный номер хода (реконнекты локальный счётчик не сбивают).
256
+ turn = typeof turnSeq === "number" && turnSeq > turn ? turnSeq : turn + 1;
230
257
  textEntry = null;
231
258
  }
232
259
 
@@ -276,13 +303,118 @@ export function sidebarTraceEvent(kind, data) {
276
303
  const chunk = data.chunk || "";
277
304
  textEntry.chars += chunk.length;
278
305
  textEntry.text += chunk;
279
- if (textEntry.text.length > 4000) textEntry.text = textEntry.text.slice(-4000);
306
+ // Полный текст хода — в память (деталь покажет целиком); обрезка честная,
307
+ // тем же капом и маркером, что у выводов тулов.
308
+ if (textEntry.text.length > MAX_OUTPUT_CHARS) {
309
+ textEntry.text = textEntry.text.slice(0, MAX_OUTPUT_CHARS) +
310
+ "\n… [truncated from " + textEntry.chars + " chars]";
311
+ }
280
312
  updateTextRow(textEntry);
281
313
  break;
282
314
  }
283
315
  case "error":
284
316
  pushRow(append({ kind: "error", message: data.message || data.error || "" }));
285
317
  break;
318
+ case "meta": {
319
+ // Тела тулов (`kind: "tool"`) уже идут строками tool_start/tool_end —
320
+ // в Trace несём только статусы (stuck/escalation/honest-stop и т.п.).
321
+ if (data.kind !== undefined && data.kind !== "status") break;
322
+ const text = data.chunk || "";
323
+ if (!text.trim()) break;
324
+ pushRow(append({ kind: "status", text }));
325
+ break;
326
+ }
327
+ case "log": {
328
+ // Внутренний шёпот петли (`diagnostic` + info) в Trace не несём —
329
+ // только варнинги/ошибки и пользовательские info (probe/overflow).
330
+ if (data.diagnostic && data.level === "info") break;
331
+ const text = data.text || "";
332
+ if (!text.trim()) break;
333
+ pushRow(append({ kind: "log", text, level: data.level || "", source: data.source || "" }));
334
+ break;
335
+ }
336
+ case "retract":
337
+ pushRow(append({ kind: "retract", text: data.reason || "" }));
338
+ break;
339
+ case "index": {
340
+ const stats = data.stats && typeof data.stats === "object"
341
+ ? Object.entries(data.stats).map(([k, v]) => k + ": " + v).join(", ")
342
+ : "";
343
+ pushRow(append({ kind: "index", phase: data.phase || "", text: stats }));
344
+ break;
345
+ }
346
+ default:
347
+ break;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Докачка диагностических событий из session-лога (критик, MoE, компакция,
353
+ * проба контекста, usage, аудит, claim-retry, вывод). Отдельных SSE-продюсеров
354
+ * нет — данные уже лежат в логе с `iteration`; тянем инкремент по курсору на
355
+ * каждый `turn_end`. Живые tool/text-строки не дублируются (другие kinds).
356
+ */
357
+ export async function sidebarPullLog() {
358
+ const sid = sbEl && sbEl.dataset.sid;
359
+ if (!sid || logPulling) return;
360
+ logPulling = true;
361
+ try {
362
+ let url = "/api/sessions/" + encodeURIComponent(sid) + "/log?limit=100";
363
+ if (logCursor) url += "&since=" + encodeURIComponent(logCursor);
364
+ const r = await api(url);
365
+ if (!r.ok || !r.data || !Array.isArray(r.data.log)) return;
366
+ for (const e of r.data.log) {
367
+ if (typeof e.ts === "string" && (logCursor === null || e.ts > logCursor)) logCursor = e.ts;
368
+ appendLogEntry(e);
369
+ }
370
+ } catch {
371
+ /* best-effort supplement: Trace живёт и без докачки */
372
+ } finally {
373
+ logPulling = false;
374
+ }
375
+ }
376
+
377
+ function appendLogEntry(e) {
378
+ if (!e || typeof e.type !== "string") return;
379
+ switch (e.type) {
380
+ case "critic_check":
381
+ pushRow(append({ kind: "critic", status: "check", text: "", iteration: e.iteration }));
382
+ break;
383
+ case "critic_verdict":
384
+ pushRow(append({ kind: "critic", status: e.status || "", text: e.content || "", iteration: e.iteration }));
385
+ break;
386
+ case "critic_escalation":
387
+ pushRow(append({ kind: "critic", status: "escalation", text: e.content || "", iteration: e.iteration }));
388
+ break;
389
+ case "moe_plan":
390
+ case "moe_subtask":
391
+ case "moe_replan":
392
+ case "moe_verify": {
393
+ const bits = [e.title, e.subtaskId, e.expertTag, e.status].filter((x) => typeof x === "string" && x).join(" · ");
394
+ pushRow(append({ kind: "moe", subtype: e.type, text: bits, iteration: e.iteration }));
395
+ break;
396
+ }
397
+ case "compaction":
398
+ pushRow(append({ kind: "compaction", text: e.content || "", iteration: e.iteration }));
399
+ break;
400
+ case "context_probe":
401
+ pushRow(append({ kind: "probe", text: e.content || "" }));
402
+ break;
403
+ case "llm_usage": {
404
+ const toks = typeof e.totalTokens === "number" ? e.totalTokens : (e.promptTokens || 0) + (e.completionTokens || 0);
405
+ const who = [e.model || e.provider, toks ? toks + " tok" : ""].filter(Boolean).join(" · ");
406
+ pushRow(append({ kind: "usage", text: who, iteration: e.iteration }));
407
+ break;
408
+ }
409
+ case "audit":
410
+ pushRow(append({ kind: "audit", text: e.content || "", iteration: e.iteration }));
411
+ break;
412
+ case "claim_retry":
413
+ pushRow(append({ kind: "claim", subkind: e.kind || "", text: e.content || "", iteration: e.iteration }));
414
+ break;
415
+ case "output":
416
+ pushRow(append({ kind: "output", text: e.content || "", iteration: e.iteration }));
417
+ break;
286
418
  default:
287
419
  break;
288
420
  }
@@ -331,6 +463,8 @@ function renderDetail(entry) {
331
463
  if (d) meta.appendChild(kv(t("web.sidebar.detail_time"), d.toLocaleTimeString()));
332
464
  if (entry.toolCallId) meta.appendChild(kv(t("web.sidebar.detail_tool_call"), String(entry.toolCallId)));
333
465
  if (sbEl && sbEl.dataset.sid) meta.appendChild(kv(t("web.sidebar.detail_session"), sbEl.dataset.sid));
466
+ meta.appendChild(kv(t("web.sidebar.detail_turn"), String(entry.turn)));
467
+ if (typeof entry.iteration === "number") meta.appendChild(kv(t("web.sidebar.detail_iteration"), String(entry.iteration)));
334
468
  detailBody.appendChild(meta);
335
469
 
336
470
  if (entry.kind === "tool" || entry.kind === "tool_fail") {
@@ -338,6 +472,7 @@ function renderDetail(entry) {
338
472
  detailBody.appendChild(detailGroup(t("web.sidebar.detail_output"), codeBlock(capOutput(entry.output))));
339
473
  } else if (entry.kind === "text") {
340
474
  detailBody.appendChild(detailGroup(t("web.sidebar.detail_text"), codeBlock(entry.text || "")));
475
+ loadTextProvenance(entry);
341
476
  } else if (entry.kind === "plan") {
342
477
  const list = el("div");
343
478
  (entry.steps || []).forEach((s) => {
@@ -350,6 +485,52 @@ function renderDetail(entry) {
350
485
  detailBody.appendChild(detailGroup(t("web.sidebar.trace_question"), codeBlock(entry.text || "")));
351
486
  } else if (entry.kind === "error") {
352
487
  detailBody.appendChild(detailGroup(t("web.sidebar.detail_error"), codeBlock(entry.message || "")));
488
+ } else if (
489
+ entry.kind === "status" || entry.kind === "log" || entry.kind === "retract" ||
490
+ entry.kind === "index" || entry.kind === "critic" || entry.kind === "moe" ||
491
+ entry.kind === "compaction" || entry.kind === "probe" || entry.kind === "usage" ||
492
+ entry.kind === "audit" || entry.kind === "claim" || entry.kind === "output"
493
+ ) {
494
+ const title =
495
+ entry.kind === "moe" && entry.subtype ? entry.subtype
496
+ : entry.kind === "log" && entry.level ? "log · " + entry.level
497
+ : entry.kind === "index" && entry.phase ? "index · " + entry.phase
498
+ : entry.kind === "critic" && entry.status ? "critic · " + entry.status
499
+ : entry.kind === "claim" && entry.subkind ? "claim_retry · " + entry.subkind
500
+ : rowLabel(entry);
501
+ detailBody.appendChild(detailGroup(title, codeBlock(entry.text || "")));
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Привязка текста хода к session-логу: сколько assistant-сообщений в сессии,
507
+ * их итерации и провенанс последнего LLM-вызова (модель/провайдер/токены).
508
+ * Живой текст хода уже показан выше целиком; это — сверка с логом.
509
+ */
510
+ async function loadTextProvenance(entry) {
511
+ const sid = sbEl && sbEl.dataset.sid;
512
+ if (!sid || !detailBody || selected !== entry.id) return;
513
+ try {
514
+ const r = await api(
515
+ "/api/sessions/" + encodeURIComponent(sid) + "/log?types=assistant,llm_usage&limit=20",
516
+ );
517
+ if (!r.ok || !r.data || !Array.isArray(r.data.log) || selected !== entry.id) return;
518
+ const assistants = r.data.log.filter((e) => e.type === "assistant");
519
+ const usages = r.data.log.filter((e) => e.type === "llm_usage");
520
+ const iters = assistants.map((e) => e.iteration).filter((n) => typeof n === "number");
521
+ const last = usages.length > 0 ? usages[usages.length - 1] : null;
522
+ const bits = [
523
+ t("web.sidebar.detail_provenance_count", { n: assistants.length }) +
524
+ (iters.length > 0 ? " · iter " + Math.min(...iters) + "–" + Math.max(...iters) : ""),
525
+ last
526
+ ? [last.model || last.provider, last.totalTokens ?? (last.promptTokens || 0) + (last.completionTokens || 0)]
527
+ .filter(Boolean)
528
+ .join(" · ")
529
+ : "",
530
+ ].filter(Boolean).join("\n");
531
+ if (bits) detailBody.appendChild(detailGroup(t("web.sidebar.detail_provenance"), codeBlock(bits)));
532
+ } catch {
533
+ /* best-effort: живой текст уже на месте */
353
534
  }
354
535
  }
355
536
 
@@ -374,6 +555,18 @@ function mapKv(label, value, cls) {
374
555
  return row;
375
556
  }
376
557
 
558
+ // Корень — всегда длинный путь: подпись сверху, путь снизу на всю ширину.
559
+ // В одну flex-строку с подписью он не влезает и склеивается с ней.
560
+ function mapPathRow(label, value) {
561
+ const wrap = el("div", "map-path");
562
+ wrap.appendChild(el("span", "dim", label));
563
+ const b = el("b");
564
+ b.textContent = value;
565
+ b.title = value;
566
+ wrap.appendChild(b);
567
+ return wrap;
568
+ }
569
+
377
570
  let reindexing = false; // клиентский спиннер до свежего снапшота
378
571
  let postResolved = false; // POST вернулся; следующий снапшот снимет спиннер
379
572
  let reindexError = false; // последний POST не удался
@@ -395,7 +588,7 @@ export function sidebarMapState(host) {
395
588
  mapEl.appendChild(mapKv(t("web.sidebar.map_files"), num(map.files)));
396
589
  mapEl.appendChild(mapKv(t("web.sidebar.map_symbols"), num(map.symbols)));
397
590
  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));
591
+ if (host && host.workspace) mapEl.appendChild(mapPathRow(t("web.sidebar.map_root"), host.workspace));
399
592
 
400
593
  if (Array.isArray(map.languages) && map.languages.length > 0) {
401
594
  const head = el("div", "kv");
@@ -8,7 +8,7 @@ el, icon,
8
8
  import { api } from "./api.js";
9
9
  import { t } from "./i18n.js";
10
10
  import { S, MAX_IMAGES, MAX_IMAGE_BYTES } from "./store.js";
11
- import { relTime, contextLabel } from "./format.js";
11
+ import { relTime, contextLabel, matchesQuery } from "./format.js";
12
12
  import {
13
13
  makeFeed, showEmptyFeed, activeFeed, liveFeed, appendTo,
14
14
  clearEmpty, showBusy, clearFeed, finishMd, resetTurnText,
@@ -204,18 +204,70 @@ document.getElementById("sessRefresh").addEventListener("click", () => {
204
204
  loadSessions();
205
205
  });
206
206
 
207
+ // Live filter text of the sessions dropdown. Module-level so the 2s
208
+ // `renderState` poll and the 15s `loadSessions` poll (both re-render the lists)
209
+ // never wipe an in-progress query: the `<input id="sessSearch">` itself lives
210
+ // statically in `index.html` and only the row lists are redrawn.
211
+ let sessQuery = "";
212
+ let sessSearchWired = false;
213
+
214
+ // One-time wiring for the static `#sessSearch` input: placeholder follows the
215
+ // locale on every render, listeners attach once. Returns the input or null.
216
+ function ensureSessSearch() {
217
+ const search = document.getElementById("sessSearch");
218
+ if (!search) return null;
219
+ search.placeholder = t("web.sessions.search");
220
+ search.setAttribute("aria-label", t("web.sessions.search"));
221
+ if (!sessSearchWired) {
222
+ sessSearchWired = true;
223
+ search.addEventListener("click", (e) => e.stopPropagation());
224
+ search.addEventListener("input", () => {
225
+ sessQuery = search.value;
226
+ renderSessionsDropdown();
227
+ // Re-render must not steal the caret: the input node itself survives
228
+ // (only the lists redraw), but re-query it to be safe.
229
+ const again = document.getElementById("sessSearch");
230
+ if (again && document.activeElement !== again) again.focus();
231
+ });
232
+ search.addEventListener("keydown", (e) => {
233
+ e.stopPropagation();
234
+ if (e.key === "Escape") {
235
+ if (search.value) {
236
+ e.preventDefault();
237
+ search.value = "";
238
+ sessQuery = "";
239
+ renderSessionsDropdown();
240
+ search.focus();
241
+ } else {
242
+ setSessOpen(false);
243
+ }
244
+ } else if (e.key === "ArrowDown") {
245
+ e.preventDefault();
246
+ const first = document.querySelector("#sessActiveList .si-row .dd-row-main, #sessRecentList .si-row .dd-row-main");
247
+ if (first) first.focus();
248
+ }
249
+ });
250
+ }
251
+ return search;
252
+ }
253
+
207
254
  export function renderSessionsDropdown() {
255
+ ensureSessSearch();
208
256
  const activeList = document.getElementById("sessActiveList");
209
257
  const recentList = document.getElementById("sessRecentList");
258
+ const activeHeader = document.getElementById("sessActiveHeader");
259
+ const recentHeader = document.getElementById("sessRecentHeader");
210
260
  activeList.textContent = "";
211
261
  recentList.textContent = "";
262
+ const q = sessQuery.trim();
212
263
  const activeId = S.state.activeSessionId;
213
264
  const sorted = S.state.sessions.slice().sort((a, b) => {
214
265
  const at = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
215
266
  const bt = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
216
267
  return bt - at;
217
- });
268
+ }).filter((s) => matchesQuery((s.name || "") + " " + String(s.id || ""), q));
218
269
  let activePlaced = false;
270
+ let recentPlaced = false;
219
271
  sorted.forEach((s) => {
220
272
  const isActive = s.id === activeId;
221
273
  const row = sessionRow(s, isActive);
@@ -224,13 +276,27 @@ export function renderSessionsDropdown() {
224
276
  activePlaced = true;
225
277
  } else {
226
278
  recentList.appendChild(row);
279
+ recentPlaced = true;
227
280
  }
228
281
  });
229
- if (!activePlaced) {
230
- activeList.appendChild(el("div", "si", t("web.sessions.none_active")));
231
- }
232
- if (!recentList.childNodes.length) {
233
- recentList.appendChild(el("div", "si", t("web.sessions.none")));
282
+ if (q) {
283
+ // Filtered view: empty sections hide with their headers; zero hits total
284
+ // show a single no-matches row instead of the unfiltered placeholders.
285
+ if (activeHeader) activeHeader.hidden = !activePlaced;
286
+ if (recentHeader) recentHeader.hidden = !recentPlaced;
287
+ if (!activePlaced && !recentPlaced) {
288
+ if (recentHeader) recentHeader.hidden = true;
289
+ recentList.appendChild(el("div", "si dim", t("web.settings.no_matches")));
290
+ }
291
+ } else {
292
+ if (activeHeader) activeHeader.hidden = false;
293
+ if (recentHeader) recentHeader.hidden = false;
294
+ if (!activePlaced) {
295
+ activeList.appendChild(el("div", "si", t("web.sessions.none_active")));
296
+ }
297
+ if (!recentPlaced) {
298
+ recentList.appendChild(el("div", "si", t("web.sessions.none")));
299
+ }
234
300
  }
235
301
  if (S.state.sessionsError) {
236
302
  recentList.appendChild(el("div", "dd-err", S.state.sessionsError));
@@ -304,11 +370,28 @@ const sessBtn = document.getElementById("sessBtn");
304
370
  export function setSessOpen(open) {
305
371
  sessDD.classList.toggle("open", !!open);
306
372
  sessBtn.setAttribute("aria-expanded", open ? "true" : "false");
373
+ if (!open && sessQuery) {
374
+ // Closing wipes the filter so the next opening starts from the full list.
375
+ sessQuery = "";
376
+ const search = document.getElementById("sessSearch");
377
+ if (search && search.value) {
378
+ search.value = "";
379
+ renderSessionsDropdown();
380
+ }
381
+ }
382
+ }
383
+ function focusSessSearch() {
384
+ setTimeout(() => {
385
+ const search = document.getElementById("sessSearch");
386
+ if (search) search.focus();
387
+ }, 0);
307
388
  }
308
389
  sessBtn.addEventListener("click", (e) => {
309
390
  e.stopPropagation();
310
391
  document.querySelectorAll("[data-sel]").forEach((s) => s.classList.remove("open"));
311
- setSessOpen(!sessDD.classList.contains("open"));
392
+ const willOpen = !sessDD.classList.contains("open");
393
+ setSessOpen(willOpen);
394
+ if (willOpen) focusSessSearch();
312
395
  });
313
396
 
314
397
  // --- mobile session sub-header -------------------------------------------
@@ -370,7 +453,9 @@ if (sessCloseBtn) sessCloseBtn.addEventListener("click", () => {
370
453
  if (sessNameBtn) sessNameBtn.addEventListener("click", (e) => {
371
454
  e.stopPropagation();
372
455
  document.querySelectorAll("[data-sel]").forEach((s) => s.classList.remove("open"));
373
- setSessOpen(!sessDD.classList.contains("open"));
456
+ const willOpen = !sessDD.classList.contains("open");
457
+ setSessOpen(willOpen);
458
+ if (willOpen) focusSessSearch();
374
459
  });
375
460
  if (typeof window.matchMedia === "function") {
376
461
  const narrowMq = window.matchMedia("(max-width: 760px)");
@@ -627,11 +712,8 @@ async function send() {
627
712
  const images = S.attachments.slice();
628
713
  if (!text && !images.length) return;
629
714
  const feed = liveFeed() || activeFeed();
630
- if (S.state.busy) {
631
- // Don't drop the input silently: tell the user a turn is still running.
632
- if (feed) appendTo(feed, el("div", "warnline", "⚠ " + t("web.input.busy")));
633
- return;
634
- }
715
+ // No client-side busy gate: a message sent mid-turn is queued server-side
716
+ // (`202 {queued: n}`) and runs after the current turn, in order.
635
717
  clearEmpty(feed);
636
718
  finishMd();
637
719
  resetTurnText();
@@ -672,13 +754,18 @@ async function send() {
672
754
  if (images.length) body.images = images;
673
755
  const r = await api("/api/message", { method: "POST", body });
674
756
  if (!r.ok) {
675
- const msg = (r.data && r.data.error) || r.status;
757
+ const err = (r.data && r.data.error) || r.status;
758
+ const msg = err === "queue_full" ? t("web.input.queue_full") : err;
676
759
  appendTo(feed, el("div", "warnline", "⚠ " + msg));
677
760
  } else {
678
761
  if (images.length) clearAttachments();
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);
762
+ if (r.data && r.data.queued) {
763
+ appendTo(feed, el("div", "detail", "↳ " + t("web.input.queued", { n: r.data.queued })));
764
+ } else {
765
+ // Delay the spinner so fast first tokens never flash it: `showBusy`
766
+ // itself skips when a stream/think block already started.
767
+ window.setTimeout(() => showBusy(feed), 180);
768
+ }
682
769
  }
683
770
  }
684
771