Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 0 additions & 3 deletions app/application/template.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,4 @@

{{component this.tooltip tooltipTemplate=this.tooltipTemplate class="container-tooltip" id="tooltip-base" role="tooltip" aria-hidden="false"}}
<BasicDropdownWormhole />
{{#if this.app.webmcpAvailable}}
{{ai-upgrade-review}}
{{/if}}
{{modal-root}}
3 changes: 3 additions & 0 deletions app/authenticated/template.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
{{/unless}}
{{growl-mount}}
<main class="clearfix">
{{#if this.app.webmcpAvailable}}
{{ai-upgrade-review}}
{{/if}}
{{#if (and this.forceUpgrade this.projects.current.needsUpgrade)}}
{{project-upgrade model=this.projects.current}}
{{/if}}
Expand Down
4 changes: 2 additions & 2 deletions app/components/ai-upgrade-review/template.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
<div class="alert alert-danger force-wrap" role="alert" data-testid="ai-resource-authorization-denied">{{t "webmcp.backgroundAuthorizationDenied"}}</div>
{{/if}}
{{#if this.commands.pendingPlan}}
<div class="alert alert-info" role="status" data-testid="ai-upgrade-pending">
{{this.commands.pendingPlan.displayPath}}
<div class="alert alert-info ai-upgrade-pending" role="status" data-testid="ai-upgrade-pending">
<span class="ai-upgrade-pending-target force-wrap">{{this.commands.pendingPlan.displayPath}}</span>
<button type="button" class="btn btn-sm btn-primary" {{action "review"}} data-testid="ai-upgrade-review">{{t "webmcp.reviewPlan"}}</button>
</div>
{{/if}}
34 changes: 34 additions & 0 deletions app/styles/components/_modals.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@use "../base/colors" as colors;

.protip {
font-size: 12px;
margin-bottom: 5px;
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions docs/releases/web-console-1.6.203.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
53 changes: 53 additions & 0 deletions scripts/test-ai-collaboration-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'}};")
Expand Down Expand Up @@ -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 ['<script></script>', '<ScRiPt></ScRiPt>', '<SCRIPT></SCRIPT>']) 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, /<main[^>]*>\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('<section data-route-outlet></section>'));
Handlebars.registerHelper('page-footer', () => new Handlebars.SafeString('<footer data-footer></footer>'));
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 / <ScRiPt>untrusted</sCrIpT>'};
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('&lt;ScRiPt&gt;untrusted&lt;/sCrIpT&gt;'));
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');
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/vendor/ai-upgrade-pending-layout-test.js
Original file line number Diff line number Diff line change
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');

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 = `<!doctype html><html dir="${direction}"><head>
<link rel="stylesheet" href="/assets/vendor.css"><link rel="stylesheet" href="/assets/${asset}">
</head><body class="theme-ui-${asset.startsWith('ui-dark') ? 'dark' : 'light'}" dir="${direction}" style="margin:0"><div id="application">
<main class="clearfix">
<div class="alert alert-danger force-wrap" role="alert" data-testid="ai-resource-authorization-denied"></div>
<div class="alert alert-info ai-upgrade-pending" role="status" data-testid="ai-upgrade-pending">
<span class="ai-upgrade-pending-target force-wrap"></span>
<button type="button" class="btn btn-sm btn-primary" data-testid="ai-upgrade-review"></button>
</div>
<section style="min-height:180px"><h1>Owned fixture route content</h1></section>
</main>
<footer><a href="#" class="btn btn-sm btn-link">About</a><a href="#" class="btn btn-sm btn-link">Help</a>
<div class="pull-right"><div class="dropdown footer-dropdown compose-download inline-block">
<button type="button" class="dropdown-toggle btn btn-sm btn-link" data-footer-cli></button>
</div></div>
<div class="pull-right"><div class="dropdown footer-dropdown language-dropdown inline-block">
<button type="button" class="dropdown-toggle btn btn-sm btn-link lang-select" data-footer-language></button>
</div></div>
</footer>
</div></body></html>`;
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();
}
});
Loading