Organization unavailable
${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}
${esc(s?.error??'Autonomy could not be read.')}
Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.
+ ${s.writable?`Read the decision trail and its reversals before recording your review.
${classes.map(k=>``).join('')}.
+ const prose = value => txt(value).replace(/`([^`\n]+)`/g, '$1');
const states = new Map(), forms = new Map(), knownRequests = new Map();
const pinnedScrolls = new WeakMap(), settlingPins = new WeakMap();
const pageFlow = log => log.closest('.project-chat') && matchMedia('(max-width: 319px), (max-height: 540px)').matches;
@@ -161,12 +164,12 @@
function messageBody(m) {
if (m.from_role !== 'human' && typeof m.body === 'string' && m.body.startsWith('The turn failed: ')) return `The assistant couldn't reply. Try again, or check Agents.Details: ${txt(m.body.slice('The turn failed: '.length))}`;
const r = renderReply?.(m.body, { check: m.check ?? null, name: m.from_role, changed: changedBy(m.check) });
- if (r?.kind === 'reply') return `${txt(r.text)}${r.citations.length ? cites(r.citations) : ''}`;
+ if (r?.kind === 'reply') return `${prose(r.text)}${r.citations.length ? cites(r.citations) : ''}`;
if (r?.kind === 'notice') {
const v = reverts.get(r.commit) ?? {};
return `${txt(r.text)} ${current.archiveUnknown || current.projectArchived || window.MosaicOrg ? '' : ``} ${txt(v.receipt ?? '')}`;
}
- return `${txt(m.body)}`;
+ return `${prose(m.body)}`;
}
const ago = iso => {
const minutes = Math.floor((Date.now() - Date.parse(iso)) / 60000);
diff --git a/packages/webui/src/public/org-create.css b/packages/webui/src/public/org-create.css
index 8d55f2eb9..661041850 100644
--- a/packages/webui/src/public/org-create.css
+++ b/packages/webui/src/public/org-create.css
@@ -6,6 +6,7 @@
.org-field-err{display:block;font-size:13px;color:var(--danger);margin:4px 0 0}
.org-field-err code,.org-errors code{overflow-wrap:anywhere}
.org-verb code{overflow-wrap:normal;word-break:normal}
+.cmd-word{display:inline-block;max-width:100%;overflow-wrap:anywhere}
.org-start-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:24px;align-items:start}
.org-inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 8px;align-items:center}
.org-inline label{grid-column:1/-1;font-size:14px;font-weight:600;color:var(--strong)}
diff --git a/packages/webui/src/public/org-create.js b/packages/webui/src/public/org-create.js
index fdb00aa07..7fd0faedd 100644
--- a/packages/webui/src/public/org-create.js
+++ b/packages/webui/src/public/org-create.js
@@ -4,6 +4,9 @@
(() => {
const $ = id => document.getElementById(id);
const txt = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
+ // E2E6 minor: a command shown as "Same as" wraps between words, never inside a flag such as
+ // --if-revision. Each word is an inline-block; one longer than the line still breaks inside itself.
+ const cmd = v => `${String(v ?? '').split(' ').map(w => `${txt(w)}`).join(' ')}`;
// #1873 P1-6: the remote check a repository receipt records, in one plain line.
const CHECKED = { reachable: 'reachable', 'needs-credential': 'needs a credential', unreachable: 'unreachable' };
const checkLine = r => `Remote check: ${CHECKED[r.check.result] ?? 'unknown'}, ${r.check.message}. Origin: ${r.origin ?? 'none'}.`;
@@ -253,7 +256,7 @@
$('org-archive').hidden = mode !== 'edit' || state !== 'active'; $('org-archived').hidden = mode !== 'edit' || state !== 'archived';
$('org-restore-verb').textContent = state === 'archived' ? `Same as mosaic org restore ${id} --if-revision ${revision}` : '';
$('org-editor-title').textContent = mode === 'create' ? `New organization: ${id}` : `Organization: ${id}`;
- $('org-editor-verb').innerHTML = verb ? `Same as ${txt(verb)}` : '';
+ $('org-editor-verb').innerHTML = verb ? `Same as ${cmd(verb)}` : '';
$('org-save').textContent = mode === 'create' ? 'Save organization' : 'Save changes';
$('org-errors').hidden = true;
renderForm(); renderJSON(true);
@@ -318,7 +321,7 @@
{ title: 'Initialize project records', verb: `mosaic project init ${id} ${project}` },
];
const list = $('org-quick-steps');
- list.innerHTML = steps.map((s, i) => `${txt(s.title)}
Not startedSame as ${txt(s.verb)}
`).join('');
+ list.innerHTML = steps.map((s, i) => `${txt(s.title)}
Not startedSame as ${cmd(s.verb)}
`).join('');
const run = $('org-quick-run'); run.disabled = true; $('org-quick-open').disabled = true; $('org-advanced-open').disabled = true; $('org-preset').disabled = true; $('org-thinking').disabled = true; banner('');
let current = 0;
try {
@@ -404,7 +407,7 @@
}
function showReceipt(title, body) {
const box = $('org-receipt');
- box.innerHTML = `${txt(title)}
Same as ${txt(body.verb)}
${txt(JSON.stringify(body.receipt, null, 2))}`;
+ box.innerHTML = `${txt(title)}
Same as ${cmd(body.verb)}
${txt(JSON.stringify(body.receipt, null, 2))}`;
box.hidden = false; status(`${title}. Receipt shown.`);
}
async function refreshList() {
@@ -454,7 +457,7 @@
const c = st.choice.get(key) || {}, remote = c.mode === 'remote';
return `
${i + 1} ${txt(title)}
${txt(stateLabel)}
- Same as ${txt(s.verb || '')}
+ Same as ${cmd(s.verb || '')}
${s.message ? `${txt(s.message)}
` : ''}
${key.startsWith('project:') && s.receipt?.check ? `${txt(checkLine(s.receipt))}
` : ''}
${s.lastError ? `${txt(s.lastError)}
` : ''}
@@ -570,7 +573,7 @@
: `The bus did not start: ${bus.message || 'no reason was given.'}`;
const offer = (!known || bus.outcome !== 'done') && canWrite;
const box = $('org-archive-success');
- box.innerHTML = `Organization restored. ${txt(line)}
${known && bus.verb ? `Same as ${txt(bus.verb)}
` : ''}${offer ? `
` : ''}`;
+ box.innerHTML = `Organization restored. ${txt(line)}
${known && bus.verb ? `Same as ${cmd(bus.verb)}
` : ''}${offer ? `
` : ''}`;
box.hidden = false; box.focus();
}
// Restart bus for the restored organization, the same route as the dashboard's (console-restart.js).
@@ -620,13 +623,13 @@
if (r.status !== 200) {
$('org-delete-error').textContent = `The outcome is unknown. ${r.body.message || `Deletion stopped (${r.body.error || r.status}).`} Inspect the receipt and resume with the CLI.`;
const receipt = r.body.receipt;
- $('org-delete-progress').innerHTML = `Same as ${txt(r.body.verb || `mosaic org delete ${id} --confirm ${id}`)}
- Bus: ${receipt?.bus ? 'Done' : r.body.step === 'bus' ? 'Stopped' : 'Not started'}
- Local files: ${receipt?.moves?.length || 0} moved; ${txt(r.body.step || 'deletion')} stopped
- Registry: ${receipt?.registry === 'removed' ? 'Done' : 'Not complete'}
`;
+ $('org-delete-progress').innerHTML = `Same as ${cmd(r.body.verb || `mosaic org delete ${id} --confirm ${id}`)}
- Bus: ${receipt?.bus ? 'Done' : r.body.step === 'bus' ? 'Stopped' : 'Not started'}
- Local files: ${receipt?.moves?.length || 0} moved; ${txt(r.body.step || 'deletion')} stopped
- Registry: ${receipt?.registry === 'removed' ? 'Done' : 'Not complete'}
`;
status('Deletion stopped.'); return;
}
if (!verifiedReply(r, 'delete')) throw Error('unknown deletion outcome');
editorDirty = false; setupDirty.clear(); $('org-editor').hidden = true; $('org-setup').hidden = true; $('org-delete').hidden = true; $('org-archive').hidden = true; $('org-archived').hidden = true;
const box = $('org-delete-success');
- box.innerHTML = `Organization deleted
Same as ${txt(r.body.verb)}
- Bus stopped or was already stopped: Done
- Local files moved to the deleted folder: Done
- Organization removed from the list: Done
Receipt
${txt(JSON.stringify(r.body.receipt, null, 2))}`;
+ box.innerHTML = `Organization deleted
Same as ${cmd(r.body.verb)}
- Bus stopped or was already stopped: Done
- Local files moved to the deleted folder: Done
- Organization removed from the list: Done
Receipt
${txt(JSON.stringify(r.body.receipt, null, 2))}`;
const rows = await refreshOrganizations(); box.hidden = false; box.focus(); status('Organization deleted.');
switchIfCurrentRemoved(id, rows);
} catch { $('org-delete-error').textContent = 'The outcome is unknown. Inspect the receipt and resume with the CLI.'; $('org-delete-progress').innerHTML = `Resume with ${txt(`mosaic org delete ${id} --confirm ${id}`)}
`; }
diff --git a/packages/webui/src/public/packs.css b/packages/webui/src/public/packs.css
index 919458330..3c992b7a5 100644
--- a/packages/webui/src/public/packs.css
+++ b/packages/webui/src/public/packs.css
@@ -3,6 +3,10 @@
:is(.pack-row,.pack-hire,.wf-item){border-top:1px solid var(--border);padding:12px 0;overflow-wrap:anywhere}
.pack-preview{border:1px solid var(--border);border-radius:var(--r);padding:12px;margin:12px 0;overflow-wrap:anywhere}
.pack-preview .ws-tbl{overflow-x:auto}
+/* E2E6 P2-3: the preview's overflow-wrap:anywhere let the table squeeze words ("Workfl ow"). Cells wrap
+ at word breaks; only a path, which has none, may break anywhere. */
+.pack-preview .ws-tbl :is(td,th){overflow-wrap:normal;word-break:normal}
+.pack-preview .ws-tbl td code{overflow-wrap:anywhere}
.pack-nohire,.wf-missing,.wf-note{color:var(--danger)}
.wf-missing{font-size:.9em}
.wf-steps{margin:6px 0;padding-left:22px}.wf-steps li{margin:4px 0}
diff --git a/packages/webui/src/public/packs.js b/packages/webui/src/public/packs.js
index 3a9777614..cd7f1db3f 100644
--- a/packages/webui/src/public/packs.js
+++ b/packages/webui/src/public/packs.js
@@ -39,8 +39,11 @@
if (!r.ok) throw Error(guidance[value?.error] ?? `The bus refused: ${value?.error ?? 'outcome unknown'}.`);
return value;
}
+ // E2E6 P1-1, P2-2: a poll reloads the list but keeps the open preview, the project picked for each
+ // pack and the last receipt. A kept preview applies by its digest, so a stale one is refused by the bus.
async function load(org) {
- const s = { packs: null, writable: false, error: null, projects: null, preview: null, status: '' };
+ const prev = states.get(org);
+ const s = { packs: null, writable: false, error: null, projects: null, preview: prev?.preview ?? null, status: prev?.status ?? '', picks: prev?.picks ?? {} };
try {
const value = await call(`${base(org)}/packs`);
s.packs = Array.isArray(value?.packs) ? value.packs.filter(p => p && typeof p === 'object' && p.pack && typeof p.pack === 'object') : [];
@@ -81,36 +84,42 @@
const hires = Array.isArray(v.hires) && v.hires.length ? `Hires
${v.hires.map(hireBlock).join('')}
` : 'The pack proposes no hires.
';
const skills = v.skills?.notEnabled?.length ? `Skills this machine has not enabled: ${v.skills.notEnabled.map(n => `${esc(n)}`).join(', ')}
` : '';
const chief = v.chief === false ? 'The organization has no chief, so no line is compared against one.
' : '';
- const apply = s.writable ? `` : 'This Console was started without send. Packs can be previewed, not applied.
';
+ const apply = s.writable ? `` : 'This Console was started without send. Packs can be previewed, not applied.
';
return `Preview: ${esc(v.pack?.label ?? p.pack)}${v.project ? ` for project ${esc(v.project)}` : ''}
${items}${hires}${skills}${chief}${apply}`;
}
function packRow(org, s, entry) {
const pack = entry.pack;
const project = pack.scope === 'project'
? s.projects === null ? 'The project list could not be read.
' : s.projects.length
- ? ``
+ ? ``
: 'No code project to apply this pack to.
'
: '';
const canPreview = pack.scope !== 'project' || (s.projects && s.projects.length);
return ` ${esc(pack.label ?? entry.id)} ${esc(entry.id)} ${esc(pack.version)} · ${esc(entry.origin)} · ${pack.scope === 'project' ? 'project pack' : 'organization pack'}
- ${esc(pack.description)}
${project}${canPreview ? '' : ''} `;
+ ${esc(pack.description)}
${project}${canPreview ? `` : ''}`;
}
function section(org) {
const s = states.get(org);
- if (!s || s.error) return `Starter packs
${esc(s?.error ?? 'Starter packs could not be read.')}
`;
+ if (!s || s.error) return `Starter packs
${esc(s?.error ?? 'Starter packs could not be read.')}
`;
const list = s.packs.length ? `${s.packs.map(e => packRow(org, s, e)).join('')}
` : 'No starter packs are installed.
';
- return `Starter packs
A pack adds roles, a board and workflows to the organization. Preview shows what it would change; apply adds only what is missing, and a hire it proposes is only raised by the chief, as a decision.
${list}${previewBlock(s)}${esc(s.status)}
`;
+ return `Starter packs
A pack adds roles, a board and workflows to the organization. Preview shows what it would change; apply adds only what is missing, and a hire it proposes is only raised by the chief, as a decision.
${list}${previewBlock(s)}${esc(s.status)}
`;
}
function redraw(org) {
const el = [...document.querySelectorAll('[data-packs-org]')].find(n => n.dataset.packsOrg === org);
if (el) el.outerHTML = section(org);
}
+ document.addEventListener('change', e => {
+ const select = e.target.closest?.('[data-pack-project]');
+ const s = select && states.get(select.closest('[data-packs-org]')?.dataset.packsOrg);
+ if (s) s.picks[select.closest('[data-pack]').dataset.pack] = select.value;
+ });
document.addEventListener('click', async e => {
const button = e.target.closest('[data-pack-preview-button],[data-pack-apply]');
if (!button) return;
const root = button.closest('[data-packs-org]'), org = root?.dataset.packsOrg, s = states.get(org);
if (!s) return;
button.disabled = true;
+ let applied = false;
try {
if (button.hasAttribute('data-pack-preview-button')) {
const row = button.closest('[data-pack]'), pack = row.dataset.pack, project = row.querySelector('[data-pack-project]')?.value;
@@ -123,10 +132,14 @@
const added = Array.isArray(r.added) && r.added.length ? r.added.join(', ') : 'nothing new';
s.preview = null;
s.status = `Applied. Added ${added}. Proposed hires are recorded for the chief to raise as decisions.`;
+ applied = true;
}
} catch (error) { s.status = error.message; }
// A preview the page cannot draw says so, instead of leaving the button disabled with nothing shown.
try { redraw(org); } catch { s.preview = null; s.status = 'The preview could not be shown.'; redraw(org); }
+ // An apply changes the workflows, the board and the roles too: the page and any pane showing this
+ // organization read every section again. The receipt above is kept in state, so it survives that read.
+ if (applied) document.dispatchEvent(new CustomEvent('mosaic-org-changed', { detail: { org } }));
});
window.MosaicPacks = { load, section };
})();
diff --git a/packages/webui/src/public/panes.css b/packages/webui/src/public/panes.css
index d6b863199..f137feca6 100644
--- a/packages/webui/src/public/panes.css
+++ b/packages/webui/src/public/panes.css
@@ -60,6 +60,16 @@ html[data-organizations] .pane-body .project-columns .project-section-title h2,h
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}
html[data-organizations] .pane-body .project-metrics a:not(:nth-child(4)){border-right:1px solid var(--line)}
}
+/* E2E6 P2-9: a dashboard in a pane sizes from the pane, not the window. Half a 1440 window left the
+ Settings column too narrow for its values (clipped to 1-2 characters): the split stacks below 900px
+ of pane, and a narrow Settings list puts each value on its own line, as at phone width. */
+@container (max-width:899px){
+ html[data-organizations] .pane-body .ws-split{grid-template-columns:minmax(0,1fr)}
+}
+@container (max-width:559px){
+ html[data-organizations] .pane-body .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
+ html[data-organizations] .pane-body .ws-cfg .v{grid-column:2/-1;grid-row:2}
+}
/* The side-by-side page has its own bar; the page breadcrumb would name only one pane. */
body:has(#bus-view > .sbs) #breadcrumbs{display:none}
html[data-organizations] .content:has(> #bus-view > .sbs) > .org-cue{margin:8px 14px}
diff --git a/packages/webui/src/public/panes.js b/packages/webui/src/public/panes.js
index da71ec7b4..e0cb94a24 100644
--- a/packages/webui/src/public/panes.js
+++ b/packages/webui/src/public/panes.js
@@ -176,9 +176,11 @@
function paintBody(p, html) {
const { body } = els(p);
if (p.html === html && body.childElementCount) return;
- const kept = capture(p, body);
+ const kept = capture(p, body), live = window.MosaicWorkspace?.holdLive?.(body);
body.innerHTML = html; p.html = html; p.project = null;
rewrite(p, body);
+ // E2E6 P1-1: an editor in use keeps its node, draft and focus; its ids were rewritten when first painted.
+ if (live && window.MosaicWorkspace.graftLive(body, live)) kept.focus = null;
restore(p, body, kept);
}
@@ -470,6 +472,11 @@
void orgList(true).then(rows => { if (rows) listCache = rows; });
for (const p of mounted.panes) void drawPane(p, { fresh: true });
}
+ // E2E6 P2-2: a pane showing the organization a write changed reads it again.
+ document.addEventListener('mosaic-org-changed', e => {
+ if (!mounted) return;
+ for (const p of mounted.panes) if (p.org === e.detail?.org) void drawPane(p, { fresh: true });
+ });
// The list the tabs draw from follows each fresh read of the organization list.
document.addEventListener('mosaic-org-list', e => { if (Array.isArray(e.detail?.rows)) listCache = e.detail.rows; });
diff --git a/packages/webui/src/public/projects.css b/packages/webui/src/public/projects.css
index e23587283..9b71c1662 100644
--- a/packages/webui/src/public/projects.css
+++ b/packages/webui/src/public/projects.css
@@ -86,7 +86,17 @@
.breadcrumbs:has(.project-crumb-current) a::after { content:''; position:absolute; top:0; bottom:0; left:50%; width:max(100%,44px); transform:translateX(-50%); }
.chat-area:has(.project-header),.chat-area:has(.project-list-head) { max-width:none; margin-inline:0; }
.project-tabs { scrollbar-width:none; } .project-tabs::-webkit-scrollbar { display:none; }
-@media (max-width:699px) { .project-tabs { mask-image:linear-gradient(to right, #000 calc(100% - 22px), transparent 100%); } }
+/* E2E6 P2-12: a strip wider than the screen fades out over its last 48px, so a cut tab reads as "more
+ this way". Where scroll timelines exist the fade runs only while there is more to scroll: a strip that
+ fits has an inactive timeline and keeps the 0px initial value (review P3), and the fade goes once the
+ strip is scrolled to its end. Without scroll timelines the 48px fade is static. */
+@property --tabs-fade { syntax:''; inherits:false; initial-value:0px; }
+@keyframes project-tabs-cue { 0%, 92% { --tabs-fade:48px; } 100% { --tabs-fade:0px; } }
+@media (max-width:699px) {
+ .project-tabs { mask-image:linear-gradient(to right, #000 calc(100% - var(--tabs-fade)), transparent 100%); }
+ @supports (animation-timeline: scroll()) { .project-tabs { animation:project-tabs-cue linear both; animation-timeline:scroll(self inline); } }
+ @supports not (animation-timeline: scroll()) { .project-tabs { --tabs-fade:48px; } }
+}
.project-chip { white-space:nowrap; flex:none; }
@media(max-width:699px) { .project-stepper span { white-space:nowrap; font-size:10.5px; padding:4px 1px; }.project-work-head { align-items:flex-start; } }
@@ -336,7 +346,8 @@ body:has(.chat-area .project-header):has(.project-chat) .breadcrumbs { margin-bo
/* Header actions use the specified compact widths while retaining 44px targets. */
.project-primary-action { box-sizing:border-box; flex:none; }
@media (min-width:1200px) {
- .project-header:not([data-project-conversation="true"]) { width:calc(100vw - 270px); }
+ /* E2E6 P2-11: 100vw counts the page scrollbar; max-width keeps the header inside its column. */
+ .project-header:not([data-project-conversation="true"]) { width:calc(100vw - 270px); max-width:100%; box-sizing:border-box; }
.project-header:not([data-project-conversation="true"]) .project-actions { align-self:flex-start; margin-top:4px; }
.project-primary-action:nth-of-type(1) { width:130px; }
.project-primary-action:nth-of-type(2) { width:88px; }
@@ -504,3 +515,5 @@ body:has(.chat-area .project-header):has(.project-chat) .breadcrumbs { margin-bo
/* Keep the action in flow when a read error occupies the top of the page. */
@media (min-width:700px) { .chat-area:has(.chat-error) .project-create:not([open]) { position:static; margin:12px 0; } }
+/* E2E6 P2-15: inline code in a chat bubble keeps the bubble's text colour on a light tint. */
+.project-chat .chat-body code { font-size:.92em; padding:0 .3em; border-radius:4px; background:color-mix(in srgb, currentColor 12%, transparent); color:inherit; }
diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css
index ef1f90d54..8e042e4df 100644
--- a/packages/webui/src/public/shell.css
+++ b/packages/webui/src/public/shell.css
@@ -301,6 +301,11 @@ button:disabled,[aria-disabled=true]{cursor:not-allowed}
#agents-table tbody th,#routes-table tbody th{text-transform:none;font-size:14px;font-weight:600;color:var(--strong)}
@media(max-width:759px){.settings-content .routing-panel{border:0;background:transparent;padding:0}.routing-panel .table-wrap{border:0;overflow:visible}.routing-panel #routes-table tr{display:grid;grid-template-columns:1fr 1fr;gap:0 10px;background:var(--surface)}.routing-panel #routes-table th,.routing-panel #routes-table td:first-of-type,.routing-panel #routes-table .fallback-cell:first-of-type{grid-column:1/-1}.routing-panel #routes-table td:nth-of-type(4){grid-column:1/-1}.routing-panel #routes-table td:nth-of-type(4)::before{content:'Fallback: None'}.routing-panel #routes-table .fallback-open td:nth-of-type(4)::before{content:'Fallback harness'}}
.settings-content label:has(>input[type=checkbox]){display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:0 8px}
+/* E2E6 minor: the harness checkboxes sit side by side, each keeping its 44px target, instead of one per
+ 50px row. Save and the receipt keep their own full-width rows. */
+.settings-content fieldset.harness-control{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px}
+.settings-content fieldset.harness-control>legend{flex-basis:100%}
+.settings-content fieldset.harness-control>:is(button,p){flex-basis:100%}
.settings-content :is(.instance-form,.level-form) textarea,.settings-content .assistant-form textarea{display:block;width:100%}
.route-definition{display:none}.settings-spinner{display:inline-block;width:16px;height:16px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;vertical-align:middle;animation:settings-spin .8s linear infinite}@keyframes settings-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.settings-spinner{animation:none}}
@media(max-width:759px){#routes-table .route-definition{display:inline;float:right;color:var(--muted);font-size:12px;font-weight:400}}
@@ -444,6 +449,9 @@ html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [d
html[data-organizations] .ws-btn svg.i{width:15px;height:15px}
/* The live bus restart is console-restart.js's own button; it carries its icon through repaints,
so the icon stays on the button however wide the receipt below it is. */
+/* E2E6 P2-13: the disabled pair (a pane, or a Console without the trail) sits in a flex row too. As two
+ inline-flex boxes on one line, Restart bus took its baseline from its glyph and sat 2.6px above Stop bus. */
+html[data-organizations] [data-dashboard-bus]:not(.ws-busctl){display:inline-flex;flex-wrap:wrap;align-items:center;gap:6px}
html[data-organizations] .ws-busctl{display:inline-flex;flex-direction:column;align-items:flex-end}
html[data-organizations] .ws-busctl .bus-restart{display:inline-flex;flex-direction:column;align-items:flex-end;gap:4px}
html[data-organizations] .ws-busctl .bus-buttons{display:inline-flex;flex-wrap:wrap;gap:6px;justify-content:flex-end}
@@ -477,6 +485,10 @@ html[data-organizations] .ws-panel.tight{padding:12px 14px}
html[data-organizations] .ws-split{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);gap:16px;align-items:start}
html[data-organizations] .ws-split{margin-top:22px}
html[data-organizations] .ws-split>section>.ws-sec-h:first-child{margin-top:0}
+/* E2E6 P1-3: the long editors (Autonomy, Packs, Workflows) span the page below the split, so neither
+ column runs screens past the other at 1440. */
+html[data-organizations] .ws-wide{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;margin-top:22px}
+html[data-organizations] .ws-wide>.panel{margin:0}
/* Organization dashboard (sample 03). */
html[data-organizations] .ws-orghead{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-bottom:14px}
diff --git a/packages/webui/src/public/workflows.js b/packages/webui/src/public/workflows.js
index 6fa82f5f4..b99beba0c 100644
--- a/packages/webui/src/public/workflows.js
+++ b/packages/webui/src/public/workflows.js
@@ -43,9 +43,17 @@
s.data = value;
s.known = new Set([...(value.roles.definitions || []), ...(value.roles.templates || []), 'human']);
}
+ // E2E6 P1-1: a poll reloads the data but keeps the page's own state: an open editor and its draft,
+ // a pending delete confirmation and the last receipt.
async function load(org) {
- const s = { data: null, known: new Set(), error: null, editing: null, confirm: null, status: '' };
- try { await read(org, s); } catch (error) { s.error = error.message; }
+ const prev = states.get(org);
+ const s = { data: null, known: new Set(), error: null, editing: prev?.editing ?? null, confirm: prev?.confirm ?? null, status: prev?.status ?? '' };
+ try { await read(org, s); } catch (error) {
+ // Review P3: a failed poll while an editor is open keeps the last good read, so the fresh section
+ // still carries its data-live-key and graftLive puts the held section, editor and focus back.
+ if (prev?.editing && prev.data) Object.assign(s, { data: prev.data, known: prev.known });
+ else s.error = error.message;
+ }
states.set(org, s);
}
const scopes = s => [['', 'Organization', s.data.workflows], ...Object.keys(s.data.projects).sort().map(p => [p, `Project ${p}`, s.data.projects[p]])];
@@ -60,8 +68,8 @@
const steps = Array.isArray(w?.steps) ? w.steps : [];
const confirm = s.confirm && s.confirm.scope === scope && s.confirm.name === name;
const actions = !s.data.writable ? '' : confirm
- ? `Delete this workflow?
`
- : `
`;
+ ? `Delete this workflow?
`
+ : `
`;
return `${esc(w?.title || name)} ${esc(name)}
${w?.description ? `${esc(w.description)}
` : ''}${steps.map(st => stepItem(s, st)).join('')}
${actions} `;
}
@@ -81,7 +89,7 @@
const scopeField = e.isNew
? ``
: `${e.scope ? `Project ${esc(e.scope)}` : 'Organization'}, ${esc(e.name)}
`;
- return `${e.isNew ? 'New workflow' : 'Edit workflow'}
${scopeField}
+ return `${e.isNew ? 'New workflow' : 'Edit workflow'}
${scopeField}
${e.draft.steps.map((st, i) => stepRow(s, st, i)).join('')}
@@ -95,8 +103,8 @@
const names = Object.keys(flows || {}).sort();
return `${esc(label)}
${names.length ? `${names.map(n => workflowItem(org, s, scope, n, flows[n])).join('')}
` : 'No workflows.
'}`;
}).join('');
- const add = s.data.writable ? (s.editing ? '' : '') : 'Only the owner, on a Console started with send, can change workflows.
';
- return `Workflows
A workflow lists the steps a task goes through, the state each step puts it in and the roles that do it.
${lists}${add}${editor(s)}${esc(s.status)}
`;
+ const add = s.data.writable ? (s.editing ? '' : ``) : 'Only the owner, on a Console started with send, can change workflows.
';
+ return `Workflows
A workflow lists the steps a task goes through, the state each step puts it in and the roles that do it.
${lists}${add}${editor(s)}${esc(s.status)}
`;
}
function redraw(org) {
const el = [...document.querySelectorAll('[data-workflows-org]')].find(n => n.dataset.workflowsOrg === org);
@@ -124,10 +132,10 @@
if (!s?.data) return;
const item = button.closest('[data-wf]'), scope = item?.dataset.wfScope ?? '', name = item?.dataset.wf;
if (button.hasAttribute('data-wf-new')) {
- s.editing = { isNew: true, scope: '', name: '', draft: { title: '', description: '', steps: [{ title: '', state: 'todo', roles: '' }] } };
+ s.editing = { isNew: true, revision: s.data.revision, scope: '', name: '', draft: { title: '', description: '', steps: [{ title: '', state: 'todo', roles: '' }] } };
} else if (button.hasAttribute('data-wf-edit')) {
const w = flowsIn(s, scope)?.[name];
- s.editing = { isNew: false, scope, name, draft: { title: w?.title ?? '', description: w?.description ?? '', steps: (w?.steps ?? []).map(st => ({ title: st.title ?? '', state: st.state ?? 'todo', roles: (st.roles ?? []).join(', ') })) } };
+ s.editing = { isNew: false, revision: s.data.revision, scope, name, draft: { title: w?.title ?? '', description: w?.description ?? '', steps: (w?.steps ?? []).map(st => ({ title: st.title ?? '', state: st.state ?? 'todo', roles: (st.roles ?? []).join(', ') })) } };
} else if (button.hasAttribute('data-wf-cancel')) {
s.editing = null;
} else if (button.hasAttribute('data-wf-step-add')) {
@@ -148,7 +156,10 @@
}
redraw(org);
});
+ // Every keystroke goes back into the draft, so any repaint draws what was typed.
document.addEventListener('input', ev => {
+ const form = ev.target.closest('[data-wf-editor]'), root = form?.closest('[data-workflows-org]');
+ if (form && root) { const st = states.get(root.dataset.workflowsOrg); if (st?.editing) capture(root, st); }
const field = ev.target.closest('[data-wf-edit-step] [name="roles"]');
if (!field) return;
const s = states.get(field.closest('[data-workflows-org]')?.dataset.workflowsOrg);
@@ -168,6 +179,9 @@
if (!NAME.test(e.name)) { s.status = NAME_RULE; redraw(org); return; }
// A save is a set: a new workflow under a name the scope already holds would replace it without a word.
if (e.isNew && Object.hasOwn(flowsIn(s, e.scope) ?? {}, e.name)) { s.status = `A workflow named ${e.name} already exists here. Edit it instead.`; redraw(org); return; }
+ // E2E6 P2-1: a step role that names no current role is named before the bus refuses it generically.
+ const unknown = e.draft.steps.flatMap((st, i) => st.roles.split(',').map(r => r.trim()).filter(r => r && !s.known.has(r)).map(r => [i + 1, r]));
+ if (unknown.length) { s.status = `${unknown.map(([i, r]) => `Step ${i}: ${r}`).join('; ')} ${unknown.length === 1 ? 'names' : 'name'} no current role. Use an organization role, a role template or human.`; redraw(org); return; }
const workflow = {
...(e.draft.title.trim() ? { title: e.draft.title.trim() } : {}),
...(e.draft.description.trim() ? { description: e.draft.description.trim() } : {}),
@@ -176,7 +190,8 @@
const button = ev.target.querySelector('button[type="submit"]');
button.disabled = true;
try {
- await call(`${base(org)}/workflows/${encodeURIComponent(e.name)}`, 'POST', { revision: s.data.revision, workflow, ...target(s, e.scope) });
+ // The revision the editor opened on: a change made since then is a revision-conflict, not overwritten.
+ await call(`${base(org)}/workflows/${encodeURIComponent(e.name)}`, 'POST', { revision: e.revision ?? s.data.revision, workflow, ...target(s, e.scope) });
s.editing = null;
await reload(org, s, `Saved ${e.name}.`);
} catch (error) { s.status = error.message; }
diff --git a/packages/webui/src/public/workspace-data.js b/packages/webui/src/public/workspace-data.js
index 418a18350..576351961 100644
--- a/packages/webui/src/public/workspace-data.js
+++ b/packages/webui/src/public/workspace-data.js
@@ -5,7 +5,12 @@
const read = async path => {
const response = await fetch(path, { cache: 'no-store', redirect: 'manual' });
// The status travels with the failure, so a caller can tell a refusal (403) from a bus that is down.
- if (!response.ok || response.type === 'opaqueredirect') { const error = new Error('Read unavailable'); error.status = response.type === 'opaqueredirect' ? 0 : response.status; throw error; }
+ // E2E6 P2-6: the server's refusal code and its redacted message travel too, so a section can say why.
+ if (!response.ok || response.type === 'opaqueredirect') {
+ const error = new Error('Read unavailable'); error.status = response.type === 'opaqueredirect' ? 0 : response.status;
+ if (response.type !== 'opaqueredirect') try { const body = await response.json(); if (typeof body?.error === 'string') error.code = body.error; if (typeof body?.message === 'string') error.detail = body.message; } catch { /* no JSON body */ }
+ throw error;
+ }
return response.json();
};
const orgPath = (id, path) => `/api/orgs/${encodeURIComponent(id)}/${path}`;
diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js
index a53c4792a..5a8024529 100644
--- a/packages/webui/src/public/workspace.js
+++ b/packages/webui/src/public/workspace.js
@@ -52,7 +52,7 @@
}
const av = (org, size = '') => ``;
const label = org => org.label || org.id;
- const state = org => (org.invalid ? ['s-error', '▲', 'invalid'] : org.busHealthy ? ['s-working', '●', 'bus running'] : ['s-offline', '○', 'bus unavailable']);
+ const state = org => (org.invalid ? ['s-error', '▲', 'invalid'] : org.busHealthy ? ['s-working', '●', 'bus running'] : org.busStopped ? ['s-offline', '○', 'bus stopped'] : ['s-offline', '○', 'bus unavailable']);
// A status mark: a glyph plus its word, shown or read aloud only.
const dot = (cls, glyph, word, shown = false) => `${glyph}${shown ? esc(word) : `${esc(word)}`}`;
const orgHref = id => `#/orgs/${enc(id)}`;
@@ -183,7 +183,7 @@
['business', 'Organizations', orgCount === null ? unread : plural(orgCount, 'organization'), 'system', 'organizations'],
['flag', 'About', typeof s?.release === 'string' ? `Release ${s.release}` : 'Address, release and notifier', 'system', 'about'],
['people', 'Members and invites', 'Not built yet', 'not built', null],
- ['restart', 'Bus', `${org.busHealthy ? 'Running' : 'Unavailable'} · restart from this page's header`, 'system', null],
+ ['restart', 'Bus', `${org.busHealthy ? 'Running' : org.busStopped ? 'Stopped' : 'Unavailable'} · restart from this page's header`, 'system', null],
['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'],
];
return `${rows.map(([ic, name, value, tag, page]) => {
@@ -193,6 +193,40 @@
}).join('')}
`;
}
+ // E2E6 P1-1: a poll repaints the whole page, which would drop an editor's draft and its focus. A
+ // section marked data-live-key that is being edited (it holds [data-live-hold] or [data-live-dirty])
+ // or holds focus is kept: holdLive takes it before the repaint and graftLive puts that same node, with
+ // its typed values, caret and focus, in place of its fresh counterpart. Each editor saves against the
+ // revision it started from, so a kept node cannot overwrite a change it never saw.
+ const TYPING = 'input:not([type=button],[type=submit],[type=reset],[type=checkbox],[type=radio]),select,textarea';
+ function holdLive(root) {
+ const held = new Map(), a = document.activeElement;
+ if (!root) return { held, active: null };
+ // Focus alone holds a section only while it sits in a field being typed in. A focused button or link
+ // is rebuilt with fresh data and gets its focus back through data-focus-key (review P3), so a section
+ // never stays stale just because its Apply button kept focus.
+ const typing = a?.matches?.(TYPING) ?? false;
+ for (const el of root.querySelectorAll('[data-live-key]')) {
+ if (el.matches('[data-live-hold]') || el.querySelector('[data-live-hold],[data-live-dirty]') || (typing && el.contains(a))) held.set(el.dataset.liveKey, el);
+ }
+ const active = a && [...held.values()].some(el => el.contains(a))
+ ? { el: a, caret: typeof a.selectionStart === 'number' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null } : null;
+ return { held, active };
+ }
+ function graftLive(root, kept) {
+ if (!root || !kept?.held?.size) return false;
+ for (const el of root.querySelectorAll('[data-live-key]')) {
+ const old = kept.held.get(el.dataset.liveKey);
+ if (old && old !== el) el.replaceWith(old);
+ }
+ const a = kept.active;
+ if (a && a.el.isConnected) {
+ a.el.focus({ preventScroll: true });
+ if (a.caret && typeof a.el.setSelectionRange === 'function') try { a.el.setSelectionRange(a.caret[0], a.caret[1]); a.el.scrollTop = a.caret[2]; } catch { /* not a text field */ }
+ }
+ return Boolean(a && a.el.isConnected);
+ }
+
// A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus
// restart (that stays in the organization's full view) and its ids carry the pane's prefix.
async function dashboard(id = window.MosaicOrg.id, opts = {}) {
@@ -202,7 +236,9 @@
const list = Array.isArray(listRead) ? listRead : null;
const org = list?.find(o => o.id === id && !o.invalid);
if (!org) return `Organization unavailable
${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}
`;
- const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)]);
+ // E2E6 P2-8: a stopped bus is not read for the bus-backed editors; each says the bus is stopped.
+ const stopped = !org.busHealthy && org.busStopped === true;
+ const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), ...(stopped ? [] : [window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)])]);
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
const business = value(0), settings = value(1), instances = instancesOf(value(2));
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
@@ -220,7 +256,7 @@
const bus = restart ? `${restart}` : ` `;
const reload = ``;
const [bcls, bglyph] = state(org);
- const meta = ``;
+ const meta = ``;
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
const docChips = !docRead ? 'Docs unavailable' : docRead.failed ? 'Docs unavailable' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `${icon('doc')}${esc(docName(d.path))}${esc(projectName(d.project))}`).join('') : 'No docs yet';
@@ -242,9 +278,16 @@
: threads ? `Conversation${esc(first.thread)}${more}` : 'Conversation unavailable';
return `${esc(agent)} ${where} ${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''} `;
}).join('');
- const sessions = live === null ? 'Sessions unavailable. The bus did not answer the instance read.
' : live.length ? `Agent Project and task Status ${sessionRows}
` : 'No active agent sessions
';
+ const sessions = live === null ? (stopped ? 'Sessions not read: the bus is stopped.
' : 'Sessions unavailable. The bus did not answer the instance read.
') : live.length ? `Agent Project and task Status ${sessionRows}
` : 'No active agent sessions
';
const open = queue ? queue.filter(r => r?.state !== 'done').length : null;
const queueNote = queue === null ? 'unavailable' : queue.length ? `${plural(queue.length, 'row')}, ${open} not done` : 'no rows';
+ // E2E6 P2-6: a failed queue read says why, in the words the Queue page uses (server-redacted).
+ const qerr = queue === null && reads[5].status === 'rejected' ? reads[5].reason : null;
+ const queueWhy = queue !== null ? '' : `${qerr?.code === 'queue-refused' ? `The queue refused the read.${qerr.detail ? ` ${esc(qerr.detail.replace(//g, 'This checkout'))}` : ''}`
+ : qerr?.status === 0 ? 'You are signed out. Reload the page to sign in again.'
+ : qerr?.code === 'not-configured' ? 'This Console has no queue to read for this organization.'
+ : `The queue read did not answer${qerr?.code ? ` (${esc(qerr.code)})` : ''}.`}
`;
+ const stoppedPanel = (attr, title) => `${title}
Not read: the bus is stopped.
`;
return `
@@ -257,15 +300,18 @@
${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', 'All agents')}
${sessions}
${secH('Queue', queueNote, 'Open queue')}
+ ${queueWhy}
${secH('Settings', 'every setting, with where its value comes from')}
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
- ${window.MosaicAutonomy?.section(id)??''}
- ${window.MosaicPacks?.section(id) ?? ''}
- ${window.MosaicWorkflows?.section(id) ?? ''}
+
+ ${stopped ? stoppedPanel('data-autonomy', 'Autonomy') : window.MosaicAutonomy?.section(id) ?? ''}
+ ${stopped ? stoppedPanel('data-packs', 'Starter packs') : window.MosaicPacks?.section(id) ?? ''}
+ ${stopped ? stoppedPanel('data-workflows', 'Workflows') : window.MosaicWorkflows?.section(id) ?? ''}
+
`;
}
@@ -514,7 +560,7 @@
${tile('sessions', 'Sessions', sessN(t.sessions), `${num(t.sessions?.working)} working`, parts('sessions', x => sessN(x.sessions)), `${num(t.sessions?.waiting)} waiting on you · ${num(t.sessions?.idle)} idle${num(t.sessions?.live) ? ` · ${num(t.sessions.live)} live ${t.sessions.live === 1 ? 'thread' : 'threads'}` : ''}`)}
${tile('tasks', 'Open tasks', num(t.openTasks), '', parts('tasks', x => x.openTasks), `${num(t.doneThisWeek)} done this week`)}
${tile('projects', 'Projects', num(t.projects?.active) + num(t.projects?.archived), `${num(t.projects?.active)} active`, parts('projects', x => num(x.projects?.active) + num(x.projects?.archived)), `${num(t.projects?.archived)} archived`)}
- Model spendNo spend figures yetNo read prices model use today.
+ Model spendNo spend figures yetNo model prices are read yet, so today's use has no cost figure.
` : 'No organization answered, so there are no totals yet.
';
const cards = o.rows.map(r => {
@@ -783,6 +829,6 @@
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
- window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
+ window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
refresh();
})();
diff --git a/packages/webui/tests/chat-replies-browser.test.mjs b/packages/webui/tests/chat-replies-browser.test.mjs
index f45123278..e88bb7764 100644
--- a/packages/webui/tests/chat-replies-browser.test.mjs
+++ b/packages/webui/tests/chat-replies-browser.test.mjs
@@ -30,6 +30,8 @@ const ROWS = [
row(3, reply('Unchecked.', [cite('Notes/Eyes.md', [1, 1], HOSTILE)])),
row(4, JSON.stringify({ notice: 'library-write', op: 'write', path: 'Notes/Eyes.md', commit: C4 }), undefined, 'scribe'),
row(5, JSON.stringify({ notice: 'library-write', op: 'delete', path: 'Fake/Claim.md', commit: C4 }), { citations: [] }, 'forger'),
+ // E2E6 P2-15: a human message with inline code, a hostile span and an unpaired backtick.
+ row(6, `Approve with \`mosaic project north-star work\`. Not \`${HOSTILE}\`, a lone \` stays.`, undefined, 'human'),
];
const STATUS = { project: 'books', version: 1, branch: 'main', served: C3, lastFetch: 'FIVE-MINUTES-AGO',
lastError: { code: 'library-fetch-failed', at: '2026-10-23T18:00:00.000Z' }, diverged: { commit: C2, at: '2026-10-23T18:00:00.000Z' },
@@ -83,6 +85,10 @@ test('chat replies: free text, cites with badges, the agent label, changed since
const { b, text, message } = await setup(t);
assert.equal(await text(`${message(1)} .chat-body`), 'Plain text answer.');
assert.equal(await text(`${message(2)} .chat-body`), "Mara's eyes are grey.");
+ // E2E6 P2-15: backtick pairs render as code and their contents stay text.
+ assert.deepEqual(await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(message(6) + ' .chat-body code')})].map(c => c.textContent)`), ['mosaic project north-star work', HOSTILE]);
+ assert.equal(await text(`${message(6)} .chat-body`), `Approve with mosaic project north-star work. Not ${HOSTILE}, a lone \` stays.`);
+ assert.equal(await b.evaluate(`document.querySelectorAll('.chat-log img').length`), 0);
const cites = await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(message(2) + ' .chat-cites > li')})].map(li => ({
badge: li.querySelector('.chat-scope')?.textContent ?? null, ref: (li.querySelector('a') ?? li.querySelector('code'))?.textContent,
href: li.querySelector('a')?.getAttribute('href') ?? null, rel: li.querySelector('a')?.getAttribute('rel') ?? null,
diff --git a/packages/webui/tests/decision-http.test.mjs b/packages/webui/tests/decision-http.test.mjs
index 7a2e49322..64aa4c6b4 100644
--- a/packages/webui/tests/decision-http.test.mjs
+++ b/packages/webui/tests/decision-http.test.mjs
@@ -326,12 +326,84 @@ test('D4 hire start: the other proposal checks that run before the decision inse
assert.doesNotMatch(r.json().message,/\bhire was\b/,'worded for hire, fire and change alike');
}
});
-// Review P3: a code the broker can raise after the decision exists or is consumed, or one the bus
-// server raises about the request itself, is not a definite refusal of the start.
-test('D4 hire start: a code that can follow the decision insert, or a transport code, stays outcome-unknown',async()=>{
- for(const code of ['revision-conflict-revoked','request-timeout','invalid-envelope','incomplete-request','instance-kept','organization-instance-exists','organization-unknown-instance','root-fire-shared','fire-in-progress','some-future-code'])
+// E2E6 P1-2: every definite refusal of a start keeps its own code and a next step. Only a reply that
+// never came, could not be read, or the bus server's request codes stay outcome-unknown.
+test('E2E6 hire start: an unknown project, an unknown agent on fire and an unknown harness keep their code',async()=>{
+ for(const [code,re] of [['project-agents-invalid',/code project/],['organization-unknown-instance',/Agents page/],['harness-off',/Settings > Routing/]]) {
+ const {r,s}=await startWith(new BusReadError(code));
+ assert.equal(r.status,409,`${code}: ${r.text}`);
+ assert.equal(r.json().error,code);assert.match(r.json().message,re);
+ // harness-off comes only from prepare()'s route check; the other two can also come from apply().
+ if(code==='harness-off')assert.match(r.json().message,/ Nothing was applied\.$/);
+ else {assert.match(r.json().message,/ Part of it may already be applied: check the Agents page before trying again\.$/);assert.doesNotMatch(r.json().message,/nothing was applied/i);}
+ assert.match(s.lines.at(-1),new RegExp(`step=start error=${code}$`));
+ }
+});
+test('E2E6 hire start: the other proposal and apply codes keep their code; apply-phase ones do not claim nothing changed',async()=>{
+ for(const code of ['unknown-model','harness-refused']) {
+ const {r}=await startWith(new BusReadError(code));
+ assert.equal(r.json().error,code,`${code}: ${r.text}`);assert.match(r.json().message,/ Nothing was applied\.$/);
+ }
+ // Review finding 1: apply() runs check() again and the writers run after the consume, so these
+ // never claim nothing changed, in any case or clause. The inbox lists no closed decision, so they
+ // do not send the user there to tell which side of the consume the refusal came from (re-check).
+ for(const code of ['organization-instance-exists','instance-kept','root-fire-shared','definition-rev-conflict','definition-missing','credential-leak','decision-stale','organization-instance-retired','organization-instance-referenced','organization-refused','definition-secret-check-required','not-configured','invalid-request']) {
+ const {r}=await startWith(new BusReadError(code));
+ assert.equal(r.json().error,code,`${code}: ${r.text}`);
+ assert.doesNotMatch(r.json().message,/nothing was applied/i,`${code} does not claim nothing changed`);
+ assert.doesNotMatch(r.json().message,/inbox/,`${code} does not point at the inbox, which drops a consumed decision`);
+ assert.match(r.json().message,/Part of it may already be applied: check the Agents page/);
+ }
+ for(const code of ['organization-busy','fire-in-progress','credential-revoking']) {
+ const {r}=await startWith(new BusReadError(code));
+ assert.equal(r.status,409);assert.equal(r.json().error,code);
+ assert.doesNotMatch(r.json().message,/Nothing was applied/);assert.match(r.json().message,/Agents page and the inbox/);
+ }
+ const {r}=await startWith(new BusReadError('some-future-code'));
+ assert.equal(r.status,409);assert.equal(r.json().error,'some-future-code');assert.match(r.json().message,/Agents page and the inbox/);
+});
+test('E2E6 hire start: an apply-phase decision-stale on a fire does not say nothing was applied',async()=>{
+ // #hireConsole consumes the decision and stops a project agent's runs before apply() checks again.
+ const s=setup({resolve:()=>{throw new BusReadError('decision-stale');}});
+ const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:{op:'fire',level:'project',project:'site',name:'coder',reason:'done'}}}));
+ assert.equal(s.calls.length,1);assert.equal(r.status,409,r.text);assert.equal(r.json().error,'decision-stale');
+ assert.doesNotMatch(r.json().message,/nothing was applied/i);
+ assert.doesNotMatch(r.json().message,/inbox|answer it there/,'a consumed decision is gone from the inbox');
+ assert.match(r.json().message,/check the Agents page/);
+});
+test('E2E6 hire start: invalid-request from the bus says the values, not the shape, were refused',async()=>{
+ const {r}=await startWith(new BusReadError('invalid-request'));
+ assert.equal(r.status,400);assert.equal(r.json().error,'invalid-request');assert.match(r.json().message,/Settings > Model proxy/);
+});
+test('E2E6 hire start: the bus server request codes and organization-uncertain stay outcome-unknown',async()=>{
+ for(const code of ['revision-conflict-revoked','request-timeout','invalid-envelope','incomplete-request','organization-uncertain'])
refused((await startWith(new BusReadError(code))).r,'outcome-unknown');
});
+// E2E6 P2-4: a malformed proposal names its field and the rule, and never reaches the bus.
+test('E2E6 hire start: a malformed proposal names the field and the rule before the bus',async()=>{
+ const cases=[
+ [{...START_HIRE,name:'Bad Name'},'name',/lowercase/],
+ [(({route,...h})=>h)(START_HIRE),'route',/harness and a model/],
+ [{...START_HIRE,route:{harness:'pi'}},'route.model',/required/],
+ [{...START_HIRE,level:'project'},'project',/code project/],
+ [{...START_HIRE,project:'north'},'project',/only for project level/],
+ [{...START_HIRE,reason:' '},'reason',/blank/],
+ [{...START_HIRE,op:'promote'},'op',/hire, fire or change/],
+ [{op:'fire',level:'org',name:'scout',reason:'r',route:START_HIRE.route},'route',/not used by fire/],
+ [{...START_HIRE,role:{template:'coder',sha256:'x'}},'role.sha256',/digest/],
+ [{...START_HIRE,role:{template:'coder',sha256:DIGEST,definition:'x'}},'role',/exactly one/],
+ [{...START_HIRE,colour:'blue'},'colour',/not a hire field/],
+ ];
+ for(const [hire,field,rule] of cases) {
+ const s=setup({resolve:()=>{throw new Error('must not be called');}});
+ const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire}}));
+ assert.equal(r.status,400,`${field}: ${r.text}`);
+ const body=r.json();
+ assert.equal(body.error,'invalid-request');assert.equal(body.field,field);assert.match(body.rule,rule);
+ assert.match(body.message,/Nothing was sent to the bus/);assert.ok(body.message.toLowerCase().startsWith(field.toLowerCase()),body.message);
+ assert.equal(s.calls.length,0,`${field}: no bus call`);
+ }
+});
test('D4 hire start: a lost or unreadable reply is still outcome-unknown',async()=>{
for(const err of [new BusReadError('outcome-unknown'),new BusReadError('invalid-response'),new BusReadError('response-too-large'),new BusReadError('outcome-unknown-revoked'),new BusReadError('Not A Code'),new BusReadError(''),new Error('socket closed'),Object.assign(new Error('x'),{code:'model-provider-unavailable'})])
refused((await startWith(err)).r,'outcome-unknown');
diff --git a/packages/webui/tests/e2e6-layout-browser.test.mjs b/packages/webui/tests/e2e6-layout-browser.test.mjs
new file mode 100644
index 000000000..b81392cfc
--- /dev/null
+++ b/packages/webui/tests/e2e6-layout-browser.test.mjs
@@ -0,0 +1,81 @@
+// E2E6 P2-11, P2-12, P2-13 (#1873): layout pins measured in a real browser.
+// P2-11: a project's header stays inside its column at desktop widths, so its actions are not cut off.
+// P2-12: at phone width the project tabs fade at the right edge while more tabs lie past it, and the
+// fade is gone once the row is scrolled to its end or when every tab fits.
+// P2-13: Restart bus and Stop bus share one top and height, on the organization page and in a pane.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { fixture, close } from './fixture.mjs';
+import { browser } from './browser.mjs';
+import { startServer } from '../src/serve.mjs';
+import { createDevOwner } from '../src/dev-owner.mjs';
+import { P, stub } from './helpers/projects-review-fixture.mjs';
+
+const until = (b, expression, what) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,25))}throw Error(${JSON.stringify(what)})})()`);
+const frames = b => b.evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(()=>r(true))))');
+
+async function project(t, width) {
+ const f = await fixture(), b = await browser({ machine: false, args: ['--disable-extensions'] });
+ t.after(async () => { await b.close(); await f.close(); });
+ await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
+ await b.viewport(width, 900);
+ await b.navigate(`${f.base}/#/projects/${P}`);
+ await until(b, `document.querySelector('.project-header .project-actions')`, 'project header');
+ await b.evaluate('new Promise(r=>setTimeout(r,400))');
+ return b;
+}
+
+for (const width of [1440, 1280]) {
+ test(`E2E6 P2-11: the project header and its actions stay inside the column at ${width}`, { timeout: 60000 }, async t => {
+ const b = await project(t, width);
+ const m = JSON.parse(await b.evaluate(`JSON.stringify((()=>{const h=document.querySelector('.project-header'),p=h.parentElement.getBoundingClientRect(),r=h.getBoundingClientRect();return {right:r.right,parent:p.right,actions:[...h.querySelectorAll('.project-actions > *')].map(a=>a.getBoundingClientRect().right),page:[document.documentElement.scrollWidth,innerWidth]}})())`));
+ assert.ok(m.right <= m.parent + 0.5, `header right ${m.right} inside its column ${m.parent}`);
+ for (const a of m.actions) assert.ok(a <= m.parent + 0.5, `action right ${a} inside ${m.parent}`);
+ assert.ok(m.page[0] <= m.page[1], 'no sideways page scroll');
+ });
+}
+
+test('E2E6 P2-12: at 390 the project tabs fade at the edge until scrolled to the end', { timeout: 60000 }, async t => {
+ const b = await project(t, 390);
+ const read = () => b.evaluate(`JSON.stringify((()=>{const e=document.querySelector('.project-tabs'),s=getComputedStyle(e);return {mask:s.maskImage||s.webkitMaskImage,fade:s.getPropertyValue('--tabs-fade').trim(),sw:e.scrollWidth,cw:e.clientWidth}})())`).then(JSON.parse);
+ const start = await read();
+ assert.ok(start.sw > start.cw, `the tabs overflow (${start.sw} > ${start.cw})`);
+ assert.match(start.mask, /linear-gradient/, 'a fade mask is set');
+ assert.equal(start.fade, '48px', 'a 48px fade while more tabs lie past the edge');
+ await b.evaluate(`{const e=document.querySelector('.project-tabs');e.scrollLeft=e.scrollWidth}true`);
+ await frames(b);
+ assert.equal((await read()).fade, '0px', 'no fade once the row is scrolled to its end');
+ // Review P3: a strip whose tabs all fit has nothing past the edge, so it has no fade.
+ await b.evaluate(`{const e=document.querySelector('.project-tabs');[...e.children].slice(1).forEach(c=>c.style.display='none');e.scrollLeft=0}true`);
+ await frames(b);
+ const fits = await read();
+ assert.ok(fits.sw <= fits.cw, `the remaining tab fits (${fits.sw} <= ${fits.cw})`);
+ assert.equal(fits.fade, '0px', 'no fade when nothing is cut off');
+});
+
+const org = {
+ busHealthy: true, decisionWrite: async () => ({}), send: async () => ({}),
+ requestRead: async () => { throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); },
+ reads: { business: async () => ({ business: { id: 'acme', projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) },
+ bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
+ chat: { projects: async () => [], threads: async () => [] },
+};
+for (const [width, route, where] of [[1440, 'orgs/acme/organization', 'the organization page'], [390, 'orgs/acme/organization', 'the organization page'], [1440, 'orgs/acme/side-by-side?a=acme/organization&b=acme/agents', 'a pane'], [390, 'orgs/acme/side-by-side?a=acme/agents&b=acme/organization&show=b', 'a pane']]) {
+ test(`E2E6 P2-13: Restart bus and Stop bus line up on ${where} at ${width}`, { timeout: 60000 }, async t => {
+ const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: { list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }], resolve: async () => org } });
+ t.after(() => close(server));
+ const b = await browser({ machine: false, args: ['--disable-extensions'] });
+ t.after(() => b.close());
+ await b.viewport(width, 900);
+ await b.navigate(`http://127.0.0.1:${server.address().port}/#/${route}`);
+ const pair = `[...document.querySelectorAll('[data-bus-restart],[data-bus-restart-off],[data-bus-stop],[data-bus-stop-off]')].filter(e=>e.getClientRects().length)`;
+ await until(b, `${pair}.length===2`, 'the two bus buttons');
+ await b.evaluate('new Promise(r=>setTimeout(r,300))');
+ const [restart, stop] = JSON.parse(await b.evaluate(`JSON.stringify(${pair}.map(e=>{const r=e.getBoundingClientRect();return {text:e.textContent.trim(),top:r.top,height:r.height,left:r.left,right:r.right}}))`));
+ assert.equal(restart.text, 'Restart bus');
+ assert.equal(stop.text, 'Stop bus');
+ assert.ok(Math.abs(restart.top - stop.top) < 0.5, `same top (${restart.top} vs ${stop.top})`);
+ assert.ok(Math.abs(restart.height - stop.height) < 0.5, `same height (${restart.height} vs ${stop.height})`);
+ assert.ok(stop.left - restart.right >= 4, 'a gap between them');
+ });
+}
diff --git a/packages/webui/tests/org-live-edit-browser.test.mjs b/packages/webui/tests/org-live-edit-browser.test.mjs
new file mode 100644
index 000000000..5287a60e7
--- /dev/null
+++ b/packages/webui/tests/org-live-edit-browser.test.mjs
@@ -0,0 +1,164 @@
+// E2E6 P1-1, P2-2 (#1873): an editor in use on an organization's page survives the page's refresh. The
+// workflow editor's node, typed draft and focus, the autonomy form's unsaved choice and a pack's picked
+// project are kept when the page reads every section again. An apply refreshes the other sections.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { startServer } from '../src/serve.mjs';
+import { createDevOwner } from '../src/dev-owner.mjs';
+import { browser } from './browser.mjs';
+import { close } from './fixture.mjs';
+
+const waitFor = (b, expression, what) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(what)}+': '+document.body.innerText.slice(0,2000))})()`);
+const REV = n => String(n).repeat(64);
+
+async function fixture(t) {
+ const counts = { workflows: 0, autonomy: 0, packs: 0 };
+ // ctl lets a test fail the next reads or change what the packs read returns.
+ const ctl = { workflowsFail: false, autonomyFail: false, packLabel: 'Web project' };
+ const flows = {
+ revision: REV('a'),
+ document: {
+ workflows: { intake: { title: 'Intake', steps: [{ title: 'Sort', state: 'todo', roles: ['human'] }] } },
+ projects: { site: { workflows: {} }, docs: { workflows: {} } },
+ definitions: {},
+ },
+ templates: ['coder'],
+ };
+ const read = async verb => {
+ if (verb === 'packs.console') { counts.packs++; return { packs: [{ id: 'web-project', origin: 'installed', pack: { label: ctl.packLabel, description: 'A board.', version: '0.2.0', scope: 'project' } }] }; }
+ if (verb === 'pack.preview.console') return { pack: { id: 'web-project', version: '0.2.0', label: 'Web project', origin: 'installed', scope: 'project' }, project: 'docs', revision: REV('c'), items: [{ item: 'board', name: 'board', target: 'board', path: 'board', mark: 'add' }], hires: [], skills: { notEnabled: [] }, chief: true, plan: {}, digest: REV('d') };
+ if (verb === 'autonomy.get.console') { counts.autonomy++; if (ctl.autonomyFail) throw Object.assign(new Error('bus-unreachable'), { code: 'bus-unreachable' }); return { revision: REV('e'), autonomy: { merge: 'out of the loop' }, levels: { 'cross-role': 'out of the loop', merge: 'out of the loop', policy: 'out of the loop', authority: 'in the loop' } }; }
+ throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
+ };
+ const write = async (verb, args) => {
+ if (verb === 'pack.apply.console') return { revision: REV('f'), added: ['board'], pack: { id: args.pack, version: '0.2.0' } };
+ throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
+ };
+ const server = await startServer({
+ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {},
+ organizations: {
+ list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }],
+ resolve: async () => ({
+ busHealthy: true, decisionWrite: async () => ({}), requestRead: read, requestWrite: read, packsWrite: write,
+ workflowsRead: async () => { counts.workflows++; if (ctl.workflowsFail) throw Object.assign(new Error('bus-unreachable'), { code: 'bus-unreachable' }); return structuredClone(flows); }, send: async () => ({}),
+ reads: { business: async () => ({ business: { id: 'acme', projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) },
+ bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
+ chat: { projects: async () => [], threads: async () => [] },
+ }),
+ },
+ });
+ t.after(() => close(server));
+ const b = await browser({ machine: false, args: ['--disable-extensions'] });
+ t.after(() => b.close());
+ await b.viewport(1440, 900);
+ await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/organization`);
+ // poll: wait for the page's own 10-second poll. Otherwise send the event a write on the page sends,
+ // which repaints /organization the same way.
+ const refresh = async ({ poll = false } = {}) => {
+ const n = counts.workflows;
+ if (!poll) await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-org-changed',{detail:{org:'acme'}}));true`);
+ for (let i = 0; i < 500 && counts.workflows === n; i++) await new Promise(r => setTimeout(r, 30));
+ assert.ok(counts.workflows > n, 'the refresh read the workflows again');
+ await b.evaluate(`new Promise(r=>setTimeout(()=>r(true),200))`);
+ };
+ return { b, counts, ctl, refresh };
+}
+
+test('E2E6 P1-1: an open workflow editor keeps its node, draft and focus across a refresh', { timeout: 60000 }, async t => {
+ const { b, refresh } = await fixture(t);
+ await waitFor(b, `document.querySelector('[data-wf="intake"] [data-wf-edit]')`, 'workflow list');
+ await b.evaluate(`document.querySelector('[data-wf="intake"] [data-wf-edit]').click();true`);
+ await waitFor(b, `document.querySelector('[data-wf-editor]')`, 'editor');
+ await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');window.__editor=f;const i=f.querySelector('input[name=title]');i.value='Intake, revised';i.focus();i.setSelectionRange(3,3);f.querySelector('[data-wf-edit-step="0"] [name=roles]').value='human, coder'}true`);
+ await refresh({ poll: true });
+ assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')===window.__editor`), true, 'the same editor node');
+ assert.equal(await b.evaluate(`document.querySelectorAll('[data-wf-editor]').length`), 1);
+ assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), 'Intake, revised');
+ assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] [data-wf-edit-step="0"] [name=roles]').value`), 'human, coder');
+ assert.equal(await b.evaluate(`document.activeElement===document.querySelector('[data-wf-editor] input[name=title]')`), true, 'focus stays in the field');
+ assert.equal(await b.evaluate(`document.activeElement.selectionStart`), 3, 'the caret stays put');
+ // A second refresh with focus elsewhere still keeps the open editor: it holds data-live-hold.
+ await b.evaluate(`document.activeElement.blur();true`);
+ await refresh();
+ assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')===window.__editor`), true);
+ assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), 'Intake, revised');
+ // Cancel closes it; the next refresh paints the list fresh.
+ await b.evaluate(`document.querySelector('[data-wf-cancel]').click();true`);
+ await waitFor(b, `!document.querySelector('[data-wf-editor]')`, 'cancel');
+ await refresh();
+ assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')`), null);
+});
+
+test('E2E6 P1-1, P2-14: an unsaved autonomy choice, a picked pack project and a focused link survive the poll', { timeout: 60000 }, async t => {
+ const { b, refresh } = await fixture(t);
+ await waitFor(b, `document.querySelector('[data-autonomy-form] select[name=merge]')&&document.querySelector('[data-pack-project]')`, 'autonomy and packs');
+ await b.evaluate(`{const s=document.querySelector('[data-autonomy-form] select[name=merge]');s.value='in the loop';s.dispatchEvent(new Event('change',{bubbles:true}));window.__auto=document.querySelector('[data-autonomy-form]')}true`);
+ await b.evaluate(`{const s=document.querySelector('[data-pack-project]');s.value='docs';s.dispatchEvent(new Event('change',{bubbles:true}));s.blur()}true`);
+ // P2-14: a visible Open link outside any editor keeps focus through restore(). (The tester's "Open
+ // Inbox →" is the legacy dashboard card outside #bus-view, hidden on this page, so it never took focus.)
+ assert.equal(await b.evaluate(`document.querySelector('[data-focus-key="set:Identity"]').focus();document.activeElement.dataset.focusKey`), 'set:Identity');
+ await refresh({ poll: true });
+ assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), 'set:Identity', 'focus stays on the link');
+ assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]')===window.__auto`), true, 'the dirty form is kept');
+ assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`), 'in the loop');
+ assert.equal(await b.evaluate(`document.querySelector('[data-pack-project]').value`), 'docs', 'the picked project is kept');
+});
+
+test('E2E6 P2-2: a pack apply re-reads the other sections and keeps its receipt', { timeout: 60000 }, async t => {
+ const { b, counts } = await fixture(t);
+ await waitFor(b, `document.querySelector('[data-pack="web-project"] [data-pack-preview-button]')`, 'pack list');
+ await b.evaluate(`{const s=document.querySelector('[data-pack-project]');s.value='docs';s.dispatchEvent(new Event('change',{bubbles:true}))}true`);
+ await b.evaluate(`document.querySelector('[data-pack-preview-button]').click();true`);
+ await waitFor(b, `document.querySelector('[data-pack-apply]')`, 'preview');
+ const before = { ...counts };
+ await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`);
+ await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]')?.textContent??'')`, 'apply');
+ for (let i = 0; i < 300 && counts.workflows === before.workflows; i++) await new Promise(r => setTimeout(r, 30));
+ assert.ok(counts.workflows > before.workflows, 'workflows read again after the apply');
+ assert.ok(counts.autonomy > before.autonomy, 'autonomy read again after the apply');
+ await b.evaluate(`new Promise(r=>setTimeout(()=>r(true),300))`);
+ assert.match(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), /^Applied\. Added board\./, 'the receipt survives the re-read');
+});
+
+test('E2E6 review P3: a failed read keeps an open editor, Discard drops a held form, a focused button does not hold its section, and a clean form shows a failed read', { timeout: 60000 }, async t => {
+ const { b, ctl, refresh } = await fixture(t);
+ await waitFor(b, `document.querySelector('[data-wf="intake"] [data-wf-edit]')&&document.querySelector('[data-autonomy-form] select[name=merge]')&&document.querySelector('[data-pack-preview-button]')`, 'sections');
+ // A focused Preview button alone no longer holds the packs section: the next poll shows a changed
+ // pack and focus comes back to the rebuilt button through its data-focus-key.
+ await b.evaluate(`document.querySelector('[data-pack-preview-button]').focus();true`);
+ ctl.packLabel = 'Web project, renamed';
+ await refresh({ poll: true });
+ await waitFor(b, `/Web project, renamed/.test(document.querySelector('[data-packs-org]').textContent)`, 'the renamed pack');
+ assert.match(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), /^pack-preview:acme:web-project$/, 'focus returns to the rebuilt button');
+ // One failed workflows read while the editor is open keeps the same editor node, draft and focus.
+ await b.evaluate(`document.querySelector('[data-wf="intake"] [data-wf-edit]').click();true`);
+ await waitFor(b, `document.querySelector('[data-wf-editor]')`, 'editor');
+ await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');window.__editor=f;const i=f.querySelector('input[name=title]');i.value='Typed during an outage';i.focus()}true`);
+ // A dirty autonomy form survives a failed autonomy read the same way.
+ await b.evaluate(`{const s=document.querySelector('[data-autonomy-form] select[name=merge]');s.value='in the loop';s.dispatchEvent(new Event('change',{bubbles:true}));window.__auto=document.querySelector('[data-autonomy-form]')}true`);
+ await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').focus();true`);
+ ctl.workflowsFail = true; ctl.autonomyFail = true;
+ await refresh({ poll: true });
+ assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')===window.__editor`), true, 'the same editor node after a failed read');
+ assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), 'Typed during an outage');
+ assert.equal(await b.evaluate(`document.activeElement===document.querySelector('[data-wf-editor] input[name=title]')`), true, 'focus stays in the field');
+ assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]')===window.__auto`), true, 'the dirty autonomy form is kept after a failed read');
+ // Discard draws the last read in place of the held form.
+ ctl.workflowsFail = false; ctl.autonomyFail = false;
+ await b.evaluate(`document.querySelector('[data-autonomy-discard]').click();true`);
+ assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]')!==window.__auto`), true, 'a fresh form');
+ assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`), 'out of the loop', 'the stored value is back');
+ assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]').hasAttribute('data-live-dirty')`), false);
+ assert.equal(await b.evaluate(`document.activeElement.hasAttribute('data-autonomy-discard')`), true, 'focus stays on Discard');
+ // Re-check P3: a focused button in a clean form gets its focus back after the poll repaints it.
+ await refresh({ poll: true });
+ assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), 'autonomy-discard:acme', 'focus returns to Discard after a poll');
+ // Re-check P3: a clean form the page does not hold shows a failed read instead of hiding it.
+ await b.evaluate(`document.activeElement.blur();true`);
+ ctl.autonomyFail = true;
+ await refresh({ poll: true });
+ await waitFor(b, `!document.querySelector('[data-autonomy-form]')&&/bus-unreachable|could not|refused/i.test(document.querySelector('[data-autonomy]')?.textContent??'')`, 'the autonomy read error');
+ ctl.autonomyFail = false;
+ await refresh({ poll: true });
+ await waitFor(b, `document.querySelector('[data-autonomy-form] select[name=merge]')`, 'the form after a good read');
+});
diff --git a/packages/webui/tests/s8-header-browser.test.mjs b/packages/webui/tests/s8-header-browser.test.mjs
index 1a439f04d..f0eccd40b 100644
--- a/packages/webui/tests/s8-header-browser.test.mjs
+++ b/packages/webui/tests/s8-header-browser.test.mjs
@@ -26,13 +26,18 @@ async function accessibleName(b, selector) {
for (const width of [1440,390]) {
test(`S8 project action icons, accessible names and positions at ${width}`, async t => {
const {b,wait} = await setup(t,width);
- const expected = width === 1440 ? [[1060,1190],[1202,1290],[1302,1418]] : [[12,132],[140,220],[228,340]];
+ // E2E6 P2-11 (#1873): at 1440 the actions end 22px inside the content edge. A page tall enough to
+ // scroll has a scrollbar (sb wide), so the design's x values move left by its width on that route:
+ // 1418 was 100vw-based and ran under the scrollbar, which clipped Settings.
+ const design = width === 1440 ? [[1060,1190],[1202,1290],[1302,1418]] : [[12,132],[140,220],[228,340]];
const y = width === 1440 ? [130,174] : [198,242];
for (const route of ['', '/conversations', '/tasks', '/requirements', '/files', '/settings', ...(width === 1440 ? ['/room'] : [])]) {
if (route) {
await b.evaluate(`location.hash=${JSON.stringify(route === '/room' ? '#/projects/rooms/'+P : '#/projects/'+P+route)}`);
await wait(`document.querySelector('.project-header')?.dataset.projectSection===${JSON.stringify(route.slice(1))}`);
}
+ const sb = width === 1440 ? await b.evaluate('innerWidth-document.documentElement.clientWidth') : 0;
+ const expected = design.map(x => x.map(v => v - sb));
for (const [i,name] of ['North star','PRD','Settings'].entries()) {
const selector = `.project-header .project-primary-action:nth-of-type(${i+1})`;
const data = await b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)}),r=e.getBoundingClientRect();return {icon:!!e.querySelector('svg[aria-hidden="true"]'),left:r.left,right:r.right,top:r.top,bottom:r.bottom}})()`);
diff --git a/packages/webui/tests/s9-reflow-browser.test.mjs b/packages/webui/tests/s9-reflow-browser.test.mjs
index 12a5da605..6510e7f28 100644
--- a/packages/webui/tests/s9-reflow-browser.test.mjs
+++ b/packages/webui/tests/s9-reflow-browser.test.mjs
@@ -65,10 +65,13 @@ for (const zoom of [true,false]) test(`S9 project routes reflow at ${zoom ? 'act
});
for(const width of [1440,390]) test(`S9 Overview action and S8 header boxes at ${width}`,async t=>{
const {b}=await setup(t,width);
- const data=await b.evaluate(`(()=>{const rect=e=>{const r=e.getBoundingClientRect();return [r.left,r.top,r.right,r.bottom]};return {button:rect(document.querySelector('[data-project-content] .project-section-title .btn')),section:rect(document.querySelector('.project-columns > section')),headers:[...document.querySelectorAll('.project-primary-action')].map(rect)}})()`);
+ const data=await b.evaluate(`(()=>{const rect=e=>{const r=e.getBoundingClientRect();return [r.left,r.top,r.right,r.bottom]};return {sb:innerWidth-document.documentElement.clientWidth,button:rect(document.querySelector('[data-project-content] .project-section-title .btn')),section:rect(document.querySelector('.project-columns > section')),headers:[...document.querySelectorAll('.project-primary-action')].map(rect)}})()`);
if(process.env.S9_CAPTURE) await b.screenshot(`${process.env.S9_CAPTURE}/${process.env.S9_PHASE}-overview-${width}.png`);
if(process.env.S9_MEASURE) console.log('S9 geometry',width,JSON.stringify(data));
- const expected=width===1440?[[1060,130,1190,174],[1202,130,1290,174],[1302,130,1418,174]]:[[12,198,132,242],[140,198,220,242],[228,198,340,242]];
+ // E2E6 P2-11 (#1873): at 1440 the header actions end 22px inside the content edge, so the design's
+ // x values move left by the page scrollbar when the page scrolls (1418 was 100vw-based and clipped Settings).
+ const sb=width===1440?data.sb:0;
+ const expected=(width===1440?[[1060,130,1190,174],[1202,130,1290,174],[1302,130,1418,174]]:[[12,198,132,242],[140,198,220,242],[228,198,340,242]]).map(([x0,y0,x1,y1])=>[x0-sb,y0,x1-sb,y1]);
for(let i=0;i<3;i++) for(let j=0;j<4;j++) assert.ok(Math.abs(data.headers[i][j]-expected[i][j])<=4,`header ${i}: ${JSON.stringify(data.headers[i])}`);
// The accepted shell reserves a scrollbar and narrows the content; the
// action must align to its section when that puts the target right edge out of reach.