micro-models-agent 2.15.0 → 2.16.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 +27 -0
- package/dist/assets/app.js +6 -2
- package/dist/assets/index.html +1 -0
- package/dist/assets/js/feed.js +67 -51
- package/dist/assets/js/format.js +8 -0
- package/dist/assets/js/settings.js +74 -8
- package/dist/assets/js/sidebar.js +198 -5
- package/dist/assets/js/tabs.js +105 -18
- package/dist/assets/md.js +14 -0
- package/dist/assets/styles.css +79 -30
- package/dist/i18n/en.json +20 -1
- package/dist/i18n/ru.json +20 -1
- package/dist/main.js +355 -134
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Web UI incoming message queue** (`src/modules/webui/state.ts`, `util.ts`, `forward.ts`, `module.ts`, `server.ts`, `run-agent.ts`, `assets/js/tabs.js`): a message sent mid-turn no longer dies on `409 busy` — it queues (`202 {queued: n}`, cap `MAX_QUEUE_LENGTH = 5`, overflow `429 queue_full`) and runs as the next turn on `turn_end`, in order. Drain reuses the single-flight submit path; the queue clears on server stop; `GET /api/state` and SSE `state` carry `queueLen`. The composer stays editable during a turn with a `↳ queued · position N` receipt (`web.input.queued/queue_full`, en+ru; dead `web.input.busy` removed). Docs: `docs/agents/modules.md`. Tests: `tests/webui-server.test.ts`, `tests/webui-auth.test.ts`.
|
|
12
|
+
|
|
13
|
+
- **Web Trace: diagnostics coverage + full model text** (`src/modules/webui/log-api.ts`, `sessions-api.ts`, `forward.ts`, `state.ts`, `module.ts`, `assets/app.js`, `assets/js/sidebar.js`): Trace shows status rows (SSE `meta`), warn/error logs (SSE `log`, minus info-diagnostic chatter), draft retractions, and index start/done/error — plus critic/MoE/compaction/context-probe/usage/audit/claim-retry/output pulled incrementally from `session.jsonl` on every `turn_end` via the new `GET /api/sessions/:id/log` (`?types=&since=&limit=`, field whitelist, secret scrub, no new producers). `turn_end` carries a server `turnSeq`; text details show the full turn text (honest 20K cap instead of a 4K tail slice) with session/turn and a session-log provenance footer (assistant count, iterations, model/tokens). Git ops stay inside `bash` rows (prefix heuristics too fragile). i18n `web.sidebar.trace_*/detail_*` (en+ru). Tests: `tests/webui-log.test.ts`, e2e `web-boot` (Trace panel + index lifecycle).
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Web file list is always expanded with full root-relative links** (`src/modules/webui/assets/js/feed.js`, `styles.css`): consecutive file rows no longer collapse into an expandable `│ N files` group (its chevron toggle failed to open) — every file tool renders its own `◇ <label> · <full-path-link> [copy] <status>` line, and the link text is the full workspace-relative path (`displayPathFromRoot`, absolute paths stripped by the workspace prefix) instead of basename + dim tail. Consecutive file/action rows are compact (`~4px` via `margin-top: -7px` compensating the `11px` feed gap, `line-height: 1.35`). Group CSS/JS removed. Tests: `tests/webui-assets.test.ts`, `tests/e2e/web-filecard.e2e-test.ts`.
|
|
18
|
+
- **Web transcript edges and answer separation** (`src/modules/webui/assets/styles.css`): scroll edge fades are glued exactly to the transcript edges — vertical breathing moved from the scroller to `.feed` (sticky pseudos are constrained to the padding-free content box, previously leaving a gap under the header and above the composer) and hidden fades take zero flow space (`-33px` margins neutralizing height + flex gap). Finished assistant answers carry a quiet accent rail (`.feed > .md/.ans`, `border-left: 2px solid var(--accent)`, mirroring the user block) so the result reads instantly apart from the dim tool/think process. Tests: `tests/webui-assets.test.ts`.
|
|
19
|
+
- **Success-claim no longer vetoed by plan-bookkeeping failures** (`src/modules/hallucination/success-claim.ts`): `checkSuccessClaim` ignores `plan`/`plan_step`/`todo` evidences — their failures police the agent's own notebook (covered by the final audit), never task outcome. Lived-in case (ses_mupgenhq_75f66l, qwen3.5-9b): an honest "tests passed" was retracted over a stale `re-plan` error ("Длина checks должна совпадать"), forcing a same-substance re-answer after the task was done. Tests: `tests/hallucination-success-claim.test.ts`.
|
|
20
|
+
|
|
21
|
+
- **Web UI boot was dead on 2.15.0** (`src/modules/webui/assets/js/settings.js`): a stray `}` in `setStaleBar` pushed `return` to top level (`Uncaught SyntaxError: Illegal return statement`), killing the whole `app.js` import graph — blank page, no feed, selectors stuck at `–`. One-line brace fix.
|
|
22
|
+
- **Web e2e boot smoke** (`tests/e2e/web-boot.e2e-test.ts`): `mma web` boots in a real Chromium with zero console/page errors (live session tab, chrome skeleton, settings modal) and every served JS module in the live `/app.js` import graph answers 200; `web-helpers.launch` now names collected console errors when the feed never boots, so the next broken module fails with its `SyntaxError` in the message instead of a bare timeout.
|
|
23
|
+
|
|
24
|
+
## [2.16.1] - 2026-10-01
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Smooth streamed-text cascade** (`src/modules/webui/assets/js/feed.js`, `md.js`, `styles.css`): live markdown chunks are buffered and flushed once per animation frame (`requestAnimationFrame`, multi-feed safe) instead of write+scroll per SSE event, and each streamed text run renders in a `span.tk` with a short opacity-only `tkIn` fade (staggered `(n%4)*20ms`, `.15s`). History render and code blocks skip the animation; killed by `prefers-reduced-motion`.
|
|
29
|
+
|
|
30
|
+
## [2.16.0] - 2026-10-01
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **Web dropdown search for models and sessions** (`src/modules/webui/assets/js/settings.js`, `tabs.js`, `format.js`, `index.html`, `styles.css`, `src/i18n/en.json`, `ru.json`): the composer model menu and the sessions dropdown (`☰`) gain a sticky search field — case-insensitive substring (`matchesQuery` in `format.js`; sessions match name + id) with keyboard support (`↓` into the list, `Enter` picks the first hit, `Esc` clears/closes, `↑` back to search). The sessions input lives statically in the markup so the 2s state poll never steals the caret; the model filter survives list re-reads and clears on pick. Empty sections hide with their headers, zero hits show `no matches`; `↻ refresh` / `+ new session` stay outside the filter. New keys `web.selectors.search_models` / `web.sessions.search` (en+ru). Tests: `tests/webui-assets.test.ts`.
|
|
35
|
+
|
|
9
36
|
## [2.15.0] - 2026-10-01
|
|
10
37
|
|
|
11
38
|
### Added
|
package/dist/assets/app.js
CHANGED
|
@@ -23,6 +23,7 @@ initRail, setPlan as railPlan, railEvent, applyRailI18n,
|
|
|
23
23
|
import {
|
|
24
24
|
initSidebar, sidebarSessionChanged, sidebarMapState,
|
|
25
25
|
sidebarTraceEvent, sidebarTurnEnd, sidebarBusy, sidebarMapIndicator,
|
|
26
|
+
sidebarPullLog,
|
|
26
27
|
applySidebarI18n,
|
|
27
28
|
} from "./js/sidebar.js";
|
|
28
29
|
|
|
@@ -141,7 +142,7 @@ const handlers = [
|
|
|
141
142
|
["tool_end", onToolEnd],
|
|
142
143
|
["plan", onPlan],
|
|
143
144
|
["question", onQuestion],
|
|
144
|
-
["turn_end", (d) => { onTurnEnd(d); sidebarTurnEnd(); sidebarBusy(false); }],
|
|
145
|
+
["turn_end", (d) => { onTurnEnd(d); sidebarTurnEnd(d.turnSeq); sidebarBusy(false); sidebarPullLog(); }],
|
|
145
146
|
["log", onLog],
|
|
146
147
|
["retract", (d) => onRetract(d.reason || "", d.sessionId)],
|
|
147
148
|
];
|
|
@@ -161,7 +162,10 @@ for (const ev of ["tool_start", "tool_end", "text", "plan"]) {
|
|
|
161
162
|
});
|
|
162
163
|
}
|
|
163
164
|
// Sidebar Trace: live per-session event log. Feed handlers stay untouched.
|
|
164
|
-
|
|
165
|
+
// Tool/text/phase/plan/question/error stream live; status/log/retract/index
|
|
166
|
+
// ride the same SSE bus; critic/MoE/compaction/probe/usage/audit/claim rows
|
|
167
|
+
// arrive via the session-log supplement Trace pulls on every turn_end.
|
|
168
|
+
for (const ev of ["tool_start", "tool_end", "phase", "plan", "question", "meta", "log", "retract", "index"]) {
|
|
165
169
|
es.addEventListener(ev, (e) => { const d = parse(e); if (d) sidebarTraceEvent(ev, d); });
|
|
166
170
|
}
|
|
167
171
|
es.addEventListener("text", (e) => { const d = parse(e); if (d) sidebarTraceEvent("text", d); });
|
package/dist/assets/index.html
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
<div class="dd" id="sessDD">
|
|
17
17
|
<button class="icon-btn" id="sessBtn" title="all sessions" aria-label="all sessions" aria-haspopup="menu" aria-expanded="false">☰</button>
|
|
18
18
|
<div class="dd-menu">
|
|
19
|
+
<input class="searchbox" id="sessSearch" type="search" autocomplete="off" spellcheck="false" placeholder="search sessions…" aria-label="search sessions">
|
|
19
20
|
<div class="h" id="sessActiveHeader">active</div>
|
|
20
21
|
<div id="sessActiveList"></div>
|
|
21
22
|
<div class="h" id="sessRecentHeader">recent</div>
|
package/dist/assets/js/feed.js
CHANGED
|
@@ -246,16 +246,20 @@ function resetRun() {
|
|
|
246
246
|
|
|
247
247
|
// Options passed to the streaming Markdown renderer: localized code-block
|
|
248
248
|
// copy labels. Highlighting is applied by `md.js` once a fence closes.
|
|
249
|
-
|
|
249
|
+
// `animate` wraps live-streamed text chunks in `span.tk` (CSS `tkIn` fade);
|
|
250
|
+
// history render and code blocks skip it.
|
|
251
|
+
function mdOpts(animate) {
|
|
250
252
|
return {
|
|
251
253
|
copyLabel: t("web.code.copy"),
|
|
252
254
|
copiedLabel: t("web.code.copied"),
|
|
253
255
|
copyFailedLabel: t("web.code.copy_failed"),
|
|
256
|
+
animate: animate === true,
|
|
254
257
|
};
|
|
255
258
|
}
|
|
256
259
|
|
|
257
260
|
// Close the active Markdown segment (if any) and clear the text S.state.
|
|
258
261
|
export function finishMd() {
|
|
262
|
+
flushText();
|
|
259
263
|
if (S.curMd) {
|
|
260
264
|
S.curMd.root.classList.remove("streaming");
|
|
261
265
|
S.curMd.end();
|
|
@@ -264,6 +268,38 @@ export function finishMd() {
|
|
|
264
268
|
S.curText = null;
|
|
265
269
|
}
|
|
266
270
|
|
|
271
|
+
// Text chunks arrive in bursts (several SSE `text` events per frame); writing
|
|
272
|
+
// and scrolling on every one thrashes layout and stacks overlapping `tkIn`
|
|
273
|
+
// fades ("рвано"). Buffer them and flush once per animation frame instead.
|
|
274
|
+
let textBuf = "";
|
|
275
|
+
let textFeed = null;
|
|
276
|
+
let textScheduled = false;
|
|
277
|
+
|
|
278
|
+
function flushText() {
|
|
279
|
+
textScheduled = false;
|
|
280
|
+
if (!textBuf || !S.curMd) {
|
|
281
|
+
textBuf = "";
|
|
282
|
+
textFeed = null;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const buf = textBuf;
|
|
286
|
+
const feed = textFeed;
|
|
287
|
+
textBuf = "";
|
|
288
|
+
textFeed = null;
|
|
289
|
+
S.curMd.write(buf);
|
|
290
|
+
if (feed && feed === activeFeed()) scroll();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function scheduleText() {
|
|
294
|
+
if (textScheduled) return;
|
|
295
|
+
textScheduled = true;
|
|
296
|
+
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
|
297
|
+
window.requestAnimationFrame(flushText);
|
|
298
|
+
} else {
|
|
299
|
+
flushText();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
267
303
|
export function onText(chunk, sid) {
|
|
268
304
|
const feed = feedForEvent(sid);
|
|
269
305
|
clearBusy(feed);
|
|
@@ -275,14 +311,17 @@ export function onText(chunk, sid) {
|
|
|
275
311
|
if (S.curText == null) {
|
|
276
312
|
flushRun();
|
|
277
313
|
S.curText = "";
|
|
278
|
-
S.curMd = mountMarkdown(feed, mdOpts());
|
|
314
|
+
S.curMd = mountMarkdown(feed, mdOpts(true));
|
|
279
315
|
S.curMd.root.classList.add("streaming");
|
|
280
316
|
S.turnRoots.push(S.curMd.root);
|
|
281
317
|
}
|
|
282
318
|
S.curText += chunk;
|
|
283
319
|
S.turnMd += chunk;
|
|
284
|
-
|
|
285
|
-
if (
|
|
320
|
+
// A second session streaming concurrently must not merge into another feed's buffer.
|
|
321
|
+
if (textFeed && textFeed !== feed) flushText();
|
|
322
|
+
textFeed = feed;
|
|
323
|
+
textBuf += chunk;
|
|
324
|
+
scheduleText();
|
|
286
325
|
}
|
|
287
326
|
|
|
288
327
|
export function finishThink() {
|
|
@@ -444,44 +483,23 @@ function safeUrl(url) {
|
|
|
444
483
|
return null;
|
|
445
484
|
}
|
|
446
485
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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;
|
|
486
|
+
// File rows are always expanded: one `filerow` per file-tool call, no
|
|
487
|
+
// collapsing group. Consecutive rows stay a plain expanded list (the old
|
|
488
|
+
// E grouping with a chevron toggle hid the list and failed to open).
|
|
489
|
+
function displayPathFromRoot(path) {
|
|
490
|
+
const raw = String(path == null ? "" : path).replace(/\\/g, "/");
|
|
491
|
+
try {
|
|
492
|
+
const ws = S && S.state && S.state.host && S.state.host.workspace
|
|
493
|
+
? String(S.state.host.workspace).replace(/\\/g, "/").replace(/\/+$/, "")
|
|
494
|
+
: "";
|
|
495
|
+
if (ws && (raw === ws || raw.indexOf(ws + "/") === 0)) {
|
|
496
|
+
const rel = raw === ws ? "" : raw.slice(ws.length + 1);
|
|
497
|
+
if (rel) return rel;
|
|
498
|
+
}
|
|
499
|
+
} catch (err) {
|
|
500
|
+
/* fail-open: show raw path */
|
|
483
501
|
}
|
|
484
|
-
return
|
|
502
|
+
return raw;
|
|
485
503
|
}
|
|
486
504
|
|
|
487
505
|
function cardFor(name, args, sid, ok) {
|
|
@@ -490,22 +508,21 @@ function cardFor(name, args, sid, ok) {
|
|
|
490
508
|
if (FILE_TOOLS.indexOf(name) !== -1) {
|
|
491
509
|
const path = args.path || args.file || args.dir;
|
|
492
510
|
if (!path) return null;
|
|
511
|
+
const shown = displayPathFromRoot(path);
|
|
493
512
|
const row = el("div", "filerow " + categoryOf(name));
|
|
494
513
|
row.appendChild(el("span", "g", "◇"));
|
|
495
514
|
row.appendChild(el("span", "lab", labelOf(name)));
|
|
496
515
|
row.appendChild(el("span", "sep", " · "));
|
|
497
516
|
if (OPENABLE_TOOLS.indexOf(name) !== -1) {
|
|
498
|
-
const openLink = el("a", "path", String(path)
|
|
499
|
-
openLink.href = "/api/file?path=" + encodeURIComponent(path);
|
|
517
|
+
const openLink = el("a", "path", shown || String(path));
|
|
518
|
+
openLink.href = "/api/file?path=" + encodeURIComponent(String(path));
|
|
500
519
|
openLink.target = "_blank";
|
|
501
520
|
openLink.rel = "noopener noreferrer";
|
|
502
|
-
openLink.title = t("web.card.open_title") + " · " + path;
|
|
521
|
+
openLink.title = t("web.card.open_title") + " · " + String(path);
|
|
503
522
|
row.appendChild(openLink);
|
|
504
|
-
const tail = String(path).split(/[\\/]/).slice(0, -1).join("/");
|
|
505
|
-
if (tail) row.appendChild(el("span", "tail", " · " + tail));
|
|
506
523
|
} else {
|
|
507
|
-
const s = el("span", "tail", path);
|
|
508
|
-
s.title = path;
|
|
524
|
+
const s = el("span", "tail", shown || String(path));
|
|
525
|
+
s.title = String(path);
|
|
509
526
|
row.appendChild(s);
|
|
510
527
|
}
|
|
511
528
|
const copyBtn = el("button", "copy-btn");
|
|
@@ -514,7 +531,7 @@ function cardFor(name, args, sid, ok) {
|
|
|
514
531
|
copyBtn.setAttribute("aria-label", t("web.card.copy_title"));
|
|
515
532
|
copyBtn.appendChild(icon("copy"));
|
|
516
533
|
copyBtn.addEventListener("click", () => {
|
|
517
|
-
copyText(path).then((ok) => {
|
|
534
|
+
copyText(String(path)).then((ok) => {
|
|
518
535
|
copyBtn.replaceChildren(icon(ok ? "check" : "copy"));
|
|
519
536
|
if (!ok) { const st = row.querySelector(".st"); if (st) st.textContent = " ⚠"; }
|
|
520
537
|
window.setTimeout(() => copyBtn.replaceChildren(icon("copy")), 1400);
|
|
@@ -523,11 +540,10 @@ function cardFor(name, args, sid, ok) {
|
|
|
523
540
|
row.appendChild(copyBtn);
|
|
524
541
|
row.appendChild(el("span", "st", ok === false ? " ✗" : ok === true ? " ✓" : ""));
|
|
525
542
|
if (ok === false) row.dataset.fail = "1";
|
|
526
|
-
if (/\.drawio(\.xml)?$/i.test(path) && verbosity() === "verbose") {
|
|
543
|
+
if (/\.drawio(\.xml)?$/i.test(String(path)) && verbosity() === "verbose") {
|
|
527
544
|
row.appendChild(el("span", "detail", "↳ " + t("web.card.drawio_hint")));
|
|
528
545
|
}
|
|
529
546
|
appendTo(feed, row);
|
|
530
|
-
try { maybeGroupFilerow(feed, row); } catch (err) { /* fail-open: plain row stays */ }
|
|
531
547
|
return row;
|
|
532
548
|
}
|
|
533
549
|
if (LINK_TOOLS.indexOf(name) !== -1 && args.url) {
|
package/dist/assets/js/format.js
CHANGED
|
@@ -90,6 +90,14 @@ for (const raw of text.split("\n")) {
|
|
|
90
90
|
return "";
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// Case-insensitive substring match for dropdown filters (model / session
|
|
94
|
+
// search). Empty query matches everything; non-strings stringify safely.
|
|
95
|
+
export function matchesQuery(haystack, q) {
|
|
96
|
+
const needle = String(q == null ? "" : q).trim().toLowerCase();
|
|
97
|
+
if (!needle) return true;
|
|
98
|
+
return String(haystack == null ? "" : haystack).toLowerCase().indexOf(needle) !== -1;
|
|
99
|
+
}
|
|
100
|
+
|
|
93
101
|
export function relTime(ts) {
|
|
94
102
|
if (!ts) return "";
|
|
95
103
|
const diff = Date.now() - new Date(ts).getTime();
|
|
@@ -8,7 +8,7 @@ el, icon, link, inlineTextNodes, connStatus, staleBar, makeToggle,
|
|
|
8
8
|
import { api } from "./api.js";
|
|
9
9
|
import { t, loadI18n } from "./i18n.js";
|
|
10
10
|
import { S, setPath, getPath, THEME_KEY, hooks } from "./store.js";
|
|
11
|
-
import { contextLabel } from "./format.js";
|
|
11
|
+
import { contextLabel, matchesQuery } from "./format.js";
|
|
12
12
|
import { closeAllMenus, setSessOpen } from "./tabs.js";
|
|
13
13
|
import { setIndicator, closeConfirm, confirmOverlay, trapTab } from "./dialog.js";
|
|
14
14
|
import { controlFor, PANEL_SECTIONS, keyToPanel } from "./settings-controls.js";
|
|
@@ -48,6 +48,11 @@ function menuAction(label, iconName, onClick) {
|
|
|
48
48
|
return mi;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Live filter text of the composer model dropdown. Kept module-level so a
|
|
52
|
+
// `renderSelectors()` re-read (config/models reload) preserves the query and
|
|
53
|
+
// the input is rebuilt with it; cleared once a model is actually picked.
|
|
54
|
+
let modelFilter = "";
|
|
55
|
+
|
|
51
56
|
export function renderSelectors() {
|
|
52
57
|
const cfg = S.state.config || {};
|
|
53
58
|
const provider = cfg.provider || {};
|
|
@@ -55,13 +60,70 @@ export function renderSelectors() {
|
|
|
55
60
|
setSelButton(document.getElementById("selModelBtn"), t("web.selectors.model"), cfg.model || "—");
|
|
56
61
|
const modelMenu = document.getElementById("selModelMenu");
|
|
57
62
|
modelMenu.textContent = "";
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
// Search row stays outside the filtered list: `drawModelList` only touches
|
|
64
|
+
// `listBox`, so typing never rebuilds (or unfocuses) the input itself.
|
|
65
|
+
const search = document.createElement("input");
|
|
66
|
+
search.id = "selModelSearch";
|
|
67
|
+
search.className = "searchbox";
|
|
68
|
+
search.type = "search";
|
|
69
|
+
search.autocomplete = "off";
|
|
70
|
+
search.spellcheck = false;
|
|
71
|
+
search.placeholder = t("web.selectors.search_models");
|
|
72
|
+
search.setAttribute("aria-label", t("web.selectors.search_models"));
|
|
73
|
+
search.value = modelFilter;
|
|
74
|
+
search.addEventListener("click", (e) => e.stopPropagation());
|
|
75
|
+
modelMenu.appendChild(search);
|
|
76
|
+
const listBox = el("div", "model-menu-list");
|
|
77
|
+
modelMenu.appendChild(listBox);
|
|
78
|
+
const drawModelList = () => {
|
|
79
|
+
listBox.textContent = "";
|
|
80
|
+
const models = S.state.models.filter((m) => matchesQuery(m, modelFilter));
|
|
81
|
+
if (!models.length) {
|
|
82
|
+
listBox.appendChild(el("div", "mi dim", S.state.models.length ? t("web.settings.no_matches") : t("web.selectors.no_models")));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
models.forEach((m) => {
|
|
86
|
+
const item = menuItem(m, "", m === cfg.model, () => {
|
|
87
|
+
modelFilter = "";
|
|
88
|
+
postConfig("model", m, true);
|
|
89
|
+
});
|
|
90
|
+
// ArrowUp on the first row returns focus to the search field.
|
|
91
|
+
item.addEventListener("keydown", (e) => {
|
|
92
|
+
if (e.key === "ArrowUp" && item === listBox.querySelector(".mi:not(.dim)")) {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
e.stopPropagation();
|
|
95
|
+
search.focus();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
listBox.appendChild(item);
|
|
63
99
|
});
|
|
64
|
-
}
|
|
100
|
+
};
|
|
101
|
+
search.addEventListener("input", () => {
|
|
102
|
+
modelFilter = search.value;
|
|
103
|
+
drawModelList();
|
|
104
|
+
});
|
|
105
|
+
search.addEventListener("keydown", (e) => {
|
|
106
|
+
e.stopPropagation();
|
|
107
|
+
if (e.key === "ArrowDown") {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
const first = listBox.querySelector(".mi:not(.dim)");
|
|
110
|
+
if (first) first.focus();
|
|
111
|
+
} else if (e.key === "Enter") {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
const first = listBox.querySelector(".mi:not(.dim)");
|
|
114
|
+
if (first) first.click();
|
|
115
|
+
} else if (e.key === "Escape") {
|
|
116
|
+
if (search.value) {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
search.value = "";
|
|
119
|
+
modelFilter = "";
|
|
120
|
+
drawModelList();
|
|
121
|
+
} else {
|
|
122
|
+
closeAllMenus();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
drawModelList();
|
|
65
127
|
modelMenu.appendChild(el("div", "sep"));
|
|
66
128
|
modelMenu.appendChild(
|
|
67
129
|
menuAction(t("web.selectors.refresh_list"), "refresh", () => {
|
|
@@ -196,6 +258,11 @@ document.querySelectorAll("[data-sel]").forEach((sel) => {
|
|
|
196
258
|
});
|
|
197
259
|
const open = sel.classList.toggle("open");
|
|
198
260
|
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
|
261
|
+
// Opening the model menu lands focus in its search field.
|
|
262
|
+
if (open && sel.dataset && sel.dataset.sel === "model") {
|
|
263
|
+
const search = document.getElementById("selModelSearch");
|
|
264
|
+
if (search) setTimeout(() => search.focus(), 0);
|
|
265
|
+
}
|
|
199
266
|
});
|
|
200
267
|
});
|
|
201
268
|
|
|
@@ -340,7 +407,6 @@ export function setStaleBar(stale, force) {
|
|
|
340
407
|
if (!on) return;
|
|
341
408
|
staleBar.appendChild(el("span", "", "⚠ " + t("web.i18n.stale") + " "));
|
|
342
409
|
staleBar.appendChild(link(t("web.i18n.reload"), "", () => location.reload()));
|
|
343
|
-
}
|
|
344
410
|
return lastStale;
|
|
345
411
|
}
|
|
346
412
|
|