diff options
Diffstat (limited to 'modules/core/core-bundle.js')
| -rw-r--r-- | modules/core/core-bundle.js | 82 |
1 files changed, 53 insertions, 29 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 07be2ce..623bce5 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -4222,7 +4222,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm // later module is refused. Back off and retry instead of losing the // module — measured 2026-11-07, six ui-* files 503'd every load. try { - await new Promise(r => setTimeout(r, 700)); + await new Promise(r => setTimeout(r, 1200)); await this.moduleManager.loadModuleFromURL(url); } catch (e2) { console.warn(`[Ext] ${key} failed to load: ${e2.message}`); @@ -22980,6 +22980,7 @@ class UIShellModule { this._cleanup = null; this._where = null; // [{ el, parent, next }] captured on first mount this._loadSeq = 0; // bumps on every apply(): a stale lazy load must never win + this._loading = {}; // id -> in-flight promise, so a double click loads a mode once this._terminalEl = null; // identity of the one real output node this._cssIds = []; this._pending = null; // mode requested before its ext module loaded @@ -23013,7 +23014,7 @@ class UIShellModule { const q = new URLSearchParams(location.search).get('ui'); // Validate before it reaches classList.add('wx11-ui-' + id): a space or a colon in the // query throws a DOMException there and boot dies with no interface at all. - if (q && /^[a-z][a-z0-9-]{0,15}$/.test(q)) { wanted = q; this._fromQuery = true; } + if (q && this.lazyIds().indexOf(q) !== -1) { wanted = q; this._fromQuery = true; } } catch (e) { /* no URLSearchParams: ignore the request */ } // persist:false — a shared link requests a view, it must not rewrite the user's own default // (the code comment already claimed this; the flag did the opposite). @@ -23024,22 +23025,17 @@ class UIShellModule { // kept as a dead preference forever. this._sweepT = setTimeout(() => this._sweepPending(), 6000); - // First run: show the picker only once the mode modules have actually registered, otherwise - // the user can tap a card before its module exists and get "not loaded". - if (!localStorage.getItem(this.seenKey)) { - let waited = 0; - const tryShow = () => { - if (localStorage.getItem(this.seenKey)) return; - waited += 400; - if (Object.keys(this.modes).length >= 4 || waited >= 4000) { this.showPicker(); return; } - this._pickerT = setTimeout(tryShow, 400); - }; - this._pickerT = setTimeout(tryShow, 800); + // First run: show the picker at once. Waiting for four registered modes was a dead condition — + // the six mode files are lazy now, so nothing registers before the user picks one, and every + // first-run visitor paid a 4.4 s delay for a list that was static anyway. + if (!localStorage.getItem(this.seenKey) && this.current === 'classic' && !this._pending) { + this._pickerT = setTimeout(() => this.showPicker(), 600); } } _sweepPending() { if (!this._pending) return; + if (this._loading[this._pending]) return; // still in flight: the load's own callback decides if (this.modes[this._pending]) return; // registered late: apply() already ran if (this.terminal) { this.terminal.print('Interface mode \'' + this._pending + '\' did not load — staying on ' + @@ -23065,6 +23061,10 @@ class UIShellModule { list() { return Object.keys(this.modes).map(id => this.modes[id]).sort((a, b) => a.level - b.level); } + // The six modes exist even before their files are fetched, so `ui list` / `ui next` / the picker + // must not be limited to what happens to be registered. One list, not four copies. + lazyIds() { return ['easy', 'chat', 'studio', 'workspace', 'commander', 'cockpit']; } + /* ---------- switching ---------- */ apply(id, opts) { const o = opts || {}; @@ -23075,10 +23075,12 @@ class UIShellModule { // A user-initiated switch cancels a pending boot restore, or a slow ext module would later // yank the interface back to whatever boot asked for. if (!o.silent) { this._pending = null; this._closePicker(); } - if (want !== 'classic' && !this.modes[want]) { - // Lazy load. The six mode files are NOT in extKeys: a full eager boot issues ~55 - // requests and trips nginx `limit_req zone=webx11 (60r/m, burst 50)`, which 503s every - // module after the limit — measured 2026-11-07 in the dev copy. + // hasOwnProperty: `this.modes` is a plain object, so ?ui=constructor / __proto__ passed the + // truthiness check and reached the mount path without any mode registering. + if (want !== 'classic' && !Object.prototype.hasOwnProperty.call(this.modes, want)) { + // Lazy load. The six mode files are NOT in extKeys: a full eager boot issues ~40-55 + // requests and the per-IP `limit_req` bucket (then burst=50, now 200) 503'd everything + // after it — measured 2026-11-07. if (o.retried) { if (this.terminal) this.terminal.print(`UI mode '${want}' is not available.`, 'warning'); return false; @@ -23117,6 +23119,8 @@ class UIShellModule { document.body.classList.add('wx11-ui-' + want); if (o.persist !== false) localStorage.setItem(this.key, want); + // _syncChrome on EVERY switch: it hides #quick-actions, which otherwise stays in the tab order + // and the accessibility tree behind the opaque mode root (found by review). if (want === 'classic') { this._hideFooter(); this._syncChrome(); return true; } const api = this.api(); @@ -23132,13 +23136,19 @@ class UIShellModule { // Identity, not just presence: a mode that appends its own <div id="terminal-container"> // would pass a presence check while terminal.js keeps printing into the detached original. - const out = document.getElementById('terminal-container'); - const inDoc = !!out && !!document.body.contains(out); - if (!out || !inDoc || (this._terminalEl && out !== this._terminalEl)) { - this._abortToClassic(want, 'removed or replaced the terminal pane'); + // Identity for EVERY stock node the terminal bound, not just the output pane: terminal.js also + // holds #output, #command-input, #input-area and #status-bar, so a mode that clones one of those + // leaves the terminal writing into a detached node while the page looks fine. + const lost = (this._where || []).filter((w) => { + const live = document.getElementById(w.el.id); + return !live || live !== w.el || !document.body.contains(w.el); + }); + if (lost.length) { + this._abortToClassic(want, 'removed or replaced ' + lost.map((w) => '#' + w.el.id).join(', ')); return false; } this._dockFooter(); + this._syncChrome(); // Every mode hides .prompt-label, so without this the real input has no accessible name. const inp = document.getElementById('command-input'); if (inp && !inp.getAttribute('aria-label')) inp.setAttribute('aria-label', 'Terminal command input'); @@ -23165,7 +23175,7 @@ class UIShellModule { } next() { - const order = ['classic'].concat(this.list().map(m => m.id)); + const order = ['classic'].concat(this.lazyIds()); const i = order.indexOf(this.current); return this.apply(order[(i + 1) % order.length]); } @@ -23174,6 +23184,13 @@ class UIShellModule { // never be served stale after a version bump. Public moduleManager methods only, and no // printing: a mode load must not litter the chat. async _loadMode(id) { + if (this._loading[id]) return this._loading[id]; // one load per id, however many taps + const p = this._loadModeOnce(id).finally(() => { delete this._loading[id]; }); + this._loading[id] = p; + return p; + } + + async _loadModeOnce(id) { const mm = this.terminal && this.terminal.moduleManager; if (!mm) return this._loadFail(id, 'no module manager'); const ver = localStorage.getItem('webx11_cache_ver') || mm.LATEST_VER || '0'; @@ -23183,7 +23200,9 @@ class UIShellModule { const idx = await (await fetch('packages.json?v=' + ver, { cache: 'no-store' })).json(); // Mode ids are bare (`easy`); package keys are prefixed (`ui-easy`). Looking up only the // bare id reported "no package entry" for every mode — measured 2026-11-07. - pkg = idx && idx.packages && (idx.packages['ui-' + id] || idx.packages[id]); + // Only `ui-<id>`: without this a crafted ?ui=calc fetches and initialises an unrelated ext + // module through the mode loader (same-origin, full page access). + pkg = idx && idx.packages && idx.packages['ui-' + id]; } catch (e) { return this._loadFail(id, 'packages.json unreadable: ' + e.message); } @@ -23195,7 +23214,7 @@ class UIShellModule { // load looks exactly like a broken button. let ok = await mm.loadModuleFromURL(url); if (!ok) { - await new Promise(r => setTimeout(r, 900)); + await new Promise(r => setTimeout(r, 1200)); // >= 1 token at 60 r/m before retrying ok = await mm.loadModuleFromURL(url); } if (!ok) return this._loadFail(id, 'loader refused ' + url); @@ -23236,6 +23255,7 @@ class UIShellModule { el, parent: el.parentNode, index: Array.prototype.indexOf.call(el.parentNode.children, el), + next: el.nextSibling || null, // trailing anchor: what followed the last stock node })); } @@ -23248,7 +23268,10 @@ class UIShellModule { const list = this._where.slice().sort((a, b) => a.index - b.index); list.forEach((w) => { if (!w.parent || w.el.parentNode === w.parent) return; // already in place - const anchor = list.find((c) => c.index > w.index && c.el.parentNode === w.parent); + // Anchor on the next stock node already home; for the LAST stock node fall back to the + // sibling that followed it originally, or the group lands after every trailing body child. + const anchor = list.find((c) => c.index > w.index && c.el.parentNode === w.parent) + || (w.next && w.next.parentNode === w.parent ? w.next : null); try { w.parent.insertBefore(w.el, anchor ? anchor.el : null); } catch (e) { /* detached */ } }); } @@ -23302,7 +23325,7 @@ class UIShellModule { // clip, not hidden: `overflow:hidden` still creates a scroll container, and the terminal's // scrollIntoView() on new output scrolled #ui-root by 464px — the title, the action cards // and the composer all left the screen (measured 2026-11-07). `clip` cannot be scrolled. - '#ui-root{position:fixed;inset:0;display:none;overflow:clip;z-index:40;background:var(--bg-color);color:var(--text-color)}', + '#ui-root{position:fixed;inset:0;display:none;overflow:hidden;overflow:clip;z-index:40;background:var(--bg-color);color:var(--text-color)}', // flex column, not block: every mode is a vertical stack (chrome / content / composer / // footer) and a block container makes each child's `flex:1 1 auto` inert — the terminal // collapsed to 0px and the composer sat at the very bottom of a 4 600px flow. @@ -23310,7 +23333,7 @@ class UIShellModule { '#ui-root > *{min-width:0;min-height:0}', // A non-classic mode owns the viewport: the page itself must not scroll, or terminal // output drags the action cards off-screen and the composer never comes back. - 'body[data-ui-mode]:not([data-ui-mode="classic"]){overflow:clip}', + 'body[data-ui-mode]:not([data-ui-mode="classic"]){overflow:hidden;overflow:clip}', 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{overflow:clip}', 'body[data-ui-mode]:not([data-ui-mode="classic"]) #cmd-cards{display:none!important}', // A mode IS the onboarding: six cards behind the classic "Let's Get Started" modal is two @@ -23396,7 +23419,8 @@ class UIShellModule { const node = document.createElement(tag); if (props) for (const k of Object.keys(props)) { if (k === 'text') node.textContent = props[k]; - else if (k === 'html') node.innerHTML = props[k]; + // No `html` sink: zero modes needed it, and it was the only innerHTML entry point in + // the capability API. Dynamic text goes through textContent. else if (k === 'class') node.className = props[k]; else node.setAttribute(k, props[k]); } @@ -23522,7 +23546,7 @@ class UIShellModule { if (sub === 'next') return void this.next(); // A mode that is not registered yet is not an error: it is lazy-loaded on demand. Only an // id that is not one of the six is unknown. - const LAZY = ['easy', 'chat', 'studio', 'workspace', 'commander', 'cockpit']; + const LAZY = this.lazyIds(); if (available.indexOf(sub) === -1 && LAZY.indexOf(sub) === -1) { this.terminal.print(`Unknown mode '${sub}'. Available: ${available.join(', ')}`, 'warning'); return; |