diff options
| -rw-r--r-- | CHANGELOG.md | 6 | ||||
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 82 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/core/module-uishell.js | 80 | ||||
| -rw-r--r-- | modules/core/terminal.js | 2 | ||||
| -rw-r--r-- | modules/ext/ui-cockpit.js | 6 | ||||
| -rw-r--r-- | modules/ext/ui-commander.js | 11 | ||||
| -rw-r--r-- | modules/ext/ui-easy.js | 4 | ||||
| -rw-r--r-- | modules/ext/ui-studio.js | 3 | ||||
| -rw-r--r-- | modules/ext/ui-workspace.js | 5 | ||||
| -rw-r--r-- | modules/test/test-ui-shell.js | 3 |
12 files changed, 142 insertions, 68 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d142ba..3f0a9d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Generated from git history by `scripts/build-changelog.py` — do not edit by ha <!-- generated:start --> +## v4.6.153 — 2026-11-07 + +_Touches CHANGELOG.md (1), main.html (1), modules (1) · project 1_ + +- **V4.6.153 — cache-bust the review fixes** `13271e5` + ## v4.6.152 — 2026-11-07 _Touches modules (18), CHANGELOG.md (2), README.md (1), help.html (1) · docs 2, fix 2, release 1_ @@ -1350,13 +1350,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.153"></script> +<script src="modules/core/core-bundle.js?v=v4.6.155"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.153"></script> +<script src="modules/core/module-loader.js?v=v4.6.155"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.153"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.155"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ 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; diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 7caa874..7f9ffe8 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.153'; + const LATEST_VER = 'v4.6.155'; const CACHE = '?v=' + LATEST_VER; // Exposed so ext modules can register their own URL with the same cache-busting version // instead of an unversioned path the browser may already have cached (shell.js did). diff --git a/modules/core/module-uishell.js b/modules/core/module-uishell.js index 9914ec0..11bad38 100644 --- a/modules/core/module-uishell.js +++ b/modules/core/module-uishell.js @@ -19,6 +19,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 @@ -52,7 +53,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). @@ -63,22 +64,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 ' + @@ -104,6 +100,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 || {}; @@ -114,10 +114,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; @@ -156,6 +158,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(); @@ -171,13 +175,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'); @@ -204,7 +214,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]); } @@ -213,6 +223,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'; @@ -222,7 +239,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); } @@ -234,7 +253,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); @@ -275,6 +294,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 })); } @@ -287,7 +307,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 */ } }); } @@ -341,7 +364,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. @@ -349,7 +372,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 @@ -435,7 +458,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]); } @@ -561,7 +585,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; diff --git a/modules/core/terminal.js b/modules/core/terminal.js index 6bc23b4..f0259fc 100644 --- a/modules/core/terminal.js +++ b/modules/core/terminal.js @@ -1533,7 +1533,7 @@ // 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}`); diff --git a/modules/ext/ui-cockpit.js b/modules/ext/ui-cockpit.js index 92dd60b..73fbe9f 100644 --- a/modules/ext/ui-cockpit.js +++ b/modules/ext/ui-cockpit.js @@ -62,6 +62,7 @@ body[data-ui-mode="cockpit"] #ui-root { display: grid; grid-template-rows: 1fr 1 defs.forEach((d, i) => { const pane = api.el('div', { class: 'pane' }); pane.dataset.pane = String(i + 1); + pane.tabIndex = -1; // so F1-F8 can actually move focus, not just scroll const h = api.el('h5'); h.appendChild(api.el('span', { text: 'F' + (i + 1) + ' ' + d.label })); h.appendChild(api.el('span', { class: 'n' })); @@ -85,7 +86,9 @@ body[data-ui-mode="cockpit"] #ui-root { display: grid; grid-template-rows: 1fr 1 // Generation guard: eight async captures per tick at 5s — a tick can outlive teardown. let gen = 0; const refresh = async () => { - const my = ++gen; + // Same as workspace: bumping per tick cancelled a slow run before the later panes were + // written, leaving them at "…" indefinitely. + const my = gen; for (const d of defs) { try { const text = await api.capture(d.cmd); @@ -117,6 +120,7 @@ body[data-ui-mode="cockpit"] #ui-root { display: grid; grid-template-rows: 1fr 1 if (idx === 5) { refresh(); return; } // F5 = refresh, as labelled const pane = panes.querySelector('[data-pane="' + idx + '"]'); if (!pane) return; + if (pane.focus) pane.focus(); pane.scrollIntoView({ block: 'nearest' }); pane.style.outline = '1px solid var(--accent-color)'; setTimeout(() => { pane.style.outline = ''; }, 900); diff --git a/modules/ext/ui-commander.js b/modules/ext/ui-commander.js index 4ffdc8b..03af5be 100644 --- a/modules/ext/ui-commander.js +++ b/modules/ext/ui-commander.js @@ -82,7 +82,16 @@ body[data-ui-mode="commander"] #ui-root { display: grid; grid-template-columns: const name = String(entry.name); const isDir = entry.type === 'dir' || entry._virtual; const it = api.el('div', { class: 'item', text: (isDir ? '📁 ' : '📄 ') + name }); - it.addEventListener('click', () => api.exec((isDir ? 'ls ' : 'cat ') + name)); + // Quote the path: a VFS name with a space split into two arguments, and a leading `-` + // became an option flag (found by review). + const quoted = "'" + String(name).replace(/'/g, "'\\''") + "'"; + const run = () => api.exec((isDir ? 'ls ' : 'cat ') + quoted); + it.addEventListener('click', run); + it.tabIndex = 0; + it.setAttribute('role', 'button'); + it.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); run(); } + }); left.appendChild(it); }); } catch (e) { left.appendChild(api.el('div', { class: 'item', text: '(' + e.message + ')' })); } diff --git a/modules/ext/ui-easy.js b/modules/ext/ui-easy.js index eddf11d..cd1388a 100644 --- a/modules/ext/ui-easy.js +++ b/modules/ext/ui-easy.js @@ -16,7 +16,9 @@ class UIEasyMode { mount(api) { api.injectCSS('easy', ` body[data-ui-mode="easy"] { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; } -body[data-ui-mode="easy"] #ui-root { display: flex; flex-direction: column; padding: 14px 14px 0; gap: 12px; } +/* padding-bottom 34px, not 0: #ui-foot is absolutely positioned and 34px tall, and the shorthand + reset the bottom padding the base sheet reserves, so the footer sat on top of the composer. */ +body[data-ui-mode="easy"] #ui-root { display: flex; flex-direction: column; padding: 14px 14px 34px; gap: 12px; } #easy-top { flex: 0 0 auto; } #easy-top h1 { font-size: 1.25em; margin: 0 0 4px; font-weight: 600; } #easy-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 12px; } diff --git a/modules/ext/ui-studio.js b/modules/ext/ui-studio.js index adda6a1..e8341f3 100644 --- a/modules/ext/ui-studio.js +++ b/modules/ext/ui-studio.js @@ -124,7 +124,8 @@ body[data-ui-mode="studio"] #ui-root { display: grid; grid-template-columns: 1.1 const target = filePath || '/notes.md'; const btn = api.el('button', { class: 'wx11-btn', text: '📝 Open the editor for ' + target }); btn.addEventListener('click', () => { - api.exec('edit ' + target); + // Quoted: a filename with a space would arrive as two arguments (found by review). + api.exec("edit '" + String(target).replace(/'/g, "'\\''") + "'"); api.toast('Editor takes over the chat pane — close it to return'); }); bodyEl.appendChild(btn); diff --git a/modules/ext/ui-workspace.js b/modules/ext/ui-workspace.js index be7a5e0..ee20aac 100644 --- a/modules/ext/ui-workspace.js +++ b/modules/ext/ui-workspace.js @@ -107,7 +107,10 @@ body[data-ui-mode="workspace"] #ui-root { display: flex; flex-direction: column; // a detached tree (invisible work, retained DOM). Every refresh claims a generation. let gen = 0; const refresh = async () => { - const my = ++gen; + // Do NOT bump the generation on every tick: a capture slower than the 15 s interval + // invalidated the in-flight run before it reached the later cards, so those stayed at + // "…" forever under slow storage (found by review). Teardown still bumps gen to stop it. + const my = gen; let changed = 0; for (const c of cards) { try { diff --git a/modules/test/test-ui-shell.js b/modules/test/test-ui-shell.js index aced2e0..530e2e4 100644 --- a/modules/test/test-ui-shell.js +++ b/modules/test/test-ui-shell.js @@ -109,7 +109,8 @@ const printed = []; const fakeTerminal = { print: (m, t) => printed.push({ m, t }), capturePipeOutput: async () => 'captured', - _parseArgs: s => String(s).trim().split(/\s+/).filter(Boolean) + // Deliberately absent: the production terminal has NO _parseArgs, and api.capture() must never + // call one (that regression blanked every telemetry panel). A stub here made A9 green anyway. }; const shell = new UIShellModule(); |