diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md
index 862065b9c0..69a39ee23f 100644
--- a/COMPATIBILITY.md
+++ b/COMPATIBILITY.md
@@ -7,6 +7,11 @@ Default discovery remains ordinary `apiKey`; use `apiKeyRestricted` explicitly.
The assembled Server also needs Engine363 for accurate External Service previews
and the retained native ServiceIndex cleanup repair. Publication and runtime acceptance remain pending.
+Pending-plan and authorization reminders use the authenticated content flow,
+retaining the same explicit WebMCP build ceiling and human review behavior.
+They do not overlay the footer language or CLI controls; long names and review
+labels wrap within the available width, including RTL and narrow layouts.
+
## Web Console 1.6.202 / bridge 0.2.4 published
The matching typed workflows require Engine 0.183.361 and the installed audit
diff --git a/README.md b/README.md
index 7fd5067e5d..5ef2f77631 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,9 @@ Restricted API Keys gain the same name-based discovery and audit-reference flow
as ordinary Keys. Select `apiKeyRestricted` explicitly; the default searches
`apiKey` only. This is partial family coverage, not a merged search or complete
frontend automation. Confirmation errors gain clear permission guidance in all
-thirteen locales, and follow-redirects uses its official 1.16.2 patch. Publication
-and designated QA acceptance remain pending.
+thirteen locales, and follow-redirects uses its official 1.16.2 patch. Pending
+plan reminders stay in authenticated page content so footer controls remain
+available. Publication and designated QA acceptance remain pending.
## Current component
diff --git a/app/application/template.hbs b/app/application/template.hbs
index 3d94aa4d44..6654d0a076 100644
--- a/app/application/template.hbs
+++ b/app/application/template.hbs
@@ -62,7 +62,4 @@
{{component this.tooltip tooltipTemplate=this.tooltipTemplate class="container-tooltip" id="tooltip-base" role="tooltip" aria-hidden="false"}}
-{{#if this.app.webmcpAvailable}}
- {{ai-upgrade-review}}
-{{/if}}
{{modal-root}}
diff --git a/app/authenticated/template.hbs b/app/authenticated/template.hbs
index ccb92af65a..ee41955377 100644
--- a/app/authenticated/template.hbs
+++ b/app/authenticated/template.hbs
@@ -9,6 +9,9 @@
{{/unless}}
{{growl-mount}}
+ {{#if this.app.webmcpAvailable}}
+ {{ai-upgrade-review}}
+ {{/if}}
{{#if (and this.forceUpgrade this.projects.current.needsUpgrade)}}
{{project-upgrade model=this.projects.current}}
{{/if}}
diff --git a/app/components/ai-upgrade-review/template.hbs b/app/components/ai-upgrade-review/template.hbs
index 3bf2cc87ab..70bea9a3aa 100644
--- a/app/components/ai-upgrade-review/template.hbs
+++ b/app/components/ai-upgrade-review/template.hbs
@@ -2,8 +2,8 @@
{{t "webmcp.backgroundAuthorizationDenied"}}
{{/if}}
{{#if this.commands.pendingPlan}}
-
- {{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 = `
+
+