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 @@ {{/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 = ` + +
+
+ +
+ + +
+

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(); + } +});