- {{this.commands.pendingPlan.displayPath}}
+
+ {{this.commands.pendingPlan.displayPath}}
{{/if}}
diff --git a/app/styles/components/_modals.scss b/app/styles/components/_modals.scss
index b931a8fb39..778141ac3d 100644
--- a/app/styles/components/_modals.scss
+++ b/app/styles/components/_modals.scss
@@ -1,3 +1,5 @@
+@use "../base/colors" as colors;
+
.protip {
font-size: 12px;
margin-bottom: 5px;
@@ -76,6 +78,38 @@
}
}
+.ai-upgrade-pending {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+
+ .ai-upgrade-pending-target {
+ color: colors.$blackDark;
+ flex: 1 1 16rem;
+ min-width: 0;
+ overflow-wrap: anywhere;
+ }
+
+ > button {
+ flex: 0 0 auto;
+ max-width: 100%;
+ white-space: normal;
+ overflow-wrap: anywhere;
+
+ &:focus-visible {
+ outline: 2px solid colors.$blackDark;
+ outline-offset: 2px;
+ }
+ }
+
+ .theme-ui-dark & > button {
+ color: colors.$blackDark;
+ border-color: colors.$blackDark;
+ }
+}
+
.ai-resource-diff-value {
white-space: pre-wrap;
overflow-wrap: anywhere;
diff --git a/docs/releases/web-console-1.6.203.md b/docs/releases/web-console-1.6.203.md
index 585d038265..35e605b286 100644
--- a/docs/releases/web-console-1.6.203.md
+++ b/docs/releases/web-console-1.6.203.md
@@ -38,3 +38,17 @@ following cross-origin redirects. The npm12 audit parser separately recognizes
lock-proven aliases and exact duplicate causes while retaining distinct advisory
source/range, raw findings and all existing High/Critical/review-expiry gates.
This is not a new vulnerability exception or a claim of zero Moderate findings.
+
+## Shared pending-review layout
+
+The global pending-plan reminder could cover the footer after its review modal
+was closed, preventing normal clicks on language and CLI controls. The same
+explicitly enabled review component now lives once in authenticated page
+content. Its existing review observer, manual approval action, escaped text and
+global modal lifecycle remain intact. Scoped wrapping keeps long target paths
+and translated buttons within the page in narrow and RTL layouts. Background
+authorization notices retain their existing alert semantics.
+
+Source-level and isolated layout checks do not replace fresh archive and
+designated browser acceptance. The earlier qualified 27ba candidate is retained
+as superseded evidence; no tag or release was published from it.
diff --git a/scripts/test-ai-collaboration-ui.js b/scripts/test-ai-collaboration-ui.js
index 307043e896..d8b5118bad 100644
--- a/scripts/test-ai-collaboration-ui.js
+++ b/scripts/test-ai-collaboration-ui.js
@@ -19,6 +19,7 @@ function sourceUrl(filename) {
.replace(/import \{ get, computed \} from '@ember\/object';/g, 'const computed = (...args) => args.at(-1); const get = (row, key) => key.split(".").reduce((value, part) => value && value[part], row);')
.replace(/import \{ reads \} from '@ember\/object\/computed';/g, 'const reads = () => null;')
.replace(/import \{ observer \} from '@ember\/object';/g, 'const observer = (...args) => args.at(-1);')
+ .replace(/import \{ scheduleOnce \} from '@ember\/runloop';/g, 'const scheduleOnce = (_queue, object, callback) => callback.call(object);')
.replace(/import \{ alias \} from '@ember\/object\/computed';/g, 'const alias = () => null;')
.replace(/import ModalBase from 'lacsso\/components\/modal-base';/g, 'const ModalBase = {extend: value => value};')
.replace(/import config from 'ui\/config\/environment';/g, "const config = {APP: {webmcpMode: 'enabled'}};")
@@ -75,6 +76,58 @@ async function main() {
await body(); count++; process.stdout.write(`PASS ${name}\n`);
}
+ await test('pending reminder mounts once in authenticated content under the original build ceiling and retains normal review', async () => {
+ const Handlebars = require('handlebars').create();
+ const {preprocess, traverse} = require('@glimmer/syntax');
+ function assertNoScriptElement(html) {
+ traverse(preprocess(html), {ElementNode(node) {
+ assert.notEqual(node.tag.toLowerCase(), 'script', 'rendered reminder cannot contain a script element');
+ }});
+ }
+ for (const html of ['', '', '']) assert.throws(() => assertNoScriptElement(html), assert.AssertionError);
+ const application = fs.readFileSync(path.join(app, 'application/template.hbs'), 'utf8');
+ const authenticated = fs.readFileSync(path.join(app, 'authenticated/template.hbs'), 'utf8');
+ const reminder = fs.readFileSync(path.join(app, 'components/ai-upgrade-review/template.hbs'), 'utf8');
+ assert.equal((application.match(/\{\{ai-upgrade-review\}\}/g) || []).length, 0);
+ assert.equal((authenticated.match(/\{\{ai-upgrade-review\}\}/g) || []).length, 1);
+ assert.match(authenticated, /
]*>\s*\{\{#if this\.app\.webmcpAvailable\}\}\s*\{\{ai-upgrade-review\}\}\s*\{\{\/if\}\}/);
+ const injection = fs.readFileSync(path.join(app, 'initializers/app.js'), 'utf8');
+ assert.match(injection, /Controller\.reopen\(\{ app: ownerLookup\('application:main'\) \}\)/);
+ let mounts = 0;
+ Handlebars.registerHelper('t', key => key);
+ Handlebars.registerHelper('action', () => '');
+ Handlebars.registerHelper('and', (a, b) => a && b);
+ for (const name of ['page-header', 'growl-mount', 'project-upgrade', 'console-workspace']) Handlebars.registerHelper(name, () => '');
+ Handlebars.registerHelper('outlet', () => new Handlebars.SafeString(''));
+ Handlebars.registerHelper('page-footer', () => new Handlebars.SafeString(''));
+ const renderReminder = Handlebars.compile(reminder);
+ Handlebars.registerHelper('ai-upgrade-review', function() { mounts++; return new Handlebars.SafeString(renderReminder(this)); });
+ const render = Handlebars.compile(authenticated);
+ const pendingPlan = {planRef: 'same-plan', displayPath: 'Default / '};
+ for (const [available, pending, notice] of [[false, pendingPlan, null], [true, null, null], [true, pendingPlan, null], [true, null, {code: 'ResourceChangeAuthorizationDenied'}]]) {
+ mounts = 0;
+ const html = render({app: {webmcpAvailable: available}, commands: {pendingPlan: pending, operationNotice: notice}});
+ assert.equal(mounts, available ? 1 : 0);
+ assert.equal(html.includes('data-testid="ai-upgrade-pending"'), !!(available && pending));
+ assert.equal(html.includes('data-testid="ai-resource-authorization-denied"'), !!(available && notice));
+ assertNoScriptElement(html);
+ if (available && pending) {
+ assert.ok(html.indexOf('data-testid="ai-upgrade-pending"') < html.indexOf('data-route-outlet'));
+ assert.ok(html.includes('<ScRiPt>untrusted</sCrIpT>'));
+ assert.ok(html.indexOf('data-testid="ai-upgrade-review"') < html.indexOf('data-footer'));
+ }
+ }
+ const {default: bridge} = await import(sourceUrl(path.join(app, 'components/ai-upgrade-review/component.js')));
+ for (const destroyed of [false, true, false]) {
+ const calls = [], context = {get, commands: {pendingPlan}, isDestroyed: destroyed,
+ modalService: {modalVisible: false, toggleModal: (...args) => calls.push(args)}};
+ context.openReview = () => bridge.openReview.call(context);
+ bridge.actions.review.call(context);
+ assert.equal(calls.length, destroyed ? 0 : 1, 'auth exit cannot open a stale review; re-entry can review the same pending plan');
+ if (!destroyed) assert.deepEqual(calls[0], ['modal-confirm-ai-upgrade', {plan: pendingPlan}]);
+ }
+ });
+
await test('mode controls cannot exceed the build ceiling', async () => {
assert.equal(boundedCollaborationMode('off', 'enabled'), 'off');
assert.equal(boundedCollaborationMode('read-only', 'enabled'), 'read-only');
diff --git a/tests/unit/vendor/ai-upgrade-pending-layout-test.js b/tests/unit/vendor/ai-upgrade-pending-layout-test.js
new file mode 100644
index 0000000000..b8020a8f29
--- /dev/null
+++ b/tests/unit/vendor/ai-upgrade-pending-layout-test.js
@@ -0,0 +1,74 @@
+import { module, test } from 'qunit';
+
+module('Unit | Vendor | AI pending review content layout');
+
+const copies = [
+ {locale: 'en-us', review: 'Review pending plan', language: 'English', cli: 'Download CLI'},
+ {locale: 'zh-tw', review: '檢閱待核准計畫', language: '繁體中文', cli: '下載命令列工具'},
+ {locale: 'fa-ir', review: 'بررسی طرح در انتظار تأیید', language: 'فارسی', cli: 'بارگیری ابزار خط فرمان'},
+ {locale: 'de-de', review: 'Vorgesehenen und noch nicht genehmigten Plan überprüfen', language: 'Deutsch', cli: 'Befehlszeilenprogramm herunterladen'},
+];
+const escaped = value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
+
+function layoutFrame(asset, direction) {
+ return new Promise((resolve, reject) => {
+ const frame = document.createElement('iframe');
+ const timer = setTimeout(() => reject(new Error(`Timed out loading ${asset}`)), 10000);
+ frame.style.cssText = 'position:absolute;top:0;left:0;width:1440px;height:1000px;opacity:0;pointer-events:none;border:0';
+ frame.onload = () => { clearTimeout(timer); resolve(frame); };
+ frame.srcdoc = `
+
+
+
+
+
+
+
+
+ Owned fixture route content
+
+
+
`;
+ document.getElementById('qunit-fixture').appendChild(frame);
+ });
+}
+
+test('pending and authorization notices stay in content flow and leave footer controls actionable in narrow RTL layouts', async function(assert) {
+ for (const theme of ['light', 'dark']) for (const direction of ['ltr', 'rtl']) {
+ const asset = `ui-${theme}${direction === 'rtl' ? '.rtl' : ''}.css`;
+ const frame = await layoutFrame(asset, direction), page = frame.contentDocument;
+ const notice = page.querySelector('[data-testid="ai-upgrade-pending"]');
+ assert.strictEqual(frame.contentWindow.getComputedStyle(notice).display, 'flex', `${asset}: current component stylesheet loaded`);
+ for (const width of [1440, 390, 280]) {
+ frame.style.width = `${width}px`;
+ await new Promise(resolve => frame.contentWindow.requestAnimationFrame(resolve));
+ for (const copy of copies) for (const longPath of [false, true]) {
+ page.documentElement.lang = copy.locale;
+ page.querySelector('.ai-upgrade-pending-target').innerHTML = escaped(longPath ? ('Default / owned-' + 'verylongunbrokenname'.repeat(40)).slice(0, 768) : 'Default / owned-fixture');
+ page.querySelector('[data-testid="ai-resource-authorization-denied"]').textContent = `${copy.review}: previous background operation was denied; keep the original receipt.`;
+ page.querySelector('[data-testid="ai-upgrade-review"]').textContent = copy.review;
+ page.querySelector('[data-footer-language]').textContent = copy.language;
+ page.querySelector('[data-footer-cli]').textContent = copy.cli;
+ const label = `${asset}/${width}/${copy.locale}/${longPath ? 'long' : 'short'}`;
+ const bounds = notice.getBoundingClientRect(), review = page.querySelector('[data-testid="ai-upgrade-review"]').getBoundingClientRect();
+ assert.ok(bounds.left >= -1 && bounds.right <= width + 1 && notice.scrollWidth <= notice.clientWidth + 1, `${label}: reminder fits without horizontal overflow`);
+ assert.ok(review.left >= bounds.left && review.right <= bounds.right + 1, `${label}: translated review button stays inside reminder`);
+ assert.ok(review.top >= bounds.top && review.bottom <= bounds.bottom + 1, `${label}: wrapped review button stays inside reminder vertically`);
+ assert.ok(bounds.bottom <= page.querySelector('footer').getBoundingClientRect().top + 1, `${label}: reminder clears footer`);
+ for (const selector of ['[data-footer-language]', '[data-footer-cli]']) {
+ const control = page.querySelector(selector); control.scrollIntoView({block: 'center'});
+ const box = control.getBoundingClientRect(), hit = page.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2);
+ assert.ok(box.left >= -1 && box.right <= width + 1 && hit && control.contains(hit), `${label}: ${selector} receives normal pointer input`);
+ }
+ }
+ }
+ frame.remove();
+ }
+});