fix(webui): polish room and conversation layouts
This commit is contained in:
1 parent
7c664fac91
commit
a584c08ade
4 files changed
+138
-7
No files matched your search
@@ -287,8 +287,8 @@
|
||||
}).join('');
|
||||
const off = !c.roomReady || c.channelDisabled || s.pending || !roomOwner();
|
||||
const redesigned = p && window.MosaicProjects && roomOwner() && (!legacyRoute() || current.path?.startsWith('/projects'));
|
||||
const frame = redesigned ? window.MosaicProjects.header(p, 'room', c.threads) + `<div class="project-section-title"><h2>Project room</h2><button class="btn" type="button" data-project-dialog="room" aria-label="Room details"><span class="project-action-label">Room details</span><span class="project-action-icon" aria-hidden="true">ⓘ</span></button></div><dialog class="project-dialog" id="project-room-dialog" aria-labelledby="project-room-title"><h2 id="project-room-title" tabindex="-1">Room details</h2><button class="btn" type="button" data-dialog-close>Close</button>${roomOpenControl(c.id)}<p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p></dialog>` : heading((p?.name ?? 'Project') + ' room') + roomOpenControl(c.id);
|
||||
return `<section class="chat-room${redesigned ? ' project-chat' : ''}" data-project="${esc(c.id)}"><p>${link('projects', c.id, p?.name ?? 'Project')}</p>${frame}${redesigned && c.error === ROOM_LINES['room-not-open'] ? `<div class="project-room-warning" role="status">${txt(c.error)} <button type="button" class="btn" data-room-open-inline>Open room</button></div>` : ''}${libraryLine(libraryOf(c))}<p>Messages are copied to the members you choose. Each reply stays in that member's conversation.</p><p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p><ol class="chat-log chat-room-log" tabindex="0" aria-label="Room conversation">${messages || (c.readFailed ? '<li>Messages unavailable.</li>' : '<li>No messages yet.</li>')}</ol><div class="chat-paging">${c.readFailed ? '' : s.more ? '<button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${c.projectArchived ? '<p>This project is archived; its room history stays readable.</p>' : `<form id="chat-room-send-form"><fieldset class="chat-room-picker"${off ? ' disabled' : ''}><legend>To: <span class="sr-only">Recipients</span></legend>${(c.members ?? []).map(m => redesigned ? `<input class="room-chip-input" type="checkbox" id="chat-member-${esc(m.instance)}" data-room-member="${esc(m.instance)}" tabindex="-1" aria-hidden="true"><button type="button" class="room-chip" data-room-chip="${esc(m.instance)}" aria-pressed="true">${txt(m.name)}</button>` : `<label><input type="checkbox" id="chat-member-${esc(m.instance)}" data-room-member="${esc(m.instance)}"> ${txt(m.name)} (@${txt(m.instance)})</label>`).join('')}</fieldset><label for="chat-room-body"${redesigned ? ' class="sr-only"' : ''}>Message the room</label><p id="chat-room-help"${redesigned ? ' class="sr-only"' : ''}>Choose contacts above, or use leading @tags. Without tags, the message goes to all room members. Mentions in the message do not add recipients. The broker checks every address.</p><textarea id="chat-room-body" rows="4" placeholder="Message the room" aria-describedby="chat-room-help" aria-autocomplete="list" aria-controls="chat-room-completion" aria-expanded="false"${disabled(off)}>${esc(s.draft)}</textarea>${redesigned ? '<p class="project-composer-hint">Use @names to choose recipients.</p>' : ''}<div id="chat-room-offer"></div><div id="chat-room-tags"></div><ul id="chat-room-completion" class="chat-room-completion" role="listbox" aria-label="Room members" hidden></ul><button class="btn primary" type="submit"${disabled(off)}>Send to room</button></form>`}<p id="chat-room-receipt" role="status">${txt(s.receipt)}</p></section>`;
|
||||
const frame = redesigned ? window.MosaicProjects.header(p, 'room', c.threads) + `<div class="project-section-title"><h2>Project room</h2><button class="btn" type="button" data-project-dialog="room" aria-label="Room details"><span class="project-action-icon" aria-hidden="true"><svg class="i" viewBox="0 0 24 24"><circle cx="9" cy="8" r="3"/><path d="M3 20v-2a6 6 0 0 1 12 0v2M16 5a3 3 0 0 1 0 6m1 2a5 5 0 0 1 4 5v2"/></svg></span><span class="project-action-label">Room details</span></button></div><dialog class="project-dialog" id="project-room-dialog" aria-labelledby="project-room-title"><h2 id="project-room-title" tabindex="-1">Room details</h2><button class="btn" type="button" data-dialog-close>Close</button>${roomOpenControl(c.id)}<p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p></dialog>` : heading((p?.name ?? 'Project') + ' room') + roomOpenControl(c.id);
|
||||
return `<section class="chat-room${redesigned ? ' project-chat' : ''}" data-project="${esc(c.id)}"><p>${link('projects', c.id, p?.name ?? 'Project')}</p>${frame}${redesigned && c.error === ROOM_LINES['room-not-open'] ? `<div class="project-room-warning" role="status">${txt(c.error)} <button type="button" class="btn" data-room-open-inline>Open room</button></div>` : ''}${libraryLine(libraryOf(c))}<p>Messages are copied to the members you choose. Each reply stays in that member's conversation.</p><p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p><ol class="chat-log chat-room-log" tabindex="0" aria-label="Room conversation">${messages || (c.readFailed ? '<li>Messages unavailable.</li>' : '<li>No messages yet.</li>')}</ol><div class="chat-paging">${c.readFailed ? '' : s.more ? '<button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${c.projectArchived ? '<p>This project is archived; its room history stays readable.</p>' : `<form id="chat-room-send-form"><fieldset class="chat-room-picker"${off ? ' disabled' : ''}><legend>To: <span class="sr-only">Recipients</span></legend>${(c.members ?? []).map(m => redesigned ? `<input class="room-chip-input" type="checkbox" id="chat-member-${esc(m.instance)}" data-room-member="${esc(m.instance)}" tabindex="-1" aria-hidden="true"><button type="button" class="room-chip" data-room-chip="${esc(m.instance)}" title="${esc(m.name)}" aria-pressed="true">${txt(m.name)}</button>` : `<label><input type="checkbox" id="chat-member-${esc(m.instance)}" data-room-member="${esc(m.instance)}"> ${txt(m.name)} (@${txt(m.instance)})</label>`).join('')}</fieldset><label for="chat-room-body"${redesigned ? ' class="sr-only"' : ''}>Message the room</label><p id="chat-room-help"${redesigned ? ' class="sr-only"' : ''}>Choose contacts above, or use leading @tags. Without tags, the message goes to all room members. Mentions in the message do not add recipients. The broker checks every address.</p><textarea id="chat-room-body" rows="4" placeholder="Message the room" aria-describedby="chat-room-help" aria-autocomplete="list" aria-controls="chat-room-completion" aria-expanded="false"${disabled(off)}>${esc(s.draft)}</textarea>${redesigned ? '<p class="project-composer-hint">Use @names to choose recipients.</p>' : ''}<div id="chat-room-offer"></div><div id="chat-room-tags"></div><ul id="chat-room-completion" class="chat-room-completion" role="listbox" aria-label="Room members" hidden></ul><button class="btn primary" type="submit"${disabled(off)}>Send to room</button></form>`}<p id="chat-room-receipt" role="status">${txt(s.receipt)}</p></section>`;
|
||||
}
|
||||
function drawRoomCompletion() {
|
||||
const list = document.getElementById('chat-room-completion'), field = document.getElementById('chat-room-body');
|
||||
@@ -445,7 +445,7 @@
|
||||
const disclosures = content ? [...content.querySelectorAll('details')].map((e,i)=>e.open?i:-1).filter(i=>i>=0) : [];
|
||||
// A repaint can replace a log while its own pin is waiting for layout.
|
||||
// Carry that intent only while the offset still matches the pending pin.
|
||||
return { field, dialog:dialog?.id, dialogValues, dialogFocus, toggles, projectFocus, disclosures, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log ? (pageFlow(log) ? document.scrollingElement.scrollTop : log.scrollTop) : undefined, tail: log ? transcriptAtTail(log) : true, settlingPin: !!log && !pageFlow(log) && (pinnedScrolls.get(log) === log.scrollTop || settlingPins.get(log) === log.scrollTop) };
|
||||
return { tabScroll: current.container.querySelector('.project-tabs')?.scrollLeft, field, dialog:dialog?.id, dialogValues, dialogFocus, toggles, projectFocus, disclosures, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log ? (pageFlow(log) ? document.scrollingElement.scrollTop : log.scrollTop) : undefined, tail: log ? transcriptAtTail(log) : true, settlingPin: !!log && !pageFlow(log) && (pinnedScrolls.get(log) === log.scrollTop || settlingPins.get(log) === log.scrollTop) };
|
||||
}
|
||||
let transcriptObserver = null, transcriptResize = null;
|
||||
function paint(navigated = false) {
|
||||
@@ -543,7 +543,11 @@
|
||||
} else dialog.querySelector('h2')?.focus({preventScroll:true});
|
||||
}
|
||||
}
|
||||
if (navigated) c.container.querySelector('.project-tabs [aria-current]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
const tabs = c.container.querySelector('.project-tabs');
|
||||
if (tabs) {
|
||||
if (!navigated && keep?.tabScroll !== undefined) tabs.scrollLeft = keep.tabScroll;
|
||||
else tabs.querySelector('[aria-current]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
const log = c.container.querySelector('.chat-log');
|
||||
if (log) {
|
||||
const s = state(c.key ?? c.id);
|
||||
|
||||
@@ -415,7 +415,7 @@ body:has(.chat-area .project-header):has(.project-chat) .breadcrumbs { margin-bo
|
||||
.chat-room.project-chat > .project-header { grid-column:1 / -1; grid-row:1; margin-bottom:0; }
|
||||
.chat-room.project-chat > .project-tabs { grid-column:1 / -1; grid-row:2; }
|
||||
.chat-room.project-chat > .project-section-title { grid-column:1 / -1; grid-row:3; }
|
||||
.chat-room.project-chat .project-section-title [data-project-dialog="room"] { width:128px; }
|
||||
.chat-room.project-chat .project-section-title [data-project-dialog="room"] { width:128px; box-sizing:border-box; padding-inline:8px; white-space:nowrap; }
|
||||
/* Full member names stay in Room details; the summary keeps one line, left of the Room details button. */
|
||||
.chat-room.project-chat > p:nth-of-type(3) { grid-column:2; grid-row:3; justify-self:end; margin:0 140px 0 12px; min-width:0; max-width:calc(100% - 152px); box-sizing:border-box; align-self:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.chat-room.project-chat > .project-room-warning { grid-row:4; padding:4px 12px; }
|
||||
@@ -470,3 +470,23 @@ body:has(.chat-area .project-header):has(.project-chat) .breadcrumbs { margin-bo
|
||||
.chat-room.project-chat:has(> .project-room-warning) { grid-template-rows:minmax(44px,auto) minmax(44px,auto) auto auto auto minmax(72px,1fr) auto auto auto; }
|
||||
.chat-room.project-chat:has(> .project-room-warning) > .chat-log { min-height:64px; }
|
||||
}
|
||||
|
||||
/* Keep the active section and message metadata legible at narrow tab zoom. */
|
||||
@media (max-width:319px) {
|
||||
.project-tabs { mask-image:none; }
|
||||
.project-chat .chat-message-meta { flex-wrap:wrap; overflow-wrap:normal; word-break:normal; }
|
||||
.project-chat .chat-message-meta strong,.project-chat .chat-message-meta time { flex:none; white-space:nowrap; }
|
||||
}
|
||||
/* Project names may shrink, while their full text remains in the link. */
|
||||
@media (min-width:320px) and (min-height:541px) {
|
||||
.breadcrumbs:has(.project-crumb-name) { flex-wrap:nowrap; }
|
||||
.breadcrumbs .project-crumb-name { flex:0 1 auto; min-width:0; }
|
||||
.breadcrumbs .project-crumb-name span { display:block; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
||||
.breadcrumbs:has(.project-crumb-name) > :not(.project-crumb-name) { flex:none; }
|
||||
}
|
||||
.project-chat .project-section-title [data-project-dialog="room"] .project-action-icon { display:inline-flex; }
|
||||
@media (min-width:700px) and (min-height:761px), (min-width:960px) and (min-height:760px) {
|
||||
.chat-room.project-chat .project-section-title { transform:translateY(11px); }
|
||||
.chat-room.project-chat .project-section-title h2 { line-height:30px; transform:translateY(2.64px); }
|
||||
.chat-room.project-chat .project-section-title [data-project-dialog="room"] { width:148px; box-sizing:border-box; }
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
const id=encodeURIComponent(project.id);
|
||||
const tabs=sections.map(([key,label]) => `<a href="${path(id,key)}"${section === key ? ' aria-current="page"' : ''}>${label}${key==='conversations'?` <span class="project-count">${threads.filter(t=>!t.shared && t.project===project.id).length}</span>`:''}</a>`).join('');
|
||||
const count=threads.filter(t=>!t.shared && t.project===project.id).length;
|
||||
return `<header class="project-header" data-project-id="${id}" data-project-name="${clean(project.name)}" data-project-section="${section}" data-project-conversation="${conversation}"><div class="project-heading"><span class="project-header-icon" aria-hidden="true">${conversation?chatIcon:folder}</span><div><h1 tabindex="-1">${clean(title)}</h1>${project.archived?'<span class="project-chip">Archived</span>':''}<p>${conversation ? 'With <span data-project-agent>assistant</span>' : (project.repo ? `Repository: ${clean(project.repo)}` : 'No linked repository') + ` · ${count} ${count===1?'conversation':'conversations'}`}<span data-project-context></span></p></div></div><div class="project-actions">${conversation?'<button type="button" class="btn" data-project-dialog="conversation" aria-label="Details"><span class="project-action-label">Details</span><span class="project-action-icon" aria-hidden="true"><svg class="i" viewBox="0 0 24 24"><path d="M4 7h9m4 0h3M4 17h3m4 0h9M13 4v6M7 14v6"/></svg></span></button>':`<a class="btn project-primary-action" href="${path(id,'requirements')}?section=north-star">${starIcon}North star</a><a class="btn project-primary-action" href="${path(id,'requirements')}?section=prd">${documentIcon}PRD</a><a class="btn project-primary-action" href="#/projects/${id}/settings">${settingsIcon}Settings</a>`}</div></header><nav class="project-tabs" aria-label="Project sections">${tabs}</nav>`;
|
||||
return `<header class="project-header" data-project-id="${id}" data-project-name="${clean(project.name)}" data-project-section="${section}" data-project-conversation="${conversation}"><div class="project-heading"><span class="project-header-icon" aria-hidden="true">${conversation?chatIcon:folder}</span><div><h1 tabindex="-1">${clean(title)}</h1>${project.archived?'<span class="project-chip">Archived</span>':''}<p>${conversation ? 'With <span data-project-agent>assistant</span>' : (project.repo ? `Repository: ${clean(project.repo)}` : 'No linked repository') + ` · ${count} ${count===1?'conversation':'conversations'}`}<span data-project-context></span></p></div></div><div class="project-actions">${conversation?'<button type="button" class="btn" data-project-dialog="conversation" aria-label="Details"><span class="project-action-icon" aria-hidden="true"><svg class="i" viewBox="0 0 24 24"><path d="M4 7h9m4 0h3M4 17h3m4 0h9M13 4v6M7 14v6"/></svg></span><span class="project-action-label">Details</span></button>':`<a class="btn project-primary-action" href="${path(id,'requirements')}?section=north-star">${starIcon}North star</a><a class="btn project-primary-action" href="${path(id,'requirements')}?section=prd">${documentIcon}PRD</a><a class="btn project-primary-action" href="#/projects/${id}/settings">${settingsIcon}Settings</a>`}</div></header><nav class="project-tabs" aria-label="Project sections">${tabs}</nav>`;
|
||||
}
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const storageKey='mosaic-project-tree';
|
||||
@@ -267,7 +267,7 @@
|
||||
const id=h.dataset.projectId, name=h.dataset.projectName, section=h.dataset.projectSection, conversation=h.dataset.projectConversation==='true';
|
||||
const title=sections.find(([key])=>key===section)?.[1] || (section==='settings'?'Settings':'Overview');
|
||||
const leaf=conversation?view.querySelector('h1')?.textContent:title;
|
||||
box.innerHTML=`<a href="#/projects">Projects</a><span aria-hidden="true">/</span><a href="#/projects/${encodeURIComponent(id)}">${clean(name)}</a>${conversation?`<span aria-hidden="true">/</span><a href="${path(id,'conversations')}">Conversations</a>`:''}<span class="project-crumb-current"><span aria-hidden="true">/</span> <span aria-current="page">${clean(leaf)}</span></span>`;
|
||||
box.innerHTML=`<a href="#/projects">Projects</a><span aria-hidden="true">/</span><a class="project-crumb-name" href="#/projects/${encodeURIComponent(id)}" title="${clean(name)}"><span>${clean(name)}</span></a>${conversation?`<span aria-hidden="true">/</span><a href="${path(id,'conversations')}">Conversations</a>`:''}<span class="project-crumb-current"><span aria-hidden="true">/</span> <span aria-current="page">${clean(leaf)}</span></span>`;
|
||||
return true;
|
||||
}
|
||||
window.MosaicProjects = Object.freeze({ header, path, clean, mountContent, enhanceList, enhanceSurface, enrichRequest, breadcrumb, requestIntro, palette });
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, writeFile, chmod, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fixture } from './fixture.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { P, T, stub } from './helpers/projects-review-fixture.mjs';
|
||||
|
||||
// Use Chromium tab zoom, not CSS zoom or device-scale emulation. The profile's
|
||||
// default zoom and extension both set the same 200% level for fixture pages.
|
||||
async function zoomLauncher() {
|
||||
const dir = await mkdtemp(join(tmpdir(), 's9-zoom-'));
|
||||
const ext = join(dir, 'extension');
|
||||
await mkdir(ext);
|
||||
await writeFile(join(ext, 'manifest.json'), JSON.stringify({manifest_version:3,name:'S9 fixture zoom',version:'1.0',permissions:['tabs'],background:{service_worker:'zoom.js'}}));
|
||||
await writeFile(join(ext, 'zoom.js'), "chrome.tabs.onUpdated.addListener((id,change,tab)=>{if(tab.url?.startsWith('http://127.0.0.1:')) chrome.tabs.setZoom(id,2)});\n");
|
||||
const launcher = join(dir, 'chromium');
|
||||
await writeFile(launcher, `#!/usr/bin/env python3\nimport json, math, os, pathlib, sys\np = pathlib.Path(next(a.split('=',1)[1] for a in sys.argv[1:] if a.startswith('--user-data-dir='))) / 'Default'\np.mkdir(exist_ok=True)\n(p / 'Preferences').write_text(json.dumps({'partition': {'default_zoom_level': math.log(2)/math.log(1.2)}}))\nos.execv('/usr/bin/chromium', ['/usr/bin/chromium', '--disable-extensions-except=${ext}', '--load-extension=${ext}', *sys.argv[1:]])\n`);
|
||||
await chmod(launcher, 0o755);
|
||||
return {dir,launcher};
|
||||
}
|
||||
async function setup(t, width, zoom = false, source = stub, height = width===390?900:1000) {
|
||||
const f = await fixture();
|
||||
const previous = process.env.CHROMIUM;
|
||||
const z = zoom ? await zoomLauncher() : null;
|
||||
if (z) process.env.CHROMIUM = z.launcher;
|
||||
let b;
|
||||
try { b = await browser({machine:false}); } finally { if (previous === undefined) delete process.env.CHROMIUM; else process.env.CHROMIUM = previous; }
|
||||
t.after(async () => { await b.close(); await f.close(); if(z) await rm(z.dir,{recursive:true,force:true}); });
|
||||
await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]});
|
||||
b.on('Fetch.requestPaused',p=>void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{}));
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source});
|
||||
await b.viewport(width,height);
|
||||
await b.navigate(f.base+'/#/projects/'+P);
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return;await new Promise(r=>setTimeout(r,30))}throw Error('Timeout '+${JSON.stringify(expr)})})()`);
|
||||
if(zoom) await wait(`devicePixelRatio===2 && innerWidth===${width / 2}`);
|
||||
await wait("document.querySelector('[data-project-content] [data-project-room-status]')");
|
||||
return {b,wait};
|
||||
}
|
||||
|
||||
const shotDir='/mnt/storage/scratch/mosaic-lead/uidev/s15-shots';
|
||||
const settle=b=>b.evaluate('(async()=>{for(let i=0;i<12;i++)await new Promise(requestAnimationFrame)})()');
|
||||
async function open(t,kind,width=1440,height=1000,zoom=false,source=stub){
|
||||
const {b,wait}=await setup(t,width,zoom,source,height);
|
||||
await b.evaluate(`location.hash='#/projects/${kind==='room'?'rooms/'+P:'threads/'+T}'`);
|
||||
await wait("document.querySelector('.project-chat .chat-log > li')"); await settle(b);
|
||||
return {b,wait};
|
||||
}
|
||||
async function shot(b,name){if(process.env.S15_PHASE) await b.screenshot(`${shotDir}/${process.env.S15_PHASE}-${name}.png`);}
|
||||
const rect=`e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom}}`;
|
||||
|
||||
test('S15 Room details has the specified tall desktop geometry and icon',async t=>{
|
||||
const {b}=await open(t,'room'); await shot(b,'1-room');
|
||||
const m=await b.evaluate(`(()=>{const r=${rect},b=document.querySelector('[data-project-dialog=room]'),h=document.querySelector('.project-section-title h2');return {button:r(b),heading:r(h),icon:!!b.querySelector('svg'),name:b.getAttribute('aria-label')}})()`); console.log('S15 item1',JSON.stringify(m));
|
||||
assert.equal(m.icon,true); assert.equal(m.name,'Room details'); assert.ok(Math.abs(m.button.width-148)<1&&Math.abs(m.button.right-1418)<1&&Math.abs(m.button.y-262)<2&&m.button.height===44,JSON.stringify(m)); assert.ok(Math.abs(m.heading.y-268)<1 && Math.abs(m.heading.bottom-298)<1,JSON.stringify(m));
|
||||
for(const height of [700,600]){const x=await open(t,'room',1440,height); const v=await x.b.evaluate(`(()=>{const b=document.querySelector('[data-project-dialog=room]'),r=b.getBoundingClientRect();return {width:r.width,height:r.height,icon:!!b.querySelector('svg'),name:b.getAttribute('aria-label')}})()`); assert.ok(v.icon&&v.name==='Room details'&&v.height===44&&Math.abs(v.width-(height===700?128:44))<1,JSON.stringify(v));}
|
||||
});
|
||||
|
||||
test('S15 Conversation Details icon precedes its label',async t=>{
|
||||
const {b}=await open(t,'conversation'); await shot(b,'2-conversation');
|
||||
const m=await b.evaluate(`(()=>{const r=${rect},b=document.querySelector('[data-project-dialog=conversation]');return {button:r(b),icon:r(b.querySelector('svg')),label:r(b.querySelector('.project-action-label'))}})()`); console.log('S15 item2',JSON.stringify(m));
|
||||
assert.ok(m.icon.right<=m.label.x && m.button.height===44&&Math.abs(m.button.right-1418)<1,JSON.stringify(m));
|
||||
});
|
||||
|
||||
test('S15 narrow zoom tabs remain whole and reachable',async t=>{
|
||||
const {b}=await open(t,'room',390,900,true); await b.evaluate('scrollTo(0,0)'); await settle(b); await shot(b,'3-tabs-zoom');
|
||||
const m=await b.evaluate(`(()=>{const nav=document.querySelector('.project-tabs'),active=nav.querySelector('[aria-current]'),r=${rect};return {dpr:devicePixelRatio,innerWidth,client:document.documentElement.clientWidth,docWidth:document.documentElement.scrollWidth,nav:r(nav),active:r(active),scrollLeft:nav.scrollLeft,mask:getComputedStyle(nav).maskImage}})()`); console.log('S15 item3',JSON.stringify(m));
|
||||
assert.equal(m.dpr,2); assert.equal(m.innerWidth,195); assert.equal(m.docWidth,m.client); assert.equal(m.mask,'none'); assert.ok(m.active.x>=m.nav.x-1&&m.active.right<=m.nav.right+1,JSON.stringify(m));
|
||||
const all=await b.evaluate(`(async()=>{const nav=document.querySelector('.project-tabs'),result=[];for(const a of nav.querySelectorAll('a')){a.scrollIntoView({block:'nearest',inline:'nearest'});await new Promise(requestAnimationFrame);const n=nav.getBoundingClientRect(),r=a.getBoundingClientRect();result.push({name:a.textContent,left:r.left,right:r.right,nleft:n.left,nright:n.right})}return result})()`); assert.ok(all.every(x=>x.left>=x.nleft-1&&x.right<=x.nright+1),JSON.stringify(all));
|
||||
});
|
||||
|
||||
test('S15 narrow zoom message metadata keeps words and time intact',async t=>{
|
||||
const source=stub+`;fixtureRoom[0].to=['assistant'];`;
|
||||
const {b}=await open(t,'room',390,900,true,source); await b.evaluate("document.querySelector('.chat-message-human').scrollIntoView({block:'center'})"); await settle(b); await shot(b,'4-meta-zoom');
|
||||
const m=await b.evaluate(`(()=>{const meta=document.querySelector('.chat-message-human .chat-message-meta'),walk=document.createTreeWalker(meta,NodeFilter.SHOW_TEXT),words=[];let n;while(n=walk.nextNode()){for(const match of n.textContent.matchAll(/[A-Za-z]+|\\d+:\\d+/g)){const r=document.createRange();r.setStart(n,match.index);r.setEnd(n,match.index+match[0].length);words.push({word:match[0],rects:r.getClientRects().length})}}const time=meta.querySelector('time');return {dpr:devicePixelRatio,innerWidth,words,timeRects:(()=>{const r=document.createRange();r.selectNodeContents(time);return r.getClientRects().length})()}})()`); console.log('S15 item4',JSON.stringify(m));
|
||||
assert.equal(m.dpr,2); assert.equal(m.innerWidth,195); assert.ok(m.words.some(x=>x.word==='assistant')); assert.ok(m.words.every(x=>x.rects===1)&&m.timeRects===1,JSON.stringify(m));
|
||||
});
|
||||
|
||||
test('S15 long project breadcrumb is single-line and preserves its name',async t=>{
|
||||
const title='A very long project title that must stay available to everyone while the window is short';
|
||||
const {b}=await open(t,'room',700,541,false,stub+`;fixtureProjects[0].name=${JSON.stringify(title)}`); await shot(b,'5-breadcrumb');
|
||||
const m=await b.evaluate(`(()=>{const r=${rect},box=document.querySelector('.breadcrumbs'),a=box.querySelectorAll('a')[1];return {height:document.documentElement.scrollHeight,viewport:innerHeight,box:r(box),link:r(a),title:a.title,text:a.textContent,scrollWidth:a.firstElementChild.scrollWidth,clientWidth:a.firstElementChild.clientWidth,overflow:getComputedStyle(a.firstElementChild).textOverflow}})()`); console.log('S15 item5',JSON.stringify(m));
|
||||
assert.equal(m.height,m.viewport); assert.equal(m.title,title); assert.equal(m.text,title); assert.equal(m.overflow,'ellipsis'); assert.ok(m.scrollWidth>m.clientWidth,JSON.stringify(m));
|
||||
});
|
||||
|
||||
test('S15 truncated room member chips expose full names on hover',async t=>{
|
||||
const name='A member with a much longer display name that cannot fit in the compact picker';
|
||||
const source=stub+`;{const old=fetch;window.fetch=(u,i)=>String(u)==='/api/chat/instances'?Promise.resolve(new Response(JSON.stringify({rows:[{instance:'assistant',name:${JSON.stringify(name)}},{instance:'reviewer',name:'reviewer'}]}),{headers:{'content-type':'application/json'}})):old(u,i)}`;
|
||||
const {b}=await open(t,'room',700,541,false,source); await shot(b,'6-chips');
|
||||
const m=await b.evaluate(`(()=>{const c=document.querySelector('[data-room-chip=assistant]');return {title:c.title,text:c.textContent,scroll:c.scrollWidth,client:c.clientWidth}})()`);console.log('S15 item6',JSON.stringify(m)); assert.ok(m.scroll>m.client,JSON.stringify(m)); assert.equal(m.title,name); assert.equal(m.text,name);
|
||||
});
|
||||
|
||||
test('S15 channel-disabled conversation follows message end instead of disabled composer',async t=>{
|
||||
const history=stub+`;for(let i=0;i<42;i++)fixtureMessages.push({id:'40000000-0000-4000-8000-'+String(i+10).padStart(12,'0'),from_role:'human',body:'History '+i+' '+('message text '.repeat(8)),at:'2026-11-06T10:26:00Z'});{const old=fetch;window.fetch=(u,i)=>String(u).includes('/messages')&&i?.method==='POST'?Promise.resolve(new Response(JSON.stringify({error:'channel-disabled',sent:false}),{status:409,headers:{'content-type':'application/json'}})):old(u,i)}`;
|
||||
const {b,wait}=await open(t,'conversation',390,900,true,history);
|
||||
await b.evaluate("document.querySelector('#chat-body').value='hello';document.querySelector('#chat-body').dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#chat-send-form').requestSubmit()");
|
||||
await wait("document.querySelector('#chat-receipt').textContent.includes('disabled')"); await settle(b);
|
||||
await b.evaluate("document.querySelector('#chat-new').click()"); await settle(b);
|
||||
const m=await b.evaluate(`(()=>{const l=document.querySelector('.chat-log'),f=document.querySelector('#chat-send-form'),last=l.lastElementChild.getBoundingClientRect(),form=f.getBoundingClientRect();return {dpr:devicePixelRatio,width:innerWidth,send:f.querySelector('button[type=submit]').disabled,lastBottom:last.bottom,formBottom:form.bottom,height:innerHeight}})()`); console.log('S15 fallback',JSON.stringify(m)); assert.equal(m.dpr,2); assert.equal(m.send,true); assert.ok(Math.abs(m.lastBottom-m.height)<2&&m.formBottom>m.height+20,JSON.stringify(m));
|
||||
});
|
||||
|
||||
test('S15 preserves the S13 compact Room control at 700x760',async t=>{
|
||||
const {b}=await open(t,'room',700,760);
|
||||
const m=await b.evaluate(`(()=>{const b=document.querySelector('[data-project-dialog=room]'),r=b.getBoundingClientRect();return {width:r.width,height:r.height,icon:!!b.querySelector('svg')}})()`);
|
||||
console.log('S15 boundary',JSON.stringify(m)); assert.equal(m.width,128); assert.equal(m.height,44); assert.equal(m.icon,true);
|
||||
});
|
||||
Reference in new issue
Block a user